|
| 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