postgresdk 0.18.19 → 0.18.21

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,80 @@ 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
+ **Multi-field trigram search:**
964
+ ```typescript
965
+ // Greatest strategy (default): score = GREATEST(sim(name), sim(url))
966
+ const results = await sdk.websites.list({
967
+ trigram: { fields: ["name", "url"], query: "google", strategy: "greatest" }
968
+ });
969
+
970
+ // Concat strategy: concatenate fields before scoring ("name url")
971
+ const results = await sdk.websites.list({
972
+ trigram: { fields: ["name", "url"], query: "google", strategy: "concat" }
973
+ });
974
+
975
+ // Weighted strategy: weighted average of per-field scores
976
+ const results = await sdk.websites.list({
977
+ trigram: {
978
+ fields: [{ field: "name", weight: 2 }, { field: "url", weight: 1 }],
979
+ query: "google"
980
+ }
981
+ });
982
+ ```
983
+
984
+ **Note:** `trigram` and `vector` are mutually exclusive on a single `list()` call.
985
+
986
+ 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
987
 
905
988
  ---
906
989
 
package/dist/cli.js CHANGED
@@ -3228,6 +3228,7 @@ function emitHonoRoutes(table, _graph, opts) {
3228
3228
  const fileTableName = table.name;
3229
3229
  const Type = pascal(table.name);
3230
3230
  const hasVectorColumns = table.columns.some((c) => isVectorType(c.pgType));
3231
+ const trigramMetricZod = `z.enum(["similarity", "wordSimilarity", "strictWordSimilarity"]).optional()`;
3231
3232
  const vectorColumns = table.columns.filter((c) => isVectorType(c.pgType)).map((c) => c.name);
3232
3233
  const jsonbColumns = table.columns.filter((c) => isJsonbType(c.pgType)).map((c) => c.name);
3233
3234
  const rawPk = table.pk;
@@ -3293,7 +3294,28 @@ const listSchema = z.object({
3293
3294
  query: z.array(z.number()),
3294
3295
  metric: z.enum(["cosine", "l2", "inner"]).optional(),
3295
3296
  maxDistance: z.number().optional()
3296
- }).optional()` : ""}
3297
+ }).optional(),` : ""}
3298
+ trigram: z.union([
3299
+ z.object({
3300
+ field: z.string(),
3301
+ query: z.string(),
3302
+ metric: ${trigramMetricZod},
3303
+ threshold: z.number().min(0).max(1).optional()
3304
+ }),
3305
+ z.object({
3306
+ fields: z.array(z.string()).min(1),
3307
+ strategy: z.enum(["greatest", "concat"]).optional(),
3308
+ query: z.string(),
3309
+ metric: ${trigramMetricZod},
3310
+ threshold: z.number().min(0).max(1).optional()
3311
+ }),
3312
+ z.object({
3313
+ fields: z.array(z.object({ field: z.string(), weight: z.number().positive() })).min(1),
3314
+ query: z.string(),
3315
+ metric: ${trigramMetricZod},
3316
+ threshold: z.number().min(0).max(1).optional()
3317
+ })
3318
+ ]).optional()
3297
3319
  }).strict().refine(
3298
3320
  (data) => !(data.select && data.exclude),
3299
3321
  { message: "Cannot specify both 'select' and 'exclude' parameters" }
@@ -3561,6 +3583,7 @@ function analyzeIncludeSpec(includeSpec) {
3561
3583
  function emitClient(table, graph, opts, model) {
3562
3584
  const Type = pascal(table.name);
3563
3585
  const ext = opts.useJsExtensions ? ".js" : "";
3586
+ const trigramParamType = `{ field: string; query: string; metric?: "similarity" | "wordSimilarity" | "strictWordSimilarity"; threshold?: number } | { fields: string[]; strategy?: "greatest" | "concat"; query: string; metric?: "similarity" | "wordSimilarity" | "strictWordSimilarity"; threshold?: number } | { fields: Array<{ field: string; weight: number }>; query: string; metric?: "similarity" | "wordSimilarity" | "strictWordSimilarity"; threshold?: number }`;
3564
3587
  const hasVectorColumns = table.columns.some((c) => isVectorType2(c.pgType));
3565
3588
  const hasJsonbColumns = table.columns.some((c) => isJsonbType2(c.pgType));
3566
3589
  if (process.env.SDK_DEBUG) {
@@ -3964,10 +3987,11 @@ ${hasJsonbColumns ? ` /**
3964
3987
  metric?: "cosine" | "l2" | "inner";
3965
3988
  maxDistance?: number;
3966
3989
  };` : ""}
3990
+ trigram?: ${trigramParamType};
3967
3991
  orderBy?: string | string[];
3968
3992
  order?: "asc" | "desc" | ("asc" | "desc")[];
3969
3993
  distinctOn?: string | string[];
3970
- }): Promise<PaginatedResponse<Partial<Select${Type}<TJsonb>>${hasVectorColumns ? " & { _distance?: number }" : ""}>>;
3994
+ }): Promise<PaginatedResponse<Partial<Select${Type}<TJsonb>> & { _similarity?: number }${hasVectorColumns ? " & { _distance?: number }" : ""}>>;
3971
3995
  /**
3972
3996
  * List ${table.name} records with field exclusion
3973
3997
  * @param params - Query parameters with exclude
@@ -3985,10 +4009,11 @@ ${hasJsonbColumns ? ` /**
3985
4009
  metric?: "cosine" | "l2" | "inner";
3986
4010
  maxDistance?: number;
3987
4011
  };` : ""}
4012
+ trigram?: ${trigramParamType};
3988
4013
  orderBy?: string | string[];
3989
4014
  order?: "asc" | "desc" | ("asc" | "desc")[];
3990
4015
  distinctOn?: string | string[];
3991
- }): Promise<PaginatedResponse<Partial<Select${Type}<TJsonb>>${hasVectorColumns ? " & { _distance?: number }" : ""}>>;
4016
+ }): Promise<PaginatedResponse<Partial<Select${Type}<TJsonb>> & { _similarity?: number }${hasVectorColumns ? " & { _distance?: number }" : ""}>>;
3992
4017
  /**
3993
4018
  * List ${table.name} records with pagination and filtering
3994
4019
  * @param params - Query parameters
@@ -4017,10 +4042,11 @@ ${hasJsonbColumns ? ` /**
4017
4042
  metric?: "cosine" | "l2" | "inner";
4018
4043
  maxDistance?: number;
4019
4044
  };` : ""}
4045
+ trigram?: ${trigramParamType};
4020
4046
  orderBy?: string | string[];
4021
4047
  order?: "asc" | "desc" | ("asc" | "desc")[];
4022
4048
  distinctOn?: string | string[];
4023
- }): Promise<PaginatedResponse<${Type}WithIncludes<TInclude>${hasVectorColumns ? " & { _distance?: number }" : ""}>>;
4049
+ }): Promise<PaginatedResponse<${Type}WithIncludes<TInclude> & { _similarity?: number }${hasVectorColumns ? " & { _distance?: number }" : ""}>>;
4024
4050
  async list<TJsonb extends Partial<Select${Type}> = {}>(params?: {
4025
4051
  include?: ${Type}IncludeSpec;
4026
4052
  select?: string[];
@@ -4034,11 +4060,12 @@ ${hasJsonbColumns ? ` /**
4034
4060
  metric?: "cosine" | "l2" | "inner";
4035
4061
  maxDistance?: number;
4036
4062
  };` : ""}
4063
+ trigram?: ${trigramParamType};
4037
4064
  orderBy?: string | string[];
4038
4065
  order?: "asc" | "desc" | ("asc" | "desc")[];
4039
4066
  distinctOn?: string | string[];
4040
4067
  }): Promise<PaginatedResponse<Select${Type}<TJsonb> | Partial<Select${Type}<TJsonb>>>> {
4041
- return this.post<PaginatedResponse<Select${Type}<TJsonb>${hasVectorColumns ? " & { _distance?: number }" : ""}>>(\`\${this.resource}/list\`, params ?? {});
4068
+ return this.post<PaginatedResponse<Select${Type}<TJsonb> & { _similarity?: number }${hasVectorColumns ? " & { _distance?: number }" : ""}>>(\`\${this.resource}/list\`, params ?? {});
4042
4069
  }` : ` /**
4043
4070
  * List ${table.name} records with field selection
4044
4071
  * @param params - Query parameters with select
@@ -4056,10 +4083,11 @@ ${hasJsonbColumns ? ` /**
4056
4083
  metric?: "cosine" | "l2" | "inner";
4057
4084
  maxDistance?: number;
4058
4085
  };` : ""}
4086
+ trigram?: ${trigramParamType};
4059
4087
  orderBy?: string | string[];
4060
4088
  order?: "asc" | "desc" | ("asc" | "desc")[];
4061
4089
  distinctOn?: string | string[];
4062
- }): Promise<PaginatedResponse<Partial<Select${Type}>${hasVectorColumns ? " & { _distance?: number }" : ""}>>;
4090
+ }): Promise<PaginatedResponse<Partial<Select${Type}> & { _similarity?: number }${hasVectorColumns ? " & { _distance?: number }" : ""}>>;
4063
4091
  /**
4064
4092
  * List ${table.name} records with field exclusion
4065
4093
  * @param params - Query parameters with exclude
@@ -4077,10 +4105,11 @@ ${hasJsonbColumns ? ` /**
4077
4105
  metric?: "cosine" | "l2" | "inner";
4078
4106
  maxDistance?: number;
4079
4107
  };` : ""}
4108
+ trigram?: ${trigramParamType};
4080
4109
  orderBy?: string | string[];
4081
4110
  order?: "asc" | "desc" | ("asc" | "desc")[];
4082
4111
  distinctOn?: string | string[];
4083
- }): Promise<PaginatedResponse<Partial<Select${Type}>${hasVectorColumns ? " & { _distance?: number }" : ""}>>;
4112
+ }): Promise<PaginatedResponse<Partial<Select${Type}> & { _similarity?: number }${hasVectorColumns ? " & { _distance?: number }" : ""}>>;
4084
4113
  /**
4085
4114
  * List ${table.name} records with pagination and filtering
4086
4115
  * @param params - Query parameters
@@ -4103,10 +4132,11 @@ ${hasJsonbColumns ? ` /**
4103
4132
  metric?: "cosine" | "l2" | "inner";
4104
4133
  maxDistance?: number;
4105
4134
  };` : ""}
4135
+ trigram?: ${trigramParamType};
4106
4136
  orderBy?: string | string[];
4107
4137
  order?: "asc" | "desc" | ("asc" | "desc")[];
4108
4138
  distinctOn?: string | string[];
4109
- }): Promise<PaginatedResponse<${Type}WithIncludes<TInclude>${hasVectorColumns ? " & { _distance?: number }" : ""}>>;
4139
+ }): Promise<PaginatedResponse<${Type}WithIncludes<TInclude> & { _similarity?: number }${hasVectorColumns ? " & { _distance?: number }" : ""}>>;
4110
4140
  async list(params?: {
4111
4141
  include?: ${Type}IncludeSpec;
4112
4142
  select?: string[];
@@ -4120,11 +4150,12 @@ ${hasJsonbColumns ? ` /**
4120
4150
  metric?: "cosine" | "l2" | "inner";
4121
4151
  maxDistance?: number;
4122
4152
  };` : ""}
4153
+ trigram?: ${trigramParamType};
4123
4154
  orderBy?: string | string[];
4124
4155
  order?: "asc" | "desc" | ("asc" | "desc")[];
4125
4156
  distinctOn?: string | string[];
4126
4157
  }): Promise<PaginatedResponse<Select${Type} | Partial<Select${Type}>>> {
4127
- return this.post<PaginatedResponse<Select${Type}${hasVectorColumns ? " & { _distance?: number }" : ""}>>(\`\${this.resource}/list\`, params ?? {});
4158
+ return this.post<PaginatedResponse<Select${Type} & { _similarity?: number }${hasVectorColumns ? " & { _distance?: number }" : ""}>>(\`\${this.resource}/list\`, params ?? {});
4128
4159
  }`}
4129
4160
 
4130
4161
  ${hasJsonbColumns ? ` /**
@@ -5292,6 +5323,12 @@ export type WhereOperator<T> = {
5292
5323
  $like?: T extends string ? string : never;
5293
5324
  /** Case-insensitive LIKE (strings only) */
