@metaobjectsdev/metadata 0.24.0 → 0.24.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/core/identity/identity-definition.embedded.js +2 -2
- package/dist/core/identity/identity-definition.embedded.js.map +1 -1
- package/dist/core/index/index-definition.embedded.js +2 -2
- package/dist/core/index/index-definition.embedded.js.map +1 -1
- package/dist/core/vocabulary-rewrite-yaml.d.ts +21 -0
- package/dist/core/vocabulary-rewrite-yaml.d.ts.map +1 -0
- package/dist/core/vocabulary-rewrite-yaml.js +245 -0
- package/dist/core/vocabulary-rewrite-yaml.js.map +1 -0
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +21 -2
- package/dist/errors.js.map +1 -1
- package/dist/loader/meta-data-loader.d.ts.map +1 -1
- package/dist/loader/meta-data-loader.js +4 -1
- package/dist/loader/meta-data-loader.js.map +1 -1
- package/dist/loader/validation-passes.d.ts +1 -0
- package/dist/loader/validation-passes.d.ts.map +1 -1
- package/dist/loader/validation-passes.js +261 -43
- package/dist/loader/validation-passes.js.map +1 -1
- package/dist/persistence/origin/origin-definition.embedded.js +5 -5
- package/dist/persistence/origin/origin-definition.embedded.js.map +1 -1
- package/dist/registry-manifest.d.ts +1 -1
- package/dist/registry-manifest.js +1 -1
- package/dist/vocabulary-rewrite.d.ts.map +1 -1
- package/dist/vocabulary-rewrite.js +12 -8
- package/dist/vocabulary-rewrite.js.map +1 -1
- package/package.json +6 -1
- package/src/core/identity/identity-definition.embedded.ts +2 -2
- package/src/core/index/index-definition.embedded.ts +2 -2
- package/src/core/vocabulary-rewrite-yaml.ts +267 -0
- package/src/errors.ts +21 -2
- package/src/loader/meta-data-loader.ts +5 -1
- package/src/loader/validation-passes.ts +320 -47
- package/src/persistence/origin/origin-definition.embedded.ts +5 -5
- package/src/registry-manifest.ts +1 -1
- package/src/vocabulary-rewrite.ts +12 -8
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import type { MetaData } from "../shared/meta-data.js";
|
|
14
14
|
import type { MetaObject } from "../core/object/meta-object.js";
|
|
15
15
|
import type { MetaReferenceIdentity } from "../core/identity/meta-identity.js";
|
|
16
|
-
import { ParseError } from "../errors.js";
|
|
16
|
+
import { ParseError, type ErrorCode } from "../errors.js";
|
|
17
17
|
import { resolveObjectRef, didYouMeanHint } from "../naming-refs.js";
|
|
18
18
|
import { PACKAGE_SEPARATOR, CHILD_REF_SEPARATOR } from "../shared/structural.js";
|
|
19
19
|
import { resolvedSource, type ErrorSource } from "../source.js";
|
|
@@ -32,6 +32,8 @@ import {
|
|
|
32
32
|
INDEX_SUBTYPE_LOOKUP,
|
|
33
33
|
INDEX_ATTR_FIELDS,
|
|
34
34
|
} from "../core/index/index-constants.js";
|
|
35
|
+
import { IDENTITY_SUBTYPE_SECONDARY } from "../core/identity/identity-constants.js";
|
|
36
|
+
import type { MetaIdentity } from "../core/identity/meta-identity.js";
|
|
35
37
|
import { MetaIndex } from "../core/index/meta-index.js";
|
|
36
38
|
import {
|
|
37
39
|
TEMPLATE_ATTR_PAYLOAD_REF,
|
|
@@ -59,6 +61,7 @@ import {
|
|
|
59
61
|
} from "../presentation/layout/layout-constants.js";
|
|
60
62
|
import {
|
|
61
63
|
FIELD_ATTR_FILTERABLE,
|
|
64
|
+
FIELD_ATTR_SORTABLE,
|
|
62
65
|
FIELD_ATTR_OBJECT_REF,
|
|
63
66
|
FIELD_ATTR_STORAGE,
|
|
64
67
|
STORAGE_FLATTENED,
|
|
@@ -81,7 +84,7 @@ import {
|
|
|
81
84
|
FIELD_SUBTYPE_TIMESTAMP,
|
|
82
85
|
FIELD_SUBTYPE_UUID,
|
|
83
86
|
} from "../core/field/field-constants.js";
|
|
84
|
-
import { FIELD_ATTR_DB_INDEXED } from "../persistence/db/db-constants.js";
|
|
87
|
+
import { FIELD_ATTR_DB_INDEXED, IDENTITY_ATTR_EXPR } from "../persistence/db/db-constants.js";
|
|
85
88
|
import {
|
|
86
89
|
IDENTITY_ATTR_FIELDS,
|
|
87
90
|
IDENTITY_SUBTYPE_REFERENCE,
|
|
@@ -441,6 +444,26 @@ export function validateFilterableHasSupportedOps(root: MetaData): ParseError[]
|
|
|
441
444
|
for (const field of obj.children().filter((c) => c.type === TYPE_FIELD)) {
|
|
442
445
|
// ADR-0039: resolving — a concrete field may inherit @filterable via extends.
|
|
443
446
|
if (field.attr(FIELD_ATTR_FILTERABLE) !== true) continue;
|
|
447
|
+
|
|
448
|
+
// #335 Half B — an ARRAY field has no operator band either. Every FR-009
|
|
449
|
+
// operator (eq/ne/gt/gte/lt/lte/in/like/isNull) is a scalar comparison;
|
|
450
|
+
// none applies to a collection column. The allowlist template does not
|
|
451
|
+
// consult isArray and falls through to the "string" band, so this
|
|
452
|
+
// previously emitted a `like` rule against a text[] column — SQL that
|
|
453
|
+
// cannot execute. Same reason as the subtype check below, so same code.
|
|
454
|
+
// ADR-0039: resolvedIsArray(), never the own `isArray` flag.
|
|
455
|
+
if (field.resolvedIsArray()) {
|
|
456
|
+
errors.push(
|
|
457
|
+
new ParseError(
|
|
458
|
+
`Field "${obj.name}.${field.name}" has @filterable: true but is an array ` +
|
|
459
|
+
`(isArray: true). No filter operator applies to a collection column. ` +
|
|
460
|
+
`Remove @filterable from this field.`,
|
|
461
|
+
{ code: "ERR_FILTERABLE_UNSUPPORTED_SUBTYPE", source: field.source },
|
|
462
|
+
),
|
|
463
|
+
);
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
|
|
444
467
|
if (opsForSubType(field.subType).length > 0) continue;
|
|
445
468
|
errors.push(
|
|
446
469
|
new ParseError(
|
|
@@ -455,6 +478,40 @@ export function validateFilterableHasSupportedOps(root: MetaData): ParseError[]
|
|
|
455
478
|
return errors;
|
|
456
479
|
}
|
|
457
480
|
|
|
481
|
+
// ---------------------------------------------------------------------------
|
|
482
|
+
// @sortable on a subtype or shape that cannot be ordered (#335 Half B)
|
|
483
|
+
// ---------------------------------------------------------------------------
|
|
484
|
+
// @sortable defaults FROM @filterable, so it is independently set only when
|
|
485
|
+
// explicit — and nothing validated it, while @filterable has had a hard error
|
|
486
|
+
// since SP-H Unit9. A @sortable JSON or array column emits a sort entry over a
|
|
487
|
+
// column no dialect can ORDER BY meaningfully. → ERR_SORTABLE_UNSUPPORTED_SUBTYPE.
|
|
488
|
+
|
|
489
|
+
export function validateSortableHasSupportedSubtype(root: MetaData): ParseError[] {
|
|
490
|
+
const errors: ParseError[] = [];
|
|
491
|
+
// ADR-0039: root has no super; children()==ownChildren() but resolving is the default.
|
|
492
|
+
for (const obj of root.children().filter((c) => c.type === TYPE_OBJECT)) {
|
|
493
|
+
// children() — inherited @sortable fields (via extends:/super:) are visible.
|
|
494
|
+
for (const field of obj.children().filter((c) => c.type === TYPE_FIELD)) {
|
|
495
|
+
// ADR-0039: resolving — a concrete field may inherit @sortable via extends.
|
|
496
|
+
if (field.attr(FIELD_ATTR_SORTABLE) !== true) continue;
|
|
497
|
+
// ADR-0039: resolvedIsArray(), never the own `isArray` flag.
|
|
498
|
+
const isArray = field.resolvedIsArray();
|
|
499
|
+
if (!isArray && opsForSubType(field.subType).length > 0) continue;
|
|
500
|
+
errors.push(
|
|
501
|
+
new ParseError(
|
|
502
|
+
`Field "${obj.name}.${field.name}" has @sortable: true but ` +
|
|
503
|
+
(isArray
|
|
504
|
+
? `is an array (isArray: true) — a collection column has no ordering.`
|
|
505
|
+
: `its subtype "${field.subType}" cannot be ordered.`) +
|
|
506
|
+
` Remove @sortable from this field.`,
|
|
507
|
+
{ code: "ERR_SORTABLE_UNSUPPORTED_SUBTYPE", source: field.source },
|
|
508
|
+
),
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
return errors;
|
|
513
|
+
}
|
|
514
|
+
|
|
458
515
|
// ---------------------------------------------------------------------------
|
|
459
516
|
// Origin path validation
|
|
460
517
|
//
|
|
@@ -592,10 +649,25 @@ function _validateFromPath(
|
|
|
592
649
|
return { entity: sourceObj, field: sourceField };
|
|
593
650
|
}
|
|
594
651
|
|
|
652
|
+
/** A fully-walked `@via` path: the relationship hop nodes in path order, and
|
|
653
|
+
* the TERMINAL entity reached after the last hop. Both fall out of one walk,
|
|
654
|
+
* so they are returned together — recovering the terminal with a second walk
|
|
655
|
+
* means maintaining a second copy of the ADR-0042 package-resolution rule.
|
|
656
|
+
* Shape mirrors `_validateFromPath`'s `ResolvedFromTarget`, which returns a
|
|
657
|
+
* pair for the same reason. */
|
|
658
|
+
interface WalkedViaPath {
|
|
659
|
+
hops: MetaData[];
|
|
660
|
+
terminal: MetaData;
|
|
661
|
+
}
|
|
662
|
+
|
|
595
663
|
/**
|
|
596
|
-
* Validate an explicit `@via` "Entity.rel[.rel...]" path.
|
|
597
|
-
* relationship hop nodes (in path order)
|
|
598
|
-
* cardinality checks over them —
|
|
664
|
+
* Validate an explicit `@via` "Entity.rel[.rel...]" path. On full success
|
|
665
|
+
* returns the walked relationship hop nodes (in path order) — FR-024 B5 runs
|
|
666
|
+
* the cardinality checks over them — together with the terminal entity node
|
|
667
|
+
* (#335: a whole-object `@agg:collect` has no `@of` entity, so `@orderBy` keys
|
|
668
|
+
* and value-object members resolve against the terminal instead). Returns
|
|
669
|
+
* undefined when any error was pushed, so `terminal` is defined exactly when
|
|
670
|
+
* `hops` is.
|
|
599
671
|
*/
|
|
600
672
|
function _validateViaPath(
|
|
601
673
|
viaAttr: string,
|
|
@@ -604,7 +676,7 @@ function _validateViaPath(
|
|
|
604
676
|
fieldName: string,
|
|
605
677
|
originSource: ErrorSource,
|
|
606
678
|
errors: ParseError[],
|
|
607
|
-
):
|
|
679
|
+
): WalkedViaPath | undefined {
|
|
608
680
|
const projectionName = projection.name;
|
|
609
681
|
// FR5d — referrer is `<projection-FQN>::<fieldName>`.
|
|
610
682
|
const referrer = `${projection.fqn()}::${fieldName}`;
|
|
@@ -706,7 +778,8 @@ function _validateViaPath(
|
|
|
706
778
|
hops.push(rel);
|
|
707
779
|
currentObj = nextObj;
|
|
708
780
|
}
|
|
709
|
-
|
|
781
|
+
// currentObj is the terminal: every earlier exit returned undefined.
|
|
782
|
+
return { hops, terminal: currentObj };
|
|
710
783
|
}
|
|
711
784
|
|
|
712
785
|
// ---------------------------------------------------------------------------
|
|
@@ -956,6 +1029,58 @@ function _checkAggregateCardinality(
|
|
|
956
1029
|
}
|
|
957
1030
|
}
|
|
958
1031
|
|
|
1032
|
+
/**
|
|
1033
|
+
* #335 — a whole-object `@agg:collect` projects EXACTLY the declared value
|
|
1034
|
+
* object's members, each matched by NAME against the `@via` terminal entity.
|
|
1035
|
+
*
|
|
1036
|
+
* Two rules, both fail-closed:
|
|
1037
|
+
* - a member with no matching field on the terminal is unresolvable. Failing
|
|
1038
|
+
* OPEN here is how #270 turned a curated value object into the full entity,
|
|
1039
|
+
* invisible in a diff because the metadata still read as curated.
|
|
1040
|
+
* - a matched member must agree on BOTH type axes (#185 type-preserving
|
|
1041
|
+
* doctrine), so a scalar member cannot bind an array field or vice versa.
|
|
1042
|
+
*
|
|
1043
|
+
* Both refusals carry a whole-object-specific code — ERR_COLLECT_MEMBER_UNRESOLVED
|
|
1044
|
+
* for the unmatched member, ERR_COLLECT_WHOLE_OBJECT for the type disagreement.
|
|
1045
|
+
* The latter is deliberately NOT the scalar arm's ERR_INVALID_ORIGIN: a loader
|
|
1046
|
+
* that still requires @of rejects this metadata with ERR_INVALID_ORIGIN too, so
|
|
1047
|
+
* sharing the code would make a corpus fixture pass on a port that implements
|
|
1048
|
+
* nothing (the corpus compares only code + source, never message text).
|
|
1049
|
+
*/
|
|
1050
|
+
function _checkCollectMembers(
|
|
1051
|
+
refTarget: MetaData,
|
|
1052
|
+
terminal: MetaData,
|
|
1053
|
+
obj: MetaData,
|
|
1054
|
+
field: MetaData,
|
|
1055
|
+
src: ErrorSource,
|
|
1056
|
+
errors: ParseError[],
|
|
1057
|
+
): void {
|
|
1058
|
+
// ADR-0039: resolving — a value object may inherit members via extends, and
|
|
1059
|
+
// the terminal entity may inherit fields; own-only would silently skip
|
|
1060
|
+
// inherited members, which is exactly the #270 bug class this guards.
|
|
1061
|
+
const terminalFields = terminal.children().filter((c) => c.type === TYPE_FIELD);
|
|
1062
|
+
for (const member of refTarget.children().filter((c) => c.type === TYPE_FIELD)) {
|
|
1063
|
+
const match = terminalFields.find((f) => f.name === member.name);
|
|
1064
|
+
if (match === undefined) {
|
|
1065
|
+
errors.push(new ParseError(
|
|
1066
|
+
`origin.aggregate @agg:collect on ${obj.name}.${field.name}: value-object member ` +
|
|
1067
|
+
`'${member.name}' has no matching field on '${terminal.name}' — a whole-object ` +
|
|
1068
|
+
`rollup projects exactly the declared members.`,
|
|
1069
|
+
{ code: "ERR_COLLECT_MEMBER_UNRESOLVED", source: src }));
|
|
1070
|
+
continue;
|
|
1071
|
+
}
|
|
1072
|
+
const memberLabel = _typeLabel(member);
|
|
1073
|
+
const matchLabel = _typeLabel(match);
|
|
1074
|
+
if (memberLabel !== matchLabel) {
|
|
1075
|
+
errors.push(new ParseError(
|
|
1076
|
+
`origin.aggregate @agg:collect on ${obj.name}.${field.name}: value-object member ` +
|
|
1077
|
+
`'${member.name}' is ${memberLabel} but '${terminal.name}.${match.name}' ` +
|
|
1078
|
+
`is ${matchLabel} — a whole-object rollup preserves each member's type.`,
|
|
1079
|
+
{ code: "ERR_COLLECT_WHOLE_OBJECT", source: src }));
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
|
|
959
1084
|
/**
|
|
960
1085
|
* FR-024 B6 (spec §4; ADR-0029 decision 7) — extends/origin agreement.
|
|
961
1086
|
*
|
|
@@ -1021,6 +1146,17 @@ function _checkExtendsOriginAgreement(
|
|
|
1021
1146
|
* FR-015 stored-proc parameter refs the retired ERR_PARAMETER_REF_PASSTHROUGH_
|
|
1022
1147
|
* TYPE_MISMATCH used to cover).
|
|
1023
1148
|
*/
|
|
1149
|
+
/**
|
|
1150
|
+
* Both type axes in one comparable token. Subtype names never contain "[]", so
|
|
1151
|
+
* equal labels ⇔ same subType AND same array-ness. Nullability is deliberately
|
|
1152
|
+
* NOT judged — an outer-join view legitimately widens NOT NULL.
|
|
1153
|
+
* ADR-0039: resolvedIsArray(), never the own `isArray` flag — a field may
|
|
1154
|
+
* inherit its array-ness via extends.
|
|
1155
|
+
*/
|
|
1156
|
+
function _typeLabel(field: MetaData): string {
|
|
1157
|
+
return `field.${field.subType}${field.resolvedIsArray() ? "[]" : ""}`;
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1024
1160
|
function _checkPassthroughType(
|
|
1025
1161
|
field: MetaData,
|
|
1026
1162
|
fromField: MetaData,
|
|
@@ -1031,11 +1167,8 @@ function _checkPassthroughType(
|
|
|
1031
1167
|
errors: ParseError[],
|
|
1032
1168
|
): void {
|
|
1033
1169
|
if (convert) return; // deliberate type change acknowledged
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
// deliberately not judged — an outer-join view legitimately widens NOT NULL).
|
|
1037
|
-
const declared = `field.${field.subType}${field.resolvedIsArray() ? "[]" : ""}`;
|
|
1038
|
-
const source = `field.${fromField.subType}${fromField.resolvedIsArray() ? "[]" : ""}`;
|
|
1170
|
+
const declared = _typeLabel(field);
|
|
1171
|
+
const source = _typeLabel(fromField);
|
|
1039
1172
|
if (declared === source) return;
|
|
1040
1173
|
errors.push(
|
|
1041
1174
|
new ParseError(
|
|
@@ -1057,6 +1190,10 @@ function _checkPassthroughType(
|
|
|
1057
1190
|
* and carries no vocabulary. Shared by `@agg:collect` (element order) and
|
|
1058
1191
|
* `origin.first` (row selection). A missing related entity means a prior error
|
|
1059
1192
|
* already fired — skip silently.
|
|
1193
|
+
*
|
|
1194
|
+
* `code` lets the whole-object `@agg:collect` arm report ERR_COLLECT_WHOLE_OBJECT
|
|
1195
|
+
* instead; it defaults to ERR_INVALID_ORIGIN so the scalar `@of` and `origin.first`
|
|
1196
|
+
* call sites keep their existing envelope byte-for-byte.
|
|
1060
1197
|
*/
|
|
1061
1198
|
function _validateOrderByKeys(
|
|
1062
1199
|
orderBy: unknown,
|
|
@@ -1066,6 +1203,7 @@ function _validateOrderByKeys(
|
|
|
1066
1203
|
label: string,
|
|
1067
1204
|
originSource: ErrorSource,
|
|
1068
1205
|
errors: ParseError[],
|
|
1206
|
+
code: ErrorCode = "ERR_INVALID_ORIGIN",
|
|
1069
1207
|
): void {
|
|
1070
1208
|
if (!Array.isArray(orderBy) || relatedEntity === undefined) return;
|
|
1071
1209
|
for (const raw of orderBy) {
|
|
@@ -1079,14 +1217,14 @@ function _validateOrderByKeys(
|
|
|
1079
1217
|
errors.push(
|
|
1080
1218
|
new ParseError(
|
|
1081
1219
|
`${label} on ${obj.name}.${fieldName}: @orderBy key "${raw}" — no such field "${key}" on ${relatedEntity.name}.`,
|
|
1082
|
-
{ code
|
|
1220
|
+
{ code, source: originSource },
|
|
1083
1221
|
),
|
|
1084
1222
|
);
|
|
1085
1223
|
} else if (dir !== undefined && !(SORT_ORDER_VALUES as readonly string[]).includes(dir)) {
|
|
1086
1224
|
errors.push(
|
|
1087
1225
|
new ParseError(
|
|
1088
1226
|
`${label} on ${obj.name}.${fieldName}: @orderBy key "${raw}" — direction must be one of ${SORT_ORDER_VALUES.join("|")}.`,
|
|
1089
|
-
{ code
|
|
1227
|
+
{ code, source: originSource },
|
|
1090
1228
|
),
|
|
1091
1229
|
);
|
|
1092
1230
|
}
|
|
@@ -1156,9 +1294,9 @@ export function validateOriginPaths(root: MetaData): ParseError[] {
|
|
|
1156
1294
|
// ADR-0039: own — origin.* never inherits (ADR-0029).
|
|
1157
1295
|
const via = origin.ownAttr(ORIGIN_PASSTHROUGH_ATTR_VIA);
|
|
1158
1296
|
if (typeof via === "string" && via !== "") {
|
|
1159
|
-
const
|
|
1160
|
-
if (
|
|
1161
|
-
_checkPassthroughCardinality(hops, obj, field.name, origin.source, errors);
|
|
1297
|
+
const walked = _validateViaPath(via, root, obj, field.name, origin.source, errors);
|
|
1298
|
+
if (walked !== undefined) {
|
|
1299
|
+
_checkPassthroughCardinality(walked.hops, obj, field.name, origin.source, errors);
|
|
1162
1300
|
}
|
|
1163
1301
|
} else if (fromTarget !== undefined && !isValueHost) {
|
|
1164
1302
|
// FR-024 §6 — no @via: derive the base entity; a @from targeting
|
|
@@ -1243,17 +1381,89 @@ export function validateOriginPaths(root: MetaData): ParseError[] {
|
|
|
1243
1381
|
`origin.aggregate @agg:${String(agg)} on ${obj.name}.${field.name}: requires an explicit @via (a quantifier has no @of to infer the path from).`,
|
|
1244
1382
|
{ code: "ERR_INVALID_ORIGIN", source: src }));
|
|
1245
1383
|
} else {
|
|
1246
|
-
const
|
|
1247
|
-
if (
|
|
1384
|
+
const walked = _validateViaPath(via, root, obj, field.name, src, errors);
|
|
1385
|
+
if (walked !== undefined) _checkAggregateCardinality(walked.hops, obj, field.name, src, errors);
|
|
1248
1386
|
}
|
|
1249
1387
|
continue;
|
|
1250
1388
|
}
|
|
1251
1389
|
|
|
1252
|
-
// --- count/sum/avg/min/max
|
|
1390
|
+
// --- @of: REQUIRED for count/sum/avg/min/max; OPTIONAL for collect ---
|
|
1391
|
+
// #335 — an @of-absent collect is a WHOLE-OBJECT rollup: collect the
|
|
1392
|
+
// related rows as an array of the field's declared @objectRef value
|
|
1393
|
+
// object rather than an array of one scalar column.
|
|
1253
1394
|
if (!ofPresent) {
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1395
|
+
if (!isCollect) {
|
|
1396
|
+
errors.push(new ParseError(
|
|
1397
|
+
`origin.aggregate on ${obj.name}.${field.name}: missing @of.`,
|
|
1398
|
+
{ code: "ERR_INVALID_ORIGIN", source: src }));
|
|
1399
|
+
continue;
|
|
1400
|
+
}
|
|
1401
|
+
// Whole-object rollup. The carrying field must be a field.object
|
|
1402
|
+
// naming a value object, and @via must be explicit (there is no @of
|
|
1403
|
+
// entity to infer the single-hop relation from).
|
|
1404
|
+
// ADR-0039: resolving — @objectRef may be inherited via extends.
|
|
1405
|
+
const objectRef = field.attr(FIELD_ATTR_OBJECT_REF);
|
|
1406
|
+
if (field.subType !== FIELD_SUBTYPE_OBJECT || typeof objectRef !== "string" || objectRef === "") {
|
|
1407
|
+
errors.push(new ParseError(
|
|
1408
|
+
`origin.aggregate @agg:collect on ${obj.name}.${field.name}: @of is omitted, so this is a ` +
|
|
1409
|
+
`whole-object rollup — the carrying field must be a field.object declaring @objectRef ` +
|
|
1410
|
+
`(add @of to collect a single column instead).`,
|
|
1411
|
+
{ code: "ERR_COLLECT_WHOLE_OBJECT", source: src }));
|
|
1412
|
+
continue;
|
|
1413
|
+
}
|
|
1414
|
+
// #210's value-only rule is PAYLOAD-scoped and never reaches a
|
|
1415
|
+
// projection-hosted field, so this branch enforces it itself.
|
|
1416
|
+
// Without it an @objectRef to an entity silently rolls up the FULL
|
|
1417
|
+
// entity — the #270 shape, this time baked into DDL.
|
|
1418
|
+
// ADR-0042 — a bare @objectRef resolves in the DECLARING owner's
|
|
1419
|
+
// package (an inherited field resolves in the package that
|
|
1420
|
+
// declared it) — same rule _checkNestedPayloadRefsValueOnly uses.
|
|
1421
|
+
const refOwner = field.parent ?? obj;
|
|
1422
|
+
const refPkg = refOwner.package ?? refOwner.fileDefaultPackage ?? "";
|
|
1423
|
+
const refTarget = resolveObjectRef(root, objectRef, refPkg).node;
|
|
1424
|
+
if (refTarget !== undefined && refTarget.subType !== OBJECT_SUBTYPE_VALUE) {
|
|
1425
|
+
errors.push(new ParseError(
|
|
1426
|
+
`origin.aggregate @agg:collect on ${obj.name}.${field.name}: @objectRef '${objectRef}' ` +
|
|
1427
|
+
`resolves to ${TYPE_OBJECT}.${refTarget.subType} — a whole-object rollup must target an ` +
|
|
1428
|
+
`object.value (#210, ADR-0028).`,
|
|
1429
|
+
{ code: "ERR_SUBTYPE_RULE_VIOLATION", source: src }));
|
|
1430
|
+
continue;
|
|
1431
|
+
}
|
|
1432
|
+
// ADR-0039: own — origin.* never inherits (ADR-0029).
|
|
1433
|
+
const viaAttr = origin.ownAttr(ORIGIN_AGGREGATE_ATTR_VIA);
|
|
1434
|
+
if (typeof viaAttr !== "string" || viaAttr === "") {
|
|
1435
|
+
errors.push(new ParseError(
|
|
1436
|
+
`origin.aggregate @agg:collect on ${obj.name}.${field.name}: @via is required on a ` +
|
|
1437
|
+
`whole-object rollup — there is no @of entity to infer the relationship from.`,
|
|
1438
|
+
{ code: "ERR_COLLECT_WHOLE_OBJECT", source: src }));
|
|
1439
|
+
continue;
|
|
1440
|
+
}
|
|
1441
|
+
// @distinct is refused on the object form. It is NOT an engine limit
|
|
1442
|
+
// (both engines dedupe JSON objects); it is a guaranteed no-op
|
|
1443
|
+
// whenever the value object carries the entity's primary key, which
|
|
1444
|
+
// is the common case, and a silent no-op is worse than a refusal.
|
|
1445
|
+
if (hasDistinct) {
|
|
1446
|
+
errors.push(new ParseError(
|
|
1447
|
+
`origin.aggregate @agg:collect on ${obj.name}.${field.name}: @distinct is not supported on a ` +
|
|
1448
|
+
`whole-object rollup (it is a no-op whenever the value object carries the primary key).`,
|
|
1449
|
+
{ code: "ERR_COLLECT_WHOLE_OBJECT", source: src }));
|
|
1450
|
+
continue;
|
|
1451
|
+
}
|
|
1452
|
+
// One walk yields both the hops (cardinality) and the terminal
|
|
1453
|
+
// entity (@orderBy keys, member resolution). An invalid @via
|
|
1454
|
+
// (e.g. single-segment "A") returns undefined having already
|
|
1455
|
+
// pushed its own error, so everything downstream is skipped and
|
|
1456
|
+
// no second, misleadingly-scoped error is emitted.
|
|
1457
|
+
const via = _validateViaPath(viaAttr, root, obj, field.name, src, errors);
|
|
1458
|
+
if (via !== undefined) {
|
|
1459
|
+
_checkAggregateCardinality(via.hops, obj, field.name, src, errors);
|
|
1460
|
+
// @orderBy keys resolve against the @via TERMINAL entity, not @of.
|
|
1461
|
+
_validateOrderByKeys(orderBy, via.terminal, obj, field.name, "origin.aggregate @agg:collect", src, errors,
|
|
1462
|
+
"ERR_COLLECT_WHOLE_OBJECT");
|
|
1463
|
+
if (refTarget !== undefined) {
|
|
1464
|
+
_checkCollectMembers(refTarget, via.terminal, obj, field, src, errors);
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1257
1467
|
continue;
|
|
1258
1468
|
}
|
|
1259
1469
|
// NOTE (FR-024 B6): NO extends/origin agreement check on aggregates —
|
|
@@ -1274,9 +1484,9 @@ export function validateOriginPaths(root: MetaData): ParseError[] {
|
|
|
1274
1484
|
// ADR-0039: own — origin.* never inherits (ADR-0029).
|
|
1275
1485
|
const via = origin.ownAttr(ORIGIN_AGGREGATE_ATTR_VIA);
|
|
1276
1486
|
if (typeof via === "string" && via !== "") {
|
|
1277
|
-
const
|
|
1278
|
-
if (
|
|
1279
|
-
_checkAggregateCardinality(hops, obj, field.name, src, errors);
|
|
1487
|
+
const walked = _validateViaPath(via, root, obj, field.name, src, errors);
|
|
1488
|
+
if (walked !== undefined) {
|
|
1489
|
+
_checkAggregateCardinality(walked.hops, obj, field.name, src, errors);
|
|
1280
1490
|
}
|
|
1281
1491
|
continue;
|
|
1282
1492
|
}
|
|
@@ -1369,8 +1579,8 @@ export function validateOriginPaths(root: MetaData): ParseError[] {
|
|
|
1369
1579
|
// @via — explicit (validated + cardinality) or single-hop-unique inferred.
|
|
1370
1580
|
const via = origin.ownAttr(ORIGIN_FIRST_ATTR_VIA);
|
|
1371
1581
|
if (typeof via === "string" && via !== "") {
|
|
1372
|
-
const
|
|
1373
|
-
if (
|
|
1582
|
+
const walked = _validateViaPath(via, root, obj, field.name, src, errors);
|
|
1583
|
+
if (walked !== undefined) _checkAggregateCardinality(walked.hops, obj, field.name, src, errors);
|
|
1374
1584
|
} else if (ofTarget !== undefined) {
|
|
1375
1585
|
// (A value host never reaches here — the #210 assembly-origin
|
|
1376
1586
|
// check above already rejected origin.first on a value.)
|
|
@@ -1887,12 +2097,32 @@ export function validateRelationships(root: MetaData): ParseError[] {
|
|
|
1887
2097
|
// (defaultValidationRegistry → a declarative reference descriptor with dottedFieldPath).
|
|
1888
2098
|
|
|
1889
2099
|
// ---------------------------------------------------------------------------
|
|
1890
|
-
// index.lookup
|
|
2100
|
+
// Index-key resolution for index.lookup AND identity.secondary (#342)
|
|
2101
|
+
//
|
|
2102
|
+
// An index declares its key EXACTLY ONE of two ways: plain columns (@fields) or
|
|
2103
|
+
// a key expression (@expr, e.g. `lower(email)` / `(payload->>'device_id')`).
|
|
2104
|
+
// The registry has always said so — @expr is described as "Used INSTEAD of
|
|
2105
|
+
// @fields" — and `migrate-ts` has always implemented it that way
|
|
2106
|
+
// (`columns: expr ? [] : cols`, expected-schema.ts). Only the LOADER disagreed,
|
|
2107
|
+
// requiring @fields unconditionally, which made an expression index
|
|
2108
|
+
// unreachable: omitting @fields failed to load, and the one spelling that DID
|
|
2109
|
+
// load (@fields AND @expr) had its @fields silently discarded by the engine.
|
|
2110
|
+
//
|
|
2111
|
+
// So the two rules below are one rule — the key is @fields XOR @expr:
|
|
2112
|
+
// - NEITHER: nothing declares the key.
|
|
2113
|
+
// - BOTH: contradictory, and previously half-honored. Rejected rather than
|
|
2114
|
+
// given a precedence rule, because an accepted-but-half-ignored declaration
|
|
2115
|
+
// is exactly the silent-wrong-output the sealed strict registry exists to
|
|
2116
|
+
// prevent (cf. ERR_SQL_BODY_WITH_UNMANAGED — @sql vs @unmanaged is the same
|
|
2117
|
+
// "two mutually exclusive non-default states of one axis" shape).
|
|
2118
|
+
//
|
|
2119
|
+
// Applies to identity.secondary too: per ADR-0040 uniqueness lives in the TYPE,
|
|
2120
|
+
// so identity.secondary IS a unique index and keys itself identically. Both
|
|
2121
|
+
// carry @expr from the same db provider, and migrate-ts branches on @expr for
|
|
2122
|
+
// both — the loader was the only tier treating them differently.
|
|
1891
2123
|
//
|
|
1892
|
-
//
|
|
1893
|
-
//
|
|
1894
|
-
// ADR-0039: use children() / MetaIndex.fields() — never own* — so that a
|
|
1895
|
-
// field inherited via extends still resolves correctly.
|
|
2124
|
+
// ADR-0039: children() / MetaIndex.fields() — never own* — so a field inherited
|
|
2125
|
+
// via extends still resolves.
|
|
1896
2126
|
// ---------------------------------------------------------------------------
|
|
1897
2127
|
|
|
1898
2128
|
export function validateIndexLookupFields(root: MetaData): ParseError[] {
|
|
@@ -1903,34 +2133,77 @@ export function validateIndexLookupFields(root: MetaData): ParseError[] {
|
|
|
1903
2133
|
const effectiveFieldNames = new Set(
|
|
1904
2134
|
obj.children().filter((c) => c.type === TYPE_FIELD).map((f) => f.name),
|
|
1905
2135
|
);
|
|
1906
|
-
|
|
1907
|
-
(c) =>
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
//
|
|
1914
|
-
|
|
2136
|
+
const keyed = obj.children().filter(
|
|
2137
|
+
(c) =>
|
|
2138
|
+
(c.type === TYPE_INDEX && c.subType === INDEX_SUBTYPE_LOOKUP) ||
|
|
2139
|
+
(c.type === TYPE_IDENTITY && c.subType === IDENTITY_SUBTYPE_SECONDARY),
|
|
2140
|
+
);
|
|
2141
|
+
for (const node of keyed) {
|
|
2142
|
+
const label = `${node.type}.${node.subType}`;
|
|
2143
|
+
// PRESENCE vs CONTENT are two different questions here, and conflating them
|
|
2144
|
+
// is a bug in both directions:
|
|
2145
|
+
//
|
|
2146
|
+
// - The CONTRADICTION check needs PRESENCE. `@fields: []` alongside @expr
|
|
2147
|
+
// is still a declaration of both, and keying it on non-emptiness let the
|
|
2148
|
+
// total-discard spelling load clean while `@fields: ["x"]` + @expr was
|
|
2149
|
+
// refused — the rule missing exactly the case it exists to catch.
|
|
2150
|
+
// - The KEY-RESOLUTION check needs normalized CONTENT, via the guarded
|
|
2151
|
+
// accessor. Reading the raw attr with a cast meant a scalar `@fields: 5`
|
|
2152
|
+
// threw an uncaught TypeError out of load() in TS while the other three
|
|
2153
|
+
// ports reported a clean error.
|
|
2154
|
+
//
|
|
2155
|
+
// The guarded accessor is the fix for the second and the OBSTACLE for the
|
|
2156
|
+
// first — it collapses absent, scalar and explicit `[]` to the same `[]` —
|
|
2157
|
+
// so the two questions are asked separately and never routed through one
|
|
2158
|
+
// predicate. ADR-0039: both reads resolve through extends.
|
|
2159
|
+
const hasFieldsAttr = node.attr(IDENTITY_ATTR_FIELDS) !== undefined;
|
|
2160
|
+
// MetaIndex.fields() / MetaIdentity.fields are the same guarded read
|
|
2161
|
+
// (`Array.isArray(f) ? f : []`); never re-hand-roll it.
|
|
2162
|
+
const fields =
|
|
2163
|
+
node instanceof MetaIndex ? node.fields() : (node as MetaIdentity).fields;
|
|
2164
|
+
const exprRaw = node.attr(IDENTITY_ATTR_EXPR);
|
|
2165
|
+
const hasExpr = typeof exprRaw === "string" && exprRaw.trim().length > 0;
|
|
2166
|
+
|
|
2167
|
+
// Rule 1a: exactly one of @fields / @expr may be DECLARED.
|
|
2168
|
+
if (hasFieldsAttr && hasExpr) {
|
|
1915
2169
|
errors.push(
|
|
1916
2170
|
new ParseError(
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
2171
|
+
`${label} "${node.name}" on "${obj.name}" declares BOTH ` +
|
|
2172
|
+
`@${INDEX_ATTR_FIELDS} and @${IDENTITY_ATTR_EXPR}; they are the two ` +
|
|
2173
|
+
`mutually exclusive ways to key an index. @${IDENTITY_ATTR_EXPR} is used ` +
|
|
2174
|
+
`INSTEAD of @${INDEX_ATTR_FIELDS} — drop one. ` +
|
|
2175
|
+
`(Declaring both previously loaded but silently discarded ` +
|
|
2176
|
+
`@${INDEX_ATTR_FIELDS}.)`,
|
|
2177
|
+
{ code: "ERR_INVALID_INDEX", source: node.source },
|
|
1920
2178
|
),
|
|
1921
2179
|
);
|
|
1922
2180
|
continue;
|
|
1923
2181
|
}
|
|
1924
2182
|
|
|
2183
|
+
// Rule 1b: whichever is declared must actually supply a key.
|
|
2184
|
+
if (fields.length === 0 && !hasExpr) {
|
|
2185
|
+
errors.push(
|
|
2186
|
+
new ParseError(
|
|
2187
|
+
`${label} "${node.name}" on "${obj.name}" declares no key: ` +
|
|
2188
|
+
`it must have @${INDEX_ATTR_FIELDS} (one or more columns) or ` +
|
|
2189
|
+
`@${IDENTITY_ATTR_EXPR} (a key expression)`,
|
|
2190
|
+
{ code: "ERR_INVALID_INDEX", source: node.source },
|
|
2191
|
+
),
|
|
2192
|
+
);
|
|
2193
|
+
continue;
|
|
2194
|
+
}
|
|
1925
2195
|
// Rule 2: every named field must resolve against the entity's effective field set.
|
|
2196
|
+
// An expression index has no @fields to resolve — @expr is raw SQL over the
|
|
2197
|
+
// physical columns, deliberately not parsed here (ADR-0023 keeps the grammar
|
|
2198
|
+
// closed only where the loader owns it).
|
|
1926
2199
|
for (const fieldName of fields) {
|
|
1927
2200
|
if (!effectiveFieldNames.has(fieldName)) {
|
|
1928
2201
|
errors.push(
|
|
1929
2202
|
new ParseError(
|
|
1930
|
-
|
|
2203
|
+
`${label} "${node.name}" on "${obj.name}" references field "${fieldName}" ` +
|
|
1931
2204
|
`which does not exist on "${obj.name}". ` +
|
|
1932
2205
|
`Available fields: ${[...effectiveFieldNames].join(", ") || "(none)"}`,
|
|
1933
|
-
{ code: "ERR_INVALID_INDEX", source:
|
|
2206
|
+
{ code: "ERR_INVALID_INDEX", source: node.source },
|
|
1934
2207
|
),
|
|
1935
2208
|
);
|
|
1936
2209
|
}
|
|
@@ -49,9 +49,9 @@ export const ORIGIN_DEFINITION: ProviderDefinition = {
|
|
|
49
49
|
{
|
|
50
50
|
"type": "origin",
|
|
51
51
|
"subType": "aggregate",
|
|
52
|
-
"description": "A value reduced from the related row-set reached along a relationship path (@via) from the base entity: count/sum/avg/min/max over a column (@of); any/all predicate quantifiers over a @filter; or collect (an array rollup of @of).",
|
|
52
|
+
"description": "A value reduced from the related row-set reached along a relationship path (@via) from the base entity: count/sum/avg/min/max over a column (@of); any/all predicate quantifiers over a @filter; or collect (an array rollup — of the @of column, or of the carrying field.object's declared @objectRef value object when @of is omitted).",
|
|
53
53
|
"whenToUse": "A projection needs a value derived by reducing related rows — a count/sum/avg/min/max, a 'did any/every related row match' flag, or an array of collected values. Declare it instead of hand-writing the aggregate query — it stays consistent and regenerates.",
|
|
54
|
-
"rules": "@via may be omitted only when exactly one single-hop relationship leads from the base entity to the @of entity (single-hop-unique inference; FR-024, ADR-0029). Multi-hop paths must always be stated explicitly. @of is required for count/sum/avg/min/max
|
|
54
|
+
"rules": "@via may be omitted only when exactly one single-hop relationship leads from the base entity to the @of entity (single-hop-unique inference; FR-024, ADR-0029). Multi-hop paths must always be stated explicitly. @of is required for count/sum/avg/min/max and forbidden for any/all (which quantify over rows via @filter, not a column). @filter is required for any/all. The field must be isArray:true for collect and isArray:false for every other @agg. @distinct and @orderBy are collect-only. On @agg:collect @of is OPTIONAL: omitting it declares a WHOLE-OBJECT rollup, which collects each related row as the carrying field's declared @objectRef value object instead of one scalar column. A whole-object rollup requires a field.object carrying @objectRef, requires that @objectRef to name an object.value, requires an explicit @via (there is no @of entity to infer the path from), and refuses @distinct. Its @orderBy keys resolve against the @via TERMINAL entity, not the head or a middle hop. Its value-object members bind to the terminal entity's fields BY NAME — member name == terminal field name, deliberately NOT extends, so one value object stays collectable from two different entities — and every member must match a terminal field agreeing on BOTH field.<subType> and array-ness.",
|
|
55
55
|
"children": [
|
|
56
56
|
{
|
|
57
57
|
"type": "attr",
|
|
@@ -69,7 +69,7 @@ export const ORIGIN_DEFINITION: ProviderDefinition = {
|
|
|
69
69
|
"all",
|
|
70
70
|
"collect"
|
|
71
71
|
],
|
|
72
|
-
"description": "The reducing function applied over the related row-set: count/sum/avg/min/max (numeric/ordinal reduces over @of); any/all (predicate quantifiers over @filter — @of forbidden; empty set → any=false, all=true); collect (array rollup of @of
|
|
72
|
+
"description": "The reducing function applied over the related row-set: count/sum/avg/min/max (numeric/ordinal reduces over @of); any/all (predicate quantifiers over @filter — @of forbidden; empty set → any=false, all=true); collect (array rollup — of the @of column, or of the carrying field.object's declared @objectRef value object when @of is omitted; the field must be isArray)."
|
|
73
73
|
},
|
|
74
74
|
{
|
|
75
75
|
"type": "attr",
|
|
@@ -77,7 +77,7 @@ export const ORIGIN_DEFINITION: ProviderDefinition = {
|
|
|
77
77
|
"name": "of",
|
|
78
78
|
"min": 0,
|
|
79
79
|
"max": 1,
|
|
80
|
-
"description": "Dotted Entity.field reference identifying the column being aggregated (e.g. 'Week.durationMinutes'). Required for count/sum/avg/min/max
|
|
80
|
+
"description": "Dotted Entity.field reference identifying the column being aggregated (e.g. 'Week.durationMinutes'). Required for count/sum/avg/min/max; OPTIONAL for collect, where absent means a whole-object rollup of the field's declared @objectRef value object; forbidden for any/all (which quantify over rows via @filter, not a column)."
|
|
81
81
|
},
|
|
82
82
|
{
|
|
83
83
|
"type": "attr",
|
|
@@ -101,7 +101,7 @@ export const ORIGIN_DEFINITION: ProviderDefinition = {
|
|
|
101
101
|
"name": "distinct",
|
|
102
102
|
"min": 0,
|
|
103
103
|
"max": 1,
|
|
104
|
-
"description": "Set (collect-only) to dedupe collected values (set semantics)."
|
|
104
|
+
"description": "Set (collect-only) to dedupe collected values (set semantics). Not supported on a whole-object collect (@of omitted): it is a guaranteed no-op whenever the value object carries the primary key, and a silent no-op is worse than a refusal."
|
|
105
105
|
},
|
|
106
106
|
{
|
|
107
107
|
"type": "attr",
|
package/src/registry-manifest.ts
CHANGED
|
@@ -112,7 +112,7 @@ interface ManifestType {
|
|
|
112
112
|
* constant read `"0.10"`). Bump with that script — never by hand — so the manifest and
|
|
113
113
|
* all four port constants move together.
|
|
114
114
|
*/
|
|
115
|
-
export const METAMODEL_VERSION = "0.
|
|
115
|
+
export const METAMODEL_VERSION = "0.12";
|
|
116
116
|
|
|
117
117
|
/** The full canonical manifest. All collections are sorted for byte-stability. */
|
|
118
118
|
interface RegistryManifest {
|
|
@@ -27,14 +27,18 @@
|
|
|
27
27
|
// `scopeRanges` below recovers the enclosing `"<type>.<subType>"` for each occurrence, so
|
|
28
28
|
// one pass over the document answers both correctly.
|
|
29
29
|
//
|
|
30
|
-
// CANONICAL JSON ONLY
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
30
|
+
// CANONICAL JSON ONLY — YAML lives in `core/vocabulary-rewrite-yaml.ts`, not here. This
|
|
31
|
+
// module is reachable from `src/index.ts`, which may not import the Node-only `yaml`
|
|
32
|
+
// package, so the YAML arm sits behind its own package subpath and `meta upgrade`
|
|
33
|
+
// dynamic-imports it. The split is a bundling constraint, not a difference in contract: both
|
|
34
|
+
// arms return the same result shape, scope every occurrence the same way, and refuse the
|
|
35
|
+
// same retirements.
|
|
36
|
+
//
|
|
37
|
+
// The reason YAML gets a parser and this arm does not: a hand-rolled YAML mode was tried
|
|
38
|
+
// here first and shipped a file-corrupting bug — a multi-item block sequence lost every item
|
|
39
|
+
// but the first, because a scanner stops at a newline — while the dominant authoring style
|
|
40
|
+
// (flow mappings, `{ name: x, readOnly: true }`) was not matched at all. YAML's value extent
|
|
41
|
+
// is not derivable by scanning; JSON's is.
|
|
38
42
|
//
|
|
39
43
|
// IT REFUSES WHAT IT CANNOT KNOW. A retirement with no `rewrite` (`@status: abandoned`) is
|
|
40
44
|
// reported, never guessed at. Deleting the node, retyping it, and fixing the residue it
|