@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/package.json +4 -3
- package/src/data-paths.js +2 -2
- package/src/detail-url.js +60 -119
- package/src/entity-store.js +79 -142
- package/src/fetch-config.js +275 -58
- package/src/fetcher-dispatcher.js +3 -3
- package/src/field-path.js +31 -0
- package/src/index.js +2 -2
- package/src/layout-name.js +61 -0
- package/src/sort.js +96 -12
- package/src/website.js +34 -26
- package/src/where.js +189 -127
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: {
|
|
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
|
|
32
|
-
* ne
|
|
33
|
-
* gt/gte
|
|
34
|
-
* lt/lte
|
|
35
|
-
* in
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
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
|
|
43
|
-
* or
|
|
44
|
-
* not
|
|
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
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
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
|
-
|
|
77
|
+
import { fieldPathProblem } from './field-path.js'
|
|
78
|
+
|
|
61
79
|
const OPERATORS = new Set([
|
|
62
|
-
'eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'in', '
|
|
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 (
|
|
75
|
-
|
|
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
|
-
|
|
130
|
+
if (whereOutsideLanguage(where)) return []
|
|
131
|
+
return records.filter((r) => test(where, r))
|
|
95
132
|
}
|
|
96
133
|
|
|
97
134
|
// ─── Internals ────────────────────────────────────────────────────
|
|
98
135
|
|
|
99
|
-
function
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
148
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return '`not` takes one object of conditions'
|
|
149
|
+
return whereOutsideLanguage(value)
|
|
111
150
|
}
|
|
112
|
-
|
|
113
|
-
//
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
122
|
-
|
|
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
|
|
130
|
-
if (
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
193
|
+
// Bare value: implicit equality.
|
|
194
|
+
return equals(fieldValue, value)
|
|
146
195
|
}
|
|
147
196
|
|
|
148
|
-
function
|
|
197
|
+
function testOperator(op, arg, fieldValue) {
|
|
149
198
|
switch (op) {
|
|
150
199
|
case 'eq':
|
|
151
|
-
return
|
|
200
|
+
return equals(fieldValue, arg)
|
|
152
201
|
case 'ne':
|
|
153
|
-
return !
|
|
202
|
+
return !equals(fieldValue, arg)
|
|
154
203
|
case 'gt':
|
|
155
|
-
return
|
|
204
|
+
return someMember(fieldValue, (v) => comparable(v, arg) && v > arg)
|
|
156
205
|
case 'gte':
|
|
157
|
-
return
|
|
206
|
+
return someMember(fieldValue, (v) => comparable(v, arg) && v >= arg)
|
|
158
207
|
case 'lt':
|
|
159
|
-
return
|
|
208
|
+
return someMember(fieldValue, (v) => comparable(v, arg) && v < arg)
|
|
160
209
|
case 'lte':
|
|
161
|
-
return
|
|
210
|
+
return someMember(fieldValue, (v) => comparable(v, arg) && v <= arg)
|
|
162
211
|
case 'in':
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
if (
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
264
|
+
let current = [record]
|
|
265
|
+
let throughList = false
|
|
207
266
|
for (const segment of path.split('.')) {
|
|
208
|
-
|
|
209
|
-
|
|
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
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
}
|