@atscript/db-sql-tools 0.1.145 → 0.1.147
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 +247 -16
- package/dist/index.d.cts +97 -5
- package/dist/index.d.mts +97 -5
- package/dist/index.mjs +244 -18
- package/package.json +5 -5
package/dist/index.cjs
CHANGED
|
@@ -61,6 +61,8 @@ const EMPTY_OR = {
|
|
|
61
61
|
*/
|
|
62
62
|
function createFilterVisitor(dialect, options) {
|
|
63
63
|
const columnRef = options?.columnRef ?? ((field) => dialect.quoteIdentifier(field));
|
|
64
|
+
const qualifier = options?.qualifier;
|
|
65
|
+
const aliasSeq = options?.aliasSeq ?? { n: 0 };
|
|
64
66
|
return {
|
|
65
67
|
comparison(field, op, value) {
|
|
66
68
|
if (op === "$geoWithin") {
|
|
@@ -153,9 +155,75 @@ function createFilterVisitor(dialect, options) {
|
|
|
153
155
|
sql: `NOT (${child.sql})`,
|
|
154
156
|
params: child.params
|
|
155
157
|
};
|
|
158
|
+
},
|
|
159
|
+
relation(field, op, operand) {
|
|
160
|
+
if (!(0, _atscript_db.isResolvedRelationFilter)(operand)) throw new _atscript_db.DbError("REL_FILTER_NOT_SUPPORTED", [{
|
|
161
|
+
path: field,
|
|
162
|
+
message: `Relational predicate "${op}" on "${field}" reached the SQL renderer unresolved`
|
|
163
|
+
}]);
|
|
164
|
+
return renderRelation(dialect, op, operand, qualifier ?? dialect.quoteTable(operand.source.table), aliasSeq);
|
|
156
165
|
}
|
|
157
166
|
};
|
|
158
167
|
}
|
|
168
|
+
/**
|
|
169
|
+
* Renders one resolved relational predicate as a correlated `EXISTS`:
|
|
170
|
+
*
|
|
171
|
+
* ```sql
|
|
172
|
+
* -- to / from
|
|
173
|
+
* EXISTS (SELECT 1 FROM <target> AS "_rf1"
|
|
174
|
+
* WHERE "_rf1"."<pair.target>" = <outer>."<pair.source>" [AND …] [AND <inner>])
|
|
175
|
+
* -- via
|
|
176
|
+
* EXISTS (SELECT 1 FROM <junction> AS "_rf1" JOIN <target> AS "_rf2"
|
|
177
|
+
* ON "_rf2"."<toTarget.target>" = "_rf1"."<toTarget.junction>" [AND …]
|
|
178
|
+
* WHERE "_rf1"."<toSource.junction>" = <outer>."<toSource.source>" [AND …]
|
|
179
|
+
* [AND <junction filter on _rf1>] [AND <inner on _rf2>])
|
|
180
|
+
* ```
|
|
181
|
+
*
|
|
182
|
+
* `$none` is `NOT EXISTS (…)`. Outer columns are always qualified (a bare
|
|
183
|
+
* column would bind to the subquery's table on a self relation or a
|
|
184
|
+
* same-named column). A NULL foreign-key component never satisfies `=`, so
|
|
185
|
+
* such a row has no related row (`$some` false, `$none` true).
|
|
186
|
+
*/
|
|
187
|
+
function renderRelation(dialect, op, node, outer, aliasSeq) {
|
|
188
|
+
const q = (name) => dialect.quoteIdentifier(name);
|
|
189
|
+
const nextAlias = () => q(`_rf${++aliasSeq.n}`);
|
|
190
|
+
const conditions = [];
|
|
191
|
+
const params = [];
|
|
192
|
+
const addFilter = (filter, alias) => {
|
|
193
|
+
if (!filter || Object.keys(filter).length === 0) return;
|
|
194
|
+
const fragment = (0, _uniqu_core.walkFilter)(filter, createFilterVisitor(dialect, {
|
|
195
|
+
columnRef: (field) => `${alias}.${q(field)}`,
|
|
196
|
+
qualifier: alias,
|
|
197
|
+
aliasSeq
|
|
198
|
+
}));
|
|
199
|
+
if (fragment) {
|
|
200
|
+
conditions.push(fragment.sql);
|
|
201
|
+
params.push(...fragment.params);
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
let from;
|
|
205
|
+
if (node.kind === "via") {
|
|
206
|
+
const junction = node.junction;
|
|
207
|
+
if (!junction) throw new Error(`Relational predicate on "${node.nav}": a "via" relation needs a junction`);
|
|
208
|
+
const j = nextAlias();
|
|
209
|
+
const t = nextAlias();
|
|
210
|
+
const on = junction.toTarget.map((p) => `${t}.${q(p.target)} = ${j}.${q(p.junction)}`);
|
|
211
|
+
from = `${dialect.quoteTable(junction.table)} AS ${j} JOIN ${dialect.quoteTable(node.target.table)} AS ${t} ON ${on.join(" AND ")}`;
|
|
212
|
+
for (const p of junction.toSource) conditions.push(`${j}.${q(p.junction)} = ${outer}.${q(p.source)}`);
|
|
213
|
+
addFilter(junction.filter, j);
|
|
214
|
+
addFilter(node.filter, t);
|
|
215
|
+
} else {
|
|
216
|
+
const t = nextAlias();
|
|
217
|
+
from = `${dialect.quoteTable(node.target.table)} AS ${t}`;
|
|
218
|
+
for (const p of node.pairs) conditions.push(`${t}.${q(p.target)} = ${outer}.${q(p.source)}`);
|
|
219
|
+
addFilter(node.filter, t);
|
|
220
|
+
}
|
|
221
|
+
const where = conditions.length > 0 ? ` WHERE ${conditions.join(" AND ")}` : "";
|
|
222
|
+
return {
|
|
223
|
+
sql: `${op === "$none" ? "NOT " : ""}EXISTS (SELECT 1 FROM ${from}${where})`,
|
|
224
|
+
params
|
|
225
|
+
};
|
|
226
|
+
}
|
|
159
227
|
const visitorCache = /* @__PURE__ */ new WeakMap();
|
|
160
228
|
function getVisitor(dialect) {
|
|
161
229
|
let visitor = visitorCache.get(dialect);
|
|
@@ -167,10 +235,23 @@ function getVisitor(dialect) {
|
|
|
167
235
|
}
|
|
168
236
|
/**
|
|
169
237
|
* Translates a filter expression into a parameterized SQL WHERE clause.
|
|
238
|
+
*
|
|
239
|
+
* Relational predicates (`{ nav: { $some | $none: … } }`, resolved by the
|
|
240
|
+
* core into `ResolvedRelationFilter` operands) render as correlated
|
|
241
|
+
* `[NOT] EXISTS (…)` subqueries; `opts.qualifier` names the outer table or
|
|
242
|
+
* alias they correlate to (default: the source table — see
|
|
243
|
+
* {@link TFilterVisitorOptions.qualifier}). `opts.columnRef` overrides how
|
|
244
|
+
* the filter's own columns render (e.g. `t."col"` for an aliased FROM).
|
|
245
|
+
* Placeholders stay positional `?` in textual order.
|
|
246
|
+
*
|
|
247
|
+
* @param opts - since 0.1.147
|
|
170
248
|
*/
|
|
171
|
-
function buildWhere(dialect, filter) {
|
|
249
|
+
function buildWhere(dialect, filter, opts) {
|
|
172
250
|
if (!filter || Object.keys(filter).length === 0) return EMPTY_AND;
|
|
173
|
-
return (0, _uniqu_core.walkFilter)(filter,
|
|
251
|
+
return (0, _uniqu_core.walkFilter)(filter, opts || (0, _atscript_db.containsRelationFilter)(filter) ? createFilterVisitor(dialect, {
|
|
252
|
+
...opts,
|
|
253
|
+
aliasSeq: opts?.aliasSeq ?? { n: 0 }
|
|
254
|
+
}) : getVisitor(dialect)) ?? EMPTY_AND;
|
|
174
255
|
}
|
|
175
256
|
//#endregion
|
|
176
257
|
//#region src/common.ts
|
|
@@ -215,6 +296,20 @@ function toSqlValue(value) {
|
|
|
215
296
|
if (typeof value === "boolean") return value ? 1 : 0;
|
|
216
297
|
return value;
|
|
217
298
|
}
|
|
299
|
+
/**
|
|
300
|
+
* `FOREIGN KEY (…) REFERENCES <target> (…)[ ON DELETE …][ ON UPDATE …]` of a
|
|
301
|
+
* foreign key over its physical columns ({@link fkColumns}); the target is
|
|
302
|
+
* schema-qualified when it declares `@db.schema`. `quote` quotes one
|
|
303
|
+
* identifier.
|
|
304
|
+
*/
|
|
305
|
+
function foreignKeySql(quote, fk) {
|
|
306
|
+
const { fields, targetFields } = (0, _atscript_db.fkColumns)(fk);
|
|
307
|
+
const target = fk.targetSchema ? `${quote(fk.targetSchema)}.${quote(fk.targetTable)}` : quote(fk.targetTable);
|
|
308
|
+
let sql = `FOREIGN KEY (${fields.map(quote).join(", ")}) REFERENCES ${target} (${targetFields.map(quote).join(", ")})`;
|
|
309
|
+
if (fk.onDelete) sql += ` ON DELETE ${refActionToSql(fk.onDelete)}`;
|
|
310
|
+
if (fk.onUpdate) sql += ` ON UPDATE ${refActionToSql(fk.onUpdate)}`;
|
|
311
|
+
return sql;
|
|
312
|
+
}
|
|
218
313
|
function refActionToSql(action) {
|
|
219
314
|
switch (action) {
|
|
220
315
|
case "cascade": return "CASCADE";
|
|
@@ -532,6 +627,80 @@ function viewAggExpr(dialect, c, resolveFieldRef) {
|
|
|
532
627
|
return c.aggFn === "sum" ? `COALESCE(${call}, 0)` : call;
|
|
533
628
|
}
|
|
534
629
|
/**
|
|
630
|
+
* The SQL expression of a computed view column (`@db.compute`), evaluated in
|
|
631
|
+
* IEEE double on every dialect: each field and literal leaf is cast with
|
|
632
|
+
* {@link SqlDialect.castDouble} (a computed leaf is already a double and is
|
|
633
|
+
* inlined); `+ - *` render as `(l op r)`, `/` as `(l / NULLIF(r, 0))` —
|
|
634
|
+
* division by zero is NULL — unary minus as `(-x)`, `coalesce` as
|
|
635
|
+
* `COALESCE(…)`. A leaf reads its column like the SELECT list does: an
|
|
636
|
+
* aggregate's call ({@link viewAggExpr}) or a dimension's source
|
|
637
|
+
* ({@link viewSourceExpr}). `byPath` holds the view's mappings by `viewPath`.
|
|
638
|
+
*
|
|
639
|
+
* In a `grouped` view a JSON-extracted dimension leaf reads `MIN(<extract>)`:
|
|
640
|
+
* the extraction is a GROUP BY key, so every row of a group holds the same
|
|
641
|
+
* value, but MySQL's ONLY_FULL_GROUP_BY rejects an expression over the raw
|
|
642
|
+
* JSON column (which is not itself grouped) — the aggregate form is accepted
|
|
643
|
+
* everywhere and yields that same value.
|
|
644
|
+
*
|
|
645
|
+
* @throws when the dialect has no `castDouble`, or a leaf names no column.
|
|
646
|
+
* @since 0.1.147
|
|
647
|
+
*/
|
|
648
|
+
function viewComputeExpr(dialect, c, byPath, resolveFieldRef, grouped = false, cache = /* @__PURE__ */ new Map()) {
|
|
649
|
+
const cached = cache.get(c.viewPath);
|
|
650
|
+
if (cached !== void 0) return cached;
|
|
651
|
+
const cast = dialect.castDouble?.bind(dialect);
|
|
652
|
+
if (!cast) throw new Error(`View column "${c.viewColumn}": computed view columns are not supported by this adapter`);
|
|
653
|
+
const render = (e) => {
|
|
654
|
+
if (typeof e === "number") {
|
|
655
|
+
if (!Number.isFinite(e)) throw new Error(`View column "${c.viewColumn}": non-finite literal in @db.compute`);
|
|
656
|
+
return cast(String(e));
|
|
657
|
+
}
|
|
658
|
+
if ("field" in e) {
|
|
659
|
+
const leaf = byPath.get(e.field);
|
|
660
|
+
if (!leaf) throw new Error(`View column "${c.viewColumn}": "${e.field}" is not a column of the view`);
|
|
661
|
+
if (leaf.expr !== void 0) return viewComputeExpr(dialect, leaf, byPath, resolveFieldRef, grouped, cache);
|
|
662
|
+
if (leaf.aggFn) return cast(viewAggExpr(dialect, leaf, resolveFieldRef));
|
|
663
|
+
const source = viewSourceExpr(dialect, leaf);
|
|
664
|
+
return cast(grouped && leaf.json ? `MIN(${source})` : source);
|
|
665
|
+
}
|
|
666
|
+
const args = e.args.map(render);
|
|
667
|
+
switch (e.op) {
|
|
668
|
+
case "neg": return `(-${args[0]})`;
|
|
669
|
+
case "coalesce": return `COALESCE(${args.join(", ")})`;
|
|
670
|
+
case "/": return `(${args[0]} / NULLIF(${args[1]}, 0))`;
|
|
671
|
+
default: return `(${args[0]} ${e.op} ${args[1]})`;
|
|
672
|
+
}
|
|
673
|
+
};
|
|
674
|
+
const sql = render(c.expr);
|
|
675
|
+
cache.set(c.viewPath, sql);
|
|
676
|
+
return sql;
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* The `ON` clause of a first-row join (`join.first`): the joined row is the
|
|
680
|
+
* one whose primary key equals the key of the FIRST matching target row —
|
|
681
|
+
* a correlated scalar subquery
|
|
682
|
+
* `"T"."id" = (SELECT "T"."id" FROM "table" AS "T" WHERE <condition> ORDER BY … LIMIT 1)`.
|
|
683
|
+
* The inner `FROM` repeats the join's own target / alias text, so the
|
|
684
|
+
* condition's refs to the joined scope bind to the inner row (SQL name
|
|
685
|
+
* shadowing) while refs to the entry and earlier joins stay correlated to
|
|
686
|
+
* the outer query. The ordering ends with the primary key, so the pick is
|
|
687
|
+
* deterministic; NULL sorts first in `asc` (`nullsSortLargest` dialects
|
|
688
|
+
* render `NULLS FIRST` / `NULLS LAST`). No window functions — SQLite, MySQL 8
|
|
689
|
+
* and PostgreSQL render it alike.
|
|
690
|
+
* @since 0.1.147
|
|
691
|
+
*/
|
|
692
|
+
function firstRowOn(dialect, join, target, condition, resolveFieldRef) {
|
|
693
|
+
const first = join.first;
|
|
694
|
+
const key = resolveFieldRef({
|
|
695
|
+
type: join.targetType,
|
|
696
|
+
field: first.key
|
|
697
|
+
});
|
|
698
|
+
return `${key} = (SELECT ${key} FROM ${target} WHERE ${condition} ORDER BY ${first.order.map(({ ref, desc }) => {
|
|
699
|
+
const nulls = dialect.nullsSortLargest ? desc ? " NULLS LAST" : " NULLS FIRST" : "";
|
|
700
|
+
return `${resolveFieldRef(ref)} ${desc ? "DESC" : "ASC"}${nulls}`;
|
|
701
|
+
}).join(", ")} LIMIT 1)`;
|
|
702
|
+
}
|
|
703
|
+
/**
|
|
535
704
|
* Builds a CREATE VIEW statement from a view plan and column mappings.
|
|
536
705
|
*
|
|
537
706
|
* Joins render in declaration order — `JOIN` (inner, the default) or
|
|
@@ -543,35 +712,41 @@ function viewAggExpr(dialect, c, resolveFieldRef) {
|
|
|
543
712
|
* join target may be views.
|
|
544
713
|
*/
|
|
545
714
|
function buildCreateView(dialect, viewName, plan, columns, resolveFieldRef) {
|
|
546
|
-
const
|
|
547
|
-
|
|
548
|
-
|
|
715
|
+
const columnByPath = new Map(columns.map((c) => [c.viewPath, c]));
|
|
716
|
+
const hasAggregates = columns.some((c) => c.aggFn);
|
|
717
|
+
const computedSql = /* @__PURE__ */ new Map();
|
|
718
|
+
/** A column's SELECT expression: computed, aggregate or plain source. */
|
|
719
|
+
const columnExpr = (c) => c.expr !== void 0 ? viewComputeExpr(dialect, c, columnByPath, resolveFieldRef, hasAggregates, computedSql) : c.aggFn ? viewAggExpr(dialect, c, resolveFieldRef) : viewSourceExpr(dialect, c);
|
|
720
|
+
const selectCols = columns.map((c) => `${columnExpr(c)} AS ${dialect.quoteIdentifier(c.viewColumn)}`).join(", ");
|
|
549
721
|
let sql = `${dialect.createViewPrefix} ${dialect.quoteTable(viewName)} AS SELECT ${selectCols} FROM ${dialect.quoteIdentifier(plan.entryTable)}`;
|
|
550
722
|
for (const join of plan.joins) {
|
|
551
|
-
const
|
|
723
|
+
const condition = queryNodeToSql(join.condition, resolveFieldRef);
|
|
552
724
|
const keyword = join.kind === "left" ? "LEFT JOIN" : "JOIN";
|
|
553
725
|
const target = join.scope === join.targetTable ? dialect.quoteIdentifier(join.targetTable) : `${dialect.quoteIdentifier(join.targetTable)} AS ${dialect.quoteIdentifier(join.scope)}`;
|
|
726
|
+
const onClause = join.first ? firstRowOn(dialect, join, target, condition, resolveFieldRef) : condition;
|
|
554
727
|
sql += ` ${keyword} ${target} ON ${onClause}`;
|
|
555
728
|
}
|
|
556
729
|
if (plan.filter) {
|
|
557
730
|
const whereClause = queryNodeToSql(plan.filter, resolveFieldRef);
|
|
558
731
|
sql += ` WHERE ${whereClause}`;
|
|
559
732
|
}
|
|
560
|
-
if (
|
|
561
|
-
const dimensionCols = columns.filter((c) => !c.aggFn);
|
|
733
|
+
if (hasAggregates) {
|
|
734
|
+
const dimensionCols = columns.filter((c) => !c.aggFn && c.expr === void 0);
|
|
562
735
|
if (dimensionCols.length > 0) {
|
|
563
736
|
const groupByCols = dimensionCols.map((c) => viewSourceExpr(dialect, c)).join(", ");
|
|
564
737
|
sql += ` GROUP BY ${groupByCols}`;
|
|
565
738
|
}
|
|
566
739
|
if (plan.having) {
|
|
567
|
-
|
|
568
|
-
|
|
740
|
+
let grouped;
|
|
741
|
+
const groupedColumns = () => grouped ??= new Set(dimensionCols.filter((c) => !c.json).map((c) => c.sourceColumn.toLowerCase()));
|
|
569
742
|
const havingResolver = (ref) => {
|
|
570
743
|
const col = ref.type ? void 0 : columnByPath.get(ref.field);
|
|
571
744
|
if (!col) return resolveFieldRef(ref);
|
|
745
|
+
if (col.expr !== void 0) return columnExpr(col);
|
|
572
746
|
if (col.aggFn) return viewAggExpr(dialect, col, resolveFieldRef);
|
|
573
747
|
const expr = viewSourceExpr(dialect, col);
|
|
574
|
-
|
|
748
|
+
if (!col.json) return expr;
|
|
749
|
+
return dialect.bucketAliasInHaving && groupedColumns().has(col.viewColumn.toLowerCase()) ? `MIN(${expr})` : havingGroupRef(dialect, expr, col.viewColumn);
|
|
575
750
|
};
|
|
576
751
|
const havingClause = queryNodeToSql(plan.having, havingResolver);
|
|
577
752
|
sql += ` HAVING ${havingClause}`;
|
|
@@ -632,11 +807,8 @@ function buildInsertMany(dialect, table, rows, columns = insertManyColumns(rows)
|
|
|
632
807
|
function buildSelect(dialect, table, where, controls) {
|
|
633
808
|
let sql = `SELECT ${buildProjection(dialect, controls?.$select)} FROM ${dialect.quoteTable(table)} WHERE ${where.sql}`;
|
|
634
809
|
const params = [...where.params];
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
for (const [col, dir] of Object.entries(controls.$sort)) orderParts.push(`${dialect.quoteIdentifier(col)} ${dir === -1 ? "DESC" : "ASC"}`);
|
|
638
|
-
if (orderParts.length > 0) sql += ` ORDER BY ${orderParts.join(", ")}`;
|
|
639
|
-
}
|
|
810
|
+
const orderBy = orderByList(dialect, controls?.$sort);
|
|
811
|
+
if (orderBy) sql += ` ORDER BY ${orderBy}`;
|
|
640
812
|
if (controls?.$limit !== void 0) {
|
|
641
813
|
sql += ` LIMIT ?`;
|
|
642
814
|
params.push(controls.$limit);
|
|
@@ -651,6 +823,53 @@ function buildSelect(dialect, table, where, controls) {
|
|
|
651
823
|
params
|
|
652
824
|
});
|
|
653
825
|
}
|
|
826
|
+
/** `"col" ASC, "other" DESC` of a physical `$sort` — `""` when it orders nothing. */
|
|
827
|
+
function orderByList(dialect, sort) {
|
|
828
|
+
if (!sort) return "";
|
|
829
|
+
const parts = [];
|
|
830
|
+
for (const [col, dir] of Object.entries(sort)) parts.push(`${dialect.quoteIdentifier(col)} ${dir === -1 ? "DESC" : "ASC"}`);
|
|
831
|
+
return parts.join(", ");
|
|
832
|
+
}
|
|
833
|
+
/**
|
|
834
|
+
* The row-number column {@link buildPartitionedSelect} adds to every row;
|
|
835
|
+
* {@link stripPartitionRowNumber} removes it from the result rows.
|
|
836
|
+
* @since 0.1.147
|
|
837
|
+
*/
|
|
838
|
+
const PARTITION_ROW_NUMBER_ALIAS = "__atscript_rn";
|
|
839
|
+
/**
|
|
840
|
+
* Builds a SELECT whose `$skip` / `$limit` apply to each partition — the rows
|
|
841
|
+
* sharing the values of the `partitionBy` columns (physical names) — instead
|
|
842
|
+
* of to the whole result: a `ROW_NUMBER() OVER (PARTITION BY … ORDER BY
|
|
843
|
+
* <$sort>)` window in a derived table, filtered on the row number. The rows
|
|
844
|
+
* of each partition come out in `$sort` order (partitions interleave); every
|
|
845
|
+
* row carries {@link PARTITION_ROW_NUMBER_ALIAS}. Window functions need
|
|
846
|
+
* SQLite ≥ 3.25, MySQL ≥ 8.0 or MariaDB ≥ 10.2.
|
|
847
|
+
* @since 0.1.147
|
|
848
|
+
*/
|
|
849
|
+
function buildPartitionedSelect(dialect, table, where, controls, partitionBy) {
|
|
850
|
+
const rn = dialect.quoteIdentifier(PARTITION_ROW_NUMBER_ALIAS);
|
|
851
|
+
const orderBy = orderByList(dialect, controls.$sort);
|
|
852
|
+
const window = `PARTITION BY ${partitionBy.map((col) => dialect.quoteIdentifier(col)).join(", ")}` + (orderBy ? ` ORDER BY ${orderBy}` : "");
|
|
853
|
+
const inner = `SELECT ${buildProjection(dialect, controls.$select)}, ROW_NUMBER() OVER (${window}) AS ${rn} FROM ${dialect.quoteTable(table)} WHERE ${where.sql}`;
|
|
854
|
+
const skip = controls.$skip ?? 0;
|
|
855
|
+
const limit = controls.$limit;
|
|
856
|
+
const params = [...where.params, skip];
|
|
857
|
+
let sql = `SELECT * FROM (${inner}) AS ${dialect.quoteIdentifier("__atscript_p")} WHERE ${rn} > ?`;
|
|
858
|
+
if (limit !== void 0 && limit !== null) {
|
|
859
|
+
sql += ` AND ${rn} <= ?`;
|
|
860
|
+
params.push(skip + limit);
|
|
861
|
+
}
|
|
862
|
+
sql += ` ORDER BY ${rn}`;
|
|
863
|
+
return finalizeParams(dialect, {
|
|
864
|
+
sql,
|
|
865
|
+
params
|
|
866
|
+
});
|
|
867
|
+
}
|
|
868
|
+
/** Removes {@link PARTITION_ROW_NUMBER_ALIAS} from rows read by {@link buildPartitionedSelect}. */
|
|
869
|
+
function stripPartitionRowNumber(rows) {
|
|
870
|
+
for (const row of rows) delete row[PARTITION_ROW_NUMBER_ALIAS];
|
|
871
|
+
return rows;
|
|
872
|
+
}
|
|
654
873
|
/**
|
|
655
874
|
* Marker value for {@link buildUpdate}: the column is assigned its DDL
|
|
656
875
|
* `DEFAULT` (`SET "col" = DEFAULT`, no bound parameter). Produced by
|
|
@@ -810,6 +1029,13 @@ function buildProjection(dialect, select, qualifier) {
|
|
|
810
1029
|
*/
|
|
811
1030
|
const GEO_DISTANCE_ALIAS = "__atscript_distance";
|
|
812
1031
|
/**
|
|
1032
|
+
* Alias of the searched table inside the geo and vector search statements —
|
|
1033
|
+
* a filter rendered into them correlates its relational predicates to it
|
|
1034
|
+
* (`buildWhere(filter, { qualifier: dialect.quoteTable(SEARCH_SOURCE_ALIAS) })`).
|
|
1035
|
+
* @since 0.1.147
|
|
1036
|
+
*/
|
|
1037
|
+
const SEARCH_SOURCE_ALIAS = "t";
|
|
1038
|
+
/**
|
|
813
1039
|
* Builds a distance-ranked geo search SELECT:
|
|
814
1040
|
*
|
|
815
1041
|
* ```sql
|
|
@@ -1032,6 +1258,8 @@ exports.AGG_FN_SQL = AGG_FN_SQL;
|
|
|
1032
1258
|
exports.EMPTY_AND = EMPTY_AND;
|
|
1033
1259
|
exports.EMPTY_OR = EMPTY_OR;
|
|
1034
1260
|
exports.GEO_DISTANCE_ALIAS = GEO_DISTANCE_ALIAS;
|
|
1261
|
+
exports.PARTITION_ROW_NUMBER_ALIAS = PARTITION_ROW_NUMBER_ALIAS;
|
|
1262
|
+
exports.SEARCH_SOURCE_ALIAS = SEARCH_SOURCE_ALIAS;
|
|
1035
1263
|
exports.SQL_DEFAULT = SQL_DEFAULT;
|
|
1036
1264
|
exports.VECTOR_DISTANCE_ALIAS = VECTOR_DISTANCE_ALIAS;
|
|
1037
1265
|
exports.buildAggregateCount = buildAggregateCount;
|
|
@@ -1042,6 +1270,7 @@ exports.buildGeoSearchCount = buildGeoSearchCount;
|
|
|
1042
1270
|
exports.buildGeoSearchSelect = buildGeoSearchSelect;
|
|
1043
1271
|
exports.buildInsert = buildInsert;
|
|
1044
1272
|
exports.buildInsertMany = buildInsertMany;
|
|
1273
|
+
exports.buildPartitionedSelect = buildPartitionedSelect;
|
|
1045
1274
|
exports.buildProjection = buildProjection;
|
|
1046
1275
|
exports.buildSelect = buildSelect;
|
|
1047
1276
|
exports.buildUpdate = buildUpdate;
|
|
@@ -1054,6 +1283,7 @@ exports.defaultValueToSqlLiteral = defaultValueToSqlLiteral;
|
|
|
1054
1283
|
exports.derivedColumnExpr = derivedColumnExpr;
|
|
1055
1284
|
exports.fillReplacePayload = fillReplacePayload;
|
|
1056
1285
|
exports.finalizeParams = finalizeParams;
|
|
1286
|
+
exports.foreignKeySql = foreignKeySql;
|
|
1057
1287
|
exports.geoWindowFromControls = geoWindowFromControls;
|
|
1058
1288
|
exports.groupKeySql = groupKeySql;
|
|
1059
1289
|
exports.insertManyColumns = insertManyColumns;
|
|
@@ -1068,5 +1298,6 @@ exports.renameGeoDistance = renameGeoDistance;
|
|
|
1068
1298
|
exports.replaceColumnsFor = replaceColumnsFor;
|
|
1069
1299
|
exports.sqlStringLiteral = sqlStringLiteral;
|
|
1070
1300
|
exports.sqlTimeZoneLiteral = sqlTimeZoneLiteral;
|
|
1301
|
+
exports.stripPartitionRowNumber = stripPartitionRowNumber;
|
|
1071
1302
|
exports.toSqlValue = toSqlValue;
|
|
1072
1303
|
exports.vectorDistanceSource = vectorDistanceSource;
|
package/dist/index.d.cts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AtscriptQueryFieldRef, AtscriptQueryNode, DbControls, TDbDefaultFn, TDbFieldMeta, TDbReferentialAction, TFieldOps, TResolvedBucket, TViewColumnMapping, TViewJsonType, TViewPlan, UniquSelect } from "@atscript/db";
|
|
1
|
+
import { AtscriptQueryFieldRef, AtscriptQueryNode, DbControls, TDbDefaultFn, TDbFieldMeta, TDbForeignKey, TDbReferentialAction, TFieldOps, TResolvedBucket, TViewColumnMapping, TViewJsonType, TViewPlan, UniquSelect } from "@atscript/db";
|
|
2
2
|
import { FilterExpr, FilterVisitor } from "@uniqu/core";
|
|
3
3
|
import { TDbAggregateFn } from "@atscript/db/agg";
|
|
4
4
|
|
|
@@ -34,8 +34,10 @@ interface SqlDialect {
|
|
|
34
34
|
geoWithin?(quotedCol: string, circle: TGeoCircle): TSqlFragment;
|
|
35
35
|
/**
|
|
36
36
|
* Calendar-bucket label expression over one column: TEXT `'YYYY-MM-DD'`
|
|
37
|
-
* (the local calendar date of the bucket's first day in `b.tz
|
|
38
|
-
*
|
|
37
|
+
* (the local calendar date of the bucket's first day in `b.tz`; for unit
|
|
38
|
+
* `hour`, `'YYYY-MM-DDTHH:00'`, the local wall-clock hour — truncate the
|
|
39
|
+
* zone's wall time, never the UTC instant), or NULL for a NULL source or
|
|
40
|
+
* one outside `[BUCKET_MIN_INSTANT, BUCKET_MAX_INSTANT)`.
|
|
39
41
|
* `quotedCol` is already quoted; `b.fd` identifies the storage kind.
|
|
40
42
|
*
|
|
41
43
|
* The expression must be PARAMETER-FREE — inline the zone with
|
|
@@ -83,6 +85,24 @@ interface SqlDialect {
|
|
|
83
85
|
* @since 0.1.136
|
|
84
86
|
*/
|
|
85
87
|
jsonExtract?(quotedCol: string, path: readonly string[], type: TViewJsonType): string;
|
|
88
|
+
/**
|
|
89
|
+
* `expr` cast to an IEEE double — how a computed view column
|
|
90
|
+
* (`@db.compute`) evaluates every field / literal leaf, so `7 / 2 = 3.5`
|
|
91
|
+
* everywhere (no integer division, no DECIMAL rounding): SQLite
|
|
92
|
+
* `CAST(x AS REAL)`, MySQL `CAST(x AS DOUBLE)`, PostgreSQL
|
|
93
|
+
* `CAST(x AS DOUBLE PRECISION)`. Parameter-free. Dialects without it fail
|
|
94
|
+
* view sync with `computed view columns are not supported by this adapter`.
|
|
95
|
+
* @since 0.1.147
|
|
96
|
+
*/
|
|
97
|
+
castDouble?(expr: string): string;
|
|
98
|
+
/**
|
|
99
|
+
* `true` when the database sorts NULL as the LARGEST value (PostgreSQL):
|
|
100
|
+
* first-row join order keys then render `ASC NULLS FIRST` /
|
|
101
|
+
* `DESC NULLS LAST`, keeping the uniform "NULL is the smallest value"
|
|
102
|
+
* ordering SQLite, MySQL and MongoDB have natively.
|
|
103
|
+
* @since 0.1.147
|
|
104
|
+
*/
|
|
105
|
+
nullsSortLargest?: boolean;
|
|
86
106
|
/** e.g. 'CREATE VIEW IF NOT EXISTS' or 'CREATE OR REPLACE VIEW' */
|
|
87
107
|
createViewPrefix: string;
|
|
88
108
|
/** Returns a parameter placeholder for the given 1-based index. When absent, '?' is used. */
|
|
@@ -117,6 +137,35 @@ interface TFilterVisitorOptions {
|
|
|
117
137
|
* expression (`SUM("amount")`) — PostgreSQL rejects SELECT aliases in HAVING.
|
|
118
138
|
*/
|
|
119
139
|
columnRef?: (field: string) => string;
|
|
140
|
+
/**
|
|
141
|
+
* The quoted outer table or alias that relational predicates
|
|
142
|
+
* (`{ nav: { $some | $none: … } }`) correlate to: their `EXISTS` subqueries
|
|
143
|
+
* compare the related rows with `<qualifier>."<column>"`. Defaults to the
|
|
144
|
+
* predicate's source table (`dialect.quoteTable(node.source.table)`), which
|
|
145
|
+
* is right for every statement whose FROM is the bare table; a statement
|
|
146
|
+
* that aliases its FROM (`FROM "items" AS "t"`) must pass that alias here.
|
|
147
|
+
*
|
|
148
|
+
* @since 0.1.147
|
|
149
|
+
*/
|
|
150
|
+
qualifier?: string;
|
|
151
|
+
/**
|
|
152
|
+
* Alias sequence of the relational-predicate subqueries (`_rf1`, `_rf2`, …),
|
|
153
|
+
* shared by the visitors of one statement so nested predicates get distinct
|
|
154
|
+
* aliases. A visitor created without one starts its own.
|
|
155
|
+
*
|
|
156
|
+
* @internal
|
|
157
|
+
* @since 0.1.147
|
|
158
|
+
*/
|
|
159
|
+
aliasSeq?: TRelationAliasSeq;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Alias counter of the relational-predicate subqueries of one statement.
|
|
163
|
+
*
|
|
164
|
+
* @internal
|
|
165
|
+
* @since 0.1.147
|
|
166
|
+
*/
|
|
167
|
+
interface TRelationAliasSeq {
|
|
168
|
+
n: number;
|
|
120
169
|
}
|
|
121
170
|
/**
|
|
122
171
|
* Creates a dialect-specific filter visitor for `walkFilter`.
|
|
@@ -124,8 +173,18 @@ interface TFilterVisitorOptions {
|
|
|
124
173
|
declare function createFilterVisitor(dialect: SqlDialect, options?: TFilterVisitorOptions): FilterVisitor<TSqlFragment>;
|
|
125
174
|
/**
|
|
126
175
|
* Translates a filter expression into a parameterized SQL WHERE clause.
|
|
176
|
+
*
|
|
177
|
+
* Relational predicates (`{ nav: { $some | $none: … } }`, resolved by the
|
|
178
|
+
* core into `ResolvedRelationFilter` operands) render as correlated
|
|
179
|
+
* `[NOT] EXISTS (…)` subqueries; `opts.qualifier` names the outer table or
|
|
180
|
+
* alias they correlate to (default: the source table — see
|
|
181
|
+
* {@link TFilterVisitorOptions.qualifier}). `opts.columnRef` overrides how
|
|
182
|
+
* the filter's own columns render (e.g. `t."col"` for an aliased FROM).
|
|
183
|
+
* Placeholders stay positional `?` in textual order.
|
|
184
|
+
*
|
|
185
|
+
* @param opts - since 0.1.147
|
|
127
186
|
*/
|
|
128
|
-
declare function buildWhere(dialect: SqlDialect, filter: FilterExpr): TSqlFragment;
|
|
187
|
+
declare function buildWhere(dialect: SqlDialect, filter: FilterExpr, opts?: TFilterVisitorOptions): TSqlFragment;
|
|
129
188
|
//#endregion
|
|
130
189
|
//#region src/geo.d.ts
|
|
131
190
|
/**
|
|
@@ -135,6 +194,13 @@ declare function buildWhere(dialect: SqlDialect, filter: FilterExpr): TSqlFragme
|
|
|
135
194
|
* across dialects, so the public name can't be used directly.
|
|
136
195
|
*/
|
|
137
196
|
declare const GEO_DISTANCE_ALIAS = "__atscript_distance";
|
|
197
|
+
/**
|
|
198
|
+
* Alias of the searched table inside the geo and vector search statements —
|
|
199
|
+
* a filter rendered into them correlates its relational predicates to it
|
|
200
|
+
* (`buildWhere(filter, { qualifier: dialect.quoteTable(SEARCH_SOURCE_ALIAS) })`).
|
|
201
|
+
* @since 0.1.147
|
|
202
|
+
*/
|
|
203
|
+
declare const SEARCH_SOURCE_ALIAS = "t";
|
|
138
204
|
/** The query controls a geo search page reads — an adapter passes its `query.controls` as-is. */
|
|
139
205
|
interface TGeoSearchControls {
|
|
140
206
|
$limit?: number;
|
|
@@ -272,6 +338,25 @@ declare function buildInsertMany(dialect: SqlDialect, table: string, rows: reado
|
|
|
272
338
|
* Builds a SELECT statement with optional sort, limit, offset, projection.
|
|
273
339
|
*/
|
|
274
340
|
declare function buildSelect(dialect: SqlDialect, table: string, where: TSqlFragment, controls?: DbControls): TSqlFragment;
|
|
341
|
+
/**
|
|
342
|
+
* The row-number column {@link buildPartitionedSelect} adds to every row;
|
|
343
|
+
* {@link stripPartitionRowNumber} removes it from the result rows.
|
|
344
|
+
* @since 0.1.147
|
|
345
|
+
*/
|
|
346
|
+
declare const PARTITION_ROW_NUMBER_ALIAS = "__atscript_rn";
|
|
347
|
+
/**
|
|
348
|
+
* Builds a SELECT whose `$skip` / `$limit` apply to each partition — the rows
|
|
349
|
+
* sharing the values of the `partitionBy` columns (physical names) — instead
|
|
350
|
+
* of to the whole result: a `ROW_NUMBER() OVER (PARTITION BY … ORDER BY
|
|
351
|
+
* <$sort>)` window in a derived table, filtered on the row number. The rows
|
|
352
|
+
* of each partition come out in `$sort` order (partitions interleave); every
|
|
353
|
+
* row carries {@link PARTITION_ROW_NUMBER_ALIAS}. Window functions need
|
|
354
|
+
* SQLite ≥ 3.25, MySQL ≥ 8.0 or MariaDB ≥ 10.2.
|
|
355
|
+
* @since 0.1.147
|
|
356
|
+
*/
|
|
357
|
+
declare function buildPartitionedSelect(dialect: SqlDialect, table: string, where: TSqlFragment, controls: DbControls, partitionBy: readonly string[]): TSqlFragment;
|
|
358
|
+
/** Removes {@link PARTITION_ROW_NUMBER_ALIAS} from rows read by {@link buildPartitionedSelect}. */
|
|
359
|
+
declare function stripPartitionRowNumber<R extends Record<string, unknown>>(rows: R[]): R[];
|
|
275
360
|
/**
|
|
276
361
|
* Marker value for {@link buildUpdate}: the column is assigned its DDL
|
|
277
362
|
* `DEFAULT` (`SET "col" = DEFAULT`, no bound parameter). Produced by
|
|
@@ -376,6 +461,13 @@ declare function jsonDollarPath(path: readonly string[]): string;
|
|
|
376
461
|
declare function sqlTimeZoneLiteral(tz: string): string;
|
|
377
462
|
/** Converts a JS value to a SQL-bindable parameter. Objects/arrays -> JSON, booleans -> 0/1. */
|
|
378
463
|
declare function toSqlValue(value: unknown): unknown;
|
|
464
|
+
/**
|
|
465
|
+
* `FOREIGN KEY (…) REFERENCES <target> (…)[ ON DELETE …][ ON UPDATE …]` of a
|
|
466
|
+
* foreign key over its physical columns ({@link fkColumns}); the target is
|
|
467
|
+
* schema-qualified when it declares `@db.schema`. `quote` quotes one
|
|
468
|
+
* identifier.
|
|
469
|
+
*/
|
|
470
|
+
declare function foreignKeySql(quote: (name: string) => string, fk: TDbForeignKey): string;
|
|
379
471
|
declare function refActionToSql(action: TDbReferentialAction): string;
|
|
380
472
|
/** Returns a safe SQL DEFAULT literal for a given design type. */
|
|
381
473
|
declare function defaultValueForType(designType: string): string;
|
|
@@ -446,4 +538,4 @@ declare function parseRegexString(value: unknown): {
|
|
|
446
538
|
flags: string;
|
|
447
539
|
};
|
|
448
540
|
//#endregion
|
|
449
|
-
export { AGG_FN_SQL, EMPTY_AND, EMPTY_OR, GEO_DISTANCE_ALIAS, SQL_DEFAULT, type SqlDialect, type TFilterVisitorOptions, type TGeoCircle, type TGeoSearchControls, type TGeoWindow, type TReplaceColumn, type TSqlFragment, VECTOR_DISTANCE_ALIAS, buildAggregateCount, buildAggregateSelect, buildCreateView, buildDelete, buildGeoSearchCount, buildGeoSearchSelect, buildInsert, buildInsertMany, buildProjection, buildSelect, buildUpdate, buildVectorSearchCount, buildVectorSearchSelect, buildWhere, createFilterVisitor, defaultValueForType, defaultValueToSqlLiteral, derivedColumnExpr, fillReplacePayload, finalizeParams, geoWindowFromControls, groupKeySql, insertManyColumns, jsonDollarPath, normalizeGeoPointValue, parseRegexString, queryNodeToSql, queryOpToSql, quotedJsonPathSegments, refActionToSql, renameGeoDistance, replaceColumnsFor, sqlStringLiteral, sqlTimeZoneLiteral, toSqlValue, vectorDistanceSource };
|
|
541
|
+
export { AGG_FN_SQL, EMPTY_AND, EMPTY_OR, GEO_DISTANCE_ALIAS, PARTITION_ROW_NUMBER_ALIAS, SEARCH_SOURCE_ALIAS, SQL_DEFAULT, type SqlDialect, type TFilterVisitorOptions, type TGeoCircle, type TGeoSearchControls, type TGeoWindow, type TReplaceColumn, type TSqlFragment, VECTOR_DISTANCE_ALIAS, buildAggregateCount, buildAggregateSelect, buildCreateView, buildDelete, buildGeoSearchCount, buildGeoSearchSelect, buildInsert, buildInsertMany, buildPartitionedSelect, buildProjection, buildSelect, buildUpdate, buildVectorSearchCount, buildVectorSearchSelect, buildWhere, createFilterVisitor, defaultValueForType, defaultValueToSqlLiteral, derivedColumnExpr, fillReplacePayload, finalizeParams, foreignKeySql, geoWindowFromControls, groupKeySql, insertManyColumns, jsonDollarPath, normalizeGeoPointValue, parseRegexString, queryNodeToSql, queryOpToSql, quotedJsonPathSegments, refActionToSql, renameGeoDistance, replaceColumnsFor, sqlStringLiteral, sqlTimeZoneLiteral, stripPartitionRowNumber, toSqlValue, vectorDistanceSource };
|
package/dist/index.d.mts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { FilterExpr, FilterVisitor } from "@uniqu/core";
|
|
2
|
-
import { AtscriptQueryFieldRef, AtscriptQueryNode, DbControls, TDbDefaultFn, TDbFieldMeta, TDbReferentialAction, TFieldOps, TResolvedBucket, TViewColumnMapping, TViewJsonType, TViewPlan, UniquSelect } from "@atscript/db";
|
|
2
|
+
import { AtscriptQueryFieldRef, AtscriptQueryNode, DbControls, TDbDefaultFn, TDbFieldMeta, TDbForeignKey, TDbReferentialAction, TFieldOps, TResolvedBucket, TViewColumnMapping, TViewJsonType, TViewPlan, UniquSelect } from "@atscript/db";
|
|
3
3
|
import { TDbAggregateFn } from "@atscript/db/agg";
|
|
4
4
|
|
|
5
5
|
//#region src/dialect.d.ts
|
|
@@ -34,8 +34,10 @@ interface SqlDialect {
|
|
|
34
34
|
geoWithin?(quotedCol: string, circle: TGeoCircle): TSqlFragment;
|
|
35
35
|
/**
|
|
36
36
|
* Calendar-bucket label expression over one column: TEXT `'YYYY-MM-DD'`
|
|
37
|
-
* (the local calendar date of the bucket's first day in `b.tz
|
|
38
|
-
*
|
|
37
|
+
* (the local calendar date of the bucket's first day in `b.tz`; for unit
|
|
38
|
+
* `hour`, `'YYYY-MM-DDTHH:00'`, the local wall-clock hour — truncate the
|
|
39
|
+
* zone's wall time, never the UTC instant), or NULL for a NULL source or
|
|
40
|
+
* one outside `[BUCKET_MIN_INSTANT, BUCKET_MAX_INSTANT)`.
|
|
39
41
|
* `quotedCol` is already quoted; `b.fd` identifies the storage kind.
|
|
40
42
|
*
|
|
41
43
|
* The expression must be PARAMETER-FREE — inline the zone with
|
|
@@ -83,6 +85,24 @@ interface SqlDialect {
|
|
|
83
85
|
* @since 0.1.136
|
|
84
86
|
*/
|
|
85
87
|
jsonExtract?(quotedCol: string, path: readonly string[], type: TViewJsonType): string;
|
|
88
|
+
/**
|
|
89
|
+
* `expr` cast to an IEEE double — how a computed view column
|
|
90
|
+
* (`@db.compute`) evaluates every field / literal leaf, so `7 / 2 = 3.5`
|
|
91
|
+
* everywhere (no integer division, no DECIMAL rounding): SQLite
|
|
92
|
+
* `CAST(x AS REAL)`, MySQL `CAST(x AS DOUBLE)`, PostgreSQL
|
|
93
|
+
* `CAST(x AS DOUBLE PRECISION)`. Parameter-free. Dialects without it fail
|
|
94
|
+
* view sync with `computed view columns are not supported by this adapter`.
|
|
95
|
+
* @since 0.1.147
|
|
96
|
+
*/
|
|
97
|
+
castDouble?(expr: string): string;
|
|
98
|
+
/**
|
|
99
|
+
* `true` when the database sorts NULL as the LARGEST value (PostgreSQL):
|
|
100
|
+
* first-row join order keys then render `ASC NULLS FIRST` /
|
|
101
|
+
* `DESC NULLS LAST`, keeping the uniform "NULL is the smallest value"
|
|
102
|
+
* ordering SQLite, MySQL and MongoDB have natively.
|
|
103
|
+
* @since 0.1.147
|
|
104
|
+
*/
|
|
105
|
+
nullsSortLargest?: boolean;
|
|
86
106
|
/** e.g. 'CREATE VIEW IF NOT EXISTS' or 'CREATE OR REPLACE VIEW' */
|
|
87
107
|
createViewPrefix: string;
|
|
88
108
|
/** Returns a parameter placeholder for the given 1-based index. When absent, '?' is used. */
|
|
@@ -117,6 +137,35 @@ interface TFilterVisitorOptions {
|
|
|
117
137
|
* expression (`SUM("amount")`) — PostgreSQL rejects SELECT aliases in HAVING.
|
|
118
138
|
*/
|
|
119
139
|
columnRef?: (field: string) => string;
|
|
140
|
+
/**
|
|
141
|
+
* The quoted outer table or alias that relational predicates
|
|
142
|
+
* (`{ nav: { $some | $none: … } }`) correlate to: their `EXISTS` subqueries
|
|
143
|
+
* compare the related rows with `<qualifier>."<column>"`. Defaults to the
|
|
144
|
+
* predicate's source table (`dialect.quoteTable(node.source.table)`), which
|
|
145
|
+
* is right for every statement whose FROM is the bare table; a statement
|
|
146
|
+
* that aliases its FROM (`FROM "items" AS "t"`) must pass that alias here.
|
|
147
|
+
*
|
|
148
|
+
* @since 0.1.147
|
|
149
|
+
*/
|
|
150
|
+
qualifier?: string;
|
|
151
|
+
/**
|
|
152
|
+
* Alias sequence of the relational-predicate subqueries (`_rf1`, `_rf2`, …),
|
|
153
|
+
* shared by the visitors of one statement so nested predicates get distinct
|
|
154
|
+
* aliases. A visitor created without one starts its own.
|
|
155
|
+
*
|
|
156
|
+
* @internal
|
|
157
|
+
* @since 0.1.147
|
|
158
|
+
*/
|
|
159
|
+
aliasSeq?: TRelationAliasSeq;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Alias counter of the relational-predicate subqueries of one statement.
|
|
163
|
+
*
|
|
164
|
+
* @internal
|
|
165
|
+
* @since 0.1.147
|
|
166
|
+
*/
|
|
167
|
+
interface TRelationAliasSeq {
|
|
168
|
+
n: number;
|
|
120
169
|
}
|
|
121
170
|
/**
|
|
122
171
|
* Creates a dialect-specific filter visitor for `walkFilter`.
|
|
@@ -124,8 +173,18 @@ interface TFilterVisitorOptions {
|
|
|
124
173
|
declare function createFilterVisitor(dialect: SqlDialect, options?: TFilterVisitorOptions): FilterVisitor<TSqlFragment>;
|
|
125
174
|
/**
|
|
126
175
|
* Translates a filter expression into a parameterized SQL WHERE clause.
|
|
176
|
+
*
|
|
177
|
+
* Relational predicates (`{ nav: { $some | $none: … } }`, resolved by the
|
|
178
|
+
* core into `ResolvedRelationFilter` operands) render as correlated
|
|
179
|
+
* `[NOT] EXISTS (…)` subqueries; `opts.qualifier` names the outer table or
|
|
180
|
+
* alias they correlate to (default: the source table — see
|
|
181
|
+
* {@link TFilterVisitorOptions.qualifier}). `opts.columnRef` overrides how
|
|
182
|
+
* the filter's own columns render (e.g. `t."col"` for an aliased FROM).
|
|
183
|
+
* Placeholders stay positional `?` in textual order.
|
|
184
|
+
*
|
|
185
|
+
* @param opts - since 0.1.147
|
|
127
186
|
*/
|
|
128
|
-
declare function buildWhere(dialect: SqlDialect, filter: FilterExpr): TSqlFragment;
|
|
187
|
+
declare function buildWhere(dialect: SqlDialect, filter: FilterExpr, opts?: TFilterVisitorOptions): TSqlFragment;
|
|
129
188
|
//#endregion
|
|
130
189
|
//#region src/geo.d.ts
|
|
131
190
|
/**
|
|
@@ -135,6 +194,13 @@ declare function buildWhere(dialect: SqlDialect, filter: FilterExpr): TSqlFragme
|
|
|
135
194
|
* across dialects, so the public name can't be used directly.
|
|
136
195
|
*/
|
|
137
196
|
declare const GEO_DISTANCE_ALIAS = "__atscript_distance";
|
|
197
|
+
/**
|
|
198
|
+
* Alias of the searched table inside the geo and vector search statements —
|
|
199
|
+
* a filter rendered into them correlates its relational predicates to it
|
|
200
|
+
* (`buildWhere(filter, { qualifier: dialect.quoteTable(SEARCH_SOURCE_ALIAS) })`).
|
|
201
|
+
* @since 0.1.147
|
|
202
|
+
*/
|
|
203
|
+
declare const SEARCH_SOURCE_ALIAS = "t";
|
|
138
204
|
/** The query controls a geo search page reads — an adapter passes its `query.controls` as-is. */
|
|
139
205
|
interface TGeoSearchControls {
|
|
140
206
|
$limit?: number;
|
|
@@ -272,6 +338,25 @@ declare function buildInsertMany(dialect: SqlDialect, table: string, rows: reado
|
|
|
272
338
|
* Builds a SELECT statement with optional sort, limit, offset, projection.
|
|
273
339
|
*/
|
|
274
340
|
declare function buildSelect(dialect: SqlDialect, table: string, where: TSqlFragment, controls?: DbControls): TSqlFragment;
|
|
341
|
+
/**
|
|
342
|
+
* The row-number column {@link buildPartitionedSelect} adds to every row;
|
|
343
|
+
* {@link stripPartitionRowNumber} removes it from the result rows.
|
|
344
|
+
* @since 0.1.147
|
|
345
|
+
*/
|
|
346
|
+
declare const PARTITION_ROW_NUMBER_ALIAS = "__atscript_rn";
|
|
347
|
+
/**
|
|
348
|
+
* Builds a SELECT whose `$skip` / `$limit` apply to each partition — the rows
|
|
349
|
+
* sharing the values of the `partitionBy` columns (physical names) — instead
|
|
350
|
+
* of to the whole result: a `ROW_NUMBER() OVER (PARTITION BY … ORDER BY
|
|
351
|
+
* <$sort>)` window in a derived table, filtered on the row number. The rows
|
|
352
|
+
* of each partition come out in `$sort` order (partitions interleave); every
|
|
353
|
+
* row carries {@link PARTITION_ROW_NUMBER_ALIAS}. Window functions need
|
|
354
|
+
* SQLite ≥ 3.25, MySQL ≥ 8.0 or MariaDB ≥ 10.2.
|
|
355
|
+
* @since 0.1.147
|
|
356
|
+
*/
|
|
357
|
+
declare function buildPartitionedSelect(dialect: SqlDialect, table: string, where: TSqlFragment, controls: DbControls, partitionBy: readonly string[]): TSqlFragment;
|
|
358
|
+
/** Removes {@link PARTITION_ROW_NUMBER_ALIAS} from rows read by {@link buildPartitionedSelect}. */
|
|
359
|
+
declare function stripPartitionRowNumber<R extends Record<string, unknown>>(rows: R[]): R[];
|
|
275
360
|
/**
|
|
276
361
|
* Marker value for {@link buildUpdate}: the column is assigned its DDL
|
|
277
362
|
* `DEFAULT` (`SET "col" = DEFAULT`, no bound parameter). Produced by
|
|
@@ -376,6 +461,13 @@ declare function jsonDollarPath(path: readonly string[]): string;
|
|
|
376
461
|
declare function sqlTimeZoneLiteral(tz: string): string;
|
|
377
462
|
/** Converts a JS value to a SQL-bindable parameter. Objects/arrays -> JSON, booleans -> 0/1. */
|
|
378
463
|
declare function toSqlValue(value: unknown): unknown;
|
|
464
|
+
/**
|
|
465
|
+
* `FOREIGN KEY (…) REFERENCES <target> (…)[ ON DELETE …][ ON UPDATE …]` of a
|
|
466
|
+
* foreign key over its physical columns ({@link fkColumns}); the target is
|
|
467
|
+
* schema-qualified when it declares `@db.schema`. `quote` quotes one
|
|
468
|
+
* identifier.
|
|
469
|
+
*/
|
|
470
|
+
declare function foreignKeySql(quote: (name: string) => string, fk: TDbForeignKey): string;
|
|
379
471
|
declare function refActionToSql(action: TDbReferentialAction): string;
|
|
380
472
|
/** Returns a safe SQL DEFAULT literal for a given design type. */
|
|
381
473
|
declare function defaultValueForType(designType: string): string;
|
|
@@ -446,4 +538,4 @@ declare function parseRegexString(value: unknown): {
|
|
|
446
538
|
flags: string;
|
|
447
539
|
};
|
|
448
540
|
//#endregion
|
|
449
|
-
export { AGG_FN_SQL, EMPTY_AND, EMPTY_OR, GEO_DISTANCE_ALIAS, SQL_DEFAULT, type SqlDialect, type TFilterVisitorOptions, type TGeoCircle, type TGeoSearchControls, type TGeoWindow, type TReplaceColumn, type TSqlFragment, VECTOR_DISTANCE_ALIAS, buildAggregateCount, buildAggregateSelect, buildCreateView, buildDelete, buildGeoSearchCount, buildGeoSearchSelect, buildInsert, buildInsertMany, buildProjection, buildSelect, buildUpdate, buildVectorSearchCount, buildVectorSearchSelect, buildWhere, createFilterVisitor, defaultValueForType, defaultValueToSqlLiteral, derivedColumnExpr, fillReplacePayload, finalizeParams, geoWindowFromControls, groupKeySql, insertManyColumns, jsonDollarPath, normalizeGeoPointValue, parseRegexString, queryNodeToSql, queryOpToSql, quotedJsonPathSegments, refActionToSql, renameGeoDistance, replaceColumnsFor, sqlStringLiteral, sqlTimeZoneLiteral, toSqlValue, vectorDistanceSource };
|
|
541
|
+
export { AGG_FN_SQL, EMPTY_AND, EMPTY_OR, GEO_DISTANCE_ALIAS, PARTITION_ROW_NUMBER_ALIAS, SEARCH_SOURCE_ALIAS, SQL_DEFAULT, type SqlDialect, type TFilterVisitorOptions, type TGeoCircle, type TGeoSearchControls, type TGeoWindow, type TReplaceColumn, type TSqlFragment, VECTOR_DISTANCE_ALIAS, buildAggregateCount, buildAggregateSelect, buildCreateView, buildDelete, buildGeoSearchCount, buildGeoSearchSelect, buildInsert, buildInsertMany, buildPartitionedSelect, buildProjection, buildSelect, buildUpdate, buildVectorSearchCount, buildVectorSearchSelect, buildWhere, createFilterVisitor, defaultValueForType, defaultValueToSqlLiteral, derivedColumnExpr, fillReplacePayload, finalizeParams, foreignKeySql, geoWindowFromControls, groupKeySql, insertManyColumns, jsonDollarPath, normalizeGeoPointValue, parseRegexString, queryNodeToSql, queryOpToSql, quotedJsonPathSegments, refActionToSql, renameGeoDistance, replaceColumnsFor, sqlStringLiteral, sqlTimeZoneLiteral, stripPartitionRowNumber, toSqlValue, vectorDistanceSource };
|
package/dist/index.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { BUCKET_UNITS, TIME_ZONE_NAME_RE, WEEK_STARTS, walkFilter } from "@uniqu/core";
|
|
2
|
-
import { DbError, isFieldRef } from "@atscript/db";
|
|
2
|
+
import { DbError, containsRelationFilter, fkColumns, isFieldRef, isResolvedRelationFilter } from "@atscript/db";
|
|
3
3
|
import { assertAggregateFn, resolveAlias } from "@atscript/db/agg";
|
|
4
4
|
//#region src/dialect.ts
|
|
5
5
|
/**
|
|
@@ -60,6 +60,8 @@ const EMPTY_OR = {
|
|
|
60
60
|
*/
|
|
61
61
|
function createFilterVisitor(dialect, options) {
|
|
62
62
|
const columnRef = options?.columnRef ?? ((field) => dialect.quoteIdentifier(field));
|
|
63
|
+
const qualifier = options?.qualifier;
|
|
64
|
+
const aliasSeq = options?.aliasSeq ?? { n: 0 };
|
|
63
65
|
return {
|
|
64
66
|
comparison(field, op, value) {
|
|
65
67
|
if (op === "$geoWithin") {
|
|
@@ -152,9 +154,75 @@ function createFilterVisitor(dialect, options) {
|
|
|
152
154
|
sql: `NOT (${child.sql})`,
|
|
153
155
|
params: child.params
|
|
154
156
|
};
|
|
157
|
+
},
|
|
158
|
+
relation(field, op, operand) {
|
|
159
|
+
if (!isResolvedRelationFilter(operand)) throw new DbError("REL_FILTER_NOT_SUPPORTED", [{
|
|
160
|
+
path: field,
|
|
161
|
+
message: `Relational predicate "${op}" on "${field}" reached the SQL renderer unresolved`
|
|
162
|
+
}]);
|
|
163
|
+
return renderRelation(dialect, op, operand, qualifier ?? dialect.quoteTable(operand.source.table), aliasSeq);
|
|
155
164
|
}
|
|
156
165
|
};
|
|
157
166
|
}
|
|
167
|
+
/**
|
|
168
|
+
* Renders one resolved relational predicate as a correlated `EXISTS`:
|
|
169
|
+
*
|
|
170
|
+
* ```sql
|
|
171
|
+
* -- to / from
|
|
172
|
+
* EXISTS (SELECT 1 FROM <target> AS "_rf1"
|
|
173
|
+
* WHERE "_rf1"."<pair.target>" = <outer>."<pair.source>" [AND …] [AND <inner>])
|
|
174
|
+
* -- via
|
|
175
|
+
* EXISTS (SELECT 1 FROM <junction> AS "_rf1" JOIN <target> AS "_rf2"
|
|
176
|
+
* ON "_rf2"."<toTarget.target>" = "_rf1"."<toTarget.junction>" [AND …]
|
|
177
|
+
* WHERE "_rf1"."<toSource.junction>" = <outer>."<toSource.source>" [AND …]
|
|
178
|
+
* [AND <junction filter on _rf1>] [AND <inner on _rf2>])
|
|
179
|
+
* ```
|
|
180
|
+
*
|
|
181
|
+
* `$none` is `NOT EXISTS (…)`. Outer columns are always qualified (a bare
|
|
182
|
+
* column would bind to the subquery's table on a self relation or a
|
|
183
|
+
* same-named column). A NULL foreign-key component never satisfies `=`, so
|
|
184
|
+
* such a row has no related row (`$some` false, `$none` true).
|
|
185
|
+
*/
|
|
186
|
+
function renderRelation(dialect, op, node, outer, aliasSeq) {
|
|
187
|
+
const q = (name) => dialect.quoteIdentifier(name);
|
|
188
|
+
const nextAlias = () => q(`_rf${++aliasSeq.n}`);
|
|
189
|
+
const conditions = [];
|
|
190
|
+
const params = [];
|
|
191
|
+
const addFilter = (filter, alias) => {
|
|
192
|
+
if (!filter || Object.keys(filter).length === 0) return;
|
|
193
|
+
const fragment = walkFilter(filter, createFilterVisitor(dialect, {
|
|
194
|
+
columnRef: (field) => `${alias}.${q(field)}`,
|
|
195
|
+
qualifier: alias,
|
|
196
|
+
aliasSeq
|
|
197
|
+
}));
|
|
198
|
+
if (fragment) {
|
|
199
|
+
conditions.push(fragment.sql);
|
|
200
|
+
params.push(...fragment.params);
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
let from;
|
|
204
|
+
if (node.kind === "via") {
|
|
205
|
+
const junction = node.junction;
|
|
206
|
+
if (!junction) throw new Error(`Relational predicate on "${node.nav}": a "via" relation needs a junction`);
|
|
207
|
+
const j = nextAlias();
|
|
208
|
+
const t = nextAlias();
|
|
209
|
+
const on = junction.toTarget.map((p) => `${t}.${q(p.target)} = ${j}.${q(p.junction)}`);
|
|
210
|
+
from = `${dialect.quoteTable(junction.table)} AS ${j} JOIN ${dialect.quoteTable(node.target.table)} AS ${t} ON ${on.join(" AND ")}`;
|
|
211
|
+
for (const p of junction.toSource) conditions.push(`${j}.${q(p.junction)} = ${outer}.${q(p.source)}`);
|
|
212
|
+
addFilter(junction.filter, j);
|
|
213
|
+
addFilter(node.filter, t);
|
|
214
|
+
} else {
|
|
215
|
+
const t = nextAlias();
|
|
216
|
+
from = `${dialect.quoteTable(node.target.table)} AS ${t}`;
|
|
217
|
+
for (const p of node.pairs) conditions.push(`${t}.${q(p.target)} = ${outer}.${q(p.source)}`);
|
|
218
|
+
addFilter(node.filter, t);
|
|
219
|
+
}
|
|
220
|
+
const where = conditions.length > 0 ? ` WHERE ${conditions.join(" AND ")}` : "";
|
|
221
|
+
return {
|
|
222
|
+
sql: `${op === "$none" ? "NOT " : ""}EXISTS (SELECT 1 FROM ${from}${where})`,
|
|
223
|
+
params
|
|
224
|
+
};
|
|
225
|
+
}
|
|
158
226
|
const visitorCache = /* @__PURE__ */ new WeakMap();
|
|
159
227
|
function getVisitor(dialect) {
|
|
160
228
|
let visitor = visitorCache.get(dialect);
|
|
@@ -166,10 +234,23 @@ function getVisitor(dialect) {
|
|
|
166
234
|
}
|
|
167
235
|
/**
|
|
168
236
|
* Translates a filter expression into a parameterized SQL WHERE clause.
|
|
237
|
+
*
|
|
238
|
+
* Relational predicates (`{ nav: { $some | $none: … } }`, resolved by the
|
|
239
|
+
* core into `ResolvedRelationFilter` operands) render as correlated
|
|
240
|
+
* `[NOT] EXISTS (…)` subqueries; `opts.qualifier` names the outer table or
|
|
241
|
+
* alias they correlate to (default: the source table — see
|
|
242
|
+
* {@link TFilterVisitorOptions.qualifier}). `opts.columnRef` overrides how
|
|
243
|
+
* the filter's own columns render (e.g. `t."col"` for an aliased FROM).
|
|
244
|
+
* Placeholders stay positional `?` in textual order.
|
|
245
|
+
*
|
|
246
|
+
* @param opts - since 0.1.147
|
|
169
247
|
*/
|
|
170
|
-
function buildWhere(dialect, filter) {
|
|
248
|
+
function buildWhere(dialect, filter, opts) {
|
|
171
249
|
if (!filter || Object.keys(filter).length === 0) return EMPTY_AND;
|
|
172
|
-
return walkFilter(filter,
|
|
250
|
+
return walkFilter(filter, opts || containsRelationFilter(filter) ? createFilterVisitor(dialect, {
|
|
251
|
+
...opts,
|
|
252
|
+
aliasSeq: opts?.aliasSeq ?? { n: 0 }
|
|
253
|
+
}) : getVisitor(dialect)) ?? EMPTY_AND;
|
|
173
254
|
}
|
|
174
255
|
//#endregion
|
|
175
256
|
//#region src/common.ts
|
|
@@ -214,6 +295,20 @@ function toSqlValue(value) {
|
|
|
214
295
|
if (typeof value === "boolean") return value ? 1 : 0;
|
|
215
296
|
return value;
|
|
216
297
|
}
|
|
298
|
+
/**
|
|
299
|
+
* `FOREIGN KEY (…) REFERENCES <target> (…)[ ON DELETE …][ ON UPDATE …]` of a
|
|
300
|
+
* foreign key over its physical columns ({@link fkColumns}); the target is
|
|
301
|
+
* schema-qualified when it declares `@db.schema`. `quote` quotes one
|
|
302
|
+
* identifier.
|
|
303
|
+
*/
|
|
304
|
+
function foreignKeySql(quote, fk) {
|
|
305
|
+
const { fields, targetFields } = fkColumns(fk);
|
|
306
|
+
const target = fk.targetSchema ? `${quote(fk.targetSchema)}.${quote(fk.targetTable)}` : quote(fk.targetTable);
|
|
307
|
+
let sql = `FOREIGN KEY (${fields.map(quote).join(", ")}) REFERENCES ${target} (${targetFields.map(quote).join(", ")})`;
|
|
308
|
+
if (fk.onDelete) sql += ` ON DELETE ${refActionToSql(fk.onDelete)}`;
|
|
309
|
+
if (fk.onUpdate) sql += ` ON UPDATE ${refActionToSql(fk.onUpdate)}`;
|
|
310
|
+
return sql;
|
|
311
|
+
}
|
|
217
312
|
function refActionToSql(action) {
|
|
218
313
|
switch (action) {
|
|
219
314
|
case "cascade": return "CASCADE";
|
|
@@ -531,6 +626,80 @@ function viewAggExpr(dialect, c, resolveFieldRef) {
|
|
|
531
626
|
return c.aggFn === "sum" ? `COALESCE(${call}, 0)` : call;
|
|
532
627
|
}
|
|
533
628
|
/**
|
|
629
|
+
* The SQL expression of a computed view column (`@db.compute`), evaluated in
|
|
630
|
+
* IEEE double on every dialect: each field and literal leaf is cast with
|
|
631
|
+
* {@link SqlDialect.castDouble} (a computed leaf is already a double and is
|
|
632
|
+
* inlined); `+ - *` render as `(l op r)`, `/` as `(l / NULLIF(r, 0))` —
|
|
633
|
+
* division by zero is NULL — unary minus as `(-x)`, `coalesce` as
|
|
634
|
+
* `COALESCE(…)`. A leaf reads its column like the SELECT list does: an
|
|
635
|
+
* aggregate's call ({@link viewAggExpr}) or a dimension's source
|
|
636
|
+
* ({@link viewSourceExpr}). `byPath` holds the view's mappings by `viewPath`.
|
|
637
|
+
*
|
|
638
|
+
* In a `grouped` view a JSON-extracted dimension leaf reads `MIN(<extract>)`:
|
|
639
|
+
* the extraction is a GROUP BY key, so every row of a group holds the same
|
|
640
|
+
* value, but MySQL's ONLY_FULL_GROUP_BY rejects an expression over the raw
|
|
641
|
+
* JSON column (which is not itself grouped) — the aggregate form is accepted
|
|
642
|
+
* everywhere and yields that same value.
|
|
643
|
+
*
|
|
644
|
+
* @throws when the dialect has no `castDouble`, or a leaf names no column.
|
|
645
|
+
* @since 0.1.147
|
|
646
|
+
*/
|
|
647
|
+
function viewComputeExpr(dialect, c, byPath, resolveFieldRef, grouped = false, cache = /* @__PURE__ */ new Map()) {
|
|
648
|
+
const cached = cache.get(c.viewPath);
|
|
649
|
+
if (cached !== void 0) return cached;
|
|
650
|
+
const cast = dialect.castDouble?.bind(dialect);
|
|
651
|
+
if (!cast) throw new Error(`View column "${c.viewColumn}": computed view columns are not supported by this adapter`);
|
|
652
|
+
const render = (e) => {
|
|
653
|
+
if (typeof e === "number") {
|
|
654
|
+
if (!Number.isFinite(e)) throw new Error(`View column "${c.viewColumn}": non-finite literal in @db.compute`);
|
|
655
|
+
return cast(String(e));
|
|
656
|
+
}
|
|
657
|
+
if ("field" in e) {
|
|
658
|
+
const leaf = byPath.get(e.field);
|
|
659
|
+
if (!leaf) throw new Error(`View column "${c.viewColumn}": "${e.field}" is not a column of the view`);
|
|
660
|
+
if (leaf.expr !== void 0) return viewComputeExpr(dialect, leaf, byPath, resolveFieldRef, grouped, cache);
|
|
661
|
+
if (leaf.aggFn) return cast(viewAggExpr(dialect, leaf, resolveFieldRef));
|
|
662
|
+
const source = viewSourceExpr(dialect, leaf);
|
|
663
|
+
return cast(grouped && leaf.json ? `MIN(${source})` : source);
|
|
664
|
+
}
|
|
665
|
+
const args = e.args.map(render);
|
|
666
|
+
switch (e.op) {
|
|
667
|
+
case "neg": return `(-${args[0]})`;
|
|
668
|
+
case "coalesce": return `COALESCE(${args.join(", ")})`;
|
|
669
|
+
case "/": return `(${args[0]} / NULLIF(${args[1]}, 0))`;
|
|
670
|
+
default: return `(${args[0]} ${e.op} ${args[1]})`;
|
|
671
|
+
}
|
|
672
|
+
};
|
|
673
|
+
const sql = render(c.expr);
|
|
674
|
+
cache.set(c.viewPath, sql);
|
|
675
|
+
return sql;
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* The `ON` clause of a first-row join (`join.first`): the joined row is the
|
|
679
|
+
* one whose primary key equals the key of the FIRST matching target row —
|
|
680
|
+
* a correlated scalar subquery
|
|
681
|
+
* `"T"."id" = (SELECT "T"."id" FROM "table" AS "T" WHERE <condition> ORDER BY … LIMIT 1)`.
|
|
682
|
+
* The inner `FROM` repeats the join's own target / alias text, so the
|
|
683
|
+
* condition's refs to the joined scope bind to the inner row (SQL name
|
|
684
|
+
* shadowing) while refs to the entry and earlier joins stay correlated to
|
|
685
|
+
* the outer query. The ordering ends with the primary key, so the pick is
|
|
686
|
+
* deterministic; NULL sorts first in `asc` (`nullsSortLargest` dialects
|
|
687
|
+
* render `NULLS FIRST` / `NULLS LAST`). No window functions — SQLite, MySQL 8
|
|
688
|
+
* and PostgreSQL render it alike.
|
|
689
|
+
* @since 0.1.147
|
|
690
|
+
*/
|
|
691
|
+
function firstRowOn(dialect, join, target, condition, resolveFieldRef) {
|
|
692
|
+
const first = join.first;
|
|
693
|
+
const key = resolveFieldRef({
|
|
694
|
+
type: join.targetType,
|
|
695
|
+
field: first.key
|
|
696
|
+
});
|
|
697
|
+
return `${key} = (SELECT ${key} FROM ${target} WHERE ${condition} ORDER BY ${first.order.map(({ ref, desc }) => {
|
|
698
|
+
const nulls = dialect.nullsSortLargest ? desc ? " NULLS LAST" : " NULLS FIRST" : "";
|
|
699
|
+
return `${resolveFieldRef(ref)} ${desc ? "DESC" : "ASC"}${nulls}`;
|
|
700
|
+
}).join(", ")} LIMIT 1)`;
|
|
701
|
+
}
|
|
702
|
+
/**
|
|
534
703
|
* Builds a CREATE VIEW statement from a view plan and column mappings.
|
|
535
704
|
*
|
|
536
705
|
* Joins render in declaration order — `JOIN` (inner, the default) or
|
|
@@ -542,35 +711,41 @@ function viewAggExpr(dialect, c, resolveFieldRef) {
|
|
|
542
711
|
* join target may be views.
|
|
543
712
|
*/
|
|
544
713
|
function buildCreateView(dialect, viewName, plan, columns, resolveFieldRef) {
|
|
545
|
-
const
|
|
546
|
-
|
|
547
|
-
|
|
714
|
+
const columnByPath = new Map(columns.map((c) => [c.viewPath, c]));
|
|
715
|
+
const hasAggregates = columns.some((c) => c.aggFn);
|
|
716
|
+
const computedSql = /* @__PURE__ */ new Map();
|
|
717
|
+
/** A column's SELECT expression: computed, aggregate or plain source. */
|
|
718
|
+
const columnExpr = (c) => c.expr !== void 0 ? viewComputeExpr(dialect, c, columnByPath, resolveFieldRef, hasAggregates, computedSql) : c.aggFn ? viewAggExpr(dialect, c, resolveFieldRef) : viewSourceExpr(dialect, c);
|
|
719
|
+
const selectCols = columns.map((c) => `${columnExpr(c)} AS ${dialect.quoteIdentifier(c.viewColumn)}`).join(", ");
|
|
548
720
|
let sql = `${dialect.createViewPrefix} ${dialect.quoteTable(viewName)} AS SELECT ${selectCols} FROM ${dialect.quoteIdentifier(plan.entryTable)}`;
|
|
549
721
|
for (const join of plan.joins) {
|
|
550
|
-
const
|
|
722
|
+
const condition = queryNodeToSql(join.condition, resolveFieldRef);
|
|
551
723
|
const keyword = join.kind === "left" ? "LEFT JOIN" : "JOIN";
|
|
552
724
|
const target = join.scope === join.targetTable ? dialect.quoteIdentifier(join.targetTable) : `${dialect.quoteIdentifier(join.targetTable)} AS ${dialect.quoteIdentifier(join.scope)}`;
|
|
725
|
+
const onClause = join.first ? firstRowOn(dialect, join, target, condition, resolveFieldRef) : condition;
|
|
553
726
|
sql += ` ${keyword} ${target} ON ${onClause}`;
|
|
554
727
|
}
|
|
555
728
|
if (plan.filter) {
|
|
556
729
|
const whereClause = queryNodeToSql(plan.filter, resolveFieldRef);
|
|
557
730
|
sql += ` WHERE ${whereClause}`;
|
|
558
731
|
}
|
|
559
|
-
if (
|
|
560
|
-
const dimensionCols = columns.filter((c) => !c.aggFn);
|
|
732
|
+
if (hasAggregates) {
|
|
733
|
+
const dimensionCols = columns.filter((c) => !c.aggFn && c.expr === void 0);
|
|
561
734
|
if (dimensionCols.length > 0) {
|
|
562
735
|
const groupByCols = dimensionCols.map((c) => viewSourceExpr(dialect, c)).join(", ");
|
|
563
736
|
sql += ` GROUP BY ${groupByCols}`;
|
|
564
737
|
}
|
|
565
738
|
if (plan.having) {
|
|
566
|
-
|
|
567
|
-
|
|
739
|
+
let grouped;
|
|
740
|
+
const groupedColumns = () => grouped ??= new Set(dimensionCols.filter((c) => !c.json).map((c) => c.sourceColumn.toLowerCase()));
|
|
568
741
|
const havingResolver = (ref) => {
|
|
569
742
|
const col = ref.type ? void 0 : columnByPath.get(ref.field);
|
|
570
743
|
if (!col) return resolveFieldRef(ref);
|
|
744
|
+
if (col.expr !== void 0) return columnExpr(col);
|
|
571
745
|
if (col.aggFn) return viewAggExpr(dialect, col, resolveFieldRef);
|
|
572
746
|
const expr = viewSourceExpr(dialect, col);
|
|
573
|
-
|
|
747
|
+
if (!col.json) return expr;
|
|
748
|
+
return dialect.bucketAliasInHaving && groupedColumns().has(col.viewColumn.toLowerCase()) ? `MIN(${expr})` : havingGroupRef(dialect, expr, col.viewColumn);
|
|
574
749
|
};
|
|
575
750
|
const havingClause = queryNodeToSql(plan.having, havingResolver);
|
|
576
751
|
sql += ` HAVING ${havingClause}`;
|
|
@@ -631,11 +806,8 @@ function buildInsertMany(dialect, table, rows, columns = insertManyColumns(rows)
|
|
|
631
806
|
function buildSelect(dialect, table, where, controls) {
|
|
632
807
|
let sql = `SELECT ${buildProjection(dialect, controls?.$select)} FROM ${dialect.quoteTable(table)} WHERE ${where.sql}`;
|
|
633
808
|
const params = [...where.params];
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
for (const [col, dir] of Object.entries(controls.$sort)) orderParts.push(`${dialect.quoteIdentifier(col)} ${dir === -1 ? "DESC" : "ASC"}`);
|
|
637
|
-
if (orderParts.length > 0) sql += ` ORDER BY ${orderParts.join(", ")}`;
|
|
638
|
-
}
|
|
809
|
+
const orderBy = orderByList(dialect, controls?.$sort);
|
|
810
|
+
if (orderBy) sql += ` ORDER BY ${orderBy}`;
|
|
639
811
|
if (controls?.$limit !== void 0) {
|
|
640
812
|
sql += ` LIMIT ?`;
|
|
641
813
|
params.push(controls.$limit);
|
|
@@ -650,6 +822,53 @@ function buildSelect(dialect, table, where, controls) {
|
|
|
650
822
|
params
|
|
651
823
|
});
|
|
652
824
|
}
|
|
825
|
+
/** `"col" ASC, "other" DESC` of a physical `$sort` — `""` when it orders nothing. */
|
|
826
|
+
function orderByList(dialect, sort) {
|
|
827
|
+
if (!sort) return "";
|
|
828
|
+
const parts = [];
|
|
829
|
+
for (const [col, dir] of Object.entries(sort)) parts.push(`${dialect.quoteIdentifier(col)} ${dir === -1 ? "DESC" : "ASC"}`);
|
|
830
|
+
return parts.join(", ");
|
|
831
|
+
}
|
|
832
|
+
/**
|
|
833
|
+
* The row-number column {@link buildPartitionedSelect} adds to every row;
|
|
834
|
+
* {@link stripPartitionRowNumber} removes it from the result rows.
|
|
835
|
+
* @since 0.1.147
|
|
836
|
+
*/
|
|
837
|
+
const PARTITION_ROW_NUMBER_ALIAS = "__atscript_rn";
|
|
838
|
+
/**
|
|
839
|
+
* Builds a SELECT whose `$skip` / `$limit` apply to each partition — the rows
|
|
840
|
+
* sharing the values of the `partitionBy` columns (physical names) — instead
|
|
841
|
+
* of to the whole result: a `ROW_NUMBER() OVER (PARTITION BY … ORDER BY
|
|
842
|
+
* <$sort>)` window in a derived table, filtered on the row number. The rows
|
|
843
|
+
* of each partition come out in `$sort` order (partitions interleave); every
|
|
844
|
+
* row carries {@link PARTITION_ROW_NUMBER_ALIAS}. Window functions need
|
|
845
|
+
* SQLite ≥ 3.25, MySQL ≥ 8.0 or MariaDB ≥ 10.2.
|
|
846
|
+
* @since 0.1.147
|
|
847
|
+
*/
|
|
848
|
+
function buildPartitionedSelect(dialect, table, where, controls, partitionBy) {
|
|
849
|
+
const rn = dialect.quoteIdentifier(PARTITION_ROW_NUMBER_ALIAS);
|
|
850
|
+
const orderBy = orderByList(dialect, controls.$sort);
|
|
851
|
+
const window = `PARTITION BY ${partitionBy.map((col) => dialect.quoteIdentifier(col)).join(", ")}` + (orderBy ? ` ORDER BY ${orderBy}` : "");
|
|
852
|
+
const inner = `SELECT ${buildProjection(dialect, controls.$select)}, ROW_NUMBER() OVER (${window}) AS ${rn} FROM ${dialect.quoteTable(table)} WHERE ${where.sql}`;
|
|
853
|
+
const skip = controls.$skip ?? 0;
|
|
854
|
+
const limit = controls.$limit;
|
|
855
|
+
const params = [...where.params, skip];
|
|
856
|
+
let sql = `SELECT * FROM (${inner}) AS ${dialect.quoteIdentifier("__atscript_p")} WHERE ${rn} > ?`;
|
|
857
|
+
if (limit !== void 0 && limit !== null) {
|
|
858
|
+
sql += ` AND ${rn} <= ?`;
|
|
859
|
+
params.push(skip + limit);
|
|
860
|
+
}
|
|
861
|
+
sql += ` ORDER BY ${rn}`;
|
|
862
|
+
return finalizeParams(dialect, {
|
|
863
|
+
sql,
|
|
864
|
+
params
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
/** Removes {@link PARTITION_ROW_NUMBER_ALIAS} from rows read by {@link buildPartitionedSelect}. */
|
|
868
|
+
function stripPartitionRowNumber(rows) {
|
|
869
|
+
for (const row of rows) delete row[PARTITION_ROW_NUMBER_ALIAS];
|
|
870
|
+
return rows;
|
|
871
|
+
}
|
|
653
872
|
/**
|
|
654
873
|
* Marker value for {@link buildUpdate}: the column is assigned its DDL
|
|
655
874
|
* `DEFAULT` (`SET "col" = DEFAULT`, no bound parameter). Produced by
|
|
@@ -809,6 +1028,13 @@ function buildProjection(dialect, select, qualifier) {
|
|
|
809
1028
|
*/
|
|
810
1029
|
const GEO_DISTANCE_ALIAS = "__atscript_distance";
|
|
811
1030
|
/**
|
|
1031
|
+
* Alias of the searched table inside the geo and vector search statements —
|
|
1032
|
+
* a filter rendered into them correlates its relational predicates to it
|
|
1033
|
+
* (`buildWhere(filter, { qualifier: dialect.quoteTable(SEARCH_SOURCE_ALIAS) })`).
|
|
1034
|
+
* @since 0.1.147
|
|
1035
|
+
*/
|
|
1036
|
+
const SEARCH_SOURCE_ALIAS = "t";
|
|
1037
|
+
/**
|
|
812
1038
|
* Builds a distance-ranked geo search SELECT:
|
|
813
1039
|
*
|
|
814
1040
|
* ```sql
|
|
@@ -1027,4 +1253,4 @@ function parseRegexString(value) {
|
|
|
1027
1253
|
};
|
|
1028
1254
|
}
|
|
1029
1255
|
//#endregion
|
|
1030
|
-
export { AGG_FN_SQL, EMPTY_AND, EMPTY_OR, GEO_DISTANCE_ALIAS, SQL_DEFAULT, VECTOR_DISTANCE_ALIAS, buildAggregateCount, buildAggregateSelect, buildCreateView, buildDelete, buildGeoSearchCount, buildGeoSearchSelect, buildInsert, buildInsertMany, buildProjection, buildSelect, buildUpdate, buildVectorSearchCount, buildVectorSearchSelect, buildWhere, createFilterVisitor, defaultValueForType, defaultValueToSqlLiteral, derivedColumnExpr, fillReplacePayload, finalizeParams, geoWindowFromControls, groupKeySql, insertManyColumns, jsonDollarPath, normalizeGeoPointValue, parseRegexString, queryNodeToSql, queryOpToSql, quotedJsonPathSegments, refActionToSql, renameGeoDistance, replaceColumnsFor, sqlStringLiteral, sqlTimeZoneLiteral, toSqlValue, vectorDistanceSource };
|
|
1256
|
+
export { AGG_FN_SQL, EMPTY_AND, EMPTY_OR, GEO_DISTANCE_ALIAS, PARTITION_ROW_NUMBER_ALIAS, SEARCH_SOURCE_ALIAS, SQL_DEFAULT, VECTOR_DISTANCE_ALIAS, buildAggregateCount, buildAggregateSelect, buildCreateView, buildDelete, buildGeoSearchCount, buildGeoSearchSelect, buildInsert, buildInsertMany, buildPartitionedSelect, buildProjection, buildSelect, buildUpdate, buildVectorSearchCount, buildVectorSearchSelect, buildWhere, createFilterVisitor, defaultValueForType, defaultValueToSqlLiteral, derivedColumnExpr, fillReplacePayload, finalizeParams, foreignKeySql, geoWindowFromControls, groupKeySql, insertManyColumns, jsonDollarPath, normalizeGeoPointValue, parseRegexString, queryNodeToSql, queryOpToSql, quotedJsonPathSegments, refActionToSql, renameGeoDistance, replaceColumnsFor, sqlStringLiteral, sqlTimeZoneLiteral, stripPartitionRowNumber, toSqlValue, vectorDistanceSource };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@atscript/db-sql-tools",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.147",
|
|
4
4
|
"description": "Shared SQL builder utilities for @atscript database adapters.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"atscript",
|
|
@@ -37,12 +37,12 @@
|
|
|
37
37
|
"access": "public"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
|
-
"@uniqu/core": "^0.1.
|
|
41
|
-
"unplugin-atscript": "^0.1.
|
|
40
|
+
"@uniqu/core": "^0.1.12",
|
|
41
|
+
"unplugin-atscript": "^0.1.99"
|
|
42
42
|
},
|
|
43
43
|
"peerDependencies": {
|
|
44
|
-
"@uniqu/core": "^0.1.
|
|
45
|
-
"@atscript/db": "^0.1.
|
|
44
|
+
"@uniqu/core": "^0.1.12",
|
|
45
|
+
"@atscript/db": "^0.1.147"
|
|
46
46
|
},
|
|
47
47
|
"scripts": {
|
|
48
48
|
"build": "vp pack",
|