Skip to content

Commit 2b80c60

Browse files
committed
wip: emit the declared column DEFAULT in both migration generators
1 parent 3c5f3c5 commit 2b80c60

1 file changed

Lines changed: 172 additions & 4 deletions

File tree

‎packages/cli/src/commands/generate.ts‎

Lines changed: 172 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,13 @@ import type { FieldType } from '@objectstack/spec/data';
1818
// `computeTenantField` — are spelled here in the driver's own terms ON TOP of
1919
// these, so the part that can be shared is shared and only the part that
2020
// genuinely lives on `driver-sql` is mirrored.
21-
import { isTenancyDisabled, isUniqueDeclared, numericColumnFor } from '@objectstack/spec/data';
21+
import {
22+
isNowDefaultToken,
23+
isRuntimeDefaultToken,
24+
isTenancyDisabled,
25+
isUniqueDeclared,
26+
numericColumnFor,
27+
} from '@objectstack/spec/data';
2228
import { printHeader, printSuccess, printError, printInfo, printStep, createTimer, isReportedError, CLI_ALIAS } from '../utils/format.js';
2329
import { metadataFileName } from '../utils/metadata-file-name.js';
2430
import { findEmissionParseFailures } from '../utils/emitted-source-parses.js';
@@ -1336,6 +1342,160 @@ function declaredNotNull(field: unknown): boolean {
13361342
return (field as { storage?: { notNull?: boolean } } | undefined)?.storage?.notNull === true;
13371343
}
13381344

