@happyvertical/sql 0.105.12 → 0.106.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.
@@ -1,6 +1,119 @@
1
1
  import { a as raw, i as parseConditionKey, n as buildWhere, s as unwrapRawKey } from "./schema-manager-tfASrb_b.js";
2
2
  import { DatabaseError } from "@happyvertical/utils";
3
+ //#region src/aggregate-expression.ts
4
+ var MAX_DEPTH = 24;
5
+ /** Node budget for a whole spec (every select item, join, filter and predicate together). */
6
+ var MAX_NODES = 1024;
7
+ var ARITHMETIC = {
8
+ add: "+",
9
+ sub: "-",
10
+ mul: "*",
11
+ div: "/"
12
+ };
13
+ var COMPARISON = {
14
+ eq: "=",
15
+ ne: "<>",
16
+ lt: "<",
17
+ lte: "<=",
18
+ gt: ">",
19
+ gte: ">="
20
+ };
21
+ function isRecord(value) {
22
+ return typeof value === "object" && value !== null && !Array.isArray(value);
23
+ }
24
+ function literal(value, state) {
25
+ const { ctx } = state;
26
+ if (value === null) return "NULL";
27
+ if (typeof value === "boolean") return value ? "TRUE" : "FALSE";
28
+ if (typeof value === "number") {
29
+ if (!Number.isFinite(value)) throw new Error("Aggregate expression numbers must be finite");
30
+ return String(value);
31
+ }
32
+ if (value instanceof Date) {
33
+ if (Number.isNaN(value.getTime())) throw new Error("Aggregate expression dates must be valid");
34
+ const placeholder = `$${ctx.nextIndex++}`;
35
+ ctx.values.push(value.toISOString());
36
+ if (ctx.adapterType === "duckdb" || ctx.adapterType === "json") return `CAST(${placeholder} AS TIMESTAMP)`;
37
+ if (ctx.adapterType === "postgres") return `CAST(${placeholder} AS TIMESTAMPTZ)`;
38
+ return placeholder;
39
+ }
40
+ if (typeof value === "string") {
41
+ const placeholder = `$${ctx.nextIndex++}`;
42
+ ctx.values.push(value);
43
+ return placeholder;
44
+ }
45
+ throw new Error("Aggregate expression literal must be a string, number, boolean, Date or null");
46
+ }
47
+ function secondsBetween(adapterType, later, earlier) {
48
+ if (adapterType === "postgres") return `CAST(EXTRACT(EPOCH FROM (${later} - ${earlier})) AS DOUBLE PRECISION)`;
49
+ if (adapterType === "duckdb" || adapterType === "json") return `epoch(${later}) - epoch(${earlier})`;
50
+ return `((julianday(${later}) - julianday(${earlier})) * 86400.0)`;
51
+ }
52
+ function renderOperand(node, state, depth) {
53
+ if (depth > MAX_DEPTH) throw new Error(`Aggregate expression is nested deeper than ${MAX_DEPTH}`);
54
+ state.ctx.nodeCount = (state.ctx.nodeCount ?? 0) + 1;
55
+ if (state.ctx.nodeCount > MAX_NODES) throw new Error(`Aggregate expression has more than ${MAX_NODES} nodes`);
56
+ if (!isRecord(node)) throw new Error("Aggregate expression node must be an object");
57
+ const { ctx } = state;
58
+ if ("col" in node) {
59
+ const column = ctx.identifier(node.col);
60
+ ctx.onColumn?.(column);
61
+ return column;
62
+ }
63
+ if ("value" in node) return literal(node.value, state);
64
+ const op = node.op;
65
+ const child = (value) => renderOperand(value, state, depth + 1);
66
+ if (typeof op === "string" && Object.hasOwn(ARITHMETIC, op)) {
67
+ const arithmetic = op;
68
+ const left = child(node.left);
69
+ const right = child(node.right);
70
+ if (arithmetic === "div") {
71
+ const quotient = `((${left} * 1.0) / NULLIF(${right}, 0))`;
72
+ if (ctx.adapterType === "postgres") return `CAST(${quotient} AS DOUBLE PRECISION)`;
73
+ if (ctx.adapterType === "duckdb" || ctx.adapterType === "json") return `CAST(${quotient} AS DOUBLE)`;
74
+ return quotient;
75
+ }
76
+ return `(${left} ${ARITHMETIC[arithmetic]} ${right})`;
77
+ }
78
+ if (op === "secondsBetween") return secondsBetween(ctx.adapterType, child(node.later), child(node.earlier));
79
+ if (typeof op === "string" && Object.hasOwn(COMPARISON, op)) {
80
+ const comparison = op;
81
+ const against = (side) => isRecord(side) && "value" in side && side.value === null;
82
+ if ((comparison === "eq" || comparison === "ne") && against(node.right)) return `(${child(node.left)} IS ${comparison === "eq" ? "NULL" : "NOT NULL"})`;
83
+ if ((comparison === "eq" || comparison === "ne") && against(node.left)) return `(${child(node.right)} IS ${comparison === "eq" ? "NULL" : "NOT NULL"})`;
84
+ return `(${child(node.left)} ${COMPARISON[comparison]} ${child(node.right)})`;
85
+ }
86
+ if (op === "and" || op === "or") {
87
+ const operands = node.operands;
88
+ if (!Array.isArray(operands) || operands.length === 0) throw new Error(`Aggregate expression ${op} requires at least one operand`);
89
+ return `(${operands.map(child).join(op === "and" ? " AND " : " OR ")})`;
90
+ }
91
+ if (op === "not") return `(NOT ${child(node.operand)})`;
92
+ if (op === "isNull" || op === "isNotNull") return `(${child(node.operand)} IS ${op === "isNull" ? "NULL" : "NOT NULL"})`;
93
+ if (op === "coalesce") {
94
+ const operands = node.operands;
95
+ if (!Array.isArray(operands) || operands.length === 0) throw new Error("Aggregate expression coalesce requires at least one operand");
96
+ return `COALESCE(${operands.map(child).join(", ")})`;
97
+ }
98
+ throw new Error(`Unsupported aggregate expression operator: ${String(op)}`);
99
+ }
100
+ /**
101
+ * Render an expression node to SQL, binding its string and date literals into
102
+ * `ctx` as numbered parameters. Throws on any unknown operator, unsafe
103
+ * identifier, non-finite number, or an expression too large to be legitimate.
104
+ */
105
+ function renderAggregateExpression(node, ctx) {
106
+ return renderOperand(node, { ctx }, 0);
107
+ }
108
+ //#endregion
3
109
  //#region src/aggregate.ts
