From 1c40fd25539e4f72f8f3015f33e8caccac6420d9 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Wed, 23 Sep 2026 16:30:48 +0800 Subject: [PATCH 1/4] Validate Business ID counter table shape before allocation --- examples/business-id-runtime/README.md | 11 ++ .../BusinessIdRuntimeExampleTest.java | 130 +++++++++++++++++- .../jdbc/JdbcBusinessIdAllocator.java | 105 +++++++++++++- .../core/sql/portable/TeaQLDatabase.java | 20 ++- 4 files changed, 257 insertions(+), 9 deletions(-) diff --git a/examples/business-id-runtime/README.md b/examples/business-id-runtime/README.md index a99030be..83e3a48f 100644 --- a/examples/business-id-runtime/README.md +++ b/examples/business-id-runtime/README.md @@ -14,6 +14,17 @@ the context's `BusinessClock`; tests can replace that clock. The JDBC allocator uses root/aggregate/namespace/date scoping, optimistic allocation, bounded retry, and a range error when the configured digits are exhausted. +For an existing counter table, `context.ensureSchema()` verifies the four +required columns, a single-column `scope_key` primary or unique key, a text +`scope_key` with capacity at least 512, and non-null 64-bit integral counter, +version, and timestamp columns. It does not alter an incompatible table. A +custom `TeaQLDatabase` adapter must return JDBC `DATA_TYPE`, `TYPE_NAME`, +`COLUMN_SIZE`, `DECIMAL_DIGITS`, and `NULLABLE` metadata under the lower-case +keys `data_type`, `type_name`, `column_size`, `decimal_digits`, and `nullable` +in `getTableColumns`, and implement `getTablePrimaryKeyColumns` or +`getTableUniqueKeys`. Missing shape metadata fails closed rather than letting +allocation fail later. + `BusinessIdGenerator`, `UserContext.generateBusinessId(...)`, `InMemoryBusinessIdGenerator`, and `JdbcBusinessIdGenerator` are deprecated compatibility APIs. They retain the old `PREFIXyyyyMMddNNNN` format and separate diff --git a/examples/business-id-runtime/src/test/java/io/teaql/examples/businessid/BusinessIdRuntimeExampleTest.java b/examples/business-id-runtime/src/test/java/io/teaql/examples/businessid/BusinessIdRuntimeExampleTest.java index 2c7992b4..2d7482dc 100644 --- a/examples/business-id-runtime/src/test/java/io/teaql/examples/businessid/BusinessIdRuntimeExampleTest.java +++ b/examples/business-id-runtime/src/test/java/io/teaql/examples/businessid/BusinessIdRuntimeExampleTest.java @@ -259,6 +259,78 @@ public void sqliteAllocatorRejectsIncompleteExistingSchema() throws Exception { } } + @Test + public void sqliteAllocatorRejectsExistingTableWithoutUniqueScopeKey() throws Exception { + assertInvalidExistingTable( + "CREATE TABLE teaql_business_id_space (" + + "scope_key VARCHAR(512), current_value BIGINT NOT NULL, " + + "version BIGINT NOT NULL, updated_at BIGINT NOT NULL)", + "scope_key"); + } + + @Test + public void sqliteAllocatorRejectsNullableCounter() throws Exception { + assertInvalidExistingTable( + "CREATE TABLE teaql_business_id_space (" + + "scope_key VARCHAR(512) PRIMARY KEY, current_value BIGINT, " + + "version BIGINT NOT NULL, updated_at BIGINT NOT NULL)", + "current_value"); + } + + @Test + public void sqliteAllocatorRejectsNarrowCounter() throws Exception { + assertInvalidExistingTable( + "CREATE TABLE teaql_business_id_space (" + + "scope_key VARCHAR(512) PRIMARY KEY, current_value SMALLINT NOT NULL, " + + "version BIGINT NOT NULL, updated_at BIGINT NOT NULL)", + "current_value"); + } + + @Test + public void sqliteAllocatorAcceptsNonNullableUniqueScopeKey() throws Exception { + Path databasePath = Files.createTempFile("teaql-business-id-unique-scope", ".db"); + try (Connection connection = open(databasePath)) { + try (Statement statement = connection.createStatement()) { + statement.execute("CREATE TABLE teaql_business_id_space (" + + "scope_key VARCHAR(512) NOT NULL UNIQUE, " + + "current_value BIGINT NOT NULL, version BIGINT NOT NULL, " + + "updated_at BIGINT NOT NULL)"); + } + JdbcBusinessIdAllocator allocator = new JdbcBusinessIdAllocator(database(connection)); + UserContext context = context(LocalDate.of(2026, 9, 20), allocator); + context.putAttribute(SchemaExecutor.class.getName(), noOpSchemaExecutor()); + context.ensureSchema(); + context.ensureSchema(); + BusinessIdPlan plan = new DailySequenceBusinessIdProfile().plan( + new BusinessIdGenerationRequest(ORDER_NUMBER, "tenant-a", + "commerce_order", LocalDate.of(2026, 9, 20))); + Assert.assertEquals(1L, allocator.allocate(plan).sequence()); + Assert.assertEquals(2L, allocator.allocate(plan).sequence()); + } finally { + Files.deleteIfExists(databasePath); + } + } + + private static void assertInvalidExistingTable(String ddl, String expectedField) + throws Exception { + Path databasePath = Files.createTempFile("teaql-business-id-invalid-shape", ".db"); + try (Connection connection = open(databasePath)) { + try (Statement statement = connection.createStatement()) { + statement.execute(ddl); + } + JdbcBusinessIdAllocator allocator = new JdbcBusinessIdAllocator(database(connection)); + UserContext context = context(LocalDate.of(2026, 9, 20), allocator); + context.putAttribute(SchemaExecutor.class.getName(), noOpSchemaExecutor()); + + IllegalStateException failure = Assert.assertThrows( + IllegalStateException.class, context::ensureSchema); + Assert.assertTrue(failure.getMessage(), + failure.getMessage().contains(expectedField)); + } finally { + Files.deleteIfExists(databasePath); + } + } + @Test public void sqliteAllocatorRejectsExhaustedAndOverflowedSequencesWithoutMutation() throws Exception { @@ -447,7 +519,14 @@ public List> getTableColumns(String tableName) { try (ResultSet result = metadata.getColumns( connection.getCatalog(), connection.getSchema(), name, null)) { while (result.next()) { - columns.add(Map.of("column_name", result.getString("COLUMN_NAME"))); + Map column = new HashMap<>(); + column.put("column_name", result.getString("COLUMN_NAME")); + column.put("data_type", result.getInt("DATA_TYPE")); + column.put("type_name", result.getString("TYPE_NAME")); + column.put("column_size", result.getInt("COLUMN_SIZE")); + column.put("decimal_digits", result.getInt("DECIMAL_DIGITS")); + column.put("nullable", result.getInt("NULLABLE")); + columns.add(column); } } if (!columns.isEmpty()) break; @@ -458,6 +537,55 @@ public List> getTableColumns(String tableName) { } } + @Override + public Optional> getTablePrimaryKeyColumns(String tableName) { + try { + java.sql.DatabaseMetaData metadata = connection.getMetaData(); + for (String name : List.of(tableName, tableName.toUpperCase(Locale.ROOT))) { + TreeMap ordered = new TreeMap<>(); + try (ResultSet result = metadata.getPrimaryKeys( + connection.getCatalog(), connection.getSchema(), name)) { + while (result.next()) { + ordered.put(result.getShort("KEY_SEQ"), + result.getString("COLUMN_NAME")); + } + } + if (!ordered.isEmpty()) return Optional.of(List.copyOf(ordered.values())); + } + return Optional.of(List.of()); + } catch (SQLException error) { + throw new RuntimeException(error); + } + } + + @Override + public Optional>> getTableUniqueKeys(String tableName) { + try { + java.sql.DatabaseMetaData metadata = connection.getMetaData(); + for (String name : List.of(tableName, tableName.toUpperCase(Locale.ROOT))) { + Map> indexes = new HashMap<>(); + try (ResultSet result = metadata.getIndexInfo( + connection.getCatalog(), connection.getSchema(), name, + true, true)) { + while (result.next()) { + String index = result.getString("INDEX_NAME"); + String column = result.getString("COLUMN_NAME"); + if (index == null || column == null) continue; + indexes.computeIfAbsent(index, ignored -> new TreeMap<>()) + .put(result.getShort("ORDINAL_POSITION"), column); + } + } + if (!indexes.isEmpty()) { + return Optional.of(indexes.values().stream() + .map(ordered -> List.copyOf(ordered.values())).toList()); + } + } + return Optional.of(List.of()); + } catch (SQLException error) { + throw new RuntimeException(error); + } + } + private void bind(PreparedStatement statement, Object[] args) throws SQLException { if (args == null) return; for (int index = 0; index < args.length; index++) { diff --git a/teaql-business-id-jdbc/src/main/java/io/teaql/businessid/jdbc/JdbcBusinessIdAllocator.java b/teaql-business-id-jdbc/src/main/java/io/teaql/businessid/jdbc/JdbcBusinessIdAllocator.java index 24a27684..f0c2862d 100644 --- a/teaql-business-id-jdbc/src/main/java/io/teaql/businessid/jdbc/JdbcBusinessIdAllocator.java +++ b/teaql-business-id-jdbc/src/main/java/io/teaql/businessid/jdbc/JdbcBusinessIdAllocator.java @@ -5,12 +5,15 @@ import io.teaql.core.sql.portable.TeaQLDatabase; import io.teaql.core.sql.dialect.PostgreSqlDialect; import io.teaql.core.sql.dialect.SqlDialect; +import java.sql.DatabaseMetaData; +import java.sql.Types; import java.time.ZoneOffset; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Set; -import java.util.HashSet; import java.util.Locale; +import java.util.Optional; /** Portable optimistic Business ID allocator backed by a TeaQLDatabase. */ public final class JdbcBusinessIdAllocator @@ -87,21 +90,109 @@ private List> inspectColumns() { } private void requireColumns(List> columns) { - Set found = new HashSet<>(); + Map> found = new HashMap<>(); for (Map column : columns) { - for (Map.Entry entry : column.entrySet()) { - if ("column_name".equalsIgnoreCase(entry.getKey()) && entry.getValue() != null) { - found.add(String.valueOf(entry.getValue()).toLowerCase(Locale.ROOT)); - } + Object name = value(column, "column_name"); + if (name != null) { + found.put(String.valueOf(name).toLowerCase(Locale.ROOT), column); } } Set missing = new java.util.TreeSet<>(Set.of( "scope_key", "current_value", "version", "updated_at")); - missing.removeAll(found); + missing.removeAll(found.keySet()); if (!missing.isEmpty()) { throw new IllegalStateException( "Business ID table " + table + " is missing required columns " + missing); } + + requireTextScope(found.get("scope_key")); + for (String name : List.of("current_value", "version", "updated_at")) { + requireIntegralNotNull(name, found.get(name)); + } + + Optional> primaryKey; + try { + primaryKey = database.getTablePrimaryKeyColumns(table); + } catch (RuntimeException failure) { + throw new IllegalStateException( + "Cannot inspect primary key of Business ID table " + table, failure); + } + if (primaryKey != null && primaryKey.isPresent() + && scopeKeyOnly(primaryKey.get())) { + return; + } + Optional>> uniqueKeys; + try { + uniqueKeys = database.getTableUniqueKeys(table); + } catch (RuntimeException failure) { + throw new IllegalStateException( + "Cannot inspect unique keys of Business ID table " + table, failure); + } + if (uniqueKeys != null && uniqueKeys.isPresent() + && uniqueKeys.get().stream().anyMatch(this::scopeKeyOnly)) { + return; + } + throw new IllegalStateException( + "Business ID table " + table + + " requires a single-column scope_key primary or unique key" + + "; inspected primary=" + primaryKey + ", unique=" + uniqueKeys); + } + + private boolean scopeKeyOnly(List names) { + return names.size() == 1 && "scope_key".equalsIgnoreCase(names.get(0)); + } + + private void requireTextScope(Map column) { + int type = metadataNumber(column, "data_type", "scope_key"); + int size = metadataNumber(column, "column_size", "scope_key"); + boolean unbounded = type == Types.LONGVARCHAR || type == Types.LONGNVARCHAR + || type == Types.CLOB || type == Types.NCLOB; + boolean text = unbounded || type == Types.VARCHAR || type == Types.NVARCHAR + || type == Types.CHAR || type == Types.NCHAR; + if (!text || (!unbounded && size < 512)) { + throw new IllegalStateException( + "Business ID table " + table + + ".scope_key must be text with capacity at least 512; found " + + value(column, "type_name") + "(" + size + ")"); + } + } + + private void requireIntegralNotNull(String name, Map column) { + int type = metadataNumber(column, "data_type", name); + int nullable = metadataNumber(column, "nullable", name); + int precision = metadataNumber(column, "column_size", name); + int scale = metadataNumber(column, "decimal_digits", name); + String declaredType = String.valueOf(value(column, "type_name")) + .toUpperCase(Locale.ROOT); + boolean longInteger = type == Types.BIGINT + || declaredType.equals("BIGINT") || declaredType.equals("INT8") + || ((type == Types.NUMERIC || type == Types.DECIMAL) + && precision >= 19 && scale == 0); + if (!longInteger) { + throw new IllegalStateException( + "Business ID table " + table + "." + name + + " must store signed 64-bit integral values; found " + + declaredType + "(" + precision + "," + scale + ")"); + } + if (nullable != DatabaseMetaData.columnNoNulls) { + throw new IllegalStateException( + "Business ID table " + table + "." + name + " must be NOT NULL"); + } + } + + private int metadataNumber(Map column, String key, String name) { + Object result = value(column, key); + if (result instanceof Number number) return number.intValue(); + throw new IllegalStateException( + "Business ID table " + table + "." + name + + " is missing required " + key + " metadata"); + } + + private Object value(Map column, String key) { + for (Map.Entry entry : column.entrySet()) { + if (key.equalsIgnoreCase(entry.getKey())) return entry.getValue(); + } + return null; } @Override diff --git a/teaql-sql-portable/src/main/java/io/teaql/core/sql/portable/TeaQLDatabase.java b/teaql-sql-portable/src/main/java/io/teaql/core/sql/portable/TeaQLDatabase.java index 5f946fe7..3e6aeda9 100644 --- a/teaql-sql-portable/src/main/java/io/teaql/core/sql/portable/TeaQLDatabase.java +++ b/teaql-sql-portable/src/main/java/io/teaql/core/sql/portable/TeaQLDatabase.java @@ -65,10 +65,28 @@ default void executeInTransaction(io.teaql.core.UserContext context, Runnable ac } /** - * Get column information for a database table. + * Get column information for a database table. Infrastructure that validates + * installed table shape expects a {@code column_name} plus JDBC metadata + * {@code data_type}, {@code type_name}, {@code column_size}, + * {@code decimal_digits}, and {@code nullable}; adapters must not invent + * values when their provider cannot inspect them. */ List> getTableColumns(String tableName); + /** + * Return the ordered primary-key column names when the adapter can inspect them. + * Infrastructure that relies on a particular primary key must fail closed on an + * empty {@code Optional}; an empty list means the table has no primary key. + */ + default Optional> getTablePrimaryKeyColumns(String tableName) { + return Optional.empty(); + } + + /** Ordered columns of each unique key, when the adapter can inspect them. */ + default Optional>> getTableUniqueKeys(String tableName) { + return Optional.empty(); + } + /** * Report whether an index already exists for a table. * From a4d3001d589359898aad985e8a112dafd11adcba Mon Sep 17 00:00:00 2001 From: Philip Z Date: Wed, 23 Sep 2026 16:32:47 +0800 Subject: [PATCH 2/4] Cover short Business ID scope keys and nullable versions --- .../BusinessIdRuntimeExampleTest.java | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/examples/business-id-runtime/src/test/java/io/teaql/examples/businessid/BusinessIdRuntimeExampleTest.java b/examples/business-id-runtime/src/test/java/io/teaql/examples/businessid/BusinessIdRuntimeExampleTest.java index 2d7482dc..fab97c69 100644 --- a/examples/business-id-runtime/src/test/java/io/teaql/examples/businessid/BusinessIdRuntimeExampleTest.java +++ b/examples/business-id-runtime/src/test/java/io/teaql/examples/businessid/BusinessIdRuntimeExampleTest.java @@ -286,6 +286,24 @@ public void sqliteAllocatorRejectsNarrowCounter() throws Exception { "current_value"); } + @Test + public void sqliteAllocatorRejectsShortScopeKeyColumn() throws Exception { + assertInvalidExistingTable( + "CREATE TABLE teaql_business_id_space (" + + "scope_key VARCHAR(64) PRIMARY KEY, current_value BIGINT NOT NULL, " + + "version BIGINT NOT NULL, updated_at BIGINT NOT NULL)", + "scope_key"); + } + + @Test + public void sqliteAllocatorRejectsNullableVersion() throws Exception { + assertInvalidExistingTable( + "CREATE TABLE teaql_business_id_space (" + + "scope_key VARCHAR(512) PRIMARY KEY, current_value BIGINT NOT NULL, " + + "version BIGINT, updated_at BIGINT NOT NULL)", + "version"); + } + @Test public void sqliteAllocatorAcceptsNonNullableUniqueScopeKey() throws Exception { Path databasePath = Files.createTempFile("teaql-business-id-unique-scope", ".db"); From c9b7fe1795e19add6f7247e71f9a2424e66a2816 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Wed, 23 Sep 2026 16:34:16 +0800 Subject: [PATCH 3/4] Preserve Business ID shape diagnostic after creation race --- .../jdbc/JdbcBusinessIdAllocator.java | 17 ++++++--- .../jdbc/JdbcBusinessIdAllocatorTest.java | 38 +++++++++++++++++++ 2 files changed, 49 insertions(+), 6 deletions(-) create mode 100644 teaql-business-id-jdbc/src/test/java/io/teaql/businessid/jdbc/JdbcBusinessIdAllocatorTest.java diff --git a/teaql-business-id-jdbc/src/main/java/io/teaql/businessid/jdbc/JdbcBusinessIdAllocator.java b/teaql-business-id-jdbc/src/main/java/io/teaql/businessid/jdbc/JdbcBusinessIdAllocator.java index f0c2862d..00c70e28 100644 --- a/teaql-business-id-jdbc/src/main/java/io/teaql/businessid/jdbc/JdbcBusinessIdAllocator.java +++ b/teaql-business-id-jdbc/src/main/java/io/teaql/businessid/jdbc/JdbcBusinessIdAllocator.java @@ -58,15 +58,20 @@ public void ensureSchema(UserContext context) { + "updated_at " + dialect.mapColumnType("BIGINT") + " NOT NULL)"); } catch (RuntimeException createFailure) { // Another instance may have created the table after inspection. - // Only accept that race when the installed table can be inspected. + // Only accept that race when the installed table passes the same + // validation as a table that was already present at startup. + List> concurrent; try { - List> concurrent = inspectColumns(); - if (!concurrent.isEmpty()) { - requireColumns(concurrent); - return; - } + concurrent = inspectColumns(); } catch (RuntimeException inspectFailure) { createFailure.addSuppressed(inspectFailure); + throw new IllegalStateException( + "Cannot create or inspect Business ID table " + table, + createFailure); + } + if (!concurrent.isEmpty()) { + requireColumns(concurrent); + return; } throw new IllegalStateException( "Cannot create Business ID table " + table, createFailure); diff --git a/teaql-business-id-jdbc/src/test/java/io/teaql/businessid/jdbc/JdbcBusinessIdAllocatorTest.java b/teaql-business-id-jdbc/src/test/java/io/teaql/businessid/jdbc/JdbcBusinessIdAllocatorTest.java new file mode 100644 index 00000000..b3c39217 --- /dev/null +++ b/teaql-business-id-jdbc/src/test/java/io/teaql/businessid/jdbc/JdbcBusinessIdAllocatorTest.java @@ -0,0 +1,38 @@ +package io.teaql.businessid.jdbc; + +import io.teaql.core.sql.portable.TeaQLDatabase; +import java.lang.reflect.Proxy; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.Assert; +import org.junit.Test; + +public class JdbcBusinessIdAllocatorTest { + + @Test + public void concurrentWrongShapeTableReportsItsMissingColumns() { + AtomicInteger inspections = new AtomicInteger(); + TeaQLDatabase database = (TeaQLDatabase) Proxy.newProxyInstance( + TeaQLDatabase.class.getClassLoader(), + new Class[] {TeaQLDatabase.class}, + (proxy, method, args) -> { + if (method.getName().equals("getTableColumns")) { + return inspections.incrementAndGet() == 1 + ? List.of() + : List.of(Map.of("column_name", "scope_key")); + } + if (method.getName().equals("execute")) { + throw new IllegalStateException("table already exists"); + } + throw new AssertionError("Unexpected database call " + method.getName()); + }); + + IllegalStateException failure = Assert.assertThrows( + IllegalStateException.class, + () -> new JdbcBusinessIdAllocator(database).ensureSchema(null)); + Assert.assertTrue(failure.getMessage(), + failure.getMessage().contains("missing required columns")); + Assert.assertEquals(2, inspections.get()); + } +} From d5bd6f2998e98c6f6073a6265de355ad8fa2da03 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Wed, 23 Sep 2026 16:46:27 +0800 Subject: [PATCH 4/4] Exercise malformed Business ID tables on live SQL providers --- .github/workflows/live-dialects.yml | 4 +- .../JdbcBusinessIdLiveDialectIT.java | 56 +++++++++++++++++++ 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/.github/workflows/live-dialects.yml b/.github/workflows/live-dialects.yml index 84d67866..0668618d 100644 --- a/.github/workflows/live-dialects.yml +++ b/.github/workflows/live-dialects.yml @@ -50,7 +50,7 @@ jobs: - name: Verify both live dialects (skips are forbidden) run: >- mvn -B -ntp -pl teaql-postgres,teaql-mysql,examples/business-id-runtime -am - -Dtest=PostgresIntegrationTest,MysqlIntegrationTest,JdbcBusinessIdLiveDialectIT#postgresAllocatesAcrossInstancesAndRestart+mysqlAllocatesAcrossInstancesAndRestart + -Dtest=PostgresIntegrationTest,MysqlIntegrationTest,JdbcBusinessIdLiveDialectIT#postgresAllocatesAcrossInstancesAndRestart+mysqlAllocatesAcrossInstancesAndRestart+postgresRejectsUnprotectedBusinessIdTable+mysqlRejectsUnprotectedBusinessIdTable -Dsurefire.failIfNoSpecifiedTests=false test business-id-sqlserver: @@ -87,7 +87,7 @@ jobs: - name: Verify SQL Server Business ID schema and allocation (skips are forbidden) run: >- mvn -B -ntp -pl examples/business-id-runtime -am - '-Dtest=JdbcBusinessIdLiveDialectIT#sqlServerAllocatesAcrossInstancesAndRestart+sqlServerConcurrentSchemaStartupIsIdempotent' + '-Dtest=JdbcBusinessIdLiveDialectIT#sqlServerAllocatesAcrossInstancesAndRestart+sqlServerConcurrentSchemaStartupIsIdempotent+sqlServerRejectsUnprotectedBusinessIdTable' -Dsurefire.failIfNoSpecifiedTests=false test sqlserver: diff --git a/examples/business-id-runtime/src/test/java/io/teaql/examples/businessid/JdbcBusinessIdLiveDialectIT.java b/examples/business-id-runtime/src/test/java/io/teaql/examples/businessid/JdbcBusinessIdLiveDialectIT.java index 3a935d94..b5f4de10 100644 --- a/examples/business-id-runtime/src/test/java/io/teaql/examples/businessid/JdbcBusinessIdLiveDialectIT.java +++ b/examples/business-id-runtime/src/test/java/io/teaql/examples/businessid/JdbcBusinessIdLiveDialectIT.java @@ -10,6 +10,7 @@ import io.teaql.core.sql.dialect.OracleDialect; import java.sql.Connection; import java.sql.DriverManager; +import java.sql.Statement; import java.time.LocalDate; import java.util.ArrayList; import java.util.HashSet; @@ -45,6 +46,21 @@ public void sqlServerAllocatesAcrossInstancesAndRestart() throws Exception { verify("MSSQL"); } + @Test + public void postgresRejectsUnprotectedBusinessIdTable() throws Exception { + rejectUnprotectedTable("POSTGRES"); + } + + @Test + public void mysqlRejectsUnprotectedBusinessIdTable() throws Exception { + rejectUnprotectedTable("MYSQL"); + } + + @Test + public void sqlServerRejectsUnprotectedBusinessIdTable() throws Exception { + rejectUnprotectedTable("MSSQL"); + } + @Test public void dm8AllocatesAcrossInstancesAndRestart() throws Exception { verify("DM8"); @@ -176,6 +192,46 @@ private void verify(String dialect) throws Exception { } } + private void rejectUnprotectedTable(String dialect) throws Exception { + String prefix = "TEAQL_TEST_" + dialect + "_"; + String url = System.getenv(prefix + "URL"); + String user = System.getenv(prefix + "USER"); + String password = System.getenv(prefix + "PASSWORD"); + if (url == null || user == null || password == null) { + if (Boolean.parseBoolean(System.getenv("TEAQL_REQUIRE_LIVE_DB"))) { + Assert.fail(dialect + " Business ID live gate requires URL, USER, and PASSWORD"); + } + Assume.assumeTrue(dialect + " live database is not configured", false); + } + Assert.assertTrue("Use a dedicated teaql_live_* database for " + dialect, + url.contains("teaql_live_")); + + String table = "bid_shape_" + UUID.randomUUID().toString().substring(0, 8); + try (Connection connection = DriverManager.getConnection(url, user, password)) { + try (Statement statement = connection.createStatement()) { + statement.execute("CREATE TABLE " + table + " (" + + "scope_key VARCHAR(512), current_value BIGINT NOT NULL, " + + "version BIGINT NOT NULL, updated_at BIGINT NOT NULL)"); + } + try { + JdbcBusinessIdAllocator allocator = new JdbcBusinessIdAllocator( + BusinessIdRuntimeExampleTest.database(connection), table); + UserContext context = BusinessIdRuntimeExampleTest.context( + BUSINESS_DATE, allocator); + context.putAttribute(SchemaExecutor.class.getName(), + BusinessIdRuntimeExampleTest.noOpSchemaExecutor()); + IllegalStateException failure = Assert.assertThrows( + IllegalStateException.class, context::ensureSchema); + Assert.assertTrue(failure.getMessage(), + failure.getMessage().contains("scope_key")); + } finally { + try (Statement statement = connection.createStatement()) { + statement.execute("DROP TABLE " + table); + } + } + } + } + private static JdbcBusinessIdAllocator allocator(Connection connection, String dialect) { if ("ORACLE".equals(dialect)) { return new JdbcBusinessIdAllocator(