@atscript/db-sql-tools 0.1.127 → 0.1.128

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,27 @@ 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
+ * Renders `$having`. A key that names an aggregate alias (`$as`, else
343
+ * `fn_field`) renders the aggregate expression itself — `SUM("amount") > ?`
344
+ * — because PostgreSQL does not allow a SELECT alias in HAVING (MySQL and
345
+ * SQLite tolerate it, so the expression form keeps all three identical).
346
+ * Other keys (grouped columns) render as plain columns.
347
+ */
348
+ function buildHaving(dialect, controls) {
349
+ const having = controls.$having;
350
+ const aggregates = controls.$select?.aggregates;
351
+ if (!aggregates?.length) return buildWhere(dialect, having);
352
+ const exprByAlias = /* @__PURE__ */ new Map();
353
+ for (const expr of aggregates) exprByAlias.set((0, _atscript_db_agg.resolveAlias)(expr), aggFnSql(dialect, expr));
354
+ return (0, _uniqu_core.walkFilter)(having, createFilterVisitor(dialect, { columnRef: (field) => exprByAlias.get(field) ?? dialect.quoteIdentifier(field) })) ?? EMPTY_AND;
337
355
  }
338
356
  /**
339
357
  * Builds a SELECT ... GROUP BY statement with aggregate functions.
@@ -352,7 +370,7 @@ function buildAggregateSelect(dialect, table, where, controls) {
352
370
  sql += ` GROUP BY ${groupCols}`;
353
371
  }
354
372
  if (controls.$having) {
355
- const havingFragment = buildWhere(dialect, controls.$having);
373
+ const havingFragment = buildHaving(dialect, controls);
356
374
  if (havingFragment.sql !== EMPTY_AND.sql) {
357
375
  sql += ` HAVING ${havingFragment.sql}`;
358
376
  params.push(...havingFragment.params);
@@ -433,8 +451,53 @@ function buildSelect(dialect, table, where, controls) {
433
451
  });
434
452
  }
435
453
  /**
454
+ * Marker value for {@link buildUpdate}: the column is assigned its DDL
455
+ * `DEFAULT` (`SET "col" = DEFAULT`, no bound parameter). Produced by
456
+ * {@link fillReplacePayload} for columns whose function default the engine
457
+ * owns; never appears in a patch.
458
+ */
459
+ const SQL_DEFAULT = Symbol("SQL_DEFAULT");
460
+ /**
461
+ * The columns a full replace assigns on a SQL adapter: every non-ignored
462
+ * descriptor (the same set `CREATE TABLE` emits) except the primary key —
463
+ * the row is matched by the filter, and an omitted PK must never be nulled
464
+ * or re-defaulted. Static value defaults are filled SDK-side before the
465
+ * adapter sees the row, so only native function defaults are flagged.
466
+ */
467
+ function replaceColumnsFor(fields, nativeFns) {
468
+ const out = [];
469
+ for (const fd of fields) {
470
+ if (fd.ignored || fd.isPrimaryKey) continue;
471
+ const def = fd.defaultValue;
472
+ out.push({
473
+ name: fd.physicalName,
474
+ useDefault: def?.kind === "fn" && nativeFns.has(def.fn)
475
+ });
476
+ }
477
+ return out;
478
+ }
479
+ /**
480
+ * Turns a (physical-name) replace payload into a FULL row assignment: every
481
+ * column in `columns` the payload omits becomes `null` — or {@link SQL_DEFAULT}
482
+ * when the engine owns its function default — so an UPDATE-based replace never
483
+ * retains a value the caller left out. This is the SQL counterpart of the
484
+ * whole-document replace the memory and MongoDB adapters do natively. The
485
+ * version column is excluded (`buildUpdate` appends the OCC bump itself).
486
+ * Returns a new object; `data` is not mutated.
487
+ */
488
+ function fillReplacePayload(data, columns, versionColumn) {
489
+ const full = { ...data };
490
+ for (const col of columns) {
491
+ if (col.name === versionColumn || col.name in full) continue;
492
+ full[col.name] = col.useDefault ? SQL_DEFAULT : null;
493
+ }
494
+ return full;
495
+ }
496
+ /**
436
497
  * Builds an UPDATE ... SET ... WHERE statement with optional LIMIT.
437
498
  *
499
+ * A value of {@link SQL_DEFAULT} renders as `<col> = DEFAULT` (full replace).
500
+ *
438
501
  * Optimistic concurrency control (OCC) hooks:
439
502
  * - `versionColumn` — when supplied, the builder appends
440
503
  * `<col> = <col> + 1` to the SET list. The bump is **mandatory** whenever
@@ -450,6 +513,10 @@ function buildUpdate(dialect, table, data, where, limit, ops, versionColumn, exp
450
513
  const setClauses = [];
451
514
  const params = [];
452
515
  for (const [key, value] of Object.entries(data)) {
516
+ if (value === SQL_DEFAULT) {
517
+ setClauses.push(`${dialect.quoteIdentifier(key)} = DEFAULT`);
518
+ continue;
519
+ }
453
520
  setClauses.push(`${dialect.quoteIdentifier(key)} = ?`);
454
521
  params.push(dialect.toValue(value));
455
522
  }
@@ -583,6 +650,7 @@ exports.AGG_FN_SQL = AGG_FN_SQL;
583
650
  exports.EMPTY_AND = EMPTY_AND;
584
651
  exports.EMPTY_OR = EMPTY_OR;
585
652
  exports.GEO_DISTANCE_ALIAS = GEO_DISTANCE_ALIAS;
653
+ exports.SQL_DEFAULT = SQL_DEFAULT;
586
654
  exports.buildAggregateCount = buildAggregateCount;
587
655
  exports.buildAggregateSelect = buildAggregateSelect;
588
656
  exports.buildCreateView = buildCreateView;
@@ -597,6 +665,7 @@ exports.buildWhere = buildWhere;
597
665
  exports.createFilterVisitor = createFilterVisitor;
598
666
  exports.defaultValueForType = defaultValueForType;
599
667
  exports.defaultValueToSqlLiteral = defaultValueToSqlLiteral;
668
+ exports.fillReplacePayload = fillReplacePayload;
600
669
  exports.finalizeParams = finalizeParams;
601
670
  exports.geoWindowFromControls = geoWindowFromControls;
602
671
  exports.normalizeGeoPointValue = normalizeGeoPointValue;
@@ -605,5 +674,6 @@ exports.queryNodeToSql = queryNodeToSql;
605
674
  exports.queryOpToSql = queryOpToSql;
606
675
  exports.refActionToSql = refActionToSql;
607
676
  exports.renameGeoDistance = renameGeoDistance;
677
+ exports.replaceColumnsFor = replaceColumnsFor;
608
678
  exports.sqlStringLiteral = sqlStringLiteral;
609
679
  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
@@ -186,4 +233,4 @@ declare function parseRegexString(value: unknown): {
186
233
  flags: string;
187
234
  };
188
235
  //#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 };
236
+ 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
@@ -186,4 +233,4 @@ declare function parseRegexString(value: unknown): {
186
233
  flags: string;
187
234
  };
188
235
  //#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 };
236
+ 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,27 @@ 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
+ * Renders `$having`. A key that names an aggregate alias (`$as`, else
342
+ * `fn_field`) renders the aggregate expression itself — `SUM("amount") > ?`
343
+ * — because PostgreSQL does not allow a SELECT alias in HAVING (MySQL and
344
+ * SQLite tolerate it, so the expression form keeps all three identical).
345
+ * Other keys (grouped columns) render as plain columns.
346
+ */
347
+ function buildHaving(dialect, controls) {
348
+ const having = controls.$having;
349
+ const aggregates = controls.$select?.aggregates;
350
+ if (!aggregates?.length) return buildWhere(dialect, having);
351
+ const exprByAlias = /* @__PURE__ */ new Map();
352
+ for (const expr of aggregates) exprByAlias.set(resolveAlias(expr), aggFnSql(dialect, expr));
353
+ return walkFilter(having, createFilterVisitor(dialect, { columnRef: (field) => exprByAlias.get(field) ?? dialect.quoteIdentifier(field) })) ?? EMPTY_AND;
336
354
  }
