@metaobjectsdev/codegen-ts 0.16.0-rc.1 → 0.17.0-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 +10 -0
- package/dist/column-mapper.d.ts.map +1 -1
- package/dist/column-mapper.js +21 -2
- package/dist/column-mapper.js.map +1 -1
- package/dist/naming.d.ts +4 -0
- package/dist/naming.d.ts.map +1 -1
- package/dist/naming.js +6 -0
- package/dist/naming.js.map +1 -1
- package/dist/projection/build-projection-views.d.ts +5 -1
- package/dist/projection/build-projection-views.d.ts.map +1 -1
- package/dist/projection/build-projection-views.js +165 -47
- package/dist/projection/build-projection-views.js.map +1 -1
- package/dist/projection/extract-view-spec.d.ts +8 -1
- package/dist/projection/extract-view-spec.d.ts.map +1 -1
- package/dist/projection/extract-view-spec.js +430 -29
- package/dist/projection/extract-view-spec.js.map +1 -1
- package/dist/projection/view-ddl-emit.d.ts.map +1 -1
- package/dist/projection/view-ddl-emit.js +142 -25
- package/dist/projection/view-ddl-emit.js.map +1 -1
- package/dist/projection/view-spec.d.ts +101 -3
- package/dist/projection/view-spec.d.ts.map +1 -1
- package/dist/templates/drizzle-schema.d.ts.map +1 -1
- package/dist/templates/drizzle-schema.js +9 -0
- 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 +39 -3
- package/dist/templates/entity-file.js.map +1 -1
- package/dist/templates/inferred-types.d.ts +1 -1
- package/dist/templates/inferred-types.d.ts.map +1 -1
- package/dist/templates/inferred-types.js +13 -1
- package/dist/templates/inferred-types.js.map +1 -1
- package/dist/templates/projection-decl.d.ts.map +1 -1
- package/dist/templates/projection-decl.js +9 -70
- package/dist/templates/projection-decl.js.map +1 -1
- package/dist/templates/queries-file.d.ts.map +1 -1
- package/dist/templates/queries-file.js +117 -43
- package/dist/templates/queries-file.js.map +1 -1
- package/dist/templates/queries.d.ts +21 -4
- package/dist/templates/queries.d.ts.map +1 -1
- package/dist/templates/queries.js +48 -12
- package/dist/templates/queries.js.map +1 -1
- package/dist/templates/view-decl.d.ts +28 -0
- package/dist/templates/view-decl.d.ts.map +1 -0
- package/dist/templates/view-decl.js +107 -0
- package/dist/templates/view-decl.js.map +1 -0
- package/dist/templates/zod-validators.d.ts +6 -0
- package/dist/templates/zod-validators.d.ts.map +1 -1
- package/dist/templates/zod-validators.js +53 -7
- package/dist/templates/zod-validators.js.map +1 -1
- package/package.json +6 -6
- package/src/column-mapper.ts +26 -1
- package/src/naming.ts +7 -0
- package/src/projection/build-projection-views.ts +211 -50
- package/src/projection/extract-view-spec.ts +468 -29
- package/src/projection/view-ddl-emit.ts +158 -24
- package/src/projection/view-spec.ts +104 -3
- package/src/reference/entity.ts +11 -1
- package/src/reference/queries.ts +4 -1
- package/src/templates/drizzle-schema.ts +7 -0
- package/src/templates/entity-file.ts +46 -3
- package/src/templates/inferred-types.ts +18 -1
- package/src/templates/projection-decl.ts +8 -74
- package/src/templates/queries-file.ts +133 -48
- package/src/templates/queries.ts +50 -11
- package/src/templates/view-decl.ts +128 -0
- package/src/templates/zod-validators.ts +54 -9
|
@@ -1,9 +1,29 @@
|
|
|
1
|
-
import { TYPE_FIELD, TYPE_IDENTITY, TYPE_ORIGIN, TYPE_RELATIONSHIP, MetaSource, ORIGIN_SUBTYPE_PASSTHROUGH, ORIGIN_SUBTYPE_AGGREGATE, ORIGIN_PASSTHROUGH_ATTR_FROM, ORIGIN_PASSTHROUGH_ATTR_VIA, ORIGIN_AGGREGATE_ATTR_AGG, ORIGIN_AGGREGATE_ATTR_OF, ORIGIN_AGGREGATE_ATTR_VIA, ORIGIN_AGGREGATE_ATTR_FILTER, RELATIONSHIP_ATTR_OBJECT_REF, RELATIONSHIP_ATTR_CARDINALITY, CARDINALITY_ONE, IDENTITY_SUBTYPE_REFERENCE, IDENTITY_REFERENCE_ATTR_REFERENCES, FIELD_ATTR_COLUMN, findReferenceBetween, stripPackage, } from "@metaobjectsdev/metadata";
|
|
1
|
+
import { TYPE_FIELD, TYPE_IDENTITY, TYPE_ORIGIN, TYPE_RELATIONSHIP, MetaSource, ORIGIN_SUBTYPE_PASSTHROUGH, ORIGIN_SUBTYPE_AGGREGATE, ORIGIN_SUBTYPE_COMPUTED, ORIGIN_SUBTYPE_FIRST, ORIGIN_PASSTHROUGH_ATTR_FROM, ORIGIN_PASSTHROUGH_ATTR_VIA, ORIGIN_AGGREGATE_ATTR_AGG, ORIGIN_AGGREGATE_ATTR_OF, ORIGIN_AGGREGATE_ATTR_VIA, ORIGIN_AGGREGATE_ATTR_FILTER, ORIGIN_ATTR_DISTINCT, ORIGIN_ATTR_ORDER_BY, ORIGIN_COMPUTED_ATTR_EXPR, ORIGIN_FIRST_ATTR_OF, ORIGIN_FIRST_ATTR_VIA, ORIGIN_FIRST_ATTR_FILTER, AGG_ANY, AGG_ALL, AGG_COLLECT, AGGREGATE_FUNCTIONS, FILTER_OP_EQ, FILTER_OP_NE, FILTER_OP_GT, FILTER_OP_GTE, FILTER_OP_LT, FILTER_OP_LTE, FILTER_OP_IS_NULL, FILTER_COMPOSE_AND, FILTER_COMPOSE_OR, SORT_ORDER_DESC, RELATIONSHIP_ATTR_OBJECT_REF, RELATIONSHIP_ATTR_CARDINALITY, CARDINALITY_ONE, IDENTITY_SUBTYPE_REFERENCE, IDENTITY_REFERENCE_ATTR_REFERENCES, FIELD_ATTR_COLUMN, OBJECT_PROJECTION_ATTR_FILTER, findReferenceBetween, stripPackage, } from "@metaobjectsdev/metadata";
|
|
2
2
|
import { MetaObject } from "@metaobjectsdev/metadata";
|
|
3
3
|
import { columnNameFromField, viewNameFromProjection, } from "../naming.js";
|
|
4
|
+
// #213 — a write-through ENTITY (FR-024 §7 read-view) hosts its own view; the base
|
|
5
|
+
// is the entity itself, not an extends-anchored projection.
|
|
6
|
+
import { isWriteThrough } from "./projection-detector.js";
|
|
7
|
+
// #209 — the SAME NOT-NULL predicate that drives a column's `.notNull()` decides
|
|
8
|
+
// whether a belongs-to join is INNER (required FK) vs LEFT OUTER (nullable FK).
|
|
9
|
+
import { isRequired } from "../column-mapper.js";
|
|
4
10
|
/** Compose keys in the attr.filter shape. */
|
|
5
|
-
const FILTER_AND =
|
|
6
|
-
const FILTER_OR =
|
|
11
|
+
const FILTER_AND = FILTER_COMPOSE_AND;
|
|
12
|
+
const FILTER_OR = FILTER_COMPOSE_OR;
|
|
13
|
+
// #195 — the three expression-only op/fn names of the attr.expression grammar. They
|
|
14
|
+
// mirror EXPR_OP_IS_NOT_NULL / EXPR_OP_NOT / EXPR_FN_COALESCE in the metadata package's
|
|
15
|
+
// meta-attr-expression module, which is not re-exported through the public barrel; the
|
|
16
|
+
// remaining node ops (eq/ne/gt/… , isNull, and/or) are the shared FILTER_* vocabulary
|
|
17
|
+
// imported above.
|
|
18
|
+
const EXPR_OP_IS_NOT_NULL = "isNotNull";
|
|
19
|
+
const EXPR_OP_NOT = "not";
|
|
20
|
+
const EXPR_FN_COALESCE = "coalesce";
|
|
21
|
+
/** The scalar-reduce @agg values that map to the plain `aggregate` SelectColumn kind. */
|
|
22
|
+
const SCALAR_AGG_FUNCTIONS = new Set(AGGREGATE_FUNCTIONS);
|
|
23
|
+
/** Expression comparison ops (share the filter vocabulary + per-subtype legality bands). */
|
|
24
|
+
const EXPR_COMPARISON_OPS = new Set([
|
|
25
|
+
FILTER_OP_EQ, FILTER_OP_NE, FILTER_OP_GT, FILTER_OP_GTE, FILTER_OP_LT, FILTER_OP_LTE,
|
|
26
|
+
]);
|
|
7
27
|
/**
|
|
8
28
|
* Desugar a single field clause to the canonical `{ op: value }` form (scalar→eq,
|
|
9
29
|
* array→in, null→isNull, object→as-is). Mirrors metadata's attr.filter desugar so an
|
|
@@ -55,6 +75,63 @@ function resolveAggregateFilter(filter, entity, alias, ctx) {
|
|
|
55
75
|
return undefined;
|
|
56
76
|
return clauses.length === 1 ? clauses[0] : { kind: "and", clauses };
|
|
57
77
|
}
|
|
78
|
+
/**
|
|
79
|
+
* #207 — resolve a projection's row-scope `@filter` (the desugared canonical
|
|
80
|
+
* `{ field: { op: value }, and?, or? }`) into a {@link ViewFilterClause} whose
|
|
81
|
+
* refs are resolved against the projection's OWN declared columns (its SelectSpec),
|
|
82
|
+
* NOT against a single aggregated entity+alias (that is `resolveAggregateFilter`).
|
|
83
|
+
* Each field key names a declared projection field; it resolves by SelectColumn kind:
|
|
84
|
+
* - passthrough (base OR joined) → `sourceAlias.sourceColumn` — the machinery that
|
|
85
|
+
* makes `WHERE joined.status IS NULL OR joined.status = 1` work.
|
|
86
|
+
* - computed (origin.computed) → the inlined resolved expression (`exprCmp`).
|
|
87
|
+
* - aggregate-derived (aggregate/predicateAgg/collectAgg/first) → THROW (a WHERE
|
|
88
|
+
* cannot see aggregates; HAVING is a separate later extension). The loader already
|
|
89
|
+
* fail-closes this (ERR_BAD_ATTR_FILTER) — this throw is a codegen belt-and-suspenders.
|
|
90
|
+
* - a ref naming no declared field → THROW (dangling; also loader-rejected).
|
|
91
|
+
* Multiple fields at one level compose with AND (mirrors resolveAggregateFilter).
|
|
92
|
+
*/
|
|
93
|
+
function resolveViewFilter(filter, columnsByField, projectionName) {
|
|
94
|
+
if (typeof filter !== "object" || filter === null || Array.isArray(filter))
|
|
95
|
+
return undefined;
|
|
96
|
+
const clauses = [];
|
|
97
|
+
for (const [key, val] of Object.entries(filter)) {
|
|
98
|
+
if (key === FILTER_AND || key === FILTER_OR) {
|
|
99
|
+
const subs = (Array.isArray(val) ? val : [])
|
|
100
|
+
.map((s) => resolveViewFilter(s, columnsByField, projectionName))
|
|
101
|
+
.filter((c) => c !== undefined);
|
|
102
|
+
if (subs.length > 0)
|
|
103
|
+
clauses.push({ kind: key === FILTER_AND ? "and" : "or", clauses: subs });
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const col = columnsByField.get(key);
|
|
107
|
+
if (!col) {
|
|
108
|
+
// The loader (validateProjectionFilter) already fail-closes a dangling or
|
|
109
|
+
// aggregate-derived ref, so by codegen time a missing column means a DECLARED,
|
|
110
|
+
// addressable field that buildSelectSpec could not resolve to a SELECT column
|
|
111
|
+
// (an internal inconsistency) — report it as such, not as "dangling".
|
|
112
|
+
throw new Error(`Projection ${projectionName}: view @filter field "${key}" did not resolve to a view column.`);
|
|
113
|
+
}
|
|
114
|
+
// A field clause may carry MULTIPLE ops (a range like { gte: 100, lte: 500 }); each
|
|
115
|
+
// becomes its own comparison, AND-composed (dropping all-but-the-first would silently
|
|
116
|
+
// widen the exposed row set). The loader has already validated every op for this
|
|
117
|
+
// field's subtype.
|
|
118
|
+
for (const [op, value] of Object.entries(desugarClause(val))) {
|
|
119
|
+
if (col.kind === "passthrough") {
|
|
120
|
+
clauses.push({ kind: "cmp", ref: `${col.sourceAlias}.${col.sourceColumn}`, op, value });
|
|
121
|
+
}
|
|
122
|
+
else if (col.kind === "computed") {
|
|
123
|
+
clauses.push({ kind: "exprCmp", expr: col.expr, op, value });
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
throw new Error(`Projection ${projectionName}: view @filter references "${key}", an aggregate-derived ` +
|
|
127
|
+
`field — a WHERE cannot see aggregates. Filter on a passthrough or computed field instead.`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (clauses.length === 0)
|
|
132
|
+
return undefined;
|
|
133
|
+
return clauses.length === 1 ? clauses[0] : { kind: "and", clauses };
|
|
134
|
+
}
|
|
58
135
|
// ---------------------------------------------------------------------------
|
|
59
136
|
// Private helpers
|
|
60
137
|
// ---------------------------------------------------------------------------
|
|
@@ -125,7 +202,7 @@ export function projectionViewName(projection, columnNamingStrategy) {
|
|
|
125
202
|
* loader's `_refNamedOwner`: the ref names the anchor, never the physical
|
|
126
203
|
* declaring ancestor of an inherited child.
|
|
127
204
|
*/
|
|
128
|
-
function refNamedOwner(node, root) {
|
|
205
|
+
export function refNamedOwner(node, root) {
|
|
129
206
|
const ref = node.superRef;
|
|
130
207
|
if (ref === undefined)
|
|
131
208
|
return undefined;
|
|
@@ -189,6 +266,121 @@ function joinColumnFor(entity, fieldName, ctx) {
|
|
|
189
266
|
const f = entity.fields().find((x) => x.name === fieldName);
|
|
190
267
|
return f ? sourceColumnNameFor(f, ctx) : columnNameFromField(fieldName, ctx.columnNamingStrategy);
|
|
191
268
|
}
|
|
269
|
+
/** The physical column of an entity's primary-key field — the LEFT-JOIN phantom guard
|
|
270
|
+
* (#195) tests `<joined>.<pk> IS NOT NULL`, and it is origin.first's tie-breaker. */
|
|
271
|
+
function primaryKeyColumn(entity, ctx) {
|
|
272
|
+
// ADR-0039: resolving — a projection base may inherit its primary identity via extends.
|
|
273
|
+
const pkField = entity.primaryIdentity()?.fields[0];
|
|
274
|
+
return pkField ? joinColumnFor(entity, pkField, ctx) : undefined;
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Walk a `@via` dotted path to its terminal (related) entity name. any/all carry no
|
|
278
|
+
* `@of` to name the aggregated entity, so it is derived from the last `@via` hop.
|
|
279
|
+
* Returns undefined if any hop fails to resolve (a prior loader error already fired).
|
|
280
|
+
*/
|
|
281
|
+
function viaTerminalEntity(via, root) {
|
|
282
|
+
const segments = via.split(".");
|
|
283
|
+
const rawEntity = segments[0];
|
|
284
|
+
if (!rawEntity)
|
|
285
|
+
return undefined;
|
|
286
|
+
let currentObj = root.findObject(stripPackage(rawEntity));
|
|
287
|
+
if (!currentObj)
|
|
288
|
+
return undefined;
|
|
289
|
+
let terminal = currentObj.name;
|
|
290
|
+
for (const relName of segments.slice(1)) {
|
|
291
|
+
const resolved = resolveHop(currentObj, relName);
|
|
292
|
+
if (!resolved)
|
|
293
|
+
return undefined;
|
|
294
|
+
const target = root.findObject(stripPackage(resolved.targetName));
|
|
295
|
+
if (!target)
|
|
296
|
+
return undefined;
|
|
297
|
+
currentObj = target;
|
|
298
|
+
terminal = target.name;
|
|
299
|
+
}
|
|
300
|
+
return terminal;
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Resolve `@orderBy` keys ('field[:asc|desc]') against `entity`'s effective fields into
|
|
304
|
+
* physical {column, dir} pairs (naming strategy applied). Default direction is asc; nulls
|
|
305
|
+
* placement is pinned (nulls-last) at emit, not spelled here. Unknown keys are skipped
|
|
306
|
+
* (the loader validates key existence — `_validateOrderByKeys`).
|
|
307
|
+
*/
|
|
308
|
+
function resolveOrderByKeys(orderBy, entity, ctx) {
|
|
309
|
+
if (!Array.isArray(orderBy))
|
|
310
|
+
return [];
|
|
311
|
+
const keys = [];
|
|
312
|
+
for (const raw of orderBy) {
|
|
313
|
+
if (typeof raw !== "string")
|
|
314
|
+
continue;
|
|
315
|
+
const colonIdx = raw.indexOf(":");
|
|
316
|
+
const name = colonIdx === -1 ? raw : raw.slice(0, colonIdx);
|
|
317
|
+
const dir = colonIdx === -1 ? undefined : raw.slice(colonIdx + 1);
|
|
318
|
+
const field = entity.fields().find((f) => f.name === name);
|
|
319
|
+
if (!field)
|
|
320
|
+
continue;
|
|
321
|
+
keys.push({ column: sourceColumnNameFor(field, ctx), dir: dir === SORT_ORDER_DESC ? "desc" : "asc" });
|
|
322
|
+
}
|
|
323
|
+
return keys;
|
|
324
|
+
}
|
|
325
|
+
function isPlainObject(v) {
|
|
326
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Resolve a raw `attr.expression` node (from origin.computed `@expr`) into a {@link
|
|
330
|
+
* ViewExprNode} whose `{field}` refs are lowered to the base alias's physical columns
|
|
331
|
+
* (naming strategy applied). The emitter then walks the resolved tree. Returns undefined
|
|
332
|
+
* if any node is structurally malformed or references a non-base field — the loader's
|
|
333
|
+
* origin.computed validation already rejects those, so this is defensive only.
|
|
334
|
+
*/
|
|
335
|
+
function resolveExprNode(raw, base, baseAlias, ctx) {
|
|
336
|
+
if (!isPlainObject(raw))
|
|
337
|
+
return undefined;
|
|
338
|
+
if ("field" in raw) {
|
|
339
|
+
const name = raw.field;
|
|
340
|
+
if (typeof name !== "string")
|
|
341
|
+
return undefined;
|
|
342
|
+
const field = base.fields().find((f) => f.name === name);
|
|
343
|
+
if (!field)
|
|
344
|
+
return undefined;
|
|
345
|
+
return { kind: "col", ref: `${baseAlias}.${sourceColumnNameFor(field, ctx)}` };
|
|
346
|
+
}
|
|
347
|
+
if ("value" in raw) {
|
|
348
|
+
return { kind: "lit", value: raw.value };
|
|
349
|
+
}
|
|
350
|
+
if ("fn" in raw) {
|
|
351
|
+
if (raw.fn !== EXPR_FN_COALESCE || !Array.isArray(raw.args))
|
|
352
|
+
return undefined;
|
|
353
|
+
const args = raw.args.map((a) => resolveExprNode(a, base, baseAlias, ctx));
|
|
354
|
+
if (args.some((a) => a === undefined))
|
|
355
|
+
return undefined;
|
|
356
|
+
return { kind: "coalesce", args: args };
|
|
357
|
+
}
|
|
358
|
+
if ("op" in raw && typeof raw.op === "string") {
|
|
359
|
+
const op = raw.op;
|
|
360
|
+
if (EXPR_COMPARISON_OPS.has(op)) {
|
|
361
|
+
const left = resolveExprNode(raw.left, base, baseAlias, ctx);
|
|
362
|
+
const right = resolveExprNode(raw.right, base, baseAlias, ctx);
|
|
363
|
+
return left && right ? { kind: "cmp", op, left, right } : undefined;
|
|
364
|
+
}
|
|
365
|
+
if (op === FILTER_OP_IS_NULL || op === EXPR_OP_IS_NOT_NULL) {
|
|
366
|
+
const arg = resolveExprNode(raw.arg, base, baseAlias, ctx);
|
|
367
|
+
return arg ? { kind: "nullTest", negated: op === EXPR_OP_IS_NOT_NULL, arg } : undefined;
|
|
368
|
+
}
|
|
369
|
+
if (op === EXPR_OP_NOT) {
|
|
370
|
+
const arg = resolveExprNode(raw.arg, base, baseAlias, ctx);
|
|
371
|
+
return arg ? { kind: "not", arg } : undefined;
|
|
372
|
+
}
|
|
373
|
+
if (op === FILTER_COMPOSE_AND || op === FILTER_COMPOSE_OR) {
|
|
374
|
+
if (!Array.isArray(raw.args))
|
|
375
|
+
return undefined;
|
|
376
|
+
const args = raw.args.map((a) => resolveExprNode(a, base, baseAlias, ctx));
|
|
377
|
+
if (args.some((a) => a === undefined))
|
|
378
|
+
return undefined;
|
|
379
|
+
return { kind: "logic", op: op === FILTER_COMPOSE_AND ? "and" : "or", args: args };
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
return undefined;
|
|
383
|
+
}
|
|
192
384
|
function shortAliasFor(entityName, used) {
|
|
193
385
|
const base = (entityName[0] ?? "x").toLowerCase();
|
|
194
386
|
if (!used.has(base)) {
|
|
@@ -217,9 +409,21 @@ function buildJoinTree(projection, base, root, usedAliases, baseAlias, ctx) {
|
|
|
217
409
|
if (origin.type !== TYPE_ORIGIN)
|
|
218
410
|
continue;
|
|
219
411
|
// ADR-0039: own (category 4) — origin.* never inherits (ADR-0029).
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
412
|
+
// Only aggregate (count/sum/avg/min/max/any/all/collect) and passthrough origins
|
|
413
|
+
// contribute a LEFT-JOIN branch. origin.computed is row-level (no @via); origin.first
|
|
414
|
+
// (#195) lowers to a CORRELATED subquery, NOT a join — its @via must NOT enter the
|
|
415
|
+
// join tree (both @from-via and @first-via share the physical name "via", so an
|
|
416
|
+
// unguarded read would wrongly join a `first`).
|
|
417
|
+
let viaAttr;
|
|
418
|
+
if (origin.subType === ORIGIN_SUBTYPE_AGGREGATE) {
|
|
419
|
+
viaAttr = origin.ownAttr(ORIGIN_AGGREGATE_ATTR_VIA);
|
|
420
|
+
}
|
|
421
|
+
else if (origin.subType === ORIGIN_SUBTYPE_PASSTHROUGH) {
|
|
422
|
+
viaAttr = origin.ownAttr(ORIGIN_PASSTHROUGH_ATTR_VIA);
|
|
423
|
+
}
|
|
424
|
+
else {
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
223
427
|
if (!viaAttr)
|
|
224
428
|
continue;
|
|
225
429
|
const segments = viaAttr.split(".");
|
|
@@ -261,6 +465,20 @@ function buildJoinTree(projection, base, root, usedAliases, baseAlias, ctx) {
|
|
|
261
465
|
// physical columns now so the ON clause is naming-strategy correct.
|
|
262
466
|
const fkHolder = referenceHolder === "source" ? currentObj : target;
|
|
263
467
|
const pkHolder = referenceHolder === "source" ? target : currentObj;
|
|
468
|
+
// #209 — a belongs-to hop (FK on the parent) whose FK is NOT NULL is
|
|
469
|
+
// semantically INNER: every base row has a match, so INNER and LEFT OUTER
|
|
470
|
+
// return the same set, and INNER matches the hand-written view it stands in
|
|
471
|
+
// for (and keeps `verify --db` fingerprints aligned). A nullable belongs-to
|
|
472
|
+
// FK, or ANY has-many hop (FK on the child — a base row may have zero
|
|
473
|
+
// children), stays LEFT OUTER so no base row is dropped.
|
|
474
|
+
const fkFieldObj = fkHolder.findField(fkField);
|
|
475
|
+
const selfInner = referenceHolder === "source" && fkFieldObj !== undefined && isRequired(fkFieldObj);
|
|
476
|
+
// Nested-chain safety: joins render flat + left-associative, so an INNER hop
|
|
477
|
+
// BELOW any LEFT ancestor drops the base row (its ON references a column the
|
|
478
|
+
// LEFT ancestor NULLed). An INNER only survives when the ENTIRE ancestor chain
|
|
479
|
+
// is INNER; otherwise demote to LEFT (lossless — under a LEFT ancestor, LEFT is
|
|
480
|
+
// the correct type). `path` holds this chain's ancestor hops accumulated so far.
|
|
481
|
+
const joinType = selfInner && path.every((prior) => prior.joinType === "inner") ? "inner" : "left";
|
|
264
482
|
path.push({
|
|
265
483
|
entity: currentObj,
|
|
266
484
|
relationship: relName,
|
|
@@ -268,6 +486,7 @@ function buildJoinTree(projection, base, root, usedAliases, baseAlias, ctx) {
|
|
|
268
486
|
fkColumn: joinColumnFor(fkHolder, fkField, ctx),
|
|
269
487
|
pkColumn: joinColumnFor(pkHolder, resolvedPkField, ctx),
|
|
270
488
|
referenceHolder,
|
|
489
|
+
joinType,
|
|
271
490
|
targetEntity: stripPackage(targetName),
|
|
272
491
|
});
|
|
273
492
|
currentObj = target;
|
|
@@ -299,6 +518,7 @@ function buildJoinTree(projection, base, root, usedAliases, baseAlias, ctx) {
|
|
|
299
518
|
fkColumn: step.fkColumn,
|
|
300
519
|
pkColumn: step.pkColumn,
|
|
301
520
|
referenceHolder: step.referenceHolder,
|
|
521
|
+
joinType: step.joinType,
|
|
302
522
|
children: Array.from(node.children.values()).map(toJoinNode),
|
|
303
523
|
};
|
|
304
524
|
}
|
|
@@ -326,20 +546,23 @@ function findAliasInTree(joinTree, entityName) {
|
|
|
326
546
|
}
|
|
327
547
|
return recurse(joinTree.joins);
|
|
328
548
|
}
|
|
329
|
-
function buildSelectSpec(projection, base, joinTree, root, ctx) {
|
|
549
|
+
function buildSelectSpec(projection, base, joinTree, root, ctx, usedAliases) {
|
|
330
550
|
const columns = [];
|
|
331
551
|
// FR-024 (ADR-0028): the projection's DECLARED field set IS the exposure —
|
|
332
552
|
// the inclusive list, fail-closed by construction. The pre-FR-024 loop that
|
|
333
553
|
// emitted every base-entity field as an implicit passthrough (the firehose)
|
|
334
554
|
// is removed with the B4b cutover: base columns are declared explicitly as
|
|
335
555
|
// extends-bound fields (`{ field.int: { name: id, extends: "Program.id" } }`).
|
|
336
|
-
//
|
|
337
|
-
//
|
|
338
|
-
//
|
|
339
|
-
//
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
556
|
+
// #213 — an entity read-view HOST (base === projection, FR-024 §7) exposes its
|
|
557
|
+
// EFFECTIVE field set: the `o.*` includes fields inherited via extends (a
|
|
558
|
+
// BaseEntity id/createdAt). A plain projection exposes only its DECLARED (own)
|
|
559
|
+
// fields — the declared set IS the exposure (FR-024/ADR-0028). Either way, each
|
|
560
|
+
// field's own origin decides passthrough-from-base vs derived-from-join. origin.*
|
|
561
|
+
// NEVER inherits (ADR-0029), so the origin reads below are own (category 4).
|
|
562
|
+
const declaredFields = base === projection
|
|
563
|
+
? base.fields()
|
|
564
|
+
: projection.ownChildren().filter((c) => c.type === TYPE_FIELD);
|
|
565
|
+
for (const field of declaredFields) {
|
|
343
566
|
const origin = field.ownChildren().find((c) => c.type === TYPE_ORIGIN);
|
|
344
567
|
const dbCol = sourceColumnNameFor(field, ctx);
|
|
345
568
|
if (!origin) {
|
|
@@ -384,8 +607,44 @@ function buildSelectSpec(projection, base, joinTree, root, ctx) {
|
|
|
384
607
|
else if (origin.subType === ORIGIN_SUBTYPE_AGGREGATE) {
|
|
385
608
|
// ADR-0039: own (category 4) — origin.* never inherits (ADR-0029).
|
|
386
609
|
const agg = origin.ownAttr(ORIGIN_AGGREGATE_ATTR_AGG);
|
|
610
|
+
if (!agg)
|
|
611
|
+
continue;
|
|
612
|
+
const filterAttr = origin.ownAttr(ORIGIN_AGGREGATE_ATTR_FILTER);
|
|
613
|
+
if (agg === AGG_ANY || agg === AGG_ALL) {
|
|
614
|
+
// #195 predicate quantifier — no @of; the related entity is @via's terminal
|
|
615
|
+
// hop, and @filter is the (required) quantified predicate.
|
|
616
|
+
const via = origin.ownAttr(ORIGIN_AGGREGATE_ATTR_VIA);
|
|
617
|
+
if (!via)
|
|
618
|
+
continue;
|
|
619
|
+
const relatedName = viaTerminalEntity(via, root);
|
|
620
|
+
if (!relatedName)
|
|
621
|
+
continue;
|
|
622
|
+
const relatedEntity = root.findObject(relatedName);
|
|
623
|
+
const sourceAlias = findAliasInTree(joinTree, relatedName);
|
|
624
|
+
if (!relatedEntity || sourceAlias === undefined)
|
|
625
|
+
continue;
|
|
626
|
+
const joinedPk = primaryKeyColumn(relatedEntity, ctx);
|
|
627
|
+
if (joinedPk === undefined)
|
|
628
|
+
continue;
|
|
629
|
+
const pred = filterAttr !== undefined
|
|
630
|
+
? resolveAggregateFilter(filterAttr, relatedEntity, sourceAlias, ctx)
|
|
631
|
+
: undefined;
|
|
632
|
+
if (pred === undefined)
|
|
633
|
+
continue; // loader requires @filter on any/all
|
|
634
|
+
columns.push({
|
|
635
|
+
kind: "predicateAgg",
|
|
636
|
+
fieldName: field.name,
|
|
637
|
+
dbColAlias: dbCol,
|
|
638
|
+
quant: agg === AGG_ANY ? "any" : "all",
|
|
639
|
+
sourceAlias,
|
|
640
|
+
joinedPkColumn: joinedPk,
|
|
641
|
+
pred,
|
|
642
|
+
});
|
|
643
|
+
continue;
|
|
644
|
+
}
|
|
645
|
+
// collect + the scalar reduces (count/sum/avg/min/max) all name @of.
|
|
387
646
|
const of_ = origin.ownAttr(ORIGIN_AGGREGATE_ATTR_OF);
|
|
388
|
-
if (!
|
|
647
|
+
if (!of_)
|
|
389
648
|
continue;
|
|
390
649
|
const dotIdx = of_.indexOf(".");
|
|
391
650
|
if (dotIdx < 1)
|
|
@@ -399,28 +658,131 @@ function buildSelectSpec(projection, base, joinTree, root, ctx) {
|
|
|
399
658
|
const targetField = targetEntity.fields().find((f) => f.name === fieldName);
|
|
400
659
|
if (!targetField)
|
|
401
660
|
continue;
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
661
|
+
if (agg === AGG_COLLECT) {
|
|
662
|
+
// #195 array rollup — collect @of across the related set. @distinct = set
|
|
663
|
+
// semantics; @orderBy (non-distinct) sets element order, else value-ascending.
|
|
664
|
+
const joinedPk = primaryKeyColumn(targetEntity, ctx);
|
|
665
|
+
if (joinedPk === undefined)
|
|
666
|
+
continue;
|
|
667
|
+
const distinct = origin.ownAttr(ORIGIN_ATTR_DISTINCT) === true;
|
|
668
|
+
const orderBy = resolveOrderByKeys(origin.ownAttr(ORIGIN_ATTR_ORDER_BY), targetEntity, ctx);
|
|
669
|
+
columns.push({
|
|
670
|
+
kind: "collectAgg",
|
|
671
|
+
fieldName: field.name,
|
|
672
|
+
dbColAlias: dbCol,
|
|
673
|
+
sourceAlias,
|
|
674
|
+
sourceColumn: sourceColumnNameFor(targetField, ctx),
|
|
675
|
+
joinedPkColumn: joinedPk,
|
|
676
|
+
distinct,
|
|
677
|
+
orderBy,
|
|
678
|
+
});
|
|
679
|
+
continue;
|
|
680
|
+
}
|
|
681
|
+
if (SCALAR_AGG_FUNCTIONS.has(agg)) {
|
|
682
|
+
// Optional scoping filter — resolved against the aggregated entity (the @of
|
|
683
|
+
// entity, reached at `sourceAlias`), e.g. max(version) over only active rows.
|
|
684
|
+
const filter = filterAttr !== undefined
|
|
685
|
+
? resolveAggregateFilter(filterAttr, targetEntity, sourceAlias, ctx)
|
|
686
|
+
: undefined;
|
|
687
|
+
columns.push({
|
|
688
|
+
kind: "aggregate",
|
|
689
|
+
fieldName: field.name,
|
|
690
|
+
dbColAlias: dbCol,
|
|
691
|
+
agg: agg,
|
|
692
|
+
sourceAlias,
|
|
693
|
+
sourceColumn: sourceColumnNameFor(targetField, ctx),
|
|
694
|
+
...(filter !== undefined ? { filter } : {}),
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
else if (origin.subType === ORIGIN_SUBTYPE_COMPUTED) {
|
|
699
|
+
// #195 row-level computed value from the base entity's own fields (@expr tree,
|
|
700
|
+
// no @via). ADR-0039: own — origin.* never inherits (ADR-0029).
|
|
701
|
+
const rawExpr = origin.ownAttr(ORIGIN_COMPUTED_ATTR_EXPR);
|
|
702
|
+
const expr = resolveExprNode(rawExpr, base, joinTree.baseAlias, ctx);
|
|
703
|
+
if (expr === undefined)
|
|
704
|
+
continue;
|
|
705
|
+
columns.push({ kind: "computed", fieldName: field.name, dbColAlias: dbCol, expr });
|
|
706
|
+
}
|
|
707
|
+
else if (origin.subType === ORIGIN_SUBTYPE_FIRST) {
|
|
708
|
+
// #195 correlated latest-row — argmax-then-project. Resolves to a correlated
|
|
709
|
+
// subquery (NOT a join). ADR-0039: own — origin.* never inherits (ADR-0029).
|
|
710
|
+
const of_ = origin.ownAttr(ORIGIN_FIRST_ATTR_OF);
|
|
711
|
+
if (!of_)
|
|
712
|
+
continue;
|
|
713
|
+
const dotIdx = of_.indexOf(".");
|
|
714
|
+
if (dotIdx < 1)
|
|
715
|
+
continue;
|
|
716
|
+
const childName = stripPackage(of_.slice(0, dotIdx));
|
|
717
|
+
const ofFieldName = of_.slice(dotIdx + 1);
|
|
718
|
+
const childEntity = root.findObject(childName);
|
|
719
|
+
if (!childEntity)
|
|
720
|
+
continue;
|
|
721
|
+
const ofField = childEntity.fields().find((f) => f.name === ofFieldName);
|
|
722
|
+
if (!ofField)
|
|
723
|
+
continue;
|
|
724
|
+
// The base↔child correlation FK — resolved exactly as buildJoinTree resolves a
|
|
725
|
+
// single hop (the identity.reference is the FK-direction SSOT). Single-hop @via;
|
|
726
|
+
// a multi-hop @via on origin.first is not lowered here (rare, and validated away).
|
|
727
|
+
const ref = findReferenceBetween(base, childEntity);
|
|
728
|
+
if (!ref)
|
|
729
|
+
continue;
|
|
730
|
+
const fkField = ref.referenceIdentity.fields[0];
|
|
731
|
+
if (!fkField)
|
|
732
|
+
continue;
|
|
733
|
+
const pkField = ref.referenceIdentity.resolvedTargetPkField(root) ?? "id";
|
|
734
|
+
const referenceHolder = ref.holder.name === base.name ? "source" : "target";
|
|
735
|
+
const fkHolder = referenceHolder === "source" ? base : childEntity;
|
|
736
|
+
const pkHolder = referenceHolder === "source" ? childEntity : base;
|
|
737
|
+
const childPk = primaryKeyColumn(childEntity, ctx);
|
|
738
|
+
if (childPk === undefined)
|
|
739
|
+
continue;
|
|
740
|
+
// A FRESH alias — the subquery is an independent correlated scope, never the
|
|
741
|
+
// JOIN-tree alias (reserved against usedAliases so it can never collide).
|
|
742
|
+
const childAlias = shortAliasFor(childEntity.name, usedAliases);
|
|
743
|
+
const orderBy = resolveOrderByKeys(origin.ownAttr(ORIGIN_ATTR_ORDER_BY), childEntity, ctx);
|
|
744
|
+
const filterAttr = origin.ownAttr(ORIGIN_FIRST_ATTR_FILTER);
|
|
406
745
|
const filter = filterAttr !== undefined
|
|
407
|
-
? resolveAggregateFilter(filterAttr,
|
|
746
|
+
? resolveAggregateFilter(filterAttr, childEntity, childAlias, ctx)
|
|
408
747
|
: undefined;
|
|
409
748
|
columns.push({
|
|
410
|
-
kind: "
|
|
749
|
+
kind: "first",
|
|
411
750
|
fieldName: field.name,
|
|
412
751
|
dbColAlias: dbCol,
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
sourceColumn: sourceColumnNameFor(
|
|
752
|
+
childEntity: childEntity.name,
|
|
753
|
+
childAlias,
|
|
754
|
+
sourceColumn: sourceColumnNameFor(ofField, ctx),
|
|
755
|
+
referenceHolder,
|
|
756
|
+
fkColumn: joinColumnFor(fkHolder, fkField, ctx),
|
|
757
|
+
pkColumn: joinColumnFor(pkHolder, pkField, ctx),
|
|
758
|
+
childPkColumn: childPk,
|
|
759
|
+
orderBy,
|
|
416
760
|
...(filter !== undefined ? { filter } : {}),
|
|
417
761
|
});
|
|
418
762
|
}
|
|
419
763
|
}
|
|
420
764
|
return { columns };
|
|
421
765
|
}
|
|
766
|
+
/** The inflation-sensitive aggregate kinds: a non-distinct row multiplication corrupts
|
|
767
|
+
* their value (sum/avg double-count; a non-distinct collect duplicates elements).
|
|
768
|
+
* count is DISTINCT-guarded; min/max and any/all are inflation-immune. */
|
|
769
|
+
function isInflationSensitive(c) {
|
|
770
|
+
if (c.kind === "aggregate")
|
|
771
|
+
return c.agg === "sum" || c.agg === "avg";
|
|
772
|
+
if (c.kind === "collectAgg")
|
|
773
|
+
return !c.distinct;
|
|
774
|
+
return false;
|
|
775
|
+
}
|
|
776
|
+
/** Count top-level join branches that traverse at least one to-many hop. Two or more
|
|
777
|
+
* independent many-branches multiply (cartesian) and inflate sensitive aggregates. */
|
|
778
|
+
function countManyBranches(joinTree) {
|
|
779
|
+
const hasMany = (node) => node.cardinality === "many" || node.children.some(hasMany);
|
|
780
|
+
return joinTree.joins.filter(hasMany).length;
|
|
781
|
+
}
|
|
422
782
|
function buildGroupBy(spec) {
|
|
423
|
-
|
|
783
|
+
// predicateAgg (bool_or/bool_and) and collectAgg (array_agg) are real aggregates and
|
|
784
|
+
// force GROUP BY too; computed/first are scalar-per-row and never grouped.
|
|
785
|
+
const hasAgg = spec.columns.some((c) => c.kind === "aggregate" || c.kind === "predicateAgg" || c.kind === "collectAgg");
|
|
424
786
|
if (!hasAgg)
|
|
425
787
|
return [];
|
|
426
788
|
return spec.columns
|
|
@@ -441,17 +803,56 @@ function buildGroupBy(spec) {
|
|
|
441
803
|
* @param ctx Column naming strategy for SQL identifiers.
|
|
442
804
|
*/
|
|
443
805
|
export function extractViewSpec(projection, root, ctx) {
|
|
444
|
-
|
|
806
|
+
// #213 — a write-through entity (FR-024 §7) IS its own base: stored fields SELECT
|
|
807
|
+
// from the base alias (o.*), derived (origin.*) fields from the joins. A plain
|
|
808
|
+
// projection anchors its base via an extends binding (baseEntityFor).
|
|
809
|
+
const writeThrough = isWriteThrough(projection);
|
|
810
|
+
const base = writeThrough ? projection : baseEntityFor(projection, root);
|
|
445
811
|
const usedAliases = new Set();
|
|
446
812
|
const baseAlias = shortAliasFor(base.name, usedAliases);
|
|
447
813
|
const joinTree = buildJoinTree(projection, base, root, usedAliases, baseAlias, ctx);
|
|
448
|
-
const selectSpec = buildSelectSpec(projection, base, joinTree, root, ctx);
|
|
814
|
+
const selectSpec = buildSelectSpec(projection, base, joinTree, root, ctx, usedAliases);
|
|
449
815
|
const groupBy = buildGroupBy(selectSpec);
|
|
816
|
+
const view = viewName(projection, ctx);
|
|
817
|
+
warnOnJoinInflation(projection, view, joinTree, selectSpec);
|
|
818
|
+
// #207 — a projection's OWN row-scope @filter lowers to the view's outer WHERE.
|
|
819
|
+
// Gated to projections only (v1 scope): a write-through entity read-view is
|
|
820
|
+
// NEVER filtered — a filtered replica breaks read-your-writes totality, and the
|
|
821
|
+
// @filter attr is registered on object.projection (not object.entity), so a
|
|
822
|
+
// write-through entity cannot carry one anyway. The stored value is already the
|
|
823
|
+
// desugared canonical { field: { op: value } } form (FilterAttr.desugar at parse).
|
|
824
|
+
let where;
|
|
825
|
+
if (!writeThrough) {
|
|
826
|
+
const rawFilter = projection.ownAttr(OBJECT_PROJECTION_ATTR_FILTER);
|
|
827
|
+
if (rawFilter !== undefined) {
|
|
828
|
+
const columnsByField = new Map(selectSpec.columns.map((c) => [c.fieldName, c]));
|
|
829
|
+
where = resolveViewFilter(rawFilter, columnsByField, projection.name);
|
|
830
|
+
}
|
|
831
|
+
}
|
|
450
832
|
return {
|
|
451
|
-
viewName:
|
|
833
|
+
viewName: view,
|
|
452
834
|
joinTree,
|
|
453
835
|
selectSpec,
|
|
454
836
|
groupBy,
|
|
837
|
+
...(where !== undefined ? { where } : {}),
|
|
455
838
|
};
|
|
456
839
|
}
|
|
840
|
+
/**
|
|
841
|
+
* #195 (spec §6, risk 2) — emit a load-time WARN when an inflation-sensitive aggregate
|
|
842
|
+
* (`sum`/`avg`/non-distinct `collect`) coexists with ≥2 independent to-many join
|
|
843
|
+
* branches in one view. Two many-branches multiply (cartesian), silently double-counting
|
|
844
|
+
* — a latent bug for `sum`/`avg` today, now surfaced. count is DISTINCT-guarded and
|
|
845
|
+
* min/max/any/all are inflation-immune, so they never trip it.
|
|
846
|
+
*/
|
|
847
|
+
function warnOnJoinInflation(projection, viewName, joinTree, spec) {
|
|
848
|
+
if (countManyBranches(joinTree) < 2)
|
|
849
|
+
return;
|
|
850
|
+
const sensitive = spec.columns.filter(isInflationSensitive).map((c) => c.fieldName);
|
|
851
|
+
if (sensitive.length === 0)
|
|
852
|
+
return;
|
|
853
|
+
console.warn(`[codegen-ts] projection "${projection.name}" (view ${viewName}): inflation-sensitive ` +
|
|
854
|
+
`aggregate field(s) [${sensitive.join(", ")}] coexist with ${countManyBranches(joinTree)} ` +
|
|
855
|
+
`independent to-many join branches — the joins multiply, so these values may double-count. ` +
|
|
856
|
+
`Split them into separate projections, or scope with @filter/@distinct.`);
|
|
857
|
+
}
|
|
457
858
|
//# sourceMappingURL=extract-view-spec.js.map
|