From 60c046cd808df8c23432db6cba33cd260490b5ec Mon Sep 17 00:00:00 2001 From: Amer Agovic Date: Fri, 12 Jun 2026 16:31:40 -0500 Subject: [PATCH] Update bstore-j model management and SQL support --- README.md | 21 +- build.gradle | 2 + .../com/reliancy/dbo/meta/ModelManager.java | 326 ++++++++++++++++++ .../java/com/reliancy/dbo/sql/SQLReader.java | 13 +- .../com/reliancy/dbo/sql/SQLTerminal.java | 88 ++++- .../java/com/reliancy/dbo/TestDbSupport.java | 23 +- .../reliancy/dbo/meta/ModelManagerTest.java | 131 +++++++ .../com/reliancy/dbo/sql/SQLReaderTest.java | 1 + .../sql/SQLTerminalSqlServerSmokeTest.java | 46 +++ 9 files changed, 639 insertions(+), 12 deletions(-) create mode 100644 src/main/java/com/reliancy/dbo/meta/ModelManager.java create mode 100644 src/test/java/com/reliancy/dbo/meta/ModelManagerTest.java create mode 100644 src/test/java/com/reliancy/dbo/sql/SQLTerminalSqlServerSmokeTest.java diff --git a/README.md b/README.md index 4ce3a2a..9b2a4ce 100644 --- a/README.md +++ b/README.md @@ -146,11 +146,28 @@ The central types in this area are: ## Databases -The Java implementation is SQL-oriented today. The project currently includes -dependencies and test coverage around: +The Java implementation is SQL-oriented today. The project includes JDBC drivers for: - PostgreSQL - H2 +- Microsoft SQL Server (`mssql-jdbc`), used by Farnam Control 4.0 and similar apps. + +Path-style URLs use `SQLTerminal`, for example: + +```text +sqlserver://user:password@host:1433/databaseName +``` + +Optional semicolon properties after the database segment are forwarded into the JDBC URL (for example `encrypt=true`). + +### Integration tests against SQL Server + +Tests that need a live database read **`FC4_SQL_URL` first**, then **`DB_URL`** (historic convention). Example: + +```bash +export FC4_SQL_URL='sqlserver://user:pass@host:1433/mydb' +./gradlew test --tests com.reliancy.dbo.sql.SQLTerminalSqlServerSmokeTest +``` The broader API is designed so application code can stay closer to entities and actions than to vendor-specific SQL strings. diff --git a/build.gradle b/build.gradle index 219eb07..66630c0 100644 --- a/build.gradle +++ b/build.gradle @@ -29,10 +29,12 @@ dependencies { // Database drivers implementation 'com.h2database:h2:2.3.232' implementation 'org.postgresql:postgresql:42.7.4' + implementation 'com.microsoft.sqlserver:mssql-jdbc:12.8.1.jre11' implementation 'com.zaxxer:HikariCP:5.1.0' // Testing testImplementation "junit:junit:4.13.2" + testRuntimeOnly 'org.slf4j:slf4j-simple:2.0.16' } repositories { diff --git a/src/main/java/com/reliancy/dbo/meta/ModelManager.java b/src/main/java/com/reliancy/dbo/meta/ModelManager.java new file mode 100644 index 0000000..ff2a9e6 --- /dev/null +++ b/src/main/java/com/reliancy/dbo/meta/ModelManager.java @@ -0,0 +1,326 @@ +package com.reliancy.dbo.meta; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Supplier; + +import com.reliancy.dbo.Action; +import com.reliancy.dbo.DBO; +import com.reliancy.dbo.Entity; +import com.reliancy.dbo.Field; +import com.reliancy.dbo.sql.SQLTerminal; +import com.reliancy.rec.JSON; +import com.reliancy.rec.JSONEncoder; +import com.reliancy.rec.Rec; +import com.reliancy.rec.Slot; + +/** + * Reusable management helper for bstore-backed application CLIs. + * + *

