diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7b15daa..7406af8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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: @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 674411c..60ce75a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 262c806..6494c87 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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: diff --git a/README.md b/README.md index 8f60f98..270b43a 100644 --- a/README.md +++ b/README.md @@ -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 | @@ -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) diff --git a/de.drremote.db.json.features/pom.xml b/de.drremote.db.json.features/pom.xml new file mode 100644 index 0000000..5d3bb3e --- /dev/null +++ b/de.drremote.db.json.features/pom.xml @@ -0,0 +1,25 @@ + + + 4.0.0 + de.drremotedb-parent1.1.0-SNAPSHOT + de.drremote.dbjson-featuresfeature + DrRemote DB json Karaf features + + de.drremote.dbjson${project.version} + + + org.apache.karaf.toolingkaraf-maven-plugintrue + + true + de.drremote-db-json + false + file:${project.build.directory}/feature/feature.xml + de.drremote-db-json + framework + ${karaf.version} + ${maven.compiler.release} + + + + diff --git a/de.drremote.db.json.features/src/main/feature/feature.xml b/de.drremote.db.json.features/src/main/feature/feature.xml new file mode 100644 index 0000000..55b8774 --- /dev/null +++ b/de.drremote.db.json.features/src/main/feature/feature.xml @@ -0,0 +1,10 @@ + + + +
Optional JSON results and parameters on existing JDBC transaction connections
+ mvn:com.fasterxml.jackson.core/jackson-annotations/${jackson.annotations.version} + mvn:com.fasterxml.jackson.core/jackson-core/${jackson.version} + mvn:com.fasterxml.jackson.core/jackson-databind/${jackson.version} + mvn:de.drremote.db/json/${project.version} +
+
diff --git a/de.drremote.db.json/README.md b/de.drremote.db.json/README.md new file mode 100644 index 0000000..95c344b --- /dev/null +++ b/de.drremote.db.json/README.md @@ -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). diff --git a/de.drremote.db.json/pom.xml b/de.drremote.db.json/pom.xml new file mode 100644 index 0000000..21b03e7 --- /dev/null +++ b/de.drremote.db.json/pom.xml @@ -0,0 +1,21 @@ + + + 4.0.0 + de.drremotedb-parent1.1.0-SNAPSHOT + de.drremote.dbjsonbundle + DrRemote DB optional JSON adapter + JSON query results and parameters on caller-owned JDBC connections. + + com.fasterxml.jackson.corejackson-databind + org.xerialsqlite-jdbctest + org.duckdbduckdb_jdbctest + org.postgresqlpostgresqltest + 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.json/src/main/java/de/drremote/db/json/api/JsonDbException.java b/de.drremote.db.json/src/main/java/de/drremote/db/json/api/JsonDbException.java new file mode 100644 index 0000000..47d9621 --- /dev/null +++ b/de.drremote.db.json/src/main/java/de/drremote/db/json/api/JsonDbException.java @@ -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); + } +} diff --git a/de.drremote.db.json/src/main/java/de/drremote/db/json/api/JsonQueries.java b/de.drremote.db.json/src/main/java/de/drremote/db/json/api/JsonQueries.java new file mode 100644 index 0000000..23029c2 --- /dev/null +++ b/de.drremote.db.json/src/main/java/de/drremote/db/json/api/JsonQueries.java @@ -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 jsonColumns; + + private JsonQueries(Connection connection, Set 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 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; + } + } +} diff --git a/de.drremote.db.json/src/main/java/de/drremote/db/json/api/JsonValue.java b/de.drremote.db.json/src/main/java/de/drremote/db/json/api/JsonValue.java new file mode 100644 index 0000000..0c7dc6f --- /dev/null +++ b/de.drremote.db.json/src/main/java/de/drremote/db/json/api/JsonValue.java @@ -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; + } +} diff --git a/de.drremote.db.json/src/main/java/de/drremote/db/json/impl/JdbcJsonMapper.java b/de.drremote.db.json/src/main/java/de/drremote/db/json/impl/JdbcJsonMapper.java new file mode 100644 index 0000000..a9a677e --- /dev/null +++ b/de.drremote.db.json/src/main/java/de/drremote/db/json/impl/JdbcJsonMapper.java @@ -0,0 +1,154 @@ +package de.drremote.db.json.impl; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; +import de.drremote.db.json.api.JsonDbException; + +import java.lang.reflect.Array; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.sql.Blob; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.SQLException; +import java.sql.SQLFeatureNotSupportedException; +import java.sql.Types; +import java.time.OffsetTime; +import java.time.temporal.TemporalAccessor; +import java.util.Base64; +import java.util.HashSet; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; + +/** Internal mapping plan, validated once for a result set. */ +public final class JdbcJsonMapper { + private static final JsonNodeFactory NODES = JsonNodeFactory.instance; + private final Column[] columns; + + private record Column(String label, String typeName, int type, boolean json) {} + + @FunctionalInterface + private interface SqlResource extends AutoCloseable { + @Override void close() throws SQLException; + } + + public JdbcJsonMapper(ResultSetMetaData metadata, Set selectedJson) throws SQLException { + columns = new Column[metadata.getColumnCount()]; + Set seen = new HashSet<>(); + for (int i = 0; i < columns.length; i++) { + String label = metadata.getColumnLabel(i + 1); + if (label == null || label.isBlank()) throw new JsonDbException("Blank result column label; supply an SQL alias"); + if (!seen.add(label)) throw new JsonDbException("Duplicate result column label: " + label); + columns[i] = metadata(metadata, i + 1, label, selectedJson.contains(label)); + } + for (String label : selectedJson) { + if (!seen.contains(label)) throw new JsonDbException("JSON column label is missing from result: " + label); + } + } + + private Column metadata(ResultSetMetaData metadata, int index, String label, boolean selected) throws SQLException { + String name = Objects.toString(metadata.getColumnTypeName(index), ""); + return new Column(label, name, metadata.getColumnType(index), selected + || "json".equalsIgnoreCase(name) || "jsonb".equalsIgnoreCase(name)); + } + + public ObjectNode read(ResultSet rows) throws SQLException { + ObjectNode object = NODES.objectNode(); + for (int i = 0; i < columns.length; i++) object.set(columns[i].label(), column(rows, i + 1, columns[i])); + return object; + } + + private JsonNode column(ResultSet rows, int index, Column column) throws SQLException { + if (column.json()) { + String text = rows.getString(index); + if (text == null) return NODES.nullNode(); + try { + return JsonCodec.parse(text); + } catch (JsonProcessingException | IllegalArgumentException failure) { + throw new JsonDbException("Invalid JSON in column '" + column.label() + "'", failure); + } + } + // SQLite result metadata can describe the first row rather than every value. + Object value = rows.getObject(index); + if (value == null) return NODES.nullNode(); + boolean bool = column.type() == Types.BOOLEAN || "BOOLEAN".equalsIgnoreCase(column.typeName()) + || "BOOL".equalsIgnoreCase(column.typeName()); + if (bool && (value instanceof Byte || value instanceof Short || value instanceof Integer || value instanceof Long)) { + long number = ((Number) value).longValue(); + if (number == 0 || number == 1) return NODES.booleanNode(number == 1); + } + if (value instanceof java.sql.Timestamp timestamp) { + boolean zoned = column.type() == Types.TIMESTAMP_WITH_TIMEZONE + || "timestamptz".equalsIgnoreCase(column.typeName()) + || "TIMESTAMP WITH TIME ZONE".equalsIgnoreCase(column.typeName()); + return NODES.textNode(zoned ? timestamp.toInstant().toString() : timestamp.toLocalDateTime().toString()); + } + if (column.type() == Types.TIME_WITH_TIMEZONE || value instanceof java.sql.Time + && ("timetz".equalsIgnoreCase(column.typeName()) || "TIME WITH TIME ZONE".equalsIgnoreCase(column.typeName()))) { + return NODES.textNode(rows.getObject(index, OffsetTime.class).toString()); + } + if (value instanceof java.sql.Time) { + // java.sql.Time drops fractional seconds; the JDBC text retains them. + return NODES.textNode(rows.getString(index)); + } + return value(value, column.label()); + } + + private JsonNode sqlArray(java.sql.Array array, String label) throws SQLException { + try (SqlResource free = array::free; ResultSet elements = array.getResultSet()) { + Column element; + try { + element = metadata(elements.getMetaData(), 2, label + "[]", false); + } catch (SQLFeatureNotSupportedException unsupported) { + // DuckDB exposes array element types on Array rather than its ResultSet. + String name = Objects.toString(array.getBaseTypeName(), ""); + element = new Column(label + "[]", name, array.getBaseType(), + "json".equalsIgnoreCase(name) || "jsonb".equalsIgnoreCase(name)); + } + var result = NODES.arrayNode(); + while (elements.next()) result.add(column(elements, 2, element)); + return result; + } + } + + private JsonNode binary(Blob blob, String label) throws SQLException { + try (SqlResource free = blob::free) { + long length = blob.length(); + if (length > Integer.MAX_VALUE) throw new JsonDbException("Binary value too large to materialize in column '" + label + "'"); + return NODES.textNode(Base64.getEncoder().encodeToString(blob.getBytes(1, (int) length))); + } + } + + private JsonNode value(Object value, String label) throws SQLException { + if (value == null) return NODES.nullNode(); + if (value instanceof String text) return NODES.textNode(text); + if (value instanceof Boolean bool) return NODES.booleanNode(bool); + if (value instanceof Byte || value instanceof Short || value instanceof Integer) return NODES.numberNode(((Number) value).intValue()); + if (value instanceof Long number) return NODES.numberNode(number); + if (value instanceof BigInteger number) return NODES.numberNode(number); + if (value instanceof BigDecimal number) return NODES.numberNode(number); + if (value instanceof Double number) { + if (!Double.isFinite(number)) throw new JsonDbException("Non-finite number in column '" + label + "'"); + return NODES.numberNode(number); + } + if (value instanceof Float number) { + if (!Float.isFinite(number)) throw new JsonDbException("Non-finite number in column '" + label + "'"); + return NODES.numberNode(number); + } + if (value instanceof java.sql.Date date) return NODES.textNode(date.toLocalDate().toString()); + if (value instanceof TemporalAccessor || value instanceof UUID) return NODES.textNode(value.toString()); + if (value instanceof byte[] bytes) return NODES.textNode(Base64.getEncoder().encodeToString(bytes)); + if (value instanceof Blob blob) return binary(blob, label); + if (value instanceof java.sql.Array array) return sqlArray(array, label); + if (value.getClass().isArray()) { + var result = NODES.arrayNode(); + for (int i = 0; i < Array.getLength(value); i++) result.add(value(Array.get(value, i), label)); + return result; + } + throw new JsonDbException("Unsupported JDBC value " + value.getClass().getName() + + " in column '" + label + "'; select an explicit SQL cast"); + } +} diff --git a/de.drremote.db.json/src/main/java/de/drremote/db/json/impl/JsonCodec.java b/de.drremote.db.json/src/main/java/de/drremote/db/json/impl/JsonCodec.java new file mode 100644 index 0000000..e14695d --- /dev/null +++ b/de.drremote.db.json/src/main/java/de/drremote/db/json/impl/JsonCodec.java @@ -0,0 +1,61 @@ +package de.drremote.db.json.impl; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.io.IOException; + +/** Internal shared codec, configured once and never exposed for mutation. */ +public final class JsonCodec { + private static final ObjectMapper MAPPER = new ObjectMapper() + .enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS) + .enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS); + + private JsonCodec() {} + + public static JsonNode parse(String text) throws JsonProcessingException { + JsonNode node = MAPPER.readTree(text); + if (node == null || node.isMissingNode()) { + throw new IllegalArgumentException("Expected a complete JSON value"); + } + return node; + } + + public static String serialize(JsonNode node) throws JsonProcessingException { + validate(node); + return MAPPER.writeValueAsString(node); + } + + private static void validate(JsonNode node) { + // Walk tokens without recursive Java calls; the depth limit also bounds cycles. + int depth = 0; + int maxDepth = MAPPER.getFactory().streamWriteConstraints().getMaxNestingDepth(); + try (JsonParser parser = node.traverse()) { + JsonToken token; + while ((token = parser.nextToken()) != null) { + if (token == JsonToken.START_OBJECT || token == JsonToken.START_ARRAY) { + if (++depth > maxDepth) throw new IllegalArgumentException("JSON node nesting exceeds " + maxDepth); + } else if (token == JsonToken.END_OBJECT || token == JsonToken.END_ARRAY) { + depth--; + } else if (token == JsonToken.NOT_AVAILABLE) { + throw new IllegalArgumentException("MissingNode is not a JSON value"); + } else if (token == JsonToken.VALUE_NUMBER_FLOAT) { + JsonParser.NumberType type = parser.getNumberType(); + if ((type == JsonParser.NumberType.DOUBLE || type == JsonParser.NumberType.FLOAT) + && !Double.isFinite(parser.getDoubleValue())) { + throw new IllegalArgumentException("Non-finite numbers are not JSON values"); + } + } else if (token == JsonToken.VALUE_EMBEDDED_OBJECT + && !(parser.getEmbeddedObject() instanceof byte[])) { + throw new IllegalArgumentException("Embedded Java objects are not JSON values; convert them to a JSON tree first"); + } + } + } catch (IOException failure) { + throw new IllegalArgumentException("Cannot validate JSON node", failure); + } + } +} diff --git a/de.drremote.db.json/src/test/java/de/drremote/db/json/JsonQueriesDuckDbTest.java b/de.drremote.db.json/src/test/java/de/drremote/db/json/JsonQueriesDuckDbTest.java new file mode 100644 index 0000000..9192560 --- /dev/null +++ b/de.drremote.db.json/src/test/java/de/drremote/db/json/JsonQueriesDuckDbTest.java @@ -0,0 +1,111 @@ +package de.drremote.db.json; + +import de.drremote.db.json.api.JsonDbException; +import de.drremote.db.json.api.JsonQueries; +import de.drremote.db.json.api.JsonValue; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.sql.Connection; +import java.sql.DriverManager; +import java.time.Instant; +import java.time.OffsetDateTime; + +import static org.junit.jupiter.api.Assertions.*; + +class JsonQueriesDuckDbTest { + private Connection connection; + private JsonQueries json; + + @BeforeEach + void setUp() throws Exception { + connection = DriverManager.getConnection("jdbc:duckdb:"); + json = JsonQueries.on(connection); + json.update("CREATE TABLE document(id BIGINT, active BOOLEAN, amount DECIMAL(38,12), payload JSON)"); + } + + @AfterEach + void close() throws Exception { + connection.close(); + } + + @Test + void roundTripsNativeJsonAndExactNumbers() { + BigDecimal amount = new BigDecimal("123456789012345678.123456789012"); + json.update("INSERT INTO document VALUES (?, ?, ?, ?)", 9007199254740993L, true, amount, + JsonValue.parse("{\"tags\":[\"one\",\"two\"],\"amount\":0.12345678901234567890123456789}")); + var row = json.queryOne("SELECT * FROM document"); + assertEquals(9007199254740993L, row.path("id").longValue()); + assertTrue(row.path("active").isBoolean()); + assertTrue(row.path("active").booleanValue()); + assertEquals(0, amount.compareTo(row.path("amount").decimalValue())); + assertTrue(row.path("payload").isObject()); + assertEquals("two", row.path("payload").path("tags").get(1).textValue()); + assertEquals(new BigDecimal("0.12345678901234567890123456789"), + row.path("payload").path("amount").decimalValue()); + var huge = json.queryOne("SELECT CAST(123456789012345678901234567890 AS HUGEINT) AS huge"); + assertEquals(new BigInteger("123456789012345678901234567890"), huge.path("huge").bigIntegerValue()); + } + + @Test + void keepsJsonNullSeparateFromSqlNull() { + json.update("INSERT INTO document(id, payload) VALUES (?, ?)", 1, JsonValue.parse("null")); + json.update("INSERT INTO document(id, payload) VALUES (?, ?)", 2, (Object) null); + var rows = json.query("SELECT payload, payload IS NULL AS sql_null FROM document ORDER BY id"); + assertTrue(rows.get(0).path("payload").isNull()); + assertFalse(rows.get(0).path("sql_null").booleanValue()); + assertTrue(rows.get(1).path("sql_null").booleanValue()); + } + + @Test + void mapsDatesTimesArraysUuidAndBinaryToJsonValues() { + var row = json.queryOne(""" + SELECT DATE '2026-11-01' AS day, + TIME '12:34:56.123456' AS clock, + TIMESTAMP '2026-11-01 12:34:56.123456' AS stamp, + TIMESTAMPTZ '2026-11-01 12:34:56+02:00' AS zoned, + [1, 2, NULL] AS numbers, + UUID '123e4567-e89b-12d3-a456-426614174000' AS identifier, + 'abc'::BLOB AS binary + """); + assertEquals("2026-11-01", row.path("day").textValue()); + assertEquals("12:34:56.123456", row.path("clock").textValue()); + assertEquals("2026-11-01T12:34:56.123456", row.path("stamp").textValue()); + assertEquals(Instant.parse("2026-11-01T10:34:56Z"), OffsetDateTime.parse(row.path("zoned").textValue()).toInstant()); + assertEquals(2, row.path("numbers").get(1).intValue()); + assertTrue(row.path("numbers").get(2).isNull()); + assertEquals("123e4567-e89b-12d3-a456-426614174000", row.path("identifier").textValue()); + assertTrue(row.path("binary").isTextual()); + assertEquals("YWJj", row.path("binary").textValue()); + } + + @Test + void rejectsNonFiniteNumbersInsteadOfEmittingInvalidJson() { + assertThrows(JsonDbException.class, () -> json.query("SELECT 'NaN'::DOUBLE AS value")); + assertThrows(JsonDbException.class, () -> json.query("SELECT 'Infinity'::DOUBLE AS value")); + } + + @Test + void participatesInTheExistingTransactionWithoutCommittingIt() throws Exception { + connection.setAutoCommit(false); + json.update("INSERT INTO document(id, payload) VALUES (?, ?)", 1, JsonValue.parse("{\"ok\":true}")); + assertFalse(connection.getAutoCommit()); + connection.rollback(); + assertEquals(0, json.queryOne("SELECT COUNT(*) AS count FROM document").path("count").intValue()); + assertFalse(connection.isClosed()); + } + + @Test + void temporalArrayElementsKeepTheirPrecisionAndInstant() { + var row = json.queryOne(""" + SELECT [TIME '12:34:56.123456'] AS clocks, + [TIMESTAMPTZ '2026-11-01 12:34:56.123456+02:00'] AS instants + """); + assertEquals("12:34:56.123456", row.path("clocks").get(0).textValue()); + assertEquals(Instant.parse("2026-11-01T10:34:56.123456Z"), + OffsetDateTime.parse(row.path("instants").get(0).textValue()).toInstant()); + } +} diff --git a/de.drremote.db.json/src/test/java/de/drremote/db/json/JsonQueriesPostgresTest.java b/de.drremote.db.json/src/test/java/de/drremote/db/json/JsonQueriesPostgresTest.java new file mode 100644 index 0000000..00cbb11 --- /dev/null +++ b/de.drremote.db.json/src/test/java/de/drremote/db/json/JsonQueriesPostgresTest.java @@ -0,0 +1,121 @@ +package de.drremote.db.json; + +import de.drremote.db.json.api.JsonDbException; +import de.drremote.db.json.api.JsonQueries; +import de.drremote.db.json.api.JsonValue; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; + +import java.math.BigDecimal; +import java.sql.Connection; +import java.sql.DriverManager; +import java.time.Instant; +import java.time.OffsetDateTime; + +import static org.junit.jupiter.api.Assertions.*; + +@EnabledIfEnvironmentVariable(named = "DRREMOTE_TEST_POSTGRES_URL", matches = ".+") +class JsonQueriesPostgresTest { + private Connection connection; + private JsonQueries json; + + @BeforeEach + void setUp() throws Exception { + connection = DriverManager.getConnection(System.getenv("DRREMOTE_TEST_POSTGRES_URL"), + System.getenv().getOrDefault("DRREMOTE_TEST_POSTGRES_USER", "postgres"), + System.getenv().getOrDefault("DRREMOTE_TEST_POSTGRES_PASSWORD", "postgres")); + json = JsonQueries.on(connection); + json.update("CREATE TEMP TABLE document(id BIGINT, active BOOLEAN, amount NUMERIC(38,12), payload JSONB, raw JSON)"); + } + + @AfterEach + void close() throws Exception { + if (connection != null) connection.close(); + } + + @Test + void bindsAndReadsNativeJsonAndJsonbWithoutDriverObjectsOrDoubleEncoding() { + var payload = JsonValue.parse("{\"tags\":[\"one\",\"two\"],\"enabled\":true}"); + BigDecimal amount = new BigDecimal("123456789012345678.123456789012"); + json.update("INSERT INTO document VALUES (?, ?, ?, ?, ?)", 9007199254740993L, true, amount, payload, payload); + var row = json.queryOne("SELECT id, active, amount, payload, raw, payload->'tags' AS tags, payload::text AS text FROM document"); + assertEquals(9007199254740993L, row.path("id").longValue()); + assertTrue(row.path("active").isBoolean()); + assertEquals(0, amount.compareTo(row.path("amount").decimalValue())); + assertTrue(row.path("payload").isObject()); + assertTrue(row.path("raw").isObject()); + assertTrue(row.path("tags").isArray()); + assertEquals("two", row.path("tags").get(1).textValue()); + assertTrue(row.path("text").isTextual()); + assertTrue(json.withJsonColumns("text").queryOne("SELECT payload::text AS text FROM document").path("text").isObject()); + } + + @Test + void distinguishesJsonNullFromSqlNull() { + json.update("INSERT INTO document(id, payload) VALUES (?, ?)", 1, JsonValue.parse("null")); + json.update("INSERT INTO document(id, payload) VALUES (?, ?)", 2, (Object) null); + var rows = json.query("SELECT payload, payload IS NULL AS sql_null FROM document ORDER BY id"); + assertTrue(rows.get(0).path("payload").isNull()); + assertFalse(rows.get(0).path("sql_null").booleanValue()); + assertTrue(rows.get(1).path("sql_null").booleanValue()); + } + + @Test + void preservesBigDecimalsDatesTimesArraysUuidAndBinary() { + var row = json.queryOne(""" + SELECT 123456789012345678901234567890::NUMERIC AS huge, + DATE '2026-11-01' AS day, + TIME '12:34:56.123456' AS clock, + TIMESTAMP '2026-11-01 12:34:56.123456' AS stamp, + TIMESTAMPTZ '2026-11-01 12:34:56+02:00' AS zoned, + ARRAY[1, 2, NULL] AS numbers, + UUID '123e4567-e89b-12d3-a456-426614174000' AS identifier, + 'abc'::BYTEA AS binary + """); + assertEquals(0, new BigDecimal("123456789012345678901234567890").compareTo(row.path("huge").decimalValue())); + assertEquals("2026-11-01", row.path("day").textValue()); + assertEquals("12:34:56.123456", row.path("clock").textValue()); + assertEquals("2026-11-01T12:34:56.123456", row.path("stamp").textValue()); + assertEquals(Instant.parse("2026-11-01T10:34:56Z"), OffsetDateTime.parse(row.path("zoned").textValue()).toInstant()); + assertEquals(2, row.path("numbers").get(1).intValue()); + assertTrue(row.path("numbers").get(2).isNull()); + assertEquals("123e4567-e89b-12d3-a456-426614174000", row.path("identifier").textValue()); + assertTrue(row.path("binary").isTextual()); + assertEquals("YWJj", row.path("binary").textValue()); + } + + @Test + void rejectsNonFiniteNumbers() { + assertThrows(JsonDbException.class, () -> json.query("SELECT 'NaN'::DOUBLE PRECISION AS value")); + assertThrows(JsonDbException.class, () -> json.query("SELECT 'Infinity'::DOUBLE PRECISION AS value")); + } + + @Test + void leavesRollbackAndConnectionOwnershipWithTheExistingTransaction() throws Exception { + connection.setAutoCommit(false); + json.update("INSERT INTO document(id, payload) VALUES (?, ?)", 1, JsonValue.parse("[1,true,null]")); + assertFalse(connection.getAutoCommit()); + connection.rollback(); + assertEquals(0, json.queryOne("SELECT COUNT(*) AS count FROM document").path("count").intValue()); + assertFalse(connection.isClosed()); + } + + @Test + void arrayElementsPreserveTimePrecisionTimezoneAndNativeJson() { + var row = json.queryOne(""" + SELECT ARRAY[NULL::TIME, TIME '12:34:56.123456'] AS clocks, + ARRAY[TIMESTAMPTZ '2026-11-01 12:34:56.123456+02:00'] AS instants, + ARRAY[[TIMESTAMPTZ '2026-11-01 12:34:56.123456+02:00']] AS nested, + ARRAY['{"enabled":true}'::JSONB] AS documents + """); + assertTrue(row.path("clocks").get(0).isNull()); + assertEquals("12:34:56.123456", row.path("clocks").get(1).textValue()); + Instant instant = Instant.parse("2026-11-01T10:34:56.123456Z"); + assertEquals(instant, OffsetDateTime.parse(row.path("instants").get(0).textValue()).toInstant()); + assertEquals(instant, OffsetDateTime.parse(row.path("nested").get(0).get(0).textValue()).toInstant()); + assertTrue(row.path("documents").get(0).isObject()); + assertTrue(row.path("documents").get(0).path("enabled").booleanValue()); + } +} diff --git a/de.drremote.db.json/src/test/java/de/drremote/db/json/JsonQueriesResourceTest.java b/de.drremote.db.json/src/test/java/de/drremote/db/json/JsonQueriesResourceTest.java new file mode 100644 index 0000000..ce7bb10 --- /dev/null +++ b/de.drremote.db.json/src/test/java/de/drremote/db/json/JsonQueriesResourceTest.java @@ -0,0 +1,96 @@ +package de.drremote.db.json; + +import de.drremote.db.json.api.JsonDbException; +import de.drremote.db.json.api.JsonQueries; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Proxy; +import java.sql.*; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.jupiter.api.Assertions.*; + +class JsonQueriesResourceTest { + @Test + void closesPreparedStatementIfBindingFails() { + var closed = new AtomicBoolean(); + var failure = new SQLException("binding failed"); + var statement = proxy(PreparedStatement.class, (p, method, args) -> { + if (method.getName().equals("setObject")) throw failure; + if (method.getName().equals("close")) { closed.set(true); return null; } + throw new AssertionError(method.getName()); + }); + var error = assertThrows(JsonDbException.class, + () -> JsonQueries.on(connection(statement)).query("SELECT ?", "value")); + assertSame(failure, error.getCause()); + assertTrue(closed.get()); + } + + @Test + void preservesBindingFailureWhenCleanupAlsoFails() { + var failure = new SQLException("binding failed"); + var cleanup = new SQLException("cleanup failed"); + var statement = proxy(PreparedStatement.class, (p, method, args) -> { + if (method.getName().equals("setObject")) throw failure; + if (method.getName().equals("close")) throw cleanup; + throw new AssertionError(method.getName()); + }); + var error = assertThrows(JsonDbException.class, + () -> JsonQueries.on(connection(statement)).update("UPDATE item SET value = ?", "value")); + assertSame(failure, error.getCause()); + assertArrayEquals(new Throwable[]{cleanup}, failure.getSuppressed()); + } + + @Test + void closesPreparedStatementIfExecutionFails() { + var closed = new AtomicBoolean(); + var failure = new SQLException("execution failed"); + var statement = proxy(PreparedStatement.class, (p, method, args) -> { + if (method.getName().equals("executeQuery")) throw failure; + if (method.getName().equals("close")) { closed.set(true); return null; } + throw new AssertionError(method.getName()); + }); + var error = assertThrows(JsonDbException.class, + () -> JsonQueries.on(connection(statement)).query("SELECT 1")); + assertSame(failure, error.getCause()); + assertTrue(closed.get()); + } + + @Test + void closesResultsAndStatementIfColumnMappingFails() { + var resultClosed = new AtomicBoolean(); + var statementClosed = new AtomicBoolean(); + var metadata = proxy(ResultSetMetaData.class, (p, method, args) -> switch (method.getName()) { + case "getColumnCount" -> 2; + case "getColumnLabel" -> "duplicate"; + case "getColumnTypeName" -> "INTEGER"; + case "getColumnType" -> Types.INTEGER; + default -> throw new AssertionError(method.getName()); + }); + var result = proxy(ResultSet.class, (p, method, args) -> { + if (method.getName().equals("getMetaData")) return metadata; + if (method.getName().equals("close")) { resultClosed.set(true); return null; } + throw new AssertionError(method.getName()); + }); + var statement = proxy(PreparedStatement.class, (p, method, args) -> { + if (method.getName().equals("executeQuery")) return result; + if (method.getName().equals("close")) { statementClosed.set(true); return null; } + throw new AssertionError(method.getName()); + }); + assertThrows(JsonDbException.class, () -> JsonQueries.on(connection(statement)).query("SELECT a, b")); + assertTrue(resultClosed.get()); + assertTrue(statementClosed.get()); + } + + private static Connection connection(PreparedStatement statement) { + return proxy(Connection.class, (p, method, args) -> { + if (method.getName().equals("prepareStatement")) return statement; + throw new AssertionError("Caller-owned connection operation: " + method.getName()); + }); + } + + private static T proxy(Class type, InvocationHandler handler) { + return type.cast(Proxy.newProxyInstance(type.getClassLoader(), new Class[]{type}, handler)); + } +} diff --git a/de.drremote.db.json/src/test/java/de/drremote/db/json/JsonQueriesSqliteTest.java b/de.drremote.db.json/src/test/java/de/drremote/db/json/JsonQueriesSqliteTest.java new file mode 100644 index 0000000..e329e32 --- /dev/null +++ b/de.drremote.db.json/src/test/java/de/drremote/db/json/JsonQueriesSqliteTest.java @@ -0,0 +1,150 @@ +package de.drremote.db.json; + +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.NullNode; +import de.drremote.db.json.api.JsonDbException; +import de.drremote.db.json.api.JsonQueries; +import de.drremote.db.json.api.JsonValue; +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 static org.junit.jupiter.api.Assertions.*; + +class JsonQueriesSqliteTest { + private Connection connection; + private JsonQueries json; + + @BeforeEach + void setUp() throws Exception { + connection = DriverManager.getConnection("jdbc:sqlite::memory:"); + try (var statement = connection.createStatement()) { + statement.execute("CREATE TABLE item(id INTEGER PRIMARY KEY, name TEXT, active BOOLEAN, payload TEXT, optional TEXT)"); + } + json = JsonQueries.on(connection); + } + + @AfterEach + void close() throws Exception { + connection.close(); + } + + @Test + void mapsAliasesPrimitivesAndSqlNullWithoutPrecisionLoss() { + json.update("INSERT INTO item VALUES (?, ?, ?, ?, ?)", 9007199254740993L, "Ada", true, "true", null); + var rows = json.query("SELECT id AS customer_id, name, active, optional FROM item"); + assertEquals(1, rows.size()); + var row = rows.get(0); + assertTrue(row.get("customer_id").isIntegralNumber()); + assertEquals(9007199254740993L, row.get("customer_id").longValue()); + assertEquals("Ada", row.get("name").textValue()); + assertTrue(row.get("active").isBoolean()); + assertTrue(row.get("active").booleanValue()); + assertTrue(row.has("optional")); + assertTrue(row.get("optional").isNull()); + } + + @Test + void handlesEmptySingleAndMultipleResults() { + assertTrue(json.query("SELECT * FROM item").isEmpty()); + assertNull(json.queryOne("SELECT * FROM item")); + assertEquals(1, json.queryOne("SELECT 1 AS id").get("id").intValue()); + assertThrows(JsonDbException.class, () -> json.queryOne("SELECT 1 AS id UNION ALL SELECT 2")); + } + + @Test + void parsesOnlyExplicitJsonTextColumnsAndDoesNotMutateTheOriginalHelper() { + json.update("INSERT INTO item(id, payload, name) VALUES (?, ?, ?)", 1, + JsonValue.parse("{\"tags\":[\"one\",\"two\"],\"enabled\":false}"), "{\"ordinary\":true}"); + var parsed = json.withJsonColumns("payload").queryOne("SELECT payload, name FROM item"); + assertTrue(parsed.get("payload").isObject()); + assertEquals("two", parsed.path("payload").path("tags").get(1).textValue()); + assertTrue(parsed.get("name").isTextual()); + assertTrue(json.queryOne("SELECT payload FROM item").get("payload").isTextual()); + } + + @Test + void bindsDirectJsonNodesAndDistinguishesJsonNullFromSqlNull() { + var node = JsonNodeFactory.instance.objectNode().put("id", 7); + json.update("INSERT INTO item(id, payload) VALUES (?, ?)", 1, node); + json.update("INSERT INTO item(id, payload) VALUES (?, ?)", 2, NullNode.instance); + json.update("INSERT INTO item(id, payload) VALUES (?, ?)", 3, (Object) null); + assertEquals(7, json.withJsonColumns("payload").queryOne( + "SELECT payload FROM item WHERE id = ?", 1).path("payload").path("id").intValue()); + assertEquals("null", json.queryOne("SELECT payload FROM item WHERE id = 2").path("payload").textValue()); + assertTrue(json.queryOne("SELECT payload FROM item WHERE id = 3").path("payload").isNull()); + } + + @Test + void keepsUnusualStringsAsPreparedStatementValues() { + String name = "O'Reilly; DROP TABLE item; -- \u2603"; + json.update("INSERT INTO item(id, name) VALUES (?, ?)", 1, name); + assertEquals(name, json.queryOne("SELECT name FROM item WHERE id = ?", 1).path("name").textValue()); + assertEquals(1, json.queryOne("SELECT COUNT(*) AS count FROM item").path("count").intValue()); + } + + @Test + void rejectsAmbiguousOrMissingLabelsEvenOnEmptyResults() { + assertThrows(JsonDbException.class, () -> json.query("SELECT 1 AS id, 2 AS id")); + assertThrows(JsonDbException.class, () -> json.query("SELECT 1 AS \"\"")); + assertThrows(JsonDbException.class, () -> json.withJsonColumns("missing").query("SELECT id FROM item")); + } + + @Test + void malformedStoredJsonHasContextAndDoesNotCloseTheConnection() throws Exception { + json.update("INSERT INTO item(id, payload) VALUES (1, ?)", "{invalid}"); + var failure = assertThrows(JsonDbException.class, + () -> json.withJsonColumns("payload").query("SELECT payload FROM item")); + assertTrue(failure.getMessage().contains("payload")); + assertNotNull(failure.getCause()); + assertFalse(connection.isClosed()); + assertEquals(1, json.queryOne("SELECT COUNT(*) AS count FROM item").path("count").intValue()); + } + + @Test + void leavesTransactionOwnershipWithTheCaller() throws Exception { + connection.setAutoCommit(false); + json.update("INSERT INTO item(id, name) VALUES (?, ?)", 1, "rollback"); + assertFalse(connection.getAutoCommit()); + assertFalse(connection.isClosed()); + connection.rollback(); + assertEquals(0, json.queryOne("SELECT COUNT(*) AS count FROM item").path("count").intValue()); + assertFalse(connection.getAutoCommit()); + } + + @Test + void heterogeneousColumnValuesKeepTheirTypesRegardlessOfFirstRow() { + var binaryFirst = json.query("SELECT x'61' AS v UNION ALL SELECT 'hello' UNION ALL SELECT 123"); + assertEquals("YQ==", binaryFirst.get(0).path("v").textValue()); + assertEquals("hello", binaryFirst.get(1).path("v").textValue()); + assertTrue(binaryFirst.get(2).path("v").isIntegralNumber()); + assertEquals(123, binaryFirst.get(2).path("v").intValue()); + var textFirst = json.query("SELECT 'hello' AS v UNION ALL SELECT x'61' UNION ALL SELECT 123"); + assertEquals("hello", textFirst.get(0).path("v").textValue()); + assertEquals("YQ==", textFirst.get(1).path("v").textValue()); + assertEquals(123, textFirst.get(2).path("v").intValue()); + } + + @Test + void invalidDirectJsonNodesAreRejectedBeforeAnyDataIsWritten() throws Exception { + var document = JsonNodeFactory.instance.objectNode().put("amount", Double.POSITIVE_INFINITY); + assertThrows(IllegalArgumentException.class, + () -> json.update("INSERT INTO item(id, payload) VALUES (?, ?)", 1, document)); + assertFalse(connection.isClosed()); + assertEquals(0, json.queryOne("SELECT COUNT(*) AS count FROM item").path("count").intValue()); + } + + @Test + void booleanDeclarationsDoNotCoerceUnrelatedSqliteValues() { + json.update("CREATE TABLE mixed_flag(value BOOLEAN)"); + json.update("INSERT INTO mixed_flag VALUES (?), (?), (?)", true, "hello", 2); + var rows = json.query("SELECT value FROM mixed_flag ORDER BY rowid"); + assertTrue(rows.get(0).path("value").booleanValue()); + assertEquals("hello", rows.get(1).path("value").textValue()); + assertTrue(rows.get(2).path("value").isIntegralNumber()); + assertEquals(2, rows.get(2).path("value").intValue()); + } +} diff --git a/de.drremote.db.json/src/test/java/de/drremote/db/json/JsonValueTest.java b/de.drremote.db.json/src/test/java/de/drremote/db/json/JsonValueTest.java new file mode 100644 index 0000000..02ddedc --- /dev/null +++ b/de.drremote.db.json/src/test/java/de/drremote/db/json/JsonValueTest.java @@ -0,0 +1,90 @@ +package de.drremote.db.json; + +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.MissingNode; +import com.fasterxml.jackson.databind.node.NullNode; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.DoubleNode; +import com.fasterxml.jackson.databind.node.FloatNode; +import de.drremote.db.json.api.JsonValue; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.junit.jupiter.params.provider.MethodSource; + +import java.math.BigDecimal; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.*; + +class JsonValueTest { + @Test + void snapshotsMutableDocuments() { + var node = JsonNodeFactory.instance.objectNode().put("name", "original"); + var value = JsonValue.of(node); + node.put("name", "changed"); + assertEquals("{\"name\":\"original\"}", value.json()); + } + + @Test + void acceptsObjectsArraysScalarsAndJsonNull() { + assertEquals("{\"id\":1}", JsonValue.parse(" { \"id\" : 1 } ").json()); + assertEquals("[1,true,null]", JsonValue.parse("[1,true,null]").json()); + assertEquals("\"hello\"", JsonValue.parse("\"hello\"").json()); + assertEquals("null", JsonValue.of(NullNode.instance).json()); + } + + @Test + void preservesLargeNumbersWithoutDoubleConversion() { + String json = "{\"id\":123456789012345678901234567890,\"amount\":0.12345678901234567890123456789}"; + assertEquals(json, JsonValue.parse(json).json()); + } + + @ParameterizedTest + @ValueSource(strings = {"", " ", "{} {}", "{\"x\":}", "null true"}) + void rejectsIncompleteOrMultipleDocuments(String text) { + assertThrows(IllegalArgumentException.class, () -> JsonValue.parse(text)); + } + + @Test + void rejectsMissingNodesAndJavaNull() { + assertThrows(IllegalArgumentException.class, () -> JsonValue.of(MissingNode.getInstance())); + assertThrows(NullPointerException.class, () -> JsonValue.of(null)); + assertThrows(NullPointerException.class, () -> JsonValue.parse(null)); + } + + @ParameterizedTest + @MethodSource("invalidNodeTrees") + void rejectsInvalidValuesThroughoutTheNodeTree(JsonNode node) { + assertThrows(IllegalArgumentException.class, () -> JsonValue.of(node)); + } + + static Stream invalidNodeTrees() { + var factory = JsonNodeFactory.instance; + return Stream.of( + DoubleNode.valueOf(Double.NaN), + FloatNode.valueOf(Float.POSITIVE_INFINITY), + factory.objectNode().put("amount", Double.NEGATIVE_INFINITY), + factory.arrayNode().add(Double.NaN), + factory.objectNode().set("value", MissingNode.getInstance()), + factory.arrayNode().add(MissingNode.getInstance()), + factory.pojoNode(new Object())); + } + + @Test + void acceptsHugeFiniteDecimalsAndSharedValidSubtrees() { + assertEquals("1E+1000", JsonValue.of(JsonNodeFactory.instance.numberNode(new BigDecimal("1E+1000"))).json()); + var child = JsonNodeFactory.instance.objectNode().put("ok", true); + var root = JsonNodeFactory.instance.objectNode(); + root.set("first", child); + root.set("second", child); + assertEquals("{\"first\":{\"ok\":true},\"second\":{\"ok\":true}}", JsonValue.of(root).json()); + } + + @Test + void rejectsCyclicNodes() { + var root = JsonNodeFactory.instance.objectNode(); + root.set("self", root); + assertThrows(IllegalArgumentException.class, () -> JsonValue.of(root)); + } +} diff --git a/docs/superpowers/plans/2026-11-01-db-json.md b/docs/superpowers/plans/2026-11-01-db-json.md new file mode 100644 index 0000000..949ca70 --- /dev/null +++ b/docs/superpowers/plans/2026-11-01-db-json.md @@ -0,0 +1,76 @@ +# Optional DB JSON 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:** Add optional JSON result mapping and JSON parameters to all supported databases through existing transaction connections. + +**Architecture:** One JDBC/Jackson adapter bundle and an independent Karaf feature. Existing core APIs and dependency sets remain stable. + +**Tech Stack:** Java 17+, Maven, Jackson 2.22.4 (annotations 2.22), JDBC, JUnit 5, Karaf 4.4.11. + +**Spec:** `docs/superpowers/specs/2026-11-01-db-json-design.md` + +## Global Constraints + +- English; Java 17+; public development version `1.1.0-SNAPSHOT`; Apache-2.0. +- Optional artifacts `de.drremote.db:json` and `json-features`; API package `de.drremote.db.json.api`. +- No Jackson dependencies/imports in existing database cores and no changes to private source/origin. +- Caller retains the connection and transaction; no implicit commit, rollback, close, or connection-setting changes. +- JSON query/binding support is this increment; document-store APIs are excluded. + +## Review Focus + +- Ambiguous row shapes: duplicate/blank labels and missing explicitly declared JSON columns fail clearly. +- Data fidelity: big integers/decimals, JSON null versus SQL NULL, and ordinary JSON-looking text keep their intended meaning. +- Driver differences: PostgreSQL JSON/JSONB and DuckDB JSON are parsed by metadata; SQLite TEXT is explicit. +- Resource failures: binding, execution, and mapping errors close statements/results without closing the caller's connection. +- Optional OSGi use: installing a database alone needs no Jackson; installing JSON resolves all required Jackson packages. + +--- + +### Task 1: JSON JDBC API and SQLite behavior + +**Files:** Create `de.drremote.db.json/pom.xml`; `src/main/java/de/drremote/db/json/api/{JsonQueries,JsonValue,JsonDbException}.java`; internal `impl/{JsonCodec,JdbcJsonMapper}.java`; tests under `src/test/java/de/drremote/db/json/`. Modify parent modules and managed Jackson versions. + +**Interfaces:** Consumes caller-owned `java.sql.Connection`; produces the exact API and semantics in the spec using Jackson `ArrayNode`, `ObjectNode`, and `JsonNode`. + +- [x] Verify the current baseline against the dedicated temporary PostgreSQL service. +- [x] Write `JsonValueTest` and `JsonQueriesSqliteTest` covering parsing/snapshots, primitives/aliases, empty/single/multiple rows, JSON text opt-in, JSON-looking ordinary strings, JSON parameters/null, duplicate/missing labels, and rollback/ownership. Observe expected failure before production code exists. +- [x] Implement the specified API and internal mapper/codec; keep JSON binding on prepared statements and exception causes intact. +- [x] Add focused resource-failure tests for binding/execution/mapping closure and verify them; run the module test suite. +- [x] Commit the independently testable adapter. + +### Task 2: Driver integration and optional Karaf feature + +**Files:** Add JSON-module `JsonQueriesDuckDbTest` and env-gated `JsonQueriesPostgresTest`; create `de.drremote.db.json.features/pom.xml` and `src/main/feature/feature.xml`; update root modules. + +**Interfaces:** Consumes Task 1 API; produces verified round trips against native JSON types and a resolvable optional `de.drremote-db-json` feature. + +- [x] Write driver tests for JSON/JSONB binding/reads, large exact numbers, SQL NULL versus JSON null, and existing transaction rollback; reproduce any missing backend behavior before fixing it. +- [x] Implement the smallest verified driver-specific binding/type-mapping changes needed; preserve prepared-statement parameterization. +- [x] Add filtered Karaf descriptors with Jackson annotations/core/databind and the JSON bundle. Run actual feature verification. +- [x] Inspect all core manifests and the JSON manifest; confirm Jackson remains optional and declared exports/imports resolve. +- [x] Commit integration coverage and packaging. + +### Task 3: Public guide, verification, and review + +**Files:** Create `docs/usage-json.md` and JSON module README; update root README, CHANGELOG, and CONTRIBUTING; keep evidence outside tracked sources. + +**Interfaces:** Consumes tested API and feature from Tasks 1–2; produces usable English examples and a verified public feature branch. + +- [x] Document source build, optional feature installation, transaction examples, direct/parsed JSON parameters, row type rules, explicit text-column JSON parsing, and connection ownership. +- [x] Run all tests and feature checks with PostgreSQL enabled on Java 17 and 21; validate example compilation, documentation links, and publication boundaries. +- [x] Obtain one fresh whole-branch review, reproduce/fix important findings, and rerun affected checks plus the complete suite where needed. +- [x] Prepare the GitHub branch and PR with concrete validation evidence; leave public main integration to the established approval step. + +Validation: 81 tests pass without skips on Java 17 and 21, including the real +PostgreSQL service; all four Karaf feature checks pass. Both examples compile +on Java 17. A fresh Karaf instance resolves the optional JSON feature and runs +the JSON settings client's migration, readiness, insert/update, query, parsed +payload, and missing-row behavior. Existing database modules remain unchanged. + +The final review's three important findings were reproduced before fixes: +mixed SQLite runtime value types, fractional/zoned temporal values in SQL +arrays, and invalid values nested inside JSON trees. Regression tests now pass +alongside the complete suite. The review's feature-count documentation +correction is included. Public main integration remains a separate decision. diff --git a/docs/superpowers/specs/2026-11-01-db-json-design.md b/docs/superpowers/specs/2026-11-01-db-json-design.md new file mode 100644 index 0000000..b81cffc --- /dev/null +++ b/docs/superpowers/specs/2026-11-01-db-json-design.md @@ -0,0 +1,93 @@ +# Optional JSON database adapter + +## Intent and selected approach + +The owner asked to implement the proposed optional JSON functionality: query +results as JSON and convenient JSON column parameters/reads. They asked to build +the recommended approach directly. Project content remains English. + +Two approaches were considered: adding JSON methods/dependencies to every core, +or supplying one optional adapter on JDBC connections. Use the separate adapter: +all three existing transaction handles already expose `getConnection()`, and the +core bundles can retain their existing dependencies and interfaces. + +This increment implements query mapping and JSON column values. The separately +suggested document store is outside this increment. + +## API and lifetime + +- New bundle `de.drremote.db:json:1.1.0-SNAPSHOT`, symbolic name + `de.drremote.db.json`, exports `de.drremote.db.json.api`. +- `JsonQueries.on(Connection)` creates a helper on a borrowed connection. +- `ArrayNode query(String sql, Object... parameters)` returns one JSON object per + result row. `ObjectNode queryOne(...)` returns null for no row and rejects more + than one row. `int update(...)` returns the JDBC update count. +- `JsonQueries withJsonColumns(String... labels)` returns a new helper declaring + additional result labels as JSON text. The original helper is unchanged. +- `JsonValue.of(JsonNode)` snapshots a document for parameter binding; + `JsonValue.parse(String)` validates and snapshots JSON text. `json()` exposes + the serialized value. Direct `JsonNode` parameters are also supported. +- `JsonDbException` reports JDBC/mapping failures as an unchecked exception. +- The caller owns the connection and transaction. Helpers close their statements + and result sets, including failure paths, but never close/commit/roll back the + connection or change its settings. + +Example inside any existing transaction callback: + +```java +return transactions.inTransaction(db -> JsonQueries.on(db.getConnection()) + .withJsonColumns("payload") + .query("SELECT id, payload FROM settings WHERE id = ?", id)); +``` + +## Mapping and binding decisions + +These implementation choices are the builder's: + +- Use Jackson 2.22.4 databind/core and Jackson annotations 2.22 (the versions + declared by Jackson's published 2.22.4 BOM). Jackson remains exclusive to the + optional bundle and its Karaf feature. +- Output keys are JDBC column labels, including aliases. Reject blank or duplicate + labels and declared JSON labels missing from the result, avoiding overwritten + fields or silently unparsed values. +- Preserve SQL null, booleans, integral and decimal numbers as JSON primitives. + Reject non-finite numbers. Map dates/times to their ISO Java representations, + UUIDs to strings, binary values to Base64, and SQL/Java arrays recursively. + Unsupported JDBC objects require an explicit SQL cast and fail clearly. +- Parse native `json`/`jsonb` result types automatically. SQLite text and text + projections require `withJsonColumns`, so ordinary text stays text. Invalid JSON + reports the affected label and preserves the cause. +- JSON parameters are serialized once per value snapshot. Bind PostgreSQL JSON as + JDBC `OTHER` for server type inference, and SQLite/DuckDB as text parameters. + Ordinary parameters keep `setObject` behavior. Java null is SQL NULL; + `JsonValue.parse("null")` and Jackson `NullNode` are the JSON literal null. +- Parsing accepts a single complete JSON value and rejects empty, malformed, + missing-node, or trailing-document input. JSON floating-point parsing uses + BigDecimal so JSON values do not lose decimal precision through a double. + +## Packaging and compatibility + +- Add an independent `json-features` module and `de.drremote-db-json` Karaf + feature installing the adapter and Jackson bundles. Database features remain + independently usable without JSON. Use generated, filtered feature versions. +- The JSON bundle depends on JDBC and Jackson; database drivers are test-only + dependencies. It does not require all three database bundles at runtime. +- Keep Java 17+, current public artifact IDs, APIs, licensing, private checkout, + and private origin unchanged. Work in the existing isolated public worktree on + a feature branch based on public main. No private integrations are copied. + +## Verification and documentation + +- Write tests first and observe missing behavior before implementation. +- Test actual SQLite and DuckDB connections and the existing opt-in PostgreSQL + service: aliases, primitive types/nulls, exact numbers, empty/single/multiple + rows, native/text JSON, JSON null versus SQL NULL, parameters, malformed JSON, + duplicate labels, rollback, and connection/resource ownership on errors. +- Retain the existing database suite. Verify the optional Karaf feature and run + the complete build on Java 17 and 21. Inspect manifests to confirm existing + database cores do not import Jackson. +- Add an English guide with copyable transaction examples, installation, type + mappings, and explicit JSON text column handling; update the README/changelog. +- Request one independent final review, fix important findings with reproducing + tests, and prepare a GitHub PR. Main-branch integration remains a separate + approval step, following the established workflow. diff --git a/docs/usage-json.md b/docs/usage-json.md new file mode 100644 index 0000000..b518760 --- /dev/null +++ b/docs/usage-json.md @@ -0,0 +1,143 @@ +# JSON queries and parameters + +The optional `de.drremote.db:json:1.1.0-SNAPSHOT` bundle converts JDBC rows to +Jackson JSON nodes and binds JSON values through prepared statements. It works +on the connection supplied by any existing PostgreSQL, SQLite, or DuckDB +transaction callback. The database core bundles have no Jackson dependency. + +## Install + +Build from source using `mvn clean install`. In Karaf, install your chosen +database feature and the optional JSON feature: + +```text +feature:repo-add mvn:de.drremote.db/json-features/1.1.0-SNAPSHOT/xml/features +feature:install de.drremote-db-json +``` + +This installs the JSON bundle and Jackson databind/core 2.22.4 plus annotations +2.22, as aligned by Jackson's published BOM. It does not install a database +driver or select a database for your application. + +Add `de.drremote.db:json:1.1.0-SNAPSHOT` as a `provided` dependency in an OSGi +client. Declare Jackson databind too when your client directly uses Jackson +types. The [JSON settings client](../examples/json-client) contains a complete +POM and two small Declarative Services components. + +## Query results as JSON + +Import `de.drremote.db.json.api.JsonQueries` and use the existing transaction: + +```java +ArrayNode rows = transactions.inTransaction(db -> + JsonQueries.on(db.getConnection()) + .query("SELECT id, name AS display_name, active FROM customer WHERE id > ?", 0)); +``` + +Create the application's table through its normal migrations first. Each row +becomes an `ObjectNode`; aliases become keys. `query` returns an empty array for +no rows. `queryOne` returns an `ObjectNode` or Java null for no row and throws +`JsonDbException` for multiple rows. + +Blank/duplicate column labels are rejected, so joined queries should assign +distinct aliases. Queries materialize their results; use SQL limits/pagination +when selecting a large result set. + +## JSON column values + +Your migrations choose the column type: + +| Database | Example column type | Read behavior | +| --- | --- | --- | +| PostgreSQL | `JSONB` or `JSON` | Parsed automatically from JDBC JSON type metadata. | +| SQLite | `TEXT CHECK(json_valid(payload))` | Declare the returned label using `withJsonColumns("payload")`. | +| DuckDB | `JSON` | Parsed automatically from JDBC JSON type metadata. | + +Bind an existing `JsonNode` directly, or validate JSON text with `JsonValue`: + +```java +JsonValue payload = JsonValue.parse("{\"enabled\":true,\"tags\":[\"home\",\"office\"]}"); + +ObjectNode row = transactions.inTransaction(db -> { + JsonQueries json = JsonQueries.on(db.getConnection()); + json.update("INSERT INTO settings(id, payload) VALUES (?, ?)", "device-42", payload); + return json.withJsonColumns("payload") + .queryOne("SELECT id, payload FROM settings WHERE id = ?", "device-42"); +}); +``` + +Import `de.drremote.db.json.api.JsonValue` and Jackson's node types in the client. +The example assumes an existing `settings` table. PostgreSQL JSON parameters use +JDBC `OTHER` for server-side type inference; SQLite/DuckDB receive JSON text. +SQL and JSON query operators remain specific to the chosen database. + +`JsonValue.of(node)` captures an immutable serialized snapshot, which is useful +when the original node may change later. Direct node parameters are serialized +when the statement is prepared. `JsonValue.parse(text)` accepts exactly one +complete JSON value, including arrays and scalars, and rejects empty/malformed +input or trailing documents. Node snapshots also reject non-finite numbers and +missing nodes anywhere in the tree, embedded Java objects, and cyclic/overly +deep trees. Convert Java objects to ordinary JSON nodes before binding them. + +### JSON null and SQL NULL + +| Java parameter | Stored meaning | +| --- | --- | +| Java `null` | SQL NULL. Use `(Object) null` for a single null varargs parameter. | +| `NullNode.instance` or `JsonValue.parse("null")` | JSON literal `null`. | +| Ordinary Java string | Ordinary JDBC string; JSON-looking text is not guessed. | +| `JsonNode` or `JsonValue` | Serialized JSON parameter. | + +Both SQL NULL and the JSON null literal appear as JSON null in returned rows. +Select `payload IS NULL AS sql_null` when your application needs to distinguish +their storage meaning. + +### Explicit JSON text columns + +For SQLite TEXT or a cast of native JSON to text: + +```java +JsonQueries json = JsonQueries.on(db.getConnection()).withJsonColumns("payload"); +ObjectNode row = json.queryOne("SELECT id, payload FROM settings WHERE id = ?", id); +JsonNode document = row.get("payload"); +``` + +Labels are exact and case-sensitive, including SQL aliases. A missing declared +label or malformed stored JSON raises `JsonDbException`. `withJsonColumns` +returns a new helper and leaves the original unchanged. Ordinary text stays a +JSON string even if it looks like an object, number, or boolean. + +## JDBC value mapping + +| JDBC value | JSON representation | +| --- | --- | +| SQL NULL | JSON null, with the column key retained. | +| Boolean | JSON boolean. | +| Integral / arbitrary-precision numbers | JSON number, preserving the JDBC numeric value. | +| Decimal | Decimal JSON number; JSON parsing uses BigDecimal rather than double. | +| Date/time/timestamp | ISO-style text; timezone-aware values retain an offset. | +| UUID | String. | +| Binary values | Base64 string. | +| SQL/Java arrays | JSON array, including null entries; JDBC element metadata preserves temporal values. | +| Native or explicitly selected JSON | Parsed JSON value. | + +SQLite values retain their runtime types even when one result column mixes +binary, text, and numeric values. Declared SQLite boolean columns map 0/1 to +booleans and retain unrelated values as their actual types. + +NaN/infinity and unsupported JDBC objects fail clearly; use an explicit SQL cast +when your application wants a textual representation instead. Precision already +lost by a database storage type or JDBC driver cannot be recovered by the adapter. + +## Transactions and resource ownership + +The helper never commits, rolls back, closes its connection, or changes its +settings. Your existing `TransactionManager` owns those decisions. Statements +and result sets are closed on success and failure, including parameter-binding +errors. Let exceptions escape the transaction callback when its work must roll +back. + +Create/use a helper within the connection's callback and thread. Do not retain +it after that callback returns. The adapter creates no tables or migrations; +the sample client shows how application-owned migrations and readiness work +alongside it. diff --git a/examples/json-client/README.md b/examples/json-client/README.md new file mode 100644 index 0000000..480b7fc --- /dev/null +++ b/examples/json-client/README.md @@ -0,0 +1,30 @@ +# JSON settings client + +A buildable OSGi example of JSON results and JSON parameters inside the existing +SQLite transaction service. The migration descriptor creates `json_settings`; +the `JsonSettings` service waits for its `DatabaseReady` marker. + +From the repository root: + +```sh +mvn clean install +mvn -f examples/json-client/pom.xml clean package +``` + +Configure/install [SQLite](../../README.md#quick-start-sqlite-in-karaf), then add +the optional JSON feature and this client in Karaf: + +```text +feature:repo-add mvn:de.drremote.db/json-features/1.1.0-SNAPSHOT/xml/features +feature:install de.drremote-db-json +bundle:install -s file:/absolute/path/to/checkout/examples/json-client/target/json-client-1.1.0-SNAPSHOT.jar +service:list example.jsonsettings.JsonSettings +``` + +Other components can reference `JsonSettings` and call `save(String, JsonNode)`, +`load(String)`, or `list()`. A loaded row contains `id` and a parsed `payload`. +Missing rows return Java null. Installing this example creates its table but +does not insert data or start a web interface. + +See the [JSON usage guide](../../docs/usage-json.md) for PostgreSQL/DuckDB use, +JSON null handling, and the optional module's connection ownership rules. diff --git a/examples/json-client/pom.xml b/examples/json-client/pom.xml new file mode 100644 index 0000000..42a6e35 --- /dev/null +++ b/examples/json-client/pom.xml @@ -0,0 +1,23 @@ + + + 4.0.0 + de.drremotedb-parent1.1.0-SNAPSHOT../../pom.xml + de.drremote.db.examplesjson-clientbundle + DrRemote DB JSON settings example + + de.drremote.dbsqlite${project.version}provided + de.drremote.dbjson${project.version}provided + com.fasterxml.jackson.corejackson-databindprovided + org.osgiorg.osgi.service.component.annotationsprovided + + + + org.apache.felixmaven-bundle-plugintrue + + de.drremote.db.examples.json + example.jsonsettings + + + + diff --git a/examples/json-client/src/main/java/example/jsonsettings/JsonSettings.java b/examples/json-client/src/main/java/example/jsonsettings/JsonSettings.java new file mode 100644 index 0000000..a7c08fc --- /dev/null +++ b/examples/json-client/src/main/java/example/jsonsettings/JsonSettings.java @@ -0,0 +1,41 @@ +package example.jsonsettings; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import de.drremote.db.json.api.JsonQueries; +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.Objects; + +@Component(service = JsonSettings.class) +public final class JsonSettings { + @Reference private TransactionManager transactions; + @Reference(target = "(drremote.db.plugin=json-settings)") private DatabaseReady ready; + + public void save(String id, JsonNode document) { + Objects.requireNonNull(id, "id"); + Objects.requireNonNull(document, "document"); + transactions.inTransaction(db -> { + JsonQueries.on(db.getConnection()).update( + "INSERT INTO json_settings(id, payload) VALUES (?, ?) " + + "ON CONFLICT(id) DO UPDATE SET payload = excluded.payload", id, document); + return null; + }); + } + + public ObjectNode load(String id) { + return transactions.inTransaction(db -> JsonQueries.on(db.getConnection()) + .withJsonColumns("payload") + .queryOne("SELECT id, payload FROM json_settings WHERE id = ?", id)); + } + + public ArrayNode list() { + return transactions.inTransaction(db -> JsonQueries.on(db.getConnection()) + .withJsonColumns("payload") + .query("SELECT id, payload FROM json_settings ORDER BY id")); + } +} diff --git a/examples/json-client/src/main/java/example/jsonsettings/SettingsMigrations.java b/examples/json-client/src/main/java/example/jsonsettings/SettingsMigrations.java new file mode 100644 index 0000000..adda335 --- /dev/null +++ b/examples/json-client/src/main/java/example/jsonsettings/SettingsMigrations.java @@ -0,0 +1,22 @@ +package example.jsonsettings; + +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 SettingsMigrations implements PluginMigrationDescriptor { + @Override public String getPluginName() { return "json-settings"; } + + @Override public List getMigrations() { + return List.of(new Migration() { + public int getVersion() { return 1; } + public String getDescription() { return "Create JSON settings"; } + public String getSql() { + return "CREATE TABLE json_settings (id TEXT PRIMARY KEY, payload TEXT NOT NULL CHECK(json_valid(payload)))"; + } + }); + } +} diff --git a/pom.xml b/pom.xml index f8f9fc6..dd9fce4 100644 --- a/pom.xml +++ b/pom.xml @@ -26,6 +26,8 @@ 1.5.1 5.10.3 4.4.11 + 2.22.4 + 2.22 de.drremote.db.postgresql @@ -34,8 +36,13 @@ de.drremote.db.sqlite.features de.drremote.db.duckdb de.drremote.db.duckdb.features + de.drremote.db.json + de.drremote.db.json.features + com.fasterxml.jackson.corejackson-databind${jackson.version} + com.fasterxml.jackson.corejackson-core${jackson.version} + com.fasterxml.jackson.corejackson-annotations${jackson.annotations.version} org.osgiosgi.core${osgi.core.version} org.osgiorg.osgi.service.component.annotations${osgi.ds.version} com.zaxxerHikariCP${hikari.version}