Skip to content

Commit

Permalink
fix: 🐛 properly include create relations
Browse files Browse the repository at this point in the history
  • Loading branch information
dennemark committed Aug 16, 2024
1 parent da783cd commit 97136c1
Show file tree
Hide file tree
Showing 9 changed files with 137 additions and 70 deletions.
11 changes: 6 additions & 5 deletions src/applyDataQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,10 @@ export function applyDataQuery(
args: any,
action: string,
model: string,
creationTree: CreationTree = { type: 'create', children: {} }
creationTree?: CreationTree
) {
creationTree.type = action
const tree = creationTree ? creationTree : { action: action, model: model, children: {} } as CreationTree

const permittedFields = getPermittedFields(abilities, action, model)

const accessibleQuery = accessibleBy(abilities, action)[model as Prisma.ModelName]
Expand Down Expand Up @@ -87,8 +88,8 @@ export function applyDataQuery(
const mutationAction = caslNestedOperationDict[nestedAction]
const isConnection = nestedAction === 'connect' || nestedAction === 'disconnect'

creationTree.children[field] = { type: mutationAction, children: {} }
const dataQuery = applyDataQuery(abilities, nestedArgs, mutationAction, relationModel.type, creationTree.children[field])
tree.children[field] = { action: mutationAction, model: relationModel.type as Prisma.ModelName, children: {} }
const dataQuery = applyDataQuery(abilities, nestedArgs, mutationAction, relationModel.type, tree.children[field])
mutation[field][nestedAction] = dataQuery.args
// connection works like a where query, so we apply it
if (isConnection) {
Expand All @@ -108,5 +109,5 @@ export function applyDataQuery(
})

})
return { args, creationTree }
return { args, creationTree: tree }
}
37 changes: 3 additions & 34 deletions src/applyRuleRelationsQuery.ts
Original file line number Diff line number Diff line change
@@ -1,41 +1,11 @@
import { AbilityTuple, PureAbility, Subject, ɵvalue } from '@casl/ability';
import { AbilityTuple, PureAbility } from '@casl/ability';
import { rulesToAST } from '@casl/ability/extra';
import { createPrismaAbility, PrismaQuery } from '@casl/prisma';
import { Prisma } from '@prisma/client';
import { convertCreationTreeToSelect, CreationTree } from './convertCreationTreeToSelect';
import { relationFieldsByModel } from './helpers';
import { getRuleRelationsQuery } from './getRuleRelationsQuery';

function flattenAst(ast: any) {
if (['and', 'or'].includes(ast.operator.toLowerCase())) {
return ast.value.flatMap((childAst: any) => flattenAst(childAst))
} else {
return [ast]
}
}

function getRuleRelationsQuery(model: string, ast: any, dataRelationQuery: any = {}) {
const obj: Record<string, any> = dataRelationQuery
if (ast) {
if (typeof ast.value === 'object') {
flattenAst(ast).map((childAst: any) => {
const relation = relationFieldsByModel[model]
if (childAst.field) {
if (childAst.field in relation) {
const dataInclude = childAst.field in obj ? obj[childAst.field] : {}
obj[childAst.field] = {
select: getRuleRelationsQuery(relation[childAst.field].type, childAst.value, dataInclude === true ? {} : dataInclude.select)
}
} else {
obj[childAst.field] = true
}
}
})
} else {
obj[ast.field] = true
}
}
return obj
}

