@prisma-next/psl-printer 0.5.0-dev.8 → 0.5.0-dev.80
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -12
- package/dist/index.d.mts +3 -41
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +205 -873
- package/dist/index.mjs.map +1 -1
- package/package.json +8 -12
- package/src/ast-to-print-document.ts +293 -0
- package/src/exports/index.ts +1 -8
- package/src/print-document.ts +14 -0
- package/src/print-psl.ts +6 -879
- package/src/serialize-print-document.ts +182 -0
- package/src/types.ts +14 -83
- package/dist/postgres.d.mts +0 -32
- package/dist/postgres.d.mts.map +0 -1
- package/dist/postgres.mjs +0 -346
- package/dist/postgres.mjs.map +0 -1
- package/dist/types-BmnVaMF1.d.mts +0 -54
- package/dist/types-BmnVaMF1.d.mts.map +0 -1
- package/src/default-mapping.ts +0 -66
- package/src/exports/postgres.ts +0 -8
- package/src/name-transforms.ts +0 -234
- package/src/postgres-default-mapping.ts +0 -16
- package/src/postgres-type-map.ts +0 -204
- package/src/raw-default-parser.ts +0 -98
- package/src/relation-inference.ts +0 -239
- package/src/schema-validation.ts +0 -308
package/src/print-psl.ts
CHANGED
|
@@ -1,881 +1,8 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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
|
-
|
|
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
|
}
|