Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .changeset/string-family-maxlength-varchar.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
---
'@objectstack/driver-sql': minor
---

driver-sql: a string field's declared `maxLength` now shapes the column it gets

`createColumn` mapped the string family — `string` / `email` / `url` / `phone` /
`password` — with a bare `table.string(name)`, so every column took knex's
default width of 255 and the field's own `maxLength` was never read. A field
declaring a wider bound got a narrower column, and on a dialect that enforces
`varchar` length the write was refused: measured through the driver's own
`initObjects` on MySQL 8.0.46 and Postgres 16, a 300-character value written to
a `maxLength: 1024` column came back `ER_DATA_TOO_LONG` and `22001 value too
long for type character varying(255)` respectively. `schema-drift.ts` has always
treated `varchar(field.maxLength)` as the expected physical shape, so every such
column also reported permanent drift against a table the driver had just
created.

**This changes emitted DDL for existing declarations.** A field declaring
`maxLength` now gets `varchar(maxLength)` in both directions — wider *and*
narrower than 255. Only newly created columns are affected: `createColumn` runs
on `CREATE TABLE` and `ALTER TABLE ADD COLUMN`, never on a column that already
holds rows, so nothing is truncated and no existing column is rewritten.
Narrowing a populated column remains what it was — the `narrow_varchar` drift
op, category `destructive`, behind `os migrate apply --allow-destructive`.

