pipework 0.290.0 → 0.291.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.
Files changed (51) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/REFERENCE.md +50 -0
  3. package/dist/data/db/function-definition.d.ts +46 -0
  4. package/dist/data/db/function-definition.d.ts.map +1 -0
  5. package/dist/data/db/function-definition.js +84 -0
  6. package/dist/data/db/function-definition.js.map +1 -0
  7. package/dist/data/db/function-source.d.ts +33 -2
  8. package/dist/data/db/function-source.d.ts.map +1 -1
  9. package/dist/data/db/function-source.js +47 -19
  10. package/dist/data/db/function-source.js.map +1 -1
  11. package/dist/data/db/namespace.d.ts +1 -1
  12. package/dist/data/db/namespace.d.ts.map +1 -1
  13. package/dist/data/db/namespace.js +1 -1
  14. package/dist/data/db/namespace.js.map +1 -1
  15. package/dist/data/migrate/diff.d.ts +7 -0
  16. package/dist/data/migrate/diff.d.ts.map +1 -1
  17. package/dist/data/migrate/diff.js +37 -0
  18. package/dist/data/migrate/diff.js.map +1 -1
  19. package/dist/data/migrate/history.d.ts +2 -0
  20. package/dist/data/migrate/history.d.ts.map +1 -1
  21. package/dist/data/migrate/history.js +8 -0
  22. package/dist/data/migrate/history.js.map +1 -1
  23. package/dist/data/migrate/snapshot.d.ts +3 -0
  24. package/dist/data/migrate/snapshot.d.ts.map +1 -1
  25. package/dist/data/migrate/snapshot.js +13 -0
  26. package/dist/data/migrate/snapshot.js.map +1 -1
  27. package/dist/data/migrate/sql-emit.d.ts.map +1 -1
  28. package/dist/data/migrate/sql-emit.js +30 -2
  29. package/dist/data/migrate/sql-emit.js.map +1 -1
  30. package/dist/index.d.ts +2 -1
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js.map +1 -1
  33. package/dist/test/function-reads.d.ts +97 -0
  34. package/dist/test/function-reads.d.ts.map +1 -0
  35. package/dist/test/function-reads.js +202 -0
  36. package/dist/test/function-reads.js.map +1 -0
  37. package/dist/test/index.d.ts +1 -0
  38. package/dist/test/index.d.ts.map +1 -1
  39. package/dist/test/index.js +1 -0
  40. package/dist/test/index.js.map +1 -1
  41. package/package.json +2 -2
  42. package/src/data/db/function-definition.ts +172 -0
  43. package/src/data/db/function-source.ts +86 -14
  44. package/src/data/db/namespace.ts +1 -1
  45. package/src/data/migrate/diff.ts +45 -0
  46. package/src/data/migrate/history.ts +9 -0
  47. package/src/data/migrate/snapshot.ts +17 -0
  48. package/src/data/migrate/sql-emit.ts +33 -3
  49. package/src/index.ts +2 -1
  50. package/src/test/function-reads.ts +290 -0
  51. package/src/test/index.ts +1 -0
@@ -9,6 +9,7 @@ import type { InferFieldOutput } from '../domain/brand.js'
9
9
  import type { FieldDescriptor } from '../domain/types.js'
10
10
  import { projectTable } from '../domain/build-table.js'
11
11
  import { camelToSnake } from '../domain/naming.js'
12
+ import { assertReadFunction, FUNCTION_DEFINITION, type FunctionDefinition, type FunctionVolatility } from './function-definition.js'
12
13
 
13
14
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- variance-safe bound for descriptors with any output/flags
14
15
  type AnyField = FieldDescriptor<any, any>
@@ -21,6 +22,38 @@ export interface FunctionSpec<TArgs extends readonly AnyField[], TColumns extend
21
22
  readonly args: readonly [...TArgs]
22
23
  /** One `pipe.field.*` descriptor per result column; keys are camelCase, columns are their snake_case. */
