add identifier to entities / fields

This commit is contained in:
Maxime Duchêne-Savard 2026-07-29 16:25:51 -04:00
parent 446568963f
commit ccfc6dcfa4
8 changed files with 227 additions and 51 deletions

View File

@ -8,6 +8,7 @@
interface FieldDraft { interface FieldDraft {
name: string name: string
identifier: string
type: FieldType type: FieldType
targetEntityId?: number targetEntityId?: number
relationshipType: RelationshipType relationshipType: RelationshipType
@ -16,6 +17,7 @@
interface EntityField { interface EntityField {
id: number id: number
name: string name: string
identifier: string
type: FieldType type: FieldType
targetEntityId?: number targetEntityId?: number
targetEntityName?: string targetEntityName?: string
@ -25,6 +27,7 @@
interface EntityDefinition { interface EntityDefinition {
id: number id: number
name: string name: string
identifier: string
fields: EntityField[] fields: EntityField[]
} }
@ -33,11 +36,14 @@
const saving = ref(false) const saving = ref(false)
const errorMessage = ref('') const errorMessage = ref('')
const newEntityName = ref('') const newEntityName = ref('')
const newEntityIdentifier = ref('')
const editingEntityId = ref<number>() const editingEntityId = ref<number>()
const editingEntityName = ref('') const editingEntityName = ref('')
const editingEntityIdentifier = ref('')
const newFields = reactive<Record<number, FieldDraft>>({}) const newFields = reactive<Record<number, FieldDraft>>({})
const editingFieldId = ref<number>() const editingFieldId = ref<number>()
const editingFieldName = ref('') const editingFieldName = ref('')
const editingFieldIdentifier = ref('')
const editingFieldType = ref<FieldType>('TEXT') const editingFieldType = ref<FieldType>('TEXT')
const editingTargetEntityId = ref<number>() const editingTargetEntityId = ref<number>()
const editingRelationshipType = ref<RelationshipType>('ONE_TO_ONE') const editingRelationshipType = ref<RelationshipType>('ONE_TO_ONE')
@ -67,34 +73,74 @@
} }
function ensureNewField (entityId: number) { function ensureNewField (entityId: number) {
newFields[entityId] ??= { name: '', type: 'TEXT', relationshipType: 'ONE_TO_ONE' } newFields[entityId] ??= { name: '', identifier: '', type: 'TEXT', relationshipType: 'ONE_TO_ONE' }
}
function toIdentifier (name: string) {
const words = name
.normalize('NFD')
.replace(/\p{Diacritic}/gu, '')
.replace(/['-]/g, '')
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/[^a-z0-9]+/gi, ' ')
.trim()
.split(/\s+/)
.filter(Boolean)
return words
.map((word, index) => {
const lower = word.toLowerCase()
return index === 0 ? lower : lower.charAt(0).toUpperCase() + lower.slice(1)
})
.join('')
}
function updateNewEntityIdentifier () {
newEntityIdentifier.value = toIdentifier(newEntityName.value)
}
function updateEditingEntityIdentifier () {
editingEntityIdentifier.value = toIdentifier(editingEntityName.value)
}
function updateNewFieldIdentifier (entityId: number) {
newFields[entityId].identifier = toIdentifier(newFields[entityId].name)
}
function updateEditingFieldIdentifier () {
editingFieldIdentifier.value = toIdentifier(editingFieldName.value)
} }
async function addEntity () { async function addEntity () {
const name = newEntityName.value.trim() const name = newEntityName.value.trim()
if (!name) return const identifier = newEntityIdentifier.value.trim()
if (!name || !identifier) return
await mutate(async () => { await mutate(async () => {
const response = await request('/api/entity-definitions', 'POST', { name }) const response = await request('/api/entity-definitions', 'POST', { name, identifier })
if (!response.ok) throw new Error('Entity create failed') if (!response.ok) throw new Error('Entity create failed')
const entity = await response.json() as EntityDefinition const entity = await response.json() as EntityDefinition
entities.value.push(entity) entities.value.push(entity)
ensureNewField(entity.id) ensureNewField(entity.id)
newEntityName.value = '' newEntityName.value = ''
newEntityIdentifier.value = ''
}) })
} }
function startEntityEdit (entity: EntityDefinition) { function startEntityEdit (entity: EntityDefinition) {
editingEntityId.value = entity.id editingEntityId.value = entity.id
editingEntityName.value = entity.name editingEntityName.value = entity.name
editingEntityIdentifier.value = entity.identifier
} }
async function saveEntity (entity: EntityDefinition) { async function saveEntity (entity: EntityDefinition) {
const name = editingEntityName.value.trim() const name = editingEntityName.value.trim()
if (!name) return const identifier = editingEntityIdentifier.value.trim()
if (!name || !identifier) return
await mutate(async () => { await mutate(async () => {
const response = await request(`/api/entity-definitions/${entity.id}`, 'PATCH', { name }) const response = await request(`/api/entity-definitions/${entity.id}`, 'PATCH', { name, identifier })
if (!response.ok) throw new Error('Entity update failed') if (!response.ok) throw new Error('Entity update failed')
entity.name = name Object.assign(entity, await response.json() as EntityDefinition)
editingEntityId.value = undefined editingEntityId.value = undefined
}) })
} }
@ -112,10 +158,12 @@
async function addField (entity: EntityDefinition) { async function addField (entity: EntityDefinition) {
const draft = newFields[entity.id] const draft = newFields[entity.id]
const name = draft.name.trim() const name = draft.name.trim()
if (!name) return const identifier = draft.identifier.trim()
if (!name || !identifier) return
await mutate(async () => { await mutate(async () => {
const values: Record<string, string> = { const values: Record<string, string> = {
name, name,
identifier,
type: draft.type, type: draft.type,
} }
if (draft.type === 'RELATIONSHIP') { if (draft.type === 'RELATIONSHIP') {
@ -126,13 +174,14 @@
const response = await request(`/api/entity-definitions/${entity.id}/fields`, 'POST', values) const response = await request(`/api/entity-definitions/${entity.id}/fields`, 'POST', values)
if (!response.ok) throw new Error('Field create failed') if (!response.ok) throw new Error('Field create failed')
entity.fields.push(await response.json() as EntityField) entity.fields.push(await response.json() as EntityField)
newFields[entity.id] = { name: '', type: 'TEXT', relationshipType: 'ONE_TO_ONE' } newFields[entity.id] = { name: '', identifier: '', type: 'TEXT', relationshipType: 'ONE_TO_ONE' }
}) })
} }
function startFieldEdit (field: EntityField) { function startFieldEdit (field: EntityField) {
editingFieldId.value = field.id editingFieldId.value = field.id
editingFieldName.value = field.name editingFieldName.value = field.name
editingFieldIdentifier.value = field.identifier
editingFieldType.value = field.type editingFieldType.value = field.type
editingTargetEntityId.value = field.targetEntityId editingTargetEntityId.value = field.targetEntityId
editingRelationshipType.value = field.relationshipType ?? 'ONE_TO_ONE' editingRelationshipType.value = field.relationshipType ?? 'ONE_TO_ONE'
@ -140,10 +189,12 @@
async function saveField (entity: EntityDefinition, field: EntityField) { async function saveField (entity: EntityDefinition, field: EntityField) {
const name = editingFieldName.value.trim() const name = editingFieldName.value.trim()
if (!name) return const identifier = editingFieldIdentifier.value.trim()
if (!name || !identifier) return
await mutate(async () => { await mutate(async () => {
const values: Record<string, string> = { const values: Record<string, string> = {
name, name,
identifier,
type: editingFieldType.value, type: editingFieldType.value,
} }
if (editingFieldType.value === 'RELATIONSHIP') { if (editingFieldType.value === 'RELATIONSHIP') {
@ -210,7 +261,16 @@
<label for="new-entity">Entity name</label> <label for="new-entity">Entity name</label>
<div class="form-row"> <div class="form-row">
<input id="new-entity" v-model="newEntityName" placeholder="e.g. Company" required> <input
id="new-entity"
v-model="newEntityName"
placeholder="e.g. Company"
required
@input="updateNewEntityIdentifier"
>
<label class="sr-only" for="new-entity-identifier">Entity identifier</label>
<input id="new-entity-identifier" v-model="newEntityIdentifier" placeholder="Identifier" required>
<button class="primary-button" :disabled="saving" type="submit">Add entity</button> <button class="primary-button" :disabled="saving" type="submit">Add entity</button>
</div> </div>
</form> </form>
@ -223,13 +283,18 @@
<div class="entity-heading"> <div class="entity-heading">
<form v-if="editingEntityId === entity.id" class="edit-row" @submit.prevent="saveEntity(entity)"> <form v-if="editingEntityId === entity.id" class="edit-row" @submit.prevent="saveEntity(entity)">
<label class="sr-only" :for="`edit-entity-${entity.id}`">Entity name</label> <label class="sr-only" :for="`edit-entity-${entity.id}`">Entity name</label>
<input :id="`edit-entity-${entity.id}`" v-model="editingEntityName" required> <input :id="`edit-entity-${entity.id}`" v-model="editingEntityName" required @input="updateEditingEntityIdentifier">
<label class="sr-only" :for="`edit-entity-identifier-${entity.id}`">Entity identifier</label>
<input :id="`edit-entity-identifier-${entity.id}`" v-model="editingEntityIdentifier" required>
<button class="text-button" type="submit">Save entity</button> <button class="text-button" type="submit">Save entity</button>
<button class="text-button muted" type="button" @click="editingEntityId = undefined">Cancel</button> <button class="text-button muted" type="button" @click="editingEntityId = undefined">Cancel</button>
</form> </form>
<template v-else> <template v-else>
<h2>{{ entity.name }}</h2> <div>
<h2>{{ entity.name }}</h2>
<span class="identifier">{{ entity.identifier }}</span>
</div>
<div class="actions"> <div class="actions">
<button class="text-button" type="button" @click="startEntityEdit(entity)">Edit entity</button> <button class="text-button" type="button" @click="startEntityEdit(entity)">Edit entity</button>
@ -244,7 +309,9 @@
<div v-for="field in entity.fields" :key="field.id" class="field-row"> <div v-for="field in entity.fields" :key="field.id" class="field-row">
<form v-if="editingFieldId === field.id" class="edit-field" @submit.prevent="saveField(entity, field)"> <form v-if="editingFieldId === field.id" class="edit-field" @submit.prevent="saveField(entity, field)">
<label class="sr-only" :for="`edit-field-${field.id}`">Field name</label> <label class="sr-only" :for="`edit-field-${field.id}`">Field name</label>
<input :id="`edit-field-${field.id}`" v-model="editingFieldName" required> <input :id="`edit-field-${field.id}`" v-model="editingFieldName" required @input="updateEditingFieldIdentifier">
<label class="sr-only" :for="`edit-field-identifier-${field.id}`">Field identifier</label>
<input :id="`edit-field-identifier-${field.id}`" v-model="editingFieldIdentifier" required>
<label class="sr-only" :for="`edit-type-${field.id}`">Field type</label> <label class="sr-only" :for="`edit-type-${field.id}`">Field type</label>
<select :id="`edit-type-${field.id}`" v-model="editingFieldType"> <select :id="`edit-type-${field.id}`" v-model="editingFieldType">
@ -274,6 +341,7 @@
<template v-else> <template v-else>
<div> <div>
<strong>{{ field.name }}</strong> <strong>{{ field.name }}</strong>
<span class="identifier">{{ field.identifier }}</span>
<span class="type-badge">{{ typeLabel(field.type) }}</span> <span class="type-badge">{{ typeLabel(field.type) }}</span>
<span v-if="field.type === 'RELATIONSHIP'" class="reference-detail"> <span v-if="field.type === 'RELATIONSHIP'" class="reference-detail">
@ -295,7 +363,16 @@
<label :for="`new-field-${entity.id}`">New field</label> <label :for="`new-field-${entity.id}`">New field</label>
<div class="field-inputs"> <div class="field-inputs">
<input :id="`new-field-${entity.id}`" v-model="newFields[entity.id].name" placeholder="Field name" required> <input
:id="`new-field-${entity.id}`"
v-model="newFields[entity.id].name"
placeholder="Field name"
required
@input="updateNewFieldIdentifier(entity.id)"
>
<label class="sr-only" :for="`new-field-identifier-${entity.id}`">Field identifier</label>
<input :id="`new-field-identifier-${entity.id}`" v-model="newFields[entity.id].identifier" placeholder="Identifier" required>
<label class="sr-only" :for="`new-type-${entity.id}`">Field type</label> <label class="sr-only" :for="`new-type-${entity.id}`">Field type</label>
<select :id="`new-type-${entity.id}`" v-model="newFields[entity.id].type"> <select :id="`new-type-${entity.id}`" v-model="newFields[entity.id].type">
@ -345,6 +422,7 @@
.entity-card { margin-bottom: 1.25rem; overflow: hidden; border: 1px solid var(--v0-divider); border-radius: 1rem; background: var(--v0-surface); } .entity-card { margin-bottom: 1.25rem; overflow: hidden; border: 1px solid var(--v0-divider); border-radius: 1rem; background: var(--v0-surface); }
.entity-heading { display: flex; min-height: 4.5rem; padding: 1rem 1.25rem; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--v0-divider); } .entity-heading { display: flex; min-height: 4.5rem; padding: 1rem 1.25rem; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--v0-divider); }
.entity-heading h2 { margin: 0; font-size: 1.2rem; } .entity-heading h2 { margin: 0; font-size: 1.2rem; }
.identifier { margin-right: 0.75rem; color: var(--v0-on-surface-variant); font-family: monospace; font-size: 0.78rem; }
.actions { display: flex; gap: 0.85rem; } .actions { display: flex; gap: 0.85rem; }
.text-button { padding: 0.25rem; border: 0; color: var(--v0-primary); background: transparent; font-weight: 600; } .text-button { padding: 0.25rem; border: 0; color: var(--v0-primary); background: transparent; font-weight: 600; }
.text-button.danger { color: var(--v0-error); } .text-button.danger { color: var(--v0-error); }

View File

@ -3,6 +3,7 @@ import { expect, type Page, type Route, test } from '@playwright/test'
interface EntityField { interface EntityField {
id: number id: number
name: string name: string
identifier: string
type: string type: string
targetEntityId?: number targetEntityId?: number
relationshipType?: string relationshipType?: string
@ -11,6 +12,7 @@ interface EntityField {
interface EntityDefinition { interface EntityDefinition {
id: number id: number
name: string name: string
identifier: string
fields: EntityField[] fields: EntityField[]
} }
@ -60,11 +62,13 @@ test('admin can add, edit, and remove an entity and its fields', async ({ page }
if (request.method() === 'GET') { if (request.method() === 'GET') {
await route.fulfill({ json: entities }) await route.fulfill({ json: entities })
} else if (request.method() === 'POST' && segments.at(-1) === 'entity-definitions') { } else if (request.method() === 'POST' && segments.at(-1) === 'entity-definitions') {
const created = { id: nextEntityId++, name: formValues(route).name, fields: [] } const values = formValues(route)
const created = { id: nextEntityId++, name: values.name, identifier: values.identifier, fields: [] }
entities.push(created) entities.push(created)
await route.fulfill({ status: 201, json: created }) await route.fulfill({ status: 201, json: created })
} else if (request.method() === 'PATCH' && segments.length === 3 && entity) { } else if (request.method() === 'PATCH' && segments.length === 3 && entity) {
entity.name = formValues(route).name entity.name = formValues(route).name
entity.identifier = formValues(route).identifier
await route.fulfill({ json: entity }) await route.fulfill({ json: entity })
} else if (request.method() === 'DELETE' && segments.length === 3 && entity) { } else if (request.method() === 'DELETE' && segments.length === 3 && entity) {
entities.splice(entities.indexOf(entity), 1) entities.splice(entities.indexOf(entity), 1)
@ -74,6 +78,7 @@ test('admin can add, edit, and remove an entity and its fields', async ({ page }
const created = { const created = {
id: nextFieldId++, id: nextFieldId++,
name: values.name, name: values.name,
identifier: values.identifier,
type: values.type, type: values.type,
...(values.targetEntityId ? { targetEntityId: Number(values.targetEntityId) } : {}), ...(values.targetEntityId ? { targetEntityId: Number(values.targetEntityId) } : {}),
...(values.relationshipType ? { relationshipType: values.relationshipType } : {}), ...(values.relationshipType ? { relationshipType: values.relationshipType } : {}),
@ -97,25 +102,33 @@ test('admin can add, edit, and remove an entity and its fields', async ({ page }
await expect(page.getByRole('heading', { name: 'Entity Configuration' })).toBeVisible() await expect(page.getByRole('heading', { name: 'Entity Configuration' })).toBeVisible()
await expect(page.getByRole('link', { name: 'Entity Configuration' })).toBeVisible() await expect(page.getByRole('link', { name: 'Entity Configuration' })).toBeVisible()
await page.getByLabel('Entity name', { exact: true }).fill('Company') await page.getByLabel('Entity name', { exact: true }).fill('Société & Company')
await expect(page.getByLabel('Entity identifier', { exact: true })).toHaveValue('societeCompany')
await page.getByLabel('Entity identifier', { exact: true }).fill('business')
await page.getByRole('button', { name: 'Add entity' }).click() await page.getByRole('button', { name: 'Add entity' }).click()
await expect(page.getByRole('heading', { name: 'Company' })).toBeVisible() await expect(page.getByRole('heading', { name: 'Société & Company' })).toBeVisible()
await expect(page.getByText('business', { exact: true })).toBeVisible()
await page.getByRole('button', { name: 'Edit entity' }).click() await page.getByRole('button', { name: 'Edit entity' }).click()
const entityEditForm = page.getByRole('button', { name: 'Save entity' }).locator('..') const entityEditForm = page.getByRole('button', { name: 'Save entity' }).locator('..')
await entityEditForm.getByLabel('Entity name', { exact: true }).fill('MyOrganization')
await expect(entityEditForm.getByLabel('Entity identifier')).toHaveValue('myOrganization')
await entityEditForm.getByLabel('Entity name', { exact: true }).fill('Organization') await entityEditForm.getByLabel('Entity name', { exact: true }).fill('Organization')
await entityEditForm.getByRole('button', { name: 'Save entity' }).click() await entityEditForm.getByRole('button', { name: 'Save entity' }).click()
await expect(page.getByRole('heading', { name: 'Organization' })).toBeVisible() await expect(page.getByRole('heading', { name: 'Organization' })).toBeVisible()
await page.getByLabel('New field').fill('Website') await page.getByLabel('New field').fill('Website')
await expect(page.getByLabel('Field identifier')).toHaveValue('website')
await page.getByLabel('Field identifier').fill('websiteUrl')
await page.getByLabel('Field type').selectOption('EMAIL') await page.getByLabel('Field type').selectOption('EMAIL')
await page.getByRole('button', { name: 'Add field' }).click() await page.getByRole('button', { name: 'Add field' }).click()
await expect(page.getByText('Website')).toBeVisible() await expect(page.getByText('Website', { exact: true })).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() await page.getByRole('button', { name: 'Edit field' }).click()
const fieldEditForm = page.getByRole('button', { name: 'Save field' }).locator('..') const fieldEditForm = page.getByRole('button', { name: 'Save field' }).locator('..')
await fieldEditForm.getByLabel('Field name').fill('Annual revenue') 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.getByLabel('Field type').selectOption('NUMBER')
await fieldEditForm.getByRole('button', { name: 'Save field' }).click() await fieldEditForm.getByRole('button', { name: 'Save field' }).click()
await expect(page.getByText('Annual revenue')).toBeVisible() await expect(page.getByText('Annual revenue')).toBeVisible()

View File

@ -60,15 +60,17 @@ fun main() {
post("/api/entity-definitions") { ctx -> post("/api/entity-definitions") { ctx ->
if (!ctx.requireAdmin(loginService)) return@post if (!ctx.requireAdmin(loginService)) return@post
val name = ctx.requiredFormParam("name") ?: return@post val name = ctx.requiredFormParam("name") ?: return@post
val identifier = ctx.requiredFormParam("identifier") ?: return@post
ctx.status(HttpStatus.CREATED).contentType("application/json") ctx.status(HttpStatus.CREATED).contentType("application/json")
.result(entities.create(name).toJson()) .result(entities.create(name, identifier).toJson())
} }
patch("/api/entity-definitions/{entityId}") { ctx -> patch("/api/entity-definitions/{entityId}") { ctx ->
if (!ctx.requireAdmin(loginService)) return@patch if (!ctx.requireAdmin(loginService)) return@patch
val id = ctx.pathParam("entityId").toLongOrNull() val id = ctx.pathParam("entityId").toLongOrNull()
val name = ctx.requiredFormParam("name") val name = ctx.requiredFormParam("name")
if (id == null || name == null) return@patch val identifier = ctx.requiredFormParam("identifier")
val entity = entities.update(id, name) if (id == null || name == null || identifier == null) return@patch
val entity = entities.update(id, name, identifier)
if (entity == null) ctx.notFound() else ctx.contentType("application/json").result(entity.toJson()) if (entity == null) ctx.notFound() else ctx.contentType("application/json").result(entity.toJson())
} }
delete("/api/entity-definitions/{entityId}") { ctx -> delete("/api/entity-definitions/{entityId}") { ctx ->
@ -80,15 +82,17 @@ fun main() {
if (!ctx.requireAdmin(loginService)) return@post if (!ctx.requireAdmin(loginService)) return@post
val entityId = ctx.pathParam("entityId").toLongOrNull() val entityId = ctx.pathParam("entityId").toLongOrNull()
val name = ctx.requiredFormParam("name") val name = ctx.requiredFormParam("name")
val identifier = ctx.requiredFormParam("identifier")
val type = ctx.formParam("type")?.let(FieldType::from) val type = ctx.formParam("type")?.let(FieldType::from)
val relationship = ctx.relationshipDetails(type, entities) val relationship = ctx.relationshipDetails(type, entities)
if (entityId == null || name == null || type == null || relationship == null) { if (entityId == null || name == null || identifier == null || type == null || relationship == null) {
if (type == null) ctx.badRequest("A valid field type is required") if (type == null) ctx.badRequest("A valid field type is required")
return@post return@post
} }
val field = entities.createField( val field = entities.createField(
entityId, entityId,
name, name,
identifier,
type, type,
relationship.targetEntityId, relationship.targetEntityId,
relationship.type, relationship.type,
@ -102,9 +106,10 @@ fun main() {
val entityId = ctx.pathParam("entityId").toLongOrNull() val entityId = ctx.pathParam("entityId").toLongOrNull()
val fieldId = ctx.pathParam("fieldId").toLongOrNull() val fieldId = ctx.pathParam("fieldId").toLongOrNull()
val name = ctx.requiredFormParam("name") val name = ctx.requiredFormParam("name")
val identifier = ctx.requiredFormParam("identifier")
val type = ctx.formParam("type")?.let(FieldType::from) val type = ctx.formParam("type")?.let(FieldType::from)
val relationship = ctx.relationshipDetails(type, entities) val relationship = ctx.relationshipDetails(type, entities)
if (entityId == null || fieldId == null || name == null || type == null || relationship == null) { if (entityId == null || fieldId == null || name == null || identifier == null || type == null || relationship == null) {
if (type == null) ctx.badRequest("A valid field type is required") if (type == null) ctx.badRequest("A valid field type is required")
return@patch return@patch
} }
@ -112,6 +117,7 @@ fun main() {
entityId, entityId,
fieldId, fieldId,
name, name,
identifier,
type, type,
relationship.targetEntityId, relationship.targetEntityId,
relationship.type, relationship.type,
@ -180,9 +186,9 @@ private fun io.javalin.http.Context.relationshipDetails(
private fun List<EntityDefinition>.toJson() = joinToString(prefix = "[", postfix = "]") { it.toJson() } private fun List<EntityDefinition>.toJson() = joinToString(prefix = "[", postfix = "]") { it.toJson() }
private fun EntityDefinition.toJson() = private fun EntityDefinition.toJson() =
"""{"id":$id,"name":"${name.toJsonString()}","fields":${fields.joinToString(prefix = "[", postfix = "]") { it.toJson() }}}""" """{"id":$id,"name":"${name.toJsonString()}","identifier":"${identifier.toJsonString()}","fields":${fields.joinToString(prefix = "[", postfix = "]") { it.toJson() }}}"""
private fun EntityField.toJson() = private fun EntityField.toJson() =
"""{"id":$id,"name":"${name.toJsonString()}","type":"$type","targetEntityId":${targetEntityId ?: "null"},"targetEntityName":${targetEntityName?.let { "\"${it.toJsonString()}\"" } ?: "null"},"relationshipType":${relationshipType?.let { "\"$it\"" } ?: "null"}}""" """{"id":$id,"name":"${name.toJsonString()}","identifier":"${identifier.toJsonString()}","type":"$type","targetEntityId":${targetEntityId ?: "null"},"targetEntityName":${targetEntityName?.let { "\"${it.toJsonString()}\"" } ?: "null"},"relationshipType":${relationshipType?.let { "\"$it\"" } ?: "null"}}"""
private fun String.toJsonString() = buildString { private fun String.toJsonString() = buildString {
for (character in this@toJsonString) { for (character in this@toJsonString) {

View File

@ -3,6 +3,7 @@ package dev.mduchene.bolts.entity
data class EntityField( data class EntityField(
val id: Long, val id: Long,
val name: String, val name: String,
val identifier: String,
val type: FieldType, val type: FieldType,
val targetEntityId: Long? = null, val targetEntityId: Long? = null,
val targetEntityName: String? = null, val targetEntityName: String? = null,
@ -12,6 +13,7 @@ data class EntityField(
data class EntityDefinition( data class EntityDefinition(
val id: Long, val id: Long,
val name: String, val name: String,
val identifier: String,
val fields: List<EntityField>, val fields: List<EntityField>,
) )

View File

@ -7,7 +7,7 @@ class EntityDefinitionRepository(private val database: Database) {
fun findAll(): List<EntityDefinition> { fun findAll(): List<EntityDefinition> {
val fields = database.queryList( val fields = database.queryList(
""" """
SELECT entity_fields.id, entity_fields.entity_id, entity_fields.name, 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.field_type, entity_fields.target_entity_id,
entity_fields.relationship_type, targets.name AS target_entity_name entity_fields.relationship_type, targets.name AS target_entity_name
FROM entity_fields FROM entity_fields
@ -21,27 +21,31 @@ class EntityDefinitionRepository(private val database: Database) {
) )
}.groupBy(FieldRow::entityId) }.groupBy(FieldRow::entityId)
return database.queryList("SELECT id, name FROM entity_definitions ORDER BY id") { return database.queryList("SELECT id, name, identifier FROM entity_definitions ORDER BY id") {
val id = getLong("id") val id = getLong("id")
EntityDefinition(id, getString("name"), fields[id].orEmpty().map(FieldRow::field)) EntityDefinition(id, getString("name"), getString("identifier"), fields[id].orEmpty().map(FieldRow::field))
} }
} }
fun create(name: String): EntityDefinition { fun create(name: String, identifier: String): EntityDefinition {
val sql = "INSERT INTO entity_definitions (name) VALUES (?) RETURNING id, name" val sql = "INSERT INTO entity_definitions (name, identifier) VALUES (?, ?) RETURNING id, name, identifier"
return checkNotNull(database.queryOne(sql, bind = { setString(1, name) }) { return checkNotNull(database.queryOne(sql, bind = {
EntityDefinition(getLong("id"), getString("name"), emptyList()) setString(1, name)
setString(2, identifier)
}) {
EntityDefinition(getLong("id"), getString("name"), getString("identifier"), emptyList())
}) })
} }
fun update(id: Long, name: String): EntityDefinition? { fun update(id: Long, name: String, identifier: String): EntityDefinition? {
val sql = "UPDATE entity_definitions SET name = ? WHERE id = ? RETURNING id, name" val sql = "UPDATE entity_definitions SET name = ?, identifier = ? WHERE id = ? RETURNING id, name, identifier"
return database.queryOne(sql, bind = { return database.queryOne(sql, bind = {
setString(1, name) setString(1, name)
setLong(2, id) setString(2, identifier)
setLong(3, id)
}) { }) {
val entityId = getLong("id") val entityId = getLong("id")
EntityDefinition(entityId, getString("name"), fieldsFor(entityId)) EntityDefinition(entityId, getString("name"), getString("identifier"), fieldsFor(entityId))
} }
} }
@ -51,21 +55,23 @@ class EntityDefinitionRepository(private val database: Database) {
fun createField( fun createField(
entityId: Long, entityId: Long,
name: String, name: String,
identifier: String,
type: FieldType, type: FieldType,
targetEntityId: Long?, targetEntityId: Long?,
relationshipType: RelationshipType?, relationshipType: RelationshipType?,
): EntityField? { ): EntityField? {
val sql = """ val sql = """
INSERT INTO entity_fields (entity_id, name, field_type, target_entity_id, relationship_type) INSERT INTO entity_fields (entity_id, name, identifier, field_type, target_entity_id, relationship_type)
SELECT id, ?, ?, ?, ? FROM entity_definitions WHERE id = ? SELECT id, ?, ?, ?, ?, ? FROM entity_definitions WHERE id = ?
RETURNING id, name, field_type, target_entity_id, relationship_type RETURNING id, name, identifier, field_type, target_entity_id, relationship_type
""".trimIndent() """.trimIndent()
return database.queryOne(sql, bind = { return database.queryOne(sql, bind = {
setString(1, name) setString(1, name)
setString(2, type.name) setString(2, identifier)
setObject(3, targetEntityId) setString(3, type.name)
setString(4, relationshipType?.name) setObject(4, targetEntityId)
setLong(5, entityId) setString(5, relationshipType?.name)
setLong(6, entityId)
}) { toEntityField() } }) { toEntityField() }
} }
@ -73,23 +79,25 @@ class EntityDefinitionRepository(private val database: Database) {
entityId: Long, entityId: Long,
fieldId: Long, fieldId: Long,
name: String, name: String,
identifier: String,
type: FieldType, type: FieldType,
targetEntityId: Long?, targetEntityId: Long?,
relationshipType: RelationshipType?, relationshipType: RelationshipType?,
): EntityField? { ): EntityField? {
val sql = """ val sql = """
UPDATE entity_fields UPDATE entity_fields
SET name = ?, field_type = ?, target_entity_id = ?, relationship_type = ? SET name = ?, identifier = ?, field_type = ?, target_entity_id = ?, relationship_type = ?
WHERE id = ? AND entity_id = ? WHERE id = ? AND entity_id = ?
RETURNING id, name, field_type, target_entity_id, relationship_type RETURNING id, name, identifier, field_type, target_entity_id, relationship_type
""".trimIndent() """.trimIndent()
return database.queryOne(sql, bind = { return database.queryOne(sql, bind = {
setString(1, name) setString(1, name)
setString(2, type.name) setString(2, identifier)
setObject(3, targetEntityId) setString(3, type.name)
setString(4, relationshipType?.name) setObject(4, targetEntityId)
setLong(5, fieldId) setString(5, relationshipType?.name)
setLong(6, entityId) setLong(6, fieldId)
setLong(7, entityId)
}) { toEntityField() } }) { toEntityField() }
} }
@ -102,7 +110,7 @@ class EntityDefinitionRepository(private val database: Database) {
private fun fieldsFor(entityId: Long): List<EntityField> = private fun fieldsFor(entityId: Long): List<EntityField> =
database.queryList( database.queryList(
""" """
SELECT entity_fields.id, entity_fields.name, entity_fields.field_type, 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.target_entity_id, entity_fields.relationship_type,
targets.name AS target_entity_name targets.name AS target_entity_name
FROM entity_fields FROM entity_fields
@ -119,6 +127,7 @@ class EntityDefinitionRepository(private val database: Database) {
private fun ResultSet.toEntityField() = EntityField( private fun ResultSet.toEntityField() = EntityField(
id = getLong("id"), id = getLong("id"),
name = getString("name"), name = getString("name"),
identifier = getString("identifier"),
type = FieldType.valueOf(getString("field_type")), type = FieldType.valueOf(getString("field_type")),
targetEntityId = getLong("target_entity_id").takeUnless { wasNull() }, targetEntityId = getLong("target_entity_id").takeUnless { wasNull() },
targetEntityName = runCatching { getString("target_entity_name") }.getOrNull(), targetEntityName = runCatching { getString("target_entity_name") }.getOrNull(),

View File

@ -0,0 +1,20 @@
package dev.mduchene.bolts.entity
import java.text.Normalizer
fun identifierFromName(name: String): String {
val words = Normalizer.normalize(name, Normalizer.Form.NFD)
.replace(Regex("\\p{M}+"), "")
.replace(Regex("['-]"), "")
.replace(Regex("([A-Z]+)([A-Z][a-z])"), "$1 $2")
.replace(Regex("([a-z0-9])([A-Z])"), "$1 $2")
.replace(Regex("[^A-Za-z0-9]+"), " ")
.trim()
.split(Regex("\\s+"))
.filter(String::isNotEmpty)
return words.mapIndexed { index, word ->
val lower = word.lowercase()
if (index == 0) lower else lower.replaceFirstChar(Char::uppercase)
}.joinToString("")
}

View File

@ -1,5 +1,6 @@
package dev.mduchene.bolts.persistence package dev.mduchene.bolts.persistence
import dev.mduchene.bolts.entity.identifierFromName
import java.sql.Connection import java.sql.Connection
import java.sql.DriverManager import java.sql.DriverManager
import java.sql.PreparedStatement import java.sql.PreparedStatement
@ -49,10 +50,30 @@ class Database(private val config: DatabaseConfig) {
statement.execute(CREATE_SESSIONS_TABLE) statement.execute(CREATE_SESSIONS_TABLE)
statement.execute(CREATE_ENTITY_DEFINITIONS_TABLE) statement.execute(CREATE_ENTITY_DEFINITIONS_TABLE)
statement.execute(CREATE_ENTITY_FIELDS_TABLE) statement.execute(CREATE_ENTITY_FIELDS_TABLE)
statement.execute(ADD_ENTITY_IDENTIFIER)
statement.execute(ADD_FIELD_IDENTIFIER)
statement.execute(ADD_FIELD_TARGET_ENTITY) statement.execute(ADD_FIELD_TARGET_ENTITY)
statement.execute(ADD_FIELD_RELATIONSHIP_TYPE) statement.execute(ADD_FIELD_RELATIONSHIP_TYPE)
statement.execute(ADD_FIELD_TARGET_ENTITY_CONSTRAINT) statement.execute(ADD_FIELD_TARGET_ENTITY_CONSTRAINT)
} }
backfillIdentifiers(connection)
}
}
private fun backfillIdentifiers(connection: Connection) {
listOf("entity_definitions", "entity_fields").forEach { table ->
connection.prepareStatement("SELECT id, name FROM $table WHERE identifier = ''").use { select ->
select.executeQuery().use { rows ->
connection.prepareStatement("UPDATE $table SET identifier = ? WHERE id = ?").use { update ->
while (rows.next()) {
update.setString(1, identifierFromName(rows.getString("name")))
update.setLong(2, rows.getLong("id"))
update.addBatch()
}
update.executeBatch()
}
}
}
} }
} }
@ -103,6 +124,7 @@ class Database(private val config: DatabaseConfig) {
CREATE TABLE IF NOT EXISTS entity_definitions ( CREATE TABLE IF NOT EXISTS entity_definitions (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE, name VARCHAR(100) NOT NULL UNIQUE,
identifier VARCHAR(100) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
) )
""" """
@ -112,6 +134,7 @@ class Database(private val config: DatabaseConfig) {
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
entity_id BIGINT NOT NULL REFERENCES entity_definitions(id) ON DELETE CASCADE, entity_id BIGINT NOT NULL REFERENCES entity_definitions(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL, name VARCHAR(100) NOT NULL,
identifier VARCHAR(100) NOT NULL,
field_type VARCHAR(30) NOT NULL, field_type VARCHAR(30) NOT NULL,
target_entity_id BIGINT REFERENCES entity_definitions(id) ON DELETE CASCADE, target_entity_id BIGINT REFERENCES entity_definitions(id) ON DELETE CASCADE,
relationship_type VARCHAR(30), relationship_type VARCHAR(30),
@ -120,6 +143,16 @@ class Database(private val config: DatabaseConfig) {
) )
""" """
const val ADD_ENTITY_IDENTIFIER = """
ALTER TABLE entity_definitions
ADD COLUMN IF NOT EXISTS identifier VARCHAR(100) NOT NULL DEFAULT ''
"""
const val ADD_FIELD_IDENTIFIER = """
ALTER TABLE entity_fields
ADD COLUMN IF NOT EXISTS identifier VARCHAR(100) NOT NULL DEFAULT ''
"""
const val ADD_FIELD_TARGET_ENTITY = """ const val ADD_FIELD_TARGET_ENTITY = """
ALTER TABLE entity_fields ALTER TABLE entity_fields
ADD COLUMN IF NOT EXISTS target_entity_id BIGINT ADD COLUMN IF NOT EXISTS target_entity_id BIGINT

View File

@ -0,0 +1,15 @@
package dev.mduchene.bolts.entity
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class IdentifierTest {
@Test
fun `creates camel case identifier without accents or special characters`() {
assertEquals("ecoleDuNord42", identifierFromName(" École du Nord #42 "))
assertEquals("primaryContact", identifierFromName("Primary contact"))
assertEquals("customersEmail", identifierFromName("Customer's e-mail"))
assertEquals("myTest", identifierFromName("MyTest"))
assertEquals("xmlParser", identifierFromName("XMLParser"))
}
}