fix(hub-projects): make generated data table migrations idempotent

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
tristantr
2026-07-13 14:55:28 +02:00
co-authored by Claude Fable 5
parent 8d44294570
commit 709e03e658
2 changed files with 48 additions and 2 deletions
@@ -172,14 +172,26 @@ export function generateMigrationSql(
const pkLine = pkCols.length > 0 ? `,\n PRIMARY KEY (${pkCols.join(', ')})` : ''
const qualifiedName = `"${change.schemaName}"."${change.tableName}"`
const createKeyword = options?.ifNotExists ? 'CREATE TABLE IF NOT EXISTS' : 'CREATE TABLE'
let sql = `BEGIN;\n${createKeyword} ${qualifiedName} (\n ${colDefs}${pkLine}\n);`
// The target may not have the schema at all (fresh data table import).
const schemaDdl =
change.schemaName !== 'public' ? `CREATE SCHEMA IF NOT EXISTS "${change.schemaName}";\n` : ''
let sql = `BEGIN;\n${schemaDdl}${createKeyword} ${qualifiedName} (\n ${colDefs}${pkLine}\n);`
for (const fk of table.foreignKeys ?? []) {
const fkSql = renderForeignKey(fk, {
useSchema: true,
dbType: 'postgresql',
tableName: change.tableName
})
sql += `\nALTER TABLE ${qualifiedName} ADD ${fkSql};`
// With IF NOT EXISTS the table may pre-exist with this FK already in
// place; an unconditional ADD would then abort the whole transaction.
// The constraint name is emitted unquoted, so Postgres folds it to
// lowercase — compare against the folded form.
const fkName = options?.ifNotExists
? fkSql.match(/^CONSTRAINT\s+(\S+)/)?.[1]?.toLowerCase()
: undefined
sql += fkName
? `\nDO $$\nBEGIN\n IF NOT EXISTS (\n SELECT 1 FROM pg_constraint\n WHERE conname = '${fkName}' AND conrelid = '${qualifiedName}'::regclass\n ) THEN\n ALTER TABLE ${qualifiedName} ADD ${fkSql};\n END IF;\nEND $$;`
: `\nALTER TABLE ${qualifiedName} ADD ${fkSql};`
}
sql += '\nCOMMIT;'
return sql
@@ -160,6 +160,40 @@ describe('generateDatatableMigrations', () => {
expect(migrations[0].sql).toContain('"public"."customers"')
})
it('guards FK creation so re-running on an existing table does not abort', async () => {
getDatatableFullSchemaMock.mockResolvedValue(schema)
const usage = new Map([['main', new Set(['orders'])]])
const migrations = await generateDatatableMigrations('ws', usage)
const sql = migrations[0].sql
// The ADD CONSTRAINT must be wrapped in a pg_constraint existence check.
expect(sql).toContain('DO $$')
expect(sql).toContain('SELECT 1 FROM pg_constraint')
expect(sql).toContain(`conrelid = '"public"."orders"'::regclass`)
// No unguarded ALTER TABLE ... ADD at the start of a line.
expect(/^ALTER TABLE .* ADD CONSTRAINT/m.test(sql)).toBe(false)
})
it('creates non-public schemas before their tables', async () => {
const appSchema = {
app: {
customers: {
name: 'customers',
columns: [{ name: 'id', datatype: 'integer', primary_key: true, nullable: false }],
foreign_keys: []
}
}
}
getDatatableFullSchemaMock.mockResolvedValue(appSchema)
const usage = new Map([['main', new Set(['app.customers'])]])
const migrations = await generateDatatableMigrations('ws', usage)
const sql = migrations[0].sql
expect(sql).toContain('CREATE SCHEMA IF NOT EXISTS "app";')
expect(sql.indexOf('CREATE SCHEMA IF NOT EXISTS "app";')).toBeLessThan(
sql.indexOf('CREATE TABLE IF NOT EXISTS "app"."customers"')
)
expect(sql).not.toContain('CREATE SCHEMA IF NOT EXISTS "public"')
})
it('keeps same-named tables from different schemas both created', async () => {
const twoSchemas = {
public: {