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