@@ -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 ++) {
0 commit comments