23
24
  readonly columns: TColumns
25
+ /** The body of a function pipework creates: one `LANGUAGE sql` SELECT that refers to the arguments as `$1`, `$2`, ... Without it the function is only referenced (it exists already). */
26
+ readonly body?: string
27
+ /** `'stable'` (the default) or `'immutable'`; a read is never volatile. Only with `body`. */
28
+ readonly volatility?: FunctionVolatility
29
+ /** The typical number of rows one call returns, a planner estimate (ROWS). Required with `body`. */
30
+ readonly rows?: number
31
+ /** Only `'sql'`: Postgres inlines no other language. */
32
+ readonly language?: 'sql'
33
+ }
34
+
35
+ /** Declares a SQL function that returns one value. See {@link fn}. */
36
+ export interface ScalarFunctionSpec<TArgs extends readonly AnyField[], TReturn extends AnyField> {
37
+ readonly args: readonly [...TArgs]
38
+ readonly returns: TReturn
39
+ readonly body: string
40
+ readonly volatility?: FunctionVolatility
41
+ readonly language?: 'sql'
42
+ }
43
+
44
+ /** A function callable with typed arguments; `definition` is set when pipework creates it (the spec had a `body`). */
45
+ export type DefinedCall<TCall> = TCall & {
46
+ readonly definition: FunctionDefinition | undefined
47
+ /** Where the function lives and its argument types; what a plan or catalog check looks it up by. */
48
+ readonly ref: FunctionRef
49
+ }
50
+
51
+ /** A function's schema, name and SQL argument types. */
52
+ export interface FunctionRef {
53
+ readonly schema: string
54
+ readonly name: string
55
+ readonly args: readonly string[]
56
+ readonly setReturning: boolean
24
57
  }
25
58
 
26
59
  /** A column whose data type is `T` (and that cannot be null unless `T` can). */
@@ -56,7 +89,7 @@ function quote(name: string): string {
56
89
  }
57
90
 
58
91
  /**
59
- * Declares a set-returning SQL function and returns a callable that yields a source for `.from()` and joins.
92
+ * Declares a SQL function and returns a callable. A set-returning one (`columns`) yields a source for `.from()` and joins.
60
93
  * Each argument is a value, a column or a SQL expression of the declared type. A value is bound as a parameter,
61
94
  * converted the way a column of that field is on insert (jsonb as JSON text, dates, bigints, arrays), and cast to its
62
95
  * declared SQL type: `ext.f($1::uuid, $2::text)`. A null binds SQL NULL. An array argument (`field.uuid().array()`) is
@@ -64,10 +97,15 @@ function quote(name: string): string {
64
97
  * `unnest(t.arr::uuid[])`, so an array column can be unnested in a lateral join (`.crossJoinLateral(unnestUuids(t.arr))`).
65
98
  * Result columns are typed from `columns`; the default alias is the function's unqualified name.
66
99
  */
