From 9c541f433a424de3bc193c326902bd83a518cb8e Mon Sep 17 00:00:00 2001 From: Alexander Yevsyukov Date: Tue, 4 Aug 2026 19:22:57 +0100 Subject: [PATCH 01/10] Update `config` --- .claude/settings.json | 6 +- .github/workflows/build-on-ubuntu.yml | 2 +- .gitignore | 13 +- .idea/live-templates/README.md | 2 +- .idea/misc.xml | 44 ----- buildSrc/build.gradle.kts | 6 +- buildSrc/quality/checkstyle-suppressions.xml | 2 +- buildSrc/quality/checkstyle.xml | 2 +- buildSrc/settings.gradle.kts | 2 +- buildSrc/src/main/kotlin/BuildExtensions.kt | 43 ++++- buildSrc/src/main/kotlin/BuildSettings.kt | 2 +- .../src/main/kotlin/DependencyResolution.kt | 8 +- .../src/main/kotlin/DocumentationSettings.kt | 2 +- buildSrc/src/main/kotlin/DokkaExts.kt | 6 +- buildSrc/src/main/kotlin/Strings.kt | 2 +- .../src/main/kotlin/config-tester.gradle.kts | 2 +- .../kotlin/detekt-code-analysis.gradle.kts | 8 +- .../src/main/kotlin/dokka-setup.gradle.kts | 11 +- .../kotlin/io/spine/dependency/Dependency.kt | 13 +- .../kotlin/io/spine/dependency/boms/Boms.kt | 2 +- .../io/spine/dependency/boms/BomsPlugin.kt | 7 +- .../io/spine/dependency/build/ErrorProne.kt | 9 +- .../io/spine/dependency/kotlinx/KotlinX.kt | 2 +- .../spine/dependency/kotlinx/Serialization.kt | 2 +- .../io/spine/dependency/lib/ApacheHttp.kt | 2 +- .../io/spine/dependency/lib/AppEngine.kt | 2 +- .../kotlin/io/spine/dependency/lib/Asm.kt | 2 +- .../io/spine/dependency/lib/BouncyCastle.kt | 2 +- .../io/spine/dependency/lib/Coroutines.kt | 4 +- .../kotlin/io/spine/dependency/lib/Jackson.kt | 2 +- .../kotlin/io/spine/dependency/lib/JavaJwt.kt | 2 +- .../io/spine/dependency/lib/JavaPoet.kt | 2 +- .../kotlin/io/spine/dependency/lib/JavaX.kt | 2 +- .../kotlin/io/spine/dependency/lib/Klaxon.kt | 2 +- .../kotlin/io/spine/dependency/lib/KotlinX.kt | 2 +- .../io/spine/dependency/lib/Protobuf.kt | 2 +- .../kotlin/io/spine/dependency/lib/Roaster.kt | 7 - .../kotlin/io/spine/dependency/lib/Slf4J.kt | 4 +- .../kotlin/io/spine/dependency/local/Base.kt | 4 +- .../io/spine/dependency/local/BaseTypes.kt | 4 +- .../io/spine/dependency/local/Change.kt | 4 +- .../io/spine/dependency/local/Compiler.kt | 8 +- .../io/spine/dependency/local/CoreJvm.kt | 2 +- .../spine/dependency/local/CoreJvmCompiler.kt | 4 +- .../io/spine/dependency/local/Logging.kt | 2 +- .../io/spine/dependency/local/ProtoTap.kt | 2 +- .../io/spine/dependency/local/Reflect.kt | 2 +- .../kotlin/io/spine/dependency/local/Spine.kt | 2 +- .../io/spine/dependency/local/TestLib.kt | 2 +- .../kotlin/io/spine/dependency/local/Time.kt | 2 +- .../io/spine/dependency/local/ToolBase.kt | 4 +- .../io/spine/dependency/local/Validation.kt | 2 +- .../io/spine/dependency/test/AssertK.kt | 4 +- .../io/spine/dependency/test/Hamcrest.kt | 2 +- .../kotlin/io/spine/dependency/test/Jacoco.kt | 8 + .../kotlin/io/spine/dependency/test/Kotest.kt | 2 +- .../kotlin/io/spine/dependency/test/Kover.kt | 4 +- .../io/spine/dependency/test/OpenTest4J.kt | 2 +- .../io/spine/dependency/test/SystemLambda.kt | 2 +- .../io/spine/dependency/test/TestKitTruth.kt | 2 +- .../spine/dependency/test/Testcontainers.kt | 11 +- .../kotlin/io/spine/docs/MarkdownDocument.kt | 2 +- .../src/main/kotlin/io/spine/gradle/Build.kt | 2 +- .../src/main/kotlin/io/spine/gradle/Clean.kt | 2 +- .../kotlin/io/spine/gradle/ConfigTester.kt | 4 +- .../io/spine/gradle/ProjectExtensions.kt | 4 +- .../main/kotlin/io/spine/gradle/RunBuild.kt | 2 +- .../main/kotlin/io/spine/gradle/RunGradle.kt | 2 +- .../io/spine/gradle/StringExtensions.kt | 2 +- .../main/kotlin/io/spine/gradle/TaskName.kt | 2 +- .../io/spine/gradle/VersionGradleFile.kt | 33 +++- .../main/kotlin/io/spine/gradle/base/Tasks.kt | 2 +- .../gradle/checkstyle/CheckStyleConfig.kt | 2 +- .../io/spine/gradle/dart/DartContext.kt | 6 +- .../io/spine/gradle/dart/DartEnvironment.kt | 6 +- .../io/spine/gradle/dart/DartExtension.kt | 6 +- .../spine/gradle/dart/plugin/DartPlugins.kt | 6 +- .../io/spine/gradle/dart/plugin/Protobuf.kt | 4 +- .../kotlin/io/spine/gradle/dart/task/Build.kt | 12 +- .../io/spine/gradle/dart/task/DartTasks.kt | 2 +- .../spine/gradle/dart/task/IntegrationTest.kt | 8 +- .../io/spine/gradle/dart/task/Publish.kt | 8 +- .../kotlin/io/spine/gradle/fs/LazyTempPath.kt | 9 +- .../kotlin/io/spine/gradle/fs/SpineTempDir.kt | 92 +++++++++ .../main/kotlin/io/spine/gradle/git/Branch.kt | 2 +- .../kotlin/io/spine/gradle/git/Repository.kt | 16 +- .../kotlin/io/spine/gradle/git/UserInfo.kt | 2 +- .../spine/gradle/github/pages/AuthorEmail.kt | 2 +- .../io/spine/gradle/github/pages/Update.kt | 2 +- .../gradle/github/pages/UpdateGitHubPages.kt | 2 +- .../pages/UpdateGitHubPagesExtension.kt | 2 +- .../main/kotlin/io/spine/gradle/java/Tasks.kt | 2 +- .../io/spine/gradle/javac/ErrorProne.kt | 4 +- .../kotlin/io/spine/gradle/javac/Javac.kt | 2 +- .../io/spine/gradle/javadoc/Encoding.kt | 2 +- .../gradle/javadoc/ExcludeInternalDoclet.kt | 2 +- .../io/spine/gradle/javadoc/JavadocConfig.kt | 12 +- .../io/spine/gradle/javadoc/JavadocTag.kt | 2 +- .../io/spine/gradle/javascript/JsContext.kt | 8 +- .../spine/gradle/javascript/JsEnvironment.kt | 10 +- .../io/spine/gradle/javascript/JsExtension.kt | 8 +- .../io/spine/gradle/javascript/plugin/Idea.kt | 4 +- .../gradle/javascript/plugin/JsPlugins.kt | 6 +- .../io/spine/gradle/javascript/plugin/McJs.kt | 4 +- .../gradle/javascript/plugin/Protobuf.kt | 6 +- .../gradle/javascript/task/IntegrationTest.kt | 2 +- .../spine/gradle/javascript/task/Publish.kt | 2 +- .../io/spine/gradle/kotlin/KotlinConfig.kt | 20 +- .../gradle/publish/CloudArtifactRegistry.kt | 6 +- .../io/spine/gradle/publish/CloudRepo.kt | 2 +- .../io/spine/gradle/publish/ProtoExts.kt | 2 +- .../gradle/publish/PublicationHandler.kt | 2 +- .../io/spine/gradle/publish/PublishingExts.kt | 4 +- .../spine/gradle/publish/PublishingRepos.kt | 2 +- .../spine/gradle/publish/SpinePublishing.kt | 8 +- .../publish/StandardJavaPublicationHandler.kt | 4 +- .../io/spine/gradle/repo/Credentials.kt | 2 +- .../kotlin/io/spine/gradle/repo/RepoSlug.kt | 2 +- .../gradle/report/coverage/KoverConfig.kt | 74 +++++++- .../gradle/report/coverage/SiblingCoverage.kt | 8 +- .../gradle/report/license/Configuration.kt | 2 +- .../gradle/report/license/LicenseReporter.kt | 40 ++-- .../report/license/MarkdownReportRenderer.kt | 2 +- .../report/license/ProjectDependencies.kt | 2 +- .../io/spine/gradle/report/license/Tasks.kt | 4 +- .../spine/gradle/report/license/Template.kt | 2 +- .../gradle/report/pom/DependencyScope.kt | 6 +- .../gradle/report/pom/DependencyWriter.kt | 123 +++++++++--- .../spine/gradle/report/pom/InceptionYear.kt | 2 +- .../gradle/report/pom/MarkupExtensions.kt | 2 +- .../gradle/report/pom/ModuleDependency.kt | 2 +- .../spine/gradle/report/pom/PomFormatting.kt | 2 +- .../spine/gradle/report/pom/PomXmlWriter.kt | 4 +- .../gradle/report/pom/ProjectMetadata.kt | 38 ++-- .../spine/gradle/report/pom/SpineLicense.kt | 2 +- .../kotlin/io/spine/gradle/testing/Logging.kt | 2 +- .../io/spine/gradle/testing/Multiproject.kt | 4 +- .../gradle/testing/SpineCompilerCoverage.kt | 175 ++++++++++++++++++ .../spine/gradle/testing/TestKitCoverage.kt | 9 +- .../src/main/kotlin/jacoco-kmm-jvm.gradle.kts | 87 --------- .../main/kotlin/jacoco-kotlin-jvm.gradle.kts | 80 -------- .../src/main/kotlin/jvm-module.gradle.kts | 6 +- .../src/main/kotlin/kmp-module.gradle.kts | 26 ++- .../src/main/kotlin/kmp-publish.gradle.kts | 2 +- .../src/main/kotlin/module-testing.gradle.kts | 4 +- .../src/main/kotlin/pmd-settings.gradle.kts | 2 +- .../src/main/kotlin/test-module.gradle.kts | 2 +- .../main/kotlin/uber-jar-module.gradle.kts | 7 +- .../src/main/kotlin/write-manifest.gradle.kts | 6 +- .../resources/dokka/styles/custom-styles.css | 2 +- .../io/spine/gradle/VersionGradleFileSpec.kt | 22 +++ .../io/spine/gradle/fs/LazyTempPathSpec.kt | 69 +++++++ .../io/spine/gradle/fs/SpineTempDirSpec.kt | 54 ++++++ .../gradle/report/pom/DependencyWriterSpec.kt | 113 +++++++++++ config | 2 +- gradle/wrapper/gradle-wrapper.properties | 2 +- gradlew | 4 +- gradlew.bat | 4 +- version.gradle.kts | 2 +- 159 files changed, 1126 insertions(+), 567 deletions(-) delete mode 100644 .idea/misc.xml create mode 100644 buildSrc/src/main/kotlin/io/spine/gradle/fs/SpineTempDir.kt create mode 100644 buildSrc/src/main/kotlin/io/spine/gradle/testing/SpineCompilerCoverage.kt delete mode 100644 buildSrc/src/main/kotlin/jacoco-kmm-jvm.gradle.kts delete mode 100644 buildSrc/src/main/kotlin/jacoco-kotlin-jvm.gradle.kts create mode 100644 buildSrc/src/test/kotlin/io/spine/gradle/fs/LazyTempPathSpec.kt create mode 100644 buildSrc/src/test/kotlin/io/spine/gradle/fs/SpineTempDirSpec.kt diff --git a/.claude/settings.json b/.claude/settings.json index 357650cf7..3c7da68c3 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,5 +1,6 @@ { "$schema": "https://json.schemastore.org/claude-code-settings.json", + "plansDirectory": ".claude/plans", "permissions": { "allow": [ "Edit(version.gradle.kts)", @@ -32,8 +33,11 @@ "Bash(mkdir:*)", "Bash(touch:*)", "Bash(python3 .agents/skills/update-copyright/scripts/update_copyright.py:*)", + "Bash(.agents/skills/version-bumped/scripts/version-bumped.sh)", "Bash(./config/pull)", - "Bash(./config/migrate)" + "Bash(./config/migrate)", + "Skill(pre-pr)", + "Skill(pre-pr:*)" ], "deny": [ "Bash(git reset --hard:*)", diff --git a/.github/workflows/build-on-ubuntu.yml b/.github/workflows/build-on-ubuntu.yml index 01f6fd71a..b07bf0fb0 100644 --- a/.github/workflows/build-on-ubuntu.yml +++ b/.github/workflows/build-on-ubuntu.yml @@ -87,7 +87,7 @@ jobs: # there anyway). - name: Upload code coverage report if: steps.codecov.outputs.available == 'true' || github.event_name == 'push' - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v7 with: token: ${{ secrets.CODECOV_TOKEN }} fail_ci_if_error: true diff --git a/.gitignore b/.gitignore index 8d487a22b..519d2b783 100644 --- a/.gitignore +++ b/.gitignore @@ -54,8 +54,12 @@ .idea/modules .idea/shelf +# `.idea/misc.xml` is intentionally NOT re-included below. It is project-local — +# it holds the per-project JDK name and IDEA's own churn (entry-point list +# indices, external-storage toggles) — so `.idea/*.xml` above keeps it ignored. +# `./config/pull` (via `migrate`) untracks any copy an earlier pull committed. + # Do not ignore the following IDEA settings -!.idea/misc.xml !.idea/codeStyleSettings.xml !.idea/codeStyles/ !.idea/copyright/ @@ -173,6 +177,12 @@ __pycache__/ # Claude working files /.claude/worktrees/ +# Ephemeral plan-mode scratch (durable task docs live in `.agents/tasks/`). +/.claude/plans/ + +# Personal, per-developer Claude Code settings overrides (never committed; +# the distributed `.claude/settings.json` is the shared, committed layer). +/.claude/settings.local.json # Auto-downloaded Lychee binary used by the `check-links` skill. /.agents/skills/check-links/.cache/ @@ -192,7 +202,6 @@ docs/_preview/resources/ # >>> repo-local entries (preserved across ./config/pull) >>> # Copyright 2023, TeamDev. All rights reserved. # http://www.apache.org/licenses/LICENSE-2.0 -!.idea/misc.xml !.idea/codeStyleSettings.xml !.idea/codeStyles/ !.idea/copyright/ diff --git a/.idea/live-templates/README.md b/.idea/live-templates/README.md index 950066731..267008ad1 100644 --- a/.idea/live-templates/README.md +++ b/.idea/live-templates/README.md @@ -22,6 +22,6 @@ In order to add these templates, perform the following steps: 1. Open the corresponding template: `Preferences -> Editor -> Live Templates -> User.todo`. 2. Click on `Edit variables`. -3. Set `USER` variable to your domain email address without `@teamdev.com` ending. For example, +3. Set `USER` variable to your domain email address without `@teamdev.com` ending. For example, for `jack.sparrow@teamdev.com` use the following expression `"jack.sparrow"`. 4. Verify that the template generates expected comments: `// TODO:2022-11-03:jack.sparrow: <...>`. diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index 264f82301..000000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,44 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index a61d839f8..5b377272e 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -132,7 +132,7 @@ val kotestJvmPluginVersion = "0.4.10" /** * @see [io.spine.dependency.test.Kover] */ -val koverVersion = "0.9.8" +val koverVersion = "0.9.9" /** * The version of the Shadow Plugin. @@ -222,7 +222,7 @@ dependOnBuildSrcJar() /** * Adds a dependency on a `buildSrc.jar`, iff: * 1) the `src` folder is missing, and - * 2) `buildSrc.jar` is present in `buildSrc/` folder instead. + * 2) `buildSrc.jar` is present in the `buildSrc/` folder instead. * * This approach is used in the scope of integration testing. */ @@ -241,7 +241,7 @@ fun Project.dependOnBuildSrcJar() { * Includes the `implementation` dependency on `artifactregistry-auth-common`, * with the version defined in [googleAuthToolVersion]. * - * `artifactregistry-auth-common` has transitive dependency on Gson and Apache `commons-codec`. + * `artifactregistry-auth-common` has a transitive dependency on Gson and Apache `commons-codec`. * Gson from version `2.8.6` until `2.8.9` is vulnerable to Deserialization of Untrusted Data * (https://devhub.checkmarx.com/cve-details/CVE-2022-25647/). * diff --git a/buildSrc/quality/checkstyle-suppressions.xml b/buildSrc/quality/checkstyle-suppressions.xml index a761fca35..9d169de7f 100644 --- a/buildSrc/quality/checkstyle-suppressions.xml +++ b/buildSrc/quality/checkstyle-suppressions.xml @@ -1,7 +1,7 @@ io.spine jdbc-storage -2.0.0-SNAPSHOT.104 +2.0.0-SNAPSHOT.110 2015 @@ -38,19 +38,19 @@ all modules and does not describe the project structure per-subproject. io.spine spine-server - 2.0.0-SNAPSHOT.380 + 2.0.0-SNAPSHOT.522 compile io.spine spine-time - 2.0.0-SNAPSHOT.242 + 2.0.0-SNAPSHOT.250 compile io.spine spine-validation-jvm-runtime - 2.0.0-SNAPSHOT.446 + 2.0.0-SNAPSHOT.460 compile @@ -116,7 +116,7 @@ all modules and does not describe the project structure per-subproject. io.spine.tools server-testlib - 2.0.0-SNAPSHOT.380 + 2.0.0-SNAPSHOT.522 test @@ -199,7 +199,7 @@ all modules and does not describe the project structure per-subproject. com.google.errorprone error_prone_core - 2.36.0 + 2.42.0 com.google.protobuf @@ -224,17 +224,17 @@ all modules and does not describe the project structure per-subproject. io.spine.tools compiler-cli-all - 2.0.0-SNAPSHOT.054 + 2.0.0-SNAPSHOT.064 io.spine.tools compiler-protoc-plugin - 2.0.0-SNAPSHOT.054 + 2.0.0-SNAPSHOT.064 io.spine.tools core-jvm-plugins - 2.0.0-SNAPSHOT.079 + 2.0.0-SNAPSHOT.080 io.spine.tools @@ -249,7 +249,7 @@ all modules and does not describe the project structure per-subproject. io.spine.tools validation-java-bundle - 2.0.0-SNAPSHOT.446 + 2.0.0-SNAPSHOT.460 net.sourceforge.pmd @@ -301,6 +301,11 @@ all modules and does not describe the project structure per-subproject. templating-plugin 2.2.0 + + org.jetbrains.kotlin + abi-tools + 2.3.21 + org.jetbrains.kotlin kotlin-build-tools-compat @@ -311,6 +316,11 @@ all modules and does not describe the project structure per-subproject. kotlin-build-tools-impl 2.3.21 + + org.jetbrains.kotlin + kotlin-klib-commonizer-embeddable + 2.3.21 + org.jetbrains.kotlin kotlin-scripting-compiler-embeddable diff --git a/docs/tables.md b/docs/tables.md index 51f6256a2..62ecd81ae 100644 --- a/docs/tables.md +++ b/docs/tables.md @@ -33,6 +33,45 @@ Each table created has the following structure: :warning: The framework does **not** verify the table structure for existing tables. +## Grouped tables + +Several storages of a Bounded Context may hold records of the same type. +For example, the per-entity histories introduced with Spine 2.x: + +* the event journal of an entity type (`EntityEventStorage`) stores `Event`s — + just as the journals of all other entity types, and the event log + of the Bounded Context; +* the state history of an entity type (`EntityStateHistoryStorage`) stores + `EntityRecord`s — just as the latest-state storage of the same entity type. + +To keep such storages apart, the framework passes a `StorageGroup` when creating them, +named after the state type of the served entity. This library allocates a separate table +per the combination of the record specification and the group. + +The name of a grouped table is composed of the group name and the simple name +of the stored record type: + +``` +(group name + record type name) -> (replace `.` with `_`, join with `_`) -> result +``` + +E.g. for an Entity with the state declared by `bar.acme.Project`, the tables are: + +| Storage | Table | +|--------------------------|---------------------------------| +| Latest state (ungrouped) | `bar_acme_Project` | +| Event journal | `bar_acme_Project_Event` | +| State history | `bar_acme_Project_EntityRecord` | + +Grouped tables have the same structure as the ungrouped ones: the `ID` and `bytes` +columns, plus the columns declared for the stored record type — for both histories, +these are `entity_id`, `created`, and `version`. + +:warning: Group names are the fully-qualified names of Proto types, so the names +of grouped tables run longer than the ungrouped ones. Mind the identifier length +limits of the underlying DB engine — e.g., 64 characters on MySQL — +when naming the Proto packages of entity states. + ## Adding new `(column)` In scope of development cycle, there may arise a need to modify the declaration of @@ -51,8 +90,7 @@ public static final class MyProjection } var boundedContextSpec = // ... -var factory = JdbcStorageFactory - .newBuilder() +var factory = JdbcStorageFactory.newBuilder() // ... .build(); @@ -95,9 +133,7 @@ It is possible to configure several aspects: public final class TaskProjection extends Projection { ... } -var factory = JdbcStorageFactory - .newBuilder() - +var factory = JdbcStorageFactory.newBuilder() // ... // Uses the record type to set the name for its table: @@ -110,6 +146,11 @@ var factory = JdbcStorageFactory .build(); ``` +:warning: Custom table names apply only to the storages outside any `StorageGroup`. +The [grouped tables](#grouped-tables) — such as the per-entity histories — are always +named after the group and the record type: the custom names are registered per record type, +which cannot tell apart the storages of one group from another. + * column type mapping, per type of stored records: ```java diff --git a/rdbms/src/main/java/io/spine/server/storage/jdbc/JdbcStorageFactory.java b/rdbms/src/main/java/io/spine/server/storage/jdbc/JdbcStorageFactory.java index 085c26495..daa5ed9fa 100644 --- a/rdbms/src/main/java/io/spine/server/storage/jdbc/JdbcStorageFactory.java +++ b/rdbms/src/main/java/io/spine/server/storage/jdbc/JdbcStorageFactory.java @@ -1,11 +1,11 @@ /* - * Copyright 2023, TeamDev. All rights reserved. + * Copyright 2026, TeamDev. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * https://www.apache.org/licenses/LICENSE-2.0 * * Redistribution and use in source and/or binary forms, with or without * modification, must retain the above copyright notice and the following @@ -78,10 +78,19 @@ private JdbcStorageFactory(Builder builder) { /** * Creates a new storage for records. * + *

The records are stored in an RDBMS table, the identity of which is composed + * of the passed record specification and the group. In particular, the storages + * belonging to distinct groups are allocated their own tables, even if they store + * records of the same type. + * * @param context * the bounded context within which the storage is being configured * @param spec * the record specification for the stored record + * @param group + * the group telling this storage apart from the other storages + * holding records of the same type, + * or {@code null} if the storage belongs to no particular group * @param * type of the record identifiers * @param @@ -91,7 +100,7 @@ private JdbcStorageFactory(Builder builder) { @Override public RecordStorage createRecordStorage(ContextSpec context, RecordSpec spec, @Nullable StorageGroup group) { - var result = new JdbcRecordStorage<>(context, spec, this); + var result = new JdbcRecordStorage<>(context, spec, this, group); return result; } @@ -179,7 +188,8 @@ public final DataSourceWrapper dataSource() { } /** - * Returns the DB table specification for the passed record specification. + * Returns the DB table specification for the passed record specification, + * for a storage belonging to no {@link StorageGroup}. * *

Takes into account the {@linkplain Builder#setCustomMapping(Class, JdbcColumnMapping) * custom mapping} and the {@linkplain Builder#setTableName(Class, String) custom table name} @@ -191,10 +201,38 @@ public final DataSourceWrapper dataSource() { * type of the identifiers of the described record * @param * type of the described record - * @return a new instance of table specification + * @return the table specification */ public JdbcTableSpec tableSpecFor(RecordSpec spec) { - var tableSpec = tableSpecs.specFor(spec, columnMapping); + return tableSpecFor(spec, null); + } + + /** + * Returns the DB table specification for the passed record specification + * and the storage group. + * + *

Takes into account the {@linkplain Builder#setCustomMapping(Class, JdbcColumnMapping) + * custom mapping} set for the records of target type. + * + *

For the storages belonging to no group, the + * {@linkplain Builder#setTableName(Class, String) custom table name} is applied as well. + * The tables of grouped storages are always named after the group and the record type; + * see {@link io.spine.server.storage.jdbc.record.TableNames#of(Class, StorageGroup) + * TableNames.of(Class, StorageGroup)}. + * + * @param spec + * record specification + * @param group + * the group to which the storage belongs, or {@code null} if it belongs to none + * @param + * type of the identifiers of the described record + * @param + * type of the described record + * @return the table specification + */ + public JdbcTableSpec + tableSpecFor(RecordSpec spec, @Nullable StorageGroup group) { + var tableSpec = tableSpecs.specFor(spec, group, columnMapping); return tableSpec; } @@ -302,6 +340,12 @@ public Builder setTypeMapping(TypeMapping typeMapping) { * a {@linkplain io.spine.server.storage.jdbc.record.TableNames#of(Class) default name} * is used. * + *

The custom name applies only to the storages belonging to no + * {@link io.spine.server.storage.StorageGroup StorageGroup}. The tables of grouped + * storages — such as the per-entity histories — are always named after the group + * and the record type, as a single per-record-type name cannot tell + * the storages of different groups apart. + * * @param recordType * the type of the stored record * @param name diff --git a/rdbms/src/main/java/io/spine/server/storage/jdbc/aggregate/package-info.java b/rdbms/src/main/java/io/spine/server/storage/jdbc/aggregate/package-info.java deleted file mode 100644 index 6fb0dda78..000000000 --- a/rdbms/src/main/java/io/spine/server/storage/jdbc/aggregate/package-info.java +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright 2023, TeamDev. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Redistribution and use in source and/or binary forms, with or without - * modification, must retain the above copyright notice and the following - * disclaimer. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ - -/** - * The JDBC-based implementation of the - * {@link io.spine.server.aggregate.AggregateStorage AggregateStorage}. - */ -@CheckReturnValue -@ParametersAreNonnullByDefault -package io.spine.server.storage.jdbc.aggregate; - -import com.google.errorprone.annotations.CheckReturnValue; - -import javax.annotation.ParametersAreNonnullByDefault; diff --git a/rdbms/src/main/java/io/spine/server/storage/jdbc/config/TableSpecs.java b/rdbms/src/main/java/io/spine/server/storage/jdbc/config/TableSpecs.java index d14f42af1..e6dd47b56 100644 --- a/rdbms/src/main/java/io/spine/server/storage/jdbc/config/TableSpecs.java +++ b/rdbms/src/main/java/io/spine/server/storage/jdbc/config/TableSpecs.java @@ -1,11 +1,11 @@ /* - * Copyright 2023, TeamDev. All rights reserved. + * Copyright 2026, TeamDev. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * https://www.apache.org/licenses/LICENSE-2.0 * * Redistribution and use in source and/or binary forms, with or without * modification, must retain the above copyright notice and the following @@ -31,6 +31,7 @@ import com.google.protobuf.Message; import io.spine.annotation.Internal; import io.spine.server.storage.RecordSpec; +import io.spine.server.storage.StorageGroup; import io.spine.server.storage.jdbc.record.JdbcTableSpec; import io.spine.server.storage.jdbc.record.TableNames; import io.spine.server.storage.jdbc.type.JdbcColumnMapping; @@ -38,20 +39,26 @@ import java.util.HashMap; import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; import static com.google.common.base.Preconditions.checkNotNull; import static io.spine.util.Preconditions2.checkNotEmptyOrBlank; -import static java.util.Objects.requireNonNull; /** * The set of custom database table settings as configured by the library users, * per type of the stored record. + * + *

Also serves as a cache of the table specifications created for the record + * specifications passed to {@link #specFor(RecordSpec, StorageGroup, JdbcColumnMapping) + * specFor(..)}. Each table is identified by the combination of the source type + * and the record type of the record specification, along with the name of + * the {@link StorageGroup}, if any. */ @Internal public final class TableSpecs { private final ImmutableMap, String> names; - private final Map, JdbcTableSpec> tables = new HashMap<>(); + private final Map> tables = new ConcurrentHashMap<>(); private final ImmutableMap, JdbcColumnMapping> columnMappings; @@ -65,55 +72,91 @@ private TableSpecs(Builder builder) { /** * Provides the table specification based upon the original record specification, - * and the user-defined configuration previously made with + * the storage group, and the user-defined configuration previously made with * this instance of {@code TableSpecs}, such as table name and custom column mapping. * - *

In case no custom table name was specified, - * a {@linkplain io.spine.server.storage.jdbc.record.TableNames#of(Class) + *

The specifications are cached, so that equal combinations of the source type, + * the record type, and the group always resolve to the same table. This method + * tolerates concurrent invocations, as some storages are created lazily + * on worker threads. + * + *

For the storages belonging to no group, in case no custom table name + * was specified, a {@linkplain io.spine.server.storage.jdbc.record.TableNames#of(Class) * default one} is used. * + *

The tables of grouped storages are always named after + * the {@linkplain io.spine.server.storage.jdbc.record.TableNames#of(Class, StorageGroup) + * group and the record type}. Custom table names do not apply to them: such names + * are registered per record type, which cannot tell apart the storages + * of one group from another — e.g., the event journals of all entity types + * store records of the same {@code Event} type. + * *

If no custom column mapping was set previously, * the default mapping passed to this method is used. * * @param spec * the original record specification + * @param group + * the group to which the storage belongs, or {@code null} if it belongs to none * @param defaultMapping * the column mapping to use if no custom mapping is specified for the table * @param * type of the identifiers of the records to store in the table * @param * type of the records stored in the table - * @return a new table specification + * @return the table specification */ public JdbcTableSpec - specFor(RecordSpec spec, JdbcColumnMapping defaultMapping) { - var recordType = spec.sourceType(); - if (!tables.containsKey(recordType)) { - var tableSpec = newTableSpec(spec, defaultMapping); - tables.put(recordType, tableSpec); - } + specFor(RecordSpec spec, @Nullable StorageGroup group, JdbcColumnMapping defaultMapping) { + var key = SpecKey.of(spec, group); + var tableSpec = tables.computeIfAbsent(key, k -> newTableSpec(spec, group, defaultMapping)); @SuppressWarnings("unchecked") - var result = (JdbcTableSpec) tables.get(recordType); - return requireNonNull(result); + var result = (JdbcTableSpec) tableSpec; + return result; } private JdbcTableSpec - newTableSpec(RecordSpec spec, JdbcColumnMapping defaultMapping) { - var recordType = spec.recordType(); - @Nullable String customName = findName(recordType); - @Nullable JdbcColumnMapping customMapping = findMapping(recordType); - - JdbcTableSpec tableSpec; - var tableName = customName == null - ? TableNames.of(spec.sourceType()) - : customName; - + newTableSpec(RecordSpec spec, + @Nullable StorageGroup group, + JdbcColumnMapping defaultMapping) { + @Nullable JdbcColumnMapping customMapping = findMapping(spec.recordType()); var mapping = customMapping == null ? defaultMapping : customMapping; + var tableName = tableName(spec, group); + return new JdbcTableSpec<>(tableName, spec, mapping); + } - tableSpec = new JdbcTableSpec<>(tableName, spec, mapping); - return tableSpec; + private String tableName(RecordSpec spec, @Nullable StorageGroup group) { + if (group != null) { + return TableNames.of(spec.recordType(), group); + } + @Nullable String customName = findName(spec.recordType()); + return customName == null + ? TableNames.of(spec.sourceType()) + : customName; + } + + /** + * The identity of a table: the source and the record types of the stored records, + * and the name of the storage group, if any. + * + * @param sourceType + * the source type of the record specification + * @param recordType + * the type of the stored records + * @param group + * the name of the storage group, + * or {@code null} if the storage belongs to no group + */ + private record SpecKey(Class sourceType, + Class recordType, + @Nullable String group) { + + private static SpecKey of(RecordSpec spec, @Nullable StorageGroup group) { + var groupName = group == null ? null : group.getName(); + return new SpecKey(spec.sourceType(), spec.recordType(), groupName); + } } private @Nullable String findName(Class recordType) { diff --git a/rdbms/src/main/java/io/spine/server/storage/jdbc/record/JdbcRecordStorage.java b/rdbms/src/main/java/io/spine/server/storage/jdbc/record/JdbcRecordStorage.java index a5fdb346e..f7e3e60cc 100644 --- a/rdbms/src/main/java/io/spine/server/storage/jdbc/record/JdbcRecordStorage.java +++ b/rdbms/src/main/java/io/spine/server/storage/jdbc/record/JdbcRecordStorage.java @@ -1,11 +1,11 @@ /* - * Copyright 2023, TeamDev. All rights reserved. + * Copyright 2026, TeamDev. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * https://www.apache.org/licenses/LICENSE-2.0 * * Redistribution and use in source and/or binary forms, with or without * modification, must retain the above copyright notice and the following @@ -36,7 +36,9 @@ import io.spine.server.storage.RecordSpec; import io.spine.server.storage.RecordStorage; import io.spine.server.storage.RecordWithColumns; +import io.spine.server.storage.StorageGroup; import io.spine.server.storage.jdbc.JdbcStorageFactory; +import org.jspecify.annotations.Nullable; import java.util.Iterator; @@ -57,8 +59,8 @@ public class JdbcRecordStorage extends RecordStorage private final RecordTable table; /** - * Creates a new record storage, and performs the creation of RDBMS table, - * in case such a table does not exist. + * Creates a new record storage for the records belonging to no storage group, + * and performs the creation of RDBMS table, in case such a table does not exist. * * @param contextSpec * specification of Bounded Context, in scope of which this storage exists @@ -73,10 +75,60 @@ public JdbcRecordStorage(ContextSpec contextSpec, this(contextSpec, recordSpec, factory, true); } + /** + * Creates a new record storage, and performs the creation of RDBMS table, + * in case such a table does not exist. + * + *

The passed storage group, if any, participates in the identity of the table, + * telling this storage apart from the other storages of records of the same type. + * + * @param contextSpec + * specification of Bounded Context, in scope of which this storage exists + * @param recordSpec + * specification of stored records + * @param factory + * storage factory, in which scope this storage acts + * @param group + * the group to which this storage belongs, + * or {@code null} if it belongs to no group + */ + public JdbcRecordStorage(ContextSpec contextSpec, + RecordSpec recordSpec, + JdbcStorageFactory factory, + @Nullable StorageGroup group) { + this(contextSpec, recordSpec, factory, group, true); + } + + /** + * Creates a new record storage for the records belonging to no storage group, and + * when asked, performs the creation of RDBMS table if such a table does not exist. + * + *

This constructor is internal to the framework. + * + * @param contextSpec + * specification of Bounded Context, in scope of which this storage exists + * @param recordSpec + * specification of stored records + * @param factory + * storage factory, in which scope this storage acts + * @param createTable + * whether to create the RDBMS table right away + */ + @Internal + public JdbcRecordStorage(ContextSpec contextSpec, + RecordSpec recordSpec, + JdbcStorageFactory factory, + boolean createTable) { + this(contextSpec, recordSpec, factory, null, createTable); + } + /** * Creates a new record storage, and when asked, performs the creation of RDBMS table * if such a table does not exist. * + *

The passed storage group, if any, participates in the identity of the table, + * telling this storage apart from the other storages of records of the same type. + * *

This constructor is internal to the framework. * * @param contextSpec @@ -85,6 +137,9 @@ public JdbcRecordStorage(ContextSpec contextSpec, * specification of stored records * @param factory * storage factory, in which scope this storage acts + * @param group + * the group to which this storage belongs, + * or {@code null} if it belongs to no group * @param createTable * whether to create the RDBMS table right away */ @@ -92,9 +147,10 @@ public JdbcRecordStorage(ContextSpec contextSpec, public JdbcRecordStorage(ContextSpec contextSpec, RecordSpec recordSpec, JdbcStorageFactory factory, + @Nullable StorageGroup group, boolean createTable) { super(contextSpec, recordSpec); - var tableSpec = factory.tableSpecFor(recordSpec); + var tableSpec = factory.tableSpecFor(recordSpec, group); this.table = RecordTable.by(tableSpec, factory); if (createTable) { this.table.create(); diff --git a/rdbms/src/main/java/io/spine/server/storage/jdbc/record/TableNames.java b/rdbms/src/main/java/io/spine/server/storage/jdbc/record/TableNames.java index 1fdedb22c..acc11b3a5 100644 --- a/rdbms/src/main/java/io/spine/server/storage/jdbc/record/TableNames.java +++ b/rdbms/src/main/java/io/spine/server/storage/jdbc/record/TableNames.java @@ -1,11 +1,11 @@ /* - * Copyright 2023, TeamDev. All rights reserved. + * Copyright 2026, TeamDev. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * https://www.apache.org/licenses/LICENSE-2.0 * * Redistribution and use in source and/or binary forms, with or without * modification, must retain the above copyright notice and the following @@ -28,6 +28,7 @@ import com.google.protobuf.Message; import io.spine.protobuf.Messages; +import io.spine.server.storage.StorageGroup; import java.util.regex.Pattern; @@ -68,4 +69,29 @@ public static String of(Class cls) { var result = preparedPackage + TABLE_NAME_DELIMITER + cls.getSimpleName(); return result; } + + /** + * Composes the name for the table storing the records of a storage which belongs + * to a {@link StorageGroup}. + * + *

Several storages of a Bounded Context may hold records of the same type — e.g., + * the event journals of distinct entity types all store {@code Event}s. The group, + * named by the framework after the state type of the served entity, is what tells + * such storages apart. Therefore, the table name is composed of the group name + * and the simple name of the stored record type, so that each {@code (group, record type)} + * pair maps to its own table. {@code _} symbol is used for joining the parts, + * and for the replacement of prohibited {@code .} symbols in the group name. + * + *

For instance, for the event journal of an entity with the state type + * {@code spine.test.storage.StgProject}, storing {@code spine.core.Event} records, + * the table name would be {@code spine_test_storage_StgProject_Event}. + */ + public static String of(Class recordType, StorageGroup group) { + checkNotNull(recordType); + checkNotNull(group); + var preparedGroup = PACKAGE_DOT.matcher(group.getName()) + .replaceAll(TABLE_NAME_DELIMITER); + var result = preparedGroup + TABLE_NAME_DELIMITER + recordType.getSimpleName(); + return result; + } } diff --git a/rdbms/src/test/java/io/spine/server/storage/jdbc/record/TableNamesTest.java b/rdbms/src/test/java/io/spine/server/storage/jdbc/record/TableNamesTest.java index 5d8fcf03f..c42bcbc9c 100644 --- a/rdbms/src/test/java/io/spine/server/storage/jdbc/record/TableNamesTest.java +++ b/rdbms/src/test/java/io/spine/server/storage/jdbc/record/TableNamesTest.java @@ -1,11 +1,11 @@ /* - * Copyright 2023, TeamDev. All rights reserved. + * Copyright 2026, TeamDev. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * https://www.apache.org/licenses/LICENSE-2.0 * * Redistribution and use in source and/or binary forms, with or without * modification, must retain the above copyright notice and the following @@ -26,7 +26,10 @@ package io.spine.server.storage.jdbc.record; +import com.google.common.testing.NullPointerTester; import com.google.common.testing.NullPointerTester.Visibility; +import io.spine.core.Event; +import io.spine.server.storage.StorageGroup; import io.spine.test.storage.StgProject; import io.spine.testing.UtilityClassTest; import org.junit.jupiter.api.DisplayName; @@ -42,6 +45,12 @@ final class TableNamesTest extends UtilityClassTest { super(TableNames.class, Visibility.PACKAGE); } + @Override + protected void configure(NullPointerTester tester) { + super.configure(tester); + tester.setDefault(StorageGroup.class, new StorageGroup("spine.test.storage.StgProject")); + } + @Test @DisplayName("produce same name for same class") void produceSameForSameClass() { @@ -55,4 +64,13 @@ void reflectRecordName() { assertThat(actual). isEqualTo("spine_test_storage_StgProject"); } + + @Test + @DisplayName("compose the name of a grouped table from the group and the record type") + void composeGroupedName() { + var group = new StorageGroup("spine.test.storage.StgProject"); + var actual = TableNames.of(Event.class, group); + assertThat(actual) + .isEqualTo("spine_test_storage_StgProject_Event"); + } } diff --git a/rdbms/src/test/kotlin/io/spine/server/storage/jdbc/record/ConcurrentHistoryCreationSpec.kt b/rdbms/src/test/kotlin/io/spine/server/storage/jdbc/record/ConcurrentHistoryCreationSpec.kt new file mode 100644 index 000000000..260485fea --- /dev/null +++ b/rdbms/src/test/kotlin/io/spine/server/storage/jdbc/record/ConcurrentHistoryCreationSpec.kt @@ -0,0 +1,124 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package io.spine.server.storage.jdbc.record + +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.shouldBe +import io.spine.base.Identifier +import io.spine.base.Time.currentTime +import io.spine.core.Versions +import io.spine.protobuf.AnyPacker +import io.spine.server.entity.entityRecord +import io.spine.server.storage.jdbc.JdbcStorageFactory +import io.spine.server.storage.jdbc.given.JdbcStorageFactoryTestEnv.StgProjectAggregate +import io.spine.server.storage.jdbc.record.given.HistoryStorageTestEnv +import io.spine.server.storage.jdbc.record.given.HistoryStorageTestEnv.StgTaskEntity +import io.spine.server.storage.jdbc.record.given.HistoryStorageTestEnv.h2Factory +import io.spine.test.storage.stgProject +import io.spine.test.storage.stgProjectId +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +/** + * Tests that [JdbcStorageFactory] tolerates the concurrent creation of the storages + * sharing a table. + * + * The framework may create a state history storage lazily — upon the first dispatch + * to a recording repository, on a delivery worker thread — concurrently with + * the storage creation of other repositories. See the API note of + * `StorageFactory.createEntityStateHistoryStorage`. + */ +@DisplayName("`JdbcStorageFactory`, on concurrent storage creation, should") +internal class ConcurrentHistoryCreationSpec { + + private lateinit var factory: JdbcStorageFactory + + @BeforeEach + fun createFactory() { + factory = h2Factory() + } + + @AfterEach + fun closeFactory() { + if (this::factory.isInitialized && factory.isOpen) { + factory.close() + } + } + + @Test + fun `create each storage successfully, converging on one table per storage group`() { + val context = HistoryStorageTestEnv.context() + val threads = 8 + val started = CountDownLatch(1) + val executor = Executors.newFixedThreadPool(threads) + try { + val creations = (1..threads).map { index -> + executor.submit { + started.await() + if (index % 2 == 0) { + factory.createEntityStateHistoryStorage( + context, StgProjectAggregate::class.java + ) + } else { + factory.createEntityEventStorage(context, StgTaskEntity::class.java) + } + } + } + started.countDown() + + // Each creation either succeeds or the test fails with the thrown cause. + creations.forEach { it.get(30, TimeUnit.SECONDS) } + } finally { + executor.shutdownNow() + } + + // The concurrently created storages converge on one physical table: + // a record written through a fresh storage instance is visible to another. + val entityId = stgProjectId { id = "concurrently-tracked" } + val record = entityRecord { + this.entityId = Identifier.pack(entityId) + state = AnyPacker.pack(stgProject { id = entityId }) + version = Versions.newVersion(1, currentTime()) + } + val writer = factory.createEntityStateHistoryStorage( + context, StgProjectAggregate::class.java + ) + val reader = factory.createEntityStateHistoryStorage( + context, StgProjectAggregate::class.java + ) + writer.write(record) + reader.historyBackward(entityId, batchSize = 1) + .asSequence() + .toList() shouldContainExactly listOf(record) + reader.stateAt(entityId, currentTime()) shouldBe record + } +} diff --git a/rdbms/src/test/kotlin/io/spine/server/storage/jdbc/record/GroupedTableAllocationSpec.kt b/rdbms/src/test/kotlin/io/spine/server/storage/jdbc/record/GroupedTableAllocationSpec.kt new file mode 100644 index 000000000..19df93655 --- /dev/null +++ b/rdbms/src/test/kotlin/io/spine/server/storage/jdbc/record/GroupedTableAllocationSpec.kt @@ -0,0 +1,194 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package io.spine.server.storage.jdbc.record + +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe +import io.spine.base.Identifier +import io.spine.core.Event +import io.spine.core.EventId +import io.spine.query.RecordQuery +import io.spine.server.entity.EntityStateKey +import io.spine.server.entity.storage.SpecScanner +import io.spine.server.storage.RecordSpec +import io.spine.server.storage.StorageGroup +import io.spine.server.storage.jdbc.GivenDataSource.whichIsStoredInMemory +import io.spine.server.storage.jdbc.JdbcStorageFactory +import io.spine.server.storage.jdbc.PredefinedMapping.H2_2_4 +import io.spine.server.storage.jdbc.given.JdbcStorageFactoryTestEnv.StgProjectAggregate +import io.spine.server.storage.jdbc.record.given.HistoryStorageTestEnv +import io.spine.server.storage.jdbc.record.given.HistoryStorageTestEnv.StgTaskEntity +import io.spine.server.storage.jdbc.record.given.HistoryStorageTestEnv.h2Factory +import io.spine.server.storage.jdbc.record.given.HistoryStorageTestEnv.journalSpec +import io.spine.server.storage.jdbc.record.given.HistoryStorageTestEnv.stateHistorySpec +import io.spine.test.storage.StgProjectId +import io.spine.test.storage.event.StgProjectCreated +import io.spine.test.storage.stgProjectId +import io.spine.testdata.Sample +import io.spine.testing.server.TestEventFactory +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +/** + * Tests that [JdbcStorageFactory] allocates a distinct table per the combination + * of a record specification and a [StorageGroup], as the framework expects + * of storage vendors. + * + * Without the group taking part in the table identity, the storages holding records + * of the same type would conflate: the event journals of all entity types — and + * the event log of the Bounded Context — store `Event`s, and the state history + * of an entity type stores `EntityRecord`s, just as its latest-state storage does. + */ +@DisplayName("`JdbcStorageFactory`, when allocating grouped tables, should") +internal class GroupedTableAllocationSpec { + + private lateinit var factory: JdbcStorageFactory + + private val projectGroup = StorageGroup.of(StgProjectAggregate::class.java) + private val taskGroup = StorageGroup.of(StgTaskEntity::class.java) + + @BeforeEach + fun createFactory() { + factory = h2Factory() + } + + @AfterEach + fun closeFactory() { + if (this::factory.isInitialized && factory.isOpen) { + factory.close() + } + } + + @Test + fun `tell apart the latest state, event journal, and state history of an entity type`() { + val latestState = factory.tableSpecFor(SpecScanner.scan(StgProjectAggregate::class.java)) + val journal = factory.tableSpecFor(journalSpec(), projectGroup) + val stateHistory = factory.tableSpecFor(stateHistorySpec(), projectGroup) + + latestState.tableName() shouldBe "spine_test_storage_StgProject" + journal.tableName() shouldBe "spine_test_storage_StgProject_Event" + stateHistory.tableName() shouldBe "spine_test_storage_StgProject_EntityRecord" + } + + @Test + fun `key the ID column of each table by the identifier type of its own records`() { + // Before the group-aware allocation, the table specifications were cached + // by the source type of the record specification alone. The state history + // of an entity type — sharing the source type with the latest-state storage — + // would receive the specification of the latter, with the ID column + // of the entity identifier type instead of `EntityStateKey`. + val latestState = factory.tableSpecFor(SpecScanner.scan(StgProjectAggregate::class.java)) + val stateHistory = factory.tableSpecFor(stateHistorySpec(), projectGroup) + + latestState.idColumn().javaType() shouldBe StgProjectId::class.java + stateHistory.idColumn().javaType() shouldBe EntityStateKey::class.java + } + + @Test + fun `allocate distinct tables to the event journals of different entity types`() { + val projectJournal = factory.tableSpecFor(journalSpec(), projectGroup) + val taskJournal = factory.tableSpecFor(journalSpec(), taskGroup) + + projectJournal.tableName() shouldBe "spine_test_storage_StgProject_Event" + taskJournal.tableName() shouldBe "spine_test_storage_StgTask_Event" + } + + @Test + fun `keep the events of an entity type out of the journals of other types`() { + val context = HistoryStorageTestEnv.context() + val projectJournal = + factory.createEntityEventStorage(context, StgProjectAggregate::class.java) + val taskJournal = factory.createEntityEventStorage(context, StgTaskEntity::class.java) + + projectJournal.write(newEvent()) + + // Read each journal in full, without filtering by an entity, + // to observe the whole underlying table. + val everythingJournaled = + RecordQuery.newBuilder(EventId::class.java, Event::class.java) + .build() + taskJournal.readAll(everythingJournaled) + .asSequence() + .toList() + .shouldBeEmpty() + projectJournal.readAll(everythingJournaled) + .asSequence() + .toList() shouldHaveSize 1 + } + + @Test + fun `serve one physical table to the repeatedly created storages of one group`() { + val context = HistoryStorageTestEnv.context() + val first = factory.createEntityEventStorage(context, StgProjectAggregate::class.java) + val second = factory.createEntityEventStorage(context, StgProjectAggregate::class.java) + val event = newEvent() + + first.write(event) + + second.historyBackward(producerOf(event), batchSize = 1) + .asSequence() + .toList() shouldContainExactly listOf(event) + } + + @Test + fun `apply a custom table name only to the storages outside any group`() { + factory.close() + factory = JdbcStorageFactory.newBuilder() + .setDataSource(whichIsStoredInMemory(Identifier.newUuid())) + .setTypeMapping(H2_2_4) + .setTableName(Event::class.java, "custom_event_log") + .build() + + val ungroupedEvents = RecordSpec( + EventId::class.java, + Event::class.java + ) { event -> event.id } + val eventLog = factory.tableSpecFor(ungroupedEvents) + val journal = factory.tableSpecFor(journalSpec(), projectGroup) + + eventLog.tableName() shouldBe "custom_event_log" + journal.tableName() shouldBe "spine_test_storage_StgProject_Event" + } + + private fun newEvent(): Event { + val producer = stgProjectId { + id = "grouped-tables-entity" + } + val eventFactory = TestEventFactory.newInstance( + Identifier.pack(producer), + GroupedTableAllocationSpec::class.java + ) + return eventFactory.createEvent(Sample.messageOfType(StgProjectCreated::class.java)) + } + + private fun producerOf(event: Event): StgProjectId = + Identifier.unpack(event.context.producerId, StgProjectId::class.java) +} diff --git a/rdbms/src/test/kotlin/io/spine/server/storage/jdbc/record/JdbcEntityEventStorageSpec.kt b/rdbms/src/test/kotlin/io/spine/server/storage/jdbc/record/JdbcEntityEventStorageSpec.kt new file mode 100644 index 000000000..fb49c9123 --- /dev/null +++ b/rdbms/src/test/kotlin/io/spine/server/storage/jdbc/record/JdbcEntityEventStorageSpec.kt @@ -0,0 +1,255 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package io.spine.server.storage.jdbc.record + +import com.google.protobuf.Timestamp +import com.google.protobuf.util.Durations +import com.google.protobuf.util.Timestamps.add +import com.google.protobuf.util.Timestamps.subtract +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.shouldBe +import io.spine.base.Identifier +import io.spine.base.Time.currentTime +import io.spine.core.Event +import io.spine.core.Versions.increment +import io.spine.core.Versions.zero +import io.spine.server.entity.storage.EntityEventStorage +import io.spine.server.storage.jdbc.JdbcStorageFactory +import io.spine.server.storage.jdbc.given.JdbcStorageFactoryTestEnv.StgProjectAggregate +import io.spine.server.storage.jdbc.record.given.HistoryStorageTestEnv +import io.spine.server.storage.jdbc.record.given.HistoryStorageTestEnv.h2Factory +import io.spine.test.storage.StgProjectId +import io.spine.test.storage.event.StgProjectCreated +import io.spine.test.storage.stgProjectId +import io.spine.testdata.Sample +import io.spine.testing.server.TestEventFactory +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +/** + * Tests that the JDBC-backed record storage serves the [EntityEventStorage] contract. + * + * The framework logic of the journal is covered in `core-jvm`. These tests prove + * the JDBC persistence serves the query shapes the journal relies upon: the equality + * filtering by a packed entity identifier, the descending sorting by the version + * and the creation time, the read window limits, and the timestamp-comparison + * deletion behind [EntityEventStorage.truncate]. + */ +@DisplayName("JDBC-backed `EntityEventStorage` should") +internal class JdbcEntityEventStorageSpec { + + private val entityId = stgProjectId { id = "journaled-entity" } + private val anotherEntity = stgProjectId { id = "another-entity" } + private lateinit var factory: JdbcStorageFactory + private lateinit var storage: EntityEventStorage + private var version = zero() + + @BeforeEach + fun createStorage() { + factory = h2Factory() + storage = factory.createEntityEventStorage( + HistoryStorageTestEnv.context(), + StgProjectAggregate::class.java + ) + version = zero() + } + + @AfterEach + fun closeFactory() { + if (this::factory.isInitialized && factory.isOpen) { + factory.close() + } + } + + @Test + fun `provide an empty history for an unknown entity`() { + storage.historyBackward(entityId, Int.MAX_VALUE) + .events() + .shouldBeEmpty() + } + + @Test + fun `store an event as-is, keyed by its identifier and producer`() { + val event = newEvent(entityId) + + storage.write(event) + + val read = storage.historyBackward(entityId, batchSize = 1).next() + read shouldBe event + } + + @Test + fun `read the journaled events newest first`() { + val written = appendEvents(count = 5) + + val read = storage.historyBackward(entityId, Int.MAX_VALUE) + + read.events() shouldContainExactly written.reversed() + } + + @Test + fun `limit the read window to the requested batch size`() { + val written = appendEvents(count = 5) + + val read = storage.historyBackward(entityId, batchSize = 2) + + read.events() shouldContainExactly listOf(written[4], written[3]) + } + + @Test + fun `read only the events below the given starting version`() { + val written = appendEvents(count = 5) + val versionOfThird = written[2].context.version + + val read = storage.historyBackward( + entityId, + batchSize = Int.MAX_VALUE, + startingFrom = versionOfThird + ) + + read.events() shouldContainExactly listOf(written[1], written[0]) + } + + @Test + fun `read only the events emitted by the entity with the given identifier`() { + val written = appendEvents(count = 2) + appendEvents(count = 3, toEntity = anotherEntity) + + val read = storage.historyBackward(entityId, Int.MAX_VALUE) + + read.events() shouldContainExactly written.reversed() + } + + @Test + fun `delete the journaled events by their identifiers`() { + val written = appendEvents(count = 3) + val newest = written[2] + + storage.delete(newest.id) shouldBe true + storage.delete(newest.id) shouldBe false + storage.deleteAll(listOf(written[0].id, written[1].id)) + + storage.historyBackward(entityId, Int.MAX_VALUE) + .events() + .shouldBeEmpty() + } + + @Test + fun `truncate the journal, deleting events older than the given time across entities`() { + val longAgo = subtract(currentTime(), Durations.fromDays(365)) + appendEvents(count = 2, at = longAgo) + appendEvents(count = 2, at = longAgo, toEntity = anotherEntity) + val ours = appendEvents(count = 2) + val theirs = appendEvents(count = 2, toEntity = anotherEntity) + val cutoff = subtract(currentTime(), Durations.fromDays(30)) + + storage.truncate(cutoff) + + storage.historyBackward(entityId, Int.MAX_VALUE) + .events() shouldContainExactly ours.reversed() + storage.historyBackward(anotherEntity, Int.MAX_VALUE) + .events() shouldContainExactly theirs.reversed() + } + + @Test + fun `keep the whole journal when every event is newer than the truncation cutoff`() { + val written = appendEvents(count = 3) + val pastCutoff = subtract(currentTime(), Durations.fromDays(365)) + + storage.truncate(pastCutoff) + + storage.historyBackward(entityId, Int.MAX_VALUE) + .events() shouldContainExactly written.reversed() + } + + @Test + fun `purge the whole journal when the truncation cutoff is in the future`() { + appendEvents(count = 4) + val futureCutoff = add(currentTime(), Durations.fromDays(1)) + + storage.truncate(futureCutoff) + + storage.historyBackward(entityId, Int.MAX_VALUE) + .events() + .shouldBeEmpty() + } + + /** + * Appends the given number of events, with sequentially growing versions, + * to the journal of the entity with the given identifier. + * + * The versions continue growing across the calls within one test, so that + * the batches appended later are the more recent ones. + * + * @return the appended events in the order of their versions. + */ + private fun appendEvents( + count: Int, + toEntity: StgProjectId = entityId, + at: Timestamp? = null + ): List { + val eventFactory = eventFactoryFor(toEntity) + val events = List(count) { + version = increment(version) + val message = Sample.messageOfType(StgProjectCreated::class.java) + if (at != null) { + eventFactory.createEvent(message, version, at) + } else { + eventFactory.createEvent(message, version) + } + } + events.forEach { + storage.write(it) + } + return events + } + + private fun newEvent(producer: StgProjectId): Event = + eventFactoryFor(producer) + .createEvent(Sample.messageOfType(StgProjectCreated::class.java)) + + private fun Iterator.events(): List = asSequence().toList() + + private companion object { + + /** + * Creates an event factory producing the events on behalf of the entity + * with the passed identifier. + * + * The journal stores an event under its producer, so the tests emit + * the events with the identifier they later read the history by. + */ + private fun eventFactoryFor(entityId: StgProjectId): TestEventFactory = + TestEventFactory.newInstance( + Identifier.pack(entityId), + JdbcEntityEventStorageSpec::class.java + ) + } +} diff --git a/rdbms/src/test/kotlin/io/spine/server/storage/jdbc/record/JdbcEntityStateHistoryStorageSpec.kt b/rdbms/src/test/kotlin/io/spine/server/storage/jdbc/record/JdbcEntityStateHistoryStorageSpec.kt new file mode 100644 index 000000000..c2037e5d0 --- /dev/null +++ b/rdbms/src/test/kotlin/io/spine/server/storage/jdbc/record/JdbcEntityStateHistoryStorageSpec.kt @@ -0,0 +1,289 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package io.spine.server.storage.jdbc.record + +import com.google.protobuf.Timestamp +import com.google.protobuf.util.Durations +import com.google.protobuf.util.Timestamps.add +import com.google.protobuf.util.Timestamps.subtract +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.shouldBe +import io.spine.base.Identifier +import io.spine.base.Time.currentTime +import io.spine.core.Versions +import io.spine.protobuf.AnyPacker +import io.spine.server.entity.EntityRecord +import io.spine.server.entity.entityRecord +import io.spine.server.entity.entityStateKey +import io.spine.server.entity.storage.EntityStateHistoryStorage +import io.spine.server.storage.jdbc.JdbcStorageFactory +import io.spine.server.storage.jdbc.given.JdbcStorageFactoryTestEnv.StgProjectAggregate +import io.spine.server.storage.jdbc.record.given.HistoryStorageTestEnv +import io.spine.server.storage.jdbc.record.given.HistoryStorageTestEnv.h2Factory +import io.spine.test.storage.StgProjectId +import io.spine.test.storage.stgProject +import io.spine.test.storage.stgProjectId +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +/** + * Tests that the JDBC-backed record storage serves the [EntityStateHistoryStorage] contract. + * + * The framework logic of the state history is covered in `core-jvm`. These tests prove + * the JDBC persistence serves what the history relies upon: + * the [EntityStateKey][io.spine.server.entity.EntityStateKey] record identifier — + * a Protobuf message — including the same-key overwrite, the descending sorting + * by the creation time and the version, and the timestamp comparisons behind + * [stateAt][EntityStateHistoryStorage.stateAt] and + * [truncate][EntityStateHistoryStorage.truncate]. + */ +@DisplayName("JDBC-backed `EntityStateHistoryStorage` should") +internal class JdbcEntityStateHistoryStorageSpec { + + private val entityId = stgProjectId { id = "state-tracked-entity" } + private val anotherEntity = stgProjectId { id = "another-entity" } + private lateinit var factory: JdbcStorageFactory + private lateinit var storage: EntityStateHistoryStorage + private var lastVersion = 0 + + @BeforeEach + fun createStorage() { + factory = h2Factory() + storage = factory.createEntityStateHistoryStorage( + HistoryStorageTestEnv.context(), + StgProjectAggregate::class.java + ) + lastVersion = 0 + } + + @AfterEach + fun closeFactory() { + if (this::factory.isInitialized && factory.isOpen) { + factory.close() + } + } + + @Test + fun `provide an empty history for an unknown entity`() { + storage.historyBackward(entityId, Int.MAX_VALUE) + .records() + .shouldBeEmpty() + } + + @Test + fun `store a record keyed by the entity and the version`() { + val record = record(number = 1) + + storage.write(record) + + storage.historyBackward(entityId, batchSize = 1) + .records() shouldContainExactly listOf(record) + val key = entityStateKey { + entityId = record.entityId + version = record.version.number + } + storage.delete(key) shouldBe true + } + + @Test + fun `overwrite the record stored with the same entity and version`() { + val original = record(number = 1) + val overwriting = record(number = 1) + + storage.write(original) + storage.write(overwriting) + + storage.historyBackward(entityId, Int.MAX_VALUE) + .records() shouldContainExactly listOf(overwriting) + } + + @Test + fun `read the recorded states newest first`() { + val written = appendRecords(count = 5) + + val read = storage.historyBackward(entityId, Int.MAX_VALUE) + + read.records() shouldContainExactly written.reversed() + } + + @Test + fun `limit the read window to the requested batch size`() { + val written = appendRecords(count = 5) + + val read = storage.historyBackward(entityId, batchSize = 2) + + read.records() shouldContainExactly listOf(written[4], written[3]) + } + + @Test + fun `read only the records of the entity with the given identifier`() { + val written = appendRecords(count = 2) + appendRecords(count = 3, toEntity = anotherEntity) + + val read = storage.historyBackward(entityId, Int.MAX_VALUE) + + read.records() shouldContainExactly written.reversed() + } + + @Test + fun `answer the state at the given time with the newest record at or before it`() { + val start = currentTime() + val first = writeRecord(number = 1, at = at(start, 10)) + val second = writeRecord(number = 2, at = at(start, 20)) + writeRecord(number = 3, at = at(start, 30)) + + storage.stateAt(entityId, at(start, 20)) shouldBe second + storage.stateAt(entityId, at(start, 25)) shouldBe second + storage.stateAt(entityId, at(start, 15)) shouldBe first + } + + @Test + fun `break the same-instant tie in favor of the higher version`() { + val instant = currentTime() + writeRecord(number = 1, at = instant) + val higher = writeRecord(number = 2, at = instant) + + storage.stateAt(entityId, instant) shouldBe higher + } + + @Test + fun `answer with null when the time precedes the oldest retained record`() { + val start = currentTime() + writeRecord(number = 3, at = at(start, 20)) + writeRecord(number = 4, at = at(start, 30)) + + storage.stateAt(entityId, at(start, 10)) shouldBe null + } + + @Test + fun `answer with null for an unknown entity`() { + storage.stateAt(entityId, currentTime()) shouldBe null + } + + @Test + fun `trim the per-entity history, keeping the requested number of the most recent records`() { + val written = appendRecords(count = 5) + val theirs = appendRecords(count = 3, toEntity = anotherEntity) + + storage.trim(entityId, keepMostRecent = 2) + + storage.historyBackward(entityId, Int.MAX_VALUE) + .records() shouldContainExactly listOf(written[4], written[3]) + storage.historyBackward(anotherEntity, Int.MAX_VALUE) + .records() shouldContainExactly theirs.reversed() + } + + @Test + fun `purge the whole history of an entity when trimming to zero`() { + appendRecords(count = 3) + + storage.trim(entityId, keepMostRecent = 0) + + storage.historyBackward(entityId, Int.MAX_VALUE) + .records() + .shouldBeEmpty() + } + + @Test + fun `truncate the history, deleting records older than the given time across entities`() { + val longAgo = subtract(currentTime(), Durations.fromDays(365)) + appendRecords(count = 2, at = longAgo) + appendRecords(count = 2, at = longAgo, toEntity = anotherEntity) + val ours = appendRecords(count = 2) + val theirs = appendRecords(count = 2, toEntity = anotherEntity) + val cutoff = subtract(currentTime(), Durations.fromDays(30)) + + storage.truncate(cutoff) + + storage.historyBackward(entityId, Int.MAX_VALUE) + .records() shouldContainExactly ours.reversed() + storage.historyBackward(anotherEntity, Int.MAX_VALUE) + .records() shouldContainExactly theirs.reversed() + } + + /** + * Builds a state record of the entity with the given identifier. + */ + private fun record( + entity: StgProjectId = entityId, + number: Int, + at: Timestamp = currentTime() + ): EntityRecord = entityRecord { + entityId = Identifier.pack(entity) + state = AnyPacker.pack( + stgProject { + id = entity + name = "State at version $number" + } + ) + version = Versions.newVersion(number, at) + } + + /** + * Builds and stores a state record of the entity under test. + */ + private fun writeRecord(number: Int, at: Timestamp): EntityRecord { + val result = record(number = number, at = at) + storage.write(result) + return result + } + + /** + * Appends the given number of records, with sequentially growing versions, + * to the history of the entity with the given identifier. + * + * The versions continue growing across the calls within one test, so that + * the batches appended later are the more recent ones. + * + * @return the appended records in the order of their versions. + */ + private fun appendRecords( + count: Int, + toEntity: StgProjectId = entityId, + at: Timestamp? = null + ): List { + val records = List(count) { + lastVersion++ + record(entity = toEntity, number = lastVersion, at = at ?: currentTime()) + } + records.forEach { + storage.write(it) + } + return records + } + + private fun Iterator.records(): List = asSequence().toList() + + private companion object { + + private fun at(start: Timestamp, seconds: Long): Timestamp = + add(start, Durations.fromSeconds(seconds)) + } +} diff --git a/rdbms/src/test/kotlin/io/spine/server/storage/jdbc/record/given/HistoryStorageTestEnv.kt b/rdbms/src/test/kotlin/io/spine/server/storage/jdbc/record/given/HistoryStorageTestEnv.kt new file mode 100644 index 000000000..4ffebb244 --- /dev/null +++ b/rdbms/src/test/kotlin/io/spine/server/storage/jdbc/record/given/HistoryStorageTestEnv.kt @@ -0,0 +1,109 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package io.spine.server.storage.jdbc.record.given + +import io.spine.base.Identifier.newUuid +import io.spine.core.Event +import io.spine.core.EventId +import io.spine.server.ContextSpec +import io.spine.server.entity.AbstractEntity +import io.spine.server.entity.EntityRecord +import io.spine.server.entity.EntityStateKey +import io.spine.server.entity.entityStateKey +import io.spine.server.entity.storage.EntityEventColumns +import io.spine.server.entity.storage.EntityStateHistoryColumns +import io.spine.server.storage.RecordSpec +import io.spine.server.storage.jdbc.GivenDataSource.whichIsStoredInMemory +import io.spine.server.storage.jdbc.JdbcStorageFactory +import io.spine.server.storage.jdbc.PredefinedMapping.H2_2_4 +import io.spine.test.storage.StgProject +import io.spine.test.storage.StgTask +import io.spine.test.storage.StgTaskId + +/** + * The test environment for the specifications covering the storages + * grouped by a [StorageGroup][io.spine.server.storage.StorageGroup]. + */ +internal object HistoryStorageTestEnv { + + /** + * Creates a new storage factory over an in-memory H2 database. + */ + fun h2Factory(): JdbcStorageFactory = + JdbcStorageFactory.newBuilder() + .setDataSource(whichIsStoredInMemory(newUuid())) + .setTypeMapping(H2_2_4) + .build() + + /** + * Creates the specification of a single-tenant Bounded Context named after this class. + */ + fun context(): ContextSpec = ContextSpec.singleTenant(HistoryStorageTestEnv::class.java.name) + + /** + * Composes a record specification equal to the one used by + * the [EntityEventStorage][io.spine.server.entity.storage.EntityEventStorage] journal. + * + * The framework composes the journal specification privately. The tests re-create it + * to reach the table specifications of grouped storages via + * [JdbcStorageFactory.tableSpecFor], expecting the equally-composed specifications + * to resolve to the same tables. + */ + fun journalSpec(): RecordSpec = RecordSpec( + Event::class.java, + EventId::class.java, + Event::class.java, + EntityEventColumns.definitions() + ) { event -> event.id } + + /** + * Composes a record specification equal to the one used by the + * [EntityStateHistoryStorage][io.spine.server.entity.storage.EntityStateHistoryStorage] + * of an entity with the [StgProject] state. + * + * See [journalSpec] on why the tests re-create the framework-private specification. + */ + fun stateHistorySpec(): RecordSpec = + RecordSpec( + StgProject::class.java, + EntityStateKey::class.java, + EntityRecord::class.java, + EntityStateHistoryColumns.definitions() + ) { record -> + entityStateKey { + entityId = record.entityId + version = record.version.number + } + } + + /** + * A test-only entity with the state type distinct from the one of + * `JdbcStorageFactoryTestEnv.StgProjectAggregate`, for asserting that + * the storages of different entity types stay apart. + */ + internal class StgTaskEntity : AbstractEntity() +} From 3e45bfca1c7de4d4d0a012b4e21e2307ac263009 Mon Sep 17 00:00:00 2001 From: Alexander Yevsyukov Date: Wed, 5 Aug 2026 11:41:05 +0100 Subject: [PATCH 07/10] Look up custom table settings by the source type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A custom table name and a custom column mapping are registered under the type the user passes to the factory builder — for an entity, its state type, as `docs/tables.md` shows with `setTableName(TaskView.class, ...)`. The entity state travels in `RecordSpec.sourceType()`, while `recordType()` is `EntityRecord`, so the previous lookups by the record type could never match an entity table. For standalone records, the source type equals the record type, so their behavior is unchanged. A custom name still applies only to the storages outside any group: honoring one for the state history would collide it with the latest-state table of the same entity. A custom mapping, being keyed by the source type, now also serves the grouped tables of its entity. Addresses the Codex review of #181; the mismatch predates this branch — `sourceType()` has carried the entity state since CoreJvm `2.0.0-SNAPSHOT.380` — but this branch touches the lookups, so it is fixed here, with regression tests. Co-Authored-By: Claude Fable 5 --- docs/tables.md | 9 +++--- .../storage/jdbc/JdbcStorageFactory.java | 19 +++++++++--- .../storage/jdbc/config/TableSpecs.java | 28 ++++++++++------- .../jdbc/record/GroupedTableAllocationSpec.kt | 31 +++++++++++++++++++ 4 files changed, 67 insertions(+), 20 deletions(-) diff --git a/docs/tables.md b/docs/tables.md index 62ecd81ae..68e137e27 100644 --- a/docs/tables.md +++ b/docs/tables.md @@ -136,20 +136,21 @@ public final class TaskProjection var factory = JdbcStorageFactory.newBuilder() // ... - // Uses the record type to set the name for its table: + // Uses the state type of an Entity to set the name for its table: .setTableName(TaskView.class, "my_favourite_tasks") // ... - // It also works for "system" tables: + // It also works for "system" tables, keyed by the type of the stored record: .setTableName(InboxMessage.class, "custom_inbox_messages") .build(); ``` :warning: Custom table names apply only to the storages outside any `StorageGroup`. The [grouped tables](#grouped-tables) — such as the per-entity histories — are always -named after the group and the record type: the custom names are registered per record type, -which cannot tell apart the storages of one group from another. +named after the group and the record type. A custom name set for an entity state type +names the latest-state table alone; honoring it for the state history of the same +entity would collide the two tables. * column type mapping, per type of stored records: diff --git a/rdbms/src/main/java/io/spine/server/storage/jdbc/JdbcStorageFactory.java b/rdbms/src/main/java/io/spine/server/storage/jdbc/JdbcStorageFactory.java index daa5ed9fa..9b26c3831 100644 --- a/rdbms/src/main/java/io/spine/server/storage/jdbc/JdbcStorageFactory.java +++ b/rdbms/src/main/java/io/spine/server/storage/jdbc/JdbcStorageFactory.java @@ -332,6 +332,9 @@ public Builder setTypeMapping(TypeMapping typeMapping) { /** * Sets the custom DB table name for the table storing the records of the specified type. * + *

For an Entity, pass the type of its state; for a standalone stored record, + * such as {@code InboxMessage}, the type of the record itself. + * *

The name previously set, if any, is replaced with this call. * *

The name cannot be blank. @@ -343,11 +346,11 @@ public Builder setTypeMapping(TypeMapping typeMapping) { *

The custom name applies only to the storages belonging to no * {@link io.spine.server.storage.StorageGroup StorageGroup}. The tables of grouped * storages — such as the per-entity histories — are always named after the group - * and the record type, as a single per-record-type name cannot tell - * the storages of different groups apart. + * and the record type; a custom name set for an entity state type names + * the latest-state table alone, never the history tables of that entity. * * @param recordType - * the type of the stored record + * the type of the stored record — for an Entity, its state type * @param name * the table name * @param @@ -364,13 +367,21 @@ Builder setTableName(Class recordType, String name) { /** * Sets the custom column mapping for the table storing the records of the specified type. * + *

For an Entity, pass the type of its state; for a standalone stored record, + * such as {@code InboxMessage}, the type of the record itself. + * *

The mapping previously set, if any, is replaced with this call. * *

In case no custom mapping is defined for some table, * a {@linkplain #setColumnMapping(JdbcColumnMapping) a factory-wide value} is used. * + *

Unlike a {@linkplain #setTableName(Class, String) custom table name}, + * a custom mapping set for an entity state type also applies to the tables of + * the {@linkplain io.spine.server.storage.StorageGroup grouped} storages serving + * that entity, such as its state history. + * * @param recordType - * the type of the stored record + * the type of the stored record — for an Entity, its state type * @param mapping * the custom mapping * @param diff --git a/rdbms/src/main/java/io/spine/server/storage/jdbc/config/TableSpecs.java b/rdbms/src/main/java/io/spine/server/storage/jdbc/config/TableSpecs.java index e6dd47b56..da4a66043 100644 --- a/rdbms/src/main/java/io/spine/server/storage/jdbc/config/TableSpecs.java +++ b/rdbms/src/main/java/io/spine/server/storage/jdbc/config/TableSpecs.java @@ -80,16 +80,20 @@ private TableSpecs(Builder builder) { * tolerates concurrent invocations, as some storages are created lazily * on worker threads. * + *

Custom table names and custom column mappings are looked up by + * the source type of the record specification — for an entity storage, + * the entity state type; for a standalone record, the record type itself. + * *

For the storages belonging to no group, in case no custom table name * was specified, a {@linkplain io.spine.server.storage.jdbc.record.TableNames#of(Class) * default one} is used. * *

The tables of grouped storages are always named after * the {@linkplain io.spine.server.storage.jdbc.record.TableNames#of(Class, StorageGroup) - * group and the record type}. Custom table names do not apply to them: such names - * are registered per record type, which cannot tell apart the storages - * of one group from another — e.g., the event journals of all entity types - * store records of the same {@code Event} type. + * group and the record type}. Custom table names do not apply to them: + * the event journals of all entity types share one source type, {@code Event}, + * and the state history of an entity shares its source type with + * the latest-state storage, so a custom name would collide the tables. * *

If no custom column mapping was set previously, * the default mapping passed to this method is used. @@ -119,7 +123,7 @@ private TableSpecs(Builder builder) { newTableSpec(RecordSpec spec, @Nullable StorageGroup group, JdbcColumnMapping defaultMapping) { - @Nullable JdbcColumnMapping customMapping = findMapping(spec.recordType()); + @Nullable JdbcColumnMapping customMapping = findMapping(spec.sourceType()); var mapping = customMapping == null ? defaultMapping : customMapping; @@ -131,7 +135,7 @@ private String tableName(RecordSpec spec, @Nullable StorageGroup group) { if (group != null) { return TableNames.of(spec.recordType(), group); } - @Nullable String customName = findName(spec.recordType()); + @Nullable String customName = findName(spec.sourceType()); return customName == null ? TableNames.of(spec.sourceType()) : customName; @@ -159,18 +163,18 @@ private static SpecKey of(RecordSpec spec, @Nullable StorageGroup group) { } } - private @Nullable String findName(Class recordType) { + private @Nullable String findName(Class sourceType) { @Nullable String customName = null; - if (names.containsKey(recordType)) { - customName = names.get(recordType); + if (names.containsKey(sourceType)) { + customName = names.get(sourceType); } return customName; } - private @Nullable JdbcColumnMapping findMapping(Class recordType) { + private @Nullable JdbcColumnMapping findMapping(Class sourceType) { @Nullable JdbcColumnMapping value = null; - if (columnMappings.containsKey(recordType)) { - value = columnMappings.get(recordType); + if (columnMappings.containsKey(sourceType)) { + value = columnMappings.get(sourceType); } return value; } diff --git a/rdbms/src/test/kotlin/io/spine/server/storage/jdbc/record/GroupedTableAllocationSpec.kt b/rdbms/src/test/kotlin/io/spine/server/storage/jdbc/record/GroupedTableAllocationSpec.kt index 19df93655..5ba742f3f 100644 --- a/rdbms/src/test/kotlin/io/spine/server/storage/jdbc/record/GroupedTableAllocationSpec.kt +++ b/rdbms/src/test/kotlin/io/spine/server/storage/jdbc/record/GroupedTableAllocationSpec.kt @@ -30,6 +30,8 @@ import io.kotest.matchers.collections.shouldBeEmpty import io.kotest.matchers.collections.shouldContainExactly import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeSameInstanceAs +import io.kotest.matchers.types.shouldNotBeSameInstanceAs import io.spine.base.Identifier import io.spine.core.Event import io.spine.core.EventId @@ -42,11 +44,13 @@ import io.spine.server.storage.jdbc.GivenDataSource.whichIsStoredInMemory import io.spine.server.storage.jdbc.JdbcStorageFactory import io.spine.server.storage.jdbc.PredefinedMapping.H2_2_4 import io.spine.server.storage.jdbc.given.JdbcStorageFactoryTestEnv.StgProjectAggregate +import io.spine.server.storage.jdbc.given.JdbcStorageFactoryTestEnv.TestColumnMapping import io.spine.server.storage.jdbc.record.given.HistoryStorageTestEnv import io.spine.server.storage.jdbc.record.given.HistoryStorageTestEnv.StgTaskEntity import io.spine.server.storage.jdbc.record.given.HistoryStorageTestEnv.h2Factory import io.spine.server.storage.jdbc.record.given.HistoryStorageTestEnv.journalSpec import io.spine.server.storage.jdbc.record.given.HistoryStorageTestEnv.stateHistorySpec +import io.spine.test.storage.StgProject import io.spine.test.storage.StgProjectId import io.spine.test.storage.event.StgProjectCreated import io.spine.test.storage.stgProjectId @@ -165,6 +169,7 @@ internal class GroupedTableAllocationSpec { .setDataSource(whichIsStoredInMemory(Identifier.newUuid())) .setTypeMapping(H2_2_4) .setTableName(Event::class.java, "custom_event_log") + .setTableName(StgProject::class.java, "my_projects") .build() val ungroupedEvents = RecordSpec( @@ -172,10 +177,36 @@ internal class GroupedTableAllocationSpec { Event::class.java ) { event -> event.id } val eventLog = factory.tableSpecFor(ungroupedEvents) + val latestState = factory.tableSpecFor(SpecScanner.scan(StgProjectAggregate::class.java)) val journal = factory.tableSpecFor(journalSpec(), projectGroup) + val stateHistory = factory.tableSpecFor(stateHistorySpec(), projectGroup) eventLog.tableName() shouldBe "custom_event_log" + // The name of an entity table is customized by the entity state type. + latestState.tableName() shouldBe "my_projects" + // The grouped tables of the same entity keep their generated names. journal.tableName() shouldBe "spine_test_storage_StgProject_Event" + stateHistory.tableName() shouldBe "spine_test_storage_StgProject_EntityRecord" + } + + @Test + fun `look up a custom column mapping by the entity state type, serving grouped tables too`() { + factory.close() + val custom = TestColumnMapping() + factory = JdbcStorageFactory.newBuilder() + .setDataSource(whichIsStoredInMemory(Identifier.newUuid())) + .setTypeMapping(H2_2_4) + .setCustomMapping(StgProject::class.java, custom) + .build() + + val latestState = factory.tableSpecFor(SpecScanner.scan(StgProjectAggregate::class.java)) + val stateHistory = factory.tableSpecFor(stateHistorySpec(), projectGroup) + val journal = factory.tableSpecFor(journalSpec(), projectGroup) + + latestState.columnMapping() shouldBeSameInstanceAs custom + stateHistory.columnMapping() shouldBeSameInstanceAs custom + // The journal stores `Event`s, the source type of which is not customized. + journal.columnMapping() shouldNotBeSameInstanceAs custom } private fun newEvent(): Event { From 430be13430d908106835ed6334cd67afd6238cd7 Mon Sep 17 00:00:00 2001 From: Alexander Yevsyukov Date: Wed, 5 Aug 2026 16:37:33 +0100 Subject: [PATCH 08/10] Use JSpecify `Nullable` throughout the module Aligns the module on `org.jspecify.annotations.Nullable`, matching the annotation of the CoreJvm storage seam, and resolves the mixed-library note from the review of #181. Co-Authored-By: Claude Fable 5 --- .../java/io/spine/server/storage/jdbc/DataSourceConfig.java | 2 +- .../src/main/java/io/spine/server/storage/jdbc/TableColumn.java | 2 +- .../java/io/spine/server/storage/jdbc/query/AbstractQuery.java | 2 +- .../spine/server/storage/jdbc/query/SelectMessageByIdQuery.java | 2 +- .../java/io/spine/server/storage/jdbc/query/WriteQuery.java | 2 +- .../java/io/spine/server/storage/jdbc/record/JdbcRecord.java | 2 +- .../java/io/spine/server/storage/jdbc/record/JdbcTableSpec.java | 2 +- .../io/spine/server/storage/jdbc/record/column/BytesColumn.java | 2 +- .../java/io/spine/server/storage/jdbc/mysql/MysqlTests.java | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/rdbms/src/main/java/io/spine/server/storage/jdbc/DataSourceConfig.java b/rdbms/src/main/java/io/spine/server/storage/jdbc/DataSourceConfig.java index fd8524fbe..10a40bd30 100644 --- a/rdbms/src/main/java/io/spine/server/storage/jdbc/DataSourceConfig.java +++ b/rdbms/src/main/java/io/spine/server/storage/jdbc/DataSourceConfig.java @@ -26,7 +26,7 @@ package io.spine.server.storage.jdbc; -import org.checkerframework.checker.nullness.qual.Nullable; +import org.jspecify.annotations.Nullable; import javax.sql.DataSource; import java.sql.Connection; diff --git a/rdbms/src/main/java/io/spine/server/storage/jdbc/TableColumn.java b/rdbms/src/main/java/io/spine/server/storage/jdbc/TableColumn.java index ed3ff37e6..aef97ed7c 100644 --- a/rdbms/src/main/java/io/spine/server/storage/jdbc/TableColumn.java +++ b/rdbms/src/main/java/io/spine/server/storage/jdbc/TableColumn.java @@ -30,7 +30,7 @@ import io.spine.query.ColumnName; import io.spine.server.storage.RecordWithColumns; import io.spine.server.storage.jdbc.type.JdbcColumnMapping; -import org.checkerframework.checker.nullness.qual.Nullable; +import org.jspecify.annotations.Nullable; import static io.spine.util.Exceptions.newIllegalArgumentException; diff --git a/rdbms/src/main/java/io/spine/server/storage/jdbc/query/AbstractQuery.java b/rdbms/src/main/java/io/spine/server/storage/jdbc/query/AbstractQuery.java index 457efa191..c24829381 100644 --- a/rdbms/src/main/java/io/spine/server/storage/jdbc/query/AbstractQuery.java +++ b/rdbms/src/main/java/io/spine/server/storage/jdbc/query/AbstractQuery.java @@ -47,7 +47,7 @@ import io.spine.server.storage.jdbc.record.column.IdColumn; import org.checkerframework.checker.nullness.qual.MonotonicNonNull; import org.checkerframework.checker.nullness.qual.NonNull; -import org.checkerframework.checker.nullness.qual.Nullable; +import org.jspecify.annotations.Nullable; import java.sql.Connection; import java.sql.SQLException; diff --git a/rdbms/src/main/java/io/spine/server/storage/jdbc/query/SelectMessageByIdQuery.java b/rdbms/src/main/java/io/spine/server/storage/jdbc/query/SelectMessageByIdQuery.java index 7b3f450e1..93508abab 100644 --- a/rdbms/src/main/java/io/spine/server/storage/jdbc/query/SelectMessageByIdQuery.java +++ b/rdbms/src/main/java/io/spine/server/storage/jdbc/query/SelectMessageByIdQuery.java @@ -31,7 +31,7 @@ import com.querydsl.sql.AbstractSQLQuery; import io.spine.server.storage.jdbc.DatabaseException; import io.spine.server.storage.jdbc.record.Serializer; -import org.checkerframework.checker.nullness.qual.Nullable; +import org.jspecify.annotations.Nullable; import java.sql.ResultSet; import java.sql.SQLException; diff --git a/rdbms/src/main/java/io/spine/server/storage/jdbc/query/WriteQuery.java b/rdbms/src/main/java/io/spine/server/storage/jdbc/query/WriteQuery.java index 5a93aadaa..6ae0ab4f6 100644 --- a/rdbms/src/main/java/io/spine/server/storage/jdbc/query/WriteQuery.java +++ b/rdbms/src/main/java/io/spine/server/storage/jdbc/query/WriteQuery.java @@ -32,7 +32,7 @@ import io.spine.server.storage.jdbc.record.JdbcRecord; import io.spine.server.storage.jdbc.record.RecordTable; -import javax.annotation.Nullable; +import org.jspecify.annotations.Nullable; import static com.google.common.base.Preconditions.checkNotNull; diff --git a/rdbms/src/main/java/io/spine/server/storage/jdbc/record/JdbcRecord.java b/rdbms/src/main/java/io/spine/server/storage/jdbc/record/JdbcRecord.java index 0f63cc319..3f28cd672 100644 --- a/rdbms/src/main/java/io/spine/server/storage/jdbc/record/JdbcRecord.java +++ b/rdbms/src/main/java/io/spine/server/storage/jdbc/record/JdbcRecord.java @@ -32,7 +32,7 @@ import io.spine.annotation.Internal; import io.spine.query.ColumnName; import io.spine.server.storage.RecordWithColumns; -import org.checkerframework.checker.nullness.qual.Nullable; +import org.jspecify.annotations.Nullable; /** * A record to insert into an RDBMS-backed table. diff --git a/rdbms/src/main/java/io/spine/server/storage/jdbc/record/JdbcTableSpec.java b/rdbms/src/main/java/io/spine/server/storage/jdbc/record/JdbcTableSpec.java index 635fc68dc..844e640e5 100644 --- a/rdbms/src/main/java/io/spine/server/storage/jdbc/record/JdbcTableSpec.java +++ b/rdbms/src/main/java/io/spine/server/storage/jdbc/record/JdbcTableSpec.java @@ -40,7 +40,7 @@ import io.spine.server.storage.jdbc.record.column.BytesColumn; import io.spine.server.storage.jdbc.record.column.IdColumn; import io.spine.server.storage.jdbc.type.JdbcColumnMapping; -import org.checkerframework.checker.nullness.qual.Nullable; +import org.jspecify.annotations.Nullable; import java.util.LinkedHashMap; import java.util.Map; diff --git a/rdbms/src/main/java/io/spine/server/storage/jdbc/record/column/BytesColumn.java b/rdbms/src/main/java/io/spine/server/storage/jdbc/record/column/BytesColumn.java index 173d37664..0198306a0 100644 --- a/rdbms/src/main/java/io/spine/server/storage/jdbc/record/column/BytesColumn.java +++ b/rdbms/src/main/java/io/spine/server/storage/jdbc/record/column/BytesColumn.java @@ -32,7 +32,7 @@ import io.spine.server.storage.jdbc.record.RecordTable; import io.spine.server.storage.jdbc.record.Serializer; import io.spine.server.storage.jdbc.type.JdbcColumnMapping; -import org.checkerframework.checker.nullness.qual.Nullable; +import org.jspecify.annotations.Nullable; import static io.spine.server.storage.jdbc.Type.BYTE_ARRAY; import static io.spine.server.storage.jdbc.record.Serializer.serialize; diff --git a/rdbms/src/test/java/io/spine/server/storage/jdbc/mysql/MysqlTests.java b/rdbms/src/test/java/io/spine/server/storage/jdbc/mysql/MysqlTests.java index fbf63ca6d..7b7ac30a2 100644 --- a/rdbms/src/test/java/io/spine/server/storage/jdbc/mysql/MysqlTests.java +++ b/rdbms/src/test/java/io/spine/server/storage/jdbc/mysql/MysqlTests.java @@ -31,7 +31,7 @@ import io.spine.server.storage.jdbc.DataSourceWrapper; import io.spine.server.storage.jdbc.JdbcStorageFactory; import io.spine.server.storage.jdbc.PredefinedMapping; -import org.checkerframework.checker.nullness.qual.Nullable; +import org.jspecify.annotations.Nullable; import org.testcontainers.DockerClientFactory; import org.testcontainers.containers.MySQLContainer; From d45395b10d7430902be7846dc5c5109ad587dbea Mon Sep 17 00:00:00 2001 From: Alexander Yevsyukov Date: Wed, 5 Aug 2026 16:37:49 +0100 Subject: [PATCH 09/10] Support custom names for grouped tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A grouped table — the event journal or the state history of an entity — can now be given a custom name via the new `setTableName(stateType, recordType, name)` overload of `JdbcStorageFactory.Builder`. The registration addresses a table the way its identity works: by the storage group — named by the framework after the entity state type — paired with the type of the stored records. The single-type `setTableName` keeps applying to the ungrouped storages only, as its key cannot address a grouped table without collisions. `TableSpecs` also moves to the JSpecify `Nullable`, following the module-wide alignment. Requested in the review of #181, completing the naming feature. Co-Authored-By: Claude Fable 5 --- .agents/tasks/de-event-sourcing-rollout.md | 11 ++- docs/tables.md | 25 ++++-- .../storage/jdbc/JdbcStorageFactory.java | 63 ++++++++++++-- .../storage/jdbc/config/TableSpecs.java | 84 ++++++++++++++++--- .../jdbc/record/GroupedTableAllocationSpec.kt | 35 ++++++++ 5 files changed, 192 insertions(+), 26 deletions(-) diff --git a/.agents/tasks/de-event-sourcing-rollout.md b/.agents/tasks/de-event-sourcing-rollout.md index 31e5a2127..01caeff00 100644 --- a/.agents/tasks/de-event-sourcing-rollout.md +++ b/.agents/tasks/de-event-sourcing-rollout.md @@ -36,9 +36,13 @@ Substantive half (this change): (e.g. `spine_test_storage_StgProject_Event`). Naming settled with the product owner on 2026-08-04 (generic rule over semantic suffixes). - `TableSpecs`: cache keyed by `SpecKey(sourceType, recordType, groupName)` - in a `ConcurrentHashMap` (`computeIfAbsent`). Grouped specs ignore custom - table names (registered per record type — cannot discriminate groups); - custom column mappings still apply. + in a `ConcurrentHashMap` (`computeIfAbsent`). Custom names and mappings are + looked up by the *source* type (Codex review of #181: for an entity, that is + the state type users register — `recordType()` is `EntityRecord`; the miss + predates the branch, present since CoreJvm `.380`). Grouped tables take + custom names via the `setTableName(stateType, recordType, name)` overload, + keyed by `(group name, record type)`; the single-type names never apply to + them. Custom mappings (source-type-keyed) serve grouped tables too. - `JdbcStorageFactory.createRecordStorage` threads the group into `JdbcRecordStorage`; new `tableSpecFor(spec, group)` overload. - `JdbcRecordStorage`: group-accepting constructors; legacy ones delegate @@ -54,7 +58,6 @@ contract), `JdbcEntityEventStorageSpec`, `JdbcEntityStateHistoryStorageSpec` ## Follow-ups (out of scope) -- Group-aware `setTableName` overload, if users ask for custom history names. - Grouped-DDL export via `tableCreationSql` (currently latest-state only). ## Status diff --git a/docs/tables.md b/docs/tables.md index 68e137e27..354a7a350 100644 --- a/docs/tables.md +++ b/docs/tables.md @@ -67,6 +67,8 @@ Grouped tables have the same structure as the ungrouped ones: the `ID` and `byte columns, plus the columns declared for the stored record type — for both histories, these are `entity_id`, `created`, and `version`. +A grouped table can also be given a custom name; see [Customization](#customization). + :warning: Group names are the fully-qualified names of Proto types, so the names of grouped tables run longer than the ungrouped ones. Mind the identifier length limits of the underlying DB engine — e.g., 64 characters on MySQL — @@ -146,11 +148,24 @@ var factory = JdbcStorageFactory.newBuilder() .build(); ``` -:warning: Custom table names apply only to the storages outside any `StorageGroup`. -The [grouped tables](#grouped-tables) — such as the per-entity histories — are always -named after the group and the record type. A custom name set for an entity state type -names the latest-state table alone; honoring it for the state history of the same -entity would collide the two tables. +:warning: The single-type `setTableName(...)` applies only to the storages outside any +`StorageGroup`: a name set for an entity state type names the latest-state table alone; +honoring it for the state history of the same entity would collide the two tables. + +To name the [grouped tables](#grouped-tables) of an entity — its per-entity histories — +address them by the entity state type paired with the type of the stored records: + +```java +var factory = JdbcStorageFactory.newBuilder() + // ... + + // The event journal of the `Project` entities: + .setTableName(Project.class, Event.class, "project_journal") + + // The state history of the `Project` entities: + .setTableName(Project.class, EntityRecord.class, "project_state_history") + .build(); +``` * column type mapping, per type of stored records: diff --git a/rdbms/src/main/java/io/spine/server/storage/jdbc/JdbcStorageFactory.java b/rdbms/src/main/java/io/spine/server/storage/jdbc/JdbcStorageFactory.java index 9b26c3831..f0b881bef 100644 --- a/rdbms/src/main/java/io/spine/server/storage/jdbc/JdbcStorageFactory.java +++ b/rdbms/src/main/java/io/spine/server/storage/jdbc/JdbcStorageFactory.java @@ -216,9 +216,10 @@ public JdbcTableSpec tableSpecFor(RecordSpec * *

For the storages belonging to no group, the * {@linkplain Builder#setTableName(Class, String) custom table name} is applied as well. - * The tables of grouped storages are always named after the group and the record type; - * see {@link io.spine.server.storage.jdbc.record.TableNames#of(Class, StorageGroup) - * TableNames.of(Class, StorageGroup)}. + * The tables of grouped storages take the custom names registered with + * {@link Builder#setTableName(Class, Class, String)}; without one, they are named + * after the {@linkplain io.spine.server.storage.jdbc.record.TableNames#of(Class, + * StorageGroup) group and the record type}. * * @param spec * record specification @@ -344,10 +345,10 @@ public Builder setTypeMapping(TypeMapping typeMapping) { * is used. * *

The custom name applies only to the storages belonging to no - * {@link io.spine.server.storage.StorageGroup StorageGroup}. The tables of grouped - * storages — such as the per-entity histories — are always named after the group - * and the record type; a custom name set for an entity state type names - * the latest-state table alone, never the history tables of that entity. + * {@link io.spine.server.storage.StorageGroup StorageGroup}. A custom name set + * for an entity state type names the latest-state table alone, never the history + * tables of that entity; use {@link #setTableName(Class, Class, String)} to name + * the grouped tables. * * @param recordType * the type of the stored record — for an Entity, its state type @@ -364,6 +365,54 @@ Builder setTableName(Class recordType, String name) { return this; } + /** + * Sets the custom DB table name for the table of a + * {@linkplain io.spine.server.storage.StorageGroup grouped} storage serving + * the entities with the specified state type — such as a per-entity history. + * + *

A grouped table is addressed by the storage group — named by the framework + * after the entity state type — paired with the type of the stored records. + * For instance, for the entities with the {@code Project} state: + * + *

+         * // The event journal of the `Project` entities:
+         * builder.setTableName(Project.class, Event.class, "project_journal");
+         *
+         * // The state history of the `Project` entities:
+         * builder.setTableName(Project.class, EntityRecord.class, "project_state_history");
+         * 
+ * + *

The name previously set for the same grouped table, if any, + * is replaced with this call. + * + *

The name cannot be blank. + * + *

In case no custom name is defined, a grouped table is + * {@linkplain io.spine.server.storage.jdbc.record.TableNames#of(Class, StorageGroup) + * named after the group and the record type}. + * + *

It is a responsibility of callers to select a name which does not collide + * with the names of other tables, including the generated ones. + * + * @param stateType + * the type of the state of the entity served by the grouped storage + * @param recordType + * the type of the records stored by the grouped storage + * @param name + * the table name + * @param + * the type of the entity state + * @param + * the type of the stored record + * @return this instance of {@code Builder} + */ + @CanIgnoreReturnValue + public , R extends Message> + Builder setTableName(Class stateType, Class recordType, String name) { + tableSpecs.setTableName(stateType, recordType, name); + return this; + } + /** * Sets the custom column mapping for the table storing the records of the specified type. * diff --git a/rdbms/src/main/java/io/spine/server/storage/jdbc/config/TableSpecs.java b/rdbms/src/main/java/io/spine/server/storage/jdbc/config/TableSpecs.java index da4a66043..5ddb4bd6d 100644 --- a/rdbms/src/main/java/io/spine/server/storage/jdbc/config/TableSpecs.java +++ b/rdbms/src/main/java/io/spine/server/storage/jdbc/config/TableSpecs.java @@ -30,12 +30,14 @@ import com.google.errorprone.annotations.CanIgnoreReturnValue; import com.google.protobuf.Message; import io.spine.annotation.Internal; +import io.spine.base.EntityState; import io.spine.server.storage.RecordSpec; import io.spine.server.storage.StorageGroup; import io.spine.server.storage.jdbc.record.JdbcTableSpec; import io.spine.server.storage.jdbc.record.TableNames; import io.spine.server.storage.jdbc.type.JdbcColumnMapping; -import org.checkerframework.checker.nullness.qual.Nullable; +import io.spine.type.TypeName; +import org.jspecify.annotations.Nullable; import java.util.HashMap; import java.util.Map; @@ -58,6 +60,7 @@ public final class TableSpecs { private final ImmutableMap, String> names; + private final ImmutableMap groupedNames; private final Map> tables = new ConcurrentHashMap<>(); private final ImmutableMap, JdbcColumnMapping> columnMappings; @@ -67,6 +70,7 @@ public final class TableSpecs { */ private TableSpecs(Builder builder) { this.names = ImmutableMap.copyOf(builder.names); + this.groupedNames = ImmutableMap.copyOf(builder.groupedNames); this.columnMappings = ImmutableMap.copyOf(builder.mappings); } @@ -88,12 +92,15 @@ private TableSpecs(Builder builder) { * was specified, a {@linkplain io.spine.server.storage.jdbc.record.TableNames#of(Class) * default one} is used. * - *

The tables of grouped storages are always named after - * the {@linkplain io.spine.server.storage.jdbc.record.TableNames#of(Class, StorageGroup) - * group and the record type}. Custom table names do not apply to them: + *

The tables of grouped storages take the custom names registered with + * {@link Builder#setTableName(Class, Class, String)}, addressed by the group + * and the record type. The single-type custom names do not apply to them: * the event journals of all entity types share one source type, {@code Event}, * and the state history of an entity shares its source type with - * the latest-state storage, so a custom name would collide the tables. + * the latest-state storage, so such a name would collide the tables. + * In case no custom name is registered for a grouped table, it is named after + * the {@linkplain io.spine.server.storage.jdbc.record.TableNames#of(Class, StorageGroup) + * group and the record type}. * *

If no custom column mapping was set previously, * the default mapping passed to this method is used. @@ -123,7 +130,7 @@ private TableSpecs(Builder builder) { newTableSpec(RecordSpec spec, @Nullable StorageGroup group, JdbcColumnMapping defaultMapping) { - @Nullable JdbcColumnMapping customMapping = findMapping(spec.sourceType()); + var customMapping = findMapping(spec.sourceType()); var mapping = customMapping == null ? defaultMapping : customMapping; @@ -133,9 +140,12 @@ private TableSpecs(Builder builder) { private String tableName(RecordSpec spec, @Nullable StorageGroup group) { if (group != null) { - return TableNames.of(spec.recordType(), group); + var customName = groupedNames.get(new GroupedTable(group.getName(), spec.recordType())); + return customName == null + ? TableNames.of(spec.recordType(), group) + : customName; } - @Nullable String customName = findName(spec.sourceType()); + var customName = findName(spec.sourceType()); return customName == null ? TableNames.of(spec.sourceType()) : customName; @@ -163,8 +173,26 @@ private static SpecKey of(RecordSpec spec, @Nullable StorageGroup group) { } } + /** + * The identity of a grouped table, as addressed by the custom-name registration: + * the name of the storage group, and the type of the stored records. + * + * @param group + * the name of the storage group + * @param recordType + * the type of the stored records + */ + private record GroupedTable(String group, Class recordType) { + + private static GroupedTable of(Class> stateType, + Class recordType) { + var groupName = TypeName.of(stateType).value(); + return new GroupedTable(groupName, recordType); + } + } + private @Nullable String findName(Class sourceType) { - @Nullable String customName = null; + String customName = null; if (names.containsKey(sourceType)) { customName = names.get(sourceType); } @@ -172,7 +200,7 @@ private static SpecKey of(RecordSpec spec, @Nullable StorageGroup group) { } private @Nullable JdbcColumnMapping findMapping(Class sourceType) { - @Nullable JdbcColumnMapping value = null; + JdbcColumnMapping value = null; if (columnMappings.containsKey(sourceType)) { value = columnMappings.get(sourceType); } @@ -193,6 +221,8 @@ public static final class Builder { private final Map, String> names = new HashMap<>(); + private final Map groupedNames = new HashMap<>(); + private final Map, JdbcColumnMapping> mappings = new HashMap<>(); private Builder() { @@ -226,6 +256,40 @@ Builder setTableName(Class recordType, String name) { return this; } + /** + * Sets the custom DB table name for the grouped table which serves the entities + * with the specified state type, storing the records of the specified type. + * + *

The grouped table is addressed by the storage group — named by the framework + * after the entity state type — paired with the type of the stored records. + * + *

The name previously set for the same grouped table, if any, + * is replaced with this call. + * + *

The name cannot be blank. + * + * @param stateType + * the type of the state of the entity served by the grouped storage + * @param recordType + * the type of the records stored by the grouped storage + * @param name + * the table name + * @param + * the type of the entity state + * @param + * the type of the stored record + * @return this instance of {@code Builder} + */ + @CanIgnoreReturnValue + public , R extends Message> + Builder setTableName(Class stateType, Class recordType, String name) { + checkNotNull(stateType); + checkNotNull(recordType); + checkNotEmptyOrBlank(name); + this.groupedNames.put(GroupedTable.of(stateType, recordType), name); + return this; + } + /** * Sets the column type mapping rules for the table, in which the records of the specified * type are stored. diff --git a/rdbms/src/test/kotlin/io/spine/server/storage/jdbc/record/GroupedTableAllocationSpec.kt b/rdbms/src/test/kotlin/io/spine/server/storage/jdbc/record/GroupedTableAllocationSpec.kt index 5ba742f3f..1fd9b7b1a 100644 --- a/rdbms/src/test/kotlin/io/spine/server/storage/jdbc/record/GroupedTableAllocationSpec.kt +++ b/rdbms/src/test/kotlin/io/spine/server/storage/jdbc/record/GroupedTableAllocationSpec.kt @@ -36,6 +36,7 @@ import io.spine.base.Identifier import io.spine.core.Event import io.spine.core.EventId import io.spine.query.RecordQuery +import io.spine.server.entity.EntityRecord import io.spine.server.entity.EntityStateKey import io.spine.server.entity.storage.SpecScanner import io.spine.server.storage.RecordSpec @@ -189,6 +190,40 @@ internal class GroupedTableAllocationSpec { stateHistory.tableName() shouldBe "spine_test_storage_StgProject_EntityRecord" } + @Test + fun `name a grouped table by the state and the record types of its storage`() { + factory.close() + factory = JdbcStorageFactory.newBuilder() + .setDataSource(whichIsStoredInMemory(Identifier.newUuid())) + .setTypeMapping(H2_2_4) + .setTableName(StgProject::class.java, Event::class.java, "project_journal") + .setTableName(StgProject::class.java, EntityRecord::class.java, "project_state_history") + .build() + + val journal = factory.tableSpecFor(journalSpec(), projectGroup) + val stateHistory = factory.tableSpecFor(stateHistorySpec(), projectGroup) + val latestState = factory.tableSpecFor(SpecScanner.scan(StgProjectAggregate::class.java)) + val taskJournal = factory.tableSpecFor(journalSpec(), taskGroup) + + journal.tableName() shouldBe "project_journal" + stateHistory.tableName() shouldBe "project_state_history" + // The registration addresses the grouped tables only... + latestState.tableName() shouldBe "spine_test_storage_StgProject" + // ...of the specified entity type alone. + taskJournal.tableName() shouldBe "spine_test_storage_StgTask_Event" + + // The custom-named journal is fully operational. + val storage = factory.createEntityEventStorage( + HistoryStorageTestEnv.context(), + StgProjectAggregate::class.java + ) + val event = newEvent() + storage.write(event) + storage.historyBackward(producerOf(event), batchSize = 1) + .asSequence() + .toList() shouldContainExactly listOf(event) + } + @Test fun `look up a custom column mapping by the entity state type, serving grouped tables too`() { factory.close() From eaa64825d2c7b5a30ed7bbc495b6cd8db1cb33a1 Mon Sep 17 00:00:00 2001 From: Alexander Yevsyukov Date: Wed, 5 Aug 2026 17:19:48 +0100 Subject: [PATCH 10/10] Proofread comments and documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply the English-style catalog across the project-owned prose — Javadoc, KDoc, code comments, and the Markdown docs. The fixes cover restrictive `which` → `that`, missing articles, verb complementation (`allows to do` → `allows doing`), genuine typos (`RDMBS`, `modifiers` for `modifies`, `type or` for `type of`, `in` for `is`), `-ly` adverbs freed of hyphens, and comma mechanics. A few defects beyond the catalog are repaired as well: a garbled sentence and a subjectless clause in `DataSourceConfig`, wrong prepositions in `DeleteOne` and `OperationFactory`, `basing on` → `based on`, a possessive `which` → `whose`, and a malformed `{@code}` tag in `ContainsQuery`. The sweep covers the whole repository; `buildSrc` and the other config-distributed files are excluded as upstream-owned. Co-Authored-By: Claude Fable 5 --- CONTRIBUTING.md | 4 ++-- docs/queries.md | 22 +++++++++---------- docs/tables.md | 18 +++++++-------- docs/type-mapping.md | 4 ++-- .../server/storage/jdbc/DataSourceConfig.java | 12 +++++----- .../storage/jdbc/JdbcStorageFactory.java | 14 ++++++------ .../io/spine/server/storage/jdbc/Sql.java | 7 +++--- .../io/spine/server/storage/jdbc/Type.java | 10 ++++----- .../storage/jdbc/TypeMappingBuilder.java | 6 ++--- .../jdbc/config/CreateOperationFactory.java | 6 ++--- .../delivery/JdbcShardedWorkRegistry.java | 6 ++--- .../jdbc/delivery/SessionRecordColumn.java | 8 +++---- .../storage/jdbc/engine/PredefinedEngine.java | 10 ++++----- .../storage/jdbc/operation/CreateTable.java | 6 ++--- .../storage/jdbc/operation/DeleteOne.java | 6 ++--- .../jdbc/operation/OperationFactory.java | 22 +++++++++---------- .../storage/jdbc/operation/WriteOne.java | 8 +++---- .../storage/jdbc/query/ContainsQuery.java | 7 +++--- .../server/storage/jdbc/query/DbIterator.java | 10 ++++----- .../storage/jdbc/query/ModifyQuery.java | 8 +++---- .../storage/jdbc/query/QueryPredicates.java | 8 +++---- .../jdbc/query/SelectMessagesByQuery.java | 10 ++++----- .../jdbc/query/reader/IndexColumnReaders.java | 8 +++---- .../storage/jdbc/record/JdbcRecord.java | 6 ++--- .../jdbc/record/JdbcRecordStorage.java | 8 +++---- .../storage/jdbc/record/JdbcTableSpec.java | 8 +++---- .../storage/jdbc/record/RecordTable.java | 6 ++--- .../storage/jdbc/record/column/IdColumn.java | 6 ++--- .../storage/jdbc/type/JdbcColumnMapping.java | 10 ++++----- .../record/given/HistoryStorageTestEnv.kt | 2 +- 30 files changed, 134 insertions(+), 132 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2185ef6d5..896546ae0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,12 +21,12 @@ Code contributions should: Contributor License Agreement ----------------------------- Contributions to the code of Spine Event Engine framework and its libraries must be accompanied by -Contributor License Agreement (CLA). +a Contributor License Agreement (CLA). * If you are an individual writing original source code and you're sure you own the intellectual property, then you'll need to sign an individual CLA. - * If you work for a company which wants you to contribute your work, + * If you work for a company that wants you to contribute your work, then an authorized person from your company will need to sign a corporate CLA. Please [contact us][legal-email] for arranging the paper formalities. diff --git a/docs/queries.md b/docs/queries.md index 0ddc383f2..debfca929 100644 --- a/docs/queries.md +++ b/docs/queries.md @@ -4,25 +4,25 @@ ## Defaults -All operations against RDBMS tables which this library holds, -are created through `OperationFactory`. List of operations is available -via `io.spine.server.storage.jdbc.operation` package. +All operations against RDBMS tables that this library holds +are created through `OperationFactory`. The list of operations is available +via the `io.spine.server.storage.jdbc.operation` package. Each operation creates a corresponding query. Their default implementations are available -in `io.spine.server.storage.jdbc.query` package. Most of the queries use a vanilla SQL syntax +in the `io.spine.server.storage.jdbc.query` package. Most of the queries use a vanilla SQL syntax compatible with the majority of modern RDBMS engines. However, in its generic form, -`WriteOne` operation executes two queries: one to understand whether the record already exists, +the `WriteOne` operation executes two queries: one to understand whether the record already exists, and the second one to either `INSERT` or `UPDATE` the record by its ID. -Special support is provided for queries targeting MySQL. In particular, when MySQL engine -is detected from the provided data source, `WriteOne` is substituted by `MySqlWriteOne` operation, -which in turn utilizes an `INSERT ... ON DUPLICATE KEY UPDATE` syntax specific to this engine. -It allows to significantly enhance the performance for most typical scenarios, such as updating -an Entity state. +Special support is provided for queries targeting MySQL. In particular, when the MySQL engine +is detected from the provided data source, `WriteOne` is substituted by the `MySqlWriteOne` +operation, which in turn utilizes an `INSERT ... ON DUPLICATE KEY UPDATE` syntax specific +to this engine. It allows significantly enhancing the performance for most typical scenarios, +such as updating an Entity state. ## RDBMS engine detection -By default, RDBMS engine is detected from the predefined list of engines. +By default, the RDBMS engine is detected from the predefined list of engines. See `io.spine.server.storage.jdbc.engine.PredefinedEngine` for more detail. It is also possible to customize the engine, see more on that below. diff --git a/docs/tables.md b/docs/tables.md index 354a7a350..384b2c61d 100644 --- a/docs/tables.md +++ b/docs/tables.md @@ -4,11 +4,11 @@ ## Naming and structure -Each Entity registered within application's Bounded Contexts has a corresponding RDBMS table. +Each Entity registered within the application's Bounded Contexts has a corresponding RDBMS table. Additionally, the framework has some system Entities and other types (such as `InboxMessage`) -which are also stored in their tables. +that are also stored in their tables. -For each type of stored records, the framework automatically creates an RDMBS table, +For each type of stored records, the framework automatically creates an RDBMS table, if it does not exist. The name of the table is composed according to the following scheme: @@ -17,7 +17,7 @@ The name of the table is composed according to the following scheme: (Package of Proto message + message name) -> (replace `.` with `_`) -> result ``` -E.g. a table name for an Entity, which has a state declared by `bar.acme.Project` would be +E.g. a table name for an Entity that has a state declared by `bar.acme.Project` would be "bar_acme_Project". Each table created has the following structure: @@ -69,15 +69,15 @@ these are `entity_id`, `created`, and `version`. A grouped table can also be given a custom name; see [Customization](#customization). -:warning: Group names are the fully-qualified names of Proto types, so the names +:warning: Group names are the fully qualified names of Proto types, so the names of grouped tables run longer than the ungrouped ones. Mind the identifier length limits of the underlying DB engine — e.g., 64 characters on MySQL — when naming the Proto packages of entity states. ## Adding new `(column)` -In scope of development cycle, there may arise a need to modify the declaration of -Proto messages stored as records, by marking more fields with `(column)` option. +In the scope of the development cycle, there may arise a need to modify the declaration of +Proto messages stored as records, by marking more fields with the `(column)` option. In this case, it is important to understand that the framework will **not** be updating the structure of existing tables in the underlying storage. @@ -118,12 +118,12 @@ Therefore, **no table indexes are automatically generated**. Prior to production use, it is recommended to launch the Spine-based application in a load-testing mode on top of the RDBMS of choice, analyze the usage scenarios, -and manually create indexes which suit the scenarios best. +and manually create indexes that suit the scenarios best. ## Customization The library provides an API to customize the RDBMS tables used by storage instances. -It is available as a part of `JdbcStorageFactory.Builder` API. +It is available as a part of the `JdbcStorageFactory.Builder` API. It is possible to configure several aspects: diff --git a/docs/type-mapping.md b/docs/type-mapping.md index c96b0a0c7..3012e4644 100644 --- a/docs/type-mapping.md +++ b/docs/type-mapping.md @@ -1,7 +1,7 @@ # SQL type mapping The framework provides a `TypeMapping` to configure the SQL types, which fit the target storage. -The mapping defines correspondence of `Type` to a name for a particular database. +The mapping defines the correspondence of `Type` to a name for a particular database. `Type` is an abstraction for a data type in a database. The type mapping is selected automatically based on the database product name and version, @@ -78,7 +78,7 @@ ALTER TABLE `` MODIFY `ID` VARCHAR(512) CHARACTER SET utf8mb4 COLLATE utf If the automatically selected mapping doesn't match your requirements, a custom mapping can be specified during creation of `JdbcStorageFactory`. -The library exposes `TypeMappingBuilder.mappingBuilder()` shortcut, returning a builder +The library exposes the `TypeMappingBuilder.mappingBuilder()` shortcut, returning a builder already containing default names for all data types. The designed usage scenario is to override the values for required keys: diff --git a/rdbms/src/main/java/io/spine/server/storage/jdbc/DataSourceConfig.java b/rdbms/src/main/java/io/spine/server/storage/jdbc/DataSourceConfig.java index 10a40bd30..d8b7338bb 100644 --- a/rdbms/src/main/java/io/spine/server/storage/jdbc/DataSourceConfig.java +++ b/rdbms/src/main/java/io/spine/server/storage/jdbc/DataSourceConfig.java @@ -1,11 +1,11 @@ /* - * Copyright 2023, TeamDev. All rights reserved. + * Copyright 2026, TeamDev. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * https://www.apache.org/licenses/LICENSE-2.0 * * Redistribution and use in source and/or binary forms, with or without * modification, must retain the above copyright notice and the following @@ -281,8 +281,8 @@ public String getJdbcUrl() { * {@code driverClassName} property, but try it first without. * *

NOTE: if this property is used, you may still use {@link DataSource} properties - * to configure your driver and is in fact recommended over driver parameters specified in - * the URL itself. + * to configure your driver, and doing so is in fact recommended over driver parameters + * specified in the URL itself. * *

Examples of JDBC URL (HyperSQL DB): * @@ -444,7 +444,7 @@ public Builder setIdleTimeout(Long idleTimeout) { *

When a connection reaches this timeout it is retired from the pool, * subject to a maximum variation of +30 seconds. * - *

An in-use connection is never retired, only when it is closed will it is removed then. + *

An in-use connection is never retired; only when it is closed will it be removed. * *

NOTE: It is strongly recommended to set this value, * and it should be at least 30 seconds less than any database-level connection timeout. @@ -470,7 +470,7 @@ public Builder setMaxLifetime(Long maxLifetime) { } /** - * If your driver supports JDBC4 it is strongly recommended not setting + * If your driver supports JDBC4 it is strongly recommended not to set * this property. * It is for "legacy" databases that do not support the JDBC4 * {@link Connection#isValid(int)} API. diff --git a/rdbms/src/main/java/io/spine/server/storage/jdbc/JdbcStorageFactory.java b/rdbms/src/main/java/io/spine/server/storage/jdbc/JdbcStorageFactory.java index f0b881bef..65c8bd9ea 100644 --- a/rdbms/src/main/java/io/spine/server/storage/jdbc/JdbcStorageFactory.java +++ b/rdbms/src/main/java/io/spine/server/storage/jdbc/JdbcStorageFactory.java @@ -105,7 +105,7 @@ private JdbcStorageFactory(Builder builder) { } /** - * Returns an SQL statement which would allow to manually create an RDBMS table + * Returns an SQL statement that would allow manually creating an RDBMS table * corresponding to some Entity registered in a certain Bounded Context. * * @param contextSpec @@ -151,7 +151,7 @@ public boolean isOpen() { } /** - * Closes used {@link DataSourceWrapper}. + * Closes the used {@link DataSourceWrapper}. */ @Override public void close() { @@ -279,7 +279,7 @@ public Builder setColumnMapping(JdbcColumnMapping columnMapping) { } /** - * Sets required field {@code dataSource}. + * Sets the required field {@code dataSource}. */ public Builder setDataSource(DataSourceWrapper dataSource) { this.dataSource = dataSource; @@ -287,7 +287,7 @@ public Builder setDataSource(DataSourceWrapper dataSource) { } /** - * Sets required field {@code dataSource} from the wrapped {@link DataSource}. + * Sets the required field {@code dataSource} from the wrapped {@link DataSource}. * * @see DataSourceWrapper#wrap(DataSource) */ @@ -315,7 +315,7 @@ public Builder setDataSource(DataSourceConfig dataSource) { * to build a custom mapping. * *

If the mapping was not specified, it is - * {@linkplain PredefinedMapping#select(DataSourceWrapper) selected} basing on + * {@linkplain PredefinedMapping#select(DataSourceWrapper) selected} based on * the {@linkplain java.sql.DatabaseMetaData#getDatabaseProductName() database product name} * and the database version. * @@ -391,7 +391,7 @@ Builder setTableName(Class recordType, String name) { * {@linkplain io.spine.server.storage.jdbc.record.TableNames#of(Class, StorageGroup) * named after the group and the record type}. * - *

It is a responsibility of callers to select a name which does not collide + *

It is a responsibility of callers to select a name that does not collide * with the names of other tables, including the generated ones. * * @param stateType @@ -422,7 +422,7 @@ Builder setTableName(Class stateType, Class recordType, String name) { *

The mapping previously set, if any, is replaced with this call. * *

In case no custom mapping is defined for some table, - * a {@linkplain #setColumnMapping(JdbcColumnMapping) a factory-wide value} is used. + * {@linkplain #setColumnMapping(JdbcColumnMapping) a factory-wide value} is used. * *

Unlike a {@linkplain #setTableName(Class, String) custom table name}, * a custom mapping set for an entity state type also applies to the tables of diff --git a/rdbms/src/main/java/io/spine/server/storage/jdbc/Sql.java b/rdbms/src/main/java/io/spine/server/storage/jdbc/Sql.java index c24e473d7..37338c840 100644 --- a/rdbms/src/main/java/io/spine/server/storage/jdbc/Sql.java +++ b/rdbms/src/main/java/io/spine/server/storage/jdbc/Sql.java @@ -1,11 +1,11 @@ /* - * Copyright 2023, TeamDev. All rights reserved. + * Copyright 2026, TeamDev. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * https://www.apache.org/licenses/LICENSE-2.0 * * Redistribution and use in source and/or binary forms, with or without * modification, must retain the above copyright notice and the following @@ -33,7 +33,8 @@ * build SQL expressions. * *

All the {@code enum} values have a valid token string representation, i.e. - * {@link Enum#toString() toString()} method returns a valid SQL token wrapped into the whitespaces. + * the {@link Enum#toString() toString()} method returns a valid SQL token wrapped + * into the whitespaces. */ @SuppressWarnings({"UtilityClass", "PMD.MissingStaticMethodInNonInstantiatableClass"}) public final class Sql { diff --git a/rdbms/src/main/java/io/spine/server/storage/jdbc/Type.java b/rdbms/src/main/java/io/spine/server/storage/jdbc/Type.java index da028d6b7..fa05f9dcb 100644 --- a/rdbms/src/main/java/io/spine/server/storage/jdbc/Type.java +++ b/rdbms/src/main/java/io/spine/server/storage/jdbc/Type.java @@ -1,11 +1,11 @@ /* - * Copyright 2023, TeamDev. All rights reserved. + * Copyright 2026, TeamDev. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * https://www.apache.org/licenses/LICENSE-2.0 * * Redistribution and use in source and/or binary forms, with or without * modification, must retain the above copyright notice and the following @@ -61,19 +61,19 @@ public enum Type { DOUBLE, /** - * The type representing a {@code String}, maximum length of which + * The type representing a {@code String}, the maximum length of which * doesn't exceed 255 characters. */ STRING_255, /** - * The type representing a {@code String}, maximum length of which + * The type representing a {@code String}, the maximum length of which * doesn't exceed 512 characters. */ STRING_512, /** - * The type representing a {@code String}, maximum length of which is unknown. + * The type representing a {@code String}, the maximum length of which is unknown. */ STRING, diff --git a/rdbms/src/main/java/io/spine/server/storage/jdbc/TypeMappingBuilder.java b/rdbms/src/main/java/io/spine/server/storage/jdbc/TypeMappingBuilder.java index 2e0ffd57d..47fefe55d 100644 --- a/rdbms/src/main/java/io/spine/server/storage/jdbc/TypeMappingBuilder.java +++ b/rdbms/src/main/java/io/spine/server/storage/jdbc/TypeMappingBuilder.java @@ -1,11 +1,11 @@ /* - * Copyright 2023, TeamDev. All rights reserved. + * Copyright 2026, TeamDev. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * https://www.apache.org/licenses/LICENSE-2.0 * * Redistribution and use in source and/or binary forms, with or without * modification, must retain the above copyright notice and the following @@ -57,7 +57,7 @@ public final class TypeMappingBuilder { /** * Creates a new builder. * - *

Exposed just for tests. Otherwise, would be {@code private}. + *

Exposed just for tests. Otherwise, it would be {@code private}. */ @VisibleForTesting TypeMappingBuilder() { diff --git a/rdbms/src/main/java/io/spine/server/storage/jdbc/config/CreateOperationFactory.java b/rdbms/src/main/java/io/spine/server/storage/jdbc/config/CreateOperationFactory.java index 9b1ac52e1..8f5ac5b90 100644 --- a/rdbms/src/main/java/io/spine/server/storage/jdbc/config/CreateOperationFactory.java +++ b/rdbms/src/main/java/io/spine/server/storage/jdbc/config/CreateOperationFactory.java @@ -1,11 +1,11 @@ /* - * Copyright 2023, TeamDev. All rights reserved. + * Copyright 2026, TeamDev. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * https://www.apache.org/licenses/LICENSE-2.0 * * Redistribution and use in source and/or binary forms, with or without * modification, must retain the above copyright notice and the following @@ -31,7 +31,7 @@ import io.spine.server.storage.jdbc.operation.OperationFactory; /** - * A function which creates an instance of {@link OperationFactory}. + * A function that creates an instance of {@link OperationFactory}. * * @see io.spine.server.storage.jdbc.JdbcStorageFactory.Builder#useOperationFactory(CreateOperationFactory) */ diff --git a/rdbms/src/main/java/io/spine/server/storage/jdbc/delivery/JdbcShardedWorkRegistry.java b/rdbms/src/main/java/io/spine/server/storage/jdbc/delivery/JdbcShardedWorkRegistry.java index 44febefa3..fae344c77 100644 --- a/rdbms/src/main/java/io/spine/server/storage/jdbc/delivery/JdbcShardedWorkRegistry.java +++ b/rdbms/src/main/java/io/spine/server/storage/jdbc/delivery/JdbcShardedWorkRegistry.java @@ -1,11 +1,11 @@ /* - * Copyright 2023, TeamDev. All rights reserved. + * Copyright 2026, TeamDev. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * https://www.apache.org/licenses/LICENSE-2.0 * * Redistribution and use in source and/or binary forms, with or without * modification, must retain the above copyright notice and the following @@ -132,7 +132,7 @@ public void release(ShardSessionRecord record) { /** * Creates a worker ID by combining the given node ID with the ID of the current Java thread, - * in which the execution in performed. + * in which the execution is performed. */ @Override protected WorkerId currentWorkerFor(NodeId node) { diff --git a/rdbms/src/main/java/io/spine/server/storage/jdbc/delivery/SessionRecordColumn.java b/rdbms/src/main/java/io/spine/server/storage/jdbc/delivery/SessionRecordColumn.java index b5ffcc435..87412e48f 100644 --- a/rdbms/src/main/java/io/spine/server/storage/jdbc/delivery/SessionRecordColumn.java +++ b/rdbms/src/main/java/io/spine/server/storage/jdbc/delivery/SessionRecordColumn.java @@ -1,11 +1,11 @@ /* - * Copyright 2023, TeamDev. All rights reserved. + * Copyright 2026, TeamDev. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * https://www.apache.org/licenses/LICENSE-2.0 * * Redistribution and use in source and/or binary forms, with or without * modification, must retain the above copyright notice and the following @@ -36,8 +36,8 @@ /** * The definitions of record columns to store along with the {@link ShardSessionRecord}. * - * @apiNote This type is made {@code public} to allow library users query the stored - * {@code ShardSessionRecord}s via storage API, in case they need to read + * @apiNote This type is made {@code public} to allow library users to query the stored + * {@code ShardSessionRecord}s via the storage API, in case they need to read * the storage contents manually. */ @RecordColumns(ofType = ShardSessionRecord.class) diff --git a/rdbms/src/main/java/io/spine/server/storage/jdbc/engine/PredefinedEngine.java b/rdbms/src/main/java/io/spine/server/storage/jdbc/engine/PredefinedEngine.java index 1ac64d07d..6cbf5df50 100644 --- a/rdbms/src/main/java/io/spine/server/storage/jdbc/engine/PredefinedEngine.java +++ b/rdbms/src/main/java/io/spine/server/storage/jdbc/engine/PredefinedEngine.java @@ -1,11 +1,11 @@ /* - * Copyright 2023, TeamDev. All rights reserved. + * Copyright 2026, TeamDev. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * https://www.apache.org/licenses/LICENSE-2.0 * * Redistribution and use in source and/or binary forms, with or without * modification, must retain the above copyright notice and the following @@ -35,9 +35,9 @@ /** * The DB engines known to this library, - * which make difference in terms of providing specific {@link Operation} implementations. + * which make a difference in terms of providing specific {@link Operation} implementations. * - *

This list isn't expected to be complete. However, it may get bigger as far as + *

This list isn't expected to be complete. However, it may get bigger as * the Spine routines are getting some known optimizations on other DB engines. */ public enum PredefinedEngine implements DetectedEngine { @@ -80,7 +80,7 @@ public static PredefinedEngine from(DataSourceMetaData metaData) { } /** - * Returns a generic name for each of predefined engines. + * Returns a generic name for each of the predefined engines. * *

Version is not included. */ diff --git a/rdbms/src/main/java/io/spine/server/storage/jdbc/operation/CreateTable.java b/rdbms/src/main/java/io/spine/server/storage/jdbc/operation/CreateTable.java index 4b721ea1c..26b305a58 100644 --- a/rdbms/src/main/java/io/spine/server/storage/jdbc/operation/CreateTable.java +++ b/rdbms/src/main/java/io/spine/server/storage/jdbc/operation/CreateTable.java @@ -1,11 +1,11 @@ /* - * Copyright 2023, TeamDev. All rights reserved. + * Copyright 2026, TeamDev. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * https://www.apache.org/licenses/LICENSE-2.0 * * Redistribution and use in source and/or binary forms, with or without * modification, must retain the above copyright notice and the following @@ -71,7 +71,7 @@ public class CreateTable extends Operation implement * the framework to work properly. Hence, the tables which include them should make these * values {@code DEFAULT} for these columns. * - *

The map stores the names of the Entity Columns as a string keys for simplicity and + *

The map stores the names of the Entity Columns as string keys for simplicity and * the default values of the Columns as the map values. */ private static final ImmutableMap COLUMN_DEFAULTS = diff --git a/rdbms/src/main/java/io/spine/server/storage/jdbc/operation/DeleteOne.java b/rdbms/src/main/java/io/spine/server/storage/jdbc/operation/DeleteOne.java index 2342ba5c7..473a49d8b 100644 --- a/rdbms/src/main/java/io/spine/server/storage/jdbc/operation/DeleteOne.java +++ b/rdbms/src/main/java/io/spine/server/storage/jdbc/operation/DeleteOne.java @@ -1,11 +1,11 @@ /* - * Copyright 2023, TeamDev. All rights reserved. + * Copyright 2026, TeamDev. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * https://www.apache.org/licenses/LICENSE-2.0 * * Redistribution and use in source and/or binary forms, with or without * modification, must retain the above copyright notice and the following @@ -32,7 +32,7 @@ import io.spine.server.storage.jdbc.record.RecordTable; /** - * Deletes a single record to the database by its identifier. + * Deletes a single record from the database by its identifier. * * @param * the type of the identifiers of the stored records diff --git a/rdbms/src/main/java/io/spine/server/storage/jdbc/operation/OperationFactory.java b/rdbms/src/main/java/io/spine/server/storage/jdbc/operation/OperationFactory.java index 7867c6ab8..a1dae8d4b 100644 --- a/rdbms/src/main/java/io/spine/server/storage/jdbc/operation/OperationFactory.java +++ b/rdbms/src/main/java/io/spine/server/storage/jdbc/operation/OperationFactory.java @@ -1,11 +1,11 @@ /* - * Copyright 2023, TeamDev. All rights reserved. + * Copyright 2026, TeamDev. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * https://www.apache.org/licenses/LICENSE-2.0 * * Redistribution and use in source and/or binary forms, with or without * modification, must retain the above copyright notice and the following @@ -51,7 +51,7 @@ * to build a JdbcStorageFactory}. * *

Also, all query types are made {@code public} and marked as {@code SPI} elements, - * since they may also be involved into customizing the actual SQL queries. + * since they may also be involved in customizing the actual SQL queries. */ @SPI public class OperationFactory { @@ -89,7 +89,7 @@ protected OperationFactory(DataSourceWrapper wrapper, } /** - * Produces an operation which writes a single record to the table. + * Produces an operation that writes a single record to the table. * * @param t * the table to perform the operation over @@ -108,7 +108,7 @@ public WriteOne writeOne(RecordTable t) { } /** - * Produces an operation which writes several records to the table. + * Produces an operation that writes several records to the table. * * @param t * the table to perform the operation over @@ -123,7 +123,7 @@ public WriteBulk writeBulk(RecordTable t) { } /** - * Produces an operation which reads several records from the table by their IDs. + * Produces an operation that reads several records from the table by their IDs. * * @param t * the table to perform the operation over @@ -138,7 +138,7 @@ public ReadManyByIds readManyByIds(RecordTable ReadManyByQuery readManyByQuery(RecordTable< } /** - * Produces an operation which deletes a single record from the table. + * Produces an operation that deletes a single record from the table. * * @param t * the table to perform the operation over @@ -168,7 +168,7 @@ public DeleteOne deleteOne(RecordTable t) { } /** - * Produces an operation which deletes several records from the table by their IDs. + * Produces an operation that deletes several records from the table by their IDs. * * @param t * the table to perform the operation over @@ -183,7 +183,7 @@ public DeleteManyByIds deleteManyByIds(RecordTable< } /** - * Produces an operation which creates the table in the underlying database. + * Produces an operation that creates the table in the underlying database. * * @param t * the table to perform the operation over @@ -198,7 +198,7 @@ public CreateTable createTable(RecordTable t) } /** - * Produces an operation reads the identifiers of the records stored in the table. + * Produces an operation that reads the identifiers of the records stored in the table. * * @param t * the table to perform the operation over diff --git a/rdbms/src/main/java/io/spine/server/storage/jdbc/operation/WriteOne.java b/rdbms/src/main/java/io/spine/server/storage/jdbc/operation/WriteOne.java index bd0f7881a..d06e26a67 100644 --- a/rdbms/src/main/java/io/spine/server/storage/jdbc/operation/WriteOne.java +++ b/rdbms/src/main/java/io/spine/server/storage/jdbc/operation/WriteOne.java @@ -1,11 +1,11 @@ /* - * Copyright 2023, TeamDev. All rights reserved. + * Copyright 2026, TeamDev. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * https://www.apache.org/licenses/LICENSE-2.0 * * Redistribution and use in source and/or binary forms, with or without * modification, must retain the above copyright notice and the following @@ -77,11 +77,11 @@ public void execute(JdbcRecord record) { } /** - * Checks if the table contains a record with given ID. + * Checks if the table contains a record with the given ID. * * @param id * an ID to check - * @return {@code true} if there is a record with such ID in the table, + * @return {@code true} if there is a record with such an ID in the table, * {@code false} otherwise */ protected final boolean containsRecord(I id) { diff --git a/rdbms/src/main/java/io/spine/server/storage/jdbc/query/ContainsQuery.java b/rdbms/src/main/java/io/spine/server/storage/jdbc/query/ContainsQuery.java index 8c60e1257..a9960a47d 100644 --- a/rdbms/src/main/java/io/spine/server/storage/jdbc/query/ContainsQuery.java +++ b/rdbms/src/main/java/io/spine/server/storage/jdbc/query/ContainsQuery.java @@ -1,11 +1,11 @@ /* - * Copyright 2023, TeamDev. All rights reserved. + * Copyright 2026, TeamDev. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * https://www.apache.org/licenses/LICENSE-2.0 * * Redistribution and use in source and/or binary forms, with or without * modification, must retain the above copyright notice and the following @@ -47,7 +47,8 @@ private ContainsQuery(Builder builder) { } /** - * Returns {@code true} if there is at least one record with given ID, {@code} false otherwise. + * Returns {@code true} if there is at least one record with the given ID, + * {@code false} otherwise. */ @Override public Boolean execute() { diff --git a/rdbms/src/main/java/io/spine/server/storage/jdbc/query/DbIterator.java b/rdbms/src/main/java/io/spine/server/storage/jdbc/query/DbIterator.java index fcf2b39e7..0df2b52b3 100644 --- a/rdbms/src/main/java/io/spine/server/storage/jdbc/query/DbIterator.java +++ b/rdbms/src/main/java/io/spine/server/storage/jdbc/query/DbIterator.java @@ -1,11 +1,11 @@ /* - * Copyright 2023, TeamDev. All rights reserved. + * Copyright 2026, TeamDev. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * https://www.apache.org/licenses/LICENSE-2.0 * * Redistribution and use in source and/or binary forms, with or without * modification, must retain the above copyright notice and the following @@ -50,7 +50,7 @@ * *

Uses {@link Serializer} to deserialize records. * - *

NOTE: {@code remove} operation is not supported. + *

NOTE: the {@code remove} operation is not supported. * * @param * the type of storage records @@ -179,7 +179,7 @@ public final void remove() throws UnsupportedOperationException { /** * Closes {@link #resultSet} and the related {@link Statement} and {@link Connection}. * - *

This method should be called either manually or called by {@link #hasNext()}. + *

This method should be called either manually or by {@link #hasNext()}. * * @throws DatabaseException * if {@code SQLException} occurred @@ -190,7 +190,7 @@ public void close() throws DatabaseException { if (!resultSet.isClosed()) { // Get statement before closing the result set, because PostgreSQL doesn't allow - // to retrieve a statement if a result set is closed. + // retrieving a statement if a result set is closed. // The same strategy to obtain the connection is also safer. var statement = resultSet.getStatement(); resultSet.close(); diff --git a/rdbms/src/main/java/io/spine/server/storage/jdbc/query/ModifyQuery.java b/rdbms/src/main/java/io/spine/server/storage/jdbc/query/ModifyQuery.java index df5dd14bd..4a845ab71 100644 --- a/rdbms/src/main/java/io/spine/server/storage/jdbc/query/ModifyQuery.java +++ b/rdbms/src/main/java/io/spine/server/storage/jdbc/query/ModifyQuery.java @@ -1,11 +1,11 @@ /* - * Copyright 2023, TeamDev. All rights reserved. + * Copyright 2026, TeamDev. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * https://www.apache.org/licenses/LICENSE-2.0 * * Redistribution and use in source and/or binary forms, with or without * modification, must retain the above copyright notice and the following @@ -30,7 +30,7 @@ import com.google.protobuf.Message; /** - * A query which makes changes in a data source. + * A query that makes changes in a data source. * * @param * the type of the record identifiers @@ -46,7 +46,7 @@ protected ModifyQuery( } /** - * Executes a query which modifiers the underlying table, and returns + * Executes a query that modifies the underlying table, and returns * the number of affected rows. * * @return the number of affected rows diff --git a/rdbms/src/main/java/io/spine/server/storage/jdbc/query/QueryPredicates.java b/rdbms/src/main/java/io/spine/server/storage/jdbc/query/QueryPredicates.java index e36014fc7..0f241266f 100644 --- a/rdbms/src/main/java/io/spine/server/storage/jdbc/query/QueryPredicates.java +++ b/rdbms/src/main/java/io/spine/server/storage/jdbc/query/QueryPredicates.java @@ -1,11 +1,11 @@ /* - * Copyright 2023, TeamDev. All rights reserved. + * Copyright 2026, TeamDev. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * https://www.apache.org/licenses/LICENSE-2.0 * * Redistribution and use in source and/or binary forms, with or without * modification, must retain the above copyright notice and the following @@ -50,7 +50,7 @@ import static io.spine.util.Exceptions.newIllegalArgumentException; /** - * A utility methods to work with {@linkplain Predicate predicates}. + * Utility methods to work with {@linkplain Predicate predicates}. */ public final class QueryPredicates { @@ -64,7 +64,7 @@ private QueryPredicates() { *

If there are no IDs, the resulting predicate will return {@code true} always. * * @param column - * the {@link IdColumn} describing ID to match against + * the {@link IdColumn} describing the ID to match against * @param ids * the IDs to match * @param diff --git a/rdbms/src/main/java/io/spine/server/storage/jdbc/query/SelectMessagesByQuery.java b/rdbms/src/main/java/io/spine/server/storage/jdbc/query/SelectMessagesByQuery.java index e0bbd9b5e..ee44fc082 100644 --- a/rdbms/src/main/java/io/spine/server/storage/jdbc/query/SelectMessagesByQuery.java +++ b/rdbms/src/main/java/io/spine/server/storage/jdbc/query/SelectMessagesByQuery.java @@ -1,11 +1,11 @@ /* - * Copyright 2023, TeamDev. All rights reserved. + * Copyright 2026, TeamDev. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * https://www.apache.org/licenses/LICENSE-2.0 * * Redistribution and use in source and/or binary forms, with or without * modification, must retain the above copyright notice and the following @@ -140,8 +140,8 @@ private Iterator maskFields(Iterator records) { * * @param * the type of identifiers of the queried records - * @param the - * type of queried records + * @param + * the type of queried records * @return a new {@code Builder} instance */ public static Builder newBuilder() { @@ -174,7 +174,7 @@ public Builder setQuery(RecordQuery recordQuery) { /** * {@inheritDoc} * - *

Checks that all the builder fields were set to a non-{@code null} values. + *

Checks that all the builder fields were set to non-{@code null} values. */ @Override protected void checkPreconditions() throws IllegalStateException { diff --git a/rdbms/src/main/java/io/spine/server/storage/jdbc/query/reader/IndexColumnReaders.java b/rdbms/src/main/java/io/spine/server/storage/jdbc/query/reader/IndexColumnReaders.java index cf0f95093..9b88e9910 100644 --- a/rdbms/src/main/java/io/spine/server/storage/jdbc/query/reader/IndexColumnReaders.java +++ b/rdbms/src/main/java/io/spine/server/storage/jdbc/query/reader/IndexColumnReaders.java @@ -1,11 +1,11 @@ /* - * Copyright 2023, TeamDev. All rights reserved. + * Copyright 2026, TeamDev. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * https://www.apache.org/licenses/LICENSE-2.0 * * Redistribution and use in source and/or binary forms, with or without * modification, must retain the above copyright notice and the following @@ -32,7 +32,7 @@ import static io.spine.util.Exceptions.newIllegalArgumentException; /** - * A helper class that allows to distinguish between the different {@link ColumnReader} types for + * A helper class that allows distinguishing between the different {@link ColumnReader} types for * the different ID columns. */ final class IndexColumnReaders { @@ -47,7 +47,7 @@ private IndexColumnReaders() { * @param columnName * the name of the ID column * @param idType - * the type of the IDs stored in column + * the type of the IDs stored in the column * @param * the compile-time type of the IDs * @return a new instance of the {@code ColumnReader} diff --git a/rdbms/src/main/java/io/spine/server/storage/jdbc/record/JdbcRecord.java b/rdbms/src/main/java/io/spine/server/storage/jdbc/record/JdbcRecord.java index 3f28cd672..474e5f03c 100644 --- a/rdbms/src/main/java/io/spine/server/storage/jdbc/record/JdbcRecord.java +++ b/rdbms/src/main/java/io/spine/server/storage/jdbc/record/JdbcRecord.java @@ -1,11 +1,11 @@ /* - * Copyright 2023, TeamDev. All rights reserved. + * Copyright 2026, TeamDev. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * https://www.apache.org/licenses/LICENSE-2.0 * * Redistribution and use in source and/or binary forms, with or without * modification, must retain the above copyright notice and the following @@ -41,7 +41,7 @@ * to a certain {@linkplain JdbcTableSpec table specification}. * * @param - * type or record identifiers + * type of record identifiers * @param * type of the record to insert */ diff --git a/rdbms/src/main/java/io/spine/server/storage/jdbc/record/JdbcRecordStorage.java b/rdbms/src/main/java/io/spine/server/storage/jdbc/record/JdbcRecordStorage.java index f7e3e60cc..5b254b86b 100644 --- a/rdbms/src/main/java/io/spine/server/storage/jdbc/record/JdbcRecordStorage.java +++ b/rdbms/src/main/java/io/spine/server/storage/jdbc/record/JdbcRecordStorage.java @@ -60,7 +60,7 @@ public class JdbcRecordStorage extends RecordStorage /** * Creates a new record storage for the records belonging to no storage group, - * and performs the creation of RDBMS table, in case such a table does not exist. + * and performs the creation of an RDBMS table, in case such a table does not exist. * * @param contextSpec * specification of Bounded Context, in scope of which this storage exists @@ -76,7 +76,7 @@ public JdbcRecordStorage(ContextSpec contextSpec, } /** - * Creates a new record storage, and performs the creation of RDBMS table, + * Creates a new record storage, and performs the creation of an RDBMS table, * in case such a table does not exist. * *

The passed storage group, if any, participates in the identity of the table, @@ -101,7 +101,7 @@ public JdbcRecordStorage(ContextSpec contextSpec, /** * Creates a new record storage for the records belonging to no storage group, and - * when asked, performs the creation of RDBMS table if such a table does not exist. + * when asked, performs the creation of an RDBMS table if such a table does not exist. * *

This constructor is internal to the framework. * @@ -123,7 +123,7 @@ public JdbcRecordStorage(ContextSpec contextSpec, } /** - * Creates a new record storage, and when asked, performs the creation of RDBMS table + * Creates a new record storage, and when asked, performs the creation of an RDBMS table * if such a table does not exist. * *

The passed storage group, if any, participates in the identity of the table, diff --git a/rdbms/src/main/java/io/spine/server/storage/jdbc/record/JdbcTableSpec.java b/rdbms/src/main/java/io/spine/server/storage/jdbc/record/JdbcTableSpec.java index 844e640e5..59bebf6d4 100644 --- a/rdbms/src/main/java/io/spine/server/storage/jdbc/record/JdbcTableSpec.java +++ b/rdbms/src/main/java/io/spine/server/storage/jdbc/record/JdbcTableSpec.java @@ -1,11 +1,11 @@ /* - * Copyright 2023, TeamDev. All rights reserved. + * Copyright 2026, TeamDev. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * https://www.apache.org/licenses/LICENSE-2.0 * * Redistribution and use in source and/or binary forms, with or without * modification, must retain the above copyright notice and the following @@ -70,7 +70,7 @@ public final class JdbcTableSpec { /** * Creates a new table specification, also setting a custom name for the table. * - *

It is a responsibility of callers to select the table name which is both unique and + *

It is a responsibility of callers to select the table name that is both unique and * compatible with the requirements of the underlying database engine. * * @param tableName @@ -173,7 +173,7 @@ private void addBytesColumn(Map cols) { * @param record * the record with column * @param name - * the name of the column which value should be obtained + * the name of the column whose value should be obtained */ @Nullable Object valueIn(RecordWithColumns record, ColumnName name) { var column = requireColumn(name); diff --git a/rdbms/src/main/java/io/spine/server/storage/jdbc/record/RecordTable.java b/rdbms/src/main/java/io/spine/server/storage/jdbc/record/RecordTable.java index 74d7e96ed..75e1cc906 100644 --- a/rdbms/src/main/java/io/spine/server/storage/jdbc/record/RecordTable.java +++ b/rdbms/src/main/java/io/spine/server/storage/jdbc/record/RecordTable.java @@ -1,11 +1,11 @@ /* - * Copyright 2023, TeamDev. All rights reserved. + * Copyright 2026, TeamDev. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * https://www.apache.org/licenses/LICENSE-2.0 * * Redistribution and use in source and/or binary forms, with or without * modification, must retain the above copyright notice and the following @@ -123,7 +123,7 @@ public void create() { } /** - * Returns an SQL statement allowing to create this table in the underlying storage. + * Returns an SQL statement for creating this table in the underlying storage. */ @Internal public String creationSql() { diff --git a/rdbms/src/main/java/io/spine/server/storage/jdbc/record/column/IdColumn.java b/rdbms/src/main/java/io/spine/server/storage/jdbc/record/column/IdColumn.java index 2d8dbb502..379d95445 100644 --- a/rdbms/src/main/java/io/spine/server/storage/jdbc/record/column/IdColumn.java +++ b/rdbms/src/main/java/io/spine/server/storage/jdbc/record/column/IdColumn.java @@ -1,11 +1,11 @@ /* - * Copyright 2023, TeamDev. All rights reserved. + * Copyright 2026, TeamDev. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * https://www.apache.org/licenses/LICENSE-2.0 * * Redistribution and use in source and/or binary forms, with or without * modification, must retain the above copyright notice and the following @@ -116,7 +116,7 @@ public static IdColumn of(RecordSpec spec, JdbcColumnMapping mappin * for storing. E.g. it may be useful to store a {@linkplain Message Protobuf Message} * in a JSON representation as a {@code String}. * - *

If an ID type is a simple type as {@code String}, {@code Integer}, etc. + *

If an ID type is a simple type as {@code String}, {@code Integer}, etc., * the method may return the same value. * * @param id diff --git a/rdbms/src/main/java/io/spine/server/storage/jdbc/type/JdbcColumnMapping.java b/rdbms/src/main/java/io/spine/server/storage/jdbc/type/JdbcColumnMapping.java index 9b151a5be..4ffbd758f 100644 --- a/rdbms/src/main/java/io/spine/server/storage/jdbc/type/JdbcColumnMapping.java +++ b/rdbms/src/main/java/io/spine/server/storage/jdbc/type/JdbcColumnMapping.java @@ -1,11 +1,11 @@ /* - * Copyright 2023, TeamDev. All rights reserved. + * Copyright 2026, TeamDev. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * https://www.apache.org/licenses/LICENSE-2.0 * * Redistribution and use in source and/or binary forms, with or without * modification, must retain the above copyright notice and the following @@ -58,7 +58,7 @@ * *

Also defines the RDBMS-level types for the table columns. * - *

Users may extend this type to add own custom mapping for some of the stored types. + *

Users may extend this type to add their own custom mapping for some of the stored types. */ @Experimental @SPI @@ -76,7 +76,7 @@ public class JdbcColumnMapping extends AbstractColumnMapping { * as it returns not a persistence strategy for a column, * but a type to use with RDBMS. Different implementations * may even choose to return different types, - * depending on the version of underlying DB engine. + * depending on the version of the underlying DB engine. */ public Type typeOf(Class columnType) { checkNotNull(columnType); @@ -93,7 +93,7 @@ public Type typeOf(Class columnType) { * by SPI users is used. * * @apiNote This method is made {@code final}, as it is designed - * to use {@code ImmutableMap.Builder}, which does not allow to override values. + * to use {@code ImmutableMap.Builder}, which does not allow overriding values. * Therefore, it is not possible for SPI users to provide their own mapping rules * for types such as {@code Timestamp}, for which this class already has * a default mapping. SPI users should override diff --git a/rdbms/src/test/kotlin/io/spine/server/storage/jdbc/record/given/HistoryStorageTestEnv.kt b/rdbms/src/test/kotlin/io/spine/server/storage/jdbc/record/given/HistoryStorageTestEnv.kt index 4ffebb244..dacb12bde 100644 --- a/rdbms/src/test/kotlin/io/spine/server/storage/jdbc/record/given/HistoryStorageTestEnv.kt +++ b/rdbms/src/test/kotlin/io/spine/server/storage/jdbc/record/given/HistoryStorageTestEnv.kt @@ -70,7 +70,7 @@ internal object HistoryStorageTestEnv { * * The framework composes the journal specification privately. The tests re-create it * to reach the table specifications of grouped storages via - * [JdbcStorageFactory.tableSpecFor], expecting the equally-composed specifications + * [JdbcStorageFactory.tableSpecFor], expecting the equally composed specifications * to resolve to the same tables. */ fun journalSpec(): RecordSpec = RecordSpec(