@atscript/db-sql-tools 0.1.163 → 0.1.165

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
@@ -245,6 +245,32 @@ function orFragment(parts, params) {
245
245
  params
246
246
  };
247
247
  }
248
+ /**
249
+ * The relevance ORDER BY of a fulltext search with no `$sort` (since 0.1.165):
250
+ * rows an exact-number equality matched first (`CASE WHEN … THEN 0 ELSE 1 END`,
251
+ * only next to a text rank — `n` binds each equality once), then `textRank`
252
+ * (its direction included, e.g. `ts_rank(…) DESC`), then the primary-key
253
+ * columns ascending (quoted, after `prefix`). Params: the CASE's, then the
254
+ * text rank's.
255
+ */
256
+ function searchRankOrder(dialect, opts) {
257
+ const parts = [];
258
+ const params = [];
259
+ const { exactEqualities: eqs, n, textRank } = opts;
260
+ if (textRank && n !== void 0 && eqs.length > 0) {
261
+ parts.push(`CASE WHEN ${eqs.join(" OR ")} THEN 0 ELSE 1 END`);
262
+ for (let i = 0; i < eqs.length; i++) params.push(n);
263
+ }
264
+ if (textRank) {
265
+ parts.push(textRank.sql);
266
+ params.push(...textRank.params);
267
+ }
268
+ for (const col of opts.pkColumns) parts.push(`${opts.prefix ?? ""}${dialect.quoteIdentifier(col)} ASC`);
269
+ return {
270
+ sql: parts.join(", "),
271
+ params
272
+ };
273
+ }
248
274
  //#endregion
249
275
  //#region src/arith.ts
250
276
  /**
@@ -501,10 +527,140 @@ function getVisitor(dialect) {
501
527
  */
