diff --git a/frontend/src/pages/entity-configuration.vue b/frontend/src/pages/entity-configuration.vue index 611f069..6a0537a 100644 --- a/frontend/src/pages/entity-configuration.vue +++ b/frontend/src/pages/entity-configuration.vue @@ -12,8 +12,6 @@ type: FieldType targetEntityId?: number relationshipType: RelationshipType - indexed: boolean - unique: boolean } interface EntityField { @@ -24,15 +22,22 @@ targetEntityId?: number targetEntityName?: string relationshipType?: RelationshipType - indexed?: boolean - unique?: boolean } + interface EntityIndex { + id: number + type: 'INDEX' | 'UNIQUE' + fields: Pick[] + } + + interface IndexDraft { fieldIds: number[], type: 'INDEX' | 'UNIQUE' } + interface EntityDefinition { id: number name: string identifier: string fields: EntityField[] + indexes: EntityIndex[] } interface MigrationValidation { @@ -59,14 +64,13 @@ const editingEntityName = ref('') const editingEntityIdentifier = ref('') const newFields = reactive>({}) + const newIndexes = reactive>({}) const editingFieldId = ref() const editingFieldName = ref('') const editingFieldIdentifier = ref('') const editingFieldType = ref('TEXT') const editingTargetEntityId = ref() const editingRelationshipType = ref('ONE_TO_ONE') - const editingFieldIndexed = ref(false) - const editingFieldUnique = ref(false) const validatingMigration = ref(false) const runningMigration = ref(false) const migrationValidation = ref() @@ -88,7 +92,10 @@ const response = await request('/api/entity-definitions') if (!response.ok) throw new Error('Entity request failed') entities.value = await response.json() as EntityDefinition[] - for (const entity of entities.value) ensureNewField(entity.id) + for (const entity of entities.value) { + entity.indexes ??= [] + ensureNewField(entity.id) + } } catch { errorMessage.value = 'Unable to load entity configuration.' } finally { @@ -102,9 +109,8 @@ identifier: '', type: 'TEXT', relationshipType: 'ONE_TO_ONE', - indexed: false, - unique: false, } + newIndexes[entityId] ??= { fieldIds: [], type: 'INDEX' } } function toIdentifier (name: string) { @@ -183,6 +189,7 @@ if (!response.ok) throw new Error('Entity delete failed') entities.value = entities.value.filter(item => item.id !== entity.id) delete newFields[entity.id] + delete newIndexes[entity.id] }) } @@ -196,8 +203,6 @@ name, identifier, type: draft.type, - indexed: String(draft.indexed), - unique: String(draft.unique), } if (draft.type === 'RELATIONSHIP') { if (!draft.targetEntityId) return @@ -212,8 +217,6 @@ identifier: '', type: 'TEXT', relationshipType: 'ONE_TO_ONE', - indexed: false, - unique: false, } }) } @@ -225,8 +228,6 @@ editingFieldType.value = field.type editingTargetEntityId.value = field.targetEntityId editingRelationshipType.value = field.relationshipType ?? 'ONE_TO_ONE' - editingFieldIndexed.value = field.indexed ?? false - editingFieldUnique.value = field.unique ?? false } async function saveField (entity: EntityDefinition, field: EntityField) { @@ -238,8 +239,6 @@ name, identifier, type: editingFieldType.value, - indexed: String(editingFieldIndexed.value), - unique: String(editingFieldUnique.value), } if (editingFieldType.value === 'RELATIONSHIP') { if (!editingTargetEntityId.value) return @@ -262,6 +261,28 @@ }) } + async function addIndex (entity: EntityDefinition) { + const draft = newIndexes[entity.id] + if (draft.fieldIds.length === 0) return + await mutate(async () => { + const response = await request(`/api/entity-definitions/${entity.id}/indexes`, 'POST', { + type: draft.type, + fieldIds: draft.fieldIds.join(','), + }) + if (!response.ok) throw new Error('Index create failed') + entity.indexes.push(await response.json() as EntityIndex) + newIndexes[entity.id] = { fieldIds: [], type: 'INDEX' } + }) + } + + async function removeIndex (entity: EntityDefinition, index: EntityIndex) { + await mutate(async () => { + const response = await request(`/api/entity-definitions/${entity.id}/indexes/${index.id}`, 'DELETE') + if (!response.ok) throw new Error('Index delete failed') + entity.indexes = entity.indexes.filter(item => item.id !== index.id) + }) + } + async function mutate (action: () => Promise) { saving.value = true errorMessage.value = '' @@ -487,16 +508,6 @@ - - - - @@ -506,8 +517,6 @@ {{ field.name }} {{ field.identifier }} {{ typeLabel(field.type) }} - Indexed - Unique {{ targetName(field) }} ยท {{ relationshipLabel(field.relationshipType) }} @@ -560,19 +569,44 @@ - - - - + +
+

Unique constraints and indexes

+ +

No constraints or indexes configured.

+ +
+
+ {{ index.type === 'UNIQUE' ? 'Unique constraint' : 'Index' }} + {{ index.fields.map(field => field.name).join(' + ') }} +
+ + +
+ +
+
+ Fields + + +
+ + + + + + +
+
@@ -613,6 +647,13 @@ .reference-detail { margin-left: 0.65rem; color: var(--v0-on-surface-variant); font-size: 0.8rem; } .empty-fields { color: var(--v0-on-surface-variant); font-size: 0.875rem; } .add-field { padding: 1rem 1.25rem 1.25rem; border-top: 1px solid var(--v0-divider); background: color-mix(in srgb, var(--v0-surface-variant) 35%, transparent); } + .indexes { padding: 1.25rem; border-top: 1px solid var(--v0-divider); } + .indexes h3 { margin: 0 0 0.75rem; font-size: 1rem; } + .index-row { display: flex; min-height: 3rem; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--v0-divider); } + .index-fields { margin-left: 0.65rem; font-size: 0.875rem; } + .add-index { display: flex; gap: 0.75rem; margin-top: 1rem; align-items: end; } + .add-index fieldset { display: flex; min-width: 0; margin: 0; padding: 0.65rem 0.8rem; gap: 0.8rem; flex: 1; flex-wrap: wrap; border: 1px solid var(--v0-divider); border-radius: 0.6rem; } + .add-index legend { padding: 0 0.25rem; font-size: 0.78rem; font-weight: 700; } .field-inputs input { flex: 1; } .error { color: var(--v0-error); } .migration-panel { margin-bottom: 2rem; padding: 1.25rem; border: 1px solid var(--v0-divider); border-radius: 1rem; background: var(--v0-surface); } @@ -630,7 +671,7 @@ .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } @media (max-width: 700px) { .entity-heading, .field-row { gap: 0.75rem; align-items: flex-start; flex-direction: column; } - .form-row, .field-inputs, .edit-row, .edit-field { align-items: stretch; flex-direction: column; } + .form-row, .field-inputs, .edit-row, .edit-field, .add-index { align-items: stretch; flex-direction: column; } .entity-heading, .field-row { padding-block: 1rem; } input:not([type="checkbox"]), select, .primary-button, .secondary-button { width: 100%; } .migration-actions { align-items: stretch; flex-direction: column; } diff --git a/frontend/tests/entity-configuration.spec.ts b/frontend/tests/entity-configuration.spec.ts index 732bab8..2f87917 100644 --- a/frontend/tests/entity-configuration.spec.ts +++ b/frontend/tests/entity-configuration.spec.ts @@ -7,15 +7,16 @@ interface EntityField { type: string targetEntityId?: number relationshipType?: string - indexed?: boolean - unique?: boolean } +interface EntityIndex { id: number, type: string, fields: EntityField[] } + interface EntityDefinition { id: number name: string identifier: string fields: EntityField[] + indexes: EntityIndex[] } async function useAdminSession (page: Page) { @@ -51,6 +52,7 @@ test('admin can add, edit, and remove an entity and its fields', async ({ page } const entities: EntityDefinition[] = [] let nextEntityId = 1 let nextFieldId = 1 + let nextIndexId = 1 await useAdminSession(page) await page.route('**/api/entity-definitions**', async route => { @@ -65,7 +67,7 @@ test('admin can add, edit, and remove an entity and its fields', async ({ page } await route.fulfill({ json: entities }) } else if (request.method() === 'POST' && segments.at(-1) === 'entity-definitions') { const values = formValues(route) - const created = { id: nextEntityId++, name: values.name, identifier: values.identifier, fields: [] } + const created = { id: nextEntityId++, name: values.name, identifier: values.identifier, fields: [], indexes: [] } entities.push(created) await route.fulfill({ status: 201, json: created }) } else if (request.method() === 'PATCH' && segments.length === 3 && entity) { @@ -82,17 +84,27 @@ test('admin can add, edit, and remove an entity and its fields', async ({ page } name: values.name, identifier: values.identifier, type: values.type, - indexed: values.indexed === 'true', - unique: values.unique === 'true', ...(values.targetEntityId ? { targetEntityId: Number(values.targetEntityId) } : {}), ...(values.relationshipType ? { relationshipType: values.relationshipType } : {}), } entity.fields.push(created) await route.fulfill({ status: 201, json: created }) + } else if (request.method() === 'POST' && segments.at(-1) === 'indexes' && entity) { + const values = formValues(route) + const created = { + id: nextIndexId++, + type: values.type, + fields: values.fieldIds.split(',').map(id => entity.fields.find(field => field.id === Number(id))!), + } + entity.indexes.push(created) + await route.fulfill({ status: 201, json: created }) } else if (request.method() === 'PATCH' && entity) { const field = entity.fields.find(item => item.id === fieldId)! Object.assign(field, formValues(route)) await route.fulfill({ json: field }) + } else if (request.method() === 'DELETE' && segments[3] === 'indexes' && entity) { + entity.indexes = entity.indexes.filter(item => item.id !== Number(segments[4])) + await route.fulfill({ status: 204 }) } else if (request.method() === 'DELETE' && entity) { const index = entity.fields.findIndex(item => item.id === fieldId) entity.fields.splice(index, 1) @@ -125,25 +137,35 @@ test('admin can add, edit, and remove an entity and its fields', async ({ page } await expect(page.getByLabel('Field identifier')).toHaveValue('website') await page.getByLabel('Field identifier').fill('websiteUrl') await page.getByLabel('Field type').selectOption('EMAIL') - await page.getByRole('form', { name: 'Add field to Organization' }).getByLabel('Indexed').check() - await page.getByRole('form', { name: 'Add field to Organization' }).getByLabel('Unique').check() await page.getByRole('button', { name: 'Add field' }).click() - await expect(page.getByText('Website', { exact: true })).toBeVisible() + await expect(page.getByRole('strong').filter({ hasText: 'Website' })).toBeVisible() await expect(page.locator('.type-badge')).toHaveText('Email') - await expect(page.locator('.option-badge', { hasText: 'Unique' })).toBeVisible() - await page.getByRole('button', { name: 'Edit field' }).click() + await page.getByLabel('New field').fill('Domain') + await page.getByRole('button', { name: 'Add field' }).click() + const indexForm = page.getByRole('form', { name: 'Add constraint or index to Organization' }) + await indexForm.getByLabel('Website').check() + await indexForm.getByLabel('Domain').check() + await indexForm.getByLabel('Type').selectOption('UNIQUE') + await indexForm.getByRole('button', { name: 'Add to configuration' }).click() + await expect(page.getByText('Website + Domain')).toBeVisible() + await expect(page.locator('span.type-badge', { hasText: 'Unique constraint' })).toBeVisible() + await page.getByRole('button', { name: 'Remove', exact: true }).click() + await expect(page.getByText('Website + Domain')).not.toBeVisible() + + await page.locator('.field-row', { hasText: 'Website' }).getByRole('button', { name: 'Edit field' }).click() const fieldEditForm = page.getByRole('button', { name: 'Save field' }).locator('..') await fieldEditForm.getByLabel('Field name').fill('Annual revenue') await expect(fieldEditForm.getByLabel('Field identifier')).toHaveValue('annualRevenue') await fieldEditForm.getByLabel('Field type').selectOption('NUMBER') await fieldEditForm.getByRole('button', { name: 'Save field' }).click() - await expect(page.getByText('Annual revenue')).toBeVisible() - await expect(page.locator('.type-badge')).toHaveText('Number') + const revenueRow = page.locator('.field-row', { hasText: 'Annual revenue' }) + await expect(revenueRow.getByRole('strong')).toHaveText('Annual revenue') + await expect(revenueRow.locator('.type-badge')).toHaveText('Number') page.on('dialog', dialog => dialog.accept()) - await page.getByRole('button', { name: 'Remove field' }).click() - await expect(page.getByText('Annual revenue')).not.toBeVisible() + await page.locator('.field-row', { hasText: 'Annual revenue' }).getByRole('button', { name: 'Remove field' }).click() + await expect(page.getByRole('strong').filter({ hasText: 'Annual revenue' })).not.toBeVisible() await page.getByLabel('Entity name', { exact: true }).fill('Contact') await page.getByRole('button', { name: 'Add entity' }).click() diff --git a/src/main/kotlin/dev/mduchene/bolts/entity/EntityDefinition.kt b/src/main/kotlin/dev/mduchene/bolts/entity/EntityDefinition.kt index 97cc63c..2e732cc 100644 --- a/src/main/kotlin/dev/mduchene/bolts/entity/EntityDefinition.kt +++ b/src/main/kotlin/dev/mduchene/bolts/entity/EntityDefinition.kt @@ -8,15 +8,32 @@ data class EntityField( val targetEntityId: Long? = null, val targetEntityName: String? = null, val relationshipType: RelationshipType? = null, - val indexed: Boolean = false, - val unique: Boolean = false, ) +data class EntityIndex( + val id: Long, + val type: EntityIndexType, + val fields: List, +) + +data class EntityIndexField(val id: Long, val name: String, val identifier: String) + +enum class EntityIndexType { + INDEX, + UNIQUE, + ; + + companion object { + fun from(value: String): EntityIndexType? = entries.firstOrNull { it.name.equals(value, ignoreCase = true) } + } +} + data class EntityDefinition( val id: Long, val name: String, val identifier: String, val fields: List, + val indexes: List = emptyList(), ) enum class FieldType { diff --git a/src/main/kotlin/dev/mduchene/bolts/entity/EntityDefinitionRepository.kt b/src/main/kotlin/dev/mduchene/bolts/entity/EntityDefinitionRepository.kt index 2d8acf3..8b94862 100644 --- a/src/main/kotlin/dev/mduchene/bolts/entity/EntityDefinitionRepository.kt +++ b/src/main/kotlin/dev/mduchene/bolts/entity/EntityDefinitionRepository.kt @@ -4,27 +4,29 @@ import dev.mduchene.bolts.persistence.Database import java.sql.ResultSet class EntityDefinitionRepository(private val database: Database) { - fun nameExists(name: String, excludingId: Long? = null): Boolean = - database.queryOne( + fun nameExists(name: String, excludingId: Long? = null): Boolean { + val exclusion = if (excludingId == null) "" else " AND id <> ?" + return database.queryOne( """ SELECT EXISTS ( SELECT 1 FROM entity_definitions - WHERE lower(name) = lower(?) AND (? IS NULL OR id <> ?) + WHERE lower(name) = lower(?)$exclusion ) AS found """.trimIndent(), bind = { setString(1, name) - setObject(2, excludingId) - setObject(3, excludingId) + if (excludingId != null) setLong(2, excludingId) }, ) { getBoolean("found") } == true + } fun findAll(): List { + val indexes = indexesByEntity() val fields = database.queryList( """ SELECT entity_fields.id, entity_fields.entity_id, entity_fields.name, entity_fields.identifier, entity_fields.field_type, entity_fields.target_entity_id, - entity_fields.relationship_type, entity_fields.indexed, entity_fields.is_unique, + entity_fields.relationship_type, targets.name AS target_entity_name FROM entity_fields LEFT JOIN entity_definitions targets ON targets.id = entity_fields.target_entity_id @@ -39,7 +41,7 @@ class EntityDefinitionRepository(private val database: Database) { return database.queryList("SELECT id, name, identifier FROM entity_definitions ORDER BY id") { val id = getLong("id") - EntityDefinition(id, getString("name"), getString("identifier"), fields[id].orEmpty().map(FieldRow::field)) + EntityDefinition(id, getString("name"), getString("identifier"), fields[id].orEmpty().map(FieldRow::field), indexes[id].orEmpty()) } } @@ -75,15 +77,13 @@ class EntityDefinitionRepository(private val database: Database) { type: FieldType, targetEntityId: Long?, relationshipType: RelationshipType?, - indexed: Boolean, - unique: Boolean, ): EntityField? { val sql = """ INSERT INTO entity_fields ( - entity_id, name, identifier, field_type, target_entity_id, relationship_type, indexed, is_unique + entity_id, name, identifier, field_type, target_entity_id, relationship_type ) - SELECT id, ?, ?, ?, ?, ?, ?, ? FROM entity_definitions WHERE id = ? - RETURNING id, name, identifier, field_type, target_entity_id, relationship_type, indexed, is_unique + SELECT id, ?, ?, ?, ?, ? FROM entity_definitions WHERE id = ? + RETURNING id, name, identifier, field_type, target_entity_id, relationship_type """.trimIndent() return database.queryOne(sql, bind = { setString(1, name) @@ -91,9 +91,7 @@ class EntityDefinitionRepository(private val database: Database) { setString(3, type.name) setObject(4, targetEntityId) setString(5, relationshipType?.name) - setBoolean(6, indexed) - setBoolean(7, unique) - setLong(8, entityId) + setLong(6, entityId) }) { toEntityField() } } @@ -105,15 +103,12 @@ class EntityDefinitionRepository(private val database: Database) { type: FieldType, targetEntityId: Long?, relationshipType: RelationshipType?, - indexed: Boolean, - unique: Boolean, ): EntityField? { val sql = """ UPDATE entity_fields - SET name = ?, identifier = ?, field_type = ?, target_entity_id = ?, relationship_type = ?, - indexed = ?, is_unique = ? + SET name = ?, identifier = ?, field_type = ?, target_entity_id = ?, relationship_type = ? WHERE id = ? AND entity_id = ? - RETURNING id, name, identifier, field_type, target_entity_id, relationship_type, indexed, is_unique + RETURNING id, name, identifier, field_type, target_entity_id, relationship_type """.trimIndent() return database.queryOne(sql, bind = { setString(1, name) @@ -121,17 +116,60 @@ class EntityDefinitionRepository(private val database: Database) { setString(3, type.name) setObject(4, targetEntityId) setString(5, relationshipType?.name) - setBoolean(6, indexed) - setBoolean(7, unique) - setLong(8, fieldId) - setLong(9, entityId) + setLong(6, fieldId) + setLong(7, entityId) }) { toEntityField() } } - fun deleteField(entityId: Long, fieldId: Long): Boolean = - database.executeUpdate("DELETE FROM entity_fields WHERE id = ? AND entity_id = ?") { - setLong(1, fieldId) - setLong(2, entityId) + fun deleteField(entityId: Long, fieldId: Long): Boolean = database.transaction { connection -> + val deleted = connection.prepareStatement("DELETE FROM entity_fields WHERE id = ? AND entity_id = ?").use { + it.setLong(1, fieldId); it.setLong(2, entityId); it.executeUpdate() > 0 + } + connection.createStatement().use { + it.executeUpdate("DELETE FROM entity_indexes i WHERE NOT EXISTS (SELECT 1 FROM entity_index_fields f WHERE f.index_id = i.id)") + } + deleted + } + + fun createIndex(entityId: Long, type: EntityIndexType, fieldIds: List): EntityIndex? = + database.transaction { connection -> + val validCount = connection.prepareStatement( + "SELECT count(*) FROM entity_fields WHERE entity_id = ? AND id = ANY (?)", + ).use { statement -> + statement.setLong(1, entityId) + statement.setArray(2, connection.createArrayOf("bigint", fieldIds.toTypedArray())) + statement.executeQuery().use { it.next(); it.getInt(1) } + } + if (validCount != fieldIds.size) return@transaction null + val id = connection.prepareStatement( + "INSERT INTO entity_indexes (entity_id, index_type) VALUES (?, ?) RETURNING id", + ).use { statement -> + statement.setLong(1, entityId); statement.setString(2, type.name) + statement.executeQuery().use { it.next(); it.getLong(1) } + } + connection.prepareStatement( + "INSERT INTO entity_index_fields (index_id, field_id, position) VALUES (?, ?, ?)", + ).use { statement -> + fieldIds.forEachIndexed { position, fieldId -> + statement.setLong(1, id); statement.setLong(2, fieldId); statement.setInt(3, position); statement.addBatch() + } + statement.executeBatch() + } + val selectedFields = connection.prepareStatement( + "SELECT id, name, identifier FROM entity_fields WHERE id = ANY (?)", + ).use { statement -> + statement.setArray(1, connection.createArrayOf("bigint", fieldIds.toTypedArray())) + statement.executeQuery().use { result -> + val byId = buildMap { while (result.next()) put(result.getLong(1), EntityIndexField(result.getLong(1), result.getString(2), result.getString(3))) } + fieldIds.map { byId.getValue(it) } + } + } + EntityIndex(id, type, selectedFields) + } + + fun deleteIndex(entityId: Long, indexId: Long): Boolean = + database.executeUpdate("DELETE FROM entity_indexes WHERE id = ? AND entity_id = ?") { + setLong(1, indexId); setLong(2, entityId) } > 0 private fun fieldsFor(entityId: Long): List = @@ -139,7 +177,6 @@ class EntityDefinitionRepository(private val database: Database) { """ SELECT entity_fields.id, entity_fields.name, entity_fields.identifier, entity_fields.field_type, entity_fields.target_entity_id, entity_fields.relationship_type, - entity_fields.indexed, entity_fields.is_unique, targets.name AS target_entity_name FROM entity_fields LEFT JOIN entity_definitions targets ON targets.id = entity_fields.target_entity_id @@ -149,7 +186,21 @@ class EntityDefinitionRepository(private val database: Database) { bind = { setLong(1, entityId) }, ) { toEntityField() } + private fun indexesByEntity(): Map> = database.queryList( + "SELECT id, entity_id, index_type FROM entity_indexes ORDER BY id", + ) { + val id = getLong("id") + IndexRow(getLong("entity_id"), EntityIndex(id, EntityIndexType.valueOf(getString("index_type")), indexFields(id))) + }.groupBy(IndexRow::entityId).mapValues { (_, rows) -> rows.map(IndexRow::index) } + + private fun indexFields(indexId: Long): List = database.queryList( + """SELECT f.id, f.name, f.identifier FROM entity_index_fields x + JOIN entity_fields f ON f.id = x.field_id WHERE x.index_id = ? ORDER BY x.position""", + bind = { setLong(1, indexId) }, + ) { EntityIndexField(getLong("id"), getString("name"), getString("identifier")) } + private data class FieldRow(val entityId: Long, val field: EntityField) + private data class IndexRow(val entityId: Long, val index: EntityIndex) } private fun ResultSet.toEntityField() = EntityField( @@ -160,6 +211,4 @@ private fun ResultSet.toEntityField() = EntityField( targetEntityId = getLong("target_entity_id").takeUnless { wasNull() }, targetEntityName = runCatching { getString("target_entity_name") }.getOrNull(), relationshipType = getString("relationship_type")?.let(RelationshipType::valueOf), - indexed = getBoolean("indexed"), - unique = getBoolean("is_unique"), ) diff --git a/src/main/kotlin/dev/mduchene/bolts/entity/EntityMigrationService.kt b/src/main/kotlin/dev/mduchene/bolts/entity/EntityMigrationService.kt index 354bbc0..0ef6656 100644 --- a/src/main/kotlin/dev/mduchene/bolts/entity/EntityMigrationService.kt +++ b/src/main/kotlin/dev/mduchene/bolts/entity/EntityMigrationService.kt @@ -60,7 +60,10 @@ class EntityMigrationService( val warnings = mutableListOf() val statements = mutableListOf() val identifierPattern = Regex("[A-Za-z_][A-Za-z0-9_]*") - val reservedTables = setOf("entity_definitions", "entity_fields", "schema_migrations", "sessions", "users") + val reservedTables = setOf( + "entity_definitions", "entity_fields", "entity_indexes", "entity_index_fields", + "schema_migrations", "sessions", "users", + ) val duplicateEntities = definitions.groupBy { it.identifier.lowercase() }.filterValues { it.size > 1 } if (definitions.isEmpty()) warnings += "No entity definitions are configured." @@ -161,20 +164,50 @@ class EntityMigrationService( definitions.forEach { entity -> val table = existingTables[entity.identifier.lowercase()] ?: entity.identifier - entity.fields.forEach { field -> - val requiresUnique = field.unique || field.relationshipType == RelationshipType.ONE_TO_ONE - val uniqueName = "em_uq_${entity.id}_${field.id}" - val indexName = "em_idx_${entity.id}_${field.id}" - if (requiresUnique && !constraintExists(connection, uniqueName)) { + val wantedConstraints = entity.indexes.filter { it.type == EntityIndexType.UNIQUE } + .associateBy { "em_uq_${entity.id}_${it.id}" } + val wantedIndexes = entity.indexes.filter { it.type == EntityIndexType.INDEX } + .associateBy { "em_idx_${entity.id}_${it.id}" } + + managedConstraints(connection, entity.id).filter { it !in wantedConstraints }.forEach { name -> + statements += MigrationStatement( + "ALTER TABLE ${quote(table)} DROP CONSTRAINT ${quote(name)}", + "Remove obsolete unique constraint $name.", + ) + } + managedIndexes(connection, entity.id).filter { it !in wantedIndexes }.forEach { name -> + statements += MigrationStatement("DROP INDEX ${quote(name)}", "Remove obsolete index $name.") + } + wantedConstraints.forEach { (name, index) -> + if (!constraintExists(connection, name)) { statements += MigrationStatement( - "ALTER TABLE ${quote(table)} ADD CONSTRAINT ${quote(uniqueName)} " + - "UNIQUE (${quote(field.identifier)})", - "Add a unique constraint to ${entity.identifier}.${field.identifier}.", + "ALTER TABLE ${quote(table)} ADD CONSTRAINT ${quote(name)} UNIQUE " + + "(${index.fields.joinToString { quote(it.identifier) }})", + "Add a unique constraint to ${entity.identifier} (${index.fields.joinToString { it.identifier }}).", ) - } else if (field.indexed && !requiresUnique && !indexExists(connection, indexName)) { + } + } + wantedIndexes.forEach { (name, index) -> + if (!indexExists(connection, name)) { statements += MigrationStatement( - "CREATE INDEX ${quote(indexName)} ON ${quote(table)} (${quote(field.identifier)})", - "Index ${entity.identifier}.${field.identifier}.", + "CREATE INDEX ${quote(name)} ON ${quote(table)} (${index.fields.joinToString { quote(it.identifier) }})", + "Index ${entity.identifier} (${index.fields.joinToString { it.identifier }}).", + ) + } + } + val relationshipUniques = entity.fields.filter { it.relationshipType == RelationshipType.ONE_TO_ONE } + .associateBy { "em_rel_uq_${entity.id}_${it.id}" } + managedConstraints(connection, entity.id, "em_rel_uq").filter { it !in relationshipUniques }.forEach { name -> + statements += MigrationStatement( + "ALTER TABLE ${quote(table)} DROP CONSTRAINT ${quote(name)}", + "Remove obsolete one-to-one constraint $name.", + ) + } + relationshipUniques.forEach { (name, field) -> + if (!constraintExists(connection, name)) { + statements += MigrationStatement( + "ALTER TABLE ${quote(table)} ADD CONSTRAINT ${quote(name)} UNIQUE (${quote(field.identifier)})", + "Limit ${entity.identifier}.${field.identifier} to one reference.", ) } } @@ -237,8 +270,6 @@ class EntityMigrationService( type = FieldType.valueOf(result.getString("field_type")), targetEntityId = result.getLong("target_entity_id").takeUnless { result.wasNull() }, relationshipType = result.getString("relationship_type")?.let(RelationshipType::valueOf), - indexed = result.getBoolean("indexed"), - unique = result.getBoolean("is_unique"), ) } } @@ -248,13 +279,36 @@ class EntityMigrationService( buildList { while (result.next()) { val id = result.getLong("id") - add(EntityDefinition(id, result.getString("name"), result.getString("identifier"), fields[id].orEmpty())) + add(EntityDefinition(id, result.getString("name"), result.getString("identifier"), fields[id].orEmpty(), loadIndexes(connection, id))) } } } } } + private fun loadIndexes(connection: Connection, entityId: Long): List = connection.prepareStatement( + "SELECT id, index_type FROM entity_indexes WHERE entity_id = ? ORDER BY id", + ).use { statement -> + statement.setLong(1, entityId) + statement.executeQuery().use { result -> + buildList { + while (result.next()) { + val id = result.getLong("id") + val indexFields = connection.prepareStatement( + """SELECT f.id, f.name, f.identifier FROM entity_index_fields x + JOIN entity_fields f ON f.id = x.field_id WHERE x.index_id = ? ORDER BY x.position""", + ).use { fieldsStatement -> + fieldsStatement.setLong(1, id) + fieldsStatement.executeQuery().use { fieldResult -> + buildList { while (fieldResult.next()) add(EntityIndexField(fieldResult.getLong(1), fieldResult.getString(2), fieldResult.getString(3))) } + } + } + add(EntityIndex(id, EntityIndexType.valueOf(result.getString("index_type")), indexFields)) + } + } + } + } + private fun dependencyOrder(definitions: List): List { val byId = definitions.associateBy(EntityDefinition::id) val visited = mutableSetOf() @@ -314,6 +368,20 @@ class EntityMigrationService( statement.executeQuery().use { it.next() } } + private fun managedConstraints(connection: Connection, entityId: Long, prefix: String = "em_uq"): Set = connection.prepareStatement( + "SELECT constraint_name FROM information_schema.table_constraints WHERE constraint_schema = current_schema() AND constraint_name LIKE ?", + ).use { statement -> + statement.setString(1, "${prefix}_${entityId}_%") + statement.executeQuery().use { result -> buildSet { while (result.next()) add(result.getString(1)) } } + } + + private fun managedIndexes(connection: Connection, entityId: Long): Set = connection.prepareStatement( + "SELECT indexname FROM pg_indexes WHERE schemaname = current_schema() AND indexname LIKE ?", + ).use { statement -> + statement.setString(1, "em_idx_${entityId}_%") + statement.executeQuery().use { result -> buildSet { while (result.next()) add(result.getString(1)) } } + } + private fun sqlType(type: FieldType): String = when (type) { FieldType.TEXT -> "TEXT" FieldType.NUMBER -> "NUMERIC" diff --git a/src/main/kotlin/dev/mduchene/bolts/web/EntityDefinitionController.kt b/src/main/kotlin/dev/mduchene/bolts/web/EntityDefinitionController.kt index 54dc366..ec7d56a 100644 --- a/src/main/kotlin/dev/mduchene/bolts/web/EntityDefinitionController.kt +++ b/src/main/kotlin/dev/mduchene/bolts/web/EntityDefinitionController.kt @@ -3,6 +3,7 @@ package dev.mduchene.bolts.web import dev.mduchene.bolts.entity.EntityDefinitionRepository import dev.mduchene.bolts.entity.EntityMigrationService import dev.mduchene.bolts.entity.FieldType +import dev.mduchene.bolts.entity.EntityIndexType import dev.mduchene.bolts.entity.RelationshipType import dev.mduchene.bolts.user.LoginService import io.javalin.http.Context @@ -75,8 +76,6 @@ class EntityDefinitionController( type, relationship.targetEntityId, relationship.type, - ctx.booleanFormParam("indexed"), - ctx.booleanFormParam("unique"), ) if (field == null) ctx.notFound() else { ctx.status(HttpStatus.CREATED).json(field) @@ -105,8 +104,6 @@ class EntityDefinitionController( type, relationship.targetEntityId, relationship.type, - ctx.booleanFormParam("indexed"), - ctx.booleanFormParam("unique"), ) if (field == null) ctx.notFound() else ctx.json(field) } @@ -120,6 +117,26 @@ class EntityDefinitionController( ctx.status(HttpStatus.NO_CONTENT) } } + routes.post("/api/entity-definitions/{entityId}/indexes") { ctx -> + if (!ctx.requireAdmin(loginService)) return@post + val entityId = ctx.pathParam("entityId").toLongOrNull() + val type = ctx.formParam("type")?.let(EntityIndexType::from) + val fieldIds = ctx.formParam("fieldIds")?.split(',')?.mapNotNull { it.toLongOrNull() }?.distinct().orEmpty() + if (entityId == null || type == null || fieldIds.isEmpty()) { + ctx.badRequest("An index type and at least one field are required") + return@post + } + val index = entities.createIndex(entityId, type, fieldIds) + if (index == null) ctx.badRequest("Every selected field must belong to the entity") + else ctx.status(HttpStatus.CREATED).json(index) + } + routes.delete("/api/entity-definitions/{entityId}/indexes/{indexId}") { ctx -> + if (!ctx.requireAdmin(loginService)) return@delete + val entityId = ctx.pathParam("entityId").toLongOrNull() + val indexId = ctx.pathParam("indexId").toLongOrNull() + if (entityId == null || indexId == null || !entities.deleteIndex(entityId, indexId)) ctx.notFound() + else ctx.status(HttpStatus.NO_CONTENT) + } } private fun Context.relationshipDetails(fieldType: FieldType?): RelationshipDetails? { @@ -143,6 +160,4 @@ class EntityDefinitionController( val type: RelationshipType?, ) - private fun Context.booleanFormParam(name: String): Boolean = - formParam(name)?.equals("true", ignoreCase = true) == true } diff --git a/src/main/resources/db/migrations/20260801000000_create_entity_indexes.sql b/src/main/resources/db/migrations/20260801000000_create_entity_indexes.sql new file mode 100644 index 0000000..d62eaa9 --- /dev/null +++ b/src/main/resources/db/migrations/20260801000000_create_entity_indexes.sql @@ -0,0 +1,33 @@ +CREATE TABLE IF NOT EXISTS entity_indexes ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + entity_id BIGINT NOT NULL REFERENCES entity_definitions(id) ON DELETE CASCADE, + index_type VARCHAR(16) NOT NULL CHECK (index_type IN ('INDEX', 'UNIQUE')) +); + +CREATE TABLE IF NOT EXISTS entity_index_fields ( + index_id BIGINT NOT NULL REFERENCES entity_indexes(id) ON DELETE CASCADE, + field_id BIGINT NOT NULL REFERENCES entity_fields(id) ON DELETE CASCADE, + position INTEGER NOT NULL, + PRIMARY KEY (index_id, field_id), + UNIQUE (index_id, position) +); + +-- Preserve existing single-field configuration in the new grouped model. +INSERT INTO entity_indexes (entity_id, index_type) +SELECT entity_id, CASE WHEN is_unique THEN 'UNIQUE' ELSE 'INDEX' END +FROM entity_fields +WHERE indexed OR is_unique +ORDER BY id; + +WITH legacy_fields AS ( + SELECT id, entity_id, CASE WHEN is_unique THEN 'UNIQUE' ELSE 'INDEX' END AS index_type, + row_number() OVER (PARTITION BY entity_id, CASE WHEN is_unique THEN 'UNIQUE' ELSE 'INDEX' END ORDER BY id) AS item + FROM entity_fields WHERE indexed OR is_unique +), legacy_indexes AS ( + SELECT id, entity_id, index_type, + row_number() OVER (PARTITION BY entity_id, index_type ORDER BY id) AS item + FROM entity_indexes +) +INSERT INTO entity_index_fields (index_id, field_id, position) +SELECT i.id, f.id, 0 FROM legacy_fields f +JOIN legacy_indexes i USING (entity_id, index_type, item); diff --git a/src/main/resources/db/reset.sql b/src/main/resources/db/reset.sql index b6ca9cd..57bcbca 100644 --- a/src/main/resources/db/reset.sql +++ b/src/main/resources/db/reset.sql @@ -1,3 +1,5 @@ +DROP TABLE IF EXISTS entity_index_fields; +DROP TABLE IF EXISTS entity_indexes; DROP TABLE IF EXISTS entity_fields; DROP TABLE IF EXISTS entity_definitions; DROP TABLE IF EXISTS sessions; diff --git a/src/test/kotlin/dev/mduchene/bolts/web/ResponseSerializationTest.kt b/src/test/kotlin/dev/mduchene/bolts/web/ResponseSerializationTest.kt index f6cdc57..69e814f 100644 --- a/src/test/kotlin/dev/mduchene/bolts/web/ResponseSerializationTest.kt +++ b/src/test/kotlin/dev/mduchene/bolts/web/ResponseSerializationTest.kt @@ -42,7 +42,7 @@ class ResponseSerializationTest { ) assertEquals( - """{"id":1,"name":"Company","identifier":"company","fields":[{"id":2,"name":"Annual revenue","identifier":"annualRevenue","type":"NUMBER","targetEntityId":null,"targetEntityName":null,"relationshipType":null,"indexed":false,"unique":false}]}""", + """{"id":1,"name":"Company","identifier":"company","fields":[{"id":2,"name":"Annual revenue","identifier":"annualRevenue","type":"NUMBER","targetEntityId":null,"targetEntityName":null,"relationshipType":null}],"indexes":[]}""", mapper.writeValueAsString(entity), ) }