@atscript/db-sql-tools 0.1.127 → 0.1.129
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 +99 -19
- package/dist/index.d.cts +54 -5
- package/dist/index.d.mts +54 -5
- package/dist/index.mjs +97 -20
- package/package.json +5 -5
package/dist/index.cjs
CHANGED
|
@@ -28,17 +28,18 @@ const EMPTY_OR = {
|
|
|
28
28
|
/**
|
|
29
29
|
* Creates a dialect-specific filter visitor for `walkFilter`.
|
|
30
30
|
*/
|
|
31
|
-
function createFilterVisitor(dialect) {
|
|
31
|
+
function createFilterVisitor(dialect, options) {
|
|
32
|
+
const columnRef = options?.columnRef ?? ((field) => dialect.quoteIdentifier(field));
|
|
32
33
|
return {
|
|
33
34
|
comparison(field, op, value) {
|
|
34
35
|
if (op === "$geoWithin") {
|
|
35
|
-
if (dialect.geoWithin) return dialect.geoWithin(
|
|
36
|
+
if (dialect.geoWithin) return dialect.geoWithin(columnRef(field), value);
|
|
36
37
|
throw new _atscript_db.DbError("GEO_NOT_SUPPORTED", [{
|
|
37
38
|
path: field,
|
|
38
39
|
message: "$geoWithin is not supported by this adapter"
|
|
39
40
|
}]);
|
|
40
41
|
}
|
|
41
|
-
const col =
|
|
42
|
+
const col = columnRef(field);
|
|
42
43
|
const v = dialect.toParam(value);
|
|
43
44
|
switch (op) {
|
|
44
45
|
case "$eq":
|
|
@@ -330,10 +331,35 @@ const AGG_FN_SQL = {
|
|
|
330
331
|
min: "MIN",
|
|
331
332
|
max: "MAX"
|
|
332
333
|
};
|
|
334
|
+
/** The bare aggregate call, e.g. `SUM("amount")` / `COUNT(*)`. */
|
|
335
|
+
function aggFnSql(dialect, expr) {
|
|
336
|
+
return `${AGG_FN_SQL[expr.$fn] ?? expr.$fn.toUpperCase()}(${expr.$field === "*" ? "*" : dialect.quoteIdentifier(expr.$field)})`;
|
|
337
|
+
}
|
|
333
338
|
function buildAggExpr(dialect, expr) {
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
339
|
+
return `${aggFnSql(dialect, expr)} AS ${dialect.quoteIdentifier((0, _atscript_db_agg.resolveAlias)(expr))}`;
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* ` HAVING <predicate>` (leading space) + params for `controls.$having`, or
|
|
343
|
+
* `undefined` when there is nothing to render. Shared by the row and the
|
|
344
|
+
* count builders so both filter the same group set.
|
|
345
|
+
*
|
|
346
|
+
* A key that names an aggregate alias (`$as`, else `fn_field`) renders the
|
|
347
|
+
* aggregate expression itself — `SUM("amount") > ?` — because PostgreSQL does
|
|
348
|
+
* 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.
|
|
351
|
+
*/
|
|
352
|
+
function havingClause(dialect, controls) {
|
|
353
|
+
const having = controls.$having;
|
|
354
|
+
if (!having) return void 0;
|
|
355
|
+
const exprByAlias = /* @__PURE__ */ new Map();
|
|
356
|
+
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) }));
|
|
358
|
+
if (!fragment || fragment.sql === EMPTY_AND.sql) return void 0;
|
|
359
|
+
return {
|
|
360
|
+
sql: ` HAVING ${fragment.sql}`,
|
|
361
|
+
params: fragment.params
|
|
362
|
+
};
|
|
337
363
|
}
|
|
338
364
|
/**
|
|
339
365
|
* Builds a SELECT ... GROUP BY statement with aggregate functions.
|
|
@@ -351,12 +377,10 @@ function buildAggregateSelect(dialect, table, where, controls) {
|
|
|
351
377
|
const groupCols = groupBy.map((f) => dialect.quoteIdentifier(f)).join(", ");
|
|
352
378
|
sql += ` GROUP BY ${groupCols}`;
|
|
353
379
|
}
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
params.push(...havingFragment.params);
|
|
359
|
-
}
|
|
380
|
+
const having = havingClause(dialect, controls);
|
|
381
|
+
if (having) {
|
|
382
|
+
sql += having.sql;
|
|
383
|
+
params.push(...having.params);
|
|
360
384
|
}
|
|
361
385
|
if (controls.$sort) {
|
|
362
386
|
const orderParts = [];
|
|
@@ -378,19 +402,23 @@ function buildAggregateSelect(dialect, table, where, controls) {
|
|
|
378
402
|
});
|
|
379
403
|
}
|
|
380
404
|
/**
|
|
381
|
-
* Builds a COUNT query for the number of distinct groups
|
|
382
|
-
*
|
|
405
|
+
* Builds a COUNT query for the number of distinct groups — the groups that
|
|
406
|
+
* survive `$having` when one is given (the same predicate the row query
|
|
407
|
+
* renders, so `$count` agrees with the row set). Returns `{ count: N }` when
|
|
408
|
+
* executed.
|
|
383
409
|
*/
|
|
384
410
|
function buildAggregateCount(dialect, table, where, controls) {
|
|
385
411
|
const groupFields = controls.$groupBy;
|
|
386
|
-
|
|
387
|
-
|
|
412
|
+
const having = havingClause(dialect, controls);
|
|
413
|
+
const countCol = `COUNT(*) AS ${dialect.quoteIdentifier("count")}`;
|
|
414
|
+
if (!groupFields?.length && !having) return finalizeParams(dialect, {
|
|
415
|
+
sql: `SELECT ${countCol} FROM ${dialect.quoteTable(table)} WHERE ${where.sql}`,
|
|
388
416
|
params: where.params
|
|
389
417
|
});
|
|
390
|
-
const
|
|
418
|
+
const groupBy = groupFields?.length ? ` GROUP BY ${groupFields.map((f) => dialect.quoteIdentifier(f)).join(", ")}` : "";
|
|
391
419
|
return finalizeParams(dialect, {
|
|
392
|
-
sql: `SELECT
|
|
393
|
-
params: where.params
|
|
420
|
+
sql: `SELECT ${countCol} FROM (SELECT ${groupBy ? "1" : "COUNT(*)"} FROM ${dialect.quoteTable(table)} WHERE ${where.sql}${groupBy}${having?.sql ?? ""}) AS ${dialect.quoteIdentifier("_groups")}`,
|
|
421
|
+
params: [...where.params, ...having?.params ?? []]
|
|
394
422
|
});
|
|
395
423
|
}
|
|
396
424
|
//#endregion
|
|
@@ -433,8 +461,53 @@ function buildSelect(dialect, table, where, controls) {
|
|
|
433
461
|
});
|
|
434
462
|
}
|
|
435
463
|
/**
|
|
464
|
+
* Marker value for {@link buildUpdate}: the column is assigned its DDL
|
|
465
|
+
* `DEFAULT` (`SET "col" = DEFAULT`, no bound parameter). Produced by
|
|
466
|
+
* {@link fillReplacePayload} for columns whose function default the engine
|
|
467
|
+
* owns; never appears in a patch.
|
|
468
|
+
*/
|
|
469
|
+
const SQL_DEFAULT = Symbol("SQL_DEFAULT");
|
|
470
|
+
/**
|
|
471
|
+
* The columns a full replace assigns on a SQL adapter: every non-ignored
|
|
472
|
+
* descriptor (the same set `CREATE TABLE` emits) except the primary key —
|
|
473
|
+
* the row is matched by the filter, and an omitted PK must never be nulled
|
|
474
|
+
* or re-defaulted. Static value defaults are filled SDK-side before the
|
|
475
|
+
* adapter sees the row, so only native function defaults are flagged.
|
|
476
|
+
*/
|
|
477
|
+
function replaceColumnsFor(fields, nativeFns) {
|
|
478
|
+
const out = [];
|
|
479
|
+
for (const fd of fields) {
|
|
480
|
+
if (fd.ignored || fd.isPrimaryKey) continue;
|
|
481
|
+
const def = fd.defaultValue;
|
|
482
|
+
out.push({
|
|
483
|
+
name: fd.physicalName,
|
|
484
|
+
useDefault: def?.kind === "fn" && nativeFns.has(def.fn)
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
return out;
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* Turns a (physical-name) replace payload into a FULL row assignment: every
|
|
491
|
+
* column in `columns` the payload omits becomes `null` — or {@link SQL_DEFAULT}
|
|
492
|
+
* when the engine owns its function default — so an UPDATE-based replace never
|
|
493
|
+
* retains a value the caller left out. This is the SQL counterpart of the
|
|
494
|
+
* whole-document replace the memory and MongoDB adapters do natively. The
|
|
495
|
+
* version column is excluded (`buildUpdate` appends the OCC bump itself).
|
|
496
|
+
* Returns a new object; `data` is not mutated.
|
|
497
|
+
*/
|
|
498
|
+
function fillReplacePayload(data, columns, versionColumn) {
|
|
499
|
+
const full = { ...data };
|
|
500
|
+
for (const col of columns) {
|
|
501
|
+
if (col.name === versionColumn || col.name in full) continue;
|
|
502
|
+
full[col.name] = col.useDefault ? SQL_DEFAULT : null;
|
|
503
|
+
}
|
|
504
|
+
return full;
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
436
507
|
* Builds an UPDATE ... SET ... WHERE statement with optional LIMIT.
|
|
437
508
|
*
|
|
509
|
+
* A value of {@link SQL_DEFAULT} renders as `<col> = DEFAULT` (full replace).
|
|
510
|
+
*
|
|
438
511
|
* Optimistic concurrency control (OCC) hooks:
|
|
439
512
|
* - `versionColumn` — when supplied, the builder appends
|
|
440
513
|
* `<col> = <col> + 1` to the SET list. The bump is **mandatory** whenever
|
|
@@ -450,6 +523,10 @@ function buildUpdate(dialect, table, data, where, limit, ops, versionColumn, exp
|
|
|
450
523
|
const setClauses = [];
|
|
451
524
|
const params = [];
|
|
452
525
|
for (const [key, value] of Object.entries(data)) {
|
|
526
|
+
if (value === SQL_DEFAULT) {
|
|
527
|
+
setClauses.push(`${dialect.quoteIdentifier(key)} = DEFAULT`);
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
453
530
|
setClauses.push(`${dialect.quoteIdentifier(key)} = ?`);
|
|
454
531
|
params.push(dialect.toValue(value));
|
|
455
532
|
}
|
|
@@ -583,6 +660,7 @@ exports.AGG_FN_SQL = AGG_FN_SQL;
|
|
|
583
660
|
exports.EMPTY_AND = EMPTY_AND;
|
|
584
661
|
exports.EMPTY_OR = EMPTY_OR;
|
|
585
662
|
exports.GEO_DISTANCE_ALIAS = GEO_DISTANCE_ALIAS;
|
|
663
|
+
exports.SQL_DEFAULT = SQL_DEFAULT;
|
|
586
664
|
exports.buildAggregateCount = buildAggregateCount;
|
|
587
665
|
exports.buildAggregateSelect = buildAggregateSelect;
|
|
588
666
|
exports.buildCreateView = buildCreateView;
|
|
@@ -597,6 +675,7 @@ exports.buildWhere = buildWhere;
|
|
|
597
675
|
exports.createFilterVisitor = createFilterVisitor;
|
|
598
676
|
exports.defaultValueForType = defaultValueForType;
|
|
599
677
|
exports.defaultValueToSqlLiteral = defaultValueToSqlLiteral;
|
|
678
|
+
exports.fillReplacePayload = fillReplacePayload;
|
|
600
679
|
exports.finalizeParams = finalizeParams;
|
|
601
680
|
exports.geoWindowFromControls = geoWindowFromControls;
|
|
602
681
|
exports.normalizeGeoPointValue = normalizeGeoPointValue;
|
|
@@ -605,5 +684,6 @@ exports.queryNodeToSql = queryNodeToSql;
|
|
|
605
684
|
exports.queryOpToSql = queryOpToSql;
|
|
606
685
|
exports.refActionToSql = refActionToSql;
|
|
607
686
|
exports.renameGeoDistance = renameGeoDistance;
|
|
687
|
+
exports.replaceColumnsFor = replaceColumnsFor;
|
|
608
688
|
exports.sqlStringLiteral = sqlStringLiteral;
|
|
609
689
|
exports.toSqlValue = toSqlValue;
|
package/dist/index.d.cts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { FilterExpr, FilterVisitor } from "@uniqu/core";
|
|
2
|
-
import { AtscriptQueryFieldRef, AtscriptQueryNode, DbControls, TDbReferentialAction, TFieldOps, TViewColumnMapping, TViewPlan, UniquSelect } from "@atscript/db";
|
|
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 {
|
|
@@ -45,10 +45,19 @@ declare const EMPTY_AND: TSqlFragment;
|
|
|
45
45
|
declare const EMPTY_OR: TSqlFragment;
|
|
46
46
|
//#endregion
|
|
47
47
|
//#region src/filter-builder.d.ts
|
|
48
|
+
interface TFilterVisitorOptions {
|
|
49
|
+
/**
|
|
50
|
+
* Renders a filter key as its SQL operand. Defaults to
|
|
51
|
+
* `dialect.quoteIdentifier(field)`; the aggregate builder overrides it so a
|
|
52
|
+
* `$having` key that names an aggregate alias renders the aggregate
|
|
53
|
+
* expression (`SUM("amount")`) — PostgreSQL rejects SELECT aliases in HAVING.
|
|
54
|
+
*/
|
|
55
|
+
columnRef?: (field: string) => string;
|
|
56
|
+
}
|
|
48
57
|
/**
|
|
49
58
|
* Creates a dialect-specific filter visitor for `walkFilter`.
|
|
50
59
|
*/
|
|
51
|
-
declare function createFilterVisitor(dialect: SqlDialect): FilterVisitor<TSqlFragment>;
|
|
60
|
+
declare function createFilterVisitor(dialect: SqlDialect, options?: TFilterVisitorOptions): FilterVisitor<TSqlFragment>;
|
|
52
61
|
/**
|
|
53
62
|
* Translates a filter expression into a parameterized SQL WHERE clause.
|
|
54
63
|
*/
|
|
@@ -113,9 +122,47 @@ declare function buildInsert(dialect: SqlDialect, table: string, data: Record<st
|
|
|
113
122
|
* Builds a SELECT statement with optional sort, limit, offset, projection.
|
|
114
123
|
*/
|
|
115
124
|
declare function buildSelect(dialect: SqlDialect, table: string, where: TSqlFragment, controls?: DbControls): TSqlFragment;
|
|
125
|
+
/**
|
|
126
|
+
* Marker value for {@link buildUpdate}: the column is assigned its DDL
|
|
127
|
+
* `DEFAULT` (`SET "col" = DEFAULT`, no bound parameter). Produced by
|
|
128
|
+
* {@link fillReplacePayload} for columns whose function default the engine
|
|
129
|
+
* owns; never appears in a patch.
|
|
130
|
+
*/
|
|
131
|
+
declare const SQL_DEFAULT: unique symbol;
|
|
132
|
+
/** One physical column a full replace must assign. */
|
|
133
|
+
interface TReplaceColumn {
|
|
134
|
+
/** Physical column name. */
|
|
135
|
+
name: string;
|
|
136
|
+
/**
|
|
137
|
+
* `true` when the engine owns this column's function default (`now`,
|
|
138
|
+
* `uuid`, `increment` listed in the adapter's `nativeDefaultFns()`): an
|
|
139
|
+
* omitted value re-applies the DDL `DEFAULT` instead of storing NULL.
|
|
140
|
+
*/
|
|
141
|
+
useDefault: boolean;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* The columns a full replace assigns on a SQL adapter: every non-ignored
|
|
145
|
+
* descriptor (the same set `CREATE TABLE` emits) except the primary key —
|
|
146
|
+
* the row is matched by the filter, and an omitted PK must never be nulled
|
|
147
|
+
* or re-defaulted. Static value defaults are filled SDK-side before the
|
|
148
|
+
* adapter sees the row, so only native function defaults are flagged.
|
|
149
|
+
*/
|
|
150
|
+
declare function replaceColumnsFor(fields: readonly TDbFieldMeta[], nativeFns: ReadonlySet<TDbDefaultFn>): TReplaceColumn[];
|
|
151
|
+
/**
|
|
152
|
+
* Turns a (physical-name) replace payload into a FULL row assignment: every
|
|
153
|
+
* column in `columns` the payload omits becomes `null` — or {@link SQL_DEFAULT}
|
|
154
|
+
* when the engine owns its function default — so an UPDATE-based replace never
|
|
155
|
+
* retains a value the caller left out. This is the SQL counterpart of the
|
|
156
|
+
* whole-document replace the memory and MongoDB adapters do natively. The
|
|
157
|
+
* version column is excluded (`buildUpdate` appends the OCC bump itself).
|
|
158
|
+
* Returns a new object; `data` is not mutated.
|
|
159
|
+
*/
|
|
160
|
+
declare function fillReplacePayload(data: Record<string, unknown>, columns: readonly TReplaceColumn[], versionColumn?: string): Record<string, unknown>;
|
|
116
161
|
/**
|
|
117
162
|
* Builds an UPDATE ... SET ... WHERE statement with optional LIMIT.
|
|
118
163
|
*
|
|
164
|
+
* A value of {@link SQL_DEFAULT} renders as `<col> = DEFAULT` (full replace).
|
|
165
|
+
*
|
|
119
166
|
* Optimistic concurrency control (OCC) hooks:
|
|
120
167
|
* - `versionColumn` — when supplied, the builder appends
|
|
121
168
|
* `<col> = <col> + 1` to the SET list. The bump is **mandatory** whenever
|
|
@@ -168,8 +215,10 @@ declare const AGG_FN_SQL: Record<string, string>;
|
|
|
168
215
|
*/
|
|
169
216
|
declare function buildAggregateSelect(dialect: SqlDialect, table: string, where: TSqlFragment, controls: DbControls): TSqlFragment;
|
|
170
217
|
/**
|
|
171
|
-
* Builds a COUNT query for the number of distinct groups
|
|
172
|
-
*
|
|
218
|
+
* Builds a COUNT query for the number of distinct groups — the groups that
|
|
219
|
+
* survive `$having` when one is given (the same predicate the row query
|
|
220
|
+
* renders, so `$count` agrees with the row set). Returns `{ count: N }` when
|
|
221
|
+
* executed.
|
|
173
222
|
*/
|
|
174
223
|
declare function buildAggregateCount(dialect: SqlDialect, table: string, where: TSqlFragment, controls: DbControls): TSqlFragment;
|
|
175
224
|
//#endregion
|
|
@@ -186,4 +235,4 @@ declare function parseRegexString(value: unknown): {
|
|
|
186
235
|
flags: string;
|
|
187
236
|
};
|
|
188
237
|
//#endregion
|
|
189
|
-
export { AGG_FN_SQL, EMPTY_AND, EMPTY_OR, GEO_DISTANCE_ALIAS, type SqlDialect, type TGeoCircle, type TGeoWindow, type TSqlFragment, buildAggregateCount, buildAggregateSelect, buildCreateView, buildDelete, buildGeoSearchCount, buildGeoSearchSelect, buildInsert, buildProjection, buildSelect, buildUpdate, buildWhere, createFilterVisitor, defaultValueForType, defaultValueToSqlLiteral, finalizeParams, geoWindowFromControls, normalizeGeoPointValue, parseRegexString, queryNodeToSql, queryOpToSql, refActionToSql, renameGeoDistance, sqlStringLiteral, toSqlValue };
|
|
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 };
|
package/dist/index.d.mts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { FilterExpr, FilterVisitor } from "@uniqu/core";
|
|
2
|
-
import { AtscriptQueryFieldRef, AtscriptQueryNode, DbControls, TDbReferentialAction, TFieldOps, TViewColumnMapping, TViewPlan, UniquSelect } from "@atscript/db";
|
|
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 {
|
|
@@ -45,10 +45,19 @@ declare const EMPTY_AND: TSqlFragment;
|
|
|
45
45
|
declare const EMPTY_OR: TSqlFragment;
|
|
46
46
|
//#endregion
|
|
47
47
|
//#region src/filter-builder.d.ts
|
|
48
|
+
interface TFilterVisitorOptions {
|
|
49
|
+
/**
|
|
50
|
+
* Renders a filter key as its SQL operand. Defaults to
|
|
51
|
+
* `dialect.quoteIdentifier(field)`; the aggregate builder overrides it so a
|
|
52
|
+
* `$having` key that names an aggregate alias renders the aggregate
|
|
53
|
+
* expression (`SUM("amount")`) — PostgreSQL rejects SELECT aliases in HAVING.
|
|
54
|
+
*/
|
|
55
|
+
columnRef?: (field: string) => string;
|
|
56
|
+
}
|
|
48
57
|
/**
|
|
49
58
|
* Creates a dialect-specific filter visitor for `walkFilter`.
|
|
50
59
|
*/
|
|
51
|
-
declare function createFilterVisitor(dialect: SqlDialect): FilterVisitor<TSqlFragment>;
|
|
60
|
+
declare function createFilterVisitor(dialect: SqlDialect, options?: TFilterVisitorOptions): FilterVisitor<TSqlFragment>;
|
|
52
61
|
/**
|
|
53
62
|
* Translates a filter expression into a parameterized SQL WHERE clause.
|
|
54
63
|
*/
|
|
@@ -113,9 +122,47 @@ declare function buildInsert(dialect: SqlDialect, table: string, data: Record<st
|
|
|
113
122
|
* Builds a SELECT statement with optional sort, limit, offset, projection.
|
|
114
123
|
*/
|
|
115
124
|
declare function buildSelect(dialect: SqlDialect, table: string, where: TSqlFragment, controls?: DbControls): TSqlFragment;
|
|
125
|
+
/**
|
|
126
|
+
* Marker value for {@link buildUpdate}: the column is assigned its DDL
|
|
127
|
+
* `DEFAULT` (`SET "col" = DEFAULT`, no bound parameter). Produced by
|
|
128
|
+
* {@link fillReplacePayload} for columns whose function default the engine
|
|
129
|
+
* owns; never appears in a patch.
|
|
130
|
+
*/
|
|
131
|
+
declare const SQL_DEFAULT: unique symbol;
|
|
132
|
+
/** One physical column a full replace must assign. */
|
|
133
|
+
interface TReplaceColumn {
|
|
134
|
+
/** Physical column name. */
|
|
135
|
+
name: string;
|
|
136
|
+
/**
|
|
137
|
+
* `true` when the engine owns this column's function default (`now`,
|
|
138
|
+
* `uuid`, `increment` listed in the adapter's `nativeDefaultFns()`): an
|
|
139
|
+
* omitted value re-applies the DDL `DEFAULT` instead of storing NULL.
|
|
140
|
+
*/
|
|
141
|
+
useDefault: boolean;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* The columns a full replace assigns on a SQL adapter: every non-ignored
|
|
145
|
+
* descriptor (the same set `CREATE TABLE` emits) except the primary key —
|
|
146
|
+
* the row is matched by the filter, and an omitted PK must never be nulled
|
|
147
|
+
* or re-defaulted. Static value defaults are filled SDK-side before the
|
|
148
|
+
* adapter sees the row, so only native function defaults are flagged.
|
|
149
|
+
*/
|
|
150
|
+
declare function replaceColumnsFor(fields: readonly TDbFieldMeta[], nativeFns: ReadonlySet<TDbDefaultFn>): TReplaceColumn[];
|
|
151
|
+
/**
|
|
152
|
+
* Turns a (physical-name) replace payload into a FULL row assignment: every
|
|
153
|
+
* column in `columns` the payload omits becomes `null` — or {@link SQL_DEFAULT}
|
|
154
|
+
* when the engine owns its function default — so an UPDATE-based replace never
|
|
155
|
+
* retains a value the caller left out. This is the SQL counterpart of the
|
|
156
|
+
* whole-document replace the memory and MongoDB adapters do natively. The
|
|
157
|
+
* version column is excluded (`buildUpdate` appends the OCC bump itself).
|
|
158
|
+
* Returns a new object; `data` is not mutated.
|
|
159
|
+
*/
|
|
160
|
+
declare function fillReplacePayload(data: Record<string, unknown>, columns: readonly TReplaceColumn[], versionColumn?: string): Record<string, unknown>;
|
|
116
161
|
/**
|
|
117
162
|
* Builds an UPDATE ... SET ... WHERE statement with optional LIMIT.
|
|
118
163
|
*
|
|
164
|
+
* A value of {@link SQL_DEFAULT} renders as `<col> = DEFAULT` (full replace).
|
|
165
|
+
*
|
|
119
166
|
* Optimistic concurrency control (OCC) hooks:
|
|
120
167
|
* - `versionColumn` — when supplied, the builder appends
|
|
121
168
|
* `<col> = <col> + 1` to the SET list. The bump is **mandatory** whenever
|
|
@@ -168,8 +215,10 @@ declare const AGG_FN_SQL: Record<string, string>;
|
|
|
168
215
|
*/
|
|
169
216
|
declare function buildAggregateSelect(dialect: SqlDialect, table: string, where: TSqlFragment, controls: DbControls): TSqlFragment;
|
|
170
217
|
/**
|
|
171
|
-
* Builds a COUNT query for the number of distinct groups
|
|
172
|
-
*
|
|
218
|
+
* Builds a COUNT query for the number of distinct groups — the groups that
|
|
219
|
+
* survive `$having` when one is given (the same predicate the row query
|
|
220
|
+
* renders, so `$count` agrees with the row set). Returns `{ count: N }` when
|
|
221
|
+
* executed.
|
|
173
222
|
*/
|
|
174
223
|
declare function buildAggregateCount(dialect: SqlDialect, table: string, where: TSqlFragment, controls: DbControls): TSqlFragment;
|
|
175
224
|
//#endregion
|
|
@@ -186,4 +235,4 @@ declare function parseRegexString(value: unknown): {
|
|
|
186
235
|
flags: string;
|
|
187
236
|
};
|
|
188
237
|
//#endregion
|
|
189
|
-
export { AGG_FN_SQL, EMPTY_AND, EMPTY_OR, GEO_DISTANCE_ALIAS, type SqlDialect, type TGeoCircle, type TGeoWindow, type TSqlFragment, buildAggregateCount, buildAggregateSelect, buildCreateView, buildDelete, buildGeoSearchCount, buildGeoSearchSelect, buildInsert, buildProjection, buildSelect, buildUpdate, buildWhere, createFilterVisitor, defaultValueForType, defaultValueToSqlLiteral, finalizeParams, geoWindowFromControls, normalizeGeoPointValue, parseRegexString, queryNodeToSql, queryOpToSql, refActionToSql, renameGeoDistance, sqlStringLiteral, toSqlValue };
|
|
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 };
|
package/dist/index.mjs
CHANGED
|
@@ -27,17 +27,18 @@ const EMPTY_OR = {
|
|
|
27
27
|
/**
|
|
28
28
|
* Creates a dialect-specific filter visitor for `walkFilter`.
|
|
29
29
|
*/
|
|
30
|
-
function createFilterVisitor(dialect) {
|
|
30
|
+
function createFilterVisitor(dialect, options) {
|
|
31
|
+
const columnRef = options?.columnRef ?? ((field) => dialect.quoteIdentifier(field));
|
|
31
32
|
return {
|
|
32
33
|
comparison(field, op, value) {
|
|
33
34
|
if (op === "$geoWithin") {
|
|
34
|
-
if (dialect.geoWithin) return dialect.geoWithin(
|
|
35
|
+
if (dialect.geoWithin) return dialect.geoWithin(columnRef(field), value);
|
|
35
36
|
throw new DbError("GEO_NOT_SUPPORTED", [{
|
|
36
37
|
path: field,
|
|
37
38
|
message: "$geoWithin is not supported by this adapter"
|
|
38
39
|
}]);
|
|
39
40
|
}
|
|
40
|
-
const col =
|
|
41
|
+
const col = columnRef(field);
|
|
41
42
|
const v = dialect.toParam(value);
|
|
42
43
|
switch (op) {
|
|
43
44
|
case "$eq":
|
|
@@ -329,10 +330,35 @@ const AGG_FN_SQL = {
|
|
|
329
330
|
min: "MIN",
|
|
330
331
|
max: "MAX"
|
|
331
332
|
};
|
|
333
|
+
/** The bare aggregate call, e.g. `SUM("amount")` / `COUNT(*)`. */
|
|
334
|
+
function aggFnSql(dialect, expr) {
|
|
335
|
+
return `${AGG_FN_SQL[expr.$fn] ?? expr.$fn.toUpperCase()}(${expr.$field === "*" ? "*" : dialect.quoteIdentifier(expr.$field)})`;
|
|
336
|
+
}
|
|
332
337
|
function buildAggExpr(dialect, expr) {
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
338
|
+
return `${aggFnSql(dialect, expr)} AS ${dialect.quoteIdentifier(resolveAlias(expr))}`;
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* ` HAVING <predicate>` (leading space) + params for `controls.$having`, or
|
|
342
|
+
* `undefined` when there is nothing to render. Shared by the row and the
|
|
343
|
+
* count builders so both filter the same group set.
|
|
344
|
+
*
|
|
345
|
+
* A key that names an aggregate alias (`$as`, else `fn_field`) renders the
|
|
346
|
+
* aggregate expression itself — `SUM("amount") > ?` — because PostgreSQL does
|
|
347
|
+
* 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.
|
|
350
|
+
*/
|
|
351
|
+
function havingClause(dialect, controls) {
|
|
352
|
+
const having = controls.$having;
|
|
353
|
+
if (!having) return void 0;
|
|
354
|
+
const exprByAlias = /* @__PURE__ */ new Map();
|
|
355
|
+
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) }));
|
|
357
|
+
if (!fragment || fragment.sql === EMPTY_AND.sql) return void 0;
|
|
358
|
+
return {
|
|
359
|
+
sql: ` HAVING ${fragment.sql}`,
|
|
360
|
+
params: fragment.params
|
|
361
|
+
};
|
|
336
362
|
}
|
|
337
363
|
/**
|
|
338
364
|
* Builds a SELECT ... GROUP BY statement with aggregate functions.
|
|
@@ -350,12 +376,10 @@ function buildAggregateSelect(dialect, table, where, controls) {
|
|
|
350
376
|
const groupCols = groupBy.map((f) => dialect.quoteIdentifier(f)).join(", ");
|
|
351
377
|
sql += ` GROUP BY ${groupCols}`;
|
|
352
378
|
}
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
params.push(...havingFragment.params);
|
|
358
|
-
}
|
|
379
|
+
const having = havingClause(dialect, controls);
|
|
380
|
+
if (having) {
|
|
381
|
+
sql += having.sql;
|
|
382
|
+
params.push(...having.params);
|
|
359
383
|
}
|
|
360
384
|
if (controls.$sort) {
|
|
361
385
|
const orderParts = [];
|
|
@@ -377,19 +401,23 @@ function buildAggregateSelect(dialect, table, where, controls) {
|
|
|
377
401
|
});
|
|
378
402
|
}
|
|
379
403
|
/**
|
|
380
|
-
* Builds a COUNT query for the number of distinct groups
|
|
381
|
-
*
|
|
404
|
+
* Builds a COUNT query for the number of distinct groups — the groups that
|
|
405
|
+
* survive `$having` when one is given (the same predicate the row query
|
|
406
|
+
* renders, so `$count` agrees with the row set). Returns `{ count: N }` when
|
|
407
|
+
* executed.
|
|
382
408
|
*/
|
|
383
409
|
function buildAggregateCount(dialect, table, where, controls) {
|
|
384
410
|
const groupFields = controls.$groupBy;
|
|
385
|
-
|
|
386
|
-
|
|
411
|
+
const having = havingClause(dialect, controls);
|
|
412
|
+
const countCol = `COUNT(*) AS ${dialect.quoteIdentifier("count")}`;
|
|
413
|
+
if (!groupFields?.length && !having) return finalizeParams(dialect, {
|
|
414
|
+
sql: `SELECT ${countCol} FROM ${dialect.quoteTable(table)} WHERE ${where.sql}`,
|
|
387
415
|
params: where.params
|
|
388
416
|
});
|
|
389
|
-
const
|
|
417
|
+
const groupBy = groupFields?.length ? ` GROUP BY ${groupFields.map((f) => dialect.quoteIdentifier(f)).join(", ")}` : "";
|
|
390
418
|
return finalizeParams(dialect, {
|
|
391
|
-
sql: `SELECT
|
|
392
|
-
params: where.params
|
|
419
|
+
sql: `SELECT ${countCol} FROM (SELECT ${groupBy ? "1" : "COUNT(*)"} FROM ${dialect.quoteTable(table)} WHERE ${where.sql}${groupBy}${having?.sql ?? ""}) AS ${dialect.quoteIdentifier("_groups")}`,
|
|
420
|
+
params: [...where.params, ...having?.params ?? []]
|
|
393
421
|
});
|
|
394
422
|
}
|
|
395
423
|
//#endregion
|
|
@@ -432,8 +460,53 @@ function buildSelect(dialect, table, where, controls) {
|
|
|
432
460
|
});
|
|
433
461
|
}
|
|
434
462
|
/**
|
|
463
|
+
* Marker value for {@link buildUpdate}: the column is assigned its DDL
|
|
464
|
+
* `DEFAULT` (`SET "col" = DEFAULT`, no bound parameter). Produced by
|
|
465
|
+
* {@link fillReplacePayload} for columns whose function default the engine
|
|
466
|
+
* owns; never appears in a patch.
|
|
467
|
+
*/
|
|
468
|
+
const SQL_DEFAULT = Symbol("SQL_DEFAULT");
|
|
469
|
+
/**
|
|
470
|
+
* The columns a full replace assigns on a SQL adapter: every non-ignored
|
|
471
|
+
* descriptor (the same set `CREATE TABLE` emits) except the primary key —
|
|
472
|
+
* the row is matched by the filter, and an omitted PK must never be nulled
|
|
473
|
+
* or re-defaulted. Static value defaults are filled SDK-side before the
|
|
474
|
+
* adapter sees the row, so only native function defaults are flagged.
|
|
475
|
+
*/
|
|
476
|
+
function replaceColumnsFor(fields, nativeFns) {
|
|
477
|
+
const out = [];
|
|
478
|
+
for (const fd of fields) {
|
|
479
|
+
if (fd.ignored || fd.isPrimaryKey) continue;
|
|
480
|
+
const def = fd.defaultValue;
|
|
481
|
+
out.push({
|
|
482
|
+
name: fd.physicalName,
|
|
483
|
+
useDefault: def?.kind === "fn" && nativeFns.has(def.fn)
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
return out;
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* Turns a (physical-name) replace payload into a FULL row assignment: every
|
|
490
|
+
* column in `columns` the payload omits becomes `null` — or {@link SQL_DEFAULT}
|
|
491
|
+
* when the engine owns its function default — so an UPDATE-based replace never
|
|
492
|
+
* retains a value the caller left out. This is the SQL counterpart of the
|
|
493
|
+
* whole-document replace the memory and MongoDB adapters do natively. The
|
|
494
|
+
* version column is excluded (`buildUpdate` appends the OCC bump itself).
|
|
495
|
+
* Returns a new object; `data` is not mutated.
|
|
496
|
+
*/
|
|
497
|
+
function fillReplacePayload(data, columns, versionColumn) {
|
|
498
|
+
const full = { ...data };
|
|
499
|
+
for (const col of columns) {
|
|
500
|
+
if (col.name === versionColumn || col.name in full) continue;
|
|
501
|
+
full[col.name] = col.useDefault ? SQL_DEFAULT : null;
|
|
502
|
+
}
|
|
503
|
+
return full;
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
435
506
|
* Builds an UPDATE ... SET ... WHERE statement with optional LIMIT.
|
|
436
507
|
*
|
|
508
|
+
* A value of {@link SQL_DEFAULT} renders as `<col> = DEFAULT` (full replace).
|
|
509
|
+
*
|
|
437
510
|
* Optimistic concurrency control (OCC) hooks:
|
|
438
511
|
* - `versionColumn` — when supplied, the builder appends
|
|
439
512
|
* `<col> = <col> + 1` to the SET list. The bump is **mandatory** whenever
|
|
@@ -449,6 +522,10 @@ function buildUpdate(dialect, table, data, where, limit, ops, versionColumn, exp
|
|
|
449
522
|
const setClauses = [];
|
|
450
523
|
const params = [];
|
|
451
524
|
for (const [key, value] of Object.entries(data)) {
|
|
525
|
+
if (value === SQL_DEFAULT) {
|
|
526
|
+
setClauses.push(`${dialect.quoteIdentifier(key)} = DEFAULT`);
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
452
529
|
setClauses.push(`${dialect.quoteIdentifier(key)} = ?`);
|
|
453
530
|
params.push(dialect.toValue(value));
|
|
454
531
|
}
|
|
@@ -578,4 +655,4 @@ function parseRegexString(value) {
|
|
|
578
655
|
};
|
|
579
656
|
}
|
|
580
657
|
//#endregion
|
|
581
|
-
export { AGG_FN_SQL, EMPTY_AND, EMPTY_OR, GEO_DISTANCE_ALIAS, buildAggregateCount, buildAggregateSelect, buildCreateView, buildDelete, buildGeoSearchCount, buildGeoSearchSelect, buildInsert, buildProjection, buildSelect, buildUpdate, buildWhere, createFilterVisitor, defaultValueForType, defaultValueToSqlLiteral, finalizeParams, geoWindowFromControls, normalizeGeoPointValue, parseRegexString, queryNodeToSql, queryOpToSql, refActionToSql, renameGeoDistance, sqlStringLiteral, toSqlValue };
|
|
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 };
|
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.129",
|
|
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.
|
|
41
|
-
"unplugin-atscript": "^0.1.
|
|
40
|
+
"@uniqu/core": "^0.1.8",
|
|
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.8",
|
|
45
|
+
"@atscript/db": "^0.1.129"
|
|
46
46
|
},
|
|
47
47
|
"scripts": {
|
|
48
48
|
"build": "vp pack",
|