From 022c9f5af13ecb482b190a0d24775c67e505f632 Mon Sep 17 00:00:00 2001 From: Renato Cron Date: Mon, 23 Feb 2026 15:39:54 -0300 Subject: [PATCH] feat: add AST-based schema parsing for Prisma >= 6.16.0 compatibility Add @mrleebo/prisma-ast based schema parsing to support Prisma 6.16.0+ where DMMF no longer includes field-level documentation. - Add analyseSchema() to parse schema from string - Add analyseSchemaFile() to parse from file or directory - Add findSchemaPath() for auto-detection of schema files - Support multi-file schemas (prismaSchemaFolder) - Maintain backward compatibility with DMMF fallback - Add 17 AST-specific tests including multi-file tests - Export analyseSchema and analyseSchemaFile functions Fixes #143 --- package.json | 3 +- pnpm-lock.yaml | 62 +++++ src/ast.test.ts | 513 ++++++++++++++++++++++++++++++++++++++++++ src/ast.ts | 304 +++++++++++++++++++++++++ src/extension.ts | 38 +++- src/generator/main.ts | 10 +- src/index.ts | 1 + src/middleware.ts | 38 +++- src/types.ts | 13 ++ 9 files changed, 974 insertions(+), 8 deletions(-) create mode 100644 src/ast.test.ts create mode 100644 src/ast.ts diff --git a/package.json b/package.json index 365ce73..8220d8c 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "dependencies": { "@47ng/cloak": "^1.2.0", "@47ng/codec": "^1.1.0", + "@mrleebo/prisma-ast": "0.14.0", "@prisma/generator-helper": "6.13.0", "debug": "^4.4.0", "immer": "^10.1.1", @@ -54,7 +55,7 @@ "zod": "^3.24.0" }, "peerDependencies": { - "@prisma/client": ">= 4.7 && < 6.14.0" + "@prisma/client": ">=4.7.0" }, "devDependencies": { "@commitlint/config-conventional": "^19.8.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d90b51e..b1d5ef3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@47ng/codec': specifier: ^1.1.0 version: 1.1.0 + '@mrleebo/prisma-ast': + specifier: 0.14.0 + version: 0.14.0 '@prisma/generator-helper': specifier: 6.13.0 version: 6.13.0 @@ -147,6 +150,21 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} + '@chevrotain/cst-dts-gen@11.1.1': + resolution: {integrity: sha512-fRHyv6/f542qQqiRGalrfJl/evD39mAvbJLCekPazhiextEatq1Jx1K/i9gSd5NNO0ds03ek0Cbo/4uVKmOBcw==} + + '@chevrotain/gast@11.1.1': + resolution: {integrity: sha512-Ko/5vPEYy1vn5CbCjjvnSO4U7GgxyGm+dfUZZJIWTlQFkXkyym0jFYrWEU10hyCjrA7rQtiHtBr0EaZqvHFZvg==} + + '@chevrotain/regexp-to-ast@11.1.1': + resolution: {integrity: sha512-ctRw1OKSXkOrR8VTvOxrQ5USEc4sNrfwXHa1NuTcR7wre4YbjPcKw+82C2uylg/TEwFRgwLmbhlln4qkmDyteg==} + + '@chevrotain/types@11.1.1': + resolution: {integrity: sha512-wb2ToxG8LkgPYnKe9FH8oGn3TMCBdnwiuNC5l5y+CtlaVRbCytU0kbVsk6CGrqTL4ZN4ksJa0TXOYbxpbthtqw==} + + '@chevrotain/utils@11.1.1': + resolution: {integrity: sha512-71eTYMzYXYSFPrbg/ZwftSaSDld7UYlS8OQa3lNnn9jzNtpFbaReRRyghzqS7rI3CDaorqpPJJcXGHK+FE1TVQ==} + '@colors/colors@1.5.0': resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} @@ -402,6 +420,10 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@mrleebo/prisma-ast@0.14.0': + resolution: {integrity: sha512-nKouX7rsrzk/5nk6Wayvgqt81tnAbSbPFC5XzRTDc5/tSCrBf/tSMAXEm1CeUznC/ZL664iH5KNwvvmPtJ8jqQ==} + engines: {node: '>=20.19.0'} + '@napi-rs/wasm-runtime@1.1.1': resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==} @@ -994,6 +1016,9 @@ packages: resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} engines: {node: '>=10'} + chevrotain@11.1.1: + resolution: {integrity: sha512-f0yv5CPKaFxfsPTBzX7vGuim4oIC1/gcS7LUGdBSwl2dU6+FON6LVUksdOo1qJjoUvXNn45urgh8C+0a24pACQ==} + chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} @@ -1594,6 +1619,10 @@ packages: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} + lilconfig@2.1.0: + resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} + engines: {node: '>=10'} + lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} @@ -2672,6 +2701,23 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} + '@chevrotain/cst-dts-gen@11.1.1': + dependencies: + '@chevrotain/gast': 11.1.1 + '@chevrotain/types': 11.1.1 + lodash-es: 4.17.23 + + '@chevrotain/gast@11.1.1': + dependencies: + '@chevrotain/types': 11.1.1 + lodash-es: 4.17.23 + + '@chevrotain/regexp-to-ast@11.1.1': {} + + '@chevrotain/types@11.1.1': {} + + '@chevrotain/utils@11.1.1': {} + '@colors/colors@1.5.0': optional: true @@ -2895,6 +2941,11 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@mrleebo/prisma-ast@0.14.0': + dependencies: + chevrotain: 11.1.1 + lilconfig: 2.1.0 + '@napi-rs/wasm-runtime@1.1.1': dependencies: '@emnapi/core': 1.8.1 @@ -3486,6 +3537,15 @@ snapshots: char-regex@1.0.2: {} + chevrotain@11.1.1: + dependencies: + '@chevrotain/cst-dts-gen': 11.1.1 + '@chevrotain/gast': 11.1.1 + '@chevrotain/regexp-to-ast': 11.1.1 + '@chevrotain/types': 11.1.1 + '@chevrotain/utils': 11.1.1 + lodash-es: 4.17.23 + chokidar@4.0.3: dependencies: readdirp: 4.1.2 @@ -4056,6 +4116,8 @@ snapshots: kleur@3.0.3: {} + lilconfig@2.1.0: {} + lines-and-columns@1.2.4: {} load-json-file@4.0.0: diff --git a/src/ast.test.ts b/src/ast.test.ts new file mode 100644 index 0000000..aab5c7a --- /dev/null +++ b/src/ast.test.ts @@ -0,0 +1,513 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, test } from 'vitest' +import { analyseSchema, analyseSchemaFile } from './ast' +import type { DMMFModels } from './dmmf' +import { HashFieldNormalizeOptions } from './types' + +describe('ast', () => { + test('analyseSchema - basic schema with encrypted fields', () => { + const schema = ` + model User { + id Int @id @default(autoincrement()) + email String @unique + name String? /// @encrypted + nameHash String? /// @encryption:hash(name)?normalize=lowercase + posts Post[] + pinnedPost Post? @relation(fields: [pinnedPostId], references: [id], name: "pinnedPost") + pinnedPostId Int? + } + + model Post { + id Int @id @default(autoincrement()) + title String + content String? /// @encrypted + author User? @relation(fields: [authorId], references: [id], onDelete: Cascade, onUpdate: Cascade) + authorId Int? + cursor Int @unique /// @encryption:cursor + categories Category[] + havePinned User[] @relation("pinnedPost") + } + + // Model without encrypted fields + model Category { + id Int @id @default(autoincrement()) + name String + posts Post[] + } + + // Cursor fallback on unique fields + model Unique { + id Json @id // invalid type for iteration + unique String @unique + } + ` + + const received = analyseSchema(schema) + const expected: DMMFModels = { + User: { + fields: { + name: { + encrypt: true, + strictDecryption: false, + hash: { + targetField: 'nameHash', + algorithm: 'sha256', + inputEncoding: 'utf8', + outputEncoding: 'hex', + normalize: [HashFieldNormalizeOptions.lowercase] + } + } + }, + connections: { + posts: { modelName: 'Post', isList: true }, + pinnedPost: { modelName: 'Post', isList: false } + }, + cursor: 'id' + }, + Post: { + fields: { + content: { encrypt: true, strictDecryption: false } + }, + connections: { + author: { modelName: 'User', isList: false }, + categories: { modelName: 'Category', isList: true }, + havePinned: { modelName: 'User', isList: true } + }, + cursor: 'cursor' + }, + Category: { + fields: {}, + connections: { + posts: { modelName: 'Post', isList: true } + }, + cursor: 'id' + }, + Unique: { + fields: {}, + connections: {}, + cursor: 'unique' + } + } + expect(received).toEqual(expected) + }) + + test('analyseSchema - strict mode', () => { + const schema = ` + model Secret { + id Int @id @default(autoincrement()) + value String /// @encrypted?mode=strict + } + ` + const result = analyseSchema(schema) + expect(result.Secret.fields.value.encrypt).toBe(true) + expect(result.Secret.fields.value.strictDecryption).toBe(true) + }) + + test('analyseSchema - readonly mode', () => { + const schema = ` + model Legacy { + id Int @id @default(autoincrement()) + value String /// @encrypted?mode=readonly + } + ` + const result = analyseSchema(schema) + expect(result.Legacy.fields.value.encrypt).toBe(false) + expect(result.Legacy.fields.value.strictDecryption).toBe(false) + }) + + test('analyseSchema - multiple encrypted fields', () => { + const schema = ` + model Sensitive { + id Int @id @default(autoincrement()) + secret1 String /// @encrypted + secret2 String /// @encrypted?mode=strict + public String + } + ` + const result = analyseSchema(schema) + expect(Object.keys(result.Sensitive.fields)).toEqual(['secret1', 'secret2']) + expect(result.Sensitive.fields.secret1.encrypt).toBe(true) + expect(result.Sensitive.fields.secret2.strictDecryption).toBe(true) + }) + + test('analyseSchema - throws on non-String encrypted field', () => { + const schema = ` + model Bad { + id Int @id @default(autoincrement()) + value Int /// @encrypted + } + ` + expect(() => analyseSchema(schema)).toThrow(/unsupported type/) + }) + + test('analyseSchema - throws on non-unique cursor', () => { + const schema = ` + model Bad { + id Int @id @default(autoincrement()) + cursor String /// @encryption:cursor + value String /// @encrypted + } + ` + expect(() => analyseSchema(schema)).toThrow(/should have a @unique/) + }) + + test('analyseSchema - throws on encrypted cursor', () => { + const schema = ` + model Bad { + id Int @id @default(autoincrement()) + cursor String @unique /// @encrypted @encryption:cursor + value String /// @encrypted + } + ` + expect(() => analyseSchema(schema)).toThrow(/cannot be used as a cursor/) + }) + + test('analyseSchema - BigInt cursor', () => { + const schema = ` + model BigIntModel { + id BigInt @id + value String /// @encrypted + } + ` + const result = analyseSchema(schema) + expect(result.BigIntModel.cursor).toBe('id') + }) + + test('analyseSchema - hash with full options', () => { + const schema = ` + model Hashed { + id Int @id @default(autoincrement()) + email String /// @encrypted + emailHash String /// @encryption:hash(email)?algorithm=sha512&inputEncoding=utf8&outputEncoding=base64 + } + ` + const result = analyseSchema(schema) + expect(result.Hashed.fields.email.hash).toEqual({ + targetField: 'emailHash', + algorithm: 'sha512', + inputEncoding: 'utf8', + outputEncoding: 'base64', + normalize: [] + }) + }) + + test('analyseSchema - model with no id and no unique field', () => { + const schema = ` + model NoId { + name String /// @encrypted + value String + } + ` + // Should warn but not throw + const result = analyseSchema(schema) + expect(result.NoId.cursor).toBeUndefined() + }) + + test('analyseSchemaFile - reads from file', () => { + const schemaPath = path.resolve(__dirname, '../prisma/schema.prisma') + const result = analyseSchemaFile(schemaPath) + + // Verify it parses the actual project schema + expect(result.User).toBeDefined() + expect(result.User.fields.name).toBeDefined() + expect(result.User.fields.name.encrypt).toBe(true) + expect(result.Post).toBeDefined() + expect(result.Post.fields.content).toBeDefined() + }) + + test('analyseSchema - produces same output as analyseDMMF for matching schema', async () => { + // This test verifies AST output matches DMMF output for the same schema + const { getDMMF } = await import('@prisma/internals') + const schema = ` + model User { + id Int @id @default(autoincrement()) + email String @unique + name String? /// @encrypted + nameHash String? /// @encryption:hash(name)?normalize=lowercase + posts Post[] + pinnedPost Post? @relation(fields: [pinnedPostId], references: [id], name: "pinnedPost") + pinnedPostId Int? + } + + model Post { + id Int @id @default(autoincrement()) + title String + content String? /// @encrypted + author User? @relation(fields: [authorId], references: [id], onDelete: Cascade, onUpdate: Cascade) + authorId Int? + cursor Int @unique /// @encryption:cursor + categories Category[] + havePinned User[] @relation("pinnedPost") + } + + model Category { + id Int @id @default(autoincrement()) + name String + posts Post[] + } + + model Unique { + id Json @id + unique String @unique + } + ` + + const { analyseDMMF } = await import('./dmmf') + const dmmf = await getDMMF({ datamodel: schema }) + const dmmfResult = analyseDMMF(dmmf) + const astResult = analyseSchema(schema) + + expect(astResult).toEqual(dmmfResult) + }) + + // Multi-file schema tests + describe('multi-file schema support', () => { + let tmpDir: string + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'prisma-test-')) + }) + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }) + }) + + test('analyseSchemaFile - reads directory with multiple .prisma files', () => { + // Simulate a multi-file schema setup + const schemaFile = path.join(tmpDir, 'schema.prisma') + const userFile = path.join(tmpDir, 'user.prisma') + const postFile = path.join(tmpDir, 'post.prisma') + + fs.writeFileSync( + schemaFile, + ` + datasource db { + provider = "sqlite" + url = "file:./dev.db" + } + + generator client { + provider = "prisma-client-js" + } + ` + ) + + fs.writeFileSync( + userFile, + ` + model User { + id Int @id @default(autoincrement()) + email String @unique + name String /// @encrypted + posts Post[] + } + ` + ) + + fs.writeFileSync( + postFile, + ` + model Post { + id Int @id @default(autoincrement()) + title String + content String /// @encrypted + author User? @relation(fields: [authorId], references: [id]) + authorId Int? + } + ` + ) + + // Pass directory path instead of file path + const result = analyseSchemaFile(tmpDir) + + expect(result.User).toBeDefined() + expect(result.User.fields.name).toBeDefined() + expect(result.User.fields.name.encrypt).toBe(true) + expect(result.User.connections.posts).toEqual({ + modelName: 'Post', + isList: true + }) + + expect(result.Post).toBeDefined() + expect(result.Post.fields.content).toBeDefined() + expect(result.Post.fields.content.encrypt).toBe(true) + expect(result.Post.connections.author).toEqual({ + modelName: 'User', + isList: false + }) + }) + + test('analyseSchemaFile - reads nested subdirectories', () => { + // Simulate a multi-file schema with subdirectories + const modelsDir = path.join(tmpDir, 'models') + fs.mkdirSync(modelsDir) + + fs.writeFileSync( + path.join(tmpDir, 'schema.prisma'), + ` + datasource db { + provider = "sqlite" + url = "file:./dev.db" + } + ` + ) + + fs.writeFileSync( + path.join(modelsDir, 'user.prisma'), + ` + model User { + id Int @id @default(autoincrement()) + name String /// @encrypted + } + ` + ) + + fs.writeFileSync( + path.join(modelsDir, 'post.prisma'), + ` + model Post { + id Int @id @default(autoincrement()) + content String /// @encrypted + author User? @relation(fields: [authorId], references: [id]) + authorId Int? + } + ` + ) + + const result = analyseSchemaFile(tmpDir) + + expect(result.User).toBeDefined() + expect(result.User.fields.name.encrypt).toBe(true) + expect(result.Post).toBeDefined() + expect(result.Post.fields.content.encrypt).toBe(true) + // Cross-file connection should resolve + expect(result.Post.connections.author).toEqual({ + modelName: 'User', + isList: false + }) + }) + + test('analyseSchemaFile - skips migrations directory', () => { + const migrationsDir = path.join(tmpDir, 'migrations') + fs.mkdirSync(migrationsDir) + + fs.writeFileSync( + path.join(tmpDir, 'schema.prisma'), + ` + model User { + id Int @id @default(autoincrement()) + name String /// @encrypted + } + ` + ) + + // This file in migrations/ should be ignored + fs.writeFileSync( + path.join(migrationsDir, 'old.prisma'), + ` + model OldModel { + id Int @id @default(autoincrement()) + value String /// @encrypted + } + ` + ) + + const result = analyseSchemaFile(tmpDir) + + expect(result.User).toBeDefined() + expect(result.OldModel).toBeUndefined() + }) + + test('analyseSchemaFile - throws when directory has no .prisma files', () => { + const emptyDir = path.join(tmpDir, 'empty') + fs.mkdirSync(emptyDir) + + expect(() => analyseSchemaFile(emptyDir)).toThrow( + /No .prisma files found/ + ) + }) + + test('analyseSchemaFile - models across files can reference each other', () => { + // This is the core multi-file schema test - models in separate files + // must be able to reference each other for connections + fs.writeFileSync( + path.join(tmpDir, 'user.prisma'), + ` + model User { + id Int @id @default(autoincrement()) + name String /// @encrypted + posts Post[] + comments Comment[] + } + ` + ) + + fs.writeFileSync( + path.join(tmpDir, 'post.prisma'), + ` + model Post { + id Int @id @default(autoincrement()) + content String /// @encrypted + author User @relation(fields: [authorId], references: [id]) + authorId Int + comments Comment[] + } + ` + ) + + fs.writeFileSync( + path.join(tmpDir, 'comment.prisma'), + ` + model Comment { + id Int @id @default(autoincrement()) + text String /// @encrypted + post Post @relation(fields: [postId], references: [id]) + postId Int + author User @relation(fields: [authorId], references: [id]) + authorId Int + } + ` + ) + + const result = analyseSchemaFile(tmpDir) + + // All three models should be found + expect(result.User).toBeDefined() + expect(result.Post).toBeDefined() + expect(result.Comment).toBeDefined() + + // Cross-file connections should all resolve + expect(result.User.connections.posts).toEqual({ + modelName: 'Post', + isList: true + }) + expect(result.User.connections.comments).toEqual({ + modelName: 'Comment', + isList: true + }) + expect(result.Post.connections.author).toEqual({ + modelName: 'User', + isList: false + }) + expect(result.Post.connections.comments).toEqual({ + modelName: 'Comment', + isList: true + }) + expect(result.Comment.connections.post).toEqual({ + modelName: 'Post', + isList: false + }) + expect(result.Comment.connections.author).toEqual({ + modelName: 'User', + isList: false + }) + + // All encrypted fields should be detected + expect(result.User.fields.name.encrypt).toBe(true) + expect(result.Post.fields.content.encrypt).toBe(true) + expect(result.Comment.fields.text.encrypt).toBe(true) + }) + }) +}) diff --git a/src/ast.ts b/src/ast.ts new file mode 100644 index 0000000..2b8d7e8 --- /dev/null +++ b/src/ast.ts @@ -0,0 +1,304 @@ +import fs from 'node:fs' +import path from 'node:path' +import { getSchema } from '@mrleebo/prisma-ast' +import type { ConnectionDescriptor, DMMFModelDescriptor, DMMFModels } from './dmmf' +import { parseEncryptedAnnotation, parseHashAnnotation } from './dmmf' +import { errors, warnings } from './errors' + +interface ASTField { + type: 'field' + name: string + fieldType: string + array?: boolean + optional?: boolean + attributes?: Array<{ + type: 'attribute' + name: string + args?: any[] + group?: string + }> + comment?: string +} + +interface ASTModel { + type: 'model' + name: string + properties: Array +} + +const supportedCursorTypes = ['Int', 'String', 'BigInt'] + +/** + * Extracts the documentation string from a field's triple-slash comment. + * prisma-ast stores `/// @encrypted` as `field.comment = "/// @encrypted"`. + * We strip the leading `///` and any leading whitespace. + */ +function extractDocumentation(comment?: string): string | undefined { + if (!comment) return undefined + // Handle triple-slash comments: "/// @encrypted" -> " @encrypted" + const match = comment.match(/^\/\/\/\s?(.*)/) + return match ? match[1] : undefined +} + +/** + * Check if a field has a specific attribute (e.g., @id, @unique). + */ +function hasAttribute(field: ASTField, attrName: string): boolean { + return field.attributes?.some(attr => attr.name === attrName) ?? false +} + +/** + * Analyses a Prisma schema string directly using AST parsing, + * returning the same DMMFModels structure as analyseDMMF. + * This avoids dependency on Prisma's internal DMMF format. + */ +export function analyseSchema(schemaSource: string): DMMFModels { + const schema = getSchema(schemaSource) + + // Extract all model blocks + const allModels = schema.list.filter( + (block): block is ASTModel => block.type === 'model' + ) + + // Extract all model names for connection resolution + const modelNames = new Set(allModels.map(m => m.name)) + + return allModels.reduce((output, model) => { + // Filter to only field properties + const fields = model.properties.filter( + (prop): prop is ASTField => prop.type === 'field' + ) + + // Find cursor field + const idField = fields.find( + field => + hasAttribute(field, 'id') && + supportedCursorTypes.includes(String(field.fieldType)) + ) + const uniqueField = fields.find( + field => + hasAttribute(field, 'unique') && + supportedCursorTypes.includes(String(field.fieldType)) + ) + const cursorField = fields.find(field => { + const doc = extractDocumentation(field.comment) + return doc?.includes('@encryption:cursor') + }) + + if (cursorField) { + // Make sure custom cursor field is valid + if (!hasAttribute(cursorField, 'unique')) { + throw new Error(errors.nonUniqueCursor(model.name, cursorField.name)) + } + if (!supportedCursorTypes.includes(String(cursorField.fieldType))) { + throw new Error( + errors.unsupportedCursorType( + model.name, + cursorField.name, + String(cursorField.fieldType) + ) + ) + } + const cursorDoc = extractDocumentation(cursorField.comment) + if (cursorDoc?.includes('@encrypted')) { + throw new Error(errors.encryptedCursor(model.name, cursorField.name)) + } + } + + const modelDescriptor: DMMFModelDescriptor = { + cursor: cursorField?.name ?? idField?.name ?? uniqueField?.name, + fields: fields.reduce( + (fieldMap, field) => { + const doc = extractDocumentation(field.comment) + const fieldConfig = parseEncryptedAnnotation( + doc, + model.name, + field.name + ) + if (fieldConfig && String(field.fieldType) !== 'String') { + // Build a DMMFModel/DMMFField-compatible object for the error message + throw new Error( + errors.unsupportedFieldType( + { name: model.name, fields: [] } as any, + { + name: field.name, + type: field.fieldType, + isList: false, + isUnique: false, + isId: false + } as any + ) + ) + } + return fieldConfig + ? { ...fieldMap, [field.name]: fieldConfig } + : fieldMap + }, + {} + ), + connections: fields.reduce( + (connections, field) => { + // A field is a connection if its type is the name of another model + if (!modelNames.has(String(field.fieldType))) { + return connections + } + const connection: ConnectionDescriptor = { + modelName: String(field.fieldType), + isList: field.array ?? false + } + return { + ...connections, + [field.name]: connection + } + }, + {} + ) + } + + // Inject hash information + fields.forEach(field => { + const doc = extractDocumentation(field.comment) + const hashConfig = parseHashAnnotation(doc, model.name, field.name) + if (!hashConfig) { + return + } + if (String(field.fieldType) !== 'String') { + throw new Error( + errors.unsupporteHashFieldType( + { name: model.name, fields: [] } as any, + { + name: field.name, + type: field.fieldType, + isList: false, + isUnique: false, + isId: false + } as any + ) + ) + } + const { sourceField, ...hash } = hashConfig + if (!(sourceField in modelDescriptor.fields)) { + throw new Error( + errors.hashSourceFieldNotFound( + { name: model.name, fields: [] } as any, + { + name: field.name, + type: field.fieldType, + isList: false, + isUnique: false, + isId: false + } as any, + sourceField + ) + ) + } + modelDescriptor.fields[hashConfig.sourceField].hash = hash + }) + + if ( + Object.keys(modelDescriptor.fields).length > 0 && + !modelDescriptor.cursor + ) { + console.warn(warnings.noCursorFound(model.name)) + } + + return { + ...output, + [model.name]: modelDescriptor + } + }, {}) +} + +/** + * Recursively collects all `.prisma` files from a directory. + */ +function collectPrismaFiles(dirPath: string): string[] { + const files: string[] = [] + const entries = fs.readdirSync(dirPath, { withFileTypes: true }) + for (const entry of entries) { + const fullPath = path.join(dirPath, entry.name) + if (entry.isDirectory()) { + // Skip migrations and node_modules directories + if (entry.name === 'migrations' || entry.name === 'node_modules') { + continue + } + files.push(...collectPrismaFiles(fullPath)) + } else if (entry.isFile() && entry.name.endsWith('.prisma')) { + files.push(fullPath) + } + } + return files +} + +/** + * Reads all .prisma files from a directory and concatenates them + * into a single schema string. This supports Prisma's multi-file + * schema feature (prismaSchemaFolder) available since Prisma 5.15. + */ +function readSchemaDirectory(dirPath: string): string { + const prismaFiles = collectPrismaFiles(dirPath) + if (prismaFiles.length === 0) { + throw new Error( + `[prisma-field-encryption] No .prisma files found in directory: ${dirPath}` + ) + } + // Sort for deterministic ordering + prismaFiles.sort() + + return prismaFiles + .map(filePath => fs.readFileSync(filePath, 'utf-8')) + .join('\n\n') +} + +/** + * Analyses a Prisma schema from a file path or directory path. + * If the path is a directory, all `.prisma` files within it + * (including nested subdirectories) are read and merged, + * supporting Prisma's multi-file schema feature. + */ +export function analyseSchemaFile(schemaPath: string): DMMFModels { + const resolvedPath = path.resolve(schemaPath) + const stat = fs.statSync(resolvedPath) + + let source: string + if (stat.isDirectory()) { + source = readSchemaDirectory(resolvedPath) + } else { + source = fs.readFileSync(resolvedPath, 'utf-8') + } + return analyseSchema(source) +} + +/** + * Attempts to find the Prisma schema file or directory by looking + * at common locations. Supports both single-file and multi-file + * schema setups. + */ +export function findSchemaPath(): string | undefined { + const candidates = [ + // Single file locations + path.resolve('prisma/schema.prisma'), + path.resolve('schema.prisma'), + // Multi-file schema directory (prismaSchemaFolder) + path.resolve('prisma/schema'), + // The prisma/ directory itself may contain multiple .prisma files + path.resolve('prisma') + ] + for (const candidate of candidates) { + if (!fs.existsSync(candidate)) { + continue + } + const stat = fs.statSync(candidate) + if (stat.isFile()) { + return candidate + } + if (stat.isDirectory()) { + // Only return a directory if it contains .prisma files + const hasSchemaFiles = collectPrismaFiles(candidate).length > 0 + if (hasSchemaFiles) { + return candidate + } + } + } + return undefined +} diff --git a/src/extension.ts b/src/extension.ts index 0285069..cb3a13b 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,18 +1,50 @@ import { Prisma } from '@prisma/client/extension' +import { analyseSchema, analyseSchemaFile, findSchemaPath } from './ast' import { debug } from './debugger' import { analyseDMMF } from './dmmf' import { configureKeys, decryptOnRead, encryptOnWrite } from './encryption' import type { Configuration, MiddlewareParams } from './types' +function resolveModels(config: Configuration) { + // Priority: schemaSource > schemaPath > dmmf > auto-detect schema > fallback to DMMF + if (config.schemaSource) { + return analyseSchema(config.schemaSource) + } + if (config.schemaPath) { + return analyseSchemaFile(config.schemaPath) + } + if (config.dmmf) { + return analyseDMMF(config.dmmf) + } + // Try to auto-detect schema file first (works with Prisma >= 6.16.0) + const detectedPath = findSchemaPath() + if (detectedPath) { + try { + return analyseSchemaFile(detectedPath) + } catch { + // Fall through to DMMF + } + } + // Fallback to DMMF (works with Prisma < 6.16.0) + try { + return analyseDMMF(require('@prisma/client').Prisma.dmmf) + } catch { + throw new Error( + '[prisma-field-encryption] Could not resolve schema. ' + + 'Please provide `schemaPath` or `dmmf` in the configuration. ' + + 'Starting with Prisma 6.16.0, DMMF no longer includes field documentation, ' + + 'so `schemaPath` pointing to your schema.prisma file is required.' + ) + } +} + export function fieldEncryptionExtension< Models extends string = any, Actions extends string = any >(config: Configuration = {}) { const keys = configureKeys(config) debug.setup('Keys: %O', keys) - const models = analyseDMMF( - config.dmmf ?? require('@prisma/client').Prisma.dmmf - ) + const models = resolveModels(config) debug.setup('Models: %O', models) return Prisma.defineExtension({ diff --git a/src/generator/main.ts b/src/generator/main.ts index 45eb070..fa06b8c 100644 --- a/src/generator/main.ts +++ b/src/generator/main.ts @@ -2,6 +2,7 @@ import { generatorHandler } from '@prisma/generator-helper' import fs from 'node:fs/promises' +import { analyseSchema } from '../ast' import { analyseDMMF } from '../dmmf' import { generateIndex } from './generateIndex' import { generateModel } from './generateModel' @@ -21,7 +22,14 @@ generatorHandler({ } }, async onGenerate(options) { - const models = analyseDMMF(options.dmmf) + // Try AST-based parsing first (using schema datamodel string), + // fall back to DMMF for older Prisma versions + let models + try { + models = analyseSchema(options.datamodel) + } catch { + models = analyseDMMF(options.dmmf) + } const outputDir = options.generator.output?.value! const concurrently = options.generator.config?.concurrently === 'true' const prismaClient = options.otherGenerators.find( diff --git a/src/index.ts b/src/index.ts index 63b34da..f912a9d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,3 @@ export { fieldEncryptionExtension } from './extension' // Prisma >= 4.7.0 export { fieldEncryptionMiddleware } from './middleware' // Prisma >= 3.8 +export { analyseSchema, analyseSchemaFile } from './ast' diff --git a/src/middleware.ts b/src/middleware.ts index aa2a932..d9d3397 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,8 +1,42 @@ +import { analyseSchema, analyseSchemaFile, findSchemaPath } from './ast' import { debug } from './debugger' import { analyseDMMF } from './dmmf' import { configureKeys, decryptOnRead, encryptOnWrite } from './encryption' import type { Configuration, Middleware, MiddlewareParams } from './types' +function resolveModels(config: Configuration) { + // Priority: schemaSource > schemaPath > dmmf > auto-detect schema > fallback to DMMF + if (config.schemaSource) { + return analyseSchema(config.schemaSource) + } + if (config.schemaPath) { + return analyseSchemaFile(config.schemaPath) + } + if (config.dmmf) { + return analyseDMMF(config.dmmf) + } + // Try to auto-detect schema file first (works with Prisma >= 6.16.0) + const detectedPath = findSchemaPath() + if (detectedPath) { + try { + return analyseSchemaFile(detectedPath) + } catch { + // Fall through to DMMF + } + } + // Fallback to DMMF (works with Prisma < 6.16.0) + try { + return analyseDMMF(require('@prisma/client').Prisma.dmmf) + } catch { + throw new Error( + '[prisma-field-encryption] Could not resolve schema. ' + + 'Please provide `schemaPath` or `dmmf` in the configuration. ' + + 'Starting with Prisma 6.16.0, DMMF no longer includes field documentation, ' + + 'so `schemaPath` pointing to your schema.prisma file is required.' + ) + } +} + export function fieldEncryptionMiddleware< Models extends string = any, Actions extends string = any @@ -11,9 +45,7 @@ export function fieldEncryptionMiddleware< // or if anything is invalid. const keys = configureKeys(config) debug.setup('Keys: %O', keys) - const models = analyseDMMF( - config.dmmf ?? require('@prisma/client').Prisma.dmmf - ) + const models = resolveModels(config) debug.setup('Models: %O', models) return async function fieldEncryptionMiddleware( diff --git a/src/types.ts b/src/types.ts index 87a6a4a..b67019b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -70,6 +70,19 @@ export interface Configuration { encryptionKey?: string decryptionKeys?: string[] dmmf?: Readonly + /** + * Path to the Prisma schema file. + * When provided, the schema file is parsed directly using AST, + * bypassing Prisma's internal DMMF format. + * This is required for Prisma >= 6.16.0 where DMMF no longer + * exposes field-level documentation. + */ + schemaPath?: string + /** + * Raw Prisma schema source string. + * Alternative to schemaPath: pass the schema content directly. + */ + schemaSource?: string } export type HashFieldConfiguration = {