@simtlix/simfinity-core 3.16.0 → 3.17.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/README.md +2 -0
- package/package.json +1 -1
- package/src/metadata.js +2 -2
- package/src/query-comparisons.js +54 -0
- package/src/query-frames.js +119 -20
- package/src/query-operators.js +9 -5
- package/src/query-plan.js +80 -26
- package/src/runtime.js +26 -12
- package/types/index.d.ts +26 -3
package/README.md
CHANGED
|
@@ -84,3 +84,5 @@ pinned to the facade's exact version, including under pnpm or Yarn PnP. A late
|
|
|
84
84
|
call fails without deleting installed metadata. Migrate clients selecting
|
|
85
85
|
`__Field.extensions` before opting out. See the [descriptor reference](https://simtlix.github.io/simfinity.js/reference/client-descriptor.html)
|
|
86
86
|
for the format, protected HTTP access and composition limits.
|
|
87
|
+
|
|
88
|
+
`queryFeatures.valuePath: true` opts an adapter into stored-field comparisons and requires `referenceEmptiness: true`; only true enables it. `valuePath` is nullable on all three generated filter inputs and is mutually exclusive with a supplied `value` (including null). Core validates both operand paths, their logical types, visibility and collection correlation, and preserves outer origins internally. `createQueryPlan` adds `operand?: QueryOperand` with `{ up, path, joins? }` and a null value placeholder; same-frame scope unions live in predicate `joins`, ancestor scopes in operand `joins`. Literal nodes stay unchanged. `describeModels` retains additive `enumName` and effective `temporalName` metadata. See [the comparison rules and limits](https://simtlix.github.io/simfinity.js/guide/queries.html#compare-stored-fields) and [compiler contract](https://simtlix.github.io/simfinity.js/guide/sql-plugins.html#query-features).
|
package/package.json
CHANGED
package/src/metadata.js
CHANGED
|
@@ -54,7 +54,7 @@ const scalarDescription = (type) => {
|
|
|
54
54
|
if (storedValues.has(stored) && !Object.is(storedValues.get(stored), entry.value)) invalid(`Enum storage collision in ${type.name}: distinct internal values serialize to ${stored}`);
|
|
55
55
|
storedValues.set(stored, entry.value);
|
|
56
56
|
}
|
|
57
|
-
return { scalar: 'Enum', values: [...storedValues.keys()], enumValues: type.getValues().map(({ name, value }) => ({ name, value })) };
|
|
57
|
+
return { scalar: 'Enum', enumName: type.name, values: [...storedValues.keys()], enumValues: type.getValues().map(({ name, value }) => ({ name, value })) };
|
|
58
58
|
}
|
|
59
59
|
// The walk stops on a type that still has a base only where the chain repeats.
|
|
60
60
|
if (rootScalar(type).baseScalarType) invalid(`Cyclic base scalar ${type.name}`);
|
|
@@ -63,7 +63,7 @@ const scalarDescription = (type) => {
|
|
|
63
63
|
if (!['String', 'ID', 'Int', 'Float', 'Boolean', 'DateTime'].includes(scalar)) {
|
|
64
64
|
invalid(`Unsupported scalar ${type.name}: declare a supported baseScalarType`);
|
|
65
65
|
}
|
|
66
|
-
return { scalar };
|
|
66
|
+
return { scalar, ...(scalar === 'DateTime' ? { temporalName: name } : {}) };
|
|
67
67
|
};
|
|
68
68
|
|
|
69
69
|
// The version field of a protected entity, described the same way whether the type carries Simfinity's
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import SimfinityError from './errors/simfinity.error.js';
|
|
2
|
+
|
|
3
|
+
const fail = (message, code = 'INVALID_FILTER_VALUE') => { throw new SimfinityError(message, code, 400); };
|
|
4
|
+
const equality = new Set(['EQ', 'NE']);
|
|
5
|
+
const ordered = new Set(['LT', 'LTE', 'GT', 'GTE']);
|
|
6
|
+
const text = new Set(['LIKE', 'STARTS_WITH', 'ENDS_WITH']);
|
|
7
|
+
const temporal = new Set(['DateTime', 'Date', 'Time']);
|
|
8
|
+
|
|
9
|
+
/** A nullable optional path selects a field operand only when non-null and non-undefined. */
|
|
10
|
+
export const hasValuePath = (carrier) => carrier?.valuePath !== undefined && carrier?.valuePath !== null;
|
|
11
|
+
|
|
12
|
+
/** Validate the public operand syntax before resolving either path. Literal carriers are unchanged. */
|
|
13
|
+
export const validateComparisonOperand = (carrier) => {
|
|
14
|
+
if (!hasValuePath(carrier)) return null;
|
|
15
|
+
if (carrier.value !== undefined) fail('A filter cannot supply both value and valuePath');
|
|
16
|
+
if (typeof carrier.valuePath !== 'string') fail('valuePath requires a string field path');
|
|
17
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/.test(carrier.valuePath)) {
|
|
18
|
+
fail('valuePath requires a nonempty dotted field path; outer paths require an explicit group', 'INVALID_FILTER_PATH');
|
|
19
|
+
}
|
|
20
|
+
return carrier.valuePath;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const typeOf = (field, path) => {
|
|
24
|
+
if (field.identity || field.scalar === 'ID') return 'ID';
|
|
25
|
+
if (field.scalar === 'Enum') {
|
|
26
|
+
if (!field.enumName) fail(`Field comparison on ${path} requires logical enumName metadata`, 'UNSUPPORTED_QUERY');
|
|
27
|
+
return `Enum:${field.enumName}`;
|
|
28
|
+
}
|
|
29
|
+
if (temporal.has(field.scalar)) {
|
|
30
|
+
if (!field.temporalName) fail(`Field comparison on ${path} requires effective temporalName metadata`, 'UNSUPPORTED_QUERY');
|
|
31
|
+
return field.temporalName;
|
|
32
|
+
}
|
|
33
|
+
return field.scalar;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** Logical comparison rules shared by runtime/model validation and hand-built engine plans. */
|
|
37
|
+
export const validateComparisonTypes = (left, right, operator = 'EQ', leftPath = 'left', rightPath = 'right') => {
|
|
38
|
+
if (right.list) fail(`The right operand ${rightPath} must be a scalar, not a list`, 'INVALID_FILTER_PATH');
|
|
39
|
+
if (!equality.has(operator) && !ordered.has(operator) && !text.has(operator)) {
|
|
40
|
+
fail(`${operator} does not accept valuePath`, 'INVALID_FILTER_OPERATOR');
|
|
41
|
+
}
|
|
42
|
+
if (left.list && !equality.has(operator)) fail(`${operator} does not accept a list left operand`, 'INVALID_FILTER_OPERATOR');
|
|
43
|
+
const a = typeOf(left, leftPath);
|
|
44
|
+
const b = typeOf(right, rightPath);
|
|
45
|
+
const numeric = ['Int', 'Float'].includes(a) && ['Int', 'Float'].includes(b);
|
|
46
|
+
const supported = numeric || (a === b && (['String', 'Boolean', 'ID'].includes(a) || temporal.has(a) || a?.startsWith('Enum:')));
|
|
47
|
+
if (!supported || left.version || right.version) {
|
|
48
|
+
fail(`Incompatible field comparison: ${leftPath} (${a}) and ${rightPath} (${b})`);
|
|
49
|
+
}
|
|
50
|
+
if (text.has(operator) && a !== 'String') fail(`${operator} requires String operands`, 'INVALID_FILTER_OPERATOR');
|
|
51
|
+
if (ordered.has(operator) && !numeric && a !== 'String' && !temporal.has(a)) {
|
|
52
|
+
fail(`${operator} does not order ${a} operands`, 'INVALID_FILTER_OPERATOR');
|
|
53
|
+
}
|
|
54
|
+
};
|
package/src/query-frames.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getNamedType, isObjectType } from 'graphql';
|
|
1
|
+
import { getNamedType, isEnumType, isObjectType } from 'graphql';
|
|
2
2
|
import { GROUP_KEYS, QUANTIFIED_LISTS, QUANTIFIERS } from './const/QLQuantifier.js';
|
|
3
3
|
import SimfinityError from './errors/simfinity.error.js';
|
|
4
4
|
import { effectiveScalarName } from './metadata.js';
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
} from './query-emptiness.js';
|
|
9
9
|
import {
|
|
10
10
|
assertContainsAllPath, isQueryOperator, takesListValue, textQueryOperator, validateContainsAllValue, validateTextFilterValue,
|
|
11
|
-
validateTextListFilterValue,
|
|
11
|
+
validateTextListFilterValue, hasValuePath, validateComparisonOperand, validateComparisonTypes,
|
|
12
12
|
} from './query-operators.js';
|
|
13
13
|
import { collectQueryOperators, validateQueryGroupKeys } from './query-plan.js';
|
|
14
14
|
import { getQueryMaxFrameNesting } from './query-limits.js';
|
|
@@ -56,13 +56,17 @@ const resolve = (path, ctx, onStep, virtualLabel, ordinaryCondition = false) =>
|
|
|
56
56
|
fail(`Query path ${label} uses the virtual field ${current.gqltype.name}.${name}, which is not stored`, 'INVALID_FILTER_FIELD');
|
|
57
57
|
}
|
|
58
58
|
step = graphqlStep(name, field);
|
|
59
|
-
|
|
59
|
+
const type = getNamedType(field.type);
|
|
60
|
+
step.scalar = isEnumType(type) ? 'Enum' : effectiveScalarName(type);
|
|
61
|
+
step.enumName = isEnumType(type) ? type.name : undefined;
|
|
62
|
+
step.temporalName = ['Date', 'Time', 'DateTime'].includes(step.scalar) ? step.scalar : undefined;
|
|
63
|
+
step.version = step.scalar === 'RecordVersion' || step.scalar === 'Version';
|
|
60
64
|
raw.push(field);
|
|
61
65
|
} else {
|
|
62
66
|
const fields = current.fields ?? current.models?.entities.find((entity) => entity.name === current.entity)?.fields;
|
|
63
67
|
const field = fields?.find((item) => item.name === name && !item.private);
|
|
64
68
|
if (!field) fail(`Unknown field ${parts.slice(0, index + 1).join('.')}`, 'INVALID_FILTER_FIELD');
|
|
65
|
-
step = { ...modelStep(field)
|
|
69
|
+
step = { ...field, ...modelStep(field) };
|
|
66
70
|
if (relation(step)) step.node = ctx.models?.entities.find((entity) => entity.name === step.target)?.fields;
|
|
67
71
|
raw.push(field);
|
|
68
72
|
}
|
|
@@ -74,6 +78,24 @@ const resolve = (path, ctx, onStep, virtualLabel, ordinaryCondition = false) =>
|
|
|
74
78
|
return { steps, raw, end: current };
|
|
75
79
|
};
|
|
76
80
|
|
|
81
|
+
const comparisonDescriptor = (resolved) => {
|
|
82
|
+
const last = resolved.steps.at(-1);
|
|
83
|
+
const previous = resolved.steps.at(-2);
|
|
84
|
+
return { ...last, list: last.kind === 'scalarList', identity: ['id', '_id'].includes(last.name)
|
|
85
|
+
&& (!previous || relation(previous)), version: last.version || ['Version', 'RecordVersion'].includes(last.scalar) };
|
|
86
|
+
};
|
|
87
|
+
const operandContext = (entry, ctx, up) => inFrames({ frames: entry.frames.slice(0, entry.frames.length - up) }, ctx);
|
|
88
|
+
const validateInternalOperand = (entry) => {
|
|
89
|
+
const operand = entry.operand;
|
|
90
|
+
if (operand === undefined) return;
|
|
91
|
+
if (!plain(operand) || !Number.isSafeInteger(operand.up) || operand.up <= 0 || operand.up > entry.frames.length
|
|
92
|
+
|| entry.frames.slice(-operand.up).some((frame) => frame.construct.__implicit !== true)
|
|
93
|
+
|| !Array.isArray(operand.path) || !operand.path.length
|
|
94
|
+
|| operand.path.some((part) => typeof part !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(part))
|
|
95
|
+
|| Object.keys(operand).some((key) => !['up', 'path', 'scopes'].includes(key)) || entry.hasValuePath || entry.value !== undefined) {
|
|
96
|
+
fail('Invalid compiler-generated comparison operand', 'INVALID_FILTER_PATH');
|
|
97
|
+
}
|
|
98
|
+
};
|
|
77
99
|
const inFrames = (entry, ctx) => {
|
|
78
100
|
let current = ctx;
|
|
79
101
|
for (const frame of entry.frames) {
|
|
@@ -124,6 +146,13 @@ const cutAt = (resolved, J) => {
|
|
|
124
146
|
}
|
|
125
147
|
return null;
|
|
126
148
|
};
|
|
149
|
+
const comparisonInGroup = (group, seen = new Set()) => {
|
|
150
|
+
if (seen.has(group)) return false;
|
|
151
|
+
seen.add(group);
|
|
152
|
+
return list(group.conditions).some((condition) => hasValuePath(condition) || condition.__operand !== undefined)
|
|
153
|
+
|| ['AND', 'OR', ...QUANTIFIED_LISTS].some((key) => list(group[key]).some((child) => comparisonInGroup(child, seen)));
|
|
154
|
+
};
|
|
155
|
+
const comparisonKeys = (resolved) => resolved.steps.flatMap((step, index, steps) => relation(step) ? [steps.slice(0, index + 1).map(({ name }) => name).join('.')] : []);
|
|
127
156
|
const markerKeys = (resolved, J = new Set()) => {
|
|
128
157
|
const cut = cutAt(resolved, J);
|
|
129
158
|
const anchored = resolved.steps.some((step, index, steps) => step.kind === 'collection'
|
|
@@ -136,14 +165,25 @@ const markerKeys = (resolved, J = new Set()) => {
|
|
|
136
165
|
const prefixScopes = (scopes, prefix, keys) => Object.fromEntries(Object.entries(scopes ?? {}).filter(([key]) => (
|
|
137
166
|
keys.includes(key) && (key === prefix || prefix.startsWith(`${key}.`))
|
|
138
167
|
)));
|
|
139
|
-
const cutCondition = (item, rest, prefix, keys) => {
|
|
168
|
+
const cutCondition = (item, rest, prefix, keys, rightKeys = []) => {
|
|
140
169
|
const { __scope, ...operands } = item;
|
|
141
170
|
delete operands.field;
|
|
142
171
|
delete operands.path;
|
|
143
172
|
const condition = { field: rest, ...operands };
|
|
173
|
+
if (item.__operand) condition.__operand = { ...item.__operand, up: item.__operand.up + 1 };
|
|
174
|
+
else if (hasValuePath(item)) {
|
|
175
|
+
if (item.valuePath.startsWith(`${prefix}.`)) condition.valuePath = item.valuePath.slice(prefix.length + 1);
|
|
176
|
+
else {
|
|
177
|
+
delete condition.valuePath;
|
|
178
|
+
condition.__operand = { up: 1, path: item.valuePath.split('.') };
|
|
179
|
+
if (hasMarker(item) && rightKeys.length) {
|
|
180
|
+
condition.__operand.scopes = Object.fromEntries(Object.entries(__scope?.scopes ?? {}).filter(([key]) => rightKeys.includes(key)));
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
144
184
|
if (hasMarker(item)) {
|
|
145
185
|
const scopes = Object.fromEntries(Object.entries(__scope?.scopes ?? {})
|
|
146
|
-
.filter(([key]) => keys.includes(key) && key.startsWith(`${prefix}.`)).map(([key, value]) => [key.slice(prefix.length + 1), value]));
|
|
186
|
+
.filter(([key]) => [...keys, ...rightKeys].includes(key) && key.startsWith(`${prefix}.`)).map(([key, value]) => [key.slice(prefix.length + 1), value]));
|
|
147
187
|
if (Object.keys(scopes).length) condition.__scope = { id: __scope.id, scopes };
|
|
148
188
|
}
|
|
149
189
|
return condition;
|
|
@@ -185,11 +225,13 @@ export const frameCollectionPaths = (args, ctx) => {
|
|
|
185
225
|
if (at.anchor.length) bucket.__anchor = at.anchor;
|
|
186
226
|
buckets.set(prefix, bucket);
|
|
187
227
|
}
|
|
188
|
-
const
|
|
228
|
+
const rightResolved = hasValuePath(condition) ? resolve(condition.valuePath, frame) : null;
|
|
229
|
+
const rightKeys = rightResolved ? comparisonKeys(rightResolved) : [];
|
|
230
|
+
const keys = hasValuePath(condition) || condition.__operand ? comparisonKeys(resolved) : markerKeys(resolved, J);
|
|
189
231
|
if (hasMarker(condition) && !hasMarker(bucket)) {
|
|
190
|
-
bucket.__scope = { scopes: prefixScopes(condition.__scope?.scopes, prefix, keys) };
|
|
232
|
+
bucket.__scope = { scopes: prefixScopes(condition.__scope?.scopes, prefix, [...keys, ...rightKeys]) };
|
|
191
233
|
}
|
|
192
|
-
bucket.conditions.push(cutCondition(condition, resolved.steps.slice(at.index + 1).map(({ name }) => name).join('.'), prefix, keys));
|
|
234
|
+
bucket.conditions.push(cutCondition(condition, resolved.steps.slice(at.index + 1).map(({ name }) => name).join('.'), prefix, keys, rightKeys));
|
|
193
235
|
return bucket;
|
|
194
236
|
};
|
|
195
237
|
if (Array.isArray(input.conditions)) {
|
|
@@ -229,7 +271,8 @@ export const frameCollectionPaths = (args, ctx) => {
|
|
|
229
271
|
if (['scalar', 'scalarList'].includes(leaf.kind)) {
|
|
230
272
|
// Native planner inputs may name a scalar through collections. Each entry is its own
|
|
231
273
|
// correlation group; ignored terms and filter metadata never become predicates or markers.
|
|
232
|
-
const
|
|
274
|
+
const carrier = hasValuePath(value) ? { ...value } : { operator: value.operator ?? 'EQ', value: value.value };
|
|
275
|
+
const group = { conditions: [{ field: name, ...carrier }] };
|
|
233
276
|
const rewritten = rewriteGroup(group, ctx, ctx.J ?? new Set());
|
|
234
277
|
if (list(rewritten.ANY).length) {
|
|
235
278
|
syntheticGroups.push({ __implicit: true, ANY: rewritten.ANY });
|
|
@@ -238,7 +281,7 @@ export const frameCollectionPaths = (args, ctx) => {
|
|
|
238
281
|
continue;
|
|
239
282
|
}
|
|
240
283
|
if (!Array.isArray(value.terms) || valueLeaf(leaf, ctx)) continue;
|
|
241
|
-
const group = { conditions: value.terms.map((term) => ({ ...termCondition(term), field: pathless(term.path) ? name : `${name}.${term.path}` })) };
|
|
284
|
+
const group = { conditions: value.terms.map((term) => ({ ...termCondition(term), field: pathless(term.path) ? name : `${name}.${term.path}`, ...(hasValuePath(term) ? { valuePath: `${name}.${term.valuePath}` } : {}) })) };
|
|
242
285
|
const rewritten = rewriteGroup(group, ctx, ctx.J ?? new Set());
|
|
243
286
|
implicitGroups.push(...list(rewritten.ANY));
|
|
244
287
|
if (rewritten.conditions.length === value.terms.length) continue;
|
|
@@ -373,7 +416,8 @@ export const validateQuantifiedGroups = (args, ctx) => {
|
|
|
373
416
|
if (ctx.gqltype && leaf.kind === 'abstract') abstractFields.add(name);
|
|
374
417
|
if (value.quantifier == null && valueLeaf(leaf, ctx) && Array.isArray(value.terms) && value.operator === undefined) {
|
|
375
418
|
scalarCarriers.push({ form: 'filter', field: name, path: name, relative: partsOf(name), segments: partsOf(name), frames: [],
|
|
376
|
-
operator: 'EQ', value: value.value, construct: null
|
|
419
|
+
operator: 'EQ', value: value.value, construct: hasValuePath(value) ? value : null,
|
|
420
|
+
...(hasValuePath(value) ? { hasValuePath: true, valuePath: value.valuePath } : {}) });
|
|
377
421
|
}
|
|
378
422
|
if (!valueLeaf(leaf, ctx) && value.quantifier == null) {
|
|
379
423
|
if (isEmptinessOperator(value.operator)) fail(`Filter on ${name} takes ${value.operator} only in a term; to test the field itself, use a term `
|
|
@@ -396,7 +440,12 @@ export const validateQuantifiedGroups = (args, ctx) => {
|
|
|
396
440
|
entries.push(...abstractCarriers);
|
|
397
441
|
for (const entry of entries) {
|
|
398
442
|
if (ignoredValueTerm(entry, ctx)) continue;
|
|
443
|
+
if (entry.hasValuePath) validateComparisonOperand(entry.construct);
|
|
444
|
+
validateInternalOperand(entry);
|
|
445
|
+
const comparison = entry.hasValuePath || entry.operand !== undefined;
|
|
399
446
|
const current = inFrames(entry, ctx);
|
|
447
|
+
let rightResolved;
|
|
448
|
+
let rightCurrent;
|
|
400
449
|
const implicit = entry.form === 'quantified' && entry.construct.__implicit === true;
|
|
401
450
|
if (entry.form === 'condition' && typeof entry.field !== 'string') fail('Invalid filter path', 'INVALID_FILTER_PATH');
|
|
402
451
|
if (entry.ambiguous) fail('Filter condition cannot use both dotted field syntax and a separate path', 'AMBIGUOUS_FILTER_FIELD');
|
|
@@ -448,7 +497,39 @@ export const validateQuantifiedGroups = (args, ctx) => {
|
|
|
448
497
|
if (ctx.quantifiers !== true || last.kind === 'embeddedList') fail('Quantified groups are not supported by this query compiler', 'UNSUPPORTED_QUERY');
|
|
449
498
|
});
|
|
450
499
|
} else {
|
|
451
|
-
if (
|
|
500
|
+
if (comparison) {
|
|
501
|
+
rightCurrent = entry.operand ? operandContext(entry, ctx, entry.operand.up) : current;
|
|
502
|
+
const rightPath = entry.operand?.path ?? partsOf(entry.valuePath);
|
|
503
|
+
rightResolved = resolve(rightPath, rightCurrent);
|
|
504
|
+
if (!['scalar', 'scalarList'].includes(last.kind) || !['scalar', 'scalarList'].includes(rightResolved.steps.at(-1).kind)) {
|
|
505
|
+
fail('Field comparison operands must end in scalar fields', 'INVALID_FILTER_PATH');
|
|
506
|
+
}
|
|
507
|
+
if (!isQueryOperator(entry.operator)) fail(`Unknown filter operator: ${entry.operator}`, 'INVALID_FILTER_OPERATOR');
|
|
508
|
+
if (ctx.operators && !ctx.operators.has(entry.operator)) fail(`The ${entry.operator} filter operator is not supported by this query compiler`, 'UNSUPPORTED_QUERY');
|
|
509
|
+
const collections = new Set(steps.flatMap((step, index) => step.kind === 'collection' ? [steps.slice(0, index + 1).map(({ name }) => name).join('.')] : []));
|
|
510
|
+
if (entry.operand) {
|
|
511
|
+
const chain = entry.frames.slice(entry.frames.length - entry.operand.up)
|
|
512
|
+
.flatMap((frame) => [...list(frame.construct.__anchor), ...partsOf(frame.path)]);
|
|
513
|
+
const bound = resolve([...chain, ...path], rightCurrent).steps;
|
|
514
|
+
for (const [index, step] of bound.entries()) if (step.kind === 'collection') {
|
|
515
|
+
const key = bound.slice(0, index + 1).map(({ name }) => name).join('.');
|
|
516
|
+
if (ctx.J?.has(key) && entry.operand.up === entry.frames.length) collections.add(key);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
if (rightResolved.steps.some((step, index, right) => step.kind === 'collection'
|
|
520
|
+
&& !collections.has(right.slice(0, index + 1).map(({ name }) => name).join('.')))) {
|
|
521
|
+
fail('The right operand introduces an unbound collection; use an explicit group', 'INVALID_FILTER_PATH');
|
|
522
|
+
}
|
|
523
|
+
validateComparisonTypes(comparisonDescriptor(resolved), comparisonDescriptor(rightResolved), entry.operator, path.join('.'), rightPath.join('.'));
|
|
524
|
+
gates.push(() => {
|
|
525
|
+
if ([...steps, ...rightResolved.steps].some((step) => step.kind === 'embeddedList')) fail('Field comparisons through embedded lists require element frames', 'UNSUPPORTED_QUERY');
|
|
526
|
+
if (ctx.valuePath !== true || ctx.referenceEmptiness !== true) fail('Field comparisons require valuePath and referenceEmptiness support from this query compiler', 'UNSUPPORTED_QUERY');
|
|
527
|
+
const J = entry.frames.length ? new Set() : ctx.J ?? new Set();
|
|
528
|
+
if (ctx.quantifiers !== true && steps.some((step, index) => step.kind === 'collection' && !J.has(steps.slice(0, index + 1).map(({ name }) => name).join('.')))) {
|
|
529
|
+
fail('Collection field comparisons require quantifiers', 'UNSUPPORTED_QUERY');
|
|
530
|
+
}
|
|
531
|
+
});
|
|
532
|
+
} else if (isEmptinessOperator(entry.operator)) {
|
|
452
533
|
if (ctx.operators && !ctx.operators.has(entry.operator)) fail(`The ${entry.operator} filter operator is not supported by this query compiler`, 'UNSUPPORTED_QUERY');
|
|
453
534
|
if (ctx.quantifiers === true && steps.slice(0, -1).some((step) => step.kind === 'collection')) fail('The shorthand excludes them; use an explicit group', 'INVALID_FILTER_PATH');
|
|
454
535
|
shape = describe(resolved, path, current, entry.operator, entry.value);
|
|
@@ -464,6 +545,14 @@ export const validateQuantifiedGroups = (args, ctx) => {
|
|
|
464
545
|
validateValue(entry, resolved, current);
|
|
465
546
|
}
|
|
466
547
|
}
|
|
548
|
+
if (entry.operand && ctx.scopeMarkers !== 'pending' && (entry.operand.scopes !== undefined
|
|
549
|
+
|| hasMarker(entry.construct))) markers.push(() => {
|
|
550
|
+
// An outer binding can still read a collection row owned by aggregate J.
|
|
551
|
+
const keys = comparisonKeys(rightResolved);
|
|
552
|
+
const scopes = entry.operand.scopes;
|
|
553
|
+
if (keys.length && (!plain(scopes) || keys.some((key) => !Object.hasOwn(scopes, key) || scopes[key] === undefined))) detached('Outer operand scope marker is not filled for every crossing');
|
|
554
|
+
if (!keys.length && scopes !== undefined) detached('Outer operand scope marker reads no reference or collection');
|
|
555
|
+
});
|
|
467
556
|
const J = entry.frames.length ? new Set() : ctx.J ?? new Set();
|
|
468
557
|
const extra = entry.form === 'quantified' ? 1 : shape ? (emptinessScopePaths(shape, path).frames.length ? 1 : 0) : ctx.quantifiers === true ? steps.filter((step, index) => step.kind === 'collection'
|
|
469
558
|
&& !J.has(steps.slice(0, index + 1).map(({ name }) => name).join('.'))).length : 0;
|
|
@@ -472,7 +561,7 @@ export const validateQuantifiedGroups = (args, ctx) => {
|
|
|
472
561
|
});
|
|
473
562
|
if (hasMarker(entry.construct) && ctx.scopeMarkers !== 'pending') markers.push(() => {
|
|
474
563
|
const suffix = '; only the runtime writes and fills client scope markers (docs/guide/middleware.md)';
|
|
475
|
-
if (!shape && entry.form !== 'quantified' && !entry.frames.length
|
|
564
|
+
if (!comparison && !shape && entry.form !== 'quantified' && !entry.frames.length
|
|
476
565
|
&& (ctx.quantifiers !== true || !cutAt(resolved, ctx.J ?? new Set()))) {
|
|
477
566
|
// Mongo rejects these at its native predicate checkpoint, after parsing/casting once.
|
|
478
567
|
// Every other caller remains fail-closed here, after the common 400 gates above.
|
|
@@ -484,6 +573,9 @@ export const validateQuantifiedGroups = (args, ctx) => {
|
|
|
484
573
|
const paths = emptinessScopePaths(shape, path);
|
|
485
574
|
keys = [...paths.joins, ...paths.frames];
|
|
486
575
|
} else keys = markerKeys(resolved, !entry.frames.length && (implicit || entry.form !== 'quantified') ? ctx.J ?? new Set() : new Set());
|
|
576
|
+
if (comparison) keys = [...new Set([...comparisonKeys(resolved), ...(!entry.operand ? comparisonKeys(rightResolved) : [])])];
|
|
577
|
+
const comparisonAnchor = implicit && anchor.length && comparisonInGroup(entry.construct);
|
|
578
|
+
if (comparisonAnchor) keys = comparisonKeys(resolved);
|
|
487
579
|
const scopes = entry.construct.__scope?.scopes;
|
|
488
580
|
if (shape) {
|
|
489
581
|
// Preserve 2b's direct-compiler diagnostics when the shared validator now runs first.
|
|
@@ -496,7 +588,7 @@ export const validateQuantifiedGroups = (args, ctx) => {
|
|
|
496
588
|
for (const [index, step] of steps.slice(0, anchor.length).entries()) {
|
|
497
589
|
const key = steps.slice(0, index + 1).map(({ name }) => name).join('.');
|
|
498
590
|
if ((step.kind === 'collection' && scopes[key] !== null)
|
|
499
|
-
|| (step.kind === 'reference' && Object.hasOwn(scopes, key))) detached('Aggregate anchors require null J scopes and legacy reference joins');
|
|
591
|
+
|| (step.kind === 'reference' && !comparisonAnchor && Object.hasOwn(scopes, key))) detached('Aggregate anchors require null J scopes and legacy reference joins');
|
|
500
592
|
}
|
|
501
593
|
}
|
|
502
594
|
});
|
|
@@ -517,6 +609,7 @@ export const frameUnits = (args, ctx) => {
|
|
|
517
609
|
return identities.get(construct);
|
|
518
610
|
};
|
|
519
611
|
const frameKey = (entry) => entry.frames.reduce((key, frame) => `${key}/${id(frame.construct)}:${frame.type}:${frame.path}`, 'root');
|
|
612
|
+
const joinUnit = (frame, path, scoped) => `join:${frame}:${path}${scoped ? ':scoped' : ''}`;
|
|
520
613
|
for (const entry of collectQueryOperators(rewritten, ctx.operation)) {
|
|
521
614
|
if (ignoredValueTerm(entry, ctx)) continue;
|
|
522
615
|
const current = inFrames(entry, ctx);
|
|
@@ -527,18 +620,24 @@ export const frameUnits = (args, ctx) => {
|
|
|
527
620
|
const path = entry.relative;
|
|
528
621
|
const { steps } = resolve(path, current);
|
|
529
622
|
for (const [index, step] of steps.entries()) {
|
|
530
|
-
if (index >= anchor.length && index < steps.length - 1 && step.kind === 'reference') {
|
|
531
|
-
units.add(
|
|
623
|
+
if ((index >= anchor.length || comparisonInGroup(entry.construct)) && index < steps.length - 1 && step.kind === 'reference') {
|
|
624
|
+
units.add(joinUnit(frame, steps.slice(0, index + 1).map(({ name }) => name).join('.'), hasMarker(entry.construct)));
|
|
532
625
|
}
|
|
533
626
|
}
|
|
534
627
|
} else if (isEmptinessOperator(entry.operator)) {
|
|
535
628
|
const resolved = resolve(entry.relative, current);
|
|
536
629
|
const shape = describe(resolved, entry.relative, current, entry.operator, entry.value);
|
|
537
630
|
const paths = emptinessScopePaths(shape, entry.relative);
|
|
538
|
-
for (const path of paths.joins) units.add(
|
|
631
|
+
for (const path of paths.joins) units.add(joinUnit(frame, path, hasMarker(entry.construct)));
|
|
539
632
|
for (const path of paths.frames) units.add(`emptiness-frame:${frame}:${path}`);
|
|
540
|
-
} else if (entry.frames.length) {
|
|
541
|
-
for (const path of crossingKeys(entry.relative, current)) units.add(
|
|
633
|
+
} else if (entry.frames.length || entry.hasValuePath || entry.operand) {
|
|
634
|
+
for (const path of crossingKeys(entry.relative, current)) units.add(joinUnit(frame, path, hasMarker(entry.construct)));
|
|
635
|
+
if (entry.hasValuePath) for (const path of crossingKeys(entry.valuePath, current)) units.add(joinUnit(frame, path, hasMarker(entry.construct)));
|
|
636
|
+
if (entry.operand) {
|
|
637
|
+
const outer = operandContext(entry, ctx, entry.operand.up);
|
|
638
|
+
const owner = frameKey({ frames: entry.frames.slice(0, entry.frames.length - entry.operand.up) });
|
|
639
|
+
for (const path of crossingKeys(entry.operand.path, outer)) units.add(joinUnit(owner, path, Object.hasOwn(entry.operand, 'scopes')));
|
|
640
|
+
}
|
|
542
641
|
}
|
|
543
642
|
}
|
|
544
643
|
return { count: units.size, units: [...units] };
|
package/src/query-operators.js
CHANGED
|
@@ -14,6 +14,7 @@ export {
|
|
|
14
14
|
export {
|
|
15
15
|
ANCHOR_KEY, GROUP_KEYS, IMPLICIT_KEY, aggregateJoinPrefixes, crossingKeys, frameCollectionPaths, frameUnits, validateQuantifiedGroups,
|
|
16
16
|
} from './query-frames.js';
|
|
17
|
+
export { hasValuePath, validateComparisonOperand, validateComparisonTypes } from './query-comparisons.js';
|
|
17
18
|
export { effectiveScalarName } from './metadata.js';
|
|
18
19
|
|
|
19
20
|
/** Every `QLOperator` value, in declaration order. QLOperator is the only list of operators. */
|
|
@@ -153,23 +154,23 @@ const invalidFeatures = (message) => {
|
|
|
153
154
|
};
|
|
154
155
|
|
|
155
156
|
/**
|
|
156
|
-
* Reads an adapter's `queryFeatures` declaration into `{ operators, referenceEmptiness, quantifiers }`:
|
|
157
|
+
* Reads an adapter's `queryFeatures` declaration into `{ operators, referenceEmptiness, quantifiers, valuePath }`:
|
|
157
158
|
* the operators it compiles and whether it compiles reference emptiness and collection frames.
|
|
158
159
|
* Without a declaration (undefined or null) the adapter supports {@link LEGACY_QUERY_OPERATORS}
|
|
159
160
|
* and neither feature. A declaration is an object whose `operators`
|
|
160
161
|
* is an array of operator names, whose names this version does not know are ignored, and whose
|
|
161
|
-
* optional `referenceEmptiness` and `
|
|
162
|
+
* optional `referenceEmptiness`, `quantifiers` and `valuePath` are booleans, undefined or null (false). A name
|
|
162
163
|
* counts once this version knows it: a declaration that listed `CONTAINS_ALL` or `IIN` before
|
|
163
164
|
* #108 part 4 added them receives them on upgrade.
|
|
164
|
-
* Quantifiers require reference emptiness for scoped joins. Other members are left for later
|
|
165
|
+
* Quantifiers and valuePath require reference emptiness for scoped joins. Other members are left for later
|
|
165
166
|
* features. Anything else throws `INVALID_QUERY_FEATURES` (500).
|
|
166
167
|
*/
|
|
167
168
|
export const resolveQueryFeatures = (queryFeatures) => {
|
|
168
|
-
if (queryFeatures == null) return { operators: new Set(LEGACY_QUERY_OPERATORS), referenceEmptiness: false, quantifiers: false };
|
|
169
|
+
if (queryFeatures == null) return { operators: new Set(LEGACY_QUERY_OPERATORS), referenceEmptiness: false, quantifiers: false, valuePath: false };
|
|
169
170
|
if (typeof queryFeatures !== 'object' || Array.isArray(queryFeatures)) {
|
|
170
171
|
invalidFeatures('must be an object with an operators array');
|
|
171
172
|
}
|
|
172
|
-
const { operators, referenceEmptiness, quantifiers } = queryFeatures;
|
|
173
|
+
const { operators, referenceEmptiness, quantifiers, valuePath } = queryFeatures;
|
|
173
174
|
if (!Array.isArray(operators) || operators.some((operator) => typeof operator !== 'string')) {
|
|
174
175
|
invalidFeatures('operators must be an array of operator names');
|
|
175
176
|
}
|
|
@@ -182,9 +183,12 @@ export const resolveQueryFeatures = (queryFeatures) => {
|
|
|
182
183
|
if (quantifiers === true && referenceEmptiness !== true) {
|
|
183
184
|
invalidFeatures('quantifiers require referenceEmptiness: true');
|
|
184
185
|
}
|
|
186
|
+
if (valuePath != null && typeof valuePath !== 'boolean') invalidFeatures('valuePath must be a boolean');
|
|
187
|
+
if (valuePath === true && referenceEmptiness !== true) invalidFeatures('valuePath requires referenceEmptiness: true');
|
|
185
188
|
return {
|
|
186
189
|
operators: new Set(operators.filter(isQueryOperator)),
|
|
187
190
|
referenceEmptiness: referenceEmptiness === true,
|
|
188
191
|
quantifiers: quantifiers === true,
|
|
192
|
+
valuePath: valuePath === true,
|
|
189
193
|
};
|
|
190
194
|
};
|
package/src/query-plan.js
CHANGED
|
@@ -4,7 +4,7 @@ import { paginationStages } from './query-limits.js';
|
|
|
4
4
|
import SimfinityError from './errors/simfinity.error.js';
|
|
5
5
|
import {
|
|
6
6
|
describeEmptiness, describeModelEmptiness, emptinessMemberPaths, emptinessScopeTargets, isEmptinessOperator, isQueryOperator,
|
|
7
|
-
takesListValue, textQueryOperator, validateContainsAllValue, validateTextFilterValue, validateTextListFilterValue,
|
|
7
|
+
hasValuePath, takesListValue, textQueryOperator, validateComparisonOperand, validateContainsAllValue, validateTextFilterValue, validateTextListFilterValue,
|
|
8
8
|
} from './query-operators.js';
|
|
9
9
|
|
|
10
10
|
const fail = (message, code = 'INVALID_FILTER_VALUE') => { throw new SimfinityError(message, code, 400); };
|
|
@@ -45,7 +45,7 @@ const walkQueryEntries = (args, operation, { paths = false, gqltype, expandMembe
|
|
|
45
45
|
const records = [];
|
|
46
46
|
const open = new Set();
|
|
47
47
|
const childFrame = (parent, path, quantifier, construct, anchor = []) => ({
|
|
48
|
-
base: [...parent.base, ...anchor, ...pathSegments(path)],
|
|
48
|
+
parent, base: [...parent.base, ...anchor, ...pathSegments(path)],
|
|
49
49
|
frames: [...parent.frames, { path, type: quantifier, construct }],
|
|
50
50
|
groups: new Map(),
|
|
51
51
|
});
|
|
@@ -96,7 +96,21 @@ const walkQueryEntries = (args, operation, { paths = false, gqltype, expandMembe
|
|
|
96
96
|
entries.push({ ...entry, segments, relative: segments.slice(frame.base.length), member: true });
|
|
97
97
|
}
|
|
98
98
|
};
|
|
99
|
-
const
|
|
99
|
+
const operandPath = (item, prefix = '') => hasValuePath(item)
|
|
100
|
+
? (prefix ? `${prefix}.${item.valuePath}` : item.valuePath) : undefined;
|
|
101
|
+
const addPredicatePaths = (path, item, frame, container, group = null, prefix = '') => {
|
|
102
|
+
const comparison = hasValuePath(item) || item?.__operand != null;
|
|
103
|
+
addPath(path, frame, container, group, item?.operator ?? 'EQ', comparison ? item : item ?? null,
|
|
104
|
+
comparison ? { role: 'left', comparison: true } : {});
|
|
105
|
+
if (!comparison) return;
|
|
106
|
+
let origin = frame;
|
|
107
|
+
if (item.__operand) {
|
|
108
|
+
for (let up = item.__operand.up; up > 0 && origin.parent; up--) origin = origin.parent;
|
|
109
|
+
}
|
|
110
|
+
const right = item.__operand ? item.__operand.path?.join('.') : operandPath(item, prefix);
|
|
111
|
+
addPath(right, origin, container, group, item.operator ?? 'EQ', item, { role: 'right', comparison: true });
|
|
112
|
+
};
|
|
113
|
+
const addOperator = (item, form, field, rawPath, depth, frame, container, relativePath, quantifier, prefix = '') => {
|
|
100
114
|
if (!isObject(item)) return;
|
|
101
115
|
const quantified = form === 'quantified';
|
|
102
116
|
const relative = pathSegments(relativePath);
|
|
@@ -112,9 +126,11 @@ const walkQueryEntries = (args, operation, { paths = false, gqltype, expandMembe
|
|
|
112
126
|
rawPath,
|
|
113
127
|
value: item.value,
|
|
114
128
|
hasValue: Object.hasOwn(item, 'value'),
|
|
129
|
+
...(hasValuePath(item) ? { hasValuePath: true, valuePath: operandPath(item, prefix) } : {}),
|
|
130
|
+
...(Object.hasOwn(item, '__operand') ? { operand: item.__operand } : {}),
|
|
115
131
|
ambiguous: form === 'condition' && typeof field === 'string' && field.includes('.') && !isPathless(rawPath),
|
|
116
132
|
depth,
|
|
117
|
-
construct: quantified || form === 'term' || form === 'condition' ? item : null,
|
|
133
|
+
construct: quantified || form === 'term' || form === 'condition' || hasValuePath(item) || item.__operand ? item : null,
|
|
118
134
|
emptiness: isEmptinessOperator(operator),
|
|
119
135
|
...(quantified ? { quantifier } : {}),
|
|
120
136
|
});
|
|
@@ -134,7 +150,7 @@ const walkQueryEntries = (args, operation, { paths = false, gqltype, expandMembe
|
|
|
134
150
|
const field = condition?.field;
|
|
135
151
|
const path = conditionPath(field, condition?.path);
|
|
136
152
|
if (paths) {
|
|
137
|
-
if (typeof field === 'string')
|
|
153
|
+
if (typeof field === 'string') addPredicatePaths(path, condition, inner, group, group);
|
|
138
154
|
} else addOperator(condition, 'condition', field, condition?.path, depth, inner, group, path);
|
|
139
155
|
}
|
|
140
156
|
continue;
|
|
@@ -149,7 +165,7 @@ const walkQueryEntries = (args, operation, { paths = false, gqltype, expandMembe
|
|
|
149
165
|
if (paths) {
|
|
150
166
|
// Invalid path-less terms still name the field, as before; validation rejects them
|
|
151
167
|
// before authorization marking. Their relative path names nothing in the child.
|
|
152
|
-
if (hasTermPath(term))
|
|
168
|
+
if (hasTermPath(term)) addPredicatePaths(term.path, term, frame, value);
|
|
153
169
|
else if (isPathless(term.path)) {
|
|
154
170
|
entries.push({ ...metadata([], frame, value), group: null, operator: term.operator ?? 'EQ', construct: term });
|
|
155
171
|
}
|
|
@@ -159,20 +175,21 @@ const walkQueryEntries = (args, operation, { paths = false, gqltype, expandMembe
|
|
|
159
175
|
}
|
|
160
176
|
if (!paths) {
|
|
161
177
|
const hasTerms = Array.isArray(value.terms);
|
|
162
|
-
if (!hasTerms || value.operator !== undefined) addOperator(value, hasTerms ? 'beside-terms' : 'filter', name, undefined, 0, root, value, name);
|
|
163
|
-
if (hasTerms) for (const term of terms) addOperator(term, 'term', name, term?.path, 0, root, value, conditionPath(name, term?.path));
|
|
178
|
+
if (!hasTerms || value.operator !== undefined || hasValuePath(value)) addOperator(value, hasTerms ? 'beside-terms' : 'filter', name, undefined, 0, root, value, name);
|
|
179
|
+
if (hasTerms) for (const term of terms) addOperator(term, 'term', name, term?.path, 0, root, value, conditionPath(name, term?.path), undefined, name);
|
|
164
180
|
continue;
|
|
165
181
|
}
|
|
166
182
|
const pathless = (term) => isEmptinessOperator(term?.operator) && isPathless(term.path);
|
|
167
183
|
if (!terms.some(hasTermPath)) {
|
|
168
184
|
const term = terms.find(pathless);
|
|
169
185
|
if (term) addPath(name, root, value, null, term.operator, term);
|
|
186
|
+
else if (hasValuePath(value)) addPredicatePaths(name, value, root, value);
|
|
170
187
|
else addPath(name, root, value, null, value.operator ?? 'EQ');
|
|
171
188
|
continue;
|
|
172
189
|
}
|
|
173
190
|
let field = false;
|
|
174
191
|
for (const term of terms) {
|
|
175
|
-
if (hasTermPath(term))
|
|
192
|
+
if (hasTermPath(term)) addPredicatePaths(`${name}.${term.path}`, term, root, value, null, name);
|
|
176
193
|
else if (!field && pathless(term)) {
|
|
177
194
|
addPath(name, root, value, null, term.operator, term);
|
|
178
195
|
field = true;
|
|
@@ -198,7 +215,9 @@ const walkQueryEntries = (args, operation, { paths = false, gqltype, expandMembe
|
|
|
198
215
|
* A quantified carrier names its path in the containing frame, with `quantifier` and its own
|
|
199
216
|
* construct; children enter its frame. The legacy `group` remains the conditions owner, otherwise
|
|
200
217
|
* null; operator and construct retain their old meanings except that quantifier filters now carry
|
|
201
|
-
* their own construct.
|
|
218
|
+
* their own construct. Comparisons emit left/right roles with comparison:true and one owning
|
|
219
|
+
* construct, including ordinary field filters; RHS paths preserve their lexical term/frame origin.
|
|
220
|
+
* An omitted filter operator is EQ; non-filter reads and quantified carriers
|
|
202
221
|
* have no operator. `expandMembers` appends stored emptiness member paths, preserving metadata.
|
|
203
222
|
*/
|
|
204
223
|
export const collectQueryPathEntries = (args, operation = 'find', options = {}) => (
|
|
@@ -209,7 +228,8 @@ export const collectQueryPathEntries = (args, operation = 'find', options = {})
|
|
|
209
228
|
* Internal: filter constructs only, with the same frame metadata as the path collector. Preserves
|
|
210
229
|
* form, field, rawPath, value, hasValue, ambiguous, deepest depth and emptiness. Quantified carriers
|
|
211
230
|
* have form `quantified`, operator null and their own construct; ordinary own filter operators keep
|
|
212
|
-
* construct null.
|
|
231
|
+
* construct null. Comparisons add hasValuePath:true and a frame-relative valuePath; their owning
|
|
232
|
+
* construct is always retained. Generated outer comparisons carry operand instead. Explicit groups add a depth level, implicit ones none. Invalid entries are left
|
|
213
233
|
* to the validator; unknown group keys and path on a plain group fail closed here.
|
|
214
234
|
*/
|
|
215
235
|
export const collectQueryOperators = (args, operation = 'find') => walkQueryEntries(args, operation);
|
|
@@ -290,9 +310,12 @@ export const resolveModelPath = (models, entityName, path) => {
|
|
|
290
310
|
* explicit ANY/ALL/NONE and quantifier filters retain their quantifier, and scoped reference
|
|
291
311
|
* crossings inside a frame lift into `joins`. Aggregate collection joins remain legacy; later
|
|
292
312
|
* frames carry their legacy chain in `anchor`. Sorts through collections fail even in count mode.
|
|
313
|
+
* With `valuePath` (the same default as `quantifiers`), comparisons preserve their literal-free
|
|
314
|
+
* value:null placeholder and add operand:{up,path}. Same-frame scoped joins are the union of both
|
|
315
|
+
* paths on the predicate; generated outer operands own their joins in their ancestor frame.
|
|
293
316
|
*/
|
|
294
317
|
export const createQueryPlan = (models, entityName, source = {}, {
|
|
295
|
-
mode = 'find', operators: supported, referenceEmptiness = supported == null, quantifiers = supported == null,
|
|
318
|
+
mode = 'find', operators: supported, referenceEmptiness = supported == null, quantifiers = supported == null, valuePath = supported == null,
|
|
296
319
|
} = {}) => {
|
|
297
320
|
let input = source;
|
|
298
321
|
const allowed = supported == null ? null : new Set(supported);
|
|
@@ -304,19 +327,23 @@ export const createQueryPlan = (models, entityName, source = {}, {
|
|
|
304
327
|
// The client scope marker of a construct, lifted into the predicate's `scopes`: each target scope's
|
|
305
328
|
// arguments are planned on the target type, as trusted find arguments in their own frame, whatever
|
|
306
329
|
// the query's mode, with the operator names already read (`operators` may be a one-time iterable).
|
|
307
|
-
const liftScopes = (marker, targets, segments, operator) => {
|
|
330
|
+
const liftScopes = (marker, targets, segments, operator, anchors = []) => {
|
|
308
331
|
const label = `${operator} on ${segments.join('.')}`;
|
|
309
|
-
const paths = Object.keys(targets);
|
|
332
|
+
const paths = [...Object.keys(targets), ...anchors];
|
|
310
333
|
if (!paths.length) detached(`${label} carries a client scope marker, but reads no reference or collection`);
|
|
311
334
|
const scopes = marker?.scopes;
|
|
312
335
|
if (!scopes || typeof scopes !== 'object' || Array.isArray(scopes)) detached(`${label} carries a client scope marker whose scopes are not filled`);
|
|
313
336
|
const lifted = {};
|
|
314
337
|
for (const path of paths) {
|
|
315
338
|
if (!Object.hasOwn(scopes, path) || scopes[path] === undefined) detached(`${label} carries a client scope marker without the scope of ${path}`);
|
|
339
|
+
if (anchors.includes(path)) {
|
|
340
|
+
if (scopes[path] !== null) detached(`${label} requires a null aggregate anchor scope for ${path}`);
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
316
343
|
if (scopes[path] !== null && (!isObject(scopes[path]) || ![Object.prototype, null].includes(Object.getPrototypeOf(scopes[path])))) detached(`${label} carries a malformed scope of ${path}`);
|
|
317
344
|
lifted[path] = scopes[path] === null
|
|
318
345
|
? null
|
|
319
|
-
: createQueryPlan(models, targets[path], scopes[path], { mode: 'find', operators: allowed ?? undefined, referenceEmptiness: references, quantifiers }).where;
|
|
346
|
+
: createQueryPlan(models, targets[path], scopes[path], { mode: 'find', operators: allowed ?? undefined, referenceEmptiness: references, quantifiers, valuePath }).where;
|
|
320
347
|
}
|
|
321
348
|
return lifted;
|
|
322
349
|
};
|
|
@@ -338,7 +365,7 @@ export const createQueryPlan = (models, entityName, source = {}, {
|
|
|
338
365
|
};
|
|
339
366
|
// `construct` is the term or group condition that holds the predicate, the only objects whose
|
|
340
367
|
// `__scope` is read; null for a field filter's own operator.
|
|
341
|
-
const predicate = (path, operator, value, construct = null, frameEntity = entityName, inFrame = false) => {
|
|
368
|
+
const predicate = (path, operator, value, construct = null, frameEntity = entityName, inFrame = false, ancestors = [], prefix = '') => {
|
|
342
369
|
const steps = resolveModelPath(models, frameEntity, path);
|
|
343
370
|
const op = operator ?? 'EQ';
|
|
344
371
|
if (isEmptinessOperator(op)) return emptiness(path, steps, op, value, construct, frameEntity);
|
|
@@ -348,6 +375,30 @@ export const createQueryPlan = (models, entityName, source = {}, {
|
|
|
348
375
|
}
|
|
349
376
|
if (!isQueryOperator(op)) fail(`Unknown filter operator: ${op}`, 'INVALID_FILTER_OPERATOR');
|
|
350
377
|
if (allowed && !allowed.has(op)) fail(`The ${op} filter operator is not supported by this query compiler`, 'UNSUPPORTED_QUERY');
|
|
378
|
+
const comparison = hasValuePath(construct) || construct?.__operand !== undefined;
|
|
379
|
+
if (comparison) {
|
|
380
|
+
const internal = construct.__operand;
|
|
381
|
+
const rightPath = internal?.path ?? (prefix ? `${prefix}.${validateComparisonOperand(construct)}` : validateComparisonOperand(construct)).split('.');
|
|
382
|
+
const up = internal?.up ?? 0;
|
|
383
|
+
const rightEntity = up ? ancestors[ancestors.length - up] : frameEntity;
|
|
384
|
+
if (!rightEntity) fail('Invalid comparison operand frame', 'INVALID_FILTER_PATH');
|
|
385
|
+
resolveModelPath(models, rightEntity, rightPath);
|
|
386
|
+
const segments = Array.isArray(path) ? path : path.split('.');
|
|
387
|
+
const node = { kind: 'predicate', path: segments, operator: op, value: null, operand: { up, path: rightPath } };
|
|
388
|
+
const scopeTargets = (paths, entity) => Object.fromEntries([...new Set(paths.flatMap((item) => crossingKeys(item, { models, entity })))].map((key) => [key, resolveModelPath(models, entity, key).at(-1).target]));
|
|
389
|
+
// J collections retain their existing aggregate row. Validate their provenance, but only
|
|
390
|
+
// references become scoped joins; an empty collection scope must not lose the marker.
|
|
391
|
+
const anchors = (paths, entity) => [...new Set(paths.flatMap((parts) => resolveModelPath(models, entity, parts)
|
|
392
|
+
.flatMap((step, index) => step.kind === 'collection' ? [parts.slice(0, index + 1).join('.')] : [])))];
|
|
393
|
+
if (hasMarker(construct)) {
|
|
394
|
+
const paths = up ? [segments] : [segments, rightPath];
|
|
395
|
+
node.joins = liftScopes(construct.__scope, scopeTargets(paths, frameEntity), segments, op, anchors(paths, frameEntity));
|
|
396
|
+
}
|
|
397
|
+
if (internal && Object.hasOwn(internal, 'scopes')) {
|
|
398
|
+
node.operand.joins = liftScopes({ scopes: internal.scopes }, scopeTargets([rightPath], rightEntity), rightPath, op, anchors([rightPath], rightEntity));
|
|
399
|
+
}
|
|
400
|
+
return node;
|
|
401
|
+
}
|
|
351
402
|
const collection = takesListValue(op);
|
|
352
403
|
if (value === undefined || (!collection && Array.isArray(value)) || (value === null && !['EQ', 'NE'].includes(op))) fail('Expected a scalar filter value');
|
|
353
404
|
if (collection && Array.isArray(value) && value.some((item) => item == null || Array.isArray(item))) fail('Filter lists require non-null scalar elements');
|
|
@@ -364,11 +415,13 @@ export const createQueryPlan = (models, entityName, source = {}, {
|
|
|
364
415
|
}
|
|
365
416
|
return node;
|
|
366
417
|
};
|
|
418
|
+
const hasComparisonNode = (node) => Boolean(node && (node.operand || hasComparisonNode(node.where)
|
|
419
|
+
|| node.terms?.some(hasComparisonNode)));
|
|
367
420
|
const validateGroup = (value, keys = ['conditions', 'AND', 'OR']) => {
|
|
368
421
|
if (!value || typeof value !== 'object' || Array.isArray(value)) fail('Expected a filter group object');
|
|
369
422
|
for (const key of keys) if (value[key] != null && !Array.isArray(value[key])) fail(`${key} requires an array`);
|
|
370
423
|
};
|
|
371
|
-
const group = (value, depth = -1, frameEntity = entityName, inFrame = false) => {
|
|
424
|
+
const group = (value, depth = -1, frameEntity = entityName, inFrame = false, ancestors = []) => {
|
|
372
425
|
validateGroup(value);
|
|
373
426
|
const level = depth + (value.__implicit === true ? 0 : 1);
|
|
374
427
|
if (level > 5) fail('Filter nesting too deep', 'FILTER_DEPTH_EXCEEDED');
|
|
@@ -376,7 +429,7 @@ export const createQueryPlan = (models, entityName, source = {}, {
|
|
|
376
429
|
for (const condition of value.conditions || []) {
|
|
377
430
|
if (!condition || typeof condition.field !== 'string') fail('Invalid filter path', 'INVALID_FILTER_PATH');
|
|
378
431
|
if (condition.field.includes('.') && condition.path) fail('Filter condition cannot use both dotted field syntax and a separate path', 'AMBIGUOUS_FILTER_FIELD');
|
|
379
|
-
terms.push(predicate(condition.path ? `${condition.field}.${condition.path}` : condition.field, condition.operator, condition.value, condition, frameEntity, inFrame));
|
|
432
|
+
terms.push(predicate(condition.path ? `${condition.field}.${condition.path}` : condition.field, condition.operator, condition.value, condition, frameEntity, inFrame, ancestors));
|
|
380
433
|
}
|
|
381
434
|
if (quantifiers !== true && QUANTIFIED_LISTS.some((key) => value[key] != null)) {
|
|
382
435
|
fail('Quantified groups are not supported by this query compiler', 'UNSUPPORTED_QUERY');
|
|
@@ -385,12 +438,12 @@ export const createQueryPlan = (models, entityName, source = {}, {
|
|
|
385
438
|
const anchor = item.__implicit === true ? item.__anchor : undefined;
|
|
386
439
|
const path = item.path.split('.');
|
|
387
440
|
const target = resolveModelPath(models, frameEntity, [...(anchor ?? []), ...path]).at(-1).target;
|
|
388
|
-
const node = { kind: 'quantified', quantifier, path, where: group(item, level, target, true), scope: null };
|
|
441
|
+
const node = { kind: 'quantified', quantifier, path, where: group(item, level, target, true, [...ancestors, frameEntity]), scope: null };
|
|
389
442
|
if (anchor?.length) node.anchor = anchor;
|
|
390
443
|
if (hasMarker(item)) {
|
|
391
444
|
const fullPath = [...(anchor ?? []), ...path];
|
|
392
445
|
const key = fullPath.join('.');
|
|
393
|
-
const crossings = anchor?.length ? [] : crossingKeys(
|
|
446
|
+
const crossings = anchor?.length && !hasComparisonNode(node.where) ? [] : crossingKeys(fullPath, { models, entity: frameEntity });
|
|
394
447
|
const targets = Object.fromEntries(crossings.map((prefix) => [prefix, resolveModelPath(models, frameEntity, prefix).at(-1).target]));
|
|
395
448
|
targets[key] = target;
|
|
396
449
|
const scopes = liftScopes(item.__scope, targets, fullPath, quantifier);
|
|
@@ -399,17 +452,18 @@ export const createQueryPlan = (models, entityName, source = {}, {
|
|
|
399
452
|
for (const prefix of J) if (key.startsWith(`${prefix}.`)) {
|
|
400
453
|
if (!Object.hasOwn(item.__scope.scopes, prefix) || item.__scope.scopes[prefix] !== null) detached(`${quantifier} on ${key} requires a null aggregate anchor scope for ${prefix}`);
|
|
401
454
|
}
|
|
402
|
-
}
|
|
455
|
+
}
|
|
456
|
+
if (crossings.length) node.joins = Object.fromEntries(crossings.map((prefix) => [prefix, scopes[prefix]]));
|
|
403
457
|
}
|
|
404
458
|
terms.push(node);
|
|
405
459
|
}
|
|
406
|
-
for (const item of value.AND || []) terms.push(group(item, level, frameEntity, inFrame));
|
|
407
|
-
terms.push(combine('or', (value.OR || []).map((item) => group(item, level, frameEntity, inFrame))));
|
|
460
|
+
for (const item of value.AND || []) terms.push(group(item, level, frameEntity, inFrame, ancestors));
|
|
461
|
+
terms.push(combine('or', (value.OR || []).map((item) => group(item, level, frameEntity, inFrame, ancestors))));
|
|
408
462
|
return combine('and', terms);
|
|
409
463
|
};
|
|
410
464
|
// Validate received arguments in filled-marker mode before the pure, frame-producing rewrite.
|
|
411
465
|
validateGroup(input, ['AND', 'OR']);
|
|
412
|
-
const context = { models, entity: entityName, operation: mode, operators: allowed, referenceEmptiness: references, quantifiers };
|
|
466
|
+
const context = { models, entity: entityName, operation: mode, operators: allowed, referenceEmptiness: references, quantifiers, valuePath };
|
|
413
467
|
const J = mode === 'aggregate' ? aggregateJoinPrefixes(input.aggregation, context) : new Set();
|
|
414
468
|
context.J = J;
|
|
415
469
|
validateQuantifiedGroups(input, context);
|
|
@@ -428,7 +482,7 @@ export const createQueryPlan = (models, entityName, source = {}, {
|
|
|
428
482
|
+ `${name}: { terms: [{ operator: ${value.operator} }] }`, 'MISSING_FILTER_PATH');
|
|
429
483
|
}
|
|
430
484
|
if (object && (!Array.isArray(value.terms) || !value.terms.length)) fail('Object filters require non-empty terms', 'MISSING_FILTER_PATH');
|
|
431
|
-
if (!object) terms.push(predicate(name, value.operator, value.value));
|
|
485
|
+
if (!object) terms.push(predicate(name, value.operator, value.value, hasValuePath(value) || value.__operand ? value : null));
|
|
432
486
|
else for (const term of value.terms) {
|
|
433
487
|
if (!term || typeof term !== 'object' || Array.isArray(term)) fail('Expected a relationship filter term');
|
|
434
488
|
if (isEmptinessOperator(term.operator) && isPathless(term.path)) {
|
|
@@ -436,7 +490,7 @@ export const createQueryPlan = (models, entityName, source = {}, {
|
|
|
436
490
|
continue;
|
|
437
491
|
}
|
|
438
492
|
if (typeof term.path !== 'string' || !term.path) fail('Relationship terms require a path', 'INVALID_FILTER_PATH');
|
|
439
|
-
terms.push(predicate(`${name}.${term.path}`, term.operator, term.value, term));
|
|
493
|
+
terms.push(predicate(`${name}.${term.path}`, term.operator, term.value, term, entityName, false, [], name));
|
|
440
494
|
}
|
|
441
495
|
}
|
|
442
496
|
for (const item of input.AND || []) terms.push(group(item));
|
package/src/runtime.js
CHANGED
|
@@ -26,7 +26,7 @@ import QLSort from './const/QLSort.js';
|
|
|
26
26
|
import {
|
|
27
27
|
collectQueryOperators, collectQueryPathEntries, walkQueryPath,
|
|
28
28
|
} from './query-plan.js';
|
|
29
|
-
import { QUERY_OPERATORS, isQueryOperator, resolveQueryFeatures } from './query-operators.js';
|
|
29
|
+
import { QUERY_OPERATORS, hasValuePath, isQueryOperator, resolveQueryFeatures } from './query-operators.js';
|
|
30
30
|
import {
|
|
31
31
|
assertEmptinessPath, describeEmptiness, emptinessScopeTargets, isEmptinessOperator,
|
|
32
32
|
} from './query-emptiness.js';
|
|
@@ -210,6 +210,7 @@ const prefixScopeGroup = (group, prefix, copyQuantifiedGroup) => {
|
|
|
210
210
|
field: `${prefix}.${condition?.path != null && condition.path !== '' ? `${condition.field}.${condition.path}` : condition?.field}`,
|
|
211
211
|
operator: condition?.operator,
|
|
212
212
|
value: condition?.value,
|
|
213
|
+
...(hasValuePath(condition) ? { valuePath: `${prefix}.${condition.valuePath}` } : {}),
|
|
213
214
|
}));
|
|
214
215
|
}
|
|
215
216
|
if (group.AND != null) prefixed.AND = [].concat(group.AND).map((item) => prefixScopeGroup(item, prefix, copyQuantifiedGroup));
|
|
@@ -243,9 +244,13 @@ const prefixScopeArgs = (scopeArgs, prefix, typeName, copyQuantifiedGroup) => {
|
|
|
243
244
|
// An emptiness term without a path (omitted, null or "") tests the field itself; other
|
|
244
245
|
// operators keep the path as written.
|
|
245
246
|
const itself = isEmptinessOperator(term?.operator) && (term.path == null || term.path === '');
|
|
246
|
-
conditions.push({ field: itself ? `${prefix}.${name}` : `${prefix}.${name}.${term?.path}`, operator: term?.operator, value: term?.value
|
|
247
|
+
conditions.push({ field: itself ? `${prefix}.${name}` : `${prefix}.${name}.${term?.path}`, operator: term?.operator, value: term?.value,
|
|
248
|
+
...(hasValuePath(term) ? { valuePath: `${prefix}.${name}.${term.valuePath}` } : {}),
|
|
249
|
+
});
|
|
247
250
|
}
|
|
248
|
-
} else conditions.push({ field: `${prefix}.${name}`, operator: value.operator, value: value.value
|
|
251
|
+
} else conditions.push({ field: `${prefix}.${name}`, operator: value.operator, value: value.value,
|
|
252
|
+
...(hasValuePath(value) ? { valuePath: `${prefix}.${value.valuePath}` } : {}),
|
|
253
|
+
});
|
|
249
254
|
}
|
|
250
255
|
return conditions.length ? [{ conditions }, ...groups] : groups;
|
|
251
256
|
};
|
|
@@ -599,7 +604,7 @@ export const createRuntime = (adapter) => {
|
|
|
599
604
|
// IS_EMPTY and IS_NOT_EMPTY through references and collections (#108 part 2b), and a client's
|
|
600
605
|
// conditions of that kind carry a scope marker from the runtime (see markClientConstructs).
|
|
601
606
|
const {
|
|
602
|
-
operators: supportedOperators, referenceEmptiness: supportsReferenceEmptiness, quantifiers: supportsQuantifiers,
|
|
607
|
+
operators: supportedOperators, referenceEmptiness: supportsReferenceEmptiness, quantifiers: supportsQuantifiers, valuePath: supportsValuePath,
|
|
603
608
|
} = resolveQueryFeatures(adapter.queryFeatures);
|
|
604
609
|
const supportsEveryOperator = QUERY_OPERATORS.every((operator) => supportedOperators.has(operator));
|
|
605
610
|
// The write features the adapter implements, read once: with optimisticConcurrency, schemas may
|
|
@@ -735,6 +740,7 @@ const QLFilter = new GraphQLInputObjectType({
|
|
|
735
740
|
fields: () => ({
|
|
736
741
|
operator: { type: QLOperator },
|
|
737
742
|
value: { type: QLValue },
|
|
743
|
+
valuePath: { type: GraphQLString, description: 'Compare with a stored field path; mutually exclusive with value.' },
|
|
738
744
|
}),
|
|
739
745
|
});
|
|
740
746
|
|
|
@@ -743,6 +749,7 @@ const QLTypeFilter = new GraphQLInputObjectType({
|
|
|
743
749
|
fields: () => ({
|
|
744
750
|
operator: { type: QLOperator },
|
|
745
751
|
value: { type: QLValue },
|
|
752
|
+
valuePath: { type: GraphQLString, description: 'Compare with a stored field path; mutually exclusive with value.' },
|
|
746
753
|
path: { type: GraphQLString },
|
|
747
754
|
}),
|
|
748
755
|
});
|
|
@@ -768,6 +775,7 @@ const QLFilterCondition = new GraphQLInputObjectType({
|
|
|
768
775
|
field: { type: new GraphQLNonNull(GraphQLString) },
|
|
769
776
|
operator: { type: QLOperator },
|
|
770
777
|
value: { type: QLValue },
|
|
778
|
+
valuePath: { type: GraphQLString, description: 'Compare with a stored field path; mutually exclusive with value.' },
|
|
771
779
|
path: { type: GraphQLString },
|
|
772
780
|
}),
|
|
773
781
|
});
|
|
@@ -2291,13 +2299,13 @@ const inspectClientPaths = (gqltype, args, operation, { checkQueryable = true }
|
|
|
2291
2299
|
const J = operation === 'aggregate' ? aggregateJoinPrefixes(args.aggregation, { gqltype }) : new Set();
|
|
2292
2300
|
const entries = collectQueryPathEntries(args, operation, { gqltype, expandMembers: true });
|
|
2293
2301
|
for (const {
|
|
2294
|
-
segments, group, member, operator, use, frames, quantifier,
|
|
2302
|
+
segments, group, member, operator, use, frames, quantifier, comparison,
|
|
2295
2303
|
} of entries) {
|
|
2296
2304
|
const filter = use === 'filter';
|
|
2297
2305
|
const relations = filter && supportsQuantifiers ? pathRelations(gqltype, segments) : [];
|
|
2298
2306
|
const cut = relations.find((relation) => relation.collection && !J.has(relation.path));
|
|
2299
2307
|
const anchored = cut && relations.some((relation) => relation.collection && J.has(relation.path));
|
|
2300
|
-
const joins = !member && !(supportsReferenceEmptiness && isEmptinessOperator(operator))
|
|
2308
|
+
const joins = !member && !comparison && !(supportsReferenceEmptiness && isEmptinessOperator(operator))
|
|
2301
2309
|
&& !(filter && (frames.length || quantifier || (cut && !anchored)));
|
|
2302
2310
|
walkQueryPath(gqltype, segments, ({ type, fieldName, field, index }) => {
|
|
2303
2311
|
const path = segments.slice(0, index + 1).join('.');
|
|
@@ -2400,7 +2408,7 @@ const assertEmptinessForm = (gqltype, {
|
|
|
2400
2408
|
// carriers, which do not become predicates but must not use known undeclared capabilities.
|
|
2401
2409
|
const queryFrameContext = (gqltype, args, operation) => ({
|
|
2402
2410
|
gqltype, operation, quantifiers: supportsQuantifiers, referenceEmptiness: supportsReferenceEmptiness,
|
|
2403
|
-
operators: supportedOperators,
|
|
2411
|
+
operators: supportedOperators, valuePath: supportsValuePath,
|
|
2404
2412
|
J: operation === 'aggregate' ? aggregateJoinPrefixes(args.aggregation, { gqltype }) : new Set(),
|
|
2405
2413
|
scopeMarkers: 'pending',
|
|
2406
2414
|
});
|
|
@@ -2614,8 +2622,9 @@ const markClientConstructs = (checked, { aggregation, issued = new Map() } = {})
|
|
|
2614
2622
|
};
|
|
2615
2623
|
for (const { entry, shape } of conditions) {
|
|
2616
2624
|
const { construct } = entry;
|
|
2617
|
-
if (!construct || !(shape || entry.form === 'quantified' || ordinaryFrameCarrier(gqltype, entry, clientJ))) continue;
|
|
2618
|
-
const
|
|
2625
|
+
if (!construct || !(shape || entry.hasValuePath || entry.form === 'quantified' || ordinaryFrameCarrier(gqltype, entry, clientJ))) continue;
|
|
2626
|
+
const comparisonPath = entry.hasValuePath ? [...entry.segments.slice(0, -entry.relative.length), ...entry.valuePath.split('.')] : [];
|
|
2627
|
+
const targets = [...pathRelations(gqltype, entry.segments), ...pathRelations(gqltype, comparisonPath)].map(({ typeName }) => typeName);
|
|
2619
2628
|
if (shape) targets.push(...Object.values(emptinessScopeTargets(shape, entry.relative)));
|
|
2620
2629
|
if (!targets.some((name) => getScope(typesDict.types[name]?.gqltype, 'find'))) continue;
|
|
2621
2630
|
const origin = { operator: entry.operator ?? entry.quantifier, path: entry.path };
|
|
@@ -2658,7 +2667,8 @@ const assertClientMarkers = (gqltype, args, operation, issued, clientJ) => {
|
|
|
2658
2667
|
throw detachedClientFilter(`The client's ${origin.operator} condition on ${origin.path} is inside a trusted frame`);
|
|
2659
2668
|
}
|
|
2660
2669
|
if (supportsQuantifiers && !entry.frames.length && entry.form !== 'quantified' && !isEmptinessOperator(operator)) {
|
|
2661
|
-
|
|
2670
|
+
const operandPaths = [entry.segments, ...(entry.hasValuePath ? [[...entry.segments.slice(0, -entry.relative.length), ...entry.valuePath.split('.')]] : [])];
|
|
2671
|
+
for (const relation of operandPaths.flatMap((parts) => pathRelations(gqltype, parts))) if (relation.collection && clientJ.has(relation.path) !== J.has(relation.path)) {
|
|
2662
2672
|
throw detachedClientFilter(`The client's ${origin.operator} condition on ${origin.path} changed aggregate join prefixes`);
|
|
2663
2673
|
}
|
|
2664
2674
|
}
|
|
@@ -2735,7 +2745,11 @@ const fillClientScopes = async (gqltype, checked, context, issued) => {
|
|
|
2735
2745
|
const vectors = [];
|
|
2736
2746
|
for (const { entry, shape, type } of positions) {
|
|
2737
2747
|
const relations = pathRelations(type, entry.relative);
|
|
2738
|
-
if (
|
|
2748
|
+
if (entry.hasValuePath) {
|
|
2749
|
+
const existing = new Set(relations.map(({ path }) => path));
|
|
2750
|
+
relations.push(...pathRelations(type, entry.valuePath.split('.')).filter(({ path }) => !existing.has(path)));
|
|
2751
|
+
}
|
|
2752
|
+
if (!shape && !entry.hasValuePath && entry.form !== 'quantified' && !ordinaryFrameCarrier(gqltype, entry, J)) {
|
|
2739
2753
|
const relation = relationOnPath(gqltype, entry.segments);
|
|
2740
2754
|
if (relation) throw detachedClientFilter(`The client's ${origin.operator} condition on ${origin.path} now uses ${entry.operator} on `
|
|
2741
2755
|
+ `${entry.path}, which reads through ${relation} without its scope`);
|
|
@@ -2744,7 +2758,7 @@ const fillClientScopes = async (gqltype, checked, context, issued) => {
|
|
|
2744
2758
|
const anchored = rootShorthand && relations.some(({ collection, path }) => collection && J.has(path));
|
|
2745
2759
|
const cut = anchored ? relations.find(({ collection, path }) => collection && !J.has(path)) : null;
|
|
2746
2760
|
const keys = shape ? emptinessScopeTargets(shape, entry.relative) : Object.fromEntries(relations
|
|
2747
|
-
.filter((relation) => !anchored || relation.collection || (cut && relation.index >= cut.index))
|
|
2761
|
+
.filter((relation) => entry.hasValuePath || !anchored || relation.collection || (cut && relation.index >= cut.index))
|
|
2748
2762
|
.map(({ path, typeName, collection }) => [path, rootShorthand && collection && J.has(path) ? null : typeName]));
|
|
2749
2763
|
for (const [key, target] of Object.entries(keys)) {
|
|
2750
2764
|
if (Object.hasOwn(targets, key) && targets[key] !== target) {
|
package/types/index.d.ts
CHANGED
|
@@ -77,6 +77,8 @@ export interface QLFilterCondition {
|
|
|
77
77
|
field: string;
|
|
78
78
|
operator?: QueryOperator | null;
|
|
79
79
|
value?: any;
|
|
80
|
+
/** A stored field path, mutually exclusive with a supplied value; null preserves literal behavior. */
|
|
81
|
+
valuePath?: string | null;
|
|
80
82
|
path?: string | null;
|
|
81
83
|
}
|
|
82
84
|
|
|
@@ -101,6 +103,8 @@ export interface QLTypeFilterExpression {
|
|
|
101
103
|
terms?: Array<{
|
|
102
104
|
operator?: QueryOperator | null;
|
|
103
105
|
value?: any;
|
|
106
|
+
/** A stored field path, mutually exclusive with a supplied value; null preserves literal behavior. */
|
|
107
|
+
valuePath?: string | null;
|
|
104
108
|
path?: string | null;
|
|
105
109
|
} | null> | null;
|
|
106
110
|
}
|
|
@@ -115,6 +119,8 @@ export interface FilterCondition {
|
|
|
115
119
|
field: string;
|
|
116
120
|
operator?: QueryOperator | (string & {});
|
|
117
121
|
value?: any;
|
|
122
|
+
/** A stored field path, mutually exclusive with a supplied value; null preserves literal behavior. */
|
|
123
|
+
valuePath?: string | null;
|
|
118
124
|
path?: string;
|
|
119
125
|
}
|
|
120
126
|
|
|
@@ -156,6 +162,8 @@ export interface QueryFeatures {
|
|
|
156
162
|
* legacy joins. All adapters reject list sorts through collections, including count mode.
|
|
157
163
|
*/
|
|
158
164
|
quantifiers?: boolean | null | undefined;
|
|
165
|
+
/** Field operands, including scoped references. Requires referenceEmptiness: true. */
|
|
166
|
+
valuePath?: boolean | null | undefined;
|
|
159
167
|
}
|
|
160
168
|
|
|
161
169
|
export interface ModelRegistration {
|
|
@@ -176,6 +184,10 @@ export interface FieldDescription {
|
|
|
176
184
|
scalar?: 'ID' | 'String' | 'Int' | 'Float' | 'Boolean' | 'DateTime' | 'Enum' | 'Version';
|
|
177
185
|
values?: string[];
|
|
178
186
|
enumValues?: { name: string; value: unknown }[];
|
|
187
|
+
/** Logical GraphQL enum identity, required for field comparisons. */
|
|
188
|
+
enumName?: string;
|
|
189
|
+
/** Effective temporal base before storage normalizes Date and Time to DateTime. */
|
|
190
|
+
temporalName?: 'DateTime' | 'Date' | 'Time';
|
|
179
191
|
target?: string;
|
|
180
192
|
connectionField?: string;
|
|
181
193
|
fields?: FieldDescription[];
|
|
@@ -897,6 +909,9 @@ export function createRuntime<Model = any, Session = any>(adapter: DatabaseAdapt
|
|
|
897
909
|
/** Scoped single-reference joins, keyed by a dotted prefix relative to the containing frame. */
|
|
898
910
|
export type QueryJoins = Record<string, QueryPredicate | null>;
|
|
899
911
|
|
|
912
|
+
/** A field operand in the same frame (up: 0) or an enclosing shorthand frame. */
|
|
913
|
+
export type QueryOperand = { up: number; path: string[]; joins?: QueryJoins };
|
|
914
|
+
|
|
900
915
|
/**
|
|
901
916
|
* `operator` is a {@link QueryOperator}; the value of a text operator is a validated string. For
|
|
902
917
|
* `CONTAINS_ALL` the value is a non-empty array of non-null scalars, validated and not cast; the
|
|
@@ -918,10 +933,11 @@ export type QueryJoins = Record<string, QueryPredicate | null>;
|
|
|
918
933
|
* A `quantified` node selects elements with `ANY`, `ALL` or `NONE`, applying `scope` before `where`;
|
|
919
934
|
* both predicates use paths relative to the element, and null means no restriction. Empty elements
|
|
920
935
|
* give ANY false, ALL/NONE true; scope is never negated, and frames never multiply roots. `anchor` is
|
|
921
|
-
* an aggregate's legacy join chain; when present, `path` is relative to it
|
|
936
|
+
* an aggregate's legacy join chain; when present, `path` is relative to it. Comparison-bearing
|
|
937
|
+
* anchored frames may carry full-prefix joins as visibility guards, preserving the existing J row.
|
|
922
938
|
*/
|
|
923
939
|
export type QueryPredicate =
|
|
924
|
-
| { kind: 'predicate'; path: string[]; operator: string; value: any; scopes?: Record<string, QueryPredicate | null>; joins?: QueryJoins }
|
|
940
|
+
| { kind: 'predicate'; path: string[]; operator: string; value: any; operand?: QueryOperand; scopes?: Record<string, QueryPredicate | null>; joins?: QueryJoins }
|
|
925
941
|
| { kind: 'and' | 'or'; terms: QueryPredicate[] }
|
|
926
942
|
| {
|
|
927
943
|
kind: 'quantified';
|
|
@@ -975,6 +991,13 @@ export interface QueryPlan {
|
|
|
975
991
|
* `quantifiers`, a row filter on an aggregate's join prefix, a legacy join otherwise. `CONTAINS_ALL`
|
|
976
992
|
* is never cut into a frame.
|
|
977
993
|
*
|
|
994
|
+
* `valuePath` selects a compatible stored scalar operand instead of a literal value. It defaults
|
|
995
|
+
* like `quantifiers`; a comparison node has `value: null` and `operand: { up, path, joins? }`.
|
|
996
|
+
* Same-frame operands use the union of scoped paths in `joins`; internal shorthand outer bindings
|
|
997
|
+
* use positive `up` and their own operand joins. Public inputs do not accept outer-frame syntax.
|
|
998
|
+
* Null equality is two-valued, and a left scalar list uses null-safe EQ/NE membership.
|
|
999
|
+
* Embedded-list comparisons, right scalar lists and incompatible logical types fail explicitly.
|
|
1000
|
+
*
|
|
978
1001
|
* `referenceEmptiness` defaults to true when `operators` is omitted and to false when it is given.
|
|
979
1002
|
* When it is false, an emptiness condition that crosses or ends at a reference or a collection, or
|
|
980
1003
|
* reads a reference or collection member, fails with `UNSUPPORTED_QUERY` where the order above has
|
|
@@ -995,7 +1018,7 @@ export interface QueryPlan {
|
|
|
995
1018
|
* checked before the path rule, the rest after it.
|
|
996
1019
|
* A marker not filled for every path fails with `DETACHED_CLIENT_FILTER` (500).
|
|
997
1020
|
*/
|
|
998
|
-
export function createQueryPlan(models: ModelDescription, entityName: string, input?: Record<string, any>, options?: { mode?: QueryPlan['mode']; operators?: Iterable<string>; referenceEmptiness?: boolean; quantifiers?: boolean }): QueryPlan;
|
|
1021
|
+
export function createQueryPlan(models: ModelDescription, entityName: string, input?: Record<string, any>, options?: { mode?: QueryPlan['mode']; operators?: Iterable<string>; referenceEmptiness?: boolean; quantifiers?: boolean; valuePath?: boolean }): QueryPlan;
|
|
999
1022
|
export function resolveModelPath(models: ModelDescription, entityName: string, path: string | string[]): FieldDescription[];
|
|
1000
1023
|
|
|
1001
1024
|
/**
|