100
+ export function fn<const TArgs extends readonly AnyField[], TReturn extends AnyField>(
101
+ name: string,
102
+ spec: ScalarFunctionSpec<TArgs, TReturn>,
103
+ ): DefinedCall<(...values: ArgValues<TArgs>) => SQL<InferFieldOutput<TReturn>>>
67
104
  export function fn<const TArgs extends readonly AnyField[], TColumns extends Record<string, AnyField>>(
68
105
  name: string,
69
106
  spec: FunctionSpec<TArgs, TColumns>,
70
- ): (...values: ArgValues<TArgs>) => FunctionSource<string, TColumns> {
107
+ ): DefinedCall<(...values: ArgValues<TArgs>) => FunctionSource<string, TColumns>>
108
+ export function fn(name: string, spec: FunctionSpec<readonly AnyField[], Record<string, AnyField>> | ScalarFunctionSpec<readonly AnyField[], AnyField>): unknown {
71
109
  const parts = name.split('.')
72
110
  if (parts.length > 2 || parts.some(p => !NAME_PART.test(p))) {
73
111
  fail(
@@ -75,29 +113,58 @@ export function fn<const TArgs extends readonly AnyField[], TColumns extends Rec
75
113
  'use "fn" or "schema.fn", each part lowercase letters, digits and underscores, not starting with a digit.',
76
114
  )
77
115
  }
78
- const keys = Object.keys(spec.columns)
79
- if (keys.length === 0) fail(`pipe.fn("${name}"): columns is empty.`, 'declare at least one result column.')
116
+ const scalar = 'returns' in spec ? spec.returns : undefined
117
+ const columns: Record<string, AnyField> = 'columns' in spec ? spec.columns : {}
118
+ const keys = Object.keys(columns)
119
+ if (scalar === undefined && keys.length === 0) fail(`pipe.fn("${name}"): columns is empty.`, 'declare at least one result column.')
120
+ if (scalar !== undefined && 'columns' in spec) fail(`pipe.fn("${name}"): both returns and columns are declared.`, 'declare `returns` for a scalar function or `columns` for a set-returning one.')
80
121
  for (const key of keys) {
81
122
  if (key === 'as' || key === '_') {
82
123
  fail(`pipe.fn("${name}"): a result column cannot be named "${key}".`, 'rename the key; the column name comes from camelToSnake(key).')
83
124
  }
84
125
  }
85
126
 
127
+ const sqlTypeOf = (fields: Record<string, AnyField>): Record<string, string> => {
128
+ const cols = getTableColumns(projectTable('pw_fn_args', fields, {})) as Record<string, { getSQLType(): string }>
129
+ return Object.fromEntries(Object.entries(cols).map(([k, c]) => {
130
+ const t = c.getSQLType()
131
+ return [k, t === 'serial' ? 'integer' : t === 'bigserial' ? 'bigint' : t]
132
+ }))
133
+ }
134
+
86
135
  const argFields: Record<string, AnyField> = {}
87
136
  spec.args.forEach((a, i) => { argFields[`a${i}`] = a })
88
137
  const argCols = getTableColumns(projectTable('pw_fn_args', argFields, {})) as Record<string, { getSQLType(): string; mapToDriverValue(value: unknown): unknown }>
89
- const argTypes = spec.args.map((_, i) => {
90
- const t = argCols[`a${i}`]!.getSQLType()
91
- return t === 'serial' ? 'integer' : t === 'bigserial' ? 'bigint' : t
92
- })
138
+ const argTypes = spec.args.map((_, i) => sqlTypeOf({ a: spec.args[i]! })['a']!)
139
+
140
+ let definition: FunctionDefinition | undefined
141
+ if (spec.body !== undefined) {
142
+ if (typeof spec.body !== 'string') fail(`pipe.fn("${name}"): body must be a string.`, 'pass the SELECT as a string, with arguments as $1, $2, ...')
143
+ assertReadFunction({ name, argCount: spec.args.length, body: spec.body, spec: spec as unknown as Record<string, unknown>, setReturning: scalar === undefined })
144
+ const returnTypes = scalar === undefined ? sqlTypeOf(columns) : sqlTypeOf({ r: scalar })
145
+ definition = {
146
+ schema: parts.length === 2 ? parts[0]! : 'public',
147
+ name: parts[parts.length - 1]!,
148
+ args: argTypes,
149
+ returns: scalar === undefined
150
+ ? { kind: 'table', columns: keys.map(k => ({ name: camelToSnake(k), type: returnTypes[k]! })), rows: (spec as FunctionSpec<readonly AnyField[], Record<string, AnyField>>).rows! }
151
+ : { kind: 'scalar', type: returnTypes['r']! },
152
+ body: spec.body,
153
+ volatility: spec.volatility ?? 'stable',
154
+ }
155
+ } else {
156
+ for (const key of ['volatility', 'rows', 'language'] as const) {
157
+ if (key in spec) fail(`pipe.fn("${name}"): "${key}" is given without a body.`, 'add `body` to have pipework create the function, or remove the option (a referenced function keeps what it was created with).')
158
+ }
159
+ }
93
160
 
94
161
  const fnSql = sql.raw(parts.map(quote).join('.'))
95
162
  const defaultAlias = parts[parts.length - 1]!
163
+ const argList = (values: readonly unknown[]): SQL => sql.join(values.map((v, i) => sql`${bindIfParam(v, argCols[`a${i}`] as unknown as Column)}::${sql.raw(argTypes[i]!)}`), sql`, `)
96
164
 
97
- const build = (values: readonly unknown[], alias: string): FunctionSource<string, TColumns> => {
165
+ const build = (values: readonly unknown[], alias: string): FunctionSource<string, Record<string, AnyField>> => {
98
166
  const columnList = sql.join(keys.map(k => sql.raw(quote(camelToSnake(k)))), sql`, `)
99
- const argList = sql.join(values.map((v, i) => sql`${bindIfParam(v, argCols[`a${i}`] as unknown as Column)}::${sql.raw(argTypes[i]!)}`), sql`, `)
100
- const inner = sql`select ${columnList} from ${fnSql}(${argList})`
167
+ const inner = sql`select ${columnList} from ${fnSql}(${argList(values)})`
101
168
  const fields: Record<string, SQL> = {}
102
169
  for (const k of keys) {
103
170
  fields[k] = sql.raw(`${quote(alias)}.${quote(camelToSnake(k))}`)
@@ -109,16 +176,21 @@ export function fn<const TArgs extends readonly AnyField[], TColumns extends Rec
109
176
  if (prop === 'as') return (next: string) => build(values, next)
110
177
  return selection.get(target, prop)
111
178
  },
112
- }) as unknown as FunctionSource<string, TColumns>
179
+ }) as unknown as FunctionSource<string, Record<string, AnyField>>
113
180
  }
