Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
91d1073
fix(folders): refuse DROP FOLDER on a non-empty folder (#892)
claude Aug 14, 2026
64b110e
fix(alter-page): refuse a bare DataGrid2 column as an INSERT/REPLACE …
claude Aug 14, 2026
b065b55
fix(describe): emit widgets nested in a pluggable widget's object-lis…
claude Aug 14, 2026
bb67072
Keep an additive chain's operators in the order they were written
claude Aug 15, 2026
4828f81
Ship skill packs: a skill that carries assets, not just prose
claude Aug 15, 2026
bf28213
Add the Vega charting pack, with the namespace fitted at install
claude Aug 15, 2026
e3da0c9
Merge pull request #149 from ako/claude/fix-additive-operator-order
ako Aug 15, 2026
7bb0739
Merge pull request #150 from ako/claude/skill-packs
ako Aug 15, 2026
0bf9382
mxcli test: fail closed on an @expect it cannot evaluate
claude Aug 15, 2026
c48ee09
Keep the #891/#892 repros out of the check-time negative harness
claude Aug 15, 2026
d93071f
Merge pull request #151 from ako/claude/sudoku-test-issue-46-iyxn30
ako Aug 16, 2026
9a7f5fd
Merge branch 'main' into claude/mxcli-issues-ovfoxk
ako Aug 16, 2026
54a013b
Merge pull request #148 from ako/claude/mxcli-issues-ovfoxk
ako Aug 16, 2026
2788c54
Merge mendixlabs/mxcli main into ako main (v0.18.0)
claude Aug 16, 2026
a8dc083
Merge pull request #153 from ako/claude/sync-origin-0.18.0
ako Aug 16, 2026
3cc8e56
mxcli test: report what each test actually asserted
claude Aug 16, 2026
3bbafe7
Merge pull request #154 from ako/claude/sudoku-test-issue-46-iyxn30
ako Aug 16, 2026
2b3a60f
Ship the Vega pack's lockfile, so the command it documents can run
claude Aug 16, 2026
11b7f4e
Let a pack place Java, and land the OData pushdown pack
claude Aug 16, 2026
d94d1c2
Merge pull request #155 from ako/claude/vega-pack-npm-lock
ako Aug 16, 2026
9135bba
Merge pull request #156 from ako/claude/skillpack-installs-java
ako Aug 16, 2026
9526e43
Explain the OData property whose name invites the wrong value
claude Aug 16, 2026
e34f99d
Say why links are the right answer, not just that they build
claude Aug 16, 2026
da189cf
Say what to key an aggregate resource on, since a grain is not a busi…
claude Aug 16, 2026
42ab9ca
Merge pull request #157 from ako/claude/odata-association-representation
ako Aug 16, 2026
046e1c4
Let MDL publish a service as GraphQL as well as OData
claude Aug 16, 2026
64135d4
Refuse GraphQL with associations as an object id, and verify on a rea…
claude Aug 16, 2026
6e7f0f2
Record what the GraphQL surface actually covers, and its two traps
claude Aug 17, 2026
6fdfd4f
CE6624 does not apply to a view entity, so a grain is not always needed
claude Aug 17, 2026
ca6f7f8
A view entity read from the database already pushes down the query op…
claude Aug 17, 2026
a98c523
Describe the topology the pushdown pack is actually for
claude Aug 17, 2026
1952608
Measure which query options a read microflow must implement itself
claude Aug 17, 2026
26e379b
Push $select into the source query, and give the pack a runnable verify
claude Aug 17, 2026
ce546ea
Merge pull request #158 from ako/claude/odata-graphql
ako Aug 17, 2026
48c2193
Merge pull request #159 from ako/claude/odata-pushdown-guidance
ako Aug 17, 2026
32b58b3
Merge pull request #160 from ako/claude/pushdown-select
ako Aug 17, 2026
5c70014
Merge branch 'mendixlabs:main' into main
ako Aug 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .claude/skills/fix-issue.md

Large diffs are not rendered by default.

299 changes: 297 additions & 2 deletions .claude/skills/mendix/odata-data-sharing.md
Original file line number Diff line number Diff line change
Expand Up @@ -471,8 +471,303 @@ Two things worth knowing before you write this:
takes no parameters at all. `SkipSupported: No` and `TopSupported: No` turn
off `$skip` and `$top` the same way. All three default to Yes.

`PublishAssociations` must stay at its default (Yes) here: a non-persistable
entity cannot publish its ID, so object-id mode can never build for it.
`PublishAssociations` must stay at its default (Yes) — and not only here.

**It is not a yes/no, it is a two-value representation.** Studio Pro's own labels
for it are "As a link (recommended)" (Yes) and "As an associated object id" (No).
So `PublishAssociations: No` does not mean "this service publishes no
associations"; it selects the legacy representation, which requires the system
`ID` attribute published as the key. MDL cannot publish the system ID (CE1613),
so `No` cannot build from a script.

That holds even when the service publishes no associations at all, and even for
a persistable entity with a perfectly good key of its own. Measured on Mendix
11.13, both arms of the same service:

| | `mx check` |
|---|---|
| `PublishAssociations: No` | **CE7375** "Attribute ID … must be published and be the key when associations are exposed as an associated object id" |
| `PublishAssociations: Yes` | 0 errors |

The error names a concept the script never mentions, which is why this costs
hours rather than minutes. `mxcli check` now warns (MDL-ODATA06).

**Do not take CE7375's advice literally.** It says to publish the `ID` and make
it the key, and that is the wrong direction for anything you share outside the
app. The two representations exist for a reason: OData v3 had no link support,
so a foreign key had to be an exposed object id; v4 added links largely so
internal ids no longer had to leave the app. Going back to ids gives up that.

**A published key should be a business key.** Mendix object ids are
autogenerated and are not stable across an app landscape — the same record has
different ids in test, acceptance and production — so an id baked into an
external contract breaks the moment a consumer moves between environments, or
compares data from two of them. Pick something the business already guarantees:
an invoice number, an ISIN, an employee number. Mendix requires a key to be
unique, required and stable (the last is the point here), and the unique
validation rule it makes you add is checking exactly that.

That is also why the key needs `unique error '…'` on the attribute — see the
CE6624 note below. Both halves of the same idea: the value identifies one row,
and keeps identifying it.

### A view entity read from the database gets the query options for free

**This decides whether you need any pushdown machinery at all**, so check it
before reaching for Java.

A published resource has an `Action`: *Read from database*, or a read microflow.
The difference is not a detail:

| Action | `$filter` `$orderby` `$top` `$skip` `$count` |
|---|---|
| **Read from database** (a view entity, or a persistable one) | **Mendix applies them** — they reach the database |
| Read microflow | Mendix applies **none** of them; whatever the microflow returns is what the client gets |

Measured on 11.13 against a running app — an OQL view over four rows aggregating
to three, published with `Action: Read from database` and no Java anywhere:

```
$top=1 -> 1 row, not 3
$count=true&$top=1 -> "@odata.count": 3, one row returned
$filter=Category eq 'Rent' -> only the Rent row
$orderby=Total desc&$skip=1 -> [400, 250] (1500 correctly skipped)
```

So for a **view entity**, aggregation happens in the database and paging and
filtering push down to it — a chart or grid can page a large resource with
nothing hand-written. That is the whole capability the `mendix-odata-pushdown`
pack exists to recreate.

**Which options a read microflow actually has to implement — measured, and not
all-or-nothing.** The same view served both ways on 11.13, three rows behind
each:

| option | database read | read microflow |
|---|---|---|
| `$select` | applied | **applied** — Mendix projects the response either way |
| `$filter` | applied | **200, unfiltered** |
| `$orderby` | applied | **200, unsorted** |
| `$top` / `$skip` | applied | **200, full set** |
| `$count` | applied | needs `System.ODataResponse` (CE6962) |

Two things follow that "Mendix applies none of them" gets wrong:

- **`$select` is not the microflow's correctness problem.** The client already
receives only the fields it asked for. And the consumer drives it: removing
attributes from an external entity narrows the `$select` it sends, because the
external entity has nowhere to put what it dropped. So pushing `$select` into
the source query is a *cost* optimisation — fewer columns read at the source —
never a fix for wrong output.
- **Declaring the capability is what turns a safe refusal into a silent lie.**
With `Filterable`/`Sortable` *not* declared, Mendix rejects the request:
`400 "Property 'Category' is non-filterable."` Declare them — which you must,
or no client can filter at all — and the identical request becomes 200 with
every row. The declaration is a promise Mendix enforces at the boundary and
does not keep for you.

That second one is the sharpest statement of why this work exists: the failure
is *created by* promising the capability, and the microflow is the only place
left to keep the promise.

The pack is for the case a view cannot cover: **the data is not in this app's
database at all**, so there is no table for a view to select from and a read
microflow is the only way to produce the rows. Mendix then applies nothing to
them — a `?$top=5` that quietly returns all 917 rows.

Its motivating shape is two apps, and the topology is what makes the pushdown
load-bearing rather than an optimisation:

```
frontend app --- external entities / OData ---> backend app
(grid, chart) (no data of its own)
|
external database connector
|
DuckDB over CSV
```

The frontend's grid pages and filters by generating `$top` / `$skip` /
`$filter` — it has no other vocabulary, because external entities *are* OData.
The backend's read microflow has to translate those options into the SQL it
sends through the connector. Without that translation the frontend's paging
still looks correct while every page drags the whole file across, and nothing
in either app reports a problem.

Two consequences worth holding on to:

- **A view entity is not an option here**, so "prefer the view" is not advice
that applies. The question is only whether *this app* owns the data.
- **The consumer's capability flags must match the service.** An external
entity generated with `TopSupported`/`SkipSupported` that the service does
not honour is CE6630 in the consuming app — the two ends of this contract
are checked against each other.

If the resource *is* backed by this app's own tables, prefer a view entity and
skip the machinery entirely.

### An aggregate view's key is its grain

A summary resource — an OQL view entity, or a non-persistable row filled by a
read microflow — has no business key to reach for. Monthly totals per category
are not an invoice; nothing in the domain issues them a number.

**The key is the grain: the columns the aggregate groups by.** For monthly
totals per category that is `(Period, Category)` — together they identify
exactly one row, they are stable because they are the definition of the row, and
they mean the same thing in every environment.

What not to do is cast the internal id into a column (`cast(c.id as string) as
RowId`) and publish that. It satisfies "a key" and it is the id problem again,
one level down: autogenerated, environment-specific, and now stable only as long
as nobody rebuilds the view.

Measured on Mendix 11.13, each row a separate build:

| shape | result |
|---|---|
| single key attribute, persistable, no `unique` rule | **CE6624** — add one |
| single key attribute, persistable, `unique error '…'` | 0 errors |
| **single key attribute, VIEW entity, no `unique` rule** | **0 errors** |
| **composite key, `OData3`** | **CE7238** "You can only have more than one key attribute when the OData version is 4" |
| composite key, `OData4`, persistable, no `unique` rules | 0 errors |
| **composite key, `OData4`, non-persistable, no `unique` rules** | **0 errors** |
| any validation rule on a non-persistable entity | **CE0070** — not allowed |

Two consequences worth holding on to:

- **A grain key needs `ODataVersion: OData4`.** More than one key attribute is a
v4 feature; on v3 the same model is CE7238.
- **A composite key needs no `unique` validation rule**, and a non-persistable
entity could not carry one anyway (CE0070). The rule is only demanded for a
*single*-attribute key, where one attribute has to be unique by itself —
which is exactly the case a grain is not. So the CE6624 hurdle disappears
the moment the key is honest about being multi-column.
- **CE6624 does not apply to a view entity at all.** A view can carry a
*single*-attribute key with no validation rule and build cleanly — confirmed
against a Studio Pro service publishing a view keyed on one column. So if the
view already has a naturally unique column (an id carried through from the
source data, not the platform's object id), key on that and skip the grain.
Reach for the grain when no single column identifies a row — which is the
normal case for an aggregate.

```sql
create non-persistent entity Fin.VMonthCategory (
Period: string(7), -- 2026-08
Category: string(60),
Total: decimal
);

create odata service Fin.ChartApi (
path: 'odata/charts/', ServiceName: 'ChartApi', Namespace: 'Fin.Charts',
version: '1.0.0', ODataVersion: OData4 -- required: the key is composite
) {
publish entity Fin.VMonthCategory (
ReadMode: microflow Fin.Read_MonthCategory,
Countable: No -- else CE6962 wants System.ODataResponse
)
expose ( Period (KEY), Category (KEY), Total )
}
```

(Measured on 11.13: `PublishAssociations: Yes` builds under both `OData3` and
`OData4`, so choosing links is not a v4-only option in current Mendix.)

**`Path` has two rules and one trap.** No leading slash (CE6550), and it must end
with a single slash (CE6552). A path with **no slash at all** is the trap: mxbuild
throws `System.ArgumentOutOfRangeException` out of its own validator, with no
error code, no element name and no line, which reads as a corrupt project. Use
`'odata/thing/'`. `mxcli check` catches all three (MDL-ODATA05).

## Also Publishing as GraphQL

`SupportsGraphQL: Yes` makes the same service answer GraphQL as well as OData.
One boolean; the OData surface is untouched.

```sql
create odata service Fin.ChartApi (
path: 'odata/charts/', ServiceName: 'ChartApi', Namespace: 'Fin.Charts',
version: '1.0.0', ODataVersion: OData4,
SupportsGraphQL: Yes
) { ... }
```

**The GraphQL endpoint is the service location itself** — there is no `/graphql`
path. Clients `POST` a query to the same URL that serves OData:

```
GET /odata/charts/$metadata -> the OData contract
POST /odata/charts/ -> {"query":"{ monthCategories { period } }"}
```

Verified against a running Mendix 11.13 app:

| request | response |
|---|---|
| `POST` `{ __schema { queryType { name } } }` | `{"data":{"__schema":{"queryType":{"name":"Query"}}}}` |
| `POST` `{ monthCategories { period category total } }` | `{"data":{"monthCategories":[]}}` |

Three things that only bite once GraphQL is on:

- **Query field names are camelCased.** `Period` in the model is `period` in a
query; asking for `Total` returns 400
`{"errors":[{"message":"Field 'Total' not found"}]}`. The OData names are
unchanged, so the two surfaces spell the same attribute differently.
- **Exposed names must be unique beyond case (CE2881).** Publishing an entity
without `as '...'` gives the entity type and the entity set the same name,
which OData accepts and GraphQL rejects. A service that built yesterday can
fail on the day it is enabled. Give the set its own name:
`publish entity Fin.VMonthCategory as 'MonthCategories'`.
- **`PublishAssociations` must be Yes.** GraphQL has no representation for an
associated object id, so Mendix refuses the pair: CE8055 "A service that
supports GraphQL must publish associations as a link." mxcli refuses it before
writing, since no other change can make it build.
- **Mendix 10.14+**, where it arrived as an experimental feature. mxcli refuses
the statement on an older project rather than writing a property that version's
metamodel does not have — an unknown property is not a build error, it is a
document Studio Pro will not open.

### What GraphQL actually covers, measured

The GraphQL surface is narrower than the OData one, and the gaps are not
documented next to the checkbox. Introspected and exercised on 11.13, on a
resource published over both at once:

| OData | GraphQL |
|---|---|
| `$select` | **inherent** — you name the fields, that *is* the projection |
| `$top` | `first: Int` |
| `$skip` | `offset: Int` |
| `$orderby` | `orderBy: [{field: ASC\|DESC}]` |
| key lookup `Set(K='v')` | a singular field: `vMonthCategory(period: "…", category: "…")` |
| **`$filter`** | **absent** |
| **`$count`** | **absent** |
| `$expand` | not measured here (the probe has no associations) |

The whole schema for a one-entity service is nine types — `Query`,
`SortOrder`, the entity, its order input, and the scalars. There is no filter
type, no where type and no count type in it.

Two traps, both measured:

- **`orderBy` must be a LIST.** `orderBy: {total: DESC}` fails with
`Incorrect value for orderBy`, while `orderBy: [{total: DESC}]` works — and
introspection advertises the argument as a bare input object
(`VMonthCategoryOrderInput`), not a list, so the schema and the parser
disagree. The error does not mention it.
- **An unknown argument is silently ignored.** `monthCategories(where: {…})`
and even `monthCategories(bogusArgument: 42)` both return **200 with the
full result set** rather than an error. A client that assumes a filter
argument exists gets every row and no warning — the same "200 with the wrong
rows" failure the pushdown pack was written about, in a different surface.

So: **paging and sorting are safe over GraphQL; filtering is not there.** A
widget that needs server-side filtering has to use the OData surface, and a
resource where the client filters is a reason to keep OData even when GraphQL
is enabled.

GraphQL here is not as complete as the OData surface — it is a second way to read
the same published resources, which some widgets and clients prefer.

## HTTP Status Codes and Errors: What Each Capability Can Do

Expand Down
Loading
Loading