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
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.
+2
View File
@@ -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;
}
}
@@ -190,6 +190,15 @@ public class SQLReader implements SiphonIterator<DBO>, ActionHero{
if(tr.offset != 0) {
sql.offset(tr.offset);
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;"));
}
}