@atscript/db-sql-tools 0.1.152 → 0.1.154

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
@@ -157,15 +157,53 @@ async function mapQueryErrors(dialect, fn, controls) {
157
157
  }
158
158
  }
159
159
  /**
160
- * One `ORDER BY` key with the uniform "NULL is the smallest value" ordering:
161
- * `<expr> ASC` / `<expr> DESC`, plus `NULLS FIRST` / `NULLS LAST` on a
162
- * dialect where NULL sorts largest ({@link SqlDialect.nullsSortLargest}).
163
- * Shared by first-row joins and `first` / `last` aggregates.
164
- * @since 0.1.148
160
+ * The `$nulls` entry of one `ORDER BY` key (`controls.$nulls` is keyed like
161
+ * `controls.$sort`), or `undefined`.
162
+ * @since 0.1.153
163
+ */
164
+ function nullsPlacementOf(nulls, key) {
165
+ if (!nulls) return void 0;
166
+ const map = nulls;
167
+ return Object.hasOwn(map, key) ? map[key] : void 0;
168
+ }
169
+ /**
170
+ * One `ORDER BY` key with a requested NULL placement. Without `nulls`, the
171
+ * plain `<expr> ASC|DESC` (the engine's native placement). With it:
172
+ *
173
+ * - the engine's native placement already matches (NULL is the smallest
174
+ * value — `ASC` → first, `DESC` → last — unless
175
+ * {@link SqlDialect.nullsSortLargest}, where it is the opposite) → the plain
176
+ * key, so an index on `expr` still serves the order;
177
+ * - else `<expr> ASC|DESC NULLS FIRST|LAST` on a dialect with
178
+ * {@link SqlDialect.nullsPlacementSyntax} (or `nullsSortLargest`);
179
+ * - else (MySQL) a leading NULL-ness key: `(<expr> IS NULL) DESC, <expr> DIR`
180
+ * for `first`, `(<expr> IS NULL) ASC, <expr> DIR` for `last`.
181
+ *
182
+ * May render two comma-separated keys — only valid inside an `ORDER BY` list.
183
+ * `nullTest` renders the `IS NULL` operand when it must differ from `expr`
184
+ * (MySQL resolves a name inside an expression to a table column before a
185
+ * SELECT alias, so a grouped `ORDER BY` tests the alias's own expression).
186
+ * @since 0.1.153
187
+ */
188
+ function nullsOrderSql(dialect, expr, desc, nulls, nullTest) {
189
+ const key = `${expr} ${desc ? "DESC" : "ASC"}`;
190
+ if (!nulls) return key;
191
+ const nativeFirst = desc === (dialect.nullsSortLargest === true);
192
+ if (nulls === "first" === nativeFirst) return key;
193
+ if (dialect.nullsPlacementSyntax || dialect.nullsSortLargest) return `${key} NULLS ${nulls === "first" ? "FIRST" : "LAST"}`;
194
+ return `(${nullTest ? nullTest() : expr} IS NULL) ${nulls === "first" ? "DESC" : "ASC"}, ${key}`;
195
+ }
196
+ /**
197
+ * One `ORDER BY` key with the uniform "NULL is the smallest value" ordering
198
+ * (`ASC` → NULL first, `DESC` → NULL last) unless `nulls` asks for another
199
+ * placement — {@link nullsOrderSql}. Without `nulls`: `<expr> ASC` /
200
+ * `<expr> DESC`, plus `NULLS FIRST` / `NULLS LAST` on a dialect where NULL
201
+ * sorts largest ({@link SqlDialect.nullsSortLargest}). Shared by first-row
202
+ * joins, `first` / `last` aggregates and the grouped `ORDER BY`.
203
+ * @since 0.1.148 (`nulls` since 0.1.153)
165
204
  */
166
- function orderKeySql(dialect, expr, desc) {
167
- const nulls = dialect.nullsSortLargest ? desc ? " NULLS LAST" : " NULLS FIRST" : "";
168
- return `${expr} ${desc ? "DESC" : "ASC"}${nulls}`;
205
+ function orderKeySql(dialect, expr, desc, nulls, nullTest) {
206
+ return nullsOrderSql(dialect, expr, desc, nulls ?? (desc ? "last" : "first"), nullTest);
169
207
  }
170
208
  /**
171
209
  * Each JSON path segment wrapped in double quotes (`"a"`), for the dialects'
@@ -468,6 +506,16 @@ function buildWhere(dialect, filter, opts) {
468
506
  }) : getVisitor(dialect)) ?? EMPTY_AND;
469
507
  }
470
508
  //#endregion
509
+ //#region src/from-source.ts
510
+ /** The FROM text of `source`: the quoted table name, or the derived table. */
511
+ function fromSourceSql(dialect, source) {
512
+ return typeof source === "string" ? dialect.quoteTable(source) : source.sql;
513
+ }
514
+ /** The hint the SELECT directly reading `source` carries (`""` for a table). */
515
+ function fromSourceHint(source) {
516
+ return typeof source === "string" ? "" : source.hint;
517
+ }
518
+ //#endregion
471
519
  //#region src/common.ts
472
520
  /** Formats a string value as a SQL literal with single-quote escaping. */
473
521
  function sqlStringLiteral(value) {
@@ -815,17 +863,24 @@ function rowColumns(controls) {
815
863
  * WHERE moves inside unchanged, so the bind parameters keep their order.
816
864
  */
817
865
  function aggSource(dialect, table, where, controls, withRows) {
818
- const quotedTable = dialect.quoteTable(table);
866
+ const quotedTable = fromSourceSql(dialect, table);
819
867
  const firstLast = controls.$select?.firstLast;
820
868
  const rowOrder = controls.$select?.rowOrder;
821
- if (!withRows || !firstLast?.length || !rowOrder?.length) return `FROM ${quotedTable} WHERE ${where.sql}`;
869
+ if (!withRows || !firstLast?.length || !rowOrder?.length) return {
870
+ hint: fromSourceHint(table),
871
+ sql: `FROM ${quotedTable} WHERE ${where.sql}`
872
+ };
822
873
  const groupBy = controls.$groupBy;
823
874
  const partition = groupBy?.length ? `PARTITION BY ${groupBy.map((key) => groupKeySql(dialect, controls, key)).join(", ")} ` : "";
824
- const order = (reverse) => rowOrder.map((k) => orderKeySql(dialect, dialect.quoteIdentifier(k.column), k.desc !== reverse)).join(", ");
825
- return `FROM (SELECT ${[...rowColumns(controls).map((column) => dialect.quoteIdentifier(column)), ...firstLast.map((fl, i) => {
875
+ const order = (reverse) => rowOrder.map((k) => orderKeySql(dialect, dialect.quoteIdentifier(k.column), k.desc !== reverse, k.nulls && reverse ? k.nulls === "first" ? "last" : "first" : k.nulls)).join(", ");
876
+ const columns = [...rowColumns(controls).map((column) => dialect.quoteIdentifier(column)), ...firstLast.map((fl, i) => {
826
877
  const col = dialect.quoteIdentifier(firstLastColumn(i));
827
878
  return `FIRST_VALUE(${dialect.quoteIdentifier(fl.column)}) OVER (${partition}ORDER BY ${order(fl.fn === "last")}) AS ${col}`;
828
- })].join(", ")} FROM ${quotedTable} WHERE ${where.sql}) AS ${dialect.quoteIdentifier(ROWS_ALIAS)}`;
879
+ })];
880
+ return {
881
+ hint: "",
882
+ sql: `FROM (SELECT ${fromSourceHint(table)}${columns.join(", ")} FROM ${quotedTable} WHERE ${where.sql}) AS ${dialect.quoteIdentifier(ROWS_ALIAS)}`
883
+ };
829
884
  }
830
885
  /**
831
886
  * Builds a SELECT ... GROUP BY statement with aggregate functions.
@@ -847,7 +902,9 @@ function buildAggregateSelect(dialect, table, where, controls) {
847
902
  aliasSql = aliasSqlMap(dialect, controls);
848
903
  for (const alias of select.computedAliases) selectParts.push(`${aliasSql.get(alias)} AS ${dialect.quoteIdentifier(alias)}`);
849
904
  } else for (const expr of select?.aggregates ?? []) selectParts.push(`${aggFnSql(dialect, select, expr)} AS ${dialect.quoteIdentifier((0, _atscript_db_agg.resolveAlias)(expr))}`);
850
- let sql = `SELECT ${selectParts.length > 0 ? selectParts.join(", ") : "*"} ${aggSource(dialect, table, where, controls, true)}${groupByClause(dialect, controls)}`;
905
+ const cols = selectParts.length > 0 ? selectParts.join(", ") : "*";
906
+ const source = aggSource(dialect, table, where, controls, true);
907
+ let sql = `SELECT ${source.hint}${cols} ${source.sql}${groupByClause(dialect, controls)}`;
851
908
  const params = [...where.params];
852
909
  const having = havingClause(dialect, controls, aliasSql);
853
910
  if (having) {
@@ -855,8 +912,12 @@ function buildAggregateSelect(dialect, table, where, controls) {
855
912
  params.push(...having.params);
856
913
  }
857
914
  if (controls.$sort) {
915
+ const nullTest = (col) => () => {
916
+ aliasSql ??= aliasSqlMap(dialect, controls);
917
+ return aliasSql.get(col) ?? dialect.quoteIdentifier(col);
918
+ };
858
919
  const orderParts = [];
859
- for (const [col, dir] of Object.entries(controls.$sort)) orderParts.push(orderKeySql(dialect, dialect.quoteIdentifier(col), dir === -1));
920
+ for (const [col, dir] of Object.entries(controls.$sort)) orderParts.push(orderKeySql(dialect, dialect.quoteIdentifier(col), dir === -1, nullsPlacementOf(controls.$nulls, col), nullTest(col)));
860
921
  if (orderParts.length > 0) sql += ` ORDER BY ${orderParts.join(", ")}`;
861
922
  }
862
923
  if (controls.$limit !== void 0) {
@@ -891,7 +952,7 @@ function buildAggregateCount(dialect, table, where, controls) {
891
952
  params: []
892
953
  });
893
954
  return finalizeParams(dialect, {
894
- sql: `SELECT ${countCol} FROM ${dialect.quoteTable(table)} WHERE ${where.sql}`,
955
+ sql: `SELECT ${fromSourceHint(table)}${countCol} FROM ${fromSourceSql(dialect, table)} WHERE ${where.sql}`,
895
956
  params: where.params
896
957
  });
897
958
  }
@@ -900,7 +961,7 @@ function buildAggregateCount(dialect, table, where, controls) {
900
961
  let inner = "COUNT(*)";
901
962
  if (groupBy) inner = bucketSelectParts(dialect, controls).join(", ") || "1";
902
963
  return finalizeParams(dialect, {
903
- sql: `SELECT ${countCol} FROM (SELECT ${inner} ${from}${groupBy}${having?.sql ?? ""}) AS ${dialect.quoteIdentifier("_groups")}`,
964
+ sql: `SELECT ${countCol} FROM (SELECT ${from.hint}${inner} ${from.sql}${groupBy}${having?.sql ?? ""}) AS ${dialect.quoteIdentifier("_groups")}`,
904
965
  params: [...where.params, ...having?.params ?? []]
905
966
  });
906
967
  }
@@ -1002,7 +1063,17 @@ function firstRowOn(dialect, join, target, condition, resolveFieldRef) {
1002
1063
  return `${key} = (SELECT ${key} FROM ${target} WHERE ${condition} ORDER BY ${first.order.map(({ ref, desc }) => orderKeySql(dialect, resolveFieldRef(ref), desc)).join(", ")} LIMIT 1)`;
1003
1064
  }
1004
1065
  /**
1005
- * Builds a CREATE VIEW statement from a view plan and column mappings.
1066
+ * Builds a CREATE VIEW statement from a view plan and column mappings:
1067
+ * `<dialect.createViewPrefix> <view> AS <buildViewSelect(…)>`.
1068
+ */
1069
+ function buildCreateView(dialect, viewName, plan, columns, resolveFieldRef) {
1070
+ return `${dialect.createViewPrefix} ${dialect.quoteTable(viewName)} AS ${buildViewSelect(dialect, plan, columns, resolveFieldRef)}`;
1071
+ }
1072
+ /**
1073
+ * The SELECT of a managed view — the body of its CREATE VIEW, and (since
1074
+ * 0.1.153) the inline definition a pruned view read selects from (with a
1075
+ * `AtscriptDbView.readPlan` variant's plan and columns), so the two can never
1076
+ * drift. Parameter-free: predicate literals are inlined.
1006
1077
  *
1007
1078
  * Joins render in declaration order — `JOIN` (inner, the default) or
1008
1079
  * `LEFT JOIN` for `kind: "left"`; a `@db.alias` target renders as
@@ -1011,15 +1082,15 @@ function firstRowOn(dialect, join, target, condition, resolveFieldRef) {
1011
1082
  * name for an aliased join); `resolveFieldRef` renders predicate refs (join
1012
1083
  * ON, WHERE, HAVING fallbacks) as `"table"."column"`. The entry table and a
1013
1084
  * join target may be views.
1085
+ * @since 0.1.153
1014
1086
  */
