Skip to content

Commit

Permalink
fix: Handle multiple connections
Browse files Browse the repository at this point in the history
- Use @prisma/sdk to generate test DMMFs from schema strings
  • Loading branch information
franky47 committed Nov 28, 2021
1 parent 43166d7 commit 1b5860c
Show file tree
Hide file tree
Showing 5 changed files with 927 additions and 89 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"devDependencies": {
"@commitlint/config-conventional": "^15.0.0",
"@prisma/client": "^3.5.0",
"@prisma/sdk": "^3.5.0",
"@types/jest": "^27.0.3",
"@types/node": "^16.11.10",
"@types/object-path": "^0.11.1",
Expand Down
63 changes: 60 additions & 3 deletions src/dmmf.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
// @ts-check

import { parseAnnotation } from './dmmf'
import { getDMMF } from '@prisma/sdk'
import { analyseDMMF, DMMFAnalysis, parseAnnotation } from './dmmf'

describe('dmmf', () => {
describe('parseAnnotation', () => {
Expand Down Expand Up @@ -46,4 +45,62 @@ describe('dmmf', () => {
expect(received!.strictDecryption).toEqual(false)
})
})

test('analyseDMMF', async () => {
const dmmf = await getDMMF({
datamodel: `
model User {
id Int @id @default(autoincrement())
email String @unique
name String? /// @encrypted
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?
categories Category[]
havePinned User[] @relation("pinnedPost")
}
// Model without encrypted fields
model Category {
id Int @id @default(autoincrement())
name String
posts Post[]
}
`
})
const received = analyseDMMF(dmmf)
const expected: DMMFAnalysis = {
models: [
{
name: { titleCase: 'User', lowercase: 'user', plural: 'users' },
fields: { name: { encrypt: true, strictDecryption: false } },
connections: {
Post: [
{ name: 'posts', isList: true },
{ name: 'pinnedPost', isList: false }
]
}
},
{
name: { titleCase: 'Post', lowercase: 'post', plural: 'posts' },
fields: { content: { encrypt: true, strictDecryption: false } },
connections: {
User: [
{ name: 'author', isList: false },
{ name: 'havePinned', isList: true }
]
}
}
]
}
expect(received).toEqual(expected)
})
})
10 changes: 8 additions & 2 deletions src/dmmf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export interface ConnectionDescriptor {
}

// Key: model name (eg: User)
export type ModelConnections = Record<string, ConnectionDescriptor>
export type ModelConnections = Record<string, Array<ConnectionDescriptor>>

export interface DMMFModelDescriptor {
name: {
Expand Down Expand Up @@ -75,7 +75,13 @@ export function analyseDMMF(dmmf: DMMF = Prisma.dmmf): DMMFAnalysis {
name: field.name,
isList: field.isList
}
return { ...connections, [targetModel.name]: connection }
return {
...connections,
[targetModel.name]: [
...(connections[targetModel.name] ?? []),
connection
]
}
},
{}
)
Expand Down
58 changes: 41 additions & 17 deletions src/matchers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,9 @@ export type EncryptionOperations =
| 'updateMany'

/**
* From a given model & field from the DMMF, read annotations & generate
* associated path matchers for encryption.
* Generate matchers for write operations on a given model.
*
* @param model a model from the DMMF
* @param field a field belonging to the model
* Note: we need the list of all model descriptors to resolve connections.
*/
export function getEncryptionMatchersForModel(
model: DMMFModelDescriptor,
Expand All @@ -37,6 +35,15 @@ export function getEncryptionMatchersForModel(
fieldConfig: refModel.fields[field]
})

/**
* Generate matchers for this model's connections.
*
* This allows targeting nested write operations, by visiting
* the connected models (target model), checking out if we're
* linking to an encrypted field, and extracting its configuration.
*
* @param makePath path generator function to simplify matchers declaration
*/
const makeLinkedMatchers = (
makePath: (
relation: string,
Expand All @@ -45,25 +52,42 @@ export function getEncryptionMatchersForModel(
) => string | false
): FieldMatcher[] => {
return Object.entries(model.connections).reduce<FieldMatcher[]>(
(matchers, [targetModelName, { name: field, isList }]) => {
(matchers, [targetModelName, connections]) => {
const targetModel = models.find(
model => model.name.titleCase === targetModelName
)
if (!targetModel) {
return matchers
}
const targetMatchers = Object.keys(targetModel.fields).reduce<
FieldMatcher[]
>((targetMatchers, targetField) => {
const path = makePath(field, targetField, isList)
if (!path) {
return targetMatchers
}
return [
...targetMatchers,
makeFieldMatcher(targetField, path, targetModel)
]
}, [])

/**
* Note: this model can have many connections to the same targetModel, eg:
* - User.posts of type Post[]
* - User.pinnedPost of type Post?
*/
const targetMatchers = connections.flatMap(
({ name: connectionName, isList }) =>
// Navigate through the target model's fields
// keep only those that are encrypted and add
// generate matchers for them.
Object.keys(targetModel.fields).reduce<FieldMatcher[]>(
(targetMatchers, targetField) => {
const path = makePath(connectionName, targetField, isList)
if (!path) {
return targetMatchers
}
const fieldConfig = targetModel.fields[targetField]
if (!fieldConfig?.encrypt) {
return targetMatchers
}
return [
...targetMatchers,
makeFieldMatcher(targetField, path, targetModel)
]
},
[]
)
)
return [...matchers, ...targetMatchers]
},
[]
Expand Down

0 comments on commit 1b5860c

Please sign in to comment.