From d4405027c73ed05d4c47f50ab1a74631bbe3d1d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20Zed=C3=A9n=20Yver=C3=A5s?= Date: Sat, 23 Nov 2024 16:31:13 +0100 Subject: [PATCH 1/2] feat(database): configure project and start prototyping This commit adds the database project as well as my current local state when it comes to tests and sql files, primarily relating to text translations. --- components/api/database/.gitignore | 210 ++++++++++++++ components/api/database/.idea/.gitignore | 8 + components/api/database/.idea/.name | 1 + components/api/database/.idea/dataSources.xml | 17 ++ components/api/database/.idea/detekt.xml | 7 + components/api/database/.idea/icon.svg | 101 +++++++ components/api/database/.idea/kotlinc.xml | 6 + components/api/database/.idea/ktfmt.xml | 7 + components/api/database/.idea/misc.xml | 10 + .../runConfigurations/All_Database_Tests.xml | 24 ++ .../runConfigurations/_dev__Database.xml | 22 ++ .../api/database/.idea/sqlDataSources.xml | 44 +++ components/api/database/.idea/sqldialects.xml | 6 + components/api/database/.idea/vcs.xml | 15 + components/api/database/Dockerfile | 45 +++ components/api/database/README.md | 45 +++ .../api/database/ddl/dansdata/dansdata.sql | 1 + .../dansdata/events_public/events_public.sql | 5 + .../events_public/notify_entity_deleted.sql | 9 + .../translations_private/ext_refs.sql | 20 ++ .../get_or_create_ext_ref.sql | 24 ++ .../translations_private/languages.sql | 22 ++ .../translations_private/metadatas.sql | 41 +++ .../translations_private.sql | 3 + .../dansdata/translations_private/values.sql | 24 ++ .../allocate_translation.sql | 15 + .../translations_public/drop_ext_ref.sql | 14 + .../translations_public/drop_translation.sql | 14 + .../translations_public/set_translation.sql | 58 ++++ .../set_translation_override.sql | 34 +++ .../translations_public/translations.sql | 17 ++ .../translations_public.sql | 5 + .../10_create_system_roles.sql | 32 +++ .../12_create_db_owner_user.sh | 28 ++ .../13_create_app_backstage_user.sh | 30 ++ .../20_create_application_database.sh | 37 +++ .../99_initial_migrate.sh | 16 ++ .../templates/create_database.sql | 9 + .../templates/create_extensions.sql | 13 + .../templates/create_user.sql | 5 + components/api/database/migrate.sh | 21 ++ components/api/database/seed.sql | 85 ++++++ components/api/database/tests/.editorconfig | 94 +++++++ components/api/database/tests/.gitignore | 263 ++++++++++++++++++ .../api/database/tests/build.gradle.kts | 59 ++++ components/api/database/tests/detekt.yml | 7 + .../api/database/tests/gradle.properties | 1 + .../tests/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 60756 bytes .../gradle/wrapper/gradle-wrapper.properties | 6 + components/api/database/tests/gradlew | 234 ++++++++++++++++ components/api/database/tests/gradlew.bat | 89 ++++++ .../api/database/tests/settings.gradle.kts | 5 + .../dansdata/database/DansdataDbContainer.kt | 92 ++++++ .../kotlin/se/dansdata/database/DbRole.kt | 8 + .../kotlin/se/dansdata/database/DbSchema.kt | 7 + .../kotlin/se/dansdata/database/DbUser.kt | 9 + .../database/strikt/DataFrameAssertions.kt | 89 ++++++ .../database/strikt/UuidAssertions.kt | 17 ++ .../database/tests/AbstractSchemaTests.kt | 101 +++++++ .../dansdata/database/tests/ContainerTests.kt | 29 ++ .../dansdata/database/tests/ExtRefFacade.kt | 11 + .../se/dansdata/database/tests/Facade.kt | 18 ++ .../events_public/EventsPublicSchemaTests.kt | 6 + .../TranslationsPrivateSchemaTests.kt | 6 + .../tests/translations/_public/Facade.kt | 25 ++ .../_public/ProcAllocateTranslationTests.kt | 61 ++++ .../_public/TranslationsPublicSchemaTests.kt | 6 + .../_public/TranslationsViewTests.kt | 68 +++++ .../test/resources/junit-platform.properties | 4 + 69 files changed, 2465 insertions(+) create mode 100644 components/api/database/.gitignore create mode 100644 components/api/database/.idea/.gitignore create mode 100644 components/api/database/.idea/.name create mode 100644 components/api/database/.idea/dataSources.xml create mode 100644 components/api/database/.idea/detekt.xml create mode 100644 components/api/database/.idea/icon.svg create mode 100644 components/api/database/.idea/kotlinc.xml create mode 100644 components/api/database/.idea/ktfmt.xml create mode 100644 components/api/database/.idea/misc.xml create mode 100644 components/api/database/.idea/runConfigurations/All_Database_Tests.xml create mode 100644 components/api/database/.idea/runConfigurations/_dev__Database.xml create mode 100644 components/api/database/.idea/sqlDataSources.xml create mode 100644 components/api/database/.idea/sqldialects.xml create mode 100644 components/api/database/.idea/vcs.xml create mode 100644 components/api/database/Dockerfile create mode 100644 components/api/database/README.md create mode 100644 components/api/database/ddl/dansdata/dansdata.sql create mode 100644 components/api/database/ddl/dansdata/events_public/events_public.sql create mode 100644 components/api/database/ddl/dansdata/events_public/notify_entity_deleted.sql create mode 100644 components/api/database/ddl/dansdata/translations_private/ext_refs.sql create mode 100644 components/api/database/ddl/dansdata/translations_private/get_or_create_ext_ref.sql create mode 100644 components/api/database/ddl/dansdata/translations_private/languages.sql create mode 100644 components/api/database/ddl/dansdata/translations_private/metadatas.sql create mode 100644 components/api/database/ddl/dansdata/translations_private/translations_private.sql create mode 100644 components/api/database/ddl/dansdata/translations_private/values.sql create mode 100644 components/api/database/ddl/dansdata/translations_public/allocate_translation.sql create mode 100644 components/api/database/ddl/dansdata/translations_public/drop_ext_ref.sql create mode 100644 components/api/database/ddl/dansdata/translations_public/drop_translation.sql create mode 100644 components/api/database/ddl/dansdata/translations_public/set_translation.sql create mode 100644 components/api/database/ddl/dansdata/translations_public/set_translation_override.sql create mode 100644 components/api/database/ddl/dansdata/translations_public/translations.sql create mode 100644 components/api/database/ddl/dansdata/translations_public/translations_public.sql create mode 100644 components/api/database/docker-entrypoint-initdb.d/10_create_system_roles.sql create mode 100644 components/api/database/docker-entrypoint-initdb.d/12_create_db_owner_user.sh create mode 100644 components/api/database/docker-entrypoint-initdb.d/13_create_app_backstage_user.sh create mode 100644 components/api/database/docker-entrypoint-initdb.d/20_create_application_database.sh create mode 100644 components/api/database/docker-entrypoint-initdb.d/99_initial_migrate.sh create mode 100644 components/api/database/docker-entrypoint-initdb.d/templates/create_database.sql create mode 100644 components/api/database/docker-entrypoint-initdb.d/templates/create_extensions.sql create mode 100644 components/api/database/docker-entrypoint-initdb.d/templates/create_user.sql create mode 100755 components/api/database/migrate.sh create mode 100644 components/api/database/seed.sql create mode 100644 components/api/database/tests/.editorconfig create mode 100644 components/api/database/tests/.gitignore create mode 100644 components/api/database/tests/build.gradle.kts create mode 100644 components/api/database/tests/detekt.yml create mode 100644 components/api/database/tests/gradle.properties create mode 100644 components/api/database/tests/gradle/wrapper/gradle-wrapper.jar create mode 100644 components/api/database/tests/gradle/wrapper/gradle-wrapper.properties create mode 100755 components/api/database/tests/gradlew create mode 100644 components/api/database/tests/gradlew.bat create mode 100644 components/api/database/tests/settings.gradle.kts create mode 100644 components/api/database/tests/src/test/kotlin/se/dansdata/database/DansdataDbContainer.kt create mode 100644 components/api/database/tests/src/test/kotlin/se/dansdata/database/DbRole.kt create mode 100644 components/api/database/tests/src/test/kotlin/se/dansdata/database/DbSchema.kt create mode 100644 components/api/database/tests/src/test/kotlin/se/dansdata/database/DbUser.kt create mode 100644 components/api/database/tests/src/test/kotlin/se/dansdata/database/strikt/DataFrameAssertions.kt create mode 100644 components/api/database/tests/src/test/kotlin/se/dansdata/database/strikt/UuidAssertions.kt create mode 100644 components/api/database/tests/src/test/kotlin/se/dansdata/database/tests/AbstractSchemaTests.kt create mode 100644 components/api/database/tests/src/test/kotlin/se/dansdata/database/tests/ContainerTests.kt create mode 100644 components/api/database/tests/src/test/kotlin/se/dansdata/database/tests/ExtRefFacade.kt create mode 100644 components/api/database/tests/src/test/kotlin/se/dansdata/database/tests/Facade.kt create mode 100644 components/api/database/tests/src/test/kotlin/se/dansdata/database/tests/events_public/EventsPublicSchemaTests.kt create mode 100644 components/api/database/tests/src/test/kotlin/se/dansdata/database/tests/translations/_private/TranslationsPrivateSchemaTests.kt create mode 100644 components/api/database/tests/src/test/kotlin/se/dansdata/database/tests/translations/_public/Facade.kt create mode 100644 components/api/database/tests/src/test/kotlin/se/dansdata/database/tests/translations/_public/ProcAllocateTranslationTests.kt create mode 100644 components/api/database/tests/src/test/kotlin/se/dansdata/database/tests/translations/_public/TranslationsPublicSchemaTests.kt create mode 100644 components/api/database/tests/src/test/kotlin/se/dansdata/database/tests/translations/_public/TranslationsViewTests.kt create mode 100644 components/api/database/tests/src/test/resources/junit-platform.properties diff --git a/components/api/database/.gitignore b/components/api/database/.gitignore new file mode 100644 index 0000000..1721572 --- /dev/null +++ b/components/api/database/.gitignore @@ -0,0 +1,210 @@ +# Created by https://www.toptal.com/developers/gitignore/api/linux,windows,macos,intellij,visualstudiocode +# Edit at https://www.toptal.com/developers/gitignore?templates=linux,windows,macos,intellij,visualstudiocode + +### Intellij ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +# .idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +.idea/artifacts +.idea/compiler.xml +.idea/jarRepositories.xml +.idea/modules.xml +.idea/*.iml +.idea/modules +*.iml +*.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### Intellij Patch ### +# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 + +# *.iml +# modules.xml +# .idea/misc.xml +# *.ipr + +# Sonarlint plugin +# https://plugins.jetbrains.com/plugin/7973-sonarlint +.idea/**/sonarlint/ + +# SonarQube Plugin +# https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin +.idea/**/sonarIssues.xml + +# Markdown Navigator plugin +# https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced +.idea/**/markdown-navigator.xml +.idea/**/markdown-navigator-enh.xml +.idea/**/markdown-navigator/ + +# Cache file creation bug +# See https://youtrack.jetbrains.com/issue/JBR-2257 +.idea/$CACHE_FILE$ + +# CodeStream plugin +# https://plugins.jetbrains.com/plugin/12206-codestream +.idea/codestream.xml + +# Azure Toolkit for IntelliJ plugin +# https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij +.idea/**/azureSettings.xml + +### Linux ### +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### macOS Patch ### +# iCloud generated files +*.icloud + +### VisualStudioCode ### +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +### VisualStudioCode Patch ### +# Ignore all local history of files +.history +.ionide + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# End of https://www.toptal.com/developers/gitignore/api/linux,windows,macos,intellij,visualstudiocode diff --git a/components/api/database/.idea/.gitignore b/components/api/database/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/components/api/database/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/components/api/database/.idea/.name b/components/api/database/.idea/.name new file mode 100644 index 0000000..3d49173 --- /dev/null +++ b/components/api/database/.idea/.name @@ -0,0 +1 @@ +Dansdata Database diff --git a/components/api/database/.idea/dataSources.xml b/components/api/database/.idea/dataSources.xml new file mode 100644 index 0000000..c189655 --- /dev/null +++ b/components/api/database/.idea/dataSources.xml @@ -0,0 +1,17 @@ + + + + + postgresql + true + org.postgresql.Driver + jdbc:postgresql://127.0.0.1:32768/dansdata + + + + + + $ProjectFileDir$ + + + \ No newline at end of file diff --git a/components/api/database/.idea/detekt.xml b/components/api/database/.idea/detekt.xml new file mode 100644 index 0000000..ee7289c --- /dev/null +++ b/components/api/database/.idea/detekt.xml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/components/api/database/.idea/icon.svg b/components/api/database/.idea/icon.svg new file mode 100644 index 0000000..6722bea --- /dev/null +++ b/components/api/database/.idea/icon.svg @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/components/api/database/.idea/kotlinc.xml b/components/api/database/.idea/kotlinc.xml new file mode 100644 index 0000000..d4b7acc --- /dev/null +++ b/components/api/database/.idea/kotlinc.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/components/api/database/.idea/ktfmt.xml b/components/api/database/.idea/ktfmt.xml new file mode 100644 index 0000000..1743a6d --- /dev/null +++ b/components/api/database/.idea/ktfmt.xml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/components/api/database/.idea/misc.xml b/components/api/database/.idea/misc.xml new file mode 100644 index 0000000..482c34e --- /dev/null +++ b/components/api/database/.idea/misc.xml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/components/api/database/.idea/runConfigurations/All_Database_Tests.xml b/components/api/database/.idea/runConfigurations/All_Database_Tests.xml new file mode 100644 index 0000000..506cd0f --- /dev/null +++ b/components/api/database/.idea/runConfigurations/All_Database_Tests.xml @@ -0,0 +1,24 @@ + + + + + + + false + true + false + true + + + \ No newline at end of file diff --git a/components/api/database/.idea/runConfigurations/_dev__Database.xml b/components/api/database/.idea/runConfigurations/_dev__Database.xml new file mode 100644 index 0000000..5deff27 --- /dev/null +++ b/components/api/database/.idea/runConfigurations/_dev__Database.xml @@ -0,0 +1,22 @@ + + + + + + + + + + \ No newline at end of file diff --git a/components/api/database/.idea/sqlDataSources.xml b/components/api/database/.idea/sqlDataSources.xml new file mode 100644 index 0000000..bb8eaa6 --- /dev/null +++ b/components/api/database/.idea/sqlDataSources.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + diff --git a/components/api/database/.idea/sqldialects.xml b/components/api/database/.idea/sqldialects.xml new file mode 100644 index 0000000..1ed273d --- /dev/null +++ b/components/api/database/.idea/sqldialects.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/components/api/database/.idea/vcs.xml b/components/api/database/.idea/vcs.xml new file mode 100644 index 0000000..f3aa348 --- /dev/null +++ b/components/api/database/.idea/vcs.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/components/api/database/Dockerfile b/components/api/database/Dockerfile new file mode 100644 index 0000000..3a8e8d0 --- /dev/null +++ b/components/api/database/Dockerfile @@ -0,0 +1,45 @@ +FROM debian:bookworm-slim as build-migration +WORKDIR /tmp/migrations + +COPY ./ddl /tmp/migrations/ddl +COPY ./seed.sql /tmp/migrations/ +COPY migrate.sh /tmp/migrations/ + +RUN ./migrate.sh + +FROM postgres:17-bookworm + +ENV DEBIAN_FRONTEND=noninteractive +ENV DBMS_OWNER_USER="postgres" +ENV DBMS_OWNER_PASSWORD="postgres-pwd" +ENV DB_OWNER_USER="dansdata" +ENV DB_OWNER_PASSWORD="dansdata-pwd" +ENV DB_APP_BACKSTAGE_USER="backstage" +ENV DB_APP_BACKSTAGE_PASSWORD="backstage-pwd" +ENV DB_NAME="dansdata" + +# Postgres variables +ENV POSTGRES_USER="$DBMS_OWNER_USER" +ENV POSTGRES_PASSWORD="$DBMS_OWNER_PASSWORD" +ENV POSTGRES_DB="postgres" + +RUN set -eux \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + wget \ + lsb-release \ + && echo "deb https://packagecloud.io/timescale/timescaledb/debian $(lsb_release -c -s) main" | tee /etc/apt/sources.list.d/timescaledb.list \ + && wget -qO - https://packagecloud.io/timescale/timescaledb/gpgkey | gpg --dearmor -o /etc/apt/trusted.gpg.d/timescaledb.gpg \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + postgresql-17-postgis-3 \ + timescaledb-2-postgresql-17 \ + && rm -rf /var/lib/apt/lists/* + +COPY --chmod=444 ./docker-entrypoint-initdb.d /docker-entrypoint-initdb.d +COPY --chmod=444 --from=build-migration /tmp/migrations/migrate_latest.sql /tmp/migrate_latest.sql +RUN chmod 555 /docker-entrypoint-initdb.d/*.sh && chmod 555 /docker-entrypoint-initdb.d/templates + +EXPOSE 5432 +CMD ["postgres", "-c", "shared_preload_libraries=timescaledb", "-c", "log_statement=all", "-c", "log_destination=stderr"] diff --git a/components/api/database/README.md b/components/api/database/README.md new file mode 100644 index 0000000..37e71fe --- /dev/null +++ b/components/api/database/README.md @@ -0,0 +1,45 @@ +

