add migration system
This commit is contained in:
parent
0eb488be2c
commit
1ec9f7e8e7
@ -31,6 +31,20 @@
|
|||||||
fields: EntityField[]
|
fields: EntityField[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MigrationValidation {
|
||||||
|
valid: boolean
|
||||||
|
errors: string[]
|
||||||
|
warnings: string[]
|
||||||
|
actions: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MigrationResult {
|
||||||
|
success: boolean
|
||||||
|
message: string
|
||||||
|
actions: string[]
|
||||||
|
errors: string[]
|
||||||
|
}
|
||||||
|
|
||||||
const entities = ref<EntityDefinition[]>([])
|
const entities = ref<EntityDefinition[]>([])
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
@ -47,6 +61,10 @@
|
|||||||
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 validatingMigration = ref(false)
|
||||||
|
const runningMigration = ref(false)
|
||||||
|
const migrationValidation = ref<MigrationValidation>()
|
||||||
|
const migrationResult = ref<MigrationResult>()
|
||||||
|
|
||||||
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, {
|
||||||
@ -223,6 +241,8 @@
|
|||||||
errorMessage.value = ''
|
errorMessage.value = ''
|
||||||
try {
|
try {
|
||||||
await action()
|
await action()
|
||||||
|
migrationValidation.value = undefined
|
||||||
|
migrationResult.value = undefined
|
||||||
} catch {
|
} catch {
|
||||||
errorMessage.value = 'The change could not be saved. Please try again.'
|
errorMessage.value = 'The change could not be saved. Please try again.'
|
||||||
} finally {
|
} finally {
|
||||||
@ -230,6 +250,45 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function validateMigration () {
|
||||||
|
validatingMigration.value = true
|
||||||
|
migrationResult.value = undefined
|
||||||
|
errorMessage.value = ''
|
||||||
|
try {
|
||||||
|
const response = await request('/api/entity-definitions/migration/validate', 'POST')
|
||||||
|
if (!response.ok) throw new Error('Migration validation failed')
|
||||||
|
migrationValidation.value = await response.json() as MigrationValidation
|
||||||
|
} catch {
|
||||||
|
errorMessage.value = 'Unable to validate the entity migration.'
|
||||||
|
} finally {
|
||||||
|
validatingMigration.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runMigration () {
|
||||||
|
if (!migrationValidation.value?.valid) return
|
||||||
|
runningMigration.value = true
|
||||||
|
migrationResult.value = undefined
|
||||||
|
errorMessage.value = ''
|
||||||
|
try {
|
||||||
|
const response = await request('/api/entity-definitions/migration/run', 'POST')
|
||||||
|
const result = await response.json() as MigrationResult
|
||||||
|
migrationResult.value = result
|
||||||
|
if (!response.ok && result.errors.length === 0) {
|
||||||
|
result.errors = ['The migration could not be completed.']
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
migrationResult.value = {
|
||||||
|
success: false,
|
||||||
|
message: 'Entity migration failed. No database changes were kept.',
|
||||||
|
actions: [],
|
||||||
|
errors: ['The server did not return migration details.'],
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
runningMigration.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function typeLabel (type: FieldType) {
|
function typeLabel (type: FieldType) {
|
||||||
if (type === 'RELATIONSHIP') return 'Relationship'
|
if (type === 'RELATIONSHIP') return 'Relationship'
|
||||||
if (type === 'USER') return 'User reference'
|
if (type === 'USER') return 'User reference'
|
||||||
@ -254,7 +313,7 @@
|
|||||||
<header>
|
<header>
|
||||||
<p class="eyebrow">Administration</p>
|
<p class="eyebrow">Administration</p>
|
||||||
<h1>Entity Configuration</h1>
|
<h1>Entity Configuration</h1>
|
||||||
<p>Define the entities and fields your CRM will use. This configuration does not create database tables yet.</p>
|
<p>Define the entities and fields your CRM will use, then validate and apply the database changes.</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<form aria-label="Add entity" class="add-entity" @submit.prevent="addEntity">
|
<form aria-label="Add entity" class="add-entity" @submit.prevent="addEntity">
|
||||||
@ -276,6 +335,74 @@
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p v-if="errorMessage" class="error" role="alert">{{ errorMessage }}</p>
|
<p v-if="errorMessage" class="error" role="alert">{{ errorMessage }}</p>
|
||||||
|
|
||||||
|
<section aria-labelledby="migration-heading" class="migration-panel">
|
||||||
|
<div>
|
||||||
|
<h2 id="migration-heading">Database migration</h2>
|
||||||
|
<p>Validate the configuration to preview the tables, fields, and relationships that will be created.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="migration-actions">
|
||||||
|
<button
|
||||||
|
class="secondary-button"
|
||||||
|
:disabled="saving || validatingMigration || runningMigration"
|
||||||
|
type="button"
|
||||||
|
@click="validateMigration"
|
||||||
|
>
|
||||||
|
{{ validatingMigration ? 'Validating…' : 'Validate migration' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="primary-button"
|
||||||
|
:disabled="!migrationValidation?.valid || validatingMigration || runningMigration"
|
||||||
|
type="button"
|
||||||
|
@click="runMigration"
|
||||||
|
>
|
||||||
|
{{ runningMigration ? 'Running migration…' : 'Run migration' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="migrationValidation"
|
||||||
|
class="migration-report"
|
||||||
|
:class="migrationValidation.valid ? 'valid' : 'invalid'"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
<strong>{{ migrationValidation.valid ? 'Validation passed' : 'Validation failed' }}</strong>
|
||||||
|
|
||||||
|
<ul v-if="migrationValidation.errors.length > 0" class="error-list">
|
||||||
|
<li v-for="item in migrationValidation.errors" :key="item">{{ item }}</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<ul v-if="migrationValidation.warnings.length > 0" class="warning-list">
|
||||||
|
<li v-for="item in migrationValidation.warnings" :key="item">{{ item }}</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<template v-if="migrationValidation.actions.length > 0">
|
||||||
|
<p>Planned changes</p>
|
||||||
|
|
||||||
|
<ol>
|
||||||
|
<li v-for="item in migrationValidation.actions" :key="item">{{ item }}</li>
|
||||||
|
</ol>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<p v-else-if="migrationValidation.valid">The database is already up to date.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="migrationResult"
|
||||||
|
class="migration-report"
|
||||||
|
:class="migrationResult.success ? 'valid' : 'invalid'"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<strong>{{ migrationResult.message }}</strong>
|
||||||
|
|
||||||
|
<ul v-if="migrationResult.errors.length > 0" class="error-list">
|
||||||
|
<li v-for="item in migrationResult.errors" :key="item">{{ item }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<p v-if="loading" class="status">Loading configuration…</p>
|
<p v-if="loading" class="status">Loading configuration…</p>
|
||||||
<p v-else-if="entities.length === 0" class="status">No entities yet. Add your first entity above.</p>
|
<p v-else-if="entities.length === 0" class="status">No entities yet. Add your first entity above.</p>
|
||||||
|
|
||||||
@ -437,6 +564,17 @@
|
|||||||
.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; }
|
||||||
.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 h2 { margin: 0; font-size: 1.15rem; }
|
||||||
|
.migration-panel > div > p { margin: 0.35rem 0 0; color: var(--v0-on-surface-variant); }
|
||||||
|
.migration-actions { display: flex; gap: 0.65rem; margin-top: 1rem; }
|
||||||
|
.migration-report { margin-top: 1rem; padding: 1rem; border-radius: 0.7rem; line-height: 1.5; }
|
||||||
|
.migration-report.valid { border: 1px solid #2e7d32; background: color-mix(in srgb, #2e7d32 10%, transparent); }
|
||||||
|
.migration-report.invalid { border: 1px solid var(--v0-error); background: color-mix(in srgb, var(--v0-error) 9%, transparent); }
|
||||||
|
.migration-report ul, .migration-report ol { margin: 0.55rem 0 0; padding-left: 1.3rem; }
|
||||||
|
.migration-report > p { margin: 0.65rem 0 0; font-weight: 600; }
|
||||||
|
.error-list { color: var(--v0-error); }
|
||||||
|
.warning-list { color: var(--v0-on-surface-variant); }
|
||||||
.status { padding: 1.5rem; border: 1px dashed var(--v0-divider); border-radius: 0.8rem; color: var(--v0-on-surface-variant); }
|
.status { padding: 1.5rem; border: 1px dashed var(--v0-divider); border-radius: 0.8rem; color: var(--v0-on-surface-variant); }
|
||||||
.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) {
|
||||||
@ -444,5 +582,6 @@
|
|||||||
.form-row, .field-inputs, .edit-row, .edit-field { align-items: stretch; flex-direction: column; }
|
.form-row, .field-inputs, .edit-row, .edit-field { align-items: stretch; flex-direction: column; }
|
||||||
.entity-heading, .field-row { padding-block: 1rem; }
|
.entity-heading, .field-row { padding-block: 1rem; }
|
||||||
input, select, .primary-button, .secondary-button { width: 100%; }
|
input, select, .primary-button, .secondary-button { width: 100%; }
|
||||||
|
.migration-actions { align-items: stretch; flex-direction: column; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -168,3 +168,45 @@ test('admin can add, edit, and remove an entity and its fields', async ({ page }
|
|||||||
await contact.getByRole('button', { name: 'Remove entity' }).click()
|
await contact.getByRole('button', { name: 'Remove entity' }).click()
|
||||||
await expect(page.getByText('No entities yet.')).toBeVisible()
|
await expect(page.getByText('No entities yet.')).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('admin validates before running a migration and sees migration failures', async ({ page }) => {
|
||||||
|
await useAdminSession(page)
|
||||||
|
await page.route('**/api/entity-definitions', route => route.fulfill({
|
||||||
|
json: [{
|
||||||
|
id: 1,
|
||||||
|
name: 'Company',
|
||||||
|
identifier: 'company',
|
||||||
|
fields: [{ id: 1, name: 'Name', identifier: 'name', type: 'TEXT' }],
|
||||||
|
}],
|
||||||
|
}))
|
||||||
|
await page.route('**/api/entity-definitions/migration/validate', route => route.fulfill({
|
||||||
|
json: {
|
||||||
|
valid: true,
|
||||||
|
errors: [],
|
||||||
|
warnings: ['Table company already exists; missing fields will be added with ALTER TABLE.'],
|
||||||
|
actions: ['Add company.name (TEXT).'],
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
await page.route('**/api/entity-definitions/migration/run', route => route.fulfill({
|
||||||
|
status: 400,
|
||||||
|
json: {
|
||||||
|
success: false,
|
||||||
|
message: 'Entity migration failed and was rolled back.',
|
||||||
|
actions: [],
|
||||||
|
errors: ['permission denied for table company'],
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
await page.goto('/admin/entities')
|
||||||
|
const runButton = page.getByRole('button', { name: 'Run migration' })
|
||||||
|
await expect(runButton).toBeDisabled()
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Validate migration' }).click()
|
||||||
|
await expect(page.getByText('Validation passed')).toBeVisible()
|
||||||
|
await expect(page.getByText('Add company.name (TEXT).')).toBeVisible()
|
||||||
|
await expect(runButton).toBeEnabled()
|
||||||
|
|
||||||
|
await runButton.click()
|
||||||
|
await expect(page.getByText('Entity migration failed and was rolled back.')).toBeVisible()
|
||||||
|
await expect(page.getByText('permission denied for table company')).toBeVisible()
|
||||||
|
})
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import dev.mduchene.bolts.entity.EntityDefinitionRepository
|
import dev.mduchene.bolts.entity.EntityDefinitionRepository
|
||||||
|
import dev.mduchene.bolts.entity.EntityMigrationService
|
||||||
import dev.mduchene.bolts.persistence.Database
|
import dev.mduchene.bolts.persistence.Database
|
||||||
import dev.mduchene.bolts.persistence.DatabaseConfig
|
import dev.mduchene.bolts.persistence.DatabaseConfig
|
||||||
import dev.mduchene.bolts.user.LoginService
|
import dev.mduchene.bolts.user.LoginService
|
||||||
@ -19,11 +20,12 @@ fun main() {
|
|||||||
val loginService = LoginService(users, SessionRepository(database))
|
val loginService = LoginService(users, SessionRepository(database))
|
||||||
loginService.initializeAdmin()
|
loginService.initializeAdmin()
|
||||||
|
|
||||||
|
val entities = EntityDefinitionRepository(database)
|
||||||
val controllers = listOf(
|
val controllers = listOf(
|
||||||
HomeController(),
|
HomeController(),
|
||||||
AuthController(loginService),
|
AuthController(loginService),
|
||||||
UserController(users, loginService),
|
UserController(users, loginService),
|
||||||
EntityDefinitionController(EntityDefinitionRepository(database), loginService),
|
EntityDefinitionController(entities, loginService, EntityMigrationService(database, entities)),
|
||||||
)
|
)
|
||||||
|
|
||||||
Javalin.create { config ->
|
Javalin.create { config ->
|
||||||
|
|||||||
@ -0,0 +1,293 @@
|
|||||||
|
package dev.mduchene.bolts.entity
|
||||||
|
|
||||||
|
import dev.mduchene.bolts.persistence.Database
|
||||||
|
import java.sql.Connection
|
||||||
|
|
||||||
|
data class EntityMigrationValidation(
|
||||||
|
val valid: Boolean,
|
||||||
|
val errors: List<String>,
|
||||||
|
val warnings: List<String>,
|
||||||
|
val actions: List<String>,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class EntityMigrationResult(
|
||||||
|
val success: Boolean,
|
||||||
|
val message: String,
|
||||||
|
val actions: List<String>,
|
||||||
|
val errors: List<String> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
class EntityMigrationService(
|
||||||
|
private val database: Database,
|
||||||
|
private val entities: EntityDefinitionRepository,
|
||||||
|
) {
|
||||||
|
fun validate(): EntityMigrationValidation =
|
||||||
|
database.getConnection().use { connection -> plan(connection, entities.findAll()).validation }
|
||||||
|
|
||||||
|
fun migrate(): EntityMigrationResult =
|
||||||
|
try {
|
||||||
|
database.transaction { connection ->
|
||||||
|
val plan = plan(connection, loadEntities(connection))
|
||||||
|
if (!plan.validation.valid) {
|
||||||
|
return@transaction EntityMigrationResult(
|
||||||
|
success = false,
|
||||||
|
message = "Validation failed; no database changes were made.",
|
||||||
|
actions = plan.validation.actions,
|
||||||
|
errors = plan.validation.errors,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
connection.createStatement().use { statement ->
|
||||||
|
plan.statements.forEach { statement.execute(it.sql) }
|
||||||
|
}
|
||||||
|
EntityMigrationResult(
|
||||||
|
success = true,
|
||||||
|
message = "Entity migration completed successfully.",
|
||||||
|
actions = plan.validation.actions,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
EntityMigrationResult(
|
||||||
|
success = false,
|
||||||
|
message = "Entity migration failed and was rolled back.",
|
||||||
|
actions = emptyList(),
|
||||||
|
errors = listOf(exception.message ?: "Database migration failed"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun plan(connection: Connection, definitions: List<EntityDefinition>): MigrationPlan {
|
||||||
|
val errors = mutableListOf<String>()
|
||||||
|
val warnings = mutableListOf<String>()
|
||||||
|
val statements = mutableListOf<MigrationStatement>()
|
||||||
|
val identifierPattern = Regex("[A-Za-z_][A-Za-z0-9_]*")
|
||||||
|
val reservedTables = setOf("entity_definitions", "entity_fields", "schema_migrations", "sessions", "users")
|
||||||
|
val duplicateEntities = definitions.groupBy { it.identifier.lowercase() }.filterValues { it.size > 1 }
|
||||||
|
|
||||||
|
if (definitions.isEmpty()) warnings += "No entity definitions are configured."
|
||||||
|
duplicateEntities.values.forEach {
|
||||||
|
errors += "Entities ${it.joinToString { entity -> "“${entity.name}”" }} use the same identifier."
|
||||||
|
}
|
||||||
|
|
||||||
|
definitions.forEach { entity ->
|
||||||
|
if (!identifierPattern.matches(entity.identifier)) {
|
||||||
|
errors += "Entity “${entity.name}” has an invalid SQL identifier: ${entity.identifier}."
|
||||||
|
}
|
||||||
|
if (entity.identifier.lowercase() in reservedTables) {
|
||||||
|
errors += "Entity “${entity.name}” uses the reserved application table ${entity.identifier}."
|
||||||
|
}
|
||||||
|
entity.fields.groupBy { it.identifier.lowercase() }.filterValues { it.size > 1 }.values.forEach {
|
||||||
|
errors += "Entity “${entity.name}” has duplicate field identifier ${it.first().identifier}."
|
||||||
|
}
|
||||||
|
entity.fields.forEach { field ->
|
||||||
|
if (!identifierPattern.matches(field.identifier)) {
|
||||||
|
errors += "Field “${entity.name}.${field.name}” has an invalid SQL identifier: ${field.identifier}."
|
||||||
|
}
|
||||||
|
if (field.identifier.equals("id", ignoreCase = true)) {
|
||||||
|
errors += "Field “${entity.name}.${field.name}” cannot use the reserved identifier id."
|
||||||
|
}
|
||||||
|
if (field.type == FieldType.RELATIONSHIP) {
|
||||||
|
if (field.targetEntityId == null || definitions.none { it.id == field.targetEntityId }) {
|
||||||
|
errors += "Relationship “${entity.name}.${field.name}” has no valid target entity."
|
||||||
|
}
|
||||||
|
if (field.relationshipType == null) {
|
||||||
|
errors += "Relationship “${entity.name}.${field.name}” has no relationship type."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.isNotEmpty()) return migrationPlan(errors, warnings, statements)
|
||||||
|
|
||||||
|
val existingTables = tableNames(connection)
|
||||||
|
val ordered = dependencyOrder(definitions)
|
||||||
|
|
||||||
|
ordered.forEach { entity ->
|
||||||
|
if (entity.identifier.lowercase() !in existingTables) {
|
||||||
|
statements += MigrationStatement(
|
||||||
|
"""CREATE TABLE ${quote(entity.identifier)} ("id" BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY)""",
|
||||||
|
"Create table ${entity.identifier}.",
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
warnings += "Table ${entity.identifier} already exists; missing fields will be added with ALTER TABLE."
|
||||||
|
val idType = columnTypes(connection, entity.identifier)["id"]
|
||||||
|
if (idType == null) errors += "Existing table ${entity.identifier} has no id column."
|
||||||
|
else if (idType !in setOf("bigint", "integer", "smallint")) {
|
||||||
|
errors += "Existing table ${entity.identifier} has an incompatible id column ($idType)."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
definitions.forEach { entity ->
|
||||||
|
val columns = if (entity.identifier.lowercase() in existingTables) {
|
||||||
|
columnTypes(connection, entity.identifier)
|
||||||
|
} else {
|
||||||
|
emptyMap()
|
||||||
|
}
|
||||||
|
entity.fields.forEach { field ->
|
||||||
|
val expected = sqlType(field.type)
|
||||||
|
val actual = columns[field.identifier.lowercase()]
|
||||||
|
if (actual == null) {
|
||||||
|
val table = existingTables[entity.identifier.lowercase()] ?: entity.identifier
|
||||||
|
statements += MigrationStatement(
|
||||||
|
"ALTER TABLE ${quote(table)} ADD COLUMN ${quote(field.identifier)} $expected",
|
||||||
|
"Add ${entity.identifier}.${field.identifier} ($expected).",
|
||||||
|
)
|
||||||
|
} else if (!compatible(field.type, actual)) {
|
||||||
|
errors += "Existing column ${entity.identifier}.${field.identifier} is $actual, but ${field.type} requires $expected."
|
||||||
|
} else {
|
||||||
|
warnings += "Column ${entity.identifier}.${field.identifier} already exists and will be left unchanged."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
definitions.forEach { entity ->
|
||||||
|
entity.fields.filter { it.type == FieldType.RELATIONSHIP || it.type == FieldType.USER }.forEach { field ->
|
||||||
|
val target = if (field.type == FieldType.USER) "users" else {
|
||||||
|
definitions.first { it.id == field.targetEntityId }.identifier.let {
|
||||||
|
existingTables[it.lowercase()] ?: it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val table = existingTables[entity.identifier.lowercase()] ?: entity.identifier
|
||||||
|
val constraint = "em_fk_${entity.id}_${field.id}"
|
||||||
|
if (!constraintExists(connection, constraint)) {
|
||||||
|
statements += MigrationStatement(
|
||||||
|
"ALTER TABLE ${quote(table)} ADD CONSTRAINT ${quote(constraint)} " +
|
||||||
|
"FOREIGN KEY (${quote(field.identifier)}) REFERENCES ${quote(target)} (\"id\")",
|
||||||
|
"Add reference ${entity.identifier}.${field.identifier} → $target.id.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (field.relationshipType == RelationshipType.ONE_TO_ONE) {
|
||||||
|
val unique = "em_uq_${entity.id}_${field.id}"
|
||||||
|
if (!constraintExists(connection, unique)) {
|
||||||
|
statements += MigrationStatement(
|
||||||
|
"ALTER TABLE ${quote(table)} ADD CONSTRAINT ${quote(unique)} " +
|
||||||
|
"UNIQUE (${quote(field.identifier)})",
|
||||||
|
"Make ${entity.identifier}.${field.identifier} one-to-one.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return migrationPlan(errors, warnings, statements)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadEntities(connection: Connection): List<EntityDefinition> {
|
||||||
|
val fields = mutableMapOf<Long, MutableList<EntityField>>()
|
||||||
|
connection.prepareStatement(
|
||||||
|
"""
|
||||||
|
SELECT id, entity_id, name, identifier, field_type, target_entity_id, relationship_type
|
||||||
|
FROM entity_fields ORDER BY id
|
||||||
|
""".trimIndent(),
|
||||||
|
).use { statement ->
|
||||||
|
statement.executeQuery().use { result ->
|
||||||
|
while (result.next()) {
|
||||||
|
val entityId = result.getLong("entity_id")
|
||||||
|
fields.getOrPut(entityId, ::mutableListOf) += EntityField(
|
||||||
|
id = result.getLong("id"),
|
||||||
|
name = result.getString("name"),
|
||||||
|
identifier = result.getString("identifier"),
|
||||||
|
type = FieldType.valueOf(result.getString("field_type")),
|
||||||
|
targetEntityId = result.getLong("target_entity_id").takeUnless { result.wasNull() },
|
||||||
|
relationshipType = result.getString("relationship_type")?.let(RelationshipType::valueOf),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return connection.prepareStatement("SELECT id, name, identifier FROM entity_definitions ORDER BY id").use { statement ->
|
||||||
|
statement.executeQuery().use { result ->
|
||||||
|
buildList {
|
||||||
|
while (result.next()) {
|
||||||
|
val id = result.getLong("id")
|
||||||
|
add(EntityDefinition(id, result.getString("name"), result.getString("identifier"), fields[id].orEmpty()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun dependencyOrder(definitions: List<EntityDefinition>): List<EntityDefinition> {
|
||||||
|
val byId = definitions.associateBy(EntityDefinition::id)
|
||||||
|
val visited = mutableSetOf<Long>()
|
||||||
|
val visiting = mutableSetOf<Long>()
|
||||||
|
val result = mutableListOf<EntityDefinition>()
|
||||||
|
fun visit(entity: EntityDefinition) {
|
||||||
|
if (entity.id in visited || !visiting.add(entity.id)) return
|
||||||
|
entity.fields.mapNotNull { it.targetEntityId }.mapNotNull(byId::get).forEach(::visit)
|
||||||
|
visiting.remove(entity.id)
|
||||||
|
visited += entity.id
|
||||||
|
result += entity
|
||||||
|
}
|
||||||
|
definitions.forEach(::visit)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tableNames(connection: Connection): Map<String, String> =
|
||||||
|
connection.prepareStatement(
|
||||||
|
"SELECT table_name FROM information_schema.tables WHERE table_schema = current_schema()",
|
||||||
|
).use { statement ->
|
||||||
|
statement.executeQuery().use { result ->
|
||||||
|
buildMap {
|
||||||
|
while (result.next()) {
|
||||||
|
val name = result.getString(1)
|
||||||
|
put(name.lowercase(), name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun columnTypes(connection: Connection, table: String): Map<String, String> =
|
||||||
|
connection.prepareStatement(
|
||||||
|
"""
|
||||||
|
SELECT column_name, data_type FROM information_schema.columns
|
||||||
|
WHERE table_schema = current_schema() AND lower(table_name) = lower(?)
|
||||||
|
""".trimIndent(),
|
||||||
|
).use { statement ->
|
||||||
|
statement.setString(1, table)
|
||||||
|
statement.executeQuery().use { result ->
|
||||||
|
buildMap { while (result.next()) put(result.getString(1).lowercase(), result.getString(2).lowercase()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun constraintExists(connection: Connection, name: String): Boolean =
|
||||||
|
connection.prepareStatement(
|
||||||
|
"SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema = current_schema() AND constraint_name = ?",
|
||||||
|
).use { statement ->
|
||||||
|
statement.setString(1, name)
|
||||||
|
statement.executeQuery().use { it.next() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sqlType(type: FieldType): String = when (type) {
|
||||||
|
FieldType.TEXT -> "TEXT"
|
||||||
|
FieldType.NUMBER -> "NUMERIC"
|
||||||
|
FieldType.BOOLEAN -> "BOOLEAN"
|
||||||
|
FieldType.DATE -> "DATE"
|
||||||
|
FieldType.EMAIL, FieldType.PHONE -> "VARCHAR(255)"
|
||||||
|
FieldType.RELATIONSHIP, FieldType.USER -> "BIGINT"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun compatible(type: FieldType, actual: String): Boolean = when (type) {
|
||||||
|
FieldType.TEXT, FieldType.EMAIL, FieldType.PHONE -> actual in setOf("text", "character varying", "character")
|
||||||
|
FieldType.NUMBER -> actual in setOf("numeric", "decimal", "real", "double precision", "bigint", "integer", "smallint")
|
||||||
|
FieldType.BOOLEAN -> actual == "boolean"
|
||||||
|
FieldType.DATE -> actual == "date"
|
||||||
|
FieldType.RELATIONSHIP, FieldType.USER -> actual in setOf("bigint", "integer", "smallint")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun quote(identifier: String) = "\"${identifier.replace("\"", "\"\"")}\""
|
||||||
|
|
||||||
|
private fun migrationPlan(
|
||||||
|
errors: List<String>,
|
||||||
|
warnings: List<String>,
|
||||||
|
statements: List<MigrationStatement>,
|
||||||
|
) = MigrationPlan(
|
||||||
|
EntityMigrationValidation(errors.isEmpty(), errors, warnings, statements.map(MigrationStatement::description)),
|
||||||
|
statements,
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class MigrationStatement(val sql: String, val description: String)
|
||||||
|
private data class MigrationPlan(
|
||||||
|
val validation: EntityMigrationValidation,
|
||||||
|
val statements: List<MigrationStatement>,
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -45,6 +45,17 @@ class Database(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun <T> transaction(action: (Connection) -> T): T =
|
||||||
|
getConnection().use { connection ->
|
||||||
|
connection.autoCommit = false
|
||||||
|
try {
|
||||||
|
action(connection).also { connection.commit() }
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
connection.rollback()
|
||||||
|
throw exception
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun <T> query(
|
private fun <T> query(
|
||||||
sql: String,
|
sql: String,
|
||||||
bind: PreparedStatement.() -> Unit,
|
bind: PreparedStatement.() -> Unit,
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
package dev.mduchene.bolts.web
|
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.FieldType
|
import dev.mduchene.bolts.entity.FieldType
|
||||||
import dev.mduchene.bolts.entity.RelationshipType
|
import dev.mduchene.bolts.entity.RelationshipType
|
||||||
import dev.mduchene.bolts.user.LoginService
|
import dev.mduchene.bolts.user.LoginService
|
||||||
@ -11,12 +12,23 @@ import io.javalin.router.JavalinDefaultRoutingApi
|
|||||||
class EntityDefinitionController(
|
class EntityDefinitionController(
|
||||||
private val entities: EntityDefinitionRepository,
|
private val entities: EntityDefinitionRepository,
|
||||||
private val loginService: LoginService,
|
private val loginService: LoginService,
|
||||||
|
private val migrations: EntityMigrationService,
|
||||||
) : Controller {
|
) : Controller {
|
||||||
override fun register(routes: JavalinDefaultRoutingApi) {
|
override fun register(routes: JavalinDefaultRoutingApi) {
|
||||||
routes.get("/api/entity-definitions") { ctx ->
|
routes.get("/api/entity-definitions") { ctx ->
|
||||||
if (!ctx.requireAdmin(loginService)) return@get
|
if (!ctx.requireAdmin(loginService)) return@get
|
||||||
ctx.json(entities.findAll())
|
ctx.json(entities.findAll())
|
||||||
}
|
}
|
||||||
|
routes.post("/api/entity-definitions/migration/validate") { ctx ->
|
||||||
|
if (!ctx.requireAdmin(loginService)) return@post
|
||||||
|
ctx.json(migrations.validate())
|
||||||
|
}
|
||||||
|
routes.post("/api/entity-definitions/migration/run") { ctx ->
|
||||||
|
if (!ctx.requireAdmin(loginService)) return@post
|
||||||
|
val result = migrations.migrate()
|
||||||
|
if (!result.success) ctx.status(HttpStatus.BAD_REQUEST)
|
||||||
|
ctx.json(result)
|
||||||
|
}
|
||||||
routes.post("/api/entity-definitions") { ctx ->
|
routes.post("/api/entity-definitions") { ctx ->
|
||||||
if (!ctx.requireAdmin(loginService)) return@post
|
if (!ctx.requireAdmin(loginService)) return@post
|
||||||
val name = ctx.requiredFormParam("name") ?: return@post
|
val name = ctx.requiredFormParam("name") ?: return@post
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user