postgresdk 0.18.18 → 0.18.20

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/README.md CHANGED
@@ -680,6 +680,16 @@ const sorted = await sdk.users.list({
680
680
  order: ["asc", "desc"] // or use single direction: order: "asc"
681
681
  });
682
682
 
683
+ // DISTINCT ON - one row per unique value (PostgreSQL)
684
+ const latestPerUser = await sdk.events.list({
685
+ distinctOn: "user_id", // or array: ["user_id", "type"]
686
+ orderBy: "created_at",
687
+ order: "desc"
688
+ });
689
+ // Returns one event per user_id, ordered by created_at DESC.
690
+ // When orderBy contains columns outside of distinctOn, a subquery is used
691
+ // automatically so the outer ordering is always respected.
692
+
683
693
  // Advanced WHERE operators
684
694
  const filtered = await sdk.users.list({
685
695
  where: {
@@ -900,7 +910,59 @@ const uniqueResults = Array.from(
900
910
 
901
911
  **Note:** Vector columns are auto-detected during introspection. Rows with `NULL` embeddings are excluded from vector search results.
902
912
 
903
- See the generated SDK documentation for all available operators: `$eq`, `$ne`, `$gt`, `$gte`, `$lt`, `$lte`, `$in`, `$nin`, `$like`, `$ilike`, `$is`, `$isNot`, `$or`, `$and`, `$jsonbContains`, `$jsonbContainedBy`, `$jsonbHasKey`, `$jsonbHasAnyKeys`, `$jsonbHasAllKeys`, `$jsonbPath`.
913
+ #### Trigram Search (pg_trgm)
914
+
915
+ PostgreSDK supports full-text fuzzy search via [pg_trgm](https://www.postgresql.org/docs/current/pgtrgm.html). Requires the `pg_trgm` extension installed.
916
+
917
+ ```sql
918
+ CREATE EXTENSION IF NOT EXISTS "pg_trgm";
919
+ ```
920
+
921
+ ```typescript
922
+ // Basic trigram similarity search on a text field
923
+ const results = await sdk.books.list({
924
+ trigram: {
925
+ field: "title",
926
+ query: "postgrs", // typo-tolerant fuzzy match
927
+ metric: "similarity", // "similarity" (default), "wordSimilarity", "strictWordSimilarity"
928
+ threshold: 0.3 // optional: exclude rows below this score (0–1)
929
+ },
930
+ limit: 10
931
+ });
932
+
933
+ // _similarity score is automatically included in each result
934
+ results.data.forEach(book => {
935
+ console.log(book.title, book._similarity);
936
+ });
937
+
938
+ // Combine with WHERE filters
939
+ const filtered = await sdk.books.list({
940
+ trigram: { field: "title", query: "postgrs", threshold: 0.2 },
941
+ where: { published: true },
942
+ limit: 20
943
+ });
944
+ ```
945
+
946
+ **Similarity Metrics:**
947
+ - `similarity` (default): Standard trigram similarity — `"col" % value`. Fraction of matching trigrams.
948
+ - `wordSimilarity`: Highest similarity between the query and any word in the column — `value <% "col"`.
949
+ - `strictWordSimilarity`: Strict word similarity — `value <<% "col"`. Requires the query to match an entire word.
950
+
951
+ **Inline `where` operators (without a top-level `trigram` param):**
952
+ ```typescript
953
+ // Filter by trigram similarity inside a where clause
954
+ const results = await sdk.books.list({
955
+ where: {
956
+ title: { $similarity: "postgrs" } // % operator
957
+ // title: { $wordSimilarity: "postgrs" } // <% operator
958
+ // title: { $strictWordSimilarity: "postgrs" } // <<% operator
959
+ }
960
+ });
961
+ ```
962
+
963
+ **Note:** `trigram` and `vector` are mutually exclusive on a single `list()` call.
964
+
965
+ See the generated SDK documentation for all available operators: `$eq`, `$ne`, `$gt`, `$gte`, `$lt`, `$lte`, `$in`, `$nin`, `$like`, `$ilike`, `$similarity`, `$wordSimilarity`, `$strictWordSimilarity`, `$is`, `$isNot`, `$or`, `$and`, `$jsonbContains`, `$jsonbContainedBy`, `$jsonbHasKey`, `$jsonbHasAnyKeys`, `$jsonbHasAllKeys`, `$jsonbPath`.
904
966
 
905
967
  ---
906
968
 
package/dist/cli.js CHANGED
@@ -3293,7 +3293,13 @@ const listSchema = z.object({
3293
3293
  query: z.array(z.number()),
3294
3294
  metric: z.enum(["cosine", "l2", "inner"]).optional(),
3295
3295
  maxDistance: z.number().optional()
3296
- }).optional()` : ""}
3296
+ }).optional(),` : ""}
3297
+ trigram: z.object({
3298
+ field: z.string(),
3299
+ query: z.string(),
3300
+ metric: z.enum(["similarity", "wordSimilarity", "strictWordSimilarity"]).optional(),
3301
+ threshold: z.number().min(0).max(1).optional()
3302
+ }).optional()
3297
3303
  }).strict().refine(
3298
3304
  (data) => !(data.select && data.exclude),
3299
3305
  { message: "Cannot specify both 'select' and 'exclude' parameters" }
@@ -3964,10 +3970,11 @@ ${hasJsonbColumns ? ` /**
3964
3970
  metric?: "cosine" | "l2" | "inner";
3965
3971
  maxDistance?: number;
3966
3972
  };` : ""}
3973
+ trigram?: { field: string; query: string; metric?: "similarity" | "wordSimilarity" | "strictWordSimilarity"; threshold?: number };
3967
3974
  orderBy?: string | string[];
3968
3975
  order?: "asc" | "desc" | ("asc" | "desc")[];
3969
3976
  distinctOn?: string | string[];
3970
- }): Promise<PaginatedResponse<Partial<Select${Type}<TJsonb>>${hasVectorColumns ? " & { _distance?: number }" : ""}>>;
3977
+ }): Promise<PaginatedResponse<Partial<Select${Type}<TJsonb>> & { _similarity?: number }${hasVectorColumns ? " & { _distance?: number }" : ""}>>;
3971
3978
  /**
3972
3979
  * List ${table.name} records with field exclusion
3973
3980
  * @param params - Query parameters with exclude
@@ -3985,10 +3992,11 @@ ${hasJsonbColumns ? ` /**
3985
3992
  metric?: "cosine" | "l2" | "inner";
3986
3993
  maxDistance?: number;
3987
3994
  };` : ""}
3995
+ trigram?: { field: string; query: string; metric?: "similarity" | "wordSimilarity" | "strictWordSimilarity"; threshold?: number };
3988
3996
  orderBy?: string | string[];
3989
3997
  order?: "asc" | "desc" | ("asc" | "desc")[];
3990
3998
  distinctOn?: string | string[];
3991
- }): Promise<PaginatedResponse<Partial<Select${Type}<TJsonb>>${hasVectorColumns ? " & { _distance?: number }" : ""}>>;
3999
+ }): Promise<PaginatedResponse<Partial<Select${Type}<TJsonb>> & { _similarity?: number }${hasVectorColumns ? " & { _distance?: number }" : ""}>>;
3992
4000
  /**
3993
4001
  * List ${table.name} records with pagination and filtering
3994
4002
  * @param params - Query parameters
@@ -4017,10 +4025,11 @@ ${hasJsonbColumns ? ` /**
4017
4025
  metric?: "cosine" | "l2" | "inner";
4018
4026
  maxDistance?: number;
4019
4027
  };` : ""}
4028
+ trigram?: { field: string; query: string; metric?: "similarity" | "wordSimilarity" | "strictWordSimilarity"; threshold?: number };
4020
4029
  orderBy?: string | string[];
4021
4030
  order?: "asc" | "desc" | ("asc" | "desc")[];
4022
4031
  distinctOn?: string | string[];
4023
- }): Promise<PaginatedResponse<${Type}WithIncludes<TInclude>${hasVectorColumns ? " & { _distance?: number }" : ""}>>;
4032
+ }): Promise<PaginatedResponse<${Type}WithIncludes<TInclude> & { _similarity?: number }${hasVectorColumns ? " & { _distance?: number }" : ""}>>;
4024
4033
  async list<TJsonb extends Partial<Select${Type}> = {}>(params?: {
4025
4034
  include?: ${Type}IncludeSpec;
4026
4035
  select?: string[];
@@ -4034,11 +4043,12 @@ ${hasJsonbColumns ? ` /**
4034
4043
  metric?: "cosine" | "l2" | "inner";
4035
4044
  maxDistance?: number;
4036
4045
  };` : ""}
4046
+ trigram?: { field: string; query: string; metric?: "similarity" | "wordSimilarity" | "strictWordSimilarity"; threshold?: number };
4037
4047
  orderBy?: string | string[];
4038
4048
  order?: "asc" | "desc" | ("asc" | "desc")[];
4039
4049
  distinctOn?: string | string[];
4040
4050
  }): Promise<PaginatedResponse<Select${Type}<TJsonb> | Partial<Select${Type}<TJsonb>>>> {
4041
- return this.post<PaginatedResponse<Select${Type}<TJsonb>${hasVectorColumns ? " & { _distance?: number }" : ""}>>(\`\${this.resource}/list\`, params ?? {});
4051
+ return this.post<PaginatedResponse<Select${Type}<TJsonb> & { _similarity?: number }${hasVectorColumns ? " & { _distance?: number }" : ""}>>(\`\${this.resource}/list\`, params ?? {});
4042
4052
  }` : ` /**
4043
4053
  * List ${table.name} records with field selection
4044
4054
  * @param params - Query parameters with select
@@ -4056,10 +4066,11 @@ ${hasJsonbColumns ? ` /**
4056
4066
  metric?: "cosine" | "l2" | "inner";
4057
4067
  maxDistance?: number;
4058
4068
  };` : ""}
4069
+ trigram?: { field: string; query: string; metric?: "similarity" | "wordSimilarity" | "strictWordSimilarity"; threshold?: number };
4059
4070
  orderBy?: string | string[];
4060
4071
  order?: "asc" | "desc" | ("asc" | "desc")[];
4061
4072
  distinctOn?: string | string[];
4062
- }): Promise<PaginatedResponse<Partial<Select${Type}>${hasVectorColumns ? " & { _distance?: number }" : ""}>>;
4073
+ }): Promise<PaginatedResponse<Partial<Select${Type}> & { _similarity?: number }${hasVectorColumns ? " & { _distance?: number }" : ""}>>;
4063
4074
  /**
4064
4075
  * List ${table.name} records with field exclusion
4065
4076
  * @param params - Query parameters with exclude
@@ -4077,10 +4088,11 @@ ${hasJsonbColumns ? ` /**
4077
4088
  metric?: "cosine" | "l2" | "inner";
4078
4089
  maxDistance?: number;
4079
4090
  };` : ""}
4091
+ trigram?: { field: string; query: string; metric?: "similarity" | "wordSimilarity" | "strictWordSimilarity"; threshold?: number };
4080
4092
  orderBy?: string | string[];
4081
4093
  order?: "asc" | "desc" | ("asc" | "desc")[];
4082
4094
  distinctOn?: string | string[];
4083
- }): Promise<PaginatedResponse<Partial<Select${Type}>${hasVectorColumns ? " & { _distance?: number }" : ""}>>;
4095
+ }): Promise<PaginatedResponse<Partial<Select${Type}> & { _similarity?: number }${hasVectorColumns ? " & { _distance?: number }" : ""}>>;
4084
4096
  /**
4085
4097
  * List ${table.name} records with pagination and filtering
4086
4098
  * @param params - Query parameters
@@ -4103,10 +4115,11 @@ ${hasJsonbColumns ? ` /**
4103
4115
  metric?: "cosine" | "l2" | "inner";
4104
4116
  maxDistance?: number;
4105
4117
  };` : ""}
4118
+ trigram?: { field: string; query: string; metric?: "similarity" | "wordSimilarity" | "strictWordSimilarity"; threshold?: number };
4106
4119
  orderBy?: string | string[];
4107
4120
  order?: "asc" | "desc" | ("asc" | "desc")[];
4108
4121
  distinctOn?: string | string[];
4109
- }): Promise<PaginatedResponse<${Type}WithIncludes<TInclude>${hasVectorColumns ? " & { _distance?: number }" : ""}>>;
4122
+ }): Promise<PaginatedResponse<${Type}WithIncludes<TInclude> & { _similarity?: number }${hasVectorColumns ? " & { _distance?: number }" : ""}>>;
4110
4123
  async list(params?: {
4111
4124
  include?: ${Type}IncludeSpec;
4112
4125
  select?: string[];
@@ -4120,11 +4133,12 @@ ${hasJsonbColumns ? ` /**
4120
4133
  metric?: "cosine" | "l2" | "inner";
4121
4134
  maxDistance?: number;
4122
4135
  };` : ""}
4136
+ trigram?: { field: string; query: string; metric?: "similarity" | "wordSimilarity" | "strictWordSimilarity"; threshold?: number };
4123
4137
  orderBy?: string | string[];
4124
4138
  order?: "asc" | "desc" | ("asc" | "desc")[];
4125
4139
  distinctOn?: string | string[];
4126
4140
  }): Promise<PaginatedResponse<Select${Type} | Partial<Select${Type}>>> {
4127
- return this.post<PaginatedResponse<Select${Type}${hasVectorColumns ? " & { _distance?: number }" : ""}>>(\`\${this.resource}/list\`, params ?? {});
4141
+ return this.post<PaginatedResponse<Select${Type} & { _similarity?: number }${hasVectorColumns ? " & { _distance?: number }" : ""}>>(\`\${this.resource}/list\`, params ?? {});
4128
4142
  }`}
4129
4143
 
4130
4144
  ${hasJsonbColumns ? ` /**
@@ -5292,6 +5306,12 @@ export type WhereOperator<T> = {
5292
5306
  $like?: T extends string ? string : never;
5293
5307
  /** Case-insensitive LIKE (strings only) */
