@prisma-next/psl-printer 0.5.0-dev.9 → 0.5.1

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/src/print-psl.ts CHANGED
@@ -1,881 +1,8 @@
1
- import type { ColumnDefault } from '@prisma-next/contract/types';
2
- import { mapDefault } from './default-mapping';
3
- import { toEnumName, toFieldName, toModelName, toNamedTypeName } from './name-transforms';
4
- import { inferRelations } from './relation-inference';
5
- import type {
6
- PrintableSqlColumnDefault,
7
- PslPrintableSqlSchemaIR,
8
- PslPrintableSqlTable,
9
- } from './schema-validation';
10
- import type {
11
- EnumInfo,
12
- PrinterField,
13
- PrinterModel,
14
- PrinterNamedType,
15
- PslNativeTypeAttribute,
16
- PslPrinterOptions,
17
- RelationField,
18
- } from './types';
1
+ import type { PslDocumentAst } from '@prisma-next/framework-components/psl-ast';
2
+ import { astDocumentToPrintDocument } from './ast-to-print-document';
3
+ import { serializePrintDocument } from './serialize-print-document';
19
4
 
20
- const DEFAULT_HEADER = '// This file was introspected from the database. Do not edit manually.';
21
-
22
- /**
23
- * Escapes a string for use inside a PSL double-quoted context (e.g., @map("..."), @relation(name: "...")).
24
- * Prevents malformed PSL when database identifiers contain `"` or newlines.
25
- */
26
- function escapePslString(value: string): string {
27
- return value
28
- .replace(/\\/g, '\\\\')
29
- .replace(/"/g, '\\"')
30
- .replace(/\n/g, '\\n')
31
- .replace(/\r/g, '\\r');
32
- }
33
-
34
- type ResolvedColumnFieldName = {
35
- readonly fieldName: string;
36
- readonly fieldMap?: string | undefined;
37
- };
38
-
39
- type TableColumnFieldNameMap = ReadonlyMap<string, ResolvedColumnFieldName>;
40
-
41
- type NamedTypeRegistry = {
42
- readonly entriesByKey: Map<string, PrinterNamedType>;
43
- readonly usedNames: Set<string>;
44
- };
45
-
46
- type TopLevelNameResult = {
47
- readonly name: string;
48
- readonly map?: string | undefined;
49
- };
50
-
51
- const PSL_IDENTIFIER_PATTERN = /^[A-Za-z_]\w*$/;
52
- const ENUM_MEMBER_RESERVED_WORDS = new Set([
53
- 'datasource',
54
- 'default',
55
- 'enum',
56
- 'generator',
57
- 'model',
58
- 'type',
59
- 'types',
60
- ]);
61
- const PSL_SCALAR_TYPE_NAMES = new Set([
62
- 'String',
63
- 'Boolean',
64
- 'Int',
65
- 'BigInt',
66
- 'Float',
67
- 'Decimal',
68
- 'DateTime',
69
- 'Json',
70
- 'Bytes',
71
- ]);
72
-
73
- /**
74
- * Converts a SqlSchemaIR to a PSL (Prisma Schema Language) string.
75
- *
76
- * The output follows PSL formatting conventions:
77
- * - Header comment
78
- * - `types` block (if parameterized types exist)
79
- * - `enum` blocks (alphabetical)
80
- * - `model` blocks (topologically sorted by FK deps, alphabetical fallback)
81
- *
82
- * @param schemaIR - The introspected schema IR
83
- * @param options - Printer configuration (type map, header)
84
- * @returns A valid PSL string
85
- */
86
- export function printPsl(schemaIR: PslPrintableSqlSchemaIR, options: PslPrinterOptions): string {
87
- const { typeMap, header, defaultMapping, enumInfo, parseRawDefault } = options;
88
- const headerComment = header ?? DEFAULT_HEADER;
89
-
90
- const emptyEnumInfo: EnumInfo = {
91
- typeNames: new Set<string>(),
92
- definitions: new Map<string, readonly string[]>(),
93
- };
94
- const { typeNames: enumTypeNames, definitions: enumDefinitions } = enumInfo ?? emptyEnumInfo;
95
-
96
- // Build model name mapping (db table name → PSL model name)
97
- const modelNames = buildTopLevelNameMap(
98
- Object.keys(schemaIR.tables),
99
- toModelName,
100
- 'model',
101
- 'table',
102
- );
103
-
104
- // Build enum name mapping (db type name → PSL enum name)
105
- const enumNames = buildTopLevelNameMap(enumTypeNames, toEnumName, 'enum', 'enum type');
106
- assertNoCrossKindNameCollisions(modelNames, enumNames);
107
-
108
- const modelNameMap = new Map(
109
- [...modelNames].map(([tableName, result]) => [tableName, result.name]),
110
- );
111
- const enumNameMap = new Map(
112
- [...enumNames].map(([pgTypeName, result]) => [pgTypeName, result.name]),
113
- );
114
- const reservedNamedTypeNames = createReservedNamedTypeNames(modelNames, enumNames);
115
-
116
- const fieldNamesByTable = buildFieldNamesByTable(schemaIR.tables);
117
-
118
- // Infer relations from foreign keys
119
- const { relationsByTable } = inferRelations(schemaIR.tables, modelNameMap);
120
-
121
- // Collect named types for the types block
122
- const namedTypes = seedNamedTypeRegistry(schemaIR, typeMap, enumNameMap, reservedNamedTypeNames);
123
-
124
- // Process tables into models
125
- const models: PrinterModel[] = [];
126
- for (const table of Object.values(schemaIR.tables)) {
127
- const model = processTable(
128
- table,
129
- typeMap,
130
- enumNameMap,
131
- fieldNamesByTable,
132
- namedTypes,
133
- defaultMapping,
134
- parseRawDefault,
135
- relationsByTable.get(table.name) ?? [],
136
- );
137
- models.push(model);
138
- }
139
-
140
- // Process enums
141
- const enums: Array<{ name: string; mapName: string | undefined; values: readonly string[] }> = [];
142
- for (const [pgTypeName, values] of enumDefinitions) {
143
- const enumName = enumNames.get(pgTypeName) as TopLevelNameResult;
144
- enums.push({ name: enumName.name, mapName: enumName.map, values });
145
- }
146
-
147
- // Sort enums alphabetically
148
- enums.sort((a, b) => a.name.localeCompare(b.name));
149
-
150
- // Sort models topologically by FK dependencies
151
- const sortedModels = topologicalSort(models, schemaIR.tables, modelNameMap);
152
-
153
- // Serialize
154
- const sections: string[] = [];
155
-
156
- // Header
157
- sections.push(headerComment);
158
-
159
- // Types block
160
- const namedTypeEntries = [...namedTypes.entriesByKey.values()].sort((a, b) =>
161
- a.name.localeCompare(b.name),
162
- );
163
- if (namedTypeEntries.length > 0) {
164
- sections.push(serializeTypesBlock(namedTypeEntries));
165
- }
166
-
167
- // Enum blocks
168
- for (const e of enums) {
169
- sections.push(serializeEnum(e));
170
- }
171
-
172
- // Model blocks
173
- for (const model of sortedModels) {
174
- sections.push(serializeModel(model));
175
- }
176
-
177
- return `${sections.join('\n\n')}\n`;
178
- }
179
-
180
- /**
181
- * Processes a SQL table into a PrinterModel.
182
- */
183
- function processTable(
184
- table: PslPrintableSqlTable,
185
- typeMap: PslPrinterOptions['typeMap'],
186
- enumNameMap: ReadonlyMap<string, string>,
187
- fieldNamesByTable: ReadonlyMap<string, TableColumnFieldNameMap>,
188
- namedTypes: NamedTypeRegistry,
189
- defaultMapping: PslPrinterOptions['defaultMapping'],
190
- rawDefaultParser: PslPrinterOptions['parseRawDefault'],
191
- relationFields: readonly RelationField[],
192
- ): PrinterModel {
193
- const { name: modelName, map: mapName } = toModelName(table.name);
194
- const fieldNameMap = fieldNamesByTable.get(table.name);
195
-
196
- const pkColumns = new Set(table.primaryKey?.columns ?? []);
197
- const isSinglePk = pkColumns.size === 1;
198
- const singlePkConstraintName = isSinglePk ? table.primaryKey?.name : undefined;
199
-
200
- // Build lookup for unique single-column constraints.
201
- const uniqueColumns = new Map<string, string | undefined>();
202
- for (const unique of table.uniques) {
203
- if (unique.columns.length === 1) {
204
- const [columnName = ''] = unique.columns;
205
- const existingConstraintName = uniqueColumns.get(columnName);
206
- if (!uniqueColumns.has(columnName) || (existingConstraintName === undefined && unique.name)) {
207
- uniqueColumns.set(columnName, unique.name);
208
- }
209
- }
210
- }
211
-
212
- // Process columns into fields
213
- const fields: PrinterField[] = [];
214
- const columnEntries = Object.values(table.columns);
215
-
216
- for (const column of columnEntries) {
217
- const resolvedField = fieldNameMap?.get(column.name);
218
- const fieldName = resolvedField?.fieldName ?? toFieldName(column.name).name;
219
- const fieldMap = resolvedField?.fieldMap;
220
-
221
- // Resolve type
222
- const resolution = typeMap.resolve(column.nativeType, table.annotations);
223
-
224
- if ('unsupported' in resolution) {
225
- // Unsupported type
226
- fields.push({
227
- name: fieldName,
228
- typeName: `Unsupported("${escapePslString(resolution.nativeType)}")`,
229
- optional: column.nullable,
230
- list: false,
231
- attributes: fieldMap ? [`@map("${escapePslString(fieldMap)}")`] : [],
232
- mapName: fieldMap ?? undefined,
233
- isId: false,
234
- isRelation: false,
235
- isUnsupported: true,
236
- });
237
- continue;
238
- }
239
-
240
- // Check if this is an enum type
241
- let typeName = resolution.pslType;
242
- const enumPslName = enumNameMap.get(column.nativeType);
243
- if (enumPslName) {
244
- typeName = enumPslName;
245
- }
246
-
247
- // Preserve non-default native storage shapes via named types.
248
- if (resolution.nativeTypeAttribute && !enumPslName) {
249
- typeName = resolveNamedTypeName(namedTypes, resolution);
250
- }
251
-
252
- // Build attributes
253
- const attributes: string[] = [];
254
- const isId = isSinglePk && pkColumns.has(column.name);
255
- if (isId) {
256
- attributes.push(formatFieldConstraintAttribute('@id', singlePkConstraintName));
257
- }
258
-
259
- // Default value
260
- let comment: string | undefined;
261
- if (column.default !== undefined) {
262
- const parsed = parseDefaultIfNeeded(column.default, column.nativeType, rawDefaultParser);
263
- if (parsed) {
264
- const result = mapDefault(parsed, defaultMapping);
265
- if ('attribute' in result) {
266
- attributes.push(result.attribute);
267
- } else {
268
- comment = result.comment;
269
- }
270
- }
271
- }
272
-
273
- // Unique
274
- const uniqueConstraintName = uniqueColumns.get(column.name);
275
- if (uniqueConstraintName !== undefined || uniqueColumns.has(column.name)) {
276
- if (!isId) {
277
- attributes.push(formatFieldConstraintAttribute('@unique', uniqueConstraintName));
278
- }
279
- }
280
-
281
- // Map
282
- if (fieldMap) {
283
- attributes.push(`@map("${escapePslString(fieldMap)}")`);
284
- }
285
-
286
- fields.push({
287
- name: fieldName,
288
- typeName,
289
- optional: column.nullable,
290
- list: false,
291
- attributes,
292
- mapName: fieldMap ?? undefined,
293
- isId,
294
- isRelation: false,
295
- isUnsupported: false,
296
- comment,
297
- });
298
- }
299
-
300
- // Add relation fields
301
- const usedFieldNames = new Set(fields.map((field) => field.name));
302
- for (const rel of relationFields) {
303
- const relationFieldName = createUniqueFieldName(rel.fieldName, usedFieldNames);
304
- const relAttributes: string[] = [];
305
-
306
- if (rel.fields && rel.references) {
307
- const parts: string[] = [];
308
- if (rel.relationName) {
309
- parts.push(`name: "${escapePslString(rel.relationName)}"`);
310
- }
311
- parts.push(
312
- `fields: [${rel.fields
313
- .map((fieldName) => resolveColumnFieldName(fieldNamesByTable, table.name, fieldName))
314
- .join(', ')}]`,
315
- );
316
- parts.push(
317
- `references: [${rel.references
318
- .map((fieldName) =>
319
- resolveColumnFieldName(fieldNamesByTable, rel.referencedTableName ?? '', fieldName),
320
- )
321
- .join(', ')}]`,
322
- );
323
- if (rel.onDelete) {
324
- parts.push(`onDelete: ${rel.onDelete}`);
325
- }
326
- if (rel.onUpdate) {
327
- parts.push(`onUpdate: ${rel.onUpdate}`);
328
- }
329
- if (rel.fkName) {
330
- parts.push(`map: "${escapePslString(rel.fkName)}"`);
331
- }
332
- relAttributes.push(`@relation(${parts.join(', ')})`);
333
- } else if (rel.relationName) {
334
- relAttributes.push(`@relation(name: "${escapePslString(rel.relationName)}")`);
335
- }
336
-
337
- fields.push({
338
- name: relationFieldName,
339
- typeName: rel.typeName,
340
- optional: rel.optional,
341
- list: rel.list,
342
- attributes: relAttributes,
343
- isId: false,
344
- isRelation: true,
345
- isUnsupported: false,
346
- });
347
- usedFieldNames.add(relationFieldName);
348
- }
349
-
350
- // Model-level attributes
351
- const modelAttributes: string[] = [];
352
-
353
- // Composite PK
354
- if (table.primaryKey && table.primaryKey.columns.length > 1) {
355
- const pkFieldNames = table.primaryKey.columns.map((columnName) =>
356
- resolveColumnFieldName(fieldNamesByTable, table.name, columnName),
357
- );
358
- modelAttributes.push(
359
- formatModelConstraintAttribute('@@id', pkFieldNames, table.primaryKey.name),
360
- );
361
- }
362
-
363
- // Composite unique constraints
364
- for (const unique of table.uniques) {
365
- if (unique.columns.length > 1) {
366
- const fieldNames = unique.columns.map((columnName) =>
367
- resolveColumnFieldName(fieldNamesByTable, table.name, columnName),
368
- );
369
- modelAttributes.push(formatModelConstraintAttribute('@@unique', fieldNames, unique.name));
370
- }
371
- }
372
-
373
- // Indexes (non-unique only; unique indexes are handled by @@unique)
374
- for (const index of table.indexes) {
375
- if (!index.unique) {
376
- const fieldNames = index.columns.map((columnName) =>
377
- resolveColumnFieldName(fieldNamesByTable, table.name, columnName),
378
- );
379
- modelAttributes.push(formatModelConstraintAttribute('@@index', fieldNames, index.name));
380
- }
381
- }
382
-
383
- // @@map
384
- if (mapName) {
385
- modelAttributes.push(`@@map("${escapePslString(mapName)}")`);
386
- }
387
-
388
- // Table without PK warning
389
- const tableComment = !table.primaryKey
390
- ? '// WARNING: This table has no primary key in the database'
391
- : undefined;
392
-
393
- return {
394
- name: modelName,
395
- mapName: mapName ?? undefined,
396
- fields,
397
- modelAttributes,
398
- comment: tableComment,
399
- };
400
- }
401
-
402
- function parseDefaultIfNeeded(
403
- value: PrintableSqlColumnDefault,
404
- nativeType: string | undefined,
405
- rawDefaultParser: PslPrinterOptions['parseRawDefault'],
406
- ): ColumnDefault | undefined {
407
- if (typeof value === 'string') {
408
- return rawDefaultParser ? rawDefaultParser(value, nativeType) : undefined;
409
- }
410
- return value;
411
- }
412
-
413
- function formatFieldConstraintAttribute(
414
- attribute: '@id' | '@unique',
415
- constraintName?: string,
416
- ): string {
417
- return constraintName ? `${attribute}(map: "${escapePslString(constraintName)}")` : attribute;
418
- }
419
-
420
- function formatModelConstraintAttribute(
421
- attribute: '@@id' | '@@unique' | '@@index',
422
- fields: readonly string[],
423
- constraintName?: string,
424
- ): string {
425
- const parts = [`[${fields.join(', ')}]`];
426
- if (constraintName) {
427
- parts.push(`map: "${escapePslString(constraintName)}"`);
428
- }
429
- return `${attribute}(${parts.join(', ')})`;
430
- }
431
-
432
- function buildFieldNamesByTable(
433
- tables: Record<string, PslPrintableSqlTable>,
434
- ): ReadonlyMap<string, TableColumnFieldNameMap> {
435
- const fieldNamesByTable = new Map<string, TableColumnFieldNameMap>();
436
-
437
- for (const table of Object.values(tables)) {
438
- const columns = Object.values(table.columns).map((column, index) => {
439
- const { name, map } = toFieldName(column.name);
440
- return {
441
- columnName: column.name,
442
- desiredFieldName: name,
443
- fieldMap: map,
444
- index,
445
- };
446
- });
447
-
448
- const assignmentOrder = [...columns].sort((left, right) => {
449
- const mapComparison =
450
- Number(left.fieldMap !== undefined) - Number(right.fieldMap !== undefined);
451
- if (mapComparison !== 0) {
452
- return mapComparison;
453
- }
454
- return left.index - right.index;
455
- });
456
-
457
- const usedFieldNames = new Set<string>();
458
- const tableFieldNames = new Map<string, ResolvedColumnFieldName>();
459
-
460
- for (const column of assignmentOrder) {
461
- const fieldName = createUniqueFieldName(column.desiredFieldName, usedFieldNames);
462
- usedFieldNames.add(fieldName);
463
- tableFieldNames.set(column.columnName, {
464
- fieldName,
465
- fieldMap: column.fieldMap,
466
- });
467
- }
468
-
469
- fieldNamesByTable.set(table.name, tableFieldNames);
470
- }
471
-
472
- return fieldNamesByTable;
473
- }
474
-
475
- function resolveColumnFieldName(
476
- fieldNamesByTable: ReadonlyMap<string, TableColumnFieldNameMap>,
477
- tableName: string,
478
- columnName: string,
479
- ): string {
480
- return (
481
- fieldNamesByTable.get(tableName)?.get(columnName)?.fieldName ?? toFieldName(columnName).name
482
- );
483
- }
484
-
485
- function createUniqueFieldName(desiredName: string, usedFieldNames: ReadonlySet<string>): string {
486
- if (!usedFieldNames.has(desiredName)) {
487
- return desiredName;
488
- }
489
-
490
- let counter = 2;
491
- while (usedFieldNames.has(`${desiredName}${counter}`)) {
492
- counter++;
493
- }
494
- return `${desiredName}${counter}`;
495
- }
496
-
497
- function buildTopLevelNameMap(
498
- sources: Iterable<string>,
499
- normalize: (source: string) => TopLevelNameResult,
500
- kind: 'model' | 'enum',
501
- sourceKind: 'table' | 'enum type',
502
- ): Map<string, TopLevelNameResult> {
503
- const results = new Map<string, TopLevelNameResult>();
504
- const normalizedToSources = new Map<string, string[]>();
505
-
506
- for (const source of sources) {
507
- const normalized = normalize(source);
508
- results.set(source, normalized);
509
- normalizedToSources.set(normalized.name, [
510
- ...(normalizedToSources.get(normalized.name) ?? []),
511
- source,
512
- ]);
513
- }
514
-
515
- const duplicates = [...normalizedToSources.entries()].filter(
516
- ([, conflictingSources]) => conflictingSources.length > 1,
517
- );
518
- if (duplicates.length > 0) {
519
- const details = duplicates.map(
520
- ([normalizedName, conflictingSources]) =>
521
- `- ${kind} "${normalizedName}" from ${sourceKind}s ${conflictingSources
522
- .map((source) => `"${source}"`)
523
- .join(', ')}`,
524
- );
525
- throw new Error(`PSL ${kind} name collisions detected:\n${details.join('\n')}`);
526
- }
527
-
528
- return results;
529
- }
530
-
531
- function assertNoCrossKindNameCollisions(
532
- modelNames: ReadonlyMap<string, TopLevelNameResult>,
533
- enumNames: ReadonlyMap<string, TopLevelNameResult>,
534
- ): void {
535
- const enumSourceByName = new Map([...enumNames].map(([source, result]) => [result.name, source]));
536
-
537
- const collisions = [...modelNames.entries()]
538
- .map(([tableName, result]) => {
539
- const enumSource = enumSourceByName.get(result.name);
540
- return enumSource
541
- ? `- identifier "${result.name}" from table "${tableName}" collides with enum type "${enumSource}"`
542
- : undefined;
543
- })
544
- .filter((detail): detail is string => detail !== undefined);
545
-
546
- if (collisions.length > 0) {
547
- throw new Error(`PSL top-level name collisions detected:\n${collisions.join('\n')}`);
548
- }
549
- }
550
-
551
- function createReservedNamedTypeNames(
552
- modelNames: ReadonlyMap<string, TopLevelNameResult>,
553
- enumNames: ReadonlyMap<string, TopLevelNameResult>,
554
- ): Set<string> {
555
- const reservedNames = new Set<string>(PSL_SCALAR_TYPE_NAMES);
556
-
557
- for (const result of modelNames.values()) {
558
- reservedNames.add(result.name);
559
- }
560
-
561
- for (const result of enumNames.values()) {
562
- reservedNames.add(result.name);
563
- }
564
-
565
- return reservedNames;
566
- }
567
-
568
- function seedNamedTypeRegistry(
569
- schemaIR: PslPrintableSqlSchemaIR,
570
- typeMap: PslPrinterOptions['typeMap'],
571
- enumNameMap: ReadonlyMap<string, string>,
572
- reservedNames: ReadonlySet<string>,
573
- ): NamedTypeRegistry {
574
- const seeds = new Map<
575
- string,
576
- {
577
- readonly baseType: string;
578
- readonly desiredName: string;
579
- readonly attributes: readonly string[];
580
- }
581
- >();
582
-
583
- for (const tableName of Object.keys(schemaIR.tables).sort()) {
584
- const table = schemaIR.tables[tableName];
585
- if (!table) {
586
- continue;
587
- }
588
-
589
- for (const columnName of Object.keys(table.columns).sort()) {
590
- const column = table.columns[columnName];
591
- if (!column) {
592
- continue;
593
- }
594
-
595
- const resolution = typeMap.resolve(column.nativeType, table.annotations);
596
- if (
597
- 'unsupported' in resolution ||
598
- enumNameMap.has(column.nativeType) ||
599
- !resolution.nativeTypeAttribute
600
- ) {
601
- continue;
602
- }
603
-
604
- const signatureKey = createNamedTypeSignatureKey(resolution);
605
- if (!seeds.has(signatureKey)) {
606
- seeds.set(signatureKey, {
607
- baseType: resolution.pslType,
608
- desiredName: toNamedTypeName(column.name),
609
- attributes: [renderNativeTypeAttribute(resolution.nativeTypeAttribute)],
610
- });
611
- }
612
- }
613
- }
614
-
615
- const registry: NamedTypeRegistry = {
616
- entriesByKey: new Map<string, PrinterNamedType>(),
617
- usedNames: new Set<string>(reservedNames),
618
- };
619
-
620
- const sortedSeeds = [...seeds.entries()].sort((left, right) => {
621
- const desiredNameComparison = left[1].desiredName.localeCompare(right[1].desiredName);
622
- if (desiredNameComparison !== 0) {
623
- return desiredNameComparison;
624
- }
625
- return left[0].localeCompare(right[0]);
626
- });
627
-
628
- for (const [signatureKey, seed] of sortedSeeds) {
629
- const name = createUniqueFieldName(seed.desiredName, registry.usedNames);
630
- registry.entriesByKey.set(signatureKey, {
631
- name,
632
- baseType: seed.baseType,
633
- attributes: seed.attributes,
634
- });
635
- registry.usedNames.add(name);
636
- }
637
-
638
- return registry;
639
- }
640
-
641
- function resolveNamedTypeName(
642
- registry: NamedTypeRegistry,
643
- resolution: {
644
- readonly pslType: string;
645
- readonly nativeType: string;
646
- readonly typeParams?: Record<string, unknown>;
647
- readonly nativeTypeAttribute?: PslNativeTypeAttribute;
648
- },
649
- ): string {
650
- const key = createNamedTypeSignatureKey(resolution);
651
- const existing = registry.entriesByKey.get(key);
652
- if (existing) {
653
- return existing.name;
654
- }
655
-
656
- throw new Error(`Named type registry was not seeded for native type "${resolution.nativeType}"`);
657
- }
658
-
659
- function createNamedTypeSignatureKey(resolution: {
660
- readonly pslType: string;
661
- readonly nativeType: string;
662
- readonly typeParams?: Record<string, unknown>;
663
- readonly nativeTypeAttribute?: PslNativeTypeAttribute;
664
- }): string {
665
- return JSON.stringify({
666
- baseType: resolution.pslType,
667
- nativeTypeAttribute: resolution.nativeTypeAttribute
668
- ? {
669
- name: resolution.nativeTypeAttribute.name,
670
- args: resolution.nativeTypeAttribute.args ?? null,
671
- }
672
- : null,
673
- });
674
- }
675
-
676
- function isNormalizedEnumMemberReservedWord(value: string): boolean {
677
- return ENUM_MEMBER_RESERVED_WORDS.has(value.toLowerCase());
678
- }
679
-
680
- function normalizeEnumMemberName(value: string, usedNames: ReadonlySet<string>): string {
681
- const desiredName =
682
- PSL_IDENTIFIER_PATTERN.test(value) && !isNormalizedEnumMemberReservedWord(value)
683
- ? value
684
- : createNormalizedEnumMemberBaseName(value);
685
-
686
- return createUniqueFieldName(desiredName, usedNames);
687
- }
688
-
689
- function createNormalizedEnumMemberBaseName(value: string): string {
690
- const tokens = value.match(/[A-Za-z0-9]+/g)?.map((token) => token.toLowerCase()) ?? [];
691
- let normalized = tokens[0] ?? 'value';
692
-
693
- for (const token of tokens.slice(1)) {
694
- normalized += token.charAt(0).toUpperCase() + token.slice(1);
695
- }
696
-
697
- if (isNormalizedEnumMemberReservedWord(normalized) || /^\d/.test(normalized)) {
698
- normalized = `_${normalized}`;
699
- }
700
-
701
- return normalized;
702
- }
703
-
704
- /**
705
- * Topologically sorts models by FK dependencies.
706
- * Parent tables (those referenced by FKs) come before child tables.
707
- * Alphabetical fallback for cycles.
708
- */
709
- function topologicalSort(
710
- models: PrinterModel[],
711
- tables: Record<string, PslPrintableSqlTable>,
712
- modelNameMap: ReadonlyMap<string, string>,
713
- ): PrinterModel[] {
714
- const modelByName = new Map<string, PrinterModel>();
715
- for (const model of models) {
716
- modelByName.set(model.name, model);
717
- }
718
-
719
- // Build adjacency: model name → set of model names it depends on (via FK)
720
- const deps = new Map<string, Set<string>>();
721
- const tableToModel = new Map<string, string>();
722
- for (const tableName of Object.keys(tables)) {
723
- const modelName = modelNameMap.get(tableName) as string;
724
- tableToModel.set(tableName, modelName);
725
- deps.set(modelName, new Set());
726
- }
727
-
728
- for (const [tableName, table] of Object.entries(tables)) {
729
- const modelName = tableToModel.get(tableName) as string;
730
- for (const fk of table.foreignKeys) {
731
- const refModelName = tableToModel.get(fk.referencedTable);
732
- if (refModelName && refModelName !== modelName) {
733
- (deps.get(modelName) as Set<string>).add(refModelName);
734
- }
735
- }
736
- }
737
-
738
- // DFS-based topological sort with cycle detection
739
- const result: PrinterModel[] = [];
740
- const visited = new Set<string>();
741
- const visiting = new Set<string>();
742
-
743
- // Sort model names alphabetically for deterministic output
744
- const sortedNames = [...deps.keys()].sort();
745
-
746
- function visit(name: string): void {
747
- if (visited.has(name)) return;
748
- if (visiting.has(name)) return; // Cycle — break it (alphabetical order handles it)
749
- visiting.add(name);
750
-
751
- // Visit dependencies first (parent tables before child tables)
752
- const sortedDeps = [...(deps.get(name) as Set<string>)].sort();
753
- for (const dep of sortedDeps) {
754
- visit(dep);
755
- }
756
-
757
- visiting.delete(name);
758
- visited.add(name);
759
- result.push(modelByName.get(name) as PrinterModel);
760
- }
761
-
762
- for (const name of sortedNames) {
763
- visit(name);
764
- }
765
-
766
- return result;
767
- }
768
-
769
- // ============================================================================
770
- // Serialization
771
- // ============================================================================
772
-
773
- /**
774
- * Serializes the `types` block.
775
- */
776
- function serializeTypesBlock(namedTypes: readonly PrinterNamedType[]): string {
777
- const lines = ['types {'];
778
- for (const nt of namedTypes) {
779
- const attrStr = nt.attributes.length > 0 ? ` ${nt.attributes.join(' ')}` : '';
780
- lines.push(` ${nt.name} = ${nt.baseType}${attrStr}`);
781
- }
782
- lines.push('}');
783
- return lines.join('\n');
784
- }
785
-
786
- function renderNativeTypeAttribute(attribute: PslNativeTypeAttribute): string {
787
- if (!attribute.args || attribute.args.length === 0) {
788
- return `@${attribute.name}`;
789
- }
790
- return `@${attribute.name}(${attribute.args.join(', ')})`;
791
- }
792
-
793
- /**
794
- * Serializes an enum block.
795
- */
796
- function serializeEnum(e: {
797
- name: string;
798
- mapName?: string | undefined;
799
- values: readonly string[];
800
- }): string {
801
- const lines = [`enum ${e.name} {`];
802
- const usedNames = new Set<string>();
803
- for (const value of e.values) {
804
- const memberName = normalizeEnumMemberName(value, usedNames);
805
- lines.push(` ${memberName}`);
806
- usedNames.add(memberName);
807
- }
808
- if (e.mapName) {
809
- lines.push('');
810
- lines.push(` @@map("${escapePslString(e.mapName)}")`);
811
- }
812
- lines.push('}');
813
- return lines.join('\n');
814
- }
815
-
816
- /**
817
- * Serializes a model block with column-aligned fields.
818
- */
819
- function serializeModel(model: PrinterModel): string {
820
- const lines: string[] = [];
821
-
822
- if (model.comment) {
823
- lines.push(model.comment);
824
- }
825
- lines.push(`model ${model.name} {`);
826
-
827
- // Separate fields into groups:
828
- // 1. @id fields first
829
- // 2. Scalar fields (non-id, non-relation) in original order
830
- // 3. Relation fields
831
- const idFields = model.fields.filter((f) => f.isId);
832
- const scalarFields = model.fields.filter((f) => !f.isId && !f.isRelation);
833
- const relationFields = model.fields.filter((f) => f.isRelation);
834
-
835
- const allOrderedFields = [...idFields, ...scalarFields, ...relationFields];
836
-
837
- if (allOrderedFields.length > 0) {
838
- // Calculate column widths for alignment
839
- const maxNameLen = Math.max(...allOrderedFields.map((f) => f.name.length));
840
- const maxTypeLen = Math.max(...allOrderedFields.map((f) => formatFieldType(f).length));
841
-
842
- for (const field of allOrderedFields) {
843
- const typePart = formatFieldType(field);
844
- const paddedName = field.name.padEnd(maxNameLen);
845
- const paddedType = typePart.padEnd(maxTypeLen);
846
-
847
- if (field.comment) {
848
- lines.push(` ${field.comment}`);
849
- }
850
-
851
- const attrStr = field.attributes.length > 0 ? ` ${field.attributes.join(' ')}` : '';
852
- lines.push(` ${paddedName} ${paddedType}${attrStr}`.trimEnd());
853
- }
854
- }
855
-
856
- // Model-level attributes (blank line before if there are fields)
857
- if (model.modelAttributes.length > 0) {
858
- if (allOrderedFields.length > 0) {
859
- lines.push('');
860
- }
861
- for (const attr of model.modelAttributes) {
862
- lines.push(` ${attr}`);
863
- }
864
- }
865
-
866
- lines.push('}');
867
- return lines.join('\n');
868
- }
869
-
870
- /**
871
- * Formats a field type string with optional/list modifiers.
872
- */
873
- function formatFieldType(field: PrinterField): string {
874
- let type = field.typeName;
875
- if (field.list) {
876
- type += '[]';
877
- } else if (field.optional) {
878
- type += '?';
879
- }
880
- return type;
5
+ export function printPslFromAst(ast: PslDocumentAst): string {
6
+ const doc = astDocumentToPrintDocument(ast);
7
+ return serializePrintDocument(doc);
881
8
  }