adding relationships / users to entities config

This commit is contained in:
Maxime Duchêne-Savard 2026-07-29 16:01:25 -04:00
parent 46f0b091e8
commit d3ccae9c75
7 changed files with 280 additions and 32 deletions

View File

@ -2,13 +2,24 @@
import { onMounted, reactive, ref } from 'vue'
import { auth } from '@/auth'
const fieldTypes = ['TEXT', 'NUMBER', 'BOOLEAN', 'DATE', 'EMAIL', 'PHONE'] as const
const fieldTypes = ['TEXT', 'NUMBER', 'BOOLEAN', 'DATE', 'EMAIL', 'PHONE', 'RELATIONSHIP', 'USER'] as const
type FieldType = typeof fieldTypes[number]
type RelationshipType = 'ONE_TO_ONE' | 'ONE_TO_MANY'
interface FieldDraft {
name: string
type: FieldType
targetEntityId?: number
relationshipType: RelationshipType
}
interface EntityField {
id: number
name: string
type: FieldType
targetEntityId?: number
targetEntityName?: string
relationshipType?: RelationshipType
}
interface EntityDefinition {
@ -24,10 +35,12 @@
const newEntityName = ref('')
const editingEntityId = ref<number>()
const editingEntityName = ref('')
const newFields = reactive<Record<number, { name: string, type: FieldType }>>({})
const newFields = reactive<Record<number, FieldDraft>>({})
const editingFieldId = ref<number>()
const editingFieldName = ref('')
const editingFieldType = ref<FieldType>('TEXT')
const editingTargetEntityId = ref<number>()
const editingRelationshipType = ref<RelationshipType>('ONE_TO_ONE')
function request (url: string, method = 'GET', values?: Record<string, string>) {
return fetch(url, {
@ -54,7 +67,7 @@
}
function ensureNewField (entityId: number) {
newFields[entityId] ??= { name: '', type: 'TEXT' }
newFields[entityId] ??= { name: '', type: 'TEXT', relationshipType: 'ONE_TO_ONE' }
}
async function addEntity () {
@ -101,13 +114,19 @@
const name = draft.name.trim()
if (!name) return
await mutate(async () => {
const response = await request(`/api/entity-definitions/${entity.id}/fields`, 'POST', {
const values: Record<string, string> = {
name,
type: draft.type,
})
}
if (draft.type === 'RELATIONSHIP') {
if (!draft.targetEntityId) return
values.targetEntityId = String(draft.targetEntityId)
values.relationshipType = draft.relationshipType
}
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: '', type: 'TEXT' }
newFields[entity.id] = { name: '', type: 'TEXT', relationshipType: 'ONE_TO_ONE' }
})
}
@ -115,16 +134,24 @@
editingFieldId.value = field.id
editingFieldName.value = field.name
editingFieldType.value = field.type
editingTargetEntityId.value = field.targetEntityId
editingRelationshipType.value = field.relationshipType ?? 'ONE_TO_ONE'
}
async function saveField (entity: EntityDefinition, field: EntityField) {
const name = editingFieldName.value.trim()
if (!name) return
await mutate(async () => {
const response = await request(`/api/entity-definitions/${entity.id}/fields/${field.id}`, 'PATCH', {
const values: Record<string, string> = {
name,
type: editingFieldType.value,
})
}
if (editingFieldType.value === 'RELATIONSHIP') {
if (!editingTargetEntityId.value) return
values.targetEntityId = String(editingTargetEntityId.value)
values.relationshipType = editingRelationshipType.value
}
const response = await request(`/api/entity-definitions/${entity.id}/fields/${field.id}`, 'PATCH', values)
if (!response.ok) throw new Error('Field update failed')
Object.assign(field, await response.json() as EntityField)
editingFieldId.value = undefined
@ -152,6 +179,22 @@
}
}
function typeLabel (type: FieldType) {
if (type === 'RELATIONSHIP') return 'Relationship'
if (type === 'USER') return 'User reference'
return type.charAt(0) + type.slice(1).toLowerCase()
}
function relationshipLabel (type?: RelationshipType) {
return type === 'ONE_TO_MANY' ? 'One to many' : 'One to one'
}
function targetName (field: EntityField) {
return field.targetEntityName
?? entities.value.find(entity => entity.id === field.targetEntityId)?.name
?? 'Unknown entity'
}
onMounted(loadEntities)
</script>
@ -205,15 +248,40 @@
<label class="sr-only" :for="`edit-type-${field.id}`">Field type</label>
<select :id="`edit-type-${field.id}`" v-model="editingFieldType">
<option v-for="type in fieldTypes" :key="type" :value="type">{{ type }}</option>
<option v-for="type in fieldTypes" :key="type" :value="type">{{ typeLabel(type) }}</option>
</select>
<template v-if="editingFieldType === 'RELATIONSHIP'">
<label class="sr-only" :for="`edit-target-${field.id}`">Related entity</label>
<select :id="`edit-target-${field.id}`" v-model="editingTargetEntityId" required>
<option disabled :value="undefined">Related entity</option>
<option v-for="target in entities" :key="target.id" :value="target.id">{{ target.name }}</option>
</select>
<label class="sr-only" :for="`edit-relationship-${field.id}`">Relationship type</label>
<select :id="`edit-relationship-${field.id}`" v-model="editingRelationshipType">
<option value="ONE_TO_ONE">One to one</option>
<option value="ONE_TO_MANY">One to many</option>
</select>
</template>
<button class="text-button" type="submit">Save field</button>
<button class="text-button muted" type="button" @click="editingFieldId = undefined">Cancel</button>
</form>
<template v-else>
<div><strong>{{ field.name }}</strong><span class="type-badge">{{ field.type }}</span></div>
<div>
<strong>{{ field.name }}</strong>
<span class="type-badge">{{ typeLabel(field.type) }}</span>
<span v-if="field.type === 'RELATIONSHIP'" class="reference-detail">
{{ targetName(field) }} · {{ relationshipLabel(field.relationshipType) }}
</span>
<span v-else-if="field.type === 'USER'" class="reference-detail">Built-in User entity</span>
</div>
<div class="actions">
<button class="text-button" type="button" @click="startFieldEdit(field)">Edit field</button>
@ -231,9 +299,25 @@
<label class="sr-only" :for="`new-type-${entity.id}`">Field type</label>
<select :id="`new-type-${entity.id}`" v-model="newFields[entity.id].type">
<option v-for="type in fieldTypes" :key="type" :value="type">{{ type }}</option>
<option v-for="type in fieldTypes" :key="type" :value="type">{{ typeLabel(type) }}</option>
</select>
<template v-if="newFields[entity.id].type === 'RELATIONSHIP'">
<label class="sr-only" :for="`new-target-${entity.id}`">Related entity</label>
<select :id="`new-target-${entity.id}`" v-model="newFields[entity.id].targetEntityId" required>
<option disabled :value="undefined">Related entity</option>
<option v-for="target in entities" :key="target.id" :value="target.id">{{ target.name }}</option>
</select>
<label class="sr-only" :for="`new-relationship-${entity.id}`">Relationship type</label>
<select :id="`new-relationship-${entity.id}`" v-model="newFields[entity.id].relationshipType">
<option value="ONE_TO_ONE">One to one</option>
<option value="ONE_TO_MANY">One to many</option>
</select>
</template>
<button class="secondary-button" :disabled="saving" type="submit">Add field</button>
</div>
</form>
@ -270,6 +354,7 @@
.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; }
.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); }
.field-inputs input { flex: 1; }

