@byline/core 6.9.0 → 6.10.1
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/dist/@types/query-predicate.d.ts +4 -2
- package/dist/@types/search-types.d.ts +34 -0
- package/dist/@types/search-types.js +2 -0
- package/dist/@types/search-types.test.node.d.ts +8 -0
- package/dist/@types/search-types.test.node.js +21 -0
- package/dist/query/parse-where.js +76 -53
- package/dist/query/parse-where.test.node.js +133 -6
- package/package.json +6 -6
|
@@ -7,8 +7,10 @@
|
|
|
7
7
|
*/
|
|
8
8
|
/**
|
|
9
9
|
* Comparison operators usable on a single field-level predicate value. The
|
|
10
|
-
* parser normalises a bare value (`'foo'`) to `{ $eq: 'foo' }
|
|
11
|
-
*
|
|
10
|
+
* parser normalises a bare value (`'foo'`) to `{ $eq: 'foo' }`. Every operator
|
|
11
|
+
* in an operator object applies and the conditions are ANDed, so
|
|
12
|
+
* `{ $gte: 10, $lte: 20 }` is a closed range. An empty object or an unknown
|
|
13
|
+
* operator is a validation error.
|
|
12
14
|
*/
|
|
13
15
|
export interface FilterOperators {
|
|
14
16
|
$eq?: string | number | boolean | null;
|
|
@@ -167,6 +167,24 @@ export interface SearchMatching {
|
|
|
167
167
|
}
|
|
168
168
|
/** Maximum UTF-16 length accepted for one full-text query. */
|
|
169
169
|
export declare const MAX_SEARCH_QUERY_LENGTH = 1024;
|
|
170
|
+
/** Maximum `ceil((end - start) / gap)` for one range-facet request. */
|
|
171
|
+
export declare const MAX_RANGE_FACET_BUCKETS = 200;
|
|
172
|
+
/** One numeric range-facet request over an integer `search.filters` field. */
|
|
173
|
+
export interface SearchRangeFacetRequest {
|
|
174
|
+
field: string;
|
|
175
|
+
/** Inclusive lower edge of the first bucket. */
|
|
176
|
+
start: number;
|
|
177
|
+
/** Exclusive upper edge of the last bucket. */
|
|
178
|
+
end: number;
|
|
179
|
+
/** Bucket width; must be finite and greater than zero. */
|
|
180
|
+
gap: number;
|
|
181
|
+
}
|
|
182
|
+
/** One range bucket: `[start, end)` and its match count. */
|
|
183
|
+
export interface SearchRangeFacetBucket {
|
|
184
|
+
start: number;
|
|
185
|
+
end: number;
|
|
186
|
+
count: number;
|
|
187
|
+
}
|
|
170
188
|
/**
|
|
171
189
|
* A search request. Either collection-scoped (`collectionPath`) for
|
|
172
190
|
* homogeneous results, or zone-scoped (`zone`) for heterogeneous
|
|
@@ -201,6 +219,8 @@ export interface SearchQuery {
|
|
|
201
219
|
where?: QueryPredicate;
|
|
202
220
|
/** Field names to compute facet buckets for (driver-capability gated). */
|
|
203
221
|
facets?: string[];
|
|
222
|
+
/** Integer range aggregates; requires `capabilities.rangeFacets: true`. */
|
|
223
|
+
rangeFacets?: SearchRangeFacetRequest[];
|
|
204
224
|
/** Restrict to a single content locale. */
|
|
205
225
|
locale?: string;
|
|
206
226
|
/**
|
|
@@ -254,6 +274,12 @@ export interface SearchResults {
|
|
|
254
274
|
* driver supports them.
|
|
255
275
|
*/
|
|
256
276
|
facets?: Record<string, SearchFacetBucket[]>;
|
|
277
|
+
/**
|
|
278
|
+
* Contiguous ascending buckets keyed by field, including zero counts.
|
|
279
|
+
* Cover exactly the requested `[start, end)`, truncating the final bucket.
|
|
280
|
+
* Counts respect the query, where, locale, status, and collection/zone scope.
|
|
281
|
+
*/
|
|
282
|
+
rangeFacets?: Record<string, SearchRangeFacetBucket[]>;
|
|
257
283
|
}
|
|
258
284
|
/**
|
|
259
285
|
* What a given driver can actually do. Lets consumers (the admin UI, the
|
|
@@ -266,6 +292,14 @@ export interface SearchResults {
|
|
|
266
292
|
export interface SearchCapabilities {
|
|
267
293
|
/** Faceted aggregation over indexed fields. */
|
|
268
294
|
facets: boolean;
|
|
295
|
+
/** Integer range aggregation. Omission means false; unsupported requests must fail. */
|
|
296
|
+
rangeFacets?: boolean;
|
|
297
|
+
/**
|
|
298
|
+
* Applies `SearchQuery.where`. Omission means false. Advertise-only: the
|
|
299
|
+
* client still forwards `where` when absent. Providers may support a
|
|
300
|
+
* documented subset, but must reject unsupported fields or operators.
|
|
301
|
+
*/
|
|
302
|
+
structuredFilters?: boolean;
|
|
269
303
|
/** Fuzzy / typo-tolerant matching (e.g. `pg_trgm`). */
|
|
270
304
|
typoTolerance: boolean;
|
|
271
305
|
/** Vector / semantic / hybrid retrieval. */
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
import { expect, it } from 'vitest';
|
|
9
|
+
import { MAX_RANGE_FACET_BUCKETS } from '../index.js';
|
|
10
|
+
it('exports the range-facet contract and bucket cap from the public core entry point', () => {
|
|
11
|
+
const request = { field: 'year', start: 2000, end: 2003, gap: 2 };
|
|
12
|
+
const buckets = [
|
|
13
|
+
{ start: 2000, end: 2002, count: 0 },
|
|
14
|
+
{ start: 2002, end: 2003, count: 1 },
|
|
15
|
+
];
|
|
16
|
+
const query = { query: '', rangeFacets: [request] };
|
|
17
|
+
const result = { hits: [], total: 1, rangeFacets: { year: buckets } };
|
|
18
|
+
expect(MAX_RANGE_FACET_BUCKETS).toBe(200);
|
|
19
|
+
expect(query.rangeFacets).toEqual([request]);
|
|
20
|
+
expect(result.rangeFacets?.year).toEqual(buckets);
|
|
21
|
+
});
|
|
@@ -134,9 +134,13 @@ async function validateSecurityPredicate(predicate, definition, ctx, allowEmpty
|
|
|
134
134
|
}
|
|
135
135
|
function validateDocumentColumnPredicate(value, collectionPath, column) {
|
|
136
136
|
validateSecurityOperator(value, collectionPath, column);
|
|
137
|
-
const parsed
|
|
138
|
-
|
|
139
|
-
|
|
137
|
+
for (const parsed of normaliseToOperators(value, collectionPath, column)) {
|
|
138
|
+
validateDocumentColumnOperator(parsed, collectionPath, column);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
function validateDocumentColumnOperator(parsed, collectionPath, column) {
|
|
142
|
+
if (!DOCUMENT_COLUMN_OPERATORS[column].has(parsed.operator)) {
|
|
143
|
+
throw invalidSecurityPredicate(collectionPath, `operator '${parsed.operator}' is not supported for '${column}'`);
|
|
140
144
|
}
|
|
141
145
|
const values = Array.isArray(parsed.value) ? parsed.value : [parsed.value];
|
|
142
146
|
if ((parsed.operator === '$in' || parsed.operator === '$nin') && !Array.isArray(parsed.value)) {
|
|
@@ -163,18 +167,20 @@ function validateSecurityOperator(value, collectionPath, fieldName) {
|
|
|
163
167
|
throw invalidSecurityPredicate(collectionPath, `invalid predicate for '${fieldName}'`);
|
|
164
168
|
}
|
|
165
169
|
const entries = Object.entries(value);
|
|
166
|
-
if (entries.length
|
|
167
|
-
throw invalidSecurityPredicate(collectionPath, `predicate for '${fieldName}' must contain
|
|
168
|
-
}
|
|
169
|
-
const [operator, operand] = entries[0] ?? [];
|
|
170
|
-
if ((operator === '$in' || operator === '$nin') && !Array.isArray(operand)) {
|
|
171
|
-
throw invalidSecurityPredicate(collectionPath, `${operator} for '${fieldName}' requires an array`);
|
|
172
|
-
}
|
|
173
|
-
if (operator === '$contains' && typeof operand !== 'string') {
|
|
174
|
-
throw invalidSecurityPredicate(collectionPath, `$contains for '${fieldName}' requires a string`);
|
|
170
|
+
if (entries.length === 0 || entries.some(([operator]) => !FILTER_OPERATORS.has(operator))) {
|
|
171
|
+
throw invalidSecurityPredicate(collectionPath, `predicate for '${fieldName}' must contain only supported operators`);
|
|
175
172
|
}
|
|
176
|
-
|
|
177
|
-
|
|
173
|
+
// Every operator in the object applies (ANDed), so every one is checked.
|
|
174
|
+
for (const [operator, operand] of entries) {
|
|
175
|
+
if ((operator === '$in' || operator === '$nin') && !Array.isArray(operand)) {
|
|
176
|
+
throw invalidSecurityPredicate(collectionPath, `${operator} for '${fieldName}' requires an array`);
|
|
177
|
+
}
|
|
178
|
+
if (operator === '$contains' && typeof operand !== 'string') {
|
|
179
|
+
throw invalidSecurityPredicate(collectionPath, `$contains for '${fieldName}' requires a string`);
|
|
180
|
+
}
|
|
181
|
+
if (operand === undefined) {
|
|
182
|
+
throw invalidSecurityPredicate(collectionPath, `operator for '${fieldName}' requires a value`);
|
|
183
|
+
}
|
|
178
184
|
}
|
|
179
185
|
}
|
|
180
186
|
function invalidSecurityPredicate(collectionPath, reason) {
|
|
@@ -241,8 +247,7 @@ async function parseWhereInternal(where, definition, ctx, { isNested, inCombinat
|
|
|
241
247
|
// belong at the top level of the where clause.
|
|
242
248
|
if (key === '$and' || key === '$or') {
|
|
243
249
|
if (!Array.isArray(rawValue)) {
|
|
244
|
-
|
|
245
|
-
continue;
|
|
250
|
+
throw invalidWhere(definition.path, `${key} must be an array of predicates`);
|
|
246
251
|
}
|
|
247
252
|
const childFilters = [];
|
|
248
253
|
for (const child of rawValue) {
|
|
@@ -311,9 +316,10 @@ async function parseWhereInternal(where, definition, ctx, { isNested, inCombinat
|
|
|
311
316
|
// text search has no sensible OR-composing form, and a per-target
|
|
312
317
|
// EXISTS over `store_text` is intentionally deferred.
|
|
313
318
|
if (key === 'status') {
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
319
|
+
// A bare string at the top level keeps the scalar `status` form. An
|
|
320
|
+
// operator object takes the `DocumentColumnFilter` form at any scope.
|
|
321
|
+
if (isNested || inCombinator || typeof rawValue !== 'string') {
|
|
322
|
+
for (const parsed of normaliseToOperators(rawValue, definition.path, key)) {
|
|
317
323
|
result.filters.push({
|
|
318
324
|
kind: 'docColumn',
|
|
319
325
|
column: 'status',
|
|
@@ -323,9 +329,7 @@ async function parseWhereInternal(where, definition, ctx, { isNested, inCombinat
|
|
|
323
329
|
}
|
|
324
330
|
continue;
|
|
325
331
|
}
|
|
326
|
-
|
|
327
|
-
result.status = rawValue;
|
|
328
|
-
}
|
|
332
|
+
result.status = rawValue;
|
|
329
333
|
continue;
|
|
330
334
|
}
|
|
331
335
|
if (key === 'query') {
|
|
@@ -343,9 +347,12 @@ async function parseWhereInternal(where, definition, ctx, { isNested, inCombinat
|
|
|
343
347
|
continue;
|
|
344
348
|
}
|
|
345
349
|
if (key === 'path') {
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
350
|
+
const operators = normaliseToOperators(rawValue, definition.path, key);
|
|
351
|
+
// `pathFilter` holds one operator. Several operators on a top-level
|
|
352
|
+
// `path` take the `DocumentColumnFilter` form instead, which the
|
|
353
|
+
// adapter resolves against the top-level scope just as well.
|
|
354
|
+
if (isNested || inCombinator || operators.length > 1) {
|
|
355
|
+
for (const parsed of operators) {
|
|
349
356
|
result.filters.push({
|
|
350
357
|
kind: 'docColumn',
|
|
351
358
|
column: 'path',
|
|
@@ -355,7 +362,7 @@ async function parseWhereInternal(where, definition, ctx, { isNested, inCombinat
|
|
|
355
362
|
}
|
|
356
363
|
continue;
|
|
357
364
|
}
|
|
358
|
-
const parsed =
|
|
365
|
+
const [parsed] = operators;
|
|
359
366
|
if (parsed) {
|
|
360
367
|
result.pathFilter = {
|
|
361
368
|
operator: parsed.operator,
|
|
@@ -373,8 +380,7 @@ async function parseWhereInternal(where, definition, ctx, { isNested, inCombinat
|
|
|
373
380
|
// don't `String(...)` it the way status/path do, because the headline
|
|
374
381
|
// use case is `$in` / `$nin` carrying an array of document ids.
|
|
375
382
|
if (key === 'id') {
|
|
376
|
-
const parsed
|
|
377
|
-
if (parsed) {
|
|
383
|
+
for (const parsed of normaliseToOperators(rawValue, definition.path, key)) {
|
|
378
384
|
result.filters.push({
|
|
379
385
|
kind: 'docColumn',
|
|
380
386
|
column: 'id',
|
|
@@ -453,17 +459,16 @@ async function parseWhereInternal(where, definition, ctx, { isNested, inCombinat
|
|
|
453
459
|
const storeInfo = fieldTypeToStore[field.type];
|
|
454
460
|
if (!storeInfo)
|
|
455
461
|
continue; // Structure fields can't be filtered directly
|
|
456
|
-
const parsed
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
});
|
|
462
|
+
for (const parsed of normaliseToOperators(rawValue, definition.path, key)) {
|
|
463
|
+
result.filters.push({
|
|
464
|
+
kind: 'field',
|
|
465
|
+
fieldName: key,
|
|
466
|
+
storeType: storeInfo.storeType,
|
|
467
|
+
valueColumn: storeInfo.valueColumn,
|
|
468
|
+
operator: parsed.operator,
|
|
469
|
+
value: parsed.value,
|
|
470
|
+
});
|
|
471
|
+
}
|
|
467
472
|
}
|
|
468
473
|
return result;
|
|
469
474
|
}
|
|
@@ -555,7 +560,7 @@ function isPlainSubWhere(raw) {
|
|
|
555
560
|
return false;
|
|
556
561
|
const keys = Object.keys(raw);
|
|
557
562
|
// An empty object is not a meaningful sub-where; treat as non-match
|
|
558
|
-
// (ordinary field-filter path will then reject it via
|
|
563
|
+
// (ordinary field-filter path will then reject it via normaliseToOperators).
|
|
559
564
|
if (keys.length === 0)
|
|
560
565
|
return false;
|
|
561
566
|
for (const k of keys) {
|
|
@@ -565,28 +570,46 @@ function isPlainSubWhere(raw) {
|
|
|
565
570
|
return true;
|
|
566
571
|
}
|
|
567
572
|
/**
|
|
568
|
-
* Normalise a WhereValue (bare value or operator object) into a
|
|
569
|
-
* operator + value
|
|
573
|
+
* Normalise a WhereValue (bare value or operator object) into a list of
|
|
574
|
+
* operator + value pairs. Every operator in an operator object applies, so
|
|
575
|
+
* `{ $gte: 10, $lte: 20 }` yields two pairs that the caller ANDs together.
|
|
576
|
+
* Operators whose value is `undefined` are skipped, matching how the parser
|
|
577
|
+
* skips `undefined` keys. An object with no operators left, or with an
|
|
578
|
+
* unknown operator, is a validation error rather than a dropped filter.
|
|
570
579
|
*/
|
|
571
|
-
function
|
|
580
|
+
function normaliseToOperators(raw, collectionPath, key) {
|
|
572
581
|
// Bare value → $eq
|
|
573
582
|
if (raw === null ||
|
|
574
583
|
typeof raw === 'string' ||
|
|
575
584
|
typeof raw === 'number' ||
|
|
576
585
|
typeof raw === 'boolean') {
|
|
577
|
-
return { operator: '$eq', value: raw };
|
|
586
|
+
return [{ operator: '$eq', value: raw }];
|
|
578
587
|
}
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
const [op
|
|
586
|
-
|
|
588
|
+
if (typeof raw !== 'object' || Array.isArray(raw)) {
|
|
589
|
+
throw invalidWhere(collectionPath, `invalid value for '${key}'`);
|
|
590
|
+
}
|
|
591
|
+
// Operator object. Names are checked before `undefined` operands are
|
|
592
|
+
// skipped, so an unknown operator throws even when its value is missing.
|
|
593
|
+
const entries = Object.entries(raw);
|
|
594
|
+
for (const [op] of entries) {
|
|
595
|
+
if (!FILTER_OPERATORS.has(op)) {
|
|
596
|
+
throw invalidWhere(collectionPath, `unknown operator '${op}' for '${key}'`);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
const operatorEntries = entries.filter(([, val]) => val !== undefined);
|
|
600
|
+
if (operatorEntries.length === 0) {
|
|
601
|
+
throw invalidWhere(collectionPath, `operator object for '${key}' is empty`);
|
|
602
|
+
}
|
|
603
|
+
return operatorEntries.map(([op, val]) => ({
|
|
587
604
|
operator: op,
|
|
588
605
|
value: val,
|
|
589
|
-
};
|
|
606
|
+
}));
|
|
607
|
+
}
|
|
608
|
+
function invalidWhere(collectionPath, reason) {
|
|
609
|
+
return ERR_VALIDATION({
|
|
610
|
+
message: `invalid where clause for '${collectionPath}': ${reason}`,
|
|
611
|
+
details: { collectionPath, reason },
|
|
612
|
+
});
|
|
590
613
|
}
|
|
591
614
|
function stringifyDocumentColumnValue(value) {
|
|
592
615
|
if (value === null)
|
|
@@ -522,12 +522,10 @@ describe('parseWhere — combinators', () => {
|
|
|
522
522
|
// landmine if compiled to SQL.
|
|
523
523
|
expect(result.filters).toEqual([]);
|
|
524
524
|
});
|
|
525
|
-
it('
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
}, testCollection);
|
|
530
|
-
expect(result.filters).toEqual([]);
|
|
525
|
+
it.each(['$and', '$or'])('rejects a non-array %s value', async (key) => {
|
|
526
|
+
await expect(parseWhere(
|
|
527
|
+
// biome-ignore lint/suspicious/noExplicitAny: deliberately malformed input
|
|
528
|
+
{ [key]: { title: 'Hello' } }, testCollection)).rejects.toThrow(`${key} must be an array of predicates`);
|
|
531
529
|
});
|
|
532
530
|
it('nests $or inside $and correctly', async () => {
|
|
533
531
|
const result = await parseWhere({
|
|
@@ -867,3 +865,132 @@ describe('parseWhere — relation quantifiers', () => {
|
|
|
867
865
|
expect(result.filters).toHaveLength(0);
|
|
868
866
|
});
|
|
869
867
|
});
|
|
868
|
+
// ---------------------------------------------------------------------------
|
|
869
|
+
// Multi-operator objects
|
|
870
|
+
// ---------------------------------------------------------------------------
|
|
871
|
+
describe('parseWhere — multi-operator objects', () => {
|
|
872
|
+
const views = (operator, value) => ({
|
|
873
|
+
kind: 'field',
|
|
874
|
+
fieldName: 'views',
|
|
875
|
+
storeType: 'numeric',
|
|
876
|
+
valueColumn: 'value_integer',
|
|
877
|
+
operator,
|
|
878
|
+
value,
|
|
879
|
+
});
|
|
880
|
+
it('emits one filter per operator in a closed range', async () => {
|
|
881
|
+
const result = await parseWhere({ views: { $gte: 10, $lte: 20 } }, testCollection);
|
|
882
|
+
expect(result.filters).toEqual([views('$gte', 10), views('$lte', 20)]);
|
|
883
|
+
});
|
|
884
|
+
it('keeps every operator of the downstream date-range shape', async () => {
|
|
885
|
+
const result = await parseWhere({ views: { $gt: 0, $gte: 20200000, $lte: 20201231 } }, testCollection);
|
|
886
|
+
expect(result.filters).toEqual([
|
|
887
|
+
views('$gt', 0),
|
|
888
|
+
views('$gte', 20200000),
|
|
889
|
+
views('$lte', 20201231),
|
|
890
|
+
]);
|
|
891
|
+
});
|
|
892
|
+
it('combines $eq with a range operator', async () => {
|
|
893
|
+
const result = await parseWhere({ views: { $eq: 42, $lt: 100 } }, testCollection);
|
|
894
|
+
expect(result.filters).toEqual([views('$eq', 42), views('$lt', 100)]);
|
|
895
|
+
});
|
|
896
|
+
it('skips operators whose value is undefined', async () => {
|
|
897
|
+
const result = await parseWhere({ views: { $gte: undefined, $lte: 20 } }, testCollection);
|
|
898
|
+
expect(result.filters).toEqual([views('$lte', 20)]);
|
|
899
|
+
});
|
|
900
|
+
it('keeps the $and workaround shape working', async () => {
|
|
901
|
+
const result = await parseWhere({ $and: [{ views: { $gt: 0 } }, { views: { $gte: 10 } }, { views: { $lte: 20 } }] }, testCollection);
|
|
902
|
+
expect(result.filters).toEqual([views('$gt', 0), views('$gte', 10), views('$lte', 20)]);
|
|
903
|
+
});
|
|
904
|
+
it('emits every operator on a document column', async () => {
|
|
905
|
+
const result = await parseWhere({ $or: [{ status: { $ne: 'archived', $in: ['draft', 'published'] } }] }, testCollection);
|
|
906
|
+
expect(result.filters).toEqual([
|
|
907
|
+
{
|
|
908
|
+
kind: 'or',
|
|
909
|
+
children: [
|
|
910
|
+
{
|
|
911
|
+
kind: 'and',
|
|
912
|
+
children: [
|
|
913
|
+
{ kind: 'docColumn', column: 'status', operator: '$ne', value: 'archived' },
|
|
914
|
+
{
|
|
915
|
+
kind: 'docColumn',
|
|
916
|
+
column: 'status',
|
|
917
|
+
operator: '$in',
|
|
918
|
+
value: ['draft', 'published'],
|
|
919
|
+
},
|
|
920
|
+
],
|
|
921
|
+
},
|
|
922
|
+
],
|
|
923
|
+
},
|
|
924
|
+
]);
|
|
925
|
+
});
|
|
926
|
+
it('compiles a top-level status operator object instead of dropping it', async () => {
|
|
927
|
+
const scalar = await parseWhere({ status: 'draft' }, testCollection);
|
|
928
|
+
expect(scalar.status).toBe('draft');
|
|
929
|
+
expect(scalar.filters).toEqual([]);
|
|
930
|
+
const result = await parseWhere({ status: { $in: ['draft', 'published'] } }, testCollection);
|
|
931
|
+
expect(result.status).toBeUndefined();
|
|
932
|
+
expect(result.filters).toEqual([
|
|
933
|
+
{ kind: 'docColumn', column: 'status', operator: '$in', value: ['draft', 'published'] },
|
|
934
|
+
]);
|
|
935
|
+
});
|
|
936
|
+
it('moves a multi-operator top-level path to document-column filters', async () => {
|
|
937
|
+
const single = await parseWhere({ path: { $contains: 'docs' } }, testCollection);
|
|
938
|
+
expect(single.pathFilter).toEqual({ operator: '$contains', value: 'docs' });
|
|
939
|
+
const multi = await parseWhere({ path: { $contains: 'docs', $ne: 'docs/old' } }, testCollection);
|
|
940
|
+
expect(multi.pathFilter).toBeUndefined();
|
|
941
|
+
expect(multi.filters).toEqual([
|
|
942
|
+
{ kind: 'docColumn', column: 'path', operator: '$contains', value: 'docs' },
|
|
943
|
+
{ kind: 'docColumn', column: 'path', operator: '$ne', value: 'docs/old' },
|
|
944
|
+
]);
|
|
945
|
+
});
|
|
946
|
+
it('emits every operator inside a relation sub-where', async () => {
|
|
947
|
+
const result = await parseWhere({ category: { slug: { $contains: 'new', $ne: 'news-old' } } }, testCollection, ctx);
|
|
948
|
+
expect(result.filters[0]).toMatchObject({
|
|
949
|
+
kind: 'relation',
|
|
950
|
+
fieldName: 'category',
|
|
951
|
+
nested: [
|
|
952
|
+
expect.objectContaining({ fieldName: 'slug', operator: '$contains', value: 'new' }),
|
|
953
|
+
expect.objectContaining({ fieldName: 'slug', operator: '$ne', value: 'news-old' }),
|
|
954
|
+
],
|
|
955
|
+
});
|
|
956
|
+
});
|
|
957
|
+
it('emits every operator inside a quantified relation filter', async () => {
|
|
958
|
+
const result = await parseWhere({ tags: { $every: { slug: { $contains: 'a', $ne: 'b' } } } }, testCollection, ctx);
|
|
959
|
+
expect(result.filters[0]).toMatchObject({
|
|
960
|
+
kind: 'relation',
|
|
961
|
+
quantifier: 'every',
|
|
962
|
+
nested: [
|
|
963
|
+
expect.objectContaining({ operator: '$contains', value: 'a' }),
|
|
964
|
+
expect.objectContaining({ operator: '$ne', value: 'b' }),
|
|
965
|
+
],
|
|
966
|
+
});
|
|
967
|
+
});
|
|
968
|
+
it.each([
|
|
969
|
+
[{ views: {} }, "operator object for 'views' is empty"],
|
|
970
|
+
[{ views: { $gte: undefined } }, "operator object for 'views' is empty"],
|
|
971
|
+
[{ views: { $gte: 10, $bogus: 1 } }, "unknown operator '$bogus' for 'views'"],
|
|
972
|
+
[{ views: { $gte: 10, $bogus: undefined } }, "unknown operator '$bogus' for 'views'"],
|
|
973
|
+
[{ status: { $wat: 'x' } }, "unknown operator '$wat' for 'status'"],
|
|
974
|
+
[{ id: {} }, "operator object for 'id' is empty"],
|
|
975
|
+
[{ views: [1, 2] }, "invalid value for 'views'"],
|
|
976
|
+
[{ category: {} }, "operator object for 'category' is empty"],
|
|
977
|
+
])('rejects %o', async (where, message) => {
|
|
978
|
+
await expect(parseWhere(where, testCollection, ctx)).rejects.toThrow(message);
|
|
979
|
+
});
|
|
980
|
+
it('accepts multi-operator objects in strict security predicates', async () => {
|
|
981
|
+
const filters = await parsePredicateFilters({ views: { $gte: 10, $lte: 20 }, status: { $ne: 'archived', $in: ['published'] } }, testCollection, ctx, { strict: true });
|
|
982
|
+
expect(filters).toEqual([
|
|
983
|
+
views('$gte', 10),
|
|
984
|
+
views('$lte', 20),
|
|
985
|
+
{ kind: 'docColumn', column: 'status', operator: '$ne', value: 'archived' },
|
|
986
|
+
{ kind: 'docColumn', column: 'status', operator: '$in', value: ['published'] },
|
|
987
|
+
]);
|
|
988
|
+
});
|
|
989
|
+
it.each([
|
|
990
|
+
[{ views: { $gte: 10, $wat: 1 } }, 'supported operators'],
|
|
991
|
+
[{ status: { $eq: 'published', $contains: 'pub' } }, "'$contains' is not supported"],
|
|
992
|
+
[{ status: { $ne: 'archived', $in: 'published' } }, 'requires an array'],
|
|
993
|
+
])('validates every operator of strict predicate %o', async (predicate, message) => {
|
|
994
|
+
await expect(parsePredicateFilters(predicate, testCollection, ctx, { strict: true })).rejects.toThrow(message);
|
|
995
|
+
});
|
|
996
|
+
});
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@byline/core",
|
|
3
3
|
"private": false,
|
|
4
4
|
"license": "MPL-2.0",
|
|
5
|
-
"version": "6.
|
|
5
|
+
"version": "6.10.1",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": ">=22.22.2"
|
|
8
8
|
},
|
|
@@ -82,7 +82,7 @@
|
|
|
82
82
|
"dist"
|
|
83
83
|
],
|
|
84
84
|
"dependencies": {
|
|
85
|
-
"@byline/auth": "6.
|
|
85
|
+
"@byline/auth": "6.10.1",
|
|
86
86
|
"dotenv": "^18.0.8",
|
|
87
87
|
"pino": "^10.4.0",
|
|
88
88
|
"sharp": "^0.35.5",
|
|
@@ -90,15 +90,15 @@
|
|
|
90
90
|
"zod": "^4.6.7"
|
|
91
91
|
},
|
|
92
92
|
"devDependencies": {
|
|
93
|
-
"@biomejs/biome": "2.
|
|
93
|
+
"@biomejs/biome": "2.6.2",
|
|
94
94
|
"@types/node": "^26.6.5",
|
|
95
|
-
"chokidar": "^
|
|
95
|
+
"chokidar": "^6.0.0",
|
|
96
96
|
"chokidar-cli": "^3.0.0",
|
|
97
97
|
"npm-run-all": "^4.1.5",
|
|
98
|
-
"tsc-alias": "^1.9.
|
|
98
|
+
"tsc-alias": "^1.9.8",
|
|
99
99
|
"tsx": "^4.23.15",
|
|
100
100
|
"typescript": "^7.0.2",
|
|
101
|
-
"vitest": "^5.0.
|
|
101
|
+
"vitest": "^5.0.5"
|
|
102
102
|
},
|
|
103
103
|
"publishConfig": {
|
|
104
104
|
"access": "public",
|