@simtlix/simfinity-core 3.14.0 → 3.16.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 +31 -4
- package/package.json +12 -2
- package/src/after-commit.js +83 -0
- package/src/auth/index.js +97 -36
- package/src/auth/operation-entries.js +111 -0
- package/src/const/QLOperator.js +2 -0
- package/src/execution-state.js +71 -0
- package/src/introspection-config.js +17 -0
- package/src/introspection-state.js +10 -0
- package/src/introspection.js +5 -3
- package/src/mutation-limits.js +37 -7
- package/src/operation-input.js +345 -0
- package/src/query-frames.js +42 -7
- package/src/query-operators.js +90 -6
- package/src/query-plan.js +15 -4
- package/src/runtime.js +722 -55
- package/src/schema-registrations.js +23 -0
- package/src/transaction-callbacks.js +34 -0
- package/src/transaction-references.js +259 -0
- package/src/transaction-schema.js +163 -0
- package/types/index.d.ts +186 -17
- package/types/introspection.d.ts +4 -0
package/src/query-operators.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import QLOperator from './const/QLOperator.js';
|
|
2
2
|
import SimfinityError from './errors/simfinity.error.js';
|
|
3
3
|
|
|
4
|
-
// Internal: the filter operators and the value rules every backend applies to them
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
4
|
+
// Internal: the filter operators and the value rules every backend applies to them, and the path
|
|
5
|
+
// and leaf rules of the list operator CONTAINS_ALL (#108, part 4). They are shared by the core
|
|
6
|
+
// runtime, the query plan and the backend compilers through the `./internal/query-operators`
|
|
7
|
+
// subpath, which is not public API. The emptiness operators live in query-emptiness.js and are
|
|
8
|
+
// re-exported here, so the subpath stays the only one.
|
|
8
9
|
|
|
9
10
|
export {
|
|
10
11
|
EMPTINESS_QUERY_OPERATORS, describeEmptiness, describeModelEmptiness, emptinessMemberPaths, emptinessScopePaths,
|
|
@@ -28,7 +29,9 @@ export const LEGACY_QUERY_OPERATORS = Object.freeze(['EQ', 'NE', 'LT', 'LTE', 'G
|
|
|
28
29
|
* The literal text operators (#108, part 1) and what each one matches: `contains`, `prefix`,
|
|
29
30
|
* `suffix` or `exact` (the whole value), respecting case or ignoring it (`ignoreCase`). Values are
|
|
30
31
|
* literal: `%`, `_`, `.`, `*`, `\`, `^`, `$` and `(` match themselves. `LIKE`, the case-sensitive
|
|
31
|
-
* substring of 3.7.0, keeps its own validation and compilation and is not listed here.
|
|
32
|
+
* substring of 3.7.0, keeps its own validation and compilation and is not listed here. Neither is
|
|
33
|
+
* `IIN` (#108, part 4), `IN` ignoring case: it takes a list of such values, which
|
|
34
|
+
* {@link validateTextListFilterValue} checks.
|
|
32
35
|
*/
|
|
33
36
|
export const TEXT_QUERY_OPERATORS = Object.freeze({
|
|
34
37
|
ILIKE: Object.freeze({ match: 'contains', ignoreCase: true }),
|
|
@@ -66,6 +69,85 @@ export const validateTextFilterValue = (operator, value, scalar, path) => {
|
|
|
66
69
|
return value;
|
|
67
70
|
};
|
|
68
71
|
|
|
72
|
+
/** The operators whose value is an array: IN, NIN and BTW (3.7.0), CONTAINS_ALL and IIN (#108 part 4). */
|
|
73
|
+
export const LIST_VALUE_OPERATORS = Object.freeze(['IN', 'NIN', 'BTW', 'CONTAINS_ALL', 'IIN']);
|
|
74
|
+
|
|
75
|
+
/** Whether `operator` takes an array value. */
|
|
76
|
+
export const takesListValue = (operator) => LIST_VALUE_OPERATORS.includes(operator);
|
|
77
|
+
|
|
78
|
+
const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
79
|
+
&& [Object.prototype, null].includes(Object.getPrototypeOf(value));
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Validates the value of CONTAINS_ALL (#108 section 3.3) before any backend compiles it: a non-empty array of
|
|
83
|
+
* non-null scalars, none of them an array or a plain object. Anything else fails with INVALID_FILTER_VALUE (400).
|
|
84
|
+
* Returns the value unchanged: the compilers cast each element as they cast IN elements, and keep duplicates.
|
|
85
|
+
*/
|
|
86
|
+
export const validateContainsAllValue = (value) => {
|
|
87
|
+
if (value === undefined || value === null) invalidValue('Expected a scalar filter value');
|
|
88
|
+
// Array.from reads a hole of a sparse array as undefined; `some` would skip it.
|
|
89
|
+
if (Array.isArray(value) && Array.from(value).some((item) => item == null || Array.isArray(item) || isPlainObject(item))) {
|
|
90
|
+
invalidValue('Filter lists require non-null scalar elements');
|
|
91
|
+
}
|
|
92
|
+
if (!Array.isArray(value)) invalidValue('CONTAINS_ALL requires an array value');
|
|
93
|
+
if (!value.length) invalidValue('CONTAINS_ALL requires a non-empty list');
|
|
94
|
+
return value;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Validates the value of IIN, IN ignoring case, before any backend compiles it: the field must be a `String` field
|
|
99
|
+
* or a `String` list by its effective scalar, as for IEQ, and the value an array of strings without U+0000; the
|
|
100
|
+
* empty array is valid and matches nothing. Anything else fails with INVALID_FILTER_VALUE (400). Returns the value
|
|
101
|
+
* unchanged: it is neither folded nor deduplicated.
|
|
102
|
+
*/
|
|
103
|
+
export const validateTextListFilterValue = (operator, value, scalar, path) => {
|
|
104
|
+
const where = path == null ? '' : ` on ${Array.isArray(path) ? path.join('.') : path}`;
|
|
105
|
+
if (scalar !== 'String') invalidValue(`${operator} requires a String field${where}`);
|
|
106
|
+
if (!Array.isArray(value)) invalidValue(`${operator} requires an array value${where}`);
|
|
107
|
+
// Array.from reads a hole of a sparse array as undefined; `some` would skip it.
|
|
108
|
+
const items = Array.from(value);
|
|
109
|
+
if (items.some((item) => typeof item !== 'string')) invalidValue(`${operator} requires non-null string elements${where}`);
|
|
110
|
+
if (items.some((item) => item.includes('\u0000'))) invalidValue(`${operator} values cannot contain U+0000${where}`);
|
|
111
|
+
return value;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const failPath = (message, code) => { throw new SimfinityError(message, code, 400); };
|
|
115
|
+
// The validator's steps (graphqlStep, modelStep) and resolveModelPath's field descriptions, read alike.
|
|
116
|
+
const stepKind = (step) => {
|
|
117
|
+
if (step.kind === 'embedded') return step.list ? 'embeddedList' : 'embedded';
|
|
118
|
+
if (step.kind === 'scalar') return step.list ? 'scalarList' : 'scalar';
|
|
119
|
+
if (step.kind === 'reference' && step.inferred) return 'scalar';
|
|
120
|
+
return step.kind;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The permanent path and leaf rules of CONTAINS_ALL (#108 section 3.3), on the steps of a path relative to its
|
|
125
|
+
* frame: the path ends at a value, as for every operator (MISSING_FILTER_PATH for one segment, INVALID_FILTER_PATH
|
|
126
|
+
* otherwise; the validator has applied this rule already, and PostgreSQL's hand-built plans get core's codes from
|
|
127
|
+
* it), no collection is crossed (INVALID_FILTER_PATH, which the shorthand's exclusion states), no reference follows
|
|
128
|
+
* an embedded list (INVALID_FILTER_PATH: that join yields one referenced record per row), and the leaf is a list: a
|
|
129
|
+
* scalar list, or a scalar reached through an embedded list (INVALID_FILTER_OPERATOR otherwise).
|
|
130
|
+
*/
|
|
131
|
+
export const assertContainsAllPath = (steps) => {
|
|
132
|
+
const kinds = steps.map(stepKind);
|
|
133
|
+
const names = steps.map(({ name }) => name);
|
|
134
|
+
// Core's object-end rule first (query-frames.js:420-422), so that every caller gets core's codes in core's order.
|
|
135
|
+
if (['embedded', 'embeddedList', 'reference', 'collection'].includes(kinds.at(-1))) {
|
|
136
|
+
failPath('Filter on object field requires a scalar path', steps.length > 1 ? 'INVALID_FILTER_PATH' : 'MISSING_FILTER_PATH');
|
|
137
|
+
}
|
|
138
|
+
const crossed = kinds.slice(0, -1);
|
|
139
|
+
if (crossed.includes('collection')) failPath('The shorthand excludes them; use an explicit group', 'INVALID_FILTER_PATH');
|
|
140
|
+
const list = crossed.indexOf('embeddedList');
|
|
141
|
+
const reference = list === -1 ? -1 : crossed.findIndex((kind, index) => index > list && kind === 'reference');
|
|
142
|
+
if (reference !== -1) {
|
|
143
|
+
failPath(`CONTAINS_ALL cannot cross the reference ${names.slice(0, reference + 1).join('.')} inside an embedded list, which joins one referenced record per row; combine EQ conditions on the path instead`, 'INVALID_FILTER_PATH');
|
|
144
|
+
}
|
|
145
|
+
const leaf = kinds.at(-1);
|
|
146
|
+
if (leaf !== 'scalarList' && !(leaf === 'scalar' && list !== -1)) {
|
|
147
|
+
failPath(`CONTAINS_ALL requires a list field, or a field reached through an embedded list; ${names.join('.')} is not one`, 'INVALID_FILTER_OPERATOR');
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
|
|
69
151
|
const invalidFeatures = (message) => {
|
|
70
152
|
throw new SimfinityError(`DatabaseAdapter queryFeatures ${message}`, 'INVALID_QUERY_FEATURES', 500);
|
|
71
153
|
};
|
|
@@ -76,7 +158,9 @@ const invalidFeatures = (message) => {
|
|
|
76
158
|
* Without a declaration (undefined or null) the adapter supports {@link LEGACY_QUERY_OPERATORS}
|
|
77
159
|
* and neither feature. A declaration is an object whose `operators`
|
|
78
160
|
* is an array of operator names, whose names this version does not know are ignored, and whose
|
|
79
|
-
* optional `referenceEmptiness` and `quantifiers` are booleans, undefined or null (false).
|
|
161
|
+
* optional `referenceEmptiness` and `quantifiers` are booleans, undefined or null (false). A name
|
|
162
|
+
* counts once this version knows it: a declaration that listed `CONTAINS_ALL` or `IIN` before
|
|
163
|
+
* #108 part 4 added them receives them on upgrade.
|
|
80
164
|
* Quantifiers require reference emptiness for scoped joins. Other members are left for later
|
|
81
165
|
* features. Anything else throws `INVALID_QUERY_FEATURES` (500).
|
|
82
166
|
*/
|
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
|
-
textQueryOperator, validateTextFilterValue,
|
|
7
|
+
takesListValue, textQueryOperator, 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); };
|
|
@@ -256,10 +256,19 @@ export const resolveModelPath = (models, entityName, path) => {
|
|
|
256
256
|
|
|
257
257
|
/**
|
|
258
258
|
* Typed paths and logical groups; neither SQL nor Mongo pipeline objects are stored here. Operators
|
|
259
|
-
* and values are validated here, text operators through validateTextFilterValue
|
|
259
|
+
* and values are validated here, text operators through validateTextFilterValue, CONTAINS_ALL
|
|
260
|
+
* through validateContainsAllValue and IIN through validateTextListFilterValue. With `operators`,
|
|
260
261
|
* the names a compiler supports, any other QLOperator value fails with UNSUPPORTED_QUERY (400);
|
|
261
262
|
* without it every QLOperator value is planned.
|
|
262
263
|
*
|
|
264
|
+
* The list operators (#108 part 4) plan `{ kind: 'predicate', path, operator, value }` with the
|
|
265
|
+
* array as validated, neither cast nor deduplicated: CONTAINS_ALL's is a non-empty array of
|
|
266
|
+
* non-null scalars, on a scalar list or a scalar reached through embedded lists; IIN's is an array
|
|
267
|
+
* of strings, possibly empty, on a String field or list. CONTAINS_ALL's path and leaf rules are the
|
|
268
|
+
* shared validator's, which runs first: through a collection or a reference inside an embedded
|
|
269
|
+
* list it fails with INVALID_FILTER_PATH, on any other leaf with INVALID_FILTER_OPERATOR. IIN
|
|
270
|
+
* through a collection becomes a correlated ANY frame like IN, and CONTAINS_ALL is never cut.
|
|
271
|
+
*
|
|
263
272
|
* The emptiness operators plan `{ kind: 'predicate', path, operator, value: null }`, whose path may
|
|
264
273
|
* end at a scalar, a scalar list, an embedded list or an embedded object, after the checks of
|
|
265
274
|
* describeEmptiness in their order (on the entity's GraphQL type, or on the field descriptions when
|
|
@@ -339,12 +348,14 @@ export const createQueryPlan = (models, entityName, source = {}, {
|
|
|
339
348
|
}
|
|
340
349
|
if (!isQueryOperator(op)) fail(`Unknown filter operator: ${op}`, 'INVALID_FILTER_OPERATOR');
|
|
341
350
|
if (allowed && !allowed.has(op)) fail(`The ${op} filter operator is not supported by this query compiler`, 'UNSUPPORTED_QUERY');
|
|
342
|
-
const collection =
|
|
351
|
+
const collection = takesListValue(op);
|
|
343
352
|
if (value === undefined || (!collection && Array.isArray(value)) || (value === null && !['EQ', 'NE'].includes(op))) fail('Expected a scalar filter value');
|
|
344
353
|
if (collection && Array.isArray(value) && value.some((item) => item == null || Array.isArray(item))) fail('Filter lists require non-null scalar elements');
|
|
345
|
-
if (
|
|
354
|
+
if (collection && !Array.isArray(value)) fail(`${op} requires an array value`);
|
|
346
355
|
if (op === 'BTW' && value.length !== 2) fail('BTW requires two values');
|
|
347
356
|
if (textQueryOperator(op)) validateTextFilterValue(op, value, field.scalar, path);
|
|
357
|
+
if (op === 'CONTAINS_ALL') validateContainsAllValue(value);
|
|
358
|
+
if (op === 'IIN') validateTextListFilterValue(op, value, field.scalar, path);
|
|
348
359
|
const node = { kind: 'predicate', path: Array.isArray(path) ? path : path.split('.'), operator: op, value: value === undefined ? null : value };
|
|
349
360
|
if (hasMarker(construct)) {
|
|
350
361
|
if (!inFrame) detached(`${op} on ${node.path.join('.')} carries a client scope marker, but is not an emptiness condition`);
|