schematic-pg 0.1.19 → 0.1.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
@@ -390,6 +390,15 @@ functions {
390
390
  """
391
391
  }
392
392
 
393
+ function searchProducts(query: TEXT): TABLE(id: UUID, name: TEXT, price: DECIMAL) {
394
+ language: sql
395
+ volatility: STABLE
396
+ execute: """
397
+ SELECT id, name, price FROM product
398
+ WHERE name ILIKE '%' || query || '%'
399
+ """
400
+ }
401
+
393
402
  function setUpdatedAt(): TRIGGER {
394
403
  language: plpgsql
395
404
  execute: """
@@ -407,11 +416,11 @@ functions {
407
416
  | `security` | `INVOKER`, `DEFINER` | `INVOKER` |
408
417
  | `execute` | Triple-quoted SQL / PL/pgSQL | required |
409
418
 
410
- Return types are PostgreSQL types (`INTEGER`, `UUID`, `JSONB`, …), `TRIGGER`, or `VOID`. Body keys may be newline-separated or comma-separated.
419
+ Return types are PostgreSQL types (`INTEGER`, `UUID`, `JSONB`, …), `TRIGGER`, `VOID`, or `TABLE(col: Type, …)` for set-returning functions. Body keys may be newline-separated or comma-separated. TABLE column names must be unique and must not collide with input parameter names.
411
420
 
412
421
  `language: plpgsql` wraps the body in `BEGIN` / `END` unless it already starts with `DECLARE` or `BEGIN`. Function names must be unique in the schema.
413
422
 
414
- `db:diff` treats body, language, volatility, and security changes as `CREATE OR REPLACE`. Argument or return-type changes drop the old function, then create the new one.
423
+ `db:diff` treats body, language, volatility, and security changes as `CREATE OR REPLACE`. Argument or return-type changes (including TABLE column changes) drop the old function, then create the new one.
415
424
 
416
425
  Functions are database objects only in this release — they are not REST endpoints. Call them with `db.$queryRaw`:
417
426
 
@@ -420,6 +429,11 @@ const [row] = await db.$queryRaw<{ get_user_balance: number }>(
420
429
  'SELECT get_user_balance($1)',
421
430
  [userId],
422
431
  );
432
+
433
+ const products = await db.$queryRaw<{ id: string; name: string; price: string }>(
434
+ 'SELECT * FROM search_products($1)',
435
+ [query],
436
+ );
423
437
  ```
424
438
 
425
439
  ---
@@ -100,7 +100,7 @@ models {
100
100
  - **Policies:** `@policy(role: ..., allow: [select|insert|update|delete|all], where: "...")` — `where` supports `{{auth.user.id}}`.
101
101
  - **Validation:** `@regex(...)`, `@range(min: ..., max: ...)` flow into generated Zod schemas.
102
102
  - **Indexes / triggers:** `@@index(...)`, `@@trigger { timing, event, level, execute: """...""" }`.
103
- - **SQL functions:** optional `functions { function name(args): ReturnType { execute: """...""" } }` after `models`. Names snake_case in SQL; call with `db.$queryRaw`.
103
+ - **SQL functions:** optional `functions { function name(args): ReturnType { execute: """...""" } }` after `models`. `ReturnType` may be a scalar (`INTEGER`, `TRIGGER`, `VOID`, …) or `TABLE(col: Type, …)`. Names snake_case in SQL; call scalars with `SELECT fn($1)`, table functions with `SELECT * FROM fn($1)` via `db.$queryRaw`.
104
104
 
105
105
  ## Database Client
106
106
 
@@ -36,7 +36,7 @@ export interface SqlFunction {
36
36
  kind: 'SqlFunction';
37
37
  name: string;
38
38
  params: FunctionParam[];
39
- returns: TypeExpr;
39
+ returns: FunctionReturn;
40
40
  language?: string;
41
41
  volatility?: string;
42
42
  security?: string;
@@ -49,6 +49,13 @@ export interface FunctionParam {
49
49
  type: TypeExpr;
50
50
  loc: SourceLocation;
51
51
  }
52
+ export interface TableReturn {
53
+ kind: 'TableReturn';
54
+ columns: FunctionParam[];
55
+ loc: SourceLocation;
56
+ }
57
+ export type FunctionReturn = TypeExpr | TableReturn;
58
+ export declare function isTableReturn(returns: FunctionReturn): returns is TableReturn;
52
59
  export interface Field {
53
60
  kind: 'Field';
54
61
  name: string;
@@ -1 +1,3 @@
1
- export {};
1
+ export function isTableReturn(returns) {
2
+ return returns.kind === 'TableReturn';
3
+ }
@@ -22,6 +22,8 @@ export declare class Parser {
22
22
  private parseModelsSection;
23
23
  private parseFunctionsSection;
24
24
  parseFunction(existingNames?: Set<string>): SqlFunction;
25
+ private parseFunctionReturn;
26
+ private parseTableReturn;
25
27
  private parseFunctionParams;
26
28
  private parseFunctionParam;
27
29
  private parseFunctionBody;
@@ -137,7 +137,7 @@ export class Parser {
137
137
  const params = this.parseFunctionParams();
138
138
  this.expect(TokenType.RPAREN, "')'");
139
139
  this.expect(TokenType.COLON, "':'");
140
- const returns = this.parseTypeExpr();
140
+ const returns = this.parseFunctionReturn(params);
141
141
  this.expect(TokenType.LBRACE, "'{'");
142
142
  const body = this.parseFunctionBody();
143
143
  this.expect(TokenType.RBRACE, "'}'");
@@ -153,6 +153,53 @@ export class Parser {
153
153
  loc: this.loc(start),
154
154
  };
155
155
  }
156
+ parseFunctionReturn(params) {
157
+ const current = this.current();
158
+ if (current.type === TokenType.IDENT &&
159
+ current.value === 'TABLE' &&
160
+ this.peekType(1) === TokenType.LPAREN) {
161
+ return this.parseTableReturn(params);
162
+ }
163
+ if (current.type === TokenType.IDENT && current.value === 'TABLE') {
164
+ throw new ParseError("TABLE column list '(...)'", current);
165
+ }
166
+ return this.parseTypeExpr();
167
+ }
168
+ parseTableReturn(params) {
169
+ const start = this.expect(TokenType.IDENT, "'TABLE'");
170
+ this.expect(TokenType.LPAREN, "'('");
171
+ if (this.check(TokenType.RPAREN)) {
172
+ throw new ParseError('at least one TABLE column', this.current());
173
+ }
174
+ const columns = [];
175
+ const columnNames = new Set();
176
+ const paramNames = new Set(params.map((param) => param.name));
177
+ do {
178
+ const nameToken = this.expect(TokenType.IDENT, 'TABLE column name');
179
+ if (columnNames.has(nameToken.value)) {
180
+ throw new ParseError(`unique TABLE column name, "${nameToken.value}" already defined`, nameToken);
181
+ }
182
+ if (paramNames.has(nameToken.value)) {
183
+ throw new ParseError(`TABLE column name distinct from parameter "${nameToken.value}"`, nameToken);
184
+ }
185
+ columnNames.add(nameToken.value);
186
+ this.expect(TokenType.COLON, "':'");
187
+ const type = this.parseTypeExpr();
188
+ columns.push({
189
+ kind: 'FunctionParam',
190
+ name: nameToken.value,
191
+ type,
192
+ loc: this.loc(nameToken),
193
+ });
194
+ } while (this.match(TokenType.COMMA) && !this.check(TokenType.RPAREN));
195
+ this.consumeTrailingComma();
196
+ this.expect(TokenType.RPAREN, "')'");
197
+ return {
198
+ kind: 'TableReturn',
199
+ columns,
200
+ loc: this.loc(start),
201
+ };
202
+ }
156
203
  parseFunctionParams() {
157
204
  if (this.check(TokenType.RPAREN)) {
158
205
  return [];
@@ -1,4 +1,4 @@
1
- import { getEnumNames, normalizeFunction, } from '../utils/ast-helpers.js';
1
+ import { formatNormalizedFunctionReturn, getEnumNames, normalizeFunction, } from '../utils/ast-helpers.js';
2
2
  import { joinSection } from '../utils/format.js';
3
3
  import { quoteIdentifier } from '../utils/snake-case.js';
4
4
  export function generateCreateFunction(normalized) {
@@ -8,7 +8,7 @@ export function generateCreateFunction(normalized) {
8
8
  .join(', ');
9
9
  const clauses = [
10
10
  `CREATE OR REPLACE FUNCTION ${functionName}(${params})`,
11
- `RETURNS ${normalized.returns}`,
11
+ `RETURNS ${formatNormalizedFunctionReturn(normalized.returns)}`,
12
12
  `LANGUAGE ${normalized.language}`,
13
13
  ];
14
14
  if (normalized.volatility !== 'VOLATILE') {
@@ -61,16 +61,24 @@ export interface NormalizedFunctionParam {
61
61
  sqlName: string;
62
62
  sqlType: string;
63
63
  }
64
+ export type NormalizedFunctionReturn = {
65
+ kind: 'scalar';
66
+ sqlType: string;
67
+ } | {
68
+ kind: 'table';
69
+ columns: NormalizedFunctionParam[];
70
+ };
64
71
  export interface NormalizedFunction {
65
72
  name: string;
66
73
  sqlName: string;
67
74
  params: NormalizedFunctionParam[];
68
- returns: string;
75
+ returns: NormalizedFunctionReturn;
69
76
  language: string;
70
77
  volatility: string;
71
78
  security: string;
72
79
  execute: string;
73
80
  }
74
81
  export declare function normalizeFunction(sqlFunction: SqlFunction, enumNames: Set<string>): NormalizedFunction;
82
+ export declare function formatNormalizedFunctionReturn(returns: NormalizedFunctionReturn): string;
75
83
  export declare function functionIdentity(normalized: NormalizedFunction): string;
76
84
  export declare function functionSignature(normalized: NormalizedFunction): string;
@@ -1,6 +1,7 @@
1
+ import { isTableReturn } from '../../schema-dsl/ast.js';
1
2
  import { formatDefaultValue, serializeValue } from './value-formatter.js';
2
3
  import { mapColumnType } from './type-mapper.js';
3
- import { toSnakeCase, toTableName } from './snake-case.js';
4
+ import { toSnakeCase, toTableName, quoteIdentifier } from './snake-case.js';
4
5
  export function getModelNames(schema) {
5
6
  return new Set(schema.models.map((model) => model.name));
6
7
  }
@@ -251,21 +252,44 @@ export function resolveTriggerNames(model, timing, event) {
251
252
  };
252
253
  }
253
254
  export function normalizeFunction(sqlFunction, enumNames) {
255
+ const params = sqlFunction.params.map((param) => ({
256
+ name: param.name,
257
+ sqlName: toSnakeCase(param.name),
258
+ sqlType: serializeColumnType(param.type, enumNames),
259
+ }));
260
+ const returns = isTableReturn(sqlFunction.returns)
261
+ ? {
262
+ kind: 'table',
263
+ columns: sqlFunction.returns.columns.map((column) => ({
264
+ name: column.name,
265
+ sqlName: toSnakeCase(column.name),
266
+ sqlType: serializeColumnType(column.type, enumNames),
267
+ })),
268
+ }
269
+ : {
270
+ kind: 'scalar',
271
+ sqlType: serializeColumnType(sqlFunction.returns, enumNames),
272
+ };
254
273
  return {
255
274
  name: sqlFunction.name,
256
275
  sqlName: toSnakeCase(sqlFunction.name),
257
- params: sqlFunction.params.map((param) => ({
258
- name: param.name,
259
- sqlName: toSnakeCase(param.name),
260
- sqlType: serializeColumnType(param.type, enumNames),
261
- })),
262
- returns: serializeColumnType(sqlFunction.returns, enumNames),
276
+ params,
277
+ returns,
263
278
  language: (sqlFunction.language ?? 'sql').toLowerCase(),
264
279
  volatility: (sqlFunction.volatility ?? 'VOLATILE').toUpperCase(),
265
280
  security: (sqlFunction.security ?? 'INVOKER').toUpperCase(),
266
281
  execute: sqlFunction.execute.trim(),
267
282
  };
268
283
  }
284
+ export function formatNormalizedFunctionReturn(returns) {
285
+ if (returns.kind === 'scalar') {
286
+ return returns.sqlType;
287
+ }
288
+ const columns = returns.columns
289
+ .map((column) => `${quoteIdentifier(column.sqlName)} ${column.sqlType}`)
290
+ .join(', ');
291
+ return `TABLE (${columns})`;
292
+ }
269
293
  export function functionIdentity(normalized) {
270
294
  return JSON.stringify({
271
295
  sqlName: normalized.sqlName,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "schematic-pg",
3
- "version": "0.1.19",
3
+ "version": "0.1.20",
4
4
  "description": "Single-file backend framework for PostgreSQL and Node.js",
5
5
  "type": "module",
6
6
  "license": "MIT",