Skip to content

Commit 15d2217

Browse files
philipgreatPhilip Z
andauthored
Fail closed on malformed Business ID counter tables (#172)
* Validate Business ID counter table shape before allocation * Cover short Business ID scope keys and nullable versions * Preserve Business ID shape diagnostic after creation race * Exercise malformed Business ID tables on live SQL providers --------- Co-authored-by: Philip Z <hello@teaql.io>
1 parent 7229997 commit 15d2217

7 files changed

Lines changed: 382 additions & 17 deletions

File tree

‎.github/workflows/live-dialects.yml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ jobs:
5050
- name: Verify both live dialects (skips are forbidden)
5151
run: >-
5252
mvn -B -ntp -pl teaql-postgres,teaql-mysql,examples/business-id-runtime -am
53-
-Dtest=PostgresIntegrationTest,MysqlIntegrationTest,JdbcBusinessIdLiveDialectIT#postgresAllocatesAcrossInstancesAndRestart+mysqlAllocatesAcrossInstancesAndRestart
53+
-Dtest=PostgresIntegrationTest,MysqlIntegrationTest,JdbcBusinessIdLiveDialectIT#postgresAllocatesAcrossInstancesAndRestart+mysqlAllocatesAcrossInstancesAndRestart+postgresRejectsUnprotectedBusinessIdTable+mysqlRejectsUnprotectedBusinessIdTable
5454
-Dsurefire.failIfNoSpecifiedTests=false test
5555
5656
business-id-sqlserver:
@@ -87,7 +87,7 @@ jobs:
8787
- name: Verify SQL Server Business ID schema and allocation (skips are forbidden)
8888
run: >-
8989
mvn -B -ntp -pl examples/business-id-runtime -am
90-
'-Dtest=JdbcBusinessIdLiveDialectIT#sqlServerAllocatesAcrossInstancesAndRestart+sqlServerConcurrentSchemaStartupIsIdempotent'
90+
'-Dtest=JdbcBusinessIdLiveDialectIT#sqlServerAllocatesAcrossInstancesAndRestart+sqlServerConcurrentSchemaStartupIsIdempotent+sqlServerRejectsUnprotectedBusinessIdTable'
9191
-Dsurefire.failIfNoSpecifiedTests=false test
9292
9393
sqlserver:

‎examples/business-id-runtime/README.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,17 @@ the context's `BusinessClock`; tests can replace that clock. The JDBC allocator
1414
uses root/aggregate/namespace/date scoping, optimistic allocation, bounded
1515
retry, and a range error when the configured digits are exhausted.
1616

17+
For an existing counter table, `context.ensureSchema()` verifies the four
18+
required columns, a single-column `scope_key` primary or unique key, a text
19+
`scope_key` with capacity at least 512, and non-null 64-bit integral counter,
20+
version, and timestamp columns. It does not alter an incompatible table. A
21+
custom `TeaQLDatabase` adapter must return JDBC `DATA_TYPE`, `TYPE_NAME`,
22+
`COLUMN_SIZE`, `DECIMAL_DIGITS`, and `NULLABLE` metadata under the lower-case
23+
keys `data_type`, `type_name`, `column_size`, `decimal_digits`, and `nullable`
24+
in `getTableColumns`, and implement `getTablePrimaryKeyColumns` or
25+
`getTableUniqueKeys`. Missing shape metadata fails closed rather than letting
26+
allocation fail later.
27+
1728
`BusinessIdGenerator`, `UserContext.generateBusinessId(...)`,
1829
`InMemoryBusinessIdGenerator`, and `JdbcBusinessIdGenerator` are deprecated
1930
compatibility APIs. They retain the old `PREFIXyyyyMMddNNNN` format and separate

‎examples/business-id-runtime/src/test/java/io/teaql/examples/businessid/BusinessIdRuntimeExampleTest.java‎

Lines changed: 147 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,96 @@ public void sqliteAllocatorRejectsIncompleteExistingSchema() throws Exception {
259259
}
260260
}
261261

