@zmdb/sql 1.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +35 -0
  3. package/dist/aggregations/index.d.ts +30 -0
  4. package/dist/aggregations/index.d.ts.map +1 -0
  5. package/dist/aggregations/index.js +52 -0
  6. package/dist/aggregations/index.js.map +1 -0
  7. package/dist/clauses.d.ts +112 -0
  8. package/dist/clauses.d.ts.map +1 -0
  9. package/dist/clauses.js +267 -0
  10. package/dist/clauses.js.map +1 -0
  11. package/dist/comments/index.d.ts +26 -0
  12. package/dist/comments/index.d.ts.map +1 -0
  13. package/dist/comments/index.js +28 -0
  14. package/dist/comments/index.js.map +1 -0
  15. package/dist/compiled-query.d.ts +12 -0
  16. package/dist/compiled-query.d.ts.map +1 -0
  17. package/dist/compiled-query.js +2 -0
  18. package/dist/compiled-query.js.map +1 -0
  19. package/dist/dialects/index.d.ts +19 -0
  20. package/dist/dialects/index.d.ts.map +1 -0
  21. package/dist/dialects/index.js +43 -0
  22. package/dist/dialects/index.js.map +1 -0
  23. package/dist/dialects/protocol.d.ts +230 -0
  24. package/dist/dialects/protocol.d.ts.map +1 -0
  25. package/dist/dialects/protocol.js +230 -0
  26. package/dist/dialects/protocol.js.map +1 -0
  27. package/dist/errors.d.ts +9 -0
  28. package/dist/errors.d.ts.map +1 -0
  29. package/dist/errors.js +17 -0
  30. package/dist/errors.js.map +1 -0
  31. package/dist/expressions/index.d.ts +50 -0
  32. package/dist/expressions/index.d.ts.map +1 -0
  33. package/dist/expressions/index.js +77 -0
  34. package/dist/expressions/index.js.map +1 -0
  35. package/dist/extensions/index.d.ts +85 -0
  36. package/dist/extensions/index.d.ts.map +1 -0
  37. package/dist/extensions/index.js +120 -0
  38. package/dist/extensions/index.js.map +1 -0
  39. package/dist/fts/index.d.ts +28 -0
  40. package/dist/fts/index.d.ts.map +1 -0
  41. package/dist/fts/index.js +110 -0
  42. package/dist/fts/index.js.map +1 -0
  43. package/dist/index.d.ts +128 -0
  44. package/dist/index.d.ts.map +1 -0
  45. package/dist/index.js +364 -0
  46. package/dist/index.js.map +1 -0
  47. package/dist/introspect/types.d.ts +58 -0
  48. package/dist/introspect/types.d.ts.map +1 -0
  49. package/dist/introspect/types.js +2 -0
  50. package/dist/introspect/types.js.map +1 -0
  51. package/dist/joins/index.d.ts +20 -0
  52. package/dist/joins/index.d.ts.map +1 -0
  53. package/dist/joins/index.js +24 -0
  54. package/dist/joins/index.js.map +1 -0
  55. package/dist/migrations/types.d.ts +100 -0
  56. package/dist/migrations/types.d.ts.map +1 -0
  57. package/dist/migrations/types.js +2 -0
  58. package/dist/migrations/types.js.map +1 -0
  59. package/dist/quoting.d.ts +40 -0
  60. package/dist/quoting.d.ts.map +1 -0
  61. package/dist/quoting.js +98 -0
  62. package/dist/quoting.js.map +1 -0
  63. package/dist/schema-objects/extensions.d.ts +5 -0
  64. package/dist/schema-objects/extensions.d.ts.map +1 -0
  65. package/dist/schema-objects/extensions.js +9 -0
  66. package/dist/schema-objects/extensions.js.map +1 -0
  67. package/dist/schema-objects/index.d.ts +25 -0
  68. package/dist/schema-objects/index.d.ts.map +1 -0
  69. package/dist/schema-objects/index.js +86 -0
  70. package/dist/schema-objects/index.js.map +1 -0
  71. package/dist/schema-objects/types.d.ts +62 -0
  72. package/dist/schema-objects/types.d.ts.map +1 -0
  73. package/dist/schema-objects/types.js +2 -0
  74. package/dist/schema-objects/types.js.map +1 -0
  75. package/dist/set-ops/index.d.ts +16 -0
  76. package/dist/set-ops/index.d.ts.map +1 -0
  77. package/dist/set-ops/index.js +41 -0
  78. package/dist/set-ops/index.js.map +1 -0
  79. package/package.json +70 -0
  80. package/src/aggregations/index.ts +127 -0
  81. package/src/clauses.ts +387 -0
  82. package/src/comments/index.ts +49 -0
  83. package/src/compiled-query.ts +12 -0
  84. package/src/dialects/index.ts +103 -0
  85. package/src/dialects/protocol.ts +478 -0
  86. package/src/errors.ts +18 -0
  87. package/src/expressions/index.ts +127 -0
  88. package/src/extensions/index.ts +235 -0
  89. package/src/fts/index.ts +157 -0
  90. package/src/index.ts +685 -0
  91. package/src/introspect/types.ts +60 -0
  92. package/src/joins/index.ts +69 -0
  93. package/src/migrations/types.ts +93 -0
  94. package/src/quoting.ts +111 -0
  95. package/src/schema-objects/extensions.ts +12 -0
  96. package/src/schema-objects/index.ts +125 -0
  97. package/src/schema-objects/types.ts +73 -0
  98. package/src/set-ops/index.ts +48 -0
