@uniweb/core 0.6.1 → 0.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/src/where.js ADDED
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Where-object evaluator.
3
+ *
4
+ * A where-object is a structured JSON predicate. The format is small,
5
+ * additive, and YAML/JSON-native — there is no DSL or parser. The same
6
+ * predicate travels from author YAML, through transports, to backends
7
+ * (which translate to their native query language) or to this evaluator
8
+ * (which walks the object against a record).
9
+ *
10
+ * Architecture: see kb/framework/architecture/data-fetching.md.
11
+ *
12
+ * Shape:
13
+ *
14
+ * {
15
+ * // Top-level keys are field names; values are the values to match.
16
+ * // Implicit AND across keys.
17
+ * department: 'biology',
18
+ * tenured: true,
19
+ *
20
+ * // For non-equality, the value is an operator object.
21
+ * start_year: { gte: 2010 },
22
+ * rank: { in: ['associate', 'full'] },
23
+ * title: { like: 'Origin*' },
24
+ *
25
+ * // Explicit composition keys at any nesting level.
26
+ * and: [{ tenured: true }, { rank: 'full' }],
27
+ * or: [{ rank: 'full' }, { years_in_role: { gte: 10 } }],
28
+ * not: { department: 'emeritus' },
29
+ * }
30
+ *
31
+ * Operators (in operator-object form):
32
+ *
33
+ * eq Equal (also implicit when the value is bare, non-object, non-null).
34
+ * ne Not equal.
35
+ * gt/gte Greater than / greater than or equal.
36
+ * lt/lte Less than / less than or equal.
37
+ * in Value is in the listed array.
38
+ * nin Value is not in the listed array.
39
+ * like Glob match (`*` any run, `?` one char). String fields only.
40
+ * exists Field is truthy (boolean toggle).
41
+ *
42
+ * Composition keys:
43
+ *
44
+ * and Array of sub-predicates; all must match.
45
+ * or Array of sub-predicates; at least one must match.
46
+ * not Single sub-predicate; must not match.
47
+ *
48
+ * Dotted paths descend into nested objects: `tenure.start: { gte: 2015 }`.
49
+ *
50
+ * Type safety: type mismatches return `false` rather than throwing
51
+ * (e.g., comparing a string to a number with `gt`). Missing fields
52
+ * return `false` for equality and most operators; `exists: false` matches
53
+ * missing/falsy fields.
54
+ */
55
+
56
+ const COMPOSITION_KEYS = new Set(['and', 'or', 'not'])
57
+ const OPERATORS = new Set([
58
+ 'eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'in', 'nin', 'like', 'exists',
59
+ ])
60
+
61
+ /**
62
+ * Evaluate a where-object against a single record.
63
+ *
64
+ * @param {Object} where - The where-object predicate.
65
+ * @param {Object} record - The record to test.
66
+ * @returns {boolean} true if the record matches.
67
+ */
68
+ export function evaluate(where, record) {
69
+ if (where == null) return true
70
+ if (typeof where !== 'object' || Array.isArray(where)) return false
71
+ if (record == null || typeof record !== 'object') return false
72
+
73
+ // Implicit AND across all top-level keys.
74
+ for (const key of Object.keys(where)) {
75
+ if (!evaluateClause(key, where[key], record)) return false
76
+ }
77
+ return true
78
+ }
79
+
80
+ /**
81
+ * Filter an array of records by a where-object predicate.
82
+ *
83
+ * @param {Object} where - The where-object predicate.
84
+ * @param {Array<Object>} records - The records to filter.
85
+ * @returns {Array<Object>} Records in source order for which the predicate is true.
86
+ */
87
+ export function match(where, records) {
88
+ if (!Array.isArray(records)) return []
89
+ if (where == null) return records.slice()
90
+ return records.filter((r) => evaluate(where, r))
91
+ }
92
+
93
+ // ─── Internals ────────────────────────────────────────────────────
94
+
95
+ function evaluateClause(key, value, record) {
96
+ // Composition keys.
97
+ if (key === 'and') {
98
+ if (!Array.isArray(value)) return false
99
+ return value.every((sub) => evaluate(sub, record))
100
+ }
101
+ if (key === 'or') {
102
+ if (!Array.isArray(value)) return false
103
+ return value.some((sub) => evaluate(sub, record))
104
+ }
105
+ if (key === 'not') {
106
+ return !evaluate(value, record)
107
+ }
108
+
109
+ // Field clause: key is a (possibly dotted) field name; value is either
110
+ // a bare value (implicit eq) or an operator-object.
111
+ const fieldValue = getPath(record, key)
112
+
113
+ if (value === null) {
114
+ return fieldValue === null || fieldValue === undefined
115
+ }
116
+
117
+ if (typeof value === 'object' && !Array.isArray(value) && isOperatorObject(value)) {
118
+ return evaluateOperatorObject(value, fieldValue)
119
+ }
120
+
121
+ // Bare value (string, number, boolean, array): implicit equality.
122
+ return matchEqual(fieldValue, value)
123
+ }
124
+
125
+ function isOperatorObject(value) {
126
+ if (value == null || typeof value !== 'object' || Array.isArray(value)) return false
127
+ // An operator-object's keys are all in OPERATORS. If even one key isn't
128
+ // an operator, it's not an operator-object — it might be a nested
129
+ // sub-predicate or a structured equality target. The latter is rare;
130
+ // we treat any object whose keys are all known operators as an
131
+ // operator-object, otherwise fall back to deep-equality matching.
132
+ const keys = Object.keys(value)
133
+ if (keys.length === 0) return false
134
+ return keys.every((k) => OPERATORS.has(k))
135
+ }
136
+
137
+ function evaluateOperatorObject(opObject, fieldValue) {
138
+ for (const op of Object.keys(opObject)) {
139
+ if (!evaluateOperator(op, opObject[op], fieldValue)) return false
140
+ }
141
+ return true
142
+ }
143
+
144
+ function evaluateOperator(op, opValue, fieldValue) {
145
+ switch (op) {
146
+ case 'eq':
147
+ return matchEqual(fieldValue, opValue)
148
+ case 'ne':
149
+ return !matchEqual(fieldValue, opValue)
150
+ case 'gt':
151
+ return compareCanRun(fieldValue, opValue) && fieldValue > opValue
152
+ case 'gte':
153
+ return compareCanRun(fieldValue, opValue) && fieldValue >= opValue
154
+ case 'lt':
155
+ return compareCanRun(fieldValue, opValue) && fieldValue < opValue
156
+ case 'lte':
157
+ return compareCanRun(fieldValue, opValue) && fieldValue <= opValue
158
+ case 'in':
159
+ if (!Array.isArray(opValue)) return false
160
+ return opValue.some((v) => matchEqual(fieldValue, v))
161
+ case 'nin':
162
+ if (!Array.isArray(opValue)) return false
163
+ return !opValue.some((v) => matchEqual(fieldValue, v))
164
+ case 'like':
165
+ if (typeof fieldValue !== 'string' || typeof opValue !== 'string') return false
166
+ return globMatch(opValue, fieldValue)
167
+ case 'exists':
168
+ return Boolean(fieldValue) === Boolean(opValue)
169
+ default:
170
+ // Unknown operator → fail closed.
171
+ return false
172
+ }
173
+ }
174
+
175
+ function matchEqual(a, b) {
176
+ if (a === b) return true
177
+ if (a == null || b == null) return false
178
+ // Array-on-either-side: if `a` is an array (record's field), match if
179
+ // any element equals b. This makes `tags: 'featured'` match a record
180
+ // with `tags: ['featured', 'sale']`.
181
+ if (Array.isArray(a) && !Array.isArray(b)) {
182
+ return a.some((v) => v === b)
183
+ }
184
+ if (typeof a === 'object' || typeof b === 'object') {
185
+ // No deep equality for objects in v1 — keep the surface narrow.
186
+ return false
187
+ }
188
+ return false
189
+ }
190
+
191
+ function compareCanRun(a, b) {
192
+ if (a == null || b == null) return false
193
+ // Numbers and ISO-date strings (which compare correctly with </>=) are fine.
194
+ // Mixed types (string vs number) are a mismatch — return false rather
195
+ // than coerce.
196
+ return typeof a === typeof b
197
+ }
198
+
199
+ function getPath(record, path) {
200
+ if (typeof path !== 'string') return undefined
201
+ if (path.indexOf('.') === -1) return record[path]
202
+ let cursor = record
203
+ for (const segment of path.split('.')) {
204
+ if (cursor == null || typeof cursor !== 'object') return undefined
205
+ cursor = cursor[segment]
206
+ }
207
+ return cursor
208
+ }
209
+
210
+ /**
211
+ * Shell-glob match: `*` matches any run of characters, `?` matches one char.
212
+ * Anchored — the pattern must match the whole string.
213
+ */
214
+ function globMatch(pattern, value) {
215
+ // Translate to a RegExp with anchors. Escape regex metacharacters
216
+ // except for our wildcards.
217
+ const re = '^' + pattern
218
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
219
+ .replace(/\*/g, '.*')
220
+ .replace(/\?/g, '.')
221
+ + '$'
222
+ return new RegExp(re).test(value)
223
+ }