Skip to content
482 changes: 482 additions & 0 deletions apps/web/src/backend/modules/generated-ddl-live.test.ts

Large diffs are not rendered by default.

5 changes: 0 additions & 5 deletions apps/web/src/frontend/components/TopToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import { CredentialManager } from './CredentialManager';
import { MigrationHistory } from './MigrationHistory';
import { TYPE_META, TYPE_ORDER } from './SchemaTreePanel';
import type { DbObjectType } from '../lib/types';
import { PROVIDER_SETTINGS } from '../lib/provider-settings';
import { ConnectionModal } from './ConnectionModal';
import { PasswordInput } from './PasswordInput';
import { useAuthStore } from '../store/authStore';
Expand All @@ -22,8 +21,6 @@ import { BrowseBar } from './BrowseBar';

const ProfileMenu = ProfileMenuNamed ?? ProfileMenuDefault;

const dialectOptions = Object.values(PROVIDER_SETTINGS);

export const TopToolbar: React.FC = () => {
const {
sourceConfig,
Expand All @@ -38,8 +35,6 @@ export const TopToolbar: React.FC = () => {
testTargetConnection,
isComparing,
runSchemaComparison,
browseSchema,
isBrowsing,
compareResult,
resetSync,
selectedObjectTypes,
Expand Down
12 changes: 10 additions & 2 deletions packages/db/src/providers/db2/db2.provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -414,14 +414,22 @@ export class Db2Provider implements SchemaProvider {
[schemaName]
),
// Roles are database-global (not schema-scoped).
//
// `SYS%` is excluded because those are DB2's own built-in roles
// (SYSDEBUG, SYSTS_ADM, SYSGEOADM, …). They exist in every database,
// belong to nobody, and cannot be recreated — DB2 reserves the SYS
// prefix and answers `CREATE ROLE SYSDEBUG` with SQL0707N. Reporting
// them put six phantom objects in every DB2 comparison and put eleven
// statements that can never succeed into the migration.
ConnectionFactory.executeOnConnection<Db2RoleRaw>(
this.provider, conn,
`SELECT ROLENAME FROM SYSCAT.ROLES ORDER BY ROLENAME`,
`SELECT ROLENAME FROM SYSCAT.ROLES WHERE ROLENAME NOT LIKE 'SYS%' ORDER BY ROLENAME`,
[]
),
ConnectionFactory.executeOnConnection<Db2RoleAuthRaw>(
this.provider, conn,
`SELECT ROLENAME, GRANTEE, GRANTEETYPE FROM SYSCAT.ROLEAUTH ORDER BY ROLENAME, GRANTEE`,
`SELECT ROLENAME, GRANTEE, GRANTEETYPE FROM SYSCAT.ROLEAUTH
WHERE ROLENAME NOT LIKE 'SYS%' ORDER BY ROLENAME, GRANTEE`,
[]
)
]);
Expand Down
14 changes: 14 additions & 0 deletions packages/sql/src/modules/dialect-dba-utilities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,3 +112,17 @@ describe('dialect-dba-utilities', () => {
expect(q.params).toEqual(['CARTER', 'CARTER']);
});
});

