ormbridge 0.0.0-stage → 0.2.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.
Files changed (84) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +408 -2
  3. package/dist/cli.d.ts +2 -0
  4. package/dist/cli.js +20 -0
  5. package/dist/cli.js.map +1 -0
  6. package/dist/cliRunner.d.ts +20 -0
  7. package/dist/cliRunner.js +343 -0
  8. package/dist/cliRunner.js.map +1 -0
  9. package/dist/config.d.ts +40 -0
  10. package/dist/config.js +204 -0
  11. package/dist/config.js.map +1 -0
  12. package/dist/convert.d.ts +20 -0
  13. package/dist/convert.js +61 -0
  14. package/dist/convert.js.map +1 -0
  15. package/dist/emitters/django.d.ts +4 -0
  16. package/dist/emitters/django.js +501 -0
  17. package/dist/emitters/django.js.map +1 -0
  18. package/dist/emitters/doctrine.d.ts +20 -0
  19. package/dist/emitters/doctrine.js +1134 -0
  20. package/dist/emitters/doctrine.js.map +1 -0
  21. package/dist/emitters/graphene.d.ts +4 -0
  22. package/dist/emitters/graphene.js +431 -0
  23. package/dist/emitters/graphene.js.map +1 -0
  24. package/dist/emitters/laravel.d.ts +33 -0
  25. package/dist/emitters/laravel.js +1695 -0
  26. package/dist/emitters/laravel.js.map +1 -0
  27. package/dist/emitters/prisma.d.ts +21 -0
  28. package/dist/emitters/prisma.js +1058 -0
  29. package/dist/emitters/prisma.js.map +1 -0
  30. package/dist/emitters/typeorm.d.ts +9 -0
  31. package/dist/emitters/typeorm.js +833 -0
  32. package/dist/emitters/typeorm.js.map +1 -0
  33. package/dist/emitters/typescriptInterfaces.d.ts +15 -0
  34. package/dist/emitters/typescriptInterfaces.js +350 -0
  35. package/dist/emitters/typescriptInterfaces.js.map +1 -0
  36. package/dist/formats.d.ts +80 -0
  37. package/dist/formats.js +212 -0
  38. package/dist/formats.js.map +1 -0
  39. package/dist/index.d.ts +33 -0
  40. package/dist/index.js +18 -0
  41. package/dist/index.js.map +1 -0
  42. package/dist/io.d.ts +48 -0
  43. package/dist/io.js +328 -0
  44. package/dist/io.js.map +1 -0
  45. package/dist/ir.d.ts +197 -0
  46. package/dist/ir.js +10 -0
  47. package/dist/ir.js.map +1 -0
  48. package/dist/naming.d.ts +11 -0
  49. package/dist/naming.js +88 -0
  50. package/dist/naming.js.map +1 -0
  51. package/dist/parsers/django.d.ts +14 -0
  52. package/dist/parsers/django.js +1161 -0
  53. package/dist/parsers/django.js.map +1 -0
  54. package/dist/parsers/doctrine.d.ts +12 -0
  55. package/dist/parsers/doctrine.js +1782 -0
  56. package/dist/parsers/doctrine.js.map +1 -0
  57. package/dist/parsers/laravel.d.ts +17 -0
  58. package/dist/parsers/laravel.js +2824 -0
  59. package/dist/parsers/laravel.js.map +1 -0
  60. package/dist/parsers/phpSyntax.d.ts +124 -0
  61. package/dist/parsers/phpSyntax.js +429 -0
  62. package/dist/parsers/phpSyntax.js.map +1 -0
  63. package/dist/parsers/prisma.d.ts +12 -0
  64. package/dist/parsers/prisma.js +964 -0
  65. package/dist/parsers/prisma.js.map +1 -0
  66. package/dist/parsers/pythonSyntax.d.ts +38 -0
  67. package/dist/parsers/pythonSyntax.js +120 -0
  68. package/dist/parsers/pythonSyntax.js.map +1 -0
  69. package/dist/parsers/typeorm.d.ts +12 -0
  70. package/dist/parsers/typeorm.js +1614 -0
  71. package/dist/parsers/typeorm.js.map +1 -0
  72. package/dist/parsers/typescriptSyntax.d.ts +57 -0
  73. package/dist/parsers/typescriptSyntax.js +200 -0
  74. package/dist/parsers/typescriptSyntax.js.map +1 -0
  75. package/dist/prismaOnlyConstructs.d.ts +7 -0
  76. package/dist/prismaOnlyConstructs.js +76 -0
  77. package/dist/prismaOnlyConstructs.js.map +1 -0
  78. package/dist/result.d.ts +20 -0
  79. package/dist/result.js +18 -0
  80. package/dist/result.js.map +1 -0
  81. package/dist/transforms.d.ts +13 -0
  82. package/dist/transforms.js +142 -0
  83. package/dist/transforms.js.map +1 -0
  84. package/package.json +83 -4
