@uniweb/core 0.26.2 → 0.28.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/src/where.js CHANGED
@@ -7,6 +7,14 @@
7
7
  * (which translate to their native query language) or to this evaluator
8
8
  * (which walks the object against a record).
9
9
  *
10
+ * ⭐ ONE LANGUAGE, ONE MEANING PER OPERATOR, on every lane. A compiled site
11
+ * evaluates a query here; a host that answers queries evaluates the same
12
+ * language at the source. The set below is the language ruled on 2026-09-13
13
+ * [Diego] for a stored query and a question alike, so a query answers the same
14
+ * wherever it is asked. ⛔ Until then this evaluator had `like` and `nin` and none
15
+ * of `contains`, `starts_with`, `ends_with`, and it read `exists`, comparisons on
16
+ * a list and an empty `and` differently (measured).
17
+ *
10
18
  * Shape:
11
19
  *
12
20
  * {
@@ -18,7 +26,7 @@
18
26
  * // For non-equality, the value is an operator object.
19
27
  * start_year: { gte: 2010 },
20
28
  * rank: { in: ['associate', 'full'] },
21
- * title: { like: 'Origin*' },
29
+ * title: { starts_with: 'the' },
22
30
  *
23
31
  * // Explicit composition keys at any nesting level.
24
32
  * and: [{ tenured: true }, { rank: 'full' }],
@@ -28,57 +36,84 @@
28
36
  *
29
37
  * Operators (in operator-object form):
30
38
  *
31
- * eq Equal (also implicit when the value is bare, non-object, non-null).
32
- * ne Not equal.
33
- * gt/gte Greater than / greater than or equal.
34
- * lt/lte Less than / less than or equal.
35
- * in Value is in the listed array.
36
- * nin Value is not in the listed array.
37
- * like Glob match (`*` any run, `?` one char). String fields only.
38
- * exists Field is truthy (boolean toggle).
39
+ * eq Equal (also implicit when the value is bare).
40
+ * ne Not equal.
41
+ * gt/gte Greater than / greater than or equal.
42
+ * lt/lte Less than / less than or equal.
43
+ * in Equal to one of the listed values.
44
+ * not_in Equal to none of the listed values.
45
+ * exists `true`: the field has a value — not missing, null, "" or [].
46
+ * `false`: it has none.
47
+ * contains On a list, holds an item equal to the value; on a text, holds
48
+ * the value as a piece of it (plain text, case-insensitive).
49
+ * starts_with A text that starts with the value (plain text, case-insensitive).
50
+ * ends_with A text that ends with the value (plain text, case-insensitive).
39
51
  *
40
52
  * Composition keys:
41
53
  *
42
- * and Array of sub-predicates; all must match.
43
- * or Array of sub-predicates; at least one must match.
44
- * not Single sub-predicate; must not match.
54
+ * and A non-empty list of sub-predicates; all must match.
55
+ * or A non-empty list of sub-predicates; at least one must match.
56
+ * not One sub-predicate; must not match.
45
57
  *
46
- * ⛔ **`under` is retired (2026-09-11 [Diego]).** It existed for `where: { path:
47
- * { under: X } }` — a folder branch, written before a query had `scope:`. A branch
48
- * is a scope now, on both lanes (`./scope.js`), and the build refuses `under` with
49
- * a message naming `scope:`. An operator object carrying it is no longer an
50
- * operator object here, so it matches nothing: the build's refusal is the guard.
58
+ * ⭐ A LIST FIELD (`tags: ['news', 'rust']`) holds a condition when ANY member
59
+ * satisfies it: `tags: news` has `news`, `ne: news` does not have it, `not_in`
60
+ * has none of the values, a comparison holds for some member. Values stay typed:
61
+ * `'3'` does not equal `3`. A field a record has no value for satisfies `ne`,
62
+ * `not_in` and `exists: false`, and nothing else.
51
63
  *
52
- * Dotted paths descend into nested objects: `tenure.start: { gte: 2015 }`.
64
+ * ⛔ A WHERE OUTSIDE THE LANGUAGE SELECTS NO RECORDS — an unknown or retired
65
+ * operator (`like`, `nin`, `under`), an empty `and` / `or`, a text operator with an
66
+ * empty argument. It never falls back to a wider answer. `whereOutsideLanguage`
67
+ * says why, so a producer can refuse the declaration where the author wrote it.
53
68
  *
54
- * Type safety: type mismatches return `false` rather than throwing
55
- * (e.g., comparing a string to a number with `gt`). Missing fields
56
- * return `false` for equality and most operators; `exists: false` matches
57
- * missing/falsy fields.
69
+ * Dotted paths descend into nested objects: `tenure.start: { gte: 2015 }` — never one with
70
+ * an empty step, nor one starting at a `$` field (`field-path.js`). ⭐ A
71
+ * path that meets a list descends into each item, and the values it reaches are
72
+ * read as a list field — so `education.degree: PhD` matches a record any of whose
73
+ * `education` entries has that degree. (Kept on this lane by ruling 2026-09-13
74
+ * [Diego]; a host's records service may not answer them.)
58
75
  */
59
76
 
60
- const COMPOSITION_KEYS = new Set(['and', 'or', 'not'])
77
+ import { fieldPathProblem } from './field-path.js'
78
+
61
79
  const OPERATORS = new Set([
62
- 'eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'in', 'nin', 'like', 'exists',
80
+ 'eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'in', 'not_in', 'exists', 'contains', 'starts_with', 'ends_with',
63
81
  ])
64
82
 
83
+ /** Retired spellings, each with the sentence that names its replacement. */
84
+ const RETIRED_OPERATORS = {
85
+ nin: '`nin` is spelled `not_in`',
86
+ like: '`like` is retired — use `starts_with`, `ends_with` or `contains`',
87
+ under: '`under` is retired — a folder branch is `scope:`',
88
+ }
89
+
90
+ /**
91
+ * Why a where-object is outside the language, or null when it is inside.
92
+ *
93
+ * @param {*} where
94
+ * @returns {string|null} the first problem found, as a sentence
95
+ */
96
+ export function whereOutsideLanguage(where) {
97
+ if (where == null) return null
98
+ if (typeof where !== 'object' || Array.isArray(where)) return 'a where is an object of conditions'
99
+ for (const [key, value] of Object.entries(where)) {
100
+ const problem = clauseProblem(key, value)
101
+ if (problem) return problem
102
+ }
103
+ return null
104
+ }
105
+
65
106
  /**
66
107
  * Evaluate a where-object against a single record.
67
108
  *
68
109
  * @param {Object} where - The where-object predicate.
69
110
  * @param {Object} record - The record to test.
70
- * @returns {boolean} true if the record matches.
111
+ * @returns {boolean} true if the record matches; false for a where outside the language.
71
112
  */
72
113
  export function evaluate(where, record) {
73
114
  if (where == null) return true
74
- if (typeof where !== 'object' || Array.isArray(where)) return false
75
- if (record == null || typeof record !== 'object') return false
76
-
77
- // Implicit AND across all top-level keys.
78
- for (const key of Object.keys(where)) {
79
- if (!evaluateClause(key, where[key], record)) return false
80
- }
81
- return true
115
+ if (whereOutsideLanguage(where)) return false
116
+ return test(where, record)
82
117
  }
83
118
 
84
119
  /**
@@ -86,142 +121,169 @@ export function evaluate(where, record) {
86
121
  *
87
122
  * @param {Object} where - The where-object predicate.
88
123
  * @param {Array<Object>} records - The records to filter.
89
- * @returns {Array<Object>} Records in source order for which the predicate is true.
124
+ * @returns {Array<Object>} Records in source order for which the predicate is true;
125
+ * none for a where outside the language.
90
126
  */
91
127
  export function match(where, records) {
92
128
  if (!Array.isArray(records)) return []
93
129
  if (where == null) return records.slice()
94
- return records.filter((r) => evaluate(where, r))
130
+ if (whereOutsideLanguage(where)) return []
131
+ return records.filter((r) => test(where, r))
95
132
  }
96
133
 
97
134
  // ─── Internals ────────────────────────────────────────────────────
98
135
 
99
- function evaluateClause(key, value, record) {
100
- // Composition keys.
101
- if (key === 'and') {
102
- if (!Array.isArray(value)) return false
103
- return value.every((sub) => evaluate(sub, record))
104
- }
105
- if (key === 'or') {
106
- if (!Array.isArray(value)) return false
107
- return value.some((sub) => evaluate(sub, record))
136
+ function clauseProblem(key, value) {
137
+ if (key === 'and' || key === 'or') {
138
+ if (!Array.isArray(value) || value.length === 0) return `\`${key}\` takes a non-empty list of conditions`
139
+ for (const sub of value) {
140
+ const problem = sub && typeof sub === 'object' && !Array.isArray(sub)
141
+ ? whereOutsideLanguage(sub)
142
+ : `each item of \`${key}\` is an object of conditions`
143
+ if (problem) return problem
144
+ }
145
+ return null
108
146
  }
109
147
  if (key === 'not') {
110
- return !evaluate(value, record)
148
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return '`not` takes one object of conditions'
149
+ return whereOutsideLanguage(value)
111
150
  }
112
-
113
- // Field clause: key is a (possibly dotted) field name; value is either
114
- // a bare value (implicit eq) or an operator-object.
115
- const fieldValue = getPath(record, key)
116
-
117
- if (value === null) {
118
- return fieldValue === null || fieldValue === undefined
151
+ // A field clause: a bare value, null, or an operator object — under a key that is a
152
+ // field or a path the language admits (`field-path.js`).
153
+ const pathProblem = fieldPathProblem(key)
154
+ if (pathProblem) return pathProblem
155
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return null
156
+ const ops = Object.keys(value)
157
+ if (ops.length === 0) return `the condition on \`${key}\` is empty`
158
+ for (const op of ops) {
159
+ if (RETIRED_OPERATORS[op]) return RETIRED_OPERATORS[op]
160
+ if (!OPERATORS.has(op)) return `\`${op}\` (on \`${key}\`) is not an operator`
161
+ const arg = value[op]
162
+ if ((op === 'in' || op === 'not_in') && !Array.isArray(arg)) return `\`${op}\` takes a list of values`
163
+ if (op === 'exists' && typeof arg !== 'boolean') return '`exists` takes true or false'
164
+ if ((op === 'starts_with' || op === 'ends_with') && (typeof arg !== 'string' || arg === '')) {
165
+ return `\`${op}\` takes non-empty text`
166
+ }
167
+ if (op === 'contains' && arg === '') return '`contains` takes a value that is not empty text'
119
168
  }
169
+ return null
170
+ }
120
171
 
121
- if (typeof value === 'object' && !Array.isArray(value) && isOperatorObject(value)) {
122
- return evaluateOperatorObject(value, fieldValue)
172
+ function test(where, record) {
173
+ if (record == null || typeof record !== 'object') return false
174
+ for (const key of Object.keys(where)) {
175
+ if (!testClause(key, where[key], record)) return false
123
176
  }
124
-
125
- // Bare value (string, number, boolean, array): implicit equality.
126
- return matchEqual(fieldValue, value)
177
+ return true
127
178
  }
128
179
 
129
- function isOperatorObject(value) {
130
- if (value == null || typeof value !== 'object' || Array.isArray(value)) return false
131
- // An operator-object's keys are all in OPERATORS. If even one key isn't
132
- // an operator, it's not an operator-object — it might be a nested
133
- // sub-predicate or a structured equality target. The latter is rare;
134
- // we treat any object whose keys are all known operators as an
135
- // operator-object, otherwise fall back to deep-equality matching.
136
- const keys = Object.keys(value)
137
- if (keys.length === 0) return false
138
- return keys.every((k) => OPERATORS.has(k))
139
- }
180
+ function testClause(key, value, record) {
181
+ if (key === 'and') return value.every((sub) => test(sub, record))
182
+ if (key === 'or') return value.some((sub) => test(sub, record))
183
+ if (key === 'not') return !test(value, record)
140
184
 
141
- function evaluateOperatorObject(opObject, fieldValue) {
142
- for (const op of Object.keys(opObject)) {
143
- if (!evaluateOperator(op, opObject[op], fieldValue)) return false
185
+ const fieldValue = getPath(record, key)
186
+ if (value === null) return fieldValue === null || fieldValue === undefined
187
+ if (typeof value === 'object' && !Array.isArray(value)) {
188
+ for (const op of Object.keys(value)) {
189
+ if (!testOperator(op, value[op], fieldValue)) return false
190
+ }
191
+ return true
144
192
  }
145
- return true
193
+ // Bare value: implicit equality.
194
+ return equals(fieldValue, value)
146
195
  }
147
196
 
148
- function evaluateOperator(op, opValue, fieldValue) {
197
+ function testOperator(op, arg, fieldValue) {
149
198
  switch (op) {
150
199
  case 'eq':
151
- return matchEqual(fieldValue, opValue)
200
+ return equals(fieldValue, arg)
152
201
  case 'ne':
153
- return !matchEqual(fieldValue, opValue)
202
+ return !equals(fieldValue, arg)
154
203
  case 'gt':
155
- return compareCanRun(fieldValue, opValue) && fieldValue > opValue
204
+ return someMember(fieldValue, (v) => comparable(v, arg) && v > arg)
156
205
  case 'gte':
157
- return compareCanRun(fieldValue, opValue) && fieldValue >= opValue
206
+ return someMember(fieldValue, (v) => comparable(v, arg) && v >= arg)
158
207
  case 'lt':
159
- return compareCanRun(fieldValue, opValue) && fieldValue < opValue
208
+ return someMember(fieldValue, (v) => comparable(v, arg) && v < arg)
160
209
  case 'lte':
161
- return compareCanRun(fieldValue, opValue) && fieldValue <= opValue
210
+ return someMember(fieldValue, (v) => comparable(v, arg) && v <= arg)
162
211
  case 'in':
163
- if (!Array.isArray(opValue)) return false
164
- return opValue.some((v) => matchEqual(fieldValue, v))
165
- case 'nin':
166
- if (!Array.isArray(opValue)) return false
167
- return !opValue.some((v) => matchEqual(fieldValue, v))
168
- case 'like':
169
- if (typeof fieldValue !== 'string' || typeof opValue !== 'string') return false
170
- return globMatch(opValue, fieldValue)
212
+ return arg.some((a) => equals(fieldValue, a))
213
+ case 'not_in':
214
+ return !arg.some((a) => equals(fieldValue, a))
171
215
  case 'exists':
172
- return Boolean(fieldValue) === Boolean(opValue)
216
+ return hasValue(fieldValue) === arg
217
+ case 'contains':
218
+ if (Array.isArray(fieldValue)) return fieldValue.some((v) => v === arg)
219
+ if (typeof fieldValue === 'string' && typeof arg === 'string') {
220
+ return fieldValue.toLowerCase().includes(arg.toLowerCase())
221
+ }
222
+ return false
223
+ case 'starts_with':
224
+ return someMember(fieldValue, (v) => typeof v === 'string' && v.toLowerCase().startsWith(arg.toLowerCase()))
225
+ case 'ends_with':
226
+ return someMember(fieldValue, (v) => typeof v === 'string' && v.toLowerCase().endsWith(arg.toLowerCase()))
173
227
  default:
174
- // Unknown operator → fail closed.
175
228
  return false
176
229
  }
177
230
  }
178
231
 
179
- function matchEqual(a, b) {
180
- if (a === b) return true
181
- if (a == null || b == null) return false
182
- // Array-on-either-side: if `a` is an array (record's field), match if
183
- // any element equals b. This makes `tags: 'featured'` match a record
184
- // with `tags: ['featured', 'sale']`.
185
- if (Array.isArray(a) && !Array.isArray(b)) {
186
- return a.some((v) => v === b)
187
- }
188
- if (typeof a === 'object' || typeof b === 'object') {
189
- // No deep equality for objects in v1 — keep the surface narrow.
190
- return false
191
- }
192
- return false
232
+ /** Equality, typed; a list field equals a value when any member does. */
233
+ function equals(fieldValue, arg) {
234
+ if (Array.isArray(fieldValue)) return fieldValue.some((v) => v === arg)
235
+ return fieldValue === arg
236
+ }
237
+
238
+ /** A condition on a value, or on any member when the field holds a list. */
239
+ function someMember(fieldValue, predicate) {
240
+ if (Array.isArray(fieldValue)) return fieldValue.some(predicate)
241
+ return predicate(fieldValue)
193
242
  }
194
243
 
195
- function compareCanRun(a, b) {
244
+ /** Numbers with numbers, text with text (ISO dates compare as text); nothing else. */
245
+ function comparable(a, b) {
196
246
  if (a == null || b == null) return false
197
- // Numbers and ISO-date strings (which compare correctly with </>=) are fine.
198
- // Mixed types (string vs number) are a mismatch — return false rather
199
- // than coerce.
200
- return typeof a === typeof b
247
+ return typeof a === typeof b && typeof a !== 'object'
201
248
  }
202
249
 
250
+ /** Not missing, null, "" or []. `0` and `false` are values. */
251
+ function hasValue(v) {
252
+ if (v === undefined || v === null || v === '') return false
253
+ if (Array.isArray(v) && v.length === 0) return false
254
+ return true
255
+ }
256
+
257
+ /**
258
+ * The value at a (possibly dotted) path. A path that meets a list before it ends
259
+ * descends into each item, and returns the values it reached as one list.
260
+ */
203
261
  function getPath(record, path) {
204
262
  if (typeof path !== 'string') return undefined
205
263
  if (path.indexOf('.') === -1) return record[path]
206
- let cursor = record
264
+ let current = [record]
265
+ let throughList = false
207
266
  for (const segment of path.split('.')) {
208
- if (cursor == null || typeof cursor !== 'object') return undefined
209
- cursor = cursor[segment]
267
+ const next = []
268
+ for (const node of current) {
269
+ if (node == null || typeof node !== 'object') continue
270
+ if (Array.isArray(node)) {
271
+ throughList = true
272
+ for (const item of node) {
273
+ if (item != null && typeof item === 'object' && !Array.isArray(item)) next.push(item[segment])
274
+ }
275
+ } else {
276
+ next.push(node[segment])
277
+ }
278
+ }
279
+ current = next
210
280
  }
211
- return cursor
212
- }
213
-
214
- /**
215
- * Shell-glob match: `*` matches any run of characters, `?` matches one char.
216
- * Anchored — the pattern must match the whole string.
217
- */
218
- function globMatch(pattern, value) {
219
- // Translate to a RegExp with anchors. Escape regex metacharacters
220
- // except for our wildcards.
221
- const re = '^' + pattern
222
- .replace(/[.+^${}()|[\]\\]/g, '\\$&')
223
- .replace(/\*/g, '.*')
224
- .replace(/\?/g, '.')
225
- + '$'
226
- return new RegExp(re).test(value)
281
+ if (!throughList) return current[0]
282
+ const reached = []
283
+ for (const v of current) {
284
+ if (v === undefined) continue
285
+ if (Array.isArray(v)) reached.push(...v)
286
+ else reached.push(v)
287
+ }
288
+ return reached
227
289
  }