composite unique constraints / indexes
This commit is contained in:
parent
f6b3f6370a
commit
140b195111
@ -12,8 +12,6 @@
|
|||||||
type: FieldType
|
type: FieldType
|
||||||
targetEntityId?: number
|
targetEntityId?: number
|
||||||
relationshipType: RelationshipType
|
relationshipType: RelationshipType
|
||||||
indexed: boolean
|
|
||||||
unique: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EntityField {
|
interface EntityField {
|
||||||
@ -24,15 +22,22 @@
|
|||||||
targetEntityId?: number
|
targetEntityId?: number
|
||||||
targetEntityName?: string
|
targetEntityName?: string
|
||||||
relationshipType?: RelationshipType
|
relationshipType?: RelationshipType
|
||||||
indexed?: boolean
|
|
||||||
unique?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface EntityIndex {
|
||||||
|
id: number
|
||||||
|
type: 'INDEX' | 'UNIQUE'
|
||||||
|
fields: Pick<EntityField, 'id' | 'name' | 'identifier'>[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IndexDraft { fieldIds: number[], type: 'INDEX' | 'UNIQUE' }
|
||||||
|
|
||||||
interface EntityDefinition {
|
interface EntityDefinition {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
identifier: string
|
identifier: string
|
||||||
fields: EntityField[]
|
fields: EntityField[]
|
||||||
|
indexes: EntityIndex[]
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MigrationValidation {
|
interface MigrationValidation {
|
||||||
@ -59,14 +64,13 @@
|
|||||||
const editingEntityName = ref('')
|
const editingEntityName = ref('')
|
||||||
const editingEntityIdentifier = ref('')
|
const editingEntityIdentifier = ref('')
|
||||||
const newFields = reactive<Record<number, FieldDraft>>({})
|
const newFields = reactive<Record<number, FieldDraft>>({})
|
||||||
|
const newIndexes = reactive<Record<number, IndexDraft>>({})
|
||||||
const editingFieldId = ref<number>()
|
const editingFieldId = ref<number>()
|
||||||
const editingFieldName = ref('')
|
const editingFieldName = ref('')
|
||||||
const editingFieldIdentifier = 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')
|
||||||
const editingFieldIndexed = ref(false)
|
|
||||||
const editingFieldUnique = ref(false)
|
|
||||||
const validatingMigration = ref(false)
|
const validatingMigration = ref(false)
|
||||||
const runningMigration = ref(false)
|
const runningMigration = ref(false)
|
||||||
const migrationValidation = ref<MigrationValidation>()
|
const migrationValidation = ref<MigrationValidation>()
|
||||||
@ -88,7 +92,10 @@
|
|||||||
const response = await request('/api/entity-definitions')
|
const response = await request('/api/entity-definitions')
|
||||||
if (!response.ok) throw new Error('Entity request failed')
|
if (!response.ok) throw new Error('Entity request failed')
|
||||||
entities.value = await response.json() as EntityDefinition[]
|
entities.value = await response.json() as EntityDefinition[]
|
||||||
for (const entity of entities.value) ensureNewField(entity.id)
|
for (const entity of entities.value) {
|
||||||
|
entity.indexes ??= []
|
||||||
|
ensureNewField(entity.id)
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
errorMessage.value = 'Unable to load entity configuration.'
|
errorMessage.value = 'Unable to load entity configuration.'
|
||||||
} finally {
|
} finally {
|
||||||
@ -102,9 +109,8 @@
|
|||||||
identifier: '',
|
identifier: '',
|
||||||
type: 'TEXT',
|
type: 'TEXT',
|
||||||
relationshipType: 'ONE_TO_ONE',
|
relationshipType: 'ONE_TO_ONE',
|
||||||
indexed: false,
|
|
||||||
unique: false,
|
|
||||||
}
|
}
|
||||||
|
newIndexes[entityId] ??= { fieldIds: [], type: 'INDEX' }
|
||||||
}
|
}
|
||||||
|
|
||||||
function toIdentifier (name: string) {
|
function toIdentifier (name: string) {
|
||||||
@ -183,6 +189,7 @@
|
|||||||
if (!response.ok) throw new Error('Entity delete failed')
|
if (!response.ok) throw new Error('Entity delete failed')
|
||||||
entities.value = entities.value.filter(item => item.id !== entity.id)
|
entities.value = entities.value.filter(item => item.id !== entity.id)
|
||||||
delete newFields[entity.id]
|
delete newFields[entity.id]
|
||||||
|
delete newIndexes[entity.id]
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -196,8 +203,6 @@
|
|||||||
name,
|
name,
|
||||||
identifier,
|
identifier,
|
||||||
type: draft.type,
|
type: draft.type,
|
||||||
indexed: String(draft.indexed),
|
|
||||||
unique: String(draft.unique),
|
|
||||||
}
|
}
|
||||||
if (draft.type === 'RELATIONSHIP') {
|
if (draft.type === 'RELATIONSHIP') {
|
||||||
if (!draft.targetEntityId) return
|
if (!draft.targetEntityId) return
|
||||||
@ -212,8 +217,6 @@
|
|||||||
identifier: '',
|
identifier: '',
|
||||||
type: 'TEXT',
|
type: 'TEXT',
|
||||||
relationshipType: 'ONE_TO_ONE',
|
relationshipType: 'ONE_TO_ONE',
|
||||||
indexed: false,
|
|
||||||
unique: false,
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -225,8 +228,6 @@
|
|||||||
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'
|
||||||
editingFieldIndexed.value = field.indexed ?? false
|
|
||||||
editingFieldUnique.value = field.unique ?? false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveField (entity: EntityDefinition, field: EntityField) {
|
async function saveField (entity: EntityDefinition, field: EntityField) {
|
||||||
@ -238,8 +239,6 @@
|
|||||||
name,
|
name,
|
||||||
identifier,
|
identifier,
|
||||||
type: editingFieldType.value,
|
type: editingFieldType.value,
|
||||||
indexed: String(editingFieldIndexed.value),
|
|
||||||
unique: String(editingFieldUnique.value),
|
|
||||||
}
|
}
|
||||||
if (editingFieldType.value === 'RELATIONSHIP') {
|
if (editingFieldType.value === 'RELATIONSHIP') {
|
||||||
if (!editingTargetEntityId.value) return
|
if (!editingTargetEntityId.value) return
|
||||||
@ -262,6 +261,28 @@
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function addIndex (entity: EntityDefinition) {
|
||||||
|
const draft = newIndexes[entity.id]
|
||||||
|
if (draft.fieldIds.length === 0) return
|
||||||
|
await mutate(async () => {
|
||||||
|
const response = await request(`/api/entity-definitions/${entity.id}/indexes`, 'POST', {
|
||||||
|
type: draft.type,
|
||||||
|
fieldIds: draft.fieldIds.join(','),
|
||||||
|
})
|
||||||
|
if (!response.ok) throw new Error('Index create failed')
|
||||||
|
entity.indexes.push(await response.json() as EntityIndex)
|
||||||
|
newIndexes[entity.id] = { fieldIds: [], type: 'INDEX' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeIndex (entity: EntityDefinition, index: EntityIndex) {
|
||||||
|
await mutate(async () => {
|
||||||
|
const response = await request(`/api/entity-definitions/${entity.id}/indexes/${index.id}`, 'DELETE')
|
||||||
|
if (!response.ok) throw new Error('Index delete failed')
|
||||||
|
entity.indexes = entity.indexes.filter(item => item.id !== index.id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async function mutate (action: () => Promise<void>) {
|
async function mutate (action: () => Promise<void>) {
|
||||||
saving.value = true
|
saving.value = true
|
||||||
errorMessage.value = ''
|
errorMessage.value = ''
|
||||||
@ -487,16 +508,6 @@
|
|||||||
</select>
|
</select>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<label class="checkbox-label">
|
|
||||||
<input v-model="editingFieldIndexed" type="checkbox">
|
|
||||||
Indexed
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label class="checkbox-label">
|
|
||||||
<input v-model="editingFieldUnique" type="checkbox">
|
|
||||||
Unique
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<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>
|
||||||
@ -506,8 +517,6 @@
|
|||||||
<strong>{{ field.name }}</strong>
|
<strong>{{ field.name }}</strong>
|
||||||
<span class="identifier">{{ field.identifier }}</span>
|
<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.indexed && !field.unique" class="option-badge">Indexed</span>
|
|
||||||
<span v-if="field.unique" class="option-badge">Unique</span>
|
|
||||||
|
|
||||||
<span v-if="field.type === 'RELATIONSHIP'" class="reference-detail">
|
<span v-if="field.type === 'RELATIONSHIP'" class="reference-detail">
|
||||||
{{ targetName(field) }} · {{ relationshipLabel(field.relationshipType) }}
|
{{ targetName(field) }} · {{ relationshipLabel(field.relationshipType) }}
|
||||||
@ -560,19 +569,44 @@
|
|||||||
</select>
|
</select>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<label class="checkbox-label">
|
|
||||||
<input v-model="newFields[entity.id].indexed" type="checkbox">
|
|
||||||
Indexed
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label class="checkbox-label">
|
|
||||||
<input v-model="newFields[entity.id].unique" type="checkbox">
|
|
||||||
Unique
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<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>
|
||||||
|
|
||||||
|
<section :aria-labelledby="`indexes-${entity.id}`" class="indexes">
|
||||||
|
<h3 :id="`indexes-${entity.id}`">Unique constraints and indexes</h3>
|
||||||
|
|
||||||
|
<p v-if="entity.indexes.length === 0" class="empty-fields">No constraints or indexes configured.</p>
|
||||||
|
|
||||||
|
<div v-for="index in entity.indexes" :key="index.id" class="index-row">
|
||||||
|
<div>
|
||||||
|
<span class="type-badge">{{ index.type === 'UNIQUE' ? 'Unique constraint' : 'Index' }}</span>
|
||||||
|
<span class="index-fields">{{ index.fields.map(field => field.name).join(' + ') }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="text-button danger" type="button" @click="removeIndex(entity, index)">Remove</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form :aria-label="`Add constraint or index to ${entity.name}`" class="add-index" @submit.prevent="addIndex(entity)">
|
||||||
|
<fieldset>
|
||||||
|
<legend>Fields</legend>
|
||||||
|
|
||||||
|
<label v-for="field in entity.fields" :key="field.id" class="checkbox-label">
|
||||||
|
<input v-model="newIndexes[entity.id].fieldIds" type="checkbox" :value="field.id">
|
||||||
|
{{ field.name }}
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<label :for="`index-type-${entity.id}`">Type</label>
|
||||||
|
|
||||||
|
<select :id="`index-type-${entity.id}`" v-model="newIndexes[entity.id].type">
|
||||||
|
<option value="INDEX">Index</option>
|
||||||
|
<option value="UNIQUE">Unique constraint</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button class="secondary-button" :disabled="saving || newIndexes[entity.id].fieldIds.length === 0" type="submit">Add to configuration</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
@ -613,6 +647,13 @@
|
|||||||
.reference-detail { margin-left: 0.65rem; color: var(--v0-on-surface-variant); font-size: 0.8rem; }
|
.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); }
|
||||||
|
.indexes { padding: 1.25rem; border-top: 1px solid var(--v0-divider); }
|
||||||
|
.indexes h3 { margin: 0 0 0.75rem; font-size: 1rem; }
|
||||||
|
.index-row { display: flex; min-height: 3rem; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--v0-divider); }
|
||||||
|
.index-fields { margin-left: 0.65rem; font-size: 0.875rem; }
|
||||||
|
.add-index { display: flex; gap: 0.75rem; margin-top: 1rem; align-items: end; }
|
||||||
|
.add-index fieldset { display: flex; min-width: 0; margin: 0; padding: 0.65rem 0.8rem; gap: 0.8rem; flex: 1; flex-wrap: wrap; border: 1px solid var(--v0-divider); border-radius: 0.6rem; }
|
||||||
|
.add-index legend { padding: 0 0.25rem; font-size: 0.78rem; font-weight: 700; }
|
||||||
.field-inputs input { flex: 1; }
|
.field-inputs input { flex: 1; }
|
||||||
.error { color: var(--v0-error); }
|
.error { color: var(--v0-error); }
|
||||||
.migration-panel { margin-bottom: 2rem; padding: 1.25rem; border: 1px solid var(--v0-divider); border-radius: 1rem; background: var(--v0-surface); }
|
.migration-panel { margin-bottom: 2rem; padding: 1.25rem; border: 1px solid var(--v0-divider); border-radius: 1rem; background: var(--v0-surface); }
|
||||||
@ -630,7 +671,7 @@
|
|||||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
||||||
@media (max-width: 700px) {
|
@media (max-width: 700px) {
|
||||||
.entity-heading, .field-row { gap: 0.75rem; align-items: flex-start; flex-direction: column; }
|
.entity-heading, .field-row { gap: 0.75rem; align-items: flex-start; flex-direction: column; }
|
||||||
.form-row, .field-inputs, .edit-row, .edit-field { align-items: stretch; flex-direction: column; }
|
.form-row, .field-inputs, .edit-row, .edit-field, .add-index { align-items: stretch; flex-direction: column; }
|
||||||
.entity-heading, .field-row { padding-block: 1rem; }
|
.entity-heading, .field-row { padding-block: 1rem; }
|
||||||
input:not([type="checkbox"]), select, .primary-button, .secondary-button { width: 100%; }
|
input:not([type="checkbox"]), select, .primary-button, .secondary-button { width: 100%; }
|
||||||
.migration-actions { align-items: stretch; flex-direction: column; }
|
.migration-actions { align-items: stretch; flex-direction: column; }
|
||||||
|
|||||||
@ -7,15 +7,16 @@ interface EntityField {
|
|||||||
type: string
|
type: string
|
||||||
targetEntityId?: number
|
targetEntityId?: number
|
||||||
relationshipType?: string
|
relationshipType?: string
|
||||||
indexed?: boolean
|
|
||||||
unique?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface EntityIndex { id: number, type: string, fields: EntityField[] }
|
||||||
|
|
||||||
interface EntityDefinition {
|
interface EntityDefinition {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
identifier: string
|
identifier: string
|
||||||
fields: EntityField[]
|
fields: EntityField[]
|
||||||
|
indexes: EntityIndex[]
|
||||||
}
|
}
|
||||||
|
|
||||||
async function useAdminSession (page: Page) {
|
async function useAdminSession (page: Page) {
|
||||||
@ -51,6 +52,7 @@ test('admin can add, edit, and remove an entity and its fields', async ({ page }
|
|||||||
const entities: EntityDefinition[] = []
|
const entities: EntityDefinition[] = []
|
||||||
let nextEntityId = 1
|
let nextEntityId = 1
|
||||||
let nextFieldId = 1
|
let nextFieldId = 1
|
||||||
|
let nextIndexId = 1
|
||||||
|
|
||||||
await useAdminSession(page)
|
await useAdminSession(page)
|
||||||
await page.route('**/api/entity-definitions**', async route => {
|
await page.route('**/api/entity-definitions**', async route => {
|
||||||
@ -65,7 +67,7 @@ test('admin can add, edit, and remove an entity and its fields', async ({ page }
|
|||||||
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 values = formValues(route)
|
const values = formValues(route)
|
||||||
const created = { id: nextEntityId++, name: values.name, identifier: values.identifier, fields: [] }
|
const created = { id: nextEntityId++, name: values.name, identifier: values.identifier, fields: [], indexes: [] }
|
||||||
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) {
|
||||||
@ -82,17 +84,27 @@ test('admin can add, edit, and remove an entity and its fields', async ({ page }
|
|||||||
name: values.name,
|
name: values.name,
|
||||||
identifier: values.identifier,
|
identifier: values.identifier,
|
||||||
type: values.type,
|
type: values.type,
|
||||||
indexed: values.indexed === 'true',
|
|
||||||
unique: values.unique === 'true',
|
|
||||||
...(values.targetEntityId ? { targetEntityId: Number(values.targetEntityId) } : {}),
|
...(values.targetEntityId ? { targetEntityId: Number(values.targetEntityId) } : {}),
|
||||||
...(values.relationshipType ? { relationshipType: values.relationshipType } : {}),
|
...(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() === 'POST' && segments.at(-1) === 'indexes' && entity) {
|
||||||
|
const values = formValues(route)
|
||||||
|
const created = {
|
||||||
|
id: nextIndexId++,
|
||||||
|
type: values.type,
|
||||||
|
fields: values.fieldIds.split(',').map(id => entity.fields.find(field => field.id === Number(id))!),
|
||||||
|
}
|
||||||
|
entity.indexes.push(created)
|
||||||
|
await route.fulfill({ status: 201, json: created })
|
||||||
} else if (request.method() === 'PATCH' && entity) {
|
} else if (request.method() === 'PATCH' && entity) {
|
||||||
const field = entity.fields.find(item => item.id === fieldId)!
|
const field = entity.fields.find(item => item.id === fieldId)!
|
||||||
Object.assign(field, formValues(route))
|
Object.assign(field, formValues(route))
|
||||||
await route.fulfill({ json: field })
|
await route.fulfill({ json: field })
|
||||||
|
} else if (request.method() === 'DELETE' && segments[3] === 'indexes' && entity) {
|
||||||
|
entity.indexes = entity.indexes.filter(item => item.id !== Number(segments[4]))
|
||||||
|
await route.fulfill({ status: 204 })
|
||||||
} else if (request.method() === 'DELETE' && entity) {
|
} else if (request.method() === 'DELETE' && entity) {
|
||||||
const index = entity.fields.findIndex(item => item.id === fieldId)
|
const index = entity.fields.findIndex(item => item.id === fieldId)
|
||||||
entity.fields.splice(index, 1)
|
entity.fields.splice(index, 1)
|
||||||
@ -125,25 +137,35 @@ test('admin can add, edit, and remove an entity and its fields', async ({ page }
|
|||||||
await expect(page.getByLabel('Field identifier')).toHaveValue('website')
|
await expect(page.getByLabel('Field identifier')).toHaveValue('website')
|
||||||
await page.getByLabel('Field identifier').fill('websiteUrl')
|
await page.getByLabel('Field identifier').fill('websiteUrl')
|
||||||
await page.getByLabel('Field type').selectOption('EMAIL')
|
await page.getByLabel('Field type').selectOption('EMAIL')
|
||||||
await page.getByRole('form', { name: 'Add field to Organization' }).getByLabel('Indexed').check()
|
|
||||||
await page.getByRole('form', { name: 'Add field to Organization' }).getByLabel('Unique').check()
|
|
||||||
await page.getByRole('button', { name: 'Add field' }).click()
|
await page.getByRole('button', { name: 'Add field' }).click()
|
||||||
await expect(page.getByText('Website', { exact: true })).toBeVisible()
|
await expect(page.getByRole('strong').filter({ hasText: 'Website' })).toBeVisible()
|
||||||
await expect(page.locator('.type-badge')).toHaveText('Email')
|
await expect(page.locator('.type-badge')).toHaveText('Email')
|
||||||
await expect(page.locator('.option-badge', { hasText: 'Unique' })).toBeVisible()
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Edit field' }).click()
|
await page.getByLabel('New field').fill('Domain')
|
||||||
|
await page.getByRole('button', { name: 'Add field' }).click()
|
||||||
|
const indexForm = page.getByRole('form', { name: 'Add constraint or index to Organization' })
|
||||||
|
await indexForm.getByLabel('Website').check()
|
||||||
|
await indexForm.getByLabel('Domain').check()
|
||||||
|
await indexForm.getByLabel('Type').selectOption('UNIQUE')
|
||||||
|
await indexForm.getByRole('button', { name: 'Add to configuration' }).click()
|
||||||
|
await expect(page.getByText('Website + Domain')).toBeVisible()
|
||||||
|
await expect(page.locator('span.type-badge', { hasText: 'Unique constraint' })).toBeVisible()
|
||||||
|
await page.getByRole('button', { name: 'Remove', exact: true }).click()
|
||||||
|
await expect(page.getByText('Website + Domain')).not.toBeVisible()
|
||||||
|
|
||||||
|
await page.locator('.field-row', { hasText: 'Website' }).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 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()
|
const revenueRow = page.locator('.field-row', { hasText: 'Annual revenue' })
|
||||||
await expect(page.locator('.type-badge')).toHaveText('Number')
|
await expect(revenueRow.getByRole('strong')).toHaveText('Annual revenue')
|
||||||
|
await expect(revenueRow.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.locator('.field-row', { hasText: 'Annual revenue' }).getByRole('button', { name: 'Remove field' }).click()
|
||||||
await expect(page.getByText('Annual revenue')).not.toBeVisible()
|
await expect(page.getByRole('strong').filter({ hasText: 'Annual revenue' })).not.toBeVisible()
|
||||||
|
|
||||||
await page.getByLabel('Entity name', { exact: true }).fill('Contact')
|
await page.getByLabel('Entity name', { exact: true }).fill('Contact')
|
||||||
await page.getByRole('button', { name: 'Add entity' }).click()
|
await page.getByRole('button', { name: 'Add entity' }).click()
|
||||||
|
|||||||
@ -8,15 +8,32 @@ data class EntityField(
|
|||||||
val targetEntityId: Long? = null,
|
val targetEntityId: Long? = null,
|
||||||
val targetEntityName: String? = null,
|
val targetEntityName: String? = null,
|
||||||
val relationshipType: RelationshipType? = null,
|
val relationshipType: RelationshipType? = null,
|
||||||
val indexed: Boolean = false,
|
|
||||||
val unique: Boolean = false,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class EntityIndex(
|
||||||
|
val id: Long,
|
||||||
|
val type: EntityIndexType,
|
||||||
|
val fields: List<EntityIndexField>,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class EntityIndexField(val id: Long, val name: String, val identifier: String)
|
||||||
|
|
||||||
|
enum class EntityIndexType {
|
||||||
|
INDEX,
|
||||||
|
UNIQUE,
|
||||||
|
;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun from(value: String): EntityIndexType? = entries.firstOrNull { it.name.equals(value, ignoreCase = true) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
data class EntityDefinition(
|
data class EntityDefinition(
|
||||||
val id: Long,
|
val id: Long,
|
||||||
val name: String,
|
val name: String,
|
||||||
val identifier: String,
|
val identifier: String,
|
||||||
val fields: List<EntityField>,
|
val fields: List<EntityField>,
|
||||||
|
val indexes: List<EntityIndex> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
enum class FieldType {
|
enum class FieldType {
|
||||||
|
|||||||
@ -4,27 +4,29 @@ import dev.mduchene.bolts.persistence.Database
|
|||||||
import java.sql.ResultSet
|
import java.sql.ResultSet
|
||||||
|
|
||||||
class EntityDefinitionRepository(private val database: Database) {
|
class EntityDefinitionRepository(private val database: Database) {
|
||||||
fun nameExists(name: String, excludingId: Long? = null): Boolean =
|
fun nameExists(name: String, excludingId: Long? = null): Boolean {
|
||||||
database.queryOne(
|
val exclusion = if (excludingId == null) "" else " AND id <> ?"
|
||||||
|
return database.queryOne(
|
||||||
"""
|
"""
|
||||||
SELECT EXISTS (
|
SELECT EXISTS (
|
||||||
SELECT 1 FROM entity_definitions
|
SELECT 1 FROM entity_definitions
|
||||||
WHERE lower(name) = lower(?) AND (? IS NULL OR id <> ?)
|
WHERE lower(name) = lower(?)$exclusion
|
||||||
) AS found
|
) AS found
|
||||||
""".trimIndent(),
|
""".trimIndent(),
|
||||||
bind = {
|
bind = {
|
||||||
setString(1, name)
|
setString(1, name)
|
||||||
setObject(2, excludingId)
|
if (excludingId != null) setLong(2, excludingId)
|
||||||
setObject(3, excludingId)
|
|
||||||
},
|
},
|
||||||
) { getBoolean("found") } == true
|
) { getBoolean("found") } == true
|
||||||
|
}
|
||||||
|
|
||||||
fun findAll(): List<EntityDefinition> {
|
fun findAll(): List<EntityDefinition> {
|
||||||
|
val indexes = indexesByEntity()
|
||||||
val fields = database.queryList(
|
val fields = database.queryList(
|
||||||
"""
|
"""
|
||||||
SELECT entity_fields.id, entity_fields.entity_id, entity_fields.name, entity_fields.identifier,
|
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, entity_fields.indexed, entity_fields.is_unique,
|
entity_fields.relationship_type,
|
||||||
targets.name AS target_entity_name
|
targets.name AS target_entity_name
|
||||||
FROM entity_fields
|
FROM entity_fields
|
||||||
LEFT JOIN entity_definitions targets ON targets.id = entity_fields.target_entity_id
|
LEFT JOIN entity_definitions targets ON targets.id = entity_fields.target_entity_id
|
||||||
@ -39,7 +41,7 @@ class EntityDefinitionRepository(private val database: Database) {
|
|||||||
|
|
||||||
return database.queryList("SELECT id, name, identifier 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"), getString("identifier"), fields[id].orEmpty().map(FieldRow::field))
|
EntityDefinition(id, getString("name"), getString("identifier"), fields[id].orEmpty().map(FieldRow::field), indexes[id].orEmpty())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -75,15 +77,13 @@ class EntityDefinitionRepository(private val database: Database) {
|
|||||||
type: FieldType,
|
type: FieldType,
|
||||||
targetEntityId: Long?,
|
targetEntityId: Long?,
|
||||||
relationshipType: RelationshipType?,
|
relationshipType: RelationshipType?,
|
||||||
indexed: Boolean,
|
|
||||||
unique: Boolean,
|
|
||||||
): EntityField? {
|
): EntityField? {
|
||||||
val sql = """
|
val sql = """
|
||||||
INSERT INTO entity_fields (
|
INSERT INTO entity_fields (
|
||||||
entity_id, name, identifier, field_type, target_entity_id, relationship_type, indexed, is_unique
|
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, identifier, field_type, target_entity_id, relationship_type, indexed, is_unique
|
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)
|
||||||
@ -91,9 +91,7 @@ class EntityDefinitionRepository(private val database: Database) {
|
|||||||
setString(3, type.name)
|
setString(3, type.name)
|
||||||
setObject(4, targetEntityId)
|
setObject(4, targetEntityId)
|
||||||
setString(5, relationshipType?.name)
|
setString(5, relationshipType?.name)
|
||||||
setBoolean(6, indexed)
|
setLong(6, entityId)
|
||||||
setBoolean(7, unique)
|
|
||||||
setLong(8, entityId)
|
|
||||||
}) { toEntityField() }
|
}) { toEntityField() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -105,15 +103,12 @@ class EntityDefinitionRepository(private val database: Database) {
|
|||||||
type: FieldType,
|
type: FieldType,
|
||||||
targetEntityId: Long?,
|
targetEntityId: Long?,
|
||||||
relationshipType: RelationshipType?,
|
relationshipType: RelationshipType?,
|
||||||
indexed: Boolean,
|
|
||||||
unique: Boolean,
|
|
||||||
): EntityField? {
|
): EntityField? {
|
||||||
val sql = """
|
val sql = """
|
||||||
UPDATE entity_fields
|
UPDATE entity_fields
|
||||||
SET name = ?, identifier = ?, field_type = ?, target_entity_id = ?, relationship_type = ?,
|
SET name = ?, identifier = ?, field_type = ?, target_entity_id = ?, relationship_type = ?
|
||||||
indexed = ?, is_unique = ?
|
|
||||||
WHERE id = ? AND entity_id = ?
|
WHERE id = ? AND entity_id = ?
|
||||||
RETURNING id, name, identifier, field_type, target_entity_id, relationship_type, indexed, is_unique
|
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)
|
||||||
@ -121,17 +116,60 @@ class EntityDefinitionRepository(private val database: Database) {
|
|||||||
setString(3, type.name)
|
setString(3, type.name)
|
||||||
setObject(4, targetEntityId)
|
setObject(4, targetEntityId)
|
||||||
setString(5, relationshipType?.name)
|
setString(5, relationshipType?.name)
|
||||||
setBoolean(6, indexed)
|
setLong(6, fieldId)
|
||||||
setBoolean(7, unique)
|
setLong(7, entityId)
|
||||||
setLong(8, fieldId)
|
|
||||||
setLong(9, entityId)
|
|
||||||
}) { toEntityField() }
|
}) { toEntityField() }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deleteField(entityId: Long, fieldId: Long): Boolean =
|
fun deleteField(entityId: Long, fieldId: Long): Boolean = database.transaction { connection ->
|
||||||
database.executeUpdate("DELETE FROM entity_fields WHERE id = ? AND entity_id = ?") {
|
val deleted = connection.prepareStatement("DELETE FROM entity_fields WHERE id = ? AND entity_id = ?").use {
|
||||||
setLong(1, fieldId)
|
it.setLong(1, fieldId); it.setLong(2, entityId); it.executeUpdate() > 0
|
||||||
setLong(2, entityId)
|
}
|
||||||
|
connection.createStatement().use {
|
||||||
|
it.executeUpdate("DELETE FROM entity_indexes i WHERE NOT EXISTS (SELECT 1 FROM entity_index_fields f WHERE f.index_id = i.id)")
|
||||||
|
}
|
||||||
|
deleted
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createIndex(entityId: Long, type: EntityIndexType, fieldIds: List<Long>): EntityIndex? =
|
||||||
|
database.transaction { connection ->
|
||||||
|
val validCount = connection.prepareStatement(
|
||||||
|
"SELECT count(*) FROM entity_fields WHERE entity_id = ? AND id = ANY (?)",
|
||||||
|
).use { statement ->
|
||||||
|
statement.setLong(1, entityId)
|
||||||
|
statement.setArray(2, connection.createArrayOf("bigint", fieldIds.toTypedArray()))
|
||||||
|
statement.executeQuery().use { it.next(); it.getInt(1) }
|
||||||
|
}
|
||||||
|
if (validCount != fieldIds.size) return@transaction null
|
||||||
|
val id = connection.prepareStatement(
|
||||||
|
"INSERT INTO entity_indexes (entity_id, index_type) VALUES (?, ?) RETURNING id",
|
||||||
|
).use { statement ->
|
||||||
|
statement.setLong(1, entityId); statement.setString(2, type.name)
|
||||||
|
statement.executeQuery().use { it.next(); it.getLong(1) }
|
||||||
|
}
|
||||||
|
connection.prepareStatement(
|
||||||
|
"INSERT INTO entity_index_fields (index_id, field_id, position) VALUES (?, ?, ?)",
|
||||||
|
).use { statement ->
|
||||||
|
fieldIds.forEachIndexed { position, fieldId ->
|
||||||
|
statement.setLong(1, id); statement.setLong(2, fieldId); statement.setInt(3, position); statement.addBatch()
|
||||||
|
}
|
||||||
|
statement.executeBatch()
|
||||||
|
}
|
||||||
|
val selectedFields = connection.prepareStatement(
|
||||||
|
"SELECT id, name, identifier FROM entity_fields WHERE id = ANY (?)",
|
||||||
|
).use { statement ->
|
||||||
|
statement.setArray(1, connection.createArrayOf("bigint", fieldIds.toTypedArray()))
|
||||||
|
statement.executeQuery().use { result ->
|
||||||
|
val byId = buildMap { while (result.next()) put(result.getLong(1), EntityIndexField(result.getLong(1), result.getString(2), result.getString(3))) }
|
||||||
|
fieldIds.map { byId.getValue(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EntityIndex(id, type, selectedFields)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteIndex(entityId: Long, indexId: Long): Boolean =
|
||||||
|
database.executeUpdate("DELETE FROM entity_indexes WHERE id = ? AND entity_id = ?") {
|
||||||
|
setLong(1, indexId); setLong(2, entityId)
|
||||||
} > 0
|
} > 0
|
||||||
|
|
||||||
private fun fieldsFor(entityId: Long): List<EntityField> =
|
private fun fieldsFor(entityId: Long): List<EntityField> =
|
||||||
@ -139,7 +177,6 @@ class EntityDefinitionRepository(private val database: Database) {
|
|||||||
"""
|
"""
|
||||||
SELECT entity_fields.id, entity_fields.name, entity_fields.identifier, 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,
|
||||||
entity_fields.indexed, entity_fields.is_unique,
|
|
||||||
targets.name AS target_entity_name
|
targets.name AS target_entity_name
|
||||||
FROM entity_fields
|
FROM entity_fields
|
||||||
LEFT JOIN entity_definitions targets ON targets.id = entity_fields.target_entity_id
|
LEFT JOIN entity_definitions targets ON targets.id = entity_fields.target_entity_id
|
||||||
@ -149,7 +186,21 @@ class EntityDefinitionRepository(private val database: Database) {
|
|||||||
bind = { setLong(1, entityId) },
|
bind = { setLong(1, entityId) },
|
||||||
) { toEntityField() }
|
) { toEntityField() }
|
||||||
|
|
||||||
|
private fun indexesByEntity(): Map<Long, List<EntityIndex>> = database.queryList(
|
||||||
|
"SELECT id, entity_id, index_type FROM entity_indexes ORDER BY id",
|
||||||
|
) {
|
||||||
|
val id = getLong("id")
|
||||||
|
IndexRow(getLong("entity_id"), EntityIndex(id, EntityIndexType.valueOf(getString("index_type")), indexFields(id)))
|
||||||
|
}.groupBy(IndexRow::entityId).mapValues { (_, rows) -> rows.map(IndexRow::index) }
|
||||||
|
|
||||||
|
private fun indexFields(indexId: Long): List<EntityIndexField> = database.queryList(
|
||||||
|
"""SELECT f.id, f.name, f.identifier FROM entity_index_fields x
|
||||||
|
JOIN entity_fields f ON f.id = x.field_id WHERE x.index_id = ? ORDER BY x.position""",
|
||||||
|
bind = { setLong(1, indexId) },
|
||||||
|
) { EntityIndexField(getLong("id"), getString("name"), getString("identifier")) }
|
||||||
|
|
||||||
private data class FieldRow(val entityId: Long, val field: EntityField)
|
private data class FieldRow(val entityId: Long, val field: EntityField)
|
||||||
|
private data class IndexRow(val entityId: Long, val index: EntityIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun ResultSet.toEntityField() = EntityField(
|
private fun ResultSet.toEntityField() = EntityField(
|
||||||
@ -160,6 +211,4 @@ private fun ResultSet.toEntityField() = EntityField(
|
|||||||
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(),
|
||||||
relationshipType = getString("relationship_type")?.let(RelationshipType::valueOf),
|
relationshipType = getString("relationship_type")?.let(RelationshipType::valueOf),
|
||||||
indexed = getBoolean("indexed"),
|
|
||||||
unique = getBoolean("is_unique"),
|
|
||||||
)
|
)
|
||||||
|
|||||||
@ -60,7 +60,10 @@ class EntityMigrationService(
|
|||||||
val warnings = mutableListOf<String>()
|
val warnings = mutableListOf<String>()
|
||||||
val statements = mutableListOf<MigrationStatement>()
|
val statements = mutableListOf<MigrationStatement>()
|
||||||
val identifierPattern = Regex("[A-Za-z_][A-Za-z0-9_]*")
|
val identifierPattern = Regex("[A-Za-z_][A-Za-z0-9_]*")
|
||||||
val reservedTables = setOf("entity_definitions", "entity_fields", "schema_migrations", "sessions", "users")
|
val reservedTables = setOf(
|
||||||
|
"entity_definitions", "entity_fields", "entity_indexes", "entity_index_fields",
|
||||||
|
"schema_migrations", "sessions", "users",
|
||||||
|
)
|
||||||
val duplicateEntities = definitions.groupBy { it.identifier.lowercase() }.filterValues { it.size > 1 }
|
val duplicateEntities = definitions.groupBy { it.identifier.lowercase() }.filterValues { it.size > 1 }
|
||||||
|
|
||||||
if (definitions.isEmpty()) warnings += "No entity definitions are configured."
|
if (definitions.isEmpty()) warnings += "No entity definitions are configured."
|
||||||
@ -161,20 +164,50 @@ class EntityMigrationService(
|
|||||||
|
|
||||||
definitions.forEach { entity ->
|
definitions.forEach { entity ->
|
||||||
val table = existingTables[entity.identifier.lowercase()] ?: entity.identifier
|
val table = existingTables[entity.identifier.lowercase()] ?: entity.identifier
|
||||||
entity.fields.forEach { field ->
|
val wantedConstraints = entity.indexes.filter { it.type == EntityIndexType.UNIQUE }
|
||||||
val requiresUnique = field.unique || field.relationshipType == RelationshipType.ONE_TO_ONE
|
.associateBy { "em_uq_${entity.id}_${it.id}" }
|
||||||
val uniqueName = "em_uq_${entity.id}_${field.id}"
|
val wantedIndexes = entity.indexes.filter { it.type == EntityIndexType.INDEX }
|
||||||
val indexName = "em_idx_${entity.id}_${field.id}"
|
.associateBy { "em_idx_${entity.id}_${it.id}" }
|
||||||
if (requiresUnique && !constraintExists(connection, uniqueName)) {
|
|
||||||
|
managedConstraints(connection, entity.id).filter { it !in wantedConstraints }.forEach { name ->
|
||||||
|
statements += MigrationStatement(
|
||||||
|
"ALTER TABLE ${quote(table)} DROP CONSTRAINT ${quote(name)}",
|
||||||
|
"Remove obsolete unique constraint $name.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
managedIndexes(connection, entity.id).filter { it !in wantedIndexes }.forEach { name ->
|
||||||
|
statements += MigrationStatement("DROP INDEX ${quote(name)}", "Remove obsolete index $name.")
|
||||||
|
}
|
||||||
|
wantedConstraints.forEach { (name, index) ->
|
||||||
|
if (!constraintExists(connection, name)) {
|
||||||
statements += MigrationStatement(
|
statements += MigrationStatement(
|
||||||
"ALTER TABLE ${quote(table)} ADD CONSTRAINT ${quote(uniqueName)} " +
|
"ALTER TABLE ${quote(table)} ADD CONSTRAINT ${quote(name)} UNIQUE " +
|
||||||
"UNIQUE (${quote(field.identifier)})",
|
"(${index.fields.joinToString { quote(it.identifier) }})",
|
||||||
"Add a unique constraint to ${entity.identifier}.${field.identifier}.",
|
"Add a unique constraint to ${entity.identifier} (${index.fields.joinToString { it.identifier }}).",
|
||||||
)
|
)
|
||||||
} else if (field.indexed && !requiresUnique && !indexExists(connection, indexName)) {
|
}
|
||||||
|
}
|
||||||
|
wantedIndexes.forEach { (name, index) ->
|
||||||
|
if (!indexExists(connection, name)) {
|
||||||
statements += MigrationStatement(
|
statements += MigrationStatement(
|
||||||
"CREATE INDEX ${quote(indexName)} ON ${quote(table)} (${quote(field.identifier)})",
|
"CREATE INDEX ${quote(name)} ON ${quote(table)} (${index.fields.joinToString { quote(it.identifier) }})",
|
||||||
"Index ${entity.identifier}.${field.identifier}.",
|
"Index ${entity.identifier} (${index.fields.joinToString { it.identifier }}).",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val relationshipUniques = entity.fields.filter { it.relationshipType == RelationshipType.ONE_TO_ONE }
|
||||||
|
.associateBy { "em_rel_uq_${entity.id}_${it.id}" }
|
||||||
|
managedConstraints(connection, entity.id, "em_rel_uq").filter { it !in relationshipUniques }.forEach { name ->
|
||||||
|
statements += MigrationStatement(
|
||||||
|
"ALTER TABLE ${quote(table)} DROP CONSTRAINT ${quote(name)}",
|
||||||
|
"Remove obsolete one-to-one constraint $name.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
relationshipUniques.forEach { (name, field) ->
|
||||||
|
if (!constraintExists(connection, name)) {
|
||||||
|
statements += MigrationStatement(
|
||||||
|
"ALTER TABLE ${quote(table)} ADD CONSTRAINT ${quote(name)} UNIQUE (${quote(field.identifier)})",
|
||||||
|
"Limit ${entity.identifier}.${field.identifier} to one reference.",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -237,8 +270,6 @@ class EntityMigrationService(
|
|||||||
type = FieldType.valueOf(result.getString("field_type")),
|
type = FieldType.valueOf(result.getString("field_type")),
|
||||||
targetEntityId = result.getLong("target_entity_id").takeUnless { result.wasNull() },
|
targetEntityId = result.getLong("target_entity_id").takeUnless { result.wasNull() },
|
||||||
relationshipType = result.getString("relationship_type")?.let(RelationshipType::valueOf),
|
relationshipType = result.getString("relationship_type")?.let(RelationshipType::valueOf),
|
||||||
indexed = result.getBoolean("indexed"),
|
|
||||||
unique = result.getBoolean("is_unique"),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -248,13 +279,36 @@ class EntityMigrationService(
|
|||||||
buildList {
|
buildList {
|
||||||
while (result.next()) {
|
while (result.next()) {
|
||||||
val id = result.getLong("id")
|
val id = result.getLong("id")
|
||||||
add(EntityDefinition(id, result.getString("name"), result.getString("identifier"), fields[id].orEmpty()))
|
add(EntityDefinition(id, result.getString("name"), result.getString("identifier"), fields[id].orEmpty(), loadIndexes(connection, id)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun loadIndexes(connection: Connection, entityId: Long): List<EntityIndex> = connection.prepareStatement(
|
||||||
|
"SELECT id, index_type FROM entity_indexes WHERE entity_id = ? ORDER BY id",
|
||||||
|
).use { statement ->
|
||||||
|
statement.setLong(1, entityId)
|
||||||
|
statement.executeQuery().use { result ->
|
||||||
|
buildList {
|
||||||
|
while (result.next()) {
|
||||||
|
val id = result.getLong("id")
|
||||||
|
val indexFields = connection.prepareStatement(
|
||||||
|
"""SELECT f.id, f.name, f.identifier FROM entity_index_fields x
|
||||||
|
JOIN entity_fields f ON f.id = x.field_id WHERE x.index_id = ? ORDER BY x.position""",
|
||||||
|
).use { fieldsStatement ->
|
||||||
|
fieldsStatement.setLong(1, id)
|
||||||
|
fieldsStatement.executeQuery().use { fieldResult ->
|
||||||
|
buildList { while (fieldResult.next()) add(EntityIndexField(fieldResult.getLong(1), fieldResult.getString(2), fieldResult.getString(3))) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
add(EntityIndex(id, EntityIndexType.valueOf(result.getString("index_type")), indexFields))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun dependencyOrder(definitions: List<EntityDefinition>): List<EntityDefinition> {
|
private fun dependencyOrder(definitions: List<EntityDefinition>): List<EntityDefinition> {
|
||||||
val byId = definitions.associateBy(EntityDefinition::id)
|
val byId = definitions.associateBy(EntityDefinition::id)
|
||||||
val visited = mutableSetOf<Long>()
|
val visited = mutableSetOf<Long>()
|
||||||
@ -314,6 +368,20 @@ class EntityMigrationService(
|
|||||||
statement.executeQuery().use { it.next() }
|
statement.executeQuery().use { it.next() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun managedConstraints(connection: Connection, entityId: Long, prefix: String = "em_uq"): Set<String> = connection.prepareStatement(
|
||||||
|
"SELECT constraint_name FROM information_schema.table_constraints WHERE constraint_schema = current_schema() AND constraint_name LIKE ?",
|
||||||
|
).use { statement ->
|
||||||
|
statement.setString(1, "${prefix}_${entityId}_%")
|
||||||
|
statement.executeQuery().use { result -> buildSet { while (result.next()) add(result.getString(1)) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun managedIndexes(connection: Connection, entityId: Long): Set<String> = connection.prepareStatement(
|
||||||
|
"SELECT indexname FROM pg_indexes WHERE schemaname = current_schema() AND indexname LIKE ?",
|
||||||
|
).use { statement ->
|
||||||
|
statement.setString(1, "em_idx_${entityId}_%")
|
||||||
|
statement.executeQuery().use { result -> buildSet { while (result.next()) add(result.getString(1)) } }
|
||||||
|
}
|
||||||
|
|
||||||
private fun sqlType(type: FieldType): String = when (type) {
|
private fun sqlType(type: FieldType): String = when (type) {
|
||||||
FieldType.TEXT -> "TEXT"
|
FieldType.TEXT -> "TEXT"
|
||||||
FieldType.NUMBER -> "NUMERIC"
|
FieldType.NUMBER -> "NUMERIC"
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package dev.mduchene.bolts.web
|
|||||||
import dev.mduchene.bolts.entity.EntityDefinitionRepository
|
import dev.mduchene.bolts.entity.EntityDefinitionRepository
|
||||||
import dev.mduchene.bolts.entity.EntityMigrationService
|
import dev.mduchene.bolts.entity.EntityMigrationService
|
||||||
import dev.mduchene.bolts.entity.FieldType
|
import dev.mduchene.bolts.entity.FieldType
|
||||||
|
import dev.mduchene.bolts.entity.EntityIndexType
|
||||||
import dev.mduchene.bolts.entity.RelationshipType
|
import dev.mduchene.bolts.entity.RelationshipType
|
||||||
import dev.mduchene.bolts.user.LoginService
|
import dev.mduchene.bolts.user.LoginService
|
||||||
import io.javalin.http.Context
|
import io.javalin.http.Context
|
||||||
@ -75,8 +76,6 @@ class EntityDefinitionController(
|
|||||||
type,
|
type,
|
||||||
relationship.targetEntityId,
|
relationship.targetEntityId,
|
||||||
relationship.type,
|
relationship.type,
|
||||||
ctx.booleanFormParam("indexed"),
|
|
||||||
ctx.booleanFormParam("unique"),
|
|
||||||
)
|
)
|
||||||
if (field == null) ctx.notFound() else {
|
if (field == null) ctx.notFound() else {
|
||||||
ctx.status(HttpStatus.CREATED).json(field)
|
ctx.status(HttpStatus.CREATED).json(field)
|
||||||
@ -105,8 +104,6 @@ class EntityDefinitionController(
|
|||||||
type,
|
type,
|
||||||
relationship.targetEntityId,
|
relationship.targetEntityId,
|
||||||
relationship.type,
|
relationship.type,
|
||||||
ctx.booleanFormParam("indexed"),
|
|
||||||
ctx.booleanFormParam("unique"),
|
|
||||||
)
|
)
|
||||||
if (field == null) ctx.notFound() else ctx.json(field)
|
if (field == null) ctx.notFound() else ctx.json(field)
|
||||||
}
|
}
|
||||||
@ -120,6 +117,26 @@ class EntityDefinitionController(
|
|||||||
ctx.status(HttpStatus.NO_CONTENT)
|
ctx.status(HttpStatus.NO_CONTENT)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
routes.post("/api/entity-definitions/{entityId}/indexes") { ctx ->
|
||||||
|
if (!ctx.requireAdmin(loginService)) return@post
|
||||||
|
val entityId = ctx.pathParam("entityId").toLongOrNull()
|
||||||
|
val type = ctx.formParam("type")?.let(EntityIndexType::from)
|
||||||
|
val fieldIds = ctx.formParam("fieldIds")?.split(',')?.mapNotNull { it.toLongOrNull() }?.distinct().orEmpty()
|
||||||
|
if (entityId == null || type == null || fieldIds.isEmpty()) {
|
||||||
|
ctx.badRequest("An index type and at least one field are required")
|
||||||
|
return@post
|
||||||
|
}
|
||||||
|
val index = entities.createIndex(entityId, type, fieldIds)
|
||||||
|
if (index == null) ctx.badRequest("Every selected field must belong to the entity")
|
||||||
|
else ctx.status(HttpStatus.CREATED).json(index)
|
||||||
|
}
|
||||||
|
routes.delete("/api/entity-definitions/{entityId}/indexes/{indexId}") { ctx ->
|
||||||
|
if (!ctx.requireAdmin(loginService)) return@delete
|
||||||
|
val entityId = ctx.pathParam("entityId").toLongOrNull()
|
||||||
|
val indexId = ctx.pathParam("indexId").toLongOrNull()
|
||||||
|
if (entityId == null || indexId == null || !entities.deleteIndex(entityId, indexId)) ctx.notFound()
|
||||||
|
else ctx.status(HttpStatus.NO_CONTENT)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Context.relationshipDetails(fieldType: FieldType?): RelationshipDetails? {
|
private fun Context.relationshipDetails(fieldType: FieldType?): RelationshipDetails? {
|
||||||
@ -143,6 +160,4 @@ class EntityDefinitionController(
|
|||||||
val type: RelationshipType?,
|
val type: RelationshipType?,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun Context.booleanFormParam(name: String): Boolean =
|
|
||||||
formParam(name)?.equals("true", ignoreCase = true) == true
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,33 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS entity_indexes (
|
||||||
|
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
entity_id BIGINT NOT NULL REFERENCES entity_definitions(id) ON DELETE CASCADE,
|
||||||
|
index_type VARCHAR(16) NOT NULL CHECK (index_type IN ('INDEX', 'UNIQUE'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS entity_index_fields (
|
||||||
|
index_id BIGINT NOT NULL REFERENCES entity_indexes(id) ON DELETE CASCADE,
|
||||||
|
field_id BIGINT NOT NULL REFERENCES entity_fields(id) ON DELETE CASCADE,
|
||||||
|
position INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (index_id, field_id),
|
||||||
|
UNIQUE (index_id, position)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Preserve existing single-field configuration in the new grouped model.
|
||||||
|
INSERT INTO entity_indexes (entity_id, index_type)
|
||||||
|
SELECT entity_id, CASE WHEN is_unique THEN 'UNIQUE' ELSE 'INDEX' END
|
||||||
|
FROM entity_fields
|
||||||
|
WHERE indexed OR is_unique
|
||||||
|
ORDER BY id;
|
||||||
|
|
||||||
|
WITH legacy_fields AS (
|
||||||
|
SELECT id, entity_id, CASE WHEN is_unique THEN 'UNIQUE' ELSE 'INDEX' END AS index_type,
|
||||||
|
row_number() OVER (PARTITION BY entity_id, CASE WHEN is_unique THEN 'UNIQUE' ELSE 'INDEX' END ORDER BY id) AS item
|
||||||
|
FROM entity_fields WHERE indexed OR is_unique
|
||||||
|
), legacy_indexes AS (
|
||||||
|
SELECT id, entity_id, index_type,
|
||||||
|
row_number() OVER (PARTITION BY entity_id, index_type ORDER BY id) AS item
|
||||||
|
FROM entity_indexes
|
||||||
|
)
|
||||||
|
INSERT INTO entity_index_fields (index_id, field_id, position)
|
||||||
|
SELECT i.id, f.id, 0 FROM legacy_fields f
|
||||||
|
JOIN legacy_indexes i USING (entity_id, index_type, item);
|
||||||
@ -1,3 +1,5 @@
|
|||||||
|
DROP TABLE IF EXISTS entity_index_fields;
|
||||||
|
DROP TABLE IF EXISTS entity_indexes;
|
||||||
DROP TABLE IF EXISTS entity_fields;
|
DROP TABLE IF EXISTS entity_fields;
|
||||||
DROP TABLE IF EXISTS entity_definitions;
|
DROP TABLE IF EXISTS entity_definitions;
|
||||||
DROP TABLE IF EXISTS sessions;
|
DROP TABLE IF EXISTS sessions;
|
||||||
|
|||||||
@ -42,7 +42,7 @@ class ResponseSerializationTest {
|
|||||||
)
|
)
|
||||||
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
"""{"id":1,"name":"Company","identifier":"company","fields":[{"id":2,"name":"Annual revenue","identifier":"annualRevenue","type":"NUMBER","targetEntityId":null,"targetEntityName":null,"relationshipType":null,"indexed":false,"unique":false}]}""",
|
"""{"id":1,"name":"Company","identifier":"company","fields":[{"id":2,"name":"Annual revenue","identifier":"annualRevenue","type":"NUMBER","targetEntityId":null,"targetEntityName":null,"relationshipType":null}],"indexes":[]}""",
|
||||||
mapper.writeValueAsString(entity),
|
mapper.writeValueAsString(entity),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user