Update bstore-j model management and SQL support
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -187,9 +187,18 @@ public class SQLReader implements SiphonIterator<DBO>, 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);
|
||||
|
||||
@@ -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
|
||||
* </pre>
|
||||
*
|
||||
@@ -107,7 +107,8 @@ import com.zaxxer.hikari.HikariDataSource;
|
||||
* <h2>Identifier Quoting:</h2>
|
||||
* <p>The terminal uses database-specific identifier quotes:
|
||||
* <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>
|
||||
* </ul>
|
||||
*
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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(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;"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user