pipework 0.289.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.
- package/CHANGELOG.md +9 -0
- package/REFERENCE.md +52 -0
- package/dist/data/db/expressions.d.ts +23 -0
- package/dist/data/db/expressions.d.ts.map +1 -1
- package/dist/data/db/expressions.js +27 -1
- package/dist/data/db/expressions.js.map +1 -1
- package/dist/data/db/filter.d.ts +3 -1
- package/dist/data/db/filter.d.ts.map +1 -1
- package/dist/data/db/filter.js +3 -1
- package/dist/data/db/filter.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 +3 -1
- package/dist/data/db/namespace.d.ts.map +1 -1
- package/dist/data/db/namespace.js +1 -1
- package/dist/data/db/namespace.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/query/sql/expressions/conditions.d.ts +2 -0
- package/dist/data/query/sql/expressions/conditions.d.ts.map +1 -1
- package/dist/data/query/sql/expressions/conditions.js +13 -0
- package/dist/data/query/sql/expressions/conditions.js.map +1 -1
- package/dist/data/query/sql/expressions/index.d.ts +1 -0
- package/dist/data/query/sql/expressions/index.d.ts.map +1 -1
- package/dist/data/query/sql/expressions/index.js +1 -0
- package/dist/data/query/sql/expressions/index.js.map +1 -1
- package/dist/data/query/sql/expressions/row.d.ts +19 -0
- package/dist/data/query/sql/expressions/row.d.ts.map +1 -0
- package/dist/data/query/sql/expressions/row.js +50 -0
- package/dist/data/query/sql/expressions/row.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/data/db/expressions.ts +29 -1
- package/src/data/db/filter.ts +3 -1
- package/src/data/db/function-definition.ts +172 -0
- package/src/data/db/function-source.ts +86 -14
- package/src/data/db/namespace.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/query/sql/expressions/conditions.ts +8 -0
- package/src/data/query/sql/expressions/index.ts +1 -0
- package/src/data/query/sql/expressions/row.ts +63 -0
- package/src/index.ts +2 -1
- package/src/test/function-reads.ts +290 -0
- package/src/test/index.ts +1 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { sql } from '../data/db/sql.js';
|
|
2
|
+
import { quoteIdentifier } from '../data/db/identifiers.js';
|
|
3
|
+
function walk(node, visit) {
|
|
4
|
+
visit(node);
|
|
5
|
+
for (const child of node.Plans ?? [])
|
|
6
|
+
walk(child, visit);
|
|
7
|
+
}
|
|
8
|
+
function qualified(ref) {
|
|
9
|
+
return ref.schema === 'public' ? ref.name : `${ref.schema}.${ref.name}`;
|
|
10
|
+
}
|
|
11
|
+
/** Rows touched and the shape facts of one EXPLAIN (ANALYZE, VERBOSE, FORMAT JSON) plan. */
|
|
12
|
+
export function measurePlan(root, ref, history, statistics) {
|
|
13
|
+
const scans = [];
|
|
14
|
+
let calledAsFunction = false;
|
|
15
|
+
let cteScan = false;
|
|
16
|
+
const callText = new RegExp(`(?:^|[^\\w])${ref.name}\\s*\\(`);
|
|
17
|
+
walk(root, node => {
|
|
18
|
+
if (node['Relation Name'] !== undefined) {
|
|
19
|
+
const loops = node['Actual Loops'] ?? 1;
|
|
20
|
+
const rows = (node['Actual Rows'] ?? 0) + (node['Rows Removed by Filter'] ?? 0) + (node['Rows Removed by Index Recheck'] ?? 0);
|
|
21
|
+
scans.push({
|
|
22
|
+
node: node['Node Type'] ?? 'Scan',
|
|
23
|
+
relation: `${node['Schema'] ?? 'public'}.${node['Relation Name']}`,
|
|
24
|
+
touched: rows * loops,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
if (node['Node Type'] === 'CTE Scan')
|
|
28
|
+
cteScan = true;
|
|
29
|
+
if (node['Function Name'] === ref.name)
|
|
30
|
+
calledAsFunction = true;
|
|
31
|
+
for (const [key, value] of Object.entries(node)) {
|
|
32
|
+
if (key === 'Plans')
|
|
33
|
+
continue;
|
|
34
|
+
if ((key === 'Output' || key === 'Function Call' || key.endsWith('Filter') || key === 'Join Filter' || key === 'Index Cond') && callText.test(JSON.stringify(value)))
|
|
35
|
+
calledAsFunction = true;
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
scans.sort((a, b) => b.touched - a.touched);
|
|
39
|
+
return { history, statistics, touched: scans.reduce((n, s) => n + s.touched, 0), scans, calledAsFunction, cteScan };
|
|
40
|
+
}
|
|
41
|
+
/** Why Postgres would not inline the function, from its catalog row: the settings a plan cannot show. */
|
|
42
|
+
export function catalogProblems(name, c) {
|
|
43
|
+
if (c === undefined)
|
|
44
|
+
return [`the function ${name} does not exist in the database. Run the migration that creates it before checking it.`];
|
|
45
|
+
const problems = [];
|
|
46
|
+
if (c.config !== null && c.config.length > 0) {
|
|
47
|
+
problems.push(`${name} has a SET clause (${c.config.join(', ')}). Postgres never inlines a function with a SET clause, so each call is a black box the planner cannot push keys into. ` +
|
|
48
|
+
'Remove it: a session setting such as jit belongs on the role (`ALTER ROLE app SET jit = off`).');
|
|
49
|
+
}
|
|
50
|
+
if (c.language !== 'sql') {
|
|
51
|
+
problems.push(`${name} is LANGUAGE ${c.language}. Postgres inlines only LANGUAGE sql functions. Write the read as one SELECT; a computation that is not one SELECT is a derivation, not a read.`);
|
|
52
|
+
}
|
|
53
|
+
if (c.volatility === 'v')
|
|
54
|
+
problems.push(`${name} is VOLATILE. Postgres does not inline a volatile function. Declare it STABLE or IMMUTABLE.`);
|
|
55
|
+
if (c.securityDefiner)
|
|
56
|
+
problems.push(`${name} is SECURITY DEFINER. Postgres does not inline a security definer function.`);
|
|
57
|
+
if (c.strict)
|
|
58
|
+
problems.push(`${name} is STRICT. Postgres does not inline a strict set-returning function.`);
|
|
59
|
+
return problems;
|
|
60
|
+
}
|
|
61
|
+
async function readCatalog(db, ref) {
|
|
62
|
+
const signature = `${quoteIdentifier(ref.schema, 'schema')}.${quoteIdentifier(ref.name, 'function')}(${ref.args.join(', ')})`;
|
|
63
|
+
const rows = await db.execute(sql `
|
|
64
|
+
select l.lanname as lang, p.provolatile::text as vol, p.proconfig as config, p.prosecdef as secdef, p.proisstrict as strict
|
|
65
|
+
from pg_proc p join pg_language l on l.oid = p.prolang
|
|
66
|
+
where p.oid = to_regprocedure(${signature})`);
|
|
67
|
+
const r = rows[0];
|
|
68
|
+
return r === undefined ? undefined : { language: r.lang, volatility: r.vol, config: r.config, securityDefiner: r.secdef, strict: r.strict };
|
|
69
|
+
}
|
|
70
|
+
async function explain(db, opts, history, statistics) {
|
|
71
|
+
const source = opts.fn(...opts.args);
|
|
72
|
+
return await db.transaction(async (tx) => {
|
|
73
|
+
// Parallel workers would split the loop counts the measurement multiplies by.
|
|
74
|
+
await tx.execute(sql `set local max_parallel_workers_per_gather = 0`);
|
|
75
|
+
// The call is the typed source (or expression) pipe.fn returns, so the plan is the one the caller's own query gets.
|
|
76
|
+
const query = opts.fn.ref.setReturning
|
|
77
|
+
? tx.select().from(source)
|
|
78
|
+
: sql `select ${source} as value`;
|
|
79
|
+
const rows = await tx.execute(sql `explain (analyze, verbose, format json) ${query}`);
|
|
80
|
+
const raw = rows[0]?.['QUERY PLAN'];
|
|
81
|
+
const parsed = (typeof raw === 'string' ? JSON.parse(raw) : raw);
|
|
82
|
+
return measurePlan(parsed[0].Plan, opts.fn.ref, history, statistics);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
async function setAutovacuum(db, tables, on) {
|
|
86
|
+
for (const t of tables) {
|
|
87
|
+
try {
|
|
88
|
+
await db.execute(sql.raw(`alter table ${qualifiedTable(t)} ${on ? 'reset (autovacuum_enabled)' : 'set (autovacuum_enabled = false)'}`));
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
throw new Error(`[pipework] checkFunctionRead cannot change autovacuum on ${t}: ${err instanceof Error ? err.message : String(err)}\n\n` +
|
|
92
|
+
' Fix: run the check as the role that owns the tables (or a superuser). Autovacuum must be off on them for the run, or a background ANALYZE could turn the stale and missing measurements into fresh ones.\n');
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function qualifiedTable(table) {
|
|
97
|
+
const parts = table.split('.');
|
|
98
|
+
if (parts.length > 2)
|
|
99
|
+
throw new Error(`[pipework] checkFunctionRead: "${table}" is not a table name; use "schema.table" or "table".`);
|
|
100
|
+
return parts.map(p => quoteIdentifier(p, 'table')).join('.');
|
|
101
|
+
}
|
|
102
|
+
const where = (m) => m.scans.length === 0 ? '' : ` Largest scan: ${m.scans[0].node} on ${m.scans[0].relation} (${m.scans[0].touched} rows).`;
|
|
103
|
+
/**
|
|
104
|
+
* Plans one call of a pipework-defined read at two history sizes and under three states of the planner's
|
|
105
|
+
* statistics, and reports whether it is a key lookup: inlined into the calling query, touching a number of rows
|
|
106
|
+
* that does not grow with history, and no more of them under stale or missing statistics than after ANALYZE.
|
|
107
|
+
* Rows touched is counted from `EXPLAIN (ANALYZE)`: each relation scan's rows returned plus rows removed by
|
|
108
|
+
* filter or recheck, times its loops, summed. A read that touches no rows on the fixture is `not-checked`, never passed.
|
|
109
|
+
*
|
|
110
|
+
* The statistics states: after ANALYZE at the small size the large history is seeded without analyzing (stale), then
|
|
111
|
+
* analyzed (fresh), then the tables' pg_statistic rows are deleted (missing). Deleting them needs a superuser, as the
|
|
112
|
+
* test cluster's role is; autovacuum is switched off on the tables for the run and restored.
|
|
113
|
+
*/
|
|
114
|
+
export async function checkFunctionRead(db, opts) {
|
|
115
|
+
const ref = opts.fn.ref;
|
|
116
|
+
const name = qualified(ref);
|
|
117
|
+
const [small, large] = opts.history ?? [200, 2000];
|
|
118
|
+
const relative = opts.tolerance?.relative ?? 0.1;
|
|
119
|
+
const absolute = opts.tolerance?.absolute ?? 5;
|
|
120
|
+
const analyze = async () => { for (const t of opts.tables)
|
|
121
|
+
await db.execute(sql.raw(`analyze ${qualifiedTable(t)}`)); };
|
|
122
|
+
const problems = catalogProblems(name, await readCatalog(db, ref));
|
|
123
|
+
const measurements = [];
|
|
124
|
+
const role = (await db.execute(sql `select current_user::text as role, rolsuper as super from pg_roles where rolname = current_user`))[0];
|
|
125
|
+
const notChecked = [];
|
|
126
|
+
if (role?.super !== true) {
|
|
127
|
+
notChecked.push(`Missing statistics were not checked: role "${role?.role ?? 'unknown'}" is not a superuser, so it cannot delete the tables' pg_statistic rows. ` +
|
|
128
|
+
'Run the check as a superuser (the disposable test cluster\'s role; PIPEWORK_TEST_SUPERUSER_URL).');
|
|
129
|
+
}
|
|
130
|
+
await setAutovacuum(db, opts.tables, false);
|
|
131
|
+
try {
|
|
132
|
+
await opts.seed(db, small);
|
|
133
|
+
await analyze();
|
|
134
|
+
const freshSmall = await explain(db, opts, small, 'fresh');
|
|
135
|
+
await opts.seed(db, large);
|
|
136
|
+
const stale = await explain(db, opts, large, 'stale');
|
|
137
|
+
await analyze();
|
|
138
|
+
const freshLarge = await explain(db, opts, large, 'fresh');
|
|
139
|
+
measurements.push(freshSmall, stale, freshLarge);
|
|
140
|
+
if (notChecked.length === 0) {
|
|
141
|
+
for (const t of opts.tables)
|
|
142
|
+
await db.execute(sql `delete from pg_statistic where starelid = ${qualifiedTable(t)}::regclass`);
|
|
143
|
+
measurements.push(await explain(db, opts, large, 'missing'));
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
finally {
|
|
147
|
+
await setAutovacuum(db, opts.tables, true);
|
|
148
|
+
}
|
|
149
|
+
return judgeFunctionRead(name, problems, measurements, { relative, absolute }, notChecked);
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* The verdict on four measurements (fresh at the small history, then stale, fresh and missing at the large one)
|
|
153
|
+
* and the problems the catalog already named. Pure, so each verdict can be tested without a database.
|
|
154
|
+
*/
|
|
155
|
+
export function judgeFunctionRead(name, catalog, measurements, tolerance, notChecked = []) {
|
|
156
|
+
const [freshSmall, stale, freshLarge, missing] = measurements;
|
|
157
|
+
const allowed = (baseline) => baseline * (1 + tolerance.relative) + tolerance.absolute;
|
|
158
|
+
const problems = [...catalog];
|
|
159
|
+
const small = freshSmall.history;
|
|
160
|
+
const large = freshLarge.history;
|
|
161
|
+
const done = (status) => ({
|
|
162
|
+
function: name,
|
|
163
|
+
status: status === 'passed' && notChecked.length > 0 ? 'not-checked' : status,
|
|
164
|
+
problems: [...problems, ...notChecked],
|
|
165
|
+
notChecked,
|
|
166
|
+
measurements,
|
|
167
|
+
});
|
|
168
|
+
if (measurements.some(m => m.calledAsFunction)) {
|
|
169
|
+
if (problems.length === 0) {
|
|
170
|
+
problems.push(`${name} was not inlined: the plan calls it as a function (a Function Scan), so the planner could not push the call's keys into its body. ` +
|
|
171
|
+
'Postgres inlines a function only when it is LANGUAGE sql, one SELECT, STABLE or IMMUTABLE, without SET, SECURITY DEFINER or STRICT, and (for a set) the body is a plain query.');
|
|
172
|
+
}
|
|
173
|
+
return done('failed');
|
|
174
|
+
}
|
|
175
|
+
if (freshLarge.touched === 0) {
|
|
176
|
+
if (problems.length > 0)
|
|
177
|
+
return done('failed');
|
|
178
|
+
problems.push(`${name} was not exercised: the call touched no rows on the fixture at ${large} rows of history. Seed rows for the keys in \`args\` so the read has something to look up.`);
|
|
179
|
+
return done('not-checked');
|
|
180
|
+
}
|
|
181
|
+
if (freshLarge.touched > allowed(freshSmall.touched)) {
|
|
182
|
+
problems.push(`${name} touched ${freshSmall.touched} rows at ${small} rows of history and ${freshLarge.touched} at ${large}: its rows touched grow with history, so it scans history instead of looking keys up.${where(freshLarge)}` +
|
|
183
|
+
(freshLarge.cteScan ? ' The plan has a CTE Scan: a MATERIALIZED CTE (or one used twice) is computed in full before the call\'s keys apply; use it once without MATERIALIZED, or write it as a subquery.' : '') +
|
|
184
|
+
' Give the read an index on its key columns and filter on them in the body.');
|
|
185
|
+
}
|
|
186
|
+
for (const m of [stale, missing]) {
|
|
187
|
+
if (m !== undefined && m.touched > allowed(freshLarge.touched)) {
|
|
188
|
+
problems.push(`${name} touched ${m.touched} rows with ${m.statistics} statistics against ${freshLarge.touched} after ANALYZE (at ${large} rows of history): its plan depends on the statistics being current.${where(m)} ` +
|
|
189
|
+
'Make the plan the only reasonable one: an index on the key columns, and a filter the index serves.');
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return done(problems.length === 0 ? 'passed' : 'failed');
|
|
193
|
+
}
|
|
194
|
+
/** `checkFunctionRead`, throwing a `[pipework]` error unless the read passed; `not-checked` throws too. */
|
|
195
|
+
export async function expectFunctionRead(db, opts) {
|
|
196
|
+
const report = await checkFunctionRead(db, opts);
|
|
197
|
+
if (report.status !== 'passed') {
|
|
198
|
+
throw new Error(`[pipework] function read ${report.function} ${report.status === 'failed' ? 'is not a key lookup' : 'was not checked'}:\n\n${report.problems.map(p => ` - ${p}`).join('\n')}\n`);
|
|
199
|
+
}
|
|
200
|
+
return report;
|
|
201
|
+
}
|
|
202
|
+
//# sourceMappingURL=function-reads.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"function-reads.js","sourceRoot":"","sources":["../../src/test/function-reads.ts"],"names":[],"mappings":"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"}
|
package/dist/test/index.d.ts
CHANGED
|
@@ -10,4 +10,5 @@ export { pipework as vitestPlugin, type PipeworkVitestOptions } from './vitest-p
|
|
|
10
10
|
export { resolveVitestConfig, resolveVitestModules } from './vitest-workspace.js';
|
|
11
11
|
export { tap, type ManagedConnection, type ConnectOptions } from '../data/db/pool.js';
|
|
12
12
|
export { crawlFixtureSite, type CrawlFixtureOptions, type CrawlFixtureRoute, type CrawlFixtureRequest, type CrawlFixtureSite } from './crawl-fixture.js';
|
|
13
|
+
export { checkFunctionRead, expectFunctionRead, judgeFunctionRead, measurePlan, catalogProblems, type FunctionReadOptions, type FunctionReadReport, type FunctionReadMeasurement, type StatisticsState } from './function-reads.js';
|
|
13
14
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/test/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/test/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,qBAAqB,EAAE,eAAe,EAAE,gBAAgB,EAAE,KAAK,YAAY,EAAE,KAAK,YAAY,EAAE,MAAM,YAAY,CAAA;AAC/I,OAAO,EAAE,iBAAiB,EAAE,oBAAoB,EAAE,oBAAoB,EAAE,MAAM,YAAY,CAAA;AAC1F,OAAO,EACL,YAAY,EACZ,aAAa,EACb,yBAAyB,EACzB,KAAK,UAAU,EACf,KAAK,aAAa,EAClB,KAAK,qBAAqB,GAC3B,MAAM,eAAe,CAAA;AACtB,OAAO,EAAE,YAAY,EAAE,qBAAqB,EAAE,KAAK,eAAe,EAAE,MAAM,YAAY,CAAA;AACtF,OAAO,EAAE,kBAAkB,EAAE,gBAAgB,EAAE,KAAK,WAAW,EAAE,KAAK,uBAAuB,EAAE,MAAM,aAAa,CAAA;AAClH,OAAO,EAAE,kBAAkB,EAAE,SAAS,EAAE,UAAU,EAAE,eAAe,EAAE,aAAa,EAAE,KAAK,oBAAoB,EAAE,qBAAqB,EAAE,yBAAyB,EAAE,gBAAgB,EAAE,QAAQ,EAAE,KAAK,eAAe,EAAE,MAAM,aAAa,CAAA;AACtO,OAAO,EAAE,QAAQ,EAAE,QAAQ,EAAE,MAAM,WAAW,CAAA;AAC9C,OAAO,EAAE,WAAW,EAAE,cAAc,EAAE,KAAK,kBAAkB,EAAE,MAAM,cAAc,CAAA;AACnF,OAAO,EAAE,QAAQ,IAAI,YAAY,EAAE,KAAK,qBAAqB,EAAE,MAAM,oBAAoB,CAAA;AACzF,OAAO,EAAE,mBAAmB,EAAE,oBAAoB,EAAE,MAAM,uBAAuB,CAAA;AACjF,OAAO,EAAE,GAAG,EAAE,KAAK,iBAAiB,EAAE,KAAK,cAAc,EAAE,MAAM,oBAAoB,CAAA;AACrF,OAAO,EAAE,gBAAgB,EAAE,KAAK,mBAAmB,EAAE,KAAK,iBAAiB,EAAE,KAAK,mBAAmB,EAAE,KAAK,gBAAgB,EAAE,MAAM,oBAAoB,CAAA"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/test/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,qBAAqB,EAAE,eAAe,EAAE,gBAAgB,EAAE,KAAK,YAAY,EAAE,KAAK,YAAY,EAAE,MAAM,YAAY,CAAA;AAC/I,OAAO,EAAE,iBAAiB,EAAE,oBAAoB,EAAE,oBAAoB,EAAE,MAAM,YAAY,CAAA;AAC1F,OAAO,EACL,YAAY,EACZ,aAAa,EACb,yBAAyB,EACzB,KAAK,UAAU,EACf,KAAK,aAAa,EAClB,KAAK,qBAAqB,GAC3B,MAAM,eAAe,CAAA;AACtB,OAAO,EAAE,YAAY,EAAE,qBAAqB,EAAE,KAAK,eAAe,EAAE,MAAM,YAAY,CAAA;AACtF,OAAO,EAAE,kBAAkB,EAAE,gBAAgB,EAAE,KAAK,WAAW,EAAE,KAAK,uBAAuB,EAAE,MAAM,aAAa,CAAA;AAClH,OAAO,EAAE,kBAAkB,EAAE,SAAS,EAAE,UAAU,EAAE,eAAe,EAAE,aAAa,EAAE,KAAK,oBAAoB,EAAE,qBAAqB,EAAE,yBAAyB,EAAE,gBAAgB,EAAE,QAAQ,EAAE,KAAK,eAAe,EAAE,MAAM,aAAa,CAAA;AACtO,OAAO,EAAE,QAAQ,EAAE,QAAQ,EAAE,MAAM,WAAW,CAAA;AAC9C,OAAO,EAAE,WAAW,EAAE,cAAc,EAAE,KAAK,kBAAkB,EAAE,MAAM,cAAc,CAAA;AACnF,OAAO,EAAE,QAAQ,IAAI,YAAY,EAAE,KAAK,qBAAqB,EAAE,MAAM,oBAAoB,CAAA;AACzF,OAAO,EAAE,mBAAmB,EAAE,oBAAoB,EAAE,MAAM,uBAAuB,CAAA;AACjF,OAAO,EAAE,GAAG,EAAE,KAAK,iBAAiB,EAAE,KAAK,cAAc,EAAE,MAAM,oBAAoB,CAAA;AACrF,OAAO,EAAE,gBAAgB,EAAE,KAAK,mBAAmB,EAAE,KAAK,iBAAiB,EAAE,KAAK,mBAAmB,EAAE,KAAK,gBAAgB,EAAE,MAAM,oBAAoB,CAAA;AACxJ,OAAO,EAAE,iBAAiB,EAAE,kBAAkB,EAAE,iBAAiB,EAAE,WAAW,EAAE,eAAe,EAAE,KAAK,mBAAmB,EAAE,KAAK,kBAAkB,EAAE,KAAK,uBAAuB,EAAE,KAAK,eAAe,EAAE,MAAM,qBAAqB,CAAA"}
|
package/dist/test/index.js
CHANGED
|
@@ -10,4 +10,5 @@ export { pipework as vitestPlugin } from './vitest-plugin.js';
|
|
|
10
10
|
export { resolveVitestConfig, resolveVitestModules } from './vitest-workspace.js';
|
|
11
11
|
export { tap } from '../data/db/pool.js';
|
|
12
12
|
export { crawlFixtureSite } from './crawl-fixture.js';
|
|
13
|
+
export { checkFunctionRead, expectFunctionRead, judgeFunctionRead, measurePlan, catalogProblems } from './function-reads.js';
|
|
13
14
|
//# sourceMappingURL=index.js.map
|
package/dist/test/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/test/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,qBAAqB,EAAE,eAAe,EAAE,gBAAgB,EAAwC,MAAM,YAAY,CAAA;AAC/I,OAAO,EAAE,iBAAiB,EAAE,oBAAoB,EAAE,oBAAoB,EAAE,MAAM,YAAY,CAAA;AAC1F,OAAO,EACL,YAAY,EACZ,aAAa,EACb,yBAAyB,GAI1B,MAAM,eAAe,CAAA;AACtB,OAAO,EAAE,YAAY,EAAE,qBAAqB,EAAwB,MAAM,YAAY,CAAA;AACtF,OAAO,EAAE,kBAAkB,EAAE,gBAAgB,EAAkD,MAAM,aAAa,CAAA;AAClH,OAAO,EAAE,kBAAkB,EAAE,SAAS,EAAE,UAAU,EAAE,eAAe,EAAE,aAAa,EAA6B,qBAAqB,EAAE,yBAAyB,EAAE,gBAAgB,EAAE,QAAQ,EAAwB,MAAM,aAAa,CAAA;AACtO,OAAO,EAAE,QAAQ,EAAE,QAAQ,EAAE,MAAM,WAAW,CAAA;AAC9C,OAAO,EAAE,WAAW,EAAE,cAAc,EAA2B,MAAM,cAAc,CAAA;AACnF,OAAO,EAAE,QAAQ,IAAI,YAAY,EAA8B,MAAM,oBAAoB,CAAA;AACzF,OAAO,EAAE,mBAAmB,EAAE,oBAAoB,EAAE,MAAM,uBAAuB,CAAA;AACjF,OAAO,EAAE,GAAG,EAA+C,MAAM,oBAAoB,CAAA;AACrF,OAAO,EAAE,gBAAgB,EAAqG,MAAM,oBAAoB,CAAA"}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/test/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,qBAAqB,EAAE,eAAe,EAAE,gBAAgB,EAAwC,MAAM,YAAY,CAAA;AAC/I,OAAO,EAAE,iBAAiB,EAAE,oBAAoB,EAAE,oBAAoB,EAAE,MAAM,YAAY,CAAA;AAC1F,OAAO,EACL,YAAY,EACZ,aAAa,EACb,yBAAyB,GAI1B,MAAM,eAAe,CAAA;AACtB,OAAO,EAAE,YAAY,EAAE,qBAAqB,EAAwB,MAAM,YAAY,CAAA;AACtF,OAAO,EAAE,kBAAkB,EAAE,gBAAgB,EAAkD,MAAM,aAAa,CAAA;AAClH,OAAO,EAAE,kBAAkB,EAAE,SAAS,EAAE,UAAU,EAAE,eAAe,EAAE,aAAa,EAA6B,qBAAqB,EAAE,yBAAyB,EAAE,gBAAgB,EAAE,QAAQ,EAAwB,MAAM,aAAa,CAAA;AACtO,OAAO,EAAE,QAAQ,EAAE,QAAQ,EAAE,MAAM,WAAW,CAAA;AAC9C,OAAO,EAAE,WAAW,EAAE,cAAc,EAA2B,MAAM,cAAc,CAAA;AACnF,OAAO,EAAE,QAAQ,IAAI,YAAY,EAA8B,MAAM,oBAAoB,CAAA;AACzF,OAAO,EAAE,mBAAmB,EAAE,oBAAoB,EAAE,MAAM,uBAAuB,CAAA;AACjF,OAAO,EAAE,GAAG,EAA+C,MAAM,oBAAoB,CAAA;AACrF,OAAO,EAAE,gBAAgB,EAAqG,MAAM,oBAAoB,CAAA;AACxJ,OAAO,EAAE,iBAAiB,EAAE,kBAAkB,EAAE,iBAAiB,EAAE,WAAW,EAAE,eAAe,EAAyG,MAAM,qBAAqB,CAAA"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pipework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.291.0",
|
|
4
4
|
"description": "TypeScript framework for multi-tenant SaaS applications. PostgreSQL-only.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -210,7 +210,7 @@
|
|
|
210
210
|
"mutate": "stryker run",
|
|
211
211
|
"rust:fmt": "cargo fmt --all --check",
|
|
212
212
|
"rust:clippy": "cargo clippy --workspace --all-targets -- -D warnings && cargo clippy -p pipework-query --all-targets --no-default-features --features all-types,audit,hashable-value,serde,thread-safe,postgres-driver -- -D warnings",
|
|
213
|
-
"rust:test": "cargo test --workspace && cargo test -p pipework-query --features all-types,hashable-value,serde",
|
|
213
|
+
"rust:test": "set -a; [ ! -f .env.test.local ] || . ./.env.test.local; set +a; cargo test --workspace && cargo test -p pipework-query --features all-types,hashable-value,serde",
|
|
214
214
|
"rust:check": "pnpm rust:fmt && pnpm rust:clippy && pnpm rust:test"
|
|
215
215
|
}
|
|
216
216
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { sql } from './sql.js'
|
|
2
2
|
import type { SQL, Column } from './types.js'
|
|
3
3
|
import type { SQLWrapper } from '../query/sql/sql.js'
|
|
4
|
-
import { bindIfParam } from '../query/index.js'
|
|
4
|
+
import { and, bindIfParam, gte, lte, Row, type RowElement } from '../query/index.js'
|
|
5
5
|
import { PgArray } from '../query/pg-core/index.js'
|
|
6
6
|
import { quoteCollation, type CollationName } from '../query/collation.js'
|
|
7
7
|
import { is } from '../query/entity.js'
|
|
@@ -486,3 +486,31 @@ export function walReplayLsn(): SQL<string | null> {
|
|
|
486
486
|
export function replicaFresh(createdLsn: string | Column | SQL): SQL<boolean | null> {
|
|
487
487
|
return sql<boolean | null>`pg_last_wal_replay_lsn() >= ${createdLsn}::pg_lsn`
|
|
488
488
|
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* A row constructor `(e1, e2, ...)` for comparing composite keys: `eq` / `ne` / `gt` / `gte` / `lt` / `lte`
|
|
492
|
+
* accept a row on both sides (row against row, equal length), so a composite-key join needs no `pipe.sql`:
|
|
493
|
+
* `gte(row(t.a, t.b), row(k.a, k.b))` renders `("t"."a", "t"."b") >= ("k"."a", "k"."b")`.
|
|
494
|
+
* Each element is a column, a SQL expression (a `pipe.rows` column is one) or a bound JS value. A column or
|
|
495
|
+
* expression is embedded as written. A value is bound and, when the element facing it on the other side is
|
|
496
|
+
* a column, converted by that column's driver mapping and cast to its SQL type (`$1::text`); facing an
|
|
497
|
+
* expression it is bound without a cast and Postgres infers its type from the other side.
|
|
498
|
+
* Throws `[pipework] row:` for fewer than two elements, for rows of unequal length, and when a row is
|
|
499
|
+
* compared with a non-row.
|
|
500
|
+
*/
|
|
501
|
+
export function row(...elements: RowElement[]): Row {
|
|
502
|
+
return new Row(elements)
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Row equality as the range pair `lhs >= rhs and lhs <= rhs`, for a composite-key match that must use an index.
|
|
507
|
+
* Written as `(a, b, c) = (x, y, z)` Postgres splits it into three equalities, and a join then probes the shortest
|
|
508
|
+
* index holding a and b and filters c. A row comparison is a btree index condition only on an index holding all
|
|
509
|
+
* its columns in order, so the range pair takes the index over (a, b, c) and reads only the matching rows.
|
|
510
|
+
* It equals equality for keys that are never NULL. With a NULL element a match is never true, as with
|
|
511
|
+
* equality (a NULL-bearing key does not match itself), but the result can be NULL where equality gives
|
|
512
|
+
* false, which differs only under `not(...)`; keep NULL-able columns out of the row or filter them first.
|
|
513
|
+
*/
|
|
514
|
+
export function rowEquals(lhs: Row, rhs: Row): SQL<boolean> {
|
|
515
|
+
return and(gte(lhs, rhs), lte(lhs, rhs)) as SQL<boolean>
|
|
516
|
+
}
|
package/src/data/db/filter.ts
CHANGED
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
} from '../query/index.js'
|
|
6
6
|
import { countAgg, countDistinctAgg, sumAgg, sumDistinctAgg, avgAgg, avgDistinctAgg, maxAgg, minAgg, boolAnd, boolOr, arrayAgg, stringAgg, jsonbAgg, jsonbObjectAgg, jsonbBuildObject } from './aggregates.js'
|
|
7
7
|
import { except, exceptAll, intersect, intersectAll, union, unionAll } from '../query/pg-core/index.js'
|
|
8
|
-
import { increment, decrement, add, subtract, coalesce, now, scalar, cast, collate, interval, clockTimestamp, strpos, textContains, startsWith, left, right, length, concat, arrayReplace, arrayHas, splitPart, lower, upper, trim, replace, substring, reverse, lpad, rpad, regexpReplace, regexpMatch, stringToArray, cardinality, arrayAt, arrayPosition, arraySlice, arrayToString, arrayAppend, arrayOf, arrayRemove, dateTrunc, rowNumber, rank, denseRank, over, identifier, withRecursive, caseWhen, walCurrentLsn, walReplayLsn, replicaFresh } from './expressions.js'
|
|
8
|
+
import { increment, decrement, add, subtract, coalesce, now, scalar, cast, collate, interval, clockTimestamp, strpos, textContains, startsWith, left, right, length, concat, arrayReplace, arrayHas, splitPart, lower, upper, trim, replace, substring, reverse, lpad, rpad, regexpReplace, regexpMatch, stringToArray, cardinality, arrayAt, arrayPosition, arraySlice, arrayToString, arrayAppend, arrayOf, row, rowEquals, arrayRemove, dateTrunc, rowNumber, rank, denseRank, over, identifier, withRecursive, caseWhen, walCurrentLsn, walReplayLsn, replicaFresh } from './expressions.js'
|
|
9
9
|
import { ago, castUuid, castJsonb, castTimestamp, castBoolean, castNumeric, castInteger, castText, nextSequence } from './composed.js'
|
|
10
10
|
import { json as jsonbExtract, pathText as jsonbExtractText, contains as jsonbContains, merge as jsonbMerge, concat as jsonbConcat, remove as jsonbRemove } from '../jsonb/query.js'
|
|
11
11
|
import { arrayAny, arrayAll, jsonbElements } from './array-elements.js'
|
|
@@ -57,6 +57,8 @@ export const filter = {
|
|
|
57
57
|
arrayToString,
|
|
58
58
|
arrayAppend,
|
|
59
59
|
arrayOf,
|
|
60
|
+
row,
|
|
61
|
+
rowEquals,
|
|
60
62
|
arrayRemove,
|
|
61
63
|
dateTrunc,
|
|
62
64
|
rowNumber,
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { blankLiterals, stripComments } from '../migrate/statements.js'
|
|
2
|
+
|
|
3
|
+
/** Marks a callable returned by `pipe.fn` that carries a definition (a `body`), so the migration generator can find it among a schema file's exports. */
|
|
4
|
+
export const FUNCTION_DEFINITION = Symbol.for('pipework.fn.definition')
|
|
5
|
+
|
|
6
|
+
export type FunctionVolatility = 'stable' | 'immutable'
|
|
7
|
+
|
|
8
|
+
/** One column of a set-returning function's result, as the snapshot and the DDL carry it. */
|
|
9
|
+
export interface FunctionColumnDefinition {
|
|
10
|
+
readonly name: string
|
|
11
|
+
readonly type: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** A function pipework creates and migrates: what the snapshot records and the DDL is emitted from. */
|
|
15
|
+
export interface FunctionDefinition {
|
|
16
|
+
readonly schema: string
|
|
17
|
+
readonly name: string
|
|
18
|
+
/** SQL types of the arguments, in call order. */
|
|
19
|
+
readonly args: readonly string[]
|
|
20
|
+
/** `table`: RETURNS TABLE(columns) with a ROWS estimate. `scalar`: RETURNS type. */
|
|
21
|
+
readonly returns:
|
|
22
|
+
| { readonly kind: 'table'; readonly columns: readonly FunctionColumnDefinition[]; readonly rows: number }
|
|
23
|
+
| { readonly kind: 'scalar'; readonly type: string }
|
|
24
|
+
readonly body: string
|
|
25
|
+
readonly volatility: FunctionVolatility
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** What `pipe.fn` was given, for the rules that look at it before it becomes a definition. */
|
|
29
|
+
export interface FunctionDefinitionInput {
|
|
30
|
+
readonly name: string
|
|
31
|
+
readonly argCount: number
|
|
32
|
+
readonly body: string
|
|
33
|
+
readonly spec: Readonly<Record<string, unknown>>
|
|
34
|
+
readonly setReturning: boolean
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const SPEC_KEYS = new Set(['args', 'columns', 'returns', 'body', 'volatility', 'rows', 'language'])
|
|
38
|
+
|
|
39
|
+
function fail(name: string, message: string, fix: string): never {
|
|
40
|
+
throw new Error(`[pipework] pipe.fn("${name}"): ${message}\n\n Fix: ${fix}\n`)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The identity of a function in a snapshot: `schema.name(type, type)`; a changed argument list is a different function. */
|
|
44
|
+
export function functionKey(def: Pick<FunctionDefinition, 'schema' | 'name' | 'args'>): string {
|
|
45
|
+
return `${def.schema}.${def.name}(${def.args.join(', ')})`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The static rules of a pipework-defined read. Each exists because Postgres inlines a SQL function into the
|
|
50
|
+
* calling query only when the function allows it, and a function that is not inlined is a black box the planner
|
|
51
|
+
* cannot push the caller's keys into; its rows touched then grow with history instead of with its keys.
|
|
52
|
+
* Throws a `[pipework]` error naming the rule and the fix.
|
|
53
|
+
*/
|
|
54
|
+
export function assertReadFunction(input: FunctionDefinitionInput): void {
|
|
55
|
+
const { name, spec, body } = input
|
|
56
|
+
|
|
57
|
+
for (const key of ['set', 'config', 'settings']) {
|
|
58
|
+
if (key in spec) {
|
|
59
|
+
fail(
|
|
60
|
+
name,
|
|
61
|
+
`"${key}" is a SET clause, which a read function cannot have. Postgres never inlines a function with a SET clause (proconfig is set), so the planner cannot push the caller's keys into it and every call plans and scans on its own.`,
|
|
62
|
+
'remove it. A session setting such as jit belongs on the role (`ALTER ROLE app SET jit = off`), not on a function.',
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const unknown = Object.keys(spec).filter(k => !SPEC_KEYS.has(k) && k !== 'set' && k !== 'config' && k !== 'settings')
|
|
67
|
+
if (unknown.length > 0) {
|
|
68
|
+
fail(
|
|
69
|
+
name,
|
|
70
|
+
`unknown option${unknown.length > 1 ? 's' : ''} ${unknown.map(k => `"${k}"`).join(', ')}.`,
|
|
71
|
+
`a read function takes ${[...SPEC_KEYS].join(', ')}. SECURITY DEFINER, STRICT, PARALLEL and COST are not offered: each stops Postgres inlining the function.`,
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const language = spec['language']
|
|
76
|
+
if (language !== undefined && language !== 'sql') {
|
|
77
|
+
fail(
|
|
78
|
+
name,
|
|
79
|
+
`language "${String(language)}" is not allowed for a read. Postgres inlines only LANGUAGE sql functions; a plpgsql function is a black box that runs per call and cannot be pushed into.`,
|
|
80
|
+
'write the read as one LANGUAGE sql SELECT. A computation that cannot be one SELECT is a derivation (maintained on write), not a read.',
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const volatility = spec['volatility']
|
|
85
|
+
if (volatility !== undefined && volatility !== 'stable' && volatility !== 'immutable') {
|
|
86
|
+
fail(
|
|
87
|
+
name,
|
|
88
|
+
`volatility "${String(volatility)}" is not allowed for a read. Postgres does not inline a VOLATILE function into the calling query.`,
|
|
89
|
+
"use 'stable' (reads tables) or 'immutable' (reads nothing that changes).",
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (input.setReturning) {
|
|
94
|
+
const rows = spec['rows']
|
|
95
|
+
if (typeof rows !== 'number' || !Number.isInteger(rows) || rows <= 0) {
|
|
96
|
+
fail(
|
|
97
|
+
name,
|
|
98
|
+
'a set-returning function needs a ROWS estimate (a positive integer). Without it Postgres assumes 1000 rows for the call, and plans every join and filter around that guess.',
|
|
99
|
+
'give `rows` the typical result size for one call, e.g. `rows: 50`.',
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
} else if (spec['rows'] !== undefined) {
|
|
103
|
+
fail(name, '`rows` applies to a set-returning function, and this one returns a scalar.', 'remove `rows`, or declare `columns` to return a set.')
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const code = blankLiterals(stripComments(body))
|
|
107
|
+
const trimmed = code.trim().replace(/;\s*$/, '')
|
|
108
|
+
|
|
109
|
+
if (/;/.test(trimmed)) {
|
|
110
|
+
fail(
|
|
111
|
+
name,
|
|
112
|
+
'the body has more than one statement. Postgres inlines a function only when its body is a single SELECT.',
|
|
113
|
+
'make the body one SELECT (use a CTE or a join for the second step).',
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
if (!/^(select|with|\(\s*select)\b/i.test(trimmed)) {
|
|
117
|
+
fail(
|
|
118
|
+
name,
|
|
119
|
+
'the body is not a SELECT. A read function is one SELECT statement; Postgres inlines only a single SELECT.',
|
|
120
|
+
'start the body with SELECT or WITH ... SELECT.',
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
if (/\b(insert\s+into|update\s+[\w".]+\s+set|delete\s+from|merge\s+into|truncate\s+(?:table\s+)?["\w])/i.test(trimmed)) {
|
|
124
|
+
fail(
|
|
125
|
+
name,
|
|
126
|
+
'the body writes (INSERT, UPDATE, DELETE, MERGE or TRUNCATE, possibly inside a CTE). A read function is one SELECT; a body with a data-modifying statement is not inlined.',
|
|
127
|
+
'move the write to pipe.insert / pipe.update / pipe.delete.',
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
if (/\bfor\s+(no\s+key\s+update|update|key\s+share|share)\b/i.test(trimmed)) {
|
|
131
|
+
fail(name, 'the body takes row locks (FOR UPDATE / FOR SHARE). Postgres does not inline a function that locks rows.', 'remove the locking clause; a read does not lock.')
|
|
132
|
+
}
|
|
133
|
+
if (/\bset_config\s*\(/i.test(trimmed)) {
|
|
134
|
+
fail(
|
|
135
|
+
name,
|
|
136
|
+
'the body calls set_config(), which is a SET clause in disguise: it makes the function VOLATILE and Postgres does not inline it.',
|
|
137
|
+
'remove it. A session setting belongs on the role (`ALTER ROLE app SET ... `).',
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
if (/\bas\s+materialized\b/i.test(trimmed)) {
|
|
141
|
+
fail(
|
|
142
|
+
name,
|
|
143
|
+
'the body has a MATERIALIZED CTE. Postgres computes it once, in full, before the caller\'s keys are applied: it is an optimisation fence, and the rows it scans grow with history, not with the keys.',
|
|
144
|
+
'drop the MATERIALIZED keyword (a CTE used once is inlined by default), or write the CTE as a subquery or a join.',
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
if (/\bin\s*\(\s*\$\d+\s*(?:,\s*\$\d+\s*)+\)/i.test(trimmed) || /\barray\s*\[\s*\$\d+\s*(?:,\s*\$\d+\s*)+\]/i.test(trimmed)) {
|
|
148
|
+
fail(
|
|
149
|
+
name,
|
|
150
|
+
'a list is passed as separate parameters ($1, $2, ...). A list argument is one array parameter: separate parameters give every list length its own signature and its own plan.',
|
|
151
|
+
'declare one array argument (`pipe.field.uuid().array()`) and write `column = ANY($n)`.',
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
for (const m of trimmed.matchAll(/\$(\d+)/g)) {
|
|
155
|
+
const n = Number(m[1])
|
|
156
|
+
if (n < 1 || n > input.argCount) {
|
|
157
|
+
fail(
|
|
158
|
+
name,
|
|
159
|
+
`the body refers to $${n}, but the function declares ${input.argCount} argument${input.argCount === 1 ? '' : 's'}.`,
|
|
160
|
+
'refer to arguments as $1 ... $n, in the order of `args`.',
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The dollar-quote tag for a body: `$pw_body$`, lengthened until the body does not contain it. */
|
|
167
|
+
export function bodyQuote(body: string): string {
|
|
168
|
+
let tag = '$pw_body$'
|
|
169
|
+
let i = 0
|
|
170
|
+
while (body.includes(tag)) tag = `$pw_body${++i}$`
|
|
171
|
+
return tag
|
|
172
|
+
}
|