add migration system
This commit is contained in:
parent
0eb488be2c
commit
1ec9f7e8e7
@ -31,6 +31,20 @@
|
||||
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 loading = ref(true)
|
||||
const saving = ref(false)
|
||||
@ -47,6 +61,10 @@
|
||||
const editingFieldType = ref<FieldType>('TEXT')
|
||||
const editingTargetEntityId = ref<number>()
|
||||
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>) {
|
||||
return fetch(url, {
|
||||
@ -223,6 +241,8 @@
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
await action()
|
||||
migrationValidation.value = undefined
|
||||
migrationResult.value = undefined
|
||||
} catch {
|
||||
errorMessage.value = 'The change could not be saved. Please try again.'
|
||||
} 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) {
|
||||
if (type === 'RELATIONSHIP') return 'Relationship'
|
||||
if (type === 'USER') return 'User reference'
|
||||
@ -254,7 +313,7 @@
|
||||
<header>
|
||||
<p class="eyebrow">Administration</p>
|
||||
<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>
|
||||
|
||||
<form aria-label="Add entity" class="add-entity" @submit.prevent="addEntity">
|
||||
@ -276,6 +335,74 @@
|
||||
</form>
|
||||
|
||||
<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-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); }
|
||||
.field-inputs input { flex: 1; }
|
||||
.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); }
|
||||
.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) {
|
||||
@ -444,5 +582,6 @@
|
||||
.form-row, .field-inputs, .edit-row, .edit-field { align-items: stretch; flex-direction: column; }
|
||||
.entity-heading, .field-row { padding-block: 1rem; }
|
||||
input, select, .primary-button, .secondary-button { width: 100%; }
|
||||
.migration-actions { align-items: stretch; flex-direction: column; }
|
||||
}
|
||||
</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 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.EntityMigrationService
|
||||
import dev.mduchene.bolts.persistence.Database
|
||||
import dev.mduchene.bolts.persistence.DatabaseConfig
|
||||
import dev.mduchene.bolts.user.LoginService
|
||||
@ -19,11 +20,12 @@ fun main() {
|
||||
val loginService = LoginService(users, SessionRepository(database))
|
||||
loginService.initializeAdmin()
|
||||
|
||||
val entities = EntityDefinitionRepository(database)
|
||||
val controllers = listOf(
|
||||
HomeController(),
|
||||
AuthController(loginService),
|
||||
UserController(users, loginService),
|
||||
EntityDefinitionController(EntityDefinitionRepository(database), loginService),
|
||||
EntityDefinitionController(entities, loginService, EntityMigrationService(database, entities)),
|
||||
)
|
||||
|
||||
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(
|
||||
sql: String,
|
||||
bind: PreparedStatement.() -> Unit,
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package dev.mduchene.bolts.web
|
||||
|
||||
import dev.mduchene.bolts.entity.EntityDefinitionRepository
|
||||
import dev.mduchene.bolts.entity.EntityMigrationService
|
||||
import dev.mduchene.bolts.entity.FieldType
|
||||
import dev.mduchene.bolts.entity.RelationshipType
|
||||
import dev.mduchene.bolts.user.LoginService
|
||||
@ -11,12 +12,23 @@ import io.javalin.router.JavalinDefaultRoutingApi
|
||||
class EntityDefinitionController(
|
||||
private val entities: EntityDefinitionRepository,
|
||||
private val loginService: LoginService,
|
||||
private val migrations: EntityMigrationService,
|
||||
) : Controller {
|
||||
override fun register(routes: JavalinDefaultRoutingApi) {
|
||||
routes.get("/api/entity-definitions") { ctx ->
|
||||
if (!ctx.requireAdmin(loginService)) return@get
|
||||
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 ->
|
||||
if (!ctx.requireAdmin(loginService)) return@post
|
||||
val name = ctx.requiredFormParam("name") ?: return@post
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user