|
173 | 173 | * ⚠️ Scope, as `generateMigrationSql`'s docblock and the `--format` help text |
174 | 174 | * already say (#15521): this is a POSTGRESQL claim and nothing else. Neither |
175 | 175 | * generator reproduces the driver's dialect branching. |
| 176 | + * |
| 177 | + * ## ⭐ ...and the oracle runs on SQLite, so the PostgreSQL claim rests on a |
| 178 | + * PREMISE — now measured rather than assumed (#16394) |
| 179 | + * |
| 180 | + * `PRAGMA table_info` is the tell: the real chain below runs against an |
| 181 | + * in-memory better-sqlite3 database while every width it asserts is a |
| 182 | + * PostgreSQL claim. That is sound exactly while ONE premise holds — *the width |
| 183 | + * the chain produces does not depend on the dialect* — and nothing was holding |
| 184 | + * it. Measured as a mutation battery against this very oracle: |
| 185 | + * |
| 186 | + * ``` |
| 187 | + * leg driver-side mutation this file |
| 188 | + * L7 a Postgres gate on `createColumn`'s quoted line 2 failed / 79 |
| 189 | + * L7b the same gate as the FIRST LINE of all green |
| 190 | + * `keyableTextLength` |
| 191 | + * ``` |
| 192 | + * |
| 193 | + * L7b changes the column a real PostgreSQL deployment gets and this file |
| 194 | + * reported everything fine; L7 reddened only because a source-text assertion |
| 195 | + * here happens to quote the line the mutation landed on — luck about placement, |
| 196 | + * not coverage. The premise is now carried in two halves: |
| 197 | + * |
| 198 | + * - HERE, in §F2's dialect-parity block: the two width bodies and the |
| 199 | + * dispatch mirror are asked on a driver configured for EACH dialect the |
| 200 | + * emitter branches on, and must answer identically. No server is needed — |
| 201 | + * `isPostgres` reads the CONFIG — so it runs wherever this file runs, and |
| 202 | + * it reddens on L7b. |
| 203 | + * - In `driver-sql`, by |
| 204 | + * `sql-driver-16394-character-width-dialect-parity.test.ts`: the same |
| 205 | + * object through the same real chain on a LIVE Postgres and MySQL, columns |
| 206 | + * read back from the server's own catalog and compared against the SQLite |
| 207 | + * ones. That half also covers `createColumn`'s DISPATCH, which asking the |
| 208 | + * bodies cannot see move. ⚠️ It lives there and not here because the |
| 209 | + * `Temporal Conformance (live PG + MySQL)` job — the only job in this |
| 210 | + * repository that provisions a live server — runs `pnpm --filter |
| 211 | + * @objectstack/driver-sql test` and nothing else, so a live cell written |
| 212 | + * into this file would be provisioned by no job and would report itself |
| 213 | + * un-run forever. |
176 | 214 | */ |
177 | 215 |
|
178 | 216 | import fs from 'node:fs'; |
@@ -966,6 +1004,20 @@ class DriverOracle extends SqlDriver { |
966 | 1004 | return this.declaredVarcharLength(field); |
967 | 1005 | } |
968 | 1006 |
|
| 1007 | + /** |
| 1008 | + * `SqlDriver.varcharColumnChars`, unmodified — the emitter's own read-only |
| 1009 | + * mirror of which arm `createColumn` sends a field to, and at what width. |
| 1010 | + * |
| 1011 | + * ⚠️ A MIRROR, not the dispatch. It is the driver's own second copy of that |
| 1012 | + * switch, so it reaches one layer past the two width bodies and still stops |
| 1013 | + * short of `createColumn` itself; the real chain is {@link createdColumns}, |
| 1014 | + * and its live-dialect half is `driver-sql`'s |
| 1015 | + * `sql-driver-16394-character-width-dialect-parity.test.ts`. |
| 1016 | + */ |
| 1017 | + public varcharCharsFor(field: unknown, keyed?: { unique: boolean }): number | null { |
| 1018 | + return this.varcharColumnChars(field, keyed); |
| 1019 | + } |
| 1020 | + |
969 | 1021 | /** |
970 | 1022 | * ⭐ THE REAL CHAIN. The columns `initObjects` actually creates for one |
971 | 1023 | * object, read back out of the database it created them in. |
@@ -1006,8 +1058,34 @@ const ORACLE = new DriverOracle({ |
1006 | 1058 | useNullAsDefault: true, |
1007 | 1059 | }); |
1008 | 1060 |
|
| 1061 | +/** |
| 1062 | + * [#16394] The SAME oracle, configured for each dialect the emitter branches on. |
| 1063 | + * |
| 1064 | + * ⭐ These are never dialled. `isPostgres` / `isMysql` are derived from |
| 1065 | + * `SqlDriver.clientSpelling(this.config)` — a read of the CONFIG, with no |
| 1066 | + * connection anywhere in it — so a dialect-configured driver answers every |
| 1067 | + * declaration-only question this file asks without a server existing. That is |
| 1068 | + * what makes the dialect axis affordable HERE, in Test Core, rather than only |
| 1069 | + * in the one job that provisions live servers. |
| 1070 | + * |
| 1071 | + * The connection strings are deliberately unreachable (port 1) and deliberately |
| 1072 | + * present: knex wants one, and a value nothing can dial is the loudest possible |
| 1073 | + * statement that nothing is meant to. |
| 1074 | + */ |
| 1075 | +const DIALECT_ORACLES: ReadonlyArray<{ dialect: string; oracle: DriverOracle }> = [ |
| 1076 | + { dialect: 'sqlite', oracle: ORACLE }, |
| 1077 | + { |
| 1078 | + dialect: 'postgres', |
| 1079 | + oracle: new DriverOracle({ client: 'pg', connection: 'postgres://never:dialled@127.0.0.1:1/none' }), |
| 1080 | + }, |
| 1081 | + { |
| 1082 | + dialect: 'mysql', |
| 1083 | + oracle: new DriverOracle({ client: 'mysql2', connection: 'mysql://never:dialled@127.0.0.1:1/none' }), |
| 1084 | + }, |
| 1085 | +]; |
| 1086 | + |
1009 | 1087 | afterAll(async () => { |
1010 | | - await ORACLE.disconnect(); |
| 1088 | + for (const { oracle } of DIALECT_ORACLES) await oracle.disconnect(); |
1011 | 1089 | }); |
1012 | 1090 |
|
1013 | 1091 | /** |
@@ -1626,6 +1704,97 @@ describe('#16091 — the driver is the ORACLE, not just the source text', () => |
1626 | 1704 | expect(sqlWidth(columnsFor({ type: 'email', maxLength: 1000 }).sql)).toBe(1000); |
1627 | 1705 | }); |
1628 | 1706 |
|
| 1707 | + // ── F2b: the DIALECT axis (#16394) ──────────────────────────────────────── |
| 1708 | + // |
| 1709 | + // Everything above — source readers, leaf differentials and the real chain |
| 1710 | + // alike — asks ONE driver, configured for SQLite, and then asserts the answer |
| 1711 | + // as a POSTGRESQL claim. The step between the two is a premise nothing was |
| 1712 | + // holding: that the width does not depend on the dialect. It is false as soon |
| 1713 | + // as anyone writes a dialect gate into one of these bodies, and a gate inside |
| 1714 | + // `keyableTextLength` left every assertion in this file green while changing |
| 1715 | + // the column a real PostgreSQL deployment gets. |
| 1716 | + // |
| 1717 | + // ⛔ NOT a source-text guard over the width arm. "No Postgres token appears |
| 1718 | + // in these lines" reddens because a TOKEN appeared, which a rename evades and |
| 1719 | + // which is the layer this file spent four rounds leaving. What follows CALLS |
| 1720 | + // the driver's own bodies on a driver configured for each dialect and |
| 1721 | + // compares the VALUES they return, so it reddens because the width moved. |
| 1722 | + // |
| 1723 | + // ⚠️ What it does not reach: `createColumn`'s dispatch, and everything |
| 1724 | + // `initObjects` does around it, on a real Postgres. That needs a server, and |
| 1725 | + // it is measured — over this same corpus of arms — by `driver-sql`'s |
| 1726 | + // `sql-driver-16394-character-width-dialect-parity.test.ts`, which runs in |
| 1727 | + // the one job that provisions one. |
| 1728 | + |
| 1729 | + it('control — the dialect oracles really are DIFFERENT dialects, unconnected', () => { |
| 1730 | + // ⛔ Non-vacuity, and the whole load-bearing half of it: if all three |
| 1731 | + // instances reported the same dialect, the parity sweep below would compare |
| 1732 | + // one driver against itself 38 times and pass while measuring nothing. |
| 1733 | + expect(DIALECT_ORACLES.map((d) => d.oracle.dialectName)).toEqual(['sqlite', 'postgres', 'mysql']); |
| 1734 | + expect(DIALECT_ORACLES.map((d) => d.dialect)).toEqual(['sqlite', 'postgres', 'mysql']); |
| 1735 | + // `dialectName` is derived from the config, so it answers with no server — |
| 1736 | + // which is what makes this block affordable outside the live job. |
| 1737 | + expect(new Set(DIALECT_ORACLES.map((d) => d.oracle.dialectName)).size).toBe(3); |
| 1738 | + // ...and every one of them really is a driver that still answers. |
| 1739 | + for (const { dialect, oracle } of DIALECT_ORACLES) { |
| 1740 | + expect(oracle, dialect).toBeInstanceOf(SqlDriver); |
| 1741 | + expect(oracle.keyableCharsFor({ maxLength: PROBE_CHARS }), dialect).toBe(PROBE_CHARS); |
| 1742 | + } |
| 1743 | + }); |
| 1744 | + |
| 1745 | + it('both width bodies and the emitter mirror answer the SAME on every dialect', () => { |
| 1746 | + const disagreements: string[] = []; |
| 1747 | + const answers = new Set<string>(); |
| 1748 | + let compared = 0; |
| 1749 | + |
| 1750 | + const record = (what: string, shown: string, per: (number | null)[]): void => { |
| 1751 | + const distinct = new Set(per.map((v) => String(v))); |
| 1752 | + answers.add([...distinct].sort().join('|')); |
| 1753 | + compared += 1; |
| 1754 | + if (distinct.size !== 1) { |
| 1755 | + disagreements.push( |
| 1756 | + `${what} @ ${shown}: ` + |
| 1757 | + DIALECT_ORACLES.map((d, i) => `${d.dialect}=${String(per[i])}`).join(' '), |
| 1758 | + ); |
| 1759 | + } |
| 1760 | + }; |
| 1761 | + |
| 1762 | + for (const maxLength of WIDTH_DECLARATIONS) { |
| 1763 | + const shown = JSON.stringify(maxLength) ?? String(maxLength); |
| 1764 | + record('keyableTextLength', shown, DIALECT_ORACLES.map((d) => d.oracle.keyableCharsFor({ maxLength }))); |
| 1765 | + record('declaredVarcharLength', shown, DIALECT_ORACLES.map((d) => d.oracle.declaredCharsFor({ maxLength }))); |
| 1766 | + // The mirror, over every character TYPE and both keyednesses — so a gate |
| 1767 | + // written into the SWITCH rather than into a width body is seen too, at |
| 1768 | + // the one layer that can be reached without a server. MEMBERSHIP is the |
| 1769 | + // driver's own, read off its case labels, exactly as the sweeps above. |
| 1770 | + for (const type of [...armMembers('text'), ...armMembers('email'), ...characterCatchAllMembers()]) { |
| 1771 | + const field: Record<string, unknown> = { type }; |
| 1772 | + if (maxLength !== undefined) field.maxLength = maxLength; |
| 1773 | + for (const keyed of [undefined, { unique: true }]) { |
| 1774 | + record( |
| 1775 | + `varcharColumnChars(${type}${keyed ? ' keyed' : ''})`, |
| 1776 | + shown, |
| 1777 | + DIALECT_ORACLES.map((d) => d.oracle.varcharCharsFor(field, keyed)), |
| 1778 | + ); |
| 1779 | + } |
| 1780 | + } |
| 1781 | + } |
| 1782 | + |
| 1783 | + // Non-vacuity: the sweep really ran over every declaration, and the driver |
| 1784 | + // really gave more than one answer across it — a body that returned one |
| 1785 | + // constant everywhere would agree with itself on every dialect. |
| 1786 | + expect(compared).toBeGreaterThan(WIDTH_DECLARATIONS.length * 2); |
| 1787 | + expect(answers.size).toBeGreaterThan(3); |
| 1788 | + |
| 1789 | + expect( |
| 1790 | + disagreements.slice(0, 20), |
| 1791 | + `${disagreements.length} of ${compared} width questions are answered differently by ` + |
| 1792 | + 'dialect. The generators emit ONE dialect\'s DDL, and this file reads its oracle off ' + |
| 1793 | + 'another — that is only sound while the driver\'s character width is dialect-invariant. ' + |
| 1794 | + 'Fix driver-sql, never this expectation.', |
| 1795 | + ).toEqual([]); |
| 1796 | + }); |
| 1797 | + |
1629 | 1798 | it('every character TYPE, at every declaration, takes the width initObjects CREATES', async () => { |
1630 | 1799 | // ⭐ The width half of F1b, and it reaches one layer the two cases above |
1631 | 1800 | // cannot: `createColumn`'s DISPATCH. `ORACLE.keyableCharsFor(...)` answers |
|
0 commit comments