From 5693657b547e2ba82d2ebb40532c55915d272a0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maxime=20Duch=C3=AAne-Savard?= Date: Sat, 1 Aug 2026 23:18:07 -0400 Subject: [PATCH] use latest effective model instead of current config --- .../entity/EntityDefinitionRepository.kt | 19 +++++++++ .../bolts/entity/EntityMigrationService.kt | 42 ++++++++++++++++++- .../bolts/entity/EntityRecordRepository.kt | 14 +++---- ...01010000_create_effective_entity_model.sql | 42 +++++++++++++++++++ src/main/resources/db/reset.sql | 2 + 5 files changed, 111 insertions(+), 8 deletions(-) create mode 100644 src/main/resources/db/migrations/20260801010000_create_effective_entity_model.sql diff --git a/src/main/kotlin/dev/mduchene/bolts/entity/EntityDefinitionRepository.kt b/src/main/kotlin/dev/mduchene/bolts/entity/EntityDefinitionRepository.kt index 8b94862..cdee1d9 100644 --- a/src/main/kotlin/dev/mduchene/bolts/entity/EntityDefinitionRepository.kt +++ b/src/main/kotlin/dev/mduchene/bolts/entity/EntityDefinitionRepository.kt @@ -45,6 +45,25 @@ class EntityDefinitionRepository(private val database: Database) { } } + /** The last model whose physical database migration completed successfully. */ + fun findEffectiveAll(): List { + val fields = database.queryList( + """ + SELECT fields.id, fields.entity_id, fields.name, fields.identifier, + fields.field_type, fields.target_entity_id, fields.relationship_type, + targets.name AS target_entity_name + FROM effective_entity_fields fields + LEFT JOIN effective_entity_definitions targets ON targets.id = fields.target_entity_id + ORDER BY fields.id + """.trimIndent(), + ) { FieldRow(getLong("entity_id"), toEntityField()) }.groupBy(FieldRow::entityId) + + return database.queryList("SELECT id, name, identifier FROM effective_entity_definitions ORDER BY id") { + val id = getLong("id") + EntityDefinition(id, getString("name"), getString("identifier"), fields[id].orEmpty().map(FieldRow::field)) + } + } + fun create(name: String, identifier: String): EntityDefinition { val sql = "INSERT INTO entity_definitions (name, identifier) VALUES (?, ?) RETURNING id, name, identifier" return checkNotNull(database.queryOne(sql, bind = { diff --git a/src/main/kotlin/dev/mduchene/bolts/entity/EntityMigrationService.kt b/src/main/kotlin/dev/mduchene/bolts/entity/EntityMigrationService.kt index 0ef6656..ce9f255 100644 --- a/src/main/kotlin/dev/mduchene/bolts/entity/EntityMigrationService.kt +++ b/src/main/kotlin/dev/mduchene/bolts/entity/EntityMigrationService.kt @@ -27,7 +27,8 @@ class EntityMigrationService( fun migrate(): EntityMigrationResult = try { database.transaction { connection -> - val plan = plan(connection, loadEntities(connection)) + val definitions = loadEntities(connection) + val plan = plan(connection, definitions) if (!plan.validation.valid) { return@transaction EntityMigrationResult( success = false, @@ -40,6 +41,7 @@ class EntityMigrationService( connection.createStatement().use { statement -> plan.statements.forEach { statement.execute(it.sql) } } + saveEffectiveModel(connection, definitions) EntityMigrationResult( success = true, message = "Entity migration completed successfully.", @@ -55,6 +57,43 @@ class EntityMigrationService( ) } + private fun saveEffectiveModel(connection: Connection, definitions: List) { + connection.createStatement().use { statement -> + statement.executeUpdate("DELETE FROM effective_entity_fields") + statement.executeUpdate("DELETE FROM effective_entity_definitions") + } + connection.prepareStatement( + "INSERT INTO effective_entity_definitions (id, name, identifier) VALUES (?, ?, ?)", + ).use { statement -> + definitions.forEach { entity -> + statement.setLong(1, entity.id) + statement.setString(2, entity.name) + statement.setString(3, entity.identifier) + statement.addBatch() + } + statement.executeBatch() + } + connection.prepareStatement( + """INSERT INTO effective_entity_fields + (id, entity_id, name, identifier, field_type, target_entity_id, relationship_type) + VALUES (?, ?, ?, ?, ?, ?, ?)""", + ).use { statement -> + definitions.forEach { entity -> + entity.fields.forEach { field -> + statement.setLong(1, field.id) + statement.setLong(2, entity.id) + statement.setString(3, field.name) + statement.setString(4, field.identifier) + statement.setString(5, field.type.name) + statement.setObject(6, field.targetEntityId) + statement.setString(7, field.relationshipType?.name) + statement.addBatch() + } + } + statement.executeBatch() + } + } + private fun plan(connection: Connection, definitions: List): MigrationPlan { val errors = mutableListOf() val warnings = mutableListOf() @@ -62,6 +101,7 @@ class EntityMigrationService( val identifierPattern = Regex("[A-Za-z_][A-Za-z0-9_]*") val reservedTables = setOf( "entity_definitions", "entity_fields", "entity_indexes", "entity_index_fields", + "effective_entity_definitions", "effective_entity_fields", "schema_migrations", "sessions", "users", ) val duplicateEntities = definitions.groupBy { it.identifier.lowercase() }.filterValues { it.size > 1 } diff --git a/src/main/kotlin/dev/mduchene/bolts/entity/EntityRecordRepository.kt b/src/main/kotlin/dev/mduchene/bolts/entity/EntityRecordRepository.kt index 149425b..c4b66a2 100644 --- a/src/main/kotlin/dev/mduchene/bolts/entity/EntityRecordRepository.kt +++ b/src/main/kotlin/dev/mduchene/bolts/entity/EntityRecordRepository.kt @@ -34,7 +34,7 @@ class EntityRecordRepository( buildSet { while (result.next()) add(result.getString("TABLE_NAME").lowercase()) } } - entities.findAll() + entities.findEffectiveAll() .filter { entity -> entity.identifier.lowercase() in tables && entity.fields.any { it.type == FieldType.USER } } @@ -58,7 +58,7 @@ class EntityRecordRepository( userFieldId: Long, submittedValues: Map, ): EntityRecord? { - val child = entities.findAll().firstOrNull { it.name == childEntityName } ?: return null + val child = entities.findEffectiveAll().firstOrNull { it.name == childEntityName } ?: return null val userField = child.fields.firstOrNull { it.id == userFieldId && it.type == FieldType.USER } ?: return null @@ -75,7 +75,7 @@ class EntityRecordRepository( } fun findRecordLinkedToUser(userId: Long, entityName: String, recordId: Long): EntityRecord? { - val definitions = entities.findAll() + val definitions = entities.findEffectiveAll() val entity = definitions.firstOrNull { it.name == entityName } ?: return null val values = findValues(entity, recordId) ?: return null if (!isAccessibleToUser(entity, values, userId, definitions, mutableSetOf())) return null @@ -111,7 +111,7 @@ class EntityRecordRepository( relationshipFieldId: Long, submittedValues: Map, ): EntityRecord? { - val definitions = entities.findAll() + val definitions = entities.findEffectiveAll() val parent = definitions.firstOrNull { it.name == parentEntityName } ?: return null if (findRecordLinkedToUser(userId, parentEntityName, parentRecordId) == null) return null val child = definitions.firstOrNull { it.name == childEntityName } ?: return null @@ -144,7 +144,7 @@ class EntityRecordRepository( } private fun findChildren(parentEntityId: Long, parentRecordId: Long): List { - val definitions = entities.findAll() + val definitions = entities.findEffectiveAll() val parent = definitions.firstOrNull { it.id == parentEntityId } ?: return emptyList() return parent.fields .filter { it.type == FieldType.RELATIONSHIP && it.targetEntityId != null } @@ -159,9 +159,9 @@ class EntityRecordRepository( } private fun outboundChildIds(fieldId: Long, sourceRecordId: Long): List = - entities.findAll().firstNotNullOfOrNull { parent -> + entities.findEffectiveAll().firstNotNullOfOrNull { parent -> val relationship = parent.fields.firstOrNull { it.id == fieldId } ?: return@firstNotNullOfOrNull null - val child = entities.findAll().firstOrNull { it.id == relationship.targetEntityId } + val child = entities.findEffectiveAll().firstOrNull { it.id == relationship.targetEntityId } ?: return@firstNotNullOfOrNull emptyList() database.queryList( "SELECT \"id\" FROM ${quote(child.identifier)} WHERE ${quote(parentColumn(fieldId))} = ? ORDER BY \"id\"", diff --git a/src/main/resources/db/migrations/20260801010000_create_effective_entity_model.sql b/src/main/resources/db/migrations/20260801010000_create_effective_entity_model.sql new file mode 100644 index 0000000..503151e --- /dev/null +++ b/src/main/resources/db/migrations/20260801010000_create_effective_entity_model.sql @@ -0,0 +1,42 @@ +CREATE TABLE IF NOT EXISTS effective_entity_definitions ( + id BIGINT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + identifier VARCHAR(255) NOT NULL +); + +CREATE TABLE IF NOT EXISTS effective_entity_fields ( + id BIGINT PRIMARY KEY, + entity_id BIGINT NOT NULL REFERENCES effective_entity_definitions(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + identifier VARCHAR(255) NOT NULL, + field_type VARCHAR(32) NOT NULL, + target_entity_id BIGINT, + relationship_type VARCHAR(32) +); + +-- Seed upgrades from only the parts of the configured model that are already +-- present in the physical database. Future snapshots are written after a +-- successful dynamic-entity migration. +INSERT INTO effective_entity_definitions (id, name, identifier) +SELECT d.id, d.name, d.identifier +FROM entity_definitions d +WHERE EXISTS ( + SELECT 1 FROM information_schema.tables t + WHERE t.table_schema = current_schema() AND lower(t.table_name) = lower(d.identifier) +); + +INSERT INTO effective_entity_fields ( + id, entity_id, name, identifier, field_type, target_entity_id, relationship_type +) +SELECT f.id, f.entity_id, f.name, f.identifier, f.field_type, f.target_entity_id, f.relationship_type +FROM entity_fields f +JOIN effective_entity_definitions d ON d.id = f.entity_id +WHERE EXISTS ( + SELECT 1 FROM information_schema.columns c + WHERE c.table_schema = current_schema() + AND lower(c.table_name) = lower(d.identifier) + AND lower(c.column_name) = lower(f.identifier) +) +AND (f.target_entity_id IS NULL OR EXISTS ( + SELECT 1 FROM effective_entity_definitions target WHERE target.id = f.target_entity_id +)); diff --git a/src/main/resources/db/reset.sql b/src/main/resources/db/reset.sql index 57bcbca..0538783 100644 --- a/src/main/resources/db/reset.sql +++ b/src/main/resources/db/reset.sql @@ -1,5 +1,7 @@ DROP TABLE IF EXISTS entity_index_fields; DROP TABLE IF EXISTS entity_indexes; +DROP TABLE IF EXISTS effective_entity_fields; +DROP TABLE IF EXISTS effective_entity_definitions; DROP TABLE IF EXISTS entity_fields; DROP TABLE IF EXISTS entity_definitions; DROP TABLE IF EXISTS sessions;