502
528
  function buildWhere(dialect, filter, opts) {
503
529
  if (!filter || Object.keys(filter).length === 0) return EMPTY_AND;
504
- return (0, _uniqu_core.walkFilter)(filter, opts || (0, _atscript_db.containsRelationFilter)(filter) ? createFilterVisitor(dialect, {
530
+ const visitor = opts || (0, _atscript_db.containsRelationFilter)(filter) ? createFilterVisitor(dialect, {
505
531
  ...opts,
506
532
  aliasSeq: opts?.aliasSeq ?? { n: 0 }
507
- }) : getVisitor(dialect)) ?? EMPTY_AND;
533
+ }) : getVisitor(dialect);
534
+ return (0, _uniqu_core.walkFilter)(collapseOrEqualities(filter), visitor) ?? EMPTY_AND;
535
+ }
536
+ /**
537
+ * Rewrites every `$or` whose branches include several equalities on ONE
538
+ * field (`{ f: v }`, `{ f: { $eq: v } }`, `{ f: { $in: [...] } }`, v a
539
+ * non-null scalar, all of one JS type) into one `{ f: { $in: [...] } }`
540
+ * branch (since 0.1.165).
541
+ * Same rows, but one predicate instead of an OR chain whose depth grows with
542
+ * the batch: SQLite refuses an expression tree deeper than 1000, which a
543
+ * per-row key filter over a large batch reached. Composite, null, operator
544
+ * and lone branches are kept as they are. Returns `filter` itself when
545
+ * nothing changes.
546
+ */
547
+ function collapseOrEqualities(filter) {
548
+ if (!filter || typeof filter !== "object") return filter;
549
+ const node = filter;
550
+ let out;
551
+ const and = node.$and;
552
+ if (Array.isArray(and)) {
553
+ const next = collapseEach(and);
554
+ if (next !== and) (out ??= { ...node }).$and = next;
555
+ }
556
+ const or = node.$or;
557
+ if (Array.isArray(or)) {
558
+ let next = collapseEach(or);
559
+ if (next.length > 1 && mayRepeatField(next)) next = mergeEqualityBranches(next);
560
+ if (next !== or) (out ??= { ...node }).$or = next;
561
+ }
562
+ const not = node.$not;
563
+ if (not && typeof not === "object") {
564
+ const next = collapseOrEqualities(not);
565
+ if (next !== not) (out ??= { ...node }).$not = next;
566
+ }
567
+ return out ?? filter;
568
+ }
569
+ /** `list` with each object child collapsed — the same array when none changed (copied on the first change). */
570
+ function collapseEach(list) {
571
+ let copy;
572
+ for (let i = 0; i < list.length; i++) {
573
+ const child = list[i];
574
+ const next = child && typeof child === "object" ? collapseOrEqualities(child) : child;
575
+ if (next !== child) (copy ??= list.slice())[i] = next;
576
+ }
577
+ return copy ?? list;
578
+ }
579
+ /** The sole key of a one-key, non-operator branch — allocation-free. */
580
+ function soleField(branch) {
581
+ if (!branch || typeof branch !== "object" || Array.isArray(branch)) return void 0;
582
+ let field;
583
+ for (const key in branch) {
584
+ if (field !== void 0) return void 0;
585
+ field = key;
586
+ }
587
+ return field === void 0 || field.startsWith("$") ? void 0 : field;
588
+ }
589
+ /**
590
+ * Cheap pre-check for {@link mergeEqualityBranches}: whether two branches
591
+ * share a sole field (long lists go straight to the merge pass).
592
+ */
593
+ function mayRepeatField(branches) {
594
+ if (branches.length > 64) return true;
595
+ for (let i = 1; i < branches.length; i++) {
596
+ const field = soleField(branches[i]);
597
+ if (field === void 0) continue;
598
+ for (let j = 0; j < i; j++) if (soleField(branches[j]) === field) return true;
599
+ }
600
+ return false;
601
+ }
602
+ /** The `$or` branches with the equalities of each repeated field merged into one `$in`. */
603
+ function mergeEqualityBranches(branches) {
604
+ const eqs = branches.map(equalityOf);
605
+ const fields = /* @__PURE__ */ new Map();
606
+ for (const eq of eqs) {
607
+ if (!eq) continue;
608
+ let entry = fields.get(eq.field);
609
+ if (!entry) fields.set(eq.field, entry = { count: 0 });
610
+ entry.count++;
611
+ for (const v of eq.values) {
612
+ const t = typeof v;
613
+ entry.type = entry.type === void 0 || entry.type === t ? t : "mixed";
614
+ }
615
+ }
616
+ const merges = (field) => {
617
+ const entry = fields.get(field);
618
+ return entry.count > 1 && entry.type !== "mixed";
619
+ };
620
+ if (!eqs.some((eq) => eq && merges(eq.field))) return branches;
621
+ const out = [];
622
+ for (let i = 0; i < branches.length; i++) {
623
+ const eq = eqs[i];
624
+ if (!eq || !merges(eq.field)) {
625
+ out.push(branches[i]);
626
+ continue;
627
+ }
628
+ const entry = fields.get(eq.field);
629
+ if (!entry.values) {
630
+ entry.values = [];
631
+ out.push({ [eq.field]: { $in: entry.values } });
632
+ }
633
+ entry.values.push(...eq.values);
634
+ }
635
+ return out;
636
+ }
637
+ /** `{ field, values }` of a one-field equality branch, else `undefined`. */
638
+ function equalityOf(branch) {
639
+ if (!branch || typeof branch !== "object" || Array.isArray(branch)) return void 0;
640
+ const keys = Object.keys(branch);
641
+ if (keys.length !== 1 || keys[0].startsWith("$")) return void 0;
642
+ const field = keys[0];
643
+ const value = branch[field];
644
+ if (isScalar(value)) return {
645
+ field,
646
+ values: [value]
647
+ };
648
+ if (!value || typeof value !== "object" || Array.isArray(value)) return void 0;
649
+ const ops = Object.keys(value);
650
+ if (ops.length !== 1) return void 0;
651
+ const operand = value[ops[0]];
652
+ if (ops[0] === "$eq" && isScalar(operand)) return {
653
+ field,
654
+ values: [operand]
655
+ };
656
+ if (ops[0] === "$in" && Array.isArray(operand) && operand.every(isScalar)) return {
657
+ field,
658
+ values: operand
659
+ };
660
+ }
661
+ function isScalar(value) {
662
+ const t = typeof value;
663
+ return t === "string" || t === "number" || t === "boolean" || t === "bigint";
508
664
  }
509
665
  //#endregion
510
666
  //#region src/from-source.ts
