Skip to content

Commit efc8544

Browse files
dmealingclaude
andcommitted
feat(#207): mirror the projection @filter fail-closed validation cross-port
Complete the #207 cross-port contract: a projection row-scope @filter whose field-ref is dangling (names no declared field) or aggregate-derived (origin.aggregate / origin.first / origin.collection — a WHERE runs before aggregation, so it cannot see an aggregate) is now a fail-closed load error (ERR_BAD_ATTR_FILTER) identically in every port (C#, Java, Python — mirroring the TS reference validateProjectionFilter; Kotlin rides Java's loader), not just TS. Own accessors throughout (the @filter is declared locally; origin.* never inherits, ADR-0029/0039). Gated by two shared negative conformance fixtures (error-projection-filter- dangling / -aggregate) that every port's loader runs, so the contract can't drift. The TS-only hardening (operator-band + malformed-compose-shape checks) and the SQL WHERE lowering stay TS-side (ADR-0015) — the cross-port contract is the two core reference checks. Verified per port: TS conformance green, Python conformance green, C# conformance 768 green, Java metadata 1173 green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NGQ7oSuNcjhsMHWwZzhBwr
1 parent 7f5d8f7 commit efc8544

11 files changed

Lines changed: 402 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
1212
A projection (`object.projection`) can now declare a row-scope `@filter` — a portable `attr.filter` object (`eq`/`ne`/`gt`/`gte`/`lt`/`lte`/`like`/`in`/`isNull` with `and`/`or`, desugared to canonical `{ field: { op: value } }` at parse time) selecting which rows the derived view returns. It lowers to an outer SQL `WHERE`, the metadata-managed way to express soft-delete / status / type views instead of a hand-written unmanaged view (which is drift, invisible to `verify --db`). Placement mirrors `origin.aggregate @filter` exactly: a `filter`-subtype attr on `object.projection` (the predicate is a LOGICAL derivation, so it lives on the object, not on `source.rdb` — it survives non-RDB lowerings).
1313

1414
- **Registered in all five ports** (TS / C# / Java / Python / Kotlin), `registry-conformance`-gated (`object.projection` now carries the `@filter` attr in the byte-matched manifest), with a `fixtures/conformance/projection-filter` corpus fixture proving it loads + canonical-serializes identically everywhere.
15-
- **Resolution (TS lowering, ADR-0015 — schema is TS-owned).** Each filter field-ref names one of the projection's own declared fields and resolves against its `SelectColumn`: a **passthrough** (base OR joined) → `sourceAlias.sourceColumn` (so `WHERE joined.status IS NULL OR joined.status = 1` works across a join); a **computed** (`origin.computed`) field → its inlined expression. An **aggregate-derived** ref (`origin.aggregate`/`origin.first`) or a **dangling** ref (naming no declared field) is a fail-closed load error (`ERR_BAD_ATTR_FILTER`) — a `WHERE` runs before aggregation, so it cannot see an aggregate (post-aggregate filtering is a separate future `HAVING`). The `WHERE` renders after the joins and BEFORE any `GROUP BY`, composing with `origin.first` correlated subqueries.
15+
- **Fail-closed validation (cross-port).** A `@filter` field-ref must name one of the projection's own declared fields, and that field must be addressable in a `WHERE`. An **aggregate-derived** ref (`origin.aggregate`/`origin.first`/`origin.collection`) or a **dangling** ref (naming no declared field) is a fail-closed load error (`ERR_BAD_ATTR_FILTER`) — a `WHERE` runs before aggregation, so it cannot see an aggregate (post-aggregate filtering is a separate future `HAVING`). This dangling/aggregate-derived check runs identically in **all five ports** (gated by shared negative conformance fixtures). TS additionally hardens the load (rejecting a non-array `and`/`or`, an empty op-object, and an op illegal for the field's subtype), so a malformed filter fails at load rather than silently dropping the predicate or crashing the view synthesizer.
16+
- **Resolution + lowering (TS-owned, ADR-0015).** The SQL `WHERE` lowering lives in TS (schema is TS-owned). Each field-ref resolves against its `SelectColumn`: a **passthrough** (base OR joined) → `sourceAlias.sourceColumn` (so `WHERE joined.status IS NULL OR joined.status = 1` works across a join); a **computed** (`origin.computed`) field → its inlined expression. A field clause may carry multiple ops (a range `{ gte, lte }`), each AND-composed. The `WHERE` renders after the joins and BEFORE any `GROUP BY`, composing with `origin.first` correlated subqueries.
1617
- **v1 scope: projections only.** A write-through entity read-view (`isWriteThrough`) is excluded by construction — the attr is registered on `object.projection`, not `object.entity` — because a filtered *replica* view breaks read-your-writes totality (write a row, read it back through the filter, it's gone).
1718
- Gated by golden extract+emit tests, the loader validation suite, and a **real-Postgres round-trip** (emit → apply → introspect → re-diff EMPTY, plus a filtered view that returns only the matching subset). Reuses the shipped `attr.filter` desugar + the `ViewFilterClause` renderer (extended with an inlined-expression comparison node for computed refs).
1819

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
[{ "code": "ERR_BAD_ATTR_FILTER" }]
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
{
2+
"metadata.root": {
3+
"package": "demo",
4+
"children": [
5+
{
6+
"object.entity": {
7+
"name": "Order",
8+
"children": [
9+
{ "source.rdb": { "@table": "orders" } },
10+
{ "field.long": { "name": "id" } },
11+
{ "identity.primary": { "name": "id", "@fields": ["id"] } },
12+
{ "relationship.association": { "name": "lines", "@objectRef": "Line", "@cardinality": "many" } }
13+
]
14+
}
15+
},
16+
{
17+
"object.entity": {
18+
"name": "Line",
19+
"children": [
20+
{ "source.rdb": { "@table": "lines" } },
21+
{ "field.long": { "name": "id" } },
22+
{ "field.long": { "name": "orderId" } },
23+
{ "identity.primary": { "name": "id", "@fields": ["id"] } },
24+
{ "identity.reference": { "name": "ref_order", "@fields": ["orderId"], "@references": "Order" } }
25+
]
26+
}
27+
},
28+
{
29+
"object.projection": {
30+
"name": "OrderSummary",
31+
"@filter": { "lineCount": { "gt": 0 } },
32+
"children": [
33+
{ "source.rdb": { "@kind": "view", "@view": "v_order_summary" } },
34+
{ "field.long": { "name": "id", "extends": "demo::Order.id" } },
35+
{ "field.int": { "name": "lineCount", "children": [
36+
{ "origin.aggregate": { "@agg": "count", "@of": "demo::Line.id", "@via": "demo::Order.lines" } } ] } },
37+
{ "identity.primary": { "name": "id", "extends": "demo::Order.id" } }
38+
]
39+
}
40+
}
41+
]
42+
}
43+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
["metaobjects-core-types", "metaobjects-db"]
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
[{ "code": "ERR_BAD_ATTR_FILTER" }]
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
{
2+
"metadata.root": {
3+
"package": "demo",
4+
"children": [
5+
{
6+
"object.entity": {
7+
"name": "Order",
8+
"children": [
9+
{ "source.rdb": { "@table": "orders" } },
10+
{ "field.uuid": { "name": "id" } },
11+
{ "field.string": { "name": "status" } },
12+
{ "identity.primary": { "name": "id", "@fields": ["id"] } }
13+
]
14+
}
15+
},
16+
{
17+
"object.projection": {
18+
"name": "OrdersView",
19+
"@filter": { "nope": { "eq": "x" } },
20+
"children": [
21+
{ "source.rdb": { "@kind": "view", "@view": "v_orders_view" } },
22+
{ "field.uuid": { "name": "id", "extends": "demo::Order.id" } },
23+
{ "field.string": { "name": "status", "extends": "demo::Order.status" } },
24+
{ "identity.primary": { "name": "id", "extends": "demo::Order.id" } }
25+
]
26+
}
27+
}
28+
]
29+
}
30+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
["metaobjects-core-types", "metaobjects-db"]

server/csharp/MetaObjects/Loader/MetaDataLoader.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -502,6 +502,10 @@ public LoadResult Load(IReadOnlyList<IMetaDataSource> sources)
502502
// Pass 7: dataGrid @filter value validation (field filterable + op allowed)
503503
errors.AddRange(ValidationPasses.ValidateDataGridFilterValues(root));
504504

505+
// Pass 7b (#207): projection view-level @filter — a dangling or
506+
// aggregate-derived field-ref fails closed with ERR_BAD_ATTR_FILTER.
507+
errors.AddRange(ValidationPasses.ValidateProjectionFilter(root));
508+
505509
// Pass 8: @storage cross-attribute validation on field.object
506510
errors.AddRange(ValidationPasses.ValidateFieldObjectStorage(root));
507511

server/csharp/MetaObjects/Loader/ValidationPasses.cs

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1485,6 +1485,118 @@ private static void CheckFilterClauses(
14851485
}
14861486
}
14871487

1488+
// =========================================================================
1489+
// Pass 7b: ValidateProjectionFilter (#207)
1490+
// A view-level @filter on object.projection may only reference the
1491+
// projection's OWN declared, addressable fields. Two cross-port-gated
1492+
// checks, each fail-closed with ERR_BAD_ATTR_FILTER:
1493+
// - dangling ref — a field-ref naming no OWN declared field;
1494+
// - aggregate-derived ref — a field-ref naming an OWN field whose origin
1495+
// child is aggregate-derived (origin subType OTHER than
1496+
// passthrough/computed; a plain field with no origin is addressable).
1497+
// A view WHERE runs before aggregation, so it cannot filter an aggregate.
1498+
//
1499+
// Operator-band + malformed-compose-shape checks are TS-reference-only
1500+
// hardening, NOT gated cross-port — intentionally omitted here (the
1501+
// compose recursion skips non-array/non-object shapes silently).
1502+
//
1503+
// Own-attrs / own-children only: the @filter is declared locally on the
1504+
// projection, and origin.* never inherits (ADR-0029 / ADR-0039).
1505+
//
1506+
// Ported from typescript/packages/metadata/src/loader/validation-passes.ts
1507+
// validateProjectionFilter + checkProjectionFilterRefs.
1508+
// =========================================================================
1509+
1510+
public static IReadOnlyList<MetaError> ValidateProjectionFilter(MetaData root)
1511+
{
1512+
var errors = new List<MetaError>();
1513+
1514+
foreach (var obj in root.OwnChildren()
1515+
.Where(c => c.Type == TYPE_OBJECT && c.SubType == OBJECT_SUBTYPE_PROJECTION))
1516+
{
1517+
// ADR-0039: own — the @filter is declared locally on this projection.
1518+
// Non-object shapes are rejected by the attr-schema check (FilterAttr).
1519+
if (obj.OwnAttr(OBJECT_PROJECTION_ATTR_FILTER)
1520+
is not IReadOnlyDictionary<string, object?> filter)
1521+
{
1522+
continue;
1523+
}
1524+
1525+
// Classify the projection's OWN fields (the declared set IS the exposure —
1526+
// FR-024 / ADR-0028): a field is aggregate-derived when it carries an OWN
1527+
// origin child whose subType is OTHER than passthrough/computed. origin.*
1528+
// never inherits (ADR-0029), so the origin read is own.
1529+
var declared = new HashSet<string>(StringComparer.Ordinal);
1530+
var aggregateDerived = new HashSet<string>(StringComparer.Ordinal);
1531+
foreach (var f in obj.OwnChildren().Where(c => c.Type == TYPE_FIELD))
1532+
{
1533+
declared.Add(f.Name);
1534+
var origin = f.OwnChildren().FirstOrDefault(c => c.Type == TYPE_ORIGIN);
1535+
if (origin is not null &&
1536+
origin.SubType != ORIGIN_SUBTYPE_PASSTHROUGH &&
1537+
origin.SubType != ORIGIN_SUBTYPE_COMPUTED)
1538+
{
1539+
aggregateDerived.Add(f.Name);
1540+
}
1541+
}
1542+
1543+
CheckProjectionFilterRefs(filter, declared, aggregateDerived, obj.Name, obj.Source, errors);
1544+
}
1545+
1546+
return errors.AsReadOnly();
1547+
}
1548+
1549+
private static void CheckProjectionFilterRefs(
1550+
IReadOnlyDictionary<string, object?> filter,
1551+
HashSet<string> declared,
1552+
HashSet<string> aggregateDerived,
1553+
string projectionName,
1554+
ErrorSource source,
1555+
List<MetaError> errors)
1556+
{
1557+
foreach (var (key, clause) in filter)
1558+
{
1559+
if (key == FILTER_COMPOSE_OR || key == FILTER_COMPOSE_AND)
1560+
{
1561+
// Compose key → recurse into each sub-clause object. A non-array /
1562+
// non-object element is skipped silently (shape checks are TS-only).
1563+
if (clause is IReadOnlyList<object?> subList)
1564+
{
1565+
foreach (var sub in subList)
1566+
{
1567+
if (sub is IReadOnlyDictionary<string, object?> subFilter)
1568+
{
1569+
CheckProjectionFilterRefs(
1570+
subFilter, declared, aggregateDerived, projectionName, source, errors);
1571+
}
1572+
}
1573+
}
1574+
continue;
1575+
}
1576+
1577+
if (!declared.Contains(key))
1578+
{
1579+
errors.Add(new MetaError(
1580+
$"projection \"{projectionName}\" @filter references \"{key}\", which is not a declared " +
1581+
"field of the projection. A view-level @filter may only reference the projection's own " +
1582+
"declared fields.",
1583+
ErrorCode.ERR_BAD_ATTR_FILTER,
1584+
Envelope: source));
1585+
continue;
1586+
}
1587+
1588+
if (aggregateDerived.Contains(key))
1589+
{
1590+
errors.Add(new MetaError(
1591+
$"projection \"{projectionName}\" @filter references \"{key}\", an aggregate-derived field. " +
1592+
"A view-level WHERE runs before aggregation, so it cannot filter on an aggregate. Filter on " +
1593+
"a passthrough or computed field instead.",
1594+
ErrorCode.ERR_BAD_ATTR_FILTER,
1595+
Envelope: source));
1596+
}
1597+
}
1598+
}
1599+
14881600
private static void WalkAttrSchema(
14891601
MetaData node,
14901602
TypeRegistry registry,

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

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,8 @@ public static void run(MetaRoot root, MetaDataLoader loader) {
210210
// FR-024 B3 — projection identity pass-through + key correspondence.
211211
pass(collected, () -> validateIdentityPassthrough(root));
212212
pass(collected, () -> validateDataGridLayouts(root));
213+
// #207 — projection row-scope @filter field-ref validation (fail-closed).
214+
pass(collected, () -> validateProjectionFilter(root));
213215
pass(collected, () -> validateTemplates(root));
214216
pass(collected, () -> validateEntityHasPrimaryIdentity(root, loader));
215217
pass(collected, () -> validateFilterableHasSupportedOps(root));
@@ -2278,6 +2280,101 @@ private static java.util.Set<String> allowedOpsFor(MetaField field) {
22782280
return band.isEmpty() ? com.metaobjects.query.FilterOps.OPS_STRING : band;
22792281
}
22802282

2283+
// =========================================================================
2284+
// #207 — projection row-scope @filter (view-level WHERE) reference validation
2285+
//
2286+
// A projection's @filter (a portable attr.filter object) scopes which rows the
2287+
// derived view returns. Its field refs must name the projection's OWN declared
2288+
// fields, and each must be ADDRESSABLE in a WHERE:
2289+
// - a plain (extends-bound / no-origin) or origin.passthrough or origin.computed
2290+
// field → addressable (a base/joined column, or an inlined row-level expression).
2291+
// - an aggregate-derived field (origin.aggregate / origin.first / origin.collection —
2292+
// anything OTHER than passthrough/computed) → NOT addressable: a WHERE runs before
2293+
// aggregation, so it cannot see an aggregate. Fail-closed → ERR_BAD_ATTR_FILTER.
2294+
// - a ref naming no declared field → dangling → ERR_BAD_ATTR_FILTER.
2295+
//
2296+
// Cross-port: mirrors TS validation-passes.ts (validateProjectionFilter). Only the two
2297+
// CORE checks (dangling ref + aggregate-derived ref) are gated cross-port here; the TS
2298+
// reference's operator-band + malformed-compose-shape checks are TS-only hardening and
2299+
// are deliberately NOT mirrored (see fixtures/conformance/error-projection-filter-*).
2300+
//
2301+
// Own-attrs/own-children only: the @filter is declared locally (registered on
2302+
// object.projection alone), and origin.* never inherits (ADR-0029).
2303+
// =========================================================================
2304+
2305+
static void validateProjectionFilter(MetaRoot root) {
2306+
for (MetaData rootChild : root.getChildren(MetaData.class, false)) {
2307+
if (!(rootChild instanceof MetaObject)) continue;
2308+
MetaObject obj = (MetaObject) rootChild;
2309+
if (!MetaObject.SUBTYPE_PROJECTION.equals(obj.getSubType())) continue;
2310+
// ADR-0039: own — the @filter is declared locally on this projection.
2311+
if (!obj.hasMetaAttr(MetaObject.ATTR_FILTER, false)) continue;
2312+
Object raw = obj.getMetaAttr(MetaObject.ATTR_FILTER, false).getValue();
2313+
// A non-object shape is rejected by the attr schema check (FilterAttribute).
2314+
if (!(raw instanceof java.util.Map)) continue;
2315+
2316+
// Classify the projection's OWN fields (the declared set IS the exposure —
2317+
// FR-024/ADR-0028): aggregate-derived-ness by the field's OWN origin child.
2318+
// origin.* never inherits (ADR-0029), so both reads are own (category 4).
2319+
java.util.Set<String> declared = new java.util.HashSet<>();
2320+
java.util.Set<String> aggregateDerived = new java.util.HashSet<>();
2321+
for (MetaField<?> f : obj.getChildren(MetaField.class, false)) {
2322+
String name = f.getShortName();
2323+
declared.add(name);
2324+
for (MetaData originChild : f.getChildren(MetaData.class, false)) {
2325+
if (!(originChild instanceof MetaOrigin)) continue;
2326+
String os = originChild.getSubType();
2327+
// Derived (not row-addressable) = any origin OTHER than
2328+
// passthrough/computed (aggregate/first/collection).
2329+
if (!PassthroughOrigin.SUBTYPE_PASSTHROUGH.equals(os)
2330+
&& !ComputedOrigin.SUBTYPE_COMPUTED.equals(os)) {
2331+
aggregateDerived.add(name);
2332+
}
2333+
}
2334+
}
2335+
checkProjectionFilterRefs(obj, (java.util.Map<?, ?>) raw, declared, aggregateDerived);
2336+
}
2337+
}
2338+
2339+
private static void checkProjectionFilterRefs(MetaObject obj, java.util.Map<?, ?> filter,
2340+
java.util.Set<String> declared,
2341+
java.util.Set<String> aggregateDerived) {
2342+
for (java.util.Map.Entry<?, ?> e : filter.entrySet()) {
2343+
String key = e.getKey() == null ? "" : e.getKey().toString();
2344+
// Compose keys — recurse into each sub-clause. Non-list / non-map elements are
2345+
// skipped silently: malformed-compose-shape is TS-only hardening, not gated here.
2346+
if ("and".equals(key) || "or".equals(key)) {
2347+
if (e.getValue() instanceof Iterable) {
2348+
for (Object sub : (Iterable<?>) e.getValue()) {
2349+
if (sub instanceof java.util.Map) {
2350+
checkProjectionFilterRefs(obj, (java.util.Map<?, ?>) sub,
2351+
declared, aggregateDerived);
2352+
}
2353+
}
2354+
}
2355+
continue;
2356+
}
2357+
if (!declared.contains(key)) {
2358+
throw new MetaDataException(
2359+
ErrorMessageConstants.ERR_BAD_ATTR_FILTER
2360+
+ ": projection '" + obj.getShortName()
2361+
+ "' @filter references '" + key + "', which is not a declared field of"
2362+
+ " the projection. A view-level @filter may only reference the"
2363+
+ " projection's own declared fields.",
2364+
ErrorCode.ERR_BAD_ATTR_FILTER, obj.getSource());
2365+
}
2366+
if (aggregateDerived.contains(key)) {
2367+
throw new MetaDataException(
2368+
ErrorMessageConstants.ERR_BAD_ATTR_FILTER
2369+
+ ": projection '" + obj.getShortName()
2370+
+ "' @filter references '" + key + "', an aggregate-derived field."
2371+
+ " A view-level WHERE runs before aggregation, so it cannot filter on"
2372+
+ " an aggregate. Filter on a passthrough or computed field instead.",
2373+
ErrorCode.ERR_BAD_ATTR_FILTER, obj.getSource());
2374+
}
2375+
}
2376+
}
2377+
22812378
// =========================================================================
22822379
// @filterable without backing index — warning pass
22832380
//

0 commit comments

Comments
 (0)