diff --git a/.agents/tasks/de-event-sourcing-rollout.md b/.agents/tasks/de-event-sourcing-rollout.md new file mode 100644 index 000000000..01caeff00 --- /dev/null +++ b/.agents/tasks/de-event-sourcing-rollout.md @@ -0,0 +1,66 @@ +# Adopt the de-event-sourcing storage API of core-jvm (Phase H rollout) + +Upstream plan: `core-jvm/.agents/tasks/de-event-sourcing-plan.md`, Phase H. +Branch: `de-event-sourcing`. Depends on core-jvm `2.0.0-SNAPSHOT.522`. + +## Problem + +core-jvm removed event-sourced aggregate loading. For storage vendors: + +- `AggregateStorage`, `StorageFactory.createAggregateStorage`, and the published + `AggregateStorageTest` fixture are **removed**. Aggregate latest state arrives + via `createEntityRecordStorage`/`createRecordStorage` with `group == null`. +- `createRecordStorage` gained a `@Nullable StorageGroup group` parameter. + Non-null groups arrive from the per-entity histories (`EntityEventStorage`, + `EntityStateHistoryStorage`), both named after the entity state type. + The vendor must allocate physical storage by the **(group, recordType)** pair. +- `createEntityStateHistoryStorage` may be invoked concurrently (delivery + worker threads); the factory must tolerate that. + +Without honoring the group, JDBC table identity (`RecordSpec.sourceType()` alone) +conflates: all event journals with each other **and** with the event log +(`sourceType == Event` everywhere), and an entity's state history with its +latest-state storage — with `TableSpecs` handing out a cached spec with +the wrong ID column type. + +## Fix + +Mechanical half (earlier commits on the branch): 3-arg `createRecordStorage` +signature, `RecordStorageDelegateTest` → `DelegatingRecordStorageTest` renames, +obsolete aggregate test suites deleted, version bumped (`.110`). + +Substantive half (this change): + +- `TableNames.of(recordType, group)`: grouped table name = + sanitized group name + `_` + record type simple name + (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`). 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 + with `group = null` (`JdbcSessionStorage`, `tableCreationSql` unchanged). +- Deleted the dead `io.spine.server.storage.jdbc.aggregate` package + (dangling `{@link AggregateStorage}` broke Dokka). +- `docs/tables.md`: "Grouped tables" section. + +New Kotlin specs (H2): `GroupedTableAllocationSpec` (the vendor allocation +contract), `JdbcEntityEventStorageSpec`, `JdbcEntityStateHistoryStorageSpec` +(round-trips incl. `EntityStateKey` Message ID, upsert overwrite, `stateAt`, +`trim`, `truncate`), `ConcurrentHistoryCreationSpec`. + +## Follow-ups (out of scope) + +- Grouped-DDL export via `tableCreationSql` (currently latest-state only). + +## Status + +Implemented; `./gradlew build dokkaGenerate` green. Delete this file on merge +to master. 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/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/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/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 51f6256a2..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: @@ -33,10 +33,51 @@ 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`. + +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 — +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. @@ -51,8 +92,7 @@ public static final class MyProjection } var boundedContextSpec = // ... -var factory = JdbcStorageFactory - .newBuilder() +var factory = JdbcStorageFactory.newBuilder() // ... .build(); @@ -78,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: @@ -95,21 +135,38 @@ 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: + // 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: 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: ```java 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/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index df6a6ad76..a9db11550 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip networkTimeout=10000 retries=0 retryBackOffMs=500 diff --git a/gradlew b/gradlew index b9bb139f7..249efbb03 100755 --- a/gradlew +++ b/gradlew @@ -20,7 +20,7 @@ ############################################################################## # -# Gradle start up script for POSIX generated by Gradle. +# gradlew start up script for POSIX generated by Gradle. # # Important for running: # @@ -29,7 +29,7 @@ # bash, then to run this script, type that shell name before the whole # command line, like: # -# ksh Gradle +# ksh gradlew # # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: diff --git a/gradlew.bat b/gradlew.bat index 24c62d56f..a51ec4f58 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -19,7 +19,7 @@ @if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem -@rem Gradle startup script for Windows +@rem gradlew startup script for Windows @rem @rem ########################################################################## @@ -72,7 +72,7 @@ echo location of your Java installation. 1>&2 -@rem Execute Gradle +@rem Execute gradlew @rem endlocal doesn't take effect until after the line is parsed and variables are expanded @rem which allows us to clear the local environment before executing the java command endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel 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..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 @@ -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; @@ -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/DefaultDataSourceWrapper.java b/rdbms/src/main/java/io/spine/server/storage/jdbc/DefaultDataSourceWrapper.java index be37ef83f..f93c94779 100644 --- a/rdbms/src/main/java/io/spine/server/storage/jdbc/DefaultDataSourceWrapper.java +++ b/rdbms/src/main/java/io/spine/server/storage/jdbc/DefaultDataSourceWrapper.java @@ -93,9 +93,9 @@ public SQLTemplates templates() { public void close() { checkNotClosed(); isClosed = true; - if (dataSource instanceof AutoCloseable) { + if (dataSource instanceof AutoCloseable autoCloseable) { try { - ((AutoCloseable) dataSource).close(); + autoCloseable.close(); } catch (Exception e) { logger().atError() .withCause(e) 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 8a753f254..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 @@ -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 @@ -39,6 +39,7 @@ import io.spine.server.storage.RecordSpec; import io.spine.server.storage.RecordStorage; import io.spine.server.storage.StorageFactory; +import io.spine.server.storage.StorageGroup; import io.spine.server.storage.jdbc.config.CreateOperationFactory; import io.spine.server.storage.jdbc.config.TableSpecs; import io.spine.server.storage.jdbc.delivery.JdbcSessionStorage; @@ -46,6 +47,7 @@ import io.spine.server.storage.jdbc.record.JdbcRecordStorage; import io.spine.server.storage.jdbc.record.JdbcTableSpec; import io.spine.server.storage.jdbc.type.JdbcColumnMapping; +import org.jspecify.annotations.Nullable; import javax.sql.DataSource; @@ -76,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 @@ -88,13 +99,13 @@ private JdbcStorageFactory(Builder builder) { */ @Override public RecordStorage - createRecordStorage(ContextSpec context, RecordSpec spec) { - var result = new JdbcRecordStorage<>(context, spec, this); + createRecordStorage(ContextSpec context, RecordSpec spec, @Nullable StorageGroup group) { + var result = new JdbcRecordStorage<>(context, spec, this, group); return result; } /** - * 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 @@ -140,7 +151,7 @@ public boolean isOpen() { } /** - * Closes used {@link DataSourceWrapper}. + * Closes the used {@link DataSourceWrapper}. */ @Override public void close() { @@ -177,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} @@ -189,10 +201,39 @@ 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 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 + * @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; } @@ -238,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; @@ -246,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) */ @@ -274,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. * @@ -292,6 +333,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. @@ -300,8 +344,14 @@ 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}. 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 + * the type of the stored record — for an Entity, its state type * @param name * the table name * @param @@ -315,16 +365,72 @@ 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 that 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. * + *

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. + * {@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/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/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/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/config/TableSpecs.java b/rdbms/src/main/java/io/spine/server/storage/jdbc/config/TableSpecs.java index d14f42af1..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 @@ -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,28 +30,38 @@ 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; +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 ImmutableMap groupedNames; + private final Map> tables = new ConcurrentHashMap<>(); private final ImmutableMap, JdbcColumnMapping> columnMappings; @@ -60,74 +70,139 @@ 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); } /** * 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. + * + *

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 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 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. * * @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) { + var customMapping = findMapping(spec.sourceType()); var mapping = customMapping == null ? defaultMapping : customMapping; + var tableName = tableName(spec, group); + return new JdbcTableSpec<>(tableName, spec, mapping); + } + + private String tableName(RecordSpec spec, @Nullable StorageGroup group) { + if (group != null) { + var customName = groupedNames.get(new GroupedTable(group.getName(), spec.recordType())); + return customName == null + ? TableNames.of(spec.recordType(), group) + : customName; + } + var customName = findName(spec.sourceType()); + return customName == null + ? TableNames.of(spec.sourceType()) + : customName; + } - tableSpec = new JdbcTableSpec<>(tableName, spec, mapping); - return tableSpec; + /** + * 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) { - @Nullable String customName = null; - if (names.containsKey(recordType)) { - customName = names.get(recordType); + /** + * 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) { + String customName = null; + if (names.containsKey(sourceType)) { + customName = names.get(sourceType); } return customName; } - private @Nullable JdbcColumnMapping findMapping(Class recordType) { - @Nullable JdbcColumnMapping value = null; - if (columnMappings.containsKey(recordType)) { - value = columnMappings.get(recordType); + private @Nullable JdbcColumnMapping findMapping(Class sourceType) { + JdbcColumnMapping value = null; + if (columnMappings.containsKey(sourceType)) { + value = columnMappings.get(sourceType); } return value; } @@ -146,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() { @@ -179,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/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/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/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 f31a8cf87..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 @@ -90,18 +90,12 @@ static Predicate nullFilter(ComparisonOperator operator, ComparablePath columnPath) { checkNotNull(operator); checkNotNull(columnPath); - switch (operator) { - case EQUALS: - return columnPath.isNull(); - case GREATER_THAN: - case LESS_THAN: - case GREATER_OR_EQUALS: - case LESS_OR_EQUALS: - throw newIllegalArgumentException( - "Operator %s not supported for the null filter value.", operator); - default: - throw newIllegalArgumentException("Unexpected filter operator %s.", operator); - } + return switch (operator) { + case EQUALS -> columnPath.isNull(); + case GREATER_THAN, LESS_THAN, GREATER_OR_EQUALS, LESS_OR_EQUALS -> + throw newIllegalArgumentException( + "Operator %s not supported for the null filter value.", operator); + }; } @VisibleForTesting @@ -112,23 +106,19 @@ static Predicate valueFilter(ComparablePath columnPath, checkNotNull(columnPath); checkNotNull(operator); checkNotNull(columnValue); - switch (operator) { - case EQUALS: - return columnPath.eq(columnValue); - case GREATER_THAN: - return columnPath.gt(columnValue); - case LESS_THAN: - return columnPath.lt(columnValue); - case GREATER_OR_EQUALS: - return columnPath.goe(columnValue); - case LESS_OR_EQUALS: - return columnPath.loe(columnValue); - default: - throw newIllegalArgumentException("Unexpected operator %s.", operator); - } + return switch (operator) { + case EQUALS -> columnPath.eq(columnValue); + case GREATER_THAN -> columnPath.gt(columnValue); + case LESS_THAN -> columnPath.lt(columnValue); + case GREATER_OR_EQUALS -> columnPath.goe(columnValue); + case LESS_OR_EQUALS -> columnPath.loe(columnValue); + }; } - @SuppressWarnings("rawtypes") /* To avoid the hell in generics. */ + @SuppressWarnings({ + "rawtypes" /* To avoid the hell in generics. */, + "ConstantValue" /* `convertedValue` could be `null`. */ + }) private static Predicate matchParameter(SubjectParameter parameter, JdbcColumnMapping columnMapping) { var column = parameter.column(); 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/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/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/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 0f63cc319..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 @@ -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. @@ -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 288bc41dc..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 @@ -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 an RDBMS table, in case such a table does not exist. * * @param contextSpec * specification of Bounded Context, in scope of which this storage exists @@ -74,9 +76,59 @@ public JdbcRecordStorage(ContextSpec contextSpec, } /** - * Creates a new record storage, and when asked, 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, + * 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 an 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 an 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(); @@ -154,7 +210,7 @@ public Iterator index() { @Override public void write(I id, R record) { - var spec = (RecordSpec) recordSpec(); + var spec = recordSpec(); writeRecord(RecordWithColumns.create(id, record, spec)); } 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..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 @@ -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; @@ -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/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/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/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/java/io/spine/server/storage/jdbc/aggregate/JdbcAggregateStorageTest.java b/rdbms/src/test/java/io/spine/server/storage/jdbc/aggregate/JdbcAggregateStorageTest.java deleted file mode 100644 index ed7cfb5af..000000000 --- a/rdbms/src/test/java/io/spine/server/storage/jdbc/aggregate/JdbcAggregateStorageTest.java +++ /dev/null @@ -1,64 +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. - */ - -package io.spine.server.storage.jdbc.aggregate; - -import io.spine.environment.Tests; -import io.spine.server.ServerEnvironment; -import io.spine.server.aggregate.AggregateStorageTest; -import io.spine.server.storage.jdbc.JdbcStorageFactory; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; - -import static io.spine.base.Identifier.newUuid; -import static io.spine.server.storage.jdbc.GivenDataSource.whichIsStoredInMemory; -import static io.spine.server.storage.jdbc.PredefinedMapping.H2_2_4; - -@DisplayName("RDBMS-backed `AggregateStorage` should") -class JdbcAggregateStorageTest extends AggregateStorageTest { - - private JdbcStorageFactory factory; - - @BeforeEach - void setUp() { - factory = JdbcStorageFactory.newBuilder() - .setDataSource(whichIsStoredInMemory(newUuid())) - .setTypeMapping(H2_2_4) - .build(); - ServerEnvironment - .when(Tests.class) - .use(factory); - } - - @AfterEach - void tearDown() { - ServerEnvironment.instance().reset(); - if(factory != null) { - factory.close(); - } - } -} diff --git a/rdbms/src/test/java/io/spine/server/storage/jdbc/aggregate/JdbcAggregateStorageTruncationTest.java b/rdbms/src/test/java/io/spine/server/storage/jdbc/aggregate/JdbcAggregateStorageTruncationTest.java deleted file mode 100644 index f6d53b138..000000000 --- a/rdbms/src/test/java/io/spine/server/storage/jdbc/aggregate/JdbcAggregateStorageTruncationTest.java +++ /dev/null @@ -1,54 +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. - */ - -package io.spine.server.storage.jdbc.aggregate; - -import io.spine.environment.Tests; -import io.spine.server.ServerEnvironment; -import io.spine.server.aggregate.AggregateHistoryTruncationTest; -import org.junit.jupiter.api.AfterAll; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.DisplayName; - -import static io.spine.server.storage.jdbc.given.JdbcStorageFactoryTestEnv.newFactory; - -@DisplayName("RDBMS-backed `AggregateStorage` after truncation should") -class JdbcAggregateStorageTruncationTest extends AggregateHistoryTruncationTest { - - @BeforeAll - static void setUpClass() { - ServerEnvironment - .when(Tests.class) - .useStorageFactory((env) -> newFactory()); - } - - @AfterAll - static void tearDownClass() { - ServerEnvironment - .instance() - .reset(); - } -} diff --git a/rdbms/src/test/java/io/spine/server/storage/jdbc/mysql/MysqlAggregateStorageTest.java b/rdbms/src/test/java/io/spine/server/storage/jdbc/mysql/MysqlAggregateStorageTest.java deleted file mode 100644 index 9390e02fb..000000000 --- a/rdbms/src/test/java/io/spine/server/storage/jdbc/mysql/MysqlAggregateStorageTest.java +++ /dev/null @@ -1,56 +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. - */ - -package io.spine.server.storage.jdbc.mysql; - -import io.spine.environment.Tests; -import io.spine.server.ServerEnvironment; -import io.spine.server.aggregate.AggregateStorageTest; -import io.spine.testing.SlowTest; -import org.junit.jupiter.api.AfterAll; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; - -@DisplayName("`AggregateRecordStorage` running on top of MySQL instance should") -@SlowTest -@EnableConditionally -final class MysqlAggregateStorageTest extends AggregateStorageTest { - - @BeforeEach - @Override - public void setUpAbstractStorageTest() { - var factory = MysqlTests.newFactory(); - ServerEnvironment.when(Tests.class) - .useStorageFactory((env) -> factory); - super.setUpAbstractStorageTest(); - } - - @AfterAll - static void tearDownClass() { - ServerEnvironment.instance() - .reset(); - } -} diff --git a/rdbms/src/test/java/io/spine/server/storage/jdbc/mysql/MysqlRecordStorageTest.java b/rdbms/src/test/java/io/spine/server/storage/jdbc/mysql/MysqlRecordStorageTest.java index b964e2831..0cf555ff5 100644 --- a/rdbms/src/test/java/io/spine/server/storage/jdbc/mysql/MysqlRecordStorageTest.java +++ b/rdbms/src/test/java/io/spine/server/storage/jdbc/mysql/MysqlRecordStorageTest.java @@ -28,7 +28,7 @@ import io.spine.environment.Tests; import io.spine.server.ServerEnvironment; -import io.spine.server.storage.RecordStorageDelegateTest; +import io.spine.server.storage.DelegatingRecordStorageTest; import io.spine.testing.SlowTest; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeEach; @@ -37,7 +37,7 @@ @DisplayName("`JdbcRecordStorage` running on top of MySQL instance should") @SlowTest @EnableConditionally -class MysqlRecordStorageTest extends RecordStorageDelegateTest { +class MysqlRecordStorageTest extends DelegatingRecordStorageTest { @BeforeEach @Override 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; diff --git a/rdbms/src/test/java/io/spine/server/storage/jdbc/query/QueryPredicatesTest.java b/rdbms/src/test/java/io/spine/server/storage/jdbc/query/QueryPredicatesTest.java index 3216ec5a5..6096cfc78 100644 --- a/rdbms/src/test/java/io/spine/server/storage/jdbc/query/QueryPredicatesTest.java +++ b/rdbms/src/test/java/io/spine/server/storage/jdbc/query/QueryPredicatesTest.java @@ -32,10 +32,10 @@ import io.spine.query.ComparisonOperator; import io.spine.query.QueryPredicate; import io.spine.server.entity.storage.SpecScanner; +import io.spine.server.storage.jdbc.given.JdbcStorageFactoryTestEnv.StgProjectAggregate; import io.spine.server.storage.jdbc.record.column.IdColumn; import io.spine.server.storage.jdbc.type.JdbcColumnMapping; import io.spine.test.storage.StgProject; -import io.spine.test.storage.StgProjectId; import io.spine.testing.UtilityClassTest; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -68,7 +68,7 @@ class QueryPredicatesTest extends UtilityClassTest { protected void configure(NullPointerTester tester) { super.configure(tester); var mapping = new JdbcColumnMapping(); - var idColumn = IdColumn.of(SpecScanner.scan(StgProjectId.class, StgProject.class), + var idColumn = IdColumn.of(SpecScanner.scan(StgProjectAggregate.class), mapping); var predicate = StgProject.query() .build() @@ -118,6 +118,12 @@ void lessOrEqual() { assertThrows(IllegalArgumentException.class, () -> runNullFilterCreationFor(LESS_OR_EQUALS)); } + + @SuppressWarnings("ResultOfMethodCallIgnored") // Method called to throw exception. + private static void runNullFilterCreationFor(ComparisonOperator operator) { + var path = comparablePath(Comparable.class, ""); + nullFilter(operator, path); + } } @Nested @@ -165,9 +171,4 @@ void lessOrEqual() { } } - @SuppressWarnings("ResultOfMethodCallIgnored") // Method called to throw exception. - private static void runNullFilterCreationFor(ComparisonOperator operator) { - var path = comparablePath(Comparable.class, ""); - nullFilter(operator, path); - } } diff --git a/rdbms/src/test/java/io/spine/server/storage/jdbc/record/JdbcRecordStorageTest.java b/rdbms/src/test/java/io/spine/server/storage/jdbc/record/JdbcRecordStorageTest.java index ee8688c9f..efe62cf48 100644 --- a/rdbms/src/test/java/io/spine/server/storage/jdbc/record/JdbcRecordStorageTest.java +++ b/rdbms/src/test/java/io/spine/server/storage/jdbc/record/JdbcRecordStorageTest.java @@ -28,7 +28,7 @@ import io.spine.environment.Tests; import io.spine.server.ServerEnvironment; -import io.spine.server.storage.RecordStorageDelegateTest; +import io.spine.server.storage.DelegatingRecordStorageTest; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -36,7 +36,7 @@ import static io.spine.server.storage.jdbc.given.JdbcStorageFactoryTestEnv.newFactory; @DisplayName("`JdbcRecordStorage` should") -class JdbcRecordStorageTest extends RecordStorageDelegateTest { +class JdbcRecordStorageTest extends DelegatingRecordStorageTest { @BeforeEach @Override 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..1fd9b7b1a --- /dev/null +++ b/rdbms/src/test/kotlin/io/spine/server/storage/jdbc/record/GroupedTableAllocationSpec.kt @@ -0,0 +1,260 @@ +/* + * 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.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 +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 +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.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 +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") + .setTableName(StgProject::class.java, "my_projects") + .build() + + val ungroupedEvents = RecordSpec( + EventId::class.java, + 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 `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() + 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 { + 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..dacb12bde --- /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() +} diff --git a/version.gradle.kts b/version.gradle.kts index e449b1993..ea33d5426 100644 --- a/version.gradle.kts +++ b/version.gradle.kts @@ -27,4 +27,4 @@ /** * The version of this library. */ -val versionToPublish: String by extra("2.0.0-SNAPSHOT.104") +val versionToPublish: String by extra("2.0.0-SNAPSHOT.110")