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 +61 -1
- package/dist/cli/templates/agents.md +2 -1
- package/dist/schema-dsl/ast.d.ts +25 -0
- package/dist/schema-dsl/ast.js +3 -1
- package/dist/schema-dsl/lexer.js +4 -1
- package/dist/schema-dsl/parser.d.ts +8 -1
- package/dist/schema-dsl/parser.js +178 -0
- package/dist/schema-dsl/tokens.d.ts +2 -0
- package/dist/schema-dsl/tokens.js +4 -0
- package/dist/sql-generator/generators/functions.d.ts +6 -0
- package/dist/sql-generator/generators/functions.js +50 -0
- package/dist/sql-generator/migration-planner.d.ts +1 -0
- package/dist/sql-generator/migration-planner.js +44 -1
- package/dist/sql-generator/migration-sql-generator.js +31 -7
- package/dist/sql-generator/migration-types.d.ts +14 -1
- package/dist/sql-generator/sql-generator.js +2 -0
- package/dist/sql-generator/utils/ast-helpers.d.ts +27 -1
- package/dist/sql-generator/utils/ast-helpers.js +51 -1
- package/dist/sql-generator/utils/type-mapper.js +3 -0
- package/package.json +1 -1
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
|
|
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
|
|
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
|
|
package/dist/schema-dsl/ast.d.ts
CHANGED
|
@@ -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;
|
package/dist/schema-dsl/ast.js
CHANGED
package/dist/schema-dsl/lexer.js
CHANGED
|
@@ -130,7 +130,10 @@ export class Lexer {
|
|
|
130
130
|
scanTripleString(startLine, startCol) {
|
|
131
131
|
let value = '';
|
|
132
132
|
while (!this.isAtEnd()) {
|
|
133
|
-
if (this.
|
|
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 = [];
|
|
@@ -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
|
+
}
|
|
@@ -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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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, {
|
|
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
|
}
|