add identifier to entities / fields
This commit is contained in:
parent
446568963f
commit
ccfc6dcfa4
@ -8,6 +8,7 @@
|
||||
|
||||
interface FieldDraft {
|
||||
name: string
|
||||
identifier: string
|
||||
type: FieldType
|
||||
targetEntityId?: number
|
||||
relationshipType: RelationshipType
|
||||
@ -16,6 +17,7 @@
|
||||
interface EntityField {
|
||||
id: number
|
||||
name: string
|
||||
identifier: string
|
||||
type: FieldType
|
||||
targetEntityId?: number
|
||||
targetEntityName?: string
|
||||
@ -25,6 +27,7 @@
|
||||
interface EntityDefinition {
|
||||
id: number
|
||||
name: string
|
||||
identifier: string
|
||||
fields: EntityField[]
|
||||
}
|
||||
|
||||
@ -33,11 +36,14 @@
|
||||
const saving = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const newEntityName = ref('')
|
||||
const newEntityIdentifier = ref('')
|
||||
const editingEntityId = ref<number>()
|
||||
const editingEntityName = ref('')
|
||||
const editingEntityIdentifier = ref('')
|
||||
const newFields = reactive<Record<number, FieldDraft>>({})
|
||||
const editingFieldId = ref<number>()
|
||||
const editingFieldName = ref('')
|
||||
const editingFieldIdentifier = ref('')
|
||||
const editingFieldType = ref<FieldType>('TEXT')
|
||||
const editingTargetEntityId = ref<number>()
|
||||
const editingRelationshipType = ref<RelationshipType>('ONE_TO_ONE')
|
||||
@ -67,34 +73,74 @@
|
||||
}
|
||||
|
||||
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 () {
|
||||
const name = newEntityName.value.trim()
|
||||
if (!name) return
|
||||
const identifier = newEntityIdentifier.value.trim()
|
||||
if (!name || !identifier) return
|
||||
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')
|
||||
const entity = await response.json() as EntityDefinition
|
||||
entities.value.push(entity)
|
||||
ensureNewField(entity.id)
|
||||
newEntityName.value = ''
|
||||
newEntityIdentifier.value = ''
|
||||
})
|
||||
}
|
||||
|
||||
function startEntityEdit (entity: EntityDefinition) {
|
||||
editingEntityId.value = entity.id
|
||||
editingEntityName.value = entity.name
|
||||
editingEntityIdentifier.value = entity.identifier
|
||||
}
|
||||
|
||||
async function saveEntity (entity: EntityDefinition) {
|
||||
const name = editingEntityName.value.trim()
|
||||
if (!name) return
|
||||
const identifier = editingEntityIdentifier.value.trim()
|
||||
if (!name || !identifier) return
|
||||
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')
|
||||
entity.name = name
|
||||
Object.assign(entity, await response.json() as EntityDefinition)
|
||||
editingEntityId.value = undefined
|
||||
})
|
||||
}
|
||||
@ -112,10 +158,12 @@
|
||||
async function addField (entity: EntityDefinition) {
|
||||
const draft = newFields[entity.id]
|
||||
const name = draft.name.trim()
|
||||
if (!name) return
|
||||
const identifier = draft.identifier.trim()
|
||||
if (!name || !identifier) return
|
||||
await mutate(async () => {
|
||||
const values: Record<string, string> = {
|
||||
name,
|
||||
identifier,
|
||||
type: draft.type,
|
||||
}
|
||||
if (draft.type === 'RELATIONSHIP') {
|
||||
@ -126,13 +174,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: '', type: 'TEXT', relationshipType: 'ONE_TO_ONE' }
|
||||
newFields[entity.id] = { name: '', identifier: '', type: 'TEXT', relationshipType: 'ONE_TO_ONE' }
|
||||
})
|
||||
}
|
||||
|
||||
function startFieldEdit (field: EntityField) {
|
||||
editingFieldId.value = field.id
|
||||
editingFieldName.value = field.name
|
||||
editingFieldIdentifier.value = field.identifier
|
||||
editingFieldType.value = field.type
|
||||
editingTargetEntityId.value = field.targetEntityId
|
||||
editingRelationshipType.value = field.relationshipType ?? 'ONE_TO_ONE'
|
||||
@ -140,10 +189,12 @@
|
||||
|
||||
async function saveField (entity: EntityDefinition, field: EntityField) {
|
||||
const name = editingFieldName.value.trim()
|
||||
if (!name) return
|
||||
const identifier = editingFieldIdentifier.value.trim()
|
||||
if (!name || !identifier) return
|
||||
await mutate(async () => {
|
||||
const values: Record<string, string> = {
|
||||
name,
|
||||
identifier,
|
||||
type: editingFieldType.value,
|
||||
}
|
||||
if (editingFieldType.value === 'RELATIONSHIP') {
|
||||
@ -210,7 +261,16 @@
|
||||
<label for="new-entity">Entity name</label>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</form>
|
||||
@ -223,13 +283,18 @@
|
||||
<div class="entity-heading">
|
||||
<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>
|
||||
<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 muted" type="button" @click="editingEntityId = undefined">Cancel</button>
|
||||
</form>
|
||||
|
||||
<template v-else>
|
||||
<h2>{{ entity.name }}</h2>
|
||||
<div>
|
||||
<h2>{{ entity.name }}</h2>
|
||||
<span class="identifier">{{ entity.identifier }}</span>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<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">
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<select :id="`edit-type-${field.id}`" v-model="editingFieldType">
|
||||
@ -274,6 +341,7 @@
|
||||
<template v-else>
|
||||
<div>
|
||||
<strong>{{ field.name }}</strong>
|
||||
<span class="identifier">{{ field.identifier }}</span>
|
||||
<span class="type-badge">{{ typeLabel(field.type) }}</span>
|
||||
|
||||
<span v-if="field.type === 'RELATIONSHIP'" class="reference-detail">
|
||||
@ -295,7 +363,16 @@
|
||||
<label :for="`new-field-${entity.id}`">New field</label>
|
||||
|
||||
<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>
|
||||
|
||||
<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-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; }
|
||||
.identifier { margin-right: 0.75rem; color: var(--v0-on-surface-variant); font-family: monospace; font-size: 0.78rem; }
|
||||
.actions { display: flex; gap: 0.85rem; }
|
||||
.text-button { padding: 0.25rem; border: 0; color: var(--v0-primary); background: transparent; font-weight: 600; }
|
||||
.text-button.danger { color: var(--v0-error); }
|
||||
|
||||
@ -3,6 +3,7 @@ import { expect, type Page, type Route, test } from '@playwright/test'
|
||||
interface EntityField {
|
||||
id: number
|
||||
name: string
|
||||
identifier: string
|
||||
type: string
|
||||
targetEntityId?: number
|
||||
relationshipType?: string
|
||||
@ -11,6 +12,7 @@ interface EntityField {
|
||||
interface EntityDefinition {
|
||||
id: number
|
||||
name: string
|
||||
identifier: string
|
||||
fields: EntityField[]
|
||||
}
|
||||
|
||||
@ -60,11 +62,13 @@ test('admin can add, edit, and remove an entity and its fields', async ({ page }
|
||||
if (request.method() === 'GET') {
|
||||
await route.fulfill({ json: entities })
|
||||
} 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)
|
||||
await route.fulfill({ status: 201, json: created })
|
||||
} else if (request.method() === 'PATCH' && segments.length === 3 && entity) {
|
||||
entity.name = formValues(route).name
|
||||
entity.identifier = formValues(route).identifier
|
||||
await route.fulfill({ json: entity })
|
||||
} else if (request.method() === 'DELETE' && segments.length === 3 && entity) {
|
||||
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 = {
|
||||
id: nextFieldId++,
|
||||
name: values.name,
|
||||
identifier: values.identifier,
|
||||
type: values.type,
|
||||
...(values.targetEntityId ? { targetEntityId: Number(values.targetEntityId) } : {}),
|
||||
...(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('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 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()
|
||||
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.getByRole('button', { name: 'Save entity' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Organization' })).toBeVisible()
|
||||
|
||||
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.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 page.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()
|
||||
|
||||
@ -60,15 +60,17 @@ fun main() {
|
||||
post("/api/entity-definitions") { ctx ->
|
||||
if (!ctx.requireAdmin(loginService)) return@post
|
||||
val name = ctx.requiredFormParam("name") ?: return@post
|
||||
val identifier = ctx.requiredFormParam("identifier") ?: return@post
|
||||
ctx.status(HttpStatus.CREATED).contentType("application/json")
|
||||
.result(entities.create(name).toJson())
|
||||
.result(entities.create(name, identifier).toJson())
|
||||
}
|
||||
patch("/api/entity-definitions/{entityId}") { ctx ->
|
||||
if (!ctx.requireAdmin(loginService)) return@patch
|
||||
val id = ctx.pathParam("entityId").toLongOrNull()
|
||||
val name = ctx.requiredFormParam("name")
|
||||
if (id == null || name == null) return@patch
|
||||
val entity = entities.update(id, name)
|
||||
val identifier = ctx.requiredFormParam("identifier")
|
||||
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())
|
||||
}
|
||||
delete("/api/entity-definitions/{entityId}") { ctx ->
|
||||
@ -80,15 +82,17 @@ fun main() {
|
||||
if (!ctx.requireAdmin(loginService)) return@post
|
||||
val entityId = ctx.pathParam("entityId").toLongOrNull()
|
||||
val name = ctx.requiredFormParam("name")
|
||||
val identifier = ctx.requiredFormParam("identifier")
|
||||
val type = ctx.formParam("type")?.let(FieldType::from)
|
||||
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")
|
||||
return@post
|
||||
}
|
||||
val field = entities.createField(
|
||||
entityId,
|
||||
name,
|
||||
identifier,
|
||||
type,
|
||||
relationship.targetEntityId,
|
||||
relationship.type,
|
||||
@ -102,9 +106,10 @@ fun main() {
|
||||
val entityId = ctx.pathParam("entityId").toLongOrNull()
|
||||
val fieldId = ctx.pathParam("fieldId").toLongOrNull()
|
||||
val name = ctx.requiredFormParam("name")
|
||||
val identifier = ctx.requiredFormParam("identifier")
|
||||
val type = ctx.formParam("type")?.let(FieldType::from)
|
||||
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")
|
||||
return@patch
|
||||
}
|
||||
@ -112,6 +117,7 @@ fun main() {
|
||||
entityId,
|
||||
fieldId,
|
||||
name,
|
||||
identifier,
|
||||
type,
|
||||
relationship.targetEntityId,
|
||||
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 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() =
|
||||
"""{"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 {
|
||||
for (character in this@toJsonString) {
|
||||
|
||||
@ -3,6 +3,7 @@ package dev.mduchene.bolts.entity
|
||||
data class EntityField(
|
||||
val id: Long,
|
||||
val name: String,
|
||||
val identifier: String,
|
||||
val type: FieldType,
|
||||
val targetEntityId: Long? = null,
|
||||
val targetEntityName: String? = null,
|
||||
@ -12,6 +13,7 @@ data class EntityField(
|
||||
data class EntityDefinition(
|
||||
val id: Long,
|
||||
val name: String,
|
||||
val identifier: String,
|
||||
val fields: List<EntityField>,
|
||||
)
|
||||
|
||||
|
||||
@ -7,7 +7,7 @@ class EntityDefinitionRepository(private val database: Database) {
|
||||
fun findAll(): List<EntityDefinition> {
|
||||
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.relationship_type, targets.name AS target_entity_name
|
||||
FROM entity_fields
|
||||
@ -21,27 +21,31 @@ class EntityDefinitionRepository(private val database: Database) {
|
||||
)
|
||||
}.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")
|
||||
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 {
|
||||
val sql = "INSERT INTO entity_definitions (name) VALUES (?) RETURNING id, name"
|
||||
return checkNotNull(database.queryOne(sql, bind = { setString(1, name) }) {
|
||||
EntityDefinition(getLong("id"), getString("name"), emptyList())
|
||||
fun create(name: String, identifier: String): EntityDefinition {
|
||||
val sql = "INSERT INTO entity_definitions (name, identifier) VALUES (?, ?) RETURNING id, name, identifier"
|
||||
return checkNotNull(database.queryOne(sql, bind = {
|
||||
setString(1, name)
|
||||
setString(2, identifier)
|
||||
}) {
|
||||
EntityDefinition(getLong("id"), getString("name"), getString("identifier"), emptyList())
|
||||
})
|
||||
}
|
||||
|
||||
fun update(id: Long, name: String): EntityDefinition? {
|
||||
val sql = "UPDATE entity_definitions SET name = ? WHERE id = ? RETURNING id, name"
|
||||
fun update(id: Long, name: String, identifier: String): EntityDefinition? {
|
||||
val sql = "UPDATE entity_definitions SET name = ?, identifier = ? WHERE id = ? RETURNING id, name, identifier"
|
||||
return database.queryOne(sql, bind = {
|
||||
setString(1, name)
|
||||
setLong(2, id)
|
||||
setString(2, identifier)
|
||||
setLong(3, 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(
|
||||
entityId: Long,
|
||||
name: String,
|
||||
identifier: String,
|
||||
type: FieldType,
|
||||
targetEntityId: Long?,
|
||||
relationshipType: RelationshipType?,
|
||||
): EntityField? {
|
||||
val sql = """
|
||||
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
|
||||
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
|
||||
""".trimIndent()
|
||||
return database.queryOne(sql, bind = {
|
||||
setString(1, name)
|
||||
setString(2, type.name)
|
||||
setObject(3, targetEntityId)
|
||||
setString(4, relationshipType?.name)
|
||||
setLong(5, entityId)
|
||||
setString(2, identifier)
|
||||
setString(3, type.name)
|
||||
setObject(4, targetEntityId)
|
||||
setString(5, relationshipType?.name)
|
||||
setLong(6, entityId)
|
||||
}) { toEntityField() }
|
||||
}
|
||||
|
||||
@ -73,23 +79,25 @@ class EntityDefinitionRepository(private val database: Database) {
|
||||
entityId: Long,
|
||||
fieldId: Long,
|
||||
name: String,
|
||||
identifier: String,
|
||||
type: FieldType,
|
||||
targetEntityId: Long?,
|
||||
relationshipType: RelationshipType?,
|
||||
): EntityField? {
|
||||
val sql = """
|
||||
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 = ?
|
||||
RETURNING id, name, field_type, target_entity_id, relationship_type
|
||||
RETURNING id, name, identifier, field_type, target_entity_id, relationship_type
|
||||
""".trimIndent()
|
||||
return database.queryOne(sql, bind = {
|
||||
setString(1, name)
|
||||
setString(2, type.name)
|
||||
setObject(3, targetEntityId)
|
||||
setString(4, relationshipType?.name)
|
||||
setLong(5, fieldId)
|
||||
setLong(6, entityId)
|
||||
setString(2, identifier)
|
||||
setString(3, type.name)
|
||||
setObject(4, targetEntityId)
|
||||
setString(5, relationshipType?.name)
|
||||
setLong(6, fieldId)
|
||||
setLong(7, entityId)
|
||||
}) { toEntityField() }
|
||||
}
|
||||
|
||||
@ -102,7 +110,7 @@ class EntityDefinitionRepository(private val database: Database) {
|
||||
private fun fieldsFor(entityId: Long): List<EntityField> =
|
||||
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,
|
||||
targets.name AS target_entity_name
|
||||
FROM entity_fields
|
||||
@ -119,6 +127,7 @@ class EntityDefinitionRepository(private val database: Database) {
|
||||
private fun ResultSet.toEntityField() = EntityField(
|
||||
id = getLong("id"),
|
||||
name = getString("name"),
|
||||
identifier = getString("identifier"),
|
||||
type = FieldType.valueOf(getString("field_type")),
|
||||
targetEntityId = getLong("target_entity_id").takeUnless { wasNull() },
|
||||
targetEntityName = runCatching { getString("target_entity_name") }.getOrNull(),
|
||||
|
||||
20
src/main/kotlin/dev/mduchene/bolts/entity/Identifier.kt
Normal file
20
src/main/kotlin/dev/mduchene/bolts/entity/Identifier.kt
Normal 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("")
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
package dev.mduchene.bolts.persistence
|
||||
|
||||
import dev.mduchene.bolts.entity.identifierFromName
|
||||
import java.sql.Connection
|
||||
import java.sql.DriverManager
|
||||
import java.sql.PreparedStatement
|
||||
@ -49,10 +50,30 @@ 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_ENTITY_IDENTIFIER)
|
||||
statement.execute(ADD_FIELD_IDENTIFIER)
|
||||
statement.execute(ADD_FIELD_TARGET_ENTITY)
|
||||
statement.execute(ADD_FIELD_RELATIONSHIP_TYPE)
|
||||
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 (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL UNIQUE,
|
||||
identifier VARCHAR(100) NOT NULL,
|
||||
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,
|
||||
entity_id BIGINT NOT NULL REFERENCES entity_definitions(id) ON DELETE CASCADE,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
identifier 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),
|
||||
@ -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 = """
|
||||
ALTER TABLE entity_fields
|
||||
ADD COLUMN IF NOT EXISTS target_entity_id BIGINT
|
||||
|
||||
15
src/test/kotlin/dev/mduchene/bolts/entity/IdentifierTest.kt
Normal file
15
src/test/kotlin/dev/mduchene/bolts/entity/IdentifierTest.kt
Normal 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"))
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user