@metaobjectsdev/codegen-ts 0.16.0 → 0.17.0
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
|
@@ -6,35 +6,81 @@ import {
|
|
|
6
6
|
MetaSource,
|
|
7
7
|
ORIGIN_SUBTYPE_PASSTHROUGH,
|
|
8
8
|
ORIGIN_SUBTYPE_AGGREGATE,
|
|
9
|
+
ORIGIN_SUBTYPE_COMPUTED,
|
|
10
|
+
ORIGIN_SUBTYPE_FIRST,
|
|
9
11
|
ORIGIN_PASSTHROUGH_ATTR_FROM,
|
|
10
12
|
ORIGIN_PASSTHROUGH_ATTR_VIA,
|
|
11
13
|
ORIGIN_AGGREGATE_ATTR_AGG,
|
|
12
14
|
ORIGIN_AGGREGATE_ATTR_OF,
|
|
13
15
|
ORIGIN_AGGREGATE_ATTR_VIA,
|
|
14
16
|
ORIGIN_AGGREGATE_ATTR_FILTER,
|
|
17
|
+
ORIGIN_ATTR_DISTINCT,
|
|
18
|
+
ORIGIN_ATTR_ORDER_BY,
|
|
19
|
+
ORIGIN_COMPUTED_ATTR_EXPR,
|
|
20
|
+
ORIGIN_FIRST_ATTR_OF,
|
|
21
|
+
ORIGIN_FIRST_ATTR_VIA,
|
|
22
|
+
ORIGIN_FIRST_ATTR_FILTER,
|
|
23
|
+
AGG_ANY,
|
|
24
|
+
AGG_ALL,
|
|
25
|
+
AGG_COLLECT,
|
|
26
|
+
AGGREGATE_FUNCTIONS,
|
|
27
|
+
FILTER_OP_EQ,
|
|
28
|
+
FILTER_OP_NE,
|
|
29
|
+
FILTER_OP_GT,
|
|
30
|
+
FILTER_OP_GTE,
|
|
31
|
+
FILTER_OP_LT,
|
|
32
|
+
FILTER_OP_LTE,
|
|
33
|
+
FILTER_OP_IS_NULL,
|
|
34
|
+
FILTER_COMPOSE_AND,
|
|
35
|
+
FILTER_COMPOSE_OR,
|
|
36
|
+
SORT_ORDER_DESC,
|
|
15
37
|
RELATIONSHIP_ATTR_OBJECT_REF,
|
|
16
38
|
RELATIONSHIP_ATTR_CARDINALITY,
|
|
17
39
|
CARDINALITY_ONE,
|
|
18
40
|
IDENTITY_SUBTYPE_REFERENCE,
|
|
19
41
|
IDENTITY_REFERENCE_ATTR_REFERENCES,
|
|
20
42
|
FIELD_ATTR_COLUMN,
|
|
43
|
+
OBJECT_PROJECTION_ATTR_FILTER,
|
|
21
44
|
findReferenceBetween,
|
|
22
45
|
stripPackage,
|
|
23
46
|
type AggregateFunction,
|
|
24
47
|
} from "@metaobjectsdev/metadata";
|
|
25
|
-
import { type MetaData, type MetaRoot, MetaObject } from "@metaobjectsdev/metadata";
|
|
48
|
+
import { type MetaData, type MetaField, type MetaRoot, MetaObject } from "@metaobjectsdev/metadata";
|
|
26
49
|
import {
|
|
27
50
|
columnNameFromField,
|
|
28
51
|
viewNameFromProjection,
|
|
29
52
|
} from "../naming.js";
|
|
53
|
+
// #213 — a write-through ENTITY (FR-024 §7 read-view) hosts its own view; the base
|
|
54
|
+
// is the entity itself, not an extends-anchored projection.
|
|
55
|
+
import { isWriteThrough } from "./projection-detector.js";
|
|
56
|
+
// #209 — the SAME NOT-NULL predicate that drives a column's `.notNull()` decides
|
|
57
|
+
// whether a belongs-to join is INNER (required FK) vs LEFT OUTER (nullable FK).
|
|
58
|
+
import { isRequired } from "../column-mapper.js";
|
|
30
59
|
import type { ColumnNamingStrategy } from "../metaobjects-config.js";
|
|
31
60
|
import type {
|
|
32
61
|
JoinNode, JoinTree, SelectColumn, SelectSpec, ViewSpec, ViewFilterClause,
|
|
62
|
+
ViewExprNode, ViewExprLiteral, ViewOrderKey,
|
|
33
63
|
} from "./view-spec.js";
|
|
34
64
|
|
|
35
65
|
/** Compose keys in the attr.filter shape. */
|
|
36
|
-
const FILTER_AND =
|
|
37
|
-
const FILTER_OR =
|
|
66
|
+
const FILTER_AND = FILTER_COMPOSE_AND;
|
|
67
|
+
const FILTER_OR = FILTER_COMPOSE_OR;
|
|
68
|
+
|
|
69
|
+
// #195 — the three expression-only op/fn names of the attr.expression grammar. They
|
|
70
|
+
// mirror EXPR_OP_IS_NOT_NULL / EXPR_OP_NOT / EXPR_FN_COALESCE in the metadata package's
|
|
71
|
+
// meta-attr-expression module, which is not re-exported through the public barrel; the
|
|
72
|
+
// remaining node ops (eq/ne/gt/… , isNull, and/or) are the shared FILTER_* vocabulary
|
|
73
|
+
// imported above.
|
|
74
|
+
const EXPR_OP_IS_NOT_NULL = "isNotNull";
|
|
75
|
+
const EXPR_OP_NOT = "not";
|
|
76
|
+
const EXPR_FN_COALESCE = "coalesce";
|
|
77
|
+
|
|
78
|
+
/** The scalar-reduce @agg values that map to the plain `aggregate` SelectColumn kind. */
|
|
79
|
+
const SCALAR_AGG_FUNCTIONS: ReadonlySet<string> = new Set(AGGREGATE_FUNCTIONS);
|
|
80
|
+
/** Expression comparison ops (share the filter vocabulary + per-subtype legality bands). */
|
|
81
|
+
const EXPR_COMPARISON_OPS: ReadonlySet<string> = new Set([
|
|
82
|
+
FILTER_OP_EQ, FILTER_OP_NE, FILTER_OP_GT, FILTER_OP_GTE, FILTER_OP_LT, FILTER_OP_LTE,
|
|
83
|
+
]);
|
|
38
84
|
|
|
39
85
|
/**
|
|
40
86
|
* Desugar a single field clause to the canonical `{ op: value }` form (scalar→eq,
|
|
@@ -86,6 +132,67 @@ function resolveAggregateFilter(
|
|
|
86
132
|
return clauses.length === 1 ? clauses[0]! : { kind: "and", clauses };
|
|
87
133
|
}
|
|
88
134
|
|
|
135
|
+
/**
|
|
136
|
+
* #207 — resolve a projection's row-scope `@filter` (the desugared canonical
|
|
137
|
+
* `{ field: { op: value }, and?, or? }`) into a {@link ViewFilterClause} whose
|
|
138
|
+
* refs are resolved against the projection's OWN declared columns (its SelectSpec),
|
|
139
|
+
* NOT against a single aggregated entity+alias (that is `resolveAggregateFilter`).
|
|
140
|
+
* Each field key names a declared projection field; it resolves by SelectColumn kind:
|
|
141
|
+
* - passthrough (base OR joined) → `sourceAlias.sourceColumn` — the machinery that
|
|
142
|
+
* makes `WHERE joined.status IS NULL OR joined.status = 1` work.
|
|
143
|
+
* - computed (origin.computed) → the inlined resolved expression (`exprCmp`).
|
|
144
|
+
* - aggregate-derived (aggregate/predicateAgg/collectAgg/first) → THROW (a WHERE
|
|
145
|
+
* cannot see aggregates; HAVING is a separate later extension). The loader already
|
|
146
|
+
* fail-closes this (ERR_BAD_ATTR_FILTER) — this throw is a codegen belt-and-suspenders.
|
|
147
|
+
* - a ref naming no declared field → THROW (dangling; also loader-rejected).
|
|
148
|
+
* Multiple fields at one level compose with AND (mirrors resolveAggregateFilter).
|
|
149
|
+
*/
|
|
150
|
+
function resolveViewFilter(
|
|
151
|
+
filter: unknown,
|
|
152
|
+
columnsByField: ReadonlyMap<string, SelectColumn>,
|
|
153
|
+
projectionName: string,
|
|
154
|
+
): ViewFilterClause | undefined {
|
|
155
|
+
if (typeof filter !== "object" || filter === null || Array.isArray(filter)) return undefined;
|
|
156
|
+
const clauses: ViewFilterClause[] = [];
|
|
157
|
+
for (const [key, val] of Object.entries(filter as Record<string, unknown>)) {
|
|
158
|
+
if (key === FILTER_AND || key === FILTER_OR) {
|
|
159
|
+
const subs = (Array.isArray(val) ? val : [])
|
|
160
|
+
.map((s) => resolveViewFilter(s, columnsByField, projectionName))
|
|
161
|
+
.filter((c): c is ViewFilterClause => c !== undefined);
|
|
162
|
+
if (subs.length > 0) clauses.push({ kind: key === FILTER_AND ? "and" : "or", clauses: subs });
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const col = columnsByField.get(key);
|
|
166
|
+
if (!col) {
|
|
167
|
+
// The loader (validateProjectionFilter) already fail-closes a dangling or
|
|
168
|
+
// aggregate-derived ref, so by codegen time a missing column means a DECLARED,
|
|
169
|
+
// addressable field that buildSelectSpec could not resolve to a SELECT column
|
|
170
|
+
// (an internal inconsistency) — report it as such, not as "dangling".
|
|
171
|
+
throw new Error(
|
|
172
|
+
`Projection ${projectionName}: view @filter field "${key}" did not resolve to a view column.`,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
// A field clause may carry MULTIPLE ops (a range like { gte: 100, lte: 500 }); each
|
|
176
|
+
// becomes its own comparison, AND-composed (dropping all-but-the-first would silently
|
|
177
|
+
// widen the exposed row set). The loader has already validated every op for this
|
|
178
|
+
// field's subtype.
|
|
179
|
+
for (const [op, value] of Object.entries(desugarClause(val))) {
|
|
180
|
+
if (col.kind === "passthrough") {
|
|
181
|
+
clauses.push({ kind: "cmp", ref: `${col.sourceAlias}.${col.sourceColumn}`, op, value });
|
|
182
|
+
} else if (col.kind === "computed") {
|
|
183
|
+
clauses.push({ kind: "exprCmp", expr: col.expr, op, value });
|
|
184
|
+
} else {
|
|
185
|
+
throw new Error(
|
|
186
|
+
`Projection ${projectionName}: view @filter references "${key}", an aggregate-derived ` +
|
|
187
|
+
`field — a WHERE cannot see aggregates. Filter on a passthrough or computed field instead.`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (clauses.length === 0) return undefined;
|
|
193
|
+
return clauses.length === 1 ? clauses[0]! : { kind: "and", clauses };
|
|
194
|
+
}
|
|
195
|
+
|
|
89
196
|
// ---------------------------------------------------------------------------
|
|
90
197
|
// Public context type
|
|
91
198
|
// ---------------------------------------------------------------------------
|
|
@@ -180,7 +287,7 @@ export function projectionViewName(
|
|
|
180
287
|
* loader's `_refNamedOwner`: the ref names the anchor, never the physical
|
|
181
288
|
* declaring ancestor of an inherited child.
|
|
182
289
|
*/
|
|
183
|
-
function refNamedOwner(node: MetaData, root: MetaRoot): MetaObject | undefined {
|
|
290
|
+
export function refNamedOwner(node: MetaData, root: MetaRoot): MetaObject | undefined {
|
|
184
291
|
const ref = (node as { superRef?: string }).superRef;
|
|
185
292
|
if (ref === undefined) return undefined;
|
|
186
293
|
const lastSep = ref.lastIndexOf("::");
|
|
@@ -250,6 +357,122 @@ function joinColumnFor(entity: MetaObject, fieldName: string, ctx: ExtractContex
|
|
|
250
357
|
return f ? sourceColumnNameFor(f, ctx) : columnNameFromField(fieldName, ctx.columnNamingStrategy);
|
|
251
358
|
}
|
|
252
359
|
|
|
360
|
+
/** The physical column of an entity's primary-key field — the LEFT-JOIN phantom guard
|
|
361
|
+
* (#195) tests `<joined>.<pk> IS NOT NULL`, and it is origin.first's tie-breaker. */
|
|
362
|
+
function primaryKeyColumn(entity: MetaObject, ctx: ExtractContext): string | undefined {
|
|
363
|
+
// ADR-0039: resolving — a projection base may inherit its primary identity via extends.
|
|
364
|
+
const pkField = entity.primaryIdentity()?.fields[0];
|
|
365
|
+
return pkField ? joinColumnFor(entity, pkField, ctx) : undefined;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Walk a `@via` dotted path to its terminal (related) entity name. any/all carry no
|
|
370
|
+
* `@of` to name the aggregated entity, so it is derived from the last `@via` hop.
|
|
371
|
+
* Returns undefined if any hop fails to resolve (a prior loader error already fired).
|
|
372
|
+
*/
|
|
373
|
+
function viaTerminalEntity(via: string, root: MetaRoot): string | undefined {
|
|
374
|
+
const segments = via.split(".");
|
|
375
|
+
const rawEntity = segments[0];
|
|
376
|
+
if (!rawEntity) return undefined;
|
|
377
|
+
let currentObj = root.findObject(stripPackage(rawEntity));
|
|
378
|
+
if (!currentObj) return undefined;
|
|
379
|
+
let terminal = currentObj.name;
|
|
380
|
+
for (const relName of segments.slice(1)) {
|
|
381
|
+
const resolved = resolveHop(currentObj, relName);
|
|
382
|
+
if (!resolved) return undefined;
|
|
383
|
+
const target = root.findObject(stripPackage(resolved.targetName));
|
|
384
|
+
if (!target) return undefined;
|
|
385
|
+
currentObj = target;
|
|
386
|
+
terminal = target.name;
|
|
387
|
+
}
|
|
388
|
+
return terminal;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Resolve `@orderBy` keys ('field[:asc|desc]') against `entity`'s effective fields into
|
|
393
|
+
* physical {column, dir} pairs (naming strategy applied). Default direction is asc; nulls
|
|
394
|
+
* placement is pinned (nulls-last) at emit, not spelled here. Unknown keys are skipped
|
|
395
|
+
* (the loader validates key existence — `_validateOrderByKeys`).
|
|
396
|
+
*/
|
|
397
|
+
function resolveOrderByKeys(
|
|
398
|
+
orderBy: unknown,
|
|
399
|
+
entity: MetaObject,
|
|
400
|
+
ctx: ExtractContext,
|
|
401
|
+
): ViewOrderKey[] {
|
|
402
|
+
if (!Array.isArray(orderBy)) return [];
|
|
403
|
+
const keys: ViewOrderKey[] = [];
|
|
404
|
+
for (const raw of orderBy) {
|
|
405
|
+
if (typeof raw !== "string") continue;
|
|
406
|
+
const colonIdx = raw.indexOf(":");
|
|
407
|
+
const name = colonIdx === -1 ? raw : raw.slice(0, colonIdx);
|
|
408
|
+
const dir = colonIdx === -1 ? undefined : raw.slice(colonIdx + 1);
|
|
409
|
+
const field = entity.fields().find((f) => f.name === name);
|
|
410
|
+
if (!field) continue;
|
|
411
|
+
keys.push({ column: sourceColumnNameFor(field, ctx), dir: dir === SORT_ORDER_DESC ? "desc" : "asc" });
|
|
412
|
+
}
|
|
413
|
+
return keys;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
417
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Resolve a raw `attr.expression` node (from origin.computed `@expr`) into a {@link
|
|
422
|
+
* ViewExprNode} whose `{field}` refs are lowered to the base alias's physical columns
|
|
423
|
+
* (naming strategy applied). The emitter then walks the resolved tree. Returns undefined
|
|
424
|
+
* if any node is structurally malformed or references a non-base field — the loader's
|
|
425
|
+
* origin.computed validation already rejects those, so this is defensive only.
|
|
426
|
+
*/
|
|
427
|
+
function resolveExprNode(
|
|
428
|
+
raw: unknown,
|
|
429
|
+
base: MetaObject,
|
|
430
|
+
baseAlias: string,
|
|
431
|
+
ctx: ExtractContext,
|
|
432
|
+
): ViewExprNode | undefined {
|
|
433
|
+
if (!isPlainObject(raw)) return undefined;
|
|
434
|
+
|
|
435
|
+
if ("field" in raw) {
|
|
436
|
+
const name = raw.field;
|
|
437
|
+
if (typeof name !== "string") return undefined;
|
|
438
|
+
const field = base.fields().find((f) => f.name === name);
|
|
439
|
+
if (!field) return undefined;
|
|
440
|
+
return { kind: "col", ref: `${baseAlias}.${sourceColumnNameFor(field, ctx)}` };
|
|
441
|
+
}
|
|
442
|
+
if ("value" in raw) {
|
|
443
|
+
return { kind: "lit", value: raw.value as ViewExprLiteral };
|
|
444
|
+
}
|
|
445
|
+
if ("fn" in raw) {
|
|
446
|
+
if (raw.fn !== EXPR_FN_COALESCE || !Array.isArray(raw.args)) return undefined;
|
|
447
|
+
const args = raw.args.map((a) => resolveExprNode(a, base, baseAlias, ctx));
|
|
448
|
+
if (args.some((a) => a === undefined)) return undefined;
|
|
449
|
+
return { kind: "coalesce", args: args as ViewExprNode[] };
|
|
450
|
+
}
|
|
451
|
+
if ("op" in raw && typeof raw.op === "string") {
|
|
452
|
+
const op = raw.op;
|
|
453
|
+
if (EXPR_COMPARISON_OPS.has(op)) {
|
|
454
|
+
const left = resolveExprNode(raw.left, base, baseAlias, ctx);
|
|
455
|
+
const right = resolveExprNode(raw.right, base, baseAlias, ctx);
|
|
456
|
+
return left && right ? { kind: "cmp", op, left, right } : undefined;
|
|
457
|
+
}
|
|
458
|
+
if (op === FILTER_OP_IS_NULL || op === EXPR_OP_IS_NOT_NULL) {
|
|
459
|
+
const arg = resolveExprNode(raw.arg, base, baseAlias, ctx);
|
|
460
|
+
return arg ? { kind: "nullTest", negated: op === EXPR_OP_IS_NOT_NULL, arg } : undefined;
|
|
461
|
+
}
|
|
462
|
+
if (op === EXPR_OP_NOT) {
|
|
463
|
+
const arg = resolveExprNode(raw.arg, base, baseAlias, ctx);
|
|
464
|
+
return arg ? { kind: "not", arg } : undefined;
|
|
465
|
+
}
|
|
466
|
+
if (op === FILTER_COMPOSE_AND || op === FILTER_COMPOSE_OR) {
|
|
467
|
+
if (!Array.isArray(raw.args)) return undefined;
|
|
468
|
+
const args = raw.args.map((a) => resolveExprNode(a, base, baseAlias, ctx));
|
|
469
|
+
if (args.some((a) => a === undefined)) return undefined;
|
|
470
|
+
return { kind: "logic", op: op === FILTER_COMPOSE_AND ? "and" : "or", args: args as ViewExprNode[] };
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
return undefined;
|
|
474
|
+
}
|
|
475
|
+
|
|
253
476
|
function shortAliasFor(entityName: string, used: Set<string>): string {
|
|
254
477
|
const base = (entityName[0] ?? "x").toLowerCase();
|
|
255
478
|
if (!used.has(base)) { used.add(base); return base; }
|
|
@@ -273,6 +496,8 @@ interface PathStep {
|
|
|
273
496
|
fkColumn: string;
|
|
274
497
|
pkColumn: string;
|
|
275
498
|
referenceHolder: "source" | "target";
|
|
499
|
+
/** #209 — derived join type: `inner` for a required belongs-to FK, else `left`. */
|
|
500
|
+
joinType: "inner" | "left";
|
|
276
501
|
targetEntity: string;
|
|
277
502
|
}
|
|
278
503
|
|
|
@@ -302,9 +527,19 @@ function buildJoinTree(
|
|
|
302
527
|
for (const origin of field.ownChildren()) {
|
|
303
528
|
if (origin.type !== TYPE_ORIGIN) continue;
|
|
304
529
|
// ADR-0039: own (category 4) — origin.* never inherits (ADR-0029).
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
530
|
+
// Only aggregate (count/sum/avg/min/max/any/all/collect) and passthrough origins
|
|
531
|
+
// contribute a LEFT-JOIN branch. origin.computed is row-level (no @via); origin.first
|
|
532
|
+
// (#195) lowers to a CORRELATED subquery, NOT a join — its @via must NOT enter the
|
|
533
|
+
// join tree (both @from-via and @first-via share the physical name "via", so an
|
|
534
|
+
// unguarded read would wrongly join a `first`).
|
|
535
|
+
let viaAttr: string | undefined;
|
|
536
|
+
if (origin.subType === ORIGIN_SUBTYPE_AGGREGATE) {
|
|
537
|
+
viaAttr = origin.ownAttr(ORIGIN_AGGREGATE_ATTR_VIA) as string | undefined;
|
|
538
|
+
} else if (origin.subType === ORIGIN_SUBTYPE_PASSTHROUGH) {
|
|
539
|
+
viaAttr = origin.ownAttr(ORIGIN_PASSTHROUGH_ATTR_VIA) as string | undefined;
|
|
540
|
+
} else {
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
308
543
|
if (!viaAttr) continue;
|
|
309
544
|
|
|
310
545
|
const segments = viaAttr.split(".");
|
|
@@ -348,6 +583,23 @@ function buildJoinTree(
|
|
|
348
583
|
const fkHolder = referenceHolder === "source" ? currentObj : target;
|
|
349
584
|
const pkHolder = referenceHolder === "source" ? target : currentObj;
|
|
350
585
|
|
|
586
|
+
// #209 — a belongs-to hop (FK on the parent) whose FK is NOT NULL is
|
|
587
|
+
// semantically INNER: every base row has a match, so INNER and LEFT OUTER
|
|
588
|
+
// return the same set, and INNER matches the hand-written view it stands in
|
|
589
|
+
// for (and keeps `verify --db` fingerprints aligned). A nullable belongs-to
|
|
590
|
+
// FK, or ANY has-many hop (FK on the child — a base row may have zero
|
|
591
|
+
// children), stays LEFT OUTER so no base row is dropped.
|
|
592
|
+
const fkFieldObj = (fkHolder as MetaObject).findField(fkField);
|
|
593
|
+
const selfInner =
|
|
594
|
+
referenceHolder === "source" && fkFieldObj !== undefined && isRequired(fkFieldObj);
|
|
595
|
+
// Nested-chain safety: joins render flat + left-associative, so an INNER hop
|
|
596
|
+
// BELOW any LEFT ancestor drops the base row (its ON references a column the
|
|
597
|
+
// LEFT ancestor NULLed). An INNER only survives when the ENTIRE ancestor chain
|
|
598
|
+
// is INNER; otherwise demote to LEFT (lossless — under a LEFT ancestor, LEFT is
|
|
599
|
+
// the correct type). `path` holds this chain's ancestor hops accumulated so far.
|
|
600
|
+
const joinType: "inner" | "left" =
|
|
601
|
+
selfInner && path.every((prior) => prior.joinType === "inner") ? "inner" : "left";
|
|
602
|
+
|
|
351
603
|
path.push({
|
|
352
604
|
entity: currentObj,
|
|
353
605
|
relationship: relName,
|
|
@@ -355,6 +607,7 @@ function buildJoinTree(
|
|
|
355
607
|
fkColumn: joinColumnFor(fkHolder, fkField, ctx),
|
|
356
608
|
pkColumn: joinColumnFor(pkHolder, resolvedPkField, ctx),
|
|
357
609
|
referenceHolder,
|
|
610
|
+
joinType,
|
|
358
611
|
targetEntity: stripPackage(targetName),
|
|
359
612
|
});
|
|
360
613
|
currentObj = target;
|
|
@@ -387,6 +640,7 @@ function buildJoinTree(
|
|
|
387
640
|
fkColumn: step.fkColumn,
|
|
388
641
|
pkColumn: step.pkColumn,
|
|
389
642
|
referenceHolder: step.referenceHolder,
|
|
643
|
+
joinType: step.joinType,
|
|
390
644
|
children: Array.from(node.children.values()).map(toJoinNode),
|
|
391
645
|
};
|
|
392
646
|
}
|
|
@@ -426,6 +680,7 @@ function buildSelectSpec(
|
|
|
426
680
|
joinTree: JoinTree,
|
|
427
681
|
root: MetaRoot,
|
|
428
682
|
ctx: ExtractContext,
|
|
683
|
+
usedAliases: Set<string>,
|
|
429
684
|
): SelectSpec {
|
|
430
685
|
const columns: SelectColumn[] = [];
|
|
431
686
|
|
|
@@ -435,12 +690,17 @@ function buildSelectSpec(
|
|
|
435
690
|
// is removed with the B4b cutover: base columns are declared explicitly as
|
|
436
691
|
// extends-bound fields (`{ field.int: { name: id, extends: "Program.id" } }`).
|
|
437
692
|
|
|
438
|
-
//
|
|
439
|
-
//
|
|
440
|
-
//
|
|
441
|
-
//
|
|
442
|
-
|
|
443
|
-
|
|
693
|
+
// #213 — an entity read-view HOST (base === projection, FR-024 §7) exposes its
|
|
694
|
+
// EFFECTIVE field set: the `o.*` includes fields inherited via extends (a
|
|
695
|
+
// BaseEntity id/createdAt). A plain projection exposes only its DECLARED (own)
|
|
696
|
+
// fields — the declared set IS the exposure (FR-024/ADR-0028). Either way, each
|
|
697
|
+
// field's own origin decides passthrough-from-base vs derived-from-join. origin.*
|
|
698
|
+
// NEVER inherits (ADR-0029), so the origin reads below are own (category 4).
|
|
699
|
+
const declaredFields: MetaField[] =
|
|
700
|
+
base === projection
|
|
701
|
+
? base.fields()
|
|
702
|
+
: projection.ownChildren().filter((c): c is MetaField => c.type === TYPE_FIELD);
|
|
703
|
+
for (const field of declaredFields) {
|
|
444
704
|
const origin = field.ownChildren().find((c) => c.type === TYPE_ORIGIN);
|
|
445
705
|
const dbCol = sourceColumnNameFor(field, ctx);
|
|
446
706
|
|
|
@@ -482,9 +742,41 @@ function buildSelectSpec(
|
|
|
482
742
|
});
|
|
483
743
|
} else if (origin.subType === ORIGIN_SUBTYPE_AGGREGATE) {
|
|
484
744
|
// ADR-0039: own (category 4) — origin.* never inherits (ADR-0029).
|
|
485
|
-
const agg = origin.ownAttr(ORIGIN_AGGREGATE_ATTR_AGG) as
|
|
486
|
-
|
|
487
|
-
|
|
745
|
+
const agg = origin.ownAttr(ORIGIN_AGGREGATE_ATTR_AGG) as string | undefined;
|
|
746
|
+
if (!agg) continue;
|
|
747
|
+
const filterAttr = origin.ownAttr(ORIGIN_AGGREGATE_ATTR_FILTER);
|
|
748
|
+
|
|
749
|
+
if (agg === AGG_ANY || agg === AGG_ALL) {
|
|
750
|
+
// #195 predicate quantifier — no @of; the related entity is @via's terminal
|
|
751
|
+
// hop, and @filter is the (required) quantified predicate.
|
|
752
|
+
const via = origin.ownAttr(ORIGIN_AGGREGATE_ATTR_VIA) as string | undefined;
|
|
753
|
+
if (!via) continue;
|
|
754
|
+
const relatedName = viaTerminalEntity(via, root);
|
|
755
|
+
if (!relatedName) continue;
|
|
756
|
+
const relatedEntity = root.findObject(relatedName);
|
|
757
|
+
const sourceAlias = findAliasInTree(joinTree, relatedName);
|
|
758
|
+
if (!relatedEntity || sourceAlias === undefined) continue;
|
|
759
|
+
const joinedPk = primaryKeyColumn(relatedEntity, ctx);
|
|
760
|
+
if (joinedPk === undefined) continue;
|
|
761
|
+
const pred = filterAttr !== undefined
|
|
762
|
+
? resolveAggregateFilter(filterAttr, relatedEntity, sourceAlias, ctx)
|
|
763
|
+
: undefined;
|
|
764
|
+
if (pred === undefined) continue; // loader requires @filter on any/all
|
|
765
|
+
columns.push({
|
|
766
|
+
kind: "predicateAgg",
|
|
767
|
+
fieldName: field.name,
|
|
768
|
+
dbColAlias: dbCol,
|
|
769
|
+
quant: agg === AGG_ANY ? "any" : "all",
|
|
770
|
+
sourceAlias,
|
|
771
|
+
joinedPkColumn: joinedPk,
|
|
772
|
+
pred,
|
|
773
|
+
});
|
|
774
|
+
continue;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// collect + the scalar reduces (count/sum/avg/min/max) all name @of.
|
|
778
|
+
const of_ = origin.ownAttr(ORIGIN_AGGREGATE_ATTR_OF) as string | undefined;
|
|
779
|
+
if (!of_) continue;
|
|
488
780
|
const dotIdx = of_.indexOf(".");
|
|
489
781
|
if (dotIdx < 1) continue;
|
|
490
782
|
const entityName = stripPackage(of_.slice(0, dotIdx));
|
|
@@ -494,20 +786,97 @@ function buildSelectSpec(
|
|
|
494
786
|
if (!targetEntity || sourceAlias === undefined) continue;
|
|
495
787
|
const targetField = targetEntity.fields().find((f) => f.name === fieldName);
|
|
496
788
|
if (!targetField) continue;
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
789
|
+
|
|
790
|
+
if (agg === AGG_COLLECT) {
|
|
791
|
+
// #195 array rollup — collect @of across the related set. @distinct = set
|
|
792
|
+
// semantics; @orderBy (non-distinct) sets element order, else value-ascending.
|
|
793
|
+
const joinedPk = primaryKeyColumn(targetEntity, ctx);
|
|
794
|
+
if (joinedPk === undefined) continue;
|
|
795
|
+
const distinct = origin.ownAttr(ORIGIN_ATTR_DISTINCT) === true;
|
|
796
|
+
const orderBy = resolveOrderByKeys(origin.ownAttr(ORIGIN_ATTR_ORDER_BY), targetEntity, ctx);
|
|
797
|
+
columns.push({
|
|
798
|
+
kind: "collectAgg",
|
|
799
|
+
fieldName: field.name,
|
|
800
|
+
dbColAlias: dbCol,
|
|
801
|
+
sourceAlias,
|
|
802
|
+
sourceColumn: sourceColumnNameFor(targetField, ctx),
|
|
803
|
+
joinedPkColumn: joinedPk,
|
|
804
|
+
distinct,
|
|
805
|
+
orderBy,
|
|
806
|
+
});
|
|
807
|
+
continue;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
if (SCALAR_AGG_FUNCTIONS.has(agg)) {
|
|
811
|
+
// Optional scoping filter — resolved against the aggregated entity (the @of
|
|
812
|
+
// entity, reached at `sourceAlias`), e.g. max(version) over only active rows.
|
|
813
|
+
const filter = filterAttr !== undefined
|
|
814
|
+
? resolveAggregateFilter(filterAttr, targetEntity, sourceAlias, ctx)
|
|
815
|
+
: undefined;
|
|
816
|
+
columns.push({
|
|
817
|
+
kind: "aggregate",
|
|
818
|
+
fieldName: field.name,
|
|
819
|
+
dbColAlias: dbCol,
|
|
820
|
+
agg: agg as AggregateFunction,
|
|
821
|
+
sourceAlias,
|
|
822
|
+
sourceColumn: sourceColumnNameFor(targetField, ctx),
|
|
823
|
+
...(filter !== undefined ? { filter } : {}),
|
|
824
|
+
});
|
|
825
|
+
}
|
|
826
|
+
} else if (origin.subType === ORIGIN_SUBTYPE_COMPUTED) {
|
|
827
|
+
// #195 row-level computed value from the base entity's own fields (@expr tree,
|
|
828
|
+
// no @via). ADR-0039: own — origin.* never inherits (ADR-0029).
|
|
829
|
+
const rawExpr = origin.ownAttr(ORIGIN_COMPUTED_ATTR_EXPR);
|
|
830
|
+
const expr = resolveExprNode(rawExpr, base, joinTree.baseAlias, ctx);
|
|
831
|
+
if (expr === undefined) continue;
|
|
832
|
+
columns.push({ kind: "computed", fieldName: field.name, dbColAlias: dbCol, expr });
|
|
833
|
+
} else if (origin.subType === ORIGIN_SUBTYPE_FIRST) {
|
|
834
|
+
// #195 correlated latest-row — argmax-then-project. Resolves to a correlated
|
|
835
|
+
// subquery (NOT a join). ADR-0039: own — origin.* never inherits (ADR-0029).
|
|
836
|
+
const of_ = origin.ownAttr(ORIGIN_FIRST_ATTR_OF) as string | undefined;
|
|
837
|
+
if (!of_) continue;
|
|
838
|
+
const dotIdx = of_.indexOf(".");
|
|
839
|
+
if (dotIdx < 1) continue;
|
|
840
|
+
const childName = stripPackage(of_.slice(0, dotIdx));
|
|
841
|
+
const ofFieldName = of_.slice(dotIdx + 1);
|
|
842
|
+
const childEntity = root.findObject(childName);
|
|
843
|
+
if (!childEntity) continue;
|
|
844
|
+
const ofField = childEntity.fields().find((f) => f.name === ofFieldName);
|
|
845
|
+
if (!ofField) continue;
|
|
846
|
+
// The base↔child correlation FK — resolved exactly as buildJoinTree resolves a
|
|
847
|
+
// single hop (the identity.reference is the FK-direction SSOT). Single-hop @via;
|
|
848
|
+
// a multi-hop @via on origin.first is not lowered here (rare, and validated away).
|
|
849
|
+
const ref = findReferenceBetween(base, childEntity);
|
|
850
|
+
if (!ref) continue;
|
|
851
|
+
const fkField = ref.referenceIdentity.fields[0];
|
|
852
|
+
if (!fkField) continue;
|
|
853
|
+
const pkField = ref.referenceIdentity.resolvedTargetPkField(root) ?? "id";
|
|
854
|
+
const referenceHolder: "source" | "target" =
|
|
855
|
+
ref.holder.name === base.name ? "source" : "target";
|
|
856
|
+
const fkHolder = referenceHolder === "source" ? base : childEntity;
|
|
857
|
+
const pkHolder = referenceHolder === "source" ? childEntity : base;
|
|
858
|
+
const childPk = primaryKeyColumn(childEntity, ctx);
|
|
859
|
+
if (childPk === undefined) continue;
|
|
860
|
+
// A FRESH alias — the subquery is an independent correlated scope, never the
|
|
861
|
+
// JOIN-tree alias (reserved against usedAliases so it can never collide).
|
|
862
|
+
const childAlias = shortAliasFor(childEntity.name, usedAliases);
|
|
863
|
+
const orderBy = resolveOrderByKeys(origin.ownAttr(ORIGIN_ATTR_ORDER_BY), childEntity, ctx);
|
|
864
|
+
const filterAttr = origin.ownAttr(ORIGIN_FIRST_ATTR_FILTER);
|
|
501
865
|
const filter = filterAttr !== undefined
|
|
502
|
-
? resolveAggregateFilter(filterAttr,
|
|
866
|
+
? resolveAggregateFilter(filterAttr, childEntity, childAlias, ctx)
|
|
503
867
|
: undefined;
|
|
504
868
|
columns.push({
|
|
505
|
-
kind: "
|
|
869
|
+
kind: "first",
|
|
506
870
|
fieldName: field.name,
|
|
507
871
|
dbColAlias: dbCol,
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
sourceColumn: sourceColumnNameFor(
|
|
872
|
+
childEntity: childEntity.name,
|
|
873
|
+
childAlias,
|
|
874
|
+
sourceColumn: sourceColumnNameFor(ofField, ctx),
|
|
875
|
+
referenceHolder,
|
|
876
|
+
fkColumn: joinColumnFor(fkHolder, fkField, ctx),
|
|
877
|
+
pkColumn: joinColumnFor(pkHolder, pkField, ctx),
|
|
878
|
+
childPkColumn: childPk,
|
|
879
|
+
orderBy,
|
|
511
880
|
...(filter !== undefined ? { filter } : {}),
|
|
512
881
|
});
|
|
513
882
|
}
|
|
@@ -516,8 +885,29 @@ function buildSelectSpec(
|
|
|
516
885
|
return { columns };
|
|
517
886
|
}
|
|
518
887
|
|
|
888
|
+
/** The inflation-sensitive aggregate kinds: a non-distinct row multiplication corrupts
|
|
889
|
+
* their value (sum/avg double-count; a non-distinct collect duplicates elements).
|
|
890
|
+
* count is DISTINCT-guarded; min/max and any/all are inflation-immune. */
|
|
891
|
+
function isInflationSensitive(c: SelectColumn): boolean {
|
|
892
|
+
if (c.kind === "aggregate") return c.agg === "sum" || c.agg === "avg";
|
|
893
|
+
if (c.kind === "collectAgg") return !c.distinct;
|
|
894
|
+
return false;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/** Count top-level join branches that traverse at least one to-many hop. Two or more
|
|
898
|
+
* independent many-branches multiply (cartesian) and inflate sensitive aggregates. */
|
|
899
|
+
function countManyBranches(joinTree: JoinTree): number {
|
|
900
|
+
const hasMany = (node: JoinNode): boolean =>
|
|
901
|
+
node.cardinality === "many" || node.children.some(hasMany);
|
|
902
|
+
return joinTree.joins.filter(hasMany).length;
|
|
903
|
+
}
|
|
904
|
+
|
|
519
905
|
function buildGroupBy(spec: SelectSpec): string[] {
|
|
520
|
-
|
|
906
|
+
// predicateAgg (bool_or/bool_and) and collectAgg (array_agg) are real aggregates and
|
|
907
|
+
// force GROUP BY too; computed/first are scalar-per-row and never grouped.
|
|
908
|
+
const hasAgg = spec.columns.some(
|
|
909
|
+
(c) => c.kind === "aggregate" || c.kind === "predicateAgg" || c.kind === "collectAgg",
|
|
910
|
+
);
|
|
521
911
|
if (!hasAgg) return [];
|
|
522
912
|
return spec.columns
|
|
523
913
|
.filter((c) => c.kind === "passthrough")
|
|
@@ -543,17 +933,66 @@ export function extractViewSpec(
|
|
|
543
933
|
root: MetaRoot,
|
|
544
934
|
ctx: ExtractContext,
|
|
545
935
|
): ViewSpec {
|
|
546
|
-
|
|
936
|
+
// #213 — a write-through entity (FR-024 §7) IS its own base: stored fields SELECT
|
|
937
|
+
// from the base alias (o.*), derived (origin.*) fields from the joins. A plain
|
|
938
|
+
// projection anchors its base via an extends binding (baseEntityFor).
|
|
939
|
+
const writeThrough = isWriteThrough(projection);
|
|
940
|
+
const base = writeThrough ? projection : baseEntityFor(projection, root);
|
|
547
941
|
const usedAliases = new Set<string>();
|
|
548
942
|
const baseAlias = shortAliasFor(base.name, usedAliases);
|
|
549
943
|
const joinTree = buildJoinTree(projection, base, root, usedAliases, baseAlias, ctx);
|
|
550
|
-
const selectSpec = buildSelectSpec(projection, base, joinTree, root, ctx);
|
|
944
|
+
const selectSpec = buildSelectSpec(projection, base, joinTree, root, ctx, usedAliases);
|
|
551
945
|
const groupBy = buildGroupBy(selectSpec);
|
|
552
946
|
|
|
947
|
+
const view = viewName(projection, ctx);
|
|
948
|
+
warnOnJoinInflation(projection, view, joinTree, selectSpec);
|
|
949
|
+
|
|
950
|
+
// #207 — a projection's OWN row-scope @filter lowers to the view's outer WHERE.
|
|
951
|
+
// Gated to projections only (v1 scope): a write-through entity read-view is
|
|
952
|
+
// NEVER filtered — a filtered replica breaks read-your-writes totality, and the
|
|
953
|
+
// @filter attr is registered on object.projection (not object.entity), so a
|
|
954
|
+
// write-through entity cannot carry one anyway. The stored value is already the
|
|
955
|
+
// desugared canonical { field: { op: value } } form (FilterAttr.desugar at parse).
|
|
956
|
+
let where: ViewFilterClause | undefined;
|
|
957
|
+
if (!writeThrough) {
|
|
958
|
+
const rawFilter = projection.ownAttr(OBJECT_PROJECTION_ATTR_FILTER);
|
|
959
|
+
if (rawFilter !== undefined) {
|
|
960
|
+
const columnsByField = new Map<string, SelectColumn>(
|
|
961
|
+
selectSpec.columns.map((c) => [c.fieldName, c] as const),
|
|
962
|
+
);
|
|
963
|
+
where = resolveViewFilter(rawFilter, columnsByField, projection.name);
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
|
|
553
967
|
return {
|
|
554
|
-
viewName:
|
|
968
|
+
viewName: view,
|
|
555
969
|
joinTree,
|
|
556
970
|
selectSpec,
|
|
557
971
|
groupBy,
|
|
972
|
+
...(where !== undefined ? { where } : {}),
|
|
558
973
|
};
|
|
559
974
|
}
|
|
975
|
+
|
|
976
|
+
/**
|
|
977
|
+
* #195 (spec §6, risk 2) — emit a load-time WARN when an inflation-sensitive aggregate
|
|
978
|
+
* (`sum`/`avg`/non-distinct `collect`) coexists with ≥2 independent to-many join
|
|
979
|
+
* branches in one view. Two many-branches multiply (cartesian), silently double-counting
|
|
980
|
+
* — a latent bug for `sum`/`avg` today, now surfaced. count is DISTINCT-guarded and
|
|
981
|
+
* min/max/any/all are inflation-immune, so they never trip it.
|
|
982
|
+
*/
|
|
983
|
+
function warnOnJoinInflation(
|
|
984
|
+
projection: MetaObject,
|
|
985
|
+
viewName: string,
|
|
986
|
+
joinTree: JoinTree,
|
|
987
|
+
spec: SelectSpec,
|
|
988
|
+
): void {
|
|
989
|
+
if (countManyBranches(joinTree) < 2) return;
|
|
990
|
+
const sensitive = spec.columns.filter(isInflationSensitive).map((c) => c.fieldName);
|
|
991
|
+
if (sensitive.length === 0) return;
|
|
992
|
+
console.warn(
|
|
993
|
+
`[codegen-ts] projection "${projection.name}" (view ${viewName}): inflation-sensitive ` +
|
|
994
|
+
`aggregate field(s) [${sensitive.join(", ")}] coexist with ${countManyBranches(joinTree)} ` +
|
|
995
|
+
`independent to-many join branches — the joins multiply, so these values may double-count. ` +
|
|
996
|
+
`Split them into separate projections, or scope with @filter/@distinct.`,
|
|
997
|
+
);
|
|
998
|
+
}
|