From d625edbab7daab5fdaed1bc7c28c0a50c31fe8cf Mon Sep 17 00:00:00 2001 From: huyplb Date: Sun, 16 Aug 2026 23:19:13 -0600 Subject: [PATCH] fix(db2): "Include identity" promised a clause Db2 does not have MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tested the identity-insert matrix against the live engines rather than reading it: create a table with an identity column, write an explicit value using exactly the ceremony the app prescribes, then ask the engine to generate the *next* id and see whether it collides. Db2 was listed as `overriding`, which the data-copy path lets through. Verified on Db2 LUW 11.5 that this cannot work: `OVERRIDING SYSTEM VALUE` is **SQL0104N** in every position (before the column list, after it, on INSERT … SELECT), and a plain insert into GENERATED ALWAYS is **SQL0798N**. There is no override — so the copy started and then failed mid-run, which is the single outcome that table exists to prevent. Db2 is `unsupported` now, with the same advice Oracle gives: turn the toggle off, or make the column GENERATED BY DEFAULT (which does take an explicit value — also verified). The unit tests asserted the same wrong thing the code did, which is why this survived; they now encode what the servers actually answered. What the run confirmed as already correct: * Postgres — `OVERRIDING SYSTEM VALUE` accepted, and `resyncSequence: true` is right: after writing id 500 the generator is still behind, so the next generated id collides. That is the quiet one, and the matrix has it. * MySQL — plain insert, and the counter moves past the written value (`resyncSequence: false`). * Oracle — refuses GENERATED ALWAYS with ORA-32795, exactly as documented. * SQL Server — the session toggle only holds inside one batch; separate statements land on a different session and the insert is rejected. The migrate path already refuses `toggle` with a message saying it does not issue SET IDENTITY_INSERT, so that limit is honestly stated. * GENERATED BY DEFAULT relaxes the requirement on Postgres, Db2 and Oracle. Co-Authored-By: Claude Opus 5 --- .../frontend/lib/identity-insert-sql.test.ts | 9 ++++++--- apps/web/src/frontend/lib/rowDml.ts | 6 ++++-- .../modules/dialect-identity-insert.test.ts | 18 ++++++++++++++++-- .../sql/src/modules/dialect-identity-insert.ts | 16 +++++++++++++++- 4 files changed, 41 insertions(+), 8 deletions(-) diff --git a/apps/web/src/frontend/lib/identity-insert-sql.test.ts b/apps/web/src/frontend/lib/identity-insert-sql.test.ts index 32b519ea..365758a2 100644 --- a/apps/web/src/frontend/lib/identity-insert-sql.test.ts +++ b/apps/web/src/frontend/lib/identity-insert-sql.test.ts @@ -13,9 +13,12 @@ const build = (dialect: string, generation?: string) => { describe('explicit identity INSERT is shaped per dialect', () => { it('adds OVERRIDING SYSTEM VALUE where the engine demands it', () => { - // Postgres/Db2 reject an explicit GENERATED ALWAYS value without this - // (SQLSTATE 428C9 / SQL0798N). - for (const d of ['postgres', 'cockroachdb', 'yugabytedb', 'db2']) { + // The Postgres family rejects an explicit GENERATED ALWAYS value without + // this (SQLSTATE 428C9). Db2 is NOT one of them despite a similar error: + // verified on Db2 LUW 11.5, the clause itself is SQL0104N there, so a Db2 + // GENERATED ALWAYS column is simply not writable — see the `unsupported` + // entry in dialect-identity-insert. + for (const d of ['postgres', 'cockroachdb', 'yugabytedb']) { expect(build(d, 'ALWAYS'), d).toContain('OVERRIDING SYSTEM VALUE'); // It must sit between the column list and VALUES, not anywhere else. expect(build(d, 'ALWAYS'), d).toMatch(/\)\s+OVERRIDING SYSTEM VALUE\s+VALUES/); diff --git a/apps/web/src/frontend/lib/rowDml.ts b/apps/web/src/frontend/lib/rowDml.ts index 5f63e85c..8c4d9b5b 100644 --- a/apps/web/src/frontend/lib/rowDml.ts +++ b/apps/web/src/frontend/lib/rowDml.ts @@ -303,8 +303,10 @@ export function buildPeekInsert(opts: { } if (Object.keys(row).length === 0) return { error: 'Nothing to insert.' }; - // Postgres and Db2 refuse an explicit value for a GENERATED ALWAYS identity - // column unless the statement says so (SQLSTATE 428C9 / SQL0798N). The + // Postgres refuses an explicit value for a GENERATED ALWAYS identity column + // unless the statement says so (SQLSTATE 428C9). Db2 is *not* in this group + // despite the similar error: it has no overriding clause at all (SQL0798N, + // and SQL0104N if you try one), so its support entry is `unsupported`. The // clause comes from a fixed capability table, never from user input. const support = writeIdentityGeneration ? identityInsertFor(dialect, writeIdentityGeneration) diff --git a/packages/sql/src/modules/dialect-identity-insert.test.ts b/packages/sql/src/modules/dialect-identity-insert.test.ts index b742a791..0236be77 100644 --- a/packages/sql/src/modules/dialect-identity-insert.test.ts +++ b/packages/sql/src/modules/dialect-identity-insert.test.ts @@ -19,10 +19,15 @@ describe('identityInsertSupport — every registered dialect has an answer', () for (const d of ['mysql', 'mariadb', 'tidb', 'sqlite']) { expect(identityInsertSupport(d).kind, d).toBe('native'); } - for (const d of ['postgres', 'cockroachdb', 'yugabytedb', 'db2']) { + for (const d of ['postgres', 'cockroachdb', 'yugabytedb']) { expect(identityInsertSupport(d).kind, d).toBe('overriding'); expect(identityInsertSupport(d).clause, d).toBe('OVERRIDING SYSTEM VALUE'); } + // Db2 is deliberately not in that list. It looks like Postgres from the + // error message alone (both complain about GENERATED ALWAYS), but verified + // on Db2 LUW 11.5: OVERRIDING SYSTEM VALUE is SQL0104N in every position, + // so there is no ceremony that makes the write legal. + expect(identityInsertSupport('db2').kind).toBe('unsupported'); for (const d of ['sqlserver', 'azuresql']) { expect(identityInsertSupport(d).kind, d).toBe('toggle'); } @@ -72,7 +77,16 @@ describe('identityInsertFor — how the column was declared', () => { it('keeps the overriding clause for ALWAYS', () => { expect(identityInsertFor('postgres', 'ALWAYS').kind).toBe('overriding'); - expect(identityInsertFor('db2', 'ALWAYS').kind).toBe('overriding'); + }); + + it('refuses a Db2 GENERATED ALWAYS identity outright', () => { + // Letting this through as `overriding` started the copy and then failed it + // mid-run with SQL0104N — the outcome this table exists to prevent. + const always = identityInsertFor('db2', 'ALWAYS'); + expect(always.kind).toBe('unsupported'); + expect(always.reason).toMatch(/GENERATED BY DEFAULT/i); + // BY DEFAULT is genuinely writable — confirmed against the live server. + expect(identityInsertFor('db2', 'BY DEFAULT').kind).toBe('native'); }); it('lets Oracle BY DEFAULT through', () => { diff --git a/packages/sql/src/modules/dialect-identity-insert.ts b/packages/sql/src/modules/dialect-identity-insert.ts index 6cbbe5ca..63d0c61b 100644 --- a/packages/sql/src/modules/dialect-identity-insert.ts +++ b/packages/sql/src/modules/dialect-identity-insert.ts @@ -91,7 +91,21 @@ const SUPPORT: Record = { postgres: OVERRIDING, cockroachdb: OVERRIDING, yugabytedb: OVERRIDING, - db2: OVERRIDING, + + // Not OVERRIDING: that clause is Postgres (and DB2 for i / z/OS). Verified on + // DB2 LUW 11.5 — `OVERRIDING SYSTEM VALUE` is SQL0104N in every position + // (before the column list, after it, and on INSERT … SELECT), and a plain + // insert into GENERATED ALWAYS is SQL0798N. There is no override, so listing + // DB2 as `overriding` let the copy start and then fail mid-run, which is the + // one outcome this table exists to prevent. + db2: { + kind: 'unsupported', + resyncSequence: true, + reason: + 'Db2 has no overriding clause for a GENERATED ALWAYS identity column ' + + '(SQL0798N, and SQL0104N for OVERRIDING SYSTEM VALUE). Turn off Include ' + + 'identity, or change the column to GENERATED BY DEFAULT.', + }, sqlserver: TOGGLE, azuresql: TOGGLE,