@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 +125 -6
- package/dist/index.d.cts +73 -2
- package/dist/index.d.mts +73 -2
- package/dist/index.mjs +123 -8
- package/package.json +4 -4
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).
|
|
350
|
-
*
|
|
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) =>
|
|
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((
|
|
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((
|
|
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 ${
|
|
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).
|
|
349
|
-
*
|
|
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) =>
|
|
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((
|
|
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((
|
|
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 ${
|
|
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.
|
|
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.
|
|
40
|
+
"@uniqu/core": "^0.1.10",
|
|
41
41
|
"unplugin-atscript": "^0.1.92"
|
|
42
42
|
},
|
|
43
43
|
"peerDependencies": {
|
|
44
|
-
"@uniqu/core": "^0.1.
|
|
45
|
-
"@atscript/db": "^0.1.
|
|
44
|
+
"@uniqu/core": "^0.1.10",
|
|
45
|
+
"@atscript/db": "^0.1.133"
|
|
46
46
|
},
|
|
47
47
|
"scripts": {
|
|
48
48
|
"build": "vp pack",
|