262+
@Test
263+
public void sqliteAllocatorRejectsExistingTableWithoutUniqueScopeKey() throws Exception {
264+
assertInvalidExistingTable(
265+
"CREATE TABLE teaql_business_id_space ("
266+
+ "scope_key VARCHAR(512), current_value BIGINT NOT NULL, "
267+
+ "version BIGINT NOT NULL, updated_at BIGINT NOT NULL)",
268+
"scope_key");
269+
}
270+
271+
@Test
272+
public void sqliteAllocatorRejectsNullableCounter() throws Exception {
273+
assertInvalidExistingTable(
274+
"CREATE TABLE teaql_business_id_space ("
275+
+ "scope_key VARCHAR(512) PRIMARY KEY, current_value BIGINT, "
276+
+ "version BIGINT NOT NULL, updated_at BIGINT NOT NULL)",
277+
"current_value");
278+
}
279+
280+
@Test
281+
public void sqliteAllocatorRejectsNarrowCounter() throws Exception {
282+
assertInvalidExistingTable(
283+
"CREATE TABLE teaql_business_id_space ("
284+
+ "scope_key VARCHAR(512) PRIMARY KEY, current_value SMALLINT NOT NULL, "
285+
+ "version BIGINT NOT NULL, updated_at BIGINT NOT NULL)",
286+
"current_value");
287+
}
288+
289+
@Test
290+
public void sqliteAllocatorRejectsShortScopeKeyColumn() throws Exception {
291+
assertInvalidExistingTable(
292+
"CREATE TABLE teaql_business_id_space ("
293+
+ "scope_key VARCHAR(64) PRIMARY KEY, current_value BIGINT NOT NULL, "
294+
+ "version BIGINT NOT NULL, updated_at BIGINT NOT NULL)",
295+
"scope_key");
296+
}
297+
298+
@Test
299+
public void sqliteAllocatorRejectsNullableVersion() throws Exception {
300+
assertInvalidExistingTable(
301+
"CREATE TABLE teaql_business_id_space ("
302+
+ "scope_key VARCHAR(512) PRIMARY KEY, current_value BIGINT NOT NULL, "
303+
+ "version BIGINT, updated_at BIGINT NOT NULL)",
304+
"version");
305+
}
306+
307+
@Test
308+
public void sqliteAllocatorAcceptsNonNullableUniqueScopeKey() throws Exception {
309+
Path databasePath = Files.createTempFile("teaql-business-id-unique-scope", ".db");
310+
try (Connection connection = open(databasePath)) {
311+
try (Statement statement = connection.createStatement()) {
312+
statement.execute("CREATE TABLE teaql_business_id_space ("
313+
+ "scope_key VARCHAR(512) NOT NULL UNIQUE, "
314+
+ "current_value BIGINT NOT NULL, version BIGINT NOT NULL, "
315+
+ "updated_at BIGINT NOT NULL)");
316+
}
317+
JdbcBusinessIdAllocator allocator = new JdbcBusinessIdAllocator(database(connection));
318+
UserContext context = context(LocalDate.of(2026, 9, 20), allocator);
319+
context.putAttribute(SchemaExecutor.class.getName(), noOpSchemaExecutor());
320+
context.ensureSchema();
321+
context.ensureSchema();
322+
BusinessIdPlan plan = new DailySequenceBusinessIdProfile().plan(
323+
new BusinessIdGenerationRequest(ORDER_NUMBER, "tenant-a",
324+
"commerce_order", LocalDate.of(2026, 9, 20)));
325+
Assert.assertEquals(1L, allocator.allocate(plan).sequence());
326+
Assert.assertEquals(2L, allocator.allocate(plan).sequence());
327+
} finally {
328+
Files.deleteIfExists(databasePath);
329+
}
330+
}
331+
332+
private static void assertInvalidExistingTable(String ddl, String expectedField)
333+
throws Exception {
334+
Path databasePath = Files.createTempFile("teaql-business-id-invalid-shape", ".db");
335+
try (Connection connection = open(databasePath)) {
336+
try (Statement statement = connection.createStatement()) {
337+
statement.execute(ddl);
338+
}
339+
JdbcBusinessIdAllocator allocator = new JdbcBusinessIdAllocator(database(connection));
340+
UserContext context = context(LocalDate.of(2026, 9, 20), allocator);
341+
context.putAttribute(SchemaExecutor.class.getName(), noOpSchemaExecutor());
342+
343+
IllegalStateException failure = Assert.assertThrows(
344+
IllegalStateException.class, context::ensureSchema);
345+
Assert.assertTrue(failure.getMessage(),
346+
failure.getMessage().contains(expectedField));
347+
} finally {
348+
Files.deleteIfExists(databasePath);
349+
}
350+
}
351+
262352
@Test
263353
public void sqliteAllocatorRejectsExhaustedAndOverflowedSequencesWithoutMutation()
264354
throws Exception {
@@ -447,7 +537,14 @@ public List<Map<String, Object>> getTableColumns(String tableName) {
447537
try (ResultSet result = metadata.getColumns(
448538
connection.getCatalog(), connection.getSchema(), name, null)) {
449539
while (result.next()) {
450-
columns.add(Map.of("column_name", result.getString("COLUMN_NAME")));
540+
Map<String, Object> column = new HashMap<>();
541+
column.put("column_name", result.getString("COLUMN_NAME"));
542+
column.put("data_type", result.getInt("DATA_TYPE"));
543+
column.put("type_name", result.getString("TYPE_NAME"));
544+
column.put("column_size", result.getInt("COLUMN_SIZE"));
545+
column.put("decimal_digits", result.getInt("DECIMAL_DIGITS"));
546+
column.put("nullable", result.getInt("NULLABLE"));
547+
columns.add(column);
451548
}
452549
}
453550
if (!columns.isEmpty()) break;
@@ -458,6 +555,55 @@ public List<Map<String, Object>> getTableColumns(String tableName) {
458555
}
459556
}
460557

558+
@Override
559+
public Optional<List<String>> getTablePrimaryKeyColumns(String tableName) {
560+
try {
561+
java.sql.DatabaseMetaData metadata = connection.getMetaData();
562+
for (String name : List.of(tableName, tableName.toUpperCase(Locale.ROOT))) {
563+
TreeMap<Short, String> ordered = new TreeMap<>();
564+
try (ResultSet result = metadata.getPrimaryKeys(
565+
connection.getCatalog(), connection.getSchema(), name)) {
566+
while (result.next()) {
567+
ordered.put(result.getShort("KEY_SEQ"),
568+
result.getString("COLUMN_NAME"));
569+
}
570+
}
571+
if (!ordered.isEmpty()) return Optional.of(List.copyOf(ordered.values()));
572+
}
573+
return Optional.of(List.of());
574+
} catch (SQLException error) {
575+
throw new RuntimeException(error);
576+
}
577+
}
578+
579+
@Override
580+
public Optional<List<List<String>>> getTableUniqueKeys(String tableName) {
581+
try {
582+
java.sql.DatabaseMetaData metadata = connection.getMetaData();
583+
for (String name : List.of(tableName, tableName.toUpperCase(Locale.ROOT))) {
584+
Map<String, TreeMap<Short, String>> indexes = new HashMap<>();
585+
try (ResultSet result = metadata.getIndexInfo(
586+
connection.getCatalog(), connection.getSchema(), name,
587+
true, true)) {
588+
while (result.next()) {
589+
String index = result.getString("INDEX_NAME");
590+
String column = result.getString("COLUMN_NAME");
591+
if (index == null || column == null) continue;
592+
indexes.computeIfAbsent(index, ignored -> new TreeMap<>())
593+
.put(result.getShort("ORDINAL_POSITION"), column);
594+
}
595+
}
596+
if (!indexes.isEmpty()) {
597+
return Optional.of(indexes.values().stream()
598+
.map(ordered -> List.copyOf(ordered.values())).toList());
599+
}
600+
}
601+
return Optional.of(List.of());
602+
} catch (SQLException error) {
603+
throw new RuntimeException(error);
604+
}
605+
}
606+
461607
private void bind(PreparedStatement statement, Object[] args) throws SQLException {
462608
if (args == null) return;
463609
for (int index = 0; index < args.length; index++) {

‎examples/business-id-runtime/src/test/java/io/teaql/examples/businessid/JdbcBusinessIdLiveDialectIT.java‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import io.teaql.core.sql.dialect.OracleDialect;
1111
import java.sql.Connection;
1212
import java.sql.DriverManager;
13+
import java.sql.Statement;
1314
import java.time.LocalDate;
1415
import java.util.ArrayList;
1516
import java.util.HashSet;
@@ -45,6 +46,21 @@ public void sqlServerAllocatesAcrossInstancesAndRestart() throws Exception {
4546
verify("MSSQL");
4647
}
4748

49+
@Test
50+
public void postgresRejectsUnprotectedBusinessIdTable() throws Exception {
51+
rejectUnprotectedTable("POSTGRES");
52+
}
53+
54+
@Test
55+
public void mysqlRejectsUnprotectedBusinessIdTable() throws Exception {
56+
rejectUnprotectedTable("MYSQL");
57+
}
58+
59+
@Test
60+
public void sqlServerRejectsUnprotectedBusinessIdTable() throws Exception {
61+
rejectUnprotectedTable("MSSQL");
62+
}
63+
4864
@Test
4965
public void dm8AllocatesAcrossInstancesAndRestart() throws Exception {
5066
verify("DM8");
@@ -176,6 +192,46 @@ private void verify(String dialect) throws Exception {
176192
}
177193
}
178194

195+
private void rejectUnprotectedTable(String dialect) throws Exception {
196+
String prefix = "TEAQL_TEST_" + dialect + "_";
197+
String url = System.getenv(prefix + "URL");
198+
String user = System.getenv(prefix + "USER");
199+
String password = System.getenv(prefix + "PASSWORD");
200+
if (url == null || user == null || password == null) {
201+
if (Boolean.parseBoolean(System.getenv("TEAQL_REQUIRE_LIVE_DB"))) {
202+
Assert.fail(dialect + " Business ID live gate requires URL, USER, and PASSWORD");
203+
}
204+
Assume.assumeTrue(dialect + " live database is not configured", false);
205+
}
206+
Assert.assertTrue("Use a dedicated teaql_live_* database for " + dialect,
207+
url.contains("teaql_live_"));
208+
209+
String table = "bid_shape_" + UUID.randomUUID().toString().substring(0, 8);
210+
try (Connection connection = DriverManager.getConnection(url, user, password)) {
211+
try (Statement statement = connection.createStatement()) {
212+
statement.execute("CREATE TABLE " + table + " ("
213+
+ "scope_key VARCHAR(512), current_value BIGINT NOT NULL, "
214+
+ "version BIGINT NOT NULL, updated_at BIGINT NOT NULL)");
215+
}
216+
try {
217+
JdbcBusinessIdAllocator allocator = new JdbcBusinessIdAllocator(
218+
BusinessIdRuntimeExampleTest.database(connection), table);
219+
UserContext context = BusinessIdRuntimeExampleTest.context(
220+
BUSINESS_DATE, allocator);
221+
context.putAttribute(SchemaExecutor.class.getName(),
222+
BusinessIdRuntimeExampleTest.noOpSchemaExecutor());
223+
IllegalStateException failure = Assert.assertThrows(
224+
IllegalStateException.class, context::ensureSchema);
225+
Assert.assertTrue(failure.getMessage(),
226+
failure.getMessage().contains("scope_key"));
227+
} finally {
228+
try (Statement statement = connection.createStatement()) {
229+
statement.execute("DROP TABLE " + table);
230+
}
231+
}
232+
}
233+
}
234+
179235
private static JdbcBusinessIdAllocator allocator(Connection connection, String dialect) {
180236
if ("ORACLE".equals(dialect)) {
181237
return new JdbcBusinessIdAllocator(

0 commit comments

Comments
 (0)