turbine-orm 0.34.0 → 0.36.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 +18 -16
- package/dist/cjs/cli/index.js +109 -16
- package/dist/cjs/cli/migrate.js +78 -3
- package/dist/cjs/cli/studio-ui.generated.js +1 -1
- package/dist/cjs/cli/studio.js +333 -22
- package/dist/cjs/cli/ui.js +7 -1
- package/dist/cjs/client.js +26 -4
- package/dist/cjs/dialect.js +2 -1
- package/dist/cjs/errors.js +41 -1
- package/dist/cjs/generate.js +23 -2
- package/dist/cjs/index.js +4 -2
- package/dist/cjs/mssql.js +27 -5
- package/dist/cjs/mysql.js +4 -0
- package/dist/cjs/powdb.js +197 -25
- package/dist/cjs/powql.js +515 -51
- package/dist/cjs/query/aggregates.js +683 -0
- package/dist/cjs/query/batched-loader.js +2 -0
- package/dist/cjs/query/builder.js +361 -4508
- package/dist/cjs/query/filters.js +12 -0
- package/dist/cjs/query/relations.js +1698 -0
- package/dist/cjs/query/where-compile.js +180 -0
- package/dist/cjs/query/where.js +1491 -0
- package/dist/cjs/query/writes.js +680 -0
- package/dist/cjs/schema-builder.js +6 -0
- package/dist/cjs/schema-metadata.js +4 -0
- package/dist/cjs/schema-sql.js +265 -3
- package/dist/cjs/sqlite.js +4 -1
- package/dist/cli/index.d.ts +8 -2
- package/dist/cli/index.js +111 -18
- package/dist/cli/migrate.d.ts +24 -1
- package/dist/cli/migrate.js +77 -3
- package/dist/cli/studio-ui.generated.js +1 -1
- package/dist/cli/studio.d.ts +46 -13
- package/dist/cli/studio.js +331 -23
- package/dist/cli/ui.js +7 -1
- package/dist/client.d.ts +32 -5
- package/dist/client.js +26 -4
- package/dist/dialect.d.ts +28 -6
- package/dist/dialect.js +2 -1
- package/dist/errors.d.ts +36 -0
- package/dist/errors.js +39 -0
- package/dist/generate.js +23 -2
- package/dist/index.d.ts +3 -3
- package/dist/index.js +2 -2
- package/dist/mssql.js +27 -5
- package/dist/mysql.js +4 -0
- package/dist/powdb.d.ts +135 -9
- package/dist/powdb.js +197 -25
- package/dist/powql.d.ts +166 -4
- package/dist/powql.js +516 -52
- package/dist/query/aggregates.d.ts +74 -0
- package/dist/query/aggregates.js +641 -0
- package/dist/query/batched-loader.d.ts +6 -0
- package/dist/query/batched-loader.js +2 -0
- package/dist/query/builder.d.ts +98 -830
- package/dist/query/builder.js +366 -4513
- package/dist/query/deferred.d.ts +13 -2
- package/dist/query/filters.d.ts +7 -0
- package/dist/query/filters.js +11 -0
- package/dist/query/relations.d.ts +441 -0
- package/dist/query/relations.js +1627 -0
- package/dist/query/types.d.ts +25 -6
- package/dist/query/where-compile.d.ts +139 -0
- package/dist/query/where-compile.js +175 -0
- package/dist/query/where.d.ts +494 -0
- package/dist/query/where.js +1431 -0
- package/dist/query/writes.d.ts +131 -0
- package/dist/query/writes.js +626 -0
- package/dist/schema-builder.d.ts +18 -3
- package/dist/schema-builder.js +6 -0
- package/dist/schema-metadata.js +4 -0
- package/dist/schema-sql.d.ts +60 -3
- package/dist/schema-sql.js +261 -4
- package/dist/schema.d.ts +10 -0
- package/dist/sqlite.js +4 -1
- package/package.json +4 -4
|
@@ -0,0 +1,626 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* turbine-orm: write compilation (extracted from builder.ts)
|
|
3
|
+
*
|
|
4
|
+
* SQL builders for the mutating operations (create / createMany / update /
|
|
5
|
+
* delete / upsert / updateMany / deleteMany) plus the write-projection helpers
|
|
6
|
+
* (writeReturningColumns / writeReselectSelection / parseWriteRow, the PII
|
|
7
|
+
* column set, optimistic-lock and atomic-operator SET clauses). All functions
|
|
8
|
+
* take a {@link BuilderCtx} first argument; WHERE compilation is reused from
|
|
9
|
+
* where.ts (via `whereMod`), and the cache / dialect / row-parse primitives
|
|
10
|
+
* stay class-resident, reached through the ctx. See builder.ts for the thin
|
|
11
|
+
* delegating methods and the async execute wrappers.
|
|
12
|
+
*/
|
|
13
|
+
import { NotFoundError, OptimisticLockError, ValidationError } from '../errors.js';
|
|
14
|
+
import { camelToSnake, snakeToCamel } from '../schema.js';
|
|
15
|
+
import { UPDATE_OPERATOR_KEYS } from './filters.js';
|
|
16
|
+
import * as whereMod from './where.js';
|
|
17
|
+
/**
|
|
18
|
+
* Build a `SELECT * ... WHERE <predicate>` that re-fetches the row(s) matched
|
|
19
|
+
* by a write's `where` clause. Used by the `'reselect'` result strategy to
|
|
20
|
+
* return rows from non-RETURNING engines. Reuses the same parameterized WHERE
|
|
21
|
+
* builder as reads, so no user value is interpolated.
|
|
22
|
+
*/
|
|
23
|
+
export function buildReselectByWhere(qi, whereObj) {
|
|
24
|
+
const params = [];
|
|
25
|
+
const clause = whereMod.buildWhereClause(qi, whereObj, params);
|
|
26
|
+
const where = clause ? ` WHERE ${clause}` : '';
|
|
27
|
+
return { sql: `SELECT ${writeReselectSelection(qi)} FROM ${qi.q(qi.table)}${where}`, params };
|
|
28
|
+
}
|
|
29
|
+
export function buildCreate(qi, args) {
|
|
30
|
+
assertWritable(qi, 'create');
|
|
31
|
+
assertNoGeneratedColumns(qi, args.data, 'create');
|
|
32
|
+
const entries = Object.entries(args.data).filter(([, v]) => v !== undefined);
|
|
33
|
+
const columns = entries.map(([k]) => qi.toSqlColumn(k));
|
|
34
|
+
const params = entries.map(([, v]) => v);
|
|
35
|
+
// Enum columns get an explicit `::"EnumName"` cast (see enumTypeForColumn).
|
|
36
|
+
const placeholders = entries.map(([k], i) => `${qi.p(i + 1)}${whereMod.enumCastSuffix(qi, qi.toColumn(k))}`);
|
|
37
|
+
const sql = qi.dialect.buildInsertStatement({
|
|
38
|
+
table: qi.q(qi.table),
|
|
39
|
+
columns,
|
|
40
|
+
valuePlaceholders: placeholders,
|
|
41
|
+
returning: writeReturningColumns(qi),
|
|
42
|
+
});
|
|
43
|
+
return {
|
|
44
|
+
sql,
|
|
45
|
+
params,
|
|
46
|
+
transform: (result) => {
|
|
47
|
+
const row = result.rows[0];
|
|
48
|
+
if (!row) {
|
|
49
|
+
throw new NotFoundError({
|
|
50
|
+
table: qi.table,
|
|
51
|
+
operation: 'create',
|
|
52
|
+
message: `[turbine] create on "${qi.table}" returned no row from RETURNING *; this should never happen.`,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
return parseWriteRow(qi, row);
|
|
56
|
+
},
|
|
57
|
+
tag: `${qi.table}.create`,
|
|
58
|
+
// Non-RETURNING engines: INSERT, then re-fetch the new row by primary key
|
|
59
|
+
// (provided value, else the driver's generated insert id).
|
|
60
|
+
reselect: makeCreateReselect(qi, sql, params, args.data),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Build the `'reselect'` plan for {@link buildCreate}: run the INSERT, then
|
|
65
|
+
* `SELECT * WHERE pk = ?`. Returns `undefined` (skipped) unless the active
|
|
66
|
+
* dialect's result strategy is `'reselect'`, so the PostgreSQL/RETURNING path
|
|
67
|
+
* pays nothing. Not yet wired to a real non-RETURNING engine.
|
|
68
|
+
*/
|
|
69
|
+
export function makeCreateReselect(qi, insertSql, insertParams, data) {
|
|
70
|
+
if (qi.dialect.resultStrategy !== 'reselect')
|
|
71
|
+
return undefined;
|
|
72
|
+
return async (exec) => {
|
|
73
|
+
const writeResult = await exec(insertSql, insertParams);
|
|
74
|
+
const insertId = qi.mutationInsertId(writeResult);
|
|
75
|
+
const conds = [];
|
|
76
|
+
const selParams = [];
|
|
77
|
+
let idx = 1;
|
|
78
|
+
for (const pk of qi.tableMeta.primaryKey) {
|
|
79
|
+
const field = qi.tableMeta.reverseColumnMap[pk] ?? snakeToCamel(pk);
|
|
80
|
+
selParams.push(data[field] ?? data[pk] ?? insertId);
|
|
81
|
+
conds.push(`${qi.q(pk)} = ${qi.p(idx++)}`);
|
|
82
|
+
}
|
|
83
|
+
const where = conds.length > 0 ? ` WHERE ${conds.join(' AND ')}` : '';
|
|
84
|
+
return exec(`SELECT ${writeReselectSelection(qi)} FROM ${qi.q(qi.table)}${where}`, selParams);
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
export function buildCreateMany(qi, args) {
|
|
88
|
+
const qt = qi.q(qi.table);
|
|
89
|
+
if (args.data.length === 0) {
|
|
90
|
+
return {
|
|
91
|
+
sql: `SELECT * FROM ${qt} WHERE false`,
|
|
92
|
+
params: [],
|
|
93
|
+
transform: () => [],
|
|
94
|
+
tag: `${qi.table}.createMany`,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
assertWritable(qi, 'createMany');
|
|
98
|
+
for (const row of args.data) {
|
|
99
|
+
assertNoGeneratedColumns(qi, row, 'createMany');
|
|
100
|
+
}
|
|
101
|
+
const keys = Object.keys(args.data[0]).filter((k) => args.data[0][k] !== undefined);
|
|
102
|
+
const columns = keys.map((k) => qi.toColumn(k));
|
|
103
|
+
const rowValues = args.data.map((row) => {
|
|
104
|
+
const record = row;
|
|
105
|
+
return keys.map((key) => record[key]);
|
|
106
|
+
});
|
|
107
|
+
// Use actual Postgres types for array casts in the default PostgreSQL dialect.
|
|
108
|
+
// Enum columns cast to `"EnumName"[]` — the generic text[] fallback would
|
|
109
|
+
// type the UNNEST output as text, which Postgres refuses to coerce to the
|
|
110
|
+
// enum ("column X is of type Y but expression is of type text").
|
|
111
|
+
const typeCasts = columns.map((col) => {
|
|
112
|
+
const enumType = whereMod.enumTypeForColumn(qi, col);
|
|
113
|
+
return enumType ? `${qi.q(enumType)}[]` : whereMod.getColumnArrayType(qi, col);
|
|
114
|
+
});
|
|
115
|
+
const quotedColumns = columns.map((c) => qi.q(c));
|
|
116
|
+
const built = qi.dialect.buildBulkInsertStatement({
|
|
117
|
+
table: qt,
|
|
118
|
+
columns: quotedColumns,
|
|
119
|
+
rowValues,
|
|
120
|
+
columnArrayTypes: typeCasts,
|
|
121
|
+
skipDuplicates: args.skipDuplicates,
|
|
122
|
+
returning: writeReturningColumns(qi),
|
|
123
|
+
});
|
|
124
|
+
return {
|
|
125
|
+
sql: built.sql,
|
|
126
|
+
params: built.params,
|
|
127
|
+
transform: (result) => result.rows.map((row) => parseWriteRow(qi, row)),
|
|
128
|
+
tag: `${qi.table}.createMany`,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
export function buildUpdate(qi, args) {
|
|
132
|
+
assertWritable(qi, 'update');
|
|
133
|
+
qi.currentSkip = args.skipGlobalFilters;
|
|
134
|
+
const dataObj = args.data;
|
|
135
|
+
assertNoGeneratedColumns(qi, dataObj, 'update');
|
|
136
|
+
const userWhere = args.where;
|
|
137
|
+
const lock = args.optimisticLock;
|
|
138
|
+
// The empty-`where` guard checks the USER predicate only — a global filter
|
|
139
|
+
// must never turn an unguarded mass update into an allowed one.
|
|
140
|
+
const userHasPredicate = !whereMod.userPredicateIsEmpty(qi, userWhere) || !!lock;
|
|
141
|
+
whereMod.assertMutationHasPredicate(qi, 'update', userHasPredicate ? ' WHERE x' : '', args.allowFullTableScan);
|
|
142
|
+
// The SQL is built from the global-filter-merged where (soft-delete keeps an
|
|
143
|
+
// update from touching already-deleted rows).
|
|
144
|
+
const whereObj = (whereMod.mergeGlobalFilter(qi, userWhere) ?? {});
|
|
145
|
+
const setFp = fingerprintSet(qi, dataObj);
|
|
146
|
+
const whereFp = whereMod.fingerprintWhere(qi, whereObj);
|
|
147
|
+
const ck = lock ? null : `u:${setFp}|${whereFp}${whereMod.globalFilterCacheSegment(qi)}`;
|
|
148
|
+
const params = [];
|
|
149
|
+
const buildSql = (freshParams) => {
|
|
150
|
+
const setEntries = Object.entries(dataObj).filter(([, v]) => v !== undefined);
|
|
151
|
+
const setClauses = setEntries.map(([k, v]) => buildSetClause(qi, k, v, freshParams));
|
|
152
|
+
if (lock) {
|
|
153
|
+
const versionCol = qi.toSqlColumn(lock.field);
|
|
154
|
+
setClauses.push(`${versionCol} = ${versionCol} + 1`);
|
|
155
|
+
}
|
|
156
|
+
const whereClause = whereMod.buildWhereClause(qi, whereObj, freshParams);
|
|
157
|
+
let whereSql = whereClause ? ` WHERE ${whereClause}` : '';
|
|
158
|
+
if (lock) {
|
|
159
|
+
const versionCol = qi.toSqlColumn(lock.field);
|
|
160
|
+
freshParams.push(lock.expected);
|
|
161
|
+
const versionCheck = `${versionCol} = ${qi.p(freshParams.length)}`;
|
|
162
|
+
whereSql = whereSql ? `${whereSql} AND ${versionCheck}` : ` WHERE ${versionCheck}`;
|
|
163
|
+
}
|
|
164
|
+
// Engines that inject their returning shape MID-statement (SQL Server
|
|
165
|
+
// `OUTPUT INSERTED.*` between SET and WHERE) override buildUpdateStatement;
|
|
166
|
+
// absent → the trailing-clause PG/SQLite/MySQL form (byte-identical).
|
|
167
|
+
// `returning` excludes PII columns on tagged tables (else '*').
|
|
168
|
+
const returning = writeReturningColumns(qi);
|
|
169
|
+
return qi.dialect.buildUpdateStatement
|
|
170
|
+
? qi.dialect.buildUpdateStatement({ table: qi.q(qi.table), setClauses, whereSql, returning })
|
|
171
|
+
: `UPDATE ${qi.q(qi.table)} SET ${setClauses.join(', ')}${whereSql}${qi.dialect.buildReturningClause(returning)}`;
|
|
172
|
+
};
|
|
173
|
+
let sql;
|
|
174
|
+
let preparedName;
|
|
175
|
+
let cacheEntry;
|
|
176
|
+
if (ck) {
|
|
177
|
+
cacheEntry = qi.acquireSql(ck, buildSql);
|
|
178
|
+
sql = cacheEntry.sql;
|
|
179
|
+
preparedName = cacheEntry.name;
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
// optimisticLock path: value-variant version check → uncacheable, no cross-check.
|
|
183
|
+
sql = buildSql([]);
|
|
184
|
+
}
|
|
185
|
+
// Collect params: SET first, then WHERE, then version check (same order as fresh build)
|
|
186
|
+
collectSetParams(qi, dataObj, params);
|
|
187
|
+
whereMod.collectWhereParams(qi, whereObj, params);
|
|
188
|
+
if (lock) {
|
|
189
|
+
params.push(lock.expected);
|
|
190
|
+
}
|
|
191
|
+
if (ck && cacheEntry) {
|
|
192
|
+
qi.crossCheckCache('update', ck, cacheEntry, buildSql, params);
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
sql,
|
|
196
|
+
params,
|
|
197
|
+
transform: (result) => {
|
|
198
|
+
const row = result.rows[0];
|
|
199
|
+
if (!row) {
|
|
200
|
+
if (lock) {
|
|
201
|
+
throw new OptimisticLockError({
|
|
202
|
+
table: qi.table,
|
|
203
|
+
versionField: lock.field,
|
|
204
|
+
expectedVersion: lock.expected,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
throw new NotFoundError({
|
|
208
|
+
table: qi.table,
|
|
209
|
+
where: args.where,
|
|
210
|
+
operation: 'update',
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
return parseWriteRow(qi, row);
|
|
214
|
+
},
|
|
215
|
+
tag: `${qi.table}.update`,
|
|
216
|
+
preparedName,
|
|
217
|
+
// Non-RETURNING engines: UPDATE, then re-fetch the row by the same where.
|
|
218
|
+
reselect: qi.dialect.resultStrategy === 'reselect'
|
|
219
|
+
? async (exec) => {
|
|
220
|
+
const writeResult = await exec(sql, params, preparedName);
|
|
221
|
+
// Optimistic-lock conflict: the version-checked UPDATE matched no
|
|
222
|
+
// row. The re-fetch below uses `where` WITHOUT the version
|
|
223
|
+
// predicate, so it would return the stale row and silently mask
|
|
224
|
+
// the conflict — detect it from affected-rows here instead, to
|
|
225
|
+
// match the OptimisticLockError thrown on RETURNING/OUTPUT engines.
|
|
226
|
+
if (lock && (writeResult.rowCount ?? 0) === 0) {
|
|
227
|
+
throw new OptimisticLockError({
|
|
228
|
+
table: qi.table,
|
|
229
|
+
versionField: lock.field,
|
|
230
|
+
expectedVersion: lock.expected,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
const sel = buildReselectByWhere(qi, whereObj);
|
|
234
|
+
return exec(sel.sql, sel.params);
|
|
235
|
+
}
|
|
236
|
+
: undefined,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
export function buildDelete(qi, args) {
|
|
240
|
+
assertWritable(qi, 'delete');
|
|
241
|
+
qi.currentSkip = args.skipGlobalFilters;
|
|
242
|
+
// Guard the USER predicate (a global filter must not satisfy the guard).
|
|
243
|
+
whereMod.assertMutationHasPredicate(qi, 'delete', whereMod.userPredicateIsEmpty(qi, args.where) ? '' : ' WHERE x', args.allowFullTableScan);
|
|
244
|
+
const whereObj = (whereMod.mergeGlobalFilter(qi, args.where) ?? {});
|
|
245
|
+
const whereFp = whereMod.fingerprintWhere(qi, whereObj);
|
|
246
|
+
const ck = `d:${whereFp}${whereMod.globalFilterCacheSegment(qi)}`;
|
|
247
|
+
const params = [];
|
|
248
|
+
const buildSql = (freshParams) => {
|
|
249
|
+
const clause = whereMod.buildWhereClause(qi, whereObj, freshParams);
|
|
250
|
+
const whereSql = clause ? ` WHERE ${clause}` : '';
|
|
251
|
+
// SQL Server injects `OUTPUT DELETED.*` between `DELETE FROM <t>` and WHERE;
|
|
252
|
+
// absent override → the trailing-clause PG/SQLite/MySQL form (byte-identical).
|
|
253
|
+
// `returning` excludes PII columns on tagged tables (else '*').
|
|
254
|
+
const returning = writeReturningColumns(qi);
|
|
255
|
+
return qi.dialect.buildDeleteStatement
|
|
256
|
+
? qi.dialect.buildDeleteStatement({ table: qi.q(qi.table), whereSql, returning })
|
|
257
|
+
: `DELETE FROM ${qi.q(qi.table)}${whereSql}${qi.dialect.buildReturningClause(returning)}`;
|
|
258
|
+
};
|
|
259
|
+
const entry = qi.acquireSql(ck, buildSql);
|
|
260
|
+
whereMod.collectWhereParams(qi, whereObj, params);
|
|
261
|
+
qi.crossCheckCache('delete', ck, entry, buildSql, params);
|
|
262
|
+
return {
|
|
263
|
+
sql: entry.sql,
|
|
264
|
+
params,
|
|
265
|
+
transform: (result) => {
|
|
266
|
+
const row = result.rows[0];
|
|
267
|
+
if (!row) {
|
|
268
|
+
throw new NotFoundError({
|
|
269
|
+
table: qi.table,
|
|
270
|
+
where: args.where,
|
|
271
|
+
operation: 'delete',
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
return parseWriteRow(qi, row);
|
|
275
|
+
},
|
|
276
|
+
tag: `${qi.table}.delete`,
|
|
277
|
+
preparedName: entry.name,
|
|
278
|
+
// Non-RETURNING engines: the row is gone after DELETE, so pre-SELECT it
|
|
279
|
+
// by the same where, then run the DELETE, returning the captured row.
|
|
280
|
+
reselect: qi.dialect.resultStrategy === 'reselect'
|
|
281
|
+
? async (exec) => {
|
|
282
|
+
const sel = buildReselectByWhere(qi, whereObj);
|
|
283
|
+
const pre = await exec(sel.sql, sel.params);
|
|
284
|
+
await exec(entry.sql, params, entry.name);
|
|
285
|
+
return pre;
|
|
286
|
+
}
|
|
287
|
+
: undefined,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
export function buildUpsert(qi, args) {
|
|
291
|
+
assertWritable(qi, 'upsert');
|
|
292
|
+
assertNoGeneratedColumns(qi, args.create, 'upsert');
|
|
293
|
+
assertNoGeneratedColumns(qi, args.update, 'upsert');
|
|
294
|
+
qi.currentSkip = args.skipGlobalFilters;
|
|
295
|
+
// Build the INSERT part from create data
|
|
296
|
+
const createEntries = Object.entries(args.create).filter(([, v]) => v !== undefined);
|
|
297
|
+
const columns = createEntries.map(([k]) => qi.toSqlColumn(k));
|
|
298
|
+
const createParams = createEntries.map(([, v]) => v);
|
|
299
|
+
// Enum columns get an explicit `::"EnumName"` cast (see enumTypeForColumn).
|
|
300
|
+
const placeholders = createEntries.map(([k], i) => `${qi.p(i + 1)}${whereMod.enumCastSuffix(qi, qi.toColumn(k))}`);
|
|
301
|
+
// The conflict target comes from `where` keys — must be unique/PK columns
|
|
302
|
+
const conflictKeys = Object.keys(args.where).filter((k) => args.where[k] !== undefined);
|
|
303
|
+
const conflictColumns = conflictKeys.map((k) => qi.toSqlColumn(k));
|
|
304
|
+
// Build the UPDATE SET part
|
|
305
|
+
const updateEntries = Object.entries(args.update).filter(([, v]) => v !== undefined);
|
|
306
|
+
let paramIdx = createParams.length + 1;
|
|
307
|
+
const setClauses = updateEntries.map(([k]) => {
|
|
308
|
+
const clause = `${qi.toSqlColumn(k)} = ${qi.p(paramIdx)}${whereMod.enumCastSuffix(qi, qi.toColumn(k))}`;
|
|
309
|
+
paramIdx++;
|
|
310
|
+
return clause;
|
|
311
|
+
});
|
|
312
|
+
const updateParams = updateEntries.map(([, v]) => v);
|
|
313
|
+
const params = [...createParams, ...updateParams];
|
|
314
|
+
// Global filter → restrict the conflict-UPDATE (soft-delete / tenancy) so an
|
|
315
|
+
// upsert never resurrects a soft-deleted row or writes across tenants. Only
|
|
316
|
+
// on engines whose upsert can carry a predicate (Postgres); the gf params
|
|
317
|
+
// continue the placeholder numbering after create+update params.
|
|
318
|
+
let updateWhere;
|
|
319
|
+
if (qi.dialect.supportsUpsertUpdateWhere) {
|
|
320
|
+
const gf = whereMod.resolveGlobalFilter(qi, qi.table);
|
|
321
|
+
if (gf)
|
|
322
|
+
updateWhere = whereMod.buildWhereClause(qi, gf, params) ?? undefined;
|
|
323
|
+
}
|
|
324
|
+
const sql = qi.dialect.buildUpsertStatement({
|
|
325
|
+
table: qi.q(qi.table),
|
|
326
|
+
insertColumns: columns,
|
|
327
|
+
valuePlaceholders: placeholders,
|
|
328
|
+
conflictColumns,
|
|
329
|
+
updateSetClauses: setClauses,
|
|
330
|
+
updateWhere,
|
|
331
|
+
returning: writeReturningColumns(qi),
|
|
332
|
+
});
|
|
333
|
+
return {
|
|
334
|
+
sql,
|
|
335
|
+
params,
|
|
336
|
+
transform: (result) => {
|
|
337
|
+
const row = result.rows[0];
|
|
338
|
+
if (!row) {
|
|
339
|
+
throw new NotFoundError({
|
|
340
|
+
table: qi.table,
|
|
341
|
+
where: args.where,
|
|
342
|
+
operation: 'upsert',
|
|
343
|
+
message: `[turbine] upsert on "${qi.table}" returned no row from RETURNING *; this should never happen.`,
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
return parseWriteRow(qi, row);
|
|
347
|
+
},
|
|
348
|
+
tag: `${qi.table}.upsert`,
|
|
349
|
+
// Non-RETURNING engines: run the upsert, then re-fetch by the where keys.
|
|
350
|
+
reselect: qi.dialect.resultStrategy === 'reselect'
|
|
351
|
+
? async (exec) => {
|
|
352
|
+
await exec(sql, params);
|
|
353
|
+
const sel = buildReselectByWhere(qi, (whereMod.mergeGlobalFilter(qi, args.where) ?? {}));
|
|
354
|
+
return exec(sel.sql, sel.params);
|
|
355
|
+
}
|
|
356
|
+
: undefined,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
export function buildUpdateMany(qi, args) {
|
|
360
|
+
assertWritable(qi, 'updateMany');
|
|
361
|
+
qi.currentSkip = args.skipGlobalFilters;
|
|
362
|
+
const dataObj = args.data;
|
|
363
|
+
assertNoGeneratedColumns(qi, dataObj, 'updateMany');
|
|
364
|
+
whereMod.assertMutationHasPredicate(qi, 'updateMany', whereMod.userPredicateIsEmpty(qi, args.where) ? '' : ' WHERE x', args.allowFullTableScan);
|
|
365
|
+
const whereObj = (whereMod.mergeGlobalFilter(qi, args.where) ?? {});
|
|
366
|
+
const setFp = fingerprintSet(qi, dataObj);
|
|
367
|
+
const whereFp = whereMod.fingerprintWhere(qi, whereObj);
|
|
368
|
+
const ck = `um:${setFp}|${whereFp}${whereMod.globalFilterCacheSegment(qi)}`;
|
|
369
|
+
const params = [];
|
|
370
|
+
const buildSql = (freshParams) => {
|
|
371
|
+
const setEntries = Object.entries(dataObj).filter(([, v]) => v !== undefined);
|
|
372
|
+
const setClauses = setEntries.map(([k, v]) => buildSetClause(qi, k, v, freshParams));
|
|
373
|
+
const whereClause = whereMod.buildWhereClause(qi, whereObj, freshParams);
|
|
374
|
+
const whereSql = whereClause ? ` WHERE ${whereClause}` : '';
|
|
375
|
+
return `UPDATE ${qi.q(qi.table)} SET ${setClauses.join(', ')}${whereSql}`;
|
|
376
|
+
};
|
|
377
|
+
const entry = qi.acquireSql(ck, buildSql);
|
|
378
|
+
collectSetParams(qi, dataObj, params);
|
|
379
|
+
whereMod.collectWhereParams(qi, whereObj, params);
|
|
380
|
+
qi.crossCheckCache('updateMany', ck, entry, buildSql, params);
|
|
381
|
+
return {
|
|
382
|
+
sql: entry.sql,
|
|
383
|
+
params,
|
|
384
|
+
transform: (result) => ({ count: result.rowCount ?? 0 }),
|
|
385
|
+
tag: `${qi.table}.updateMany`,
|
|
386
|
+
preparedName: entry.name,
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
export function buildDeleteMany(qi, args) {
|
|
390
|
+
assertWritable(qi, 'deleteMany');
|
|
391
|
+
qi.currentSkip = args.skipGlobalFilters;
|
|
392
|
+
whereMod.assertMutationHasPredicate(qi, 'deleteMany', whereMod.userPredicateIsEmpty(qi, args.where) ? '' : ' WHERE x', args.allowFullTableScan);
|
|
393
|
+
const whereObj = (whereMod.mergeGlobalFilter(qi, args.where) ?? {});
|
|
394
|
+
const whereFp = whereMod.fingerprintWhere(qi, whereObj);
|
|
395
|
+
const ck = `dm:${whereFp}${whereMod.globalFilterCacheSegment(qi)}`;
|
|
396
|
+
const params = [];
|
|
397
|
+
const buildSql = (freshParams) => {
|
|
398
|
+
const clause = whereMod.buildWhereClause(qi, whereObj, freshParams);
|
|
399
|
+
const whereSql = clause ? ` WHERE ${clause}` : '';
|
|
400
|
+
return `DELETE FROM ${qi.q(qi.table)}${whereSql}`;
|
|
401
|
+
};
|
|
402
|
+
const entry = qi.acquireSql(ck, buildSql);
|
|
403
|
+
whereMod.collectWhereParams(qi, whereObj, params);
|
|
404
|
+
qi.crossCheckCache('deleteMany', ck, entry, buildSql, params);
|
|
405
|
+
return {
|
|
406
|
+
sql: entry.sql,
|
|
407
|
+
params,
|
|
408
|
+
transform: (result) => ({ count: result.rowCount ?? 0 }),
|
|
409
|
+
tag: `${qi.table}.deleteMany`,
|
|
410
|
+
preparedName: entry.name,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* The snake_case names of a table's PII-tagged (`defineSchema` `pii: true`)
|
|
415
|
+
* columns. PII columns are excluded from default projections (findMany /
|
|
416
|
+
* findUnique / relation subqueries / batched loads) unless the query opts in
|
|
417
|
+
* via `includePii` or names the column explicitly in `select`. Returns an
|
|
418
|
+
* empty set for any table with no PII column, so untagged schemas keep their
|
|
419
|
+
* byte-identical SQL.
|
|
420
|
+
*/
|
|
421
|
+
export function piiColumns(_qi, meta) {
|
|
422
|
+
const out = new Set();
|
|
423
|
+
for (const col of meta.columns) {
|
|
424
|
+
if (col.pii)
|
|
425
|
+
out.add(col.name);
|
|
426
|
+
}
|
|
427
|
+
return out;
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* The camelCase field names of a table's PII-tagged columns: the read-side
|
|
431
|
+
* counterpart of {@link piiColumns} applied to already-parsed entities.
|
|
432
|
+
* Used to strip PII from a write's RETURNING/reselect row (writes accept no
|
|
433
|
+
* `includePii`/`select`, so their returned row always applies the default
|
|
434
|
+
* exclusion; you may still write PII fields freely).
|
|
435
|
+
*/
|
|
436
|
+
export function piiFields(_qi, meta) {
|
|
437
|
+
const out = [];
|
|
438
|
+
for (const col of meta.columns) {
|
|
439
|
+
if (col.pii)
|
|
440
|
+
out.push(col.field);
|
|
441
|
+
}
|
|
442
|
+
return out;
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* The `RETURNING` / `OUTPUT` selection for a write on this table. A table with
|
|
446
|
+
* no PII column returns `'*'` (every column — byte-identical SQL to before);
|
|
447
|
+
* a table WITH PII columns returns an explicit quoted list of every non-PII
|
|
448
|
+
* column so the PII values never leave the database on a write. A PII-tagged
|
|
449
|
+
* PRIMARY KEY column is kept in the projection regardless (the returned row
|
|
450
|
+
* must stay addressable): tag sensitive data, not keys — a PII PK is
|
|
451
|
+
* documented out of scope for stripping. Writes accept no `select`/`includePii`
|
|
452
|
+
* (unlike reads), so this is the whole write-return policy at the SQL level;
|
|
453
|
+
* {@link parseWriteRow} remains as a defense-in-depth strip (a no-op once the
|
|
454
|
+
* SQL already excludes the columns). Derived purely from static per-table
|
|
455
|
+
* schema metadata, so the write SQL cache needs no extra key segment.
|
|
456
|
+
*/
|
|
457
|
+
export function writeReturningColumns(qi) {
|
|
458
|
+
const piiCols = piiColumns(qi, qi.tableMeta);
|
|
459
|
+
if (piiCols.size === 0)
|
|
460
|
+
return '*';
|
|
461
|
+
const pk = new Set(qi.tableMeta.primaryKey);
|
|
462
|
+
return qi.tableMeta.allColumns.filter((col) => !piiCols.has(col) || pk.has(col)).map((col) => qi.q(col));
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* String form of {@link writeReturningColumns} for a `SELECT` list (the
|
|
466
|
+
* `'reselect'` result strategy re-fetches via a SELECT, not RETURNING).
|
|
467
|
+
* `'*'` when there is no PII column; otherwise the comma-joined quoted list.
|
|
468
|
+
*/
|
|
469
|
+
export function writeReselectSelection(qi) {
|
|
470
|
+
const cols = writeReturningColumns(qi);
|
|
471
|
+
return cols === '*' ? '*' : cols.join(', ');
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* Parse a write's returned row (create/update/upsert/delete), then strip the
|
|
475
|
+
* table's PII fields: the write-side read policy. On PII-tagged tables the
|
|
476
|
+
* statement's RETURNING/OUTPUT already omits these columns (see
|
|
477
|
+
* {@link writeReturningColumns}), so this strip is defense-in-depth and a
|
|
478
|
+
* no-op. Untagged tables incur only one `for` over a zero-length field list,
|
|
479
|
+
* so behavior is unchanged.
|
|
480
|
+
*/
|
|
481
|
+
export function parseWriteRow(qi, row) {
|
|
482
|
+
const parsed = qi.parseRow(row, qi.table);
|
|
483
|
+
for (const field of piiFields(qi, qi.tableMeta)) {
|
|
484
|
+
delete parsed[field];
|
|
485
|
+
}
|
|
486
|
+
return parsed;
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* Reject any write against a view (H4). Views are introspected with
|
|
490
|
+
* `isView: true` and are read-only in every engine; a write raises a
|
|
491
|
+
* {@link ValidationError} (E003) rather than emitting SQL Postgres would
|
|
492
|
+
* reject (or, worse, silently applying to an updatable view).
|
|
493
|
+
*/
|
|
494
|
+
export function assertWritable(qi, operation) {
|
|
495
|
+
if (qi.tableMeta.isView) {
|
|
496
|
+
throw new ValidationError(`[turbine] Cannot ${operation} "${qi.table}": it is a view (read-only). ` +
|
|
497
|
+
'Views support reads (findMany/findFirst/…) but not writes.');
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* Reject a write whose `data` names a `GENERATED ALWAYS AS (...) STORED`
|
|
502
|
+
* column (H3). Postgres computes these from other columns and errors if you
|
|
503
|
+
* try to write them; we fail early with a clear {@link ValidationError} (E003)
|
|
504
|
+
* instead of surfacing a cryptic driver error. Undefined values are ignored
|
|
505
|
+
* (they're stripped from the statement anyway).
|
|
506
|
+
*/
|
|
507
|
+
export function assertNoGeneratedColumns(qi, data, operation) {
|
|
508
|
+
for (const [key, value] of Object.entries(data)) {
|
|
509
|
+
if (value === undefined)
|
|
510
|
+
continue;
|
|
511
|
+
const col = qi.tableMeta.columns.find((c) => c.field === key || c.name === key || c.name === camelToSnake(key));
|
|
512
|
+
if (col?.isGeneratedStored) {
|
|
513
|
+
throw new ValidationError(`[turbine] Cannot ${operation} "${qi.table}": column "${key}" is a GENERATED ALWAYS AS (…) STORED ` +
|
|
514
|
+
'column whose value the database computes — remove it from your data.');
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Build a single SET clause entry for update/updateMany.
|
|
520
|
+
*
|
|
521
|
+
* Supports plain values and atomic operator objects ({ set, increment,
|
|
522
|
+
* decrement, multiply, divide }). An operator object is detected ONLY when
|
|
523
|
+
* it has EXACTLY one key that is one of the 5 operator keys — this avoids
|
|
524
|
+
* misinterpreting JSON column values like `{ set: 'x' }` as operators
|
|
525
|
+
* (real operator objects always have exactly one key, and a plain JSON
|
|
526
|
+
* payload that happens to have a single `set` key is extremely unusual).
|
|
527
|
+
* Multi-key objects are always treated as plain (JSON) values.
|
|
528
|
+
*
|
|
529
|
+
* Returns the SQL fragment (e.g., `"view_count" = "view_count" + $3`) and
|
|
530
|
+
* pushes any required params onto the shared params array so that WHERE
|
|
531
|
+
* clause numbering continues correctly afterward.
|
|
532
|
+
*/
|
|
533
|
+
export function buildSetClause(qi, key, value, params) {
|
|
534
|
+
const col = qi.toSqlColumn(key);
|
|
535
|
+
// Enum columns get an explicit `::"EnumName"` cast on their value bind
|
|
536
|
+
// (see enumTypeForColumn); `''` everywhere else. Value-invariant, so the
|
|
537
|
+
// SQL cache and collectSetParams are unaffected.
|
|
538
|
+
const cast = whereMod.enumCastSuffix(qi, qi.toColumn(key));
|
|
539
|
+
// Detect atomic-operator object: plain object (not null, not array, not
|
|
540
|
+
// Date, not Buffer) with EXACTLY one key matching an operator name.
|
|
541
|
+
if (value !== null &&
|
|
542
|
+
typeof value === 'object' &&
|
|
543
|
+
!Array.isArray(value) &&
|
|
544
|
+
!(value instanceof Date) &&
|
|
545
|
+
!Buffer.isBuffer(value)) {
|
|
546
|
+
const v = value;
|
|
547
|
+
const keys = Object.keys(v);
|
|
548
|
+
if (keys.length === 1 && UPDATE_OPERATOR_KEYS.has(keys[0])) {
|
|
549
|
+
const op = keys[0];
|
|
550
|
+
const opValue = v[op];
|
|
551
|
+
if (op === 'set') {
|
|
552
|
+
params.push(opValue);
|
|
553
|
+
return `${col} = ${qi.p(params.length)}${cast}`;
|
|
554
|
+
}
|
|
555
|
+
// Arithmetic operators: must be finite numbers
|
|
556
|
+
if (typeof opValue !== 'number' || !Number.isFinite(opValue)) {
|
|
557
|
+
throw new ValidationError(`[turbine] update operator "${op}" on "${qi.table}.${key}" requires a finite number, got ${typeof opValue}`);
|
|
558
|
+
}
|
|
559
|
+
if (op === 'increment') {
|
|
560
|
+
params.push(opValue);
|
|
561
|
+
return `${col} = ${col} + ${qi.p(params.length)}`;
|
|
562
|
+
}
|
|
563
|
+
if (op === 'decrement') {
|
|
564
|
+
params.push(opValue);
|
|
565
|
+
return `${col} = ${col} - ${qi.p(params.length)}`;
|
|
566
|
+
}
|
|
567
|
+
if (op === 'multiply') {
|
|
568
|
+
params.push(opValue);
|
|
569
|
+
return `${col} = ${col} * ${qi.p(params.length)}`;
|
|
570
|
+
}
|
|
571
|
+
if (op === 'divide') {
|
|
572
|
+
params.push(opValue);
|
|
573
|
+
return `${col} = ${col} / ${qi.p(params.length)}`;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
// Fall through: multi-key objects or non-operator single-key objects
|
|
577
|
+
// are treated as plain values (e.g., JSONB column payloads).
|
|
578
|
+
}
|
|
579
|
+
// Plain value (including null, Date, Buffer, arrays, JSON objects)
|
|
580
|
+
params.push(value);
|
|
581
|
+
return `${col} = ${qi.p(params.length)}${cast}`;
|
|
582
|
+
}
|
|
583
|
+
/**
|
|
584
|
+
* Fingerprint SET clauses for update/updateMany.
|
|
585
|
+
* Captures key names + operator types (set/increment/etc) but not values.
|
|
586
|
+
*/
|
|
587
|
+
export function fingerprintSet(_qi, data) {
|
|
588
|
+
const entries = Object.entries(data).filter(([, v]) => v !== undefined);
|
|
589
|
+
const parts = [];
|
|
590
|
+
for (const [k, v] of entries) {
|
|
591
|
+
if (v !== null &&
|
|
592
|
+
typeof v === 'object' &&
|
|
593
|
+
!Array.isArray(v) &&
|
|
594
|
+
!(v instanceof Date) &&
|
|
595
|
+
!(typeof Buffer !== 'undefined' && Buffer.isBuffer(v))) {
|
|
596
|
+
const keys = Object.keys(v);
|
|
597
|
+
if (keys.length === 1 && UPDATE_OPERATOR_KEYS.has(keys[0])) {
|
|
598
|
+
parts.push(`${k}:${keys[0]}`);
|
|
599
|
+
continue;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
parts.push(`${k}:eq`);
|
|
603
|
+
}
|
|
604
|
+
return parts.join(',');
|
|
605
|
+
}
|
|
606
|
+
/**
|
|
607
|
+
* Collect SET params for update/updateMany. Mirrors buildSetClause param order.
|
|
608
|
+
*/
|
|
609
|
+
export function collectSetParams(_qi, data, params) {
|
|
610
|
+
const entries = Object.entries(data).filter(([, v]) => v !== undefined);
|
|
611
|
+
for (const [, v] of entries) {
|
|
612
|
+
if (v !== null &&
|
|
613
|
+
typeof v === 'object' &&
|
|
614
|
+
!Array.isArray(v) &&
|
|
615
|
+
!(v instanceof Date) &&
|
|
616
|
+
!(typeof Buffer !== 'undefined' && Buffer.isBuffer(v))) {
|
|
617
|
+
const obj = v;
|
|
618
|
+
const keys = Object.keys(obj);
|
|
619
|
+
if (keys.length === 1 && UPDATE_OPERATOR_KEYS.has(keys[0])) {
|
|
620
|
+
params.push(obj[keys[0]]);
|
|
621
|
+
continue;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
params.push(v);
|
|
625
|
+
}
|
|
626
|
+
}
|
package/dist/schema-builder.d.ts
CHANGED
|
@@ -64,6 +64,15 @@ export interface ColumnDef {
|
|
|
64
64
|
array?: boolean;
|
|
65
65
|
/** Column-level `CHECK` expression (raw SQL, e.g. `price >= 0`). */
|
|
66
66
|
check?: string;
|
|
67
|
+
/**
|
|
68
|
+
* Marks this column as personally identifiable information (PII). Purely a
|
|
69
|
+
* code-first declaration: it carries onto {@link ColumnConfig.pii} and, via
|
|
70
|
+
* `schemaDefToMetadata` / codegen, onto
|
|
71
|
+
* {@link import('./schema.js').ColumnMetadata.pii}. A PII column is excluded
|
|
72
|
+
* from default projections (read back only via an explicit `select` or
|
|
73
|
+
* `includePii: true`) and redacted by Studio. Introspection never auto-tags PII.
|
|
74
|
+
*/
|
|
75
|
+
pii?: boolean;
|
|
67
76
|
}
|
|
68
77
|
/** Postgres-level column type (uppercase, as used in DDL) */
|
|
69
78
|
export type ColumnType = 'SERIAL' | 'BIGSERIAL' | 'BIGINT' | 'INTEGER' | 'SMALLINT' | 'TEXT' | 'BOOLEAN' | 'TIMESTAMPTZ' | 'JSONB' | 'UUID' | 'REAL' | 'DOUBLE PRECISION' | 'NUMERIC' | 'BYTEA' | 'DATE' | 'VARCHAR' | 'ENUM' | 'VECTOR';
|
|
@@ -88,6 +97,8 @@ export interface ColumnConfig {
|
|
|
88
97
|
isArray: boolean;
|
|
89
98
|
/** Column-level `CHECK` expression, or null. */
|
|
90
99
|
check: string | null;
|
|
100
|
+
/** Whether this column is tagged as PII (personally identifiable information). */
|
|
101
|
+
pii: boolean;
|
|
91
102
|
}
|
|
92
103
|
/**
|
|
93
104
|
* Explicit many-to-many relation declaration for the code-first schema.
|
|
@@ -155,9 +166,12 @@ export interface DocFieldIndexDef {
|
|
|
155
166
|
* ({@link ColumnIndexDef}) or a doc-field expression index into a json column
|
|
156
167
|
* ({@link DocFieldIndexDef}).
|
|
157
168
|
*
|
|
158
|
-
* Consumed
|
|
159
|
-
* {@link import('./schema.js').IndexMetadata} by `schemaDefToMetadata
|
|
160
|
-
*
|
|
169
|
+
* Consumed by the PowDB DDL generator (`powqlSchemaDDL`), carried onto
|
|
170
|
+
* {@link import('./schema.js').IndexMetadata} by `schemaDefToMetadata`, and
|
|
171
|
+
* since 0.36 emitted as `CREATE [UNIQUE] INDEX` by the SQL DDL generators:
|
|
172
|
+
* `schemaToSQL` emits plain column-list indexes, and `schemaDiff` adds ones
|
|
173
|
+
* missing from the live database (matching by name; it warns on definition
|
|
174
|
+
* mismatches and never drops). Doc-field indexes stay PowDB-only.
|
|
161
175
|
*/
|
|
162
176
|
export type SchemaIndexDef = ColumnIndexDef | DocFieldIndexDef;
|
|
163
177
|
/** Type guard: is this index declaration a doc-field expression index? */
|
|
@@ -296,6 +310,7 @@ export declare class ColumnBuilder {
|
|
|
296
310
|
onUpdate?: ReferentialAction;
|
|
297
311
|
}): this;
|
|
298
312
|
check(expression: string): this;
|
|
313
|
+
pii(): this;
|
|
299
314
|
array(): this;
|
|
300
315
|
build(): ColumnConfig;
|
|
301
316
|
}
|