schematic-pg 0.1.18 → 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
@@ -11,7 +11,7 @@
11
11
 
12
12
  `app.schema` is the source of truth. From it, schematic-pg generates PostgreSQL DDL, a type-safe DB client, REST routes, Zod validators, and ACL policies.
13
13
 
14
- A schema file always has three sections, in this order: `extensions`, `enums`, `models`.
14
+ A schema file has three required sections, in this order: `extensions`, `enums`, `models`. An optional `functions` section may follow.
15
15
 
16
16
  ```ts
17
17
  extensions {
@@ -376,6 +376,66 @@ model User {
376
376
  | `level` | `ROW`, `STATEMENT` | `ROW` |
377
377
  | `execute` | Triple-quoted PL/pgSQL | — |
378
378
 
379
+ ### Functions
380
+
381
+ Optional section after `models`. Each `function` becomes a PostgreSQL `CREATE OR REPLACE FUNCTION`. Names and parameters are converted to `snake_case` (`getUserBalance` → `get_user_balance`, `userId` → `user_id`). The `execute` body is copied as-is — use those SQL names inside it, not camelCase.
382
+
383
+ ```ts
384
+ functions {
385
+ function getUserBalance(userId: UUID): INTEGER {
386
+ language: sql
387
+ volatility: STABLE
388
+ execute: """
389
+ SELECT balance FROM "user" WHERE id = user_id
390
+ """
391
+ }
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
+
402
+ function setUpdatedAt(): TRIGGER {
403
+ language: plpgsql
404
+ execute: """
405
+ NEW.updated_at = now();
406
+ RETURN NEW;
407
+ """
408
+ }
409
+ }
410
+ ```
411
+
412
+ | Argument | Values | Default |
413
+ |----------|--------|---------|
414
+ | `language` | `sql`, `plpgsql` | `sql` |
415
+ | `volatility` | `VOLATILE`, `STABLE`, `IMMUTABLE` | `VOLATILE` |
416
+ | `security` | `INVOKER`, `DEFINER` | `INVOKER` |
417
+ | `execute` | Triple-quoted SQL / PL/pgSQL | required |
418
+
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.
420
+
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.
422
+
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.
424
+
425
+ Functions are database objects only in this release — they are not REST endpoints. Call them with `db.$queryRaw`:
426
+
427
+ ```ts
428
+ const [row] = await db.$queryRaw<{ get_user_balance: number }>(
429
+ 'SELECT get_user_balance($1)',
430
+ [userId],
431
+ );
432
+
433
+ const products = await db.$queryRaw<{ id: string; name: string; price: string }>(
434
+ 'SELECT * FROM search_products($1)',
435
+ [query],
436
+ );
437
+ ```
438
+
379
439
  ---
380
440
 
381
441
  ## Quick Start
@@ -14,7 +14,7 @@ schematic-pg is a single-file backend framework for PostgreSQL and Node.js. **`a
14
14
 
15
15
  1. **Never edit `generated/`** — it is overwritten on every `generate` / `dev` run.
16
16
  2. **Regenerate after changes** to `app.schema`, `src/routes/`, or `src/hooks/` (`schematic-pg generate` or `schematic-pg dev`).
17
- 3. **Edit `app.schema`** for models, relations, policies, indexes, and triggers.
17
+ 3. **Edit `app.schema`** for models, relations, policies, indexes, triggers, and SQL functions.
18
18
  4. **Use extension points** for app-specific logic: `src/routes/` (custom HTTP) and `src/hooks/` (lifecycle hooks).
19
19
  5. **Do not hand-write SQL** for CRUD — use the generated DB client or REST API.
20
20
 
