diff --git a/packages/language/src/generated/ast.ts b/packages/language/src/generated/ast.ts index e086b82e5..e8737b257 100644 --- a/packages/language/src/generated/ast.ts +++ b/packages/language/src/generated/ast.ts @@ -194,6 +194,7 @@ export interface DataModel extends AstNode { comments: Array fields: Array isAbstract: boolean + isView: boolean name: RegularID superTypes: Array> } @@ -748,6 +749,7 @@ export class ZModelAstReflection extends AbstractAstReflection { { name: 'comments', type: 'array' }, { name: 'fields', type: 'array' }, { name: 'isAbstract', type: 'boolean' }, + { name: 'isView', type: 'boolean' }, { name: 'superTypes', type: 'array' } ] }; diff --git a/packages/language/src/generated/grammar.ts b/packages/language/src/generated/grammar.ts index 7561db187..a0b1c972b 100644 --- a/packages/language/src/generated/grammar.ts +++ b/packages/language/src/generated/grammar.ts @@ -1681,74 +1681,110 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "cardinality": "*" }, { - "$type": "Assignment", - "feature": "isAbstract", - "operator": "?=", - "terminal": { - "$type": "Keyword", - "value": "abstract" - }, - "cardinality": "?" - }, - { - "$type": "Keyword", - "value": "model" - }, - { - "$type": "Assignment", - "feature": "name", - "operator": "=", - "terminal": { - "$type": "RuleCall", - "rule": { - "$ref": "#/rules@40" - }, - "arguments": [] - } - }, - { - "$type": "Group", + "$type": "Alternatives", "elements": [ { - "$type": "Keyword", - "value": "extends" - }, - { - "$type": "Assignment", - "feature": "superTypes", - "operator": "+=", - "terminal": { - "$type": "CrossReference", - "type": { - "$ref": "#/rules@30" + "$type": "Group", + "elements": [ + { + "$type": "Assignment", + "feature": "isAbstract", + "operator": "?=", + "terminal": { + "$type": "Keyword", + "value": "abstract" + }, + "cardinality": "?" }, - "deprecatedSyntax": false - } + { + "$type": "Keyword", + "value": "model" + }, + { + "$type": "Assignment", + "feature": "name", + "operator": "=", + "terminal": { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@40" + }, + "arguments": [] + } + }, + { + "$type": "Group", + "elements": [ + { + "$type": "Keyword", + "value": "extends" + }, + { + "$type": "Assignment", + "feature": "superTypes", + "operator": "+=", + "terminal": { + "$type": "CrossReference", + "type": { + "$ref": "#/rules@30" + }, + "deprecatedSyntax": false + } + }, + { + "$type": "Group", + "elements": [ + { + "$type": "Keyword", + "value": "," + }, + { + "$type": "Assignment", + "feature": "superTypes", + "operator": "+=", + "terminal": { + "$type": "CrossReference", + "type": { + "$ref": "#/rules@30" + }, + "deprecatedSyntax": false + } + } + ], + "cardinality": "*" + } + ], + "cardinality": "?" + } + ] }, { "$type": "Group", "elements": [ { - "$type": "Keyword", - "value": "," + "$type": "Assignment", + "feature": "isView", + "operator": "?=", + "terminal": { + "$type": "Keyword", + "value": "view" + } }, { "$type": "Assignment", - "feature": "superTypes", - "operator": "+=", + "feature": "name", + "operator": "=", "terminal": { - "$type": "CrossReference", - "type": { - "$ref": "#/rules@30" + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@40" }, - "deprecatedSyntax": false + "arguments": [] } } - ], - "cardinality": "*" + ] } - ], - "cardinality": "?" + ] }, { "$type": "Keyword", diff --git a/packages/language/src/zmodel.langium b/packages/language/src/zmodel.langium index 89cdfa8ee..6a4b993ab 100644 --- a/packages/language/src/zmodel.langium +++ b/packages/language/src/zmodel.langium @@ -156,8 +156,12 @@ Argument: // model DataModel: (comments+=TRIPLE_SLASH_COMMENT)* - (isAbstract?='abstract')? 'model' name=RegularID - ('extends' superTypes+=[DataModel] (',' superTypes+=[DataModel])*)? '{' ( + ( + ((isAbstract?='abstract')? 'model' name=RegularID + ('extends' superTypes+=[DataModel] (',' superTypes+=[DataModel])*)?) | + ((isView?='view') name=RegularID) + ) + '{' ( fields+=DataModelField | attributes+=DataModelAttribute )+ diff --git a/packages/language/syntaxes/zmodel.tmLanguage.json b/packages/language/syntaxes/zmodel.tmLanguage.json index 2db106523..d886d9b61 100644 --- a/packages/language/syntaxes/zmodel.tmLanguage.json +++ b/packages/language/syntaxes/zmodel.tmLanguage.json @@ -10,7 +10,7 @@ }, { "name": "keyword.control.zmodel", - "match": "\\b(Any|Asc|BigInt|Boolean|Bytes|ContextType|DateTime|Decimal|Desc|FieldReference|Float|Int|Json|Null|Object|String|TransitiveFieldReference|Unsupported|abstract|attribute|datasource|enum|extends|false|function|generator|import|in|model|plugin|sort|true)\\b" + "match": "\\b(Any|Asc|BigInt|Boolean|Bytes|ContextType|DateTime|Decimal|Desc|FieldReference|Float|Int|Json|Null|Object|String|TransitiveFieldReference|Unsupported|abstract|attribute|datasource|enum|extends|false|function|generator|import|in|model|plugin|sort|true|view)\\b" }, { "name": "string.quoted.double.zmodel", diff --git a/packages/schema/src/language-server/validator/datamodel-validator.ts b/packages/schema/src/language-server/validator/datamodel-validator.ts index 65804a31c..a4329e44d 100644 --- a/packages/schema/src/language-server/validator/datamodel-validator.ts +++ b/packages/schema/src/language-server/validator/datamodel-validator.ts @@ -6,7 +6,7 @@ import { isLiteralExpr, ReferenceExpr, } from '@zenstackhq/language/ast'; -import { analyzePolicies, getIdFields, getLiteral } from '@zenstackhq/sdk'; +import { analyzePolicies, getModelIdFields, getModelUniqueFields, getLiteral } from '@zenstackhq/sdk'; import { AstNode, DiagnosticInfo, getDocument, ValidationAcceptor } from 'langium'; import { IssueCodes, SCALAR_TYPES } from '../constants'; import { AstValidator } from '../types'; @@ -26,16 +26,29 @@ export default class DataModelValidator implements AstValidator { private validateFields(dm: DataModel, accept: ValidationAcceptor) { const idFields = dm.$resolvedFields.filter((f) => f.attributes.find((attr) => attr.decl.ref?.name === '@id')); - const modelLevelIds = getIdFields(dm); - - if (idFields.length === 0 && modelLevelIds.length === 0) { + const uniqueFields = dm.$resolvedFields.filter((f) => + f.attributes.find((attr) => attr.decl.ref?.name === '@unique') + ); + const modelLevelIds = getModelIdFields(dm); + const modelUniqueFields = getModelUniqueFields(dm); + + if ( + idFields.length === 0 && + modelLevelIds.length === 0 && + uniqueFields.length === 0 && + modelUniqueFields.length === 0 + ) { const { allows, denies, hasFieldValidation } = analyzePolicies(dm); if (allows.length > 0 || denies.length > 0 || hasFieldValidation) { // TODO: relax this requirement to require only @unique fields // when access policies or field valdaition is used, require an @id field - accept('error', 'Model must include a field with @id attribute or a model-level @@id attribute', { - node: dm, - }); + accept( + 'error', + 'Model must include a field with @id or @unique attribute, or a model-level @@id or @@unique attribute to use access policies', + { + node: dm, + } + ); } } else if (idFields.length > 0 && modelLevelIds.length > 0) { accept('error', 'Model cannot have both field-level @id and model-level @@id attributes', { diff --git a/packages/schema/src/plugins/prisma/prisma-builder.ts b/packages/schema/src/plugins/prisma/prisma-builder.ts index 4b8665d51..92ecb377a 100644 --- a/packages/schema/src/plugins/prisma/prisma-builder.ts +++ b/packages/schema/src/plugins/prisma/prisma-builder.ts @@ -35,7 +35,13 @@ export class PrismaModel { } addModel(name: string): Model { - const model = new Model(name); + const model = new Model(name, false); + this.models.push(model); + return model; + } + + addView(name: string): Model { + const model = new Model(name, true); this.models.push(model); return model; } @@ -127,7 +133,7 @@ export class FieldDeclaration extends DeclarationBase { export class Model extends ContainerDeclaration { public fields: ModelField[] = []; - constructor(public name: string, documentations: string[] = []) { + constructor(public name: string, public isView: boolean, documentations: string[] = []) { super(documentations); } @@ -164,7 +170,7 @@ export class Model extends ContainerDeclaration { result.push(...this.attributes); return ( super.toString() + - `model ${this.name} {\n` + + `${this.isView ? 'view' : 'model'} ${this.name} {\n` + indentString(result.map((d) => d.toString()).join('\n')) + `\n}` ); diff --git a/packages/schema/src/plugins/prisma/schema-generator.ts b/packages/schema/src/plugins/prisma/schema-generator.ts index 81c01f874..44e3b6b75 100644 --- a/packages/schema/src/plugins/prisma/schema-generator.ts +++ b/packages/schema/src/plugins/prisma/schema-generator.ts @@ -277,7 +277,7 @@ export default class PrismaSchemaGenerator { } private generateModel(prisma: PrismaModel, decl: DataModel, config?: Record) { - const model = prisma.addModel(decl.name); + const model = decl.isView ? prisma.addView(decl.name) : prisma.addModel(decl.name); for (const field of decl.fields) { this.generateModelField(model, field); } @@ -336,6 +336,10 @@ export default class PrismaSchemaGenerator { } private shouldGenerateAuxFields(decl: DataModel) { + if (decl.isView) { + return false; + } + const { allowAll, denyAll, hasFieldValidation } = analyzePolicies(decl); if (!allowAll && !denyAll) { diff --git a/packages/schema/tests/generator/prisma-generator.test.ts b/packages/schema/tests/generator/prisma-generator.test.ts index 9ed524fa7..95841f523 100644 --- a/packages/schema/tests/generator/prisma-generator.test.ts +++ b/packages/schema/tests/generator/prisma-generator.test.ts @@ -523,4 +523,38 @@ describe('Prisma generator test', () => { expect(post?.fields.map((f) => f.name)).toContain('zenstack_guard'); expect(post?.fields.map((f) => f.name)).toContain('zenstack_transaction'); }); + + it('view support', async () => { + const model = await loadModel(` + datasource db { + provider = 'postgresql' + url = env('URL') + } + + generator client { + provider = "prisma-client-js" + previewFeatures = ["views"] + } + + view UserInfo { + id Int @unique + email String + name String + bio String + } + `); + + const { name } = tmp.fileSync({ postfix: '.prisma' }); + await new PrismaSchemaGenerator().generate(model, { + name: 'Prisma', + provider: '@core/prisma', + schemaPath: 'schema.zmodel', + output: name, + format: false, + generateClient: false, + }); + + const content = fs.readFileSync(name, 'utf-8'); + await getDMMF({ datamodel: content }); + }); }); diff --git a/packages/schema/tests/schema/parser.test.ts b/packages/schema/tests/schema/parser.test.ts index c9230ae1f..49dd5b2ff 100644 --- a/packages/schema/tests/schema/parser.test.ts +++ b/packages/schema/tests/schema/parser.test.ts @@ -1,4 +1,6 @@ /* eslint-disable @typescript-eslint/ban-types */ +/// + import { ArrayExpr, AttributeArg, @@ -509,4 +511,17 @@ describe('Parsing Tests', () => { `; await loadModel(content, false); }); + + it('view support', async () => { + const content = ` + view UserInfo { + id Int @unique + email String + name String + bio String + } + `; + const doc = await loadModel(content, false); + expect((doc.declarations[0] as DataModel).isView).toBeTruthy(); + }); }); diff --git a/packages/schema/tests/schema/trigger-dev.test.ts b/packages/schema/tests/schema/trigger-dev.test.ts new file mode 100644 index 000000000..c712ad25d --- /dev/null +++ b/packages/schema/tests/schema/trigger-dev.test.ts @@ -0,0 +1,12 @@ +import * as fs from 'fs'; +import path from 'path'; +import { loadModel } from '../utils'; + +describe('Trigger.dev Schema Tests', () => { + it('model loading', async () => { + const content = fs.readFileSync(path.join(__dirname, './trigger-dev.zmodel'), { + encoding: 'utf-8', + }); + await loadModel(content); + }); +}); diff --git a/packages/schema/tests/schema/trigger-dev.zmodel b/packages/schema/tests/schema/trigger-dev.zmodel new file mode 100644 index 000000000..67be17825 --- /dev/null +++ b/packages/schema/tests/schema/trigger-dev.zmodel @@ -0,0 +1,1039 @@ +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +generator client { + provider = "prisma-client-js" + binaryTargets = ["native", "debian-openssl-1.1.x"] +} + +model User { + id String @id @default(cuid()) + email String @unique + + authenticationMethod AuthenticationMethod + accessToken String? + authenticationProfile Json? + authenticationExtraParams Json? + authIdentifier String? @unique + + displayName String? + name String? + avatarUrl String? + + admin Boolean @default(false) + isOnCloudWaitlist Boolean @default(false) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + featureCloud Boolean @default(false) + isOnHostedRepoWaitlist Boolean @default(false) + + marketingEmails Boolean @default(true) + confirmedBasicDetails Boolean @default(false) + + orgMemberships OrgMember[] + sentInvites OrgMemberInvite[] + apiVotes ApiIntegrationVote[] + + invitationCode InvitationCode? @relation(fields: [invitationCodeId], references: [id]) + invitationCodeId String? +} + +model InvitationCode { + id String @id @default(cuid()) + code String @unique + + users User[] + + createdAt DateTime @default(now()) +} + +enum AuthenticationMethod { + GITHUB + MAGIC_LINK +} + +model Organization { + id String @id @default(cuid()) + slug String @unique + title String + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + environments RuntimeEnvironment[] + connections IntegrationConnection[] + endpoints Endpoint[] + jobs Job[] + jobVersions JobVersion[] + events EventRecord[] + jobRuns JobRun[] + + projects Project[] + members OrgMember[] + invites OrgMemberInvite[] + externalAccounts ExternalAccount[] + integrations Integration[] + sources TriggerSource[] +} + +model ExternalAccount { + id String @id @default(cuid()) + identifier String + metadata Json? + + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade, onUpdate: Cascade) + organizationId String + + environment RuntimeEnvironment @relation(fields: [environmentId], references: [id], onDelete: Cascade, onUpdate: Cascade) + environmentId String + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + connections IntegrationConnection[] + events EventRecord[] + runs JobRun[] + schedules ScheduleSource[] + triggerSources TriggerSource[] + missingConnections MissingConnection[] + + @@unique([environmentId, identifier]) +} + +// This is a "global" table that store all the integration methods for all the integrations across all orgs +model IntegrationAuthMethod { + id String @id @default(cuid()) + key String + + name String + description String + type String + + client Json? + config Json? + scopes Json? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + integrations Integration[] + + definition IntegrationDefinition @relation(fields: [definitionId], references: [id], onDelete: Cascade, onUpdate: Cascade) + definitionId String + + help Json? + + @@unique([definitionId, key]) +} + +model IntegrationDefinition { + id String @id + name String + instructions String? + description String? + packageName String @default("") + + authMethods IntegrationAuthMethod[] + Integration Integration[] +} + +model Integration { + id String @id @default(cuid()) + + slug String + + title String? + description String? + + setupStatus IntegrationSetupStatus @default(COMPLETE) + authSource IntegrationAuthSource @default(HOSTED) + + definition IntegrationDefinition @relation(fields: [definitionId], references: [id], onDelete: Cascade, onUpdate: Cascade) + definitionId String + + authMethod IntegrationAuthMethod? @relation(fields: [authMethodId], references: [id], onDelete: Cascade, onUpdate: Cascade) + authMethodId String? + + connectionType ConnectionType @default(DEVELOPER) + + scopes String[] + + customClientReference SecretReference? @relation(fields: [customClientReferenceId], references: [id], onDelete: Cascade, onUpdate: Cascade) + customClientReferenceId String? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade, onUpdate: Cascade) + organizationId String + + attempts ConnectionAttempt[] + connections IntegrationConnection[] + jobIntegrations JobIntegration[] + sources TriggerSource[] + missingConnections MissingConnection[] + RunConnection RunConnection[] + + @@unique([organizationId, slug]) +} + +enum IntegrationAuthSource { + HOSTED + LOCAL +} + +enum IntegrationSetupStatus { + MISSING_FIELDS + COMPLETE +} + +model IntegrationConnection { + id String @id @default(cuid()) + + connectionType ConnectionType @default(DEVELOPER) + + expiresAt DateTime? + metadata Json + scopes String[] + + dataReference SecretReference @relation(fields: [dataReferenceId], references: [id], onDelete: Cascade, onUpdate: Cascade) + dataReferenceId String + + integration Integration @relation(fields: [integrationId], references: [id], onDelete: Cascade, onUpdate: Cascade) + integrationId String + + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade, onUpdate: Cascade) + organizationId String + + externalAccount ExternalAccount? @relation(fields: [externalAccountId], references: [id], onDelete: Cascade, onUpdate: Cascade) + externalAccountId String? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + runConnections RunConnection[] +} + +enum ConnectionType { + EXTERNAL + DEVELOPER +} + +model ConnectionAttempt { + id String @id @default(cuid()) + + securityCode String? + + redirectTo String @default("/") + + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + + integration Integration @relation(fields: [integrationId], references: [id]) + integrationId String +} + +model OrgMember { + id String @id @default(cuid()) + + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade, onUpdate: Cascade) + organizationId String + + user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) + userId String + + role OrgMemberRole @default(MEMBER) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + environments RuntimeEnvironment[] + + @@unique([organizationId, userId]) +} + +enum OrgMemberRole { + ADMIN + MEMBER +} + +model OrgMemberInvite { + id String @id @default(cuid()) + token String @unique @default(cuid()) + email String + role OrgMemberRole @default(MEMBER) + + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade, onUpdate: Cascade) + organizationId String + + inviter User @relation(fields: [inviterId], references: [id], onDelete: Cascade, onUpdate: Cascade) + inviterId String + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([organizationId, email]) +} + +model RuntimeEnvironment { + id String @id @default(cuid()) + slug String + apiKey String @unique + pkApiKey String? + + type RuntimeEnvironmentType @default(DEVELOPMENT) + + autoEnableInternalSources Boolean @default(true) + + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade, onUpdate: Cascade) + organizationId String + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) + projectId String + + //when the org member is deleted, it will keep the environment but set it to null + orgMember OrgMember? @relation(fields: [orgMemberId], references: [id], onDelete: SetNull, onUpdate: Cascade) + orgMemberId String? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + endpoints Endpoint[] + jobVersions JobVersion[] + events EventRecord[] + jobRuns JobRun[] + requestDeliveries HttpSourceRequestDelivery[] + jobAliases JobAlias[] + JobQueue JobQueue[] + sources TriggerSource[] + eventDispatchers EventDispatcher[] + scheduleSources ScheduleSource[] + ExternalAccount ExternalAccount[] + + @@unique([projectId, slug, orgMemberId]) +} + +enum RuntimeEnvironmentType { + PRODUCTION + STAGING + DEVELOPMENT + PREVIEW +} + +model Project { + id String @id @default(cuid()) + slug String @unique + name String + + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade, onUpdate: Cascade) + organizationId String + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + environments RuntimeEnvironment[] + endpoints Endpoint[] + jobs Job[] + jobVersion JobVersion[] + events EventRecord[] + runs JobRun[] + sources TriggerSource[] +} + +model Endpoint { + id String @id @default(cuid()) + slug String + url String + + environment RuntimeEnvironment @relation(fields: [environmentId], references: [id], onDelete: Cascade, onUpdate: Cascade) + environmentId String + + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade, onUpdate: Cascade) + organizationId String + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) + projectId String + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + indexingHookIdentifier String? + + jobVersions JobVersion[] + jobRuns JobRun[] + httpRequestDeliveries HttpSourceRequestDelivery[] + dynamictriggers DynamicTrigger[] + sources TriggerSource[] + indexings EndpointIndex[] + + @@unique([environmentId, slug]) +} + +model EndpointIndex { + id String @id @default(cuid()) + + endpoint Endpoint @relation(fields: [endpointId], references: [id], onDelete: Cascade, onUpdate: Cascade) + endpointId String + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + source EndpointIndexSource @default(MANUAL) + sourceData Json? + reason String? + + data Json + stats Json +} + +enum EndpointIndexSource { + MANUAL + API + INTERNAL + HOOK +} + +model Job { + id String @id @default(cuid()) + slug String + title String + internal Boolean @default(false) + + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade, onUpdate: Cascade) + organizationId String + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) + projectId String + + versions JobVersion[] + runs JobRun[] + integrations JobIntegration[] + aliases JobAlias[] + dynamicTriggers DynamicTrigger[] + + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + + @@unique([projectId, slug]) +} + +model JobVersion { + id String @id @default(cuid()) + version String + eventSpecification Json + + properties Json? + + job Job @relation(fields: [jobId], references: [id], onDelete: Cascade, onUpdate: Cascade) + jobId String + + endpoint Endpoint @relation(fields: [endpointId], references: [id], onDelete: Cascade, onUpdate: Cascade) + endpointId String + + environment RuntimeEnvironment @relation(fields: [environmentId], references: [id], onDelete: Cascade, onUpdate: Cascade) + environmentId String + + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade, onUpdate: Cascade) + organizationId String + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) + projectId String + + queue JobQueue @relation(fields: [queueId], references: [id]) + queueId String + + startPosition JobStartPosition @default(INITIAL) + preprocessRuns Boolean @default(false) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + runs JobRun[] + integrations JobIntegration[] + aliases JobAlias[] + examples EventExample[] + dynamicTriggers DynamicTrigger[] + triggerSources TriggerSource[] + + @@unique([jobId, version, environmentId]) +} + +model EventExample { + id String @id @default(cuid()) + + slug String + name String + icon String? + + payload Json? + + jobVersion JobVersion @relation(fields: [jobVersionId], references: [id], onDelete: Cascade, onUpdate: Cascade) + jobVersionId String + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([slug, jobVersionId]) +} + +model JobQueue { + id String @id @default(cuid()) + name String + + environment RuntimeEnvironment @relation(fields: [environmentId], references: [id], onDelete: Cascade, onUpdate: Cascade) + environmentId String + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + jobCount Int @default(0) + maxJobs Int @default(100) + + runs JobRun[] + jobVersion JobVersion[] + + @@unique([environmentId, name]) +} + +model JobAlias { + id String @id @default(cuid()) + name String @default("latest") + value String + + version JobVersion @relation(fields: [versionId], references: [id], onDelete: Cascade, onUpdate: Cascade) + versionId String + + job Job @relation(fields: [jobId], references: [id], onDelete: Cascade, onUpdate: Cascade) + jobId String + + environment RuntimeEnvironment @relation(fields: [environmentId], references: [id], onDelete: Cascade, onUpdate: Cascade) + environmentId String + + @@unique([jobId, environmentId, name]) +} + +model JobIntegration { + id String @id @default(cuid()) + key String + + version JobVersion @relation(fields: [versionId], references: [id], onDelete: Cascade, onUpdate: Cascade) + versionId String + + job Job @relation(fields: [jobId], references: [id], onDelete: Cascade, onUpdate: Cascade) + jobId String + + integration Integration @relation(fields: [integrationId], references: [id], onDelete: Cascade, onUpdate: Cascade) + integrationId String + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([versionId, key]) +} + +model RunConnection { + id String @id @default(cuid()) + key String + + authSource IntegrationAuthSource @default(HOSTED) + + run JobRun @relation(fields: [runId], references: [id], onDelete: Cascade, onUpdate: Cascade) + runId String + + connection IntegrationConnection? @relation(fields: [connectionId], references: [id], onDelete: Cascade, onUpdate: Cascade) + connectionId String? + + integration Integration @relation(fields: [integrationId], references: [id], onDelete: Cascade, onUpdate: Cascade) + integrationId String + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + tasks Task[] + + @@unique([runId, key]) +} + +model DynamicTrigger { + id String @id @default(cuid()) + type DynamicTriggerType @default(EVENT) + slug String + + endpoint Endpoint @relation(fields: [endpointId], references: [id], onDelete: Cascade, onUpdate: Cascade) + endpointId String + + jobs Job[] + sources TriggerSource[] + registrations DynamicTriggerRegistration[] + + sourceRegistrationJob JobVersion? @relation(fields: [sourceRegistrationJobId], references: [id], onDelete: Cascade, onUpdate: Cascade) + sourceRegistrationJobId String? + + @@unique([endpointId, slug, type]) +} + +enum DynamicTriggerType { + EVENT + SCHEDULE +} + +model EventDispatcher { + id String @id @default(cuid()) + event String + source String + payloadFilter Json? + contextFilter Json? + manual Boolean @default(false) + + dispatchableId String + dispatchable Json + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + enabled Boolean @default(true) + + environment RuntimeEnvironment @relation(fields: [environmentId], references: [id], onDelete: Cascade, onUpdate: Cascade) + environmentId String + + registrations DynamicTriggerRegistration[] + scheduleSources ScheduleSource[] + + @@unique([dispatchableId, environmentId]) +} + +enum JobStartPosition { + INITIAL + LATEST +} + +model EventRecord { + id String @id @default(cuid()) + eventId String + name String + timestamp DateTime @default(now()) + payload Json + context Json? + sourceContext Json? + + source String @default("trigger.dev") + + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade, onUpdate: Cascade) + organizationId String + + environment RuntimeEnvironment @relation(fields: [environmentId], references: [id], onDelete: Cascade, onUpdate: Cascade) + environmentId String + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) + projectId String + + externalAccount ExternalAccount? @relation(fields: [externalAccountId], references: [id], onDelete: Cascade, onUpdate: Cascade) + externalAccountId String? + + deliverAt DateTime @default(now()) + deliveredAt DateTime? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + isTest Boolean @default(false) + runs JobRun[] + + @@unique([eventId, environmentId]) +} + +model JobRun { + id String @id @default(cuid()) + number Int + + job Job @relation(fields: [jobId], references: [id], onDelete: Cascade, onUpdate: Cascade) + jobId String + + version JobVersion @relation(fields: [versionId], references: [id], onDelete: Cascade, onUpdate: Cascade) + versionId String + + event EventRecord @relation(fields: [eventId], references: [id], onDelete: Cascade, onUpdate: Cascade) + eventId String + + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade, onUpdate: Cascade) + organizationId String + + endpoint Endpoint @relation(fields: [endpointId], references: [id], onDelete: Cascade, onUpdate: Cascade) + endpointId String + + environment RuntimeEnvironment @relation(fields: [environmentId], references: [id], onDelete: Cascade, onUpdate: Cascade) + environmentId String + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) + projectId String + + queue JobQueue @relation(fields: [queueId], references: [id]) + queueId String + + externalAccount ExternalAccount? @relation(fields: [externalAccountId], references: [id], onDelete: Cascade, onUpdate: Cascade) + externalAccountId String? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + queuedAt DateTime? + startedAt DateTime? + completedAt DateTime? + + properties Json? + + status JobRunStatus @default(PENDING) + output Json? + + timedOutAt DateTime? + timedOutReason String? + + isTest Boolean @default(false) + preprocess Boolean @default(false) + + tasks Task[] + runConnections RunConnection[] + missingConnections MissingConnection[] + executions JobRunExecution[] +} + +enum JobRunStatus { + PENDING + QUEUED + WAITING_ON_CONNECTIONS + PREPROCESSING + STARTED + SUCCESS + FAILURE + TIMED_OUT + ABORTED +} + +model JobRunExecution { + id String @id @default(cuid()) + + run JobRun @relation(fields: [runId], references: [id], onDelete: Cascade, onUpdate: Cascade) + runId String + + retryCount Int @default(0) + retryLimit Int @default(0) + retryDelayInMs Int @default(0) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + startedAt DateTime? + completedAt DateTime? + + error String? + + reason JobRunExecutionReason @default(EXECUTE_JOB) + status JobRunExecutionStatus @default(PENDING) + + resumeTask Task? @relation(fields: [resumeTaskId], references: [id], onDelete: Cascade, onUpdate: Cascade) + resumeTaskId String? + + graphileJobId String? +} + +enum JobRunExecutionReason { + PREPROCESS + EXECUTE_JOB +} + +enum JobRunExecutionStatus { + PENDING + STARTED + SUCCESS + FAILURE +} + +model Task { + id String @id + idempotencyKey String + displayKey String? + name String + icon String? + + status TaskStatus @default(PENDING) + delayUntil DateTime? + noop Boolean @default(false) + + description String? + properties Json? + outputProperties Json? + params Json? + output Json? + error String? + redact Json? + style Json? + operation String? + + startedAt DateTime? + completedAt DateTime? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + run JobRun @relation(fields: [runId], references: [id], onDelete: Cascade, onUpdate: Cascade) + runId String + + parent Task? @relation("TaskParent", fields: [parentId], references: [id], onDelete: Cascade, onUpdate: Cascade) + parentId String? + + runConnection RunConnection? @relation(fields: [runConnectionId], references: [id], onDelete: Cascade, onUpdate: Cascade) + runConnectionId String? + + children Task[] @relation("TaskParent") + executions JobRunExecution[] + attempts TaskAttempt[] + + @@unique([runId, idempotencyKey]) +} + +enum TaskStatus { + PENDING + WAITING + RUNNING + COMPLETED + ERRORED +} + +model TaskAttempt { + id String @id @default(cuid()) + + number Int + + task Task @relation(fields: [taskId], references: [id], onDelete: Cascade, onUpdate: Cascade) + taskId String + + status TaskAttemptStatus @default(PENDING) + + error String? + + runAt DateTime? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([taskId, number]) +} + +enum TaskAttemptStatus { + PENDING + STARTED + COMPLETED + ERRORED +} + +model SecretReference { + id String @id @default(cuid()) + key String @unique + provider SecretStoreProvider @default(DATABASE) + + connections IntegrationConnection[] + integrations Integration[] + triggerSources TriggerSource[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +enum SecretStoreProvider { + DATABASE + AWS_PARAM_STORE +} + +/// Used when the provider = "database". Not recommended outside of local development. +model SecretStore { + key String @unique + value Json + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model TriggerSource { + id String @id @default(cuid()) + + key String + params Json? + + channel TriggerChannel @default(HTTP) + channelData Json? + + events TriggerSourceEvent[] + + secretReference SecretReference @relation(fields: [secretReferenceId], references: [id], onDelete: Cascade, onUpdate: Cascade) + secretReferenceId String + + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade, onUpdate: Cascade) + organizationId String + + environment RuntimeEnvironment @relation(fields: [environmentId], references: [id], onDelete: Cascade, onUpdate: Cascade) + environmentId String + + endpoint Endpoint @relation(fields: [endpointId], references: [id], onDelete: Cascade, onUpdate: Cascade) + endpointId String + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) + projectId String + + integration Integration @relation(fields: [integrationId], references: [id], onDelete: Cascade, onUpdate: Cascade) + integrationId String + + dynamicTrigger DynamicTrigger? @relation(fields: [dynamicTriggerId], references: [id], onDelete: Cascade, onUpdate: Cascade) + dynamicTriggerId String? + + externalAccount ExternalAccount? @relation(fields: [externalAccountId], references: [id], onDelete: Cascade, onUpdate: Cascade) + externalAccountId String? + + sourceRegistrationJob JobVersion? @relation(fields: [sourceRegistrationJobId], references: [id], onDelete: Cascade, onUpdate: Cascade) + sourceRegistrationJobId String? + + dynamicSourceId String? + dynamicSourceMetadata Json? + + active Boolean @default(false) + interactive Boolean @default(false) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + httpDeliveries HttpSourceRequestDelivery[] + registrations DynamicTriggerRegistration[] + + @@unique([key, environmentId]) +} + +enum TriggerChannel { + HTTP + SQS + SMTP +} + +model TriggerSourceEvent { + id String @id @default(cuid()) + name String + + source TriggerSource @relation(fields: [sourceId], references: [id], onDelete: Cascade, onUpdate: Cascade) + sourceId String + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + registered Boolean @default(false) + + @@unique([name, sourceId]) +} + +model DynamicTriggerRegistration { + id String @id @default(cuid()) + + key String + + dynamicTrigger DynamicTrigger @relation(fields: [dynamicTriggerId], references: [id], onDelete: Cascade, onUpdate: Cascade) + dynamicTriggerId String + + eventDispatcher EventDispatcher @relation(fields: [eventDispatcherId], references: [id], onDelete: Cascade, onUpdate: Cascade) + eventDispatcherId String + + source TriggerSource @relation(fields: [sourceId], references: [id], onDelete: Cascade, onUpdate: Cascade) + sourceId String + + metadata Json? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([key, dynamicTriggerId]) +} + +model HttpSourceRequestDelivery { + id String @id @default(cuid()) + url String + method String + headers Json + body Bytes? + + source TriggerSource @relation(fields: [sourceId], references: [id], onDelete: Cascade, onUpdate: Cascade) + sourceId String + + endpoint Endpoint @relation(fields: [endpointId], references: [id], onDelete: Cascade, onUpdate: Cascade) + endpointId String + + environment RuntimeEnvironment @relation(fields: [environmentId], references: [id], onDelete: Cascade, onUpdate: Cascade) + environmentId String + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deliveredAt DateTime? +} + +model ScheduleSource { + id String @id @default(cuid()) + + key String + schedule Json + + environment RuntimeEnvironment @relation(fields: [environmentId], references: [id], onDelete: Cascade, onUpdate: Cascade) + environmentId String + + dispatcher EventDispatcher @relation(fields: [dispatcherId], references: [id], onDelete: Cascade, onUpdate: Cascade) + dispatcherId String + + lastEventTimestamp DateTime? + + workerJobId String? + + active Boolean @default(false) + + metadata Json? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + externalAccount ExternalAccount? @relation(fields: [externalAccountId], references: [id], onDelete: Cascade, onUpdate: Cascade) + externalAccountId String? + + @@unique([key, environmentId]) +} + +model MissingConnection { + id String @id @default(cuid()) + + resolved Boolean @default(false) + + runs JobRun[] + + integration Integration @relation(fields: [integrationId], references: [id], onDelete: Cascade, onUpdate: Cascade) + integrationId String + + connectionType ConnectionType @default(DEVELOPER) + + externalAccount ExternalAccount? @relation(fields: [externalAccountId], references: [id], onDelete: Cascade, onUpdate: Cascade) + externalAccountId String? + + accountIdentifier String? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([integrationId, connectionType, externalAccountId]) + @@unique([integrationId, connectionType, accountIdentifier]) +} + +model ApiIntegrationVote { + id String @id @default(cuid()) + + apiIdentifier String + + user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) + userId String + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([apiIdentifier, userId]) +} \ No newline at end of file diff --git a/packages/schema/tests/schema/validation/datamodel-validation.test.ts b/packages/schema/tests/schema/validation/datamodel-validation.test.ts index daa7c381e..a2d68f2c0 100644 --- a/packages/schema/tests/schema/validation/datamodel-validation.test.ts +++ b/packages/schema/tests/schema/validation/datamodel-validation.test.ts @@ -128,6 +128,9 @@ describe('Data Model Validation Tests', () => { } `); + const err = + 'Model must include a field with @id or @unique attribute, or a model-level @@id or @@unique attribute to use access policies'; + expect( await loadModelWithError(` ${prelude} @@ -136,7 +139,27 @@ describe('Data Model Validation Tests', () => { @@allow('all', x > 0) } `) - ).toContain(`Model must include a field with @id attribute or a model-level @@id attribute`); + ).toContain(err); + + // @unique used as id + await loadModel(` + ${prelude} + model M { + id Int @unique + x Int + @@allow('all', x > 0) + } + `); + + // @@unique used as id + await loadModel(` + ${prelude} + model M { + x Int + @@unique([x]) + @@allow('all', x > 0) + } + `); expect( await loadModelWithError(` @@ -146,7 +169,7 @@ describe('Data Model Validation Tests', () => { @@deny('all', x <= 0) } `) - ).toContain(`Model must include a field with @id attribute or a model-level @@id attribute`); + ).toContain(err); expect( await loadModelWithError(` @@ -155,7 +178,7 @@ describe('Data Model Validation Tests', () => { x Int @gt(0) } `) - ).toContain(`Model must include a field with @id attribute or a model-level @@id attribute`); + ).toContain(err); expect( await loadModelWithError(` diff --git a/packages/sdk/src/utils.ts b/packages/sdk/src/utils.ts index edd0fa84e..fb2e7e6cf 100644 --- a/packages/sdk/src/utils.ts +++ b/packages/sdk/src/utils.ts @@ -138,9 +138,9 @@ export function isEnumFieldReference(node: AstNode): node is ReferenceExpr { } /** - * Gets id fields declared at the data model level + * Gets `@@id` fields declared at the data model level */ -export function getIdFields(model: DataModel) { +export function getModelIdFields(model: DataModel) { const idAttr = model.attributes.find((attr) => attr.decl.ref?.name === '@@id'); if (!idAttr) { return []; @@ -155,21 +155,58 @@ export function getIdFields(model: DataModel) { .map((item) => resolved(item.target) as DataModelField); } +/** + * Gets `@@unique` fields declared at the data model level + */ +export function getModelUniqueFields(model: DataModel) { + const uniqueAttr = model.attributes.find((attr) => attr.decl.ref?.name === '@@unique'); + if (!uniqueAttr) { + return []; + } + const fieldsArg = uniqueAttr.args.find((a) => a.$resolvedParam?.name === 'fields'); + if (!fieldsArg || !isArrayExpr(fieldsArg.value)) { + return []; + } + + return fieldsArg.value.items + .filter((item): item is ReferenceExpr => isReferenceExpr(item)) + .map((item) => resolved(item.target) as DataModelField); +} + /** * Returns if the given field is declared as an id field. */ export function isIdField(field: DataModelField) { // field-level @id attribute - if (field.attributes.some((attr) => attr.decl.ref?.name === '@id')) { + if (hasAttribute(field, '@id')) { return true; } - // model-level @@id attribute with a list of fields const model = field.$container as DataModel; - const modelLevelIds = getIdFields(model); + + // model-level @@id attribute with a list of fields + const modelLevelIds = getModelIdFields(model); if (modelLevelIds.includes(field)) { return true; } + + if (model.fields.some((f) => hasAttribute(f, '@id')) || modelLevelIds.length > 0) { + // the model already has id field, don't check @unique and @@unique + return false; + } + + // then, the first field with @unique can be used as id + const firstUniqueField = model.fields.find((f) => hasAttribute(f, '@unique')); + if (firstUniqueField) { + return firstUniqueField === field; + } + + // last, the first model level @@unique can be used as id + const modelLevelUnique = getModelUniqueFields(model); + if (modelLevelUnique.includes(field)) { + return true; + } + return false; } diff --git a/tests/integration/tests/with-policy/unique-as-id.test.ts b/tests/integration/tests/with-policy/unique-as-id.test.ts new file mode 100644 index 000000000..42dd7ce05 --- /dev/null +++ b/tests/integration/tests/with-policy/unique-as-id.test.ts @@ -0,0 +1,177 @@ +import { loadSchema } from '@zenstackhq/testtools'; +import path from 'path'; + +describe('With Policy: unique as id', () => { + let origDir: string; + + beforeAll(async () => { + origDir = path.resolve('.'); + }); + + afterEach(() => { + process.chdir(origDir); + }); + + it('unique fields', async () => { + const { prisma, withPolicy } = await loadSchema( + ` + 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) + } + ` + ); + + const db = withPolicy(); + + 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(); + + await expect( + db.a.create({ + include: { b: true }, + data: { x: '2', y: 3, value: 1, b: { create: { b1: '1', b2: '2', value: 2 } } }, + }) + ).toBeRejectedByPolicy(); + const r = await prisma.b.findUnique({ where: { b1: '1' } }); + expect(r.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 { prisma, withPolicy } = await loadSchema( + ` + 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) + } + ` + ); + + const db = withPolicy(); + + 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(); + + await expect( + db.a.create({ + include: { b: true }, + data: { x: '2', y: 3, value: 1, b: { create: { b1: '1', b2: '2', value: 2 } } }, + }) + ).toBeRejectedByPolicy(); + const r = await prisma.b.findUnique({ where: { b1: '1' } }); + expect(r.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 { prisma, withPolicy } = await loadSchema( + ` + 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]) + } + ` + ); + + const db = withPolicy(); + + 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(); + + await expect( + db.a.create({ + include: { b: true }, + data: { x: '2', y: 1, value: 1, b: { create: { b1: '1', b2: '2', value: 2 } } }, + }) + ).toBeRejectedByPolicy(); + const r = await prisma.b.findUnique({ where: { b1_b2: { b1: '1', b2: '2' } } }); + expect(r.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(); + }); +}); diff --git a/tests/integration/tests/with-policy/view.test.ts b/tests/integration/tests/with-policy/view.test.ts new file mode 100644 index 000000000..d0dd92c07 --- /dev/null +++ b/tests/integration/tests/with-policy/view.test.ts @@ -0,0 +1,105 @@ +import { loadSchema } from '@zenstackhq/testtools'; +import path from 'path'; + +describe('View Policy Test', () => { + let origDir: string; + + beforeAll(async () => { + origDir = path.resolve('.'); + }); + + afterEach(() => { + process.chdir(origDir); + }); + + it('view policy', async () => { + const { prisma, withPolicy } = await loadSchema( + ` + datasource db { + provider = "sqlite" + url = "file:./dev.db" + } + + generator client { + provider = "prisma-client-js" + previewFeatures = ["views"] + } + + 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) + } + `, + false + ); + + await prisma.$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 prisma.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 prisma.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, + }, + ], + }, + }, + }); + + const db = withPolicy(); + + await expect(prisma.userInfo.findMany()).resolves.toHaveLength(2); + await expect(db.userInfo.findMany()).resolves.toHaveLength(1); + + const r1 = await prisma.userInfo.findFirst({ include: { user: true } }); + expect(r1.user).toBeTruthy(); + + // user not readable + await expect(db.userInfo.findFirst({ include: { user: true } })).toBeRejectedByPolicy(); + }); +});