orez-lite 0.12.21 → 0.13.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 +1 -1
- package/dist/aggregate.d.ts +85 -0
- package/dist/aggregate.d.ts.map +1 -0
- package/dist/aggregate.js +616 -0
- package/dist/aggregate.js.map +1 -0
- package/dist/client/fixture-schema.d.ts +1 -1
- package/package.json +8 -8
- package/dist/rollup.d.ts +0 -82
- package/dist/rollup.d.ts.map +0 -1
- package/dist/rollup.js +0 -592
- package/dist/rollup.js.map +0 -1
|
@@ -0,0 +1,616 @@
|
|
|
1
|
+
import { createBuilder } from '@rocicorp/zero';
|
|
2
|
+
const AGGREGATE_RUNTIME = Symbol.for('orez-lite:aggregate-runtime');
|
|
3
|
+
export function count() {
|
|
4
|
+
return { kind: 'count' };
|
|
5
|
+
}
|
|
6
|
+
export function sum(source) {
|
|
7
|
+
return { kind: 'sum', source };
|
|
8
|
+
}
|
|
9
|
+
function quoteIdentifier(identifier) {
|
|
10
|
+
return `"${identifier.replaceAll('"', '""')}"`;
|
|
11
|
+
}
|
|
12
|
+
function tablePhysicalName(table) {
|
|
13
|
+
return table.serverName ?? table.name.replace(/^public\./, '');
|
|
14
|
+
}
|
|
15
|
+
function columnPhysicalName(columnName, column) {
|
|
16
|
+
if (column &&
|
|
17
|
+
typeof column === 'object' &&
|
|
18
|
+
'serverName' in column &&
|
|
19
|
+
typeof column.serverName === 'string') {
|
|
20
|
+
return column.serverName;
|
|
21
|
+
}
|
|
22
|
+
return columnName;
|
|
23
|
+
}
|
|
24
|
+
function ensureOwnColumn(aggregateName, tableName, columns, columnName) {
|
|
25
|
+
if (!Object.hasOwn(columns, columnName)) {
|
|
26
|
+
throw new TypeError(`aggregate ${aggregateName} references unknown column ${tableName}.${columnName}`);
|
|
27
|
+
}
|
|
28
|
+
return columns[columnName];
|
|
29
|
+
}
|
|
30
|
+
// combine per-namespace definition records into the single record
|
|
31
|
+
// defineAggregates compiles. spreading them would let a name declared in two
|
|
32
|
+
// namespaces silently win by source order, and the loser's triggers would never
|
|
33
|
+
// install — the same invisible zero-total failure a missed definition causes.
|
|
34
|
+
//
|
|
35
|
+
// the parameter stays unconstrained so each namespace's literal types survive
|
|
36
|
+
// into defineAggregates, which is where they get checked against the schema.
|
|
37
|
+
// constraining to AggregateDefinitions here has nothing to infer S from, so it
|
|
38
|
+
// widens every source and target back to string and the schema check is lost.
|
|
39
|
+
export function mergeAggregateDefinitions(...records) {
|
|
40
|
+
const merged = {};
|
|
41
|
+
for (const record of records) {
|
|
42
|
+
for (const [name, definition] of Object.entries(record)) {
|
|
43
|
+
if (name in merged) {
|
|
44
|
+
throw new TypeError(`aggregate ${name} is declared in more than one namespace`);
|
|
45
|
+
}
|
|
46
|
+
merged[name] = definition;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return merged;
|
|
50
|
+
}
|
|
51
|
+
export function defineAggregates(schema, definitions) {
|
|
52
|
+
const compiled = [];
|
|
53
|
+
const triggerNames = new Set();
|
|
54
|
+
for (const [name, definition] of Object.entries(definitions)) {
|
|
55
|
+
if (!/^[A-Za-z][A-Za-z0-9_]{0,62}$/.test(name)) {
|
|
56
|
+
throw new TypeError(`aggregate name must be an identifier of at most 63 characters: ${JSON.stringify(name)}`);
|
|
57
|
+
}
|
|
58
|
+
if (triggerNames.has(name)) {
|
|
59
|
+
throw new TypeError(`duplicate aggregate name: ${name}`);
|
|
60
|
+
}
|
|
61
|
+
triggerNames.add(name);
|
|
62
|
+
const source = schema.tables[definition.source];
|
|
63
|
+
const target = schema.tables[definition.target];
|
|
64
|
+
if (!source) {
|
|
65
|
+
throw new TypeError(`aggregate ${name} references unknown table ${definition.source}`);
|
|
66
|
+
}
|
|
67
|
+
if (!target) {
|
|
68
|
+
throw new TypeError(`aggregate ${name} references unknown table ${definition.target}`);
|
|
69
|
+
}
|
|
70
|
+
if (definition.source === definition.target) {
|
|
71
|
+
throw new TypeError(`aggregate ${name} must use different source and target tables`);
|
|
72
|
+
}
|
|
73
|
+
const groups = Object.entries(definition.groupBy).map(([sourceColumnName, targetColumnName]) => {
|
|
74
|
+
if (typeof targetColumnName !== 'string') {
|
|
75
|
+
throw new TypeError(`aggregate ${name} has an invalid group mapping`);
|
|
76
|
+
}
|
|
77
|
+
const sourceColumn = ensureOwnColumn(name, definition.source, source.columns, sourceColumnName);
|
|
78
|
+
const targetColumn = ensureOwnColumn(name, definition.target, target.columns, targetColumnName);
|
|
79
|
+
return {
|
|
80
|
+
source: {
|
|
81
|
+
logical: sourceColumnName,
|
|
82
|
+
physical: columnPhysicalName(sourceColumnName, sourceColumn),
|
|
83
|
+
},
|
|
84
|
+
target: {
|
|
85
|
+
logical: targetColumnName,
|
|
86
|
+
physical: columnPhysicalName(targetColumnName, targetColumn),
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
});
|
|
90
|
+
if (groups.length === 0) {
|
|
91
|
+
throw new TypeError(`aggregate ${name} must group by at least one column`);
|
|
92
|
+
}
|
|
93
|
+
if (new Set(groups.map((group) => group.target.logical)).size !== groups.length) {
|
|
94
|
+
throw new TypeError(`aggregate ${name} maps more than one source column to one key`);
|
|
95
|
+
}
|
|
96
|
+
const targetPrimaryKey = [...target.primaryKey];
|
|
97
|
+
const groupedTargetColumns = [...groups].map((group) => group.target.logical).sort();
|
|
98
|
+
if (targetPrimaryKey.length !== groupedTargetColumns.length ||
|
|
99
|
+
[...targetPrimaryKey]
|
|
100
|
+
.sort()
|
|
101
|
+
.some((columnName, index) => columnName !== groupedTargetColumns[index])) {
|
|
102
|
+
throw new TypeError(`aggregate ${name} groupBy must map every primary key column of ${definition.target}`);
|
|
103
|
+
}
|
|
104
|
+
const columns = Object.entries(definition.columns).map(([targetColumnName, column]) => {
|
|
105
|
+
if (!column || typeof column !== 'object') {
|
|
106
|
+
throw new TypeError(`aggregate ${name} has an invalid column`);
|
|
107
|
+
}
|
|
108
|
+
const targetColumn = ensureOwnColumn(name, definition.target, target.columns, targetColumnName);
|
|
109
|
+
if (!targetColumn ||
|
|
110
|
+
typeof targetColumn !== 'object' ||
|
|
111
|
+
!('type' in targetColumn) ||
|
|
112
|
+
targetColumn.type !== 'number') {
|
|
113
|
+
throw new TypeError(`aggregate ${name} target column ${definition.target}.${targetColumnName} must be numeric`);
|
|
114
|
+
}
|
|
115
|
+
const compiledTarget = {
|
|
116
|
+
logical: targetColumnName,
|
|
117
|
+
physical: columnPhysicalName(targetColumnName, targetColumn),
|
|
118
|
+
};
|
|
119
|
+
const kind = Reflect.get(column, 'kind');
|
|
120
|
+
if (kind === 'count') {
|
|
121
|
+
return { kind: 'count', target: compiledTarget };
|
|
122
|
+
}
|
|
123
|
+
const columnSource = Reflect.get(column, 'source');
|
|
124
|
+
if (kind !== 'sum' || typeof columnSource !== 'string') {
|
|
125
|
+
throw new TypeError(`aggregate ${name} has an invalid column`);
|
|
126
|
+
}
|
|
127
|
+
const sourceColumn = ensureOwnColumn(name, definition.source, source.columns, columnSource);
|
|
128
|
+
if (!sourceColumn ||
|
|
129
|
+
typeof sourceColumn !== 'object' ||
|
|
130
|
+
!('type' in sourceColumn) ||
|
|
131
|
+
sourceColumn.type !== 'number') {
|
|
132
|
+
throw new TypeError(`aggregate ${name} source column ${definition.source}.${columnSource} must be numeric`);
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
kind: 'sum',
|
|
136
|
+
source: {
|
|
137
|
+
logical: columnSource,
|
|
138
|
+
physical: columnPhysicalName(columnSource, sourceColumn),
|
|
139
|
+
},
|
|
140
|
+
target: compiledTarget,
|
|
141
|
+
};
|
|
142
|
+
});
|
|
143
|
+
if (columns.length === 0) {
|
|
144
|
+
throw new TypeError(`aggregate ${name} must declare at least one column`);
|
|
145
|
+
}
|
|
146
|
+
if (columns.filter((column) => column.kind === 'count').length > 1) {
|
|
147
|
+
throw new TypeError(`aggregate ${name} may declare only one count column`);
|
|
148
|
+
}
|
|
149
|
+
if (groups.some((group) => columns.some((column) => column.target.logical === group.target.logical))) {
|
|
150
|
+
throw new TypeError(`aggregate ${name} cannot write a primary key column`);
|
|
151
|
+
}
|
|
152
|
+
if (definition.mode === 'materialized') {
|
|
153
|
+
if (!columns.some((column) => column.kind === 'count')) {
|
|
154
|
+
throw new TypeError(`materialized aggregate ${name} needs a count column to remove empty groups`);
|
|
155
|
+
}
|
|
156
|
+
const ownedTargetColumns = new Set([
|
|
157
|
+
...groups.map((group) => group.target.logical),
|
|
158
|
+
...columns.map((column) => column.target.logical),
|
|
159
|
+
]);
|
|
160
|
+
const extraTargetColumn = Object.keys(target.columns).find((columnName) => !ownedTargetColumns.has(columnName));
|
|
161
|
+
if (extraTargetColumn) {
|
|
162
|
+
throw new TypeError(`materialized aggregate ${name} target has non-aggregate column ${definition.target}.${extraTargetColumn}`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
else if (definition.mode !== 'existing') {
|
|
166
|
+
throw new TypeError(`aggregate ${name} has an invalid target mode`);
|
|
167
|
+
}
|
|
168
|
+
compiled.push({
|
|
169
|
+
name,
|
|
170
|
+
source: {
|
|
171
|
+
logical: definition.source,
|
|
172
|
+
physical: tablePhysicalName(source),
|
|
173
|
+
primaryKey: [...source.primaryKey],
|
|
174
|
+
},
|
|
175
|
+
target: {
|
|
176
|
+
logical: definition.target,
|
|
177
|
+
physical: tablePhysicalName(target),
|
|
178
|
+
primaryKey: targetPrimaryKey,
|
|
179
|
+
},
|
|
180
|
+
mode: definition.mode,
|
|
181
|
+
groups,
|
|
182
|
+
columns,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
const sourceTables = new Set(compiled.map((aggregate) => aggregate.source.logical));
|
|
186
|
+
for (const aggregate of compiled) {
|
|
187
|
+
if (sourceTables.has(aggregate.target.logical)) {
|
|
188
|
+
throw new TypeError(`aggregate ${aggregate.name} target ${aggregate.target.logical} cannot feed another aggregate`);
|
|
189
|
+
}
|
|
190
|
+
for (const candidate of compiled) {
|
|
191
|
+
if (candidate === aggregate ||
|
|
192
|
+
candidate.target.logical !== aggregate.target.logical) {
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
if (aggregate.mode === 'materialized' || candidate.mode === 'materialized') {
|
|
196
|
+
throw new TypeError(`materialized aggregate target ${aggregate.target.logical} may have only one owner`);
|
|
197
|
+
}
|
|
198
|
+
const duplicate = aggregate.columns.find((column) => candidate.columns.some((other) => other.target.logical === column.target.logical));
|
|
199
|
+
if (duplicate) {
|
|
200
|
+
throw new TypeError(`aggregate target column ${aggregate.target.logical}.${duplicate.target.logical} has more than one owner`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const bySource = new Map();
|
|
205
|
+
for (const aggregate of compiled) {
|
|
206
|
+
const sourceAggregates = bySource.get(aggregate.source.logical);
|
|
207
|
+
if (sourceAggregates)
|
|
208
|
+
sourceAggregates.push(aggregate);
|
|
209
|
+
else
|
|
210
|
+
bySource.set(aggregate.source.logical, [aggregate]);
|
|
211
|
+
}
|
|
212
|
+
return {
|
|
213
|
+
[AGGREGATE_RUNTIME]: {
|
|
214
|
+
schema,
|
|
215
|
+
definitions: compiled,
|
|
216
|
+
queryBuilder: createBuilder(schema),
|
|
217
|
+
bySource,
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
function sourceReference(column, row) {
|
|
222
|
+
return `${row}.${quoteIdentifier(column.physical)}`;
|
|
223
|
+
}
|
|
224
|
+
function targetWhere(aggregate, row) {
|
|
225
|
+
return aggregate.groups
|
|
226
|
+
.map((group) => `${quoteIdentifier(group.target.physical)} IS ${sourceReference(group.source, row)}`)
|
|
227
|
+
.join(' AND ');
|
|
228
|
+
}
|
|
229
|
+
function sameGroup(aggregate) {
|
|
230
|
+
return aggregate.groups
|
|
231
|
+
.map((group) => `${sourceReference(group.source, 'OLD')} IS ${sourceReference(group.source, 'NEW')}`)
|
|
232
|
+
.join(' AND ');
|
|
233
|
+
}
|
|
234
|
+
function aggregateChanged(aggregate) {
|
|
235
|
+
const sums = aggregate.columns.filter((column) => column.kind === 'sum');
|
|
236
|
+
return sums.length
|
|
237
|
+
? sums
|
|
238
|
+
.map((column) => `${sourceReference(column.source, 'OLD')} IS NOT ${sourceReference(column.source, 'NEW')}`)
|
|
239
|
+
.join(' OR ')
|
|
240
|
+
: '0';
|
|
241
|
+
}
|
|
242
|
+
function contribution(column, row) {
|
|
243
|
+
return column.kind === 'count'
|
|
244
|
+
? '1'
|
|
245
|
+
: `COALESCE(${sourceReference(column.source, row)}, 0)`;
|
|
246
|
+
}
|
|
247
|
+
function assignments(aggregate, operation) {
|
|
248
|
+
return aggregate.columns
|
|
249
|
+
.flatMap((column) => {
|
|
250
|
+
const target = quoteIdentifier(column.target.physical);
|
|
251
|
+
if (operation === 'delta') {
|
|
252
|
+
return column.kind === 'sum'
|
|
253
|
+
? [
|
|
254
|
+
`${target} = ${target} + ${contribution(column, 'NEW')} - ${contribution(column, 'OLD')}`,
|
|
255
|
+
]
|
|
256
|
+
: [];
|
|
257
|
+
}
|
|
258
|
+
const operator = operation === 'add' ? '+' : '-';
|
|
259
|
+
const row = operation === 'add' ? 'NEW' : 'OLD';
|
|
260
|
+
return [`${target} = ${target} ${operator} ${contribution(column, row)}`];
|
|
261
|
+
})
|
|
262
|
+
.join(', ');
|
|
263
|
+
}
|
|
264
|
+
function insertColumns(aggregate) {
|
|
265
|
+
return [
|
|
266
|
+
...aggregate.groups.map((group) => group.target),
|
|
267
|
+
...aggregate.columns.map((a) => a.target),
|
|
268
|
+
]
|
|
269
|
+
.map((column) => quoteIdentifier(column.physical))
|
|
270
|
+
.join(', ');
|
|
271
|
+
}
|
|
272
|
+
function insertValues(aggregate, row) {
|
|
273
|
+
return [
|
|
274
|
+
...aggregate.groups.map((group) => sourceReference(group.source, row)),
|
|
275
|
+
...aggregate.columns.map((column) => contribution(column, row)),
|
|
276
|
+
].join(', ');
|
|
277
|
+
}
|
|
278
|
+
function upsertAssignments(aggregate) {
|
|
279
|
+
return aggregate.columns
|
|
280
|
+
.map((column) => {
|
|
281
|
+
const target = quoteIdentifier(column.target.physical);
|
|
282
|
+
return `${target} = ${target} + excluded.${target}`;
|
|
283
|
+
})
|
|
284
|
+
.join(', ');
|
|
285
|
+
}
|
|
286
|
+
function countTarget(aggregate) {
|
|
287
|
+
const column = aggregate.columns.find((candidate) => candidate.kind === 'count');
|
|
288
|
+
if (!column) {
|
|
289
|
+
throw new TypeError(`materialized aggregate ${aggregate.name} has no count column`);
|
|
290
|
+
}
|
|
291
|
+
return column.target;
|
|
292
|
+
}
|
|
293
|
+
function watchedSourceColumns(aggregate) {
|
|
294
|
+
return [
|
|
295
|
+
...new Set([
|
|
296
|
+
...aggregate.groups.map((group) => group.source.physical),
|
|
297
|
+
...aggregate.columns.flatMap((column) => column.kind === 'sum' ? [column.source.physical] : []),
|
|
298
|
+
]),
|
|
299
|
+
]
|
|
300
|
+
.map(quoteIdentifier)
|
|
301
|
+
.join(', ');
|
|
302
|
+
}
|
|
303
|
+
function backfillStatements(aggregate) {
|
|
304
|
+
const sourceTable = quoteIdentifier(aggregate.source.physical);
|
|
305
|
+
const targetTable = quoteIdentifier(aggregate.target.physical);
|
|
306
|
+
if (aggregate.mode === 'materialized') {
|
|
307
|
+
const select = [
|
|
308
|
+
...aggregate.groups.map((group) => quoteIdentifier(group.source.physical)),
|
|
309
|
+
...aggregate.columns.map((column) => column.kind === 'count'
|
|
310
|
+
? 'COUNT(*)'
|
|
311
|
+
: `COALESCE(SUM(${quoteIdentifier(column.source.physical)}), 0)`),
|
|
312
|
+
].join(', ');
|
|
313
|
+
const groupBy = aggregate.groups
|
|
314
|
+
.map((group) => quoteIdentifier(group.source.physical))
|
|
315
|
+
.join(', ');
|
|
316
|
+
return [
|
|
317
|
+
`DELETE FROM ${targetTable}`,
|
|
318
|
+
`INSERT INTO ${targetTable} (${insertColumns(aggregate)}) SELECT ${select} FROM ${sourceTable} GROUP BY ${groupBy}`,
|
|
319
|
+
];
|
|
320
|
+
}
|
|
321
|
+
const correlation = aggregate.groups
|
|
322
|
+
.map((group) => `${sourceTable}.${quoteIdentifier(group.source.physical)} IS ${targetTable}.${quoteIdentifier(group.target.physical)}`)
|
|
323
|
+
.join(' AND ');
|
|
324
|
+
const values = aggregate.columns
|
|
325
|
+
.map((column) => {
|
|
326
|
+
const target = quoteIdentifier(column.target.physical);
|
|
327
|
+
const value = column.kind === 'count'
|
|
328
|
+
? `COUNT(*)`
|
|
329
|
+
: `COALESCE(SUM(${sourceTable}.${quoteIdentifier(column.source.physical)}), 0)`;
|
|
330
|
+
return `${target} = (SELECT ${value} FROM ${sourceTable} WHERE ${correlation})`;
|
|
331
|
+
})
|
|
332
|
+
.join(', ');
|
|
333
|
+
return [`UPDATE ${targetTable} SET ${values}`];
|
|
334
|
+
}
|
|
335
|
+
function triggerStatements(aggregate) {
|
|
336
|
+
const sourceTable = quoteIdentifier(aggregate.source.physical);
|
|
337
|
+
const targetTable = quoteIdentifier(aggregate.target.physical);
|
|
338
|
+
const insertTrigger = quoteIdentifier(`_orez_aggregate_${aggregate.name}_insert`);
|
|
339
|
+
const updateTrigger = quoteIdentifier(`_orez_aggregate_${aggregate.name}_update`);
|
|
340
|
+
const deleteTrigger = quoteIdentifier(`_orez_aggregate_${aggregate.name}_delete`);
|
|
341
|
+
const keyConflict = aggregate.groups
|
|
342
|
+
.map((group) => quoteIdentifier(group.target.physical))
|
|
343
|
+
.join(', ');
|
|
344
|
+
const changedGroup = `NOT (${sameGroup(aggregate)})`;
|
|
345
|
+
const sumsChanged = aggregateChanged(aggregate);
|
|
346
|
+
if (aggregate.mode === 'existing') {
|
|
347
|
+
const updateColumns = watchedSourceColumns(aggregate);
|
|
348
|
+
const updateBody = [
|
|
349
|
+
`UPDATE ${targetTable} SET ${assignments(aggregate, 'remove')} WHERE ${targetWhere(aggregate, 'OLD')} AND ${changedGroup};`,
|
|
350
|
+
`UPDATE ${targetTable} SET ${assignments(aggregate, 'add')} WHERE ${targetWhere(aggregate, 'NEW')} AND ${changedGroup};`,
|
|
351
|
+
...(assignments(aggregate, 'delta')
|
|
352
|
+
? [
|
|
353
|
+
`UPDATE ${targetTable} SET ${assignments(aggregate, 'delta')} WHERE ${targetWhere(aggregate, 'NEW')} AND (${sameGroup(aggregate)}) AND (${sumsChanged});`,
|
|
354
|
+
]
|
|
355
|
+
: []),
|
|
356
|
+
].join(' ');
|
|
357
|
+
return [
|
|
358
|
+
`CREATE TRIGGER ${insertTrigger} AFTER INSERT ON ${sourceTable} BEGIN UPDATE ${targetTable} SET ${assignments(aggregate, 'add')} WHERE ${targetWhere(aggregate, 'NEW')}; END`,
|
|
359
|
+
`CREATE TRIGGER ${updateTrigger} AFTER UPDATE OF ${updateColumns} ON ${sourceTable} BEGIN ${updateBody} END`,
|
|
360
|
+
`CREATE TRIGGER ${deleteTrigger} AFTER DELETE ON ${sourceTable} BEGIN UPDATE ${targetTable} SET ${assignments(aggregate, 'remove')} WHERE ${targetWhere(aggregate, 'OLD')}; END`,
|
|
361
|
+
];
|
|
362
|
+
}
|
|
363
|
+
const countColumn = quoteIdentifier(countTarget(aggregate).physical);
|
|
364
|
+
const insert = `INSERT INTO ${targetTable} (${insertColumns(aggregate)}) VALUES (${insertValues(aggregate, 'NEW')}) ON CONFLICT (${keyConflict}) DO UPDATE SET ${upsertAssignments(aggregate)};`;
|
|
365
|
+
const updateColumns = watchedSourceColumns(aggregate);
|
|
366
|
+
const updateBody = [
|
|
367
|
+
`UPDATE ${targetTable} SET ${assignments(aggregate, 'remove')} WHERE ${targetWhere(aggregate, 'OLD')} AND ${changedGroup};`,
|
|
368
|
+
`DELETE FROM ${targetTable} WHERE ${targetWhere(aggregate, 'OLD')} AND ${countColumn} <= 0 AND ${changedGroup};`,
|
|
369
|
+
`INSERT INTO ${targetTable} (${insertColumns(aggregate)}) SELECT ${insertValues(aggregate, 'NEW')} WHERE ${changedGroup} ON CONFLICT (${keyConflict}) DO UPDATE SET ${upsertAssignments(aggregate)};`,
|
|
370
|
+
...(assignments(aggregate, 'delta')
|
|
371
|
+
? [
|
|
372
|
+
`UPDATE ${targetTable} SET ${assignments(aggregate, 'delta')} WHERE ${targetWhere(aggregate, 'NEW')} AND (${sameGroup(aggregate)}) AND (${sumsChanged});`,
|
|
373
|
+
]
|
|
374
|
+
: []),
|
|
375
|
+
].join(' ');
|
|
376
|
+
return [
|
|
377
|
+
`CREATE TRIGGER ${insertTrigger} AFTER INSERT ON ${sourceTable} BEGIN ${insert} END`,
|
|
378
|
+
`CREATE TRIGGER ${updateTrigger} AFTER UPDATE OF ${updateColumns} ON ${sourceTable} BEGIN ${updateBody} END`,
|
|
379
|
+
`CREATE TRIGGER ${deleteTrigger} AFTER DELETE ON ${sourceTable} BEGIN UPDATE ${targetTable} SET ${assignments(aggregate, 'remove')} WHERE ${targetWhere(aggregate, 'OLD')}; DELETE FROM ${targetTable} WHERE ${targetWhere(aggregate, 'OLD')} AND ${countColumn} <= 0; END`,
|
|
380
|
+
];
|
|
381
|
+
}
|
|
382
|
+
export function aggregateMigrationStatements(aggregates) {
|
|
383
|
+
return aggregates[AGGREGATE_RUNTIME].definitions.flatMap((aggregate) => {
|
|
384
|
+
const triggerNames = ['insert', 'update', 'delete'].map((operation) => quoteIdentifier(`_orez_aggregate_${aggregate.name}_${operation}`));
|
|
385
|
+
return [
|
|
386
|
+
...triggerNames.map((name) => `DROP TRIGGER IF EXISTS ${name}`),
|
|
387
|
+
...backfillStatements(aggregate),
|
|
388
|
+
...triggerStatements(aggregate),
|
|
389
|
+
];
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
export function aggregateMigrationSQL(aggregates) {
|
|
393
|
+
return aggregateMigrationStatements(aggregates).join('\n--> statement-breakpoint\n');
|
|
394
|
+
}
|
|
395
|
+
function aggregateRow(value, context) {
|
|
396
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
397
|
+
throw new TypeError(`${context} must be a row object`);
|
|
398
|
+
}
|
|
399
|
+
return Object.fromEntries(Object.entries(value));
|
|
400
|
+
}
|
|
401
|
+
function numericValue(value, context, nullable) {
|
|
402
|
+
if (nullable && value === null)
|
|
403
|
+
return 0;
|
|
404
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
405
|
+
throw new TypeError(`${context} must be a finite number`);
|
|
406
|
+
}
|
|
407
|
+
return value;
|
|
408
|
+
}
|
|
409
|
+
async function readAggregateRow(tx, builder, tableName, primaryKey, key) {
|
|
410
|
+
let query = Reflect.get(builder, tableName);
|
|
411
|
+
if (!query || typeof query !== 'object') {
|
|
412
|
+
throw new TypeError(`aggregate query table is unavailable: ${tableName}`);
|
|
413
|
+
}
|
|
414
|
+
for (const columnName of primaryKey) {
|
|
415
|
+
const value = key[columnName];
|
|
416
|
+
if (value === undefined || value === null) {
|
|
417
|
+
throw new TypeError(`aggregate key ${tableName}.${columnName} must be non-null`);
|
|
418
|
+
}
|
|
419
|
+
const where = Reflect.get(query, 'where');
|
|
420
|
+
if (typeof where !== 'function') {
|
|
421
|
+
throw new TypeError(`aggregate query does not support where(): ${tableName}`);
|
|
422
|
+
}
|
|
423
|
+
query = Reflect.apply(where, query, [columnName, '=', value]);
|
|
424
|
+
if (!query || typeof query !== 'object') {
|
|
425
|
+
throw new TypeError(`aggregate query returned an invalid value: ${tableName}`);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
const one = Reflect.get(query, 'one');
|
|
429
|
+
const run = Reflect.get(tx, 'run');
|
|
430
|
+
if (typeof one !== 'function' || typeof run !== 'function') {
|
|
431
|
+
throw new TypeError(`aggregate transaction cannot read ${tableName}`);
|
|
432
|
+
}
|
|
433
|
+
const singular = Reflect.apply(one, query, []);
|
|
434
|
+
const result = await Reflect.apply(run, tx, [singular]);
|
|
435
|
+
return result === undefined || result === null
|
|
436
|
+
? null
|
|
437
|
+
: aggregateRow(result, `aggregate query result for ${tableName}`);
|
|
438
|
+
}
|
|
439
|
+
function groupKey(aggregate, row) {
|
|
440
|
+
const key = {};
|
|
441
|
+
for (const group of aggregate.groups) {
|
|
442
|
+
const value = row[group.source.logical];
|
|
443
|
+
if (value === undefined || value === null) {
|
|
444
|
+
throw new TypeError(`aggregate ${aggregate.name} group ${aggregate.source.logical}.${group.source.logical} must be non-null`);
|
|
445
|
+
}
|
|
446
|
+
key[group.target.logical] = value;
|
|
447
|
+
}
|
|
448
|
+
return key;
|
|
449
|
+
}
|
|
450
|
+
function sameKey(aggregate, left, right) {
|
|
451
|
+
return aggregate.groups.every((group) => Object.is(left[group.target.logical], right[group.target.logical]));
|
|
452
|
+
}
|
|
453
|
+
function aggregateContribution(aggregate, row) {
|
|
454
|
+
const contribution = {};
|
|
455
|
+
for (const column of aggregate.columns) {
|
|
456
|
+
contribution[column.target.logical] =
|
|
457
|
+
column.kind === 'count'
|
|
458
|
+
? 1
|
|
459
|
+
: numericValue(row[column.source.logical], `aggregate ${aggregate.name} source ${aggregate.source.logical}.${column.source.logical}`, true);
|
|
460
|
+
}
|
|
461
|
+
return contribution;
|
|
462
|
+
}
|
|
463
|
+
async function mutateAggregateTarget(tx, aggregate, operation, row) {
|
|
464
|
+
const mutate = Reflect.get(tx, 'mutate');
|
|
465
|
+
if (!mutate || typeof mutate !== 'object') {
|
|
466
|
+
throw new TypeError(`aggregate transaction has no mutate object`);
|
|
467
|
+
}
|
|
468
|
+
const tableMutate = Reflect.get(mutate, aggregate.target.logical);
|
|
469
|
+
if (!tableMutate || typeof tableMutate !== 'object') {
|
|
470
|
+
throw new TypeError(`aggregate target mutation is unavailable: ${aggregate.target.logical}`);
|
|
471
|
+
}
|
|
472
|
+
const method = Reflect.get(tableMutate, operation);
|
|
473
|
+
if (typeof method !== 'function') {
|
|
474
|
+
throw new TypeError(`aggregate target mutation is unavailable: ${aggregate.target.logical}.${operation}`);
|
|
475
|
+
}
|
|
476
|
+
await Reflect.apply(method, tableMutate, [row]);
|
|
477
|
+
}
|
|
478
|
+
async function addAggregateContribution(tx, builder, aggregate, sourceRow) {
|
|
479
|
+
const key = groupKey(aggregate, sourceRow);
|
|
480
|
+
const targetRow = await readAggregateRow(tx, builder, aggregate.target.logical, aggregate.target.primaryKey, key);
|
|
481
|
+
const contribution = aggregateContribution(aggregate, sourceRow);
|
|
482
|
+
if (!targetRow) {
|
|
483
|
+
if (aggregate.mode === 'materialized') {
|
|
484
|
+
await mutateAggregateTarget(tx, aggregate, 'insert', { ...key, ...contribution });
|
|
485
|
+
}
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
const update = { ...key };
|
|
489
|
+
for (const column of aggregate.columns) {
|
|
490
|
+
update[column.target.logical] =
|
|
491
|
+
numericValue(targetRow[column.target.logical], `aggregate ${aggregate.name} target ${aggregate.target.logical}.${column.target.logical}`, false) + contribution[column.target.logical];
|
|
492
|
+
}
|
|
493
|
+
await mutateAggregateTarget(tx, aggregate, 'update', update);
|
|
494
|
+
}
|
|
495
|
+
async function removeAggregateContribution(tx, builder, aggregate, sourceRow) {
|
|
496
|
+
const key = groupKey(aggregate, sourceRow);
|
|
497
|
+
const targetRow = await readAggregateRow(tx, builder, aggregate.target.logical, aggregate.target.primaryKey, key);
|
|
498
|
+
if (!targetRow)
|
|
499
|
+
return;
|
|
500
|
+
const contribution = aggregateContribution(aggregate, sourceRow);
|
|
501
|
+
const update = { ...key };
|
|
502
|
+
let remainingCount = null;
|
|
503
|
+
for (const column of aggregate.columns) {
|
|
504
|
+
const next = numericValue(targetRow[column.target.logical], `aggregate ${aggregate.name} target ${aggregate.target.logical}.${column.target.logical}`, false) - contribution[column.target.logical];
|
|
505
|
+
update[column.target.logical] = next;
|
|
506
|
+
if (column.kind === 'count')
|
|
507
|
+
remainingCount = next;
|
|
508
|
+
}
|
|
509
|
+
if (aggregate.mode === 'materialized' &&
|
|
510
|
+
remainingCount !== null &&
|
|
511
|
+
remainingCount <= 0) {
|
|
512
|
+
await mutateAggregateTarget(tx, aggregate, 'delete', key);
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
await mutateAggregateTarget(tx, aggregate, 'update', update);
|
|
516
|
+
}
|
|
517
|
+
async function updateAggregateContribution(tx, builder, aggregate, previousSourceRow, nextSourceRow) {
|
|
518
|
+
const previousKey = groupKey(aggregate, previousSourceRow);
|
|
519
|
+
const nextKey = groupKey(aggregate, nextSourceRow);
|
|
520
|
+
if (!sameKey(aggregate, previousKey, nextKey)) {
|
|
521
|
+
await removeAggregateContribution(tx, builder, aggregate, previousSourceRow);
|
|
522
|
+
await addAggregateContribution(tx, builder, aggregate, nextSourceRow);
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
const previous = aggregateContribution(aggregate, previousSourceRow);
|
|
526
|
+
const next = aggregateContribution(aggregate, nextSourceRow);
|
|
527
|
+
if (aggregate.columns.every((column) => Object.is(previous[column.target.logical], next[column.target.logical]))) {
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
const targetRow = await readAggregateRow(tx, builder, aggregate.target.logical, aggregate.target.primaryKey, nextKey);
|
|
531
|
+
if (!targetRow)
|
|
532
|
+
return;
|
|
533
|
+
const update = { ...nextKey };
|
|
534
|
+
for (const column of aggregate.columns) {
|
|
535
|
+
update[column.target.logical] =
|
|
536
|
+
numericValue(targetRow[column.target.logical], `aggregate ${aggregate.name} target ${aggregate.target.logical}.${column.target.logical}`, false) +
|
|
537
|
+
next[column.target.logical] -
|
|
538
|
+
previous[column.target.logical];
|
|
539
|
+
}
|
|
540
|
+
await mutateAggregateTarget(tx, aggregate, 'update', update);
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* Wrap a Zero client transaction so source-table CRUD also projects its
|
|
544
|
+
* declared aggregates. Server transactions are returned unchanged because SQLite
|
|
545
|
+
* triggers own the authoritative write.
|
|
546
|
+
*/
|
|
547
|
+
export function withOptimisticAggregates(tx, aggregates) {
|
|
548
|
+
const runtime = aggregates[AGGREGATE_RUNTIME];
|
|
549
|
+
if (Reflect.get(tx, 'location') !== 'client' || runtime.definitions.length === 0) {
|
|
550
|
+
return tx;
|
|
551
|
+
}
|
|
552
|
+
const baseMutate = Reflect.get(tx, 'mutate');
|
|
553
|
+
if (!baseMutate || typeof baseMutate !== 'object') {
|
|
554
|
+
throw new TypeError(`aggregate transaction has no mutate object`);
|
|
555
|
+
}
|
|
556
|
+
const builder = runtime.queryBuilder;
|
|
557
|
+
const bySource = runtime.bySource;
|
|
558
|
+
const tableProxies = new Map();
|
|
559
|
+
const wrappedMutate = new Proxy(baseMutate, {
|
|
560
|
+
get(target, tableName, receiver) {
|
|
561
|
+
if (typeof tableName !== 'string' || !bySource.has(tableName)) {
|
|
562
|
+
return Reflect.get(target, tableName, receiver);
|
|
563
|
+
}
|
|
564
|
+
const cached = tableProxies.get(tableName);
|
|
565
|
+
if (cached)
|
|
566
|
+
return cached;
|
|
567
|
+
const tableMutate = Reflect.get(target, tableName, receiver);
|
|
568
|
+
if (!tableMutate || typeof tableMutate !== 'object')
|
|
569
|
+
return tableMutate;
|
|
570
|
+
const tableProxy = new Proxy(tableMutate, {
|
|
571
|
+
get(tableTarget, operation, tableReceiver) {
|
|
572
|
+
const method = Reflect.get(tableTarget, operation, tableReceiver);
|
|
573
|
+
if (typeof operation !== 'string' ||
|
|
574
|
+
!['insert', 'upsert', 'update', 'delete'].includes(operation) ||
|
|
575
|
+
typeof method !== 'function') {
|
|
576
|
+
return method;
|
|
577
|
+
}
|
|
578
|
+
return async (...args) => {
|
|
579
|
+
const input = aggregateRow(args[0], `aggregate source mutation ${tableName}.${operation}`);
|
|
580
|
+
const definitions = bySource.get(tableName);
|
|
581
|
+
const source = definitions[0].source;
|
|
582
|
+
const previous = operation === 'insert'
|
|
583
|
+
? null
|
|
584
|
+
: await readAggregateRow(tx, builder, source.logical, source.primaryKey, input);
|
|
585
|
+
await Reflect.apply(method, tableTarget, args);
|
|
586
|
+
const next = operation === 'delete'
|
|
587
|
+
? null
|
|
588
|
+
: await readAggregateRow(tx, builder, source.logical, source.primaryKey, input);
|
|
589
|
+
for (const aggregate of definitions) {
|
|
590
|
+
if (previous && next) {
|
|
591
|
+
await updateAggregateContribution(tx, builder, aggregate, previous, next);
|
|
592
|
+
}
|
|
593
|
+
else if (previous) {
|
|
594
|
+
await removeAggregateContribution(tx, builder, aggregate, previous);
|
|
595
|
+
}
|
|
596
|
+
else if (next) {
|
|
597
|
+
await addAggregateContribution(tx, builder, aggregate, next);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
};
|
|
601
|
+
},
|
|
602
|
+
});
|
|
603
|
+
tableProxies.set(tableName, tableProxy);
|
|
604
|
+
return tableProxy;
|
|
605
|
+
},
|
|
606
|
+
});
|
|
607
|
+
return new Proxy(tx, {
|
|
608
|
+
get(target, property, receiver) {
|
|
609
|
+
if (property === 'mutate')
|
|
610
|
+
return wrappedMutate;
|
|
611
|
+
const value = Reflect.get(target, property, receiver);
|
|
612
|
+
return typeof value === 'function' ? value.bind(target) : value;
|
|
613
|
+
},
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
//# sourceMappingURL=aggregate.js.map
|