@@ -100,6 +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`. `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`.
103
104
 
104
105
  ## Database Client
105
106
 
@@ -9,6 +9,7 @@ export interface Schema {
9
9
  extensions: Extension[];
10
10
  enums: Enum[];
11
11
  models: Model[];
12
+ functions: SqlFunction[];
12
13
  loc: SourceLocation;
13
14
  }
14
15
  export interface Extension {
@@ -31,6 +32,30 @@ export interface Model {
31
32
  directives: Directive[];
32
33
  loc: SourceLocation;
33
34
  }
35
+ export interface SqlFunction {
36
+ kind: 'SqlFunction';
37
+ name: string;
38
+ params: FunctionParam[];
39
+ returns: FunctionReturn;
40
+ language?: string;
41
+ volatility?: string;
42
+ security?: string;
43
+ execute: string;
44
+ loc: SourceLocation;
45
+ }
46
+ export interface FunctionParam {
47
+ kind: 'FunctionParam';
48
+ name: string;
49
+ type: TypeExpr;
50
+ loc: SourceLocation;
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;
34
59
  export interface Field {
35
60
  kind: 'Field';
36
61
  name: string;
@@ -1 +1,3 @@
1
- export {};
1
+ export function isTableReturn(returns) {
2
+ return returns.kind === 'TableReturn';
3
+ }
@@ -130,7 +130,10 @@ export class Lexer {
130
130
  scanTripleString(startLine, startCol) {
131
131
  let value = '';
132
132
  while (!this.isAtEnd()) {
133
- if (this.match('"') && this.match('"') && this.match('"')) {
133
+ if (this.peekChar() === '"' && this.peekChar(1) === '"' && this.peekChar(2) === '"') {
134
+ this.advance();
135
+ this.advance();
136
+ this.advance();
134
137
  return this.makeToken(TokenType.TRIPLE_STRING, value, startLine, startCol);
135
138
  }
136
139
  value += this.advance();
@@ -1,4 +1,4 @@
1
- import type { Attribute, Field, Model, Schema } from './ast.js';
1
+ import type { Attribute, Field, Model, Schema, SqlFunction } from './ast.js';
2
2
  import { Token } from './tokens.js';
3
3
  export declare class ParseError extends Error {
4
4
  readonly line: number;
@@ -20,6 +20,13 @@ export declare class Parser {
20
20
  private parseEnumsSection;
21
21
  private parseEnum;
22
22
  private parseModelsSection;
23
+ private parseFunctionsSection;
24
+ parseFunction(existingNames?: Set<string>): SqlFunction;
25
+ private parseFunctionReturn;
26
+ private parseTableReturn;
27
+ private parseFunctionParams;
28
+ private parseFunctionParam;
29
+ private parseFunctionBody;
23
30
  private parseModelBody;
24
31
  private parseTypeExpr;
25
32
  private parseFieldAttributes;
@@ -24,12 +24,14 @@ export class Parser {
24
24
  const extensions = this.parseExtensionsSection();
25
25
  const enums = this.parseEnumsSection();
26
26
  const models = this.parseModelsSection();
27
+ const functions = this.check(TokenType.FUNCTIONS) ? this.parseFunctionsSection() : [];
27
28
  this.expect(TokenType.EOF, 'end of schema');
28
29
  return {
29
30
  kind: 'Schema',
30
31
  extensions,
31
32
  enums,
32
33
  models,
34
+ functions,
33
35
  loc: this.loc(start),
34
36
  };
35
37
  }
@@ -113,6 +115,182 @@ export class Parser {
113
115
  this.expect(TokenType.RBRACE, "'}'");
114
116
  return models;
115
117
  }
118
+ parseFunctionsSection() {
119
+ this.expect(TokenType.FUNCTIONS, "'functions'");
120
+ this.expect(TokenType.LBRACE, "'{'");
121
+ const functions = [];
122
+ const names = new Set();
123
+ while (!this.check(TokenType.RBRACE)) {
124
+ functions.push(this.parseFunction(names));
125
+ }
126
+ this.expect(TokenType.RBRACE, "'}'");
127
+ return functions;
128
+ }
129
+ parseFunction(existingNames) {
130
+ const start = this.expect(TokenType.FUNCTION, "'function'");
131
+ const nameToken = this.expect(TokenType.IDENT, 'function name');
132
+ if (existingNames?.has(nameToken.value)) {
133
+ throw new ParseError(`unique function name, "${nameToken.value}" already defined`, nameToken);
134
+ }
135
+ existingNames?.add(nameToken.value);
136
+ this.expect(TokenType.LPAREN, "'('");
137
+ const params = this.parseFunctionParams();
138
+ this.expect(TokenType.RPAREN, "')'");
139
+ this.expect(TokenType.COLON, "':'");
140
+ const returns = this.parseFunctionReturn(params);
141
+ this.expect(TokenType.LBRACE, "'{'");
142
+ const body = this.parseFunctionBody();
143
+ this.expect(TokenType.RBRACE, "'}'");
144
+ return {
145
+ kind: 'SqlFunction',
146
+ name: nameToken.value,
147
+ params,
148
+ returns,
149
+ language: body.language,
150
+ volatility: body.volatility,
151
+ security: body.security,
152
+ execute: body.execute,
153
+ loc: this.loc(start),
154
+ };
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
+ }
203
+ parseFunctionParams() {
204
+ if (this.check(TokenType.RPAREN)) {
205
+ return [];
206
+ }
207
+ const params = [];
208
+ do {
209
+ params.push(this.parseFunctionParam());
210
+ } while (this.match(TokenType.COMMA) && !this.check(TokenType.RPAREN));
211
+ this.consumeTrailingComma();
212
+ return params;
213
+ }
214
+ parseFunctionParam() {
215
+ const start = this.expect(TokenType.IDENT, 'parameter name');
216
+ this.expect(TokenType.COLON, "':'");
217
+ const type = this.parseTypeExpr();
218
+ return {
219
+ kind: 'FunctionParam',
220
+ name: start.value,
221
+ type,
222
+ loc: this.loc(start),
223
+ };
224
+ }
225
+ parseFunctionBody() {
226
+ if (this.check(TokenType.RBRACE)) {
227
+ throw new ParseError("function body key 'execute'", this.current());
228
+ }
229
+ let language;
230
+ let volatility;
231
+ let security;
232
+ let execute;
233
+ do {
234
+ const keyToken = this.expect(TokenType.IDENT, 'function body key');
235
+ this.expect(TokenType.COLON, "':'");
236
+ const valueToken = this.current();
237
+ const value = this.parseValue();
238
+ switch (keyToken.value) {
239
+ case 'language': {
240
+ if (value.kind !== 'Identifier') {
241
+ throw new ParseError("'sql' or 'plpgsql'", valueToken);
242
+ }
243
+ const languageName = value.name.toLowerCase();
244
+ if (languageName !== 'sql' && languageName !== 'plpgsql') {
245
+ throw new ParseError("'sql' or 'plpgsql'", valueToken);
246
+ }
247
+ language = languageName;
248
+ break;
249
+ }
250
+ case 'volatility': {
251
+ if (value.kind !== 'Identifier') {
252
+ throw new ParseError("'VOLATILE', 'STABLE', or 'IMMUTABLE'", valueToken);
253
+ }
254
+ const volatilityName = value.name.toUpperCase();
255
+ if (volatilityName !== 'VOLATILE' &&
256
+ volatilityName !== 'STABLE' &&
257
+ volatilityName !== 'IMMUTABLE') {
258
+ throw new ParseError("'VOLATILE', 'STABLE', or 'IMMUTABLE'", valueToken);
259
+ }
260
+ volatility = volatilityName;
261
+ break;
262
+ }
263
+ case 'security': {
264
+ if (value.kind !== 'Identifier') {
265
+ throw new ParseError("'INVOKER' or 'DEFINER'", valueToken);
266
+ }
267
+ const securityName = value.name.toUpperCase();
268
+ if (securityName !== 'INVOKER' && securityName !== 'DEFINER') {
269
+ throw new ParseError("'INVOKER' or 'DEFINER'", valueToken);
270
+ }
271
+ security = securityName;
272
+ break;
273
+ }
274
+ case 'execute': {
275
+ if (value.kind !== 'TripleStringLiteral') {
276
+ throw new ParseError('triple-quoted execute body', valueToken);
277
+ }
278
+ execute = value.value.trim();
279
+ if (execute.length === 0) {
280
+ throw new ParseError('non-empty execute body', valueToken);
281
+ }
282
+ break;
283
+ }
284
+ default:
285
+ throw new ParseError("function body key 'language', 'volatility', 'security', or 'execute'", keyToken);
286
+ }
287
+ this.match(TokenType.COMMA);
288
+ } while (!this.check(TokenType.RBRACE));
289
+ if (!execute) {
290
+ throw new ParseError("function body key 'execute'", this.current());
291
+ }
292
+ return { language, volatility, security, execute };
293
+ }
116
294
  parseModelBody(name, start) {
117
295
  const fields = [];
118
296
  const attributes = [];
@@ -3,6 +3,8 @@ export declare enum TokenType {
3
3
  ENUMS = "ENUMS",
4
4
  MODELS = "MODELS",
5
5
  MODEL = "MODEL",
6
+ FUNCTIONS = "FUNCTIONS",
7
+ FUNCTION = "FUNCTION",
6
8
  STRING = "STRING",
7
9
  TRIPLE_STRING = "TRIPLE_STRING",
8
10
  NUMBER = "NUMBER",
@@ -4,6 +4,8 @@ export var TokenType;
4
4
  TokenType["ENUMS"] = "ENUMS";
5
5
  TokenType["MODELS"] = "MODELS";
6
6
  TokenType["MODEL"] = "MODEL";
7
+ TokenType["FUNCTIONS"] = "FUNCTIONS";
8
+ TokenType["FUNCTION"] = "FUNCTION";
7
9
  TokenType["STRING"] = "STRING";
8
10
  TokenType["TRIPLE_STRING"] = "TRIPLE_STRING";
9
11
  TokenType["NUMBER"] = "NUMBER";
@@ -27,6 +29,8 @@ const KEYWORDS = {
27
29
  enums: TokenType.ENUMS,
28
30
  models: TokenType.MODELS,
29
31
  model: TokenType.MODEL,
32
+ functions: TokenType.FUNCTIONS,
33
+ function: TokenType.FUNCTION,
30
34
  true: TokenType.BOOLEAN,
31
35
  false: TokenType.BOOLEAN,
32
36
  };
@@ -0,0 +1,6 @@
1
+ import type { Schema } from '../../schema-dsl/ast.js';
2
+ import { type NormalizedFunction } from '../utils/ast-helpers.js';
3
+ export type { NormalizedFunction };
4
+ export declare function generateCreateFunction(normalized: NormalizedFunction): string;
5
+ export declare function generateDropFunction(normalized: NormalizedFunction): string;
6
+ export declare function generateFunctions(schema: Schema): string;
@@ -0,0 +1,50 @@
1
+ import { formatNormalizedFunctionReturn, getEnumNames, normalizeFunction, } from '../utils/ast-helpers.js';
2
+ import { joinSection } from '../utils/format.js';
3
+ import { quoteIdentifier } from '../utils/snake-case.js';
4
+ export function generateCreateFunction(normalized) {
5
+ const functionName = quoteIdentifier(normalized.sqlName);
6
+ const params = normalized.params
7
+ .map((param) => `${quoteIdentifier(param.sqlName)} ${param.sqlType}`)
8
+ .join(', ');
9
+ const clauses = [
10
+ `CREATE OR REPLACE FUNCTION ${functionName}(${params})`,
11
+ `RETURNS ${formatNormalizedFunctionReturn(normalized.returns)}`,
12
+ `LANGUAGE ${normalized.language}`,
13
+ ];
14
+ if (normalized.volatility !== 'VOLATILE') {
15
+ clauses.push(normalized.volatility);
16
+ }
17
+ if (normalized.security === 'DEFINER') {
18
+ clauses.push('SECURITY DEFINER');
19
+ }
20
+ const body = formatFunctionBody(normalized.execute, normalized.language);
21
+ clauses.push(`AS $$\n${body}\n$$;`);
22
+ return clauses.join('\n');
23
+ }
24
+ export function generateDropFunction(normalized) {
25
+ const functionName = quoteIdentifier(normalized.sqlName);
26
+ const argTypes = normalized.params.map((param) => param.sqlType).join(', ');
27
+ return `DROP FUNCTION IF EXISTS ${functionName}(${argTypes});`;
28
+ }
29
+ export function generateFunctions(schema) {
30
+ const enumNames = getEnumNames(schema);
31
+ const statements = schema.functions.map((sqlFunction) => generateCreateFunction(normalizeFunction(sqlFunction, enumNames)));
32
+ return joinSection('Create functions', statements);
33
+ }
34
+ function formatFunctionBody(execute, language) {
35
+ const trimmed = execute.trim();
36
+ const indented = indentBody(trimmed);
37
+ if (language !== 'plpgsql') {
38
+ return indented;
39
+ }
40
+ if (/^(declare|begin)\b/i.test(trimmed)) {
41
+ return indented;
42
+ }
43
+ return `BEGIN\n${indented}\nEND;`;
44
+ }
45
+ function indentBody(body) {
46
+ return body
47
+ .split('\n')
48
+ .map((line) => (line.length > 0 ? ` ${line}` : line))
49
+ .join('\n');
50
+ }
@@ -9,6 +9,7 @@ export declare class MigrationPlanner {
9
9
  private diffConstraints;
10
10
  private diffIndexes;
11
11
  private diffTriggers;
12
+ private diffFunctions;
12
13
  private triggerSignatures;
13
14
  private indexSignatures;
14
15
  }
@@ -1,4 +1,4 @@
1
- import { collectForeignKeys, getDirectives, getEnumNames, getModelNames, getStoredFields, isStoredField, normalizeIndexDirective, normalizeTriggerDirective, serializeColumnType, serializeDefault, serializeForeignKey, } from './utils/ast-helpers.js';
1
+ import { collectForeignKeys, functionIdentity, functionSignature, getDirectives, getEnumNames, getModelNames, getStoredFields, isStoredField, normalizeFunction, normalizeIndexDirective, normalizeTriggerDirective, serializeColumnType, serializeDefault, serializeForeignKey, } from './utils/ast-helpers.js';
2
2
  export class MigrationPlanner {
3
3
  generateMigration(oldSchema, newSchema) {
4
4
  const migrations = [];
@@ -7,6 +7,7 @@ export class MigrationPlanner {
7
7
  migrations.push(...this.diffModels(oldSchema, newSchema));
8
8
  migrations.push(...this.diffConstraints(oldSchema, newSchema));
9
9
  migrations.push(...this.diffIndexes(oldSchema, newSchema));
10
+ migrations.push(...this.diffFunctions(oldSchema, newSchema));
10
11
  migrations.push(...this.diffTriggers(oldSchema, newSchema));
11
12
  return migrations;
12
13
  }
@@ -195,6 +196,48 @@ export class MigrationPlanner {
195
196
  }
196
197
  return migrations;
197
198
  }
199
+ diffFunctions(oldSchema, newSchema) {
200
+ const migrations = [];
201
+ const oldEnumNames = getEnumNames(oldSchema);
202
+ const newEnumNames = getEnumNames(newSchema);
203
+ const oldFunctions = new Map(oldSchema.functions.map((sqlFunction) => [
204
+ sqlFunction.name,
205
+ normalizeFunction(sqlFunction, oldEnumNames),
206
+ ]));
207
+ const newFunctions = new Map(newSchema.functions.map((sqlFunction) => [
208
+ sqlFunction.name,
209
+ normalizeFunction(sqlFunction, newEnumNames),
210
+ ]));
211
+ for (const [functionName, newFunction] of newFunctions) {
212
+ const oldFunction = oldFunctions.get(functionName);
213
+ if (!oldFunction) {
214
+ migrations.push({ kind: 'CreateFunction', functionName });
215
+ continue;
216
+ }
217
+ if (functionIdentity(oldFunction) !== functionIdentity(newFunction)) {
218
+ migrations.push({
219
+ kind: 'DropFunction',
220
+ functionName,
221
+ signature: functionSignature(oldFunction),
222
+ });
223
+ migrations.push({ kind: 'CreateFunction', functionName });
224
+ continue;
225
+ }
226
+ if (functionSignature(oldFunction) !== functionSignature(newFunction)) {
227
+ migrations.push({ kind: 'ReplaceFunction', functionName });
228
+ }
229
+ }
230
+ for (const [functionName, oldFunction] of oldFunctions) {
231
+ if (!newFunctions.has(functionName)) {
232
+ migrations.push({
233
+ kind: 'DropFunction',
234
+ functionName,
235
+ signature: functionSignature(oldFunction),
236
+ });
237
+ }
238
+ }
239
+ return migrations;
240
+ }
198
241
  triggerSignatures(model) {
199
242
  return new Set(getDirectives(model, 'trigger').map((directive) => JSON.stringify(normalizeTriggerDirective(directive))));
200
243
  }
@@ -1,10 +1,11 @@
1
1
  import { generateAddEnumValue, generateEnum } from './generators/enums.js';
2
2
  import { generateCreateExtension, generateDropExtension } from './generators/extensions.js';
3
3
  import { generateForeignKey } from './generators/foreign-keys.js';
4
+ import { generateCreateFunction, generateDropFunction, } from './generators/functions.js';
4
5
  import { generateCreateIndex, generateDropIndex, } from './generators/indexes.js';
5
6
  import { generateColumnDefinition, generateTable } from './generators/tables.js';
6
7
  import { generateCreateTrigger, generateDropTrigger, } from './generators/triggers.js';
7
- import { getDirectives, getDefaultExpression, getEnumNames, getModelNames, getStoredFields, normalizeIndexDirective, normalizeTriggerDirective, parseForeignKeySignature, } from './utils/ast-helpers.js';
8
+ import { getDirectives, getDefaultExpression, getEnumNames, getModelNames, getStoredFields, normalizeFunction, normalizeIndexDirective, normalizeTriggerDirective, parseForeignKeySignature, } from './utils/ast-helpers.js';
8
9
  import { quoteIdentifier, toSnakeCase, toTableName } from './utils/snake-case.js';
9
10
  const MIGRATION_ORDER = {
10
11
  CreateExtension: 0,
@@ -18,10 +19,13 @@ const MIGRATION_ORDER = {
18
19
  DropConstraint: 8,
19
20
  DropIndex: 9,
20
21
  CreateIndex: 10,
21
- CreateTrigger: 11,
22
- DropTrigger: 12,
23
- DropTable: 13,
24
- DropExtension: 14,
22
+ DropFunction: 11,
23
+ CreateFunction: 12,
24
+ ReplaceFunction: 13,
25
+ CreateTrigger: 14,
26
+ DropTrigger: 15,
27
+ DropTable: 16,
28
+ DropExtension: 17,
25
29
  };
26
30
  export class MigrationSqlGenerator {
27
31
  generate(migrations, newSchema) {
@@ -32,12 +36,20 @@ export class MigrationSqlGenerator {
32
36
  const modelNames = getModelNames(newSchema);
33
37
  const modelMap = new Map(newSchema.models.map((model) => [model.name, model]));
34
38
  const enumMap = new Map(newSchema.enums.map((enumDef) => [enumDef.name, enumDef]));
39
+ const functionMap = new Map(newSchema.functions.map((sqlFunction) => [sqlFunction.name, sqlFunction]));
35
40
  const ordered = [...migrations].sort((left, right) => MIGRATION_ORDER[left.kind] - MIGRATION_ORDER[right.kind]);
36
- const statements = ordered.map((migration) => this.migrationToSql(migration, { newSchema, enumNames, modelNames, modelMap, enumMap }));
41
+ const statements = ordered.map((migration) => this.migrationToSql(migration, {
42
+ newSchema,
43
+ enumNames,
44
+ modelNames,
45
+ modelMap,
46
+ enumMap,
47
+ functionMap,
48
+ }));
37
49
  return `${statements.join('\n\n')}\n`;
38
50
  }
39
51
  migrationToSql(migration, context) {
40
- const { enumNames, modelNames, modelMap, enumMap } = context;
52
+ const { enumNames, modelNames, modelMap, enumMap, functionMap } = context;
41
53
  switch (migration.kind) {
42
54
  case 'CreateExtension':
43
55
  return generateCreateExtension(migration.extensionName);
@@ -147,6 +159,18 @@ export class MigrationSqlGenerator {
147
159
  const normalized = JSON.parse(migration.signature);
148
160
  return generateDropTrigger(model, normalized);
149
161
  }
162
+ case 'CreateFunction':
163
+ case 'ReplaceFunction': {
164
+ const sqlFunction = functionMap.get(migration.functionName);
165
+ if (!sqlFunction) {
166
+ throw new Error(`Function "${migration.functionName}" not found in new schema`);
167
+ }
168
+ return generateCreateFunction(normalizeFunction(sqlFunction, enumNames));
169
+ }
170
+ case 'DropFunction': {
171
+ const normalized = JSON.parse(migration.signature);
172
+ return generateDropFunction(normalized);
173
+ }
150
174
  default: {
151
175
  const exhaustive = migration;
152
176
  throw new Error(`Unsupported migration kind: ${exhaustive.kind}`);
@@ -1,4 +1,4 @@
1
- export type Migration = CreateExtension | DropExtension | CreateTable | DropTable | AddColumn | DropColumn | AlterColumn | CreateIndex | DropIndex | CreateEnum | AddEnumValue | AddConstraint | DropConstraint | CreateTrigger | DropTrigger;
1
+ export type Migration = CreateExtension | DropExtension | CreateTable | DropTable | AddColumn | DropColumn | AlterColumn | CreateIndex | DropIndex | CreateEnum | AddEnumValue | AddConstraint | DropConstraint | CreateFunction | ReplaceFunction | DropFunction | CreateTrigger | DropTrigger;
2
2
  export interface CreateTable {
3
3
  kind: 'CreateTable';
4
4
  modelName: string;
@@ -84,3 +84,16 @@ export interface DropTrigger {
84
84
  modelName: string;
85
85
  signature: string;
86
86
  }
87
+ export interface CreateFunction {
88
+ kind: 'CreateFunction';
89
+ functionName: string;
90
+ }
91
+ export interface ReplaceFunction {
92
+ kind: 'ReplaceFunction';
93
+ functionName: string;
94
+ }
95
+ export interface DropFunction {
96
+ kind: 'DropFunction';
97
+ functionName: string;
98
+ signature: string;
99
+ }
@@ -3,6 +3,7 @@ import { generateDropTables } from './generators/drop-tables.js';
3
3
  import { generateEnums } from './generators/enums.js';
4
4
  import { generateExtensions } from './generators/extensions.js';
5
5
  import { generateForeignKeys } from './generators/foreign-keys.js';
6
+ import { generateFunctions } from './generators/functions.js';
6
7
  import { generateIndexes } from './generators/indexes.js';
7
8
  import { generateTables } from './generators/tables.js';
8
9
  import { generateTriggers } from './generators/triggers.js';
@@ -15,6 +16,7 @@ export class SqlGenerator {
15
16
  generateTables(schema),
16
17
  generateForeignKeys(schema),
17
18
  generateIndexes(schema),
19
+ generateFunctions(schema),
18
20
  generateTriggers(schema),
19
21
  ];
20
22
  return `${sections.join('\n')}\n`;
@@ -1,4 +1,4 @@
1
- import type { Attribute, AttributeArgs, Directive, Field, KeyValueArgs, Model, Schema, TypeExpr, Value } from '../../schema-dsl/ast.js';
1
+ import type { Attribute, AttributeArgs, Directive, Field, KeyValueArgs, Model, Schema, SqlFunction, TypeExpr, Value } from '../../schema-dsl/ast.js';
2
2
  export interface PrimaryKeyInfo {
3
3
  fields: string[];
4
4
  composite: boolean;
@@ -56,3 +56,29 @@ export interface TriggerNames {
56
56
  }
57
57
  export declare function normalizeTriggerDirective(directive: Directive): NormalizedTrigger;
58
58
  export declare function resolveTriggerNames(model: Model, timing: string, event: string): TriggerNames;
59
+ export interface NormalizedFunctionParam {
60
+ name: string;
61
+ sqlName: string;
62
+ sqlType: string;
63
+ }
64
+ export type NormalizedFunctionReturn = {
65
+ kind: 'scalar';
66
+ sqlType: string;
67
+ } | {
68
+ kind: 'table';
69
+ columns: NormalizedFunctionParam[];
70
+ };
71
+ export interface NormalizedFunction {
72
+ name: string;
73
+ sqlName: string;
74
+ params: NormalizedFunctionParam[];
75
+ returns: NormalizedFunctionReturn;
76
+ language: string;
77
+ volatility: string;
78
+ security: string;
79
+ execute: string;
80
+ }
81
+ export declare function normalizeFunction(sqlFunction: SqlFunction, enumNames: Set<string>): NormalizedFunction;
82
+ export declare function formatNormalizedFunctionReturn(returns: NormalizedFunctionReturn): string;
83
+ export declare function functionIdentity(normalized: NormalizedFunction): string;
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
  }
@@ -250,3 +251,52 @@ export function resolveTriggerNames(model, timing, event) {
250
251
  triggerName: `${baseName}_trigger`,
251
252
  };
252
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
+ };
273
+ return {
274
+ name: sqlFunction.name,
275
+ sqlName: toSnakeCase(sqlFunction.name),
276
+ params,
277
+ returns,
278
+ language: (sqlFunction.language ?? 'sql').toLowerCase(),
279
+ volatility: (sqlFunction.volatility ?? 'VOLATILE').toUpperCase(),
280
+ security: (sqlFunction.security ?? 'INVOKER').toUpperCase(),
281
+ execute: sqlFunction.execute.trim(),
282
+ };
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
+ }
293
+ export function functionIdentity(normalized) {
294
+ return JSON.stringify({
295
+ sqlName: normalized.sqlName,
296
+ params: normalized.params.map((param) => param.sqlType),
297
+ returns: normalized.returns,
298
+ });
299
+ }
300
+ export function functionSignature(normalized) {
301
+ return JSON.stringify(normalized);
302
+ }
@@ -13,6 +13,9 @@ const PRIMITIVE_TYPES = new Set([
13
13
  'TIMESTAMP',
14
14
  ]);
15
15
  export function mapColumnType(type, enumNames) {
16
+ if (type.name === 'TRIGGER' || type.name === 'VOID') {
17
+ return type.name;
18
+ }
16
19
  const baseType = mapBaseType(type, enumNames);
17
20
  return type.array ? `${baseType}[]` : baseType;
18
21
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "schematic-pg",
3
- "version": "0.1.18",
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",