diff --git a/packages/runtime/src/client/crud/dialects/base-dialect.ts b/packages/runtime/src/client/crud/dialects/base-dialect.ts index 642297b8..17d300ac 100644 --- a/packages/runtime/src/client/crud/dialects/base-dialect.ts +++ b/packages/runtime/src/client/crud/dialects/base-dialect.ts @@ -102,7 +102,7 @@ export abstract class BaseCrudDialect { if ('distinct' in args && (args as any).distinct) { const distinct = ensureArray((args as any).distinct) as string[]; if (this.supportsDistinctOn) { - result = result.distinctOn(distinct.map((f) => sql.ref(`${modelAlias}.${f}`))); + result = result.distinctOn(distinct.map((f) => this.eb.ref(`${modelAlias}.${f}`))); } else { throw new QueryError(`"distinct" is not supported by "${this.schema.provider.type}" provider`); } @@ -248,7 +248,7 @@ export abstract class BaseCrudDialect { if (ownedByModel && !fieldDef.originModel) { // can be short-circuited to FK null check - return this.and(...keyPairs.map(({ fk }) => this.eb(sql.ref(`${modelAlias}.${fk}`), 'is', null))); + return this.and(...keyPairs.map(({ fk }) => this.eb(this.eb.ref(`${modelAlias}.${fk}`), 'is', null))); } else { // translate it to `{ is: null }` filter return this.buildToOneRelationFilter(model, modelAlias, field, fieldDef, { is: null }); @@ -268,7 +268,9 @@ export abstract class BaseCrudDialect { const joinSelect = this.eb .selectFrom(`${fieldDef.type} as ${joinAlias}`) - .where(() => this.and(...joinPairs.map(([left, right]) => this.eb(sql.ref(left), '=', sql.ref(right))))) + .where(() => + this.and(...joinPairs.map(([left, right]) => this.eb(this.eb.ref(left), '=', this.eb.ref(right)))), + ) .select(() => this.eb.fn.count(this.eb.lit(1)).as(filterResultField)); const conditions: Expression[] = []; @@ -331,7 +333,7 @@ export abstract class BaseCrudDialect { ) { // null check needs to be converted to fk "is null" checks if (payload === null) { - return this.eb(sql.ref(`${modelAlias}.${field}`), 'is', null); + return this.eb(this.eb.ref(`${modelAlias}.${field}`), 'is', null); } const relationModel = fieldDef.type; @@ -351,15 +353,15 @@ export abstract class BaseCrudDialect { invariant(relationIdFields.length === 1, 'many-to-many relation must have exactly one id field'); return eb( - sql.ref(`${relationFilterSelectAlias}.${relationIdFields[0]}`), + this.eb.ref(`${relationFilterSelectAlias}.${relationIdFields[0]}`), 'in', eb .selectFrom(m2m.joinTable) .select(`${m2m.joinTable}.${m2m.otherFkName}`) .whereRef( - sql.ref(`${m2m.joinTable}.${m2m.parentFkName}`), + this.eb.ref(`${m2m.joinTable}.${m2m.parentFkName}`), '=', - sql.ref(`${modelAlias}.${modelIdFields[0]}`), + this.eb.ref(`${modelAlias}.${modelIdFields[0]}`), ), ); } else { @@ -370,12 +372,20 @@ export abstract class BaseCrudDialect { if (relationKeyPairs.ownedByModel) { result = this.and( result, - eb(sql.ref(`${modelAlias}.${fk}`), '=', sql.ref(`${relationFilterSelectAlias}.${pk}`)), + eb( + this.eb.ref(`${modelAlias}.${fk}`), + '=', + this.eb.ref(`${relationFilterSelectAlias}.${pk}`), + ), ); } else { result = this.and( result, - eb(sql.ref(`${modelAlias}.${pk}`), '=', sql.ref(`${relationFilterSelectAlias}.${fk}`)), + eb( + this.eb.ref(`${modelAlias}.${pk}`), + '=', + this.eb.ref(`${relationFilterSelectAlias}.${fk}`), + ), ); } } @@ -833,7 +843,9 @@ export abstract class BaseCrudDialect { const joinPairs = buildJoinPairs(this.schema, model, modelAlias, field, subQueryAlias); subQuery = subQuery.where(() => this.and( - ...joinPairs.map(([left, right]) => eb(sql.ref(left), '=', sql.ref(right))), + ...joinPairs.map(([left, right]) => + eb(this.eb.ref(left), '=', this.eb.ref(right)), + ), ), ); subQuery = subQuery.select(() => eb.fn.count(eb.lit(1)).as('_count')); @@ -845,7 +857,9 @@ export abstract class BaseCrudDialect { result = result.leftJoin(relationModel, (join) => { const joinPairs = buildJoinPairs(this.schema, model, modelAlias, field, relationModel); return join.on((eb) => - this.and(...joinPairs.map(([left, right]) => eb(sql.ref(left), '=', sql.ref(right)))), + this.and( + ...joinPairs.map(([left, right]) => eb(this.eb.ref(left), '=', this.eb.ref(right))), + ), ); }); result = this.buildOrderBy(result, fieldDef.type, relationModel, value, false, negated); @@ -934,7 +948,7 @@ export abstract class BaseCrudDialect { return query.select(() => this.fieldRef(model, field, modelAlias).as(field)); } else if (!fieldDef.originModel) { // regular field - return query.select(sql.ref(`${modelAlias}.${field}`).as(field)); + return query.select(this.eb.ref(`${modelAlias}.${field}`).as(field)); } else { return this.buildSelectField(query, fieldDef.originModel, fieldDef.originModel, field); } diff --git a/packages/runtime/src/client/crud/dialects/postgresql.ts b/packages/runtime/src/client/crud/dialects/postgresql.ts index 0a60c350..2aec3a67 100644 --- a/packages/runtime/src/client/crud/dialects/postgresql.ts +++ b/packages/runtime/src/client/crud/dialects/postgresql.ts @@ -231,7 +231,7 @@ export class PostgresCrudDialect extends BaseCrudDiale } else { const joinPairs = buildJoinPairs(this.schema, model, parentAlias, relationField, relationModelAlias); query = query.where((eb) => - this.and(...joinPairs.map(([left, right]) => eb(sql.ref(left), '=', sql.ref(right)))), + this.and(...joinPairs.map(([left, right]) => eb(this.eb.ref(left), '=', this.eb.ref(right)))), ); } return query; diff --git a/packages/runtime/src/client/crud/operations/aggregate.ts b/packages/runtime/src/client/crud/operations/aggregate.ts index 5df07608..6362fbe6 100644 --- a/packages/runtime/src/client/crud/operations/aggregate.ts +++ b/packages/runtime/src/client/crud/operations/aggregate.ts @@ -1,4 +1,3 @@ -import { sql } from 'kysely'; import { match } from 'ts-pattern'; import type { SchemaDef } from '../../../schema'; import { getField } from '../../query-utils'; @@ -80,7 +79,9 @@ export class AggregateOperationHandler extends BaseOpe ); } else { query = query.select((eb) => - eb.cast(eb.fn.count(sql.ref(`$sub.${field}`)), 'integer').as(`${key}.${field}`), + eb + .cast(eb.fn.count(eb.ref(`$sub.${field}` as any)), 'integer') + .as(`${key}.${field}`), ); } } @@ -102,7 +103,7 @@ export class AggregateOperationHandler extends BaseOpe .with('_max', () => eb.fn.max) .with('_min', () => eb.fn.min) .exhaustive(); - return fn(sql.ref(`$sub.${field}`)).as(`${key}.${field}`); + return fn(eb.ref(`$sub.${field}` as any)).as(`${key}.${field}`); }); } }); diff --git a/packages/runtime/src/client/crud/operations/base.ts b/packages/runtime/src/client/crud/operations/base.ts index 1832874a..a33913d3 100644 --- a/packages/runtime/src/client/crud/operations/base.ts +++ b/packages/runtime/src/client/crud/operations/base.ts @@ -1540,7 +1540,9 @@ export abstract class BaseOperationHandler { if (!relationFieldDef.array) { const query = kysely .updateTable(model) - .where((eb) => eb.and(keyPairs.map(({ fk, pk }) => eb(sql.ref(fk), '=', fromRelation.ids[pk])))) + .where((eb) => + eb.and(keyPairs.map(({ fk, pk }) => eb(eb.ref(fk as any), '=', fromRelation.ids[pk]))), + ) .set(keyPairs.reduce((acc, { fk }) => ({ ...acc, [fk]: null }), {} as any)) .modifyEnd( this.makeContextComment({ diff --git a/packages/runtime/src/client/crud/operations/count.ts b/packages/runtime/src/client/crud/operations/count.ts index 90451745..0b31d795 100644 --- a/packages/runtime/src/client/crud/operations/count.ts +++ b/packages/runtime/src/client/crud/operations/count.ts @@ -1,4 +1,3 @@ -import { sql } from 'kysely'; import type { SchemaDef } from '../../../schema'; import { BaseOperationHandler } from './base'; @@ -40,7 +39,7 @@ export class CountOperationHandler extends BaseOperati Object.keys(parsedArgs.select!).map((key) => key === '_all' ? eb.cast(eb.fn.countAll(), 'integer').as('_all') - : eb.cast(eb.fn.count(sql.ref(`${subQueryName}.${key}`)), 'integer').as(key), + : eb.cast(eb.fn.count(eb.ref(`${subQueryName}.${key}` as any)), 'integer').as(key), ), ); const result = await this.executeQuery(this.kysely, query, 'count'); diff --git a/packages/runtime/src/client/executor/name-mapper.ts b/packages/runtime/src/client/executor/name-mapper.ts index 410aa7b7..dcea8152 100644 --- a/packages/runtime/src/client/executor/name-mapper.ts +++ b/packages/runtime/src/client/executor/name-mapper.ts @@ -38,10 +38,10 @@ export class QueryNameMapper extends OperationNodeTransformer { this.modelToTableMap.set(modelName, mappedName); } - for (const [fieldName, fieldDef] of Object.entries(modelDef.fields)) { + for (const fieldDef of this.getModelFields(modelDef)) { const mappedName = this.getMappedName(fieldDef); if (mappedName) { - this.fieldToColumnMap.set(`${modelName}.${fieldName}`, mappedName); + this.fieldToColumnMap.set(`${modelName}.${fieldDef.name}`, mappedName); } } } @@ -72,11 +72,14 @@ export class QueryNameMapper extends OperationNodeTransformer { on: this.transformNode(join.on), })) : undefined; + const selections = this.processSelectQuerySelections(node); + const baseResult = super.transformSelectQuery(node); + return { - ...super.transformSelectQuery(node), + ...baseResult, from: FromNode.create(processedFroms.map((f) => f.node)), joins, - selections: this.processSelectQuerySelections(node), + selections, }; }); } @@ -132,7 +135,8 @@ export class QueryNameMapper extends OperationNodeTransformer { mappedTableName ? TableNode.create(mappedTableName) : undefined, ); } else { - return super.transformReference(node); + // no name mapping needed + return node; } } @@ -270,7 +274,7 @@ export class QueryNameMapper extends OperationNodeTransformer { if (!modelDef) { continue; } - if (modelDef.fields[name]) { + if (this.getModelFields(modelDef).some((f) => f.name === name)) { return scope; } } diff --git a/tests/e2e/orm/client-api/computed-fields.test.ts b/tests/e2e/orm/client-api/computed-fields.test.ts index 4969d21d..84d006b8 100644 --- a/tests/e2e/orm/client-api/computed-fields.test.ts +++ b/tests/e2e/orm/client-api/computed-fields.test.ts @@ -1,4 +1,3 @@ -import { sql } from '@zenstackhq/runtime/helpers'; import { createTestClient } from '@zenstackhq/testtools'; import { afterEach, describe, expect, it } from 'vitest'; @@ -226,7 +225,7 @@ model Post { postCount: (eb: any, context: { modelAlias: string }) => eb .selectFrom('Post') - .whereRef('Post.authorId', '=', sql.ref(`${context.modelAlias}.id`)) + .whereRef('Post.authorId', '=', eb.ref(`${context.modelAlias}.id`)) .select(() => eb.fn.countAll().as('count')), }, }, diff --git a/tests/regression/test/v2-migrated/issue-1235.test.ts b/tests/regression/test/v2-migrated/issue-1235.test.ts index e5d17b6a..93b637af 100644 --- a/tests/regression/test/v2-migrated/issue-1235.test.ts +++ b/tests/regression/test/v2-migrated/issue-1235.test.ts @@ -1,4 +1,4 @@ -import { createPolicyTestClient, testLogger } from '@zenstackhq/testtools'; +import { createPolicyTestClient } from '@zenstackhq/testtools'; import { describe, expect, it } from 'vitest'; describe('Regression for issue 1235', () => { @@ -11,7 +11,6 @@ model Post { @@allow('all', true) } `, - { log: testLogger }, ); const post = await db.post.create({ data: {} }); diff --git a/tests/regression/test/v2-migrated/issue-1506.test.ts b/tests/regression/test/v2-migrated/issue-1506.test.ts new file mode 100644 index 00000000..759ec279 --- /dev/null +++ b/tests/regression/test/v2-migrated/issue-1506.test.ts @@ -0,0 +1,35 @@ +import { createPolicyTestClient } from '@zenstackhq/testtools'; +import { it } from 'vitest'; + +it('verifies issue 1506', async () => { + await createPolicyTestClient( + ` +model A { + id Int @id @default(autoincrement()) + value Int + b B @relation(fields: [bId], references: [id]) + bId Int @unique + + @@allow('read', true) +} + +model B { + id Int @id @default(autoincrement()) + value Int + a A? + c C @relation(fields: [cId], references: [id]) + cId Int @unique + + @@allow('read', value > c.value) +} + +model C { + id Int @id @default(autoincrement()) + value Int + b B? + + @@allow('read', true) +} + `, + ); +}); diff --git a/tests/regression/test/v2-migrated/issue-1507.test.ts b/tests/regression/test/v2-migrated/issue-1507.test.ts new file mode 100644 index 00000000..aee6fddd --- /dev/null +++ b/tests/regression/test/v2-migrated/issue-1507.test.ts @@ -0,0 +1,25 @@ +import { createPolicyTestClient } from '@zenstackhq/testtools'; +import { expect, it } from 'vitest'; + +it('verifies issue 1507', async () => { + const db = await createPolicyTestClient( + ` +model User { + id Int @id @default(autoincrement()) + age Int +} + +model Profile { + id Int @id @default(autoincrement()) + age Int + + @@allow('read', auth().age == age) +} + `, + ); + + await db.$unuseAll().profile.create({ data: { age: 18 } }); + await db.$unuseAll().profile.create({ data: { age: 20 } }); + await expect(db.$setAuth({ id: 1, age: 18 }).profile.findMany()).resolves.toHaveLength(1); + await expect(db.$setAuth({ id: 1, age: 18 }).profile.count()).resolves.toBe(1); +}); diff --git a/tests/regression/test/v2-migrated/issue-1518.test.ts b/tests/regression/test/v2-migrated/issue-1518.test.ts new file mode 100644 index 00000000..1dfa436e --- /dev/null +++ b/tests/regression/test/v2-migrated/issue-1518.test.ts @@ -0,0 +1,30 @@ +import { createTestClient } from '@zenstackhq/testtools'; +import { it } from 'vitest'; + +it('verifies issue 1518', async () => { + const db = await createTestClient( + ` +model Activity { + id String @id @default(uuid()) + title String + type String + @@delegate(type) + @@allow('all', true) +} + +model TaskActivity extends Activity { + description String + @@map("task_activity") + @@allow('all', true) +} + `, + ); + + await db.taskActivity.create({ + data: { + id: '00000000-0000-0000-0000-111111111111', + title: 'Test Activity', + description: 'Description of task', + }, + }); +}); diff --git a/tests/regression/test/v2-migrated/issue-1520.test.ts b/tests/regression/test/v2-migrated/issue-1520.test.ts new file mode 100644 index 00000000..79b8fc54 --- /dev/null +++ b/tests/regression/test/v2-migrated/issue-1520.test.ts @@ -0,0 +1,67 @@ +import { createTestClient } from '@zenstackhq/testtools'; +import { expect, it } from 'vitest'; + +it('verifies issue 1520', async () => { + const db = await createTestClient( + ` +model Course { + id Int @id @default(autoincrement()) + title String + addedToNotifications AddedToCourseNotification[] +} + +model Group { + id Int @id @default(autoincrement()) + addedToNotifications AddedToGroupNotification[] +} + +model Notification { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) + type String + senderId Int + receiverId Int + @@delegate (type) +} + +model AddedToGroupNotification extends Notification { + groupId Int + group Group @relation(fields: [groupId], references: [id], onDelete: Cascade) +} + +model AddedToCourseNotification extends Notification { + courseId Int + course Course @relation(fields: [courseId], references: [id], onDelete: Cascade) +} + `, + ); + + const r = await db.course.create({ + data: { + title: 'English classes', + addedToNotifications: { + createMany: { + data: [ + { + id: 1, + receiverId: 1, + senderId: 2, + }, + ], + }, + }, + }, + include: { addedToNotifications: true }, + }); + + expect(r.addedToNotifications).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 1, + courseId: 1, + receiverId: 1, + senderId: 2, + }), + ]), + ); +}); diff --git a/tests/regression/test/v2-migrated/issue-1522.test.ts b/tests/regression/test/v2-migrated/issue-1522.test.ts new file mode 100644 index 00000000..da18046a --- /dev/null +++ b/tests/regression/test/v2-migrated/issue-1522.test.ts @@ -0,0 +1,90 @@ +import { createTestClient } from '@zenstackhq/testtools'; +import { expect, it } from 'vitest'; + +it('verifies issue 1522', async () => { + const db = await createTestClient( + ` +model Course { + id String @id @default(uuid()) + title String + description String + sections Section[] + activities Activity[] + @@allow('all', true) +} + +model Section { + id String @id @default(uuid()) + title String + courseId String + idx Int @default(0) + course Course @relation(fields: [courseId], references: [id]) + activities Activity[] +} + +model Activity { + id String @id @default(uuid()) + title String + courseId String + sectionId String + idx Int @default(0) + type String + course Course @relation(fields: [courseId], references: [id]) + section Section @relation(fields: [sectionId], references: [id]) + @@delegate(type) +} + +model UrlActivity extends Activity { + url String +} + +model TaskActivity extends Activity { + description String +} + `, + ); + + const course = await db.course.create({ + data: { + title: 'Test Course', + description: 'Description of course', + sections: { + create: { + id: '00000000-0000-0000-0000-000000000002', + title: 'Test Section', + idx: 0, + }, + }, + }, + include: { + sections: true, + }, + }); + + const section = course.sections[0]; + await db.taskActivity.create({ + data: { + title: 'Test Activity', + description: 'Description of task', + idx: 0, + courseId: course.id, + sectionId: section.id, + }, + }); + + const found = await db.course.findFirst({ + where: { id: course.id }, + include: { + sections: { + orderBy: { idx: 'asc' }, + include: { + activities: { orderBy: { idx: 'asc' } }, + }, + }, + }, + }); + + expect(found.sections[0].activities[0]).toMatchObject({ + description: 'Description of task', + }); +}); diff --git a/tests/regression/test/v2-migrated/issue-1530.test.ts b/tests/regression/test/v2-migrated/issue-1530.test.ts new file mode 100644 index 00000000..55fb1089 --- /dev/null +++ b/tests/regression/test/v2-migrated/issue-1530.test.ts @@ -0,0 +1,34 @@ +import { createTestClient } from '@zenstackhq/testtools'; +import { expect, it } from 'vitest'; + +it('verifies issue 1530', async () => { + const db = await createTestClient( + ` +model Category { + id Int @id @default(autoincrement()) + name String @unique + + parentId Int? + parent Category? @relation("ParentChildren", fields: [parentId], references: [id]) + children Category[] @relation("ParentChildren") + @@allow('all', true) +} + `, + { usePrismaPush: true }, + ); + + await db.$unuseAll().category.create({ + data: { id: 1, name: 'C1' }, + }); + + await db.category.update({ + where: { id: 1 }, + data: { parent: { connect: { id: 1 } } }, + }); + + const r = await db.category.update({ + where: { id: 1 }, + data: { parent: { disconnect: true } }, + }); + expect(r.parent).toBeUndefined(); +}); diff --git a/tests/regression/test/v2-migrated/issue-1533.test.ts b/tests/regression/test/v2-migrated/issue-1533.test.ts new file mode 100644 index 00000000..4b1e131b --- /dev/null +++ b/tests/regression/test/v2-migrated/issue-1533.test.ts @@ -0,0 +1,53 @@ +import { createTestClient } from '@zenstackhq/testtools'; +import { expect, it } from 'vitest'; + +// TODO: JSON null support +it.skip('verifies issue 1533', async () => { + const db = await createTestClient( + ` +model Test { + id String @id @default(uuid()) @db.Uuid + metadata Json + @@allow('all', true) +} + `, + ); + + const testWithMetadata = await db.test.create({ + data: { + metadata: { + test: 'test', + }, + }, + }); + const testWithEmptyMetadata = await db.test.create({ + data: { + metadata: {}, + }, + }); + + let result = await db.test.findMany({ + where: { + metadata: { + path: ['test'], + // @ts-expect-error + equals: Prisma.DbNull, + }, + }, + }); + + expect(result).toHaveLength(1); + expect(result).toEqual(expect.arrayContaining([expect.objectContaining({ id: testWithEmptyMetadata.id })])); + + result = await db.test.findMany({ + where: { + metadata: { + path: ['test'], + equals: 'test', + }, + }, + }); + + expect(result).toHaveLength(1); + expect(result).toEqual(expect.arrayContaining([expect.objectContaining({ id: testWithMetadata.id })])); +}); diff --git a/tests/regression/test/v2-migrated/issue-1551.test.ts b/tests/regression/test/v2-migrated/issue-1551.test.ts new file mode 100644 index 00000000..441eed43 --- /dev/null +++ b/tests/regression/test/v2-migrated/issue-1551.test.ts @@ -0,0 +1,26 @@ +import { loadSchema } from '@zenstackhq/testtools'; +import { it } from 'vitest'; + +it('verifies issue 1551', async () => { + await loadSchema( + ` +model User { + id Int @id + profile Profile? @relation(fields: [profileId], references: [id]) + profileId Int? @unique @map('profile_id') +} + +model Profile { + id Int @id + contentType String + user User? + + @@delegate(contentType) +} + +model IndividualProfile extends Profile { + name String +} + `, + ); +}); diff --git a/tests/regression/test/v2-migrated/issue-1562.test.ts b/tests/regression/test/v2-migrated/issue-1562.test.ts new file mode 100644 index 00000000..98e3e98a --- /dev/null +++ b/tests/regression/test/v2-migrated/issue-1562.test.ts @@ -0,0 +1,25 @@ +import { createTestClient } from '@zenstackhq/testtools'; +import { expect, it } from 'vitest'; + +it('verifies issue 1562', async () => { + const db = await createTestClient( + ` +type Base { + id String @id @default(uuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt() + + // require login + @@allow('all', true) +} + +model User with Base { + name String @unique @regex('^[a-zA-Z0-9_]{3,30}$') + + @@allow('read', true) +} + `, + ); + + await expect(db.user.create({ data: { name: '1 2 3 4' } })).toBeRejectedByValidation(); +}); diff --git a/tests/regression/test/v2-migrated/issue-1563.test.ts b/tests/regression/test/v2-migrated/issue-1563.test.ts new file mode 100644 index 00000000..5b7c1b28 --- /dev/null +++ b/tests/regression/test/v2-migrated/issue-1563.test.ts @@ -0,0 +1,26 @@ +import { createTestClient } from '@zenstackhq/testtools'; +import { expect, it } from 'vitest'; + +it('verifies issue 1563', async () => { + const db = await createTestClient( + ` +model ModelA { + id String @id @default(cuid()) + ref ModelB[] +} + +model ModelB { + id String @id @default(cuid()) + ref ModelA? @relation(fields: [refId], references: [id]) + refId String? + + @@validate(refId != null, "refId must be set") +} + `, + ); + + const a = await db.modelA.create({ data: {} }); + const b = await db.modelB.create({ data: { refId: a.id } }); + + await expect(db.modelB.update({ where: { id: b.id }, data: { refId: a.id } })).toResolveTruthy(); +}); diff --git a/tests/regression/test/v2-migrated/issue-1574.test.ts b/tests/regression/test/v2-migrated/issue-1574.test.ts new file mode 100644 index 00000000..895852b3 --- /dev/null +++ b/tests/regression/test/v2-migrated/issue-1574.test.ts @@ -0,0 +1,107 @@ +import { createPolicyTestClient } from '@zenstackhq/testtools'; +import { expect, it } from 'vitest'; + +// TODO: field-level policy support +it.skip('verifies issue 1574', async () => { + const db = await createPolicyTestClient( + ` +model User { + id String @id @default(cuid()) + modelA ModelA[] +} + +// +// ModelA has model-level access-all by owner, but read-all override for the name property +// +model ModelA { + id String @id @default(cuid()) + + owner User @relation(fields: [ownerId], references: [id]) + ownerId String + + name String @allow('read', true, true) + prop2 String? + + refsB ModelB[] + refsC ModelC[] + + @@allow('all', owner == auth()) +} + +// +// ModelB and ModelC are both allow-all everyone. +// They both have a reference to ModelA, but in ModelB that reference is optional. +// +model ModelB { + id String @id @default(cuid()) + + ref ModelA? @relation(fields: [refId], references: [id]) + refId String? + + @@allow('all', true) +} +model ModelC { + id String @id @default(cuid()) + + ref ModelA @relation(fields: [refId], references: [id]) + refId String + + @@allow('all', true) +} + `, + ); + + // create two users + const user1 = await db.$unuseAll().user.create({ data: { id: '1' } }); + const user2 = await db.$unuseAll().user.create({ data: { id: '2' } }); + + // create two db instances, enhanced for users 1 and 2 + const db1 = db.$setAuth(user1); + const db2 = db.$setAuth(user2); + + // create a ModelA owned by user1 + const a = await db1.modelA.create({ data: { name: 'a', ownerId: user1.id } }); + + // create a ModelB and a ModelC with refs to ModelA + await db1.modelB.create({ data: { refId: a.id } }); + await db2.modelC.create({ data: { refId: a.id } }); + + // works: user1 should be able to read b as well as the entire referenced a + const t1 = await db1.modelB.findFirst({ select: { ref: true } }); + expect(t1.ref.name).toBeTruthy(); + + // works: user1 also should be able to read b as well as the name of the referenced a + const t2 = await db1.modelB.findFirst({ select: { ref: { select: { name: true } } } }); + expect(t2.ref.name).toBeTruthy(); + + // works: user2 also should be able to read b as well as the name of the referenced a + const t3 = await db2.modelB.findFirst({ select: { ref: { select: { name: true } } } }); + expect(t3.ref.name).toBeTruthy(); + + // works: but user2 should not be able to read b with the entire referenced a + const t4 = await db2.modelB.findFirst({ select: { ref: true } }); + expect(t4.ref).toBeFalsy(); + + // + // The following are essentially the same tests, but with ModelC instead of ModelB + // + + // works: user1 should be able to read c as well as the entire referenced a + const t5 = await db1.modelC.findFirst({ select: { ref: true } }); + expect(t5.ref.name).toBeTruthy(); + + // works: user1 also should be able to read c as well as the name of the referenced a + const t6 = await db1.modelC.findFirst({ select: { ref: { select: { name: true } } } }); + expect(t6.ref.name).toBeTruthy(); + + // works: user2 should not be able to read b along with the a reference. + // In this case, the entire query returns null because of the required (but inaccessible) ref. + await expect(db2.modelC.findFirst({ select: { ref: true } })).toResolveFalsy(); + + // works: if user2 queries c directly and gets the refId to a, it can get the a.name directly + const t7 = await db2.modelC.findFirstOrThrow(); + await expect(db2.modelA.findFirst({ select: { name: true }, where: { id: t7.refId } })).toResolveTruthy(); + + // fails: since the last query worked, we'd expect to be able to query c along with the name of the referenced a directly + await expect(db2.modelC.findFirst({ select: { ref: { select: { name: true } } } })).toResolveTruthy(); +}); diff --git a/tests/regression/test/v2-migrated/issue-1575.test.ts b/tests/regression/test/v2-migrated/issue-1575.test.ts new file mode 100644 index 00000000..c09ac5aa --- /dev/null +++ b/tests/regression/test/v2-migrated/issue-1575.test.ts @@ -0,0 +1,29 @@ +import { loadSchema } from '@zenstackhq/testtools'; +import { it } from 'vitest'; + +it('verifies issue 1575', async () => { + await loadSchema( + ` +model UserAssets { + id String @id @default(cuid()) + videoId String + videoStream Asset @relation("userVideo", fields: [videoId], references: [id]) + subtitleId String + subtitlesAsset Asset @relation("userSubtitles", fields: [subtitleId], references: [id]) +} + +model Asset { + id String @id @default(cuid()) + type String + userVideo UserAssets[] @relation("userVideo") + userSubtitles UserAssets[] @relation("userSubtitles") + + @@delegate(type) +} + +model Movie extends Asset { + duration Int +} + `, + ); +}); diff --git a/tests/regression/test/v2-migrated/issue-1576.test.ts b/tests/regression/test/v2-migrated/issue-1576.test.ts new file mode 100644 index 00000000..078fd7cc --- /dev/null +++ b/tests/regression/test/v2-migrated/issue-1576.test.ts @@ -0,0 +1,61 @@ +import { createTestClient } from '@zenstackhq/testtools'; +import { expect, it } from 'vitest'; + +it('verifies issue 1576', async () => { + const db = await createTestClient( + ` +model Profile { + id Int @id @default(autoincrement()) + name String + items Item[] + type String + @@delegate(type) + @@allow('all', true) +} + +model GoldProfile extends Profile { + ticket Int +} + +model Item { + id Int @id @default(autoincrement()) + profileId Int + profile Profile @relation(fields: [profileId], references: [id]) + type String + @@delegate(type) + @@allow('all', true) +} + +model GoldItem extends Item { + inventory Boolean +} + `, + ); + + const profile = await db.goldProfile.create({ + data: { + name: 'hello', + ticket: 5, + }, + }); + + await expect( + db.goldItem.createManyAndReturn({ + data: [ + { + profileId: profile.id, + inventory: true, + }, + { + profileId: profile.id, + inventory: true, + }, + ], + }), + ).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ profileId: profile.id, type: 'GoldItem', inventory: true }), + expect.objectContaining({ profileId: profile.id, type: 'GoldItem', inventory: true }), + ]), + ); +}); diff --git a/tests/regression/test/v2-migrated/issue-1585.test.ts b/tests/regression/test/v2-migrated/issue-1585.test.ts new file mode 100644 index 00000000..388e0cde --- /dev/null +++ b/tests/regression/test/v2-migrated/issue-1585.test.ts @@ -0,0 +1,29 @@ +import { createTestClient } from '@zenstackhq/testtools'; +import { expect, it } from 'vitest'; + +it('verifies issue 1585', async () => { + const db = await createTestClient( + ` + model Asset { + id Int @id @default(autoincrement()) + type String + views Int + + @@allow('all', true) + @@delegate(type) + } + + model Post extends Asset { + title String + } + `, + ); + + await db.post.create({ data: { title: 'Post1', views: 0 } }); + await db.post.create({ data: { title: 'Post2', views: 1 } }); + await expect( + db.post.count({ + where: { views: { gt: 0 } }, + }), + ).resolves.toBe(1); +}); diff --git a/tests/regression/test/v2-migrated/issue-1627.test.ts b/tests/regression/test/v2-migrated/issue-1627.test.ts new file mode 100644 index 00000000..a086b42f --- /dev/null +++ b/tests/regression/test/v2-migrated/issue-1627.test.ts @@ -0,0 +1,49 @@ +import { createPolicyTestClient } from '@zenstackhq/testtools'; +import { expect, it } from 'vitest'; + +it('verifies issue 1627', async () => { + const db = await createPolicyTestClient( + ` +model User { + id String @id + memberships GymUser[] +} + +model Gym { + id String @id + members GymUser[] + + @@allow('all', true) +} + +model GymUser { + id String @id + userID String + user User @relation(fields: [userID], references: [id]) + gymID String? + gym Gym? @relation(fields: [gymID], references: [id]) + role String + + @@allow('read',gym.members?[user == auth() && (role == "ADMIN" || role == "TRAINER")]) + @@unique([userID, gymID]) +} + `, + ); + + await db.$unuseAll().user.create({ data: { id: '1' } }); + + await db.$unuseAll().gym.create({ + data: { + id: '1', + members: { + create: { + id: '1', + user: { connect: { id: '1' } }, + role: 'ADMIN', + }, + }, + }, + }); + + await expect(db.gymUser.findMany()).resolves.toHaveLength(0); +}); diff --git a/tests/regression/test/v2-migrated/issue-1642.test.ts b/tests/regression/test/v2-migrated/issue-1642.test.ts new file mode 100644 index 00000000..3fc9b542 --- /dev/null +++ b/tests/regression/test/v2-migrated/issue-1642.test.ts @@ -0,0 +1,40 @@ +import { createPolicyTestClient } from '@zenstackhq/testtools'; +import { expect, it } from 'vitest'; + +it('verifies issue 1642', async () => { + const db = await createPolicyTestClient( + ` +model User { + id Int @id + name String + posts Post[] + + @@allow('read', true) + @@allow('all', auth().id == 1) +} + +model Post { + id Int @id + title String + description String + author User @relation(fields: [authorId], references: [id]) + authorId Int + + // delegate all access policies to the author: + @@allow('all', check(author)) + @@allow('update', true) + @@allow('post-update', title == 'hello') +} + `, + ); + + await db.$unuseAll().user.create({ data: { id: 1, name: 'User1' } }); + await db.$unuseAll().post.create({ data: { id: 1, title: 'hello', description: 'desc1', authorId: 1 } }); + + const authDb = db.$setAuth({ id: 2 }); + await expect( + authDb.post.update({ where: { id: 1 }, data: { title: 'world', description: 'desc2' } }), + ).toBeRejectedByPolicy(); + + await expect(authDb.post.update({ where: { id: 1 }, data: { description: 'desc2' } })).toResolveTruthy(); +}); diff --git a/tests/regression/test/v2-migrated/issue-1644.test.ts b/tests/regression/test/v2-migrated/issue-1644.test.ts new file mode 100644 index 00000000..e6c691d2 --- /dev/null +++ b/tests/regression/test/v2-migrated/issue-1644.test.ts @@ -0,0 +1,24 @@ +import { createTestClient } from '@zenstackhq/testtools'; +import { expect, it } from 'vitest'; + +// TODO: field-level policy support +it.skip('verifies issue 1644', async () => { + const db = await createTestClient( + ` +model User { + id Int @id @default(autoincrement()) + email String @unique @email @length(6, 32) @allow('read', auth() == this) + + // full access to all + @@allow('all', true) +} + `, + ); + + await db.$unuseAll().user.create({ data: { id: 1, email: 'a@example.com' } }); + await db.$unuseAll().user.create({ data: { id: 2, email: 'b@example.com' } }); + + const authDb = db.$setAuth({ id: 1 }); + await expect(authDb.user.count({ where: { email: { contains: 'example.com' } } })).resolves.toBe(1); + await expect(authDb.user.findMany({ where: { email: { contains: 'example.com' } } })).resolves.toHaveLength(1); +}); diff --git a/tests/regression/test/v2-migrated/issue-1645.test.ts b/tests/regression/test/v2-migrated/issue-1645.test.ts new file mode 100644 index 00000000..a04fca7d --- /dev/null +++ b/tests/regression/test/v2-migrated/issue-1645.test.ts @@ -0,0 +1,201 @@ +import { createPolicyTestClient } from '@zenstackhq/testtools'; +import { expect, it } from 'vitest'; + +it('verifies issue 1645', async () => { + const db = await createPolicyTestClient( + ` +model Product { + id String @id @default(cuid()) + name String + slug String + description String? + sku String + price Int + onSale Boolean @default(false) + salePrice Int @default(0) + saleStartDateTime DateTime? + saleEndDateTime DateTime? + scheduledAvailability Boolean @default(false) + availabilityStartDateTime DateTime? + availabilityEndDateTime DateTime? + type String @default('VARIABLE') + image String + orderItems OrderItem[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([slug]) + + @@allow('all', true) +} + +model BaseOrder { + id String @id @default(cuid()) + orderNumber String @unique @default(nanoid(16)) + lineItems OrderItem[] + status String @default('PENDING') + type String @default('PARENT') + userType String? + billingAddress BillingAddress @relation(fields: [billingAddressId], references: [id]) + billingAddressId String @map("billing_address_id") + shippingAddress ShippingAddress @relation(fields: [shippingAddressId], references: [id]) + shippingAddressId String @map("shipping_address_id") + notes String? @default('') + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@allow('all', true) + @@delegate(userType) +} + +model Order extends BaseOrder { + parentId String? @map("parent_id") + parent Order? @relation("OrderToParent", fields: [parentId], references: [id]) + groupedOrders Order[] @relation("OrderToParent") + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId String @map("user_id") +} + +model User { + id String @id @default(cuid()) + name String? + email String? @unique + emailVerified DateTime? + image String? + orders Order[] + billingAddresses BillingAddress[] + shippingAddresses ShippingAddress[] + + @@allow('create,read', true) + @@allow('update,delete', auth().id == this.id) +} + +model GuestUser { + id String @id @default(cuid()) + name String? + email String @unique + orders GuestOrder[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@auth + @@allow('all', true) +} + +model GuestOrder extends BaseOrder { + guestUser GuestUser @relation(fields: [guestUserId], references: [id], onDelete: Cascade) + guestUserId String @map("guest_user_id") + parentId String? @map("parent_id") + parent GuestOrder? @relation("OrderToParent", fields: [parentId], references: [id]) + groupedOrders GuestOrder[] @relation("OrderToParent") +} + +model OrderItem { + id String @id @default(cuid()) + order BaseOrder @relation(fields: [orderId], references: [id], onDelete: Cascade) + orderId String @map("order_id") + product Product @relation(fields: [productId], references: [id]) + productId String @map("product_id") + quantity Int + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@allow('all', true) +} + +model OrderAddress { + id String @id @default(cuid()) + firstName String + lastName String + address1 String + address2 String? + city String + state String + postalCode String + country String + email String + phone String + type String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@allow('all', true) + @@delegate(type) +} + +model BillingAddress extends OrderAddress { + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + userId String? @map("user_id") + order BaseOrder[] +} + +model ShippingAddress extends OrderAddress { + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + userId String? @map("user_id") + order BaseOrder[] +} + `, + { usePrismaPush: true }, + ); + + await db.user.create({ data: { id: '1', name: 'John', email: 'john@example.com' } }); + + const shipping = await db.shippingAddress.create({ + data: { + id: '1', + firstName: 'John', + lastName: 'Doe', + address1: '123 Main St', + city: 'Anytown', + state: 'CA', + postalCode: '12345', + country: 'US', + email: 'john@example.com', + phone: '123-456-7890', + user: { connect: { id: '1' } }, + }, + }); + + const billing = await db.billingAddress.create({ + data: { + id: '2', + firstName: 'John', + lastName: 'Doe', + address1: '123 Main St', + city: 'Anytown', + state: 'CA', + postalCode: '12345', + country: 'US', + email: 'john@example.com', + phone: '123-456-7890', + user: { connect: { id: '1' } }, + }, + }); + + await db.order.create({ + data: { + id: '1', + orderNumber: '1', + status: 'PENDING', + type: 'PARENT', + shippingAddress: { connect: { id: '1' } }, + billingAddress: { connect: { id: '2' } }, + user: { connect: { id: '1' } }, + }, + }); + + const updated = await db.order.update({ + where: { id: '1' }, + include: { + lineItems: true, + billingAddress: true, + shippingAddress: true, + }, + data: { + type: 'CAMPAIGN', + }, + }); + + expect(updated.shippingAddress).toEqual(shipping); + expect(updated.billingAddress).toEqual(billing); +});