@an5/orm 1.0.9 → 1.1.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/README.md +10 -3
- package/dist/config.d.ts +9 -1
- package/dist/config.js +13 -18
- package/dist/generator/src/api.d.ts +35 -0
- package/dist/generator/src/api.js +58 -0
- package/dist/generator/src/config.d.ts +162 -0
- package/dist/generator/src/config.js +354 -0
- package/dist/generator/src/dialect.d.ts +88 -0
- package/dist/generator/src/dialect.js +318 -0
- package/dist/generator/src/dotnet-generator.d.ts +8 -0
- package/dist/generator/src/dotnet-generator.js +282 -101
- package/dist/generator/src/field-types.d.ts +96 -0
- package/dist/generator/src/field-types.js +433 -0
- package/dist/generator/src/golang-generator.d.ts +36 -0
- package/dist/generator/src/golang-generator.js +1229 -0
- package/dist/generator/src/index.js +69 -36
- package/dist/generator/src/metadata-generator.js +2 -1
- package/dist/generator/src/parser.d.ts +28 -2
- package/dist/generator/src/parser.js +92 -90
- package/dist/generator/src/push-apply.d.ts +37 -0
- package/dist/generator/src/push-apply.js +109 -0
- package/dist/generator/src/push-schema.d.ts +93 -0
- package/dist/generator/src/push-schema.js +189 -0
- package/dist/generator/src/python-generator.d.ts +19 -1
- package/dist/generator/src/python-generator.js +147 -64
- package/dist/generator/src/rust-generator.d.ts +38 -0
- package/dist/generator/src/rust-generator.js +971 -0
- package/dist/generator/src/suggest.d.ts +17 -0
- package/dist/generator/src/suggest.js +45 -0
- package/dist/generator/src/type-kinds.d.ts +27 -0
- package/dist/generator/src/type-kinds.js +72 -0
- package/dist/generator/src/types.d.ts +33 -0
- package/dist/generator/src/types.js +39 -0
- package/dist/migration-core.d.ts +16 -1
- package/dist/migration-core.js +60 -21
- package/dist/orm.d.ts +147 -0
- package/dist/orm.js +1654 -0
- package/dist/provider-support.d.ts +27 -0
- package/dist/provider-support.js +50 -0
- package/generator/src/api.ts +45 -0
- package/generator/src/config.ts +496 -0
- package/generator/src/dialect.ts +415 -0
- package/generator/src/dotnet-generator.ts +277 -88
- package/generator/src/field-types.ts +473 -0
- package/generator/src/golang-generator.ts +89 -64
- package/generator/src/index.ts +71 -36
- package/generator/src/metadata-generator.ts +2 -2
- package/generator/src/parser.ts +122 -90
- package/generator/src/push-apply.ts +148 -0
- package/generator/src/push-schema.ts +250 -0
- package/generator/src/python-generator.ts +118 -62
- package/generator/src/rust-generator.ts +974 -0
- package/generator/src/suggest.ts +41 -0
- package/generator/src/type-kinds.ts +78 -0
- package/generator/src/types.ts +48 -0
- package/package.json +26 -6
package/generator/src/parser.ts
CHANGED
|
@@ -1,70 +1,61 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import { Model, Field, Relation } from './types';
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
'DECIMAL': 'number',
|
|
23
|
-
'NUMERIC': 'number',
|
|
24
|
-
'MONEY': 'number',
|
|
25
|
-
'SMALLMONEY': 'number',
|
|
26
|
-
// Boolean
|
|
27
|
-
'BIT': 'boolean',
|
|
28
|
-
// Date types
|
|
29
|
-
'DATETIME': 'Date',
|
|
30
|
-
'DATETIME2': 'Date',
|
|
31
|
-
'SMALLDATETIME': 'Date',
|
|
32
|
-
'DATE': 'Date',
|
|
33
|
-
'TIME': 'Date',
|
|
34
|
-
'DATETIMEOFFSET': 'Date',
|
|
35
|
-
// Binary types
|
|
36
|
-
'VARBINARY': 'Buffer',
|
|
37
|
-
'BINARY': 'Buffer',
|
|
38
|
-
'IMAGE': 'Buffer',
|
|
39
|
-
// Other
|
|
40
|
-
'UNIQUEIDENTIFIER': 'string',
|
|
41
|
-
'SQL_VARIANT': 'any',
|
|
42
|
-
'ROWVERSION': 'Buffer',
|
|
43
|
-
'HIERARCHYID': 'string',
|
|
44
|
-
'GEOGRAPHY': 'string',
|
|
45
|
-
'GEOMETRY': 'string',
|
|
46
|
-
'VECTOR': 'number[] | string',
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
// Parse base type from "NVARCHAR(255)" → "NVARCHAR"
|
|
50
|
-
function parseSqlType(raw: string): { base: string; params: string } {
|
|
51
|
-
const match = raw.match(/^(\w+)(?:\((.+)\))?$/);
|
|
52
|
-
if (!match) return { base: raw, params: '' };
|
|
53
|
-
return { base: match[1].toUpperCase(), params: match[2] || '' };
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_PROVIDER,
|
|
6
|
+
FieldTypeError,
|
|
7
|
+
FieldTypeIssue,
|
|
8
|
+
Provider,
|
|
9
|
+
readFieldLineHead,
|
|
10
|
+
resolveFieldType,
|
|
11
|
+
unknownFieldTypeMessage,
|
|
12
|
+
} from './field-types';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Maps a column type of a provider to its TypeScript type.
|
|
16
|
+
*
|
|
17
|
+
* `provider` defaults to SQL Server so existing callers keep their behaviour;
|
|
18
|
+
* the generator passes the provider derived from the connection string.
|
|
19
|
+
*/
|
|
20
|
+
export function sqlTypeToTs(sqlType: string, provider: Provider = DEFAULT_PROVIDER): string {
|
|
21
|
+
return resolveFieldType(sqlType, provider)?.ts ?? 'any';
|
|
54
22
|
}
|
|
55
23
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
24
|
+
/**
|
|
25
|
+
* A field whose type is in no table for this provider. Whether it is a relation
|
|
26
|
+
* or a typo is not known yet, so the decision waits until the whole schema has
|
|
27
|
+
* been read.
|
|
28
|
+
*/
|
|
29
|
+
interface PendingModelRef {
|
|
30
|
+
model: Model;
|
|
31
|
+
name: string;
|
|
32
|
+
/** The name as written in the schema, kept verbatim to compare with model names. */
|
|
33
|
+
type: string;
|
|
34
|
+
isArray: boolean;
|
|
35
|
+
isOptional: boolean;
|
|
36
|
+
/** The original line, needed to read `@relation`/`@description`. */
|
|
37
|
+
line: string;
|
|
60
38
|
}
|
|
61
39
|
|
|
62
40
|
export class SchemaParser {
|
|
63
41
|
private schemaText: string = '';
|
|
64
|
-
|
|
65
|
-
|
|
42
|
+
private issues: FieldTypeIssue[] = [];
|
|
43
|
+
private modelRefs: PendingModelRef[] = [];
|
|
44
|
+
private models: Model[] = [];
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @param schemaDir directory holding the `.an5` files
|
|
48
|
+
* @param provider target database; decides which field types are valid
|
|
49
|
+
*/
|
|
50
|
+
constructor(
|
|
51
|
+
private schemaDir: string,
|
|
52
|
+
private provider: Provider = DEFAULT_PROVIDER,
|
|
53
|
+
) {}
|
|
66
54
|
|
|
67
55
|
public async parse(): Promise<Model[]> {
|
|
56
|
+
this.issues = [];
|
|
57
|
+
this.modelRefs = [];
|
|
58
|
+
this.models = [];
|
|
68
59
|
this.loadSchema();
|
|
69
60
|
const lines = this.schemaText.split('\n');
|
|
70
61
|
const models: Model[] = [];
|
|
@@ -80,7 +71,12 @@ export class SchemaParser {
|
|
|
80
71
|
currentModel = {
|
|
81
72
|
name: modelName,
|
|
82
73
|
tableName: modelName.toLowerCase() + 's',
|
|
83
|
-
|
|
74
|
+
// SQL Server's default schema, and only SQL Server's. For SQLite or MySQL
|
|
75
|
+
// an unqualified name is what the connection resolves, and for PostgreSQL
|
|
76
|
+
// it follows `search_path` — the same rule `db:push` now uses, so the
|
|
77
|
+
// generated client and the pushed table cannot drift apart.
|
|
78
|
+
schemaName: this.provider === 'mssql' ? 'dbo' : '',
|
|
79
|
+
provider: this.provider,
|
|
84
80
|
fields: [],
|
|
85
81
|
relations: []
|
|
86
82
|
};
|
|
@@ -98,6 +94,15 @@ export class SchemaParser {
|
|
|
98
94
|
}
|
|
99
95
|
}
|
|
100
96
|
|
|
97
|
+
this.models = models;
|
|
98
|
+
this.resolveModelRefs();
|
|
99
|
+
|
|
100
|
+
// Every bad type is reported at once, so one pass over the schema is enough
|
|
101
|
+
// instead of re-running the generator after every line.
|
|
102
|
+
if (this.issues.length > 0) {
|
|
103
|
+
throw new FieldTypeError(this.provider, this.issues);
|
|
104
|
+
}
|
|
105
|
+
|
|
101
106
|
this.postProcessRelations(models);
|
|
102
107
|
return models;
|
|
103
108
|
}
|
|
@@ -120,8 +125,12 @@ export class SchemaParser {
|
|
|
120
125
|
return;
|
|
121
126
|
}
|
|
122
127
|
if (line.startsWith('@@schema')) {
|
|
123
|
-
|
|
124
|
-
|
|
128
|
+
// `(.*)` rather than `(.+)`: `@@schema("")` is how a model says "no
|
|
129
|
+
// schema", needed for databases that have no schema concept. With `(.+)`
|
|
130
|
+
// the empty string does not match, the directive is silently ignored and
|
|
131
|
+
// the model keeps `dbo`.
|
|
132
|
+
const schemaMatch = line.match(/@@schema\("(.*)"\)/);
|
|
133
|
+
if (schemaMatch) model.schemaName = schemaMatch[1].trim();
|
|
125
134
|
return;
|
|
126
135
|
}
|
|
127
136
|
if (line.startsWith('@@unique')) {
|
|
@@ -140,48 +149,71 @@ export class SchemaParser {
|
|
|
140
149
|
}
|
|
141
150
|
if (line.startsWith('@@')) return;
|
|
142
151
|
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
let fieldType = parts[1];
|
|
146
|
-
if (!fieldName || !fieldType) return;
|
|
147
|
-
|
|
148
|
-
const isArray = fieldType.endsWith('[]');
|
|
149
|
-
const isOptional = fieldType.endsWith('?');
|
|
150
|
-
const cleanType = fieldType.replace('[]', '').replace('?', '');
|
|
152
|
+
const head = readFieldLineHead(line);
|
|
153
|
+
if (!head) return;
|
|
151
154
|
|
|
152
|
-
//
|
|
153
|
-
|
|
155
|
+
// No table entry for this provider. This used to fall through to
|
|
156
|
+
// `tsType = 'any'` (or, worse, become a relation to a model that does not
|
|
157
|
+
// exist) and the client was still generated; the failure only surfaced
|
|
158
|
+
// later at the database layer, in a message unrelated to the schema.
|
|
159
|
+
const resolved = resolveFieldType(head.type, this.provider);
|
|
160
|
+
if (!resolved) {
|
|
161
|
+
this.modelRefs.push({ model, name: head.name, type: head.type, isArray: head.isArray, isOptional: head.isOptional, line });
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
154
164
|
|
|
155
|
-
|
|
156
|
-
|
|
165
|
+
const hasDefault = line.includes('@default') || line.includes('@updatedAt') || line.includes('@id');
|
|
166
|
+
const isId = line.includes('@id');
|
|
167
|
+
let description: string | undefined;
|
|
168
|
+
const descMatch = line.match(/@description\("(.+)"\)/);
|
|
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 });
|
|
171
|
+
}
|
|
157
172
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
173
|
+
/**
|
|
174
|
+
* Settles the fields that matched no type: a name matching a model in the
|
|
175
|
+
* schema is a relation, anything else is a bad type — reported with the
|
|
176
|
+
* provider name and a suggestion.
|
|
177
|
+
*
|
|
178
|
+
* The decision has to wait for the whole schema, because a relation may point
|
|
179
|
+
* at a model declared later. Every upper-case token used to count as a
|
|
180
|
+
* relation, so `INTEGER` written for SQL Server became a relation to a model
|
|
181
|
+
* named `INTEGER` and nothing ever complained.
|
|
182
|
+
*/
|
|
183
|
+
private resolveModelRefs() {
|
|
184
|
+
const modelNames = this.models.map((model) => model.name);
|
|
185
|
+
|
|
186
|
+
for (const ref of this.modelRefs) {
|
|
187
|
+
if (!modelNames.includes(ref.type)) {
|
|
188
|
+
this.issues.push({
|
|
189
|
+
path: `${ref.model.name}.${ref.name}`,
|
|
190
|
+
message: unknownFieldTypeMessage(ref.type, this.provider, modelNames),
|
|
191
|
+
});
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
166
194
|
|
|
167
|
-
if (isRelation) {
|
|
168
195
|
let foreignKey = '', localKey = '', relationName = '';
|
|
169
|
-
const nameMatch = line.match(/@relation\("(\w+)"/);
|
|
196
|
+
const nameMatch = ref.line.match(/@relation\("(\w+)"/);
|
|
170
197
|
if (nameMatch) relationName = nameMatch[1];
|
|
171
|
-
const relationMatch = line.match(/@relation\((?:.*fields:\s*\[(\w+)\],)?\s*(?:.*references:\s*\[(\w+)\],?)?.*\)/);
|
|
198
|
+
const relationMatch = ref.line.match(/@relation\((?:.*fields:\s*\[(\w+)\],)?\s*(?:.*references:\s*\[(\w+)\],?)?.*\)/);
|
|
172
199
|
if (relationMatch) {
|
|
173
200
|
foreignKey = relationMatch[1] || '';
|
|
174
201
|
localKey = relationMatch[2] || '';
|
|
175
202
|
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
203
|
+
const description = ref.line.match(/@description\("(.+)"\)/)?.[1];
|
|
204
|
+
ref.model.relations.push({
|
|
205
|
+
name: ref.name,
|
|
206
|
+
type: ref.type,
|
|
207
|
+
isArray: ref.isArray,
|
|
208
|
+
isOptional: ref.isOptional,
|
|
209
|
+
foreignKey,
|
|
210
|
+
localKey,
|
|
211
|
+
relationName,
|
|
212
|
+
...(description ? { description } : {}),
|
|
213
|
+
});
|
|
184
214
|
}
|
|
215
|
+
|
|
216
|
+
this.modelRefs = [];
|
|
185
217
|
}
|
|
186
218
|
|
|
187
219
|
private postProcessRelations(models: Model[]) {
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Applying a schema to a database: what `db:push` does once the schema is read.
|
|
3
|
+
*
|
|
4
|
+
* Kept out of `push.ts` so the sequence can be run against a real database in a
|
|
5
|
+
* test — the CLI needs a live server, and a test that re-implements the sequence
|
|
6
|
+
* instead of calling it proves only that the copy works.
|
|
7
|
+
*
|
|
8
|
+
* The database is two functions rather than the adapter, which keeps this testable
|
|
9
|
+
* and keeps the driver out of the decision making.
|
|
10
|
+
*/
|
|
11
|
+
import { PushDialect } from './dialect';
|
|
12
|
+
import { PushIndex, PushModel, directiveName, qualifiedTableName, safeIdentifierName } from './push-schema';
|
|
13
|
+
|
|
14
|
+
/** The two things this needs from a database connection. */
|
|
15
|
+
export interface PushDatabase {
|
|
16
|
+
/** Runs a query and returns its rows; existence checks are `length > 0`. */
|
|
17
|
+
query(sql: string): Promise<readonly unknown[]>;
|
|
18
|
+
/** Runs one statement. */
|
|
19
|
+
execute(sql: string): Promise<void>;
|
|
20
|
+
/** Progress, so the CLI prints what it is doing. */
|
|
21
|
+
log(message: string): void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ApplyResult {
|
|
25
|
+
/** How many tables were created, and how many altered. */
|
|
26
|
+
tablesCreated: number;
|
|
27
|
+
tablesAltered: number;
|
|
28
|
+
columnsAdded: number;
|
|
29
|
+
indexesCreated: number;
|
|
30
|
+
uniqueConstraintsAdded: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Creates whatever the schema has and the database does not.
|
|
35
|
+
*
|
|
36
|
+
* The order is deliberate: a table first, then missing columns, then unique
|
|
37
|
+
* constraints and indexes — a unique over a column that does not exist yet fails.
|
|
38
|
+
* Everything is checked before it is created, so running it twice changes nothing.
|
|
39
|
+
*/
|
|
40
|
+
export async function applySchema(
|
|
41
|
+
models: readonly PushModel[],
|
|
42
|
+
dialect: PushDialect,
|
|
43
|
+
db: PushDatabase,
|
|
44
|
+
): Promise<ApplyResult> {
|
|
45
|
+
const result: ApplyResult = {
|
|
46
|
+
tablesCreated: 0,
|
|
47
|
+
tablesAltered: 0,
|
|
48
|
+
columnsAdded: 0,
|
|
49
|
+
indexesCreated: 0,
|
|
50
|
+
uniqueConstraintsAdded: 0,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
for (const model of models) {
|
|
54
|
+
const tableName = qualifiedTableName(model);
|
|
55
|
+
const sqlTableName = dialect.quoteTable(tableName);
|
|
56
|
+
const quoteFields = (fields: readonly string[]) => fields.map((field) => dialect.quote(field));
|
|
57
|
+
db.log(`Processing table ${sqlTableName}...`);
|
|
58
|
+
|
|
59
|
+
const safeName = safeIdentifierName(model.tableName);
|
|
60
|
+
// SQLite has no named unique constraints — a UNIQUE there becomes an index the
|
|
61
|
+
// engine names itself — so the unique index carries our name instead, and that
|
|
62
|
+
// name is what the existence check can look for.
|
|
63
|
+
const compoundUniques = model.compoundUniques.map((directive, idx) => ({
|
|
64
|
+
name: directiveName(directive, model.tableName, 'compound', idx),
|
|
65
|
+
fields: directive.fields,
|
|
66
|
+
directive,
|
|
67
|
+
}));
|
|
68
|
+
|
|
69
|
+
const tableExists = (await db.query(dialect.tableExists(tableName))).length > 0;
|
|
70
|
+
|
|
71
|
+
if (!tableExists) {
|
|
72
|
+
db.log(`Creating table ${sqlTableName}...`);
|
|
73
|
+
const columnDefs = model.fields.map((field) => dialect.columnDefinition(field));
|
|
74
|
+
for (const entry of compoundUniques) {
|
|
75
|
+
if (!dialect.supportsNamedUniqueConstraint) continue;
|
|
76
|
+
const fieldsStr = quoteFields(entry.fields).join(', ');
|
|
77
|
+
columnDefs.push(`CONSTRAINT ${dialect.quote(entry.name)} UNIQUE (${fieldsStr})`);
|
|
78
|
+
}
|
|
79
|
+
await db.execute(dialect.createTable(sqlTableName, columnDefs));
|
|
80
|
+
db.log(`✅ Table ${sqlTableName} created.`);
|
|
81
|
+
result.tablesCreated += 1;
|
|
82
|
+
} else {
|
|
83
|
+
for (const field of model.fields) {
|
|
84
|
+
const columnExists = (await db.query(dialect.columnExists(tableName, field.name))).length > 0;
|
|
85
|
+
if (columnExists) continue;
|
|
86
|
+
db.log(`Adding column ${dialect.quote(field.name)} to table ${sqlTableName}...`);
|
|
87
|
+
await db.execute(dialect.addColumn(sqlTableName, field));
|
|
88
|
+
result.columnsAdded += 1;
|
|
89
|
+
// A new `@unique` column needs its unique too, and on SQLite that cannot
|
|
90
|
+
// ride along with the ADD COLUMN — SQLite refuses to add a UNIQUE column.
|
|
91
|
+
if (field.isUnique && !field.isId && !dialect.supportsUniqueInlineOnAddColumn) {
|
|
92
|
+
const indexName = `UQ_${safeName}_${field.name}`;
|
|
93
|
+
const quoted = dialect.quote(indexName);
|
|
94
|
+
if ((await db.query(dialect.uniqueConstraintExists(tableName, indexName))).length === 0) {
|
|
95
|
+
db.log(`Creating unique index ${quoted} on table ${sqlTableName}...`);
|
|
96
|
+
await db.execute(dialect.createUniqueIndex(quoted, sqlTableName, quoteFields([field.name])));
|
|
97
|
+
result.uniqueConstraintsAdded += 1;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
result.tablesAltered += 1;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Unique constraints that are not part of the column definitions above.
|
|
105
|
+
for (const entry of compoundUniques) {
|
|
106
|
+
if (await hasUniqueConstraint(db, dialect, tableName, entry.name)) continue;
|
|
107
|
+
const quoted = dialect.quote(entry.name);
|
|
108
|
+
if (dialect.supportsNamedUniqueConstraint) {
|
|
109
|
+
db.log(`Adding compound unique constraint ${quoted} to table ${sqlTableName}...`);
|
|
110
|
+
await db.execute(dialect.addUniqueConstraint(quoted, sqlTableName, quoteFields(entry.fields)));
|
|
111
|
+
} else {
|
|
112
|
+
db.log(`Creating unique index ${quoted} on table ${sqlTableName}...`);
|
|
113
|
+
await db.execute(dialect.createUniqueIndex(quoted, sqlTableName, quoteFields(entry.fields)));
|
|
114
|
+
result.indexesCreated += 1;
|
|
115
|
+
result.uniqueConstraintsAdded += 1;
|
|
116
|
+
}
|
|
117
|
+
result.uniqueConstraintsAdded += 1;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
for (const [idx, directive] of model.indexes.entries()) {
|
|
121
|
+
const name = directiveName(directive, model.tableName, 'index', idx);
|
|
122
|
+
if ((await db.query(dialect.indexExists(tableName, name))).length > 0) continue;
|
|
123
|
+
const quoted = dialect.quote(name);
|
|
124
|
+
db.log(`Creating index ${quoted} on table ${sqlTableName}...`);
|
|
125
|
+
await db.execute(dialect.createIndex(quoted, sqlTableName, quoteFields(directive.fields)));
|
|
126
|
+
result.indexesCreated += 1;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return result;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Whether the unique is already there.
|
|
135
|
+
*
|
|
136
|
+
* A compound unique created inside `CREATE TABLE` has no separate object to look
|
|
137
|
+
* for on the providers that keep them, so it is treated as present once the table
|
|
138
|
+
* exists; the per-column uniques inside a column definition are handled with the
|
|
139
|
+
* column above.
|
|
140
|
+
*/
|
|
141
|
+
async function hasUniqueConstraint(
|
|
142
|
+
db: PushDatabase,
|
|
143
|
+
dialect: PushDialect,
|
|
144
|
+
tableName: string,
|
|
145
|
+
name: string,
|
|
146
|
+
): Promise<boolean> {
|
|
147
|
+
return (await db.query(dialect.uniqueConstraintExists(tableName, name))).length > 0;
|
|
148
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading `.an5` files for `db:push`.
|
|
3
|
+
*
|
|
4
|
+
* `SchemaParser` and `parseSchemaText` cover the generators and `db:migrate`;
|
|
5
|
+
* this is the third reader, and it exists because push needs a shape of its own:
|
|
6
|
+
* raw `@default` expressions rather than rendered SQL, and the schema a table
|
|
7
|
+
* lands in. It is kept here rather than inline in `push.ts` so the mapping from
|
|
8
|
+
* schema text to a column can be tested without a database.
|
|
9
|
+
*/
|
|
10
|
+
import {
|
|
11
|
+
FieldTypeIssue,
|
|
12
|
+
Provider,
|
|
13
|
+
readFieldLineHead,
|
|
14
|
+
resolveFieldType,
|
|
15
|
+
unknownFieldTypeMessage,
|
|
16
|
+
} from './field-types';
|
|
17
|
+
|
|
18
|
+
/** A column, as push needs it: the type as written plus what the dialect renders. */
|
|
19
|
+
export interface PushField {
|
|
20
|
+
name: string;
|
|
21
|
+
sqlType: string;
|
|
22
|
+
isOptional: boolean;
|
|
23
|
+
isId: boolean;
|
|
24
|
+
isUnique: boolean;
|
|
25
|
+
/** The `@default(...)` expression, not SQL: only the dialect knows the spelling. */
|
|
26
|
+
defaultExpr?: string | undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface PushModel {
|
|
30
|
+
name: string;
|
|
31
|
+
tableName: string;
|
|
32
|
+
/** The schema to create the table in; `''` when the provider has none. */
|
|
33
|
+
schema: string;
|
|
34
|
+
/** Set only when the schema file named one, so a wrong one can be reported. */
|
|
35
|
+
declaredSchema?: string | undefined;
|
|
36
|
+
fields: PushField[];
|
|
37
|
+
/** `@@unique([a, b])`, as field lists. */
|
|
38
|
+
compoundUniques: PushIndex[];
|
|
39
|
+
/** `@@index([a, b])`, as field lists. */
|
|
40
|
+
indexes: PushIndex[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The schema a model lands in when the schema file does not say: none, for every
|
|
45
|
+
* provider, so the name goes wherever the connection resolves.
|
|
46
|
+
*
|
|
47
|
+
* The parser used to default this to `dbo` and push followed it, which failed two
|
|
48
|
+
* ways: SQLite said "unknown database [dbo]", and a SQL Server login whose default
|
|
49
|
+
* schema was something else had push look for `[dbo].[widgets]`, miss the table it
|
|
50
|
+
* had created, and then fail with "there is already an object named ...".
|
|
51
|
+
*/
|
|
52
|
+
export function defaultSchemaFor(_provider: Provider): string {
|
|
53
|
+
return '';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* One `@@unique` or `@@index`, with the options `db:migrate` already understands.
|
|
58
|
+
*
|
|
59
|
+
* `name` matters as much as the fields: `map:` is how a schema asks for a specific
|
|
60
|
+
* artifact name, and if push invented its own the two commands would create two
|
|
61
|
+
* different constraints for the same directive — and the next migration would keep
|
|
62
|
+
* trying to add the one push never made.
|
|
63
|
+
*/
|
|
64
|
+
export interface PushIndex {
|
|
65
|
+
fields: string[];
|
|
66
|
+
name?: string | undefined;
|
|
67
|
+
includeFields?: string[] | undefined;
|
|
68
|
+
filter?: string | undefined;
|
|
69
|
+
options?: string | undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface PushSchema {
|
|
73
|
+
models: PushModel[];
|
|
74
|
+
/** Field types the provider does not have; empty when the schema is valid. */
|
|
75
|
+
issues: FieldTypeIssue[];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The field list of `@@unique([a, b], …)`, plus whatever options follow it. */
|
|
79
|
+
const DIRECTIVE = /^@@(?:unique|index)\(\s*\[([\w\s,]+)\]([^)]*)\)/;
|
|
80
|
+
|
|
81
|
+
function parseDirective(line: string, fields: string): PushIndex {
|
|
82
|
+
const options = line.match(DIRECTIVE)?.[2] ?? '';
|
|
83
|
+
const name = options.match(/\bmap\s*:\s*"([^"]+)"/)?.[1];
|
|
84
|
+
const include = options.match(/\binclude\s*:\s*\[([\w,\s]+)\]/)?.[1];
|
|
85
|
+
const filter = options.match(/\bfilter\s*:\s*"([^"]+)"/)?.[1];
|
|
86
|
+
const opts = options.match(/\boptions\s*:\s*"([^"]+)"/)?.[1];
|
|
87
|
+
return {
|
|
88
|
+
fields: fields.split(',').map((field) => field.trim()).filter(Boolean),
|
|
89
|
+
...(name ? { name } : {}),
|
|
90
|
+
...(include ? { includeFields: include.split(',').map((field) => field.trim()).filter(Boolean) } : {}),
|
|
91
|
+
...(filter ? { filter } : {}),
|
|
92
|
+
...(opts ? { options: opts } : {}),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The name a `@@unique` or `@@index` artifact gets.
|
|
98
|
+
*
|
|
99
|
+
* `map:` wins; otherwise the name is derived from the table and fields, which is what
|
|
100
|
+
* both commands have always done for an unnamed directive.
|
|
101
|
+
*/
|
|
102
|
+
export function directiveName(
|
|
103
|
+
directive: PushIndex,
|
|
104
|
+
table: string,
|
|
105
|
+
kind: 'compound' | 'index',
|
|
106
|
+
position: number,
|
|
107
|
+
): string {
|
|
108
|
+
if (directive.name) return safeIdentifierName(directive.name);
|
|
109
|
+
const base = safeIdentifierName(table);
|
|
110
|
+
return kind === 'compound'
|
|
111
|
+
? `UQ_${base}_compound_${position}`
|
|
112
|
+
: `IX_${base}_${directive.fields.join('_')}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Reads schema text for push, validating every field type against the provider.
|
|
117
|
+
*
|
|
118
|
+
* All bad types come back in `issues` rather than as an exception, so the caller
|
|
119
|
+
* can print them as a group. A field whose type matches no table is only a
|
|
120
|
+
* mistake once the whole schema has been read: it may be a relation to a model
|
|
121
|
+
* declared further down.
|
|
122
|
+
*/
|
|
123
|
+
export function parsePushSchema(text: string, provider: Provider): PushSchema {
|
|
124
|
+
const models: PushModel[] = [];
|
|
125
|
+
const issues: FieldTypeIssue[] = [];
|
|
126
|
+
const modelRefs: Array<{ model: string; field: string; type: string }> = [];
|
|
127
|
+
let current: PushModel | null = null;
|
|
128
|
+
|
|
129
|
+
for (const raw of text.split('\n')) {
|
|
130
|
+
const line = raw.trim();
|
|
131
|
+
if (!line || line.startsWith('//')) continue;
|
|
132
|
+
|
|
133
|
+
const header = line.match(/^model\s+(\w+)\s*\{/);
|
|
134
|
+
if (header) {
|
|
135
|
+
current = {
|
|
136
|
+
name: header[1],
|
|
137
|
+
tableName: header[1].toLowerCase() + 's',
|
|
138
|
+
schema: defaultSchemaFor(provider),
|
|
139
|
+
fields: [],
|
|
140
|
+
compoundUniques: [],
|
|
141
|
+
indexes: [],
|
|
142
|
+
};
|
|
143
|
+
models.push(current);
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (line === '}') {
|
|
148
|
+
current = null;
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (!current) continue;
|
|
152
|
+
|
|
153
|
+
if (line.startsWith('@@map')) {
|
|
154
|
+
const match = line.match(/@@map\("(.+)"\)/);
|
|
155
|
+
if (match) current.tableName = match[1];
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (line.startsWith('@@schema')) {
|
|
159
|
+
// `(.*)` rather than `(.+)`, because `@@schema("")` is how a model says "no
|
|
160
|
+
// schema" and has to survive as an empty string rather than being dropped as
|
|
161
|
+
// no match — which is how SQLite and MySQL schemas are written.
|
|
162
|
+
const match = line.match(/@@schema\("(.*)"\)/);
|
|
163
|
+
if (match) {
|
|
164
|
+
current.schema = match[1].trim();
|
|
165
|
+
current.declaredSchema = current.schema;
|
|
166
|
+
}
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
if (line.startsWith('@@unique') || line.startsWith('@@index')) {
|
|
170
|
+
const match = line.match(DIRECTIVE);
|
|
171
|
+
if (match) {
|
|
172
|
+
const definition = parseDirective(line, match[1] ?? '');
|
|
173
|
+
if (line.startsWith('@@unique')) current.compoundUniques.push(definition);
|
|
174
|
+
else current.indexes.push(definition);
|
|
175
|
+
}
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (line.startsWith('@@')) continue;
|
|
179
|
+
|
|
180
|
+
const head = readFieldLineHead(line);
|
|
181
|
+
if (!head) continue;
|
|
182
|
+
|
|
183
|
+
if (!resolveFieldType(head.type, provider)) {
|
|
184
|
+
modelRefs.push({ model: current.name, field: head.name, type: head.type });
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// The type is kept as written (upper-cased) because that is what the database
|
|
189
|
+
// is told; which types exist is what `resolveFieldType` just checked.
|
|
190
|
+
current.fields.push({
|
|
191
|
+
name: head.name,
|
|
192
|
+
sqlType: head.type.toUpperCase(),
|
|
193
|
+
isOptional: head.isOptional,
|
|
194
|
+
isId: line.includes('@id'),
|
|
195
|
+
isUnique: line.includes('@unique'),
|
|
196
|
+
// The expression rather than SQL: `uuid()` and `autoincrement()` are spelled
|
|
197
|
+
// differently per provider, and only the dialect knows how. `@updatedAt` is
|
|
198
|
+
// `now()` under another name.
|
|
199
|
+
defaultExpr: line.match(/@default\((.*)\)/)?.[1]
|
|
200
|
+
?? (line.includes('@updatedAt') ? 'now()' : undefined),
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// A declared `dbo` on a provider without schemas would push to a database that
|
|
205
|
+
// does not exist there; SQLite answers "unknown database [dbo]", which says
|
|
206
|
+
// nothing about the schema file. Only an explicit declaration is worth reporting:
|
|
207
|
+
// an absent one now means "whatever the connection resolves", on every provider.
|
|
208
|
+
for (const model of models) {
|
|
209
|
+
if (model.declaredSchema === 'dbo' && provider !== 'mssql') {
|
|
210
|
+
issues.push({
|
|
211
|
+
path: model.name,
|
|
212
|
+
message:
|
|
213
|
+
`schema "dbo" does not exist in ${provider}; remove the @@schema directive or set it ` +
|
|
214
|
+
'to the schema this database uses',
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const modelNames = models.map((model) => model.name);
|
|
220
|
+
for (const ref of modelRefs) {
|
|
221
|
+
if (!modelNames.includes(ref.type)) {
|
|
222
|
+
issues.push({
|
|
223
|
+
path: `${ref.model}.${ref.field}`,
|
|
224
|
+
message: unknownFieldTypeMessage(ref.type, provider, modelNames),
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return { models, issues };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* The table name to create, `schema.table` unless the model cleared the schema.
|
|
234
|
+
*
|
|
235
|
+
* An empty schema has to stay empty: `[].[widgets]` is not valid SQL, which is the
|
|
236
|
+
* whole reason `@@schema("")` exists.
|
|
237
|
+
*/
|
|
238
|
+
/**
|
|
239
|
+
* An identifier for a generated artifact name: letters, digits and underscores only.
|
|
240
|
+
*
|
|
241
|
+
* The name goes into DDL as an identifier, so anything else is replaced rather than
|
|
242
|
+
* quoted — `@@map("catalog entries")` must not produce a name the provider rejects.
|
|
243
|
+
*/
|
|
244
|
+
export function safeIdentifierName(raw: string): string {
|
|
245
|
+
return raw.replace(/[^A-Za-z0-9_]/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, '');
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function qualifiedTableName(model: PushModel): string {
|
|
249
|
+
return model.schema ? `${model.schema}.${model.tableName}` : model.tableName;
|
|
250
|
+
}
|