This class centralizes the repetitive schema and change-log orchestration + * commonly implemented in application-specific CLIs: + *

+ * + *

Application-specific concerns such as seeding and environment lookup stay + * outside this helper. + */ +public class ModelManager { + private final List entities; + private final Supplier terminalSupplier; + private final Path migrationDir; + + @SafeVarargs + public ModelManager(String dbUrl, Path migrationDir, Class... modelClasses) { + this(() -> new SQLTerminal(dbUrl), migrationDir, recallEntities(modelClasses)); + } + + public ModelManager(Supplier terminalSupplier, Path migrationDir, Iterable entities) { + this.terminalSupplier = Objects.requireNonNull(terminalSupplier, "terminalSupplier"); + this.migrationDir = migrationDir; + this.entities = new ArrayList<>(); + if (entities != null) { + for (Entity entity : entities) { + if (entity != null) { + this.entities.add(entity); + } + } + } + } + + @SafeVarargs + private static List recallEntities(Class... modelClasses) { + List entities = new ArrayList<>(); + if (modelClasses != null) { + for (Class modelClass : modelClasses) { + if (modelClass != null) { + entities.add(Entity.recall(modelClass)); + } + } + } + return entities; + } + + public List getEntities() { + return new ArrayList<>(entities); + } + + public Path defaultRevisionPath(String migrationId) { + if (migrationDir == null) { + throw new IllegalStateException("migrationDir is not configured"); + } + return migrationDir.resolve(migrationId + ".json"); + } + + public void ensureSchema() throws IOException { + try (SQLTerminal terminal = terminalSupplier.get()) { + MetaTerminal meta = terminal.meta(null); + for (Entity entity : entities) { + meta.assertEntity("model-manager", "ensure-schema", entity); + } + } + } + + public void migrate(String originatorId, String migrationId) throws IOException { + try (SQLTerminal terminal = terminalSupplier.get()) { + terminal.meta(null).migrate(originatorId, migrationId, entities.toArray(Entity[]::new)); + } + } + + public List snapshot() throws IOException { + try (SQLTerminal terminal = terminalSupplier.get()) { + MetaTerminal meta = terminal.meta(null); + List ret = new ArrayList<>(); + for (Entity entity : entities) { + ret.add(meta.discover_entity(entity)); + } + return ret; + } + } + + public List captureChanges(String originatorId, String migrationId) throws IOException { + try (SQLTerminal terminal = terminalSupplier.get()) { + MetaTerminal meta = terminal.meta(null); + List ret = new ArrayList<>(); + for (Entity entity : entities) { + EntityDefinition desired = meta.discover_entity(entity); + EntityDefinition actual = meta.discover_entity(entity.getName()); + for (ChangeEvent change : meta.discover_changes(originatorId, migrationId, actual, desired)) { + ret.add(change); + } + } + return ret; + } + } + + public List captureChanges(String originatorId, String migrationId, Path outputPath) throws IOException { + List changes = captureChanges(originatorId, migrationId); + writeChanges(changes, outputPath); + return changes; + } + + public void writeChanges(Iterable changes, Path outputPath) throws IOException { + List> payload = new ArrayList<>(); + for (ChangeEvent change : changes) { + payload.add(changeToMap(change)); + } + if (outputPath.getParent() != null) { + Files.createDirectories(outputPath.getParent()); + } + StringBuilder json = new StringBuilder(); + JSONEncoder.encode(payload, json); + Files.writeString(outputPath, json.toString(), StandardCharsets.UTF_8); + } + + public int applyChanges(Path inputPath) throws IOException { + try (InputStream in = Files.newInputStream(inputPath)) { + return applyChanges(loadChanges(in)); + } + } + + public int applyChanges(Iterable changes) throws IOException { + List list = new ArrayList<>(); + for (ChangeEvent change : changes) { + list.add(change); + } + try (SQLTerminal terminal = terminalSupplier.get()) { + terminal.meta(null).apply_changes(list); + } + return list.size(); + } + + public List listSnapshots() throws IOException { + try (SQLTerminal terminal = terminalSupplier.get(); + Action action = terminal.begin().load(ChangeEvent.class).execute()) { + List ret = new ArrayList<>(); + for (DBO dbo : action) { + ret.add((ChangeEvent) dbo); + } + return ret; + } + } + + public static List loadChanges(InputStream in) throws IOException { + Rec parsed = JSON.reads(new String(in.readAllBytes(), StandardCharsets.UTF_8)); + List ret = new ArrayList<>(); + if (parsed == null) { + return ret; + } + if (parsed.meta() != null && parsed.meta().checkFlags(com.reliancy.rec.Hdr.FLAG_ARRAY)) { + for (int i = 0; i < parsed.count(); i++) { + Object item = parsed.get(i); + if (item instanceof Rec rec) { + ret.add(changeFromMap(rec)); + } + } + } else { + ret.add(changeFromMap(parsed)); + } + return ret; + } + + private static Map changeToMap(ChangeEvent change) { + Map row = new LinkedHashMap<>(); + row.put("id", change.get(ChangeEvent.ID)); + row.put("migration_id", change.get(ChangeEvent.MIGRATION_ID)); + row.put("originator_id", change.get(ChangeEvent.ORIGINATOR_ID)); + row.put("object_path", change.getObjectPath()); + row.put("object_id", change.getObjectId()); + row.put("object_code", change.getObjectCode() != null ? change.getObjectCode().getCode() : null); + row.put("verb_type", change.getVerbType() != null ? change.getVerbType().getCode() : null); + row.put("payload", payloadToObject(change.getPayload())); + return row; + } + + private static Object payloadToObject(Rec payload) { + if (payload == null) { + return null; + } + if (payload instanceof EntityDefinition entityDefinition) { + Map map = new LinkedHashMap<>(entityDefinition.toMap()); + List> fields = new ArrayList<>(); + for (FieldDefinition field : entityDefinition.getFields()) { + fields.add(field.toMap()); + } + map.put("fields", fields); + return map; + } + if (payload instanceof FieldDefinition fieldDefinition) { + return fieldDefinition.toMap(); + } + return recToMap(payload); + } + + private static ChangeEvent changeFromMap(Rec rec) { + ChangeEvent change = new ChangeEvent(); + copyField(change, ChangeEvent.ID, rec, "id"); + copyField(change, ChangeEvent.MIGRATION_ID, rec, "migration_id"); + copyField(change, ChangeEvent.ORIGINATOR_ID, rec, "originator_id"); + + ObjectType objectType = objectType(recValue(rec, "object_code")); + EventVerbType verbType = verbType(recValue(rec, "verb_type")); + change.setObjectURI(objectType, stringValue(recValue(rec, "object_path")), recValue(rec, "object_id")); + change.setVerbType(verbType); + + Object payload = recValue(rec, "payload"); + if (payload instanceof Rec payloadRec) { + change.setPayload(rehydratePayload(objectType, payloadRec)); + } + return change; + } + + private static void copyField(ChangeEvent change, Field field, Rec rec, String key) { + Object value = recValue(rec, key); + if (value != null) { + change.set(field, value); + } + } + + private static Object recValue(Rec rec, String key) { + Slot slot = rec.getSlot(key); + return slot != null ? rec.get(slot, null) : null; + } + + private static String stringValue(Object value) { + return value != null ? value.toString() : null; + } + + private static ObjectType objectType(Object value) { + return value != null ? ObjectType.fromCode(value.toString()) : null; + } + + private static EventVerbType verbType(Object value) { + return value != null ? EventVerbType.fromCode(value.toString()) : null; + } + + private static Rec rehydratePayload(ObjectType objectType, Rec payloadRec) { + if (objectType == ObjectType.ENTITY) { + EntityDefinition entity = new EntityDefinition(); + hydrate(entity, payloadRec); + Object fields = recValue(payloadRec, "fields"); + if (fields instanceof Rec fieldArray && fieldArray.meta() != null && fieldArray.meta().checkFlags(com.reliancy.rec.Hdr.FLAG_ARRAY)) { + for (int i = 0; i < fieldArray.count(); i++) { + Object item = fieldArray.get(i); + if (item instanceof Rec fieldRec) { + FieldDefinition field = new FieldDefinition(); + hydrate(field, fieldRec); + entity.addField(field); + } + } + } + return entity; + } + if (objectType == ObjectType.FIELD) { + FieldDefinition field = new FieldDefinition(); + hydrate(field, payloadRec); + return field; + } + return payloadRec; + } + + private static T hydrate(T target, Rec rec) { + Entity entity = target.getType(); + if (entity == null || rec == null) { + return target; + } + for (int i = 0; i < rec.count(); i++) { + Slot slot = rec.getSlot(i); + if (slot == null) { + continue; + } + Field field = entity.getField(slot.getName()); + if (field != null) { + field.set(target, rec.get(slot, null)); + } + } + return target; + } + + private static Map recToMap(Rec rec) { + Map map = new LinkedHashMap<>(); + if (rec == null) { + return map; + } + for (int i = 0; i < rec.count(); i++) { + Slot slot = rec.getSlot(i); + if (slot != null) { + map.put(slot.getName(), rec.get(slot, null)); + } + } + return map; + } +} diff --git a/src/main/java/com/reliancy/dbo/sql/SQLReader.java b/src/main/java/com/reliancy/dbo/sql/SQLReader.java index 6470944..ad07272 100644 --- a/src/main/java/com/reliancy/dbo/sql/SQLReader.java +++ b/src/main/java/com/reliancy/dbo/sql/SQLReader.java @@ -187,9 +187,18 @@ public class SQLReader implements SiphonIterator, ActionHero{ boolean isSqlServerOrOracle = protocol.contains("sqlserver") || protocol.contains("oracle"); if(isSqlServerOrOracle){ // SQL Server/Oracle order: OFFSET ... ROWS FETCH NEXT ... ROWS ONLY - if(tr.offset!=0){ + if(tr.offset != 0) { sql.offset(tr.offset); - tr.isOffsetApplied=true; + tr.isOffsetApplied = true; + } else if (tr.limit != 0) { + // T-SQL / Oracle OFFSET-FETCH: FETCH NEXT must follow OFFSET; SQLBuilder.offset() skips 0 + sql.append(SQLBuilder.WS) + .append(SQLBuilder.OFFSET) + .append(SQLBuilder.WS) + .append("0") + .append(SQLBuilder.WS) + .append("ROWS"); + tr.isOffsetApplied = true; } if(tr.limit!=0){ sql.limit(tr.limit); diff --git a/src/main/java/com/reliancy/dbo/sql/SQLTerminal.java b/src/main/java/com/reliancy/dbo/sql/SQLTerminal.java index ed671ff..112dee4 100644 --- a/src/main/java/com/reliancy/dbo/sql/SQLTerminal.java +++ b/src/main/java/com/reliancy/dbo/sql/SQLTerminal.java @@ -41,7 +41,7 @@ import com.zaxxer.hikari.HikariDataSource; * Examples: * postgres://user:pass@localhost:5432/mydb * mysql://root:secret@192.168.1.100:3306/app - * sqlserver://sa:pass@server:1433/testdb + * sqlserver://sa:pass@server:1433/testdb (mapped to Microsoft {@code ;databaseName=} JDBC form) * h2://sa:@mem:testdb * * @@ -107,7 +107,8 @@ import com.zaxxer.hikari.HikariDataSource; *

Identifier Quoting:

*

The terminal uses database-specific identifier quotes: *

* @@ -117,7 +118,7 @@ import com.zaxxer.hikari.HikariDataSource; * @see SQLWriter * @see SQLCleaner */ -public class SQLTerminal implements Terminal{ +public class SQLTerminal implements Terminal, AutoCloseable{ HikariConfig config = new HikariConfig(); HikariDataSource ds; Path url; @@ -128,7 +129,11 @@ public class SQLTerminal implements Terminal{ this.url=new Path(url); String proto=this.url.getProtocol(); if(!proto.startsWith("jdbc:")) proto="jdbc:"+proto; - String u=proto+"://"+this.url.getHost()+":"+this.url.getPort()+"/"+this.url.getDatabase(); + if (proto.toLowerCase().contains("sqlserver")) { + quoteLeft = "["; + quoteRight = "]"; + } + String u = jdbcUrlFromPath(proto, this.url); config.setJdbcUrl(u); config.setUsername(this.url.getUserid()); config.setPassword(this.url.getPassword()); @@ -138,9 +143,83 @@ public class SQLTerminal implements Terminal{ config.addDataSourceProperty( "prepStmtCacheSqlLimit" , "2048" ); ds = new HikariDataSource( config ); } + + /** + * Microsoft JDBC does not treat the catalog like a URL path; {@code host:1433/dbname} is parsed as an + * invalid port. Build {@code jdbc:sqlserver://host[:port];databaseName=...;extra...} instead. + */ + static String jdbcUrlFromPath(String protoWithJdbc, Path p) { + String lower = protoWithJdbc.toLowerCase(); + if (lower.contains("sqlserver")) { + String host = p.getHost(); + if (host == null || host.isBlank()) { + host = "localhost"; + } + String port = p.getPort(); + String db = p.getDatabase() != null ? p.getDatabase() : ""; + StringBuilder sb = new StringBuilder(protoWithJdbc).append("://").append(host); + if (port != null && !port.isBlank()) { + sb.append(":").append(port); + } + sb.append(";databaseName=").append(db); + appendPathQueryAsJdbcProps(sb, p.getProperties()); + // mssql-jdbc 11+ defaults encrypt=true, which requires a JVM-trusted server cert; internal + // hosts often fail PKIX unless encrypt is set. Only add when caller did not specify encrypt=. + String out = sb.toString(); + if (!jdbcSemicolonPropsContains(out, "encrypt")) { + out = out + ";encrypt=false"; + } + return out; + } + return protoWithJdbc + "://" + p.getHost() + ":" + p.getPort() + "/" + p.getDatabase(); + } + + /** True if {@code name=value} appears as a JDBC semicolon property (case-insensitive name). */ + static boolean jdbcSemicolonPropsContains(String jdbcUrl, String name) { + String n = name.toLowerCase() + "="; + String u = jdbcUrl.toLowerCase(); + int i = 0; + while (i < u.length()) { + int semi = u.indexOf(';', i); + String seg = semi < 0 ? u.substring(i) : u.substring(i, semi); + if (seg.startsWith(n)) { + return true; + } + if (semi < 0) { + break; + } + i = semi + 1; + } + return false; + } + + private static void appendPathQueryAsJdbcProps(StringBuilder sb, String pathProps) { + if (pathProps == null || pathProps.isBlank()) { + return; + } + String rest = pathProps; + while (rest.startsWith("?") || rest.startsWith(";")) { + rest = rest.substring(1); + } + if (rest.isBlank()) { + return; + } + if (!rest.startsWith(";")) { + sb.append(";"); + } + sb.append(rest); + } + public Connection getConnection() throws SQLException{ return ds.getConnection(); } + + @Override + public void close() { + if (ds != null) { + ds.close(); + } + } @Override public ActionHero getExecutor(Entity ent, Action.Trait trait) { // Default executors for regular entities @@ -305,4 +384,3 @@ public class SQLTerminal implements Terminal{ return new SQLMetaTerminal(this); } } - diff --git a/src/test/java/com/reliancy/dbo/TestDbSupport.java b/src/test/java/com/reliancy/dbo/TestDbSupport.java index 27e04da..fa5c79b 100644 --- a/src/test/java/com/reliancy/dbo/TestDbSupport.java +++ b/src/test/java/com/reliancy/dbo/TestDbSupport.java @@ -17,10 +17,27 @@ public final class TestDbSupport { private TestDbSupport() { } + /** + * Path-style JDBC URL for {@link SQLTerminal}. Prefer {@code FC4_SQL_URL} (fc4 / Farnam Control), + * then {@code DB_URL} (historic bstore-j convention). + */ public static String requireDbUrl() { - String url = System.getenv("DB_URL"); - assumeTrue("DB_URL environment variable must be set for integration tests", url != null && !url.trim().isEmpty()); - return url; + String url = firstNonBlank(System.getenv("FC4_SQL_URL"), System.getenv("DB_URL")); + assumeTrue( + "Set FC4_SQL_URL or DB_URL for integration tests (e.g. sqlserver://user:pass@host:1433/db)", + url != null && !url.trim().isEmpty() + ); + return url.trim(); + } + + private static String firstNonBlank(String a, String b) { + if (a != null && !a.isBlank()) { + return a; + } + if (b != null && !b.isBlank()) { + return b; + } + return null; } public static SQLTerminal openTerminal() { diff --git a/src/test/java/com/reliancy/dbo/meta/ModelManagerTest.java b/src/test/java/com/reliancy/dbo/meta/ModelManagerTest.java new file mode 100644 index 0000000..7b16628 --- /dev/null +++ b/src/test/java/com/reliancy/dbo/meta/ModelManagerTest.java @@ -0,0 +1,131 @@ +package com.reliancy.dbo.meta; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.List; + +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; + +import com.reliancy.dbo.DBO; +import com.reliancy.dbo.Entity; +import com.reliancy.dbo.Field; +import com.reliancy.dbo.TestDbSupport; +import com.reliancy.dbo.sql.SQLTerminal; + +public class ModelManagerTest { + @Entity.Info(name = "public.model_manager_widget") + public static class ModelManagerWidget extends DBO { + public static final Field ID = Field.Int("id").setPk(true).setAutoIncrement(true); + public static final Field NAME = Field.Str("name").setTypeParams("255"); + } + + private static Path migrationDir; + + @BeforeClass + public static void setUp() throws IOException, SQLException { + migrationDir = Files.createTempDirectory("bstore-j-model-manager"); + dropTable(); + clearChangeLog("mgr-apply"); + clearChangeLog("mgr-migrate"); + } + + @AfterClass + public static void tearDown() throws IOException, SQLException { + dropTable(); + } + + @Test + public void captureApplySnapshotAndListHistoryWorkTogether() throws Exception { + dropTable(); + clearChangeLog("mgr-apply"); + + ModelManager manager = new ModelManager( + TestDbSupport::openTerminal, + migrationDir, + List.of(Entity.recall(ModelManagerWidget.class)) + ); + + Path revision = manager.defaultRevisionPath("mgr-apply"); + List changes = manager.captureChanges("tests", "mgr-apply", revision); + assertFalse(changes.isEmpty()); + assertTrue(Files.exists(revision)); + + int applied = manager.applyChanges(revision); + assertEquals(changes.size(), applied); + + List snapshot = manager.snapshot(); + assertEquals(1, snapshot.size()); + assertNotNull(snapshot.get(0)); + assertNotNull(snapshot.get(0).findField("id")); + assertNotNull(snapshot.get(0).findField("name")); + + List history = manager.listSnapshots(); + assertFalse(history.isEmpty()); + assertTrue(history.stream().anyMatch(change -> "mgr-apply".equals(change.get(ChangeEvent.MIGRATION_ID)))); + } + + @Test + public void migrateAndEnsureSchemaRunWithoutExtraAppCode() throws Exception { + dropTable(); + clearChangeLog("mgr-migrate"); + + ModelManager manager = new ModelManager( + TestDbSupport::openTerminal, + migrationDir, + List.of(Entity.recall(ModelManagerWidget.class)) + ); + + manager.migrate("tests", "mgr-migrate"); + assertTrue(tableExists()); + + dropTable(); + manager.ensureSchema(); + assertTrue(tableExists()); + } + + private static void dropTable() throws SQLException, IOException { + try (SQLTerminal terminal = TestDbSupport.openTerminal(); + Connection conn = terminal.getConnection(); + Statement stmt = conn.createStatement()) { + stmt.executeUpdate("DROP TABLE IF EXISTS \"public\".\"model_manager_widget\" CASCADE"); + } + } + + private static boolean tableExists() throws SQLException, IOException { + try (SQLTerminal terminal = TestDbSupport.openTerminal(); + Connection conn = terminal.getConnection(); + Statement stmt = conn.createStatement(); + ResultSet rs = stmt.executeQuery( + "SELECT COUNT(*) FROM information_schema.tables " + + "WHERE table_schema = 'public' AND table_name = 'model_manager_widget'" + )) { + rs.next(); + return rs.getInt(1) > 0; + } + } + + private static void clearChangeLog(String migrationId) throws SQLException, IOException { + try (SQLTerminal terminal = TestDbSupport.openTerminal(); + Connection conn = terminal.getConnection(); + Statement stmt = conn.createStatement()) { + stmt.executeUpdate("CREATE SCHEMA IF NOT EXISTS \"bstore\""); + stmt.executeUpdate("DELETE FROM \"bstore\".\"change_event\" WHERE migration_id = '" + migrationId + "'"); + } catch (SQLException e) { + if (e.getMessage() == null || !e.getMessage().toLowerCase().contains("change_event")) { + throw e; + } + } + } +} diff --git a/src/test/java/com/reliancy/dbo/sql/SQLReaderTest.java b/src/test/java/com/reliancy/dbo/sql/SQLReaderTest.java index c25e971..ea8cc16 100644 --- a/src/test/java/com/reliancy/dbo/sql/SQLReaderTest.java +++ b/src/test/java/com/reliancy/dbo/sql/SQLReaderTest.java @@ -88,4 +88,5 @@ public class SQLReaderTest { assertFalse(row.isModified()); assertFalse(reader.hasNext()); } + } diff --git a/src/test/java/com/reliancy/dbo/sql/SQLTerminalSqlServerSmokeTest.java b/src/test/java/com/reliancy/dbo/sql/SQLTerminalSqlServerSmokeTest.java new file mode 100644 index 0000000..f9b2fea --- /dev/null +++ b/src/test/java/com/reliancy/dbo/sql/SQLTerminalSqlServerSmokeTest.java @@ -0,0 +1,46 @@ +package com.reliancy.dbo.sql; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.Statement; + +import org.junit.Assume; +import org.junit.Test; + +import com.reliancy.dbo.TestDbSupport; +import com.reliancy.util.Path; + +/** + * Live SQL Server check via {@link SQLTerminal}. Skips unless {@code FC4_SQL_URL} or {@code DB_URL} + * points at {@code sqlserver://...}. + */ +public class SQLTerminalSqlServerSmokeTest { + + @Test + public void connectsAndRunsSimpleQuery() throws Exception { + String url = TestDbSupport.requireDbUrl(); + Assume.assumeTrue( + "smoke test is for sqlserver URLs only", + url.toLowerCase().contains("sqlserver") + ); + + SQLTerminal terminal = new SQLTerminal(url); + try (Connection c = terminal.getConnection(); + Statement st = c.createStatement(); + ResultSet rs = st.executeQuery("SELECT 1 AS one")) { + assertTrue(rs.next()); + assertEquals(1, rs.getInt("one")); + } + } + + @Test + public void jdbcUrlFromPathAddsDatabaseNameSemicolonForm() { + String path = "sqlserver://u:p@dbhost:1433/farnamcontrol"; + String jdbc = SQLTerminal.jdbcUrlFromPath("jdbc:sqlserver", new Path(path)); + assertTrue(jdbc, jdbc.contains(";databaseName=farnamcontrol")); + assertTrue(jdbc, jdbc.startsWith("jdbc:sqlserver://dbhost:1433;")); + } +}