@atscript/db-sql-tools 0.1.131 → 0.1.133

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/dist/index.cjs CHANGED
@@ -254,6 +254,23 @@ function renameGeoDistance(row) {
254
254
  function sqlStringLiteral(value) {
255
255
  return `'${value.replace(/'/g, "''")}'`;
256
256
  }
257
+ /**
258
+ * A calendar bucket's time zone as an inlined SQL string literal (`'Europe/Berlin'`).
259
+ *
260
+ * The zone is already canonical (the core normalizer ran uniqu's
261
+ * `checkTimeZone`); this re-asserts uniqu's `TIME_ZONE_NAME_RE` charset —
262
+ * no quote, backslash or whitespace can reach the literal — as defense in
263
+ * depth for dialects that inline it (bucket expressions are parameter-free).
264
+ *
265
+ * @throws DbError `INVALID_QUERY` for a name outside the charset.
266
+ */
267
+ function sqlTimeZoneLiteral(tz) {
268
+ if (!_uniqu_core.TIME_ZONE_NAME_RE.test(tz)) throw new _atscript_db.DbError("INVALID_QUERY", [{
269
+ path: "$select",
270
+ message: `Unknown time zone "${tz}"`
271
+ }]);
272
+ return `'${tz}'`;
273
+ }
257
274
  /** Converts a JS value to a SQL-bindable parameter. Objects/arrays -> JSON, booleans -> 0/1. */
258
275
  function toSqlValue(value) {
259
276
  if (value === void 0) return null;
@@ -338,6 +355,52 @@ function aggFnSql(dialect, expr) {
338
355
  function buildAggExpr(dialect, expr) {
339
356
  return `${aggFnSql(dialect, expr)} AS ${dialect.quoteIdentifier((0, _atscript_db_agg.resolveAlias)(expr))}`;
340
357
  }
358
+ const BUCKET_UNIT_SET = new Set(_uniqu_core.BUCKET_UNITS);
359
+ const WEEK_START_SET = new Set(_uniqu_core.WEEK_STARTS);
360
+ /**
361
+ * The dialect's label expression for one calendar bucket.
362
+ *
363
+ * Internal assertions, once for every dialect, before it renders:
364
+ * - the dialect has `calendarBucket` — the user-facing `BUCKET_NOT_SUPPORTED`
365
+ * is the core's (`calendarBucketUnits()`), so only an adapter advertising
366
+ * units its dialect cannot render reaches this throw;
367
+ * - the literals a dialect inlines (the expression is parameter-free) are in
368
+ * their closed sets — unit, week start, ISO week start 1..7 — and the zone
369
+ * passes `sqlTimeZoneLiteral`'s charset. Defense in depth: the core's
370
+ * normalizer already validated all of them.
371
+ */
372
+ function bucketSql(dialect, bucket) {
373
+ if (!dialect.calendarBucket) throw new _atscript_db.DbError("BUCKET_NOT_SUPPORTED", [{
374
+ path: "$select",
375
+ message: "Calendar buckets are not supported by this adapter"
376
+ }]);
377
+ const { unit, weekStart, weekStartIso } = bucket;
378
+ for (const [ok, value] of [
379
+ [BUCKET_UNIT_SET.has(unit), unit],
380
+ [WEEK_START_SET.has(weekStart), weekStart],
381
+ [Number.isInteger(weekStartIso) && weekStartIso >= 1 && weekStartIso <= 7, weekStartIso]
382
+ ]) if (!ok) throw new _atscript_db.DbError("INVALID_QUERY", [{
383
+ path: "$select",
384
+ message: `Invalid calendar bucket argument "${String(value)}"`
385
+ }]);
386
+ sqlTimeZoneLiteral(bucket.tz);
387
+ return dialect.calendarBucket(dialect.quoteIdentifier(bucket.field), bucket);
388
+ }
389
+ /**
390
+ * The SQL a `$groupBy` key renders as: a calendar-bucket alias renders the
391
+ * bucket EXPRESSION (`dialect.calendarBucket`), anything else the quoted
392
+ * column. GROUP BY, HAVING and the count query's GROUP BY use it — PostgreSQL
393
+ * rejects SELECT aliases in HAVING and lets an input column of the same name
394
+ * win in GROUP BY, so the expression form is the legal, unambiguous rendering
395
+ * there (HAVING on MySQL is the exception — see `SqlDialect.bucketAliasInHaving`).
396
+ * SELECT and GROUP BY render identical, parameter-free text (PostgreSQL /
397
+ * MySQL `ONLY_FULL_GROUP_BY` match them structurally); ORDER BY keeps the
398
+ * bare output alias.
399
+ */
400
+ function groupKeySql(dialect, controls, key) {
401
+ const bucket = controls.$select?.bucketByAlias(key);
402
+ return bucket ? bucketSql(dialect, bucket) : dialect.quoteIdentifier(key);
403
+ }
341
404
  /**
342
405
  * ` HAVING <predicate>` (leading space) + params for `controls.$having`, or
343
406
  * `undefined` when there is nothing to render. Shared by the row and the
@@ -346,35 +409,52 @@ function buildAggExpr(dialect, expr) {
346
409
  * A key that names an aggregate alias (`$as`, else `fn_field`) renders the
347
410
  * aggregate expression itself — `SUM("amount") > ?` — because PostgreSQL does
348
411
  * not allow a SELECT alias in HAVING (MySQL and SQLite tolerate it, so the
349
- * expression form keeps all three identical). Other keys (grouped columns)
350
- * render as plain columns.
412
+ * expression form keeps all three identical). A calendar-bucket alias renders
413
+ * its bucket expression ({@link groupKeySql}), or its quoted alias when the
414
+ * dialect sets `SqlDialect.bucketAliasInHaving` (why: see there). Other keys
415
+ * (grouped columns) render as plain columns.
351
416
  */
352
417
  function havingClause(dialect, controls) {
353
418
  const having = controls.$having;
354
419
  if (!having) return void 0;
355
420
  const exprByAlias = /* @__PURE__ */ new Map();
356
421
  for (const expr of controls.$select?.aggregates ?? []) exprByAlias.set((0, _atscript_db_agg.resolveAlias)(expr), aggFnSql(dialect, expr));
357
- const fragment = (0, _uniqu_core.walkFilter)(having, createFilterVisitor(dialect, { columnRef: (field) => exprByAlias.get(field) ?? dialect.quoteIdentifier(field) }));
422
+ const fragment = (0, _uniqu_core.walkFilter)(having, createFilterVisitor(dialect, { columnRef: (field) => {
423
+ const aggExpr = exprByAlias.get(field);
424
+ if (aggExpr) return aggExpr;
425
+ if (dialect.bucketAliasInHaving && controls.$select?.bucketByAlias(field)) return dialect.quoteIdentifier(field);
426
+ return groupKeySql(dialect, controls, field);
427
+ } }));
358
428
  if (!fragment || fragment.sql === EMPTY_AND.sql) return void 0;
359
429
  return {
360
430
  sql: ` HAVING ${fragment.sql}`,
361
431
  params: fragment.params
362
432
  };
363
433
  }
434
+ /** `<bucket expr> AS "alias"` for every calendar bucket in `$select`. */
435
+ function bucketSelectParts(dialect, controls) {
436
+ return (controls.$select?.buckets ?? []).map((bucket) => `${bucketSql(dialect, bucket)} AS ${dialect.quoteIdentifier(bucket.alias)}`);
437
+ }
364
438
  /**
365
439
  * Builds a SELECT ... GROUP BY statement with aggregate functions.
440
+ *
441
+ * SELECT lists the plain grouped columns, then `<bucket expr> AS "alias"`
442
+ * per calendar bucket, then the aggregates. Bucket expressions are
443
+ * parameter-free, so the bind parameters are exactly those of the same query
444
+ * without buckets (WHERE, HAVING, LIMIT, OFFSET).
366
445
  */
367
446
  function buildAggregateSelect(dialect, table, where, controls) {
368
447
  const selectParts = [];
369
448
  const plainFields = controls.$select?.asArray;
370
449
  if (plainFields) for (const f of plainFields) selectParts.push(dialect.quoteIdentifier(f));
450
+ selectParts.push(...bucketSelectParts(dialect, controls));
371
451
  const aggregates = controls.$select?.aggregates;
372
452
  if (aggregates) for (const expr of aggregates) selectParts.push(buildAggExpr(dialect, expr));
373
453
  let sql = `SELECT ${selectParts.length > 0 ? selectParts.join(", ") : "*"} FROM ${dialect.quoteTable(table)} WHERE ${where.sql}`;
374
454
  const params = [...where.params];
375
455
  const groupBy = controls.$groupBy;
376
456
  if (groupBy?.length) {
377
- const groupCols = groupBy.map((f) => dialect.quoteIdentifier(f)).join(", ");
457
+ const groupCols = groupBy.map((key) => groupKeySql(dialect, controls, key)).join(", ");
378
458
  sql += ` GROUP BY ${groupCols}`;
379
459
  }
380
460
  const having = havingClause(dialect, controls);
@@ -415,9 +495,11 @@ function buildAggregateCount(dialect, table, where, controls) {
415
495
  sql: `SELECT ${countCol} FROM ${dialect.quoteTable(table)} WHERE ${where.sql}`,
416
496
  params: where.params
417
497
  });
418
- const groupBy = groupFields?.length ? ` GROUP BY ${groupFields.map((f) => dialect.quoteIdentifier(f)).join(", ")}` : "";
498
+ const groupBy = groupFields?.length ? ` GROUP BY ${groupFields.map((key) => groupKeySql(dialect, controls, key)).join(", ")}` : "";
499
+ let inner = "COUNT(*)";
500
+ if (groupBy) inner = bucketSelectParts(dialect, controls).join(", ") || "1";
419
501
  return finalizeParams(dialect, {
420
- sql: `SELECT ${countCol} FROM (SELECT ${groupBy ? "1" : "COUNT(*)"} FROM ${dialect.quoteTable(table)} WHERE ${where.sql}${groupBy}${having?.sql ?? ""}) AS ${dialect.quoteIdentifier("_groups")}`,
502
+ sql: `SELECT ${countCol} FROM (SELECT ${inner} FROM ${dialect.quoteTable(table)} WHERE ${where.sql}${groupBy}${having?.sql ?? ""}) AS ${dialect.quoteIdentifier("_groups")}`,
421
503
  params: [...where.params, ...having?.params ?? []]
422
504
  });
423
505
  }
@@ -436,6 +518,39 @@ function buildInsert(dialect, table, data) {
436
518
  });
437
519
  }