337
355
  /**
338
356
  * Builds a SELECT ... GROUP BY statement with aggregate functions.
@@ -351,7 +369,7 @@ function buildAggregateSelect(dialect, table, where, controls) {
351
369
  sql += ` GROUP BY ${groupCols}`;
352
370
  }
353
371
  if (controls.$having) {
354
- const havingFragment = buildWhere(dialect, controls.$having);
372
+ const havingFragment = buildHaving(dialect, controls);
355
373
  if (havingFragment.sql !== EMPTY_AND.sql) {
356
374
  sql += ` HAVING ${havingFragment.sql}`;
357
375
  params.push(...havingFragment.params);
@@ -432,8 +450,53 @@ function buildSelect(dialect, table, where, controls) {
432
450
  });
433
451
  }
434
452
  /**
453
+ * Marker value for {@link buildUpdate}: the column is assigned its DDL
454
+ * `DEFAULT` (`SET "col" = DEFAULT`, no bound parameter). Produced by
455
+ * {@link fillReplacePayload} for columns whose function default the engine
456
+ * owns; never appears in a patch.
457
+ */
458
+ const SQL_DEFAULT = Symbol("SQL_DEFAULT");
459
+ /**
460
+ * The columns a full replace assigns on a SQL adapter: every non-ignored
461
+ * descriptor (the same set `CREATE TABLE` emits) except the primary key —
462
+ * the row is matched by the filter, and an omitted PK must never be nulled
463
+ * or re-defaulted. Static value defaults are filled SDK-side before the
464
+ * adapter sees the row, so only native function defaults are flagged.
465
+ */
466
+ function replaceColumnsFor(fields, nativeFns) {
467
+ const out = [];
468
+ for (const fd of fields) {
469
+ if (fd.ignored || fd.isPrimaryKey) continue;
470
+ const def = fd.defaultValue;
471
+ out.push({
472
+ name: fd.physicalName,
473
+ useDefault: def?.kind === "fn" && nativeFns.has(def.fn)
474
+ });
475
+ }
476
+ return out;
477
+ }
478
+ /**
479
+ * Turns a (physical-name) replace payload into a FULL row assignment: every
480
+ * column in `columns` the payload omits becomes `null` — or {@link SQL_DEFAULT}
481
+ * when the engine owns its function default — so an UPDATE-based replace never
482
+ * retains a value the caller left out. This is the SQL counterpart of the
483
+ * whole-document replace the memory and MongoDB adapters do natively. The
484
+ * version column is excluded (`buildUpdate` appends the OCC bump itself).
485
+ * Returns a new object; `data` is not mutated.
486
+ */
487
+ function fillReplacePayload(data, columns, versionColumn) {
488
+ const full = { ...data };
489
+ for (const col of columns) {
490
+ if (col.name === versionColumn || col.name in full) continue;
491
+ full[col.name] = col.useDefault ? SQL_DEFAULT : null;
492
+ }
493
+ return full;
494
+ }
495
+ /**
435
496
  * Builds an UPDATE ... SET ... WHERE statement with optional LIMIT.
436
497
  *
498
+ * A value of {@link SQL_DEFAULT} renders as `<col> = DEFAULT` (full replace).
499
+ *
437
500
  * Optimistic concurrency control (OCC) hooks:
438
501
  * - `versionColumn` — when supplied, the builder appends
439
502
  * `<col> = <col> + 1` to the SET list. The bump is **mandatory** whenever
@@ -449,6 +512,10 @@ function buildUpdate(dialect, table, data, where, limit, ops, versionColumn, exp
449
512
  const setClauses = [];
450
513
  const params = [];
451
514
  for (const [key, value] of Object.entries(data)) {
515
+ if (value === SQL_DEFAULT) {
516
+ setClauses.push(`${dialect.quoteIdentifier(key)} = DEFAULT`);
517
+ continue;
518
+ }
452
519
  setClauses.push(`${dialect.quoteIdentifier(key)} = ?`);
453
520
  params.push(dialect.toValue(value));
454
521
  }
@@ -578,4 +645,4 @@ function parseRegexString(value) {
578
645
  };
579
646
  }
580
647
  //#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 };
648
+ 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.128",
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.128"
46
46
  },
47
47
  "scripts": {
48
48
  "build": "vp pack",