@metaobjectsdev/codegen-ts 1.0.5-rc.2 → 1.0.5-rc.4
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.js +5 -4
- package/dist/generators/api-model.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/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/routes-file.d.ts.map +1 -1
- package/dist/templates/routes-file.js +60 -9
- package/dist/templates/routes-file.js.map +1 -1
- package/dist/templates/zod-validators.d.ts +14 -8
- package/dist/templates/zod-validators.d.ts.map +1 -1
- package/dist/templates/zod-validators.js +44 -16
- package/dist/templates/zod-validators.js.map +1 -1
- package/package.json +6 -6
- package/src/generators/api-model.ts +5 -4
- package/src/relation-resolver.ts +90 -7
- package/src/runner.ts +1 -1
- package/src/templates/entity-ui-descriptor.ts +9 -16
- package/src/templates/routes-file.ts +95 -9
- package/src/templates/zod-validators.ts +43 -16
package/src/relation-resolver.ts
CHANGED
|
@@ -18,6 +18,8 @@ import {
|
|
|
18
18
|
stripPackage,
|
|
19
19
|
} from "@metaobjectsdev/metadata";
|
|
20
20
|
import { variableNameFromEntity } from "./naming.js";
|
|
21
|
+
import { hasWritableRdbSource } from "./source-detect.js";
|
|
22
|
+
import { tphStorageObject } from "./templates/zod-validators.js";
|
|
21
23
|
import { isProjection } from "./projection/projection-detector.js";
|
|
22
24
|
|
|
23
25
|
export interface RelationEntry {
|
|
@@ -55,8 +57,16 @@ export type RelationMap = Map<string, RelationEntry[]>;
|
|
|
55
57
|
/**
|
|
56
58
|
* Walk all entities, collect relationship children, and also register inverse
|
|
57
59
|
* many() sides on the target entity.
|
|
60
|
+
*
|
|
61
|
+
* `onWarn`, when supplied, receives one line per M:N relationship whose junction FKs
|
|
62
|
+
* could not be derived: the entry is skipped (a route that mounts nothing is an
|
|
63
|
+
* ABSENCE, not an error), and the warning is the only thing that says so. The runner
|
|
64
|
+
* passes its warnings channel; callers without one keep the silent skip.
|
|
58
65
|
*/
|
|
59
|
-
export function buildRelationMap(
|
|
66
|
+
export function buildRelationMap(
|
|
67
|
+
root: MetaRoot,
|
|
68
|
+
onWarn?: (msg: string) => void,
|
|
69
|
+
): RelationMap {
|
|
60
70
|
const result: RelationMap = new Map();
|
|
61
71
|
|
|
62
72
|
const ensure = (name: string): RelationEntry[] => {
|
|
@@ -64,12 +74,71 @@ export function buildRelationMap(root: MetaRoot): RelationMap {
|
|
|
64
74
|
return result.get(name)!;
|
|
65
75
|
};
|
|
66
76
|
|
|
77
|
+
// Files a CARDINALITY-ONE entry under the entity whose module renders the
|
|
78
|
+
// relations() block. A TPH subtype has no module of its own — it is folded into
|
|
79
|
+
// the discriminator base's single table, and the block renders on the BASE's.
|
|
80
|
+
// So an entry must be filed under the entity that actually renders it, or it is
|
|
81
|
+
// silently never emitted. `tphStorageName` is the seam for exactly this (its own
|
|
82
|
+
// doc says "for the name-keyed relation map"); the TARGET side of relations-block
|
|
83
|
+
// already resolves through it — only the SOURCE side was keyed raw.
|
|
84
|
+
//
|
|
85
|
+
// Because `obj.relationships()` RESOLVES, a base-declared relationship is reached
|
|
86
|
+
// again through every subtype and would now land on the same key repeatedly, so an
|
|
87
|
+
// entry structurally identical to one already filed is skipped. A name that collides
|
|
88
|
+
// with a DIFFERENT shape is a real conflict the base's single block cannot express:
|
|
89
|
+
// it is reported through `onWarn` rather than silently overwritten, because a
|
|
90
|
+
// navigation that quietly resolves to another subtype's target is the worse failure.
|
|
91
|
+
// True when a module renders a relations() block for this storage object — the
|
|
92
|
+
// question the map's contract asks, not either cause of its answer. Both
|
|
93
|
+
// non-abstractness and a writable source.rdb are required: the entity file
|
|
94
|
+
// routes an object failing EITHER to renderValueObjectFile, which emits no
|
|
95
|
+
// block. The oracle's tableBackedObjects walk makes the same exclusion. Filing
|
|
96
|
+
// an entry without it would only document, in `meta docs`/api-model, a
|
|
97
|
+
// `<Entity>Relations` export that no module emits.
|
|
98
|
+
const rendersRelationsBlock = (storing: MetaObject): boolean =>
|
|
99
|
+
!storing.isAbstract && hasWritableRdbSource(storing);
|
|
100
|
+
|
|
101
|
+
const push = (obj: MetaObject, entry: RelationEntry): void => {
|
|
102
|
+
const storing = tphStorageObject(obj);
|
|
103
|
+
if (!rendersRelationsBlock(storing)) return;
|
|
104
|
+
const key = storing.name;
|
|
105
|
+
const entries = ensure(key);
|
|
106
|
+
const clash = entries.find((e) => e.name === entry.name);
|
|
107
|
+
if (clash !== undefined) {
|
|
108
|
+
const same =
|
|
109
|
+
clash.cardinality === entry.cardinality &&
|
|
110
|
+
clash.targetEntity === entry.targetEntity &&
|
|
111
|
+
clash.fkField === entry.fkField;
|
|
112
|
+
if (!same) {
|
|
113
|
+
onWarn?.(
|
|
114
|
+
`relationship "${entry.name}" on entity "${obj.name}" gets no relations() entry: ` +
|
|
115
|
+
`"${key}" already carries a different "${entry.name}" ` +
|
|
116
|
+
`(-> ${clash.targetEntity} via ${clash.fkField}), and a single-table hierarchy ` +
|
|
117
|
+
`renders ONE relations() block on the base, which cannot hold both. ` +
|
|
118
|
+
`Rename one of them.`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
entries.push(entry);
|
|
124
|
+
};
|
|
125
|
+
|
|
67
126
|
for (const obj of root.objects()) {
|
|
68
127
|
// Projections (source.dbView) are view-backed; they never emit a relations()
|
|
69
128
|
// block, and their inherited belongs-to relationships would otherwise register
|
|
70
129
|
// a spurious inverse-many on the target entity.
|
|
71
130
|
if (isProjection(obj)) continue;
|
|
72
131
|
|
|
132
|
+
// An ABSTRACT level's own declarations do not file. Its FK column reaches the
|
|
133
|
+
// base's single table only through a CONCRETE @discriminatorValue descendant
|
|
134
|
+
// (collectTphSubtypeFields folds effective fields per concrete subtype), and
|
|
135
|
+
// that descendant's RESOLVING relationships() walk reaches this same
|
|
136
|
+
// relationship and files the identical entry — the dedupe collapses the
|
|
137
|
+
// copies. With no concrete descendant there is no folded column and no rows to
|
|
138
|
+
// navigate, so absence is the honest output: an entry would make the base's
|
|
139
|
+
// relations() block name a column the table does not have.
|
|
140
|
+
if (obj.isAbstract) continue;
|
|
141
|
+
|
|
73
142
|
for (const child of obj.relationships()) {
|
|
74
143
|
// ADR-0039: resolving — a relationship may inherit @cardinality via extends.
|
|
75
144
|
const cardinality = child.attr(RELATIONSHIP_ATTR_CARDINALITY) as string | undefined;
|
|
@@ -79,7 +148,12 @@ export function buildRelationMap(root: MetaRoot): RelationMap {
|
|
|
79
148
|
// many(junction) navigation on the source.
|
|
80
149
|
// ADR-0039: resolving — @through may be inherited via extends.
|
|
81
150
|
if (cardinality === CARDINALITY_MANY && child.attr(RELATIONSHIP_ATTR_THROUGH) !== undefined) {
|
|
82
|
-
const m2m = buildM2mEntry(obj, child as MetaRelationship, root);
|
|
151
|
+
const m2m = buildM2mEntry(obj, child as MetaRelationship, root, onWarn);
|
|
152
|
+
// NOT re-keyed to the storage base: the ROUTES tier reads this map to mount
|
|
153
|
+
// an M:N under EACH concrete subtype's segment, so it needs the per-subtype
|
|
154
|
+
// entries. Collapsing them onto the base key silently reduces four mounts to
|
|
155
|
+
// one. Only the cardinality-one path below is re-keyed, because that is the
|
|
156
|
+
// one the relations() block renders on the base's module.
|
|
83
157
|
if (m2m) ensure(obj.name).push(m2m);
|
|
84
158
|
continue;
|
|
85
159
|
}
|
|
@@ -106,7 +180,7 @@ export function buildRelationMap(root: MetaRoot): RelationMap {
|
|
|
106
180
|
const fkField = matching.fields[0];
|
|
107
181
|
if (!fkField) continue;
|
|
108
182
|
|
|
109
|
-
|
|
183
|
+
push(obj, {
|
|
110
184
|
name: child.name,
|
|
111
185
|
cardinality: "one",
|
|
112
186
|
targetEntity,
|
|
@@ -131,7 +205,6 @@ export function buildRelationMap(root: MetaRoot): RelationMap {
|
|
|
131
205
|
for (const junctionName of collectJunctionNames(root)) {
|
|
132
206
|
const junction = root.findObject(junctionName);
|
|
133
207
|
if (!junction) continue;
|
|
134
|
-
const entries = ensure(junctionName);
|
|
135
208
|
for (const ref of junction.referenceIdentities()) {
|
|
136
209
|
const targetRaw = ref.targetEntity;
|
|
137
210
|
const fkField = ref.fields[0];
|
|
@@ -143,7 +216,7 @@ export function buildRelationMap(root: MetaRoot): RelationMap {
|
|
|
143
216
|
const refName = ref.name && ref.name.length > 0
|
|
144
217
|
? ref.name
|
|
145
218
|
: variableNameFromEntity(targetEntity);
|
|
146
|
-
|
|
219
|
+
push(junction, {
|
|
147
220
|
name: refName,
|
|
148
221
|
cardinality: "one",
|
|
149
222
|
targetEntity,
|
|
@@ -174,12 +247,16 @@ function collectJunctionNames(root: MetaRoot): Set<string> {
|
|
|
174
247
|
* Build the source-side M:N navigation entry: derive the junction FK fields from
|
|
175
248
|
* the junction's two identity.reference children (the SSOT), handling hetero /
|
|
176
249
|
* directed-self-join / symmetric. Returns null (skips the entry) if derivation
|
|
177
|
-
* fails
|
|
250
|
+
* fails, reporting the derivation's own reason through `onWarn` when supplied —
|
|
251
|
+
* the loader's rules never check subject pairing, so a model can load clean and
|
|
252
|
+
* still carry a junction this pass cannot pair (e.g. one whose identity.reference
|
|
253
|
+
* names a concrete subtype of the declaring entity).
|
|
178
254
|
*/
|
|
179
255
|
function buildM2mEntry(
|
|
180
256
|
source: MetaObject,
|
|
181
257
|
rel: MetaRelationship,
|
|
182
258
|
root: MetaRoot,
|
|
259
|
+
onWarn?: (msg: string) => void,
|
|
183
260
|
): RelationEntry | null {
|
|
184
261
|
// ADR-0039: resolving — @objectRef/@through may be inherited via extends.
|
|
185
262
|
const targetRaw = rel.attr(RELATIONSHIP_ATTR_OBJECT_REF) as string | undefined;
|
|
@@ -188,7 +265,13 @@ function buildM2mEntry(
|
|
|
188
265
|
let fields;
|
|
189
266
|
try {
|
|
190
267
|
fields = deriveM2MFields(rel, source, root);
|
|
191
|
-
} catch {
|
|
268
|
+
} catch (err) {
|
|
269
|
+
onWarn?.(
|
|
270
|
+
`M:N relationship "${rel.name}" on entity "${source.name}" gets no traversal route: its ` +
|
|
271
|
+
`@through junction "${stripPackage(throughRaw)}" could not be paired — ` +
|
|
272
|
+
`${err instanceof Error ? err.message : String(err)}. The model loads, so the run ` +
|
|
273
|
+
`continues, but the endpoint is absent (a 404).`,
|
|
274
|
+
);
|
|
192
275
|
return null;
|
|
193
276
|
}
|
|
194
277
|
return {
|
package/src/runner.ts
CHANGED
|
@@ -486,7 +486,7 @@ export async function runGen(opts: RunGenOpts): Promise<RunGenResult> {
|
|
|
486
486
|
|
|
487
487
|
// 3. Build shared render state once.
|
|
488
488
|
const pkMap = buildPkMap(root);
|
|
489
|
-
const relationMap = buildRelationMap(root);
|
|
489
|
+
const relationMap = buildRelationMap(root, (m) => warnings.push(m));
|
|
490
490
|
// ADR-0044/#228 — the ENTITY-tier collision domain is the run's EMITTED
|
|
491
491
|
// `object.value` SET (NOT any per-payload closure): value-object module
|
|
492
492
|
// filenames + `packageOf` are per-run/global, so the emitted-name map is built
|
|
@@ -53,7 +53,6 @@ import {
|
|
|
53
53
|
import { inferViewKind, currencyMetaFor, labelFor, humanize, valueObjectFor } from "./field-meta.js";
|
|
54
54
|
import { VIEW_CONTEXT_FORM } from "../view-context.js";
|
|
55
55
|
import { enumValues } from "../enum-meta.js";
|
|
56
|
-
import { isProjection } from "../projection/projection-detector.js";
|
|
57
56
|
// `restPath` lives HERE rather than in api-surface.ts, which is where it used to sit and
|
|
58
57
|
// which now re-exports it. The descriptor is what emits `$path`, so the composition has to
|
|
59
58
|
// be reachable from this module; importing it back from `api-surface.js` would be a cycle,
|
|
@@ -116,30 +115,24 @@ export interface EntityUiDescriptor {
|
|
|
116
115
|
* An object's OWN pluralized resource path — the one derivation of that spelling, and the
|
|
117
116
|
* input {@link restPath} composes an address from. It is NOT `$path` on its own.
|
|
118
117
|
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
* `agent/ui.md` and `api/AGENT-API.md` both came to print `/order_summaries` for a
|
|
126
|
-
* projection served at `/order-summaries`.
|
|
118
|
+
* ONE rule for entities and projections alike: snake_case the name, then pluralize.
|
|
119
|
+
* A projection used to be KEBAB-cased here while an entity was SNAKE-cased, composed in
|
|
120
|
+
* the opposite ORDER, and that split was grandfathered rather than designed — the only
|
|
121
|
+
* reason recorded for it was that the projection const had always emitted it. It is
|
|
122
|
+
* collapsed, so `OrderSummary` is `/order_summaries` whichever it is. That renamed every
|
|
123
|
+
* multi-word projection's collection URL; see CHANGELOG.
|
|
127
124
|
*
|
|
128
125
|
* "Subscriber" → "/subscribers"
|
|
129
126
|
* "WorkoutEvent" → "/workout_events"
|
|
130
|
-
* "ProgramSummary" → "/
|
|
127
|
+
* "ProgramSummary" → "/program_summaries" (projection — same rule)
|
|
131
128
|
*
|
|
132
129
|
* A TPH SUBTYPE is not addressed by this path — it is mounted under its base — so this is
|
|
133
130
|
* an INPUT to {@link restPath}, not the answer. `$path` carries `restPath`; call this only
|
|
134
131
|
* when you specifically want an object's own pluralized name, never to build an address.
|
|
135
132
|
*/
|
|
136
133
|
export function resourcePath(entity: MetaData): string {
|
|
137
|
-
//
|
|
138
|
-
|
|
139
|
-
// pluralize is what the entity const has. Neither may be "tidied" into the other.
|
|
140
|
-
return isProjection(entity)
|
|
141
|
-
? `/${toSnakeCase(pluralize(entity.name)).replace(/_/g, "-")}`
|
|
142
|
-
: `/${pluralize(toSnakeCase(entity.name))}`;
|
|
134
|
+
// One rule, every object kind and every port: snake_case, then pluralize.
|
|
135
|
+
return `/${pluralize(toSnakeCase(entity.name))}`;
|
|
143
136
|
}
|
|
144
137
|
|
|
145
138
|
/**
|
|
@@ -167,9 +167,7 @@ export async function ${handlerName}(fastify: ${FastifyInstanceSym}) {
|
|
|
167
167
|
// FK columns were derived from the junction's identity.reference children (the
|
|
168
168
|
// SSOT) by the relation-resolver pre-pass; here we resolve them to physical
|
|
169
169
|
// column names for the Drizzle two-stage join.
|
|
170
|
-
const m2mEntries = (ctx
|
|
171
|
-
(e): e is RelationEntry & { junctionEntity: string } => e.junctionEntity !== undefined,
|
|
172
|
-
);
|
|
170
|
+
const m2mEntries = m2mEntriesOf(ctx, entityName);
|
|
173
171
|
// Two fastify-scope variants: under an apiPrefix the mounts live inside the
|
|
174
172
|
// register-block (`instance`); otherwise they bind directly to `fastify`.
|
|
175
173
|
const m2mMountsPrefixed = renderM2mMounts(m2mEntries, entity, ctx, "instance");
|
|
@@ -242,6 +240,21 @@ ${m2mMountsFlat}}
|
|
|
242
240
|
return header + literalImports.toString() + body.toString();
|
|
243
241
|
}
|
|
244
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
|
+
|
|
245
258
|
/**
|
|
246
259
|
* Render the M:N traversal mounts for an entity as a single Code fragment to
|
|
247
260
|
* interpolate INTO the handler-body code template (so the junction/target table
|
|
@@ -255,13 +268,38 @@ function renderM2mMounts(
|
|
|
255
268
|
source: MetaObject,
|
|
256
269
|
ctx: RenderContext,
|
|
257
270
|
fastifyVar: string,
|
|
271
|
+
tphSource?: TphM2mSource,
|
|
258
272
|
): Code | string {
|
|
259
273
|
if (entries.length === 0) return "";
|
|
260
|
-
const mounts = entries.map((e) => renderM2mMount(e, source, ctx, fastifyVar));
|
|
274
|
+
const mounts = entries.map((e) => renderM2mMount(e, source, ctx, fastifyVar, tphSource));
|
|
261
275
|
return code`${joinCode(mounts, { on: "\n", trim: false })}
|
|
262
276
|
`;
|
|
263
277
|
}
|
|
264
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
|
+
|
|
265
303
|
/**
|
|
266
304
|
* Render one M:N traversal mount. The junction + target Drizzle table consts are
|
|
267
305
|
* imported from their sibling entity files (imp() lets ts-poet track + emit the
|
|
@@ -274,6 +312,7 @@ function renderM2mMount(
|
|
|
274
312
|
source: MetaObject,
|
|
275
313
|
ctx: RenderContext,
|
|
276
314
|
fastifyVar: string,
|
|
315
|
+
tphSource?: TphM2mSource,
|
|
277
316
|
): Code {
|
|
278
317
|
// `source` never changes across this function, so its effective package is computed
|
|
279
318
|
// once and reused below (both crossEntitySpecifier calls, and the three
|
|
@@ -329,9 +368,20 @@ function renderM2mMount(
|
|
|
329
368
|
targetDiscriminator: { column: ${resolveJunctionColumn(target, pin.fieldName, ctx, sourcePkg)}, value: ${JSON.stringify(pin.value)} },`
|
|
330
369
|
: "";
|
|
331
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)} },`;
|
|
381
|
+
|
|
332
382
|
return code` ${mountM2mRouteSym}({
|
|
333
383
|
fastify: ${fastifyVar},
|
|
334
|
-
path: ${
|
|
384
|
+
path: ${pathExpr},
|
|
335
385
|
relationName: ${JSON.stringify(entry.name)},
|
|
336
386
|
db,
|
|
337
387
|
junctionTable: ${junctionVarSym},
|
|
@@ -339,7 +389,7 @@ function renderM2mMount(
|
|
|
339
389
|
sourceColumn: ${sourceColumn},
|
|
340
390
|
targetColumn: ${targetColumn},
|
|
341
391
|
targetPkColumn: ${targetPkColumn},
|
|
342
|
-
symmetric: ${entry.symmetric ? "true" : "false"},${discriminatorLine}
|
|
392
|
+
symmetric: ${entry.symmetric ? "true" : "false"},${discriminatorLine}${sourceDiscriminatorLine}
|
|
343
393
|
});`;
|
|
344
394
|
}
|
|
345
395
|
|
|
@@ -440,7 +490,20 @@ function renderTphRoutesFile(
|
|
|
440
490
|
dialect: ${dialectLit},${polymorphicExposeLine}
|
|
441
491
|
});`;
|
|
442
492
|
|
|
443
|
-
|
|
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 }) => {
|
|
444
507
|
const subFileSpec = entityModuleSpecifier(
|
|
445
508
|
ctx.selfTarget, ctx.entityModuleTarget, effectivePackage(sub), sub.name, ctx.extStyle,
|
|
446
509
|
);
|
|
@@ -457,7 +520,7 @@ function renderTphRoutesFile(
|
|
|
457
520
|
// (discriminator excluded — it's pinned by this path).
|
|
458
521
|
const subFilterSym = imp(`${sub.name}FilterAllowlist@${subFileSpec}`);
|
|
459
522
|
const subSortSym = imp(`${sub.name}SortAllowlist@${subFileSpec}`);
|
|
460
|
-
|
|
523
|
+
const crud = code`
|
|
461
524
|
${mountCrudRoutesSym}({
|
|
462
525
|
fastify: ${fastifyRef},
|
|
463
526
|
path: ${baseConstSym}.$path + ${JSON.stringify("/" + segment)},
|
|
@@ -470,9 +533,32 @@ function renderTphRoutesFile(
|
|
|
470
533
|
dialect: ${dialectLit},
|
|
471
534
|
discriminator: { column: ${JSON.stringify(discField)}, value: ${JSON.stringify(value)} },${exposeLine(expose, " ")}
|
|
472
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];
|
|
473
556
|
});
|
|
474
557
|
|
|
475
|
-
const mounts = joinCode(
|
|
558
|
+
const mounts = joinCode(
|
|
559
|
+
[polymorphic, ...(baseM2mMounts === "" ? [] : [baseM2mMounts as Code]), ...subtypeMounts],
|
|
560
|
+
{ on: "\n" },
|
|
561
|
+
);
|
|
476
562
|
// The base path is read-only by construction (TPH_POLYMORPHIC_VERBS), but the
|
|
477
563
|
// per-subtype mounts below it are full CRUD — so `expose` does narrow this file.
|
|
478
564
|
const tphAuthJsDoc = authSeamJsDoc({ framework: "fastify", handlerName, narrowable: true });
|
|
@@ -148,26 +148,46 @@ export function hasAutoSetFields(obj: MetaObject): boolean {
|
|
|
148
148
|
return false;
|
|
149
149
|
}
|
|
150
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
|
+
|
|
151
164
|
/**
|
|
152
165
|
* Is this field NULL-tolerant in a TPH subtype's READ shape?
|
|
153
166
|
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
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).
|
|
159
180
|
*
|
|
160
181
|
* ONE predicate, because TWO emitters answer this question about the same field: the
|
|
161
182
|
* Zod read schema (`renderTphSubtypeReadSchema`) and the declared TS type
|
|
162
|
-
* (`renderValueObjectInterface`).
|
|
163
|
-
* `parse<Base>()` returns was not assignable to the base union and the generated
|
|
164
|
-
* 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;
|
|
165
184
|
* keeping the two call sites pointed here is the fix.
|
|
166
185
|
*/
|
|
167
186
|
export function isTphReadNullTolerant(obj: MetaObject, field: MetaField): boolean {
|
|
168
187
|
if (!isTphSubtype(obj)) return false;
|
|
169
|
-
if (
|
|
170
|
-
|
|
188
|
+
if (primaryIdentityFieldNames(obj).includes(field.name)) return false;
|
|
189
|
+
if (!isTphBaseOwnField(obj, field)) return true;
|
|
190
|
+
return !isRequired(field);
|
|
171
191
|
}
|
|
172
192
|
|
|
173
193
|
/**
|
|
@@ -191,10 +211,16 @@ export function renderTphSubtypeReadSchema(obj: MetaObject, ctx?: RenderContext)
|
|
|
191
211
|
fieldLines.push(code` ${child.name}: z.literal(${JSON.stringify(tphPin.value)})`);
|
|
192
212
|
continue;
|
|
193
213
|
}
|
|
194
|
-
|
|
195
|
-
//
|
|
196
|
-
// `.
|
|
197
|
-
//
|
|
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());
|
|
198
224
|
fieldLines.push(
|
|
199
225
|
isTphReadNullTolerant(obj, child)
|
|
200
226
|
? code` ${child.name}: ${expr}.nullable()`
|
|
@@ -606,8 +632,9 @@ function zodFieldExpr(
|
|
|
606
632
|
field: MetaField,
|
|
607
633
|
owner?: MetaObject,
|
|
608
634
|
ctx?: RenderContext,
|
|
609
|
-
/** Suppress the trailing `.optional()
|
|
610
|
-
*
|
|
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). */
|
|
611
638
|
forceRequired = false,
|
|
612
639
|
): Code {
|
|
613
640
|
// `@dbColumnType: jsonb` on a scalar (legal only on field.string) is the
|