@byline/core 6.10.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.
|
@@ -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;
|
|
@@ -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.10.
|
|
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.10.
|
|
85
|
+
"@byline/auth": "6.10.1",
|
|
86
86
|
"dotenv": "^18.0.8",
|
|
87
87
|
"pino": "^10.4.0",
|
|
88
88
|
"sharp": "^0.35.5",
|