@an5/orm 1.3.2 → 1.3.3
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 +74 -11
- package/dist/cleanup.d.ts +1 -0
- package/dist/cleanup.js +177 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +147 -0
- package/dist/generator/src/attributes.d.ts +57 -0
- package/dist/generator/src/attributes.js +225 -0
- package/dist/generator/src/code-generator.js +49 -16
- package/dist/generator/src/config.d.ts +2 -2
- package/dist/generator/src/config.js +4 -3
- package/dist/generator/src/dialect.d.ts +52 -1
- package/dist/generator/src/dialect.js +136 -3
- package/dist/generator/src/dotnet-generator.js +67 -23
- package/dist/generator/src/golang-generator.d.ts +8 -0
- package/dist/generator/src/golang-generator.js +86 -21
- package/dist/generator/src/java-generator.d.ts +8 -0
- package/dist/generator/src/java-generator.js +108 -3
- package/dist/generator/src/kotlin-generator.js +51 -0
- package/dist/generator/src/parser.d.ts +1 -0
- package/dist/generator/src/parser.js +90 -35
- package/dist/generator/src/pull-schema.d.ts +54 -0
- package/dist/generator/src/pull-schema.js +116 -0
- package/dist/generator/src/push-apply.d.ts +4 -2
- package/dist/generator/src/push-apply.js +53 -4
- package/dist/generator/src/push-schema.d.ts +21 -9
- package/dist/generator/src/push-schema.js +132 -32
- package/dist/generator/src/python-generator.js +35 -22
- package/dist/generator/src/rust-generator.js +79 -74
- package/dist/generator/src/swift-generator.js +54 -3
- package/dist/generator/src/types.d.ts +10 -0
- package/dist/generator/src/view-order.d.ts +33 -0
- package/dist/generator/src/view-order.js +123 -0
- package/dist/migrate.d.ts +12 -0
- package/dist/migrate.js +442 -0
- package/dist/migration-core.d.ts +32 -3
- package/dist/migration-core.js +213 -43
- package/dist/provider-support.d.ts +0 -1
- package/dist/provider-support.js +0 -1
- package/dist/pull.d.ts +1 -0
- package/dist/pull.js +425 -0
- package/dist/push.d.ts +1 -0
- package/dist/push.js +93 -0
- package/dist/seed.d.ts +1 -0
- package/dist/seed.js +33 -0
- package/generator/src/attributes.ts +231 -0
- package/generator/src/code-generator.ts +47 -16
- package/generator/src/config.ts +5 -3
- package/generator/src/dialect.ts +188 -5
- package/generator/src/dotnet-generator.ts +68 -24
- package/generator/src/golang-generator.ts +89 -22
- package/generator/src/java-generator.ts +109 -3
- package/generator/src/kotlin-generator.ts +51 -0
- package/generator/src/parser.ts +102 -30
- package/generator/src/pull-schema.ts +127 -0
- package/generator/src/push-apply.ts +62 -5
- package/generator/src/push-schema.ts +160 -32
- package/generator/src/python-generator.ts +33 -20
- package/generator/src/rust-generator.ts +79 -73
- package/generator/src/swift-generator.ts +55 -4
- package/generator/src/types.ts +10 -0
- package/generator/src/view-order.ts +133 -0
- package/package.json +19 -14
package/generator/src/parser.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import { Model, Field, Relation } from './types';
|
|
4
|
+
import { attributeArgs, hasAttribute, parseIndexDirective, splitSchemaLines, unquote } from './attributes';
|
|
4
5
|
import {
|
|
5
6
|
DEFAULT_PROVIDER,
|
|
6
7
|
FieldTypeError,
|
|
@@ -37,10 +38,25 @@ interface PendingModelRef {
|
|
|
37
38
|
line: string;
|
|
38
39
|
}
|
|
39
40
|
|
|
41
|
+
/**
|
|
42
|
+
* A field inside a `view` whose type matched nothing.
|
|
43
|
+
*
|
|
44
|
+
* A view's fields describe the shape of a result set, never a relation, so the
|
|
45
|
+
* decision made for models — "a name used by another block is a relation" — does
|
|
46
|
+
* not apply. The reference is kept until every block has been read so the error
|
|
47
|
+
* can still suggest a close model name.
|
|
48
|
+
*/
|
|
49
|
+
interface PendingViewRef {
|
|
50
|
+
view: string;
|
|
51
|
+
field: string;
|
|
52
|
+
type: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
40
55
|
export class SchemaParser {
|
|
41
56
|
private schemaText: string = '';
|
|
42
57
|
private issues: FieldTypeIssue[] = [];
|
|
43
58
|
private modelRefs: PendingModelRef[] = [];
|
|
59
|
+
private viewRefs: PendingViewRef[] = [];
|
|
44
60
|
private models: Model[] = [];
|
|
45
61
|
|
|
46
62
|
/**
|
|
@@ -55,9 +71,10 @@ export class SchemaParser {
|
|
|
55
71
|
public async parse(): Promise<Model[]> {
|
|
56
72
|
this.issues = [];
|
|
57
73
|
this.modelRefs = [];
|
|
74
|
+
this.viewRefs = [];
|
|
58
75
|
this.models = [];
|
|
59
76
|
this.loadSchema();
|
|
60
|
-
const lines = this.schemaText
|
|
77
|
+
const lines = splitSchemaLines(this.schemaText);
|
|
61
78
|
const models: Model[] = [];
|
|
62
79
|
let currentModel: Model | null = null;
|
|
63
80
|
|
|
@@ -84,6 +101,22 @@ export class SchemaParser {
|
|
|
84
101
|
continue;
|
|
85
102
|
}
|
|
86
103
|
|
|
104
|
+
const viewHeaderMatch = line.match(/^view\s+(\w+)\s*\{/);
|
|
105
|
+
if (viewHeaderMatch) {
|
|
106
|
+
const viewName = viewHeaderMatch[1];
|
|
107
|
+
currentModel = {
|
|
108
|
+
name: viewName,
|
|
109
|
+
tableName: viewName.toLowerCase() + 's',
|
|
110
|
+
schemaName: this.provider === 'mssql' ? 'dbo' : '',
|
|
111
|
+
provider: this.provider,
|
|
112
|
+
fields: [],
|
|
113
|
+
relations: [],
|
|
114
|
+
isView: true,
|
|
115
|
+
};
|
|
116
|
+
models.push(currentModel);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
|
|
87
120
|
if (line === '}') {
|
|
88
121
|
currentModel = null;
|
|
89
122
|
continue;
|
|
@@ -97,6 +130,12 @@ export class SchemaParser {
|
|
|
97
130
|
this.models = models;
|
|
98
131
|
this.resolveModelRefs();
|
|
99
132
|
|
|
133
|
+
const missingSql = models.filter((model) => model.isView && !model.viewSql?.trim());
|
|
134
|
+
if (missingSql.length > 0) {
|
|
135
|
+
const names = missingSql.map((model) => `"${model.name}"`).join(', ');
|
|
136
|
+
throw new Error(`View ${names} must declare @@sql("SELECT ...").`);
|
|
137
|
+
}
|
|
138
|
+
|
|
100
139
|
// Every bad type is reported at once, so one pass over the schema is enough
|
|
101
140
|
// instead of re-running the generator after every line.
|
|
102
141
|
if (this.issues.length > 0) {
|
|
@@ -119,32 +158,42 @@ export class SchemaParser {
|
|
|
119
158
|
}
|
|
120
159
|
|
|
121
160
|
private parseModelLine(line: string, model: Model) {
|
|
161
|
+
if (line.startsWith('@@sql') || line.startsWith('@@definition')) {
|
|
162
|
+
// `@@definition` is accepted as an alias: the directive names a SQL body,
|
|
163
|
+
// and both spellings are unambiguous.
|
|
164
|
+
const attribute = line.startsWith('@@sql') ? '@@sql' : '@@definition';
|
|
165
|
+
if (model.isView) {
|
|
166
|
+
const sql = unquote(attributeArgs(line, attribute));
|
|
167
|
+
if (sql !== undefined) model.viewSql = sql.trim();
|
|
168
|
+
}
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
122
171
|
if (line.startsWith('@@map')) {
|
|
123
|
-
const
|
|
124
|
-
if (
|
|
172
|
+
const mapped = unquote(attributeArgs(line, '@@map'));
|
|
173
|
+
if (mapped) model.tableName = mapped;
|
|
125
174
|
return;
|
|
126
175
|
}
|
|
127
176
|
if (line.startsWith('@@schema')) {
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
// the
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
if (schemaMatch) model.schemaName = schemaMatch[1].trim();
|
|
177
|
+
// An empty string is meaningful: `@@schema("")` says "no schema", needed
|
|
178
|
+
// for databases that have no schema concept. It must not be dropped as if
|
|
179
|
+
// the directive were absent.
|
|
180
|
+
const declared = unquote(attributeArgs(line, '@@schema'));
|
|
181
|
+
if (declared !== undefined) model.schemaName = declared.trim();
|
|
134
182
|
return;
|
|
135
183
|
}
|
|
136
184
|
if (line.startsWith('@@unique')) {
|
|
137
|
-
|
|
138
|
-
if (
|
|
139
|
-
|
|
185
|
+
// Constraints describe stored tables; a view's shape comes from its SQL.
|
|
186
|
+
if (model.isView) return;
|
|
187
|
+
const parsed = parseIndexDirective(attributeArgs(line, '@@unique'));
|
|
188
|
+
if (parsed) {
|
|
140
189
|
model.compoundUniques = model.compoundUniques || [];
|
|
141
|
-
model.compoundUniques.push(fields);
|
|
190
|
+
model.compoundUniques.push(parsed.fields);
|
|
142
191
|
}
|
|
143
192
|
return;
|
|
144
193
|
}
|
|
145
194
|
if (line.startsWith('@@description')) {
|
|
146
|
-
const
|
|
147
|
-
if (
|
|
195
|
+
const description = unquote(attributeArgs(line, '@@description'));
|
|
196
|
+
if (description !== undefined) model.description = description;
|
|
148
197
|
return;
|
|
149
198
|
}
|
|
150
199
|
if (line.startsWith('@@')) return;
|
|
@@ -158,16 +207,19 @@ export class SchemaParser {
|
|
|
158
207
|
// later at the database layer, in a message unrelated to the schema.
|
|
159
208
|
const resolved = resolveFieldType(head.type, this.provider);
|
|
160
209
|
if (!resolved) {
|
|
210
|
+
if (model.isView) {
|
|
211
|
+
// A view's fields are columns of a result set, never relations.
|
|
212
|
+
this.viewRefs.push({ view: model.name, field: head.name, type: head.type });
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
161
215
|
this.modelRefs.push({ model, name: head.name, type: head.type, isArray: head.isArray, isOptional: head.isOptional, line });
|
|
162
216
|
return;
|
|
163
217
|
}
|
|
164
218
|
|
|
165
|
-
const hasDefault = line
|
|
166
|
-
const isId = line
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
if (descMatch) description = descMatch[1];
|
|
170
|
-
model.fields.push({ name: head.name, type: resolved.ts, sqlType: head.type, isOptional: head.isOptional, hasDefault, isId, description });
|
|
219
|
+
const hasDefault = hasAttribute(line, '@default') || hasAttribute(line, '@updatedAt') || hasAttribute(line, '@id');
|
|
220
|
+
const isId = hasAttribute(line, '@id');
|
|
221
|
+
const description = unquote(attributeArgs(line, '@description'));
|
|
222
|
+
model.fields.push({ name: head.name, type: resolved.ts, sqlType: head.type, isOptional: head.isOptional, hasDefault, isId, ...(description !== undefined ? { description } : {}) });
|
|
171
223
|
}
|
|
172
224
|
|
|
173
225
|
/**
|
|
@@ -182,25 +234,45 @@ export class SchemaParser {
|
|
|
182
234
|
*/
|
|
183
235
|
private resolveModelRefs() {
|
|
184
236
|
const modelNames = this.models.map((model) => model.name);
|
|
237
|
+
const tableNames = this.models.filter((model) => !model.isView).map((model) => model.name);
|
|
238
|
+
const viewNames = new Set(this.models.filter((model) => model.isView).map((model) => model.name));
|
|
239
|
+
|
|
240
|
+
for (const ref of this.viewRefs) {
|
|
241
|
+
this.issues.push({
|
|
242
|
+
path: `${ref.view}.${ref.field}`,
|
|
243
|
+
message: unknownFieldTypeMessage(ref.type, this.provider, modelNames),
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
this.viewRefs = [];
|
|
185
247
|
|
|
186
248
|
for (const ref of this.modelRefs) {
|
|
187
|
-
if (
|
|
249
|
+
if (viewNames.has(ref.type)) {
|
|
250
|
+
// A relation carries a foreign key and generated write inputs. A view has
|
|
251
|
+
// neither, and an `include` of it would have no CreateInput to reference.
|
|
252
|
+
this.issues.push({
|
|
253
|
+
path: `${ref.model.name}.${ref.name}`,
|
|
254
|
+
message: `"${ref.type}" is a view and cannot be the target of a relation; select from it directly instead`,
|
|
255
|
+
});
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
if (!tableNames.includes(ref.type)) {
|
|
188
259
|
this.issues.push({
|
|
189
260
|
path: `${ref.model.name}.${ref.name}`,
|
|
190
|
-
message: unknownFieldTypeMessage(ref.type, this.provider,
|
|
261
|
+
message: unknownFieldTypeMessage(ref.type, this.provider, tableNames),
|
|
191
262
|
});
|
|
192
263
|
continue;
|
|
193
264
|
}
|
|
194
265
|
|
|
195
266
|
let foreignKey = '', localKey = '', relationName = '';
|
|
196
|
-
const
|
|
197
|
-
if (
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
267
|
+
const relationArgs = attributeArgs(ref.line, '@relation');
|
|
268
|
+
if (relationArgs) {
|
|
269
|
+
// `@relation("name", fields: [userId], references: [id])`; the name is
|
|
270
|
+
// either positional or spelled `name:`.
|
|
271
|
+
relationName = relationArgs.match(/^\s*(?:\w+\s*:\s*)?"([^"]+)"/)?.[1] ?? '';
|
|
272
|
+
foreignKey = (relationArgs.match(/\bfields\s*:\s*\[([^\]]*)\]/)?.[1] ?? '').split(',')[0]?.trim() ?? '';
|
|
273
|
+
localKey = (relationArgs.match(/\breferences\s*:\s*\[([^\]]*)\]/)?.[1] ?? '').split(',')[0]?.trim() ?? '';
|
|
202
274
|
}
|
|
203
|
-
const description = ref.line
|
|
275
|
+
const description = unquote(attributeArgs(ref.line, '@description'));
|
|
204
276
|
ref.model.relations.push({
|
|
205
277
|
name: ref.name,
|
|
206
278
|
type: ref.type,
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers for `db:pull`.
|
|
3
|
+
*
|
|
4
|
+
* Reading SQL Server catalogs and writing schema files lives in `pull.ts`, which
|
|
5
|
+
* starts a connection as soon as it is imported. The decisions it makes about the
|
|
6
|
+
* text — which block to replace, how a view becomes a `view` block — are here so
|
|
7
|
+
* they can be tested without a database.
|
|
8
|
+
*/
|
|
9
|
+
import { viewSqlBody } from './dialect';
|
|
10
|
+
|
|
11
|
+
/** One column as `sys.columns`/`sys.types` describe it. */
|
|
12
|
+
export interface PulledColumn {
|
|
13
|
+
columnName: string;
|
|
14
|
+
dataType: string;
|
|
15
|
+
maxLength: number;
|
|
16
|
+
precision: number;
|
|
17
|
+
scale: number;
|
|
18
|
+
isNullable: boolean | number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The schema type of an introspected SQL Server column.
|
|
23
|
+
*
|
|
24
|
+
* `max_length` is bytes, so the character types divide it by two; `-1` is `MAX`.
|
|
25
|
+
* Tables and views describe their columns the same way, so they share this.
|
|
26
|
+
*/
|
|
27
|
+
export function sqlServerTypeForColumn(col: PulledColumn): string {
|
|
28
|
+
let sqlType = col.dataType.toUpperCase();
|
|
29
|
+
const dt = col.dataType.toLowerCase();
|
|
30
|
+
|
|
31
|
+
if (dt === 'nvarchar' || dt === 'varchar') {
|
|
32
|
+
sqlType = col.maxLength === -1 ? `${sqlType.toUpperCase()}(MAX)` : `${sqlType.toUpperCase()}(${col.maxLength / 2})`;
|
|
33
|
+
} else if (dt === 'char' || dt === 'nchar') {
|
|
34
|
+
sqlType = `${sqlType.toUpperCase()}(${col.maxLength / 2})`;
|
|
35
|
+
} else if (dt === 'varbinary' || dt === 'binary') {
|
|
36
|
+
sqlType = col.maxLength === -1 ? `${sqlType.toUpperCase()}(MAX)` : `${sqlType.toUpperCase()}(${col.maxLength})`;
|
|
37
|
+
} else if (dt === 'decimal' || dt === 'numeric') {
|
|
38
|
+
sqlType = `${sqlType.toUpperCase()}(${col.precision}, ${col.scale})`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return sqlType;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Where `keyword Name { ... }` starts and ends in a schema file.
|
|
46
|
+
*
|
|
47
|
+
* The closing brace is the one on its own line, not the first `}` anywhere: a
|
|
48
|
+
* view's SQL may contain one (`WHERE name <> '}'`) and a regex that stops there
|
|
49
|
+
* would truncate the block.
|
|
50
|
+
*/
|
|
51
|
+
export function blockRange(text: string, keyword: 'model' | 'view', name: string): { start: number; end: number } | null {
|
|
52
|
+
const header = new RegExp(`(^|\\n)[ \\t]*${keyword}\\s+${name}\\s*\\{`);
|
|
53
|
+
const match = header.exec(text);
|
|
54
|
+
if (!match) return null;
|
|
55
|
+
|
|
56
|
+
const start = match.index + (match[1]?.length ?? 0);
|
|
57
|
+
const bodyStart = match.index + match[0].length;
|
|
58
|
+
const close = /(^|\n)[ \t]*\}/.exec(text.slice(bodyStart));
|
|
59
|
+
if (!close) return null;
|
|
60
|
+
|
|
61
|
+
return { start, end: bodyStart + close.index + close[0].length };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The additional key a schema block is known by, e.g. `reporting.active_users`. */
|
|
65
|
+
export function qualifiedBlockKey(name: string, schemaName: string | undefined): string {
|
|
66
|
+
return !schemaName || schemaName.toLowerCase() === 'dbo' ? name : `${schemaName}.${name}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* A likely block name for an introspected object.
|
|
71
|
+
*
|
|
72
|
+
* `db:pull` names a new block after the table or view, dropping the plural `s`
|
|
73
|
+
* so `users` becomes `User`. A word that already ends in `s` but is singular
|
|
74
|
+
* (`status`, `address`, `analysis`) must keep it: stripping the character turns
|
|
75
|
+
* it into `Statu`, which reads like a typo in every generated client. Existing
|
|
76
|
+
* blocks keep their own name, so this only decides what a newly pulled object is
|
|
77
|
+
* called.
|
|
78
|
+
*/
|
|
79
|
+
export function blockNameForObject(objectName: string): string {
|
|
80
|
+
// `active_users` becomes `ActiveUser`, not `Active_user`: the block name is a
|
|
81
|
+
// generated type in every client, so it follows the schema's own casing.
|
|
82
|
+
const words = objectName.split(/[^A-Za-z0-9]+/).filter(Boolean);
|
|
83
|
+
const capitalized = (words.length > 0 ? words : [objectName])
|
|
84
|
+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
85
|
+
.join('');
|
|
86
|
+
// `addresses`/`classes` lose `es`; `status`/`address`/`analysis` are singular
|
|
87
|
+
// and keep the `s`.
|
|
88
|
+
if (/sses$/i.test(capitalized)) return capitalized.slice(0, -2);
|
|
89
|
+
if (/(?:ss|us|is)$/i.test(capitalized)) return capitalized;
|
|
90
|
+
if (/s$/i.test(capitalized)) return capitalized.slice(0, -1);
|
|
91
|
+
return capitalized;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* A `view` block for a catalog view.
|
|
96
|
+
*
|
|
97
|
+
* `@@sql` gets the body, not the `CREATE VIEW` statement the catalog returns:
|
|
98
|
+
* push and migrate add the `CREATE VIEW` themselves, so storing the header would
|
|
99
|
+
* produce `CREATE OR REPLACE VIEW x AS CREATE VIEW ...`.
|
|
100
|
+
*/
|
|
101
|
+
export function buildViewBlock(input: {
|
|
102
|
+
modelName: string;
|
|
103
|
+
viewName: string;
|
|
104
|
+
schemaName: string;
|
|
105
|
+
columns: readonly PulledColumn[];
|
|
106
|
+
definition: string | null | undefined;
|
|
107
|
+
}): string | null {
|
|
108
|
+
const body = viewSqlBody(input.definition);
|
|
109
|
+
if (!body) return null;
|
|
110
|
+
|
|
111
|
+
const lines = [`view ${input.modelName} {`];
|
|
112
|
+
for (const col of input.columns) {
|
|
113
|
+
lines.push(` ${col.columnName} ${sqlServerTypeForColumn(col)}${col.isNullable ? '?' : ''}`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (input.viewName !== input.modelName.toLowerCase() + 's') {
|
|
117
|
+
lines.push('', ` @@map("${input.viewName}")`);
|
|
118
|
+
}
|
|
119
|
+
if (input.schemaName && input.schemaName.toLowerCase() !== 'dbo') {
|
|
120
|
+
lines.push(` @@schema("${input.schemaName}")`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
lines.push('', ' @@sql("""');
|
|
124
|
+
for (const line of body.split(/\r?\n/)) lines.push(` ${line}`);
|
|
125
|
+
lines.push(' """)', '}');
|
|
126
|
+
return lines.join('\n');
|
|
127
|
+
}
|
|
@@ -8,8 +8,17 @@
|
|
|
8
8
|
* The database is two functions rather than the adapter, which keeps this testable
|
|
9
9
|
* and keeps the driver out of the decision making.
|
|
10
10
|
*/
|
|
11
|
-
import { PushDialect } from './dialect';
|
|
12
|
-
import {
|
|
11
|
+
import { PushDialect, normalizeViewSql } from './dialect';
|
|
12
|
+
import {
|
|
13
|
+
PushIndex,
|
|
14
|
+
PushModel,
|
|
15
|
+
PushView,
|
|
16
|
+
directiveName,
|
|
17
|
+
qualifiedTableName,
|
|
18
|
+
qualifiedViewName,
|
|
19
|
+
safeIdentifierName,
|
|
20
|
+
} from './push-schema';
|
|
21
|
+
import { orderViewsByDependency } from './view-order';
|
|
13
22
|
|
|
14
23
|
/** The two things this needs from a database connection. */
|
|
15
24
|
export interface PushDatabase {
|
|
@@ -28,6 +37,8 @@ export interface ApplyResult {
|
|
|
28
37
|
columnsAdded: number;
|
|
29
38
|
indexesCreated: number;
|
|
30
39
|
uniqueConstraintsAdded: number;
|
|
40
|
+
/** How many views were created or replaced. */
|
|
41
|
+
viewsApplied: number;
|
|
31
42
|
}
|
|
32
43
|
|
|
33
44
|
/**
|
|
@@ -41,6 +52,7 @@ export async function applySchema(
|
|
|
41
52
|
models: readonly PushModel[],
|
|
42
53
|
dialect: PushDialect,
|
|
43
54
|
db: PushDatabase,
|
|
55
|
+
views: readonly PushView[] = [],
|
|
44
56
|
): Promise<ApplyResult> {
|
|
45
57
|
const result: ApplyResult = {
|
|
46
58
|
tablesCreated: 0,
|
|
@@ -48,6 +60,7 @@ export async function applySchema(
|
|
|
48
60
|
columnsAdded: 0,
|
|
49
61
|
indexesCreated: 0,
|
|
50
62
|
uniqueConstraintsAdded: 0,
|
|
63
|
+
viewsApplied: 0,
|
|
51
64
|
};
|
|
52
65
|
|
|
53
66
|
for (const model of models) {
|
|
@@ -89,7 +102,9 @@ export async function applySchema(
|
|
|
89
102
|
// A new `@unique` column needs its unique too, and on SQLite that cannot
|
|
90
103
|
// ride along with the ADD COLUMN — SQLite refuses to add a UNIQUE column.
|
|
91
104
|
if (field.isUnique && !field.isId && !dialect.supportsUniqueInlineOnAddColumn) {
|
|
92
|
-
|
|
105
|
+
// The same name `db:migrate` expects for this column's unique, so the
|
|
106
|
+
// next diff sees push's artifact instead of adding another one.
|
|
107
|
+
const indexName = field.uniqueName ?? `UQ_${safeName}_${field.name}`;
|
|
93
108
|
const quoted = dialect.quote(indexName);
|
|
94
109
|
if ((await db.query(dialect.uniqueConstraintExists(tableName, indexName))).length === 0) {
|
|
95
110
|
db.log(`Creating unique index ${quoted} on table ${sqlTableName}...`);
|
|
@@ -112,7 +127,6 @@ export async function applySchema(
|
|
|
112
127
|
db.log(`Creating unique index ${quoted} on table ${sqlTableName}...`);
|
|
113
128
|
await db.execute(dialect.createUniqueIndex(quoted, sqlTableName, quoteFields(entry.fields)));
|
|
114
129
|
result.indexesCreated += 1;
|
|
115
|
-
result.uniqueConstraintsAdded += 1;
|
|
116
130
|
}
|
|
117
131
|
result.uniqueConstraintsAdded += 1;
|
|
118
132
|
}
|
|
@@ -122,11 +136,54 @@ export async function applySchema(
|
|
|
122
136
|
if ((await db.query(dialect.indexExists(tableName, name))).length > 0) continue;
|
|
123
137
|
const quoted = dialect.quote(name);
|
|
124
138
|
db.log(`Creating index ${quoted} on table ${sqlTableName}...`);
|
|
125
|
-
await db.execute(
|
|
139
|
+
await db.execute(
|
|
140
|
+
dialect.createIndex(quoted, sqlTableName, quoteFields(directive.fields), {
|
|
141
|
+
includeFields: directive.includeFields ? quoteFields(directive.includeFields) : undefined,
|
|
142
|
+
filter: directive.filter,
|
|
143
|
+
options: directive.options,
|
|
144
|
+
}),
|
|
145
|
+
);
|
|
126
146
|
result.indexesCreated += 1;
|
|
127
147
|
}
|
|
128
148
|
}
|
|
129
149
|
|
|
150
|
+
// Views are applied after every table: a view may select from a table this same
|
|
151
|
+
// push created, and SQL Server validates the reference while compiling it.
|
|
152
|
+
// Within the views, a dependent view has to come after the one it reads.
|
|
153
|
+
const orderedViews = orderViewsByDependency(
|
|
154
|
+
views.map((view) => ({
|
|
155
|
+
view,
|
|
156
|
+
names: [qualifiedViewName(view), view.viewName],
|
|
157
|
+
sql: view.sql,
|
|
158
|
+
})),
|
|
159
|
+
);
|
|
160
|
+
if (orderedViews.cyclic) {
|
|
161
|
+
// The definitions cannot all exist at once; the database reports which one
|
|
162
|
+
// is missing, which is more useful than a generic ordering error here.
|
|
163
|
+
db.log('Warning: view definitions reference each other in a cycle; keeping schema order.');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
for (const { view } of orderedViews.ordered) {
|
|
167
|
+
const viewName = qualifiedViewName(view);
|
|
168
|
+
const sqlViewName = dialect.quoteTable(viewName);
|
|
169
|
+
db.log(`Processing view ${sqlViewName}...`);
|
|
170
|
+
|
|
171
|
+
const rows = await db.query(dialect.viewDefinition(viewName));
|
|
172
|
+
const existing = rows[0] as { definition?: string | null } | undefined;
|
|
173
|
+
if (existing && normalizeViewSql(existing.definition) === normalizeViewSql(view.sql)) {
|
|
174
|
+
// The catalog and the schema agree; recreating would just churn dependencies.
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (existing && !dialect.supportsCreateOrReplaceView) {
|
|
179
|
+
db.log(`Dropping view ${sqlViewName} before recreating it...`);
|
|
180
|
+
await db.execute(dialect.dropView(sqlViewName));
|
|
181
|
+
}
|
|
182
|
+
db.log(`Creating view ${sqlViewName}...`);
|
|
183
|
+
await db.execute(dialect.createView(sqlViewName, view.sql));
|
|
184
|
+
result.viewsApplied += 1;
|
|
185
|
+
}
|
|
186
|
+
|
|
130
187
|
return result;
|
|
131
188
|
}
|
|
132
189
|
|