@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 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(dialect.quoteIdentifier(field), value);
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 = dialect.quoteIdentifier(field);
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
- const fn = AGG_FN_SQL[expr.$fn] ?? expr.$fn.toUpperCase();
335
- const alias = dialect.quoteIdentifier((0, _atscript_db_agg.resolveAlias)(expr));
336
- return `${fn}(${expr.$field === "*" ? "*" : dialect.quoteIdentifier(expr.$field)}) AS ${alias}`;
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
- if (controls.$having) {
355
- const havingFragment = buildWhere(dialect, controls.$having);
356
- if (havingFragment.sql !== EMPTY_AND.sql) {
357
- sql += ` HAVING ${havingFragment.sql}`;
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
- * Returns `{ count: N }` when executed.
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
- if (!groupFields?.length) return finalizeParams(dialect, {
387
- sql: `SELECT COUNT(*) AS ${dialect.quoteIdentifier("count")} FROM ${dialect.quoteTable(table)} WHERE ${where.sql}`,
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 groupCols = groupFields.map((f) => dialect.quoteIdentifier(f)).join(", ");
418
+ const groupBy = groupFields?.length ? ` GROUP BY ${groupFields.map((f) => dialect.quoteIdentifier(f)).join(", ")}` : "";
391
419
  return finalizeParams(dialect, {
392
- sql: `SELECT COUNT(*) AS ${dialect.quoteIdentifier("count")} FROM (SELECT 1 FROM ${dialect.quoteTable(table)} WHERE ${where.sql} GROUP BY ${groupCols}) AS ${dialect.quoteIdentifier("_groups")}`,
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
- * Returns `{ count: N }` when executed.
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
- * Returns `{ count: N }` when executed.
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(dialect.quoteIdentifier(field), value);
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 = dialect.quoteIdentifier(field);
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
- const fn = AGG_FN_SQL[expr.$fn] ?? expr.$fn.toUpperCase();
334
- const alias = dialect.quoteIdentifier(resolveAlias(expr));
335
- return `${fn}(${expr.$field === "*" ? "*" : dialect.quoteIdentifier(expr.$field)}) AS ${alias}`;
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
- if (controls.$having) {
354
- const havingFragment = buildWhere(dialect, controls.$having);
355
- if (havingFragment.sql !== EMPTY_AND.sql) {
356
- sql += ` HAVING ${havingFragment.sql}`;
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
- * Returns `{ count: N }` when executed.
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
- if (!groupFields?.length) return finalizeParams(dialect, {
386
- sql: `SELECT COUNT(*) AS ${dialect.quoteIdentifier("count")} FROM ${dialect.quoteTable(table)} WHERE ${where.sql}`,
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 groupCols = groupFields.map((f) => dialect.quoteIdentifier(f)).join(", ");
417
+ const groupBy = groupFields?.length ? ` GROUP BY ${groupFields.map((f) => dialect.quoteIdentifier(f)).join(", ")}` : "";
390
418
  return finalizeParams(dialect, {
391
- sql: `SELECT COUNT(*) AS ${dialect.quoteIdentifier("count")} FROM (SELECT 1 FROM ${dialect.quoteTable(table)} WHERE ${where.sql} GROUP BY ${groupCols}) AS ${dialect.quoteIdentifier("_groups")}`,
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.127",
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.7",
41
- "unplugin-atscript": "^0.1.89"
40
+ "@uniqu/core": "^0.1.8",
41
+ "unplugin-atscript": "^0.1.92"
42
42
  },
43
43
  "peerDependencies": {
44
- "@uniqu/core": "^0.1.7",
45
- "@atscript/db": "^0.1.127"
44
+ "@uniqu/core": "^0.1.8",
45
+ "@atscript/db": "^0.1.129"
46
46
  },
47
47
  "scripts": {
48
48
  "build": "vp pack",