110
+ var EXPRESSION_AGGREGATES = /* @__PURE__ */ new Set([
111
+ "count",
112
+ "sum",
113
+ "avg",
114
+ "min",
115
+ "max"
116
+ ]);
4
117
  var VALID_DIRECTIONS = /* @__PURE__ */ new Set(["ASC", "DESC"]);
5
118
  var VALID_BUCKET_UNITS = /* @__PURE__ */ new Set([
6
119
  "minute",
@@ -36,32 +149,66 @@ function bucketExpr(adapterType, unit, column) {
36
149
  case "year": return `strftime('%Y-01-01 00:00:00', ${col})`;
37
150
  }
38
151
  }
39
- function aggregateExpression(expr) {
152
+ function checkColumn(state, column) {
153
+ const { scope } = state;
154
+ if (scope && column.includes(".")) {
155
+ const parts = column.split(".");
156
+ if (parts.length !== 2 || !scope.has(parts[0])) throw new Error(`Aggregate column ${column} must be qualified by the from table or a join alias`);
157
+ }
158
+ return column;
159
+ }
160
+ function validateColumn(state, column) {
161
+ return checkColumn(state, validateSqlIdentifier(column));
162
+ }
163
+ function renderExpression(state, node) {
164
+ return renderAggregateExpression(node, state.ctx);
165
+ }
166
+ function aggregateExpression(expr, state) {
40
167
  const normalizedFn = String(expr.fn).toLowerCase();
41
168
  const fn = validateSqlIdentifier(String(expr.fn)).toUpperCase();
42
- if (normalizedFn === "count" && !expr.column) {
169
+ if (expr.column !== void 0 && expr.expr !== void 0) throw new Error(`Aggregate ${expr.fn} takes a column or an expr, not both`);
170
+ if ((expr.expr !== void 0 || expr.filter !== void 0) && !EXPRESSION_AGGREGATES.has(normalizedFn)) throw new Error(`Aggregate ${expr.fn} cannot take an expr or filter; use count, sum, avg, min or max`);
171
+ const hasArgument = Boolean(expr.column) || expr.expr !== void 0;
172
+ const filter = expr.filter !== void 0 ? renderExpression(state, expr.filter) : void 0;
173
+ if (normalizedFn === "count" && !hasArgument) {
43
174
  if (expr.distinct) throw new Error(`Aggregate ${expr.fn} with distinct requires a column`);
44
- return "COUNT(*)";
175
+ return filter ? `COUNT(CASE WHEN ${filter} THEN 1 END)` : "COUNT(*)";
45
176
  }
46
- if (!expr.column) throw new Error(`Aggregate ${expr.fn} requires a column`);
47
- const col = validateSqlIdentifier(expr.column);
48
- return `${fn}(${expr.distinct ? "DISTINCT " : ""}${col})`;
177
+ if (!hasArgument) throw new Error(`Aggregate ${expr.fn} requires a column`);
178
+ const argument = expr.expr !== void 0 ? renderExpression(state, expr.expr) : validateColumn(state, expr.column);
179
+ return `${fn}(${expr.distinct ? "DISTINCT " : ""}${filter ? `CASE WHEN ${filter} THEN ${argument} END` : argument})`;
49
180
  }
50
- function selectExpression(expr, adapterType) {
51
- if ("column" in expr && !("fn" in expr) && !("bucket" in expr)) return {
52
- expression: validateSqlIdentifier(expr.column),
53
- alias: validateSqlIdentifier(expr.as ?? expr.column),
54
- groupingCandidate: true
181
+ function selectExpression(expr, adapterType, state) {
182
+ if ([
183
+ "fn" in expr,
184
+ "expr" in expr && !("fn" in expr),
185
+ "bucket" in expr
186
+ ].filter(Boolean).length > 1) throw new Error("Aggregate select item mixes fn, bucket and expr");
187
+ if ("fn" in expr) return {
188
+ expression: aggregateExpression(expr, state),
189
+ alias: validateSqlIdentifier(expr.as),
190
+ groupingCandidate: false,
191
+ computed: false
55
192
  };
56
- if ("bucket" in expr) return {
57
- expression: bucketExpr(adapterType, expr.bucket, expr.column),
193
+ if ("expr" in expr) return {
194
+ expression: renderExpression(state, expr.expr),
58
195
  alias: validateSqlIdentifier(expr.as),
59
- groupingCandidate: true
196
+ groupingCandidate: true,
197
+ computed: true
198
+ };
199
+ if ("column" in expr && !("bucket" in expr)) return {
200
+ expression: validateColumn(state, expr.column),
201
+ alias: validateSqlIdentifier(expr.as ?? expr.column.split(".").pop() ?? expr.column),
202
+ groupingCandidate: true,
203
+ computed: false
60
204
  };
205
+ const column = validateColumn(state, expr.column);
206
+ const bucket = expr.bucket;
61
207
  return {
62
- expression: aggregateExpression(expr),
208
+ expression: bucketExpr(adapterType, bucket, column),
63
209
  alias: validateSqlIdentifier(expr.as),
64
- groupingCandidate: false
210
+ groupingCandidate: true,
211
+ computed: false
65
212
  };
66
213
  }
67
214
  function expandHavingKey(key, expressionByAlias) {
@@ -85,61 +232,121 @@ function buildHaving(having, expressionByAlias, startIndex, adapterType) {
85
232
  values: built.values
86
233
  };
87
234
  }
88
- function buildOrderBy(orderBy) {
235
+ function buildOrderBy(orderBy, state, aliases) {
89
236
  if (!orderBy) return "";
90
237
  return `ORDER BY ${(Array.isArray(orderBy) ? orderBy : [orderBy]).map((item) => {
91
238
  const [field, direction = "ASC"] = item.trim().split(/\s+/);
92
239
  const normalizedDirection = direction.toUpperCase();
93
240
  if (!VALID_DIRECTIONS.has(normalizedDirection)) throw new Error(`Invalid aggregate order direction: ${direction}`);
94
- return `${validateSqlIdentifier(field)} ${normalizedDirection}`;
241
+ return `${aliases.has(field) ? validateSqlIdentifier(field) : validateColumn(state, field)} ${normalizedDirection}`;
95
242
  }).join(", ")}`;
96
243
  }
244
+ var VALID_JOIN_TYPES = /* @__PURE__ */ new Set(["inner", "left"]);
245
+ var JOIN_ALIAS = /^[A-Za-z_][A-Za-z0-9_]*$/;
246
+ function buildScope(fromAlias, joins, from) {
247
+ if (joins.length === 0 && fromAlias === void 0) return void 0;
248
+ const scope = /* @__PURE__ */ new Set();
249
+ const claim = (name, what) => {
250
+ if (typeof name !== "string" || !JOIN_ALIAS.test(name)) throw new Error(`Invalid aggregate ${what}: ${name}`);
251
+ if (scope.has(name)) throw new Error(`Duplicate aggregate table name or alias: ${name}`);
252
+ scope.add(name);
253
+ };
254
+ if (fromAlias !== void 0) claim(fromAlias, "from alias");
255
+ else {
256
+ if (from.includes(".")) throw new Error("AggregateSpec.fromAlias is required when joining a schema-qualified from table");
257
+ claim(from, "from table");
258
+ }
259
+ for (const join of joins) claim(join.alias, "join alias");
260
+ return scope;
261
+ }
262
+ function joinSql(state, joins) {
263
+ return joins.map((join) => {
264
+ const type = join.type ?? "inner";
265
+ if (!VALID_JOIN_TYPES.has(type)) throw new Error(`Invalid aggregate join type: ${type}`);
266
+ if (join.on === void 0) throw new Error(`Aggregate join ${join.alias} requires an on condition`);
267
+ const table = validateSqlIdentifier(join.table);
268
+ const alias = join.alias;
269
+ if (typeof alias !== "string" || !JOIN_ALIAS.test(alias)) throw new Error(`Invalid aggregate join alias: ${String(alias)}`);
270
+ const on = renderExpression(state, join.on);
271
+ return `${type === "left" ? "LEFT" : "INNER"} JOIN ${table} AS ${alias} ON ${on}`;
272
+ });
273
+ }
97
274
  function buildAggregate(spec, startIndex = 1, adapterType) {
98
275
  const from = validateSqlIdentifier(spec.from);
99
276
  if (spec.select.length === 0) throw new Error("AggregateSpec.select must contain at least one expression");
277
+ if (spec.joins !== void 0 && !Array.isArray(spec.joins)) throw new Error("AggregateSpec.joins must be an array");
278
+ const values = [];
279
+ const ctx = {
280
+ adapterType,
281
+ nextIndex: startIndex,
282
+ values,
283
+ identifier: validateSqlIdentifier
284
+ };
285
+ const fromAlias = spec.fromAlias;
286
+ const joins = (spec.joins ?? []).map((join) => ({
287
+ table: join.table,
288
+ alias: join.alias,
289
+ type: join.type,
290
+ on: join.on
291
+ }));
292
+ const state = {
293
+ ctx,
294
+ scope: buildScope(fromAlias, joins, from)
295
+ };
296
+ ctx.onColumn = (column) => {
297
+ checkColumn(state, column);
298
+ };
100
299
  const expressionByAlias = /* @__PURE__ */ new Map();
300
+ const ordinalByAlias = /* @__PURE__ */ new Map();
101
301
  const groupingAliases = [];
102
- const selectSql = spec.select.map((expr) => {
103
- const selected = selectExpression(expr, adapterType);
302
+ const selectSql = spec.select.map((expr, position) => {
303
+ const selected = selectExpression(expr, adapterType, state);
304
+ if (expressionByAlias.has(selected.alias)) throw new Error(`Duplicate aggregate select alias: ${selected.alias}`);
104
305
  expressionByAlias.set(selected.alias, selected.expression);
306
+ if (selected.computed) ordinalByAlias.set(selected.alias, position + 1);
105
307
  if (selected.groupingCandidate) groupingAliases.push(selected.alias);
106
308
  return `${selected.expression} AS ${selected.alias}`;
107
309
  });
108
- const where = spec.where ? buildWhere(spec.where, startIndex, adapterType) : {
310
+ const joinParts = joinSql(state, joins);
311
+ const where = spec.where ? buildWhere(spec.where, ctx.nextIndex, adapterType) : {
109
312
  sql: "",
110
313
  values: []
111
314
  };
112
- let nextParamIndex = startIndex + where.values.length;
315
+ values.push(...where.values);
316
+ ctx.nextIndex += where.values.length;
317
+ let whereSql = where.sql;
318
+ if (spec.whereExpr !== void 0) {
319
+ const predicate = renderExpression(state, spec.whereExpr);
320
+ whereSql = whereSql ? `WHERE (${whereSql.replace(/^WHERE /, "")}) AND ${predicate}` : `WHERE ${predicate}`;
321
+ }
113
322
  const groupByItems = spec.groupBy ?? groupingAliases;
114
- const groupBySql = groupByItems.length > 0 ? `GROUP BY ${groupByItems.map((item) => expressionByAlias.has(item) ? expressionByAlias.get(item) : validateSqlIdentifier(item)).join(", ")}` : "";
115
- const having = buildHaving(spec.having, expressionByAlias, nextParamIndex, adapterType);
116
- nextParamIndex += having.values.length;
117
- const orderBySql = buildOrderBy(spec.orderBy);
118
- const pagingValues = [];
323
+ const groupBySql = groupByItems.length > 0 ? `GROUP BY ${groupByItems.map((item) => ordinalByAlias.has(item) ? String(ordinalByAlias.get(item)) : expressionByAlias.has(item) ? expressionByAlias.get(item) : validateColumn(state, item)).join(", ")}` : "";
324
+ const having = buildHaving(spec.having, expressionByAlias, ctx.nextIndex, adapterType);
325
+ values.push(...having.values);
326
+ ctx.nextIndex += having.values.length;
327
+ const orderBySql = buildOrderBy(spec.orderBy, state, new Set(expressionByAlias.keys()));
119
328
  const pagingSql = [];
120
329
  if (spec.limit !== void 0) {
121
- pagingSql.push(`LIMIT ${placeholder(nextParamIndex++)}`);
122
- pagingValues.push(spec.limit);
330
+ pagingSql.push(`LIMIT ${placeholder(ctx.nextIndex++)}`);
331
+ values.push(spec.limit);
123
332
  } else if (spec.offset !== void 0 && normalizeAdapter(adapterType) === "sqlite") pagingSql.push("LIMIT -1");
124
333
  if (spec.offset !== void 0) {
125
- pagingSql.push(`OFFSET ${placeholder(nextParamIndex++)}`);
126
- pagingValues.push(spec.offset);
334
+ pagingSql.push(`OFFSET ${placeholder(ctx.nextIndex++)}`);
335
+ values.push(spec.offset);
127
336
  }
337
+ const fromSql = spec.fromAlias !== void 0 ? `${from} AS ${fromAlias}` : from;
128
338
  return {
129
339
  sql: [
130
340
  `SELECT ${selectSql.join(", ")}`,
131
- `FROM ${from}`,
132
- where.sql,
341
+ `FROM ${fromSql}`,
342
+ ...joinParts,
343
+ whereSql,
133
344
  groupBySql,
134
345
  having.sql,
135
346
  orderBySql,
136
347
  pagingSql.join(" ")
137
348
  ].filter(Boolean).join(" "),
138
- values: [
139
- ...where.values,
140
- ...having.values,
141
- ...pagingValues
142
- ]
349
+ values
143
350
  };
144
351
  }
145
352
  //#endregion
@@ -350,4 +557,4 @@ async function syncSchema(options) {
350
557
  //#endregion
351
558
  export { NestedTransactionError as a, bucketExpr as c, validateColumnName as i, buildAggregate as l, syncSchema as n, resolveSchemas as o, tableExists as r, convertUniqueIndexesToInlineConstraints as s, escapeSqlValue as t };
352
559
 
353
- //# sourceMappingURL=query-DubG7aF0.js.map
560
+ //# sourceMappingURL=query-B6ATOWzc.js.map