diff --git a/.github/workflows/live-dialects.yml b/.github/workflows/live-dialects.yml index 7b2c8530..84d67866 100644 --- a/.github/workflows/live-dialects.yml +++ b/.github/workflows/live-dialects.yml @@ -50,7 +50,44 @@ 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 + -Dtest=PostgresIntegrationTest,MysqlIntegrationTest,JdbcBusinessIdLiveDialectIT#postgresAllocatesAcrossInstancesAndRestart+mysqlAllocatesAcrossInstancesAndRestart + -Dsurefire.failIfNoSpecifiedTests=false test + + business-id-sqlserver: + runs-on: ubuntu-latest + services: + sqlserver: + image: mcr.microsoft.com/mssql/server:2022-latest + env: + ACCEPT_EULA: "Y" + MSSQL_SA_PASSWORD: TeaqlBusinessLiveTest123! + ports: + - 1433:1433 + options: >- + --health-cmd "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P TeaqlBusinessLiveTest123! -C -Q 'SELECT 1' -b" + --health-interval 10s --health-timeout 5s --health-retries 20 + env: + TEAQL_REQUIRE_LIVE_DB: "true" + TEAQL_TEST_MSSQL_URL: "jdbc:sqlserver://127.0.0.1:1433;databaseName=teaql_live_ci;encrypt=false;trustServerCertificate=true" + TEAQL_TEST_MSSQL_USER: sa + TEAQL_TEST_MSSQL_PASSWORD: TeaqlBusinessLiveTest123! + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + cache: maven + - name: Create isolated Business ID test database + run: >- + docker exec "${{ job.services.sqlserver.id }}" + /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa + -P TeaqlBusinessLiveTest123! -C + -Q 'CREATE DATABASE teaql_live_ci' + - 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' -Dsurefire.failIfNoSpecifiedTests=false test sqlserver: diff --git a/examples/business-id-runtime/README.md b/examples/business-id-runtime/README.md index 78eb2d4c..a99030be 100644 --- a/examples/business-id-runtime/README.md +++ b/examples/business-id-runtime/README.md @@ -31,3 +31,36 @@ mvn -pl examples/business-id-runtime -am \ -Dtest=BusinessIdRuntimeExampleTest \ -Dsurefire.failIfNoSpecifiedTests=false test ``` + +The live suite also verifies PostgreSQL, MySQL, and SQL Server in CI. The +SQL Server cases include independent-connection allocation and concurrent +schema startup. DM8 and Oracle are local-only live checks until durable CI +images and dedicated test credentials are available. Run DM8 only against a +disposable instance; +the test refuses to run without `TEAQL_TEST_DM8_ISOLATED=true`: + +```bash +TEAQL_REQUIRE_LIVE_DB=true \ +TEAQL_TEST_DM8_ISOLATED=true \ +TEAQL_TEST_DM8_URL='jdbc:dm://127.0.0.1:5236' \ +TEAQL_TEST_DM8_USER='SYSDBA' \ +TEAQL_TEST_DM8_PASSWORD='' \ +mvn -pl examples/business-id-runtime -am \ + '-Dtest=JdbcBusinessIdLiveDialectIT#dm8AllocatesAcrossInstancesAndRestart' \ + -Dsurefire.failIfNoSpecifiedTests=false test +``` + +For Oracle, create a dedicated `TEAQL_LIVE_*` user in an isolated test PDB, +then run the Oracle method with that user's credentials. Oracle requires +`new JdbcBusinessIdAllocator(database, table, new OracleDialect())` so the +Business ID counter uses `NUMBER(19,0)` rather than unsupported `BIGINT`: + +```bash +TEAQL_REQUIRE_LIVE_DB=true \ +TEAQL_TEST_ORACLE_URL='jdbc:oracle:thin:@//127.0.0.1:1521/FREEPDB1' \ +TEAQL_TEST_ORACLE_USER='TEAQL_LIVE_BID' \ +TEAQL_TEST_ORACLE_PASSWORD='' \ +mvn -pl examples/business-id-runtime -am \ + '-Dtest=JdbcBusinessIdLiveDialectIT#oracleAllocatesAcrossInstancesAndRestart' \ + -Dsurefire.failIfNoSpecifiedTests=false test +``` diff --git a/examples/business-id-runtime/pom.xml b/examples/business-id-runtime/pom.xml index d066c4d1..efb18da4 100644 --- a/examples/business-id-runtime/pom.xml +++ b/examples/business-id-runtime/pom.xml @@ -43,5 +43,23 @@ junit test + + com.microsoft.sqlserver + mssql-jdbc + 13.4.0.jre11 + test + + + com.dameng + DmJdbcDriver18 + 8.1.3.140 + test + + + com.oracle.database.jdbc + ojdbc11 + 23.9.0.25.07 + test + 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 b4788745..2c7992b4 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 @@ -238,6 +238,27 @@ public void sqliteAllocatorRejectsMalformedSequenceRowWithoutRetryExhaustion() } } + @Test + public void sqliteAllocatorRejectsIncompleteExistingSchema() throws Exception { + Path databasePath = Files.createTempFile("teaql-business-id-incomplete-schema", ".db"); + try (Connection connection = open(databasePath)) { + try (Statement statement = connection.createStatement()) { + statement.execute("CREATE TABLE teaql_business_id_space (scope_key TEXT PRIMARY KEY)"); + } + 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("missing required columns")); + } finally { + Files.deleteIfExists(databasePath); + } + } + @Test public void sqliteAllocatorRejectsExhaustedAndOverflowedSequencesWithoutMutation() throws Exception { @@ -419,7 +440,22 @@ public void executeInTransaction(Runnable action) { @Override public List> getTableColumns(String tableName) { - throw new UnsupportedOperationException(); + try { + List> columns = new ArrayList<>(); + java.sql.DatabaseMetaData metadata = connection.getMetaData(); + for (String name : List.of(tableName, tableName.toUpperCase(Locale.ROOT))) { + try (ResultSet result = metadata.getColumns( + connection.getCatalog(), connection.getSchema(), name, null)) { + while (result.next()) { + columns.add(Map.of("column_name", result.getString("COLUMN_NAME"))); + } + } + if (!columns.isEmpty()) break; + } + return columns; + } catch (SQLException error) { + throw new RuntimeException(error); + } } private void bind(PreparedStatement statement, Object[] args) throws SQLException { 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 a0b6218f..3a935d94 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 @@ -7,6 +7,7 @@ import io.teaql.core.businessid.BusinessIdGenerationRequest; import io.teaql.core.businessid.BusinessIdPlan; import io.teaql.runtime.businessid.DailySequenceBusinessIdProfile; +import io.teaql.core.sql.dialect.OracleDialect; import java.sql.Connection; import java.sql.DriverManager; import java.time.LocalDate; @@ -24,7 +25,7 @@ import org.junit.Assume; import org.junit.Test; -/** Live, independent-connection Business ID allocation across PostgreSQL and MySQL. */ +/** Live, independent-connection Business ID allocation across SQL providers. */ public class JdbcBusinessIdLiveDialectIT { private static final LocalDate BUSINESS_DATE = LocalDate.of(2026, 9, 20); private static final int ALLOCATIONS_PER_INSTANCE = 40; @@ -39,6 +40,65 @@ public void mysqlAllocatesAcrossInstancesAndRestart() throws Exception { verify("MYSQL"); } + @Test + public void sqlServerAllocatesAcrossInstancesAndRestart() throws Exception { + verify("MSSQL"); + } + + @Test + public void dm8AllocatesAcrossInstancesAndRestart() throws Exception { + verify("DM8"); + } + + @Test + public void oracleAllocatesAcrossInstancesAndRestart() throws Exception { + verify("ORACLE"); + } + + @Test + public void sqlServerConcurrentSchemaStartupIsIdempotent() throws Exception { + String url = System.getenv("TEAQL_TEST_MSSQL_URL"); + String user = System.getenv("TEAQL_TEST_MSSQL_USER"); + String password = System.getenv("TEAQL_TEST_MSSQL_PASSWORD"); + if (url == null || user == null || password == null) { + if (Boolean.parseBoolean(System.getenv("TEAQL_REQUIRE_LIVE_DB"))) { + Assert.fail("MSSQL Business ID live gate requires URL, USER, and PASSWORD"); + } + Assume.assumeTrue("MSSQL live database is not configured", false); + } + Assert.assertTrue("Use a dedicated teaql_live_* database for MSSQL", + url.contains("teaql_live_")); + String table = "bid_schema_" + UUID.randomUUID().toString().substring(0, 8); + CountDownLatch start = new CountDownLatch(1); + ExecutorService workers = Executors.newFixedThreadPool(2); + try { + List> futures = new ArrayList<>(); + for (int worker = 0; worker < 2; worker++) { + futures.add(workers.submit(() -> { + try (Connection connection = DriverManager.getConnection(url, user, password)) { + JdbcBusinessIdAllocator allocator = new JdbcBusinessIdAllocator( + BusinessIdRuntimeExampleTest.database(connection), table); + UserContext context = BusinessIdRuntimeExampleTest.context( + BUSINESS_DATE, allocator); + context.putAttribute(SchemaExecutor.class.getName(), + BusinessIdRuntimeExampleTest.noOpSchemaExecutor()); + Assert.assertTrue(start.await(15, TimeUnit.SECONDS)); + context.ensureSchema(); + return null; + } + })); + } + start.countDown(); + for (Future future : futures) future.get(30, TimeUnit.SECONDS); + } finally { + workers.shutdownNow(); + } + try (Connection connection = DriverManager.getConnection(url, user, password)) { + Assert.assertEquals(4, BusinessIdRuntimeExampleTest.database(connection) + .getTableColumns(table).size()); + } + } + private void verify(String dialect) throws Exception { String prefix = "TEAQL_TEST_" + dialect + "_"; String url = System.getenv(prefix + "URL"); @@ -50,8 +110,16 @@ private void verify(String dialect) throws Exception { } Assume.assumeTrue(dialect + " live database is not configured", false); } - Assert.assertTrue("Use a dedicated teaql_live_* database for " + dialect, - url.contains("teaql_live_")); + if ("DM8".equals(dialect)) { + Assert.assertEquals("Use only an isolated disposable DM8 instance", + "true", System.getenv("TEAQL_TEST_DM8_ISOLATED")); + } else if ("ORACLE".equals(dialect)) { + Assert.assertTrue("Use only a dedicated TEAQL_LIVE_* Oracle user", + user.toUpperCase(java.util.Locale.ROOT).startsWith("TEAQL_LIVE_")); + } else { + Assert.assertTrue("Use a dedicated teaql_live_* database for " + dialect, + url.contains("teaql_live_")); + } BusinessIdDefinition definition = BusinessIdDefinition.dailySequence( "order_number", "CO", "commerce_order"); @@ -61,12 +129,12 @@ private void verify(String dialect) throws Exception { "commerce_order", BUSINESS_DATE)); try (Connection schemaConnection = DriverManager.getConnection(url, user, password)) { - JdbcBusinessIdAllocator allocator = new JdbcBusinessIdAllocator( - BusinessIdRuntimeExampleTest.database(schemaConnection)); + JdbcBusinessIdAllocator allocator = allocator(schemaConnection, dialect); UserContext context = BusinessIdRuntimeExampleTest.context(BUSINESS_DATE, allocator); context.putAttribute(SchemaExecutor.class.getName(), BusinessIdRuntimeExampleTest.noOpSchemaExecutor()); context.ensureSchema(); + context.ensureSchema(); } CountDownLatch start = new CountDownLatch(1); @@ -76,8 +144,7 @@ private void verify(String dialect) throws Exception { for (int worker = 0; worker < 2; worker++) { futures.add(workers.submit(() -> { try (Connection connection = DriverManager.getConnection(url, user, password)) { - JdbcBusinessIdAllocator allocator = new JdbcBusinessIdAllocator( - BusinessIdRuntimeExampleTest.database(connection)); + JdbcBusinessIdAllocator allocator = allocator(connection, dialect); Assert.assertTrue("Concurrent start timed out", start.await(15, TimeUnit.SECONDS)); List sequences = new ArrayList<>(); @@ -103,10 +170,19 @@ private void verify(String dialect) throws Exception { } try (Connection restarted = DriverManager.getConnection(url, user, password)) { - JdbcBusinessIdAllocator allocator = new JdbcBusinessIdAllocator( - BusinessIdRuntimeExampleTest.database(restarted)); + JdbcBusinessIdAllocator allocator = allocator(restarted, dialect); Assert.assertEquals(2L * ALLOCATIONS_PER_INSTANCE + 1, allocator.allocate(plan).sequence()); } } + + private static JdbcBusinessIdAllocator allocator(Connection connection, String dialect) { + if ("ORACLE".equals(dialect)) { + return new JdbcBusinessIdAllocator( + BusinessIdRuntimeExampleTest.database(connection), + JdbcBusinessIdAllocator.DEFAULT_TABLE, + new OracleDialect()); + } + return new JdbcBusinessIdAllocator(BusinessIdRuntimeExampleTest.database(connection)); + } } 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 69912b18..24a27684 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 @@ -3,9 +3,14 @@ import io.teaql.core.UserContext; import io.teaql.core.businessid.*; import io.teaql.core.sql.portable.TeaQLDatabase; +import io.teaql.core.sql.dialect.PostgreSqlDialect; +import io.teaql.core.sql.dialect.SqlDialect; import java.time.ZoneOffset; import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.HashSet; +import java.util.Locale; /** Portable optimistic Business ID allocator backed by a TeaQLDatabase. */ public final class JdbcBusinessIdAllocator @@ -15,27 +20,88 @@ public final class JdbcBusinessIdAllocator private final TeaQLDatabase database; private final String table; + private final SqlDialect dialect; public JdbcBusinessIdAllocator(TeaQLDatabase database) { this(database, DEFAULT_TABLE); } public JdbcBusinessIdAllocator(TeaQLDatabase database, String table) { + this(database, table, new PostgreSqlDialect()); + } + + public JdbcBusinessIdAllocator(TeaQLDatabase database, String table, SqlDialect dialect) { this.database = java.util.Objects.requireNonNull(database, "database"); if (table == null || !table.matches("[A-Za-z][A-Za-z0-9_]*")) { throw new IllegalArgumentException("Invalid Business ID table name: " + table); } this.table = table; + this.dialect = java.util.Objects.requireNonNull(dialect, "dialect"); } /** Explicit development/test schema operation; construction never executes DDL. */ @Override public void ensureSchema(UserContext context) { - database.execute(context, "CREATE TABLE IF NOT EXISTS " + table + " (" - + "scope_key VARCHAR(512) PRIMARY KEY, " - + "current_value BIGINT NOT NULL, " - + "version BIGINT NOT NULL, " - + "updated_at BIGINT NOT NULL)"); + List> existing = inspectColumns(); + if (!existing.isEmpty()) { + requireColumns(existing); + return; + } + try { + database.execute(context, "CREATE TABLE " + table + " (" + + "scope_key " + dialect.mapColumnType("VARCHAR(512)") + " PRIMARY KEY, " + + "current_value " + dialect.mapColumnType("BIGINT") + " NOT NULL, " + + "version " + dialect.mapColumnType("BIGINT") + " NOT NULL, " + + "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. + try { + List> concurrent = inspectColumns(); + if (!concurrent.isEmpty()) { + requireColumns(concurrent); + return; + } + } catch (RuntimeException inspectFailure) { + createFailure.addSuppressed(inspectFailure); + } + throw new IllegalStateException( + "Cannot create Business ID table " + table, createFailure); + } + requireColumns(inspectColumns()); + } + + private List> inspectColumns() { + try { + List> columns = database.getTableColumns(table); + if (columns == null) { + throw new IllegalStateException("Column inspection returned null for " + table); + } + return columns; + } catch (RuntimeException failure) { + throw new IllegalStateException( + "Cannot inspect Business ID table " + table + + "; the database adapter must provide column metadata", + failure); + } + } + + private void requireColumns(List> columns) { + Set found = new HashSet<>(); + 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)); + } + } + } + Set missing = new java.util.TreeSet<>(Set.of( + "scope_key", "current_value", "version", "updated_at")); + missing.removeAll(found); + if (!missing.isEmpty()) { + throw new IllegalStateException( + "Business ID table " + table + " is missing required columns " + missing); + } } @Override diff --git a/teaql-sql-portable/src/main/java/io/teaql/core/sql/dialect/OracleDialect.java b/teaql-sql-portable/src/main/java/io/teaql/core/sql/dialect/OracleDialect.java index a006126f..d301e360 100644 --- a/teaql-sql-portable/src/main/java/io/teaql/core/sql/dialect/OracleDialect.java +++ b/teaql-sql-portable/src/main/java/io/teaql/core/sql/dialect/OracleDialect.java @@ -40,6 +40,9 @@ public String mapColumnType(String type) { if ("LARGE_TEXT".equalsIgnoreCase(type)) { return "CLOB"; } + if ("BIGINT".equalsIgnoreCase(type)) { + return "NUMBER(19,0)"; + } return type; } } diff --git a/teaql-sql-portable/src/test/java/io/teaql/core/sql/portable/OracleDialectTypeTest.java b/teaql-sql-portable/src/test/java/io/teaql/core/sql/portable/OracleDialectTypeTest.java new file mode 100644 index 00000000..b464108f --- /dev/null +++ b/teaql-sql-portable/src/test/java/io/teaql/core/sql/portable/OracleDialectTypeTest.java @@ -0,0 +1,15 @@ +package io.teaql.core.sql.portable; + +import static org.junit.Assert.assertEquals; + +import io.teaql.core.sql.dialect.OracleDialect; +import org.junit.Test; + +public class OracleDialectTypeTest { + @Test + public void mapsBusinessIdCounterToIntegralNumber() { + OracleDialect dialect = new OracleDialect(); + assertEquals("NUMBER(19,0)", dialect.mapColumnType("BIGINT")); + assertEquals("VARCHAR(512)", dialect.mapColumnType("VARCHAR(512)")); + } +}