Skip to content

Commit ee1235b

Browse files
authored
feat(cli): write PSL from db introspect by default (#244)
## Summary - add a new `@prisma-next/psl-printer` package that renders `SqlSchemaIR` into deterministic PSL, including models, relations, enums, named types, and defaults - change `prisma-next db introspect` to write `schema.prisma` by default, move the tree preview behind `--dry-run`, and surface the written PSL path in JSON output - add integration coverage for the brownfield PSL flow and land follow-up fixes for output-path resolution, storage UUID defaults, inferred relation-name escaping, and colliding normalized field aliases ## Limitations - generated PSL does not support enum value `@map(...)` annotations yet ## Testing - `pnpm --filter @prisma-next/cli test -- test/commands/db-introspect-paths.test.ts` - `pnpm test` in `packages/1-framework/2-authoring/psl-printer` - `pnpm typecheck` in `packages/1-framework/2-authoring/psl-printer` <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * CLI: db introspect now writes PSL (.prisma) files by default, supports --output and --dry-run, and reports "Schema written to"; overwrite warnings can be suppressed with --quiet. * **New Features** * New PSL printer: deterministic conversion of introspected schemas into Prisma Schema Language output. * **Enhancements** * Generated schemas include enums, named/parameterized types, relations, indexes, composite keys, preserved defaults, and stable naming. * **Tests** * Extensive unit and E2E coverage for printing, parsing, naming, type mapping, relation inference, and CLI flows. * **Documentation** * README added for the PSL printer. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: jkomyno <12381818+jkomyno@users.noreply.github.com>
1 parent e9be5d1 commit ee1235b

100 files changed

Lines changed: 8886 additions & 625 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/products/psl/README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,9 @@ For package-level responsibilities and supported defaults, see:
3636

3737
This is a deliberate “strict subset” choice: PSL v1 is bounded by the current TS authoring surface + contract model and prefers “fail loudly” over silent partial interpretation.
3838

39-
- **Native type attributes (`@db.`*) are mostly unsupported** today.
40-
- The only parameterized attribute surface currently mapped for parity is `@pgvector.column(...)`.
39+
- **Selected Postgres native type attributes (`@db.`*) are supported on named types** for brownfield round-trips.
40+
- Current support covers the printer/provider subset needed to preserve native storage shape for inferred PSL: `@db.Char`, `@db.VarChar`, `@db.Numeric`, `@db.Uuid`, `@db.SmallInt`, `@db.Real`, `@db.Timestamp`, `@db.Timestamptz`, `@db.Date`, `@db.Time`, `@db.Timetz`, and `@db.Json`.
41+
- The extension-pack parity surface still includes `@pgvector.column(...)` when the corresponding pack is composed.
4142
- **Typed JSON schema parameterization is unsupported** (PSL has no way to encode TS `typeParams` schema payloads in v1).
4243

4344
### Relations
@@ -88,7 +89,7 @@ This is a deliberate “strict subset” choice: PSL v1 is bounded by the curren
8889

8990
These are recurring “next” areas implied by the above limitations:
9091

91-
- **Broader parameterized native types** (`@db.`* parity beyond pgvector)
92+
- **Broader `@db.` parity** beyond the current brownfield round-trip subset
9293
- **Richer index / constraint features** (names, methods, predicates) gated by target/packs
9394
- **Tooling-friendly inline language blocks** (tagged template literals like `sql\`...`) for SQL snippets, with explicit rules (e.g. no interpolation) and parser-agnostic tag configuration
9495

packages/1-framework/2-authoring/psl-parser/src/parser.ts

Lines changed: 62 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,7 @@ function parseModelBlock(context: ParserContext, name: string, bounds: BlockBoun
238238

239239
function parseEnumBlock(context: ParserContext, name: string, bounds: BlockBounds): PslEnum {
240240
const values: PslEnumValue[] = [];
241+
const attributes: PslAttribute[] = [];
241242

242243
for (let lineIndex = bounds.startLine + 1; lineIndex < bounds.endLine; lineIndex += 1) {
243244
const raw = context.lines[lineIndex] ?? '';
@@ -246,6 +247,14 @@ function parseEnumBlock(context: ParserContext, name: string, bounds: BlockBound
246247
continue;
247248
}
248249

250+
if (line.startsWith('@@')) {
251+
const attribute = parseEnumAttribute(context, line, lineIndex);
252+
if (attribute) {
253+
attributes.push(attribute);
254+
}
255+
continue;
256+
}
257+
249258
const valueMatch = line.match(/^([A-Za-z_]\w*)$/);
250259
if (!valueMatch) {
251260
pushDiagnostic(context, {
@@ -267,6 +276,7 @@ function parseEnumBlock(context: ParserContext, name: string, bounds: BlockBound
267276
kind: 'enum',
268277
name,
269278
values,
279+
attributes,
270280
span: createLineRangeSpan(context, bounds.startLine, bounds.endLine),
271281
};
272282
}
@@ -367,6 +377,50 @@ function parseModelAttribute(
367377
});
368378
}
369379

380+
function parseEnumAttribute(
381+
context: ParserContext,
382+
line: string,
383+
lineIndex: number,
384+
): PslAttribute | undefined {
385+
const rawLine = context.lines[lineIndex] ?? '';
386+
const tokenParse = extractAttributeTokensWithSpans(
387+
context,
388+
lineIndex,
389+
line,
390+
firstNonWhitespaceColumn(rawLine),
391+
);
392+
if (!tokenParse.ok || tokenParse.tokens.length !== 1) {
393+
pushDiagnostic(context, {
394+
code: 'PSL_INVALID_ENUM_MEMBER',
395+
message: `Invalid enum value declaration "${line}"`,
396+
span: createTrimmedLineSpan(context, lineIndex),
397+
});
398+
return undefined;
399+
}
400+
const token = tokenParse.tokens[0];
401+
if (!token) {
402+
return undefined;
403+
}
404+
const parsed = parseAttributeToken(context, {
405+
token: token.text,
406+
target: 'enum',
407+
lineIndex,
408+
span: token.span,
409+
});
410+
if (!parsed) {
411+
return undefined;
412+
}
413+
if (parsed.name !== 'map') {
414+
pushDiagnostic(context, {
415+
code: 'PSL_INVALID_ENUM_MEMBER',
416+
message: `Invalid enum value declaration "${line}"`,
417+
span: createTrimmedLineSpan(context, lineIndex),
418+
});
419+
return undefined;
420+
}
421+
return parsed;
422+
}
423+
370424
function parseField(context: ParserContext, line: string, lineIndex: number): PslField | undefined {
371425
const fieldMatch = line.match(/^([A-Za-z_]\w*)\s+([A-Za-z_]\w*(?:\[\])?)(\?)?(.*)$/);
372426
if (!fieldMatch) {
@@ -442,24 +496,25 @@ function parseAttributeToken(
442496
readonly span: PslSpan;
443497
},
444498
): PslAttribute | undefined {
445-
const expectsModelPrefix = input.target === 'model';
446-
if (expectsModelPrefix && !input.token.startsWith('@@')) {
499+
const expectsBlockPrefix = input.target === 'model' || input.target === 'enum';
500+
const targetLabel = input.target === 'enum' ? 'Enum' : 'Model';
501+
if (expectsBlockPrefix && !input.token.startsWith('@@')) {
447502
pushDiagnostic(context, {
448503
code: 'PSL_INVALID_ATTRIBUTE_SYNTAX',
449-
message: `Model attribute "${input.token}" must use @@ prefix`,
504+
message: `${targetLabel} attribute "${input.token}" must use @@ prefix`,
450505
span: input.span,
451506
});
452507
return undefined;
453508
}
454-
if (!expectsModelPrefix && !input.token.startsWith('@')) {
509+
if (!expectsBlockPrefix && !input.token.startsWith('@')) {
455510
pushDiagnostic(context, {
456511
code: 'PSL_INVALID_ATTRIBUTE_SYNTAX',
457512
message: `Attribute "${input.token}" must use @ prefix`,
458513
span: input.span,
459514
});
460515
return undefined;
461516
}
462-
if (!expectsModelPrefix && input.token.startsWith('@@')) {
517+
if (!expectsBlockPrefix && input.token.startsWith('@@')) {
463518
pushDiagnostic(context, {
464519
code: 'PSL_INVALID_ATTRIBUTE_SYNTAX',
465520
message: `Attribute "${input.token}" is not valid in ${input.target} context`,
@@ -468,7 +523,7 @@ function parseAttributeToken(
468523
return undefined;
469524
}
470525

471-
const rawBody = expectsModelPrefix ? input.token.slice(2) : input.token.slice(1);
526+
const rawBody = expectsBlockPrefix ? input.token.slice(2) : input.token.slice(1);
472527
const openParen = rawBody.indexOf('(');
473528
const closeParen = rawBody.lastIndexOf(')');
474529
const hasArgs = openParen >= 0 || closeParen >= 0;
@@ -504,7 +559,7 @@ function parseAttributeToken(
504559
const argsRaw = rawBody.slice(openParen + 1, closeParen);
505560
const parsedArgs = parseAttributeArguments(context, {
506561
argsRaw,
507-
argsOffset: input.span.start.column - 1 + (expectsModelPrefix ? 2 : 1) + openParen + 1,
562+
argsOffset: input.span.start.column - 1 + (expectsBlockPrefix ? 2 : 1) + openParen + 1,
508563
lineIndex: input.lineIndex,
509564
token: input.token,
510565
span: input.span,

packages/1-framework/2-authoring/psl-parser/src/types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export interface PslDefaultLiteralValue {
4141

4242
export type PslDefaultValue = PslDefaultFunctionValue | PslDefaultLiteralValue;
4343

44-
export type PslAttributeTarget = 'field' | 'model' | 'namedType';
44+
export type PslAttributeTarget = 'field' | 'model' | 'enum' | 'namedType';
4545

4646
export interface PslAttributePositionalArgument {
4747
readonly kind: 'positional';
@@ -113,6 +113,7 @@ export interface PslEnum {
113113
readonly kind: 'enum';
114114
readonly name: string;
115115
readonly values: readonly PslEnumValue[];
116+
readonly attributes: readonly PslAttribute[];
116117
readonly span: PslSpan;
117118
}
118119

packages/1-framework/2-authoring/psl-parser/test/parser.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,30 @@ model Account {
171171
});
172172
});
173173

174+
it('parses enum @@map through generic attributes', () => {
175+
const schema = `
176+
enum UserRole {
177+
USER
178+
ADMIN
179+
@@map("user_role")
180+
}
181+
`;
182+
183+
const result = parsePslDocument({
184+
schema,
185+
sourceId: 'schema.prisma',
186+
});
187+
188+
expect(result.ok).toBe(true);
189+
const userRole = result.ast.enums.find((enumBlock) => enumBlock.name === 'UserRole');
190+
expect(userRole?.attributes.find((attribute) => attribute.name === 'map')).toMatchObject({
191+
kind: 'attribute',
192+
target: 'enum',
193+
name: 'map',
194+
args: [{ kind: 'positional', value: '"user_role"' }],
195+
});
196+
});
197+
174198
it('returns diagnostics for malformed attribute syntax', () => {
175199
const schema = `
176200
model User {
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# @prisma-next/psl-printer
2+
3+
Prints Prisma Schema Language (PSL) from introspected SQL schema IR.
4+
5+
## Overview
6+
7+
`@prisma-next/psl-printer` converts `SqlSchemaIR` into deterministic PSL text for brownfield authoring and database introspection flows. It is intentionally printer-only: database inspection, semantic verification, and contract emission stay in adjacent packages.
8+
9+
## Responsibilities
10+
11+
- Convert `SqlSchemaIR` tables, relations, enums, defaults, and indexes into valid PSL output.
12+
- Normalize database names into stable PSL identifiers while preserving original names with `@map` and `@@map`.
13+
- Preserve storage-level defaults when PSL client-side defaults would change semantics.
14+
- Generate deterministic output so round-tripping and snapshot-based tests remain stable.
15+
- Surface unsupported types and raw defaults in a way that keeps the emitted PSL readable.
16+
17+
## Dependencies
18+
19+
- **Depends on**
20+
- `@prisma-next/contract`
21+
- `@prisma-next/sql-schema-ir`
22+
- `@prisma-next/utils`
23+
- **Used by**
24+
- `@prisma-next/cli` for `db introspect`
25+
- future authoring and emit flows that need PSL output from SQL schema IR
26+
27+
## Related Docs
28+
29+
- `docs/Architecture Overview.md`
30+
- `docs/architecture docs/subsystems/2. Contract Emitter & Types.md`
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
{
2+
"name": "@prisma-next/psl-printer",
3+
"version": "0.0.1",
4+
"type": "module",
5+
"sideEffects": false,
6+
"description": "PSL printer: converts SqlSchemaIR to Prisma Schema Language (.prisma) files",
7+
"scripts": {
8+
"build": "tsdown",
9+
"test": "vitest run",
10+
"test:coverage": "vitest run --coverage",
11+
"typecheck": "tsc --project tsconfig.json --noEmit",
12+
"lint": "biome check . --error-on-warnings",
13+
"lint:fix": "biome check --write .",
14+
"lint:fix:unsafe": "biome check --write --unsafe .",
15+
"clean": "rm -rf dist dist-tsc dist-tsc-prod coverage .tmp-output"
16+
},
17+
"dependencies": {
18+
"@prisma-next/contract": "workspace:*",
19+
"@prisma-next/sql-schema-ir": "workspace:*",
20+
"@prisma-next/utils": "workspace:*"
21+
},
22+
"devDependencies": {
23+
"@prisma-next/tsconfig": "workspace:*",
24+
"@prisma-next/tsdown": "workspace:*",
25+
"tsdown": "catalog:",
26+
"typescript": "catalog:",
27+
"vitest": "catalog:"
28+
},
29+
"files": [
30+
"dist",
31+
"src"
32+
],
33+
"exports": {
34+
".": {
35+
"types": "./dist/index.d.mts",
36+
"import": "./dist/index.mjs"
37+
},
38+
"./postgres": {
39+
"types": "./dist/postgres.d.mts",
40+
"import": "./dist/postgres.mjs"
41+
},
42+
"./package.json": "./package.json"
43+
},
44+
"main": "./dist/index.mjs",
45+
"module": "./dist/index.mjs",
46+
"types": "./dist/index.d.mts",
47+
"repository": {
48+
"type": "git",
49+
"url": "https://github.com/prisma/prisma-next.git",
50+
"directory": "packages/1-framework/2-authoring/psl-printer"
51+
}
52+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import type { ColumnDefault } from '@prisma-next/contract/types';
2+
3+
const DEFAULT_FUNCTION_ATTRIBUTES: Readonly<Record<string, string>> = {
4+
'autoincrement()': '@default(autoincrement())',
5+
'now()': '@default(now())',
6+
};
7+
8+
type TaggedBigInt = {
9+
readonly $type: 'bigint';
10+
readonly value: string;
11+
};
12+
13+
export interface DefaultMappingOptions {
14+
readonly functionAttributes?: Readonly<Record<string, string>>;
15+
readonly fallbackFunctionAttribute?: ((expression: string) => string | undefined) | undefined;
16+
}
17+
18+
/**
19+
* Result of mapping a ColumnDefault to a PSL @default expression.
20+
*/
21+
export type DefaultMappingResult = { readonly attribute: string } | { readonly comment: string };
22+
23+
/**
24+
* Maps a normalized ColumnDefault to a PSL @default(...) attribute string,
25+
* or a comment for unrecognized expressions.
26+
*/
27+
export function mapDefault(
28+
columnDefault: ColumnDefault,
29+
options?: DefaultMappingOptions,
30+
): DefaultMappingResult {
31+
switch (columnDefault.kind) {
32+
case 'literal':
33+
return { attribute: `@default(${formatLiteralValue(columnDefault.value)})` };
34+
case 'function': {
35+
const attribute =
36+
options?.functionAttributes?.[columnDefault.expression] ??
37+
DEFAULT_FUNCTION_ATTRIBUTES[columnDefault.expression] ??
38+
options?.fallbackFunctionAttribute?.(columnDefault.expression);
39+
return attribute
40+
? { attribute }
41+
: { comment: `// Raw default: ${columnDefault.expression.replace(/[\r\n]+/g, ' ')}` };
42+
}
43+
}
44+
}
45+
46+
/**
47+
* Formats a literal value for use in @default(...).
48+
*/
49+
function formatLiteralValue(value: unknown): string {
50+
if (value === null) {
51+
return 'null';
52+
}
53+
if (isTaggedBigInt(value)) {
54+
return value.value;
55+
}
56+
57+
switch (typeof value) {
58+
case 'boolean':
59+
case 'number':
60+
case 'bigint':
61+
return String(value);
62+
case 'string':
63+
return quoteString(value);
64+
default:
65+
// Fallback for complex types (arrays, objects) — not representable in PSL @default
66+
return quoteString(JSON.stringify(value));
67+
}
68+
}
69+
70+
function isTaggedBigInt(value: unknown): value is TaggedBigInt {
71+
return (
72+
typeof value === 'object' &&
73+
value !== null &&
74+
'$type' in value &&
75+
(value as Record<string, unknown>)['$type'] === 'bigint'
76+
);
77+
}
78+
79+
function quoteString(str: string): string {
80+
return `"${escapeString(str)}"`;
81+
}
82+
83+
function escapeString(str: string): string {
84+
return JSON.stringify(str).slice(1, -1);
85+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export { printPsl } from '../print-psl';
2+
export type {
3+
PslPrintableSqlColumn,
4+
PslPrintableSqlSchemaIR,
5+
PslPrintableSqlTable,
6+
} from '../schema-validation';
7+
export { validatePrintableSqlSchemaIR } from '../schema-validation';
8+
export type { EnumInfo, PslPrinterOptions, PslTypeMap, PslTypeResolution } from '../types';
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export { createPostgresDefaultMapping } from '../postgres-default-mapping';
2+
export {
3+
createPostgresTypeMap,
4+
extractEnumDefinitions,
5+
extractEnumInfo,
6+
extractEnumTypeNames,
7+
} from '../postgres-type-map';
8+
export { parseRawDefault } from '../raw-default-parser';

0 commit comments

Comments
 (0)