Skip to content

Commit 4f221a6

Browse files
dmealingclaude
andcommitted
fix: cross-port deferred-followups — filterable drift, payload subType, attr-name dots
Closes the substantive items deferred from the prior pre-merge gate (see merge 738e07f's body). Three new conformance fixtures lock the behavior so the drift can't silently come back. Fixtures: - loader-filterable-on-db-indexed-no-warning — @filterable + @db.indexed on a field exempts the no-index warning (parity with TS validation-passes + Python _validate_filterable_has_index). - warning-filterable-inherited-without-index — abstract base declares @filterable email with no covering identity; concrete child inherits the field; warning fires on both (parity with TS effective-iteration). - error-template-payload-ref-not-value — template.prompt @payloadRef targeting object.entity (not object.value) emits ERR_INVALID_TEMPLATE (parity with FR-004 R2; Java + Python already correct, TS + C# now match). Java fix — warnFilterableWithoutIndex was own-only and ignored @db.indexed: - ValidationPhase.checkFilterableFields now iterates effective fields and effective identities (`getChildren(..., true)`), matching TS reference (validation-passes.ts:140). Without this, a concrete entity inheriting a @filterable field via BaseEntity is silently skipped. - Adds ATTR_DB_INDEXED constant + the explicit @db.indexed exemption check (validation-passes.ts:155). Java fix — validateName rejected dotted attr names: - MetaData.validateName now permits dotted attr names (e.g., @db.indexed, @db.column) and FQN-qualified attr names (@pkg::name::attr) when the type is "attr". TS + Python already accept these; Java's stricter pattern was a real cross-port drift that surfaced when the @db.indexed fixture was added. Other types (object/field/etc.) keep their strict identifier pattern unchanged. TS + C# fix — template @payloadRef required tightening to object.value: - validation-passes.ts + ValidationPasses.cs now require c.subType === OBJECT_SUBTYPE_VALUE in the payload lookup. Error message updated to "does not resolve to an object.value at root" matching Python's wording. FR-004 R2 is unambiguous (payload must be object.value); Java + Python already enforced this. Verification: - Java metadata module: 647/647 green - TS metadata package: 1219/1219 green - Python conformance: 88/88 green - C# all 4 projects: 535/535 green Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 738e07f commit 4f221a6

11 files changed

Lines changed: 198 additions & 7 deletions

File tree

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[
2+
{ "code": "ERR_INVALID_TEMPLATE" }
3+
]
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"metadata.root": {
3+
"package": "acme::ai",
4+
"children": [
5+
{
6+
"object.entity": {
7+
"name": "Npc",
8+
"children": [
9+
{ "field.string": { "name": "name" } },
10+
{ "identity.primary": { "@fields": "name" } }
11+
]
12+
}
13+
},
14+
{
15+
"template.prompt": {
16+
"name": "npcTurn",
17+
"@payloadRef": "Npc",
18+
"@textRef": "npc/turn",
19+
"@format": "xml"
20+
}
21+
}
22+
]
23+
}
24+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
{
2+
"metadata.root": {
3+
"package": "acme",
4+
"children": [
5+
{
6+
"object.entity": {
7+
"name": "Subscriber",
8+
"children": [
9+
{
10+
"field.long": {
11+
"name": "id"
12+
}
13+
},
14+
{
15+
"field.string": {
16+
"name": "email",
17+
"@db.indexed": true,
18+
"@filterable": true
19+
}
20+
},
21+
{
22+
"identity.primary": {
23+
"@fields": [
24+
"id"
25+
]
26+
}
27+
}
28+
]
29+
}
30+
}
31+
]
32+
}
33+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
"metadata.root": {
3+
"package": "acme",
4+
"children": [
5+
{
6+
"object.entity": {
7+
"name": "Subscriber",
8+
"children": [
9+
{ "field.long": { "name": "id" } },
10+
{
11+
"field.string": {
12+
"name": "email",
13+
"@filterable": true,
14+
"@db.indexed": true
15+
}
16+
},
17+
{ "identity.primary": { "@fields": "id" } }
18+
]
19+
}
20+
}
21+
]
22+
}
23+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
[
2+
"[filterable-without-index] field \"BaseEntity.email\" has @filterable: true but is not part of any identity. Filtering on this field will sequential-scan. Add @db.indexed: true to the field (when supported), or remove @filterable: true.",
3+
"[filterable-without-index] field \"Subscriber.email\" has @filterable: true but is not part of any identity. Filtering on this field will sequential-scan. Add @db.indexed: true to the field (when supported), or remove @filterable: true."
4+
]
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
{
2+
"metadata.root": {
3+
"package": "acme",
4+
"children": [
5+
{
6+
"object.entity": {
7+
"name": "BaseEntity",
8+
"abstract": true,
9+
"children": [
10+
{
11+
"field.long": {
12+
"name": "id"
13+
}
14+
},
15+
{
16+
"field.string": {
17+
"name": "email",
18+
"@filterable": true
19+
}
20+
},
21+
{
22+
"identity.primary": {
23+
"@fields": [
24+
"id"
25+
]
26+
}
27+
}
28+
]
29+
}
30+
},
31+
{
32+
"object.entity": {
33+
"name": "Subscriber",
34+
"extends": "BaseEntity"
35+
}
36+
}
37+
]
38+
}
39+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
{
2+
"metadata.root": {
3+
"package": "acme",
4+
"children": [
5+
{
6+
"object.entity": {
7+
"name": "BaseEntity",
8+
"abstract": true,
9+
"children": [
10+
{ "field.long": { "name": "id" } },
11+
{
12+
"field.string": {
13+
"name": "email",
14+
"@filterable": true
15+
}
16+
},
17+
{ "identity.primary": { "@fields": "id" } }
18+
]
19+
}
20+
},
21+
{
22+
"object.entity": {
23+
"name": "Subscriber",
24+
"extends": "BaseEntity"
25+
}
26+
}
27+
]
28+
}
29+
}

server/csharp/MetaObjects/Loader/ValidationPasses.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -887,10 +887,10 @@ public static IReadOnlyList<MetaError> ValidateTemplatePayloadRefs(MetaData root
887887

888888
var payload = root.OwnChildren()
889889
.FirstOrDefault(c => c.Type == TYPE_OBJECT && c.Name == payloadRef);
890-
if (payload is null)
890+
if (payload is null || payload.SubType != OBJECT_SUBTYPE_VALUE)
891891
{
892892
errors.Add(new MetaError(
893-
$"template \"{tmpl.Name}\" @payloadRef \"{payloadRef}\" does not resolve to a known object in this model",
893+
$"template \"{tmpl.Name}\" @payloadRef \"{payloadRef}\" does not resolve to an object.value at root",
894894
ErrorCode.ERR_INVALID_TEMPLATE));
895895
continue;
896896
}

server/java/metadata/src/main/java/com/metaobjects/MetaData.java

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,26 @@ public MetaData(String type, String subType, String name ) {
354354
* Validate MetaData name during construction
355355
*/
356356
private void validateName(String name) {
357+
// Attributes may use dotted names to namespace persistence-concern
358+
// attrs from core attrs (e.g., @db.indexed, @db.column). TS + Python
359+
// accept these; the cross-port contract requires Java match. Also
360+
// accept FQN-qualified attr names (`pkg::name::attr` form) which the
361+
// loader synthesises for cross-package attr lookups.
362+
if ("attr".equals(type)) {
363+
if (name.contains("::")) {
364+
for (String part : name.split("::")) {
365+
if (!part.matches("^[a-zA-Z][a-zA-Z0-9_.]*$")) {
366+
throw new IllegalArgumentException(
367+
"Invalid attr name part '" + part + "' in '" + name
368+
+ "': must follow pattern ^[a-zA-Z][a-zA-Z0-9_.]*$");
369+
}
370+
}
371+
} else if (!name.matches("^[a-zA-Z][a-zA-Z0-9_.]*$")) {
372+
throw new IllegalArgumentException(
373+
"Invalid attr name '" + name + "': must follow pattern ^[a-zA-Z][a-zA-Z0-9_.]*$");
374+
}
375+
return;
376+
}
357377
// Loaders and views can have more flexible naming (allow hyphens). Views
358378
// also accept package-qualified names because the loader synthesises
359379
// FQNs like `pkg::Entity::currency1` for unnamed children.

server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -784,6 +784,7 @@ private static void validateIdentityNode(MetaIdentity identity) {
784784
// =========================================================================
785785

786786
private static final String ATTR_FILTERABLE = "filterable";
787+
private static final String ATTR_DB_INDEXED = "db.indexed";
787788

788789
private static final java.util.Set<String> OPS_FOR_BOOLEAN =
789790
java.util.Set.of("eq", "ne", "isNull");
@@ -933,24 +934,38 @@ static void warnFilterableWithoutIndex(MetaRoot root, MetaDataLoader loader) {
933934
}
934935

935936
private static void checkFilterableFields(MetaObject obj, MetaDataLoader loader) {
936-
List<MetaField> indexedFieldNames = obj.getChildren(MetaField.class, false);
937+
// Use effective (includes inherited via extends:/super:) so a child
938+
// entity inheriting a @filterable field via BaseEntity is also gated.
939+
// Mirrors TS validation-passes.ts:140 (`const effective = obj.children()`).
940+
List<MetaField> fields = obj.getChildren(MetaField.class, true);
937941
java.util.Set<String> indexed = new java.util.HashSet<>();
938-
for (MetaData child : obj.getChildren(MetaData.class, false)) {
942+
for (MetaData child : obj.getChildren(MetaData.class, true)) {
939943
if (!(child instanceof MetaIdentity)) continue;
940944
MetaIdentity identity = (MetaIdentity) child;
941945
if (!identity.hasMetaAttr(MetaIdentity.ATTR_FIELDS, false)) continue;
942946
Object raw = identity.getMetaAttr(MetaIdentity.ATTR_FIELDS, false).getValue();
943947
collectIdentityFields(raw, indexed);
944948
}
945949

946-
for (MetaField field : indexedFieldNames) {
950+
for (MetaField field : fields) {
947951
if (!field.hasMetaAttr(ATTR_FILTERABLE, false)) continue;
948952
Object v = field.getMetaAttr(ATTR_FILTERABLE, false).getValue();
949953
boolean filterable =
950954
(v instanceof Boolean) ? (Boolean) v
951955
: (v instanceof String) ? "true".equalsIgnoreCase((String) v)
952956
: false;
953957
if (!filterable) continue;
958+
// @db.indexed: true is an explicit escape hatch — author asserts a
959+
// backing index exists (or will, when supported). Mirrors TS
960+
// validation-passes.ts:155.
961+
if (field.hasMetaAttr(ATTR_DB_INDEXED, false)) {
962+
Object iv = field.getMetaAttr(ATTR_DB_INDEXED, false).getValue();
963+
boolean dbIndexed =
964+
(iv instanceof Boolean) ? (Boolean) iv
965+
: (iv instanceof String) ? "true".equalsIgnoreCase((String) iv)
966+
: false;
967+
if (dbIndexed) continue;
968+
}
954969
if (indexed.contains(field.getShortName())) continue;
955970
String objName = obj.getShortName() != null ? obj.getShortName() : obj.getName();
956971
loader.addWarning(

0 commit comments

Comments
 (0)