Skip to content

Commit c4d9472

Browse files
fix(driver-sql): honour a string field's declared maxLength in emitted DDL
`createColumn` mapped the string family (string/email/url/phone/password) with a bare `table.string(name)`, so every column took knex's default of 255 and the field's `maxLength` was never read. Measured through the driver's own `initObjects` on live MySQL 8.0.46 and Postgres 16: a `maxLength: 400` email, a `maxLength: 1024` url and a `maxLength: 20` phone all landed as `varchar(255)`, and a 300-character write the declaration plainly permits was refused by both (`ER_DATA_TOO_LONG`; `22001 value too long for type character varying(255)`). The bound now binds in both directions. Narrowing is not a destructive migration: `createColumn`'s four call sites are all CREATE TABLE / ALTER ADD COLUMN, so the column it sizes is always empty. Narrowing an existing populated column stays where it was — the `narrow_varchar` drift op, behind `os migrate apply --allow-destructive`. Past the varchar ceiling (measured: MySQL refuses 16384 with ERROR 1074) the column becomes TEXT rather than being clamped, since clamping would reinstate the defect. lookup/user, autonumber and the catch-all keep 255 — none of them stores the declared value. schema-drift.ts is in the diff because the emitter and the differ must agree on which declarations count: it now applies the same positive-integer predicate (`maxLength: 0` planned `varchar(0)` at severity error/destructive), and no longer reads a MySQL TEXT column as a varchar 65535 wide (measured: MySQL reports character_maximum_length 65535 for TEXT, Postgres NULL — so every bounded unkeyed text column reported a permanent destructive narrow_varchar on MySQL only). Part of #11431 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RfyXxZ2WPjcjhuXpiQQc3y
1 parent daacc10 commit c4d9472

3 files changed

Lines changed: 448 additions & 11 deletions

File tree

‎packages/drivers/driver-sql/src/schema-drift.ts‎

Lines changed: 62 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,38 @@ function enforcesVarcharLength(dialect: SqlDialectName): boolean {
374374
return dialect === 'postgres' || dialect === 'mysql';
375375
}
376376

