Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ jobs:
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: Build the JSON example
run: mvn --batch-mode --no-transfer-progress -f examples/json-client/pom.xml verify
- name: Upload bundles, feature descriptors, and example
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
Expand All @@ -55,6 +57,7 @@ jobs:
de.drremote.db.*/target/*.jar
de.drremote.db.*.features/target/feature/feature.xml
examples/notes-client/target/*.jar
examples/json-client/target/*.jar
- name: Upload test reports
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

## 1.1.0-SNAPSHOT — unreleased

- Add the optional `de.drremote.db:json` adapter and `json-features` Karaf feature.
Query rows as JSON, bind JSON values, and explicitly parse JSON text columns
inside existing transactions. Existing database cores retain their APIs and
dependencies. Includes PostgreSQL/SQLite/DuckDB integration tests and a buildable
JSON settings client.

- 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.
Expand Down
3 changes: 2 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,14 @@ Requirements: JDK 17+ and Maven 3.9+.
mvn clean verify
mvn install
mvn -f examples/notes-client/pom.xml verify
mvn -f examples/json-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.
Karaf's Maven plugin verifies all four 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. The optional JSON module adds real-driver mapping/parameter tests for all three databases, with its PostgreSQL tests using the same opt-in environment variables.

For a clean-room check, create an empty settings file and choose a fresh cache directory:

Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,23 @@ service:list example.notes.NotesRepository

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.

## Optional JSON support

Use the separate JSON adapter inside any existing transaction callback:

```java
ArrayNode rows = transactions.inTransaction(db -> JsonQueries.on(db.getConnection())
.withJsonColumns("payload")
.query("SELECT id, payload FROM settings WHERE id = ?", id));
```

It converts result rows to JSON and binds `JsonNode` / `JsonValue` parameters,
including PostgreSQL JSONB and SQLite/DuckDB JSON storage. Existing database
cores keep their current APIs and have no Jackson dependency. Install the
optional `de.drremote-db-json` feature when using it in Karaf.

See the [JSON guide](docs/usage-json.md) and [buildable JSON client](examples/json-client).

## Other databases

| Database | Example configuration | Karaf feature repository | Feature |
Expand Down Expand Up @@ -97,6 +114,7 @@ Use a `provided` dependency in an OSGi client; the runtime installs the database
- 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)
- [JSON query results and parameters](docs/usage-json.md)
- [Changes and upgrade notes](CHANGELOG.md)
- [Contributing and validation](CONTRIBUTING.md)

Expand Down
25 changes: 25 additions & 0 deletions de.drremote.db.json.features/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent><groupId>de.drremote</groupId><artifactId>db-parent</artifactId><version>1.1.0-SNAPSHOT</version></parent>
<groupId>de.drremote.db</groupId><artifactId>json-features</artifactId><packaging>feature</packaging>
<name>DrRemote DB json Karaf features</name>
<dependencies>
<dependency><groupId>de.drremote.db</groupId><artifactId>json</artifactId><version>${project.version}</version></dependency>
</dependencies>
<build><plugins>
<plugin><groupId>org.apache.karaf.tooling</groupId><artifactId>karaf-maven-plugin</artifactId><extensions>true</extensions>
<configuration>
<enableGeneration>true</enableGeneration>
<primaryFeatureName>de.drremote-db-json</primaryFeatureName>
<includeTransitiveDependency>false</includeTransitiveDependency>
<descriptors><descriptor>file:${project.build.directory}/feature/feature.xml</descriptor></descriptors>
<features><feature>de.drremote-db-json</feature></features>
<framework><feature>framework</feature></framework>
<karafVersion>${karaf.version}</karafVersion>
<javase>${maven.compiler.release}</javase>
</configuration>
</plugin>
</plugins></build>
</project>
10 changes: 10 additions & 0 deletions de.drremote.db.json.features/src/main/feature/feature.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<features xmlns="http://karaf.apache.org/xmlns/features/v1.6.0" name="drremote-db-json-${project.version}">
<feature name="de.drremote-db-json" version="${project.version}">
<details>Optional JSON results and parameters on existing JDBC transaction connections</details>
<bundle>mvn:com.fasterxml.jackson.core/jackson-annotations/${jackson.annotations.version}</bundle>
<bundle>mvn:com.fasterxml.jackson.core/jackson-core/${jackson.version}</bundle>
<bundle>mvn:com.fasterxml.jackson.core/jackson-databind/${jackson.version}</bundle>
<bundle>mvn:de.drremote.db/json/${project.version}</bundle>
</feature>
</features>
18 changes: 18 additions & 0 deletions de.drremote.db.json/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# DrRemote DB — optional JSON adapter

`de.drremote.db:json:1.1.0-SNAPSHOT` provides JSON query results and JSON parameters
on a caller-owned JDBC connection. Its API is `de.drremote.db.json.api`:

- `JsonQueries.on(connection)` — query, queryOne, and update within your transaction.
- `withJsonColumns(...)` — explicitly parse JSON returned in text columns.
- `JsonValue` — validated JSON parameter snapshots.
- `JsonDbException` — JDBC/mapping failures with their causes.

It uses Jackson 2.22.4 (annotations 2.22), Java 17+, and no database driver at
runtime. Existing database cores remain usable without it.

See the [usage guide](../docs/usage-json.md), [buildable client](../examples/json-client),
and [main README](../README.md). For Karaf, install `de.drremote-db-json` from
`de.drremote.db:json-features:1.1.0-SNAPSHOT` after building from source.

Licensed under [Apache-2.0](../LICENSE).
21 changes: 21 additions & 0 deletions de.drremote.db.json/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent><groupId>de.drremote</groupId><artifactId>db-parent</artifactId><version>1.1.0-SNAPSHOT</version></parent>
<groupId>de.drremote.db</groupId><artifactId>json</artifactId><packaging>bundle</packaging>
<name>DrRemote DB optional JSON adapter</name>
<description>JSON query results and parameters on caller-owned JDBC connections.</description>
<dependencies>
<dependency><groupId>com.fasterxml.jackson.core</groupId><artifactId>jackson-databind</artifactId></dependency>
<dependency><groupId>org.xerial</groupId><artifactId>sqlite-jdbc</artifactId><scope>test</scope></dependency>
<dependency><groupId>org.duckdb</groupId><artifactId>duckdb_jdbc</artifactId><scope>test</scope></dependency>
<dependency><groupId>org.postgresql</groupId><artifactId>postgresql</artifactId><scope>test</scope></dependency>
<dependency><groupId>org.junit.jupiter</groupId><artifactId>junit-jupiter</artifactId><scope>test</scope></dependency>
</dependencies>
<build><plugins>
<plugin><groupId>org.apache.felix</groupId><artifactId>maven-bundle-plugin</artifactId><extensions>true</extensions></plugin>
<plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-source-plugin</artifactId></plugin>
<plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-javadoc-plugin</artifactId></plugin>
</plugins></build>
</project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package de.drremote.db.json.api;

/** An error executing a JSON query or converting its JDBC result. */
public final class JsonDbException extends RuntimeException {
private static final long serialVersionUID = 1L;

public JsonDbException(String message) {
super(message);
}

public JsonDbException(String message, Throwable cause) {
super(message, cause);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package de.drremote.db.json.api;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
import com.fasterxml.jackson.databind.node.ObjectNode;
import de.drremote.db.json.impl.JdbcJsonMapper;

import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Types;
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;

/**
* JSON query results and parameters on a caller-owned JDBC connection.
* Use inside an existing transaction callback. This helper never closes,
* commits, rolls back, or changes settings on its connection. It is confined
* to the same thread/lifetime as that connection.
*/
public final class JsonQueries {
private final Connection connection;
private final Set<String> jsonColumns;

private JsonQueries(Connection connection, Set<String> jsonColumns) {
this.connection = connection;
this.jsonColumns = jsonColumns;
}

/** Creates an adapter without taking ownership of the connection. */
public static JsonQueries on(Connection connection) {
return new JsonQueries(Objects.requireNonNull(connection, "connection"), Set.of());
}

/**
* Returns a new helper that additionally parses these exact result labels
* as JSON. Native JSON/JSONB columns are recognized without configuration.
*/
public JsonQueries withJsonColumns(String... labels) {
Objects.requireNonNull(labels, "labels");
Set<String> selected = new HashSet<>(jsonColumns);
for (String label : labels) {
if (label == null || label.isBlank()) throw new IllegalArgumentException("JSON column label must not be blank");
selected.add(label);
}
return new JsonQueries(connection, Set.copyOf(selected));
}

/** Returns one JSON object per row, using JDBC column labels as keys. */
public ArrayNode query(String sql, Object... parameters) {
try (PreparedStatement statement = prepare(sql, parameters);
ResultSet rows = statement.executeQuery()) {
JdbcJsonMapper mapper = new JdbcJsonMapper(rows.getMetaData(), jsonColumns);
ArrayNode result = JsonNodeFactory.instance.arrayNode();
while (rows.next()) result.add(mapper.read(rows));
return result;
} catch (SQLException failure) {
throw new JsonDbException("Failed to execute JSON query", failure);
}
}

/** Returns null for no row and rejects a result with more than one row. */
public ObjectNode queryOne(String sql, Object... parameters) {
try (PreparedStatement statement = prepare(sql, parameters);
ResultSet rows = statement.executeQuery()) {
JdbcJsonMapper mapper = new JdbcJsonMapper(rows.getMetaData(), jsonColumns);
if (!rows.next()) return null;
ObjectNode result = mapper.read(rows);
if (rows.next()) throw new JsonDbException("Expected at most one JSON row");
return result;
} catch (SQLException failure) {
throw new JsonDbException("Failed to execute single-row JSON query", failure);
}
}

/** Executes a prepared update and returns its JDBC affected-row count. */
public int update(String sql, Object... parameters) {
try (PreparedStatement statement = prepare(sql, parameters)) {
return statement.executeUpdate();
} catch (SQLException failure) {
throw new JsonDbException("Failed to execute JSON update", failure);
}
}

private PreparedStatement prepare(String sql, Object[] parameters) throws SQLException {
Objects.requireNonNull(sql, "sql");
if (sql.isBlank()) throw new IllegalArgumentException("SQL must not be blank");
PreparedStatement statement = connection.prepareStatement(sql);
try {
Boolean postgresql = null;
if (parameters != null) {
for (int i = 0; i < parameters.length; i++) {
Object value = parameters[i];
if (value instanceof JsonNode node) value = JsonValue.of(node);
if (value instanceof JsonValue json) {
if (postgresql == null) {
postgresql = "PostgreSQL".equalsIgnoreCase(connection.getMetaData().getDatabaseProductName());
}
if (postgresql) statement.setObject(i + 1, json.json(), Types.OTHER);
else statement.setString(i + 1, json.json());
} else {
statement.setObject(i + 1, value);
}
}
}
return statement;
} catch (SQLException | RuntimeException | Error failure) {
try {
statement.close();
} catch (SQLException closeFailure) {
if (closeFailure != failure) failure.addSuppressed(closeFailure);
}
throw failure;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package de.drremote.db.json.api;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import de.drremote.db.json.impl.JsonCodec;

import java.util.Objects;

/**
* An immutable JSON parameter snapshot. Java null parameters mean SQL NULL;
* a snapshot of the JSON literal null is a non-null JSON value.
*/
public final class JsonValue {
private final String json;

private JsonValue(String json) {
this.json = json;
}

/**
* Serializes a snapshot so subsequent changes cannot affect it. Non-finite
* numbers, missing nodes, and embedded Java objects are rejected throughout
* the tree. Jackson's nesting limit also rejects cycles/overly deep input.
*/
public static JsonValue of(JsonNode node) {
Objects.requireNonNull(node, "node");
if (node.isMissingNode()) throw new IllegalArgumentException("MissingNode is not a JSON value");
try {
return new JsonValue(JsonCodec.serialize(node));
} catch (JsonProcessingException failure) {
throw new IllegalArgumentException("Cannot serialize JSON value", failure);
}
}

/** Validates exactly one complete JSON value and snapshots its serialization. */
public static JsonValue parse(String text) {
Objects.requireNonNull(text, "text");
try {
return of(JsonCodec.parse(text));
} catch (JsonProcessingException failure) {
throw new IllegalArgumentException("Invalid JSON value", failure);
}
}

/** Returns the validated, serialized JSON value. */
public String json() {
return json;
}
}
Loading
Loading