diff --git a/frontend/src/pages/entity-configuration.vue b/frontend/src/pages/entity-configuration.vue index 2704b67..611f069 100644 --- a/frontend/src/pages/entity-configuration.vue +++ b/frontend/src/pages/entity-configuration.vue @@ -12,6 +12,8 @@ type: FieldType targetEntityId?: number relationshipType: RelationshipType + indexed: boolean + unique: boolean } interface EntityField { @@ -22,6 +24,8 @@ targetEntityId?: number targetEntityName?: string relationshipType?: RelationshipType + indexed?: boolean + unique?: boolean } interface EntityDefinition { @@ -61,6 +65,8 @@ 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() @@ -91,7 +97,14 @@ } function ensureNewField (entityId: number) { - newFields[entityId] ??= { name: '', identifier: '', type: 'TEXT', relationshipType: 'ONE_TO_ONE' } + newFields[entityId] ??= { + name: '', + identifier: '', + type: 'TEXT', + relationshipType: 'ONE_TO_ONE', + indexed: false, + unique: false, + } } function toIdentifier (name: string) { @@ -183,6 +196,8 @@ name, identifier, type: draft.type, + indexed: String(draft.indexed), + unique: String(draft.unique), } if (draft.type === 'RELATIONSHIP') { if (!draft.targetEntityId) return @@ -192,7 +207,14 @@ const response = await request(`/api/entity-definitions/${entity.id}/fields`, 'POST', values) if (!response.ok) throw new Error('Field create failed') entity.fields.push(await response.json() as EntityField) - newFields[entity.id] = { name: '', identifier: '', type: 'TEXT', relationshipType: 'ONE_TO_ONE' } + newFields[entity.id] = { + name: '', + identifier: '', + type: 'TEXT', + relationshipType: 'ONE_TO_ONE', + indexed: false, + unique: false, + } }) } @@ -203,6 +225,8 @@ 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) { @@ -214,6 +238,8 @@ name, identifier, type: editingFieldType.value, + indexed: String(editingFieldIndexed.value), + unique: String(editingFieldUnique.value), } if (editingFieldType.value === 'RELATIONSHIP') { if (!editingTargetEntityId.value) return @@ -461,6 +487,16 @@ + + + + @@ -470,6 +506,8 @@ {{ field.name }} {{ field.identifier }} {{ typeLabel(field.type) }} + Indexed + Unique {{ targetName(field) }} · {{ relationshipLabel(field.relationshipType) }} @@ -522,6 +560,16 @@ + + + + @@ -559,6 +607,9 @@ .field-row:last-child { border-bottom: 0; } .field-row strong { margin-right: 0.75rem; } .type-badge { padding: 0.2rem 0.55rem; border-radius: 999px; color: var(--v0-primary); background: color-mix(in srgb, var(--v0-primary) 13%, transparent); font-size: 0.7rem; font-weight: 700; } + .option-badge { margin-left: 0.4rem; padding: 0.2rem 0.55rem; border-radius: 999px; color: var(--v0-on-surface-variant); background: var(--v0-surface-variant); font-size: 0.7rem; font-weight: 700; } + .checkbox-label { display: inline-flex; margin: 0; gap: 0.35rem; align-items: center; white-space: nowrap; font-weight: 600; } + .checkbox-label input { width: 1rem; height: 1rem; padding: 0; accent-color: var(--v0-primary); } .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); } @@ -581,7 +632,7 @@ .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; } .entity-heading, .field-row { padding-block: 1rem; } - input, select, .primary-button, .secondary-button { width: 100%; } + 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 c791d7c..732bab8 100644 --- a/frontend/tests/entity-configuration.spec.ts +++ b/frontend/tests/entity-configuration.spec.ts @@ -7,6 +7,8 @@ interface EntityField { type: string targetEntityId?: number relationshipType?: string + indexed?: boolean + unique?: boolean } interface EntityDefinition { @@ -80,6 +82,8 @@ 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 } : {}), } @@ -121,9 +125,12 @@ 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.locator('.type-badge')).toHaveText('Email') + await expect(page.locator('.option-badge', { hasText: 'Unique' })).toBeVisible() await page.getByRole('button', { name: 'Edit field' }).click() const fieldEditForm = page.getByRole('button', { name: 'Save field' }).locator('..') diff --git a/src/main/kotlin/dev/mduchene/bolts/entity/EntityDefinition.kt b/src/main/kotlin/dev/mduchene/bolts/entity/EntityDefinition.kt index 6b116c5..97cc63c 100644 --- a/src/main/kotlin/dev/mduchene/bolts/entity/EntityDefinition.kt +++ b/src/main/kotlin/dev/mduchene/bolts/entity/EntityDefinition.kt @@ -8,6 +8,8 @@ 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 EntityDefinition( diff --git a/src/main/kotlin/dev/mduchene/bolts/entity/EntityDefinitionRepository.kt b/src/main/kotlin/dev/mduchene/bolts/entity/EntityDefinitionRepository.kt index da32ca8..f0d7164 100644 --- a/src/main/kotlin/dev/mduchene/bolts/entity/EntityDefinitionRepository.kt +++ b/src/main/kotlin/dev/mduchene/bolts/entity/EntityDefinitionRepository.kt @@ -9,7 +9,8 @@ class EntityDefinitionRepository(private val database: Database) { """ 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, targets.name AS target_entity_name + 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 ORDER BY entity_fields.id @@ -59,11 +60,15 @@ 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) - SELECT id, ?, ?, ?, ?, ? FROM entity_definitions WHERE id = ? - RETURNING id, name, identifier, field_type, target_entity_id, relationship_type + INSERT INTO entity_fields ( + entity_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, indexed, is_unique """.trimIndent() return database.queryOne(sql, bind = { setString(1, name) @@ -71,7 +76,9 @@ class EntityDefinitionRepository(private val database: Database) { setString(3, type.name) setObject(4, targetEntityId) setString(5, relationshipType?.name) - setLong(6, entityId) + setBoolean(6, indexed) + setBoolean(7, unique) + setLong(8, entityId) }) { toEntityField() } } @@ -83,12 +90,15 @@ 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 = ? + SET name = ?, identifier = ?, field_type = ?, target_entity_id = ?, relationship_type = ?, + indexed = ?, is_unique = ? WHERE id = ? AND entity_id = ? - RETURNING id, name, identifier, field_type, target_entity_id, relationship_type + RETURNING id, name, identifier, field_type, target_entity_id, relationship_type, indexed, is_unique """.trimIndent() return database.queryOne(sql, bind = { setString(1, name) @@ -96,8 +106,10 @@ class EntityDefinitionRepository(private val database: Database) { setString(3, type.name) setObject(4, targetEntityId) setString(5, relationshipType?.name) - setLong(6, fieldId) - setLong(7, entityId) + setBoolean(6, indexed) + setBoolean(7, unique) + setLong(8, fieldId) + setLong(9, entityId) }) { toEntityField() } } @@ -112,6 +124,7 @@ 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 @@ -132,4 +145,6 @@ 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 7c218ac..9304e03 100644 --- a/src/main/kotlin/dev/mduchene/bolts/entity/EntityMigrationService.kt +++ b/src/main/kotlin/dev/mduchene/bolts/entity/EntityMigrationService.kt @@ -156,15 +156,26 @@ class EntityMigrationService( "Add reference ${entity.identifier}.${field.identifier} → $target.id.", ) } - if (field.relationshipType == RelationshipType.ONE_TO_ONE) { - val unique = "em_uq_${entity.id}_${field.id}" - if (!constraintExists(connection, unique)) { - statements += MigrationStatement( - "ALTER TABLE ${quote(table)} ADD CONSTRAINT ${quote(unique)} " + - "UNIQUE (${quote(field.identifier)})", - "Make ${entity.identifier}.${field.identifier} one-to-one.", - ) - } + } + } + + 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)) { + statements += MigrationStatement( + "ALTER TABLE ${quote(table)} ADD CONSTRAINT ${quote(uniqueName)} " + + "UNIQUE (${quote(field.identifier)})", + "Add a unique constraint to ${entity.identifier}.${field.identifier}.", + ) + } else if (field.indexed && !requiresUnique && !indexExists(connection, indexName)) { + statements += MigrationStatement( + "CREATE INDEX ${quote(indexName)} ON ${quote(table)} (${quote(field.identifier)})", + "Index ${entity.identifier}.${field.identifier}.", + ) } } } @@ -176,7 +187,8 @@ class EntityMigrationService( val fields = mutableMapOf>() connection.prepareStatement( """ - SELECT id, entity_id, name, identifier, field_type, target_entity_id, relationship_type + SELECT id, entity_id, name, identifier, field_type, target_entity_id, relationship_type, + indexed, is_unique FROM entity_fields ORDER BY id """.trimIndent(), ).use { statement -> @@ -190,6 +202,8 @@ 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"), ) } } @@ -257,6 +271,14 @@ class EntityMigrationService( statement.executeQuery().use { it.next() } } + private fun indexExists(connection: Connection, name: String): Boolean = + connection.prepareStatement( + "SELECT 1 FROM pg_indexes WHERE schemaname = current_schema() AND indexname = ?", + ).use { statement -> + statement.setString(1, name) + statement.executeQuery().use { it.next() } + } + 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 042c3d2..fe54e21 100644 --- a/src/main/kotlin/dev/mduchene/bolts/web/EntityDefinitionController.kt +++ b/src/main/kotlin/dev/mduchene/bolts/web/EntityDefinitionController.kt @@ -67,6 +67,8 @@ 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) @@ -95,6 +97,8 @@ class EntityDefinitionController( type, relationship.targetEntityId, relationship.type, + ctx.booleanFormParam("indexed"), + ctx.booleanFormParam("unique"), ) if (field == null) ctx.notFound() else ctx.json(field) } @@ -130,4 +134,7 @@ class EntityDefinitionController( val targetEntityId: Long?, 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/20260730000000_add_field_index_options.sql b/src/main/resources/db/migrations/20260730000000_add_field_index_options.sql new file mode 100644 index 0000000..2571e36 --- /dev/null +++ b/src/main/resources/db/migrations/20260730000000_add_field_index_options.sql @@ -0,0 +1,5 @@ +ALTER TABLE entity_fields +ADD COLUMN IF NOT EXISTS indexed BOOLEAN NOT NULL DEFAULT FALSE; + +ALTER TABLE entity_fields +ADD COLUMN IF NOT EXISTS is_unique BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/src/test/kotlin/dev/mduchene/bolts/web/ResponseSerializationTest.kt b/src/test/kotlin/dev/mduchene/bolts/web/ResponseSerializationTest.kt index 39b7303..f6cdc57 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}]}""", + """{"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}]}""", mapper.writeValueAsString(entity), ) }