Skip to content

Commit cb687df

Browse files
committed
Merge main (d0c0c7f) into claude/issue-11466-node-slot-union-v1 (objectui#11466)
Brings in objectui#10813 (PR #11555), which landed after the previous merge. No textual conflict. Claude-Session: https://claude.ai/code/session_016tKoy8NJa35Yih1FdzrVmn Co-authored-by: Claude <noreply@anthropic.com>
2 parents 5afa026 + d0c0c7f commit cb687df

23 files changed

Lines changed: 462 additions & 262 deletions

‎.changeset/10790-empty-operators-accepted-shape.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,10 @@ value OR the empty string, and its complement:
2121
Criteria saved in the old shape still open in the builder as the same row, and
2222
opening one rewrites nothing; the builder writes the new shape the next time any
2323
row of that criteria is edited.
24+
25+
**Correction, 2026-10-03 (objectui#10813).** The two shapes above are not what this release
26+
writes. Later in the same release the pair moved to the spec's one 「is empty」 operator:
27+
"Is empty" stores `{ FIELD: { $empty: true } }` and "Is not empty" `{ FIELD: { $empty: false } }`,
28+
whose meaning is the field's declared row of the spec's per-type table rather than "no value OR
29+
the empty string" on every type. Criteria saved in either earlier shape still open as the same
30+
row and are written as `$empty` on their next edit (`.changeset/10813-filter-condition-empty-operator.md`).
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@object-ui/app-shell': minor
3+
---
4+
5+
The Studio dataset filter builder writes "Is empty" / "Is not empty" as the spec's `{ FIELD: { $empty: true } }` / `{ FIELD: { $empty: false } }` instead of `$exists` (objectui#10813).
6+
7+
`dataset.filter` and `measure.filter` stored the pair as `{ FIELD: { $exists: false } }` / `{ FIELD: { $exists: true } }`. `$exists` is the spec's has-a-value test (`!= null`), so a text value of `''` or a multi-value `[]` was never "empty" there, while the same operator in the sharing-rule widget and in a saved view meant something else. `@objectstack/spec` 17.6.0 admits `$empty` (objectstack#20446), whose meaning is the column's DECLARED row of the spec's per-type table (ruling B on objectstack#20311): null or `''` on a text-like column, null or `[]` on a multi-value one, null alone on every other type. The analytics service answers it from the field's declared type and `multiple` on its SQL and ObjectQL strategies. The spec's own face table declares one face that refuses it: the `driver-memory` analytics (cube) face, the lowest-priority fallback strategy (`MemoryAnalyticsService`), refuses a newly authored "Is empty" / "Is not empty" filter with `INVALID_FILTER` / 400, where it answered `$exists`. The bridge keeps no copy of the table.
8+
9+
The `@objectstack/spec` dependency floor rises from `^17.5.0` to `^17.6.0`, because this package now writes `$empty`, which `@objectstack/spec` 17.5.0 declares staged and refuses.
10+
11+
The read half reads `$empty` back as the pair, with a boolean flag only.
12+
13+
**What moves for a stored filter.** A stored `$exists` no longer opens as "Is empty" / "Is not empty": it opens in the Source tab, with its bytes untouched, and keeps matching what it matched before. Opening it as the pair would let an edit to ANOTHER row rewrite it to `$empty` and move `''` / `[]` across the line, which the read-half invariant (objectui#10257) forbids, and this inspector offers no `exists` row. To move such a filter to the new meaning, remove the row and add "Is empty" again. Readable stores (this repository, objectstack, hotcrm and cloud) hold no `dataset.filter` with `$exists`.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@object-ui/fields': minor
3+
---
4+
5+
`FilterConditionField` writes "Is empty" / "Is not empty" as the spec's one 「is empty」 operator, `{ FIELD: { $empty: true } }` / `{ FIELD: { $empty: false } }` (objectui#10813).
6+
7+
The widget behind sharing-rule `criteria_json`, `relatedListFilter` and `summaryOperations.filter` used to write its own meaning of 「is empty」: `{ $or: [{ FIELD: { $in: [''] } }, { FIELD: { $null: true } }] }` and its complement `{ FIELD: { $nin: [''], $null: false } }`, i.e. "no value OR `''`" on every field type. The `''` member reached a number or date column as `IN ('')`, and a multi-value column (stored as JSON by the SQL driver) as an `$in` that driver refuses. `@objectstack/spec` 17.6.0 admits `$empty` to `FILTER_OPERATORS` (objectstack#20446), and its meaning is the field's DECLARED row of the spec's per-type table (ruling B on objectstack#20311): null or `''` on a text-like field, null or `[]` on a multi-value field, null alone on every other type. Every evaluator expands it itself (`expandEmptyOperator`), so the widget now writes the same token on every column type and keeps no copy of the table.
8+
9+
The `@objectstack/spec` dependency floor rises from `^17.5.0` to `^17.6.0`, because this package now writes `$empty`, which `@objectstack/spec` 17.5.0 declares staged and refuses.
10+
11+
`kvToCondition` reads `$empty` back as the pair, with a boolean flag only: any other flag stays the raw criteria it is, as every evaluator refuses it.
12+
13+
**What moves for a stored rule.** Reading alone rewrites nothing: a criteria saved in either earlier shape (the objectui#10790 `$or` entry and `$nin` + `$null` pair, or the older `$in: [null, '']` / `$nin: [null, '']`) still opens as the same "Is empty" / "Is not empty" row, and an un-edited rule keeps its stored bytes and keeps matching as before. The next time the criteria is edited, those rows are written as `$empty`, which re-scopes the rule:
14+
15+
- text-like column: no change (both mean null or `''`);
16+
- every other single-valued column (number, boolean, date, datetime, time, select, a single lookup): a row holding `''` is no longer "empty" and becomes "not empty". Since objectstack#20308 the write door stores a cleared number, boolean, date, datetime or time as null, so on those types only a value written before it can hold `''`; a select or lookup can still hold one, and the spec's ruled table does not count it as empty;
17+
- multi-value column (multiselect, checkboxes, tags, or a `multiple: true` select, lookup or user): a row holding `[]` becomes "empty" and leaves "is not empty". On the SQL driver the earlier shape was refused outright, so a rule there starts running;
18+
- the `$in: [null, '']` shapes were refused by every objectstack filter face, so a rule still in them starts running.
19+
20+
Measured on the installed 17.6.0 by-value readers (`@objectstack/formula`'s `matchesFilterCondition` and `ValueDataSource`) over null, an absent key, `''`, `[]` and set values: the `$or` entry and `$empty: true` disagree only on `[]`. Readable stores (this repository, objectstack, hotcrm and cloud) hold no criteria in either earlier shape.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@object-ui/plugin-list': minor
3+
---
4+
5+
The list view's live query sends "Is empty" / "Is not empty" as the spec's `isempty` / `isnotempty` instead of an equality to `null` (objectui#10813). `@objectstack/spec` 17.6.0 and later lower that pair to `$empty`; an earlier reader lowers it to `$null`, as it does the saved view's `is_empty`.
6+
7+
`convertFilterGroupToAST` resolved the pair to `[FIELD, '=', null]` / `[FIELD, '!=', null]`, a null-only test, before `mapOperator` was consulted. The same rule saved into the view is persisted as `is_empty`, which `@objectstack/spec` 17.6.0 lowers to `$empty` (objectstack#20446): so one filter panel answered two record sets, depending on whether the view had been saved. The pair now takes the value-less path like `is_null`, and `mapOperator` gains `isempty` / `isnotempty` arms. The node is `[FIELD, 'isempty', null]`; the spec discards the third slot.
8+
9+
**What moves.** Nothing is stored by this path. Against a 17.6.0 or later server, on a text-like column a row holding `''` is now "empty", and on a multi-value column a row holding `[]` is, matching the server's per-type answer for the saved view. On a `provider: 'value'` list a row with no key at all is now "empty" too: the equality to `null` did not select it there.

‎.changeset/9359-list-ast-valueless-canonical-fold.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,3 +75,9 @@ objectui#9306's census), every pair emits the same node except
7575
folds onto `icontains` before a row leaves it. The vocabulary
7676
question this entry calls open is answered: the dropdown speaks the protocol's
7777
ids, and camelCase is the deprecated alias form.
78+
79+
**Correction, 2026-10-03 (objectui#10813).** The two `isEmpty` / `isNotEmpty` arms that
80+
"resolve to a null comparison ahead of `mapOperator`" are gone in this release. The empty pair
81+
now takes the same value-less path as `is_null`, and `mapOperator` emits the spec's `isempty` /
82+
`isnotempty`, which the spec lowers to `$empty`; every spelling of the pair still emits one node
83+
(`.changeset/10813-list-view-empty-operator.md`).

‎packages/app-shell/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@
8585
"@object-ui/types": "workspace:*",
8686
"@objectstack/formula": "^17.5.0",
8787
"@objectstack/lint": "^17.0.0",
88-
"@objectstack/spec": "^17.5.0",
88+
"@objectstack/spec": "^17.6.0",
8989
"@sentry/react": "^10.70.0",
9090
"jsonc-parser": "^3.3.1",
9191
"lucide-react": "^1.43.0",

‎packages/app-shell/src/views/drillNotNullDialect-9508.test.tsx‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,10 @@
4444
*
4545
* ⇒ reading only `$null` would have closed the composed route and left the
4646
* uncomposed one degrading exactly as before, for an author who picked "is not
47-
* empty" in the dataset filter inspector (which writes the `$exists` pair).
47+
* empty" in the dataset filter inspector, which wrote the `$exists` pair until
48+
* objectui#10813; a filter stored before then still carries it, and the row
49+
* now writes `{ $empty: false }` instead, which this dialect carries on its
50+
* `[empty]` arm (objectui#11547).
4851
*
4952
* ## What this card did NOT change
5053
*

‎packages/app-shell/src/views/drillUrlFilters.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,8 +120,11 @@ export const RANGE_OP_PARAM: Record<string, string> = { $gte: 'gte', $lte: 'lte'
120120
* that hands its own resolved filter straight to the escape hatch
121121
* (`ObjectMetricWidget`, whose drawer renders `OpenInListButton`) passes
122122
* through no canonicaliser at all, so `$exists` reaches this function
123-
* verbatim — and the dataset filter inspector's "is not empty" row writes
124-
* exactly that pair.
123+
* verbatim. The dataset filter inspector's "is not empty" row wrote
124+
* exactly that pair until objectui#10813, so a filter stored before then
125+
* still carries it; that row now writes `{ $empty: false }`, which is not
126+
* one of these two keys: it reaches this function verbatim too, and the
127+
* `[empty]` arm, {@link EMPTY_FILTER}, carries it (objectui#11547).
125128
*
126129
* ⚠️ A NON-boolean under either key says nothing about emptiness and writes
127130
* nothing, which is what it did before this pair existed.

‎packages/app-shell/src/views/metadata-admin/inspectors/datasetFilterCondition.nullOperators-9363.test.ts‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,9 +102,13 @@ describe('groupToCondition — the null predicates this inspector offers (object
102102
).toEqual({ stage: { $null: true } });
103103
});
104104

105-
it('leaves the $exists pair exactly as it was', () => {
106-
expect(groupToCondition(row('is_empty'))).toEqual({ closed_at: { $exists: false } });
107-
expect(groupToCondition(row('is_not_empty'))).toEqual({ closed_at: { $exists: true } });
105+
it('keeps the empty pair a predicate of its own — `$empty`, not `$null` (objectui#10813)', () => {
106+
// This file's repair left the empty pair on `$exists`; objectui#10813 moved
107+
// it to the spec's one 「is empty」 operator. Either way it is NOT the null
108+
// pair: `$empty` also counts `''` on a text column and `[]` on a
109+
// multi-value one, so folding the two would rewrite the author's choice.
110+
expect(groupToCondition(row('is_empty'))).toEqual({ closed_at: { $empty: true } });
111+
expect(groupToCondition(row('is_not_empty'))).toEqual({ closed_at: { $empty: false } });
108112
});
109113

110114
it('still drops an operator it does not map, rather than emitting a wrong filter', () => {

‎packages/app-shell/src/views/metadata-admin/inspectors/datasetFilterCondition.readHalfHolds-10257.test.tsx‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,7 @@ describe('class 1 — an incomplete stored value is not opened as a row the next
208208
});
209209

210210
it('CONTROL: the value-less tokens carry no value to be incomplete, and stay opened', () => {
211-
for (const other of [{ name: { $exists: false } }, { name: { $null: true } }]) {
211+
for (const other of [{ name: { $empty: true } }, { name: { $null: true } }]) {
212212
const { group, representable } = conditionToGroup(beside(other), FIELDS);
213213
expect(representable, JSON.stringify(other)).toBe(true);
214214
expect(editStage(group, 'lost')).toEqual({ $and: [{ stage: { $eq: 'lost' } }, other] });
@@ -224,7 +224,7 @@ describe('class 2 — a stored token is not opened as an operator the column\'s
224224
{ stored: { amount: { $in: [1, 2] } }, type: 'number', readAs: 'in' },
225225
// "Every token" includes the value-less arms: the boolean bucket offers
226226
// only `equals` / `notEquals`.
227-
{ stored: { flag: { $exists: true } }, type: 'boolean', readAs: 'is_not_empty' },
227+
{ stored: { flag: { $empty: false } }, type: 'boolean', readAs: 'is_not_empty' },
228228
{ stored: { flag: { $null: false } }, type: 'boolean', readAs: 'is_not_null' },
229229
];
230230

@@ -262,7 +262,7 @@ describe('class 2 — a stored token is not opened as an operator the column\'s
262262
});
263263

264264
it('CONTROL: the value-less tokens on a column whose bucket offers them still open', () => {
265-
for (const stored of [{ name: { $exists: true } }, { closed_at: { $null: false } }, { region: { $exists: false } }]) {
265+
for (const stored of [{ name: { $empty: false } }, { closed_at: { $null: false } }, { region: { $empty: true } }]) {
266266
const { group, representable } = conditionToGroup(stored, FIELDS);
267267
expect(representable, JSON.stringify(stored)).toBe(true);
268268
expect(groupToCondition(group)).toEqual(stored);
@@ -315,7 +315,16 @@ describe('the invariant, swept: every row the read half opens, the panel draws a
315315
.flatMap((t) => SCALARS.map((v) => ({ stored: { [t]: v }, implicit: false }))),
316316
...['$in', '$nin'].flatMap((t) => LISTS.map((v) => ({ stored: { [t]: v }, implicit: false }))),
317317
...PAIRS.map((v) => ({ stored: { $between: v }, implicit: false })),
318-
...[true, false].flatMap((b) => [{ stored: { $exists: b }, implicit: false }, { stored: { $null: b }, implicit: false }]),
318+
// `$empty` is what the empty pair writes since objectui#10813; `$exists`,
319+
// what it wrote before, stays in the domain — it is now REFUSED (no
320+
// operator this inspector offers reads it back), which the invariant
321+
// accepts, and a read that still opened it would be named by the
322+
// byte-identical check below.
323+
...[true, false].flatMap((b) => [
324+
{ stored: { $empty: b }, implicit: false },
325+
{ stored: { $exists: b }, implicit: false },
326+
{ stored: { $null: b }, implicit: false },
327+
]),
319328
...[...SCALARS, ...LISTS].map((v) => ({ stored: v, implicit: true })),
320329
];
321330

0 commit comments

Comments
 (0)