@@ -0,0 +1,1134 @@
1
+ import { prismaOnlyWarnings } from '../prismaOnlyConstructs.js';
2
+ import { findModel } from '../ir.js';
3
+ import { singularize, toCamelCase, toPascalCase, toSnakeCase, } from '../naming.js';
4
+ export const DEFAULT_DOCTRINE_NAMESPACE = 'App\\Entity';
5
+ const NAMESPACE_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*(\\[A-Za-z_][A-Za-z0-9_]*)*$/;
6
+ const IDENTIFIER_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
7
+ const MAX_LINE_WIDTH = 100;
8
+ const INDENT = ' ';
9
+ /** True when `value` is a valid PHP namespace such as `App\Entity` (no leading or trailing backslash). */
10
+ export function isValidPhpNamespace(value) {
11
+ return NAMESPACE_PATTERN.test(value);
12
+ }
13
+ const RESERVED_CLASS_NAMES = new Set([
14
+ 'abstract and array as break callable case catch',
15
+ 'class clone const continue declare default do echo',
16
+ 'else elseif empty enddeclare endfor endforeach endif endswitch',
17
+ 'endwhile enum eval exit extends final finally fn',
18
+ 'for foreach function global goto if implements include',
19
+ 'instanceof insteadof interface isset list match namespace new',
20
+ 'or print private protected public readonly require return',
21
+ 'static switch throw trait try unset use var',
22
+ 'while xor yield bool false float int iterable',
23
+ 'mixed never null object parent self string true',
24
+ 'void arraycollection collection dateinterval datetimeimmutable orm',
25
+ ]
26
+ .join(' ')
27
+ .split(' ')
28
+ .map((name) => name.toLowerCase()));
29
+ const ON_DELETE_NAMES = {
30
+ cascade: 'CASCADE',
31
+ setNull: 'SET NULL',
32
+ restrict: 'RESTRICT',
33
+ noAction: 'NO ACTION',
34
+ setDefault: 'SET DEFAULT',
35
+ };
36
+ export function emitDoctrine(schema, options) {
37
+ const warnings = prismaOnlyWarnings(schema);
38
+ let entityNamespace = DEFAULT_DOCTRINE_NAMESPACE;
39
+ if (options.namespace !== undefined) {
40
+ const requested = options.namespace.replace(/^\\+|\\+$/g, '');
41
+ if (isValidPhpNamespace(requested)) {
42
+ entityNamespace = requested;
43
+ }
44
+ else {
45
+ warnings.push(`The namespace "${options.namespace}" is not a valid PHP namespace; "${DEFAULT_DOCTRINE_NAMESPACE}" was used instead.`);
46
+ }
47
+ }
48
+ const context = {
49
+ schema,
50
+ options,
51
+ dialect: dialectOf(options.provider),
52
+ warnings,
53
+ entityNamespace,
54
+ enumNamespace: siblingEnumNamespace(entityNamespace),
55
+ classNames: new Map(),
56
+ enumNames: new Map(),
57
+ classes: new Map(),
58
+ };
59
+ if (options.provider === 'mongodb') {
60
+ warnings.push('Provider "mongodb": Doctrine ORM is relational (use Doctrine MongoDB ODM for MongoDB); relational entity mappings were written.');
61
+ }
62
+ allocateClassNames(context);
63
+ for (const model of schema.models) {
64
+ planClass(context, model);
65
+ }
66
+ for (const model of schema.models) {
67
+ planInverseSides(context, model);
68
+ }
69
+ const files = {};
70
+ for (const enumDefinition of schema.enums) {
71
+ files[enumPath(context, enumDefinition)] = renderEnum(context, enumDefinition);
72
+ }
73
+ for (const model of schema.models) {
74
+ const plan = context.classes.get(model.name);
75
+ if (plan !== undefined) {
76
+ files[entityPath(context, plan)] = renderClass(context, plan);
77
+ }
78
+ }
79
+ return { files, warnings };
80
+ }
81
+ // ---------------------------------------------------------------------------
82
+ // Namespaces and paths
83
+ // ---------------------------------------------------------------------------
84
+ function siblingEnumNamespace(entityNamespace) {
85
+ const segments = entityNamespace.split('\\');
86
+ if (segments.length > 1 && segments[segments.length - 1] === 'Entity') {
87
+ segments[segments.length - 1] = 'Enum';
88
+ return segments.join('\\');
89
+ }
90
+ return `${entityNamespace}\\Enum`;
91
+ }
92
+ /** PSR-4 directory for a namespace: the first segment maps to `src/`, the rest to folders. */
93
+ function namespaceDirectory(namespace) {
94
+ const segments = namespace.split('\\').slice(1);
95
+ return ['src', ...segments].join('/');
96
+ }
97
+ function entityPath(context, plan) {
98
+ return `${namespaceDirectory(context.entityNamespace)}/${plan.className}.php`;
99
+ }
100
+ function enumPath(context, enumDefinition) {
101
+ const name = context.enumNames.get(enumDefinition.name) ?? '';
102
+ return `${namespaceDirectory(context.enumNamespace)}/${name}.php`;
103
+ }
104
+ // ---------------------------------------------------------------------------
105
+ // Small helpers
106
+ // ---------------------------------------------------------------------------
107
+ function dialectOf(provider) {
108
+ switch (provider) {
109
+ case 'mysql':
110
+ return 'mysql';
111
+ case 'sqlite':
112
+ return 'sqlite';
113
+ case 'sqlserver':
114
+ return 'sqlserver';
115
+ default:
116
+ return 'postgres';
117
+ }
118
+ }
119
+ /** Single-quoted PHP string. */
120
+ function quote(value) {
121
+ return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
122
+ }
123
+ /** Words that are reserved on at least one of PostgreSQL, MySQL, SQL Server and SQLite. */
124
+ const RESERVED_SQL_NAMES = new Set([
125
+ 'all alter and any as asc between by',
126
+ 'case check column constraint create cross current_date current_time',
127
+ 'current_timestamp current_user default delete desc distinct drop else',
128
+ 'end exists false for foreign from full grant',
129
+ 'group having in index inner insert into is',
130
+ 'join key left like limit not null offset',
131
+ 'on or order outer primary references right select',
132
+ 'session_user set table then to true union unique',
133
+ 'update user using values when where with',
134
+ ]
135
+ .join(' ')
136
+ .split(' '));
137
+ /**
138
+ * Quotes a database identifier with backticks (Doctrine's portable quoting)
139
+ * when it is a reserved SQL word such as a table named "user" or "order".
140
+ */
141
+ function sqlIdentifier(name) {
142
+ return RESERVED_SQL_NAMES.has(name.toLowerCase()) ? `\`${name}\`` : name;
143
+ }
144
+ /** A database identifier as a PHP string literal. */
145
+ function dbName(name) {
146
+ return quote(sqlIdentifier(name));
147
+ }
148
+ function uniqueName(baseName, used, caseInsensitive = false) {
149
+ const taken = (candidate) => used.has(caseInsensitive ? candidate.toLowerCase() : candidate);
150
+ let candidate = baseName;
151
+ let suffix = 2;
152
+ while (taken(candidate)) {
153
+ candidate = `${baseName}${suffix}`;
154
+ suffix += 1;
155
+ }
156
+ used.add(caseInsensitive ? candidate.toLowerCase() : candidate);
157
+ return candidate;
158
+ }
159
+ /** Makes a raw name a valid PHP identifier, warning when it had to change. */
160
+ function identifier(context, owner, raw) {
161
+ if (IDENTIFIER_PATTERN.test(raw)) {
162
+ return raw;
163
+ }
164
+ const replaced = raw.replace(/[^A-Za-z0-9_]/g, '_');
165
+ const safe = /^[0-9]/.test(replaced) ? `_${replaced}` : replaced;
166
+ const result = safe === '' ? '_' : safe;
167
+ context.warnings.push(`${owner}: "${raw}" is not a valid PHP identifier; it was written as "${result}".`);
168
+ return result;
169
+ }
170
+ function propertyName(context, rawName) {
171
+ return context.options.camelFields ? toCamelCase(rawName) : rawName;
172
+ }
173
+ function isKeyedRelation(model, relation) {
174
+ return (relation.isPrimaryKey === true ||
175
+ (model.compositePrimaryKey?.includes(relation.name) ?? false));
176
+ }
177
+ /**
178
+ * Formats an attribute. Arguments stay on one line when they fit and wrap one
179
+ * per line otherwise.
180
+ */
181
+ function attribute(name, args) {
182
+ const inline = `#[ORM\\${name}${args.length === 0 ? '' : `(${args.join(', ')})`}]`;
183
+ if (args.length === 0 || INDENT.length + inline.length <= MAX_LINE_WIDTH) {
184
+ return [inline];
185
+ }
186
+ return [
187
+ `#[ORM\\${name}(`,
188
+ ...args.map((arg) => `${INDENT}${arg},`),
189
+ ')]',
190
+ ];
191
+ }
192
+ function methodWords(propName) {
193
+ const pascal = toPascalCase(propName);
194
+ return pascal === '' ? 'Value' : pascal;
195
+ }
196
+ function parameterName(propName) {
197
+ const camel = toCamelCase(propName);
198
+ if (!IDENTIFIER_PATTERN.test(camel) || camel === 'this') {
199
+ return 'value';
200
+ }
201
+ return camel;
202
+ }
203
+ function phpFloat(value) {
204
+ const text = String(value);
205
+ return /[.eE]/.test(text) ? text : `${text}.0`;
206
+ }
207
+ /** Renders a parsed JSON value as a PHP literal (short array syntax). */
208
+ function phpLiteral(value) {
209
+ if (value === null || value === undefined) {
210
+ return 'null';
211
+ }
212
+ if (typeof value === 'boolean') {
213
+ return value ? 'true' : 'false';
214
+ }
215
+ if (typeof value === 'number') {
216
+ return Number.isInteger(value) ? String(value) : phpFloat(value);
217
+ }
218
+ if (typeof value === 'string') {
219
+ return quote(value);
220
+ }
221
+ if (Array.isArray(value)) {
222
+ return `[${value.map(phpLiteral).join(', ')}]`;
223
+ }
224
+ if (typeof value === 'object') {
225
+ const entries = Object.entries(value).map(([key, entry]) => `${quote(key)} => ${phpLiteral(entry)}`);
226
+ return `[${entries.join(', ')}]`;
227
+ }
228
+ return 'null';
229
+ }
230
+ // ---------------------------------------------------------------------------
231
+ // Names
232
+ // ---------------------------------------------------------------------------
233
+ function allocateClassNames(context) {
234
+ const used = new Set();
235
+ for (const model of context.schema.models) {
236
+ let base = model.name;
237
+ if (!IDENTIFIER_PATTERN.test(base)) {
238
+ base = identifier(context, model.name, toPascalCase(model.name));
239
+ }
240
+ if (RESERVED_CLASS_NAMES.has(base.toLowerCase())) {
241
+ const renamed = `${base}Entity`;
242
+ context.warnings.push(`${model.name}: "${base}" cannot be used as a PHP class name; the class was named "${renamed}".`);
243
+ base = renamed;
244
+ }
245
+ context.classNames.set(model.name, uniqueName(base, used, true));
246
+ }
247
+ for (const enumDefinition of context.schema.enums) {
248
+ let base = enumDefinition.name;
249
+ if (!IDENTIFIER_PATTERN.test(base)) {
250
+ base = identifier(context, `enum ${enumDefinition.name}`, toPascalCase(enumDefinition.name));
251
+ }
252
+ if (RESERVED_CLASS_NAMES.has(base.toLowerCase())) {
253
+ const renamed = `${base}Enum`;
254
+ context.warnings.push(`enum ${enumDefinition.name}: "${base}" cannot be used as a PHP class name; the enum was named "${renamed}".`);
255
+ base = renamed;
256
+ }
257
+ context.enumNames.set(enumDefinition.name, uniqueName(base, new Set(), true));
258
+ }
259
+ }
260
+ function classNameOf(context, modelName) {
261
+ return context.classNames.get(modelName) ?? modelName;
262
+ }
263
+ /** Local name of an enum inside an entity file; aliased when an entity has the same name. */
264
+ function enumLocalName(context, enumName) {
265
+ const phpName = context.enumNames.get(enumName) ?? enumName;
266
+ const clashes = [...context.classNames.values()].some((className) => className.toLowerCase() === phpName.toLowerCase());
267
+ return clashes ? `${phpName}Enum` : phpName;
268
+ }
269
+ function useStatementFor(context, localName) {
270
+ for (const [irName, phpName] of context.enumNames) {
271
+ const alias = enumLocalName(context, irName);
272
+ if (alias === localName) {
273
+ return alias === phpName
274
+ ? `${context.enumNamespace}\\${phpName}`
275
+ : `${context.enumNamespace}\\${phpName} as ${alias}`;
276
+ }
277
+ }
278
+ return localName;
279
+ }
280
+ // ---------------------------------------------------------------------------
281
+ // Column types
282
+ // ---------------------------------------------------------------------------
283
+ function findEnum(context, name) {
284
+ return context.schema.enums.find((candidate) => candidate.name === name);
285
+ }
286
+ function plain(type, phpType, valueKind, extra = [], imports = []) {
287
+ return {
288
+ entries: [`type: ${quote(type)}`, ...extra],
289
+ phpType,
290
+ valueKind,
291
+ imports,
292
+ };
293
+ }
294
+ function columnSpecOf(context, label, field) {
295
+ if ((field.arrayDepth ?? 0) > 0) {
296
+ context.warnings.push(`${label}: Doctrine has no array column type that works on every platform; the array was written as a json column (PHP array).`);
297
+ return plain('json', 'array', 'array');
298
+ }
299
+ return scalarColumnSpec(context, label, field);
300
+ }
301
+ function scalarColumnSpec(context, label, field) {
302
+ const dialect = context.dialect;
303
+ const lengthEntry = field.maxLength === undefined ? [] : [`length: ${field.maxLength}`];
304
+ if (field.enumName !== undefined) {
305
+ const enumDefinition = findEnum(context, field.enumName);
306
+ if (enumDefinition === undefined) {
307
+ context.warnings.push(`${label}: enum "${field.enumName}" does not exist in the schema; the column is written as a plain string.`);
308
+ }
309
+ else {
310
+ const local = enumLocalName(context, enumDefinition.name);
311
+ return {
312
+ entries: [
313
+ `type: 'string'`,
314
+ ...lengthEntry,
315
+ `enumType: ${local}::class`,
316
+ ],
317
+ phpType: local,
318
+ valueKind: 'enum',
319
+ imports: [local],
320
+ enumClass: local,
321
+ enumDefinition,
322
+ };
323
+ }
324
+ }
325
+ switch (field.type) {
326
+ case 'string':
327
+ return plain('string', 'string', 'string', lengthEntry);
328
+ case 'text':
329
+ return plain('text', 'string', 'string');
330
+ case 'uuid':
331
+ return plain('guid', 'string', 'string');
332
+ case 'int':
333
+ return plain('integer', 'int', 'int');
334
+ case 'bigInt':
335
+ return plain('bigint', 'string', 'string');
336
+ case 'float':
337
+ return plain('float', 'float', 'float');
338
+ case 'decimal': {
339
+ const extra = [];
340
+ if (field.maxDigits !== undefined && field.decimalPlaces !== undefined) {
341
+ extra.push(`precision: ${field.maxDigits}`, `scale: ${field.decimalPlaces}`);
342
+ }
343
+ return plain('decimal', 'string', 'string', extra);
344
+ }
345
+ case 'boolean':
346
+ return plain('boolean', 'bool', 'bool');
347
+ case 'dateTime':
348
+ return plain(dialect === 'postgres' || dialect === 'sqlserver'
349
+ ? 'datetimetz_immutable'
350
+ : 'datetime_immutable', 'DateTimeImmutable', 'date', [], ['DateTimeImmutable']);
351
+ case 'date':
352
+ return plain('date_immutable', 'DateTimeImmutable', 'date', [], ['DateTimeImmutable']);
353
+ case 'time':
354
+ return plain('time_immutable', 'DateTimeImmutable', 'date', [], ['DateTimeImmutable']);
355
+ case 'json':
356
+ return plain('json', 'mixed', 'json');
357
+ case 'bytes':
358
+ return plain('blob', 'mixed', 'other');
359
+ case 'duration':
360
+ context.warnings.push(`${label}: Doctrine has no database interval type; the duration was written as a dateinterval column (an ISO 8601 string such as P1DT2H), so the stored values differ from the source column.`);
361
+ return plain('dateinterval', 'DateInterval', 'other', [], ['DateInterval']);
362
+ case 'ipAddress':
363
+ return plain('string', 'string', 'string', ['length: 45']);
364
+ case 'hstore':
365
+ context.warnings.push(`${label}: Doctrine has no hstore type; the field was written as a json column (PHP array).`);
366
+ return plain('json', 'array', 'array');
367
+ case 'range':
368
+ context.warnings.push(`${label}: Doctrine has no range column type; the field was written as a string column holding the range text.`);
369
+ return plain('string', 'string', 'string');
370
+ case 'unsupported':
371
+ return plain('string', 'string', 'string');
372
+ default:
373
+ context.warnings.push(`${label}: unknown field type "${String(field.type)}"; it was written as a string column.`);
374
+ return plain('string', 'string', 'string');
375
+ }
376
+ }
377
+ /** Finds the enum case a default refers to, by case name or stored value. */
378
+ function enumCaseReference(context, spec, raw) {
379
+ const definition = spec.enumDefinition;
380
+ if (definition === undefined || spec.enumClass === undefined) {
381
+ return undefined;
382
+ }
383
+ const match = definition.values.find((candidate) => candidate.name === raw) ??
384
+ definition.values.find((candidate) => candidate.dbValue === raw);
385
+ if (match === undefined) {
386
+ return undefined;
387
+ }
388
+ return {
389
+ reference: `${spec.enumClass}::${enumCaseName(context, definition, match)}`,
390
+ value: match.dbValue,
391
+ };
392
+ }
393
+ function enumCaseName(context, definition, value) {
394
+ return identifier({ ...context, warnings: [] }, '', value.name);
395
+ }
396
+ /** Works out how a field's default is written as an initializer, constructor statement and column option. */
397
+ function defaultOf(context, label, field, spec, property, plan) {
398
+ const defaultValue = field.default;
399
+ if (defaultValue === undefined) {
400
+ return {};
401
+ }
402
+ switch (defaultValue.kind) {
403
+ case 'autoIncrement':
404
+ context.warnings.push(`${label}: an auto-increment default is only supported on primary keys; it was dropped.`);
405
+ return {};
406
+ case 'now':
407
+ if (spec.valueKind !== 'date') {
408
+ context.warnings.push(`${label}: a "now" default needs a date or time column; it was dropped.`);
409
+ return {};
410
+ }
411
+ plan.imports.add('DateTimeImmutable');
412
+ return {
413
+ constructorLine: `$this->${property} = new DateTimeImmutable();`,
414
+ databaseValue: quote('CURRENT_TIMESTAMP'),
415
+ };
416
+ case 'uuid':
417
+ if (spec.valueKind !== 'string') {
418
+ context.warnings.push(`${label}: a UUID default needs a string or uuid column; it was dropped.`);
419
+ return {};
420
+ }
421
+ plan.usesUuid = true;
422
+ return { constructorLine: `$this->${property} = self::generateUuid();` };
423
+ case 'enumValue': {
424
+ const found = enumCaseReference(context, spec, defaultValue.value);
425
+ if (found === undefined) {
426
+ context.warnings.push(`${label}: the enum default "${defaultValue.value}" does not match a case of the enum; it was dropped.`);
427
+ return {};
428
+ }
429
+ return {
430
+ initializer: found.reference,
431
+ databaseValue: quote(found.value),
432
+ };
433
+ }
434
+ case 'literal':
435
+ return literalDefault(context, label, field, spec, defaultValue.value, property, plan);
436
+ default:
437
+ // clientGenerated and dbExpression defaults are reported by prismaOnlyWarnings.
438
+ return {};
439
+ }
440
+ }
441
+ function literalDefault(context, label, field, spec, value, property, plan) {
442
+ switch (spec.valueKind) {
443
+ case 'enum': {
444
+ const found = enumCaseReference(context, spec, String(value));
445
+ if (found === undefined) {
446
+ context.warnings.push(`${label}: the enum default "${String(value)}" does not match a case of the enum; it was dropped.`);
447
+ return {};
448
+ }
449
+ return {
450
+ initializer: found.reference,
451
+ databaseValue: quote(found.value),
452
+ };
453
+ }
454
+ case 'bool': {
455
+ const text = value === true || value === 'true' ? 'true' : 'false';
456
+ return { initializer: text, databaseValue: text };
457
+ }
458
+ case 'int': {
459
+ const text = String(Math.trunc(Number(value)));
460
+ return { initializer: text, databaseValue: text };
461
+ }
462
+ case 'float': {
463
+ const text = phpFloat(Number(value));
464
+ return { initializer: text, databaseValue: text };
465
+ }
466
+ case 'string':
467
+ return {
468
+ initializer: quote(String(value)),
469
+ databaseValue: quote(String(value)),
470
+ };
471
+ case 'date':
472
+ if (typeof value !== 'string') {
473
+ return {};
474
+ }
475
+ plan.imports.add('DateTimeImmutable');
476
+ return {
477
+ constructorLine: `$this->${property} = new DateTimeImmutable(${quote(value)});`,
478
+ databaseValue: quote(value),
479
+ };
480
+ case 'json':
481
+ case 'array': {
482
+ if (typeof value !== 'string') {
483
+ return { initializer: phpLiteral(value) };
484
+ }
485
+ try {
486
+ return { initializer: phpLiteral(JSON.parse(value)) };
487
+ }
488
+ catch {
489
+ context.warnings.push(`${label}: the default ${quote(value)} is not valid JSON and was dropped.`);
490
+ return {};
491
+ }
492
+ }
493
+ default:
494
+ context.warnings.push(`${label}: a literal default on a ${field.type} column cannot be represented; it was dropped.`);
495
+ return {};
496
+ }
497
+ }
498
+ // ---------------------------------------------------------------------------
499
+ // Planning: classes, fields, relations
500
+ // ---------------------------------------------------------------------------
501
+ function newPlan(context, model) {
502
+ const plan = {
503
+ model,
504
+ className: classNameOf(context, model.name),
505
+ props: [],
506
+ propNames: new Set(),
507
+ methodNames: new Set(['__construct', 'generateuuid']),
508
+ byKey: new Map(),
509
+ imports: new Set(['Doctrine\\ORM\\Mapping as ORM']),
510
+ constructorLines: [],
511
+ autoUpdated: [],
512
+ usesUuid: false,
513
+ };
514
+ context.classes.set(model.name, plan);
515
+ return plan;
516
+ }
517
+ function addProp(plan, key, prop) {
518
+ plan.props.push(prop);
519
+ plan.byKey.set(key, prop);
520
+ }
521
+ function allocateAccessors(plan, propName, isBool) {
522
+ const words = methodWords(propName);
523
+ let getterBase = `get${words}`;
524
+ if (isBool) {
525
+ getterBase = /^(Is|Has|Can|Should|Was|Did)[A-Z]/.test(words)
526
+ ? `${words.charAt(0).toLowerCase()}${words.slice(1)}`
527
+ : `is${words}`;
528
+ }
529
+ return {
530
+ getter: uniqueName(getterBase, plan.methodNames, true),
531
+ setter: uniqueName(`set${words}`, plan.methodNames, true),
532
+ };
533
+ }
534
+ function isGeneratedKey(model, field) {
535
+ const inComposite = model.compositePrimaryKey?.includes(field.name) ?? false;
536
+ return (field.isPrimaryKey &&
537
+ !inComposite &&
538
+ field.default?.kind === 'autoIncrement' &&
539
+ (field.type === 'int' || field.type === 'bigInt'));
540
+ }
541
+ function planClass(context, model) {
542
+ const plan = newPlan(context, model);
543
+ const hasPrimaryKey = model.fields.some((field) => field.isPrimaryKey) ||
544
+ model.relations.some((relation) => relation.isPrimaryKey === true) ||
545
+ (model.compositePrimaryKey !== undefined &&
546
+ model.compositePrimaryKey.length > 0);
547
+ if (!hasPrimaryKey) {
548
+ context.warnings.push(`${model.name}: the model has no primary key; Doctrine requires at least one #[ORM\\Id] property.`);
549
+ }
550
+ for (const field of model.fields) {
551
+ planField(context, plan, field);
552
+ }
553
+ for (const relation of model.relations) {
554
+ const target = findModel(context.schema, relation.targetModel);
555
+ const label = `${model.name}.${relation.name}`;
556
+ if (target === undefined) {
557
+ context.warnings.push(`${label}: target model "${relation.targetModel}" does not exist in the schema; the relation was skipped.`);
558
+ continue;
559
+ }
560
+ if (relation.kind === 'manyToMany') {
561
+ planManyToMany(context, plan, relation, target);
562
+ }
563
+ else {
564
+ planToOne(context, plan, relation, target);
565
+ }
566
+ }
567
+ }
568
+ function planField(context, plan, field) {
569
+ const model = plan.model;
570
+ const label = `${model.name}.${field.name}`;
571
+ const name = uniqueName(identifier(context, label, propertyName(context, field.name)), plan.propNames);
572
+ const spec = columnSpecOf(context, label, field);
573
+ for (const imported of spec.imports) {
574
+ plan.imports.add(spec.enumClass === imported
575
+ ? useStatementFor(context, imported)
576
+ : imported);
577
+ }
578
+ if (field.generated !== undefined) {
579
+ context.warnings.push(`${label}: the generated column expression ${field.generated.expression} is Python, not SQL, and has no Doctrine equivalent; the property was written as a regular column.`);
580
+ }
581
+ if (field.isAutoUpdated && spec.valueKind !== 'date') {
582
+ context.warnings.push(`${label}: an auto-updated field must be a date or time column; it was written as a plain column.`);
583
+ }
584
+ const inComposite = model.compositePrimaryKey?.includes(field.name) ?? false;
585
+ const isKey = field.isPrimaryKey || inComposite;
586
+ const generatedKey = isGeneratedKey(model, field);
587
+ const defaults = generatedKey
588
+ ? {}
589
+ : defaultOf(context, label, field, spec, name, plan);
590
+ if (defaults.constructorLine !== undefined) {
591
+ plan.constructorLines.push(defaults.constructorLine);
592
+ }
593
+ if (field.isAutoUpdated && spec.valueKind === 'date') {
594
+ plan.autoUpdated.push(name);
595
+ }
596
+ const nullable = field.isNullable || generatedKey;
597
+ const canBeNull = spec.phpType !== 'mixed' && nullable;
598
+ const phpType = `${canBeNull ? '?' : ''}${spec.phpType}`;
599
+ let initializer = defaults.initializer;
600
+ if (initializer === undefined && nullable && spec.phpType !== 'mixed') {
601
+ initializer = 'null';
602
+ }
603
+ else if (initializer === undefined &&
604
+ field.isNullable &&
605
+ spec.phpType === 'mixed') {
606
+ initializer = 'null';
607
+ }
608
+ const accessors = allocateAccessors(plan, name, spec.phpType === 'bool');
609
+ const nameOption = `name: ${dbName(field.columnName)}`;
610
+ const attributes = () => {
611
+ const result = [];
612
+ if (isKey) {
613
+ result.push(attribute('Id', []));
614
+ }
615
+ if (generatedKey) {
616
+ result.push(attribute('GeneratedValue', [`strategy: 'AUTO'`]));
617
+ }
618
+ const entries = [];
619
+ entries.push(nameOption, ...spec.entries);
620
+ if (!isKey && field.isNullable) {
621
+ entries.push('nullable: true');
622
+ }
623
+ if (!isKey && field.isUnique) {
624
+ entries.push('unique: true');
625
+ }
626
+ const options = [];
627
+ if (defaults.databaseValue !== undefined) {
628
+ options.push(`'default' => ${defaults.databaseValue}`);
629
+ }
630
+ if (field.type === 'json' &&
631
+ context.dialect === 'postgres' &&
632
+ (field.arrayDepth ?? 0) === 0) {
633
+ options.push(`'jsonb' => true`);
634
+ }
635
+ if (options.length > 0) {
636
+ entries.push(`options: [${options.join(', ')}]`);
637
+ }
638
+ result.push(attribute('Column', entries));
639
+ return result;
640
+ };
641
+ addProp(plan, `f:${field.name}`, {
642
+ name,
643
+ role: 'column',
644
+ nullable: canBeNull,
645
+ phpType,
646
+ ...(initializer === undefined ? {} : { initializer }),
647
+ getter: accessors.getter,
648
+ ...(generatedKey ? {} : { setter: accessors.setter }),
649
+ attributes,
650
+ });
651
+ if (generatedKey) {
652
+ plan.methodNames.delete(accessors.setter.toLowerCase());
653
+ }
654
+ }
655
+ /** Column name of the key a relation points at: an explicit `toField`, otherwise the primary key. */
656
+ function referencedColumn(context, target, toField, depth = 0) {
657
+ if (depth > 5) {
658
+ return undefined;
659
+ }
660
+ const explicit = toField === undefined
661
+ ? undefined
662
+ : target.fields.find((field) => field.name === toField);
663
+ if (explicit !== undefined) {
664
+ return explicit.columnName;
665
+ }
666
+ const keys = target.fields.filter((field) => field.isPrimaryKey);
667
+ if (keys.length === 1 && (target.compositePrimaryKey?.length ?? 0) === 0) {
668
+ return keys[0]?.columnName;
669
+ }
670
+ if (keys.length > 1 || (target.compositePrimaryKey?.length ?? 0) > 1) {
671
+ return undefined;
672
+ }
673
+ const primaryRelation = target.relations.find((relation) => relation.isPrimaryKey === true);
674
+ if (primaryRelation !== undefined) {
675
+ return primaryRelation.columnName;
676
+ }
677
+ return undefined;
678
+ }
679
+ function planToOne(context, plan, relation, target) {
680
+ const model = plan.model;
681
+ const label = `${model.name}.${relation.name}`;
682
+ const referenced = referencedColumn(context, target, relation.toField);
683
+ if (referenced === undefined) {
684
+ context.warnings.push(`${label}: target model "${target.name}" has no single-column primary key to reference; the relation was skipped.`);
685
+ return;
686
+ }
687
+ const explicitTarget = relation.toField === undefined
688
+ ? undefined
689
+ : target.fields.find((candidate) => candidate.name === relation.toField);
690
+ if (explicitTarget !== undefined && !explicitTarget.isPrimaryKey) {
691
+ context.warnings.push(`${label}: the relation references ${target.name}.${explicitTarget.name}, which is not a primary key; the foreign key is written as-is, but Doctrine's schema validator (orm:validate-schema) rejects referenced columns that are not primary keys.`);
692
+ }
693
+ if (relation.onDelete === 'setNull' && !relation.isNullable) {
694
+ context.warnings.push(`${label}: onDelete SET NULL on a required relation will fail at the database level; review the relation.`);
695
+ }
696
+ const name = uniqueName(identifier(context, label, propertyName(context, relation.name)), plan.propNames);
697
+ const element = classNameOf(context, target.name);
698
+ const accessors = allocateAccessors(plan, name, false);
699
+ const keyed = isKeyedRelation(model, relation);
700
+ const nullable = relation.isNullable && !keyed;
701
+ const prop = {
702
+ name,
703
+ role: 'owningToOne',
704
+ nullable,
705
+ phpType: `${nullable ? '?' : ''}${element}`,
706
+ ...(nullable ? { initializer: 'null' } : {}),
707
+ element,
708
+ getter: accessors.getter,
709
+ setter: accessors.setter,
710
+ attributes: () => {
711
+ const result = [];
712
+ if (keyed) {
713
+ result.push(attribute('Id', []));
714
+ }
715
+ const args = [`targetEntity: ${element}::class`];
716
+ if (prop.peer !== undefined) {
717
+ args.push(`inversedBy: ${quote(prop.peer.name)}`);
718
+ }
719
+ result.push(attribute(relation.kind === 'oneToOne' ? 'OneToOne' : 'ManyToOne', args));
720
+ result.push(attribute('JoinColumn', [
721
+ `name: ${dbName(relation.columnName)}`,
722
+ `referencedColumnName: ${dbName(referenced)}`,
723
+ `nullable: ${nullable ? 'true' : 'false'}`,
724
+ `onDelete: ${quote(ON_DELETE_NAMES[relation.onDelete])}`,
725
+ ]));
726
+ return result;
727
+ },
728
+ };
729
+ addProp(plan, `r:${relation.name}`, prop);
730
+ }
731
+ function planManyToMany(context, plan, relation, target) {
732
+ const model = plan.model;
733
+ const label = `${model.name}.${relation.name}`;
734
+ const ownerKey = referencedColumn(context, model, undefined);
735
+ const targetKey = referencedColumn(context, target, undefined);
736
+ if (ownerKey === undefined || targetKey === undefined) {
737
+ context.warnings.push(`${label}: ${ownerKey === undefined ? model.name : target.name} has no single-column primary key to reference from a join table; the relation was skipped.`);
738
+ return;
739
+ }
740
+ const name = uniqueName(identifier(context, label, propertyName(context, relation.name)), plan.propNames);
741
+ const element = classNameOf(context, target.name);
742
+ const isSelfReference = model.name === target.name;
743
+ const ownerColumn = `${isSelfReference ? 'from_' : ''}${toSnakeCase(model.name)}_id`;
744
+ const targetColumn = `${isSelfReference ? 'to_' : ''}${toSnakeCase(target.name)}_id`;
745
+ const collection = collectionAccessors(plan, name);
746
+ plan.imports.add('Doctrine\\Common\\Collections\\ArrayCollection');
747
+ plan.imports.add('Doctrine\\Common\\Collections\\Collection');
748
+ plan.constructorLines.push(`$this->${name} = new ArrayCollection();`);
749
+ const prop = {
750
+ name,
751
+ role: 'manyToMany',
752
+ nullable: false,
753
+ phpType: 'Collection',
754
+ docType: `Collection<int, ${element}>`,
755
+ element,
756
+ ...collection,
757
+ attributes: () => {
758
+ const args = [`targetEntity: ${element}::class`];
759
+ if (prop.peer !== undefined) {
760
+ args.push(`inversedBy: ${quote(prop.peer.name)}`);
761
+ }
762
+ return [
763
+ attribute('ManyToMany', args),
764
+ attribute('JoinTable', [
765
+ `name: ${dbName(`${model.tableName}_${relation.name}`)}`,
766
+ ]),
767
+ attribute('JoinColumn', [
768
+ `name: ${dbName(ownerColumn)}`,
769
+ `referencedColumnName: ${dbName(ownerKey)}`,
770
+ `onDelete: 'CASCADE'`,
771
+ ]),
772
+ attribute('InverseJoinColumn', [
773
+ `name: ${dbName(targetColumn)}`,
774
+ `referencedColumnName: ${dbName(targetKey)}`,
775
+ `onDelete: 'CASCADE'`,
776
+ ]),
777
+ ];
778
+ },
779
+ };
780
+ addProp(plan, `r:${relation.name}`, prop);
781
+ }
782
+ function collectionAccessors(plan, propName) {
783
+ const snake = toSnakeCase(propName).replace(/_set$/, '');
784
+ const singular = singularize(snake);
785
+ const words = methodWords(singular);
786
+ return {
787
+ getter: uniqueName(`get${methodWords(propName)}`, plan.methodNames, true),
788
+ adder: uniqueName(`add${words}`, plan.methodNames, true),
789
+ remover: uniqueName(`remove${words}`, plan.methodNames, true),
790
+ elementParam: parameterName(singular),
791
+ };
792
+ }
793
+ /** Adds the inverse side of every relation that was planned on `model` to the target class. */
794
+ function planInverseSides(context, model) {
795
+ const ownerPlan = context.classes.get(model.name);
796
+ if (ownerPlan === undefined) {
797
+ return;
798
+ }
799
+ for (const relation of model.relations) {
800
+ const owning = ownerPlan.byKey.get(`r:${relation.name}`);
801
+ const target = findModel(context.schema, relation.targetModel);
802
+ const targetPlan = target === undefined ? undefined : context.classes.get(target.name);
803
+ if (owning === undefined ||
804
+ target === undefined ||
805
+ targetPlan === undefined) {
806
+ continue;
807
+ }
808
+ const label = `${model.name}.${relation.name}`;
809
+ const defaultName = relation.kind === 'oneToOne'
810
+ ? toSnakeCase(model.name)
811
+ : `${toSnakeCase(model.name)}_set`;
812
+ const name = uniqueName(identifier(context, label, propertyName(context, relation.relatedName ?? defaultName)), targetPlan.propNames);
813
+ const element = ownerPlan.className;
814
+ const ownerColumnProp = owning.name;
815
+ let inverse;
816
+ if (relation.kind === 'oneToOne') {
817
+ const accessors = allocateAccessors(targetPlan, name, false);
818
+ inverse = {
819
+ name,
820
+ role: 'inverseToOne',
821
+ nullable: true,
822
+ phpType: `?${element}`,
823
+ initializer: 'null',
824
+ element,
825
+ getter: accessors.getter,
826
+ setter: accessors.setter,
827
+ attributes: () => [
828
+ attribute('OneToOne', [
829
+ `targetEntity: ${element}::class`,
830
+ `mappedBy: ${quote(ownerColumnProp)}`,
831
+ ]),
832
+ ],
833
+ };
834
+ }
835
+ else if (relation.kind === 'manyToMany') {
836
+ const collection = collectionAccessors(targetPlan, name);
837
+ targetPlan.imports.add('Doctrine\\Common\\Collections\\ArrayCollection');
838
+ targetPlan.imports.add('Doctrine\\Common\\Collections\\Collection');
839
+ targetPlan.constructorLines.push(`$this->${name} = new ArrayCollection();`);
840
+ inverse = {
841
+ name,
842
+ role: 'manyToMany',
843
+ nullable: false,
844
+ phpType: 'Collection',
845
+ docType: `Collection<int, ${element}>`,
846
+ element,
847
+ ...collection,
848
+ attributes: () => [
849
+ attribute('ManyToMany', [
850
+ `targetEntity: ${element}::class`,
851
+ `mappedBy: ${quote(ownerColumnProp)}`,
852
+ ]),
853
+ ],
854
+ };
855
+ }
856
+ else {
857
+ const collection = collectionAccessors(targetPlan, name);
858
+ targetPlan.imports.add('Doctrine\\Common\\Collections\\ArrayCollection');
859
+ targetPlan.imports.add('Doctrine\\Common\\Collections\\Collection');
860
+ targetPlan.constructorLines.push(`$this->${name} = new ArrayCollection();`);
861
+ inverse = {
862
+ name,
863
+ role: 'inverseToMany',
864
+ nullable: false,
865
+ phpType: 'Collection',
866
+ docType: `Collection<int, ${element}>`,
867
+ element,
868
+ ...collection,
869
+ attributes: () => [
870
+ attribute('OneToMany', [
871
+ `targetEntity: ${element}::class`,
872
+ `mappedBy: ${quote(ownerColumnProp)}`,
873
+ ]),
874
+ ],
875
+ };
876
+ }
877
+ inverse.peer = owning;
878
+ owning.peer = inverse;
879
+ addProp(targetPlan, `i:${model.name}.${relation.name}`, inverse);
880
+ }
881
+ }
882
+ // ---------------------------------------------------------------------------
883
+ // Rendering
884
+ // ---------------------------------------------------------------------------
885
+ function renderEnum(context, enumDefinition) {
886
+ const name = context.enumNames.get(enumDefinition.name) ?? enumDefinition.name;
887
+ const lines = [
888
+ '<?php',
889
+ '',
890
+ 'declare(strict_types=1);',
891
+ '',
892
+ `namespace ${context.enumNamespace};`,
893
+ '',
894
+ `enum ${name}: string`,
895
+ '{',
896
+ ];
897
+ const used = new Set();
898
+ for (const value of enumDefinition.values) {
899
+ const caseName = uniqueName(identifier(context, `enum ${enumDefinition.name}`, value.name), used);
900
+ if (value.label !== undefined && value.label !== '') {
901
+ lines.push(`${INDENT}/** ${value.label.replace(/\*\//g, '* /')} */`);
902
+ }
903
+ lines.push(`${INDENT}case ${caseName} = ${quote(value.dbValue)};`);
904
+ }
905
+ lines.push('}', '');
906
+ return lines.join('\n');
907
+ }
908
+ function indentLines(lines, depth = 1) {
909
+ return lines.map((line) => line === '' ? line : `${INDENT.repeat(depth)}${line}`);
910
+ }
911
+ /**
912
+ * The table name plus one class-level `#[ORM\Index]` / `#[ORM\UniqueConstraint]`
913
+ * per index. `Table(indexes:, uniqueConstraints:)` has no effect in Doctrine ORM
914
+ * 3 (it is deprecated since 2.19), so the class-level attributes are used.
915
+ */
916
+ function renderTableAttribute(context, plan) {
917
+ const model = plan.model;
918
+ const lines = attribute('Table', [
919
+ `name: ${dbName(model.tableName)}`,
920
+ ]);
921
+ for (const index of model.indexes) {
922
+ const columns = indexColumns(context, model, index);
923
+ if (columns === undefined) {
924
+ continue;
925
+ }
926
+ const args = [];
927
+ if (index.name !== undefined) {
928
+ args.push(`name: ${quote(index.name)}`);
929
+ }
930
+ args.push(`columns: [${columns.map(quote).join(', ')}]`);
931
+ lines.push(...attribute(index.isUnique ? 'UniqueConstraint' : 'Index', args));
932
+ }
933
+ return lines;
934
+ }
935
+ /** Resolves the IR field or relation names of an index to database column names. */
936
+ function indexColumns(context, model, index) {
937
+ const columns = [];
938
+ const label = `${model.name} index (${index.fields.join(', ')})`;
939
+ for (const fieldName of index.fields) {
940
+ const relation = model.relations.find((candidate) => candidate.name === fieldName) ??
941
+ model.relations.find((candidate) => candidate.kind !== 'manyToMany' && candidate.columnName === fieldName);
942
+ if (relation !== undefined) {
943
+ if (relation.kind === 'manyToMany') {
944
+ context.warnings.push(`${label}: "${fieldName}" is a many-to-many relation and has no column; the index was skipped.`);
945
+ return undefined;
946
+ }
947
+ columns.push(sqlIdentifier(relation.columnName));
948
+ continue;
949
+ }
950
+ const field = model.fields.find((candidate) => candidate.name === fieldName) ??
951
+ model.fields.find((candidate) => candidate.columnName === fieldName);
952
+ if (field !== undefined) {
953
+ columns.push(sqlIdentifier(field.columnName));
954
+ continue;
955
+ }
956
+ context.warnings.push(`${label}: an index references "${fieldName}", which is not a field of the model; it was written as-is.`);
957
+ columns.push(sqlIdentifier(fieldName));
958
+ }
959
+ return columns;
960
+ }
961
+ function renderProperty(prop) {
962
+ const lines = [];
963
+ if (prop.docType !== undefined) {
964
+ lines.push(`/** @var ${prop.docType} */`);
965
+ }
966
+ for (const attributeLines of prop.attributes()) {
967
+ lines.push(...attributeLines);
968
+ }
969
+ lines.push(`private ${prop.phpType} $${prop.name}${prop.initializer === undefined ? '' : ` = ${prop.initializer}`};`);
970
+ return lines;
971
+ }
972
+ function method(signature, body, docLines = []) {
973
+ return [...docLines, signature, '{', ...indentLines(body), '}'];
974
+ }
975
+ function renderAccessors(prop) {
976
+ const param = parameterName(prop.name);
977
+ const methods = [];
978
+ switch (prop.role) {
979
+ case 'column':
980
+ case 'owningToOne':
981
+ methods.push(method(`public function ${prop.getter}(): ${prop.phpType}`, [
982
+ `return $this->${prop.name};`,
983
+ ]));
984
+ if (prop.setter !== undefined) {
985
+ methods.push(method(`public function ${prop.setter}(${prop.phpType} $${param}): static`, [`$this->${prop.name} = $${param};`, '', 'return $this;']));
986
+ }
987
+ break;
988
+ case 'inverseToOne':
989
+ methods.push(method(`public function ${prop.getter}(): ${prop.phpType}`, [
990
+ `return $this->${prop.name};`,
991
+ ]));
992
+ if (prop.setter !== undefined) {
993
+ const peerSetter = prop.peer?.setter ?? '';
994
+ methods.push(method(`public function ${prop.setter}(${prop.phpType} $${param}): static`, [
995
+ `$this->${prop.name} = $${param};`,
996
+ ...(peerSetter === ''
997
+ ? []
998
+ : [
999
+ `if ($${param} !== null) {`,
1000
+ `${INDENT}$${param}->${peerSetter}($this);`,
1001
+ '}',
1002
+ ]),
1003
+ '',
1004
+ 'return $this;',
1005
+ ]));
1006
+ }
1007
+ break;
1008
+ case 'inverseToMany':
1009
+ case 'manyToMany':
1010
+ methods.push(...renderCollectionAccessors(prop));
1011
+ break;
1012
+ }
1013
+ return methods;
1014
+ }
1015
+ function renderCollectionAccessors(prop) {
1016
+ const element = prop.element ?? 'mixed';
1017
+ const param = prop.elementParam ?? 'item';
1018
+ const peer = prop.peer;
1019
+ const addBody = [
1020
+ `if (!$this->${prop.name}->contains($${param})) {`,
1021
+ `${INDENT}$this->${prop.name}->add($${param});`,
1022
+ ];
1023
+ const removeBody = [];
1024
+ if (prop.role === 'manyToMany') {
1025
+ if (peer?.adder !== undefined) {
1026
+ addBody.push(`${INDENT}$${param}->${peer.adder}($this);`);
1027
+ }
1028
+ addBody.push('}');
1029
+ removeBody.push(`if ($this->${prop.name}->removeElement($${param})) {`);
1030
+ if (peer?.remover !== undefined) {
1031
+ removeBody.push(`${INDENT}$${param}->${peer.remover}($this);`);
1032
+ }
1033
+ else {
1034
+ removeBody.push(`${INDENT}// Nothing else to keep in sync.`);
1035
+ }
1036
+ removeBody.push('}');
1037
+ }
1038
+ else {
1039
+ if (peer?.setter !== undefined) {
1040
+ addBody.push(`${INDENT}$${param}->${peer.setter}($this);`);
1041
+ }
1042
+ addBody.push('}');
1043
+ removeBody.push(`if ($this->${prop.name}->removeElement($${param})) {`);
1044
+ if (peer?.nullable === true && peer.setter !== undefined) {
1045
+ removeBody.push(`${INDENT}if ($${param}->${peer.getter}() === $this) {`, `${INDENT}${INDENT}$${param}->${peer.setter}(null);`, `${INDENT}}`);
1046
+ }
1047
+ else {
1048
+ removeBody.push(`${INDENT}// The owning side is required, so it is not unset here.`);
1049
+ }
1050
+ removeBody.push('}');
1051
+ }
1052
+ return [
1053
+ method(`public function ${prop.getter}(): Collection`, [`return $this->${prop.name};`], ['/**', ` * @return ${prop.docType ?? 'Collection'}`, ' */']),
1054
+ method(`public function ${prop.adder ?? 'add'}(${element} $${param}): static`, [...addBody, '', 'return $this;']),
1055
+ method(`public function ${prop.remover ?? 'remove'}(${element} $${param}): static`, [...removeBody, '', 'return $this;']),
1056
+ ];
1057
+ }
1058
+ const UUID_HELPER = [
1059
+ 'private static function generateUuid(): string',
1060
+ '{',
1061
+ `${INDENT}$bytes = random_bytes(16);`,
1062
+ `${INDENT}$bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40);`,
1063
+ `${INDENT}$bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80);`,
1064
+ '',
1065
+ `${INDENT}return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($bytes), 4));`,
1066
+ '}',
1067
+ ];
1068
+ function renderClass(context, plan) {
1069
+ const imports = [...plan.imports];
1070
+ const header = [
1071
+ '<?php',
1072
+ '',
1073
+ 'declare(strict_types=1);',
1074
+ '',
1075
+ `namespace ${context.entityNamespace};`,
1076
+ '',
1077
+ ...imports
1078
+ .sort((first, second) => {
1079
+ const a = first.toLowerCase();
1080
+ const b = second.toLowerCase();
1081
+ return a < b ? -1 : a > b ? 1 : 0;
1082
+ })
1083
+ .map((name) => `use ${name};`),
1084
+ '',
1085
+ ];
1086
+ const classAttributes = [
1087
+ ...attribute('Entity', []),
1088
+ ...renderTableAttribute(context, plan),
1089
+ ];
1090
+ if (plan.autoUpdated.length > 0) {
1091
+ classAttributes.push(...attribute('HasLifecycleCallbacks', []));
1092
+ }
1093
+ const blocks = [];
1094
+ for (const prop of plan.props) {
1095
+ blocks.push(renderProperty(prop));
1096
+ }
1097
+ // Properties are separated by a blank line; the constructor and methods follow.
1098
+ const body = blocks.flatMap((block, index) => index === 0 ? block : ['', ...block]);
1099
+ const members = [];
1100
+ if (plan.constructorLines.length > 0) {
1101
+ members.push(method('public function __construct()', plan.constructorLines));
1102
+ }
1103
+ for (const prop of plan.props) {
1104
+ members.push(...renderAccessors(prop));
1105
+ }
1106
+ if (plan.autoUpdated.length > 0) {
1107
+ const refresh = uniqueName('refreshAutoUpdatedFields', plan.methodNames, true);
1108
+ members.push([
1109
+ ...attribute('PrePersist', []),
1110
+ ...attribute('PreUpdate', []),
1111
+ ...method(`public function ${refresh}(): void`, plan.autoUpdated.map((name) => `$this->${name} = new DateTimeImmutable();`)),
1112
+ ]);
1113
+ }
1114
+ if (plan.usesUuid) {
1115
+ members.push(UUID_HELPER);
1116
+ }
1117
+ const classBody = [...body];
1118
+ for (const member of members) {
1119
+ if (classBody.length > 0) {
1120
+ classBody.push('');
1121
+ }
1122
+ classBody.push(...member);
1123
+ }
1124
+ return [
1125
+ ...header,
1126
+ ...classAttributes,
1127
+ `class ${plan.className}`,
1128
+ '{',
1129
+ ...indentLines(classBody),
1130
+ '}',
1131
+ '',
1132
+ ].join('\n');
1133
+ }
1134
+ //# sourceMappingURL=doctrine.js.map