@specverse/engines 6.17.0 → 6.18.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/dist/libs/instance-factories/services/templates/postgres-native/ddl-generator.js +61 -7
- package/libs/instance-factories/services/templates/postgres-native/__tests__/ddl-generator.test.ts +285 -0
- package/libs/instance-factories/services/templates/postgres-native/ddl-generator.ts +140 -24
- package/package.json +1 -1
|
@@ -38,25 +38,38 @@ CREATE TABLE IF NOT EXISTS "${table}" (
|
|
|
38
38
|
);`;
|
|
39
39
|
}
|
|
40
40
|
const list = Array.isArray(attrs) ? attrs.map((a) => [a.name, a]) : Object.entries(attrs);
|
|
41
|
+
const compositePk = parseColumnList(model.keys ?? model.primaryKey);
|
|
42
|
+
const compositeUniques = parseCompositeUnique(model.unique);
|
|
41
43
|
const columns = [];
|
|
42
44
|
const indexes = [];
|
|
43
45
|
let hasIdColumn = false;
|
|
46
|
+
const compositePkSet = new Set(compositePk);
|
|
44
47
|
for (const [colName, attr] of list) {
|
|
45
48
|
if (!colName) continue;
|
|
46
|
-
const
|
|
49
|
+
const partOfCompositePk = compositePkSet.has(colName);
|
|
50
|
+
const { sql, isPk, isUnique } = columnDef(colName, attr, modelName, partOfCompositePk);
|
|
47
51
|
if (isPk) hasIdColumn = true;
|
|
48
52
|
columns.push(" " + sql);
|
|
49
|
-
if (isUnique && !isPk) {
|
|
53
|
+
if (isUnique && !isPk && !partOfCompositePk) {
|
|
50
54
|
indexes.push(
|
|
51
55
|
`CREATE UNIQUE INDEX IF NOT EXISTS "${table}_${colName}_uq" ON "${table}" ("${colName}");`
|
|
52
56
|
);
|
|
53
|
-
} else if (colName.endsWith("Id") && colName !== "id") {
|
|
57
|
+
} else if (colName.endsWith("Id") && colName !== "id" && !partOfCompositePk) {
|
|
54
58
|
indexes.push(
|
|
55
59
|
`CREATE INDEX IF NOT EXISTS "${table}_${colName}_idx" ON "${table}" ("${colName}");`
|
|
56
60
|
);
|
|
57
61
|
}
|
|
62
|
+
const partial = parsePartialIndex(attr.index);
|
|
63
|
+
if (partial) {
|
|
64
|
+
const indexName = `${table}_${colName}${partial.unique ? "_uq" : "_idx"}_partial`;
|
|
65
|
+
const uniqueClause = partial.unique ? "UNIQUE " : "";
|
|
66
|
+
const whereClause = partial.where ? ` WHERE ${partial.where}` : "";
|
|
67
|
+
indexes.push(
|
|
68
|
+
`CREATE ${uniqueClause}INDEX IF NOT EXISTS "${indexName}" ON "${table}" ("${colName}")${whereClause};`
|
|
69
|
+
);
|
|
70
|
+
}
|
|
58
71
|
}
|
|
59
|
-
if (!hasIdColumn) {
|
|
72
|
+
if (compositePk.length === 0 && !hasIdColumn) {
|
|
60
73
|
columns.unshift(' "id" TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text');
|
|
61
74
|
}
|
|
62
75
|
const declaredNames = new Set(list.map(([n]) => n));
|
|
@@ -74,18 +87,24 @@ CREATE TABLE IF NOT EXISTS "${table}" (
|
|
|
74
87
|
const defaultClause = initial ? ` DEFAULT '${initial.replace(/'/g, "''")}'` : "";
|
|
75
88
|
columns.push(` "${lcName}" TEXT NOT NULL${defaultClause}`);
|
|
76
89
|
}
|
|
90
|
+
if (compositePk.length > 0) {
|
|
91
|
+
columns.push(` PRIMARY KEY (${compositePk.map(quoteIdent).join(", ")})`);
|
|
92
|
+
}
|
|
93
|
+
for (const cols of compositeUniques) {
|
|
94
|
+
columns.push(` UNIQUE (${cols.map(quoteIdent).join(", ")})`);
|
|
95
|
+
}
|
|
77
96
|
const indexBlock = indexes.length > 0 ? "\n\n" + indexes.join("\n") : "";
|
|
78
97
|
return `-- ${modelName}
|
|
79
98
|
CREATE TABLE IF NOT EXISTS "${table}" (
|
|
80
99
|
${columns.join(",\n")}
|
|
81
100
|
);${indexBlock}`;
|
|
82
101
|
}
|
|
83
|
-
function columnDef(name, attr, _modelName) {
|
|
84
|
-
const required = !!attr.required;
|
|
102
|
+
function columnDef(name, attr, _modelName, forceNotNull) {
|
|
103
|
+
const required = !!attr.required || forceNotNull;
|
|
85
104
|
const unique = !!attr.unique;
|
|
86
105
|
const type = (attr.type || "String").toLowerCase();
|
|
87
106
|
const isIdColumn = name === "id";
|
|
88
|
-
if (isIdColumn) {
|
|
107
|
+
if (isIdColumn && !forceNotNull) {
|
|
89
108
|
if (type === "uuid") {
|
|
90
109
|
return { sql: `"id" UUID PRIMARY KEY DEFAULT gen_random_uuid()`, isPk: true, isUnique: true };
|
|
91
110
|
}
|
|
@@ -164,6 +183,41 @@ function formatColumnDefault(value, type) {
|
|
|
164
183
|
}
|
|
165
184
|
return "";
|
|
166
185
|
}
|
|
186
|
+
function parseColumnList(raw) {
|
|
187
|
+
if (raw === void 0 || raw === null) return [];
|
|
188
|
+
if (typeof raw === "string") return [raw];
|
|
189
|
+
if (!Array.isArray(raw)) return [];
|
|
190
|
+
if (raw.length === 0) return [];
|
|
191
|
+
if (typeof raw[0] === "string") return raw.filter((x) => typeof x === "string");
|
|
192
|
+
if (Array.isArray(raw[0])) {
|
|
193
|
+
return raw[0].filter((x) => typeof x === "string");
|
|
194
|
+
}
|
|
195
|
+
return [];
|
|
196
|
+
}
|
|
197
|
+
function parseCompositeUnique(raw) {
|
|
198
|
+
if (raw === void 0 || raw === null) return [];
|
|
199
|
+
if (!Array.isArray(raw) || raw.length === 0) return [];
|
|
200
|
+
if (typeof raw[0] === "string") {
|
|
201
|
+
return [raw.filter((x) => typeof x === "string")];
|
|
202
|
+
}
|
|
203
|
+
if (Array.isArray(raw[0])) {
|
|
204
|
+
return raw.map((row) => row.filter((x) => typeof x === "string")).filter((row) => row.length > 0);
|
|
205
|
+
}
|
|
206
|
+
return [];
|
|
207
|
+
}
|
|
208
|
+
function parsePartialIndex(raw) {
|
|
209
|
+
if (raw === void 0 || raw === null || raw === false) return null;
|
|
210
|
+
if (raw === true) return { where: null, unique: false };
|
|
211
|
+
if (typeof raw === "object") {
|
|
212
|
+
const where = typeof raw.where === "string" && raw.where.trim() !== "" ? raw.where.trim() : null;
|
|
213
|
+
const unique = !!raw.unique;
|
|
214
|
+
return { where, unique };
|
|
215
|
+
}
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
function quoteIdent(name) {
|
|
219
|
+
return '"' + name.replace(/"/g, '""') + '"';
|
|
220
|
+
}
|
|
167
221
|
export {
|
|
168
222
|
generatePgSchemaSql as default
|
|
169
223
|
};
|
package/libs/instance-factories/services/templates/postgres-native/__tests__/ddl-generator.test.ts
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression tests for the postgres-native DDL generator.
|
|
3
|
+
*
|
|
4
|
+
* Pins existing functionality (CREATE TABLE shape, type mapping, FK
|
|
5
|
+
* indexes, lifecycle columns, default values, unique constraints) AND
|
|
6
|
+
* the new Tier 1 / Tier 2 features added in engines 6.18.0:
|
|
7
|
+
* - Composite primary keys (`keys: [a, b]`, `primaryKey: [a, b]`)
|
|
8
|
+
* - Composite unique constraints (`unique: [[a, b]]`)
|
|
9
|
+
* - Partial indexes (`attr.index: { where, unique }`)
|
|
10
|
+
* - JSON type mapping (already present; pinned as a regression)
|
|
11
|
+
*
|
|
12
|
+
* Tests assert SQL substrings rather than full-text equality so a future
|
|
13
|
+
* comment / formatting tweak doesn't cascade into 30 broken tests.
|
|
14
|
+
*/
|
|
15
|
+
import { describe, it, expect } from 'vitest';
|
|
16
|
+
import generatePgSchemaSql from '../ddl-generator.js';
|
|
17
|
+
|
|
18
|
+
const todoModel = {
|
|
19
|
+
name: 'Todo',
|
|
20
|
+
attributes: {
|
|
21
|
+
id: { name: 'id', type: 'UUID', required: true, auto: 'uuid4' },
|
|
22
|
+
title: { name: 'title', type: 'String', required: true },
|
|
23
|
+
completed: { name: 'completed', type: 'Boolean', default: false },
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
describe('postgres-native — ddl-generator (existing functionality)', () => {
|
|
28
|
+
it('emits CREATE TABLE IF NOT EXISTS + pgcrypto extension', () => {
|
|
29
|
+
const sql = generatePgSchemaSql({ models: [todoModel] } as any);
|
|
30
|
+
expect(sql).toContain('CREATE EXTENSION IF NOT EXISTS "pgcrypto"');
|
|
31
|
+
expect(sql).toContain('CREATE TABLE IF NOT EXISTS "todos"');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('maps types correctly (TEXT/INTEGER/BOOLEAN/TIMESTAMPTZ/UUID)', () => {
|
|
35
|
+
const model = {
|
|
36
|
+
name: 'Mix',
|
|
37
|
+
attributes: {
|
|
38
|
+
id: { name: 'id', type: 'UUID', required: true },
|
|
39
|
+
title: { name: 'title', type: 'String', required: true },
|
|
40
|
+
count: { name: 'count', type: 'Integer', required: true },
|
|
41
|
+
ratio: { name: 'ratio', type: 'Float' },
|
|
42
|
+
active: { name: 'active', type: 'Boolean', default: true },
|
|
43
|
+
when: { name: 'when', type: 'DateTime' },
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
const sql = generatePgSchemaSql({ models: [model] } as any);
|
|
47
|
+
expect(sql).toContain('"title" TEXT NOT NULL');
|
|
48
|
+
expect(sql).toContain('"count" INTEGER NOT NULL');
|
|
49
|
+
expect(sql).toContain('"ratio" DOUBLE PRECISION');
|
|
50
|
+
expect(sql).toContain('"active" BOOLEAN');
|
|
51
|
+
expect(sql).toContain('"when" TIMESTAMPTZ');
|
|
52
|
+
expect(sql).toContain('"id" UUID PRIMARY KEY DEFAULT gen_random_uuid()');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('synthesises an id column when no id is declared', () => {
|
|
56
|
+
const model = {
|
|
57
|
+
name: 'NoId',
|
|
58
|
+
attributes: { name: { name: 'name', type: 'String', required: true } },
|
|
59
|
+
};
|
|
60
|
+
const sql = generatePgSchemaSql({ models: [model] } as any);
|
|
61
|
+
expect(sql).toContain('"id" TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('emits FK indexes for *Id columns', () => {
|
|
65
|
+
const model = {
|
|
66
|
+
name: 'Item',
|
|
67
|
+
attributes: {
|
|
68
|
+
id: { name: 'id', type: 'UUID', required: true },
|
|
69
|
+
userId: { name: 'userId', type: 'UUID', required: true },
|
|
70
|
+
categoryId: { name: 'categoryId', type: 'UUID', required: true },
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
const sql = generatePgSchemaSql({ models: [model] } as any);
|
|
74
|
+
expect(sql).toContain('CREATE INDEX IF NOT EXISTS "items_userId_idx" ON "items" ("userId")');
|
|
75
|
+
expect(sql).toContain('CREATE INDEX IF NOT EXISTS "items_categoryId_idx" ON "items" ("categoryId")');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('emits unique indexes for column-level `unique: true`', () => {
|
|
79
|
+
const model = {
|
|
80
|
+
name: 'User',
|
|
81
|
+
attributes: {
|
|
82
|
+
id: { name: 'id', type: 'UUID', required: true },
|
|
83
|
+
email: { name: 'email', type: 'String', required: true, unique: true },
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
const sql = generatePgSchemaSql({ models: [model] } as any);
|
|
87
|
+
expect(sql).toContain('CREATE UNIQUE INDEX IF NOT EXISTS "users_email_uq" ON "users" ("email")');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('honours `model.storage.table` override', () => {
|
|
91
|
+
const sql = generatePgSchemaSql({
|
|
92
|
+
models: [{ ...todoModel, storage: { table: 'custom_todos' } }],
|
|
93
|
+
} as any);
|
|
94
|
+
expect(sql).toContain('CREATE TABLE IF NOT EXISTS "custom_todos"');
|
|
95
|
+
expect(sql).not.toContain('CREATE TABLE IF NOT EXISTS "todos"');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('emits lifecycle columns auto-derived from `flow:` shorthand', () => {
|
|
99
|
+
const model = {
|
|
100
|
+
...todoModel,
|
|
101
|
+
lifecycles: { status: { name: 'status', flow: 'open -> closed' } },
|
|
102
|
+
};
|
|
103
|
+
const sql = generatePgSchemaSql({ models: [model] } as any);
|
|
104
|
+
expect(sql).toContain(`"status" TEXT NOT NULL DEFAULT 'open'`);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('adds createdAt/updatedAt defaults if not declared', () => {
|
|
108
|
+
const sql = generatePgSchemaSql({ models: [todoModel] } as any);
|
|
109
|
+
expect(sql).toContain('"createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW()');
|
|
110
|
+
expect(sql).toContain('"updatedAt" TIMESTAMPTZ NOT NULL DEFAULT NOW()');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('returns a stub when no models declared', () => {
|
|
114
|
+
expect(generatePgSchemaSql({} as any)).toContain('No models declared');
|
|
115
|
+
expect(generatePgSchemaSql({ models: [] } as any)).toContain('No models declared');
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe('postgres-native — ddl-generator (engines 6.18.0 — Tier 1: composite keys + uniques)', () => {
|
|
120
|
+
it('composite primary key via `keys: [a, b]` emits a table-level PRIMARY KEY constraint', () => {
|
|
121
|
+
const model = {
|
|
122
|
+
name: 'Membership',
|
|
123
|
+
attributes: {
|
|
124
|
+
tenantId: { name: 'tenantId', type: 'UUID', required: true },
|
|
125
|
+
userId: { name: 'userId', type: 'UUID', required: true },
|
|
126
|
+
role: { name: 'role', type: 'String', required: true },
|
|
127
|
+
},
|
|
128
|
+
keys: ['tenantId', 'userId'],
|
|
129
|
+
};
|
|
130
|
+
const sql = generatePgSchemaSql({ models: [model] } as any);
|
|
131
|
+
// Table-level constraint
|
|
132
|
+
expect(sql).toContain('PRIMARY KEY ("tenantId", "userId")');
|
|
133
|
+
// Both columns NOT NULL (forced by composite-PK membership)
|
|
134
|
+
expect(sql).toContain('"tenantId" UUID NOT NULL');
|
|
135
|
+
expect(sql).toContain('"userId" UUID NOT NULL');
|
|
136
|
+
// No synthesised id column (composite PK takes its place)
|
|
137
|
+
expect(sql).not.toContain('"id" TEXT PRIMARY KEY');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('accepts `primaryKey:` as an alias for `keys:`', () => {
|
|
141
|
+
const model = {
|
|
142
|
+
name: 'Pair',
|
|
143
|
+
attributes: {
|
|
144
|
+
a: { name: 'a', type: 'String', required: true },
|
|
145
|
+
b: { name: 'b', type: 'String', required: true },
|
|
146
|
+
},
|
|
147
|
+
primaryKey: ['a', 'b'],
|
|
148
|
+
};
|
|
149
|
+
const sql = generatePgSchemaSql({ models: [model] } as any);
|
|
150
|
+
expect(sql).toContain('PRIMARY KEY ("a", "b")');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('composite unique constraint via `unique: [[a, b]]`', () => {
|
|
154
|
+
const model = {
|
|
155
|
+
name: 'Slug',
|
|
156
|
+
attributes: {
|
|
157
|
+
id: { name: 'id', type: 'UUID', required: true },
|
|
158
|
+
tenantId: { name: 'tenantId', type: 'UUID', required: true },
|
|
159
|
+
slug: { name: 'slug', type: 'String', required: true },
|
|
160
|
+
},
|
|
161
|
+
unique: [['tenantId', 'slug']],
|
|
162
|
+
};
|
|
163
|
+
const sql = generatePgSchemaSql({ models: [model] } as any);
|
|
164
|
+
expect(sql).toContain('UNIQUE ("tenantId", "slug")');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('multiple composite uniques', () => {
|
|
168
|
+
const model = {
|
|
169
|
+
name: 'Multi',
|
|
170
|
+
attributes: {
|
|
171
|
+
id: { name: 'id', type: 'UUID', required: true },
|
|
172
|
+
a: { name: 'a', type: 'String' },
|
|
173
|
+
b: { name: 'b', type: 'String' },
|
|
174
|
+
c: { name: 'c', type: 'String' },
|
|
175
|
+
},
|
|
176
|
+
unique: [['a', 'b'], ['b', 'c']],
|
|
177
|
+
};
|
|
178
|
+
const sql = generatePgSchemaSql({ models: [model] } as any);
|
|
179
|
+
expect(sql).toContain('UNIQUE ("a", "b")');
|
|
180
|
+
expect(sql).toContain('UNIQUE ("b", "c")');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('a single-array `unique: [a, b]` is treated as ONE composite (not two single-column)', () => {
|
|
184
|
+
// This is the documented contract: single uniques go at the attribute
|
|
185
|
+
// level, not in `model.unique`. So `unique: ["a", "b"]` is one
|
|
186
|
+
// composite spanning a and b.
|
|
187
|
+
const model = {
|
|
188
|
+
name: 'Single',
|
|
189
|
+
attributes: {
|
|
190
|
+
id: { name: 'id', type: 'UUID', required: true },
|
|
191
|
+
a: { name: 'a', type: 'String' },
|
|
192
|
+
b: { name: 'b', type: 'String' },
|
|
193
|
+
},
|
|
194
|
+
unique: ['a', 'b'],
|
|
195
|
+
};
|
|
196
|
+
const sql = generatePgSchemaSql({ models: [model] } as any);
|
|
197
|
+
expect(sql).toContain('UNIQUE ("a", "b")');
|
|
198
|
+
// Did NOT generate two separate single-column unique indexes.
|
|
199
|
+
expect(sql).not.toContain('"single_a_uq"');
|
|
200
|
+
expect(sql).not.toContain('"single_b_uq"');
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('does not double-emit FK indexes for columns that are part of composite PK', () => {
|
|
204
|
+
// userId is the FK that would normally get an `_idx` index. When it's
|
|
205
|
+
// part of the PK, the PK already covers index lookups starting on
|
|
206
|
+
// userId, so the extra index would be wasted disk.
|
|
207
|
+
const model = {
|
|
208
|
+
name: 'Member',
|
|
209
|
+
attributes: {
|
|
210
|
+
userId: { name: 'userId', type: 'UUID', required: true },
|
|
211
|
+
groupId: { name: 'groupId', type: 'UUID', required: true },
|
|
212
|
+
},
|
|
213
|
+
keys: ['userId', 'groupId'],
|
|
214
|
+
};
|
|
215
|
+
const sql = generatePgSchemaSql({ models: [model] } as any);
|
|
216
|
+
expect(sql).not.toContain('"members_userId_idx"');
|
|
217
|
+
expect(sql).not.toContain('"members_groupId_idx"');
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
describe('postgres-native — ddl-generator (engines 6.18.0 — Tier 2: partial indexes + JSON)', () => {
|
|
222
|
+
it('partial unique index via `attr.index: { where, unique: true }`', () => {
|
|
223
|
+
const model = {
|
|
224
|
+
name: 'User',
|
|
225
|
+
attributes: {
|
|
226
|
+
id: { name: 'id', type: 'UUID', required: true },
|
|
227
|
+
email: {
|
|
228
|
+
name: 'email',
|
|
229
|
+
type: 'String',
|
|
230
|
+
required: true,
|
|
231
|
+
// Soft-delete-aware unique: only enforce uniqueness for live rows.
|
|
232
|
+
index: { where: '"deletedAt" IS NULL', unique: true },
|
|
233
|
+
},
|
|
234
|
+
deletedAt: { name: 'deletedAt', type: 'DateTime' },
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
const sql = generatePgSchemaSql({ models: [model] } as any);
|
|
238
|
+
expect(sql).toContain('CREATE UNIQUE INDEX IF NOT EXISTS "users_email_uq_partial" ON "users" ("email") WHERE "deletedAt" IS NULL');
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('plain partial index via `attr.index: { where: "..." }`', () => {
|
|
242
|
+
const model = {
|
|
243
|
+
name: 'Job',
|
|
244
|
+
attributes: {
|
|
245
|
+
id: { name: 'id', type: 'UUID', required: true },
|
|
246
|
+
status: {
|
|
247
|
+
name: 'status',
|
|
248
|
+
type: 'String',
|
|
249
|
+
index: { where: "status IN ('pending', 'running')" },
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
const sql = generatePgSchemaSql({ models: [model] } as any);
|
|
254
|
+
expect(sql).toContain(`CREATE INDEX IF NOT EXISTS "jobs_status_idx_partial" ON "jobs" ("status") WHERE status IN ('pending', 'running')`);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('`attr.index: true` emits a plain index (no WHERE)', () => {
|
|
258
|
+
const model = {
|
|
259
|
+
name: 'Q',
|
|
260
|
+
attributes: {
|
|
261
|
+
id: { name: 'id', type: 'UUID', required: true },
|
|
262
|
+
priority: { name: 'priority', type: 'Integer', index: true },
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
const sql = generatePgSchemaSql({ models: [model] } as any);
|
|
266
|
+
// Index emitted without WHERE clause.
|
|
267
|
+
const idxLine = sql.match(/CREATE.*"qs_priority_idx_partial".*$/m)?.[0] ?? '';
|
|
268
|
+
expect(idxLine).toContain('"priority"');
|
|
269
|
+
expect(idxLine).not.toContain('WHERE');
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('JSON / Jsonb maps to JSONB column', () => {
|
|
273
|
+
const model = {
|
|
274
|
+
name: 'Doc',
|
|
275
|
+
attributes: {
|
|
276
|
+
id: { name: 'id', type: 'UUID', required: true },
|
|
277
|
+
payload: { name: 'payload', type: 'Json', required: true },
|
|
278
|
+
metadata: { name: 'metadata', type: 'Jsonb' },
|
|
279
|
+
},
|
|
280
|
+
};
|
|
281
|
+
const sql = generatePgSchemaSql({ models: [model] } as any);
|
|
282
|
+
expect(sql).toContain('"payload" JSONB NOT NULL');
|
|
283
|
+
expect(sql).toContain('"metadata" JSONB');
|
|
284
|
+
});
|
|
285
|
+
});
|
|
@@ -13,24 +13,35 @@
|
|
|
13
13
|
* Float, Number → DOUBLE PRECISION
|
|
14
14
|
* Boolean → BOOLEAN
|
|
15
15
|
* DateTime, Date → TIMESTAMPTZ
|
|
16
|
-
*
|
|
16
|
+
* UUID → UUID
|
|
17
|
+
* Json, Jsonb → JSONB
|
|
17
18
|
*
|
|
18
19
|
* Primary key:
|
|
19
|
-
* - `
|
|
20
|
-
*
|
|
20
|
+
* - Composite via `model.keys: [a, b]` or `model.primaryKey: [a, b]` →
|
|
21
|
+
* PRIMARY KEY (a, b) constraint at the end of the table; the columns
|
|
22
|
+
* are forced NOT NULL and the synthesised id column is suppressed.
|
|
23
|
+
* - `id UUID required` → id UUID PRIMARY KEY DEFAULT gen_random_uuid()
|
|
24
|
+
* - any other "id" required → id TEXT PRIMARY KEY
|
|
21
25
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
26
|
+
* Composite unique constraints:
|
|
27
|
+
* - `model.unique: [[a, b], [c, d]]` → UNIQUE (a, b) constraint per
|
|
28
|
+
* entry. Mixed: a single string array `[a, b]` is interpreted as ONE
|
|
29
|
+
* composite (NOT two single-column ones) — use the
|
|
30
|
+
* attribute-level `unique: true` for single-column uniqueness.
|
|
31
|
+
*
|
|
32
|
+
* Partial indexes:
|
|
33
|
+
* - `attribute.index: { where: "deleted_at IS NULL", unique: true }`
|
|
34
|
+
* emits `CREATE [UNIQUE] INDEX ... WHERE deleted_at IS NULL` after
|
|
35
|
+
* the table. Adopt for soft-delete + tenant-scoped uniqueness.
|
|
36
|
+
*
|
|
37
|
+
* Out of scope (deferred — see TODO #43L): RLS policies, check
|
|
38
|
+
* constraints derived from `min/max` ranges, materialised views.
|
|
24
39
|
*/
|
|
25
40
|
import type { TemplateContext } from '@specverse/types';
|
|
26
41
|
|
|
27
42
|
export default function generatePgSchemaSql(context: TemplateContext): string {
|
|
28
43
|
const { spec, models } = context as any;
|
|
29
44
|
|
|
30
|
-
// Pull a flat list of {modelName, model} from the most-likely shapes:
|
|
31
|
-
// 1. Realize passes `models` (array of model specs from the resolved
|
|
32
|
-
// component); use it directly.
|
|
33
|
-
// 2. Otherwise walk spec.components[].models.
|
|
34
45
|
const allModels: Array<[string, any]> = [];
|
|
35
46
|
if (Array.isArray(models) && models.length > 0) {
|
|
36
47
|
for (const m of models) if (m?.name) allModels.push([m.name, m]);
|
|
@@ -77,29 +88,57 @@ function generateTable(modelName: string, model: any): string {
|
|
|
77
88
|
? attrs.map((a: any) => [a.name, a])
|
|
78
89
|
: Object.entries(attrs);
|
|
79
90
|
|
|
91
|
+
// Composite primary key — read either `keys` or `primaryKey`. Both
|
|
92
|
+
// accepted shapes: array of strings (one composite) OR a single string
|
|
93
|
+
// (degenerate single-column PK; used by some specs as a way to point
|
|
94
|
+
// at a non-`id` column).
|
|
95
|
+
const compositePk = parseColumnList(model.keys ?? model.primaryKey);
|
|
96
|
+
// Composite uniques — array of arrays, each defining one constraint.
|
|
97
|
+
const compositeUniques = parseCompositeUnique(model.unique);
|
|
98
|
+
|
|
80
99
|
const columns: string[] = [];
|
|
81
100
|
const indexes: string[] = [];
|
|
82
101
|
let hasIdColumn = false;
|
|
102
|
+
const compositePkSet = new Set(compositePk);
|
|
83
103
|
|
|
84
104
|
for (const [colName, attr] of list) {
|
|
85
105
|
if (!colName) continue;
|
|
86
|
-
|
|
106
|
+
// When this column is part of a composite PK, force NOT NULL and
|
|
107
|
+
// skip the column-level PRIMARY KEY (the constraint goes at the end
|
|
108
|
+
// of the CREATE TABLE).
|
|
109
|
+
const partOfCompositePk = compositePkSet.has(colName);
|
|
110
|
+
const { sql, isPk, isUnique } = columnDef(colName, attr, modelName, partOfCompositePk);
|
|
87
111
|
if (isPk) hasIdColumn = true;
|
|
88
112
|
columns.push(' ' + sql);
|
|
89
|
-
|
|
113
|
+
|
|
114
|
+
if (isUnique && !isPk && !partOfCompositePk) {
|
|
90
115
|
indexes.push(
|
|
91
116
|
`CREATE UNIQUE INDEX IF NOT EXISTS "${table}_${colName}_uq" ON "${table}" ("${colName}");`,
|
|
92
117
|
);
|
|
93
|
-
} else if (colName.endsWith('Id') && colName !== 'id') {
|
|
118
|
+
} else if (colName.endsWith('Id') && colName !== 'id' && !partOfCompositePk) {
|
|
94
119
|
// FK columns — index for join performance.
|
|
95
120
|
indexes.push(
|
|
96
121
|
`CREATE INDEX IF NOT EXISTS "${table}_${colName}_idx" ON "${table}" ("${colName}");`,
|
|
97
122
|
);
|
|
98
123
|
}
|
|
124
|
+
|
|
125
|
+
// Partial indexes — declared at the attribute level so the column +
|
|
126
|
+
// its specialised index live together in the spec. `attr.index` is
|
|
127
|
+
// either `true` (plain index, no WHERE) or `{ where, unique }`.
|
|
128
|
+
const partial = parsePartialIndex(attr.index);
|
|
129
|
+
if (partial) {
|
|
130
|
+
const indexName = `${table}_${colName}${partial.unique ? '_uq' : '_idx'}_partial`;
|
|
131
|
+
const uniqueClause = partial.unique ? 'UNIQUE ' : '';
|
|
132
|
+
const whereClause = partial.where ? ` WHERE ${partial.where}` : '';
|
|
133
|
+
indexes.push(
|
|
134
|
+
`CREATE ${uniqueClause}INDEX IF NOT EXISTS "${indexName}" ON "${table}" ("${colName}")${whereClause};`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
99
137
|
}
|
|
100
138
|
|
|
101
|
-
//
|
|
102
|
-
if
|
|
139
|
+
// Composite PK takes precedence over the synthesised id column. Skip
|
|
140
|
+
// the synthesised id only if the composite covers every "id" need.
|
|
141
|
+
if (compositePk.length === 0 && !hasIdColumn) {
|
|
103
142
|
columns.unshift(' "id" TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text');
|
|
104
143
|
}
|
|
105
144
|
|
|
@@ -112,11 +151,7 @@ function generateTable(modelName: string, model: any): string {
|
|
|
112
151
|
columns.push(' "updatedAt" TIMESTAMPTZ NOT NULL DEFAULT NOW()');
|
|
113
152
|
}
|
|
114
153
|
|
|
115
|
-
// Lifecycle columns
|
|
116
|
-
// (named after the lifecycle, defaulting to its first state) so
|
|
117
|
-
// controller.evolve has somewhere to write the transitioned state.
|
|
118
|
-
// Mongo is schemaless so this happens implicitly there; in postgres we
|
|
119
|
-
// need an explicit column.
|
|
154
|
+
// Lifecycle columns.
|
|
120
155
|
const lifecycles = Array.isArray(model.lifecycles)
|
|
121
156
|
? model.lifecycles
|
|
122
157
|
: (model.lifecycles ? Object.entries(model.lifecycles).map(([name, lc]: [string, any]) => ({ name, ...lc })) : []);
|
|
@@ -128,6 +163,16 @@ function generateTable(modelName: string, model: any): string {
|
|
|
128
163
|
columns.push(` "${lcName}" TEXT NOT NULL${defaultClause}`);
|
|
129
164
|
}
|
|
130
165
|
|
|
166
|
+
// Table-level constraints (composite PK + composite uniques) go at
|
|
167
|
+
// the end of the column list so the table reads top-to-bottom in the
|
|
168
|
+
// natural order: columns, then constraints.
|
|
169
|
+
if (compositePk.length > 0) {
|
|
170
|
+
columns.push(` PRIMARY KEY (${compositePk.map(quoteIdent).join(', ')})`);
|
|
171
|
+
}
|
|
172
|
+
for (const cols of compositeUniques) {
|
|
173
|
+
columns.push(` UNIQUE (${cols.map(quoteIdent).join(', ')})`);
|
|
174
|
+
}
|
|
175
|
+
|
|
131
176
|
const indexBlock = indexes.length > 0 ? '\n\n' + indexes.join('\n') : '';
|
|
132
177
|
return `-- ${modelName}\nCREATE TABLE IF NOT EXISTS "${table}" (\n${columns.join(',\n')}\n);${indexBlock}`;
|
|
133
178
|
}
|
|
@@ -138,13 +183,15 @@ interface ColumnDefResult {
|
|
|
138
183
|
isUnique: boolean;
|
|
139
184
|
}
|
|
140
185
|
|
|
141
|
-
function columnDef(name: string, attr: any, _modelName: string): ColumnDefResult {
|
|
142
|
-
const required = !!attr.required;
|
|
186
|
+
function columnDef(name: string, attr: any, _modelName: string, forceNotNull: boolean): ColumnDefResult {
|
|
187
|
+
const required = !!attr.required || forceNotNull;
|
|
143
188
|
const unique = !!attr.unique;
|
|
144
189
|
const type = (attr.type || 'String').toLowerCase();
|
|
145
190
|
const isIdColumn = name === 'id';
|
|
146
191
|
|
|
147
|
-
|
|
192
|
+
// When the column is part of a composite PK, suppress the column-level
|
|
193
|
+
// PRIMARY KEY — the table-level PRIMARY KEY (a, b) constraint covers it.
|
|
194
|
+
if (isIdColumn && !forceNotNull) {
|
|
148
195
|
if (type === 'uuid') {
|
|
149
196
|
return { sql: `"id" UUID PRIMARY KEY DEFAULT gen_random_uuid()`, isPk: true, isUnique: true };
|
|
150
197
|
}
|
|
@@ -212,7 +259,6 @@ function deriveInitialState(lc: any): string | null {
|
|
|
212
259
|
return head || null;
|
|
213
260
|
}
|
|
214
261
|
if (lc.transitions && typeof lc.transitions === 'object') {
|
|
215
|
-
// Pick the source side of the first transition.
|
|
216
262
|
const first = Object.values(lc.transitions)[0];
|
|
217
263
|
if (typeof first === 'string') return first.split(/\s*->\s*/)[0]?.trim() || null;
|
|
218
264
|
}
|
|
@@ -229,8 +275,78 @@ function formatColumnDefault(value: any, type: string): string {
|
|
|
229
275
|
if (value === 'now' && (type === 'datetime' || type === 'date' || type === 'timestamp')) {
|
|
230
276
|
return ` DEFAULT NOW()`;
|
|
231
277
|
}
|
|
232
|
-
// Quoted SQL string literal — escape single quotes.
|
|
233
278
|
return ` DEFAULT '${value.replace(/'/g, "''")}'`;
|
|
234
279
|
}
|
|
235
280
|
return '';
|
|
236
281
|
}
|
|
282
|
+
|
|
283
|
+
/** Parse a `keys:` / `primaryKey:` declaration. Accepts:
|
|
284
|
+
* - undefined / empty → []
|
|
285
|
+
* - string → [string] (single-column PK named explicitly; degenerate)
|
|
286
|
+
* - string[] → as-is (composite PK)
|
|
287
|
+
* - string[][] → flatten the first entry (defensive — some specs nest)
|
|
288
|
+
*/
|
|
289
|
+
function parseColumnList(raw: any): string[] {
|
|
290
|
+
if (raw === undefined || raw === null) return [];
|
|
291
|
+
if (typeof raw === 'string') return [raw];
|
|
292
|
+
if (!Array.isArray(raw)) return [];
|
|
293
|
+
if (raw.length === 0) return [];
|
|
294
|
+
if (typeof raw[0] === 'string') return raw.filter((x: any): x is string => typeof x === 'string');
|
|
295
|
+
if (Array.isArray(raw[0])) {
|
|
296
|
+
return (raw[0] as any[]).filter((x: any): x is string => typeof x === 'string');
|
|
297
|
+
}
|
|
298
|
+
return [];
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Parse a composite-unique declaration. Accepts:
|
|
302
|
+
* - undefined → []
|
|
303
|
+
* - string[] → [[...]] (single composite spanning the listed columns)
|
|
304
|
+
* - string[][] → as-is (multiple composites)
|
|
305
|
+
*
|
|
306
|
+
* Single-column uniqueness is intentionally left at the attribute level
|
|
307
|
+
* (`attr.unique: true`) so the spec author signals intent at the right
|
|
308
|
+
* scope. Mixing single + composite at this entry would be ambiguous.
|
|
309
|
+
*/
|
|
310
|
+
function parseCompositeUnique(raw: any): string[][] {
|
|
311
|
+
if (raw === undefined || raw === null) return [];
|
|
312
|
+
if (!Array.isArray(raw) || raw.length === 0) return [];
|
|
313
|
+
if (typeof raw[0] === 'string') {
|
|
314
|
+
return [raw.filter((x: any): x is string => typeof x === 'string')];
|
|
315
|
+
}
|
|
316
|
+
if (Array.isArray(raw[0])) {
|
|
317
|
+
return (raw as any[][])
|
|
318
|
+
.map((row) => row.filter((x: any): x is string => typeof x === 'string'))
|
|
319
|
+
.filter((row) => row.length > 0);
|
|
320
|
+
}
|
|
321
|
+
return [];
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
interface PartialIndex {
|
|
325
|
+
where: string | null;
|
|
326
|
+
unique: boolean;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Parse an attribute-level `index` declaration. Accepts:
|
|
330
|
+
* - undefined / false → null (no extra index)
|
|
331
|
+
* - true → { where: null, unique: false } (plain index, no WHERE — same
|
|
332
|
+
* as a regular index but explicit; useful for non-FK columns)
|
|
333
|
+
* - { where, unique } → as-is
|
|
334
|
+
*/
|
|
335
|
+
function parsePartialIndex(raw: any): PartialIndex | null {
|
|
336
|
+
if (raw === undefined || raw === null || raw === false) return null;
|
|
337
|
+
if (raw === true) return { where: null, unique: false };
|
|
338
|
+
if (typeof raw === 'object') {
|
|
339
|
+
const where = typeof raw.where === 'string' && raw.where.trim() !== ''
|
|
340
|
+
? raw.where.trim()
|
|
341
|
+
: null;
|
|
342
|
+
const unique = !!raw.unique;
|
|
343
|
+
// No `where` AND no `unique` → equivalent to `index: true`. Still
|
|
344
|
+
// emit so the spec author's explicit declaration stays meaningful.
|
|
345
|
+
return { where, unique };
|
|
346
|
+
}
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function quoteIdent(name: string): string {
|
|
351
|
+
return '"' + name.replace(/"/g, '""') + '"';
|
|
352
|
+
}
|