package/package.json ADDED
@@ -0,0 +1,70 @@
1
+ {
2
+ "name": "@zmdb/sql",
3
+ "version": "1.0.0-beta.1",
4
+ "description": "SQL-first, dialect-aware query compiler with reads, writes, joins, aggregates, full-text search, set operations, and schema-object DDL.",
5
+ "keywords": [
6
+ "mysql",
7
+ "postgres",
8
+ "query-builder",
9
+ "sql",
10
+ "sqlite",
11
+ "typescript",
12
+ "zmdb"
13
+ ],
14
+ "homepage": "https://github.com/ambasta/zmdb#readme",
15
+ "bugs": {
16
+ "url": "https://github.com/ambasta/zmdb/issues"
17
+ },
18
+ "license": "GPL-3.0-or-later",
19
+ "author": "zmdb contributors",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/ambasta/zmdb.git",
23
+ "directory": "packages/sql"
24
+ },
25
+ "type": "module",
26
+ "sideEffects": false,
27
+ "exports": {
28
+ ".": {
29
+ "types": "./dist/index.d.ts",
30
+ "import": "./dist/index.js"
31
+ },
32
+ "./comments": {
33
+ "types": "./dist/comments/index.d.ts",
34
+ "import": "./dist/comments/index.js"
35
+ },
36
+ "./fts": {
37
+ "types": "./dist/fts/index.d.ts",
38
+ "import": "./dist/fts/index.js"
39
+ },
40
+ "./joins": {
41
+ "types": "./dist/joins/index.d.ts",
42
+ "import": "./dist/joins/index.js"
43
+ },
44
+ "./aggregations": {
45
+ "types": "./dist/aggregations/index.d.ts",
46
+ "import": "./dist/aggregations/index.js"
47
+ },
48
+ "./set-ops": {
49
+ "types": "./dist/set-ops/index.d.ts",
50
+ "import": "./dist/set-ops/index.js"
51
+ },
52
+ "./schema-objects": {
53
+ "types": "./dist/schema-objects/index.d.ts",
54
+ "import": "./dist/schema-objects/index.js"
55
+ }
56
+ },
57
+ "publishConfig": {
58
+ "access": "public",
59
+ "tag": "beta"
60
+ },
61
+ "scripts": {
62
+ "build": "node ../../scripts/build-package.mjs",
63
+ "test": "vitest run"
64
+ },
65
+ "engines": {
66
+ "node": ">=26"
67
+ },
68
+ "main": "./dist/index.js",
69
+ "types": "./dist/index.d.ts"
70
+ }
@@ -0,0 +1,127 @@
1
+ import {
2
+ type ComparisonPredicate,
3
+ type JoinCondition,
4
+ type JoinSpec,
5
+ type Predicate,
6
+ frozenQuery,
7
+ havingClause,
8
+ joinClauses,
9
+ joinMethods,
10
+ queryTelemetry,
11
+ tailClause,
12
+ tailMethods,
13
+ whereClause,
14
+ } from '../clauses.js';
15
+ import type { DialectTarget } from '../dialects/index.js';
16
+ import { type CompiledQuery, type QueryCompilerOptions } from '../index.js';
17
+ import { quoteColumn, quoteIdentifier, quoteTable } from '../quoting.js';
18
+
19
+ export type { JoinCondition, JoinKind } from '../clauses.js';
20
+
21
+ type SelectItem =
22
+ | { kind: 'col'; col: string }
23
+ | { kind: 'agg'; fn: 'COUNT' | 'SUM' | 'AVG' | 'MIN' | 'MAX'; col: string; alias: string }
24
+ | { kind: 'expr'; raw: string; alias: string };
25
+
26
+ interface Comparison {
27
+ col: string;
28
+ op: string;
29
+ value: unknown;
30
+ connector?: 'AND' | 'OR';
31
+ }
32
+
33
+ interface State {
34
+ readonly table: string;
35
+ readonly items: readonly SelectItem[];
36
+ readonly joins: readonly JoinSpec[];
37
+ readonly wheres: readonly Predicate[];
38
+ readonly groups: readonly string[];
39
+ readonly havings: readonly Comparison[];
40
+ readonly orderBys: readonly { col: string; dir: 'asc' | 'desc' }[];
41
+ readonly limitN?: number;
42
+ readonly offsetN?: number;
43
+ }
44
+
45
+ export interface AggregateSelect {
46
+ select(cols: readonly string[]): AggregateSelect;
47
+ count(expr: string, alias: string): AggregateSelect;
48
+ sum(expr: string, alias: string): AggregateSelect;
49
+ avg(expr: string, alias: string): AggregateSelect;
50
+ min(expr: string, alias: string): AggregateSelect;
51
+ max(expr: string, alias: string): AggregateSelect;
52
+ expr(rawExpr: string, alias: string): AggregateSelect;
53
+ innerJoin(target: string, leftCol: string, rightCol: string, on?: readonly Predicate[]): AggregateSelect;
54
+ innerJoin(target: string, conditions: readonly JoinCondition[], on?: readonly Predicate[]): AggregateSelect;
55
+ leftJoin(target: string, leftCol: string, rightCol: string, on?: readonly Predicate[]): AggregateSelect;
56
+ leftJoin(target: string, conditions: readonly JoinCondition[], on?: readonly Predicate[]): AggregateSelect;
57
+ rightJoin(target: string, leftCol: string, rightCol: string, on?: readonly Predicate[]): AggregateSelect;
58
+ rightJoin(target: string, conditions: readonly JoinCondition[], on?: readonly Predicate[]): AggregateSelect;
59
+ where(col: string, op: string, value: unknown): AggregateSelect;
60
+ orWhere(col: string, op: string, value: unknown): AggregateSelect;
61
+ whereGroup(predicates: readonly ComparisonPredicate[]): AggregateSelect;
62
+ groupBy(...cols: string[]): AggregateSelect;
63
+ having(col: string, op: string, value: unknown): AggregateSelect;
64
+ orderBy(col: string, dir: 'asc' | 'desc'): AggregateSelect;
65
+ limit(n: number): AggregateSelect;
66
+ offset(n: number): AggregateSelect;
67
+ compile(): CompiledQuery;
68
+ }
69
+
70
+ function make(d: DialectTarget, s: State, telemetry: boolean): AggregateSelect {
71
+ const next = (p: Partial<State>): AggregateSelect => make(d, { ...s, ...p }, telemetry);
72
+ const agg = (fn: 'COUNT' | 'SUM' | 'AVG' | 'MIN' | 'MAX', col: string, alias: string) =>
73
+ next({ items: [...s.items, { kind: 'agg', fn, col, alias }] });
74
+
75
+ return {
76
+ ...joinMethods(s.joins, next),
77
+ ...tailMethods(s, next),
78
+ select: cols => next({ items: [...s.items, ...cols.map((c): SelectItem => ({ kind: 'col', col: c }))] }),
79
+ count: (e, a) => agg('COUNT', e, a),
80
+ sum: (e, a) => agg('SUM', e, a),
81
+ avg: (e, a) => agg('AVG', e, a),
82
+ min: (e, a) => agg('MIN', e, a),
83
+ max: (e, a) => agg('MAX', e, a),
84
+ expr: (raw, alias) => next({ items: [...s.items, { kind: 'expr', raw, alias }] }),
85
+ where: (col, op, value) => next({ wheres: [...s.wheres, { col, op, value, connector: 'AND' }] }),
86
+ orWhere: (col, op, value) => next({ wheres: [...s.wheres, { col, op, value, connector: 'OR' }] }),
87
+ whereGroup: predicates => next({ wheres: [...s.wheres, { kind: 'group', predicates, connector: 'AND' }] }),
88
+ groupBy: (...cols) => next({ groups: [...s.groups, ...cols] }),
89
+ having: (col, op, value) => next({ havings: [...s.havings, { col, op, value }] }),
90
+ compile: () => {
91
+ const params: unknown[] = [];
92
+ const cols = s.items.map(it => {
93
+ if (it.kind === 'col') {
94
+ const m = /^(\S+)\s+as\s+(\S+)$/i.exec(it.col.trim());
95
+ if (m && m[1] && m[2]) return `${quoteColumn(d, m[1])} AS ${quoteIdentifier(d, m[2])}`;
96
+ if (it.col.includes('.') && !it.col.toLowerCase().startsWith(`${s.table.toLowerCase()}.`)) {
97
+ return `${quoteColumn(d, it.col)} AS ${quoteIdentifier(d, it.col)}`;
98
+ }
99
+ return quoteColumn(d, it.col);
100
+ }
101
+ if (it.kind === 'agg') return `${it.fn}(${quoteColumn(d, it.col)}) AS ${quoteIdentifier(d, it.alias)}`;
102
+ return `${it.raw} AS ${quoteIdentifier(d, it.alias)}`;
103
+ });
104
+ const groupBy = s.groups.length > 0 ? ` GROUP BY ${s.groups.map(c => quoteColumn(d, c)).join(', ')}` : '';
105
+ const text =
106
+ `SELECT ${cols.join(', ')} FROM ${quoteTable(d, s.table)}` +
107
+ joinClauses(d, s.joins, params) +
108
+ whereClause(d, s.wheres, params) +
109
+ groupBy +
110
+ havingClause(d, s.havings, params) +
111
+ tailClause(d, s);
112
+ return frozenQuery(text, params, queryTelemetry(d, 'SELECT', s.table, telemetry));
113
+ },
114
+ };
115
+ }
116
+
117
+ export function aggregateSelectFrom(
118
+ table: string,
119
+ dialect: DialectTarget,
120
+ options?: QueryCompilerOptions,
121
+ ): AggregateSelect {
122
+ return make(
123
+ dialect,
124
+ { table, items: [], joins: [], wheres: [], groups: [], havings: [], orderBys: [] },
125
+ options?.telemetry === true,
126
+ );
127
+ }
package/src/clauses.ts ADDED
@@ -0,0 +1,387 @@
1
+ // Clause rendering shared by every builder in this package.
2
+ //
3
+ // SELECT, the join builder, the aggregate builder, FTS, UPDATE and DELETE all
4
+ // have to turn the same three things into SQL: a predicate list, a join list,
5
+ // and the ORDER BY / LIMIT / OFFSET tail. Each builder used to carry its own
6
+ // copy, and the copies had drifted: the aggregate builder's `having` understood
7
+ // a subquery but its `where` pushed the builder object into the parameter list
8
+ // as if it were a value, and three of them reimplemented `renumberPlaceholders`
9
+ // inline rather than importing the one in ./quoting.ts.
10
+ //
11
+ // Everything here appends its own leading space and returns '' when it has
12
+ // nothing to render, so callers concatenate unconditionally.
13
+ import { dialectName, dialectTraits, type DialectTarget } from './dialects/index.js';
14
+ import { UnsupportedFeatureError } from './errors.js';
15
+ import {
16
+ DISTANCE_OPERATORS,
17
+ encodePgVector,
18
+ isDistanceOp,
19
+ renderSpatialPredicate,
20
+ type SpatialPredicateNode,
21
+ } from './extensions/index.js';
22
+ import { type CompiledQuery, type QueryTelemetry } from './index.js';
23
+ import { formatPlaceholder, quoteColumn, quoteTable, renumberPlaceholders, unaliasedTable } from './quoting.js';
24
+
25
+ export type JoinKind = 'inner' | 'left' | 'right';
26
+
27
+ export interface JoinCondition {
28
+ readonly leftCol: string;
29
+ readonly rightCol: string;
30
+ }
31
+
32
+ export interface JoinSpec {
33
+ readonly kind: JoinKind;
34
+ readonly target: string;
35
+ readonly conditions: readonly JoinCondition[];
36
+ /** Predicates that belong to this target and therefore stay in the ON clause. */
37
+ readonly on?: readonly Predicate[];
38
+ }
39
+
40
+ /**
41
+ * One comparison in a WHERE or HAVING list. `connector` says how it attaches to
42
+ * the predicate before it and is ignored on the first one; builders that only
43
+ * ever conjoin can leave it out.
44
+ */
45
+ export interface ComparisonPredicate {
46
+ readonly kind?: 'comparison';
47
+ readonly col: string;
48
+ readonly op: string;
49
+ readonly value: unknown;
50
+ readonly connector?: 'AND' | 'OR' | undefined;
51
+ }
52
+
53
+ /** One parenthesized predicate list, attached to the surrounding list by `connector`. */
54
+ export interface PredicateGroup {
55
+ readonly kind: 'group';
56
+ readonly predicates: readonly Predicate[];
57
+ readonly connector?: 'AND' | 'OR' | undefined;
58
+ }
59
+
60
+ export type Predicate = ComparisonPredicate | SpatialPredicateNode | PredicateGroup;
61
+
62
+ export interface Tail<C = string> {
63
+ readonly orderBys?: readonly { readonly col: C; readonly dir: 'asc' | 'desc' }[] | undefined;
64
+ readonly limitN?: number | undefined;
65
+ readonly offsetN?: number | undefined;
66
+ /** Used by builders that render a richer ORDER BY expression before delegating pagination. */
67
+ readonly ordered?: boolean | undefined;
68
+ }
69
+
70
+ const JOIN_KEYWORD: Record<JoinKind, string> = {
71
+ inner: 'INNER JOIN',
72
+ left: 'LEFT JOIN',
73
+ right: 'RIGHT JOIN',
74
+ };
75
+
76
+ // Object.create(null) prevents prototype inheritance so an operator named 'constructor' cannot resolve through Object.prototype.
77
+ export const OP_MAP: Readonly<Record<string, string>> = Object.freeze(
78
+ Object.assign(Object.create(null), {
79
+ '=': '=',
80
+ '!=': '!=',
81
+ '<': '<',
82
+ '<=': '<=',
83
+ '>': '>',
84
+ '>=': '>=',
85
+ like: 'LIKE',
86
+ ilike: 'ILIKE',
87
+ in: 'IN',
88
+ 'not in': 'NOT IN',
89
+ nin: 'NOT IN',
90
+ exists: 'EXISTS',
91
+ 'not exists': 'NOT EXISTS',
92
+ 'is null': 'IS NULL',
93
+ 'is not null': 'IS NOT NULL',
94
+ ...DISTANCE_OPERATORS,
95
+ }),
96
+ );
97
+
98
+ /**
99
+ * The lexical shape of an operator the compiler does not know by name.
100
+ *
101
+ * This is deliberately not a dialect/operator allowlist: PostgreSQL extensions,
102
+ * SQLite GLOB, MySQL's null-safe equality and SQL Server's !< all remain usable.
103
+ * It is only the boundary between one SQL token and caller-controlled SQL text.
104
+ *
105
+ * `--` is excluded even though `-` is needed by real operators such as `->>`.
106
+ * `#` is restricted to PostgreSQL's `#>` / `#>>`; a bare hash starts a MySQL
107
+ * line comment. Placeholder markers are excluded on the dialects where they
108
+ * would be parsed as parameters. Slash is absent, so block-comment openers
109
+ * cannot be formed.
110
+ */
111
+ function isUnmappedOperatorToken(op: string, dialect: DialectTarget): boolean {
112
+ return dialectTraits(dialect).acceptsOperator(op);
113
+ }
114
+
115
+ /**
116
+ * Anything with a `compile()` — a builder from this package, or a caller's own.
117
+ *
118
+ * boundary: the cast is inside the guard that the rest of the package relies on, and it
119
+ * reads the one property the `in` check on the line above has just proven is there. Its
120
+ * type is `unknown`, so the `typeof` is what establishes anything; a narrower cast would be
121
+ * the claim this function exists to test.
122
+ */
123
+ export function isSubqueryTarget(value: unknown): value is { compile(): CompiledQuery } {
124
+ return (
125
+ value !== null &&
126
+ typeof value === 'object' &&
127
+ 'compile' in value &&
128
+ typeof (value as { compile?: unknown }).compile === 'function'
129
+ );
130
+ }
131
+
132
+ /**
133
+ * Normalizes known operators to canonical SQL keywords and admits an unmapped
134
+ * operator only when it is one bounded SQL token.
135
+ */
136
+ export function sqlOperator(op: string, dialect: DialectTarget): string {
137
+ const normalized = op.toLowerCase().trim();
138
+ if (isDistanceOp(normalized) && !dialectTraits(dialect).vectorDistance) {
139
+ throw new UnsupportedFeatureError(normalized, dialectName(dialect));
140
+ }
141
+ // A plain index read, and no own-property guard: `OP_MAP` has a null prototype, so
142
+ // `OP_MAP['constructor']` is already `undefined` rather than a function off
143
+ // `Object.prototype`. That is what makes `??` safe here, and it is why the map is built
144
+ // the way it is.
145
+ const mapped = OP_MAP[normalized];
146
+ if (mapped !== undefined) return mapped;
147
+ if (!isUnmappedOperatorToken(op, dialect)) {
148
+ const name = dialectName(dialect);
149
+ throw new TypeError(
150
+ `invalid unmapped SQL operator ${JSON.stringify(op)} for dialect ${JSON.stringify(name)}; expected ` +
151
+ 'one non-comment operator token that does not conflict with the dialect placeholder syntax',
152
+ );
153
+ }
154
+ return op;
155
+ }
156
+
157
+ /** `col op $n`, or `EXISTS (…)` / `col op (…)` when the value is a subquery. */
158
+ export function renderPredicate(dialect: DialectTarget, p: Predicate, params: unknown[]): string {
159
+ if (p.kind === 'group') {
160
+ if (p.predicates.length === 0) throw new TypeError('predicate groups must not be empty');
161
+ return `(${predicateList(dialect, p.predicates, params)})`;
162
+ }
163
+ if (p.kind === 'spatial') return renderSpatialPredicate(dialect, p, params);
164
+ const normalized = p.op.toLowerCase().trim();
165
+ const sqlOp = sqlOperator(p.op, dialect);
166
+
167
+ if (sqlOp === 'IS NULL' || sqlOp === 'IS NOT NULL') {
168
+ return `${quoteColumn(dialect, p.col)} ${sqlOp}`;
169
+ }
170
+
171
+ if (isDistanceOp(normalized)) {
172
+ params.push(encodePgVector(p.value));
173
+ return `${quoteColumn(dialect, p.col)} ${sqlOp} ${formatPlaceholder(dialect, params.length)}`;
174
+ }
175
+
176
+ if (sqlOp === 'IS NULL' || sqlOp === 'IS NOT NULL') {
177
+ return `${quoteColumn(dialect, p.col)} ${sqlOp}`;
178
+ }
179
+
180
+ if (isSubqueryTarget(p.value)) {
181
+ const sub = p.value.compile();
182
+ // Continue the outer statement's numbering. Positional placeholders are a
183
+ // no-op here, so the order of the pushes below is what matters.
184
+ const text = renumberPlaceholders(sub.text, params.length, dialect);
185
+ params.push(...sub.parameters);
186
+
187
+ if (sqlOp === 'EXISTS') return `EXISTS (${text})`;
188
+ if (sqlOp === 'NOT EXISTS') return `NOT EXISTS (${text})`;
189
+ return `${quoteColumn(dialect, p.col)} ${sqlOp} (${text})`;
190
+ }
191
+
192
+ if (sqlOp === 'IN' || sqlOp === 'NOT IN') {
193
+ const isNotIn = sqlOp === 'NOT IN';
194
+ let arr = Array.isArray(p.value) ? p.value : [p.value];
195
+ if (isNotIn) {
196
+ arr = arr.filter(item => item !== null && item !== undefined);
197
+ }
198
+ if (arr.length === 0) {
199
+ return isNotIn ? '1 = 1' : '1 = 0';
200
+ }
201
+ const placeholders = arr
202
+ .map(item => {
203
+ params.push(item);
204
+ return formatPlaceholder(dialect, params.length);
205
+ })
206
+ .join(', ');
207
+ return `${quoteColumn(dialect, p.col)} ${sqlOp} (${placeholders})`;
208
+ }
209
+
210
+ params.push(p.value);
211
+ return `${quoteColumn(dialect, p.col)} ${sqlOp} ${formatPlaceholder(dialect, params.length)}`;
212
+ }
213
+
214
+ function predicateList(dialect: DialectTarget, preds: readonly Predicate[], params: unknown[]): string {
215
+ return preds
216
+ .map((p, i) => {
217
+ const cond = renderPredicate(dialect, p, params);
218
+ return i === 0 ? cond : `${p.connector ?? 'AND'} ${cond}`;
219
+ })
220
+ .join(' ');
221
+ }
222
+
223
+ /** ` WHERE …`, appending each predicate's parameters to `params` in order. */
224
+ export function whereClause(dialect: DialectTarget, preds: readonly Predicate[], params: unknown[]): string {
225
+ if (preds.length === 0) return '';
226
+ return ` WHERE ${predicateList(dialect, preds, params)}`;
227
+ }
228
+
229
+ /** ` HAVING …` — same rendering as WHERE, which is why they share a code path. */
230
+ export function havingClause(dialect: DialectTarget, preds: readonly Predicate[], params: unknown[]): string {
231
+ if (preds.length === 0) return '';
232
+ return ` HAVING ${predicateList(dialect, preds, params)}`;
233
+ }
234
+
235
+ /** ` INNER JOIN … ON … = … [AND … = …] [AND …]` for each join, in order. */
236
+ export function joinClauses(dialect: DialectTarget, joins: readonly JoinSpec[], params: unknown[] = []): string {
237
+ return joins
238
+ .map(j => {
239
+ const conditions = j.conditions
240
+ .map(condition => `${quoteColumn(dialect, condition.leftCol)} = ${quoteColumn(dialect, condition.rightCol)}`)
241
+ .join(' AND ');
242
+ const targetPredicates =
243
+ j.on === undefined || j.on.length === 0
244
+ ? ''
245
+ : j.on
246
+ .map((predicate, index) => {
247
+ const rendered = renderPredicate(dialect, predicate, params);
248
+ return `${index === 0 ? 'AND' : (predicate.connector ?? 'AND')} ${rendered}`;
249
+ })
250
+ .join(' ');
251
+ return (
252
+ ` ${JOIN_KEYWORD[j.kind]} ${quoteTable(dialect, j.target)} ` +
253
+ `ON ${conditions}` +
254
+ (targetPredicates.length === 0 ? '' : ` ${targetPredicates}`)
255
+ );
256
+ })
257
+ .join('');
258
+ }
259
+
260
+ /**
261
+ * ` ORDER BY … LIMIT n OFFSET n`. LIMIT and OFFSET are interpolated rather than
262
+ * parameterized because they are numbers this package produced, never caller
263
+ * strings — the builders' `limit`/`offset` take `number`.
264
+ */
265
+ export function tailClause(dialect: DialectTarget, tail: Tail): string {
266
+ let text = '';
267
+ const rendersOrderBy = tail.orderBys !== undefined && tail.orderBys.length > 0;
268
+ const ordered = tail.ordered ?? rendersOrderBy;
269
+ if (rendersOrderBy) {
270
+ const ob = tail.orderBys.map(o => `${quoteColumn(dialect, o.col)} ${o.dir.toUpperCase()}`).join(', ');
271
+ text += ` ORDER BY ${ob}`;
272
+ }
273
+ text += dialectTraits(dialect).paginate({
274
+ ...(tail.limitN === undefined ? {} : { limit: tail.limitN }),
275
+ ...(tail.offsetN === undefined ? {} : { offset: tail.offsetN }),
276
+ ordered,
277
+ });
278
+ return text;
279
+ }
280
+
281
+ /** Every `compile()` in this package returns this shape, frozen at both levels. */
282
+ export function frozenQuery(text: string, params: readonly unknown[], telemetry?: QueryTelemetry): CompiledQuery {
283
+ const parameters = Object.freeze([...params]);
284
+ return telemetry === undefined ? Object.freeze({ text, parameters }) : Object.freeze({ text, parameters, telemetry });
285
+ }
286
+
287
+ /** Compile-known database attributes, absent when telemetry was not requested. */
288
+ export function queryTelemetry(
289
+ dialect: DialectTarget,
290
+ operation: QueryTelemetry['operation'],
291
+ collection: string,
292
+ enabled: boolean,
293
+ ): QueryTelemetry | undefined {
294
+ if (!enabled) return undefined;
295
+ return Object.freeze({
296
+ system: dialect.telemetrySystem,
297
+ operation,
298
+ collection: unaliasedTable(collection),
299
+ });
300
+ }
301
+
302
+ // --- builder wiring --------------------------------------------------------
303
+ //
304
+ // The rendering above is shared, but each builder still wired the same methods
305
+ // onto its own state by hand. The two below are the ones that were identical in
306
+ // three places (`orderBy`/`limit`/`offset`) and two (`innerJoin` and friends),
307
+ // which is one drift away from the bugs the header describes.
308
+ //
309
+ // Both take the current value and the builder's own `next`, and hand back
310
+ // methods returning whatever `next` returns — so each builder keeps its own
311
+ // state type and its own return type, and no cast is needed in either
312
+ // direction. That works because they ask for the *narrowest* patch they could
313
+ // pass: `TailPatch` is assignable to every builder's `Partial<State>`, so
314
+ // contravariance makes each builder's `next` acceptable here.
315
+ //
316
+ // It does require the state's arrays be `readonly`, which they should be
317
+ // anyway: `next` replaces them, nothing appends in place.
318
+
319
+ /**
320
+ * What {@link tailMethods} passes to a builder's `next`. Deliberately not
321
+ * `Partial<Tail>`: under `exactOptionalPropertyTypes` an optional property that
322
+ * also admits `undefined` is not assignable to one that doesn't, and no
323
+ * builder's state wants an explicit `undefined` written over its array.
324
+ */
325
+ export interface TailPatch<C = string> {
326
+ readonly orderBys?: readonly { readonly col: C; readonly dir: 'asc' | 'desc' }[];
327
+ readonly limitN?: number;
328
+ readonly offsetN?: number;
329
+ }
330
+
331
+ /** `orderBy` / `limit` / `offset`, for any state carrying a {@link Tail}. */
332
+ export function tailMethods<C, B>(tail: Tail<C>, next: (patch: TailPatch<C>) => B) {
333
+ return {
334
+ orderBy: (col: C, dir: 'asc' | 'desc'): B => next({ orderBys: [...(tail.orderBys ?? []), { col, dir }] }),
335
+ limit: (n: number): B => next({ limitN: n }),
336
+ offset: (n: number): B => next({ offsetN: n }),
337
+ };
338
+ }
339
+
340
+ export interface JoinMethod<B> {
341
+ (target: string, leftCol: string, rightCol: string, on?: readonly Predicate[]): B;
342
+ (target: string, conditions: readonly JoinCondition[], on?: readonly Predicate[]): B;
343
+ }
344
+
345
+ /** `innerJoin` / `leftJoin` / `rightJoin`, for any state carrying a join list. */
346
+ export function joinMethods<B>(joins: readonly JoinSpec[], next: (patch: { joins: readonly JoinSpec[] }) => B) {
347
+ const add = (kind: JoinKind): JoinMethod<B> => {
348
+ function join(target: string, leftCol: string, rightCol: string, on?: readonly Predicate[]): B;
349
+ function join(target: string, conditions: readonly JoinCondition[], on?: readonly Predicate[]): B;
350
+ function join(
351
+ target: string,
352
+ leftColOrConditions: string | readonly JoinCondition[],
353
+ rightColOrOn?: string | readonly Predicate[],
354
+ scalarOn?: readonly Predicate[],
355
+ ): B {
356
+ const conditions =
357
+ typeof leftColOrConditions === 'string'
358
+ ? typeof rightColOrOn === 'string'
359
+ ? [{ leftCol: leftColOrConditions, rightCol: rightColOrOn }]
360
+ : []
361
+ : leftColOrConditions;
362
+ if (conditions.length === 0) {
363
+ throw new RangeError(`join "${target}" needs at least one ON condition`);
364
+ }
365
+ const on = typeof leftColOrConditions === 'string' ? scalarOn : rightColOrOn;
366
+ if (on !== undefined && typeof on === 'string') {
367
+ throw new TypeError(`join "${target}" received an invalid ON predicate list`);
368
+ }
369
+ return next({
370
+ joins: [
371
+ ...joins,
372
+ {
373
+ kind,
374
+ target,
375
+ conditions: conditions.map(condition => ({
376
+ leftCol: condition.leftCol,
377
+ rightCol: condition.rightCol,
378
+ })),
379
+ ...(on === undefined ? {} : { on }),
380
+ },
381
+ ],
382
+ });
383
+ }
384
+ return join;
385
+ };
386
+ return { innerJoin: add('inner'), leftJoin: add('left'), rightJoin: add('right') };
387
+ }
@@ -0,0 +1,49 @@
1
+ import { type CompiledQuery } from '../index.js';
2
+
3
+ /** The closed sqlcommenter key set. */
4
+ export type CommentKey = 'traceparent' | 'controller' | 'action' | 'route' | 'framework';
5
+
6
+ /** Values available to one query execution. */
7
+ export type CommentPairs = Readonly<Partial<Record<CommentKey, string>>>;
8
+
9
+ /** A non-empty configured key list: absence, not an empty list, means off. */
10
+ export type CommentKeys = readonly [CommentKey, ...CommentKey[]];
11
+
12
+ interface ExecuteOptions {
13
+ readonly signal?: AbortSignal;
14
+ readonly batchSize?: number;
15
+ }
16
+
17
+ const encode = (value: string): string => encodeURIComponent(value).replace(/'/g, "\\'");
18
+
19
+ /** Serialize the inside of a sqlcommenter block in deterministic key order. */
20
+ export function serializeComment(pairs: CommentPairs): string {
21
+ return Object.entries(pairs)
22
+ .toSorted(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
23
+ .map(([key, value]) => `${encode(key)}='${encode(value)}'`)
24
+ .join(',');
25
+ }
26
+
27
+ /** Append one trailing sqlcommenter block, or return the original text unchanged. */
28
+ export function appendComment(text: string, pairs: CommentPairs): string {
29
+ const serialized = serializeComment(pairs);
30
+ return serialized.length === 0 ? text : `${text} /*${serialized}*/`;
31
+ }
32
+
33
+ /**
34
+ * Render request-scoped comments at execution time without mutating or widening
35
+ * the reusable compiled query.
36
+ */
37
+ export function withComments<
38
+ D extends {
39
+ execute(query: CompiledQuery, options?: ExecuteOptions): Promise<readonly Record<string, unknown>[]>;
40
+ },
41
+ >(driver: D, pairs: () => CommentPairs) {
42
+ return {
43
+ ...driver,
44
+ execute(query: CompiledQuery, options?: ExecuteOptions): Promise<readonly Record<string, unknown>[]> {
45
+ const text = appendComment(query.text, pairs());
46
+ return driver.execute(text === query.text ? query : { ...query, text }, options);
47
+ },
48
+ };
49
+ }
@@ -0,0 +1,12 @@
1
+ export interface CompiledQuery {
2
+ readonly text: string;
3
+ readonly parameters: readonly unknown[];
4
+ readonly telemetry?: QueryTelemetry;
5
+ }
6
+
7
+ /** Compile-time database attributes consumed by tracing and metrics. */
8
+ export interface QueryTelemetry {
9
+ readonly system: string;
10
+ readonly operation: 'SELECT' | 'INSERT' | 'UPDATE' | 'DELETE';
11
+ readonly collection: string;
12
+ }