1345+
/**
1346+
* The physical column DEFAULT a field's `defaultValue` calls for — or, exactly
1347+
* as deliberately, none at all.
1348+
*
1349+
* `SqlDriver.applyDeclaredColumnDefault` is the single place a `defaultValue`
1350+
* becomes DDL on the platform side, and this is its decision, format-free. Both
1351+
* emitters below render THIS verdict, so the two formats cannot answer the
1352+
* question differently from each other — which is the shape #16294 measured:
1353+
* both generators agreed with each other and disagreed with the platform.
1354+
*
1355+
* The driver's four cases, in its own order:
1356+
*
1357+
* 1. **`'NOW()'`** — the one runtime token with a database counterpart,
1358+
* translated to the driver-native canonical default (`nowColumnDefault`).
1359+
* That translation is TYPE-branched, which is why the three `now-*`
1360+
* verdicts are distinguished here rather than collapsed: a bare
1361+
* `CURRENT_TIMESTAMP` in a `date` column resolves the calendar day in the
1362+
* SERVER's timezone (a UTC-12 server records YESTERDAY, #4022) and in a
1363+
* `time` column resolves it in the server's or the session's clock (#3994).
1364+
* 2. **Any other runtime token** (`current_user`, and whatever the spec adds
1365+
* to `DEFAULT_VALUE_TOKENS` later) — resolved by the ENGINE at insert time
1366+
* against the request context, with NO database counterpart, so nothing is
1367+
* emitted. That omission is the contract: the engine deliberately leaves a
1368+
* `current_user` field UNSET when there is no authenticated user, and a
1369+
* column DEFAULT silently overrode that decision by writing the literal
1370+
* string `'current_user'` into `lookup('sys_user')` columns (#4560).
1371+
* 3. **Objects** — Expression envelopes (`{ dialect, source }`), evaluated
1372+
* app-side; never a column DEFAULT.
1373+
* 4. **Everything else** — a real literal, emitted verbatim.
1374+
*
1375+
* ⛔ The two token predicates are IMPORTED, never re-spelled: `isNowDefaultToken`
1376+
* is case- and whitespace-tolerant, and `isRuntimeDefaultToken` is what makes a
1377+
* token added tomorrow degrade to "no column default" instead of leaking its own
1378+
* spelling into the database. A transcription here would be a second vocabulary
1379+
* for one contract — the defect class this family of pins exists to close.
1380+
*
1381+
* ## What this deliberately does NOT emit, each because the driver does not
1382+
*
1383+
* - **A `multiple: true` field.** `createColumn` short-circuits on the flag and
1384+
* returns before both the nullability line and this one, so a multi-value
1385+
* column carries no DEFAULT on the platform either.
1386+
* - **An option-level `default: true`** on a `select`. `applyDeclaredColumnDefault`
1387+
* states at length why that stays out of DDL (one resolver owns the precedence;
1388+
* the `multiple` shape has no scalar DDL form; a retrofit would divide
1389+
* deployments silently) — ⛔ do not restate the reasoning here, read it there.
1390+
* - **A non-finite number**, and any `typeof` a parsed config cannot hold at all
1391+
* (`symbol`, `function`). `Infinity` and `NaN` have no literal that round-trips
1392+
* through a numeric column; named rather than left to the renderer so the
1393+
* omission is a decision and not a malformed statement.
1394+
*/
1395+
type DeclaredColumnDefault =
1396+
| { kind: 'none' }
1397+
| { kind: 'now' }
1398+
| { kind: 'now-date' }
1399+
| { kind: 'now-time' }
1400+
| { kind: 'literal'; value: string | number | bigint | boolean };
1401+
1402+
function declaredColumnDefault(field: unknown, type: string): DeclaredColumnDefault {
1403+
const declaring = field as { defaultValue?: unknown; multiple?: unknown } | undefined;
1404+
if (declaring?.multiple) return { kind: 'none' };
1405+
const dv = declaring?.defaultValue;
1406+
if (dv === undefined || dv === null) return { kind: 'none' };
1407+
if (isNowDefaultToken(dv)) {
1408+
if (type === 'date') return { kind: 'now-date' };
1409+
if (type === 'time') return { kind: 'now-time' };
1410+
return { kind: 'now' };
1411+
}
1412+
if (isRuntimeDefaultToken(dv)) return { kind: 'none' };
1413+
if (typeof dv === 'object') return { kind: 'none' };
1414+
if (typeof dv === 'number' && !Number.isFinite(dv)) return { kind: 'none' };
1415+
if (typeof dv !== 'string' && typeof dv !== 'number' && typeof dv !== 'bigint' && typeof dv !== 'boolean') {
1416+
return { kind: 'none' };
1417+
}
1418+
return { kind: 'literal', value: dv };
1419+
}
1420+
1421+
/**
1422+
* {@link declaredColumnDefault} as the `--format sql` emitter spells it: the
1423+
* ` DEFAULT …` tail of a column definition, or `''`.
1424+
*
1425+
* PostgreSQL, and only PostgreSQL — the same claim `generateMigrationSql`'s own
1426+
* header already makes for `JSONB` / `TIMESTAMPTZ` / `CURRENT_TIMESTAMP`. The
1427+
* three `now-*` spellings are `SqlDriver.nowColumnDefault`'s Postgres arm, and
1428+
* `generate-declared-column-default.pin.test.ts` recomputes them from the
1429+
* driver's own builder rather than trusting these literals.
1430+
*
1431+
* ⭐ A NUMBER is quoted too, and that is a measurement rather than a style
1432+
* choice. knex quotes every bound default (`default '42'`, `default '9.99'`),
1433+
* and PostgreSQL keeps the two forms textually APART in
1434+
* `information_schema.column_default`: an unquoted `DEFAULT 42` on a
1435+
* `DECIMAL(18,2)` column is recorded as `42`, the quoted one as `'42'::numeric`
1436+
* — which is what the driver's own column carries. Same value, permanently
1437+
* different default TEXT, and #15521 already paid for that exact row once (a
1438+
* schema differ comparing default text reported the audit pair forever). A
1439+
* BOOLEAN is left bare: knex binds it as `'1'`, PostgreSQL records `true` for
1440+
* both spellings, so the catalog agrees either way and the readable one wins in
1441+
* a file a human opens.
1442+
*
1443+
* A string literal is single-quoted with `'` doubled — SQL's own escape, and the
1444+
* form `information_schema.column_default` reads back for the driver's own.
1445+
*/
1446+
function columnDefaultSql(field: unknown, type: string): string {
1447+
const declared = declaredColumnDefault(field, type);
1448+
switch (declared.kind) {
1449+
case 'none': return '';
1450+
case 'now': return ' DEFAULT CURRENT_TIMESTAMP';
1451+
case 'now-date': return " DEFAULT (timezone('utc', now())::date)";
1452+
case 'now-time': return " DEFAULT (timezone('utc', now())::time(3))";
1453+
case 'literal':
1454+
return typeof declared.value === 'boolean'
1455+
? ` DEFAULT ${String(declared.value)}`
1456+
: ` DEFAULT '${String(declared.value).replace(/'/g, "''")}'`;
1457+
}
1458+
}
1459+
1460+
/**
1461+
* {@link declaredColumnDefault} as the `--format ts` emitter spells it: the
1462+
* `.defaultTo(…)` link of the knex column chain, or `''`.
1463+
*
1464+
* Appended AFTER the nullability call because that is the order
1465+
* `SqlDriver.createColumn` applies them in — `col.notNullable()`, then
1466+
* `applyDeclaredColumnDefault`. knex builds both as independent modifiers on one
1467+
* `ColumnBuilder`, so there is no coupling to reconcile on this line; the
1468+
* coupling the audit-column block below records belongs to
1469+
* `table.timestamps(true, true)` alone — that HELPER compiles its second
1470+
* argument to `.notNullable().defaultTo(…)` and offers no spelling for one
1471+
* without the other, which is why those two columns are written out longhand.
1472+
* ⛔ Do not read that note as a constraint here.
1473+
*
1474+
* The two expression defaults are `db.raw` for the reason
1475+
* `generate-declared-unique-index.pin.test.ts` already measured one property
1476+
* over: knex's builder has no expression spelling, and `db.raw` is the seam the
1477+
* driver itself uses. They carry the PostgreSQL arm — this file's declared claim
1478+
* — so a `date`/`time` field defaulted to `NOW()` is the one emitted line that
1479+
* is not dialect-portable, and it is the line whose portable spelling
1480+
* (`db.fn.now()`) is measurably WRONG on the dialect the file does claim.
1481+
*
1482+
* A string is emitted through `JSON.stringify`, a valid TypeScript expression
1483+
* for every string, which escapes the quote, the backslash and the newline an
1484+
* authored default may legally contain. This emitter single-quotes IDENTIFIERS
1485+
* it has validated; a default VALUE is neither.
1486+
*/
1487+
function columnDefaultTs(field: unknown, type: string): string {
1488+
const declared = declaredColumnDefault(field, type);
1489+
switch (declared.kind) {
1490+
case 'none': return '';
1491+
case 'now': return '.defaultTo(db.fn.now())';
1492+
case 'now-date': return '.defaultTo(db.raw("(timezone(\'utc\', now())::date)"))';
1493+
case 'now-time': return '.defaultTo(db.raw("(timezone(\'utc\', now())::time(3))"))';
1494+
case 'literal':
1495+
return `.defaultTo(${typeof declared.value === 'string' ? JSON.stringify(declared.value) : String(declared.value)})`;
1496+
}
1497+
}
1498+
13391499
/**
13401500
* The widest `varchar(n)` any dialect this platform speaks will declare —
13411501
* `SqlDriver.MAX_VARCHAR_CHARS`, whose own comment records the measurement
@@ -1922,8 +2082,9 @@ export function generateMigrationSql(config: Record<string, unknown>): string {
19222082
// as DDL that cannot run. Filled by the loop below, read after it.
19232083
const emittedColumns = new Set<string>(['id']);
19242084
for (const [fieldName, fieldDef] of Object.entries(fields)) {
2085+
const fType = String(fieldDef.type || 'text');
19252086
const sqlType = fieldTypeToSql(
1926-
String(fieldDef.type || 'text'),
2087+
fType,
19272088
!!fieldDef.multiple,
19282089
fieldDef.maxLength,
19292090
keyColumns.has(fieldName),
@@ -1936,7 +2097,12 @@ export function generateMigrationSql(config: Record<string, unknown>): string {
19362097
// storage constraint, never from `required`. See {@link declaredNotNull}
19372098
// for the driver's own recorded reason; ⛔ do not restate it here.
19382099
const notNull = declaredNotNull(fieldDef) ? ' NOT NULL' : '';
1939-
fieldLines.push(` "${fieldName}" ${sqlType}${notNull}`);
2100+
// [#16294 cause 3] The column DEFAULT the field's `defaultValue` calls
2101+
// for. See {@link declaredColumnDefault} for the driver's own four cases
2102+
// and for the three it deliberately does not emit; ⛔ do not restate them
2103+
// here. Ordered after NOT NULL to match `createColumn`'s own sequence.
2104+
const columnDefault = columnDefaultSql(fieldDef, fType);
2105+
fieldLines.push(` "${fieldName}" ${sqlType}${notNull}${columnDefault}`);
19402106
emittedColumns.add(fieldName);
19412107
}
19422108

@@ -2263,7 +2429,9 @@ export function generateMigrationTs(config: Record<string, unknown>): string {
22632429
// #14828 — the virtual answer: emit nothing at all for this field.
22642430
if (colMethod === null) continue;
22652431

2266-
lines.push(` ${colMethod}${required};`);
2432+
// [#16294 cause 3] The same verdict the sql format above renders, in
2433+
// knex's spelling. See {@link columnDefaultTs}.
2434+
lines.push(` ${colMethod}${required}${columnDefaultTs(fieldDef, fType)};`);
22672435
emittedColumns.add(fieldName);
22682436
}
22692437

0 commit comments

Comments
 (0)