Update bstore-j model management and SQL support

This commit is contained in:
Amer Agovic
2026-06-12 16:31:40 -05:00
parent 5e5510be50
commit 60c046cd80
9 changed files with 639 additions and 12 deletions
+19 -2
View File
@@ -146,11 +146,28 @@ The central types in this area are:
## Databases ## Databases
The Java implementation is SQL-oriented today. The project currently includes The Java implementation is SQL-oriented today. The project includes JDBC drivers for:
dependencies and test coverage around:
- PostgreSQL - PostgreSQL
- H2 - 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 The broader API is designed so application code can stay closer to entities and
actions than to vendor-specific SQL strings. actions than to vendor-specific SQL strings.
+2
View File
@@ -29,10 +29,12 @@ dependencies {
// Database drivers // Database drivers
implementation 'com.h2database:h2:2.3.232' implementation 'com.h2database:h2:2.3.232'
implementation 'org.postgresql:postgresql:42.7.4' implementation 'org.postgresql:postgresql:42.7.4'
implementation 'com.microsoft.sqlserver:mssql-jdbc:12.8.1.jre11'
implementation 'com.zaxxer:HikariCP:5.1.0' implementation 'com.zaxxer:HikariCP:5.1.0'
// Testing // Testing
testImplementation "junit:junit:4.13.2" testImplementation "junit:junit:4.13.2"
testRuntimeOnly 'org.slf4j:slf4j-simple:2.0.16'
} }
repositories { repositories {
@@ -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.
*
* <p>This class centralizes the repetitive schema and change-log orchestration
* commonly implemented in application-specific CLIs:
* <ul>
* <li>entity recall from model classes</li>
* <li>schema ensuring</li>
* <li>metadata-driven migrate/snapshot flows</li>
* <li>change capture to JSON revision files</li>
* <li>change replay from JSON revision files</li>
* <li>listing persisted {@link ChangeEvent} rows</li>
* </ul>
*
* <p>Application-specific concerns such as seeding and environment lookup stay
* outside this helper.
*/
public class ModelManager {
private final List<Entity> entities;
private final Supplier<SQLTerminal> terminalSupplier;
private final Path migrationDir;
@SafeVarargs
public ModelManager(String dbUrl, Path migrationDir, Class<? extends DBO>... modelClasses) {
this(() -> new SQLTerminal(dbUrl), migrationDir, recallEntities(modelClasses));
}
public ModelManager(Supplier<SQLTerminal> terminalSupplier, Path migrationDir, Iterable<Entity> 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<Entity> recallEntities(Class<? extends DBO>... modelClasses) {
List<Entity> entities = new ArrayList<>();
if (modelClasses != null) {
for (Class<? extends DBO> modelClass : modelClasses) {
if (modelClass != null) {
entities.add(Entity.recall(modelClass));
}
}
}
return entities;
}
public List<Entity> 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<EntityDefinition> snapshot() throws IOException {
try (SQLTerminal terminal = terminalSupplier.get()) {
MetaTerminal meta = terminal.meta(null);
List<EntityDefinition> ret = new ArrayList<>();
for (Entity entity : entities) {
ret.add(meta.discover_entity(entity));
}
return ret;
}
}
public List<ChangeEvent> captureChanges(String originatorId, String migrationId) throws IOException {
try (SQLTerminal terminal = terminalSupplier.get()) {
MetaTerminal meta = terminal.meta(null);
List<ChangeEvent> 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<ChangeEvent> captureChanges(String originatorId, String migrationId, Path outputPath) throws IOException {
List<ChangeEvent> changes = captureChanges(originatorId, migrationId);
writeChanges(changes, outputPath);
return changes;
}
public void writeChanges(Iterable<ChangeEvent> changes, Path outputPath) throws IOException {
List<Map<String, Object>> 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<ChangeEvent> changes) throws IOException {
List<ChangeEvent> 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<ChangeEvent> listSnapshots() throws IOException {
try (SQLTerminal terminal = terminalSupplier.get();
Action action = terminal.begin().load(ChangeEvent.class).execute()) {
List<ChangeEvent> ret = new ArrayList<>();
for (DBO dbo : action) {
ret.add((ChangeEvent) dbo);
}
return ret;
}
}
public static List<ChangeEvent> loadChanges(InputStream in) throws IOException {
Rec parsed = JSON.reads(new String(in.readAllBytes(), StandardCharsets.UTF_8));
List<ChangeEvent> 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<String, Object> changeToMap(ChangeEvent change) {
Map<String, Object> 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<String, Object> map = new LinkedHashMap<>(entityDefinition.toMap());
List<Map<String, Object>> 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 extends DBO> 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<String, Object> recToMap(Rec rec) {
Map<String, Object> 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;
}
}
@@ -190,6 +190,15 @@ public class SQLReader implements SiphonIterator<DBO>, ActionHero{
if(tr.offset != 0) { if(tr.offset != 0) {
sql.offset(tr.offset); 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){ if(tr.limit!=0){
sql.limit(tr.limit); sql.limit(tr.limit);
@@ -41,7 +41,7 @@ import com.zaxxer.hikari.HikariDataSource;
* Examples: * Examples:
* postgres://user:pass@localhost:5432/mydb * postgres://user:pass@localhost:5432/mydb
* mysql://root:secret@192.168.1.100:3306/app * 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 * h2://sa:@mem:testdb
* </pre> * </pre>
* *
@@ -107,7 +107,8 @@ import com.zaxxer.hikari.HikariDataSource;
* <h2>Identifier Quoting:</h2> * <h2>Identifier Quoting:</h2>
* <p>The terminal uses database-specific identifier quotes: * <p>The terminal uses database-specific identifier quotes:
* <ul> * <ul>
* <li>Standard: {@code "tablename"."columnname"}</li> * <li>PostgreSQL / generic: {@code "tablename"."columnname"}</li>
* <li>SQL Server: {@code [schema].[table]} and {@code [column]} (avoids {@code QUOTED_IDENTIFIER} surprises)</li>
* <li>Access via {@link #getQuoteLeft()} and {@link #getQuoteRight()}</li> * <li>Access via {@link #getQuoteLeft()} and {@link #getQuoteRight()}</li>
* </ul> * </ul>
* *
@@ -117,7 +118,7 @@ import com.zaxxer.hikari.HikariDataSource;
* @see SQLWriter * @see SQLWriter
* @see SQLCleaner * @see SQLCleaner
*/ */
public class SQLTerminal implements Terminal{ public class SQLTerminal implements Terminal, AutoCloseable{
HikariConfig config = new HikariConfig(); HikariConfig config = new HikariConfig();
HikariDataSource ds; HikariDataSource ds;
Path url; Path url;
@@ -128,7 +129,11 @@ public class SQLTerminal implements Terminal{
this.url=new Path(url); this.url=new Path(url);
String proto=this.url.getProtocol(); String proto=this.url.getProtocol();
if(!proto.startsWith("jdbc:")) proto="jdbc:"+proto; 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.setJdbcUrl(u);
config.setUsername(this.url.getUserid()); config.setUsername(this.url.getUserid());
config.setPassword(this.url.getPassword()); config.setPassword(this.url.getPassword());
@@ -138,9 +143,83 @@ public class SQLTerminal implements Terminal{
config.addDataSourceProperty( "prepStmtCacheSqlLimit" , "2048" ); config.addDataSourceProperty( "prepStmtCacheSqlLimit" , "2048" );
ds = new HikariDataSource( config ); 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{ public Connection getConnection() throws SQLException{
return ds.getConnection(); return ds.getConnection();
} }
@Override
public void close() {
if (ds != null) {
ds.close();
}
}
@Override @Override
public ActionHero getExecutor(Entity ent, Action.Trait trait) { public ActionHero getExecutor(Entity ent, Action.Trait trait) {
// Default executors for regular entities // Default executors for regular entities
@@ -305,4 +384,3 @@ public class SQLTerminal implements Terminal{
return new SQLMetaTerminal(this); return new SQLMetaTerminal(this);
} }
} }
@@ -17,10 +17,27 @@ public final class TestDbSupport {
private 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() { public static String requireDbUrl() {
String url = System.getenv("DB_URL"); String url = firstNonBlank(System.getenv("FC4_SQL_URL"), System.getenv("DB_URL"));
assumeTrue("DB_URL environment variable must be set for integration tests", url != null && !url.trim().isEmpty()); assumeTrue(
return url; "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() { public static SQLTerminal openTerminal() {
@@ -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<ChangeEvent> changes = manager.captureChanges("tests", "mgr-apply", revision);
assertFalse(changes.isEmpty());
assertTrue(Files.exists(revision));
int applied = manager.applyChanges(revision);
assertEquals(changes.size(), applied);
List<EntityDefinition> snapshot = manager.snapshot();
assertEquals(1, snapshot.size());
assertNotNull(snapshot.get(0));
assertNotNull(snapshot.get(0).findField("id"));
assertNotNull(snapshot.get(0).findField("name"));
List<ChangeEvent> 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;
}
}
}
}
@@ -88,4 +88,5 @@ public class SQLReaderTest {
assertFalse(row.isModified()); assertFalse(row.isModified());
assertFalse(reader.hasNext()); assertFalse(reader.hasNext());
} }
} }
@@ -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;"));
}
}