From 08b7392c69031c880a423760c846708e37133e16 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 10:21:02 +0000 Subject: [PATCH 1/3] fix(driver-sql): introspectUniqueConstraints reports single-column uniqueness on all three dialects (#11202) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SQLite arm kept unique indexes of exactly one column while the Postgres and MySQL arms returned every member of every composite constraint, so `UNIQUE (a, b)` made `introspectSchema`'s per-column `isUnique` claim that `a` alone is unique and `b` alone is unique — a claim the constraint does not make. All three arms now normalise to `UniqueConstraintMember` and decide through one predicate, `singleColumnUniqueColumns`. Per maintainer ruling 2026-08-23 (option A->B), a composite representation waits for real demand. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Rxnd8cyFnoU8V5y21PaTsy --- ...-driver-11202-single-column-unique.test.ts | 323 ++++++++++++++++++ packages/drivers/driver-sql/src/sql-driver.ts | 168 ++++++++- 2 files changed, 481 insertions(+), 10 deletions(-) create mode 100644 packages/drivers/driver-sql/src/sql-driver-11202-single-column-unique.test.ts diff --git a/packages/drivers/driver-sql/src/sql-driver-11202-single-column-unique.test.ts b/packages/drivers/driver-sql/src/sql-driver-11202-single-column-unique.test.ts new file mode 100644 index 0000000000..cac43288f5 --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-11202-single-column-unique.test.ts @@ -0,0 +1,323 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#11202] `introspectUniqueConstraints` reports SINGLE-COLUMN uniqueness + * only, and reports it identically on all three dialects. + * + * The three arms used to answer three different questions. SQLite kept unique + * indexes of exactly one column (`info.length === 1`); Postgres and MySQL + * returned every member of every composite constraint. `introspectSchema` + * folds the flat `string[]` into a per-column `isUnique`, so for `UNIQUE + * (a, b)` the same table read through Postgres claimed `a` alone is unique and + * `b` alone is unique — a claim the constraint does not make — while through + * SQLite it claimed neither. + * + * The divergence was LATENT until #11161: the Postgres arm's query named an + * alias that was not in scope, and the bare `catch {}` it carried turned every + * execution into `[]`. Repairing the query is what put three live answers into + * conflict for the first time. + * + * Maintainer ruling 2026-08-23 (option A→B), verbatim and untranslated: + * 「10950 不考虑存量,其他接受你的建议」 — narrow the flag to single-column + * uniqueness now; a composite representation waits for real demand. + * + * ## Why this file has two halves + * + * The **predicate half** feeds `singleColumnUniqueColumns` the exact row + * shapes each dialect's query returns. It runs everywhere, which matters + * because the Postgres and MySQL narrowing is otherwise only measurable on a + * provisioned live server — the arms now group in JS precisely so the decision + * is testable without one. What it cannot prove is that the queries really + * return those rows. + * + * The **live half** proves that, end to end, on every provisioned cell, and is + * declared through `declareDialectCell` so an unprovisioned dialect is a NAMED + * skip (a red under `OS_EXPECT_LIVE_DIALECT_MATRIX=1`), never a silent pass. + * + * ## The interesting assertion is an ABSENCE, so the fixture is proven first + * + * "`a` is not flagged" goes green for free on a table whose composite + * constraint never got created. Every live cell therefore first makes the + * DATABASE state its own witness: two rows sharing `a` are ACCEPTED (so `a` + * alone is genuinely not unique — exactly what the flag must not claim), a + * repeat of the `(a, b)` pair is REJECTED (so the composite constraint exists + * and is enforced), and a repeat of `email` is REJECTED (so the single-column + * constraint that must be flagged exists too). If the fixture is not real, + * that case fails before any absence is asserted. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { SqlDriver, singleColumnUniqueColumns } from './sql-driver.js'; +import { DIALECT_CELLS, declareDialectCell, type DialectCell } from './live-dialect-matrix.testkit.js'; + +const MATRIX = 'single-column unique introspection'; + +/** Composite `UNIQUE (a, b)` plus a single-column `UNIQUE (email)`. */ +const TABLE = 'os11202_uniq'; + +/** `introspectUniqueConstraints` is `protected`; this is the narrowest reach. */ +class UniqueProbeDriver extends SqlDriver { + uniqueConstraints(table: string) { + return this.introspectUniqueConstraints(table); + } +} + +// ── Half 1: the predicate, on every dialect's real row shape ──────────────── + +describe('singleColumnUniqueColumns — the one definition of what the flag means (#11202)', () => { + it('keeps a one-member constraint and drops every member of a composite one', () => { + expect( + singleColumnUniqueColumns([ + { constraint: ['pair'], column: 'a' }, + { constraint: ['pair'], column: 'b' }, + { constraint: ['solo'], column: 'email' }, + ]), + ).toEqual(['email']); + }); + + it('a three-column constraint contributes nothing — width is counted, not assumed to be two', () => { + expect( + singleColumnUniqueColumns([ + { constraint: ['triple'], column: 'a' }, + { constraint: ['triple'], column: 'b' }, + { constraint: ['triple'], column: 'c' }, + ]), + ).toEqual([]); + }); + + it('a column carrying TWO separate single-column constraints is named once', () => { + expect( + singleColumnUniqueColumns([ + { constraint: ['by_clause'], column: 'email' }, + { constraint: ['by_index'], column: 'email' }, + ]), + ).toEqual(['email']); + }); + + it('a column that is BOTH a composite member and single-column unique is still flagged', () => { + // The composite membership must not veto the standalone constraint: `a` + // really is unique on its own here, by a constraint of its own. + expect( + singleColumnUniqueColumns([ + { constraint: ['pair'], column: 'a' }, + { constraint: ['pair'], column: 'b' }, + { constraint: ['solo_a'], column: 'a' }, + ]), + ).toEqual(['a']); + }); + + it('an empty answer stays empty — no constraint, nothing flagged', () => { + expect(singleColumnUniqueColumns([])).toEqual([]); + }); + + describe('the Postgres row shape — identity is (schema, name), not name alone', () => { + /** + * The arm's answer spans `current_schemas(false)`, and Postgres auto-names + * a unique constraint `__key` — so two same-named tables in + * two schemas hand back two DIFFERENT constraints under one name. Keyed on + * the name alone they fuse into an apparent two-member constraint and the + * genuine single-column unique disappears from the answer. This is the + * #11201 defect class, one method over. + */ + it('same constraint name in two schemas stays two constraints', () => { + const rows = [ + { constraint_schema: 'app', constraint_name: 'orders_email_key', column_name: 'email' }, + { constraint_schema: 'other', constraint_name: 'orders_email_key', column_name: 'email' }, + ]; + const members = rows.map((row) => ({ + constraint: [row.constraint_schema, row.constraint_name], + column: row.column_name, + })); + expect(singleColumnUniqueColumns(members)).toEqual(['email']); + + // The counterfactual: keyed on the name alone, the same rows lose it. + const nameOnly = rows.map((row) => ({ + constraint: [row.constraint_name], + column: row.column_name, + })); + expect(singleColumnUniqueColumns(nameOnly)).toEqual([]); + }); + + it('a composite constraint in one schema is dropped, its single-column sibling kept', () => { + const rows = [ + { constraint_schema: 'app', constraint_name: 'os11202_ab', column_name: 'a' }, + { constraint_schema: 'app', constraint_name: 'os11202_ab', column_name: 'b' }, + { constraint_schema: 'app', constraint_name: 'os11202_email', column_name: 'email' }, + ]; + expect( + singleColumnUniqueColumns( + rows.map((row) => ({ + constraint: [row.constraint_schema, row.constraint_name], + column: row.column_name, + })), + ), + ).toEqual(['email']); + }); + }); + + describe('the MySQL row shape — SCREAMING keys, one identity part', () => { + it('composite members are dropped, the single-column constraint kept', () => { + const rows = [ + { CONSTRAINT_NAME: 'os11202_ab', COLUMN_NAME: 'a' }, + { CONSTRAINT_NAME: 'os11202_ab', COLUMN_NAME: 'b' }, + { CONSTRAINT_NAME: 'os11202_email', COLUMN_NAME: 'email' }, + ]; + expect( + singleColumnUniqueColumns( + rows.map((row) => ({ constraint: [row.CONSTRAINT_NAME], column: row.COLUMN_NAME })), + ), + ).toEqual(['email']); + }); + }); + + describe('the SQLite row shape — an index member is not always a column', () => { + it('a one-term EXPRESSION index contributes nothing, and never a null', () => { + // `PRAGMA index_info` reports `name: null` for an expression term. The + // arm used to push that row's `name` straight into a `string[]`. + const flagged = singleColumnUniqueColumns([ + { constraint: ['os11202_lower_c'], column: null }, + ]); + expect(flagged).toEqual([]); + expect(flagged).not.toContain(null); + }); + + it('a column PAIRED with an expression term is not single-column unique', () => { + // The unnamed member still occupies a slot: `(d, lower(e))` is a + // two-member index, so `d` alone is not unique and must not be flagged. + expect( + singleColumnUniqueColumns([ + { constraint: ['os11202_d_lower_e'], column: 'd' }, + { constraint: ['os11202_d_lower_e'], column: null }, + ]), + ).toEqual([]); + }); + }); +}); + +// ── Half 2: end to end, on every provisioned dialect ──────────────────────── + +function declareSingleColumnUniqueSuite(cell: DialectCell): void { + describe(`introspectUniqueConstraints — single-column only — ${cell.label} (#11202)`, () => { + let driver: UniqueProbeDriver; + + beforeAll(async () => { + driver = new UniqueProbeDriver(cell.config()); + await driver.execute(`drop table if exists ${TABLE}`).catch(() => {}); + // No primary key on purpose: SQLite materialises a non-INTEGER primary + // key as a unique auto-index that `PRAGMA index_list` reports, while the + // Postgres and MySQL arms filter on `CONSTRAINT_TYPE = 'UNIQUE'` and so + // never see primary keys at all. Keeping keys out of the fixture makes + // this suite measure the composite-vs-single question and nothing else. + await driver.execute( + `create table ${TABLE} ( + a varchar(64) not null, + b varchar(64) not null, + email varchar(64) not null, + note varchar(64), + constraint os11202_ab unique (a, b), + constraint os11202_email unique (email) + )`, + ); + }); + + afterAll(async () => { + await driver.execute(`drop table if exists ${TABLE}`).catch(() => {}); + await driver.disconnect().catch(() => {}); + }); + + it('the fixture is real: `a` alone is NOT unique, the pair IS, and `email` IS', async () => { + // Non-vacuity, asserted against the server rather than the catalog: an + // absence assertion below is worthless if the constraints never landed. + await driver.execute(`insert into ${TABLE} (a, b, email) values ('x', '1', 'e1@example.com')`); + + // Two rows sharing `a` — ACCEPTED. This is the fact the flag must not + // contradict: `a` is not unique on its own. + await driver.execute(`insert into ${TABLE} (a, b, email) values ('x', '2', 'e2@example.com')`); + + // The PAIR repeated — REJECTED, so the composite constraint is enforced. + await expect( + driver.execute(`insert into ${TABLE} (a, b, email) values ('x', '1', 'e3@example.com')`), + ).rejects.toThrow(); + + // `email` repeated — REJECTED, so the single-column constraint exists. + await expect( + driver.execute(`insert into ${TABLE} (a, b, email) values ('y', '9', 'e1@example.com')`), + ).rejects.toThrow(); + }); + + it('reports the single-column unique column and NEITHER member of the composite one', async () => { + const columns = await driver.uniqueConstraints(TABLE); + + expect(columns).toContain('email'); + expect(columns).not.toContain('a'); + expect(columns).not.toContain('b'); + expect(columns).not.toContain('note'); + // Exact, so a dialect that starts reporting something extra is caught + // rather than absorbed by the three `not.toContain`s above. + expect(columns).toEqual(['email']); + }); + + it("`introspectSchema` folds that into `isUnique` — the consumer-visible half", async () => { + const schema = await driver.introspectSchema(); + const table = schema.tables[TABLE]; + expect(table, `${TABLE} missing from the introspected schema`).toBeDefined(); + + const byName = Object.fromEntries(table.columns.map((col) => [col.name, col])); + expect(byName.email?.isUnique).toBe(true); + // Falsy, not `false`: the flag is only ever SET to `true`, so a + // non-unique column carries `undefined` and asserting `false` would pin + // a shape the producer does not emit. + expect(byName.a?.isUnique).toBeFalsy(); + expect(byName.b?.isUnique).toBeFalsy(); + expect(byName.note?.isUnique).toBeFalsy(); + }); + }); +} + +for (const cell of DIALECT_CELLS) { + declareDialectCell(cell, MATRIX, declareSingleColumnUniqueSuite); +} + +// ── SQLite-only: the member shapes no other dialect can produce ───────────── + +describe('SQLite expression indexes — a unique index member that is not a column (#11202)', () => { + const EXPR_TABLE = 'os11202_expr'; + let driver: UniqueProbeDriver; + + beforeAll(async () => { + driver = new UniqueProbeDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); + await driver.execute( + `create table ${EXPR_TABLE} (c varchar(64), d varchar(64), e varchar(64), f varchar(64))`, + ); + // One-term expression index: a single member carrying no column name. + await driver.execute(`create unique index os11202_lower_c on ${EXPR_TABLE} (lower(c))`); + // Column + expression: two members, so `d` is not unique on its own. + await driver.execute(`create unique index os11202_d_lower_e on ${EXPR_TABLE} (d, lower(e))`); + // A plain single-column unique index, so this suite has a positive too. + await driver.execute(`create unique index os11202_f on ${EXPR_TABLE} (f)`); + }); + + afterAll(async () => { + await driver.disconnect().catch(() => {}); + }); + + it('PRAGMA index_info really reports a null name for an expression term', async () => { + // Pins the premise the arm's null-handling rests on. If SQLite ever named + // these members, the handling would be dead code and should be re-read. + const info: any = await driver.execute(`PRAGMA index_info(os11202_lower_c)`); + const rows = Array.isArray(info) ? info : []; + expect(rows).toHaveLength(1); + expect(rows[0].name).toBeNull(); + }); + + it('flags only the plain single-column index — no nulls, no expression terms', async () => { + const columns = await driver.uniqueConstraints(EXPR_TABLE); + expect(columns).toEqual(['f']); + expect(columns).not.toContain(null); + expect(columns).not.toContain('d'); + }); +}); diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index 865d3e5bee..c799779952 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -3683,7 +3683,24 @@ function nullSafeNegationOperand(node: Record): Record { - const uniqueColumns: string[] = []; + const members: UniqueConstraintMember[] = []; try { if (this.isPostgres) { @@ -14092,9 +14136,18 @@ export class SqlDriver implements IDataDriver { // user can read, so without it the newly-working query would report a // same-named table's constraints from a schema the session never // reaches. + // `constraint_schema` is SELECTed, not just joined on, because it is + // half the constraint's IDENTITY here (#11202). `table_schema = ANY + // (current_schemas(false))` deliberately spans every schema on the + // search path, and Postgres auto-names a unique constraint after the + // table and column (`orders_email_key`) — so two same-named tables in + // two schemas produce two DIFFERENT constraints carrying the SAME + // name. Grouping on the name alone would fuse them into one apparent + // two-member constraint and drop a genuinely single-column unique from + // the answer. const result = await this.knex.raw( ` - SELECT ccu.column_name + SELECT tc.constraint_schema, tc.constraint_name, ccu.column_name FROM information_schema.table_constraints tc JOIN information_schema.constraint_column_usage AS ccu ON tc.constraint_schema = ccu.constraint_schema @@ -14107,12 +14160,19 @@ export class SqlDriver implements IDataDriver { ); for (const row of result.rows) { - uniqueColumns.push(row.column_name); + members.push({ + constraint: [String(row.constraint_schema), String(row.constraint_name)], + column: row.column_name ?? null, + }); } } else if (this.isMysql) { + // `TABLE_SCHEMA = DATABASE()` and `TABLE_NAME = ?` are both pinned in + // the WHERE and merged by the `USING` join, so a constraint name is + // already unique within this answer — one identity part suffices, + // unlike the Postgres arm above. const result = await this.knex.raw( ` - SELECT COLUMN_NAME + SELECT CONSTRAINT_NAME, COLUMN_NAME FROM information_schema.TABLE_CONSTRAINTS tc JOIN information_schema.KEY_COLUMN_USAGE kcu USING (CONSTRAINT_NAME, TABLE_SCHEMA, TABLE_NAME) @@ -14124,7 +14184,10 @@ export class SqlDriver implements IDataDriver { ); for (const row of result[0]) { - uniqueColumns.push(row.COLUMN_NAME); + members.push({ + constraint: [String(row.CONSTRAINT_NAME)], + column: row.COLUMN_NAME ?? null, + }); } } else if (this.isSqlite) { const safeTableName = tableName.replace(/[^a-zA-Z0-9_]/g, ''); @@ -14133,16 +14196,26 @@ export class SqlDriver implements IDataDriver { const tableNames = Array.isArray(tablesResult) ? tablesResult.map((row: any) => row.name) : []; if (!tableNames.includes(safeTableName)) { - return uniqueColumns; + return []; } const indexes = await this.knex.raw(`PRAGMA index_list(${safeTableName})`); for (const idx of indexes) { if (idx.unique === 1) { + // `PRAGMA index_info` reports one row per index MEMBER, and a + // member is not always a column: an expression term + // (`CREATE UNIQUE INDEX … ON t (lower(a))`) arrives with + // `name: null`. Every member is carried through so it still + // COUNTS toward the index's width — dropping the unnamed ones + // here would make `(a, lower(b))` look single-column and flag `a`. + // The `null` is discarded later, where it can only cost a flag + // rather than manufacture one. Previously the row's `name` was + // pushed unconditionally, so a one-term expression index put a + // literal `null` into a `string[]`. const info = await this.knex.raw(`PRAGMA index_info(${idx.name})`); - if (info.length === 1) { - uniqueColumns.push(info[0].name); + for (const entry of info) { + members.push({ constraint: [String(idx.name)], column: entry.name ?? null }); } } } @@ -14152,6 +14225,81 @@ export class SqlDriver implements IDataDriver { if (opts.onFailure !== 'partial') throw e; } - return uniqueColumns; + return singleColumnUniqueColumns(members); } } + +/** + * One (constraint, column) membership row, normalised across dialects — + * the input to {@link singleColumnUniqueColumns} (#11202). + */ +export interface UniqueConstraintMember { + /** + * What makes this constraint a DISTINCT constraint within one answer, as + * its identity parts. + * + * Spelled as parts rather than a pre-joined string so each arm has to state + * what it is relying on: Postgres needs `[schema, name]` because its answer + * spans the search path and auto-generated constraint names repeat across + * schemas; MySQL and SQLite pin one schema/database in the query itself and + * so need only the name. Two constraints whose parts are equal are treated + * as one constraint. + */ + constraint: readonly string[]; + /** + * The column this member covers, or `null` for a member that is not a plain + * column — a SQLite expression-index term. A `null` still occupies a member + * slot; it simply cannot be flagged. + */ + column: string | null; +} + +/** + * The one definition of what `introspectUniqueConstraints` reports: the + * columns that ALONE carry a unique constraint (#11202). + * + * A constraint contributes its column iff it has exactly ONE member and that + * member is a real column. Members of a composite constraint contribute + * nothing — `UNIQUE (a, b)` says the PAIR is unique, and a per-column flag + * cannot say that, so claiming it per column is a claim the constraint never + * made. + * + * ## Both defensive choices fail toward NOT flagging, on purpose + * + * Members are counted as they arrive, without de-duplicating identical rows. + * No in-tree arm can produce a duplicate (each query joins one + * constraint-listing row to its own column rows), but if one ever did, the + * inflated count would merely hide a genuine single-column unique. The + * opposite policy — collapsing rows before counting — could merge two real + * members and flag a composite one. An under-claimed flag is a missed + * optimisation downstream; an over-claimed one is the defect being fixed + * here, and it reaches `introspectSchema`'s per-column `isUnique`, the + * federated-object draft (ADR-0015) and schema-drift comparison as fact. + * + * The returned columns are de-duplicated: a column carrying two separate + * single-column unique constraints (or, on SQLite, both a `UNIQUE` clause and + * a hand-made unique index) is one unique column, named once. + */ +export function singleColumnUniqueColumns(members: readonly UniqueConstraintMember[]): string[] { + const byConstraint = new Map(); + + for (const member of members) { + // JSON is used as the composite-key encoding because it is injective: + // joining the parts with a separator would fuse `['a.b', 'c']` and + // `['a', 'b.c']`, and a schema or constraint name really can contain the + // separator (Postgres quotes such identifiers rather than rejecting them). + const key = JSON.stringify(member.constraint); + const existing = byConstraint.get(key); + if (existing) existing.push(member.column); + else byConstraint.set(key, [member.column]); + } + + const uniqueColumns: string[] = []; + for (const columns of byConstraint.values()) { + if (columns.length !== 1) continue; + const only = columns[0]; + if (only == null) continue; + if (!uniqueColumns.includes(only)) uniqueColumns.push(only); + } + return uniqueColumns; +} From 5c4e0744bb12e600aaa333ad9ee6c976db3ebcec Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 10:44:01 +0000 Subject: [PATCH 2/3] chore(changeset): single-column unique introspection (#11202) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Rxnd8cyFnoU8V5y21PaTsy --- ...-sql-single-column-unique-introspection.md | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 .changeset/driver-sql-single-column-unique-introspection.md diff --git a/.changeset/driver-sql-single-column-unique-introspection.md b/.changeset/driver-sql-single-column-unique-introspection.md new file mode 100644 index 0000000000..e7315e8ecf --- /dev/null +++ b/.changeset/driver-sql-single-column-unique-introspection.md @@ -0,0 +1,58 @@ +--- +"@objectstack/driver-sql": patch +--- + +fix(driver-sql): `introspectUniqueConstraints` reports single-column uniqueness on all three dialects (#11202) + +`SqlDriver.introspectUniqueConstraints` returns a flat `string[]` that +`introspectSchema` folds into a per-column `isUnique` flag, and the three dialect arms +disagreed about what that list meant. SQLite pushed a column only when the unique index +had exactly one column; the Postgres and MySQL arms returned **every member of every +composite constraint**. So for `UNIQUE (a, b)` the same table read through Postgres +claimed `a` alone is unique *and* `b` alone is unique — a claim the constraint does not +make — while through SQLite it claimed neither. + +The divergence was latent rather than active until recently: the Postgres arm's query +selected `c.column_name` with no alias `c` in scope, and the bare `catch {}` the method +carried until #11161 turned every execution into `[]`. Live Postgres had therefore never +once reported a unique constraint through this method. Repairing that query is what put +three dialects into conflict on live systems for the first time. + +Per maintainer ruling 2026-08-23 (option A→B), the flag is now narrowed to +**single-column uniqueness only**: a column is reported iff some unique constraint covers +that column and nothing else. A composite constraint's members are deliberately absent — +a per-column boolean is structurally unable to say "a and b are unique *together*", so +setting it on both members asserts something different and false. Representing composite +constraints is option B and waits for real demand; until it exists, an absent flag on a +composite member means "not single-column unique", never "no constraint". + +All three arms now normalise their rows to a `UniqueConstraintMember` and decide through +one predicate, so a fourth dialect cannot quietly acquire a fourth meaning. The Postgres +arm additionally selects `constraint_schema` and keys constraint identity on +`(schema, name)`: its answer spans `current_schemas(false)` and Postgres auto-names a +unique constraint after the table and column, so two same-named tables in two schemas +produce two different constraints under one name — keyed on the name alone they would +fuse into an apparent two-member constraint and drop a genuinely single-column unique +(the #11201 defect class, one method over). + +Two smaller corrections ride the same rewrite, both in the SQLite arm's handling of +`PRAGMA index_info` rows: an expression-index term (`… ON t (lower(a))`) reports +`name: null`, which the arm used to push into a `string[]` as a literal `null` — it is +now discarded, while still counting toward the index's width so `(d, lower(e))` cannot +read as single-column; and the returned columns are de-duplicated, so a column carrying +both a `UNIQUE` clause and a hand-made unique index is named once. + +No interface shape and no accepted input changes, and `isUnique` is only ever *set* to +`true`, so a column that stops being flagged carries `undefined` exactly as an +unconstrained column always has. The one in-tree consumer is +`introspectedSchemaToObjects` in `@objectstack/objectql`, which turns the flag into a +drafted field's `unique: true` — it is the direct beneficiary: composite members no +longer draft fields declaring a single-column uniqueness the database never enforced. + +Verified on embedded SQLite, including the consumer-visible `introspectSchema` fold; the +live Postgres and MySQL cells are declared through the shared dialect matrix and run in +the `Temporal Conformance (live PG + MySQL)` job. The narrowing predicate is pinned +directly against each dialect's real row shape, so the Postgres and MySQL decision is +measurable without a provisioned server. Reverse-verified by ablation: with the width +filter removed, 9 of the new pins fail — the Postgres and MySQL row-shape cases, the +end-to-end SQLite cell, and the `isUnique` fold. From 65f613328d626bb5cc844ca71af648ae0485b02b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 10:55:59 +0000 Subject: [PATCH 3/3] docs(objectql): align the derived IntrospectedColumn.isUnique sentence with the narrowed producer contract (#11655) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The consumer-side copy still stated the composite-member semantics #11202 retires, directly above introspectedSchemaToObjects — the flag's one real consumer. PM-authorized surface amendment on top of the #11202 claim. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Rxnd8cyFnoU8V5y21PaTsy --- packages/objectql/src/util.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/objectql/src/util.ts b/packages/objectql/src/util.ts index 0f2ea015ad..1d6af0a026 100644 --- a/packages/objectql/src/util.ts +++ b/packages/objectql/src/util.ts @@ -31,7 +31,18 @@ import type { * diff-facing contract does not declare. */ export interface IntrospectedColumn extends SpecIntrospectedColumn { - /** Whether this column has a unique constraint */ + /** + * Whether this column ALONE carries a single-column unique constraint — + * true iff some unique constraint covers this column and nothing else. + * + * Membership of a COMPOSITE constraint is deliberately not represented + * (#11202): `UNIQUE (a, b)` constrains the pair, and a per-column boolean + * cannot say that. The producer's declaration — + * `SqlDriver`'s `IntrospectedColumn.isUnique` in `@objectstack/driver-sql` + * — is the contract sentence; this is the consumer-side copy of the same + * key and must not drift from it. An absent flag on a composite member + * means "not single-column unique", never "no constraint". + */ isUnique?: boolean; /** * Maximum length for string types — raw as knex `columnInfo()` reports it: