You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit e96bbe3
Browse filesBrowse the repository at this point in the historyBrowse files
fix(metamodel): the index key is @fields XOR @expr, in all five ports (#342)
`@expr` was registered, built and rendered — and unreachable. The registry has always
described it as "Used INSTEAD of @fields", migrate-ts has always keyed off it
(`columns: expr ? [] : cols`), the Postgres emitter has always rendered it, and
introspection has always read expression keys back via pg_get_expr. Only the LOADER
disagreed, requiring @fields unconditionally. So: @expr alone failed to load on both
node types, and the one spelling that DID load — @fields AND @expr — had its @fields
silently discarded downstream. An expression index had to be kept as hand-written SQL
outside the migration ledger, which then read as permanent `verify --db` drift.
The rule is now stated once and enforced once. Neither is an error, as before; BOTH is
now also an error rather than accepted-and-half-honored. Failing closed matches the
sealed-strict posture and the ERR_SQL_BODY_WITH_UNMANAGED precedent — @SQL vs
@Unmanaged is the same "two mutually exclusive non-default states of one axis" shape.
PATCH, not MINOR: a declaration that loaded while silently throwing half of itself away
was never validly expressible, so refusing it corrects previously-wrong acceptance
rather than changing a contract — the same call as the @min clamp in 0.19.1 and the
`like` case-sensitivity fix in 0.21.6. metamodelVersion moves 0.10 -> 0.11 (@fields
becomes optional on both types, which the gate classifies as additive); the package
line stays a patch, which is what ADR-0035 Am. 2 severed the two numbers for.
Covers identity.secondary as well as index.lookup: per ADR-0040 uniqueness lives in the
TYPE, so identity.secondary IS a unique index, keys itself identically, and carries
@expr from the same db provider. identity.primary / identity.reference are untouched —
a primary key or an FK is always plain columns and has no @expr at all.
TWO CROSS-PORT DIVERGENCES THE NEW FIXTURE FOUND, THAT NO PORT'S OWN SUITE DID.
Java carried a BESPOKE @fields requirement in ValidationPhase.validateIdentityNode,
applied to every identity independently of the registry — so relaxing the schema tier
left Java alone refusing metadata the other three accepted. And Python emitted its
index errors with an EMPTY provenance envelope while TS/Java/C# attached node.source;
the corpus asserts envelope shape, which is what surfaced it.
Gated by two shared fixtures — index-expr-keys-without-fields (both node types keyed by
expression) and error-index-fields-and-expr — plus per-port tests. The positive fixture
asserts a CLEAN load, not merely the absence of one error code: the weaker assertion
passed while the child-rule check still refused it under a different code, which is how
this reached an adopter. Proved by reverting the fix and watching it go red.
Also folds in a one-line uv.lock catch-up left stale by the 0.24.0 version bump.
TS 2481 · Java 1470 · Python 1837 · C# 1672 — all green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DhpswkF1NvwxhFWMmdAT15
### Fixed — an expression index was undeclarable, and the one spelling that loaded was half-ignored ([#342](https://github.com/metaobjectsdev/metaobjects/issues/342))
13
+
14
+
**`@expr` was registered, built and rendered — and unreachable.** The registry has always
15
+
described it as *"Used **INSTEAD of**`@fields`"*, `migrate-ts` has always keyed off it
16
+
(`columns: expr ? [] : cols`), the Postgres emitter has always rendered it, and
17
+
introspection has always read expression keys back via `pg_get_expr`. Only the **loader**
18
+
disagreed, requiring `@fields` unconditionally. So the three-row matrix an adopter hit was:
19
+
20
+
| Node | attrs | Before |
21
+
|---|---|---|
22
+
|`index.lookup`|`@expr` only | ❌ fails to load |
23
+
|`identity.secondary`|`@expr` only | ❌ fails to load |
24
+
| either |`@fields`**+**`@expr`| ⚠️ loads — and `@fields` is **silently discarded**|
25
+
26
+
An expression index therefore had to live as hand-written SQL outside the migration
27
+
ledger, which then reported as permanent `verify --db` drift.
28
+
29
+
**The rule is now stated once and enforced once: the index key is `@fields` XOR `@expr`.**
30
+
Declaring neither is an error (as before); declaring **both is now also an error**
31
+
(`ERR_INVALID_INDEX`) rather than being accepted and half-honored. Failing closed matches
32
+
the sealed-strict-registry posture and the existing `ERR_SQL_BODY_WITH_UNMANAGED`
33
+
precedent — `@sql` vs `@unmanaged` is the same "two mutually exclusive non-default states
34
+
of one axis" shape. **This is a PATCH, not a MINOR:** a declaration that loaded while
35
+
silently throwing half of itself away was never validly expressible, so refusing it
36
+
corrects previously-wrong acceptance rather than changing a contract — the same call, for
37
+
the same reason, as the `@min` clamp in `0.19.1` and the `like` case-sensitivity fix in
38
+
`0.21.6`.
39
+
40
+
**Applies to `identity.secondary` as well as `index.lookup`**, because per
|`@expr`| string | no ||| metaobjects-db | Raw key EXPRESSION for a functional/expression index (e.g. "lower(email)"). Used INSTEAD of @fields — the index key is the expression rather than plain columns. RDB-physical — contributed by the db provider. |
68
-
|`@fields`| string[]|yes||| metaobjects-core-types | The field name(s) composing this identity. Single-element for a simple unique index, multiple for a composite unique constraint. |
68
+
|`@fields`| string[]|no||| metaobjects-core-types | The field name(s) composing this identity. Single-element for a simple unique index, multiple for a composite unique constraint. Required UNLESS @expr is present — a unique index keys off plain columns (@fields) or a key expression (@expr), never both; declaring both is ERR_INVALID_INDEX. |
69
69
|`@orders`| string[]| no ||`asc`, `desc`| metaobjects-db | Physical index-key sort direction, positional to @fields ('asc' \| 'desc'). Omit for all-ascending (the default); a shorter array leaves trailing keys ascending. Drives DESC-ordered index keys (e.g. a recency index on a timestamp). RDB-physical — contributed by the db provider, not core identity. |
70
70
|`@using`| string | no ||| metaobjects-db | Index access method (e.g. "gin", "gist", "hash"); default "btree" (not rendered). Pair with @expr for e.g. a GIN index over an array/jsonb expression. RDB-physical — contributed by the db provider. |
71
71
|`@where`| string | no ||| metaobjects-db | Partial-index predicate (raw SQL, e.g. "delivered_at IS NULL"). When set, the index covers only rows matching the predicate — smaller and cheaper for queries that always filter on it. Absent = a full index over every row. RDB-physical — contributed by the db provider. |
|`@expr`| string | no ||| metaobjects-db | Raw key EXPRESSION for a functional/expression index (e.g. "lower(email)"). Used INSTEAD of @fields — the index key is the expression rather than plain columns. RDB-physical — contributed by the db provider. |
24
-
|`@fields`| string[]|yes||| — | The field name(s) this index covers (at least one). When @expr is present, it is the key expression derived from these fields. |
24
+
|`@fields`| string[]|no||| — | The field name(s) this index covers. Required UNLESS @expr is present — an index keys off plain columns (@fields) or a key expression (@expr), never both; declaring both is ERR_INVALID_INDEX. |
25
25
|`@orders`| string[]| no ||`asc`, `desc`| metaobjects-db | Physical index-key sort direction, positional to @fields ('asc' \| 'desc'). Omit for all-ascending (the default); a shorter array leaves trailing keys ascending. Drives DESC-ordered index keys (e.g. a recency index on a timestamp). RDB-physical — contributed by the db provider. |
26
26
|`@using`| string | no ||| metaobjects-db | Index access method (e.g. "gin", "gist", "hash"); default "btree" (not rendered). Pair with @expr for e.g. a GIN index over an array/jsonb expression. RDB-physical — contributed by the db provider. |
27
27
|`@where`| string | no ||| metaobjects-db | Partial-index predicate (raw SQL, e.g. "delivered_at IS NULL"). When set, the index covers only rows matching the predicate — smaller and cheaper for queries that always filter on it. Absent = a full index over every row. RDB-physical — contributed by the db provider. |
Copy file name to clipboardExpand all lines: fixtures/registry-conformance/expected-registry.json
+5-5Lines changed: 5 additions & 5 deletions
Original file line number
Diff line number
Diff line change
@@ -1,5 +1,5 @@
1
1
{
2
-
"metamodelVersion": "0.10",
2
+
"metamodelVersion": "0.11",
3
3
"types": [
4
4
{
5
5
"type": "attr",
@@ -2924,8 +2924,8 @@
2924
2924
"name": "fields",
2925
2925
"valueType": "string",
2926
2926
"isArray": true,
2927
-
"required": true,
2928
-
"description": "The field name(s) composing this identity. Single-element for a simple unique index, multiple for a composite unique constraint."
2927
+
"required": false,
2928
+
"description": "The field name(s) composing this identity. Single-element for a simple unique index, multiple for a composite unique constraint. Required UNLESS @expr is present — a unique index keys off plain columns (@fields) or a key expression (@expr), never both; declaring both is ERR_INVALID_INDEX."
2929
2929
},
2930
2930
{
2931
2931
"name": "orders",
@@ -2972,8 +2972,8 @@
2972
2972
"name": "fields",
2973
2973
"valueType": "string",
2974
2974
"isArray": true,
2975
-
"required": true,
2976
-
"description": "The field name(s) this index covers (at least one). When @expr is present, it is the key expression derived from these fields."
2975
+
"required": false,
2976
+
"description": "The field name(s) this index covers. Required UNLESS @expr is present — an index keys off plain columns (@fields) or a key expression (@expr), never both; declaring both is ERR_INVALID_INDEX."
0 commit comments