diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..7b15daa --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,64 @@ +name: Build + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + verify: + name: Java ${{ matrix.java }} + runs-on: ubuntu-latest + timeout-minutes: 20 + services: + postgres: + image: postgres:17 + env: + POSTGRES_DB: drremote_test + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + env: + DRREMOTE_TEST_POSTGRES_URL: jdbc:postgresql://localhost:5432/drremote_test + DRREMOTE_TEST_POSTGRES_USER: postgres + DRREMOTE_TEST_POSTGRES_PASSWORD: postgres + strategy: + fail-fast: false + matrix: + java: ['17', '21'] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 + with: + distribution: temurin + java-version: ${{ matrix.java }} + - name: Build, test, and verify Karaf features + run: mvn --batch-mode --no-transfer-progress clean install + - name: Build the SQLite example + run: mvn --batch-mode --no-transfer-progress -f examples/notes-client/pom.xml verify + - name: Upload bundles, feature descriptors, and example + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: drremote-db-java-${{ matrix.java }} + if-no-files-found: error + path: | + de.drremote.db.*/target/*.jar + de.drremote.db.*.features/target/feature/feature.xml + examples/notes-client/target/*.jar + - name: Upload test reports + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: test-reports-java-${{ matrix.java }} + if-no-files-found: ignore + path: de.drremote.db.*/target/surefire-reports/ diff --git a/.gitignore b/.gitignore index ace4c60..f8c3aef 100644 --- a/.gitignore +++ b/.gitignore @@ -31,4 +31,6 @@ Thumbs.db *~ # Optional Maven wrapper cache artifacts -.mvn/timing.properties \ No newline at end of file +.mvn/timing.properties +# Local agent execution records +.superpowers/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..674411c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,27 @@ +# Changelog + +## 1.1.0-SNAPSHOT — unreleased + +- Add DuckDB as an embedded analytical database option, with a JDBC bundle, Karaf feature, configuration, and tests. +- Refresh PostgreSQL and SQLite transaction handling. Nested calls on the same transaction manager/thread join the outer transaction; a nested failure marks it rollback-only even if caught. +- Add SQLite atomic batch migrations, optional baselines for new installations, migration SQL drift detection, and automatic migration readiness services. +- Refresh PostgreSQL migration coordination, JDBC URL conversion, and `psql` execution. Script migrations default to a single transaction and stop on SQL errors. +- Fix PostgreSQL JDBC migration schema creation and run migration locking/work on one borrowed connection. Failed lock acquisition releases its connection. +- Add regression tests, live PostgreSQL CI, Java 17/21 builds, actual Karaf feature resolution checks, and a buildable SQLite client example. +- Correct the source-build and installation instructions. The former public Maven repository is retired; project artifacts must be built locally. + +### Upgrading from the existing public source + +The public Maven names remain `de.drremote.db:postgresql`, `sqlite`, and their `*-features` artifacts. Exported Java API packages and bundle symbolic names remain unchanged. DuckDB adds `de.drremote.db:duckdb` and `duckdb-features`. + +Use `1.1.0-SNAPSHOT` consistently for Maven artifacts; Karaf displays its normalized feature/bundle version as `1.1.0.SNAPSHOT`. The former README's `1.0.0-SNAPSHOT` examples did not match the public POM's `1.0.0` version. + +The minimum Java version is now 17. The supported feature target is Karaf 4.4.11. Existing PostgreSQL/SQLite single-statement migration descriptors remain supported; the new SQLite baseline method has a default implementation returning `null`. + +Nested PostgreSQL/SQLite transaction failures now prevent an outer commit. PostgreSQL script migrations default to `psql --single-transaction`; override `ScriptMigration.isSingleTransaction()` only for commands that cannot execute inside a transaction. Review both changes if older callers relied on the previous behavior. + +Do not edit already-applied migrations. Add a new version instead. SQLite now reports SQL drift for recorded migrations with stored SQL. Baselines only apply when the plugin has no existing migration history. + +DuckDB currently has a simpler transaction/migration layer: avoid nested transaction calls, register/apply migrations explicitly, and use one SQL statement per migration. It does not expose the PostgreSQL/SQLite `DatabaseReady` contract. + +There is no new tagged release or remotely published artifact implied by this development version. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..262c806 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,42 @@ +# Contributing + +Issues and pull requests are welcome. Describe the database, Java/Karaf versions, configuration with credentials removed, expected behavior, actual behavior, and a small reproduction. + +## Build and tests + +Requirements: JDK 17+ and Maven 3.9+. + +```sh +mvn clean verify +mvn install +mvn -f examples/notes-client/pom.xml verify +``` + +The main suite includes PostgreSQL transaction/URL/script-runner regression tests, SQLite migration/transaction tests, and real temporary DuckDB transaction/migration tests. It does not require a running PostgreSQL server by default. The PostgreSQL integration tests run when `DRREMOTE_TEST_POSTGRES_URL` is set; CI supplies a PostgreSQL 17 service and runs them on both Java versions. Those tests require `psql` and a dedicated test database, and cover fresh schemas, replay, one-connection pools, and mixed JDBC/script migrations. + +To run them locally, point `DRREMOTE_TEST_POSTGRES_URL` at a disposable database (for example `jdbc:postgresql://localhost:5432/drremote_test`) and set `DRREMOTE_TEST_POSTGRES_USER` and `DRREMOTE_TEST_POSTGRES_PASSWORD` as appropriate. The default test user/password are `postgres`. The tests create uniquely named schemas and remove their objects afterward; do not use a production database. + +Karaf's Maven plugin verifies all three feature descriptors against Java 17 and Karaf 4.4.11. The GitHub workflow repeats the build on Java 17 and 21. Live Karaf smoke checks are separate from the default Maven suite. + +For a clean-room check, create an empty settings file and choose a fresh cache directory: + +```xml + +``` + +```sh +mvn -s /path/to/empty-settings.xml -gs /path/to/empty-settings.xml -Dmaven.repo.local=/path/to/fresh-cache clean install +``` + +Use the same settings and cache when building the example. No private parent, account, sibling repository, or DrRemote Runtime is needed. + +## Changes + +- Keep existing public Maven artifact names and exported API packages stable. +- Add regression tests for changed database behavior; use temporary databases/files. +- Keep Java 17 compatibility and all project content in English. +- Use parameterized SQL for values. Do not add real credentials or deployment state. +- Keep dependency versions and feature descriptors aligned through the parent POM. +- Update configuration/examples and the changelog when behavior changes. + +Publishing and release credentials are not part of the build. A successful `mvn install` does not imply a public artifact release. diff --git a/README.md b/README.md index e1f488d..8f60f98 100644 --- a/README.md +++ b/README.md @@ -1,206 +1,103 @@ # DrRemote DB -`DrRemote DB` provides PostgreSQL and SQLite database layers for DrRemote as a modular OSGi/Karaf repository. +[![Build](https://github.com/DoctorRemoteControl/drremote-db-postgresql-sqlite/actions/workflows/build.yml/badge.svg)](https://github.com/DoctorRemoteControl/drremote-db-postgresql-sqlite/actions/workflows/build.yml) +[![License: Apache-2.0](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE) -It contains: +PostgreSQL, SQLite, and DuckDB services for modular Java applications running in **OSGi / Apache Karaf**. Each database bundle exposes a pooled JDBC data source, transactions, parameterized queries, and versioned plugin migrations through OSGi Declarative Services. -* a PostgreSQL core bundle for JDBC access, transactions, and migrations -* a SQLite core bundle for file-based JDBC access, transactions, and migrations -* Karaf features modules for simple runtime installation +| Database | Typical use | Migration support | +| --- | --- | --- | +| PostgreSQL | Applications using a database server | Per-plugin schemas, JDBC and `psql` scripts, readiness services | +| SQLite | Embedded applications and local storage | Single statements, atomic batches, baselines, readiness services | +| DuckDB | Embedded analytical workloads | Explicit single-statement migrations | -## Overview - -This repository is intended for modular Java applications running in OSGi/Karaf that need reusable database integration layers for PostgreSQL or SQLite. - -The database cores provide: - -* JDBC-based database access -* transaction handling via `TransactionManager` -* a HikariCP-backed `DataSource` -* lightweight query helpers -* plugin migrations -* centralized migration tracking - -## Modules - -### `de.drremote.db.postgresql` - -The PostgreSQL core bundle. - -Provides: - -* PostgreSQL connectivity -* pooled `DataSource` -* transaction support -* query execution helpers -* plugin migration support - -### `de.drremote.db.postgresql.features` - -The Karaf features module. - -Provides: - -* a Karaf feature descriptor -* runtime provisioning of: - - * PostgreSQL JDBC driver - * HikariCP - * the DrRemote PostgreSQL core bundle - -### `de.drremote.db.sqlite` - -The SQLite core bundle. - -Provides: - -* SQLite connectivity -* pooled `DataSource` -* transaction support -* query execution helpers -* plugin migration support - -### `de.drremote.db.sqlite.features` - -The Karaf features module. - -Provides: - -* a Karaf feature descriptor -* runtime provisioning of: - - * SQLite JDBC driver - * HikariCP - * the DrRemote SQLite core bundle +**Current development version: `1.1.0-SNAPSHOT`.** Build this project from source. Its artifacts are not published to Maven Central, and the former public DrRemote Maven repository is no longer available. Third-party dependencies come from Maven Central; no private server or account is required. ## Requirements -* Java 21 -* Apache Karaf or another OSGi runtime -* PostgreSQL -* PostgreSQL JDBC driver available in the runtime -* HikariCP available in the runtime -* `psql` available on `PATH` for script-based migrations -* SQLite JDBC driver available in the runtime +- JDK **17 or later**; CI checks Java 17 and 21. +- Maven **3.9 or later**. +- Apache Karaf **4.4.11** for the supplied features, or an OSGi runtime with Declarative Services, ConfigAdmin, and the required JDBC/HikariCP bundles. +- PostgreSQL only: an existing database and database user. Install `psql` on the runtime host if you use script migrations. -## Build +SQLite and DuckDB use embedded native JDBC drivers. Native platform support follows the selected driver versions. -Build the complete repository with: +## Build ```sh +git clone https://github.com/DoctorRemoteControl/drremote-db-postgresql-sqlite.git +cd drremote-db-postgresql-sqlite mvn clean install ``` -## Artifacts +This runs the tests, builds the bundles and Karaf feature XML, checks the features' OSGi dependency resolution, and installs the project artifacts into your **local** Maven repository (normally `~/.m2/repository`). It does not publish them to a server. -Published artifacts: +For validation without installation, use `mvn clean verify`. CI also uploads bundles, source/Javadoc JARs, feature descriptors, and test reports as workflow artifacts. -* Bundle: `de.drremote.db:postgresql` -* Feature: `de.drremote.db:postgresql-features` -* Bundle: `de.drremote.db:sqlite` -* Feature: `de.drremote.db:sqlite-features` +## Quick start: SQLite in Karaf -Karaf feature name: +1. Build with `mvn clean install` as above. +2. Copy [the SQLite configuration](examples/de.drremote.db.sqlite.cfg.example) to your Karaf installation: -* `de.drremote-db-postgresql` -* `de.drremote-db-sqlite` + ```sh + cp examples/de.drremote.db.sqlite.cfg.example "$KARAF_HOME/etc/de.drremote.db.sqlite.cfg" + ``` -Current workspace version: +3. In the Karaf console, install the feature: -* `1.0.0-SNAPSHOT` + ```text + feature:repo-add mvn:de.drremote.db/sqlite-features/1.1.0-SNAPSHOT/xml/features + feature:install de.drremote-db-sqlite + service:list de.drremote.db.sqlite.api.DbCore + ``` -## Karaf Usage +The example database is stored at `${karaf.data}/drremote.sqlite`. Karaf must run as the same user as Maven, or have its Maven resolver's local repository configured to the repository used for `mvn install`. See [Karaf installation and troubleshooting](docs/installation.md). -Add the features repository: +### Try a client bundle -```sh -feature:repo-add mvn:de.drremote.db/postgresql-features/1.0.0-SNAPSHOT/xml/features -``` - -Install the feature: +The [notes example](examples/notes-client) contains a real migration descriptor and repository service: ```sh -feature:install de.drremote-db-postgresql +mvn -f examples/notes-client/pom.xml clean package ``` -SQLite: - -Add the features repository: - -```sh -feature:repo-add mvn:de.drremote.db/sqlite-features/1.0.0-SNAPSHOT/xml/features -``` - -Install the feature: - -```sh -feature:install de.drremote-db-sqlite -``` - -## Configuration - -The PostgreSQL core is configured through the ConfigAdmin PID: +Then install its JAR in the Karaf console, replacing the path with your checkout's absolute path: ```text -de.drremote.db.postgresql +bundle:install -s file:/absolute/path/to/drremote-db-postgresql-sqlite/examples/notes-client/target/notes-client-1.1.0-SNAPSHOT.jar +service:list example.notes.NotesRepository ``` -Recommended configuration file: +The migration registry creates the `notes` table and publishes a `DatabaseReady` service. The repository waits for that service before activation. Read the [SQLite usage guide](docs/usage-sqlite.md) for queries, transaction behavior, batches, and baselines. -```text -$KARAF_HOME/etc/de.drremote.db.postgresql.cfg -``` +## Other databases -For a full configuration example, see: +| Database | Example configuration | Karaf feature repository | Feature | +| --- | --- | --- | --- | +| PostgreSQL | [Configuration](examples/de.drremote.db.postgresql.cfg.example) | `mvn:de.drremote.db/postgresql-features/1.1.0-SNAPSHOT/xml/features` | `de.drremote-db-postgresql` | +| DuckDB | [Configuration](examples/de.drremote.db.duckdb.cfg.example) | `mvn:de.drremote.db/duckdb-features/1.1.0-SNAPSHOT/xml/features` | `de.drremote-db-duckdb` | -* `docs/configuration-postgresql.md` +Copy the appropriate configuration to `$KARAF_HOME/etc/`, removing the `.example` suffix, and replace PostgreSQL placeholders before installing its feature. -The SQLite core is configured through the ConfigAdmin PID: +## Maven coordinates -```text -de.drremote.db.sqlite -``` +All entries below use version `1.1.0-SNAPSHOT`, after building and installing from source: -Recommended configuration file: +| Group | Bundle artifact | Feature artifact | Java API package | +| --- | --- | --- | --- | +| `de.drremote.db` | `postgresql` | `postgresql-features` | `de.drremote.db.postgresql.api` | +| `de.drremote.db` | `sqlite` | `sqlite-features` | `de.drremote.db.sqlite.api` | +| `de.drremote.db` | `duckdb` | `duckdb-features` | `de.drremote.db.duckdb.api` | -```text -$KARAF_HOME/etc/de.drremote.db.sqlite.cfg -``` +Use a `provided` dependency in an OSGi client; the runtime installs the database bundle and its dependencies. [The example POM](examples/notes-client/pom.xml) shows a complete client build. These libraries are OSGi services, rather than standalone database servers or a command-line application. ## Documentation -Repository documentation: - -* `docs/configuration-postgresql.md` — PostgreSQL configuration -* `docs/configuration-sqlite.md` — SQLite configuration -* `docs/usage-postgresql.md` — PostgreSQL usage examples -* `docs/usage-sqlite.md` — SQLite usage examples -* `de.drremote.db.postgresql/README.md` — module-level documentation -* `de.drremote.db.sqlite/README.md` — module-level documentation - -## Repository Layout - -```text -db/ -├─ pom.xml -├─ README.md -├─ LICENSE -├─ docs/ -│ ├─ configuration.md -│ └─ usage.md -├─ de.drremote.db.postgresql/ -├─ de.drremote.db.postgresql.features/ -├─ de.drremote.db.sqlite/ -└─ de.drremote.db.sqlite.features/ -``` - -## Notes - -* Configuration examples use placeholder credentials. -* Do not use example passwords unchanged in production. -* The current Maven project version in this repository is `1.0.0-SNAPSHOT`. Use that version in `feature:repo-add` until a release artifact is published. -* This repository is functional, but not yet production-hardened. - -## License +- [Installation and troubleshooting](docs/installation.md) +- PostgreSQL: [configuration](docs/configuration-postgresql.md) · [usage](docs/usage-postgresql.md) +- SQLite: [configuration](docs/configuration-sqlite.md) · [usage](docs/usage-sqlite.md) +- DuckDB: [configuration](docs/configuration-duckdb.md) · [usage](docs/usage-duckdb.md) +- [Changes and upgrade notes](CHANGELOG.md) +- [Contributing and validation](CONTRIBUTING.md) -Licensed under Apache-2.0. See `LICENSE`. +The project is in active development. Test upgrades against your own schema and workloads before using them with existing data. Licensed under [Apache-2.0](LICENSE). diff --git a/de.drremote.db.duckdb.features/pom.xml b/de.drremote.db.duckdb.features/pom.xml new file mode 100644 index 0000000..ace0094 --- /dev/null +++ b/de.drremote.db.duckdb.features/pom.xml @@ -0,0 +1,25 @@ + + + 4.0.0 + de.drremotedb-parent1.1.0-SNAPSHOT + de.drremote.dbduckdb-featuresfeature + DrRemote DB duckdb Karaf features + + de.drremote.dbduckdb${project.version} + + + org.apache.karaf.toolingkaraf-maven-plugintrue + + true + de.drremote-db-duckdb + false + file:${project.build.directory}/feature/feature.xml + de.drremote-db-duckdb + framework + ${karaf.version} + ${maven.compiler.release} + + + + diff --git a/de.drremote.db.duckdb.features/src/main/feature/feature.xml b/de.drremote.db.duckdb.features/src/main/feature/feature.xml new file mode 100644 index 0000000..fcead53 --- /dev/null +++ b/de.drremote.db.duckdb.features/src/main/feature/feature.xml @@ -0,0 +1,11 @@ + + + mvn:org.apache.karaf.features/standard/${karaf.version}/xml/features + +
DrRemote duckdb database services
+ scr + mvn:org.duckdb/duckdb_jdbc/${duckdb.version} + mvn:com.zaxxer/HikariCP/${hikari.version} + mvn:de.drremote.db/duckdb/${project.version} +
+
diff --git a/de.drremote.db.duckdb/README.md b/de.drremote.db.duckdb/README.md new file mode 100644 index 0000000..e004596 --- /dev/null +++ b/de.drremote.db.duckdb/README.md @@ -0,0 +1,14 @@ +# DrRemote DB — DuckDB + +OSGi database services for DuckDB: pooled JDBC access, transactions, queries, and plugin migrations. + +- Public artifact: `de.drremote.db:duckdb:1.1.0-SNAPSHOT` +- API package / bundle symbolic name: `de.drremote.db.duckdb.api` / `de.drremote.db.duckdb` +- ConfigAdmin PID: `de.drremote.db.duckdb` +- Java 17+; supplied Karaf feature targets 4.4.11. + +Build from the repository root with `mvn clean install`. Project artifacts must currently be built locally; there is no hosted public DrRemote Maven repository. + +See the [main README](../README.md), [configuration](../docs/configuration-duckdb.md), [usage](../docs/usage-duckdb.md), and [installation guide](../docs/installation.md). + +Licensed under [Apache-2.0](../LICENSE). diff --git a/de.drremote.db.duckdb/pom.xml b/de.drremote.db.duckdb/pom.xml new file mode 100644 index 0000000..99378bc --- /dev/null +++ b/de.drremote.db.duckdb/pom.xml @@ -0,0 +1,22 @@ + + + 4.0.0 + de.drremotedb-parent1.1.0-SNAPSHOT + de.drremote.db + duckdb + bundle + DrRemote DB duckdb + + org.osgiosgi.coreprovided + org.osgiorg.osgi.service.component.annotationsprovided + com.zaxxerHikariCPprovided + org.duckdbduckdb_jdbcprovided + org.junit.jupiterjunit-jupitertest + + + org.apache.felixmaven-bundle-plugintrue + org.apache.maven.pluginsmaven-source-plugin + org.apache.maven.pluginsmaven-javadoc-plugin + + diff --git a/de.drremote.db.duckdb/src/main/java/de/drremote/db/duckdb/api/DbCore.java b/de.drremote.db.duckdb/src/main/java/de/drremote/db/duckdb/api/DbCore.java new file mode 100644 index 0000000..fba4ba8 --- /dev/null +++ b/de.drremote.db.duckdb/src/main/java/de/drremote/db/duckdb/api/DbCore.java @@ -0,0 +1,6 @@ +package de.drremote.db.duckdb.api; + +public interface DbCore { + + boolean isReady(); +} diff --git a/de.drremote.db.duckdb/src/main/java/de/drremote/db/duckdb/api/DbCoreException.java b/de.drremote.db.duckdb/src/main/java/de/drremote/db/duckdb/api/DbCoreException.java new file mode 100644 index 0000000..8892fcf --- /dev/null +++ b/de.drremote.db.duckdb/src/main/java/de/drremote/db/duckdb/api/DbCoreException.java @@ -0,0 +1,18 @@ +package de.drremote.db.duckdb.api; + +public class DbCoreException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + public DbCoreException(String message) { + super(message); + } + + public DbCoreException(String message, Throwable cause) { + super(message, cause); + } + + public DbCoreException(Throwable cause) { + super(cause); + } +} diff --git a/de.drremote.db.duckdb/src/main/java/de/drremote/db/duckdb/api/DbHandle.java b/de.drremote.db.duckdb/src/main/java/de/drremote/db/duckdb/api/DbHandle.java new file mode 100644 index 0000000..12cbcfc --- /dev/null +++ b/de.drremote.db.duckdb/src/main/java/de/drremote/db/duckdb/api/DbHandle.java @@ -0,0 +1,13 @@ +package de.drremote.db.duckdb.api; + +import java.sql.Connection; + +public interface DbHandle extends AutoCloseable { + + Connection getConnection(); + + QueryRunner getQueryRunner(); + + @Override + void close(); +} diff --git a/de.drremote.db.duckdb/src/main/java/de/drremote/db/duckdb/api/Migration.java b/de.drremote.db.duckdb/src/main/java/de/drremote/db/duckdb/api/Migration.java new file mode 100644 index 0000000..b028b9b --- /dev/null +++ b/de.drremote.db.duckdb/src/main/java/de/drremote/db/duckdb/api/Migration.java @@ -0,0 +1,10 @@ +package de.drremote.db.duckdb.api; + +public interface Migration { + + int getVersion(); + + String getDescription(); + + String getSql(); +} diff --git a/de.drremote.db.duckdb/src/main/java/de/drremote/db/duckdb/api/MigrationService.java b/de.drremote.db.duckdb/src/main/java/de/drremote/db/duckdb/api/MigrationService.java new file mode 100644 index 0000000..f03381f --- /dev/null +++ b/de.drremote.db.duckdb/src/main/java/de/drremote/db/duckdb/api/MigrationService.java @@ -0,0 +1,5 @@ +package de.drremote.db.duckdb.api; + +public interface MigrationService { + void migratePlugin(PluginMigrationDescriptor descriptor) throws DbCoreException; +} diff --git a/de.drremote.db.duckdb/src/main/java/de/drremote/db/duckdb/api/PluginMigrationDescriptor.java b/de.drremote.db.duckdb/src/main/java/de/drremote/db/duckdb/api/PluginMigrationDescriptor.java new file mode 100644 index 0000000..3d6947b --- /dev/null +++ b/de.drremote.db.duckdb/src/main/java/de/drremote/db/duckdb/api/PluginMigrationDescriptor.java @@ -0,0 +1,10 @@ +package de.drremote.db.duckdb.api; + +import java.util.List; + +public interface PluginMigrationDescriptor { + + String getPluginName(); + + List getMigrations(); +} diff --git a/de.drremote.db.duckdb/src/main/java/de/drremote/db/duckdb/api/QueryRunner.java b/de.drremote.db.duckdb/src/main/java/de/drremote/db/duckdb/api/QueryRunner.java new file mode 100644 index 0000000..57ced1a --- /dev/null +++ b/de.drremote.db.duckdb/src/main/java/de/drremote/db/duckdb/api/QueryRunner.java @@ -0,0 +1,12 @@ +package de.drremote.db.duckdb.api; + +import java.util.List; + +public interface QueryRunner { + + List query(String sql, RowMapper mapper, Object... params) throws DbCoreException; + + T queryOne(String sql, RowMapper mapper, Object... params) throws DbCoreException; + + int update(String sql, Object... params) throws DbCoreException; +} diff --git a/de.drremote.db.duckdb/src/main/java/de/drremote/db/duckdb/api/RowMapper.java b/de.drremote.db.duckdb/src/main/java/de/drremote/db/duckdb/api/RowMapper.java new file mode 100644 index 0000000..e3dc056 --- /dev/null +++ b/de.drremote.db.duckdb/src/main/java/de/drremote/db/duckdb/api/RowMapper.java @@ -0,0 +1,9 @@ +package de.drremote.db.duckdb.api; + +import java.sql.ResultSet; +import java.sql.SQLException; + +@FunctionalInterface +public interface RowMapper { + T mapRow(ResultSet rs) throws SQLException; +} diff --git a/de.drremote.db.duckdb/src/main/java/de/drremote/db/duckdb/api/TransactionCallback.java b/de.drremote.db.duckdb/src/main/java/de/drremote/db/duckdb/api/TransactionCallback.java new file mode 100644 index 0000000..f227482 --- /dev/null +++ b/de.drremote.db.duckdb/src/main/java/de/drremote/db/duckdb/api/TransactionCallback.java @@ -0,0 +1,6 @@ +package de.drremote.db.duckdb.api; + +@FunctionalInterface +public interface TransactionCallback { + T doInTransaction(DbHandle handle) throws Exception; +} diff --git a/de.drremote.db.duckdb/src/main/java/de/drremote/db/duckdb/api/TransactionManager.java b/de.drremote.db.duckdb/src/main/java/de/drremote/db/duckdb/api/TransactionManager.java new file mode 100644 index 0000000..1c56600 --- /dev/null +++ b/de.drremote.db.duckdb/src/main/java/de/drremote/db/duckdb/api/TransactionManager.java @@ -0,0 +1,5 @@ +package de.drremote.db.duckdb.api; + +public interface TransactionManager { + T inTransaction(TransactionCallback callback) throws DbCoreException; +} diff --git a/de.drremote.db.duckdb/src/main/java/de/drremote/db/duckdb/impl/DbCoreComponent.java b/de.drremote.db.duckdb/src/main/java/de/drremote/db/duckdb/impl/DbCoreComponent.java new file mode 100644 index 0000000..40bef9b --- /dev/null +++ b/de.drremote.db.duckdb/src/main/java/de/drremote/db/duckdb/impl/DbCoreComponent.java @@ -0,0 +1,62 @@ +package de.drremote.db.duckdb.impl; + +import de.drremote.db.duckdb.api.DbCore; +import de.drremote.db.duckdb.api.TransactionManager; +import org.osgi.service.component.annotations.*; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.Statement; +import java.util.logging.Logger; + +@Component( + service = DbCore.class, + immediate = true +) +public final class DbCoreComponent implements DbCore { + + private static final Logger LOG = Logger.getLogger(DbCoreComponent.class.getName()); + + private DataSource dataSource; + private TransactionManager transactionManager; + + private volatile boolean ready = false; + + @Reference(target = "(drremote.db.kind=duckdb)") + void bindDataSource(DataSource dataSource) { + this.dataSource = dataSource; + } + + @Reference + void bindTransactionManager(TransactionManager transactionManager) { + this.transactionManager = transactionManager; + } + + @Activate + void activate() { + if (dataSource == null || transactionManager == null) { + throw new IllegalStateException("DbCore requires DataSource and TransactionManager"); + } + + try (Connection conn = dataSource.getConnection(); + Statement st = conn.createStatement()) { + st.execute("SELECT 1"); + } catch (Exception e) { + throw new IllegalStateException("Database connectivity check failed", e); + } + + ready = true; + LOG.info("Core activated"); + } + + @Deactivate + void deactivate() { + ready = false; + LOG.info("Core deactivated"); + } + + @Override + public boolean isReady() { + return ready; + } +} diff --git a/de.drremote.db.duckdb/src/main/java/de/drremote/db/duckdb/impl/jdbc/HikariDataSourceComponent.java b/de.drremote.db.duckdb/src/main/java/de/drremote/db/duckdb/impl/jdbc/HikariDataSourceComponent.java new file mode 100644 index 0000000..24e3599 --- /dev/null +++ b/de.drremote.db.duckdb/src/main/java/de/drremote/db/duckdb/impl/jdbc/HikariDataSourceComponent.java @@ -0,0 +1,170 @@ +package de.drremote.db.duckdb.impl.jdbc; + +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import org.osgi.service.component.annotations.*; + +import javax.sql.DataSource; +import java.io.PrintWriter; +import java.sql.Connection; +import java.sql.SQLException; +import java.sql.SQLFeatureNotSupportedException; +import java.util.Map; +import java.util.logging.Logger; + +@Component( + service = DataSource.class, + immediate = true, + configurationPid = "de.drremote.db.duckdb", + property = "drremote.db.kind=duckdb" +) +public final class HikariDataSourceComponent implements DataSource { + + private static final Logger LOG = Logger.getLogger(HikariDataSourceComponent.class.getName()); + + private volatile HikariDataSource delegate; + + @Activate + void activate(Map props) { + try { + Class.forName("org.duckdb.DuckDBDriver"); + } catch (ClassNotFoundException e) { + throw new IllegalStateException("DuckDB JDBC Driver not found (OSGi classloader)", e); + } + + reconfigure(props); + + LOG.info("DataSource active: " + delegate.getJdbcUrl()); + } + + @Modified + void modified(Map props) { + reconfigure(props); + LOG.info("DataSource reconfigured: " + delegate.getJdbcUrl()); + } + + @Deactivate + void deactivate() { + closeDelegate(); + LOG.info("DataSource stopped"); + } + + private synchronized void reconfigure(Map props) { + String jdbcUrl = getString(props, "jdbcUrl", null); + if (jdbcUrl == null || jdbcUrl.isBlank()) { + throw new IllegalStateException("Missing required config property: jdbcUrl"); + } + int maxPoolSize = getInt(props, "maxPoolSize", 1); + int minIdle = getInt(props, "minIdle", 1); + String poolName = getString(props, "poolName", "db-duckdb-pool"); + boolean readOnly = getBoolean(props, "readOnly", false); + String tempDirectory = getString(props, "tempDirectory", null); + + if (maxPoolSize != 1) { + throw new IllegalStateException("DuckDB requires maxPoolSize=1"); + } + if (minIdle != 1) { + throw new IllegalStateException("DuckDB requires minIdle=1"); + } + + HikariConfig hc = new HikariConfig(); + hc.setJdbcUrl(jdbcUrl); + hc.setDriverClassName("org.duckdb.DuckDBDriver"); + hc.setMaximumPoolSize(maxPoolSize); + hc.setMinimumIdle(minIdle); + hc.setPoolName(poolName); + + if (readOnly) { + hc.addDataSourceProperty("duckdb.read_only", "true"); + } + if (tempDirectory != null && !tempDirectory.isBlank()) { + hc.addDataSourceProperty("temp_directory", tempDirectory); + } + + HikariDataSource newDelegate = new HikariDataSource(hc); + HikariDataSource oldDelegate = delegate; + delegate = newDelegate; + closeQuietly(oldDelegate); + } + + private synchronized void closeDelegate() { + HikariDataSource ds = delegate; + delegate = null; + closeQuietly(ds); + } + + private static void closeQuietly(HikariDataSource ds) { + if (ds != null) { + try { ds.close(); } catch (Exception ignore) {} + } + } + + private static String getString(Map props, String key, String def) { + Object v = props == null ? null : props.get(key); + return (v == null) ? def : String.valueOf(v).trim(); + } + + private static int getInt(Map props, String key, int def) { + Object v = props == null ? null : props.get(key); + if (v == null) return def; + if (v instanceof Number n) return n.intValue(); + try { return Integer.parseInt(String.valueOf(v).trim()); } + catch (Exception e) { return def; } + } + + private static boolean getBoolean(Map props, String key, boolean def) { + Object v = props == null ? null : props.get(key); + if (v == null) return def; + if (v instanceof Boolean b) return b; + return Boolean.parseBoolean(String.valueOf(v).trim()); + } + + // ===================== + // DataSource delegation + // ===================== + + @Override + public Connection getConnection() throws SQLException { + return delegate.getConnection(); + } + + @Override + public Connection getConnection(String username, String password) throws SQLException { + return delegate.getConnection(username, password); + } + + @Override + public PrintWriter getLogWriter() throws SQLException { + return delegate.getLogWriter(); + } + + @Override + public void setLogWriter(PrintWriter out) throws SQLException { + delegate.setLogWriter(out); + } + + @Override + public void setLoginTimeout(int seconds) throws SQLException { + delegate.setLoginTimeout(seconds); + } + + @Override + public int getLoginTimeout() throws SQLException { + return delegate.getLoginTimeout(); + } + + @Override + public Logger getParentLogger() throws SQLFeatureNotSupportedException { + return delegate.getParentLogger(); + } + + @Override + public T unwrap(Class iface) throws SQLException { + return delegate.unwrap(iface); + } + + @Override + public boolean isWrapperFor(Class iface) throws SQLException { + return delegate.isWrapperFor(iface); + } +} diff --git a/de.drremote.db.duckdb/src/main/java/de/drremote/db/duckdb/impl/jdbc/JdbcDbHandle.java b/de.drremote.db.duckdb/src/main/java/de/drremote/db/duckdb/impl/jdbc/JdbcDbHandle.java new file mode 100644 index 0000000..03832fd --- /dev/null +++ b/de.drremote.db.duckdb/src/main/java/de/drremote/db/duckdb/impl/jdbc/JdbcDbHandle.java @@ -0,0 +1,32 @@ +package de.drremote.db.duckdb.impl.jdbc; + +import de.drremote.db.duckdb.api.DbHandle; +import de.drremote.db.duckdb.api.QueryRunner; + +import java.sql.Connection; + +public final class JdbcDbHandle implements DbHandle { + + private final Connection connection; + private final QueryRunner queryRunner; + + public JdbcDbHandle(Connection connection) { + this.connection = connection; + this.queryRunner = new JdbcQueryRunner(connection); + } + + @Override + public Connection getConnection() { + return connection; + } + + @Override + public QueryRunner getQueryRunner() { + return queryRunner; + } + + @Override + public void close() { + // no-op: lifecycle is managed by JdbcTransactionManager + } +} diff --git a/de.drremote.db.duckdb/src/main/java/de/drremote/db/duckdb/impl/jdbc/JdbcQueryRunner.java b/de.drremote.db.duckdb/src/main/java/de/drremote/db/duckdb/impl/jdbc/JdbcQueryRunner.java new file mode 100644 index 0000000..8bb6bab --- /dev/null +++ b/de.drremote.db.duckdb/src/main/java/de/drremote/db/duckdb/impl/jdbc/JdbcQueryRunner.java @@ -0,0 +1,66 @@ +package de.drremote.db.duckdb.impl.jdbc; + +import de.drremote.db.duckdb.api.DbCoreException; +import de.drremote.db.duckdb.api.QueryRunner; +import de.drremote.db.duckdb.api.RowMapper; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; + +public class JdbcQueryRunner implements QueryRunner { + + private final Connection connection; + + public JdbcQueryRunner(Connection connection) { + this.connection = connection; + } + + @Override + public List query(String sql, RowMapper mapper, Object... params) throws DbCoreException { + try (PreparedStatement ps = prepareStatement(sql, params); + ResultSet rs = ps.executeQuery()) { + List result = new ArrayList<>(); + while (rs.next()) { + result.add(mapper.mapRow(rs)); + } + return result; + } catch (SQLException e) { + throw new DbCoreException("Error executing query: " + sql, e); + } + } + + @Override + public T queryOne(String sql, RowMapper mapper, Object... params) throws DbCoreException { + List list = query(sql, mapper, params); + if (list.isEmpty()) { + return null; + } + if (list.size() > 1) { + throw new DbCoreException("Expected single result, got " + list.size()); + } + return list.get(0); + } + + @Override + public int update(String sql, Object... params) throws DbCoreException { + try (PreparedStatement ps = prepareStatement(sql, params)) { + return ps.executeUpdate(); + } catch (SQLException e) { + throw new DbCoreException("Error executing update: " + sql, e); + } + } + + private PreparedStatement prepareStatement(String sql, Object... params) throws SQLException { + PreparedStatement ps = connection.prepareStatement(sql); + if (params != null) { + for (int i = 0; i < params.length; i++) { + ps.setObject(i + 1, params[i]); + } + } + return ps; + } +} diff --git a/de.drremote.db.duckdb/src/main/java/de/drremote/db/duckdb/impl/jdbc/JdbcTransactionManager.java b/de.drremote.db.duckdb/src/main/java/de/drremote/db/duckdb/impl/jdbc/JdbcTransactionManager.java new file mode 100644 index 0000000..ad58f99 --- /dev/null +++ b/de.drremote.db.duckdb/src/main/java/de/drremote/db/duckdb/impl/jdbc/JdbcTransactionManager.java @@ -0,0 +1,48 @@ +package de.drremote.db.duckdb.impl.jdbc; + +import de.drremote.db.duckdb.api.DbCoreException; +import de.drremote.db.duckdb.api.TransactionCallback; +import de.drremote.db.duckdb.api.TransactionManager; +import org.osgi.service.component.annotations.*; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.util.logging.Logger; + +@Component(service = TransactionManager.class) +public final class JdbcTransactionManager implements TransactionManager { + + private static final Logger LOG = Logger.getLogger(JdbcTransactionManager.class.getName()); + + private DataSource dataSource; + + @Reference(target = "(drremote.db.kind=duckdb)") + void bindDataSource(DataSource dataSource) { + this.dataSource = dataSource; + LOG.info("DataSource bound"); + } + + @Activate + void activate() { + LOG.info("TransactionManager activated"); + } + + @Override + public T inTransaction(TransactionCallback callback) throws DbCoreException { + try (Connection conn = dataSource.getConnection()) { + conn.setAutoCommit(false); + + JdbcDbHandle handle = new JdbcDbHandle(conn); + try { + T result = callback.doInTransaction(handle); + conn.commit(); + return result; + } catch (Exception e) { + conn.rollback(); + throw e; + } + } catch (Exception e) { + throw new DbCoreException("Transaction failed", e); + } + } +} diff --git a/de.drremote.db.duckdb/src/main/java/de/drremote/db/duckdb/impl/migration/MigrationServiceImpl.java b/de.drremote.db.duckdb/src/main/java/de/drremote/db/duckdb/impl/migration/MigrationServiceImpl.java new file mode 100644 index 0000000..2f7a036 --- /dev/null +++ b/de.drremote.db.duckdb/src/main/java/de/drremote/db/duckdb/impl/migration/MigrationServiceImpl.java @@ -0,0 +1,150 @@ +package de.drremote.db.duckdb.impl.migration; + +import de.drremote.db.duckdb.api.*; +import org.osgi.service.component.annotations.*; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.logging.Level; +import java.util.logging.Logger; + +@Component( + service = MigrationService.class, + immediate = true +) +public final class MigrationServiceImpl implements MigrationService { + + private static final Logger LOG = Logger.getLogger(MigrationServiceImpl.class.getName()); + + private volatile TransactionManager tx; + + @Reference + void bindTransactionManager(TransactionManager tm) { + this.tx = tm; + ensureCoreSchema(); + } + + @Override + public void migratePlugin(PluginMigrationDescriptor descriptor) throws DbCoreException { + applyMigrations(descriptor.getPluginName(), descriptor.getMigrations()); + } + + private void ensureCoreSchema() { + TransactionManager tm = this.tx; + if (tm == null) { + throw new IllegalStateException("TransactionManager not bound"); + } + + tm.inTransaction(handle -> { + try (Statement st = handle.getConnection().createStatement()) { + st.execute(""" + CREATE TABLE IF NOT EXISTS core_schema_migrations ( + plugin_name VARCHAR NOT NULL, + version INTEGER NOT NULL, + description VARCHAR NOT NULL, + sql VARCHAR, + applied_at TIMESTAMP NOT NULL DEFAULT current_timestamp, + PRIMARY KEY (plugin_name, version) + ) + """); + } + return null; + }); + } + + private void applyMigrations(String pluginName, List migrations) { + LOG.log(Level.FINE, () -> "applyMigrations plugin=" + pluginName + + " migrations=" + (migrations == null ? "null" : migrations.size())); + + if (migrations == null || migrations.isEmpty()) { + LOG.fine("no migrations -> return"); + return; + } + + TransactionManager tm = this.tx; + if (tm == null) { + throw new IllegalStateException("TransactionManager not bound"); + } + + List sorted = new ArrayList<>(migrations); + sorted.sort(Comparator.comparingInt(Migration::getVersion)); + + for (Migration m : sorted) { + tm.inTransaction(handle -> { + Connection conn = handle.getConnection(); + + boolean already = isAlreadyApplied(conn, pluginName, m.getVersion()); + if (already) { + LOG.log(Level.FINE, () -> "skipping v" + m.getVersion()); + return null; + } + + String sqlText = m.getSql(); + if (sqlText == null || sqlText.isBlank()) { + throw new IllegalStateException("Migration SQL is empty for v" + m.getVersion()); + } + + String normalizedSql = sqlText.trim(); + if (normalizedSql.endsWith(";")) { + normalizedSql = normalizedSql.substring(0, normalizedSql.length() - 1).trim(); + } + if (normalizedSql.isEmpty()) { + throw new IllegalStateException("Migration SQL is empty for v" + m.getVersion()); + } + if (normalizedSql.contains(";")) { + throw new IllegalStateException( + "Migration SQL must contain exactly one statement for v" + m.getVersion() + ); + } + + LOG.log(Level.FINE, () -> "will apply v" + m.getVersion()); + + try (Statement st = conn.createStatement()) { + st.execute(normalizedSql); + } + + insertMigrationRow(conn, pluginName, m, normalizedSql); + return null; + }); + } + } + + private boolean isAlreadyApplied(Connection conn, String pluginName, int version) throws SQLException { + try (PreparedStatement ps = conn.prepareStatement(""" + SELECT 1 + FROM core_schema_migrations + WHERE plugin_name = ? AND version = ? + """)) { + ps.setString(1, pluginName); + ps.setInt(2, version); + try (ResultSet rs = ps.executeQuery()) { + return rs.next(); + } + } + } + + private void insertMigrationRow( + Connection conn, + String pluginName, + Migration migration, + String sqlText + ) throws SQLException { + try (PreparedStatement ps = conn.prepareStatement(""" + INSERT INTO core_schema_migrations + (plugin_name, version, description, sql) + VALUES (?,?,?,?) + """)) { + ps.setString(1, pluginName); + ps.setInt(2, migration.getVersion()); + ps.setString(3, migration.getDescription()); + ps.setString(4, sqlText); + ps.executeUpdate(); + } + } +} diff --git a/de.drremote.db.duckdb/src/test/java/de/drremote/db/duckdb/impl/jdbc/JdbcTransactionManagerTest.java b/de.drremote.db.duckdb/src/test/java/de/drremote/db/duckdb/impl/jdbc/JdbcTransactionManagerTest.java new file mode 100644 index 0000000..97abd4e --- /dev/null +++ b/de.drremote.db.duckdb/src/test/java/de/drremote/db/duckdb/impl/jdbc/JdbcTransactionManagerTest.java @@ -0,0 +1,72 @@ +package de.drremote.db.duckdb.impl.jdbc; + +import de.drremote.db.duckdb.api.DbCoreException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class JdbcTransactionManagerTest { + @TempDir Path directory; + private HikariDataSourceComponent dataSource; + private JdbcTransactionManager transactions; + + @BeforeEach + void setUp() { + dataSource = new HikariDataSourceComponent(); + dataSource.activate(Map.of("jdbcUrl", "jdbc:duckdb:" + directory.resolve("test.duckdb"))); + transactions = new JdbcTransactionManager(); + transactions.bindDataSource(dataSource); + transactions.inTransaction(db -> { + db.getQueryRunner().update("CREATE TABLE items (id INTEGER PRIMARY KEY, label VARCHAR)"); + return null; + }); + } + + @AfterEach + void tearDown() { + if (dataSource != null) dataSource.deactivate(); + } + + @Test + void commitsAndQueriesBoundParameters() { + transactions.inTransaction(db -> { + db.getQueryRunner().update("INSERT INTO items VALUES (?, ?)", 1, "first"); + return null; + }); + String label = transactions.inTransaction(db -> db.getQueryRunner().queryOne( + "SELECT label FROM items WHERE id = ?", row -> row.getString(1), 1)); + assertEquals("first", label); + } + + @Test + void rollsBackFailedWorkAndCanRunAnotherTransaction() { + assertThrows(DbCoreException.class, () -> transactions.inTransaction(db -> { + db.getQueryRunner().update("INSERT INTO items VALUES (?, ?)", 1, "rollback"); + throw new IllegalStateException("abort"); + })); + long count = transactions.inTransaction(db -> db.getQueryRunner().queryOne( + "SELECT COUNT(*) FROM items", row -> row.getLong(1))); + assertEquals(0L, count); + transactions.inTransaction(db -> { + db.getQueryRunner().update("INSERT INTO items VALUES (?, ?)", 2, "after failure"); + return null; + }); + assertEquals("after failure", transactions.inTransaction(db -> db.getQueryRunner().queryOne( + "SELECT label FROM items WHERE id = ?", row -> row.getString(1), 2))); + } + + @Test + void closingHandleDoesNotCloseTheManagedConnection() { + transactions.inTransaction(db -> { + db.close(); + assertFalse(db.getConnection().isClosed()); + return null; + }); + } +} diff --git a/de.drremote.db.duckdb/src/test/java/de/drremote/db/duckdb/impl/migration/MigrationServiceImplTest.java b/de.drremote.db.duckdb/src/test/java/de/drremote/db/duckdb/impl/migration/MigrationServiceImplTest.java new file mode 100644 index 0000000..b6c7be6 --- /dev/null +++ b/de.drremote.db.duckdb/src/test/java/de/drremote/db/duckdb/impl/migration/MigrationServiceImplTest.java @@ -0,0 +1,102 @@ +package de.drremote.db.duckdb.impl.migration; + +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import de.drremote.db.duckdb.api.*; +import de.drremote.db.duckdb.impl.jdbc.JdbcDbHandle; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class MigrationServiceImplTest { + @TempDir Path directory; + private HikariDataSource dataSource; + private TransactionManager transactions; + private MigrationServiceImpl migrations; + + @BeforeEach + void setUp() { + HikariConfig config = new HikariConfig(); + config.setJdbcUrl("jdbc:duckdb:" + directory.resolve("migrations.duckdb")); + config.setMaximumPoolSize(1); + dataSource = new HikariDataSource(config); + transactions = new TransactionManager() { + @Override public T inTransaction(TransactionCallback callback) { + try (var connection = dataSource.getConnection()) { + connection.setAutoCommit(false); + try { + T result = callback.doInTransaction(new JdbcDbHandle(connection)); + connection.commit(); + return result; + } catch (Exception failure) { + connection.rollback(); + throw failure; + } + } catch (Exception failure) { + throw new DbCoreException(failure); + } + } + }; + migrations = new MigrationServiceImpl(); + migrations.bindTransactionManager(transactions); + } + + @AfterEach + void tearDown() { + if (dataSource != null) dataSource.close(); + } + + @Test + void appliesInVersionOrderAndReplaysWithoutDuplicatingRows() { + var descriptor = descriptor(List.of( + migration(2, "INSERT INTO items VALUES (1, 'ready')"), + migration(1, "CREATE TABLE items (id INTEGER PRIMARY KEY, label VARCHAR)") + )); + migrations.migratePlugin(descriptor); + migrations.migratePlugin(descriptor); + assertEquals(1L, count("items")); + assertEquals(2L, count("core_schema_migrations")); + } + + @Test + void failedMigrationDoesNotRecordACompletedVersion() { + assertThrows(DbCoreException.class, () -> migrations.migratePlugin(descriptor( + List.of(migration(1, "INSERT INTO missing_table VALUES (1)"))))); + assertEquals(0L, count("core_schema_migrations")); + migrations.migratePlugin(descriptor(List.of(migration(1, "CREATE TABLE items(id INTEGER)")))); + assertEquals(1L, count("core_schema_migrations")); + } + + @Test + void rejectsMultipleStatementsBeforeApplyingEither() { + assertThrows(DbCoreException.class, () -> migrations.migratePlugin(descriptor(List.of( + migration(1, "CREATE TABLE first_item(id INTEGER); CREATE TABLE second_item(id INTEGER)"))))); + assertEquals(0L, count("core_schema_migrations")); + } + + private long count(String table) { + return transactions.inTransaction(db -> db.getQueryRunner().queryOne( + "SELECT COUNT(*) FROM " + table, row -> row.getLong(1))); + } + + private static Migration migration(int version, String sql) { + return new Migration() { + public int getVersion() { return version; } + public String getDescription() { return "step " + version; } + public String getSql() { return sql; } + }; + } + + private static PluginMigrationDescriptor descriptor(List steps) { + return new PluginMigrationDescriptor() { + public String getPluginName() { return "example"; } + public List getMigrations() { return steps; } + }; + } +} diff --git a/de.drremote.db.postgresql.features/pom.xml b/de.drremote.db.postgresql.features/pom.xml index 264ae38..4a4eca9 100644 --- a/de.drremote.db.postgresql.features/pom.xml +++ b/de.drremote.db.postgresql.features/pom.xml @@ -1,39 +1,25 @@ - - 4.0.0 - - - de.drremote - db-parent - 1.0.0 - ../pom.xml - - - de.drremote.db - postgresql-features - feature - - Db PostgreSQL Karaf Features - - - 4.4.10 - UTF-8 - - - - - - org.apache.karaf.tooling - karaf-maven-plugin - ${karaf.version} - true - - - - + de.drremotedb-parent1.1.0-SNAPSHOT + de.drremote.dbpostgresql-featuresfeature + DrRemote DB postgresql Karaf features + + de.drremote.dbpostgresql${project.version} + + + org.apache.karaf.toolingkaraf-maven-plugintrue + + true + de.drremote-db-postgresql + false + file:${project.build.directory}/feature/feature.xml + de.drremote-db-postgresql + framework + ${karaf.version} + ${maven.compiler.release} + + + - - diff --git a/de.drremote.db.postgresql.features/src/main/feature/feature.xml b/de.drremote.db.postgresql.features/src/main/feature/feature.xml index c2fd45b..98d0b14 100644 --- a/de.drremote.db.postgresql.features/src/main/feature/feature.xml +++ b/de.drremote.db.postgresql.features/src/main/feature/feature.xml @@ -1,13 +1,11 @@ - - - -
DrRemote PostgreSQL core for Karaf
- - wrap:mvn:org.postgresql/postgresql/42.7.3 - wrap:mvn:com.zaxxer/HikariCP/7.0.2 - mvn:de.drremote.db/postgresql/1.0.0 + + mvn:org.apache.karaf.features/standard/${karaf.version}/xml/features + +
DrRemote postgresql database services
+ scr + mvn:org.postgresql/postgresql/${postgres.version} + mvn:com.zaxxer/HikariCP/${hikari.version} + mvn:de.drremote.db/postgresql/${project.version}
-
diff --git a/de.drremote.db.postgresql/README.md b/de.drremote.db.postgresql/README.md index 68ac37c..e235095 100644 --- a/de.drremote.db.postgresql/README.md +++ b/de.drremote.db.postgresql/README.md @@ -1,316 +1,14 @@ -# de.drremote.db.postgresql +# DrRemote DB — PostgreSQL -`de.drremote.db.postgresql` is an OSGi/Karaf bundle that provides PostgreSQL access, transaction handling, and schema-based plugin migrations for modular applications. +OSGi database services for PostgreSQL: pooled JDBC access, transactions, queries, and plugin migrations. -It is built for setups where multiple plugins share the same PostgreSQL database while keeping their own schema and migration history. +- Public artifact: `de.drremote.db:postgresql:1.1.0-SNAPSHOT` +- API package / bundle symbolic name: `de.drremote.db.postgresql.api` / `de.drremote.db.postgresql` +- ConfigAdmin PID: `de.drremote.db.postgresql` +- Java 17+; supplied Karaf feature targets 4.4.11. -## Features +Build from the repository root with `mvn clean install`. Project artifacts must currently be built locally; there is no hosted public DrRemote Maven repository. -* OSGi Declarative Services (DS) -* PostgreSQL access via JDBC -* HikariCP-backed `DataSource` -* Transaction handling through `TransactionManager` -* Lightweight query API via `QueryRunner` -* Plugin-specific schema switching via PostgreSQL `search_path` -* Central migration tracking in `core.schema_migrations` -* Configuration through Karaf `ConfigAdmin` +See the [main README](../README.md), [configuration](../docs/configuration-postgresql.md), [usage](../docs/usage-postgresql.md), and [installation guide](../docs/installation.md). -## Requirements - -* Java 21 -* Apache Karaf or another OSGi runtime -* PostgreSQL -* PostgreSQL JDBC driver available in the runtime -* HikariCP available in the runtime -* `psql` installed and available on `PATH` for script-based migrations - -## Build - -```sh -mvn clean install -``` - -## Configuration - -Configuration PID: - -```text -de.drremote.db.postgresql -``` - -Recommended Karaf config file: - -```text -$KARAF_HOME/etc/de.drremote.db.postgresql.cfg -``` - -Example: - -```properties -############################################# -# DB.POSTGRESQL Config (PID: de.drremote.db.postgresql) -############################################# - -# --- JDBC / PostgreSQL --- -jdbcUrl=jdbc:postgresql://localhost:5432/appdb -username=karaf_postgresql -password=change_me - -# --- HikariCP Pool --- -poolName=db-postgresql-pool -maxPoolSize=10 -minIdle=1 - -############################################# -# Optional: legacy keys (deprecated) -# (only used if the "new" keys above are NOT set) -############################################# -# dbcore.jdbcUrl=jdbc:postgresql://localhost:5432/appdb -# dbcore.username=karaf_postgresql -# dbcore.password=change_me -``` - -Alternatively, configure it through the Karaf shell: - -```sh -config:edit de.drremote.db.postgresql - -config:property-set jdbcUrl jdbc:postgresql://localhost:5432/appdb -config:property-set username karaf_postgresql -config:property-set password change_me -config:property-set poolName db-postgresql-pool -config:property-set maxPoolSize 10 -config:property-set minIdle 1 - -config:update -``` - -Verify the active configuration: - -```sh -config:list "(service.pid=de.drremote.db.postgresql)" - -config:edit de.drremote.db.postgresql -config:property-list -``` - -## Quick Start - -Inject `TransactionManager` and execute your database code inside a transaction: - -```java -@Component -public class ProjectRepository { - - @Reference - private TransactionManager tx; - - public List findAllNames() { - return tx.inTransaction(handle -> { - handle.setSearchPath("sqlcode"); - return handle.getQueryRunner().query( - "SELECT name FROM projects ORDER BY name", - rs -> rs.getString(1) - ); - }); - } -} -``` - -Write operations work the same way: - -```java -@Component -public class ProjectWriter { - - @Reference - private TransactionManager tx; - - public void insert(String name) { - tx.inTransaction(handle -> { - handle.setSearchPath("sqlcode"); - handle.getQueryRunner().update( - "INSERT INTO projects(name) VALUES (?)", - name - ); - return null; - }); - } -} -``` - -## Main APIs - -### `DbCore` - -Marker service for the PostgreSQL core lifecycle. - -Referencing this service guarantees that the PostgreSQL core is active. - -### `TransactionManager` - -Executes code inside a JDBC transaction. - -### `DbHandle` - -Provides access to: - -* the current JDBC connection -* the `QueryRunner` -* PostgreSQL `search_path` handling - -### `QueryRunner` - -Simple helper API for: - -* `query(...)` -* `queryOne(...)` -* `update(...)` - -### `MigrationService` - -Executes core and plugin migrations and stores applied versions centrally. - -### `ScriptMigration` - -Migration type backed by external SQL scripts executed through `psql`. - -> The current implementation supports `ScriptMigration` only. - -## Database Preparation - -A preparation script is included at: - -```text -src/main/resources/migrations/init.sql -``` - -The script can: - -* create a database role -* create a database -* create the `core` schema -* create the `core.schema_migrations` table -* grant base and default privileges - -Run it as a PostgreSQL superuser: - -```sh -psql -U postgres -f src/main/resources/migrations/init.sql -``` - -Before running it, adjust the variables in the script: - -```sql -\set dbname 'appdb' -\set role 'karaf_postgresql' -\set role_password 'CHANGE_ME' -``` - -## Migrations - -This module stores migration state in: - -```text -core.schema_migrations -``` - -Tracked fields include: - -* `plugin_name` -* `schema_name` -* `version` -* `description` -* `sql` -* `applied_at` - -Primary key: - -```text -(plugin_name, version) -``` - -Migrations are sorted by version and applied once. - -Example plugin migration descriptor: - -```java -public final class SqlcodeMigration implements PluginMigrationDescriptor { - - @Override - public String getPluginName() { - return "sqlcode"; - } - - @Override - public String getSchemaName() { - return "sqlcode"; - } - - @Override - public List getMigrations() { - return List.of(new ScriptMigration() { - @Override - public int getVersion() { - return 1; - } - - @Override - public String getDescription() { - return "initial schema"; - } - - @Override - public URL getScriptUrl() { - return getClass().getResource("/migrations/sqlcode/V1__init.sql"); - } - }); - } -} -``` - -Run the migration through `MigrationService`: - -```java -@Component(immediate = true) -public class MigrationRunner { - - @Reference - void run(MigrationService migrationService) { - migrationService.migratePlugin(new SqlcodeMigration()); - } -} -``` - -## Implementation Notes - -* Transactions are backed by JDBC connections from the registered `DataSource` -* On success, transactions are committed automatically -* On failure, transactions are rolled back automatically -* The implementation is PostgreSQL-specific -* Script migrations require `psql` and a reachable database host -* Schema names should be treated as trusted internal identifiers - -## Project Structure - -```text -src/main/java/de/drremote/db/postgresql/api -src/main/java/de/drremote/db/postgresql/impl -src/main/java/de/drremote/db/postgresql/impl/jdbc -src/main/java/de/drremote/db/postgresql/impl/migration -src/main/resources/migrations -``` - -## Roadmap - -Planned or useful next improvements: - -* structured logging instead of `System.out` -* stronger schema name validation -* more tests -* expanded documentation -* clearer separation between generic migrations and script migrations - -## License - -Licensed under Apache-2.0. See `LICENSE`. +Licensed under [Apache-2.0](../LICENSE). diff --git a/de.drremote.db.postgresql/config-example.md b/de.drremote.db.postgresql/config-example.md index d4c4c67..27f8d45 100644 --- a/de.drremote.db.postgresql/config-example.md +++ b/de.drremote.db.postgresql/config-example.md @@ -1,72 +1,3 @@ -## Full Configuration Example (Karaf) +# PostgreSQL configuration example -You can configure the PostgreSQL core completely through **a single** configuration (PID `de.drremote.db.postgresql`). - -> The values below are placeholders only. Do not use them unchanged in production. - -### Option 1: `etc/de.drremote.db.postgresql.cfg` file (recommended) - -Path: - -- `$KARAF_HOME/etc/de.drremote.db.postgresql.cfg` - -Content (example using database `appdb` and user `karaf_postgresql`): - -```properties -############################################# -# DB.POSTGRESQL Config (PID: de.drremote.db.postgresql) -############################################# - -# --- JDBC / PostgreSQL --- -jdbcUrl=jdbc:postgresql://localhost:5432/appdb -username=karaf_postgresql -password=change_me - -# --- HikariCP Pool --- -poolName=db-postgresql-pool -maxPoolSize=10 -minIdle=1 - -############################################# -# Optional: legacy keys (only if needed) -# (these are only used if the "new" keys -# above are NOT set) -############################################# -# dbcore.jdbcUrl=jdbc:postgresql://localhost:5432/appdb -# dbcore.username=karaf_postgresql -# dbcore.password=change_me -``` - -> After saving the file, Karaf will usually pick it up automatically through ConfigAdmin. -> Otherwise, restart Karaf. - ---- - -### Option 2: Configure everything through the Karaf shell (`config:edit`) - -```sh -config:edit de.drremote.db.postgresql - -# JDBC -config:property-set jdbcUrl jdbc:postgresql://localhost:5432/appdb -config:property-set username karaf_postgresql -config:property-set password change_me - -# HikariCP -config:property-set poolName db-postgresql-pool -config:property-set maxPoolSize 10 -config:property-set minIdle 1 - -config:update -``` - ---- - -### Optional: Verify the configuration (after setting it) - -```sh -config:list "(service.pid=de.drremote.db.postgresql)" - -config:edit de.drremote.db.postgresql -config:property-list -``` +See the [configuration guide](../docs/configuration-postgresql.md) and [copyable Karaf example](../examples/de.drremote.db.postgresql.cfg.example). diff --git a/de.drremote.db.postgresql/pom.xml b/de.drremote.db.postgresql/pom.xml index 7e2abae..9122589 100644 --- a/de.drremote.db.postgresql/pom.xml +++ b/de.drremote.db.postgresql/pom.xml @@ -1,140 +1,26 @@ - - - 4.0.0 - - - de.drremote - db-parent - 1.0.0 - ../pom.xml - - - de.drremote.db - postgresql - bundle - - Db PostgreSQL - - - 21 - UTF-8 - - ${project.version} - - 3.13.0 - 5.1.9 - 3.3.1 - 3.10.1 - - 1.5.1 - 7.0.2 - 42.7.3 - - - - - org.osgi - org.osgi.service.component.annotations - ${osgi.ds.version} - provided - - - - com.zaxxer - HikariCP - ${hikari.version} - provided - - - - org.postgresql - postgresql - ${postgres.version} - provided - - - - - - - org.apache.maven.plugins - maven-compiler-plugin - ${maven.compiler.plugin.version} - - ${java.version} - ${project.build.sourceEncoding} - - - - - org.apache.felix - maven-bundle-plugin - ${maven.bundle.plugin.version} - true - - - - ${project.groupId}.${project.artifactId} - ${project.name} - ${project.version} - JavaSE-21 - - - de.drremote.db.postgresql.api;version="${api.version}" - - - - !org.osgi.service.component.annotations, - javax.sql, - javax.naming, - java.sql, - com.zaxxer.hikari, - org.postgresql.*, - * - - - <_dsannotations>* - - - - - - org.apache.maven.plugins - maven-source-plugin - ${maven.source.plugin.version} - - - attach-sources - - jar-no-fork - - - - - - - org.apache.maven.plugins - maven-javadoc-plugin - ${maven.javadoc.plugin.version} - - ${java.version} - ${project.build.sourceEncoding} - none - - - - attach-javadocs - - jar - - - - - - - + + 4.0.0 + de.drremotedb-parent1.1.0-SNAPSHOT + de.drremote.db + postgresql + bundle + DrRemote DB postgresql + + org.osgiosgi.coreprovided + org.osgiorg.osgi.service.component.annotationsprovided + com.zaxxerHikariCPprovided + org.postgresqlpostgresqlprovided + org.junit.jupiterjunit-jupitertest + + + org.apache.felixmaven-bundle-plugintrue + + osgi.service;objectClass:List<String>="de.drremote.db.postgresql.api.DatabaseReady" + + + org.apache.maven.pluginsmaven-source-plugin + org.apache.maven.pluginsmaven-javadoc-plugin + - diff --git a/de.drremote.db.postgresql/src/main/java/de/drremote/db/postgresql/api/DatabaseReady.java b/de.drremote.db.postgresql/src/main/java/de/drremote/db/postgresql/api/DatabaseReady.java new file mode 100644 index 0000000..52e00af --- /dev/null +++ b/de.drremote.db.postgresql/src/main/java/de/drremote/db/postgresql/api/DatabaseReady.java @@ -0,0 +1,17 @@ +package de.drremote.db.postgresql.api; + +/** + * Marker service published after all migrations of a PostgreSQL plugin succeeded. + */ +public interface DatabaseReady { + + String PROPERTY_KIND = "drremote.db.kind"; + String PROPERTY_PLUGIN = "drremote.db.plugin"; + String PROPERTY_SCHEMA = "drremote.db.schema"; + + String getDatabaseKind(); + + String getPluginName(); + + String getSchemaName(); +} diff --git a/de.drremote.db.postgresql/src/main/java/de/drremote/db/postgresql/api/ScriptMigration.java b/de.drremote.db.postgresql/src/main/java/de/drremote/db/postgresql/api/ScriptMigration.java index 3375ce1..c812c3c 100644 --- a/de.drremote.db.postgresql/src/main/java/de/drremote/db/postgresql/api/ScriptMigration.java +++ b/de.drremote.db.postgresql/src/main/java/de/drremote/db/postgresql/api/ScriptMigration.java @@ -15,6 +15,16 @@ public interface ScriptMigration extends Migration { */ URL getScriptUrl(); + /** + * Whether psql should execute the script with --single-transaction. + * + * Override and return false for PostgreSQL commands that are not allowed + * inside an explicit transaction block. + */ + default boolean isSingleTransaction() { + return true; + } + /** * Script migrations are NOT executed via JDBC SQL strings. * This method must never be used. diff --git a/de.drremote.db.postgresql/src/main/java/de/drremote/db/postgresql/api/TransactionManager.java b/de.drremote.db.postgresql/src/main/java/de/drremote/db/postgresql/api/TransactionManager.java index b549440..fe816b3 100644 --- a/de.drremote.db.postgresql/src/main/java/de/drremote/db/postgresql/api/TransactionManager.java +++ b/de.drremote.db.postgresql/src/main/java/de/drremote/db/postgresql/api/TransactionManager.java @@ -2,5 +2,11 @@ public interface TransactionManager { + /** + * Runs work atomically. Nested calls on this manager and thread join the outer + * transaction; only the outer call commits or rolls back. A nested failure marks + * the entire transaction rollback-only, even if the caller catches it. + * Transactions do not propagate to other threads or manager instances. + */ T inTransaction(TransactionCallback callback) throws DbCoreException; } diff --git a/de.drremote.db.postgresql/src/main/java/de/drremote/db/postgresql/impl/DbCoreComponent.java b/de.drremote.db.postgresql/src/main/java/de/drremote/db/postgresql/impl/DbCoreComponent.java index 88d8cb1..714d738 100644 --- a/de.drremote.db.postgresql/src/main/java/de/drremote/db/postgresql/impl/DbCoreComponent.java +++ b/de.drremote.db.postgresql/src/main/java/de/drremote/db/postgresql/impl/DbCoreComponent.java @@ -1,8 +1,12 @@ package de.drremote.db.postgresql.impl; import de.drremote.db.postgresql.api.DbCore; +import de.drremote.db.postgresql.api.TransactionManager; import org.osgi.service.component.annotations.*; +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.Statement; import java.util.logging.Logger; @Component( @@ -13,10 +17,34 @@ public final class DbCoreComponent implements DbCore { private static final Logger LOG = Logger.getLogger(DbCoreComponent.class.getName()); + private DataSource dataSource; + private TransactionManager transactionManager; + private volatile boolean ready = false; + @Reference(target = "(drremote.db.kind=postgresql)") + void bindDataSource(DataSource dataSource) { + this.dataSource = dataSource; + } + + @Reference + void bindTransactionManager(TransactionManager transactionManager) { + this.transactionManager = transactionManager; + } + @Activate void activate() { + if (dataSource == null || transactionManager == null) { + throw new IllegalStateException("DbCore requires DataSource and TransactionManager"); + } + + try (Connection conn = dataSource.getConnection(); + Statement st = conn.createStatement()) { + st.execute("SELECT 1"); + } catch (Exception e) { + throw new IllegalStateException("Database connectivity check failed", e); + } + ready = true; LOG.info("Core activated"); } diff --git a/de.drremote.db.postgresql/src/main/java/de/drremote/db/postgresql/impl/jdbc/HikariDataSourceComponent.java b/de.drremote.db.postgresql/src/main/java/de/drremote/db/postgresql/impl/jdbc/HikariDataSourceComponent.java index 8659326..691b932 100644 --- a/de.drremote.db.postgresql/src/main/java/de/drremote/db/postgresql/impl/jdbc/HikariDataSourceComponent.java +++ b/de.drremote.db.postgresql/src/main/java/de/drremote/db/postgresql/impl/jdbc/HikariDataSourceComponent.java @@ -15,7 +15,8 @@ @Component( service = DataSource.class, immediate = true, - configurationPid = "de.drremote.db.postgresql" + configurationPid = "de.drremote.db.postgresql", + property = "drremote.db.kind=postgresql" ) public final class HikariDataSourceComponent implements DataSource { @@ -50,10 +51,9 @@ void deactivate() { } private synchronized void reconfigure(Map props) { - // defaults - String jdbcUrl = getString(props, "jdbcUrl", "jdbc:postgresql://localhost:5432/postgres"); - String username = getString(props, "username", "postgres"); - String password = getString(props, "password", "postgres"); + String jdbcUrl = getString(props, "jdbcUrl", null); + String username = getString(props, "username", null); + String password = getString(props, "password", null); int maxPoolSize = getInt(props, "maxPoolSize", 10); int minIdle = getInt(props, "minIdle", 1); String poolName = getString(props, "poolName", "db-postgresql-pool"); @@ -63,24 +63,38 @@ private synchronized void reconfigure(Map props) { username = getString(props, "username", getString(props, "dbcore.username", username)); password = getString(props, "password", getString(props, "dbcore.password", password)); + if (jdbcUrl == null || jdbcUrl.isBlank()) { + throw new IllegalStateException("Missing required config property: jdbcUrl"); + } + if (username == null || username.isBlank()) { + throw new IllegalStateException("Missing required config property: username"); + } + HikariConfig hc = new HikariConfig(); hc.setJdbcUrl(jdbcUrl); + hc.setDriverClassName("org.postgresql.Driver"); hc.setUsername(username); hc.setPassword(password); hc.setMaximumPoolSize(maxPoolSize); hc.setMinimumIdle(minIdle); hc.setPoolName(poolName); - closeDelegate(); - delegate = new HikariDataSource(hc); + HikariDataSource newDelegate = new HikariDataSource(hc); + HikariDataSource oldDelegate = delegate; + delegate = newDelegate; + closeQuietly(oldDelegate); } - private void closeDelegate() { + private synchronized void closeDelegate() { HikariDataSource ds = delegate; + delegate = null; + closeQuietly(ds); + } + + private static void closeQuietly(HikariDataSource ds) { if (ds != null) { try { ds.close(); } catch (Exception ignore) {} } - delegate = null; } private static String getString(Map props, String key, String def) { diff --git a/de.drremote.db.postgresql/src/main/java/de/drremote/db/postgresql/impl/jdbc/JdbcDbHandle.java b/de.drremote.db.postgresql/src/main/java/de/drremote/db/postgresql/impl/jdbc/JdbcDbHandle.java index 4706d14..e0102a0 100644 --- a/de.drremote.db.postgresql/src/main/java/de/drremote/db/postgresql/impl/jdbc/JdbcDbHandle.java +++ b/de.drremote.db.postgresql/src/main/java/de/drremote/db/postgresql/impl/jdbc/JdbcDbHandle.java @@ -1,8 +1,8 @@ package de.drremote.db.postgresql.impl.jdbc; +import de.drremote.db.postgresql.api.DbCoreException; import de.drremote.db.postgresql.api.DbHandle; import de.drremote.db.postgresql.api.QueryRunner; -import de.drremote.db.postgresql.api.DbCoreException; import java.sql.Connection; import java.sql.SQLException; @@ -30,13 +30,13 @@ public QueryRunner getQueryRunner() { @Override public void setSearchPath(String schema) { - if (schema == null || !schema.matches("[A-Za-z0-9_]+")) { - throw new IllegalArgumentException("Invalid schema name: " + schema); + if (schema == null || !schema.matches("[A-Za-z_][A-Za-z0-9_]*")) { + throw new DbCoreException("Invalid schema name: " + schema); } try (Statement st = connection.createStatement()) { st.execute("SET search_path TO " + schema + ", core, public"); } catch (SQLException e) { - throw new RuntimeException( + throw new DbCoreException( "Failed to set search_path to schema '" + schema + "'", e ); } @@ -44,12 +44,6 @@ public void setSearchPath(String schema) { @Override public void close() { - try { - if (!connection.isClosed()) { - connection.close(); - } - } catch (SQLException e) { - throw new DbCoreException("Failed to close JDBC connection", e); - } + // no-op: lifecycle is managed by JdbcTransactionManager } } diff --git a/de.drremote.db.postgresql/src/main/java/de/drremote/db/postgresql/impl/jdbc/JdbcQueryRunner.java b/de.drremote.db.postgresql/src/main/java/de/drremote/db/postgresql/impl/jdbc/JdbcQueryRunner.java index 1ccc03f..bef6ed4 100644 --- a/de.drremote.db.postgresql/src/main/java/de/drremote/db/postgresql/impl/jdbc/JdbcQueryRunner.java +++ b/de.drremote.db.postgresql/src/main/java/de/drremote/db/postgresql/impl/jdbc/JdbcQueryRunner.java @@ -4,6 +4,7 @@ import de.drremote.db.postgresql.api.QueryRunner; import de.drremote.db.postgresql.api.RowMapper; import java.sql.*; +import java.time.Instant; import java.util.ArrayList; import java.util.List; @@ -54,9 +55,17 @@ private PreparedStatement prepareStatement(String sql, Object... params) throws PreparedStatement ps = connection.prepareStatement(sql); if (params != null) { for (int i = 0; i < params.length; i++) { - ps.setObject(i + 1, params[i]); + setParameter(ps, i + 1, params[i]); } } return ps; } + + private static void setParameter(PreparedStatement ps, int index, Object value) throws SQLException { + if (value instanceof Instant instant) { + ps.setTimestamp(index, Timestamp.from(instant)); + return; + } + ps.setObject(index, value); + } } diff --git a/de.drremote.db.postgresql/src/main/java/de/drremote/db/postgresql/impl/jdbc/JdbcTransactionManager.java b/de.drremote.db.postgresql/src/main/java/de/drremote/db/postgresql/impl/jdbc/JdbcTransactionManager.java index de28533..c86beea 100644 --- a/de.drremote.db.postgresql/src/main/java/de/drremote/db/postgresql/impl/jdbc/JdbcTransactionManager.java +++ b/de.drremote.db.postgresql/src/main/java/de/drremote/db/postgresql/impl/jdbc/JdbcTransactionManager.java @@ -6,17 +6,18 @@ import org.osgi.service.component.annotations.*; import javax.sql.DataSource; -import java.sql.Connection; +import java.sql.*; +import java.util.Objects; import java.util.logging.Logger; @Component(service = TransactionManager.class) public final class JdbcTransactionManager implements TransactionManager { private static final Logger LOG = Logger.getLogger(JdbcTransactionManager.class.getName()); - + private final ThreadLocal current = new ThreadLocal<>(); private DataSource dataSource; - @Reference + @Reference(target = "(drremote.db.kind=postgresql)") void bindDataSource(DataSource dataSource) { this.dataSource = dataSource; LOG.info("DataSource bound"); @@ -29,20 +30,72 @@ void activate() { @Override public T inTransaction(TransactionCallback callback) throws DbCoreException { + Objects.requireNonNull(callback, "callback"); + TransactionState existing = current.get(); + if (existing != null) return join(existing, callback); + try (Connection conn = dataSource.getConnection()) { conn.setAutoCommit(false); + try (Statement statement = conn.createStatement()) { + statement.execute("SET search_path TO core, public"); + } - JdbcDbHandle handle = new JdbcDbHandle(conn); + TransactionState state = new TransactionState(new JdbcDbHandle(conn)); + current.set(state); try { - T result = callback.doInTransaction(handle); + T result = callback.doInTransaction(state.handle); + if (state.failure != null) { + throw new DbCoreException("Transaction marked rollback-only by nested failure", state.failure); + } conn.commit(); return result; - } catch (Exception e) { - conn.rollback(); - throw e; + } catch (Exception | Error failure) { + try { + conn.rollback(); + } catch (SQLException rollbackFailure) { + failure.addSuppressed(rollbackFailure); + } + throw failure; + } finally { + current.remove(); } - } catch (Exception e) { - throw new DbCoreException("Transaction failed", e); + } catch (Exception failure) { + throw new DbCoreException("Transaction failed", failure); + } + } + + private T join(TransactionState state, TransactionCallback callback) { + if (state.failure != null) { + throw new DbCoreException("Transaction is already rollback-only", state.failure); + } + try { + String searchPath; + try (Statement statement = state.handle.getConnection().createStatement(); + ResultSet result = statement.executeQuery("SHOW search_path")) { + result.next(); + searchPath = result.getString(1); + } + T result = callback.doInTransaction(state.handle); + // A nested repository may select another plugin schema. Restore the caller's path. + try (PreparedStatement statement = state.handle.getConnection().prepareStatement( + "SELECT set_config('search_path', ?, true)")) { + statement.setString(1, searchPath); + statement.execute(); + } + return result; + } catch (Exception | Error failure) { + if (state.failure == null) state.failure = failure; + if (failure instanceof Error error) throw error; + throw new DbCoreException("Nested transaction failed", failure); + } + } + + private static final class TransactionState { + private final JdbcDbHandle handle; + private Throwable failure; + + private TransactionState(JdbcDbHandle handle) { + this.handle = handle; } } } diff --git a/de.drremote.db.postgresql/src/main/java/de/drremote/db/postgresql/impl/migration/MigrationServiceImpl.java b/de.drremote.db.postgresql/src/main/java/de/drremote/db/postgresql/impl/migration/MigrationServiceImpl.java index 7426c65..e2fb596 100644 --- a/de.drremote.db.postgresql/src/main/java/de/drremote/db/postgresql/impl/migration/MigrationServiceImpl.java +++ b/de.drremote.db.postgresql/src/main/java/de/drremote/db/postgresql/impl/migration/MigrationServiceImpl.java @@ -1,6 +1,9 @@ package de.drremote.db.postgresql.impl.migration; import de.drremote.db.postgresql.api.*; +import de.drremote.db.postgresql.impl.jdbc.JdbcDbHandle; +import org.osgi.framework.BundleContext; +import org.osgi.framework.ServiceRegistration; import org.osgi.service.component.annotations.*; import java.io.InputStream; @@ -12,6 +15,7 @@ import java.util.*; import java.util.logging.Level; import java.util.logging.Logger; +import javax.sql.DataSource; @Component( service = MigrationService.class, @@ -23,28 +27,79 @@ public final class MigrationServiceImpl implements MigrationService { private static final Logger LOG = Logger.getLogger(MigrationServiceImpl.class.getName()); private volatile TransactionManager tx; + private DataSource dataSource; private final PsqlRunner psql = new PsqlRunner(); + private final List pending = new ArrayList<>(); + private final Map> readyServices = + new IdentityHashMap<>(); + private BundleContext context; // ConfigAdmin values (defaults) private volatile String jdbcUrl = "jdbc:postgresql://localhost:5432/postgres"; private volatile String username = "postgres"; private volatile String password = "postgres"; + private volatile String psqlExecutable = "psql"; + private volatile int psqlConnectTimeoutSeconds = 30; + private volatile boolean migrationsEnabled = true; @Activate - void activate(Map props) { + synchronized void activate(BundleContext context, Map props) { + this.context = context; applyConfig(props); + if (!migrationsEnabled) { + LOG.info("PostgreSQL automatic migrations are disabled"); + return; + } + runPendingMigrations(); + } + + @Deactivate + synchronized void deactivate() { + readyServices.values().forEach(ServiceRegistration::unregister); + readyServices.clear(); + pending.clear(); + context = null; } @Modified - void modified(Map props) { + synchronized void modified(Map props) { + boolean wasEnabled = migrationsEnabled; applyConfig(props); + if (!wasEnabled && migrationsEnabled && context != null) { + runPendingMigrations(); + } + } + + private void runPendingMigrations() { + ensureCoreSchema(); + List descriptors = List.copyOf(pending); + pending.clear(); + descriptors.forEach(this::migrateAndPublish); } private void applyConfig(Map props) { - // bevorzugt kurze Keys, fallback auf alte dotted Keys + // Prefer short keys and fall back to the legacy dotted keys. this.jdbcUrl = getString2(props, "jdbcUrl", "dbcore.jdbcUrl", this.jdbcUrl); this.username = getString2(props, "username", "dbcore.username", this.username); this.password = getString2(props, "password", "dbcore.password", this.password); + this.psqlExecutable = getString2( + props, + "psqlExecutable", + "dbcore.psqlExecutable", + this.psqlExecutable + ); + this.psqlConnectTimeoutSeconds = getInt2( + props, + "psqlConnectTimeoutSeconds", + "dbcore.psqlConnectTimeoutSeconds", + this.psqlConnectTimeoutSeconds + ); + this.migrationsEnabled = getBoolean2( + props, + "migrationsEnabled", + "dbcore.migrationsEnabled", + this.migrationsEnabled + ); } private static String getString2(Map props, String k1, String k2, String def) { @@ -53,10 +108,65 @@ private static String getString2(Map props, String k1, String k2 return v == null ? def : String.valueOf(v).trim(); } + private static int getInt2(Map props, String k1, String k2, int def) { + Object v = props != null ? props.get(k1) : null; + if (v == null && props != null) v = props.get(k2); + if (v == null) { + return def; + } + int parsed; + if (v instanceof Number n) { + parsed = n.intValue(); + } else { + try { + parsed = Integer.parseInt(String.valueOf(v).trim()); + } catch (Exception e) { + return def; + } + } + return parsed > 0 ? parsed : def; + } + + private static boolean getBoolean2(Map props, String k1, String k2, boolean def) { + Object value = props != null ? props.get(k1) : null; + if (value == null && props != null) value = props.get(k2); + if (value == null) return def; + if (value instanceof Boolean bool) return bool; + String text = String.valueOf(value).trim(); + if ("true".equalsIgnoreCase(text)) return true; + if ("false".equalsIgnoreCase(text)) return false; + return def; + } + @Reference void bindTransactionManager(TransactionManager tm) { this.tx = tm; - ensureCoreSchema(); + } + + @Reference(target = "(drremote.db.kind=postgresql)") + void bindDataSource(DataSource dataSource) { + this.dataSource = dataSource; + } + + @Reference( + service = PluginMigrationDescriptor.class, + cardinality = ReferenceCardinality.MULTIPLE, + policy = ReferencePolicy.DYNAMIC + ) + synchronized void addMigrationDescriptor(PluginMigrationDescriptor descriptor) { + if (context == null || !migrationsEnabled) { + pending.add(descriptor); + return; + } + migrateAndPublish(descriptor); + } + + synchronized void removeMigrationDescriptor(PluginMigrationDescriptor descriptor) { + pending.removeIf(candidate -> candidate == descriptor); + ServiceRegistration registration = readyServices.remove(descriptor); + if (registration != null) { + registration.unregister(); + } } // =============================== @@ -65,11 +175,13 @@ void bindTransactionManager(TransactionManager tm) { @Override public void migrateCoreSchema(List migrations) throws DbCoreException { + requireMigrationsEnabled(); applyMigrations("core", "core", migrations); } @Override public void migratePlugin(PluginMigrationDescriptor descriptor) throws DbCoreException { + requireMigrationsEnabled(); applyMigrations( descriptor.getPluginName(), descriptor.getSchemaName(), @@ -77,6 +189,64 @@ public void migratePlugin(PluginMigrationDescriptor descriptor) throws DbCoreExc ); } + private void requireMigrationsEnabled() { + if (!migrationsEnabled) { + throw new DbCoreException("PostgreSQL automatic migrations are disabled by configuration"); + } + } + + private void migrateAndPublish(PluginMigrationDescriptor descriptor) { + if (readyServices.containsKey(descriptor)) { + return; + } + + String pluginName = requireText(descriptor.getPluginName(), "plugin name"); + String schemaName = requireText(descriptor.getSchemaName(), "schema name"); + try { + migratePlugin(descriptor); + + DatabaseReady ready = new ReadyService(pluginName, schemaName); + Dictionary properties = new Hashtable<>(); + properties.put(DatabaseReady.PROPERTY_KIND, ready.getDatabaseKind()); + properties.put(DatabaseReady.PROPERTY_PLUGIN, ready.getPluginName()); + properties.put(DatabaseReady.PROPERTY_SCHEMA, ready.getSchemaName()); + readyServices.put( + descriptor, + context.registerService(DatabaseReady.class, ready, properties) + ); + LOG.info(() -> "PostgreSQL migrations ready: plugin=" + pluginName + + " schema=" + schemaName); + } catch (RuntimeException exception) { + LOG.log(Level.SEVERE, "PostgreSQL migration failed: plugin=" + pluginName + + " schema=" + schemaName, exception); + throw exception; + } + } + + private static String requireText(String value, String label) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("Migration descriptor " + label + " must not be blank"); + } + return value.trim(); + } + + private record ReadyService(String pluginName, String schemaName) implements DatabaseReady { + @Override + public String getDatabaseKind() { + return "postgresql"; + } + + @Override + public String getPluginName() { + return pluginName; + } + + @Override + public String getSchemaName() { + return schemaName; + } + } + // =============================== // CORE SCHEMA // =============================== @@ -132,38 +302,47 @@ private void applyMigrations(String pluginName, String schemaName, List - isAlreadyApplied(handle.getConnection(), pluginName, m.getVersion()) - ); - - if (already) { - LOG.log(Level.FINE, () -> "skipping v" + m.getVersion()); - continue; + Connection lockConn = null; + String lockKey = pluginName + ":" + schemaName; + try { + lockConn = acquireMigrationLock(lockKey); + // Keep locking and migration work on the same borrowed connection. + // Reserving another pool connection here deadlocks a one-connection pool. + tm = migrationTransactions(lockConn); + + for (Migration m : sorted) { + if (m instanceof ScriptMigration sm) { + applyScriptMigration( + tm, + pluginName, + schemaName, + sm, + jdbcUrlLocal, + usernameLocal, + passwordLocal, + psqlExecutableLocal, + psqlConnectTimeoutSecondsLocal + ); + } else { + applyJdbcMigration(tm, pluginName, schemaName, m); + } } - - LOG.log(Level.FINE, () -> "will apply v" + m.getVersion()); - - if (!(m instanceof ScriptMigration sm)) { - throw new IllegalStateException("Only ScriptMigration supported: " + m.getClass()); + } catch (SQLException e) { + throw new IllegalStateException("Failed to acquire migration lock", e); + } finally { + if (lockConn != null) { + try { + releaseMigrationLock(lockConn, lockKey); + } catch (Exception ignore) { + } + try { + lockConn.close(); + } catch (Exception ignore) { + } } - - String sqlText = readSqlText(sm); - - runPsqlMigration(jdbcUrlLocal, usernameLocal, passwordLocal, schemaName, sm); - - tm.inTransaction(handle -> { - insertMigrationRow( - handle.getConnection(), - pluginName, - schemaName, - m, - sqlText - ); - return null; - }); } } @@ -171,22 +350,66 @@ private void applyMigrations(String pluginName, String schemaName, List + isAlreadyApplied(handle.getConnection(), pluginName, migration.getVersion()) + ); + + if (already) { + LOG.log(Level.FINE, () -> "skipping v" + migration.getVersion()); + return; + } + + LOG.log(Level.FINE, () -> "will apply v" + migration.getVersion()); + + String sqlText = readSqlText(migration); + runPsqlMigration( + jdbcUrl, + username, + password, + schemaName, + migration, + psqlExecutable, + psqlConnectTimeoutSeconds + ); + + tm.inTransaction(handle -> { + insertMigrationRow( + handle.getConnection(), + pluginName, + schemaName, + migration, + sqlText + ); + return null; + }); + } + private void runPsqlMigration( String jdbcUrl, String username, String password, String schemaName, - ScriptMigration migration + ScriptMigration migration, + String psqlExecutable, + int psqlConnectTimeoutSeconds ) { - URL url = migration.getScriptUrl(); - if (url == null) { - throw new IllegalStateException("Migration script URL is null for v" + migration.getVersion()); - } + URL url = scriptUrl(migration); Path tmp = null; try { tmp = Files.createTempFile( - "db-postgresql-" + schemaName + "-v" + migration.getVersion() + "-", + tempFilePrefix(schemaName, migration.getVersion()), ".sql" ); @@ -194,7 +417,16 @@ private void runPsqlMigration( Files.copy(in, tmp, java.nio.file.StandardCopyOption.REPLACE_EXISTING); } - psql.runSqlFile(jdbcUrl, username, password, schemaName, tmp); + psql.runSqlFile( + psqlExecutable, + jdbcUrl, + username, + password, + schemaName, + tmp, + migration.isSingleTransaction(), + psqlConnectTimeoutSeconds + ); } catch (Exception e) { throw new IllegalStateException("Failed to apply migration script v" + migration.getVersion(), e); @@ -210,9 +442,10 @@ private void runPsqlMigration( // =============================== private String readSqlText(ScriptMigration migration) { - try (InputStream in = migration.getScriptUrl().openStream()) { + URL url = scriptUrl(migration); + try (InputStream in = url.openStream()) { if (in == null) { - throw new IllegalStateException("Cannot read SQL from " + migration.getScriptUrl()); + throw new IllegalStateException("Cannot read SQL from " + url); } return new String(in.readAllBytes(), StandardCharsets.UTF_8); } catch (Exception e) { @@ -223,6 +456,62 @@ private String readSqlText(ScriptMigration migration) { } } + private URL scriptUrl(ScriptMigration migration) { + URL url = migration.getScriptUrl(); + if (url == null) { + throw new IllegalStateException("Migration script URL is null for v" + migration.getVersion()); + } + return url; + } + + private String tempFilePrefix(String schemaName, int version) { + String safeSchema = schemaName == null ? "schema" : schemaName.replaceAll("[^A-Za-z0-9._-]", "_"); + String prefix = "db-postgresql-" + safeSchema + "-v" + version + "-"; + return prefix.length() >= 3 ? prefix : "db-"; + } + + private void runJdbcMigration( + TransactionManager tm, + String pluginName, + String schemaName, + Migration migration, + String sqlText + ) { + if (sqlText == null || sqlText.isBlank()) { + throw new IllegalStateException("Migration SQL is blank for v" + migration.getVersion()); + } + + tm.inTransaction(handle -> { + Connection conn = handle.getConnection(); + if (isAlreadyApplied(conn, pluginName, migration.getVersion())) { + LOG.log(Level.FINE, () -> "skipping v" + migration.getVersion()); + return null; + } + + LOG.log(Level.FINE, () -> "will apply v" + migration.getVersion()); + + handle.setSearchPath(schemaName); + try (Statement st = conn.createStatement()) { + // setSearchPath validates the identifier before it is used in DDL. + st.execute("CREATE SCHEMA IF NOT EXISTS " + schemaName); + st.execute(sqlText); + } catch (SQLException e) { + throw new IllegalStateException("Failed to apply JDBC migration v" + migration.getVersion(), e); + } + insertMigrationRow(conn, pluginName, schemaName, migration, sqlText); + return null; + }); + } + + private void applyJdbcMigration( + TransactionManager tm, + String pluginName, + String schemaName, + Migration migration + ) { + runJdbcMigration(tm, pluginName, schemaName, migration, migration.getSql()); + } + // =============================== // MIGRATION STATE // =============================== @@ -261,4 +550,69 @@ private void insertMigrationRow( ps.executeUpdate(); } } + + private TransactionManager migrationTransactions(Connection connection) { + return new TransactionManager() { + @Override + public T inTransaction(TransactionCallback callback) throws DbCoreException { + try { + connection.setAutoCommit(false); + try { + try (Statement statement = connection.createStatement()) { + statement.execute("SET search_path TO core, public"); + } + T result = callback.doInTransaction(new JdbcDbHandle(connection)); + connection.commit(); + return result; + } catch (Exception | Error failure) { + try { + connection.rollback(); + } catch (SQLException rollbackFailure) { + failure.addSuppressed(rollbackFailure); + } + throw failure; + } + } catch (Exception failure) { + throw new DbCoreException("Migration transaction failed", failure); + } + } + }; + } + + private Connection acquireMigrationLock(String lockKey) throws SQLException { + if (dataSource == null) { + throw new IllegalStateException("DataSource not bound"); + } + Connection conn = dataSource.getConnection(); + boolean locked = false; + try { + try (PreparedStatement ps = conn.prepareStatement("SELECT pg_advisory_lock(hashtext(?))")) { + ps.setString(1, lockKey); + ps.execute(); + locked = true; + } + return conn; + } catch (SQLException | RuntimeException | Error failure) { + if (locked) { + try { + releaseMigrationLock(conn, lockKey); + } catch (SQLException cleanupFailure) { + if (cleanupFailure != failure) failure.addSuppressed(cleanupFailure); + } + } + try { + conn.close(); + } catch (SQLException closeFailure) { + if (closeFailure != failure) failure.addSuppressed(closeFailure); + } + throw failure; + } + } + + private void releaseMigrationLock(Connection conn, String lockKey) throws SQLException { + try (PreparedStatement ps = conn.prepareStatement("SELECT pg_advisory_unlock(hashtext(?))")) { + ps.setString(1, lockKey); + ps.execute(); + } + } } diff --git a/de.drremote.db.postgresql/src/main/java/de/drremote/db/postgresql/impl/migration/PsqlRunner.java b/de.drremote.db.postgresql/src/main/java/de/drremote/db/postgresql/impl/migration/PsqlRunner.java index 5fc86c5..bcd1ef1 100644 --- a/de.drremote.db.postgresql/src/main/java/de/drremote/db/postgresql/impl/migration/PsqlRunner.java +++ b/de.drremote.db.postgresql/src/main/java/de/drremote/db/postgresql/impl/migration/PsqlRunner.java @@ -1,12 +1,19 @@ package de.drremote.db.postgresql.impl.migration; +import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.List; public final class PsqlRunner { + private static final String DEFAULT_EXECUTABLE = "psql"; + private static final int MAX_CAPTURED_OUTPUT_BYTES = 64 * 1024; + public void runSqlFile( String jdbcUrl, String username, @@ -14,16 +21,41 @@ public void runSqlFile( String schemaName, Path sqlFile ) { + runSqlFile(DEFAULT_EXECUTABLE, jdbcUrl, username, password, schemaName, sqlFile, true, 30); + } + + public void runSqlFile( + String executable, + String jdbcUrl, + String username, + String password, + String schemaName, + Path sqlFile, + boolean singleTransaction, + int connectTimeoutSeconds + ) { + String command = normalizeExecutable(executable); String psqlUrl = PostgresJdbcUrlConverter.toPsqlUrl(jdbcUrl); + if (psqlUrl == null || psqlUrl.isBlank()) { + throw new IllegalArgumentException("jdbcUrl must not be empty"); + } + if (sqlFile == null || !Files.isRegularFile(sqlFile)) { + throw new IllegalArgumentException("sqlFile must point to an existing file"); + } // psql keeps a single session for -c and -f in one invocation. // So we can create schema + set search_path then execute the file. String preamble = buildPreamble(schemaName); List cmd = new ArrayList<>(); - cmd.add("psql"); - cmd.add(psqlUrl); + cmd.add(command); cmd.add("-X"); // do not read ~/.psqlrc (important for reproducibility) + cmd.add("-w"); // never wait for an interactive password prompt + if (singleTransaction) { + cmd.add("-1"); // single transaction + } + cmd.add("-d"); + cmd.add(psqlUrl); cmd.add("-v"); cmd.add("ON_ERROR_STOP=1"); // fail hard on first error cmd.add("-c"); @@ -40,18 +72,24 @@ public void runSqlFile( if (password != null) { pb.environment().put("PGPASSWORD", password); } + if (connectTimeoutSeconds > 0) { + pb.environment().put("PGCONNECT_TIMEOUT", String.valueOf(connectTimeoutSeconds)); + } - pb.inheritIO(); + pb.redirectErrorStream(true); try { Process p = pb.start(); + String output = readProcessOutput(p.getInputStream()); int exit = p.waitFor(); if (exit != 0) { - throw new IllegalStateException("psql failed (exit code " + exit + ")"); + throw new IllegalStateException( + "psql failed (exit code " + exit + ")" + formatOutput(output) + ); } } catch (IOException e) { throw new IllegalStateException( - "psql could not be started. Is it installed and on PATH?", + "psql could not be started using executable '" + command + "'", e ); } catch (InterruptedException e) { @@ -60,7 +98,14 @@ public void runSqlFile( } } - private String buildPreamble(String schemaName) { + private String normalizeExecutable(String executable) { + if (executable == null || executable.isBlank()) { + return DEFAULT_EXECUTABLE; + } + return executable.trim(); + } + + String buildPreamble(String schemaName) { String safeSchema = quoteIdent(schemaName); // Ensure schema exists (plugins) and set search_path. @@ -78,12 +123,40 @@ private String quoteIdent(String ident) { throw new IllegalArgumentException("schemaName must not be empty"); } - // allow a-z A-Z 0-9 _ only - if (ident.matches("[A-Za-z0-9_]+")) { + // PostgreSQL unquoted identifiers must not start with a digit. + if (ident.matches("[A-Za-z_][A-Za-z0-9_]*")) { return ident; } // otherwise quote and escape " return "\"" + ident.replace("\"", "\"\"") + "\""; } + + private String readProcessOutput(InputStream in) throws IOException { + ByteArrayOutputStream captured = new ByteArrayOutputStream(); + byte[] buffer = new byte[8192]; + int total = 0; + int read; + while ((read = in.read(buffer)) != -1) { + if (total < MAX_CAPTURED_OUTPUT_BYTES) { + int keep = Math.min(read, MAX_CAPTURED_OUTPUT_BYTES - total); + captured.write(buffer, 0, keep); + } + total += read; + } + + String output = new String(captured.toByteArray(), StandardCharsets.UTF_8); + if (total > MAX_CAPTURED_OUTPUT_BYTES) { + output += System.lineSeparator() + + "... psql output truncated after " + MAX_CAPTURED_OUTPUT_BYTES + " bytes"; + } + return output; + } + + private String formatOutput(String output) { + if (output == null || output.isBlank()) { + return ""; + } + return System.lineSeparator() + output.strip(); + } } diff --git a/de.drremote.db.postgresql/src/main/resources/migrations/init.sql b/de.drremote.db.postgresql/src/main/resources/migrations/init.sql index 3146aa9..bed515b 100644 --- a/de.drremote.db.postgresql/src/main/resources/migrations/init.sql +++ b/de.drremote.db.postgresql/src/main/resources/migrations/init.sql @@ -24,22 +24,26 @@ \echo \echo '==[1/3] Create role (if missing) ==' -DO $$ -BEGIN - IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = :'role') THEN - EXECUTE format('CREATE ROLE %I LOGIN PASSWORD %L', :'role', :'role_password'); - END IF; -END $$; +SELECT format( + 'CREATE ROLE %I LOGIN PASSWORD %L', + :'role', + :'role_password' +) +WHERE NOT EXISTS ( + SELECT 1 FROM pg_roles WHERE rolname = :'role' +)\gexec \echo \echo '==[2/3] Create database (if missing) ==' -DO $$ -BEGIN - IF NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = :'dbname') THEN - EXECUTE format('CREATE DATABASE %I OWNER %I', :'dbname', :'role'); - END IF; -END $$; +SELECT format( + 'CREATE DATABASE %I OWNER %I', + :'dbname', + :'role' +) +WHERE NOT EXISTS ( + SELECT 1 FROM pg_database WHERE datname = :'dbname' +)\gexec \echo \echo '== Connect to target DB ==' diff --git a/de.drremote.db.postgresql/src/test/java/de/drremote/db/postgresql/impl/jdbc/JdbcTransactionManagerTest.java b/de.drremote.db.postgresql/src/test/java/de/drremote/db/postgresql/impl/jdbc/JdbcTransactionManagerTest.java new file mode 100644 index 0000000..3375b81 --- /dev/null +++ b/de.drremote.db.postgresql/src/test/java/de/drremote/db/postgresql/impl/jdbc/JdbcTransactionManagerTest.java @@ -0,0 +1,161 @@ +package de.drremote.db.postgresql.impl.jdbc; + +import de.drremote.db.postgresql.api.DbCoreException; +import org.junit.jupiter.api.Test; +import javax.sql.DataSource; +import java.lang.reflect.Proxy; +import java.sql.*; +import java.util.List; +import java.util.concurrent.*; +import static org.junit.jupiter.api.Assertions.*; + +class JdbcTransactionManagerTest { + private final List connections = new java.util.concurrent.CopyOnWriteArrayList<>(); + private final JdbcTransactionManager tx = manager(); + + @Test void nestedCallsUseOneConnectionAndCommitOnce() { + assertEquals("done", tx.inTransaction(outer -> { + tx.inTransaction(inner -> { + assertSame(outer.getConnection(), inner.getConnection()); + return null; + }); + assertEquals(0, connections.get(0).commits); + assertEquals(0, connections.get(0).closes); + return "done"; + })); + assertEquals(1, connections.size()); + assertEquals(1, connections.get(0).commits); + assertEquals(0, connections.get(0).rollbacks); + assertEquals(1, connections.get(0).closes); + } + + @Test void caughtInnerFailureRollsBackOuterAndCleansThreadState() { + assertThrows(DbCoreException.class, () -> tx.inTransaction(outer -> { + assertThrows(DbCoreException.class, () -> tx.inTransaction(inner -> { + throw new SQLException("failed write"); + })); + return null; + })); + assertEquals(0, connections.get(0).commits); + assertEquals(1, connections.get(0).rollbacks); + tx.inTransaction(handle -> null); + assertEquals(2, connections.size()); + assertEquals(1, connections.get(1).commits); + } + + @Test void errorAlsoRollsBackAndCleansThreadState() { + AssertionError failure = new AssertionError("stop"); + assertSame(failure, assertThrows(AssertionError.class, () -> tx.inTransaction(outer -> + tx.inTransaction(inner -> { throw failure; })))); + assertEquals(1, connections.get(0).rollbacks); + assertEquals(1, connections.get(0).closes); + tx.inTransaction(handle -> null); + assertEquals(2, connections.size()); + } + + @Test void rollbackFailureDoesNotHideOriginalFailure() { + SQLException original = new SQLException("write failed"); + DbCoreException failure = assertThrows(DbCoreException.class, () -> tx.inTransaction(handle -> { + connections.get(0).failRollback = true; + throw original; + })); + assertSame(original, failure.getCause()); + assertEquals("rollback failed", original.getSuppressed()[0].getMessage()); + assertEquals(1, connections.get(0).closes); + } + + @Test void commitFailureRollsBackAndReleasesConnection() { + assertThrows(DbCoreException.class, () -> tx.inTransaction(handle -> { + connections.get(0).failCommit = true; + return null; + })); + assertEquals(1, connections.get(0).rollbacks); + assertEquals(1, connections.get(0).closes); + } + + @Test void concurrentThreadsHaveIndependentTransactions() throws Exception { + ExecutorService executor = Executors.newFixedThreadPool(2); + CyclicBarrier barrier = new CyclicBarrier(2); + Callable work = () -> tx.inTransaction(outer -> { + barrier.await(5, TimeUnit.SECONDS); + return tx.inTransaction(inner -> inner.getConnection()); + }); + try { + Future first = executor.submit(work); + Future second = executor.submit(work); + assertNotSame(first.get(10, TimeUnit.SECONDS), second.get(10, TimeUnit.SECONDS)); + assertEquals(2, connections.size()); + assertTrue(connections.stream().allMatch(state -> state.commits == 1 && state.closes == 1)); + } finally { executor.shutdownNow(); } + } + + @Test void nestedRepositoryRestoresOuterSearchPath() { + tx.inTransaction(outer -> { + outer.setSearchPath("iam"); + tx.inTransaction(inner -> { inner.setSearchPath("emailverify"); return null; }); + assertEquals("iam, core, public", connections.get(0).searchPath); + return null; + }); + } + + private JdbcTransactionManager manager() { + JdbcTransactionManager result = new JdbcTransactionManager(); + result.bindDataSource(proxy(DataSource.class, (p, method, args) -> { + if (!method.getName().equals("getConnection")) throw new UnsupportedOperationException(); + ConnectionState state = new ConnectionState(); + connections.add(state); + return state.connection(); + })); + return result; + } + + private static T proxy(Class type, java.lang.reflect.InvocationHandler handler) { + return type.cast(Proxy.newProxyInstance(type.getClassLoader(), new Class[]{type}, handler)); + } + + private static final class ConnectionState { + int commits, rollbacks, closes; + boolean failRollback, failCommit; + String searchPath = "core, public"; + + Connection connection() { + return proxy(Connection.class, (p, method, args) -> { + switch (method.getName()) { + case "setAutoCommit": return null; + case "commit": + if (failCommit) throw new SQLException("commit failed"); + commits++; return null; + case "rollback": + rollbacks++; + if (failRollback) throw new SQLException("rollback failed"); + return null; + case "close": closes++; return null; + case "createStatement": return proxy(Statement.class, (sp, sm, sa) -> { + if (sm.getName().equals("close")) return null; + if (sm.getName().equals("execute")) { + searchPath = ((String) sa[0]).substring("SET search_path TO ".length()); + return false; + } + if (sm.getName().equals("executeQuery")) { + String saved = searchPath; + return proxy(ResultSet.class, (rp, rm, ra) -> switch (rm.getName()) { + case "next" -> true; + case "getString" -> saved; + case "close" -> null; + default -> throw new UnsupportedOperationException(rm.getName()); + }); + } + throw new UnsupportedOperationException(sm.getName()); + }); + case "prepareStatement": return proxy(PreparedStatement.class, (pp, pm, pa) -> { + if (pm.getName().equals("setString")) { searchPath = (String) pa[1]; return null; } + if (pm.getName().equals("execute")) return true; + if (pm.getName().equals("close")) return null; + throw new UnsupportedOperationException(pm.getName()); + }); + default: throw new UnsupportedOperationException(method.getName()); + } + }); + } + } +} diff --git a/de.drremote.db.postgresql/src/test/java/de/drremote/db/postgresql/impl/jdbc/PostgresTestTransactions.java b/de.drremote.db.postgresql/src/test/java/de/drremote/db/postgresql/impl/jdbc/PostgresTestTransactions.java new file mode 100644 index 0000000..7758f0c --- /dev/null +++ b/de.drremote.db.postgresql/src/test/java/de/drremote/db/postgresql/impl/jdbc/PostgresTestTransactions.java @@ -0,0 +1,14 @@ +package de.drremote.db.postgresql.impl.jdbc; + +import de.drremote.db.postgresql.api.TransactionManager; +import javax.sql.DataSource; + +public final class PostgresTestTransactions { + private PostgresTestTransactions() {} + + public static TransactionManager using(DataSource dataSource) { + JdbcTransactionManager manager = new JdbcTransactionManager(); + manager.bindDataSource(dataSource); + return manager; + } +} diff --git a/de.drremote.db.postgresql/src/test/java/de/drremote/db/postgresql/impl/migration/MigrationLockTest.java b/de.drremote.db.postgresql/src/test/java/de/drremote/db/postgresql/impl/migration/MigrationLockTest.java new file mode 100644 index 0000000..612cd44 --- /dev/null +++ b/de.drremote.db.postgresql/src/test/java/de/drremote/db/postgresql/impl/migration/MigrationLockTest.java @@ -0,0 +1,57 @@ +package de.drremote.db.postgresql.impl.migration; + +import de.drremote.db.postgresql.api.*; +import org.junit.jupiter.api.Test; + +import javax.sql.DataSource; +import java.lang.reflect.Proxy; +import java.sql.Connection; +import java.sql.SQLException; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.jupiter.api.Assertions.*; + +class MigrationLockTest { + @Test + void failedAdvisoryLockAcquisitionClosesBorrowedConnection() { + AtomicBoolean borrowed = new AtomicBoolean(); + SQLException lockFailure = new SQLException("lock timeout"); + Connection connection = (Connection) Proxy.newProxyInstance( + getClass().getClassLoader(), new Class[]{Connection.class}, (proxy, method, args) -> { + if (method.getName().equals("prepareStatement")) throw lockFailure; + if (method.getName().equals("close")) { borrowed.set(false); return null; } + throw new AssertionError(method.getName()); + }); + DataSource source = (DataSource) Proxy.newProxyInstance( + getClass().getClassLoader(), new Class[]{DataSource.class}, (proxy, method, args) -> { + if (method.getName().equals("getConnection")) { + assertTrue(borrowed.compareAndSet(false, true), "Pool capacity was leaked"); + return connection; + } + throw new AssertionError(method.getName()); + }); + MigrationServiceImpl service = new MigrationServiceImpl(); + service.bindDataSource(source); + service.bindTransactionManager(new TransactionManager() { + public T inTransaction(TransactionCallback callback) { throw new AssertionError("Unexpected transaction"); } + }); + PluginMigrationDescriptor descriptor = new PluginMigrationDescriptor() { + public String getPluginName() { return "lock-test"; } + public String getSchemaName() { return "lock_test"; } + public List getMigrations() { + return List.of(new Migration() { + public int getVersion() { return 1; } + public String getDescription() { return "unused"; } + public String getSql() { return "SELECT 1"; } + }); + } + }; + IllegalStateException failure = assertThrows(IllegalStateException.class, + () -> service.migratePlugin(descriptor)); + assertSame(lockFailure, failure.getCause()); + assertFalse(borrowed.get(), "Failed lock acquisition must release its connection"); + assertThrows(IllegalStateException.class, () -> service.migratePlugin(descriptor)); + assertFalse(borrowed.get()); + } +} diff --git a/de.drremote.db.postgresql/src/test/java/de/drremote/db/postgresql/impl/migration/MigrationServicePostgresTest.java b/de.drremote.db.postgresql/src/test/java/de/drremote/db/postgresql/impl/migration/MigrationServicePostgresTest.java new file mode 100644 index 0000000..5fea482 --- /dev/null +++ b/de.drremote.db.postgresql/src/test/java/de/drremote/db/postgresql/impl/migration/MigrationServicePostgresTest.java @@ -0,0 +1,170 @@ +package de.drremote.db.postgresql.impl.migration; + +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import de.drremote.db.postgresql.api.Migration; +import de.drremote.db.postgresql.api.PluginMigrationDescriptor; +import de.drremote.db.postgresql.api.ScriptMigration; +import de.drremote.db.postgresql.impl.jdbc.PostgresTestTransactions; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.net.URL; +import java.sql.Connection; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +@EnabledIfEnvironmentVariable(named = "DRREMOTE_TEST_POSTGRES_URL", matches = ".+") +class MigrationServicePostgresTest { + @TempDir Path directory; + private HikariDataSource dataSource; + private MigrationServiceImpl service; + private String plugin; + private String schema; + private String table; + + @BeforeEach + void names() { + String id = UUID.randomUUID().toString().replace("-", ""); + plugin = "pgtest_" + id; + schema = "schema_" + id; + table = "item_" + id; + } + + private void start(int poolSize) { + HikariConfig config = new HikariConfig(); + config.setJdbcUrl(System.getenv("DRREMOTE_TEST_POSTGRES_URL")); + config.setUsername(System.getenv().getOrDefault("DRREMOTE_TEST_POSTGRES_USER", "postgres")); + config.setPassword(System.getenv().getOrDefault("DRREMOTE_TEST_POSTGRES_PASSWORD", "postgres")); + config.setMaximumPoolSize(poolSize); + config.setMinimumIdle(1); + config.setConnectionTimeout(500); + dataSource = new HikariDataSource(config); + service = new MigrationServiceImpl(); + service.bindDataSource(dataSource); + service.bindTransactionManager(PostgresTestTransactions.using(dataSource)); + service.activate(null, Map.of( + "jdbcUrl", config.getJdbcUrl(), "username", config.getUsername(), + "password", config.getPassword())); + } + + @AfterEach + void tearDown() throws Exception { + if (dataSource == null) return; + try (Connection connection = dataSource.getConnection(); + var statement = connection.createStatement()) { + statement.execute("DROP SCHEMA IF EXISTS " + schema + " CASCADE"); + statement.execute("DROP TABLE IF EXISTS core." + table); + try (var delete = connection.prepareStatement( + "DELETE FROM core.schema_migrations WHERE plugin_name = ?")) { + delete.setString(1, plugin); + delete.executeUpdate(); + } + } finally { + dataSource.close(); + } + } + + @Test + void createsFreshPluginSchemaBeforeUnqualifiedJdbcMigration() throws Exception { + start(2); + service.migratePlugin(descriptor("CREATE TABLE " + table + " (id INTEGER PRIMARY KEY)")); + assertNotNull(relation(schema + "." + table)); + assertNull(relation("core." + table)); + } + + @Test + void schemaQualifiedMigrationWorksOnFreshDatabase() throws Exception { + start(2); + service.migratePlugin(descriptor("CREATE TABLE " + schema + "." + table + " (id INTEGER PRIMARY KEY)")); + assertNotNull(relation(schema + "." + table)); + } + + @Test + void oneConnectionPoolCanMigrateAndReplayWithoutStarvation() throws Exception { + start(1); + try (var connection = dataSource.getConnection(); var statement = connection.createStatement()) { + statement.execute("CREATE SCHEMA " + schema); + } + var descriptor = descriptor("CREATE TABLE " + table + " (id INTEGER PRIMARY KEY)"); + service.migratePlugin(descriptor); + service.migratePlugin(descriptor); + assertNotNull(relation(schema + "." + table)); + try (var connection = dataSource.getConnection(); + var statement = connection.prepareStatement( + "SELECT COUNT(*) FROM core.schema_migrations WHERE plugin_name = ?")) { + statement.setString(1, plugin); + try (var rows = statement.executeQuery()) { + assertTrue(rows.next()); + assertEquals(1, rows.getInt(1)); + } + } + } + + @Test + @Timeout(15) + void mixedJdbcAndPsqlMigrationsCommitBetweenVersionsWithOneConnection() throws Exception { + start(1); + Path script = directory.resolve("insert.sql"); + Files.writeString(script, "INSERT INTO " + table + " VALUES (1);\n"); + ScriptMigration insert = new ScriptMigration() { + public int getVersion() { return 2; } + public String getDescription() { return "Insert from psql"; } + public URL getScriptUrl() { + try { return script.toUri().toURL(); } + catch (Exception failure) { throw new IllegalStateException(failure); } + } + }; + service.migratePlugin(descriptor(List.of( + migration(1, "CREATE TABLE " + table + " (id INTEGER PRIMARY KEY)"), + insert, + migration(3, "INSERT INTO " + table + " VALUES (2)")))); + try (var connection = dataSource.getConnection(); var statement = connection.createStatement(); + var rows = statement.executeQuery("SELECT COUNT(*) FROM " + schema + "." + table)) { + assertTrue(rows.next()); + assertEquals(2, rows.getInt(1)); + } + } + + private String relation(String name) throws Exception { + try (var connection = dataSource.getConnection(); + var statement = connection.prepareStatement("SELECT to_regclass(?)::text")) { + statement.setString(1, name); + try (var rows = statement.executeQuery()) { + rows.next(); + return rows.getString(1); + } + } + } + + private PluginMigrationDescriptor descriptor(String sql) { + return descriptor(List.of(migration(1, sql))); + } + + private static Migration migration(int version, String sql) { + return new Migration() { + public int getVersion() { return version; } + public String getDescription() { return "Test migration " + version; } + public String getSql() { return sql; } + }; + } + + private PluginMigrationDescriptor descriptor(List migrations) { + return new PluginMigrationDescriptor() { + public String getPluginName() { return plugin; } + public String getSchemaName() { return schema; } + public List getMigrations() { + return migrations; + } + }; + } +} diff --git a/de.drremote.db.postgresql/src/test/java/de/drremote/db/postgresql/impl/migration/PostgresJdbcUrlConverterTest.java b/de.drremote.db.postgresql/src/test/java/de/drremote/db/postgresql/impl/migration/PostgresJdbcUrlConverterTest.java new file mode 100644 index 0000000..ddcdf36 --- /dev/null +++ b/de.drremote.db.postgresql/src/test/java/de/drremote/db/postgresql/impl/migration/PostgresJdbcUrlConverterTest.java @@ -0,0 +1,32 @@ +package de.drremote.db.postgresql.impl.migration; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import org.junit.jupiter.api.Test; + +final class PostgresJdbcUrlConverterTest { + + @Test + void convertsStandardJdbcUrlToPsqlUrl() { + assertEquals( + "postgresql://localhost:5432/appdb?sslmode=require", + PostgresJdbcUrlConverter.toPsqlUrl( + " jdbc:postgresql://localhost:5432/appdb?sslmode=require " + ) + ); + } + + @Test + void keepsNonStandardJdbcUrlUnchanged() { + assertEquals( + "jdbc:postgresql:appdb", + PostgresJdbcUrlConverter.toPsqlUrl("jdbc:postgresql:appdb") + ); + } + + @Test + void keepsNullAsNull() { + assertNull(PostgresJdbcUrlConverter.toPsqlUrl(null)); + } +} diff --git a/de.drremote.db.postgresql/src/test/java/de/drremote/db/postgresql/impl/migration/PsqlRunnerTest.java b/de.drremote.db.postgresql/src/test/java/de/drremote/db/postgresql/impl/migration/PsqlRunnerTest.java new file mode 100644 index 0000000..6758245 --- /dev/null +++ b/de.drremote.db.postgresql/src/test/java/de/drremote/db/postgresql/impl/migration/PsqlRunnerTest.java @@ -0,0 +1,138 @@ +package de.drremote.db.postgresql.impl.migration; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Locale; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +final class PsqlRunnerTest { + + @TempDir + Path tempDir; + + @Test + void invokesConfiguredPsqlWithEnvironmentAndPreamble() throws Exception { + Path record = tempDir.resolve("psql-record.txt"); + Path fakePsql = fakePsql("fake-psql-success", record, 0, "ok"); + Path sqlFile = sqlFile(); + + new PsqlRunner().runSqlFile( + fakePsql.toString(), + "jdbc:postgresql://localhost:5432/appdb", + "app_user", + "secret", + "app_schema", + sqlFile, + false, + 12 + ); + + String out = Files.readString(record); + assertTrue(out.contains("PGUSER=app_user")); + assertTrue(out.contains("PGPASSWORD=secret")); + assertTrue(out.contains("PGCONNECT_TIMEOUT=12")); + assertTrue(out.contains("ARG=-X")); + assertTrue(out.contains("ARG=-w")); + assertTrue(out.contains("ARG=-d")); + assertTrue(out.contains("ARG=postgresql://localhost:5432/appdb")); + assertTrue(out.contains("CREATE SCHEMA IF NOT EXISTS app_schema")); + assertTrue(out.contains("SET search_path TO app_schema, core, public")); + assertTrue(out.contains("ARG=" + sqlFile.toAbsolutePath())); + assertFalse(out.contains("ARG=-1")); + } + + @Test + void quotesNonStandardSchemaNamesInPreamble() { + String preamble = new PsqlRunner().buildPreamble("123 bad\"schema"); + + assertTrue(preamble.contains("CREATE SCHEMA IF NOT EXISTS \"123 bad\"\"schema\"")); + assertTrue(preamble.contains("SET search_path TO \"123 bad\"\"schema\", core, public")); + } + + @Test + void includesPsqlOutputWhenCommandFails() throws Exception { + Path record = tempDir.resolve("psql-record-fail.txt"); + Path fakePsql = fakePsql("fake-psql-fail", record, 7, "boom from psql"); + + IllegalStateException ex = assertThrows( + IllegalStateException.class, + () -> new PsqlRunner().runSqlFile( + fakePsql.toString(), + "jdbc:postgresql://localhost:5432/appdb", + "app_user", + "secret", + "app_schema", + sqlFile(), + true, + 30 + ) + ); + + assertTrue(ex.getMessage().contains("exit code 7")); + assertTrue(ex.getMessage().contains("boom from psql")); + } + + private Path sqlFile() throws IOException { + Path sqlFile = tempDir.resolve("migration.sql"); + Files.writeString(sqlFile, "CREATE TABLE sample(id integer);"); + return sqlFile; + } + + private Path fakePsql(String name, Path record, int exitCode, String output) throws IOException { + boolean windows = isWindows(); + Path script = tempDir.resolve(name + (windows ? ".cmd" : ".sh")); + Files.writeString(script, windows + ? windowsScript(record, exitCode, output) + : unixScript(record, exitCode, output)); + if (!windows) { + script.toFile().setExecutable(true); + } + return script; + } + + private static boolean isWindows() { + return System.getProperty("os.name").toLowerCase(Locale.ROOT).contains("win"); + } + + private static String windowsScript(Path record, int exitCode, String output) { + String recordPath = record.toString(); + return """ + @echo off + setlocal EnableDelayedExpansion + echo PGUSER=%%PGUSER%%> "%s" + echo PGPASSWORD=%%PGPASSWORD%%>> "%s" + echo PGCONNECT_TIMEOUT=%%PGCONNECT_TIMEOUT%%>> "%s" + :args + if "%%~1"=="" goto done + set "ARG=%%~1" + echo ARG=!ARG!>> "%s" + shift + goto args + :done + echo %s + exit /b %d + """.formatted(recordPath, recordPath, recordPath, recordPath, output, exitCode); + } + + private static String unixScript(Path record, int exitCode, String output) { + return """ + #!/bin/sh + { + printf 'PGUSER=%%s\\n' "$PGUSER" + printf 'PGPASSWORD=%%s\\n' "$PGPASSWORD" + printf 'PGCONNECT_TIMEOUT=%%s\\n' "$PGCONNECT_TIMEOUT" + for arg in "$@"; do + printf 'ARG=%%s\\n' "$arg" + done + } > '%s' + printf '%s\\n' + exit %d + """.formatted(record, output, exitCode); + } +} diff --git a/de.drremote.db.postgresql/usage.md b/de.drremote.db.postgresql/usage.md index ce6e5c7..cc6e9f0 100644 --- a/de.drremote.db.postgresql/usage.md +++ b/de.drremote.db.postgresql/usage.md @@ -1,177 +1,3 @@ -## Example 1: DbCore as a lifecycle guarantee +# PostgreSQL usage -```java -@Component(immediate = true) -public class DbCoreGuard { - - @Reference - void bind(DbCore core) { - if (!core.isReady()) { - throw new IllegalStateException("DbCore not ready"); - } - } -} -``` - -## Example 2: Simple repository (read) - -```java -@Component -public class ProjectRepository { - - @Reference - TransactionManager tx; - - public List findAllNames() { - return tx.inTransaction(h -> { - h.setSearchPath("sqlcode"); - return h.getQueryRunner().query( - "SELECT name FROM projects ORDER BY name", - rs -> rs.getString(1) - ); - }); - } -} -``` - -## Example 3: Insert / update - -```java -@Component -public class ProjectWriter { - - @Reference - TransactionManager tx; - - public void insert(String name) { - tx.inTransaction(h -> { - h.setSearchPath("sqlcode"); - h.getQueryRunner().update( - "INSERT INTO projects(name) VALUES (?)", - name - ); - return null; - }); - } -} -``` - -## Example 4: QueryOne - -```java -public long countProjects() { - return tx.inTransaction(h -> { - h.setSearchPath("sqlcode"); - return h.getQueryRunner().queryOne( - "SELECT COUNT(*) FROM projects", - rs -> rs.getLong(1) - ); - }); -} -``` - -## Example 5: Domain mapping - -```java -record Project(long id, String name) {} - -public List load() { - return tx.inTransaction(h -> { - h.setSearchPath("sqlcode"); - return h.getQueryRunner().query( - "SELECT project_id, name FROM projects", - rs -> new Project( - rs.getLong("project_id"), - rs.getString("name") - ) - ); - }); -} -``` - -## Example 6: Explicit rollback - -```java -public void failingOp() { - tx.inTransaction(h -> { - h.getQueryRunner().update("DELETE FROM projects"); - throw new IllegalStateException("boom"); - }); -} -``` - -## Example 7: Plugin migration descriptor - -```java -public final class SqlcodeMigration implements PluginMigrationDescriptor { - - @Override - public String getPluginName() { - return "sqlcode"; - } - - @Override - public String getSchemaName() { - return "sqlcode"; - } - - @Override - public List getMigrations() { - return List.of(new ScriptMigration() { - @Override - public int getVersion() { - return 1; - } - - @Override - public String getDescription() { - return "initial schema"; - } - - @Override - public URL getScriptUrl() { - return getClass().getResource("/migrations/sqlcode/V1__init.sql"); - } - }); - } -} -``` - -## Example 8: Run migration - -```java -@Component(immediate = true) -public class MigrationRunner { - - @Reference - void run(MigrationService ms) { - ms.migratePlugin(new SqlcodeMigration()); - } -} -``` - -## Example 9: Service layer on top of repository - -```java -@Component -public class ProjectService { - - @Reference - ProjectRepository repo; - - public boolean hasProjects() { - return !repo.findAllNames().isEmpty(); - } -} -``` - -## Example 10: Pure utility using TransactionCallback - -```java -public T withSqlcode(TransactionManager tx, TransactionCallback cb) { - return tx.inTransaction(h -> { - h.setSearchPath("sqlcode"); - return cb.doInTransaction(h); - }); -} -``` +See the [usage guide](../docs/usage-postgresql.md) for transactions, plugin migration descriptors, readiness, and script migrations. diff --git a/de.drremote.db.sqlite.features/pom.xml b/de.drremote.db.sqlite.features/pom.xml index 3e10c02..b66b85e 100644 --- a/de.drremote.db.sqlite.features/pom.xml +++ b/de.drremote.db.sqlite.features/pom.xml @@ -1,37 +1,25 @@ - - 4.0.0 - - - de.drremote - db-parent - 1.0.0 - ../pom.xml - - - de.drremote.db - sqlite-features - feature - - Db SQLite Karaf Features - - - 4.4.10 - UTF-8 - - - - - - org.apache.karaf.tooling - karaf-maven-plugin - ${karaf.version} - true - - - - - \ No newline at end of file + de.drremotedb-parent1.1.0-SNAPSHOT + de.drremote.dbsqlite-featuresfeature + DrRemote DB sqlite Karaf features + + de.drremote.dbsqlite${project.version} + + + org.apache.karaf.toolingkaraf-maven-plugintrue + + true + de.drremote-db-sqlite + false + file:${project.build.directory}/feature/feature.xml + de.drremote-db-sqlite + framework + ${karaf.version} + ${maven.compiler.release} + + + + diff --git a/de.drremote.db.sqlite.features/src/main/feature/feature.xml b/de.drremote.db.sqlite.features/src/main/feature/feature.xml index 0c158da..608d385 100644 --- a/de.drremote.db.sqlite.features/src/main/feature/feature.xml +++ b/de.drremote.db.sqlite.features/src/main/feature/feature.xml @@ -1,13 +1,11 @@ - - - -
DrRemote SQLite core for Karaf
- - wrap:mvn:org.xerial/sqlite-jdbc/3.50.3.0 - wrap:mvn:com.zaxxer/HikariCP/7.0.2 - mvn:de.drremote.db/sqlite/1.0.0 + + mvn:org.apache.karaf.features/standard/${karaf.version}/xml/features + +
DrRemote sqlite database services
+ scr + mvn:org.xerial/sqlite-jdbc/${sqlite.version} + mvn:com.zaxxer/HikariCP/${hikari.version} + mvn:de.drremote.db/sqlite/${project.version}
- -
\ No newline at end of file +
diff --git a/de.drremote.db.sqlite/README.md b/de.drremote.db.sqlite/README.md index 7ab125d..d463d55 100644 --- a/de.drremote.db.sqlite/README.md +++ b/de.drremote.db.sqlite/README.md @@ -1,90 +1,14 @@ -# de.drremote.db.sqlite +# DrRemote DB — SQLite -`de.drremote.db.sqlite` is an OSGi/Karaf bundle that provides SQLite access, transaction handling, and lightweight plugin migrations for modular applications. +OSGi database services for SQLite: pooled JDBC access, transactions, queries, and plugin migrations. -It is intended for smaller or embedded deployments where a local file-based database is preferred over PostgreSQL. +- Public artifact: `de.drremote.db:sqlite:1.1.0-SNAPSHOT` +- API package / bundle symbolic name: `de.drremote.db.sqlite.api` / `de.drremote.db.sqlite` +- ConfigAdmin PID: `de.drremote.db.sqlite` +- Java 17+; supplied Karaf feature targets 4.4.11. -## Features +Build from the repository root with `mvn clean install`. Project artifacts must currently be built locally; there is no hosted public DrRemote Maven repository. -- OSGi Declarative Services (DS) -- SQLite access via JDBC -- HikariCP-backed `DataSource` -- Transaction handling through `TransactionManager` -- Lightweight query API via `QueryRunner` -- Plugin migrations tracked in `core_schema_migrations` -- Configuration through Karaf `ConfigAdmin` +See the [main README](../README.md), [configuration](../docs/configuration-sqlite.md), [usage](../docs/usage-sqlite.md), and [installation guide](../docs/installation.md). -## Requirements - -- Java 21 -- Apache Karaf or another OSGi runtime -- SQLite JDBC driver available in the runtime -- HikariCP available in the runtime - -## Build - -```sh -mvn clean install -``` - -## Configuration - -Configuration PID: - -```text -de.drremote.db.sqlite -``` - -Recommended Karaf config file: - -```text -$KARAF_HOME/etc/de.drremote.db.sqlite.cfg -``` - -Example: - -```properties -jdbcUrl=jdbc:sqlite:${karaf.data}/drremote.db -poolName=db-sqlite-pool -maxPoolSize=1 -minIdle=1 -busyTimeoutMs=5000 -foreignKeys=true -``` - -## Notes - -SQLite does not provide PostgreSQL-style schemas or `search_path`. - -For plugin isolation, use clear table prefixes such as: - -* `sqlcode_projects` -* `iam_users` -* `shell_commands` - -Migration state is stored in: - -```text -core_schema_migrations -``` - -For SQLite, keep one migration limited to one SQL statement unless a dedicated script runner is introduced. - -## Artifacts - -* Bundle: `de.drremote.db:sqlite` -* Feature: `de.drremote.db:sqlite-features` -* Karaf feature name: `de.drremote-db-sqlite` - -## Using In Karaf - -```text -feature:repo-add mvn:de.drremote.db/sqlite-features/1.0.0-SNAPSHOT/xml/features -feature:install de.drremote-db-sqlite -``` - -Use the matching release version only after a non-snapshot artifact has actually been published. - -## License - -Licensed under Apache-2.0. See `LICENSE`. +Licensed under [Apache-2.0](../LICENSE). diff --git a/de.drremote.db.sqlite/pom.xml b/de.drremote.db.sqlite/pom.xml index 6335088..3521231 100644 --- a/de.drremote.db.sqlite/pom.xml +++ b/de.drremote.db.sqlite/pom.xml @@ -1,137 +1,26 @@ - - 4.0.0 - - - de.drremote - db-parent - 1.0.0 - ../pom.xml - - + de.drremotedb-parent1.1.0-SNAPSHOT de.drremote.db sqlite bundle - - Db SQLite - - - 21 - UTF-8 - - ${project.version} - - 3.13.0 - 5.1.9 - 3.3.1 - 3.10.1 - - 1.5.1 - 7.0.2 - 3.50.3.0 - - + DrRemote DB sqlite - - org.osgi - org.osgi.service.component.annotations - ${osgi.ds.version} - provided - - - - com.zaxxer - HikariCP - ${hikari.version} - provided - - - - org.xerial - sqlite-jdbc - ${sqlite.version} - provided - + org.osgiosgi.coreprovided + org.osgiorg.osgi.service.component.annotationsprovided + com.zaxxerHikariCPprovided + org.xerialsqlite-jdbcprovided + org.junit.jupiterjunit-jupitertest - - - - - org.apache.maven.plugins - maven-compiler-plugin - ${maven.compiler.plugin.version} - - ${java.version} - ${project.build.sourceEncoding} - - - - - org.apache.felix - maven-bundle-plugin - ${maven.bundle.plugin.version} - true - - - ${project.groupId}.${project.artifactId} - ${project.name} - ${project.version} - JavaSE-21 - - - de.drremote.db.sqlite.api;version="${api.version}" - - - - !org.osgi.service.component.annotations, - javax.sql, - java.sql, - org.sqlite.*, - com.zaxxer.hikari, - * - - - <_dsannotations>* - - - - - - org.apache.maven.plugins - maven-source-plugin - ${maven.source.plugin.version} - - - attach-sources - - jar-no-fork - - - - - - - org.apache.maven.plugins - maven-javadoc-plugin - ${maven.javadoc.plugin.version} - - ${java.version} - ${project.build.sourceEncoding} - none - - - - attach-javadocs - - jar - - - - - - - - \ No newline at end of file + + org.apache.felixmaven-bundle-plugintrue + + osgi.service;objectClass:List<String>="de.drremote.db.sqlite.api.DatabaseReady" + + + org.apache.maven.pluginsmaven-source-plugin + org.apache.maven.pluginsmaven-javadoc-plugin + +
diff --git a/de.drremote.db.sqlite/src/main/java/de/drremote/db/sqlite/api/BatchMigration.java b/de.drremote.db.sqlite/src/main/java/de/drremote/db/sqlite/api/BatchMigration.java new file mode 100644 index 0000000..6331433 --- /dev/null +++ b/de.drremote.db.sqlite/src/main/java/de/drremote/db/sqlite/api/BatchMigration.java @@ -0,0 +1,17 @@ +package de.drremote.db.sqlite.api; + +import java.util.List; + +/** + * A single versioned migration made up of multiple explicitly separated SQL statements. + * All statements are executed in one transaction and recorded as one migration version. + */ +public interface BatchMigration extends Migration { + + List getStatements(); + + @Override + default String getSql() { + return String.join(";\n", getStatements()); + } +} diff --git a/de.drremote.db.sqlite/src/main/java/de/drremote/db/sqlite/api/DatabaseReady.java b/de.drremote.db.sqlite/src/main/java/de/drremote/db/sqlite/api/DatabaseReady.java new file mode 100644 index 0000000..610ff26 --- /dev/null +++ b/de.drremote.db.sqlite/src/main/java/de/drremote/db/sqlite/api/DatabaseReady.java @@ -0,0 +1,17 @@ +package de.drremote.db.sqlite.api; + +/** + * Marker service published after all migrations of a SQLite plugin succeeded. + */ +public interface DatabaseReady { + + String PROPERTY_KIND = "drremote.db.kind"; + String PROPERTY_PLUGIN = "drremote.db.plugin"; + String PROPERTY_SCHEMA = "drremote.db.schema"; + + String getDatabaseKind(); + + String getPluginName(); + + String getSchemaName(); +} diff --git a/de.drremote.db.sqlite/src/main/java/de/drremote/db/sqlite/api/MigrationBaseline.java b/de.drremote.db.sqlite/src/main/java/de/drremote/db/sqlite/api/MigrationBaseline.java new file mode 100644 index 0000000..e1c6b23 --- /dev/null +++ b/de.drremote.db.sqlite/src/main/java/de/drremote/db/sqlite/api/MigrationBaseline.java @@ -0,0 +1,16 @@ +package de.drremote.db.sqlite.api; + +import java.util.List; + +/** + * Current schema snapshot used only when a plugin has no migration history yet. + * The version identifies the latest incremental migration represented by the snapshot. + */ +public interface MigrationBaseline { + + int getVersion(); + + String getDescription(); + + List getStatements(); +} diff --git a/de.drremote.db.sqlite/src/main/java/de/drremote/db/sqlite/api/PluginMigrationDescriptor.java b/de.drremote.db.sqlite/src/main/java/de/drremote/db/sqlite/api/PluginMigrationDescriptor.java index 111ea32..cdec15e 100644 --- a/de.drremote.db.sqlite/src/main/java/de/drremote/db/sqlite/api/PluginMigrationDescriptor.java +++ b/de.drremote.db.sqlite/src/main/java/de/drremote/db/sqlite/api/PluginMigrationDescriptor.java @@ -7,4 +7,12 @@ public interface PluginMigrationDescriptor { String getPluginName(); List getMigrations(); -} \ No newline at end of file + + /** + * Optional current-schema snapshot for databases without any history for this plugin. + * Existing installations always continue with {@link #getMigrations()}. + */ + default MigrationBaseline getBaseline() { + return null; + } +} diff --git a/de.drremote.db.sqlite/src/main/java/de/drremote/db/sqlite/api/TransactionManager.java b/de.drremote.db.sqlite/src/main/java/de/drremote/db/sqlite/api/TransactionManager.java index 2e27abb..6a31e98 100644 --- a/de.drremote.db.sqlite/src/main/java/de/drremote/db/sqlite/api/TransactionManager.java +++ b/de.drremote.db.sqlite/src/main/java/de/drremote/db/sqlite/api/TransactionManager.java @@ -1,5 +1,12 @@ package de.drremote.db.sqlite.api; public interface TransactionManager { + + /** + * Runs work atomically. Nested calls on this manager and thread join the outer + * transaction; only the outer call commits or rolls back. A nested failure marks + * the entire transaction rollback-only, even if the caller catches it. + * Transactions do not propagate to other threads or manager instances. + */ T inTransaction(TransactionCallback callback) throws DbCoreException; -} \ No newline at end of file +} diff --git a/de.drremote.db.sqlite/src/main/java/de/drremote/db/sqlite/impl/DbCoreComponent.java b/de.drremote.db.sqlite/src/main/java/de/drremote/db/sqlite/impl/DbCoreComponent.java index 84c8a67..0474baa 100644 --- a/de.drremote.db.sqlite/src/main/java/de/drremote/db/sqlite/impl/DbCoreComponent.java +++ b/de.drremote.db.sqlite/src/main/java/de/drremote/db/sqlite/impl/DbCoreComponent.java @@ -1,8 +1,12 @@ package de.drremote.db.sqlite.impl; import de.drremote.db.sqlite.api.DbCore; +import de.drremote.db.sqlite.api.TransactionManager; import org.osgi.service.component.annotations.*; +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.Statement; import java.util.logging.Logger; @Component( @@ -13,10 +17,34 @@ public final class DbCoreComponent implements DbCore { private static final Logger LOG = Logger.getLogger(DbCoreComponent.class.getName()); + private DataSource dataSource; + private TransactionManager transactionManager; + private volatile boolean ready = false; + @Reference(target = "(drremote.db.kind=sqlite)") + void bindDataSource(DataSource dataSource) { + this.dataSource = dataSource; + } + + @Reference + void bindTransactionManager(TransactionManager transactionManager) { + this.transactionManager = transactionManager; + } + @Activate void activate() { + if (dataSource == null || transactionManager == null) { + throw new IllegalStateException("DbCore requires DataSource and TransactionManager"); + } + + try (Connection conn = dataSource.getConnection(); + Statement st = conn.createStatement()) { + st.execute("SELECT 1"); + } catch (Exception e) { + throw new IllegalStateException("Database connectivity check failed", e); + } + ready = true; LOG.info("Core activated"); } @@ -31,4 +59,4 @@ void deactivate() { public boolean isReady() { return ready; } -} \ No newline at end of file +} diff --git a/de.drremote.db.sqlite/src/main/java/de/drremote/db/sqlite/impl/jdbc/HikariDataSourceComponent.java b/de.drremote.db.sqlite/src/main/java/de/drremote/db/sqlite/impl/jdbc/HikariDataSourceComponent.java index 63c43c6..657c228 100644 --- a/de.drremote.db.sqlite/src/main/java/de/drremote/db/sqlite/impl/jdbc/HikariDataSourceComponent.java +++ b/de.drremote.db.sqlite/src/main/java/de/drremote/db/sqlite/impl/jdbc/HikariDataSourceComponent.java @@ -15,7 +15,8 @@ @Component( service = DataSource.class, immediate = true, - configurationPid = "de.drremote.db.sqlite" + configurationPid = "de.drremote.db.sqlite", + property = "drremote.db.kind=sqlite" ) public final class HikariDataSourceComponent implements DataSource { @@ -68,6 +69,7 @@ private synchronized void reconfigure(Map props) { HikariConfig hc = new HikariConfig(); hc.setJdbcUrl(jdbcUrl); + hc.setDriverClassName("org.sqlite.JDBC"); hc.setMaximumPoolSize(maxPoolSize); hc.setMinimumIdle(minIdle); hc.setPoolName(poolName); @@ -75,16 +77,22 @@ private synchronized void reconfigure(Map props) { hc.addDataSourceProperty("busy_timeout", busyTimeoutMs); hc.addDataSourceProperty("foreign_keys", foreignKeys); - closeDelegate(); - delegate = new HikariDataSource(hc); + HikariDataSource newDelegate = new HikariDataSource(hc); + HikariDataSource oldDelegate = delegate; + delegate = newDelegate; + closeQuietly(oldDelegate); } - private void closeDelegate() { + private synchronized void closeDelegate() { HikariDataSource ds = delegate; + delegate = null; + closeQuietly(ds); + } + + private static void closeQuietly(HikariDataSource ds) { if (ds != null) { try { ds.close(); } catch (Exception ignore) {} } - delegate = null; } private static String getString(Map props, String key, String def) { diff --git a/de.drremote.db.sqlite/src/main/java/de/drremote/db/sqlite/impl/jdbc/JdbcDbHandle.java b/de.drremote.db.sqlite/src/main/java/de/drremote/db/sqlite/impl/jdbc/JdbcDbHandle.java index 33f9f70..547debf 100644 --- a/de.drremote.db.sqlite/src/main/java/de/drremote/db/sqlite/impl/jdbc/JdbcDbHandle.java +++ b/de.drremote.db.sqlite/src/main/java/de/drremote/db/sqlite/impl/jdbc/JdbcDbHandle.java @@ -1,11 +1,9 @@ package de.drremote.db.sqlite.impl.jdbc; -import de.drremote.db.sqlite.api.DbCoreException; import de.drremote.db.sqlite.api.DbHandle; import de.drremote.db.sqlite.api.QueryRunner; import java.sql.Connection; -import java.sql.SQLException; public final class JdbcDbHandle implements DbHandle { diff --git a/de.drremote.db.sqlite/src/main/java/de/drremote/db/sqlite/impl/jdbc/JdbcTransactionManager.java b/de.drremote.db.sqlite/src/main/java/de/drremote/db/sqlite/impl/jdbc/JdbcTransactionManager.java index 0a42382..03002e9 100644 --- a/de.drremote.db.sqlite/src/main/java/de/drremote/db/sqlite/impl/jdbc/JdbcTransactionManager.java +++ b/de.drremote.db.sqlite/src/main/java/de/drremote/db/sqlite/impl/jdbc/JdbcTransactionManager.java @@ -6,17 +6,18 @@ import org.osgi.service.component.annotations.*; import javax.sql.DataSource; -import java.sql.Connection; +import java.sql.*; +import java.util.Objects; import java.util.logging.Logger; @Component(service = TransactionManager.class) public final class JdbcTransactionManager implements TransactionManager { private static final Logger LOG = Logger.getLogger(JdbcTransactionManager.class.getName()); - + private final ThreadLocal current = new ThreadLocal<>(); private DataSource dataSource; - @Reference + @Reference(target = "(drremote.db.kind=sqlite)") void bindDataSource(DataSource dataSource) { this.dataSource = dataSource; LOG.info("DataSource bound"); @@ -29,20 +30,55 @@ void activate() { @Override public T inTransaction(TransactionCallback callback) throws DbCoreException { + Objects.requireNonNull(callback, "callback"); + TransactionState existing = current.get(); + if (existing != null) return join(existing, callback); + try (Connection conn = dataSource.getConnection()) { conn.setAutoCommit(false); - - JdbcDbHandle handle = new JdbcDbHandle(conn); + TransactionState state = new TransactionState(new JdbcDbHandle(conn)); + current.set(state); try { - T result = callback.doInTransaction(handle); + T result = callback.doInTransaction(state.handle); + if (state.failure != null) { + throw new DbCoreException("Transaction marked rollback-only by nested failure", state.failure); + } conn.commit(); return result; - } catch (Exception e) { - conn.rollback(); - throw e; + } catch (Exception | Error failure) { + try { + conn.rollback(); + } catch (SQLException rollbackFailure) { + failure.addSuppressed(rollbackFailure); + } + throw failure; + } finally { + current.remove(); } - } catch (Exception e) { - throw new DbCoreException("Transaction failed", e); + } catch (Exception failure) { + throw new DbCoreException("Transaction failed", failure); + } + } + + private T join(TransactionState state, TransactionCallback callback) { + if (state.failure != null) { + throw new DbCoreException("Transaction is already rollback-only", state.failure); + } + try { + return callback.doInTransaction(state.handle); + } catch (Exception | Error failure) { + if (state.failure == null) state.failure = failure; + if (failure instanceof Error error) throw error; + throw new DbCoreException("Nested transaction failed", failure); + } + } + + private static final class TransactionState { + private final JdbcDbHandle handle; + private Throwable failure; + + private TransactionState(JdbcDbHandle handle) { + this.handle = handle; } } -} \ No newline at end of file +} diff --git a/de.drremote.db.sqlite/src/main/java/de/drremote/db/sqlite/impl/migration/MigrationRegistry.java b/de.drremote.db.sqlite/src/main/java/de/drremote/db/sqlite/impl/migration/MigrationRegistry.java new file mode 100644 index 0000000..61300ce --- /dev/null +++ b/de.drremote.db.sqlite/src/main/java/de/drremote/db/sqlite/impl/migration/MigrationRegistry.java @@ -0,0 +1,115 @@ +package de.drremote.db.sqlite.impl.migration; + +import de.drremote.db.sqlite.api.DatabaseReady; +import de.drremote.db.sqlite.api.MigrationService; +import de.drremote.db.sqlite.api.PluginMigrationDescriptor; +import org.osgi.framework.BundleContext; +import org.osgi.framework.ServiceRegistration; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Deactivate; +import org.osgi.service.component.annotations.Reference; +import org.osgi.service.component.annotations.ReferenceCardinality; +import org.osgi.service.component.annotations.ReferencePolicy; + +import java.util.ArrayList; +import java.util.Dictionary; +import java.util.Hashtable; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; + +@Component(immediate = true) +public final class MigrationRegistry { + + private final List pending = new ArrayList<>(); + private final Map> registrations = + new IdentityHashMap<>(); + + private volatile MigrationService migrationService; + private BundleContext context; + + @Reference + void bindMigrationService(MigrationService migrationService) { + this.migrationService = migrationService; + } + + @Reference( + service = PluginMigrationDescriptor.class, + cardinality = ReferenceCardinality.MULTIPLE, + policy = ReferencePolicy.DYNAMIC + ) + synchronized void addMigrationDescriptor(PluginMigrationDescriptor descriptor) { + if (context == null) { + pending.add(descriptor); + return; + } + migrateAndPublish(descriptor); + } + + synchronized void removeMigrationDescriptor(PluginMigrationDescriptor descriptor) { + pending.removeIf(candidate -> candidate == descriptor); + ServiceRegistration registration = registrations.remove(descriptor); + if (registration != null) { + registration.unregister(); + } + } + + @Activate + synchronized void activate(BundleContext context) { + this.context = context; + List descriptors = List.copyOf(pending); + pending.clear(); + descriptors.forEach(this::migrateAndPublish); + } + + @Deactivate + synchronized void deactivate() { + registrations.values().forEach(ServiceRegistration::unregister); + registrations.clear(); + context = null; + } + + private void migrateAndPublish(PluginMigrationDescriptor descriptor) { + if (registrations.containsKey(descriptor)) { + return; + } + + String pluginName = requireText(descriptor.getPluginName(), "plugin name"); + migrationService.migratePlugin(descriptor); + + DatabaseReady ready = new ReadyService(pluginName); + Dictionary properties = new Hashtable<>(); + properties.put(DatabaseReady.PROPERTY_KIND, ready.getDatabaseKind()); + properties.put(DatabaseReady.PROPERTY_PLUGIN, ready.getPluginName()); + properties.put(DatabaseReady.PROPERTY_SCHEMA, ready.getSchemaName()); + registrations.put( + descriptor, + context.registerService(DatabaseReady.class, ready, properties) + ); + } + + private static String requireText(String value, String label) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("Migration descriptor " + label + " must not be blank"); + } + return value.trim(); + } + + private record ReadyService(String pluginName) implements DatabaseReady { + @Override + public String getDatabaseKind() { + return "sqlite"; + } + + @Override + public String getPluginName() { + return pluginName; + } + + @Override + public String getSchemaName() { + return "main"; + } + } +} diff --git a/de.drremote.db.sqlite/src/main/java/de/drremote/db/sqlite/impl/migration/MigrationServiceImpl.java b/de.drremote.db.sqlite/src/main/java/de/drremote/db/sqlite/impl/migration/MigrationServiceImpl.java index 990989a..0c09d28 100644 --- a/de.drremote.db.sqlite/src/main/java/de/drremote/db/sqlite/impl/migration/MigrationServiceImpl.java +++ b/de.drremote.db.sqlite/src/main/java/de/drremote/db/sqlite/impl/migration/MigrationServiceImpl.java @@ -10,7 +10,9 @@ import java.sql.Statement; import java.util.ArrayList; import java.util.Comparator; +import java.util.HashSet; import java.util.List; +import java.util.Set; import java.util.logging.Level; import java.util.logging.Logger; @@ -32,7 +34,14 @@ void bindTransactionManager(TransactionManager tm) { @Override public void migratePlugin(PluginMigrationDescriptor descriptor) throws DbCoreException { - applyMigrations(descriptor.getPluginName(), descriptor.getMigrations()); + if (descriptor == null) { + throw new IllegalArgumentException("Migration descriptor must not be null"); + } + applyMigrations( + descriptor.getPluginName(), + descriptor.getMigrations(), + descriptor.getBaseline() + ); } private void ensureCoreSchema() { @@ -53,16 +62,36 @@ applied_at TEXT NOT NULL DEFAULT (datetime('now')), PRIMARY KEY (plugin_name, version) ) """); + st.execute(""" + CREATE TABLE IF NOT EXISTS core_schema_baselines ( + plugin_name TEXT PRIMARY KEY, + version INTEGER NOT NULL, + description TEXT NOT NULL, + sql TEXT NOT NULL, + applied_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + """); } return null; }); } - private void applyMigrations(String pluginName, List migrations) { + private void applyMigrations( + String pluginName, + List migrations, + MigrationBaseline proposedBaseline + ) { LOG.log(Level.FINE, () -> "applyMigrations plugin=" + pluginName + " migrations=" + (migrations == null ? "null" : migrations.size())); + if (pluginName == null || pluginName.isBlank()) { + throw new IllegalArgumentException("Plugin name must not be blank"); + } + if (migrations == null || migrations.isEmpty()) { + if (proposedBaseline != null) { + throw new IllegalArgumentException("A baseline requires incremental migrations"); + } LOG.fine("no migrations -> return"); return; } @@ -74,40 +103,34 @@ private void applyMigrations(String pluginName, List migrations) { List sorted = new ArrayList<>(migrations); sorted.sort(Comparator.comparingInt(Migration::getVersion)); + validateVersions(sorted); + validateBaseline(proposedBaseline, sorted); + + Integer baselineVersion = prepareBaseline(pluginName, proposedBaseline); for (Migration m : sorted) { + if (baselineVersion != null && m.getVersion() <= baselineVersion) { + int skippedVersion = m.getVersion(); + LOG.log(Level.FINE, () -> "skipping v" + skippedVersion + " covered by baseline v" + baselineVersion); + continue; + } + tm.inTransaction(handle -> { Connection conn = handle.getConnection(); - boolean already = isAlreadyApplied(conn, pluginName, m.getVersion()); - if (already) { + AppliedMigration applied = findAppliedMigration(conn, pluginName, m.getVersion()); + if (applied.exists()) { + verifyAppliedSql(pluginName, m, applied.sql()); LOG.log(Level.FINE, () -> "skipping v" + m.getVersion()); return null; } - String sqlText = m.getSql(); - if (sqlText == null || sqlText.isBlank()) { - throw new IllegalStateException("Migration SQL is empty for v" + m.getVersion()); - } - - String normalizedSql = sqlText.trim(); - if (normalizedSql.endsWith(";")) { - normalizedSql = normalizedSql.substring(0, normalizedSql.length() - 1).trim(); - } - if (normalizedSql.isEmpty()) { - throw new IllegalStateException("Migration SQL is empty for v" + m.getVersion()); - } - if (normalizedSql.contains(";")) { - throw new IllegalStateException( - "Migration SQL must contain exactly one statement for v" + m.getVersion() - ); - } + List statements = migrationStatements(m); + String normalizedSql = String.join(";\n", statements); LOG.log(Level.FINE, () -> "will apply v" + m.getVersion()); - try (Statement st = conn.createStatement()) { - st.execute(normalizedSql); - } + executeStatements(conn, statements); insertMigrationRow(conn, pluginName, m, normalizedSql); return null; @@ -115,14 +138,184 @@ private void applyMigrations(String pluginName, List migrations) { } } - private boolean isAlreadyApplied(Connection conn, String pluginName, int version) throws SQLException { + private Integer prepareBaseline(String pluginName, MigrationBaseline proposedBaseline) { + TransactionManager tm = this.tx; + return tm.inTransaction(handle -> { + Connection conn = handle.getConnection(); + Integer appliedVersion = findAppliedBaselineVersion(conn, pluginName); + if (appliedVersion != null) { + return appliedVersion; + } + if (proposedBaseline == null || hasAppliedMigrations(conn, pluginName)) { + return null; + } + + List statements = explicitStatements( + proposedBaseline.getStatements(), + "baseline v" + proposedBaseline.getVersion() + ); + executeStatements(conn, statements); + insertBaselineRow(conn, pluginName, proposedBaseline, String.join(";\n", statements)); + LOG.log(Level.INFO, () -> "applied baseline v" + proposedBaseline.getVersion() + + " for plugin " + pluginName); + return proposedBaseline.getVersion(); + }); + } + + private static void validateVersions(List migrations) { + Set versions = new HashSet<>(); + for (Migration migration : migrations) { + if (migration == null) { + throw new IllegalArgumentException("Migration list must not contain null entries"); + } + int version = migration.getVersion(); + if (version <= 0) { + throw new IllegalArgumentException("Migration version must be positive: " + version); + } + if (!versions.add(version)) { + throw new IllegalArgumentException("Duplicate migration version: " + version); + } + } + } + + private static void validateBaseline(MigrationBaseline baseline, List migrations) { + if (baseline == null) { + return; + } + int version = baseline.getVersion(); + boolean represented = migrations.stream().anyMatch(migration -> migration.getVersion() == version); + if (version <= 0 || !represented) { + throw new IllegalArgumentException( + "Baseline version must match an incremental migration version: " + version + ); + } + if (baseline.getDescription() == null || baseline.getDescription().isBlank()) { + throw new IllegalArgumentException("Baseline description must not be blank"); + } + explicitStatements(baseline.getStatements(), "baseline v" + version); + } + + private static List migrationStatements(Migration migration) { + if (migration instanceof BatchMigration batchMigration) { + return explicitStatements( + batchMigration.getStatements(), + "migration v" + migration.getVersion() + ); + } + return List.of(singleStatement(migration.getSql(), migration.getVersion())); + } + + private static List explicitStatements(List sqlStatements, String label) { + if (sqlStatements == null || sqlStatements.isEmpty()) { + throw new IllegalStateException("SQL statement list is empty for " + label); + } + List normalized = new ArrayList<>(sqlStatements.size()); + for (String sql : sqlStatements) { + String statement = trimTrailingSemicolon(sql); + if (statement.isEmpty()) { + throw new IllegalStateException("SQL statement is empty for " + label); + } + normalized.add(statement); + } + return List.copyOf(normalized); + } + + private static String singleStatement(String sqlText, int version) { + String normalizedSql = trimTrailingSemicolon(sqlText); + if (normalizedSql.isEmpty()) { + throw new IllegalStateException("Migration SQL is empty for v" + version); + } + if (normalizedSql.contains(";")) { + throw new IllegalStateException( + "Migration SQL must contain exactly one statement for v" + version + ); + } + return normalizedSql; + } + + private static String trimTrailingSemicolon(String sqlText) { + if (sqlText == null || sqlText.isBlank()) { + return ""; + } + String normalizedSql = sqlText.trim(); + if (normalizedSql.endsWith(";")) { + normalizedSql = normalizedSql.substring(0, normalizedSql.length() - 1).trim(); + } + return normalizedSql; + } + + private static void executeStatements(Connection conn, List statements) throws SQLException { + try (Statement st = conn.createStatement()) { + for (String sql : statements) { + st.execute(sql); + } + } + } + + private AppliedMigration findAppliedMigration( + Connection conn, + String pluginName, + int version + ) throws SQLException { try (PreparedStatement ps = conn.prepareStatement(""" - SELECT 1 + SELECT sql FROM core_schema_migrations WHERE plugin_name = ? AND version = ? """)) { ps.setString(1, pluginName); ps.setInt(2, version); + try (ResultSet rs = ps.executeQuery()) { + return rs.next() + ? new AppliedMigration(true, rs.getString("sql")) + : new AppliedMigration(false, null); + } + } + } + + private void verifyAppliedSql(String pluginName, Migration migration, String appliedSql) { + if (appliedSql == null || appliedSql.isBlank()) { + LOG.log(Level.WARNING, () -> "cannot verify migration drift for " + pluginName + + " v" + migration.getVersion() + ": stored SQL is empty"); + return; + } + String expectedSql = String.join(";\n", migrationStatements(migration)); + if (!comparableSql(appliedSql).equals(comparableSql(expectedSql))) { + throw new IllegalStateException( + "Migration drift detected for " + pluginName + " v" + migration.getVersion() + + "; restore the originally released SQL and add a new migration instead" + ); + } + } + + private static String comparableSql(String sql) { + String normalized = sql.replace("\r\n", "\n").replace('\r', '\n').trim(); + if (normalized.endsWith(";")) { + normalized = normalized.substring(0, normalized.length() - 1).trim(); + } + return normalized; + } + + private Integer findAppliedBaselineVersion(Connection conn, String pluginName) throws SQLException { + try (PreparedStatement ps = conn.prepareStatement(""" + SELECT version + FROM core_schema_baselines + WHERE plugin_name = ? + """)) { + ps.setString(1, pluginName); + try (ResultSet rs = ps.executeQuery()) { + return rs.next() ? rs.getInt("version") : null; + } + } + } + + private boolean hasAppliedMigrations(Connection conn, String pluginName) throws SQLException { + try (PreparedStatement ps = conn.prepareStatement(""" + SELECT 1 + FROM core_schema_migrations + WHERE plugin_name = ? + LIMIT 1 + """)) { + ps.setString(1, pluginName); try (ResultSet rs = ps.executeQuery()) { return rs.next(); } @@ -147,4 +340,26 @@ private void insertMigrationRow( ps.executeUpdate(); } } + + private void insertBaselineRow( + Connection conn, + String pluginName, + MigrationBaseline baseline, + String sqlText + ) throws SQLException { + try (PreparedStatement ps = conn.prepareStatement(""" + INSERT INTO core_schema_baselines + (plugin_name, version, description, sql) + VALUES (?,?,?,?) + """)) { + ps.setString(1, pluginName); + ps.setInt(2, baseline.getVersion()); + ps.setString(3, baseline.getDescription()); + ps.setString(4, sqlText); + ps.executeUpdate(); + } + } + + private record AppliedMigration(boolean exists, String sql) { + } } diff --git a/de.drremote.db.sqlite/src/test/java/de/drremote/db/sqlite/impl/jdbc/JdbcTransactionManagerTest.java b/de.drremote.db.sqlite/src/test/java/de/drremote/db/sqlite/impl/jdbc/JdbcTransactionManagerTest.java new file mode 100644 index 0000000..fe89588 --- /dev/null +++ b/de.drremote.db.sqlite/src/test/java/de/drremote/db/sqlite/impl/jdbc/JdbcTransactionManagerTest.java @@ -0,0 +1,153 @@ +package de.drremote.db.sqlite.impl.jdbc; + +import de.drremote.db.sqlite.api.DbCoreException; +import org.junit.jupiter.api.Test; +import javax.sql.DataSource; +import java.lang.reflect.Proxy; +import java.sql.*; +import java.util.List; +import java.util.concurrent.*; +import static org.junit.jupiter.api.Assertions.*; + +class JdbcTransactionManagerTest { + private final List connections = new java.util.concurrent.CopyOnWriteArrayList<>(); + private final JdbcTransactionManager tx = manager(); + + @Test void nestedCallsUseOneConnectionAndCommitOnce() { + assertEquals("done", tx.inTransaction(outer -> { + tx.inTransaction(inner -> { + assertSame(outer.getConnection(), inner.getConnection()); + return null; + }); + assertEquals(0, connections.get(0).commits); + assertEquals(0, connections.get(0).closes); + return "done"; + })); + assertEquals(1, connections.size()); + assertEquals(1, connections.get(0).commits); + assertEquals(0, connections.get(0).rollbacks); + assertEquals(1, connections.get(0).closes); + } + + @Test void caughtInnerFailureRollsBackOuterAndCleansThreadState() { + assertThrows(DbCoreException.class, () -> tx.inTransaction(outer -> { + assertThrows(DbCoreException.class, () -> tx.inTransaction(inner -> { + throw new SQLException("failed write"); + })); + return null; + })); + assertEquals(0, connections.get(0).commits); + assertEquals(1, connections.get(0).rollbacks); + tx.inTransaction(handle -> null); + assertEquals(2, connections.size()); + assertEquals(1, connections.get(1).commits); + } + + @Test void errorAlsoRollsBackAndCleansThreadState() { + AssertionError failure = new AssertionError("stop"); + assertSame(failure, assertThrows(AssertionError.class, () -> tx.inTransaction(outer -> + tx.inTransaction(inner -> { throw failure; })))); + assertEquals(1, connections.get(0).rollbacks); + assertEquals(1, connections.get(0).closes); + tx.inTransaction(handle -> null); + assertEquals(2, connections.size()); + } + + @Test void rollbackFailureDoesNotHideOriginalFailure() { + SQLException original = new SQLException("write failed"); + DbCoreException failure = assertThrows(DbCoreException.class, () -> tx.inTransaction(handle -> { + connections.get(0).failRollback = true; + throw original; + })); + assertSame(original, failure.getCause()); + assertEquals("rollback failed", original.getSuppressed()[0].getMessage()); + assertEquals(1, connections.get(0).closes); + } + + @Test void commitFailureRollsBackAndReleasesConnection() { + assertThrows(DbCoreException.class, () -> tx.inTransaction(handle -> { + connections.get(0).failCommit = true; + return null; + })); + assertEquals(1, connections.get(0).rollbacks); + assertEquals(1, connections.get(0).closes); + } + + @Test void concurrentThreadsHaveIndependentTransactions() throws Exception { + ExecutorService executor = Executors.newFixedThreadPool(2); + CyclicBarrier barrier = new CyclicBarrier(2); + Callable work = () -> tx.inTransaction(outer -> { + barrier.await(5, TimeUnit.SECONDS); + return tx.inTransaction(inner -> inner.getConnection()); + }); + try { + Future first = executor.submit(work); + Future second = executor.submit(work); + assertNotSame(first.get(10, TimeUnit.SECONDS), second.get(10, TimeUnit.SECONDS)); + assertEquals(2, connections.size()); + assertTrue(connections.stream().allMatch(state -> state.commits == 1 && state.closes == 1)); + } finally { executor.shutdownNow(); } + } + + + private JdbcTransactionManager manager() { + JdbcTransactionManager result = new JdbcTransactionManager(); + result.bindDataSource(proxy(DataSource.class, (p, method, args) -> { + if (!method.getName().equals("getConnection")) throw new UnsupportedOperationException(); + ConnectionState state = new ConnectionState(); + connections.add(state); + return state.connection(); + })); + return result; + } + + private static T proxy(Class type, java.lang.reflect.InvocationHandler handler) { + return type.cast(Proxy.newProxyInstance(type.getClassLoader(), new Class[]{type}, handler)); + } + + private static final class ConnectionState { + int commits, rollbacks, closes; + boolean failRollback, failCommit; + String searchPath = "core, public"; + + Connection connection() { + return proxy(Connection.class, (p, method, args) -> { + switch (method.getName()) { + case "setAutoCommit": return null; + case "commit": + if (failCommit) throw new SQLException("commit failed"); + commits++; return null; + case "rollback": + rollbacks++; + if (failRollback) throw new SQLException("rollback failed"); + return null; + case "close": closes++; return null; + case "createStatement": return proxy(Statement.class, (sp, sm, sa) -> { + if (sm.getName().equals("close")) return null; + if (sm.getName().equals("execute")) { + searchPath = ((String) sa[0]).substring("SET search_path TO ".length()); + return false; + } + if (sm.getName().equals("executeQuery")) { + String saved = searchPath; + return proxy(ResultSet.class, (rp, rm, ra) -> switch (rm.getName()) { + case "next" -> true; + case "getString" -> saved; + case "close" -> null; + default -> throw new UnsupportedOperationException(rm.getName()); + }); + } + throw new UnsupportedOperationException(sm.getName()); + }); + case "prepareStatement": return proxy(PreparedStatement.class, (pp, pm, pa) -> { + if (pm.getName().equals("setString")) { searchPath = (String) pa[1]; return null; } + if (pm.getName().equals("execute")) return true; + if (pm.getName().equals("close")) return null; + throw new UnsupportedOperationException(pm.getName()); + }); + default: throw new UnsupportedOperationException(method.getName()); + } + }); + } + } +} diff --git a/de.drremote.db.sqlite/src/test/java/de/drremote/db/sqlite/impl/migration/MigrationServiceImplTest.java b/de.drremote.db.sqlite/src/test/java/de/drremote/db/sqlite/impl/migration/MigrationServiceImplTest.java new file mode 100644 index 0000000..b448df6 --- /dev/null +++ b/de.drremote.db.sqlite/src/test/java/de/drremote/db/sqlite/impl/migration/MigrationServiceImplTest.java @@ -0,0 +1,289 @@ +package de.drremote.db.sqlite.impl.migration; + +import de.drremote.db.sqlite.api.BatchMigration; +import de.drremote.db.sqlite.api.DbCoreException; +import de.drremote.db.sqlite.api.Migration; +import de.drremote.db.sqlite.api.MigrationBaseline; +import de.drremote.db.sqlite.api.PluginMigrationDescriptor; +import de.drremote.db.sqlite.api.TransactionCallback; +import de.drremote.db.sqlite.api.TransactionManager; +import de.drremote.db.sqlite.impl.jdbc.JdbcDbHandle; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.Statement; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class MigrationServiceImplTest { + private TestTransactionManager tx; + private MigrationServiceImpl service; + + @BeforeEach + void setUp() throws Exception { + tx = new TestTransactionManager(); + service = new MigrationServiceImpl(); + service.bindTransactionManager(tx); + } + + @AfterEach + void tearDown() throws Exception { + tx.close(); + } + + @Test + void appliesBatchAtomicallyAndRecordsOneVersion() throws Exception { + service.migratePlugin(descriptor( + "batch", + List.of(batch(1, "table and index", List.of( + "CREATE TABLE batch_item (id INTEGER PRIMARY KEY, value TEXT NOT NULL)", + "CREATE INDEX ix_batch_item_value ON batch_item(value)" + ))), + null + )); + + assertTrue(objectExists("table", "batch_item")); + assertTrue(objectExists("index", "ix_batch_item_value")); + assertEquals(1, scalarInt( + "SELECT COUNT(*) FROM core_schema_migrations WHERE plugin_name = 'batch'" + )); + } + + @Test + void rollsBackWholeBatchWhenOneStatementFails() throws Exception { + var descriptor = descriptor( + "broken-batch", + List.of(batch(1, "broken", List.of( + "CREATE TABLE must_rollback (id INTEGER PRIMARY KEY)", + "INSERT INTO missing_table(id) VALUES (1)" + ))), + null + ); + + assertThrows(DbCoreException.class, () -> service.migratePlugin(descriptor)); + assertFalse(objectExists("table", "must_rollback")); + assertEquals(0, scalarInt( + "SELECT COUNT(*) FROM core_schema_migrations WHERE plugin_name = 'broken-batch'" + )); + } + + @Test + void usesBaselineOnlyForEmptyHistoryAndContinuesAboveItsVersion() throws Exception { + List migrations = List.of( + migration(1, "legacy table", "CREATE TABLE legacy_only (id INTEGER PRIMARY KEY)"), + migration(2, "legacy column", "ALTER TABLE app_item ADD COLUMN legacy_value TEXT"), + migration(3, "current data", "INSERT INTO app_item(id, value) VALUES (1, 'current')") + ); + MigrationBaseline baseline = baseline(2, "current schema", List.of( + "CREATE TABLE app_item (id INTEGER PRIMARY KEY, value TEXT NOT NULL)", + "CREATE INDEX ix_app_item_value ON app_item(value)" + )); + + PluginMigrationDescriptor descriptor = descriptor("baseline", migrations, baseline); + service.migratePlugin(descriptor); + service.migratePlugin(descriptor); + + assertFalse(objectExists("table", "legacy_only")); + assertTrue(objectExists("index", "ix_app_item_value")); + assertEquals(2, scalarInt( + "SELECT version FROM core_schema_baselines WHERE plugin_name = 'baseline'" + )); + assertEquals(1, scalarInt( + "SELECT COUNT(*) FROM core_schema_migrations WHERE plugin_name = 'baseline'" + )); + assertEquals(1, scalarInt("SELECT COUNT(*) FROM app_item WHERE value = 'current'")); + } + + @Test + void ignoresProposedBaselineWhenIncrementalHistoryExists() throws Exception { + List oldMigrations = List.of( + migration(1, "table", "CREATE TABLE upgrade_item (id INTEGER PRIMARY KEY)"), + migration(2, "name", "ALTER TABLE upgrade_item ADD COLUMN name TEXT") + ); + service.migratePlugin(descriptor("upgrade", oldMigrations, null)); + + List currentMigrations = List.of( + oldMigrations.get(0), + oldMigrations.get(1), + migration(3, "data", "INSERT INTO upgrade_item(id, name) VALUES (1, 'upgraded')") + ); + service.migratePlugin(descriptor( + "upgrade", + currentMigrations, + baseline(2, "must not run", List.of("CREATE TABLE baseline_only (id INTEGER PRIMARY KEY)")) + )); + + assertFalse(objectExists("table", "baseline_only")); + assertEquals(0, scalarInt( + "SELECT COUNT(*) FROM core_schema_baselines WHERE plugin_name = 'upgrade'" + )); + assertEquals(3, scalarInt( + "SELECT COUNT(*) FROM core_schema_migrations WHERE plugin_name = 'upgrade'" + )); + assertEquals(1, scalarInt("SELECT COUNT(*) FROM upgrade_item WHERE name = 'upgraded'")); + } + + @Test + void keepsSingleStatementGuardForRegularMigrations() { + PluginMigrationDescriptor descriptor = descriptor( + "guard", + List.of(migration( + 1, + "two statements", + "CREATE TABLE first_table(id INTEGER); CREATE TABLE second_table(id INTEGER)" + )), + null + ); + + assertThrows(DbCoreException.class, () -> service.migratePlugin(descriptor)); + } + + @Test + void rejectsChangesToAlreadyAppliedMigrationSql() { + service.migratePlugin(descriptor( + "drift", + List.of(migration(1, "original", "CREATE TABLE original_table(id INTEGER)")), + null + )); + + PluginMigrationDescriptor changed = descriptor( + "drift", + List.of(migration(1, "changed", "CREATE TABLE changed_table(id INTEGER)")), + null + ); + + DbCoreException failure = assertThrows( + DbCoreException.class, + () -> service.migratePlugin(changed) + ); + assertTrue(rootMessage(failure).contains("Migration drift detected for drift v1")); + } + + private boolean objectExists(String type, String name) throws Exception { + try (var ps = tx.connection.prepareStatement( + "SELECT COUNT(*) FROM sqlite_master WHERE type = ? AND name = ?" + )) { + ps.setString(1, type); + ps.setString(2, name); + try (ResultSet rs = ps.executeQuery()) { + return rs.next() && rs.getInt(1) == 1; + } + } + } + + private int scalarInt(String sql) throws Exception { + try (Statement statement = tx.connection.createStatement(); + ResultSet result = statement.executeQuery(sql)) { + return result.next() ? result.getInt(1) : 0; + } + } + + private static String rootMessage(Throwable failure) { + Throwable current = failure; + while (current.getCause() != null) { + current = current.getCause(); + } + return String.valueOf(current.getMessage()); + } + + private static PluginMigrationDescriptor descriptor( + String pluginName, + List migrations, + MigrationBaseline baseline + ) { + return new PluginMigrationDescriptor() { + @Override + public String getPluginName() { + return pluginName; + } + + @Override + public List getMigrations() { + return migrations; + } + + @Override + public MigrationBaseline getBaseline() { + return baseline; + } + }; + } + + private static Migration migration(int version, String description, String sql) { + return new SimpleMigration(version, description, sql); + } + + private static BatchMigration batch(int version, String description, List statements) { + return new SimpleBatchMigration(version, description, statements); + } + + private static MigrationBaseline baseline(int version, String description, List statements) { + return new SimpleBaseline(version, description, statements); + } + + private record SimpleMigration(int version, String description, String sql) implements Migration { + @Override public int getVersion() { return version; } + @Override public String getDescription() { return description; } + @Override public String getSql() { return sql; } + } + + private record SimpleBatchMigration( + int version, + String description, + List statements + ) implements BatchMigration { + @Override public int getVersion() { return version; } + @Override public String getDescription() { return description; } + @Override public List getStatements() { return statements; } + } + + private record SimpleBaseline( + int version, + String description, + List statements + ) implements MigrationBaseline { + @Override public int getVersion() { return version; } + @Override public String getDescription() { return description; } + @Override public List getStatements() { return statements; } + } + + private static final class TestTransactionManager implements TransactionManager, AutoCloseable { + private final Connection connection; + + private TestTransactionManager() throws Exception { + connection = DriverManager.getConnection("jdbc:sqlite::memory:"); + } + + @Override + public synchronized T inTransaction(TransactionCallback callback) throws DbCoreException { + try { + connection.setAutoCommit(false); + try { + T result = callback.doInTransaction(new JdbcDbHandle(connection)); + connection.commit(); + return result; + } catch (Exception failure) { + connection.rollback(); + throw failure; + } finally { + connection.setAutoCommit(true); + } + } catch (Exception failure) { + throw new DbCoreException("Test transaction failed", failure); + } + } + + @Override + public void close() throws Exception { + connection.close(); + } + } +} diff --git a/docs/configuration-duckdb.md b/docs/configuration-duckdb.md new file mode 100644 index 0000000..5f40628 --- /dev/null +++ b/docs/configuration-duckdb.md @@ -0,0 +1,26 @@ +# DuckDB configuration + +ConfigAdmin PID: `de.drremote.db.duckdb`. In Karaf, use `etc/de.drremote.db.duckdb.cfg`. + +Start from the [example configuration](../examples/de.drremote.db.duckdb.cfg.example): + +```properties +jdbcUrl=jdbc:duckdb:${karaf.data}/drremote.duckdb +poolName=db-duckdb-pool +maxPoolSize=1 +minIdle=1 +readOnly=false +``` + +| Property | Default / requirement | +| --- | --- | +| `jdbcUrl` | Required; writable database file path, unless opening an existing database read-only. | +| `poolName` | `db-duckdb-pool` | +| `maxPoolSize` | Must be `1` | +| `minIdle` | Must be `1` | +| `readOnly` | `false` | +| `tempDirectory` | Optional directory for DuckDB temporary storage. | + +Karaf expands `${karaf.data}`. Other OSGi runtimes must provide an appropriate path. DuckDB is an embedded analytical database; native driver/platform support follows DuckDB JDBC. Coordinate writers and do not open competing application instances against the same writable file. + +See [installation](installation.md) and [usage](usage-duckdb.md). diff --git a/docs/configuration-postgresql.md b/docs/configuration-postgresql.md index 200bb88..4dd4aca 100644 --- a/docs/configuration-postgresql.md +++ b/docs/configuration-postgresql.md @@ -1,74 +1,41 @@ -## Full Configuration Example (Karaf) +# PostgreSQL configuration -You can configure the PostgreSQL core completely through **a single** configuration (PID `de.drremote.db.postgresql`). +ConfigAdmin PID: `de.drremote.db.postgresql`. In Karaf, use `etc/de.drremote.db.postgresql.cfg`. -> This file covers PostgreSQL only. For SQLite configuration, see `docs/configuration-sqlite.md`. +Create a PostgreSQL database and user before installing the feature. For example, an administrator can use PostgreSQL's `createuser --pwprompt app_user` and `createdb --owner=app_user appdb`. Run these against the intended PostgreSQL server with appropriate administrative access. -> The values below are placeholders only. Do not use them unchanged in production. - -### Option 1: `etc/de.drremote.db.postgresql.cfg` file (recommended) - -Path: - -- `$KARAF_HOME/etc/de.drremote.db.postgresql.cfg` - -Content (example using database `appdb` and user `karaf_postgresql`): +Copy the [example configuration](../examples/de.drremote.db.postgresql.cfg.example) and replace its password placeholder: ```properties -############################################# -# DB.POSTGRESQL Config (PID: de.drremote.db.postgresql) -############################################# - -# --- JDBC / PostgreSQL --- jdbcUrl=jdbc:postgresql://localhost:5432/appdb -username=karaf_postgresql -password=change_me - -# --- HikariCP Pool --- +username=app_user +password=REPLACE_WITH_YOUR_DATABASE_PASSWORD poolName=db-postgresql-pool maxPoolSize=10 minIdle=1 - -############################################# -# Optional: legacy keys (only if needed) -# (these are only used if the "new" keys -# above are NOT set) -############################################# -# dbcore.jdbcUrl=jdbc:postgresql://localhost:5432/appdb -# dbcore.username=karaf_postgresql -# dbcore.password=change_me +migrationsEnabled=true +psqlExecutable=psql +psqlConnectTimeoutSeconds=30 ``` -> After saving the file, Karaf will usually pick it up automatically through ConfigAdmin. -> Otherwise, restart Karaf. +| Property | Default / requirement | +| --- | --- | +| `jdbcUrl` | Required for the data source. PostgreSQL JDBC URL. | +| `username` | Required for the data source. | +| `password` | Set the database user's actual password for password authentication. | +| `poolName` | `db-postgresql-pool` | +| `maxPoolSize` | `10` | +| `minIdle` | `1` | +| `migrationsEnabled` | `true`; `false` disables automatic and explicit migrations. | +| `psqlExecutable` | `psql`; may be an absolute executable path. | +| `psqlConnectTimeoutSeconds` | `30`; connection timeout for `psql`, not a query execution timeout. | ---- +`jdbcUrl`, `username`, `password`, `migrationsEnabled`, `psqlExecutable`, and `psqlConnectTimeoutSeconds` also support their legacy `dbcore.`-prefixed names. Prefer the short names. Supply a consistent JDBC/migration configuration rather than relying on the migration component's legacy defaults. -### Option 2: Configure everything through the Karaf shell (`config:edit`) +The migration service creates the `core` schema and its tracking tables and creates plugin schemas as needed. The database user therefore needs suitable schema creation/object privileges. The JDBC data source does not create the database or database role. -```sh -config:edit de.drremote.db.postgresql +`psql` is needed only for `ScriptMigration` descriptors and must be installed on the machine running the bundle. Script execution passes the password in the child process environment, not in the command-line arguments. Protect your actual configuration using your runtime's normal credential handling and file permissions. -# JDBC -config:property-set jdbcUrl jdbc:postgresql://localhost:5432/appdb -config:property-set username karaf_postgresql -config:property-set password change_me +ConfigAdmin applies changes dynamically. Coordinate connection/pool changes and verify migration state before enabling a previously disabled migration service. -# HikariCP -config:property-set poolName db-postgresql-pool -config:property-set maxPoolSize 10 -config:property-set minIdle 1 - -config:update -``` - ---- - -### Optional: Verify the configuration (after setting it) - -```sh -config:list "(service.pid=de.drremote.db.postgresql)" - -config:edit de.drremote.db.postgresql -config:property-list -``` +See [installation](installation.md) and [usage](usage-postgresql.md). diff --git a/docs/configuration-sqlite.md b/docs/configuration-sqlite.md index 21b16cf..15b176a 100644 --- a/docs/configuration-sqlite.md +++ b/docs/configuration-sqlite.md @@ -1,67 +1,29 @@ -## SQLite Configuration Example (Karaf) +# SQLite configuration -Configure the SQLite core through a single configuration (PID `de.drremote.db.sqlite`). +ConfigAdmin PID: `de.drremote.db.sqlite`. In Karaf, use `etc/de.drremote.db.sqlite.cfg`. -> The values below are placeholders only. Do not use them unchanged in production. - -### Option 1: `etc/de.drremote.db.sqlite.cfg` file (recommended) - -Path: - -- `$KARAF_HOME/etc/de.drremote.db.sqlite.cfg` - -Content: +Copy the [complete example](../examples/de.drremote.db.sqlite.cfg.example) before installing the feature: ```properties -############################################# -# DB.SQLITE Config (PID: de.drremote.db.sqlite) -############################################# - -# --- JDBC / SQLite --- -jdbcUrl=jdbc:sqlite:${karaf.data}/drremote.db - -# --- HikariCP Pool (SQLite must be 1/1) --- +jdbcUrl=jdbc:sqlite:${karaf.data}/drremote.sqlite poolName=db-sqlite-pool maxPoolSize=1 minIdle=1 - -# --- SQLite specifics --- busyTimeoutMs=5000 foreignKeys=true ``` -> After saving the file, Karaf will usually pick it up automatically through ConfigAdmin. -> Otherwise, restart Karaf. - ---- - -### Option 2: Configure everything through the Karaf shell (`config:edit`) - -```sh -config:edit de.drremote.db.sqlite - -# JDBC -config:property-set jdbcUrl jdbc:sqlite:${karaf.data}/drremote.db - -# HikariCP -config:property-set poolName db-sqlite-pool -config:property-set maxPoolSize 1 -config:property-set minIdle 1 - -# SQLite specifics -config:property-set busyTimeoutMs 5000 -config:property-set foreignKeys true - -config:update -``` - ---- +| Property | Default / requirement | +| --- | --- | +| `jdbcUrl` | Required; JDBC SQLite URL. The process needs access to the parent directory/file. | +| `poolName` | `db-sqlite-pool` | +| `maxPoolSize` | Must be `1` | +| `minIdle` | Must be `1` | +| `busyTimeoutMs` | `5000` milliseconds | +| `foreignKeys` | `true` | -### Optional: Verify the configuration (after setting it) +`${karaf.data}` is expanded by Karaf configuration handling. In another OSGi runtime, provide an actual writable path or that runtime's supported property substitution. -```sh -config:list "(service.pid=de.drremote.db.sqlite)" +SQLite is embedded; no server or password is required. Avoid two application instances writing the same file. Configuration updates replace the pool, so coordinate changes with callers. -config:edit de.drremote.db.sqlite -config:property-list -``` \ No newline at end of file +See [installation](installation.md) and [usage](usage-sqlite.md). diff --git a/docs/configuration.md b/docs/configuration.md index eebabaa..31c6ca2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,6 +1,7 @@ -## Configuration Examples +# Configuration -This repository now contains **two** database modules. Use the matching configuration guide: +- [PostgreSQL](configuration-postgresql.md) +- [SQLite](configuration-sqlite.md) +- [DuckDB](configuration-duckdb.md) -- PostgreSQL: `docs/configuration-postgresql.md` -- SQLite: `docs/configuration-sqlite.md` +See [installation](installation.md) for the source build and Karaf setup. diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..db12855 --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,57 @@ +# Installation + +## Build artifacts locally + +Use JDK 17+ and Maven 3.9+ from the repository root: + +```sh +mvn clean install +``` + +The build uses Maven Central for third-party dependencies. The project's own artifacts must be built from source; no public DrRemote Maven server or Maven Central publication is assumed. `install` writes artifacts to your local Maven repository; `verify` builds and checks them without installing. + +Generated feature XML is in each `de.drremote.db..features/target/feature/feature.xml`. Use the generated files, not the templates under `src/main/feature`, which contain Maven placeholders. + +## Apache Karaf 4.4.11 + +Build and install the project as the same operating-system user that runs Karaf. Copy one of the [example configurations](../examples) to the matching filename in Karaf's `etc/` directory before installing its feature. + +```text +feature:repo-add mvn:de.drremote.db/sqlite-features/1.1.0-SNAPSHOT/xml/features +feature:install de.drremote-db-sqlite +``` + +Replace `sqlite` with `postgresql` or `duckdb` as needed. The descriptors install the JDBC driver, HikariCP, and database bundle, with Karaf's `scr` feature as a prerequisite. Feature versions use OSGi notation `1.1.0.SNAPSHOT`; Maven URLs use `1.1.0-SNAPSHOT`. + +### Local repository resolution + +By default Karaf uses `~/.m2/repository`. If you built with a custom `-Dmaven.repo.local=...`, set `org.ops4j.pax.url.mvn.localRepository` in Karaf's `etc/org.ops4j.pax.url.mvn.cfg` to that absolute directory. A different OS user, container, or remote machine needs access to the installed artifacts too; a local Maven installation does not distribute files to another host. + +Karaf can also add a generated descriptor directly: + +```text +feature:repo-add file:/absolute/path/to/checkout/de.drremote.db.sqlite.features/target/feature/feature.xml +``` + +That changes how the descriptor is loaded; its `mvn:` bundle dependencies still need to be resolvable. + +See the [Karaf Maven URL handler documentation](https://karaf.apache.org/manual/latest/#_maven_url_handler) for resolver settings. + +## Other OSGi runtimes + +Install the database bundle, the matching JDBC driver and HikariCP versions from the parent POM, and the runtime services required by their manifests. Enable Declarative Services and provide ConfigAdmin configuration using the documented PID. The Karaf feature files describe the dependency set; they are not a standalone runtime distribution. + +## Diagnostics + +```text +feature:list -i +bundle:list +scr:list +service:list de.drremote.db.sqlite.api.DbCore +log:tail +``` + +- **Artifact not found:** run `mvn clean install`, check the exact version, and verify that Karaf sees the same local repository. Do not add the retired DrRemote Maven server. +- **Unresolved bundle:** check `bundle:diag ` and confirm the driver/HikariCP versions match the generated descriptor. +- **DataSource activation fails:** check the configuration filename, `jdbcUrl`, file permissions, and PostgreSQL connectivity/credentials. +- **Client remains unsatisfied:** inspect `scr:info `. For PostgreSQL/SQLite clients waiting on `DatabaseReady`, check migration errors and match the descriptor's plugin name to the reference filter. diff --git a/docs/superpowers/plans/2026-11-01-public-db-refresh.md b/docs/superpowers/plans/2026-11-01-public-db-refresh.md new file mode 100644 index 0000000..630c0f7 --- /dev/null +++ b/docs/superpowers/plans/2026-11-01-public-db-refresh.md @@ -0,0 +1,79 @@ +# Public DB Refresh Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the public PostgreSQL, SQLite, and DuckDB project independently buildable, tested, and documented in English. + +**Architecture:** Selectively import database code into a branch descended from public GitHub history. Retain the public Maven coordinates and OSGi identities. Use Maven Central for third-party dependencies and source installation for this project's artifacts. + +**Tech Stack:** Java 17+, Maven 3.9, OSGi Declarative Services, Apache Karaf, JDBC, HikariCP, JUnit 5, GitHub Actions. + +**Spec:** `docs/superpowers/specs/2026-11-01-public-db-refresh-design.md` + +## Global Constraints + +- English throughout; Apache-2.0 licensing retained. +- Minimum Java 17; public development version `1.1.0-SNAPSHOT`. +- Public artifact IDs: `postgresql`, `postgresql-features`, `sqlite`, `sqlite-features`, `duckdb`, `duckdb-features`, under `de.drremote.db`. +- No private history, Runtime integration, settings, endpoints, deployments, credentials, or service changes. +- Existing public Java packages and bundle symbolic names remain stable. +- No claim that DrRemote artifacts are available on Maven Central or the retired Maven server. + +## Review Focus + +- Fresh machines: build with empty Maven settings/cache, without sibling projects. +- Embedded persistence: transactions commit and roll back using actual temporary databases. +- Migration replay: repeated migration does not duplicate changes or tracking rows. +- Karaf installation: built feature XML references the actual public artifact IDs/version, including JDBC dependencies. +- Older users: preserve public Maven IDs and explicitly document API/behavior differences. + +--- + +### Task 1: Public database reactor and regression coverage + +**Files:** Modify root `pom.xml` and database/feature module POMs; import the three database modules' `src/` trees; add DuckDB JDBC/migration tests under its `src/test/java/`. + +**Interfaces:** Consumes the reviewed development database source. Produces six independently public Maven modules at `1.1.0-SNAPSHOT`, with existing API package names and passing tests. + +- [x] Record a baseline build with empty Maven settings and a fresh cache. +- [x] Import existing PostgreSQL/SQLite regression tests first; run them against the old public implementation and record expected failure where newer APIs/behavior are absent. +- [x] Import the selected current Java sources and SQL resources, retaining module directories but adapting their POM artifact IDs to the public names. +- [x] Centralize shared Java/dependency/plugin properties in the parent. Ensure DuckDB runs JUnit tests and the public reactor contains no installations module. +- [x] Add focused DuckDB tests for real JDBC commit/rollback and migration replay; verify missing DuckDB implementation fails before importing that module's production code. +- [x] Run the whole reactor tests using the isolated Maven settings/cache; inspect reports and generated OSGi manifests. +- [x] Commit the independently buildable reactor and tests. + +### Task 2: Matching Karaf installation descriptors + +**Files:** Modify `de.drremote.db.{postgresql,sqlite,duckdb}.features/src/main/feature/feature.xml` and related POMs; add public configuration examples under `examples/`. + +**Interfaces:** Consumes Task 1 artifact IDs/version. Produces feature descriptors and example configurations that match those artifacts without private repositories. + +- [x] Reproduce any stale artifact/version resolution using the actual feature build or descriptor inspection; retain the evidence. +- [x] Align feature descriptors, drivers, HikariCP, and prerequisites with the reactor. Use Maven filtering or equivalent single-source version values. +- [x] Add portable SQLite, DuckDB, and PostgreSQL `.cfg.example` files containing placeholders only. +- [x] Verify packaged descriptors/manifests; attempt an isolated Karaf SQLite/DuckDB smoke installation if practical. +- [x] Commit the descriptors and examples. + +### Task 3: Public docs and CI + +**Files:** Rewrite `README.md`; refresh module READMEs and `docs/configuration-*.md`, `docs/usage-*.md`; add `CONTRIBUTING.md`, `CHANGELOG.md`, and `.github/workflows/build.yml`. + +**Interfaces:** Consumes Tasks 1–2 commands/artifacts. Produces English source-build, usage, compatibility, contribution, and validation instructions, plus automated JDK 17/21 builds. + +- [x] Document source installation, unavailable hosted DrRemote artifacts, local Maven/Karaf resolution, configuration, and a minimal SQLite example. +- [x] Import/adapt public database API examples, explain migration limits and compatibility changes, and remove private Runtime references. +- [x] Add a read-only CI workflow using pinned actions, JDK 17/21 verification, and uploaded JAR/feature outputs; no publishing credentials. +- [x] Validate local links, sample paths, versions, workflow syntax, and API examples against the implementation. +- [x] Commit public documentation and CI. + +### Task 4: Clean verification, review, and GitHub preparation + +**Files:** Final changes across the branch; verification evidence kept outside tracked public content. + +**Interfaces:** Consumes all earlier tasks. Produces a reviewed, tested public branch suitable for GitHub review. + +- [x] Build and install from a standalone source export with empty settings and a fresh local Maven cache, so no private artifacts or sibling projects can mask missing dependencies. +- [x] Inspect test reports, artifacts, feature descriptors, document references, diff whitespace, publication contents, and ancestry. +- [x] Request a fresh whole-branch review against the spec and this plan. Fix important findings with a failing reproduction followed by passing verification. +- [x] Prepare the reviewed public branch and PR description for GitHub. The finishing step publishes that branch under the user's authorization; it must not merge, force-push, tag a release, or change the private origin. diff --git a/docs/superpowers/specs/2026-11-01-public-db-refresh-design.md b/docs/superpowers/specs/2026-11-01-public-db-refresh-design.md new file mode 100644 index 0000000..e72b0e7 --- /dev/null +++ b/docs/superpowers/specs/2026-11-01-public-db-refresh-design.md @@ -0,0 +1,116 @@ +# Public DrRemote DB refresh + +## Intent + +Refresh the existing public `DoctorRemoteControl/drremote-db-postgresql-sqlite` +project so an interested developer can understand, build, test, and use it +without access to the owner's private infrastructure. The public repository has +received external interest but has not been updated since April 2026. The former +public Maven repository no longer exists. + +The owner selected PostgreSQL, SQLite, and DuckDB for this refresh and requested +English throughout. Private development remains separate from this public +project. + +## Approaches + +1. **Refresh PostgreSQL and SQLite only.** Smaller update, but omits the existing + DuckDB implementation and leaves the public project behind the current DB + functionality. +2. **Selectively refresh all three database layers. Selected by the owner.** + Import the relevant current database sources and tests into a branch based on + the existing public history. Keep the public project independently buildable + and retain a clear boundary around private infrastructure. This needs more + validation, particularly for DuckDB, which currently has no tests. + +For distribution, the proposed first step is building from public source and +installing the artifacts into the user's local Maven repository. CI provides +reproducible build evidence and downloadable build artifacts. Publishing to +Maven Central or rebuilding a hosted Maven service is outside this refresh; +neither is needed to establish a usable public source project. + +## Public contents and boundary + +- Include the current PostgreSQL, SQLite, and DuckDB Java implementation, + database APIs, migration code, and applicable tests, plus their Karaf feature + modules and public configuration/usage documentation. +- Start from public commit `df992d0276892472310079c9b9ec280b1cb5f106` on isolated + branch `public/db-refresh`. Import selected database files from the separately + maintained development source after review. +- Copy selected files rather than merging private commits. No private commit + history, Git credentials, Maven settings, private endpoints, Eclipse workspace + state, local deployments, or private Runtime integration is published. +- Exclude `.drremote/`, private deployment examples, and + `de.drremote.db.installations`. The native Runtime package descriptors require + a separate, nonpublic DrRemote Runtime and are not the public entry point. +- Preserve the private checkout's branch, files, upstream, and disabled legacy + push configuration. The public worktree is under the existing OSGi workspace. + +## Build and compatibility + +Implementation choices proposed by the builder: + +- Preserve the existing public Maven IDs `de.drremote.db:postgresql`, + `postgresql-features`, `sqlite`, and `sqlite-features`. Use `duckdb` and + `duckdb-features` for the new public modules. The private source's renamed + artifact IDs are not necessary for this independent public project. +- Preserve the existing exported Java package names and OSGi bundle symbolic + names. Identify and explain any actual API or behavior incompatibility found + when importing the newer code. +- Use public development version `1.1.0-SNAPSHOT` consistently across the reactor, + feature descriptors, examples, and docs. An actual tagged release is a later + publication decision; do not imply snapshot binaries are remotely published. +- Use Java 17 as the minimum, matching the current database source POMs; validate + on Java 17 and Java 21 where available. Third-party dependencies and plugins + must resolve from Maven Central without private settings or cached DrRemote + artifacts. Reuse compatible existing dependency versions unless a demonstrated + problem warrants a change. +- Keep common dependency/plugin versions in the parent where useful. Avoid + unrelated rewrites of the database implementation. +- Make feature descriptors refer to the artifacts and versions actually built. + Document the local Maven repository used by Karaf and the runtime prerequisites. + +## Documentation and project presentation + +- Rewrite the English README around purpose, module differences, requirements, + a working build command, a minimal SQLite quick start, configuration, and + links to deeper examples. +- Explain that the project's own artifacts must currently be built from source; + Maven coordinates alone do not imply availability from Maven Central or the + retired Maven server. +- Align PostgreSQL, SQLite, and DuckDB configuration and usage docs with the + imported APIs and the new public version. Cover required external PostgreSQL + and `psql` only where used, embedded database file paths, and actual migration + limitations. +- Add concise contributor guidance, validation commands, and release/change + notes for the refresh. Retain Apache-2.0 licensing. +- Add GitHub Actions for build/tests and uploaded build outputs. Avoid deployment + workflows and private service credentials. + +## Validation and acceptance + +1. A clean public checkout builds and verifies with an empty local Maven cache + and empty user/global settings, downloading only public third-party artifacts. + No private checkout or Runtime source is required. +2. Imported transaction and migration regression tests pass. Add focused embedded + SQLite and DuckDB tests covering real JDBC access, rollback, and migration + behavior where this provides missing evidence. Tests use temporary files and + do not touch the owner's existing databases or services. +3. Verify that packaged bundles contain the expected OSGi metadata and that feature + XML resolves the public artifact IDs and build version. Run an isolated Karaf + smoke check if practical; report any runtime validation gap explicitly. +4. Validate documented local file references, versions, and example APIs. Make + docs consistent about what was tested and which PostgreSQL checks require an + external database. +5. Inspect the publication diff and the public branch's ancestry for accidental + private content. Private commits must remain unreachable from this branch. +6. Run a fresh final verification and review. Prepare the changes for GitHub + review; do not force-push, replace the private origin, create a tagged release, + or deploy/restart any existing service. + +## Left to implementation + +The builder may settle CI action pins, supported Maven patch version, the exact +small test fixtures, documentation organization, and the mechanism that keeps +feature versions consistent. Any need for a new public service, private access, +licensing change, or broader publication must be raised separately. diff --git a/docs/usage-duckdb.md b/docs/usage-duckdb.md new file mode 100644 index 0000000..21e8910 --- /dev/null +++ b/docs/usage-duckdb.md @@ -0,0 +1,33 @@ +# DuckDB usage + +The interfaces are in `de.drremote.db.duckdb.api`. [Configure](configuration-duckdb.md) and install the DuckDB feature first. + +## JDBC work + +Reference `TransactionManager` from a Declarative Services component and run work inside its callback: + +```java +@Reference +private TransactionManager transactions; + +public long itemCount() { + return transactions.inTransaction(db -> db.getQueryRunner().queryOne( + "SELECT COUNT(*) FROM items", row -> row.getLong(1))); +} +``` + +`query(...)`, `queryOne(...)`, and `update(...)` support `?` value parameters. `queryOne` returns `null` for no result and rejects multiple rows. The callback's work commits on success and rolls back on an exception. + +The DuckDB implementation is simpler than the PostgreSQL/SQLite layers: **do not nest transaction callbacks**. Its single-connection pool cannot serve a second connection while the outer callback is holding the first. Do not retain the connection beyond the callback or share writable files between application instances. + +## Migrations + +Implement `PluginMigrationDescriptor` using `getPluginName()` and `getMigrations()`. Each `Migration` supplies its version, description, and a single SQL statement. Reference `MigrationService` and call `migratePlugin(descriptor)` explicitly before accessing the plugin's tables. + +DuckDB does not currently automatically register descriptors or publish `DatabaseReady` markers. Your component controls migration-before-use ordering. + +Migrations are sorted by version and recorded in `core_schema_migrations`. Replaying an already recorded version skips it. Each version is transactional; several versions do not form one transaction. Do not change already-applied migration SQL. + +The current statement guard rejects embedded semicolons, including those in literals/comments; a trailing semicolon is allowed. Multi-statement scripts, SQLite-style batch/baseline migrations, and drift checking are not part of this DuckDB implementation. + +Real temporary-database tests cover parameterized JDBC access, transaction rollback/recovery, version ordering, migration replay, failed-version tracking, and the statement guard. diff --git a/docs/usage-postgresql.md b/docs/usage-postgresql.md index 6c51020..4d1d310 100644 --- a/docs/usage-postgresql.md +++ b/docs/usage-postgresql.md @@ -1,179 +1,66 @@ -## Example 1: DbCore as a lifecycle guarantee +# PostgreSQL usage -> This file covers PostgreSQL only. For SQLite usage examples, see `docs/usage-sqlite.md`. +The service interfaces are in `de.drremote.db.postgresql.api`. [Configure a running database](configuration-postgresql.md) before installing the feature. -```java -@Component(immediate = true) -public class DbCoreGuard { - - @Reference - void bind(DbCore core) { - if (!core.isReady()) { - throw new IllegalStateException("DbCore not ready"); - } - } -} -``` +## Queries and transactions -## Example 2: Simple repository (read) +Reference `TransactionManager` from an OSGi Declarative Services component: ```java -@Component -public class ProjectRepository { - - @Reference - TransactionManager tx; - - public List findAllNames() { - return tx.inTransaction(h -> { - h.setSearchPath("sqlcode"); - return h.getQueryRunner().query( - "SELECT name FROM projects ORDER BY name", - rs -> rs.getString(1) - ); - }); - } +@Reference +private TransactionManager transactions; + +public List names() { + return transactions.inTransaction(db -> db.getQueryRunner().query( + "SELECT name FROM app.customer WHERE id > ? ORDER BY id", + row -> row.getString("name"), 0)); } ``` -## Example 3: Insert / update +`query(...)` returns a list, `queryOne(...)` returns a single result or `null` (and rejects multiple results), and `update(...)` returns the affected-row count. Bind values with `?`; identifiers must be part of the SQL text. -```java -@Component -public class ProjectWriter { - - @Reference - TransactionManager tx; - - public void insert(String name) { - tx.inTransaction(h -> { - h.setSearchPath("sqlcode"); - h.getQueryRunner().update( - "INSERT INTO projects(name) VALUES (?)", - name - ); - return null; - }); - } -} -``` +An outer callback commits once on success and rolls back on failure. Nested calls on the same manager/thread join that transaction. An inner failure marks it rollback-only even when caught. Transaction context is not propagated across threads or manager instances. Do not retain connections/handles beyond the callback. + +## Plugin migrations -## Example 4: QueryOne +Publish an OSGi `PluginMigrationDescriptor` service implementing: ```java -public long countProjects() { - return tx.inTransaction(h -> { - h.setSearchPath("sqlcode"); - return h.getQueryRunner().queryOne( - "SELECT COUNT(*) FROM projects", - rs -> rs.getLong(1) - ); - }); -} +String getPluginName(); +String getSchemaName(); +List getMigrations(); ``` -## Example 5: Domain mapping +Use a stable plugin name and schema name and increasing version numbers. A `Migration` supplies `getVersion()`, `getDescription()`, and `getSql()`. The migration service creates the plugin schema, applies JDBC migrations, and records versions centrally in `core.schema_migrations`. -```java -record Project(long id, String name) {} - -public List load() { - return tx.inTransaction(h -> { - h.setSearchPath("sqlcode"); - return h.getQueryRunner().query( - "SELECT project_id, name FROM projects", - rs -> new Project( - rs.getLong("project_id"), - rs.getString("name") - ) - ); - }); -} -``` +Regular JDBC migration text is passed to the JDBC driver as one execution. Prefer one SQL statement per version, and use script migrations for actual `psql` scripts. Do not mix unmanaged schema modifications into the recorded migration history. -## Example 6: Explicit rollback +A consumer can wait for successful plugin migration through: ```java -public void failingOp() { - tx.inTransaction(h -> { - h.getQueryRunner().update("DELETE FROM projects"); - throw new IllegalStateException("boom"); - }); -} +@Reference(target = "(drremote.db.plugin=my-plugin)") +private DatabaseReady ready; ``` -## Example 7: Plugin migration descriptor +`DbCore` indicates the core service is active. `DatabaseReady` identifies successful migration for a specific plugin; the two services serve different purposes. -```java -public final class SqlcodeMigration implements PluginMigrationDescriptor { - - @Override - public String getPluginName() { - return "sqlcode"; - } - - @Override - public String getSchemaName() { - return "sqlcode"; - } - - @Override - public List getMigrations() { - return List.of(new ScriptMigration() { - @Override - public int getVersion() { - return 1; - } - - @Override - public String getDescription() { - return "initial schema"; - } - - @Override - public URL getScriptUrl() { - return getClass().getResource("/migrations/sqlcode/V1__init.sql"); - } - }); - } -} -``` +## Script migrations -## Example 8: Run migration +Implement `ScriptMigration` when a migration needs a resource URL, `psql` commands, or a SQL script: ```java -@Component(immediate = true) -public class MigrationRunner { - - @Reference - void run(MigrationService ms) { - ms.migratePlugin(new SqlcodeMigration()); - } +@Override +public URL getScriptUrl() { + return getClass().getResource("/migrations/V2__customer.sql"); } ``` -## Example 9: Service layer on top of repository - -```java -@Component -public class ProjectService { +The resource must exist in your client bundle. The runner loads the script into a temporary file, sets the plugin search path in the same `psql` session, stops on SQL errors, ignores `.psqlrc`, and never prompts for a password. - @Reference - ProjectRepository repo; +`ScriptMigration.isSingleTransaction()` defaults to `true`. Override it to `false` only when the script contains commands that PostgreSQL forbids in a transaction. That opt-out removes whole-script atomicity; design the script so a partial failure can be recovered safely. - public boolean hasProjects() { - return !repo.findAllNames().isEmpty(); - } -} -``` +The configured `psqlConnectTimeoutSeconds` limits connection establishment. It does not impose a whole-script execution timeout. JDBC migrations and an external `psql` process do not share one JDBC transaction. -## Example 10: Pure utility using TransactionCallback +## Validation -```java -public T withSqlcode(TransactionManager tx, TransactionCallback cb) { - return tx.inTransaction(h -> { - h.setSearchPath("sqlcode"); - return cb.doInTransaction(h); - }); -} -``` +The default Maven suite exercises nested transaction coordination, rollback/error paths, JDBC-to-psql URL conversion, and script command construction/execution with a temporary fake executable. It does not start a live PostgreSQL server. With `DRREMOTE_TEST_POSTGRES_URL` set, additional integration tests check schema placement, replay, a one-connection pool, and mixed JDBC/psql migrations against a dedicated database. CI supplies PostgreSQL 17; see [contributing](../CONTRIBUTING.md) for local setup. Validate your own migrations against the PostgreSQL versions you support. diff --git a/docs/usage-sqlite.md b/docs/usage-sqlite.md index 0d4a3c8..a4c2e01 100644 --- a/docs/usage-sqlite.md +++ b/docs/usage-sqlite.md @@ -1,164 +1,62 @@ -## SQLite Usage Examples +# SQLite usage -SQLite does not support PostgreSQL-style schemas or `search_path`. -Use clear table prefixes per plugin (e.g. `sqlcode_projects`). +The service interfaces are in `de.drremote.db.sqlite.api`. Install/configure the bundle first; the [notes client](../examples/notes-client) is a complete, buildable OSGi example. -### Example 1: DbCore as a lifecycle guarantee +## Queries and transactions -```java -@Component(immediate = true) -public class DbCoreGuard { - - @Reference - void bind(DbCore core) { - if (!core.isReady()) { - throw new IllegalStateException("DbCore not ready"); - } - } -} -``` - -### Example 2: Simple repository (read) +Reference `TransactionManager` from an OSGi Declarative Services component: ```java -@Component -public class ProjectRepository { - - @Reference - TransactionManager tx; - - public List findAllNames() { - return tx.inTransaction(h -> h.getQueryRunner().query( - "SELECT name FROM sqlcode_projects ORDER BY name", - rs -> rs.getString(1) - )); - } -} -``` - -### Example 3: Insert / update +@Reference +private TransactionManager transactions; -```java -@Component -public class ProjectWriter { - - @Reference - TransactionManager tx; - - public void insert(String name) { - tx.inTransaction(h -> { - h.getQueryRunner().update( - "INSERT INTO sqlcode_projects(name) VALUES (?)", - name - ); - return null; - }); - } +public List names() { + return transactions.inTransaction(db -> db.getQueryRunner().query( + "SELECT text FROM notes WHERE id > ? ORDER BY id", + row -> row.getString("text"), 0)); } ``` -### Example 4: QueryOne +Use `update(sql, parameters...)` for changes, `query(...)` for a list, and `queryOne(...)` for at most one result (`null` when absent; an exception for multiple results). Bind values using `?` parameters. Table/schema identifiers are part of the SQL text and are not value parameters. -```java -public long countProjects() { - return tx.inTransaction(h -> h.getQueryRunner().queryOne( - "SELECT COUNT(*) FROM sqlcode_projects", - rs -> rs.getLong(1) - )); -} -``` +The outer transaction commits on success and rolls back on failure. Nested calls on the **same manager and thread** share the connection and transaction. An inner failure marks the transaction rollback-only even if the caller catches it. Other threads/manager instances do not inherit the transaction. Do not retain the callback's connection or handle after it returns; closing a `DbHandle` does not close the manager's connection. -### Example 5: Domain mapping +## Automatic migrations and readiness -```java -record Project(long id, String name) {} - -public List load() { - return tx.inTransaction(h -> h.getQueryRunner().query( - "SELECT project_id, name FROM sqlcode_projects", - rs -> new Project( - rs.getLong("project_id"), - rs.getString("name") - ) - )); -} -``` +Publish an OSGi `PluginMigrationDescriptor` service with a stable plugin name and ordered version numbers. [NotesMigrations.java](../examples/notes-client/src/main/java/example/notes/NotesMigrations.java) creates a table in migration 1. The registry applies descriptors and records completed versions in `core_schema_migrations`. -### Example 6: Explicit rollback +Wait for the plugin's readiness service before accessing its tables: ```java -public void failingOp() { - tx.inTransaction(h -> { - h.getQueryRunner().update("DELETE FROM sqlcode_projects"); - throw new IllegalStateException("boom"); - }); -} +@Reference(target = "(drremote.db.plugin=notes)") +private DatabaseReady ready; ``` -### Example 7: Plugin migration descriptor +The marker is published only after the descriptor migrates successfully. [NotesRepository.java](../examples/notes-client/src/main/java/example/notes/NotesRepository.java) demonstrates this alongside a transaction reference. -```java -public final class SqlcodeMigration implements PluginMigrationDescriptor { +Regular migrations contain a single SQL statement. The current statement guard rejects embedded semicolons, including those inside SQL literals/comments; a final semicolon is allowed. Do not feed SQL scripts to this interface. - @Override - public String getPluginName() { - return "sqlcode"; - } +## Atomic batches - @Override - public List getMigrations() { - return List.of(new Migration() { - @Override - public int getVersion() { - return 1; - } - - @Override - public String getDescription() { - return "initial schema"; - } - - @Override - public String getSql() { - return "CREATE TABLE sqlcode_projects (project_id INTEGER PRIMARY KEY, name TEXT NOT NULL)"; - } - }); - } -} -``` - -### Example 8: Run migration +Implement `BatchMigration` when a version needs several explicit statements: ```java -@Component(immediate = true) -public class MigrationRunner { - - @Reference - void run(MigrationService ms) { - ms.migratePlugin(new SqlcodeMigration()); +new BatchMigration() { + public int getVersion() { return 2; } + public String getDescription() { return "Add note index"; } + public List getStatements() { + return List.of( + "ALTER TABLE notes ADD COLUMN category TEXT", + "CREATE INDEX notes_category ON notes(category)" + ); } -} +}; ``` -### Example 9: Service layer on top of repository +All statements in that version commit or roll back together and create one tracking row. Each list entry must still be a single statement. -```java -@Component -public class ProjectService { +## Baselines and upgrades - @Reference - ProjectRepository repo; +`PluginMigrationDescriptor.getBaseline()` may return a `MigrationBaseline` representing the current schema through a particular version. It is used only for a plugin with no migration history; existing databases continue through incremental migrations. Keep the incremental history and add a new migration for every subsequent change. Baseline state is recorded in `core_schema_baselines`. - public boolean hasProjects() { - return !repo.findAllNames().isEmpty(); - } -} -``` - -### Example 10: Pure utility using TransactionCallback - -```java -public T withSqlcode(TransactionManager tx, TransactionCallback cb) { - return tx.inTransaction(h -> cb.doInTransaction(h)); -} -``` \ No newline at end of file +Do not edit applied migration SQL. Replay skips matching versions, and changed SQL is reported as migration drift when the previous SQL was recorded. The test suite covers replay, atomic rollback, baseline adoption, incremental upgrades, and drift detection. diff --git a/docs/usage.md b/docs/usage.md index e3d6ea5..b012e8a 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -1,6 +1,6 @@ -## Usage Examples +# Usage -This repository now contains **two** database modules. Use the matching usage guide: - -- PostgreSQL: `docs/usage-postgresql.md` -- SQLite: `docs/usage-sqlite.md` +- [PostgreSQL](usage-postgresql.md) +- [SQLite](usage-sqlite.md) +- [DuckDB](usage-duckdb.md) +- [Buildable SQLite notes client](../examples/notes-client) diff --git a/examples/de.drremote.db.duckdb.cfg.example b/examples/de.drremote.db.duckdb.cfg.example new file mode 100644 index 0000000..7321507 --- /dev/null +++ b/examples/de.drremote.db.duckdb.cfg.example @@ -0,0 +1,6 @@ +# Copy to KARAF_HOME/etc/de.drremote.db.duckdb.cfg before installing the feature. +jdbcUrl=jdbc:duckdb:${karaf.data}/drremote.duckdb +poolName=db-duckdb-pool +maxPoolSize=1 +minIdle=1 +readOnly=false diff --git a/examples/de.drremote.db.postgresql.cfg.example b/examples/de.drremote.db.postgresql.cfg.example new file mode 100644 index 0000000..36f8570 --- /dev/null +++ b/examples/de.drremote.db.postgresql.cfg.example @@ -0,0 +1,11 @@ +# Copy to KARAF_HOME/etc/de.drremote.db.postgresql.cfg and replace the placeholders. +# PostgreSQL must already be running and the database/user must exist. +jdbcUrl=jdbc:postgresql://localhost:5432/appdb +username=app_user +password=REPLACE_WITH_YOUR_DATABASE_PASSWORD +poolName=db-postgresql-pool +maxPoolSize=10 +minIdle=1 +migrationsEnabled=true +psqlExecutable=psql +psqlConnectTimeoutSeconds=30 diff --git a/examples/de.drremote.db.sqlite.cfg.example b/examples/de.drremote.db.sqlite.cfg.example new file mode 100644 index 0000000..85b0c53 --- /dev/null +++ b/examples/de.drremote.db.sqlite.cfg.example @@ -0,0 +1,7 @@ +# Copy to KARAF_HOME/etc/de.drremote.db.sqlite.cfg before installing the feature. +jdbcUrl=jdbc:sqlite:${karaf.data}/drremote.sqlite +poolName=db-sqlite-pool +maxPoolSize=1 +minIdle=1 +busyTimeoutMs=5000 +foreignKeys=true diff --git a/examples/notes-client/README.md b/examples/notes-client/README.md new file mode 100644 index 0000000..3a343b8 --- /dev/null +++ b/examples/notes-client/README.md @@ -0,0 +1,21 @@ +# SQLite notes client + +This small OSGi bundle demonstrates a migration descriptor, `DatabaseReady` activation ordering, and parameterized queries through `TransactionManager`. + +From the main repository root: + +```sh +mvn clean install +mvn -f examples/notes-client/pom.xml clean package +``` + +Install/configure the SQLite feature using the [main quick start](../../README.md#quick-start-sqlite-in-karaf), then install this bundle: + +```text +bundle:install -s file:/absolute/path/to/checkout/examples/notes-client/target/notes-client-1.1.0-SNAPSHOT.jar +service:list example.notes.NotesRepository +``` + +[NotesMigrations](src/main/java/example/notes/NotesMigrations.java) creates the table as a versioned migration. [NotesRepository](src/main/java/example/notes/NotesRepository.java) exposes `add(String)` and `list()` to other OSGi components. The service becomes available after migration, but installing the example does not insert sample notes or start a web interface. + +The example consumes `de.drremote.db:sqlite:1.1.0-SNAPSHOT` from the local Maven repository created by the root build. It does not require any private infrastructure. diff --git a/examples/notes-client/pom.xml b/examples/notes-client/pom.xml new file mode 100644 index 0000000..58da4b7 --- /dev/null +++ b/examples/notes-client/pom.xml @@ -0,0 +1,36 @@ + + + 4.0.0 + de.drremote.db.examples + notes-client + 1.1.0-SNAPSHOT + bundle + DrRemote DB SQLite notes example + + UTF-8 + 17 + + + de.drremote.dbsqlite1.1.0-SNAPSHOTprovided + org.osgiorg.osgi.service.component.annotations1.5.1provided + + + org.apache.maven.pluginsmaven-clean-plugin3.4.1 + org.apache.maven.pluginsmaven-resources-plugin3.3.1 + org.apache.maven.pluginsmaven-surefire-plugin3.2.5 + org.apache.maven.pluginsmaven-install-plugin3.1.3 + org.apache.maven.pluginsmaven-deploy-plugin3.1.3 + + org.apache.maven.pluginsmaven-compiler-plugin3.13.0 + + org.apache.felixmaven-bundle-plugin5.1.9true + + de.drremote.db.examples.notes + example.notes + JavaSE-17 + <_dsannotations>* + + + + diff --git a/examples/notes-client/src/main/java/example/notes/NotesMigrations.java b/examples/notes-client/src/main/java/example/notes/NotesMigrations.java new file mode 100644 index 0000000..380a962 --- /dev/null +++ b/examples/notes-client/src/main/java/example/notes/NotesMigrations.java @@ -0,0 +1,22 @@ +package example.notes; + +import de.drremote.db.sqlite.api.Migration; +import de.drremote.db.sqlite.api.PluginMigrationDescriptor; +import org.osgi.service.component.annotations.Component; + +import java.util.List; + +@Component(service = PluginMigrationDescriptor.class) +public final class NotesMigrations implements PluginMigrationDescriptor { + @Override public String getPluginName() { return "notes"; } + + @Override public List getMigrations() { + return List.of(new Migration() { + @Override public int getVersion() { return 1; } + @Override public String getDescription() { return "Create notes"; } + @Override public String getSql() { + return "CREATE TABLE notes (id INTEGER PRIMARY KEY, text TEXT NOT NULL)"; + } + }); + } +} diff --git a/examples/notes-client/src/main/java/example/notes/NotesRepository.java b/examples/notes-client/src/main/java/example/notes/NotesRepository.java new file mode 100644 index 0000000..b1632df --- /dev/null +++ b/examples/notes-client/src/main/java/example/notes/NotesRepository.java @@ -0,0 +1,26 @@ +package example.notes; + +import de.drremote.db.sqlite.api.DatabaseReady; +import de.drremote.db.sqlite.api.TransactionManager; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; + +import java.util.List; + +@Component(service = NotesRepository.class) +public final class NotesRepository { + @Reference private TransactionManager transactions; + @Reference(target = "(drremote.db.plugin=notes)") private DatabaseReady ready; + + public void add(String text) { + transactions.inTransaction(db -> { + db.getQueryRunner().update("INSERT INTO notes(text) VALUES (?)", text); + return null; + }); + } + + public List list() { + return transactions.inTransaction(db -> db.getQueryRunner().query( + "SELECT text FROM notes ORDER BY id", row -> row.getString("text"))); + } +} diff --git a/pom.xml b/pom.xml index 8dd9899..f8f9fc6 100644 --- a/pom.xml +++ b/pom.xml @@ -1,21 +1,81 @@ - - 4.0.0 - de.drremote db-parent - 1.0.0 + 1.1.0-SNAPSHOT pom - - DrRemote db Parent + DrRemote DB + PostgreSQL, SQLite, and DuckDB services for modular OSGi applications. + https://github.com/DoctorRemoteControl/drremote-db-postgresql-sqlite + Apache License, Version 2.0https://www.apache.org/licenses/LICENSE-2.0.txt + + scm:git:https://github.com/DoctorRemoteControl/drremote-db-postgresql-sqlite.git + https://github.com/DoctorRemoteControl/drremote-db-postgresql-sqlite + HEAD + + + UTF-8 + 17 + 7.0.2 + 42.7.8 + 3.50.3.0 + 1.5.3.0 + 8.0.0 + 1.5.1 + 5.10.3 + 4.4.11 + de.drremote.db.postgresql de.drremote.db.postgresql.features de.drremote.db.sqlite de.drremote.db.sqlite.features + de.drremote.db.duckdb + de.drremote.db.duckdb.features - + + org.osgiosgi.core${osgi.core.version} + org.osgiorg.osgi.service.component.annotations${osgi.ds.version} + com.zaxxerHikariCP${hikari.version} + org.postgresqlpostgresql${postgres.version} + org.xerialsqlite-jdbc${sqlite.version} + org.duckdbduckdb_jdbc${duckdb.version} + org.junit.jupiterjunit-jupiter${junit.version} + + + + org.apache.maven.pluginsmaven-clean-plugin3.4.1 + org.apache.maven.pluginsmaven-resources-plugin3.3.1 + org.apache.maven.pluginsmaven-jar-plugin3.4.2 + org.apache.maven.pluginsmaven-install-plugin3.1.3 + org.apache.maven.pluginsmaven-deploy-plugin3.1.3 + org.apache.karaf.toolingkaraf-maven-plugin${karaf.version} + + org.apache.felixmaven-bundle-plugin5.1.9 + + ${project.groupId}.${project.artifactId} + ${project.name} + JavaSE-17 + ${project.groupId}.${project.artifactId}.api;version="${project.version}" + !org.osgi.service.component.annotations,* + <_dsannotations>* + + + + org.apache.maven.pluginsmaven-source-plugin3.3.1 + attach-sourcesjar-no-fork + + + org.apache.maven.pluginsmaven-javadoc-plugin3.10.1 + none + attach-javadocsjar + + + + org.apache.maven.pluginsmaven-compiler-plugin3.13.0 + org.apache.maven.pluginsmaven-surefire-plugin3.2.5 + +