@byline/core 1.3.0 → 1.4.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.
|
@@ -75,6 +75,13 @@ declare const DOCUMENT_SORT_COLUMNS: Record<string, string>;
|
|
|
75
75
|
* are resolved into `RelationFilter` entries; otherwise only
|
|
76
76
|
* direct/operator predicates against the relation's own
|
|
77
77
|
* `target_document_id` are emitted.
|
|
78
|
+
*
|
|
79
|
+
* Reserved-key rules inside a nested sub-clause: `status` and `path`
|
|
80
|
+
* downshift to `DocumentColumnFilter` entries against the target
|
|
81
|
+
* version's `document_versions` columns (the adapter wires these to
|
|
82
|
+
* `td${depth}.status` / `td${depth}.path` via the inner relation scope);
|
|
83
|
+
* `query` is dropped with a debug log because text search has no
|
|
84
|
+
* sensible composition through a relation hop.
|
|
78
85
|
*/
|
|
79
86
|
export declare function parseWhere(where: WhereClause | undefined, definition: CollectionDefinition, ctx?: ParseContext): Promise<ParsedWhere>;
|
|
80
87
|
/**
|
|
@@ -31,6 +31,13 @@ const DOCUMENT_SORT_COLUMNS = {
|
|
|
31
31
|
* are resolved into `RelationFilter` entries; otherwise only
|
|
32
32
|
* direct/operator predicates against the relation's own
|
|
33
33
|
* `target_document_id` are emitted.
|
|
34
|
+
*
|
|
35
|
+
* Reserved-key rules inside a nested sub-clause: `status` and `path`
|
|
36
|
+
* downshift to `DocumentColumnFilter` entries against the target
|
|
37
|
+
* version's `document_versions` columns (the adapter wires these to
|
|
38
|
+
* `td${depth}.status` / `td${depth}.path` via the inner relation scope);
|
|
39
|
+
* `query` is dropped with a debug log because text search has no
|
|
40
|
+
* sensible composition through a relation hop.
|
|
34
41
|
*/
|
|
35
42
|
export async function parseWhere(where, definition, ctx) {
|
|
36
43
|
return parseWhereInternal(where, definition, ctx, { isNested: false, inCombinator: false });
|
|
@@ -57,10 +64,15 @@ export function mergePredicates(hookPredicate, userWhere) {
|
|
|
57
64
|
return { $and: [hookPredicate, userWhere] };
|
|
58
65
|
}
|
|
59
66
|
/**
|
|
60
|
-
* Recursion entry point. `isNested: true`
|
|
61
|
-
* keys
|
|
62
|
-
*
|
|
63
|
-
*
|
|
67
|
+
* Recursion entry point. `isNested: true` rewires the document-level
|
|
68
|
+
* reserved keys: `status` / `path` downshift to `DocumentColumnFilter`
|
|
69
|
+
* entries against the target version's columns (the adapter resolves
|
|
70
|
+
* those via the inner relation scope, e.g. `td${depth}.status`); `query`
|
|
71
|
+
* is dropped with a debug log because text search has no sensible
|
|
72
|
+
* composition through a relation hop. Reserved keys still take precedence
|
|
73
|
+
* over field lookups — a target collection that happens to declare a
|
|
74
|
+
* `path` or `status` field will not see those clauses resolve as field
|
|
75
|
+
* filters; rename the offending field if that conflict matters.
|
|
64
76
|
*/
|
|
65
77
|
async function parseWhereInternal(where, definition, ctx, { isNested, inCombinator }) {
|
|
66
78
|
const result = { filters: [] };
|
|
@@ -134,65 +146,82 @@ async function parseWhereInternal(where, definition, ctx, { isNested, inCombinat
|
|
|
134
146
|
continue;
|
|
135
147
|
}
|
|
136
148
|
// --- Document-level reserved keys --------------------------------------
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
149
|
+
// `status` / `path` / `query` are reserved on every collection — they
|
|
150
|
+
// never resolve to a field of the same name (no field-shadow exception),
|
|
151
|
+
// mirroring the consumer-intuitive rule that these names refer to the
|
|
152
|
+
// document version's metadata columns.
|
|
153
|
+
//
|
|
154
|
+
// Where they compile depends on scope:
|
|
155
|
+
//
|
|
156
|
+
// • Top level (not nested in a relation, not inside a combinator):
|
|
157
|
+
// map to direct adapter scalar parameters
|
|
158
|
+
// (`ParsedWhere.status` / `query` / `pathFilter`) which compile to
|
|
159
|
+
// the outermost WHERE clause.
|
|
160
|
+
//
|
|
161
|
+
// • Inside a combinator, OR inside a relation sub-clause: the scalar-
|
|
162
|
+
// parameter mapping no longer composes (the predicate needs to OR
|
|
163
|
+
// with siblings, or anchor against the *target's* doc version), so
|
|
164
|
+
// `status` / `path` downshift to a `DocumentColumnFilter`. The SQL
|
|
165
|
+
// compiler reads `outerScope.status` / `outerScope.path`, which the
|
|
166
|
+
// adapter rewires per-scope (`d.path` at the top level,
|
|
167
|
+
// `td${depth}.path` inside a relation hop).
|
|
168
|
+
//
|
|
169
|
+
// • `query` is dropped with a debug log in both downshift cases —
|
|
170
|
+
// text search has no sensible OR-composing form, and a per-target
|
|
171
|
+
// EXISTS over `store_text` is intentionally deferred.
|
|
172
|
+
if (key === 'status') {
|
|
173
|
+
if (isNested || inCombinator) {
|
|
174
|
+
const parsed = normaliseToOperator(rawValue);
|
|
175
|
+
if (parsed) {
|
|
176
|
+
result.filters.push({
|
|
177
|
+
kind: 'docColumn',
|
|
178
|
+
column: 'status',
|
|
179
|
+
operator: parsed.operator,
|
|
180
|
+
value: parsed.value === null ? null : String(parsed.value),
|
|
181
|
+
});
|
|
161
182
|
}
|
|
162
183
|
continue;
|
|
163
184
|
}
|
|
164
|
-
if (
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
}
|
|
185
|
+
if (typeof rawValue === 'string') {
|
|
186
|
+
result.status = rawValue;
|
|
187
|
+
}
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (key === 'query') {
|
|
191
|
+
if (isNested) {
|
|
192
|
+
ctx?.logger?.debug({ collection: definition.path }, 'parse-where: dropping `query` inside a relation sub-clause — text search does not compose through a relation hop');
|
|
172
193
|
continue;
|
|
173
194
|
}
|
|
174
|
-
if (
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
continue;
|
|
186
|
-
}
|
|
195
|
+
if (inCombinator) {
|
|
196
|
+
ctx?.logger?.debug({ collection: definition.path }, 'parse-where: dropping `query` inside a combinator — text search does not compose with OR/AND');
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if (typeof rawValue === 'string') {
|
|
200
|
+
result.query = rawValue;
|
|
201
|
+
}
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (key === 'path') {
|
|
205
|
+
if (isNested || inCombinator) {
|
|
187
206
|
const parsed = normaliseToOperator(rawValue);
|
|
188
207
|
if (parsed) {
|
|
189
|
-
result.
|
|
208
|
+
result.filters.push({
|
|
209
|
+
kind: 'docColumn',
|
|
210
|
+
column: 'path',
|
|
190
211
|
operator: parsed.operator,
|
|
191
|
-
value: String(parsed.value),
|
|
192
|
-
};
|
|
212
|
+
value: parsed.value === null ? null : String(parsed.value),
|
|
213
|
+
});
|
|
193
214
|
}
|
|
194
215
|
continue;
|
|
195
216
|
}
|
|
217
|
+
const parsed = normaliseToOperator(rawValue);
|
|
218
|
+
if (parsed) {
|
|
219
|
+
result.pathFilter = {
|
|
220
|
+
operator: parsed.operator,
|
|
221
|
+
value: String(parsed.value),
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
continue;
|
|
196
225
|
}
|
|
197
226
|
// --- Field-level keys --------------------------------------------------
|
|
198
227
|
const field = definition.fields.find((f) => f.name === key);
|
|
@@ -226,11 +255,11 @@ async function parseWhereInternal(where, definition, ctx, { isNested, inCombinat
|
|
|
226
255
|
isNested: true,
|
|
227
256
|
inCombinator: false,
|
|
228
257
|
});
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
// the
|
|
258
|
+
// Splice nested filters straight in. The nested parse runs with
|
|
259
|
+
// `isNested: true`, which promotes `status` / `path` into
|
|
260
|
+
// `DocumentColumnFilter` entries the adapter resolves against the
|
|
261
|
+
// target version's columns (`td${depth}.status` / `td${depth}.path`)
|
|
262
|
+
// via the inner relation scope. `query` is dropped one level up.
|
|
234
263
|
result.filters.push({
|
|
235
264
|
kind: 'relation',
|
|
236
265
|
fieldName: key,
|
|
@@ -40,7 +40,11 @@ const categoriesCollection = defineCollection({
|
|
|
40
40
|
}),
|
|
41
41
|
fields: [
|
|
42
42
|
{ name: 'name', type: 'text', label: 'Name', localized: true },
|
|
43
|
-
|
|
43
|
+
// `slug`, not `path` — `path` is a reserved key that resolves to the
|
|
44
|
+
// target version's `document_versions.path` column inside a nested
|
|
45
|
+
// sub-clause (same precedence as the top level), so a real `path`
|
|
46
|
+
// field would be unreachable through the where clause.
|
|
47
|
+
{ name: 'slug', type: 'text', label: 'Slug' },
|
|
44
48
|
{
|
|
45
49
|
name: 'parent',
|
|
46
50
|
type: 'relation',
|
|
@@ -220,8 +224,8 @@ describe('parseWhere', () => {
|
|
|
220
224
|
value: ['cat-a', 'cat-b'],
|
|
221
225
|
});
|
|
222
226
|
});
|
|
223
|
-
it('should emit a RelationFilter for a nested plain-object sub-where', async () => {
|
|
224
|
-
const result = await parseWhere({ category: {
|
|
227
|
+
it('should emit a RelationFilter for a nested plain-object sub-where (field key)', async () => {
|
|
228
|
+
const result = await parseWhere({ category: { slug: 'news' } }, testCollection, ctx);
|
|
225
229
|
expect(result.filters).toHaveLength(1);
|
|
226
230
|
expect(result.filters[0]).toEqual({
|
|
227
231
|
kind: 'relation',
|
|
@@ -230,7 +234,7 @@ describe('parseWhere', () => {
|
|
|
230
234
|
nested: [
|
|
231
235
|
{
|
|
232
236
|
kind: 'field',
|
|
233
|
-
fieldName: '
|
|
237
|
+
fieldName: 'slug',
|
|
234
238
|
storeType: 'text',
|
|
235
239
|
valueColumn: 'value',
|
|
236
240
|
operator: '$eq',
|
|
@@ -239,6 +243,55 @@ describe('parseWhere', () => {
|
|
|
239
243
|
],
|
|
240
244
|
});
|
|
241
245
|
});
|
|
246
|
+
it('promotes `path` inside a nested sub-where to a DocumentColumnFilter', async () => {
|
|
247
|
+
// Reserved-key precedence: `path` inside a relation sub-clause maps to
|
|
248
|
+
// the target version's `document_versions.path` column, never to a
|
|
249
|
+
// field of the same name. The adapter wires it to `td${depth}.path`
|
|
250
|
+
// via the inner relation scope.
|
|
251
|
+
const result = await parseWhere({ category: { path: 'news' } }, testCollection, ctx);
|
|
252
|
+
expect(result.filters).toHaveLength(1);
|
|
253
|
+
expect(result.filters[0]).toEqual({
|
|
254
|
+
kind: 'relation',
|
|
255
|
+
fieldName: 'category',
|
|
256
|
+
targetCollectionId: 'id-test-categories',
|
|
257
|
+
nested: [
|
|
258
|
+
{
|
|
259
|
+
kind: 'docColumn',
|
|
260
|
+
column: 'path',
|
|
261
|
+
operator: '$eq',
|
|
262
|
+
value: 'news',
|
|
263
|
+
},
|
|
264
|
+
],
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
it('promotes `status` inside a nested sub-where to a DocumentColumnFilter', async () => {
|
|
268
|
+
const result = await parseWhere({ category: { status: 'draft' } }, testCollection, ctx);
|
|
269
|
+
expect(result.filters).toHaveLength(1);
|
|
270
|
+
expect(result.filters[0]).toEqual({
|
|
271
|
+
kind: 'relation',
|
|
272
|
+
fieldName: 'category',
|
|
273
|
+
targetCollectionId: 'id-test-categories',
|
|
274
|
+
nested: [
|
|
275
|
+
{
|
|
276
|
+
kind: 'docColumn',
|
|
277
|
+
column: 'status',
|
|
278
|
+
operator: '$eq',
|
|
279
|
+
value: 'draft',
|
|
280
|
+
},
|
|
281
|
+
],
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
it('drops `query` inside a nested sub-where (no nested filter emitted)', async () => {
|
|
285
|
+
// Same rationale as `query` inside a combinator: text search has no
|
|
286
|
+
// sensible composition through a relation hop.
|
|
287
|
+
const result = await parseWhere({ category: { query: 'news' } }, testCollection, ctx);
|
|
288
|
+
expect(result.filters).toHaveLength(1);
|
|
289
|
+
const relation = result.filters[0];
|
|
290
|
+
expect(relation?.kind).toBe('relation');
|
|
291
|
+
if (relation?.kind !== 'relation')
|
|
292
|
+
return;
|
|
293
|
+
expect(relation.nested).toEqual([]);
|
|
294
|
+
});
|
|
242
295
|
it('should support operator objects inside a nested sub-where', async () => {
|
|
243
296
|
const result = await parseWhere({ category: { name: { $contains: 'news' } } }, testCollection, ctx);
|
|
244
297
|
const relation = result.filters[0];
|
|
@@ -257,7 +310,7 @@ describe('parseWhere', () => {
|
|
|
257
310
|
]);
|
|
258
311
|
});
|
|
259
312
|
it('should recurse into multi-hop relation sub-wheres', async () => {
|
|
260
|
-
const result = await parseWhere({ category: { parent: {
|
|
313
|
+
const result = await parseWhere({ category: { parent: { slug: 'news' } } }, testCollection, ctx);
|
|
261
314
|
const top = result.filters[0];
|
|
262
315
|
expect(top?.kind).toBe('relation');
|
|
263
316
|
if (top?.kind !== 'relation')
|
|
@@ -274,7 +327,7 @@ describe('parseWhere', () => {
|
|
|
274
327
|
expect(inner.nested).toEqual([
|
|
275
328
|
{
|
|
276
329
|
kind: 'field',
|
|
277
|
-
fieldName: '
|
|
330
|
+
fieldName: 'slug',
|
|
278
331
|
storeType: 'text',
|
|
279
332
|
valueColumn: 'value',
|
|
280
333
|
operator: '$eq',
|
|
@@ -282,6 +335,26 @@ describe('parseWhere', () => {
|
|
|
282
335
|
},
|
|
283
336
|
]);
|
|
284
337
|
});
|
|
338
|
+
it('recurses multi-hop with the doc-column form (target.path at depth 2)', async () => {
|
|
339
|
+
const result = await parseWhere({ category: { parent: { path: 'news' } } }, testCollection, ctx);
|
|
340
|
+
const top = result.filters[0];
|
|
341
|
+
expect(top?.kind).toBe('relation');
|
|
342
|
+
if (top?.kind !== 'relation')
|
|
343
|
+
return;
|
|
344
|
+
expect(top.nested).toHaveLength(1);
|
|
345
|
+
const inner = top.nested[0];
|
|
346
|
+
expect(inner?.kind).toBe('relation');
|
|
347
|
+
if (inner?.kind !== 'relation')
|
|
348
|
+
return;
|
|
349
|
+
expect(inner.nested).toEqual([
|
|
350
|
+
{
|
|
351
|
+
kind: 'docColumn',
|
|
352
|
+
column: 'path',
|
|
353
|
+
operator: '$eq',
|
|
354
|
+
value: 'news',
|
|
355
|
+
},
|
|
356
|
+
]);
|
|
357
|
+
});
|
|
285
358
|
it('should skip nested sub-where when ctx is not provided', async () => {
|
|
286
359
|
const result = await parseWhere({ category: { path: 'news' } }, testCollection);
|
|
287
360
|
expect(result.filters).toEqual([]);
|
|
@@ -526,6 +599,11 @@ describe('parseWhere — combinators', () => {
|
|
|
526
599
|
expect(result.filters).toEqual([]);
|
|
527
600
|
});
|
|
528
601
|
it('parses combinators inside a nested relation sub-where', async () => {
|
|
602
|
+
// Inside the nested sub-where: `name` is a real field on the target
|
|
603
|
+
// (resolves to a FieldFilter) and `path` is a reserved key that
|
|
604
|
+
// downshifts to a `DocumentColumnFilter` against the target version's
|
|
605
|
+
// path column. Inside an `$or` either side composes — the OR is
|
|
606
|
+
// emitted as a CombinatorFilter wrapping both children.
|
|
529
607
|
const result = await parseWhere({
|
|
530
608
|
category: {
|
|
531
609
|
$or: [{ name: 'News' }, { path: 'announcements' }],
|
|
@@ -541,7 +619,7 @@ describe('parseWhere — combinators', () => {
|
|
|
541
619
|
kind: 'or',
|
|
542
620
|
children: [
|
|
543
621
|
{ kind: 'field', fieldName: 'name' },
|
|
544
|
-
{ kind: '
|
|
622
|
+
{ kind: 'docColumn', column: 'path', operator: '$eq', value: 'announcements' },
|
|
545
623
|
],
|
|
546
624
|
});
|
|
547
625
|
});
|