+ Database +

+ +

+ PostgreSQL 17 + editor: IntelliJ IDEA Ultimate + Contributors + MIT License + + +

+ Primary database for Dansdata's backend services. +

+ +## Migrations + +We have not yet decided on a migration tool. + +Please update [`migrate.sh`](migrate.sh) to generate necessary SQL files to migrate from a zero state. + +## Development Environment + +Open this directory in IntelliJ IDEA Ultimate. + +### Plugins + +These editor plugins should be installed for the smoothest developer experience. + +| Plugin | Motivation | url | +| ----------------------------------- | ------------------------------- | ------------------------------------------------------------------------------------------ | +| Database Tools and SQL for WebStorm | SQL language support | (bundled) | +| detekt | Static code analysis for Kotlin | | +| docker | Local testing | (bundled) | +| ktfmt | Kotlin code formatter | | + +## Running Tests + +_Docker must be installed on your system to run database tests!_ + +From the `tests` subdirectory, run + +```bash +./gradlew :check +``` diff --git a/components/api/database/ddl/dansdata/dansdata.sql b/components/api/database/ddl/dansdata/dansdata.sql new file mode 100644 index 0000000..876ee3e --- /dev/null +++ b/components/api/database/ddl/dansdata/dansdata.sql @@ -0,0 +1 @@ +CREATE DATABASE dansdata; diff --git a/components/api/database/ddl/dansdata/events_public/events_public.sql b/components/api/database/ddl/dansdata/events_public/events_public.sql new file mode 100644 index 0000000..f8a0c89 --- /dev/null +++ b/components/api/database/ddl/dansdata/events_public/events_public.sql @@ -0,0 +1,5 @@ +CREATE SCHEMA events_public; + +ALTER SCHEMA events_public OWNER TO dansdata; + +GRANT USAGE ON SCHEMA events_public TO PUBLIC; diff --git a/components/api/database/ddl/dansdata/events_public/notify_entity_deleted.sql b/components/api/database/ddl/dansdata/events_public/notify_entity_deleted.sql new file mode 100644 index 0000000..a7b0219 --- /dev/null +++ b/components/api/database/ddl/dansdata/events_public/notify_entity_deleted.sql @@ -0,0 +1,9 @@ +CREATE OR REPLACE PROCEDURE events_public.notify_entity_deleted(entity_uri TEXT) + LANGUAGE sql AS +$$ +SELECT pg_notify('dansdata_events_v1'::TEXT, JSON_BUILD_OBJECT('type', 'delete', 'entityUri', entity_uri)::TEXT); +$$; + +COMMENT ON PROCEDURE events_public.notify_entity_deleted(entity_uri TEXT) IS 'Emit a notification that the specified entity was deleted.'; + +GRANT EXECUTE ON PROCEDURE events_public.notify_entity_deleted TO event_emitter; diff --git a/components/api/database/ddl/dansdata/translations_private/ext_refs.sql b/components/api/database/ddl/dansdata/translations_private/ext_refs.sql new file mode 100644 index 0000000..52c3e35 --- /dev/null +++ b/components/api/database/ddl/dansdata/translations_private/ext_refs.sql @@ -0,0 +1,20 @@ +CREATE TABLE IF NOT EXISTS translations_private.ext_refs ( + id INTEGER GENERATED ALWAYS AS IDENTITY + CONSTRAINT external_refs_pk + PRIMARY KEY, + uri TEXT NOT NULL + UNIQUE + CHECK (uri LIKE 'dansdata.entity://%') +); + +COMMENT ON TABLE translations_private.ext_refs IS 'References to externally owned entities.'; + +COMMENT ON COLUMN translations_private.ext_refs.id IS 'Internal identifier for this reference to an external entity.'; + +COMMENT ON COLUMN translations_private.ext_refs.uri IS 'Globally unique URI reference for the external entity. + +The expected protocol is `dansdata.entity://`'; + + +ALTER TABLE translations_private.ext_refs + OWNER TO translations_owner; diff --git a/components/api/database/ddl/dansdata/translations_private/get_or_create_ext_ref.sql b/components/api/database/ddl/dansdata/translations_private/get_or_create_ext_ref.sql new file mode 100644 index 0000000..e636233 --- /dev/null +++ b/components/api/database/ddl/dansdata/translations_private/get_or_create_ext_ref.sql @@ -0,0 +1,24 @@ +CREATE OR REPLACE FUNCTION translations_private.get_or_create_ext_ref(entity_uri TEXT) RETURNS INTEGER + LANGUAGE sql AS +$$ +WITH + allocate AS ( INSERT INTO translations_private.ext_refs (uri) VALUES + ( + get_or_create_ext_ref.entity_uri + ) ON CONFLICT DO NOTHING RETURNING id + ) +SELECT * + FROM + allocate +UNION +SELECT + id + FROM + translations_private.ext_refs + WHERE + translations_private.ext_refs.uri = get_or_create_ext_ref.entity_uri; +$$; + +COMMENT ON FUNCTION translations_private.get_or_create_ext_ref(entity_uri TEXT) IS 'Retrieves the internal id to use for the given external entity, generating one if such an id does not already exist.'; + +ALTER FUNCTION translations_private.get_or_create_ext_ref(entity_uri TEXT) OWNER TO translations_owner; diff --git a/components/api/database/ddl/dansdata/translations_private/languages.sql b/components/api/database/ddl/dansdata/translations_private/languages.sql new file mode 100644 index 0000000..0d82611 --- /dev/null +++ b/components/api/database/ddl/dansdata/translations_private/languages.sql @@ -0,0 +1,22 @@ +CREATE TABLE IF NOT EXISTS translations_private.languages ( + id INTEGER GENERATED ALWAYS AS IDENTITY + CONSTRAINT languages_pk + PRIMARY KEY, + code TEXT NOT NULL + CONSTRAINT languages_code_uniq + UNIQUE, + name_id INTEGER NOT NULL + CONSTRAINT languages_name_id_metadatas_id_fk + REFERENCES translations_private.metadatas + ON UPDATE CASCADE + ON DELETE CASCADE +); + +COMMENT ON TABLE translations_private.languages IS 'A list of all languages known to the system.'; + +COMMENT ON COLUMN translations_private.languages.code IS 'ISO 639-1 Alpha-2 language code.'; + +COMMENT ON COLUMN translations_private.languages.name_id IS 'Reference to a translated string containing the name of this language.'; + +ALTER TABLE translations_private.languages + OWNER TO translations_owner; diff --git a/components/api/database/ddl/dansdata/translations_private/metadatas.sql b/components/api/database/ddl/dansdata/translations_private/metadatas.sql new file mode 100644 index 0000000..581d688 --- /dev/null +++ b/components/api/database/ddl/dansdata/translations_private/metadatas.sql @@ -0,0 +1,41 @@ +CREATE TABLE IF NOT EXISTS translations_private.metadatas ( + id INTEGER GENERATED ALWAYS AS IDENTITY + CONSTRAINT metadatas_pk + PRIMARY KEY, + external_id uuid DEFAULT gen_random_uuid() NOT NULL + CONSTRAINT metadatas_ext_id_uniq + UNIQUE, + owner_id INTEGER NOT NULL + CONSTRAINT metadatas_owner_id_external_refs_id_fk + REFERENCES translations_private.ext_refs + ON UPDATE CASCADE + ON DELETE CASCADE, + override_value TEXT +); + +COMMENT ON TABLE translations_private.metadatas IS 'Contains metadata about a given translation.'; + +COMMENT ON COLUMN translations_private.metadatas.external_id IS 'Id that can be used by external entities to refer to this translation.'; + +COMMENT ON COLUMN translations_private.metadatas.owner_id IS 'Reference to the external owner of this translation.'; + +COMMENT ON COLUMN translations_private.metadatas.override_value IS 'Translation to use fo all languages, or null to use a language-specific string.'; + +ALTER TABLE translations_private.metadatas + OWNER TO translations_owner; + +CREATE OR REPLACE FUNCTION translations_private.metadatas_trigger_notify_translation_was_deleted() RETURNS TRIGGER + LANGUAGE plpgsql AS +$$ +BEGIN + CALL events_public.notify_entity_deleted(('dansdata.entity://dansdata.se/v1/translations/translation?id=' + || old.external_id)::TEXT); + RETURN NULL; +END; +$$; + +CREATE OR REPLACE TRIGGER metadatas_trigger_notify_translation_was_deleted + AFTER DELETE + ON translations_private.metadatas + FOR EACH ROW +EXECUTE PROCEDURE translations_private.metadatas_trigger_notify_translation_was_deleted(); diff --git a/components/api/database/ddl/dansdata/translations_private/translations_private.sql b/components/api/database/ddl/dansdata/translations_private/translations_private.sql new file mode 100644 index 0000000..5362666 --- /dev/null +++ b/components/api/database/ddl/dansdata/translations_private/translations_private.sql @@ -0,0 +1,3 @@ +CREATE SCHEMA translations_private; + +ALTER SCHEMA translations_private OWNER TO translations_owner; diff --git a/components/api/database/ddl/dansdata/translations_private/values.sql b/components/api/database/ddl/dansdata/translations_private/values.sql new file mode 100644 index 0000000..0ade9e4 --- /dev/null +++ b/components/api/database/ddl/dansdata/translations_private/values.sql @@ -0,0 +1,24 @@ +CREATE TABLE IF NOT EXISTS translations_private.values ( + metadata_id INTEGER NOT NULL + CONSTRAINT values_metadata_id_metadatas_id_fk + REFERENCES translations_private.metadatas + ON UPDATE CASCADE + ON DELETE CASCADE, + language_id INTEGER NOT NULL + CONSTRAINT values_language_id_languages_id_fk + REFERENCES translations_private.languages + ON UPDATE CASCADE + ON DELETE CASCADE, + value TEXT NOT NULL, + CONSTRAINT values_pk + PRIMARY KEY (language_id, metadata_id) +); + +COMMENT ON TABLE translations_private.values IS 'Contains the translated texts for translations_private.'; + +COMMENT ON COLUMN translations_private.values.language_id IS 'Reference to the language this text is written in.'; + +COMMENT ON COLUMN translations_private.values.value IS 'The translated text.'; + +ALTER TABLE translations_private.values + OWNER TO translations_owner; diff --git a/components/api/database/ddl/dansdata/translations_public/allocate_translation.sql b/components/api/database/ddl/dansdata/translations_public/allocate_translation.sql new file mode 100644 index 0000000..6009f81 --- /dev/null +++ b/components/api/database/ddl/dansdata/translations_public/allocate_translation.sql @@ -0,0 +1,15 @@ +CREATE OR REPLACE FUNCTION translations_public.allocate_translation(entity_uri TEXT) RETURNS uuid + SECURITY DEFINER + LANGUAGE sql AS +$$ +INSERT INTO translations_private.metadatas( + owner_id +) +SELECT + translations_private.get_or_create_ext_ref(entity_uri) + RETURNING external_id; +$$; + +COMMENT ON FUNCTION translations_public.allocate_translation(entity_uri TEXT) IS 'Allocates a new translation id for use by the given owner.'; + +ALTER FUNCTION translations_public.allocate_translation(entity_uri TEXT) OWNER TO translations_owner; diff --git a/components/api/database/ddl/dansdata/translations_public/drop_ext_ref.sql b/components/api/database/ddl/dansdata/translations_public/drop_ext_ref.sql new file mode 100644 index 0000000..68b0429 --- /dev/null +++ b/components/api/database/ddl/dansdata/translations_public/drop_ext_ref.sql @@ -0,0 +1,14 @@ +CREATE OR REPLACE PROCEDURE translations_public.drop_ext_ref(entity_uri TEXT) + SECURITY DEFINER + LANGUAGE sql AS +$$ +DELETE + FROM + translations_private.ext_refs + WHERE + uri = entity_uri; +$$; + +COMMENT ON PROCEDURE translations_public.drop_ext_ref(entity_uri TEXT) IS 'Delete any references to an external entity and release all associated resources.'; + +ALTER PROCEDURE translations_public.drop_ext_ref(entity_uri TEXT) OWNER TO translations_owner; diff --git a/components/api/database/ddl/dansdata/translations_public/drop_translation.sql b/components/api/database/ddl/dansdata/translations_public/drop_translation.sql new file mode 100644 index 0000000..5390e92 --- /dev/null +++ b/components/api/database/ddl/dansdata/translations_public/drop_translation.sql @@ -0,0 +1,14 @@ +CREATE OR REPLACE PROCEDURE translations_public.drop_translation(translation_id uuid) + SECURITY DEFINER + LANGUAGE sql AS +$$ +DELETE + FROM + translations_private.metadatas + WHERE + external_id = translation_id; +$$; + +COMMENT ON PROCEDURE translations_public.drop_translation(translation_id uuid) IS 'Delete the given translation.'; + +ALTER PROCEDURE translations_public.drop_translation(translation_id uuid) OWNER TO translations_owner; diff --git a/components/api/database/ddl/dansdata/translations_public/set_translation.sql b/components/api/database/ddl/dansdata/translations_public/set_translation.sql new file mode 100644 index 0000000..483796f --- /dev/null +++ b/components/api/database/ddl/dansdata/translations_public/set_translation.sql @@ -0,0 +1,58 @@ +CREATE OR REPLACE PROCEDURE translations_public.set_translation( + translation_id uuid, + language_code TEXT, + value TEXT +) + SECURITY DEFINER + LANGUAGE sql AS +$$ +INSERT INTO translations_private.values AS v + ( + metadata_id, + language_id, + value + ) +SELECT + m.id, + l.id, + CASE + WHEN l.code = set_translation.language_code + THEN set_translation.value + ELSE t.value + END + FROM + translations_private.metadatas m + INNER JOIN translations_public.translations t + ON m.external_id = t.id + INNER JOIN translations_private.languages l + ON l.code = t.language_code + WHERE + external_id = set_translation.translation_id +ON CONFLICT(metadata_id, language_id) DO UPDATE SET + value = set_translation.value + WHERE + v.language_id = ( + SELECT + id + FROM + translations_private.languages l + WHERE + l.code = set_translation.language_code + ); + + -- Clean up any existing override translation. +UPDATE translations_private.metadatas +SET + override_value = NULL + WHERE + external_id = translation_id; +$$; + +COMMENT ON PROCEDURE translations_public.set_translation(translation_id UUID, language_code TEXT, value TEXT) IS 'Set the text to use as the translation for a single language. + +If an override text exists for the translation, this text will be converted +into language-specific translations first.'; + +ALTER PROCEDURE translations_public.set_translation(translation_id UUID, language_code TEXT, "value" TEXT) OWNER TO translations_owner; + +GRANT EXECUTE ON PROCEDURE translations_public.set_translation(translation_id UUID, language_code TEXT, "value" TEXT) TO edit_dance_info, moderate_dance_info; diff --git a/components/api/database/ddl/dansdata/translations_public/set_translation_override.sql b/components/api/database/ddl/dansdata/translations_public/set_translation_override.sql new file mode 100644 index 0000000..ac44abc --- /dev/null +++ b/components/api/database/ddl/dansdata/translations_public/set_translation_override.sql @@ -0,0 +1,34 @@ +CREATE OR REPLACE PROCEDURE translations_public.set_translation_override( + translation_id uuid, + value TEXT +) + SECURITY DEFINER + LANGUAGE sql AS +$$ +UPDATE translations_private.metadatas +SET + override_value = set_translation_override.value + WHERE + external_id = translation_id; + + + -- Clean up any existing per-language definitions. +DELETE + FROM + translations_private.values + WHERE + metadata_id = ( + SELECT + id + FROM + translations_private.metadatas m + WHERE + m.external_id = translation_id + ); +$$; + +COMMENT ON PROCEDURE translations_public.set_translation_override(translation_id UUID, value TEXT) IS 'Set the text to use as the translation for all languages.'; + +ALTER PROCEDURE translations_public.set_translation_override(translation_id UUID, "value" TEXT) OWNER TO translations_owner; + +GRANT EXECUTE ON PROCEDURE translations_public.set_translation_override(translation_id UUID, "value" TEXT) TO edit_dance_info, moderate_dance_info; diff --git a/components/api/database/ddl/dansdata/translations_public/translations.sql b/components/api/database/ddl/dansdata/translations_public/translations.sql new file mode 100644 index 0000000..e69561c --- /dev/null +++ b/components/api/database/ddl/dansdata/translations_public/translations.sql @@ -0,0 +1,17 @@ +CREATE OR REPLACE VIEW translations_public.translations(id, language_code, value) AS + SELECT + m.external_id AS id, + l.code AS language_code, + COALESCE(m.override_value, v.value, ''::TEXT) AS value + FROM + translations_private.metadatas m + CROSS JOIN translations_private.languages l + LEFT JOIN translations_private.values v + ON v.metadata_id = m.id AND v.language_id = l.id; + +COMMENT ON VIEW translations_public.translations IS 'A view of all translated strings, with values for all languages.'; + +ALTER TABLE translations_public.translations + OWNER TO translations_owner; + +GRANT SELECT ON translations_public.translations TO PUBLIC; diff --git a/components/api/database/ddl/dansdata/translations_public/translations_public.sql b/components/api/database/ddl/dansdata/translations_public/translations_public.sql new file mode 100644 index 0000000..937d398 --- /dev/null +++ b/components/api/database/ddl/dansdata/translations_public/translations_public.sql @@ -0,0 +1,5 @@ +CREATE SCHEMA translations_public; + +ALTER SCHEMA translations_public OWNER TO translations_owner; + +GRANT USAGE ON SCHEMA translations_public TO PUBLIC; diff --git a/components/api/database/docker-entrypoint-initdb.d/10_create_system_roles.sql b/components/api/database/docker-entrypoint-initdb.d/10_create_system_roles.sql new file mode 100644 index 0000000..b01d5c7 --- /dev/null +++ b/components/api/database/docker-entrypoint-initdb.d/10_create_system_roles.sql @@ -0,0 +1,32 @@ +------------------------------------------------- +-- System roles +------------------------------------------------- +-- This file defines the roles used as basic +-- building blocks for RBAC. +-- +-- General API access is typically assigned to +-- the PUBLIC role. + +CREATE ROLE event_emitter; + +CREATE ROLE translations_owner; +GRANT event_emitter TO translations_owner; + +------------------------------------------------- +-- dance_information +------------------------------------------------- +-- Roles related to the dance information in the +-- system. +------------------------------------------------- +-- Editors are able to create, update and delete +-- events, provided they are associated with a +-- profile which is itself associated with the +-- relevant event. +CREATE ROLE edit_dance_info; + +-- Moderators are trusted users who are able to +-- manage all events, including data which is +-- otherwise inaccessible to editors and +-- metadata. +CREATE ROLE moderate_dance_info; +GRANT edit_dance_info TO moderate_dance_info; diff --git a/components/api/database/docker-entrypoint-initdb.d/12_create_db_owner_user.sh b/components/api/database/docker-entrypoint-initdb.d/12_create_db_owner_user.sh new file mode 100644 index 0000000..a3c7304 --- /dev/null +++ b/components/api/database/docker-entrypoint-initdb.d/12_create_db_owner_user.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd /docker-entrypoint-initdb.d/ + +# Import helpers from docker-entrypoint.sh +# (this file has a specific check to prevent +# it from executing when being sourced) +# https://github.com/docker-library/postgres/blob/master/17/bookworm/docker-entrypoint.sh +source /usr/local/bin/docker-entrypoint.sh + +file_env 'DB_OWNER_USER' +file_env 'DB_OWNER_PASSWORD' + +# Create database owner. +# +# This user is used to apply migrations and can be considered a local superuser +# for the application database. +docker_process_sql \ + -v USERNAME="$DB_OWNER_USER" \ + -v PASSWORD="$DB_OWNER_PASSWORD" \ + -f templates/create_user.sql + +# Must grant highest privilege that may be required by the application. +# The privileges can then be downgraded at runtime using `SET ROLE`. +docker_process_sql \ + -v USERNAME="$DB_OWNER_USER" \ + <<< 'GRANT translations_owner TO :"USERNAME"' diff --git a/components/api/database/docker-entrypoint-initdb.d/13_create_app_backstage_user.sh b/components/api/database/docker-entrypoint-initdb.d/13_create_app_backstage_user.sh new file mode 100644 index 0000000..e68815d --- /dev/null +++ b/components/api/database/docker-entrypoint-initdb.d/13_create_app_backstage_user.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd /docker-entrypoint-initdb.d/ + +# Import helpers from docker-entrypoint.sh +# (this file has a specific check to prevent +# it from executing when being sourced) +# https://github.com/docker-library/postgres/blob/master/17/bookworm/docker-entrypoint.sh +source /usr/local/bin/docker-entrypoint.sh + +file_env 'DB_APP_BACKSTAGE_USER' +file_env 'DB_APP_BACKSTAGE_PASSWORD' + +# Create authentication user for applications +# +# This user is used temporarily by the application that actually connects to +# our database (i.e. Postgraphile). It is expected that the connecting +# appliation will call `SET ROLE` after authentication to downgrade their +# access to the level actually granted to the user being served. +docker_process_sql \ + -v USERNAME="$DB_APP_BACKSTAGE_USER" \ + -v PASSWORD="$DB_APP_BACKSTAGE_PASSWORD" \ + -f templates/create_user.sql + +# Must grant highest privilege that may be required by the application. +# The privileges can then be downgraded at runtime using `SET ROLE`. +docker_process_sql \ + -v USERNAME="$DB_APP_BACKSTAGE_USER" \ + <<< 'GRANT moderate_dance_info TO :"USERNAME"' diff --git a/components/api/database/docker-entrypoint-initdb.d/20_create_application_database.sh b/components/api/database/docker-entrypoint-initdb.d/20_create_application_database.sh new file mode 100644 index 0000000..b255811 --- /dev/null +++ b/components/api/database/docker-entrypoint-initdb.d/20_create_application_database.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd /docker-entrypoint-initdb.d/ + +# Import helpers from docker-entrypoint.sh +# (this file has a specific check to prevent +# it from executing when being sourced) +# https://github.com/docker-library/postgres/blob/master/17/bookworm/docker-entrypoint.sh +source /usr/local/bin/docker-entrypoint.sh + +file_env 'DB_OWNER_USER' +file_env 'DB_APP_BACKSTAGE_USER' +file_env 'DB_NAME' +file_env 'POSTGRES_DB' + +create_db() { + local db_name=$1 + # Create database + docker_process_sql \ + -v DB_OWNER="$DB_OWNER_USER" \ + -v DB_NAME="$db_name" \ + -f templates/create_database.sql + + # Grant basic connect access for users + docker_process_sql \ + -v USERNAME="$DB_APP_BACKSTAGE_USER" \ + -v DB_NAME="$db_name" \ + <<< 'GRANT CONNECT ON DATABASE :"DB_NAME" TO :"USERNAME";' + + # Add extensions + docker_process_sql \ + --dbname="$db_name" \ + -f templates/create_extensions.sql +} + +create_db "$DB_NAME" diff --git a/components/api/database/docker-entrypoint-initdb.d/99_initial_migrate.sh b/components/api/database/docker-entrypoint-initdb.d/99_initial_migrate.sh new file mode 100644 index 0000000..9f041ad --- /dev/null +++ b/components/api/database/docker-entrypoint-initdb.d/99_initial_migrate.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd /docker-entrypoint-initdb.d/ + +# Import helpers from docker-entrypoint.sh +# (this file has a specific check to prevent +# it from executing when being sourced) +# https://github.com/docker-library/postgres/blob/master/17/bookworm/docker-entrypoint.sh +source /usr/local/bin/docker-entrypoint.sh + +file_env 'DB_NAME' + +docker_process_sql \ + --dbname="$DB_NAME" \ + -f /tmp/migrate_latest.sql diff --git a/components/api/database/docker-entrypoint-initdb.d/templates/create_database.sql b/components/api/database/docker-entrypoint-initdb.d/templates/create_database.sql new file mode 100644 index 0000000..c5e66a7 --- /dev/null +++ b/components/api/database/docker-entrypoint-initdb.d/templates/create_database.sql @@ -0,0 +1,9 @@ +DROP DATABASE IF EXISTS :"DB_NAME"; + +CREATE DATABASE :"DB_NAME" WITH OWNER :"DB_OWNER" -- + ENCODING 'UTF8' -- + LOCALE_PROVIDER 'icu' -- + ICU_LOCALE 'sv-SE' -- + TEMPLATE template0; + +REVOKE ALL ON DATABASE :"DB_NAME" FROM PUBLIC; diff --git a/components/api/database/docker-entrypoint-initdb.d/templates/create_extensions.sql b/components/api/database/docker-entrypoint-initdb.d/templates/create_extensions.sql new file mode 100644 index 0000000..4386d2c --- /dev/null +++ b/components/api/database/docker-entrypoint-initdb.d/templates/create_extensions.sql @@ -0,0 +1,13 @@ +CREATE SCHEMA IF NOT EXISTS extensions; + +GRANT USAGE ON SCHEMA extensions TO PUBLIC; + +CREATE EXTENSION IF NOT EXISTS plpgsql WITH SCHEMA extensions; + +CREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA extensions; + +CREATE EXTENSION IF NOT EXISTS pgcrypto WITH SCHEMA extensions; + +CREATE EXTENSION IF NOT EXISTS postgis WITH SCHEMA extensions; + +CREATE EXTENSION IF NOT EXISTS timescaledb WITH SCHEMA extensions; diff --git a/components/api/database/docker-entrypoint-initdb.d/templates/create_user.sql b/components/api/database/docker-entrypoint-initdb.d/templates/create_user.sql new file mode 100644 index 0000000..37f37ba --- /dev/null +++ b/components/api/database/docker-entrypoint-initdb.d/templates/create_user.sql @@ -0,0 +1,5 @@ +DROP USER IF EXISTS :"USERNAME"; + +CREATE USER :"USERNAME" WITH PASSWORD :'PASSWORD'; + +ALTER ROLE :"USERNAME" SET search_path = "$user", public, extensions, dance_api_public; diff --git a/components/api/database/migrate.sh b/components/api/database/migrate.sh new file mode 100755 index 0000000..f67e884 --- /dev/null +++ b/components/api/database/migrate.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -eux + +cat \ + ./ddl/dansdata/events_public/events_public.sql \ + ./ddl/dansdata/events_public/notify_entity_deleted.sql \ + ./ddl/dansdata/translations_private/translations_private.sql \ + ./ddl/dansdata/translations_private/ext_refs.sql \ + ./ddl/dansdata/translations_private/get_or_create_ext_ref.sql \ + ./ddl/dansdata/translations_private/metadatas.sql \ + ./ddl/dansdata/translations_private/languages.sql \ + ./ddl/dansdata/translations_private/values.sql \ + ./ddl/dansdata/translations_public/translations_public.sql \ + ./ddl/dansdata/translations_public/drop_ext_ref.sql \ + ./ddl/dansdata/translations_public/translations.sql \ + ./ddl/dansdata/translations_public/allocate_translation.sql \ + ./ddl/dansdata/translations_public/set_translation_override.sql \ + ./ddl/dansdata/translations_public/set_translation.sql \ + ./ddl/dansdata/translations_public/drop_translation.sql \ + ./seed.sql \ + > ./migrate_latest.sql diff --git a/components/api/database/seed.sql b/components/api/database/seed.sql new file mode 100644 index 0000000..489e9c2 --- /dev/null +++ b/components/api/database/seed.sql @@ -0,0 +1,85 @@ +-- +-- PostgreSQL database dump +-- + +-- Dumped from database version 17.0 (Debian 17.0-1.pgdg120+1) +-- Dumped by pg_dump version 17.0 (Debian 17.0-1.pgdg120+1) + +SET statement_timeout = 0; +SET lock_timeout = 0; +SET idle_in_transaction_session_timeout = 0; +SET transaction_timeout = 0; +SET client_encoding = 'UTF8'; +SET standard_conforming_strings = on; +SELECT pg_catalog.set_config('search_path', '', false); +SET check_function_bodies = false; +SET xmloption = content; +SET client_min_messages = warning; +SET row_security = off; + +-- +-- Data for Name: ext_refs; Type: TABLE DATA; Schema: translations_private; Owner: dansdata +-- + +INSERT INTO translations_private.ext_refs (id, uri) OVERRIDING SYSTEM VALUE VALUES (1, 'dansdata.entity://dansdata.se/v1/translations/language?code=sv'); +INSERT INTO translations_private.ext_refs (id, uri) OVERRIDING SYSTEM VALUE VALUES (2, 'dansdata.entity://dansdata.se/v1/translations/language?code=en'); +INSERT INTO translations_private.ext_refs (id, uri) OVERRIDING SYSTEM VALUE VALUES (3, 'dansdata.entity://dansdata.se/v1/translations/language?code=no'); + + +-- +-- Data for Name: metadatas; Type: TABLE DATA; Schema: translations_private; Owner: dansdata +-- + +INSERT INTO translations_private.metadatas (id, owner_id, override_value) OVERRIDING SYSTEM VALUE VALUES (1, 1, NULL); +INSERT INTO translations_private.metadatas (id, owner_id, override_value) OVERRIDING SYSTEM VALUE VALUES (2, 2, NULL); +INSERT INTO translations_private.metadatas (id, owner_id, override_value) OVERRIDING SYSTEM VALUE VALUES (3, 3, NULL); + + +-- +-- Data for Name: languages; Type: TABLE DATA; Schema: translations_private; Owner: dansdata +-- + +INSERT INTO translations_private.languages (id, code, name_id) OVERRIDING SYSTEM VALUE VALUES (1, 'sv', 1); +INSERT INTO translations_private.languages (id, code, name_id) OVERRIDING SYSTEM VALUE VALUES (2, 'en', 2); +INSERT INTO translations_private.languages (id, code, name_id) OVERRIDING SYSTEM VALUE VALUES (3, 'no', 3); + + +-- +-- Data for Name: values; Type: TABLE DATA; Schema: translations_private; Owner: dansdata +-- + +INSERT INTO translations_private."values" (metadata_id, language_id, value) VALUES (1, 1, 'Svenska'); +INSERT INTO translations_private."values" (metadata_id, language_id, value) VALUES (1, 2, 'Swedish'); +INSERT INTO translations_private."values" (metadata_id, language_id, value) VALUES (1, 3, 'Svensk'); +INSERT INTO translations_private."values" (metadata_id, language_id, value) VALUES (2, 1, 'Engelska'); +INSERT INTO translations_private."values" (metadata_id, language_id, value) VALUES (2, 2, 'English'); +INSERT INTO translations_private."values" (metadata_id, language_id, value) VALUES (2, 3, 'Engelsk'); +INSERT INTO translations_private."values" (metadata_id, language_id, value) VALUES (3, 1, 'Norska'); +INSERT INTO translations_private."values" (metadata_id, language_id, value) VALUES (3, 2, 'Norwegian'); +INSERT INTO translations_private."values" (metadata_id, language_id, value) VALUES (3, 3, 'Norsk'); + + +-- +-- Name: ext_refs_id_seq; Type: SEQUENCE SET; Schema: translations_private; Owner: dansdata +-- + +SELECT pg_catalog.setval('translations_private.ext_refs_id_seq', 3, true); + + +-- +-- Name: languages_id_seq; Type: SEQUENCE SET; Schema: translations_private; Owner: dansdata +-- + +SELECT pg_catalog.setval('translations_private.languages_id_seq', 3, true); + + +-- +-- Name: metadatas_id_seq; Type: SEQUENCE SET; Schema: translations_private; Owner: dansdata +-- + +SELECT pg_catalog.setval('translations_private.metadatas_id_seq', 3, true); + + +-- +-- PostgreSQL database dump complete +-- diff --git a/components/api/database/tests/.editorconfig b/components/api/database/tests/.editorconfig new file mode 100644 index 0000000..85d6e05 --- /dev/null +++ b/components/api/database/tests/.editorconfig @@ -0,0 +1,94 @@ +# This .editorconfig section approximates ktfmt's formatting rules. You can include it in an +# existing .editorconfig file or use it standalone by copying it to /.editorconfig +# and making sure your editor is set to read settings from .editorconfig files. +# +# It includes editor-specific config options for IntelliJ IDEA. +# +# If any option is wrong, PR are welcome + +[{*.kt,*.kts}] +indent_style = space +insert_final_newline = true +max_line_length = 100 +indent_size = 4 +ij_continuation_indent_size = 4 +ij_java_names_count_to_use_import_on_demand = 9999 +ij_kotlin_align_in_columns_case_branch = false +ij_kotlin_align_multiline_binary_operation = false +ij_kotlin_align_multiline_extends_list = false +ij_kotlin_align_multiline_method_parentheses = false +ij_kotlin_align_multiline_parameters = true +ij_kotlin_align_multiline_parameters_in_calls = false +ij_kotlin_allow_trailing_comma = true +ij_kotlin_allow_trailing_comma_on_call_site = true +ij_kotlin_assignment_wrap = normal +ij_kotlin_blank_lines_after_class_header = 0 +ij_kotlin_blank_lines_around_block_when_branches = 0 +ij_kotlin_blank_lines_before_declaration_with_comment_or_annotation_on_separate_line = 1 +ij_kotlin_block_comment_at_first_column = true +ij_kotlin_call_parameters_new_line_after_left_paren = true +ij_kotlin_call_parameters_right_paren_on_new_line = false +ij_kotlin_call_parameters_wrap = on_every_item +ij_kotlin_catch_on_new_line = false +ij_kotlin_class_annotation_wrap = split_into_lines +ij_kotlin_code_style_defaults = KOTLIN_OFFICIAL +ij_kotlin_continuation_indent_for_chained_calls = true +ij_kotlin_continuation_indent_for_expression_bodies = true +ij_kotlin_continuation_indent_in_argument_lists = true +ij_kotlin_continuation_indent_in_elvis = false +ij_kotlin_continuation_indent_in_if_conditions = false +ij_kotlin_continuation_indent_in_parameter_lists = false +ij_kotlin_continuation_indent_in_supertype_lists = false +ij_kotlin_else_on_new_line = false +ij_kotlin_enum_constants_wrap = off +ij_kotlin_extends_list_wrap = normal +ij_kotlin_field_annotation_wrap = off +ij_kotlin_finally_on_new_line = false +ij_kotlin_if_rparen_on_new_line = false +ij_kotlin_import_nested_classes = false +ij_kotlin_imports_layout = * +ij_kotlin_insert_whitespaces_in_simple_one_line_method = true +ij_kotlin_keep_blank_lines_before_right_brace = 2 +ij_kotlin_keep_blank_lines_in_code = 2 +ij_kotlin_keep_blank_lines_in_declarations = 2 +ij_kotlin_keep_first_column_comment = true +ij_kotlin_keep_indents_on_empty_lines = false +ij_kotlin_keep_line_breaks = true +ij_kotlin_lbrace_on_next_line = false +ij_kotlin_line_comment_add_space = false +ij_kotlin_line_comment_at_first_column = true +ij_kotlin_method_annotation_wrap = split_into_lines +ij_kotlin_method_call_chain_wrap = normal +ij_kotlin_method_parameters_new_line_after_left_paren = true +ij_kotlin_method_parameters_right_paren_on_new_line = true +ij_kotlin_method_parameters_wrap = on_every_item +ij_kotlin_name_count_to_use_star_import = 9999 +ij_kotlin_name_count_to_use_star_import_for_members = 9999 +ij_kotlin_parameter_annotation_wrap = off +ij_kotlin_space_after_comma = true +ij_kotlin_space_after_extend_colon = true +ij_kotlin_space_after_type_colon = true +ij_kotlin_space_before_catch_parentheses = true +ij_kotlin_space_before_comma = false +ij_kotlin_space_before_extend_colon = true +ij_kotlin_space_before_for_parentheses = true +ij_kotlin_space_before_if_parentheses = true +ij_kotlin_space_before_lambda_arrow = true +ij_kotlin_space_before_type_colon = false +ij_kotlin_space_before_when_parentheses = true +ij_kotlin_space_before_while_parentheses = true +ij_kotlin_spaces_around_additive_operators = true +ij_kotlin_spaces_around_assignment_operators = true +ij_kotlin_spaces_around_equality_operators = true +ij_kotlin_spaces_around_function_type_arrow = true +ij_kotlin_spaces_around_logical_operators = true +ij_kotlin_spaces_around_multiplicative_operators = true +ij_kotlin_spaces_around_range = false +ij_kotlin_spaces_around_relational_operators = true +ij_kotlin_spaces_around_unary_operator = false +ij_kotlin_spaces_around_when_arrow = true +ij_kotlin_variable_annotation_wrap = off +ij_kotlin_while_on_new_line = false +ij_kotlin_wrap_elvis_expressions = 1 +ij_kotlin_wrap_expression_body_functions = 1 +ij_kotlin_wrap_first_method_in_call_chain = false diff --git a/components/api/database/tests/.gitignore b/components/api/database/tests/.gitignore new file mode 100644 index 0000000..0ea0f1c --- /dev/null +++ b/components/api/database/tests/.gitignore @@ -0,0 +1,263 @@ +# Created by https://www.toptal.com/developers/gitignore/api/linux,windows,macos,kotlin,gradle,intellij,visualstudiocode +# Edit at https://www.toptal.com/developers/gitignore?templates=linux,windows,macos,kotlin,gradle,intellij,visualstudiocode + +### Intellij ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +.idea/artifacts +.idea/compiler.xml +.idea/jarRepositories.xml +.idea/modules.xml +.idea/*.iml +.idea/modules +*.iml +*.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### Intellij Patch ### +# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 + +# *.iml +# modules.xml +# .idea/misc.xml +# *.ipr + +# Sonarlint plugin +# https://plugins.jetbrains.com/plugin/7973-sonarlint +.idea/**/sonarlint/ + +# SonarQube Plugin +# https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin +.idea/**/sonarIssues.xml + +# Markdown Navigator plugin +# https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced +.idea/**/markdown-navigator.xml +.idea/**/markdown-navigator-enh.xml +.idea/**/markdown-navigator/ + +# Cache file creation bug +# See https://youtrack.jetbrains.com/issue/JBR-2257 +.idea/$CACHE_FILE$ + +# CodeStream plugin +# https://plugins.jetbrains.com/plugin/12206-codestream +.idea/codestream.xml + +# Azure Toolkit for IntelliJ plugin +# https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij +.idea/**/azureSettings.xml + +### Kotlin ### +# Compiled class file +*.class + +# Log file +*.log + +# BlueJ files +*.ctxt + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files # +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* +replay_pid* + +### Linux ### +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### macOS Patch ### +# iCloud generated files +*.icloud + +### VisualStudioCode ### +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +### VisualStudioCode Patch ### +# Ignore all local history of files +.history +.ionide + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +### Gradle ### +.gradle +**/build/ +!src/**/build/ + +# Ignore Gradle GUI config +gradle-app.setting + +# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) +!gradle-wrapper.jar + +# Avoid ignore Gradle wrappper properties +!gradle-wrapper.properties + +# Cache of project +.gradletasknamecache + +# Eclipse Gradle plugin generated files +# Eclipse Core +.project +# JDT-specific (Eclipse Java Development Tools) +.classpath + +### Gradle Patch ### +# Java heap dump +*.hprof + +# End of https://www.toptal.com/developers/gitignore/api/linux,windows,macos,kotlin,gradle,intellij,visualstudiocode diff --git a/components/api/database/tests/build.gradle.kts b/components/api/database/tests/build.gradle.kts new file mode 100644 index 0000000..17d6144 --- /dev/null +++ b/components/api/database/tests/build.gradle.kts @@ -0,0 +1,59 @@ +import com.ncorti.ktfmt.gradle.tasks.KtfmtFormatTask +import io.gitlab.arturbosch.detekt.Detekt +import io.gitlab.arturbosch.detekt.DetektCreateBaselineTask + +plugins { + kotlin("jvm") version "2.0.20" + id("org.jetbrains.kotlinx.dataframe") version "0.14.1" + id("com.ncorti.ktfmt.gradle") version "0.20.1" + id("io.gitlab.arturbosch.detekt") version "1.23.7" +} + +group = "se.dansdata" + +version = "1.0-SNAPSHOT" + +repositories { mavenCentral() } + +dependencies { + testImplementation(kotlin("test")) + testImplementation("org.jetbrains.kotlinx:dataframe-jdbc:0.14.1") + testImplementation("org.junit.jupiter:junit-jupiter-params:5.10.3") + testImplementation("org.postgresql:postgresql:42.7.4") + testImplementation("io.strikt:strikt-core:0.34.0") + testImplementation("org.testcontainers:testcontainers:1.20.3") + testImplementation("org.testcontainers:junit-jupiter:1.20.3") + testImplementation("org.testcontainers:jdbc:1.20.3") +} + +tasks.test { useJUnitPlatform() } + +kotlin { jvmToolchain(21) } + +tasks.register("ktfmtPrecommit") { + source = project.fileTree(rootDir) + include("**/*.kt") +} + +detekt { + buildUponDefaultConfig = true + config.from("detekt.yml") +} + +ktfmt { + kotlinLangStyle() + removeUnusedImports.set(true) + manageTrailingCommas.set(true) +} + +tasks.withType().configureEach { jvmTarget = "21" } + +tasks.withType().configureEach { jvmTarget = "21" } + +// Workaround for https://github.com/testcontainers/testcontainers-java/issues/2857 +tasks.register("buildDockerImage", type = Exec::class) { + workingDir(project.rootDir.resolve("..")) + commandLine("docker", "build", "-t", "dansdata/database:test", ".") +} + +tasks.named("test").get().dependsOn(tasks.named("buildDockerImage")) diff --git a/components/api/database/tests/detekt.yml b/components/api/database/tests/detekt.yml new file mode 100644 index 0000000..e2e4afd --- /dev/null +++ b/components/api/database/tests/detekt.yml @@ -0,0 +1,7 @@ +config: + validation: true + warningsAsErrors: false + +naming: + PackageNaming: + active: false diff --git a/components/api/database/tests/gradle.properties b/components/api/database/tests/gradle.properties new file mode 100644 index 0000000..7fc6f1f --- /dev/null +++ b/components/api/database/tests/gradle.properties @@ -0,0 +1 @@ +kotlin.code.style=official diff --git a/components/api/database/tests/gradle/wrapper/gradle-wrapper.jar b/components/api/database/tests/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..249e5832f090a2944b7473328c07c9755baa3196 GIT binary patch literal 60756 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 '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/components/api/database/tests/gradlew.bat b/components/api/database/tests/gradlew.bat new file mode 100644 index 0000000..107acd3 --- /dev/null +++ b/components/api/database/tests/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/components/api/database/tests/settings.gradle.kts b/components/api/database/tests/settings.gradle.kts new file mode 100644 index 0000000..afaf6ff --- /dev/null +++ b/components/api/database/tests/settings.gradle.kts @@ -0,0 +1,5 @@ +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" +} +rootProject.name = "tests" + diff --git a/components/api/database/tests/src/test/kotlin/se/dansdata/database/DansdataDbContainer.kt b/components/api/database/tests/src/test/kotlin/se/dansdata/database/DansdataDbContainer.kt new file mode 100644 index 0000000..51199c4 --- /dev/null +++ b/components/api/database/tests/src/test/kotlin/se/dansdata/database/DansdataDbContainer.kt @@ -0,0 +1,92 @@ +package se.dansdata.database + +import kotlin.time.Duration.Companion.seconds +import kotlin.time.toJavaDuration +import org.jetbrains.kotlinx.dataframe.io.DbConnectionConfig +import org.slf4j.LoggerFactory +import org.testcontainers.containers.JdbcDatabaseContainer +import org.testcontainers.containers.output.Slf4jLogConsumer +import org.testcontainers.containers.wait.strategy.LogMessageWaitStrategy +import org.testcontainers.utility.DockerImageName + +/** Testcontainers implementation for Dansdata's backing database. */ +class DansdataDbContainer private constructor() : + JdbcDatabaseContainer(DockerImageName.parse("dansdata/database:test")) { + private val logger = LoggerFactory.getLogger(DansdataDbContainer::class.java) + + init { + waitStrategy = + LogMessageWaitStrategy() + .withRegEx(".*database system is ready to accept connections.*\\s") + .withTimes(2) + .withStartupTimeout(30.seconds.toJavaDuration()) + + addExposedPort(5432) + + withLogConsumer(Slf4jLogConsumer(logger).withSeparateOutputStreams()) + } + + fun getConfigForUser(dbUser: DbUser) = + DbConnectionConfig(jdbcUrl, dbUser.userName, dbUser.password) + + override fun configure() { + addEnv("DBMS_OWNER_USER", DbUser.DbmsOwner.userName) + addEnv("DBMS_OWNER_PASSWORD", DbUser.DbmsOwner.password) + addEnv("DB_OWNER_USER", DbUser.DbOwner.userName) + addEnv("DB_OWNER_PASSWORD", DbUser.DbOwner.password) + addEnv("DB_APP_BACKSTAGE_USER", DbUser.AppBackstage.userName) + addEnv("DB_APP_BACKSTAGE_PASSWORD", DbUser.AppBackstage.password) + } + + override fun getDriverClassName(): String = "org.postgresql.Driver" + + override fun getJdbcUrl(): String = + "jdbc:postgresql://" + + host + + ":" + + getMappedPort(5432) + + "/" + + databaseName + + constructUrlParameters("?", "&") + + override fun getUsername(): String = DbUser.DbOwner.userName + + override fun getPassword(): String = DbUser.DbOwner.password + + override fun getDatabaseName(): String = "dansdata" + + override fun getTestQueryString(): String = "SELECT 1" + + override fun waitUntilContainerStarted() { + getWaitStrategy().waitUntilReady(this) + } + + class TestWrapper(private val container: DansdataDbContainer) { + fun queryAs(user: DbUser, block: DbConnectionConfig.() -> R): R = + container.getConfigForUser(user).run(block) + } + + object Shared { + @JvmStatic private val container = DansdataDbContainer().apply { start() } + + fun use(block: TestWrapper.() -> Unit) { + block(TestWrapper(container)) + } + } + + companion object { + /** + * Creates a new [DansdataDbContainer] for use only under the given block. + * + * Note: container starts are quite expensive! Prefer [Shared.use] when possible. + * + * @see Shared.use + */ + fun use(block: TestWrapper.() -> Unit) { + DansdataDbContainer().use { container -> + container.start() + block(TestWrapper(container)) + } + } + } +} diff --git a/components/api/database/tests/src/test/kotlin/se/dansdata/database/DbRole.kt b/components/api/database/tests/src/test/kotlin/se/dansdata/database/DbRole.kt new file mode 100644 index 0000000..37dd81f --- /dev/null +++ b/components/api/database/tests/src/test/kotlin/se/dansdata/database/DbRole.kt @@ -0,0 +1,8 @@ +package se.dansdata.database + +enum class DbRole(val roleName: String) { + DbmsOwner("postgres"), + DbOwner("dansdata"), + AppBackstage("backstage"), + TranslationsOwner("translations_owner"), +} diff --git a/components/api/database/tests/src/test/kotlin/se/dansdata/database/DbSchema.kt b/components/api/database/tests/src/test/kotlin/se/dansdata/database/DbSchema.kt new file mode 100644 index 0000000..5a07a0d --- /dev/null +++ b/components/api/database/tests/src/test/kotlin/se/dansdata/database/DbSchema.kt @@ -0,0 +1,7 @@ +package se.dansdata.database + +enum class DbSchema(val schemaName: String, val owner: DbRole, val isPublic: Boolean) { + EventsPublic("events_public", DbRole.DbOwner, true), + TranslationsPrivate("translations_private", DbRole.TranslationsOwner, false), + TranslationsPublic("translations_public", DbRole.TranslationsOwner, true), +} diff --git a/components/api/database/tests/src/test/kotlin/se/dansdata/database/DbUser.kt b/components/api/database/tests/src/test/kotlin/se/dansdata/database/DbUser.kt new file mode 100644 index 0000000..226c452 --- /dev/null +++ b/components/api/database/tests/src/test/kotlin/se/dansdata/database/DbUser.kt @@ -0,0 +1,9 @@ +package se.dansdata.database + +enum class DbUser(role: DbRole, val password: String) { + DbmsOwner(DbRole.DbmsOwner, "postgres-pwd"), + DbOwner(DbRole.DbOwner, "dansdata-pwd"), + AppBackstage(DbRole.AppBackstage, "backstage-pwd"); + + val userName: String = role.roleName +} diff --git a/components/api/database/tests/src/test/kotlin/se/dansdata/database/strikt/DataFrameAssertions.kt b/components/api/database/tests/src/test/kotlin/se/dansdata/database/strikt/DataFrameAssertions.kt new file mode 100644 index 0000000..ae38e76 --- /dev/null +++ b/components/api/database/tests/src/test/kotlin/se/dansdata/database/strikt/DataFrameAssertions.kt @@ -0,0 +1,89 @@ +@file:Suppress("Unused") + +package se.dansdata.database.strikt + +import org.jetbrains.kotlinx.dataframe.AnyCol +import org.jetbrains.kotlinx.dataframe.DataFrame +import org.jetbrains.kotlinx.dataframe.DataRow +import org.jetbrains.kotlinx.dataframe.api.columnsCount +import org.jetbrains.kotlinx.dataframe.api.containsKey +import org.jetbrains.kotlinx.dataframe.api.single +import strikt.api.Assertion +import strikt.assertions.isEqualTo + +/** Asserts that the data frame contains exactly [count] rows. */ +fun Assertion.Builder>.hasRowCount(count: Int): Assertion.Builder> = + with({ rowsCount() }) { isEqualTo(count) } + +/** Executes the given assertions on the single row contained in this [DataFrame] */ +fun Assertion.Builder>.withSingleRow(block: Assertion.Builder>.() -> Unit) { + hasRowCount(1).get("singular row") { single() }.apply(block) +} + +/** Maps this assertion to an assertion on the value indexed by [columnName] in the data row. */ +operator fun Assertion.Builder>.get(columnName: String): Assertion.Builder = + containsColumn(columnName).get("value in column [$columnName]") { get(columnName) } + +/** Maps this assertion to an assertion on the value indexed by [columnIndex] in the data row. */ +operator fun Assertion.Builder>.get(columnIndex: Int): Assertion.Builder = + containsColumn(columnIndex).get("value in column at index [$columnIndex]") { get(columnIndex) } + +/** + * Runs a group of assertions on the value in the data row that corresponds to [columnName]. + * + * @param block a closure that can perform multiple assertions that will all be evaluated regardless + * of whether preceding ones pass or fail. + * @return this builder, to facilitate chaining. + */ +fun Assertion.Builder>.withValue( + columnName: String, + block: Assertion.Builder.() -> Unit, +): Assertion.Builder> = + containsColumn(columnName).and { + with("value in column [$columnName]", { get(columnName) }, block) + } + +/** Asserts that the data row contains a column indexed by [columnName]. */ +infix fun Assertion.Builder>.containsColumn( + columnName: String +): Assertion.Builder> = + assertThat("has a column with the name %s", columnName) { it.containsKey(columnName) } + +/** Asserts that the data row contains a column indexed by [columnIndex]. */ +infix fun Assertion.Builder>.containsColumn( + columnIndex: Int +): Assertion.Builder> = + assertThat("has a column at index %s", columnIndex) { columnIndex < it.columnsCount() } + +/** Asserts that the data row does not contain a column indexed by [columnName]. */ +infix fun Assertion.Builder>.doesNotContainColumn( + columnName: String +): Assertion.Builder> = + assertThat("does not have a column with the name %s", columnName) { + !it.containsKey(columnName) + } + +/** Asserts that the data row contains entries for all [columnNames]. */ +fun Assertion.Builder>.containsColumnNames( + vararg columnNames: String +): Assertion.Builder> = + compose("has columns named %s", columnNames.toList()) { + columnNames.forEach { columnName -> containsColumn(columnName) } + } then { if (allPassed) pass() else fail() } + +/** Asserts that the data row doesn't contain any columns in [columnNames]. */ +fun Assertion.Builder>.doesNotContainColumnNames( + vararg columnNames: String +): Assertion.Builder> = + compose("doesn't have columns named %s", columnNames.toList()) { + columnNames.forEach { columnName -> doesNotContainColumn(columnName) } + } then { if (allPassed) pass() else fail() } + +/** + * Asserts that the data row contains a column indexed by [columnName] with a value equal to + * [value]. + */ +fun Assertion.Builder>.hasEntry( + columnName: String, + value: AnyCol, +): Assertion.Builder> = apply { containsColumn(columnName)[columnName].isEqualTo(value) } diff --git a/components/api/database/tests/src/test/kotlin/se/dansdata/database/strikt/UuidAssertions.kt b/components/api/database/tests/src/test/kotlin/se/dansdata/database/strikt/UuidAssertions.kt new file mode 100644 index 0000000..da11a5a --- /dev/null +++ b/components/api/database/tests/src/test/kotlin/se/dansdata/database/strikt/UuidAssertions.kt @@ -0,0 +1,17 @@ +package se.dansdata.database.strikt + +import java.util.* +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid +import kotlin.uuid.toKotlinUuid +import strikt.api.Assertion +import strikt.assertions.isA + +/** + * Verifies that the given value is a java [UUID] and converts it into a kotlin [Uuid]. + * + * Necessary as the postgres JDBC driver returns a java UUID whereas we prefer to work with kotlin + * [Uuid]s. + */ +@OptIn(ExperimentalUuidApi::class) +fun Assertion.Builder.isAUuid(): Assertion.Builder = isA().get { toKotlinUuid() } diff --git a/components/api/database/tests/src/test/kotlin/se/dansdata/database/tests/AbstractSchemaTests.kt b/components/api/database/tests/src/test/kotlin/se/dansdata/database/tests/AbstractSchemaTests.kt new file mode 100644 index 0000000..88e3947 --- /dev/null +++ b/components/api/database/tests/src/test/kotlin/se/dansdata/database/tests/AbstractSchemaTests.kt @@ -0,0 +1,101 @@ +package se.dansdata.database.tests + +import org.jetbrains.kotlinx.dataframe.io.readDataFrame +import org.junit.jupiter.api.Assumptions +import org.junit.jupiter.api.Test +import se.dansdata.database.DansdataDbContainer +import se.dansdata.database.DbRole +import se.dansdata.database.DbSchema +import se.dansdata.database.DbUser +import se.dansdata.database.strikt.get +import se.dansdata.database.strikt.withSingleRow +import strikt.api.expectThat +import strikt.assertions.isA +import strikt.assertions.isEqualTo +import strikt.assertions.isFalse +import strikt.assertions.isTrue + +abstract class AbstractSchemaTests(private val schema: DbSchema) { + @Test + fun `Schema exists with correct owner`() = + DansdataDbContainer.Shared.use { + val result = + queryAs(DbUser.DbOwner) { + readDataFrame( + // language=postgresql + """ + SELECT + schema_name, + schema_owner + FROM + information_schema.schemata + WHERE + schema_name = '${schema.schemaName}' + """ + .trimIndent() + ) + } + + expectThat(result).withSingleRow { + get("schema_name").isA().isEqualTo(schema.schemaName) + get("schema_owner").isA().isEqualTo(schema.owner.roleName) + } + } + + @Test + fun `App user has USAGE on the schema`() { + Assumptions.assumeTrue(schema.isPublic) + + DansdataDbContainer.Shared.use { + val result = + queryAs(DbUser.DbmsOwner) { + readDataFrame( + // language=postgresql + """ + SELECT pg_catalog.has_schema_privilege('${DbRole.AppBackstage.roleName}', '${schema.schemaName}', 'USAGE') AS "usage" + """ + .trimIndent() + ) + } + + expectThat(result).withSingleRow { get("usage").isA().isTrue() } + } + } + + @Test + fun `App user does not have USAGE on the schema`() { + Assumptions.assumeFalse(schema.isPublic) + + DansdataDbContainer.Shared.use { + val result = + queryAs(DbUser.DbmsOwner) { + readDataFrame( + // language=postgresql + """ + SELECT pg_catalog.has_schema_privilege('${DbRole.AppBackstage.roleName}', '${schema.schemaName}', 'USAGE') AS "usage" + """ + .trimIndent() + ) + } + + expectThat(result).withSingleRow { get("usage").isA().isFalse() } + } + } + + @Test + fun `App user does not have CREATE on the schema`() = + DansdataDbContainer.Shared.use { + val result = + queryAs(DbUser.DbmsOwner) { + readDataFrame( + // language=postgresql + """ + SELECT pg_catalog.has_schema_privilege('${DbRole.AppBackstage.roleName}', '${schema.schemaName}', 'CREATE') AS "create" + """ + .trimIndent() + ) + } + + expectThat(result).withSingleRow { get("create").isA().isFalse() } + } +} diff --git a/components/api/database/tests/src/test/kotlin/se/dansdata/database/tests/ContainerTests.kt b/components/api/database/tests/src/test/kotlin/se/dansdata/database/tests/ContainerTests.kt new file mode 100644 index 0000000..1424f8d --- /dev/null +++ b/components/api/database/tests/src/test/kotlin/se/dansdata/database/tests/ContainerTests.kt @@ -0,0 +1,29 @@ +package se.dansdata.database.tests + +import org.jetbrains.kotlinx.dataframe.io.readDataFrame +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.EnumSource +import se.dansdata.database.DansdataDbContainer +import se.dansdata.database.DbUser +import se.dansdata.database.strikt.get +import se.dansdata.database.strikt.withSingleRow +import strikt.api.expectThat +import strikt.assertions.isEqualTo + +class ContainerTests { + @ParameterizedTest(name = "Can connect as {0}") + @EnumSource(DbUser::class) + fun `Can connect as `(dbUser: DbUser) { + DansdataDbContainer.Shared.use { + val result = + queryAs(dbUser) { + readDataFrame( + // language=postgresql + "SELECT 1" + ) + } + + expectThat(result).withSingleRow { get(0).isEqualTo(1) } + } + } +} diff --git a/components/api/database/tests/src/test/kotlin/se/dansdata/database/tests/ExtRefFacade.kt b/components/api/database/tests/src/test/kotlin/se/dansdata/database/tests/ExtRefFacade.kt new file mode 100644 index 0000000..02f4025 --- /dev/null +++ b/components/api/database/tests/src/test/kotlin/se/dansdata/database/tests/ExtRefFacade.kt @@ -0,0 +1,11 @@ +package se.dansdata.database.tests + +import java.net.URI +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +object ExtRefFacade { + @OptIn(ExperimentalUuidApi::class) + fun genericExtRefUri(id: String = Uuid.random().toHexString()): URI = + URI("dansdata.entity", "dansdata.se", "/v1/tests/generic_data_owner", "id=$id", "") +} diff --git a/components/api/database/tests/src/test/kotlin/se/dansdata/database/tests/Facade.kt b/components/api/database/tests/src/test/kotlin/se/dansdata/database/tests/Facade.kt new file mode 100644 index 0000000..a0feb63 --- /dev/null +++ b/components/api/database/tests/src/test/kotlin/se/dansdata/database/tests/Facade.kt @@ -0,0 +1,18 @@ +package se.dansdata.database.tests + +import java.sql.Connection +import java.sql.DriverManager +import java.util.* +import org.jetbrains.kotlinx.dataframe.io.DbConnectionConfig + +fun DbConnectionConfig.getConnection(): Connection = + DriverManager.getConnection( + url, + Properties().also { props -> + props.setProperty("user", this@getConnection.user) + props.setProperty("password", this@getConnection.password) + // Ensure EscapeSyntaxCallmode property is set to support procedures if there is no + // return value. + props.setProperty("escapeSyntaxCallMode", "callIfNoReturn") + }, + ) diff --git a/components/api/database/tests/src/test/kotlin/se/dansdata/database/tests/events_public/EventsPublicSchemaTests.kt b/components/api/database/tests/src/test/kotlin/se/dansdata/database/tests/events_public/EventsPublicSchemaTests.kt new file mode 100644 index 0000000..0d386a1 --- /dev/null +++ b/components/api/database/tests/src/test/kotlin/se/dansdata/database/tests/events_public/EventsPublicSchemaTests.kt @@ -0,0 +1,6 @@ +package se.dansdata.database.tests.events_public + +import se.dansdata.database.DbSchema +import se.dansdata.database.tests.AbstractSchemaTests + +class EventsPublicSchemaTests : AbstractSchemaTests(DbSchema.EventsPublic) diff --git a/components/api/database/tests/src/test/kotlin/se/dansdata/database/tests/translations/_private/TranslationsPrivateSchemaTests.kt b/components/api/database/tests/src/test/kotlin/se/dansdata/database/tests/translations/_private/TranslationsPrivateSchemaTests.kt new file mode 100644 index 0000000..475c05c --- /dev/null +++ b/components/api/database/tests/src/test/kotlin/se/dansdata/database/tests/translations/_private/TranslationsPrivateSchemaTests.kt @@ -0,0 +1,6 @@ +package se.dansdata.database.tests.translations._private + +import se.dansdata.database.DbSchema +import se.dansdata.database.tests.AbstractSchemaTests + +class TranslationsPrivateSchemaTests : AbstractSchemaTests(DbSchema.TranslationsPrivate) diff --git a/components/api/database/tests/src/test/kotlin/se/dansdata/database/tests/translations/_public/Facade.kt b/components/api/database/tests/src/test/kotlin/se/dansdata/database/tests/translations/_public/Facade.kt new file mode 100644 index 0000000..de525a6 --- /dev/null +++ b/components/api/database/tests/src/test/kotlin/se/dansdata/database/tests/translations/_public/Facade.kt @@ -0,0 +1,25 @@ +package se.dansdata.database.tests.translations._public + +import java.net.URI +import java.sql.Types +import java.util.UUID +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid +import kotlin.uuid.toKotlinUuid +import org.jetbrains.kotlinx.dataframe.io.DbConnectionConfig +import se.dansdata.database.tests.getConnection + +@OptIn(ExperimentalUuidApi::class) +fun DbConnectionConfig.allocateTranslation(owner: URI): Uuid = + getConnection().use { conn -> + conn + .prepareCall("{? = CALL translations_public.allocate_translation(?)}") + .apply { + registerOutParameter(1, Types.OTHER) + setString(2, owner.toString()) + } + .use { stmt -> + stmt.execute() + (stmt.getObject(1) as UUID).toKotlinUuid() + } + } diff --git a/components/api/database/tests/src/test/kotlin/se/dansdata/database/tests/translations/_public/ProcAllocateTranslationTests.kt b/components/api/database/tests/src/test/kotlin/se/dansdata/database/tests/translations/_public/ProcAllocateTranslationTests.kt new file mode 100644 index 0000000..fbc71fe --- /dev/null +++ b/components/api/database/tests/src/test/kotlin/se/dansdata/database/tests/translations/_public/ProcAllocateTranslationTests.kt @@ -0,0 +1,61 @@ +package se.dansdata.database.tests.translations._public + +import kotlin.uuid.ExperimentalUuidApi +import org.jetbrains.kotlinx.dataframe.io.readDataFrame +import org.junit.jupiter.api.Test +import se.dansdata.database.DansdataDbContainer +import se.dansdata.database.DbUser +import se.dansdata.database.strikt.get +import se.dansdata.database.strikt.isAUuid +import se.dansdata.database.strikt.withSingleRow +import se.dansdata.database.tests.ExtRefFacade +import strikt.api.expectThat +import strikt.assertions.isA +import strikt.assertions.isEqualTo + +@OptIn(ExperimentalUuidApi::class) +class ProcAllocateTranslationTests { + @Test + fun `Allocating a translation creates an extref and metadata`() = + DansdataDbContainer.use { + val extRefUri = ExtRefFacade.genericExtRefUri() + val translationId = queryAs(DbUser.AppBackstage) { allocateTranslation(extRefUri) } + val extRefResult = + queryAs(DbUser.DbOwner) { + readDataFrame( + // language=postgresql + """ + SELECT * + FROM + translations_private.ext_refs + WHERE + uri = '$extRefUri' + """ + .trimIndent() + ) + } + + expectThat(extRefResult).withSingleRow { + get("id") + .isA() + .get("matches metadata") { + val ownerId = this + queryAs(DbUser.DbOwner) { + readDataFrame( + // language=postgresql + """ + SELECT external_id + FROM + translations_private.metadatas + WHERE + owner_id = $ownerId + """ + .trimIndent() + ) + } + } + .withSingleRow { get("external_id").isAUuid().isEqualTo(translationId) } + get("uri").isA().isEqualTo(extRefUri.toString()) + } + } +} diff --git a/components/api/database/tests/src/test/kotlin/se/dansdata/database/tests/translations/_public/TranslationsPublicSchemaTests.kt b/components/api/database/tests/src/test/kotlin/se/dansdata/database/tests/translations/_public/TranslationsPublicSchemaTests.kt new file mode 100644 index 0000000..d935b39 --- /dev/null +++ b/components/api/database/tests/src/test/kotlin/se/dansdata/database/tests/translations/_public/TranslationsPublicSchemaTests.kt @@ -0,0 +1,6 @@ +package se.dansdata.database.tests.translations._public + +import se.dansdata.database.DbSchema +import se.dansdata.database.tests.AbstractSchemaTests + +class TranslationsPublicSchemaTests : AbstractSchemaTests(DbSchema.TranslationsPublic) diff --git a/components/api/database/tests/src/test/kotlin/se/dansdata/database/tests/translations/_public/TranslationsViewTests.kt b/components/api/database/tests/src/test/kotlin/se/dansdata/database/tests/translations/_public/TranslationsViewTests.kt new file mode 100644 index 0000000..ab929b0 --- /dev/null +++ b/components/api/database/tests/src/test/kotlin/se/dansdata/database/tests/translations/_public/TranslationsViewTests.kt @@ -0,0 +1,68 @@ +package se.dansdata.database.tests.translations._public + +import org.jetbrains.kotlinx.dataframe.io.readDataFrame +import org.junit.jupiter.api.Test +import se.dansdata.database.DansdataDbContainer +import se.dansdata.database.DbUser +import se.dansdata.database.strikt.get +import se.dansdata.database.strikt.hasRowCount +import se.dansdata.database.strikt.withSingleRow +import strikt.api.expectThat +import strikt.assertions.isA +import strikt.assertions.isEqualTo +import strikt.assertions.isGreaterThanOrEqualTo + +class TranslationsViewTests { + @Test + fun `Is accessible to backstage app user`() = + DansdataDbContainer.Shared.use { + val result = + queryAs(DbUser.AppBackstage) { + readDataFrame( + // language=postgresql + """ + SELECT + 1 + FROM + translations_public.translations + LIMIT 1 + """ + .trimIndent() + ) + } + + expectThat(result).hasRowCount(1) + } + + @Test + fun `Contains entries for all languages, in all languages`() = + DansdataDbContainer.Shared.use { + val result = + queryAs(DbUser.DbOwner) { + readDataFrame( + // language=postgresql + """ + SELECT + COUNT(DISTINCT l) AS language_count, + COUNT(DISTINCT m) AS metadata_count, + COUNT(DISTINCT t) AS translation_count + FROM + translations_private.languages l + INNER JOIN translations_private.metadatas m + ON m.id = l.name_id + INNER JOIN translations_public.translations t + ON t.id = m.external_id + """ + .trimIndent() + ) + } + + expectThat(result).withSingleRow { + val languageCount = + get("language_count").isA().isGreaterThanOrEqualTo(1).subject + val metadataCount = + get("metadata_count").isA().isGreaterThanOrEqualTo(1).subject + get("translation_count").isA().isEqualTo(languageCount * metadataCount) + } + } +} diff --git a/components/api/database/tests/src/test/resources/junit-platform.properties b/components/api/database/tests/src/test/resources/junit-platform.properties new file mode 100644 index 0000000..30abba9 --- /dev/null +++ b/components/api/database/tests/src/test/resources/junit-platform.properties @@ -0,0 +1,4 @@ +junit.jupiter.execution.parallel.enabled = true +junit.jupiter.execution.parallel.config.dynamic.factor = 1 +junit.jupiter.execution.parallel.mode.default = concurrent +junit.jupiter.execution.parallel.mode.classes.default = concurrent From 2589b9d6cb44472ed12b9f847398e88ad374b3ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20Zed=C3=A9n=20Yver=C3=A5s?= Date: Sat, 23 Nov 2024 16:36:47 +0100 Subject: [PATCH 2/2] ci(database): configure CI verifications --- .github/workflows/database-verify.yml | 39 +++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 .github/workflows/database-verify.yml diff --git a/.github/workflows/database-verify.yml b/.github/workflows/database-verify.yml new file mode 100644 index 0000000..d7ca799 --- /dev/null +++ b/.github/workflows/database-verify.yml @@ -0,0 +1,39 @@ +name: Database / Verify + +on: + push: + branches: ["main"] + paths: + - ".github/workflows/database-*" + - "components/api/database/**" + pull_request: + paths: + - ".github/workflows/database-*" + - "components/api/database/**" + workflow_dispatch: + +defaults: + run: + working-directory: components/api/database/tests + +jobs: + build: + name: "Test and Build" + runs-on: ubuntu-latest + # Local testing has proved that there are certain errors that can cause + # Testcontainers to stall. Make sure the job is aborted after a reasonable + # time in such cases! + timeout-minutes: 20 + permissions: + contents: read + + steps: + - uses: actions/checkout@v4 + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: "21" + distribution: "temurin" + cache: "gradle" + + - run: ./gradlew :check