-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsql.ts
More file actions
78 lines (68 loc) · 2.57 KB
/
Copy pathsql.ts
File metadata and controls
78 lines (68 loc) · 2.57 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
/**
* Small SQL helpers for the repository layer.
*
* Values are *always* bound as `$n` parameters — never string-concatenated — so
* the data layer cannot be SQL-injected. Identifiers (table/column names) come
* only from our own constants, but are still validated against a strict pattern
* as defense in depth before being interpolated.
*/
const IDENT_RE = /^[a-z_][a-z0-9_]*$/;
/** Reject any identifier that isn't a plain snake_case SQL name. */
export function assertIdent(name: string): string {
if (!IDENT_RE.test(name)) {
throw new Error(`unsafe SQL identifier: ${JSON.stringify(name)}`);
}
return name;
}
/** A parameterized statement: SQL text plus its ordered bind values. */
export interface Statement {
readonly text: string;
readonly params: unknown[];
}
/** Options for {@link buildInsert}. */
export interface InsertOptions {
/**
* Columns of a unique constraint to treat as an idempotent reservation: when
* a conflicting row already exists the insert becomes a no-op
* (`ON CONFLICT (...) DO NOTHING`) and `RETURNING *` yields no row. Used for
* atomic anti-replay on `(agent_id, nonce)` (migration 0002). Identifiers are
* validated like every other name.
*/
readonly onConflictDoNothing?: readonly string[];
}
/**
* Build a parameterized `INSERT ... RETURNING *` from a column→value map.
* Keys present with `undefined` values are omitted (the column keeps its DB
* default); `null` is passed through as a real SQL NULL. With
* {@link InsertOptions.onConflictDoNothing} the statement is an idempotent
* reservation that returns no row on conflict.
*/
export function buildInsert(
table: string,
values: Record<string, unknown>,
options: InsertOptions = {},
): Statement {
assertIdent(table);
const cols: string[] = [];
const params: unknown[] = [];
const placeholders: string[] = [];
for (const [col, value] of Object.entries(values)) {
if (value === undefined) continue;
cols.push(assertIdent(col));
params.push(value);
placeholders.push(`$${params.length}`);
}
if (cols.length === 0) {
throw new Error(`buildInsert(${table}): no columns to insert`);
}
let text = `INSERT INTO ${table} (${cols.join(', ')}) VALUES (${placeholders.join(', ')})`;
const conflict = options.onConflictDoNothing;
if (conflict !== undefined) {
if (conflict.length === 0) {
throw new Error(`buildInsert(${table}): onConflictDoNothing needs at least one column`);
}
text += ` ON CONFLICT (${conflict.map(assertIdent).join(', ')}) DO NOTHING`;
}
text += ' RETURNING *';
return { text, params };
}