5294
5308
  $ilike?: T extends string ? string : never;
5309
+ /** Trigram similarity match - "col" % value (pg_trgm required, uses similarity_threshold GUC) */
5310
+ $similarity?: T extends string ? string : never;
5311
+ /** Word trigram similarity match - value <% "col" (pg_trgm required) */
5312
+ $wordSimilarity?: T extends string ? string : never;
5313
+ /** Strict word trigram similarity match - value <<% "col" (pg_trgm required) */
5314
+ $strictWordSimilarity?: T extends string ? string : never;
5295
5315
  /** IS NULL */
5296
5316
  $is?: null;
5297
5317
  /** IS NOT NULL */
@@ -6068,6 +6088,24 @@ function buildWhereClause(
6068
6088
  whereParams.push(opValue);
6069
6089
  paramIndex++;
6070
6090
  break;
6091
+ case '$similarity':
6092
+ // pg_trgm: boolean similarity operator (respects similarity_threshold GUC)
6093
+ whereParts.push(\`"\${key}" % $\${paramIndex}\`);
6094
+ whereParams.push(opValue);
6095
+ paramIndex++;
6096
+ break;
6097
+ case '$wordSimilarity':
6098
+ // pg_trgm: word similarity operator (value <% col)
6099
+ whereParts.push(\`$\${paramIndex} <% "\${key}"\`);
6100
+ whereParams.push(opValue);
6101
+ paramIndex++;
6102
+ break;
6103
+ case '$strictWordSimilarity':
6104
+ // pg_trgm: strict word similarity operator (value <<% col)
6105
+ whereParts.push(\`$\${paramIndex} <<% "\${key}"\`);
6106
+ whereParams.push(opValue);
6107
+ paramIndex++;
6108
+ break;
6071
6109
  case '$is':
6072
6110
  if (opValue === null) {
6073
6111
  whereParts.push(\`"\${key}" IS NULL\`);
@@ -6242,7 +6280,29 @@ function getVectorDistanceOperator(metric?: string): string {
6242
6280
  }
6243
6281
 
6244
6282
  /**
6245
- * LIST operation - Get multiple records with optional filters and vector search
6283
+ * Build the pg_trgm SQL function expression for a given field and metric.
6284
+ * $1 is always the query string parameter.
6285
+ */
6286
+ function getTrigramFnExpr(field: string, metric?: string): string {
6287
+ switch (metric) {
6288
+ case "wordSimilarity":
6289
+ return \`word_similarity($1, "\${field}")\`;
6290
+ case "strictWordSimilarity":
6291
+ return \`strict_word_similarity($1, "\${field}")\`;
6292
+ case "similarity":
6293
+ default:
6294
+ return \`similarity("\${field}", $1)\`;
6295
+ }
6296
+ }
6297
+
6298
+ /** Builds a SQL ORDER BY clause from parallel cols/dirs arrays. Returns "" when cols is empty. */
6299
+ function buildOrderBySQL(cols: string[], dirs: ("asc" | "desc")[]): string {
6300
+ if (cols.length === 0) return "";
6301
+ return \`ORDER BY \${cols.map((c, i) => \`"\${c}" \${(dirs[i] ?? "asc").toUpperCase()}\`).join(", ")}\`;
6302
+ }
6303
+
6304
+ /**
6305
+ * LIST operation - Get multiple records with optional filters, vector search, and trigram similarity search
6246
6306
  */
6247
6307
  export async function listRecords(
6248
6308
  ctx: OperationContext,
@@ -6260,23 +6320,48 @@ export async function listRecords(
6260
6320
  metric?: "cosine" | "l2" | "inner";
6261
6321
  maxDistance?: number;
6262
6322
  };
6323
+ trigram?: {
6324
+ field: string;
6325
+ query: string;
6326
+ metric?: "similarity" | "wordSimilarity" | "strictWordSimilarity";
6327
+ threshold?: number;
6328
+ };
6263
6329
  }
6264
6330
  ): Promise<{ data?: any; total?: number; limit?: number; offset?: number; hasMore?: boolean; error?: string; issues?: any; needsIncludes?: boolean; includeSpec?: any; status: number }> {
6265
6331
  try {
6266
- const { where: whereClause, limit = 50, offset = 0, include, orderBy, order, vector, distinctOn } = params;
6332
+ const { where: whereClause, limit = 50, offset = 0, include, orderBy, order, vector, trigram, distinctOn } = params;
6267
6333
 
6268
6334
  // DISTINCT ON support
6269
6335
  const distinctCols: string[] | null = distinctOn ? (Array.isArray(distinctOn) ? distinctOn : [distinctOn]) : null;
6270
6336
  const _distinctOnColsSQL = distinctCols ? distinctCols.map(c => '"' + c + '"').join(', ') : '';
6271
6337
 
6338
+ // Pre-compute user order cols (reused in ORDER BY block and useSubquery check)
6339
+ const userOrderCols: string[] = orderBy ? (Array.isArray(orderBy) ? orderBy : [orderBy]) : [];
6340
+
6341
+ // Auto-detect subquery form: needed when distinctOn is set AND the caller wants to order
6342
+ // by a column outside of distinctOn (inline DISTINCT ON can't satisfy that without silently
6343
+ // overriding the requested ordering). Vector/trigram search always stays inline.
6344
+ const useSubquery: boolean =
6345
+ distinctCols !== null &&
6346
+ !vector &&
6347
+ !trigram &&
6348
+ userOrderCols.some(col => !distinctCols.includes(col));
6349
+
6272
6350
  // Get distance operator if vector search
6273
6351
  const distanceOp = vector ? getVectorDistanceOperator(vector.metric) : "";
6274
6352
 
6275
- // Add vector to params array if present
6276
- const queryParams: any[] = vector ? [JSON.stringify(vector.query)] : [];
6353
+ // Get trigram similarity SQL expression (pg_trgm). $1 is always the query string.
6354
+ const trigramFnExpr = trigram ? getTrigramFnExpr(trigram.field, trigram.metric) : "";
6277
6355
 
6278
- // Build WHERE clause for SELECT/UPDATE queries (with vector as $1 if present)
6279
- let paramIndex = vector ? 2 : 1;
6356
+ // Add vector or trigram query as $1 if present (mutually exclusive)
6357
+ const queryParams: any[] = vector
6358
+ ? [JSON.stringify(vector.query)]
6359
+ : trigram
6360
+ ? [trigram.query]
6361
+ : [];
6362
+
6363
+ // Build WHERE clause for SELECT/UPDATE queries (with vector/trigram as $1 if present)
6364
+ let paramIndex = vector || trigram ? 2 : 1;
6280
6365
  const whereParts: string[] = [];
6281
6366
  let whereParams: any[] = [];
6282
6367
 
@@ -6300,28 +6385,38 @@ export async function listRecords(
6300
6385
  whereParts.push(\`("\${vector.field}" \${distanceOp} ($1)::vector) < \${vector.maxDistance}\`);
6301
6386
  }
6302
6387
 
6388
+ // Add trigram threshold filter if specified (inlined as literal — it's a float, safe)
6389
+ if (trigram?.threshold !== undefined) {
6390
+ whereParts.push(\`\${trigramFnExpr} >= \${trigram.threshold}\`);
6391
+ }
6392
+
6303
6393
  const whereSQL = whereParts.length > 0 ? \`WHERE \${whereParts.join(" AND ")}\` : "";
6304
6394
 
6305
6395
  // Build WHERE clause for COUNT query (may need different param indices)
6306
6396
  let countWhereSQL = whereSQL;
6307
6397
  let countParams = whereParams;
6308
6398
 
6309
- if (vector && vector.maxDistance === undefined && whereParams.length > 0) {
6310
- // COUNT query doesn't use vector, so rebuild WHERE without vector offset
6399
+ // When a $1 query param exists (vector/trigram) but has NO threshold filter, the COUNT query
6400
+ // must not reference $1 since it's only needed in SELECT/ORDER BY. Rebuild WHERE from scratch
6401
+ // starting at $1. When a threshold IS set, $1 appears in the WHERE condition so keep it.
6402
+ const hasQueryParam = !!(vector || trigram);
6403
+ const hasThreshold = vector?.maxDistance !== undefined || trigram?.threshold !== undefined;
6404
+
6405
+ if (hasQueryParam && !hasThreshold && whereParams.length > 0) {
6311
6406
  const countWhereParts: string[] = [];
6312
6407
  if (ctx.softDeleteColumn) {
6313
6408
  countWhereParts.push(\`"\${ctx.softDeleteColumn}" IS NULL\`);
6314
6409
  }
6315
6410
  if (whereClause) {
6316
- const result = buildWhereClause(whereClause, 1); // Start at $1 for count
6411
+ const result = buildWhereClause(whereClause, 1); // Start at $1, no query-param offset
6317
6412
  if (result.sql) {
6318
6413
  countWhereParts.push(result.sql);
6319
6414
  countParams = result.params;
6320
6415
  }
6321
6416
  }
6322
6417
  countWhereSQL = countWhereParts.length > 0 ? \`WHERE \${countWhereParts.join(" AND ")}\` : "";
6323
- } else if (vector?.maxDistance !== undefined) {
6324
- // COUNT query includes vector for maxDistance filter
6418
+ } else if (hasQueryParam && hasThreshold) {
6419
+ // $1 appears in WHERE threshold condition — COUNT needs the full params
6325
6420
  countParams = [...queryParams, ...whereParams];
6326
6421
  }
6327
6422
 
@@ -6330,45 +6425,44 @@ export async function listRecords(
6330
6425
  const baseColumns = buildColumnList(ctx.select, ctx.exclude, ctx.allColumnNames);
6331
6426
  const selectClause = vector
6332
6427
  ? \`\${_distinctOnPrefix}\${baseColumns}, ("\${vector.field}" \${distanceOp} ($1)::vector) AS _distance\`
6428
+ : trigram
6429
+ ? \`\${_distinctOnPrefix}\${baseColumns}, \${trigramFnExpr} AS _similarity\`
6333
6430
  : \`\${_distinctOnPrefix}\${baseColumns}\`;
6334
6431
 
6335
6432
  // Build ORDER BY clause
6336
6433
  let orderBySQL = "";
6434
+ const userDirs: ("asc" | "desc")[] = userOrderCols.length > 0
6435
+ ? (Array.isArray(order) ? order : (order ? Array(userOrderCols.length).fill(order) : Array(userOrderCols.length).fill("asc")))
6436
+ : [];
6437
+
6337
6438
  if (vector) {
6338
- // For vector search, always order by distance
6439
+ // Vector search always orders by distance; DISTINCT ON + vector stays inline
6339
6440
  orderBySQL = \`ORDER BY "\${vector.field}" \${distanceOp} ($1)::vector\`;
6340
- } else {
6341
- const userCols = orderBy ? (Array.isArray(orderBy) ? orderBy : [orderBy]) : [];
6342
- const userDirs: ("asc" | "desc")[] = orderBy
6343
- ? (Array.isArray(order) ? order : (order ? Array(userCols.length).fill(order) : Array(userCols.length).fill("asc")))
6344
- : [];
6345
-
6441
+ } else if (trigram) {
6442
+ // Trigram search orders by similarity score descending
6443
+ orderBySQL = \`ORDER BY _similarity DESC\`;
6444
+ } else if (useSubquery) {
6445
+ // Subquery form: outer query gets the user's full ORDER BY.
6446
+ // Inner query only needs to satisfy PG's DISTINCT ON prefix requirement (built at query assembly).
6447
+ orderBySQL = buildOrderBySQL(userOrderCols, userDirs);
6448
+ } else if (distinctCols) {
6449
+ // Inline DISTINCT ON: prepend distinctCols as the leftmost ORDER BY prefix (PG requirement)
6346
6450
  const finalCols: string[] = [];
6347
- const finalDirs: string[] = [];
6348
-
6349
- if (distinctCols) {
6350
- // DISTINCT ON requires its columns to be the leftmost ORDER BY prefix
6351
- for (const col of distinctCols) {
6352
- const userIdx = userCols.indexOf(col);
6353
- finalCols.push(col);
6354
- finalDirs.push(userIdx >= 0 ? (userDirs[userIdx] || "asc") : "asc");
6355
- }
6356
- // Append remaining user-specified cols not already covered by distinctOn
6357
- for (let i = 0; i < userCols.length; i++) {
6358
- if (!distinctCols.includes(userCols[i]!)) {
6359
- finalCols.push(userCols[i]!);
6360
- finalDirs.push(userDirs[i] || "asc");
6361
- }
6362
- }
6363
- } else {
6364
- finalCols.push(...userCols);
6365
- finalDirs.push(...userDirs.map(d => d || "asc"));
6451
+ const finalDirs: ("asc" | "desc")[] = [];
6452
+ for (const col of distinctCols) {
6453
+ const userIdx = userOrderCols.indexOf(col);
6454
+ finalCols.push(col);
6455
+ finalDirs.push(userIdx >= 0 ? (userDirs[userIdx] ?? "asc") : "asc");
6366
6456
  }
6367
-
6368
- if (finalCols.length > 0) {
6369
- const orderParts = finalCols.map((c, i) => \`"\${c}" \${finalDirs[i]!.toUpperCase()}\`);
6370
- orderBySQL = \`ORDER BY \${orderParts.join(", ")}\`;
6457
+ for (let i = 0; i < userOrderCols.length; i++) {
6458
+ if (!distinctCols.includes(userOrderCols[i]!)) {
6459
+ finalCols.push(userOrderCols[i]!);
6460
+ finalDirs.push(userDirs[i] ?? "asc");
6461
+ }
6371
6462
  }
6463
+ orderBySQL = buildOrderBySQL(finalCols, finalDirs);
6464
+ } else {
6465
+ orderBySQL = buildOrderBySQL(userOrderCols, userDirs);
6372
6466
  }
6373
6467
 
6374
6468
  // Add limit and offset params
@@ -6385,7 +6479,15 @@ export async function listRecords(
6385
6479
  const total = parseInt(countResult.rows[0].count, 10);
6386
6480
 
6387
6481
  // Get paginated data
6388
- const text = \`SELECT \${selectClause} FROM "\${ctx.table}" \${whereSQL} \${orderBySQL} LIMIT \${limitParam} OFFSET \${offsetParam}\`;
6482
+ let text: string;
6483
+ if (useSubquery) {
6484
+ // Inner query: DISTINCT ON with only the distinctCols ORDER BY prefix (PG requirement).
6485
+ // Outer query: free ORDER BY from the user's full orderBy list, plus LIMIT/OFFSET.
6486
+ const innerQuery = \`SELECT DISTINCT ON (\${_distinctOnColsSQL}) \${baseColumns} FROM "\${ctx.table}" \${whereSQL} ORDER BY \${_distinctOnColsSQL}\`;
6487
+ text = \`SELECT * FROM (\${innerQuery}) __distinct \${orderBySQL} LIMIT \${limitParam} OFFSET \${offsetParam}\`;
6488
+ } else {
6489
+ text = \`SELECT \${selectClause} FROM "\${ctx.table}" \${whereSQL} \${orderBySQL} LIMIT \${limitParam} OFFSET \${offsetParam}\`;
6490
+ }
6389
6491
  log.debug(\`LIST \${ctx.table} SQL:\`, text, "params:", allParams);
6390
6492
 
6391
6493
  const { rows } = await ctx.pg.query(text, allParams);
package/dist/index.js CHANGED
@@ -2332,7 +2332,13 @@ const listSchema = z.object({
2332
2332
  query: z.array(z.number()),
2333
2333
  metric: z.enum(["cosine", "l2", "inner"]).optional(),
2334
2334
  maxDistance: z.number().optional()
2335
- }).optional()` : ""}
2335
+ }).optional(),` : ""}
2336
+ trigram: z.object({
2337
+ field: z.string(),
2338
+ query: z.string(),
2339
+ metric: z.enum(["similarity", "wordSimilarity", "strictWordSimilarity"]).optional(),
2340
+ threshold: z.number().min(0).max(1).optional()
2341
+ }).optional()
2336
2342
  }).strict().refine(
2337
2343
  (data) => !(data.select && data.exclude),
2338
2344
  { message: "Cannot specify both 'select' and 'exclude' parameters" }
@@ -3003,10 +3009,11 @@ ${hasJsonbColumns ? ` /**
3003
3009
  metric?: "cosine" | "l2" | "inner";
3004
3010
  maxDistance?: number;
3005
3011
  };` : ""}
3012
+ trigram?: { field: string; query: string; metric?: "similarity" | "wordSimilarity" | "strictWordSimilarity"; threshold?: number };
3006
3013
  orderBy?: string | string[];
3007
3014
  order?: "asc" | "desc" | ("asc" | "desc")[];
3008
3015
  distinctOn?: string | string[];
3009
- }): Promise<PaginatedResponse<Partial<Select${Type}<TJsonb>>${hasVectorColumns ? " & { _distance?: number }" : ""}>>;
3016
+ }): Promise<PaginatedResponse<Partial<Select${Type}<TJsonb>> & { _similarity?: number }${hasVectorColumns ? " & { _distance?: number }" : ""}>>;
3010
3017
  /**
3011
3018
  * List ${table.name} records with field exclusion
3012
3019
  * @param params - Query parameters with exclude
@@ -3024,10 +3031,11 @@ ${hasJsonbColumns ? ` /**
3024
3031
  metric?: "cosine" | "l2" | "inner";
3025
3032
  maxDistance?: number;
3026
3033
  };` : ""}
3034
+ trigram?: { field: string; query: string; metric?: "similarity" | "wordSimilarity" | "strictWordSimilarity"; threshold?: number };
3027
3035
  orderBy?: string | string[];
3028
3036
  order?: "asc" | "desc" | ("asc" | "desc")[];
3029
3037
  distinctOn?: string | string[];
3030
- }): Promise<PaginatedResponse<Partial<Select${Type}<TJsonb>>${hasVectorColumns ? " & { _distance?: number }" : ""}>>;
3038
+ }): Promise<PaginatedResponse<Partial<Select${Type}<TJsonb>> & { _similarity?: number }${hasVectorColumns ? " & { _distance?: number }" : ""}>>;
3031
3039
  /**
3032
3040
  * List ${table.name} records with pagination and filtering
3033
3041
  * @param params - Query parameters
@@ -3056,10 +3064,11 @@ ${hasJsonbColumns ? ` /**
3056
3064
  metric?: "cosine" | "l2" | "inner";
3057
3065
  maxDistance?: number;
3058
3066
  };` : ""}
3067
+ trigram?: { field: string; query: string; metric?: "similarity" | "wordSimilarity" | "strictWordSimilarity"; threshold?: number };
3059
3068
  orderBy?: string | string[];
3060
3069
  order?: "asc" | "desc" | ("asc" | "desc")[];
3061
3070
  distinctOn?: string | string[];
3062
- }): Promise<PaginatedResponse<${Type}WithIncludes<TInclude>${hasVectorColumns ? " & { _distance?: number }" : ""}>>;
3071
+ }): Promise<PaginatedResponse<${Type}WithIncludes<TInclude> & { _similarity?: number }${hasVectorColumns ? " & { _distance?: number }" : ""}>>;
3063
3072
  async list<TJsonb extends Partial<Select${Type}> = {}>(params?: {
3064
3073
  include?: ${Type}IncludeSpec;
3065
3074
  select?: string[];
@@ -3073,11 +3082,12 @@ ${hasJsonbColumns ? ` /**
3073
3082
  metric?: "cosine" | "l2" | "inner";
3074
3083
  maxDistance?: number;
3075
3084
  };` : ""}
3085
+ trigram?: { field: string; query: string; metric?: "similarity" | "wordSimilarity" | "strictWordSimilarity"; threshold?: number };
3076
3086
  orderBy?: string | string[];
3077
3087
  order?: "asc" | "desc" | ("asc" | "desc")[];
3078
3088
  distinctOn?: string | string[];
3079
3089
  }): Promise<PaginatedResponse<Select${Type}<TJsonb> | Partial<Select${Type}<TJsonb>>>> {
3080
- return this.post<PaginatedResponse<Select${Type}<TJsonb>${hasVectorColumns ? " & { _distance?: number }" : ""}>>(\`\${this.resource}/list\`, params ?? {});
3090
+ return this.post<PaginatedResponse<Select${Type}<TJsonb> & { _similarity?: number }${hasVectorColumns ? " & { _distance?: number }" : ""}>>(\`\${this.resource}/list\`, params ?? {});
3081
3091
  }` : ` /**
3082
3092
  * List ${table.name} records with field selection
3083
3093
  * @param params - Query parameters with select
@@ -3095,10 +3105,11 @@ ${hasJsonbColumns ? ` /**
3095
3105
  metric?: "cosine" | "l2" | "inner";
3096
3106
  maxDistance?: number;
3097
3107
  };` : ""}
3108
+ trigram?: { field: string; query: string; metric?: "similarity" | "wordSimilarity" | "strictWordSimilarity"; threshold?: number };
3098
3109
  orderBy?: string | string[];
3099
3110
  order?: "asc" | "desc" | ("asc" | "desc")[];
3100
3111
  distinctOn?: string | string[];
3101
- }): Promise<PaginatedResponse<Partial<Select${Type}>${hasVectorColumns ? " & { _distance?: number }" : ""}>>;
3112
+ }): Promise<PaginatedResponse<Partial<Select${Type}> & { _similarity?: number }${hasVectorColumns ? " & { _distance?: number }" : ""}>>;
3102
3113
  /**
3103
3114
  * List ${table.name} records with field exclusion
3104
3115
  * @param params - Query parameters with exclude
@@ -3116,10 +3127,11 @@ ${hasJsonbColumns ? ` /**
3116
3127
  metric?: "cosine" | "l2" | "inner";
3117
3128
  maxDistance?: number;
3118
3129
  };` : ""}
3130
+ trigram?: { field: string; query: string; metric?: "similarity" | "wordSimilarity" | "strictWordSimilarity"; threshold?: number };
3119
3131
  orderBy?: string | string[];
3120
3132
  order?: "asc" | "desc" | ("asc" | "desc")[];
3121
3133
  distinctOn?: string | string[];
3122
- }): Promise<PaginatedResponse<Partial<Select${Type}>${hasVectorColumns ? " & { _distance?: number }" : ""}>>;
3134
+ }): Promise<PaginatedResponse<Partial<Select${Type}> & { _similarity?: number }${hasVectorColumns ? " & { _distance?: number }" : ""}>>;
3123
3135
  /**
3124
3136
  * List ${table.name} records with pagination and filtering
3125
3137
  * @param params - Query parameters
@@ -3142,10 +3154,11 @@ ${hasJsonbColumns ? ` /**
3142
3154
  metric?: "cosine" | "l2" | "inner";
3143
3155
  maxDistance?: number;
3144
3156
  };` : ""}
3157
+ trigram?: { field: string; query: string; metric?: "similarity" | "wordSimilarity" | "strictWordSimilarity"; threshold?: number };
3145
3158
  orderBy?: string | string[];
3146
3159
  order?: "asc" | "desc" | ("asc" | "desc")[];
3147
3160
  distinctOn?: string | string[];
3148
- }): Promise<PaginatedResponse<${Type}WithIncludes<TInclude>${hasVectorColumns ? " & { _distance?: number }" : ""}>>;
3161
+ }): Promise<PaginatedResponse<${Type}WithIncludes<TInclude> & { _similarity?: number }${hasVectorColumns ? " & { _distance?: number }" : ""}>>;
3149
3162
  async list(params?: {
3150
3163
  include?: ${Type}IncludeSpec;
3151
3164
  select?: string[];
@@ -3159,11 +3172,12 @@ ${hasJsonbColumns ? ` /**
3159
3172
  metric?: "cosine" | "l2" | "inner";
3160
3173
  maxDistance?: number;
3161
3174
  };` : ""}
3175
+ trigram?: { field: string; query: string; metric?: "similarity" | "wordSimilarity" | "strictWordSimilarity"; threshold?: number };
3162
3176
  orderBy?: string | string[];
3163
3177
  order?: "asc" | "desc" | ("asc" | "desc")[];
3164
3178
  distinctOn?: string | string[];
3165
3179
  }): Promise<PaginatedResponse<Select${Type} | Partial<Select${Type}>>> {
3166
- return this.post<PaginatedResponse<Select${Type}${hasVectorColumns ? " & { _distance?: number }" : ""}>>(\`\${this.resource}/list\`, params ?? {});
3180
+ return this.post<PaginatedResponse<Select${Type} & { _similarity?: number }${hasVectorColumns ? " & { _distance?: number }" : ""}>>(\`\${this.resource}/list\`, params ?? {});
3167
3181
  }`}
3168
3182
 
3169
3183
  ${hasJsonbColumns ? ` /**
@@ -4331,6 +4345,12 @@ export type WhereOperator<T> = {
4331
4345
  $like?: T extends string ? string : never;
4332
4346
  /** Case-insensitive LIKE (strings only) */
