@atscript/db-postgres 0.1.164 → 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
@@ -311,8 +311,8 @@ function buildInsert(table, data) {
311
311
  function buildInsertMany(table, rows, columns) {
312
312
  return (0, _atscript_db_sql_tools.buildInsertMany)(pgDialect, table, rows, columns);
313
313
  }
314
- function buildSelect(table, where, controls, lock) {
315
- return (0, _atscript_db_sql_tools.buildSelect)(pgDialect, table, where, controls, lock);
314
+ function buildSelect(table, where, controls, lock, defaultOrder) {
315
+ return (0, _atscript_db_sql_tools.buildSelect)(pgDialect, table, where, controls, lock, defaultOrder);
316
316
  }
317
317
  function buildUpdate(table, data, where, limit, ops, versionColumn, expectedVersion) {
318
318
  return (0, _atscript_db_sql_tools.buildUpdate)(pgDialect, table, data, where, limit, ops, versionColumn, expectedVersion);
@@ -931,11 +931,19 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
931
931
  _mapConstraintError(error) {
932
932
  if (error && typeof error === "object" && "code" in error) {
933
933
  const err = error;
934
- if (err.code === "23505") throw new _atscript_db.DbError("CONFLICT", [{
935
- path: this._extractFieldFromConstraint(err.constraint) ?? "",
936
- message: err.detail ?? err.message
937
- }]);
938
- if (err.code === "23503") throw new _atscript_db.DbError("FK_VIOLATION", this._mapFkError(err.detail ?? err.message, err.constraint));
934
+ if (err.code === "23505") throw (0, _atscript_db.constraintViolation)("CONFLICT", {
935
+ name: err.constraint,
936
+ columns: keyColumns(err.detail),
937
+ primaryKey: err.constraint?.endsWith("_pkey")
938
+ }, err.detail ?? err.message);
939
+ if (err.code === "23503") {
940
+ const detail = err.detail ?? err.message;
941
+ const referencedBy = detail.match(/is still referenced from table "([^"]+)"/)?.[1];
942
+ throw (0, _atscript_db.constraintViolation)("FK_VIOLATION", referencedBy ? { referencedBy } : {
943
+ name: err.constraint,
944
+ columns: keyColumns(detail)
945
+ }, detail);
946
+ }
939
947
  const contention = PG_CONTENTION_CODES[err.code];
940
948
  if (contention) throw new _atscript_db.DbError(contention, [{
941
949
  path: "",
@@ -944,30 +952,6 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
944
952
  }
945
953
  throw error;
946
954
  }
947
- _extractFieldFromConstraint(constraint) {
948
- if (!constraint) return;
949
- const tableName = this._table.tableName;
950
- if (constraint.startsWith(`${tableName}_`) && constraint.endsWith("_key")) {
951
- const fieldPart = constraint.slice(tableName.length + 1, -4);
952
- const fd = this._table.fieldDescriptors.find((f) => f.physicalName === fieldPart);
953
- if (fd) return fd.path;
954
- }
955
- return constraint;
956
- }
957
- _mapFkError(detail, constraint) {
958
- const fkMatch = detail.match(/Key \(([^)]+)\)/);
959
- if (fkMatch) {
960
- const physicalCol = fkMatch[1].split(",")[0].trim();
961
- return [{
962
- path: this._table.fieldDescriptors.find((f) => f.physicalName === physicalCol)?.path ?? physicalCol,
963
- message: detail
964
- }];
965
- }
966
- return [{
967
- path: constraint ?? "",
968
- message: detail
969
- }];
970
- }
971
955
  async insertOne(data) {
972
956
  const insert = buildInsert(this.resolveTableName(), data);
973
957
  const sql = insert.sql + this._pk().returning;
@@ -1942,7 +1926,8 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
1942
1926
  const indexes = [];
1943
1927
  const { all: ftAll, def: ftDefault } = this._fulltextIndexes();
1944
1928
  for (const index of ftAll) indexes.push({
1945
- name: index.key,
1929
+ name: index.name,
1930
+ aliases: [index.key],
1946
1931
  description: (0, _atscript_db.describeFulltext)(index, (names) => `GIN tsvector index on ${names}`),
1947
1932
  type: "text",
1948
1933
  fields: this._indexLogicalPaths(index),
@@ -1964,7 +1949,7 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
1964
1949
  async search(text, query, indexName) {
1965
1950
  if (!text.trim()) return [];
1966
1951
  const combinedWhere = this._buildSearchWhere(text, query, indexName);
1967
- const { sql, params } = buildSelect(this.resolveTableName(), combinedWhere, query.controls);
1952
+ const { sql, params } = buildSelect(this.resolveTableName(), combinedWhere, query.controls, void 0, combinedWhere.rank);
1968
1953
  this._log(sql, params);
1969
1954
  return this._exec().all(sql, params);
1970
1955
  }
@@ -1976,7 +1961,7 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
1976
1961
  const combinedWhere = this._buildSearchWhere(text, query, indexName);
1977
1962
  const tableName = this.resolveTableName();
1978
1963
  const selectPromise = (async () => {
1979
- const { sql, params } = buildSelect(tableName, combinedWhere, query.controls);
1964
+ const { sql, params } = buildSelect(tableName, combinedWhere, query.controls, void 0, combinedWhere.rank);
1980
1965
  this._log(sql, params);
1981
1966
  return this._exec().all(sql, params);
1982
1967
  })();
@@ -1994,37 +1979,80 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
1994
1979
  count
1995
1980
  };
1996
1981
  }
1982
+ /**
1983
+ * The search predicate (AND the request filter) and its relevance order
1984
+ * (`rank`, since 0.1.165) — used as the ORDER BY when the read has no
1985
+ * `$sort`: exact-number hits first, then `ts_rank` over the text members
1986
+ * (per-field weights through `setweight` buckets), then the primary key.
1987
+ * Grouped searches use only the predicate.
1988
+ */
1997
1989
  _buildSearchWhere(text, query, indexName) {
1998
1990
  const fulltextIndex = this._getFulltextIndex(indexName);
1999
- if (!fulltextIndex) throw new Error("No fulltext index found for search");
1991
+ if (!fulltextIndex) throw new Error((0, _atscript_db.searchIndexNotFoundMessage)(indexName));
2000
1992
  const { text: textFields, integer: integerFields } = (0, _atscript_db.splitFulltextFields)(fulltextIndex);
2001
1993
  const where = buildWhere(query.filter);
2002
1994
  const n = integerFields.length > 0 ? (0, _atscript_db.searchTermInteger)(text) : void 0;
2003
1995
  const parts = [];
2004
1996
  const params = [];
1997
+ const equalities = n === void 0 ? [] : integerFields.map((f) => `${qi(f.name)} = CAST(? AS BIGINT)`);
1998
+ let textRank;
2005
1999
  if (textFields.length > 0) {
2006
- const tsvectorExpr = this._buildTsvectorExpr(textFields);
2007
- parts.push(`to_tsvector('english', ${tsvectorExpr}) @@ plainto_tsquery('english', ?)`);
2000
+ const tsvector = `to_tsvector('english', ${this._buildTsvectorExpr(textFields)})`;
2001
+ parts.push(`${tsvector} @@ plainto_tsquery('english', ?)`);
2008
2002
  params.push(text);
2003
+ textRank = {
2004
+ sql: `${this._buildTsRankExpr(textFields, tsvector)} DESC`,
2005
+ params: [text]
2006
+ };
2009
2007
  }
2010
- if (n !== void 0) for (const f of integerFields) {
2011
- parts.push(`${qi(f.name)} = CAST(? AS BIGINT)`);
2008
+ for (const eq of equalities) {
2009
+ parts.push(eq);
2012
2010
  params.push(n);
2013
2011
  }
2014
2012
  const clause = (0, _atscript_db_sql_tools.orFragment)(parts, params);
2015
2013
  return {
2016
2014
  sql: where.sql === "1=1" ? clause.sql : `${where.sql} AND ${clause.sql}`,
2017
- params: [...where.params, ...clause.params]
2015
+ params: [...where.params, ...clause.params],
2016
+ rank: (0, _atscript_db_sql_tools.searchRankOrder)(pgDialect, {
2017
+ exactEqualities: equalities,
2018
+ n,
2019
+ textRank,
2020
+ pkColumns: this._table.primaryKeys.map((key) => this._table.physicalPath(key))
2021
+ })
2018
2022
  };
2019
2023
  }
2024
+ /**
2025
+ * `ts_rank(…)` over the text members (one `?` — the search text). Without
2026
+ * per-field weights it ranks `tsvector` — the indexed expression — itself; with them each
2027
+ * member's vector is put in a `setweight` bucket (the distinct weights,
2028
+ * highest first, map to A, B, C, then D for every lower one) and the bucket
2029
+ * weights are passed relative to the highest (`{D, C, B, A}`). The match
2030
+ * predicate keeps the unweighted indexed expression, so the GIN index serves
2031
+ * it unchanged.
2032
+ */
2033
+ _buildTsRankExpr(fields, tsvector) {
2034
+ const query = `plainto_tsquery('english', ?)`;
2035
+ const distinct = [...new Set(fields.map(weightOf))].toSorted((a, b) => b - a);
2036
+ if (distinct.length <= 1) return `ts_rank(${tsvector}, ${query})`;
2037
+ const letters = [
2038
+ "A",
2039
+ "B",
2040
+ "C",
2041
+ "D"
2042
+ ];
2043
+ const bucketOf = (w) => Math.min(distinct.indexOf(w), 3);
2044
+ const top = distinct[0];
2045
+ const bucketWeights = letters.map((_, i) => distinct[Math.min(i, distinct.length - 1)] / top);
2046
+ const vector = fields.map((f) => `setweight(to_tsvector('english', coalesce(${qi(f.name)}, '')), '${letters[bucketOf(weightOf(f))]}')`).join(" || ");
2047
+ return `ts_rank('{${[...bucketWeights].toReversed().map((w) => Number(w.toFixed(6))).join(", ")}}', ${vector}, ${query})`;
2048
+ }
2020
2049
  /** Builds the tsvector SQL expression for a fulltext index's fields. Must match between index DDL and queries. */
2021
2050
  _buildTsvectorExpr(fields) {
2022
2051
  return fields.map((f) => `coalesce(${qi(f.name)}, '')`).join(" || ' ' || ");
2023
2052
  }
2024
2053
  _getFulltextIndex(indexName) {
2025
2054
  const { all, def } = this._fulltextIndexes();
2026
- if (!indexName) return def;
2027
- return all.find((index) => index.key === indexName);
2055
+ return indexName ? (0, _atscript_db.resolveFulltextIndex)(all, indexName) : def;
2028
2056
  }
2029
2057
  /** The table's fulltext indexes and the default one, built once per table. */
2030
2058
  _fulltextIndexes() {
@@ -2383,6 +2411,14 @@ function similarityToPgOps(similarity) {
2383
2411
  function vectorToString(vector) {
2384
2412
  return `[${vector.join(",")}]`;
2385
2413
  }
2414
+ /** The columns of a PostgreSQL `Key (a, b)=(…)` detail. */
2415
+ function keyColumns(detail) {
2416
+ return (detail?.match(/Key \(([^)]+)\)=/)?.[1])?.split(",").map((c) => c.trim().replace(/^"|"$/g, ""));
2417
+ }
2418
+ /** A fulltext member's weight (`@db.index.fulltext 'idx', w`), `1` when unset. */
2419
+ function weightOf(f) {
2420
+ return f.weight ?? 1;
2421
+ }
2386
2422
  //#endregion
2387
2423
  //#region src/pg-driver.ts
2388
2424
  const NO_PARAMS = [];
package/dist/index.d.cts CHANGED
@@ -213,8 +213,6 @@ declare class PostgresAdapter extends BaseDbAdapter {
213
213
  private _wrapConstraintError;
214
214
  /** Rethrows `error` as a structured `DbError` when it is a unique / FK violation or a deadlock / lock timeout, else as is. */
215
215
  private _mapConstraintError;
216
- private _extractFieldFromConstraint;
217
- private _mapFkError;
218
216
  insertOne(data: Record<string, unknown>): Promise<TDbInsertResult>;
219
217
  insertMany(data: Array<Record<string, unknown>>): Promise<TDbInsertManyResult>;
220
218
  supportsInsertIgnore(): boolean;
@@ -388,7 +386,24 @@ declare class PostgresAdapter extends BaseDbAdapter {
388
386
  data: Array<Record<string, unknown>>;
389
387
  count: number;
390
388
  }>;
389
+ /**
390
+ * The search predicate (AND the request filter) and its relevance order
391
+ * (`rank`, since 0.1.165) — used as the ORDER BY when the read has no
392
+ * `$sort`: exact-number hits first, then `ts_rank` over the text members
393
+ * (per-field weights through `setweight` buckets), then the primary key.
394
+ * Grouped searches use only the predicate.
395
+ */
391
396
  private _buildSearchWhere;
397
+ /**
398
+ * `ts_rank(…)` over the text members (one `?` — the search text). Without
399
+ * per-field weights it ranks `tsvector` — the indexed expression — itself; with them each
400
+ * member's vector is put in a `setweight` bucket (the distinct weights,
401
+ * highest first, map to A, B, C, then D for every lower one) and the bucket
402
+ * weights are passed relative to the highest (`{D, C, B, A}`). The match
403
+ * predicate keeps the unweighted indexed expression, so the GIN index serves
404
+ * it unchanged.
405
+ */
406
+ private _buildTsRankExpr;
392
407
  /** Builds the tsvector SQL expression for a fulltext index's fields. Must match between index DDL and queries. */
393
408
  private _buildTsvectorExpr;
394
409
  private _getFulltextIndex;
package/dist/index.d.mts CHANGED
@@ -213,8 +213,6 @@ declare class PostgresAdapter extends BaseDbAdapter {
213
213
  private _wrapConstraintError;
214
214
  /** Rethrows `error` as a structured `DbError` when it is a unique / FK violation or a deadlock / lock timeout, else as is. */
215
215
  private _mapConstraintError;
216
- private _extractFieldFromConstraint;
217
- private _mapFkError;
218
216
  insertOne(data: Record<string, unknown>): Promise<TDbInsertResult>;
219
217
  insertMany(data: Array<Record<string, unknown>>): Promise<TDbInsertManyResult>;
220
218
  supportsInsertIgnore(): boolean;
@@ -388,7 +386,24 @@ declare class PostgresAdapter extends BaseDbAdapter {
388
386
  data: Array<Record<string, unknown>>;
389
387
  count: number;
390
388
  }>;
389
+ /**
390
+ * The search predicate (AND the request filter) and its relevance order
391
+ * (`rank`, since 0.1.165) — used as the ORDER BY when the read has no
392
+ * `$sort`: exact-number hits first, then `ts_rank` over the text members
393
+ * (per-field weights through `setweight` buckets), then the primary key.
394
+ * Grouped searches use only the predicate.
395
+ */
391
396
  private _buildSearchWhere;
397
+ /**
398
+ * `ts_rank(…)` over the text members (one `?` — the search text). Without
399
+ * per-field weights it ranks `tsvector` — the indexed expression — itself; with them each
400
+ * member's vector is put in a `setweight` bucket (the distinct weights,
401
+ * highest first, map to A, B, C, then D for every lower one) and the bucket
402
+ * weights are passed relative to the highest (`{D, C, B, A}`). The match
403
+ * predicate keeps the unweighted indexed expression, so the GIN index serves
404
+ * it unchanged.
405
+ */
406
+ private _buildTsRankExpr;
392
407
  /** Builds the tsvector SQL expression for a fulltext index's fields. Must match between index DDL and queries. */
393
408
  private _buildTsvectorExpr;
394
409
  private _getFulltextIndex;
package/dist/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
- import { ALL_AGGREGATE_FNS, ALL_BUCKET_UNITS, ALL_ISOLATION_LEVELS, ALL_VIEW_CAPABILITIES, BaseDbAdapter, DbError, DbSpace, bucketTimeZoneUnavailable, createChangeRecorder, defaultFulltextIndex, describeFulltext, fkKey, isColumnTypeChanged, searchTermInteger, splitFulltextFields, uniqueKeyTuple, vectorIndexNotFoundMessage } from "@atscript/db";
1
+ import { ALL_AGGREGATE_FNS, ALL_BUCKET_UNITS, ALL_ISOLATION_LEVELS, ALL_VIEW_CAPABILITIES, BaseDbAdapter, DbError, DbSpace, bucketTimeZoneUnavailable, constraintViolation, createChangeRecorder, defaultFulltextIndex, describeFulltext, fkKey, isColumnTypeChanged, resolveFulltextIndex, searchIndexNotFoundMessage, searchTermInteger, splitFulltextFields, uniqueKeyTuple, vectorIndexNotFoundMessage } from "@atscript/db";
2
2
  import { resolveAggregateSearch } from "@atscript/db/agg";
3
- import { SEARCH_SOURCE_ALIAS, arithOverflowError, buildAggregateCount, buildAggregateSelect, buildCreateView, buildDelete, buildGeoSearchCount, buildGeoSearchSelect, buildInsert, buildInsertMany, buildJsonColumnCopy, buildJsonifyText, buildKeyViolationCount, buildPartitionedSelect, buildSelect, buildUpdate, buildVectorSearchCount, buildVectorSearchSelect, buildWhere as buildWhere$1, chunkInsertRows, defaultValueForType, defaultValueToSqlLiteral, derivedColumnExpr, fillReplacePayload, finalizeParams, foreignKeySql, geoWindowFromControls, mapQueryErrors, normalizeGeoPointValue, numericOutOfRangeError, orFragment, parseRegexString, quotedJsonPathSegments, renameGeoDistance, replaceColumnsFor, sqlStringLiteral, sqlTimeZoneLiteral, stripPartitionRowNumber, unquoteSqlStringLiteral, vectorDistanceSource, writeLimitWhere } from "@atscript/db-sql-tools";
3
+ import { SEARCH_SOURCE_ALIAS, arithOverflowError, buildAggregateCount, buildAggregateSelect, buildCreateView, buildDelete, buildGeoSearchCount, buildGeoSearchSelect, buildInsert, buildInsertMany, buildJsonColumnCopy, buildJsonifyText, buildKeyViolationCount, buildPartitionedSelect, buildSelect, buildUpdate, buildVectorSearchCount, buildVectorSearchSelect, buildWhere as buildWhere$1, chunkInsertRows, defaultValueForType, defaultValueToSqlLiteral, derivedColumnExpr, fillReplacePayload, finalizeParams, foreignKeySql, geoWindowFromControls, mapQueryErrors, normalizeGeoPointValue, numericOutOfRangeError, orFragment, parseRegexString, quotedJsonPathSegments, renameGeoDistance, replaceColumnsFor, searchRankOrder, sqlStringLiteral, sqlTimeZoneLiteral, stripPartitionRowNumber, unquoteSqlStringLiteral, vectorDistanceSource, writeLimitWhere } from "@atscript/db-sql-tools";
4
4
  import { BUCKET_MAX_INSTANT, BUCKET_MIN_INSTANT } from "@uniqu/core";
5
5
  //#region src/insert-ignore.ts
6
6
  /**
@@ -310,8 +310,8 @@ function buildInsert$1(table, data) {
310
310
  function buildInsertMany$1(table, rows, columns) {
311
311
  return buildInsertMany(pgDialect, table, rows, columns);
312
312
  }
313
- function buildSelect$1(table, where, controls, lock) {
314
- return buildSelect(pgDialect, table, where, controls, lock);
313
+ function buildSelect$1(table, where, controls, lock, defaultOrder) {
314
+ return buildSelect(pgDialect, table, where, controls, lock, defaultOrder);
315
315
  }
316
316
  function buildUpdate$1(table, data, where, limit, ops, versionColumn, expectedVersion) {
317
317
  return buildUpdate(pgDialect, table, data, where, limit, ops, versionColumn, expectedVersion);
@@ -930,11 +930,19 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
930
930
  _mapConstraintError(error) {
931
931
  if (error && typeof error === "object" && "code" in error) {
932
932
  const err = error;
933
- if (err.code === "23505") throw new DbError("CONFLICT", [{
934
- path: this._extractFieldFromConstraint(err.constraint) ?? "",
935
- message: err.detail ?? err.message
936
- }]);
937
- if (err.code === "23503") throw new DbError("FK_VIOLATION", this._mapFkError(err.detail ?? err.message, err.constraint));
933
+ if (err.code === "23505") throw constraintViolation("CONFLICT", {
934
+ name: err.constraint,
935
+ columns: keyColumns(err.detail),
936
+ primaryKey: err.constraint?.endsWith("_pkey")
937
+ }, err.detail ?? err.message);
938
+ if (err.code === "23503") {
939
+ const detail = err.detail ?? err.message;
940
+ const referencedBy = detail.match(/is still referenced from table "([^"]+)"/)?.[1];
941
+ throw constraintViolation("FK_VIOLATION", referencedBy ? { referencedBy } : {
942
+ name: err.constraint,
943
+ columns: keyColumns(detail)
944
+ }, detail);
945
+ }
938
946
  const contention = PG_CONTENTION_CODES[err.code];
939
947
  if (contention) throw new DbError(contention, [{
940
948
  path: "",
@@ -943,30 +951,6 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
943
951
  }
944
952
  throw error;
945
953
  }
946
- _extractFieldFromConstraint(constraint) {
947
- if (!constraint) return;
948
- const tableName = this._table.tableName;
949
- if (constraint.startsWith(`${tableName}_`) && constraint.endsWith("_key")) {
950
- const fieldPart = constraint.slice(tableName.length + 1, -4);
951
- const fd = this._table.fieldDescriptors.find((f) => f.physicalName === fieldPart);
952
- if (fd) return fd.path;
953
- }
954
- return constraint;
955
- }
956
- _mapFkError(detail, constraint) {
957
- const fkMatch = detail.match(/Key \(([^)]+)\)/);
958
- if (fkMatch) {
959
- const physicalCol = fkMatch[1].split(",")[0].trim();
960
- return [{
961
- path: this._table.fieldDescriptors.find((f) => f.physicalName === physicalCol)?.path ?? physicalCol,
962
- message: detail
963
- }];
964
- }
965
- return [{
966
- path: constraint ?? "",
967
- message: detail
968
- }];
969
- }
970
954
  async insertOne(data) {
971
955
  const insert = buildInsert$1(this.resolveTableName(), data);
972
956
  const sql = insert.sql + this._pk().returning;
@@ -1941,7 +1925,8 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
1941
1925
  const indexes = [];
1942
1926
  const { all: ftAll, def: ftDefault } = this._fulltextIndexes();
1943
1927
  for (const index of ftAll) indexes.push({
1944
- name: index.key,
1928
+ name: index.name,
1929
+ aliases: [index.key],
1945
1930
  description: describeFulltext(index, (names) => `GIN tsvector index on ${names}`),
1946
1931
  type: "text",
1947
1932
  fields: this._indexLogicalPaths(index),
@@ -1963,7 +1948,7 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
1963
1948
  async search(text, query, indexName) {
1964
1949
  if (!text.trim()) return [];
1965
1950
  const combinedWhere = this._buildSearchWhere(text, query, indexName);
1966
- const { sql, params } = buildSelect$1(this.resolveTableName(), combinedWhere, query.controls);
1951
+ const { sql, params } = buildSelect$1(this.resolveTableName(), combinedWhere, query.controls, void 0, combinedWhere.rank);
1967
1952
  this._log(sql, params);
1968
1953
  return this._exec().all(sql, params);
1969
1954
  }
@@ -1975,7 +1960,7 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
1975
1960
  const combinedWhere = this._buildSearchWhere(text, query, indexName);
1976
1961
  const tableName = this.resolveTableName();
1977
1962
  const selectPromise = (async () => {
1978
- const { sql, params } = buildSelect$1(tableName, combinedWhere, query.controls);
1963
+ const { sql, params } = buildSelect$1(tableName, combinedWhere, query.controls, void 0, combinedWhere.rank);
1979
1964
  this._log(sql, params);
1980
1965
  return this._exec().all(sql, params);
1981
1966
  })();
@@ -1993,37 +1978,80 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
1993
1978
  count
1994
1979
  };
1995
1980
  }
1981
+ /**
1982
+ * The search predicate (AND the request filter) and its relevance order
1983
+ * (`rank`, since 0.1.165) — used as the ORDER BY when the read has no
1984
+ * `$sort`: exact-number hits first, then `ts_rank` over the text members
1985
+ * (per-field weights through `setweight` buckets), then the primary key.
1986
+ * Grouped searches use only the predicate.
1987
+ */
1996
1988
  _buildSearchWhere(text, query, indexName) {
1997
1989
  const fulltextIndex = this._getFulltextIndex(indexName);
1998
- if (!fulltextIndex) throw new Error("No fulltext index found for search");
1990
+ if (!fulltextIndex) throw new Error(searchIndexNotFoundMessage(indexName));
1999
1991
  const { text: textFields, integer: integerFields } = splitFulltextFields(fulltextIndex);
2000
1992
  const where = buildWhere(query.filter);
2001
1993
  const n = integerFields.length > 0 ? searchTermInteger(text) : void 0;
2002
1994
  const parts = [];
2003
1995
  const params = [];
1996
+ const equalities = n === void 0 ? [] : integerFields.map((f) => `${qi(f.name)} = CAST(? AS BIGINT)`);
1997
+ let textRank;
2004
1998
  if (textFields.length > 0) {
2005
- const tsvectorExpr = this._buildTsvectorExpr(textFields);
2006
- parts.push(`to_tsvector('english', ${tsvectorExpr}) @@ plainto_tsquery('english', ?)`);
1999
+ const tsvector = `to_tsvector('english', ${this._buildTsvectorExpr(textFields)})`;
2000
+ parts.push(`${tsvector} @@ plainto_tsquery('english', ?)`);
2007
2001
  params.push(text);
2002
+ textRank = {
2003
+ sql: `${this._buildTsRankExpr(textFields, tsvector)} DESC`,
2004
+ params: [text]
2005
+ };
2008
2006
  }
2009
- if (n !== void 0) for (const f of integerFields) {
2010
- parts.push(`${qi(f.name)} = CAST(? AS BIGINT)`);
2007
+ for (const eq of equalities) {
2008
+ parts.push(eq);
2011
2009
  params.push(n);
2012
2010
  }
2013
2011
  const clause = orFragment(parts, params);
2014
2012
  return {
2015
2013
  sql: where.sql === "1=1" ? clause.sql : `${where.sql} AND ${clause.sql}`,
2016
- params: [...where.params, ...clause.params]
2014
+ params: [...where.params, ...clause.params],
2015
+ rank: searchRankOrder(pgDialect, {
2016
+ exactEqualities: equalities,
2017
+ n,
2018
+ textRank,
2019
+ pkColumns: this._table.primaryKeys.map((key) => this._table.physicalPath(key))
2020
+ })
2017
2021
  };
2018
2022
  }
2023
+ /**
2024
+ * `ts_rank(…)` over the text members (one `?` — the search text). Without
2025
+ * per-field weights it ranks `tsvector` — the indexed expression — itself; with them each
2026
+ * member's vector is put in a `setweight` bucket (the distinct weights,
2027
+ * highest first, map to A, B, C, then D for every lower one) and the bucket
2028
+ * weights are passed relative to the highest (`{D, C, B, A}`). The match
2029
+ * predicate keeps the unweighted indexed expression, so the GIN index serves
2030
+ * it unchanged.
2031
+ */
2032
+ _buildTsRankExpr(fields, tsvector) {
2033
+ const query = `plainto_tsquery('english', ?)`;
2034
+ const distinct = [...new Set(fields.map(weightOf))].toSorted((a, b) => b - a);
2035
+ if (distinct.length <= 1) return `ts_rank(${tsvector}, ${query})`;
2036
+ const letters = [
2037
+ "A",
2038
+ "B",
2039
+ "C",
2040
+ "D"
2041
+ ];
2042
+ const bucketOf = (w) => Math.min(distinct.indexOf(w), 3);
2043
+ const top = distinct[0];
2044
+ const bucketWeights = letters.map((_, i) => distinct[Math.min(i, distinct.length - 1)] / top);
2045
+ const vector = fields.map((f) => `setweight(to_tsvector('english', coalesce(${qi(f.name)}, '')), '${letters[bucketOf(weightOf(f))]}')`).join(" || ");
2046
+ return `ts_rank('{${[...bucketWeights].toReversed().map((w) => Number(w.toFixed(6))).join(", ")}}', ${vector}, ${query})`;
2047
+ }
2019
2048
  /** Builds the tsvector SQL expression for a fulltext index's fields. Must match between index DDL and queries. */
2020
2049
  _buildTsvectorExpr(fields) {
2021
2050
  return fields.map((f) => `coalesce(${qi(f.name)}, '')`).join(" || ' ' || ");
2022
2051
  }
2023
2052
  _getFulltextIndex(indexName) {
2024
2053
  const { all, def } = this._fulltextIndexes();
2025
- if (!indexName) return def;
2026
- return all.find((index) => index.key === indexName);
2054
+ return indexName ? resolveFulltextIndex(all, indexName) : def;
2027
2055
  }
2028
2056
  /** The table's fulltext indexes and the default one, built once per table. */
2029
2057
  _fulltextIndexes() {
@@ -2382,6 +2410,14 @@ function similarityToPgOps(similarity) {
2382
2410
  function vectorToString(vector) {
2383
2411
  return `[${vector.join(",")}]`;
2384
2412
  }
2413
+ /** The columns of a PostgreSQL `Key (a, b)=(…)` detail. */
2414
+ function keyColumns(detail) {
2415
+ return (detail?.match(/Key \(([^)]+)\)=/)?.[1])?.split(",").map((c) => c.trim().replace(/^"|"$/g, ""));
2416
+ }
2417
+ /** A fulltext member's weight (`@db.index.fulltext 'idx', w`), `1` when unset. */
2418
+ function weightOf(f) {
2419
+ return f.weight ?? 1;
2420
+ }
2385
2421
  //#endregion
2386
2422
  //#region src/pg-driver.ts
2387
2423
  const NO_PARAMS = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atscript/db-postgres",
3
- "version": "0.1.164",
3
+ "version": "0.1.165",
4
4
  "description": "PostgreSQL adapter for @atscript/db with pg (node-postgres) driver support.",
5
5
  "keywords": [
6
6
  "atscript",
@@ -43,20 +43,20 @@
43
43
  "access": "public"
44
44
  },
45
45
  "devDependencies": {
46
- "@atscript/core": "^0.1.109",
47
- "@atscript/typescript": "^0.1.109",
46
+ "@atscript/core": "^0.2.1",
47
+ "@atscript/typescript": "^0.2.1",
48
48
  "@types/pg": "^8.11.0",
49
49
  "@uniqu/core": "^0.1.15",
50
50
  "pg": "^8.13.0",
51
- "unplugin-atscript": "^0.1.109"
51
+ "unplugin-atscript": "^0.2.1"
52
52
  },
53
53
  "peerDependencies": {
54
- "@atscript/core": "^0.1.109",
55
- "@atscript/typescript": "^0.1.109",
54
+ "@atscript/core": "^0.2.1",
55
+ "@atscript/typescript": "^0.2.1",
56
56
  "@uniqu/core": "^0.1.15",
57
57
  "pg": ">=8.0.0",
58
- "@atscript/db": "^0.1.164",
59
- "@atscript/db-sql-tools": "^0.1.164"
58
+ "@atscript/db": "^0.1.165",
59
+ "@atscript/db-sql-tools": "^0.1.165"
60
60
  },
61
61
  "peerDependenciesMeta": {
62
62
  "pg": {