@@ -1255,13 +1411,19 @@ function buildInsertMany(dialect, table, rows, columns = insertManyColumns(rows)
1255
1411
  * Builds a SELECT statement with optional sort, limit, offset, projection.
1256
1412
  * `table` may be a derived source (a pruned view read, since 0.1.153).
1257
1413
  * `lock` (since 0.1.162) appends a row-locking clause — see {@link readLockSql}.
1414
+ * `defaultOrder` (since 0.1.165) is the ORDER BY list used when `$sort` orders
1415
+ * nothing — a search's relevance order; its params bind after the WHERE's.
1258
1416
  */
1259
- function buildSelect(dialect, table, where, controls, lock) {
1417
+ function buildSelect(dialect, table, where, controls, lock, defaultOrder) {
1260
1418
  const cols = buildProjection(dialect, controls?.$select);
1261
1419
  let sql = `SELECT ${fromSourceHint(table)}${cols} FROM ${fromSourceSql(dialect, table)} WHERE ${where.sql}`;
1262
1420
  const params = [...where.params];
1263
1421
  const orderBy = orderByList(dialect, controls?.$sort, controls?.$nulls);
1264
1422
  if (orderBy) sql += ` ORDER BY ${orderBy}`;
1423
+ else if (defaultOrder?.sql) {
1424
+ sql += ` ORDER BY ${defaultOrder.sql}`;
1425
+ params.push(...defaultOrder.params);
1426
+ }
1265
1427
  if (controls?.$limit !== void 0) {
1266
1428
  sql += ` LIMIT ?`;
1267
1429
  params.push(controls.$limit);
@@ -1357,12 +1519,14 @@ const SQL_DEFAULT = Symbol("SQL_DEFAULT");
1357
1519
  * and an omitted PK must never be nulled or re-defaulted. Static value
1358
1520
  * defaults are filled SDK-side before the adapter sees the row, so only
1359
1521
  * native function defaults are flagged. Ignored and derived descriptors in
1360
- * `fields` are skipped, so `fieldDescriptors` works too.
1522
+ * `fields` are skipped, so `fieldDescriptors` works too. Insert-only columns
1523
+ * (`number.timestamp.created`, since 0.1.165) are left out: a replace keeps
1524
+ * their stored value.
1361
1525
  */
1362
1526
  function replaceColumnsFor(fields, nativeFns) {
1363
1527
  const out = [];
1364
1528
  for (const fd of fields) {
1365
- if (fd.ignored || fd.isPrimaryKey || fd.derived) continue;
1529
+ if (fd.ignored || fd.isPrimaryKey || fd.derived || fd.insertOnly) continue;
1366
1530
  const def = fd.defaultValue;
1367
1531
  out.push({
1368
1532
  name: fd.physicalName,
@@ -1911,6 +2075,7 @@ exports.refActionToSql = refActionToSql;
1911
2075
  exports.renameGeoDistance = renameGeoDistance;
1912
2076
  exports.renderArith = renderArith;
1913
2077
  exports.replaceColumnsFor = replaceColumnsFor;
2078
+ exports.searchRankOrder = searchRankOrder;
1914
2079
  exports.sqlStringLiteral = sqlStringLiteral;
1915
2080
  exports.sqlTimeZoneLiteral = sqlTimeZoneLiteral;
1916
2081
  exports.stripPartitionRowNumber = stripPartitionRowNumber;
package/dist/index.d.cts CHANGED
@@ -269,6 +269,21 @@ declare const EMPTY_AND: TSqlFragment;
269
269
  declare const EMPTY_OR: TSqlFragment;
270
270
  /** ORs predicate parts (parenthesized when several); no parts matches nothing. */
271
271
  declare function orFragment(parts: string[], params: unknown[]): TSqlFragment;
272
+ /**
273
+ * The relevance ORDER BY of a fulltext search with no `$sort` (since 0.1.165):
274
+ * rows an exact-number equality matched first (`CASE WHEN … THEN 0 ELSE 1 END`,
275
+ * only next to a text rank — `n` binds each equality once), then `textRank`
276
+ * (its direction included, e.g. `ts_rank(…) DESC`), then the primary-key
277
+ * columns ascending (quoted, after `prefix`). Params: the CASE's, then the
278
+ * text rank's.
279
+ */
280
+ declare function searchRankOrder(dialect: Pick<SqlDialect, "quoteIdentifier">, opts: {
281
+ /** One `?`-equality per integer member (`"ref_no" = ?`). */exactEqualities: readonly string[]; /** The whole-number term, when the search term is one. */
282
+ n: number | undefined;
283
+ textRank?: TSqlFragment;
284
+ pkColumns: readonly string[]; /** Prepended to each quoted primary-key column (`t.`). */
285
+ prefix?: string;
286
+ }): TSqlFragment;
272
287
  //#endregion
273
288
  //#region src/arith.d.ts
274
289
  /** Why {@link renderArith} cannot render an expression. */
@@ -566,8 +581,10 @@ declare function buildInsertMany(dialect: SqlDialect, table: string, rows: reado
566
581
  * Builds a SELECT statement with optional sort, limit, offset, projection.
567
582
  * `table` may be a derived source (a pruned view read, since 0.1.153).
568
583
  * `lock` (since 0.1.162) appends a row-locking clause — see {@link readLockSql}.
584
+ * `defaultOrder` (since 0.1.165) is the ORDER BY list used when `$sort` orders
585
+ * nothing — a search's relevance order; its params bind after the WHERE's.
569
586
  */
570
- declare function buildSelect(dialect: SqlDialect, table: TSqlFromSource, where: TSqlFragment, controls?: DbControls, lock?: TDbReadLock): TSqlFragment;
587
+ declare function buildSelect(dialect: SqlDialect, table: TSqlFromSource, where: TSqlFragment, controls?: DbControls, lock?: TDbReadLock, defaultOrder?: TSqlFragment): TSqlFragment;
571
588
  /**
572
589
  * The row-locking clause of a locking read — `" FOR UPDATE"`,
573
590
  * `" FOR SHARE NOWAIT"`, `" FOR UPDATE SKIP LOCKED"`, … (PostgreSQL and
@@ -627,7 +644,9 @@ interface TReplaceColumn {
627
644
  * and an omitted PK must never be nulled or re-defaulted. Static value
628
645
  * defaults are filled SDK-side before the adapter sees the row, so only
629
646
  * native function defaults are flagged. Ignored and derived descriptors in
630
- * `fields` are skipped, so `fieldDescriptors` works too.
647
+ * `fields` are skipped, so `fieldDescriptors` works too. Insert-only columns
648
+ * (`number.timestamp.created`, since 0.1.165) are left out: a replace keeps
649
+ * their stored value.
631
650
  */
632
651
  declare function replaceColumnsFor(fields: readonly TDbFieldMeta[], nativeFns: ReadonlySet<TDbDefaultFn>): TReplaceColumn[];
633
652
  /**
@@ -882,4 +901,4 @@ declare function buildJsonColumnCopy(dialect: SqlDialect, table: string, source:
882
901
  */
883
902
  declare function buildJsonifyText(dialect: SqlDialect, table: string, column: string): string;
884
903
  //#endregion
885
- 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, type TSqlWriteLimit, VECTOR_DISTANCE_ALIAS, arithOverflowError, buildAggregateCount, buildAggregateSelect, buildCreateView, buildDelete, buildGeoSearchCount, buildGeoSearchSelect, buildInsert, buildInsertMany, buildJsonColumnCopy, buildJsonifyText, buildKeyViolationCount, 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, readLockSql, refActionToSql, renameGeoDistance, renderArith, replaceColumnsFor, sqlStringLiteral, sqlTimeZoneLiteral, stripPartitionRowNumber, toSqlValue, unquoteSqlStringLiteral, vectorDistanceSource, viewReadSource, writeLimitCount, writeLimitWhere };
904
+ 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, type TSqlWriteLimit, VECTOR_DISTANCE_ALIAS, arithOverflowError, buildAggregateCount, buildAggregateSelect, buildCreateView, buildDelete, buildGeoSearchCount, buildGeoSearchSelect, buildInsert, buildInsertMany, buildJsonColumnCopy, buildJsonifyText, buildKeyViolationCount, 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, readLockSql, refActionToSql, renameGeoDistance, renderArith, replaceColumnsFor, searchRankOrder, sqlStringLiteral, sqlTimeZoneLiteral, stripPartitionRowNumber, toSqlValue, unquoteSqlStringLiteral, vectorDistanceSource, viewReadSource, writeLimitCount, writeLimitWhere };
package/dist/index.d.mts CHANGED
@@ -269,6 +269,21 @@ declare const EMPTY_AND: TSqlFragment;
269
269
  declare const EMPTY_OR: TSqlFragment;
270
270
  /** ORs predicate parts (parenthesized when several); no parts matches nothing. */
271
271
  declare function orFragment(parts: string[], params: unknown[]): TSqlFragment;
272
+ /**
273
+ * The relevance ORDER BY of a fulltext search with no `$sort` (since 0.1.165):
274
+ * rows an exact-number equality matched first (`CASE WHEN … THEN 0 ELSE 1 END`,
275
+ * only next to a text rank — `n` binds each equality once), then `textRank`
276
+ * (its direction included, e.g. `ts_rank(…) DESC`), then the primary-key
277
+ * columns ascending (quoted, after `prefix`). Params: the CASE's, then the
278
+ * text rank's.
279
+ */
280
+ declare function searchRankOrder(dialect: Pick<SqlDialect, "quoteIdentifier">, opts: {
281
+ /** One `?`-equality per integer member (`"ref_no" = ?`). */exactEqualities: readonly string[]; /** The whole-number term, when the search term is one. */
282
+ n: number | undefined;
283
+ textRank?: TSqlFragment;
284
+ pkColumns: readonly string[]; /** Prepended to each quoted primary-key column (`t.`). */
285
+ prefix?: string;
286
+ }): TSqlFragment;
272
287
  //#endregion
273
288
  //#region src/arith.d.ts
274
289
  /** Why {@link renderArith} cannot render an expression. */
@@ -566,8 +581,10 @@ declare function buildInsertMany(dialect: SqlDialect, table: string, rows: reado
566
581
  * Builds a SELECT statement with optional sort, limit, offset, projection.
567
582
  * `table` may be a derived source (a pruned view read, since 0.1.153).
568
583
  * `lock` (since 0.1.162) appends a row-locking clause — see {@link readLockSql}.
584
+ * `defaultOrder` (since 0.1.165) is the ORDER BY list used when `$sort` orders
585
+ * nothing — a search's relevance order; its params bind after the WHERE's.
569
586
  */
570
- declare function buildSelect(dialect: SqlDialect, table: TSqlFromSource, where: TSqlFragment, controls?: DbControls, lock?: TDbReadLock): TSqlFragment;
587
+ declare function buildSelect(dialect: SqlDialect, table: TSqlFromSource, where: TSqlFragment, controls?: DbControls, lock?: TDbReadLock, defaultOrder?: TSqlFragment): TSqlFragment;
571
588
  /**
572
589
  * The row-locking clause of a locking read — `" FOR UPDATE"`,
573
590
  * `" FOR SHARE NOWAIT"`, `" FOR UPDATE SKIP LOCKED"`, … (PostgreSQL and
@@ -627,7 +644,9 @@ interface TReplaceColumn {
627
644
  * and an omitted PK must never be nulled or re-defaulted. Static value
628
645
  * defaults are filled SDK-side before the adapter sees the row, so only
629
646
  * native function defaults are flagged. Ignored and derived descriptors in
630
- * `fields` are skipped, so `fieldDescriptors` works too.
647
+ * `fields` are skipped, so `fieldDescriptors` works too. Insert-only columns
648
+ * (`number.timestamp.created`, since 0.1.165) are left out: a replace keeps
649
+ * their stored value.
631
650
  */
632
651
  declare function replaceColumnsFor(fields: readonly TDbFieldMeta[], nativeFns: ReadonlySet<TDbDefaultFn>): TReplaceColumn[];
633
652
  /**
@@ -882,4 +901,4 @@ declare function buildJsonColumnCopy(dialect: SqlDialect, table: string, source:
882
901
  */
883
902
  declare function buildJsonifyText(dialect: SqlDialect, table: string, column: string): string;
884
903
  //#endregion
885
- 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, type TSqlWriteLimit, VECTOR_DISTANCE_ALIAS, arithOverflowError, buildAggregateCount, buildAggregateSelect, buildCreateView, buildDelete, buildGeoSearchCount, buildGeoSearchSelect, buildInsert, buildInsertMany, buildJsonColumnCopy, buildJsonifyText, buildKeyViolationCount, 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, readLockSql, refActionToSql, renameGeoDistance, renderArith, replaceColumnsFor, sqlStringLiteral, sqlTimeZoneLiteral, stripPartitionRowNumber, toSqlValue, unquoteSqlStringLiteral, vectorDistanceSource, viewReadSource, writeLimitCount, writeLimitWhere };
904
+ 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, type TSqlWriteLimit, VECTOR_DISTANCE_ALIAS, arithOverflowError, buildAggregateCount, buildAggregateSelect, buildCreateView, buildDelete, buildGeoSearchCount, buildGeoSearchSelect, buildInsert, buildInsertMany, buildJsonColumnCopy, buildJsonifyText, buildKeyViolationCount, 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, readLockSql, refActionToSql, renameGeoDistance, renderArith, replaceColumnsFor, searchRankOrder, sqlStringLiteral, sqlTimeZoneLiteral, stripPartitionRowNumber, toSqlValue, unquoteSqlStringLiteral, vectorDistanceSource, viewReadSource, writeLimitCount, writeLimitWhere };
package/dist/index.mjs CHANGED
@@ -244,6 +244,32 @@ function orFragment(parts, params) {
244
244
  params
245
245
  };
246
246
  }
247
+ /**
248
+ * The relevance ORDER BY of a fulltext search with no `$sort` (since 0.1.165):
249
+ * rows an exact-number equality matched first (`CASE WHEN … THEN 0 ELSE 1 END`,
250
+ * only next to a text rank — `n` binds each equality once), then `textRank`
251
+ * (its direction included, e.g. `ts_rank(…) DESC`), then the primary-key
252
+ * columns ascending (quoted, after `prefix`). Params: the CASE's, then the
253
+ * text rank's.
254
+ */
255
+ function searchRankOrder(dialect, opts) {
256
+ const parts = [];
257
+ const params = [];
258
+ const { exactEqualities: eqs, n, textRank } = opts;
259
+ if (textRank && n !== void 0 && eqs.length > 0) {
260
+ parts.push(`CASE WHEN ${eqs.join(" OR ")} THEN 0 ELSE 1 END`);
261
+ for (let i = 0; i < eqs.length; i++) params.push(n);
262
+ }
263
+ if (textRank) {
264
+ parts.push(textRank.sql);
265
+ params.push(...textRank.params);
266
+ }
267
+ for (const col of opts.pkColumns) parts.push(`${opts.prefix ?? ""}${dialect.quoteIdentifier(col)} ASC`);
268
+ return {
269
+ sql: parts.join(", "),
270
+ params
271
+ };
272
+ }
247
273
  //#endregion
248
274
  //#region src/arith.ts
249
275
  /**
@@ -500,10 +526,140 @@ function getVisitor(dialect) {
500
526
  */
501
527
  function buildWhere(dialect, filter, opts) {
502
528
  if (!filter || Object.keys(filter).length === 0) return EMPTY_AND;
503
- return walkFilter(filter, opts || containsRelationFilter(filter) ? createFilterVisitor(dialect, {
529
+ const visitor = opts || containsRelationFilter(filter) ? createFilterVisitor(dialect, {
504
530
  ...opts,
505
531
  aliasSeq: opts?.aliasSeq ?? { n: 0 }
506
- }) : getVisitor(dialect)) ?? EMPTY_AND;
532
+ }) : getVisitor(dialect);
533
+ return walkFilter(collapseOrEqualities(filter), visitor) ?? EMPTY_AND;
534
+ }
535
+ /**
536
+ * Rewrites every `$or` whose branches include several equalities on ONE
537
+ * field (`{ f: v }`, `{ f: { $eq: v } }`, `{ f: { $in: [...] } }`, v a
538
+ * non-null scalar, all of one JS type) into one `{ f: { $in: [...] } }`
539
+ * branch (since 0.1.165).
540
+ * Same rows, but one predicate instead of an OR chain whose depth grows with
541
+ * the batch: SQLite refuses an expression tree deeper than 1000, which a
542
+ * per-row key filter over a large batch reached. Composite, null, operator
543
+ * and lone branches are kept as they are. Returns `filter` itself when
544
+ * nothing changes.
545
+ */
546
+ function collapseOrEqualities(filter) {
547
+ if (!filter || typeof filter !== "object") return filter;
548
+ const node = filter;
549
+ let out;
550
+ const and = node.$and;
551
+ if (Array.isArray(and)) {
552
+ const next = collapseEach(and);
553
+ if (next !== and) (out ??= { ...node }).$and = next;
554
+ }
555
+ const or = node.$or;
556
+ if (Array.isArray(or)) {
557
+ let next = collapseEach(or);
558
+ if (next.length > 1 && mayRepeatField(next)) next = mergeEqualityBranches(next);
559
+ if (next !== or) (out ??= { ...node }).$or = next;
560
+ }
561
+ const not = node.$not;
562
+ if (not && typeof not === "object") {
563
+ const next = collapseOrEqualities(not);
564
+ if (next !== not) (out ??= { ...node }).$not = next;
565
+ }
566
+ return out ?? filter;
567
+ }
568
+ /** `list` with each object child collapsed — the same array when none changed (copied on the first change). */
569
+ function collapseEach(list) {
570
+ let copy;
571
+ for (let i = 0; i < list.length; i++) {
572
+ const child = list[i];
573
+ const next = child && typeof child === "object" ? collapseOrEqualities(child) : child;
574
+ if (next !== child) (copy ??= list.slice())[i] = next;
575
+ }
576
+ return copy ?? list;
577
+ }
578
+ /** The sole key of a one-key, non-operator branch — allocation-free. */
579
+ function soleField(branch) {
580
+ if (!branch || typeof branch !== "object" || Array.isArray(branch)) return void 0;
581
+ let field;
582
+ for (const key in branch) {
583
+ if (field !== void 0) return void 0;
584
+ field = key;
585
+ }
586
+ return field === void 0 || field.startsWith("$") ? void 0 : field;
587
+ }
588
+ /**
589
+ * Cheap pre-check for {@link mergeEqualityBranches}: whether two branches
590
+ * share a sole field (long lists go straight to the merge pass).
591
+ */
592
+ function mayRepeatField(branches) {
593
+ if (branches.length > 64) return true;
594
+ for (let i = 1; i < branches.length; i++) {
595
+ const field = soleField(branches[i]);
596
+ if (field === void 0) continue;
597
+ for (let j = 0; j < i; j++) if (soleField(branches[j]) === field) return true;
598
+ }
599
+ return false;
600
+ }
601
+ /** The `$or` branches with the equalities of each repeated field merged into one `$in`. */
602
+ function mergeEqualityBranches(branches) {
603
+ const eqs = branches.map(equalityOf);
604
+ const fields = /* @__PURE__ */ new Map();
605
+ for (const eq of eqs) {
606
+ if (!eq) continue;
607
+ let entry = fields.get(eq.field);
608
+ if (!entry) fields.set(eq.field, entry = { count: 0 });
609
+ entry.count++;
610
+ for (const v of eq.values) {
611
+ const t = typeof v;
612
+ entry.type = entry.type === void 0 || entry.type === t ? t : "mixed";
613
+ }
614
+ }
615
+ const merges = (field) => {
616
+ const entry = fields.get(field);
617
+ return entry.count > 1 && entry.type !== "mixed";
618
+ };
619
+ if (!eqs.some((eq) => eq && merges(eq.field))) return branches;
620
+ const out = [];
621
+ for (let i = 0; i < branches.length; i++) {
622
+ const eq = eqs[i];
623
+ if (!eq || !merges(eq.field)) {
624
+ out.push(branches[i]);
625
+ continue;
626
+ }
627
+ const entry = fields.get(eq.field);
628
+ if (!entry.values) {
629
+ entry.values = [];
630
+ out.push({ [eq.field]: { $in: entry.values } });
631
+ }
632
+ entry.values.push(...eq.values);
633
+ }
634
+ return out;
635
+ }
636
+ /** `{ field, values }` of a one-field equality branch, else `undefined`. */
637
+ function equalityOf(branch) {
638
+ if (!branch || typeof branch !== "object" || Array.isArray(branch)) return void 0;
639
+ const keys = Object.keys(branch);
640
+ if (keys.length !== 1 || keys[0].startsWith("$")) return void 0;
641
+ const field = keys[0];
642
+ const value = branch[field];
643
+ if (isScalar(value)) return {
644
+ field,
645
+ values: [value]
646
+ };
647
+ if (!value || typeof value !== "object" || Array.isArray(value)) return void 0;
648
+ const ops = Object.keys(value);
649
+ if (ops.length !== 1) return void 0;
650
+ const operand = value[ops[0]];
651
+ if (ops[0] === "$eq" && isScalar(operand)) return {
652
+ field,
653
+ values: [operand]
654
+ };
655
+ if (ops[0] === "$in" && Array.isArray(operand) && operand.every(isScalar)) return {
656
+ field,
657
+ values: operand
658
+ };
659
+ }
660
+ function isScalar(value) {
661
+ const t = typeof value;
662
+ return t === "string" || t === "number" || t === "boolean" || t === "bigint";
507
663
  }
508
664
  //#endregion
509
665
  //#region src/from-source.ts
@@ -1254,13 +1410,19 @@ function buildInsertMany(dialect, table, rows, columns = insertManyColumns(rows)
1254
1410
  * Builds a SELECT statement with optional sort, limit, offset, projection.
1255
1411
  * `table` may be a derived source (a pruned view read, since 0.1.153).
1256
1412
  * `lock` (since 0.1.162) appends a row-locking clause — see {@link readLockSql}.
1413
+ * `defaultOrder` (since 0.1.165) is the ORDER BY list used when `$sort` orders
1414
+ * nothing — a search's relevance order; its params bind after the WHERE's.
1257
1415
  */
1258
- function buildSelect(dialect, table, where, controls, lock) {
1416
+ function buildSelect(dialect, table, where, controls, lock, defaultOrder) {
1259
1417
  const cols = buildProjection(dialect, controls?.$select);
1260
1418
  let sql = `SELECT ${fromSourceHint(table)}${cols} FROM ${fromSourceSql(dialect, table)} WHERE ${where.sql}`;
1261
1419
  const params = [...where.params];
1262
1420
  const orderBy = orderByList(dialect, controls?.$sort, controls?.$nulls);
1263
1421
  if (orderBy) sql += ` ORDER BY ${orderBy}`;
1422
+ else if (defaultOrder?.sql) {
1423
+ sql += ` ORDER BY ${defaultOrder.sql}`;
1424
+ params.push(...defaultOrder.params);
1425
+ }
1264
1426
  if (controls?.$limit !== void 0) {
1265
1427
  sql += ` LIMIT ?`;
1266
1428
  params.push(controls.$limit);
@@ -1356,12 +1518,14 @@ const SQL_DEFAULT = Symbol("SQL_DEFAULT");
1356
1518
  * and an omitted PK must never be nulled or re-defaulted. Static value
1357
1519
  * defaults are filled SDK-side before the adapter sees the row, so only
1358
1520
  * native function defaults are flagged. Ignored and derived descriptors in
1359
- * `fields` are skipped, so `fieldDescriptors` works too.
1521
+ * `fields` are skipped, so `fieldDescriptors` works too. Insert-only columns
1522
+ * (`number.timestamp.created`, since 0.1.165) are left out: a replace keeps
1523
+ * their stored value.
1360
1524
  */
1361
1525
  function replaceColumnsFor(fields, nativeFns) {
1362
1526
  const out = [];
1363
1527
  for (const fd of fields) {
1364
- if (fd.ignored || fd.isPrimaryKey || fd.derived) continue;
1528
+ if (fd.ignored || fd.isPrimaryKey || fd.derived || fd.insertOnly) continue;
1365
1529
  const def = fd.defaultValue;
1366
1530
  out.push({
1367
1531
  name: fd.physicalName,
@@ -1850,4 +2014,4 @@ function buildJsonifyText(dialect, table, column) {
1850
2014
  return `UPDATE ${dialect.quoteTable(table)} SET ${col} = ${dialect.jsonFromText(col)} WHERE ${col} IS NOT NULL`;
1851
2015
  }
1852
2016
  //#endregion
1853
- 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, buildJsonColumnCopy, buildJsonifyText, buildKeyViolationCount, 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, readLockSql, refActionToSql, renameGeoDistance, renderArith, replaceColumnsFor, sqlStringLiteral, sqlTimeZoneLiteral, stripPartitionRowNumber, toSqlValue, unquoteSqlStringLiteral, vectorDistanceSource, viewReadSource, writeLimitCount, writeLimitWhere };
2017
+ 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, buildJsonColumnCopy, buildJsonifyText, buildKeyViolationCount, 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, readLockSql, refActionToSql, renameGeoDistance, renderArith, replaceColumnsFor, searchRankOrder, sqlStringLiteral, sqlTimeZoneLiteral, stripPartitionRowNumber, toSqlValue, unquoteSqlStringLiteral, vectorDistanceSource, viewReadSource, writeLimitCount, writeLimitWhere };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atscript/db-sql-tools",
3
- "version": "0.1.163",
3
+ "version": "0.1.165",
4
4
  "description": "Shared SQL builder utilities for @atscript database adapters.",
5
5
  "keywords": [
6
6
  "atscript",
@@ -38,11 +38,11 @@
38
38
  },
39
39
  "devDependencies": {
40
40
  "@uniqu/core": "^0.1.15",
41
- "unplugin-atscript": "^0.1.109"
41
+ "unplugin-atscript": "^0.2.1"
42
42
  },
43
43
  "peerDependencies": {
44
44
  "@uniqu/core": "^0.1.15",
45
- "@atscript/db": "^0.1.163"
45
+ "@atscript/db": "^0.1.165"
46
46
  },
47
47
  "scripts": {
48
48
  "build": "vp pack",