5294
5325
  $ilike?: T extends string ? string : never;
5326
+ /** Trigram similarity match - "col" % value (pg_trgm required, uses similarity_threshold GUC) */
5327
+ $similarity?: T extends string ? string : never;
5328
+ /** Word trigram similarity match - value <% "col" (pg_trgm required) */
5329
+ $wordSimilarity?: T extends string ? string : never;
5330
+ /** Strict word trigram similarity match - value <<% "col" (pg_trgm required) */
5331
+ $strictWordSimilarity?: T extends string ? string : never;
5295
5332
  /** IS NULL */
5296
5333
  $is?: null;
5297
5334
  /** IS NOT NULL */
@@ -5908,7 +5945,7 @@ export async function createRecord(
5908
5945
 
5909
5946
  const preparedVals = vals.map((v, i) =>
5910
5947
  v !== null && v !== undefined && typeof v === 'object' &&
5911
- (ctx.jsonbColumns?.includes(cols[i]) || ctx.vectorColumns?.includes(cols[i]))
5948
+ (ctx.jsonbColumns?.includes(cols[i]!) || ctx.vectorColumns?.includes(cols[i]!))
5912
5949
  ? JSON.stringify(v)
5913
5950
  : v
5914
5951
  );
@@ -6068,6 +6105,24 @@ function buildWhereClause(
6068
6105
  whereParams.push(opValue);
6069
6106
  paramIndex++;
6070
6107
  break;
6108
+ case '$similarity':
6109
+ // pg_trgm: boolean similarity operator (respects similarity_threshold GUC)
6110
+ whereParts.push(\`"\${key}" % $\${paramIndex}\`);
6111
+ whereParams.push(opValue);
6112
+ paramIndex++;
6113
+ break;
6114
+ case '$wordSimilarity':
6115
+ // pg_trgm: word similarity operator (value <% col)
6116
+ whereParts.push(\`$\${paramIndex} <% "\${key}"\`);
6117
+ whereParams.push(opValue);
6118
+ paramIndex++;
6119
+ break;
6120
+ case '$strictWordSimilarity':
6121
+ // pg_trgm: strict word similarity operator (value <<% col)
6122
+ whereParts.push(\`$\${paramIndex} <<% "\${key}"\`);
6123
+ whereParams.push(opValue);
6124
+ paramIndex++;
6125
+ break;
6071
6126
  case '$is':
6072
6127
  if (opValue === null) {
6073
6128
  whereParts.push(\`"\${key}" IS NULL\`);
@@ -6241,6 +6296,53 @@ function getVectorDistanceOperator(metric?: string): string {
6241
6296
  }
6242
6297
  }
6243
6298
 
6299
+ export type TrigramMetric = "similarity" | "wordSimilarity" | "strictWordSimilarity";
6300
+
6301
+ export type TrigramParam =
6302
+ | { field: string; query: string; metric?: TrigramMetric; threshold?: number }
6303
+ | { fields: string[]; strategy?: "greatest" | "concat"; query: string; metric?: TrigramMetric; threshold?: number }
6304
+ | { fields: Array<{ field: string; weight: number }>; query: string; metric?: TrigramMetric; threshold?: number };
6305
+
6306
+ /**
6307
+ * Build a pg_trgm function expression for a raw SQL field expression.
6308
+ * $1 is always the query string parameter.
6309
+ */
6310
+ function trigramFnRaw(fieldExpr: string, metric?: TrigramMetric): string {
6311
+ switch (metric) {
6312
+ case "wordSimilarity": return \`word_similarity($1, \${fieldExpr})\`;
6313
+ case "strictWordSimilarity": return \`strict_word_similarity($1, \${fieldExpr})\`;
6314
+ default: return \`similarity(\${fieldExpr}, $1)\`;
6315
+ }
6316
+ }
6317
+
6318
+ /** Build the full pg_trgm SQL expression for the given trigram param. */
6319
+ function getTrigramFnExpr(trigram: TrigramParam): string {
6320
+ if ("field" in trigram) {
6321
+ return trigramFnRaw(\`"\${trigram.field}"\`, trigram.metric);
6322
+ }
6323
+
6324
+ const { fields, metric } = trigram;
6325
+
6326
+ // Weighted: fields is Array<{ field, weight }>
6327
+ if (fields.length > 0 && typeof fields[0] === "object" && "weight" in fields[0]) {
6328
+ const wfields = fields as Array<{ field: string; weight: number }>;
6329
+ const totalWeight = wfields.reduce((sum, f) => sum + f.weight, 0);
6330
+ const terms = wfields.map(f => \`\${trigramFnRaw(\`"\${f.field}"\`, metric)} * \${f.weight}\`).join(" + ");
6331
+ return \`(\${terms}) / \${totalWeight}\`;
6332
+ }
6333
+
6334
+ const sfields = fields as string[];
6335
+ const strategy = (trigram as Extract<TrigramParam, { fields: string[] }>).strategy ?? "greatest";
6336
+
6337
+ if (strategy === "concat") {
6338
+ const concatExpr = sfields.map(f => \`"\${f}"\`).join(\` || ' ' || \`);
6339
+ return trigramFnRaw(concatExpr, metric);
6340
+ }
6341
+
6342
+ // greatest (default)
6343
+ return \`GREATEST(\${sfields.map(f => trigramFnRaw(\`"\${f}"\`, metric)).join(", ")})\`;
6344
+ }
6345
+
6244
6346
  /** Builds a SQL ORDER BY clause from parallel cols/dirs arrays. Returns "" when cols is empty. */
6245
6347
  function buildOrderBySQL(cols: string[], dirs: ("asc" | "desc")[]): string {
6246
6348
  if (cols.length === 0) return "";
@@ -6248,7 +6350,7 @@ function buildOrderBySQL(cols: string[], dirs: ("asc" | "desc")[]): string {
6248
6350
  }
6249
6351
 
6250
6352
  /**
6251
- * LIST operation - Get multiple records with optional filters and vector search
6353
+ * LIST operation - Get multiple records with optional filters, vector search, and trigram similarity search
6252
6354
  */
6253
6355
  export async function listRecords(
6254
6356
  ctx: OperationContext,
@@ -6266,10 +6368,11 @@ export async function listRecords(
6266
6368
  metric?: "cosine" | "l2" | "inner";
6267
6369
  maxDistance?: number;
6268
6370
  };
6371
+ trigram?: TrigramParam;
6269
6372
  }
6270
6373
  ): Promise<{ data?: any; total?: number; limit?: number; offset?: number; hasMore?: boolean; error?: string; issues?: any; needsIncludes?: boolean; includeSpec?: any; status: number }> {
6271
6374
  try {
6272
- const { where: whereClause, limit = 50, offset = 0, include, orderBy, order, vector, distinctOn } = params;
6375
+ const { where: whereClause, limit = 50, offset = 0, include, orderBy, order, vector, trigram, distinctOn } = params;
6273
6376
 
6274
6377
  // DISTINCT ON support
6275
6378
  const distinctCols: string[] | null = distinctOn ? (Array.isArray(distinctOn) ? distinctOn : [distinctOn]) : null;
@@ -6280,20 +6383,28 @@ export async function listRecords(
6280
6383
 
6281
6384
  // Auto-detect subquery form: needed when distinctOn is set AND the caller wants to order
6282
6385
  // by a column outside of distinctOn (inline DISTINCT ON can't satisfy that without silently
6283
- // overriding the requested ordering). Vector search always stays inline.
6386
+ // overriding the requested ordering). Vector/trigram search always stays inline.
6284
6387
  const useSubquery: boolean =
6285
6388
  distinctCols !== null &&
6286
6389
  !vector &&
6390
+ !trigram &&
6287
6391
  userOrderCols.some(col => !distinctCols.includes(col));
6288
6392
 
6289
6393
  // Get distance operator if vector search
6290
6394
  const distanceOp = vector ? getVectorDistanceOperator(vector.metric) : "";
6291
6395
 
6292
- // Add vector to params array if present
6293
- const queryParams: any[] = vector ? [JSON.stringify(vector.query)] : [];
6396
+ // Get trigram similarity SQL expression (pg_trgm). $1 is always the query string.
6397
+ const trigramFnExpr = trigram ? getTrigramFnExpr(trigram) : "";
6398
+
6399
+ // Add vector or trigram query as $1 if present (mutually exclusive)
6400
+ const queryParams: any[] = vector
6401
+ ? [JSON.stringify(vector.query)]
6402
+ : trigram
6403
+ ? [trigram.query]
6404
+ : [];
6294
6405
 
6295
- // Build WHERE clause for SELECT/UPDATE queries (with vector as $1 if present)
6296
- let paramIndex = vector ? 2 : 1;
6406
+ // Build WHERE clause for SELECT/UPDATE queries (with vector/trigram as $1 if present)
6407
+ let paramIndex = vector || trigram ? 2 : 1;
6297
6408
  const whereParts: string[] = [];
6298
6409
  let whereParams: any[] = [];
6299
6410
 
@@ -6317,28 +6428,38 @@ export async function listRecords(
6317
6428
  whereParts.push(\`("\${vector.field}" \${distanceOp} ($1)::vector) < \${vector.maxDistance}\`);
6318
6429
  }
6319
6430
 
6431
+ // Add trigram threshold filter if specified (inlined as literal — it's a float, safe)
6432
+ if (trigram?.threshold !== undefined) {
6433
+ whereParts.push(\`\${trigramFnExpr} >= \${trigram.threshold}\`);
6434
+ }
6435
+
6320
6436
  const whereSQL = whereParts.length > 0 ? \`WHERE \${whereParts.join(" AND ")}\` : "";
6321
6437
 
6322
6438
  // Build WHERE clause for COUNT query (may need different param indices)
6323
6439
  let countWhereSQL = whereSQL;
6324
6440
  let countParams = whereParams;
6325
6441
 
6326
- if (vector && vector.maxDistance === undefined && whereParams.length > 0) {
6327
- // COUNT query doesn't use vector, so rebuild WHERE without vector offset
6442
+ // When a $1 query param exists (vector/trigram) but has NO threshold filter, the COUNT query
6443
+ // must not reference $1 since it's only needed in SELECT/ORDER BY. Rebuild WHERE from scratch
6444
+ // starting at $1. When a threshold IS set, $1 appears in the WHERE condition so keep it.
6445
+ const hasQueryParam = !!(vector || trigram);
6446
+ const hasThreshold = vector?.maxDistance !== undefined || trigram?.threshold !== undefined;
6447
+
6448
+ if (hasQueryParam && !hasThreshold && whereParams.length > 0) {
6328
6449
  const countWhereParts: string[] = [];
6329
6450
  if (ctx.softDeleteColumn) {
6330
6451
  countWhereParts.push(\`"\${ctx.softDeleteColumn}" IS NULL\`);
6331
6452
  }
6332
6453
  if (whereClause) {
6333
- const result = buildWhereClause(whereClause, 1); // Start at $1 for count
6454
+ const result = buildWhereClause(whereClause, 1); // Start at $1, no query-param offset
6334
6455
  if (result.sql) {
6335
6456
  countWhereParts.push(result.sql);
6336
6457
  countParams = result.params;
6337
6458
  }
6338
6459
  }
6339
6460
  countWhereSQL = countWhereParts.length > 0 ? \`WHERE \${countWhereParts.join(" AND ")}\` : "";
6340
- } else if (vector?.maxDistance !== undefined) {
6341
- // COUNT query includes vector for maxDistance filter
6461
+ } else if (hasQueryParam && hasThreshold) {
6462
+ // $1 appears in WHERE threshold condition — COUNT needs the full params
6342
6463
  countParams = [...queryParams, ...whereParams];
6343
6464
  }
6344
6465
 
@@ -6347,6 +6468,8 @@ export async function listRecords(
6347
6468
  const baseColumns = buildColumnList(ctx.select, ctx.exclude, ctx.allColumnNames);
6348
6469
  const selectClause = vector
6349
6470
  ? \`\${_distinctOnPrefix}\${baseColumns}, ("\${vector.field}" \${distanceOp} ($1)::vector) AS _distance\`
6471
+ : trigram
6472
+ ? \`\${_distinctOnPrefix}\${baseColumns}, \${trigramFnExpr} AS _similarity\`
6350
6473
  : \`\${_distinctOnPrefix}\${baseColumns}\`;
6351
6474
 
6352
6475
  // Build ORDER BY clause
@@ -6358,6 +6481,9 @@ export async function listRecords(
6358
6481
  if (vector) {
6359
6482
  // Vector search always orders by distance; DISTINCT ON + vector stays inline
6360
6483
  orderBySQL = \`ORDER BY "\${vector.field}" \${distanceOp} ($1)::vector\`;
6484
+ } else if (trigram) {
6485
+ // Trigram search orders by similarity score descending
6486
+ orderBySQL = \`ORDER BY _similarity DESC\`;
6361
6487
  } else if (useSubquery) {
6362
6488
  // Subquery form: outer query gets the user's full ORDER BY.
6363
6489
  // Inner query only needs to satisfy PG's DISTINCT ON prefix requirement (built at query assembly).
package/dist/index.js CHANGED
@@ -2267,6 +2267,7 @@ function emitHonoRoutes(table, _graph, opts) {
2267
2267
  const fileTableName = table.name;
2268
2268
  const Type = pascal(table.name);
2269
2269
  const hasVectorColumns = table.columns.some((c) => isVectorType(c.pgType));
2270
+ const trigramMetricZod = `z.enum(["similarity", "wordSimilarity", "strictWordSimilarity"]).optional()`;
2270
2271
  const vectorColumns = table.columns.filter((c) => isVectorType(c.pgType)).map((c) => c.name);
2271
2272
  const jsonbColumns = table.columns.filter((c) => isJsonbType(c.pgType)).map((c) => c.name);
2272
2273
  const rawPk = table.pk;
@@ -2332,7 +2333,28 @@ const listSchema = z.object({
2332
2333
  query: z.array(z.number()),
2333
2334
  metric: z.enum(["cosine", "l2", "inner"]).optional(),
2334
2335
  maxDistance: z.number().optional()
2335
- }).optional()` : ""}
2336
+ }).optional(),` : ""}
2337
+ trigram: z.union([
2338
+ z.object({
2339
+ field: z.string(),
2340
+ query: z.string(),
2341
+ metric: ${trigramMetricZod},
2342
+ threshold: z.number().min(0).max(1).optional()
2343
+ }),
2344
+ z.object({
2345
+ fields: z.array(z.string()).min(1),
2346
+ strategy: z.enum(["greatest", "concat"]).optional(),
2347
+ query: z.string(),
2348
+ metric: ${trigramMetricZod},
2349
+ threshold: z.number().min(0).max(1).optional()
2350
+ }),
2351
+ z.object({
2352
+ fields: z.array(z.object({ field: z.string(), weight: z.number().positive() })).min(1),
2353
+ query: z.string(),
2354
+ metric: ${trigramMetricZod},
2355
+ threshold: z.number().min(0).max(1).optional()
2356
+ })
2357
+ ]).optional()
2336
2358
  }).strict().refine(
2337
2359
  (data) => !(data.select && data.exclude),
2338
2360
  { message: "Cannot specify both 'select' and 'exclude' parameters" }
@@ -2600,6 +2622,7 @@ function analyzeIncludeSpec(includeSpec) {
2600
2622
  function emitClient(table, graph, opts, model) {
2601
2623
  const Type = pascal(table.name);
2602
2624
  const ext = opts.useJsExtensions ? ".js" : "";
2625
+ const trigramParamType = `{ field: string; query: string; metric?: "similarity" | "wordSimilarity" | "strictWordSimilarity"; threshold?: number } | { fields: string[]; strategy?: "greatest" | "concat"; query: string; metric?: "similarity" | "wordSimilarity" | "strictWordSimilarity"; threshold?: number } | { fields: Array<{ field: string; weight: number }>; query: string; metric?: "similarity" | "wordSimilarity" | "strictWordSimilarity"; threshold?: number }`;
2603
2626
  const hasVectorColumns = table.columns.some((c) => isVectorType2(c.pgType));
2604
2627
  const hasJsonbColumns = table.columns.some((c) => isJsonbType2(c.pgType));
2605
2628
  if (process.env.SDK_DEBUG) {
@@ -3003,10 +3026,11 @@ ${hasJsonbColumns ? ` /**
3003
3026
  metric?: "cosine" | "l2" | "inner";
3004
3027
  maxDistance?: number;
3005
3028
  };` : ""}
3029
+ trigram?: ${trigramParamType};
3006
3030
  orderBy?: string | string[];
3007
3031
  order?: "asc" | "desc" | ("asc" | "desc")[];
3008
3032
  distinctOn?: string | string[];
3009
- }): Promise<PaginatedResponse<Partial<Select${Type}<TJsonb>>${hasVectorColumns ? " & { _distance?: number }" : ""}>>;
3033
+ }): Promise<PaginatedResponse<Partial<Select${Type}<TJsonb>> & { _similarity?: number }${hasVectorColumns ? " & { _distance?: number }" : ""}>>;
3010
3034
  /**
3011
3035
  * List ${table.name} records with field exclusion
3012
3036
  * @param params - Query parameters with exclude
@@ -3024,10 +3048,11 @@ ${hasJsonbColumns ? ` /**
3024
3048
  metric?: "cosine" | "l2" | "inner";
3025
3049
  maxDistance?: number;
3026
3050
  };` : ""}
3051
+ trigram?: ${trigramParamType};
3027
3052
  orderBy?: string | string[];
3028
3053
  order?: "asc" | "desc" | ("asc" | "desc")[];
3029
3054
  distinctOn?: string | string[];
3030
- }): Promise<PaginatedResponse<Partial<Select${Type}<TJsonb>>${hasVectorColumns ? " & { _distance?: number }" : ""}>>;
3055
+ }): Promise<PaginatedResponse<Partial<Select${Type}<TJsonb>> & { _similarity?: number }${hasVectorColumns ? " & { _distance?: number }" : ""}>>;
3031
3056
  /**
3032
3057
  * List ${table.name} records with pagination and filtering
3033
3058
  * @param params - Query parameters
@@ -3056,10 +3081,11 @@ ${hasJsonbColumns ? ` /**
3056
3081
  metric?: "cosine" | "l2" | "inner";
3057
3082
  maxDistance?: number;
3058
3083
  };` : ""}
3084
+ trigram?: ${trigramParamType};
3059
3085
  orderBy?: string | string[];
3060
3086
  order?: "asc" | "desc" | ("asc" | "desc")[];
3061
3087
  distinctOn?: string | string[];
3062
- }): Promise<PaginatedResponse<${Type}WithIncludes<TInclude>${hasVectorColumns ? " & { _distance?: number }" : ""}>>;
3088
+ }): Promise<PaginatedResponse<${Type}WithIncludes<TInclude> & { _similarity?: number }${hasVectorColumns ? " & { _distance?: number }" : ""}>>;
3063
3089
  async list<TJsonb extends Partial<Select${Type}> = {}>(params?: {
3064
3090
  include?: ${Type}IncludeSpec;
3065
3091
  select?: string[];
@@ -3073,11 +3099,12 @@ ${hasJsonbColumns ? ` /**
3073
3099
  metric?: "cosine" | "l2" | "inner";
3074
3100
  maxDistance?: number;
3075
3101
  };` : ""}
3102
+ trigram?: ${trigramParamType};
3076
3103
  orderBy?: string | string[];
3077
3104
  order?: "asc" | "desc" | ("asc" | "desc")[];
3078
3105
  distinctOn?: string | string[];
3079
3106
  }): Promise<PaginatedResponse<Select${Type}<TJsonb> | Partial<Select${Type}<TJsonb>>>> {
3080
- return this.post<PaginatedResponse<Select${Type}<TJsonb>${hasVectorColumns ? " & { _distance?: number }" : ""}>>(\`\${this.resource}/list\`, params ?? {});
3107
+ return this.post<PaginatedResponse<Select${Type}<TJsonb> & { _similarity?: number }${hasVectorColumns ? " & { _distance?: number }" : ""}>>(\`\${this.resource}/list\`, params ?? {});
3081
3108
  }` : ` /**
3082
3109
  * List ${table.name} records with field selection
3083
3110
  * @param params - Query parameters with select
@@ -3095,10 +3122,11 @@ ${hasJsonbColumns ? ` /**
3095
3122
  metric?: "cosine" | "l2" | "inner";
3096
3123
  maxDistance?: number;
3097
3124
  };` : ""}
3125
+ trigram?: ${trigramParamType};
3098
3126
  orderBy?: string | string[];
3099
3127
  order?: "asc" | "desc" | ("asc" | "desc")[];
3100
3128
  distinctOn?: string | string[];
3101
- }): Promise<PaginatedResponse<Partial<Select${Type}>${hasVectorColumns ? " & { _distance?: number }" : ""}>>;
3129
+ }): Promise<PaginatedResponse<Partial<Select${Type}> & { _similarity?: number }${hasVectorColumns ? " & { _distance?: number }" : ""}>>;
3102
3130
  /**
3103
3131
  * List ${table.name} records with field exclusion
3104
3132
  * @param params - Query parameters with exclude
@@ -3116,10 +3144,11 @@ ${hasJsonbColumns ? ` /**
3116
3144
  metric?: "cosine" | "l2" | "inner";
3117
3145
  maxDistance?: number;
3118
3146
  };` : ""}
3147
+ trigram?: ${trigramParamType};
3119
3148
  orderBy?: string | string[];
3120
3149
  order?: "asc" | "desc" | ("asc" | "desc")[];
3121
3150
  distinctOn?: string | string[];
3122
- }): Promise<PaginatedResponse<Partial<Select${Type}>${hasVectorColumns ? " & { _distance?: number }" : ""}>>;
3151
+ }): Promise<PaginatedResponse<Partial<Select${Type}> & { _similarity?: number }${hasVectorColumns ? " & { _distance?: number }" : ""}>>;
3123
3152
  /**
3124
3153
  * List ${table.name} records with pagination and filtering
3125
3154
  * @param params - Query parameters
@@ -3142,10 +3171,11 @@ ${hasJsonbColumns ? ` /**
3142
3171
  metric?: "cosine" | "l2" | "inner";
3143
3172
  maxDistance?: number;
3144
3173
  };` : ""}
3174
+ trigram?: ${trigramParamType};
3145
3175
  orderBy?: string | string[];
3146
3176
  order?: "asc" | "desc" | ("asc" | "desc")[];
3147
3177
  distinctOn?: string | string[];
3148
- }): Promise<PaginatedResponse<${Type}WithIncludes<TInclude>${hasVectorColumns ? " & { _distance?: number }" : ""}>>;
3178
+ }): Promise<PaginatedResponse<${Type}WithIncludes<TInclude> & { _similarity?: number }${hasVectorColumns ? " & { _distance?: number }" : ""}>>;
3149
3179
  async list(params?: {
3150
3180
  include?: ${Type}IncludeSpec;
3151
3181
  select?: string[];
@@ -3159,11 +3189,12 @@ ${hasJsonbColumns ? ` /**
3159
3189
  metric?: "cosine" | "l2" | "inner";
3160
3190
  maxDistance?: number;
3161
3191
  };` : ""}
3192
+ trigram?: ${trigramParamType};
3162
3193
  orderBy?: string | string[];
3163
3194
  order?: "asc" | "desc" | ("asc" | "desc")[];
3164
3195
  distinctOn?: string | string[];
3165
3196
  }): Promise<PaginatedResponse<Select${Type} | Partial<Select${Type}>>> {
3166
- return this.post<PaginatedResponse<Select${Type}${hasVectorColumns ? " & { _distance?: number }" : ""}>>(\`\${this.resource}/list\`, params ?? {});
3197
+ return this.post<PaginatedResponse<Select${Type} & { _similarity?: number }${hasVectorColumns ? " & { _distance?: number }" : ""}>>(\`\${this.resource}/list\`, params ?? {});
3167
3198
  }`}
3168
3199
 
3169
3200
  ${hasJsonbColumns ? ` /**
@@ -4331,6 +4362,12 @@ export type WhereOperator<T> = {
4331
4362
  $like?: T extends string ? string : never;
4332
4363
  /** Case-insensitive LIKE (strings only) */
