ormbridge 0.0.0-stage → 0.3.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/LICENSE +21 -0
- package/README.md +459 -2
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +20 -0
- package/dist/cli.js.map +1 -0
- package/dist/cliRunner.d.ts +20 -0
- package/dist/cliRunner.js +363 -0
- package/dist/cliRunner.js.map +1 -0
- package/dist/config.d.ts +44 -0
- package/dist/config.js +220 -0
- package/dist/config.js.map +1 -0
- package/dist/convert.d.ts +20 -0
- package/dist/convert.js +61 -0
- package/dist/convert.js.map +1 -0
- package/dist/emitters/django.d.ts +4 -0
- package/dist/emitters/django.js +501 -0
- package/dist/emitters/django.js.map +1 -0
- package/dist/emitters/doctrine.d.ts +20 -0
- package/dist/emitters/doctrine.js +1134 -0
- package/dist/emitters/doctrine.js.map +1 -0
- package/dist/emitters/gorm.d.ts +38 -0
- package/dist/emitters/gorm.js +1507 -0
- package/dist/emitters/gorm.js.map +1 -0
- package/dist/emitters/graphene.d.ts +4 -0
- package/dist/emitters/graphene.js +431 -0
- package/dist/emitters/graphene.js.map +1 -0
- package/dist/emitters/laravel.d.ts +33 -0
- package/dist/emitters/laravel.js +1695 -0
- package/dist/emitters/laravel.js.map +1 -0
- package/dist/emitters/prisma.d.ts +21 -0
- package/dist/emitters/prisma.js +1058 -0
- package/dist/emitters/prisma.js.map +1 -0
- package/dist/emitters/typeorm.d.ts +9 -0
- package/dist/emitters/typeorm.js +838 -0
- package/dist/emitters/typeorm.js.map +1 -0
- package/dist/emitters/typescriptInterfaces.d.ts +15 -0
- package/dist/emitters/typescriptInterfaces.js +350 -0
- package/dist/emitters/typescriptInterfaces.js.map +1 -0
- package/dist/formats.d.ts +82 -0
- package/dist/formats.js +238 -0
- package/dist/formats.js.map +1 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/io.d.ts +48 -0
- package/dist/io.js +343 -0
- package/dist/io.js.map +1 -0
- package/dist/ir.d.ts +197 -0
- package/dist/ir.js +10 -0
- package/dist/ir.js.map +1 -0
- package/dist/naming.d.ts +11 -0
- package/dist/naming.js +88 -0
- package/dist/naming.js.map +1 -0
- package/dist/parsers/django.d.ts +14 -0
- package/dist/parsers/django.js +1161 -0
- package/dist/parsers/django.js.map +1 -0
- package/dist/parsers/doctrine.d.ts +12 -0
- package/dist/parsers/doctrine.js +1984 -0
- package/dist/parsers/doctrine.js.map +1 -0
- package/dist/parsers/goSyntax.d.ts +105 -0
- package/dist/parsers/goSyntax.js +435 -0
- package/dist/parsers/goSyntax.js.map +1 -0
- package/dist/parsers/gorm.d.ts +16 -0
- package/dist/parsers/gorm.js +1857 -0
- package/dist/parsers/gorm.js.map +1 -0
- package/dist/parsers/laravel.d.ts +17 -0
- package/dist/parsers/laravel.js +2824 -0
- package/dist/parsers/laravel.js.map +1 -0
- package/dist/parsers/phpSyntax.d.ts +124 -0
- package/dist/parsers/phpSyntax.js +429 -0
- package/dist/parsers/phpSyntax.js.map +1 -0
- package/dist/parsers/prisma.d.ts +12 -0
- package/dist/parsers/prisma.js +964 -0
- package/dist/parsers/prisma.js.map +1 -0
- package/dist/parsers/pythonSyntax.d.ts +38 -0
- package/dist/parsers/pythonSyntax.js +120 -0
- package/dist/parsers/pythonSyntax.js.map +1 -0
- package/dist/parsers/typeorm.d.ts +12 -0
- package/dist/parsers/typeorm.js +1614 -0
- package/dist/parsers/typeorm.js.map +1 -0
- package/dist/parsers/typescriptSyntax.d.ts +57 -0
- package/dist/parsers/typescriptSyntax.js +200 -0
- package/dist/parsers/typescriptSyntax.js.map +1 -0
- package/dist/prismaOnlyConstructs.d.ts +7 -0
- package/dist/prismaOnlyConstructs.js +76 -0
- package/dist/prismaOnlyConstructs.js.map +1 -0
- package/dist/result.d.ts +20 -0
- package/dist/result.js +18 -0
- package/dist/result.js.map +1 -0
- package/dist/transforms.d.ts +13 -0
- package/dist/transforms.js +142 -0
- package/dist/transforms.js.map +1 -0
- package/package.json +83 -4
|
@@ -0,0 +1,1695 @@
|
|
|
1
|
+
import { prismaOnlyWarnings } from '../prismaOnlyConstructs.js';
|
|
2
|
+
import { findModel } from '../ir.js';
|
|
3
|
+
import { toCamelCase, toPascalCase, toSnakeCase } from '../naming.js';
|
|
4
|
+
import { isValidPhpNamespace } from './doctrine.js';
|
|
5
|
+
export const DEFAULT_LARAVEL_NAMESPACE = 'App\\Models';
|
|
6
|
+
const IDENTIFIER_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
7
|
+
const MAX_LINE_WIDTH = 100;
|
|
8
|
+
const INDENT = ' ';
|
|
9
|
+
const MIGRATION_DIRECTORY = 'database/migrations';
|
|
10
|
+
const MIGRATION_DATE_PREFIX = '2024_01_01';
|
|
11
|
+
const RESERVED_CLASS_NAMES = new Set([
|
|
12
|
+
'abstract and array as break callable case catch',
|
|
13
|
+
'class clone const continue declare default do echo',
|
|
14
|
+
'else elseif empty enddeclare endfor endforeach endif endswitch',
|
|
15
|
+
'endwhile enum eval exit extends final finally fn',
|
|
16
|
+
'for foreach function global goto if implements include',
|
|
17
|
+
'instanceof insteadof interface isset list match namespace new',
|
|
18
|
+
'or print private protected public readonly require return',
|
|
19
|
+
'static switch throw trait try unset use var',
|
|
20
|
+
'while xor yield bool false float int iterable',
|
|
21
|
+
'mixed never null object parent self string true',
|
|
22
|
+
'void model softdeletes hasuuids hasulids belongsto belongstomany',
|
|
23
|
+
'hasmany hasone collection',
|
|
24
|
+
]
|
|
25
|
+
.join(' ')
|
|
26
|
+
.split(' '));
|
|
27
|
+
/** Names of Eloquent methods that a relationship method must not shadow (lower case). */
|
|
28
|
+
const RESERVED_METHOD_NAMES = new Set([
|
|
29
|
+
'save delete update fill refresh fresh touch push replicate query create',
|
|
30
|
+
'find all get first newquery getkey getattribute setattribute is isnot',
|
|
31
|
+
'with load toarray tojson booted boot casts trashed restore forcedelete',
|
|
32
|
+
'uniqueids newuniqueid where getconnection gettable getkeyname fillable',
|
|
33
|
+
'guarded hidden visible appends dates destroy upsert increment decrement',
|
|
34
|
+
'getattributes setattributes getrelations setrelation relation unsetrelation',
|
|
35
|
+
'qualifycolumn newinstance newcollection replicate usesuniqueids',
|
|
36
|
+
]
|
|
37
|
+
.join(' ')
|
|
38
|
+
.split(' '));
|
|
39
|
+
/** Words whose plural Laravel's inflector keeps unchanged. */
|
|
40
|
+
const UNCOUNTABLE_WORDS = new Set([
|
|
41
|
+
'equipment',
|
|
42
|
+
'information',
|
|
43
|
+
'rice',
|
|
44
|
+
'money',
|
|
45
|
+
'species',
|
|
46
|
+
'series',
|
|
47
|
+
'fish',
|
|
48
|
+
'sheep',
|
|
49
|
+
'news',
|
|
50
|
+
'data',
|
|
51
|
+
'metadata',
|
|
52
|
+
'media',
|
|
53
|
+
]);
|
|
54
|
+
const IRREGULAR_PLURALS = {
|
|
55
|
+
person: 'people',
|
|
56
|
+
man: 'men',
|
|
57
|
+
woman: 'women',
|
|
58
|
+
child: 'children',
|
|
59
|
+
foot: 'feet',
|
|
60
|
+
tooth: 'teeth',
|
|
61
|
+
goose: 'geese',
|
|
62
|
+
mouse: 'mice',
|
|
63
|
+
ox: 'oxen',
|
|
64
|
+
};
|
|
65
|
+
/** Word endings whose plural is irregular often enough that the emitter will not assume Laravel's guess. */
|
|
66
|
+
const UNCERTAIN_PLURAL_ENDING = /(f|fe|o|z|is|us|um|on|ix|ex|ax|a|man|ouse|ooth|oot|ice|ife)$/;
|
|
67
|
+
const CREATED_FIELD_NAMES = new Set([
|
|
68
|
+
'created_at',
|
|
69
|
+
'created',
|
|
70
|
+
'createdat',
|
|
71
|
+
'date_created',
|
|
72
|
+
]);
|
|
73
|
+
const UPDATED_FIELD_NAMES = new Set([
|
|
74
|
+
'updated_at',
|
|
75
|
+
'updated',
|
|
76
|
+
'updatedat',
|
|
77
|
+
'modified',
|
|
78
|
+
'modified_at',
|
|
79
|
+
'date_updated',
|
|
80
|
+
]);
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// Public entry points
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
export function emitLaravel(schema, options) {
|
|
85
|
+
const normalize = options.naming === 'normalize';
|
|
86
|
+
const prepared = normalize
|
|
87
|
+
? normalizeLaravelSchema(schema)
|
|
88
|
+
: schema;
|
|
89
|
+
const warnings = prismaOnlyWarnings(prepared);
|
|
90
|
+
let modelNamespace = DEFAULT_LARAVEL_NAMESPACE;
|
|
91
|
+
if (options.namespace !== undefined) {
|
|
92
|
+
const requested = options.namespace.replace(/^\\+|\\+$/g, '');
|
|
93
|
+
if (isValidPhpNamespace(requested)) {
|
|
94
|
+
modelNamespace = requested;
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
warnings.push(`The namespace "${options.namespace}" is not a valid PHP namespace; "${DEFAULT_LARAVEL_NAMESPACE}" was used instead.`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const context = {
|
|
101
|
+
schema: prepared,
|
|
102
|
+
options,
|
|
103
|
+
normalize,
|
|
104
|
+
warnings,
|
|
105
|
+
modelNamespace,
|
|
106
|
+
enumNamespace: siblingEnumNamespace(modelNamespace),
|
|
107
|
+
classNames: new Map(),
|
|
108
|
+
enumNames: new Map(),
|
|
109
|
+
plans: new Map(),
|
|
110
|
+
pivots: [],
|
|
111
|
+
usedTables: new Set(),
|
|
112
|
+
};
|
|
113
|
+
if (options.provider === 'mongodb') {
|
|
114
|
+
warnings.push('Provider "mongodb": Laravel migrations are relational (use the mongodb/laravel-mongodb package for MongoDB); relational migrations and models were written.');
|
|
115
|
+
}
|
|
116
|
+
allocateNames(context);
|
|
117
|
+
for (const model of prepared.models) {
|
|
118
|
+
planModel(context, model);
|
|
119
|
+
}
|
|
120
|
+
const links = [];
|
|
121
|
+
for (const model of prepared.models) {
|
|
122
|
+
planOwnedRelations(context, model, links);
|
|
123
|
+
}
|
|
124
|
+
for (const model of prepared.models) {
|
|
125
|
+
planInverseRelations(context, model, links);
|
|
126
|
+
}
|
|
127
|
+
removeHandledWarnings(context);
|
|
128
|
+
const files = {};
|
|
129
|
+
for (const enumDefinition of prepared.enums) {
|
|
130
|
+
files[enumPath(context, enumDefinition)] = renderEnum(context, enumDefinition);
|
|
131
|
+
}
|
|
132
|
+
for (const model of prepared.models) {
|
|
133
|
+
const plan = context.plans.get(model.name);
|
|
134
|
+
if (plan !== undefined) {
|
|
135
|
+
files[modelPath(context, plan)] = renderModel(context, plan);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
for (const [path, text] of Object.entries(renderMigrations(context))) {
|
|
139
|
+
files[path] = text;
|
|
140
|
+
}
|
|
141
|
+
return { files, warnings };
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Applies Laravel conventions to a schema: plural snake_case table names (join
|
|
145
|
+
* tables keep theirs), snake_case columns, and `created_at` / `updated_at`
|
|
146
|
+
* where a model has none. Primary keys keep their type (UUID or ULID keys only
|
|
147
|
+
* when the schema says so).
|
|
148
|
+
*/
|
|
149
|
+
export function normalizeLaravelSchema(schema) {
|
|
150
|
+
return {
|
|
151
|
+
...schema,
|
|
152
|
+
models: schema.models.map((model) => normalizeLaravelModel(model)),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
function normalizeLaravelModel(model) {
|
|
156
|
+
const fields = model.fields.map((field) => ({
|
|
157
|
+
...field,
|
|
158
|
+
columnName: toSnakeCase(field.columnName),
|
|
159
|
+
}));
|
|
160
|
+
if (model.isJoinTable !== true) {
|
|
161
|
+
const hasCreated = fields.some((field) => CREATED_FIELD_NAMES.has(field.name.toLowerCase()));
|
|
162
|
+
const hasUpdated = fields.some((field) => field.isAutoUpdated || UPDATED_FIELD_NAMES.has(field.name.toLowerCase()));
|
|
163
|
+
if (!hasCreated) {
|
|
164
|
+
fields.push({
|
|
165
|
+
name: 'created_at',
|
|
166
|
+
columnName: 'created_at',
|
|
167
|
+
type: 'dateTime',
|
|
168
|
+
isPrimaryKey: false,
|
|
169
|
+
isUnique: false,
|
|
170
|
+
isNullable: false,
|
|
171
|
+
isAutoUpdated: false,
|
|
172
|
+
default: { kind: 'now' },
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
if (!hasUpdated) {
|
|
176
|
+
fields.push({
|
|
177
|
+
name: 'updated_at',
|
|
178
|
+
columnName: 'updated_at',
|
|
179
|
+
type: 'dateTime',
|
|
180
|
+
isPrimaryKey: false,
|
|
181
|
+
isUnique: false,
|
|
182
|
+
isNullable: false,
|
|
183
|
+
isAutoUpdated: true,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return {
|
|
188
|
+
...model,
|
|
189
|
+
tableName: model.isJoinTable === true
|
|
190
|
+
? model.tableName
|
|
191
|
+
: pluralize(laravelSnake(model.name)).value,
|
|
192
|
+
fields,
|
|
193
|
+
relations: model.relations.map((relation) => ({
|
|
194
|
+
...relation,
|
|
195
|
+
columnName: relation.columnName === '' ? '' : toSnakeCase(relation.columnName),
|
|
196
|
+
})),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
// Small helpers
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
/** Single-quoted PHP string. */
|
|
203
|
+
function quote(value) {
|
|
204
|
+
return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
|
|
205
|
+
}
|
|
206
|
+
/** Laravel's `Str::snake`, which differs from the package helper for acronyms. */
|
|
207
|
+
function laravelSnake(value) {
|
|
208
|
+
if (value === value.toLowerCase()) {
|
|
209
|
+
return value;
|
|
210
|
+
}
|
|
211
|
+
return value
|
|
212
|
+
.replace(/\s+/g, '')
|
|
213
|
+
.replace(/(.)(?=[A-Z])/gu, '$1_')
|
|
214
|
+
.toLowerCase();
|
|
215
|
+
}
|
|
216
|
+
/** Pluralizes the last word of a snake_case identifier the way Laravel's inflector does for regular words. */
|
|
217
|
+
function pluralize(snake) {
|
|
218
|
+
const words = snake.split('_');
|
|
219
|
+
const last = words.pop() ?? '';
|
|
220
|
+
const prefix = words.length === 0 ? '' : `${words.join('_')}_`;
|
|
221
|
+
const lower = last.toLowerCase();
|
|
222
|
+
const irregular = IRREGULAR_PLURALS[lower];
|
|
223
|
+
if (irregular !== undefined) {
|
|
224
|
+
return { value: `${prefix}${irregular}`, certain: true };
|
|
225
|
+
}
|
|
226
|
+
if (UNCOUNTABLE_WORDS.has(lower) || /data$/.test(lower)) {
|
|
227
|
+
return { value: snake, certain: true };
|
|
228
|
+
}
|
|
229
|
+
if (/[^aeiou]y$/.test(lower)) {
|
|
230
|
+
return { value: `${prefix}${last.slice(0, -1)}ies`, certain: true };
|
|
231
|
+
}
|
|
232
|
+
const certain = !UNCERTAIN_PLURAL_ENDING.test(lower);
|
|
233
|
+
if (/ss$/.test(lower)) {
|
|
234
|
+
return { value: `${prefix}${last}es`, certain: true };
|
|
235
|
+
}
|
|
236
|
+
if (/is$/.test(lower)) {
|
|
237
|
+
return { value: `${prefix}${last.slice(0, -2)}es`, certain: false };
|
|
238
|
+
}
|
|
239
|
+
if (/(us|as)$/.test(lower)) {
|
|
240
|
+
return { value: `${prefix}${last}es`, certain: false };
|
|
241
|
+
}
|
|
242
|
+
if (/s$/.test(lower)) {
|
|
243
|
+
// Already plural (Laravel's inflector leaves "PostTags" and "Settings" alone).
|
|
244
|
+
return { value: snake, certain };
|
|
245
|
+
}
|
|
246
|
+
if (/(x|ch|sh)$/.test(lower)) {
|
|
247
|
+
return { value: `${prefix}${last}es`, certain };
|
|
248
|
+
}
|
|
249
|
+
return { value: `${prefix}${last}s`, certain };
|
|
250
|
+
}
|
|
251
|
+
function uniqueName(baseName, used, caseInsensitive = true) {
|
|
252
|
+
const taken = (candidate) => used.has(caseInsensitive ? candidate.toLowerCase() : candidate);
|
|
253
|
+
let candidate = baseName;
|
|
254
|
+
let suffix = 2;
|
|
255
|
+
while (taken(candidate)) {
|
|
256
|
+
candidate = `${baseName}${suffix}`;
|
|
257
|
+
suffix += 1;
|
|
258
|
+
}
|
|
259
|
+
used.add(caseInsensitive ? candidate.toLowerCase() : candidate);
|
|
260
|
+
return candidate;
|
|
261
|
+
}
|
|
262
|
+
/** Makes a raw name a valid PHP identifier, warning when it had to change. */
|
|
263
|
+
function identifier(context, owner, raw) {
|
|
264
|
+
if (IDENTIFIER_PATTERN.test(raw)) {
|
|
265
|
+
return raw;
|
|
266
|
+
}
|
|
267
|
+
const replaced = raw.replace(/[^A-Za-z0-9_]/g, '_');
|
|
268
|
+
const safe = /^[0-9]/.test(replaced) ? `_${replaced}` : replaced;
|
|
269
|
+
const result = safe === '' ? '_' : safe;
|
|
270
|
+
context.warnings.push(`${owner}: "${raw}" is not a valid PHP identifier; it was written as "${result}".`);
|
|
271
|
+
return result;
|
|
272
|
+
}
|
|
273
|
+
function siblingEnumNamespace(modelNamespace) {
|
|
274
|
+
const segments = modelNamespace.split('\\');
|
|
275
|
+
if (segments.length > 1 && segments[segments.length - 1] === 'Models') {
|
|
276
|
+
segments[segments.length - 1] = 'Enums';
|
|
277
|
+
return segments.join('\\');
|
|
278
|
+
}
|
|
279
|
+
return `${modelNamespace}\\Enums`;
|
|
280
|
+
}
|
|
281
|
+
/** PSR-4 directory for a namespace: the first segment maps to `app/`, the rest to folders. */
|
|
282
|
+
function namespaceDirectory(namespace) {
|
|
283
|
+
return ['app', ...namespace.split('\\').slice(1)].join('/');
|
|
284
|
+
}
|
|
285
|
+
function modelPath(context, plan) {
|
|
286
|
+
return `${namespaceDirectory(context.modelNamespace)}/${plan.className}.php`;
|
|
287
|
+
}
|
|
288
|
+
function enumPath(context, enumDefinition) {
|
|
289
|
+
const name = context.enumNames.get(enumDefinition.name) ?? '';
|
|
290
|
+
return `${namespaceDirectory(context.enumNamespace)}/${name}.php`;
|
|
291
|
+
}
|
|
292
|
+
function findEnum(context, name) {
|
|
293
|
+
return context.schema.enums.find((candidate) => candidate.name === name);
|
|
294
|
+
}
|
|
295
|
+
function classNameOf(context, modelName) {
|
|
296
|
+
return context.classNames.get(modelName) ?? modelName;
|
|
297
|
+
}
|
|
298
|
+
/** Local name of an enum inside a model file; aliased when a model has the same name. */
|
|
299
|
+
function enumLocalName(context, enumName) {
|
|
300
|
+
const phpName = context.enumNames.get(enumName) ?? enumName;
|
|
301
|
+
const clashes = [...context.classNames.values()].some((className) => className.toLowerCase() === phpName.toLowerCase());
|
|
302
|
+
return clashes ? `${phpName}Enum` : phpName;
|
|
303
|
+
}
|
|
304
|
+
function enumUseStatement(context, enumName) {
|
|
305
|
+
const phpName = context.enumNames.get(enumName) ?? enumName;
|
|
306
|
+
const local = enumLocalName(context, enumName);
|
|
307
|
+
return local === phpName
|
|
308
|
+
? `${context.enumNamespace}\\${phpName}`
|
|
309
|
+
: `${context.enumNamespace}\\${phpName} as ${local}`;
|
|
310
|
+
}
|
|
311
|
+
function enumCaseName(context, value) {
|
|
312
|
+
const name = identifier({ ...context, warnings: [] }, '', value.name);
|
|
313
|
+
return name.toLowerCase() === 'class' ? `${name}_` : name;
|
|
314
|
+
}
|
|
315
|
+
function allocateNames(context) {
|
|
316
|
+
const used = new Set();
|
|
317
|
+
for (const model of context.schema.models) {
|
|
318
|
+
let base = model.name;
|
|
319
|
+
if (!IDENTIFIER_PATTERN.test(base)) {
|
|
320
|
+
base = identifier(context, model.name, toPascalCase(model.name));
|
|
321
|
+
}
|
|
322
|
+
if (RESERVED_CLASS_NAMES.has(base.toLowerCase())) {
|
|
323
|
+
const renamed = `${base}Model`;
|
|
324
|
+
context.warnings.push(`${model.name}: "${base}" cannot be used as a PHP class name; the class was named "${renamed}".`);
|
|
325
|
+
base = renamed;
|
|
326
|
+
}
|
|
327
|
+
context.classNames.set(model.name, uniqueName(base, used));
|
|
328
|
+
}
|
|
329
|
+
for (const enumDefinition of context.schema.enums) {
|
|
330
|
+
let base = enumDefinition.name;
|
|
331
|
+
if (!IDENTIFIER_PATTERN.test(base)) {
|
|
332
|
+
base = identifier(context, `enum ${enumDefinition.name}`, toPascalCase(enumDefinition.name));
|
|
333
|
+
}
|
|
334
|
+
if (RESERVED_CLASS_NAMES.has(base.toLowerCase())) {
|
|
335
|
+
const renamed = `${base}Enum`;
|
|
336
|
+
context.warnings.push(`enum ${enumDefinition.name}: "${base}" cannot be used as a PHP class name; the enum was named "${renamed}".`);
|
|
337
|
+
base = renamed;
|
|
338
|
+
}
|
|
339
|
+
context.enumNames.set(enumDefinition.name, base);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
// ---------------------------------------------------------------------------
|
|
343
|
+
// Keys
|
|
344
|
+
// ---------------------------------------------------------------------------
|
|
345
|
+
function isUuidField(field) {
|
|
346
|
+
return (field.type === 'uuid' ||
|
|
347
|
+
(field.default?.kind === 'uuid' &&
|
|
348
|
+
(field.type === 'string' || field.type === 'text')));
|
|
349
|
+
}
|
|
350
|
+
function isUlidField(field) {
|
|
351
|
+
return (field.default?.kind === 'clientGenerated' &&
|
|
352
|
+
field.default.generator.toLowerCase() === 'ulid' &&
|
|
353
|
+
(field.type === 'string' || field.type === 'uuid'));
|
|
354
|
+
}
|
|
355
|
+
function isAutoIncrement(field) {
|
|
356
|
+
return (field.default?.kind === 'autoIncrement' &&
|
|
357
|
+
(field.type === 'int' || field.type === 'bigInt'));
|
|
358
|
+
}
|
|
359
|
+
function fieldKeyKind(field, isKey) {
|
|
360
|
+
if (isKey && isAutoIncrement(field)) {
|
|
361
|
+
return 'increments';
|
|
362
|
+
}
|
|
363
|
+
if (isUlidField(field)) {
|
|
364
|
+
return 'ulid';
|
|
365
|
+
}
|
|
366
|
+
if (isUuidField(field)) {
|
|
367
|
+
return 'uuid';
|
|
368
|
+
}
|
|
369
|
+
return 'plain';
|
|
370
|
+
}
|
|
371
|
+
/** Column names of a composite primary key, or undefined for a single-column key. */
|
|
372
|
+
function compositeKeyNames(model) {
|
|
373
|
+
if (model.compositePrimaryKey !== undefined) {
|
|
374
|
+
return model.compositePrimaryKey.length > 0
|
|
375
|
+
? model.compositePrimaryKey
|
|
376
|
+
: undefined;
|
|
377
|
+
}
|
|
378
|
+
const keyNames = [
|
|
379
|
+
...model.fields
|
|
380
|
+
.filter((field) => field.isPrimaryKey)
|
|
381
|
+
.map((field) => field.name),
|
|
382
|
+
...model.relations
|
|
383
|
+
.filter((relation) => relation.isPrimaryKey === true && relation.kind !== 'manyToMany')
|
|
384
|
+
.map((relation) => relation.name),
|
|
385
|
+
];
|
|
386
|
+
return keyNames.length > 1 ? keyNames : undefined;
|
|
387
|
+
}
|
|
388
|
+
function keyOf(context, model, depth = 0) {
|
|
389
|
+
if (compositeKeyNames(model) !== undefined) {
|
|
390
|
+
return undefined;
|
|
391
|
+
}
|
|
392
|
+
const keyField = model.fields.find((field) => field.isPrimaryKey);
|
|
393
|
+
if (keyField !== undefined) {
|
|
394
|
+
return {
|
|
395
|
+
column: keyField.columnName,
|
|
396
|
+
name: keyField.name,
|
|
397
|
+
kind: fieldKeyKind(keyField, true),
|
|
398
|
+
field: keyField,
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
const keyRelation = model.relations.find((relation) => relation.isPrimaryKey === true && relation.kind !== 'manyToMany');
|
|
402
|
+
if (keyRelation === undefined || depth > 5) {
|
|
403
|
+
return undefined;
|
|
404
|
+
}
|
|
405
|
+
const target = findModel(context.schema, keyRelation.targetModel);
|
|
406
|
+
const targetKey = target === undefined ? undefined : keyOf(context, target, depth + 1);
|
|
407
|
+
if (targetKey === undefined) {
|
|
408
|
+
return undefined;
|
|
409
|
+
}
|
|
410
|
+
return {
|
|
411
|
+
column: keyRelation.columnName,
|
|
412
|
+
name: keyRelation.name,
|
|
413
|
+
kind: targetKey.kind === 'increments' ? 'foreignId' : targetKey.kind,
|
|
414
|
+
...(targetKey.field === undefined ? {} : { field: targetKey.field }),
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
function referenceStyle(kind) {
|
|
418
|
+
switch (kind) {
|
|
419
|
+
case 'increments':
|
|
420
|
+
case 'foreignId':
|
|
421
|
+
return 'id';
|
|
422
|
+
case 'uuid':
|
|
423
|
+
return 'uuid';
|
|
424
|
+
case 'ulid':
|
|
425
|
+
return 'ulid';
|
|
426
|
+
default:
|
|
427
|
+
return 'plain';
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
// ---------------------------------------------------------------------------
|
|
431
|
+
// Column types
|
|
432
|
+
// ---------------------------------------------------------------------------
|
|
433
|
+
function spec(method, args, valueKind, cast) {
|
|
434
|
+
return {
|
|
435
|
+
method,
|
|
436
|
+
args,
|
|
437
|
+
valueKind,
|
|
438
|
+
...(cast === undefined ? {} : { cast }),
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
function valueKindOfMethod(method) {
|
|
442
|
+
if (/integer$/i.test(method)) {
|
|
443
|
+
return 'int';
|
|
444
|
+
}
|
|
445
|
+
switch (method) {
|
|
446
|
+
case 'string':
|
|
447
|
+
case 'char':
|
|
448
|
+
case 'text':
|
|
449
|
+
case 'mediumText':
|
|
450
|
+
case 'longText':
|
|
451
|
+
case 'uuid':
|
|
452
|
+
case 'ulid':
|
|
453
|
+
case 'ipAddress':
|
|
454
|
+
return 'string';
|
|
455
|
+
case 'decimal':
|
|
456
|
+
return 'decimal';
|
|
457
|
+
case 'double':
|
|
458
|
+
case 'float':
|
|
459
|
+
return 'float';
|
|
460
|
+
case 'dateTime':
|
|
461
|
+
case 'dateTimeTz':
|
|
462
|
+
return 'datetime';
|
|
463
|
+
case 'date':
|
|
464
|
+
return 'date';
|
|
465
|
+
case 'time':
|
|
466
|
+
case 'timeTz':
|
|
467
|
+
return 'time';
|
|
468
|
+
case 'json':
|
|
469
|
+
case 'jsonb':
|
|
470
|
+
return 'json';
|
|
471
|
+
case 'boolean':
|
|
472
|
+
return 'bool';
|
|
473
|
+
default:
|
|
474
|
+
return 'other';
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
function castOf(kind, field) {
|
|
478
|
+
switch (kind) {
|
|
479
|
+
case 'bool':
|
|
480
|
+
return `'boolean'`;
|
|
481
|
+
case 'json':
|
|
482
|
+
return `'array'`;
|
|
483
|
+
case 'datetime':
|
|
484
|
+
return `'datetime'`;
|
|
485
|
+
case 'date':
|
|
486
|
+
return `'date'`;
|
|
487
|
+
case 'float':
|
|
488
|
+
return `'float'`;
|
|
489
|
+
case 'decimal':
|
|
490
|
+
return field.decimalPlaces === undefined
|
|
491
|
+
? undefined
|
|
492
|
+
: `'decimal:${field.decimalPlaces}'`;
|
|
493
|
+
default:
|
|
494
|
+
return undefined;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
/** A native column type (Prisma `@db.*`) as a Blueprint method, when it maps to one. */
|
|
498
|
+
function nativeSpec(field) {
|
|
499
|
+
const native = field.nativeType;
|
|
500
|
+
if (native === undefined) {
|
|
501
|
+
return undefined;
|
|
502
|
+
}
|
|
503
|
+
const first = native.args[0];
|
|
504
|
+
const second = native.args[1];
|
|
505
|
+
const simple = (method, args = []) => spec(method, args, valueKindOfMethod(method));
|
|
506
|
+
switch (native.name.toLowerCase()) {
|
|
507
|
+
case 'varchar':
|
|
508
|
+
return simple('string', first === undefined ? [] : [first]);
|
|
509
|
+
case 'char':
|
|
510
|
+
return simple('char', first === undefined ? [] : [first]);
|
|
511
|
+
case 'text':
|
|
512
|
+
case 'tinytext':
|
|
513
|
+
return simple('text');
|
|
514
|
+
case 'mediumtext':
|
|
515
|
+
return simple('mediumText');
|
|
516
|
+
case 'longtext':
|
|
517
|
+
return simple('longText');
|
|
518
|
+
case 'uuid':
|
|
519
|
+
return simple('uuid');
|
|
520
|
+
case 'smallint':
|
|
521
|
+
case 'int2':
|
|
522
|
+
return simple('smallInteger');
|
|
523
|
+
case 'tinyint':
|
|
524
|
+
return first === '1' && field.type === 'boolean'
|
|
525
|
+
? simple('boolean')
|
|
526
|
+
: simple('tinyInteger');
|
|
527
|
+
case 'mediumint':
|
|
528
|
+
return simple('mediumInteger');
|
|
529
|
+
case 'unsignedint':
|
|
530
|
+
return simple('unsignedInteger');
|
|
531
|
+
case 'unsignedbigint':
|
|
532
|
+
return simple('unsignedBigInteger');
|
|
533
|
+
case 'unsignedsmallint':
|
|
534
|
+
return simple('unsignedSmallInteger');
|
|
535
|
+
case 'unsignedtinyint':
|
|
536
|
+
return simple('unsignedTinyInteger');
|
|
537
|
+
case 'unsignedmediumint':
|
|
538
|
+
return simple('unsignedMediumInteger');
|
|
539
|
+
case 'decimal':
|
|
540
|
+
case 'numeric':
|
|
541
|
+
return first !== undefined && second !== undefined
|
|
542
|
+
? simple('decimal', [first, second])
|
|
543
|
+
: undefined;
|
|
544
|
+
case 'doubleprecision':
|
|
545
|
+
case 'double':
|
|
546
|
+
return simple('double');
|
|
547
|
+
case 'timestamptz':
|
|
548
|
+
return simple('dateTimeTz', first === undefined ? [] : [first]);
|
|
549
|
+
case 'timestamp':
|
|
550
|
+
case 'datetime':
|
|
551
|
+
return simple('dateTime', first === undefined ? [] : [first]);
|
|
552
|
+
case 'jsonb':
|
|
553
|
+
return simple('jsonb');
|
|
554
|
+
case 'json':
|
|
555
|
+
return simple('json');
|
|
556
|
+
case 'bytea':
|
|
557
|
+
case 'blob':
|
|
558
|
+
case 'longblob':
|
|
559
|
+
case 'mediumblob':
|
|
560
|
+
case 'tinyblob':
|
|
561
|
+
case 'binary':
|
|
562
|
+
case 'varbinary':
|
|
563
|
+
return simple('binary');
|
|
564
|
+
case 'inet':
|
|
565
|
+
return simple('ipAddress');
|
|
566
|
+
default:
|
|
567
|
+
return undefined;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
function columnSpecOf(context, label, field) {
|
|
571
|
+
if ((field.arrayDepth ?? 0) > 0) {
|
|
572
|
+
context.warnings.push(`${label}: Laravel has no array column type that works on every database; the array was written as a json column cast to an array.`);
|
|
573
|
+
return spec(jsonMethod(context), [], 'json', `'array'`);
|
|
574
|
+
}
|
|
575
|
+
const native = nativeSpec(field);
|
|
576
|
+
if (native !== undefined && field.enumName === undefined) {
|
|
577
|
+
const cast = castOf(native.valueKind, field);
|
|
578
|
+
return cast === undefined ? native : { ...native, cast };
|
|
579
|
+
}
|
|
580
|
+
return scalarSpec(context, label, field);
|
|
581
|
+
}
|
|
582
|
+
function jsonMethod(context) {
|
|
583
|
+
return context.options.provider === 'postgresql' ||
|
|
584
|
+
context.options.provider === 'cockroachdb'
|
|
585
|
+
? 'jsonb'
|
|
586
|
+
: 'json';
|
|
587
|
+
}
|
|
588
|
+
function scalarSpec(context, label, field) {
|
|
589
|
+
if (field.enumName !== undefined) {
|
|
590
|
+
const enumDefinition = findEnum(context, field.enumName);
|
|
591
|
+
if (enumDefinition === undefined) {
|
|
592
|
+
context.warnings.push(`${label}: enum "${field.enumName}" does not exist in the schema; the column is written as a plain string.`);
|
|
593
|
+
}
|
|
594
|
+
else {
|
|
595
|
+
const values = enumDefinition.values
|
|
596
|
+
.map((value) => quote(value.dbValue))
|
|
597
|
+
.join(', ');
|
|
598
|
+
return {
|
|
599
|
+
method: 'enum',
|
|
600
|
+
args: [`[${values}]`],
|
|
601
|
+
valueKind: 'enum',
|
|
602
|
+
cast: `${enumLocalName(context, enumDefinition.name)}::class`,
|
|
603
|
+
enumDefinition,
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
switch (field.type) {
|
|
608
|
+
case 'string':
|
|
609
|
+
return spec('string', field.maxLength === undefined || field.maxLength === 255
|
|
610
|
+
? []
|
|
611
|
+
: [String(field.maxLength)], 'string');
|
|
612
|
+
case 'text':
|
|
613
|
+
return spec('text', [], 'string');
|
|
614
|
+
case 'uuid':
|
|
615
|
+
return spec('uuid', [], 'string');
|
|
616
|
+
case 'int':
|
|
617
|
+
return spec('integer', [], 'int');
|
|
618
|
+
case 'bigInt':
|
|
619
|
+
return spec('bigInteger', [], 'int');
|
|
620
|
+
case 'float':
|
|
621
|
+
return spec('double', [], 'float', `'float'`);
|
|
622
|
+
case 'decimal':
|
|
623
|
+
if (field.maxDigits !== undefined && field.decimalPlaces !== undefined) {
|
|
624
|
+
return spec('decimal', [String(field.maxDigits), String(field.decimalPlaces)], 'decimal', `'decimal:${field.decimalPlaces}'`);
|
|
625
|
+
}
|
|
626
|
+
context.warnings.push(`${label}: the decimal precision and scale are unknown; Laravel's default of (8, 2) was used.`);
|
|
627
|
+
return spec('decimal', [], 'decimal');
|
|
628
|
+
case 'boolean':
|
|
629
|
+
return spec('boolean', [], 'bool', `'boolean'`);
|
|
630
|
+
case 'dateTime':
|
|
631
|
+
return spec('dateTime', [], 'datetime', `'datetime'`);
|
|
632
|
+
case 'date':
|
|
633
|
+
return spec('date', [], 'date', `'date'`);
|
|
634
|
+
case 'time':
|
|
635
|
+
return spec('time', [], 'time');
|
|
636
|
+
case 'json':
|
|
637
|
+
return spec(jsonMethod(context), [], 'json', `'array'`);
|
|
638
|
+
case 'bytes':
|
|
639
|
+
return spec('binary', [], 'other');
|
|
640
|
+
case 'duration':
|
|
641
|
+
context.warnings.push(`${label}: Laravel has no interval column type; the duration was written as a string column.`);
|
|
642
|
+
return spec('string', [], 'string');
|
|
643
|
+
case 'ipAddress':
|
|
644
|
+
return spec('ipAddress', [], 'string');
|
|
645
|
+
case 'hstore':
|
|
646
|
+
context.warnings.push(`${label}: Laravel has no hstore type; the field was written as a json column cast to an array.`);
|
|
647
|
+
return spec(jsonMethod(context), [], 'json', `'array'`);
|
|
648
|
+
case 'range':
|
|
649
|
+
context.warnings.push(`${label}: Laravel has no range column type; the field was written as a string column holding the range text.`);
|
|
650
|
+
return spec('string', [], 'string');
|
|
651
|
+
case 'unsupported':
|
|
652
|
+
return spec('string', [], 'string');
|
|
653
|
+
default:
|
|
654
|
+
context.warnings.push(`${label}: unknown field type "${String(field.type)}"; it was written as a string column.`);
|
|
655
|
+
return spec('string', [], 'string');
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
/** Finds the stored value of an enum default, given its case name or stored value. */
|
|
659
|
+
function enumStoredValue(definition, raw) {
|
|
660
|
+
if (definition === undefined) {
|
|
661
|
+
return undefined;
|
|
662
|
+
}
|
|
663
|
+
const match = definition.values.find((candidate) => candidate.name === raw) ??
|
|
664
|
+
definition.values.find((candidate) => candidate.dbValue === raw);
|
|
665
|
+
return match?.dbValue;
|
|
666
|
+
}
|
|
667
|
+
function numberText(value) {
|
|
668
|
+
if (typeof value === 'number') {
|
|
669
|
+
return Number.isFinite(value) ? String(value) : undefined;
|
|
670
|
+
}
|
|
671
|
+
if (typeof value === 'string' && /^-?\d+(\.\d+)?$/.test(value.trim())) {
|
|
672
|
+
return value.trim();
|
|
673
|
+
}
|
|
674
|
+
return undefined;
|
|
675
|
+
}
|
|
676
|
+
/** The `->default(...)`, `->useCurrent()` ... modifiers that carry a field's default. */
|
|
677
|
+
function defaultModifiers(context, label, field, columnSpec, imports) {
|
|
678
|
+
const defaultValue = field.default;
|
|
679
|
+
if (defaultValue === undefined) {
|
|
680
|
+
return [];
|
|
681
|
+
}
|
|
682
|
+
const drop = (reason) => {
|
|
683
|
+
context.warnings.push(`${label}: ${reason}; it was dropped.`);
|
|
684
|
+
return [];
|
|
685
|
+
};
|
|
686
|
+
switch (defaultValue.kind) {
|
|
687
|
+
case 'autoIncrement':
|
|
688
|
+
return drop('an auto-increment default is only supported on primary keys');
|
|
689
|
+
case 'now':
|
|
690
|
+
if (columnSpec.valueKind !== 'datetime') {
|
|
691
|
+
return drop('a "now" default needs a dateTime column');
|
|
692
|
+
}
|
|
693
|
+
return ['useCurrent()'];
|
|
694
|
+
case 'uuid':
|
|
695
|
+
// Generated by the HasUuids trait rather than the database.
|
|
696
|
+
return [];
|
|
697
|
+
case 'enumValue':
|
|
698
|
+
return enumDefault(columnSpec, defaultValue.value, drop);
|
|
699
|
+
case 'literal':
|
|
700
|
+
return literalDefault(context, field, columnSpec, defaultValue.value, imports, drop);
|
|
701
|
+
default:
|
|
702
|
+
// clientGenerated and dbExpression defaults are reported by prismaOnlyWarnings.
|
|
703
|
+
return [];
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
function enumDefault(columnSpec, raw, drop) {
|
|
707
|
+
const stored = enumStoredValue(columnSpec.enumDefinition, raw);
|
|
708
|
+
return stored === undefined
|
|
709
|
+
? drop(`the enum default "${raw}" does not match a case of the enum`)
|
|
710
|
+
: [`default(${quote(stored)})`];
|
|
711
|
+
}
|
|
712
|
+
function literalDefault(context, field, columnSpec, value, imports, drop) {
|
|
713
|
+
switch (columnSpec.valueKind) {
|
|
714
|
+
case 'enum':
|
|
715
|
+
return enumDefault(columnSpec, String(value), drop);
|
|
716
|
+
case 'bool':
|
|
717
|
+
return [
|
|
718
|
+
`default(${value === true || value === 'true' ? 'true' : 'false'})`,
|
|
719
|
+
];
|
|
720
|
+
case 'int': {
|
|
721
|
+
const text = numberText(value);
|
|
722
|
+
return text === undefined
|
|
723
|
+
? drop(`the default ${String(value)} is not a number`)
|
|
724
|
+
: [`default(${String(Math.trunc(Number(text)))})`];
|
|
725
|
+
}
|
|
726
|
+
case 'float':
|
|
727
|
+
case 'decimal': {
|
|
728
|
+
const text = numberText(value);
|
|
729
|
+
return text === undefined
|
|
730
|
+
? drop(`the default ${String(value)} is not a number`)
|
|
731
|
+
: [`default(${text})`];
|
|
732
|
+
}
|
|
733
|
+
case 'string':
|
|
734
|
+
case 'date':
|
|
735
|
+
case 'datetime':
|
|
736
|
+
case 'time':
|
|
737
|
+
return [`default(${quote(String(value))})`];
|
|
738
|
+
case 'json': {
|
|
739
|
+
const text = typeof value === 'string' ? value : String(value);
|
|
740
|
+
if (context.options.provider === 'mysql') {
|
|
741
|
+
imports.add('Illuminate\\Database\\Query\\Expression');
|
|
742
|
+
return [
|
|
743
|
+
`default(new Expression(${quote(`('${text.replace(/'/g, "''")}')`)}))`,
|
|
744
|
+
];
|
|
745
|
+
}
|
|
746
|
+
return [`default(${quote(text)})`];
|
|
747
|
+
}
|
|
748
|
+
default:
|
|
749
|
+
return drop(`a literal default on a ${field.type} column cannot be represented`);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
// ---------------------------------------------------------------------------
|
|
753
|
+
// Chain rendering
|
|
754
|
+
// ---------------------------------------------------------------------------
|
|
755
|
+
function chainText(chain) {
|
|
756
|
+
return `$table->${chain.method}(${chain.args.join(', ')})${chain.modifiers
|
|
757
|
+
.map((modifier) => `->${modifier}`)
|
|
758
|
+
.join('')}`;
|
|
759
|
+
}
|
|
760
|
+
/** Renders `$table->...;` at an indent, wrapping the chain one call per line when it is too long. */
|
|
761
|
+
function renderStatement(text, depth) {
|
|
762
|
+
const indent = INDENT.repeat(depth);
|
|
763
|
+
const single = `${indent}${text};`;
|
|
764
|
+
if (single.length <= MAX_LINE_WIDTH) {
|
|
765
|
+
return [single];
|
|
766
|
+
}
|
|
767
|
+
const parts = splitCalls(text);
|
|
768
|
+
return parts.map((part, index) => {
|
|
769
|
+
const prefix = index === 0 ? indent : `${indent}${INDENT}`;
|
|
770
|
+
return `${prefix}${part}${index === parts.length - 1 ? ';' : ''}`;
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
/** Splits `$table->a(1)->b('x')` at the top-level `->` separators. */
|
|
774
|
+
function splitCalls(text) {
|
|
775
|
+
const parts = [];
|
|
776
|
+
let depth = 0;
|
|
777
|
+
let inString = false;
|
|
778
|
+
let current = '';
|
|
779
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
780
|
+
const char = text.charAt(index);
|
|
781
|
+
if (inString) {
|
|
782
|
+
current += char;
|
|
783
|
+
if (char === '\\') {
|
|
784
|
+
index += 1;
|
|
785
|
+
current += text.charAt(index);
|
|
786
|
+
}
|
|
787
|
+
else if (char === "'") {
|
|
788
|
+
inString = false;
|
|
789
|
+
}
|
|
790
|
+
continue;
|
|
791
|
+
}
|
|
792
|
+
if (char === "'") {
|
|
793
|
+
inString = true;
|
|
794
|
+
}
|
|
795
|
+
else if (char === '(' || char === '[') {
|
|
796
|
+
depth += 1;
|
|
797
|
+
}
|
|
798
|
+
else if (char === ')' || char === ']') {
|
|
799
|
+
depth -= 1;
|
|
800
|
+
}
|
|
801
|
+
if (depth === 0 &&
|
|
802
|
+
char === '-' &&
|
|
803
|
+
text.charAt(index + 1) === '>' &&
|
|
804
|
+
current !== '$table') {
|
|
805
|
+
parts.push(current);
|
|
806
|
+
current = '->';
|
|
807
|
+
index += 1;
|
|
808
|
+
continue;
|
|
809
|
+
}
|
|
810
|
+
current += char;
|
|
811
|
+
}
|
|
812
|
+
parts.push(current);
|
|
813
|
+
return parts;
|
|
814
|
+
}
|
|
815
|
+
// ---------------------------------------------------------------------------
|
|
816
|
+
// Planning: models, tables, columns
|
|
817
|
+
// ---------------------------------------------------------------------------
|
|
818
|
+
function newTablePlan(node, table) {
|
|
819
|
+
return {
|
|
820
|
+
node,
|
|
821
|
+
table,
|
|
822
|
+
columns: [],
|
|
823
|
+
foreignKeys: [],
|
|
824
|
+
tail: [],
|
|
825
|
+
constraints: [],
|
|
826
|
+
imports: new Set(),
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
function conventionalTable(className) {
|
|
830
|
+
return pluralize(laravelSnake(className));
|
|
831
|
+
}
|
|
832
|
+
function planModel(context, model) {
|
|
833
|
+
const className = classNameOf(context, model.name);
|
|
834
|
+
const table = newTablePlan(model.name, model.tableName);
|
|
835
|
+
if (context.usedTables.has(model.tableName)) {
|
|
836
|
+
context.warnings.push(`${model.name}: the table "${model.tableName}" is also used by another model; the migrations would create it twice.`);
|
|
837
|
+
}
|
|
838
|
+
context.usedTables.add(model.tableName);
|
|
839
|
+
const key = keyOf(context, model);
|
|
840
|
+
const compositeKey = compositeKeyNames(model);
|
|
841
|
+
const plan = {
|
|
842
|
+
model,
|
|
843
|
+
className,
|
|
844
|
+
table,
|
|
845
|
+
key,
|
|
846
|
+
compositeKey: undefined,
|
|
847
|
+
hasTimestamps: false,
|
|
848
|
+
hasSoftDeletes: false,
|
|
849
|
+
fillable: [],
|
|
850
|
+
casts: [],
|
|
851
|
+
uuidColumns: [],
|
|
852
|
+
usesUlid: false,
|
|
853
|
+
usesUuid: false,
|
|
854
|
+
keyGenerated: false,
|
|
855
|
+
takenNames: new Set(),
|
|
856
|
+
methods: [],
|
|
857
|
+
imports: new Set(['Illuminate\\Database\\Eloquent\\Model']),
|
|
858
|
+
};
|
|
859
|
+
context.plans.set(model.name, plan);
|
|
860
|
+
if (key === undefined && compositeKey === undefined) {
|
|
861
|
+
context.warnings.push(`${model.name}: the model has no primary key; Eloquent expects one, so $primaryKey was set to null.`);
|
|
862
|
+
}
|
|
863
|
+
if (compositeKey !== undefined) {
|
|
864
|
+
context.warnings.push(`${model.name}: Eloquent does not support composite primary keys; the migration declares primary(${compositeKey.join(', ')}) but the model can only address the table through its own queries.`);
|
|
865
|
+
}
|
|
866
|
+
planFields(context, plan, compositeKey);
|
|
867
|
+
planTableIndexes(context, plan);
|
|
868
|
+
}
|
|
869
|
+
function columnOfName(model, name) {
|
|
870
|
+
const field = model.fields.find((candidate) => candidate.name === name);
|
|
871
|
+
if (field !== undefined) {
|
|
872
|
+
return field.columnName;
|
|
873
|
+
}
|
|
874
|
+
return model.relations.find((candidate) => candidate.name === name)?.columnName;
|
|
875
|
+
}
|
|
876
|
+
function planFields(context, plan, compositeKey) {
|
|
877
|
+
const model = plan.model;
|
|
878
|
+
const table = plan.table;
|
|
879
|
+
const pair = timestampPair(model);
|
|
880
|
+
const softDelete = model.fields.find((field) => field.columnName === 'deleted_at' &&
|
|
881
|
+
field.type === 'dateTime' &&
|
|
882
|
+
field.isNullable &&
|
|
883
|
+
field.default === undefined &&
|
|
884
|
+
field.enumName === undefined &&
|
|
885
|
+
!field.isPrimaryKey &&
|
|
886
|
+
(field.arrayDepth ?? 0) === 0);
|
|
887
|
+
plan.hasTimestamps = pair !== undefined;
|
|
888
|
+
plan.hasSoftDeletes = softDelete !== undefined;
|
|
889
|
+
plan.compositeKey =
|
|
890
|
+
compositeKey === undefined
|
|
891
|
+
? undefined
|
|
892
|
+
: compositeKey
|
|
893
|
+
.map((name) => columnOfName(model, name))
|
|
894
|
+
.filter((column) => column !== undefined);
|
|
895
|
+
for (const field of model.fields) {
|
|
896
|
+
const label = `${model.name}.${field.name}`;
|
|
897
|
+
plan.takenNames.add(field.columnName.toLowerCase());
|
|
898
|
+
plan.takenNames.add(field.name.toLowerCase());
|
|
899
|
+
if (pair?.includes(field) === true || field === softDelete) {
|
|
900
|
+
continue;
|
|
901
|
+
}
|
|
902
|
+
planField(context, plan, field, label, compositeKey);
|
|
903
|
+
}
|
|
904
|
+
for (const relation of model.relations) {
|
|
905
|
+
if (relation.kind !== 'manyToMany') {
|
|
906
|
+
plan.takenNames.add(relation.columnName.toLowerCase());
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
if (pair !== undefined) {
|
|
910
|
+
table.tail.push({ method: 'timestamps', args: [], modifiers: [] });
|
|
911
|
+
}
|
|
912
|
+
if (softDelete !== undefined) {
|
|
913
|
+
table.tail.push({ method: 'softDeletes', args: [], modifiers: [] });
|
|
914
|
+
plan.imports.add('Illuminate\\Database\\Eloquent\\SoftDeletes');
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
/** The `created_at` / `updated_at` pair that Laravel's `timestamps()` and Eloquent manage together. */
|
|
918
|
+
function timestampPair(model) {
|
|
919
|
+
const usable = (field) => field !== undefined &&
|
|
920
|
+
field.type === 'dateTime' &&
|
|
921
|
+
field.enumName === undefined &&
|
|
922
|
+
(field.arrayDepth ?? 0) === 0 &&
|
|
923
|
+
!field.isPrimaryKey &&
|
|
924
|
+
!field.isUnique &&
|
|
925
|
+
(field.default === undefined || field.default.kind === 'now');
|
|
926
|
+
const created = model.fields.find((field) => field.columnName === 'created_at');
|
|
927
|
+
const updated = model.fields.find((field) => field.columnName === 'updated_at');
|
|
928
|
+
if (!usable(created) || !usable(updated)) {
|
|
929
|
+
return undefined;
|
|
930
|
+
}
|
|
931
|
+
if (created.isAutoUpdated) {
|
|
932
|
+
return undefined;
|
|
933
|
+
}
|
|
934
|
+
return [created, updated];
|
|
935
|
+
}
|
|
936
|
+
function planField(context, plan, field, label, compositeKey) {
|
|
937
|
+
const table = plan.table;
|
|
938
|
+
const columnSpec = columnSpecOf(context, label, field);
|
|
939
|
+
if (field.generated !== undefined) {
|
|
940
|
+
context.warnings.push(`${label}: the generated column expression ${field.generated.expression} is not a SQL expression Laravel can write (storedAs / virtualAs); the column was written as a regular column.`);
|
|
941
|
+
}
|
|
942
|
+
const inComposite = compositeKey?.includes(field.name) ?? false;
|
|
943
|
+
const isKey = plan.key?.field === field && plan.key.name === field.name;
|
|
944
|
+
const keyKind = fieldKeyKind(field, isKey && !inComposite);
|
|
945
|
+
const quotedName = quote(field.columnName);
|
|
946
|
+
if (isKey && keyKind === 'increments') {
|
|
947
|
+
table.primary =
|
|
948
|
+
field.columnName === 'id'
|
|
949
|
+
? { method: 'id', args: [], modifiers: [] }
|
|
950
|
+
: { method: 'bigIncrements', args: [quotedName], modifiers: [] };
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
const chain = {
|
|
954
|
+
method: columnSpec.method,
|
|
955
|
+
args: [quotedName, ...columnSpec.args],
|
|
956
|
+
modifiers: [],
|
|
957
|
+
};
|
|
958
|
+
if (isKey && keyKind === 'uuid') {
|
|
959
|
+
chain.method = 'uuid';
|
|
960
|
+
chain.args = [quotedName];
|
|
961
|
+
plan.usesUuid = true;
|
|
962
|
+
plan.keyGenerated = true;
|
|
963
|
+
}
|
|
964
|
+
else if (isKey && keyKind === 'ulid') {
|
|
965
|
+
chain.method = 'ulid';
|
|
966
|
+
chain.args = [quotedName];
|
|
967
|
+
plan.usesUlid = true;
|
|
968
|
+
plan.keyGenerated = true;
|
|
969
|
+
}
|
|
970
|
+
else if (!isKey && keyKind === 'uuid' && field.default?.kind === 'uuid') {
|
|
971
|
+
plan.uuidColumns.push(field.columnName);
|
|
972
|
+
plan.usesUuid = true;
|
|
973
|
+
}
|
|
974
|
+
if (!isKey && field.isNullable) {
|
|
975
|
+
chain.modifiers.push('nullable()');
|
|
976
|
+
}
|
|
977
|
+
if (!isKey && field.isUnique) {
|
|
978
|
+
chain.modifiers.push(field.uniqueName === undefined
|
|
979
|
+
? 'unique()'
|
|
980
|
+
: `unique(${quote(field.uniqueName)})`);
|
|
981
|
+
}
|
|
982
|
+
if (isKey) {
|
|
983
|
+
chain.modifiers.push('primary()');
|
|
984
|
+
}
|
|
985
|
+
else {
|
|
986
|
+
chain.modifiers.push(...defaultModifiers(context, label, field, columnSpec, table.imports));
|
|
987
|
+
}
|
|
988
|
+
if (field.isAutoUpdated) {
|
|
989
|
+
context.warnings.push(`${label}: an auto-updated column outside the created_at / updated_at pair is only refreshed by MySQL (ON UPDATE CURRENT_TIMESTAMP); Eloquent does not maintain it.`);
|
|
990
|
+
if (columnSpec.valueKind === 'datetime') {
|
|
991
|
+
if (!chain.modifiers.includes('useCurrent()')) {
|
|
992
|
+
chain.modifiers.push('useCurrent()');
|
|
993
|
+
}
|
|
994
|
+
chain.modifiers.push('useCurrentOnUpdate()');
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
table.columns.push(chain);
|
|
998
|
+
if (!isKey || keyKind === 'plain') {
|
|
999
|
+
plan.fillable.push(field.columnName);
|
|
1000
|
+
}
|
|
1001
|
+
if (columnSpec.cast !== undefined && !(isKey && keyKind !== 'plain')) {
|
|
1002
|
+
plan.casts.push([field.columnName, columnSpec.cast]);
|
|
1003
|
+
if (columnSpec.enumDefinition !== undefined) {
|
|
1004
|
+
plan.imports.add(enumUseStatement(context, columnSpec.enumDefinition.name));
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
function planTableIndexes(context, plan) {
|
|
1009
|
+
const model = plan.model;
|
|
1010
|
+
if (plan.compositeKey !== undefined && plan.compositeKey.length > 0) {
|
|
1011
|
+
plan.table.constraints.push(`$table->primary([${plan.compositeKey.map(quote).join(', ')}])`);
|
|
1012
|
+
}
|
|
1013
|
+
for (const index of model.indexes) {
|
|
1014
|
+
const label = `${model.name} index (${index.fields.join(', ')})`;
|
|
1015
|
+
const columns = [];
|
|
1016
|
+
let missing;
|
|
1017
|
+
for (const name of index.fields) {
|
|
1018
|
+
const column = columnOfName(model, name);
|
|
1019
|
+
if (column === undefined) {
|
|
1020
|
+
missing = name;
|
|
1021
|
+
break;
|
|
1022
|
+
}
|
|
1023
|
+
columns.push(column);
|
|
1024
|
+
}
|
|
1025
|
+
if (missing !== undefined || columns.length === 0) {
|
|
1026
|
+
context.warnings.push(`${label}: "${missing ?? ''}" is not a field or relation of the model; the index was skipped.`);
|
|
1027
|
+
continue;
|
|
1028
|
+
}
|
|
1029
|
+
plan.table.constraints.push(indexStatement(index, columns));
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
function indexStatement(index, columns) {
|
|
1033
|
+
const target = columns.length === 1
|
|
1034
|
+
? quote(columns[0] ?? '')
|
|
1035
|
+
: `[${columns.map(quote).join(', ')}]`;
|
|
1036
|
+
const name = index.name === undefined ? '' : `, ${quote(index.name)}`;
|
|
1037
|
+
return `$table->${index.isUnique ? 'unique' : 'index'}(${target}${name})`;
|
|
1038
|
+
}
|
|
1039
|
+
function referencedField(target, key, toField) {
|
|
1040
|
+
if (toField !== undefined) {
|
|
1041
|
+
const field = target.fields.find((candidate) => candidate.name === toField);
|
|
1042
|
+
return field === undefined
|
|
1043
|
+
? undefined
|
|
1044
|
+
: { column: field.columnName, field };
|
|
1045
|
+
}
|
|
1046
|
+
return key === undefined
|
|
1047
|
+
? undefined
|
|
1048
|
+
: { column: key.column, field: key.field };
|
|
1049
|
+
}
|
|
1050
|
+
function keyStyleOf(key, referenced, toField) {
|
|
1051
|
+
if (toField === undefined) {
|
|
1052
|
+
return key === undefined ? 'plain' : referenceStyle(key.kind);
|
|
1053
|
+
}
|
|
1054
|
+
return referenced !== undefined && isUuidField(referenced) ? 'uuid' : 'plain';
|
|
1055
|
+
}
|
|
1056
|
+
function onDeleteModifiers(action) {
|
|
1057
|
+
switch (action) {
|
|
1058
|
+
case 'cascade':
|
|
1059
|
+
return ['cascadeOnDelete()'];
|
|
1060
|
+
case 'restrict':
|
|
1061
|
+
return ['restrictOnDelete()'];
|
|
1062
|
+
case 'setNull':
|
|
1063
|
+
return ['nullOnDelete()'];
|
|
1064
|
+
case 'setDefault':
|
|
1065
|
+
return [`onDelete('set default')`];
|
|
1066
|
+
default:
|
|
1067
|
+
return [];
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
function onUpdateModifiers(action) {
|
|
1071
|
+
switch (action) {
|
|
1072
|
+
case 'cascade':
|
|
1073
|
+
return ['cascadeOnUpdate()'];
|
|
1074
|
+
case 'restrict':
|
|
1075
|
+
return ['restrictOnUpdate()'];
|
|
1076
|
+
case 'setNull':
|
|
1077
|
+
return ['nullOnUpdate()'];
|
|
1078
|
+
case 'setDefault':
|
|
1079
|
+
return [`onUpdate('set default')`];
|
|
1080
|
+
case 'noAction':
|
|
1081
|
+
return ['noActionOnUpdate()'];
|
|
1082
|
+
default:
|
|
1083
|
+
return [];
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
/** The column type a foreign key needs to match the field it references. */
|
|
1087
|
+
function plainColumnType(context, label, field) {
|
|
1088
|
+
if (field === undefined) {
|
|
1089
|
+
return {};
|
|
1090
|
+
}
|
|
1091
|
+
const columnSpec = columnSpecOf(context, label, {
|
|
1092
|
+
...field,
|
|
1093
|
+
enumName: undefined,
|
|
1094
|
+
arrayDepth: undefined,
|
|
1095
|
+
});
|
|
1096
|
+
return { columnType: { method: columnSpec.method, args: columnSpec.args } };
|
|
1097
|
+
}
|
|
1098
|
+
function methodNameFor(context, plan, label, rawName) {
|
|
1099
|
+
const base = identifier(context, label, toCamelCase(rawName) || rawName);
|
|
1100
|
+
const taken = (candidate) => plan.takenNames.has(candidate.toLowerCase()) ||
|
|
1101
|
+
RESERVED_METHOD_NAMES.has(candidate.toLowerCase());
|
|
1102
|
+
let candidate = base;
|
|
1103
|
+
if (taken(candidate)) {
|
|
1104
|
+
candidate = `${base}Relation`;
|
|
1105
|
+
let suffix = 2;
|
|
1106
|
+
while (taken(candidate)) {
|
|
1107
|
+
candidate = `${base}Relation${suffix}`;
|
|
1108
|
+
suffix += 1;
|
|
1109
|
+
}
|
|
1110
|
+
context.warnings.push(`${label}: "${base}" clashes with a column or an Eloquent method; the relationship method was named "${candidate}".`);
|
|
1111
|
+
}
|
|
1112
|
+
plan.takenNames.add(candidate.toLowerCase());
|
|
1113
|
+
return candidate;
|
|
1114
|
+
}
|
|
1115
|
+
function addMethod(plan, returnType, generic, name, body) {
|
|
1116
|
+
plan.imports.add(`Illuminate\\Database\\Eloquent\\Relations\\${returnType}`);
|
|
1117
|
+
plan.methods.push([
|
|
1118
|
+
`${INDENT}/** @return ${returnType}<${generic}, $this> */`,
|
|
1119
|
+
`${INDENT}public function ${name}(): ${returnType}`,
|
|
1120
|
+
`${INDENT}{`,
|
|
1121
|
+
`${INDENT}${INDENT}return ${body};`,
|
|
1122
|
+
`${INDENT}}`,
|
|
1123
|
+
]);
|
|
1124
|
+
}
|
|
1125
|
+
function planOwnedRelations(context, model, links) {
|
|
1126
|
+
const plan = context.plans.get(model.name);
|
|
1127
|
+
if (plan === undefined) {
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
1130
|
+
for (const relation of model.relations) {
|
|
1131
|
+
const label = `${model.name}.${relation.name}`;
|
|
1132
|
+
const target = findModel(context.schema, relation.targetModel);
|
|
1133
|
+
if (target === undefined) {
|
|
1134
|
+
context.warnings.push(`${label}: target model "${relation.targetModel}" does not exist in the schema; the relation was skipped.`);
|
|
1135
|
+
continue;
|
|
1136
|
+
}
|
|
1137
|
+
if (relation.kind === 'manyToMany') {
|
|
1138
|
+
planManyToMany(context, plan, relation, target, links);
|
|
1139
|
+
}
|
|
1140
|
+
else {
|
|
1141
|
+
planToOne(context, plan, relation, target);
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
function planToOne(context, plan, relation, target) {
|
|
1146
|
+
const model = plan.model;
|
|
1147
|
+
const label = `${model.name}.${relation.name}`;
|
|
1148
|
+
const targetPlan = context.plans.get(target.name);
|
|
1149
|
+
if (targetPlan === undefined) {
|
|
1150
|
+
return;
|
|
1151
|
+
}
|
|
1152
|
+
const reference = referencedField(target, targetPlan.key, relation.toField);
|
|
1153
|
+
if (reference === undefined) {
|
|
1154
|
+
context.warnings.push(`${label}: target model "${target.name}" has no single-column primary key to reference; the relation was skipped.`);
|
|
1155
|
+
return;
|
|
1156
|
+
}
|
|
1157
|
+
if (relation.onDelete === 'setNull' && !relation.isNullable) {
|
|
1158
|
+
context.warnings.push(`${label}: onDelete SET NULL on a required relation will fail at the database level; review the relation.`);
|
|
1159
|
+
}
|
|
1160
|
+
const isKey = relation.isPrimaryKey === true;
|
|
1161
|
+
const nullable = relation.isNullable && !isKey;
|
|
1162
|
+
plan.table.foreignKeys.push({
|
|
1163
|
+
column: relation.columnName,
|
|
1164
|
+
referencedTable: target.tableName,
|
|
1165
|
+
referencedColumn: reference.column,
|
|
1166
|
+
style: keyStyleOf(targetPlan.key, reference.field, relation.toField),
|
|
1167
|
+
...plainColumnType(context, label, reference.field),
|
|
1168
|
+
nullable,
|
|
1169
|
+
unique: relation.kind === 'oneToOne' && !isKey,
|
|
1170
|
+
isKey,
|
|
1171
|
+
onDelete: relation.onDelete,
|
|
1172
|
+
...(relation.onUpdate === undefined ? {} : { onUpdate: relation.onUpdate }),
|
|
1173
|
+
targetNode: target.name,
|
|
1174
|
+
deferred: false,
|
|
1175
|
+
});
|
|
1176
|
+
plan.fillable.push(relation.columnName);
|
|
1177
|
+
// belongsTo on the owning side.
|
|
1178
|
+
const methodName = methodNameFor(context, plan, label, relation.name);
|
|
1179
|
+
const targetKeyName = targetPlan.key?.column ?? 'id';
|
|
1180
|
+
const conventionalColumn = `${laravelSnake(methodName)}_${targetKeyName}`;
|
|
1181
|
+
const ownerKeyDiffers = reference.column !== targetKeyName;
|
|
1182
|
+
const args = [`${targetPlan.className}::class`];
|
|
1183
|
+
if (relation.columnName !== conventionalColumn || ownerKeyDiffers) {
|
|
1184
|
+
args.push(quote(relation.columnName));
|
|
1185
|
+
}
|
|
1186
|
+
if (ownerKeyDiffers) {
|
|
1187
|
+
args.push(quote(reference.column));
|
|
1188
|
+
}
|
|
1189
|
+
addMethod(plan, 'BelongsTo', targetPlan.className, methodName, `$this->belongsTo(${args.join(', ')})`);
|
|
1190
|
+
}
|
|
1191
|
+
function pivotColumnNames(owner, target, ownerKey, targetKey) {
|
|
1192
|
+
const isSelf = owner.model.name === target.model.name;
|
|
1193
|
+
const ownerColumn = `${isSelf ? 'from_' : ''}${laravelSnake(owner.className)}_${ownerKey.column}`;
|
|
1194
|
+
const targetColumn = `${isSelf ? 'to_' : ''}${laravelSnake(target.className)}_${targetKey.column}`;
|
|
1195
|
+
return { owner: ownerColumn, target: targetColumn, conventional: !isSelf };
|
|
1196
|
+
}
|
|
1197
|
+
function planManyToMany(context, plan, relation, target, links) {
|
|
1198
|
+
const model = plan.model;
|
|
1199
|
+
const label = `${model.name}.${relation.name}`;
|
|
1200
|
+
const targetPlan = context.plans.get(target.name);
|
|
1201
|
+
if (targetPlan === undefined) {
|
|
1202
|
+
return;
|
|
1203
|
+
}
|
|
1204
|
+
const ownerKey = plan.key;
|
|
1205
|
+
const targetKey = targetPlan.key;
|
|
1206
|
+
if (ownerKey === undefined || targetKey === undefined) {
|
|
1207
|
+
context.warnings.push(`${label}: ${ownerKey === undefined ? model.name : target.name} has no single-column primary key to reference from a pivot table; the relation was skipped.`);
|
|
1208
|
+
return;
|
|
1209
|
+
}
|
|
1210
|
+
const reverse = links.find((link) => link.owner === target.name &&
|
|
1211
|
+
link.target === model.name &&
|
|
1212
|
+
(link.relation.relatedName === relation.name ||
|
|
1213
|
+
relation.relatedName === link.relation.name));
|
|
1214
|
+
if (reverse !== undefined) {
|
|
1215
|
+
return;
|
|
1216
|
+
}
|
|
1217
|
+
const columns = pivotColumnNames(plan, targetPlan, ownerKey, targetKey);
|
|
1218
|
+
const sortedNames = [
|
|
1219
|
+
laravelSnake(plan.className),
|
|
1220
|
+
laravelSnake(targetPlan.className),
|
|
1221
|
+
].sort();
|
|
1222
|
+
const conventionalTable = sortedNames.join('_');
|
|
1223
|
+
let tableName = context.normalize
|
|
1224
|
+
? conventionalTable
|
|
1225
|
+
: `${model.tableName}_${relation.name}`;
|
|
1226
|
+
if (context.usedTables.has(tableName)) {
|
|
1227
|
+
tableName = `${model.tableName}_${relation.name}`;
|
|
1228
|
+
let suffix = 2;
|
|
1229
|
+
const base = tableName;
|
|
1230
|
+
while (context.usedTables.has(tableName)) {
|
|
1231
|
+
tableName = `${base}_${suffix}`;
|
|
1232
|
+
suffix += 1;
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
context.usedTables.add(tableName);
|
|
1236
|
+
const pivot = newTablePlan(`pivot:${tableName}`, tableName);
|
|
1237
|
+
const sides = [
|
|
1238
|
+
[columns.owner, ownerKey, model, model.name],
|
|
1239
|
+
[columns.target, targetKey, target, target.name],
|
|
1240
|
+
];
|
|
1241
|
+
for (const [column, key, side, node] of sides) {
|
|
1242
|
+
pivot.foreignKeys.push({
|
|
1243
|
+
column,
|
|
1244
|
+
referencedTable: side.tableName,
|
|
1245
|
+
referencedColumn: key.column,
|
|
1246
|
+
style: referenceStyle(key.kind),
|
|
1247
|
+
...plainColumnType(context, label, key.field),
|
|
1248
|
+
nullable: false,
|
|
1249
|
+
unique: false,
|
|
1250
|
+
isKey: false,
|
|
1251
|
+
onDelete: 'cascade',
|
|
1252
|
+
targetNode: node,
|
|
1253
|
+
deferred: false,
|
|
1254
|
+
});
|
|
1255
|
+
}
|
|
1256
|
+
pivot.constraints.push(`$table->primary([${quote(columns.owner)}, ${quote(columns.target)}])`);
|
|
1257
|
+
context.pivots.push(pivot);
|
|
1258
|
+
const link = {
|
|
1259
|
+
owner: model.name,
|
|
1260
|
+
target: target.name,
|
|
1261
|
+
relation,
|
|
1262
|
+
table: tableName,
|
|
1263
|
+
ownerColumn: columns.owner,
|
|
1264
|
+
targetColumn: columns.target,
|
|
1265
|
+
conventional: columns.conventional && tableName === conventionalTable,
|
|
1266
|
+
};
|
|
1267
|
+
links.push(link);
|
|
1268
|
+
const methodName = methodNameFor(context, plan, label, relation.name);
|
|
1269
|
+
addMethod(plan, 'BelongsToMany', targetPlan.className, methodName, `$this->belongsToMany(${belongsToManyArgs(targetPlan.className, link, false)})`);
|
|
1270
|
+
}
|
|
1271
|
+
function belongsToManyArgs(className, link, inverse) {
|
|
1272
|
+
const args = [`${className}::class`];
|
|
1273
|
+
if (!link.conventional) {
|
|
1274
|
+
args.push(quote(link.table), quote(inverse ? link.targetColumn : link.ownerColumn), quote(inverse ? link.ownerColumn : link.targetColumn));
|
|
1275
|
+
}
|
|
1276
|
+
return args.join(', ');
|
|
1277
|
+
}
|
|
1278
|
+
function planInverseRelations(context, model, links) {
|
|
1279
|
+
const ownerPlan = context.plans.get(model.name);
|
|
1280
|
+
if (ownerPlan === undefined) {
|
|
1281
|
+
return;
|
|
1282
|
+
}
|
|
1283
|
+
for (const relation of model.relations) {
|
|
1284
|
+
const target = findModel(context.schema, relation.targetModel);
|
|
1285
|
+
const targetPlan = target === undefined ? undefined : context.plans.get(target.name);
|
|
1286
|
+
if (target === undefined || targetPlan === undefined) {
|
|
1287
|
+
continue;
|
|
1288
|
+
}
|
|
1289
|
+
const label = `${model.name}.${relation.name}`;
|
|
1290
|
+
if (relation.kind === 'manyToMany') {
|
|
1291
|
+
const link = links.find((candidate) => candidate.relation === relation);
|
|
1292
|
+
if (link === undefined) {
|
|
1293
|
+
continue;
|
|
1294
|
+
}
|
|
1295
|
+
const defaultName = toCamelCase(pluralize(laravelSnake(ownerPlan.className)).value);
|
|
1296
|
+
const name = methodNameFor(context, targetPlan, label, relation.relatedName ?? defaultName);
|
|
1297
|
+
addMethod(targetPlan, 'BelongsToMany', ownerPlan.className, name, `$this->belongsToMany(${belongsToManyArgs(ownerPlan.className, link, true)})`);
|
|
1298
|
+
continue;
|
|
1299
|
+
}
|
|
1300
|
+
planInverseToOne(context, ownerPlan, targetPlan, relation, label);
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
function planInverseToOne(context, ownerPlan, targetPlan, relation, label) {
|
|
1304
|
+
const owning = ownerPlan.table.foreignKeys.find((candidate) => candidate.column === relation.columnName &&
|
|
1305
|
+
candidate.targetNode === targetPlan.model.name);
|
|
1306
|
+
if (owning === undefined) {
|
|
1307
|
+
return;
|
|
1308
|
+
}
|
|
1309
|
+
const isOne = relation.kind === 'oneToOne';
|
|
1310
|
+
const singular = toCamelCase(ownerPlan.className);
|
|
1311
|
+
const defaultName = isOne
|
|
1312
|
+
? singular
|
|
1313
|
+
: toCamelCase(pluralize(laravelSnake(ownerPlan.className)).value);
|
|
1314
|
+
let rawName = relation.relatedName ?? defaultName;
|
|
1315
|
+
if (relation.relatedName === undefined &&
|
|
1316
|
+
targetPlan.takenNames.has(rawName.toLowerCase())) {
|
|
1317
|
+
rawName = `${defaultName}As${toPascalCase(relation.name)}`;
|
|
1318
|
+
}
|
|
1319
|
+
const name = methodNameFor(context, targetPlan, label, rawName);
|
|
1320
|
+
const parentKeyName = targetPlan.key?.column ?? 'id';
|
|
1321
|
+
const conventionalColumn = `${laravelSnake(targetPlan.className)}_${parentKeyName}`;
|
|
1322
|
+
const localKeyDiffers = owning.referencedColumn !== parentKeyName;
|
|
1323
|
+
const args = [`${ownerPlan.className}::class`];
|
|
1324
|
+
if (relation.columnName !== conventionalColumn || localKeyDiffers) {
|
|
1325
|
+
args.push(quote(relation.columnName));
|
|
1326
|
+
}
|
|
1327
|
+
if (localKeyDiffers) {
|
|
1328
|
+
args.push(quote(owning.referencedColumn));
|
|
1329
|
+
}
|
|
1330
|
+
addMethod(targetPlan, isOne ? 'HasOne' : 'HasMany', ownerPlan.className, name, `$this->${isOne ? 'hasOne' : 'hasMany'}(${args.join(', ')})`);
|
|
1331
|
+
}
|
|
1332
|
+
/** Drops prismaOnlyWarnings lines for things this emitter does write (onUpdate actions, ULID defaults). */
|
|
1333
|
+
function removeHandledWarnings(context) {
|
|
1334
|
+
const handled = new Set();
|
|
1335
|
+
for (const plan of context.plans.values()) {
|
|
1336
|
+
for (const relation of plan.model.relations) {
|
|
1337
|
+
if (relation.onUpdate !== undefined) {
|
|
1338
|
+
handled.add(`${plan.model.name}.${relation.name}: the onUpdate action "${relation.onUpdate}" is not supported by this format and was ignored.`);
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
for (const field of plan.model.fields) {
|
|
1342
|
+
if (isUlidField(field)) {
|
|
1343
|
+
handled.add(`${plan.model.name}.${field.name}: the ${field.default?.kind === 'clientGenerated' ? field.default.generator : ''}() default is generated by Prisma Client and was dropped.`);
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
const kept = context.warnings.filter((warning) => !handled.has(warning));
|
|
1348
|
+
context.warnings.splice(0, context.warnings.length, ...kept);
|
|
1349
|
+
}
|
|
1350
|
+
// ---------------------------------------------------------------------------
|
|
1351
|
+
// Ordering
|
|
1352
|
+
// ---------------------------------------------------------------------------
|
|
1353
|
+
/**
|
|
1354
|
+
* Orders the tables so every foreign key points at a table created earlier.
|
|
1355
|
+
* When a cycle blocks progress, the table with the fewest unplaced
|
|
1356
|
+
* dependencies goes next and the foreign keys to tables not yet created are
|
|
1357
|
+
* moved to a later migration (`deferred`).
|
|
1358
|
+
*/
|
|
1359
|
+
function orderTables(tables) {
|
|
1360
|
+
const placed = new Set();
|
|
1361
|
+
const ordered = [];
|
|
1362
|
+
let remaining = [...tables];
|
|
1363
|
+
const unplacedDependencies = (table) => [
|
|
1364
|
+
...new Set(table.foreignKeys
|
|
1365
|
+
.map((key) => key.targetNode)
|
|
1366
|
+
.filter((node) => node !== table.node && !placed.has(node))),
|
|
1367
|
+
];
|
|
1368
|
+
while (remaining.length > 0) {
|
|
1369
|
+
let next = remaining.find((table) => unplacedDependencies(table).length === 0);
|
|
1370
|
+
if (next === undefined) {
|
|
1371
|
+
let best = Number.POSITIVE_INFINITY;
|
|
1372
|
+
for (const table of remaining) {
|
|
1373
|
+
const count = unplacedDependencies(table).length;
|
|
1374
|
+
if (count < best) {
|
|
1375
|
+
best = count;
|
|
1376
|
+
next = table;
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
if (next !== undefined) {
|
|
1380
|
+
for (const key of next.foreignKeys) {
|
|
1381
|
+
if (key.targetNode !== next.node && !placed.has(key.targetNode)) {
|
|
1382
|
+
key.deferred = true;
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
const chosen = next ?? remaining[0];
|
|
1388
|
+
if (chosen === undefined) {
|
|
1389
|
+
break;
|
|
1390
|
+
}
|
|
1391
|
+
placed.add(chosen.node);
|
|
1392
|
+
ordered.push(chosen);
|
|
1393
|
+
remaining = remaining.filter((table) => table !== chosen);
|
|
1394
|
+
}
|
|
1395
|
+
return ordered;
|
|
1396
|
+
}
|
|
1397
|
+
// ---------------------------------------------------------------------------
|
|
1398
|
+
// Rendering: migrations
|
|
1399
|
+
// ---------------------------------------------------------------------------
|
|
1400
|
+
function foreignKeyChain(key, inline) {
|
|
1401
|
+
const name = quote(key.column);
|
|
1402
|
+
let chain;
|
|
1403
|
+
switch (key.style) {
|
|
1404
|
+
case 'id':
|
|
1405
|
+
chain = { method: 'foreignId', args: [name], modifiers: [] };
|
|
1406
|
+
break;
|
|
1407
|
+
case 'uuid':
|
|
1408
|
+
chain = { method: 'foreignUuid', args: [name], modifiers: [] };
|
|
1409
|
+
break;
|
|
1410
|
+
case 'ulid':
|
|
1411
|
+
chain = { method: 'foreignUlid', args: [name], modifiers: [] };
|
|
1412
|
+
break;
|
|
1413
|
+
default:
|
|
1414
|
+
chain = plainReferenceChain(key);
|
|
1415
|
+
}
|
|
1416
|
+
if (key.nullable) {
|
|
1417
|
+
chain.modifiers.push('nullable()');
|
|
1418
|
+
}
|
|
1419
|
+
if (key.unique) {
|
|
1420
|
+
chain.modifiers.push('unique()');
|
|
1421
|
+
}
|
|
1422
|
+
if (key.isKey) {
|
|
1423
|
+
chain.modifiers.push('primary()');
|
|
1424
|
+
}
|
|
1425
|
+
if (inline && key.style !== 'plain') {
|
|
1426
|
+
chain.modifiers.push(key.referencedColumn === 'id'
|
|
1427
|
+
? `constrained(${quote(key.referencedTable)})`
|
|
1428
|
+
: `constrained(${quote(key.referencedTable)}, ${quote(key.referencedColumn)})`, ...onDeleteModifiers(key.onDelete), ...onUpdateModifiers(key.onUpdate));
|
|
1429
|
+
}
|
|
1430
|
+
return chain;
|
|
1431
|
+
}
|
|
1432
|
+
/** Column declaration for a foreign key whose referenced column is neither an auto-increment id nor a UUID. */
|
|
1433
|
+
function plainReferenceChain(key) {
|
|
1434
|
+
const name = quote(key.column);
|
|
1435
|
+
const columnType = key.columnType ?? {
|
|
1436
|
+
method: 'string',
|
|
1437
|
+
args: [],
|
|
1438
|
+
};
|
|
1439
|
+
return {
|
|
1440
|
+
method: columnType.method,
|
|
1441
|
+
args: [name, ...columnType.args],
|
|
1442
|
+
modifiers: [],
|
|
1443
|
+
};
|
|
1444
|
+
}
|
|
1445
|
+
function foreignStatement(key) {
|
|
1446
|
+
const modifiers = [
|
|
1447
|
+
...onDeleteModifiers(key.onDelete),
|
|
1448
|
+
...onUpdateModifiers(key.onUpdate),
|
|
1449
|
+
];
|
|
1450
|
+
return `$table->foreign(${quote(key.column)})->references(${quote(key.referencedColumn)})->on(${quote(key.referencedTable)})${modifiers
|
|
1451
|
+
.map((modifier) => `->${modifier}`)
|
|
1452
|
+
.join('')}`;
|
|
1453
|
+
}
|
|
1454
|
+
function sanitizeFileSegment(table) {
|
|
1455
|
+
const cleaned = table.replace(/[^A-Za-z0-9_]+/g, '_');
|
|
1456
|
+
return cleaned.replace(/^_+|_+$/g, '') || 'table';
|
|
1457
|
+
}
|
|
1458
|
+
function renderMigrationFile(imports, up, down) {
|
|
1459
|
+
const uses = [
|
|
1460
|
+
...new Set([
|
|
1461
|
+
'Illuminate\\Database\\Migrations\\Migration',
|
|
1462
|
+
'Illuminate\\Database\\Schema\\Blueprint',
|
|
1463
|
+
'Illuminate\\Support\\Facades\\Schema',
|
|
1464
|
+
...imports,
|
|
1465
|
+
]),
|
|
1466
|
+
].sort();
|
|
1467
|
+
return [
|
|
1468
|
+
'<?php',
|
|
1469
|
+
'',
|
|
1470
|
+
'declare(strict_types=1);',
|
|
1471
|
+
'',
|
|
1472
|
+
...uses.map((name) => `use ${name};`),
|
|
1473
|
+
'',
|
|
1474
|
+
'return new class extends Migration',
|
|
1475
|
+
'{',
|
|
1476
|
+
`${INDENT}public function up(): void`,
|
|
1477
|
+
`${INDENT}{`,
|
|
1478
|
+
...up,
|
|
1479
|
+
`${INDENT}}`,
|
|
1480
|
+
'',
|
|
1481
|
+
`${INDENT}public function down(): void`,
|
|
1482
|
+
`${INDENT}{`,
|
|
1483
|
+
...down,
|
|
1484
|
+
`${INDENT}}`,
|
|
1485
|
+
'};',
|
|
1486
|
+
'',
|
|
1487
|
+
].join('\n');
|
|
1488
|
+
}
|
|
1489
|
+
function renderCreate(table) {
|
|
1490
|
+
const statements = [];
|
|
1491
|
+
const push = (text) => {
|
|
1492
|
+
statements.push(...renderStatement(text, 3));
|
|
1493
|
+
};
|
|
1494
|
+
if (table.primary !== undefined) {
|
|
1495
|
+
push(chainText(table.primary));
|
|
1496
|
+
}
|
|
1497
|
+
for (const chain of table.columns) {
|
|
1498
|
+
push(chainText(chain));
|
|
1499
|
+
}
|
|
1500
|
+
for (const key of table.foreignKeys) {
|
|
1501
|
+
push(chainText(foreignKeyChain(key, !key.deferred)));
|
|
1502
|
+
}
|
|
1503
|
+
for (const chain of table.tail) {
|
|
1504
|
+
push(chainText(chain));
|
|
1505
|
+
}
|
|
1506
|
+
for (const key of table.foreignKeys) {
|
|
1507
|
+
if (key.style === 'plain' && !key.deferred) {
|
|
1508
|
+
push(foreignStatement(key));
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
for (const constraint of table.constraints) {
|
|
1512
|
+
push(constraint);
|
|
1513
|
+
}
|
|
1514
|
+
const up = [
|
|
1515
|
+
`${INDENT}${INDENT}Schema::create(${quote(table.table)}, function (Blueprint $table): void {`,
|
|
1516
|
+
...statements,
|
|
1517
|
+
`${INDENT}${INDENT}});`,
|
|
1518
|
+
];
|
|
1519
|
+
const down = [
|
|
1520
|
+
`${INDENT}${INDENT}Schema::dropIfExists(${quote(table.table)});`,
|
|
1521
|
+
];
|
|
1522
|
+
return renderMigrationFile(table.imports, up, down);
|
|
1523
|
+
}
|
|
1524
|
+
function renderForeignKeyMigration(table, keys) {
|
|
1525
|
+
const up = [
|
|
1526
|
+
`${INDENT}${INDENT}Schema::table(${quote(table.table)}, function (Blueprint $table): void {`,
|
|
1527
|
+
...keys.flatMap((key) => renderStatement(foreignStatement(key), 3)),
|
|
1528
|
+
`${INDENT}${INDENT}});`,
|
|
1529
|
+
];
|
|
1530
|
+
const down = [
|
|
1531
|
+
`${INDENT}${INDENT}Schema::table(${quote(table.table)}, function (Blueprint $table): void {`,
|
|
1532
|
+
...keys.flatMap((key) => renderStatement(`$table->dropForeign([${quote(key.column)}])`, 3)),
|
|
1533
|
+
`${INDENT}${INDENT}});`,
|
|
1534
|
+
];
|
|
1535
|
+
return renderMigrationFile(new Set(), up, down);
|
|
1536
|
+
}
|
|
1537
|
+
function renderMigrations(context) {
|
|
1538
|
+
const tables = [
|
|
1539
|
+
...context.schema.models.flatMap((model) => {
|
|
1540
|
+
const plan = context.plans.get(model.name);
|
|
1541
|
+
return plan === undefined ? [] : [plan.table];
|
|
1542
|
+
}),
|
|
1543
|
+
...context.pivots,
|
|
1544
|
+
];
|
|
1545
|
+
const ordered = orderTables(tables);
|
|
1546
|
+
const files = {};
|
|
1547
|
+
let counter = 0;
|
|
1548
|
+
const filename = (name) => {
|
|
1549
|
+
counter += 1;
|
|
1550
|
+
return `${MIGRATION_DIRECTORY}/${MIGRATION_DATE_PREFIX}_${String(counter).padStart(6, '0')}_${name}.php`;
|
|
1551
|
+
};
|
|
1552
|
+
for (const table of ordered) {
|
|
1553
|
+
files[filename(`create_${sanitizeFileSegment(table.table)}_table`)] =
|
|
1554
|
+
renderCreate(table);
|
|
1555
|
+
}
|
|
1556
|
+
for (const table of ordered) {
|
|
1557
|
+
const deferred = table.foreignKeys.filter((key) => key.deferred);
|
|
1558
|
+
if (deferred.length > 0) {
|
|
1559
|
+
files[filename(`add_foreign_keys_to_${sanitizeFileSegment(table.table)}_table`)] = renderForeignKeyMigration(table, deferred);
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
return files;
|
|
1563
|
+
}
|
|
1564
|
+
// ---------------------------------------------------------------------------
|
|
1565
|
+
// Rendering: enums and models
|
|
1566
|
+
// ---------------------------------------------------------------------------
|
|
1567
|
+
function renderEnum(context, enumDefinition) {
|
|
1568
|
+
const name = context.enumNames.get(enumDefinition.name) ?? '';
|
|
1569
|
+
const used = new Set();
|
|
1570
|
+
const cases = enumDefinition.values.map((value) => {
|
|
1571
|
+
const caseName = uniqueName(enumCaseName(context, value), used, false);
|
|
1572
|
+
return `${INDENT}case ${caseName} = ${quote(value.dbValue)};`;
|
|
1573
|
+
});
|
|
1574
|
+
return [
|
|
1575
|
+
'<?php',
|
|
1576
|
+
'',
|
|
1577
|
+
'declare(strict_types=1);',
|
|
1578
|
+
'',
|
|
1579
|
+
`namespace ${context.enumNamespace};`,
|
|
1580
|
+
'',
|
|
1581
|
+
`enum ${name}: string`,
|
|
1582
|
+
'{',
|
|
1583
|
+
...cases,
|
|
1584
|
+
'}',
|
|
1585
|
+
'',
|
|
1586
|
+
].join('\n');
|
|
1587
|
+
}
|
|
1588
|
+
function renderModel(context, plan) {
|
|
1589
|
+
const model = plan.model;
|
|
1590
|
+
const traits = [];
|
|
1591
|
+
if (plan.hasSoftDeletes) {
|
|
1592
|
+
traits.push('SoftDeletes');
|
|
1593
|
+
}
|
|
1594
|
+
if (plan.usesUlid) {
|
|
1595
|
+
traits.push('HasUlids');
|
|
1596
|
+
plan.imports.add('Illuminate\\Database\\Eloquent\\Concerns\\HasUlids');
|
|
1597
|
+
}
|
|
1598
|
+
else if (plan.usesUuid) {
|
|
1599
|
+
traits.push('HasUuids');
|
|
1600
|
+
plan.imports.add('Illuminate\\Database\\Eloquent\\Concerns\\HasUuids');
|
|
1601
|
+
}
|
|
1602
|
+
const blocks = [];
|
|
1603
|
+
if (traits.length > 0) {
|
|
1604
|
+
blocks.push([`${INDENT}use ${traits.join(', ')};`]);
|
|
1605
|
+
}
|
|
1606
|
+
const conventional = conventionalTable(plan.className);
|
|
1607
|
+
if (!(conventional.certain && conventional.value === model.tableName)) {
|
|
1608
|
+
blocks.push([`${INDENT}protected $table = ${quote(model.tableName)};`]);
|
|
1609
|
+
}
|
|
1610
|
+
const key = plan.key;
|
|
1611
|
+
const compositeFirst = plan.compositeKey?.[0];
|
|
1612
|
+
if (key === undefined && compositeFirst === undefined) {
|
|
1613
|
+
blocks.push([`${INDENT}protected $primaryKey = null;`]);
|
|
1614
|
+
blocks.push([`${INDENT}public $incrementing = false;`]);
|
|
1615
|
+
}
|
|
1616
|
+
else if (key === undefined) {
|
|
1617
|
+
blocks.push([
|
|
1618
|
+
`${INDENT}protected $primaryKey = ${quote(compositeFirst ?? 'id')};`,
|
|
1619
|
+
]);
|
|
1620
|
+
blocks.push([`${INDENT}public $incrementing = false;`]);
|
|
1621
|
+
}
|
|
1622
|
+
else {
|
|
1623
|
+
if (key.column !== 'id') {
|
|
1624
|
+
blocks.push([`${INDENT}protected $primaryKey = ${quote(key.column)};`]);
|
|
1625
|
+
}
|
|
1626
|
+
if (key.kind !== 'increments' && !plan.keyGenerated) {
|
|
1627
|
+
const stringKey = key.kind === 'uuid' || key.kind === 'ulid' || isStringKey(key);
|
|
1628
|
+
if (stringKey) {
|
|
1629
|
+
blocks.push([`${INDENT}protected $keyType = 'string';`]);
|
|
1630
|
+
}
|
|
1631
|
+
blocks.push([`${INDENT}public $incrementing = false;`]);
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
if (!plan.hasTimestamps) {
|
|
1635
|
+
blocks.push([`${INDENT}public $timestamps = false;`]);
|
|
1636
|
+
}
|
|
1637
|
+
blocks.push([
|
|
1638
|
+
`${INDENT}/** @var list<string> */`,
|
|
1639
|
+
...listProperty('fillable', plan.fillable.map(quote)),
|
|
1640
|
+
]);
|
|
1641
|
+
if (plan.casts.length > 0) {
|
|
1642
|
+
blocks.push([
|
|
1643
|
+
`${INDENT}/** @var array<string, string> */`,
|
|
1644
|
+
`${INDENT}protected $casts = [`,
|
|
1645
|
+
...plan.casts.map(([column, cast]) => `${INDENT}${INDENT}${quote(column)} => ${cast},`),
|
|
1646
|
+
`${INDENT}];`,
|
|
1647
|
+
]);
|
|
1648
|
+
}
|
|
1649
|
+
if (plan.usesUuid && plan.uuidColumns.length > 0) {
|
|
1650
|
+
const columns = [
|
|
1651
|
+
...(key?.kind === 'uuid' ? [key.column] : []),
|
|
1652
|
+
...plan.uuidColumns,
|
|
1653
|
+
];
|
|
1654
|
+
blocks.push([
|
|
1655
|
+
`${INDENT}/** @return list<string> */`,
|
|
1656
|
+
`${INDENT}public function uniqueIds(): array`,
|
|
1657
|
+
`${INDENT}{`,
|
|
1658
|
+
`${INDENT}${INDENT}return [${columns.map(quote).join(', ')}];`,
|
|
1659
|
+
`${INDENT}}`,
|
|
1660
|
+
]);
|
|
1661
|
+
}
|
|
1662
|
+
blocks.push(...plan.methods);
|
|
1663
|
+
const imports = [...plan.imports].sort();
|
|
1664
|
+
const namespaceLine = `namespace ${context.modelNamespace};`;
|
|
1665
|
+
return [
|
|
1666
|
+
'<?php',
|
|
1667
|
+
'',
|
|
1668
|
+
'declare(strict_types=1);',
|
|
1669
|
+
'',
|
|
1670
|
+
namespaceLine,
|
|
1671
|
+
'',
|
|
1672
|
+
...imports.map((name) => `use ${name};`),
|
|
1673
|
+
...(imports.length > 0 ? [''] : []),
|
|
1674
|
+
`class ${plan.className} extends Model`,
|
|
1675
|
+
'{',
|
|
1676
|
+
blocks.map((block) => block.join('\n')).join('\n\n'),
|
|
1677
|
+
'}',
|
|
1678
|
+
'',
|
|
1679
|
+
].join('\n');
|
|
1680
|
+
}
|
|
1681
|
+
function isStringKey(key) {
|
|
1682
|
+
const type = key.field?.type;
|
|
1683
|
+
return type === 'string' || type === 'text' || type === 'uuid';
|
|
1684
|
+
}
|
|
1685
|
+
function listProperty(name, items) {
|
|
1686
|
+
if (items.length === 0) {
|
|
1687
|
+
return [`${INDENT}protected $${name} = [];`];
|
|
1688
|
+
}
|
|
1689
|
+
return [
|
|
1690
|
+
`${INDENT}protected $${name} = [`,
|
|
1691
|
+
...items.map((item) => `${INDENT}${INDENT}${item},`),
|
|
1692
|
+
`${INDENT}];`,
|
|
1693
|
+
];
|
|
1694
|
+
}
|
|
1695
|
+
//# sourceMappingURL=laravel.js.map
|