postgresdk 0.9.8 → 0.10.0

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
@@ -160,6 +160,7 @@ const authors = await sdk.authors.list({
160
160
  ### Filtering & Pagination
161
161
 
162
162
  ```typescript
163
+ // Simple equality filtering
163
164
  const users = await sdk.users.list({
164
165
  where: { status: "active" },
165
166
  orderBy: "created_at",
@@ -167,8 +168,20 @@ const users = await sdk.users.list({
167
168
  limit: 20,
168
169
  offset: 40
169
170
  });
171
+
172
+ // Advanced WHERE operators
173
+ const filtered = await sdk.users.list({
174
+ where: {
175
+ age: { $gte: 18, $lt: 65 }, // Range queries
176
+ email: { $ilike: '%@company.com' }, // Pattern matching
177
+ status: { $in: ['active', 'pending'] }, // Array matching
178
+ deleted_at: { $is: null } // NULL checks
179
+ }
180
+ });
170
181
  ```
171
182
 
183
+ See the generated SDK documentation for all available operators: `$eq`, `$ne`, `$gt`, `$gte`, `$lt`, `$lte`, `$in`, `$nin`, `$like`, `$ilike`, `$is`, `$isNot`.
184
+
172
185
  ## Authentication
173
186
 
174
187
  postgresdk supports API key and JWT authentication:
package/dist/cli.js CHANGED
@@ -1170,6 +1170,140 @@ function generateUnifiedContractMarkdown(contract) {
1170
1170
  lines.push("");
1171
1171
  }
1172
1172
  }
1173
+ lines.push("## Filtering with WHERE Clauses");
1174
+ lines.push("");
1175
+ lines.push("The SDK provides type-safe WHERE clause filtering with support for various operators.");
1176
+ lines.push("");
1177
+ lines.push("### Basic Filtering");
1178
+ lines.push("");
1179
+ lines.push("**Direct equality:**");
1180
+ lines.push("");
1181
+ lines.push("```typescript");
1182
+ lines.push("// Find users with specific email");
1183
+ lines.push("const users = await sdk.users.list({");
1184
+ lines.push(" where: { email: 'user@example.com' }");
1185
+ lines.push("});");
1186
+ lines.push("");
1187
+ lines.push("// Multiple conditions (AND)");
1188
+ lines.push("const activeUsers = await sdk.users.list({");
1189
+ lines.push(" where: {");
1190
+ lines.push(" status: 'active',");
1191
+ lines.push(" role: 'admin'");
1192
+ lines.push(" }");
1193
+ lines.push("});");
1194
+ lines.push("```");
1195
+ lines.push("");
1196
+ lines.push("### Comparison Operators");
1197
+ lines.push("");
1198
+ lines.push("Use comparison operators for numeric, date, and other comparable fields:");
1199
+ lines.push("");
1200
+ lines.push("```typescript");
1201
+ lines.push("// Greater than / Less than");
1202
+ lines.push("const adults = await sdk.users.list({");
1203
+ lines.push(" where: { age: { $gt: 18 } }");
1204
+ lines.push("});");
1205
+ lines.push("");
1206
+ lines.push("// Range queries");
1207
+ lines.push("const workingAge = await sdk.users.list({");
1208
+ lines.push(" where: {");
1209
+ lines.push(" age: { $gte: 18, $lte: 65 }");
1210
+ lines.push(" }");
1211
+ lines.push("});");
1212
+ lines.push("");
1213
+ lines.push("// Not equal");
1214
+ lines.push("const notPending = await sdk.orders.list({");
1215
+ lines.push(" where: { status: { $ne: 'pending' } }");
1216
+ lines.push("});");
1217
+ lines.push("```");
1218
+ lines.push("");
1219
+ lines.push("### String Operators");
1220
+ lines.push("");
1221
+ lines.push("Pattern matching for string fields:");
1222
+ lines.push("");
1223
+ lines.push("```typescript");
1224
+ lines.push("// Case-sensitive LIKE");
1225
+ lines.push("const johnsmiths = await sdk.users.list({");
1226
+ lines.push(" where: { name: { $like: '%Smith%' } }");
1227
+ lines.push("});");
1228
+ lines.push("");
1229
+ lines.push("// Case-insensitive ILIKE");
1230
+ lines.push("const gmailUsers = await sdk.users.list({");
1231
+ lines.push(" where: { email: { $ilike: '%@gmail.com' } }");
1232
+ lines.push("});");
1233
+ lines.push("```");
1234
+ lines.push("");
1235
+ lines.push("### Array Operators");
1236
+ lines.push("");
1237
+ lines.push("Filter by multiple possible values:");
1238
+ lines.push("");
1239
+ lines.push("```typescript");
1240
+ lines.push("// IN - match any value in array");
1241
+ lines.push("const specificUsers = await sdk.users.list({");
1242
+ lines.push(" where: {");
1243
+ lines.push(" id: { $in: ['id1', 'id2', 'id3'] }");
1244
+ lines.push(" }");
1245
+ lines.push("});");
1246
+ lines.push("");
1247
+ lines.push("// NOT IN - exclude values");
1248
+ lines.push("const nonSystemUsers = await sdk.users.list({");
1249
+ lines.push(" where: {");
1250
+ lines.push(" role: { $nin: ['admin', 'system'] }");
1251
+ lines.push(" }");
1252
+ lines.push("});");
1253
+ lines.push("```");
1254
+ lines.push("");
1255
+ lines.push("### NULL Checks");
1256
+ lines.push("");
1257
+ lines.push("Check for null or non-null values:");
1258
+ lines.push("");
1259
+ lines.push("```typescript");
1260
+ lines.push("// IS NULL");
1261
+ lines.push("const activeRecords = await sdk.records.list({");
1262
+ lines.push(" where: { deleted_at: { $is: null } }");
1263
+ lines.push("});");
1264
+ lines.push("");
1265
+ lines.push("// IS NOT NULL");
1266
+ lines.push("const deletedRecords = await sdk.records.list({");
1267
+ lines.push(" where: { deleted_at: { $isNot: null } }");
1268
+ lines.push("});");
1269
+ lines.push("```");
1270
+ lines.push("");
1271
+ lines.push("### Combining Operators");
1272
+ lines.push("");
1273
+ lines.push("Mix multiple operators for complex queries:");
1274
+ lines.push("");
1275
+ lines.push("```typescript");
1276
+ lines.push("const filteredUsers = await sdk.users.list({");
1277
+ lines.push(" where: {");
1278
+ lines.push(" age: { $gte: 18, $lt: 65 },");
1279
+ lines.push(" email: { $ilike: '%@company.com' },");
1280
+ lines.push(" status: { $in: ['active', 'pending'] },");
1281
+ lines.push(" deleted_at: { $is: null }");
1282
+ lines.push(" },");
1283
+ lines.push(" limit: 50,");
1284
+ lines.push(" offset: 0");
1285
+ lines.push("});");
1286
+ lines.push("```");
1287
+ lines.push("");
1288
+ lines.push("### Available Operators");
1289
+ lines.push("");
1290
+ lines.push("| Operator | Description | Example | Types |");
1291
+ lines.push("|----------|-------------|---------|-------|");
1292
+ lines.push("| `$eq` | Equal to | `{ age: { $eq: 25 } }` | All |");
1293
+ lines.push("| `$ne` | Not equal to | `{ status: { $ne: 'inactive' } }` | All |");
1294
+ lines.push("| `$gt` | Greater than | `{ price: { $gt: 100 } }` | Number, Date |");
1295
+ lines.push("| `$gte` | Greater than or equal | `{ age: { $gte: 18 } }` | Number, Date |");
1296
+ lines.push("| `$lt` | Less than | `{ quantity: { $lt: 10 } }` | Number, Date |");
1297
+ lines.push("| `$lte` | Less than or equal | `{ age: { $lte: 65 } }` | Number, Date |");
1298
+ lines.push("| `$in` | In array | `{ id: { $in: ['a', 'b'] } }` | All |");
1299
+ lines.push("| `$nin` | Not in array | `{ role: { $nin: ['admin'] } }` | All |");
1300
+ lines.push("| `$like` | Pattern match (case-sensitive) | `{ name: { $like: '%John%' } }` | String |");
1301
+ lines.push("| `$ilike` | Pattern match (case-insensitive) | `{ email: { $ilike: '%@GMAIL%' } }` | String |");
1302
+ lines.push("| `$is` | IS NULL | `{ deleted_at: { $is: null } }` | Nullable fields |");
1303
+ lines.push("| `$isNot` | IS NOT NULL | `{ created_by: { $isNot: null } }` | Nullable fields |");
1304
+ lines.push("");
1305
+ lines.push("**Note:** The WHERE clause types are fully type-safe. TypeScript will only allow operators that are valid for each field type.");
1306
+ lines.push("");
1173
1307
  lines.push("## Resources");
1174
1308
  lines.push("");
1175
1309
  for (const resource of contract.resources) {
@@ -1826,115 +1960,131 @@ async function initCommand(args) {
1826
1960
  }
1827
1961
  var CONFIG_TEMPLATE = `/**
1828
1962
  * PostgreSDK Configuration
1829
- *
1830
- * This file configures how postgresdk generates your SDK.
1963
+ *
1964
+ * This file configures how postgresdk generates your type-safe API and SDK
1965
+ * from your PostgreSQL database schema.
1966
+ *
1967
+ * QUICK START:
1968
+ * 1. Update the connectionString below
1969
+ * 2. Run: postgresdk generate
1970
+ * 3. Start using your generated SDK!
1971
+ *
1972
+ * CLI COMMANDS:
1973
+ * postgresdk init Initialize this config file
1974
+ * postgresdk generate Generate API and SDK from your database
1975
+ * postgresdk pull Pull SDK from a remote API
1976
+ * postgresdk help Show help and examples
1977
+ *
1831
1978
  * Environment variables are automatically loaded from .env files.
1832
1979
  */
1833
1980
 
1834
1981
  export default {
1835
1982
  // ========== DATABASE CONNECTION (Required) ==========
1836
-
1983
+
1837
1984
  /**
1838
1985
  * PostgreSQL connection string
1839
1986
  * Format: postgres://user:password@host:port/database
1987
+ *
1988
+ * Tip: Use environment variables to keep credentials secure
1840
1989
  */
1841
1990
  connectionString: process.env.DATABASE_URL || "postgres://user:password@localhost:5432/mydb",
1842
-
1991
+
1843
1992
  // ========== BASIC OPTIONS ==========
1844
-
1993
+
1845
1994
  /**
1846
1995
  * Database schema to introspect
1847
- * @default "public"
1996
+ * Default: "public"
1848
1997
  */
1849
1998
  // schema: "public",
1850
-
1999
+
1851
2000
  /**
1852
2001
  * Output directory for server-side code (routes, validators, etc.)
1853
- * @default "./api/server"
2002
+ * Default: "./api/server"
1854
2003
  */
1855
2004
  // outServer: "./api/server",
1856
-
2005
+
1857
2006
  /**
1858
2007
  * Output directory for client SDK
1859
- * @default "./api/client"
2008
+ * Default: "./api/client"
1860
2009
  */
1861
2010
  // outClient: "./api/client",
1862
-
2011
+
1863
2012
  // ========== ADVANCED OPTIONS ==========
1864
-
2013
+
1865
2014
  /**
1866
2015
  * Column name for soft deletes. When set, DELETE operations will update
1867
2016
  * this column instead of removing rows.
1868
- * @default null (hard deletes)
1869
- * @example "deleted_at"
2017
+ *
2018
+ * Default: null (hard deletes)
2019
+ * Example: "deleted_at"
1870
2020
  */
1871
2021
  // softDeleteColumn: null,
1872
-
2022
+
1873
2023
  /**
1874
2024
  * Maximum depth for nested relationship includes to prevent infinite loops
1875
- * @default 2
2025
+ * Default: 2
1876
2026
  */
1877
2027
  // includeMethodsDepth: 2,
1878
-
1879
-
2028
+
2029
+
1880
2030
  /**
1881
2031
  * Server framework for generated API routes
1882
- * - "hono": Lightweight, edge-compatible web framework (default)
1883
- * - "express": Traditional Node.js framework (planned)
1884
- * - "fastify": High-performance Node.js framework (planned)
1885
- * @default "hono"
2032
+ * Options:
2033
+ * - "hono": Lightweight, edge-compatible web framework (default)
2034
+ * - "express": Traditional Node.js framework (planned)
2035
+ * - "fastify": High-performance Node.js framework (planned)
2036
+ *
2037
+ * Default: "hono"
1886
2038
  */
1887
2039
  // serverFramework: "hono",
1888
-
2040
+
1889
2041
  /**
1890
2042
  * Use .js extensions in server imports (for Vercel Edge, Deno, etc.)
1891
- * @default false
2043
+ * Default: false
1892
2044
  */
1893
2045
  // useJsExtensions: false,
1894
-
2046
+
1895
2047
  /**
1896
2048
  * Use .js extensions in client SDK imports (rarely needed)
1897
- * @default false
2049
+ * Default: false
1898
2050
  */
1899
2051
  // useJsExtensionsClient: false,
1900
-
2052
+
1901
2053
  // ========== TEST GENERATION ==========
1902
-
2054
+
1903
2055
  /**
1904
- * Generate basic SDK tests
1905
- * Uncomment to enable test generation with Docker setup
2056
+ * Generate basic SDK tests with Docker setup
2057
+ * Uncomment to enable test generation
1906
2058
  */
1907
2059
  // tests: {
1908
2060
  // generate: true,
1909
2061
  // output: "./api/tests",
1910
2062
  // framework: "vitest" // or "jest" or "bun"
1911
2063
  // },
1912
-
2064
+
1913
2065
  // ========== AUTHENTICATION ==========
1914
-
2066
+
1915
2067
  /**
1916
2068
  * Authentication configuration for your API
1917
- *
1918
- * Simple syntax examples:
2069
+ *
2070
+ * SIMPLE SYNTAX (recommended):
1919
2071
  * auth: { apiKey: process.env.API_KEY }
1920
2072
  * auth: { jwt: process.env.JWT_SECRET }
1921
- *
1922
- * Multiple API keys:
1923
2073
  * auth: { apiKeys: [process.env.KEY1, process.env.KEY2] }
1924
- *
1925
- * Full syntax for advanced options:
2074
+ *
2075
+ * FULL SYNTAX (advanced):
1926
2076
  */
1927
2077
  // auth: {
1928
2078
  // // Strategy: "none" | "api-key" | "jwt-hs256"
1929
2079
  // strategy: "none",
1930
- //
2080
+ //
1931
2081
  // // For API Key authentication
1932
2082
  // apiKeyHeader: "x-api-key", // Header name for API key
1933
2083
  // apiKeys: [ // List of valid API keys
1934
2084
  // process.env.API_KEY_1,
1935
2085
  // process.env.API_KEY_2,
1936
2086
  // ],
1937
- //
2087
+ //
1938
2088
  // // For JWT (HS256) authentication
1939
2089
  // jwt: {
1940
2090
  // sharedSecret: process.env.JWT_SECRET, // Secret for signing/verifying
@@ -1942,12 +2092,12 @@ export default {
1942
2092
  // audience: "my-users", // Optional: validate 'aud' claim
1943
2093
  // }
1944
2094
  // },
1945
-
2095
+
1946
2096
  // ========== SDK DISTRIBUTION (Pull Configuration) ==========
1947
-
2097
+
1948
2098
  /**
1949
2099
  * Configuration for pulling SDK from a remote API
1950
- * Used when running 'postgresdk pull' command
2100
+ * Used when running: postgresdk pull
1951
2101
  */
1952
2102
  // pull: {
1953
2103
  // from: "https://api.myapp.com", // API URL to pull SDK from
@@ -2423,6 +2573,7 @@ import * as coreOps from "../core/operations${ext}";
2423
2573
  ${authImport}
2424
2574
 
2425
2575
  const listSchema = z.object({
2576
+ where: z.any().optional(),
2426
2577
  include: z.any().optional(),
2427
2578
  limit: z.number().int().positive().max(100).optional(),
2428
2579
  offset: z.number().int().min(0).optional(),
@@ -2593,7 +2744,7 @@ function emitClient(table, graph, opts, model) {
2593
2744
  let includeMethodsCode = "";
2594
2745
  for (const method of includeMethods) {
2595
2746
  const isGetByPk = method.name.startsWith("getByPk");
2596
- const baseParams = isGetByPk ? "" : `params?: Omit<{ limit?: number; offset?: number; where?: any; orderBy?: string; order?: "asc" | "desc"; }, "include">`;
2747
+ const baseParams = isGetByPk ? "" : `params?: Omit<{ limit?: number; offset?: number; where?: Where<Select${Type}>; orderBy?: string; order?: "asc" | "desc"; }, "include">`;
2597
2748
  if (isGetByPk) {
2598
2749
  const pkWhere = hasCompositePk ? `{ ${safePk.map((col) => `${col}: pk.${col}`).join(", ")} }` : `{ ${safePk[0] || "id"}: pk }`;
2599
2750
  const baseReturnType = method.returnType.replace(" | null", "");
@@ -2617,6 +2768,7 @@ function emitClient(table, graph, opts, model) {
2617
2768
  }
2618
2769
  return `/* Generated. Do not edit. */
2619
2770
  import { BaseClient } from "./base-client${ext}";
2771
+ import type { Where } from "./where-types${ext}";
2620
2772
  ${typeImports}
2621
2773
  ${otherTableImports.join(`
2622
2774
  `)}
@@ -2636,10 +2788,11 @@ export class ${Type}Client extends BaseClient {
2636
2788
  return this.get<Select${Type} | null>(\`\${this.resource}/\${path}\`);
2637
2789
  }
2638
2790
 
2639
- async list(params?: {
2640
- limit?: number;
2791
+ async list(params?: {
2792
+ include?: any;
2793
+ limit?: number;
2641
2794
  offset?: number;
2642
- where?: any;
2795
+ where?: Where<Select${Type}>;
2643
2796
  orderBy?: string;
2644
2797
  order?: "asc" | "desc";
2645
2798
  }): Promise<Select${Type}[]> {
@@ -2701,6 +2854,17 @@ export type { AuthConfig, HeaderMap, AuthHeadersProvider } from "./base-client${
2701
2854
  out += `export { BaseClient } from "./base-client${ext}";
2702
2855
  `;
2703
2856
  out += `
2857
+ // Include specification types for custom queries
2858
+ `;
2859
+ out += `export type {
2860
+ `;
2861
+ for (const t of tables) {
2862
+ out += ` ${pascal(t.name)}IncludeSpec,
2863
+ `;
2864
+ }
2865
+ out += `} from "./include-spec${ext}";
2866
+ `;
2867
+ out += `
2704
2868
  // Zod schemas for form validation
2705
2869
  `;
2706
2870
  for (const t of tables) {
@@ -3238,6 +3402,54 @@ export function safe<T extends (c: any) => any>(handler: T) {
3238
3402
  `;
3239
3403
  }
3240
3404
 
3405
+ // src/emit-where-types.ts
3406
+ function emitWhereTypes() {
3407
+ return `/* Generated. Do not edit. */
3408
+
3409
+ /**
3410
+ * WHERE clause operators for filtering
3411
+ */
3412
+ export type WhereOperator<T> = {
3413
+ /** Equal to */
3414
+ $eq?: T;
3415
+ /** Not equal to */
3416
+ $ne?: T;
3417
+ /** Greater than */
3418
+ $gt?: T;
3419
+ /** Greater than or equal to */
3420
+ $gte?: T;
3421
+ /** Less than */
3422
+ $lt?: T;
3423
+ /** Less than or equal to */
3424
+ $lte?: T;
3425
+ /** In array */
3426
+ $in?: T[];
3427
+ /** Not in array */
3428
+ $nin?: T[];
3429
+ /** LIKE pattern match (strings only) */
3430
+ $like?: T extends string ? string : never;
3431
+ /** Case-insensitive LIKE (strings only) */
3432
+ $ilike?: T extends string ? string : never;
3433
+ /** IS NULL */
3434
+ $is?: null;
3435
+ /** IS NOT NULL */
3436
+ $isNot?: null;
3437
+ };
3438
+
3439
+ /**
3440
+ * WHERE condition - can be a direct value or an operator object
3441
+ */
3442
+ export type WhereCondition<T> = T | WhereOperator<T>;
3443
+
3444
+ /**
3445
+ * WHERE clause type for a given table type
3446
+ */
3447
+ export type Where<T> = {
3448
+ [K in keyof T]?: WhereCondition<T[K]>;
3449
+ };
3450
+ `;
3451
+ }
3452
+
3241
3453
  // src/emit-auth.ts
3242
3454
  function emitAuth(cfgAuth) {
3243
3455
  const strategy = cfgAuth?.strategy ?? "none";
@@ -3652,19 +3864,124 @@ export async function getByPk(
3652
3864
  */
3653
3865
  export async function listRecords(
3654
3866
  ctx: OperationContext,
3655
- params: { limit?: number; offset?: number; include?: any }
3656
- ): Promise<{ data?: any; error?: string; issues?: any; status: number; needsIncludes?: boolean; includeSpec?: any }> {
3867
+ params: { where?: any; limit?: number; offset?: number; include?: any }
3868
+ ): Promise<{ data?: any; error?: string; issues?: any; needsIncludes?: boolean; includeSpec?: any; status: number }> {
3657
3869
  try {
3658
- const { limit = 50, offset = 0, include } = params;
3870
+ const { where: whereClause, limit = 50, offset = 0, include } = params;
3871
+
3872
+ // Build WHERE clause
3873
+ const whereParts: string[] = [];
3874
+ const whereParams: any[] = [];
3875
+ let paramIndex = 1;
3876
+
3877
+ // Add soft delete filter if applicable
3878
+ if (ctx.softDeleteColumn) {
3879
+ whereParts.push(\`"\${ctx.softDeleteColumn}" IS NULL\`);
3880
+ }
3881
+
3882
+ // Add user-provided where conditions
3883
+ if (whereClause && typeof whereClause === 'object') {
3884
+ for (const [key, value] of Object.entries(whereClause)) {
3885
+ if (value === undefined) {
3886
+ // Skip undefined values
3887
+ continue;
3888
+ }
3889
+
3890
+ // Handle operator objects like { $gt: 5, $like: "%test%" }
3891
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
3892
+ for (const [op, opValue] of Object.entries(value)) {
3893
+ if (opValue === undefined) continue;
3894
+
3895
+ switch (op) {
3896
+ case '$eq':
3897
+ whereParts.push(\`"\${key}" = $\${paramIndex}\`);
3898
+ whereParams.push(opValue);
3899
+ paramIndex++;
3900
+ break;
3901
+ case '$ne':
3902
+ whereParts.push(\`"\${key}" != $\${paramIndex}\`);
3903
+ whereParams.push(opValue);
3904
+ paramIndex++;
3905
+ break;
3906
+ case '$gt':
3907
+ whereParts.push(\`"\${key}" > $\${paramIndex}\`);
3908
+ whereParams.push(opValue);
3909
+ paramIndex++;
3910
+ break;
3911
+ case '$gte':
3912
+ whereParts.push(\`"\${key}" >= $\${paramIndex}\`);
3913
+ whereParams.push(opValue);
3914
+ paramIndex++;
3915
+ break;
3916
+ case '$lt':
3917
+ whereParts.push(\`"\${key}" < $\${paramIndex}\`);
3918
+ whereParams.push(opValue);
3919
+ paramIndex++;
3920
+ break;
3921
+ case '$lte':
3922
+ whereParts.push(\`"\${key}" <= $\${paramIndex}\`);
3923
+ whereParams.push(opValue);
3924
+ paramIndex++;
3925
+ break;
3926
+ case '$in':
3927
+ if (Array.isArray(opValue) && opValue.length > 0) {
3928
+ whereParts.push(\`"\${key}" = ANY($\${paramIndex})\`);
3929
+ whereParams.push(opValue);
3930
+ paramIndex++;
3931
+ }
3932
+ break;
3933
+ case '$nin':
3934
+ if (Array.isArray(opValue) && opValue.length > 0) {
3935
+ whereParts.push(\`"\${key}" != ALL($\${paramIndex})\`);
3936
+ whereParams.push(opValue);
3937
+ paramIndex++;
3938
+ }
3939
+ break;
3940
+ case '$like':
3941
+ whereParts.push(\`"\${key}" LIKE $\${paramIndex}\`);
3942
+ whereParams.push(opValue);
3943
+ paramIndex++;
3944
+ break;
3945
+ case '$ilike':
3946
+ whereParts.push(\`"\${key}" ILIKE $\${paramIndex}\`);
3947
+ whereParams.push(opValue);
3948
+ paramIndex++;
3949
+ break;
3950
+ case '$is':
3951
+ if (opValue === null) {
3952
+ whereParts.push(\`"\${key}" IS NULL\`);
3953
+ }
3954
+ break;
3955
+ case '$isNot':
3956
+ if (opValue === null) {
3957
+ whereParts.push(\`"\${key}" IS NOT NULL\`);
3958
+ }
3959
+ break;
3960
+ }
3961
+ }
3962
+ } else if (value === null) {
3963
+ // Direct null value
3964
+ whereParts.push(\`"\${key}" IS NULL\`);
3965
+ } else {
3966
+ // Direct value (simple equality)
3967
+ whereParts.push(\`"\${key}" = $\${paramIndex}\`);
3968
+ whereParams.push(value);
3969
+ paramIndex++;
3970
+ }
3971
+ }
3972
+ }
3973
+
3974
+ const whereSQL = whereParts.length > 0 ? \`WHERE \${whereParts.join(" AND ")}\` : "";
3659
3975
 
3660
- const where = ctx.softDeleteColumn
3661
- ? \`WHERE "\${ctx.softDeleteColumn}" IS NULL\`
3662
- : "";
3976
+ // Add limit and offset params
3977
+ const limitParam = \`$\${paramIndex}\`;
3978
+ const offsetParam = \`$\${paramIndex + 1}\`;
3979
+ const allParams = [...whereParams, limit, offset];
3663
3980
 
3664
- const text = \`SELECT * FROM "\${ctx.table}" \${where} LIMIT $1 OFFSET $2\`;
3665
- log.debug(\`LIST \${ctx.table} SQL:\`, text, "params:", [limit, offset]);
3981
+ const text = \`SELECT * FROM "\${ctx.table}" \${whereSQL} LIMIT \${limitParam} OFFSET \${offsetParam}\`;
3982
+ log.debug(\`LIST \${ctx.table} SQL:\`, text, "params:", allParams);
3666
3983
 
3667
- const { rows } = await ctx.pg.query(text, [limit, offset]);
3984
+ const { rows } = await ctx.pg.query(text, allParams);
3668
3985
 
3669
3986
  if (!include) {
3670
3987
  log.debug(\`LIST \${ctx.table} rows:\`, rows.length);
@@ -4582,6 +4899,7 @@ async function generate(configPath) {
4582
4899
  files.push({ path: join(clientDir, "include-spec.ts"), content: includeSpec });
4583
4900
  files.push({ path: join(clientDir, "params", "shared.ts"), content: emitSharedParamsZod() });
4584
4901
  files.push({ path: join(clientDir, "base-client.ts"), content: emitBaseClient() });
4902
+ files.push({ path: join(clientDir, "where-types.ts"), content: emitWhereTypes() });
4585
4903
  files.push({
4586
4904
  path: join(serverDir, "include-builder.ts"),
4587
4905
  content: emitIncludeBuilder(graph, cfg.includeMethodsDepth || 2)
@@ -35,6 +35,7 @@ export declare function getByPk(ctx: OperationContext, pkValues: any[]): Promise
35
35
  * LIST operation - Get multiple records with optional filters
36
36
  */
37
37
  export declare function listRecords(ctx: OperationContext, params: {
38
+ where?: any;
38
39
  limit?: number;
39
40
  offset?: number;
40
41
  include?: any;
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Emits WHERE clause type utilities for type-safe filtering
3
+ */
4
+ export declare function emitWhereTypes(): string;
package/dist/index.js CHANGED
@@ -1169,6 +1169,140 @@ function generateUnifiedContractMarkdown(contract) {
1169
1169
  lines.push("");
1170
1170
  }
1171
1171
  }
1172
+ lines.push("## Filtering with WHERE Clauses");
1173
+ lines.push("");
1174
+ lines.push("The SDK provides type-safe WHERE clause filtering with support for various operators.");
1175
+ lines.push("");
1176
+ lines.push("### Basic Filtering");
1177
+ lines.push("");
1178
+ lines.push("**Direct equality:**");
1179
+ lines.push("");
1180
+ lines.push("```typescript");
1181
+ lines.push("// Find users with specific email");
1182
+ lines.push("const users = await sdk.users.list({");
1183
+ lines.push(" where: { email: 'user@example.com' }");
1184
+ lines.push("});");
1185
+ lines.push("");
1186
+ lines.push("// Multiple conditions (AND)");
1187
+ lines.push("const activeUsers = await sdk.users.list({");
1188
+ lines.push(" where: {");
1189
+ lines.push(" status: 'active',");
1190
+ lines.push(" role: 'admin'");
1191
+ lines.push(" }");
1192
+ lines.push("});");
1193
+ lines.push("```");
1194
+ lines.push("");
1195
+ lines.push("### Comparison Operators");
1196
+ lines.push("");
1197
+ lines.push("Use comparison operators for numeric, date, and other comparable fields:");
1198
+ lines.push("");
1199
+ lines.push("```typescript");
1200
+ lines.push("// Greater than / Less than");
1201
+ lines.push("const adults = await sdk.users.list({");
1202
+ lines.push(" where: { age: { $gt: 18 } }");
1203
+ lines.push("});");
1204
+ lines.push("");
1205
+ lines.push("// Range queries");
1206
+ lines.push("const workingAge = await sdk.users.list({");
1207
+ lines.push(" where: {");
1208
+ lines.push(" age: { $gte: 18, $lte: 65 }");
1209
+ lines.push(" }");
1210
+ lines.push("});");
1211
+ lines.push("");
1212
+ lines.push("// Not equal");
1213
+ lines.push("const notPending = await sdk.orders.list({");
1214
+ lines.push(" where: { status: { $ne: 'pending' } }");
1215
+ lines.push("});");
1216
+ lines.push("```");
1217
+ lines.push("");
1218
+ lines.push("### String Operators");
1219
+ lines.push("");
1220
+ lines.push("Pattern matching for string fields:");
1221
+ lines.push("");
1222
+ lines.push("```typescript");
1223
+ lines.push("// Case-sensitive LIKE");
1224
+ lines.push("const johnsmiths = await sdk.users.list({");
1225
+ lines.push(" where: { name: { $like: '%Smith%' } }");
1226
+ lines.push("});");
1227
+ lines.push("");
1228
+ lines.push("// Case-insensitive ILIKE");
1229
+ lines.push("const gmailUsers = await sdk.users.list({");
1230
+ lines.push(" where: { email: { $ilike: '%@gmail.com' } }");
1231
+ lines.push("});");
1232
+ lines.push("```");
1233
+ lines.push("");
1234
+ lines.push("### Array Operators");
1235
+ lines.push("");
1236
+ lines.push("Filter by multiple possible values:");
1237
+ lines.push("");
1238
+ lines.push("```typescript");
1239
+ lines.push("// IN - match any value in array");
1240
+ lines.push("const specificUsers = await sdk.users.list({");
1241
+ lines.push(" where: {");
1242
+ lines.push(" id: { $in: ['id1', 'id2', 'id3'] }");
1243
+ lines.push(" }");
1244
+ lines.push("});");
1245
+ lines.push("");
1246
+ lines.push("// NOT IN - exclude values");
1247
+ lines.push("const nonSystemUsers = await sdk.users.list({");
1248
+ lines.push(" where: {");
1249
+ lines.push(" role: { $nin: ['admin', 'system'] }");
1250
+ lines.push(" }");
1251
+ lines.push("});");
1252
+ lines.push("```");
1253
+ lines.push("");
1254
+ lines.push("### NULL Checks");
1255
+ lines.push("");
1256
+ lines.push("Check for null or non-null values:");
1257
+ lines.push("");
1258
+ lines.push("```typescript");
1259
+ lines.push("// IS NULL");
1260
+ lines.push("const activeRecords = await sdk.records.list({");
1261
+ lines.push(" where: { deleted_at: { $is: null } }");
1262
+ lines.push("});");
1263
+ lines.push("");
1264
+ lines.push("// IS NOT NULL");
1265
+ lines.push("const deletedRecords = await sdk.records.list({");
1266
+ lines.push(" where: { deleted_at: { $isNot: null } }");
1267
+ lines.push("});");
1268
+ lines.push("```");
1269
+ lines.push("");
1270
+ lines.push("### Combining Operators");
1271
+ lines.push("");
1272
+ lines.push("Mix multiple operators for complex queries:");
1273
+ lines.push("");
1274
+ lines.push("```typescript");
1275
+ lines.push("const filteredUsers = await sdk.users.list({");
1276
+ lines.push(" where: {");
1277
+ lines.push(" age: { $gte: 18, $lt: 65 },");
1278
+ lines.push(" email: { $ilike: '%@company.com' },");
1279
+ lines.push(" status: { $in: ['active', 'pending'] },");
1280
+ lines.push(" deleted_at: { $is: null }");
1281
+ lines.push(" },");
1282
+ lines.push(" limit: 50,");
1283
+ lines.push(" offset: 0");
1284
+ lines.push("});");
1285
+ lines.push("```");
1286
+ lines.push("");
1287
+ lines.push("### Available Operators");
1288
+ lines.push("");
1289
+ lines.push("| Operator | Description | Example | Types |");
1290
+ lines.push("|----------|-------------|---------|-------|");
1291
+ lines.push("| `$eq` | Equal to | `{ age: { $eq: 25 } }` | All |");
1292
+ lines.push("| `$ne` | Not equal to | `{ status: { $ne: 'inactive' } }` | All |");
1293
+ lines.push("| `$gt` | Greater than | `{ price: { $gt: 100 } }` | Number, Date |");
1294
+ lines.push("| `$gte` | Greater than or equal | `{ age: { $gte: 18 } }` | Number, Date |");
1295
+ lines.push("| `$lt` | Less than | `{ quantity: { $lt: 10 } }` | Number, Date |");
1296
+ lines.push("| `$lte` | Less than or equal | `{ age: { $lte: 65 } }` | Number, Date |");
1297
+ lines.push("| `$in` | In array | `{ id: { $in: ['a', 'b'] } }` | All |");
1298
+ lines.push("| `$nin` | Not in array | `{ role: { $nin: ['admin'] } }` | All |");
1299
+ lines.push("| `$like` | Pattern match (case-sensitive) | `{ name: { $like: '%John%' } }` | String |");
1300
+ lines.push("| `$ilike` | Pattern match (case-insensitive) | `{ email: { $ilike: '%@GMAIL%' } }` | String |");
1301
+ lines.push("| `$is` | IS NULL | `{ deleted_at: { $is: null } }` | Nullable fields |");
1302
+ lines.push("| `$isNot` | IS NOT NULL | `{ created_by: { $isNot: null } }` | Nullable fields |");
1303
+ lines.push("");
1304
+ lines.push("**Note:** The WHERE clause types are fully type-safe. TypeScript will only allow operators that are valid for each field type.");
1305
+ lines.push("");
1172
1306
  lines.push("## Resources");
1173
1307
  lines.push("");
1174
1308
  for (const resource of contract.resources) {
@@ -1679,6 +1813,7 @@ import * as coreOps from "../core/operations${ext}";
1679
1813
  ${authImport}
1680
1814
 
1681
1815
  const listSchema = z.object({
1816
+ where: z.any().optional(),
1682
1817
  include: z.any().optional(),
1683
1818
  limit: z.number().int().positive().max(100).optional(),
1684
1819
  offset: z.number().int().min(0).optional(),
@@ -1849,7 +1984,7 @@ function emitClient(table, graph, opts, model) {
1849
1984
  let includeMethodsCode = "";
1850
1985
  for (const method of includeMethods) {
1851
1986
  const isGetByPk = method.name.startsWith("getByPk");
1852
- const baseParams = isGetByPk ? "" : `params?: Omit<{ limit?: number; offset?: number; where?: any; orderBy?: string; order?: "asc" | "desc"; }, "include">`;
1987
+ const baseParams = isGetByPk ? "" : `params?: Omit<{ limit?: number; offset?: number; where?: Where<Select${Type}>; orderBy?: string; order?: "asc" | "desc"; }, "include">`;
1853
1988
  if (isGetByPk) {
1854
1989
  const pkWhere = hasCompositePk ? `{ ${safePk.map((col) => `${col}: pk.${col}`).join(", ")} }` : `{ ${safePk[0] || "id"}: pk }`;
1855
1990
  const baseReturnType = method.returnType.replace(" | null", "");
@@ -1873,6 +2008,7 @@ function emitClient(table, graph, opts, model) {
1873
2008
  }
1874
2009
  return `/* Generated. Do not edit. */
1875
2010
  import { BaseClient } from "./base-client${ext}";
2011
+ import type { Where } from "./where-types${ext}";
1876
2012
  ${typeImports}
1877
2013
  ${otherTableImports.join(`
1878
2014
  `)}
@@ -1892,10 +2028,11 @@ export class ${Type}Client extends BaseClient {
1892
2028
  return this.get<Select${Type} | null>(\`\${this.resource}/\${path}\`);
1893
2029
  }
1894
2030
 
1895
- async list(params?: {
1896
- limit?: number;
2031
+ async list(params?: {
2032
+ include?: any;
2033
+ limit?: number;
1897
2034
  offset?: number;
1898
- where?: any;
2035
+ where?: Where<Select${Type}>;
1899
2036
  orderBy?: string;
1900
2037
  order?: "asc" | "desc";
1901
2038
  }): Promise<Select${Type}[]> {
@@ -1957,6 +2094,17 @@ export type { AuthConfig, HeaderMap, AuthHeadersProvider } from "./base-client${
1957
2094
  out += `export { BaseClient } from "./base-client${ext}";
1958
2095
  `;
1959
2096
  out += `
2097
+ // Include specification types for custom queries
2098
+ `;
2099
+ out += `export type {
2100
+ `;
2101
+ for (const t of tables) {
2102
+ out += ` ${pascal(t.name)}IncludeSpec,
2103
+ `;
2104
+ }
2105
+ out += `} from "./include-spec${ext}";
2106
+ `;
2107
+ out += `
1960
2108
  // Zod schemas for form validation
1961
2109
  `;
1962
2110
  for (const t of tables) {
@@ -2494,6 +2642,54 @@ export function safe<T extends (c: any) => any>(handler: T) {
2494
2642
  `;
2495
2643
  }
2496
2644
 
2645
+ // src/emit-where-types.ts
2646
+ function emitWhereTypes() {
2647
+ return `/* Generated. Do not edit. */
2648
+
2649
+ /**
2650
+ * WHERE clause operators for filtering
2651
+ */
2652
+ export type WhereOperator<T> = {
2653
+ /** Equal to */
2654
+ $eq?: T;
2655
+ /** Not equal to */
2656
+ $ne?: T;
2657
+ /** Greater than */
2658
+ $gt?: T;
2659
+ /** Greater than or equal to */
2660
+ $gte?: T;
2661
+ /** Less than */
2662
+ $lt?: T;
2663
+ /** Less than or equal to */
2664
+ $lte?: T;
2665
+ /** In array */
2666
+ $in?: T[];
2667
+ /** Not in array */
2668
+ $nin?: T[];
2669
+ /** LIKE pattern match (strings only) */
2670
+ $like?: T extends string ? string : never;
2671
+ /** Case-insensitive LIKE (strings only) */
2672
+ $ilike?: T extends string ? string : never;
2673
+ /** IS NULL */
2674
+ $is?: null;
2675
+ /** IS NOT NULL */
2676
+ $isNot?: null;
2677
+ };
2678
+
2679
+ /**
2680
+ * WHERE condition - can be a direct value or an operator object
2681
+ */
2682
+ export type WhereCondition<T> = T | WhereOperator<T>;
2683
+
2684
+ /**
2685
+ * WHERE clause type for a given table type
2686
+ */
2687
+ export type Where<T> = {
2688
+ [K in keyof T]?: WhereCondition<T[K]>;
2689
+ };
2690
+ `;
2691
+ }
2692
+
2497
2693
  // src/emit-auth.ts
2498
2694
  function emitAuth(cfgAuth) {
2499
2695
  const strategy = cfgAuth?.strategy ?? "none";
@@ -2908,19 +3104,124 @@ export async function getByPk(
2908
3104
  */
2909
3105
  export async function listRecords(
2910
3106
  ctx: OperationContext,
2911
- params: { limit?: number; offset?: number; include?: any }
2912
- ): Promise<{ data?: any; error?: string; issues?: any; status: number; needsIncludes?: boolean; includeSpec?: any }> {
3107
+ params: { where?: any; limit?: number; offset?: number; include?: any }
3108
+ ): Promise<{ data?: any; error?: string; issues?: any; needsIncludes?: boolean; includeSpec?: any; status: number }> {
2913
3109
  try {
2914
- const { limit = 50, offset = 0, include } = params;
3110
+ const { where: whereClause, limit = 50, offset = 0, include } = params;
3111
+
3112
+ // Build WHERE clause
3113
+ const whereParts: string[] = [];
3114
+ const whereParams: any[] = [];
3115
+ let paramIndex = 1;
3116
+
3117
+ // Add soft delete filter if applicable
3118
+ if (ctx.softDeleteColumn) {
3119
+ whereParts.push(\`"\${ctx.softDeleteColumn}" IS NULL\`);
3120
+ }
3121
+
3122
+ // Add user-provided where conditions
3123
+ if (whereClause && typeof whereClause === 'object') {
3124
+ for (const [key, value] of Object.entries(whereClause)) {
3125
+ if (value === undefined) {
3126
+ // Skip undefined values
3127
+ continue;
3128
+ }
3129
+
3130
+ // Handle operator objects like { $gt: 5, $like: "%test%" }
3131
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
3132
+ for (const [op, opValue] of Object.entries(value)) {
3133
+ if (opValue === undefined) continue;
3134
+
3135
+ switch (op) {
3136
+ case '$eq':
3137
+ whereParts.push(\`"\${key}" = $\${paramIndex}\`);
3138
+ whereParams.push(opValue);
3139
+ paramIndex++;
3140
+ break;
3141
+ case '$ne':
3142
+ whereParts.push(\`"\${key}" != $\${paramIndex}\`);
3143
+ whereParams.push(opValue);
3144
+ paramIndex++;
3145
+ break;
3146
+ case '$gt':
3147
+ whereParts.push(\`"\${key}" > $\${paramIndex}\`);
3148
+ whereParams.push(opValue);
3149
+ paramIndex++;
3150
+ break;
3151
+ case '$gte':
3152
+ whereParts.push(\`"\${key}" >= $\${paramIndex}\`);
3153
+ whereParams.push(opValue);
3154
+ paramIndex++;
3155
+ break;
3156
+ case '$lt':
3157
+ whereParts.push(\`"\${key}" < $\${paramIndex}\`);
3158
+ whereParams.push(opValue);
3159
+ paramIndex++;
3160
+ break;
3161
+ case '$lte':
3162
+ whereParts.push(\`"\${key}" <= $\${paramIndex}\`);
3163
+ whereParams.push(opValue);
3164
+ paramIndex++;
3165
+ break;
3166
+ case '$in':
3167
+ if (Array.isArray(opValue) && opValue.length > 0) {
3168
+ whereParts.push(\`"\${key}" = ANY($\${paramIndex})\`);
3169
+ whereParams.push(opValue);
3170
+ paramIndex++;
3171
+ }
3172
+ break;
3173
+ case '$nin':
3174
+ if (Array.isArray(opValue) && opValue.length > 0) {
3175
+ whereParts.push(\`"\${key}" != ALL($\${paramIndex})\`);
3176
+ whereParams.push(opValue);
3177
+ paramIndex++;
3178
+ }
3179
+ break;
3180
+ case '$like':
3181
+ whereParts.push(\`"\${key}" LIKE $\${paramIndex}\`);
3182
+ whereParams.push(opValue);
3183
+ paramIndex++;
3184
+ break;
3185
+ case '$ilike':
3186
+ whereParts.push(\`"\${key}" ILIKE $\${paramIndex}\`);
3187
+ whereParams.push(opValue);
3188
+ paramIndex++;
3189
+ break;
3190
+ case '$is':
3191
+ if (opValue === null) {
3192
+ whereParts.push(\`"\${key}" IS NULL\`);
3193
+ }
3194
+ break;
3195
+ case '$isNot':
3196
+ if (opValue === null) {
3197
+ whereParts.push(\`"\${key}" IS NOT NULL\`);
3198
+ }
3199
+ break;
3200
+ }
3201
+ }
3202
+ } else if (value === null) {
3203
+ // Direct null value
3204
+ whereParts.push(\`"\${key}" IS NULL\`);
3205
+ } else {
3206
+ // Direct value (simple equality)
3207
+ whereParts.push(\`"\${key}" = $\${paramIndex}\`);
3208
+ whereParams.push(value);
3209
+ paramIndex++;
3210
+ }
3211
+ }
3212
+ }
3213
+
3214
+ const whereSQL = whereParts.length > 0 ? \`WHERE \${whereParts.join(" AND ")}\` : "";
2915
3215
 
2916
- const where = ctx.softDeleteColumn
2917
- ? \`WHERE "\${ctx.softDeleteColumn}" IS NULL\`
2918
- : "";
3216
+ // Add limit and offset params
3217
+ const limitParam = \`$\${paramIndex}\`;
3218
+ const offsetParam = \`$\${paramIndex + 1}\`;
3219
+ const allParams = [...whereParams, limit, offset];
2919
3220
 
2920
- const text = \`SELECT * FROM "\${ctx.table}" \${where} LIMIT $1 OFFSET $2\`;
2921
- log.debug(\`LIST \${ctx.table} SQL:\`, text, "params:", [limit, offset]);
3221
+ const text = \`SELECT * FROM "\${ctx.table}" \${whereSQL} LIMIT \${limitParam} OFFSET \${offsetParam}\`;
3222
+ log.debug(\`LIST \${ctx.table} SQL:\`, text, "params:", allParams);
2922
3223
 
2923
- const { rows } = await ctx.pg.query(text, [limit, offset]);
3224
+ const { rows } = await ctx.pg.query(text, allParams);
2924
3225
 
2925
3226
  if (!include) {
2926
3227
  log.debug(\`LIST \${ctx.table} rows:\`, rows.length);
@@ -3838,6 +4139,7 @@ async function generate(configPath) {
3838
4139
  files.push({ path: join(clientDir, "include-spec.ts"), content: includeSpec });
3839
4140
  files.push({ path: join(clientDir, "params", "shared.ts"), content: emitSharedParamsZod() });
3840
4141
  files.push({ path: join(clientDir, "base-client.ts"), content: emitBaseClient() });
4142
+ files.push({ path: join(clientDir, "where-types.ts"), content: emitWhereTypes() });
3841
4143
  files.push({
3842
4144
  path: join(serverDir, "include-builder.ts"),
3843
4145
  content: emitIncludeBuilder(graph, cfg.includeMethodsDepth || 2)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "postgresdk",
3
- "version": "0.9.8",
3
+ "version": "0.10.0",
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:init && bun test:gen && bun test:gen-with-tests && bun test:pull && bun test:typecheck && bun test:drizzle-e2e",
25
+ "test": "bun test:init && bun test:gen && bun test test/test-where-clause.test.ts && bun test:gen-with-tests && bun test:pull && bun test:typecheck && bun test:drizzle-e2e",
26
26
  "test:init": "bun test/test-init.ts",
27
27
  "test:gen": "bun test/test-gen.ts",
28
28
  "test:gen-with-tests": "bun test/test-gen-with-tests.ts",