1015
- function buildCreateView(dialect, viewName, plan, columns, resolveFieldRef) {
1087
+ function buildViewSelect(dialect, plan, columns, resolveFieldRef) {
1016
1088
  const columnByPath = new Map(columns.map((c) => [c.viewPath, c]));
1017
1089
  const hasAggregates = columns.some((c) => c.aggFn);
1018
1090
  const computedSql = /* @__PURE__ */ new Map();
1019
1091
  /** A column's SELECT expression: computed, aggregate or plain source. */
1020
1092
  const columnExpr = (c) => c.expr !== void 0 ? viewComputeExpr(dialect, c, columnByPath, resolveFieldRef, hasAggregates, computedSql) : c.aggFn ? viewAggExpr(dialect, c, resolveFieldRef) : viewSourceExpr(dialect, c);
1021
- const selectCols = columns.map((c) => `${columnExpr(c)} AS ${dialect.quoteIdentifier(c.viewColumn)}`).join(", ");
1022
- let sql = `${dialect.createViewPrefix} ${dialect.quoteTable(viewName)} AS SELECT ${selectCols} FROM ${dialect.quoteIdentifier(plan.entryTable)}`;
1093
+ let sql = `SELECT ${columns.map((c) => `${columnExpr(c)} AS ${dialect.quoteIdentifier(c.viewColumn)}`).join(", ")} FROM ${dialect.quoteIdentifier(plan.entryTable)}`;
1023
1094
  for (const join of plan.joins) {
1024
1095
  const condition = queryNodeToSql(join.condition, resolveFieldRef);
1025
1096
  const keyword = join.kind === "left" ? "LEFT JOIN" : "JOIN";
@@ -1172,11 +1243,13 @@ function buildInsertMany(dialect, table, rows, columns = insertManyColumns(rows)
1172
1243
  }
1173
1244
  /**
1174
1245
  * Builds a SELECT statement with optional sort, limit, offset, projection.
1246
+ * `table` may be a derived source (a pruned view read, since 0.1.153).
1175
1247
  */
1176
1248
  function buildSelect(dialect, table, where, controls) {
1177
- let sql = `SELECT ${buildProjection(dialect, controls?.$select)} FROM ${dialect.quoteTable(table)} WHERE ${where.sql}`;
1249
+ const cols = buildProjection(dialect, controls?.$select);
1250
+ let sql = `SELECT ${fromSourceHint(table)}${cols} FROM ${fromSourceSql(dialect, table)} WHERE ${where.sql}`;
1178
1251
  const params = [...where.params];
1179
- const orderBy = orderByList(dialect, controls?.$sort);
1252
+ const orderBy = orderByList(dialect, controls?.$sort, controls?.$nulls);
1180
1253
  if (orderBy) sql += ` ORDER BY ${orderBy}`;
1181
1254
  if (controls?.$limit !== void 0) {
1182
1255
  sql += ` LIMIT ?`;
@@ -1192,11 +1265,17 @@ function buildSelect(dialect, table, where, controls) {
1192
1265
  params
1193
1266
  });
1194
1267
  }
1195
- /** `"col" ASC, "other" DESC` of a physical `$sort` — `""` when it orders nothing. */
1196
- function orderByList(dialect, sort) {
1268
+ /**
1269
+ * `"col" ASC, "other" DESC` of a physical `$sort` — `""` when it orders
1270
+ * nothing. A key with a `$nulls` entry places NULL as asked
1271
+ * ({@link nullsOrderSql}); `prefix` is prepended verbatim to each quoted
1272
+ * column (`t.` → `t."col"`).
1273
+ * @since 0.1.153 (exported)
1274
+ */
1275
+ function orderByList(dialect, sort, nulls, prefix = "") {
1197
1276
  if (!sort) return "";
1198
1277
  const parts = [];
1199
- for (const [col, dir] of Object.entries(sort)) parts.push(`${dialect.quoteIdentifier(col)} ${dir === -1 ? "DESC" : "ASC"}`);
1278
+ for (const [col, dir] of Object.entries(sort)) parts.push(nullsOrderSql(dialect, prefix + dialect.quoteIdentifier(col), dir === -1, nullsPlacementOf(nulls, col)));
1200
1279
  return parts.join(", ");
1201
1280
  }
1202
1281
  /**
@@ -1217,9 +1296,9 @@ const PARTITION_ROW_NUMBER_ALIAS = "__atscript_rn";
1217
1296
  */
1218
1297
  function buildPartitionedSelect(dialect, table, where, controls, partitionBy) {
1219
1298
  const rn = dialect.quoteIdentifier(PARTITION_ROW_NUMBER_ALIAS);
1220
- const orderBy = orderByList(dialect, controls.$sort);
1299
+ const orderBy = orderByList(dialect, controls.$sort, controls.$nulls);
1221
1300
  const window = `PARTITION BY ${partitionBy.map((col) => dialect.quoteIdentifier(col)).join(", ")}` + (orderBy ? ` ORDER BY ${orderBy}` : "");
1222
- const inner = `SELECT ${buildProjection(dialect, controls.$select)}, ROW_NUMBER() OVER (${window}) AS ${rn} FROM ${dialect.quoteTable(table)} WHERE ${where.sql}`;
1301
+ const inner = `SELECT ${fromSourceHint(table)}${buildProjection(dialect, controls.$select)}, ROW_NUMBER() OVER (${window}) AS ${rn} FROM ${fromSourceSql(dialect, table)} WHERE ${where.sql}`;
1223
1302
  const skip = controls.$skip ?? 0;
1224
1303
  const limit = controls.$limit;
1225
1304
  const params = [...where.params, skip];
@@ -1626,6 +1705,49 @@ function parseRegexString(value) {
1626
1705
  };
1627
1706
  }
1628
1707
  //#endregion
1708
+ //#region src/view-read.ts
1709
+ /** Rendered pruned definitions per read variant (variants are memoised by their view). */
1710
+ const rendered = /* @__PURE__ */ new WeakMap();
1711
+ /**
1712
+ * The FROM source of a read of `readable` (the adapter's registered table or
1713
+ * view): `tableName` unchanged, except for a managed view whose read does
1714
+ * not need some of its LEFT joins (`AtscriptDbView.readPlan` over the
1715
+ * columns `query` reads — {@link queryReadColumns}): then the view's own
1716
+ * definition without those joins ({@link buildViewSelect}), aliased as the
1717
+ * bare view name so every column reference — and the correlation of a
1718
+ * relational predicate — stays valid. A column of a dropped join is absent
1719
+ * from the derived table, so a reference the collector missed fails with
1720
+ * "unknown column" rather than reading wrong data.
1721
+ *
1722
+ * Never pruned: a table, an external view, a view read under another name
1723
+ * than its own (`tableName` schema-qualified — its definition's unqualified
1724
+ * names resolve against that schema, the derived table's against the
1725
+ * connection's — or renamed by an adapter subclass), or anything `readPlan`
1726
+ * keeps whole.
1727
+ * @since 0.1.153
1728
+ */
1729
+ function viewReadSource(dialect, readable, tableName, query, kind, partitionBy) {
1730
+ if (!readable.isView || tableName !== readable.tableName) return tableName;
1731
+ const view = readable;
1732
+ const variant = view.readPlan((0, _atscript_db.queryReadColumns)(query, kind, partitionBy));
1733
+ if (!variant) return tableName;
1734
+ let byDialect = rendered.get(variant);
1735
+ if (!byDialect) {
1736
+ byDialect = /* @__PURE__ */ new Map();
1737
+ rendered.set(variant, byDialect);
1738
+ }
1739
+ let source = byDialect.get(dialect);
1740
+ if (!source) {
1741
+ const qi = (name) => dialect.quoteIdentifier(name);
1742
+ source = {
1743
+ sql: `(${buildViewSelect(dialect, variant.plan, variant.columns, (ref) => view.resolveFieldRef(ref, qi))}) AS ${qi(readable.tableName)}`,
1744
+ hint: dialect.derivedMergeHint ? `${dialect.derivedMergeHint(readable.tableName)} ` : ""
1745
+ };
1746
+ byDialect.set(dialect, source);
1747
+ }
1748
+ return source;
1749
+ }
1750
+ //#endregion
1629
1751
  exports.AGG_FN_SQL = AGG_FN_SQL;
1630
1752
  exports.EMPTY_AND = EMPTY_AND;
1631
1753
  exports.EMPTY_OR = EMPTY_OR;
@@ -1650,6 +1772,7 @@ exports.buildSelect = buildSelect;
1650
1772
  exports.buildUpdate = buildUpdate;
1651
1773
  exports.buildVectorSearchCount = buildVectorSearchCount;
1652
1774
  exports.buildVectorSearchSelect = buildVectorSearchSelect;
1775
+ exports.buildViewSelect = buildViewSelect;
1653
1776
  exports.buildWhere = buildWhere;
1654
1777
  exports.chunkInsertRows = chunkInsertRows;
1655
1778
  exports.createFilterVisitor = createFilterVisitor;
@@ -1659,14 +1782,19 @@ exports.derivedColumnExpr = derivedColumnExpr;
1659
1782
  exports.fillReplacePayload = fillReplacePayload;
1660
1783
  exports.finalizeParams = finalizeParams;
1661
1784
  exports.foreignKeySql = foreignKeySql;
1785
+ exports.fromSourceHint = fromSourceHint;
1786
+ exports.fromSourceSql = fromSourceSql;
1662
1787
  exports.geoWindowFromControls = geoWindowFromControls;
1663
1788
  exports.groupKeySql = groupKeySql;
1664
1789
  exports.insertManyColumns = insertManyColumns;
1665
1790
  exports.jsonDollarPath = jsonDollarPath;
1666
1791
  exports.mapQueryErrors = mapQueryErrors;
1667
1792
  exports.normalizeGeoPointValue = normalizeGeoPointValue;
1793
+ exports.nullsOrderSql = nullsOrderSql;
1794
+ exports.nullsPlacementOf = nullsPlacementOf;
1668
1795
  exports.numericOutOfRangeError = numericOutOfRangeError;
1669
1796
  exports.orFragment = orFragment;
1797
+ exports.orderByList = orderByList;
1670
1798
  exports.orderKeySql = orderKeySql;
1671
1799
  exports.parseRegexString = parseRegexString;
1672
1800
  exports.queryNodeToSql = queryNodeToSql;
@@ -1681,3 +1809,4 @@ exports.sqlTimeZoneLiteral = sqlTimeZoneLiteral;
1681
1809
  exports.stripPartitionRowNumber = stripPartitionRowNumber;
1682
1810
  exports.toSqlValue = toSqlValue;
1683
1811
  exports.vectorDistanceSource = vectorDistanceSource;
1812
+ exports.viewReadSource = viewReadSource;
package/dist/index.d.cts CHANGED
@@ -1,5 +1,5 @@
1
- import { AtscriptExprNode, AtscriptQueryFieldRef, AtscriptQueryNode, DbControls, DbError, TDbDefaultFn, TDbFieldMeta, TDbForeignKey, TDbReferentialAction, TFieldOps, TResolvedBucket, TViewColumnMapping, TViewJsonType, TViewPlan, UniquSelect } from "@atscript/db";
2
- import { FilterExpr, FilterVisitor } from "@uniqu/core";
1
+ import { AtscriptExprNode, AtscriptQueryFieldRef, AtscriptQueryNode, DbControls, DbError, DbQuery, TDbDefaultFn, TDbFieldMeta, TDbForeignKey, TDbReferentialAction, TFieldOps, TReadColumnsKind, TResolvedBucket, TViewColumnMapping, TViewJsonType, TViewPlan, UniquSelect } from "@atscript/db";
2
+ import { FilterExpr, FilterVisitor, NullsPlacement } from "@uniqu/core";
3
3
  import { TDbAggregateFn } from "@atscript/db/agg";
4
4
 
5
5
  //#region src/dialect.d.ts
@@ -19,6 +19,14 @@ interface SqlDialect {
19
19
  quoteTable(name: string): string;
20
20
  /** SQL literal for unlimited LIMIT (SQLite: '-1', MySQL: '18446744073709551615') */
21
21
  unlimitedLimit: string;
22
+ /**
23
+ * The optimizer hint asking the engine to MERGE the derived table `alias`
24
+ * into the SELECT reading it (MySQL: `/*+ MERGE(\`alias\`) *\/`) — carried
25
+ * by pruned view reads (`viewReadSource`), so the inline definition merges
26
+ * even in a session with `derived_merge=off`. Omitted: no hint.
27
+ * @since 0.1.153
28
+ */
29
+ derivedMergeHint?(alias: string): string;
22
30
  /** Convert JS value to SQL-bindable param for DML */
23
31
  toValue(value: unknown): unknown;
24
32
  /** Convert JS value to SQL-bindable param for filters (lighter) */
@@ -142,6 +150,14 @@ interface SqlDialect {
142
150
  * @since 0.1.147
143
151
  */
144
152
  nullsSortLargest?: boolean;
153
+ /**
154
+ * `true` when the engine accepts `NULLS FIRST` / `NULLS LAST` on an
155
+ * `ORDER BY` key (PostgreSQL, SQLite ≥ 3.30). Without it a requested NULL
156
+ * placement that differs from the native one renders as an extra
157
+ * `(<expr> IS NULL)` key ({@link nullsOrderSql}).
158
+ * @since 0.1.153
159
+ */
160
+ nullsPlacementSyntax?: boolean;
145
161
  /** e.g. 'CREATE VIEW IF NOT EXISTS' or 'CREATE OR REPLACE VIEW' */
146
162
  createViewPrefix: string;
147
163
  /** Returns a parameter placeholder for the given 1-based index. When absent, '?' is used. */
@@ -169,13 +185,41 @@ declare function finalizeParams(dialect: SqlDialect, fragment: TSqlFragment): TS
169
185
  */
170
186
  declare function mapQueryErrors<R>(dialect: SqlDialect, fn: () => Promise<R>, controls?: DbControls): Promise<R>;
171
187
  /**
172
- * One `ORDER BY` key with the uniform "NULL is the smallest value" ordering:
173
- * `<expr> ASC` / `<expr> DESC`, plus `NULLS FIRST` / `NULLS LAST` on a
174
- * dialect where NULL sorts largest ({@link SqlDialect.nullsSortLargest}).
175
- * Shared by first-row joins and `first` / `last` aggregates.
176
- * @since 0.1.148
188
+ * The `$nulls` entry of one `ORDER BY` key (`controls.$nulls` is keyed like
189
+ * `controls.$sort`), or `undefined`.
190
+ * @since 0.1.153
191
+ */
192
+ declare function nullsPlacementOf(nulls: DbControls["$nulls"], key: string): NullsPlacement | undefined;
193
+ /**
194
+ * One `ORDER BY` key with a requested NULL placement. Without `nulls`, the
195
+ * plain `<expr> ASC|DESC` (the engine's native placement). With it:
196
+ *
197
+ * - the engine's native placement already matches (NULL is the smallest
198
+ * value — `ASC` → first, `DESC` → last — unless
199
+ * {@link SqlDialect.nullsSortLargest}, where it is the opposite) → the plain
200
+ * key, so an index on `expr` still serves the order;
201
+ * - else `<expr> ASC|DESC NULLS FIRST|LAST` on a dialect with
202
+ * {@link SqlDialect.nullsPlacementSyntax} (or `nullsSortLargest`);
203
+ * - else (MySQL) a leading NULL-ness key: `(<expr> IS NULL) DESC, <expr> DIR`
204
+ * for `first`, `(<expr> IS NULL) ASC, <expr> DIR` for `last`.
205
+ *
206
+ * May render two comma-separated keys — only valid inside an `ORDER BY` list.
207
+ * `nullTest` renders the `IS NULL` operand when it must differ from `expr`
208
+ * (MySQL resolves a name inside an expression to a table column before a
209
+ * SELECT alias, so a grouped `ORDER BY` tests the alias's own expression).
210
+ * @since 0.1.153
211
+ */
212
+ declare function nullsOrderSql(dialect: SqlDialect, expr: string, desc: boolean, nulls?: NullsPlacement, nullTest?: () => string): string;
213
+ /**
214
+ * One `ORDER BY` key with the uniform "NULL is the smallest value" ordering
215
+ * (`ASC` → NULL first, `DESC` → NULL last) unless `nulls` asks for another
216
+ * placement — {@link nullsOrderSql}. Without `nulls`: `<expr> ASC` /
217
+ * `<expr> DESC`, plus `NULLS FIRST` / `NULLS LAST` on a dialect where NULL
218
+ * sorts largest ({@link SqlDialect.nullsSortLargest}). Shared by first-row
219
+ * joins, `first` / `last` aggregates and the grouped `ORDER BY`.
220
+ * @since 0.1.148 (`nulls` since 0.1.153)
177
221
  */
178
- declare function orderKeySql(dialect: SqlDialect, expr: string, desc: boolean): string;
222
+ declare function orderKeySql(dialect: SqlDialect, expr: string, desc: boolean, nulls?: NullsPlacement, nullTest?: () => string): string;
179
223
  /**
180
224
  * Each JSON path segment wrapped in double quotes (`"a"`), for the dialects'
181
225
  * {@link SqlDialect.jsonExtract} path literals (`'$."a"."b"'`, `'{"a","b"}'`).
@@ -394,9 +438,39 @@ declare function buildVectorSearchCount(dialect: SqlDialect, source: TSqlFragmen
394
438
  residual?: TSqlFragment;
395
439
  }): TSqlFragment;
396
440
  //#endregion
441
+ //#region src/from-source.d.ts
442
+ /**
443
+ * A pre-rendered derived table — `(<select>) AS "alias"` — read in place of
444
+ * a table name, plus the optimizer hint the SELECT that reads it carries
445
+ * (`/*+ MERGE(…) *\/ `, or `""`).
446
+ * @since 0.1.153
447
+ */
448
+ interface TSqlDerivedSource {
449
+ readonly sql: string;
450
+ readonly hint: string;
451
+ }
452
+ /**
453
+ * The FROM source of a read builder: a table / view name (quoted by the
454
+ * dialect) or a {@link TSqlDerivedSource}.
455
+ * @since 0.1.153
456
+ */
457
+ type TSqlFromSource = string | TSqlDerivedSource;
458
+ /** The FROM text of `source`: the quoted table name, or the derived table. */
459
+ declare function fromSourceSql(dialect: SqlDialect, source: TSqlFromSource): string;
460
+ /** The hint the SELECT directly reading `source` carries (`""` for a table). */
461
+ declare function fromSourceHint(source: TSqlFromSource): string;
462
+ //#endregion
397
463
  //#region src/view-builder.d.ts
398
464
  /**
399
- * Builds a CREATE VIEW statement from a view plan and column mappings.
465
+ * Builds a CREATE VIEW statement from a view plan and column mappings:
466
+ * `<dialect.createViewPrefix> <view> AS <buildViewSelect(…)>`.
467
+ */
468
+ declare function buildCreateView(dialect: SqlDialect, viewName: string, plan: TViewPlan, columns: TViewColumnMapping[], resolveFieldRef: (ref: AtscriptQueryFieldRef) => string): string;
469
+ /**
470
+ * The SELECT of a managed view — the body of its CREATE VIEW, and (since
471
+ * 0.1.153) the inline definition a pruned view read selects from (with a
472
+ * `AtscriptDbView.readPlan` variant's plan and columns), so the two can never
473
+ * drift. Parameter-free: predicate literals are inlined.
400
474
  *
401
475
  * Joins render in declaration order — `JOIN` (inner, the default) or
402
476
  * `LEFT JOIN` for `kind: "left"`; a `@db.alias` target renders as
@@ -405,8 +479,9 @@ declare function buildVectorSearchCount(dialect: SqlDialect, source: TSqlFragmen
405
479
  * name for an aliased join); `resolveFieldRef` renders predicate refs (join
406
480
  * ON, WHERE, HAVING fallbacks) as `"table"."column"`. The entry table and a
407
481
  * join target may be views.
482
+ * @since 0.1.153
408
483
  */
409
- declare function buildCreateView(dialect: SqlDialect, viewName: string, plan: TViewPlan, columns: TViewColumnMapping[], resolveFieldRef: (ref: AtscriptQueryFieldRef) => string): string;
484
+ declare function buildViewSelect(dialect: SqlDialect, plan: TViewPlan, columns: TViewColumnMapping[], resolveFieldRef: (ref: AtscriptQueryFieldRef) => string): string;
410
485
  //#endregion
411
486
  //#region src/sql-builder.d.ts
412
487
  /**
@@ -456,8 +531,17 @@ declare function chunkInsertRows(rows: readonly Record<string, unknown>[], maxPa
456
531
  declare function buildInsertMany(dialect: SqlDialect, table: string, rows: readonly Record<string, unknown>[], columns?: readonly string[]): TSqlFragment;
457
532
  /**
458
533
  * Builds a SELECT statement with optional sort, limit, offset, projection.
534
+ * `table` may be a derived source (a pruned view read, since 0.1.153).
459
535
  */
460
- declare function buildSelect(dialect: SqlDialect, table: string, where: TSqlFragment, controls?: DbControls): TSqlFragment;
536
+ declare function buildSelect(dialect: SqlDialect, table: TSqlFromSource, where: TSqlFragment, controls?: DbControls): TSqlFragment;
537
+ /**
538
+ * `"col" ASC, "other" DESC` of a physical `$sort` — `""` when it orders
539
+ * nothing. A key with a `$nulls` entry places NULL as asked
540
+ * ({@link nullsOrderSql}); `prefix` is prepended verbatim to each quoted
541
+ * column (`t.` → `t."col"`).
542
+ * @since 0.1.153 (exported)
543
+ */
544
+ declare function orderByList(dialect: SqlDialect, sort: DbControls["$sort"], nulls?: DbControls["$nulls"], prefix?: string): string;
461
545
  /**
462
546
  * The row-number column {@link buildPartitionedSelect} adds to every row;
463
547
  * {@link stripPartitionRowNumber} removes it from the result rows.
@@ -474,7 +558,7 @@ declare const PARTITION_ROW_NUMBER_ALIAS = "__atscript_rn";
474
558
  * SQLite ≥ 3.25, MySQL ≥ 8.0 or MariaDB ≥ 10.2.
475
559
  * @since 0.1.147
476
560
  */
477
- declare function buildPartitionedSelect(dialect: SqlDialect, table: string, where: TSqlFragment, controls: DbControls, partitionBy: readonly string[]): TSqlFragment;
561
+ declare function buildPartitionedSelect(dialect: SqlDialect, table: TSqlFromSource, where: TSqlFragment, controls: DbControls, partitionBy: readonly string[]): TSqlFragment;
478
562
  /** Removes {@link PARTITION_ROW_NUMBER_ALIAS} from rows read by {@link buildPartitionedSelect}. */
479
563
  declare function stripPartitionRowNumber<R extends Record<string, unknown>>(rows: R[]): R[];
480
564
  /**
@@ -641,7 +725,7 @@ declare function groupKeySql(dialect: SqlDialect, controls: DbControls, key: str
641
725
  * parameter-free, so the bind parameters are exactly those of the same query
642
726
  * without buckets (WHERE, HAVING, LIMIT, OFFSET).
643
727
  */
644
- declare function buildAggregateSelect(dialect: SqlDialect, table: string, where: TSqlFragment, controls: DbControls): TSqlFragment;
728
+ declare function buildAggregateSelect(dialect: SqlDialect, table: TSqlFromSource, where: TSqlFragment, controls: DbControls): TSqlFragment;
645
729
  /**
646
730
  * Builds a COUNT query for the number of distinct groups — the groups that
647
731
  * survive `$having` when one is given (the same predicate the row query
@@ -650,7 +734,7 @@ declare function buildAggregateSelect(dialect: SqlDialect, table: string, where:
650
734
  * `first` / `last` value (or an expression over one): only then the window
651
735
  * derived table is built.
652
736
  */
653
- declare function buildAggregateCount(dialect: SqlDialect, table: string, where: TSqlFragment, controls: DbControls): TSqlFragment;
737
+ declare function buildAggregateCount(dialect: SqlDialect, table: TSqlFromSource, where: TSqlFragment, controls: DbControls): TSqlFragment;
654
738
  //#endregion
655
739
  //#region src/regex.d.ts
656
740
  /**
@@ -665,4 +749,30 @@ declare function parseRegexString(value: unknown): {
665
749
  flags: string;
666
750
  };
667
751
  //#endregion
668
- export { AGG_FN_SQL, EMPTY_AND, EMPTY_OR, GEO_DISTANCE_ALIAS, InsertSqlCache, PARTITION_ROW_NUMBER_ALIAS, SEARCH_SOURCE_ALIAS, SQL_DEFAULT, type SqlDialect, type TArithFailure, type TFilterVisitorOptions, type TGeoCircle, type TGeoSearchControls, type TGeoWindow, type TReplaceColumn, type TSqlFragment, VECTOR_DISTANCE_ALIAS, arithOverflowError, buildAggregateCount, buildAggregateSelect, buildCreateView, buildDelete, buildGeoSearchCount, buildGeoSearchSelect, buildInsert, buildInsertMany, buildPartitionedSelect, buildProjection, buildSelect, buildUpdate, buildVectorSearchCount, buildVectorSearchSelect, buildWhere, chunkInsertRows, createFilterVisitor, defaultValueForType, defaultValueToSqlLiteral, derivedColumnExpr, fillReplacePayload, finalizeParams, foreignKeySql, geoWindowFromControls, groupKeySql, insertManyColumns, jsonDollarPath, mapQueryErrors, normalizeGeoPointValue, numericOutOfRangeError, orFragment, orderKeySql, parseRegexString, queryNodeToSql, queryOpToSql, quotedJsonPathSegments, refActionToSql, renameGeoDistance, renderArith, replaceColumnsFor, sqlStringLiteral, sqlTimeZoneLiteral, stripPartitionRowNumber, toSqlValue, vectorDistanceSource };
752
+ //#region src/view-read.d.ts
753
+ /** The structural view surface {@link viewReadSource} needs from an adapter's readable. */
754
+ interface TReadableLike {
755
+ readonly isView: boolean;
756
+ readonly tableName: string;
757
+ }
758
+ /**
759
+ * The FROM source of a read of `readable` (the adapter's registered table or
760
+ * view): `tableName` unchanged, except for a managed view whose read does
761
+ * not need some of its LEFT joins (`AtscriptDbView.readPlan` over the
762
+ * columns `query` reads — {@link queryReadColumns}): then the view's own
763
+ * definition without those joins ({@link buildViewSelect}), aliased as the
764
+ * bare view name so every column reference — and the correlation of a
765
+ * relational predicate — stays valid. A column of a dropped join is absent
766
+ * from the derived table, so a reference the collector missed fails with
767
+ * "unknown column" rather than reading wrong data.
768
+ *
769
+ * Never pruned: a table, an external view, a view read under another name
770
+ * than its own (`tableName` schema-qualified — its definition's unqualified
771
+ * names resolve against that schema, the derived table's against the
772
+ * connection's — or renamed by an adapter subclass), or anything `readPlan`
773
+ * keeps whole.
774
+ * @since 0.1.153
775
+ */
776
+ declare function viewReadSource(dialect: SqlDialect, readable: TReadableLike, tableName: string, query: DbQuery, kind: TReadColumnsKind, partitionBy?: readonly string[]): TSqlFromSource;
777
+ //#endregion
778
+ export { AGG_FN_SQL, EMPTY_AND, EMPTY_OR, GEO_DISTANCE_ALIAS, InsertSqlCache, PARTITION_ROW_NUMBER_ALIAS, SEARCH_SOURCE_ALIAS, SQL_DEFAULT, type SqlDialect, type TArithFailure, type TFilterVisitorOptions, type TGeoCircle, type TGeoSearchControls, type TGeoWindow, type TReplaceColumn, type TSqlDerivedSource, type TSqlFragment, type TSqlFromSource, VECTOR_DISTANCE_ALIAS, arithOverflowError, buildAggregateCount, buildAggregateSelect, buildCreateView, buildDelete, buildGeoSearchCount, buildGeoSearchSelect, buildInsert, buildInsertMany, buildPartitionedSelect, buildProjection, buildSelect, buildUpdate, buildVectorSearchCount, buildVectorSearchSelect, buildViewSelect, buildWhere, chunkInsertRows, createFilterVisitor, defaultValueForType, defaultValueToSqlLiteral, derivedColumnExpr, fillReplacePayload, finalizeParams, foreignKeySql, fromSourceHint, fromSourceSql, geoWindowFromControls, groupKeySql, insertManyColumns, jsonDollarPath, mapQueryErrors, normalizeGeoPointValue, nullsOrderSql, nullsPlacementOf, numericOutOfRangeError, orFragment, orderByList, orderKeySql, parseRegexString, queryNodeToSql, queryOpToSql, quotedJsonPathSegments, refActionToSql, renameGeoDistance, renderArith, replaceColumnsFor, sqlStringLiteral, sqlTimeZoneLiteral, stripPartitionRowNumber, toSqlValue, vectorDistanceSource, viewReadSource };
package/dist/index.d.mts CHANGED
@@ -1,5 +1,5 @@
1
- import { AtscriptExprNode, AtscriptQueryFieldRef, AtscriptQueryNode, DbControls, DbError, TDbDefaultFn, TDbFieldMeta, TDbForeignKey, TDbReferentialAction, TFieldOps, TResolvedBucket, TViewColumnMapping, TViewJsonType, TViewPlan, UniquSelect } from "@atscript/db";
2
- import { FilterExpr, FilterVisitor } from "@uniqu/core";
1
+ import { AtscriptExprNode, AtscriptQueryFieldRef, AtscriptQueryNode, DbControls, DbError, DbQuery, TDbDefaultFn, TDbFieldMeta, TDbForeignKey, TDbReferentialAction, TFieldOps, TReadColumnsKind, TResolvedBucket, TViewColumnMapping, TViewJsonType, TViewPlan, UniquSelect } from "@atscript/db";
2
+ import { FilterExpr, FilterVisitor, NullsPlacement } from "@uniqu/core";
3
3
  import { TDbAggregateFn } from "@atscript/db/agg";
4
4
 
5
5
  //#region src/dialect.d.ts
@@ -19,6 +19,14 @@ interface SqlDialect {
19
19
  quoteTable(name: string): string;
20
20
  /** SQL literal for unlimited LIMIT (SQLite: '-1', MySQL: '18446744073709551615') */
21
21
  unlimitedLimit: string;
22
+ /**
23
+ * The optimizer hint asking the engine to MERGE the derived table `alias`
24
+ * into the SELECT reading it (MySQL: `/*+ MERGE(\`alias\`) *\/`) — carried
25
+ * by pruned view reads (`viewReadSource`), so the inline definition merges
26
+ * even in a session with `derived_merge=off`. Omitted: no hint.
27
+ * @since 0.1.153
28
+ */
29
+ derivedMergeHint?(alias: string): string;
22
30
  /** Convert JS value to SQL-bindable param for DML */
23
31
  toValue(value: unknown): unknown;
24
32
  /** Convert JS value to SQL-bindable param for filters (lighter) */
@@ -142,6 +150,14 @@ interface SqlDialect {
142
150
  * @since 0.1.147
143
151
  */
144
152
  nullsSortLargest?: boolean;
153
+ /**
154
+ * `true` when the engine accepts `NULLS FIRST` / `NULLS LAST` on an
155
+ * `ORDER BY` key (PostgreSQL, SQLite ≥ 3.30). Without it a requested NULL
156
+ * placement that differs from the native one renders as an extra
157
+ * `(<expr> IS NULL)` key ({@link nullsOrderSql}).
158
+ * @since 0.1.153
159
+ */
160
+ nullsPlacementSyntax?: boolean;
145
161
  /** e.g. 'CREATE VIEW IF NOT EXISTS' or 'CREATE OR REPLACE VIEW' */
146
162
  createViewPrefix: string;
147
163
  /** Returns a parameter placeholder for the given 1-based index. When absent, '?' is used. */
@@ -169,13 +185,41 @@ declare function finalizeParams(dialect: SqlDialect, fragment: TSqlFragment): TS
169
185
  */
170
186
  declare function mapQueryErrors<R>(dialect: SqlDialect, fn: () => Promise<R>, controls?: DbControls): Promise<R>;
171
187
  /**
172
- * One `ORDER BY` key with the uniform "NULL is the smallest value" ordering:
173
- * `<expr> ASC` / `<expr> DESC`, plus `NULLS FIRST` / `NULLS LAST` on a
174
- * dialect where NULL sorts largest ({@link SqlDialect.nullsSortLargest}).
175
- * Shared by first-row joins and `first` / `last` aggregates.
176
- * @since 0.1.148
188
+ * The `$nulls` entry of one `ORDER BY` key (`controls.$nulls` is keyed like
189
+ * `controls.$sort`), or `undefined`.
190
+ * @since 0.1.153
191
+ */
192
+ declare function nullsPlacementOf(nulls: DbControls["$nulls"], key: string): NullsPlacement | undefined;
193
+ /**
194
+ * One `ORDER BY` key with a requested NULL placement. Without `nulls`, the
195
+ * plain `<expr> ASC|DESC` (the engine's native placement). With it:
196
+ *
197
+ * - the engine's native placement already matches (NULL is the smallest
198
+ * value — `ASC` → first, `DESC` → last — unless
199
+ * {@link SqlDialect.nullsSortLargest}, where it is the opposite) → the plain
200
+ * key, so an index on `expr` still serves the order;
201
+ * - else `<expr> ASC|DESC NULLS FIRST|LAST` on a dialect with
202
+ * {@link SqlDialect.nullsPlacementSyntax} (or `nullsSortLargest`);
203
+ * - else (MySQL) a leading NULL-ness key: `(<expr> IS NULL) DESC, <expr> DIR`
204
+ * for `first`, `(<expr> IS NULL) ASC, <expr> DIR` for `last`.
205
+ *
206
+ * May render two comma-separated keys — only valid inside an `ORDER BY` list.
207
+ * `nullTest` renders the `IS NULL` operand when it must differ from `expr`
208
+ * (MySQL resolves a name inside an expression to a table column before a
209
+ * SELECT alias, so a grouped `ORDER BY` tests the alias's own expression).
210
+ * @since 0.1.153
211
+ */
212
+ declare function nullsOrderSql(dialect: SqlDialect, expr: string, desc: boolean, nulls?: NullsPlacement, nullTest?: () => string): string;
213
+ /**
214
+ * One `ORDER BY` key with the uniform "NULL is the smallest value" ordering
215
+ * (`ASC` → NULL first, `DESC` → NULL last) unless `nulls` asks for another
216
+ * placement — {@link nullsOrderSql}. Without `nulls`: `<expr> ASC` /
217
+ * `<expr> DESC`, plus `NULLS FIRST` / `NULLS LAST` on a dialect where NULL
218
+ * sorts largest ({@link SqlDialect.nullsSortLargest}). Shared by first-row
219
+ * joins, `first` / `last` aggregates and the grouped `ORDER BY`.
220
+ * @since 0.1.148 (`nulls` since 0.1.153)
177
221
  */
178
- declare function orderKeySql(dialect: SqlDialect, expr: string, desc: boolean): string;
222
+ declare function orderKeySql(dialect: SqlDialect, expr: string, desc: boolean, nulls?: NullsPlacement, nullTest?: () => string): string;
179
223
  /**
180
224
  * Each JSON path segment wrapped in double quotes (`"a"`), for the dialects'
181
225
  * {@link SqlDialect.jsonExtract} path literals (`'$."a"."b"'`, `'{"a","b"}'`).
@@ -394,9 +438,39 @@ declare function buildVectorSearchCount(dialect: SqlDialect, source: TSqlFragmen
394
438
  residual?: TSqlFragment;
395
439
  }): TSqlFragment;
396
440
  //#endregion
441
+ //#region src/from-source.d.ts
442
+ /**
443
+ * A pre-rendered derived table — `(<select>) AS "alias"` — read in place of
444
+ * a table name, plus the optimizer hint the SELECT that reads it carries
445
+ * (`/*+ MERGE(…) *\/ `, or `""`).
446
+ * @since 0.1.153
447
+ */
448
+ interface TSqlDerivedSource {
449
+ readonly sql: string;
450
+ readonly hint: string;
451
+ }
452
+ /**
453
+ * The FROM source of a read builder: a table / view name (quoted by the
454
+ * dialect) or a {@link TSqlDerivedSource}.
455
+ * @since 0.1.153
456
+ */
457
+ type TSqlFromSource = string | TSqlDerivedSource;
458
+ /** The FROM text of `source`: the quoted table name, or the derived table. */
459
+ declare function fromSourceSql(dialect: SqlDialect, source: TSqlFromSource): string;
460
+ /** The hint the SELECT directly reading `source` carries (`""` for a table). */
461
+ declare function fromSourceHint(source: TSqlFromSource): string;
462
+ //#endregion
397
463
  //#region src/view-builder.d.ts
398
464
  /**
399
- * Builds a CREATE VIEW statement from a view plan and column mappings.
465
+ * Builds a CREATE VIEW statement from a view plan and column mappings:
466
+ * `<dialect.createViewPrefix> <view> AS <buildViewSelect(…)>`.
467
+ */
468
+ declare function buildCreateView(dialect: SqlDialect, viewName: string, plan: TViewPlan, columns: TViewColumnMapping[], resolveFieldRef: (ref: AtscriptQueryFieldRef) => string): string;
469
+ /**
470
+ * The SELECT of a managed view — the body of its CREATE VIEW, and (since
471
+ * 0.1.153) the inline definition a pruned view read selects from (with a
472
+ * `AtscriptDbView.readPlan` variant's plan and columns), so the two can never
473
+ * drift. Parameter-free: predicate literals are inlined.
400
474
  *
401
475
  * Joins render in declaration order — `JOIN` (inner, the default) or
402
476
  * `LEFT JOIN` for `kind: "left"`; a `@db.alias` target renders as
@@ -405,8 +479,9 @@ declare function buildVectorSearchCount(dialect: SqlDialect, source: TSqlFragmen
405
479
  * name for an aliased join); `resolveFieldRef` renders predicate refs (join
406
480
  * ON, WHERE, HAVING fallbacks) as `"table"."column"`. The entry table and a
407
481
  * join target may be views.
482
+ * @since 0.1.153
408
483
  */
409
- declare function buildCreateView(dialect: SqlDialect, viewName: string, plan: TViewPlan, columns: TViewColumnMapping[], resolveFieldRef: (ref: AtscriptQueryFieldRef) => string): string;
484
+ declare function buildViewSelect(dialect: SqlDialect, plan: TViewPlan, columns: TViewColumnMapping[], resolveFieldRef: (ref: AtscriptQueryFieldRef) => string): string;
410
485
  //#endregion
411
486
  //#region src/sql-builder.d.ts
412
487
  /**
@@ -456,8 +531,17 @@ declare function chunkInsertRows(rows: readonly Record<string, unknown>[], maxPa
456
531
  declare function buildInsertMany(dialect: SqlDialect, table: string, rows: readonly Record<string, unknown>[], columns?: readonly string[]): TSqlFragment;
457
532
  /**
458
533
  * Builds a SELECT statement with optional sort, limit, offset, projection.
534
+ * `table` may be a derived source (a pruned view read, since 0.1.153).
459
535
  */
460
- declare function buildSelect(dialect: SqlDialect, table: string, where: TSqlFragment, controls?: DbControls): TSqlFragment;
536
+ declare function buildSelect(dialect: SqlDialect, table: TSqlFromSource, where: TSqlFragment, controls?: DbControls): TSqlFragment;
537
+ /**
538
+ * `"col" ASC, "other" DESC` of a physical `$sort` — `""` when it orders
539
+ * nothing. A key with a `$nulls` entry places NULL as asked
540
+ * ({@link nullsOrderSql}); `prefix` is prepended verbatim to each quoted
541
+ * column (`t.` → `t."col"`).
542
+ * @since 0.1.153 (exported)
543
+ */
544
+ declare function orderByList(dialect: SqlDialect, sort: DbControls["$sort"], nulls?: DbControls["$nulls"], prefix?: string): string;
461
545
  /**
462
546
  * The row-number column {@link buildPartitionedSelect} adds to every row;
463
547
  * {@link stripPartitionRowNumber} removes it from the result rows.
@@ -474,7 +558,7 @@ declare const PARTITION_ROW_NUMBER_ALIAS = "__atscript_rn";
474
558
  * SQLite ≥ 3.25, MySQL ≥ 8.0 or MariaDB ≥ 10.2.
475
559
  * @since 0.1.147
476
560
  */
477
- declare function buildPartitionedSelect(dialect: SqlDialect, table: string, where: TSqlFragment, controls: DbControls, partitionBy: readonly string[]): TSqlFragment;
561
+ declare function buildPartitionedSelect(dialect: SqlDialect, table: TSqlFromSource, where: TSqlFragment, controls: DbControls, partitionBy: readonly string[]): TSqlFragment;
478
562
  /** Removes {@link PARTITION_ROW_NUMBER_ALIAS} from rows read by {@link buildPartitionedSelect}. */
479
563
  declare function stripPartitionRowNumber<R extends Record<string, unknown>>(rows: R[]): R[];
480
564
  /**
@@ -641,7 +725,7 @@ declare function groupKeySql(dialect: SqlDialect, controls: DbControls, key: str
641
725
  * parameter-free, so the bind parameters are exactly those of the same query
642
726
  * without buckets (WHERE, HAVING, LIMIT, OFFSET).
643
727
  */
644
- declare function buildAggregateSelect(dialect: SqlDialect, table: string, where: TSqlFragment, controls: DbControls): TSqlFragment;
728
+ declare function buildAggregateSelect(dialect: SqlDialect, table: TSqlFromSource, where: TSqlFragment, controls: DbControls): TSqlFragment;
645
729
  /**
646
730
  * Builds a COUNT query for the number of distinct groups — the groups that
647
731
  * survive `$having` when one is given (the same predicate the row query
@@ -650,7 +734,7 @@ declare function buildAggregateSelect(dialect: SqlDialect, table: string, where:
650
734
  * `first` / `last` value (or an expression over one): only then the window
651
735
  * derived table is built.
652
736
  */
653
- declare function buildAggregateCount(dialect: SqlDialect, table: string, where: TSqlFragment, controls: DbControls): TSqlFragment;
737
+ declare function buildAggregateCount(dialect: SqlDialect, table: TSqlFromSource, where: TSqlFragment, controls: DbControls): TSqlFragment;
654
738
  //#endregion
655
739
  //#region src/regex.d.ts
656
740
  /**
@@ -665,4 +749,30 @@ declare function parseRegexString(value: unknown): {
665
749
  flags: string;
666
750
  };
667
751
  //#endregion
668
- export { AGG_FN_SQL, EMPTY_AND, EMPTY_OR, GEO_DISTANCE_ALIAS, InsertSqlCache, PARTITION_ROW_NUMBER_ALIAS, SEARCH_SOURCE_ALIAS, SQL_DEFAULT, type SqlDialect, type TArithFailure, type TFilterVisitorOptions, type TGeoCircle, type TGeoSearchControls, type TGeoWindow, type TReplaceColumn, type TSqlFragment, VECTOR_DISTANCE_ALIAS, arithOverflowError, buildAggregateCount, buildAggregateSelect, buildCreateView, buildDelete, buildGeoSearchCount, buildGeoSearchSelect, buildInsert, buildInsertMany, buildPartitionedSelect, buildProjection, buildSelect, buildUpdate, buildVectorSearchCount, buildVectorSearchSelect, buildWhere, chunkInsertRows, createFilterVisitor, defaultValueForType, defaultValueToSqlLiteral, derivedColumnExpr, fillReplacePayload, finalizeParams, foreignKeySql, geoWindowFromControls, groupKeySql, insertManyColumns, jsonDollarPath, mapQueryErrors, normalizeGeoPointValue, numericOutOfRangeError, orFragment, orderKeySql, parseRegexString, queryNodeToSql, queryOpToSql, quotedJsonPathSegments, refActionToSql, renameGeoDistance, renderArith, replaceColumnsFor, sqlStringLiteral, sqlTimeZoneLiteral, stripPartitionRowNumber, toSqlValue, vectorDistanceSource };
752
+ //#region src/view-read.d.ts
753
+ /** The structural view surface {@link viewReadSource} needs from an adapter's readable. */
754
+ interface TReadableLike {
755
+ readonly isView: boolean;
756
+ readonly tableName: string;
757
+ }
758
+ /**
759
+ * The FROM source of a read of `readable` (the adapter's registered table or
760
+ * view): `tableName` unchanged, except for a managed view whose read does
761
+ * not need some of its LEFT joins (`AtscriptDbView.readPlan` over the
762
+ * columns `query` reads — {@link queryReadColumns}): then the view's own
763
+ * definition without those joins ({@link buildViewSelect}), aliased as the
764
+ * bare view name so every column reference — and the correlation of a
765
+ * relational predicate — stays valid. A column of a dropped join is absent
766
+ * from the derived table, so a reference the collector missed fails with
767
+ * "unknown column" rather than reading wrong data.
768
+ *
769
+ * Never pruned: a table, an external view, a view read under another name
770
+ * than its own (`tableName` schema-qualified — its definition's unqualified
771
+ * names resolve against that schema, the derived table's against the
772
+ * connection's — or renamed by an adapter subclass), or anything `readPlan`
773
+ * keeps whole.
774
+ * @since 0.1.153
775
+ */
776
+ declare function viewReadSource(dialect: SqlDialect, readable: TReadableLike, tableName: string, query: DbQuery, kind: TReadColumnsKind, partitionBy?: readonly string[]): TSqlFromSource;
777
+ //#endregion
778
+ export { AGG_FN_SQL, EMPTY_AND, EMPTY_OR, GEO_DISTANCE_ALIAS, InsertSqlCache, PARTITION_ROW_NUMBER_ALIAS, SEARCH_SOURCE_ALIAS, SQL_DEFAULT, type SqlDialect, type TArithFailure, type TFilterVisitorOptions, type TGeoCircle, type TGeoSearchControls, type TGeoWindow, type TReplaceColumn, type TSqlDerivedSource, type TSqlFragment, type TSqlFromSource, VECTOR_DISTANCE_ALIAS, arithOverflowError, buildAggregateCount, buildAggregateSelect, buildCreateView, buildDelete, buildGeoSearchCount, buildGeoSearchSelect, buildInsert, buildInsertMany, buildPartitionedSelect, buildProjection, buildSelect, buildUpdate, buildVectorSearchCount, buildVectorSearchSelect, buildViewSelect, buildWhere, chunkInsertRows, createFilterVisitor, defaultValueForType, defaultValueToSqlLiteral, derivedColumnExpr, fillReplacePayload, finalizeParams, foreignKeySql, fromSourceHint, fromSourceSql, geoWindowFromControls, groupKeySql, insertManyColumns, jsonDollarPath, mapQueryErrors, normalizeGeoPointValue, nullsOrderSql, nullsPlacementOf, numericOutOfRangeError, orFragment, orderByList, orderKeySql, parseRegexString, queryNodeToSql, queryOpToSql, quotedJsonPathSegments, refActionToSql, renameGeoDistance, renderArith, replaceColumnsFor, sqlStringLiteral, sqlTimeZoneLiteral, stripPartitionRowNumber, toSqlValue, vectorDistanceSource, viewReadSource };
package/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { DbError, INTEGER_REGEX_OP, aggregateExpressionsNotSupported, containsRelationFilter, fkColumns, isFieldRef, isResolvedRelationFilter } from "@atscript/db";
1
+ import { DbError, INTEGER_REGEX_OP, aggregateExpressionsNotSupported, containsRelationFilter, fkColumns, isFieldRef, isResolvedRelationFilter, queryReadColumns } from "@atscript/db";
2
2
  import { BUCKET_UNITS, TIME_ZONE_NAME_RE, WEEK_STARTS, walkFilter } from "@uniqu/core";
3
3
  import { assertAggregateFn, resolveAlias } from "@atscript/db/agg";
4
4
  //#region src/dialect.ts
@@ -156,15 +156,53 @@ async function mapQueryErrors(dialect, fn, controls) {
156
156
  }
157
157
  }
158
158
  /**
159
- * One `ORDER BY` key with the uniform "NULL is the smallest value" ordering:
160
- * `<expr> ASC` / `<expr> DESC`, plus `NULLS FIRST` / `NULLS LAST` on a
161
- * dialect where NULL sorts largest ({@link SqlDialect.nullsSortLargest}).
162
- * Shared by first-row joins and `first` / `last` aggregates.
163
- * @since 0.1.148
159
+ * The `$nulls` entry of one `ORDER BY` key (`controls.$nulls` is keyed like
160
+ * `controls.$sort`), or `undefined`.
161
+ * @since 0.1.153
162
+ */
163
+ function nullsPlacementOf(nulls, key) {
164
+ if (!nulls) return void 0;
165
+ const map = nulls;
166
+ return Object.hasOwn(map, key) ? map[key] : void 0;
167
+ }
168
+ /**
169
+ * One `ORDER BY` key with a requested NULL placement. Without `nulls`, the
170
+ * plain `<expr> ASC|DESC` (the engine's native placement). With it:
171
+ *
172
+ * - the engine's native placement already matches (NULL is the smallest
173
+ * value — `ASC` → first, `DESC` → last — unless
174
+ * {@link SqlDialect.nullsSortLargest}, where it is the opposite) → the plain
175
+ * key, so an index on `expr` still serves the order;
176
+ * - else `<expr> ASC|DESC NULLS FIRST|LAST` on a dialect with
177
+ * {@link SqlDialect.nullsPlacementSyntax} (or `nullsSortLargest`);
178
+ * - else (MySQL) a leading NULL-ness key: `(<expr> IS NULL) DESC, <expr> DIR`
179
+ * for `first`, `(<expr> IS NULL) ASC, <expr> DIR` for `last`.
180
+ *
181
+ * May render two comma-separated keys — only valid inside an `ORDER BY` list.
182
+ * `nullTest` renders the `IS NULL` operand when it must differ from `expr`
183
+ * (MySQL resolves a name inside an expression to a table column before a
184
+ * SELECT alias, so a grouped `ORDER BY` tests the alias's own expression).
185
+ * @since 0.1.153
186
+ */
187
+ function nullsOrderSql(dialect, expr, desc, nulls, nullTest) {
188
+ const key = `${expr} ${desc ? "DESC" : "ASC"}`;
189
+ if (!nulls) return key;
190
+ const nativeFirst = desc === (dialect.nullsSortLargest === true);
191
+ if (nulls === "first" === nativeFirst) return key;
192
+ if (dialect.nullsPlacementSyntax || dialect.nullsSortLargest) return `${key} NULLS ${nulls === "first" ? "FIRST" : "LAST"}`;
193
+ return `(${nullTest ? nullTest() : expr} IS NULL) ${nulls === "first" ? "DESC" : "ASC"}, ${key}`;
194
+ }
195
+ /**
196
+ * One `ORDER BY` key with the uniform "NULL is the smallest value" ordering
197
+ * (`ASC` → NULL first, `DESC` → NULL last) unless `nulls` asks for another
198
+ * placement — {@link nullsOrderSql}. Without `nulls`: `<expr> ASC` /
199
+ * `<expr> DESC`, plus `NULLS FIRST` / `NULLS LAST` on a dialect where NULL
200
+ * sorts largest ({@link SqlDialect.nullsSortLargest}). Shared by first-row
201
+ * joins, `first` / `last` aggregates and the grouped `ORDER BY`.
202
+ * @since 0.1.148 (`nulls` since 0.1.153)
164
203
  */
165
- function orderKeySql(dialect, expr, desc) {
166
- const nulls = dialect.nullsSortLargest ? desc ? " NULLS LAST" : " NULLS FIRST" : "";
167
- return `${expr} ${desc ? "DESC" : "ASC"}${nulls}`;
204
+ function orderKeySql(dialect, expr, desc, nulls, nullTest) {
205
+ return nullsOrderSql(dialect, expr, desc, nulls ?? (desc ? "last" : "first"), nullTest);
168
206
  }
169
207
  /**
170
208
  * Each JSON path segment wrapped in double quotes (`"a"`), for the dialects'
@@ -467,6 +505,16 @@ function buildWhere(dialect, filter, opts) {
467
505
  }) : getVisitor(dialect)) ?? EMPTY_AND;
468
506
  }
469
507
  //#endregion
508
+ //#region src/from-source.ts
509
+ /** The FROM text of `source`: the quoted table name, or the derived table. */
510
+ function fromSourceSql(dialect, source) {
511
+ return typeof source === "string" ? dialect.quoteTable(source) : source.sql;
512
+ }
513
+ /** The hint the SELECT directly reading `source` carries (`""` for a table). */
514
+ function fromSourceHint(source) {
515
+ return typeof source === "string" ? "" : source.hint;
516
+ }
517
+ //#endregion
470
518
  //#region src/common.ts
471
519
  /** Formats a string value as a SQL literal with single-quote escaping. */
472
520
  function sqlStringLiteral(value) {
@@ -814,17 +862,24 @@ function rowColumns(controls) {
814
862
  * WHERE moves inside unchanged, so the bind parameters keep their order.
815
863
  */
816
864
  function aggSource(dialect, table, where, controls, withRows) {
817
- const quotedTable = dialect.quoteTable(table);
865
+ const quotedTable = fromSourceSql(dialect, table);
818
866
  const firstLast = controls.$select?.firstLast;
819
867
  const rowOrder = controls.$select?.rowOrder;
820
- if (!withRows || !firstLast?.length || !rowOrder?.length) return `FROM ${quotedTable} WHERE ${where.sql}`;
868
+ if (!withRows || !firstLast?.length || !rowOrder?.length) return {
869
+ hint: fromSourceHint(table),
870
+ sql: `FROM ${quotedTable} WHERE ${where.sql}`
871
+ };
821
872
  const groupBy = controls.$groupBy;
822
873
  const partition = groupBy?.length ? `PARTITION BY ${groupBy.map((key) => groupKeySql(dialect, controls, key)).join(", ")} ` : "";
823
- const order = (reverse) => rowOrder.map((k) => orderKeySql(dialect, dialect.quoteIdentifier(k.column), k.desc !== reverse)).join(", ");
824
- return `FROM (SELECT ${[...rowColumns(controls).map((column) => dialect.quoteIdentifier(column)), ...firstLast.map((fl, i) => {
874
+ const order = (reverse) => rowOrder.map((k) => orderKeySql(dialect, dialect.quoteIdentifier(k.column), k.desc !== reverse, k.nulls && reverse ? k.nulls === "first" ? "last" : "first" : k.nulls)).join(", ");
875
+ const columns = [...rowColumns(controls).map((column) => dialect.quoteIdentifier(column)), ...firstLast.map((fl, i) => {
825
876
  const col = dialect.quoteIdentifier(firstLastColumn(i));
826
877
  return `FIRST_VALUE(${dialect.quoteIdentifier(fl.column)}) OVER (${partition}ORDER BY ${order(fl.fn === "last")}) AS ${col}`;
827
- })].join(", ")} FROM ${quotedTable} WHERE ${where.sql}) AS ${dialect.quoteIdentifier(ROWS_ALIAS)}`;
878
+ })];
879
+ return {
880
+ hint: "",
881
+ sql: `FROM (SELECT ${fromSourceHint(table)}${columns.join(", ")} FROM ${quotedTable} WHERE ${where.sql}) AS ${dialect.quoteIdentifier(ROWS_ALIAS)}`
882
+ };
828
883
  }
829
884
  /**
830
885
  * Builds a SELECT ... GROUP BY statement with aggregate functions.
@@ -846,7 +901,9 @@ function buildAggregateSelect(dialect, table, where, controls) {
846
901
  aliasSql = aliasSqlMap(dialect, controls);
847
902
  for (const alias of select.computedAliases) selectParts.push(`${aliasSql.get(alias)} AS ${dialect.quoteIdentifier(alias)}`);
848
903
  } else for (const expr of select?.aggregates ?? []) selectParts.push(`${aggFnSql(dialect, select, expr)} AS ${dialect.quoteIdentifier(resolveAlias(expr))}`);
849
- let sql = `SELECT ${selectParts.length > 0 ? selectParts.join(", ") : "*"} ${aggSource(dialect, table, where, controls, true)}${groupByClause(dialect, controls)}`;
904
+ const cols = selectParts.length > 0 ? selectParts.join(", ") : "*";
905
+ const source = aggSource(dialect, table, where, controls, true);
906
+ let sql = `SELECT ${source.hint}${cols} ${source.sql}${groupByClause(dialect, controls)}`;
850
907
  const params = [...where.params];
851
908
  const having = havingClause(dialect, controls, aliasSql);
852
909
  if (having) {
@@ -854,8 +911,12 @@ function buildAggregateSelect(dialect, table, where, controls) {
854
911
  params.push(...having.params);
855
912
  }
856
913
  if (controls.$sort) {
914
+ const nullTest = (col) => () => {
915
+ aliasSql ??= aliasSqlMap(dialect, controls);
916
+ return aliasSql.get(col) ?? dialect.quoteIdentifier(col);
917
+ };
857
918
  const orderParts = [];
858
- for (const [col, dir] of Object.entries(controls.$sort)) orderParts.push(orderKeySql(dialect, dialect.quoteIdentifier(col), dir === -1));
919
+ for (const [col, dir] of Object.entries(controls.$sort)) orderParts.push(orderKeySql(dialect, dialect.quoteIdentifier(col), dir === -1, nullsPlacementOf(controls.$nulls, col), nullTest(col)));
859
920
  if (orderParts.length > 0) sql += ` ORDER BY ${orderParts.join(", ")}`;
860
921
  }
861
922
  if (controls.$limit !== void 0) {
@@ -890,7 +951,7 @@ function buildAggregateCount(dialect, table, where, controls) {
890
951
  params: []
891
952
  });
892
953
  return finalizeParams(dialect, {
893
- sql: `SELECT ${countCol} FROM ${dialect.quoteTable(table)} WHERE ${where.sql}`,
954
+ sql: `SELECT ${fromSourceHint(table)}${countCol} FROM ${fromSourceSql(dialect, table)} WHERE ${where.sql}`,
894
955
  params: where.params
895
956
  });
896
957
  }
@@ -899,7 +960,7 @@ function buildAggregateCount(dialect, table, where, controls) {
899
960
  let inner = "COUNT(*)";
900
961
  if (groupBy) inner = bucketSelectParts(dialect, controls).join(", ") || "1";
901
962
  return finalizeParams(dialect, {
902
- sql: `SELECT ${countCol} FROM (SELECT ${inner} ${from}${groupBy}${having?.sql ?? ""}) AS ${dialect.quoteIdentifier("_groups")}`,
963
+ sql: `SELECT ${countCol} FROM (SELECT ${from.hint}${inner} ${from.sql}${groupBy}${having?.sql ?? ""}) AS ${dialect.quoteIdentifier("_groups")}`,
903
964
  params: [...where.params, ...having?.params ?? []]
904
965
  });
905
966
  }
@@ -1001,7 +1062,17 @@ function firstRowOn(dialect, join, target, condition, resolveFieldRef) {
1001
1062
  return `${key} = (SELECT ${key} FROM ${target} WHERE ${condition} ORDER BY ${first.order.map(({ ref, desc }) => orderKeySql(dialect, resolveFieldRef(ref), desc)).join(", ")} LIMIT 1)`;
1002
1063
  }
1003
1064
  /**
1004
- * Builds a CREATE VIEW statement from a view plan and column mappings.
1065
+ * Builds a CREATE VIEW statement from a view plan and column mappings:
1066
+ * `<dialect.createViewPrefix> <view> AS <buildViewSelect(…)>`.
1067
+ */
1068
+ function buildCreateView(dialect, viewName, plan, columns, resolveFieldRef) {
1069
+ return `${dialect.createViewPrefix} ${dialect.quoteTable(viewName)} AS ${buildViewSelect(dialect, plan, columns, resolveFieldRef)}`;
1070
+ }
1071
+ /**
1072
+ * The SELECT of a managed view — the body of its CREATE VIEW, and (since
1073
+ * 0.1.153) the inline definition a pruned view read selects from (with a
1074
+ * `AtscriptDbView.readPlan` variant's plan and columns), so the two can never
1075
+ * drift. Parameter-free: predicate literals are inlined.
1005
1076
  *
1006
1077
  * Joins render in declaration order — `JOIN` (inner, the default) or
1007
1078
  * `LEFT JOIN` for `kind: "left"`; a `@db.alias` target renders as
@@ -1010,15 +1081,15 @@ function firstRowOn(dialect, join, target, condition, resolveFieldRef) {
1010
1081
  * name for an aliased join); `resolveFieldRef` renders predicate refs (join
1011
1082
  * ON, WHERE, HAVING fallbacks) as `"table"."column"`. The entry table and a
1012
1083
  * join target may be views.
1084
+ * @since 0.1.153
1013
1085
  */
1014
- function buildCreateView(dialect, viewName, plan, columns, resolveFieldRef) {
1086
+ function buildViewSelect(dialect, plan, columns, resolveFieldRef) {
1015
1087
  const columnByPath = new Map(columns.map((c) => [c.viewPath, c]));
1016
1088
  const hasAggregates = columns.some((c) => c.aggFn);
1017
1089
  const computedSql = /* @__PURE__ */ new Map();
1018
1090
  /** A column's SELECT expression: computed, aggregate or plain source. */
1019
1091
  const columnExpr = (c) => c.expr !== void 0 ? viewComputeExpr(dialect, c, columnByPath, resolveFieldRef, hasAggregates, computedSql) : c.aggFn ? viewAggExpr(dialect, c, resolveFieldRef) : viewSourceExpr(dialect, c);
1020
- const selectCols = columns.map((c) => `${columnExpr(c)} AS ${dialect.quoteIdentifier(c.viewColumn)}`).join(", ");
1021
- let sql = `${dialect.createViewPrefix} ${dialect.quoteTable(viewName)} AS SELECT ${selectCols} FROM ${dialect.quoteIdentifier(plan.entryTable)}`;
1092
+ let sql = `SELECT ${columns.map((c) => `${columnExpr(c)} AS ${dialect.quoteIdentifier(c.viewColumn)}`).join(", ")} FROM ${dialect.quoteIdentifier(plan.entryTable)}`;
1022
1093
  for (const join of plan.joins) {
1023
1094
  const condition = queryNodeToSql(join.condition, resolveFieldRef);
1024
1095
  const keyword = join.kind === "left" ? "LEFT JOIN" : "JOIN";
@@ -1171,11 +1242,13 @@ function buildInsertMany(dialect, table, rows, columns = insertManyColumns(rows)
1171
1242
  }
1172
1243
  /**
1173
1244
  * Builds a SELECT statement with optional sort, limit, offset, projection.
1245
+ * `table` may be a derived source (a pruned view read, since 0.1.153).
1174
1246
  */
1175
1247
  function buildSelect(dialect, table, where, controls) {
1176
- let sql = `SELECT ${buildProjection(dialect, controls?.$select)} FROM ${dialect.quoteTable(table)} WHERE ${where.sql}`;
1248
+ const cols = buildProjection(dialect, controls?.$select);
1249
+ let sql = `SELECT ${fromSourceHint(table)}${cols} FROM ${fromSourceSql(dialect, table)} WHERE ${where.sql}`;
1177
1250
  const params = [...where.params];
1178
- const orderBy = orderByList(dialect, controls?.$sort);
1251
+ const orderBy = orderByList(dialect, controls?.$sort, controls?.$nulls);
1179
1252
  if (orderBy) sql += ` ORDER BY ${orderBy}`;
1180
1253
  if (controls?.$limit !== void 0) {
1181
1254
  sql += ` LIMIT ?`;
@@ -1191,11 +1264,17 @@ function buildSelect(dialect, table, where, controls) {
1191
1264
  params
1192
1265
  });
1193
1266
  }
1194
- /** `"col" ASC, "other" DESC` of a physical `$sort` — `""` when it orders nothing. */
1195
- function orderByList(dialect, sort) {
1267
+ /**
1268
+ * `"col" ASC, "other" DESC` of a physical `$sort` — `""` when it orders
1269
+ * nothing. A key with a `$nulls` entry places NULL as asked
1270
+ * ({@link nullsOrderSql}); `prefix` is prepended verbatim to each quoted
1271
+ * column (`t.` → `t."col"`).
1272
+ * @since 0.1.153 (exported)
1273
+ */
1274
+ function orderByList(dialect, sort, nulls, prefix = "") {
1196
1275
  if (!sort) return "";
1197
1276
  const parts = [];
1198
- for (const [col, dir] of Object.entries(sort)) parts.push(`${dialect.quoteIdentifier(col)} ${dir === -1 ? "DESC" : "ASC"}`);
1277
+ for (const [col, dir] of Object.entries(sort)) parts.push(nullsOrderSql(dialect, prefix + dialect.quoteIdentifier(col), dir === -1, nullsPlacementOf(nulls, col)));
1199
1278
  return parts.join(", ");
1200
1279
  }
1201
1280
  /**
@@ -1216,9 +1295,9 @@ const PARTITION_ROW_NUMBER_ALIAS = "__atscript_rn";
1216
1295
  */
1217
1296
  function buildPartitionedSelect(dialect, table, where, controls, partitionBy) {
1218
1297
  const rn = dialect.quoteIdentifier(PARTITION_ROW_NUMBER_ALIAS);
1219
- const orderBy = orderByList(dialect, controls.$sort);
1298
+ const orderBy = orderByList(dialect, controls.$sort, controls.$nulls);
1220
1299
  const window = `PARTITION BY ${partitionBy.map((col) => dialect.quoteIdentifier(col)).join(", ")}` + (orderBy ? ` ORDER BY ${orderBy}` : "");
1221
- const inner = `SELECT ${buildProjection(dialect, controls.$select)}, ROW_NUMBER() OVER (${window}) AS ${rn} FROM ${dialect.quoteTable(table)} WHERE ${where.sql}`;
1300
+ const inner = `SELECT ${fromSourceHint(table)}${buildProjection(dialect, controls.$select)}, ROW_NUMBER() OVER (${window}) AS ${rn} FROM ${fromSourceSql(dialect, table)} WHERE ${where.sql}`;
1222
1301
  const skip = controls.$skip ?? 0;
1223
1302
  const limit = controls.$limit;
1224
1303
  const params = [...where.params, skip];
@@ -1625,4 +1704,47 @@ function parseRegexString(value) {
1625
1704
  };
1626
1705
  }
1627
1706
  //#endregion
1628
- export { AGG_FN_SQL, EMPTY_AND, EMPTY_OR, GEO_DISTANCE_ALIAS, InsertSqlCache, PARTITION_ROW_NUMBER_ALIAS, SEARCH_SOURCE_ALIAS, SQL_DEFAULT, VECTOR_DISTANCE_ALIAS, arithOverflowError, buildAggregateCount, buildAggregateSelect, buildCreateView, buildDelete, buildGeoSearchCount, buildGeoSearchSelect, buildInsert, buildInsertMany, buildPartitionedSelect, buildProjection, buildSelect, buildUpdate, buildVectorSearchCount, buildVectorSearchSelect, buildWhere, chunkInsertRows, createFilterVisitor, defaultValueForType, defaultValueToSqlLiteral, derivedColumnExpr, fillReplacePayload, finalizeParams, foreignKeySql, geoWindowFromControls, groupKeySql, insertManyColumns, jsonDollarPath, mapQueryErrors, normalizeGeoPointValue, numericOutOfRangeError, orFragment, orderKeySql, parseRegexString, queryNodeToSql, queryOpToSql, quotedJsonPathSegments, refActionToSql, renameGeoDistance, renderArith, replaceColumnsFor, sqlStringLiteral, sqlTimeZoneLiteral, stripPartitionRowNumber, toSqlValue, vectorDistanceSource };
1707
+ //#region src/view-read.ts
1708
+ /** Rendered pruned definitions per read variant (variants are memoised by their view). */
1709
+ const rendered = /* @__PURE__ */ new WeakMap();
1710
+ /**
1711
+ * The FROM source of a read of `readable` (the adapter's registered table or
1712
+ * view): `tableName` unchanged, except for a managed view whose read does
1713
+ * not need some of its LEFT joins (`AtscriptDbView.readPlan` over the
1714
+ * columns `query` reads — {@link queryReadColumns}): then the view's own
1715
+ * definition without those joins ({@link buildViewSelect}), aliased as the
1716
+ * bare view name so every column reference — and the correlation of a
1717
+ * relational predicate — stays valid. A column of a dropped join is absent
1718
+ * from the derived table, so a reference the collector missed fails with
1719
+ * "unknown column" rather than reading wrong data.
1720
+ *
1721
+ * Never pruned: a table, an external view, a view read under another name
1722
+ * than its own (`tableName` schema-qualified — its definition's unqualified
1723
+ * names resolve against that schema, the derived table's against the
1724
+ * connection's — or renamed by an adapter subclass), or anything `readPlan`
1725
+ * keeps whole.
1726
+ * @since 0.1.153
1727
+ */
1728
+ function viewReadSource(dialect, readable, tableName, query, kind, partitionBy) {
1729
+ if (!readable.isView || tableName !== readable.tableName) return tableName;
1730
+ const view = readable;
1731
+ const variant = view.readPlan(queryReadColumns(query, kind, partitionBy));
1732
+ if (!variant) return tableName;
1733
+ let byDialect = rendered.get(variant);
1734
+ if (!byDialect) {
1735
+ byDialect = /* @__PURE__ */ new Map();
1736
+ rendered.set(variant, byDialect);
1737
+ }
1738
+ let source = byDialect.get(dialect);
1739
+ if (!source) {
1740
+ const qi = (name) => dialect.quoteIdentifier(name);
1741
+ source = {
1742
+ sql: `(${buildViewSelect(dialect, variant.plan, variant.columns, (ref) => view.resolveFieldRef(ref, qi))}) AS ${qi(readable.tableName)}`,
1743
+ hint: dialect.derivedMergeHint ? `${dialect.derivedMergeHint(readable.tableName)} ` : ""
1744
+ };
1745
+ byDialect.set(dialect, source);
1746
+ }
1747
+ return source;
1748
+ }
1749
+ //#endregion
1750
+ export { AGG_FN_SQL, EMPTY_AND, EMPTY_OR, GEO_DISTANCE_ALIAS, InsertSqlCache, PARTITION_ROW_NUMBER_ALIAS, SEARCH_SOURCE_ALIAS, SQL_DEFAULT, VECTOR_DISTANCE_ALIAS, arithOverflowError, buildAggregateCount, buildAggregateSelect, buildCreateView, buildDelete, buildGeoSearchCount, buildGeoSearchSelect, buildInsert, buildInsertMany, buildPartitionedSelect, buildProjection, buildSelect, buildUpdate, buildVectorSearchCount, buildVectorSearchSelect, buildViewSelect, buildWhere, chunkInsertRows, createFilterVisitor, defaultValueForType, defaultValueToSqlLiteral, derivedColumnExpr, fillReplacePayload, finalizeParams, foreignKeySql, fromSourceHint, fromSourceSql, geoWindowFromControls, groupKeySql, insertManyColumns, jsonDollarPath, mapQueryErrors, normalizeGeoPointValue, nullsOrderSql, nullsPlacementOf, numericOutOfRangeError, orFragment, orderByList, orderKeySql, parseRegexString, queryNodeToSql, queryOpToSql, quotedJsonPathSegments, refActionToSql, renameGeoDistance, renderArith, replaceColumnsFor, sqlStringLiteral, sqlTimeZoneLiteral, stripPartitionRowNumber, toSqlValue, vectorDistanceSource, viewReadSource };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atscript/db-sql-tools",
3
- "version": "0.1.152",
3
+ "version": "0.1.154",
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.14",
41
- "unplugin-atscript": "^0.1.102"
40
+ "@uniqu/core": "^0.1.15",
41
+ "unplugin-atscript": "^0.1.103"
42
42
  },
43
43
  "peerDependencies": {
44
- "@uniqu/core": "^0.1.14",
45
- "@atscript/db": "^0.1.152"
44
+ "@uniqu/core": "^0.1.15",
45
+ "@atscript/db": "^0.1.154"
46
46
  },
47
47
  "scripts": {
48
48
  "build": "vp pack",