adding relationships / users to entities config
This commit is contained in:
parent
46f0b091e8
commit
d3ccae9c75
@ -2,13 +2,24 @@
|
|||||||
import { onMounted, reactive, ref } from 'vue'
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
import { auth } from '@/auth'
|
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 FieldType = typeof fieldTypes[number]
|
||||||
|
type RelationshipType = 'ONE_TO_ONE' | 'ONE_TO_MANY'
|
||||||
|
|
||||||
|
interface FieldDraft {
|
||||||
|
name: string
|
||||||
|
type: FieldType
|
||||||
|
targetEntityId?: number
|
||||||
|
relationshipType: RelationshipType
|
||||||
|
}
|
||||||
|
|
||||||
interface EntityField {
|
interface EntityField {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
type: FieldType
|
type: FieldType
|
||||||
|
targetEntityId?: number
|
||||||
|
targetEntityName?: string
|
||||||
|
relationshipType?: RelationshipType
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EntityDefinition {
|
interface EntityDefinition {
|
||||||
@ -24,10 +35,12 @@
|
|||||||
const newEntityName = ref('')
|
const newEntityName = ref('')
|
||||||
const editingEntityId = ref<number>()
|
const editingEntityId = ref<number>()
|
||||||
const editingEntityName = ref('')
|
const editingEntityName = ref('')
|
||||||
const newFields = reactive<Record<number, { name: string, type: FieldType }>>({})
|
const newFields = reactive<Record<number, FieldDraft>>({})
|
||||||
const editingFieldId = ref<number>()
|
const editingFieldId = ref<number>()
|
||||||
const editingFieldName = ref('')
|
const editingFieldName = ref('')
|
||||||
const editingFieldType = ref<FieldType>('TEXT')
|
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>) {
|
function request (url: string, method = 'GET', values?: Record<string, string>) {
|
||||||
return fetch(url, {
|
return fetch(url, {
|
||||||
@ -54,7 +67,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ensureNewField (entityId: number) {
|
function ensureNewField (entityId: number) {
|
||||||
newFields[entityId] ??= { name: '', type: 'TEXT' }
|
newFields[entityId] ??= { name: '', type: 'TEXT', relationshipType: 'ONE_TO_ONE' }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addEntity () {
|
async function addEntity () {
|
||||||
@ -101,13 +114,19 @@
|
|||||||
const name = draft.name.trim()
|
const name = draft.name.trim()
|
||||||
if (!name) return
|
if (!name) return
|
||||||
await mutate(async () => {
|
await mutate(async () => {
|
||||||
const response = await request(`/api/entity-definitions/${entity.id}/fields`, 'POST', {
|
const values: Record<string, string> = {
|
||||||
name,
|
name,
|
||||||
type: draft.type,
|
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')
|
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' }
|
newFields[entity.id] = { name: '', type: 'TEXT', relationshipType: 'ONE_TO_ONE' }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -115,16 +134,24 @@
|
|||||||
editingFieldId.value = field.id
|
editingFieldId.value = field.id
|
||||||
editingFieldName.value = field.name
|
editingFieldName.value = field.name
|
||||||
editingFieldType.value = field.type
|
editingFieldType.value = field.type
|
||||||
|
editingTargetEntityId.value = field.targetEntityId
|
||||||
|
editingRelationshipType.value = field.relationshipType ?? 'ONE_TO_ONE'
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
if (!name) return
|
||||||
await mutate(async () => {
|
await mutate(async () => {
|
||||||
const response = await request(`/api/entity-definitions/${entity.id}/fields/${field.id}`, 'PATCH', {
|
const values: Record<string, string> = {
|
||||||
name,
|
name,
|
||||||
type: editingFieldType.value,
|
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')
|
if (!response.ok) throw new Error('Field update failed')
|
||||||
Object.assign(field, await response.json() as EntityField)
|
Object.assign(field, await response.json() as EntityField)
|
||||||
editingFieldId.value = undefined
|
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)
|
onMounted(loadEntities)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -205,15 +248,40 @@
|
|||||||
<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">
|
||||||
<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>
|
</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" type="submit">Save field</button>
|
||||||
<button class="text-button muted" type="button" @click="editingFieldId = undefined">Cancel</button>
|
<button class="text-button muted" type="button" @click="editingFieldId = undefined">Cancel</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<template v-else>
|
<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">
|
<div class="actions">
|
||||||
<button class="text-button" type="button" @click="startFieldEdit(field)">Edit field</button>
|
<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>
|
<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">
|
||||||
<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>
|
</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>
|
<button class="secondary-button" :disabled="saving" type="submit">Add field</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@ -270,6 +354,7 @@
|
|||||||
.field-row:last-child { border-bottom: 0; }
|
.field-row:last-child { border-bottom: 0; }
|
||||||
.field-row strong { margin-right: 0.75rem; }
|
.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; }
|
.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; }
|
.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); }
|
.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; }
|
.field-inputs input { flex: 1; }
|
||||||
|
|||||||
@ -4,6 +4,8 @@ interface EntityField {
|
|||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
type: string
|
type: string
|
||||||
|
targetEntityId?: number
|
||||||
|
relationshipType?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EntityDefinition {
|
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 })
|
await route.fulfill({ status: 204 })
|
||||||
} else if (request.method() === 'POST' && segments.at(-1) === 'fields' && entity) {
|
} else if (request.method() === 'POST' && segments.at(-1) === 'fields' && entity) {
|
||||||
const values = formValues(route)
|
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)
|
entity.fields.push(created)
|
||||||
await route.fulfill({ status: 201, json: created })
|
await route.fulfill({ status: 201, json: created })
|
||||||
} else if (request.method() === 'PATCH' && entity) {
|
} 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.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')).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('..')
|
||||||
@ -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.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()
|
||||||
await expect(page.locator('.type-badge')).toHaveText('NUMBER')
|
await expect(page.locator('.type-badge')).toHaveText('Number')
|
||||||
|
|
||||||
page.on('dialog', dialog => dialog.accept())
|
page.on('dialog', dialog => dialog.accept())
|
||||||
await page.getByRole('button', { name: 'Remove field' }).click()
|
await page.getByRole('button', { name: 'Remove field' }).click()
|
||||||
await expect(page.getByText('Annual revenue')).not.toBeVisible()
|
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()
|
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()
|
await expect(page.getByText('No entities yet.')).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import dev.mduchene.bolts.entity.EntityDefinition
|
|||||||
import dev.mduchene.bolts.entity.EntityDefinitionRepository
|
import dev.mduchene.bolts.entity.EntityDefinitionRepository
|
||||||
import dev.mduchene.bolts.entity.EntityField
|
import dev.mduchene.bolts.entity.EntityField
|
||||||
import dev.mduchene.bolts.entity.FieldType
|
import dev.mduchene.bolts.entity.FieldType
|
||||||
|
import dev.mduchene.bolts.entity.RelationshipType
|
||||||
import dev.mduchene.bolts.user.LoginService
|
import dev.mduchene.bolts.user.LoginService
|
||||||
import dev.mduchene.bolts.user.SessionRepository
|
import dev.mduchene.bolts.user.SessionRepository
|
||||||
import dev.mduchene.bolts.user.UserRepository
|
import dev.mduchene.bolts.user.UserRepository
|
||||||
@ -80,11 +81,18 @@ fun main() {
|
|||||||
val entityId = ctx.pathParam("entityId").toLongOrNull()
|
val entityId = ctx.pathParam("entityId").toLongOrNull()
|
||||||
val name = ctx.requiredFormParam("name")
|
val name = ctx.requiredFormParam("name")
|
||||||
val type = ctx.formParam("type")?.let(FieldType::from)
|
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")
|
if (type == null) ctx.badRequest("A valid field type is required")
|
||||||
return@post
|
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 {
|
if (field == null) ctx.notFound() else {
|
||||||
ctx.status(HttpStatus.CREATED).contentType("application/json").result(field.toJson())
|
ctx.status(HttpStatus.CREATED).contentType("application/json").result(field.toJson())
|
||||||
}
|
}
|
||||||
@ -95,11 +103,19 @@ fun main() {
|
|||||||
val fieldId = ctx.pathParam("fieldId").toLongOrNull()
|
val fieldId = ctx.pathParam("fieldId").toLongOrNull()
|
||||||
val name = ctx.requiredFormParam("name")
|
val name = ctx.requiredFormParam("name")
|
||||||
val type = ctx.formParam("type")?.let(FieldType::from)
|
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")
|
if (type == null) ctx.badRequest("A valid field type is required")
|
||||||
return@patch
|
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())
|
if (field == null) ctx.notFound() else ctx.contentType("application/json").result(field.toJson())
|
||||||
}
|
}
|
||||||
delete("/api/entity-definitions/{entityId}/fields/{fieldId}") { ctx ->
|
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"}""")
|
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 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()}","fields":${fields.joinToString(prefix = "[", postfix = "]") { it.toJson() }}}"""
|
||||||
private fun EntityField.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 {
|
private fun String.toJsonString() = buildString {
|
||||||
for (character in this@toJsonString) {
|
for (character in this@toJsonString) {
|
||||||
|
|||||||
@ -4,6 +4,9 @@ data class EntityField(
|
|||||||
val id: Long,
|
val id: Long,
|
||||||
val name: String,
|
val name: String,
|
||||||
val type: FieldType,
|
val type: FieldType,
|
||||||
|
val targetEntityId: Long? = null,
|
||||||
|
val targetEntityName: String? = null,
|
||||||
|
val relationshipType: RelationshipType? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class EntityDefinition(
|
data class EntityDefinition(
|
||||||
@ -19,6 +22,8 @@ enum class FieldType {
|
|||||||
DATE,
|
DATE,
|
||||||
EMAIL,
|
EMAIL,
|
||||||
PHONE,
|
PHONE,
|
||||||
|
RELATIONSHIP,
|
||||||
|
USER,
|
||||||
;
|
;
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@ -26,3 +31,14 @@ enum class FieldType {
|
|||||||
entries.firstOrNull { it.name.equals(value, ignoreCase = true) }
|
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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -6,7 +6,14 @@ import java.sql.ResultSet
|
|||||||
class EntityDefinitionRepository(private val database: Database) {
|
class EntityDefinitionRepository(private val database: Database) {
|
||||||
fun findAll(): List<EntityDefinition> {
|
fun findAll(): List<EntityDefinition> {
|
||||||
val fields = database.queryList(
|
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(
|
FieldRow(
|
||||||
entityId = getLong("entity_id"),
|
entityId = getLong("entity_id"),
|
||||||
@ -41,30 +48,48 @@ class EntityDefinitionRepository(private val database: Database) {
|
|||||||
fun delete(id: Long): Boolean =
|
fun delete(id: Long): Boolean =
|
||||||
database.executeUpdate("DELETE FROM entity_definitions WHERE id = ?") { setLong(1, id) } > 0
|
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 = """
|
val sql = """
|
||||||
INSERT INTO entity_fields (entity_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 = ?
|
SELECT id, ?, ?, ?, ? FROM entity_definitions WHERE id = ?
|
||||||
RETURNING id, name, field_type
|
RETURNING id, name, 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, type.name)
|
||||||
setLong(3, entityId)
|
setObject(3, targetEntityId)
|
||||||
|
setString(4, relationshipType?.name)
|
||||||
|
setLong(5, entityId)
|
||||||
}) { toEntityField() }
|
}) { 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 = """
|
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 = ?
|
WHERE id = ? AND entity_id = ?
|
||||||
RETURNING id, name, field_type
|
RETURNING id, name, 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, type.name)
|
||||||
setLong(3, fieldId)
|
setObject(3, targetEntityId)
|
||||||
setLong(4, entityId)
|
setString(4, relationshipType?.name)
|
||||||
|
setLong(5, fieldId)
|
||||||
|
setLong(6, entityId)
|
||||||
}) { toEntityField() }
|
}) { toEntityField() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -76,7 +101,15 @@ 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 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) },
|
bind = { setLong(1, entityId) },
|
||||||
) { toEntityField() }
|
) { toEntityField() }
|
||||||
|
|
||||||
@ -87,4 +120,7 @@ private fun ResultSet.toEntityField() = EntityField(
|
|||||||
id = getLong("id"),
|
id = getLong("id"),
|
||||||
name = getString("name"),
|
name = getString("name"),
|
||||||
type = FieldType.valueOf(getString("field_type")),
|
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),
|
||||||
)
|
)
|
||||||
|
|||||||
@ -43,6 +43,9 @@ 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_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,
|
entity_id BIGINT NOT NULL REFERENCES entity_definitions(id) ON DELETE CASCADE,
|
||||||
name VARCHAR(100) NOT NULL,
|
name 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,
|
||||||
|
relationship_type VARCHAR(30),
|
||||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
UNIQUE (entity_id, name)
|
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 ${'$'}$
|
||||||
|
"""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,4 +15,10 @@ class FieldTypeTest {
|
|||||||
fun `rejects unsupported field types`() {
|
fun `rejects unsupported field types`() {
|
||||||
assertNull(FieldType.from("attachment"))
|
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"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user