diff --git a/examples/exact-decimal/GeneratedExactDecimalProbe.java b/examples/exact-decimal/GeneratedExactDecimalProbe.java new file mode 100644 index 00000000..479caa41 --- /dev/null +++ b/examples/exact-decimal/GeneratedExactDecimalProbe.java @@ -0,0 +1,122 @@ +package com.example.exactdecimalservice; + +import com.example.exactdecimalservice.decimalentry.DecimalEntry; +import com.example.exactdecimalservice.platform.Platform; +import io.teaql.core.meta.SimpleEntityMetaFactory; +import io.teaql.core.sql.portable.IdSpaceIdGenerator; +import io.teaql.core.sqlite.SqliteDataServiceExecutor; +import io.teaql.provider.jdbc.JdbcSqlExecutor; +import io.teaql.runtime.TeaQLRuntime; +import java.math.BigDecimal; +import java.nio.file.Files; +import java.nio.file.Path; +import org.sqlite.SQLiteDataSource; + +/** Application-owned exact Decimal check against a freshly generated domain library. */ +public final class GeneratedExactDecimalProbe { + private GeneratedExactDecimalProbe() {} + + private static TeaQLRuntime runtime(SQLiteDataSource dataSource, JdbcSqlExecutor sql) { + return TeaQLRuntime.builder() + .metadata(new SimpleEntityMetaFactory()) + .dataService("default", new SqliteDataServiceExecutor("default", sql, dataSource)) + .idGenerationService(new IdSpaceIdGenerator(new WorkspaceTeaQLDatabase(sql, dataSource))) + .executionLogging(false) + .build() + .install(GeneratedRuntimeModule.module()); + } + + public static void main(String[] args) throws Exception { + Path directory = Files.createTempDirectory("teaql-java-generated-exact-decimal-"); + SQLiteDataSource sourceData = new SQLiteDataSource(); + sourceData.setUrl("jdbc:sqlite:" + directory.resolve("source.sqlite")); + JdbcSqlExecutor sourceSql = new JdbcSqlExecutor(sourceData); + var source = new CustomUserContext(runtime(sourceData, sourceSql)); + source.ensureSchema(); + Platform platform = Q.platforms().withIdIs(1L) + .comment("Locate the exact Decimal platform") + .purpose("Attach ledger entries to their generated root") + .executeForOne(source); + if (platform == null) throw new AssertionError("seeded platform is missing"); + + save(source, platform, "first", new BigDecimal("0.1")); + save(source, platform, "second", new BigDecimal("0.2")); + var loaded = Q.decimalEntriesWithMinimalFields() + .selectLabel().selectAmount() + .withAmountIs(new BigDecimal("0.1")) + .orderByIdAscending().limit(10) + .comment("Read a generated exact Decimal row") + .purpose("Verify Q filtering and E hydration") + .executeForList(source); + if (loaded.size() != 1 || !"first".equals(E.decimalEntry(loaded.get(0)).getLabel().eval()) + || E.decimalEntry(loaded.get(0)).getAmount().eval().compareTo(new BigDecimal("0.1")) != 0) { + throw new AssertionError("generated Decimal Q/E round-trip failed"); + } + if (sum(source).compareTo(new BigDecimal("0.3")) != 0) { + throw new AssertionError("generated Decimal SUM is not exact"); + } + DecimalEntry preciseEntry = save(source, platform, "precise", new BigDecimal("3.50")); + BigDecimal scale = reloadAmount(source, preciseEntry.getId()); + if (!new BigDecimal("3.50").equals(scale)) { + throw new AssertionError("generated Decimal scale was not preserved: " + scale); + } + BigDecimal preciseInput = new BigDecimal("123456789012.3456789"); + preciseEntry.updateAmount(preciseInput); + preciseEntry.auditAs("Update exact Decimal precision for generated integration gate").save(source); + BigDecimal precision = reloadAmount(source, preciseEntry.getId()); + if (!preciseInput.equals(precision)) { + throw new AssertionError("generated Decimal precision was not preserved: " + precision); + } + + for (String exponent : new String[] {"1E-7", "1E+8"}) { + BigDecimal input = new BigDecimal(exponent); + DecimalEntry entry = save(source, platform, "exponent " + exponent, input); + BigDecimal reloaded = reloadAmount(source, entry.getId()); + var filtered = Q.decimalEntriesWithMinimalFields().selectAmount() + .withIdIs(entry.getId()).withAmountIs(input).limit(1) + .comment("Find generated exact Decimal exponent value") + .purpose("Verify canonical SQLite binding in generated Q and mutation APIs") + .executeForOne(source); + if (input.compareTo(reloaded) != 0 || filtered == null + || input.compareTo(E.decimalEntry(filtered).getAmount().eval()) != 0) { + throw new AssertionError("generated Decimal exponent round-trip failed: " + exponent); + } + } + + System.out.println("GENERATED_EXACT_DECIMAL_PASS q=1 e=1 save=5 update=1 sum=0.3 scale=3.50 precision=19 exponent=2"); + } + + private static DecimalEntry save(CustomUserContext context, Platform platform, + String label, BigDecimal amount) { + DecimalEntry entry = Q.decimalEntries() + .comment("Initialize generated Decimal entry") + .purpose("Record an auditable ledger value") + .newEntity(context); + entry.updatePlatform(platform); + entry.updateLabel(label); + entry.updateAmount(amount); + entry.auditAs("Create exact Decimal entry for generated integration gate").save(context); + return entry; + } + + private static BigDecimal sum(CustomUserContext context) { + var rows = Q.decimalEntriesWithMinimalFields().sumAmountAs("total") + .comment("Sum generated exact Decimal entries") + .purpose("Verify financial aggregation without binary floating point") + .aggregation(context).toList(); + if (rows.size() != 1 || !(rows.get(0).get("total") instanceof BigDecimal total)) { + throw new AssertionError("generated Decimal aggregate has the wrong shape"); + } + return total; + } + + private static BigDecimal reloadAmount(CustomUserContext context, Long id) { + var entry = Q.decimalEntriesWithMinimalFields().selectAmount() + .withIdIs(id).limit(1) + .comment("Reload generated exact Decimal value") + .purpose("Verify scale and precision after audited save") + .executeForOne(context); + if (entry == null) throw new AssertionError("generated Decimal entry is missing: " + id); + return E.decimalEntry(entry).getAmount().eval(); + } +} diff --git a/examples/exact-decimal/GeneratedWorkspaceStartupProbe.java b/examples/exact-decimal/GeneratedWorkspaceStartupProbe.java new file mode 100644 index 00000000..59573904 --- /dev/null +++ b/examples/exact-decimal/GeneratedWorkspaceStartupProbe.java @@ -0,0 +1,18 @@ +package com.example.exactdecimalservice; + +import io.teaql.provider.jdbc.JdbcSqlExecutor; +import org.springframework.boot.SpringApplication; + +/** Closes the real generated Spring workspace after its ensure-schema runner. */ +public final class GeneratedWorkspaceStartupProbe { + private GeneratedWorkspaceStartupProbe() {} + + public static void main(String[] args) { + try (var context = SpringApplication.run(App.class, args)) { + if (context.getBeansOfType(JdbcSqlExecutor.class).size() != 1) { + throw new AssertionError("generated SQLite workspace must own one JDBC executor"); + } + } + System.out.println("GENERATED_EXACT_DECIMAL_WORKSPACE_PASS"); + } +} diff --git a/examples/exact-decimal/README.md b/examples/exact-decimal/README.md new file mode 100644 index 00000000..453782f5 --- /dev/null +++ b/examples/exact-decimal/README.md @@ -0,0 +1,29 @@ +# Generated Java/SQLite exact Decimal example + +This is a small, generation-dependent runtime example. It uses two entities, +one modeled `number()` field, a local generator checkout and this local Java +runtime checkout. No generated domain source is edited. + +```bash +TEAQL_CODEGEN_DIR=/path/to/teaql-code-gen bash examples/exact-decimal/verify.sh +``` + +The verifier creates a fresh generated Java library and console workspace, +installs only the local runtime into Maven's local cache, then runs twice: + +- Generated Q equality, strong `BigDecimal` hydration and E evaluation. +- Audited saves of `0.1` and `0.2`, followed by an exact modeled Q `SUM=0.3`. +- A `3.50` scale round-trip and audited update to + `123456789012.3456789` without losing numeric precision. +- Exponent-form `BigDecimal` inputs (`1E-7`, `1E+8`) through generated + audited mutation, typed Q filtering and E hydration. +- The actual generated Spring console startup, including repeated + `ensureSchema` against one workspace database. + +It also verifies that the generated library hash does not change while the +application code is compiled and executed. Every run retains its logs under +the printed temporary evidence directory. This does not qualify migration of +pre-existing SQLite `NUMERIC` columns or participation in an application-owned +Spring `@Transactional` boundary; TeaQL's own graph save is transactional. +Committed mutation replication is tested separately on its P1 branch and is +not required for this isolated Decimal fix. diff --git a/examples/exact-decimal/model.xml b/examples/exact-decimal/model.xml new file mode 100644 index 00000000..2184858b --- /dev/null +++ b/examples/exact-decimal/model.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/examples/exact-decimal/verify.sh b/examples/exact-decimal/verify.sh new file mode 100644 index 00000000..15a3d96b --- /dev/null +++ b/examples/exact-decimal/verify.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +set -euo pipefail + +fixture_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +runtime_root="$(cd "$fixture_dir/../.." && pwd)" +generator_root="${TEAQL_CODEGEN_DIR:?Set TEAQL_CODEGEN_DIR to the local teaql-code-gen checkout}" +generator_root="$(cd "$generator_root" && pwd)" +run_dir="$(mktemp -d -t teaql-java-exact-decimal.XXXXXX)" +model_file="$fixture_dir/model.xml" + +command -v mvn >/dev/null +command -v rg >/dev/null +mvn -q -f "$generator_root/pom.xml" -pl model -am -DskipTests install \ + >"$run_dir/model-install.log" 2>&1 +mvn -q -f "$generator_root/pom.xml" -pl generator -am -DskipTests compile \ + >"$run_dir/generator-compile.log" 2>&1 +mvn -q -f "$generator_root/pom.xml" -pl generator exec:java \ + -Dexec.mainClass=com.skynet.codegenerator.LocalGenerator \ + -Dexec.args="java-app-console $run_dir/java-app-console $model_file" \ + >"$run_dir/generation.log" 2>&1 +mvn -q -f "$runtime_root/pom.xml" -pl teaql-sqlite -am \ + -DskipTests install >"$run_dir/runtime-install.log" 2>&1 + +app_dir="$run_dir/java-app-console" +app_src="$app_dir/src/main/java/com/example/exactdecimalservice" +cp "$fixture_dir/GeneratedExactDecimalProbe.java" "$app_src/GeneratedExactDecimalProbe.java" +cp "$fixture_dir/GeneratedWorkspaceStartupProbe.java" "$app_src/GeneratedWorkspaceStartupProbe.java" +library_hash_before="$(cd "$app_dir/lib" && rg --files src | LC_ALL=C sort \ + | xargs sha256sum | sha256sum | cut -d' ' -f1)" +mvn -q -f "$app_dir/lib/pom.xml" -DskipTests install \ + >"$run_dir/library-install.log" 2>&1 + +for round in 1 2; do + if ! mvn -q -f "$app_dir/pom.xml" -DskipTests compile exec:java \ + -Dexec.mainClass=com.example.exactdecimalservice.GeneratedExactDecimalProbe \ + >"$run_dir/round-$round.log" 2>&1; then + echo "Generated exact Decimal probe failed: $run_dir/round-$round.log" >&2 + exit 1 + fi + if ! rg -q '^GENERATED_EXACT_DECIMAL_PASS q=1 e=1 save=5 update=1 sum=0.3 scale=3.50 precision=19 exponent=2$' \ + "$run_dir/round-$round.log"; then + echo "Generated exact Decimal probe lacks its complete marker: $run_dir/round-$round.log" >&2 + exit 1 + fi + if ! timeout 35s mvn -q -f "$app_dir/pom.xml" -DskipTests compile exec:java \ + -Dexec.mainClass=com.example.exactdecimalservice.GeneratedWorkspaceStartupProbe \ + -Dexec.args="--spring.datasource.url=jdbc:sqlite:$run_dir/workspace.sqlite" \ + >"$run_dir/workspace-$round.log" 2>&1; then + echo "Generated SQLite workspace failed: $run_dir/workspace-$round.log" >&2 + exit 1 + fi + if ! rg -q '^GENERATED_EXACT_DECIMAL_WORKSPACE_PASS$' "$run_dir/workspace-$round.log"; then + echo "Generated SQLite workspace lacks its complete marker: $run_dir/workspace-$round.log" >&2 + exit 1 + fi +done +library_hash_after="$(cd "$app_dir/lib" && rg --files src | LC_ALL=C sort \ + | xargs sha256sum | sha256sum | cut -d' ' -f1)" +if [[ "$library_hash_before" != "$library_hash_after" ]]; then + echo "Generated library changed during the application probe" >&2 + exit 1 +fi +printf 'GENERATED_EXACT_DECIMAL_GATE_PASS rounds=2 workspace=2 library_unchanged=1 generator=%s runtime=%s evidence=%s\n' \ + "$(git -C "$generator_root" rev-parse --short HEAD)" \ + "$(git -C "$runtime_root" rev-parse --short HEAD)" "$run_dir" diff --git a/examples/verify-runtime-examples.sh b/examples/verify-runtime-examples.sh index 2e072eab..821c4a6e 100755 --- a/examples/verify-runtime-examples.sh +++ b/examples/verify-runtime-examples.sh @@ -87,7 +87,9 @@ done bash "$repo_dir/examples/trace-chain/verify.sh" if [[ -n "${TEAQL_CODEGEN_DIR:-}" ]]; then bash "$repo_dir/examples/shared-load-state/verify.sh" --generate + bash "$repo_dir/examples/exact-decimal/verify.sh" + printf 'PASS Java runtime examples: 5/5 (including generated exact Decimal)\n' else bash "$repo_dir/examples/shared-load-state/verify.sh" + printf 'PASS Java runtime examples: 4/4; generated exact Decimal requires TEAQL_CODEGEN_DIR\n' fi -printf 'PASS Java runtime examples: 4/4\n' diff --git a/scripts/verify-examples.sh b/scripts/verify-examples.sh index 204f9989..4bae0b90 100755 --- a/scripts/verify-examples.sh +++ b/scripts/verify-examples.sh @@ -4,7 +4,7 @@ set -euo pipefail repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" verification_dir="$(mktemp -d)" trap 'rm -rf -- "$verification_dir"' EXIT -expected=(business-id-runtime conformance http-resilience object-storage order-management round-trip-reference-runtime school-management security-foundations shared-load-state standard-order trace-chain) +expected=(business-id-runtime conformance exact-decimal http-resilience object-storage order-management round-trip-reference-runtime school-management security-foundations shared-load-state standard-order trace-chain) mapfile -t actual < <(find "$repo/examples" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort) if [[ "${actual[*]}" != "${expected[*]}" ]]; then echo "example inventory changed; update scripts/verify-examples.sh: ${actual[*]}" >&2 @@ -36,4 +36,4 @@ mvn -q -f examples/order-management/pom.xml install -DskipTests bash examples/trace-chain/verify.sh bash examples/trace-chain/verify-standard-telemetry.sh bash examples/standard-order/verify.sh -echo "PASS: standard Java examples; shared-load-state has its own generator-backed verify.sh gate" +echo "PASS: standard Java examples; shared-load-state and exact-decimal have separate generator-backed gates" diff --git a/teaql-provider-jdbc/src/main/java/io/teaql/provider/jdbc/JdbcSqlExecutor.java b/teaql-provider-jdbc/src/main/java/io/teaql/provider/jdbc/JdbcSqlExecutor.java index c433a51e..8bd072ad 100644 --- a/teaql-provider-jdbc/src/main/java/io/teaql/provider/jdbc/JdbcSqlExecutor.java +++ b/teaql-provider-jdbc/src/main/java/io/teaql/provider/jdbc/JdbcSqlExecutor.java @@ -17,6 +17,10 @@ public class JdbcSqlExecutor implements SqlExecutionAdapter { + // Cap SQLite plain-text materialization before expanding exponent-form + // BigDecimal inputs. Model precision/scale checks remain separate. + private static final long MAX_SQLITE_DECIMAL_TEXT_LENGTH = 1_000_000L; + private final DataSource dataSource; // A held view retains only this token, not the executor, connection or DataSource. private final Object storageIdentity = new Object(); @@ -281,6 +285,9 @@ private static V convertColumn(Class type, Object value) { return (V) java.time.LocalTime.parse(separator < 0 ? text : text.substring(separator + 1)); } if (type == String.class) return (V) String.valueOf(value); + if (type == java.math.BigDecimal.class && value instanceof CharSequence text) { + return (V) new java.math.BigDecimal(text.toString()); + } throw new IllegalArgumentException("Cannot convert JDBC value " + value.getClass().getName() + " to " + type.getName()); } @@ -421,7 +428,22 @@ static void bind(PreparedStatement statement, int index, Object value) throws SQ } else if (value instanceof Byte number) { statement.setByte(index, number); } else if (value instanceof java.math.BigDecimal number) { - statement.setBigDecimal(index, number); + // SQLite's exact Decimal columns require canonical plain text. The + // driver binds exponent-form BigDecimal values (for example 1E-7) + // as exponent text, which the column CHECK correctly rejects. + // Keep native numeric binding for the other JDBC providers. + if (isSqlite(statement)) { + long precision = number.precision(); + long scale = number.scale(); + long plainLength = scale <= 0 + ? precision - scale + : Math.max(precision, scale + 1) + 1; + if (number.signum() < 0) plainLength++; + if (plainLength > MAX_SQLITE_DECIMAL_TEXT_LENGTH) { + throw new SQLException("SQLite Decimal plain-text representation exceeds safe length"); + } + statement.setString(index, number.toPlainString()); + } else statement.setBigDecimal(index, number); } else if (value instanceof Double number) { statement.setDouble(index, number); } else if (value instanceof Float number) { diff --git a/teaql-provider-jdbc/src/test/java/io/teaql/provider/jdbc/JdbcSqlExecutorTest.java b/teaql-provider-jdbc/src/test/java/io/teaql/provider/jdbc/JdbcSqlExecutorTest.java index 7e311267..e4ded361 100644 --- a/teaql-provider-jdbc/src/test/java/io/teaql/provider/jdbc/JdbcSqlExecutorTest.java +++ b/teaql-provider-jdbc/src/test/java/io/teaql/provider/jdbc/JdbcSqlExecutorTest.java @@ -18,6 +18,7 @@ import java.util.logging.Logger; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; public class JdbcSqlExecutorTest { @@ -162,6 +163,22 @@ public void testSqliteLocalDateParametersUseIsoTextOrdering() throws Exception { } } + @Test + public void sqliteDecimalBindingRejectsExponentExpansionBeforeMaterialization() throws Exception { + try (Connection connection = DriverManager.getConnection("jdbc:sqlite::memory:"); + java.sql.PreparedStatement query = connection.prepareStatement("SELECT ?")) { + assertThrows(SQLException.class, () -> JdbcSqlExecutor.bind( + query, 1, new java.math.BigDecimal("1E-1000000000"))); + assertThrows(SQLException.class, () -> JdbcSqlExecutor.bind( + query, 1, new java.math.BigDecimal("1E+1000000000"))); + JdbcSqlExecutor.bind(query, 1, new java.math.BigDecimal("1E-7")); + try (ResultSet result = query.executeQuery()) { + assertTrue(result.next()); + assertEquals("0.0000001", result.getString(1)); + } + } + } + @Test public void testBatchUpdate() throws Exception { List batch = new ArrayList<>(); diff --git a/teaql-sql-portable/src/main/java/io/teaql/core/sql/SQLColumnResolver.java b/teaql-sql-portable/src/main/java/io/teaql/core/sql/SQLColumnResolver.java index 86bba550..7d9534c5 100644 --- a/teaql-sql-portable/src/main/java/io/teaql/core/sql/SQLColumnResolver.java +++ b/teaql-sql-portable/src/main/java/io/teaql/core/sql/SQLColumnResolver.java @@ -4,12 +4,20 @@ import java.util.Map; import io.teaql.core.SearchRequest; +import io.teaql.core.AggrFunction; +import io.teaql.core.Expression; import io.teaql.core.UserContext; import io.teaql.core.utils.CollUtil; import io.teaql.core.sql.expression.SQLExpressionParser; public interface SQLColumnResolver { + /** Optional provider override for typed aggregation; null uses portable SQL. */ + default String aggregateSql(UserContext context, AggrFunction function, + Expression operand, String columnSql) { + return null; + } + default io.teaql.core.SqlParameterLogPolicy parameterLogPolicy(String property) { return io.teaql.core.SqlParameterLogPolicy.UNKNOWN; } diff --git a/teaql-sql-portable/src/main/java/io/teaql/core/sql/dialect/SqlDialect.java b/teaql-sql-portable/src/main/java/io/teaql/core/sql/dialect/SqlDialect.java index cb47ba32..c2afe6dd 100644 --- a/teaql-sql-portable/src/main/java/io/teaql/core/sql/dialect/SqlDialect.java +++ b/teaql-sql-portable/src/main/java/io/teaql/core/sql/dialect/SqlDialect.java @@ -1,6 +1,8 @@ package io.teaql.core.sql.dialect; import io.teaql.core.SearchRequest; +import io.teaql.core.AggrFunction; +import io.teaql.core.sql.SQLColumn; import java.util.List; import java.util.Locale; import java.util.Map; @@ -54,6 +56,21 @@ default String mapColumnType(String type) { return type; } + /** Physical type plus column-local constraints needed by a provider. */ + default String columnTypeAndConstraintsSql(SQLColumn column) { + return mapColumnType(column.getType()); + } + + /** Override only when a provider needs an exact Decimal aggregate. */ + default String decimalAggregateSql(AggrFunction function, String columnSql, boolean directColumn) { + return null; + } + + /** Decode an exact modeled Decimal aggregate without binary-float fallback. */ + default Object decodeDecimalAggregate(Object value) { + return value; + } + /** * Compare the storage families reported by the provider with the type selected for the model. * Providers may override this when their storage type system has wider compatibility rules. diff --git a/teaql-sql-portable/src/main/java/io/teaql/core/sql/expression/AggrExpressionParser.java b/teaql-sql-portable/src/main/java/io/teaql/core/sql/expression/AggrExpressionParser.java index 54f3f989..fc305f9b 100644 --- a/teaql-sql-portable/src/main/java/io/teaql/core/sql/expression/AggrExpressionParser.java +++ b/teaql-sql-portable/src/main/java/io/teaql/core/sql/expression/AggrExpressionParser.java @@ -39,7 +39,9 @@ public String toSql( String sqlColumn = ExpressionHelper.toSql( userContext, expressions.get(0), idTable, parameters, sqlColumnResolver); - return genAggrSQL((AggrFunction) operator, sqlColumn); + String specialized = sqlColumnResolver.aggregateSql( + userContext, (AggrFunction) operator, expressions.get(0), sqlColumn); + return specialized == null ? genAggrSQL((AggrFunction) operator, sqlColumn) : specialized; } public String genAggrSQL(AggrFunction operator, String sqlColumn) { diff --git a/teaql-sql-portable/src/main/java/io/teaql/core/sql/portable/PortableSQLRepository.java b/teaql-sql-portable/src/main/java/io/teaql/core/sql/portable/PortableSQLRepository.java index dc4da6c9..b9b59f76 100644 --- a/teaql-sql-portable/src/main/java/io/teaql/core/sql/portable/PortableSQLRepository.java +++ b/teaql-sql-portable/src/main/java/io/teaql/core/sql/portable/PortableSQLRepository.java @@ -124,6 +124,22 @@ public void setDialect(io.teaql.core.sql.dialect.SqlDialect dialect) { this.dialect = dialect; } + @Override + public String aggregateSql(UserContext context, io.teaql.core.AggrFunction function, + io.teaql.core.Expression operand, String columnSql) { + boolean direct = operand instanceof io.teaql.core.PropertyReference; + for (String name : operand.properties(context)) { + PropertyDescriptor property = allProperties.stream() + .filter(candidate -> name.equals(candidate.getName())) + .findFirst().orElse(null); + if (property != null && property.getType() != null + && property.getType().javaType() == BigDecimal.class) { + return dialect.decimalAggregateSql(function, columnSql, direct); + } + } + return null; + } + public String escapeIdentifier(String identifier) { return dialect.escapeIdentifier(identifier); } @@ -2103,7 +2119,8 @@ private static boolean isUnboundedTextFamily(String family) { private static boolean isDecimalFamily(String family) { return "DECIMAL".equals(family) || "NUMERIC".equals(family) - || "NUMBER".equals(family); + || "NUMBER".equals(family) + || "DECIMAL_TEXT".equals(family); } private String metadataColumnName(Map column, String table) { @@ -2123,7 +2140,8 @@ protected void createTable(UserContext context, String table, List co sb.append("CREATE TABLE ").append(table).append(" (\n"); sb.append(columns.stream() .map(column -> { - String dbColumn = dialect.escapeIdentifier(column.getColumnName()) + " " + dialect.mapColumnType(column.getType()); + String dbColumn = dialect.escapeIdentifier(column.getColumnName()) + " " + + dialect.columnTypeAndConstraintsSql(column); if (column.isIdColumn()) dbColumn += " NOT NULL PRIMARY KEY"; else if (column.isRequired()) dbColumn += " NOT NULL"; return dbColumn; @@ -2143,7 +2161,7 @@ protected void createTable(UserContext context, String table, List co protected void addColumn(UserContext context, SQLColumn column) { String sql = StrUtil.format("ALTER TABLE {} ADD COLUMN {} {}{}", dialect.escapeIdentifier(column.getTableName()), dialect.escapeIdentifier(column.getColumnName()), - dialect.mapColumnType(column.getType()), column.isRequired() ? " NOT NULL" : ""); + dialect.columnTypeAndConstraintsSql(column), column.isRequired() ? " NOT NULL" : ""); logInfo(sql + ";"); if (ensureTableEnabled(context)) { try { @@ -2434,7 +2452,8 @@ private AggregationResult aggregateWithIntent(UserContext userContext, SearchReq AggregationItem item = new AggregationItem(); for (SimpleNamedExpression function : request.getAggregations().getAggregates()) { String columnKey = findColumnKey(row, function.name()); - item.addValue(function, columnKey == null ? null : row.get(columnKey)); + Object raw = columnKey == null ? null : row.get(columnKey); + item.addValue(function, decodeAggregateValue(function, raw)); } for (SimpleNamedExpression dimension : request.getAggregations().getDimensions()) { String columnKey = findColumnKey(row, dimension.name()); @@ -2449,6 +2468,21 @@ private AggregationResult aggregateWithIntent(UserContext userContext, SearchReq } } + private Object decodeAggregateValue(SimpleNamedExpression selected, Object raw) { + if (raw == null || !(selected.getExpression() instanceof io.teaql.core.AggrExpression aggregate) + || aggregate.getOperator() == io.teaql.core.AggrFunction.COUNT + || aggregate.getExpressions().size() != 1 + || !(aggregate.getExpressions().get(0) instanceof io.teaql.core.PropertyReference reference)) { + return raw; + } + PropertyDescriptor property = allProperties.stream() + .filter(candidate -> reference.getPropertyName().equals(candidate.getName())) + .findFirst().orElse(null); + return property != null && property.getType() != null + && property.getType().javaType() == BigDecimal.class + ? dialect.decodeDecimalAggregate(raw) : raw; + } + // ========================================== // Stream support // ========================================== diff --git a/teaql-sqlite/src/main/java/io/teaql/core/sqlite/SqliteDataServiceExecutor.java b/teaql-sqlite/src/main/java/io/teaql/core/sqlite/SqliteDataServiceExecutor.java index 6b1b3b2b..2e1aec6c 100644 --- a/teaql-sqlite/src/main/java/io/teaql/core/sqlite/SqliteDataServiceExecutor.java +++ b/teaql-sqlite/src/main/java/io/teaql/core/sqlite/SqliteDataServiceExecutor.java @@ -21,9 +21,11 @@ public class SqliteDataServiceExecutor extends SqlDataServiceExecutor { System.getLogger(SqliteDataServiceExecutor.class.getName()); private final DataSource dataSource; private boolean soundexInstalled; + private boolean decimalInstalled; public SqliteDataServiceExecutor(String name, SqlExecutionAdapter executionAdapter, DataSource dataSource) { super(name, executionAdapter); + this.dialect = new SqliteDialect(); this.debugDatabaseKind = "sqlite"; this.topNRelationPlanPolicy = io.teaql.core.sql.portable.TopNRelationPlanPolicy.ALWAYS_PROBE; this.dataSource = dataSource; @@ -32,8 +34,13 @@ public SqliteDataServiceExecutor(String name, SqlExecutionAdapter executionAdapt @Override public void ensureSchema(UserContext context, io.teaql.core.SchemaExecutor.Invocation invocation) { io.teaql.core.SchemaExecutor.Invocation.requireContextOwned(invocation); - ensureSoundexOnEveryConnection(); List descriptors = EntityMetaFactory.requireFrom(context).allEntityDescriptors(); + if (descriptors.stream().flatMap(descriptor -> descriptor.getOwnProperties().stream()) + .flatMap(property -> io.teaql.core.sql.portable.SQLPropertyUtil.getColumns(property).stream()) + .anyMatch(this::isExactDecimalColumn)) { + ensureDecimalOnEveryConnection(); + } + ensureSoundexOnEveryConnection(); TeaQLDatabase dbAdapter = new TeaQLDatabase() { @Override @@ -97,8 +104,47 @@ public Optional indexExists( for (EntityDescriptor descriptor : descriptors) { PortableSQLRepository repository = new PortableSQLRepository(descriptor, dbAdapter, null); + repository.setDialect(dialect); repository.ensurePhysicalSchema(context); + for (var property : descriptor.getOwnProperties()) { + for (var column : io.teaql.core.sql.portable.SQLPropertyUtil.getColumns(property)) { + if (isExactDecimalColumn(column)) { + SqliteDecimalSchemaVerifier.verify( + getExecutionAdapter(), column.getTableName(), column.getColumnName()); + } + } + } + } + } + + private boolean isExactDecimalColumn(io.teaql.core.sql.SQLColumn column) { + String mapped = dialect.mapColumnType(column.getType()); + return mapped != null && mapped.toUpperCase(java.util.Locale.ROOT).startsWith("DECIMAL_TEXT"); + } + + private synchronized void ensureDecimalOnEveryConnection() { + if (decimalInstalled) return; + if (getExecutionAdapter() instanceof JdbcSqlExecutor jdbc) { + jdbc.addConnectionInitializer(connection -> { + try { + SqliteDecimalFunctions.register(connection); + } catch (java.sql.SQLException failure) { + throw new IllegalStateException("Unable to register SQLite exact Decimal functions", failure); + } + }); + } + try { + getExecutionAdapter().queryForList( + "SELECT teaql_decimal_valid('1') AS valid, " + + "'1' = '1' COLLATE TEAQL_DECIMAL AS ordered, " + + "teaql_decimal_sum(value) AS total FROM (SELECT '1' AS value)", + new Object[0]); + } catch (RuntimeException missing) { + throw new IllegalStateException( + "SQLite exact Decimal functions and collation must be registered on every connection", + missing); } + decimalInstalled = true; } private synchronized void ensureSoundexOnEveryConnection() { diff --git a/teaql-sqlite/src/main/java/io/teaql/core/sqlite/SqliteDecimalFunctions.java b/teaql-sqlite/src/main/java/io/teaql/core/sqlite/SqliteDecimalFunctions.java new file mode 100644 index 00000000..1e777b0d --- /dev/null +++ b/teaql-sqlite/src/main/java/io/teaql/core/sqlite/SqliteDecimalFunctions.java @@ -0,0 +1,89 @@ +package io.teaql.core.sqlite; + +import java.math.BigDecimal; +import java.sql.Connection; +import java.sql.SQLException; +import org.sqlite.Collation; +import org.sqlite.Function; +import org.sqlite.core.Codes; + +/** Connection-local exact Decimal primitives for SQLite's text-affinity columns. */ +final class SqliteDecimalFunctions { + + static final String COLLATION = "TEAQL_DECIMAL"; + static final String VALID = "teaql_decimal_valid"; + static final String SUM = "teaql_decimal_sum"; + + private SqliteDecimalFunctions() {} + + private static boolean hasExponent(String text) { + return text != null && (text.indexOf('e') >= 0 || text.indexOf('E') >= 0); + } + + static void register(Connection connection) throws SQLException { + Connection sqlite = connection instanceof org.sqlite.SQLiteConnection + ? connection : connection.unwrap(org.sqlite.SQLiteConnection.class); + Collation.create(sqlite, COLLATION, new Collation() { + @Override + protected int xCompare(String left, String right) { + try { + return new BigDecimal(left).compareTo(new BigDecimal(right)); + } catch (NumberFormatException corruptLegacyValue) { + // A collation cannot report an error. Schema CHECK and row + // decoding reject invalid text at their respective edges. + return left.compareTo(right); + } + } + }); + Function.create(sqlite, VALID, new Function() { + @Override + protected void xFunc() throws SQLException { + if (value_type(0) != Codes.SQLITE_TEXT) { + result(0); + return; + } + String text = value_text(0); + boolean valid; + try { + // Exponent notation is never canonical plain text. Reject it + // before toPlainString(), which could expand a tiny input into + // billions of characters (for example, 1e-1000000000). + valid = text != null && !hasExponent(text) + && new BigDecimal(text).toPlainString().equals(text); + } catch (NumberFormatException invalid) { + valid = false; + } + result(valid ? 1 : 0); + } + }, 1, Function.FLAG_DETERMINISTIC); + Function.create(sqlite, SUM, new Function.Aggregate() { + private BigDecimal total; + + @Override + protected void xStep() throws SQLException { + if (value_type(0) == Codes.SQLITE_NULL) return; + if (value_type(0) != Codes.SQLITE_TEXT) { + error("invalid decimal storage type"); + return; + } + String text = value_text(0); + if (hasExponent(text)) { + error("invalid decimal text"); + return; + } + try { + BigDecimal value = new BigDecimal(text); + total = total == null ? value : total.add(value); + } catch (NumberFormatException invalid) { + error("invalid decimal text"); + } + } + + @Override + protected void xFinal() throws SQLException { + if (total == null) result(); + else result(total.toPlainString()); + } + }, 1); + } +} diff --git a/teaql-sqlite/src/main/java/io/teaql/core/sqlite/SqliteDecimalSchemaVerifier.java b/teaql-sqlite/src/main/java/io/teaql/core/sqlite/SqliteDecimalSchemaVerifier.java new file mode 100644 index 00000000..738eb915 --- /dev/null +++ b/teaql-sqlite/src/main/java/io/teaql/core/sqlite/SqliteDecimalSchemaVerifier.java @@ -0,0 +1,136 @@ +package io.teaql.core.sqlite; + +import io.teaql.dataservice.sql.SqlExecutionAdapter; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** Fail closed when an existing Decimal column lacks TeaQL's exact semantics. */ +final class SqliteDecimalSchemaVerifier { + + private SqliteDecimalSchemaVerifier() {} + + static void verify(SqlExecutionAdapter adapter, String table, String column) { + List> schema = adapter.queryForList( + "SELECT sql FROM sqlite_master WHERE type='table' AND name=?", + new Object[] {table}); + if (schema.size() != 1) throw incompatible(table, column, "table definition is missing"); + String ddl = String.valueOf(value(schema.get(0), "sql")); + String expected = column.toLowerCase(Locale.ROOT); + List check = List.of("check", "(", expected, "is", "null", "or", + SqliteDecimalFunctions.VALID, "(", expected, ")", ")"); + boolean validated = columns(ddl).stream().anyMatch(tokens -> tokens.size() > 2 + && expected.equals(tokens.get(0)) && "decimal_text".equals(tokens.get(1)) + && hasStandaloneCheck(tokens, check)); + if (!validated) throw incompatible(table, column, "required Decimal CHECK is missing"); + + List> program = adapter.queryForList( + "EXPLAIN SELECT 1 FROM " + quote(table) + " NOT INDEXED WHERE " + + quote(column) + " = ?", new Object[] {"0"}); + boolean collated = program.stream().anyMatch(row -> { + String opcode = String.valueOf(value(row, "opcode")); + Object p4 = value(row, "p4"); + return ("Eq".equals(opcode) || "Ne".equals(opcode)) + && p4 != null && String.valueOf(p4).startsWith(SqliteDecimalFunctions.COLLATION); + }); + if (!collated) throw incompatible(table, column, "TEAQL_DECIMAL collation is missing"); + } + + private static IllegalStateException incompatible(String table, String column, String reason) { + return new IllegalStateException("Ensure Schema incompatible exact Decimal column '" + + column + "' on table '" + table + "': " + reason + + "; migrate the column explicitly before retrying"); + } + + private static String quote(String identifier) { + return "\"" + identifier.replace("\"", "\"\"") + "\""; + } + + private static Object value(Map row, String name) { + for (Map.Entry entry : row.entrySet()) { + if (name.equalsIgnoreCase(entry.getKey())) return entry.getValue(); + } + return null; + } + + private static boolean hasStandaloneCheck(List tokens, List check) { + int depth = 0; + for (int index = 0; index < tokens.size(); index++) { + if (depth == 0 && "check".equals(tokens.get(index)) + && index + check.size() <= tokens.size() + && tokens.subList(index, index + check.size()).equals(check)) return true; + if ("(".equals(tokens.get(index))) depth++; + else if (")".equals(tokens.get(index))) depth--; + } + return false; + } + + private static List> columns(String ddl) { + List tokens = tokens(ddl); + int opening = tokens.indexOf("("); + if (opening < 0) return List.of(); + List> columns = new ArrayList<>(); + List current = new ArrayList<>(); + int depth = 1; + for (int index = opening + 1; index < tokens.size(); index++) { + String token = tokens.get(index); + if ("(".equals(token)) depth++; + else if (")".equals(token) && --depth == 0) { + columns.add(current); + break; + } else if (",".equals(token) && depth == 1) { + columns.add(current); + current = new ArrayList<>(); + continue; + } + current.add(token); + } + return columns; + } + + private static List tokens(String sql) { + List tokens = new ArrayList<>(); + for (int offset = 0; offset < sql.length();) { + char current = sql.charAt(offset); + if (Character.isWhitespace(current)) { + offset++; + } else if (current == '-' && offset + 1 < sql.length() && sql.charAt(offset + 1) == '-') { + offset += 2; + while (offset < sql.length() && sql.charAt(offset) != '\n') offset++; + } else if (current == '/' && offset + 1 < sql.length() && sql.charAt(offset + 1) == '*') { + offset += 2; + while (offset + 1 < sql.length() + && !(sql.charAt(offset) == '*' && sql.charAt(offset + 1) == '/')) offset++; + offset = Math.min(sql.length(), offset + 2); + } else if (current == '\'' || current == '"' || current == '`' || current == '[') { + char close = current == '[' ? ']' : current; + offset++; + StringBuilder content = new StringBuilder(); + while (offset < sql.length()) { + char next = sql.charAt(offset++); + if (next == close) { + if (offset < sql.length() && sql.charAt(offset) == close) { + content.append(close); + offset++; + } else break; + } else content.append(next); + } + tokens.add(current == '\'' ? "" + : content.toString().toLowerCase(Locale.ROOT)); + } else if (Character.isLetterOrDigit(current) || current == '_' || current == '$') { + int start = offset++; + while (offset < sql.length()) { + char next = sql.charAt(offset); + if (!(Character.isLetterOrDigit(next) || next == '_' || next == '$')) break; + offset++; + } + tokens.add(sql.substring(start, offset).toLowerCase(Locale.ROOT)); + } else { + tokens.add(String.valueOf(current)); + offset++; + } + } + return tokens; + } +} diff --git a/teaql-sqlite/src/main/java/io/teaql/core/sqlite/SqliteDialect.java b/teaql-sqlite/src/main/java/io/teaql/core/sqlite/SqliteDialect.java new file mode 100644 index 00000000..a6bf58c9 --- /dev/null +++ b/teaql-sqlite/src/main/java/io/teaql/core/sqlite/SqliteDialect.java @@ -0,0 +1,60 @@ +package io.teaql.core.sqlite; + +import java.math.BigDecimal; +import java.math.BigInteger; +import io.teaql.core.sql.SQLColumn; +import io.teaql.core.AggrFunction; +import io.teaql.core.sql.dialect.PostgreSqlDialect; +import java.util.Locale; + +/** SQLite-specific physical Decimal storage; ordinary SQL remains portable. */ +final class SqliteDialect extends PostgreSqlDialect { + + @Override + public String mapColumnType(String type) { + String mapped = super.mapColumnType(type); + if (mapped == null) return null; + String normalized = mapped.trim().toUpperCase(Locale.ROOT); + for (String family : new String[] {"DECIMAL", "NUMERIC"}) { + if (normalized.equals(family) || normalized.startsWith(family + "(")) { + return "DECIMAL_TEXT" + mapped.trim().substring(family.length()); + } + } + return mapped; + } + + @Override + public String columnTypeAndConstraintsSql(SQLColumn column) { + String type = mapColumnType(column.getType()); + if (type == null) return null; + if (!type.toUpperCase(Locale.ROOT).startsWith("DECIMAL_TEXT")) return type; + String name = escapeIdentifier(column.getColumnName()); + return type + " COLLATE " + SqliteDecimalFunctions.COLLATION + + " CHECK (" + name + " IS NULL OR " + SqliteDecimalFunctions.VALID + + "(" + name + "))"; + } + + @Override + public String decimalAggregateSql(AggrFunction function, String columnSql, boolean directColumn) { + if (!directColumn) { + throw new IllegalArgumentException( + "SQLite exact Decimal aggregation requires a direct Decimal column"); + } + return switch (function) { + case SUM -> SqliteDecimalFunctions.SUM + "(" + columnSql + ")"; + case SELF, MIN, MAX, COUNT -> null; + default -> throw new IllegalArgumentException( + "SQLite exact Decimal aggregate is unsupported: " + function); + }; + } + + @Override + public Object decodeDecimalAggregate(Object value) { + if (value == null || value instanceof BigDecimal) return value; + if (value instanceof CharSequence text) return new BigDecimal(text.toString()); + if (value instanceof BigInteger || value instanceof Long || value instanceof Integer) { + return new BigDecimal(value.toString()); + } + throw new IllegalArgumentException("SQLite exact Decimal aggregate returned an inexact value"); + } +} diff --git a/teaql-sqlite/src/test/java/io/teaql/core/sqlite/SqliteDecimalFunctionsTest.java b/teaql-sqlite/src/test/java/io/teaql/core/sqlite/SqliteDecimalFunctionsTest.java new file mode 100644 index 00000000..000018d2 --- /dev/null +++ b/teaql-sqlite/src/test/java/io/teaql/core/sqlite/SqliteDecimalFunctionsTest.java @@ -0,0 +1,303 @@ +package io.teaql.core.sqlite; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import io.teaql.core.BaseEntity; +import io.teaql.core.BaseRequest; +import io.teaql.core.criteria.Operator; +import io.teaql.core.meta.SimpleEntityMetaFactory; +import io.teaql.core.sql.GenericSQLProperty; +import io.teaql.core.sql.SQLEntityDescriptor; +import io.teaql.provider.jdbc.JdbcSqlExecutor; +import io.teaql.runtime.DefaultUserContext; +import io.teaql.runtime.TeaQLRuntime; +import java.math.BigDecimal; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.Statement; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.Test; +import org.sqlite.SQLiteDataSource; + +public class SqliteDecimalFunctionsTest { + + @Test + public void virtualColumnsWithoutPhysicalTypeRemainUnmapped() { + assertNull(new SqliteDialect().mapColumnType(null)); + } + + public static class DecimalRow extends BaseEntity { + private BigDecimal amount; + + @Override public String typeName() { return "DecimalRow"; } + + @Override public void __internalSet(String property, Object value) { + if ("amount".equals(property)) amount = (BigDecimal) value; + else super.__internalSet(property, value); + } + + @Override public Object __internalGet(String property) { + return "amount".equals(property) ? amount : super.__internalGet(property); + } + } + + public static class DecimalRequest extends BaseRequest { + public DecimalRequest() { super(DecimalRow.class); } + @Override public String getTypeName() { return "DecimalRow"; } + public DecimalRequest comment(String comment) { + super.internalComment(comment); + return this; + } + + public DecimalRequest amountIs(BigDecimal amount) { + appendSearchCriteria(createBasicSearchCriteria("amount", Operator.EQUAL, amount)); + return this; + } + } + + @Test + public void exactStorageComparisonAndSumSurviveJdbcDecimalBinding() throws Exception { + try (Connection connection = DriverManager.getConnection("jdbc:sqlite::memory:")) { + SqliteDecimalFunctions.register(connection); + SqliteDecimalFunctions.register(connection); + try (Statement statement = connection.createStatement()) { + statement.execute("CREATE TABLE amounts (value DECIMAL_TEXT COLLATE TEAQL_DECIMAL " + + "CHECK (value IS NULL OR teaql_decimal_valid(value)))"); + } + for (String value : new String[] {"3.50", "2", "10", "123456789012.3456789", "-0.1"}) { + try (PreparedStatement statement = connection.prepareStatement( + "INSERT INTO amounts (value) VALUES (?)")) { + statement.setBigDecimal(1, new BigDecimal(value)); + statement.executeUpdate(); + } + } + try (Statement statement = connection.createStatement(); + ResultSet rows = statement.executeQuery( + "SELECT typeof(value), value FROM amounts ORDER BY value LIMIT 1")) { + rows.next(); + assertEquals("text", rows.getString(1)); + assertEquals("-0.1", rows.getString(2)); + } + try (Statement statement = connection.createStatement(); + ResultSet rows = statement.executeQuery( + "SELECT value FROM amounts WHERE value = '3.5'")) { + rows.next(); + assertEquals("3.50", rows.getString(1)); + } + try (Statement statement = connection.createStatement(); + ResultSet rows = statement.executeQuery( + "SELECT teaql_decimal_sum(value), typeof(sum(value)) FROM amounts")) { + rows.next(); + assertEquals("123456789027.7456789", rows.getString(1)); + assertEquals("real", rows.getString(2)); + } + try (Statement statement = connection.createStatement()) { + assertThrows(java.sql.SQLException.class, + () -> statement.executeUpdate("INSERT INTO amounts (value) VALUES ('not-a-decimal')")); + assertThrows(java.sql.SQLException.class, + () -> statement.executeUpdate("INSERT INTO amounts (value) VALUES (X'31')")); + assertThrows(java.sql.SQLException.class, + () -> statement.executeUpdate( + "INSERT INTO amounts (value) VALUES ('1e-1000000000')")); + } + } + } + + @Test + public void exponentTextCannotAmplifyDecimalValidationOrAggregation() throws Exception { + try (Connection connection = DriverManager.getConnection("jdbc:sqlite::memory:")) { + SqliteDecimalFunctions.register(connection); + try (Statement statement = connection.createStatement(); + ResultSet rows = statement.executeQuery( + "SELECT teaql_decimal_valid('1e-1000000000'), " + + "teaql_decimal_valid('1E+1000000000'), " + + "teaql_decimal_valid('0.0000001')")) { + assertTrue(rows.next()); + assertEquals(0, rows.getInt(1)); + assertEquals(0, rows.getInt(2)); + assertEquals(1, rows.getInt(3)); + } + try (Statement statement = connection.createStatement()) { + assertThrows(java.sql.SQLException.class, + () -> statement.executeQuery( + "SELECT teaql_decimal_sum('1e-1000000000')")); + } + } + } + + @Test + public void contextEnsureSchemaCreatesExactDecimalStorageAndRepeats() throws Exception { + Path file = Files.createTempFile("teaql-java-exact-decimal-", ".db"); + try { + SQLiteDataSource dataSource = new SQLiteDataSource(); + dataSource.setUrl("jdbc:sqlite:" + file); + JdbcSqlExecutor jdbc = new JdbcSqlExecutor(dataSource); + SqliteDataServiceExecutor provider = new SqliteDataServiceExecutor( + "sqlite", jdbc, dataSource); + SQLEntityDescriptor entity = new SQLEntityDescriptor(); + entity.setType("DecimalRow"); + entity.setTargetType(DecimalRow.class); + entity.setEntitySupplier(DecimalRow::new); + entity.setDataService("sqlite"); + ((GenericSQLProperty) entity.addSimpleProperty("id", Long.class)) + .setColumnType("BIGINT"); + ((GenericSQLProperty) entity.addSimpleProperty("version", Long.class)) + .setColumnType("BIGINT"); + ((GenericSQLProperty) entity.addSimpleProperty("bucket", String.class)) + .setColumnType("VARCHAR(20)"); + ((GenericSQLProperty) entity.addSimpleProperty("amount", BigDecimal.class)) + .setColumnType("NUMERIC(19,7)"); + SimpleEntityMetaFactory metadata = new SimpleEntityMetaFactory(); + metadata.register(entity); + DefaultUserContext context = new DefaultUserContext(TeaQLRuntime.builder() + .metadata(metadata).dataService("sqlite", provider).build()); + + context.ensureSchema(); + context.ensureSchema(); + String ddl = String.valueOf(jdbc.queryForList( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='decimal_row_data'", + new Object[0]).get(0).get("sql")); + assertTrue(ddl, ddl.contains("DECIMAL_TEXT(19,7) COLLATE TEAQL_DECIMAL")); + assertTrue(ddl, ddl.contains("CHECK (amount IS NULL OR teaql_decimal_valid(amount))")); + jdbc.update("INSERT INTO decimal_row_data (id, version, bucket, amount) VALUES (?, ?, ?, ?)", + new Object[] {1L, 1L, "A", new BigDecimal("3.50")}); + assertEquals("3.50", jdbc.queryForList( + "SELECT amount FROM decimal_row_data WHERE amount = ?", new Object[] { + new BigDecimal("3.5")}).get(0).get("amount")); + DecimalRequest query = new DecimalRequest(); + query.sum("total", "amount"); + Object total = query.comment("calculate exact order amount") + .purpose("verify modeled Q aggregation") + .aggregation(context).getData().get(0).getValues().values().iterator().next(); + assertEquals(new BigDecimal("3.50"), total); + + jdbc.update("INSERT INTO decimal_row_data (id, version, bucket, amount) VALUES (?, ?, ?, ?)", + new Object[] {2L, 1L, "A", new BigDecimal("10.25")}); + jdbc.update("INSERT INTO decimal_row_data (id, version, bucket, amount) VALUES (?, ?, ?, ?)", + new Object[] {3L, 1L, "B", new BigDecimal("123456789012.3456789")}); + DecimalRequest selected = new DecimalRequest(); + for (String property : new String[] {"id", "version", "amount"}) { + selected.selectProperty(property); + } + var loaded = selected.amountIs(new BigDecimal("3.5")) + .comment("load exact Decimal row") + .purpose("verify typed Q and E Decimal hydration") + .executeForList(context); + assertEquals(1, loaded.size()); + assertEquals(new BigDecimal("3.50"), loaded.get(0).getProperty("amount")); + + DecimalRequest grouped = new DecimalRequest(); + grouped.groupBy("bucket"); + grouped.sum("total", "amount"); + grouped.min("smallest", "amount"); + grouped.max("largest", "amount"); + grouped.countProperty("items", "amount"); + var rows = grouped.comment("aggregate exact Decimal buckets") + .purpose("verify grouped monetary totals").aggregation(context).toList(); + assertEquals(2, rows.size()); + var first = rows.stream().filter(row -> "A".equals(row.get("bucket"))).findFirst().orElseThrow(); + assertEquals(new BigDecimal("13.75"), first.get("total")); + assertEquals(new BigDecimal("3.50"), first.get("smallest")); + assertEquals(new BigDecimal("10.25"), first.get("largest")); + assertEquals(2L, ((Number) first.get("items")).longValue()); + var second = rows.stream().filter(row -> "B".equals(row.get("bucket"))).findFirst().orElseThrow(); + assertEquals(new BigDecimal("123456789012.3456789"), second.get("total")); + + DecimalRequest unsupported = new DecimalRequest(); + unsupported.avg("average", "amount"); + assertThrows(IllegalArgumentException.class, () -> unsupported + .comment("reject approximate Decimal average") + .purpose("prevent silent financial rounding").aggregation(context)); + + for (String value : new String[] {"1E-7", "1E+8"}) { + jdbc.update("INSERT INTO decimal_row_data (id, version, bucket, amount) VALUES (?, ?, ?, ?)", + new Object[] {value.equals("1E-7") ? 4L : 5L, 1L, "C", new BigDecimal(value)}); + var matched = jdbc.queryForList( + "SELECT amount FROM decimal_row_data WHERE amount = ?", + new Object[] {new BigDecimal(value)}); + assertEquals(1, matched.size()); + assertEquals(new BigDecimal(value).toPlainString(), matched.get(0).get("amount")); + } + } finally { + Files.deleteIfExists(file); + } + } + + @Test + public void existingDecimalTablesWithoutTheFullContractFailClosed() throws Exception { + String[] invalidDefinitions = { + "amount NUMERIC(19,7)", + "amount DECIMAL_TEXT COLLATE TEAQL_DECIMAL", + "amount DECIMAL_TEXT CHECK (amount IS NULL OR teaql_decimal_valid(amount))", + "amount DECIMAL_TEXT COLLATE TEAQL_DECIMAL " + + "DEFAULT 'CHECK (amount IS NULL OR teaql_decimal_valid(amount))'", + "amount DECIMAL_TEXT COLLATE TEAQL_DECIMAL " + + "/* CHECK (amount IS NULL OR teaql_decimal_valid(amount)) */" + }; + for (String definition : invalidDefinitions) { + Path file = Files.createTempFile("teaql-java-decimal-legacy-", ".db"); + try { + SQLiteDataSource dataSource = new SQLiteDataSource(); + dataSource.setUrl("jdbc:sqlite:" + file); + JdbcSqlExecutor jdbc = new JdbcSqlExecutor(dataSource); + jdbc.addConnectionInitializer(connection -> { + try { + SqliteDecimalFunctions.register(connection); + } catch (java.sql.SQLException failure) { + throw new IllegalStateException(failure); + } + }); + jdbc.execute("CREATE TABLE amounts (" + definition + ")"); + IllegalStateException failure = assertThrows(IllegalStateException.class, + () -> SqliteDecimalSchemaVerifier.verify(jdbc, "amounts", "amount")); + assertTrue(failure.getMessage(), failure.getMessage().contains("incompatible exact Decimal")); + } finally { + Files.deleteIfExists(file); + } + } + } + + @Test + public void contextEnsureSchemaDoesNotAdoptLegacyNumericStorage() throws Exception { + Path file = Files.createTempFile("teaql-java-decimal-context-legacy-", ".db"); + try { + SQLiteDataSource dataSource = new SQLiteDataSource(); + dataSource.setUrl("jdbc:sqlite:" + file); + JdbcSqlExecutor jdbc = new JdbcSqlExecutor(dataSource); + String ddl = "CREATE TABLE decimal_row_data (id BIGINT NOT NULL PRIMARY KEY, " + + "version BIGINT, bucket VARCHAR(20), amount NUMERIC(19,7))"; + jdbc.execute(ddl); + + SQLEntityDescriptor entity = new SQLEntityDescriptor(); + entity.setType("DecimalRow"); + entity.setTargetType(DecimalRow.class); + entity.setEntitySupplier(DecimalRow::new); + entity.setDataService("sqlite"); + ((GenericSQLProperty) entity.addSimpleProperty("id", Long.class)).setColumnType("BIGINT"); + ((GenericSQLProperty) entity.addSimpleProperty("version", Long.class)).setColumnType("BIGINT"); + ((GenericSQLProperty) entity.addSimpleProperty("bucket", String.class)) + .setColumnType("VARCHAR(20)"); + ((GenericSQLProperty) entity.addSimpleProperty("amount", BigDecimal.class)) + .setColumnType("NUMERIC(19,7)"); + SimpleEntityMetaFactory metadata = new SimpleEntityMetaFactory(); + metadata.register(entity); + DefaultUserContext context = new DefaultUserContext(TeaQLRuntime.builder() + .metadata(metadata) + .dataService("sqlite", new SqliteDataServiceExecutor("sqlite", jdbc, dataSource)) + .build()); + + assertThrows(RuntimeException.class, context::ensureSchema); + assertEquals(ddl, jdbc.queryForList( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='decimal_row_data'", + new Object[0]).get(0).get("sql")); + } finally { + Files.deleteIfExists(file); + } + } +}