@simtlix/simfinity-core 3.16.0 → 3.18.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 +4 -0
- package/package.json +1 -1
- package/src/metadata.js +2 -2
- package/src/query-comparisons.js +54 -0
- package/src/query-context.js +117 -0
- package/src/query-frames.js +188 -85
- package/src/query-operators.js +16 -6
- package/src/query-plan.js +105 -36
- package/src/runtime.js +39 -17
- package/types/index.d.ts +36 -3
package/README.md
CHANGED
|
@@ -84,3 +84,7 @@ 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).
|
|
89
|
+
|
|
90
|
+
Version 3.18.0 adds `queryFeatures.elementFrames: true` for embedded object `ANY`/`ALL`/`NONE` and structural `$parent` operands. It requires `quantifiers`, `referenceEmptiness` and `valuePath`, all true. Missing/null lists and null items contribute no elements; `ALL` and `NONE` are true without elements. Parent traversal includes single embedded containers and stops at entity boundaries. Filters preserve selected arrays and outer row cardinality. See [the syntax and examples](https://simtlix.github.io/simfinity.js/guide/queries.html#embedded-object-lists-and-parent); versions through 3.17.0 do not support this extension.
|
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 (!/^(?:\$parent\.)*[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/.test(carrier.valuePath)) {
|
|
18
|
+
fail('valuePath requires a field path optionally preceded by embedded $parent prefixes', '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
|
+
};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { getNamedType, isEnumType, isObjectType } from 'graphql';
|
|
2
|
+
import SimfinityError from './errors/simfinity.error.js';
|
|
3
|
+
import { effectiveScalarName } from './metadata.js';
|
|
4
|
+
import { isVirtualField } from './relation-storage.js';
|
|
5
|
+
import { graphqlStep, modelStep } from './query-emptiness.js';
|
|
6
|
+
|
|
7
|
+
const fail = (message, code = 'INVALID_FILTER_PATH') => { throw new SimfinityError(message, code, 400); };
|
|
8
|
+
const relation = (step) => ['reference', 'collection'].includes(step.kind);
|
|
9
|
+
const partsOf = (path) => (Array.isArray(path) ? path : typeof path === 'string' ? path.split('.') : []);
|
|
10
|
+
const bindingOf = (ctx) => ctx.binding ?? { depth: 0, path: [] };
|
|
11
|
+
|
|
12
|
+
const childContext = (ctx, step) => ({
|
|
13
|
+
...ctx,
|
|
14
|
+
...(ctx.gqltype ? { gqltype: step.node } : { entity: step.target ?? ctx.entity, fields: step.node }),
|
|
15
|
+
parent: ['embedded', 'embeddedList'].includes(step.kind) ? ctx : undefined,
|
|
16
|
+
absolute: [...(ctx.absolute ?? []), step.name],
|
|
17
|
+
binding: { depth: bindingOf(ctx).depth, path: [...bindingOf(ctx).path, step.name] },
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
// One schema-path resolver for these operations, using the same step classifications as emptiness.
|
|
21
|
+
// Model contexts retain embedded fields locally; relation steps switch to the target entity.
|
|
22
|
+
export const resolveQueryContext = (path, ctx, { onStep, virtualLabel, ordinaryCondition = false } = {}) => {
|
|
23
|
+
const parts = partsOf(path);
|
|
24
|
+
const invalid = parts.findIndex((part) => typeof part !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(part));
|
|
25
|
+
if (!parts.length || invalid !== -1) {
|
|
26
|
+
if (ordinaryCondition && invalid > 0) fail(`Invalid filter path segment: "${parts[invalid]}"`, 'INVALID_FILTER_PATH');
|
|
27
|
+
fail('Invalid filter path', 'INVALID_FILTER_PATH');
|
|
28
|
+
}
|
|
29
|
+
let current = ctx;
|
|
30
|
+
const steps = [];
|
|
31
|
+
const raw = [];
|
|
32
|
+
for (const [index, name] of parts.entries()) {
|
|
33
|
+
let step;
|
|
34
|
+
if (ctx.gqltype) {
|
|
35
|
+
const fields = isObjectType(current.gqltype) ? current.gqltype.getFields() : null;
|
|
36
|
+
const field = fields && Object.hasOwn(fields, name) ? fields[name] : null;
|
|
37
|
+
if (!field) fail(ordinaryCondition && index === 0 ? `Unknown filter field: ${name}`
|
|
38
|
+
: `Unknown field ${parts.slice(0, index + 1).join('.')}`, 'INVALID_FILTER_FIELD');
|
|
39
|
+
if (isVirtualField(field)) {
|
|
40
|
+
const label = virtualLabel?.(field) ?? parts.join('.');
|
|
41
|
+
fail(`Query path ${label} uses the virtual field ${current.gqltype.name}.${name}, which is not stored`, 'INVALID_FILTER_FIELD');
|
|
42
|
+
}
|
|
43
|
+
step = graphqlStep(name, field);
|
|
44
|
+
const type = getNamedType(field.type);
|
|
45
|
+
step.scalar = isEnumType(type) ? 'Enum' : effectiveScalarName(type);
|
|
46
|
+
step.enumName = isEnumType(type) ? type.name : undefined;
|
|
47
|
+
step.temporalName = ['Date', 'Time', 'DateTime'].includes(step.scalar) ? step.scalar : undefined;
|
|
48
|
+
step.version = step.scalar === 'RecordVersion' || step.scalar === 'Version';
|
|
49
|
+
raw.push(field);
|
|
50
|
+
} else {
|
|
51
|
+
const fields = current.fields ?? current.models?.entities.find((entity) => entity.name === current.entity)?.fields;
|
|
52
|
+
const field = fields?.find((item) => item.name === name && !item.private);
|
|
53
|
+
if (!field) fail(`Unknown field ${parts.slice(0, index + 1).join('.')}`, 'INVALID_FILTER_FIELD');
|
|
54
|
+
step = { ...field, ...modelStep(field) };
|
|
55
|
+
if (relation(step)) step.node = ctx.models?.entities.find((entity) => entity.name === step.target)?.fields;
|
|
56
|
+
raw.push(field);
|
|
57
|
+
}
|
|
58
|
+
onStep?.(step, current);
|
|
59
|
+
steps.push(step);
|
|
60
|
+
if (index < parts.length - 1 && !step.node) fail(`Field ${name} cannot be traversed`, 'INVALID_FILTER_PATH');
|
|
61
|
+
current = childContext(current, step);
|
|
62
|
+
}
|
|
63
|
+
return { steps, raw, end: current };
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** Bind the endpoint while retaining its actual structural containers. */
|
|
67
|
+
export const enterQueryFrame = (context, path) => {
|
|
68
|
+
const { end } = resolveQueryContext(path, context);
|
|
69
|
+
return { ...end, frameParent: context, binding: { depth: bindingOf(context).depth + 1, path: [] } };
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** Resolve public comparison paths from their filter carrier, before normalization. */
|
|
73
|
+
export const resolveComparisonContext = (valuePath, context, prefix = '') => {
|
|
74
|
+
const parts = partsOf(valuePath);
|
|
75
|
+
if (parts[0] !== '$parent') {
|
|
76
|
+
return { resolved: resolveQueryContext([...(prefix ? partsOf(prefix) : []), ...parts], context), context };
|
|
77
|
+
}
|
|
78
|
+
let current = prefix ? resolveQueryContext(prefix, context).end : context;
|
|
79
|
+
while (parts[0] === '$parent') {
|
|
80
|
+
if (!current.parent) fail('$parent requires an embedded containing object');
|
|
81
|
+
current = current.parent;
|
|
82
|
+
parts.shift();
|
|
83
|
+
}
|
|
84
|
+
const resolved = resolveQueryContext(parts, current);
|
|
85
|
+
const binding = bindingOf(current);
|
|
86
|
+
return { resolved, context: current, path: parts,
|
|
87
|
+
container: { up: bindingOf(context).depth - binding.depth, path: binding.path } };
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/** A structural operand may address only one of this context's bound containers. */
|
|
91
|
+
export const resolveContainerContext = (container, context) => {
|
|
92
|
+
if (!container || typeof container !== 'object' || Array.isArray(container)
|
|
93
|
+
|| Object.keys(container).some((key) => !['up', 'path'].includes(key))
|
|
94
|
+
|| !Number.isSafeInteger(container.up) || container.up < 0 || !Array.isArray(container.path)
|
|
95
|
+
|| container.path.some((part) => typeof part !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(part))) {
|
|
96
|
+
fail('Invalid embedded comparison container');
|
|
97
|
+
}
|
|
98
|
+
for (let parent = context.parent; parent; parent = parent.parent) {
|
|
99
|
+
const binding = bindingOf(parent);
|
|
100
|
+
if (bindingOf(context).depth - binding.depth === container.up
|
|
101
|
+
&& binding.path.length === container.path.length && binding.path.every((part, index) => part === container.path[index])) return parent;
|
|
102
|
+
}
|
|
103
|
+
fail('Comparison container is not an embedded ancestor');
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/** Recover a collector entry's bound context without inferring parents from dotted strings. */
|
|
107
|
+
export const queryEntryContext = (entry, context) => {
|
|
108
|
+
let current = context;
|
|
109
|
+
for (const frame of entry.frames) {
|
|
110
|
+
const anchor = frame.construct.__implicit === true && Array.isArray(frame.construct.__anchor) ? frame.construct.__anchor : [];
|
|
111
|
+
current = enterQueryFrame(current, [...anchor, ...partsOf(frame.path)]);
|
|
112
|
+
}
|
|
113
|
+
return current;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/** Distinct marker namespace for a public parent operand before its binding is normalized. */
|
|
117
|
+
export const parentScopePrefix = (valuePath) => typeof valuePath === 'string' ? valuePath.match(/^(?:\$parent\.)+/)?.[0] ?? '' : '';
|