@metaobjectsdev/codegen-ts 1.0.4 → 1.0.5-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/column-mapper.d.ts.map +1 -1
- package/dist/column-mapper.js +14 -0
- package/dist/column-mapper.js.map +1 -1
- package/dist/projection/extract-view-spec.d.ts.map +1 -1
- package/dist/projection/extract-view-spec.js +49 -3
- package/dist/projection/extract-view-spec.js.map +1 -1
- package/dist/templates/drizzle-schema.d.ts +9 -1
- package/dist/templates/drizzle-schema.d.ts.map +1 -1
- package/dist/templates/drizzle-schema.js +14 -45
- package/dist/templates/drizzle-schema.js.map +1 -1
- package/dist/templates/entity-file.d.ts.map +1 -1
- package/dist/templates/entity-file.js +9 -1
- package/dist/templates/entity-file.js.map +1 -1
- package/dist/templates/enum-int-codec.d.ts +43 -0
- package/dist/templates/enum-int-codec.d.ts.map +1 -0
- package/dist/templates/enum-int-codec.js +66 -0
- package/dist/templates/enum-int-codec.js.map +1 -0
- package/dist/templates/inferred-types.d.ts.map +1 -1
- package/dist/templates/inferred-types.js +7 -1
- package/dist/templates/inferred-types.js.map +1 -1
- package/dist/templates/view-decl.d.ts +8 -0
- package/dist/templates/view-decl.d.ts.map +1 -1
- package/dist/templates/view-decl.js +29 -4
- package/dist/templates/view-decl.js.map +1 -1
- package/dist/templates/zod-validators.d.ts +18 -1
- package/dist/templates/zod-validators.d.ts.map +1 -1
- package/dist/templates/zod-validators.js +28 -3
- package/dist/templates/zod-validators.js.map +1 -1
- package/package.json +6 -6
- package/src/column-mapper.ts +13 -0
- package/src/projection/extract-view-spec.ts +55 -6
- package/src/templates/drizzle-schema.ts +16 -46
- package/src/templates/entity-file.ts +9 -1
- package/src/templates/enum-int-codec.ts +67 -0
- package/src/templates/inferred-types.ts +7 -1
- package/src/templates/view-decl.ts +43 -5
- package/src/templates/zod-validators.ts +27 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@metaobjectsdev/codegen-ts",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.5-rc.1",
|
|
4
4
|
"description": "TypeScript codegen engine for MetaObjects — emits Drizzle, Zod, and Fastify artifacts.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -111,8 +111,8 @@
|
|
|
111
111
|
"access": "public"
|
|
112
112
|
},
|
|
113
113
|
"dependencies": {
|
|
114
|
-
"@metaobjectsdev/metadata": "1.0.
|
|
115
|
-
"@metaobjectsdev/render": "1.0.
|
|
114
|
+
"@metaobjectsdev/metadata": "1.0.5-rc.1",
|
|
115
|
+
"@metaobjectsdev/render": "1.0.5-rc.1",
|
|
116
116
|
"@biomejs/js-api": "^0.7.0",
|
|
117
117
|
"@biomejs/wasm-nodejs": "^1.9.4",
|
|
118
118
|
"@toon-format/toon": "^2.3.0",
|
|
@@ -124,9 +124,9 @@
|
|
|
124
124
|
},
|
|
125
125
|
"devDependencies": {
|
|
126
126
|
"@biomejs/biome": "^1.9.0",
|
|
127
|
-
"@metaobjectsdev/codegen-ts-react": "1.0.
|
|
128
|
-
"@metaobjectsdev/migrate-ts": "1.0.
|
|
129
|
-
"@metaobjectsdev/runtime-ts": "1.0.
|
|
127
|
+
"@metaobjectsdev/codegen-ts-react": "1.0.5-rc.1",
|
|
128
|
+
"@metaobjectsdev/migrate-ts": "1.0.5-rc.1",
|
|
129
|
+
"@metaobjectsdev/runtime-ts": "1.0.5-rc.1",
|
|
130
130
|
"bun-types": "latest",
|
|
131
131
|
"drizzle-orm": "^0.38.0",
|
|
132
132
|
"hono": "^4.6.0",
|
package/src/column-mapper.ts
CHANGED
|
@@ -468,6 +468,19 @@ export function mapColumnType(
|
|
|
468
468
|
// SQLite has no native array type; serialize as JSON in a text column.
|
|
469
469
|
fnName = "text";
|
|
470
470
|
fnOptions = { mode: "json" };
|
|
471
|
+
} else if (field.attr(FIELD_ATTR_DB_COLUMN_TYPE) === DB_COLUMN_TYPE_JSONB) {
|
|
472
|
+
// `@dbColumnType: jsonb` — the open JSON bag. SQLite has no native jsonb, but
|
|
473
|
+
// the attribute is not merely physical here: the wire contract says the value
|
|
474
|
+
// round-trips as a PARSED JSON value on every dialect (the api-contract jsonb
|
|
475
|
+
// corpus: "POST a JSON object, read it back as an object — never a
|
|
476
|
+
// JSON-encoded string"), and the generated Zod/TS type is `unknown` for that
|
|
477
|
+
// reason. Falling through to a plain `text()` gave that column a `string` type
|
|
478
|
+
// while the entity type stayed `unknown`, so the generated queries did not
|
|
479
|
+
// compile — and had they compiled, the value would have come back as a string.
|
|
480
|
+
// text(..., { mode: "json" }) is the same idiomatic form field.object and
|
|
481
|
+
// field.map already take on this dialect, and it keeps both halves agreeing.
|
|
482
|
+
fnName = "text";
|
|
483
|
+
fnOptions = { mode: "json" };
|
|
471
484
|
} else {
|
|
472
485
|
switch (subType) {
|
|
473
486
|
case FIELD_SUBTYPE_BOOLEAN:
|
|
@@ -570,6 +570,50 @@ function sourceColumnNameFor(
|
|
|
570
570
|
return columnNameFromField(entityField.name, ctx.columnNamingStrategy);
|
|
571
571
|
}
|
|
572
572
|
|
|
573
|
+
/**
|
|
574
|
+
* The BASE table's column an extends-bound passthrough field SELECTS.
|
|
575
|
+
*
|
|
576
|
+
* A projection field may RENAME what it exposes
|
|
577
|
+
* (`{ field.string: { name: bookingRef, extends: "Shipment.reference" } }`). The view's
|
|
578
|
+
* OUTPUT alias is the projection field's own; the column it reads from the base table is
|
|
579
|
+
* the extends TARGET's. Deriving both from the projection field emits
|
|
580
|
+
* `SELECT s.booking_ref` against a table whose column is `reference` — SQL that SQLite
|
|
581
|
+
* accepts at CREATE VIEW and only fails at the first SELECT, and that Postgres rejects
|
|
582
|
+
* outright at migrate time.
|
|
583
|
+
*
|
|
584
|
+
* Caller contract: this is for a PLAIN projection only. A read-view HOST (`base ===
|
|
585
|
+
* projection`) reads its own table, so its passthrough source is always the field's own
|
|
586
|
+
* column and there is no rename to resolve — and a host field may legally `extends` a
|
|
587
|
+
* SIBLING field of the same entity for shape reuse, which this would otherwise read as a
|
|
588
|
+
* rename and silently serve the sibling's data under this field's name.
|
|
589
|
+
*
|
|
590
|
+
* Only a DOTTED extends naming the BASE entity redirects the source column:
|
|
591
|
+
*
|
|
592
|
+
* - `extends: "Shipment.reference"` — binds a base column; the target's column wins.
|
|
593
|
+
* - `extends: "Code24"` (bare, a package-level abstract) — SHAPE REUSE, not a binding.
|
|
594
|
+
* `refNamedOwner` returns undefined, so the field's own name stays the source.
|
|
595
|
+
* - `extends: "Other.foo"` — names an entity that is not the base. The caller emits this
|
|
596
|
+
* column against `joinTree.baseAlias`, so borrowing another entity's column name would
|
|
597
|
+
* be a second wrong answer; keep the field's own.
|
|
598
|
+
*
|
|
599
|
+
* The alias is not always the projection's spelling: `sourceColumnNameFor` reads `@column`
|
|
600
|
+
* with the RESOLVING accessor, so a renaming field whose base carries an explicit `@column`
|
|
601
|
+
* inherits it and both sides land on that physical name. The rename then holds on the TS /
|
|
602
|
+
* wire tier only. That is pre-existing and self-consistent — the Drizzle view decl binds
|
|
603
|
+
* the same column — but it is not a promise this function makes.
|
|
604
|
+
*/
|
|
605
|
+
function baseColumnNameFor(
|
|
606
|
+
field: MetaField,
|
|
607
|
+
base: MetaObject,
|
|
608
|
+
root: MetaRoot,
|
|
609
|
+
ctx: ExtractContext,
|
|
610
|
+
): string {
|
|
611
|
+
const target = field.superData;
|
|
612
|
+
if (target === undefined) return sourceColumnNameFor(field, ctx);
|
|
613
|
+
if (refNamedOwner(field, root) !== base) return sourceColumnNameFor(field, ctx);
|
|
614
|
+
return sourceColumnNameFor(target, ctx);
|
|
615
|
+
}
|
|
616
|
+
|
|
573
617
|
/**
|
|
574
618
|
* Physical column for a join FK/PK field — resolves @column + naming strategy the
|
|
575
619
|
* same way passthrough columns do (EFFECTIVE fields, so inherited PKs resolve).
|
|
@@ -976,22 +1020,27 @@ function buildSelectSpec(
|
|
|
976
1020
|
// fields — the declared set IS the exposure (FR-024/ADR-0028). Either way, each
|
|
977
1021
|
// field's own origin decides passthrough-from-base vs derived-from-join. origin.*
|
|
978
1022
|
// NEVER inherits (ADR-0029), so the origin reads below are own (category 4).
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
1023
|
+
// A read-view HOST reads its OWN table (base === projection), so every passthrough
|
|
1024
|
+
// sources from the field's own column; a plain projection reads the base's, which an
|
|
1025
|
+
// extends-bound field may rename. The flag decides both the field set and the source.
|
|
1026
|
+
const isReadViewHost = base === projection;
|
|
1027
|
+
const declaredFields: MetaField[] = isReadViewHost
|
|
1028
|
+
? base.fields()
|
|
1029
|
+
: projection.ownChildren().filter((c): c is MetaField => c.type === TYPE_FIELD);
|
|
983
1030
|
for (const field of declaredFields) {
|
|
984
1031
|
const origin = field.ownChildren().find((c) => c.type === TYPE_ORIGIN);
|
|
985
1032
|
const dbCol = sourceColumnNameFor(field, ctx);
|
|
986
1033
|
|
|
987
1034
|
if (!origin) {
|
|
988
|
-
// Declared on projection but no origin — passthrough from base table.
|
|
1035
|
+
// Declared on projection but no origin — passthrough from base table. The alias is
|
|
1036
|
+
// this field's own column; the SOURCE is the extends target's, which differs
|
|
1037
|
+
// whenever the projection renames the base field.
|
|
989
1038
|
columns.push({
|
|
990
1039
|
kind: "passthrough",
|
|
991
1040
|
fieldName: field.name,
|
|
992
1041
|
dbColAlias: dbCol,
|
|
993
1042
|
sourceAlias: joinTree.baseAlias,
|
|
994
|
-
sourceColumn: dbCol,
|
|
1043
|
+
sourceColumn: isReadViewHost ? dbCol : baseColumnNameFor(field, base, root, ctx),
|
|
995
1044
|
});
|
|
996
1045
|
continue;
|
|
997
1046
|
}
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
namesRef, physicalNameExpr, sourceSchemaExpr, indexNameExpr, columnExpr,
|
|
23
23
|
} from "../names.js";
|
|
24
24
|
import { resolveTableSchema } from "@metaobjectsdev/metadata";
|
|
25
|
+
import { renderEnumIntCustomType } from "./enum-int-codec.js";
|
|
25
26
|
import { renderRelationsBlock } from "./relations-block.js";
|
|
26
27
|
import { renderDocsFor } from "./jsdoc.js";
|
|
27
28
|
import { collectTphSubtypeFields } from "./tph-discriminator.js";
|
|
@@ -35,7 +36,18 @@ import { effectivePackage } from "../docs-paths.js";
|
|
|
35
36
|
* Returns a Code object so ts-poet can deduplicate imports when this composes
|
|
36
37
|
* with the rest of the entity file. Biome formatting runs after composition.
|
|
37
38
|
*/
|
|
38
|
-
export function renderDrizzleSchema(
|
|
39
|
+
export function renderDrizzleSchema(
|
|
40
|
+
obj: MetaObject,
|
|
41
|
+
ctx: RenderContext,
|
|
42
|
+
/**
|
|
43
|
+
* OUT param: the int-backed-enum codec const names this table DECLARES. A caller that
|
|
44
|
+
* emits another artifact referencing the same codecs into the SAME module — entity-file's
|
|
45
|
+
* write-through replica view — passes a set here and hands it to that emitter, which then
|
|
46
|
+
* references the consts instead of re-declaring them. A duplicate module-scope `const` is
|
|
47
|
+
* a JS parse error, so the module would not even load.
|
|
48
|
+
*/
|
|
49
|
+
declaredEnumIntCodecs?: Set<string>,
|
|
50
|
+
): Code {
|
|
39
51
|
const dialect = ctx.dialect;
|
|
40
52
|
const tableFn = dialect === "sqlite" ? "sqliteTable" : "pgTable";
|
|
41
53
|
const importModule = dialect === "sqlite" ? "drizzle-orm/sqlite-core" : "drizzle-orm/pg-core";
|
|
@@ -381,57 +393,15 @@ ${joinCode(columnLines, { on: ",\n", trim: false })}
|
|
|
381
393
|
const enumIntBlocks = [...enumIntTypes.values()]
|
|
382
394
|
.sort((a, b) => a.fnConstName.localeCompare(b.fnConstName))
|
|
383
395
|
.map((t) => renderEnumIntCustomType(t, importModule));
|
|
396
|
+
if (declaredEnumIntCodecs !== undefined) {
|
|
397
|
+
for (const name of enumIntTypes.keys()) declaredEnumIntCodecs.add(name);
|
|
398
|
+
}
|
|
384
399
|
|
|
385
400
|
const blocks: Code[] = [...enumIntBlocks, tableBlock];
|
|
386
401
|
if (relationsBlock !== null) blocks.push(relationsBlock);
|
|
387
402
|
return blocks.length === 1 ? blocks[0]! : joinCode(blocks, { on: "\n" });
|
|
388
403
|
}
|
|
389
404
|
|
|
390
|
-
/**
|
|
391
|
-
* Render an int-backed `field.enum`'s Drizzle `customType` helper plus its two
|
|
392
|
-
* lookup maps.
|
|
393
|
-
*
|
|
394
|
-
* The codec lives HERE, in the column definition, so nothing downstream needs to
|
|
395
|
-
* know about it: `db.insert().values()` encodes on bind, a selected row decodes on
|
|
396
|
-
* read, and a filter comparison encodes because Drizzle binds through the column
|
|
397
|
-
* type. That is why this shape was chosen over a Zod write-transform plus a
|
|
398
|
-
* generated read-decode — TS's generated queries return raw Drizzle rows and have
|
|
399
|
-
* no decode seam, so the query-layer approach meant inventing one and wrapping
|
|
400
|
-
* every generated read. It is also the direct analogue of what the other four
|
|
401
|
-
* ports already do (EF Core `HasConversion`, OMDB `JdbcFieldCodec`, Exposed
|
|
402
|
-
* `customEnumeration`, Python `ObjectManager` coercion).
|
|
403
|
-
*
|
|
404
|
-
* `fromDriver` throws on an unmapped integer rather than returning undefined: a
|
|
405
|
-
* value outside the map means the DB holds data the model says is impossible
|
|
406
|
-
* (a hand-written INSERT, or a member removed without a migration), and silently
|
|
407
|
-
* yielding `undefined` for a non-nullable field would surface far from the cause.
|
|
408
|
-
*/
|
|
409
|
-
function renderEnumIntCustomType(t: EnumIntCustomType, importModule: string): Code {
|
|
410
|
-
const customTypeSym = imp(`customType@${importModule}`);
|
|
411
|
-
const union = t.members.map((m) => JSON.stringify(m)).join(" | ");
|
|
412
|
-
const toEntries = t.members
|
|
413
|
-
.map((m) => `${JSON.stringify(m)}: ${t.intByMember[m]}`)
|
|
414
|
-
.join(", ");
|
|
415
|
-
const fromEntries = t.members
|
|
416
|
-
.map((m) => `${t.intByMember[m]}: ${JSON.stringify(m)}`)
|
|
417
|
-
.join(", ");
|
|
418
|
-
return code`
|
|
419
|
-
const ${t.toIntConstName} = { ${toEntries} } as const satisfies Record<${union}, number>;
|
|
420
|
-
const ${t.fromIntConstName}: Record<number, ${union}> = { ${fromEntries} };
|
|
421
|
-
const ${t.fnConstName} = ${customTypeSym}<{ data: ${union}; driverData: number }>({
|
|
422
|
-
dataType: () => ${JSON.stringify(t.dataType)},
|
|
423
|
-
toDriver: (value) => ${t.toIntConstName}[value],
|
|
424
|
-
fromDriver: (value) => {
|
|
425
|
-
const member = ${t.fromIntConstName}[value];
|
|
426
|
-
if (member === undefined) {
|
|
427
|
-
throw new Error(\`unmapped ${t.fnConstName} value: \${value}\`);
|
|
428
|
-
}
|
|
429
|
-
return member;
|
|
430
|
-
},
|
|
431
|
-
});
|
|
432
|
-
`;
|
|
433
|
-
}
|
|
434
|
-
|
|
435
405
|
interface FkInfo {
|
|
436
406
|
targetVarName: string; // e.g., "users"
|
|
437
407
|
targetEntityName: string; // e.g., "User" — used for the import path
|
|
@@ -136,6 +136,12 @@ export function renderEntityFile(
|
|
|
136
136
|
// compile error). A base+write-through combo keeps the TPH polymorphic read path (reads
|
|
137
137
|
// the base table); routing its reads through a replica view is a documented non-goal.
|
|
138
138
|
const writeThrough = isWriteThrough(entity) && !tphBase;
|
|
139
|
+
// Rendered BEFORE the replica view below, for two reasons that both matter: the table
|
|
140
|
+
// DECLARES the int-backed-enum codecs (a `const` the view's declaration would duplicate,
|
|
141
|
+
// and a duplicate module-scope const is a parse error), and those consts must precede the
|
|
142
|
+
// view's use of them in the emitted file or the view hits a TDZ ReferenceError at import.
|
|
143
|
+
const declaredEnumIntCodecs = new Set<string>();
|
|
144
|
+
const schemaBlock = renderDrizzleSchema(entity, ctx, declaredEnumIntCodecs);
|
|
139
145
|
const viewSections: Code[] = [];
|
|
140
146
|
if (writeThrough) {
|
|
141
147
|
const camel = entity.name.charAt(0).toLowerCase() + entity.name.slice(1);
|
|
@@ -168,6 +174,8 @@ export function renderEntityFile(
|
|
|
168
174
|
// is passed separately below and stays a literal — the artifact holds the primary
|
|
169
175
|
// (table) source's name, not this one.
|
|
170
176
|
names: entityNames,
|
|
177
|
+
// The table above already declared these; the view references them by name.
|
|
178
|
+
declaredEnumIntCodecs,
|
|
171
179
|
// The replica view's OWN @schema, from the same source node projectionViewName picks
|
|
172
180
|
// — never the entity's, which is the WRITE TABLE's and would qualify this view with a
|
|
173
181
|
// schema that belongs to something else. A view and the table it replicates need not
|
|
@@ -206,7 +214,7 @@ ${docsPrefix}export type ${entity.name} = ${z}.infer<typeof ${entity.name}Schema
|
|
|
206
214
|
const constantsNames = namesRef(entity, ctx);
|
|
207
215
|
|
|
208
216
|
const sections: Code[] = [
|
|
209
|
-
|
|
217
|
+
schemaBlock,
|
|
210
218
|
...viewSections,
|
|
211
219
|
renderInferredTypes(entity, tphBase, ctx, writeThrough /* skipRow — read type is the view schema */),
|
|
212
220
|
...(enumAliases !== null ? [enumAliases] : []),
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The int-backed `field.enum` codec emitter, shared by the two artifacts that can
|
|
3
|
+
* reference one: the entity's Drizzle table (`drizzle-schema.ts`) and any view-backed
|
|
4
|
+
* read model (`view-decl.ts` — a projection or an entity read-view).
|
|
5
|
+
*
|
|
6
|
+
* It lives in its own module because the codec is a MODULE-LOCAL const in whatever file
|
|
7
|
+
* declares the column, so every emitter that can produce such a column must be able to
|
|
8
|
+
* declare it too. While only the table template owned it, the view template emitted the
|
|
9
|
+
* call site and let the const name fall through to `imp(fnName@drizzle-orm/*-core)` —
|
|
10
|
+
* importing a member that package does not export, which `meta gen` reported as success
|
|
11
|
+
* and only `tsc` caught.
|
|
12
|
+
*
|
|
13
|
+
* The invariant this module exists to protect: the codec is declared ONCE PER MODULE, and
|
|
14
|
+
* it must precede every use. A write-through entity puts its table and its replica view in
|
|
15
|
+
* one file and both reference the same consts, so `entity-file.ts` renders the table first,
|
|
16
|
+
* collects the names it declared, and hands them to the view — which then references them
|
|
17
|
+
* instead of re-declaring. Two module-scope `const`s of one name is a JS parse error, and a
|
|
18
|
+
* declaration emitted after its use is a TDZ ReferenceError at import: both are worse than
|
|
19
|
+
* the missing-codec bug, because neither lets the module load at all.
|
|
20
|
+
*/
|
|
21
|
+
import { code, imp, type Code } from "ts-poet";
|
|
22
|
+
import type { EnumIntCustomType } from "../column-mapper.js";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Render an int-backed `field.enum`'s Drizzle `customType` helper plus its two
|
|
26
|
+
* lookup maps.
|
|
27
|
+
*
|
|
28
|
+
* The codec lives HERE, in the column definition, so nothing downstream needs to
|
|
29
|
+
* know about it: `db.insert().values()` encodes on bind, a selected row decodes on
|
|
30
|
+
* read, and a filter comparison encodes because Drizzle binds through the column
|
|
31
|
+
* type. That is why this shape was chosen over a Zod write-transform plus a
|
|
32
|
+
* generated read-decode — TS's generated queries return raw Drizzle rows and have
|
|
33
|
+
* no decode seam, so the query-layer approach meant inventing one and wrapping
|
|
34
|
+
* every generated read. It is also the direct analogue of what the other four
|
|
35
|
+
* ports already do (EF Core `HasConversion`, OMDB `JdbcFieldCodec`, Exposed
|
|
36
|
+
* `customEnumeration`, Python `ObjectManager` coercion).
|
|
37
|
+
*
|
|
38
|
+
* `fromDriver` throws on an unmapped integer rather than returning undefined: a
|
|
39
|
+
* value outside the map means the DB holds data the model says is impossible
|
|
40
|
+
* (a hand-written INSERT, or a member removed without a migration), and silently
|
|
41
|
+
* yielding `undefined` for a non-nullable field would surface far from the cause.
|
|
42
|
+
*/
|
|
43
|
+
export function renderEnumIntCustomType(t: EnumIntCustomType, importModule: string): Code {
|
|
44
|
+
const customTypeSym = imp(`customType@${importModule}`);
|
|
45
|
+
const union = t.members.map((m) => JSON.stringify(m)).join(" | ");
|
|
46
|
+
const toEntries = t.members
|
|
47
|
+
.map((m) => `${JSON.stringify(m)}: ${t.intByMember[m]}`)
|
|
48
|
+
.join(", ");
|
|
49
|
+
const fromEntries = t.members
|
|
50
|
+
.map((m) => `${t.intByMember[m]}: ${JSON.stringify(m)}`)
|
|
51
|
+
.join(", ");
|
|
52
|
+
return code`
|
|
53
|
+
const ${t.toIntConstName} = { ${toEntries} } as const satisfies Record<${union}, number>;
|
|
54
|
+
const ${t.fromIntConstName}: Record<number, ${union}> = { ${fromEntries} };
|
|
55
|
+
const ${t.fnConstName} = ${customTypeSym}<{ data: ${union}; driverData: number }>({
|
|
56
|
+
dataType: () => ${JSON.stringify(t.dataType)},
|
|
57
|
+
toDriver: (value) => ${t.toIntConstName}[value],
|
|
58
|
+
fromDriver: (value) => {
|
|
59
|
+
const member = ${t.fromIntConstName}[value];
|
|
60
|
+
if (member === undefined) {
|
|
61
|
+
throw new Error(\`unmapped ${t.fnConstName} value: \${value}\`);
|
|
62
|
+
}
|
|
63
|
+
return member;
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
`;
|
|
67
|
+
}
|
|
@@ -39,6 +39,7 @@ import { stripPackage } from "@metaobjectsdev/metadata";
|
|
|
39
39
|
import { enumValues } from "../enum-meta.js";
|
|
40
40
|
import { sharedEnumZodConstName } from "./enums-file.js";
|
|
41
41
|
import { renderDocsFor } from "./jsdoc.js";
|
|
42
|
+
import { isTphReadNullTolerant } from "./zod-validators.js";
|
|
42
43
|
import { sharedEnumForField } from "../enum-shared.js";
|
|
43
44
|
import { sharedEnumImportSpecifier, providedEnumImportSpecifier } from "../enum-import.js";
|
|
44
45
|
import { fieldDeclaringPackage, type RenderContext } from "../render-context.js";
|
|
@@ -387,7 +388,12 @@ export function renderValueObjectInterface(entity: MetaObject, ctx?: RenderConte
|
|
|
387
388
|
const required = field.attr(FIELD_ATTR_REQUIRED) === true;
|
|
388
389
|
const optional = required ? "" : "?";
|
|
389
390
|
const tsType = valueObjectFieldType(entity, field, ctx);
|
|
390
|
-
|
|
391
|
+
// A TPH subtype's read schema accepts `null` for a column that is NULL on a
|
|
392
|
+
// sibling subtype's row; the declared type has to admit the same values, or
|
|
393
|
+
// `parse<Base>()` returns something this interface rejects. One predicate
|
|
394
|
+
// answers it for both emitters (ADR-style single source, see its docblock).
|
|
395
|
+
const nullable = isTphReadNullTolerant(entity, field) ? " | null" : "";
|
|
396
|
+
lines.push(code` ${field.name}${optional}: ${tsType}${nullable};`);
|
|
391
397
|
}
|
|
392
398
|
|
|
393
399
|
// joinCode with "\n" interpolates each Code segment on its own line and
|
|
@@ -12,7 +12,8 @@ import {
|
|
|
12
12
|
type MetaField, FIELD_SUBTYPE_OBJECT, FIELD_ATTR_OBJECT_REF,
|
|
13
13
|
} from "@metaobjectsdev/metadata";
|
|
14
14
|
import type { ColumnNamingStrategy } from "../metaobjects-config.js";
|
|
15
|
-
import { mapColumnType } from "../column-mapper.js";
|
|
15
|
+
import { mapColumnType, type EnumIntCustomType } from "../column-mapper.js";
|
|
16
|
+
import { renderEnumIntCustomType } from "./enum-int-codec.js";
|
|
16
17
|
import { zodTypeFor } from "./field-meta.js";
|
|
17
18
|
import { columnExpr, type ObjectNames } from "../names.js";
|
|
18
19
|
|
|
@@ -21,6 +22,14 @@ export interface ViewDeclOpts {
|
|
|
21
22
|
readonly columnNamingStrategy: ColumnNamingStrategy;
|
|
22
23
|
/** Drives the timestamp column TS type (Date vs string) in the view declaration. */
|
|
23
24
|
readonly timestampMode: "date" | "string";
|
|
25
|
+
/**
|
|
26
|
+
* Int-backed-enum codec const names ALREADY declared in the module this view lands in.
|
|
27
|
+
* A write-through entity's table and its replica view share one file, and both reference
|
|
28
|
+
* the same codecs — so the view references these by name and declares only what the table
|
|
29
|
+
* did not (a derived field's codec, which the write table omits). Absent = the view owns
|
|
30
|
+
* every codec it needs, which is the projection case.
|
|
31
|
+
*/
|
|
32
|
+
readonly declaredEnumIntCodecs?: ReadonlySet<string>;
|
|
24
33
|
/**
|
|
25
34
|
* ADR-0044/#228 — resolve a `field.object` / `field.map`'s `@objectRef` to the
|
|
26
35
|
* value object's EMITTED name (bare when unique in the run, package-qualified on
|
|
@@ -72,10 +81,26 @@ export interface ViewDeclOpts {
|
|
|
72
81
|
* so `db.select().from(<view>)` is typed. Honors `@dbColumnType`; `.existing()`
|
|
73
82
|
* views carry type + physical name only (no PK/default/notNull DDL modifiers).
|
|
74
83
|
*/
|
|
75
|
-
function viewColumnLine(
|
|
84
|
+
function viewColumnLine(
|
|
85
|
+
f: MetaField,
|
|
86
|
+
opts: ViewDeclOpts,
|
|
87
|
+
/** Codecs this view needs, collected for the caller to DECLARE — see below. */
|
|
88
|
+
enumIntTypes: Map<string, EnumIntCustomType>,
|
|
89
|
+
): Code {
|
|
76
90
|
const { dialect, columnNamingStrategy, timestampMode } = opts;
|
|
77
91
|
const spec = mapColumnType(f, dialect, columnNamingStrategy, timestampMode);
|
|
78
|
-
|
|
92
|
+
// An int-backed field.enum's column function is a LOCAL generated const (the
|
|
93
|
+
// customType helper the caller emits into this same file), so it must not be
|
|
94
|
+
// imported from drizzle-orm/*-core like a built-in column type would be. Same
|
|
95
|
+
// rule the table template applies in `renderColumn`; a view reads the very same
|
|
96
|
+
// integer column and needs the very same decode on the way out.
|
|
97
|
+
if (spec.enumIntCustomType !== undefined) {
|
|
98
|
+
enumIntTypes.set(spec.enumIntCustomType.fnConstName, spec.enumIntCustomType);
|
|
99
|
+
}
|
|
100
|
+
const colSym =
|
|
101
|
+
spec.enumIntCustomType !== undefined
|
|
102
|
+
? spec.enumIntCustomType.fnConstName
|
|
103
|
+
: imp(`${spec.fnName}@${spec.importModule}`);
|
|
79
104
|
const optsArg =
|
|
80
105
|
spec.fnOptions && Object.keys(spec.fnOptions).length > 0
|
|
81
106
|
? `, ${JSON.stringify(spec.fnOptions)}`
|
|
@@ -137,7 +162,8 @@ export function renderExistingViewDecl(
|
|
|
137
162
|
const viewFn = opts.dialect === "postgres" ? "pgView" : "sqliteView";
|
|
138
163
|
const viewModule = opts.dialect === "postgres" ? "drizzle-orm/pg-core" : "drizzle-orm/sqlite-core";
|
|
139
164
|
const viewSym = imp(`${viewFn}@${viewModule}`);
|
|
140
|
-
const
|
|
165
|
+
const enumIntTypes = new Map<string, EnumIntCustomType>();
|
|
166
|
+
const viewColumnLines = fields.map((f) => viewColumnLine(f, opts, enumIntTypes));
|
|
141
167
|
const viewNameExpr = typeof viewName === "string" ? code`${JSON.stringify(viewName)}` : viewName;
|
|
142
168
|
|
|
143
169
|
// @schema — a view lands in a schema exactly as a table does, and migrate qualifies the
|
|
@@ -153,7 +179,16 @@ export function renderExistingViewDecl(
|
|
|
153
179
|
const viewCall: Code = viewSchemaExpr === undefined
|
|
154
180
|
? code`${viewSym}`
|
|
155
181
|
: code`${imp(`pgSchema@${viewModule}`)}(${viewSchemaExpr}).view`;
|
|
156
|
-
|
|
182
|
+
// Int-backed enum codecs are declared BEFORE the view that references them, sorted
|
|
183
|
+
// by const name so output is deterministic regardless of field order — the same
|
|
184
|
+
// contract the table template holds.
|
|
185
|
+
const alreadyDeclared = opts.declaredEnumIntCodecs;
|
|
186
|
+
const enumIntBlocks = [...enumIntTypes.values()]
|
|
187
|
+
.filter((t) => alreadyDeclared === undefined || !alreadyDeclared.has(t.fnConstName))
|
|
188
|
+
.sort((a, b) => a.fnConstName.localeCompare(b.fnConstName))
|
|
189
|
+
.map((t) => renderEnumIntCustomType(t, viewModule));
|
|
190
|
+
|
|
191
|
+
const viewBlock = code`
|
|
157
192
|
// View declaration — Drizzle uses this for typed SELECT queries.
|
|
158
193
|
// The SQL view is created/managed by migrate-ts; .existing() tells Drizzle
|
|
159
194
|
// not to attempt DDL for this declaration.
|
|
@@ -161,6 +196,9 @@ export const ${viewVar} = ${viewCall}(${viewNameExpr}, {
|
|
|
161
196
|
${joinCode(viewColumnLines, { on: ",\n" })}
|
|
162
197
|
}).existing();
|
|
163
198
|
`;
|
|
199
|
+
return enumIntBlocks.length === 0
|
|
200
|
+
? viewBlock
|
|
201
|
+
: joinCode([...enumIntBlocks, viewBlock], { on: "\n" });
|
|
164
202
|
}
|
|
165
203
|
|
|
166
204
|
/**
|
|
@@ -126,6 +126,28 @@ export function hasAutoSetFields(obj: MetaObject): boolean {
|
|
|
126
126
|
return false;
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Is this field NULL-tolerant in a TPH subtype's READ shape?
|
|
131
|
+
*
|
|
132
|
+
* A TPH subtype shares one physical table with its siblings, so a column only one
|
|
133
|
+
* subtype declares is NULL on every other subtype's row, and a non-`@required` column
|
|
134
|
+
* of this subtype's own is NULL when unset. Either way the value read back is `null`,
|
|
135
|
+
* not `undefined`. The PRIMARY KEY is the exception — it is the shared base table's
|
|
136
|
+
* key and is present on every row.
|
|
137
|
+
*
|
|
138
|
+
* ONE predicate, because TWO emitters answer this question about the same field: the
|
|
139
|
+
* Zod read schema (`renderTphSubtypeReadSchema`) and the declared TS type
|
|
140
|
+
* (`renderValueObjectInterface`). They answered it differently, so the value
|
|
141
|
+
* `parse<Base>()` returns was not assignable to the base union and the generated
|
|
142
|
+
* module did not compile (TS2322). A second answer to one question is the defect;
|
|
143
|
+
* keeping the two call sites pointed here is the fix.
|
|
144
|
+
*/
|
|
145
|
+
export function isTphReadNullTolerant(obj: MetaObject, field: MetaField): boolean {
|
|
146
|
+
if (!isTphSubtype(obj)) return false;
|
|
147
|
+
if (!fieldWillBeOptional(field)) return false;
|
|
148
|
+
return !primaryIdentityFieldNames(obj).includes(field.name);
|
|
149
|
+
}
|
|
150
|
+
|
|
129
151
|
/**
|
|
130
152
|
* FR-017 Tier 2 — the per-subtype FULL read schema `<Sub>Schema`. Unlike the
|
|
131
153
|
* insert schema, this includes every effective field (PK included) so a raw DB
|
|
@@ -149,10 +171,12 @@ export function renderTphSubtypeReadSchema(obj: MetaObject, ctx?: RenderContext)
|
|
|
149
171
|
}
|
|
150
172
|
const expr = zodFieldExpr(child, obj, ctx);
|
|
151
173
|
// zodFieldExpr already appends `.optional()` for non-required fields; add
|
|
152
|
-
// `.nullable()` on top so a NULL column value
|
|
153
|
-
//
|
|
174
|
+
// `.nullable()` on top so a NULL column value parses cleanly. The declared
|
|
175
|
+
// interface widens the SAME fields — see isTphReadNullTolerant.
|
|
154
176
|
fieldLines.push(
|
|
155
|
-
|
|
177
|
+
isTphReadNullTolerant(obj, child)
|
|
178
|
+
? code` ${child.name}: ${expr}.nullable()`
|
|
179
|
+
: code` ${child.name}: ${expr}`,
|
|
156
180
|
);
|
|
157
181
|
}
|
|
158
182
|
|