438
520
  /**
521
+ * The columns of a multi-row INSERT: the union of the rows' keys, in
522
+ * first-seen order (rows may differ in shape — an optional field omitted on
523
+ * some).
524
+ */
525
+ function insertManyColumns(rows) {
526
+ const columns = /* @__PURE__ */ new Set();
527
+ for (const row of rows) for (const key of Object.keys(row)) columns.add(key);
528
+ return [...columns];
529
+ }
530
+ /**
531
+ * Builds a multi-row `INSERT … VALUES (…), (…)` statement over `columns`
532
+ * (default: {@link insertManyColumns} of `rows`). A row lacking a column gets
533
+ * `DEFAULT` — exactly what a single-row INSERT omitting it stores. Callers
534
+ * split large inputs into batches themselves (passing the same `columns` to
535
+ * each) and append any `RETURNING` clause.
536
+ */
537
+ function buildInsertMany(dialect, table, rows, columns = insertManyColumns(rows)) {
538
+ const cols = columns.map((k) => dialect.quoteIdentifier(k)).join(", ");
539
+ const fullRow = `(${columns.map(() => "?").join(", ")})`;
540
+ const params = [];
541
+ const clauses = [];
542
+ for (const row of rows) {
543
+ let missing = false;
544
+ for (const k of columns) if (k in row) params.push(dialect.toValue(row[k]));
545
+ else missing = true;
546
+ clauses.push(missing ? `(${columns.map((k) => k in row ? "?" : "DEFAULT").join(", ")})` : fullRow);
547
+ }
548
+ return finalizeParams(dialect, {
549
+ sql: `INSERT INTO ${dialect.quoteTable(table)} (${cols}) VALUES ${clauses.join(", ")}`,
550
+ params
551
+ });
552
+ }
553
+ /**
439
554
  * Builds a SELECT statement with optional sort, limit, offset, projection.
440
555
  */