A declared bound above 16383 characters (MySQL's utf8mb4 `varchar` ceiling)
makes the column `TEXT` rather than clamping it, since a clamp would reinstate
the same defect. Fields declaring no `maxLength`, or a malformed one, keep
`varchar(255)` exactly as before. `lookup` / `user`, `autonumber`, and the
catch-all branch are deliberately unchanged — none of them stores the value the
declared bound describes.

Two matching corrections in `schema-drift.ts`, so the differ and the emitter
agree on which declarations count: a `maxLength` that is not a positive integer
is no longer read as a bound (`maxLength: 0` planned a destructive `varchar(0)`
ALTER), and a MySQL `TEXT` column is no longer diffed as a `varchar` 65535 wide
— MySQL reports `character_maximum_length` 65535 for `TEXT` where Postgres
reports NULL, so on MySQL alone every bounded unkeyed text column had been
reporting a permanent destructive `narrow_varchar` against itself.
24 changes: 20 additions & 4 deletions content/docs/protocol/objectql/types.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -189,7 +189,8 @@ parser, the value is **not** lowercased, and no DNS/MX lookup is performed. Stri
rules belong in a validation rule or custom validator.

**Database mapping:**
- SQL driver: `VARCHAR(255)`
- SQL driver: `VARCHAR(maxLength)`, or `VARCHAR(255)` when the field declares no
`maxLength`
- MongoDB: `String`

**Use cases:**
Expand DownExpand Up@@ -230,8 +231,9 @@ phone:
label: Phone Number
```

**Storage format:** the string as entered — a `VARCHAR(255)` column. The engine
does **not** normalize to E.164 and does **not** reformat for display.
**Storage format:** the string as entered — a `VARCHAR(maxLength)` column, or
`VARCHAR(255)` when the field declares no `maxLength`. The engine does **not**
normalize to E.164 and does **not** reformat for display.

**Validation:** a shape check only — at least 5 characters drawn from digits and
`+ ( ) - . ` and whitespace (`invalid_phone` otherwise). There is no country-code
Expand DownExpand Up@@ -1123,7 +1125,7 @@ The column each type gets from the SQL driver, per dialect:
| ObjectQL Type | PostgreSQL | MySQL | SQLite |
|---------------|------------|-------|--------|
| `text` / `textarea` / `html` | `TEXT` \* | `TEXT` \* | `TEXT` \* |
| `email` / `url` / `phone` | `VARCHAR(255)` | `VARCHAR(255)` | `VARCHAR(255)` |
| `email` / `url` / `phone` / `password` | `VARCHAR(maxLength)` | `VARCHAR(maxLength)` | `VARCHAR(maxLength)` † |
| `number` / `currency` / `percent` | `REAL` | `FLOAT` | `REAL` |
| `date` | `DATE` | `DATE` | `TEXT` (`YYYY-MM-DD`) |
| `datetime` | `TIMESTAMPTZ` | `DATETIME(3)` | `TEXT` (canonical `…Z`) |
Expand All@@ -1145,6 +1147,20 @@ declares, and so does a keyed column whose bound exceeds 768 characters — see
the `text` type above for why, and for what the driver does when a keyed column
cannot be bounded.

† The string family takes the field's declared `maxLength` verbatim, in both
directions — a declared 1024 is a `VARCHAR(1024)` and a declared 20 is a
`VARCHAR(20)`. A field that declares no `maxLength` keeps `VARCHAR(255)`, and so
does one whose declaration is not a positive integer. Above 16383 characters
(MySQL's utf8mb4 `VARCHAR` ceiling) the column is `TEXT` instead of being
clamped, since a clamp would refuse writes the declaration permits; the bound is
still enforced at write time by the record validator's `max_length` check.

Note the neighbouring rows that deliberately do **not** follow this rule:
`select` / `radio` store an option's machine name, `lookup` / `master_detail` /
`tree` store the referenced record's id, and `autonumber` stores a
runtime-issued number — in none of those is the stored string the value the
field's `maxLength` describes, so all of them keep `VARCHAR(255)`.

Any field flagged `multiple: true` becomes a `JSON` column regardless of its
type. Relationship columns are plain id strings with no database `FOREIGN KEY`
constraint (see `lookup` above). The MongoDB driver is schemaless — it issues no
Expand Down
71 changes: 62 additions & 9 deletions packages/drivers/driver-sql/src/schema-drift.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -374,6 +374,38 @@ function enforcesVarcharLength(dialect: SqlDialectName): boolean {
return dialect === 'postgres' || dialect === 'mysql';
}

/**
* Is this physical column a `varchar`/`char` — the only kind that HAS a
* declared length to compare against (#11431)?
*
* Without this the length branch below read a MySQL **TEXT** column as a
* varchar 65535 wide, because that is literally what the server reports for it.
* Measured on MySQL 8.0.46 and Postgres 16, same two columns:
*
* MySQL `text` character_maximum_length = 65535
* Postgres `text` character_maximum_length = NULL
* both `varchar(30)` character_maximum_length = 30
*
* So the defect was MySQL-only and invisible on Postgres. Every bounded,
* unkeyed text field — the shape `createColumn` deliberately leaves as TEXT —
* diffed as "declared 4000, column allows 65535" and produced a
* `narrow_varchar` op at severity `error`, category **destructive**, against a
* table the driver had just created and which held no rows. Measured on live
* MySQL: `sys_email`'s envelope alone accounts for seven such findings, each
* inviting `os migrate apply --allow-destructive` to rewrite a TEXT column into
* a varchar for no reason.
*
* A TEXT column refuses nothing a `maxLength` allows, so there is no
* divergence to plan an ALTER for; the bound is enforced at the write seam.
* Spelled as a substring test rather than an equality because the three
* dialects disagree on the word — Postgres says `character varying`, MySQL and
* SQLite say `varchar` — matching the predicate `introspectSchema` already
* uses for the same question.
*/
function isCharacterColumn(type: string | undefined): boolean {
return /char/i.test(String(type ?? ''));
}

/**
* Diff one table's metadata fields against its physical columns and return the
* set of *drift* findings. Metadata is authoritative.
Expand DownExpand Up@@ -487,37 +519,58 @@ export function diffManagedTable(args: {
}

// ── varchar length (only where the dialect enforces it) ──────────
//
// `maxLength` must be a POSITIVE INTEGER to be a bound (#11431). Without
// that predicate this branch read a malformed declaration as authoritative
// and planned DDL no server will accept: `maxLength: 0` took the narrowing
// arm (`0 > col.maxLength` is false) and asked for `varchar(0)`, and
// `maxLength: 12.5` asked for `varchar(12.5)` — both reported at severity
// `error`, category `destructive`, i.e. as work `os migrate apply
// --allow-destructive` should go do.
//
// It is the same predicate the EMITTER applies
// (`SqlDriver.declaredVarcharLength`, and `keyableTextLength` before it):
// a malformed bound is treated as no bound at all, and the column keeps
// its default width. Sharing the predicate is the point — the two halves
// disagreeing about which declarations count is the defect class #11431
// exists to close, and a differ that still honoured a malformed
// `maxLength` would have re-opened it one case to the left.
const declaredMaxLength =
typeof field.maxLength === 'number' && Number.isInteger(field.maxLength) && field.maxLength > 0
? field.maxLength
: undefined;
if (
enforcesVarcharLength(dialect) &&
typeof field.maxLength === 'number' &&
declaredMaxLength !== undefined &&
isCharacterColumn(col.type) &&
typeof col.maxLength === 'number' &&
field.maxLength !== col.maxLength
declaredMaxLength !== col.maxLength
) {
if (field.maxLength > col.maxLength) {
if (declaredMaxLength > col.maxLength) {
out.push({
kind: 'type_mismatch',
remoteName: table,
table,
column: fieldName,
expected: `varchar(${field.maxLength})`,
expected: `varchar(${declaredMaxLength})`,
actual: `varchar(${col.maxLength})`,
severity: 'warning',
category: 'safe',
op: { type: 'widen_varchar', table, column: fieldName, to: field.maxLength, from: col.maxLength },
message: `${table}.${fieldName}: metadata allows ${field.maxLength} chars but the column caps at ${col.maxLength} — widen via "os migrate".`,
op: { type: 'widen_varchar', table, column: fieldName, to: declaredMaxLength, from: col.maxLength },
message: `${table}.${fieldName}: metadata allows ${declaredMaxLength} chars but the column caps at ${col.maxLength} — widen via "os migrate".`,
});
} else {
out.push({
kind: 'type_mismatch',
remoteName: table,
table,
column: fieldName,
expected: `varchar(${field.maxLength})`,
expected: `varchar(${declaredMaxLength})`,
actual: `varchar(${col.maxLength})`,
severity: 'error',
category: 'destructive',
op: { type: 'narrow_varchar', table, column: fieldName, to: field.maxLength, from: col.maxLength },
message: `${table}.${fieldName}: metadata caps at ${field.maxLength} chars but the column allows ${col.maxLength} — narrowing may truncate. "os migrate apply --allow-destructive".`,
op: { type: 'narrow_varchar', table, column: fieldName, to: declaredMaxLength, from: col.maxLength },
message: `${table}.${fieldName}: metadata caps at ${declaredMaxLength} chars but the column allows ${col.maxLength} — narrowing may truncate. "os migrate apply --allow-destructive".`,
});
}
}
Expand Down
Loading
Loading