@simtlix/simfinity-postgres 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,255 @@
1
+ import { resolveModelPath, SimfinityError } from '@simtlix/simfinity-core';
2
+ import { identifier as q, literal, qualified } from '../schema/sql.js';
3
+ import { encodeScalar, storageTypes, invalidValue } from '../codecs.js';
4
+
5
+ const unsupported = (message) => { throw new SimfinityError(message, 'UNSUPPORTED_QUERY', 400); };
6
+
7
+ /** Compiles validated paths; all caller values are parameters, identifiers come from metadata. */
8
+ export const compileQuery = (models, database, plan, extra = null) => {
9
+ const values = [];
10
+ const joins = new Map();
11
+ let serial = 0;
12
+ const alias = () => `t${++serial}`;
13
+ const tableByName = (name) => database.tables.find((item) => item.name === name);
14
+ const root = tableByName(plan.entity);
15
+ if (!root) unsupported(`Unknown persistent entity ${plan.entity}`);
16
+ const bind = (value, type) => { values.push(value); return `$${values.length}::${type}`; };
17
+ const col = (name, field) => `${name}.${q(field)}`;
18
+ const join = (key, table, condition) => {
19
+ if (!joins.has(key)) {
20
+ const name = alias();
21
+ joins.set(key, { alias: name, sql: `LEFT JOIN ${qualified(database.schema, table)} ${name} ON ${condition(name)}` });
22
+ }
23
+ return joins.get(key).alias;
24
+ };
25
+ const arrayFrom = (vector, expression) => `ARRAY(SELECT ${expression} FROM ${vector.from} WHERE ${vector.where})`;
26
+ const jsonItems = (expression, missing = '[]') => `jsonb_array_elements(CASE WHEN jsonb_typeof(${expression}) = 'array' THEN ${expression} ELSE '${missing}'::jsonb END)`;
27
+ const helper = (name, ...args) => `${qualified(database.schema, `__simfinity_${name}`)}(${args.join(', ')})`;
28
+ const dateJSON = (field, sql) => field.scalar === 'DateTime' ? helper('date_value', sql) : sql;
29
+ const nativeJSON = (field, sql) => {
30
+ if (field.scalar !== 'DateTime') return `to_jsonb(${sql})`;
31
+ if (field.list) return `CASE WHEN ${sql} IS NULL THEN NULL ELSE (SELECT COALESCE(jsonb_agg(extract(epoch FROM value) * 1000 ORDER BY position), '[]'::jsonb) FROM unnest(${sql}) WITH ORDINALITY d(value, position)) END`;
32
+ return `to_jsonb(extract(epoch FROM ${sql}) * 1000)`;
33
+ };
34
+ const project = (context, steps) => {
35
+ const [field, ...rest] = steps;
36
+ const owned = context.table && database.tables.find((item) => item.ownership?.ownerTable === context.table.name && item.ownership.field === field.name);
37
+ if (owned) {
38
+ const name = alias();
39
+ const value = project({ table: owned, alias: name, json: null }, rest);
40
+ const state = col(context.alias, owned.ownership.stateColumn);
41
+ const condition = `${col(name, '__owner_id')} = ${col(context.alias, context.table.primaryKey.columns[0])}${owned.ownership.nullableItems ? ` AND ${col(name, '__item_present')}` : ''}`;
42
+ const selection = field.list ? `COALESCE(jsonb_agg(${value} ORDER BY ${col(name, '__position')}) FILTER (WHERE ${value} IS NOT NULL), '[]'::jsonb)` : value;
43
+ return `CASE WHEN ${state} = 'present' THEN (SELECT ${selection} FROM ${qualified(database.schema, owned.name)} ${name} WHERE ${condition}) END`;
44
+ }
45
+ let value;
46
+ if (context.json) value = `(${context.json} -> ${literal(field.storageName)})`;
47
+ else {
48
+ const identity = !context.table.ownership && ['id', '_id'].includes(field.name);
49
+ const column = context.table.columns.find((item) => item.name === (identity ? 'id' : field.storageName));
50
+ const sql = col(context.alias, column.name);
51
+ value = `COALESCE(${nativeJSON(field, sql)}, 'null'::jsonb)`;
52
+ if (column.presenceColumn) value = `CASE WHEN ${sql} IS NOT NULL OR ${col(context.alias, column.presenceColumn)} THEN ${value} END`;
53
+ else value = nativeJSON(field, sql);
54
+ }
55
+ if (!rest.length) return context.json ? dateJSON(field, value) : value;
56
+ if (field.kind !== 'embedded') unsupported('Unnormalized embedded reference');
57
+ if (!field.list) return project({ json: value }, rest);
58
+ const name = alias();
59
+ const child = project({ json: `${name}.value` }, rest);
60
+ return `CASE WHEN jsonb_typeof(${value}) = 'array' THEN (SELECT COALESCE(jsonb_agg(${child} ORDER BY ${name}.ordinality) FILTER (WHERE ${child} IS NOT NULL), '[]'::jsonb) FROM ${jsonItems(value)} WITH ORDINALITY ${name}(value, ordinality)) END`;
61
+ };
62
+ const path = (parts, usage = 'filter') => {
63
+ const steps = resolveModelPath(models, plan.entity, parts);
64
+ let context = { table: root, alias: 't0', json: null, vector: null };
65
+ for (let index = 0; index < steps.length; index++) {
66
+ const field = steps[index];
67
+ const key = parts.slice(0, index + 1).join('.');
68
+ const last = index === steps.length - 1;
69
+ if (usage === 'aggregate' && steps.slice(index).some((item) => item.list) && !steps.slice(index).some((item, offset, remaining) => item.target && offset < remaining.length - 1)) {
70
+ const leaf = steps.at(-1);
71
+ if (leaf.kind !== 'scalar') unsupported('Whole embedded objects cannot be sorted or aggregated');
72
+ return { sql: project(context, steps.slice(index)), field: leaf, type: storageTypes[leaf.scalar], list: true, json: true };
73
+ }
74
+ if (field.kind === 'embedded') {
75
+ if (last) unsupported('Whole embedded objects cannot be sorted or aggregated');
76
+ const owned = context.table && database.tables.find((item) => item.ownership?.ownerTable === context.table.name && item.ownership.field === field.name);
77
+ if (owned) {
78
+ const ownerAlias = context.alias;
79
+ const ownerId = col(ownerAlias, context.table.primaryKey.columns[0]);
80
+ const state = `${col(ownerAlias, owned.ownership.stateColumn)} = 'present'`;
81
+ const presence = (name) => owned.ownership.nullableItems && usage !== 'sort' ? ` AND ${col(name, '__item_present')}` : '';
82
+ if (!field.list && !context.vector) {
83
+ const joined = join(key, owned.name, (name) => `${col(name, '__owner_id')} = ${ownerId} AND ${state}`);
84
+ context = { table: owned, alias: joined, json: null, vector: null };
85
+ } else {
86
+ const name = alias();
87
+ const stored = alias();
88
+ // Missing/null lists contribute a missing path, empty lists and null items do not.
89
+ const condition = `${col(stored, '__owner_id')} = ${ownerId} AND ${state}${presence(stored)}`;
90
+ const nullRow = owned.columns.map((column) => `NULL::${column.type} AS ${q(column.name)}`).join(', ');
91
+ const noItems = usage === 'sort' ? ` OR NOT EXISTS (SELECT 1 FROM ${qualified(database.schema, owned.name)} empty_child WHERE empty_child.__owner_id = ${ownerId})` : '';
92
+ const source = `LATERAL (SELECT ${stored}.* FROM ${qualified(database.schema, owned.name)} ${stored} WHERE ${condition} UNION ALL SELECT ${nullRow} WHERE ${col(ownerAlias, owned.ownership.stateColumn)} IS DISTINCT FROM 'present'${noItems}) ${name}`;
93
+ const vector = context.vector
94
+ ? { from: `${context.vector.from} CROSS JOIN ${source}`, where: context.vector.where }
95
+ : { from: source, where: 'TRUE' };
96
+ context = { table: owned, alias: name, json: null, vector };
97
+ }
98
+ } else {
99
+ const json = context.json ? `(${context.json} -> ${literal(field.storageName)})` : col(context.alias, field.storageName);
100
+ if (field.list) {
101
+ const name = alias();
102
+ const source = `${jsonItems(usage === 'sort' ? `CASE WHEN ${json} = '[]'::jsonb THEN '[{}]'::jsonb ELSE ${json} END` : json, '[{}]')} ${name}(value)`;
103
+ const present = usage === 'sort' ? 'TRUE' : `jsonb_typeof(${name}.value) = 'object'`;
104
+ const vector = context.vector
105
+ ? { from: `${context.vector.from} CROSS JOIN LATERAL ${source}`, where: `${context.vector.where} AND ${present}` }
106
+ : { from: source, where: present };
107
+ context = { table: null, alias: name, json: `${name}.value`, vector };
108
+ } else context = { ...context, table: null, json };
109
+ }
110
+ } else if (field.target && !last) {
111
+ if (context.json) unsupported('Unnormalized embedded reference');
112
+ const source = context;
113
+ const joined = join(key, field.target, (name) => {
114
+ if (field.kind === 'collection') return `${col(name, field.connectionField)} = ${col(source.alias, source.table.primaryKey.columns[0])}`;
115
+ const reference = col(source.alias, field.storageName);
116
+ return source.vector
117
+ ? `${col(name, 'id')} = ANY (${arrayFrom(source.vector, reference)})`
118
+ : `${col(name, 'id')} = ${reference}`;
119
+ });
120
+ context = { table: tableByName(field.target), alias: joined, json: null, vector: null };
121
+ } else {
122
+ if (!last || (field.kind !== 'scalar' && !field.inferred)) unsupported('A query path must end at a scalar field');
123
+ const identity = context.table && !context.table.ownership && ['id', '_id'].includes(field.name);
124
+ const type = identity || field.kind === 'reference' ? 'uuid' : storageTypes[field.scalar];
125
+ let expression;
126
+ if (context.json) {
127
+ if (field.list) {
128
+ const json = `(${context.json} -> ${literal(field.storageName)})`;
129
+ const typedJSON = dateJSON(field, json);
130
+ expression = `CASE WHEN ${json} IS NULL OR ${json} = 'null'::jsonb THEN NULL ELSE ARRAY(SELECT (value #>> '{}')::${field.scalar === 'DateTime' ? 'double precision' : type} FROM ${jsonItems(typedJSON)} j(value)) END`;
131
+ } else expression = field.scalar === 'DateTime' ? `(${dateJSON(field, `(${context.json} -> ${literal(field.storageName)})`)} #>> '{}')::double precision` : `(${context.json} ->> ${literal(field.storageName)})::${type}`;
132
+ } else expression = col(context.alias, identity ? 'id' : field.storageName);
133
+ if (usage === 'aggregate' && field.stateNames) expression = `CASE ${expression} ${field.stateNames.map((item) => `WHEN ${literal(item.value)} THEN ${literal(item.name)}`).join(' ')} END`;
134
+ const dateNumeric = field.scalar === 'DateTime' && !!context.json;
135
+ let sortKey;
136
+ if (usage === 'sort' && (context.vector || field.list)) {
137
+ const json = dateNumeric ? `to_jsonb(${expression})` : nativeJSON(field, expression);
138
+ const candidate = (order) => helper('sort_key', json, literal(field.scalar), order === 'ASC' ? 'true' : 'false');
139
+ sortKey = (order) => context.vector
140
+ ? `COALESCE((SELECT value FROM unnest(${arrayFrom(context.vector, candidate(order))}) s(value) ORDER BY value ${order} LIMIT 1), ${helper('value_key', 'NULL::jsonb', literal(field.scalar))})`
141
+ : candidate(order);
142
+ }
143
+ if (context.vector && field.list) {
144
+ const name = alias();
145
+ const vector = { from: `${context.vector.from} CROSS JOIN LATERAL unnest(COALESCE(${expression}, ARRAY[NULL]::${dateNumeric ? 'double precision' : type}[])) ${name}(value)`, where: context.vector.where };
146
+ const sql = arrayFrom(vector, `${name}.value`);
147
+ return { sql: type === 'text' ? `(${sql}) COLLATE "pg_catalog"."C"` : sql, field, type: dateNumeric ? 'double precision' : type, list: true, vector: true, dateNumeric, sortKey };
148
+ }
149
+ let sql = context.vector ? arrayFrom(context.vector, expression) : expression;
150
+ if (type === 'text') sql = `(${sql}) COLLATE "pg_catalog"."C"`;
151
+ return { sql, field: identity ? { ...field, scalar: 'ID' } : field, type: dateNumeric ? 'double precision' : type, list: field.list || !!context.vector, vector: !!context.vector, dateNumeric, sortKey };
152
+ }
153
+ }
154
+ unsupported('Invalid query path');
155
+ };
156
+ const predicate = (expression, operator, value) => {
157
+ const { sql, field, type, list, vector } = expression;
158
+ // Filters accept member names first; writes already receive internal enum values.
159
+ const encodeFilter = (item) => {
160
+ if (field.scalar !== 'Enum' || item == null) return encodeScalar(field, item);
161
+ const entry = field.enumValues.find((candidate) => candidate.name === item)
162
+ || field.enumValues.find((candidate) => candidate.value === item);
163
+ if (!entry) invalidValue(`Invalid enum value for ${field.name}`);
164
+ return encodeScalar(field, entry.value);
165
+ };
166
+ if (operator === 'LIKE' && field.scalar !== 'String') invalidValue('LIKE requires a string field');
167
+ const scalarBind = (item) => { const value = encodeFilter(item); return bind(expression.dateNumeric && value ? value.getTime() : value, type); };
168
+ const equality = (item) => {
169
+ if (Array.isArray(item)) {
170
+ if (!field.list || vector) invalidValue('Array equality requires a scalar-list field');
171
+ return `${sql} IS NOT DISTINCT FROM ${bind(item.map(encodeFilter), `${type}[]`)}`;
172
+ }
173
+ if (list) {
174
+ const contains = `array_position(${sql}, ${scalarBind(item)}) IS NOT NULL`;
175
+ return item == null ? `(${sql} IS NULL OR ${contains})` : contains;
176
+ }
177
+ return item == null ? `${sql} IS NULL` : `${sql} IS NOT DISTINCT FROM ${scalarBind(item)}`;
178
+ };
179
+ if (operator === 'EQ') return equality(value);
180
+ if (operator === 'NE') {
181
+ if (!list && !Array.isArray(value)) return value == null ? `${sql} IS NOT NULL` : `${sql} IS DISTINCT FROM ${scalarBind(value)}`;
182
+ return `NOT (${equality(value)})`;
183
+ }
184
+ if (operator === 'IN' || operator === 'NIN') {
185
+ const expressionSQL = value.length ? `(${value.map(equality).join(' OR ')})` : 'FALSE';
186
+ return operator === 'IN' ? expressionSQL : value.length ? `NOT ${expressionSQL}` : 'TRUE';
187
+ }
188
+ if (operator === 'BTW') return `(${predicate(expression, 'GTE', value[0])} AND ${predicate(expression, 'LTE', value[1])})`;
189
+ if (value == null || Array.isArray(value)) invalidValue(`${operator} requires a non-null scalar value`);
190
+ const parameter = scalarBind(value);
191
+ const compare = (left) => {
192
+ if (operator === 'LIKE') {
193
+ return `strpos(${left}, ${parameter}) > 0`;
194
+ }
195
+ return `${left} ${{ LT: '<', LTE: '<=', GT: '>', GTE: '>=' }[operator]} ${parameter}`;
196
+ };
197
+ return list ? `EXISTS (SELECT 1 FROM unnest(${sql}) v(value) WHERE ${compare('v.value')})` : `COALESCE(${compare(sql)}, FALSE)`;
198
+ };
199
+ const where = (node) => {
200
+ if (!node) return 'TRUE';
201
+ if (node.kind === 'predicate') return `(${predicate(path(node.path), node.operator, node.value)})`;
202
+ return `(${node.terms.map(where).join(node.kind === 'and' ? ' AND ' : ' OR ')})`;
203
+ };
204
+ let condition = where(plan.where);
205
+ if (extra) {
206
+ const column = root.columns.find((item) => item.name === extra.column);
207
+ if (!column || column.type !== 'uuid') unsupported('Invalid inverse connection field');
208
+ condition += ` AND ${col('t0', column.name)} = ${bind(extra.id, 'uuid')}`;
209
+ }
210
+ let select = 't0.*';
211
+ let group = '';
212
+ const order = [];
213
+ if (plan.mode === 'count') select = 'count(*) AS size';
214
+ else if (plan.mode === 'aggregate') {
215
+ const groupPath = path(plan.aggregation.groupId, 'aggregate');
216
+ // Mongo merges missing and explicit-null group keys, but retains nulls inside arrays.
217
+ const groupSQL = groupPath.json ? `NULLIF(${groupPath.sql}, 'null'::jsonb)` : groupPath.sql;
218
+ select = `${groupSQL} AS "groupId"`;
219
+ group = ` GROUP BY ${groupSQL}`;
220
+ const outputs = [groupPath];
221
+ plan.aggregation.facts.forEach((fact, index) => {
222
+ const expression = path(fact.path, 'aggregate');
223
+ outputs.push({ ...expression, list: ['MIN', 'MAX'].includes(fact.operation) && expression.list });
224
+ let operation;
225
+ if (fact.operation === 'COUNT') operation = 'count(*)';
226
+ else if (expression.list) {
227
+ if (fact.operation === 'SUM' || fact.operation === 'AVG') operation = fact.operation === 'SUM' ? '0' : 'NULL';
228
+ else operation = `(array_agg(${expression.sql} ORDER BY ${helper('value_key', expression.sql, literal(expression.field.scalar))} ${fact.operation === 'MIN' ? 'ASC' : 'DESC'}) FILTER (WHERE ${expression.sql} IS NOT NULL AND ${expression.sql} <> 'null'::jsonb))[1]`;
229
+ }
230
+ else if (['SUM', 'AVG'].includes(fact.operation) && !['Int', 'Float'].includes(expression.field.scalar)) operation = fact.operation === 'SUM' ? '0' : 'NULL';
231
+ else if (['MIN', 'MAX'].includes(fact.operation) && expression.type === 'boolean') operation = `${fact.operation === 'MIN' ? 'bool_and' : 'bool_or'}(${expression.sql})`;
232
+ else if (['MIN', 'MAX'].includes(fact.operation) && expression.type === 'uuid') operation = `${fact.operation}(${expression.sql}::text COLLATE "pg_catalog"."C")::uuid`;
233
+ else operation = `${fact.operation}(${expression.sql})`;
234
+ if (fact.operation === 'SUM') operation = `COALESCE(${operation}, 0)`;
235
+ select += `, ${operation} AS ${q(`fact_${index}`)}`;
236
+ });
237
+ for (const term of plan.sort.length ? plan.sort : [{ path: ['groupId'], order: 'ASC' }]) {
238
+ const index = plan.aggregation.facts.findIndex((fact) => fact.factName === term.path.join('.'));
239
+ const column = q(index < 0 ? 'groupId' : `fact_${index}`);
240
+ const output = outputs[index + 1];
241
+ const key = output.list ? helper('sort_key', column, literal(output.field.scalar), term.order === 'ASC' ? 'true' : 'false') : column;
242
+ order.push(`${key} ${term.order} NULLS ${term.order === 'ASC' ? 'FIRST' : 'LAST'}`);
243
+ }
244
+ } else for (const term of plan.sort) {
245
+ const expression = path(term.path, 'sort');
246
+ const key = expression.sql;
247
+ if (expression.sortKey) { order.push(`${expression.sortKey(term.order)} ${term.order} NULLS ${term.order === 'ASC' ? 'FIRST' : 'LAST'}`); continue; }
248
+ order.push(`${key} ${term.order} NULLS ${term.order === 'ASC' ? 'FIRST' : 'LAST'}`);
249
+ }
250
+ let text = `SELECT ${select} FROM ${qualified(database.schema, root.name)} t0 ${[...joins.values()].map((item) => item.sql).join(' ')} WHERE ${condition}${group}`;
251
+ if (plan.mode === 'aggregate') text = `SELECT * FROM (${text}) aggregated`;
252
+ if (order.length) text += ` ORDER BY ${order.join(', ')}`;
253
+ if (plan.pagination) text += ` LIMIT ${bind(plan.pagination.limit, 'bigint')} OFFSET ${bind(plan.pagination.offset, 'bigint')}`;
254
+ return { text, values, ...(plan.mode === 'aggregate' ? { aggregateFields: [resolveModelPath(models, plan.entity, plan.aggregation.groupId).at(-1), ...plan.aggregation.facts.map((fact) => resolveModelPath(models, plan.entity, fact.path).at(-1))] } : {}) };
255
+ };
@@ -0,0 +1,47 @@
1
+ import { qualified } from '../schema/sql.js';
2
+
3
+ /** Immutable, catalog-checked helpers shared by filters, projections and ordering.
4
+ * Keys use supported BSON type ranks, terminated recursive arrays and UTF8 text,
5
+ * and fixed-width IEEE754 bytes with a sign transform and canonical numeric zero.
6
+ * PostgreSQL text cannot contain NUL, so its zero terminator preserves prefix order.
7
+ */
8
+ export const queryFunctions = (schema) => {
9
+ const call = (name) => qualified(schema, name);
10
+ const fn = (name, args, returns, body) => ({ name, arguments: args, returns, body, language: 'plpgsql', volatility: 'IMMUTABLE', configuration: ['search_path=pg_catalog', 'TimeZone=UTC', 'DateStyle=ISO, YMD'] });
11
+ return [
12
+ fn('__simfinity_date_value', ['jsonb'], 'jsonb', `DECLARE __parts integer[]; __year integer; __mapped integer; BEGIN
13
+ IF $1 IS NULL OR $1 = 'null'::jsonb THEN RETURN $1; END IF;
14
+ IF jsonb_typeof($1) = 'array' THEN RETURN (SELECT COALESCE(jsonb_agg(${call('__simfinity_date_value')}(value) ORDER BY position), '[]'::jsonb) FROM jsonb_array_elements($1) WITH ORDINALITY e(value, position)); END IF;
15
+ __parts := regexp_match($1 #>> '{}', '^([+-]?[0-9]+)-([0-9]+)-([0-9]+)T([0-9]+):([0-9]+):([0-9]+)[.]([0-9]+)Z$')::integer[];
16
+ __year := __parts[1]; __mapped := 2000 + ((__year % 400 + 400) % 400);
17
+ RETURN to_jsonb(((make_date(__mapped, __parts[2], __parts[3]) - DATE '1970-01-01')::bigint + ((__year - __mapped)::bigint / 400) * 146097) * 86400000 + __parts[4]::bigint * 3600000 + __parts[5]::bigint * 60000 + __parts[6]::bigint * 1000 + __parts[7]);
18
+ END`),
19
+ fn('__simfinity_value_key', ['jsonb', 'text'], 'bytea', `DECLARE __kind text; __bytes bytea; __result bytea; __item jsonb; __number double precision; __index integer; BEGIN
20
+ IF $1 IS NULL OR $1 = 'null'::jsonb THEN RETURN decode('10', 'hex'); END IF;
21
+ __kind := jsonb_typeof($1);
22
+ IF __kind = 'array' THEN
23
+ __result := decode('40', 'hex');
24
+ FOR __item IN SELECT value FROM jsonb_array_elements($1) WITH ORDINALITY e(value, position) ORDER BY position LOOP __result := __result || ${call('__simfinity_value_key')}(__item, $2); END LOOP;
25
+ RETURN __result || decode('00', 'hex');
26
+ ELSIF __kind = 'number' THEN
27
+ __number := ($1 #>> '{}')::double precision;
28
+ IF __number = 0 THEN __number := 0; END IF;
29
+ __bytes := float8send(__number);
30
+ IF get_byte(__bytes, 0) >= 128 THEN
31
+ FOR __index IN 0..7 LOOP __bytes := set_byte(__bytes, __index, 255 - get_byte(__bytes, __index)); END LOOP;
32
+ ELSE __bytes := set_byte(__bytes, 0, get_byte(__bytes, 0) + 128); END IF;
33
+ RETURN decode(CASE WHEN $2 = 'DateTime' THEN '70' ELSE '20' END, 'hex') || __bytes;
34
+ ELSIF __kind = 'string' THEN RETURN decode(CASE WHEN $2 = 'ID' THEN '50' ELSE '30' END, 'hex') || convert_to($1 #>> '{}', 'UTF8') || decode('00', 'hex');
35
+ ELSIF __kind = 'boolean' THEN RETURN decode(CASE WHEN $1 = 'true'::jsonb THEN '6001' ELSE '6000' END, 'hex');
36
+ END IF;
37
+ RAISE EXCEPTION 'Unsupported query comparison value' USING ERRCODE = '22023';
38
+ END`),
39
+ fn('__simfinity_sort_key', ['jsonb', 'text', 'boolean'], 'bytea', `BEGIN
40
+ IF $1 = '[]'::jsonb THEN RETURN decode('01', 'hex'); END IF;
41
+ IF jsonb_typeof($1) = 'array' THEN
42
+ RETURN (SELECT ${call('__simfinity_value_key')}(value, $2) FROM jsonb_array_elements($1) ORDER BY CASE WHEN $3 THEN ${call('__simfinity_value_key')}(value, $2) END ASC, CASE WHEN NOT $3 THEN ${call('__simfinity_value_key')}(value, $2) END DESC LIMIT 1);
43
+ END IF;
44
+ RETURN ${call('__simfinity_value_key')}($1, $2);
45
+ END`),
46
+ ];
47
+ };
package/src/records.js ADDED
@@ -0,0 +1,219 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { getNamedType } from 'graphql';
3
+ import { SimfinityError } from '@simtlix/simfinity-core';
4
+ import { identifier as q, qualified } from './schema/sql.js';
5
+ import { castId, encodeScalar, decodeScalar } from './codecs.js';
6
+
7
+ const missing = (name) => { throw new SimfinityError(`Required value ${name} is missing`, 'REQUIRED_VALUE', 400); };
8
+ const shapeError = (name) => { throw new SimfinityError(`Invalid value for ${name}`, 'INVALID_VALUE', 400); };
9
+
10
+ export const createRecordStore = (models, database, query) => {
11
+ const model = (name) => models.entities.find((item) => item.name === name);
12
+ const table = (name) => database.tables.find((item) => item.name === name);
13
+ const children = (owner) => database.tables.filter((item) => item.ownership?.ownerTable === owner.name);
14
+ const child = (owner, field) => children(owner).find((item) => item.ownership.field === field.name);
15
+ const gqlFields = (type) => type?.getFields() || {};
16
+ const encodeValue = (field, value) => {
17
+ if (value == null) return null;
18
+ if (field.kind === 'embedded') return mapEmbedded(field, value, encodeFields);
19
+ if (field.list) {
20
+ if (!Array.isArray(value)) shapeError(field.name);
21
+ return value.map((item) => {
22
+ if (item == null && field.itemRequired) missing(`${field.name}[]`);
23
+ return encodeScalar(field, item);
24
+ });
25
+ }
26
+ return encodeScalar(field, value);
27
+ };
28
+ const mapEmbedded = (field, value, mapper, gqltype) => {
29
+ if (value == null) return value;
30
+ if (field.list) {
31
+ if (!Array.isArray(value)) shapeError(field.name);
32
+ return value.map((item) => {
33
+ if (item == null) { if (field.itemRequired) missing(`${field.name}[]`); return null; }
34
+ return mapper(field.fields, item, gqltype);
35
+ });
36
+ }
37
+ return mapper(field.fields, value, gqltype);
38
+ };
39
+ // Mongoose inline objects materialize descendant array defaults and minimize
40
+ // scalar-only empty objects. Embedded array items themselves are retained.
41
+ const normalize = (fields, data, full = true) => {
42
+ if (!data || typeof data !== 'object' || Array.isArray(data)) shapeError('embedded object');
43
+ const result = { ...data };
44
+ for (const field of fields) {
45
+ if (field.kind === 'collection' || (!full && !Object.hasOwn(data, field.storageName))) continue;
46
+ let value = data[field.storageName];
47
+ if (value === undefined && !full) continue;
48
+ if (value === undefined && full && field.list) value = [];
49
+ if (field.kind === 'embedded' && value !== null) {
50
+ if (field.list) {
51
+ if (!Array.isArray(value)) shapeError(field.name);
52
+ value = value.map((item) => item == null ? item : normalize(field.fields, item));
53
+ } else {
54
+ value = normalize(field.fields, value ?? {});
55
+ if (!Object.keys(value).length) value = undefined;
56
+ }
57
+ }
58
+ if (value !== undefined) result[field.storageName] = value;
59
+ else if (full) delete result[field.storageName];
60
+ else result[field.storageName] = undefined;
61
+ }
62
+ return result;
63
+ };
64
+ const encodeFields = (fields, data) => {
65
+ if (!data || typeof data !== 'object' || Array.isArray(data)) shapeError('embedded object');
66
+ const result = {};
67
+ for (const field of fields) {
68
+ if (field.kind === 'collection') continue;
69
+ const value = data[field.storageName];
70
+ if (value == null && field.required) missing(field.name);
71
+ if (value !== undefined) result[field.storageName] = encodeValue(field, value);
72
+ else if (field.list) result[field.storageName] = [];
73
+ }
74
+ return result;
75
+ };
76
+ const decodeFields = (fields, data, gqltype) => {
77
+ const result = {};
78
+ for (const field of fields) {
79
+ if (field.kind === 'collection' || data[field.storageName] === undefined) continue;
80
+ const value = data[field.storageName];
81
+ const gqlField = gqlFields(gqltype)[field.name];
82
+ if (field.kind === 'embedded') result[field.storageName] = mapEmbedded(field, value, decodeFields, gqlField && getNamedType(gqlField.type));
83
+ else result[field.storageName] = field.list && value ? value.map((item) => decodeScalar(field, item, gqlField)) : decodeScalar(field, value, gqlField);
84
+ }
85
+ return result;
86
+ };
87
+ const hydrate = async (storage, fields, rows, gqltype, session) => {
88
+ const result = rows.map((row) => {
89
+ const visible = { ...row };
90
+ for (const column of storage.columns) if (column.presenceColumn && row[column.name] == null && !row[column.presenceColumn]) delete visible[column.name];
91
+ const value = decodeFields(fields, visible, gqltype);
92
+ if (!storage.ownership) { value._id = row.id; value.id = row.id; }
93
+ return value;
94
+ });
95
+ if (!rows.length) return result;
96
+ for (const owned of children(storage)) {
97
+ const field = fields.find((item) => item.name === owned.ownership.field);
98
+ const ids = rows.filter((row) => row[owned.ownership.stateColumn] === 'present').map((row) => row[storage.primaryKey.columns[0]]);
99
+ let ownedRows = [];
100
+ if (ids.length) ownedRows = (await query(`SELECT * FROM ${qualified(database.schema, owned.name)} WHERE "__owner_id" = ANY ($1::uuid[])${field.list ? ' ORDER BY "__position"' : ''}`, [ids], session)).rows;
101
+ const decoded = await hydrate(owned, field.fields, ownedRows, getNamedType(gqlFields(gqltype)[field.name].type), session);
102
+ const byOwner = new Map();
103
+ ownedRows.forEach((row, index) => {
104
+ const list = byOwner.get(row.__owner_id) || [];
105
+ list.push(owned.ownership.nullableItems && !row.__item_present ? null : decoded[index]);
106
+ byOwner.set(row.__owner_id, list);
107
+ });
108
+ rows.forEach((row, index) => {
109
+ const state = row[owned.ownership.stateColumn];
110
+ if (state === 'null') result[index][field.storageName] = null;
111
+ else if (state === 'present') {
112
+ const items = byOwner.get(row[storage.primaryKey.columns[0]]) || [];
113
+ result[index][field.storageName] = field.list ? items : items[0] ?? null;
114
+ }
115
+ });
116
+ }
117
+ return result;
118
+ };
119
+ const columnValues = (storage, fields, data, full, nullItem = false) => {
120
+ const result = {};
121
+ for (const field of fields) {
122
+ if (field.kind === 'collection' || (!storage.ownership && ['id', '_id'].includes(field.name))) continue;
123
+ const present = Object.hasOwn(data, field.storageName);
124
+ let value = data[field.storageName];
125
+ if (!present && !full) continue;
126
+ if (!present && full && field.list && !nullItem) value = [];
127
+ const owned = child(storage, field);
128
+ if (owned) {
129
+ if (value == null && field.required && !nullItem) missing(field.name);
130
+ result[owned.ownership.stateColumn] = value === undefined ? 'missing' : value === null ? 'null' : 'present';
131
+ } else {
132
+ if (value == null && field.required && !nullItem) missing(field.name);
133
+ const presenceColumn = storage.columns.find((column) => column.name === field.storageName)?.presenceColumn;
134
+ if (presenceColumn) result[presenceColumn] = value !== undefined && !nullItem;
135
+ if (value !== undefined) {
136
+ const encoded = encodeValue(field, value);
137
+ result[field.storageName] = field.kind === 'embedded' && encoded != null ? JSON.stringify(encoded) : encoded;
138
+ } else if (full || field.kind === 'embedded') result[field.storageName] = null;
139
+ }
140
+ }
141
+ return result;
142
+ };
143
+ const insertRow = async (storage, data, session) => {
144
+ const entries = Object.entries(data);
145
+ const placeholders = entries.map(([name], index) => `$${index + 1}::${storage.columns.find((column) => column.name === name).type}`);
146
+ return (await query(`INSERT INTO ${qualified(database.schema, storage.name)} (${entries.map(([name]) => q(name)).join(', ')}) VALUES (${placeholders.join(', ')}) RETURNING *`, entries.map(([, value]) => value), session)).rows[0];
147
+ };
148
+ const persistOwned = async (storage, fields, ownerId, data, session, replace = false) => {
149
+ for (const owned of children(storage)) {
150
+ const field = fields.find((item) => item.name === owned.ownership.field);
151
+ if (!Object.hasOwn(data, field.storageName)) continue;
152
+ if (replace) await query(`DELETE FROM ${qualified(database.schema, owned.name)} WHERE "__owner_id" = $1::uuid`, [ownerId], session);
153
+ const value = data[field.storageName];
154
+ if (value == null) continue;
155
+ if (field.list && !Array.isArray(value)) shapeError(field.name);
156
+ const entries = field.list ? value : [value];
157
+ for (let index = 0; index < entries.length; index++) {
158
+ const entry = entries[index];
159
+ if (entry == null && (!field.list || field.itemRequired)) missing(`${field.name}[]`);
160
+ if (entry != null && (typeof entry !== 'object' || Array.isArray(entry))) shapeError(field.name);
161
+ const row = { __id: randomUUID(), __owner_id: ownerId, ...columnValues(owned, field.fields, entry || {}, true, entry == null) };
162
+ if (field.list) row.__position = index;
163
+ if (owned.ownership.nullableItems) row.__item_present = entry != null;
164
+ await insertRow(owned, row, session);
165
+ if (entry) await persistOwned(owned, field.fields, row.__id, entry, session);
166
+ }
167
+ }
168
+ };
169
+ const hydrateRows = async (name, rows, session) => {
170
+ const entity = model(name);
171
+ if (!entity) throw new SimfinityError(`Type ${name} is not an entity`, 'INVALID_MODEL', 400);
172
+ return hydrate(table(name), entity.fields, rows, entity.gqltype, session);
173
+ };
174
+ const getById = async (name, value, session, { projection, lock, requiredId } = {}) => {
175
+ if (value == null) return null;
176
+ const id = castId(value);
177
+ if (requiredId != null && id !== castId(requiredId)) return null;
178
+ const rows = (await query(`SELECT * FROM ${qualified(database.schema, name)} WHERE id = $1::uuid${lock ? ' FOR UPDATE' : ''}`, [id], session)).rows;
179
+ const [record] = await hydrateRows(name, rows, session);
180
+ if (!record) return null;
181
+ return projection ? Object.fromEntries(Object.entries(record).filter(([key]) => key === '_id' || key === 'id' || projection[key])) : record;
182
+ };
183
+ const create = async (name, record, session) => {
184
+ const entity = model(name);
185
+ if (!entity) throw new SimfinityError(`Type ${name} is not an entity`, 'INVALID_MODEL', 400);
186
+ const storage = table(name);
187
+ const normalized = normalize(entity.fields, record);
188
+ const data = { id: castId(normalized._id || normalized.id), ...columnValues(storage, entity.fields, normalized, true) };
189
+ const row = await insertRow(storage, data, session);
190
+ await persistOwned(storage, entity.fields, row.id, normalized, session);
191
+ return (await hydrateRows(name, [row], session))[0];
192
+ };
193
+ const update = async (name, value, changes, session) => {
194
+ const id = castId(value);
195
+ const entity = model(name); const storage = table(name);
196
+ const source = normalize(entity.fields, changes, false);
197
+ delete source.$unset;
198
+ for (const field of Object.keys(changes.$unset || {})) source[field] = undefined;
199
+ const data = columnValues(storage, entity.fields, source, false);
200
+ for (const field of Object.keys(changes.$unset || {})) {
201
+ const column = storage.columns.find((item) => item.name === field);
202
+ if (column && !['id', '_id'].includes(field)) data[field] = null;
203
+ }
204
+ const entries = Object.entries(data);
205
+ let rows;
206
+ if (entries.length) rows = (await query(`UPDATE ${qualified(database.schema, name)} SET ${entries.map(([key], index) => `${q(key)} = $${index + 2}::${storage.columns.find((column) => column.name === key).type}`).join(', ')} WHERE id = $1::uuid RETURNING *`, [id, ...entries.map(([, value]) => value)], session)).rows;
207
+ else rows = (await query(`SELECT * FROM ${qualified(database.schema, name)} WHERE id = $1::uuid`, [id], session)).rows;
208
+ if (!rows.length) return null;
209
+ await persistOwned(storage, entity.fields, id, source, session, true);
210
+ return (await hydrateRows(name, rows, session))[0];
211
+ };
212
+ const remove = async (name, value, session) => {
213
+ const record = await getById(name, value, session);
214
+ if (!record) return null;
215
+ await query(`DELETE FROM ${qualified(database.schema, name)} WHERE id = $1::uuid`, [record._id], session);
216
+ return record;
217
+ };
218
+ return { create, update, remove, getById, hydrateRows };
219
+ };