Skip to content

Commit 992161b

Browse files
os-warrenclaude
andauthored
fix(driver-sql): introspectUniqueConstraints reports single-column uniqueness on all three dialects (#11202) (#11657)
* fix(driver-sql): introspectUniqueConstraints reports single-column uniqueness on all three dialects (#11202) 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rxnd8cyFnoU8V5y21PaTsy * chore(changeset): single-column unique introspection (#11202) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rxnd8cyFnoU8V5y21PaTsy * docs(objectql): align the derived IntrospectedColumn.isUnique sentence with the narrowed producer contract (#11655) 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rxnd8cyFnoU8V5y21PaTsy --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8bcd054 commit 992161b

4 files changed

Lines changed: 551 additions & 11 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
---
2+
"@objectstack/driver-sql": patch
3+
---
4+
5+
fix(driver-sql): `introspectUniqueConstraints` reports single-column uniqueness on all three dialects (#11202)
6+
7+
`SqlDriver.introspectUniqueConstraints` returns a flat `string[]` that
8+
`introspectSchema` folds into a per-column `isUnique` flag, and the three dialect arms
9+
disagreed about what that list meant. SQLite pushed a column only when the unique index
10+
had exactly one column; the Postgres and MySQL arms returned **every member of every
11+
composite constraint**. So for `UNIQUE (a, b)` the same table read through Postgres
12+
claimed `a` alone is unique *and* `b` alone is unique — a claim the constraint does not
13+
make — while through SQLite it claimed neither.
14+
15+
The divergence was latent rather than active until recently: the Postgres arm's query
16+
selected `c.column_name` with no alias `c` in scope, and the bare `catch {}` the method
17+
carried until #11161 turned every execution into `[]`. Live Postgres had therefore never
18+
once reported a unique constraint through this method. Repairing that query is what put
19+
three dialects into conflict on live systems for the first time.
20+
21+
Per maintainer ruling 2026-08-23 (option A→B), the flag is now narrowed to
22+
**single-column uniqueness only**: a column is reported iff some unique constraint covers
23+
that column and nothing else. A composite constraint's members are deliberately absent —
24+
a per-column boolean is structurally unable to say "a and b are unique *together*", so
25+
setting it on both members asserts something different and false. Representing composite
26+
constraints is option B and waits for real demand; until it exists, an absent flag on a
27+
composite member means "not single-column unique", never "no constraint".
28+
29+
All three arms now normalise their rows to a `UniqueConstraintMember` and decide through
30+
one predicate, so a fourth dialect cannot quietly acquire a fourth meaning. The Postgres
31+
arm additionally selects `constraint_schema` and keys constraint identity on
32+
`(schema, name)`: its answer spans `current_schemas(false)` and Postgres auto-names a
33+
unique constraint after the table and column, so two same-named tables in two schemas
34+
produce two different constraints under one name — keyed on the name alone they would
35+
fuse into an apparent two-member constraint and drop a genuinely single-column unique
36+
(the #11201 defect class, one method over).
37+
38+
Two smaller corrections ride the same rewrite, both in the SQLite arm's handling of
39+
`PRAGMA index_info` rows: an expression-index term (`… ON t (lower(a))`) reports
40+
`name: null`, which the arm used to push into a `string[]` as a literal `null` — it is
41+
now discarded, while still counting toward the index's width so `(d, lower(e))` cannot
42+
read as single-column; and the returned columns are de-duplicated, so a column carrying
43+
both a `UNIQUE` clause and a hand-made unique index is named once.
44+
45+
No interface shape and no accepted input changes, and `isUnique` is only ever *set* to
46+
`true`, so a column that stops being flagged carries `undefined` exactly as an
47+
unconstrained column always has. The one in-tree consumer is
48+
`introspectedSchemaToObjects` in `@objectstack/objectql`, which turns the flag into a
49+
drafted field's `unique: true` — it is the direct beneficiary: composite members no
50+
longer draft fields declaring a single-column uniqueness the database never enforced.
51+
52+
Verified on embedded SQLite, including the consumer-visible `introspectSchema` fold; the
53+
live Postgres and MySQL cells are declared through the shared dialect matrix and run in
54+
the `Temporal Conformance (live PG + MySQL)` job. The narrowing predicate is pinned
55+
directly against each dialect's real row shape, so the Postgres and MySQL decision is
56+
measurable without a provisioned server. Reverse-verified by ablation: with the width
57+
filter removed, 9 of the new pins fail — the Postgres and MySQL row-shape cases, the
58+
end-to-end SQLite cell, and the `isUnique` fold.
Lines changed: 323 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,323 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#11202] `introspectUniqueConstraints` reports SINGLE-COLUMN uniqueness
5+
* only, and reports it identically on all three dialects.
6+
*
7+
* The three arms used to answer three different questions. SQLite kept unique
8+
* indexes of exactly one column (`info.length === 1`); Postgres and MySQL
9+
* returned every member of every composite constraint. `introspectSchema`
10+
* folds the flat `string[]` into a per-column `isUnique`, so for `UNIQUE
11+
* (a, b)` the same table read through Postgres claimed `a` alone is unique and
12+
* `b` alone is unique — a claim the constraint does not make — while through
13+
* SQLite it claimed neither.
14+
*
15+
* The divergence was LATENT until #11161: the Postgres arm's query named an
16+
* alias that was not in scope, and the bare `catch {}` it carried turned every
17+
* execution into `[]`. Repairing the query is what put three live answers into
18+
* conflict for the first time.
19+
*
20+
* Maintainer ruling 2026-08-23 (option A→B), verbatim and untranslated:
21+
* 「10950 不考虑存量,其他接受你的建议」 — narrow the flag to single-column
22+
* uniqueness now; a composite representation waits for real demand.
23+
*
24+
* ## Why this file has two halves
25+
*
26+
* The **predicate half** feeds `singleColumnUniqueColumns` the exact row
27+
* shapes each dialect's query returns. It runs everywhere, which matters
28+
* because the Postgres and MySQL narrowing is otherwise only measurable on a
29+
* provisioned live server — the arms now group in JS precisely so the decision
30+
* is testable without one. What it cannot prove is that the queries really
31+
* return those rows.
32+
*
33+
* The **live half** proves that, end to end, on every provisioned cell, and is
34+
* declared through `declareDialectCell` so an unprovisioned dialect is a NAMED
35+
* skip (a red under `OS_EXPECT_LIVE_DIALECT_MATRIX=1`), never a silent pass.
36+
*
37+
* ## The interesting assertion is an ABSENCE, so the fixture is proven first
38+
*
39+
* "`a` is not flagged" goes green for free on a table whose composite
40+
* constraint never got created. Every live cell therefore first makes the
41+
* DATABASE state its own witness: two rows sharing `a` are ACCEPTED (so `a`
42+
* alone is genuinely not unique — exactly what the flag must not claim), a
43+
* repeat of the `(a, b)` pair is REJECTED (so the composite constraint exists
44+
* and is enforced), and a repeat of `email` is REJECTED (so the single-column
45+
* constraint that must be flagged exists too). If the fixture is not real,
46+
* that case fails before any absence is asserted.
47+
*/
48+
49+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
50+
import { SqlDriver, singleColumnUniqueColumns } from './sql-driver.js';
51+
import { DIALECT_CELLS, declareDialectCell, type DialectCell } from './live-dialect-matrix.testkit.js';
52+
53+
const MATRIX = 'single-column unique introspection';
54+
55+
/** Composite `UNIQUE (a, b)` plus a single-column `UNIQUE (email)`. */
56+
const TABLE = 'os11202_uniq';
57+
58+
/** `introspectUniqueConstraints` is `protected`; this is the narrowest reach. */
59+
class UniqueProbeDriver extends SqlDriver {
60+
uniqueConstraints(table: string) {
61+
return this.introspectUniqueConstraints(table);
62+
}
63+
}
64+
65+
// ── Half 1: the predicate, on every dialect's real row shape ────────────────
66+
67+
describe('singleColumnUniqueColumns — the one definition of what the flag means (#11202)', () => {
68+
it('keeps a one-member constraint and drops every member of a composite one', () => {
69+
expect(
70+
singleColumnUniqueColumns([
71+
{ constraint: ['pair'], column: 'a' },
72+
{ constraint: ['pair'], column: 'b' },
73+
{ constraint: ['solo'], column: 'email' },
74+
]),
75+
).toEqual(['email']);
76+
});
77+
78+
it('a three-column constraint contributes nothing — width is counted, not assumed to be two', () => {
79+
expect(
80+
singleColumnUniqueColumns([
81+
{ constraint: ['triple'], column: 'a' },
82+
{ constraint: ['triple'], column: 'b' },
83+
{ constraint: ['triple'], column: 'c' },
84+
]),
85+
).toEqual([]);
86+
});
87+
88+
it('a column carrying TWO separate single-column constraints is named once', () => {
89+
expect(
90+
singleColumnUniqueColumns([
91+
{ constraint: ['by_clause'], column: 'email' },
92+
{ constraint: ['by_index'], column: 'email' },
93+
]),
94+
).toEqual(['email']);
95+
});
96+
97+
it('a column that is BOTH a composite member and single-column unique is still flagged', () => {
98+
// The composite membership must not veto the standalone constraint: `a`
99+
// really is unique on its own here, by a constraint of its own.
100+
expect(
101+
singleColumnUniqueColumns([
102+
{ constraint: ['pair'], column: 'a' },
103+
{ constraint: ['pair'], column: 'b' },
104+
{ constraint: ['solo_a'], column: 'a' },
105+
]),
106+
).toEqual(['a']);
107+
});
108+
109+
it('an empty answer stays empty — no constraint, nothing flagged', () => {
110+
expect(singleColumnUniqueColumns([])).toEqual([]);
111+
});
112+
113+
describe('the Postgres row shape — identity is (schema, name), not name alone', () => {
114+
/**
115+
* The arm's answer spans `current_schemas(false)`, and Postgres auto-names
116+
* a unique constraint `<table>_<column>_key` — so two same-named tables in
117+
* two schemas hand back two DIFFERENT constraints under one name. Keyed on
118+
* the name alone they fuse into an apparent two-member constraint and the
119+
* genuine single-column unique disappears from the answer. This is the
120+
* #11201 defect class, one method over.
121+
*/
122+
it('same constraint name in two schemas stays two constraints', () => {
123+
const rows = [
124+
{ constraint_schema: 'app', constraint_name: 'orders_email_key', column_name: 'email' },
125+
{ constraint_schema: 'other', constraint_name: 'orders_email_key', column_name: 'email' },
126+
];
127+
const members = rows.map((row) => ({
128+
constraint: [row.constraint_schema, row.constraint_name],
129+
column: row.column_name,
130+
}));
131+
expect(singleColumnUniqueColumns(members)).toEqual(['email']);
132+
133+
// The counterfactual: keyed on the name alone, the same rows lose it.
134+
const nameOnly = rows.map((row) => ({
135+
constraint: [row.constraint_name],
136+
column: row.column_name,
137+
}));
138+
expect(singleColumnUniqueColumns(nameOnly)).toEqual([]);
139+
});
140+
141+
it('a composite constraint in one schema is dropped, its single-column sibling kept', () => {
142+
const rows = [
143+
{ constraint_schema: 'app', constraint_name: 'os11202_ab', column_name: 'a' },
144+
{ constraint_schema: 'app', constraint_name: 'os11202_ab', column_name: 'b' },
145+
{ constraint_schema: 'app', constraint_name: 'os11202_email', column_name: 'email' },
146+
];
147+
expect(
148+
singleColumnUniqueColumns(
149+
rows.map((row) => ({
150+
constraint: [row.constraint_schema, row.constraint_name],
151+
column: row.column_name,
152+
})),
153+
),
154+
).toEqual(['email']);
155+
});
156+
});
157+
158+
describe('the MySQL row shape — SCREAMING keys, one identity part', () => {
159+
it('composite members are dropped, the single-column constraint kept', () => {
160+
const rows = [
161+
{ CONSTRAINT_NAME: 'os11202_ab', COLUMN_NAME: 'a' },
162+
{ CONSTRAINT_NAME: 'os11202_ab', COLUMN_NAME: 'b' },
163+
{ CONSTRAINT_NAME: 'os11202_email', COLUMN_NAME: 'email' },
164+
];
165+
expect(
166+
singleColumnUniqueColumns(
167+
rows.map((row) => ({ constraint: [row.CONSTRAINT_NAME], column: row.COLUMN_NAME })),
168+
),
169+
).toEqual(['email']);
170+
});
171+
});
172+
173+
describe('the SQLite row shape — an index member is not always a column', () => {
174+
it('a one-term EXPRESSION index contributes nothing, and never a null', () => {
175+
// `PRAGMA index_info` reports `name: null` for an expression term. The
176+
// arm used to push that row's `name` straight into a `string[]`.
177+
const flagged = singleColumnUniqueColumns([
178+
{ constraint: ['os11202_lower_c'], column: null },
179+
]);
180+
expect(flagged).toEqual([]);
181+
expect(flagged).not.toContain(null);
182+
});
183+
184+
it('a column PAIRED with an expression term is not single-column unique', () => {
185+
// The unnamed member still occupies a slot: `(d, lower(e))` is a
186+
// two-member index, so `d` alone is not unique and must not be flagged.
187+
expect(
188+
singleColumnUniqueColumns([
189+
{ constraint: ['os11202_d_lower_e'], column: 'd' },
190+
{ constraint: ['os11202_d_lower_e'], column: null },
191+
]),
192+
).toEqual([]);
193+
});
194+
});
195+
});
196+
197+
// ── Half 2: end to end, on every provisioned dialect ────────────────────────
198+
199+
function declareSingleColumnUniqueSuite(cell: DialectCell): void {
200+
describe(`introspectUniqueConstraints — single-column only — ${cell.label} (#11202)`, () => {
201+
let driver: UniqueProbeDriver;
202+
203+
beforeAll(async () => {
204+
driver = new UniqueProbeDriver(cell.config());
205+
await driver.execute(`drop table if exists ${TABLE}`).catch(() => {});
206+
// No primary key on purpose: SQLite materialises a non-INTEGER primary
207+
// key as a unique auto-index that `PRAGMA index_list` reports, while the
208+
// Postgres and MySQL arms filter on `CONSTRAINT_TYPE = 'UNIQUE'` and so
209+
// never see primary keys at all. Keeping keys out of the fixture makes
210+
// this suite measure the composite-vs-single question and nothing else.
211+
await driver.execute(
212+
`create table ${TABLE} (
213+
a varchar(64) not null,
214+
b varchar(64) not null,
215+
email varchar(64) not null,
216+
note varchar(64),
217+
constraint os11202_ab unique (a, b),
218+
constraint os11202_email unique (email)
219+
)`,
220+
);
221+
});
222+
223+
afterAll(async () => {
224+
await driver.execute(`drop table if exists ${TABLE}`).catch(() => {});
225+
await driver.disconnect().catch(() => {});
226+
});
227+
228+
it('the fixture is real: `a` alone is NOT unique, the pair IS, and `email` IS', async () => {
229+
// Non-vacuity, asserted against the server rather than the catalog: an
230+
// absence assertion below is worthless if the constraints never landed.
231+
await driver.execute(`insert into ${TABLE} (a, b, email) values ('x', '1', 'e1@example.com')`);
232+
233+
// Two rows sharing `a` — ACCEPTED. This is the fact the flag must not
234+
// contradict: `a` is not unique on its own.
235+
await driver.execute(`insert into ${TABLE} (a, b, email) values ('x', '2', 'e2@example.com')`);
236+
237+
// The PAIR repeated — REJECTED, so the composite constraint is enforced.
238+
await expect(
239+
driver.execute(`insert into ${TABLE} (a, b, email) values ('x', '1', 'e3@example.com')`),
240+
).rejects.toThrow();
241+
242+
// `email` repeated — REJECTED, so the single-column constraint exists.
243+
await expect(
244+
driver.execute(`insert into ${TABLE} (a, b, email) values ('y', '9', 'e1@example.com')`),
245+
).rejects.toThrow();
246+
});
247+
248+
it('reports the single-column unique column and NEITHER member of the composite one', async () => {
249+
const columns = await driver.uniqueConstraints(TABLE);
250+
251+
expect(columns).toContain('email');
252+
expect(columns).not.toContain('a');
253+
expect(columns).not.toContain('b');
254+
expect(columns).not.toContain('note');
255+
// Exact, so a dialect that starts reporting something extra is caught
256+
// rather than absorbed by the three `not.toContain`s above.
257+
expect(columns).toEqual(['email']);
258+
});
259+
260+
it("`introspectSchema` folds that into `isUnique` — the consumer-visible half", async () => {
261+
const schema = await driver.introspectSchema();
262+
const table = schema.tables[TABLE];
263+
expect(table, `${TABLE} missing from the introspected schema`).toBeDefined();
264+
265+
const byName = Object.fromEntries(table.columns.map((col) => [col.name, col]));
266+
expect(byName.email?.isUnique).toBe(true);
267+
// Falsy, not `false`: the flag is only ever SET to `true`, so a
268+
// non-unique column carries `undefined` and asserting `false` would pin
269+
// a shape the producer does not emit.
270+
expect(byName.a?.isUnique).toBeFalsy();
271+
expect(byName.b?.isUnique).toBeFalsy();
272+
expect(byName.note?.isUnique).toBeFalsy();
273+
});
274+
});
275+
}
276+
277+
for (const cell of DIALECT_CELLS) {
278+
declareDialectCell(cell, MATRIX, declareSingleColumnUniqueSuite);
279+
}
280+
281+
// ── SQLite-only: the member shapes no other dialect can produce ─────────────
282+
283+
describe('SQLite expression indexes — a unique index member that is not a column (#11202)', () => {
284+
const EXPR_TABLE = 'os11202_expr';
285+
let driver: UniqueProbeDriver;
286+
287+
beforeAll(async () => {
288+
driver = new UniqueProbeDriver({
289+
client: 'better-sqlite3',
290+
connection: { filename: ':memory:' },
291+
useNullAsDefault: true,
292+
});
293+
await driver.execute(
294+
`create table ${EXPR_TABLE} (c varchar(64), d varchar(64), e varchar(64), f varchar(64))`,
295+
);
296+
// One-term expression index: a single member carrying no column name.
297+
await driver.execute(`create unique index os11202_lower_c on ${EXPR_TABLE} (lower(c))`);
298+
// Column + expression: two members, so `d` is not unique on its own.
299+
await driver.execute(`create unique index os11202_d_lower_e on ${EXPR_TABLE} (d, lower(e))`);
300+
// A plain single-column unique index, so this suite has a positive too.
301+
await driver.execute(`create unique index os11202_f on ${EXPR_TABLE} (f)`);
302+
});
303+
304+
afterAll(async () => {
305+
await driver.disconnect().catch(() => {});
306+
});
307+
308+
it('PRAGMA index_info really reports a null name for an expression term', async () => {
309+
// Pins the premise the arm's null-handling rests on. If SQLite ever named
310+
// these members, the handling would be dead code and should be re-read.
311+
const info: any = await driver.execute(`PRAGMA index_info(os11202_lower_c)`);
312+
const rows = Array.isArray(info) ? info : [];
313+
expect(rows).toHaveLength(1);
314+
expect(rows[0].name).toBeNull();
315+
});
316+
317+
it('flags only the plain single-column index — no nulls, no expression terms', async () => {
318+
const columns = await driver.uniqueConstraints(EXPR_TABLE);
319+
expect(columns).toEqual(['f']);
320+
expect(columns).not.toContain(null);
321+
expect(columns).not.toContain('d');
322+
});
323+
});

0 commit comments

Comments
 (0)