@metaobjectsdev/codegen-ts 1.0.5-rc.1 → 1.0.5-rc.3
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/generators/api-model.d.ts.map +1 -1
- package/dist/generators/api-model.js +11 -10
- package/dist/generators/api-model.js.map +1 -1
- package/dist/generators/queries-file.d.ts.map +1 -1
- package/dist/generators/queries-file.js +7 -2
- package/dist/generators/queries-file.js.map +1 -1
- package/dist/generators/routes-file-hono.d.ts +2 -1
- package/dist/generators/routes-file-hono.d.ts.map +1 -1
- package/dist/generators/routes-file-hono.js +4 -3
- package/dist/generators/routes-file-hono.js.map +1 -1
- package/dist/generators/routes-file.d.ts +2 -1
- package/dist/generators/routes-file.d.ts.map +1 -1
- package/dist/generators/routes-file.js +4 -3
- package/dist/generators/routes-file.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/relation-resolver.d.ts +6 -1
- package/dist/relation-resolver.d.ts.map +1 -1
- package/dist/relation-resolver.js +80 -8
- package/dist/relation-resolver.js.map +1 -1
- package/dist/runner.js +1 -1
- package/dist/runner.js.map +1 -1
- package/dist/templates/drizzle-schema.d.ts.map +1 -1
- package/dist/templates/drizzle-schema.js +23 -4
- package/dist/templates/drizzle-schema.js.map +1 -1
- package/dist/templates/entity-ui-descriptor.d.ts +7 -9
- package/dist/templates/entity-ui-descriptor.d.ts.map +1 -1
- package/dist/templates/entity-ui-descriptor.js +9 -16
- package/dist/templates/entity-ui-descriptor.js.map +1 -1
- package/dist/templates/relations-block.d.ts.map +1 -1
- package/dist/templates/relations-block.js +13 -6
- package/dist/templates/relations-block.js.map +1 -1
- package/dist/templates/routes-file.d.ts.map +1 -1
- package/dist/templates/routes-file.js +80 -12
- package/dist/templates/routes-file.js.map +1 -1
- package/dist/templates/zod-validators.d.ts +29 -9
- package/dist/templates/zod-validators.d.ts.map +1 -1
- package/dist/templates/zod-validators.js +65 -16
- package/dist/templates/zod-validators.js.map +1 -1
- package/package.json +6 -6
- package/src/generators/api-model.ts +11 -10
- package/src/generators/queries-file.ts +7 -2
- package/src/generators/routes-file-hono.ts +4 -3
- package/src/generators/routes-file.ts +4 -3
- package/src/index.ts +1 -1
- package/src/reference/queries.ts +4 -2
- package/src/reference/routes-hono.ts +2 -2
- package/src/reference/routes.ts +4 -3
- package/src/relation-resolver.ts +90 -7
- package/src/runner.ts +1 -1
- package/src/templates/drizzle-schema.ts +21 -4
- package/src/templates/entity-ui-descriptor.ts +9 -16
- package/src/templates/relations-block.ts +19 -11
- package/src/templates/routes-file.ts +123 -20
- package/src/templates/zod-validators.ts +66 -17
|
@@ -31,6 +31,7 @@ import type { RelationEntry } from "../relation-resolver.js";
|
|
|
31
31
|
import { isTphDiscriminatorBase, tphPlan } from "./tph-discriminator.js";
|
|
32
32
|
import { authSeamJsDoc, type CrudVerb, exposeLine, intersectExpose, TPH_POLYMORPHIC_VERBS } from "../routes-expose.js";
|
|
33
33
|
import { effectivePackage } from "../docs-paths.js";
|
|
34
|
+
import { tphDiscriminatorPin, tphStorageObject } from "./zod-validators.js";
|
|
34
35
|
|
|
35
36
|
export function renderRoutesFile(
|
|
36
37
|
entity: MetaObject,
|
|
@@ -166,9 +167,7 @@ export async function ${handlerName}(fastify: ${FastifyInstanceSym}) {
|
|
|
166
167
|
// FK columns were derived from the junction's identity.reference children (the
|
|
167
168
|
// SSOT) by the relation-resolver pre-pass; here we resolve them to physical
|
|
168
169
|
// column names for the Drizzle two-stage join.
|
|
169
|
-
const m2mEntries = (ctx
|
|
170
|
-
(e): e is RelationEntry & { junctionEntity: string } => e.junctionEntity !== undefined,
|
|
171
|
-
);
|
|
170
|
+
const m2mEntries = m2mEntriesOf(ctx, entityName);
|
|
172
171
|
// Two fastify-scope variants: under an apiPrefix the mounts live inside the
|
|
173
172
|
// register-block (`instance`); otherwise they bind directly to `fastify`.
|
|
174
173
|
const m2mMountsPrefixed = renderM2mMounts(m2mEntries, entity, ctx, "instance");
|
|
@@ -241,6 +240,21 @@ ${m2mMountsFlat}}
|
|
|
241
240
|
return header + literalImports.toString() + body.toString();
|
|
242
241
|
}
|
|
243
242
|
|
|
243
|
+
/**
|
|
244
|
+
* The M:N navigation entries of `name` — the ONE rule for which relationships get a
|
|
245
|
+
* traversal mount. The vanilla entity path and the TPH path both go through it, so
|
|
246
|
+
* they cannot drift apart: a rule change moves every mount at once, and the
|
|
247
|
+
* independent oracle's route rule stays checkable against both emit paths alike.
|
|
248
|
+
*/
|
|
249
|
+
function m2mEntriesOf(
|
|
250
|
+
ctx: RenderContext,
|
|
251
|
+
name: string,
|
|
252
|
+
): Array<RelationEntry & { junctionEntity: string }> {
|
|
253
|
+
return (ctx.relationMap.get(name) ?? []).filter(
|
|
254
|
+
(e): e is RelationEntry & { junctionEntity: string } => e.junctionEntity !== undefined,
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
244
258
|
/**
|
|
245
259
|
* Render the M:N traversal mounts for an entity as a single Code fragment to
|
|
246
260
|
* interpolate INTO the handler-body code template (so the junction/target table
|
|
@@ -254,13 +268,38 @@ function renderM2mMounts(
|
|
|
254
268
|
source: MetaObject,
|
|
255
269
|
ctx: RenderContext,
|
|
256
270
|
fastifyVar: string,
|
|
271
|
+
tphSource?: TphM2mSource,
|
|
257
272
|
): Code | string {
|
|
258
273
|
if (entries.length === 0) return "";
|
|
259
|
-
const mounts = entries.map((e) => renderM2mMount(e, source, ctx, fastifyVar));
|
|
274
|
+
const mounts = entries.map((e) => renderM2mMount(e, source, ctx, fastifyVar, tphSource));
|
|
260
275
|
return code`${joinCode(mounts, { on: "\n", trim: false })}
|
|
261
276
|
`;
|
|
262
277
|
}
|
|
263
278
|
|
|
279
|
+
/**
|
|
280
|
+
* Where a TPH M:N mount hangs, and how it proves the source id is really ITS subtype's.
|
|
281
|
+
*
|
|
282
|
+
* Only `renderTphRoutesFile` supplies this, and only for a relationship declared ON a
|
|
283
|
+
* subtype. The path gains that subtype's segment, and `sourceDiscriminator` gets the
|
|
284
|
+
* check that makes the segment mean something: the junction FK points at the shared base
|
|
285
|
+
* table, so without it a sibling subtype's id reaches the same junction rows and the
|
|
286
|
+
* segment in the URL is decorative. A relationship declared on the BASE passes nothing
|
|
287
|
+
* here — every row of the table is a legitimate source — so its mount is byte-identical
|
|
288
|
+
* to a vanilla entity's.
|
|
289
|
+
*/
|
|
290
|
+
interface TphM2mSource {
|
|
291
|
+
/** Appended to the base entity's `$path`, e.g. `"/bridge"`. */
|
|
292
|
+
pathSuffix: string;
|
|
293
|
+
/** The base table const this subtype's rows live in. */
|
|
294
|
+
table: Code | string;
|
|
295
|
+
/** Physical PK column of the base table. */
|
|
296
|
+
pkColumn: Code;
|
|
297
|
+
/** Physical discriminator column of the base table. */
|
|
298
|
+
discriminatorColumn: Code;
|
|
299
|
+
/** This subtype's `@discriminatorValue`. */
|
|
300
|
+
value: string;
|
|
301
|
+
}
|
|
302
|
+
|
|
264
303
|
/**
|
|
265
304
|
* Render one M:N traversal mount. The junction + target Drizzle table consts are
|
|
266
305
|
* imported from their sibling entity files (imp() lets ts-poet track + emit the
|
|
@@ -273,6 +312,7 @@ function renderM2mMount(
|
|
|
273
312
|
source: MetaObject,
|
|
274
313
|
ctx: RenderContext,
|
|
275
314
|
fastifyVar: string,
|
|
315
|
+
tphSource?: TphM2mSource,
|
|
276
316
|
): Code {
|
|
277
317
|
// `source` never changes across this function, so its effective package is computed
|
|
278
318
|
// once and reused below (both crossEntitySpecifier calls, and the three
|
|
@@ -288,18 +328,30 @@ function renderM2mMount(
|
|
|
288
328
|
ctx.extStyle,
|
|
289
329
|
)}`,
|
|
290
330
|
);
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
331
|
+
// An M:N onto a TPH subtype traverses into its discriminator BASE's table — the
|
|
332
|
+
// subtype has no table const, and the junction FK can only point at the base table —
|
|
333
|
+
// and filters the rows to the subtype, because a Broker id in that FK column is not a
|
|
334
|
+
// Carrier. Every other target binds to itself, and emits no filter.
|
|
335
|
+
const declaredTarget = ctx.loadedRoot.findObject(entry.targetEntity);
|
|
336
|
+
const target = declaredTarget === undefined ? undefined : tphStorageObject(declaredTarget);
|
|
337
|
+
const targetTableEntity = target?.name ?? entry.targetEntity;
|
|
338
|
+
const pin = declaredTarget === undefined ? undefined : tphDiscriminatorPin(declaredTarget);
|
|
339
|
+
// A self-join's target table IS the source table, which this file already imports from
|
|
340
|
+
// the entity module; a second import of the same binding is TS2300, and a SyntaxError
|
|
341
|
+
// when Node loads the module.
|
|
342
|
+
const targetVarSym = targetTableEntity === source.name
|
|
343
|
+
? ctx.collectionName(source.name)
|
|
344
|
+
: imp(
|
|
345
|
+
`${ctx.collectionName(targetTableEntity)}@${crossEntitySpecifier(
|
|
346
|
+
ctx.outputLayout,
|
|
347
|
+
sourcePkg,
|
|
348
|
+
ctx.packageOf.get(targetTableEntity),
|
|
349
|
+
targetTableEntity,
|
|
350
|
+
ctx.extStyle,
|
|
351
|
+
)}`,
|
|
352
|
+
);
|
|
300
353
|
const mountM2mRouteSym = imp("mountM2mRoute@@metaobjectsdev/runtime-ts/drizzle-fastify");
|
|
301
354
|
const junction = ctx.loadedRoot.findObject(entry.junctionEntity);
|
|
302
|
-
const target = ctx.loadedRoot.findObject(entry.targetEntity);
|
|
303
355
|
// fromPackage = source.package: this routes file is SOURCE's own module, never the
|
|
304
356
|
// junction's or the target's — see resolveJunctionColumn's doc comment (B1).
|
|
305
357
|
const sourceColumn: Code = junction
|
|
@@ -309,12 +361,27 @@ function renderM2mMount(
|
|
|
309
361
|
? resolveJunctionColumn(junction, entry.targetJoinField!, ctx, sourcePkg)
|
|
310
362
|
: code`${JSON.stringify(entry.targetJoinField!)}`;
|
|
311
363
|
const targetPkColumn: Code = target
|
|
312
|
-
? resolveJunctionColumn(target, ctx.pkMap.get(
|
|
364
|
+
? resolveJunctionColumn(target, ctx.pkMap.get(targetTableEntity)?.fieldName ?? "id", ctx, sourcePkg)
|
|
313
365
|
: code`${JSON.stringify("id")}`;
|
|
366
|
+
const discriminatorLine: Code | string = pin !== undefined && target !== undefined
|
|
367
|
+
? code`
|
|
368
|
+
targetDiscriminator: { column: ${resolveJunctionColumn(target, pin.fieldName, ctx, sourcePkg)}, value: ${JSON.stringify(pin.value)} },`
|
|
369
|
+
: "";
|
|
370
|
+
|
|
371
|
+
// A subtype-declared M:N hangs under the subtype's segment; everything else hangs at
|
|
372
|
+
// the source entity's own path. `$path` is read from the BASE const either way — a TPH
|
|
373
|
+
// subtype's module exports no entity const of its own.
|
|
374
|
+
const pathExpr: Code = tphSource === undefined
|
|
375
|
+
? code`${source.name}.$path`
|
|
376
|
+
: code`${source.name}.$path + ${JSON.stringify(tphSource.pathSuffix)}`;
|
|
377
|
+
const sourceDiscriminatorLine: Code | string = tphSource === undefined
|
|
378
|
+
? ""
|
|
379
|
+
: code`
|
|
380
|
+
sourceDiscriminator: { table: ${tphSource.table}, pkColumn: ${tphSource.pkColumn}, column: ${tphSource.discriminatorColumn}, value: ${JSON.stringify(tphSource.value)} },`;
|
|
314
381
|
|
|
315
382
|
return code` ${mountM2mRouteSym}({
|
|
316
383
|
fastify: ${fastifyVar},
|
|
317
|
-
path: ${
|
|
384
|
+
path: ${pathExpr},
|
|
318
385
|
relationName: ${JSON.stringify(entry.name)},
|
|
319
386
|
db,
|
|
320
387
|
junctionTable: ${junctionVarSym},
|
|
@@ -322,7 +389,7 @@ function renderM2mMount(
|
|
|
322
389
|
sourceColumn: ${sourceColumn},
|
|
323
390
|
targetColumn: ${targetColumn},
|
|
324
391
|
targetPkColumn: ${targetPkColumn},
|
|
325
|
-
symmetric: ${entry.symmetric ? "true" : "false"}
|
|
392
|
+
symmetric: ${entry.symmetric ? "true" : "false"},${discriminatorLine}${sourceDiscriminatorLine}
|
|
326
393
|
});`;
|
|
327
394
|
}
|
|
328
395
|
|
|
@@ -423,7 +490,20 @@ function renderTphRoutesFile(
|
|
|
423
490
|
dialect: ${dialectLit},${polymorphicExposeLine}
|
|
424
491
|
});`;
|
|
425
492
|
|
|
426
|
-
|
|
493
|
+
// FR-018 x FR-017 — M:N traversal inside a TPH hierarchy. This file never consulted
|
|
494
|
+
// the relation map at all, so BOTH sides vanished from the generated API: a
|
|
495
|
+
// relationship declared on the base (every row of the table is a legitimate source)
|
|
496
|
+
// and one declared on a subtype (only that subtype's rows are). Neither is a compile
|
|
497
|
+
// error — a route that is never mounted is an absence — which is why the codegen
|
|
498
|
+
// compile gate stayed green while the endpoint 404'd.
|
|
499
|
+
//
|
|
500
|
+
// The physical columns stage 0 needs, resolved once against the base's own table.
|
|
501
|
+
const basePkField = ctx.pkMap.get(baseName)?.fieldName ?? "id";
|
|
502
|
+
const basePkColumn = resolveJunctionColumn(base, basePkField, ctx, basePkg);
|
|
503
|
+
const baseDiscColumn = resolveJunctionColumn(base, discField, ctx, basePkg);
|
|
504
|
+
const baseM2mMounts = renderM2mMounts(m2mEntriesOf(ctx, baseName), base, ctx, fastifyRef);
|
|
505
|
+
|
|
506
|
+
const subtypeMounts: Code[] = plan.subtypes.flatMap(({ entity: sub, value, routeSegment: segment }) => {
|
|
427
507
|
const subFileSpec = entityModuleSpecifier(
|
|
428
508
|
ctx.selfTarget, ctx.entityModuleTarget, effectivePackage(sub), sub.name, ctx.extStyle,
|
|
429
509
|
);
|
|
@@ -440,7 +520,7 @@ function renderTphRoutesFile(
|
|
|
440
520
|
// (discriminator excluded — it's pinned by this path).
|
|
441
521
|
const subFilterSym = imp(`${sub.name}FilterAllowlist@${subFileSpec}`);
|
|
442
522
|
const subSortSym = imp(`${sub.name}SortAllowlist@${subFileSpec}`);
|
|
443
|
-
|
|
523
|
+
const crud = code`
|
|
444
524
|
${mountCrudRoutesSym}({
|
|
445
525
|
fastify: ${fastifyRef},
|
|
446
526
|
path: ${baseConstSym}.$path + ${JSON.stringify("/" + segment)},
|
|
@@ -453,9 +533,32 @@ function renderTphRoutesFile(
|
|
|
453
533
|
dialect: ${dialectLit},
|
|
454
534
|
discriminator: { column: ${JSON.stringify(discField)}, value: ${JSON.stringify(value)} },${exposeLine(expose, " ")}
|
|
455
535
|
});`;
|
|
536
|
+
// This subtype's own M:N navigations, mounted beneath its segment and gated on the
|
|
537
|
+
// discriminator so a sibling's id yields [] instead of the sibling's relations.
|
|
538
|
+
//
|
|
539
|
+
// Every relationship this subtype RESOLVES, inherited ones included — not just the
|
|
540
|
+
// ones it declares. A subtype resource is a resource: `/auths/bridge/1` carries the
|
|
541
|
+
// same sub-resources as any other, so it carries the base's relationships too, and
|
|
542
|
+
// an abstract mid level's relationship has nowhere else to be served at all.
|
|
543
|
+
//
|
|
544
|
+
// The base mounts its own set separately, at its own path. The overlap is deliberate
|
|
545
|
+
// and not redundant: `/auths/1/tags` accepts any row of the table, while
|
|
546
|
+
// `/auths/bridge/1/tags` answers [] for a Copay id — the segment is a type
|
|
547
|
+
// assertion, which is exactly what sourceDiscriminator enforces below.
|
|
548
|
+
const subM2m = renderM2mMounts(m2mEntriesOf(ctx, sub.name), base, ctx, fastifyRef, {
|
|
549
|
+
pathSuffix: "/" + segment,
|
|
550
|
+
table: code`${tableSym}`,
|
|
551
|
+
pkColumn: basePkColumn,
|
|
552
|
+
discriminatorColumn: baseDiscColumn,
|
|
553
|
+
value,
|
|
554
|
+
});
|
|
555
|
+
return subM2m === "" ? [crud] : [crud, subM2m as Code];
|
|
456
556
|
});
|
|
457
557
|
|
|
458
|
-
const mounts = joinCode(
|
|
558
|
+
const mounts = joinCode(
|
|
559
|
+
[polymorphic, ...(baseM2mMounts === "" ? [] : [baseM2mMounts as Code]), ...subtypeMounts],
|
|
560
|
+
{ on: "\n" },
|
|
561
|
+
);
|
|
459
562
|
// The base path is read-only by construction (TPH_POLYMORPHIC_VERBS), but the
|
|
460
563
|
// per-subtype mounts below it are full CRUD — so `expose` does narrow this file.
|
|
461
564
|
const tphAuthJsDoc = authSeamJsDoc({ framework: "fastify", handlerName, narrowable: true });
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// downstream (e.g. to LLM tool_use input_schema) lost the nested object shape.
|
|
11
11
|
|
|
12
12
|
import { code, joinCode, imp, type Code } from "ts-poet";
|
|
13
|
-
import { MetaObject, MetaField, stripPackage } from "@metaobjectsdev/metadata";
|
|
13
|
+
import { MetaObject, MetaField, type MetaRoot, stripPackage } from "@metaobjectsdev/metadata";
|
|
14
14
|
import {
|
|
15
15
|
FIELD_SUBTYPE_STRING, FIELD_SUBTYPE_INT, FIELD_SUBTYPE_LONG, FIELD_SUBTYPE_CURRENCY,
|
|
16
16
|
FIELD_SUBTYPE_BOOLEAN, FIELD_SUBTYPE_DOUBLE, FIELD_SUBTYPE_FLOAT,
|
|
@@ -75,6 +75,28 @@ export function tphDiscriminatorBase(obj: MetaObject): MetaObject | undefined {
|
|
|
75
75
|
return tphDiscriminatorLevel(obj)?.base;
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
/**
|
|
79
|
+
* The object whose TABLE stores `obj`'s rows: the discriminator base for anything beneath
|
|
80
|
+
* a TPH base — a concrete subtype or an abstract level in between — otherwise `obj`.
|
|
81
|
+
*
|
|
82
|
+
* Neither a subtype's module nor an abstract level's emits a table const, so anything
|
|
83
|
+
* that binds to "the other side" of a reference or relationship — an FK's `.references()`,
|
|
84
|
+
* a `relations()` entry, an M:N traversal's target table — must bind here and never to the
|
|
85
|
+
* target's own name. Binding to the subtype imported `carriers` from a `Carrier.ts` that
|
|
86
|
+
* does not export it.
|
|
87
|
+
*/
|
|
88
|
+
export function tphStorageObject(obj: MetaObject): MetaObject {
|
|
89
|
+
if (declaresTphDiscriminator(obj)) return obj;
|
|
90
|
+
return tphDiscriminatorBase(obj) ?? obj;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** {@link tphStorageObject} by entity name, for the name-keyed relation map. A name the
|
|
94
|
+
* root does not resolve is returned unchanged. */
|
|
95
|
+
export function tphStorageName(entityName: string, root: MetaRoot): string {
|
|
96
|
+
const obj = root.findObject(entityName);
|
|
97
|
+
return obj === undefined ? entityName : tphStorageObject(obj).name;
|
|
98
|
+
}
|
|
99
|
+
|
|
78
100
|
/**
|
|
79
101
|
* True when this object itself DECLARES `@discriminator` — the level of a TPH hierarchy
|
|
80
102
|
* that owns the discriminator, whether or not any concrete subtype extends it yet.
|
|
@@ -126,26 +148,46 @@ export function hasAutoSetFields(obj: MetaObject): boolean {
|
|
|
126
148
|
return false;
|
|
127
149
|
}
|
|
128
150
|
|
|
151
|
+
/** Is this field's column one the TPH BASE declares, rather than a subtype-only one?
|
|
152
|
+
*
|
|
153
|
+
* Resolving (ADR-0039) and compared by name against `base.fields()` — exactly the set
|
|
154
|
+
* `collectTphSubtypeFields` treats as "already emitted" when it folds the subtype
|
|
155
|
+
* columns into the base table, so the two answers cannot drift apart. A field declared
|
|
156
|
+
* on an abstract level BETWEEN the base and the subtype is subtype-only: the base's own
|
|
157
|
+
* field set does not carry it, and neither does the base's `.notNull()`. */
|
|
158
|
+
function isTphBaseOwnField(obj: MetaObject, field: MetaField): boolean {
|
|
159
|
+
const base = tphDiscriminatorBase(obj);
|
|
160
|
+
if (base === undefined) return false;
|
|
161
|
+
return base.fields().some((f) => f.name === field.name);
|
|
162
|
+
}
|
|
163
|
+
|
|
129
164
|
/**
|
|
130
165
|
* Is this field NULL-tolerant in a TPH subtype's READ shape?
|
|
131
166
|
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
167
|
+
* Answered from the PHYSICAL column, because that is the only thing a read can return.
|
|
168
|
+
* A TPH subtype shares one table with its siblings, so a column only some subtypes
|
|
169
|
+
* declare is NULL on every other subtype's row and `drizzle-schema.ts` drops its
|
|
170
|
+
* `.notNull()` whatever `@required` says (its `forceNullable` fold). A column the base
|
|
171
|
+
* itself declares carries `.notNull()` precisely when the field is required. The PRIMARY
|
|
172
|
+
* KEY is the shared base table's key and is present on every row.
|
|
173
|
+
*
|
|
174
|
+
* `@default` is deliberately NOT consulted, and that is the fix rather than an omission.
|
|
175
|
+
* A default decides whether an INSERT may leave the value out; it says nothing about what
|
|
176
|
+
* a READ can see. Asking `fieldWillBeOptional` here widened a `NOT NULL DEFAULT` column to
|
|
177
|
+
* `| null` and — through the `.optional()` that same predicate mirrors — to `| undefined`,
|
|
178
|
+
* which the declared interface did not admit: `parse<Base>()` returned a value not
|
|
179
|
+
* assignable to the base union and the generated module failed to compile (TS2322).
|
|
137
180
|
*
|
|
138
181
|
* ONE predicate, because TWO emitters answer this question about the same field: the
|
|
139
182
|
* Zod read schema (`renderTphSubtypeReadSchema`) and the declared TS type
|
|
140
|
-
* (`renderValueObjectInterface`).
|
|
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;
|
|
183
|
+
* (`renderValueObjectInterface`). A second answer to one question is the defect;
|
|
143
184
|
* keeping the two call sites pointed here is the fix.
|
|
144
185
|
*/
|
|
145
186
|
export function isTphReadNullTolerant(obj: MetaObject, field: MetaField): boolean {
|
|
146
187
|
if (!isTphSubtype(obj)) return false;
|
|
147
|
-
if (
|
|
148
|
-
|
|
188
|
+
if (primaryIdentityFieldNames(obj).includes(field.name)) return false;
|
|
189
|
+
if (!isTphBaseOwnField(obj, field)) return true;
|
|
190
|
+
return !isRequired(field);
|
|
149
191
|
}
|
|
150
192
|
|
|
151
193
|
/**
|
|
@@ -169,10 +211,16 @@ export function renderTphSubtypeReadSchema(obj: MetaObject, ctx?: RenderContext)
|
|
|
169
211
|
fieldLines.push(code` ${child.name}: z.literal(${JSON.stringify(tphPin.value)})`);
|
|
170
212
|
continue;
|
|
171
213
|
}
|
|
172
|
-
|
|
173
|
-
//
|
|
174
|
-
// `.
|
|
175
|
-
//
|
|
214
|
+
// forceRequired: a row selected from the table carries every column as a KEY —
|
|
215
|
+
// a nullable one arrives as `null`, never absent — so nothing with a COLUMN in a
|
|
216
|
+
// read shape is `.optional()`. Letting zodFieldExpr append it made the inferred
|
|
217
|
+
// type `T | undefined` while the declared interface said `T`, and the two
|
|
218
|
+
// disagreed. A derived (origin-bearing) field is the one member of obj.fields()
|
|
219
|
+
// with no column — drizzle-schema.ts emits none, and the TPH queries path selects
|
|
220
|
+
// the bare base table — so its key is genuinely absent from every parsed row and
|
|
221
|
+
// it alone keeps `.optional()`, matching the interface's `?: T | null`.
|
|
222
|
+
// Null-tolerance is added below, from the column, by isTphReadNullTolerant.
|
|
223
|
+
const expr = zodFieldExpr(child, obj, ctx, !child.isDerived());
|
|
176
224
|
fieldLines.push(
|
|
177
225
|
isTphReadNullTolerant(obj, child)
|
|
178
226
|
? code` ${child.name}: ${expr}.nullable()`
|
|
@@ -584,8 +632,9 @@ function zodFieldExpr(
|
|
|
584
632
|
field: MetaField,
|
|
585
633
|
owner?: MetaObject,
|
|
586
634
|
ctx?: RenderContext,
|
|
587
|
-
/** Suppress the trailing `.optional()
|
|
588
|
-
*
|
|
635
|
+
/** Suppress the trailing `.optional()`. Set by the insert-shape emitters for an
|
|
636
|
+
* assigned PK (see assignedPkFieldNames) and by the TPH read shape, where every
|
|
637
|
+
* column is a present key. The UPDATE shape must stay optional (PATCH semantics). */
|
|
589
638
|
forceRequired = false,
|
|
590
639
|
): Code {
|
|
591
640
|
// `@dbColumnType: jsonb` on a scalar (legal only on field.string) is the
|