4333
4364
  $ilike?: T extends string ? string : never;
4365
+ /** Trigram similarity match - "col" % value (pg_trgm required, uses similarity_threshold GUC) */
4366
+ $similarity?: T extends string ? string : never;
4367
+ /** Word trigram similarity match - value <% "col" (pg_trgm required) */
4368
+ $wordSimilarity?: T extends string ? string : never;
4369
+ /** Strict word trigram similarity match - value <<% "col" (pg_trgm required) */
4370
+ $strictWordSimilarity?: T extends string ? string : never;
4334
4371
  /** IS NULL */
4335
4372
  $is?: null;
4336
4373
  /** IS NOT NULL */
@@ -4947,7 +4984,7 @@ export async function createRecord(
4947
4984
 
4948
4985
  const preparedVals = vals.map((v, i) =>
4949
4986
  v !== null && v !== undefined && typeof v === 'object' &&
4950
- (ctx.jsonbColumns?.includes(cols[i]) || ctx.vectorColumns?.includes(cols[i]))
4987
+ (ctx.jsonbColumns?.includes(cols[i]!) || ctx.vectorColumns?.includes(cols[i]!))
4951
4988
  ? JSON.stringify(v)
4952
4989
  : v
4953
4990
  );
@@ -5107,6 +5144,24 @@ function buildWhereClause(
5107
5144
  whereParams.push(opValue);
5108
5145
  paramIndex++;
5109
5146
  break;
5147
+ case '$similarity':
5148
+ // pg_trgm: boolean similarity operator (respects similarity_threshold GUC)
5149
+ whereParts.push(\`"\${key}" % $\${paramIndex}\`);
5150
+ whereParams.push(opValue);
5151
+ paramIndex++;
5152
+ break;
5153
+ case '$wordSimilarity':
5154
+ // pg_trgm: word similarity operator (value <% col)
5155
+ whereParts.push(\`$\${paramIndex} <% "\${key}"\`);
5156
+ whereParams.push(opValue);
5157
+ paramIndex++;
5158
+ break;
5159
+ case '$strictWordSimilarity':
5160
+ // pg_trgm: strict word similarity operator (value <<% col)
5161
+ whereParts.push(\`$\${paramIndex} <<% "\${key}"\`);
5162
+ whereParams.push(opValue);
5163
+ paramIndex++;
5164
+ break;
5110
5165
  case '$is':
5111
5166
  if (opValue === null) {
5112
5167
  whereParts.push(\`"\${key}" IS NULL\`);
@@ -5280,6 +5335,53 @@ function getVectorDistanceOperator(metric?: string): string {
5280
5335
  }
5281
5336
  }
5282
5337
 
5338
+ export type TrigramMetric = "similarity" | "wordSimilarity" | "strictWordSimilarity";
5339
+
5340
+ export type TrigramParam =
5341
+ | { field: string; query: string; metric?: TrigramMetric; threshold?: number }
5342
+ | { fields: string[]; strategy?: "greatest" | "concat"; query: string; metric?: TrigramMetric; threshold?: number }
5343
+ | { fields: Array<{ field: string; weight: number }>; query: string; metric?: TrigramMetric; threshold?: number };
5344
+
5345
+ /**
5346
+ * Build a pg_trgm function expression for a raw SQL field expression.
5347
+ * $1 is always the query string parameter.
5348
+ */
5349
+ function trigramFnRaw(fieldExpr: string, metric?: TrigramMetric): string {
5350
+ switch (metric) {
5351
+ case "wordSimilarity": return \`word_similarity($1, \${fieldExpr})\`;
5352
+ case "strictWordSimilarity": return \`strict_word_similarity($1, \${fieldExpr})\`;
5353
+ default: return \`similarity(\${fieldExpr}, $1)\`;
5354
+ }
5355
+ }
5356
+
5357
+ /** Build the full pg_trgm SQL expression for the given trigram param. */
5358
+ function getTrigramFnExpr(trigram: TrigramParam): string {
5359
+ if ("field" in trigram) {
5360
+ return trigramFnRaw(\`"\${trigram.field}"\`, trigram.metric);
5361
+ }
5362
+
5363
+ const { fields, metric } = trigram;
5364
+
5365
+ // Weighted: fields is Array<{ field, weight }>
5366
+ if (fields.length > 0 && typeof fields[0] === "object" && "weight" in fields[0]) {
5367
+ const wfields = fields as Array<{ field: string; weight: number }>;
5368
+ const totalWeight = wfields.reduce((sum, f) => sum + f.weight, 0);
5369
+ const terms = wfields.map(f => \`\${trigramFnRaw(\`"\${f.field}"\`, metric)} * \${f.weight}\`).join(" + ");
5370
+ return \`(\${terms}) / \${totalWeight}\`;
5371
+ }
5372
+
5373
+ const sfields = fields as string[];
5374
+ const strategy = (trigram as Extract<TrigramParam, { fields: string[] }>).strategy ?? "greatest";
5375
+
5376
+ if (strategy === "concat") {
5377
+ const concatExpr = sfields.map(f => \`"\${f}"\`).join(\` || ' ' || \`);
5378
+ return trigramFnRaw(concatExpr, metric);
5379
+ }
5380
+
5381
+ // greatest (default)
5382
+ return \`GREATEST(\${sfields.map(f => trigramFnRaw(\`"\${f}"\`, metric)).join(", ")})\`;
5383
+ }
5384
+
5283
5385
  /** Builds a SQL ORDER BY clause from parallel cols/dirs arrays. Returns "" when cols is empty. */
5284
5386
  function buildOrderBySQL(cols: string[], dirs: ("asc" | "desc")[]): string {
5285
5387
  if (cols.length === 0) return "";
@@ -5287,7 +5389,7 @@ function buildOrderBySQL(cols: string[], dirs: ("asc" | "desc")[]): string {
5287
5389
  }
5288
5390
 
5289
5391
  /**
5290
- * LIST operation - Get multiple records with optional filters and vector search
5392
+ * LIST operation - Get multiple records with optional filters, vector search, and trigram similarity search
5291
5393
  */
5292
5394
  export async function listRecords(
5293
5395
  ctx: OperationContext,
@@ -5305,10 +5407,11 @@ export async function listRecords(
5305
5407
  metric?: "cosine" | "l2" | "inner";
5306
5408
  maxDistance?: number;
5307
5409
  };
5410
+ trigram?: TrigramParam;
5308
5411
  }
5309
5412
  ): Promise<{ data?: any; total?: number; limit?: number; offset?: number; hasMore?: boolean; error?: string; issues?: any; needsIncludes?: boolean; includeSpec?: any; status: number }> {
5310
5413
  try {
5311
- const { where: whereClause, limit = 50, offset = 0, include, orderBy, order, vector, distinctOn } = params;
5414
+ const { where: whereClause, limit = 50, offset = 0, include, orderBy, order, vector, trigram, distinctOn } = params;
5312
5415
 
5313
5416
  // DISTINCT ON support
5314
5417
  const distinctCols: string[] | null = distinctOn ? (Array.isArray(distinctOn) ? distinctOn : [distinctOn]) : null;
@@ -5319,20 +5422,28 @@ export async function listRecords(
5319
5422
 
5320
5423
  // Auto-detect subquery form: needed when distinctOn is set AND the caller wants to order
5321
5424
  // by a column outside of distinctOn (inline DISTINCT ON can't satisfy that without silently
5322
- // overriding the requested ordering). Vector search always stays inline.
5425
+ // overriding the requested ordering). Vector/trigram search always stays inline.
5323
5426
  const useSubquery: boolean =
5324
5427
  distinctCols !== null &&
5325
5428
  !vector &&
5429
+ !trigram &&
5326
5430
  userOrderCols.some(col => !distinctCols.includes(col));
5327
5431
 
5328
5432
  // Get distance operator if vector search
5329
5433
  const distanceOp = vector ? getVectorDistanceOperator(vector.metric) : "";
5330
5434
 
5331
- // Add vector to params array if present
5332
- const queryParams: any[] = vector ? [JSON.stringify(vector.query)] : [];
5435
+ // Get trigram similarity SQL expression (pg_trgm). $1 is always the query string.
5436
+ const trigramFnExpr = trigram ? getTrigramFnExpr(trigram) : "";
5437
+
5438
+ // Add vector or trigram query as $1 if present (mutually exclusive)
5439
+ const queryParams: any[] = vector
5440
+ ? [JSON.stringify(vector.query)]
5441
+ : trigram
5442
+ ? [trigram.query]
5443
+ : [];
5333
5444
 
5334
- // Build WHERE clause for SELECT/UPDATE queries (with vector as $1 if present)
5335
- let paramIndex = vector ? 2 : 1;
5445
+ // Build WHERE clause for SELECT/UPDATE queries (with vector/trigram as $1 if present)
5446
+ let paramIndex = vector || trigram ? 2 : 1;
5336
5447
  const whereParts: string[] = [];
5337
5448
  let whereParams: any[] = [];
5338
5449
 
@@ -5356,28 +5467,38 @@ export async function listRecords(
5356
5467
  whereParts.push(\`("\${vector.field}" \${distanceOp} ($1)::vector) < \${vector.maxDistance}\`);
5357
5468
  }
5358
5469
 
5470
+ // Add trigram threshold filter if specified (inlined as literal — it's a float, safe)
5471
+ if (trigram?.threshold !== undefined) {
5472
+ whereParts.push(\`\${trigramFnExpr} >= \${trigram.threshold}\`);
5473
+ }
5474
+
5359
5475
  const whereSQL = whereParts.length > 0 ? \`WHERE \${whereParts.join(" AND ")}\` : "";
5360
5476
 
5361
5477
  // Build WHERE clause for COUNT query (may need different param indices)
5362
5478
  let countWhereSQL = whereSQL;
5363
5479
  let countParams = whereParams;
5364
5480
 
5365
- if (vector && vector.maxDistance === undefined && whereParams.length > 0) {
5366
- // COUNT query doesn't use vector, so rebuild WHERE without vector offset
5481
+ // When a $1 query param exists (vector/trigram) but has NO threshold filter, the COUNT query
5482
+ // must not reference $1 since it's only needed in SELECT/ORDER BY. Rebuild WHERE from scratch
5483
+ // starting at $1. When a threshold IS set, $1 appears in the WHERE condition so keep it.
5484
+ const hasQueryParam = !!(vector || trigram);
5485
+ const hasThreshold = vector?.maxDistance !== undefined || trigram?.threshold !== undefined;
5486
+
5487
+ if (hasQueryParam && !hasThreshold && whereParams.length > 0) {
5367
5488
  const countWhereParts: string[] = [];
5368
5489
  if (ctx.softDeleteColumn) {
5369
5490
  countWhereParts.push(\`"\${ctx.softDeleteColumn}" IS NULL\`);
5370
5491
  }
5371
5492
  if (whereClause) {
5372
- const result = buildWhereClause(whereClause, 1); // Start at $1 for count
5493
+ const result = buildWhereClause(whereClause, 1); // Start at $1, no query-param offset
5373
5494
  if (result.sql) {
5374
5495
  countWhereParts.push(result.sql);
5375
5496
  countParams = result.params;
5376
5497
  }
5377
5498
  }
5378
5499
  countWhereSQL = countWhereParts.length > 0 ? \`WHERE \${countWhereParts.join(" AND ")}\` : "";
5379
- } else if (vector?.maxDistance !== undefined) {
5380
- // COUNT query includes vector for maxDistance filter
5500
+ } else if (hasQueryParam && hasThreshold) {
5501
+ // $1 appears in WHERE threshold condition — COUNT needs the full params
5381
5502
  countParams = [...queryParams, ...whereParams];
5382
5503
  }
5383
5504
 
@@ -5386,6 +5507,8 @@ export async function listRecords(
5386
5507
  const baseColumns = buildColumnList(ctx.select, ctx.exclude, ctx.allColumnNames);
5387
5508
  const selectClause = vector
5388
5509
  ? \`\${_distinctOnPrefix}\${baseColumns}, ("\${vector.field}" \${distanceOp} ($1)::vector) AS _distance\`
5510
+ : trigram
5511
+ ? \`\${_distinctOnPrefix}\${baseColumns}, \${trigramFnExpr} AS _similarity\`
5389
5512
  : \`\${_distinctOnPrefix}\${baseColumns}\`;
5390
5513
 
5391
5514
  // Build ORDER BY clause
@@ -5397,6 +5520,9 @@ export async function listRecords(
5397
5520
  if (vector) {
5398
5521
  // Vector search always orders by distance; DISTINCT ON + vector stays inline
5399
5522
  orderBySQL = \`ORDER BY "\${vector.field}" \${distanceOp} ($1)::vector\`;
5523
+ } else if (trigram) {
5524
+ // Trigram search orders by similarity score descending
5525
+ orderBySQL = \`ORDER BY _similarity DESC\`;
5400
5526
  } else if (useSubquery) {
5401
5527
  // Subquery form: outer query gets the user's full ORDER BY.
5402
5528
  // Inner query only needs to satisfy PG's DISTINCT ON prefix requirement (built at query assembly).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "postgresdk",
3
- "version": "0.18.19",
3
+ "version": "0.18.21",
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",