/**
* takes args query and rule relation query
Expand Down Expand Up @@ -137,7 +107,6 @@ function mergeArgsAndRelationQuery(args: any, relationQuery: any) {
export function applyRuleRelationsQuery(args: any, abilities: PureAbility<AbilityTuple, PrismaQuery>, action: string, model: Prisma.ModelName, creationTree?: CreationTree) {



// rulesToAST won't return conditions
// if a rule is inverted and if a can rule exists without condition
// we therefore create fake ability here
Expand All @@ -149,7 +118,7 @@ export function applyRuleRelationsQuery(args: any, abilities: PureAbility<Abilit
}
}))
const ast = rulesToAST(ability, action, model)
const creationSelectQuery = creationTree ? convertCreationTreeToSelect(creationTree) ?? {} : {}
const creationSelectQuery = creationTree ? convertCreationTreeToSelect(abilities, creationTree) ?? {} : {}

const queryRelations = getRuleRelationsQuery(model, ast, creationSelectQuery === true ? {} : creationSelectQuery)

Expand Down
24 changes: 17 additions & 7 deletions src/convertCreationTreeToSelect.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,29 @@
import { AbilityTuple, PureAbility } from '@casl/ability';
import { rulesToAST } from '@casl/ability/extra';
import { PrismaQuery } from '@casl/prisma';
import { Prisma } from '@prisma/client';
import { getRuleRelationsQuery } from './getRuleRelationsQuery';

export type CreationTree = { type: string, children: Record<string, CreationTree> }
export type CreationTree = { action: string, model: Prisma.ModelName, children: Record<string, CreationTree> }

export function convertCreationTreeToSelect(relationQuery: CreationTree): Record<string, any> | true | null {
export function convertCreationTreeToSelect(abilities: PureAbility<AbilityTuple, PrismaQuery>, relationQuery: CreationTree): Record<string, any> | true | null {
// Recursively filter children
let relationResult: Record<string, any> = {};
if (relationQuery.action === 'create') {
const ast = rulesToAST(abilities, relationQuery.action, relationQuery.model)
relationResult = getRuleRelationsQuery(relationQuery.model, ast, {})
}

// Base case: if there are no children and type is 'create', keep this node
if (Object.keys(relationQuery.children).length === 0) {
return relationQuery.type === 'create' ? {} : null;
return relationQuery.action === 'create' ? relationResult : null;
}

// Recursively filter children
const relationResult: Record<string, any> = {};


for (const key in relationQuery.children) {

const childRelation = convertCreationTreeToSelect(relationQuery.children[key]);
const childRelation = convertCreationTreeToSelect(abilities, relationQuery.children[key]);

// If the filtered child is valid, add it to the filtered children
if (childRelation !== null) {
Expand All @@ -23,6 +33,6 @@ export function convertCreationTreeToSelect(relationQuery: CreationTree): Record
}
// After filtering children, check if there are any valid children left
// or if this node itself is a valid 'create' node
return Object.keys(relationResult).length > 0 ? relationResult : relationQuery.type === 'create' ? {} : null
return Object.keys(relationResult).length > 0 ? relationResult : relationQuery.action === 'create' ? {} : null

}
4 changes: 2 additions & 2 deletions src/filterQueryResults.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { AbilityTuple, PureAbility } from "@casl/ability";
import { accessibleBy, PrismaQuery } from "@casl/prisma";
import { PrismaQuery } from "@casl/prisma";
import { Prisma } from "@prisma/client";
import { CreationTree } from "./convertCreationTreeToSelect";
import { getPermittedFields, getSubject, relationFieldsByModel } from "./helpers";
Expand All @@ -15,7 +15,7 @@ export function filterQueryResults(result: any, mask: any, creationTree: Creatio

const filterPermittedFields = (entry: any) => {
if (!entry) { return null }
if (creationTree?.type === 'create') {
if (creationTree?.action === 'create') {
try {
if (!abilities.can('create', getSubject(model, entry))) {
throw new Error('')
Expand Down
33 changes: 33 additions & 0 deletions src/getRuleRelationsQuery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { relationFieldsByModel } from './helpers';

function flattenAst(ast: any) {
if (['and', 'or'].includes(ast.operator.toLowerCase())) {
return ast.value.flatMap((childAst: any) => flattenAst(childAst))
} else {
return [ast]
}
}

export function getRuleRelationsQuery(model: string, ast: any, dataRelationQuery: any = {}) {
const obj: Record<string, any> = dataRelationQuery
if (ast) {
if (typeof ast.value === 'object') {
flattenAst(ast).map((childAst: any) => {
const relation = relationFieldsByModel[model]
if (childAst.field) {
if (childAst.field in relation) {
const dataInclude = childAst.field in obj ? obj[childAst.field] : {}
obj[childAst.field] = {
select: getRuleRelationsQuery(relation[childAst.field].type, childAst.value, dataInclude === true ? {} : dataInclude.select)
}
} else {
obj[childAst.field] = true
}
}
})
} else {
obj[ast.field] = true
}
}
return obj
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ export function useCaslAbilities(getAbilityFactory: () => AbilityBuilder<PureAbi
perf?.mark('prisma-casl-extension-2')
logger?.log('Query Args', JSON.stringify(caslQuery.args))
logger?.log('Query Mask', JSON.stringify(caslQuery.mask))

const cleanupResults = (result: any) => {

perf?.mark('prisma-casl-extension-3')
Expand Down
22 changes: 12 additions & 10 deletions test/applyDataQuery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ describe('apply data query', () => {
can('update', 'User')
const result = applyDataQuery(build(), { data: { authorId: 0 }, where: { id: 0 } }, 'update', 'Post')
expect(result.args).toEqual({ data: { author: { connect: { id: 0, AND: [{}] } } }, where: { id: 0, AND: [{}] } })
expect(result.creationTree).toEqual({ "children": { "author": { "children": {}, "type": "update" } }, "type": "update" })
expect(result.creationTree).toEqual({ children: { "author": { children: {}, model: 'User', action: "update" } }, model: 'Post', action: "update" })
})
it('throws error if update on connection is not allowed', () => {
const { can, cannot, build } = abilityBuilder()
Expand All @@ -32,7 +32,7 @@ describe('apply data query', () => {
data: { id: 0 },
where: { id: 1, AND: [{ OR: [{ id: 0 }] }] }
})
expect(result.creationTree).toEqual({ "children": {}, "type": mutation })
expect(result.creationTree).toEqual({ children: {}, model: 'User', action: mutation })
})

it('throws error if mutation of property is omitted', () => {
Expand Down Expand Up @@ -66,11 +66,13 @@ describe('apply data query', () => {
const result = applyDataQuery(build(), { data: { creator: { upsert: { create: { email: '-1' }, update: { email: '-1' }, where: { id: 1 } } } }, where: { id: 0 } }, 'update', 'Thread')
expect(result.args).toEqual({ data: { creator: { upsert: { create: { email: '-1' }, update: { email: '-1' }, where: { id: 1, AND: [{ OR: [{ id: 0 }] }] } } } }, where: { id: 0, AND: [{}] } })
expect(result.creationTree).toEqual({
type: 'update',
action: 'update',
model: 'Thread',
children: {
creator: {
children: {},
type: 'create'
action: 'create',
model: 'User'
}
}
})
Expand Down Expand Up @@ -98,7 +100,7 @@ describe('apply data query', () => {
})
const result = applyDataQuery(build(), { data: { id: 1, posts: { connect: { id: 0 } } }, where: { id: 0 } }, 'update', 'User')
expect(result.args).toEqual({ data: { id: 1, posts: { connect: { id: 0, AND: [{ OR: [{ id: 1 }] }] } } }, where: { id: 0, AND: [{ OR: [{ id: 0 }] }] } })
expect(result.creationTree).toEqual({ "children": { "posts": { "children": {}, "type": "update" } }, "type": "update" })
expect(result.creationTree).toEqual({ children: { posts: { children: {}, action: 'update', model: 'Post', } }, model: 'User', action: "update" })
})
it('adds where and connection clause in nested array connection update', () => {
const { can, build } = abilityBuilder()
Expand All @@ -111,7 +113,7 @@ describe('apply data query', () => {

const result = applyDataQuery(build(), { data: { id: 1, posts: { connect: [{ id: 0 }] } }, where: { id: 0 } }, 'update', 'User')
expect(result.args).toEqual({ data: { id: 1, posts: { connect: [{ id: 0, AND: [{ OR: [{ id: 1 }] }] }] } }, where: { id: 0, AND: [{ OR: [{ id: 0 }] }] } })
expect(result.creationTree).toEqual({ "children": { "posts": { "children": {}, "type": "update" } }, "type": "update" })
expect(result.creationTree).toEqual({ children: { posts: { children: {}, model: 'Post', action: "update" } }, model: 'User', action: "update" })
})
it('throws error if data in nested connection is not allowed', () => {
const { can, cannot, build } = abilityBuilder()
Expand Down Expand Up @@ -153,7 +155,7 @@ describe('apply data query', () => {
}, 'update', 'User')

expect(result.args).toEqual({ data: { id: 1, posts: { connectOrCreate: { create: { text: '' }, where: { id: 1, AND: [{ OR: [{ id: 2 }] }] } } } }, where: { id: 0, AND: [{ OR: [{ id: 0 }] }] } })
expect(result.creationTree).toEqual({ type: 'update', children: { posts: { type: 'create', children: {} } } })
expect(result.creationTree).toEqual({ action: 'update', model: 'User', children: { posts: { model: 'Post', action: 'create', children: {} } } })
})

it('adds where and connection clause in nested array connection update', () => {
Expand Down Expand Up @@ -181,7 +183,7 @@ describe('apply data query', () => {
}
}, 'update', 'User')
expect(result.args).toEqual({ data: { id: 1, posts: { connectOrCreate: [{ create: { text: '' }, where: { id: 0, AND: [{ OR: [{ id: 2 }] }] } }] } }, where: { id: 0, AND: [{ OR: [{ id: 0 }] }] } })
expect(result.creationTree).toEqual({ type: 'update', children: { posts: { type: 'create', children: {} } } })
expect(result.creationTree).toEqual({ action: 'update', model: 'User', children: { posts: { model: 'Post', action: 'create', children: {} } } })
})
it('throws error if data in nested connection is not allowed', () => {
const { can, cannot, build } = abilityBuilder()
Expand Down Expand Up @@ -253,7 +255,7 @@ describe('apply data query', () => {

const result = applyDataQuery(build(), { data: { id: 1, posts: { update: { data: { thread: { update: { id: 0 } } }, where: { id: 0 } } } }, where: { id: 0 } }, 'update', 'User')
expect(result.args).toEqual({ data: { id: 1, posts: { update: { data: { thread: { update: { id: 0 } } }, where: { id: 0, AND: [{}] } } } }, where: { id: 0, AND: [{}] } })
expect(result.creationTree).toEqual({ "children": { "posts": { "children": { "thread": { "children": {}, "type": "update" } }, "type": "update" } }, "type": "update" })
expect(result.creationTree).toEqual({ children: { posts: { children: { thread: { children: {}, model: 'Thread', action: "update" } }, model: 'Post', action: "update" } }, model: 'User', action: "update" })
})
it('throws error if data in nested nested update is not allowed', () => {
const { can, cannot, build } = abilityBuilder()
Expand Down Expand Up @@ -284,7 +286,7 @@ describe('apply data query', () => {
}, 'create', 'User')
expect(result.args)
.toEqual({ data: { id: 0, posts: { createMany: { data: { text: '' } } } } })
expect(result.creationTree).toEqual({ type: 'create', children: { posts: { type: 'create', children: {} } } })
expect(result.creationTree).toEqual({ action: 'create', model: 'User', children: { posts: { model: 'Post', action: 'create', children: {} } } })
})
it('throws error if data in nested create is not allowed', () => {
const { can, cannot, build } = abilityBuilder()
Expand Down
40 changes: 29 additions & 11 deletions test/convertCreationTreeToSelect.test.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,37 @@
import { convertCreationTreeToSelect } from "../src/convertCreationTreeToSelect"
import { abilityBuilder } from "./abilities"


describe('convert creation tree to select query', () => {
it('correctly builds data query', () => {
expect(convertCreationTreeToSelect({
type: 'update',
const { can, build } = abilityBuilder()
can('create', 'Post', {
author: {
is: {
id: 0
}
}
})
expect(convertCreationTreeToSelect(build(), {
action: 'update',
model: 'User',
children: {
post: {
type: 'create',
model: 'Post',
action: 'create',
children: {
topic: {
type: 'connect',
action: 'connect',
model: 'Topic',
children: {
category: {
type: 'create',
action: 'create',
model: 'Thread',
children: {},
},
tag: {
type: 'update',
action: 'update',
model: 'Thread',
children: {},
},
},
Expand All @@ -27,15 +41,19 @@ describe('convert creation tree to select query', () => {

},
})
).toEqual({ post: { select: { topic: { select: { category: { select: {} } } } } } })
).toEqual({ post: { select: { author: { select: { id: true } }, topic: { select: { category: { select: {} } } } } } })
})
it('correctly builds data query', () => {
expect(convertCreationTreeToSelect({
type: 'create',
const { build } = abilityBuilder()

expect(convertCreationTreeToSelect(build(), {
action: 'create',
model: 'User',
children: {
posts: {
type: 'create',
children: { thread: { type: 'update', children: {} } }
action: 'create',
model: 'Post',
children: { thread: { model: 'Thread', action: 'update', children: {} } }
}
}
})).toEqual({ posts: { select: {} } })
Expand Down
35 changes: 34 additions & 1 deletion test/extension.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1433,7 +1433,40 @@ describe('prisma extension casl', () => {
})
expect(result).toEqual({ email: 'new' })
})
it('cannot do nested create with conditions', async () => {
it('can do nested create with conditions', async () => {
function builderFactory() {
const builder = abilityBuilder()
const { can, cannot } = builder

can('read', 'User', 'email')
can('create', 'User')
can('update', 'Thread')
can('create', 'Post', {
author: {
is: {
email: 'old'
}
}
})
can('read', 'Post')
return builder
}
const client = seedClient.$extends(
useCaslAbilities(builderFactory)
)
expect(await client.user.create({
data: {
email: 'old',
posts: {
create: {
threadId: 0,
text: '1'
}
}
}
})).toEqual({ email: 'old' })
})
it('cannot do nested create with failing conditions', async () => {
function builderFactory() {
const builder = abilityBuilder()
const { can, cannot } = builder
Expand Down

0 comments on commit 97136c1

Please sign in to comment.