@object-ui/core 17.6.0 → 17.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6610 -0
- package/README.md +101 -14
- package/dist/actions/ActionEngine.d.ts +4 -2
- package/dist/actions/ActionEngine.js +13 -8
- package/dist/actions/ActionRunner.d.ts +288 -27
- package/dist/actions/ActionRunner.js +443 -63
- package/dist/actions/TransactionManager.d.ts +7 -2
- package/dist/actions/actionKeys.d.ts +9 -7
- package/dist/actions/actionKeys.js +57 -14
- package/dist/actions/actionResponse.js +2 -2
- package/dist/actions/index.d.ts +1 -0
- package/dist/actions/index.js +1 -0
- package/dist/actions/recordDelete.d.ts +118 -0
- package/dist/actions/recordDelete.js +112 -0
- package/dist/adapters/ValueDataSource.d.ts +6 -1
- package/dist/adapters/ValueDataSource.js +1090 -98
- package/dist/adapters/batchTransaction.d.ts +1 -1
- package/dist/adapters/batchTransaction.js +1 -1
- package/dist/builder/schema-builder.d.ts +38 -81
- package/dist/builder/schema-builder.js +33 -152
- package/dist/data-scope/ViewDataProvider.d.ts +1 -1
- package/dist/data-scope/ViewDataProvider.js +21 -1
- package/dist/data-scope/element-data-source.d.ts +261 -7
- package/dist/data-scope/element-data-source.js +342 -52
- package/dist/data-scope/index.d.ts +9 -4
- package/dist/data-scope/index.js +9 -4
- package/dist/evaluator/ExpressionEvaluator.d.ts +86 -0
- package/dist/evaluator/ExpressionEvaluator.js +184 -12
- package/dist/evaluator/FormulaFunctions.d.ts +0 -10
- package/dist/evaluator/FormulaFunctions.js +166 -27
- package/dist/evaluator/declaredPredicate.d.ts +93 -9
- package/dist/evaluator/declaredPredicate.js +117 -16
- package/dist/evaluator/fieldRules.d.ts +88 -4
- package/dist/evaluator/fieldRules.js +203 -31
- package/dist/evaluator/index.d.ts +1 -0
- package/dist/evaluator/index.js +1 -0
- package/dist/evaluator/listConditional.d.ts +103 -34
- package/dist/evaluator/listConditional.js +127 -50
- package/dist/evaluator/optionRules.d.ts +77 -6
- package/dist/evaluator/optionRules.js +87 -10
- package/dist/evaluator/predicateInput.d.ts +15 -4
- package/dist/evaluator/predicateInput.js +35 -2
- package/dist/evaluator/rowPredicateCanon.d.ts +7 -18
- package/dist/evaluator/rowPredicateCanon.js +65 -115
- package/dist/evaluator/subjectPermissions.d.ts +76 -0
- package/dist/evaluator/subjectPermissions.js +44 -0
- package/dist/evaluator/unevaluablePredicate.d.ts +57 -0
- package/dist/evaluator/unevaluablePredicate.js +70 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +80 -2
- package/dist/protocols/index.d.ts +0 -1
- package/dist/protocols/index.js +9 -1
- package/dist/registry/Registry.d.ts +358 -31
- package/dist/registry/Registry.js +263 -11
- package/dist/registry/WidgetRegistry.js +7 -3
- package/dist/registry/html-tier-intrinsics.d.ts +90 -0
- package/dist/registry/html-tier-intrinsics.js +110 -0
- package/dist/registry/public-blocks.d.ts +8 -0
- package/dist/registry/public-blocks.js +57 -0
- package/dist/theme/ThemeEngine.js +1 -1
- package/dist/utils/chart-category-key.d.ts +82 -0
- package/dist/utils/chart-category-key.js +84 -0
- package/dist/utils/chart-measure-key.d.ts +64 -0
- package/dist/utils/chart-measure-key.js +65 -0
- package/dist/utils/chart-presentation.d.ts +95 -112
- package/dist/utils/chart-presentation.js +169 -104
- package/dist/utils/chart-series.d.ts +131 -5
- package/dist/utils/chart-series.js +98 -16
- package/dist/utils/column-sortability.d.ts +157 -0
- package/dist/utils/column-sortability.js +217 -0
- package/dist/utils/dashboard-filters.d.ts +38 -6
- package/dist/utils/dashboard-filters.js +107 -61
- package/dist/utils/dataset-format.d.ts +211 -30
- package/dist/utils/dataset-format.js +389 -46
- package/dist/utils/date-display.d.ts +354 -0
- package/dist/utils/date-display.js +463 -0
- package/dist/utils/date-macros.js +7 -0
- package/dist/utils/drill-down.d.ts +78 -0
- package/dist/utils/drill-down.js +93 -0
- package/dist/utils/emptiness.d.ts +96 -0
- package/dist/utils/emptiness.js +101 -0
- package/dist/utils/expand-fields.d.ts +76 -6
- package/dist/utils/expand-fields.js +76 -6
- package/dist/utils/extract-records.d.ts +65 -5
- package/dist/utils/extract-records.js +65 -8
- package/dist/utils/filter-converter.d.ts +152 -4
- package/dist/utils/filter-converter.js +1092 -22
- package/dist/utils/filter-tokens.d.ts +49 -5
- package/dist/utils/filter-tokens.js +105 -33
- package/dist/utils/grouping-fields.d.ts +61 -0
- package/dist/utils/grouping-fields.js +84 -0
- package/dist/utils/managedBy.d.ts +3 -3
- package/dist/utils/managedBy.js +5 -5
- package/dist/utils/native-date-value.d.ts +48 -0
- package/dist/utils/native-date-value.js +167 -0
- package/dist/utils/non-grid-row-ceiling.d.ts +143 -0
- package/dist/utils/non-grid-row-ceiling.js +141 -0
- package/dist/utils/normalize-list-view.d.ts +121 -1
- package/dist/utils/normalize-list-view.js +186 -6
- package/dist/utils/number-display.d.ts +63 -14
- package/dist/utils/number-display.js +34 -12
- package/dist/utils/object-aggregate-query.d.ts +113 -0
- package/dist/utils/object-aggregate-query.js +50 -0
- package/dist/utils/parent-scope.d.ts +123 -0
- package/dist/utils/parent-scope.js +140 -0
- package/dist/utils/predicate-fields.d.ts +45 -12
- package/dist/utils/predicate-fields.js +137 -12
- package/dist/utils/record-color.d.ts +75 -0
- package/dist/utils/record-color.js +65 -0
- package/dist/utils/record-source.d.ts +298 -0
- package/dist/utils/record-source.js +331 -0
- package/dist/utils/record-title.d.ts +148 -8
- package/dist/utils/record-title.js +220 -26
- package/dist/utils/reference-keys.d.ts +21 -22
- package/dist/utils/reference-keys.js +532 -29
- package/dist/utils/sort-query.d.ts +115 -15
- package/dist/utils/sort-query.js +240 -13
- package/dist/utils/text-comparand.d.ts +92 -0
- package/dist/utils/text-comparand.js +99 -0
- package/dist/utils/without-denied-fields.d.ts +49 -0
- package/dist/utils/without-denied-fields.js +52 -0
- package/dist/validation/index.d.ts +8 -4
- package/dist/validation/index.js +8 -4
- package/dist/validation/schema-validator.d.ts +1 -1
- package/dist/validation/schema-validator.js +200 -82
- package/dist/validation/validators/object-validation-engine.d.ts +1 -1
- package/dist/validation/validators/object-validation-engine.js +14 -3
- package/package.json +8 -9
- package/dist/data-scope/DataScopeManager.d.ts +0 -127
- package/dist/data-scope/DataScopeManager.js +0 -229
- package/dist/protocols/ResponsiveProtocol.d.ts +0 -73
- package/dist/protocols/ResponsiveProtocol.js +0 -158
- package/dist/validation/validation-engine.d.ts +0 -88
- package/dist/validation/validation-engine.js +0 -434
|
@@ -11,13 +11,9 @@
|
|
|
11
11
|
* Shared utilities for converting MongoDB-like filter operators
|
|
12
12
|
* to ObjectStack FilterNode AST format.
|
|
13
13
|
*/
|
|
14
|
-
import { normalizeFilterOperator } from '@objectstack/spec/ui';
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
*
|
|
18
|
-
* @param operator - MongoDB-style operator (e.g., '$gte', '$in')
|
|
19
|
-
* @returns ObjectStack operator or null if not recognized
|
|
20
|
-
*/
|
|
14
|
+
import { normalizeFilterOperator, VIEW_FILTER_OPERATORS, VIEW_FILTER_LIST_VALUE_OPERATORS, VIEW_FILTER_PAIR_VALUE_OPERATORS, } from '@objectstack/spec/ui';
|
|
15
|
+
import { isAcceptedFilterComparand, ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE } from '@objectstack/spec/data';
|
|
16
|
+
import { isRefusedTextComparand, textComparandRefusalReason } from './text-comparand.js';
|
|
21
17
|
/**
|
|
22
18
|
* A filter operator this layer will not translate.
|
|
23
19
|
*
|
|
@@ -27,7 +23,7 @@ import { normalizeFilterOperator } from '@objectstack/spec/ui';
|
|
|
27
23
|
* fault, which is the one thing this is definitely not.
|
|
28
24
|
*/
|
|
29
25
|
export class FilterOperatorError extends Error {
|
|
30
|
-
constructor(message) {
|
|
26
|
+
constructor(message, subject) {
|
|
31
27
|
super(message);
|
|
32
28
|
Object.defineProperty(this, "code", {
|
|
33
29
|
enumerable: true,
|
|
@@ -41,13 +37,75 @@ export class FilterOperatorError extends Error {
|
|
|
41
37
|
writable: true,
|
|
42
38
|
value: 400
|
|
43
39
|
});
|
|
40
|
+
/** @see FilterRefusalSubject */
|
|
41
|
+
Object.defineProperty(this, "operator", {
|
|
42
|
+
enumerable: true,
|
|
43
|
+
configurable: true,
|
|
44
|
+
writable: true,
|
|
45
|
+
value: void 0
|
|
46
|
+
});
|
|
47
|
+
/** @see FilterRefusalSubject */
|
|
48
|
+
Object.defineProperty(this, "field", {
|
|
49
|
+
enumerable: true,
|
|
50
|
+
configurable: true,
|
|
51
|
+
writable: true,
|
|
52
|
+
value: void 0
|
|
53
|
+
});
|
|
44
54
|
this.name = 'FilterOperatorError';
|
|
55
|
+
this.operator = subject?.operator;
|
|
56
|
+
this.field = subject?.field;
|
|
45
57
|
}
|
|
46
58
|
}
|
|
59
|
+
/**
|
|
60
|
+
* The one token a "this view's filter is malformed" state names.
|
|
61
|
+
*
|
|
62
|
+
* Operator first, because that is what the ruling asks the state to name and
|
|
63
|
+
* what an author can act on — `$regex` is not supported, `$startswith` is
|
|
64
|
+
* retired. Field second, for the refusals that have no operator to name — the
|
|
65
|
+
* two comparand arms and the empty operator map. `undefined` for neither, which no arm produces today and which a
|
|
66
|
+
* renderer must still handle rather than print "undefined" at a user.
|
|
67
|
+
*/
|
|
68
|
+
export function filterRefusalSubject(error) {
|
|
69
|
+
return error.operator ?? error.field ?? undefined;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The four lowercase `$`-dialect spellings this file used to accept "for
|
|
73
|
+
* tolerance", and the canonical spelling each author meant.
|
|
74
|
+
*
|
|
75
|
+
* RETIRED by objectui#8568, on the maintainer's ruling that this project follows
|
|
76
|
+
* the ObjectStack protocol and its documentation follows the implementation.
|
|
77
|
+
* They were a renderer-side second dialect — exactly what AGENTS.md #0.1
|
|
78
|
+
* refuses, and the same ground objectui#8447 stood on when it declined to teach
|
|
79
|
+
* the in-memory matcher the same arms ("would fossilise a second dialect").
|
|
80
|
+
* Until now `convertFiltersToAST` accepted them while `ValueDataSource` refused
|
|
81
|
+
* them, so ONE authored filter had two fates depending on which data source was
|
|
82
|
+
* behind the view. One dialect, one spelling.
|
|
83
|
+
*
|
|
84
|
+
* Refused BY NAME rather than dropped into the unknown-operator arm below,
|
|
85
|
+
* because the two answers cost an author very different amounts: "unknown
|
|
86
|
+
* operator, here is the supported list" makes them diff two lists and guess
|
|
87
|
+
* which entry they meant, while "write `$startsWith`" IS the repair. There is no
|
|
88
|
+
* deprecation window (maintainer, 2026-08-27), so this message is the entire
|
|
89
|
+
* migration aid — the reason it is worth spelling out.
|
|
90
|
+
*
|
|
91
|
+
* ⛔ Not a lowering table in disguise: nothing reads the value as an operator.
|
|
92
|
+
* Every VALUE is a member of the spec's `FILTER_OPERATORS` and every KEY is
|
|
93
|
+
* absent from it, and both directions are EXECUTED against
|
|
94
|
+
* `@objectstack/spec` in `filter-alias-retirement-8568.test.ts` rather than
|
|
95
|
+
* restated here, so this map cannot drift into prescribing a spelling the spec
|
|
96
|
+
* does not declare.
|
|
97
|
+
*/
|
|
98
|
+
const RETIRED_OPERATOR_ALIASES = {
|
|
99
|
+
'$notin': '$nin',
|
|
100
|
+
'$notcontains': '$notContains',
|
|
101
|
+
'$startswith': '$startsWith',
|
|
102
|
+
'$endswith': '$endsWith',
|
|
103
|
+
};
|
|
47
104
|
export function convertOperatorToAST(operator) {
|
|
48
105
|
// Spec reference: framework/packages/spec/src/data/filter.zod.ts
|
|
49
|
-
//
|
|
50
|
-
//
|
|
106
|
+
// Every key below is a canonical `FILTER_OPERATORS` spelling and nothing else.
|
|
107
|
+
// The four lowercase aliases that used to sit here are retired and refused by
|
|
108
|
+
// name (objectui#8568) — see RETIRED_OPERATOR_ALIASES above.
|
|
51
109
|
const operatorMap = {
|
|
52
110
|
'$eq': '=',
|
|
53
111
|
'$ne': '!=',
|
|
@@ -57,18 +115,357 @@ export function convertOperatorToAST(operator) {
|
|
|
57
115
|
'$lte': '<=',
|
|
58
116
|
'$in': 'in',
|
|
59
117
|
'$nin': 'nin',
|
|
60
|
-
'$notin': 'nin',
|
|
61
118
|
'$between': 'between',
|
|
62
119
|
'$contains': 'contains',
|
|
63
120
|
'$notContains': 'notcontains',
|
|
64
|
-
'$notcontains': 'notcontains',
|
|
65
121
|
'$startsWith': 'startswith',
|
|
66
|
-
'$startswith': 'startswith',
|
|
67
122
|
'$endsWith': 'endswith',
|
|
68
|
-
|
|
123
|
+
// Case-insensitive contains. A canonical `FILTER_OPERATORS` member that
|
|
124
|
+
// `ValueDataSource` executes and `FilterConditionField` emits (for its
|
|
125
|
+
// `icontains` builder row — spelled `containsCaseInsensitive` until
|
|
126
|
+
// objectui#9306), while this map refused it with the
|
|
127
|
+
// generic unknown-operator paragraph — so ONE authored filter selected rows
|
|
128
|
+
// through the in-memory matcher and 400'd on the ObjectStack lowering path
|
|
129
|
+
// (objectui#8976). The other direction of the same split objectui#8568 fixed:
|
|
130
|
+
// there the map was MORE tolerant than the matcher, here it was LESS tolerant
|
|
131
|
+
// than the contract. Restored by aligning the map with the spec, per AGENTS.md
|
|
132
|
+
// #0 — not by removing the operator from the builder, which the spec declares.
|
|
133
|
+
//
|
|
134
|
+
// The VALUE is an IDENTITY, and that is not a typo: `icontains` is itself a
|
|
135
|
+
// member of the spec's `VALID_AST_OPERATORS`, so unlike its camelCase siblings
|
|
136
|
+
// above there is no case to squash. Same identity row, for the same stated
|
|
137
|
+
// reason, that `FILTER_OPERATOR_ALIASES` carries in
|
|
138
|
+
// `packages/data-objectstack/src/index.ts`.
|
|
139
|
+
'$icontains': 'icontains',
|
|
69
140
|
};
|
|
70
141
|
return operatorMap[operator] || null;
|
|
71
142
|
}
|
|
143
|
+
/**
|
|
144
|
+
* The `FilterCondition` combinators this lowering can carry, and the AST
|
|
145
|
+
* keyword each becomes.
|
|
146
|
+
*
|
|
147
|
+
* The vocabulary is the spec's, not a second list: `FILTER_ARRAY_LOGIC_KEYWORDS`
|
|
148
|
+
* (`@objectstack/spec/data`) is `['and', 'or']` — measured — and those two are
|
|
149
|
+
* exactly the heads `isFilterAST` opens a group on. `$not` is absent from it,
|
|
150
|
+
* which is why it has no row here and is refused below rather than translated.
|
|
151
|
+
*/
|
|
152
|
+
const AST_LOGIC_KEYWORD = {
|
|
153
|
+
$and: 'and',
|
|
154
|
+
$or: 'or',
|
|
155
|
+
};
|
|
156
|
+
/**
|
|
157
|
+
* Lower ONE `$and` / `$or` group to an AST group node.
|
|
158
|
+
*
|
|
159
|
+
* Returns `undefined` when the group is the TRUE identity and therefore
|
|
160
|
+
* constrains nothing, so the caller drops it instead of emitting a childless
|
|
161
|
+
* `['and']` — which is NOT the same thing. Measured against the spec's own
|
|
162
|
+
* doors: `isFilterAST(['and'])` is `false` and `parseFilterAST(['and'])` is
|
|
163
|
+
* `undefined`, i.e. NO FILTER — every row. A combinator that reduces to "no
|
|
164
|
+
* constraint" must therefore disappear at THIS level; emitting an empty group
|
|
165
|
+
* would widen the result set, which is the one failure direction this file
|
|
166
|
+
* exists to avoid.
|
|
167
|
+
*
|
|
168
|
+
* `objectstack-ai/objectstack#5322` (maintainer ruling 2026-08-04, recorded on `FilterConditionSchema`)
|
|
169
|
+
* fixes the identities: `{ $and: [] }` is TRUE, `{ $or: [] }` is FALSE, and a
|
|
170
|
+
* `{}` disjunct is TRUE and ABSORBS its `$or`. TRUE is expressible here — it is
|
|
171
|
+
* the absence of a constraint. FALSE is not: the AST has no contradiction
|
|
172
|
+
* literal, so `{ $or: [] }` keeps the emission it already had (see
|
|
173
|
+
* {@link falseIdentityLeaf}).
|
|
174
|
+
*/
|
|
175
|
+
function lowerLogicalGroup(field, keyword, value) {
|
|
176
|
+
if (!Array.isArray(value)) {
|
|
177
|
+
throw new FilterOperatorError(`[ObjectUI] The '${field}' filter combinator takes an ARRAY of conditions. ` +
|
|
178
|
+
`Received ${typeof value === 'object' ? 'an object' : typeof value}: ` +
|
|
179
|
+
`${JSON.stringify(value)}. Spec: FilterCondition declares ` +
|
|
180
|
+
`'${field}?: FilterCondition[]' (data/filter.zod.ts).`, { operator: field });
|
|
181
|
+
}
|
|
182
|
+
if (value.length === 0) {
|
|
183
|
+
return keyword === 'and' ? undefined : falseIdentityLeaf(field, value);
|
|
184
|
+
}
|
|
185
|
+
const children = [];
|
|
186
|
+
// Set when an `$or` member is the TRUE identity. Recorded, not returned
|
|
187
|
+
// on the spot (objectui#10789): the members AFTER it still have to be read,
|
|
188
|
+
// or a malformed one is refused only when it happens to come FIRST —
|
|
189
|
+
// `{ $or: [{ a: {} }, {}] }` refused while `{ $or: [{}, { a: {} }] }`
|
|
190
|
+
// answered TRUE, the same two members with two fates. The absorption itself
|
|
191
|
+
// is unchanged; it is decided after the loop, once every member was judged.
|
|
192
|
+
let absorbed = false;
|
|
193
|
+
for (const child of value) {
|
|
194
|
+
if (child === null || typeof child !== 'object' || Array.isArray(child)) {
|
|
195
|
+
throw new FilterOperatorError(`[ObjectUI] Every member of '${field}' must be a filter condition OBJECT. ` +
|
|
196
|
+
`Received ${JSON.stringify(child)}. Spec: FilterCondition declares ` +
|
|
197
|
+
`'${field}?: FilterCondition[]' (data/filter.zod.ts).`, { operator: field });
|
|
198
|
+
}
|
|
199
|
+
const lowered = convertFiltersToAST(child);
|
|
200
|
+
if (!Array.isArray(lowered)) {
|
|
201
|
+
// A child that produced no conditions comes back as a NON-ARRAY, in one
|
|
202
|
+
// of two spellings: the ORIGINAL OBJECT for a `{}` disjunct, and
|
|
203
|
+
// `undefined` for a child that is itself a TRUE-identity group
|
|
204
|
+
// (`{ $and: [{ $and: [] }] }` — objectui#8770's tail below) or holds only
|
|
205
|
+
// null/undefined values (objectui#9020). A child that is an EMPTY
|
|
206
|
+
// OPERATOR MAP (`{ a: {} }`) never gets here: the same tail refuses it,
|
|
207
|
+
// and the refusal propagates out of the whole filter (objectui#9164).
|
|
208
|
+
// Both spellings mean the same thing here, which is
|
|
209
|
+
// why the test is `Array.isArray` and not a comparison against either
|
|
210
|
+
// spelling: that child is the TRUE identity (objectstack-ai/objectstack#5322), so it absorbs an
|
|
211
|
+
// `$or` outright and drops out of an `$and`. It must not be pushed as a
|
|
212
|
+
// child either way: an object in AST child position makes `isFilterAST`
|
|
213
|
+
// false (measured), and the wire face answers `400 INVALID_FILTER` for
|
|
214
|
+
// the whole filter.
|
|
215
|
+
if (keyword === 'or')
|
|
216
|
+
absorbed = true;
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
children.push(lowered);
|
|
220
|
+
}
|
|
221
|
+
// A TRUE disjunct absorbs its `$or` (objectstack-ai/objectstack#5322) — now that every member was read.
|
|
222
|
+
if (absorbed)
|
|
223
|
+
return undefined;
|
|
224
|
+
// Every conjunct reduced to TRUE, so the `$and` constrains nothing.
|
|
225
|
+
if (children.length === 0)
|
|
226
|
+
return undefined;
|
|
227
|
+
// A one-child group is emitted as a group, not unwrapped. `['or', node]` is
|
|
228
|
+
// accepted by `isFilterAST` (length >= 2 = keyword + one condition) and
|
|
229
|
+
// `parseFilterAST` reduces it to the child, so the extra hop costs nothing
|
|
230
|
+
// and keeps this function's output shape a function of the INPUT shape.
|
|
231
|
+
return [keyword, ...children];
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* `{ $or: [] }` — FALSE, the OR identity (objectstack-ai/objectstack#5322) — as the leaf this file has
|
|
235
|
+
* always emitted for it.
|
|
236
|
+
*
|
|
237
|
+
* Deliberately unchanged, and deliberately not a group node. Three measurements
|
|
238
|
+
* against `@objectstack/spec` 17.3.0 and this repo's own evaluator decide it:
|
|
239
|
+
*
|
|
240
|
+
* - `parseFilterAST(['$or', '=', []])` is `{ $or: [] }` — the FilterCondition
|
|
241
|
+
* the author wrote, which every backend reduces to zero rows. Correct.
|
|
242
|
+
* - `ValueDataSource`'s matcher reads it as a comparison on a field named
|
|
243
|
+
* `$or`, which no record has, so it excludes every row. Also correct, and
|
|
244
|
+
* the same answer.
|
|
245
|
+
* - `['or']` — the "obvious" empty group — is `isFilterAST` FALSE and
|
|
246
|
+
* `parseFilterAST` `undefined`: no filter at all, i.e. EVERY row. That is
|
|
247
|
+
* the widening direction, on a filter whose whole purpose is to hide rows
|
|
248
|
+
* (objectstack-ai/objectstack#5134), so it is the one shape that must not be emitted.
|
|
249
|
+
*
|
|
250
|
+
* A leaf naming `$or` as a field is not a shape to be proud of; it is the shape
|
|
251
|
+
* that answers FALSE at both consumers, which the alternatives do not.
|
|
252
|
+
*/
|
|
253
|
+
function falseIdentityLeaf(field, value) {
|
|
254
|
+
return [field, '=', value];
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Is `value` an OPERATOR MAP — the shape the operator loop reads — or a
|
|
258
|
+
* comparand the author wrote?
|
|
259
|
+
*
|
|
260
|
+
* The loop's premise is that an object which is not an accepted comparand is a
|
|
261
|
+
* map of `$` operators. That premise holds for exactly one population: a PLAIN
|
|
262
|
+
* object, which is what an author writes and what `JSON.parse` of stored
|
|
263
|
+
* metadata produces. An exotic instance — `RegExp`, `Set`, `Map`, `URL`, a class
|
|
264
|
+
* instance — is not a map of operators under any reading; it is a VALUE, and
|
|
265
|
+
* `Object.entries` of each of those is `[]` (measured), which is why the loop
|
|
266
|
+
* used to run zero times and push NO condition for the field.
|
|
267
|
+
*
|
|
268
|
+
* The PROTOTYPE is the test, deliberately, and not `Object.keys(value).length
|
|
269
|
+
* === 0`: zero own entries is exactly what `{}` and `/x/` have in COMMON, and
|
|
270
|
+
* they need different answers — `{}` is an operator map that names no
|
|
271
|
+
* operator, refused as such (objectui#9164 / objectui#10788), while `/x/` is a
|
|
272
|
+
* VALUE the spec does not accept as a comparand, refused as that — and a keys
|
|
273
|
+
* count cannot tell the two apart. Prototype can. (Until objectui#10788 `{}`
|
|
274
|
+
* beside a key that lowers was the TRUE identity that constrained nothing,
|
|
275
|
+
* pinned in filter-date-comparand-8555.test.ts section 4; that row now pins
|
|
276
|
+
* the refusal.)
|
|
277
|
+
*
|
|
278
|
+
* The second hop admits a CROSS-REALM plain object (an iframe, a `vm` context),
|
|
279
|
+
* whose prototype is that realm's `Object.prototype` rather than this one's: a
|
|
280
|
+
* plain object's prototype has a null prototype, while a `RegExp`'s chain is one
|
|
281
|
+
* link longer. So the test is about SHAPE, not about object identity.
|
|
282
|
+
*
|
|
283
|
+
* A plain object with no entries stays an operator map with no operators,
|
|
284
|
+
* whatever else it happens to be. That boundary is deliberate:
|
|
285
|
+
* this predicate separates values from operator maps, and it is not a second
|
|
286
|
+
* opinion about the identity objectui#5322 already ruled on. What the converter
|
|
287
|
+
* then DOES with an operator map that has no operators is decided by its
|
|
288
|
+
* caller, not here: it is refused wherever it appears (objectui#9164 alone,
|
|
289
|
+
* objectui#10788 beside a key that lowers; see refuseEmptyOperatorMap).
|
|
290
|
+
*/
|
|
291
|
+
function isOperatorMap(value) {
|
|
292
|
+
const proto = Object.getPrototypeOf(value);
|
|
293
|
+
return proto === null || proto === Object.prototype || Object.getPrototypeOf(proto) === null;
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Name the offending comparand for the refusal message.
|
|
297
|
+
*
|
|
298
|
+
* `JSON.stringify` is what every other message in this file uses, and it is
|
|
299
|
+
* useless here: it renders a `RegExp`, a `Set` and a `Map` all as `{}` — the
|
|
300
|
+
* spec's own refusal prints `({})` for a RegExp for exactly this reason. The
|
|
301
|
+
* constructor name plus `String(value)` prints `/abc/` for a pattern and falls
|
|
302
|
+
* back to the type alone when the value has no useful text form.
|
|
303
|
+
*
|
|
304
|
+
* Every read is guarded: a refusal message must never itself throw, or the
|
|
305
|
+
* loud failure this arm exists to produce becomes a different, confusing one.
|
|
306
|
+
*/
|
|
307
|
+
function describeExoticComparand(value) {
|
|
308
|
+
let name;
|
|
309
|
+
let text;
|
|
310
|
+
try {
|
|
311
|
+
const proto = Object.getPrototypeOf(value);
|
|
312
|
+
name = proto?.constructor?.name;
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
name = undefined;
|
|
316
|
+
}
|
|
317
|
+
if (typeof name !== 'string' || name === '')
|
|
318
|
+
return 'an object of an unrecognised type';
|
|
319
|
+
try {
|
|
320
|
+
text = String(value);
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
text = undefined;
|
|
324
|
+
}
|
|
325
|
+
return text && text !== `[object ${name}]` ? `a ${name} instance (${text})` : `a ${name} instance`;
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* An `$icontains` comparand that is not a NON-EMPTY STRING —
|
|
329
|
+
* `{ name: { $icontains: '' } }`, `{ name: { $icontains: 42 } }` (objectui#9001).
|
|
330
|
+
*
|
|
331
|
+
* ## Not this file's ruling, and not a new one
|
|
332
|
+
*
|
|
333
|
+
* `FILTER_TEXT_CASES` (`@objectstack/spec/data`) carries both shapes as
|
|
334
|
+
* REJECTION rows rather than as row-set expectations, each with
|
|
335
|
+
* `code: 'INVALID_FILTER'` and `mustMention: ['$icontains']`:
|
|
336
|
+
*
|
|
337
|
+
* > *an empty `$icontains` comparand is REFUSED* — "Every row contains the
|
|
338
|
+
* > empty substring, so evaluating it is a predicate that constrains nothing —
|
|
339
|
+
* > the widening [objectstack-ai/objectstack#5240] refused `{ field: {} }` over, one level in."
|
|
340
|
+
*
|
|
341
|
+
* > *a non-string `$icontains` comparand is REFUSED* — "Coercing 42 to `"42"`
|
|
342
|
+
* > would answer a query nobody wrote; the declared comparand type is string."
|
|
343
|
+
*
|
|
344
|
+
* `ValueDataSource` has answered both since objectui#8748
|
|
345
|
+
* (`refuseTextComparand`), so this is a PORT of a shipped, reviewed
|
|
346
|
+
* implementation one data source over — deliberately NOT a second design.
|
|
347
|
+
* Measured on `origin/main` `152f0a700` (objectui#8996 and objectui#9019 both
|
|
348
|
+
* landed), both faces in one process:
|
|
349
|
+
*
|
|
350
|
+
* ```
|
|
351
|
+
* $icontains '' LOWER=["name","icontains",""] MATCH=[] REFUSED(1)
|
|
352
|
+
* $icontains 42 LOWER=["name","icontains",42] MATCH=[] REFUSED(1)
|
|
353
|
+
* ```
|
|
354
|
+
*
|
|
355
|
+
* One authored filter, refused by the in-memory matcher and lowered onto the
|
|
356
|
+
* wire by the ObjectStack path — the same acceptance-set split objectui#8568 and
|
|
357
|
+
* objectui#8976 each closed on the operator-KEY axis, here on the COMPARAND
|
|
358
|
+
* axis. ⚠️ objectui#8996 did not open this door: before it the `$` dialect was
|
|
359
|
+
* stopped by the generic unknown-operator arm, an accident rather than a
|
|
360
|
+
* comparand ruling, while the object-form dialect
|
|
361
|
+
* (`FILTER_OPERATOR_ALIASES` in `@object-ui/data-objectstack`) already lowered
|
|
362
|
+
* the same node with no converter involved.
|
|
363
|
+
*
|
|
364
|
+
* ## What transfers from the sibling, and what cannot
|
|
365
|
+
*
|
|
366
|
+
* The DISCRIMINATION and the MESSAGE transfer verbatim, and the message is
|
|
367
|
+
* load-bearing rather than cosmetic: `mustMention: ['$icontains']` means a
|
|
368
|
+
* differently-worded refusal is a different failure to honour the same
|
|
369
|
+
* contract, not a stylistic variant.
|
|
370
|
+
* `filter-text-comparand-9001.test.ts` pins the two messages against each other
|
|
371
|
+
* by DRIVING both faces and asserting this one contains the sibling's refusal
|
|
372
|
+
* text, so the mirror cannot drift in silence.
|
|
373
|
+
*
|
|
374
|
+
* ⭐ Since objectui#9048 "transfer verbatim" is no longer a claim about two
|
|
375
|
+
* copies agreeing: both halves are ONE implementation in `text-comparand.ts`,
|
|
376
|
+
* and this function is the envelope that seats it. The pin above is kept, and
|
|
377
|
+
* is now a pin on the SEATING rather than on a transcription.
|
|
378
|
+
*
|
|
379
|
+
* The DELIVERY cannot transfer, and that is the card's own Q1 answered by the
|
|
380
|
+
* two call sites rather than by a fresh ruling. `ValueDataSource`
|
|
381
|
+
* excludes-and-logs because it is deciding about one ROW and HAS a row to
|
|
382
|
+
* exclude; this function is the PRODUCER deciding whether to send a query at
|
|
383
|
+
* all, and has none. Its declared refusal shape is {@link FilterOperatorError},
|
|
384
|
+
* which carries exactly the `INVALID_FILTER` / 400 envelope the spec rows
|
|
385
|
+
* declare and which this file already uses for `$regex`, `$not`, the retired
|
|
386
|
+
* aliases and three comparand shapes. Same code, same message, each face's own
|
|
387
|
+
* envelope.
|
|
388
|
+
*
|
|
389
|
+
* ## Scope
|
|
390
|
+
*
|
|
391
|
+
* Only `$icontains`, because only `$icontains` is what the table declares. The
|
|
392
|
+
* sibling positive operators (`$contains` / `$startsWith` / `$endsWith`) have no
|
|
393
|
+
* such row and keep the answer they have always given here — the same boundary
|
|
394
|
+
* `refuseTextComparand`'s own docblock draws one data source over. Widening it
|
|
395
|
+
* by analogy is the published table's decision, not this file's, and the
|
|
396
|
+
* asymmetry is pinned so the next reader sees a scope boundary rather than an
|
|
397
|
+
* oversight.
|
|
398
|
+
*/
|
|
399
|
+
function refuseTextComparand(field, operator, target) {
|
|
400
|
+
const ported = `It is refused here rather than lowered onto the wire (objectui#9001; ported from `
|
|
401
|
+
+ `ValueDataSource's refuseTextComparand, objectui#8748).`;
|
|
402
|
+
throw new FilterOperatorError(`[ObjectUI] The ${textComparandRefusalReason(field, operator, target)}. ${ported}`, { operator, field });
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* A field that names NO operator — `{ a: {} }` — wherever it appears in a
|
|
406
|
+
* filter: alone (objectui#9164) or beside a key that lowers (objectui#10788).
|
|
407
|
+
*
|
|
408
|
+
* ## What it used to do
|
|
409
|
+
*
|
|
410
|
+
* The loop ENTERS the key — the value is a plain object, so it is an operator
|
|
411
|
+
* map — and the operator loop runs zero times, so NO condition is pushed for
|
|
412
|
+
* the field. What happened next depended on the field's SIBLINGS, the hazard
|
|
413
|
+
* objectui#8555 named on this file:
|
|
414
|
+
*
|
|
415
|
+
* - beside a key that lowers, the sibling's condition carried the filter and
|
|
416
|
+
* the field VANISHED from it: `{ status: 'a', created: {} }` lowered to
|
|
417
|
+
* `['status', '=', 'a']`, byte-identical to `{ status: 'a' }` on both
|
|
418
|
+
* `find()` routes of `@object-ui/data-objectstack` — a result set WIDER than
|
|
419
|
+
* the author wrote, silently (objectui#10788);
|
|
420
|
+
* - alone, the general tail handed back the CALLER'S ORIGINAL OBJECT, which
|
|
421
|
+
* reached those two routes as two different wrong requests: the plain route
|
|
422
|
+
* spread it into query parameters (`?a=[object Object]`, and no `filter`
|
|
423
|
+
* parameter at all), the `$expand` / `$search` route serialised it into
|
|
424
|
+
* `filter={"a":{}}` (objectui#9164).
|
|
425
|
+
*
|
|
426
|
+
* ## Why a refusal, and not a fold or a lowering
|
|
427
|
+
*
|
|
428
|
+
* - There is nothing to LOWER it to. `@objectstack/spec` ruled
|
|
429
|
+
* `{ field: {} }` REJECTED (objectstack#5240, recorded on
|
|
430
|
+
* `FilterConditionSchema`) in every position, so the request above is one
|
|
431
|
+
* the platform refuses, and inventing a node for it would invent semantics.
|
|
432
|
+
* - FOLDING it — dropping the key, which is what the beside-a-key case did —
|
|
433
|
+
* silently drops a filter the author wrote, and "no constraint on this
|
|
434
|
+
* field" widens the result on a slot whose purpose can be to hide rows.
|
|
435
|
+
* - A refusal is what this function already answers for the bare-array and the
|
|
436
|
+
* exotic-comparand arms, for the same reason: the author learned nothing at
|
|
437
|
+
* lowering time.
|
|
438
|
+
*
|
|
439
|
+
* ## Where — the operator-map arm, not the tail
|
|
440
|
+
*
|
|
441
|
+
* It is refused where it is met, in the loop, like the two comparand arms.
|
|
442
|
+
* objectui#9164 judged it in the tail and so reached it only when nothing else
|
|
443
|
+
* produced a condition; beside a key that lowers the tail is never entered,
|
|
444
|
+
* which is exactly the case objectui#10788 closes. One throw site serves both,
|
|
445
|
+
* so the alone case is not refused twice over and cannot drift from the
|
|
446
|
+
* beside case. A `$and` / `$or` member is converted by this same function, so
|
|
447
|
+
* a member that is or carries an empty operator map is refused too, once
|
|
448
|
+
* `lowerLogicalGroup` converts it (a TRUE disjunct earlier in an `$or` still
|
|
449
|
+
* returns first; objectui#10789).
|
|
450
|
+
*
|
|
451
|
+
* `field` is the only handle, so it travels as the refusal's subject and no
|
|
452
|
+
* `operator` is set: the author wrote none, and naming one would send them
|
|
453
|
+
* looking for a key their filter does not contain.
|
|
454
|
+
*/
|
|
455
|
+
function refuseEmptyOperatorMap(field) {
|
|
456
|
+
throw new FilterOperatorError(`[ObjectUI] The filter on field '${field}' is an EMPTY operator map — ` +
|
|
457
|
+
`{ ${field}: {} } names the field and no operator. It cannot be lowered: the ` +
|
|
458
|
+
`ObjectQL filter AST has no node for a field with zero operators, and ` +
|
|
459
|
+
`'@objectstack/spec' ruled the shape REJECTED wherever it appears ` +
|
|
460
|
+
`(objectstack#5240, recorded on FilterConditionSchema). It is refused here ` +
|
|
461
|
+
`rather than dropped: beside a key that lowers, the '${field}' condition used ` +
|
|
462
|
+
`to vanish from the filter and silently WIDEN the result set (objectui#10788), ` +
|
|
463
|
+
`and alone it was handed back as the caller's object, which reached the wire ` +
|
|
464
|
+
`as ?${field}=[object Object] on one route and as a filter the server refuses ` +
|
|
465
|
+
`on the other (objectui#9164). Choose an operator — ` +
|
|
466
|
+
`{ ${field}: { $eq: ... } }, { ${field}: { $in: [...] } }, ` +
|
|
467
|
+
`{ ${field}: { $null: true } } — or remove the key.`, { field });
|
|
468
|
+
}
|
|
72
469
|
/**
|
|
73
470
|
* Convert object-based filters to ObjectStack FilterNode AST format.
|
|
74
471
|
* Converts MongoDB-like operators to ObjectStack filter expressions.
|
|
@@ -91,17 +488,257 @@ export function convertOperatorToAST(operator) {
|
|
|
91
488
|
* convertFiltersToAST({ age: { $gte: 18, $lte: 65 }, status: 'active' })
|
|
92
489
|
* // => ['and', ['age', '>=', 18], ['age', '<=', 65], ['status', '=', 'active']]
|
|
93
490
|
*
|
|
94
|
-
* @
|
|
491
|
+
* @example
|
|
492
|
+
* // Logical combinators (objectui#6948) — children lower recursively
|
|
493
|
+
* convertFiltersToAST({ $or: [{ status: 'open' }, { status: 'blocked' }] })
|
|
494
|
+
* // => ['or', ['status', '=', 'open'], ['status', '=', 'blocked']]
|
|
495
|
+
*
|
|
496
|
+
* @example
|
|
497
|
+
* // A Date is a comparand, not an operator map (objectui#8555). It lowers as
|
|
498
|
+
* // the Date INSTANCE — the spec accepts one, and the operator form already
|
|
499
|
+
* // emits one.
|
|
500
|
+
* convertFiltersToAST({ created: new Date('2026-01-01') })
|
|
501
|
+
* // => ['created', '=', Date(2026-01-01)]
|
|
502
|
+
*
|
|
503
|
+
* @throws {FilterOperatorError} If an unknown operator is encountered, if
|
|
504
|
+
* `$not` is used — see the `$not` arm for why the AST cannot carry it — if a
|
|
505
|
+
* field's value is a bare ARRAY (`{ tags: ['a', 'b'] }`) — see the array arm for
|
|
506
|
+
* why that is refused rather than read as `$in` — or if a field's value is an
|
|
507
|
+
* EXOTIC object the spec does not accept as a comparand (`{ name: /abc/ }`,
|
|
508
|
+
* a `Set`, a `Map`): see the exotic-comparand arm for why that is refused
|
|
509
|
+
* rather than dropped (objectui#8567), or if an `$icontains` comparand is not a
|
|
510
|
+
* NON-EMPTY STRING (`{ name: { $icontains: '' } }`, `{ name: { $icontains: 42 } }`)
|
|
511
|
+
* — two shapes `@objectstack/spec`'s `FILTER_TEXT_CASES` declares REFUSED and
|
|
512
|
+
* `ValueDataSource` has refused since objectui#8748; see
|
|
513
|
+
* {@link refuseTextComparand} (objectui#9001), or if a field is an EMPTY
|
|
514
|
+
* operator map (`{ a: {} }`) — alone (objectui#9164) or
|
|
515
|
+
* beside a key that lowers (objectui#10788): see {@link refuseEmptyOperatorMap},
|
|
516
|
+
* or if a `$empty` flag is not a boolean (`{ a: { $empty: 'yes' } }`,
|
|
517
|
+
* objectui#11094).
|
|
518
|
+
*
|
|
519
|
+
* @example
|
|
520
|
+
* // A field with NO operator (objectui#9164)
|
|
521
|
+
* convertFiltersToAST({ a: {} })
|
|
522
|
+
* // => throws FilterOperatorError (INVALID_FILTER / 400), field 'a'
|
|
523
|
+
*
|
|
524
|
+
* @example
|
|
525
|
+
* // … and beside a key that lowers, where it used to vanish and widen the
|
|
526
|
+
* // result to `['status', '=', 'a']` (objectui#10788)
|
|
527
|
+
* convertFiltersToAST({ status: 'a', created: {} })
|
|
528
|
+
* // => throws FilterOperatorError (INVALID_FILTER / 400), field 'created'
|
|
529
|
+
*
|
|
530
|
+
* @example
|
|
531
|
+
* // A filter that is NOTHING BUT combinators reducing to the TRUE identity
|
|
532
|
+
* // constrains nothing, and says so (objectui#8770). Callers skip the slot.
|
|
533
|
+
* convertFiltersToAST({ $and: [] })
|
|
534
|
+
* // => undefined
|
|
535
|
+
*
|
|
536
|
+
* @example
|
|
537
|
+
* // A filter whose EVERY key is skipped for a null/undefined value also
|
|
538
|
+
* // constrains nothing — the same answer this function already gives that key
|
|
539
|
+
* // when a sibling survives (objectui#9020). Callers skip the slot.
|
|
540
|
+
* convertFiltersToAST({ a: null })
|
|
541
|
+
* // => undefined
|
|
542
|
+
*
|
|
543
|
+
* @example
|
|
544
|
+
* // … and a filter that MIXES the two constrains nothing either, because the
|
|
545
|
+
* // identity fold counts only the keys the loop actually PROCESSED
|
|
546
|
+
* // (objectui#9030). A skipped key used to be counted against that fold, so a
|
|
547
|
+
* // filter whose every key folds alone did not fold together.
|
|
548
|
+
* convertFiltersToAST({ $and: [], b: undefined })
|
|
549
|
+
* // => undefined
|
|
95
550
|
*/
|
|
96
551
|
export function convertFiltersToAST(filter) {
|
|
97
552
|
const conditions = [];
|
|
553
|
+
/**
|
|
554
|
+
* How many keys were a `$and` / `$or` group that reduced to the TRUE identity
|
|
555
|
+
* — see the tail for why a COUNT rather than a flag.
|
|
556
|
+
*/
|
|
557
|
+
let trueIdentityGroups = 0;
|
|
558
|
+
/**
|
|
559
|
+
* How many keys the loop SKIPPED for a `null` / `undefined` value — a SECOND
|
|
560
|
+
* count, deliberately not folded into the one above. The tail says why the
|
|
561
|
+
* two "this constrains nothing" states are counted apart even though they
|
|
562
|
+
* reach the same answer (objectui#9020).
|
|
563
|
+
*/
|
|
564
|
+
let skippedNullKeys = 0;
|
|
98
565
|
for (const [field, value] of Object.entries(filter)) {
|
|
99
|
-
if (value === null || value === undefined)
|
|
566
|
+
if (value === null || value === undefined) {
|
|
567
|
+
skippedNullKeys += 1;
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
// Logical combinators are read BEFORE the field/operator machinery below,
|
|
571
|
+
// because they are not fields and their value is not an operator map.
|
|
572
|
+
// Without this arm `$and` / `$or` reached the simple-equality branch (their
|
|
573
|
+
// value is an array, so the operator loop was skipped) and became a leaf
|
|
574
|
+
// naming a field literally called `$and` / `$or`, while `$not` entered the
|
|
575
|
+
// operator loop with its OWN nested object's keys read as operator names.
|
|
576
|
+
const logicKeyword = AST_LOGIC_KEYWORD[field];
|
|
577
|
+
if (logicKeyword) {
|
|
578
|
+
const group = lowerLogicalGroup(field, logicKeyword, value);
|
|
579
|
+
if (group !== undefined)
|
|
580
|
+
conditions.push(group);
|
|
581
|
+
else
|
|
582
|
+
trueIdentityGroups += 1;
|
|
100
583
|
continue;
|
|
584
|
+
}
|
|
585
|
+
if (field === '$not') {
|
|
586
|
+
throw new FilterOperatorError(`[ObjectUI] The '$not' filter combinator cannot be lowered to the ObjectQL ` +
|
|
587
|
+
`filter AST. '@objectstack/spec' declares it on FilterCondition, but the AST ` +
|
|
588
|
+
`this layer emits has no negation keyword (FILTER_ARRAY_LOGIC_KEYWORDS is ` +
|
|
589
|
+
`['and', 'or']), and rewriting the negation inward is not available either — ` +
|
|
590
|
+
`'startswith', 'endswith', 'between' and 'icontains' have no negated ` +
|
|
591
|
+
`counterpart in VALID_AST_OPERATORS, so the rewrite would be silently ` +
|
|
592
|
+
`partial. Express the negation with a negated operator instead ($ne, $nin, ` +
|
|
593
|
+
`$notContains); note those follow each operator's own answer for a missing ` +
|
|
594
|
+
`value rather than $not's NULL-safe rule (objectstack#5146). ` +
|
|
595
|
+
`Value: ${JSON.stringify(value)}.`, { operator: '$not' });
|
|
596
|
+
}
|
|
597
|
+
// A bare ARRAY in comparand position is the third shape this file cannot
|
|
598
|
+
// lower, after `$regex` and `$not` (objectui#8530). The equality `else`
|
|
599
|
+
// below used to catch it and emit `[field, '=', [...]]` — an array in a
|
|
600
|
+
// scalar-equality slot. The spec's own doors pass that node through
|
|
601
|
+
// unjudged (`isFilterAST` is true and `parseFilterAST` hands back
|
|
602
|
+
// `{ field: [...] }`; measured against @objectstack/spec 17.3.0, whose
|
|
603
|
+
// `assertListComparandShapes` rules only on `$in` / `$nin` / `$between`),
|
|
604
|
+
// so the refusal arrived two layers later: `@objectstack/driver-sql`
|
|
605
|
+
// answers 400 INVALID_FILTER, and the in-memory matchers
|
|
606
|
+
// (`@objectstack/formula`, `ValueDataSource` since objectui#8514) refuse
|
|
607
|
+
// the node and exclude every row. Either way the author learned nothing at
|
|
608
|
+
// lowering time. Refused HERE instead, where the field name and the
|
|
609
|
+
// offending value are both still in hand.
|
|
610
|
+
//
|
|
611
|
+
// NOT lowered to `in`. `{ tags: ['a', 'b'] }` and `{ tags: { $in: ['a', 'b'] } }`
|
|
612
|
+
// are different statements and the second is already spellable; rewriting
|
|
613
|
+
// one into the other guesses at intent and silently changes which rows a
|
|
614
|
+
// stored view returns — the lenient second contract objectui#8514 was
|
|
615
|
+
// resolved against on this same data shape one layer down. A lowering to
|
|
616
|
+
// `in` would be a deliberate, separately-argued change, not a fallback.
|
|
617
|
+
//
|
|
618
|
+
// `$in` / `$nin` / `$between` MEMBERS are legitimately arrays and are read
|
|
619
|
+
// by the operator loop below, never here; `$and` / `$or` carry arrays of
|
|
620
|
+
// conditions and were consumed by the combinator arm above.
|
|
621
|
+
if (Array.isArray(value)) {
|
|
622
|
+
throw new FilterOperatorError(`[ObjectUI] The filter on field '${field}' carries a bare ARRAY as its ` +
|
|
623
|
+
`equality comparand: ${JSON.stringify(value)}. It cannot be lowered: the ` +
|
|
624
|
+
`ObjectQL filter AST has no array-equality node, so the lowered node ` +
|
|
625
|
+
`[${field}, '=', [...]] is refused by @objectstack/driver-sql (400 INVALID_FILTER) ` +
|
|
626
|
+
`and matches no row in the in-memory matchers — it can never select anything. ` +
|
|
627
|
+
`It is deliberately NOT read as membership here: { ${field}: [...] } and ` +
|
|
628
|
+
`{ ${field}: { $in: [...] } } are different statements, and guessing the ` +
|
|
629
|
+
`second from the first would silently change which rows a stored view ` +
|
|
630
|
+
`returns (objectui#8530; the same ruling objectui#8514 applied one layer ` +
|
|
631
|
+
`down). Spell membership as { ${field}: { $in: [...] } }, its negation as ` +
|
|
632
|
+
`{ ${field}: { $nin: [...] } }, or a range as { ${field}: { $between: [min, max] } }.`, { field });
|
|
633
|
+
}
|
|
101
634
|
// Check if value is a complex operator object
|
|
102
635
|
if (typeof value === 'object' && !Array.isArray(value)) {
|
|
636
|
+
// A `Date` is a COMPARAND, not an operator map — objectui#8555.
|
|
637
|
+
//
|
|
638
|
+
// `typeof new Date()` is `'object'` and a Date is not an array, so it used
|
|
639
|
+
// to enter the loop below; `Object.entries(someDate)` is `[]`, the body
|
|
640
|
+
// never ran, and NO condition was pushed for the field. Not refused, not
|
|
641
|
+
// lowered wrongly — ABSENT, so `{ status: 'a', created: someDate }`
|
|
642
|
+
// lowered to `['status', '=', 'a']` and the result set got WIDER than the
|
|
643
|
+
// author asked for, silently. That is the one failure direction this file
|
|
644
|
+
// exists to avoid. It also made the field's behaviour depend on its
|
|
645
|
+
// SIBLINGS: a Date alone left `conditions` empty, so the original object
|
|
646
|
+
// came back untouched and the defect was invisible until a second field
|
|
647
|
+
// appeared.
|
|
648
|
+
//
|
|
649
|
+
// Lowered rather than refused, and the spec is what decides it — the
|
|
650
|
+
// opposite answer to objectui#8514, which was a refusal precisely because
|
|
651
|
+
// the spec DECLINED to rule on that shape. Here it rules, twice over
|
|
652
|
+
// (measured against @objectstack/spec 17.3.0):
|
|
653
|
+
// `ACCEPTED_FILTER_COMPARAND_TYPES` is
|
|
654
|
+
// `['string','number','bigint','boolean','null','Date']`, and
|
|
655
|
+
// `$gt`/`$gte`/`$lt`/`$lte`/`$between` declare `z.ZodDate` in comparand
|
|
656
|
+
// position. So a Date is a first-class filter comparand, not a shape this
|
|
657
|
+
// layer has to invent an answer for.
|
|
658
|
+
//
|
|
659
|
+
// ⛔ NOT converted to an ISO string or an epoch here. The wire form is not
|
|
660
|
+
// this adapter's question to answer: `parseFilterAST(['created', '=', d])`
|
|
661
|
+
// hands back `{ created: d }` with the Date INSTANCE intact (measured), and
|
|
662
|
+
// `normalizeFilterComparandTypes` accepts it as-is. The operator arm below
|
|
663
|
+
// already passes a Date through untouched (`{ created: { $gte: d } }` →
|
|
664
|
+
// `['created', '>=', d]`), so stringifying here would make the shorthand
|
|
665
|
+
// and the operator form emit two different comparand types for the same
|
|
666
|
+
// author intent — a second dialect, in the file whose whole job is to have
|
|
667
|
+
// one.
|
|
668
|
+
//
|
|
669
|
+
// The gate is the spec's own predicate rather than a local `instanceof
|
|
670
|
+
// Date`, the same reason `normalizeFilterOperator` is used below instead of
|
|
671
|
+
// a second operator map. Today `Date` is its only object-typed member
|
|
672
|
+
// (pinned in filter-date-comparand-8555.test.ts), so this arm is a Date arm
|
|
673
|
+
// in practice; if the spec ever accepts another object-shaped literal, this
|
|
674
|
+
// reads it as a comparand instead of silently dropping it.
|
|
675
|
+
if (isAcceptedFilterComparand(value)) {
|
|
676
|
+
conditions.push([field, '=', value]);
|
|
677
|
+
continue;
|
|
678
|
+
}
|
|
679
|
+
// An EXOTIC object in comparand position — objectui#8567, the other half
|
|
680
|
+
// of the hole objectui#8555 closed.
|
|
681
|
+
//
|
|
682
|
+
// Same silent failure, one door further along: `Object.entries(/abc/)`,
|
|
683
|
+
// `Object.entries(new Set(['x']))` and `Object.entries(new Map())` are all
|
|
684
|
+
// `[]` (measured), so the loop below ran zero times and pushed NO condition
|
|
685
|
+
// for the field. `{ status: 'a', created: /abc/ }` lowered to
|
|
686
|
+
// `['status', '=', 'a']` — WIDER than the author asked for, with nothing
|
|
687
|
+
// thrown and nothing logged. With the exotic value alone `conditions` ended
|
|
688
|
+
// empty and the original object came back untouched, so the defect needed a
|
|
689
|
+
// sibling field to become visible at all.
|
|
690
|
+
//
|
|
691
|
+
// REFUSED rather than lowered, and — unlike the arm above — the spec rules
|
|
692
|
+
// it OUT rather than IN (measured against @objectstack/spec 17.3.0, floor
|
|
693
|
+
// ^17.2.0): `isAcceptedFilterComparand(/x/)` is `false`, and
|
|
694
|
+
// `normalizeFilterComparandTypes({ created: /x/ })` answers `INVALID_FILTER`
|
|
695
|
+
// / 400 — "Filter comparand at where.created is a RegExp instance ({}),
|
|
696
|
+
// which no driver can compare." Lowering it would only move that refusal
|
|
697
|
+
// downstream; dropping it is the defect. So the answer the wire would give
|
|
698
|
+
// two layers later is given HERE, where the field name and the offending
|
|
699
|
+
// value are both still in hand.
|
|
700
|
+
//
|
|
701
|
+
// ⚠️ This is NOT the class the `$regex` and `$not` refusals below belong
|
|
702
|
+
// to, and their message idiom is deliberately not copied. Those two answer
|
|
703
|
+
// "this layer has no TARGET for your operator" — `$regex` is absent from
|
|
704
|
+
// the spec's `FILTER_OPERATORS`, and `$not` is declared by the spec but has
|
|
705
|
+
// no keyword in the AST dialect this file emits. This one answers a
|
|
706
|
+
// question about a VALUE: the spec's own comparand-type predicate rejects
|
|
707
|
+
// it. The nearest neighbour is the bare-array arm above, which is also
|
|
708
|
+
// about a comparand — but that refusal rests on the spec DECLINING to rule
|
|
709
|
+
// (`assertListComparandShapes` rules only on `$in` / `$nin` / `$between`),
|
|
710
|
+
// which is the opposite authority to this one. What IS shared is the error
|
|
711
|
+
// TYPE: `FilterOperatorError` is named for operators but already carries
|
|
712
|
+
// four non-operator refusals in this file, because what it actually
|
|
713
|
+
// transports is the `INVALID_FILTER` / 400 envelope `classifyLoadError`
|
|
714
|
+
// reads — see its declaration.
|
|
715
|
+
//
|
|
716
|
+
// The accepted-type list is the spec's own sentence, not a second list
|
|
717
|
+
// spelled out here, for the same reason the gate above is the spec's
|
|
718
|
+
// predicate rather than a local `instanceof Date`.
|
|
719
|
+
if (!isOperatorMap(value)) {
|
|
720
|
+
throw new FilterOperatorError(`[ObjectUI] The filter on field '${field}' carries ${describeExoticComparand(value)} ` +
|
|
721
|
+
`in comparand position. '@objectstack/spec' does not accept it: a comparison value must ` +
|
|
722
|
+
`be ${ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE} (ACCEPTED_FILTER_COMPARAND_TYPES), so ` +
|
|
723
|
+
`isAcceptedFilterComparand is false for it and normalizeFilterComparandTypes answers 400 ` +
|
|
724
|
+
`INVALID_FILTER — "which no driver can compare". It is not an operator map either: only ` +
|
|
725
|
+
`a plain object can be one, so until objectui#8567 this value was read as ZERO operators ` +
|
|
726
|
+
`and the '${field}' condition was dropped from the filter entirely, silently WIDENING the ` +
|
|
727
|
+
`result set. Spell a text match as { ${field}: { $contains: '...' } } ($startsWith / ` +
|
|
728
|
+
`$endsWith; a pattern itself has no operator here, see the $regex refusal), a membership ` +
|
|
729
|
+
`test as { ${field}: { $in: [...] } }, and a date bound as ` +
|
|
730
|
+
`{ ${field}: { $gte: new Date(...) } }.`, { field });
|
|
731
|
+
}
|
|
732
|
+
// An operator map with NO operators — `{ created: {} }` — would push
|
|
733
|
+
// nothing below. It is refused HERE, where it is met, and not in the
|
|
734
|
+
// tail: beside a key that lowers, the tail is never reached, and the key
|
|
735
|
+
// used to vanish from the filter (objectui#10788). Alone, it is refused
|
|
736
|
+
// by this same line (objectui#9164). See refuseEmptyOperatorMap.
|
|
737
|
+
const operatorEntries = Object.entries(value);
|
|
738
|
+
if (operatorEntries.length === 0)
|
|
739
|
+
refuseEmptyOperatorMap(field);
|
|
103
740
|
// Handle operator-based filters
|
|
104
|
-
for (const [operator, operatorValue] of
|
|
741
|
+
for (const [operator, operatorValue] of operatorEntries) {
|
|
105
742
|
// `$regex` is refused, not downgraded. It used to become `contains`
|
|
106
743
|
// behind a `console.warn` — but substring matching is a DIFFERENT
|
|
107
744
|
// QUESTION, not a weaker version of the same one: `$regex: 'a.c'`
|
|
@@ -117,7 +754,8 @@ export function convertFiltersToAST(filter) {
|
|
|
117
754
|
`converted to 'contains', which matches a literal substring rather than a ` +
|
|
118
755
|
`pattern — a different result, not a degraded one. ` +
|
|
119
756
|
`Field: '${field}', Value: ${JSON.stringify(operatorValue)}. ` +
|
|
120
|
-
`Use $contains, $
|
|
757
|
+
`Use $contains for a case-sensitive substring, $icontains for a ` +
|
|
758
|
+
`case-insensitive one, or $startsWith / $endsWith.`, { operator: '$regex', field });
|
|
121
759
|
}
|
|
122
760
|
// $null / $exists translate based on their boolean value (per spec semantics).
|
|
123
761
|
// $null: true → IS NULL | $null: false → IS NOT NULL
|
|
@@ -130,16 +768,66 @@ export function convertFiltersToAST(filter) {
|
|
|
130
768
|
conditions.push([field, operatorValue ? 'is_not_null' : 'is_null', true]);
|
|
131
769
|
continue;
|
|
132
770
|
}
|
|
771
|
+
// objectui#11094 — `$empty`, admitted to the spec's `FILTER_OPERATORS`
|
|
772
|
+
// in `@objectstack/spec` 17.6.0 (objectstack#20446). The spec's own
|
|
773
|
+
// `parseFilterAST` reads `is_empty` as `$empty: true` and `is_not_empty`
|
|
774
|
+
// as `$empty: false`, so this is the inverse of that lowering, written
|
|
775
|
+
// the way `$null` is written above: direction from the flag, a `true`
|
|
776
|
+
// placeholder in the value slot, which the spec discards.
|
|
777
|
+
//
|
|
778
|
+
// The flag is a BOOLEAN by declaration (`SpecialOperatorSchema`), so a
|
|
779
|
+
// non-boolean is refused rather than read for its truthiness. That is
|
|
780
|
+
// the answer the spec's save door and every query face give, and
|
|
781
|
+
// `ValueDataSource`'s `$empty` arm refuses the same values, so one
|
|
782
|
+
// authored filter gets one answer whichever data source is behind it.
|
|
783
|
+
// Until this arm the operator reached the unknown-operator throw below.
|
|
784
|
+
if (operator === '$empty') {
|
|
785
|
+
if (operatorValue !== true && operatorValue !== false) {
|
|
786
|
+
throw new FilterOperatorError(`[ObjectUI] The '$empty' filter operator on field '${field}' takes a boolean flag ` +
|
|
787
|
+
`(true or false); received ${JSON.stringify(operatorValue) ?? String(operatorValue)}. ` +
|
|
788
|
+
`Write { ${field}: { $empty: true } } for an empty value, or ` +
|
|
789
|
+
`{ ${field}: { $empty: false } } for a non-empty one.`, { operator: '$empty', field });
|
|
790
|
+
}
|
|
791
|
+
conditions.push([field, operatorValue ? 'is_empty' : 'is_not_empty', true]);
|
|
792
|
+
continue;
|
|
793
|
+
}
|
|
133
794
|
const astOperator = convertOperatorToAST(operator);
|
|
134
795
|
if (astOperator) {
|
|
796
|
+
// objectui#9001 — the comparand door, at the ONE place this function
|
|
797
|
+
// reads a comparand. It runs before the push, so the refused node is
|
|
798
|
+
// never built; there is no `continue` and no key is skipped, which is
|
|
799
|
+
// what keeps the TRUE-identity tail's key-count comparison
|
|
800
|
+
// (objectui#8770, and the denominator objectui#9030 narrowed to the
|
|
801
|
+
// keys the loop processes) reading exactly what it read before.
|
|
802
|
+
//
|
|
803
|
+
// Keyed on the LOWERED operator rather than on the `$` spelling: the
|
|
804
|
+
// rule belongs to `icontains` itself, and `ValueDataSource`'s AST arm
|
|
805
|
+
// is keyed the same way. The `$` spelling the AUTHOR wrote is what
|
|
806
|
+
// travels into the message, which is what `FILTER_TEXT_CASES`'
|
|
807
|
+
// `mustMention: ['$icontains']` is about.
|
|
808
|
+
if (astOperator === 'icontains' && isRefusedTextComparand(operatorValue)) {
|
|
809
|
+
refuseTextComparand(field, operator, operatorValue);
|
|
810
|
+
}
|
|
135
811
|
conditions.push([field, astOperator, operatorValue]);
|
|
136
812
|
}
|
|
137
813
|
else {
|
|
814
|
+
// A RETIRED lowercase alias is answered by name, before the generic
|
|
815
|
+
// arm below can swallow it into "unknown operator" (objectui#8568).
|
|
816
|
+
const canonical = RETIRED_OPERATOR_ALIASES[operator];
|
|
817
|
+
if (canonical) {
|
|
818
|
+
throw new FilterOperatorError(`[ObjectUI] The '${operator}' filter operator is retired. Write ` +
|
|
819
|
+
`'${canonical}' instead — the canonical spelling '@objectstack/spec' declares ` +
|
|
820
|
+
`in FILTER_OPERATORS (data/filter.zod.ts). The operator itself is unchanged; ` +
|
|
821
|
+
`only the key is renamed. Field: '${field}', Value: ${JSON.stringify(operatorValue)}. ` +
|
|
822
|
+
`It used to be accepted here as a lowercase alias while the in-memory matcher ` +
|
|
823
|
+
`refused it, so the same filter selected rows through one data source and none ` +
|
|
824
|
+
`through the other (objectui#8568).`, { operator, field });
|
|
825
|
+
}
|
|
138
826
|
// Unknown operator - throw error to avoid silent failure
|
|
139
827
|
throw new FilterOperatorError(`[ObjectUI] Unknown filter operator '${operator}' for field '${field}'. ` +
|
|
140
828
|
`Supported operators: $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $between, ` +
|
|
141
|
-
`$contains, $notContains, $startsWith, $endsWith, $null, $exists. ` +
|
|
142
|
-
`If you need exact object matching, use the value directly without an operator
|
|
829
|
+
`$contains, $notContains, $startsWith, $endsWith, $icontains, $null, $exists, $empty. ` +
|
|
830
|
+
`If you need exact object matching, use the value directly without an operator.`, { operator, field });
|
|
143
831
|
}
|
|
144
832
|
}
|
|
145
833
|
}
|
|
@@ -148,8 +836,136 @@ export function convertFiltersToAST(filter) {
|
|
|
148
836
|
conditions.push([field, '=', value]);
|
|
149
837
|
}
|
|
150
838
|
}
|
|
151
|
-
// If no conditions, return original filter
|
|
152
839
|
if (conditions.length === 0) {
|
|
840
|
+
// The WHOLE filter was combinators that reduced to the TRUE identity —
|
|
841
|
+
// objectui#8770.
|
|
842
|
+
//
|
|
843
|
+
// `lowerLogicalGroup` answers `undefined` for a group that constrains
|
|
844
|
+
// nothing (`{ $and: [] }`, `{ $and: [{}] }`, `{ $or: [{}] }` — the
|
|
845
|
+
// identities objectstack#5322 rules TRUE), deliberately, so that no
|
|
846
|
+
// childless `['and']` is emitted. When such a group was the only thing in
|
|
847
|
+
// the filter that `undefined` reached the tail below, and the CALLER'S
|
|
848
|
+
// ORIGINAL OBJECT came back in its place — so the group did not disappear
|
|
849
|
+
// at this level after all. It reappeared one level up, in the `$` dialect,
|
|
850
|
+
// in a slot the AST is expected to occupy.
|
|
851
|
+
//
|
|
852
|
+
// What that cost, measured against @objectstack/spec 17.4.0 and
|
|
853
|
+
// @objectstack/client 17.4.0 rather than assumed: the object is NOT handed
|
|
854
|
+
// to the wire as a filter and refused there. `client.data.find()` tests the
|
|
855
|
+
// value with `isFilterAST`, and its ELSE branch spreads a plain object's
|
|
856
|
+
// entries as query parameters — so `{ $and: [] }` left as `?$and=` and
|
|
857
|
+
// `{ $or: [{}] }` as `?$or=[object Object]`, with no `filter` parameter at
|
|
858
|
+
// all, and the server refused the unknown `$`-prefixed parameter with `400
|
|
859
|
+
// UNSUPPORTED_QUERY_PARAM`. A filter whose ruled answer is EVERY ROW was a
|
|
860
|
+
// failed list. (The `$expand` / `$search` route sends the same object as
|
|
861
|
+
// `filter={"$and":[]}`, which the server accepts as a `FilterCondition` and
|
|
862
|
+
// answers with every row — so the two routes disagreed about one filter,
|
|
863
|
+
// and the fix makes both say what the ruling says.)
|
|
864
|
+
//
|
|
865
|
+
// `undefined` is not a new vocabulary invented here. It is already what
|
|
866
|
+
// `toFilterNode`, `mergeFilterNodes` and data-objectstack's
|
|
867
|
+
// `translateFilterToAST` mean by "no filter, skip the slot", and every call
|
|
868
|
+
// site of this function already acts on it: `lowerLogicalGroup` above tests
|
|
869
|
+
// `Array.isArray`, the other three test for `undefined` or falsiness.
|
|
870
|
+
//
|
|
871
|
+
// ⛔ Scoped to a filter whose every PROCESSED key is such a group, which is
|
|
872
|
+
// why the count above is compared with a count instead of being a flag.
|
|
873
|
+
// Inputs that are not combinators at all are NOT this case: `{}` still
|
|
874
|
+
// reaches the `return filter` below, and an empty operator map never gets
|
|
875
|
+
// this far — the operator-map arm refuses it where it is met
|
|
876
|
+
// (objectui#9164, objectui#10788).
|
|
877
|
+
//
|
|
878
|
+
// ⭐ The denominator is the number of keys the LOOP ACTUALLY PROCESSED, not
|
|
879
|
+
// `Object.keys(filter).length` — objectui#9030. Those differ by exactly the
|
|
880
|
+
// keys the loop's own first statement skipped, and counting a skipped key
|
|
881
|
+
// against this fold made the fold's answer depend on a key that, by this
|
|
882
|
+
// function's oldest ruling, contributes nothing: `{ $and: [] }` folded and
|
|
883
|
+
// `{ $and: [], b: undefined }` did not, though `JSON.stringify` drops the
|
|
884
|
+
// second key entirely and the two reach either wire route as the same
|
|
885
|
+
// bytes. Both `null` and `undefined` are subtracted, because the loop skips
|
|
886
|
+
// them with one statement and the guard below already gives that whole
|
|
887
|
+
// class the same `undefined` when it is alone — leaving the MIXTURE out
|
|
888
|
+
// would mean adding an always-TRUE `$and: []` to a filter that folds could
|
|
889
|
+
// stop it folding, which is the sibling-dependence hazard objectui#8555
|
|
890
|
+
// named on this file.
|
|
891
|
+
//
|
|
892
|
+
// ⚠ Subtraction, not a merged counter. `trueIdentityGroups > 0` still
|
|
893
|
+
// gates this arm and the skipped count still has its own arm below, so the
|
|
894
|
+
// two states stay told apart — what changed is only which keys this one
|
|
895
|
+
// is measured against.
|
|
896
|
+
const keysTheLoopProcessed = Object.keys(filter).length - skippedNullKeys;
|
|
897
|
+
if (trueIdentityGroups > 0 && trueIdentityGroups === keysTheLoopProcessed) {
|
|
898
|
+
return undefined;
|
|
899
|
+
}
|
|
900
|
+
// The WHOLE filter was keys the loop SKIPPED for a null/undefined value —
|
|
901
|
+
// objectui#9020.
|
|
902
|
+
//
|
|
903
|
+
// Skipping such a key is this file's oldest documented behaviour and is NOT
|
|
904
|
+
// what changed here: `{ a: null, s: 1 }` still lowers to `['s', '=', 1]`,
|
|
905
|
+
// pinned as "should skip null and undefined values" since long before any of
|
|
906
|
+
// the cards above. What changed is what the function says when the skip
|
|
907
|
+
// leaves NOTHING behind. It used to hand back the CALLER'S ORIGINAL OBJECT,
|
|
908
|
+
// and that object meant two different things on the two `find()` routes of
|
|
909
|
+
// `@object-ui/data-objectstack` — measured against @objectstack/spec 17.4.0
|
|
910
|
+
// and @objectstack/client 17.4.0:
|
|
911
|
+
//
|
|
912
|
+
// - the plain route (`convertQueryParams` → `client.data.find`) tests the
|
|
913
|
+
// value with `isFilterAST`, and its ELSE branch spreads a plain object's
|
|
914
|
+
// entries as query parameters SKIPPING null ones — so `{ a: null }`
|
|
915
|
+
// appended nothing at all, no `filter` parameter was sent, and the
|
|
916
|
+
// answer was EVERY ROW;
|
|
917
|
+
// - the `$expand` / `$search` route (`rawFindWithPopulate`, and the export
|
|
918
|
+
// route beside it) JSON-serialises the same object into `filter=`, and
|
|
919
|
+
// `{ a: null }` is a well-formed `FilterCondition` the spec accepts
|
|
920
|
+
// (`null` is in `ACCEPTED_FILTER_COMPARAND_TYPES`), so it arrived as a
|
|
921
|
+
// REAL predicate and the answer was SOME rows.
|
|
922
|
+
//
|
|
923
|
+
// One authored filter, two row sets, and the deciding input was whether the
|
|
924
|
+
// query happened to want a lookup expanded — the split objectui#6948
|
|
925
|
+
// recorded on this file and the shared `translateFilterToAST` helper exists
|
|
926
|
+
// to close. It was also self-inconsistent within ONE route: the key meant
|
|
927
|
+
// "no constraint" the moment ANY sibling produced a condition and meant a
|
|
928
|
+
// predicate when it was alone, which is the sibling-dependence objectui#8555
|
|
929
|
+
// named here.
|
|
930
|
+
//
|
|
931
|
+
// ⇒ the tie is broken by what this function ALREADY says about the key, not
|
|
932
|
+
// by inventing a meaning for it. The loop's `continue` is the ruling: the key
|
|
933
|
+
// contributes no condition. Carrying that to the wire is `undefined`, and an
|
|
934
|
+
// author who wants the predicate has always been able to spell it
|
|
935
|
+
// `{ a: { $null: true } }` → `['a', 'is_null', true]`. The opposite repair —
|
|
936
|
+
// making a null-valued key lower to a predicate — would have broken the skip
|
|
937
|
+
// pin and changed every caller.
|
|
938
|
+
//
|
|
939
|
+
// ⚠️ SAME ANSWER as the fold above, NOT the same state, which is why these
|
|
940
|
+
// are two counts and two guards rather than one. That fold's `undefined` is
|
|
941
|
+
// objectstack#5322's RULED identity, reached by `lowerLogicalGroup`; this
|
|
942
|
+
// one is this file's own tolerance made consistent with itself. They
|
|
943
|
+
// coincide because "no constraint" has exactly ONE expressible spelling in
|
|
944
|
+
// this dialect — the absence of the slot — not because the two inputs are
|
|
945
|
+
// the same kind of thing.
|
|
946
|
+
//
|
|
947
|
+
// A filter that MIXES the two kinds (`{ $and: [], a: null }`,
|
|
948
|
+
// `{ $and: [], b: undefined }`) is answered by the fold ABOVE, since
|
|
949
|
+
// subtracting the skipped keys leaves a filter whose every processed key is
|
|
950
|
+
// an identity group — objectui#9030. It used to satisfy neither guard and
|
|
951
|
+
// come back as the object, so a filter each of whose keys folds ALONE did
|
|
952
|
+
// not fold TOGETHER. That is why the counts staying apart is a statement
|
|
953
|
+
// about the two STATES and never about the two ARMS being unreachable from
|
|
954
|
+
// one input.
|
|
955
|
+
//
|
|
956
|
+
// ⛔ `{}` is not this case either — `skippedNullKeys > 0` excludes it. An
|
|
957
|
+
// empty filter has no key to skip, `toFilterNode` already folds it one level
|
|
958
|
+
// up, and objectui#8770 measured that boundary; moving it is a separate
|
|
959
|
+
// argument nobody has made.
|
|
960
|
+
if (skippedNullKeys > 0 && skippedNullKeys === Object.keys(filter).length) {
|
|
961
|
+
return undefined;
|
|
962
|
+
}
|
|
963
|
+
// Only `{}` — a filter with no key at all — still comes back as itself.
|
|
964
|
+
// Every other way to reach this tail is answered above, and an empty
|
|
965
|
+
// operator map (`{ a: {} }`, `{ a: {}, b: undefined }`,
|
|
966
|
+
// `{ $and: [], a: {} }`) never reaches it: the operator-map arm refuses it
|
|
967
|
+
// where it is met (objectui#9164, objectui#10788). `toFilterNode` folds
|
|
968
|
+
// `{}` one level up, and objectui#8770 measured that boundary.
|
|
153
969
|
return filter;
|
|
154
970
|
}
|
|
155
971
|
// If only one condition, return it directly
|
|
@@ -183,9 +999,247 @@ function isViewFilterRule(value) {
|
|
|
183
999
|
* turns a hole in an array into `null`, and `['x', 'equals', null]` is a real
|
|
184
1000
|
* `{x: null}` predicate, i.e. a silently-wrong filter. Same rule the write side
|
|
185
1001
|
* applies (`if (c.value !== undefined)`).
|
|
1002
|
+
*
|
|
1003
|
+
* The value's SHAPE is checked too, against the operator's arity — an array on a
|
|
1004
|
+
* single-value operator is refused rather than passed through. See the arm
|
|
1005
|
+
* itself for the reasoning and for why the refusal is a throw (objectui#8557).
|
|
1006
|
+
*
|
|
1007
|
+
* Its COMPARAND is checked on one operator, `icontains`, whose two refused
|
|
1008
|
+
* shapes `@objectstack/spec`'s `FILTER_TEXT_CASES` declares (objectui#9048).
|
|
1009
|
+
* That arm reads the same shared refusal `convertFiltersToAST` and
|
|
1010
|
+
* `ValueDataSource` read, and the arm itself carries the weighing for a SAVED
|
|
1011
|
+
* view — which is a different input from objectui#8557's and was redone rather
|
|
1012
|
+
* than inherited.
|
|
1013
|
+
*
|
|
1014
|
+
* @throws {FilterOperatorError} If the rule carries an ARRAY on an operator the
|
|
1015
|
+
* spec declares single-valued, or an empty / non-string comparand on
|
|
1016
|
+
* `icontains`.
|
|
1017
|
+
*/
|
|
1018
|
+
/**
|
|
1019
|
+
* The view-filter operators whose `value` is legitimately an ARRAY.
|
|
1020
|
+
*
|
|
1021
|
+
* Not a local list — both halves are the spec's own, and it exports them for
|
|
1022
|
+
* exactly this question: `VIEW_FILTER_LIST_VALUE_OPERATORS` (`['in', 'not_in']`)
|
|
1023
|
+
* says which operators take a membership list, `VIEW_FILTER_PAIR_VALUE_OPERATORS`
|
|
1024
|
+
* (`['between']`) which take a `[min, max]` pair, and the docblock on the first
|
|
1025
|
+
* of them names a hard-coded `["in", "notIn"]` elsewhere in this repo as the
|
|
1026
|
+
* mistake it exists to prevent (`notIn` is an alias, not the canonical member).
|
|
1027
|
+
* Same reason the operator itself goes through `normalizeFilterOperator` rather
|
|
1028
|
+
* than a second map.
|
|
1029
|
+
*/
|
|
1030
|
+
const ARRAY_VALUED_VIEW_OPERATORS = new Set([
|
|
1031
|
+
...VIEW_FILTER_LIST_VALUE_OPERATORS,
|
|
1032
|
+
...VIEW_FILTER_PAIR_VALUE_OPERATORS,
|
|
1033
|
+
]);
|
|
1034
|
+
/**
|
|
1035
|
+
* The view-filter operators that carry NO comparand — their direction comes from
|
|
1036
|
+
* the operator NAME.
|
|
1037
|
+
*
|
|
1038
|
+
* The spec exports the two array-valued sets but no set for these, so this is
|
|
1039
|
+
* the one classification written out here. It is pinned against
|
|
1040
|
+
* `VIEW_FILTER_OPERATORS` (filter-view-rule-arity-8557.test.ts) so the four sets
|
|
1041
|
+
* partition the vocabulary exactly: an operator added to the spec lands in no
|
|
1042
|
+
* class, the pin reddens, and someone classifies it rather than it silently
|
|
1043
|
+
* inheriting a verdict.
|
|
1044
|
+
*
|
|
1045
|
+
* They are excluded from the refusal below deliberately, and on a measurement:
|
|
1046
|
+
* the spec DISCARDS a value on these operators — `parseFilterAST(['tags',
|
|
1047
|
+
* 'is_null', ['a']])` is `{ tags: { $null: true } }` — so a stray array here
|
|
1048
|
+
* cannot produce a node that selects the wrong rows. Refusing it would turn a
|
|
1049
|
+
* harmless input into a render-time throw, and the message would prescribe `in`
|
|
1050
|
+
* for an operator that takes no value at all.
|
|
186
1051
|
*/
|
|
1052
|
+
const VALUELESS_VIEW_OPERATORS = new Set([
|
|
1053
|
+
'is_empty',
|
|
1054
|
+
'is_not_empty',
|
|
1055
|
+
'is_null',
|
|
1056
|
+
'is_not_null',
|
|
1057
|
+
]);
|
|
1058
|
+
/** Every operator spelling the spec knows, canonical forms only. */
|
|
1059
|
+
const KNOWN_VIEW_OPERATORS = new Set(VIEW_FILTER_OPERATORS);
|
|
187
1060
|
function viewFilterRuleToNode(rule) {
|
|
188
1061
|
const operator = normalizeFilterOperator(rule.operator);
|
|
1062
|
+
// An ARRAY on an operator that takes ONE value is refused here — objectui#8557.
|
|
1063
|
+
//
|
|
1064
|
+
// `rule.value` used to travel through unread, so a stored view rule
|
|
1065
|
+
// `{ field: 'tags', operator: 'equals', value: ['a'] }` became
|
|
1066
|
+
// `['tags', 'equals', ['a']]`. Measured against @objectstack/spec 17.3.0, the
|
|
1067
|
+
// spec's doors pass that node through unjudged — `isFilterAST` is `true` and
|
|
1068
|
+
// `parseFilterAST` hands back `{ tags: ['a'] }` — which is the SAME
|
|
1069
|
+
// array-in-a-scalar-slot shape the object arm above refuses (objectui#8530):
|
|
1070
|
+
// the ObjectQL AST has no array-equality node, `@objectstack/driver-sql`
|
|
1071
|
+
// answers `400 INVALID_FILTER`, and every in-memory matcher excludes every
|
|
1072
|
+
// row. So a hand-authored `{ tags: ['a'] }` already failed fast with a message
|
|
1073
|
+
// naming `$in`, while the same mistake SAVED INTO A VIEW still travelled
|
|
1074
|
+
// silently to a 400 the author could not attribute to their filter. This
|
|
1075
|
+
// closes that asymmetry.
|
|
1076
|
+
//
|
|
1077
|
+
// Keyed on the operator's ARITY, never on `Array.isArray(value)` alone:
|
|
1078
|
+
// `in` / `not_in` / `between` rules legitimately carry arrays through this
|
|
1079
|
+
// very function, and that path had no pin protecting it until this card.
|
|
1080
|
+
// The check runs AFTER normalization so an alias is judged by what it means —
|
|
1081
|
+
// `nin` is `not_in`, and is array-valued.
|
|
1082
|
+
//
|
|
1083
|
+
// An operator the spec does NOT know is left alone. `normalizeFilterOperator`
|
|
1084
|
+
// passes a misspelling through verbatim precisely so `isFilterAST` refuses it
|
|
1085
|
+
// and the server names it; refusing it HERE would report the wrong problem
|
|
1086
|
+
// ("use $in") for what is actually a typo.
|
|
1087
|
+
//
|
|
1088
|
+
// ## Why the refusal is a THROW, and why it belongs here
|
|
1089
|
+
//
|
|
1090
|
+
// It throws from the lowering, which for a saved view means the view fails at
|
|
1091
|
+
// render rather than returning a narrower answer. That is a real blast radius
|
|
1092
|
+
// and it was weighed:
|
|
1093
|
+
//
|
|
1094
|
+
// - It is not a NEW blast radius. Both sinks already catch a
|
|
1095
|
+
// `FilterOperatorError` from this same file — `plugin-list`'s
|
|
1096
|
+
// `buildEffectiveFilter` runs inside `ListView`'s load `try` and lands in
|
|
1097
|
+
// `setLoadError`, `plugin-view`'s `ObjectView` calls `mergeFilterNodes`
|
|
1098
|
+
// inside its own load `try` — because the object arm has thrown for
|
|
1099
|
+
// `$regex`, `$not` and bare arrays since objectui#8530. `classifyLoadError`
|
|
1100
|
+
// reads this error's `INVALID_FILTER` / `400`, so what a user sees is the
|
|
1101
|
+
// "filter is malformed" panel, not a crashed page and not a network error.
|
|
1102
|
+
// - The alternatives are both silent. DROPPING the rule widens the result
|
|
1103
|
+
// set, the one direction this file exists to avoid — and a stored view's
|
|
1104
|
+
// whole purpose can be to hide rows. Rewriting `equals` into `in` changes
|
|
1105
|
+
// the author's meaning, the lenient second contract objectui#8514 was
|
|
1106
|
+
// resolved against on this same data shape.
|
|
1107
|
+
// - The rule was authored long ago by someone who is not present, which
|
|
1108
|
+
// argues for the LOUDER answer, not the quieter one: a silent 400 two
|
|
1109
|
+
// layers away is attributable to nothing, whereas this names the view's
|
|
1110
|
+
// field and operator at the moment the filter is built.
|
|
1111
|
+
if (Array.isArray(rule.value)
|
|
1112
|
+
&& typeof operator === 'string'
|
|
1113
|
+
&& KNOWN_VIEW_OPERATORS.has(operator)
|
|
1114
|
+
&& !ARRAY_VALUED_VIEW_OPERATORS.has(operator)
|
|
1115
|
+
&& !VALUELESS_VIEW_OPERATORS.has(operator)) {
|
|
1116
|
+
throw new FilterOperatorError(`[ObjectUI] The stored view rule on field '${rule.field}' carries an ARRAY ` +
|
|
1117
|
+
`as the comparand of '${operator}', which takes a single value: ` +
|
|
1118
|
+
`${JSON.stringify(rule.value)}. It cannot be lowered: the ObjectQL filter ` +
|
|
1119
|
+
`AST has no array-equality node, so the lowered node ` +
|
|
1120
|
+
`[${rule.field}, '${operator}', [...]] is refused by @objectstack/driver-sql ` +
|
|
1121
|
+
`(400 INVALID_FILTER) and matches no row in the in-memory matchers — it can ` +
|
|
1122
|
+
`never select anything. It is deliberately NOT rewritten to 'in': that ` +
|
|
1123
|
+
`would change what the saved view means, and 'in' is already spellable in ` +
|
|
1124
|
+
`the rule itself. Spell membership as ` +
|
|
1125
|
+
`{ field: '${rule.field}', operator: 'in', value: [...] }, its negation as ` +
|
|
1126
|
+
`'not_in', or a range as { operator: 'between', value: [min, max] } — the ` +
|
|
1127
|
+
`three operators the spec declares array-valued, which are untouched ` +
|
|
1128
|
+
`(objectui#8557; the same ruling objectui#8530 applied to the object arm).`, { operator, field: rule.field });
|
|
1129
|
+
}
|
|
1130
|
+
// The `icontains` COMPARAND door, on the STORED VIEW rule vocabulary —
|
|
1131
|
+
// objectui#9048. Same two shapes `FILTER_TEXT_CASES` declares refused, same
|
|
1132
|
+
// discrimination and same words as `convertFiltersToAST` and
|
|
1133
|
+
// `ValueDataSource`, because all three now read ONE implementation
|
|
1134
|
+
// (`text-comparand.ts`) rather than three copies of it. Triage ruled exactly
|
|
1135
|
+
// that: "three implementations of one refusal, of which one is correct ⇒ the
|
|
1136
|
+
// useful dispatch is not 'add a third guard' but 'make the dialects share the
|
|
1137
|
+
// one that works'".
|
|
1138
|
+
//
|
|
1139
|
+
// ## Placed AFTER the arity arm, deliberately
|
|
1140
|
+
//
|
|
1141
|
+
// An ARRAY comparand on `icontains` is refused by objectui#8557 above, with
|
|
1142
|
+
// its message about `in` / `not_in` / `between`. Running this door first would
|
|
1143
|
+
// answer the same input with "not a string" — a true sentence that prescribes
|
|
1144
|
+
// the wrong repair, and a silent change to a shipped ruling this card is
|
|
1145
|
+
// fenced away from.
|
|
1146
|
+
//
|
|
1147
|
+
// ## An ABSENT comparand is left alone, and that is a boundary not an omission
|
|
1148
|
+
//
|
|
1149
|
+
// `isRefusedTextComparand(undefined)` is true, so the `!== undefined` test is
|
|
1150
|
+
// load-bearing. The `$` dialect has no "absent" — `{ $icontains: undefined }`
|
|
1151
|
+
// still HAS the key — but this vocabulary does, and the tail below already
|
|
1152
|
+
// encodes it: a rule with no `value` lowers to the 2-tuple `[field, operator]`
|
|
1153
|
+
// because the valueless operators (`is_null` and friends) take their direction
|
|
1154
|
+
// from the operator NAME. Measured on today's tree, that 2-tuple is already
|
|
1155
|
+
// refused downstream with THIS card's code: `parseFilterAST(['name',
|
|
1156
|
+
// 'icontains'])` throws `INVALID_FILTER` / 400 naming
|
|
1157
|
+
// `where.name.$icontains`. So the shape is loud already; moving WHERE it is
|
|
1158
|
+
// refused is a separate argument nobody has made, and `FILTER_TEXT_CASES`
|
|
1159
|
+
// declares two rows, not three.
|
|
1160
|
+
//
|
|
1161
|
+
// ## Why a THROW here, when it means a saved view fails at RENDER
|
|
1162
|
+
//
|
|
1163
|
+
// This is objectui#8557's weighing done again on a different input, not
|
|
1164
|
+
// inherited from it — the card says so, and the inputs really do differ.
|
|
1165
|
+
// Measured on `origin/main` `6df26f025` with `@objectstack/spec` 17.4.0, one
|
|
1166
|
+
// authored view rule `{ field: 'name', operator: 'icontains', value: '' }`:
|
|
1167
|
+
//
|
|
1168
|
+
// - it lowers to `['name','icontains','']`, which `isFilterAST` ACCEPTS, so
|
|
1169
|
+
// it is sent rather than stopped at any door;
|
|
1170
|
+
// - `parseFilterAST` reads it as `{ name: { $icontains: '' } }`;
|
|
1171
|
+
// - against the in-memory matchers that condition selects ZERO of the
|
|
1172
|
+
// spec's own nine `FILTER_TEXT_ROWS` (`@objectstack/formula`'s
|
|
1173
|
+
// `matchesFilterCondition`, and `ValueDataSource` since objectui#8748,
|
|
1174
|
+
// which also prints a refusal);
|
|
1175
|
+
// - and on the wire the published table states the other answer in its own
|
|
1176
|
+
// words — "Every row contains the empty substring, so evaluating it is a
|
|
1177
|
+
// predicate that constrains nothing".
|
|
1178
|
+
//
|
|
1179
|
+
// So the pre-fix answer is not one behaviour but TWO, chosen by which data
|
|
1180
|
+
// source the saved view happens to render against: silently-empty on the
|
|
1181
|
+
// in-memory faces, constrains-nothing on the wire. That is the acceptance-set
|
|
1182
|
+
// split this card family exists to close, and neither half is "a view that
|
|
1183
|
+
// renders correctly today". objectui#8557's conclusion (loud beats
|
|
1184
|
+
// silently-empty) covers the first half unchanged; the second half is the
|
|
1185
|
+
// WIDENING direction this file's own arity arm names as the one it exists to
|
|
1186
|
+
// avoid — "a stored view's whole purpose can be to hide rows". Both halves
|
|
1187
|
+
// point the same way, which is why the weighing survives the different input.
|
|
1188
|
+
//
|
|
1189
|
+
// The blast radius is also smaller than the shape suggests, measured rather
|
|
1190
|
+
// than assumed. The Console cannot author this rule: `foldFilterGroupToSpecRules`
|
|
1191
|
+
// (`@object-ui/app-shell`, objectui#4155) DROPS a condition whose value is
|
|
1192
|
+
// `null` / `''` / `[]` before it is persisted, precisely so an incomplete row
|
|
1193
|
+
// never reaches storage. And nothing in this tree authors the shape: over
|
|
1194
|
+
// every tracked file, an `operator: 'icontains'` rule appears only in this
|
|
1195
|
+
// card's own test, changeset and comment — lit control, 40 tracked files
|
|
1196
|
+
// mention the operator at all.
|
|
1197
|
+
//
|
|
1198
|
+
// ## ⚠️ Where the throw LANDS is a second question, and it is NOT settled here
|
|
1199
|
+
//
|
|
1200
|
+
// Two of the sinks catch it: `plugin-list`'s `buildEffectiveFilter` runs
|
|
1201
|
+
// inside `ListView`'s load `try` and `plugin-view`'s `ObjectView` inside its
|
|
1202
|
+
// own, and `classifyLoadError` reads this error's `INVALID_FILTER` / `400`, so
|
|
1203
|
+
// there the user sees the "filter is malformed" panel naming their field and
|
|
1204
|
+
// operator. FOUR more entries do not: `plugin-grid`'s `ObjectGrid` (twice),
|
|
1205
|
+
// `plugin-detail`'s `RelatedList` and `plugin-form`'s `LineItemsPanel` all
|
|
1206
|
+
// call `toFilterNode` inside a RENDER-time `useMemo`, where a throw
|
|
1207
|
+
// propagates as a render error with no `classifyLoadError` in the path.
|
|
1208
|
+
// `ObjectGrid` feeds it `schema.filter`, which the spec declares as
|
|
1209
|
+
// `z.array(ViewFilterRuleSchema)` — i.e. exactly the shape this arm judges.
|
|
1210
|
+
//
|
|
1211
|
+
// That is objectui#9050, an OPEN card on the DELIVERY axis, and this arm
|
|
1212
|
+
// deliberately does not pre-empt it. It is not a class this card creates or
|
|
1213
|
+
// even joins first: objectui#8557's array-arity refusal directly above throws
|
|
1214
|
+
// from this same function through those same four entries and has shipped
|
|
1215
|
+
// that way, and objectui#9050 counts eleven pre-existing throw sites reachable
|
|
1216
|
+
// the same way. ⛔ The alternatives are worse in the direction this whole card
|
|
1217
|
+
// family exists to close — dropping the rule widens the result set, lowering
|
|
1218
|
+
// it keeps the two-answers split. So the refusal lands and WHERE it surfaces
|
|
1219
|
+
// stays objectui#9050's decision to make for all thirteen at once.
|
|
1220
|
+
if (operator === 'icontains'
|
|
1221
|
+
&& rule.value !== undefined
|
|
1222
|
+
&& isRefusedTextComparand(rule.value)) {
|
|
1223
|
+
// The spelling that ARRIVED, never `$icontains` substituted for it. The
|
|
1224
|
+
// published rows spell the operator `$icontains` because their filters are
|
|
1225
|
+
// written in the `$` dialect; a view rule spells the same operator
|
|
1226
|
+
// `icontains`, and `ValueDataSource` already answers this question the same
|
|
1227
|
+
// way on today's tree — its `$` arm names `$icontains`, its AST arm names
|
|
1228
|
+
// `icontains`, each from the node in hand. Prescribing a spelling this
|
|
1229
|
+
// vocabulary does not have would send a view author looking for a `$` key
|
|
1230
|
+
// their metadata cannot contain, so the `$` twin is NAMED in the tail below
|
|
1231
|
+
// instead of substituted for what they wrote.
|
|
1232
|
+
const arrived = typeof rule.operator === 'string' && rule.operator !== ''
|
|
1233
|
+
? rule.operator
|
|
1234
|
+
: operator;
|
|
1235
|
+
const tail = `This is a STORED VIEW rule, so it is refused as the filter is BUILT rather `
|
|
1236
|
+
+ `than lowered onto the wire: the spelling @objectstack/spec's `
|
|
1237
|
+
+ `FILTER_TEXT_CASES uses for this operator is the $-dialect '$icontains', `
|
|
1238
|
+
+ `which this vocabulary spells '${arrived}'. Remove the condition from the `
|
|
1239
|
+
+ `view, or give it a non-empty string comparand (objectui#9048; the same `
|
|
1240
|
+
+ `refusal ValueDataSource and convertFiltersToAST already share).`;
|
|
1241
|
+
throw new FilterOperatorError(`[ObjectUI] The ${textComparandRefusalReason(rule.field, arrived, rule.value)}. ${tail}`, { operator: arrived, field: rule.field });
|
|
1242
|
+
}
|
|
189
1243
|
return (rule.value === undefined
|
|
190
1244
|
? [rule.field, operator]
|
|
191
1245
|
: [rule.field, operator, rule.value]);
|
|
@@ -236,7 +1290,13 @@ function viewFilterRuleToNode(rule) {
|
|
|
236
1290
|
* untouched.
|
|
237
1291
|
*
|
|
238
1292
|
* Returns `undefined` for an absent or empty source, so callers can skip
|
|
239
|
-
* `$filter` rather than sending an empty array
|
|
1293
|
+
* `$filter` rather than sending an empty array — and, since objectui#8770, for
|
|
1294
|
+
* an object source that is nothing but TRUE-identity combinators
|
|
1295
|
+
* (`{ $and: [] }`), and since objectui#9020 for one whose every key is skipped
|
|
1296
|
+
* for a null/undefined value (`{ a: null }`). Both constrain nothing and so
|
|
1297
|
+
* contribute nothing to the `and` {@link mergeFilterNodes} builds. Those answers
|
|
1298
|
+
* are inherited from {@link convertFiltersToAST}, not decided a second time
|
|
1299
|
+
* here — including the fact that they are two separate decisions there.
|
|
240
1300
|
*/
|
|
241
1301
|
export function toFilterNode(source) {
|
|
242
1302
|
if (source === null || source === undefined)
|
|
@@ -259,6 +1319,16 @@ export function toFilterNode(source) {
|
|
|
259
1319
|
// MongoDB-style → AST, so it can sit beside the other shapes under one `and`.
|
|
260
1320
|
return convertFiltersToAST(obj);
|
|
261
1321
|
}
|
|
1322
|
+
export function toFilterNodeSafely(source) {
|
|
1323
|
+
try {
|
|
1324
|
+
return { ok: true, node: toFilterNode(source) };
|
|
1325
|
+
}
|
|
1326
|
+
catch (error) {
|
|
1327
|
+
if (error instanceof FilterOperatorError)
|
|
1328
|
+
return { ok: false, refusal: error };
|
|
1329
|
+
throw error;
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
262
1332
|
/**
|
|
263
1333
|
* Combine filter sources under a single `and`, each as its OWN child.
|
|
264
1334
|
*
|