pipework 0.290.0 → 0.292.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/CHANGELOG.md +8 -0
- package/REFERENCE.md +81 -1
- package/dist/auth/tenant/scoped-db.d.ts +9 -0
- package/dist/auth/tenant/scoped-db.d.ts.map +1 -1
- package/dist/auth/tenant/scoped-db.js +14 -0
- package/dist/auth/tenant/scoped-db.js.map +1 -1
- package/dist/data/db/function-definition.d.ts +46 -0
- package/dist/data/db/function-definition.d.ts.map +1 -0
- package/dist/data/db/function-definition.js +84 -0
- package/dist/data/db/function-definition.js.map +1 -0
- package/dist/data/db/function-source.d.ts +33 -2
- package/dist/data/db/function-source.d.ts.map +1 -1
- package/dist/data/db/function-source.js +47 -19
- package/dist/data/db/function-source.js.map +1 -1
- package/dist/data/db/namespace.d.ts +5 -3
- package/dist/data/db/namespace.d.ts.map +1 -1
- package/dist/data/db/namespace.js +5 -3
- package/dist/data/db/namespace.js.map +1 -1
- package/dist/data/db/rows.d.ts +1 -0
- package/dist/data/db/rows.d.ts.map +1 -1
- package/dist/data/db/rows.js +1 -1
- package/dist/data/db/rows.js.map +1 -1
- package/dist/data/migrate/diff.d.ts +7 -0
- package/dist/data/migrate/diff.d.ts.map +1 -1
- package/dist/data/migrate/diff.js +37 -0
- package/dist/data/migrate/diff.js.map +1 -1
- package/dist/data/migrate/history.d.ts +2 -0
- package/dist/data/migrate/history.d.ts.map +1 -1
- package/dist/data/migrate/history.js +8 -0
- package/dist/data/migrate/history.js.map +1 -1
- package/dist/data/migrate/snapshot.d.ts +3 -0
- package/dist/data/migrate/snapshot.d.ts.map +1 -1
- package/dist/data/migrate/snapshot.js +13 -0
- package/dist/data/migrate/snapshot.js.map +1 -1
- package/dist/data/migrate/sql-emit.d.ts.map +1 -1
- package/dist/data/migrate/sql-emit.js +30 -2
- package/dist/data/migrate/sql-emit.js.map +1 -1
- package/dist/data/write/audit/verb-insert.d.ts.map +1 -1
- package/dist/data/write/audit/verb-insert.js +4 -1
- package/dist/data/write/audit/verb-insert.js.map +1 -1
- package/dist/data/write/audit/verb-update.d.ts.map +1 -1
- package/dist/data/write/audit/verb-update.js +14 -3
- package/dist/data/write/audit/verb-update.js.map +1 -1
- package/dist/data/write/batch-insert.d.ts +35 -0
- package/dist/data/write/batch-insert.d.ts.map +1 -0
- package/dist/data/write/batch-insert.js +171 -0
- package/dist/data/write/batch-insert.js.map +1 -0
- package/dist/data/write/execute.d.ts +1 -1
- package/dist/data/write/execute.d.ts.map +1 -1
- package/dist/data/write/execute.js +89 -2
- package/dist/data/write/execute.js.map +1 -1
- package/dist/data/write/index.d.ts +2 -0
- package/dist/data/write/index.d.ts.map +1 -1
- package/dist/data/write/index.js +1 -0
- package/dist/data/write/index.js.map +1 -1
- package/dist/data/write/insert.d.ts +8 -0
- package/dist/data/write/insert.d.ts.map +1 -1
- package/dist/data/write/insert.js +14 -0
- package/dist/data/write/insert.js.map +1 -1
- package/dist/data/write/state.d.ts +10 -0
- package/dist/data/write/state.d.ts.map +1 -1
- package/dist/data/write/update-each.d.ts +28 -0
- package/dist/data/write/update-each.d.ts.map +1 -0
- package/dist/data/write/update-each.js +104 -0
- package/dist/data/write/update-each.js.map +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/test/function-reads.d.ts +97 -0
- package/dist/test/function-reads.d.ts.map +1 -0
- package/dist/test/function-reads.js +202 -0
- package/dist/test/function-reads.js.map +1 -0
- package/dist/test/index.d.ts +1 -0
- package/dist/test/index.d.ts.map +1 -1
- package/dist/test/index.js +1 -0
- package/dist/test/index.js.map +1 -1
- package/package.json +2 -2
- package/src/auth/tenant/scoped-db.ts +13 -0
- package/src/data/db/function-definition.ts +172 -0
- package/src/data/db/function-source.ts +86 -14
- package/src/data/db/namespace.ts +5 -3
- package/src/data/db/rows.ts +1 -1
- package/src/data/migrate/diff.ts +45 -0
- package/src/data/migrate/history.ts +9 -0
- package/src/data/migrate/snapshot.ts +17 -0
- package/src/data/migrate/sql-emit.ts +33 -3
- package/src/data/write/audit/verb-insert.ts +4 -1
- package/src/data/write/audit/verb-update.ts +13 -3
- package/src/data/write/batch-insert.ts +179 -0
- package/src/data/write/execute.ts +77 -2
- package/src/data/write/index.ts +2 -0
- package/src/data/write/insert.ts +15 -0
- package/src/data/write/state.ts +6 -0
- package/src/data/write/update-each.ts +120 -0
- package/src/index.ts +2 -1
- package/src/test/function-reads.ts +290 -0
- package/src/test/index.ts +1 -0
package/src/data/db/namespace.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { bulkSet } from './bulk-set.js'
|
|
|
11
11
|
import { values } from './values.js'
|
|
12
12
|
import { rows } from './rows.js'
|
|
13
13
|
import { fn } from './function-source.js'
|
|
14
|
-
import { update, insert, remove, upsert } from '../write/index.js'
|
|
14
|
+
import { update, updateEach, insert, remove, upsert } from '../write/index.js'
|
|
15
15
|
|
|
16
16
|
import { aliasedRelation, aliasedTable, aliasedTableColumn, getTableColumns, getTableName, getViewName, getViewSelectedFields, isTable, createMany, createOne, relations } from '../query/index.js'
|
|
17
17
|
|
|
@@ -88,7 +88,7 @@ export const pipe = Object.assign(getDb, {
|
|
|
88
88
|
/** A typed rowset from parallel array parameters, one parameter per column whatever the row count, as a source for `.from()` and joins — `pipe.rows({ g: 'text', id: 'int' }, { g: [...], id: [...] })` renders `unnest($1::text[], $2::int[]) AS rows(g, id)`. For composite-key matches that would otherwise be a union or a JS-side tuple match. */
|
|
89
89
|
rows,
|
|
90
90
|
|
|
91
|
-
/** A set-returning SQL function as a typed source for `.from()` and joins — `pipe.fn('schema.f', { args: [field.uuid()], columns: { path: field.text() } })`. Arguments are bound and cast to their declared types. */
|
|
91
|
+
/** A set-returning SQL function as a typed source for `.from()` and joins — `pipe.fn('schema.f', { args: [field.uuid()], columns: { path: field.text() } })`. Arguments are bound and cast to their declared types. With a `body` (one LANGUAGE sql SELECT, `volatility`, `rows`) pipework creates the function and migrates it, under rules that keep it inlinable; check it with `expectFunctionRead` from `pipework/test`. */
|
|
92
92
|
fn,
|
|
93
93
|
|
|
94
94
|
/** Field builders for domain definitions — uuid(), text(), integer(), etc. */
|
|
@@ -102,7 +102,9 @@ export const pipe = Object.assign(getDb, {
|
|
|
102
102
|
|
|
103
103
|
/** Pipework-owned UPDATE builder — the only consumer-facing way to update pipework-managed tables (Q8). */
|
|
104
104
|
update,
|
|
105
|
-
/**
|
|
105
|
+
/** One UPDATE for many keys, returning how many rows each key updated (in input order); refuses, naming the key, when a key does not match exactly one row — `pipe.updateEach(t, { keys: { id: 'uuid', n: 'int' }, data: { id, n }, where: k => eq(t.id, k.id), set: k => ({ n: k.n }) })`. In the caller's transaction, audited as `pipe.update`. */
|
|
106
|
+
updateEach,
|
|
107
|
+
/** Pipework-owned INSERT builder — the only consumer-facing way to insert into pipework-managed tables (Q8). A list of any size is split into statements under Postgres's parameter limit, in one transaction; `.batch(rows)` sends every row as one statement instead, arrays and NULLs included. */
|
|
106
108
|
insert,
|
|
107
109
|
/** Pipework-owned DELETE builder — the only consumer-facing way to delete from pipework-managed tables (Q8). */
|
|
108
110
|
delete: remove,
|
package/src/data/db/rows.ts
CHANGED
|
@@ -49,7 +49,7 @@ function quote(name: string): string {
|
|
|
49
49
|
// One column as a Postgres array literal, sent as a single text parameter and cast to `T[]`. postgres.js
|
|
50
50
|
// serialises a JS array only for the few element types it knows (text, int, uuid ...), so a `timestamptz[]`
|
|
51
51
|
// parameter would throw; a literal string is parsed by Postgres for any type.
|
|
52
|
-
function arrayLiteral(cells: readonly unknown[]): string {
|
|
52
|
+
export function arrayLiteral(cells: readonly unknown[]): string {
|
|
53
53
|
const item = (v: unknown): string => {
|
|
54
54
|
if (v === null || v === undefined) return 'NULL'
|
|
55
55
|
const text = v instanceof Date ? v.toISOString()
|
package/src/data/migrate/diff.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { FunctionDefinition } from '../db/function-definition.js'
|
|
1
2
|
import type {
|
|
2
3
|
PwSnapshot,
|
|
3
4
|
PwTableSnapshot,
|
|
@@ -10,6 +11,8 @@ import type {
|
|
|
10
11
|
} from './snapshot.js'
|
|
11
12
|
|
|
12
13
|
export type DiffOp =
|
|
14
|
+
| { type: 'create_function'; fn: FunctionDefinition }
|
|
15
|
+
| { type: 'drop_function'; fn: FunctionDefinition }
|
|
13
16
|
| { type: 'create_table'; table: PwTableSnapshot }
|
|
14
17
|
| { type: 'drop_table'; table: PwTableSnapshot }
|
|
15
18
|
| { type: 'add_column'; tableName: string; tableSchema: string; column: PwColumnSnapshot }
|
|
@@ -39,11 +42,16 @@ export type DiffOp =
|
|
|
39
42
|
|
|
40
43
|
/** The wire name of the table an op acts on, across all `DiffOp` shapes. */
|
|
41
44
|
export function diffOpTableName(op: DiffOp): string {
|
|
45
|
+
if (op.type === 'create_function' || op.type === 'drop_function') return ''
|
|
42
46
|
return op.type === 'create_table' || op.type === 'drop_table' ? op.table.name : op.tableName
|
|
43
47
|
}
|
|
44
48
|
|
|
45
49
|
export function diffSnapshots(prev: PwSnapshot, cur: PwSnapshot): DiffOp[] {
|
|
46
50
|
const ops: DiffOp[] = []
|
|
51
|
+
const functionOps = diffFunctions(prev.functions ?? {}, cur.functions ?? {})
|
|
52
|
+
|
|
53
|
+
// Function drops come first: a function that reads a table must be gone before the table or a column it reads is.
|
|
54
|
+
ops.push(...functionOps.filter(op => op.type === 'drop_function'))
|
|
47
55
|
|
|
48
56
|
const prevKeys = new Set(Object.keys(prev.tables))
|
|
49
57
|
const curKeys = new Set(Object.keys(cur.tables))
|
|
@@ -93,6 +101,43 @@ export function diffSnapshots(prev: PwSnapshot, cur: PwSnapshot): DiffOp[] {
|
|
|
93
101
|
diffAddConstraints(prevTable, curTable, ops)
|
|
94
102
|
}
|
|
95
103
|
|
|
104
|
+
// Function creates come last: a LANGUAGE sql body is checked when the function is created, so every table,
|
|
105
|
+
// column and index it names must exist by then.
|
|
106
|
+
ops.push(...functionOps.filter(op => op.type === 'create_function'))
|
|
107
|
+
|
|
108
|
+
return ops
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** What a function returns, without its ROWS estimate (which CREATE OR REPLACE can change). */
|
|
112
|
+
function shape(fn: FunctionDefinition): unknown {
|
|
113
|
+
return fn.returns.kind === 'table' ? { columns: fn.returns.columns } : fn.returns
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* A function is identified by `schema.name(argtypes)`. A new key is created; a key with a changed body, volatility
|
|
118
|
+
* or ROWS is replaced in place; a changed result shape cannot be replaced (Postgres refuses to change a return
|
|
119
|
+
* type), so it is dropped and created; a key that is gone is dropped. A changed argument list is a different key,
|
|
120
|
+
* so the old signature is dropped and the new one created.
|
|
121
|
+
*/
|
|
122
|
+
function diffFunctions(
|
|
123
|
+
prev: Record<string, FunctionDefinition>,
|
|
124
|
+
cur: Record<string, FunctionDefinition>,
|
|
125
|
+
): DiffOp[] {
|
|
126
|
+
const ops: DiffOp[] = []
|
|
127
|
+
for (const [key, old] of Object.entries(prev)) {
|
|
128
|
+
const now = cur[key]
|
|
129
|
+
if (now === undefined || JSON.stringify(shape(now)) !== JSON.stringify(shape(old))) {
|
|
130
|
+
ops.push({ type: 'drop_function', fn: old })
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
for (const [key, now] of Object.entries(cur)) {
|
|
134
|
+
const old = prev[key]
|
|
135
|
+
if (old === undefined) {
|
|
136
|
+
ops.push({ type: 'create_function', fn: now })
|
|
137
|
+
} else if (JSON.stringify(old) !== JSON.stringify(now)) {
|
|
138
|
+
ops.push({ type: 'create_function', fn: now })
|
|
139
|
+
}
|
|
140
|
+
}
|
|
96
141
|
return ops
|
|
97
142
|
}
|
|
98
143
|
|
|
@@ -2,6 +2,7 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
import { EMPTY_SNAPSHOT, type PwSnapshot, type PwTableSnapshot, type PwPartitionSnapshot } from './snapshot.js'
|
|
4
4
|
import type { DoorSnapshot } from './door-generate.js'
|
|
5
|
+
import type { FunctionDefinition } from '../db/function-definition.js'
|
|
5
6
|
|
|
6
7
|
export interface MigrationEntry {
|
|
7
8
|
tag: string
|
|
@@ -29,6 +30,7 @@ export interface SnapshotDelta {
|
|
|
29
30
|
version: 'pw1-delta'
|
|
30
31
|
tables: Record<string, TableDelta | null>
|
|
31
32
|
doors?: Record<string, DoorSnapshot | null>
|
|
33
|
+
functions?: Record<string, FunctionDefinition | null>
|
|
32
34
|
}
|
|
33
35
|
|
|
34
36
|
const MAP_KEYS = ['columns', 'indexes', 'foreignKeys', 'checks', 'uniqueConstraints', 'compositePrimaryKeys'] as const
|
|
@@ -148,6 +150,10 @@ export function snapshotDelta(prev: PwSnapshot, next: PwSnapshot): SnapshotDelta
|
|
|
148
150
|
const doors = diffMap<DoorSnapshot>(prev.doors ?? {}, next.doors ?? {})
|
|
149
151
|
if (Object.keys(doors).length > 0) delta.doors = doors
|
|
150
152
|
}
|
|
153
|
+
if (next.functions !== undefined || prev.functions !== undefined) {
|
|
154
|
+
const functions = diffMap<FunctionDefinition>(prev.functions ?? {}, next.functions ?? {})
|
|
155
|
+
if (Object.keys(functions).length > 0) delta.functions = functions
|
|
156
|
+
}
|
|
151
157
|
return delta
|
|
152
158
|
}
|
|
153
159
|
|
|
@@ -185,6 +191,9 @@ export function applyDelta(snap: PwSnapshot, delta: SnapshotDelta): PwSnapshot {
|
|
|
185
191
|
if (delta.doors !== undefined) {
|
|
186
192
|
result.doors = applyMap<DoorSnapshot>(snap.doors ?? {}, delta.doors) as NonNullable<PwSnapshot['doors']>
|
|
187
193
|
}
|
|
194
|
+
if (delta.functions !== undefined) {
|
|
195
|
+
result.functions = applyMap<FunctionDefinition>(snap.functions ?? {}, delta.functions)
|
|
196
|
+
}
|
|
188
197
|
return result
|
|
189
198
|
}
|
|
190
199
|
|
|
@@ -10,6 +10,7 @@ import { Column } from '../query/column.js'
|
|
|
10
10
|
import type { IndexedColumn } from '../query/pg-core/columns/common.js'
|
|
11
11
|
import { camelToSnake } from '../domain/naming.js'
|
|
12
12
|
import type { DefineOptions } from '../domain/types.js'
|
|
13
|
+
import { FUNCTION_DEFINITION, functionKey, type FunctionDefinition } from '../db/function-definition.js'
|
|
13
14
|
|
|
14
15
|
export interface PwSnapshot {
|
|
15
16
|
version: 'pw1'
|
|
@@ -18,6 +19,8 @@ export interface PwSnapshot {
|
|
|
18
19
|
tables: Record<string, PwTableSnapshot>
|
|
19
20
|
/** Cross-tenant read doors with a generated policy; absent in snapshots written before doors existed. */
|
|
20
21
|
doors?: Record<string, { table: string; role: string; ops?: readonly ('select' | 'update' | 'delete')[] }>
|
|
22
|
+
/** Functions pipework defines (`pipe.fn` with a `body`), by `schema.name(argtypes)`; absent in snapshots written before functions existed. */
|
|
23
|
+
functions?: Record<string, FunctionDefinition>
|
|
21
24
|
}
|
|
22
25
|
|
|
23
26
|
export interface PwTableSnapshot {
|
|
@@ -166,8 +169,21 @@ export function buildSnapshot(
|
|
|
166
169
|
prevId: string,
|
|
167
170
|
): PwSnapshot {
|
|
168
171
|
const tables: Record<string, PwTableSnapshot> = {}
|
|
172
|
+
const functions: Record<string, FunctionDefinition> = {}
|
|
169
173
|
|
|
170
174
|
for (const value of Object.values(imports)) {
|
|
175
|
+
const definition = typeof value === 'function' ? (value as { [FUNCTION_DEFINITION]?: FunctionDefinition })[FUNCTION_DEFINITION] : undefined
|
|
176
|
+
if (definition !== undefined) {
|
|
177
|
+
const key = functionKey(definition)
|
|
178
|
+
const seen = functions[key]
|
|
179
|
+
if (seen !== undefined && JSON.stringify(seen) !== JSON.stringify(definition)) {
|
|
180
|
+
throw new Error(
|
|
181
|
+
`[pipework] Two different definitions of the function ${key} are exported from the schema files.\n\n Fix: define it once and import it where it is called.\n`,
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
functions[key] = definition
|
|
185
|
+
continue
|
|
186
|
+
}
|
|
171
187
|
if (!is(value, PgTable)) continue
|
|
172
188
|
if (isExternal(value)) continue
|
|
173
189
|
|
|
@@ -281,6 +297,7 @@ export function buildSnapshot(
|
|
|
281
297
|
id: randomUUID(),
|
|
282
298
|
prevId,
|
|
283
299
|
tables,
|
|
300
|
+
...(Object.keys(functions).length > 0 ? { functions } : {}),
|
|
284
301
|
}
|
|
285
302
|
}
|
|
286
303
|
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
DiffOp,
|
|
3
3
|
} from './diff.js'
|
|
4
|
+
import { bodyQuote } from '../db/function-definition.js'
|
|
5
|
+
import type { FunctionDefinition } from '../db/function-definition.js'
|
|
4
6
|
import type {
|
|
5
7
|
PwTableSnapshot,
|
|
6
8
|
PwColumnSnapshot,
|
|
@@ -24,7 +26,7 @@ function qualifiedTable(schema: string, name: string): string {
|
|
|
24
26
|
* Every op type except the two `emitSQL` routes itself: `create_table` (whose
|
|
25
27
|
* foreign keys are hoisted out) and `add_foreign_key`. Issue #312.
|
|
26
28
|
*/
|
|
27
|
-
type InlineOp = Exclude<DiffOp, { type: 'create_table' } | { type: 'add_foreign_key' }>
|
|
29
|
+
type InlineOp = Exclude<DiffOp, { type: 'create_table' } | { type: 'add_foreign_key' } | { type: 'create_function' } | { type: 'drop_function' }>
|
|
28
30
|
|
|
29
31
|
export function emitSQL(ops: DiffOp[]): string[] {
|
|
30
32
|
// Foreign keys are hoisted to run after every CREATE TABLE. A from-scratch
|
|
@@ -34,8 +36,15 @@ export function emitSQL(ops: DiffOp[]): string[] {
|
|
|
34
36
|
// tables in place before any constraint is added. Issue #312.
|
|
35
37
|
const tables: string[] = []
|
|
36
38
|
const foreignKeys: string[] = []
|
|
39
|
+
// Function drops lead and function creates trail, whatever order the ops come in; see diffSnapshots.
|
|
40
|
+
const functionDrops: string[] = []
|
|
41
|
+
const functionCreates: string[] = []
|
|
37
42
|
for (const op of ops) {
|
|
38
|
-
if (op.type === '
|
|
43
|
+
if (op.type === 'drop_function') {
|
|
44
|
+
functionDrops.push(emitDropFunction(op.fn))
|
|
45
|
+
} else if (op.type === 'create_function') {
|
|
46
|
+
functionCreates.push(emitCreateFunction(op.fn))
|
|
47
|
+
} else if (op.type === 'create_table') {
|
|
39
48
|
tables.push(...emitCreateTable(op.table))
|
|
40
49
|
const partitioned = op.table.partitionBy !== undefined
|
|
41
50
|
for (const fk of Object.values(op.table.foreignKeys)) {
|
|
@@ -47,7 +56,7 @@ export function emitSQL(ops: DiffOp[]): string[] {
|
|
|
47
56
|
tables.push(...emitOp(op))
|
|
48
57
|
}
|
|
49
58
|
}
|
|
50
|
-
return [...tables, ...foreignKeys]
|
|
59
|
+
return [...functionDrops, ...tables, ...foreignKeys, ...functionCreates]
|
|
51
60
|
}
|
|
52
61
|
|
|
53
62
|
function emitOp(op: InlineOp): string[] {
|
|
@@ -390,3 +399,24 @@ function emitAddCompositePK(tableName: string, tableSchema: string, pk: PwCompos
|
|
|
390
399
|
function emitReplaceCompositePK(tableName: string, tableSchema: string, from: PwCompositePKSnapshot, to: PwCompositePKSnapshot, partitioned: boolean): string[] {
|
|
391
400
|
return emitKeyConstraint(tableName, tableSchema, 'PRIMARY KEY', to, partitioned, from.name)
|
|
392
401
|
}
|
|
402
|
+
|
|
403
|
+
function qualifiedFunction(fn: FunctionDefinition): string {
|
|
404
|
+
return `${q(fn.schema)}.${q(fn.name)}`
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function emitDropFunction(fn: FunctionDefinition): string {
|
|
408
|
+
return `DROP FUNCTION ${qualifiedFunction(fn)}(${fn.args.join(', ')})`
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* `CREATE OR REPLACE FUNCTION`, always `LANGUAGE sql`, STABLE or IMMUTABLE, no SET clause, not SECURITY DEFINER, not STRICT:
|
|
413
|
+
* the form Postgres inlines into the calling query. The body is dollar-quoted so the statement scanner keeps it whole.
|
|
414
|
+
*/
|
|
415
|
+
function emitCreateFunction(fn: FunctionDefinition): string {
|
|
416
|
+
const returns = fn.returns.kind === 'scalar'
|
|
417
|
+
? fn.returns.type
|
|
418
|
+
: `TABLE(${fn.returns.columns.map(c => `${q(c.name)} ${c.type}`).join(', ')})`
|
|
419
|
+
const rows = fn.returns.kind === 'table' ? ` ROWS ${fn.returns.rows}` : ''
|
|
420
|
+
const tag = bodyQuote(fn.body)
|
|
421
|
+
return `CREATE OR REPLACE FUNCTION ${qualifiedFunction(fn)}(${fn.args.join(', ')}) RETURNS ${returns} LANGUAGE sql ${fn.volatility.toUpperCase()}${rows} AS ${tag}${fn.body}${tag}`
|
|
422
|
+
}
|
|
@@ -9,6 +9,7 @@ import { buildAuditInserts, buildEntityIdExpr, type AuditSelectContext } from '.
|
|
|
9
9
|
import { AUDIT_COLUMNS_SQL } from './audit-columns.js'
|
|
10
10
|
import { buildFinalSelect } from './final-select.js'
|
|
11
11
|
import { applyOnConflict } from '../conflict.js'
|
|
12
|
+
import { batchInsertSql } from '../batch-insert.js'
|
|
12
13
|
import type { SQL } from '../../db/types.js'
|
|
13
14
|
import type { WriteState } from '../state.js'
|
|
14
15
|
|
|
@@ -19,7 +20,9 @@ export function compileInsertAudit(db: AnyDb, state: WriteState, ctx: AuditSelec
|
|
|
19
20
|
// `.getSQL()` strips the auto-paren drizzle adds around SQLWrapper builders
|
|
20
21
|
// when they are templated — we add explicit parens at the CTE boundary so
|
|
21
22
|
// the WITH clause keeps a uniform shape across verbs.
|
|
22
|
-
const inner =
|
|
23
|
+
const inner = state.batch === true
|
|
24
|
+
? sql`${batchInsertSql(state)} RETURNING *`
|
|
25
|
+
: applyOnConflict(db.insert(state.table).values(state.values ?? []), state).returning().getSQL()
|
|
23
26
|
const after = buildTraceSafeProjection(state.table, 'inserted') ?? sql`NULL::jsonb`
|
|
24
27
|
const auditSelect = buildAuditInserts(ctx, sql`FROM inserted`, buildEntityIdExpr(state.table, 'inserted'), sql`NULL::jsonb`, after)
|
|
25
28
|
const finalSelect = buildFinalSelect(state, 'inserted')
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// zero rows from `before`, suppressing the audit insert.
|
|
8
8
|
|
|
9
9
|
import { sql } from '../../db/sql.js'
|
|
10
|
+
import { getTableColumns } from '../../query/index.js'
|
|
10
11
|
import { quoteIdentifier } from '../../db/identifiers.js'
|
|
11
12
|
import { camelToSnake } from '../../domain/naming.js'
|
|
12
13
|
import { buildTraceSafeProjection } from './projection.js'
|
|
@@ -26,11 +27,20 @@ export function compileUpdateAudit(db: AnyDb, state: WriteState, ctx: AuditSelec
|
|
|
26
27
|
// `.getSQL()` strips the auto-paren drizzle adds around SQLWrapper builders
|
|
27
28
|
// when they are templated — we add explicit parens at the CTE boundary so
|
|
28
29
|
// the WITH clause keeps a uniform shape across verbs.
|
|
30
|
+
const each = state.each
|
|
29
31
|
let updateChain = db.update(state.table).set(set)
|
|
32
|
+
if (each !== undefined) updateChain = updateChain.from(each.source)
|
|
30
33
|
if (state.where !== undefined) updateChain = updateChain.where(state.where)
|
|
31
|
-
|
|
34
|
+
// An update for many keys also returns the index of the key each row matched, to count rows per key.
|
|
35
|
+
const updateInner = (each === undefined
|
|
36
|
+
? updateChain.returning()
|
|
37
|
+
: updateChain.returning({ ...(getTableColumns(state.table as never) as Record<string, unknown>), __i: each.ordinal.as('__i') })).getSQL()
|
|
32
38
|
|
|
33
|
-
|
|
39
|
+
// Over many keys a row can match several of them; it is snapshotted once, as the update changes it once.
|
|
40
|
+
let beforeChain = each === undefined
|
|
41
|
+
? db.select().from(state.table)
|
|
42
|
+
: db.selectDistinctOn(pk.map(k => (state.table as unknown as Record<string, unknown>)[k]), getTableColumns(state.table as never))
|
|
43
|
+
.from(state.table).crossJoin(each.source)
|
|
34
44
|
if (state.where !== undefined) beforeChain = beforeChain.where(state.where)
|
|
35
45
|
const beforeInner = beforeChain.getSQL()
|
|
36
46
|
|
|
@@ -45,7 +55,7 @@ export function compileUpdateAudit(db: AnyDb, state: WriteState, ctx: AuditSelec
|
|
|
45
55
|
const after = buildTraceSafeProjection(state.table, 'updated') ?? sql`NULL::jsonb`
|
|
46
56
|
const fromClause = sql.raw(`FROM updated JOIN before ON ${joinOn}`)
|
|
47
57
|
const auditSelect = buildAuditInserts(ctx, fromClause, buildEntityIdExpr(state.table, 'updated'), before, after)
|
|
48
|
-
const finalSelect = buildFinalSelect(state, 'updated')
|
|
58
|
+
const finalSelect = each === undefined ? buildFinalSelect(state, 'updated') : sql.raw('SELECT "__i" FROM updated')
|
|
49
59
|
|
|
50
60
|
return sql`
|
|
51
61
|
WITH before AS (${beforeInner}),
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// `pipe.insert(table).batch(rows)`: many rows as ONE statement, whatever the row count.
|
|
2
|
+
//
|
|
3
|
+
// INSERT INTO "t" ("a", "b") SELECT "batch"."a"::uuid, "batch"."b"::text[] FROM unnest($1::text[], $2::text[]) AS "batch"("a", "b")
|
|
4
|
+
//
|
|
5
|
+
// Each column travels as one text-array parameter holding the cells exactly as the column's own driver
|
|
6
|
+
// encoding (`mapToDriverValue`) would send them for a single-row insert, and is cast back to the column's SQL
|
|
7
|
+
// type in the SELECT. Two parameters per two columns, not one per cell, so the row count never reaches
|
|
8
|
+
// Postgres's parameter limit; and because the cast happens per cell, an array column (any dimension) needs no
|
|
9
|
+
// `unnest` of its own, which would flatten it. It is plain INSERT ... SELECT, so RLS insert policies, `ON CONFLICT`,
|
|
10
|
+
// `RETURNING` and the audit CTE all apply as they do to a `values()` insert (`COPY FROM` does none of these: see REFERENCE.md).
|
|
11
|
+
|
|
12
|
+
import { getTableColumns, is, SQL as SQLClass } from '../query/index.js'
|
|
13
|
+
import { getTableConfig } from '../query/pg-core/index.js'
|
|
14
|
+
import type { PgTable } from '../query/pg-core/index.js'
|
|
15
|
+
import { sql } from '../db/sql.js'
|
|
16
|
+
import type { SQL } from '../db/types.js'
|
|
17
|
+
import { arrayLiteral } from '../db/rows.js'
|
|
18
|
+
import { insertTenant } from '../../auth/tenant/scoped-db.js'
|
|
19
|
+
import type { WriteState } from './state.js'
|
|
20
|
+
|
|
21
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- drizzle's column type is deeply generic; only the members read below are used
|
|
22
|
+
type AnyColumn = any
|
|
23
|
+
|
|
24
|
+
const SQL_TYPE = /^[A-Za-z_"][A-Za-z0-9_". ]*(\([0-9, ]+\))?(\[[0-9]*\])*$/
|
|
25
|
+
const SERIAL_AS: Record<string, string> = { serial: 'integer', bigserial: 'bigint', smallserial: 'smallint' }
|
|
26
|
+
|
|
27
|
+
function fail(problem: string, fix: string): never {
|
|
28
|
+
throw new Error(`[pipework] pipe.insert().batch(): ${problem}\n\n Fix: ${fix}\n`)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function quote(name: string): string {
|
|
32
|
+
return `"${name.replace(/"/g, '""')}"`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The table's name as SQL: schema-qualified when it has a schema. */
|
|
36
|
+
export function qualifiedName(table: PgTable): string {
|
|
37
|
+
const config = getTableConfig(table)
|
|
38
|
+
return config.schema === undefined ? quote(config.name) : `${quote(config.schema)}.${quote(config.name)}`
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The state with the tenant column set on every row when the connection is tenant-scoped (a row naming another
|
|
43
|
+
* tenant throws, as `values()` does). Also runs the connection's insert policy, so a guard refuses a tenant table.
|
|
44
|
+
*/
|
|
45
|
+
export function scopeBatchRows(state: WriteState, db: unknown): WriteState {
|
|
46
|
+
const tenant = insertTenant(db as never, state.table)
|
|
47
|
+
if (tenant === null || state.values === undefined) return state
|
|
48
|
+
const { columnKey, tenantId } = tenant
|
|
49
|
+
const values = state.values.map(row => {
|
|
50
|
+
const existing = row[columnKey]
|
|
51
|
+
if (existing !== undefined && existing !== tenantId) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
`[pipework] Tenant-scoped insert: value has ${columnKey} "${String(existing)}" ` +
|
|
54
|
+
`but the current context tenant is "${tenantId}".\n\n` +
|
|
55
|
+
` Remove the explicit ${columnKey} from your insert values — it's set automatically.\n` +
|
|
56
|
+
' If you need cross-tenant writes, file an issue on the pipework repo.\n',
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
return { ...row, [columnKey]: tenantId }
|
|
60
|
+
})
|
|
61
|
+
return { ...state, values }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function cellText(column: AnyColumn, key: string, index: number, value: unknown): string | null {
|
|
65
|
+
if (value === null) return null
|
|
66
|
+
if (is(value, SQLClass)) {
|
|
67
|
+
fail(`row ${String(index)} sets "${key}" to a sql expression.`, 'a batch carries values only; use pipe.insert().values() for a row that needs an expression.')
|
|
68
|
+
}
|
|
69
|
+
const driver: unknown = column.mapToDriverValue(value)
|
|
70
|
+
if (driver === null || driver === undefined) return null
|
|
71
|
+
if (typeof driver === 'string') return driver
|
|
72
|
+
if (typeof driver === 'number' || typeof driver === 'bigint' || typeof driver === 'boolean') return String(driver)
|
|
73
|
+
if (driver instanceof Date) return driver.toISOString()
|
|
74
|
+
if (driver instanceof Uint8Array) return `\\x${Buffer.from(driver).toString('hex')}`
|
|
75
|
+
return JSON.stringify(driver)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* When a batch is split: the library sends at most this many rows, and at most this many bytes of parameter
|
|
80
|
+
* text, per statement, several in order in one transaction. Measured on the test cluster, 139,000 rows of a
|
|
81
|
+
* uuid, a text, an int and a text[] were one statement of 19 MB of parameters in 2.4 s; the bounds sit well
|
|
82
|
+
* above that and well under Postgres's 1 GB cap on a single parameter. Mutable so tests can shrink them.
|
|
83
|
+
*/
|
|
84
|
+
export const batchLimits = { rows: 250_000, bytes: 256 * 1024 * 1024 }
|
|
85
|
+
|
|
86
|
+
export interface BatchColumn { readonly key: string; readonly name: string; readonly type: string; readonly cells: (string | null)[] }
|
|
87
|
+
|
|
88
|
+
export function encode(state: WriteState): BatchColumn[] {
|
|
89
|
+
const rows = state.values ?? []
|
|
90
|
+
const columns = getTableColumns(state.table as unknown as PgTable) as Record<string, AnyColumn>
|
|
91
|
+
for (let i = 0; i < rows.length; i++) {
|
|
92
|
+
for (const key of Object.keys(rows[i]!)) {
|
|
93
|
+
if (columns[key] === undefined) {
|
|
94
|
+
fail(`row ${String(i)} has "${key}", which is not a field of "${state.table.$name}".`, `use the table's field keys: ${Object.keys(columns).join(', ')}.`)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const out: BatchColumn[] = []
|
|
99
|
+
for (const [key, column] of Object.entries(columns)) {
|
|
100
|
+
if (column.shouldDisableInsert() === true) continue
|
|
101
|
+
const given = rows.map(row => row[key])
|
|
102
|
+
const supplied = given.filter(v => v !== undefined).length
|
|
103
|
+
// A JS-side default (`$defaultFn`, `$onUpdateFn`) fills an omitted cell as `values()` does.
|
|
104
|
+
const jsDefault = column.defaultFn ?? (column.default === undefined || column.default === null ? column.onUpdateFn : undefined)
|
|
105
|
+
if (supplied === 0 && jsDefault === undefined) continue
|
|
106
|
+
if (supplied > 0 && supplied < rows.length && jsDefault === undefined) {
|
|
107
|
+
const missing = given.findIndex(v => v === undefined)
|
|
108
|
+
fail(
|
|
109
|
+
`row ${String(missing)} omits "${key}" while ${String(supplied)} of ${String(rows.length)} rows set it.`,
|
|
110
|
+
`a batch is one statement, so every row names the same columns: pass null for NULL, a value for each row, or omit "${key}" from all of them (its column default applies).`,
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
const cells = given.map((v, i) => {
|
|
114
|
+
if (v !== undefined) return cellText(column, key, i, v)
|
|
115
|
+
return cellText(column, key, i, (jsDefault as () => unknown)())
|
|
116
|
+
})
|
|
117
|
+
const raw = String(column.getSQLType())
|
|
118
|
+
const type = SERIAL_AS[raw] ?? raw
|
|
119
|
+
if (!SQL_TYPE.test(type)) fail(`column "${key}" has the SQL type "${type}", which a batch cannot cast to.`, 'use pipe.insert().values() for this table.')
|
|
120
|
+
out.push({ key, name: String(column.name), type, cells })
|
|
121
|
+
}
|
|
122
|
+
if (out.length === 0) fail('no row sets a column.', 'give at least one column a value in some row, or a column with a JS-side default.')
|
|
123
|
+
return out
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The statements a batch is sent as: the whole list in one when it fits `batchLimits`, otherwise consecutive
|
|
128
|
+
* slices (each slice's rows, in input order) that each fit. Every slice carries its already-encoded columns.
|
|
129
|
+
*/
|
|
130
|
+
export function planBatch(state: WriteState): WriteState[] {
|
|
131
|
+
const rows = state.values ?? []
|
|
132
|
+
const columns = encode(state)
|
|
133
|
+
const slices: [number, number][] = []
|
|
134
|
+
let from = 0
|
|
135
|
+
let bytes = 0
|
|
136
|
+
for (let i = 0; i < rows.length; i++) {
|
|
137
|
+
let rowBytes = 0
|
|
138
|
+
for (const c of columns) rowBytes += c.cells[i] === null ? 0 : Buffer.byteLength(c.cells[i]!)
|
|
139
|
+
if (i > from && (i - from >= batchLimits.rows || bytes + rowBytes > batchLimits.bytes)) {
|
|
140
|
+
slices.push([from, i])
|
|
141
|
+
from = i
|
|
142
|
+
bytes = 0
|
|
143
|
+
}
|
|
144
|
+
bytes += rowBytes
|
|
145
|
+
}
|
|
146
|
+
slices.push([from, rows.length])
|
|
147
|
+
if (slices.length === 1) return [{ ...state, encoded: columns }]
|
|
148
|
+
return slices.map(([a, b]) => ({
|
|
149
|
+
...state,
|
|
150
|
+
values: rows.slice(a, b),
|
|
151
|
+
encoded: columns.map(c => ({ ...c, cells: c.cells.slice(a, b) })),
|
|
152
|
+
}))
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Row i of the result is row i of `state.values`; the statement is `INSERT ... SELECT FROM unnest(...)` with the state's `ON CONFLICT` clause, no RETURNING. */
|
|
156
|
+
export function batchInsertSql(state: WriteState): SQL {
|
|
157
|
+
const columns = (state.encoded as BatchColumn[] | undefined) ?? encode(state)
|
|
158
|
+
const table = state.table as unknown as PgTable
|
|
159
|
+
const names = columns.map(c => quote(c.name))
|
|
160
|
+
const arrays = columns.map(c => sql`${sql.param(arrayLiteral(c.cells))}::text[]`)
|
|
161
|
+
const picked = columns.map(c => `"batch".${quote(c.name)}::${c.type}`)
|
|
162
|
+
let conflict = sql.raw('')
|
|
163
|
+
if (state.onConflict !== undefined) {
|
|
164
|
+
const target = state.onConflict.target
|
|
165
|
+
if (target === undefined) {
|
|
166
|
+
conflict = sql` ON CONFLICT DO NOTHING`
|
|
167
|
+
} else {
|
|
168
|
+
const cols = getTableColumns(table) as Record<string, AnyColumn>
|
|
169
|
+
const list = target.map(k => {
|
|
170
|
+
if (cols[k] === undefined) {
|
|
171
|
+
throw new Error(`pipe.insert: onConflictDoNothing target "${k}" is not a field of "${state.table.$name}". Use a field key declared on the table.`)
|
|
172
|
+
}
|
|
173
|
+
return quote(String(cols[k].name))
|
|
174
|
+
})
|
|
175
|
+
conflict = sql` ON CONFLICT (${sql.raw(list.join(', '))}) DO NOTHING`
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return sql`INSERT INTO ${sql.raw(qualifiedName(table))} (${sql.raw(names.join(', '))}) SELECT ${sql.raw(picked.join(', '))} FROM unnest(${sql.join(arrays, sql`, `)}) AS "batch"(${sql.raw(names.join(', '))})${conflict}`
|
|
179
|
+
}
|
|
@@ -26,9 +26,17 @@ import { resolveActorId } from './audit/actor.js'
|
|
|
26
26
|
import type { AuditSelectContext } from './audit/audit-select.js'
|
|
27
27
|
import { rowsPerStatement } from '../db/insert-split.js'
|
|
28
28
|
import { applyOnConflict, reportSkipped } from './conflict.js'
|
|
29
|
+
import { batchInsertSql, planBatch, scopeBatchRows } from './batch-insert.js'
|
|
30
|
+
import { buildFinalSelect } from './audit/final-select.js'
|
|
31
|
+
import { sql } from '../db/sql.js'
|
|
29
32
|
|
|
30
|
-
export async function executeWrite(
|
|
31
|
-
const db = pipe(
|
|
33
|
+
export async function executeWrite(input: WriteState): Promise<unknown> {
|
|
34
|
+
const db = pipe(input.table.$options.database)
|
|
35
|
+
// A batch carries the tenant on its rows before the audit and vanilla paths read them.
|
|
36
|
+
const state = input.batch === true ? scopeBatchRows(input, db) : input
|
|
37
|
+
if (state.batch === true && (state.values === undefined || state.values.length === 0)) {
|
|
38
|
+
return state.returning === undefined || state.returning === null ? undefined : []
|
|
39
|
+
}
|
|
32
40
|
const flow = getContext()
|
|
33
41
|
if (flow !== undefined && flow.trace !== null) {
|
|
34
42
|
const stepCtx = flow.trace.buffer.currentStep()
|
|
@@ -51,6 +59,7 @@ export async function executeWrite(state: WriteState): Promise<unknown> {
|
|
|
51
59
|
if (chunkValueSize !== null) {
|
|
52
60
|
return executeChunkedAuditWrite(state, auditCtx, chunkValueSize)
|
|
53
61
|
}
|
|
62
|
+
if (state.batch === true) return executeBatchAudit(state, auditCtx)
|
|
54
63
|
const stmt = compileAuditWrite(db, state, auditCtx)
|
|
55
64
|
// The compiled CTE is already tenant-scoped at compile time (the user
|
|
56
65
|
// write inside the CTE was built against the scoped `pipe()` DB, so it
|
|
@@ -60,10 +69,12 @@ export async function executeWrite(state: WriteState): Promise<unknown> {
|
|
|
60
69
|
const rawDb = pipeRaw(state.table.$options.database)
|
|
61
70
|
const rows = await rawDb.execute(stmt)
|
|
62
71
|
for (const touch of selection.written) stepCtx.markWrote(touch)
|
|
72
|
+
if (state.each !== undefined) return tally(state.each.count, rows, '__i')
|
|
63
73
|
if (state.onConflict !== undefined) reportSkipped(state, Array.isArray(rows) ? rows.length : 0)
|
|
64
74
|
return decodeReturning(state, rows)
|
|
65
75
|
}
|
|
66
76
|
}
|
|
77
|
+
if (state.batch === true) return executeBatchInsert(state)
|
|
67
78
|
if ((state.verb === 'insert' || state.verb === 'upsert') && state.values !== undefined) {
|
|
68
79
|
const size = rowsPerStatement(state.table)
|
|
69
80
|
if (state.values.length > size) return executeChunkedWrite(state, size)
|
|
@@ -90,6 +101,8 @@ function computeChunkValueSize(
|
|
|
90
101
|
if (state.verb !== 'insert' && state.verb !== 'upsert') return null
|
|
91
102
|
if (state.values === undefined) return null
|
|
92
103
|
if (touches === 0) return null
|
|
104
|
+
// A batch is one statement by contract: its audit rows come from one INSERT ... SELECT, with no parameter per row.
|
|
105
|
+
if (state.batch === true) return null
|
|
93
106
|
|
|
94
107
|
const limit = flow.instance.config.trace.auditChunkSize
|
|
95
108
|
const projectedAuditRows = state.values.length * touches
|
|
@@ -176,8 +189,70 @@ type AnyDb = any
|
|
|
176
189
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
177
190
|
type AnyChain = any
|
|
178
191
|
|
|
192
|
+
/** Rows per input index of a keys rowset: how many table rows each key updated. */
|
|
193
|
+
function tally(count: number, rows: unknown, column: string): number[] {
|
|
194
|
+
const counts = new Array<number>(count).fill(0)
|
|
195
|
+
if (Array.isArray(rows)) for (const row of rows as Record<string, unknown>[]) counts[Number(row[column])]! += 1
|
|
196
|
+
return counts
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Runs the statements of a batch plan in order. A batch over `batchLimits` is several statements, so they run in
|
|
201
|
+
* one transaction (a savepoint when nested): all of it lands or none, and each statement reads the one before.
|
|
202
|
+
*/
|
|
203
|
+
async function inBatches(state: WriteState, one: (chunk: WriteState) => Promise<unknown[]>): Promise<unknown[]> {
|
|
204
|
+
const plan = planBatch(state)
|
|
205
|
+
if (plan.length === 1) return one(plan[0]!)
|
|
206
|
+
const out: unknown[] = []
|
|
207
|
+
const run = async (): Promise<void> => {
|
|
208
|
+
for (const chunk of plan) for (const row of await one(chunk)) out.push(row)
|
|
209
|
+
}
|
|
210
|
+
const dbName = state.table.$options.database
|
|
211
|
+
if (dbName === undefined) await pipe.transaction(run)
|
|
212
|
+
else await pipe.transaction(dbName, run)
|
|
213
|
+
return out
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** `INSERT ... SELECT FROM unnest(...)`, one statement per plan slice; rows come back as `values()` returns them, in input order. */
|
|
217
|
+
async function executeBatchInsert(state: WriteState): Promise<unknown> {
|
|
218
|
+
const dbName = state.table.$options.database
|
|
219
|
+
const returns = state.returning !== undefined && state.returning !== null
|
|
220
|
+
const rows = await inBatches(state, async chunk => {
|
|
221
|
+
const rawDb = pipeRaw(dbName)
|
|
222
|
+
const insertSql = batchInsertSql(chunk)
|
|
223
|
+
if (!returns && state.onConflict === undefined) {
|
|
224
|
+
await rawDb.execute(insertSql)
|
|
225
|
+
return []
|
|
226
|
+
}
|
|
227
|
+
// Under onConflictDoNothing the rows written are the rows returned, so they are asked for even when the caller did not.
|
|
228
|
+
const out = await rawDb.execute(sql`WITH inserted AS (${insertSql} RETURNING *) ${buildFinalSelect(state, 'inserted')}`)
|
|
229
|
+
return Array.isArray(out) ? [...out] : []
|
|
230
|
+
})
|
|
231
|
+
if (state.onConflict !== undefined) reportSkipped(state, rows.length)
|
|
232
|
+
if (!returns) return state.onConflict === undefined ? undefined : []
|
|
233
|
+
return decodeReturning(state, rows)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** The write-and-audit statement, one per plan slice, in one transaction when there are several; each slice's audit rows land with its rows. */
|
|
237
|
+
async function executeBatchAudit(state: WriteState, ctx: AuditSelectContext): Promise<unknown> {
|
|
238
|
+
const dbName = state.table.$options.database
|
|
239
|
+
const rows = await inBatches(state, async chunk => {
|
|
240
|
+
const stmt = compileAuditWrite(pipe(dbName), chunk, ctx)
|
|
241
|
+
const out = await pipeRaw(dbName).execute(stmt)
|
|
242
|
+
return Array.isArray(out) ? [...out] : []
|
|
243
|
+
})
|
|
244
|
+
for (const touch of ctx.written) ctx.step.markWrote(touch)
|
|
245
|
+
if (state.onConflict !== undefined) reportSkipped(state, rows.length)
|
|
246
|
+
return decodeReturning(state, rows)
|
|
247
|
+
}
|
|
248
|
+
|
|
179
249
|
async function executeUpdate(db: AnyDb, state: WriteState): Promise<unknown> {
|
|
180
250
|
let q: AnyChain = db.update(state.table).set(state.set ?? {})
|
|
251
|
+
if (state.each !== undefined) {
|
|
252
|
+
q = q.from(state.each.source)
|
|
253
|
+
if (state.where !== undefined) q = q.where(state.where)
|
|
254
|
+
return tally(state.each.count, await q.returning({ i: state.each.ordinal }), 'i')
|
|
255
|
+
}
|
|
181
256
|
if (state.where !== undefined) q = q.where(state.where)
|
|
182
257
|
return runWithReturning(q, state.returning)
|
|
183
258
|
}
|
package/src/data/write/index.ts
CHANGED
|
@@ -9,3 +9,5 @@ export { insert, InsertBuilder } from './insert.js'
|
|
|
9
9
|
export { remove, DeleteBuilder } from './delete.js'
|
|
10
10
|
export { upsert, UpsertBuilder } from './upsert.js'
|
|
11
11
|
export type { WriteState, WriteVerb, ReturningShape } from './state.js'
|
|
12
|
+
export { updateEach } from './update-each.js'
|
|
13
|
+
export type { UpdateEachOptions } from './update-each.js'
|
package/src/data/write/insert.ts
CHANGED
|
@@ -19,6 +19,21 @@ export class InsertBuilder<T extends PgTable, TResult = unknown> implements Prom
|
|
|
19
19
|
})
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Sends the rows as ONE statement up to 250,000 rows or 256 MiB of parameter text, then as consecutive statements of that size in one transaction (the library splits, never the caller): each column travels as a single array parameter
|
|
24
|
+
* (so no parameter limit and no chunking) and is cast back per cell, which carries array columns, NULLs and
|
|
25
|
+
* every type `values()` carries, encoded as `values()` would. Every row must name the same columns; a column
|
|
26
|
+
* no row names takes its default. Composes with `onConflictDoNothing()` and `returning()`; the rows come back in
|
|
27
|
+
* input order. Runs under RLS insert policies and audits as `values()` does, in the caller's transaction.
|
|
28
|
+
*/
|
|
29
|
+
batch(rows: ReadonlyArray<InsertValues<T>>): InsertBuilder<T, TResult> {
|
|
30
|
+
return new InsertBuilder<T, TResult>({
|
|
31
|
+
...this.state,
|
|
32
|
+
batch: true,
|
|
33
|
+
values: rows as ReadonlyArray<Record<string, unknown>>,
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
|
|
22
37
|
/**
|
|
23
38
|
* Leaves a row that conflicts with an existing one and inserts the rest.
|
|
24
39
|
* Without `target`, any unique or exclusion constraint conflict is skipped.
|