describe('DB2 session query columns', () => {
it('does not select SESSION_DB_PARTITION_NUM', () => {
// MON_GET_CONNECTION has no such column. DB2 answers SQL0206N, so the
// Sessions utility failed for every DB2 user until this was corrected.
const built = buildDbaUtilityQuery({ dialect: 'db2', kind: 'sessions' });
expect('error' in built).toBe(false);
const sql = (built as { sql: string }).sql;
expect(sql).not.toContain('SESSION_DB_PARTITION_NUM');
// Verified live on DB2 11.5: this returns the database, which is what the
// column claims to be — a partition number was wrong for it regardless.
expect(sql).toContain('CURRENT SERVER AS database_name');
});
});
6 changes: 5 additions & 1 deletion packages/sql/src/modules/dialect-dba-utilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,10 @@ SELECT * FROM (
};
}
if (f === 'db2') {
// `database_name` is CURRENT SERVER, not a partition column. The original
// query selected a partition number here under a column name that promised
// the database — and picked one MON_GET_CONNECTION does not expose, so DB2
// answered SQL0206N and Sessions failed outright for every DB2 user.
return {
mode: asMode(mode),
params: [],
Expand All @@ -456,7 +460,7 @@ SELECT
CAST(APPLICATION_HANDLE AS VARCHAR(32)) AS session_id,
SESSION_AUTH_ID AS user_name,
CLIENT_HOSTNAME AS client_host,
SESSION_DB_PARTITION_NUM AS database_name,
CURRENT SERVER AS database_name,
APPLICATION_NAME AS state,
CAST(NULL AS VARCHAR(128)) AS wait_event,
CAST(NULL AS VARCHAR(500)) AS query_text,
Expand Down
32 changes: 32 additions & 0 deletions packages/sql/src/modules/sql-dialect.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,38 @@ export interface SqlDialect {
*/
quoteIdentifier?(name: string): string;

/**
* Statements to run after a table's columns change, before its indexes,
* keys and triggers are rebuilt.
*
* DB2 puts a table into *reorg-pending* after DROP COLUMN and after some type
* changes: `SELECT` still works, but every write fails with SQL0668N reason
* code 7 until `REORG TABLE` runs. A migration that skips it reports success
* and hands back a table nobody can write to — and because reads still work,
* it can be a while before anyone notices.
*
* Omit unless the engine needs it. Runs inside the migration's transaction
* alongside the ALTERs.
*/
/**
* Statements that clean up after this dialect's own `addColumnStatement`.
*
* DB2 cannot add a NOT NULL column to a populated table without a default
* (SQL0193N), so the dialect appends `WITH DEFAULT` to make the ALTER
* succeed. That leaves the column carrying a default (`''`, `0`) the source
* column never had — so the very next comparison reports the column as
* changed, proposes the same migration again, and never converges. Dropping
* the implicit default afterwards makes the result actually match the source.
*
* Omit unless `addColumnStatement` adds something the source did not ask for.
*/
afterAddColumnStatements?(qualifiedTable: string, colName: string, col: ColumnSpec): string[];

postColumnChangeStatements?(
qualifiedTable: string,
changed: { dropped: boolean; retyped: boolean }
): string[];

/**
* Full ` COLLATE ...` clause (with leading space) for a column's collation, used in
* CREATE TABLE / ADD COLUMN. Default: ` COLLATE <name>`, unquoted — correct for
Expand Down
128 changes: 128 additions & 0 deletions packages/sql/src/modules/sql-generator.module.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest';
import { SqlGeneratorModule } from './sql-generator.module.js';
import { db2SqlDialect } from '../providers/db2/db2.sql-dialect.js';
import { TableDiff } from '../interfaces/index.js';
import { TableSchema } from '../interfaces/index.js';

Expand Down Expand Up @@ -94,6 +95,133 @@ describe('SqlGeneratorModule.generateMigrationPlan', () => {
sourceTable: tableSchema({ name, columns: [{ name: 'ID', type: 'INTEGER', nullable: false, primaryKey: false }] }),
});

describe('DB2 tolerates a foreign key that is already gone', () => {
it('wraps the FK drop in the same 42704 handler as its other drops', () => {
// Dropping a parent table takes its inbound foreign keys with it, and
// table drops are ordered before the ALTERs — so by the time the explicit
// FK drop runs, the constraint often no longer exists. DB2 has no DROP
// CONSTRAINT IF EXISTS, and DB2 has transactional DDL, so the resulting
// SQL0204N rolled the *entire* migration back: on the live samples the
// revert reported no error and changed nothing at all.
const sql = db2SqlDialect.dropForeignKeyStatement!('S.CHILD', 'FK_CHILD_PARENT');
expect(sql).toContain("SQLSTATE '42704'");
expect(sql).toContain('DROP FOREIGN KEY FK_CHILD_PARENT');
});

it('still drops the constraint it was asked to drop', () => {
const sql = db2SqlDialect.dropForeignKeyStatement!('S.CHILD', 'FK_X');
expect(sql).toMatch(/ALTER TABLE S\.CHILD DROP FOREIGN KEY FK_X/);
});
});

describe('DB2 converges after adding a NOT NULL column', () => {
// DB2 cannot add a NOT NULL column to a populated table without a default
// (SQL0193N), so the dialect appends `WITH DEFAULT`. That leaves the column
// holding a default the source never declared, and the next comparison
// reports the same change again — for ever. Verified end to end against the
// shipped DEMO_A/DEMO_B samples on DB2 11.5: with this, re-comparing after
// the migration reports no differences at all.
const addNotNull = (over: Partial<{ nullable: boolean; defaultValue: string }> = {}): TableDiff => ({
tableName: 'PRODUCTS',
objectType: 'TABLE',
status: 'MODIFIED',
columnDiffs: [
{
name: 'SKU',
status: 'ADDED',
source: { name: 'SKU', type: 'VARCHAR(50)', nullable: false, ...over },
},
],
indexDiffs: [],
foreignKeyDiffs: [],
sourceTable: tableSchema({ name: 'PRODUCTS' }),
targetTable: tableSchema({ name: 'PRODUCTS' }),
});

it('drops the implicit default it had to add', () => {
const sql = gen.generateMigrationPlan([addNotNull()], 'db2').flatMap((s) => s.statements);
const add = sql.findIndex((s) => /ADD\b/i.test(s) && /WITH DEFAULT/i.test(s));
const drop = sql.findIndex((s) => /DROP DEFAULT/i.test(s));
expect(add, 'no ADD … WITH DEFAULT emitted').toBeGreaterThanOrEqual(0);
expect(drop, 'the implicit default is never dropped — this never converges').toBeGreaterThan(add);
});

it('keeps a default the source actually declares', () => {
const sql = gen
.generateMigrationPlan([addNotNull({ defaultValue: "'x'" })], 'db2')
.flatMap((s) => s.statements)
.join('\n');
expect(sql).toContain("DEFAULT 'x'");
expect(sql, 'dropped a default the source asked for').not.toMatch(/DROP DEFAULT/i);
});

it('leaves a nullable column alone', () => {
const sql = gen
.generateMigrationPlan([addNotNull({ nullable: true })], 'db2')
.flatMap((s) => s.statements)
.join('\n');
expect(sql).not.toMatch(/DROP DEFAULT/i);
});

it('does not touch other dialects', () => {
for (const dialect of ['postgres', 'mysql', 'sqlserver', 'oracle']) {
const sql = gen.generateMigrationPlan([addNotNull()], dialect).flatMap((s) => s.statements).join('\n');
expect(sql, dialect).not.toMatch(/DROP DEFAULT/i);
}
});
});

describe('DB2 reorg-pending after column changes', () => {
// Verified against DB2 11.5: after ALTER TABLE … DROP COLUMN, a SELECT
// still succeeds while every INSERT fails with SQL0668N reason code 7. A
// migration without the REORG therefore reports success and hands back a
// table nobody can write to — and because reads work, it can be a long
// while before anyone notices.
const droppedColumn: TableDiff = {
tableName: 'CUSTOMER',
objectType: 'TABLE',
status: 'MODIFIED',
columnDiffs: [
{ name: 'OLD_COL', status: 'REMOVED', target: { name: 'old_col', type: 'VARCHAR(10)', nullable: true } },
],
indexDiffs: [],
foreignKeyDiffs: [],
sourceTable: tableSchema({ name: 'customer' }),
targetTable: tableSchema({ name: 'customer' }),
};

it('reorgs the table after a DROP COLUMN', () => {
const sql = gen.generateMigrationPlan([droppedColumn], 'db2').flatMap((s) => s.statements);
const drop = sql.findIndex((s) => /DROP COLUMN/i.test(s));
const reorg = sql.findIndex((s) => /REORG TABLE/i.test(s));
expect(drop, 'no DROP COLUMN emitted').toBeGreaterThanOrEqual(0);
expect(reorg, 'no REORG emitted — the table stays unwritable').toBeGreaterThan(drop);
// ADMIN_CMD is the callable form; bare `REORG TABLE` is a CLP command
// and cannot be sent over a client connection.
expect(sql[reorg]).toContain('SYSPROC.ADMIN_CMD');
});

it('does not reorg when nothing was dropped or retyped', () => {
const added: TableDiff = {
...droppedColumn,
columnDiffs: [
{ name: 'NEW_COL', status: 'ADDED', source: { name: 'new_col', type: 'VARCHAR(10)', nullable: true } },
],
};
const sql = gen.generateMigrationPlan([added], 'db2').flatMap((s) => s.statements).join('\n');
expect(sql).toMatch(/ADD/i);
expect(sql).not.toMatch(/REORG/i);
});

it('leaves other dialects alone', () => {
// Only DB2 implements the hook; nobody else should grow a REORG.
for (const dialect of ['postgres', 'mysql', 'sqlserver', 'oracle', 'sqlite']) {
const sql = gen.generateMigrationPlan([droppedColumn], dialect).flatMap((s) => s.statements).join('\n');
expect(sql, dialect).not.toMatch(/REORG/i);
}
});
});

it('orders steps drop → create → alter', () => {
const diffs: TableDiff[] = [
{ ...addedTable('NEW') },
Expand Down
23 changes: 23 additions & 0 deletions packages/sql/src/modules/sql-generator.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -758,10 +758,19 @@ export class SqlGeneratorModule {
if (!col.source.nullable) colDef += ` NOT NULL`;
}
statements.push(dialect.addColumnStatement(tableName, colDef));
// Let the dialect clean up after its own workaround (DB2's implicit
// `WITH DEFAULT`), or the migration never converges.
statements.push(
...(dialect.afterAddColumnStatements?.(tableName, this.columnIdent(col), col.source) ?? [])
);
}

// A type change can also leave a table needing maintenance (see
// `postColumnChangeStatements`), so it is tracked alongside the drops.
let retypedColumns = false;
for (const col of obj.columnDiffs.filter((c) => c.status === 'MODIFIED')) {
if (!col.source) continue;
if (col.target && col.source.type !== col.target.type) retypedColumns = true;
const translated = this.translateType(col.source.type, mapping);
if (translated.warning) statements.push(`-- review: ${col.name}: ${translated.warning}`);
// Cross-dialect: the raw default AND collation are source-dialect vocabulary
Expand Down Expand Up @@ -795,12 +804,26 @@ export class SqlGeneratorModule {
}
}

const droppedColumns =
!mapping?.nonDestructive && obj.columnDiffs.some((c) => c.status === 'REMOVED');
if (!mapping?.nonDestructive) {
for (const col of obj.columnDiffs.filter((c) => c.status === 'REMOVED')) {
statements.push(dialect.dropColumnStatement(tableName, this.columnIdent(col)));
}
}

// Before the keys and indexes are rebuilt, not after: an engine that
// needs maintenance here (DB2's REORG) rejects those rebuilds too while
// the table is pending.
if (droppedColumns || retypedColumns) {
statements.push(
...(dialect.postColumnChangeStatements?.(tableName, {
dropped: droppedColumns,
retyped: retypedColumns,
}) ?? [])
);
}

const srcPk = obj.sourceTable ? this.primaryKeyColumns(obj.sourceTable) : [];
const tgtPk = obj.targetTable ? this.primaryKeyColumns(obj.targetTable) : [];
if (!this.sameColumnSet(srcPk, tgtPk)) {
Expand Down
49 changes: 48 additions & 1 deletion packages/sql/src/providers/db2/db2.sql-dialect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,12 @@ function db2Drop(keyword: string, name: string): string {
return `BEGIN\n DECLARE CONTINUE HANDLER FOR SQLSTATE '42704' BEGIN END;\n EXECUTE IMMEDIATE 'DROP ${keyword} ${safe}';\nEND`;
}

/** Same tolerance for a constraint, which lives on ALTER TABLE rather than DROP. */
function db2DropConstraint(tableName: string, fkName: string): string {
const safe = `ALTER TABLE ${tableName} DROP FOREIGN KEY ${fkName}`.replace(/'/g, "''");
return `BEGIN\n DECLARE CONTINUE HANDLER FOR SQLSTATE '42704' BEGIN END;\n EXECUTE IMMEDIATE '${safe}';\nEND`;
}

export const db2SqlDialect: SqlDialect = {
identityClause(c: ColumnSpec): string {
return c.identity ? ` GENERATED ${c.identityGeneration ?? 'ALWAYS'} AS IDENTITY` : '';
Expand All @@ -80,6 +86,22 @@ export const db2SqlDialect: SqlDialect = {
return `ALTER TABLE ${tableName} ADD ${def};`;
},

/**
* Undo the `WITH DEFAULT` above once the rows are backfilled.
*
* Without this the new column keeps a default (`''`, `0`) the source column
* never declared, so re-comparing straight after a *successful* migration
* still reports the column as changed — the tool proposes the same migration
* for ever and never converges. Verified on DB2 11.5: DROP DEFAULT is
* accepted immediately after the ADD, needs no REORG in between, and leaves
* the catalog default NULL, matching the source exactly.
*/
afterAddColumnStatements(tableName: string, colName: string, col: ColumnSpec): string[] {
const sourceHadDefault = col.defaultValue !== undefined && col.defaultValue !== null;
if (col.nullable || sourceHadDefault) return [];
return [`ALTER TABLE ${tableName} ALTER COLUMN ${colName} DROP DEFAULT;`];
},

modifyColumnStatements(tableName: string, colName: string, col: ColumnSpec): string[] {
const stmts = [`ALTER TABLE ${tableName} ALTER COLUMN ${colName} SET DATA TYPE ${col.type};`];
// DB2 nullability is a separate clause — SET DATA TYPE does not carry it.
Expand All @@ -93,6 +115,23 @@ export const db2SqlDialect: SqlDialect = {
return `ALTER TABLE ${tableName} DROP COLUMN ${colName};`;
},

/**
* DROP COLUMN (and some type changes) leave the table in *reorg-pending*.
* `SELECT` still works, so nothing looks wrong — but every INSERT/UPDATE/
* DELETE fails with SQL0668N reason code 7, and so does rebuilding the
* table's indexes and keys, until REORG runs.
*
* Verified against DB2 11.5: after `ALTER TABLE … DROP COLUMN`, a SELECT
* succeeded and an INSERT returned SQL0668N. Without this the migration
* reports success and hands back a table nobody can write to.
*
* ADMIN_CMD is the callable form — plain `REORG TABLE` is a CLP command, not
* SQL, and cannot be sent over a client connection.
*/
postColumnChangeStatements(qualifiedTable: string): string[] {
return [`CALL SYSPROC.ADMIN_CMD('REORG TABLE ${qualifiedTable.replace(/'/g, "''")}');`];
},

setDefaultStatements(tableName: string, colName: string, defaultValue: string | undefined): string[] {
return defaultValue
? [`ALTER TABLE ${tableName} ALTER COLUMN ${colName} SET DEFAULT ${defaultValue};`]
Expand All @@ -105,7 +144,15 @@ export const db2SqlDialect: SqlDialect = {

dropForeignKeyStatement(tableName: string, fkName: string): string {
// DB2 has no DROP CONSTRAINT IF EXISTS; DROP FOREIGN KEY is the native form.
return `ALTER TABLE ${tableName} DROP FOREIGN KEY ${fkName};`;
//
// Wrapped in the same 42704 handler as the other drops, because the
// constraint may already be gone by the time this runs: dropping a parent
// table earlier in the plan takes its inbound foreign keys with it. Without
// the handler that raised SQL0204N, and since DB2 has transactional DDL the
// *whole* migration rolled back — a revert that reported no error and
// changed nothing. The generic fallback says `DROP CONSTRAINT IF EXISTS`
// for exactly this reason; this restores that tolerance for DB2.
return `${db2DropConstraint(tableName, fkName)};`;
},

dropIndexStatement(indexName: string, qualifiedTable: string): string {
Expand Down
Loading