@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
|
@@ -8,7 +8,10 @@
|
|
|
8
8
|
* A DataSource adapter for the `provider: 'value'` ViewData mode.
|
|
9
9
|
* Operates entirely on an in-memory array — no network requests.
|
|
10
10
|
*/
|
|
11
|
+
import { asciiCaseInsensitiveContains, canonicalAstOperator, isAcceptedFilterComparand, isEmptyFilterValue, RETIRED_FILTER_OPERATORS, } from '@objectstack/spec/data';
|
|
11
12
|
import { emulateBatchTransaction } from './batchTransaction.js';
|
|
13
|
+
import { describeComparand, isRefusedTextComparand, textComparandRefusalReason, } from '../utils/text-comparand.js';
|
|
14
|
+
import { toFilterNodeSafely } from '../utils/filter-converter.js';
|
|
12
15
|
// ---------------------------------------------------------------------------
|
|
13
16
|
// Helpers
|
|
14
17
|
// ---------------------------------------------------------------------------
|
|
@@ -18,118 +21,1020 @@ function getRecordId(record, idField) {
|
|
|
18
21
|
return record[idField];
|
|
19
22
|
return record.id ?? record._id;
|
|
20
23
|
}
|
|
24
|
+
/**
|
|
25
|
+
* A filter node this matcher cannot execute.
|
|
26
|
+
*
|
|
27
|
+
* Recorded and EXCLUDING, never silently ignored. Answering `true` for a node
|
|
28
|
+
* the matcher does not understand is how a filtered list came back UNFILTERED —
|
|
29
|
+
* every row, no error, not one console line (objectui#7349). The measured
|
|
30
|
+
* sibling behaviour is a refusal, not a pass: `evaluateCondition`
|
|
31
|
+
* (`@object-ui/permissions`) returns `false` from its `default` arm, and
|
|
32
|
+
* `ReportViewer`'s formatting switch leaves `match` false. The wire-side
|
|
33
|
+
* sibling `@object-ui/data-objectstack` goes further and THROWS
|
|
34
|
+
* (`MalformedFilterError`), on the stated ground that dropping one entry of an
|
|
35
|
+
* `and` WIDENS the result set — but it is deciding whether to send a query at
|
|
36
|
+
* all, while this matcher is deciding about a single row. So the row is
|
|
37
|
+
* excluded and the reason is logged once per distinct refusal per `find()`,
|
|
38
|
+
* which keeps a 10k-row scan to one line.
|
|
39
|
+
*/
|
|
40
|
+
function refuseFilterNode(refusals, reason) {
|
|
41
|
+
refusals.add(`[ObjectUI] ValueDataSource: ${reason}. Rows are excluded rather than passed through.`);
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* A refusal the LOWERING raised, re-seated in this adapter's own sentence
|
|
46
|
+
* (objectui#10767).
|
|
47
|
+
*
|
|
48
|
+
* `toFilterNode` throws a {@link FilterOperatorError} for a stored view rule it
|
|
49
|
+
* will not lower — an ARRAY on a single-valued operator (objectui#8557), an
|
|
50
|
+
* empty or non-string `icontains` comparand (objectui#9048). For the producers
|
|
51
|
+
* that call it before a WIRE query, a throw is right: they are deciding whether
|
|
52
|
+
* to send a query at all. `find()` is deciding about rows, so here the same
|
|
53
|
+
* refusal takes the shape every refusal takes in this file — the rows are
|
|
54
|
+
* excluded and the reason is logged once — rather than surfacing as a throw
|
|
55
|
+
* from a `find` that never threw on a bad filter before. The converter's own
|
|
56
|
+
* `[ObjectUI]` prefix and closing period are taken off so the sentence reads
|
|
57
|
+
* once, not twice; the reasoning and the prescription are its own, verbatim.
|
|
58
|
+
*
|
|
59
|
+
* The object arm re-seats the converter's refusal through this same function
|
|
60
|
+
* since objectui#10817, for a field whose condition is an object with no own
|
|
61
|
+
* keys — see {@link zeroKeyConditionRefusal}. One envelope for both arms.
|
|
62
|
+
*/
|
|
63
|
+
function loweringRefusalReason(refusal) {
|
|
64
|
+
const body = refusal.message.replace(/^\[ObjectUI\]\s*/, '').replace(/\.\s*$/, '');
|
|
65
|
+
return `filter rule refused as it is lowered: ${body}`;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* The operators whose comparand IS an array by declaration, in both dialects.
|
|
69
|
+
*
|
|
70
|
+
* `$in` / `$nin` take a member list and `$between` a two-element range; every
|
|
71
|
+
* other operator reads its comparand as a single value. That split is what
|
|
72
|
+
* {@link refuseArrayComparand} is checked against — an array anywhere else is
|
|
73
|
+
* not a comparand this protocol has a meaning for.
|
|
74
|
+
*/
|
|
75
|
+
const DOLLAR_LIST_COMPARAND_OPERATORS = new Set(['$in', '$nin', '$between']);
|
|
76
|
+
const AST_LIST_COMPARAND_OPERATORS = new Set(['in', 'nin', 'between']);
|
|
77
|
+
/**
|
|
78
|
+
* The AST operators that never read the value slot at all, so nothing sitting
|
|
79
|
+
* there is a comparand. `matchesComparisonNode`'s null and emptiness arms take
|
|
80
|
+
* their direction from the operator NAME and the ObjectUI client sends a truthy
|
|
81
|
+
* placeholder in the third position, so the array guard must not judge it.
|
|
82
|
+
* The spec discards that slot on all four: `parseFilterAST(['x', 'is_empty',
|
|
83
|
+
* ['a']])` is `{ x: { $empty: true } }`.
|
|
84
|
+
*/
|
|
85
|
+
const AST_NO_COMPARAND_OPERATORS = new Set(['is_null', 'is_not_null', 'is_empty', 'is_not_empty']);
|
|
86
|
+
/**
|
|
87
|
+
* Is this stored value EMPTY, in the sense the spec's `$empty` operator gives
|
|
88
|
+
* the word (objectui#11094)?
|
|
89
|
+
*
|
|
90
|
+
* The answer is the spec's own `isEmptyFilterValue` (`@objectstack/spec/data`),
|
|
91
|
+
* called WITHOUT an expansion. That is the by-value reading: null, `undefined`
|
|
92
|
+
* (an absent key reads as `undefined` here), `''` and `[]` are empty, and
|
|
93
|
+
* nothing else is.
|
|
94
|
+
*
|
|
95
|
+
* ## Why no field type is read
|
|
96
|
+
*
|
|
97
|
+
* The spec's ruled 「is empty」 table (ruling B on objectstack#20311) is keyed
|
|
98
|
+
* on a field's DECLARED type and `multiple`: a text-like field is empty when
|
|
99
|
+
* null or `''`, a multi-value field when null or `[]`, every other type when
|
|
100
|
+
* null. A face that holds the declaration turns it into a row with
|
|
101
|
+
* `expandEmptyOperator` and passes that row to `isEmptyFilterValue`. This face
|
|
102
|
+
* holds none. `ValueDataSourceConfig` carries rows and an id field, and the
|
|
103
|
+
* spec's `value` data source declares only `items`. So this face judges by
|
|
104
|
+
* value, as ruling A on objectstack#20399 gives the faces with no field
|
|
105
|
+
* declarations. `@objectstack/formula`'s matcher and `driver-memory`'s
|
|
106
|
+
* reference matcher make the same call to the same function.
|
|
107
|
+
*
|
|
108
|
+
* ⛔ No per-type table is kept here, and none is inferred from the rows. The
|
|
109
|
+
* by-value reading differs from a declared face only on a stored state the
|
|
110
|
+
* declaration does not predict: `''` in a non-text column, or `[]` in a scalar
|
|
111
|
+
* one. The spec calls the first a write-door defect rather than a stored state.
|
|
112
|
+
*/
|
|
113
|
+
function isEmptyStoredValue(value) {
|
|
114
|
+
return isEmptyFilterValue(value);
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* An ARRAY where a single-value comparand belongs — `{ tags: ['a', 'b'] }`,
|
|
118
|
+
* `{ tags: { $eq: ['a', 'b'] } }`, `['tags', '=', ['a', 'b']]` (objectui#8514).
|
|
119
|
+
*
|
|
120
|
+
* ## Why this is refused rather than given a deep-equality reading
|
|
121
|
+
*
|
|
122
|
+
* The card was filed as "reference comparison excludes even the deep-equal
|
|
123
|
+
* row", which is true, and the obvious repair is to make `['a','b']` equal
|
|
124
|
+
* `['a','b']`. The census says otherwise, and the spec says who owns the
|
|
125
|
+
* question. `assertListComparandShapes` (`@objectstack/spec/data`) lists
|
|
126
|
+
* "arrays outside `$in`/`$nin`/`$between`" among the positions its comparand
|
|
127
|
+
* door deliberately does NOT judge, on the stated ground that the ruling does
|
|
128
|
+
* not name it and the matrix did not measure it — it is left "to the layers
|
|
129
|
+
* that already answer it". Those layers do not agree, and the two nearest to
|
|
130
|
+
* this one both REFUSE:
|
|
131
|
+
*
|
|
132
|
+
* - `@objectstack/formula`'s `matchesFilter` — the record-at-a-time evaluator
|
|
133
|
+
* this adapter is the closest sibling of — answers `false` structurally, on
|
|
134
|
+
* the comment "a bare array value is not a valid field spec (must be
|
|
135
|
+
* `{ $in: [...] }`)".
|
|
136
|
+
* - `@objectstack/driver-sql` refuses an array comparand with its own message.
|
|
137
|
+
* - Only the document stores give it array-equality, as a native behaviour of
|
|
138
|
+
* the store rather than a ruled contract.
|
|
139
|
+
*
|
|
140
|
+
* So there is no protocol answer to implement, and inventing one HERE is the
|
|
141
|
+
* lenient-consumer fallback the repo forbids (AGENTS.md #0.1): a second
|
|
142
|
+
* de-facto contract, agreed with by no backend, that a `provider: 'value'` list
|
|
143
|
+
* would honour and the same filter on the wire would not. Every producer in
|
|
144
|
+
* this repo already spells a multi-value comparand `{ $in: [...] }` — measured
|
|
145
|
+
* across every `$filter` literal under the packages' and apps' source trees,
|
|
146
|
+
* where the only array-valued comparands are `$in` / `$nin` members — so
|
|
147
|
+
* refusing costs no caller a working filter, and the message names the
|
|
148
|
+
* spelling that works. (The glob is spelled in words because a literal one
|
|
149
|
+
* would close this comment.)
|
|
150
|
+
*
|
|
151
|
+
* ## The rows do not move; the silence does — except on the negations
|
|
152
|
+
*
|
|
153
|
+
* For `{ tags: [...] }` and `{ tags: { $eq: [...] } }` this changes no result
|
|
154
|
+
* set: `!==` / `===` against a fresh array already excluded every row, so the
|
|
155
|
+
* repair is the diagnostic. `$ne` / `!=` are the exception and the reason this
|
|
156
|
+
* guard covers the operator positions too — `value !== target` against an
|
|
157
|
+
* array is always TRUE, so an array comparand there selected EVERY row, in
|
|
158
|
+
* silence. That is objectui#8447's fail-open direction surviving inside the
|
|
159
|
+
* arm objectui#8514 was filed about, and the same repair closes both.
|
|
160
|
+
*/
|
|
161
|
+
function refuseArrayComparand(refusals, field, operator) {
|
|
162
|
+
return refuseFilterNode(refusals, `filter comparand for field '${field}' on operator '${operator}' is an ARRAY, `
|
|
163
|
+
+ `which is a declared comparand only for $in / $nin / $between. The spec leaves `
|
|
164
|
+
+ `an array in any other position unruled and the sibling in-memory matcher `
|
|
165
|
+
+ `(@objectstack/formula) refuses it; express membership as `
|
|
166
|
+
+ `{ ${field}: { $in: [...] } } or its negation { ${field}: { $nin: [...] } }`);
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* An `icontains` / `$icontains` comparand that is not a NON-EMPTY STRING —
|
|
170
|
+
* `{ name: { $icontains: '' } }`, `['name', 'icontains', 42]` (objectui#8748).
|
|
171
|
+
*
|
|
172
|
+
* ## Not this file's ruling — the published table already refuses both
|
|
173
|
+
*
|
|
174
|
+
* `FILTER_TEXT_CASES` (`@objectstack/spec/data`) carries the two shapes as
|
|
175
|
+
* REJECTION rows rather than as row-set expectations, each with
|
|
176
|
+
* `code: 'INVALID_FILTER'` and `mustMention: ['$icontains']`:
|
|
177
|
+
*
|
|
178
|
+
* > *an empty `$icontains` comparand is REFUSED* — "Every row contains the
|
|
179
|
+
* > empty substring, so evaluating it is a predicate that constrains nothing —
|
|
180
|
+
* > the widening [objectstack-ai/objectstack#5240] refused `{ field: {} }` over, one level in."
|
|
181
|
+
*
|
|
182
|
+
* > *a non-string `$icontains` comparand is REFUSED* — "Coercing 42 to `"42"`
|
|
183
|
+
* > would answer a query nobody wrote; the declared comparand type is string."
|
|
184
|
+
*
|
|
185
|
+
* Measured on `origin/main` before this guard, over `FILTER_TEXT_ROWS`: the
|
|
186
|
+
* empty comparand answered ALL NINE ROWS in both dialects with not one console
|
|
187
|
+
* line — objectui#7349's fail-open signature, and the same widening class
|
|
188
|
+
* objectui#8447 fixed one level up. The non-string comparand was quieter and
|
|
189
|
+
* worse to debug: `String(42)` was evaluated and answered `[]`, which is
|
|
190
|
+
* indistinguishable on screen from "evaluated, matched nothing" — which is why
|
|
191
|
+
* pinning it as "42 and '42' agree" would pin nothing (they agree before this
|
|
192
|
+
* guard too). What moves is the REFUSAL: the row is excluded and the operator
|
|
193
|
+
* is named.
|
|
194
|
+
*
|
|
195
|
+
* ## Why only `icontains`, and why the row is excluded rather than thrown
|
|
196
|
+
*
|
|
197
|
+
* Only `$icontains` because only `$icontains` is what the table declares; the
|
|
198
|
+
* sibling positive operators (`$contains` / `$startsWith` / `$endsWith`) have
|
|
199
|
+
* no such row and are deliberately left alone rather than widened by analogy.
|
|
200
|
+
* Excluded-and-logged because that is this face's declared refusal shape
|
|
201
|
+
* (objectui#7349): the wire-side sibling `@object-ui/data-objectstack` throws
|
|
202
|
+
* `MalformedFilterError` because it is deciding whether to send a query at all,
|
|
203
|
+
* while this matcher is deciding about one row. Whether this face should carry
|
|
204
|
+
* the throwing envelope instead is objectui#8600 Q1, and it stays closed.
|
|
205
|
+
*
|
|
206
|
+
* ⚠️ The PRODUCER half ships in the same change and is what makes this safe.
|
|
207
|
+
* `FilterConditionField` emitted `{ [field]: { $icontains: value } }` verbatim,
|
|
208
|
+
* so a builder row with the operator chosen and the value box still empty
|
|
209
|
+
* produced exactly this shape — refusing it here alone would flip that list
|
|
210
|
+
* from "every row" to "no rows", which this file's own `$exists` arm names as
|
|
211
|
+
* the one outcome worse than the bug.
|
|
212
|
+
*/
|
|
213
|
+
/**
|
|
214
|
+
* ⚠️ The DISCRIMINATION and the MESSAGE moved to `utils/text-comparand.ts`
|
|
215
|
+
* (objectui#9048). Nothing about this face's answer changed — the reason string
|
|
216
|
+
* is the same bytes it has been since objectui#8748, and the pins that drive
|
|
217
|
+
* both faces and compare their wording verify that without transcribing it.
|
|
218
|
+
*
|
|
219
|
+
* What stayed here is the ENVELOPE, which is the half that does NOT transfer:
|
|
220
|
+
* this matcher is deciding about one ROW and HAS a row to exclude, so it
|
|
221
|
+
* excludes-and-logs (objectui#7349). Its two siblings each seat the same reason
|
|
222
|
+
* in their own envelope — `filter-converter` throws `FilterOperatorError`
|
|
223
|
+
* because it is a PRODUCER with no row to exclude.
|
|
224
|
+
*/
|
|
225
|
+
function refuseTextComparand(refusals, field, operator, target) {
|
|
226
|
+
return refuseFilterNode(refusals, textComparandRefusalReason(field, operator, target));
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* The operators a `{ $field }` reference is a legal comparand ON, in both
|
|
230
|
+
* dialects — the SIX scalar comparisons and nothing else.
|
|
231
|
+
*
|
|
232
|
+
* Not this file's choice: `FieldReferenceSchema` (`@objectstack/spec/data`)
|
|
233
|
+
* declares the reference on `$eq`/`$ne`/`$gt`/`$gte`/`$lt`/`$lte`, and
|
|
234
|
+
* objectstack#7596 REMOVED it from `$in` / `$nin` members and `$between`
|
|
235
|
+
* endpoints at the schema door, on the finding that no backend ever resolved
|
|
236
|
+
* one there.
|
|
237
|
+
*/
|
|
238
|
+
const DOLLAR_FIELD_REFERENCE_OPERATORS = new Set(['$eq', '$ne', '$gt', '$gte', '$lt', '$lte']);
|
|
239
|
+
const AST_FIELD_REFERENCE_OPERATORS = new Set(['=', '!=', '>', '>=', '<', '<=']);
|
|
240
|
+
/** A comparand this matcher refused; distinguishable from a resolved `undefined`. */
|
|
241
|
+
const REFUSED_COMPARAND = Symbol('ValueDataSource:refused-comparand');
|
|
242
|
+
/**
|
|
243
|
+
* Is this comparand shaped like a Filter Protocol FIELD REFERENCE?
|
|
244
|
+
*
|
|
245
|
+
* The test is `'$field' in value`, which is what `@objectstack/formula`'s
|
|
246
|
+
* `resolveValue` uses, so the three faces agree on what a reference IS before
|
|
247
|
+
* they differ on what to do with one. Validity of the `$field` VALUE is judged
|
|
248
|
+
* by {@link resolveFieldReference}, which refuses loudly — reporting a
|
|
249
|
+
* malformed reference beats letting it fall through to a comparison against
|
|
250
|
+
* the object, which is the silence this card is about.
|
|
251
|
+
*/
|
|
252
|
+
function isFieldReferenceComparand(value) {
|
|
253
|
+
return !!value && typeof value === 'object' && !Array.isArray(value) && '$field' in value;
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Resolve `{ $field: 'other_column' }` against the record being matched
|
|
257
|
+
* (objectui#8515), or refuse it with the reason.
|
|
258
|
+
*
|
|
259
|
+
* ## Why this is implemented rather than refused
|
|
260
|
+
*
|
|
261
|
+
* `@objectstack/spec/data` declares the shape, `@objectstack/formula`'s
|
|
262
|
+
* `compileCelToFilter` emits it for a field-to-field comparison in a CEL
|
|
263
|
+
* permission rule, and BOTH platform evaluation paths execute it — the
|
|
264
|
+
* in-memory `matchesFilter` resolves it in `resolveValue`, and `driver-sql`
|
|
265
|
+
* compiles it to a same-table column-to-column comparison (objectstack#5222),
|
|
266
|
+
* held to the same rows by a shared conformance corpus. Refusing it here would
|
|
267
|
+
* make this adapter the one face that answers a declared, executed shape with
|
|
268
|
+
* "no rows" — the outcome the sibling card ruled worse than the bug.
|
|
269
|
+
*
|
|
270
|
+
* Both of this file's matchers compared the reference OBJECT against the stored
|
|
271
|
+
* value: `value === target` for `$eq` (never equal — no rows) and `value > target`
|
|
272
|
+
* for the orderings (an object bound — no rows). Neither said anything.
|
|
273
|
+
*
|
|
274
|
+
* ## The three positions this refuses instead, each for a recorded reason
|
|
275
|
+
*
|
|
276
|
+
* - **A `$field` that is not a STRING.** `FieldReferenceSchema` declares
|
|
277
|
+
* `z.string()`, and the spec's own lowering predicate (matching `driver-sql`'s
|
|
278
|
+
* `fieldReferenceOf`) treats a non-string `$field` as "not a field reference
|
|
279
|
+
* on any path". Downstream that means "refused as the uncompilable object it
|
|
280
|
+
* is", which is what this does — loudly, rather than by object comparison.
|
|
281
|
+
* - **A DOTTED path.** This matcher addresses the left-hand side as
|
|
282
|
+
* `record[field]`, flat, so resolving a dotted path only on the right would
|
|
283
|
+
* make `['a.b', '=', x]` and `['x', '=', { $field: 'a.b' }]` disagree about
|
|
284
|
+
* what a dot means. SQL push-down refuses a dotted path too, under the
|
|
285
|
+
* maintainer's 2026-08-06 same-table ruling (no JOIN planning, no alias
|
|
286
|
+
* contract). `@objectstack/formula` DOES walk dots — that divergence is named
|
|
287
|
+
* here rather than papered over, because this adapter has no path accessor to
|
|
288
|
+
* be consistent with.
|
|
289
|
+
* - **`addDays`.** The whole-day offset (objectstack#14104) is defined against
|
|
290
|
+
* the referenced column's TEMPORAL CLASS — a `date` stays a calendar day, a
|
|
291
|
+
* `datetime` keeps its time of day — and SQL push-down compiles it only
|
|
292
|
+
* between two temporal columns of the same class, refusing everything else
|
|
293
|
+
* loudly. This matcher has no field types to read a class from, so it refuses
|
|
294
|
+
* for the same reason rather than guessing. Silently IGNORING the offset is
|
|
295
|
+
* the one answer that must not happen: it would compare against an unshifted
|
|
296
|
+
* column and return WRONG rows, where today's defect returns none.
|
|
297
|
+
*/
|
|
298
|
+
function resolveFieldReference(record, reference, operator, rawOperator, field, legalOperators, refusals) {
|
|
299
|
+
const path = reference.$field;
|
|
300
|
+
if (typeof path !== 'string') {
|
|
301
|
+
refuseFilterNode(refusals, `filter comparand for field '${field}' carries a non-string $field `
|
|
302
|
+
+ `(${describeComparand(path)}); a field reference declares `
|
|
303
|
+
+ `$field as a string, so this is not one on any path`);
|
|
304
|
+
return REFUSED_COMPARAND;
|
|
305
|
+
}
|
|
306
|
+
if (!legalOperators.has(operator)) {
|
|
307
|
+
refuseFilterNode(refusals, `a { $field } reference is not a comparand for operator '${rawOperator}' on field `
|
|
308
|
+
+ `'${field}'. It is declared only on the six scalar comparisons `
|
|
309
|
+
+ `($eq/$ne/$gt/$gte/$lt/$lte and their AST spellings); the spec removed it from `
|
|
310
|
+
+ `$in / $nin members and $between endpoints because no backend resolves one there`);
|
|
311
|
+
return REFUSED_COMPARAND;
|
|
312
|
+
}
|
|
313
|
+
if ('addDays' in reference && reference.addDays !== undefined) {
|
|
314
|
+
refuseFilterNode(refusals, `the { $field } reference on field '${field}' carries an addDays offset, which is `
|
|
315
|
+
+ `defined against the referenced column's temporal class; this matcher has no field `
|
|
316
|
+
+ `types to read one from, and answering without the offset would compare against an `
|
|
317
|
+
+ `unshifted column and return the wrong rows`);
|
|
318
|
+
return REFUSED_COMPARAND;
|
|
319
|
+
}
|
|
320
|
+
if (path.includes('.')) {
|
|
321
|
+
refuseFilterNode(refusals, `the { $field } reference on field '${field}' names the dotted path '${path}'; this `
|
|
322
|
+
+ `matcher addresses a flat record, so a dotted reference would not mean the same `
|
|
323
|
+
+ `thing as the dotted field name on the other side of the comparison`);
|
|
324
|
+
return REFUSED_COMPARAND;
|
|
325
|
+
}
|
|
326
|
+
return record[path];
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* A `{ $field }` reference sitting INSIDE a list comparand — an `$in` / `$nin`
|
|
330
|
+
* member or a `$between` endpoint (objectstack#7596, ruled 2026-08-11).
|
|
331
|
+
*
|
|
332
|
+
* The positions were declared once and honoured by nobody; the ruling was
|
|
333
|
+
* REMOVE rather than implement. The `$nin` direction is why this is loud rather
|
|
334
|
+
* than left alone: an unresolved member drops an EXCLUSION the author wrote,
|
|
335
|
+
* so the row passes — a filter that widens its result set in silence.
|
|
336
|
+
*/
|
|
337
|
+
function refuseListMemberFieldReference(refusals, field, operator) {
|
|
338
|
+
return refuseFilterNode(refusals, `a { $field } reference appears inside the list comparand of '${operator}' on field `
|
|
339
|
+
+ `'${field}'. A reference may be the WHOLE comparand of a scalar comparison, never a `
|
|
340
|
+
+ `member of $in / $nin nor an endpoint of $between — the spec removed those positions `
|
|
341
|
+
+ `because no evaluation path resolved them`);
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* EQUALITY between a stored value and one comparand, as every equality and
|
|
345
|
+
* membership position on this face reads it (objectui#10829): the AST arm's
|
|
346
|
+
* `=` / `!=` / `in` / `nin`, and the object arm's implicit equality, `$eq` /
|
|
347
|
+
* `$ne` / `$in` / `$nin`. One helper, so the two arms cannot drift apart on it.
|
|
348
|
+
*
|
|
349
|
+
* ## Two `Date`s compare their INSTANT
|
|
350
|
+
*
|
|
351
|
+
* Every position used `===` (membership used `includes`), which compares a
|
|
352
|
+
* `Date` by IDENTITY. The constructor `structuredClone`s the rows it is given,
|
|
353
|
+
* so none of them holds the comparand's instance (only `create` / `update`,
|
|
354
|
+
* which copy shallowly, can store the caller's own), and a `Date` equality
|
|
355
|
+
* matched none of them, not even the one holding that exact instant. The
|
|
356
|
+
* ordering arms already compared a `Date` by its number, so `>=` and `<=` both
|
|
357
|
+
* held for a row that `=` rejected. So when BOTH sides are `Date` instances,
|
|
358
|
+
* this compares `getTime()`, and `=`, `>=` and `<=` give one answer for one
|
|
359
|
+
* instant.
|
|
360
|
+
*
|
|
361
|
+
* An invalid `Date` (its time is `NaN`) equals nothing, itself included, so
|
|
362
|
+
* `!=` / `$ne` / `nin` / `$nin` hold for it.
|
|
363
|
+
*
|
|
364
|
+
* ## Everything else is `===`
|
|
365
|
+
*
|
|
366
|
+
* A `Date` comparand against an ISO string or an epoch-milliseconds number is
|
|
367
|
+
* NOT coerced. `@objectstack/spec`'s `FILTER_COMPARAND_TYPE_CASES` declines to
|
|
368
|
+
* assert a `Date` row set because what it matches "legitimately differs per
|
|
369
|
+
* storage form (ADR-0053)", and this matcher has no field types to read a
|
|
370
|
+
* storage form from. The ordering operators and `$between` keep their own
|
|
371
|
+
* comparisons.
|
|
372
|
+
*
|
|
373
|
+
* Membership now reads `===` too, where `includes` read SameValueZero. The one
|
|
374
|
+
* value those two disagree on is the number `NaN`: `{ x: { $in: [NaN] } }` no
|
|
375
|
+
* longer matches a stored `NaN`, which agrees with `{ x: NaN }` and `$eq`,
|
|
376
|
+
* where `NaN` never equalled `NaN`.
|
|
377
|
+
*/
|
|
378
|
+
function comparandEquals(value, target) {
|
|
379
|
+
if (value instanceof Date && target instanceof Date) {
|
|
380
|
+
const instant = value.getTime();
|
|
381
|
+
return !Number.isNaN(instant) && instant === target.getTime();
|
|
382
|
+
}
|
|
383
|
+
return value === target;
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Evaluate ONE comparison node — `[field, operator]` or `[field, operator, value]`.
|
|
387
|
+
*
|
|
388
|
+
* The operator is canonicalized through the spec's own
|
|
389
|
+
* {@link canonicalAstOperator}, so this switch has ONE arm per operator rather
|
|
390
|
+
* than one per spelling, and the accepted vocabulary is the published one
|
|
391
|
+
* rather than a second hand-written list that drifts from it. That matters here
|
|
392
|
+
* more than it reads: `viewFilterRuleToNode` (`../utils/filter-converter.ts`)
|
|
393
|
+
* lowers a stored view's rules through the spec's `normalizeFilterOperator`,
|
|
394
|
+
* so what actually arrives is the CANONICAL VIEW spelling — `equals`,
|
|
395
|
+
* `greater_than`, `starts_with` — and 16 of the 20 `VIEW_FILTER_OPERATORS`
|
|
396
|
+
* had no arm in the old spelling-keyed switch. Every one of them reached its
|
|
397
|
+
* `default: return true` and selected every row.
|
|
398
|
+
*/
|
|
399
|
+
function matchesComparisonNode(record, node, refusals) {
|
|
400
|
+
const field = node[0];
|
|
401
|
+
const rawOperator = node[1];
|
|
402
|
+
// `'not in'` (with a space) is NOT a member of the spec's
|
|
403
|
+
// `VALID_AST_OPERATORS` — the wire would refuse it — but this matcher has
|
|
404
|
+
// always implemented it and a test pins it. Canonicalizing it here keeps the
|
|
405
|
+
// refusal arm below from deleting support that exists today; whether to
|
|
406
|
+
// retire the spelling is a separate question from this card's.
|
|
407
|
+
const operator = rawOperator === 'not in' ? 'nin' : canonicalAstOperator(rawOperator);
|
|
408
|
+
const value = record[field];
|
|
409
|
+
const rawTarget = node[2];
|
|
410
|
+
// objectui#8514 — an array where a single-value comparand belongs. Checked
|
|
411
|
+
// before the switch so `=` / `!=` cannot answer it by reference (never equal
|
|
412
|
+
// / always unequal), and skipped for the operators that DECLARE an array
|
|
413
|
+
// comparand and for the two that never read the slot.
|
|
414
|
+
if (Array.isArray(rawTarget)
|
|
415
|
+
&& !AST_LIST_COMPARAND_OPERATORS.has(operator)
|
|
416
|
+
&& !AST_NO_COMPARAND_OPERATORS.has(operator)) {
|
|
417
|
+
return refuseArrayComparand(refusals, field, String(rawOperator));
|
|
418
|
+
}
|
|
419
|
+
// objectui#8515 — a `{ $field }` reference inside a list comparand. The
|
|
420
|
+
// members are walked only for the three operators that HAVE a list comparand;
|
|
421
|
+
// everywhere else an array was already refused above.
|
|
422
|
+
if (Array.isArray(rawTarget) && rawTarget.some(isFieldReferenceComparand)) {
|
|
423
|
+
return refuseListMemberFieldReference(refusals, field, String(rawOperator));
|
|
424
|
+
}
|
|
425
|
+
// objectui#8515 — a `{ $field }` reference as the WHOLE comparand. Resolved
|
|
426
|
+
// against this record so the switch below compares two of its own values,
|
|
427
|
+
// exactly as `matchesFilter` (`@objectstack/formula`) and `driver-sql` do.
|
|
428
|
+
const target = isFieldReferenceComparand(rawTarget)
|
|
429
|
+
? resolveFieldReference(record, rawTarget, operator, String(rawOperator), field, AST_FIELD_REFERENCE_OPERATORS, refusals)
|
|
430
|
+
: rawTarget;
|
|
431
|
+
if (target === REFUSED_COMPARAND)
|
|
432
|
+
return false;
|
|
433
|
+
switch (operator) {
|
|
434
|
+
// -- Null-ness. Direction comes from the operator NAME; the value slot is
|
|
435
|
+
// never read, so the 2-tuple `['x', 'is_not_null']` and the 3-tuple
|
|
436
|
+
// `['x', 'isnotnull', null]` are the same predicate. `canonicalAstOperator`
|
|
437
|
+
// folds `is_null` / `isnull` onto the first arm and their two negatives
|
|
438
|
+
// onto the second.
|
|
439
|
+
case 'is_null':
|
|
440
|
+
return value === null || value === undefined;
|
|
441
|
+
case 'is_not_null':
|
|
442
|
+
return value !== null && value !== undefined;
|
|
443
|
+
// -- Emptiness (objectui#11094). The spec stopped folding `is_empty` /
|
|
444
|
+
// `isempty` onto `is_null` (objectstack#20570, in `@objectstack/spec`
|
|
445
|
+
// 17.6.0): `canonicalAstOperator` now answers `is_empty` for those two
|
|
446
|
+
// spellings and `is_not_empty` for their two negatives, and the spec
|
|
447
|
+
// lowers them to `$empty: true` / `$empty: false`. So they get their own
|
|
448
|
+
// arms, the twins of the `$empty` arm in `matchesDollarOperator`. Without
|
|
449
|
+
// these arms, on that release a stored `is_empty` rule reached the refusal
|
|
450
|
+
// below and selected no row. Direction comes from the operator NAME, as
|
|
451
|
+
// for null-ness, and the meaning is the spec's emptiness test, not a null
|
|
452
|
+
// test: see {@link isEmptyStoredValue}.
|
|
453
|
+
case 'is_empty':
|
|
454
|
+
return isEmptyStoredValue(value);
|
|
455
|
+
case 'is_not_empty':
|
|
456
|
+
return !isEmptyStoredValue(value);
|
|
457
|
+
// objectui#10829 — equality and membership through `comparandEquals`, so
|
|
458
|
+
// two `Date`s compare their instant; the ordering arms below are untouched.
|
|
459
|
+
case '=':
|
|
460
|
+
return comparandEquals(value, target);
|
|
461
|
+
case '!=':
|
|
462
|
+
return !comparandEquals(value, target);
|
|
463
|
+
case '>':
|
|
464
|
+
return value > target;
|
|
465
|
+
case '>=':
|
|
466
|
+
return value >= target;
|
|
467
|
+
case '<':
|
|
468
|
+
return value < target;
|
|
469
|
+
case '<=':
|
|
470
|
+
return value <= target;
|
|
471
|
+
case 'in':
|
|
472
|
+
return Array.isArray(target) && target.some((member) => comparandEquals(value, member));
|
|
473
|
+
case 'nin':
|
|
474
|
+
return Array.isArray(target) && !target.some((member) => comparandEquals(value, member));
|
|
475
|
+
// -- Text operators. THE DIRECTION, so the next reader does not take it for
|
|
476
|
+
// a typo and fold it back (objectui#7379): the `$contains` FAMILY is
|
|
477
|
+
// case-SENSITIVE and `icontains` is its one case-insensitive member. That
|
|
478
|
+
// is the platform's ruling (objectstack#4706 Q2 = A), not this file's
|
|
479
|
+
// preference, and it is what every backend executes — `driver-sql`,
|
|
480
|
+
// `driver-sqlite-wasm`, `driver-turso`, `driver-mongodb` and
|
|
481
|
+
// `driver-memory` all import `FILTER_TEXT_CASES` (`@objectstack/spec/data`)
|
|
482
|
+
// and answer its `$contains is case-SENSITIVE` rows. These four arms used
|
|
483
|
+
// to lower-case BOTH sides, so `contains` executed `icontains`, the two
|
|
484
|
+
// were one predicate, and a `provider: 'value'` list answered a filter with
|
|
485
|
+
// strictly more rows than the same filter run against the wire.
|
|
486
|
+
//
|
|
487
|
+
// There is no `i` twin for the other three: `VALID_AST_OPERATORS` has
|
|
488
|
+
// `icontains` and NOTHING else with an `i` prefix — no `istartswith`, no
|
|
489
|
+
// `iendswith`, no `not_icontains` (the `$` dialect has no `$notIcontains`,
|
|
490
|
+
// and the AST table mirrors the executed set rather than widening it). So
|
|
491
|
+
// case-sensitive is the ONLY reading available to them, and it is the one
|
|
492
|
+
// `$notContains` needs for complementarity: a folding `not_contains` beside
|
|
493
|
+
// a case-exact `contains` lets one row fail an operator AND its negation.
|
|
494
|
+
case 'contains':
|
|
495
|
+
return typeof value === 'string' && value.includes(String(target));
|
|
496
|
+
// The fold is ASCII-ONLY (objectstack#4706 Q1 = A) and it runs on BOTH
|
|
497
|
+
// sides, which is why this borrows the spec's own predicate instead of
|
|
498
|
+
// spelling one here. `String.prototype.toLowerCase()` — what this arm used
|
|
499
|
+
// to reach for — is the FULL Unicode fold, so it matched `CAFÉ` against
|
|
500
|
+
// `café`; three of the five backends are SQLite underneath, whose `lower()`
|
|
501
|
+
// folds ASCII only, so a Unicode promise here is one the wire cannot keep.
|
|
502
|
+
// objectui#8748 — the comparand door, checked BEFORE the fold. An empty
|
|
503
|
+
// comparand made this arm a predicate that constrained nothing (all nine
|
|
504
|
+
// `FILTER_TEXT_ROWS` came back, in silence); a non-string one was evaluated
|
|
505
|
+
// after a `String()` coercion nobody wrote. Both are refusals in
|
|
506
|
+
// `FILTER_TEXT_CASES`; see {@link refuseTextComparand}.
|
|
507
|
+
case 'icontains':
|
|
508
|
+
if (isRefusedTextComparand(target)) {
|
|
509
|
+
return refuseTextComparand(refusals, field, String(rawOperator), target);
|
|
510
|
+
}
|
|
511
|
+
return typeof value === 'string' && asciiCaseInsensitiveContains(value, target);
|
|
512
|
+
// objectui#8452 — this arm answers the PREDICATE, not a TYPE TEST. It used
|
|
513
|
+
// to read `typeof value === 'string' && !value.includes(...)`, so a row
|
|
514
|
+
// whose value is the number 5 failed `contains '5'` (right: a number cannot
|
|
515
|
+
// contain a substring) AND failed `not_contains '5'` (wrong: for the very
|
|
516
|
+
// same reason it cannot contain it, it does not contain it). Measured on
|
|
517
|
+
// this fixture before the fix, `not_contains '5'` answered `['sx']` where
|
|
518
|
+
// it now answers seven of eight rows: six rows sat outside BOTH halves of
|
|
519
|
+
// the partition, so no filter answer included them and the opposite filter
|
|
520
|
+
// — the one thing a user has to debug with — was silent too.
|
|
521
|
+
//
|
|
522
|
+
// The direction is objectstack#14079 (maintainer ruling 2026-09-05,
|
|
523
|
+
// option A), quoted from `filter-text-conformance.ts`, the contract that
|
|
524
|
+
// carries it: "a stored value that is not a string never satisfies a
|
|
525
|
+
// positive text operator (`$contains` / `$startsWith` / `$endsWith` /
|
|
526
|
+
// `$icontains` / `$like` / `$ilike`) and satisfies `$notContains` —
|
|
527
|
+
// complementarity holds, on every face." `FILTER_TEXT_CASES`' `score` rows
|
|
528
|
+
// pin it, and `driver-memory`'s reference matcher — the face the defect was
|
|
529
|
+
// measured on — carries the same expression negated the same way.
|
|
530
|
+
//
|
|
531
|
+
// So this is the exact complement of the `contains` arm above, and it has
|
|
532
|
+
// to STAY spelled that way: the type gate on the positive operators is the
|
|
533
|
+
// other half of the same ruling, not a leftover to clean up. A `null` or an
|
|
534
|
+
// absent key is admitted by the same predicate (they are not strings),
|
|
535
|
+
// which is also what objectstack#13166 ruled for this operator.
|
|
536
|
+
case 'not_contains':
|
|
537
|
+
return !(typeof value === 'string' && value.includes(String(target)));
|
|
538
|
+
case 'starts_with':
|
|
539
|
+
return typeof value === 'string' && value.startsWith(String(target));
|
|
540
|
+
case 'ends_with':
|
|
541
|
+
return typeof value === 'string' && value.endsWith(String(target));
|
|
542
|
+
case 'between':
|
|
543
|
+
return Array.isArray(target) && target.length === 2 && value >= target[0] && value <= target[1];
|
|
544
|
+
default:
|
|
545
|
+
// Includes the spec-valid `like` / `ilike`: they carry pattern semantics
|
|
546
|
+
// this in-memory matcher does not implement, and no producer in this repo
|
|
547
|
+
// emits them into a filter. Refusing is the loud answer; matching every
|
|
548
|
+
// row was the silent one.
|
|
549
|
+
return refuseFilterNode(refusals, `filter operator '${String(rawOperator)}' is not implemented by the in-memory matcher`);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
21
552
|
/**
|
|
22
553
|
* Evaluate an AST-format filter node against a record.
|
|
23
|
-
*
|
|
24
|
-
*
|
|
554
|
+
*
|
|
555
|
+
* Reads the four shapes the spec's `FilterArraySchema` declares and
|
|
556
|
+
* `isFilterAST` accepts, so this consumer applies the same filter the wire
|
|
557
|
+
* would (objectui#7349):
|
|
558
|
+
*
|
|
559
|
+
* - `['and' | 'or', ...children]` a logical group
|
|
560
|
+
* - `[field, operator, value]` a comparison
|
|
561
|
+
* - `[field, operator]` a comparison whose operator needs no value
|
|
562
|
+
* - `[[…], […]]` a legacy flat list of conditions, implicit AND
|
|
563
|
+
*
|
|
564
|
+
* The last one is the shape this matcher used to ignore ENTIRELY, at top level
|
|
565
|
+
* and as a child of `and` / `or` alike — and it is what `mergeFilterNodes`
|
|
566
|
+
* returns for a lone surviving filter source, i.e. the common case. A flat
|
|
567
|
+
* array is unambiguous: the spec's `FilterArrayFieldSchema` forbids `and` / `or`
|
|
568
|
+
* as field names, so a node whose head is itself an array can only be a list.
|
|
569
|
+
*
|
|
570
|
+
* Anything else is refused rather than passed. An empty array stays "no
|
|
571
|
+
* filter" — the spec says the same, and `find()` never calls with one.
|
|
572
|
+
*
|
|
573
|
+
* A spec `ViewFilterRule[]` never reaches this function as rule objects:
|
|
574
|
+
* `find()` lowers the array through `toFilterNode` first (objectui#10767), so a
|
|
575
|
+
* rule arrives here as the comparison tuple its operator spells. A rule object
|
|
576
|
+
* that DOES arrive is one the lowering left alone — no string `field` — and is
|
|
577
|
+
* refused below like any other unreadable node.
|
|
25
578
|
*/
|
|
26
|
-
function matchesASTFilter(record, filterNode) {
|
|
27
|
-
if (!filterNode
|
|
579
|
+
function matchesASTFilter(record, filterNode, refusals) {
|
|
580
|
+
if (!Array.isArray(filterNode)) {
|
|
581
|
+
return refuseFilterNode(refusals, `filter node ${JSON.stringify(filterNode) ?? String(filterNode)} is not an array`);
|
|
582
|
+
}
|
|
583
|
+
if (filterNode.length === 0)
|
|
28
584
|
return true;
|
|
29
585
|
const head = filterNode[0];
|
|
30
|
-
// Logical
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
// Condition node: [field, operator, value]
|
|
38
|
-
if (filterNode.length === 3 && typeof head === 'string') {
|
|
39
|
-
const [field, operator, target] = filterNode;
|
|
40
|
-
const value = record[field];
|
|
41
|
-
switch (operator) {
|
|
42
|
-
case '=':
|
|
43
|
-
return value === target;
|
|
44
|
-
case '!=':
|
|
45
|
-
return value !== target;
|
|
46
|
-
case '>':
|
|
47
|
-
return value > target;
|
|
48
|
-
case '>=':
|
|
49
|
-
return value >= target;
|
|
50
|
-
case '<':
|
|
51
|
-
return value < target;
|
|
52
|
-
case '<=':
|
|
53
|
-
return value <= target;
|
|
54
|
-
case 'in':
|
|
55
|
-
return Array.isArray(target) && target.includes(value);
|
|
56
|
-
case 'not in':
|
|
57
|
-
case 'not_in':
|
|
58
|
-
case 'nin': // canonical (per spec)
|
|
59
|
-
case 'notin': // legacy alias
|
|
60
|
-
return Array.isArray(target) && !target.includes(value);
|
|
61
|
-
case 'contains': {
|
|
62
|
-
const lv = typeof value === 'string' ? value.toLowerCase() : '';
|
|
63
|
-
return typeof value === 'string' && lv.includes(String(target).toLowerCase());
|
|
586
|
+
// Logical group. `length >= 2` mirrors `isFilterAST`: the keyword opens a
|
|
587
|
+
// group and a group needs at least one condition.
|
|
588
|
+
if (typeof head === 'string') {
|
|
589
|
+
const keyword = head.toLowerCase();
|
|
590
|
+
if (keyword === 'and' || keyword === 'or') {
|
|
591
|
+
if (filterNode.length < 2) {
|
|
592
|
+
return refuseFilterNode(refusals, `'${keyword}' group carries no conditions`);
|
|
64
593
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
594
|
+
const children = filterNode.slice(1);
|
|
595
|
+
return keyword === 'and'
|
|
596
|
+
? children.every((sub) => matchesASTFilter(record, sub, refusals))
|
|
597
|
+
: children.some((sub) => matchesASTFilter(record, sub, refusals));
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
// Legacy flat list of conditions — implicit AND, the way the server reads it.
|
|
601
|
+
if (Array.isArray(head)) {
|
|
602
|
+
return filterNode.every((sub) => matchesASTFilter(record, sub, refusals));
|
|
603
|
+
}
|
|
604
|
+
// Comparison node, 2- or 3-element.
|
|
605
|
+
if (typeof head === 'string' && typeof filterNode[1] === 'string' && filterNode.length <= 3) {
|
|
606
|
+
return matchesComparisonNode(record, filterNode, refusals);
|
|
607
|
+
}
|
|
608
|
+
return refuseFilterNode(refusals, `filter node ${JSON.stringify(filterNode)} is not a shape the matcher reads`);
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* Evaluate ONE `$`-dialect operator against a record's value for a field.
|
|
612
|
+
*
|
|
613
|
+
* The vocabulary is the spec's own `FILTER_OPERATORS` (`@objectstack/spec/data`)
|
|
614
|
+
* and each arm answers the same question its AST twin answers in
|
|
615
|
+
* {@link matchesComparisonNode} — `$eq`/`=`, `$nin`/`nin`, `$startsWith`/
|
|
616
|
+
* `starts_with`, `$empty`/`is_empty`, and so on, one-to-one across EVERY
|
|
617
|
+
* member: every declared operator is executed here, none is refused by name.
|
|
618
|
+
* How many members there are is not written here: the case table in
|
|
619
|
+
* `ValueDataSource.dollarFilterVocabulary.test.ts` is held equal to
|
|
620
|
+
* `FILTER_OPERATORS`, and it is what re-derives that. That pairing IS the
|
|
621
|
+
* fix for objectui#8447: `find()` picks between the two matchers on nothing
|
|
622
|
+
* more than whether `$filter` arrived as an array or an object, so any operator
|
|
623
|
+
* one of them executes and the other waves through is a result that changes
|
|
624
|
+
* with the SHAPE of the filter rather than with its meaning.
|
|
625
|
+
*
|
|
626
|
+
* ## What the `default` arm used to be
|
|
627
|
+
*
|
|
628
|
+
* `default: break` — which adds NO constraint. An operator this switch did not
|
|
629
|
+
* name therefore matched EVERY row, silently, while the array arm of the very
|
|
630
|
+
* same `if` refused the unknown node, excluded the row and logged it
|
|
631
|
+
* (objectui#7349). Measured before the fix: `$nin`, `$startsWith`, `$null` and
|
|
632
|
+
* `$exists` each returned the full set, and so did `$eq` — the most ordinary
|
|
633
|
+
* operator an author can reach for, whose plain-equality sibling
|
|
634
|
+
* (`{ age: 26 }`) was correct all along.
|
|
635
|
+
*
|
|
636
|
+
* The direction of the change is worth stating plainly, because it MOVES
|
|
637
|
+
* RESULTS: a filter that silently selected everything now selects the rows it
|
|
638
|
+
* names, and one this matcher cannot execute selects nothing and says so. Both
|
|
639
|
+
* are louder than what they replace; neither is "match everything", which was
|
|
640
|
+
* nobody's intent.
|
|
641
|
+
*
|
|
642
|
+
* ## What is refused, and why each one
|
|
643
|
+
*
|
|
644
|
+
* Nothing the spec DECLARES is on this list — `REFUSED_OPERATORS` in the
|
|
645
|
+
* companion test is empty, so the `FILTER_OPERATORS` parity guard covers the
|
|
646
|
+
* whole published vocabulary rather than a subset of it. What follows is
|
|
647
|
+
* everything OUTSIDE that vocabulary.
|
|
648
|
+
*
|
|
649
|
+
* - **`$like` / `$ilike`** — declared by `StringOperatorSchema` but deliberately
|
|
650
|
+
* kept OUT of `FILTER_OPERATORS` while the faces that cannot execute them are
|
|
651
|
+
* staged in (objectstack#7536). This matcher has no pattern engine, and the
|
|
652
|
+
* spec's own note says naming them before an arm exists turns a loud refusal
|
|
653
|
+
* into a dropped predicate — i.e. every row, the defect this file just fixed.
|
|
654
|
+
* - **`$regex` / `$options`** — RETIRED from the protocol (objectstack#4706).
|
|
655
|
+
* The refusal prints `RETIRED_FILTER_OPERATORS`' prescription verbatim, the
|
|
656
|
+
* way the five driver-side refusal sites do, so six faces say one thing.
|
|
657
|
+
* - **Anything else**, including a nested relation constraint
|
|
658
|
+
* (`{ profile: { verified: true } }`), whose keys are field names rather than
|
|
659
|
+
* operators: this matcher does not descend into relations.
|
|
660
|
+
*/
|
|
661
|
+
function matchesDollarOperator(record, value, operator, rawTarget, field, refusals) {
|
|
662
|
+
// objectui#8514 — the `$` twin of the AST guard above, and the position that
|
|
663
|
+
// carries the fail-OPEN case: `$ne` against an array is always true.
|
|
664
|
+
if (Array.isArray(rawTarget) && !DOLLAR_LIST_COMPARAND_OPERATORS.has(operator)) {
|
|
665
|
+
return refuseArrayComparand(refusals, field, operator);
|
|
666
|
+
}
|
|
667
|
+
// objectui#8515 — a `{ $field }` reference inside a list comparand.
|
|
668
|
+
if (Array.isArray(rawTarget) && rawTarget.some(isFieldReferenceComparand)) {
|
|
669
|
+
return refuseListMemberFieldReference(refusals, field, operator);
|
|
670
|
+
}
|
|
671
|
+
// objectui#8515 — a `{ $field }` reference as the WHOLE comparand.
|
|
672
|
+
const target = isFieldReferenceComparand(rawTarget)
|
|
673
|
+
? resolveFieldReference(record, rawTarget, operator, operator, field, DOLLAR_FIELD_REFERENCE_OPERATORS, refusals)
|
|
674
|
+
: rawTarget;
|
|
675
|
+
if (target === REFUSED_COMPARAND)
|
|
676
|
+
return false;
|
|
677
|
+
switch (operator) {
|
|
678
|
+
// objectui#10829 — the same helper as the AST twins above.
|
|
679
|
+
case '$eq':
|
|
680
|
+
return comparandEquals(value, target);
|
|
681
|
+
case '$ne':
|
|
682
|
+
return !comparandEquals(value, target);
|
|
683
|
+
case '$gt':
|
|
684
|
+
return value > target;
|
|
685
|
+
case '$gte':
|
|
686
|
+
return value >= target;
|
|
687
|
+
case '$lt':
|
|
688
|
+
return value < target;
|
|
689
|
+
case '$lte':
|
|
690
|
+
return value <= target;
|
|
691
|
+
case '$in':
|
|
692
|
+
return Array.isArray(target) && target.some((member) => comparandEquals(value, member));
|
|
693
|
+
case '$nin':
|
|
694
|
+
return Array.isArray(target) && !target.some((member) => comparandEquals(value, member));
|
|
695
|
+
case '$between':
|
|
696
|
+
return Array.isArray(target) && target.length === 2
|
|
697
|
+
&& value >= target[0] && value <= target[1];
|
|
698
|
+
// -- Text operators. Same ruling the AST arms carry (objectstack#4706 Q2):
|
|
699
|
+
// the `$contains` family is case-SENSITIVE and `$icontains` is its one
|
|
700
|
+
// case-insensitive member, folding ASCII only on both sides.
|
|
701
|
+
case '$contains':
|
|
702
|
+
return typeof value === 'string' && value.includes(String(target));
|
|
703
|
+
// objectui#8748 — the `$` twin of the comparand door in the AST arm. Both
|
|
704
|
+
// dialects have to refuse the same two shapes: `find()` picks between the
|
|
705
|
+
// matchers on nothing more than whether `$filter` arrived as an array or an
|
|
706
|
+
// object, so a door on one side only is a result that changes with the
|
|
707
|
+
// SHAPE of the filter rather than with its meaning (objectui#8447).
|
|
708
|
+
case '$icontains':
|
|
709
|
+
if (isRefusedTextComparand(target)) {
|
|
710
|
+
return refuseTextComparand(refusals, field, operator, target);
|
|
68
711
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
712
|
+
return typeof value === 'string' && asciiCaseInsensitiveContains(value, target);
|
|
713
|
+
// objectui#8452 — the `$` spelling of the same cell, and the same fix: the
|
|
714
|
+
// complement of the `$contains` arm above rather than a `typeof` test
|
|
715
|
+
// standing in for the predicate. objectstack#14079 option A: a stored value
|
|
716
|
+
// that is not a string never satisfies a positive text operator and
|
|
717
|
+
// satisfies `$notContains`, so complementarity holds on every face. The two
|
|
718
|
+
// dialects are pinned against each other so this one cannot drift back.
|
|
719
|
+
case '$notContains':
|
|
720
|
+
return !(typeof value === 'string' && value.includes(String(target)));
|
|
721
|
+
case '$startsWith':
|
|
722
|
+
return typeof value === 'string' && value.startsWith(String(target));
|
|
723
|
+
case '$endsWith':
|
|
724
|
+
return typeof value === 'string' && value.endsWith(String(target));
|
|
725
|
+
// -- Null-ness. Unlike the AST spelling, direction comes from the VALUE:
|
|
726
|
+
// `$null: true` is IS NULL and `$null: false` is IS NOT NULL, which is the
|
|
727
|
+
// lowering `convertFiltersToAST` already performs.
|
|
728
|
+
case '$null':
|
|
729
|
+
return target
|
|
730
|
+
? value === null || value === undefined
|
|
731
|
+
: value !== null && value !== undefined;
|
|
732
|
+
// `$exists` is the exact INVERSE of `$null`, and that is the platform's own
|
|
733
|
+
// reading rather than one invented here: `convertFiltersToAST`
|
|
734
|
+
// (`../utils/filter-converter.ts`) lowers `$exists: true` to `is_not_null`
|
|
735
|
+
// and `$exists: false` to `is_null`, three lines below where it lowers
|
|
736
|
+
// `$null` the other way round. Both operators are absent from that file's
|
|
737
|
+
// `convertOperatorToAST` map and both are special-cased BEFORE it, so that
|
|
738
|
+
// map's silence is not a decision about either of them. Two producers in
|
|
739
|
+
// this repo emit `$exists` — `FilterConditionField`'s `exists` /
|
|
740
|
+
// `notExists` (kept reachable on purpose by objectui#4736) and
|
|
741
|
+
// `datasetFilterCondition`'s `isEmpty` / `isNotEmpty` — so refusing it
|
|
742
|
+
// would have turned "every row" into "no rows" on a filter that looks like
|
|
743
|
+
// it works, which is the one outcome worse than the bug.
|
|
744
|
+
case '$exists':
|
|
745
|
+
return target
|
|
746
|
+
? value !== null && value !== undefined
|
|
747
|
+
: value === null || value === undefined;
|
|
748
|
+
// objectui#11094 — `$empty`, which `@objectstack/spec` 17.6.0 admitted to
|
|
749
|
+
// `FILTER_OPERATORS` (objectstack#20446) and to which it lowers `is_empty` /
|
|
750
|
+
// `is_not_empty`. `true` selects the empty rows and `false` is the exact
|
|
751
|
+
// complement, by value (see {@link isEmptyStoredValue}); the AST twins are
|
|
752
|
+
// the `is_empty` / `is_not_empty` arms in `matchesComparisonNode`.
|
|
753
|
+
//
|
|
754
|
+
// The flag is a BOOLEAN by declaration (`SpecialOperatorSchema`), so
|
|
755
|
+
// anything else is refused here rather than read for its truthiness. That
|
|
756
|
+
// is the answer the spec gives at its save door and on every query face,
|
|
757
|
+
// and `@objectstack/formula`'s matcher denies a non-boolean flag too.
|
|
758
|
+
case '$empty':
|
|
759
|
+
if (target === true)
|
|
760
|
+
return isEmptyStoredValue(value);
|
|
761
|
+
if (target === false)
|
|
762
|
+
return !isEmptyStoredValue(value);
|
|
763
|
+
return refuseFilterNode(refusals, `filter operator '$empty' on field '${field}' takes a boolean flag (true or false); `
|
|
764
|
+
+ `received ${describeComparand(target)}`);
|
|
765
|
+
// objectui#8515 — the hand-authored IMPLICIT form `{ amount: { $field: 'x' } }`.
|
|
766
|
+
// It is not a reference comparand: an object whose only key starts with `$`
|
|
767
|
+
// reads as an OPERATOR SPEC named `$field`, which is why this arrives here
|
|
768
|
+
// as an operator at all. objectstack#7597 ruled that form keeps its
|
|
769
|
+
// fail-closed fate rather than being promoted, and named the spelling that
|
|
770
|
+
// works — the same one `driver-sql`'s refusal names. The default arm below
|
|
771
|
+
// already excluded the row; this arm only replaces "not implemented" with
|
|
772
|
+
// the prescription, so an author reading the console can act on it.
|
|
773
|
+
case '$field':
|
|
774
|
+
return refuseFilterNode(refusals, `field '${field}' carries a bare { $field } object, which reads as an operator `
|
|
775
|
+
+ `named '$field' rather than as a comparand. Write the explicit comparison `
|
|
776
|
+
+ `{ ${field}: { $eq: { $field: ${JSON.stringify(String(rawTarget))} } } }`);
|
|
777
|
+
default: {
|
|
778
|
+
const retired = RETIRED_FILTER_OPERATORS[operator];
|
|
779
|
+
if (retired) {
|
|
780
|
+
return refuseFilterNode(refusals, `filter operator '${operator}' on field '${field}' is retired. ${retired.why}`);
|
|
72
781
|
}
|
|
73
|
-
|
|
74
|
-
return
|
|
75
|
-
|
|
76
|
-
|
|
782
|
+
if (!operator.startsWith('$')) {
|
|
783
|
+
return refuseFilterNode(refusals, `filter condition on field '${field}' carries the non-operator key `
|
|
784
|
+
+ `'${operator}'; the in-memory matcher does not descend into a nested `
|
|
785
|
+
+ `relation constraint`);
|
|
786
|
+
}
|
|
787
|
+
return refuseFilterNode(refusals, `filter operator '${operator}' on field '${field}' is not implemented by the `
|
|
788
|
+
+ `in-memory matcher`);
|
|
77
789
|
}
|
|
78
790
|
}
|
|
79
|
-
return true;
|
|
80
791
|
}
|
|
81
792
|
/**
|
|
82
|
-
*
|
|
83
|
-
*
|
|
793
|
+
* Evaluate ONE `$and` / `$or` group against a record (objectui#8513).
|
|
794
|
+
*
|
|
795
|
+
* ## The identities are the JS reducers, not a special case
|
|
796
|
+
*
|
|
797
|
+
* `$and` is `every` and `$or` is `some`, and the empty-array answers those two
|
|
798
|
+
* reducers already give — `[].every(…)` is `true`, `[].some(…)` is `false` —
|
|
799
|
+
* ARE the ruled identities. objectstack#5322 (closed `completed`, merged as
|
|
800
|
+
* objectstack#5365) fixed `{ $and: [] }` as TRUE / every row and `{ $or: [] }`
|
|
801
|
+
* as FALSE / no row, and `@objectstack/spec` pins all four identity answers in
|
|
802
|
+
* the cross-backend `FILTER_LOGIC_CASES` table that
|
|
803
|
+
* `ValueDataSource.filterLogicConformance.test.ts` drives this matcher through.
|
|
804
|
+
* So there is no identity branch to write here and none to get wrong: writing
|
|
805
|
+
* one would be a second statement of the rule that could drift from the table.
|
|
806
|
+
*
|
|
807
|
+
* A `{}` branch needs no arm either. {@link matchesFilter} answers `true` for a
|
|
808
|
+
* filter with no entries, which is what makes a `{}` disjunct a TRUE branch
|
|
809
|
+
* that ABSORBS its `$or` and a `{}` conjunct one that drops out of an `$and` —
|
|
810
|
+
* the third and fourth identities, for free, from the same recursion.
|
|
811
|
+
*
|
|
812
|
+
* ## Why the members are checked BEFORE any of them is evaluated
|
|
813
|
+
*
|
|
814
|
+
* `every` / `some` short-circuit, so a lazy shape check would reach a malformed
|
|
815
|
+
* member for some records and not for others — and `refusals` is drained once
|
|
816
|
+
* per `find()`, so whether the author sees the diagnostic at all would depend
|
|
817
|
+
* on which row happened to be tested first. Validating the whole member list up
|
|
818
|
+
* front makes the refusal a property of the FILTER rather than of the data.
|
|
819
|
+
*
|
|
820
|
+
* A refused group excludes the row, the same direction every other refusal in
|
|
821
|
+
* this file takes. Inside `$or` that is narrowing (one fewer way to match) and
|
|
822
|
+
* inside `$and` it is exclusion outright; neither can widen a result set.
|
|
84
823
|
*/
|
|
85
|
-
function
|
|
824
|
+
function matchesLogicalGroup(record, keyword, value, refusals) {
|
|
825
|
+
if (!Array.isArray(value)) {
|
|
826
|
+
return refuseFilterNode(refusals, `filter combinator '${keyword}' takes an ARRAY of conditions; received `
|
|
827
|
+
+ `${typeof value === 'object' && value !== null ? 'an object' : typeof value}: `
|
|
828
|
+
+ `${JSON.stringify(value) ?? String(value)}. The spec declares `
|
|
829
|
+
+ `'${keyword}?: FilterCondition[]' (FilterConditionSchema, data/filter.zod.ts)`);
|
|
830
|
+
}
|
|
831
|
+
for (const branch of value) {
|
|
832
|
+
if (branch === null || typeof branch !== 'object' || Array.isArray(branch)) {
|
|
833
|
+
return refuseFilterNode(refusals, `every member of filter combinator '${keyword}' must be a filter condition `
|
|
834
|
+
+ `OBJECT; received ${JSON.stringify(branch) ?? String(branch)}. The spec `
|
|
835
|
+
+ `declares '${keyword}?: FilterCondition[]' (FilterConditionSchema, `
|
|
836
|
+
+ `data/filter.zod.ts)`);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
return keyword === '$and'
|
|
840
|
+
? value.every((branch) => matchesFilter(record, branch, refusals))
|
|
841
|
+
: value.some((branch) => matchesFilter(record, branch, refusals));
|
|
842
|
+
}
|
|
843
|
+
/**
|
|
844
|
+
* In-memory evaluation of an OBJECT-shaped (`$`-dialect) filter.
|
|
845
|
+
*
|
|
846
|
+
* Reads a flat key/value equality (`{ age: 26 }`) or a `FieldOperatorsSchema`
|
|
847
|
+
* condition object (`{ age: { $gte: 25 } }`), one entry per field, ANDed. What
|
|
848
|
+
* it cannot execute it refuses through {@link refuseFilterNode} — excluded and
|
|
849
|
+
* logged once per distinct refusal per `find()` — rather than adding no
|
|
850
|
+
* constraint, which is what its `default: break` used to do (objectui#8447).
|
|
851
|
+
*
|
|
852
|
+
* ## `$and` / `$or` are EXECUTED here; `$not` is still refused (objectui#8513)
|
|
853
|
+
*
|
|
854
|
+
* The three `LOGICAL_OPERATORS` are not one case and this file must not let a
|
|
855
|
+
* fix flatten them into one. Before objectui#8447 made the refusal loud, they
|
|
856
|
+
* failed in two OPPOSITE directions: `{ $and: [...] }` / `{ $or: [...] }` carry
|
|
857
|
+
* an ARRAY, so they fell to the simple-equality branch below
|
|
858
|
+
* (`record['$and'] !== [...]` is always true) and excluded EVERY row, while
|
|
859
|
+
* `{ $not: {...} }` carries an OBJECT (`FilterConditionSchema`, not an array),
|
|
860
|
+
* entered the operator branch with its own nested FIELD names read as operator
|
|
861
|
+
* names, and matched every row. Two bugs, opposite signs, one heading.
|
|
862
|
+
*
|
|
863
|
+
* **`$and` / `$or` are executed** because the semantics they were waiting on
|
|
864
|
+
* have been ruled and shipped: objectstack#5322 (merged objectstack#5365)
|
|
865
|
+
* settled the empty-group identities, and five platform backends already answer
|
|
866
|
+
* to them through the shared `FILTER_LOGIC_CASES` conformance table. Refusing a
|
|
867
|
+
* shape the spec DECLARES, this repo's own `convertFiltersToAST` LOWERS, and
|
|
868
|
+
* every wire face EXECUTES made this adapter the one face that answers "no
|
|
869
|
+
* rows" to a filter the UI itself offers — the outcome objectui#8515 already
|
|
870
|
+
* ruled worse than the bug. Executing them is therefore restoring a declared
|
|
871
|
+
* invariant, not widening an accept set: no new operator, no new key, nothing
|
|
872
|
+
* admitted that `FilterConditionSchema` does not already declare.
|
|
873
|
+
*
|
|
874
|
+
* **`$not` keeps its refusal**, deliberately and with its own message rather
|
|
875
|
+
* than by falling through to the unknown-`$`-key arm below. objectstack#5146
|
|
876
|
+
* (merged objectstack#5296) did rule its NULL-safe semantics — the operand
|
|
877
|
+
* compiles to a TOTAL predicate before being negated — but this repo's own
|
|
878
|
+
* `convertFiltersToAST` (`../utils/filter-converter.ts`) still THROWS for
|
|
879
|
+
* `$not`, on a narrowing that is about the AST rather than about the ruling:
|
|
880
|
+
* `FILTER_ARRAY_LOGIC_KEYWORDS` is `['and', 'or']`, so the array dialect has no
|
|
881
|
+
* negation keyword, and rewriting the negation inward is silently partial
|
|
882
|
+
* because `startswith` / `endswith` / `between` / `icontains` have no negated
|
|
883
|
+
* counterpart in `VALID_AST_OPERATORS`. Whether that objectui-side narrowing
|
|
884
|
+
* should stand now that upstream has ruled is a question objectui#8513 does not
|
|
885
|
+
* decide, so `$not` is left exactly where it was — refused, loudly, and
|
|
886
|
+
* distinguishable in the log from a group that executes.
|
|
887
|
+
*/
|
|
888
|
+
function matchesFilter(record, filter, refusals) {
|
|
86
889
|
for (const [key, condition] of Object.entries(filter)) {
|
|
890
|
+
// The two combinators this matcher executes. A group is one ENTRY of the
|
|
891
|
+
// condition object, so it ANDs with its siblings exactly as a field entry
|
|
892
|
+
// does — `{ status: 'open', $or: [...] }` is "status AND the group", which
|
|
893
|
+
// is what `FILTER_LOGIC_CASES` measures in both key orders.
|
|
894
|
+
if (key === '$and' || key === '$or') {
|
|
895
|
+
if (!matchesLogicalGroup(record, key, condition, refusals))
|
|
896
|
+
return false;
|
|
897
|
+
continue;
|
|
898
|
+
}
|
|
899
|
+
// Refused on its own terms, ahead of the generic `$` arm, so the log tells
|
|
900
|
+
// an author which of the three combinators they hit and why this one is
|
|
901
|
+
// different. See this function's doc for the reasoning; the short version
|
|
902
|
+
// is that the AST this repo lowers to has no negation keyword.
|
|
903
|
+
if (key === '$not') {
|
|
904
|
+
return refuseFilterNode(refusals, `filter combinator '$not' is not executed by the object-dialect matcher. `
|
|
905
|
+
+ `Its NULL-safe semantics are ruled (objectstack#5146) but this repo's own `
|
|
906
|
+
+ `lowering refuses it too: FILTER_ARRAY_LOGIC_KEYWORDS is ['and', 'or'], so `
|
|
907
|
+
+ `the filter AST has no negation keyword, and rewriting the negation inward `
|
|
908
|
+
+ `would be silently partial ('startswith', 'endswith', 'between' and `
|
|
909
|
+
+ `'icontains' have no negated counterpart). Express the negation with a `
|
|
910
|
+
+ `negated operator instead ($ne, $nin, $notContains); note those follow each `
|
|
911
|
+
+ `operator's own answer for a missing value rather than $not's NULL-safe rule`);
|
|
912
|
+
}
|
|
913
|
+
if (key.startsWith('$')) {
|
|
914
|
+
return refuseFilterNode(refusals, `filter key '${key}' is not a field name and not one of the combinators this `
|
|
915
|
+
+ `matcher executes ($and / $or). The spec's LOGICAL_OPERATORS are `
|
|
916
|
+
+ `$and / $or / $not and nothing else`);
|
|
917
|
+
}
|
|
87
918
|
const value = record[key];
|
|
88
|
-
|
|
89
|
-
|
|
919
|
+
// objectui#10829 — a `Date` is a COMPARAND, not an operator map. It is an
|
|
920
|
+
// object and not an array, so it used to enter the operator branch below,
|
|
921
|
+
// where `Object.entries` of a Date is `[]`: the loop ran zero times and the
|
|
922
|
+
// field added NO constraint. `{ status: 'a', created: someDate }` answered
|
|
923
|
+
// the same rows as `{ status: 'a' }`, in silence, while
|
|
924
|
+
// `convertFiltersToAST` lowers the same filter to
|
|
925
|
+
// `['created', '=', someDate]` (objectui#8555).
|
|
926
|
+
//
|
|
927
|
+
// So it takes the simple-equality branch, the position every other
|
|
928
|
+
// accepted comparand takes. That branch compares through
|
|
929
|
+
// `comparandEquals`, the helper the AST arm's `=` uses, so the object filter
|
|
930
|
+
// and its lowered array answer one row set: the rows holding the same
|
|
931
|
+
// instant as a `Date`. The gate is the
|
|
932
|
+
// spec's `isAcceptedFilterComparand`, the predicate the converter lowers a
|
|
933
|
+
// Date through, rather than a local `instanceof Date`: an object-typed
|
|
934
|
+
// value the converter lowers as a comparand is then one this matcher reads
|
|
935
|
+
// as a comparand, and no such value can reach the zero-operator loop.
|
|
936
|
+
if (condition && typeof condition === 'object' && !Array.isArray(condition)
|
|
937
|
+
&& !isAcceptedFilterComparand(condition)) {
|
|
938
|
+
// Operator-based filter — every operator on the field is ANDed.
|
|
90
939
|
for (const [op, target] of Object.entries(condition)) {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
if (!(value > target))
|
|
94
|
-
return false;
|
|
95
|
-
break;
|
|
96
|
-
case '$gte':
|
|
97
|
-
if (!(value >= target))
|
|
98
|
-
return false;
|
|
99
|
-
break;
|
|
100
|
-
case '$lt':
|
|
101
|
-
if (!(value < target))
|
|
102
|
-
return false;
|
|
103
|
-
break;
|
|
104
|
-
case '$lte':
|
|
105
|
-
if (!(value <= target))
|
|
106
|
-
return false;
|
|
107
|
-
break;
|
|
108
|
-
case '$ne':
|
|
109
|
-
if (value === target)
|
|
110
|
-
return false;
|
|
111
|
-
break;
|
|
112
|
-
case '$in':
|
|
113
|
-
if (!Array.isArray(target) || !target.includes(value))
|
|
114
|
-
return false;
|
|
115
|
-
break;
|
|
116
|
-
case '$contains':
|
|
117
|
-
if (typeof value !== 'string' || !value.toLowerCase().includes(String(target).toLowerCase()))
|
|
118
|
-
return false;
|
|
119
|
-
break;
|
|
120
|
-
default:
|
|
121
|
-
break;
|
|
122
|
-
}
|
|
940
|
+
if (!matchesDollarOperator(record, value, op, target, key, refusals))
|
|
941
|
+
return false;
|
|
123
942
|
}
|
|
124
943
|
}
|
|
944
|
+
else if (Array.isArray(condition)) {
|
|
945
|
+
// objectui#8514 — the implicit-equality position this card was filed
|
|
946
|
+
// about. It reaches here because the operator branch above is guarded by
|
|
947
|
+
// `!Array.isArray(condition)`, and that guard STAYS: routing an array
|
|
948
|
+
// into the operator loop would read its INDICES as operator names, the
|
|
949
|
+
// exact shape `$not` had before objectui#8447 fixed it.
|
|
950
|
+
return refuseArrayComparand(refusals, key, 'implicit equality');
|
|
951
|
+
}
|
|
125
952
|
else {
|
|
126
|
-
// Simple equality
|
|
127
|
-
if (value
|
|
953
|
+
// Simple equality — a scalar, `null`, or a `Date` (objectui#10829).
|
|
954
|
+
if (!comparandEquals(value, condition))
|
|
128
955
|
return false;
|
|
129
956
|
}
|
|
130
957
|
}
|
|
131
958
|
return true;
|
|
132
959
|
}
|
|
960
|
+
/**
|
|
961
|
+
* The object `$filter`'s first condition that is an object with NO OWN KEYS and
|
|
962
|
+
* that the converter refuses: `{ created: {} }`, alone or anywhere under `$and`
|
|
963
|
+
* / `$or` (objectui#10817).
|
|
964
|
+
*
|
|
965
|
+
* ## What was wrong
|
|
966
|
+
*
|
|
967
|
+
* {@link matchesFilter} read `{ created: {} }` as an operator map whose
|
|
968
|
+
* operator loop runs zero times, so the field added NO constraint.
|
|
969
|
+
* `{ status: 'a', created: {} }` answered the same rows as `{ status: 'a' }`,
|
|
970
|
+
* and `{ created: {} }` answered every row, with no console line. The same
|
|
971
|
+
* authored filter is refused on the ObjectStack path: `@objectstack/spec` ruled
|
|
972
|
+
* `{ field: {} }` REJECTED wherever it appears (objectstack#5240, recorded on
|
|
973
|
+
* `FilterConditionSchema`), and `convertFiltersToAST` refuses it alone
|
|
974
|
+
* (objectui#9164) and beside a key that lowers (objectui#10788). One filter,
|
|
975
|
+
* two fates, and the value face took the widening one.
|
|
976
|
+
*
|
|
977
|
+
* ## Why the converter decides, and not a check written here
|
|
978
|
+
*
|
|
979
|
+
* The field is handed to {@link toFilterNodeSafely} on its own, as
|
|
980
|
+
* `{ [field]: condition }`. The converter alone decides what an empty operator
|
|
981
|
+
* map is, with its own throw site and its own wording, so there is no second
|
|
982
|
+
* opinion to drift and no new export (`@object-ui/core` re-exports the whole
|
|
983
|
+
* converter module). Only a zero-key object is routed: an object that HAS keys
|
|
984
|
+
* is an operator map the matcher below already reads, and refuses per node
|
|
985
|
+
* when it cannot.
|
|
986
|
+
*
|
|
987
|
+
* What the routing carries, on purpose, is whatever the converter decides for
|
|
988
|
+
* a zero-key object. A `RegExp`, `Map` or `Set` also has no own keys, and the
|
|
989
|
+
* converter refuses it as an exotic comparand (objectui#8567), so this face now
|
|
990
|
+
* refuses it too instead of dropping the constraint. A `Date` has no own keys
|
|
991
|
+
* either, but the converter LOWERS it, so it reaches the matcher, which reads
|
|
992
|
+
* it as implicit equality and matches the rows holding the same instant as a
|
|
993
|
+
* `Date` (objectui#10829). A `null` / `undefined` condition is not an object
|
|
994
|
+
* and is not routed.
|
|
995
|
+
*
|
|
996
|
+
* ## Why up front, and why the whole filter
|
|
997
|
+
*
|
|
998
|
+
* {@link matchesFilter} returns on a row's first false entry and `$or` uses
|
|
999
|
+
* `some`, so a refusal raised while matching would fire for some rows and not
|
|
1000
|
+
* for others: `{ status: 'a', created: {} }` would log only if some row had
|
|
1001
|
+
* status `'a'`, and `{ $or: [{ status: 'b' }, { status: 'a', created: {} }] }`
|
|
1002
|
+
* would still answer the `'b'` rows. `@objectstack/formula` walks the tree
|
|
1003
|
+
* before evaluating for the same reason. A refused filter answers NO rows and
|
|
1004
|
+
* logs the converter's reason once, which is exactly how the array arm answers
|
|
1005
|
+
* a filter its lowering refuses (objectui#10767). `find` never throws on a bad
|
|
1006
|
+
* filter (objectui#7349).
|
|
1007
|
+
*
|
|
1008
|
+
* The walk follows the two combinators {@link matchesFilter} executes. A
|
|
1009
|
+
* malformed group, `$not` and every other `$` key keep the refusals the
|
|
1010
|
+
* matcher already gives them.
|
|
1011
|
+
*/
|
|
1012
|
+
function zeroKeyConditionRefusal(filter) {
|
|
1013
|
+
for (const [key, condition] of Object.entries(filter)) {
|
|
1014
|
+
if (key === '$and' || key === '$or') {
|
|
1015
|
+
if (!Array.isArray(condition))
|
|
1016
|
+
continue;
|
|
1017
|
+
for (const member of condition) {
|
|
1018
|
+
if (member === null || typeof member !== 'object' || Array.isArray(member))
|
|
1019
|
+
continue;
|
|
1020
|
+
const refusal = zeroKeyConditionRefusal(member);
|
|
1021
|
+
if (refusal)
|
|
1022
|
+
return refusal;
|
|
1023
|
+
}
|
|
1024
|
+
continue;
|
|
1025
|
+
}
|
|
1026
|
+
if (key.startsWith('$'))
|
|
1027
|
+
continue;
|
|
1028
|
+
if (condition === null || typeof condition !== 'object' || Array.isArray(condition))
|
|
1029
|
+
continue;
|
|
1030
|
+
if (Object.keys(condition).length > 0)
|
|
1031
|
+
continue;
|
|
1032
|
+
const lowered = toFilterNodeSafely({ [key]: condition });
|
|
1033
|
+
if (!lowered.ok)
|
|
1034
|
+
return lowered.refusal;
|
|
1035
|
+
}
|
|
1036
|
+
return undefined;
|
|
1037
|
+
}
|
|
133
1038
|
/** Apply sort ordering to an array (returns a new sorted array) */
|
|
134
1039
|
function applySort(data, orderby) {
|
|
135
1040
|
if (!orderby)
|
|
@@ -226,8 +1131,27 @@ export class ValueDataSource {
|
|
|
226
1131
|
writable: true,
|
|
227
1132
|
value: new Set()
|
|
228
1133
|
});
|
|
229
|
-
// Deep clone to prevent external mutation
|
|
230
|
-
|
|
1134
|
+
// Deep clone to prevent external mutation.
|
|
1135
|
+
//
|
|
1136
|
+
// `structuredClone`, NOT a `JSON.parse(JSON.stringify(...))` round-trip
|
|
1137
|
+
// (objectui#9175, maintainer ruling A on objectui#9061). The clone exists
|
|
1138
|
+
// only to stop a caller mutating rows this read-only query source already
|
|
1139
|
+
// handed out; it was never a serialization boundary, and the round-trip
|
|
1140
|
+
// quietly made it one. Everything routed through `provider: 'value'` had to
|
|
1141
|
+
// survive `JSON.stringify` — so a `Date` came back as a string, keys whose
|
|
1142
|
+
// value was `undefined` disappeared, a cycle threw, and objectui#6018's
|
|
1143
|
+
// pinned guarantee ("an inline value never has to be serializable at all")
|
|
1144
|
+
// became false the moment a renderer routed its inline rows through this
|
|
1145
|
+
// adapter to honour `filter` / `sort` / the objectui#7210 ceiling.
|
|
1146
|
+
//
|
|
1147
|
+
// `structuredClone` handles cycles, `Date`, `Map`/`Set`, `BigInt` and typed
|
|
1148
|
+
// arrays, and is already an unguarded runtime requirement of published
|
|
1149
|
+
// ObjectUI packages (`@object-ui/app-shell`, `@object-ui/plugin-designer`).
|
|
1150
|
+
// It still throws `DataCloneError` on a function or a DOM node — that is
|
|
1151
|
+
// deliberate and stays LOUD: ⛔ no `try`/`catch` fallback here, because
|
|
1152
|
+
// falling back to the round-trip would restore exactly the silent
|
|
1153
|
+
// flattening this replaces.
|
|
1154
|
+
this.items = structuredClone(config.items);
|
|
231
1155
|
this.idField = config.idField;
|
|
232
1156
|
}
|
|
233
1157
|
/** Notify all mutation subscribers */
|
|
@@ -248,12 +1172,73 @@ export class ValueDataSource {
|
|
|
248
1172
|
let result = [...this.items];
|
|
249
1173
|
// Filter — support both MongoDB-style objects and AST-format arrays
|
|
250
1174
|
if (params?.$filter) {
|
|
251
|
-
|
|
252
|
-
|
|
1175
|
+
// ONE collector for BOTH arms, drained after the pass: a node either
|
|
1176
|
+
// matcher refuses would otherwise log once PER ROW. Shared on purpose —
|
|
1177
|
+
// the two arms differ only in the SHAPE of `$filter`, and objectui#8447
|
|
1178
|
+
// was exactly the asymmetry of one arm refusing loudly while the other
|
|
1179
|
+
// waved everything through in silence.
|
|
1180
|
+
const refusals = new Set();
|
|
1181
|
+
if (Array.isArray(params.$filter)) {
|
|
1182
|
+
// objectui#10767 — THE ARRAY ARM LOWERS BEFORE IT MATCHES.
|
|
1183
|
+
//
|
|
1184
|
+
// An array `$filter` is one of three things: an AST node, a legacy flat
|
|
1185
|
+
// list of nodes, or a spec `ViewFilterRule[]` (`[{ field, operator,
|
|
1186
|
+
// value }, …]`) — the ONLY form the spec's converged `filter` doors
|
|
1187
|
+
// accept (objectui#6206 B), and therefore the form a spec-conformant
|
|
1188
|
+
// page carries. `matchesASTFilter` reads the first two; a rule OBJECT
|
|
1189
|
+
// is none of its shapes, so it used to refuse every row of a
|
|
1190
|
+
// spec-conformant filter with one warning, and the four blocks whose
|
|
1191
|
+
// rows can be inline (`object-map` / `object-tree` / `object-calendar`
|
|
1192
|
+
// / `object-gantt`) rendered NOTHING for the one filter an author is
|
|
1193
|
+
// allowed to write.
|
|
1194
|
+
//
|
|
1195
|
+
// The lowering is the repo's ONE sink, `toFilterNode` — the same
|
|
1196
|
+
// function the grid, the list and every other lowering caller already
|
|
1197
|
+
// use before a wire query — so this adapter grows no second lowering
|
|
1198
|
+
// and no second operator table: the rule's operator goes through the
|
|
1199
|
+
// spec's own `normalizeFilterOperator` there, and arrives here as the
|
|
1200
|
+
// AST spelling the arm below already executes (`equals`,
|
|
1201
|
+
// `greater_than`, …). `QueryParams.$filter` (`@object-ui/types`) has
|
|
1202
|
+
// declared the array form legal for every DataSource and named the wire
|
|
1203
|
+
// adapter's `translateFilterToAST` as the accept set; this was the one
|
|
1204
|
+
// face in the family that did not read the rule array, so
|
|
1205
|
+
// `resolveDataSource` answered `provider: 'value'` with an adapter that
|
|
1206
|
+
// read FEWER filter shapes than `provider: 'object'` got.
|
|
1207
|
+
//
|
|
1208
|
+
// The matcher itself is NOT relaxed. `toFilterNode` returns an AST
|
|
1209
|
+
// array untouched (no rule objects, nothing to lower), `undefined` for
|
|
1210
|
+
// an empty array (no filter — what the object-bound arm answers too),
|
|
1211
|
+
// and passes an operator the spec does not know through VERBATIM, so a
|
|
1212
|
+
// node that is genuinely unreadable still reaches the refusals below
|
|
1213
|
+
// unchanged and still excludes the row with the same sentence. A rule
|
|
1214
|
+
// the lowering itself refuses is excluded and logged once through
|
|
1215
|
+
// `loweringRefusalReason` — never thrown from `find`.
|
|
1216
|
+
const lowered = toFilterNodeSafely(params.$filter);
|
|
1217
|
+
if (!lowered.ok) {
|
|
1218
|
+
refuseFilterNode(refusals, loweringRefusalReason(lowered.refusal));
|
|
1219
|
+
result = [];
|
|
1220
|
+
}
|
|
1221
|
+
else if (Array.isArray(lowered.node) && lowered.node.length > 0) {
|
|
1222
|
+
const node = lowered.node;
|
|
1223
|
+
result = result.filter((r) => matchesASTFilter(r, node, refusals));
|
|
1224
|
+
}
|
|
253
1225
|
}
|
|
254
|
-
else if (
|
|
255
|
-
|
|
1226
|
+
else if (Object.keys(params.$filter).length > 0) {
|
|
1227
|
+
// objectui#10817 — a condition that is an object with no own keys is
|
|
1228
|
+
// judged by the converter BEFORE any row is matched, and a refusal
|
|
1229
|
+
// answers the whole filter the way the array arm answers one above.
|
|
1230
|
+
const filter = params.$filter;
|
|
1231
|
+
const refusal = zeroKeyConditionRefusal(filter);
|
|
1232
|
+
if (refusal) {
|
|
1233
|
+
refuseFilterNode(refusals, loweringRefusalReason(refusal));
|
|
1234
|
+
result = [];
|
|
1235
|
+
}
|
|
1236
|
+
else {
|
|
1237
|
+
result = result.filter((r) => matchesFilter(r, filter, refusals));
|
|
1238
|
+
}
|
|
256
1239
|
}
|
|
1240
|
+
for (const message of refusals)
|
|
1241
|
+
console.warn(message);
|
|
257
1242
|
}
|
|
258
1243
|
// Search (simple text search across all string fields)
|
|
259
1244
|
if (params?.$search) {
|
|
@@ -306,7 +1291,9 @@ export class ValueDataSource {
|
|
|
306
1291
|
throw new Error(`ValueDataSource: Record with id "${id}" not found`);
|
|
307
1292
|
}
|
|
308
1293
|
this.items[index] = { ...this.items[index], ...data };
|
|
309
|
-
|
|
1294
|
+
// The event carries the protocol's string id (objectui#10078): an inline
|
|
1295
|
+
// item may hold a numeric key, and this adapter is where it converts.
|
|
1296
|
+
this.emitMutation({ type: 'update', resource: _resource, id: String(id), record: { ...this.items[index] } });
|
|
310
1297
|
return { ...this.items[index] };
|
|
311
1298
|
}
|
|
312
1299
|
async delete(_resource, id) {
|
|
@@ -314,7 +1301,7 @@ export class ValueDataSource {
|
|
|
314
1301
|
if (index === -1)
|
|
315
1302
|
return false;
|
|
316
1303
|
this.items.splice(index, 1);
|
|
317
|
-
this.emitMutation({ type: 'delete', resource: _resource, id });
|
|
1304
|
+
this.emitMutation({ type: 'delete', resource: _resource, id: String(id) });
|
|
318
1305
|
return true;
|
|
319
1306
|
}
|
|
320
1307
|
async bulk(_resource, operation, data) {
|
|
@@ -414,8 +1401,13 @@ export class ValueDataSource {
|
|
|
414
1401
|
get count() {
|
|
415
1402
|
return this.items.length;
|
|
416
1403
|
}
|
|
417
|
-
/**
|
|
1404
|
+
/**
|
|
1405
|
+
* Get a snapshot of all items (cloned).
|
|
1406
|
+
*
|
|
1407
|
+
* Same clone as the constructor and for the same reason — see the note
|
|
1408
|
+
* there: `structuredClone`, never a JSON round-trip (objectui#9175).
|
|
1409
|
+
*/
|
|
418
1410
|
getAll() {
|
|
419
|
-
return
|
|
1411
|
+
return structuredClone(this.items);
|
|
420
1412
|
}
|
|
421
1413
|
}
|