diff --git a/packages/runtime/src/client/crud/operations/base.ts b/packages/runtime/src/client/crud/operations/base.ts index eb17167d..9b2098a3 100644 --- a/packages/runtime/src/client/crud/operations/base.ts +++ b/packages/runtime/src/client/crud/operations/base.ts @@ -279,7 +279,8 @@ export abstract class BaseOperationHandler { if (!ownedByModel) { // assign fks from parent - const parentFkFields = this.buildFkAssignments( + const parentFkFields = await this.buildFkAssignments( + kysely, fromRelation.model, fromRelation.field, fromRelation.ids, @@ -433,7 +434,12 @@ export abstract class BaseOperationHandler { return { baseEntity, remainingFields }; } - private buildFkAssignments(model: string, relationField: string, entity: any) { + private async buildFkAssignments( + kysely: ToKysely, + model: GetModels, + relationField: string, + entity: any, + ) { const parentFkFields: any = {}; invariant(relationField, 'parentField must be defined if parentModel is defined'); @@ -443,7 +449,18 @@ export abstract class BaseOperationHandler { for (const pair of keyPairs) { if (!(pair.pk in entity)) { - throw new QueryError(`Field "${pair.pk}" not found in parent created data`); + // the relation may be using a non-id field as fk, so we read in-place + // to fetch that field + const extraRead = await this.readUnique(kysely, model, { + where: entity, + select: { [pair.pk]: true }, + } as any); + if (!extraRead) { + throw new QueryError(`Field "${pair.pk}" not found in parent created data`); + } else { + // update the parent entity + Object.assign(entity, extraRead); + } } Object.assign(parentFkFields, { [pair.fk]: (entity as any)[pair.pk], @@ -1411,7 +1428,7 @@ export abstract class BaseOperationHandler { ...(enumerate(value) as { where: any; data: any }[]).map((item) => { let where; let data; - if ('where' in item) { + if ('data' in item && typeof item.data === 'object') { where = item.where; data = item.data; } else { diff --git a/packages/runtime/src/client/crud/operations/create.ts b/packages/runtime/src/client/crud/operations/create.ts index bc15bb36..26206d99 100644 --- a/packages/runtime/src/client/crud/operations/create.ts +++ b/packages/runtime/src/client/crud/operations/create.ts @@ -1,5 +1,5 @@ import { match } from 'ts-pattern'; -import { RejectedByPolicyError } from '../../../plugins/policy/errors'; +import { RejectedByPolicyError, RejectedByPolicyReason } from '../../../plugins/policy/errors'; import type { GetModels, SchemaDef } from '../../../schema'; import type { CreateArgs, CreateManyAndReturnArgs, CreateManyArgs, WhereInput } from '../../crud-types'; import { getIdValues } from '../../query-utils'; @@ -40,7 +40,11 @@ export class CreateOperationHandler extends BaseOperat }); if (!result && this.hasPolicyEnabled) { - throw new RejectedByPolicyError(this.model, `result is not allowed to be read back`); + throw new RejectedByPolicyError( + this.model, + RejectedByPolicyReason.CANNOT_READ_BACK, + `result is not allowed to be read back`, + ); } return result; diff --git a/packages/runtime/src/client/crud/operations/delete.ts b/packages/runtime/src/client/crud/operations/delete.ts index 3ed17ce0..6eb1eca3 100644 --- a/packages/runtime/src/client/crud/operations/delete.ts +++ b/packages/runtime/src/client/crud/operations/delete.ts @@ -3,6 +3,7 @@ import type { SchemaDef } from '../../../schema'; import type { DeleteArgs, DeleteManyArgs } from '../../crud-types'; import { NotFoundError } from '../../errors'; import { BaseOperationHandler } from './base'; +import { RejectedByPolicyError, RejectedByPolicyReason } from '../../../plugins/policy'; export class DeleteOperationHandler extends BaseOperationHandler { async handle(operation: 'delete' | 'deleteMany', args: unknown | undefined) { @@ -24,9 +25,6 @@ export class DeleteOperationHandler extends BaseOperat omit: args.omit, where: args.where, }); - if (!existing) { - throw new NotFoundError(this.model); - } // TODO: avoid using transaction for simple delete await this.safeTransaction(async (tx) => { @@ -36,6 +34,14 @@ export class DeleteOperationHandler extends BaseOperat } }); + if (!existing && this.hasPolicyEnabled) { + throw new RejectedByPolicyError( + this.model, + RejectedByPolicyReason.CANNOT_READ_BACK, + 'result is not allowed to be read back', + ); + } + return existing; } diff --git a/packages/runtime/src/client/crud/operations/update.ts b/packages/runtime/src/client/crud/operations/update.ts index ea22c773..ad2fc613 100644 --- a/packages/runtime/src/client/crud/operations/update.ts +++ b/packages/runtime/src/client/crud/operations/update.ts @@ -1,5 +1,5 @@ import { match } from 'ts-pattern'; -import { RejectedByPolicyError } from '../../../plugins/policy/errors'; +import { RejectedByPolicyError, RejectedByPolicyReason } from '../../../plugins/policy/errors'; import type { GetModels, SchemaDef } from '../../../schema'; import type { UpdateArgs, UpdateManyAndReturnArgs, UpdateManyArgs, UpsertArgs, WhereInput } from '../../crud-types'; import { getIdValues } from '../../query-utils'; @@ -48,7 +48,11 @@ export class UpdateOperationHandler extends BaseOperat // update succeeded but result cannot be read back if (this.hasPolicyEnabled) { // if access policy is enabled, we assume it's due to read violation (not guaranteed though) - throw new RejectedByPolicyError(this.model, 'result is not allowed to be read back'); + throw new RejectedByPolicyError( + this.model, + RejectedByPolicyReason.CANNOT_READ_BACK, + 'result is not allowed to be read back', + ); } else { // this can happen if the entity is cascade deleted during the update, return null to // be consistent with Prisma even though it doesn't comply with the method signature @@ -71,16 +75,29 @@ export class UpdateOperationHandler extends BaseOperat return []; } - return this.safeTransaction(async (tx) => { + const { readBackResult, updateResult } = await this.safeTransaction(async (tx) => { const updateResult = await this.updateMany(tx, this.model, args.where, args.data, args.limit, true); - return this.read(tx, this.model, { + const readBackResult = await this.read(tx, this.model, { select: args.select, omit: args.omit, where: { OR: updateResult.map((item) => getIdValues(this.schema, this.model, item) as any), } as any, // TODO: fix type }); + + return { readBackResult, updateResult }; }); + + if (readBackResult.length < updateResult.length && this.hasPolicyEnabled) { + // some of the updated entities cannot be read back + throw new RejectedByPolicyError( + this.model, + RejectedByPolicyReason.CANNOT_READ_BACK, + 'result is not allowed to be read back', + ); + } + + return readBackResult; } private async runUpsert(args: UpsertArgs>) { @@ -113,7 +130,11 @@ export class UpdateOperationHandler extends BaseOperat }); if (!result && this.hasPolicyEnabled) { - throw new RejectedByPolicyError(this.model, 'result is not allowed to be read back'); + throw new RejectedByPolicyError( + this.model, + RejectedByPolicyReason.CANNOT_READ_BACK, + 'result is not allowed to be read back', + ); } return result; diff --git a/packages/runtime/src/client/crud/validator.ts b/packages/runtime/src/client/crud/validator.ts index 7c8b3d5f..beb31faf 100644 --- a/packages/runtime/src/client/crud/validator.ts +++ b/packages/runtime/src/client/crud/validator.ts @@ -2,7 +2,7 @@ import { invariant } from '@zenstackhq/common-helpers'; import Decimal from 'decimal.js'; import stableStringify from 'json-stable-stringify'; import { match, P } from 'ts-pattern'; -import { z, ZodType } from 'zod'; +import { z, ZodSchema, ZodType } from 'zod'; import { type BuiltinType, type EnumDef, @@ -764,13 +764,15 @@ export class InputValidator { private makeCreateSchema(model: string) { const dataSchema = this.makeCreateDataSchema(model, false); - const schema = z.strictObject({ + let schema: ZodSchema = z.strictObject({ data: dataSchema, select: this.makeSelectSchema(model).optional(), include: this.makeIncludeSchema(model).optional(), omit: this.makeOmitSchema(model).optional(), }); - return this.refineForSelectIncludeMutuallyExclusive(schema); + schema = this.refineForSelectIncludeMutuallyExclusive(schema); + schema = this.refineForSelectOmitMutuallyExclusive(schema); + return schema; } private makeCreateManySchema(model: string) { @@ -934,7 +936,7 @@ export class InputValidator { fields['update'] = array ? this.orArray( z.strictObject({ - where: this.makeWhereSchema(fieldType, true), + where: this.makeWhereSchema(fieldType, true).optional(), data: this.makeUpdateDataSchema(fieldType, withoutFields), }), true, @@ -942,7 +944,7 @@ export class InputValidator { : z .union([ z.strictObject({ - where: this.makeWhereSchema(fieldType, true), + where: this.makeWhereSchema(fieldType, true).optional(), data: this.makeUpdateDataSchema(fieldType, withoutFields), }), this.makeUpdateDataSchema(fieldType, withoutFields), @@ -1026,14 +1028,16 @@ export class InputValidator { // #region Update private makeUpdateSchema(model: string) { - const schema = z.strictObject({ + let schema: ZodSchema = z.strictObject({ where: this.makeWhereSchema(model, true), data: this.makeUpdateDataSchema(model), select: this.makeSelectSchema(model).optional(), include: this.makeIncludeSchema(model).optional(), omit: this.makeOmitSchema(model).optional(), }); - return this.refineForSelectIncludeMutuallyExclusive(schema); + schema = this.refineForSelectIncludeMutuallyExclusive(schema); + schema = this.refineForSelectOmitMutuallyExclusive(schema); + return schema; } private makeUpdateManySchema(model: string) { @@ -1046,15 +1050,16 @@ export class InputValidator { private makeUpdateManyAndReturnSchema(model: string) { const base = this.makeUpdateManySchema(model); - const result = base.extend({ + let schema: ZodSchema = base.extend({ select: this.makeSelectSchema(model).optional(), omit: this.makeOmitSchema(model).optional(), }); - return this.refineForSelectOmitMutuallyExclusive(result); + schema = this.refineForSelectOmitMutuallyExclusive(schema); + return schema; } private makeUpsertSchema(model: string) { - const schema = z.strictObject({ + let schema: ZodSchema = z.strictObject({ where: this.makeWhereSchema(model, true), create: this.makeCreateDataSchema(model, false), update: this.makeUpdateDataSchema(model), @@ -1062,7 +1067,9 @@ export class InputValidator { include: this.makeIncludeSchema(model).optional(), omit: this.makeOmitSchema(model).optional(), }); - return this.refineForSelectIncludeMutuallyExclusive(schema); + schema = this.refineForSelectIncludeMutuallyExclusive(schema); + schema = this.refineForSelectOmitMutuallyExclusive(schema); + return schema; } private makeUpdateDataSchema(model: string, withoutFields: string[] = [], withoutRelationFields = false) { @@ -1166,12 +1173,14 @@ export class InputValidator { // #region Delete private makeDeleteSchema(model: GetModels) { - const schema = z.strictObject({ + let schema: ZodSchema = z.strictObject({ where: this.makeWhereSchema(model, true), select: this.makeSelectSchema(model).optional(), include: this.makeIncludeSchema(model).optional(), }); - return this.refineForSelectIncludeMutuallyExclusive(schema); + schema = this.refineForSelectIncludeMutuallyExclusive(schema); + schema = this.refineForSelectOmitMutuallyExclusive(schema); + return schema; } private makeDeleteManySchema(model: GetModels) { diff --git a/packages/runtime/src/client/helpers/schema-db-pusher.ts b/packages/runtime/src/client/helpers/schema-db-pusher.ts index 781c131d..9e855398 100644 --- a/packages/runtime/src/client/helpers/schema-db-pusher.ts +++ b/packages/runtime/src/client/helpers/schema-db-pusher.ts @@ -29,7 +29,8 @@ export class SchemaDbPusher { } // sort models so that target of fk constraints are created first - const sortedModels = this.sortModels(this.schema.models); + const models = Object.values(this.schema.models).filter((m) => !m.isView); + const sortedModels = this.sortModels(models); for (const modelDef of sortedModels) { const createTable = this.createModelTable(tx, modelDef); await createTable.execute(); @@ -37,10 +38,10 @@ export class SchemaDbPusher { }); } - private sortModels(models: Record): ModelDef[] { + private sortModels(models: ModelDef[]): ModelDef[] { const graph: [ModelDef, ModelDef | undefined][] = []; - for (const model of Object.values(models)) { + for (const model of models) { let added = false; if (model.baseModel) { diff --git a/packages/runtime/src/plugins/policy/errors.ts b/packages/runtime/src/plugins/policy/errors.ts index df1feab6..675506d6 100644 --- a/packages/runtime/src/plugins/policy/errors.ts +++ b/packages/runtime/src/plugins/policy/errors.ts @@ -1,11 +1,32 @@ +/** + * Reason code for policy rejection. + */ +export enum RejectedByPolicyReason { + /** + * Rejected because the operation is not allowed by policy. + */ + NO_ACCESS = 'no-access', + + /** + * Rejected because the result cannot be read back after mutation due to policy. + */ + CANNOT_READ_BACK = 'cannot-read-back', + + /** + * Other reasons. + */ + OTHER = 'other', +} + /** * Error thrown when an operation is rejected by access policy. */ export class RejectedByPolicyError extends Error { constructor( public readonly model: string | undefined, - public readonly reason?: string, + public readonly reason: RejectedByPolicyReason = RejectedByPolicyReason.NO_ACCESS, + message?: string, ) { - super(reason ?? `Operation rejected by policy${model ? ': ' + model : ''}`); + super(message ?? `Operation rejected by policy${model ? ': ' + model : ''}`); } } diff --git a/packages/runtime/src/plugins/policy/policy-handler.ts b/packages/runtime/src/plugins/policy/policy-handler.ts index 7b55b334..2c582562 100644 --- a/packages/runtime/src/plugins/policy/policy-handler.ts +++ b/packages/runtime/src/plugins/policy/policy-handler.ts @@ -39,7 +39,7 @@ import type { ProceedKyselyQueryFunction } from '../../client/plugin'; import { getManyToManyRelation, requireField, requireIdFields, requireModel } from '../../client/query-utils'; import { ExpressionUtils, type BuiltinType, type Expression, type GetModels, type SchemaDef } from '../../schema'; import { ColumnCollector } from './column-collector'; -import { RejectedByPolicyError } from './errors'; +import { RejectedByPolicyError, RejectedByPolicyReason } from './errors'; import { ExpressionTransformer } from './expression-transformer'; import type { Policy, PolicyOperation } from './types'; import { buildIsFalse, conjunction, disjunction, falseNode, getTableName } from './utils'; @@ -66,7 +66,11 @@ export class PolicyHandler extends OperationNodeTransf ) { if (!this.isCrudQueryNode(node)) { // non-CRUD queries are not allowed - throw new RejectedByPolicyError(undefined, 'non-CRUD queries are not allowed'); + throw new RejectedByPolicyError( + undefined, + RejectedByPolicyReason.OTHER, + 'non-CRUD queries are not allowed', + ); } if (!this.isMutationQueryNode(node)) { @@ -106,7 +110,11 @@ export class PolicyHandler extends OperationNodeTransf } else { const readBackResult = await this.processReadBack(node, result, proceed); if (readBackResult.rows.length !== result.rows.length) { - throw new RejectedByPolicyError(mutationModel, 'result is not allowed to be read back'); + throw new RejectedByPolicyError( + mutationModel, + RejectedByPolicyReason.CANNOT_READ_BACK, + 'result is not allowed to be read back', + ); } return readBackResult; } @@ -335,12 +343,14 @@ export class PolicyHandler extends OperationNodeTransf if (!result.rows[0]?.$conditionA) { throw new RejectedByPolicyError( m2m.firstModel as GetModels, + RejectedByPolicyReason.CANNOT_READ_BACK, `many-to-many relation participant model "${m2m.firstModel}" not updatable`, ); } if (!result.rows[0]?.$conditionB) { throw new RejectedByPolicyError( m2m.secondModel as GetModels, + RejectedByPolicyReason.NO_ACCESS, `many-to-many relation participant model "${m2m.secondModel}" not updatable`, ); } diff --git a/packages/runtime/test/policy/migrated/omit.test.ts b/packages/runtime/test/policy/migrated/omit.test.ts new file mode 100644 index 00000000..57fdd014 --- /dev/null +++ b/packages/runtime/test/policy/migrated/omit.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import { createPolicyTestClient } from '../utils'; + +describe('prisma omit', () => { + it('per query', async () => { + const db = await createPolicyTestClient( + ` + model User { + id String @id @default(cuid()) + name String + profile Profile? + age Int + value Int @allow('read', age > 20) + @@allow('all', age > 18) + } + + model Profile { + id String @id @default(cuid()) + user User @relation(fields: [userId], references: [id]) + userId String @unique + level Int + @@allow('all', level > 1) + } + `, + ); + + await db.$unuseAll().user.create({ + data: { + name: 'John', + age: 25, + value: 10, + profile: { + create: { level: 2 }, + }, + }, + }); + + let found = await db.user.findFirst({ + include: { profile: { omit: { level: true } } }, + omit: { + age: true, + }, + }); + expect(found.age).toBeUndefined(); + expect(found.value).toEqual(10); + expect(found.profile.level).toBeUndefined(); + + found = await db.user.findFirst({ + select: { value: true, profile: { omit: { level: true } } }, + }); + console.log(found); + expect(found.age).toBeUndefined(); + expect(found.value).toEqual(10); + expect(found.profile.level).toBeUndefined(); + }); +}); diff --git a/packages/runtime/test/policy/migrated/todo-sample.test.ts b/packages/runtime/test/policy/migrated/todo-sample.test.ts new file mode 100644 index 00000000..c81ac3f7 --- /dev/null +++ b/packages/runtime/test/policy/migrated/todo-sample.test.ts @@ -0,0 +1,503 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import type { ClientContract } from '../../../src'; +import { schema, type SchemaType } from '../../schemas/todo/schema'; +import { createPolicyTestClient } from '../utils'; + +describe('Todo Policy Tests', () => { + let db: ClientContract; + + beforeEach(async () => { + db = await createPolicyTestClient(schema); + }); + + it('user', async () => { + const user1 = { + id: 'user1', + email: 'user1@zenstack.dev', + name: 'User 1', + }; + const user2 = { + id: 'user2', + email: 'user2@zenstack.dev', + name: 'User 2', + }; + + const anonDb = db; + const user1Db = db.$setAuth({ id: user1.id }); + const user2Db = db.$setAuth({ id: user2.id }); + + // create user1 + // create should succeed but result can be read back anonymously + await expect(anonDb.user.create({ data: user1 })).toBeRejectedByPolicy([ + 'result is not allowed to be read back', + ]); + await expect(user1Db.user.findUnique({ where: { id: user1.id } })).toResolveTruthy(); + await expect(user2Db.user.findUnique({ where: { id: user1.id } })).toResolveNull(); + + // create user2 + await expect(anonDb.user.create({ data: user2 })).toBeRejectedByPolicy(); + + // find with user1 should only get user1 + const r = await user1Db.user.findMany(); + expect(r).toHaveLength(1); + expect(r[0]).toEqual(expect.objectContaining(user1)); + + // get user2 as user1 + await expect(user1Db.user.findUnique({ where: { id: user2.id } })).toResolveNull(); + + // add both users into the same space + await expect( + user1Db.space.create({ + data: { + name: 'Space 1', + slug: 'space1', + owner: { connect: { id: user1.id } }, + members: { + create: [ + { + user: { connect: { id: user1.id } }, + role: 'ADMIN', + }, + { + user: { connect: { id: user2.id } }, + role: 'USER', + }, + ], + }, + }, + }), + ).toResolveTruthy(); + + // now both user1 and user2 should be visible + await expect(user1Db.user.findMany()).resolves.toHaveLength(2); + await expect(user2Db.user.findMany()).resolves.toHaveLength(2); + + // update user2 as user1 + await expect( + user2Db.user.update({ + where: { id: user1.id }, + data: { name: 'hello' }, + }), + ).toBeRejectedNotFound(); + + // update user1 as user1 + await expect( + user1Db.user.update({ + where: { id: user1.id }, + data: { name: 'hello' }, + }), + ).toResolveTruthy(); + + // delete user2 as user1 + await expect(user1Db.user.delete({ where: { id: user2.id } })).toBeRejectedNotFound(); + + // delete user1 as user1 + await expect(user1Db.user.delete({ where: { id: user1.id } })).toResolveTruthy(); + await expect(user1Db.user.findUnique({ where: { id: user1.id } })).toResolveNull(); + }); + + it('todo list', async () => { + await createSpaceAndUsers(db.$unuseAll()); + + const anonDb = db; + const emptyUIDDb = db.$setAuth({ id: '' }); + const user1Db = db.$setAuth({ id: user1.id }); + const user2Db = db.$setAuth({ id: user2.id }); + const user3Db = db.$setAuth({ id: user3.id }); + + await expect( + anonDb.list.create({ + data: { + id: 'list1', + title: 'List 1', + owner: { connect: { id: user1.id } }, + space: { connect: { id: space1.id } }, + }, + }), + ).toBeRejectedByPolicy(); + + await expect( + user1Db.list.create({ + data: { + id: 'list1', + title: 'List 1', + owner: { connect: { id: user1.id } }, + space: { connect: { id: space1.id } }, + }, + }), + ).toResolveTruthy(); + + await expect(user1Db.list.findMany()).resolves.toHaveLength(1); + await expect(anonDb.list.findMany()).resolves.toHaveLength(0); + await expect(emptyUIDDb.list.findMany()).resolves.toHaveLength(0); + await expect(anonDb.list.findUnique({ where: { id: 'list1' } })).toResolveNull(); + + // accessible to owner + await expect(user1Db.list.findUnique({ where: { id: 'list1' } })).resolves.toEqual( + expect.objectContaining({ id: 'list1', title: 'List 1' }), + ); + + // accessible to user in the space + await expect(user2Db.list.findUnique({ where: { id: 'list1' } })).toResolveTruthy(); + + // inaccessible to user not in the space + await expect(user3Db.list.findUnique({ where: { id: 'list1' } })).toResolveNull(); + + // make a private list + await user1Db.list.create({ + data: { + id: 'list2', + title: 'List 2', + private: true, + owner: { connect: { id: user1.id } }, + space: { connect: { id: space1.id } }, + }, + }); + + // accessible to owner + await expect(user1Db.list.findUnique({ where: { id: 'list2' } })).toResolveTruthy(); + + // inaccessible to other user in the space + await expect(user2Db.list.findUnique({ where: { id: 'list2' } })).toResolveNull(); + + // create a list which doesn't match credential should fail + await expect( + user1Db.list.create({ + data: { + id: 'list3', + title: 'List 3', + owner: { connect: { id: user2.id } }, + space: { connect: { id: space1.id } }, + }, + }), + ).toBeRejectedByPolicy(); + + // create a list which doesn't match credential's space should fail + await expect( + user1Db.list.create({ + data: { + id: 'list3', + title: 'List 3', + owner: { connect: { id: user1.id } }, + space: { connect: { id: space2.id } }, + }, + }), + ).toBeRejectedByPolicy(); + + // update list + await expect( + user1Db.list.update({ + where: { id: 'list1' }, + data: { + title: 'List 1 updated', + }, + }), + ).resolves.toEqual(expect.objectContaining({ title: 'List 1 updated' })); + + await expect( + user2Db.list.update({ + where: { id: 'list1' }, + data: { + title: 'List 1 updated', + }, + }), + ).toBeRejectedNotFound(); + + // delete list + await expect(user2Db.list.delete({ where: { id: 'list1' } })).toBeRejectedNotFound(); + await expect(user1Db.list.delete({ where: { id: 'list1' } })).toResolveTruthy(); + await expect(user1Db.list.findUnique({ where: { id: 'list1' } })).toResolveNull(); + }); + + it('todo', async () => { + await createSpaceAndUsers(db.$unuseAll()); + + const user1Db = db.$setAuth({ id: user1.id }); + const user2Db = db.$setAuth({ id: user2.id }); + + // create a public list + await user1Db.list.create({ + data: { + id: 'list1', + title: 'List 1', + owner: { connect: { id: user1.id } }, + space: { connect: { id: space1.id } }, + }, + }); + + // create + await expect( + user1Db.todo.create({ + data: { + id: 'todo1', + title: 'Todo 1', + owner: { connect: { id: user1.id } }, + list: { + connect: { id: 'list1' }, + }, + }, + }), + ).toResolveTruthy(); + + await expect( + user2Db.todo.create({ + data: { + id: 'todo2', + title: 'Todo 2', + owner: { connect: { id: user2.id } }, + list: { + connect: { id: 'list1' }, + }, + }, + }), + ).toResolveTruthy(); + + // read + await expect(user1Db.todo.findMany()).resolves.toHaveLength(2); + await expect(user2Db.todo.findMany()).resolves.toHaveLength(2); + + // update, user in the same space can freely update + await expect( + user1Db.todo.update({ + where: { id: 'todo1' }, + data: { + title: 'Todo 1 updated', + }, + }), + ).toResolveTruthy(); + await expect( + user1Db.todo.update({ + where: { id: 'todo2' }, + data: { + title: 'Todo 2 updated', + }, + }), + ).toResolveTruthy(); + + // create a private list + await user1Db.list.create({ + data: { + id: 'list2', + private: true, + title: 'List 2', + owner: { connect: { id: user1.id } }, + space: { connect: { id: space1.id } }, + }, + }); + + // create + await expect( + user1Db.todo.create({ + data: { + id: 'todo3', + title: 'Todo 3', + owner: { connect: { id: user1.id } }, + list: { + connect: { id: 'list2' }, + }, + }, + }), + ).toResolveTruthy(); + + // reject because list2 is private + await expect( + user2Db.todo.create({ + data: { + id: 'todo4', + title: 'Todo 4', + owner: { connect: { id: user2.id } }, + list: { + connect: { id: 'list2' }, + }, + }, + }), + ).toBeRejectedByPolicy(); + + // update, only owner can update todo in a private list + await expect( + user1Db.todo.update({ + where: { id: 'todo3' }, + data: { + title: 'Todo 3 updated', + }, + }), + ).toResolveTruthy(); + await expect( + user2Db.todo.update({ + where: { id: 'todo3' }, + data: { + title: 'Todo 3 updated', + }, + }), + ).toBeRejectedNotFound(); + }); + + it('relation query', async () => { + await createSpaceAndUsers(db.$unuseAll()); + + const user1Db = db.$setAuth({ id: user1.id }); + const user2Db = db.$setAuth({ id: user2.id }); + + await user1Db.list.create({ + data: { + id: 'list1', + title: 'List 1', + owner: { connect: { id: user1.id } }, + space: { connect: { id: space1.id } }, + }, + }); + + await user1Db.list.create({ + data: { + id: 'list2', + title: 'List 2', + private: true, + owner: { connect: { id: user1.id } }, + space: { connect: { id: space1.id } }, + }, + }); + + const r = await user1Db.space.findFirstOrThrow({ + where: { id: 'space1' }, + include: { lists: true }, + }); + expect(r.lists).toHaveLength(2); + + const r1 = await user2Db.space.findFirstOrThrow({ + where: { id: 'space1' }, + include: { lists: true }, + }); + expect(r1.lists).toHaveLength(1); + }); + + // TODO: `future()` support + it.skip('post-update checks', async () => { + await createSpaceAndUsers(db.$unuseAll()); + + const user1Db = db.$setAuth({ id: user1.id }); + + await user1Db.list.create({ + data: { + id: 'list1', + title: 'List 1', + owner: { connect: { id: user1.id } }, + space: { connect: { id: space1.id } }, + todos: { + create: { + id: 'todo1', + title: 'Todo 1', + owner: { connect: { id: user1.id } }, + }, + }, + }, + }); + + // change list's owner + await expect( + user1Db.list.update({ + where: { id: 'list1' }, + data: { + owner: { connect: { id: user2.id } }, + }, + }), + ).toBeRejectedByPolicy(); + + // change todo's owner + await expect( + user1Db.todo.update({ + where: { id: 'todo1' }, + data: { + owner: { connect: { id: user2.id } }, + }, + }), + ).toBeRejectedByPolicy(); + + // nested change todo's owner + await expect( + user1Db.list.update({ + where: { id: 'list1' }, + data: { + todos: { + update: { + where: { id: 'todo1' }, + data: { + owner: { connect: { id: user2.id } }, + }, + }, + }, + }, + }), + ).toBeRejectedByPolicy(); + }); +}); + +const user1 = { + id: 'user1', + email: 'user1@zenstack.dev', + name: 'User 1', +}; + +const user2 = { + id: 'user2', + email: 'user2@zenstack.dev', + name: 'User 2', +}; + +const user3 = { + id: 'user3', + email: 'user3@zenstack.dev', + name: 'User 3', +}; + +const space1 = { + id: 'space1', + name: 'Space 1', + slug: 'space1', +}; + +const space2 = { + id: 'space2', + name: 'Space 2', + slug: 'space2', +}; + +async function createSpaceAndUsers(db: ClientContract) { + // create users + await db.user.create({ data: user1 }); + await db.user.create({ data: user2 }); + await db.user.create({ data: user3 }); + + // add user1 and user2 into space1 + await db.space.create({ + data: { + ...space1, + members: { + create: [ + { + user: { connect: { id: user1.id } }, + role: 'ADMIN', + }, + { + user: { connect: { id: user2.id } }, + role: 'USER', + }, + ], + }, + }, + }); + + // add user3 to space2 + await db.space.create({ + data: { + ...space2, + members: { + create: [ + { + user: { connect: { id: user3.id } }, + role: 'ADMIN', + }, + ], + }, + }, + }); +} diff --git a/packages/runtime/test/policy/migrated/toplevel-operations.test.ts b/packages/runtime/test/policy/migrated/toplevel-operations.test.ts new file mode 100644 index 00000000..2bdaac8d --- /dev/null +++ b/packages/runtime/test/policy/migrated/toplevel-operations.test.ts @@ -0,0 +1,260 @@ +import { describe, expect, it } from 'vitest'; +import { createPolicyTestClient } from '../utils'; +import { testLogger } from '../../utils'; + +describe('Policy toplevel operations tests', () => { + it('read tests', async () => { + const db = await createPolicyTestClient( + ` + model Model { + id String @id @default(uuid()) + value Int + + @@allow('create', true) + @@allow('read', value > 1) + } + `, + ); + + await expect( + db.model.create({ + data: { + id: '1', + value: 1, + }, + }), + ).toBeRejectedByPolicy(); + const fromPrisma = await db.$unuseAll().model.findUnique({ + where: { id: '1' }, + }); + expect(fromPrisma).toBeTruthy(); + + expect(await db.model.findMany()).toHaveLength(0); + expect(await db.model.findUnique({ where: { id: '1' } })).toBeNull(); + expect(await db.model.findFirst({ where: { id: '1' } })).toBeNull(); + await expect(db.model.findUniqueOrThrow({ where: { id: '1' } })).toBeRejectedNotFound(); + await expect(db.model.findFirstOrThrow({ where: { id: '1' } })).toBeRejectedNotFound(); + + const item2 = { + id: '2', + value: 2, + }; + const r1 = await db.model.create({ + data: item2, + }); + expect(r1).toBeTruthy(); + expect(await db.model.findMany()).toHaveLength(1); + expect(await db.model.findUnique({ where: { id: '2' } })).toEqual(expect.objectContaining(item2)); + expect(await db.model.findFirst({ where: { id: '2' } })).toEqual(expect.objectContaining(item2)); + expect(await db.model.findUniqueOrThrow({ where: { id: '2' } })).toEqual(expect.objectContaining(item2)); + expect(await db.model.findFirstOrThrow({ where: { id: '2' } })).toEqual(expect.objectContaining(item2)); + }); + + it('write tests', async () => { + const db = await createPolicyTestClient( + ` + model Model { + id String @id @default(uuid()) + value Int + + @@allow('read', value > 1) + @@allow('create', value > 0) + @@allow('update', value > 1) + } + `, + ); + + // create denied + await expect( + db.model.create({ + data: { + value: 0, + }, + }), + ).toBeRejectedByPolicy(); + + // can't read back + await expect( + db.model.create({ + data: { + id: '1', + value: 1, + }, + }), + ).toBeRejectedByPolicy(); + + // success + expect( + await db.model.create({ + data: { + id: '2', + value: 2, + }, + }), + ).toBeTruthy(); + + // update not found + await expect(db.model.update({ where: { id: '3' }, data: { value: 5 } })).toBeRejectedNotFound(); + + // update-many empty + expect( + await db.model.updateMany({ + where: { id: '3' }, + data: { value: 5 }, + }), + ).toEqual(expect.objectContaining({ count: 0 })); + + // upsert + expect( + await db.model.upsert({ + where: { id: '3' }, + create: { id: '3', value: 5 }, + update: { value: 6 }, + }), + ).toEqual(expect.objectContaining({ value: 5 })); + + // update denied + await expect( + db.model.update({ + where: { id: '1' }, + data: { + value: 3, + }, + }), + ).toBeRejectedNotFound(); + + // update success + expect( + await db.model.update({ + where: { id: '2' }, + data: { + value: 3, + }, + }), + ).toBeTruthy(); + }); + + // TODO: `future()` support + it.skip('update id tests', async () => { + const db = await createPolicyTestClient( + ` + model Model { + id String @id @default(uuid()) + value Int + + @@allow('read', value > 1) + @@allow('create', value > 0) + @@allow('update', value > 1 && future().value > 2) + } + `, + ); + + await db.model.create({ + data: { + id: '1', + value: 2, + }, + }); + + // update denied + await expect( + db.model.update({ + where: { id: '1' }, + data: { + id: '2', + value: 1, + }, + }), + ).toBeRejectedNotFound(); + + // update success + await expect( + db.model.update({ + where: { id: '1' }, + data: { + id: '2', + value: 3, + }, + }), + ).resolves.toMatchObject({ id: '2', value: 3 }); + + // upsert denied + await expect( + db.model.upsert({ + where: { id: '2' }, + update: { + id: '3', + value: 1, + }, + create: { + id: '4', + value: 5, + }, + }), + ).toBeRejectedByPolicy(); + + // upsert success + await expect( + db.model.upsert({ + where: { id: '2' }, + update: { + id: '3', + value: 4, + }, + create: { + id: '4', + value: 5, + }, + }), + ).resolves.toMatchObject({ id: '3', value: 4 }); + }); + + it('delete tests', async () => { + const db = await createPolicyTestClient( + ` + model Model { + id String @id @default(uuid()) + value Int + + @@allow('create', true) + @@allow('read', value > 2) + @@allow('delete', value > 1) + } + `, + { log: testLogger }, + ); + + await expect(db.model.delete({ where: { id: '1' } })).toBeRejectedNotFound(); + + await expect( + db.model.create({ + data: { id: '1', value: 1 }, + }), + ).toBeRejectedByPolicy(); + + await expect(db.model.delete({ where: { id: '1' } })).toBeRejectedNotFound(); + await expect(db.$unuseAll().model.findUnique({ where: { id: '1' } })).toResolveTruthy(); + + await expect( + db.model.create({ + data: { id: '2', value: 2 }, + }), + ).toBeRejectedByPolicy(); + await expect(db.$unuseAll().model.findUnique({ where: { id: '2' } })).toBeTruthy(); + // deleted but unable to read back + await expect(db.model.delete({ where: { id: '2' } })).toBeRejectedByPolicy(); + await expect(db.$unuseAll().model.findUnique({ where: { id: '2' } })).toResolveNull(); + + await expect( + db.model.create({ + data: { id: '2', value: 2 }, + }), + ).toBeRejectedByPolicy(); + // only '2' is deleted, '1' is rejected by policy + expect(await db.model.deleteMany()).toEqual(expect.objectContaining({ count: 1 })); + expect(await db.$unuseAll().model.findUnique({ where: { id: '2' } })).toBeNull(); + expect(await db.$unuseAll().model.findUnique({ where: { id: '1' } })).toBeTruthy(); + + expect(await db.model.deleteMany()).toEqual(expect.objectContaining({ count: 0 })); + }); +}); diff --git a/packages/runtime/test/policy/migrated/unique-as-id.test.ts b/packages/runtime/test/policy/migrated/unique-as-id.test.ts new file mode 100644 index 00000000..6b3e8588 --- /dev/null +++ b/packages/runtime/test/policy/migrated/unique-as-id.test.ts @@ -0,0 +1,276 @@ +import { describe, expect, it } from 'vitest'; +import { createPolicyTestClient } from '../utils'; + +describe('Policy unique as id tests', () => { + it('unique fields', async () => { + const db = await createPolicyTestClient( + ` + model A { + x String @unique + y Int @unique + value Int + b B? + + @@allow('read', true) + @@allow('create', value > 0) + } + + model B { + b1 String @unique + b2 String @unique + value Int + a A @relation(fields: [ax], references: [x]) + ax String @unique + + @@allow('read', value > 2) + @@allow('create', value > 1) + } + `, + ); + + await expect(db.a.create({ data: { x: '1', y: 1, value: 0 } })).toBeRejectedByPolicy(); + await expect(db.a.create({ data: { x: '1', y: 2, value: 1 } })).toResolveTruthy(); + + await expect( + db.a.create({ data: { x: '2', y: 3, value: 1, b: { create: { b1: '1', b2: '2', value: 1 } } } }), + ).toBeRejectedByPolicy(); + + const r = await db.a.create({ + include: { b: true }, + data: { x: '2', y: 3, value: 1, b: { create: { b1: '1', b2: '2', value: 2 } } }, + }); + expect(r.b).toBeNull(); + const r1 = await db.$unuseAll().b.findUnique({ where: { b1: '1' } }); + expect(r1.value).toBe(2); + + await expect( + db.a.create({ + include: { b: true }, + data: { x: '3', y: 4, value: 1, b: { create: { b1: '2', b2: '3', value: 3 } } }, + }), + ).toResolveTruthy(); + }); + + it('unique fields mixed with id', async () => { + const db = await createPolicyTestClient( + ` + model A { + id Int @id @default(autoincrement()) + x String @unique + y Int @unique + value Int + b B? + + @@allow('read', true) + @@allow('create', value > 0) + } + + model B { + id Int @id @default(autoincrement()) + b1 String @unique + b2 String @unique + value Int + a A @relation(fields: [ax], references: [x]) + ax String @unique + + @@allow('read', value > 2) + @@allow('create', value > 1) + } + `, + ); + + await expect(db.a.create({ data: { x: '1', y: 1, value: 0 } })).toBeRejectedByPolicy(); + await expect(db.a.create({ data: { x: '1', y: 2, value: 1 } })).toResolveTruthy(); + + await expect( + db.a.create({ data: { x: '2', y: 3, value: 1, b: { create: { b1: '1', b2: '2', value: 1 } } } }), + ).toBeRejectedByPolicy(); + + const r = await db.a.create({ + include: { b: true }, + data: { x: '2', y: 3, value: 1, b: { create: { b1: '1', b2: '2', value: 2 } } }, + }); + expect(r.b).toBeNull(); + const r1 = await db.$unuseAll().b.findUnique({ where: { b1: '1' } }); + expect(r1.value).toBe(2); + + await expect( + db.a.create({ + include: { b: true }, + data: { x: '3', y: 4, value: 1, b: { create: { b1: '2', b2: '3', value: 3 } } }, + }), + ).toResolveTruthy(); + }); + + it('model-level unique fields', async () => { + const db = await createPolicyTestClient( + ` + model A { + x String + y Int + value Int + b B? + @@unique([x, y]) + + @@allow('read', true) + @@allow('create', value > 0) + } + + model B { + b1 String + b2 String + value Int + a A @relation(fields: [ax, ay], references: [x, y]) + ax String + ay Int + + @@allow('read', value > 2) + @@allow('create', value > 1) + + @@unique([ax, ay]) + @@unique([b1, b2]) + } + `, + ); + + await expect(db.a.create({ data: { x: '1', y: 1, value: 0 } })).toBeRejectedByPolicy(); + await expect(db.a.create({ data: { x: '1', y: 2, value: 1 } })).toResolveTruthy(); + + await expect( + db.a.create({ data: { x: '2', y: 1, value: 1, b: { create: { b1: '1', b2: '2', value: 1 } } } }), + ).toBeRejectedByPolicy(); + + const r = await db.a.create({ + include: { b: true }, + data: { x: '2', y: 1, value: 1, b: { create: { b1: '1', b2: '2', value: 2 } } }, + }); + expect(r.b).toBeNull(); + const r1 = await db.$unuseAll().b.findUnique({ where: { b1_b2: { b1: '1', b2: '2' } } }); + expect(r1.value).toBe(2); + + await expect( + db.a.create({ + include: { b: true }, + data: { x: '3', y: 1, value: 1, b: { create: { b1: '2', b2: '2', value: 3 } } }, + }), + ).toResolveTruthy(); + }); + + it('unique fields with to-many nested update', async () => { + const db = await createPolicyTestClient( + ` + model A { + id Int @id @default(autoincrement()) + x Int + y Int + value Int + bs B[] + @@unique([x, y]) + + @@allow('read,create', true) + @@allow('update,delete', value > 0) + } + + model B { + id Int @id @default(autoincrement()) + value Int + a A @relation(fields: [aId], references: [id]) + aId Int + + @@allow('all', value > 0) + } + `, + ); + + await db.a.create({ + data: { x: 1, y: 1, value: 1, bs: { create: [{ id: 1, value: 1 }] } }, + }); + + await db.a.create({ + data: { x: 2, y: 2, value: 2, bs: { create: [{ id: 2, value: 2 }] } }, + }); + + await db.a.update({ + where: { x_y: { x: 1, y: 1 } }, + data: { bs: { updateMany: { where: {}, data: { value: 3 } } } }, + }); + + // check b#1 is updated + await expect(db.b.findUnique({ where: { id: 1 } })).resolves.toMatchObject({ value: 3 }); + + // check b#2 is not affected + await expect(db.b.findUnique({ where: { id: 2 } })).resolves.toMatchObject({ value: 2 }); + + await db.a.update({ + where: { x_y: { x: 1, y: 1 } }, + data: { bs: { deleteMany: {} } }, + }); + + // check b#1 is deleted + await expect(db.b.findUnique({ where: { id: 1 } })).resolves.toBeNull(); + + // check b#2 is not affected + await expect(db.b.findUnique({ where: { id: 2 } })).resolves.toMatchObject({ value: 2 }); + }); + + it('unique fields with to-one nested update', async () => { + const db = await createPolicyTestClient( + ` + model A { + id Int @id @default(autoincrement()) + x Int + y Int + value Int + b B? + @@unique([x, y]) + + @@allow('read,create', true) + @@allow('update,delete', value > 0) + } + + model B { + id Int @id @default(autoincrement()) + value Int + a A @relation(fields: [aId], references: [id]) + aId Int @unique + + @@allow('all', value > 0) + } + `, + ); + + await db.a.create({ + data: { x: 1, y: 1, value: 1, b: { create: { id: 1, value: 1 } } }, + }); + + await db.a.create({ + data: { x: 2, y: 2, value: 2, b: { create: { id: 2, value: 2 } } }, + }); + + await db.a.update({ + where: { x_y: { x: 1, y: 1 } }, + data: { b: { update: { data: { value: 3 } } } }, + }); + + // check b#1 is updated + await expect(db.b.findUnique({ where: { id: 1 } })).resolves.toMatchObject({ value: 3 }); + + // check b#2 is not affected + await expect(db.b.findUnique({ where: { id: 2 } })).resolves.toMatchObject({ value: 2 }); + + await db.a.update({ + where: { x_y: { x: 1, y: 1 } }, + data: { b: { delete: true } }, + }); + + // check b#1 is deleted + await expect(db.b.findUnique({ where: { id: 1 } })).resolves.toBeNull(); + await expect(db.a.findUnique({ where: { x_y: { x: 1, y: 1 } }, include: { b: true } })).resolves.toMatchObject({ + b: null, + }); + + // check b#2 is not affected + await expect(db.b.findUnique({ where: { id: 2 } })).resolves.toMatchObject({ value: 2 }); + await expect(db.a.findUnique({ where: { x_y: { x: 2, y: 2 } }, include: { b: true } })).resolves.toBeTruthy(); + }); +}); diff --git a/packages/runtime/test/policy/migrated/update-many-and-return.test.ts b/packages/runtime/test/policy/migrated/update-many-and-return.test.ts new file mode 100644 index 00000000..ba83335b --- /dev/null +++ b/packages/runtime/test/policy/migrated/update-many-and-return.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from 'vitest'; +import { createPolicyTestClient } from '../utils'; + +describe('Policy updateManyAndReturn tests', () => { + it('model-level policies', async () => { + const db = await createPolicyTestClient( + ` + model User { + id Int @id @default(autoincrement()) + posts Post[] + level Int + + @@allow('read', level > 0) + } + + model Post { + id Int @id @default(autoincrement()) + title String + published Boolean @default(false) + userId Int + user User @relation(fields: [userId], references: [id]) + + @@allow('read', published) + @@allow('update', contains(title, 'hello')) + } + `, + ); + + const rawDb = db.$unuseAll(); + + await rawDb.user.createMany({ + data: [{ id: 1, level: 1 }], + }); + await rawDb.user.createMany({ + data: [{ id: 2, level: 0 }], + }); + + await rawDb.post.createMany({ + data: [ + { id: 1, title: 'hello1', userId: 1, published: true }, + { id: 2, title: 'world1', userId: 1, published: false }, + ], + }); + + // only post#1 is updated + const r = await db.post.updateManyAndReturn({ + data: { title: 'foo' }, + }); + expect(r).toHaveLength(1); + expect(r[0].id).toBe(1); + + // post#2 is excluded from update + await expect( + db.post.updateManyAndReturn({ + where: { id: 2 }, + data: { title: 'foo' }, + }), + ).resolves.toHaveLength(0); + + // reset + await rawDb.post.update({ where: { id: 1 }, data: { title: 'hello1' } }); + + // post#1 is updated + await expect( + db.post.updateManyAndReturn({ + where: { id: 1 }, + data: { title: 'foo' }, + }), + ).resolves.toHaveLength(1); + + // reset + await rawDb.post.update({ where: { id: 1 }, data: { title: 'hello1' } }); + + // read-back check + // post#1 updated but can't be read back + await expect( + db.post.updateManyAndReturn({ + data: { published: false }, + }), + ).toBeRejectedByPolicy(['result is not allowed to be read back']); + // but the update should have been applied + await expect(db.$unuseAll().post.findUnique({ where: { id: 1 } })).resolves.toMatchObject({ published: false }); + }); + + // TODO: field-level policy support + it.skip('field-level policies', async () => { + const db = await createPolicyTestClient( + ` + model Post { + id Int @id @default(autoincrement()) + title String @allow('read', published) + published Boolean @default(false) + + @@allow('all', true) + } + `, + ); + + const rawDb = db.$unuseAll(); + + // update should succeed but one result's title field can't be read back + await rawDb.post.createMany({ + data: [ + { id: 1, title: 'post1', published: true }, + { id: 2, title: 'post2', published: false }, + ], + }); + + const r = await db.post.updateManyAndReturn({ + data: { title: 'foo' }, + }); + + expect(r.length).toBe(2); + expect(r[0].title).toBeTruthy(); + expect(r[1].title).toBeUndefined(); + + // check posts are updated + await expect(rawDb.post.findMany({ where: { title: 'foo' } })).resolves.toHaveLength(2); + }); +}); diff --git a/packages/runtime/test/policy/migrated/view.test.ts b/packages/runtime/test/policy/migrated/view.test.ts new file mode 100644 index 00000000..ead81bad --- /dev/null +++ b/packages/runtime/test/policy/migrated/view.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest'; +import { createPolicyTestClient } from '../utils'; + +describe('View Policy Test', () => { + it('view policy', async () => { + const db = await createPolicyTestClient( + ` + model User { + id Int @id @default(autoincrement()) + email String @unique + name String? + posts Post[] + userInfo UserInfo? + } + + model Post { + id Int @id @default(autoincrement()) + title String + content String? + published Boolean @default(false) + author User? @relation(fields: [authorId], references: [id]) + authorId Int? + } + + view UserInfo { + id Int @unique + name String + email String + postCount Int + user User @relation(fields: [id], references: [id]) + + @@allow('read', postCount > 1) + } + `, + ); + + const rawDb = db.$unuseAll(); + await rawDb.$executeRaw`CREATE VIEW UserInfo as select user.id, user.name, user.email, user.id as userId, count(post.id) as postCount from user left join post on user.id = post.authorId group by user.id;`; + + await rawDb.user.create({ + data: { + email: 'alice@prisma.io', + name: 'Alice', + posts: { + create: { + title: 'Check out Prisma with Next.js', + content: 'https://www.prisma.io/nextjs', + published: true, + }, + }, + }, + }); + await rawDb.user.create({ + data: { + email: 'bob@prisma.io', + name: 'Bob', + posts: { + create: [ + { + title: 'Follow Prisma on Twitter', + content: 'https://twitter.com/prisma', + published: true, + }, + { + title: 'Follow Nexus on Twitter', + content: 'https://twitter.com/nexusgql', + published: false, + }, + ], + }, + }, + }); + + await expect(rawDb.userInfo.findMany()).resolves.toHaveLength(2); + await expect(db.userInfo.findMany()).resolves.toHaveLength(1); + + const r1 = await rawDb.userInfo.findFirst({ include: { user: true } }); + expect(r1.user).toBeTruthy(); + + // user not readable + await expect(db.userInfo.findFirst({ include: { user: true } })).resolves.toMatchObject({ user: null }); + }); +}); diff --git a/packages/sdk/src/schema/schema.ts b/packages/sdk/src/schema/schema.ts index c6ea4d9b..e8beefc9 100644 --- a/packages/sdk/src/schema/schema.ts +++ b/packages/sdk/src/schema/schema.ts @@ -33,6 +33,7 @@ export type ModelDef = { computedFields?: Record; isDelegate?: boolean; subModels?: string[]; + isView?: boolean; }; export type AttributeApplication = { diff --git a/packages/sdk/src/ts-schema-generator.ts b/packages/sdk/src/ts-schema-generator.ts index 5fc89046..75c0f44a 100644 --- a/packages/sdk/src/ts-schema-generator.ts +++ b/packages/sdk/src/ts-schema-generator.ts @@ -310,6 +310,8 @@ export class TsSchemaGenerator { ), ] : []), + + ...(dm.isView ? [ts.factory.createPropertyAssignment('isView', ts.factory.createTrue())] : []), ]; const computedFields = dm.fields.filter((f) => hasAttribute(f, '@computed'));