@simtlix/simfinity-core 3.2.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.
@@ -0,0 +1,110 @@
1
+ import { paginationStages } from './query-limits.js';
2
+ import SimfinityError from './errors/simfinity.error.js';
3
+
4
+ const fail = (message, code = 'INVALID_FILTER_VALUE') => { throw new SimfinityError(message, code, 400); };
5
+ const operators = new Set(['EQ', 'NE', 'LT', 'LTE', 'GT', 'GTE', 'BTW', 'IN', 'NIN', 'LIKE']);
6
+ const reserved = new Set(['AND', 'OR', 'sort', 'pagination', 'aggregation']);
7
+
8
+ export const resolveModelPath = (models, entityName, path) => {
9
+ const parts = Array.isArray(path) ? path : typeof path === 'string' ? path.split('.') : [];
10
+ if (!parts.length || parts.some((part) => !/^[A-Za-z_][A-Za-z0-9_]*$/.test(part))) fail('Invalid filter path', 'INVALID_FILTER_PATH');
11
+ let entity = models.entities.find((item) => item.name === entityName);
12
+ if (!entity) fail(`Unknown entity ${entityName}`, 'INVALID_FILTER_FIELD');
13
+ let fields = entity.fields;
14
+ const steps = [];
15
+ for (let index = 0; index < parts.length; index++) {
16
+ const field = fields.find((item) => item.name === parts[index] && !item.private);
17
+ if (!field) fail(`Unknown field ${parts.slice(0, index + 1).join('.')}`, 'INVALID_FILTER_FIELD');
18
+ steps.push(field);
19
+ if (index < parts.length - 1) {
20
+ if (field.kind === 'embedded') fields = field.fields;
21
+ else if (field.target && !field.inferred) {
22
+ entity = models.entities.find((item) => item.name === field.target);
23
+ fields = entity.fields;
24
+ } else fail(`Field ${field.name} cannot be traversed`, 'INVALID_FILTER_PATH');
25
+ }
26
+ }
27
+ return steps;
28
+ };
29
+
30
+ /** Typed paths and logical groups; neither SQL nor Mongo pipeline objects are stored here. */
31
+ export const createQueryPlan = (models, entityName, input = {}, { mode = 'find' } = {}) => {
32
+ const combine = (kind, terms) => {
33
+ const active = terms.filter(Boolean);
34
+ return active.length > 1 ? { kind, terms: active } : active[0] || null;
35
+ };
36
+ const predicate = (path, operator, value) => {
37
+ const steps = resolveModelPath(models, entityName, path);
38
+ const field = steps.at(-1);
39
+ if (field.kind !== 'scalar' && !field.inferred) fail(`Filter on object field ${path} requires a scalar path`, 'MISSING_FILTER_PATH');
40
+ const op = operator ?? 'EQ';
41
+ if (!operators.has(op)) fail(`Unknown filter operator: ${op}`, 'INVALID_FILTER_OPERATOR');
42
+ const collection = ['IN', 'NIN', 'BTW'].includes(op);
43
+ if (value === undefined || (!collection && Array.isArray(value)) || (value === null && !['EQ', 'NE'].includes(op))) fail('Expected a scalar filter value');
44
+ if (collection && Array.isArray(value) && value.some((item) => item == null || Array.isArray(item))) fail('Filter lists require non-null scalar elements');
45
+ if (['IN', 'NIN', 'BTW'].includes(op) && !Array.isArray(value)) fail(`${op} requires an array value`);
46
+ if (op === 'BTW' && value.length !== 2) fail('BTW requires two values');
47
+ return { kind: 'predicate', path: Array.isArray(path) ? path : path.split('.'), operator: op, value: value === undefined ? null : value };
48
+ };
49
+ const validateGroup = (value) => {
50
+ if (!value || typeof value !== 'object' || Array.isArray(value)) fail('Expected a filter group object');
51
+ for (const key of ['conditions', 'AND', 'OR']) if (value[key] != null && !Array.isArray(value[key])) fail(`${key} requires an array`);
52
+ };
53
+ const group = (value, depth = 0) => {
54
+ validateGroup(value);
55
+ if (depth > 5) fail('Filter nesting too deep', 'FILTER_DEPTH_EXCEEDED');
56
+ const terms = [];
57
+ for (const condition of value.conditions || []) {
58
+ if (!condition || typeof condition.field !== 'string') fail('Invalid filter path', 'INVALID_FILTER_PATH');
59
+ if (condition.field.includes('.') && condition.path) fail('Filter condition cannot use both dotted field syntax and a separate path', 'AMBIGUOUS_FILTER_FIELD');
60
+ terms.push(predicate(condition.path ? `${condition.field}.${condition.path}` : condition.field, condition.operator, condition.value));
61
+ }
62
+ for (const item of value.AND || []) terms.push(group(item, depth + 1));
63
+ terms.push(combine('or', (value.OR || []).map((item) => group(item, depth + 1))));
64
+ return combine('and', terms);
65
+ };
66
+ validateGroup(input);
67
+ const terms = [];
68
+ for (const [name, value] of Object.entries(input)) {
69
+ if (reserved.has(name) || value == null) continue;
70
+ const field = resolveModelPath(models, entityName, name).at(-1);
71
+ if (typeof value !== 'object' || Array.isArray(value)) fail('Expected a filter object');
72
+ if (field.kind !== 'scalar' && !field.inferred && (!Array.isArray(value.terms) || !value.terms.length)) fail('Object filters require non-empty terms', 'MISSING_FILTER_PATH');
73
+ if (field.kind === 'scalar' || field.inferred) terms.push(predicate(name, value.operator, value.value));
74
+ else for (const term of value.terms) {
75
+ if (!term || typeof term !== 'object' || Array.isArray(term)) fail('Expected a relationship filter term');
76
+ if (typeof term.path !== 'string' || !term.path) fail('Relationship terms require a path', 'INVALID_FILTER_PATH');
77
+ terms.push(predicate(`${name}.${term.path}`, term.operator, term.value));
78
+ }
79
+ }
80
+ for (const item of input.AND || []) terms.push(group(item));
81
+ terms.push(combine('or', (input.OR || []).map((item) => group(item))));
82
+ const paging = paginationStages(input.pagination, mode === 'find');
83
+ const plan = { entity: entityName, where: combine('and', terms), mode, sort: [], pagination: mode !== 'count' && paging.length ? { limit: paging[1].$limit, offset: paging[0].$skip } : null };
84
+ if (mode === 'aggregate') {
85
+ const aggregation = input.aggregation;
86
+ if (!aggregation?.groupId || !Array.isArray(aggregation.facts) || !aggregation.facts.length) fail('Aggregation requires a groupId and facts');
87
+ resolveModelPath(models, entityName, aggregation.groupId);
88
+ const seen = new Set();
89
+ for (const fact of aggregation.facts) {
90
+ if (!['SUM', 'COUNT', 'AVG', 'MIN', 'MAX'].includes(fact.operation)) fail('Invalid aggregate operation');
91
+ if (typeof fact.factName !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(fact.factName) || ['__proto__', 'constructor', 'prototype'].includes(fact.factName) || seen.has(fact.factName)) fail('Invalid or duplicate aggregation fact name');
92
+ seen.add(fact.factName);
93
+ resolveModelPath(models, entityName, fact.path);
94
+ }
95
+ plan.aggregation = { groupId: aggregation.groupId.split('.'), facts: aggregation.facts.map((fact) => ({ ...fact, path: fact.path.split('.') })) };
96
+ }
97
+ if (input.sort && (!Array.isArray(input.sort.terms) || !input.sort.terms.length)) fail('Sort requires non-empty terms', 'INVALID_SORT');
98
+ if (mode !== 'count') for (const sort of input.sort?.terms || []) {
99
+ if (!sort || (sort.order !== 'ASC' && sort.order !== 'DESC')) fail('Invalid sort order', 'INVALID_SORT');
100
+ if (typeof sort.field !== 'string' || sort.field.split('.').some((part) => !/^[A-Za-z_][A-Za-z0-9_]*$/.test(part))) {
101
+ fail('Invalid sort path', 'INVALID_FILTER_PATH');
102
+ }
103
+ if (mode !== 'aggregate') {
104
+ const field = resolveModelPath(models, entityName, sort.field).at(-1);
105
+ if (field.kind !== 'scalar' && !field.inferred) fail('Sort path must end in a scalar field', 'INVALID_FILTER_PATH');
106
+ }
107
+ plan.sort.push({ path: sort.field.split('.'), order: sort.order });
108
+ }
109
+ return plan;
110
+ };