From cc9f0e45e5a7ecf528f9a74df4010a51444c5cd2 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Sat, 17 Oct 2026 03:34:58 +0800 Subject: [PATCH 01/11] Add Java SQLite exact Decimal connection primitives --- .../core/sqlite/SqliteDecimalFunctions.java | 68 +++++++++++++++++++ .../sqlite/SqliteDecimalFunctionsTest.java | 58 ++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 teaql-sqlite/src/main/java/io/teaql/core/sqlite/SqliteDecimalFunctions.java create mode 100644 teaql-sqlite/src/test/java/io/teaql/core/sqlite/SqliteDecimalFunctionsTest.java 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..c13f2ffe --- /dev/null +++ b/teaql-sqlite/src/main/java/io/teaql/core/sqlite/SqliteDecimalFunctions.java @@ -0,0 +1,68 @@ +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; + +/** 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() {} + + 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 { + String text = value_text(0); + boolean valid; + try { + valid = text != null && 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 { + String text = value_text(0); + if (text == null) 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/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..38d38588 --- /dev/null +++ b/teaql-sqlite/src/test/java/io/teaql/core/sqlite/SqliteDecimalFunctionsTest.java @@ -0,0 +1,58 @@ +package io.teaql.core.sqlite; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +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 org.junit.Test; + +public class SqliteDecimalFunctionsTest { + + @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')")); + } + } + } +} From adc3c12bd9ffe1f5b50c1cf6e8c71c768ed5b897 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Sat, 17 Oct 2026 03:47:30 +0800 Subject: [PATCH 02/11] Add exact SQLite Decimal schema and Q semantics --- .../teaql/provider/jdbc/JdbcSqlExecutor.java | 3 + .../io/teaql/core/sql/SQLColumnResolver.java | 8 + .../io/teaql/core/sql/dialect/SqlDialect.java | 17 ++ .../sql/expression/AggrExpressionParser.java | 4 +- .../sql/portable/PortableSQLRepository.java | 42 +++- .../sqlite/SqliteDataServiceExecutor.java | 45 +++- .../core/sqlite/SqliteDecimalFunctions.java | 11 +- .../sqlite/SqliteDecimalSchemaVerifier.java | 136 ++++++++++++ .../io/teaql/core/sqlite/SqliteDialect.java | 58 +++++ .../sqlite/SqliteDecimalFunctionsTest.java | 204 ++++++++++++++++++ 10 files changed, 521 insertions(+), 7 deletions(-) create mode 100644 teaql-sqlite/src/main/java/io/teaql/core/sqlite/SqliteDecimalSchemaVerifier.java create mode 100644 teaql-sqlite/src/main/java/io/teaql/core/sqlite/SqliteDialect.java 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..9d1448d5 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 @@ -281,6 +281,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()); } 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..33b8dce1 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,14 @@ 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(column -> dialect.mapColumnType(column.getType()) + .toUpperCase(java.util.Locale.ROOT).startsWith("DECIMAL_TEXT"))) { + ensureDecimalOnEveryConnection(); + } + ensureSoundexOnEveryConnection(); TeaQLDatabase dbAdapter = new TeaQLDatabase() { @Override @@ -97,8 +105,43 @@ 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 (dialect.mapColumnType(column.getType()) + .toUpperCase(java.util.Locale.ROOT).startsWith("DECIMAL_TEXT")) { + SqliteDecimalSchemaVerifier.verify( + getExecutionAdapter(), column.getTableName(), column.getColumnName()); + } + } + } + } + } + + 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 index c13f2ffe..c158dff8 100644 --- a/teaql-sqlite/src/main/java/io/teaql/core/sqlite/SqliteDecimalFunctions.java +++ b/teaql-sqlite/src/main/java/io/teaql/core/sqlite/SqliteDecimalFunctions.java @@ -5,6 +5,7 @@ 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 { @@ -33,6 +34,10 @@ protected int xCompare(String left, String 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 { @@ -48,8 +53,12 @@ protected void xFunc() throws SQLException { @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 (text == null) return; try { BigDecimal value = new BigDecimal(text); total = total == null ? value : total.add(value); 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..df482b9e --- /dev/null +++ b/teaql-sqlite/src/main/java/io/teaql/core/sqlite/SqliteDialect.java @@ -0,0 +1,58 @@ +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); + 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.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 index 38d38588..e8524c3a 100644 --- a/teaql-sqlite/src/test/java/io/teaql/core/sqlite/SqliteDecimalFunctionsTest.java +++ b/teaql-sqlite/src/test/java/io/teaql/core/sqlite/SqliteDecimalFunctionsTest.java @@ -2,17 +2,59 @@ import static org.junit.Assert.assertEquals; 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 { + 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:")) { @@ -52,7 +94,169 @@ public void exactStorageComparisonAndSumSurviveJdbcDecimalBinding() throws Excep 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')")); } } } + + @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)); + } 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); + } + } } From 6134566c3540598b20e8385e3f12ce33a059ce24 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Sat, 17 Oct 2026 04:03:40 +0800 Subject: [PATCH 03/11] Verify generated Java exact Decimal and default SQLite workspace --- .../GeneratedExactDecimalProbe.java | 124 ++++++++++++++++++ .../GeneratedWorkspaceStartupProbe.java | 18 +++ examples/exact-decimal/README.md | 25 ++++ examples/exact-decimal/model.xml | 18 +++ examples/exact-decimal/verify.sh | 66 ++++++++++ examples/verify-runtime-examples.sh | 4 +- 6 files changed, 254 insertions(+), 1 deletion(-) create mode 100644 examples/exact-decimal/GeneratedExactDecimalProbe.java create mode 100644 examples/exact-decimal/GeneratedWorkspaceStartupProbe.java create mode 100644 examples/exact-decimal/README.md create mode 100644 examples/exact-decimal/model.xml create mode 100644 examples/exact-decimal/verify.sh diff --git a/examples/exact-decimal/GeneratedExactDecimalProbe.java b/examples/exact-decimal/GeneratedExactDecimalProbe.java new file mode 100644 index 00000000..be98f47d --- /dev/null +++ b/examples/exact-decimal/GeneratedExactDecimalProbe.java @@ -0,0 +1,124 @@ +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.SqliteCommittedMutationRecorder; +import io.teaql.core.sqlite.SqliteDataServiceExecutor; +import io.teaql.core.sqlite.SqliteModelCheckedCommittedTarget; +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 static final String SOURCE = "generated-exact-decimal"; + + private GeneratedExactDecimalProbe() {} + + private static TeaQLRuntime runtime(SQLiteDataSource dataSource, JdbcSqlExecutor sql, + SqliteCommittedMutationRecorder recorder) { + var builder = TeaQLRuntime.builder() + .metadata(new SimpleEntityMetaFactory()) + .dataService("default", new SqliteDataServiceExecutor("default", sql, dataSource)) + .idGenerationService(new IdSpaceIdGenerator(new WorkspaceTeaQLDatabase(sql, dataSource))) + .executionLogging(false); + if (recorder != null) builder.committedMutationRecorder(recorder); + return builder.build().install(GeneratedRuntimeModule.module()); + } + + public static void main(String[] args) throws Exception { + String modelHash = System.getenv("TEAQL_TEST_MODEL_HASH"); + if (modelHash == null || !modelHash.matches("sha256:[0-9a-f]{64}")) { + throw new IllegalArgumentException("Exact model hash is required"); + } + 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); + new CustomUserContext(runtime(sourceData, sourceSql, null)).ensureSchema(); + + var recorder = new SqliteCommittedMutationRecorder(sourceSql, SOURCE, modelHash); + recorder.eventStore().ensureSchema(); + var source = new CustomUserContext(runtime(sourceData, sourceSql, recorder)); + 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"); + + DecimalEntry first = 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"); + } + + var outbox = recorder.eventStore().outboxAfter(SOURCE, 0, 10); + if (outbox.size() != 2) throw new AssertionError("two audited saves must emit two committed sets"); + SQLiteDataSource targetData = new SQLiteDataSource(); + targetData.setUrl("jdbc:sqlite:" + directory.resolve("target.sqlite")); + JdbcSqlExecutor targetSql = new JdbcSqlExecutor(targetData); + var target = new CustomUserContext(runtime(targetData, targetSql, null)); + target.ensureSchema(); + var checkedTarget = new SqliteModelCheckedCommittedTarget(targetSql, SOURCE, modelHash); + checkedTarget.ensureSchema(); + var appliedFirst = checkedTarget.apply(target, outbox.get(0)); + var appliedSecond = checkedTarget.apply(target, outbox.get(1)); + var duplicate = checkedTarget.apply(target, outbox.get(0)); + if (appliedFirst.duplicate() || appliedSecond.duplicate() || !duplicate.duplicate() + || appliedSecond.checkpoint() != 2 || duplicate.checkpoint() != 1) { + throw new AssertionError("committed Decimal apply and duplicate receipts are invalid"); + } + var replica = Q.decimalEntriesWithMinimalFields().selectAmount() + .withIdIs(first.getId()).limit(1) + .comment("Read a replicated generated Decimal row") + .purpose("Verify exact target hydration") + .executeForOne(target); + if (replica == null || E.decimalEntry(replica).getAmount().eval() + .compareTo(new BigDecimal("0.1")) != 0 + || sum(target).compareTo(new BigDecimal("0.3")) != 0) { + throw new AssertionError("replicated Decimal value or SUM is inexact"); + } + System.out.println("GENERATED_EXACT_DECIMAL_PASS q=1 e=1 save=2 sum=0.3 cmr=2 duplicate=1"); + } + + 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; + } +} 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..23419e53 --- /dev/null +++ b/examples/exact-decimal/README.md @@ -0,0 +1,25 @@ +# 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`. +- Two committed mutation sets applied to a second model-checked SQLite target, + with an exact duplicate receipt and target Q/E/SUM readback. +- 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. 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..8976a815 --- /dev/null +++ b/examples/exact-decimal/verify.sh @@ -0,0 +1,66 @@ +#!/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" +export TEAQL_TEST_MODEL_HASH="sha256:$(sha256sum "$model_file" | cut -d' ' -f1)" + +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=2 sum=0.3 cmr=2 duplicate=1$' \ + "$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' From f5d4278e9a9b1349785ab9e35bbb70413f26a315 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Sun, 18 Oct 2026 08:04:30 +0800 Subject: [PATCH 04/11] Keep exact Decimal generated example independent of committed replication --- .../GeneratedExactDecimalProbe.java | 55 +++---------------- examples/exact-decimal/README.md | 4 +- examples/exact-decimal/verify.sh | 3 +- 3 files changed, 11 insertions(+), 51 deletions(-) diff --git a/examples/exact-decimal/GeneratedExactDecimalProbe.java b/examples/exact-decimal/GeneratedExactDecimalProbe.java index be98f47d..88fb108c 100644 --- a/examples/exact-decimal/GeneratedExactDecimalProbe.java +++ b/examples/exact-decimal/GeneratedExactDecimalProbe.java @@ -4,9 +4,7 @@ import com.example.exactdecimalservice.platform.Platform; import io.teaql.core.meta.SimpleEntityMetaFactory; import io.teaql.core.sql.portable.IdSpaceIdGenerator; -import io.teaql.core.sqlite.SqliteCommittedMutationRecorder; import io.teaql.core.sqlite.SqliteDataServiceExecutor; -import io.teaql.core.sqlite.SqliteModelCheckedCommittedTarget; import io.teaql.provider.jdbc.JdbcSqlExecutor; import io.teaql.runtime.TeaQLRuntime; import java.math.BigDecimal; @@ -16,35 +14,24 @@ /** Application-owned exact Decimal check against a freshly generated domain library. */ public final class GeneratedExactDecimalProbe { - private static final String SOURCE = "generated-exact-decimal"; - private GeneratedExactDecimalProbe() {} - private static TeaQLRuntime runtime(SQLiteDataSource dataSource, JdbcSqlExecutor sql, - SqliteCommittedMutationRecorder recorder) { - var builder = TeaQLRuntime.builder() + 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); - if (recorder != null) builder.committedMutationRecorder(recorder); - return builder.build().install(GeneratedRuntimeModule.module()); + .executionLogging(false) + .build() + .install(GeneratedRuntimeModule.module()); } public static void main(String[] args) throws Exception { - String modelHash = System.getenv("TEAQL_TEST_MODEL_HASH"); - if (modelHash == null || !modelHash.matches("sha256:[0-9a-f]{64}")) { - throw new IllegalArgumentException("Exact model hash is required"); - } 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); - new CustomUserContext(runtime(sourceData, sourceSql, null)).ensureSchema(); - - var recorder = new SqliteCommittedMutationRecorder(sourceSql, SOURCE, modelHash); - recorder.eventStore().ensureSchema(); - var source = new CustomUserContext(runtime(sourceData, sourceSql, recorder)); + var source = new CustomUserContext(runtime(sourceData, sourceSql)); source.ensureSchema(); Platform platform = Q.platforms().withIdIs(1L) .comment("Locate the exact Decimal platform") @@ -52,7 +39,7 @@ public static void main(String[] args) throws Exception { .executeForOne(source); if (platform == null) throw new AssertionError("seeded platform is missing"); - DecimalEntry first = save(source, platform, "first", new BigDecimal("0.1")); + save(source, platform, "first", new BigDecimal("0.1")); save(source, platform, "second", new BigDecimal("0.2")); var loaded = Q.decimalEntriesWithMinimalFields() .selectLabel().selectAmount() @@ -69,33 +56,7 @@ public static void main(String[] args) throws Exception { throw new AssertionError("generated Decimal SUM is not exact"); } - var outbox = recorder.eventStore().outboxAfter(SOURCE, 0, 10); - if (outbox.size() != 2) throw new AssertionError("two audited saves must emit two committed sets"); - SQLiteDataSource targetData = new SQLiteDataSource(); - targetData.setUrl("jdbc:sqlite:" + directory.resolve("target.sqlite")); - JdbcSqlExecutor targetSql = new JdbcSqlExecutor(targetData); - var target = new CustomUserContext(runtime(targetData, targetSql, null)); - target.ensureSchema(); - var checkedTarget = new SqliteModelCheckedCommittedTarget(targetSql, SOURCE, modelHash); - checkedTarget.ensureSchema(); - var appliedFirst = checkedTarget.apply(target, outbox.get(0)); - var appliedSecond = checkedTarget.apply(target, outbox.get(1)); - var duplicate = checkedTarget.apply(target, outbox.get(0)); - if (appliedFirst.duplicate() || appliedSecond.duplicate() || !duplicate.duplicate() - || appliedSecond.checkpoint() != 2 || duplicate.checkpoint() != 1) { - throw new AssertionError("committed Decimal apply and duplicate receipts are invalid"); - } - var replica = Q.decimalEntriesWithMinimalFields().selectAmount() - .withIdIs(first.getId()).limit(1) - .comment("Read a replicated generated Decimal row") - .purpose("Verify exact target hydration") - .executeForOne(target); - if (replica == null || E.decimalEntry(replica).getAmount().eval() - .compareTo(new BigDecimal("0.1")) != 0 - || sum(target).compareTo(new BigDecimal("0.3")) != 0) { - throw new AssertionError("replicated Decimal value or SUM is inexact"); - } - System.out.println("GENERATED_EXACT_DECIMAL_PASS q=1 e=1 save=2 sum=0.3 cmr=2 duplicate=1"); + System.out.println("GENERATED_EXACT_DECIMAL_PASS q=1 e=1 save=2 sum=0.3"); } private static DecimalEntry save(CustomUserContext context, Platform platform, diff --git a/examples/exact-decimal/README.md b/examples/exact-decimal/README.md index 23419e53..8b00784b 100644 --- a/examples/exact-decimal/README.md +++ b/examples/exact-decimal/README.md @@ -13,8 +13,6 @@ 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`. -- Two committed mutation sets applied to a second model-checked SQLite target, - with an exact duplicate receipt and target Q/E/SUM readback. - The actual generated Spring console startup, including repeated `ensureSchema` against one workspace database. @@ -23,3 +21,5 @@ 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/verify.sh b/examples/exact-decimal/verify.sh index 8976a815..4e3a561d 100644 --- a/examples/exact-decimal/verify.sh +++ b/examples/exact-decimal/verify.sh @@ -7,7 +7,6 @@ generator_root="${TEAQL_CODEGEN_DIR:?Set TEAQL_CODEGEN_DIR to the local teaql-co generator_root="$(cd "$generator_root" && pwd)" run_dir="$(mktemp -d -t teaql-java-exact-decimal.XXXXXX)" model_file="$fixture_dir/model.xml" -export TEAQL_TEST_MODEL_HASH="sha256:$(sha256sum "$model_file" | cut -d' ' -f1)" command -v mvn >/dev/null command -v rg >/dev/null @@ -38,7 +37,7 @@ for round in 1 2; do 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=2 sum=0.3 cmr=2 duplicate=1$' \ + if ! rg -q '^GENERATED_EXACT_DECIMAL_PASS q=1 e=1 save=2 sum=0.3$' \ "$run_dir/round-$round.log"; then echo "Generated exact Decimal probe lacks its complete marker: $run_dir/round-$round.log" >&2 exit 1 From 7749909c9f1d5653cdfb7957e1c17c5747acd57a Mon Sep 17 00:00:00 2001 From: Philip Z Date: Sun, 18 Oct 2026 08:05:41 +0800 Subject: [PATCH 05/11] Cover exact Decimal scale and precision in generated example --- .../GeneratedExactDecimalProbe.java | 24 ++++++++++++++++++- examples/exact-decimal/README.md | 2 ++ examples/exact-decimal/verify.sh | 2 +- 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/examples/exact-decimal/GeneratedExactDecimalProbe.java b/examples/exact-decimal/GeneratedExactDecimalProbe.java index 88fb108c..b1316b18 100644 --- a/examples/exact-decimal/GeneratedExactDecimalProbe.java +++ b/examples/exact-decimal/GeneratedExactDecimalProbe.java @@ -55,8 +55,20 @@ public static void main(String[] args) throws Exception { 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); + } - System.out.println("GENERATED_EXACT_DECIMAL_PASS q=1 e=1 save=2 sum=0.3"); + System.out.println("GENERATED_EXACT_DECIMAL_PASS q=1 e=1 save=3 update=1 sum=0.3 scale=3.50 precision=19"); } private static DecimalEntry save(CustomUserContext context, Platform platform, @@ -82,4 +94,14 @@ private static BigDecimal sum(CustomUserContext context) { } 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/README.md b/examples/exact-decimal/README.md index 8b00784b..23f80f5f 100644 --- a/examples/exact-decimal/README.md +++ b/examples/exact-decimal/README.md @@ -13,6 +13,8 @@ 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. - The actual generated Spring console startup, including repeated `ensureSchema` against one workspace database. diff --git a/examples/exact-decimal/verify.sh b/examples/exact-decimal/verify.sh index 4e3a561d..28f5ee60 100644 --- a/examples/exact-decimal/verify.sh +++ b/examples/exact-decimal/verify.sh @@ -37,7 +37,7 @@ for round in 1 2; do 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=2 sum=0.3$' \ + if ! rg -q '^GENERATED_EXACT_DECIMAL_PASS q=1 e=1 save=3 update=1 sum=0.3 scale=3.50 precision=19$' \ "$run_dir/round-$round.log"; then echo "Generated exact Decimal probe lacks its complete marker: $run_dir/round-$round.log" >&2 exit 1 From 34a468c7646eaf87838a7e9752037eca2f94110a Mon Sep 17 00:00:00 2001 From: Philip Z Date: Sun, 18 Oct 2026 08:08:43 +0800 Subject: [PATCH 06/11] Gate exact Decimal example with paired generator in CI --- .github/workflows/example-gate.yml | 18 ++++++++++++++++++ scripts/verify-examples.sh | 4 ++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/.github/workflows/example-gate.yml b/.github/workflows/example-gate.yml index 5c69f289..6494f037 100644 --- a/.github/workflows/example-gate.yml +++ b/.github/workflows/example-gate.yml @@ -45,3 +45,21 @@ jobs: - name: Stop dedicated S3 example fixture if: always() run: docker rm -f teaql-java-example-s3 || true + exact-decimal: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/checkout@v4 + with: + repository: teaql/teaql-code-gen + ref: a6335fe50da8f28be0d3ff8b8dfc8a89fb4489a6 + path: generator + - name: Install example verification tools + run: sudo apt-get update && sudo apt-get install -y ripgrep + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + cache: maven + - name: Verify generated exact Decimal twice + run: TEAQL_CODEGEN_DIR="$GITHUB_WORKSPACE/generator" bash examples/exact-decimal/verify.sh 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" From 989b471b48dae1d9f2cbe32163dc8c04ea421fa9 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Sun, 18 Oct 2026 08:09:05 +0800 Subject: [PATCH 07/11] Keep private generator pairing outside public Java Actions --- .github/workflows/example-gate.yml | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/.github/workflows/example-gate.yml b/.github/workflows/example-gate.yml index 6494f037..5c69f289 100644 --- a/.github/workflows/example-gate.yml +++ b/.github/workflows/example-gate.yml @@ -45,21 +45,3 @@ jobs: - name: Stop dedicated S3 example fixture if: always() run: docker rm -f teaql-java-example-s3 || true - exact-decimal: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/checkout@v4 - with: - repository: teaql/teaql-code-gen - ref: a6335fe50da8f28be0d3ff8b8dfc8a89fb4489a6 - path: generator - - name: Install example verification tools - run: sudo apt-get update && sudo apt-get install -y ripgrep - - uses: actions/setup-java@v4 - with: - distribution: temurin - java-version: '21' - cache: maven - - name: Verify generated exact Decimal twice - run: TEAQL_CODEGEN_DIR="$GITHUB_WORKSPACE/generator" bash examples/exact-decimal/verify.sh From 130c13a0f0f2c39e2577981acca3e99012b0d873 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Sun, 18 Oct 2026 08:12:43 +0800 Subject: [PATCH 08/11] Preserve virtual untyped columns in SQLite Decimal detection --- .../teaql/core/sqlite/SqliteDataServiceExecutor.java | 11 +++++++---- .../main/java/io/teaql/core/sqlite/SqliteDialect.java | 2 ++ .../teaql/core/sqlite/SqliteDecimalFunctionsTest.java | 6 ++++++ 3 files changed, 15 insertions(+), 4 deletions(-) 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 33b8dce1..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 @@ -37,8 +37,7 @@ public void ensureSchema(UserContext context, io.teaql.core.SchemaExecutor.Invoc 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(column -> dialect.mapColumnType(column.getType()) - .toUpperCase(java.util.Locale.ROOT).startsWith("DECIMAL_TEXT"))) { + .anyMatch(this::isExactDecimalColumn)) { ensureDecimalOnEveryConnection(); } ensureSoundexOnEveryConnection(); @@ -109,8 +108,7 @@ public Optional indexExists( repository.ensurePhysicalSchema(context); for (var property : descriptor.getOwnProperties()) { for (var column : io.teaql.core.sql.portable.SQLPropertyUtil.getColumns(property)) { - if (dialect.mapColumnType(column.getType()) - .toUpperCase(java.util.Locale.ROOT).startsWith("DECIMAL_TEXT")) { + if (isExactDecimalColumn(column)) { SqliteDecimalSchemaVerifier.verify( getExecutionAdapter(), column.getTableName(), column.getColumnName()); } @@ -119,6 +117,11 @@ public Optional indexExists( } } + 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) { 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 index df482b9e..a6bf58c9 100644 --- a/teaql-sqlite/src/main/java/io/teaql/core/sqlite/SqliteDialect.java +++ b/teaql-sqlite/src/main/java/io/teaql/core/sqlite/SqliteDialect.java @@ -13,6 +13,7 @@ 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 + "(")) { @@ -25,6 +26,7 @@ public String mapColumnType(String type) { @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 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 index e8524c3a..81a81930 100644 --- a/teaql-sqlite/src/test/java/io/teaql/core/sqlite/SqliteDecimalFunctionsTest.java +++ b/teaql-sqlite/src/test/java/io/teaql/core/sqlite/SqliteDecimalFunctionsTest.java @@ -1,6 +1,7 @@ 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; @@ -26,6 +27,11 @@ public class SqliteDecimalFunctionsTest { + @Test + public void virtualColumnsWithoutPhysicalTypeRemainUnmapped() { + assertNull(new SqliteDialect().mapColumnType(null)); + } + public static class DecimalRow extends BaseEntity { private BigDecimal amount; From c64b0600e78c4843949e5f7bbe7711d03fbe413d Mon Sep 17 00:00:00 2001 From: Philip Z Date: Mon, 19 Oct 2026 10:43:41 +0800 Subject: [PATCH 09/11] Canonicalize exponent Decimal binding for SQLite --- .../GeneratedExactDecimalProbe.java | 17 ++++++++++++++++- examples/exact-decimal/README.md | 2 ++ examples/exact-decimal/verify.sh | 2 +- .../io/teaql/provider/jdbc/JdbcSqlExecutor.java | 7 ++++++- .../core/sqlite/SqliteDecimalFunctionsTest.java | 10 ++++++++++ 5 files changed, 35 insertions(+), 3 deletions(-) diff --git a/examples/exact-decimal/GeneratedExactDecimalProbe.java b/examples/exact-decimal/GeneratedExactDecimalProbe.java index b1316b18..479caa41 100644 --- a/examples/exact-decimal/GeneratedExactDecimalProbe.java +++ b/examples/exact-decimal/GeneratedExactDecimalProbe.java @@ -68,7 +68,22 @@ public static void main(String[] args) throws Exception { throw new AssertionError("generated Decimal precision was not preserved: " + precision); } - System.out.println("GENERATED_EXACT_DECIMAL_PASS q=1 e=1 save=3 update=1 sum=0.3 scale=3.50 precision=19"); + 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, diff --git a/examples/exact-decimal/README.md b/examples/exact-decimal/README.md index 23f80f5f..453782f5 100644 --- a/examples/exact-decimal/README.md +++ b/examples/exact-decimal/README.md @@ -15,6 +15,8 @@ installs only the local runtime into Maven's local cache, then runs twice: - 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. diff --git a/examples/exact-decimal/verify.sh b/examples/exact-decimal/verify.sh index 28f5ee60..15a3d96b 100644 --- a/examples/exact-decimal/verify.sh +++ b/examples/exact-decimal/verify.sh @@ -37,7 +37,7 @@ for round in 1 2; do 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=3 update=1 sum=0.3 scale=3.50 precision=19$' \ + 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 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 9d1448d5..9f38d0fd 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 @@ -424,7 +424,12 @@ 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)) 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-sqlite/src/test/java/io/teaql/core/sqlite/SqliteDecimalFunctionsTest.java b/teaql-sqlite/src/test/java/io/teaql/core/sqlite/SqliteDecimalFunctionsTest.java index 81a81930..7cb65978 100644 --- a/teaql-sqlite/src/test/java/io/teaql/core/sqlite/SqliteDecimalFunctionsTest.java +++ b/teaql-sqlite/src/test/java/io/teaql/core/sqlite/SqliteDecimalFunctionsTest.java @@ -189,6 +189,16 @@ public void contextEnsureSchemaCreatesExactDecimalStorageAndRepeats() throws Exc 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); } From 946404b70be53ad7444809771ac15278e2ad97c7 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Mon, 19 Oct 2026 13:02:06 +0800 Subject: [PATCH 10/11] Guard SQLite Decimal functions against exponent amplification --- .../core/sqlite/SqliteDecimalFunctions.java | 14 ++++++++++- .../sqlite/SqliteDecimalFunctionsTest.java | 25 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) 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 index c158dff8..1e777b0d 100644 --- a/teaql-sqlite/src/main/java/io/teaql/core/sqlite/SqliteDecimalFunctions.java +++ b/teaql-sqlite/src/main/java/io/teaql/core/sqlite/SqliteDecimalFunctions.java @@ -16,6 +16,10 @@ final class SqliteDecimalFunctions { 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); @@ -41,7 +45,11 @@ protected void xFunc() throws SQLException { String text = value_text(0); boolean valid; try { - valid = text != null && new BigDecimal(text).toPlainString().equals(text); + // 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; } @@ -59,6 +67,10 @@ protected void xStep() throws SQLException { 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); 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 index 7cb65978..000018d2 100644 --- a/teaql-sqlite/src/test/java/io/teaql/core/sqlite/SqliteDecimalFunctionsTest.java +++ b/teaql-sqlite/src/test/java/io/teaql/core/sqlite/SqliteDecimalFunctionsTest.java @@ -102,6 +102,31 @@ public void exactStorageComparisonAndSumSurviveJdbcDecimalBinding() throws Excep () -> 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')")); } } } From b97e008d6dfb9d575e673cd6eba66f42c81a5356 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Mon, 19 Oct 2026 13:06:23 +0800 Subject: [PATCH 11/11] Bound SQLite Decimal parameter expansion --- .../teaql/provider/jdbc/JdbcSqlExecutor.java | 18 ++++++++++++++++-- .../provider/jdbc/JdbcSqlExecutorTest.java | 17 +++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) 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 9f38d0fd..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(); @@ -428,8 +432,18 @@ static void bind(PreparedStatement statement, int index, Object value) throws SQ // 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)) statement.setString(index, number.toPlainString()); - else statement.setBigDecimal(index, number); + 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<>();