4333
4347
  $ilike?: T extends string ? string : never;
4348
+ /** Trigram similarity match - "col" % value (pg_trgm required, uses similarity_threshold GUC) */
4349
+ $similarity?: T extends string ? string : never;
4350
+ /** Word trigram similarity match - value <% "col" (pg_trgm required) */
4351
+ $wordSimilarity?: T extends string ? string : never;
4352
+ /** Strict word trigram similarity match - value <<% "col" (pg_trgm required) */
4353
+ $strictWordSimilarity?: T extends string ? string : never;
4334
4354
  /** IS NULL */
4335
4355
  $is?: null;
4336
4356
  /** IS NOT NULL */
@@ -5107,6 +5127,24 @@ function buildWhereClause(
5107
5127
  whereParams.push(opValue);
5108
5128
  paramIndex++;
5109
5129
  break;
5130
+ case '$similarity':
5131
+ // pg_trgm: boolean similarity operator (respects similarity_threshold GUC)
5132
+ whereParts.push(\`"\${key}" % $\${paramIndex}\`);
5133
+ whereParams.push(opValue);
5134
+ paramIndex++;
5135
+ break;
5136
+ case '$wordSimilarity':
5137
+ // pg_trgm: word similarity operator (value <% col)
5138
+ whereParts.push(\`$\${paramIndex} <% "\${key}"\`);
5139
+ whereParams.push(opValue);
5140
+ paramIndex++;
5141
+ break;
5142
+ case '$strictWordSimilarity':
5143
+ // pg_trgm: strict word similarity operator (value <<% col)
5144
+ whereParts.push(\`$\${paramIndex} <<% "\${key}"\`);
5145
+ whereParams.push(opValue);
5146
+ paramIndex++;
5147
+ break;
5110
5148
  case '$is':
5111
5149
  if (opValue === null) {
5112
5150
  whereParts.push(\`"\${key}" IS NULL\`);
@@ -5281,7 +5319,29 @@ function getVectorDistanceOperator(metric?: string): string {
5281
5319
  }
5282
5320
 
5283
5321
  /**
5284
- * LIST operation - Get multiple records with optional filters and vector search
5322
+ * Build the pg_trgm SQL function expression for a given field and metric.
5323
+ * $1 is always the query string parameter.
5324
+ */
5325
+ function getTrigramFnExpr(field: string, metric?: string): string {
5326
+ switch (metric) {
5327
+ case "wordSimilarity":
5328
+ return \`word_similarity($1, "\${field}")\`;
5329
+ case "strictWordSimilarity":
5330
+ return \`strict_word_similarity($1, "\${field}")\`;
5331
+ case "similarity":
5332
+ default:
5333
+ return \`similarity("\${field}", $1)\`;
5334
+ }
5335
+ }
5336
+
5337
+ /** Builds a SQL ORDER BY clause from parallel cols/dirs arrays. Returns "" when cols is empty. */
5338
+ function buildOrderBySQL(cols: string[], dirs: ("asc" | "desc")[]): string {
5339
+ if (cols.length === 0) return "";
5340
+ return \`ORDER BY \${cols.map((c, i) => \`"\${c}" \${(dirs[i] ?? "asc").toUpperCase()}\`).join(", ")}\`;
5341
+ }
5342
+
5343
+ /**
5344
+ * LIST operation - Get multiple records with optional filters, vector search, and trigram similarity search
5285
5345
  */
5286
5346
  export async function listRecords(
5287
5347
  ctx: OperationContext,
@@ -5299,23 +5359,48 @@ export async function listRecords(
5299
5359
  metric?: "cosine" | "l2" | "inner";
5300
5360
  maxDistance?: number;
5301
5361
  };
5362
+ trigram?: {
5363
+ field: string;
5364
+ query: string;
5365
+ metric?: "similarity" | "wordSimilarity" | "strictWordSimilarity";
5366
+ threshold?: number;
5367
+ };
5302
5368
  }
5303
5369
  ): Promise<{ data?: any; total?: number; limit?: number; offset?: number; hasMore?: boolean; error?: string; issues?: any; needsIncludes?: boolean; includeSpec?: any; status: number }> {
5304
5370
  try {
5305
- const { where: whereClause, limit = 50, offset = 0, include, orderBy, order, vector, distinctOn } = params;
5371
+ const { where: whereClause, limit = 50, offset = 0, include, orderBy, order, vector, trigram, distinctOn } = params;
5306
5372
 
5307
5373
  // DISTINCT ON support
5308
5374
  const distinctCols: string[] | null = distinctOn ? (Array.isArray(distinctOn) ? distinctOn : [distinctOn]) : null;
5309
5375
  const _distinctOnColsSQL = distinctCols ? distinctCols.map(c => '"' + c + '"').join(', ') : '';
5310
5376
 
5377
+ // Pre-compute user order cols (reused in ORDER BY block and useSubquery check)
5378
+ const userOrderCols: string[] = orderBy ? (Array.isArray(orderBy) ? orderBy : [orderBy]) : [];
5379
+
5380
+ // Auto-detect subquery form: needed when distinctOn is set AND the caller wants to order
5381
+ // by a column outside of distinctOn (inline DISTINCT ON can't satisfy that without silently
5382
+ // overriding the requested ordering). Vector/trigram search always stays inline.
5383
+ const useSubquery: boolean =
5384
+ distinctCols !== null &&
5385
+ !vector &&
5386
+ !trigram &&
5387
+ userOrderCols.some(col => !distinctCols.includes(col));
5388
+
5311
5389
  // Get distance operator if vector search
5312
5390
  const distanceOp = vector ? getVectorDistanceOperator(vector.metric) : "";
5313
5391
 
5314
- // Add vector to params array if present
5315
- const queryParams: any[] = vector ? [JSON.stringify(vector.query)] : [];
5392
+ // Get trigram similarity SQL expression (pg_trgm). $1 is always the query string.
5393
+ const trigramFnExpr = trigram ? getTrigramFnExpr(trigram.field, trigram.metric) : "";
5316
5394
 
5317
- // Build WHERE clause for SELECT/UPDATE queries (with vector as $1 if present)
5318
- let paramIndex = vector ? 2 : 1;
5395
+ // Add vector or trigram query as $1 if present (mutually exclusive)
5396
+ const queryParams: any[] = vector
5397
+ ? [JSON.stringify(vector.query)]
5398
+ : trigram
5399
+ ? [trigram.query]
5400
+ : [];
5401
+
5402
+ // Build WHERE clause for SELECT/UPDATE queries (with vector/trigram as $1 if present)
5403
+ let paramIndex = vector || trigram ? 2 : 1;
5319
5404
  const whereParts: string[] = [];
5320
5405
  let whereParams: any[] = [];
5321
5406
 
@@ -5339,28 +5424,38 @@ export async function listRecords(
5339
5424
  whereParts.push(\`("\${vector.field}" \${distanceOp} ($1)::vector) < \${vector.maxDistance}\`);
5340
5425
  }
5341
5426
 
5427
+ // Add trigram threshold filter if specified (inlined as literal — it's a float, safe)
5428
+ if (trigram?.threshold !== undefined) {
5429
+ whereParts.push(\`\${trigramFnExpr} >= \${trigram.threshold}\`);
5430
+ }
5431
+
5342
5432
  const whereSQL = whereParts.length > 0 ? \`WHERE \${whereParts.join(" AND ")}\` : "";
5343
5433
 
5344
5434
  // Build WHERE clause for COUNT query (may need different param indices)
5345
5435
  let countWhereSQL = whereSQL;
5346
5436
  let countParams = whereParams;
5347
5437
 
5348
- if (vector && vector.maxDistance === undefined && whereParams.length > 0) {
5349
- // COUNT query doesn't use vector, so rebuild WHERE without vector offset
5438
+ // When a $1 query param exists (vector/trigram) but has NO threshold filter, the COUNT query
5439
+ // must not reference $1 since it's only needed in SELECT/ORDER BY. Rebuild WHERE from scratch
5440
+ // starting at $1. When a threshold IS set, $1 appears in the WHERE condition so keep it.
5441
+ const hasQueryParam = !!(vector || trigram);
5442
+ const hasThreshold = vector?.maxDistance !== undefined || trigram?.threshold !== undefined;
5443
+
5444
+ if (hasQueryParam && !hasThreshold && whereParams.length > 0) {
5350
5445
  const countWhereParts: string[] = [];
5351
5446
  if (ctx.softDeleteColumn) {
5352
5447
  countWhereParts.push(\`"\${ctx.softDeleteColumn}" IS NULL\`);
5353
5448
  }
5354
5449
  if (whereClause) {
5355
- const result = buildWhereClause(whereClause, 1); // Start at $1 for count
5450
+ const result = buildWhereClause(whereClause, 1); // Start at $1, no query-param offset
5356
5451
  if (result.sql) {
5357
5452
  countWhereParts.push(result.sql);
5358
5453
  countParams = result.params;
5359
5454
  }
5360
5455
  }
5361
5456
  countWhereSQL = countWhereParts.length > 0 ? \`WHERE \${countWhereParts.join(" AND ")}\` : "";
5362
- } else if (vector?.maxDistance !== undefined) {
5363
- // COUNT query includes vector for maxDistance filter
5457
+ } else if (hasQueryParam && hasThreshold) {
5458
+ // $1 appears in WHERE threshold condition — COUNT needs the full params
5364
5459
  countParams = [...queryParams, ...whereParams];
5365
5460
  }
5366
5461
 
@@ -5369,45 +5464,44 @@ export async function listRecords(
5369
5464
  const baseColumns = buildColumnList(ctx.select, ctx.exclude, ctx.allColumnNames);
5370
5465
  const selectClause = vector
5371
5466
  ? \`\${_distinctOnPrefix}\${baseColumns}, ("\${vector.field}" \${distanceOp} ($1)::vector) AS _distance\`
5467
+ : trigram
5468
+ ? \`\${_distinctOnPrefix}\${baseColumns}, \${trigramFnExpr} AS _similarity\`
5372
5469
  : \`\${_distinctOnPrefix}\${baseColumns}\`;
5373
5470
 
5374
5471
  // Build ORDER BY clause
5375
5472
  let orderBySQL = "";
5473
+ const userDirs: ("asc" | "desc")[] = userOrderCols.length > 0
5474
+ ? (Array.isArray(order) ? order : (order ? Array(userOrderCols.length).fill(order) : Array(userOrderCols.length).fill("asc")))
5475
+ : [];
5476
+
5376
5477
  if (vector) {
5377
- // For vector search, always order by distance
5478
+ // Vector search always orders by distance; DISTINCT ON + vector stays inline
5378
5479
  orderBySQL = \`ORDER BY "\${vector.field}" \${distanceOp} ($1)::vector\`;
5379
- } else {
5380
- const userCols = orderBy ? (Array.isArray(orderBy) ? orderBy : [orderBy]) : [];
5381
- const userDirs: ("asc" | "desc")[] = orderBy
5382
- ? (Array.isArray(order) ? order : (order ? Array(userCols.length).fill(order) : Array(userCols.length).fill("asc")))
5383
- : [];
5384
-
5480
+ } else if (trigram) {
5481
+ // Trigram search orders by similarity score descending
5482
+ orderBySQL = \`ORDER BY _similarity DESC\`;
5483
+ } else if (useSubquery) {
5484
+ // Subquery form: outer query gets the user's full ORDER BY.
5485
+ // Inner query only needs to satisfy PG's DISTINCT ON prefix requirement (built at query assembly).
5486
+ orderBySQL = buildOrderBySQL(userOrderCols, userDirs);
5487
+ } else if (distinctCols) {
5488
+ // Inline DISTINCT ON: prepend distinctCols as the leftmost ORDER BY prefix (PG requirement)
5385
5489
  const finalCols: string[] = [];
5386
- const finalDirs: string[] = [];
5387
-
5388
- if (distinctCols) {
5389
- // DISTINCT ON requires its columns to be the leftmost ORDER BY prefix
5390
- for (const col of distinctCols) {
5391
- const userIdx = userCols.indexOf(col);
5392
- finalCols.push(col);
5393
- finalDirs.push(userIdx >= 0 ? (userDirs[userIdx] || "asc") : "asc");
5394
- }
5395
- // Append remaining user-specified cols not already covered by distinctOn
5396
- for (let i = 0; i < userCols.length; i++) {
5397
- if (!distinctCols.includes(userCols[i]!)) {
5398
- finalCols.push(userCols[i]!);
5399
- finalDirs.push(userDirs[i] || "asc");
5400
- }
5401
- }
5402
- } else {
5403
- finalCols.push(...userCols);
5404
- finalDirs.push(...userDirs.map(d => d || "asc"));
5490
+ const finalDirs: ("asc" | "desc")[] = [];
5491
+ for (const col of distinctCols) {
5492
+ const userIdx = userOrderCols.indexOf(col);
5493
+ finalCols.push(col);
5494
+ finalDirs.push(userIdx >= 0 ? (userDirs[userIdx] ?? "asc") : "asc");
5405
5495
  }
5406
-
5407
- if (finalCols.length > 0) {
5408
- const orderParts = finalCols.map((c, i) => \`"\${c}" \${finalDirs[i]!.toUpperCase()}\`);
5409
- orderBySQL = \`ORDER BY \${orderParts.join(", ")}\`;
5496
+ for (let i = 0; i < userOrderCols.length; i++) {
5497
+ if (!distinctCols.includes(userOrderCols[i]!)) {
5498
+ finalCols.push(userOrderCols[i]!);
5499
+ finalDirs.push(userDirs[i] ?? "asc");
5500
+ }
5410
5501
  }
5502
+ orderBySQL = buildOrderBySQL(finalCols, finalDirs);
5503
+ } else {
5504
+ orderBySQL = buildOrderBySQL(userOrderCols, userDirs);
5411
5505
  }
5412
5506
 
5413
5507
  // Add limit and offset params
@@ -5424,7 +5518,15 @@ export async function listRecords(
5424
5518
  const total = parseInt(countResult.rows[0].count, 10);
5425
5519
 
5426
5520
  // Get paginated data
5427
- const text = \`SELECT \${selectClause} FROM "\${ctx.table}" \${whereSQL} \${orderBySQL} LIMIT \${limitParam} OFFSET \${offsetParam}\`;
5521
+ let text: string;
5522
+ if (useSubquery) {
5523
+ // Inner query: DISTINCT ON with only the distinctCols ORDER BY prefix (PG requirement).
5524
+ // Outer query: free ORDER BY from the user's full orderBy list, plus LIMIT/OFFSET.
5525
+ const innerQuery = \`SELECT DISTINCT ON (\${_distinctOnColsSQL}) \${baseColumns} FROM "\${ctx.table}" \${whereSQL} ORDER BY \${_distinctOnColsSQL}\`;
5526
+ text = \`SELECT * FROM (\${innerQuery}) __distinct \${orderBySQL} LIMIT \${limitParam} OFFSET \${offsetParam}\`;
5527
+ } else {
5528
+ text = \`SELECT \${selectClause} FROM "\${ctx.table}" \${whereSQL} \${orderBySQL} LIMIT \${limitParam} OFFSET \${offsetParam}\`;
5529
+ }
5428
5530
  log.debug(\`LIST \${ctx.table} SQL:\`, text, "params:", allParams);
5429
5531
 
5430
5532
  const { rows } = await ctx.pg.query(text, allParams);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "postgresdk",
3
- "version": "0.18.18",
3
+ "version": "0.18.20",
4
4
  "description": "Generate a typed server/client SDK from a Postgres schema (includes, Zod, Hono).",
5
5
  "type": "module",
6
6
  "bin": {
@@ -22,7 +22,7 @@
22
22
  },
23
23
  "scripts": {
24
24
  "build": "bun build src/cli.ts src/index.ts --outdir dist --target node --format esm --external=pg --external=zod --external=hono --external=prompts --external=node:* && tsc -p tsconfig.build.json --emitDeclarationOnly",
25
- "test": "bun test:write-files && bun test:init && bun test:gen && bun test test/test-where-clause.test.ts && bun test test/test-where-or-and.test.ts && bun test test/test-nested-include-options.test.ts && bun test test/test-include-methods-with-options.test.ts && bun test:gen-with-tests && bun test:pull && bun test:enums && bun test:typecheck && bun test:drizzle-e2e && bun test test/test-numeric-mode-integration.test.ts && bun test test/test-jsonb-array-serialization.test.ts",
25
+ "test": "bun test:write-files && bun test:init && bun test:gen && bun test test/test-where-clause.test.ts && bun test test/test-where-or-and.test.ts && bun test test/test-nested-include-options.test.ts && bun test test/test-include-methods-with-options.test.ts && bun test:gen-with-tests && bun test:pull && bun test:enums && bun test:typecheck && bun test:drizzle-e2e && bun test test/test-numeric-mode-integration.test.ts && bun test test/test-jsonb-array-serialization.test.ts && bun test test/test-trigram-search.test.ts",
26
26
  "test:write-files": "bun test/test-write-files-if-changed.ts",
27
27
  "test:init": "bun test/test-init.ts",
28
28
  "test:gen": "bun test/test-gen.ts",