View File

@ -4,6 +4,8 @@ interface EntityField {
id: number
name: string
type: string
targetEntityId?: number
relationshipType?: string
}
interface EntityDefinition {
@ -69,7 +71,13 @@ test('admin can add, edit, and remove an entity and its fields', async ({ page }
await route.fulfill({ status: 204 })
} else if (request.method() === 'POST' && segments.at(-1) === 'fields' && entity) {
const values = formValues(route)
const created = { id: nextFieldId++, name: values.name, type: values.type }
const created = {
id: nextFieldId++,
name: values.name,
type: values.type,
...(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() === 'PATCH' && entity) {
@ -103,7 +111,7 @@ test('admin can add, edit, and remove an entity and its fields', async ({ page }
await page.getByLabel('Field type').selectOption('EMAIL')
await page.getByRole('button', { name: 'Add field' }).click()
await expect(page.getByText('Website')).toBeVisible()
await expect(page.locator('.type-badge')).toHaveText('EMAIL')
await expect(page.locator('.type-badge')).toHaveText('Email')
await page.getByRole('button', { name: 'Edit field' }).click()
const fieldEditForm = page.getByRole('button', { name: 'Save field' }).locator('..')
@ -111,13 +119,39 @@ test('admin can add, edit, and remove an entity and its fields', async ({ page }
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')
await expect(page.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.getByRole('button', { name: 'Remove entity' }).click()
await page.getByLabel('Entity name', { exact: true }).fill('Contact')
await page.getByRole('button', { name: 'Add entity' }).click()
const organization = page.locator('.entity-card').filter({
has: page.getByRole('heading', { name: 'Organization' }),
})
const organizationFieldForm = organization.getByRole('form', { name: 'Add field to Organization' })
await organizationFieldForm.getByLabel('New field').fill('Primary contact')
await organizationFieldForm.getByLabel('Field type').selectOption('RELATIONSHIP')
await organizationFieldForm.getByLabel('Related entity').selectOption({ label: 'Contact' })
await organizationFieldForm.getByLabel('Relationship type').selectOption('ONE_TO_ONE')
await organizationFieldForm.getByRole('button', { name: 'Add field' }).click()
await expect(organization.locator('.type-badge').filter({ hasText: 'Relationship' })).toBeVisible()
await expect(organization.getByText('Contact · One to one')).toBeVisible()
await organizationFieldForm.getByLabel('New field').fill('Account owner')
await organizationFieldForm.getByLabel('Field type').selectOption('USER')
await organizationFieldForm.getByRole('button', { name: 'Add field' }).click()
await expect(organization.locator('.type-badge').filter({ hasText: 'User reference' })).toBeVisible()
await expect(organization.getByText('Built-in User entity')).toBeVisible()
await organization.getByRole('button', { name: 'Remove entity' }).click()
await expect(page.getByRole('heading', { name: 'Organization' })).not.toBeVisible()
const contact = page.locator('.entity-card').filter({
has: page.getByRole('heading', { name: 'Contact' }),
})
await contact.getByRole('button', { name: 'Remove entity' }).click()
await expect(page.getByText('No entities yet.')).toBeVisible()
})

View File

@ -4,6 +4,7 @@ import dev.mduchene.bolts.entity.EntityDefinition
import dev.mduchene.bolts.entity.EntityDefinitionRepository
import dev.mduchene.bolts.entity.EntityField
import dev.mduchene.bolts.entity.FieldType
import dev.mduchene.bolts.entity.RelationshipType
import dev.mduchene.bolts.user.LoginService
import dev.mduchene.bolts.user.SessionRepository
import dev.mduchene.bolts.user.UserRepository
@ -80,11 +81,18 @@ fun main() {
val entityId = ctx.pathParam("entityId").toLongOrNull()
val name = ctx.requiredFormParam("name")
val type = ctx.formParam("type")?.let(FieldType::from)
if (entityId == null || name == null || type == null) {
val relationship = ctx.relationshipDetails(type, entities)
if (entityId == null || name == null || type == null || relationship == null) {
if (type == null) ctx.badRequest("A valid field type is required")
return@post
}
val field = entities.createField(entityId, name, type)
val field = entities.createField(
entityId,
name,
type,
relationship.targetEntityId,
relationship.type,
)
if (field == null) ctx.notFound() else {
ctx.status(HttpStatus.CREATED).contentType("application/json").result(field.toJson())
}
@ -95,11 +103,19 @@ fun main() {
val fieldId = ctx.pathParam("fieldId").toLongOrNull()
val name = ctx.requiredFormParam("name")
val type = ctx.formParam("type")?.let(FieldType::from)
if (entityId == null || fieldId == null || name == null || type == null) {
val relationship = ctx.relationshipDetails(type, entities)
if (entityId == null || fieldId == null || name == null || type == null || relationship == null) {
if (type == null) ctx.badRequest("A valid field type is required")
return@patch
}
val field = entities.updateField(entityId, fieldId, name, type)
val field = entities.updateField(
entityId,
fieldId,
name,
type,
relationship.targetEntityId,
relationship.type,
)
if (field == null) ctx.notFound() else ctx.contentType("application/json").result(field.toJson())
}
delete("/api/entity-definitions/{entityId}/fields/{fieldId}") { ctx ->
@ -138,11 +154,35 @@ private fun io.javalin.http.Context.notFound() {
status(HttpStatus.NOT_FOUND).contentType("application/json").result("""{"message":"Not found"}""")
}
private data class RelationshipDetails(
val targetEntityId: Long?,
val type: RelationshipType?,
)
private fun io.javalin.http.Context.relationshipDetails(
fieldType: FieldType?,
entities: EntityDefinitionRepository,
): RelationshipDetails? {
if (fieldType != FieldType.RELATIONSHIP) return RelationshipDetails(null, null)
val targetEntityId = formParam("targetEntityId")?.toLongOrNull()
val relationshipType = formParam("relationshipType")?.let(RelationshipType::from)
if (targetEntityId == null || entities.findAll().none { it.id == targetEntityId }) {
badRequest("A valid target entity is required")
return null
}
if (relationshipType == null) {
badRequest("A valid relationship type is required")
return null
}
return RelationshipDetails(targetEntityId, relationshipType)
}
private fun List<EntityDefinition>.toJson() = joinToString(prefix = "[", postfix = "]") { it.toJson() }
private fun EntityDefinition.toJson() =
"""{"id":$id,"name":"${name.toJsonString()}","fields":${fields.joinToString(prefix = "[", postfix = "]") { it.toJson() }}}"""
private fun EntityField.toJson() =
"""{"id":$id,"name":"${name.toJsonString()}","type":"$type"}"""
"""{"id":$id,"name":"${name.toJsonString()}","type":"$type","targetEntityId":${targetEntityId ?: "null"},"targetEntityName":${targetEntityName?.let { "\"${it.toJsonString()}\"" } ?: "null"},"relationshipType":${relationshipType?.let { "\"$it\"" } ?: "null"}}"""
private fun String.toJsonString() = buildString {
for (character in this@toJsonString) {

View File

@ -4,6 +4,9 @@ data class EntityField(
val id: Long,
val name: String,
val type: FieldType,
val targetEntityId: Long? = null,
val targetEntityName: String? = null,
val relationshipType: RelationshipType? = null,
)
data class EntityDefinition(
@ -19,6 +22,8 @@ enum class FieldType {
DATE,
EMAIL,
PHONE,
RELATIONSHIP,
USER,
;
companion object {
@ -26,3 +31,14 @@ enum class FieldType {
entries.firstOrNull { it.name.equals(value, ignoreCase = true) }
}
}
enum class RelationshipType {
ONE_TO_ONE,
ONE_TO_MANY,
;
companion object {
fun from(value: String): RelationshipType? =
entries.firstOrNull { it.name.equals(value, ignoreCase = true) }
}
}

View File

@ -6,7 +6,14 @@ import java.sql.ResultSet
class EntityDefinitionRepository(private val database: Database) {
fun findAll(): List<EntityDefinition> {
val fields = database.queryList(
"SELECT id, entity_id, name, field_type FROM entity_fields ORDER BY id",
"""
SELECT entity_fields.id, entity_fields.entity_id, entity_fields.name,
entity_fields.field_type, entity_fields.target_entity_id,
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
ORDER BY entity_fields.id
""".trimIndent(),
) {
FieldRow(
entityId = getLong("entity_id"),
@ -41,30 +48,48 @@ class EntityDefinitionRepository(private val database: Database) {
fun delete(id: Long): Boolean =
database.executeUpdate("DELETE FROM entity_definitions WHERE id = ?") { setLong(1, id) } > 0
fun createField(entityId: Long, name: String, type: FieldType): EntityField? {
fun createField(
entityId: Long,
name: String,
type: FieldType,
targetEntityId: Long?,
relationshipType: RelationshipType?,
): EntityField? {
val sql = """
INSERT INTO entity_fields (entity_id, name, field_type)
SELECT id, ?, ? FROM entity_definitions WHERE id = ?
RETURNING id, name, field_type
INSERT INTO entity_fields (entity_id, name, field_type, target_entity_id, relationship_type)
SELECT id, ?, ?, ?, ? FROM entity_definitions WHERE id = ?
RETURNING id, name, field_type, target_entity_id, relationship_type
""".trimIndent()
return database.queryOne(sql, bind = {
setString(1, name)
setString(2, type.name)
setLong(3, entityId)
setObject(3, targetEntityId)
setString(4, relationshipType?.name)
setLong(5, entityId)
}) { toEntityField() }
}
fun updateField(entityId: Long, fieldId: Long, name: String, type: FieldType): EntityField? {
fun updateField(
entityId: Long,
fieldId: Long,
name: String,
type: FieldType,
targetEntityId: Long?,
relationshipType: RelationshipType?,
): EntityField? {
val sql = """
UPDATE entity_fields SET name = ?, field_type = ?
UPDATE entity_fields
SET name = ?, field_type = ?, target_entity_id = ?, relationship_type = ?
WHERE id = ? AND entity_id = ?
RETURNING id, name, field_type
RETURNING id, name, field_type, target_entity_id, relationship_type
""".trimIndent()
return database.queryOne(sql, bind = {
setString(1, name)
setString(2, type.name)
setLong(3, fieldId)
setLong(4, entityId)
setObject(3, targetEntityId)
setString(4, relationshipType?.name)
setLong(5, fieldId)
setLong(6, entityId)
}) { toEntityField() }
}
@ -76,7 +101,15 @@ class EntityDefinitionRepository(private val database: Database) {
private fun fieldsFor(entityId: Long): List<EntityField> =
database.queryList(
"SELECT id, name, field_type FROM entity_fields WHERE entity_id = ? ORDER BY id",
"""
SELECT entity_fields.id, entity_fields.name, entity_fields.field_type,
entity_fields.target_entity_id, 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
WHERE entity_fields.entity_id = ?
ORDER BY entity_fields.id
""".trimIndent(),
bind = { setLong(1, entityId) },
) { toEntityField() }
@ -87,4 +120,7 @@ private fun ResultSet.toEntityField() = EntityField(
id = getLong("id"),
name = getString("name"),
type = FieldType.valueOf(getString("field_type")),
targetEntityId = getLong("target_entity_id").takeUnless { wasNull() },
targetEntityName = runCatching { getString("target_entity_name") }.getOrNull(),
relationshipType = getString("relationship_type")?.let(RelationshipType::valueOf),
)

View File

@ -43,6 +43,9 @@ class Database(private val config: DatabaseConfig) {
statement.execute(CREATE_SESSIONS_TABLE)
statement.execute(CREATE_ENTITY_DEFINITIONS_TABLE)
statement.execute(CREATE_ENTITY_FIELDS_TABLE)
statement.execute(ADD_FIELD_TARGET_ENTITY)
statement.execute(ADD_FIELD_RELATIONSHIP_TYPE)
statement.execute(ADD_FIELD_TARGET_ENTITY_CONSTRAINT)
}
}
}
@ -99,9 +102,37 @@ class Database(private val config: DatabaseConfig) {
entity_id BIGINT NOT NULL REFERENCES entity_definitions(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
field_type VARCHAR(30) NOT NULL,
target_entity_id BIGINT REFERENCES entity_definitions(id) ON DELETE CASCADE,
relationship_type VARCHAR(30),
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (entity_id, name)
)
"""
const val ADD_FIELD_TARGET_ENTITY = """
ALTER TABLE entity_fields
ADD COLUMN IF NOT EXISTS target_entity_id BIGINT
"""
const val ADD_FIELD_RELATIONSHIP_TYPE = """
ALTER TABLE entity_fields
ADD COLUMN IF NOT EXISTS relationship_type VARCHAR(30)
"""
const val ADD_FIELD_TARGET_ENTITY_CONSTRAINT = """
DO ${'$'}$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'entity_fields_target_entity_id_fkey'
) THEN
ALTER TABLE entity_fields
ADD CONSTRAINT entity_fields_target_entity_id_fkey
FOREIGN KEY (target_entity_id)
REFERENCES entity_definitions(id)
ON DELETE CASCADE;
END IF;
END ${'$'}$
"""
}
}

View File

@ -15,4 +15,10 @@ class FieldTypeTest {
fun `rejects unsupported field types`() {
assertNull(FieldType.from("attachment"))
}
@Test
fun `parses relationship cardinality`() {
assertEquals(RelationshipType.ONE_TO_ONE, RelationshipType.from("one_to_one"))
assertEquals(RelationshipType.ONE_TO_MANY, RelationshipType.from("ONE_TO_MANY"))
}
}