114
181
 
115
- return (...values: ArgValues<TArgs>) => {
182
+ const call = (...values: unknown[]): unknown => {
116
183
  if (values.length !== spec.args.length) {
117
184
  fail(
118
185
  `pipe.fn("${name}"): called with ${values.length} argument(s), declared ${spec.args.length}.`,
119
186
  'pass one value per declared argument.',
120
187
  )
121
188
  }
122
- return build(values, defaultAlias)
189
+ return scalar !== undefined ? sql`${fnSql}(${argList(values)})` : build(values, defaultAlias)
123
190
  }
191
+ return Object.defineProperties(call, {
192
+ definition: { value: definition, enumerable: true },
193
+ ref: { value: { schema: parts.length === 2 ? parts[0]! : 'public', name: defaultAlias, args: argTypes, setReturning: scalar === undefined }, enumerable: true },
194
+ [FUNCTION_DEFINITION]: { value: definition },
195
+ })
124
196
  }
@@ -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. */
@@ -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 === 'create_table') {
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
+ }
package/src/index.ts CHANGED
@@ -11,7 +11,8 @@ export type { Aggregate, EmptyableAggregate, AggregateOptions, JsonOf } from './
11
11
  export type { ValueColumnDef } from './data/db/values.js'
12
12
  export type { RowsColumns, RowsData, RowsSource, RowsColumnValue } from './data/db/rows.js'
13
13
  export type { ArraySource, ElementOf, JsonbElements } from './data/db/array-elements.js'
14
- export type { FunctionSpec, FunctionSource, FunctionSourceFields } from './data/db/function-source.js'
14
+ export type { FunctionSpec, ScalarFunctionSpec, FunctionSource, FunctionSourceFields, DefinedCall, FunctionRef } from './data/db/function-source.js'
15
+ export type { FunctionDefinition, FunctionVolatility } from './data/db/function-definition.js'
15
16
  export type { SerializableOptions } from './data/db/serializable.js'
16
17
  export { StatementTimeoutError, LockTimeoutError } from './data/db/timeouts.js'
17
18
  export type { TimeoutOptions } from './data/db/timeouts.js'