@blamejs/core 0.14.27 → 0.15.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 +4 -0
- package/README.md +2 -2
- package/index.js +4 -0
- package/lib/ai-content-detect.js +9 -10
- package/lib/api-key.js +107 -74
- package/lib/atomic-file.js +29 -1
- package/lib/audit-chain.js +47 -11
- package/lib/audit-sign.js +77 -2
- package/lib/audit-tools.js +79 -51
- package/lib/audit.js +218 -100
- package/lib/backup/index.js +13 -10
- package/lib/break-glass.js +202 -144
- package/lib/cache.js +174 -105
- package/lib/chain-writer.js +38 -16
- package/lib/cli.js +19 -14
- package/lib/cluster-provider-db.js +130 -104
- package/lib/cluster-storage.js +119 -22
- package/lib/cluster.js +119 -71
- package/lib/compliance.js +22 -0
- package/lib/consent.js +73 -24
- package/lib/constants.js +16 -11
- package/lib/crypto-field.js +387 -91
- package/lib/db-declare-row-policy.js +35 -22
- package/lib/db-file-lifecycle.js +3 -2
- package/lib/db-query.js +497 -255
- package/lib/db-schema.js +209 -44
- package/lib/db.js +176 -95
- package/lib/external-db-migrate.js +229 -139
- package/lib/external-db.js +25 -15
- package/lib/framework-error.js +11 -0
- package/lib/framework-files.js +73 -0
- package/lib/framework-schema.js +695 -394
- package/lib/gate-contract.js +596 -1
- package/lib/guard-agent-registry.js +26 -44
- package/lib/guard-all.js +1 -0
- package/lib/guard-auth.js +42 -112
- package/lib/guard-cidr.js +33 -154
- package/lib/guard-csv.js +46 -113
- package/lib/guard-domain.js +34 -157
- package/lib/guard-dsn.js +27 -43
- package/lib/guard-email.js +47 -69
- package/lib/guard-envelope.js +19 -32
- package/lib/guard-event-bus-payload.js +24 -42
- package/lib/guard-event-bus-topic.js +25 -43
- package/lib/guard-filename.js +42 -106
- package/lib/guard-graphql.js +42 -123
- package/lib/guard-html.js +53 -108
- package/lib/guard-idempotency-key.js +24 -42
- package/lib/guard-image.js +46 -103
- package/lib/guard-imap-command.js +18 -32
- package/lib/guard-jmap.js +16 -30
- package/lib/guard-json.js +38 -108
- package/lib/guard-jsonpath.js +38 -171
- package/lib/guard-jwt.js +49 -179
- package/lib/guard-list-id.js +25 -41
- package/lib/guard-list-unsubscribe.js +27 -43
- package/lib/guard-mail-compose.js +24 -42
- package/lib/guard-mail-move.js +26 -44
- package/lib/guard-mail-query.js +28 -46
- package/lib/guard-mail-reply.js +24 -42
- package/lib/guard-mail-sieve.js +24 -42
- package/lib/guard-managesieve-command.js +17 -31
- package/lib/guard-markdown.js +37 -104
- package/lib/guard-message-id.js +26 -45
- package/lib/guard-mime.js +39 -151
- package/lib/guard-oauth.js +54 -135
- package/lib/guard-pdf.js +45 -101
- package/lib/guard-pop3-command.js +21 -31
- package/lib/guard-posture-chain.js +24 -42
- package/lib/guard-regex.js +33 -107
- package/lib/guard-saga-config.js +24 -42
- package/lib/guard-shell.js +42 -172
- package/lib/guard-smtp-command.js +48 -54
- package/lib/guard-snapshot-envelope.js +24 -42
- package/lib/guard-sql.js +1491 -0
- package/lib/guard-stream-args.js +24 -43
- package/lib/guard-svg.js +47 -65
- package/lib/guard-template.js +35 -172
- package/lib/guard-tenant-id.js +26 -45
- package/lib/guard-time.js +32 -154
- package/lib/guard-trace-context.js +25 -44
- package/lib/guard-uuid.js +32 -153
- package/lib/guard-xml.js +38 -113
- package/lib/guard-yaml.js +51 -163
- package/lib/http-client.js +14 -0
- package/lib/inbox.js +120 -107
- package/lib/legal-hold.js +107 -50
- package/lib/log-stream-cloudwatch.js +47 -31
- package/lib/log-stream-otlp.js +32 -18
- package/lib/mail-crypto-smime.js +2 -6
- package/lib/mail-greylist.js +2 -6
- package/lib/mail-helo.js +2 -6
- package/lib/mail-journal.js +85 -64
- package/lib/mail-rbl.js +2 -6
- package/lib/mail-scan.js +2 -6
- package/lib/mail-spam-score.js +2 -6
- package/lib/mail-store.js +287 -154
- package/lib/middleware/fetch-metadata.js +17 -7
- package/lib/middleware/idempotency-key.js +54 -38
- package/lib/middleware/rate-limit.js +102 -32
- package/lib/middleware/security-headers.js +21 -5
- package/lib/migrations.js +108 -66
- package/lib/network-heartbeat.js +7 -0
- package/lib/nonce-store.js +31 -9
- package/lib/object-store/azure-blob-bucket-ops.js +9 -4
- package/lib/object-store/azure-blob.js +31 -3
- package/lib/object-store/sigv4.js +10 -0
- package/lib/outbox.js +136 -82
- package/lib/pqc-agent.js +44 -0
- package/lib/pubsub-cluster.js +42 -20
- package/lib/queue-local.js +202 -139
- package/lib/queue-redis.js +9 -1
- package/lib/queue-sqs.js +6 -0
- package/lib/retention.js +82 -39
- package/lib/safe-dns.js +29 -45
- package/lib/safe-ical.js +18 -33
- package/lib/safe-icap.js +27 -43
- package/lib/safe-sieve.js +21 -40
- package/lib/safe-sql.js +124 -3
- package/lib/safe-vcard.js +18 -33
- package/lib/scheduler.js +35 -12
- package/lib/seeders.js +122 -74
- package/lib/session-stores.js +42 -14
- package/lib/session.js +109 -72
- package/lib/sql.js +3885 -0
- package/lib/static.js +45 -7
- package/lib/subject.js +55 -17
- package/lib/vault/index.js +3 -2
- package/lib/vault/passphrase-ops.js +3 -2
- package/lib/vault/rotate.js +104 -64
- package/lib/vendor-data.js +2 -0
- package/lib/websocket.js +16 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/lib/sql.js
ADDED
|
@@ -0,0 +1,3885 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.sql
|
|
4
|
+
* @nav Validation
|
|
5
|
+
* @title SQL Builder
|
|
6
|
+
* @order 90
|
|
7
|
+
* @featured true
|
|
8
|
+
*
|
|
9
|
+
* @intro
|
|
10
|
+
* Chainable SQL builder that makes hand-rolled SQL impossible. Every
|
|
11
|
+
* table and column name is quoted by construction through
|
|
12
|
+
* `b.safeSql`; every value is a bound `?` placeholder, never
|
|
13
|
+
* string-interpolated. The builder emits BARE logical table names and
|
|
14
|
+
* `?` placeholders - `b.clusterStorage` rewrites bare framework tables
|
|
15
|
+
* to their cluster-prefixed names and translates `?` to `$N` for
|
|
16
|
+
* Postgres at execute time - so one query text runs unchanged against
|
|
17
|
+
* the local SQLite single-node backend and the operator-supplied
|
|
18
|
+
* external Postgres / MySQL in cluster mode.
|
|
19
|
+
*
|
|
20
|
+
* The terminal call is `.toSql()` returning `{ sql, params }`. Pass
|
|
21
|
+
* that straight to `b.clusterStorage.execute(sql, params)`. The
|
|
22
|
+
* builder never touches the database itself - it is a pure SQL-string
|
|
23
|
+
* composer, which keeps it free of the residency / sealed-column
|
|
24
|
+
* write-path concerns that `db.from(...)` (the executing query
|
|
25
|
+
* builder, `lib/db-query.js`) owns.
|
|
26
|
+
*
|
|
27
|
+
* Only `upsert` emits dialect-final syntax (Postgres / SQLite
|
|
28
|
+
* `ON CONFLICT ... DO UPDATE`, MySQL `ON DUPLICATE KEY UPDATE`); every
|
|
29
|
+
* other verb stays `?`-placeholder + double-quote and defers the
|
|
30
|
+
* dialect rewrite to `b.clusterStorage`. Joins, common-table
|
|
31
|
+
* expressions, scalar and `IN`/`EXISTS` subqueries, grouping,
|
|
32
|
+
* aggregates, and `RETURNING` are all composable. DDL builders
|
|
33
|
+
* (`createTable` / `createIndex` / `alterTable` / `dropTable`) reuse
|
|
34
|
+
* the framework's own type map so operator app-schema tables get the
|
|
35
|
+
* same quote-by-construction guarantee the framework tables get.
|
|
36
|
+
*
|
|
37
|
+
* Safety defaults are not opt-in: `update` and `delete` THROW without a
|
|
38
|
+
* `where()` unless `allowNoWhere` is set; a column-membership gate
|
|
39
|
+
* refuses unknown columns; `LIKE` auto-escapes `%` / `_` / `\` and
|
|
40
|
+
* emits the matching `ESCAPE`; raw fragments pass through `b.guardSql`
|
|
41
|
+
* (strict by default on the request path) plus the placeholder-count
|
|
42
|
+
* and embedded-literal scanners.
|
|
43
|
+
*
|
|
44
|
+
* @card
|
|
45
|
+
* Chainable SQL builder - every identifier quoted by construction, every value a bound placeholder, dialect-aware upsert.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
var safeSql = require("./safe-sql");
|
|
49
|
+
var frameworkSchema = require("./framework-schema");
|
|
50
|
+
var safeJson = require("./safe-json");
|
|
51
|
+
var safeJsonPath = require("./safe-jsonpath");
|
|
52
|
+
var lazyRequire = require("./lazy-require");
|
|
53
|
+
var C = require("./constants");
|
|
54
|
+
var { FrameworkError } = require("./framework-error");
|
|
55
|
+
|
|
56
|
+
// Output-validation bounds (enforced by _assertEmittable on every build).
|
|
57
|
+
// Two scopes: statement-level (the whole emitted text + total bind count)
|
|
58
|
+
// and column-level (each individual bound value).
|
|
59
|
+
//
|
|
60
|
+
// MAX_SQL_BYTES: a runaway/DoS ceiling on the emitted statement text - a
|
|
61
|
+
// build this large is a bug (an unbatched bulk insert, a pathological
|
|
62
|
+
// IN-list), never a legitimate single statement. MAX_BIND_PARAMS: the
|
|
63
|
+
// wire-protocol bind-parameter ceiling - Postgres + MySQL cap a statement
|
|
64
|
+
// at 65535 parameters (exceeding it is a hard driver error), and SQLite's
|
|
65
|
+
// default SQLITE_MAX_VARIABLE_NUMBER is 32766 since 3.32; catching it at
|
|
66
|
+
// build surfaces a clear builder error instead of a cryptic driver crash.
|
|
67
|
+
//
|
|
68
|
+
// MAX_PARAM_BYTES: the per-value (column-level) ceiling. A single bound
|
|
69
|
+
// value can be pathologically large WITHOUT tripping MAX_SQL_BYTES, because
|
|
70
|
+
// bound values ride the wire separately - they are not interpolated into
|
|
71
|
+
// the statement text. 64 MiB is MySQL's default max_allowed_packet, the
|
|
72
|
+
// tightest per-value wire boundary across the supported drivers; a value
|
|
73
|
+
// larger than this is a buffer-overflow-class mistake (an unintended whole-
|
|
74
|
+
// file / whole-buffer bind), never a legitimate single column.
|
|
75
|
+
var MAX_SQL_BYTES = C.BYTES.mib(4);
|
|
76
|
+
var MAX_BIND_PARAMS = 65535;
|
|
77
|
+
var MAX_PARAM_BYTES = C.BYTES.mib(64);
|
|
78
|
+
|
|
79
|
+
// b.guardSql is the residual-raw-surface guard (whereRaw / setRaw /
|
|
80
|
+
// having-raw / join-raw / on-raw). It is lazy-required so b.sql does not
|
|
81
|
+
// hard-depend on the guard at module load (the guard module composes
|
|
82
|
+
// gate-contract + db-query helpers and is loaded on first raw use), and
|
|
83
|
+
// so a circular load between the two never wedges boot. The guard is
|
|
84
|
+
// applied by DEFAULT on every raw fragment - strict on the request path
|
|
85
|
+
// - never behind a config flag (security defaults are wired in, not
|
|
86
|
+
// opt-in). Operators with a deliberately benign single-statement read
|
|
87
|
+
// fragment relax via `{ guardProfile: "balanced" }`; the structurally
|
|
88
|
+
// unambiguous refusals (stacked statements, invalid encoding) never
|
|
89
|
+
// relax regardless of profile.
|
|
90
|
+
var guardSql = lazyRequire(function () { return require("./guard-sql"); });
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* @primitive b.sql.SqlBuilderError
|
|
94
|
+
* @signature b.sql.SqlBuilderError
|
|
95
|
+
* @since 0.14.29
|
|
96
|
+
* @status stable
|
|
97
|
+
* @related b.safeSql.SafeSqlError, b.sql.select, b.sql.upsert
|
|
98
|
+
*
|
|
99
|
+
* Error thrown by every `b.sql` builder on a bad call shape - an unknown
|
|
100
|
+
* dialect, an invalid identifier, an unconditional `update`/`delete`, a
|
|
101
|
+
* placeholder-count mismatch, an empty value set, a conflicting upsert
|
|
102
|
+
* action, and so on. Extends `FrameworkError` and is always permanent:
|
|
103
|
+
* these are programming / config errors caught at SQL-composition time,
|
|
104
|
+
* well before the query reaches a driver, so retrying never makes them
|
|
105
|
+
* valid. The throw IS the security signal.
|
|
106
|
+
*
|
|
107
|
+
* Carries a stable `.code` with a `sql-builder/` prefix
|
|
108
|
+
* (`sql-builder/bad-dialect`, `sql-builder/no-where`,
|
|
109
|
+
* `sql-builder/placeholder-mismatch`, `sql-builder/empty-values`,
|
|
110
|
+
* `sql-builder/conflict-action`, `sql-builder/unknown-column`, ...) - the
|
|
111
|
+
* slash style mirrors `SafeSqlError`'s codes and stays distinct from the
|
|
112
|
+
* dot-style codes `b.guardSql` raises.
|
|
113
|
+
*
|
|
114
|
+
* @example
|
|
115
|
+
* var b = require("@blamejs/core");
|
|
116
|
+
* try {
|
|
117
|
+
* b.sql.update("users").set({ active: false }).toSql();
|
|
118
|
+
* } catch (e) {
|
|
119
|
+
* e instanceof b.sql.SqlBuilderError; // -> true
|
|
120
|
+
* e.code; // -> "sql-builder/no-where"
|
|
121
|
+
* }
|
|
122
|
+
*/
|
|
123
|
+
// Mirrors the in-file error-class convention used by sibling composition
|
|
124
|
+
// modules that subclass FrameworkError directly (safe-sql.js
|
|
125
|
+
// SafeSqlError, cluster-storage.js ClusterStorageError) rather than
|
|
126
|
+
// routing through framework-error.defineClass. An integrator who would
|
|
127
|
+
// rather register it centrally adds
|
|
128
|
+
// `defineClass("SqlBuilderError", { alwaysPermanent: true })` to
|
|
129
|
+
// framework-error.js and re-points this require; the public shape
|
|
130
|
+
// (name / code / permanent / isSqlBuilderError) is identical either way.
|
|
131
|
+
class SqlBuilderError extends FrameworkError {
|
|
132
|
+
constructor(message, code) {
|
|
133
|
+
super(message);
|
|
134
|
+
this.name = "SqlBuilderError";
|
|
135
|
+
this.code = code || "sql-builder/invalid";
|
|
136
|
+
this.permanent = true;
|
|
137
|
+
this.isSqlBuilderError = true;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function _err(message, code) {
|
|
142
|
+
return new SqlBuilderError(message, code);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ---- Dialects -------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
var DIALECTS = Object.freeze({ postgres: true, sqlite: true, mysql: true });
|
|
148
|
+
|
|
149
|
+
function _normDialect(dialect) {
|
|
150
|
+
if (dialect === undefined || dialect === null) return "sqlite";
|
|
151
|
+
if (typeof dialect !== "string" || DIALECTS[dialect] !== true) {
|
|
152
|
+
throw _err("dialect must be one of postgres | sqlite | mysql (got " +
|
|
153
|
+
JSON.stringify(dialect) + ")", "sql-builder/bad-dialect");
|
|
154
|
+
}
|
|
155
|
+
return dialect;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// MySQL quotes identifiers with backticks; Postgres + SQLite share the
|
|
159
|
+
// SQL-standard double-quote. The quoting below agrees with the framework
|
|
160
|
+
// DDL builder (framework-schema.js), which double-quotes on both backend
|
|
161
|
+
// dialects for the same casing-preservation reason.
|
|
162
|
+
//
|
|
163
|
+
// Validate then wrap an identifier in dialect quotes. The builder accepts
|
|
164
|
+
// reserved-word identifiers BY DESIGN: quoting a name is exactly what
|
|
165
|
+
// makes `from` / `select` / `count` / `key` usable as a real column or
|
|
166
|
+
// table, which is the framework's stated rationale for quote-by-
|
|
167
|
+
// construction (framework-schema.js DDL builder makes the same point).
|
|
168
|
+
// Every identifier the builder emits is quoted through the framework's
|
|
169
|
+
// single identifier primitive - b.safeSql.quoteIdentifier - with
|
|
170
|
+
// allowReserved on: quoting is exactly what makes a SQL-keyword column
|
|
171
|
+
// (e.g. `from`) safe in identifier position, and the builder admits
|
|
172
|
+
// reserved names by design. quoteIdentifier still enforces shape /
|
|
173
|
+
// length / null-byte / sqlite_-prefix rules, so nothing is weakened and
|
|
174
|
+
// the builder composes the primitive rather than reinventing quoting.
|
|
175
|
+
function _quoteId(name, dialect) {
|
|
176
|
+
return safeSql.quoteIdentifier(name, dialect, { allowReserved: true });
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ---- DDL logical-type map -------------------------------------------
|
|
180
|
+
//
|
|
181
|
+
// The framework's own DDL builder (framework-schema.js `_types`) is the
|
|
182
|
+
// single source of truth for the two column types that diverge across
|
|
183
|
+
// dialects - the integer and binary tokens. b.sql consumes that map for
|
|
184
|
+
// operator app-schema parity rather than forking it: postgres BIGINT /
|
|
185
|
+
// BYTEA, sqlite INTEGER / BLOB. _types covers postgres + sqlite only
|
|
186
|
+
// (the framework's two backend dialects); MySQL is a b.sql-only DDL
|
|
187
|
+
// target, so its divergent tokens are mapped here. JSON diverges three
|
|
188
|
+
// ways (postgres JSONB / mysql JSON / sqlite TEXT) and is handled in
|
|
189
|
+
// _ddlType; the remaining tokens (TEXT / BOOLEAN / REAL / NUMERIC /
|
|
190
|
+
// TIMESTAMP) resolve uniformly. If framework-schema later exports `_types`, this
|
|
191
|
+
// reads it directly; until then the postgres/sqlite INT/BLOB values are
|
|
192
|
+
// kept byte-identical to framework-schema._types so there is exactly one
|
|
193
|
+
// definition of each token in the shipped tree.
|
|
194
|
+
var _schemaTypes = (typeof frameworkSchema._types === "function")
|
|
195
|
+
? frameworkSchema._types
|
|
196
|
+
: function (dialect) {
|
|
197
|
+
if (dialect === "postgres") return { INT: "BIGINT", BLOB: "BYTEA" };
|
|
198
|
+
if (dialect === "sqlite") return { INT: "INTEGER", BLOB: "BLOB" };
|
|
199
|
+
throw _err("framework type map has no entry for dialect '" + dialect + "'",
|
|
200
|
+
"sql-builder/bad-dialect");
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
// Logical type vocabulary -> dialect-final SQL type token. INT/BLOB
|
|
204
|
+
// delegate to the framework map (or its MySQL extension); the rest are
|
|
205
|
+
// dialect-invariant. Callers pass a logical name (case-insensitive) OR a
|
|
206
|
+
// verbatim type string - a string the vocabulary does not recognise is
|
|
207
|
+
// emitted as-is so operators can declare a dialect-specific type the map
|
|
208
|
+
// does not enumerate (it is still placed after a quoted column name, so
|
|
209
|
+
// no identifier injection is possible).
|
|
210
|
+
function _ddlType(logical, dialect) {
|
|
211
|
+
if (typeof logical !== "string" || logical.length === 0) {
|
|
212
|
+
throw _err("column type must be a non-empty string", "sql-builder/bad-type");
|
|
213
|
+
}
|
|
214
|
+
var key = logical.toUpperCase();
|
|
215
|
+
var divergent;
|
|
216
|
+
if (key === "INT" || key === "INTEGER" || key === "BIGINT") {
|
|
217
|
+
divergent = (dialect === "mysql") ? { INT: "BIGINT" } : _schemaTypes(dialect);
|
|
218
|
+
return divergent.INT;
|
|
219
|
+
}
|
|
220
|
+
if (key === "BLOB" || key === "BYTEA" || key === "BINARY") {
|
|
221
|
+
divergent = (dialect === "mysql") ? { BLOB: "LONGBLOB" } : _schemaTypes(dialect);
|
|
222
|
+
return divergent.BLOB;
|
|
223
|
+
}
|
|
224
|
+
if (key === "TEXT" || key === "STRING") return "TEXT";
|
|
225
|
+
if (key === "BOOLEAN" || key === "BOOL") return "BOOLEAN";
|
|
226
|
+
if (key === "REAL" || key === "FLOAT" || key === "DOUBLE") return "REAL";
|
|
227
|
+
if (key === "NUMERIC" || key === "DECIMAL") return "NUMERIC";
|
|
228
|
+
if (key === "TIMESTAMP") return "TIMESTAMP";
|
|
229
|
+
if (key === "JSON") {
|
|
230
|
+
return dialect === "postgres" ? "JSONB" : (dialect === "mysql" ? "JSON" : "TEXT");
|
|
231
|
+
}
|
|
232
|
+
// Unrecognised: a verbatim dialect-specific type. Emitted as the
|
|
233
|
+
// operator wrote it (it follows a quoted identifier, so it is in type
|
|
234
|
+
// position, never identifier position).
|
|
235
|
+
return logical;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ---- Operators ------------------------------------------------------
|
|
239
|
+
//
|
|
240
|
+
// Shared with the executing query builder (lib/db-query.js ALLOWED_OPS):
|
|
241
|
+
// comparison, IS/IS NOT, LIKE/NOT LIKE, IN/NOT IN, BETWEEN, and the
|
|
242
|
+
// Postgres JSONB containment + key-existence operators. Operator-supplied
|
|
243
|
+
// op strings are validated against this allowlist so no operator token
|
|
244
|
+
// reaches the SQL except one of these exact strings.
|
|
245
|
+
var ALLOWED_OPS = Object.freeze({
|
|
246
|
+
"=": true, "!=": true, "<>": true, "<": true, "<=": true, ">": true, ">=": true,
|
|
247
|
+
"IS": true, "IS NOT": true, "LIKE": true, "NOT LIKE": true,
|
|
248
|
+
"IN": true, "NOT IN": true, "BETWEEN": true,
|
|
249
|
+
"@>": true, "?": true, "?|": true, "?&": true,
|
|
250
|
+
// sqlite FTS5 full-text match - `<fts-table-or-column> MATCH ?`. The
|
|
251
|
+
// operand (the FTS5 query expression) ALWAYS binds as a single `?`;
|
|
252
|
+
// build-gated to the sqlite dialect in _cmp (no Postgres / MySQL form).
|
|
253
|
+
"MATCH": true,
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
var JOIN_KINDS = Object.freeze({
|
|
257
|
+
INNER: "INNER JOIN", LEFT: "LEFT JOIN", RIGHT: "RIGHT JOIN",
|
|
258
|
+
FULL: "FULL JOIN", CROSS: "CROSS JOIN",
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// ---- Identifier helpers ---------------------------------------------
|
|
262
|
+
|
|
263
|
+
function _validateColumn(col) {
|
|
264
|
+
if (typeof col !== "string" || col.length === 0) {
|
|
265
|
+
throw _err("column name must be a non-empty string", "sql-builder/bad-column");
|
|
266
|
+
}
|
|
267
|
+
// Routes through safeSql so the shape / length / reserved-word /
|
|
268
|
+
// null-byte rules are the framework's single identifier policy.
|
|
269
|
+
safeSql.validateIdentifier(col, { allowReserved: true });
|
|
270
|
+
return col;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ---- Table reference ------------------------------------------------
|
|
274
|
+
//
|
|
275
|
+
// Bare DEFAULT logical names stay UNQUOTED so clusterStorage.resolveTables
|
|
276
|
+
// can rewrite them to the cluster-prefixed form (a quoted name would not
|
|
277
|
+
// match its bare-identifier scan). A custom prefix or a schema qualifier
|
|
278
|
+
// is validated + quoted at build time - an invalid identifier throws
|
|
279
|
+
// here, at config time, where the operator catches the typo at boot.
|
|
280
|
+
// Two-segment qualified names (schema.table) are the maximum.
|
|
281
|
+
function _normTableRef(name, opts) {
|
|
282
|
+
opts = opts || {};
|
|
283
|
+
if (name instanceof TableRef) return name;
|
|
284
|
+
if (typeof name !== "string" || name.length === 0) {
|
|
285
|
+
throw _err("table name must be a non-empty string", "sql-builder/bad-table");
|
|
286
|
+
}
|
|
287
|
+
var schema = opts.schema || null;
|
|
288
|
+
var table = name;
|
|
289
|
+
if (schema === null && name.indexOf(".") !== -1) {
|
|
290
|
+
var dotParts = name.split(".");
|
|
291
|
+
if (dotParts.length !== 2 || dotParts[0].length === 0 || dotParts[1].length === 0) {
|
|
292
|
+
throw _err("schema-qualified table must be exactly 'schema.table' (got '" +
|
|
293
|
+
name + "')", "sql-builder/bad-table");
|
|
294
|
+
}
|
|
295
|
+
schema = dotParts[0];
|
|
296
|
+
table = dotParts[1];
|
|
297
|
+
}
|
|
298
|
+
return new TableRef(table, {
|
|
299
|
+
schema: schema,
|
|
300
|
+
prefix: opts.prefix !== undefined ? opts.prefix : (opts.tablePrefix || null),
|
|
301
|
+
alias: opts.alias || null,
|
|
302
|
+
quoteName: opts.quoteName === true,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* @primitive b.sql.table
|
|
308
|
+
* @signature b.sql.table(name, opts?)
|
|
309
|
+
* @since 0.14.29
|
|
310
|
+
* @status stable
|
|
311
|
+
* @related b.sql.select, b.clusterStorage.resolveTables
|
|
312
|
+
*
|
|
313
|
+
* Build a table reference. A bare default logical name
|
|
314
|
+
* (`b.sql.table("audit_log")`) stays UNQUOTED in the emitted SQL so
|
|
315
|
+
* `b.clusterStorage` can rewrite it to the cluster-prefixed name. A
|
|
316
|
+
* schema qualifier (`{ schema: "public" }` or the dotted form
|
|
317
|
+
* `"public.users"`) or an operator app-table `prefix` is validated and
|
|
318
|
+
* quoted at build time - a bad identifier throws immediately. The
|
|
319
|
+
* `prefix` here is operator app-table namespacing, distinct from the
|
|
320
|
+
* framework's internal `_blamejs_` prefix; it is prepended to the table
|
|
321
|
+
* name and the whole result is quoted as one identifier. At most two
|
|
322
|
+
* segments (schema.table). An `alias` is quoted and appended for joins.
|
|
323
|
+
*
|
|
324
|
+
* @opts
|
|
325
|
+
* schema: string, // schema qualifier, quoted at build time
|
|
326
|
+
* prefix: string, // operator app-table namespace, prepended then quoted
|
|
327
|
+
* alias: string, // table alias, used to disambiguate joins
|
|
328
|
+
*
|
|
329
|
+
* @example
|
|
330
|
+
* var b = require("@blamejs/core");
|
|
331
|
+
* b.sql.table("audit_log").toString("sqlite");
|
|
332
|
+
* // -> "audit_log" (bare default - clusterStorage rewrites)
|
|
333
|
+
*
|
|
334
|
+
* b.sql.table("users", { schema: "public" }).toString("postgres");
|
|
335
|
+
* // -> '"public"."users"'
|
|
336
|
+
*
|
|
337
|
+
* b.sql.table("orders", { prefix: "shopX_" }).toString("sqlite");
|
|
338
|
+
* // -> '"shopX_orders"'
|
|
339
|
+
*/
|
|
340
|
+
function table(name, opts) {
|
|
341
|
+
return _normTableRef(name, opts);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
class TableRef {
|
|
345
|
+
constructor(name, opts) {
|
|
346
|
+
opts = opts || {};
|
|
347
|
+
if (typeof name !== "string" || name.length === 0) {
|
|
348
|
+
throw _err("table name must be a non-empty string", "sql-builder/bad-table");
|
|
349
|
+
}
|
|
350
|
+
this._schema = opts.schema || null;
|
|
351
|
+
this._prefix = opts.prefix || null;
|
|
352
|
+
this._alias = opts.alias || null;
|
|
353
|
+
// quoteName forces a bare default name to be quoted. The bare-default
|
|
354
|
+
// name normally stays UNQUOTED so b.clusterStorage's resolveTables can
|
|
355
|
+
// rewrite it to the cluster-prefixed form (a quoted name would not
|
|
356
|
+
// match its bare-identifier scan). A LOCAL-only consumer that does NO
|
|
357
|
+
// cluster rewrite (the executing query builder's single-node sqlite
|
|
358
|
+
// path) opts into quoting so a reserved-word / case-sensitive table
|
|
359
|
+
// name still emits a valid `"name"` identifier - quoting is exactly
|
|
360
|
+
// what makes a SQL-keyword table name safe in identifier position.
|
|
361
|
+
this._quoteName = opts.quoteName === true;
|
|
362
|
+
// A custom prefix is validated as an identifier and prepended; the
|
|
363
|
+
// combined name is then a single quoted identifier. The bare default
|
|
364
|
+
// (no prefix, no schema) stays unquoted for clusterStorage.
|
|
365
|
+
if (this._prefix !== null) {
|
|
366
|
+
_validateColumn(this._prefix);
|
|
367
|
+
this._name = this._prefix + name;
|
|
368
|
+
this._bare = false;
|
|
369
|
+
} else {
|
|
370
|
+
this._name = name;
|
|
371
|
+
this._bare = this._schema === null && !this._quoteName;
|
|
372
|
+
}
|
|
373
|
+
if (this._schema !== null) safeSql.validateIdentifier(this._schema, { allowReserved: true });
|
|
374
|
+
if (this._alias !== null) safeSql.validateIdentifier(this._alias, { allowReserved: true });
|
|
375
|
+
// Validate the base name shape even for the bare default - an
|
|
376
|
+
// attacker-influenced logical name still must be a real identifier.
|
|
377
|
+
safeSql.validateIdentifier(this._name, { allowReserved: true });
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// The reference as it appears in FROM / INTO / UPDATE / JOIN. Bare
|
|
381
|
+
// default names stay unquoted (clusterStorage rewrite target); custom
|
|
382
|
+
// / schema-qualified names are quoted. Alias is never part of the
|
|
383
|
+
// resolution target - added separately where an alias is legal.
|
|
384
|
+
ref(dialect) {
|
|
385
|
+
if (this._schema !== null) {
|
|
386
|
+
return _quoteId(this._schema, dialect) + "." + _quoteId(this._name, dialect);
|
|
387
|
+
}
|
|
388
|
+
if (this._bare) return this._name;
|
|
389
|
+
return _quoteId(this._name, dialect);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// ref() plus a quoted alias, for FROM / JOIN where an alias is legal.
|
|
393
|
+
refWithAlias(dialect) {
|
|
394
|
+
var base = this.ref(dialect);
|
|
395
|
+
return this._alias !== null ? base + " " + _quoteId(this._alias, dialect) : base;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// The identifier columns are qualified against - the alias when set,
|
|
399
|
+
// else the resolution target.
|
|
400
|
+
qualifier(dialect) {
|
|
401
|
+
if (this._alias !== null) return _quoteId(this._alias, dialect);
|
|
402
|
+
return this.ref(dialect);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
toString(dialect) {
|
|
406
|
+
return this.refWithAlias(_normDialect(dialect));
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// ---- Allowlisted SQL function literals ------------------------------
|
|
411
|
+
//
|
|
412
|
+
// A small set of nullary, side-effect-free SQL function tokens an operator
|
|
413
|
+
// commonly wants in INSERT VALUES / SET RHS (a server-side timestamp) but
|
|
414
|
+
// which CANNOT be a bound `?` parameter (a `?` binds a value; these emit a
|
|
415
|
+
// keyword the engine evaluates). Rather than open a raw-fragment hole on
|
|
416
|
+
// the values path, b.sql.fn(name) wraps EXACTLY one of these allowlisted
|
|
417
|
+
// tokens - an unknown name throws, so no arbitrary expression reaches a
|
|
418
|
+
// VALUES / SET position. The token is dialect-checked at emit (NOW() is
|
|
419
|
+
// Postgres / MySQL; CURRENT_TIMESTAMP is portable). It is NOT a value -
|
|
420
|
+
// it consumes no `?` and contributes no param.
|
|
421
|
+
var SQL_FUNCTIONS = Object.freeze({
|
|
422
|
+
"NOW": { sql: "NOW()", dialects: { postgres: true, mysql: true } },
|
|
423
|
+
"CURRENT_TIMESTAMP": { sql: "CURRENT_TIMESTAMP", dialects: { postgres: true, sqlite: true, mysql: true } },
|
|
424
|
+
"CURRENT_DATE": { sql: "CURRENT_DATE", dialects: { postgres: true, sqlite: true, mysql: true } },
|
|
425
|
+
"CURRENT_TIME": { sql: "CURRENT_TIME", dialects: { postgres: true, sqlite: true, mysql: true } },
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
class SqlFunction {
|
|
429
|
+
constructor(name) {
|
|
430
|
+
if (typeof name !== "string") {
|
|
431
|
+
throw _err("b.sql.fn(name): name must be a string", "sql-builder/bad-fn");
|
|
432
|
+
}
|
|
433
|
+
var key = name.toUpperCase();
|
|
434
|
+
if (SQL_FUNCTIONS[key] === undefined) {
|
|
435
|
+
throw _err("b.sql.fn(name): '" + name + "' is not an allowlisted SQL function " +
|
|
436
|
+
"(NOW / CURRENT_TIMESTAMP / CURRENT_DATE / CURRENT_TIME); a bound value uses a ? " +
|
|
437
|
+
"placeholder, an arbitrary expression uses a guarded raw fragment", "sql-builder/bad-fn");
|
|
438
|
+
}
|
|
439
|
+
this._key = key;
|
|
440
|
+
this.isSqlFunction = true;
|
|
441
|
+
}
|
|
442
|
+
// The SQL token for the builder's dialect; throws when the function is
|
|
443
|
+
// not available on that backend (NOW() on sqlite has no portable form).
|
|
444
|
+
toSqlToken(dialect) {
|
|
445
|
+
var def = SQL_FUNCTIONS[this._key];
|
|
446
|
+
if (def.dialects[dialect] !== true) {
|
|
447
|
+
throw _err("b.sql.fn('" + this._key + "') is not available on " + dialect +
|
|
448
|
+
" (use CURRENT_TIMESTAMP for a portable server timestamp)", "sql-builder/fn-unsupported");
|
|
449
|
+
}
|
|
450
|
+
return def.sql;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* @primitive b.sql.fn
|
|
456
|
+
* @signature b.sql.fn(name)
|
|
457
|
+
* @since 0.15.0
|
|
458
|
+
* @status stable
|
|
459
|
+
* @related b.sql.insert, b.sql.update, b.sql.cast
|
|
460
|
+
*
|
|
461
|
+
* Wrap an allowlisted, nullary, side-effect-free SQL function token for use
|
|
462
|
+
* as an INSERT `values()` / UPDATE `set()` right-hand side - a value
|
|
463
|
+
* position that must emit a keyword the engine evaluates server-side (a
|
|
464
|
+
* `NOW()` timestamp) rather than a bound `?` parameter. The allowlist is
|
|
465
|
+
* exactly `NOW` / `CURRENT_TIMESTAMP` / `CURRENT_DATE` / `CURRENT_TIME`; an
|
|
466
|
+
* unknown name throws, so no arbitrary expression reaches a VALUES / SET
|
|
467
|
+
* position. The token is dialect-checked at emit (`NOW()` is Postgres /
|
|
468
|
+
* MySQL; `CURRENT_TIMESTAMP` is portable). The wrapped function consumes
|
|
469
|
+
* no `?` and contributes no param.
|
|
470
|
+
*
|
|
471
|
+
* @example
|
|
472
|
+
* var b = require("@blamejs/core");
|
|
473
|
+
* b.sql.insert("events")
|
|
474
|
+
* .values({ topic: "x", at: b.sql.fn("CURRENT_TIMESTAMP") })
|
|
475
|
+
* .toSql();
|
|
476
|
+
* // -> { sql: 'INSERT INTO events ("topic", "at") VALUES (?, CURRENT_TIMESTAMP)',
|
|
477
|
+
* // params: ["x"] }
|
|
478
|
+
*/
|
|
479
|
+
function fn(name) { return new SqlFunction(name); }
|
|
480
|
+
|
|
481
|
+
// ---- Allowlisted column casts ---------------------------------------
|
|
482
|
+
//
|
|
483
|
+
// A `col::type` / `?::type` cast applies an allowlisted target type to a
|
|
484
|
+
// quoted column or a bound `?` placeholder. The cast TYPE is matched
|
|
485
|
+
// against a fixed vocabulary (no operator-supplied type token reaches the
|
|
486
|
+
// SQL), and the LHS is either a quoted identifier or a single bound
|
|
487
|
+
// placeholder - never raw text. Postgres `::` is the canonical form; the
|
|
488
|
+
// same vocabulary maps to a portable form where one exists (jsonb -> json
|
|
489
|
+
// on a non-Postgres backend that has it; interval has no portable cast and
|
|
490
|
+
// is Postgres-only).
|
|
491
|
+
var CAST_TYPES = Object.freeze({
|
|
492
|
+
"jsonb": { postgres: "jsonb", mysql: "json", sqlite: null },
|
|
493
|
+
"json": { postgres: "json", mysql: "json", sqlite: null },
|
|
494
|
+
"interval": { postgres: "interval", mysql: null, sqlite: null },
|
|
495
|
+
"uuid": { postgres: "uuid", mysql: null, sqlite: null },
|
|
496
|
+
"text": { postgres: "text", mysql: "char", sqlite: "text" },
|
|
497
|
+
"int": { postgres: "integer", mysql: "signed", sqlite: "integer" },
|
|
498
|
+
"bigint": { postgres: "bigint", mysql: "signed", sqlite: "integer" },
|
|
499
|
+
"timestamptz": { postgres: "timestamptz", mysql: null, sqlite: null },
|
|
500
|
+
"boolean": { postgres: "boolean", mysql: null, sqlite: null },
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
function _castType(type, dialect) {
|
|
504
|
+
if (typeof type !== "string" || type.length === 0) {
|
|
505
|
+
throw _err("cast type must be a non-empty string", "sql-builder/bad-cast");
|
|
506
|
+
}
|
|
507
|
+
var key = type.toLowerCase();
|
|
508
|
+
if (CAST_TYPES[key] === undefined) {
|
|
509
|
+
throw _err("cast type '" + type + "' is not on the allowlist (jsonb / json / " +
|
|
510
|
+
"interval / uuid / text / int / bigint / timestamptz / boolean)", "sql-builder/bad-cast");
|
|
511
|
+
}
|
|
512
|
+
var target = CAST_TYPES[key][dialect];
|
|
513
|
+
if (target === null || target === undefined) {
|
|
514
|
+
throw _err("cast to '" + type + "' has no portable form on " + dialect +
|
|
515
|
+
" (it is Postgres-only)", "sql-builder/cast-unsupported");
|
|
516
|
+
}
|
|
517
|
+
return target;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Render the dialect-correct cast suffix for a bound `?` placeholder or a
|
|
521
|
+
// quoted column. Postgres uses the `::type` operator; MySQL has no `::`,
|
|
522
|
+
// so a cast there wraps in CAST(<lhs> AS <type>). SQLite is weakly typed -
|
|
523
|
+
// the small set of casts portable to sqlite (text / int) emit
|
|
524
|
+
// CAST(<lhs> AS <type>) too; a sqlite-unsupported cast already threw in
|
|
525
|
+
// _castType.
|
|
526
|
+
function _renderCast(lhs, type, dialect) {
|
|
527
|
+
var target = _castType(type, dialect);
|
|
528
|
+
if (dialect === "postgres") return lhs + "::" + target;
|
|
529
|
+
return "CAST(" + lhs + " AS " + target + ")";
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// A value wrapped for binding-with-cast: the value binds as a single `?`
|
|
533
|
+
// and the placeholder carries the dialect cast (`?::jsonb` on Postgres).
|
|
534
|
+
class CastValue {
|
|
535
|
+
constructor(value, type) {
|
|
536
|
+
// Eager allowlist-membership check so a typo'd type token fails at the
|
|
537
|
+
// call site (the entry-point THROW tier), not deep inside a later
|
|
538
|
+
// toSql(). The dialect-portability check (interval / uuid are
|
|
539
|
+
// Postgres-only) stays at render time, where the target dialect is known.
|
|
540
|
+
if (typeof type !== "string" || type.length === 0) {
|
|
541
|
+
throw _err("cast type must be a non-empty string", "sql-builder/bad-cast");
|
|
542
|
+
}
|
|
543
|
+
if (CAST_TYPES[type.toLowerCase()] === undefined) {
|
|
544
|
+
throw _err("cast type '" + type + "' is not on the allowlist (jsonb / json / " +
|
|
545
|
+
"interval / uuid / text / int / bigint / timestamptz / boolean)", "sql-builder/bad-cast");
|
|
546
|
+
}
|
|
547
|
+
this.value = value;
|
|
548
|
+
this.type = type;
|
|
549
|
+
this.isCastValue = true;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* @primitive b.sql.cast
|
|
555
|
+
* @signature b.sql.cast(value, type)
|
|
556
|
+
* @since 0.15.0
|
|
557
|
+
* @status stable
|
|
558
|
+
* @related b.sql.insert, b.sql.update, b.sql.fn
|
|
559
|
+
*
|
|
560
|
+
* Wrap a value so it binds as a single `?` placeholder carrying a
|
|
561
|
+
* dialect-correct cast - `?::jsonb` on Postgres, `CAST(? AS json)` on
|
|
562
|
+
* MySQL. The cast TYPE is matched against a fixed allowlist (`jsonb` /
|
|
563
|
+
* `json` / `interval` / `uuid` / `text` / `int` / `bigint` / `timestamptz`
|
|
564
|
+
* / `boolean`); an unknown type, or one with no portable form on the
|
|
565
|
+
* target dialect (`interval` / `uuid` are Postgres-only), throws at build.
|
|
566
|
+
* Use it for an INSERT `values()` / UPDATE `set()` cell that must coerce a
|
|
567
|
+
* bound string into a typed column (a JSON string into a `jsonb` column, a
|
|
568
|
+
* duration string into an `interval`).
|
|
569
|
+
*
|
|
570
|
+
* @example
|
|
571
|
+
* var b = require("@blamejs/core");
|
|
572
|
+
* b.sql.insert("docs", { dialect: "postgres" })
|
|
573
|
+
* .values({ id: 1, meta: b.sql.cast('{"a":1}', "jsonb") })
|
|
574
|
+
* .toSql();
|
|
575
|
+
* // -> { sql: 'INSERT INTO docs ("id", "meta") VALUES (?, ?::jsonb)',
|
|
576
|
+
* // params: [1, '{"a":1}'] }
|
|
577
|
+
*/
|
|
578
|
+
function cast(value, type) { return new CastValue(value, type); }
|
|
579
|
+
|
|
580
|
+
// Render a single value cell for an INSERT VALUES / UPDATE SET RHS.
|
|
581
|
+
// Returns { sql, params } where sql is `?` for a bound value, `?::type`
|
|
582
|
+
// for a CastValue, or the dialect function token for a SqlFunction (no
|
|
583
|
+
// param). The single choke-point both insert and update value paths use,
|
|
584
|
+
// so the allowlisted-function / cast handling lives in one place.
|
|
585
|
+
function _renderValueCell(value, dialect) {
|
|
586
|
+
if (value instanceof SqlFunction) {
|
|
587
|
+
return { sql: value.toSqlToken(dialect), params: [] };
|
|
588
|
+
}
|
|
589
|
+
if (value instanceof CastValue) {
|
|
590
|
+
return { sql: _renderCast("?", value.type, dialect), params: [value.value] };
|
|
591
|
+
}
|
|
592
|
+
return { sql: "?", params: [value] };
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// ---- Value-binding helpers ------------------------------------------
|
|
596
|
+
//
|
|
597
|
+
// Every value is pushed to a params array and represented in the SQL by
|
|
598
|
+
// a `?`. A b.sql builder used as a subquery contributes its placeholders
|
|
599
|
+
// in left-to-right order, so a params array concatenation is always
|
|
600
|
+
// correct as long as fragments are appended in emission order.
|
|
601
|
+
|
|
602
|
+
// A column reference qualified column expression. Accepts "col" or
|
|
603
|
+
// "alias.col" / "table.col" - both segments validated + quoted.
|
|
604
|
+
function _qualifiedColumn(expr, dialect) {
|
|
605
|
+
if (typeof expr !== "string" || expr.length === 0) {
|
|
606
|
+
throw _err("column expression must be a non-empty string", "sql-builder/bad-column");
|
|
607
|
+
}
|
|
608
|
+
if (expr.indexOf(".") !== -1) {
|
|
609
|
+
var parts = expr.split(".");
|
|
610
|
+
if (parts.length !== 2 || parts[0].length === 0 || parts[1].length === 0) {
|
|
611
|
+
throw _err("qualified column must be 'qualifier.column' (got '" + expr + "')",
|
|
612
|
+
"sql-builder/bad-column");
|
|
613
|
+
}
|
|
614
|
+
safeSql.validateIdentifier(parts[0], { allowReserved: true });
|
|
615
|
+
safeSql.validateIdentifier(parts[1], { allowReserved: true });
|
|
616
|
+
return _quoteId(parts[0], dialect) + "." + _quoteId(parts[1], dialect);
|
|
617
|
+
}
|
|
618
|
+
_validateColumn(expr);
|
|
619
|
+
return _quoteId(expr, dialect);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// LIKE auto-escape. Escapes %, _, and the escape char itself in an
|
|
623
|
+
// operator-supplied LIKE value so a stray % can't widen the match into a
|
|
624
|
+
// full-table disclosure. The escape char is `~`, NOT backslash: MySQL with
|
|
625
|
+
// the default sql_mode treats backslash as a string-literal escape, so
|
|
626
|
+
// `ESCAPE '\'` reads as an unterminated literal and parse-errors - `~` is
|
|
627
|
+
// parser-mode-independent across SQLite / Postgres / MySQL. Mirrors
|
|
628
|
+
// db-query.js's LIKE handling.
|
|
629
|
+
function _escapeLike(value) {
|
|
630
|
+
return String(value).replace(/[~%_]/g, "~$&");
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// Compose a sub-builder into a parent statement. The builder quotes
|
|
634
|
+
// identifiers eagerly (at the columns() / where() call), so a sub built with
|
|
635
|
+
// a different dialect than the parent has already baked in the wrong quote
|
|
636
|
+
// char - splicing it would emit mixed quoting the wrong backend mis-reads
|
|
637
|
+
// (a default-sqlite sub's "id" inside a mysql parent, or a mysql sub's `id`
|
|
638
|
+
// inside a postgres parent). Refuse the mismatch loudly at build rather than
|
|
639
|
+
// ship a corrupt statement; the operator builds the sub with the matching
|
|
640
|
+
// { dialect } so the whole statement is one dialect. A sub left at the
|
|
641
|
+
// default (sqlite) composes cleanly into a default (sqlite) parent.
|
|
642
|
+
function _composeSub(subBuilder, parentDialect) {
|
|
643
|
+
if (subBuilder._dialect !== parentDialect) {
|
|
644
|
+
throw _err("sub-query dialect '" + subBuilder._dialect + "' does not match the " +
|
|
645
|
+
"parent statement's dialect '" + parentDialect + "' - build the composed " +
|
|
646
|
+
"sub-query with { dialect: '" + parentDialect + "' } so the whole statement " +
|
|
647
|
+
"is one dialect", "sql-builder/dialect-mismatch");
|
|
648
|
+
}
|
|
649
|
+
return subBuilder.toSql();
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// The Postgres JSONB operators. Two shared dialect-design gates compose
|
|
653
|
+
// over this set so every emission site (the value-comparison _cmp path,
|
|
654
|
+
// scalar-subquery comparison, join-ON) enforces the same rule.
|
|
655
|
+
var JSONB_OPS = Object.freeze({ "@>": true, "?": true, "?|": true, "?&": true });
|
|
656
|
+
|
|
657
|
+
// Build-time refusal: a JSONB operator on a non-Postgres builder would emit
|
|
658
|
+
// jsonb_exists* / @> to a backend that has neither (downstream regression).
|
|
659
|
+
function _assertJsonbDialect(op, dialect) {
|
|
660
|
+
if (JSONB_OPS[op] === true && dialect !== "postgres") {
|
|
661
|
+
throw _err("the '" + op + "' JSONB operator is Postgres-only (no portable " +
|
|
662
|
+
"SQLite / MySQL equivalent); build this query with { dialect: 'postgres' }",
|
|
663
|
+
"sql-builder/jsonb-postgres-only");
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// Build-time refusal: a JSONB operator in a position that has no
|
|
668
|
+
// jsonb_exists* rewrite (scalar-subquery comparison, join-ON) - the bare
|
|
669
|
+
// operator would splice in and the wrong backend mis-reads it (and a bare
|
|
670
|
+
// `?` collides with the placeholder marker even on Postgres).
|
|
671
|
+
function _refuseJsonbOp(op, position) {
|
|
672
|
+
if (JSONB_OPS[op] === true) {
|
|
673
|
+
throw _err("the '" + op + "' JSONB operator is not supported in " + position +
|
|
674
|
+
"; use where(col, '" + op + "', value) on a Postgres builder",
|
|
675
|
+
"sql-builder/jsonb-bad-position");
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// ---- Condition tree (WHERE / HAVING / JOIN-ON) ----------------------
|
|
680
|
+
//
|
|
681
|
+
// A predicate group is an ordered list of leaves joined by AND / OR.
|
|
682
|
+
// Each leaf carries its own SQL fragment + params; nesting is a leaf
|
|
683
|
+
// whose fragment is a parenthesised sub-group. This is the structure
|
|
684
|
+
// every where/having/on clause and every whereGroup closure builds.
|
|
685
|
+
|
|
686
|
+
class Predicate {
|
|
687
|
+
constructor(owner, joinerDefault) {
|
|
688
|
+
this._owner = owner; // the builder, for the column gate
|
|
689
|
+
this._joiner = joinerDefault || "AND";
|
|
690
|
+
this._parts = []; // [{ joiner, sql, params }]
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
_gate(col) {
|
|
694
|
+
if (this._owner && typeof this._owner._assertColumnMember === "function") {
|
|
695
|
+
this._owner._assertColumnMember(col, "where");
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
_dialect() {
|
|
700
|
+
return this._owner ? this._owner._dialect : "sqlite";
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
_add(joiner, sql, params) {
|
|
704
|
+
this._parts.push({ joiner: joiner, sql: sql, params: params || [] });
|
|
705
|
+
return this;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// Core comparison. op validated against ALLOWED_OPS; the JSONB key-
|
|
709
|
+
// existence operators emit the jsonb_exists* function family (see below).
|
|
710
|
+
_cmp(joiner, col, op, value) {
|
|
711
|
+
if (ALLOWED_OPS[op] !== true) {
|
|
712
|
+
throw _err("invalid where operator '" + op + "'", "sql-builder/bad-operator");
|
|
713
|
+
}
|
|
714
|
+
this._gate(col);
|
|
715
|
+
var dialect = this._dialect();
|
|
716
|
+
var qc = _qualifiedColumn(col, dialect);
|
|
717
|
+
|
|
718
|
+
// Dialect-design gate: the JSONB containment (@>) + key-existence (?, ?|,
|
|
719
|
+
// ?&) operators are Postgres-only - the JSONB type, the jsonb_exists*
|
|
720
|
+
// functions, and @> containment have no portable SQLite / MySQL form.
|
|
721
|
+
// Emitting them for a non-Postgres backend silently regresses downstream
|
|
722
|
+
// (no such function / unknown operator at execute), so refuse at build.
|
|
723
|
+
_assertJsonbDialect(op, dialect);
|
|
724
|
+
|
|
725
|
+
// JSONB / JSON-path injection guard + placeholder-safe emission
|
|
726
|
+
// (inherited + hardened from the executing query builder). The Postgres
|
|
727
|
+
// JSONB containment (@>) and key-existence (?, ?|, ?&) operators take an
|
|
728
|
+
// operator-supplied operand the engine compares verbatim; route it
|
|
729
|
+
// through safeJsonPath so NUL / control / bidi / zero-width characters a
|
|
730
|
+
// driver might silently strip can't smuggle into the JSON-shape compare.
|
|
731
|
+
//
|
|
732
|
+
// The key-existence operators are emitted as the jsonb_exists* FUNCTION
|
|
733
|
+
// family, not the literal `?` / `?|` / `?&` operator: a literal `?`
|
|
734
|
+
// collides with the `?` bind-placeholder marker, so placeholderize would
|
|
735
|
+
// rewrite the operator itself to `$N` and corrupt the query. The operand
|
|
736
|
+
// always binds via a single `?` placeholder.
|
|
737
|
+
if (op === "@>") {
|
|
738
|
+
if (typeof value === "string") {
|
|
739
|
+
var parsedContainment;
|
|
740
|
+
try { parsedContainment = safeJson.parse(value); }
|
|
741
|
+
catch (e) {
|
|
742
|
+
throw _err("where '@>' value: invalid JSON string: " + ((e && e.message) || String(e)),
|
|
743
|
+
"sql-builder/bad-jsonb-value");
|
|
744
|
+
}
|
|
745
|
+
safeJsonPath.validateContainment(parsedContainment);
|
|
746
|
+
} else {
|
|
747
|
+
safeJsonPath.validateContainment(value);
|
|
748
|
+
// Bind the canonical-shape JSON so the driver sees the bytes we
|
|
749
|
+
// just walked end-to-end.
|
|
750
|
+
value = JSON.stringify(value);
|
|
751
|
+
}
|
|
752
|
+
} else if (op === "?") {
|
|
753
|
+
if (typeof value !== "string") {
|
|
754
|
+
throw _err("where '?' requires a string key (got " + (typeof value) + ")",
|
|
755
|
+
"sql-builder/bad-jsonb-key");
|
|
756
|
+
}
|
|
757
|
+
safeJsonPath.validateKey(value);
|
|
758
|
+
return this._add(joiner, "jsonb_exists(" + qc + ", ?)", [value]);
|
|
759
|
+
} else if (op === "?|" || op === "?&") {
|
|
760
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
761
|
+
throw _err("'" + op + "' requires a non-empty array of keys", "sql-builder/bad-jsonb-keys");
|
|
762
|
+
}
|
|
763
|
+
for (var ki = 0; ki < value.length; ki += 1) safeJsonPath.validateKey(value[ki]);
|
|
764
|
+
var jsonbExistsFn = op === "?|" ? "jsonb_exists_any" : "jsonb_exists_all";
|
|
765
|
+
return this._add(joiner, jsonbExistsFn + "(" + qc + ", ?)", [value.slice()]);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
if (op === "IN" || op === "NOT IN") {
|
|
769
|
+
if (value instanceof Builder) {
|
|
770
|
+
var sub = _composeSub(value, this._dialect());
|
|
771
|
+
return this._add(joiner, qc + " " + op + " (" + sub.sql + ")", sub.params);
|
|
772
|
+
}
|
|
773
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
774
|
+
throw _err(op + " requires a non-empty array of values (or a subquery builder)",
|
|
775
|
+
"sql-builder/empty-in");
|
|
776
|
+
}
|
|
777
|
+
var holders = value.map(function () { return "?"; }).join(", ");
|
|
778
|
+
return this._add(joiner, qc + " " + op + " (" + holders + ")", value.slice());
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
if (op === "BETWEEN") {
|
|
782
|
+
if (!Array.isArray(value) || value.length !== 2) {
|
|
783
|
+
throw _err("BETWEEN requires a [low, high] pair", "sql-builder/bad-between");
|
|
784
|
+
}
|
|
785
|
+
return this._add(joiner, qc + " BETWEEN ? AND ?", [value[0], value[1]]);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
if ((op === "IS" || op === "IS NOT") && value === null) {
|
|
789
|
+
// IS NULL / IS NOT NULL - no placeholder, no param.
|
|
790
|
+
return this._add(joiner, qc + " " + op + " NULL", []);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
if ((op === "LIKE" || op === "NOT LIKE") && typeof value === "string") {
|
|
794
|
+
return this._add(joiner, qc + " " + op + " ? ESCAPE '~'", [_escapeLike(value)]);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// sqlite FTS5 `<fts-table-or-column> MATCH ?`. The full-text query
|
|
798
|
+
// expression ALWAYS binds as a single `?` - never interpolated - so an
|
|
799
|
+
// operator-supplied search term cannot reshape the statement. MATCH has
|
|
800
|
+
// no portable Postgres / MySQL form (Postgres uses to_tsvector @@
|
|
801
|
+
// to_tsquery; MySQL uses MATCH ... AGAINST with different grammar), so
|
|
802
|
+
// refuse a non-sqlite dialect at build, the config-time tier.
|
|
803
|
+
if (op === "MATCH") {
|
|
804
|
+
if (dialect !== "sqlite") {
|
|
805
|
+
throw _err("the MATCH full-text operator is sqlite-FTS5-only (no portable " +
|
|
806
|
+
"Postgres / MySQL form); build this query with { dialect: 'sqlite' }",
|
|
807
|
+
"sql-builder/match-sqlite-only");
|
|
808
|
+
}
|
|
809
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
810
|
+
throw _err("MATCH requires a non-empty FTS5 query string", "sql-builder/bad-match");
|
|
811
|
+
}
|
|
812
|
+
return this._add(joiner, qc + " MATCH ?", [value]);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
return this._add(joiner, qc + " " + op + " ?", [value]);
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// where(field, val) / where(field, op, val) / where({ ... }).
|
|
819
|
+
where(fieldOrObj, op, value) {
|
|
820
|
+
if (fieldOrObj && typeof fieldOrObj === "object" && !(fieldOrObj instanceof Builder)) {
|
|
821
|
+
var self = this;
|
|
822
|
+
Object.keys(fieldOrObj).forEach(function (k) { self._cmp("AND", k, "=", fieldOrObj[k]); });
|
|
823
|
+
return this;
|
|
824
|
+
}
|
|
825
|
+
if (arguments.length === 2) return this._cmp("AND", fieldOrObj, "=", op);
|
|
826
|
+
return this._cmp("AND", fieldOrObj, op, value);
|
|
827
|
+
}
|
|
828
|
+
andWhere(fieldOrObj, op, value) { return this.where(fieldOrObj, op, value); }
|
|
829
|
+
orWhere(fieldOrObj, op, value) {
|
|
830
|
+
if (fieldOrObj && typeof fieldOrObj === "object" && !(fieldOrObj instanceof Builder)) {
|
|
831
|
+
var self = this;
|
|
832
|
+
Object.keys(fieldOrObj).forEach(function (k) { self._cmp("OR", k, "=", fieldOrObj[k]); });
|
|
833
|
+
return this;
|
|
834
|
+
}
|
|
835
|
+
if (arguments.length === 2) return this._cmp("OR", fieldOrObj, "=", op);
|
|
836
|
+
return this._cmp("OR", fieldOrObj, op, value);
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
whereOp(col, op, value) { return this._cmp("AND", col, op, value); }
|
|
840
|
+
orWhereOp(col, op, value) { return this._cmp("OR", col, op, value); }
|
|
841
|
+
|
|
842
|
+
// LIKE with caller-controlled match mode. The structured LIKE in _cmp
|
|
843
|
+
// escapes the WHOLE bound value (so a `%` in the value is a literal
|
|
844
|
+
// percent, never a wildcard) - correct for an exact compare but it
|
|
845
|
+
// can't express a "contains" / "starts-with" search where the wrapping
|
|
846
|
+
// `%` MUST stay a live wildcard while the user's own `%` / `_` stay
|
|
847
|
+
// escaped. This helper bridges that: it escapes the user term's
|
|
848
|
+
// metacharacters with `~` (matching _cmp's escape char) and then adds
|
|
849
|
+
// the LIVE wrapping wildcard per mode, binding the assembled pattern.
|
|
850
|
+
// It emits the SAME `col LIKE ? ESCAPE '~'` form _cmp does - the ESCAPE
|
|
851
|
+
// literal is builder-emitted (not a raw fragment), so it is not subject
|
|
852
|
+
// to the raw-fragment guard's embedded-literal refusal. Mode is
|
|
853
|
+
// "substring" (default, `%term%`), "prefix" (`term%`), or "exact"
|
|
854
|
+
// (`term`, equivalent to a structured LIKE but spelled as a search).
|
|
855
|
+
whereLike(col, term, mode) { return this._like("AND", col, term, mode); }
|
|
856
|
+
orWhereLike(col, term, mode) { return this._like("OR", col, term, mode); }
|
|
857
|
+
_like(joiner, col, term, mode) {
|
|
858
|
+
if (typeof term !== "string") {
|
|
859
|
+
throw _err("whereLike requires a string term (got " + (typeof term) + ")",
|
|
860
|
+
"sql-builder/bad-like-term");
|
|
861
|
+
}
|
|
862
|
+
this._gate(col);
|
|
863
|
+
var qc = _qualifiedColumn(col, this._dialect());
|
|
864
|
+
var escaped = _escapeLike(term);
|
|
865
|
+
var pattern;
|
|
866
|
+
var m = mode || "substring";
|
|
867
|
+
if (m === "exact") pattern = escaped;
|
|
868
|
+
else if (m === "prefix") pattern = escaped + "%";
|
|
869
|
+
else if (m === "substring") pattern = "%" + escaped + "%";
|
|
870
|
+
else throw _err("whereLike mode must be 'substring' | 'prefix' | 'exact'",
|
|
871
|
+
"sql-builder/bad-like-mode");
|
|
872
|
+
return this._add(joiner, qc + " LIKE ? ESCAPE '~'", [pattern]);
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// sqlite FTS5 full-text MATCH. The target is the FTS virtual-table name
|
|
876
|
+
// (the recommended shape - `<fts> MATCH ?` inside an IN-subquery - since
|
|
877
|
+
// FTS5 MATCH binds to the virtual table, and an aliased / joined column
|
|
878
|
+
// ref is parsed as an ordinary column and fails) or a single FTS column.
|
|
879
|
+
// The query expression binds as one `?`. sqlite-only (enforced in _cmp);
|
|
880
|
+
// the column gate is bypassed because the target is a table identifier,
|
|
881
|
+
// not a member of the builder's declared column set.
|
|
882
|
+
whereMatch(target, expr) {
|
|
883
|
+
if (this._dialect() !== "sqlite") {
|
|
884
|
+
throw _err("whereMatch (FTS5 MATCH) is sqlite-only; build with { dialect: 'sqlite' }",
|
|
885
|
+
"sql-builder/match-sqlite-only");
|
|
886
|
+
}
|
|
887
|
+
if (typeof expr !== "string" || expr.length === 0) {
|
|
888
|
+
throw _err("whereMatch requires a non-empty FTS5 query string", "sql-builder/bad-match");
|
|
889
|
+
}
|
|
890
|
+
return this._add("AND", _qualifiedColumn(target, "sqlite") + " MATCH ?", [expr]);
|
|
891
|
+
}
|
|
892
|
+
orWhereMatch(target, expr) {
|
|
893
|
+
if (this._dialect() !== "sqlite") {
|
|
894
|
+
throw _err("orWhereMatch (FTS5 MATCH) is sqlite-only; build with { dialect: 'sqlite' }",
|
|
895
|
+
"sql-builder/match-sqlite-only");
|
|
896
|
+
}
|
|
897
|
+
if (typeof expr !== "string" || expr.length === 0) {
|
|
898
|
+
throw _err("orWhereMatch requires a non-empty FTS5 query string", "sql-builder/bad-match");
|
|
899
|
+
}
|
|
900
|
+
return this._add("OR", _qualifiedColumn(target, "sqlite") + " MATCH ?", [expr]);
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
// sqlite `<col> IN (SELECT value FROM json_each(?))`. The JSON-array
|
|
904
|
+
// STRING binds as one `?` and sqlite's table-valued json_each unrolls it
|
|
905
|
+
// to a one-column row set - the placeholder-safe way to test membership
|
|
906
|
+
// in a variable-length set without expanding to one `?` per element (and
|
|
907
|
+
// without the Postgres-only `= ANY(?)` array bind). The bound value MUST
|
|
908
|
+
// be a JSON array string (json_each errors at execute on a non-array).
|
|
909
|
+
// sqlite-only (json_each is a sqlite extension); the column is gated +
|
|
910
|
+
// quoted by construction.
|
|
911
|
+
whereInJsonEach(col, jsonArrayString) {
|
|
912
|
+
if (this._dialect() !== "sqlite") {
|
|
913
|
+
throw _err("whereInJsonEach (json_each table-valued function) is sqlite-only; " +
|
|
914
|
+
"use whereInArray on Postgres", "sql-builder/json-each-sqlite-only");
|
|
915
|
+
}
|
|
916
|
+
if (typeof jsonArrayString !== "string" || jsonArrayString.length === 0) {
|
|
917
|
+
throw _err("whereInJsonEach requires a JSON-array string", "sql-builder/bad-json-each");
|
|
918
|
+
}
|
|
919
|
+
this._gate(col);
|
|
920
|
+
var qc = _qualifiedColumn(col, "sqlite");
|
|
921
|
+
return this._add("AND", qc + " IN (SELECT value FROM json_each(?))", [jsonArrayString]);
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
whereIn(col, values) { return this._cmp("AND", col, "IN", values); }
|
|
925
|
+
whereNotIn(col, values) { return this._cmp("AND", col, "NOT IN", values); }
|
|
926
|
+
orWhereIn(col, values) { return this._cmp("OR", col, "IN", values); }
|
|
927
|
+
|
|
928
|
+
// Postgres `col = ANY(?)` - the whole array binds as ONE parameter
|
|
929
|
+
// (a single `?`), in contrast to `whereIn` which expands to one `?`
|
|
930
|
+
// per element. This is the form a Postgres driver wants for a
|
|
931
|
+
// variable-length id set without a placeholder explosion (and it
|
|
932
|
+
// stays a single bind under the 65535-param wire ceiling). On a
|
|
933
|
+
// non-Postgres dialect there is no `= ANY(array)` value form, so it
|
|
934
|
+
// degrades to the portable expanded `IN (?, ?, ...)` automatically -
|
|
935
|
+
// every element still binds, the contract is identical. The array is
|
|
936
|
+
// bound, never interpolated.
|
|
937
|
+
whereInArray(col, values) { return this._inArray("AND", col, values); }
|
|
938
|
+
orWhereInArray(col, values) { return this._inArray("OR", col, values); }
|
|
939
|
+
_inArray(joiner, col, values) {
|
|
940
|
+
if (!Array.isArray(values) || values.length === 0) {
|
|
941
|
+
throw _err("whereInArray requires a non-empty array of values", "sql-builder/empty-in");
|
|
942
|
+
}
|
|
943
|
+
this._gate(col);
|
|
944
|
+
var qc = _qualifiedColumn(col, this._dialect());
|
|
945
|
+
if (this._dialect() === "postgres") {
|
|
946
|
+
// The whole array is one bound value (one `?`); the driver expands
|
|
947
|
+
// it to a Postgres array operand at execute.
|
|
948
|
+
return this._add(joiner, qc + " = ANY(?)", [values.slice()]);
|
|
949
|
+
}
|
|
950
|
+
var holders = values.map(function () { return "?"; }).join(", ");
|
|
951
|
+
return this._add(joiner, qc + " IN (" + holders + ")", values.slice());
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
whereNull(col) { return this._cmp("AND", col, "IS", null); }
|
|
955
|
+
whereNotNull(col) { return this._cmp("AND", col, "IS NOT", null); }
|
|
956
|
+
orWhereNull(col) { return this._cmp("OR", col, "IS", null); }
|
|
957
|
+
|
|
958
|
+
whereBetween(col, low, high) { return this._cmp("AND", col, "BETWEEN", [low, high]); }
|
|
959
|
+
|
|
960
|
+
// Nested group: where(qb => qb.where(...).orWhere(...)) -> "( ... )".
|
|
961
|
+
whereGroup(closure) { return this._group("AND", closure); }
|
|
962
|
+
orWhereGroup(closure) { return this._group("OR", closure); }
|
|
963
|
+
_group(joiner, closure) {
|
|
964
|
+
if (typeof closure !== "function") {
|
|
965
|
+
throw _err("whereGroup(closure): expected a function", "sql-builder/bad-closure");
|
|
966
|
+
}
|
|
967
|
+
var sub = new Predicate(this._owner, "AND");
|
|
968
|
+
closure(sub);
|
|
969
|
+
var built = sub.build();
|
|
970
|
+
if (!built.sql) return this;
|
|
971
|
+
return this._add(joiner, "(" + built.sql + ")", built.params);
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
// Subquery EXISTS / NOT EXISTS.
|
|
975
|
+
whereExists(subBuilder) { return this._exists("AND", "EXISTS", subBuilder); }
|
|
976
|
+
whereNotExists(subBuilder) { return this._exists("AND", "NOT EXISTS", subBuilder); }
|
|
977
|
+
orWhereExists(subBuilder) { return this._exists("OR", "EXISTS", subBuilder); }
|
|
978
|
+
_exists(joiner, kw, subBuilder) {
|
|
979
|
+
if (!(subBuilder instanceof Builder)) {
|
|
980
|
+
throw _err(kw + " requires a b.sql subquery builder", "sql-builder/bad-subquery");
|
|
981
|
+
}
|
|
982
|
+
var sub = _composeSub(subBuilder, this._dialect());
|
|
983
|
+
return this._add(joiner, kw + " (" + sub.sql + ")", sub.params);
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// Scalar-subquery comparison: whereSub("col", "=", subBuilder).
|
|
987
|
+
whereSub(col, op, subBuilder) {
|
|
988
|
+
if (ALLOWED_OPS[op] !== true) {
|
|
989
|
+
throw _err("invalid where operator '" + op + "'", "sql-builder/bad-operator");
|
|
990
|
+
}
|
|
991
|
+
// JSONB operators have no jsonb_exists* rewrite in scalar-subquery
|
|
992
|
+
// position (only the value-comparison where() path emits it), so refuse
|
|
993
|
+
// them here rather than splice a bare ?/?|/?&/@> a backend mis-reads.
|
|
994
|
+
_refuseJsonbOp(op, "a scalar-subquery comparison");
|
|
995
|
+
if (!(subBuilder instanceof Builder)) {
|
|
996
|
+
throw _err("whereSub requires a b.sql subquery builder", "sql-builder/bad-subquery");
|
|
997
|
+
}
|
|
998
|
+
this._gate(col);
|
|
999
|
+
var sub = _composeSub(subBuilder, this._dialect());
|
|
1000
|
+
return this._add("AND", _qualifiedColumn(col, this._dialect()) + " " + op +
|
|
1001
|
+
" (" + sub.sql + ")", sub.params);
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
// Raw fragment, guarded by b.guardSql + the embedded-literal +
|
|
1005
|
+
// placeholder-count scanners. The fragment must be a value expression
|
|
1006
|
+
// (no statement terminator, no verb, no string literal) and every
|
|
1007
|
+
// value must be a `?` bound through params.
|
|
1008
|
+
whereRaw(sql, params, opts) { return this._raw("AND", sql, params, opts); }
|
|
1009
|
+
orWhereRaw(sql, params, opts) { return this._raw("OR", sql, params, opts); }
|
|
1010
|
+
_raw(joiner, sql, params, opts) {
|
|
1011
|
+
var checked = _checkRawFragment(sql, params, opts, "whereRaw");
|
|
1012
|
+
return this._add(joiner, "(" + checked.sql + ")", checked.params);
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
build() {
|
|
1016
|
+
if (this._parts.length === 0) return { sql: "", params: [] };
|
|
1017
|
+
var sql = this._parts[0].sql;
|
|
1018
|
+
var params = this._parts[0].params.slice();
|
|
1019
|
+
for (var i = 1; i < this._parts.length; i++) {
|
|
1020
|
+
sql += " " + this._parts[i].joiner + " " + this._parts[i].sql;
|
|
1021
|
+
for (var j = 0; j < this._parts[i].params.length; j++) params.push(this._parts[i].params[j]);
|
|
1022
|
+
}
|
|
1023
|
+
return { sql: sql, params: params };
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
get length() { return this._parts.length; }
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
// ---- Raw-fragment guard ---------------------------------------------
|
|
1030
|
+
//
|
|
1031
|
+
// The single choke-point every raw escape hatch (whereRaw / setRaw /
|
|
1032
|
+
// havingRaw / joinRaw / on-raw / conflictWhere) passes through. Three
|
|
1033
|
+
// layers, all on by default:
|
|
1034
|
+
// 1. b.guardSql.validate - RFC/CVE defense for hostile raw SQL
|
|
1035
|
+
// (stacked statements, comment-smuggling, file/exec/exfil
|
|
1036
|
+
// primitives, invalid encoding). Strict profile on the request
|
|
1037
|
+
// path; { ok:false } refuses the fragment.
|
|
1038
|
+
// 2. embedded-literal refusal - a '...' literal is the signature of
|
|
1039
|
+
// operator input concatenated into the fragment; refuse unless the
|
|
1040
|
+
// caller explicitly opts in for a static operator-controlled
|
|
1041
|
+
// literal.
|
|
1042
|
+
// 3. placeholder-count - the number of `?` must equal params.length,
|
|
1043
|
+
// so no value is silently unbound or over-supplied.
|
|
1044
|
+
function _checkRawFragment(sql, params, opts, where) {
|
|
1045
|
+
opts = opts || {};
|
|
1046
|
+
if (typeof sql !== "string" || sql.length === 0) {
|
|
1047
|
+
throw _err(where + ": sql must be a non-empty string", "sql-builder/bad-raw");
|
|
1048
|
+
}
|
|
1049
|
+
var p = Array.isArray(params) ? params.slice() : (params == null ? [] : [params]);
|
|
1050
|
+
|
|
1051
|
+
// Guard against hostile raw SQL via b.guardSql.validate - the direct
|
|
1052
|
+
// content checker that returns { ok, issues }. Default strict (request
|
|
1053
|
+
// path); a benign single-statement read fragment can relax via
|
|
1054
|
+
// guardProfile, but the structurally unambiguous classes (stacked
|
|
1055
|
+
// statements, invalid encoding) refuse under every profile. The
|
|
1056
|
+
// fragment context requires the fragment to be a value expression
|
|
1057
|
+
// (any statement terminator / verb / dangerous token refuses).
|
|
1058
|
+
// allowLiterals propagates to b.guardSql too: its own embedded-string-
|
|
1059
|
+
// literal rule honours the same opt (a static operator-controlled
|
|
1060
|
+
// literal the caller deliberately allows), so a fragment opted in here
|
|
1061
|
+
// must not be refused by the guard's literal rule while the local
|
|
1062
|
+
// _assertRawNoStringLiteral pass is skipped - the two literal checks
|
|
1063
|
+
// stay consistent. The structurally unambiguous classes (stacked
|
|
1064
|
+
// statements, invalid encoding, dangerous primitives) refuse regardless.
|
|
1065
|
+
var profile = opts.guardProfile || "strict";
|
|
1066
|
+
var g = guardSql();
|
|
1067
|
+
if (g && typeof g.validate === "function") {
|
|
1068
|
+
var result = g.validate(sql, {
|
|
1069
|
+
profile: profile, context: "fragment", allowLiterals: opts.allowLiterals === true,
|
|
1070
|
+
});
|
|
1071
|
+
if (result && result.ok === false) {
|
|
1072
|
+
var first = (result.issues && result.issues[0]) || {};
|
|
1073
|
+
throw _err(where + ": raw fragment refused by b.guardSql (" +
|
|
1074
|
+
(first.code || "policy") + (first.snippet ? ": " + first.snippet : "") + ")",
|
|
1075
|
+
"sql-builder/guard-refused");
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
// Embedded-literal + placeholder-count scan (single linear pass over
|
|
1080
|
+
// the fragment, comment / quoted-identifier aware).
|
|
1081
|
+
if (opts.allowLiterals !== true) _assertRawNoStringLiteral(sql, where);
|
|
1082
|
+
// A bare Postgres JSONB key-existence operator (?| / ?&) in a raw fragment
|
|
1083
|
+
// is corrupted by clusterStorage.placeholderize (the ? is rewritten to $N
|
|
1084
|
+
// -> "tags $1| keys"); _countPlaceholders also miscounts the ? as a bind.
|
|
1085
|
+
// Refuse it - the structured where(col, '?|', keys) path emits the
|
|
1086
|
+
// placeholder-safe jsonb_exists_any() form instead.
|
|
1087
|
+
_assertNoRawJsonbKeyOp(sql, where);
|
|
1088
|
+
var holders = _countPlaceholders(sql);
|
|
1089
|
+
if (holders !== p.length) {
|
|
1090
|
+
throw _err(where + ": " + holders + " placeholder(s) in sql but " + p.length +
|
|
1091
|
+
" param(s) supplied", "sql-builder/placeholder-mismatch");
|
|
1092
|
+
}
|
|
1093
|
+
return { sql: sql, params: p };
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
// Refuse a raw fragment that embeds a single-quoted string literal. A
|
|
1097
|
+
// '...' literal is the signature of operator input concatenated into the
|
|
1098
|
+
// builder (CWE-89). Double-quoted identifiers, line comments, and block
|
|
1099
|
+
// comments are skipped. Single linear pass, no backtracking regex. Same
|
|
1100
|
+
// shape as db-query.js's scanner.
|
|
1101
|
+
function _assertRawNoStringLiteral(sql, where) {
|
|
1102
|
+
var i = 0;
|
|
1103
|
+
var len = sql.length;
|
|
1104
|
+
while (i < len) {
|
|
1105
|
+
var ch = sql.charAt(i);
|
|
1106
|
+
var next = i + 1 < len ? sql.charAt(i + 1) : "";
|
|
1107
|
+
if (ch === '"') {
|
|
1108
|
+
i += 1;
|
|
1109
|
+
while (i < len) {
|
|
1110
|
+
if (sql.charAt(i) === '"') {
|
|
1111
|
+
if (sql.charAt(i + 1) === '"') { i += 2; continue; }
|
|
1112
|
+
i += 1; break;
|
|
1113
|
+
}
|
|
1114
|
+
i += 1;
|
|
1115
|
+
}
|
|
1116
|
+
continue;
|
|
1117
|
+
}
|
|
1118
|
+
if (ch === "-" && next === "-") {
|
|
1119
|
+
while (i < len && sql.charAt(i) !== "\n") i += 1;
|
|
1120
|
+
continue;
|
|
1121
|
+
}
|
|
1122
|
+
if (ch === "/" && next === "*") {
|
|
1123
|
+
i += 2;
|
|
1124
|
+
while (i < len && !(sql.charAt(i) === "*" && sql.charAt(i + 1) === "/")) i += 1;
|
|
1125
|
+
i += 2;
|
|
1126
|
+
continue;
|
|
1127
|
+
}
|
|
1128
|
+
if (ch === "'") {
|
|
1129
|
+
throw _err(where + ": raw SQL must not contain a string literal ('...') - bind " +
|
|
1130
|
+
"every value with a ? placeholder, or pass { allowLiterals: true } when the " +
|
|
1131
|
+
"literal is static and operator-controlled", "sql-builder/raw-literal");
|
|
1132
|
+
}
|
|
1133
|
+
i += 1;
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
// Refuse the two-char Postgres JSONB key-existence tokens (?| / ?&) in a raw
|
|
1138
|
+
// fragment. They are unambiguous (a `?` placeholder is never immediately
|
|
1139
|
+
// followed by `|` / `&`), can't be expressed safely under the ?->$N
|
|
1140
|
+
// placeholderize rewrite, and have a placeholder-safe structured form
|
|
1141
|
+
// (where(col, '?|', keys) -> jsonb_exists_any). Quote/comment-aware so a
|
|
1142
|
+
// `?|` inside an allowLiterals fragment's literal or comment is ignored.
|
|
1143
|
+
function _assertNoRawJsonbKeyOp(sql, where) {
|
|
1144
|
+
var i = 0;
|
|
1145
|
+
var len = sql.length;
|
|
1146
|
+
while (i < len) {
|
|
1147
|
+
var ch = sql.charAt(i);
|
|
1148
|
+
var next = i + 1 < len ? sql.charAt(i + 1) : "";
|
|
1149
|
+
if (ch === "'" || ch === '"' || ch === "`") {
|
|
1150
|
+
var q = ch;
|
|
1151
|
+
i += 1;
|
|
1152
|
+
while (i < len) {
|
|
1153
|
+
if (sql.charAt(i) === q) {
|
|
1154
|
+
if (sql.charAt(i + 1) === q) { i += 2; continue; }
|
|
1155
|
+
i += 1; break;
|
|
1156
|
+
}
|
|
1157
|
+
i += 1;
|
|
1158
|
+
}
|
|
1159
|
+
continue;
|
|
1160
|
+
}
|
|
1161
|
+
if (ch === "-" && next === "-") { while (i < len && sql.charAt(i) !== "\n") i += 1; continue; }
|
|
1162
|
+
if (ch === "/" && next === "*") {
|
|
1163
|
+
i += 2;
|
|
1164
|
+
while (i < len && !(sql.charAt(i) === "*" && sql.charAt(i + 1) === "/")) i += 1;
|
|
1165
|
+
i += 2;
|
|
1166
|
+
continue;
|
|
1167
|
+
}
|
|
1168
|
+
if (ch === "?" && (next === "|" || next === "&")) {
|
|
1169
|
+
throw _err(where + ": raw SQL must not contain the Postgres JSONB key-existence " +
|
|
1170
|
+
"operator '?" + next + "' (it collides with the ? bind placeholder) - use the " +
|
|
1171
|
+
"structured where(col, '?" + next + "', keys) form", "sql-builder/raw-jsonb-op");
|
|
1172
|
+
}
|
|
1173
|
+
i += 1;
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
// Placeholder counting (quote/comment-aware) is the scanner shared with the
|
|
1178
|
+
// residency write-gate; composed from safe-sql so the skip rules live in one
|
|
1179
|
+
// place. The output validator below uses it for placeholder/param parity.
|
|
1180
|
+
var _countPlaceholders = safeSql.countPlaceholders;
|
|
1181
|
+
|
|
1182
|
+
// Translate the builder's `?` placeholders to a dialect's positional form
|
|
1183
|
+
// (Postgres `$1..$N`; SQLite / MySQL keep `?`). Quote / comment-aware single
|
|
1184
|
+
// pass - a `?` inside a string literal, a quoted identifier, or a comment is
|
|
1185
|
+
// NOT a placeholder and is left untouched. This is the toExternalSql terminal
|
|
1186
|
+
// for code that hands the SQL to an operator-supplied driver directly (no
|
|
1187
|
+
// clusterStorage in the path to do the rewrite). The translator lives here -
|
|
1188
|
+
// the builder owns its driver-final output rendering - rather than reaching
|
|
1189
|
+
// into clusterStorage (which transitively requires this module).
|
|
1190
|
+
function _toPositional(sql, dialect) {
|
|
1191
|
+
if (dialect !== "postgres") return sql;
|
|
1192
|
+
var out = "";
|
|
1193
|
+
var n = 0;
|
|
1194
|
+
var i = 0;
|
|
1195
|
+
var len = sql.length;
|
|
1196
|
+
while (i < len) {
|
|
1197
|
+
var c = sql.charAt(i);
|
|
1198
|
+
var nx = i + 1 < len ? sql.charAt(i + 1) : "";
|
|
1199
|
+
if (c === "'" || c === '"' || c === "`") {
|
|
1200
|
+
out += c;
|
|
1201
|
+
i += 1;
|
|
1202
|
+
while (i < len) {
|
|
1203
|
+
var q = sql.charAt(i);
|
|
1204
|
+
if (q === c) {
|
|
1205
|
+
if (sql.charAt(i + 1) === c) { out += c + c; i += 2; continue; }
|
|
1206
|
+
out += c; i += 1; break;
|
|
1207
|
+
}
|
|
1208
|
+
out += q; i += 1;
|
|
1209
|
+
}
|
|
1210
|
+
continue;
|
|
1211
|
+
}
|
|
1212
|
+
if (c === "-" && nx === "-") {
|
|
1213
|
+
while (i < len && sql.charAt(i) !== "\n") { out += sql.charAt(i); i += 1; }
|
|
1214
|
+
continue;
|
|
1215
|
+
}
|
|
1216
|
+
if (c === "/" && nx === "*") {
|
|
1217
|
+
out += "/*"; i += 2;
|
|
1218
|
+
while (i < len && !(sql.charAt(i) === "*" && sql.charAt(i + 1) === "/")) { out += sql.charAt(i); i += 1; }
|
|
1219
|
+
if (i < len) { out += "*/"; i += 2; }
|
|
1220
|
+
continue;
|
|
1221
|
+
}
|
|
1222
|
+
if (c === "?") { n += 1; out += "$" + n; i += 1; continue; }
|
|
1223
|
+
out += c;
|
|
1224
|
+
i += 1;
|
|
1225
|
+
}
|
|
1226
|
+
return out;
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
/**
|
|
1230
|
+
* @primitive b.sql.toExternalSql
|
|
1231
|
+
* @signature b.sql.toExternalSql(builtOrBuilder, dialect)
|
|
1232
|
+
* @since 0.15.0
|
|
1233
|
+
* @status stable
|
|
1234
|
+
* @related b.sql.select, b.sql.createTable, b.clusterStorage.placeholderize
|
|
1235
|
+
*
|
|
1236
|
+
* Translate a built statement to a driver's positional placeholder form for
|
|
1237
|
+
* code that hands the SQL to an operator-supplied driver DIRECTLY (no
|
|
1238
|
+
* `b.clusterStorage` in the path to rewrite). Accepts either a chainable
|
|
1239
|
+
* builder (any `b.sql.select` / `insert` / `update` / `delete` / `upsert`,
|
|
1240
|
+
* via its own `.toExternalSql()` method) OR a plain `{ sql, params }` result
|
|
1241
|
+
* from a DDL builder (`createTable` / `createIndex` / `alterTable` /
|
|
1242
|
+
* `dropTable` / the RLS + catalog builders). Postgres gets `$1..$N`; SQLite
|
|
1243
|
+
* and MySQL keep `?`. The `?`-by-construction invariant is unchanged - only
|
|
1244
|
+
* the emitted text differs at the last step.
|
|
1245
|
+
*
|
|
1246
|
+
* @example
|
|
1247
|
+
* var b = require("@blamejs/core");
|
|
1248
|
+
* var ddl = b.sql.toExternalSql(
|
|
1249
|
+
* b.sql.createIndex("idx_pending", "outbox", ["next_attempt_at"],
|
|
1250
|
+
* { dialect: "postgres", where: "status = 'pending'" }),
|
|
1251
|
+
* "postgres");
|
|
1252
|
+
* // -> { sql: 'CREATE INDEX IF NOT EXISTS "idx_pending" ON outbox ' +
|
|
1253
|
+
* // '("next_attempt_at") WHERE status = \'pending\'', params: [] }
|
|
1254
|
+
*/
|
|
1255
|
+
function toExternalSql(builtOrBuilder, dialect) {
|
|
1256
|
+
if (builtOrBuilder instanceof Builder) return builtOrBuilder.toExternalSql(dialect);
|
|
1257
|
+
if (builtOrBuilder && typeof builtOrBuilder.sql === "string" &&
|
|
1258
|
+
Array.isArray(builtOrBuilder.params)) {
|
|
1259
|
+
var d = _normDialect(dialect);
|
|
1260
|
+
return { sql: _toPositional(builtOrBuilder.sql, d), params: builtOrBuilder.params };
|
|
1261
|
+
}
|
|
1262
|
+
throw _err("b.sql.toExternalSql expects a b.sql builder or a { sql, params } result",
|
|
1263
|
+
"sql-builder/bad-external-input");
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
// Final output gate - every verb's toSql() routes through _emit() before
|
|
1267
|
+
// returning, so a builder bug or a tainted identifier can never reach the
|
|
1268
|
+
// driver. Three invariants over the assembled statement, reusing the same
|
|
1269
|
+
// quote/comment-aware scan:
|
|
1270
|
+
// 1. placeholder <-> param parity - a `?` without its bound param (or a
|
|
1271
|
+
// param without its `?`) silently shifts values into the wrong
|
|
1272
|
+
// columns, the highest-severity builder bug.
|
|
1273
|
+
// 2. exactly one statement - no top-level `;` outside literals/comments
|
|
1274
|
+
// (a stacked statement has no place in a single built statement).
|
|
1275
|
+
// 3. balanced parentheses at the top level (structural well-formedness).
|
|
1276
|
+
// String literals only legitimately appear via whereRaw { allowLiterals }
|
|
1277
|
+
// (already gated at fragment time), so the literal check stays there.
|
|
1278
|
+
function _assertEmittable(sql, params) {
|
|
1279
|
+
// ---- shape ----
|
|
1280
|
+
// A builder bug must never emit a non-string / empty statement, and
|
|
1281
|
+
// params must be an array - a misshapen output hides a real defect
|
|
1282
|
+
// rather than failing loudly at the driver.
|
|
1283
|
+
if (typeof sql !== "string" || sql.length === 0) {
|
|
1284
|
+
throw _err("toSql: emitted SQL must be a non-empty string (builder bug)",
|
|
1285
|
+
"sql-builder/empty-sql");
|
|
1286
|
+
}
|
|
1287
|
+
if (!Array.isArray(params)) {
|
|
1288
|
+
throw _err("toSql: params must be an array (builder bug)",
|
|
1289
|
+
"sql-builder/bad-params-shape");
|
|
1290
|
+
}
|
|
1291
|
+
// ---- size ----
|
|
1292
|
+
// Runaway / DoS ceiling on the statement text. A statement this large
|
|
1293
|
+
// is a bug (an unbatched bulk write, a pathological IN-list), never a
|
|
1294
|
+
// legitimate single query.
|
|
1295
|
+
if (sql.length > MAX_SQL_BYTES) {
|
|
1296
|
+
throw _err("toSql: emitted SQL is " + sql.length + " bytes, over the " +
|
|
1297
|
+
MAX_SQL_BYTES + "-byte cap - batch the operation rather than building " +
|
|
1298
|
+
"one oversized statement", "sql-builder/sql-too-large");
|
|
1299
|
+
}
|
|
1300
|
+
// ---- text validity (boundary-escape) ----
|
|
1301
|
+
// A NUL byte truncates the statement at C-string-based drivers and can't
|
|
1302
|
+
// be stored in Postgres text; lone UTF-16 surrogates encode to invalid
|
|
1303
|
+
// UTF-8 on the wire (the CVE-2025-1094 encoding-escape class). Neither
|
|
1304
|
+
// can legitimately appear in builder-emitted SQL.
|
|
1305
|
+
if (sql.indexOf("\u0000") !== -1) {
|
|
1306
|
+
throw _err("toSql: emitted SQL contains a NUL byte - rejected " +
|
|
1307
|
+
"(statement-truncation / boundary-escape risk)", "sql-builder/null-byte-sql");
|
|
1308
|
+
}
|
|
1309
|
+
if (typeof sql.isWellFormed === "function" && !sql.isWellFormed()) {
|
|
1310
|
+
throw _err("toSql: emitted SQL contains invalid Unicode (lone " +
|
|
1311
|
+
"surrogates) - rejected (would encode to invalid UTF-8 on the wire)",
|
|
1312
|
+
"sql-builder/invalid-encoding-sql");
|
|
1313
|
+
}
|
|
1314
|
+
var n = params.length;
|
|
1315
|
+
// ---- bind-parameter ceiling ----
|
|
1316
|
+
// The wire-protocol limit (Postgres/MySQL 65535; SQLite 32766). Past it
|
|
1317
|
+
// is a hard driver error; catch it here with a clear message. The usual
|
|
1318
|
+
// cause is an unbounded IN-list / bulk insert that should be chunked.
|
|
1319
|
+
if (n > MAX_BIND_PARAMS) {
|
|
1320
|
+
throw _err("toSql: " + n + " bind parameters exceeds the " + MAX_BIND_PARAMS +
|
|
1321
|
+
"-parameter wire limit - chunk the values (batch the IN-list / rows)",
|
|
1322
|
+
"sql-builder/too-many-params");
|
|
1323
|
+
}
|
|
1324
|
+
// ---- param-element shape ----
|
|
1325
|
+
// A param that is `undefined` / a function / a symbol is a caller
|
|
1326
|
+
// mistake (a typo'd variable, a method reference passed by accident);
|
|
1327
|
+
// drivers coerce these ambiguously (undefined -> NULL, function ->
|
|
1328
|
+
// "[Function]"), silently storing the wrong value. Bind a concrete
|
|
1329
|
+
// value (string / number / boolean / null / bigint / Buffer / Date /
|
|
1330
|
+
// a JSON-serializable object). null is valid SQL NULL.
|
|
1331
|
+
for (var pi = 0; pi < n; pi += 1) {
|
|
1332
|
+
var pv = params[pi];
|
|
1333
|
+
var pt = typeof pv;
|
|
1334
|
+
if (pv === undefined || pt === "function" || pt === "symbol") {
|
|
1335
|
+
throw _err("toSql: param[" + pi + "] is " +
|
|
1336
|
+
(pv === undefined ? "undefined" : pt) + " - bind a concrete value " +
|
|
1337
|
+
"(string / number / boolean / null / bigint / Buffer / Date / object); " +
|
|
1338
|
+
"use null for SQL NULL", "sql-builder/bad-param-value");
|
|
1339
|
+
}
|
|
1340
|
+
// ---- column-level (per-value) size boundary ----
|
|
1341
|
+
// Only strings / Buffers can carry an unbounded payload; everything
|
|
1342
|
+
// else (number / boolean / bigint / Date / null) is fixed-small. A
|
|
1343
|
+
// single value over the per-value ceiling is a buffer-overflow-class
|
|
1344
|
+
// mistake (a whole file / whole buffer bound by accident), distinct
|
|
1345
|
+
// from the total-statement and total-param caps above.
|
|
1346
|
+
if (pt === "string" || Buffer.isBuffer(pv)) {
|
|
1347
|
+
var vbytes = pt === "string" ? Buffer.byteLength(pv, "utf8") : pv.length;
|
|
1348
|
+
if (vbytes > MAX_PARAM_BYTES) {
|
|
1349
|
+
throw _err("toSql: param[" + pi + "] is " + vbytes + " bytes, over the " +
|
|
1350
|
+
MAX_PARAM_BYTES + "-byte per-value ceiling - stream large blobs " +
|
|
1351
|
+
"through chunked storage rather than binding one oversized column",
|
|
1352
|
+
"sql-builder/param-too-large");
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
if (pt === "string") {
|
|
1356
|
+
// A bound string still rides the wire. A NUL byte cannot be stored
|
|
1357
|
+
// in a Postgres text column and truncates C-string-based drivers; a
|
|
1358
|
+
// lone UTF-16 surrogate encodes to invalid UTF-8 on the wire (the
|
|
1359
|
+
// text values that "jump out of boundaries"). Reject both here so a
|
|
1360
|
+
// malformed value fails loudly at build time, not as a corrupt store.
|
|
1361
|
+
if (pv.indexOf("\u0000") !== -1) {
|
|
1362
|
+
throw _err("toSql: param[" + pi + "] contains a NUL byte - rejected " +
|
|
1363
|
+
"(text-column / driver truncation, boundary-escape risk)",
|
|
1364
|
+
"sql-builder/null-byte-param");
|
|
1365
|
+
}
|
|
1366
|
+
if (typeof pv.isWellFormed === "function" && !pv.isWellFormed()) {
|
|
1367
|
+
throw _err("toSql: param[" + pi + "] contains invalid Unicode (lone " +
|
|
1368
|
+
"surrogates) - rejected (would encode to invalid UTF-8 on the wire)",
|
|
1369
|
+
"sql-builder/invalid-encoding-param");
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
// ---- placeholder <-> param parity ----
|
|
1374
|
+
var holders = _countPlaceholders(sql);
|
|
1375
|
+
if (holders !== n) {
|
|
1376
|
+
throw _err("toSql: placeholder/param count mismatch - " + holders +
|
|
1377
|
+
" '?' placeholder(s) but " + n + " param(s); emitting this would " +
|
|
1378
|
+
"misalign bound values across columns", "sql-builder/param-mismatch");
|
|
1379
|
+
}
|
|
1380
|
+
var i = 0;
|
|
1381
|
+
var len = sql.length;
|
|
1382
|
+
var depth = 0;
|
|
1383
|
+
while (i < len) {
|
|
1384
|
+
var ch = sql.charAt(i);
|
|
1385
|
+
var next = i + 1 < len ? sql.charAt(i + 1) : "";
|
|
1386
|
+
if (ch === "'" || ch === '"' || ch === "`") {
|
|
1387
|
+
// Quote context. A string literal / quoted identifier escapes its own
|
|
1388
|
+
// quote char by DOUBLING it; an UNTERMINATED quote is the signature of
|
|
1389
|
+
// a quote-jump breakout - a value or identifier whose embedded quote
|
|
1390
|
+
// escaped its context and ran to the end of the statement. (Backtick
|
|
1391
|
+
// covers MySQL identifier quoting; ' and " cover string literals and
|
|
1392
|
+
// ANSI / Postgres / SQLite identifiers.)
|
|
1393
|
+
var q = ch;
|
|
1394
|
+
var closed = false;
|
|
1395
|
+
i += 1;
|
|
1396
|
+
while (i < len) {
|
|
1397
|
+
if (sql.charAt(i) === q) {
|
|
1398
|
+
if (sql.charAt(i + 1) === q) { i += 2; continue; }
|
|
1399
|
+
i += 1; closed = true; break;
|
|
1400
|
+
}
|
|
1401
|
+
i += 1;
|
|
1402
|
+
}
|
|
1403
|
+
if (!closed) {
|
|
1404
|
+
throw _err("toSql: unterminated " +
|
|
1405
|
+
(q === "'" ? "string literal" : "quoted identifier") +
|
|
1406
|
+
" in emitted SQL - a quote escaped its context " +
|
|
1407
|
+
"(quote-jump / breakout risk)", "sql-builder/unterminated-quote");
|
|
1408
|
+
}
|
|
1409
|
+
continue;
|
|
1410
|
+
}
|
|
1411
|
+
if (ch === "-" && next === "-") { while (i < len && sql.charAt(i) !== "\n") i += 1; continue; }
|
|
1412
|
+
if (ch === "/" && next === "*") {
|
|
1413
|
+
i += 2;
|
|
1414
|
+
while (i < len && !(sql.charAt(i) === "*" && sql.charAt(i + 1) === "/")) i += 1;
|
|
1415
|
+
i += 2;
|
|
1416
|
+
continue;
|
|
1417
|
+
}
|
|
1418
|
+
if (ch === "(") { depth += 1; }
|
|
1419
|
+
else if (ch === ")") { depth -= 1; }
|
|
1420
|
+
else if (ch === ";") {
|
|
1421
|
+
throw _err("toSql: builder emitted a top-level ';' - exactly one " +
|
|
1422
|
+
"statement per build; a stacked statement is never valid output",
|
|
1423
|
+
"sql-builder/stacked-statement");
|
|
1424
|
+
}
|
|
1425
|
+
i += 1;
|
|
1426
|
+
}
|
|
1427
|
+
if (depth !== 0) {
|
|
1428
|
+
throw _err("toSql: unbalanced parentheses in emitted SQL (builder bug)",
|
|
1429
|
+
"sql-builder/unbalanced");
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
// Terminal wrapper: validate then return the { sql, params } shape every
|
|
1434
|
+
// verb's toSql() emits.
|
|
1435
|
+
function _emit(sql, params) {
|
|
1436
|
+
_assertEmittable(sql, params);
|
|
1437
|
+
return { sql: sql, params: params };
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
// ---- WITH (CTE) -----------------------------------------------------
|
|
1441
|
+
//
|
|
1442
|
+
// A CTE is a name + a subquery (a b.sql Builder whose toSql() composes,
|
|
1443
|
+
// params concatenated in CTE order before the main statement's params)
|
|
1444
|
+
// OR a guarded raw fragment. A statement carries an ordered list of
|
|
1445
|
+
// CTEs; withRecursive marks the WITH clause RECURSIVE.
|
|
1446
|
+
|
|
1447
|
+
function _cteFragment(cte, dialect) {
|
|
1448
|
+
var name = _quoteId(cte.name, dialect);
|
|
1449
|
+
if (cte.builder instanceof Builder) {
|
|
1450
|
+
// Render the CTE body under the OUTER statement's dialect, not the
|
|
1451
|
+
// sub-builder's own (default sqlite), so the name + body quote
|
|
1452
|
+
// consistently in one statement.
|
|
1453
|
+
var sub = _composeSub(cte.builder, dialect);
|
|
1454
|
+
return { sql: name + " AS (" + sub.sql + ")", params: sub.params };
|
|
1455
|
+
}
|
|
1456
|
+
// Raw CTE body - guarded like any raw fragment but allowed to be a
|
|
1457
|
+
// full SELECT/INSERT/UPDATE/DELETE statement (migration context).
|
|
1458
|
+
var checked = _checkRawFragment(cte.sql, cte.params, { guardProfile: cte.guardProfile || "balanced" },
|
|
1459
|
+
"with");
|
|
1460
|
+
return { sql: name + " AS (" + checked.sql + ")", params: checked.params };
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
function _renderWith(ctes, recursive, dialect) {
|
|
1464
|
+
if (!ctes || ctes.length === 0) return { sql: "", params: [] };
|
|
1465
|
+
var fragments = [];
|
|
1466
|
+
var params = [];
|
|
1467
|
+
for (var i = 0; i < ctes.length; i++) {
|
|
1468
|
+
var f = _cteFragment(ctes[i], dialect);
|
|
1469
|
+
fragments.push(f.sql);
|
|
1470
|
+
for (var j = 0; j < f.params.length; j++) params.push(f.params[j]);
|
|
1471
|
+
}
|
|
1472
|
+
return {
|
|
1473
|
+
sql: "WITH " + (recursive ? "RECURSIVE " : "") + fragments.join(", ") + " ",
|
|
1474
|
+
params: params,
|
|
1475
|
+
};
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
// ---- Base Builder ---------------------------------------------------
|
|
1479
|
+
//
|
|
1480
|
+
// Holds the shared dialect, table, CTE list, and column-membership gate.
|
|
1481
|
+
// Each verb is a subclass with its own clause set + toSql().
|
|
1482
|
+
|
|
1483
|
+
class Builder {
|
|
1484
|
+
constructor(verb, tableNameOrRef, opts) {
|
|
1485
|
+
opts = opts || {};
|
|
1486
|
+
this._verb = verb;
|
|
1487
|
+
this._dialect = _normDialect(opts.dialect);
|
|
1488
|
+
this._table = _normTableRef(tableNameOrRef, opts);
|
|
1489
|
+
this._ctes = [];
|
|
1490
|
+
this._cteRecursive = false;
|
|
1491
|
+
|
|
1492
|
+
// Column-membership gate. When the operator declares allowedColumns
|
|
1493
|
+
// (or a schema-declared set), an unknown column is refused before it
|
|
1494
|
+
// interpolates as an identifier (ORDER-BY / disclosure injection).
|
|
1495
|
+
this._allowedColumns = null;
|
|
1496
|
+
if (opts.allowedColumns) {
|
|
1497
|
+
if (!Array.isArray(opts.allowedColumns) || opts.allowedColumns.length === 0) {
|
|
1498
|
+
throw _err("allowedColumns must be a non-empty array", "sql-builder/bad-allowed-columns");
|
|
1499
|
+
}
|
|
1500
|
+
opts.allowedColumns.forEach(_validateColumn);
|
|
1501
|
+
this._allowedColumns = new Set(opts.allowedColumns);
|
|
1502
|
+
}
|
|
1503
|
+
this._columnGateMode = opts.columnGateMode || (this._allowedColumns ? "reject" : "off");
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
// Restrict columns to an explicit subset (chainable form of the opt).
|
|
1507
|
+
allowedColumns(cols) {
|
|
1508
|
+
if (!Array.isArray(cols) || cols.length === 0) {
|
|
1509
|
+
throw _err("allowedColumns(cols): expected a non-empty array", "sql-builder/bad-allowed-columns");
|
|
1510
|
+
}
|
|
1511
|
+
cols.forEach(_validateColumn);
|
|
1512
|
+
this._allowedColumns = new Set(cols);
|
|
1513
|
+
if (this._columnGateMode === "off") this._columnGateMode = "reject";
|
|
1514
|
+
return this;
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
columnGate(mode) {
|
|
1518
|
+
if (mode !== "reject" && mode !== "warn" && mode !== "off") {
|
|
1519
|
+
throw _err("columnGate mode must be 'reject' | 'warn' | 'off'", "sql-builder/bad-gate-mode");
|
|
1520
|
+
}
|
|
1521
|
+
this._columnGateMode = mode;
|
|
1522
|
+
return this;
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
// Assert a column is a member of the gate set before it is quoted into
|
|
1526
|
+
// SQL. Always enforces an explicit allowedColumns set; "warn" mode
|
|
1527
|
+
// permits unknown columns (no audit sink here - this is a pure string
|
|
1528
|
+
// builder), "off" / no set skips. A qualified "alias.col" gates on the
|
|
1529
|
+
// bare column segment.
|
|
1530
|
+
_assertColumnMember(col, where) {
|
|
1531
|
+
if (this._columnGateMode === "off" || this._allowedColumns === null) return;
|
|
1532
|
+
var bare = col.indexOf(".") !== -1 ? col.split(".").pop() : col;
|
|
1533
|
+
if (this._allowedColumns.has(bare)) return;
|
|
1534
|
+
if (this._columnGateMode === "warn") return;
|
|
1535
|
+
throw _err("column '" + col + "' is not in the allowedColumns set" +
|
|
1536
|
+
(where ? " (" + where + ")" : ""), "sql-builder/unknown-column");
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
// ---- WITH (shared by every verb) ----
|
|
1540
|
+
with(name, subqueryOrRaw, params, opts) {
|
|
1541
|
+
return this._pushCte(false, name, subqueryOrRaw, params, opts);
|
|
1542
|
+
}
|
|
1543
|
+
withRecursive(name, subqueryOrRaw, params, opts) {
|
|
1544
|
+
return this._pushCte(true, name, subqueryOrRaw, params, opts);
|
|
1545
|
+
}
|
|
1546
|
+
_pushCte(recursive, name, subqueryOrRaw, params, opts) {
|
|
1547
|
+
_validateColumn(name);
|
|
1548
|
+
if (recursive) this._cteRecursive = true;
|
|
1549
|
+
if (subqueryOrRaw instanceof Builder) {
|
|
1550
|
+
this._ctes.push({ name: name, builder: subqueryOrRaw });
|
|
1551
|
+
} else if (typeof subqueryOrRaw === "string") {
|
|
1552
|
+
this._ctes.push({
|
|
1553
|
+
name: name, sql: subqueryOrRaw, params: params,
|
|
1554
|
+
guardProfile: (opts && opts.guardProfile) || "balanced",
|
|
1555
|
+
});
|
|
1556
|
+
} else {
|
|
1557
|
+
throw _err("with(name, ...): second arg must be a b.sql builder or a raw SQL string",
|
|
1558
|
+
"sql-builder/bad-cte");
|
|
1559
|
+
}
|
|
1560
|
+
return this;
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
// Subclasses implement _render() -> { sql, params } WITHOUT the WITH
|
|
1564
|
+
// prefix; toSql() prepends the rendered CTE clause.
|
|
1565
|
+
toSql() {
|
|
1566
|
+
var body = this._render();
|
|
1567
|
+
if (this._ctes.length === 0) return body;
|
|
1568
|
+
var withClause = _renderWith(this._ctes, this._cteRecursive, this._dialect);
|
|
1569
|
+
return {
|
|
1570
|
+
sql: withClause.sql + body.sql,
|
|
1571
|
+
params: withClause.params.concat(body.params),
|
|
1572
|
+
};
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
// Driver-final form for code that targets an operator-supplied driver
|
|
1576
|
+
// DIRECTLY (b.externalDb.query / a transaction client), with no
|
|
1577
|
+
// b.clusterStorage in the path to rewrite placeholders. The builder
|
|
1578
|
+
// always composes `?` placeholders by construction; this terminal
|
|
1579
|
+
// translates them to the dialect's positional form at the boundary:
|
|
1580
|
+
// `$1..$N` for Postgres, left as `?` for SQLite / MySQL. The translation
|
|
1581
|
+
// is the SAME quote/comment-aware single pass clusterStorage uses, so a
|
|
1582
|
+
// `?` inside a string literal / quoted identifier / comment is never
|
|
1583
|
+
// rewritten. The `?`-by-construction invariant is unchanged - only the
|
|
1584
|
+
// emitted text differs at the very last step.
|
|
1585
|
+
toExternalSql(dialect) {
|
|
1586
|
+
var built = this.toSql();
|
|
1587
|
+
var d = _normDialect(dialect || this._dialect);
|
|
1588
|
+
return { sql: _toPositional(built.sql, d), params: built.params };
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
// ---- SELECT ---------------------------------------------------------
|
|
1593
|
+
|
|
1594
|
+
class SelectBuilder extends Builder {
|
|
1595
|
+
constructor(tableNameOrRef, opts) {
|
|
1596
|
+
super("select", tableNameOrRef, opts);
|
|
1597
|
+
this._projection = []; // [{ sql, params }] - column / aggregate / scalar-subquery
|
|
1598
|
+
this._distinct = false;
|
|
1599
|
+
this._joins = []; // [{ sql, params }]
|
|
1600
|
+
this._where = new Predicate(this, "AND");
|
|
1601
|
+
this._groupBy = [];
|
|
1602
|
+
this._having = new Predicate(this, "AND");
|
|
1603
|
+
this._orderBy = [];
|
|
1604
|
+
this._limit = null;
|
|
1605
|
+
this._offset = null;
|
|
1606
|
+
this._lockMode = null; // null | "UPDATE" | "SHARE"
|
|
1607
|
+
this._lockSkipLocked = false;
|
|
1608
|
+
this._lockNoWait = false;
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
distinct() { this._distinct = true; return this; }
|
|
1612
|
+
|
|
1613
|
+
// Projection: array of column names (or "alias.col"); each quoted.
|
|
1614
|
+
// Empty / unset -> "*".
|
|
1615
|
+
columns(cols) {
|
|
1616
|
+
if (!Array.isArray(cols)) throw _err("columns() expects an array", "sql-builder/bad-columns");
|
|
1617
|
+
var self = this;
|
|
1618
|
+
cols.forEach(function (c) {
|
|
1619
|
+
self._assertColumnMember(c, "select");
|
|
1620
|
+
self._projection.push({ sql: _qualifiedColumn(c, self._dialect), params: [] });
|
|
1621
|
+
});
|
|
1622
|
+
return this;
|
|
1623
|
+
}
|
|
1624
|
+
select(cols) { return this.columns(cols); }
|
|
1625
|
+
|
|
1626
|
+
// A guarded raw projection expression - a constant `1` presence sentinel
|
|
1627
|
+
// (`SELECT 1 ... WHERE ...` for an existence probe), a function-call
|
|
1628
|
+
// projection, or any value expression the structured column / aggregate
|
|
1629
|
+
// helpers don't cover. It rides the same b.guardSql raw-fragment gate as
|
|
1630
|
+
// whereRaw (no statement terminator, no embedded string literal unless
|
|
1631
|
+
// allowLiterals); any value binds via a `?` carried in params.
|
|
1632
|
+
selectRaw(expr, params, opts) {
|
|
1633
|
+
var checked = _checkRawFragment(expr, params, opts, "selectRaw");
|
|
1634
|
+
this._projection.push({ sql: checked.sql, params: checked.params });
|
|
1635
|
+
return this;
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
// Aggregate helpers. alias is quoted; the aggregated column is quoted
|
|
1639
|
+
// (or "*" for count()).
|
|
1640
|
+
count(col, alias) { return this._agg("COUNT", col || "*", alias, false); }
|
|
1641
|
+
countDistinct(col, alias) { return this._agg("COUNT", col, alias, true); }
|
|
1642
|
+
max(col, alias) { return this._agg("MAX", col, alias, false); }
|
|
1643
|
+
min(col, alias) { return this._agg("MIN", col, alias, false); }
|
|
1644
|
+
sum(col, alias) { return this._agg("SUM", col, alias, false); }
|
|
1645
|
+
avg(col, alias) { return this._agg("AVG", col, alias, false); }
|
|
1646
|
+
_agg(fn, col, alias, distinct) {
|
|
1647
|
+
var inner;
|
|
1648
|
+
if (col === "*") {
|
|
1649
|
+
inner = "*";
|
|
1650
|
+
} else {
|
|
1651
|
+
this._assertColumnMember(col, fn.toLowerCase());
|
|
1652
|
+
inner = (distinct ? "DISTINCT " : "") + _qualifiedColumn(col, this._dialect);
|
|
1653
|
+
}
|
|
1654
|
+
var sql = fn + "(" + inner + ")";
|
|
1655
|
+
if (alias) { _validateColumn(alias); sql += " AS " + _quoteId(alias, this._dialect); }
|
|
1656
|
+
this._projection.push({ sql: sql, params: [] });
|
|
1657
|
+
return this;
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
// Scalar subquery in the projection: selectSub(subBuilder, "alias").
|
|
1661
|
+
selectSub(subBuilder, alias) {
|
|
1662
|
+
if (!(subBuilder instanceof Builder)) {
|
|
1663
|
+
throw _err("selectSub requires a b.sql subquery builder", "sql-builder/bad-subquery");
|
|
1664
|
+
}
|
|
1665
|
+
_validateColumn(alias);
|
|
1666
|
+
var sub = _composeSub(subBuilder, this._dialect);
|
|
1667
|
+
this._projection.push({
|
|
1668
|
+
sql: "(" + sub.sql + ") AS " + _quoteId(alias, this._dialect),
|
|
1669
|
+
params: sub.params,
|
|
1670
|
+
});
|
|
1671
|
+
return this;
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
// ---- JOINs ----
|
|
1675
|
+
join(tbl, onLeft, op, onRight) { return this._join("INNER", tbl, onLeft, op, onRight); }
|
|
1676
|
+
innerJoin(tbl, onLeft, op, onRight) { return this._join("INNER", tbl, onLeft, op, onRight); }
|
|
1677
|
+
leftJoin(tbl, onLeft, op, onRight) { return this._join("LEFT", tbl, onLeft, op, onRight); }
|
|
1678
|
+
rightJoin(tbl, onLeft, op, onRight) { return this._join("RIGHT", tbl, onLeft, op, onRight); }
|
|
1679
|
+
fullJoin(tbl, onLeft, op, onRight) { return this._join("FULL", tbl, onLeft, op, onRight); }
|
|
1680
|
+
crossJoin(tbl) { return this._join("CROSS", tbl, null, null, null); }
|
|
1681
|
+
_join(kind, tbl, onLeft, op, onRight) {
|
|
1682
|
+
var ref = _normTableRef(tbl, {});
|
|
1683
|
+
var clause = JOIN_KINDS[kind] + " " + ref.refWithAlias(this._dialect);
|
|
1684
|
+
if (kind !== "CROSS") {
|
|
1685
|
+
if (typeof onLeft !== "string" || typeof onRight !== "string") {
|
|
1686
|
+
throw _err(kind + " join requires onLeft + onRight column expressions",
|
|
1687
|
+
"sql-builder/bad-join-on");
|
|
1688
|
+
}
|
|
1689
|
+
var joinOp = op || "=";
|
|
1690
|
+
// The ON operator is a comparison; validate against the same
|
|
1691
|
+
// allowlist. Both operands are column expressions (quoted), never
|
|
1692
|
+
// bound values - a join condition compares columns, not literals.
|
|
1693
|
+
if (ALLOWED_OPS[joinOp] !== true) {
|
|
1694
|
+
throw _err("invalid join ON operator '" + joinOp + "'", "sql-builder/bad-operator");
|
|
1695
|
+
}
|
|
1696
|
+
// A JSONB operator in a join ON has no jsonb_exists* rewrite and a bare
|
|
1697
|
+
// `?` collides with the placeholder marker; refuse it here.
|
|
1698
|
+
_refuseJsonbOp(joinOp, "a join ON clause");
|
|
1699
|
+
clause += " ON " + _qualifiedColumn(onLeft, this._dialect) + " " + joinOp + " " +
|
|
1700
|
+
_qualifiedColumn(onRight, this._dialect);
|
|
1701
|
+
}
|
|
1702
|
+
this._joins.push({ sql: clause, params: [] });
|
|
1703
|
+
return this;
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
// Raw join (guarded) - the full "<KIND> JOIN <tbl> ON <raw>" escape
|
|
1707
|
+
// hatch for join conditions the column-pair form can't express.
|
|
1708
|
+
joinRaw(sql, params, opts) {
|
|
1709
|
+
var checked = _checkRawFragment(sql, params, opts, "joinRaw");
|
|
1710
|
+
this._joins.push({ sql: checked.sql, params: checked.params });
|
|
1711
|
+
return this;
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
// ---- WHERE (delegated to the Predicate) ----
|
|
1715
|
+
// where / andWhere / orWhere forward `arguments` rather than fixed
|
|
1716
|
+
// positional params: the Predicate distinguishes the 2-arg
|
|
1717
|
+
// where(field, value) shorthand from the 3-arg where(field, op, value)
|
|
1718
|
+
// form by arguments.length, so a fixed (a, b, c) signature here would
|
|
1719
|
+
// make a 2-arg call look like 3 (binding the value as the operator).
|
|
1720
|
+
where() { this._where.where.apply(this._where, arguments); return this; }
|
|
1721
|
+
andWhere() { this._where.andWhere.apply(this._where, arguments); return this; }
|
|
1722
|
+
orWhere() { this._where.orWhere.apply(this._where, arguments); return this; }
|
|
1723
|
+
whereOp(col, op, value) { this._where.whereOp(col, op, value); return this; }
|
|
1724
|
+
orWhereOp(col, op, value) { this._where.orWhereOp(col, op, value); return this; }
|
|
1725
|
+
whereIn(col, values) { this._where.whereIn(col, values); return this; }
|
|
1726
|
+
whereNotIn(col, values) { this._where.whereNotIn(col, values); return this; }
|
|
1727
|
+
orWhereIn(col, values) { this._where.orWhereIn(col, values); return this; }
|
|
1728
|
+
whereInArray(col, values) { this._where.whereInArray(col, values); return this; }
|
|
1729
|
+
orWhereInArray(col, values) { this._where.orWhereInArray(col, values); return this; }
|
|
1730
|
+
whereInJsonEach(col, jsonArrayString) { this._where.whereInJsonEach(col, jsonArrayString); return this; }
|
|
1731
|
+
whereMatch(target, expr) { this._where.whereMatch(target, expr); return this; }
|
|
1732
|
+
orWhereMatch(target, expr) { this._where.orWhereMatch(target, expr); return this; }
|
|
1733
|
+
whereNull(col) { this._where.whereNull(col); return this; }
|
|
1734
|
+
whereNotNull(col) { this._where.whereNotNull(col); return this; }
|
|
1735
|
+
orWhereNull(col) { this._where.orWhereNull(col); return this; }
|
|
1736
|
+
whereLike(col, term, mode) { this._where.whereLike(col, term, mode); return this; }
|
|
1737
|
+
orWhereLike(col, term, mode) { this._where.orWhereLike(col, term, mode); return this; }
|
|
1738
|
+
whereBetween(col, low, high) { this._where.whereBetween(col, low, high); return this; }
|
|
1739
|
+
whereGroup(closure) { this._where.whereGroup(closure); return this; }
|
|
1740
|
+
orWhereGroup(closure) { this._where.orWhereGroup(closure); return this; }
|
|
1741
|
+
whereExists(sub) { this._where.whereExists(sub); return this; }
|
|
1742
|
+
whereNotExists(sub) { this._where.whereNotExists(sub); return this; }
|
|
1743
|
+
whereSub(col, op, sub) { this._where.whereSub(col, op, sub); return this; }
|
|
1744
|
+
whereRaw(sql, params, opts) { this._where.whereRaw(sql, params, opts); return this; }
|
|
1745
|
+
orWhereRaw(sql, params, opts) { this._where.orWhereRaw(sql, params, opts); return this; }
|
|
1746
|
+
|
|
1747
|
+
// ---- Row locking (Postgres / MySQL 8+) ----
|
|
1748
|
+
// FOR UPDATE [SKIP LOCKED] - the competing-consumer claim idiom. SKIP
|
|
1749
|
+
// LOCKED lets parallel workers each grab a disjoint row set without
|
|
1750
|
+
// blocking on each other's locks (the at-least-once outbox / job-queue
|
|
1751
|
+
// claim). It is Postgres / MySQL-only; SQLite is a single writer and
|
|
1752
|
+
// has no row lock, so the builder REFUSES forUpdate on a sqlite dialect
|
|
1753
|
+
// at build (emitting unsupported syntax would be a silent driver error)
|
|
1754
|
+
// - the caller branches on dialect and uses a plain transaction-scoped
|
|
1755
|
+
// SELECT for sqlite, exactly as the publisher does.
|
|
1756
|
+
forUpdate(opts) { return this._lock("UPDATE", opts); }
|
|
1757
|
+
forShare(opts) { return this._lock("SHARE", opts); }
|
|
1758
|
+
_lock(mode, opts) {
|
|
1759
|
+
opts = opts || {};
|
|
1760
|
+
if (this._dialect === "sqlite") {
|
|
1761
|
+
throw _err("forUpdate / forShare row locking is Postgres / MySQL-only " +
|
|
1762
|
+
"(SQLite is a single writer with no row lock); branch on dialect and use a " +
|
|
1763
|
+
"transaction-scoped SELECT for sqlite", "sql-builder/lock-unsupported");
|
|
1764
|
+
}
|
|
1765
|
+
this._lockMode = mode;
|
|
1766
|
+
this._lockSkipLocked = opts.skipLocked === true;
|
|
1767
|
+
this._lockNoWait = opts.noWait === true;
|
|
1768
|
+
if (this._lockSkipLocked && this._lockNoWait) {
|
|
1769
|
+
throw _err("forUpdate: skipLocked and noWait are mutually exclusive", "sql-builder/bad-lock");
|
|
1770
|
+
}
|
|
1771
|
+
return this;
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
// ---- GROUP BY / HAVING ----
|
|
1775
|
+
groupBy(cols) {
|
|
1776
|
+
var arr = Array.isArray(cols) ? cols : [cols];
|
|
1777
|
+
var self = this;
|
|
1778
|
+
arr.forEach(function (c) {
|
|
1779
|
+
self._assertColumnMember(c, "groupBy");
|
|
1780
|
+
self._groupBy.push(_qualifiedColumn(c, self._dialect));
|
|
1781
|
+
});
|
|
1782
|
+
return this;
|
|
1783
|
+
}
|
|
1784
|
+
having() { this._having.where.apply(this._having, arguments); return this; }
|
|
1785
|
+
orHaving() { this._having.orWhere.apply(this._having, arguments); return this; }
|
|
1786
|
+
havingRaw(sql, params, opts) { this._having.whereRaw(sql, params, opts); return this; }
|
|
1787
|
+
|
|
1788
|
+
// ---- ORDER BY / LIMIT / OFFSET ----
|
|
1789
|
+
orderBy(col, direction) {
|
|
1790
|
+
this._assertColumnMember(col, "orderBy");
|
|
1791
|
+
var dir = (direction || "asc").toLowerCase();
|
|
1792
|
+
if (dir !== "asc" && dir !== "desc") {
|
|
1793
|
+
throw _err("orderBy direction must be 'asc' or 'desc'", "sql-builder/bad-direction");
|
|
1794
|
+
}
|
|
1795
|
+
this._orderBy.push(_qualifiedColumn(col, this._dialect) + " " + dir.toUpperCase());
|
|
1796
|
+
return this;
|
|
1797
|
+
}
|
|
1798
|
+
limit(n) {
|
|
1799
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
1800
|
+
throw _err("limit must be a non-negative integer", "sql-builder/bad-limit");
|
|
1801
|
+
}
|
|
1802
|
+
this._limit = n;
|
|
1803
|
+
return this;
|
|
1804
|
+
}
|
|
1805
|
+
offset(n) {
|
|
1806
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
1807
|
+
throw _err("offset must be a non-negative integer", "sql-builder/bad-offset");
|
|
1808
|
+
}
|
|
1809
|
+
this._offset = n;
|
|
1810
|
+
return this;
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
_render() {
|
|
1814
|
+
var dialect = this._dialect;
|
|
1815
|
+
var params = [];
|
|
1816
|
+
var projSql;
|
|
1817
|
+
if (this._projection.length === 0) {
|
|
1818
|
+
projSql = "*";
|
|
1819
|
+
} else {
|
|
1820
|
+
var pieces = [];
|
|
1821
|
+
for (var p = 0; p < this._projection.length; p++) {
|
|
1822
|
+
pieces.push(this._projection[p].sql);
|
|
1823
|
+
for (var pp = 0; pp < this._projection[p].params.length; pp++) {
|
|
1824
|
+
params.push(this._projection[p].params[pp]);
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
projSql = pieces.join(", ");
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
var sql = "SELECT " + (this._distinct ? "DISTINCT " : "") + projSql +
|
|
1831
|
+
" FROM " + this._table.refWithAlias(dialect);
|
|
1832
|
+
|
|
1833
|
+
for (var j = 0; j < this._joins.length; j++) {
|
|
1834
|
+
sql += " " + this._joins[j].sql;
|
|
1835
|
+
for (var jp = 0; jp < this._joins[j].params.length; jp++) params.push(this._joins[j].params[jp]);
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
var w = this._where.build();
|
|
1839
|
+
if (w.sql) { sql += " WHERE " + w.sql; for (var wi = 0; wi < w.params.length; wi++) params.push(w.params[wi]); }
|
|
1840
|
+
|
|
1841
|
+
if (this._groupBy.length > 0) sql += " GROUP BY " + this._groupBy.join(", ");
|
|
1842
|
+
|
|
1843
|
+
var h = this._having.build();
|
|
1844
|
+
if (h.sql) { sql += " HAVING " + h.sql; for (var hi = 0; hi < h.params.length; hi++) params.push(h.params[hi]); }
|
|
1845
|
+
|
|
1846
|
+
if (this._orderBy.length > 0) sql += " ORDER BY " + this._orderBy.join(", ");
|
|
1847
|
+
if (this._limit !== null) sql += " LIMIT " + this._limit;
|
|
1848
|
+
if (this._offset !== null) sql += " OFFSET " + this._offset;
|
|
1849
|
+
|
|
1850
|
+
if (this._lockMode !== null) {
|
|
1851
|
+
sql += " FOR " + this._lockMode;
|
|
1852
|
+
if (this._lockSkipLocked) sql += " SKIP LOCKED";
|
|
1853
|
+
else if (this._lockNoWait) sql += " NOWAIT";
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
return _emit(sql, params);
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
// ---- INSERT ---------------------------------------------------------
|
|
1861
|
+
|
|
1862
|
+
class InsertBuilder extends Builder {
|
|
1863
|
+
constructor(tableNameOrRef, opts) {
|
|
1864
|
+
super("insert", tableNameOrRef, opts);
|
|
1865
|
+
this._columns = null;
|
|
1866
|
+
this._rows = []; // array of value arrays, aligned to _columns
|
|
1867
|
+
this._returning = null;
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
columns(cols) {
|
|
1871
|
+
if (!Array.isArray(cols) || cols.length === 0) {
|
|
1872
|
+
throw _err("columns() expects a non-empty array", "sql-builder/bad-columns");
|
|
1873
|
+
}
|
|
1874
|
+
var self = this;
|
|
1875
|
+
cols.forEach(function (c) { self._assertColumnMember(c, "insert"); _validateColumn(c); });
|
|
1876
|
+
this._columns = cols.slice();
|
|
1877
|
+
return this;
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
// values(obj) - one row from a column->value map (sets _columns from
|
|
1881
|
+
// the keys if not already set). values([obj, obj]) - multiple rows.
|
|
1882
|
+
// values(array) - one row aligned to a prior columns() call.
|
|
1883
|
+
values(rowOrRows) {
|
|
1884
|
+
if (Array.isArray(rowOrRows) && rowOrRows.length > 0 && typeof rowOrRows[0] === "object" &&
|
|
1885
|
+
rowOrRows[0] !== null && !Array.isArray(rowOrRows[0])) {
|
|
1886
|
+
// Array of row objects.
|
|
1887
|
+
var self = this;
|
|
1888
|
+
rowOrRows.forEach(function (r) { self._addRowObject(r); });
|
|
1889
|
+
return this;
|
|
1890
|
+
}
|
|
1891
|
+
if (Array.isArray(rowOrRows)) {
|
|
1892
|
+
// A single positional row aligned to columns().
|
|
1893
|
+
if (this._columns === null) {
|
|
1894
|
+
throw _err("values(array) requires a prior columns([...]) call", "sql-builder/no-columns");
|
|
1895
|
+
}
|
|
1896
|
+
if (rowOrRows.length !== this._columns.length) {
|
|
1897
|
+
throw _err("values(array): " + rowOrRows.length + " values but " +
|
|
1898
|
+
this._columns.length + " columns", "sql-builder/value-count");
|
|
1899
|
+
}
|
|
1900
|
+
this._rows.push(rowOrRows.slice());
|
|
1901
|
+
return this;
|
|
1902
|
+
}
|
|
1903
|
+
if (rowOrRows && typeof rowOrRows === "object") {
|
|
1904
|
+
this._addRowObject(rowOrRows);
|
|
1905
|
+
return this;
|
|
1906
|
+
}
|
|
1907
|
+
throw _err("values() requires a row object, an array of row objects, or a value array",
|
|
1908
|
+
"sql-builder/bad-values");
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
_addRowObject(obj) {
|
|
1912
|
+
var keys = Object.keys(obj);
|
|
1913
|
+
if (keys.length === 0) throw _err("insert row object is empty", "sql-builder/empty-values");
|
|
1914
|
+
if (this._columns === null) {
|
|
1915
|
+
this.columns(keys);
|
|
1916
|
+
}
|
|
1917
|
+
var self = this;
|
|
1918
|
+
var row = this._columns.map(function (c) {
|
|
1919
|
+
if (!Object.prototype.hasOwnProperty.call(obj, c)) {
|
|
1920
|
+
throw _err("insert row is missing column '" + c + "'", "sql-builder/missing-column");
|
|
1921
|
+
}
|
|
1922
|
+
return obj[c];
|
|
1923
|
+
});
|
|
1924
|
+
// Reject extra keys not in the column set (silent-drop would lose data).
|
|
1925
|
+
keys.forEach(function (k) {
|
|
1926
|
+
if (self._columns.indexOf(k) === -1) {
|
|
1927
|
+
throw _err("insert row has column '" + k + "' not in the column set", "sql-builder/extra-column");
|
|
1928
|
+
}
|
|
1929
|
+
});
|
|
1930
|
+
this._rows.push(row);
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
returning(cols) { this._returning = _normReturning(cols); return this; }
|
|
1934
|
+
|
|
1935
|
+
_render() {
|
|
1936
|
+
if (this._columns === null || this._rows.length === 0) {
|
|
1937
|
+
throw _err("insert requires columns + at least one values() row", "sql-builder/empty-values");
|
|
1938
|
+
}
|
|
1939
|
+
var dialect = this._dialect;
|
|
1940
|
+
var quotedCols = this._columns.map(function (c) { return _quoteId(c, dialect); }).join(", ");
|
|
1941
|
+
var holders = [];
|
|
1942
|
+
var params = [];
|
|
1943
|
+
// Each cell renders to `?` (bound), `?::type` (cast), or an allowlisted
|
|
1944
|
+
// SQL function token (NOW() / CURRENT_TIMESTAMP - no param). A
|
|
1945
|
+
// SqlFunction / CastValue cell is identical across rows for a given
|
|
1946
|
+
// column, but the cell is resolved per-row so a multi-row insert can
|
|
1947
|
+
// mix a literal in one row and a function in another.
|
|
1948
|
+
for (var r = 0; r < this._rows.length; r++) {
|
|
1949
|
+
var cells = [];
|
|
1950
|
+
for (var v = 0; v < this._rows[r].length; v++) {
|
|
1951
|
+
var rendered = _renderValueCell(this._rows[r][v], dialect);
|
|
1952
|
+
cells.push(rendered.sql);
|
|
1953
|
+
for (var rp = 0; rp < rendered.params.length; rp++) params.push(rendered.params[rp]);
|
|
1954
|
+
}
|
|
1955
|
+
holders.push("(" + cells.join(", ") + ")");
|
|
1956
|
+
}
|
|
1957
|
+
var sql = "INSERT INTO " + this._table.ref(dialect) + " (" + quotedCols + ") VALUES " +
|
|
1958
|
+
holders.join(", ");
|
|
1959
|
+
sql += _renderReturning(this._returning, dialect);
|
|
1960
|
+
return _emit(sql, params);
|
|
1961
|
+
}
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
// ---- UPDATE ---------------------------------------------------------
|
|
1965
|
+
|
|
1966
|
+
class UpdateBuilder extends Builder {
|
|
1967
|
+
constructor(tableNameOrRef, opts) {
|
|
1968
|
+
super("update", tableNameOrRef, opts);
|
|
1969
|
+
this._set = []; // [{ sql, params }]
|
|
1970
|
+
this._where = new Predicate(this, "AND");
|
|
1971
|
+
this._returning = null;
|
|
1972
|
+
this._allowNoWhere = false;
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
// set(obj) - column->value assignments. set(col, value) - single
|
|
1976
|
+
// assignment. A value may be a bound literal, a b.sql.cast(...) (binds
|
|
1977
|
+
// `?::type`), or a b.sql.fn(...) allowlisted SQL function (emits the
|
|
1978
|
+
// token, no param) - all routed through the single _renderValueCell
|
|
1979
|
+
// choke-point. A SqlFunction / CastValue is itself an object, so the
|
|
1980
|
+
// object-form detection excludes them explicitly.
|
|
1981
|
+
set(colOrObj, value) {
|
|
1982
|
+
var self = this;
|
|
1983
|
+
if (colOrObj && typeof colOrObj === "object" &&
|
|
1984
|
+
!(colOrObj instanceof SqlFunction) && !(colOrObj instanceof CastValue)) {
|
|
1985
|
+
var keys = Object.keys(colOrObj);
|
|
1986
|
+
if (keys.length === 0) throw _err("set object is empty", "sql-builder/empty-set");
|
|
1987
|
+
keys.forEach(function (k) {
|
|
1988
|
+
self._assertColumnMember(k, "update");
|
|
1989
|
+
var cell = _renderValueCell(colOrObj[k], self._dialect);
|
|
1990
|
+
self._set.push({ sql: _quoteId(k, self._dialect) + " = " + cell.sql, params: cell.params });
|
|
1991
|
+
});
|
|
1992
|
+
return this;
|
|
1993
|
+
}
|
|
1994
|
+
this._assertColumnMember(colOrObj, "update");
|
|
1995
|
+
var cell1 = _renderValueCell(value, this._dialect);
|
|
1996
|
+
this._set.push({ sql: _quoteId(colOrObj, this._dialect) + " = " + cell1.sql, params: cell1.params });
|
|
1997
|
+
return this;
|
|
1998
|
+
}
|
|
1999
|
+
|
|
2000
|
+
// setRaw(col, rawExpr, params) - assign a guarded raw expression
|
|
2001
|
+
// (e.g. "count" = "count" + ?). The column is quoted; the expression
|
|
2002
|
+
// is guarded + placeholder-checked.
|
|
2003
|
+
setRaw(col, expr, params, opts) {
|
|
2004
|
+
this._assertColumnMember(col, "update");
|
|
2005
|
+
var checked = _checkRawFragment(expr, params, opts, "setRaw");
|
|
2006
|
+
this._set.push({
|
|
2007
|
+
sql: _quoteId(col, this._dialect) + " = " + checked.sql,
|
|
2008
|
+
params: checked.params,
|
|
2009
|
+
});
|
|
2010
|
+
return this;
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
allowNoWhere() { this._allowNoWhere = true; return this; }
|
|
2014
|
+
|
|
2015
|
+
where() { this._where.where.apply(this._where, arguments); return this; }
|
|
2016
|
+
andWhere() { this._where.andWhere.apply(this._where, arguments); return this; }
|
|
2017
|
+
orWhere() { this._where.orWhere.apply(this._where, arguments); return this; }
|
|
2018
|
+
whereOp(col, op, value) { this._where.whereOp(col, op, value); return this; }
|
|
2019
|
+
orWhereOp(col, op, value) { this._where.orWhereOp(col, op, value); return this; }
|
|
2020
|
+
whereIn(col, values) { this._where.whereIn(col, values); return this; }
|
|
2021
|
+
whereNotIn(col, values) { this._where.whereNotIn(col, values); return this; }
|
|
2022
|
+
orWhereIn(col, values) { this._where.orWhereIn(col, values); return this; }
|
|
2023
|
+
whereInArray(col, values) { this._where.whereInArray(col, values); return this; }
|
|
2024
|
+
orWhereInArray(col, values) { this._where.orWhereInArray(col, values); return this; }
|
|
2025
|
+
whereInJsonEach(col, jsonArrayString) { this._where.whereInJsonEach(col, jsonArrayString); return this; }
|
|
2026
|
+
whereMatch(target, expr) { this._where.whereMatch(target, expr); return this; }
|
|
2027
|
+
whereNull(col) { this._where.whereNull(col); return this; }
|
|
2028
|
+
whereNotNull(col) { this._where.whereNotNull(col); return this; }
|
|
2029
|
+
orWhereNull(col) { this._where.orWhereNull(col); return this; }
|
|
2030
|
+
whereLike(col, term, mode) { this._where.whereLike(col, term, mode); return this; }
|
|
2031
|
+
orWhereLike(col, term, mode) { this._where.orWhereLike(col, term, mode); return this; }
|
|
2032
|
+
whereSub(col, op, sub) { this._where.whereSub(col, op, sub); return this; }
|
|
2033
|
+
whereExists(sub) { this._where.whereExists(sub); return this; }
|
|
2034
|
+
whereNotExists(sub) { this._where.whereNotExists(sub); return this; }
|
|
2035
|
+
whereGroup(closure) { this._where.whereGroup(closure); return this; }
|
|
2036
|
+
orWhereGroup(closure) { this._where.orWhereGroup(closure); return this; }
|
|
2037
|
+
whereRaw(sql, params, opts) { this._where.whereRaw(sql, params, opts); return this; }
|
|
2038
|
+
orWhereRaw(sql, params, opts) { this._where.orWhereRaw(sql, params, opts); return this; }
|
|
2039
|
+
|
|
2040
|
+
returning(cols) { this._returning = _normReturning(cols); return this; }
|
|
2041
|
+
|
|
2042
|
+
_render() {
|
|
2043
|
+
if (this._set.length === 0) throw _err("update requires a set(...) call", "sql-builder/empty-set");
|
|
2044
|
+
if (this._where.length === 0 && !this._allowNoWhere) {
|
|
2045
|
+
throw _err("refusing unconditional update - call where(...) first or allowNoWhere()",
|
|
2046
|
+
"sql-builder/no-where");
|
|
2047
|
+
}
|
|
2048
|
+
var dialect = this._dialect;
|
|
2049
|
+
var params = [];
|
|
2050
|
+
var setPieces = [];
|
|
2051
|
+
for (var s = 0; s < this._set.length; s++) {
|
|
2052
|
+
setPieces.push(this._set[s].sql);
|
|
2053
|
+
for (var sp = 0; sp < this._set[s].params.length; sp++) params.push(this._set[s].params[sp]);
|
|
2054
|
+
}
|
|
2055
|
+
var sql = "UPDATE " + this._table.ref(dialect) + " SET " + setPieces.join(", ");
|
|
2056
|
+
var w = this._where.build();
|
|
2057
|
+
if (w.sql) { sql += " WHERE " + w.sql; for (var wi = 0; wi < w.params.length; wi++) params.push(w.params[wi]); }
|
|
2058
|
+
sql += _renderReturning(this._returning, dialect);
|
|
2059
|
+
return _emit(sql, params);
|
|
2060
|
+
}
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
// ---- DELETE ---------------------------------------------------------
|
|
2064
|
+
|
|
2065
|
+
class DeleteBuilder extends Builder {
|
|
2066
|
+
constructor(tableNameOrRef, opts) {
|
|
2067
|
+
super("delete", tableNameOrRef, opts);
|
|
2068
|
+
this._where = new Predicate(this, "AND");
|
|
2069
|
+
this._returning = null;
|
|
2070
|
+
this._allowNoWhere = false;
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
allowNoWhere() { this._allowNoWhere = true; return this; }
|
|
2074
|
+
|
|
2075
|
+
where() { this._where.where.apply(this._where, arguments); return this; }
|
|
2076
|
+
andWhere() { this._where.andWhere.apply(this._where, arguments); return this; }
|
|
2077
|
+
orWhere() { this._where.orWhere.apply(this._where, arguments); return this; }
|
|
2078
|
+
whereOp(col, op, value) { this._where.whereOp(col, op, value); return this; }
|
|
2079
|
+
orWhereOp(col, op, value) { this._where.orWhereOp(col, op, value); return this; }
|
|
2080
|
+
whereIn(col, values) { this._where.whereIn(col, values); return this; }
|
|
2081
|
+
whereNotIn(col, values) { this._where.whereNotIn(col, values); return this; }
|
|
2082
|
+
orWhereIn(col, values) { this._where.orWhereIn(col, values); return this; }
|
|
2083
|
+
whereInArray(col, values) { this._where.whereInArray(col, values); return this; }
|
|
2084
|
+
orWhereInArray(col, values) { this._where.orWhereInArray(col, values); return this; }
|
|
2085
|
+
whereInJsonEach(col, jsonArrayString) { this._where.whereInJsonEach(col, jsonArrayString); return this; }
|
|
2086
|
+
whereMatch(target, expr) { this._where.whereMatch(target, expr); return this; }
|
|
2087
|
+
whereNull(col) { this._where.whereNull(col); return this; }
|
|
2088
|
+
whereNotNull(col) { this._where.whereNotNull(col); return this; }
|
|
2089
|
+
orWhereNull(col) { this._where.orWhereNull(col); return this; }
|
|
2090
|
+
whereLike(col, term, mode) { this._where.whereLike(col, term, mode); return this; }
|
|
2091
|
+
orWhereLike(col, term, mode) { this._where.orWhereLike(col, term, mode); return this; }
|
|
2092
|
+
whereSub(col, op, sub) { this._where.whereSub(col, op, sub); return this; }
|
|
2093
|
+
whereExists(sub) { this._where.whereExists(sub); return this; }
|
|
2094
|
+
whereNotExists(sub) { this._where.whereNotExists(sub); return this; }
|
|
2095
|
+
whereGroup(closure) { this._where.whereGroup(closure); return this; }
|
|
2096
|
+
orWhereGroup(closure) { this._where.orWhereGroup(closure); return this; }
|
|
2097
|
+
whereRaw(sql, params, opts) { this._where.whereRaw(sql, params, opts); return this; }
|
|
2098
|
+
orWhereRaw(sql, params, opts) { this._where.orWhereRaw(sql, params, opts); return this; }
|
|
2099
|
+
|
|
2100
|
+
returning(cols) { this._returning = _normReturning(cols); return this; }
|
|
2101
|
+
|
|
2102
|
+
_render() {
|
|
2103
|
+
if (this._where.length === 0 && !this._allowNoWhere) {
|
|
2104
|
+
throw _err("refusing unconditional delete - call where(...) first or allowNoWhere()",
|
|
2105
|
+
"sql-builder/no-where");
|
|
2106
|
+
}
|
|
2107
|
+
var dialect = this._dialect;
|
|
2108
|
+
var params = [];
|
|
2109
|
+
var sql = "DELETE FROM " + this._table.ref(dialect);
|
|
2110
|
+
var w = this._where.build();
|
|
2111
|
+
if (w.sql) { sql += " WHERE " + w.sql; for (var wi = 0; wi < w.params.length; wi++) params.push(w.params[wi]); }
|
|
2112
|
+
sql += _renderReturning(this._returning, dialect);
|
|
2113
|
+
return _emit(sql, params);
|
|
2114
|
+
}
|
|
2115
|
+
}
|
|
2116
|
+
|
|
2117
|
+
// ---- UPSERT (the dialect-divergence centrepiece) --------------------
|
|
2118
|
+
//
|
|
2119
|
+
// The one verb that must emit dialect-final syntax - placeholderize +
|
|
2120
|
+
// resolveTables cannot synthesise a conflict clause.
|
|
2121
|
+
//
|
|
2122
|
+
// Postgres / SQLite:
|
|
2123
|
+
// INSERT INTO t (cols) VALUES (?...) ON CONFLICT (keys)
|
|
2124
|
+
// DO UPDATE SET col = EXCLUDED.col [WHERE <guard>] [RETURNING ...]
|
|
2125
|
+
// | DO NOTHING
|
|
2126
|
+
//
|
|
2127
|
+
// MySQL:
|
|
2128
|
+
// INSERT INTO t (cols) VALUES (?...) ON DUPLICATE KEY UPDATE
|
|
2129
|
+
// col = VALUES(col) (or IF(<guard>, VALUES(col), col)
|
|
2130
|
+
// when conflictWhere is present -
|
|
2131
|
+
// MySQL has no per-statement WHERE
|
|
2132
|
+
// on the conflict action)
|
|
2133
|
+
// No WHERE, no RETURNING. A readbackSql SELECT is auto-emitted so the
|
|
2134
|
+
// caller can fetch the upserted row the way RETURNING would have
|
|
2135
|
+
// surfaced it.
|
|
2136
|
+
//
|
|
2137
|
+
// All three conflict actions are required: doUpdate (re-bind specific
|
|
2138
|
+
// columns, optionally to an expression), doUpdateFromExcluded (set the
|
|
2139
|
+
// listed columns to the proposed row's values), and doNothing.
|
|
2140
|
+
class UpsertBuilder extends Builder {
|
|
2141
|
+
constructor(tableNameOrRef, opts) {
|
|
2142
|
+
super("upsert", tableNameOrRef, opts);
|
|
2143
|
+
this._columns = null;
|
|
2144
|
+
this._values = null; // single row, aligned to _columns
|
|
2145
|
+
this._conflictKeys = null;
|
|
2146
|
+
this._action = null; // "update" | "update-excluded" | "nothing"
|
|
2147
|
+
this._updateCols = null; // for update-excluded: [col, ...]
|
|
2148
|
+
this._updateExprs = null; // for update: { col: "?" | rawExpr } map, ordered
|
|
2149
|
+
this._updateParams = null; // params for the update expressions
|
|
2150
|
+
this._conflictWhere = null; // { sql, params } guarded fragment
|
|
2151
|
+
this._returning = null;
|
|
2152
|
+
}
|
|
2153
|
+
|
|
2154
|
+
columns(cols) {
|
|
2155
|
+
if (!Array.isArray(cols) || cols.length === 0) {
|
|
2156
|
+
throw _err("columns() expects a non-empty array", "sql-builder/bad-columns");
|
|
2157
|
+
}
|
|
2158
|
+
var self = this;
|
|
2159
|
+
cols.forEach(function (c) { self._assertColumnMember(c, "upsert"); _validateColumn(c); });
|
|
2160
|
+
this._columns = cols.slice();
|
|
2161
|
+
return this;
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2164
|
+
values(obj) {
|
|
2165
|
+
if (!obj || typeof obj !== "object" || Array.isArray(obj)) {
|
|
2166
|
+
throw _err("upsert values() requires a single row object", "sql-builder/bad-values");
|
|
2167
|
+
}
|
|
2168
|
+
var keys = Object.keys(obj);
|
|
2169
|
+
if (keys.length === 0) throw _err("upsert row object is empty", "sql-builder/empty-values");
|
|
2170
|
+
if (this._columns === null) this.columns(keys);
|
|
2171
|
+
var self = this;
|
|
2172
|
+
this._values = this._columns.map(function (c) {
|
|
2173
|
+
if (!Object.prototype.hasOwnProperty.call(obj, c)) {
|
|
2174
|
+
throw _err("upsert row is missing column '" + c + "'", "sql-builder/missing-column");
|
|
2175
|
+
}
|
|
2176
|
+
return obj[c];
|
|
2177
|
+
});
|
|
2178
|
+
keys.forEach(function (k) {
|
|
2179
|
+
if (self._columns.indexOf(k) === -1) {
|
|
2180
|
+
throw _err("upsert row has column '" + k + "' not in the column set", "sql-builder/extra-column");
|
|
2181
|
+
}
|
|
2182
|
+
});
|
|
2183
|
+
return this;
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
onConflict(keyCols) {
|
|
2187
|
+
var arr = Array.isArray(keyCols) ? keyCols : [keyCols];
|
|
2188
|
+
if (arr.length === 0) throw _err("onConflict requires at least one key column", "sql-builder/bad-conflict");
|
|
2189
|
+
arr.forEach(_validateColumn);
|
|
2190
|
+
this._conflictKeys = arr.slice();
|
|
2191
|
+
return this;
|
|
2192
|
+
}
|
|
2193
|
+
|
|
2194
|
+
// DO UPDATE SET col = EXCLUDED.col for each listed column.
|
|
2195
|
+
doUpdateFromExcluded(cols) {
|
|
2196
|
+
if (!Array.isArray(cols) || cols.length === 0) {
|
|
2197
|
+
throw _err("doUpdateFromExcluded requires a non-empty column array", "sql-builder/conflict-action");
|
|
2198
|
+
}
|
|
2199
|
+
var self = this;
|
|
2200
|
+
cols.forEach(function (c) { self._assertColumnMember(c, "upsert"); _validateColumn(c); });
|
|
2201
|
+
this._action = "update-excluded";
|
|
2202
|
+
this._updateCols = cols.slice();
|
|
2203
|
+
return this;
|
|
2204
|
+
}
|
|
2205
|
+
|
|
2206
|
+
// DO UPDATE SET col = <value-or-expr>. An array of columns sets each
|
|
2207
|
+
// to EXCLUDED.col (Postgres/SQLite) / VALUES(col) (MySQL). An object
|
|
2208
|
+
// { col: "?" } re-binds the column to a supplied param; { col: rawExpr }
|
|
2209
|
+
// sets it to a guarded raw expression. Pass exprParams for any `?` in
|
|
2210
|
+
// the object's expressions, in column order.
|
|
2211
|
+
doUpdate(colsOrMap, exprParams) {
|
|
2212
|
+
if (Array.isArray(colsOrMap)) return this.doUpdateFromExcluded(colsOrMap);
|
|
2213
|
+
if (!colsOrMap || typeof colsOrMap !== "object") {
|
|
2214
|
+
throw _err("doUpdate requires a column array or a { col: expr } map", "sql-builder/conflict-action");
|
|
2215
|
+
}
|
|
2216
|
+
var keys = Object.keys(colsOrMap);
|
|
2217
|
+
if (keys.length === 0) throw _err("doUpdate map is empty", "sql-builder/conflict-action");
|
|
2218
|
+
var self = this;
|
|
2219
|
+
keys.forEach(function (c) { self._assertColumnMember(c, "upsert"); _validateColumn(c); });
|
|
2220
|
+
this._action = "update";
|
|
2221
|
+
this._updateExprs = colsOrMap;
|
|
2222
|
+
this._updateParams = Array.isArray(exprParams) ? exprParams.slice()
|
|
2223
|
+
: (exprParams == null ? [] : [exprParams]);
|
|
2224
|
+
return this;
|
|
2225
|
+
}
|
|
2226
|
+
|
|
2227
|
+
doNothing() { this._action = "nothing"; return this; }
|
|
2228
|
+
|
|
2229
|
+
// The fenced WHERE on the conflict action (Postgres/SQLite); on MySQL
|
|
2230
|
+
// it folds into IF(<guard>, VALUES(col), col). Guarded raw fragment.
|
|
2231
|
+
//
|
|
2232
|
+
// opts.guardColumn names the column the fence protects - the column the
|
|
2233
|
+
// guard expression compares against (e.g. a monotonic fencing token).
|
|
2234
|
+
// On MySQL it is emitted LAST in the SET list so the IF on every other
|
|
2235
|
+
// column evaluates the guard against this column's PRE-UPDATE value
|
|
2236
|
+
// (MySQL evaluates the SET list left to right and a later assignment in
|
|
2237
|
+
// the same statement sees earlier columns' already-updated values - the
|
|
2238
|
+
// IF-eval-order hazard). Ignored on Postgres / SQLite, which apply the
|
|
2239
|
+
// WHERE atomically. When omitted the SET list keeps its declared order
|
|
2240
|
+
// (correct whenever the guard does not also appear as a SET target).
|
|
2241
|
+
conflictWhere(sql, params, opts) {
|
|
2242
|
+
var checked = _checkRawFragment(sql, params, opts, "conflictWhere");
|
|
2243
|
+
var guardColumn = opts && opts.guardColumn;
|
|
2244
|
+
if (guardColumn !== undefined && guardColumn !== null) {
|
|
2245
|
+
_validateColumn(guardColumn);
|
|
2246
|
+
checked.guardColumn = guardColumn;
|
|
2247
|
+
}
|
|
2248
|
+
this._conflictWhere = checked;
|
|
2249
|
+
return this;
|
|
2250
|
+
}
|
|
2251
|
+
|
|
2252
|
+
returning(cols) { this._returning = _normReturning(cols); return this; }
|
|
2253
|
+
|
|
2254
|
+
// Render the VALUES tuple through the same _renderValueCell choke-point
|
|
2255
|
+
// INSERT uses, so an upsert VALUES cell may be a bound literal, a
|
|
2256
|
+
// b.sql.cast(...) (`?::type`), or a b.sql.fn(...) allowlisted function
|
|
2257
|
+
// (`NOW()` / `CURRENT_TIMESTAMP`, no param). Without this the wrapper
|
|
2258
|
+
// objects would leak straight into params (a SqlFunction / CastValue is
|
|
2259
|
+
// an object, not a scalar) and the driver would mis-bind them.
|
|
2260
|
+
_renderValuesTuple(dialect) {
|
|
2261
|
+
var cells = [];
|
|
2262
|
+
var params = [];
|
|
2263
|
+
for (var i = 0; i < this._values.length; i += 1) {
|
|
2264
|
+
var rendered = _renderValueCell(this._values[i], dialect);
|
|
2265
|
+
cells.push(rendered.sql);
|
|
2266
|
+
for (var p = 0; p < rendered.params.length; p += 1) params.push(rendered.params[p]);
|
|
2267
|
+
}
|
|
2268
|
+
return { sql: cells.join(", "), params: params };
|
|
2269
|
+
}
|
|
2270
|
+
|
|
2271
|
+
_render() {
|
|
2272
|
+
if (this._columns === null || this._values === null) {
|
|
2273
|
+
throw _err("upsert requires columns + values()", "sql-builder/empty-values");
|
|
2274
|
+
}
|
|
2275
|
+
if (this._action === null) {
|
|
2276
|
+
throw _err("upsert requires a conflict action - doUpdate(...) / " +
|
|
2277
|
+
"doUpdateFromExcluded(...) / doNothing()", "sql-builder/conflict-action");
|
|
2278
|
+
}
|
|
2279
|
+
if (this._action !== "nothing" && this._conflictKeys === null && this._dialect !== "mysql") {
|
|
2280
|
+
throw _err("upsert doUpdate requires onConflict(keys) on " + this._dialect,
|
|
2281
|
+
"sql-builder/bad-conflict");
|
|
2282
|
+
}
|
|
2283
|
+
return this._dialect === "mysql" ? this._renderMysql() : this._renderStandard();
|
|
2284
|
+
}
|
|
2285
|
+
|
|
2286
|
+
// Postgres + SQLite: ON CONFLICT (keys) DO UPDATE ... [WHERE] [RETURNING].
|
|
2287
|
+
_renderStandard() {
|
|
2288
|
+
var dialect = this._dialect;
|
|
2289
|
+
var quotedCols = this._columns.map(function (c) { return _quoteId(c, dialect); }).join(", ");
|
|
2290
|
+
var tuple = this._renderValuesTuple(dialect);
|
|
2291
|
+
var params = tuple.params;
|
|
2292
|
+
|
|
2293
|
+
var sql = "INSERT INTO " + this._table.ref(dialect) + " (" + quotedCols + ") VALUES (" +
|
|
2294
|
+
tuple.sql + ")";
|
|
2295
|
+
|
|
2296
|
+
if (this._action === "nothing") {
|
|
2297
|
+
sql += " ON CONFLICT" + this._conflictTarget(dialect) + " DO NOTHING";
|
|
2298
|
+
} else {
|
|
2299
|
+
var setClause = this._buildStandardSet(dialect);
|
|
2300
|
+
sql += " ON CONFLICT" + this._conflictTarget(dialect) + " DO UPDATE SET " + setClause.sql;
|
|
2301
|
+
for (var i = 0; i < setClause.params.length; i++) params.push(setClause.params[i]);
|
|
2302
|
+
if (this._conflictWhere) {
|
|
2303
|
+
sql += " WHERE " + this._conflictWhere.sql;
|
|
2304
|
+
for (var w = 0; w < this._conflictWhere.params.length; w++) params.push(this._conflictWhere.params[w]);
|
|
2305
|
+
}
|
|
2306
|
+
}
|
|
2307
|
+
sql += _renderReturning(this._returning, dialect);
|
|
2308
|
+
return _emit(sql, params);
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2311
|
+
_conflictTarget(dialect) {
|
|
2312
|
+
if (this._conflictKeys === null) return "";
|
|
2313
|
+
var keys = this._conflictKeys.map(function (k) { return _quoteId(k, dialect); }).join(", ");
|
|
2314
|
+
return " (" + keys + ")";
|
|
2315
|
+
}
|
|
2316
|
+
|
|
2317
|
+
_buildStandardSet(dialect) {
|
|
2318
|
+
var pieces = [];
|
|
2319
|
+
var params = [];
|
|
2320
|
+
if (this._action === "update-excluded") {
|
|
2321
|
+
for (var i = 0; i < this._updateCols.length; i++) {
|
|
2322
|
+
var c = this._updateCols[i];
|
|
2323
|
+
pieces.push(_quoteId(c, dialect) + " = EXCLUDED." + _quoteId(c, dialect));
|
|
2324
|
+
}
|
|
2325
|
+
} else {
|
|
2326
|
+
// action === "update": { col: expr } map. "?" re-binds to a param;
|
|
2327
|
+
// any other string is a guarded raw expression.
|
|
2328
|
+
var keys = Object.keys(this._updateExprs);
|
|
2329
|
+
var paramCursor = 0;
|
|
2330
|
+
for (var k = 0; k < keys.length; k++) {
|
|
2331
|
+
var col = keys[k];
|
|
2332
|
+
var expr = this._updateExprs[col];
|
|
2333
|
+
if (expr === "?") {
|
|
2334
|
+
pieces.push(_quoteId(col, dialect) + " = ?");
|
|
2335
|
+
params.push(this._updateParams[paramCursor]);
|
|
2336
|
+
paramCursor += 1;
|
|
2337
|
+
} else if (typeof expr === "string") {
|
|
2338
|
+
// Guarded raw expression (e.g. "EXCLUDED.\"count\" + 1"). Its
|
|
2339
|
+
// own `?` placeholders draw from _updateParams in order.
|
|
2340
|
+
var remaining = this._updateParams.slice(paramCursor);
|
|
2341
|
+
var needed = _countPlaceholders(expr);
|
|
2342
|
+
var exprParams = remaining.slice(0, needed);
|
|
2343
|
+
var checked = _checkRawFragment(expr, exprParams, { allowLiterals: false }, "doUpdate");
|
|
2344
|
+
pieces.push(_quoteId(col, dialect) + " = " + checked.sql);
|
|
2345
|
+
for (var ep = 0; ep < checked.params.length; ep++) params.push(checked.params[ep]);
|
|
2346
|
+
paramCursor += needed;
|
|
2347
|
+
} else {
|
|
2348
|
+
throw _err("doUpdate expression for '" + col + "' must be '?' or a raw SQL string",
|
|
2349
|
+
"sql-builder/conflict-action");
|
|
2350
|
+
}
|
|
2351
|
+
}
|
|
2352
|
+
}
|
|
2353
|
+
return { sql: pieces.join(", "), params: params };
|
|
2354
|
+
}
|
|
2355
|
+
|
|
2356
|
+
// MySQL: ON DUPLICATE KEY UPDATE col = VALUES(col). No WHERE, no
|
|
2357
|
+
// RETURNING. conflictWhere folds into IF(<guard>, VALUES(col), col).
|
|
2358
|
+
// The guard column is emitted LAST so MySQL's left-to-right evaluation
|
|
2359
|
+
// of the SET list sees the other columns' pre-guard values when the
|
|
2360
|
+
// guard references them (the IF-eval-order hazard). A readbackSql
|
|
2361
|
+
// SELECT is returned alongside so the caller can fetch the row that
|
|
2362
|
+
// RETURNING would have surfaced.
|
|
2363
|
+
_renderMysql() {
|
|
2364
|
+
var dialect = "mysql";
|
|
2365
|
+
var quotedCols = this._columns.map(function (c) { return _quoteId(c, dialect); }).join(", ");
|
|
2366
|
+
var tuple = this._renderValuesTuple(dialect);
|
|
2367
|
+
var params = tuple.params;
|
|
2368
|
+
|
|
2369
|
+
var sql = "INSERT INTO " + this._table.ref(dialect) + " (" + quotedCols + ") VALUES (" +
|
|
2370
|
+
tuple.sql + ")";
|
|
2371
|
+
|
|
2372
|
+
if (this._action === "nothing") {
|
|
2373
|
+
// MySQL has no DO NOTHING; the idiom is to no-op a key column.
|
|
2374
|
+
// Assign the first conflict / first column to itself so the row is
|
|
2375
|
+
// left unchanged on duplicate.
|
|
2376
|
+
var noopCol = (this._conflictKeys && this._conflictKeys[0]) || this._columns[0];
|
|
2377
|
+
sql += " ON DUPLICATE KEY UPDATE " + _quoteId(noopCol, dialect) + " = " +
|
|
2378
|
+
_quoteId(noopCol, dialect);
|
|
2379
|
+
} else {
|
|
2380
|
+
var setBuild = this._buildMysqlSet(dialect);
|
|
2381
|
+
sql += " ON DUPLICATE KEY UPDATE " + setBuild.sql;
|
|
2382
|
+
for (var i = 0; i < setBuild.params.length; i++) params.push(setBuild.params[i]);
|
|
2383
|
+
}
|
|
2384
|
+
|
|
2385
|
+
var out = _emit(sql, params);
|
|
2386
|
+
// RETURNING is unavailable on MySQL upsert - emit a readback SELECT
|
|
2387
|
+
// keyed on the conflict columns so the caller fetches the row. Validate
|
|
2388
|
+
// it through the same output gate.
|
|
2389
|
+
if (this._returning !== null) {
|
|
2390
|
+
var rb = this._buildReadback(dialect);
|
|
2391
|
+
_assertEmittable(rb.sql, rb.params);
|
|
2392
|
+
out.readbackSql = rb;
|
|
2393
|
+
}
|
|
2394
|
+
return out;
|
|
2395
|
+
}
|
|
2396
|
+
|
|
2397
|
+
_buildMysqlSet(dialect) {
|
|
2398
|
+
var guardSqlText = this._conflictWhere ? this._conflictWhere.sql : null;
|
|
2399
|
+
var guardParams = this._conflictWhere ? this._conflictWhere.params : [];
|
|
2400
|
+
|
|
2401
|
+
// Resolve the ordered (col, assignment-RHS) list WITHOUT the guard
|
|
2402
|
+
// wrap first; then, when a guard is present, wrap each RHS in
|
|
2403
|
+
// IF(<guard>, <rhs>, col) and order the guard column (a column the
|
|
2404
|
+
// guard references, if it is itself a set target) LAST.
|
|
2405
|
+
var assignments = []; // [{ col, rhs, rhsParams }]
|
|
2406
|
+
if (this._action === "update-excluded") {
|
|
2407
|
+
for (var i = 0; i < this._updateCols.length; i++) {
|
|
2408
|
+
var c = this._updateCols[i];
|
|
2409
|
+
assignments.push({ col: c, rhs: "VALUES(" + _quoteId(c, dialect) + ")", rhsParams: [] });
|
|
2410
|
+
}
|
|
2411
|
+
} else {
|
|
2412
|
+
var keys = Object.keys(this._updateExprs);
|
|
2413
|
+
var paramCursor = 0;
|
|
2414
|
+
for (var k = 0; k < keys.length; k++) {
|
|
2415
|
+
var col = keys[k];
|
|
2416
|
+
var expr = this._updateExprs[col];
|
|
2417
|
+
if (expr === "?") {
|
|
2418
|
+
assignments.push({ col: col, rhs: "?", rhsParams: [this._updateParams[paramCursor]] });
|
|
2419
|
+
paramCursor += 1;
|
|
2420
|
+
} else if (typeof expr === "string") {
|
|
2421
|
+
var needed = _countPlaceholders(expr);
|
|
2422
|
+
var exprParams = this._updateParams.slice(paramCursor, paramCursor + needed);
|
|
2423
|
+
var checked = _checkRawFragment(expr, exprParams, { allowLiterals: false }, "doUpdate");
|
|
2424
|
+
assignments.push({ col: col, rhs: checked.sql, rhsParams: checked.params });
|
|
2425
|
+
paramCursor += needed;
|
|
2426
|
+
} else {
|
|
2427
|
+
throw _err("doUpdate expression for '" + col + "' must be '?' or a raw SQL string",
|
|
2428
|
+
"sql-builder/conflict-action");
|
|
2429
|
+
}
|
|
2430
|
+
}
|
|
2431
|
+
}
|
|
2432
|
+
|
|
2433
|
+
var pieces = [];
|
|
2434
|
+
var params = [];
|
|
2435
|
+
if (guardSqlText === null) {
|
|
2436
|
+
for (var a = 0; a < assignments.length; a++) {
|
|
2437
|
+
pieces.push(_quoteId(assignments[a].col, dialect) + " = " + assignments[a].rhs);
|
|
2438
|
+
for (var ap = 0; ap < assignments[a].rhsParams.length; ap++) params.push(assignments[a].rhsParams[ap]);
|
|
2439
|
+
}
|
|
2440
|
+
return { sql: pieces.join(", "), params: params };
|
|
2441
|
+
}
|
|
2442
|
+
|
|
2443
|
+
// Guarded: col = IF(<guard>, <rhs>, col). The guard's own params are
|
|
2444
|
+
// bound once per assignment (the guard expression repeats per SET
|
|
2445
|
+
// target in MySQL's UPDATE list). The guard column - the column the
|
|
2446
|
+
// fenced comparison protects - is emitted last so the IF on the
|
|
2447
|
+
// other columns evaluates against this column's pre-update value.
|
|
2448
|
+
var guardColName = this._conflictWhere && this._conflictWhere.guardColumn
|
|
2449
|
+
? this._conflictWhere.guardColumn : null;
|
|
2450
|
+
var ordered = assignments.slice();
|
|
2451
|
+
if (guardColName) {
|
|
2452
|
+
ordered.sort(function (x, y) {
|
|
2453
|
+
var xg = x.col === guardColName ? 1 : 0;
|
|
2454
|
+
var yg = y.col === guardColName ? 1 : 0;
|
|
2455
|
+
return xg - yg;
|
|
2456
|
+
});
|
|
2457
|
+
}
|
|
2458
|
+
for (var o = 0; o < ordered.length; o++) {
|
|
2459
|
+
var qc = _quoteId(ordered[o].col, dialect);
|
|
2460
|
+
pieces.push(qc + " = IF(" + guardSqlText + ", " + ordered[o].rhs + ", " + qc + ")");
|
|
2461
|
+
for (var gp = 0; gp < guardParams.length; gp++) params.push(guardParams[gp]);
|
|
2462
|
+
for (var rp = 0; rp < ordered[o].rhsParams.length; rp++) params.push(ordered[o].rhsParams[rp]);
|
|
2463
|
+
}
|
|
2464
|
+
return { sql: pieces.join(", "), params: params };
|
|
2465
|
+
}
|
|
2466
|
+
|
|
2467
|
+
// Readback SELECT for the MySQL upsert path - fetch the upserted row by
|
|
2468
|
+
// its conflict key(s) bound to the proposed values, projecting the
|
|
2469
|
+
// RETURNING column list (or "*").
|
|
2470
|
+
_buildReadback(dialect) {
|
|
2471
|
+
var keys = this._conflictKeys || [];
|
|
2472
|
+
if (keys.length === 0) {
|
|
2473
|
+
// No declared conflict key - read back by the full proposed row's
|
|
2474
|
+
// first column as a best-effort key.
|
|
2475
|
+
keys = [this._columns[0]];
|
|
2476
|
+
}
|
|
2477
|
+
var proj = (this._returning === "*" || this._returning === null)
|
|
2478
|
+
? "*"
|
|
2479
|
+
: this._returning.map(function (c) { return _quoteId(c, dialect); }).join(", ");
|
|
2480
|
+
var sql = "SELECT " + proj + " FROM " + this._table.ref(dialect);
|
|
2481
|
+
var params = [];
|
|
2482
|
+
var conds = [];
|
|
2483
|
+
for (var i = 0; i < keys.length; i++) {
|
|
2484
|
+
var idx = this._columns.indexOf(keys[i]);
|
|
2485
|
+
if (idx === -1) {
|
|
2486
|
+
throw _err("upsert readback: conflict key '" + keys[i] + "' is not in the value set",
|
|
2487
|
+
"sql-builder/bad-conflict");
|
|
2488
|
+
}
|
|
2489
|
+
conds.push(_quoteId(keys[i], dialect) + " = ?");
|
|
2490
|
+
params.push(this._values[idx]);
|
|
2491
|
+
}
|
|
2492
|
+
sql += " WHERE " + conds.join(" AND ");
|
|
2493
|
+
return { sql: sql, params: params };
|
|
2494
|
+
}
|
|
2495
|
+
}
|
|
2496
|
+
|
|
2497
|
+
// RETURNING normalization - "*" or an array of validated columns.
|
|
2498
|
+
function _normReturning(cols) {
|
|
2499
|
+
if (cols === "*" || cols === undefined || cols === null) return "*";
|
|
2500
|
+
var arr = Array.isArray(cols) ? cols : [cols];
|
|
2501
|
+
arr.forEach(_validateColumn);
|
|
2502
|
+
return arr.slice();
|
|
2503
|
+
}
|
|
2504
|
+
|
|
2505
|
+
function _renderReturning(returning, dialect) {
|
|
2506
|
+
if (returning === null) return "";
|
|
2507
|
+
// MySQL / MariaDB do not support RETURNING on INSERT / UPDATE / DELETE.
|
|
2508
|
+
// Emitting it would parse-error at the driver; refuse at build with a
|
|
2509
|
+
// clear message so the operator runs an explicit read-back SELECT.
|
|
2510
|
+
// (The upsert verb's MySQL path already auto-emits a readback instead of
|
|
2511
|
+
// reaching here.)
|
|
2512
|
+
if (dialect === "mysql") {
|
|
2513
|
+
throw _err("RETURNING is not supported on MySQL for this verb - run a " +
|
|
2514
|
+
"read-back SELECT on the affected key instead", "sql-builder/returning-unsupported");
|
|
2515
|
+
}
|
|
2516
|
+
if (returning === "*") return " RETURNING *";
|
|
2517
|
+
return " RETURNING " + returning.map(function (c) { return _quoteId(c, dialect); }).join(", ");
|
|
2518
|
+
}
|
|
2519
|
+
|
|
2520
|
+
// ---- DDL builders ---------------------------------------------------
|
|
2521
|
+
//
|
|
2522
|
+
// Operator app-schema parity: createTable / createIndex / alterTable /
|
|
2523
|
+
// dropTable, dialect-aware and quote-by-construction, reusing the
|
|
2524
|
+
// framework's own type vocabulary (no fork of the type map). DDL is
|
|
2525
|
+
// declarative - these return { sql } (no params) since DDL binds no
|
|
2526
|
+
// values.
|
|
2527
|
+
|
|
2528
|
+
/**
|
|
2529
|
+
* @primitive b.sql.createTable
|
|
2530
|
+
* @signature b.sql.createTable(name, columns, opts?)
|
|
2531
|
+
* @since 0.14.29
|
|
2532
|
+
* @status stable
|
|
2533
|
+
* @related b.sql.createIndex, b.sql.alterTable, b.sql.dropTable
|
|
2534
|
+
*
|
|
2535
|
+
* Build a `CREATE TABLE` statement with every identifier quoted by
|
|
2536
|
+
* construction and every column type drawn from the framework's own
|
|
2537
|
+
* type map (so an operator app-schema table is portable across the same
|
|
2538
|
+
* dialects the framework tables are). `columns` is an array of column
|
|
2539
|
+
* specs; each `{ name, type, constraints?, primaryKey?, notNull?,
|
|
2540
|
+
* unique?, default? }`. The `type` is a logical name (`int` / `text` /
|
|
2541
|
+
* `blob` / `boolean` / `real` / `numeric` / `timestamp` / `json`) mapped
|
|
2542
|
+
* to the dialect token, or a verbatim dialect type string. Emits
|
|
2543
|
+
* `IF NOT EXISTS` by default so re-running is idempotent.
|
|
2544
|
+
*
|
|
2545
|
+
* @opts
|
|
2546
|
+
* dialect: string, // postgres | sqlite | mysql (default sqlite)
|
|
2547
|
+
* ifNotExists: boolean, // default true
|
|
2548
|
+
* primaryKey: array, // composite PK column list (table-level)
|
|
2549
|
+
*
|
|
2550
|
+
* @example
|
|
2551
|
+
* var b = require("@blamejs/core");
|
|
2552
|
+
* b.sql.createTable("widget", [
|
|
2553
|
+
* { name: "id", type: "int", primaryKey: true },
|
|
2554
|
+
* { name: "name", type: "text", notNull: true },
|
|
2555
|
+
* ], { dialect: "postgres" }).sql;
|
|
2556
|
+
* // -> 'CREATE TABLE IF NOT EXISTS widget ("id" BIGINT PRIMARY KEY, "name" TEXT NOT NULL)'
|
|
2557
|
+
* // (the bare default table name is the clusterStorage rewrite
|
|
2558
|
+
* // target; pass a prefix or schema to quote it)
|
|
2559
|
+
*/
|
|
2560
|
+
function createTable(name, columns, opts) {
|
|
2561
|
+
opts = opts || {};
|
|
2562
|
+
var dialect = _normDialect(opts.dialect);
|
|
2563
|
+
var ref = _normTableRef(name, opts);
|
|
2564
|
+
if (!Array.isArray(columns) || columns.length === 0) {
|
|
2565
|
+
throw _err("createTable requires a non-empty columns array", "sql-builder/bad-columns");
|
|
2566
|
+
}
|
|
2567
|
+
var pieces = columns.map(function (c) {
|
|
2568
|
+
if (typeof c !== "object" || c === null || typeof c.name !== "string") {
|
|
2569
|
+
throw _err("createTable column must be { name, type, ... }", "sql-builder/bad-column");
|
|
2570
|
+
}
|
|
2571
|
+
_validateColumn(c.name);
|
|
2572
|
+
var qn = _quoteId(c.name, dialect);
|
|
2573
|
+
// Auto-increment / identity PK. This MUST diverge by dialect or an app
|
|
2574
|
+
// developed on the default sqlite dialect (where INTEGER PRIMARY KEY is
|
|
2575
|
+
// a rowid alias that auto-increments implicitly) breaks on the
|
|
2576
|
+
// postgres / mysql backend the builder advertises portability to (a
|
|
2577
|
+
// plain BIGINT PRIMARY KEY there does NOT default a value). postgres ->
|
|
2578
|
+
// BIGSERIAL (implies the int type + sequence default); sqlite -> INTEGER
|
|
2579
|
+
// PRIMARY KEY AUTOINCREMENT (MUST be INTEGER, not BIGINT); mysql ->
|
|
2580
|
+
// BIGINT AUTO_INCREMENT. An identity column is the primary key and takes
|
|
2581
|
+
// no DEFAULT.
|
|
2582
|
+
if (c.autoIncrement || c.serial) {
|
|
2583
|
+
if (c.default !== undefined) {
|
|
2584
|
+
throw _err("createTable: auto-increment column '" + c.name +
|
|
2585
|
+
"' cannot also declare a default", "sql-builder/bad-column");
|
|
2586
|
+
}
|
|
2587
|
+
var idDef;
|
|
2588
|
+
if (dialect === "postgres") idDef = qn + " BIGSERIAL PRIMARY KEY";
|
|
2589
|
+
else if (dialect === "mysql") idDef = qn + " BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY";
|
|
2590
|
+
else idDef = qn + " INTEGER PRIMARY KEY AUTOINCREMENT";
|
|
2591
|
+
if (typeof c.constraints === "string" && c.constraints.length > 0) {
|
|
2592
|
+
var idCk = _checkRawFragment(c.constraints, [], { allowLiterals: true }, "createTable.constraints");
|
|
2593
|
+
idDef += " " + idCk.sql;
|
|
2594
|
+
}
|
|
2595
|
+
return idDef;
|
|
2596
|
+
}
|
|
2597
|
+
var def = qn + " " + _ddlType(c.type, dialect);
|
|
2598
|
+
if (c.primaryKey) def += " PRIMARY KEY";
|
|
2599
|
+
if (c.notNull) def += " NOT NULL";
|
|
2600
|
+
if (c.unique) def += " UNIQUE";
|
|
2601
|
+
if (c.default !== undefined) def += " DEFAULT " + _ddlDefault(c.default);
|
|
2602
|
+
// Foreign key: a quote-by-construction REFERENCES clause (string table
|
|
2603
|
+
// name or { table, column?, onDelete?, onUpdate? }). Identifiers are
|
|
2604
|
+
// validated + quoted; the referential actions are allowlisted.
|
|
2605
|
+
if (c.references !== undefined && c.references !== false) {
|
|
2606
|
+
def += _ddlReferences(c.references, dialect, opts);
|
|
2607
|
+
}
|
|
2608
|
+
if (typeof c.constraints === "string" && c.constraints.length > 0) {
|
|
2609
|
+
// Verbatim constraint clause (CHECK / REFERENCES). Guarded so an
|
|
2610
|
+
// operator-influenced constraint can't smuggle a statement.
|
|
2611
|
+
var checked = _checkRawFragment(c.constraints, [], { allowLiterals: true }, "createTable.constraints");
|
|
2612
|
+
def += " " + checked.sql;
|
|
2613
|
+
}
|
|
2614
|
+
return def;
|
|
2615
|
+
});
|
|
2616
|
+
if (Array.isArray(opts.primaryKey) && opts.primaryKey.length > 0) {
|
|
2617
|
+
opts.primaryKey.forEach(_validateColumn);
|
|
2618
|
+
pieces.push("PRIMARY KEY (" + opts.primaryKey.map(function (k) {
|
|
2619
|
+
return _quoteId(k, dialect);
|
|
2620
|
+
}).join(", ") + ")");
|
|
2621
|
+
}
|
|
2622
|
+
var ifNot = opts.ifNotExists === false ? "" : "IF NOT EXISTS ";
|
|
2623
|
+
var sql = "CREATE TABLE " + ifNot + ref.ref(dialect) + " (" + pieces.join(", ") + ")";
|
|
2624
|
+
return { sql: sql, params: [] };
|
|
2625
|
+
}
|
|
2626
|
+
|
|
2627
|
+
// DDL DEFAULT renderer - numeric / boolean / null inline; a string
|
|
2628
|
+
// default is emitted as a single-quoted SQL literal with the quote
|
|
2629
|
+
// doubled to escape it (DDL defaults are static, operator-controlled,
|
|
2630
|
+
// and never bound).
|
|
2631
|
+
function _ddlDefault(value) {
|
|
2632
|
+
if (value === null) return "NULL";
|
|
2633
|
+
if (typeof value === "number" && isFinite(value)) return String(value);
|
|
2634
|
+
if (typeof value === "boolean") return value ? "TRUE" : "FALSE";
|
|
2635
|
+
if (typeof value === "string") return "'" + value.replace(/'/g, "''") + "'";
|
|
2636
|
+
throw _err("createTable column default must be a string, number, boolean, or null",
|
|
2637
|
+
"sql-builder/bad-default");
|
|
2638
|
+
}
|
|
2639
|
+
|
|
2640
|
+
// Referential actions allowed on a foreign key (ON DELETE / ON UPDATE).
|
|
2641
|
+
var FK_ACTIONS = Object.freeze({
|
|
2642
|
+
"CASCADE": true, "SET NULL": true, "SET DEFAULT": true, "RESTRICT": true, "NO ACTION": true,
|
|
2643
|
+
});
|
|
2644
|
+
|
|
2645
|
+
// Quote-by-construction REFERENCES clause. `references` is a table-name
|
|
2646
|
+
// string (referenced column defaults to "id") or { table, column?, onDelete?,
|
|
2647
|
+
// onUpdate? }. The referenced table inherits the parent table's prefix /
|
|
2648
|
+
// schema so a prefixed deployment's FK target resolves to the same namespace.
|
|
2649
|
+
function _ddlReferences(references, dialect, opts) {
|
|
2650
|
+
var spec = typeof references === "string" ? { table: references } : references;
|
|
2651
|
+
if (!spec || typeof spec.table !== "string" || spec.table.length === 0) {
|
|
2652
|
+
throw _err("column 'references' must be a table name or { table, column?, onDelete?, onUpdate? }",
|
|
2653
|
+
"sql-builder/bad-references");
|
|
2654
|
+
}
|
|
2655
|
+
var refTable = _normTableRef(spec.table, opts || {});
|
|
2656
|
+
var refCol = spec.column || "id";
|
|
2657
|
+
_validateColumn(refCol);
|
|
2658
|
+
var out = " REFERENCES " + refTable.ref(dialect) + " (" + _quoteId(refCol, dialect) + ")";
|
|
2659
|
+
["onDelete", "onUpdate"].forEach(function (k) {
|
|
2660
|
+
if (spec[k] === undefined || spec[k] === null) return;
|
|
2661
|
+
var action = String(spec[k]).toUpperCase();
|
|
2662
|
+
if (FK_ACTIONS[action] !== true) {
|
|
2663
|
+
throw _err("invalid " + k + " referential action '" + spec[k] +
|
|
2664
|
+
"' (CASCADE / SET NULL / SET DEFAULT / RESTRICT / NO ACTION)", "sql-builder/bad-fk-action");
|
|
2665
|
+
}
|
|
2666
|
+
out += (k === "onDelete" ? " ON DELETE " : " ON UPDATE ") + action;
|
|
2667
|
+
});
|
|
2668
|
+
return out;
|
|
2669
|
+
}
|
|
2670
|
+
|
|
2671
|
+
/**
|
|
2672
|
+
* @primitive b.sql.createIndex
|
|
2673
|
+
* @signature b.sql.createIndex(name, tableName, columns, opts?)
|
|
2674
|
+
* @since 0.14.29
|
|
2675
|
+
* @status stable
|
|
2676
|
+
* @related b.sql.createTable, b.sql.dropTable
|
|
2677
|
+
*
|
|
2678
|
+
* Build a `CREATE INDEX` statement, identifiers quoted by construction,
|
|
2679
|
+
* `IF NOT EXISTS` by default. `columns` is the indexed column list (each
|
|
2680
|
+
* quoted); `opts.unique` emits a `UNIQUE INDEX`.
|
|
2681
|
+
*
|
|
2682
|
+
* @opts
|
|
2683
|
+
* dialect: string, // postgres | sqlite | mysql (default sqlite)
|
|
2684
|
+
* unique: boolean, // default false
|
|
2685
|
+
* ifNotExists: boolean, // default true
|
|
2686
|
+
* where: string, // partial-index predicate (guarded raw fragment)
|
|
2687
|
+
* whereParams: Array, // bound params for the partial-index predicate
|
|
2688
|
+
*
|
|
2689
|
+
* A partial index (`opts.where`) narrows the index to rows matching a
|
|
2690
|
+
* boolean predicate - the publisher's pending-row index
|
|
2691
|
+
* (`WHERE status = 'pending'`) is the canonical case. The predicate rides
|
|
2692
|
+
* the same `b.guardSql`-gated raw-fragment path as `whereRaw` (a static
|
|
2693
|
+
* operator-controlled literal opts in via `allowLiterals`); MySQL has no
|
|
2694
|
+
* partial index, so it throws there.
|
|
2695
|
+
*
|
|
2696
|
+
* @example
|
|
2697
|
+
* var b = require("@blamejs/core");
|
|
2698
|
+
* b.sql.createIndex("idx_widget_name", "widget", ["name"],
|
|
2699
|
+
* { dialect: "sqlite", unique: true }).sql;
|
|
2700
|
+
* // -> 'CREATE UNIQUE INDEX IF NOT EXISTS "idx_widget_name" ON widget ("name")'
|
|
2701
|
+
* // (the index name is quoted; the bare default table stays the
|
|
2702
|
+
* // clusterStorage rewrite target)
|
|
2703
|
+
*/
|
|
2704
|
+
function createIndex(name, tableName, columns, opts) {
|
|
2705
|
+
opts = opts || {};
|
|
2706
|
+
var dialect = _normDialect(opts.dialect);
|
|
2707
|
+
_validateColumn(name);
|
|
2708
|
+
var ref = _normTableRef(tableName, opts);
|
|
2709
|
+
if (!Array.isArray(columns) || columns.length === 0) {
|
|
2710
|
+
throw _err("createIndex requires a non-empty columns array", "sql-builder/bad-columns");
|
|
2711
|
+
}
|
|
2712
|
+
columns.forEach(_validateColumn);
|
|
2713
|
+
var ifNot = opts.ifNotExists === false ? "" : "IF NOT EXISTS ";
|
|
2714
|
+
var cols = columns.map(function (c) { return _quoteId(c, dialect); }).join(", ");
|
|
2715
|
+
var sql = "CREATE " + (opts.unique ? "UNIQUE " : "") + "INDEX " + ifNot +
|
|
2716
|
+
_quoteId(name, dialect) + " ON " + ref.ref(dialect) + " (" + cols + ")";
|
|
2717
|
+
var params = [];
|
|
2718
|
+
if (opts.where !== undefined && opts.where !== null) {
|
|
2719
|
+
if (dialect === "mysql") {
|
|
2720
|
+
throw _err("createIndex: partial index (where) is Postgres / SQLite-only " +
|
|
2721
|
+
"(MySQL has no partial index)", "sql-builder/partial-index-unsupported");
|
|
2722
|
+
}
|
|
2723
|
+
var checked = _checkRawFragment(opts.where, opts.whereParams,
|
|
2724
|
+
{ allowLiterals: opts.allowLiterals !== false }, "createIndex.where");
|
|
2725
|
+
sql += " WHERE " + checked.sql;
|
|
2726
|
+
params = checked.params;
|
|
2727
|
+
}
|
|
2728
|
+
return { sql: sql, params: params };
|
|
2729
|
+
}
|
|
2730
|
+
|
|
2731
|
+
/**
|
|
2732
|
+
* @primitive b.sql.alterTable
|
|
2733
|
+
* @signature b.sql.alterTable(name, change, opts?)
|
|
2734
|
+
* @since 0.14.29
|
|
2735
|
+
* @status stable
|
|
2736
|
+
* @related b.sql.createTable, b.sql.dropTable
|
|
2737
|
+
*
|
|
2738
|
+
* Build an `ALTER TABLE` statement. `change` is one of
|
|
2739
|
+
* `{ addColumn: { name, type, ... } }`,
|
|
2740
|
+
* `{ dropColumn: "name" }`, or
|
|
2741
|
+
* `{ renameColumn: { from, to } }` - each identifier quoted, the
|
|
2742
|
+
* add-column type drawn from the framework type map.
|
|
2743
|
+
*
|
|
2744
|
+
* @opts
|
|
2745
|
+
* dialect: string, // postgres | sqlite | mysql (default sqlite)
|
|
2746
|
+
*
|
|
2747
|
+
* @example
|
|
2748
|
+
* var b = require("@blamejs/core");
|
|
2749
|
+
* b.sql.alterTable("widget", { addColumn: { name: "active", type: "boolean" } },
|
|
2750
|
+
* { dialect: "postgres" }).sql;
|
|
2751
|
+
* // -> 'ALTER TABLE widget ADD COLUMN "active" BOOLEAN'
|
|
2752
|
+
* // (bare default table name; the added column is quoted)
|
|
2753
|
+
*/
|
|
2754
|
+
function alterTable(name, change, opts) {
|
|
2755
|
+
opts = opts || {};
|
|
2756
|
+
var dialect = _normDialect(opts.dialect);
|
|
2757
|
+
var ref = _normTableRef(name, opts);
|
|
2758
|
+
if (!change || typeof change !== "object") {
|
|
2759
|
+
throw _err("alterTable requires a change descriptor", "sql-builder/bad-alter");
|
|
2760
|
+
}
|
|
2761
|
+
var head = "ALTER TABLE " + ref.ref(dialect) + " ";
|
|
2762
|
+
if (change.addColumn) {
|
|
2763
|
+
var col = change.addColumn;
|
|
2764
|
+
if (typeof col.name !== "string") throw _err("addColumn requires a name", "sql-builder/bad-column");
|
|
2765
|
+
_validateColumn(col.name);
|
|
2766
|
+
var def = _quoteId(col.name, dialect) + " " + _ddlType(col.type, dialect);
|
|
2767
|
+
if (col.notNull) def += " NOT NULL";
|
|
2768
|
+
if (col.unique) def += " UNIQUE";
|
|
2769
|
+
if (col.default !== undefined) def += " DEFAULT " + _ddlDefault(col.default);
|
|
2770
|
+
return { sql: head + "ADD COLUMN " + def, params: [] };
|
|
2771
|
+
}
|
|
2772
|
+
if (change.dropColumn) {
|
|
2773
|
+
_validateColumn(change.dropColumn);
|
|
2774
|
+
return { sql: head + "DROP COLUMN " + _quoteId(change.dropColumn, dialect), params: [] };
|
|
2775
|
+
}
|
|
2776
|
+
if (change.renameColumn) {
|
|
2777
|
+
var rc = change.renameColumn;
|
|
2778
|
+
if (typeof rc.from !== "string" || typeof rc.to !== "string") {
|
|
2779
|
+
throw _err("renameColumn requires { from, to }", "sql-builder/bad-alter");
|
|
2780
|
+
}
|
|
2781
|
+
_validateColumn(rc.from);
|
|
2782
|
+
_validateColumn(rc.to);
|
|
2783
|
+
return {
|
|
2784
|
+
sql: head + "RENAME COLUMN " + _quoteId(rc.from, dialect) + " TO " + _quoteId(rc.to, dialect),
|
|
2785
|
+
params: [],
|
|
2786
|
+
};
|
|
2787
|
+
}
|
|
2788
|
+
throw _err("alterTable change must be addColumn / dropColumn / renameColumn",
|
|
2789
|
+
"sql-builder/bad-alter");
|
|
2790
|
+
}
|
|
2791
|
+
|
|
2792
|
+
/**
|
|
2793
|
+
* @primitive b.sql.dropTable
|
|
2794
|
+
* @signature b.sql.dropTable(name, opts?)
|
|
2795
|
+
* @since 0.14.29
|
|
2796
|
+
* @status stable
|
|
2797
|
+
* @related b.sql.createTable, b.sql.alterTable
|
|
2798
|
+
*
|
|
2799
|
+
* Build a `DROP TABLE` statement, identifier quoted, `IF EXISTS` by
|
|
2800
|
+
* default so dropping a missing table is a no-op.
|
|
2801
|
+
*
|
|
2802
|
+
* @opts
|
|
2803
|
+
* dialect: string, // postgres | sqlite | mysql (default sqlite)
|
|
2804
|
+
* ifExists: boolean, // default true
|
|
2805
|
+
* cascade: boolean, // default false (Postgres CASCADE)
|
|
2806
|
+
*
|
|
2807
|
+
* @example
|
|
2808
|
+
* var b = require("@blamejs/core");
|
|
2809
|
+
* b.sql.dropTable("widget", { dialect: "postgres", cascade: true }).sql;
|
|
2810
|
+
* // -> 'DROP TABLE IF EXISTS widget CASCADE'
|
|
2811
|
+
* // (bare default table name; the clusterStorage rewrite target)
|
|
2812
|
+
*/
|
|
2813
|
+
function dropTable(name, opts) {
|
|
2814
|
+
opts = opts || {};
|
|
2815
|
+
var dialect = _normDialect(opts.dialect);
|
|
2816
|
+
var ref = _normTableRef(name, opts);
|
|
2817
|
+
var ifExists = opts.ifExists === false ? "" : "IF EXISTS ";
|
|
2818
|
+
var sql = "DROP TABLE " + ifExists + ref.ref(dialect);
|
|
2819
|
+
if (opts.cascade && dialect === "postgres") sql += " CASCADE";
|
|
2820
|
+
return { sql: sql, params: [] };
|
|
2821
|
+
}
|
|
2822
|
+
|
|
2823
|
+
// ---- sqlite virtual table (FTS5) ------------------------------------
|
|
2824
|
+
//
|
|
2825
|
+
// The sqlite-only virtual-table DDL b.sql's general createTable has no
|
|
2826
|
+
// form for - the FTS5 full-text index the mail store's sealed-token
|
|
2827
|
+
// search runs MATCH against. The supported module is `fts5` (the only one
|
|
2828
|
+
// a framework primitive ships against); the column list + tokenizer
|
|
2829
|
+
// option are quoted / allowlisted by construction so no operator-supplied
|
|
2830
|
+
// token reaches the DDL raw.
|
|
2831
|
+
|
|
2832
|
+
// The tokenizers an operator may name - the fixed FTS5 built-in set. A
|
|
2833
|
+
// custom tokenizer (a loadable extension) is outside the framework's
|
|
2834
|
+
// supported surface and refused, so no arbitrary token reaches the
|
|
2835
|
+
// `tokenize = '...'` option.
|
|
2836
|
+
var FTS5_TOKENIZERS = Object.freeze({
|
|
2837
|
+
"unicode61": true, "ascii": true, "porter": true, "trigram": true,
|
|
2838
|
+
});
|
|
2839
|
+
// The tokenizer ARGUMENT tokens FTS5 accepts after the tokenizer name
|
|
2840
|
+
// (e.g. `unicode61 remove_diacritics 2`). A fixed allowlist so the whole
|
|
2841
|
+
// `tokenize` option is builder-controlled end to end.
|
|
2842
|
+
var FTS5_TOKENIZER_ARGS = Object.freeze({
|
|
2843
|
+
"remove_diacritics": true, "0": true, "1": true, "2": true,
|
|
2844
|
+
"categories": true, "tokenchars": true, "separators": true, "case_sensitive": true,
|
|
2845
|
+
});
|
|
2846
|
+
|
|
2847
|
+
/**
|
|
2848
|
+
* @primitive b.sql.createVirtualTable
|
|
2849
|
+
* @signature b.sql.createVirtualTable(name, opts)
|
|
2850
|
+
* @since 0.15.0
|
|
2851
|
+
* @status stable
|
|
2852
|
+
* @related b.sql.createTable, b.sql.select, b.sql.createIndex
|
|
2853
|
+
*
|
|
2854
|
+
* Build a sqlite `CREATE VIRTUAL TABLE ... USING fts5(...)` statement for
|
|
2855
|
+
* a full-text index - the construct `b.sql.createTable` has no form for.
|
|
2856
|
+
* `opts.columns` is the FTS5 column list; each entry is a column name (a
|
|
2857
|
+
* searched column) or `{ name, unindexed: true }` (a stored-but-not-
|
|
2858
|
+
* searched column, the join key). `opts.tokenize` names a built-in FTS5
|
|
2859
|
+
* tokenizer (`unicode61` / `ascii` / `porter` / `trigram`) and optional
|
|
2860
|
+
* allowlisted arguments (`remove_diacritics 2`); a custom / loadable
|
|
2861
|
+
* tokenizer is refused. Every column name is quoted by construction and
|
|
2862
|
+
* every tokenizer token is allowlisted, so no operator-supplied token
|
|
2863
|
+
* reaches the DDL raw. `IF NOT EXISTS` by default. sqlite-only (FTS5 is a
|
|
2864
|
+
* sqlite extension); a non-sqlite dialect throws at build.
|
|
2865
|
+
*
|
|
2866
|
+
* @opts
|
|
2867
|
+
* columns: Array, // FTS5 columns: "name" | { name, unindexed }
|
|
2868
|
+
* tokenize: string, // "unicode61 remove_diacritics 2" (built-in + allowlisted args)
|
|
2869
|
+
* ifNotExists: boolean, // default true
|
|
2870
|
+
*
|
|
2871
|
+
* @example
|
|
2872
|
+
* var b = require("@blamejs/core");
|
|
2873
|
+
* b.sql.createVirtualTable("mail_fts", {
|
|
2874
|
+
* columns: [{ name: "objectid", unindexed: true }, "subject_toks", "body_toks"],
|
|
2875
|
+
* tokenize: "unicode61 remove_diacritics 2",
|
|
2876
|
+
* }).sql;
|
|
2877
|
+
* // -> 'CREATE VIRTUAL TABLE IF NOT EXISTS "mail_fts" USING fts5(' +
|
|
2878
|
+
* // '"objectid" UNINDEXED, "subject_toks", "body_toks", ' +
|
|
2879
|
+
* // "tokenize = 'unicode61 remove_diacritics 2')"
|
|
2880
|
+
*/
|
|
2881
|
+
function createVirtualTable(name, opts) {
|
|
2882
|
+
opts = opts || {};
|
|
2883
|
+
var dialect = _normDialect(opts.dialect || "sqlite");
|
|
2884
|
+
if (dialect !== "sqlite") {
|
|
2885
|
+
throw _err("createVirtualTable (USING fts5) is sqlite-only (FTS5 is a sqlite " +
|
|
2886
|
+
"extension); build it with { dialect: 'sqlite' }", "sql-builder/vtable-sqlite-only");
|
|
2887
|
+
}
|
|
2888
|
+
// The table identifier is QUOTED (this DDL targets a concrete sqlite
|
|
2889
|
+
// handle, not a clusterStorage-rewritten bare name).
|
|
2890
|
+
var ref = _normTableRef(name, Object.assign({}, opts, { quoteName: true }));
|
|
2891
|
+
if (!Array.isArray(opts.columns) || opts.columns.length === 0) {
|
|
2892
|
+
throw _err("createVirtualTable requires a non-empty columns array", "sql-builder/bad-columns");
|
|
2893
|
+
}
|
|
2894
|
+
var cols = opts.columns.map(function (c) {
|
|
2895
|
+
var colName = typeof c === "string" ? c : (c && c.name);
|
|
2896
|
+
_validateColumn(colName);
|
|
2897
|
+
var piece = _quoteId(colName, "sqlite");
|
|
2898
|
+
if (c && typeof c === "object" && c.unindexed === true) piece += " UNINDEXED";
|
|
2899
|
+
// Reject any other per-column option token (an arbitrary string would
|
|
2900
|
+
// splice into the DDL); only UNINDEXED is supported.
|
|
2901
|
+
if (c && typeof c === "object") {
|
|
2902
|
+
for (var k in c) {
|
|
2903
|
+
if (!Object.prototype.hasOwnProperty.call(c, k)) continue;
|
|
2904
|
+
if (k === "name" || k === "unindexed") continue;
|
|
2905
|
+
throw _err("createVirtualTable column option '" + k + "' is not supported " +
|
|
2906
|
+
"(only { name, unindexed } )", "sql-builder/bad-vtable-column");
|
|
2907
|
+
}
|
|
2908
|
+
}
|
|
2909
|
+
return piece;
|
|
2910
|
+
});
|
|
2911
|
+
var tokenizeClause = "";
|
|
2912
|
+
if (opts.tokenize !== undefined && opts.tokenize !== null) {
|
|
2913
|
+
tokenizeClause = ", tokenize = '" + _ftsTokenize(opts.tokenize) + "'";
|
|
2914
|
+
}
|
|
2915
|
+
var ifNot = opts.ifNotExists === false ? "" : "IF NOT EXISTS ";
|
|
2916
|
+
var sql = "CREATE VIRTUAL TABLE " + ifNot + ref.ref("sqlite") + " USING fts5(" +
|
|
2917
|
+
cols.join(", ") + tokenizeClause + ")";
|
|
2918
|
+
return { sql: sql, params: [] };
|
|
2919
|
+
}
|
|
2920
|
+
|
|
2921
|
+
// Validate + re-render an FTS5 tokenize spec from its allowlisted tokens.
|
|
2922
|
+
// The first token is the tokenizer name (built-in only); the rest are
|
|
2923
|
+
// allowlisted argument tokens. Returns the canonical space-joined string -
|
|
2924
|
+
// every token came off the allowlist, so the emitted `'...'` literal is
|
|
2925
|
+
// fully builder-controlled (no operator token reaches the DDL raw).
|
|
2926
|
+
function _ftsTokenize(spec) {
|
|
2927
|
+
if (typeof spec !== "string" || spec.length === 0) {
|
|
2928
|
+
throw _err("createVirtualTable tokenize must be a non-empty string", "sql-builder/bad-tokenize");
|
|
2929
|
+
}
|
|
2930
|
+
var tokens = spec.trim().split(/\s+/);
|
|
2931
|
+
if (FTS5_TOKENIZERS[tokens[0]] !== true) {
|
|
2932
|
+
throw _err("createVirtualTable tokenizer '" + tokens[0] + "' is not a built-in FTS5 " +
|
|
2933
|
+
"tokenizer (unicode61 / ascii / porter / trigram); a loadable tokenizer is refused",
|
|
2934
|
+
"sql-builder/bad-tokenize");
|
|
2935
|
+
}
|
|
2936
|
+
for (var i = 1; i < tokens.length; i += 1) {
|
|
2937
|
+
if (FTS5_TOKENIZER_ARGS[tokens[i]] !== true) {
|
|
2938
|
+
throw _err("createVirtualTable tokenize argument '" + tokens[i] + "' is not on the " +
|
|
2939
|
+
"allowlist", "sql-builder/bad-tokenize");
|
|
2940
|
+
}
|
|
2941
|
+
}
|
|
2942
|
+
return tokens.join(" ");
|
|
2943
|
+
}
|
|
2944
|
+
|
|
2945
|
+
// ---- Row-Level Security (Postgres RLS) ------------------------------
|
|
2946
|
+
//
|
|
2947
|
+
// Postgres-only: ENABLE ROW LEVEL SECURITY + CREATE POLICY + DROP POLICY.
|
|
2948
|
+
// Identifiers (schema / table / policy / role) are quoted by construction
|
|
2949
|
+
// through the framework's single identifier primitive; the USING /
|
|
2950
|
+
// WITH CHECK boolean predicates ride the EXISTING guardSql-gated raw-
|
|
2951
|
+
// fragment path (the same choke-point whereRaw / setRaw use), so an
|
|
2952
|
+
// operator-influenced predicate can't smuggle a stacked statement, a
|
|
2953
|
+
// string literal, or a dangerous primitive. SQLite + MySQL have no
|
|
2954
|
+
// portable RLS grammar, so every RLS builder refuses a non-Postgres
|
|
2955
|
+
// dialect at build time (config-time tier - the operator catches the
|
|
2956
|
+
// typo at boot, not at apply).
|
|
2957
|
+
|
|
2958
|
+
var RLS_COMMANDS = Object.freeze({
|
|
2959
|
+
ALL: true, SELECT: true, INSERT: true, UPDATE: true, DELETE: true,
|
|
2960
|
+
});
|
|
2961
|
+
|
|
2962
|
+
function _assertPostgresRls(dialect, what) {
|
|
2963
|
+
if (dialect !== "postgres") {
|
|
2964
|
+
throw _err(what + " is Postgres-only (SQLite / MySQL have no portable " +
|
|
2965
|
+
"row-level-security grammar); build it with { dialect: 'postgres' }",
|
|
2966
|
+
"sql-builder/rls-postgres-only");
|
|
2967
|
+
}
|
|
2968
|
+
}
|
|
2969
|
+
|
|
2970
|
+
// A USING / WITH CHECK predicate is a boolean value expression, routed
|
|
2971
|
+
// through the SAME raw-fragment guard whereRaw uses (b.guardSql strict +
|
|
2972
|
+
// the embedded-literal + placeholder-count scanners). It binds no params
|
|
2973
|
+
// by default - an RLS predicate references session GUCs / row columns, not
|
|
2974
|
+
// per-request bound values - but accepts a params array for the rare
|
|
2975
|
+
// parameterized predicate. Returns the checked { sql, params }.
|
|
2976
|
+
function _rlsPredicate(label, expr, params, opts) {
|
|
2977
|
+
return _checkRawFragment(expr, params, opts || {}, label);
|
|
2978
|
+
}
|
|
2979
|
+
|
|
2980
|
+
/**
|
|
2981
|
+
* @primitive b.sql.enableRowLevelSecurity
|
|
2982
|
+
* @signature b.sql.enableRowLevelSecurity(table, opts?)
|
|
2983
|
+
* @since 0.15.0
|
|
2984
|
+
* @status stable
|
|
2985
|
+
* @related b.sql.createPolicy, b.sql.dropPolicy, b.db.declareRowPolicy
|
|
2986
|
+
*
|
|
2987
|
+
* Build a Postgres `ALTER TABLE ... ENABLE ROW LEVEL SECURITY` statement,
|
|
2988
|
+
* the table identifier quoted by construction (schema-qualified via
|
|
2989
|
+
* `{ schema }` or the dotted `"schema.table"` form). Postgres has no
|
|
2990
|
+
* `IF NOT EXISTS` for this verb; the declarative migration in
|
|
2991
|
+
* `b.db.declareRowPolicy` checks `pg_class.relrowsecurity` and skips the
|
|
2992
|
+
* ALTER when already enabled, so re-running a partially-applied migration
|
|
2993
|
+
* set does not fail. Refuses a non-Postgres dialect at build time.
|
|
2994
|
+
*
|
|
2995
|
+
* @opts
|
|
2996
|
+
* schema: string, // schema qualifier, quoted at build time
|
|
2997
|
+
* force: boolean, // default false - emit FORCE ROW LEVEL SECURITY
|
|
2998
|
+
*
|
|
2999
|
+
* @example
|
|
3000
|
+
* var b = require("@blamejs/core");
|
|
3001
|
+
* b.sql.enableRowLevelSecurity("sessions",
|
|
3002
|
+
* { schema: "public" }).sql;
|
|
3003
|
+
* // -> 'ALTER TABLE "public"."sessions" ENABLE ROW LEVEL SECURITY'
|
|
3004
|
+
*/
|
|
3005
|
+
function enableRowLevelSecurity(name, opts) {
|
|
3006
|
+
opts = opts || {};
|
|
3007
|
+
var dialect = _normDialect(opts.dialect || "postgres");
|
|
3008
|
+
_assertPostgresRls(dialect, "enableRowLevelSecurity");
|
|
3009
|
+
// RLS targets a concrete table, so it is quoted (quoteName) rather than
|
|
3010
|
+
// emitted bare - there is no clusterStorage rewrite for a Postgres RLS
|
|
3011
|
+
// migration, which runs against the operator's external backend directly.
|
|
3012
|
+
var ref = _normTableRef(name, Object.assign({}, opts, { quoteName: true }));
|
|
3013
|
+
var sql = "ALTER TABLE " + ref.ref(dialect) + " " +
|
|
3014
|
+
(opts.force === true ? "FORCE" : "ENABLE") + " ROW LEVEL SECURITY";
|
|
3015
|
+
return { sql: sql, params: [] };
|
|
3016
|
+
}
|
|
3017
|
+
|
|
3018
|
+
/**
|
|
3019
|
+
* @primitive b.sql.disableRowLevelSecurity
|
|
3020
|
+
* @signature b.sql.disableRowLevelSecurity(table, opts?)
|
|
3021
|
+
* @since 0.15.0
|
|
3022
|
+
* @status stable
|
|
3023
|
+
* @related b.sql.enableRowLevelSecurity, b.sql.dropPolicy
|
|
3024
|
+
*
|
|
3025
|
+
* Build a Postgres `ALTER TABLE ... DISABLE ROW LEVEL SECURITY` statement
|
|
3026
|
+
* (the inverse of `enableRowLevelSecurity`), the table identifier quoted
|
|
3027
|
+
* by construction. Refuses a non-Postgres dialect at build time.
|
|
3028
|
+
*
|
|
3029
|
+
* @opts
|
|
3030
|
+
* schema: string, // schema qualifier, quoted at build time
|
|
3031
|
+
*
|
|
3032
|
+
* @example
|
|
3033
|
+
* var b = require("@blamejs/core");
|
|
3034
|
+
* b.sql.disableRowLevelSecurity("sessions", { schema: "public" }).sql;
|
|
3035
|
+
* // -> 'ALTER TABLE "public"."sessions" DISABLE ROW LEVEL SECURITY'
|
|
3036
|
+
*/
|
|
3037
|
+
function disableRowLevelSecurity(name, opts) {
|
|
3038
|
+
opts = opts || {};
|
|
3039
|
+
var dialect = _normDialect(opts.dialect || "postgres");
|
|
3040
|
+
_assertPostgresRls(dialect, "disableRowLevelSecurity");
|
|
3041
|
+
var ref = _normTableRef(name, Object.assign({}, opts, { quoteName: true }));
|
|
3042
|
+
return { sql: "ALTER TABLE " + ref.ref(dialect) + " DISABLE ROW LEVEL SECURITY", params: [] };
|
|
3043
|
+
}
|
|
3044
|
+
|
|
3045
|
+
/**
|
|
3046
|
+
* @primitive b.sql.createPolicy
|
|
3047
|
+
* @signature b.sql.createPolicy(name, table, spec, opts?)
|
|
3048
|
+
* @since 0.15.0
|
|
3049
|
+
* @status stable
|
|
3050
|
+
* @related b.sql.enableRowLevelSecurity, b.sql.dropPolicy, b.db.declareRowPolicy
|
|
3051
|
+
*
|
|
3052
|
+
* Build a Postgres `CREATE POLICY` statement in canonical clause order:
|
|
3053
|
+
* `name -> table -> AS PERMISSIVE|RESTRICTIVE -> FOR <command> ->
|
|
3054
|
+
* TO <role> -> USING (<pred>) -> WITH CHECK (<pred>)`. The policy / table /
|
|
3055
|
+
* role identifiers are quoted by construction; the `using` and `withCheck`
|
|
3056
|
+
* boolean predicates ride the SAME `b.guardSql`-gated raw-fragment path as
|
|
3057
|
+
* `whereRaw` (strict profile by default, embedded-literal + placeholder-
|
|
3058
|
+
* count scanners), so an operator-influenced predicate cannot smuggle a
|
|
3059
|
+
* stacked statement or a dangerous primitive. Refuses a non-Postgres
|
|
3060
|
+
* dialect at build time.
|
|
3061
|
+
*
|
|
3062
|
+
* `spec.command` is one of `ALL` (default) / `SELECT` / `INSERT` /
|
|
3063
|
+
* `UPDATE` / `DELETE`; `spec.permissive` defaults `true` (a `PERMISSIVE`
|
|
3064
|
+
* policy OR-combines with peers; `false` emits `RESTRICTIVE`, which
|
|
3065
|
+
* AND-combines). `spec.role` is optional (omitted -> the policy applies to
|
|
3066
|
+
* every role). The predicates default to binding no params - an RLS
|
|
3067
|
+
* predicate references session GUCs / row columns - but a `usingParams` /
|
|
3068
|
+
* `withCheckParams` array binds values for a parameterized predicate.
|
|
3069
|
+
*
|
|
3070
|
+
* @opts
|
|
3071
|
+
* schema: string, // schema qualifier for the table
|
|
3072
|
+
* guardProfile: string, // raw-fragment guard profile (default "strict")
|
|
3073
|
+
*
|
|
3074
|
+
* @example
|
|
3075
|
+
* var b = require("@blamejs/core");
|
|
3076
|
+
* b.sql.createPolicy("tenant_isolation", "sessions", {
|
|
3077
|
+
* role: "app_user",
|
|
3078
|
+
* command: "ALL",
|
|
3079
|
+
* using: "tenant_id = current_setting('app.tenant_id')::uuid",
|
|
3080
|
+
* withCheck: "tenant_id = current_setting('app.tenant_id')::uuid",
|
|
3081
|
+
* }, { schema: "public" }).sql;
|
|
3082
|
+
* // -> 'CREATE POLICY "tenant_isolation" ON "public"."sessions" ' +
|
|
3083
|
+
* // 'AS PERMISSIVE FOR ALL TO "app_user" ' +
|
|
3084
|
+
* // "USING (tenant_id = current_setting('app.tenant_id')::uuid) " +
|
|
3085
|
+
* // "WITH CHECK (tenant_id = current_setting('app.tenant_id')::uuid)"
|
|
3086
|
+
* // (the static current_setting literal opts in via allowLiterals)
|
|
3087
|
+
*/
|
|
3088
|
+
function createPolicy(name, table, spec, opts) {
|
|
3089
|
+
opts = opts || {};
|
|
3090
|
+
spec = spec || {};
|
|
3091
|
+
var dialect = _normDialect(opts.dialect || "postgres");
|
|
3092
|
+
_assertPostgresRls(dialect, "createPolicy");
|
|
3093
|
+
_validateColumn(name);
|
|
3094
|
+
var ref = _normTableRef(table, Object.assign({}, opts, { quoteName: true }));
|
|
3095
|
+
|
|
3096
|
+
var command = "ALL";
|
|
3097
|
+
if (spec.command !== undefined && spec.command !== null) {
|
|
3098
|
+
if (typeof spec.command !== "string" || RLS_COMMANDS[spec.command.toUpperCase()] !== true) {
|
|
3099
|
+
throw _err("createPolicy command must be ALL / SELECT / INSERT / UPDATE / DELETE (got " +
|
|
3100
|
+
JSON.stringify(spec.command) + ")", "sql-builder/bad-rls-command");
|
|
3101
|
+
}
|
|
3102
|
+
command = spec.command.toUpperCase();
|
|
3103
|
+
}
|
|
3104
|
+
var permissive = spec.permissive !== false;
|
|
3105
|
+
|
|
3106
|
+
if (spec.using === undefined || spec.using === null) {
|
|
3107
|
+
throw _err("createPolicy requires a 'using' boolean predicate", "sql-builder/bad-rls-predicate");
|
|
3108
|
+
}
|
|
3109
|
+
// The USING / WITH CHECK predicates are guarded raw fragments. RLS
|
|
3110
|
+
// predicates routinely carry a static, operator-controlled string
|
|
3111
|
+
// literal (current_setting('app.tenant_id')), so allowLiterals defaults
|
|
3112
|
+
// ON here - the literal is the policy author's, never per-request input;
|
|
3113
|
+
// every value-bearing operand still binds via a ? placeholder + params.
|
|
3114
|
+
var rawOpts = {
|
|
3115
|
+
guardProfile: opts.guardProfile || "strict",
|
|
3116
|
+
allowLiterals: spec.allowLiterals !== false,
|
|
3117
|
+
};
|
|
3118
|
+
var using = _rlsPredicate("createPolicy.using", spec.using, spec.usingParams, rawOpts);
|
|
3119
|
+
var withCheck = null;
|
|
3120
|
+
if (spec.withCheck !== undefined && spec.withCheck !== null) {
|
|
3121
|
+
withCheck = _rlsPredicate("createPolicy.withCheck", spec.withCheck, spec.withCheckParams, rawOpts);
|
|
3122
|
+
}
|
|
3123
|
+
|
|
3124
|
+
var sql = "CREATE POLICY " + _quoteId(name, dialect) + " ON " + ref.ref(dialect);
|
|
3125
|
+
sql += " AS " + (permissive ? "PERMISSIVE" : "RESTRICTIVE");
|
|
3126
|
+
sql += " FOR " + command;
|
|
3127
|
+
if (spec.role !== undefined && spec.role !== null) {
|
|
3128
|
+
_validateColumn(spec.role);
|
|
3129
|
+
sql += " TO " + _quoteId(spec.role, dialect);
|
|
3130
|
+
}
|
|
3131
|
+
var params = [];
|
|
3132
|
+
sql += " USING (" + using.sql + ")";
|
|
3133
|
+
for (var ui = 0; ui < using.params.length; ui += 1) params.push(using.params[ui]);
|
|
3134
|
+
if (withCheck) {
|
|
3135
|
+
sql += " WITH CHECK (" + withCheck.sql + ")";
|
|
3136
|
+
for (var wi = 0; wi < withCheck.params.length; wi += 1) params.push(withCheck.params[wi]);
|
|
3137
|
+
}
|
|
3138
|
+
return _emit(sql, params);
|
|
3139
|
+
}
|
|
3140
|
+
|
|
3141
|
+
/**
|
|
3142
|
+
* @primitive b.sql.dropPolicy
|
|
3143
|
+
* @signature b.sql.dropPolicy(name, table, opts?)
|
|
3144
|
+
* @since 0.15.0
|
|
3145
|
+
* @status stable
|
|
3146
|
+
* @related b.sql.createPolicy, b.sql.enableRowLevelSecurity
|
|
3147
|
+
*
|
|
3148
|
+
* Build a Postgres `DROP POLICY` statement, the policy + table identifiers
|
|
3149
|
+
* quoted by construction, `IF EXISTS` by default so dropping a missing
|
|
3150
|
+
* policy is a no-op (the migration down-path is idempotent). Refuses a
|
|
3151
|
+
* non-Postgres dialect at build time.
|
|
3152
|
+
*
|
|
3153
|
+
* @opts
|
|
3154
|
+
* schema: string, // schema qualifier for the table
|
|
3155
|
+
* ifExists: boolean, // default true
|
|
3156
|
+
*
|
|
3157
|
+
* @example
|
|
3158
|
+
* var b = require("@blamejs/core");
|
|
3159
|
+
* b.sql.dropPolicy("tenant_isolation", "sessions", { schema: "public" }).sql;
|
|
3160
|
+
* // -> 'DROP POLICY IF EXISTS "tenant_isolation" ON "public"."sessions"'
|
|
3161
|
+
*/
|
|
3162
|
+
function dropPolicy(name, table, opts) {
|
|
3163
|
+
opts = opts || {};
|
|
3164
|
+
var dialect = _normDialect(opts.dialect || "postgres");
|
|
3165
|
+
_assertPostgresRls(dialect, "dropPolicy");
|
|
3166
|
+
_validateColumn(name);
|
|
3167
|
+
var ref = _normTableRef(table, Object.assign({}, opts, { quoteName: true }));
|
|
3168
|
+
var ifExists = opts.ifExists === false ? "" : "IF EXISTS ";
|
|
3169
|
+
return { sql: "DROP POLICY " + ifExists + _quoteId(name, dialect) + " ON " + ref.ref(dialect), params: [] };
|
|
3170
|
+
}
|
|
3171
|
+
|
|
3172
|
+
// ---- Catalog / PRAGMA (narrow audited sqlite-internal sub-API) ------
|
|
3173
|
+
//
|
|
3174
|
+
// b.safeSql.quoteIdentifier refuses an `sqlite_`-prefixed identifier BY
|
|
3175
|
+
// DESIGN (sql/internal-prefix) and _assertEmittable refuses a multi-verb
|
|
3176
|
+
// statement - both stay intact for every general caller. The vault key-
|
|
3177
|
+
// rotation pipeline (lib/vault/rotate.js) legitimately needs to read the
|
|
3178
|
+
// sqlite catalog (sqlite_master), introspect a table (PRAGMA table_info),
|
|
3179
|
+
// set journal mode + synchronous, checkpoint the WAL, and sample rows in
|
|
3180
|
+
// random order. None of those compose through the general builder. This
|
|
3181
|
+
// narrow sub-API allowlists EXACTLY those statements + verbs and nothing
|
|
3182
|
+
// else: every other sqlite_-prefixed identifier and every other PRAGMA
|
|
3183
|
+
// verb still refuses through the general quoteIdentifier / builder gate.
|
|
3184
|
+
//
|
|
3185
|
+
// Every emitter here returns the SAME { sql, params } shape the verbs do,
|
|
3186
|
+
// validated through a CATALOG-scoped output gate (_assertCatalogEmittable)
|
|
3187
|
+
// that allows the sqlite_master / PRAGMA / RANDOM() forms the general
|
|
3188
|
+
// _assertEmittable refuses while keeping NUL / surrogate / stacked-
|
|
3189
|
+
// statement / unterminated-quote refusals fully intact.
|
|
3190
|
+
|
|
3191
|
+
// The exact PRAGMA verbs this sub-API will emit. A verb not on this list
|
|
3192
|
+
// throws - the allowlist is the audit boundary, not a suggestion.
|
|
3193
|
+
var CATALOG_PRAGMA_VERBS = Object.freeze({
|
|
3194
|
+
"table_info": { kind: "introspect" }, // PRAGMA table_info("<table>")
|
|
3195
|
+
"journal_mode": { kind: "set-or-read" }, // PRAGMA journal_mode=WAL | PRAGMA journal_mode
|
|
3196
|
+
"synchronous": { kind: "set-or-read" }, // PRAGMA synchronous=NORMAL
|
|
3197
|
+
"wal_checkpoint": { kind: "checkpoint" }, // PRAGMA wal_checkpoint(TRUNCATE)
|
|
3198
|
+
});
|
|
3199
|
+
// Allowlisted argument tokens per set-or-read / checkpoint PRAGMA - a
|
|
3200
|
+
// fixed, operator-uninfluenced vocabulary so no arbitrary token reaches
|
|
3201
|
+
// the PRAGMA argument position.
|
|
3202
|
+
var PRAGMA_JOURNAL_MODES = Object.freeze({
|
|
3203
|
+
DELETE: true, TRUNCATE: true, PERSIST: true, MEMORY: true, WAL: true, OFF: true,
|
|
3204
|
+
});
|
|
3205
|
+
var PRAGMA_SYNC_LEVELS = Object.freeze({ OFF: true, NORMAL: true, FULL: true, EXTRA: true });
|
|
3206
|
+
var PRAGMA_CHECKPOINT_MODES = Object.freeze({ PASSIVE: true, FULL: true, RESTART: true, TRUNCATE: true });
|
|
3207
|
+
|
|
3208
|
+
// Quote an sqlite identifier WITH allowReserved (an internal table walk
|
|
3209
|
+
// can encounter any name). The sqlite_-prefix rule stays in force for the
|
|
3210
|
+
// general quoteIdentifier path; catalog identifiers that are themselves a
|
|
3211
|
+
// real user table go through the normal allowReserved quote (a user table
|
|
3212
|
+
// is never sqlite_-prefixed). The ONLY sqlite_-prefixed token this sub-API
|
|
3213
|
+
// emits is the fixed `sqlite_master` / `sqlite_schema` literal below -
|
|
3214
|
+
// never an operator-supplied name.
|
|
3215
|
+
function _catalogQuoteTable(name) {
|
|
3216
|
+
if (typeof name !== "string" || name.length === 0) {
|
|
3217
|
+
throw _err("catalog: table name must be a non-empty string", "sql-builder/bad-table");
|
|
3218
|
+
}
|
|
3219
|
+
// A catalog walk reads a name OUT of sqlite_master, so the live table
|
|
3220
|
+
// name is already a validated existing identifier; still route it through
|
|
3221
|
+
// the framework quote primitive (shape / length / NUL rules) with
|
|
3222
|
+
// allowReserved on. An sqlite_-prefixed user table cannot exist (sqlite
|
|
3223
|
+
// reserves the prefix), so the general internal-prefix refusal correctly
|
|
3224
|
+
// rejects it if one is ever passed - the catalog sub-API never relaxes
|
|
3225
|
+
// that for an operator-supplied name.
|
|
3226
|
+
return safeSql.quoteIdentifier(name, "sqlite", { allowReserved: true });
|
|
3227
|
+
}
|
|
3228
|
+
|
|
3229
|
+
// Output gate for the catalog sub-API. Keeps every boundary-escape
|
|
3230
|
+
// refusal _assertEmittable has (NUL, lone surrogate, stacked top-level ';',
|
|
3231
|
+
// unterminated quote, param/placeholder parity) but does NOT run the
|
|
3232
|
+
// single-verb / identifier-shape assumptions the general gate makes about
|
|
3233
|
+
// the builder verbs - a PRAGMA / catalog statement is its own shape.
|
|
3234
|
+
function _assertCatalogEmittable(sql, params) {
|
|
3235
|
+
if (typeof sql !== "string" || sql.length === 0) {
|
|
3236
|
+
throw _err("catalog: emitted SQL must be a non-empty string (builder bug)",
|
|
3237
|
+
"sql-builder/empty-sql");
|
|
3238
|
+
}
|
|
3239
|
+
if (!Array.isArray(params)) {
|
|
3240
|
+
throw _err("catalog: params must be an array (builder bug)", "sql-builder/bad-params-shape");
|
|
3241
|
+
}
|
|
3242
|
+
if (sql.indexOf("\u0000") !== -1) {
|
|
3243
|
+
throw _err("catalog: emitted SQL contains a NUL byte - rejected",
|
|
3244
|
+
"sql-builder/null-byte-sql");
|
|
3245
|
+
}
|
|
3246
|
+
if (typeof sql.isWellFormed === "function" && !sql.isWellFormed()) {
|
|
3247
|
+
throw _err("catalog: emitted SQL contains invalid Unicode (lone surrogates) - rejected",
|
|
3248
|
+
"sql-builder/invalid-encoding-sql");
|
|
3249
|
+
}
|
|
3250
|
+
var holders = _countPlaceholders(sql);
|
|
3251
|
+
if (holders !== params.length) {
|
|
3252
|
+
throw _err("catalog: placeholder/param count mismatch - " + holders + " '?' but " +
|
|
3253
|
+
params.length + " param(s)", "sql-builder/param-mismatch");
|
|
3254
|
+
}
|
|
3255
|
+
// Quote/comment-aware single-statement + balanced-paren scan, identical
|
|
3256
|
+
// to _assertEmittable's tail. A stacked top-level ';' / unterminated
|
|
3257
|
+
// quote is refused here too.
|
|
3258
|
+
var i = 0;
|
|
3259
|
+
var len = sql.length;
|
|
3260
|
+
var depth = 0;
|
|
3261
|
+
while (i < len) {
|
|
3262
|
+
var ch = sql.charAt(i);
|
|
3263
|
+
var next = i + 1 < len ? sql.charAt(i + 1) : "";
|
|
3264
|
+
if (ch === "'" || ch === '"' || ch === "`") {
|
|
3265
|
+
var q = ch;
|
|
3266
|
+
var closed = false;
|
|
3267
|
+
i += 1;
|
|
3268
|
+
while (i < len) {
|
|
3269
|
+
if (sql.charAt(i) === q) {
|
|
3270
|
+
if (sql.charAt(i + 1) === q) { i += 2; continue; }
|
|
3271
|
+
i += 1; closed = true; break;
|
|
3272
|
+
}
|
|
3273
|
+
i += 1;
|
|
3274
|
+
}
|
|
3275
|
+
if (!closed) {
|
|
3276
|
+
throw _err("catalog: unterminated quote in emitted SQL (quote-jump / breakout risk)",
|
|
3277
|
+
"sql-builder/unterminated-quote");
|
|
3278
|
+
}
|
|
3279
|
+
continue;
|
|
3280
|
+
}
|
|
3281
|
+
if (ch === "-" && next === "-") { while (i < len && sql.charAt(i) !== "\n") i += 1; continue; }
|
|
3282
|
+
if (ch === "/" && next === "*") {
|
|
3283
|
+
i += 2;
|
|
3284
|
+
while (i < len && !(sql.charAt(i) === "*" && sql.charAt(i + 1) === "/")) i += 1;
|
|
3285
|
+
i += 2;
|
|
3286
|
+
continue;
|
|
3287
|
+
}
|
|
3288
|
+
if (ch === "(") { depth += 1; }
|
|
3289
|
+
else if (ch === ")") { depth -= 1; }
|
|
3290
|
+
else if (ch === ";") {
|
|
3291
|
+
throw _err("catalog: emitted a top-level ';' - exactly one statement",
|
|
3292
|
+
"sql-builder/stacked-statement");
|
|
3293
|
+
}
|
|
3294
|
+
i += 1;
|
|
3295
|
+
}
|
|
3296
|
+
if (depth !== 0) {
|
|
3297
|
+
throw _err("catalog: unbalanced parentheses in emitted SQL (builder bug)", "sql-builder/unbalanced");
|
|
3298
|
+
}
|
|
3299
|
+
return { sql: sql, params: params };
|
|
3300
|
+
}
|
|
3301
|
+
|
|
3302
|
+
// The audited catalog/PRAGMA sub-API. Every method returns { sql, params }.
|
|
3303
|
+
var catalog = Object.freeze({
|
|
3304
|
+
/**
|
|
3305
|
+
* @primitive b.sql.catalog.listTables
|
|
3306
|
+
* @signature b.sql.catalog.listTables()
|
|
3307
|
+
* @since 0.15.0
|
|
3308
|
+
* @status stable
|
|
3309
|
+
* @related b.sql.catalog.tableInfo, b.sql.catalog.tableExists
|
|
3310
|
+
*
|
|
3311
|
+
* Build the sqlite catalog query that lists every user table -
|
|
3312
|
+
* `SELECT name FROM sqlite_master WHERE type='table' AND
|
|
3313
|
+
* name NOT LIKE 'sqlite_%'`. This is the ONLY general path that emits an
|
|
3314
|
+
* `sqlite_master` reference; the framework's `b.safeSql.quoteIdentifier`
|
|
3315
|
+
* refuses an `sqlite_`-prefixed identifier for every other caller, so a
|
|
3316
|
+
* `sqlite_master` scan cannot be hand-built through the normal builder.
|
|
3317
|
+
* The `sqlite_%` LIKE pattern is a builder-emitted static literal (not
|
|
3318
|
+
* operator input). sqlite-internal; no dialect option.
|
|
3319
|
+
*
|
|
3320
|
+
* @example
|
|
3321
|
+
* var b = require("@blamejs/core");
|
|
3322
|
+
* var q = b.sql.catalog.listTables();
|
|
3323
|
+
* // -> { sql: "SELECT name FROM sqlite_master WHERE type = 'table' " +
|
|
3324
|
+
* // "AND name NOT LIKE 'sqlite_%'", params: [] }
|
|
3325
|
+
*/
|
|
3326
|
+
listTables: function () {
|
|
3327
|
+
var sql = "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'";
|
|
3328
|
+
return _assertCatalogEmittable(sql, []);
|
|
3329
|
+
},
|
|
3330
|
+
|
|
3331
|
+
/**
|
|
3332
|
+
* @primitive b.sql.catalog.tableExists
|
|
3333
|
+
* @signature b.sql.catalog.tableExists(name)
|
|
3334
|
+
* @since 0.15.0
|
|
3335
|
+
* @status stable
|
|
3336
|
+
* @related b.sql.catalog.listTables, b.sql.catalog.tableInfo
|
|
3337
|
+
*
|
|
3338
|
+
* Build the sqlite catalog existence probe for one table -
|
|
3339
|
+
* `SELECT name FROM sqlite_master WHERE type='table' AND name = ?`, the
|
|
3340
|
+
* table name BOUND as a `?` parameter (never interpolated). Returns one
|
|
3341
|
+
* row when the table exists, none otherwise.
|
|
3342
|
+
*
|
|
3343
|
+
* @example
|
|
3344
|
+
* var b = require("@blamejs/core");
|
|
3345
|
+
* b.sql.catalog.tableExists("audit_log");
|
|
3346
|
+
* // -> { sql: "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?",
|
|
3347
|
+
* // params: ["audit_log"] }
|
|
3348
|
+
*/
|
|
3349
|
+
tableExists: function (name) {
|
|
3350
|
+
if (typeof name !== "string" || name.length === 0) {
|
|
3351
|
+
throw _err("catalog.tableExists: name must be a non-empty string", "sql-builder/bad-table");
|
|
3352
|
+
}
|
|
3353
|
+
var sql = "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?";
|
|
3354
|
+
return _assertCatalogEmittable(sql, [name]);
|
|
3355
|
+
},
|
|
3356
|
+
|
|
3357
|
+
/**
|
|
3358
|
+
* @primitive b.sql.catalog.tableInfo
|
|
3359
|
+
* @signature b.sql.catalog.tableInfo(name)
|
|
3360
|
+
* @since 0.15.0
|
|
3361
|
+
* @status stable
|
|
3362
|
+
* @related b.sql.catalog.listTables, b.sql.pragma
|
|
3363
|
+
*
|
|
3364
|
+
* Build a `PRAGMA table_info("<table>")` statement, the table name
|
|
3365
|
+
* quoted by construction through `b.safeSql`. PRAGMA does not bind a
|
|
3366
|
+
* parameter in its argument position, so the name is quoted (shape /
|
|
3367
|
+
* length / NUL-validated), never string-interpolated raw. sqlite-only.
|
|
3368
|
+
*
|
|
3369
|
+
* @example
|
|
3370
|
+
* var b = require("@blamejs/core");
|
|
3371
|
+
* b.sql.catalog.tableInfo("audit_log").sql;
|
|
3372
|
+
* // -> 'PRAGMA table_info("audit_log")'
|
|
3373
|
+
*/
|
|
3374
|
+
tableInfo: function (name) {
|
|
3375
|
+
var sql = "PRAGMA table_info(" + _catalogQuoteTable(name) + ")";
|
|
3376
|
+
return _assertCatalogEmittable(sql, []);
|
|
3377
|
+
},
|
|
3378
|
+
|
|
3379
|
+
/**
|
|
3380
|
+
* @primitive b.sql.catalog.sampleRandom
|
|
3381
|
+
* @signature b.sql.catalog.sampleRandom(table, columns?, opts?)
|
|
3382
|
+
* @since 0.15.0
|
|
3383
|
+
* @status stable
|
|
3384
|
+
* @related b.sql.select, b.sql.catalog.tableInfo
|
|
3385
|
+
*
|
|
3386
|
+
* Build a `SELECT <cols> FROM "<table>" ORDER BY RANDOM() LIMIT ?`
|
|
3387
|
+
* row-sampler, identifiers quoted by construction and the limit BOUND as
|
|
3388
|
+
* a `?` parameter. `RANDOM()` ordering is the audited sqlite sampler form
|
|
3389
|
+
* the general `b.sql.select` builder has no clause for (it is used to
|
|
3390
|
+
* pick representative rows for verification, not cryptographic
|
|
3391
|
+
* randomness). `columns` defaults to `*`. sqlite-only.
|
|
3392
|
+
*
|
|
3393
|
+
* @opts
|
|
3394
|
+
* limit: number, // bound LIMIT (required > 0)
|
|
3395
|
+
*
|
|
3396
|
+
* @example
|
|
3397
|
+
* var b = require("@blamejs/core");
|
|
3398
|
+
* b.sql.catalog.sampleRandom("sessions", ["_id", "email"], { limit: 50 });
|
|
3399
|
+
* // -> { sql: 'SELECT "_id", "email" FROM "sessions" ORDER BY RANDOM() LIMIT ?',
|
|
3400
|
+
* // params: [50] }
|
|
3401
|
+
*/
|
|
3402
|
+
/**
|
|
3403
|
+
* @primitive b.sql.catalog.changes
|
|
3404
|
+
* @signature b.sql.catalog.changes()
|
|
3405
|
+
* @since 0.15.0
|
|
3406
|
+
* @status stable
|
|
3407
|
+
* @related b.sql.catalog.listTables, b.sql.delete
|
|
3408
|
+
*
|
|
3409
|
+
* Build `SELECT changes() AS c` - the sqlite scalar that reports the row
|
|
3410
|
+
* count of the most recent INSERT / UPDATE / DELETE on the current
|
|
3411
|
+
* connection. `changes()` is a sqlite-internal function with no table to
|
|
3412
|
+
* select from, so the general builder (which requires a FROM table) has
|
|
3413
|
+
* no form for it; this audited builder emits the exact zero-parameter
|
|
3414
|
+
* probe the inbox sweep uses to learn how many rows a preceding DELETE
|
|
3415
|
+
* removed. sqlite-only; the column alias is `c`.
|
|
3416
|
+
*
|
|
3417
|
+
* @example
|
|
3418
|
+
* var b = require("@blamejs/core");
|
|
3419
|
+
* b.sql.catalog.changes().sql; // -> "SELECT changes() AS c"
|
|
3420
|
+
*/
|
|
3421
|
+
changes: function () {
|
|
3422
|
+
return _assertCatalogEmittable("SELECT changes() AS c", []);
|
|
3423
|
+
},
|
|
3424
|
+
|
|
3425
|
+
sampleRandom: function (table, columns, opts) {
|
|
3426
|
+
opts = opts || {};
|
|
3427
|
+
var qt = _catalogQuoteTable(table);
|
|
3428
|
+
var proj = "*";
|
|
3429
|
+
if (columns !== undefined && columns !== null) {
|
|
3430
|
+
if (!Array.isArray(columns) || columns.length === 0) {
|
|
3431
|
+
throw _err("catalog.sampleRandom: columns must be a non-empty array (or omit for *)",
|
|
3432
|
+
"sql-builder/bad-columns");
|
|
3433
|
+
}
|
|
3434
|
+
proj = columns.map(function (c) {
|
|
3435
|
+
_validateColumn(c);
|
|
3436
|
+
return _quoteId(c, "sqlite");
|
|
3437
|
+
}).join(", ");
|
|
3438
|
+
}
|
|
3439
|
+
var limit = opts.limit;
|
|
3440
|
+
if (!Number.isInteger(limit) || limit <= 0) {
|
|
3441
|
+
throw _err("catalog.sampleRandom: opts.limit must be a positive integer", "sql-builder/bad-limit");
|
|
3442
|
+
}
|
|
3443
|
+
var sql = "SELECT " + proj + " FROM " + qt + " ORDER BY RANDOM() LIMIT ?";
|
|
3444
|
+
return _assertCatalogEmittable(sql, [limit]);
|
|
3445
|
+
},
|
|
3446
|
+
});
|
|
3447
|
+
|
|
3448
|
+
/**
|
|
3449
|
+
* @primitive b.sql.pragma
|
|
3450
|
+
* @signature b.sql.pragma(verb, arg?)
|
|
3451
|
+
* @since 0.15.0
|
|
3452
|
+
* @status stable
|
|
3453
|
+
* @related b.sql.catalog.tableInfo, b.sql.catalog.listTables
|
|
3454
|
+
*
|
|
3455
|
+
* Build a sqlite `PRAGMA` statement from a NARROW allowlist of verbs:
|
|
3456
|
+
* `journal_mode` (set `PRAGMA journal_mode=WAL` or read `PRAGMA
|
|
3457
|
+
* journal_mode`), `synchronous` (`PRAGMA synchronous=NORMAL`), and
|
|
3458
|
+
* `wal_checkpoint` (`PRAGMA wal_checkpoint(TRUNCATE)`). The argument is
|
|
3459
|
+
* matched against a fixed per-verb vocabulary - a journal mode / sync
|
|
3460
|
+
* level / checkpoint mode - so no operator-influenced token reaches the
|
|
3461
|
+
* PRAGMA argument position. A verb not on the allowlist throws; this is
|
|
3462
|
+
* the audit boundary the at-rest key-rotation pipeline routes its PRAGMA
|
|
3463
|
+
* statements through. Pass no `arg` to a set-or-read verb to read the
|
|
3464
|
+
* current value. sqlite-only.
|
|
3465
|
+
*
|
|
3466
|
+
* @opts
|
|
3467
|
+
* (none - the second positional is the allowlisted argument token)
|
|
3468
|
+
*
|
|
3469
|
+
* @example
|
|
3470
|
+
* var b = require("@blamejs/core");
|
|
3471
|
+
* b.sql.pragma("journal_mode", "WAL").sql; // -> 'PRAGMA journal_mode=WAL'
|
|
3472
|
+
* b.sql.pragma("synchronous", "NORMAL").sql; // -> 'PRAGMA synchronous=NORMAL'
|
|
3473
|
+
* b.sql.pragma("wal_checkpoint", "TRUNCATE").sql; // -> 'PRAGMA wal_checkpoint(TRUNCATE)'
|
|
3474
|
+
* b.sql.pragma("journal_mode").sql; // -> 'PRAGMA journal_mode' (read)
|
|
3475
|
+
*/
|
|
3476
|
+
function pragma(verb, arg) {
|
|
3477
|
+
if (typeof verb !== "string" || CATALOG_PRAGMA_VERBS[verb] === undefined) {
|
|
3478
|
+
throw _err("pragma: verb '" + verb + "' is not on the allowlist (journal_mode / " +
|
|
3479
|
+
"synchronous / wal_checkpoint); a PRAGMA outside this set is refused by design",
|
|
3480
|
+
"sql-builder/bad-pragma");
|
|
3481
|
+
}
|
|
3482
|
+
var def = CATALOG_PRAGMA_VERBS[verb];
|
|
3483
|
+
if (def.kind === "introspect") {
|
|
3484
|
+
// table_info is reached via catalog.tableInfo (needs a quoted name).
|
|
3485
|
+
throw _err("pragma: use b.sql.catalog.tableInfo(name) for PRAGMA table_info",
|
|
3486
|
+
"sql-builder/bad-pragma");
|
|
3487
|
+
}
|
|
3488
|
+
if (def.kind === "checkpoint") {
|
|
3489
|
+
var ckMode = (arg === undefined || arg === null) ? "PASSIVE" : String(arg).toUpperCase();
|
|
3490
|
+
if (PRAGMA_CHECKPOINT_MODES[ckMode] !== true) {
|
|
3491
|
+
throw _err("pragma wal_checkpoint mode must be PASSIVE / FULL / RESTART / TRUNCATE (got " +
|
|
3492
|
+
JSON.stringify(arg) + ")", "sql-builder/bad-pragma-arg");
|
|
3493
|
+
}
|
|
3494
|
+
return _assertCatalogEmittable("PRAGMA wal_checkpoint(" + ckMode + ")", []);
|
|
3495
|
+
}
|
|
3496
|
+
// set-or-read: journal_mode / synchronous.
|
|
3497
|
+
if (arg === undefined || arg === null) {
|
|
3498
|
+
return _assertCatalogEmittable("PRAGMA " + verb, []);
|
|
3499
|
+
}
|
|
3500
|
+
var token = String(arg).toUpperCase();
|
|
3501
|
+
var vocab = verb === "journal_mode" ? PRAGMA_JOURNAL_MODES : PRAGMA_SYNC_LEVELS;
|
|
3502
|
+
if (vocab[token] !== true) {
|
|
3503
|
+
throw _err("pragma " + verb + " argument '" + arg + "' is not in the allowed vocabulary",
|
|
3504
|
+
"sql-builder/bad-pragma-arg");
|
|
3505
|
+
}
|
|
3506
|
+
return _assertCatalogEmittable("PRAGMA " + verb + "=" + token, []);
|
|
3507
|
+
}
|
|
3508
|
+
|
|
3509
|
+
// ---- Schema optimization (defineTable) ------------------------------
|
|
3510
|
+
//
|
|
3511
|
+
// PK / FK / index automation over createTable + createIndex. Each layer is
|
|
3512
|
+
// on by default and individually disablable.
|
|
3513
|
+
|
|
3514
|
+
// Naive pluralizer for FK table inference (entity -> table). Covers the
|
|
3515
|
+
// common English cases; an unusual plural is overridden with an explicit
|
|
3516
|
+
// `references`. consonant+y -> ies, sibilant -> es, else -> s.
|
|
3517
|
+
function _pluralize(s) {
|
|
3518
|
+
if (/[^aeiou]y$/i.test(s)) return s.slice(0, -1) + "ies";
|
|
3519
|
+
if (/(?:s|x|z|ch|sh)$/i.test(s)) return s + "es";
|
|
3520
|
+
return s + "s";
|
|
3521
|
+
}
|
|
3522
|
+
|
|
3523
|
+
// Infer a foreign-key reference from a column name by convention: a column
|
|
3524
|
+
// named `<entity>Id` / `<entity>_id` references `<pluralize(entity)>(<pkCol>)`.
|
|
3525
|
+
// Returns null when the name does not match (or the entity part is empty,
|
|
3526
|
+
// e.g. a bare `id` / `_id`).
|
|
3527
|
+
function _inferFkRef(colName, pkCol) {
|
|
3528
|
+
var m = /^(.+?)(?:Id|_id)$/.exec(colName);
|
|
3529
|
+
if (!m || m[1].length === 0) return null;
|
|
3530
|
+
return { table: _pluralize(m[1]), column: pkCol };
|
|
3531
|
+
}
|
|
3532
|
+
|
|
3533
|
+
// Deterministic index name `idx_<table>_<cols>`, sanitized to an identifier
|
|
3534
|
+
// and capped at the dialect identifier limit (Postgres NAMEDATALEN 63) the
|
|
3535
|
+
// same way the query builder bounds every identifier. An over-long name is
|
|
3536
|
+
// truncated with a short stable checksum suffix so two long names can't
|
|
3537
|
+
// collide after truncation.
|
|
3538
|
+
function _indexName(table, cols) {
|
|
3539
|
+
var base = ("idx_" + table + "_" + cols.join("_")).replace(/[^A-Za-z0-9_]/g, "_");
|
|
3540
|
+
if (base.length > safeSql.MAX_IDENTIFIER_LENGTH) {
|
|
3541
|
+
var h = 0;
|
|
3542
|
+
for (var i = 0; i < base.length; i += 1) h = (h * 31 + base.charCodeAt(i)) >>> 0;
|
|
3543
|
+
base = base.slice(0, safeSql.MAX_IDENTIFIER_LENGTH - 9) + "_" + h.toString(36);
|
|
3544
|
+
}
|
|
3545
|
+
return base;
|
|
3546
|
+
}
|
|
3547
|
+
|
|
3548
|
+
/**
|
|
3549
|
+
* @primitive b.sql.defineTable
|
|
3550
|
+
* @signature b.sql.defineTable(name, spec, opts?)
|
|
3551
|
+
* @since 0.14.29
|
|
3552
|
+
* @status stable
|
|
3553
|
+
* @related b.sql.createTable, b.sql.createIndex, b.sql.select
|
|
3554
|
+
*
|
|
3555
|
+
* Declarative schema with built-in PK / FK / index optimization. Returns an
|
|
3556
|
+
* ordered `{ statements: [{ sql, params }, ...] }` bundle (the `CREATE TABLE`
|
|
3557
|
+
* first, then each `CREATE INDEX`) to run in sequence. Three automation
|
|
3558
|
+
* layers, each on by default and individually disablable:
|
|
3559
|
+
*
|
|
3560
|
+
* - **Primary key** - if no column declares `primaryKey` / `autoIncrement`
|
|
3561
|
+
* and `opts.primaryKey` is unset, an identity PK column (`opts.primaryKeyColumn`,
|
|
3562
|
+
* default `id`) is auto-added in the dialect-correct form (BIGSERIAL /
|
|
3563
|
+
* INTEGER AUTOINCREMENT / BIGINT AUTO_INCREMENT). Disable: `autoPrimaryKey: false`.
|
|
3564
|
+
* - **Foreign keys** - a column named `<entity>Id` / `<entity>_id` infers a
|
|
3565
|
+
* `REFERENCES <pluralize(entity)>(<pk>)` constraint. Override one column with
|
|
3566
|
+
* an explicit `references` (`"table"` or `{ table, column?, onDelete?,
|
|
3567
|
+
* onUpdate? }`) or opt it out with `references: false`. Disable all
|
|
3568
|
+
* inference: `autoForeignKeys: false`.
|
|
3569
|
+
* - **Indexes** - every FK column is auto-indexed (databases do not index
|
|
3570
|
+
* FK columns for you), as is any column flagged `index: true`
|
|
3571
|
+
* (`unique: true` is enforced inline). Add composite / custom indexes via
|
|
3572
|
+
* `opts.indexes`. Disable auto-indexing: `autoIndex: false`.
|
|
3573
|
+
*
|
|
3574
|
+
* Every index / FK column is gated against the table's declared column set -
|
|
3575
|
+
* the same column-namespace discipline the query builder applies with
|
|
3576
|
+
* `allowedColumns` - and every generated index name is bounded to the dialect
|
|
3577
|
+
* identifier limit.
|
|
3578
|
+
*
|
|
3579
|
+
* @opts
|
|
3580
|
+
* dialect: string, // postgres | sqlite | mysql (default sqlite)
|
|
3581
|
+
* prefix: string, // operator app-table namespace prefix
|
|
3582
|
+
* schema: string, // schema qualifier
|
|
3583
|
+
* autoPrimaryKey: boolean, // default true
|
|
3584
|
+
* primaryKeyColumn: string, // default "id"
|
|
3585
|
+
* autoForeignKeys: boolean, // default true (naming-convention inference)
|
|
3586
|
+
* autoIndex: boolean, // default true
|
|
3587
|
+
* indexes: array, // [{ columns: [...], unique?, name? }]
|
|
3588
|
+
*
|
|
3589
|
+
* @example
|
|
3590
|
+
* var b = require("@blamejs/core");
|
|
3591
|
+
* var ddl = b.sql.defineTable("orders", [
|
|
3592
|
+
* { name: "userId", type: "int" }, // -> FK users(id) + index
|
|
3593
|
+
* { name: "total", type: "numeric" },
|
|
3594
|
+
* { name: "email", type: "text", index: true },
|
|
3595
|
+
* ], { dialect: "postgres" });
|
|
3596
|
+
* ddl.statements.length;
|
|
3597
|
+
* // -> 3 (CREATE TABLE orders; CREATE INDEX on userId; CREATE INDEX on email)
|
|
3598
|
+
*/
|
|
3599
|
+
function defineTable(name, spec, opts) {
|
|
3600
|
+
opts = opts || {};
|
|
3601
|
+
var dialect = _normDialect(opts.dialect);
|
|
3602
|
+
if (!Array.isArray(spec) || spec.length === 0) {
|
|
3603
|
+
throw _err("defineTable requires a non-empty columns spec array", "sql-builder/bad-columns");
|
|
3604
|
+
}
|
|
3605
|
+
var autoPk = opts.autoPrimaryKey !== false;
|
|
3606
|
+
var autoFk = opts.autoForeignKeys !== false;
|
|
3607
|
+
var autoIdx = opts.autoIndex !== false;
|
|
3608
|
+
var pkCol = opts.primaryKeyColumn || "id";
|
|
3609
|
+
|
|
3610
|
+
// Shallow-copy each spec so FK inference never mutates the caller's object.
|
|
3611
|
+
var cols = spec.map(function (c) {
|
|
3612
|
+
if (!c || typeof c !== "object" || typeof c.name !== "string") {
|
|
3613
|
+
throw _err("defineTable column must be { name, type, ... }", "sql-builder/bad-column");
|
|
3614
|
+
}
|
|
3615
|
+
return Object.assign({}, c);
|
|
3616
|
+
});
|
|
3617
|
+
|
|
3618
|
+
// PK automation.
|
|
3619
|
+
var declaredPk = cols.some(function (c) { return c.primaryKey || c.autoIncrement; }) ||
|
|
3620
|
+
(Array.isArray(opts.primaryKey) && opts.primaryKey.length > 0);
|
|
3621
|
+
if (autoPk && !declaredPk) cols.unshift({ name: pkCol, autoIncrement: true });
|
|
3622
|
+
|
|
3623
|
+
// Column namespace - index / FK columns must be members, the same gate the
|
|
3624
|
+
// query builder enforces with allowedColumns / _assertColumnMember.
|
|
3625
|
+
var declared = {};
|
|
3626
|
+
cols.forEach(function (c) { declared[c.name] = true; });
|
|
3627
|
+
function _assertMember(col, where) {
|
|
3628
|
+
if (declared[col] !== true) {
|
|
3629
|
+
throw _err("defineTable: " + where + " references column '" + col +
|
|
3630
|
+
"' which is not a declared column of '" + name + "'", "sql-builder/unknown-column");
|
|
3631
|
+
}
|
|
3632
|
+
}
|
|
3633
|
+
|
|
3634
|
+
// FK automation (convention-by-default + per-column override).
|
|
3635
|
+
var fkColumns = [];
|
|
3636
|
+
cols.forEach(function (c) {
|
|
3637
|
+
if (c.references === false) return; // opt-out
|
|
3638
|
+
if (c.references !== undefined) { fkColumns.push(c.name); return; } // explicit
|
|
3639
|
+
if (c.primaryKey || c.autoIncrement) return; // PK is not an FK
|
|
3640
|
+
if (autoFk) {
|
|
3641
|
+
var inferred = _inferFkRef(c.name, pkCol);
|
|
3642
|
+
if (inferred) { c.references = inferred; fkColumns.push(c.name); }
|
|
3643
|
+
}
|
|
3644
|
+
});
|
|
3645
|
+
|
|
3646
|
+
var statements = [createTable(name, cols, opts)];
|
|
3647
|
+
|
|
3648
|
+
// Index automation. Generated index names are bounded by _indexName.
|
|
3649
|
+
var indexed = {};
|
|
3650
|
+
function _pushIndex(indexCols, unique, explicitName) {
|
|
3651
|
+
indexCols.forEach(function (col) { _assertMember(col, "index"); });
|
|
3652
|
+
statements.push(createIndex(explicitName || _indexName(name, indexCols), name, indexCols,
|
|
3653
|
+
{ dialect: dialect, unique: unique === true, prefix: opts.prefix, schema: opts.schema }));
|
|
3654
|
+
}
|
|
3655
|
+
if (autoIdx) {
|
|
3656
|
+
fkColumns.forEach(function (cn) {
|
|
3657
|
+
if (!indexed[cn]) { indexed[cn] = true; _pushIndex([cn], false, null); }
|
|
3658
|
+
});
|
|
3659
|
+
cols.forEach(function (c) {
|
|
3660
|
+
if (c.index === true && !c.unique && !c.primaryKey && !c.autoIncrement && !indexed[c.name]) {
|
|
3661
|
+
indexed[c.name] = true; _pushIndex([c.name], false, null);
|
|
3662
|
+
}
|
|
3663
|
+
});
|
|
3664
|
+
}
|
|
3665
|
+
// Explicit indexes are always honored (even with autoIndex off).
|
|
3666
|
+
if (Array.isArray(opts.indexes)) {
|
|
3667
|
+
opts.indexes.forEach(function (ix) {
|
|
3668
|
+
if (!ix || !Array.isArray(ix.columns) || ix.columns.length === 0) {
|
|
3669
|
+
throw _err("defineTable opts.indexes entry needs a non-empty columns array",
|
|
3670
|
+
"sql-builder/bad-index");
|
|
3671
|
+
}
|
|
3672
|
+
_pushIndex(ix.columns, ix.unique, ix.name);
|
|
3673
|
+
});
|
|
3674
|
+
}
|
|
3675
|
+
|
|
3676
|
+
return { statements: statements };
|
|
3677
|
+
}
|
|
3678
|
+
|
|
3679
|
+
// ---- Verb entry points ----------------------------------------------
|
|
3680
|
+
|
|
3681
|
+
/**
|
|
3682
|
+
* @primitive b.sql.select
|
|
3683
|
+
* @signature b.sql.select(table, opts?)
|
|
3684
|
+
* @since 0.14.29
|
|
3685
|
+
* @status stable
|
|
3686
|
+
* @related b.sql.insert, b.sql.update, b.sql.delete, b.sql.upsert
|
|
3687
|
+
*
|
|
3688
|
+
* Start a `SELECT` builder over `table` (a name, a `"schema.table"`, or a
|
|
3689
|
+
* `b.sql.table(...)` reference). Chain `columns` / aggregates /
|
|
3690
|
+
* `join` family / `where` family / `groupBy` / `having` / `orderBy` /
|
|
3691
|
+
* `limit` / `offset`, then call `toSql()` for `{ sql, params }`. Emits
|
|
3692
|
+
* bare default table names + `?` placeholders so `b.clusterStorage`
|
|
3693
|
+
* applies the cluster prefix + Postgres `$N` translation at execute time.
|
|
3694
|
+
*
|
|
3695
|
+
* @opts
|
|
3696
|
+
* dialect: string, // postgres | sqlite | mysql (default sqlite)
|
|
3697
|
+
* schema: string, // schema qualifier for the table
|
|
3698
|
+
* prefix: string, // operator app-table namespace prefix
|
|
3699
|
+
* alias: string, // table alias (for joins)
|
|
3700
|
+
* allowedColumns: array, // column-membership gate set
|
|
3701
|
+
* columnGateMode: string, // reject | warn | off
|
|
3702
|
+
*
|
|
3703
|
+
* @example
|
|
3704
|
+
* var b = require("@blamejs/core");
|
|
3705
|
+
* b.sql.select("users")
|
|
3706
|
+
* .columns(["id", "email"])
|
|
3707
|
+
* .where("status", "active")
|
|
3708
|
+
* .orderBy("createdAt", "desc")
|
|
3709
|
+
* .limit(10)
|
|
3710
|
+
* .toSql();
|
|
3711
|
+
* // -> { sql: 'SELECT "id", "email" FROM users WHERE "status" = ? ORDER BY "createdAt" DESC LIMIT 10',
|
|
3712
|
+
* // params: ["active"] }
|
|
3713
|
+
*/
|
|
3714
|
+
function select(tableNameOrRef, opts) { return new SelectBuilder(tableNameOrRef, opts); }
|
|
3715
|
+
|
|
3716
|
+
/**
|
|
3717
|
+
* @primitive b.sql.insert
|
|
3718
|
+
* @signature b.sql.insert(table, opts?)
|
|
3719
|
+
* @since 0.14.29
|
|
3720
|
+
* @status stable
|
|
3721
|
+
* @related b.sql.select, b.sql.upsert, b.sql.update
|
|
3722
|
+
*
|
|
3723
|
+
* Start an `INSERT` builder. Provide rows via `columns([...])` +
|
|
3724
|
+
* `values([...])` (positional), `values({ ... })` (one row object), or
|
|
3725
|
+
* `values([{...}, {...}])` (multi-row). Optional `returning(cols)`. The
|
|
3726
|
+
* value set is fully bound - every value becomes a `?` placeholder.
|
|
3727
|
+
*
|
|
3728
|
+
* @opts
|
|
3729
|
+
* dialect: string, // postgres | sqlite | mysql (default sqlite)
|
|
3730
|
+
* schema: string, // schema qualifier
|
|
3731
|
+
* prefix: string, // operator app-table namespace prefix
|
|
3732
|
+
* allowedColumns: array, // column-membership gate set
|
|
3733
|
+
*
|
|
3734
|
+
* @example
|
|
3735
|
+
* var b = require("@blamejs/core");
|
|
3736
|
+
* b.sql.insert("users")
|
|
3737
|
+
* .values({ id: 1, email: "a@b.c" })
|
|
3738
|
+
* .returning(["id"])
|
|
3739
|
+
* .toSql();
|
|
3740
|
+
* // -> { sql: 'INSERT INTO users ("id", "email") VALUES (?, ?) RETURNING "id"',
|
|
3741
|
+
* // params: [1, "a@b.c"] }
|
|
3742
|
+
*/
|
|
3743
|
+
function insert(tableNameOrRef, opts) { return new InsertBuilder(tableNameOrRef, opts); }
|
|
3744
|
+
|
|
3745
|
+
/**
|
|
3746
|
+
* @primitive b.sql.update
|
|
3747
|
+
* @signature b.sql.update(table, opts?)
|
|
3748
|
+
* @since 0.14.29
|
|
3749
|
+
* @status stable
|
|
3750
|
+
* @related b.sql.select, b.sql.insert, b.sql.delete
|
|
3751
|
+
*
|
|
3752
|
+
* Start an `UPDATE` builder. Set assignments via `set({ ... })` /
|
|
3753
|
+
* `set(col, val)` / `setRaw(col, expr, params)`; filter via the `where`
|
|
3754
|
+
* family. An update with no `where()` THROWS unless `allowNoWhere()` is
|
|
3755
|
+
* called - a deliberate full-table write must opt in. Optional
|
|
3756
|
+
* `returning(cols)`.
|
|
3757
|
+
*
|
|
3758
|
+
* @opts
|
|
3759
|
+
* dialect: string, // postgres | sqlite | mysql (default sqlite)
|
|
3760
|
+
* schema: string, // schema qualifier
|
|
3761
|
+
* prefix: string, // operator app-table namespace prefix
|
|
3762
|
+
* allowedColumns: array, // column-membership gate set
|
|
3763
|
+
*
|
|
3764
|
+
* @example
|
|
3765
|
+
* var b = require("@blamejs/core");
|
|
3766
|
+
* b.sql.update("users")
|
|
3767
|
+
* .set({ status: "inactive" })
|
|
3768
|
+
* .where("id", 1)
|
|
3769
|
+
* .toSql();
|
|
3770
|
+
* // -> { sql: 'UPDATE users SET "status" = ? WHERE "id" = ?', params: ["inactive", 1] }
|
|
3771
|
+
*/
|
|
3772
|
+
function update(tableNameOrRef, opts) { return new UpdateBuilder(tableNameOrRef, opts); }
|
|
3773
|
+
|
|
3774
|
+
/**
|
|
3775
|
+
* @primitive b.sql.delete
|
|
3776
|
+
* @signature b.sql.delete(table, opts?)
|
|
3777
|
+
* @since 0.14.29
|
|
3778
|
+
* @status stable
|
|
3779
|
+
* @related b.sql.select, b.sql.update, b.sql.insert
|
|
3780
|
+
*
|
|
3781
|
+
* Start a `DELETE` builder. Filter via the `where` family. A delete with
|
|
3782
|
+
* no `where()` THROWS unless `allowNoWhere()` is called. Optional
|
|
3783
|
+
* `returning(cols)`.
|
|
3784
|
+
*
|
|
3785
|
+
* @opts
|
|
3786
|
+
* dialect: string, // postgres | sqlite | mysql (default sqlite)
|
|
3787
|
+
* schema: string, // schema qualifier
|
|
3788
|
+
* prefix: string, // operator app-table namespace prefix
|
|
3789
|
+
* allowedColumns: array, // column-membership gate set
|
|
3790
|
+
*
|
|
3791
|
+
* @example
|
|
3792
|
+
* var b = require("@blamejs/core");
|
|
3793
|
+
* b.sql.delete("sessions")
|
|
3794
|
+
* .where("expiresAt", "<", 1700000000)
|
|
3795
|
+
* .toSql();
|
|
3796
|
+
* // -> { sql: 'DELETE FROM sessions WHERE "expiresAt" < ?', params: [1700000000] }
|
|
3797
|
+
*/
|
|
3798
|
+
function del(tableNameOrRef, opts) { return new DeleteBuilder(tableNameOrRef, opts); }
|
|
3799
|
+
|
|
3800
|
+
/**
|
|
3801
|
+
* @primitive b.sql.upsert
|
|
3802
|
+
* @signature b.sql.upsert(table, opts?)
|
|
3803
|
+
* @since 0.14.29
|
|
3804
|
+
* @status stable
|
|
3805
|
+
* @related b.sql.insert, b.sql.update, b.sql.select
|
|
3806
|
+
*
|
|
3807
|
+
* Start an `UPSERT` builder - the one verb that emits dialect-final
|
|
3808
|
+
* conflict syntax. Supply the row via `columns` + `values({...})`, the
|
|
3809
|
+
* conflict key via `onConflict(keys)`, and one conflict action:
|
|
3810
|
+
* `doUpdate(cols | { col: expr })`, `doUpdateFromExcluded(cols)`, or
|
|
3811
|
+
* `doNothing()`. Optional `conflictWhere(rawGuard, params, opts?)` fences
|
|
3812
|
+
* the update - pass `{ guardColumn: "<col>" }` to name the column the
|
|
3813
|
+
* fence protects so the MySQL fold emits it last (see below); optional
|
|
3814
|
+
* `returning(cols)`.
|
|
3815
|
+
*
|
|
3816
|
+
* On Postgres / SQLite `toSql()` returns
|
|
3817
|
+
* `{ sql, params }` emitting `ON CONFLICT (keys) DO UPDATE SET
|
|
3818
|
+
* col = EXCLUDED.col [WHERE ...] [RETURNING ...]`. On MySQL it returns
|
|
3819
|
+
* `{ sql, params, readbackSql }` emitting `ON DUPLICATE KEY UPDATE
|
|
3820
|
+
* col = VALUES(col)` (or `IF(guard, VALUES(col), col)` when
|
|
3821
|
+
* `conflictWhere` is set); MySQL evaluates the SET list left to right, so
|
|
3822
|
+
* when the fenced guard column is itself a SET target it must be assigned
|
|
3823
|
+
* last (each IF must see the guard column's pre-update value) - name it
|
|
3824
|
+
* via `conflictWhere(..., { guardColumn })` and the fold reorders it to
|
|
3825
|
+
* the end. MySQL has no per-statement WHERE / RETURNING on the conflict
|
|
3826
|
+
* action, so a readback `SELECT` keyed on the conflict columns is
|
|
3827
|
+
* returned for the caller to fetch the upserted row.
|
|
3828
|
+
*
|
|
3829
|
+
* @opts
|
|
3830
|
+
* dialect: string, // postgres | sqlite | mysql (default sqlite)
|
|
3831
|
+
* schema: string, // schema qualifier
|
|
3832
|
+
* prefix: string, // operator app-table namespace prefix
|
|
3833
|
+
* allowedColumns: array, // column-membership gate set
|
|
3834
|
+
*
|
|
3835
|
+
* @example
|
|
3836
|
+
* var b = require("@blamejs/core");
|
|
3837
|
+
* b.sql.upsert("audit_tip", { dialect: "postgres" })
|
|
3838
|
+
* .values({ id: 1, counter: 42 })
|
|
3839
|
+
* .onConflict(["id"])
|
|
3840
|
+
* .doUpdateFromExcluded(["counter"])
|
|
3841
|
+
* .toSql();
|
|
3842
|
+
* // -> { sql: 'INSERT INTO audit_tip ("id", "counter") VALUES (?, ?) ' +
|
|
3843
|
+
* // 'ON CONFLICT ("id") DO UPDATE SET "counter" = EXCLUDED."counter"',
|
|
3844
|
+
* // params: [1, 42] }
|
|
3845
|
+
*/
|
|
3846
|
+
function upsert(tableNameOrRef, opts) { return new UpsertBuilder(tableNameOrRef, opts); }
|
|
3847
|
+
|
|
3848
|
+
module.exports = {
|
|
3849
|
+
// Verbs
|
|
3850
|
+
select: select,
|
|
3851
|
+
insert: insert,
|
|
3852
|
+
update: update,
|
|
3853
|
+
delete: del,
|
|
3854
|
+
upsert: upsert,
|
|
3855
|
+
// Table reference
|
|
3856
|
+
table: table,
|
|
3857
|
+
// Value-position helpers (INSERT values() / UPDATE set() right-hand side)
|
|
3858
|
+
fn: fn,
|
|
3859
|
+
cast: cast,
|
|
3860
|
+
// Driver-final positional translation (for direct-driver callers: a DDL
|
|
3861
|
+
// { sql, params } result or a chainable builder -> $1..$N on postgres).
|
|
3862
|
+
toExternalSql: toExternalSql,
|
|
3863
|
+
// DDL
|
|
3864
|
+
createTable: createTable,
|
|
3865
|
+
createIndex: createIndex,
|
|
3866
|
+
alterTable: alterTable,
|
|
3867
|
+
dropTable: dropTable,
|
|
3868
|
+
createVirtualTable: createVirtualTable,
|
|
3869
|
+
defineTable: defineTable,
|
|
3870
|
+
// Row-Level Security (Postgres)
|
|
3871
|
+
enableRowLevelSecurity: enableRowLevelSecurity,
|
|
3872
|
+
disableRowLevelSecurity: disableRowLevelSecurity,
|
|
3873
|
+
createPolicy: createPolicy,
|
|
3874
|
+
dropPolicy: dropPolicy,
|
|
3875
|
+
// Catalog / PRAGMA (narrow audited sqlite-internal sub-API)
|
|
3876
|
+
catalog: catalog,
|
|
3877
|
+
pragma: pragma,
|
|
3878
|
+
// Error class
|
|
3879
|
+
SqlBuilderError: SqlBuilderError,
|
|
3880
|
+
// Exposed for the integrator: the operator-facing builder bases +
|
|
3881
|
+
// operator allowlist, so wiki harvesters + adjacent lib code can
|
|
3882
|
+
// instanceof-check a builder and the must-compose detector can scope.
|
|
3883
|
+
Builder: Builder,
|
|
3884
|
+
ALLOWED_OPS: ALLOWED_OPS,
|
|
3885
|
+
};
|