377+
/**
378+
* Is this physical column a `varchar`/`char` — the only kind that HAS a
379+
* declared length to compare against (#11431)?
380+
*
381+
* Without this the length branch below read a MySQL **TEXT** column as a
382+
* varchar 65535 wide, because that is literally what the server reports for it.
383+
* Measured on MySQL 8.0.46 and Postgres 16, same two columns:
384+
*
385+
* MySQL `text` character_maximum_length = 65535
386+
* Postgres `text` character_maximum_length = NULL
387+
* both `varchar(30)` character_maximum_length = 30
388+
*
389+
* So the defect was MySQL-only and invisible on Postgres. Every bounded,
390+
* unkeyed text field — the shape `createColumn` deliberately leaves as TEXT —
391+
* diffed as "declared 4000, column allows 65535" and produced a
392+
* `narrow_varchar` op at severity `error`, category **destructive**, against a
393+
* table the driver had just created and which held no rows. Measured on live
394+
* MySQL: `sys_email`'s envelope alone accounts for seven such findings, each
395+
* inviting `os migrate apply --allow-destructive` to rewrite a TEXT column into
396+
* a varchar for no reason.
397+
*
398+
* A TEXT column refuses nothing a `maxLength` allows, so there is no
399+
* divergence to plan an ALTER for; the bound is enforced at the write seam.
400+
* Spelled as a substring test rather than an equality because the three
401+
* dialects disagree on the word — Postgres says `character varying`, MySQL and
402+
* SQLite say `varchar` — matching the predicate `introspectSchema` already
403+
* uses for the same question.
404+
*/
405+
function isCharacterColumn(type: string | undefined): boolean {
406+
return /char/i.test(String(type ?? ''));
407+
}
408+
377409
/**
378410
* Diff one table's metadata fields against its physical columns and return the
379411
* set of *drift* findings. Metadata is authoritative.
@@ -487,37 +519,58 @@ export function diffManagedTable(args: {
487519
}
488520

489521
// ── varchar length (only where the dialect enforces it) ──────────
522+
//
523+
// `maxLength` must be a POSITIVE INTEGER to be a bound (#11431). Without
524+
// that predicate this branch read a malformed declaration as authoritative
525+
// and planned DDL no server will accept: `maxLength: 0` took the narrowing
526+
// arm (`0 > col.maxLength` is false) and asked for `varchar(0)`, and
527+
// `maxLength: 12.5` asked for `varchar(12.5)` — both reported at severity
528+
// `error`, category `destructive`, i.e. as work `os migrate apply
529+
// --allow-destructive` should go do.
530+
//
531+
// It is the same predicate the EMITTER applies
532+
// (`SqlDriver.declaredVarcharLength`, and `keyableTextLength` before it):
533+
// a malformed bound is treated as no bound at all, and the column keeps
534+
// its default width. Sharing the predicate is the point — the two halves
535+
// disagreeing about which declarations count is the defect class #11431
536+
// exists to close, and a differ that still honoured a malformed
537+
// `maxLength` would have re-opened it one case to the left.
538+
const declaredMaxLength =
539+
typeof field.maxLength === 'number' && Number.isInteger(field.maxLength) && field.maxLength > 0
540+
? field.maxLength
541+
: undefined;
490542
if (
491543
enforcesVarcharLength(dialect) &&
492-
typeof field.maxLength === 'number' &&
544+
declaredMaxLength !== undefined &&
545+
isCharacterColumn(col.type) &&
493546
typeof col.maxLength === 'number' &&
494-
field.maxLength !== col.maxLength
547+
declaredMaxLength !== col.maxLength
495548
) {
496-
if (field.maxLength > col.maxLength) {
549+
if (declaredMaxLength > col.maxLength) {
497550
out.push({
498551
kind: 'type_mismatch',
499552
remoteName: table,
500553
table,
501554
column: fieldName,
502-
expected: `varchar(${field.maxLength})`,
555+
expected: `varchar(${declaredMaxLength})`,
503556
actual: `varchar(${col.maxLength})`,
504557
severity: 'warning',
505558
category: 'safe',
506-
op: { type: 'widen_varchar', table, column: fieldName, to: field.maxLength, from: col.maxLength },
507-
message: `${table}.${fieldName}: metadata allows ${field.maxLength} chars but the column caps at ${col.maxLength} — widen via "os migrate".`,
559+
op: { type: 'widen_varchar', table, column: fieldName, to: declaredMaxLength, from: col.maxLength },
560+
message: `${table}.${fieldName}: metadata allows ${declaredMaxLength} chars but the column caps at ${col.maxLength} — widen via "os migrate".`,
508561
});
509562
} else {
510563
out.push({
511564
kind: 'type_mismatch',
512565
remoteName: table,
513566
table,
514567
column: fieldName,
515-
expected: `varchar(${field.maxLength})`,
568+
expected: `varchar(${declaredMaxLength})`,
516569
actual: `varchar(${col.maxLength})`,
517570
severity: 'error',
518571
category: 'destructive',
519-
op: { type: 'narrow_varchar', table, column: fieldName, to: field.maxLength, from: col.maxLength },
520-
message: `${table}.${fieldName}: metadata caps at ${field.maxLength} chars but the column allows ${col.maxLength} — narrowing may truncate. "os migrate apply --allow-destructive".`,
572+
op: { type: 'narrow_varchar', table, column: fieldName, to: declaredMaxLength, from: col.maxLength },
573+
message: `${table}.${fieldName}: metadata caps at ${declaredMaxLength} chars but the column allows ${col.maxLength} — narrowing may truncate. "os migrate apply --allow-destructive".`,
521574
});
522575
}
523576
}
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #11431 — the STRING family and its declared `maxLength`.
5+
*
6+
* ## The defect
7+
*
8+
* `createColumn` mapped `string` / `email` / `url` / `phone` / `password` with a
9+
* bare `table.string(name)`, so every one of them took knex's invented default
10+
* of 255 and the field's own `maxLength` was never read. Measured on the
11+
* pre-fix tree against live MySQL 8.0.46 and Postgres 16, through the driver's
12+
* own `initObjects`:
13+
*
14+
* wide_email email({ maxLength: 400 }) -> varchar(255)
15+
* wide_url url({ maxLength: 1024 }) -> varchar(255)
16+
* narrow_phone phone({ maxLength: 20 }) -> varchar(255)
17+
*
18+
* and a 300-character write into the `maxLength: 1024` column — legal under the
19+
* declaration, and accepted by the record validator's own `max_length` check —
20+
* was refused by both enforcing dialects: MySQL `ER_DATA_TOO_LONG`, Postgres
21+
* `22001 value too long for type character varying(255)`.
22+
*
23+
* The bound was inert in BOTH directions, which is why this file pins both.
24+
* `schema-drift.ts` has always treated `varchar(field.maxLength)` as the
25+
* expected physical shape, so before the fix a freshly created table reported
26+
* drift against itself — measured on that same table: two `widen_varchar`
27+
* (warning/safe) plus one `narrow_varchar` (error/**destructive**), on a table
28+
* with no rows in it.
29+
*
30+
* ## Why narrowing is pinned here and is NOT a destructive migration
31+
*
32+
* It reads like one, so it is worth stating with the mechanism: `createColumn`
33+
* has exactly four call sites, and every one of them is a `CREATE TABLE` or an
34+
* `ALTER TABLE ADD COLUMN` for a column that does not exist yet. The column it
35+
* sizes is always EMPTY. Narrowing an existing populated `varchar(255)` is a
36+
* different road entirely — the `narrow_varchar` drift op, category
37+
* `destructive`, behind `os migrate apply --allow-destructive` — and this
38+
* change does not touch it. `schema-drift.ts` is byte-identical.
39+
*
40+
* Opt-in for the live halves — they need real servers:
41+
*
42+
* OS_TEST_MYSQL_URL=mysql://root:root@127.0.0.1:3306/conformance \
43+
* OS_TEST_POSTGRES_URL=postgres://postgres:postgres@127.0.0.1:5432/postgres \
44+
* pnpm --filter @objectstack/driver-sql test
45+
*/
46+
47+
import { describe, it, expect, afterEach } from 'vitest';
48+
import { SqlDriver } from '../src/index.js';
49+
import { MYSQL_CELL, PG_CELL, dialectCell, declareDialectCell } from './live-dialect-matrix.testkit.js';
50+
51+
const T = 'os11431_strings';
52+
const PARENT = 'os11431_parent';
53+
54+
/**
55+
* One table holding every corner of the decision at once, so a change to it
56+
* cannot be green in five places and wrong in the sixth.
57+
*/
58+
const stringObject = () => ({
59+
name: T,
60+
fields: {
61+
// ── the rule, both directions ──────────────────────────────
62+
wide_email: { type: 'email', maxLength: 400 },
63+
wide_url: { type: 'url', maxLength: 1024 },
64+
narrow_phone: { type: 'phone', maxLength: 20 },
65+
narrow_password: { type: 'password', maxLength: 60 },
66+
// ── no declaration → knex's 255, unchanged ─────────────────
67+
plain_email: { type: 'email' },
68+
// ── a malformed declaration is not a bound ─────────────────
69+
bogus_url: { type: 'url', maxLength: 0 },
70+
fractional_url: { type: 'url', maxLength: 12.5 },
71+
// ── past the varchar ceiling → TEXT, never a clamp ─────────
72+
huge_url: { type: 'url', maxLength: 100000 },
73+
// ── the three families deliberately left at 255 ────────────
74+
a_lookup: { type: 'lookup', maxLength: 20, reference_to: PARENT },
75+
a_user: { type: 'user', maxLength: 30 },
76+
an_autonumber: { type: 'autonumber', maxLength: 8 },
77+
a_secret: { type: 'secret', maxLength: 4000 },
78+
a_select: { type: 'select', maxLength: 4000, options: ['a', 'b'] },
79+
},
80+
});
81+
82+
const parentObject = () => ({ name: PARENT, fields: { name: { type: 'text', maxLength: 64 } } });
83+
84+
/** `type(maxLength)` per column, normalised across the three dialects. */
85+
async function columnShapes(driver: any, table: string): Promise<Record<string, string>> {
86+
const info: Record<string, any> = await driver.knex(table).columnInfo();
87+
const out: Record<string, string> = {};
88+
for (const [k, v] of Object.entries(info)) {
89+
const t = String((v as any)?.type ?? '').toLowerCase();
90+
const n = (v as any)?.maxLength;
91+
out[k] = /char/.test(t) && n ? `varchar(${n})` : t;
92+
}
93+
return out;
94+
}
95+
96+
// ── The emission rule, on a dialect every runner has ───────────────────────
97+
98+
describe('string-family columns take their declared maxLength (#11431)', () => {
99+
let driver: SqlDriver;
100+
101+
afterEach(async () => {
102+
await driver?.disconnect().catch(() => {});
103+
});
104+
105+
it('emits varchar(maxLength) in BOTH directions, and 255 without a declaration', async () => {
106+
driver = new SqlDriver(dialectCell('sqlite').config());
107+
await driver.initObjects([parentObject(), stringObject()]);
108+
const shapes = await columnShapes(driver as any, T);
109+
110+
// Wider than knex's default — the reported defect.
111+
expect(shapes.wide_email).toBe('varchar(400)');
112+
expect(shapes.wide_url).toBe('varchar(1024)');
113+
// Narrower — the same defect's other half. Empty column, nothing to truncate.
114+
expect(shapes.narrow_phone).toBe('varchar(20)');
115+
expect(shapes.narrow_password).toBe('varchar(60)');
116+
// Undeclared stays exactly where it was.
117+
expect(shapes.plain_email).toBe('varchar(255)');
118+
});
119+
120+
it('treats a malformed maxLength as no declaration rather than guessing', async () => {
121+
driver = new SqlDriver(dialectCell('sqlite').config());
122+
await driver.initObjects([parentObject(), stringObject()]);
123+
const shapes = await columnShapes(driver as any, T);
124+
// `varchar(0)` and `varchar(12.5)` are not DDL; neither is a bound.
125+
expect(shapes.bogus_url).toBe('varchar(255)');
126+
expect(shapes.fractional_url).toBe('varchar(255)');
127+
});
128+
129+
it('falls back to TEXT past the varchar ceiling instead of clamping', async () => {
130+
driver = new SqlDriver(dialectCell('sqlite').config());
131+
await driver.initObjects([parentObject(), stringObject()]);
132+
const shapes = await columnShapes(driver as any, T);
133+
// ⛔ NOT varchar(16383). Clamping would reinstate the very defect being
134+
// fixed — a column narrower than the declaration, refusing legal writes.
135+
expect(shapes.huge_url).toBe('text');
136+
});
137+
138+
it('leaves lookup / user / autonumber / catch-all at 255 — they do not store the declared value', async () => {
139+
driver = new SqlDriver(dialectCell('sqlite').config());
140+
await driver.initObjects([parentObject(), stringObject()]);
141+
const shapes = await columnShapes(driver as any, T);
142+
// A lookup holds the referenced row's ID, not the declared value: a
143+
// platform id is 26 characters, so `varchar(20)` could hold none of them.
144+
expect(shapes.a_lookup).toBe('varchar(255)');
145+
expect(shapes.a_user).toBe('varchar(255)');
146+
// Runtime-issued; `maxLength` has no write-time counterpart on this type.
147+
expect(shapes.an_autonumber).toBe('varchar(255)');
148+
// `secret` persists an opaque sys_secret ref; `select` a machine name.
149+
expect(shapes.a_secret).toBe('varchar(255)');
150+
expect(shapes.a_select).toBe('varchar(255)');
151+
});
152+
});
153+
154+
// ── The halves only a length-ENFORCING server can measure ──────────────────
155+
156+
for (const cell of [MYSQL_CELL, PG_CELL]) {
157+
declareDialectCell(cell, 'string-family maxLength (#11431)', (c) => {
158+
describe(`string-family maxLength on ${c.label} (#11431)`, () => {
159+
let driver: SqlDriver;
160+
161+
afterEach(async () => {
162+
for (const t of [T, PARENT]) await driver?.execute(`drop table if exists ${t}`).catch(() => {});
163+
await driver?.disconnect().catch(() => {});
164+
});
165+
166+
it('accepts a write the declaration allows — the whole defect, in one assertion', async () => {
167+
driver = new SqlDriver(c.config());
168+
for (const t of [T, PARENT]) await driver.execute(`drop table if exists ${t}`).catch(() => {});
169+
await driver.initObjects([parentObject(), stringObject()]);
170+
171+
const shapes = await columnShapes(driver as any, T);
172+
expect(shapes.wide_url).toBe('varchar(1024)');
173+
expect(shapes.narrow_phone).toBe('varchar(20)');
174+
175+
// Pre-fix this was REFUSED — `ER_DATA_TOO_LONG` on MySQL, `22001` on
176+
// Postgres — for a value the declaration plainly permits.
177+
await driver.create(T, { id: 'r1', wide_url: 'u'.repeat(300) });
178+
const rows: any[] = await driver.find(T, { filters: [['id', '=', 'r1']] } as any);
179+
expect(String(rows[0]?.wide_url ?? '').length).toBe(300);
180+
});
181+
182+
it('reports no varchar drift against a table it just created', async () => {
183+
driver = new SqlDriver(c.config());
184+
for (const t of [T, PARENT]) await driver.execute(`drop table if exists ${t}`).catch(() => {});
185+
await driver.initObjects([parentObject(), stringObject()]);
186+
187+
const drift: any[] = await (driver as any).detectTableDrift(T, stringObject().fields, []);
188+
const varcharDrift = drift.filter(
189+
(d) => d.op?.type === 'widen_varchar' || d.op?.type === 'narrow_varchar',
190+
);
191+
192+
// The emitter and the differ finally agree about the string family.
193+
// Pre-fix this table reported two `widen_varchar` AND a
194+
// `narrow_varchar` (error/destructive) against itself, with no rows in
195+
// it. What remains is only the families this card deliberately left
196+
// alone, so the assertion names them rather than expecting zero.
197+
expect(varcharDrift.map((d) => d.column).sort()).toEqual(
198+
['a_lookup', 'a_secret', 'a_select', 'a_user', 'an_autonumber'].sort(),
199+
);
200+
for (const col of ['wide_email', 'wide_url', 'narrow_phone', 'narrow_password', 'plain_email']) {
201+
expect(varcharDrift.map((d) => d.column)).not.toContain(col);
202+
}
203+
204+
// `huge_url` is TEXT, and the differ is right to say nothing about it:
205+
// it compares a numeric `col.maxLength`, which a TEXT column does not
206+
// report. A TEXT column refuses nothing the declaration allows, so
207+
// there is no divergence to plan an ALTER for.
208+
expect(varcharDrift.map((d) => d.column)).not.toContain('huge_url');
209+
210+
// ⛔ The malformed pair, and the reason `schema-drift.ts` is in this
211+
// diff at all. On the pre-fix tree BOTH of these reported
212+
// `narrow_varchar` at severity `error` / category `destructive` —
213+
// `maxLength: 0` takes the narrowing arm because `0 > 255` is false —
214+
// so `os migrate apply --allow-destructive` was being asked to run
215+
// `varchar(0)` and `varchar(12.5)`, DDL no server accepts. The emitter
216+
// treats a malformed bound as no bound; the differ now applies the
217+
// same predicate, which is the whole point of the fix.
218+
expect(varcharDrift.map((d) => d.column)).not.toContain('bogus_url');
219+
expect(varcharDrift.map((d) => d.column)).not.toContain('fractional_url');
220+
});
221+
222+
it('still refuses a value past the DECLARED bound — the bound binds, it did not merely move', async () => {
223+
driver = new SqlDriver(c.config());
224+
for (const t of [T, PARENT]) await driver.execute(`drop table if exists ${t}`).catch(() => {});
225+
await driver.initObjects([parentObject(), stringObject()]);
226+
227+
// ⛔ The negative half. `varchar(20)` must really be 20 — a fix that
228+
// widened everything to silence the symptom would pass every
229+
// assertion above and fail this one.
230+
await expect(
231+
driver.create(T, { id: 'r2', narrow_phone: 'p'.repeat(300) }),
232+
).rejects.toThrow(/too long|ER_DATA_TOO_LONG|22001/i);
233+
});
234+
});
235+
});
236+
}

0 commit comments

Comments
 (0)