441
556
  function buildSelect(dialect, table, where, controls) {
@@ -668,6 +783,7 @@ exports.buildDelete = buildDelete;
668
783
  exports.buildGeoSearchCount = buildGeoSearchCount;
669
784
  exports.buildGeoSearchSelect = buildGeoSearchSelect;
670
785
  exports.buildInsert = buildInsert;
786
+ exports.buildInsertMany = buildInsertMany;
671
787
  exports.buildProjection = buildProjection;
672
788
  exports.buildSelect = buildSelect;
673
789
  exports.buildUpdate = buildUpdate;
@@ -678,6 +794,8 @@ exports.defaultValueToSqlLiteral = defaultValueToSqlLiteral;
678
794
  exports.fillReplacePayload = fillReplacePayload;
679
795
  exports.finalizeParams = finalizeParams;
680
796
  exports.geoWindowFromControls = geoWindowFromControls;
797
+ exports.groupKeySql = groupKeySql;
798
+ exports.insertManyColumns = insertManyColumns;
681
799
  exports.normalizeGeoPointValue = normalizeGeoPointValue;
682
800
  exports.parseRegexString = parseRegexString;
683
801
  exports.queryNodeToSql = queryNodeToSql;
@@ -686,4 +804,5 @@ exports.refActionToSql = refActionToSql;
686
804
  exports.renameGeoDistance = renameGeoDistance;
687
805
  exports.replaceColumnsFor = replaceColumnsFor;
688
806
  exports.sqlStringLiteral = sqlStringLiteral;
807
+ exports.sqlTimeZoneLiteral = sqlTimeZoneLiteral;
689
808
  exports.toSqlValue = toSqlValue;
package/dist/index.d.cts CHANGED
@@ -1,5 +1,5 @@
1
+ import { AtscriptQueryFieldRef, AtscriptQueryNode, DbControls, TDbDefaultFn, TDbFieldMeta, TDbReferentialAction, TFieldOps, TResolvedBucket, TViewColumnMapping, TViewPlan, UniquSelect } from "@atscript/db";
1
2
  import { FilterExpr, FilterVisitor } from "@uniqu/core";
2
- import { AtscriptQueryFieldRef, AtscriptQueryNode, DbControls, TDbDefaultFn, TDbFieldMeta, TDbReferentialAction, TFieldOps, TViewColumnMapping, TViewPlan, UniquSelect } from "@atscript/db";
3
3
 
4
4
  //#region src/dialect.d.ts
5
5
  interface TSqlFragment {
@@ -31,6 +31,35 @@ interface SqlDialect {
31
31
  * support omit this; the filter visitor then throws `GEO_NOT_SUPPORTED`.
32
32
  */
33
33
  geoWithin?(quotedCol: string, circle: TGeoCircle): TSqlFragment;
34
+ /**
35
+ * Calendar-bucket label expression over one column: TEXT `'YYYY-MM-DD'`
36
+ * (the local calendar date of the bucket's first day in `b.tz`), or NULL for
37
+ * a NULL source or one outside `[BUCKET_MIN_INSTANT, BUCKET_MAX_INSTANT)`.
38
+ * `quotedCol` is already quoted; `b.fd` identifies the storage kind.
39
+ *
40
+ * The expression must be PARAMETER-FREE — inline the zone with
41
+ * `sqlTimeZoneLiteral(b.tz)` and the unit / week start as literals (the
42
+ * shared builders assert them against their closed sets first) — because
43
+ * the builders render it in SELECT, GROUP BY and HAVING and PostgreSQL
44
+ * matches GROUP BY expressions structurally (and the bind-parameter order
45
+ * must not change). Dialects without calendar buckets omit this; the
46
+ * builders then throw `BUCKET_NOT_SUPPORTED`. Since 0.1.132.
47
+ */
48
+ calendarBucket?(quotedCol: string, b: TResolvedBucket): string;
49
+ /**
50
+ * HAVING references a calendar bucket by its quoted SELECT alias instead of
51
+ * re-rendering the bucket expression (the aggregate count query's inner
52
+ * SELECT lists `<bucket expr> AS alias`, so the alias exists there too).
53
+ *
54
+ * MySQL needs this: the bucket expression reads the raw source column, which
55
+ * is not itself in GROUP BY (only the expression is), so HAVING rejects it
56
+ * (`ER_BAD_FIELD_ERROR … in 'having clause'`), while MySQL does resolve
57
+ * SELECT aliases in HAVING. PostgreSQL is the opposite (no SELECT aliases in
58
+ * HAVING), so dialects that omit this keep the expression form. Aggregate
59
+ * aliases are unaffected — they always render as the inlined aggregate call.
60
+ * Since 0.1.132.
61
+ */
62
+ bucketAliasInHaving?: boolean;
34
63
  /** e.g. 'CREATE VIEW IF NOT EXISTS' or 'CREATE OR REPLACE VIEW' */
35
64
  createViewPrefix: string;
36
65
  /** Returns a parameter placeholder for the given 1-based index. When absent, '?' is used. */
@@ -118,6 +147,20 @@ declare function renameGeoDistance(row: Record<string, unknown>): Record<string,
118
147
  * Builds an INSERT statement.
119
148
  */
120
149
  declare function buildInsert(dialect: SqlDialect, table: string, data: Record<string, unknown>): TSqlFragment;
150
+ /**
151
+ * The columns of a multi-row INSERT: the union of the rows' keys, in
152
+ * first-seen order (rows may differ in shape — an optional field omitted on
153
+ * some).
154
+ */
155
+ declare function insertManyColumns(rows: readonly Record<string, unknown>[]): string[];
156
+ /**
157
+ * Builds a multi-row `INSERT … VALUES (…), (…)` statement over `columns`
158
+ * (default: {@link insertManyColumns} of `rows`). A row lacking a column gets
159
+ * `DEFAULT` — exactly what a single-row INSERT omitting it stores. Callers
160
+ * split large inputs into batches themselves (passing the same `columns` to
161
+ * each) and append any `RETURNING` clause.
162
+ */
163
+ declare function buildInsertMany(dialect: SqlDialect, table: string, rows: readonly Record<string, unknown>[], columns?: readonly string[]): TSqlFragment;
121
164
  /**
122
165
  * Builds a SELECT statement with optional sort, limit, offset, projection.
123
166
  */
@@ -191,6 +234,17 @@ declare function buildCreateView(dialect: SqlDialect, viewName: string, plan: TV
191
234
  //#region src/common.d.ts
192
235
  /** Formats a string value as a SQL literal with single-quote escaping. */
193
236
  declare function sqlStringLiteral(value: string): string;
237
+ /**
238
+ * A calendar bucket's time zone as an inlined SQL string literal (`'Europe/Berlin'`).
239
+ *
240
+ * The zone is already canonical (the core normalizer ran uniqu's
241
+ * `checkTimeZone`); this re-asserts uniqu's `TIME_ZONE_NAME_RE` charset —
242
+ * no quote, backslash or whitespace can reach the literal — as defense in
243
+ * depth for dialects that inline it (bucket expressions are parameter-free).
244
+ *
245
+ * @throws DbError `INVALID_QUERY` for a name outside the charset.
246
+ */
247
+ declare function sqlTimeZoneLiteral(tz: string): string;
194
248
  /** Converts a JS value to a SQL-bindable parameter. Objects/arrays -> JSON, booleans -> 0/1. */
195
249
  declare function toSqlValue(value: unknown): unknown;
196
250
  declare function refActionToSql(action: TDbReferentialAction): string;
@@ -210,8 +264,25 @@ declare function queryNodeToSql(node: AtscriptQueryNode, resolveFieldRef: (ref:
210
264
  //#endregion
211
265
  //#region src/agg.d.ts
212
266
  declare const AGG_FN_SQL: Record<string, string>;
267
+ /**
268
+ * The SQL a `$groupBy` key renders as: a calendar-bucket alias renders the
269
+ * bucket EXPRESSION (`dialect.calendarBucket`), anything else the quoted
270
+ * column. GROUP BY, HAVING and the count query's GROUP BY use it — PostgreSQL
271
+ * rejects SELECT aliases in HAVING and lets an input column of the same name
272
+ * win in GROUP BY, so the expression form is the legal, unambiguous rendering
273
+ * there (HAVING on MySQL is the exception — see `SqlDialect.bucketAliasInHaving`).
274
+ * SELECT and GROUP BY render identical, parameter-free text (PostgreSQL /
275
+ * MySQL `ONLY_FULL_GROUP_BY` match them structurally); ORDER BY keeps the
276
+ * bare output alias.
277
+ */
278
+ declare function groupKeySql(dialect: SqlDialect, controls: DbControls, key: string): string;
213
279
  /**
214
280
  * Builds a SELECT ... GROUP BY statement with aggregate functions.
281
+ *
282
+ * SELECT lists the plain grouped columns, then `<bucket expr> AS "alias"`
283
+ * per calendar bucket, then the aggregates. Bucket expressions are
284
+ * parameter-free, so the bind parameters are exactly those of the same query
285
+ * without buckets (WHERE, HAVING, LIMIT, OFFSET).
215
286
  */
216
287
  declare function buildAggregateSelect(dialect: SqlDialect, table: string, where: TSqlFragment, controls: DbControls): TSqlFragment;
217
288
  /**
@@ -235,4 +306,4 @@ declare function parseRegexString(value: unknown): {
235
306
  flags: string;
236
307
  };
237
308
  //#endregion
238
- export { AGG_FN_SQL, EMPTY_AND, EMPTY_OR, GEO_DISTANCE_ALIAS, SQL_DEFAULT, type SqlDialect, type TFilterVisitorOptions, type TGeoCircle, type TGeoWindow, type TReplaceColumn, type TSqlFragment, buildAggregateCount, buildAggregateSelect, buildCreateView, buildDelete, buildGeoSearchCount, buildGeoSearchSelect, buildInsert, buildProjection, buildSelect, buildUpdate, buildWhere, createFilterVisitor, defaultValueForType, defaultValueToSqlLiteral, fillReplacePayload, finalizeParams, geoWindowFromControls, normalizeGeoPointValue, parseRegexString, queryNodeToSql, queryOpToSql, refActionToSql, renameGeoDistance, replaceColumnsFor, sqlStringLiteral, toSqlValue };
309
+ export { AGG_FN_SQL, EMPTY_AND, EMPTY_OR, GEO_DISTANCE_ALIAS, SQL_DEFAULT, type SqlDialect, type TFilterVisitorOptions, type TGeoCircle, type TGeoWindow, type TReplaceColumn, type TSqlFragment, buildAggregateCount, buildAggregateSelect, buildCreateView, buildDelete, buildGeoSearchCount, buildGeoSearchSelect, buildInsert, buildInsertMany, buildProjection, buildSelect, buildUpdate, buildWhere, createFilterVisitor, defaultValueForType, defaultValueToSqlLiteral, fillReplacePayload, finalizeParams, geoWindowFromControls, groupKeySql, insertManyColumns, normalizeGeoPointValue, parseRegexString, queryNodeToSql, queryOpToSql, refActionToSql, renameGeoDistance, replaceColumnsFor, sqlStringLiteral, sqlTimeZoneLiteral, toSqlValue };
package/dist/index.d.mts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { FilterExpr, FilterVisitor } from "@uniqu/core";
2
- import { AtscriptQueryFieldRef, AtscriptQueryNode, DbControls, TDbDefaultFn, TDbFieldMeta, TDbReferentialAction, TFieldOps, TViewColumnMapping, TViewPlan, UniquSelect } from "@atscript/db";
2
+ import { AtscriptQueryFieldRef, AtscriptQueryNode, DbControls, TDbDefaultFn, TDbFieldMeta, TDbReferentialAction, TFieldOps, TResolvedBucket, TViewColumnMapping, TViewPlan, UniquSelect } from "@atscript/db";
3
3
 
4
4
  //#region src/dialect.d.ts
5
5
  interface TSqlFragment {
@@ -31,6 +31,35 @@ interface SqlDialect {
31
31
  * support omit this; the filter visitor then throws `GEO_NOT_SUPPORTED`.
32
32
  */
33
33
  geoWithin?(quotedCol: string, circle: TGeoCircle): TSqlFragment;
34
+ /**
35
+ * Calendar-bucket label expression over one column: TEXT `'YYYY-MM-DD'`
36
+ * (the local calendar date of the bucket's first day in `b.tz`), or NULL for
37
+ * a NULL source or one outside `[BUCKET_MIN_INSTANT, BUCKET_MAX_INSTANT)`.
38
+ * `quotedCol` is already quoted; `b.fd` identifies the storage kind.
39
+ *
40
+ * The expression must be PARAMETER-FREE — inline the zone with
41
+ * `sqlTimeZoneLiteral(b.tz)` and the unit / week start as literals (the
42
+ * shared builders assert them against their closed sets first) — because
43
+ * the builders render it in SELECT, GROUP BY and HAVING and PostgreSQL
44
+ * matches GROUP BY expressions structurally (and the bind-parameter order
45
+ * must not change). Dialects without calendar buckets omit this; the
46
+ * builders then throw `BUCKET_NOT_SUPPORTED`. Since 0.1.132.
47
+ */
48
+ calendarBucket?(quotedCol: string, b: TResolvedBucket): string;
49
+ /**
50
+ * HAVING references a calendar bucket by its quoted SELECT alias instead of
51
+ * re-rendering the bucket expression (the aggregate count query's inner
52
+ * SELECT lists `<bucket expr> AS alias`, so the alias exists there too).
53
+ *
54
+ * MySQL needs this: the bucket expression reads the raw source column, which
55
+ * is not itself in GROUP BY (only the expression is), so HAVING rejects it
56
+ * (`ER_BAD_FIELD_ERROR … in 'having clause'`), while MySQL does resolve
57
+ * SELECT aliases in HAVING. PostgreSQL is the opposite (no SELECT aliases in
58
+ * HAVING), so dialects that omit this keep the expression form. Aggregate
59
+ * aliases are unaffected — they always render as the inlined aggregate call.
60
+ * Since 0.1.132.
61
+ */
62
+ bucketAliasInHaving?: boolean;
34
63
  /** e.g. 'CREATE VIEW IF NOT EXISTS' or 'CREATE OR REPLACE VIEW' */
35
64
  createViewPrefix: string;
36
65
  /** Returns a parameter placeholder for the given 1-based index. When absent, '?' is used. */
@@ -118,6 +147,20 @@ declare function renameGeoDistance(row: Record<string, unknown>): Record<string,
118
147
  * Builds an INSERT statement.
119
148
  */
120
149
  declare function buildInsert(dialect: SqlDialect, table: string, data: Record<string, unknown>): TSqlFragment;
150
+ /**
151
+ * The columns of a multi-row INSERT: the union of the rows' keys, in
152
+ * first-seen order (rows may differ in shape — an optional field omitted on
153
+ * some).
154
+ */
155
+ declare function insertManyColumns(rows: readonly Record<string, unknown>[]): string[];
156
+ /**
157
+ * Builds a multi-row `INSERT … VALUES (…), (…)` statement over `columns`
158
+ * (default: {@link insertManyColumns} of `rows`). A row lacking a column gets
159
+ * `DEFAULT` — exactly what a single-row INSERT omitting it stores. Callers
160
+ * split large inputs into batches themselves (passing the same `columns` to
161
+ * each) and append any `RETURNING` clause.
162
+ */
163
+ declare function buildInsertMany(dialect: SqlDialect, table: string, rows: readonly Record<string, unknown>[], columns?: readonly string[]): TSqlFragment;
121
164
  /**
122
165
  * Builds a SELECT statement with optional sort, limit, offset, projection.
123
166
  */
@@ -191,6 +234,17 @@ declare function buildCreateView(dialect: SqlDialect, viewName: string, plan: TV
191
234
  //#region src/common.d.ts
192
235
  /** Formats a string value as a SQL literal with single-quote escaping. */
193
236
  declare function sqlStringLiteral(value: string): string;
237
+ /**
238
+ * A calendar bucket's time zone as an inlined SQL string literal (`'Europe/Berlin'`).
239
+ *
240
+ * The zone is already canonical (the core normalizer ran uniqu's
241
+ * `checkTimeZone`); this re-asserts uniqu's `TIME_ZONE_NAME_RE` charset —
242
+ * no quote, backslash or whitespace can reach the literal — as defense in
243
+ * depth for dialects that inline it (bucket expressions are parameter-free).
244
+ *
245
+ * @throws DbError `INVALID_QUERY` for a name outside the charset.
246
+ */
247
+ declare function sqlTimeZoneLiteral(tz: string): string;
194
248
  /** Converts a JS value to a SQL-bindable parameter. Objects/arrays -> JSON, booleans -> 0/1. */
195
249
  declare function toSqlValue(value: unknown): unknown;
196
250
  declare function refActionToSql(action: TDbReferentialAction): string;
@@ -210,8 +264,25 @@ declare function queryNodeToSql(node: AtscriptQueryNode, resolveFieldRef: (ref:
210
264
  //#endregion
211
265
  //#region src/agg.d.ts
212
266
  declare const AGG_FN_SQL: Record<string, string>;
267
+ /**
268
+ * The SQL a `$groupBy` key renders as: a calendar-bucket alias renders the
269
+ * bucket EXPRESSION (`dialect.calendarBucket`), anything else the quoted
270
+ * column. GROUP BY, HAVING and the count query's GROUP BY use it — PostgreSQL
271
+ * rejects SELECT aliases in HAVING and lets an input column of the same name
272
+ * win in GROUP BY, so the expression form is the legal, unambiguous rendering
273
+ * there (HAVING on MySQL is the exception — see `SqlDialect.bucketAliasInHaving`).
274
+ * SELECT and GROUP BY render identical, parameter-free text (PostgreSQL /
275
+ * MySQL `ONLY_FULL_GROUP_BY` match them structurally); ORDER BY keeps the
276
+ * bare output alias.
277
+ */
278
+ declare function groupKeySql(dialect: SqlDialect, controls: DbControls, key: string): string;
213
279
  /**
214
280
  * Builds a SELECT ... GROUP BY statement with aggregate functions.
281
+ *
282
+ * SELECT lists the plain grouped columns, then `<bucket expr> AS "alias"`
283
+ * per calendar bucket, then the aggregates. Bucket expressions are
284
+ * parameter-free, so the bind parameters are exactly those of the same query
285
+ * without buckets (WHERE, HAVING, LIMIT, OFFSET).
215
286
  */
216
287
  declare function buildAggregateSelect(dialect: SqlDialect, table: string, where: TSqlFragment, controls: DbControls): TSqlFragment;
217
288
  /**
@@ -235,4 +306,4 @@ declare function parseRegexString(value: unknown): {
235
306
  flags: string;
236
307
  };
237
308
  //#endregion
238
- export { AGG_FN_SQL, EMPTY_AND, EMPTY_OR, GEO_DISTANCE_ALIAS, SQL_DEFAULT, type SqlDialect, type TFilterVisitorOptions, type TGeoCircle, type TGeoWindow, type TReplaceColumn, type TSqlFragment, buildAggregateCount, buildAggregateSelect, buildCreateView, buildDelete, buildGeoSearchCount, buildGeoSearchSelect, buildInsert, buildProjection, buildSelect, buildUpdate, buildWhere, createFilterVisitor, defaultValueForType, defaultValueToSqlLiteral, fillReplacePayload, finalizeParams, geoWindowFromControls, normalizeGeoPointValue, parseRegexString, queryNodeToSql, queryOpToSql, refActionToSql, renameGeoDistance, replaceColumnsFor, sqlStringLiteral, toSqlValue };
309
+ export { AGG_FN_SQL, EMPTY_AND, EMPTY_OR, GEO_DISTANCE_ALIAS, SQL_DEFAULT, type SqlDialect, type TFilterVisitorOptions, type TGeoCircle, type TGeoWindow, type TReplaceColumn, type TSqlFragment, buildAggregateCount, buildAggregateSelect, buildCreateView, buildDelete, buildGeoSearchCount, buildGeoSearchSelect, buildInsert, buildInsertMany, buildProjection, buildSelect, buildUpdate, buildWhere, createFilterVisitor, defaultValueForType, defaultValueToSqlLiteral, fillReplacePayload, finalizeParams, geoWindowFromControls, groupKeySql, insertManyColumns, normalizeGeoPointValue, parseRegexString, queryNodeToSql, queryOpToSql, refActionToSql, renameGeoDistance, replaceColumnsFor, sqlStringLiteral, sqlTimeZoneLiteral, toSqlValue };
package/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { walkFilter } from "@uniqu/core";
1
+ import { BUCKET_UNITS, TIME_ZONE_NAME_RE, WEEK_STARTS, walkFilter } from "@uniqu/core";
2
2
  import { DbError } from "@atscript/db";
3
3
  import { resolveAlias } from "@atscript/db/agg";
4
4
  //#region src/dialect.ts
@@ -253,6 +253,23 @@ function renameGeoDistance(row) {
253
253
  function sqlStringLiteral(value) {
254
254
  return `'${value.replace(/'/g, "''")}'`;
255
255
  }
256
+ /**
257
+ * A calendar bucket's time zone as an inlined SQL string literal (`'Europe/Berlin'`).
258
+ *
259
+ * The zone is already canonical (the core normalizer ran uniqu's
260
+ * `checkTimeZone`); this re-asserts uniqu's `TIME_ZONE_NAME_RE` charset —
261
+ * no quote, backslash or whitespace can reach the literal — as defense in
262
+ * depth for dialects that inline it (bucket expressions are parameter-free).
263
+ *
264
+ * @throws DbError `INVALID_QUERY` for a name outside the charset.
265
+ */
266
+ function sqlTimeZoneLiteral(tz) {
267
+ if (!TIME_ZONE_NAME_RE.test(tz)) throw new DbError("INVALID_QUERY", [{
268
+ path: "$select",
269
+ message: `Unknown time zone "${tz}"`
270
+ }]);
271
+ return `'${tz}'`;
272
+ }
256
273
  /** Converts a JS value to a SQL-bindable parameter. Objects/arrays -> JSON, booleans -> 0/1. */
257
274
  function toSqlValue(value) {
258
275
  if (value === void 0) return null;
@@ -337,6 +354,52 @@ function aggFnSql(dialect, expr) {
337
354
  function buildAggExpr(dialect, expr) {
338
355
  return `${aggFnSql(dialect, expr)} AS ${dialect.quoteIdentifier(resolveAlias(expr))}`;
339
356
  }
357
+ const BUCKET_UNIT_SET = new Set(BUCKET_UNITS);
358
+ const WEEK_START_SET = new Set(WEEK_STARTS);
359
+ /**
360
+ * The dialect's label expression for one calendar bucket.
361
+ *
362
+ * Internal assertions, once for every dialect, before it renders:
363
+ * - the dialect has `calendarBucket` — the user-facing `BUCKET_NOT_SUPPORTED`
364
+ * is the core's (`calendarBucketUnits()`), so only an adapter advertising
365
+ * units its dialect cannot render reaches this throw;
366
+ * - the literals a dialect inlines (the expression is parameter-free) are in
367
+ * their closed sets — unit, week start, ISO week start 1..7 — and the zone
368
+ * passes `sqlTimeZoneLiteral`'s charset. Defense in depth: the core's
369
+ * normalizer already validated all of them.
370
+ */
371
+ function bucketSql(dialect, bucket) {
372
+ if (!dialect.calendarBucket) throw new DbError("BUCKET_NOT_SUPPORTED", [{
373
+ path: "$select",
374
+ message: "Calendar buckets are not supported by this adapter"
375
+ }]);
376
+ const { unit, weekStart, weekStartIso } = bucket;
377
+ for (const [ok, value] of [
378
+ [BUCKET_UNIT_SET.has(unit), unit],
379
+ [WEEK_START_SET.has(weekStart), weekStart],
380
+ [Number.isInteger(weekStartIso) && weekStartIso >= 1 && weekStartIso <= 7, weekStartIso]
381
+ ]) if (!ok) throw new DbError("INVALID_QUERY", [{
382
+ path: "$select",
383
+ message: `Invalid calendar bucket argument "${String(value)}"`
384
+ }]);
385
+ sqlTimeZoneLiteral(bucket.tz);
386
+ return dialect.calendarBucket(dialect.quoteIdentifier(bucket.field), bucket);
387
+ }
388
+ /**
389
+ * The SQL a `$groupBy` key renders as: a calendar-bucket alias renders the
390
+ * bucket EXPRESSION (`dialect.calendarBucket`), anything else the quoted
391
+ * column. GROUP BY, HAVING and the count query's GROUP BY use it — PostgreSQL
392
+ * rejects SELECT aliases in HAVING and lets an input column of the same name
393
+ * win in GROUP BY, so the expression form is the legal, unambiguous rendering
394
+ * there (HAVING on MySQL is the exception — see `SqlDialect.bucketAliasInHaving`).
395
+ * SELECT and GROUP BY render identical, parameter-free text (PostgreSQL /
396
+ * MySQL `ONLY_FULL_GROUP_BY` match them structurally); ORDER BY keeps the
397
+ * bare output alias.
398
+ */
399
+ function groupKeySql(dialect, controls, key) {
400
+ const bucket = controls.$select?.bucketByAlias(key);
401
+ return bucket ? bucketSql(dialect, bucket) : dialect.quoteIdentifier(key);
402
+ }
340
403
  /**
341
404
  * ` HAVING <predicate>` (leading space) + params for `controls.$having`, or
342
405
  * `undefined` when there is nothing to render. Shared by the row and the
@@ -345,35 +408,52 @@ function buildAggExpr(dialect, expr) {
345
408
  * A key that names an aggregate alias (`$as`, else `fn_field`) renders the
346
409
  * aggregate expression itself — `SUM("amount") > ?` — because PostgreSQL does
347
410
  * not allow a SELECT alias in HAVING (MySQL and SQLite tolerate it, so the
348
- * expression form keeps all three identical). Other keys (grouped columns)
349
- * render as plain columns.
411
+ * expression form keeps all three identical). A calendar-bucket alias renders
412
+ * its bucket expression ({@link groupKeySql}), or its quoted alias when the
413
+ * dialect sets `SqlDialect.bucketAliasInHaving` (why: see there). Other keys
414
+ * (grouped columns) render as plain columns.
350
415
  */
351
416
  function havingClause(dialect, controls) {
352
417
  const having = controls.$having;
353
418
  if (!having) return void 0;
354
419
  const exprByAlias = /* @__PURE__ */ new Map();
355
420
  for (const expr of controls.$select?.aggregates ?? []) exprByAlias.set(resolveAlias(expr), aggFnSql(dialect, expr));
356
- const fragment = walkFilter(having, createFilterVisitor(dialect, { columnRef: (field) => exprByAlias.get(field) ?? dialect.quoteIdentifier(field) }));
421
+ const fragment = walkFilter(having, createFilterVisitor(dialect, { columnRef: (field) => {
422
+ const aggExpr = exprByAlias.get(field);
423
+ if (aggExpr) return aggExpr;
424
+ if (dialect.bucketAliasInHaving && controls.$select?.bucketByAlias(field)) return dialect.quoteIdentifier(field);
425
+ return groupKeySql(dialect, controls, field);
426
+ } }));
357
427
  if (!fragment || fragment.sql === EMPTY_AND.sql) return void 0;
358
428
  return {
359
429
  sql: ` HAVING ${fragment.sql}`,
360
430
  params: fragment.params
361
431
  };
362
432
  }
433
+ /** `<bucket expr> AS "alias"` for every calendar bucket in `$select`. */
434
+ function bucketSelectParts(dialect, controls) {
435
+ return (controls.$select?.buckets ?? []).map((bucket) => `${bucketSql(dialect, bucket)} AS ${dialect.quoteIdentifier(bucket.alias)}`);
436
+ }
363
437
  /**
364
438
  * Builds a SELECT ... GROUP BY statement with aggregate functions.
439
+ *
440
+ * SELECT lists the plain grouped columns, then `<bucket expr> AS "alias"`
441
+ * per calendar bucket, then the aggregates. Bucket expressions are
442
+ * parameter-free, so the bind parameters are exactly those of the same query
443
+ * without buckets (WHERE, HAVING, LIMIT, OFFSET).
365
444
  */
366
445
  function buildAggregateSelect(dialect, table, where, controls) {
367
446
  const selectParts = [];
368
447
  const plainFields = controls.$select?.asArray;
369
448
  if (plainFields) for (const f of plainFields) selectParts.push(dialect.quoteIdentifier(f));
449
+ selectParts.push(...bucketSelectParts(dialect, controls));
370
450
  const aggregates = controls.$select?.aggregates;
371
451
  if (aggregates) for (const expr of aggregates) selectParts.push(buildAggExpr(dialect, expr));
372
452
  let sql = `SELECT ${selectParts.length > 0 ? selectParts.join(", ") : "*"} FROM ${dialect.quoteTable(table)} WHERE ${where.sql}`;
373
453
  const params = [...where.params];
374
454
  const groupBy = controls.$groupBy;
375
455
  if (groupBy?.length) {
376
- const groupCols = groupBy.map((f) => dialect.quoteIdentifier(f)).join(", ");
456
+ const groupCols = groupBy.map((key) => groupKeySql(dialect, controls, key)).join(", ");
377
457
  sql += ` GROUP BY ${groupCols}`;
378
458
  }
379
459
  const having = havingClause(dialect, controls);
@@ -414,9 +494,11 @@ function buildAggregateCount(dialect, table, where, controls) {
414
494
  sql: `SELECT ${countCol} FROM ${dialect.quoteTable(table)} WHERE ${where.sql}`,
415
495
  params: where.params
416
496
  });
417
- const groupBy = groupFields?.length ? ` GROUP BY ${groupFields.map((f) => dialect.quoteIdentifier(f)).join(", ")}` : "";
497
+ const groupBy = groupFields?.length ? ` GROUP BY ${groupFields.map((key) => groupKeySql(dialect, controls, key)).join(", ")}` : "";
498
+ let inner = "COUNT(*)";
499
+ if (groupBy) inner = bucketSelectParts(dialect, controls).join(", ") || "1";
418
500
  return finalizeParams(dialect, {
419
- sql: `SELECT ${countCol} FROM (SELECT ${groupBy ? "1" : "COUNT(*)"} FROM ${dialect.quoteTable(table)} WHERE ${where.sql}${groupBy}${having?.sql ?? ""}) AS ${dialect.quoteIdentifier("_groups")}`,
501
+ sql: `SELECT ${countCol} FROM (SELECT ${inner} FROM ${dialect.quoteTable(table)} WHERE ${where.sql}${groupBy}${having?.sql ?? ""}) AS ${dialect.quoteIdentifier("_groups")}`,
420
502
  params: [...where.params, ...having?.params ?? []]
421
503
  });
422
504
  }
@@ -435,6 +517,39 @@ function buildInsert(dialect, table, data) {
435
517
  });
436
518
  }
437
519
  /**
520
+ * The columns of a multi-row INSERT: the union of the rows' keys, in
521
+ * first-seen order (rows may differ in shape — an optional field omitted on
522
+ * some).
523
+ */
524
+ function insertManyColumns(rows) {
525
+ const columns = /* @__PURE__ */ new Set();
526
+ for (const row of rows) for (const key of Object.keys(row)) columns.add(key);
527
+ return [...columns];
528
+ }
529
+ /**
530
+ * Builds a multi-row `INSERT … VALUES (…), (…)` statement over `columns`
531
+ * (default: {@link insertManyColumns} of `rows`). A row lacking a column gets
532
+ * `DEFAULT` — exactly what a single-row INSERT omitting it stores. Callers
533
+ * split large inputs into batches themselves (passing the same `columns` to
534
+ * each) and append any `RETURNING` clause.
535
+ */
536
+ function buildInsertMany(dialect, table, rows, columns = insertManyColumns(rows)) {
537
+ const cols = columns.map((k) => dialect.quoteIdentifier(k)).join(", ");
538
+ const fullRow = `(${columns.map(() => "?").join(", ")})`;
539
+ const params = [];
540
+ const clauses = [];
541
+ for (const row of rows) {
542
+ let missing = false;
543
+ for (const k of columns) if (k in row) params.push(dialect.toValue(row[k]));
544
+ else missing = true;
545
+ clauses.push(missing ? `(${columns.map((k) => k in row ? "?" : "DEFAULT").join(", ")})` : fullRow);
546
+ }
547
+ return finalizeParams(dialect, {
548
+ sql: `INSERT INTO ${dialect.quoteTable(table)} (${cols}) VALUES ${clauses.join(", ")}`,
549
+ params
550
+ });
551
+ }
552
+ /**
438
553
  * Builds a SELECT statement with optional sort, limit, offset, projection.
439
554
  */
440
555
  function buildSelect(dialect, table, where, controls) {
@@ -655,4 +770,4 @@ function parseRegexString(value) {
655
770
  };
656
771
  }
657
772
  //#endregion
658
- export { AGG_FN_SQL, EMPTY_AND, EMPTY_OR, GEO_DISTANCE_ALIAS, SQL_DEFAULT, buildAggregateCount, buildAggregateSelect, buildCreateView, buildDelete, buildGeoSearchCount, buildGeoSearchSelect, buildInsert, buildProjection, buildSelect, buildUpdate, buildWhere, createFilterVisitor, defaultValueForType, defaultValueToSqlLiteral, fillReplacePayload, finalizeParams, geoWindowFromControls, normalizeGeoPointValue, parseRegexString, queryNodeToSql, queryOpToSql, refActionToSql, renameGeoDistance, replaceColumnsFor, sqlStringLiteral, toSqlValue };
773
+ export { AGG_FN_SQL, EMPTY_AND, EMPTY_OR, GEO_DISTANCE_ALIAS, SQL_DEFAULT, buildAggregateCount, buildAggregateSelect, buildCreateView, buildDelete, buildGeoSearchCount, buildGeoSearchSelect, buildInsert, buildInsertMany, buildProjection, buildSelect, buildUpdate, buildWhere, createFilterVisitor, defaultValueForType, defaultValueToSqlLiteral, fillReplacePayload, finalizeParams, geoWindowFromControls, groupKeySql, insertManyColumns, normalizeGeoPointValue, parseRegexString, queryNodeToSql, queryOpToSql, refActionToSql, renameGeoDistance, replaceColumnsFor, sqlStringLiteral, sqlTimeZoneLiteral, toSqlValue };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atscript/db-sql-tools",
3
- "version": "0.1.131",
3
+ "version": "0.1.133",
4
4
  "description": "Shared SQL builder utilities for @atscript database adapters.",
5
5
  "keywords": [
6
6
  "atscript",
@@ -37,12 +37,12 @@
37
37
  "access": "public"
38
38
  },
39
39
  "devDependencies": {
40
- "@uniqu/core": "^0.1.8",
40
+ "@uniqu/core": "^0.1.10",
41
41
  "unplugin-atscript": "^0.1.92"
42
42
  },
43
43
  "peerDependencies": {
44
- "@uniqu/core": "^0.1.8",
45
- "@atscript/db": "^0.1.131"
44
+ "@uniqu/core": "^0.1.10",
45
+ "@atscript/db": "^0.1.133"
46
46
  },
47
47
  "scripts": {
48
48
  "build": "vp pack",