diff --git a/package.json b/package.json index c242b4b13..788c28e6e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zenstack-monorepo", - "version": "1.0.0-alpha.55", + "version": "1.0.0-alpha.60", "description": "", "scripts": { "build": "pnpm -r build", diff --git a/packages/language/package.json b/packages/language/package.json index b9b76bbd8..4f322d2af 100644 --- a/packages/language/package.json +++ b/packages/language/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/language", - "version": "1.0.0-alpha.55", + "version": "1.0.0-alpha.60", "displayName": "ZenStack modeling language compiler", "description": "ZenStack modeling language compiler", "homepage": "https://zenstack.dev", diff --git a/packages/language/src/generated/grammar.ts b/packages/language/src/generated/grammar.ts index 52caebc1b..93a60d292 100644 --- a/packages/language/src/generated/grammar.ts +++ b/packages/language/src/generated/grammar.ts @@ -2035,11 +2035,23 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "value": "." }, { - "$type": "RuleCall", - "rule": { - "$ref": "#/rules@53" - }, - "arguments": [] + "$type": "Alternatives", + "elements": [ + { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@53" + }, + "arguments": [] + }, + { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@48" + }, + "arguments": [] + } + ] } ], "cardinality": "*" diff --git a/packages/language/src/zmodel.langium b/packages/language/src/zmodel.langium index 267483ec0..e807c942d 100644 --- a/packages/language/src/zmodel.langium +++ b/packages/language/src/zmodel.langium @@ -171,7 +171,8 @@ FunctionParamType: (type=ExpressionType | reference=[TypeDeclaration]) (array?='[' ']')?; QualifiedName returns string: - ID ('.' ID)*; + // TODO: is this the right way to deal with token precedence? + ID ('.' (ID|BuiltinType))*; // attribute-level attribute AttributeAttributeName returns string: @@ -221,7 +222,7 @@ ExpressionType returns string: 'String' | 'Int' | 'Float' | 'Boolean' | 'DateTime' | 'Null' | 'Any'; BuiltinType returns string: - 'String'|'Boolean'|'Int'|'BigInt'|'Float'|'Decimal'|'DateTime'|'Json'|'Bytes'; + 'String' | 'Boolean' | 'Int' | 'BigInt' | 'Float' | 'Decimal' | 'DateTime' | 'Json' | 'Bytes'; hidden terminal WS: /\s+/; terminal BOOLEAN returns boolean: /true|false/; diff --git a/packages/next/package.json b/packages/next/package.json index 181c36b6a..0a525b995 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/next", - "version": "1.0.0-alpha.55", + "version": "1.0.0-alpha.60", "displayName": "ZenStack Next.js integration", "description": "ZenStack Next.js integration", "homepage": "https://zenstack.dev", @@ -9,6 +9,7 @@ "build": "pnpm lint && pnpm clean && tsc && copyfiles ./package.json ./README.md ./LICENSE dist", "watch": "tsc --watch", "lint": "eslint src --ext ts", + "test": "jest", "prepublishOnly": "pnpm build", "publish-dev": "pnpm publish --tag dev" }, diff --git a/packages/plugins/react/package.json b/packages/plugins/react/package.json index c8cdeb7b1..f3a068f97 100644 --- a/packages/plugins/react/package.json +++ b/packages/plugins/react/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/react", "displayName": "ZenStack plugin and runtime for ReactJS", - "version": "1.0.0-alpha.55", + "version": "1.0.0-alpha.60", "description": "ZenStack plugin and runtime for ReactJS", "main": "index.js", "repository": { diff --git a/packages/plugins/trpc/package.json b/packages/plugins/trpc/package.json index c92c1aeec..935438cd4 100644 --- a/packages/plugins/trpc/package.json +++ b/packages/plugins/trpc/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/trpc", "displayName": "ZenStack plugin for tRPC", - "version": "1.0.0-alpha.55", + "version": "1.0.0-alpha.60", "description": "ZenStack plugin for tRPC", "main": "index.js", "repository": { diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 87273829e..2557c8b8b 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/runtime", "displayName": "ZenStack Runtime Library", - "version": "1.0.0-alpha.55", + "version": "1.0.0-alpha.60", "description": "Runtime of ZenStack for both client-side and server-side environments.", "repository": { "type": "git", diff --git a/packages/runtime/src/enhancements/policy/handler.ts b/packages/runtime/src/enhancements/policy/handler.ts index 03a4e37f1..272cbbb70 100644 --- a/packages/runtime/src/enhancements/policy/handler.ts +++ b/packages/runtime/src/enhancements/policy/handler.ts @@ -86,11 +86,12 @@ export class PolicyProxyHandler implements Pr dbOps.create(writeArgs) ); - if (!this.utils.getEntityId(this.model, result)) { + const ids = this.utils.getEntityIds(this.model, result); + if (Object.keys(ids).length === 0) { throw this.utils.unknownError(`unexpected error: create didn't return an id`); } - return this.checkReadback(origArgs, this.utils.getEntityId(this.model, result), 'create', 'create'); + return this.checkReadback(origArgs, ids, 'create', 'create'); } async createMany(args: any, skipDuplicates?: boolean) { @@ -136,10 +137,11 @@ export class PolicyProxyHandler implements Pr dbOps.update(writeArgs) ); - if (!this.utils.getEntityId(this.model, result)) { + const ids = this.utils.getEntityIds(this.model, result); + if (Object.keys(ids).length === 0) { throw this.utils.unknownError(`unexpected error: update didn't return an id`); } - return this.checkReadback(origArgs, this.utils.getEntityId(this.model, result), 'update', 'update'); + return this.checkReadback(origArgs, ids, 'update', 'update'); } async updateMany(args: any) { @@ -189,11 +191,12 @@ export class PolicyProxyHandler implements Pr dbOps.upsert(writeArgs) ); - if (!this.utils.getEntityId(this.model, result)) { + const ids = this.utils.getEntityIds(this.model, result); + if (Object.keys(ids).length === 0) { throw this.utils.unknownError(`unexpected error: upsert didn't return an id`); } - return this.checkReadback(origArgs, this.utils.getEntityId(this.model, result), 'upsert', 'update'); + return this.checkReadback(origArgs, ids, 'upsert', 'update'); } async delete(args: any) { @@ -283,9 +286,13 @@ export class PolicyProxyHandler implements Pr } } - private async checkReadback(origArgs: any, id: any, action: string, operation: PolicyOperationKind) { - const idField = this.utils.getIdField(this.model); - const readArgs = { select: origArgs.select, include: origArgs.include, where: { [idField.name]: id } }; + private async checkReadback( + origArgs: any, + ids: Record, + action: string, + operation: PolicyOperationKind + ) { + const readArgs = { select: origArgs.select, include: origArgs.include, where: ids }; const result = await this.utils.readWithCheck(this.model, readArgs); if (result.length === 0) { this.logger.warn(`${action} result cannot be read back`); diff --git a/packages/runtime/src/enhancements/policy/policy-utils.ts b/packages/runtime/src/enhancements/policy/policy-utils.ts index 4c0ec1933..452ff71e8 100644 --- a/packages/runtime/src/enhancements/policy/policy-utils.ts +++ b/packages/runtime/src/enhancements/policy/policy-utils.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { PrismaClientKnownRequestError, PrismaClientUnknownRequestError } from '@prisma/client/runtime'; -import { AUXILIARY_FIELDS, CrudFailureReason, TRANSACTION_FIELD_NAME } from '@zenstackhq/sdk'; +import { AUXILIARY_FIELDS, CrudFailureReason, GUARD_FIELD_NAME, TRANSACTION_FIELD_NAME } from '@zenstackhq/sdk'; import { camelCase } from 'change-case'; import cuid from 'cuid'; import deepcopy from 'deepcopy'; @@ -42,8 +42,7 @@ export class PolicyUtil { and(...conditions: (boolean | object)[]): any { if (conditions.includes(false)) { // always false - // TODO: custom id field - return { id: { in: [] } }; + return { [GUARD_FIELD_NAME]: false }; } const filtered = conditions.filter( @@ -64,7 +63,7 @@ export class PolicyUtil { or(...conditions: (boolean | object)[]): any { if (conditions.includes(true)) { // always true - return { id: { notIn: [] } }; + return { [GUARD_FIELD_NAME]: true }; } const filtered = conditions.filter((c): c is object => typeof c === 'object' && !!c); @@ -276,7 +275,7 @@ export class PolicyUtil { return; } - const idField = this.getIdField(model); + const idFields = this.getIdFields(model); for (const field of getModelFields(injectTarget)) { const fieldInfo = resolveField(this.modelMeta, model, field); if (!fieldInfo || !fieldInfo.isDataModel) { @@ -292,10 +291,16 @@ export class PolicyUtil { await this.injectAuthGuard(injectTarget[field], fieldInfo.type, 'read'); } else { - // there's no way of injecting condition for to-one relation, so we - // make sure 'id' field is selected and check them against query result - if (injectTarget[field]?.select && injectTarget[field]?.select?.[idField.name] !== true) { - injectTarget[field].select[idField.name] = true; + // there's no way of injecting condition for to-one relation, so if there's + // "select" clause we make sure 'id' fields are selected and check them against + // query result; nothing needs to be done for "include" clause because all + // fields are already selected + if (injectTarget[field]?.select) { + for (const idField of idFields) { + if (injectTarget[field].select[idField.name] !== true) { + injectTarget[field].select[idField.name] = true; + } + } } } @@ -310,7 +315,8 @@ export class PolicyUtil { * omitted. */ async postProcessForRead(entityData: any, model: string, args: any, operation: PolicyOperationKind) { - if (!this.getEntityId(model, entityData)) { + const ids = this.getEntityIds(model, entityData); + if (Object.keys(ids).length === 0) { return; } @@ -330,21 +336,23 @@ export class PolicyUtil { // post-check them for (const field of getModelFields(injectTarget)) { + if (!entityData?.[field]) { + continue; + } + const fieldInfo = resolveField(this.modelMeta, model, field); if (!fieldInfo || !fieldInfo.isDataModel || fieldInfo.isArray) { continue; } - const idField = this.getIdField(fieldInfo.type); - const relatedEntityId = entityData?.[field]?.[idField.name]; + const ids = this.getEntityIds(fieldInfo.type, entityData[field]); - if (!relatedEntityId) { + if (Object.keys(ids).length === 0) { continue; } - this.logger.info(`Validating read of to-one relation: ${fieldInfo.type}#${relatedEntityId}`); - - await this.checkPolicyForFilter(fieldInfo.type, { [idField.name]: relatedEntityId }, operation, this.db); + this.logger.info(`Validating read of to-one relation: ${fieldInfo.type}#${formatObject(ids)}`); + await this.checkPolicyForFilter(fieldInfo.type, ids, operation, this.db); // recurse await this.postProcessForRead(entityData[field], fieldInfo.type, injectTarget[field], operation); @@ -366,14 +374,18 @@ export class PolicyUtil { // record model entities that are updated, together with their // values before update, so we can post-check if they satisfy - // model => id => entity value - const updatedModels = new Map>(); + // model => { ids, entity value } + const updatedModels = new Map; value: any }>>(); - const idField = this.getIdField(model); - if (args.select && !args.select[idField.name]) { + const idFields = this.getIdFields(model); + if (args.select) { // make sure 'id' field is selected, we need it to // read back the updated entity - args.select[idField.name] = true; + for (const idField of idFields) { + if (!args.select[idField.name]) { + args.select[idField.name] = true; + } + } } // use a transaction to conduct write, so in case any create or nested create @@ -496,7 +508,7 @@ export class PolicyUtil { if (postGuard !== true || schema) { let modelEntities = updatedModels.get(model); if (!modelEntities) { - modelEntities = new Map(); + modelEntities = []; updatedModels.set(model, modelEntities); } @@ -509,11 +521,19 @@ export class PolicyUtil { // e.g.: { a_b: { a: '1', b: '1' } } => { a: '1', b: '1' } await this.flattenGeneratedUniqueField(model, filter); - const idField = this.getIdField(model); - const query = { where: filter, select: { ...preValueSelect, [idField.name]: true } }; + const idFields = this.getIdFields(model); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const select: any = { ...preValueSelect }; + for (const idField of idFields) { + select[idField.name] = true; + } + + const query = { where: filter, select }; this.logger.info(`fetching pre-update entities for ${model}: ${formatObject(query)})}`); const entities = await this.db[model].findMany(query); - entities.forEach((entity) => modelEntities?.set(this.getEntityId(model, entity), entity)); + entities.forEach((entity) => + modelEntities?.push({ ids: this.getEntityIds(model, entity), value: entity }) + ); } }; @@ -622,8 +642,8 @@ export class PolicyUtil { await Promise.all( [...updatedModels.entries()] .map(([model, modelEntities]) => - [...modelEntities.entries()].map(async ([id, preValue]) => - this.checkPostUpdate(model, id, tx, preValue) + modelEntities.map(async ({ ids, value: preValue }) => + this.checkPostUpdate(model, ids, tx, preValue) ) ) .flat() @@ -716,14 +736,18 @@ export class PolicyUtil { } } - private async checkPostUpdate(model: string, id: any, db: Record, preValue: any) { - this.logger.info(`Checking post-update policy for ${model}#${id}, preValue: ${formatObject(preValue)}`); + private async checkPostUpdate( + model: string, + ids: Record, + db: Record, + preValue: any + ) { + this.logger.info(`Checking post-update policy for ${model}#${ids}, preValue: ${formatObject(preValue)}`); const guard = await this.getAuthGuard(model, 'postUpdate', preValue); // build a query condition with policy injected - const idField = this.getIdField(model); - const guardedQuery = { where: this.and({ [idField.name]: id }, guard) }; + const guardedQuery = { where: this.and(ids, guard) }; // query with policy injected const entity = await db[model].findFirst(guardedQuery); @@ -760,13 +784,13 @@ export class PolicyUtil { /** * Gets "id" field for a given model. */ - getIdField(model: string) { + getIdFields(model: string) { const fields = this.modelMeta.fields[camelCase(model)]; if (!fields) { throw this.unknownError(`Unable to load fields for ${model}`); } - const result = Object.values(fields).find((f) => f.isId); - if (!result) { + const result = Object.values(fields).filter((f) => f.isId); + if (result.length === 0) { throw this.unknownError(`model ${model} does not have an id field`); } return result; @@ -775,8 +799,12 @@ export class PolicyUtil { /** * Gets id field value from an entity. */ - getEntityId(model: string, entityData: any) { - const idField = this.getIdField(model); - return entityData[idField.name]; + getEntityIds(model: string, entityData: any) { + const idFields = this.getIdFields(model); + const result: Record = {}; + for (const idField of idFields) { + result[idField.name] = entityData[idField.name]; + } + return result; } } diff --git a/packages/schema/package.json b/packages/schema/package.json index 8f33abf9c..e0053b6c4 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -3,7 +3,7 @@ "publisher": "zenstack", "displayName": "ZenStack Language Tools", "description": "A toolkit for building secure CRUD apps with Next.js + Typescript", - "version": "1.0.0-alpha.55", + "version": "1.0.0-alpha.60", "author": { "name": "ZenStack Team" }, diff --git a/packages/schema/src/language-server/utils.ts b/packages/schema/src/language-server/utils.ts index 261c4815e..dcf6a9f37 100644 --- a/packages/schema/src/language-server/utils.ts +++ b/packages/schema/src/language-server/utils.ts @@ -1,6 +1,15 @@ import { AstNode } from 'langium'; import { STD_LIB_MODULE_NAME } from './constants'; -import { isModel, Model } from '@zenstackhq/language/ast'; +import { + DataModel, + DataModelField, + isArrayExpr, + isModel, + isReferenceExpr, + Model, + ReferenceExpr, +} from '@zenstackhq/language/ast'; +import { resolved } from '@zenstackhq/sdk'; /** * Gets the toplevel Model containing the given node. @@ -19,3 +28,38 @@ export function isFromStdlib(node: AstNode) { const model = getContainingModel(node); return !!model && !!model.$document && model.$document.uri.path.endsWith(STD_LIB_MODULE_NAME); } + +/** + * Gets id fields declared at the data model level + */ +export function getIdFields(model: DataModel) { + const idAttr = model.attributes.find((attr) => attr.decl.ref?.name === '@@id'); + if (!idAttr) { + return []; + } + const fieldsArg = idAttr.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); +} + +/** + * Gets lists of unique fields declared at the data model level + */ +export function getUniqueFields(model: DataModel) { + const uniqueAttrs = model.attributes.filter((attr) => attr.decl.ref?.name === '@@unique'); + return uniqueAttrs.map((uniqueAttr) => { + 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); + }); +} diff --git a/packages/schema/src/language-server/validator/datamodel-validator.ts b/packages/schema/src/language-server/validator/datamodel-validator.ts index c7cc74a29..0d8b5eeff 100644 --- a/packages/schema/src/language-server/validator/datamodel-validator.ts +++ b/packages/schema/src/language-server/validator/datamodel-validator.ts @@ -10,7 +10,9 @@ import { ValidationAcceptor } from 'langium'; import { analyzePolicies } from '../../utils/ast-utils'; import { IssueCodes, SCALAR_TYPES } from '../constants'; import { AstValidator } from '../types'; +import { getIdFields, getUniqueFields } from '../utils'; import { validateAttributeApplication, validateDuplicatedDeclarations } from './utils'; +import { getLiteral } from '@zenstackhq/sdk'; /** * Validates data model declarations. @@ -18,33 +20,41 @@ import { validateAttributeApplication, validateDuplicatedDeclarations } from './ export default class DataModelValidator implements AstValidator { validate(dm: DataModel, accept: ValidationAcceptor): void { validateDuplicatedDeclarations(dm.fields, accept); - this.validateFields(dm, accept); this.validateAttributes(dm, accept); + this.validateFields(dm, accept); } private validateFields(dm: DataModel, accept: ValidationAcceptor) { const idFields = dm.fields.filter((f) => f.attributes.find((attr) => attr.decl.ref?.name === '@id')); - if (idFields.length === 0) { + const modelLevelIds = getIdFields(dm); + + if (idFields.length === 0 && modelLevelIds.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', { + accept('error', 'Model must include a field with @id attribute or a model-level @@id attribute', { node: dm, }); } + } else if (idFields.length > 0 && modelLevelIds.length > 0) { + accept('error', 'Model cannot have both field-level @id and model-level @@id attributes', { + node: dm, + }); } else if (idFields.length > 1) { accept('error', 'Model can include at most one field with @id attribute', { node: dm, }); } else { - if (idFields[0].type.optional) { - accept('error', 'Field with @id attribute must not be optional', { node: idFields[0] }); - } - - if (idFields[0].type.array || !idFields[0].type.type || !SCALAR_TYPES.includes(idFields[0].type.type)) { - accept('error', 'Field with @id attribute must be of scalar type', { node: idFields[0] }); - } + const fieldsToCheck = idFields.length > 0 ? idFields : modelLevelIds; + fieldsToCheck.forEach((idField) => { + if (idField.type.optional) { + accept('error', 'Field with @id attribute must not be optional', { node: idField }); + } + if (idField.type.array || !idField.type.type || !SCALAR_TYPES.includes(idField.type.type)) { + accept('error', 'Field with @id attribute must be of scalar type', { node: idField }); + } + }); } dm.fields.forEach((field) => this.validateField(field, accept)); @@ -109,8 +119,13 @@ export default class DataModelValidator implements AstValidator { } if (!fields || !references) { - if (accept) { - accept('error', `Both "fields" and "references" must be provided`, { node: relAttr }); + if (this.isSelfRelation(field, name)) { + // self relations are partial + // https://www.prisma.io/docs/concepts/components/prisma-schema/relations/self-relations + } else { + if (accept) { + accept('error', `Both "fields" and "references" must be provided`, { node: relAttr }); + } } } else { // validate "fields" and "references" typing consistency @@ -148,6 +163,35 @@ export default class DataModelValidator implements AstValidator { return { attr: relAttr, name, fields, references, valid }; } + private isSelfRelation(field: DataModelField, relationName?: string) { + if (field.type.reference?.ref === field.$container) { + // field directly references back to its type + return true; + } + + if (relationName) { + // field's relation points to another type, and that type's opposite relation field + // points back + const oppositeModelFields = field.type.reference?.ref?.fields as DataModelField[]; + if (oppositeModelFields) { + for (const oppositeField of oppositeModelFields) { + // find the opposite relation with the matching name + const relAttr = oppositeField.attributes.find((a) => a.decl.ref?.name === '@relation'); + if (relAttr) { + const relNameExpr = relAttr.args.find((a) => !a.name || a.name === 'name'); + const relName = getLiteral(relNameExpr?.value); + if (relName === relationName && oppositeField.type.reference?.ref === field.$container) { + // found an opposite relation field that points back to this field's type + return true; + } + } + } + } + } + + return false; + } + private validateRelationField(field: DataModelField, accept: ValidationAcceptor) { const thisRelation = this.parseRelation(field, accept); if (!thisRelation.valid) { @@ -171,15 +215,20 @@ export default class DataModelValidator implements AstValidator { ); return; } else if (oppositeFields.length > 1) { - oppositeFields.forEach((f) => - accept( - 'error', - `Fields ${oppositeFields.map((f) => '"' + f.name + '"').join(', ')} on model "${ - oppositeModel.name - }" refer to the same relation to model "${field.$container.name}"`, - { node: f } - ) - ); + oppositeFields.forEach((f) => { + if (this.isSelfRelation(f)) { + // self relations are partial + // https://www.prisma.io/docs/concepts/components/prisma-schema/relations/self-relations + } else { + accept( + 'error', + `Fields ${oppositeFields.map((f) => '"' + f.name + '"').join(', ')} on model "${ + oppositeModel.name + }" refer to the same relation to model "${field.$container.name}"`, + { node: f } + ); + } + }); return; } @@ -207,13 +256,15 @@ export default class DataModelValidator implements AstValidator { relationOwner = field; } } else { - [field, oppositeField].forEach((f) => - accept( - 'error', - 'Field for one side of relation must carry @relation attribute with both "fields" and "references" fields', - { node: f } - ) - ); + [field, oppositeField].forEach((f) => { + if (!this.isSelfRelation(f, thisRelation.name)) { + accept( + 'error', + 'Field for one side of relation must carry @relation attribute with both "fields" and "references" fields', + { node: f } + ); + } + }); return; } @@ -239,12 +290,21 @@ export default class DataModelValidator implements AstValidator { // // UserData.userId field needs to be @unique + const containingModel = field.$container as DataModel; + const uniqueFieldList = getUniqueFields(containingModel); + thisRelation.fields?.forEach((ref) => { const refField = ref.target.ref as DataModelField; - if (refField && !refField.attributes.find((a) => a.decl.ref?.name === '@unique')) { + if (refField) { + if (refField.attributes.find((a) => a.decl.ref?.name === '@unique')) { + return; + } + if (uniqueFieldList.some((list) => list.includes(refField))) { + return; + } accept( 'error', - `Field "${refField.name}" is part of a one-to-one relation and must be marked as @unique`, + `Field "${refField.name}" is part of a one-to-one relation and must be marked as @unique or be part of a model-level @@unique attribute`, { node: refField } ); } diff --git a/packages/schema/src/language-server/validator/utils.ts b/packages/schema/src/language-server/validator/utils.ts index a29af47e3..8fb507e85 100644 --- a/packages/schema/src/language-server/validator/utils.ts +++ b/packages/schema/src/language-server/validator/utils.ts @@ -267,6 +267,9 @@ function isValidAttributeTarget(attrDecl: Attribute, targetDecl: DataModelField) case 'IntField': allowed = allowed || targetDecl.type.type === 'Int'; break; + case 'BigIntField': + allowed = allowed || targetDecl.type.type === 'BigInt'; + break; case 'FloatField': allowed = allowed || targetDecl.type.type === 'Float'; break; diff --git a/packages/schema/src/plugins/model-meta/index.ts b/packages/schema/src/plugins/model-meta/index.ts index 13794805a..43d6a255a 100644 --- a/packages/schema/src/plugins/model-meta/index.ts +++ b/packages/schema/src/plugins/model-meta/index.ts @@ -12,6 +12,7 @@ import { getAttributeArgs, getLiteral, PluginOptions, resolved } from '@zenstack import { camelCase } from 'change-case'; import path from 'path'; import { CodeBlockWriter, Project, VariableDeclarationKind } from 'ts-morph'; +import { getIdFields } from '../../language-server/utils'; import { ensureNodeModuleFolder, getDefaultOutputFolder } from '../plugin-utils'; export const name = 'Model Metadata'; @@ -142,12 +143,25 @@ function getFieldAttributes(field: DataModelField): RuntimeAttribute[] { } function isIdField(field: DataModelField) { - return field.attributes.some((attr) => attr.decl.ref?.name === '@id'); + // field-level @id attribute + if (field.attributes.some((attr) => attr.decl.ref?.name === '@id')) { + return true; + } + + // model-level @@id attribute with a list of fields + const model = field.$container as DataModel; + const modelLevelIds = getIdFields(model); + if (modelLevelIds.includes(field)) { + return true; + } + return false; } function getUniqueConstraints(model: DataModel) { const constraints: Array<{ name: string; fields: string[] }> = []; - for (const attr of model.attributes.filter((attr) => attr.decl.ref?.name === '@@unique')) { + for (const attr of model.attributes.filter( + (attr) => attr.decl.ref?.name === '@@unique' || attr.decl.ref?.name === '@@id' + )) { const argsMap = getAttributeArgs(attr); if (argsMap.fields) { const fieldNames = (argsMap.fields as ArrayExpr).items.map( diff --git a/packages/schema/src/plugins/prisma/prisma-builder.ts b/packages/schema/src/plugins/prisma/prisma-builder.ts index 60a1d9191..88190334d 100644 --- a/packages/schema/src/plugins/prisma/prisma-builder.ts +++ b/packages/schema/src/plugins/prisma/prisma-builder.ts @@ -80,7 +80,7 @@ export class Generator { } export class DeclarationBase { - public documentations: string[] = []; + constructor(public documentations: string[] = []) {} addComment(name: string): string { this.documentations.push(name); @@ -91,17 +91,29 @@ export class DeclarationBase { return this.documentations.map((x) => `${x}\n`).join(''); } } -export class Model extends DeclarationBase { + +export class ContainerDeclaration extends DeclarationBase { + constructor(documentations: string[] = [], public attributes: (ContainerAttribute | PassThroughAttribute)[] = []) { + super(documentations); + } +} + +export class FieldDeclaration extends DeclarationBase { + constructor(documentations: string[] = [], public attributes: (FieldAttribute | PassThroughAttribute)[] = []) { + super(documentations); + } +} + +export class Model extends ContainerDeclaration { public fields: ModelField[] = []; - public attributes: ModelAttribute[] = []; - constructor(public name: string, public documentations: string[] = []) { - super(); + constructor(public name: string, documentations: string[] = []) { + super(documentations); } addField( name: string, type: ModelFieldType | string, - attributes: FieldAttribute[] = [], + attributes: (FieldAttribute | PassThroughAttribute)[] = [], documentations: string[] = [] ): ModelField { const field = new ModelField(name, type, attributes, documentations); @@ -109,8 +121,8 @@ export class Model extends DeclarationBase { return field; } - addAttribute(name: string, args: AttributeArg[] = []): ModelAttribute { - const attr = new ModelAttribute(name, args); + addAttribute(name: string, args: AttributeArg[] = []) { + const attr = new ContainerAttribute(name, args); this.attributes.push(attr); return attr; } @@ -145,14 +157,14 @@ export class ModelFieldType { } } -export class ModelField extends DeclarationBase { +export class ModelField extends FieldDeclaration { constructor( public name: string, public type: ModelFieldType | string, - public attributes: FieldAttribute[] = [], - public documentations: string[] = [] + attributes: (FieldAttribute | PassThroughAttribute)[] = [], + documentations: string[] = [] ) { - super(); + super(documentations, attributes); } addAttribute(name: string, args: AttributeArg[] = []): FieldAttribute { @@ -178,7 +190,7 @@ export class FieldAttribute { } } -export class ModelAttribute { +export class ContainerAttribute { constructor(public name: string, public args: AttributeArg[] = []) {} toString(): string { @@ -186,6 +198,17 @@ export class ModelAttribute { } } +/** + * Represents @@prisma.passthrough and @prisma.passthrough + */ +export class PassThroughAttribute { + constructor(public text: string) {} + + toString(): string { + return this.text; + } +} + export class AttributeArg { constructor(public name: string | undefined, public value: AttributeArgValue) {} @@ -287,22 +310,25 @@ export class FunctionCallArg { } } -export class Enum extends DeclarationBase { +export class Enum extends ContainerDeclaration { public fields: EnumField[] = []; - public attributes: ModelAttribute[] = []; constructor(public name: string, public documentations: string[] = []) { - super(); + super(documentations); } - addField(name: string, attributes: FieldAttribute[] = [], documentations: string[] = []): EnumField { + addField( + name: string, + attributes: (FieldAttribute | PassThroughAttribute)[] = [], + documentations: string[] = [] + ): EnumField { const field = new EnumField(name, attributes, documentations); this.fields.push(field); return field; } - addAttribute(name: string, args: AttributeArg[] = []): ModelAttribute { - const attr = new ModelAttribute(name, args); + addAttribute(name: string, args: AttributeArg[] = []) { + const attr = new ContainerAttribute(name, args); this.attributes.push(attr); return attr; } @@ -323,7 +349,11 @@ export class Enum extends DeclarationBase { } export class EnumField extends DeclarationBase { - constructor(public name: string, public attributes: FieldAttribute[] = [], public documentations: string[] = []) { + constructor( + public name: string, + public attributes: (FieldAttribute | PassThroughAttribute)[] = [], + public documentations: string[] = [] + ) { super(); } diff --git a/packages/schema/src/plugins/prisma/schema-generator.ts b/packages/schema/src/plugins/prisma/schema-generator.ts index 0be856429..1820602da 100644 --- a/packages/schema/src/plugins/prisma/schema-generator.ts +++ b/packages/schema/src/plugins/prisma/schema-generator.ts @@ -1,6 +1,5 @@ import { AstNode, - Attribute, AttributeArg, DataModel, DataModelAttribute, @@ -36,6 +35,8 @@ import { execSync } from '../../utils/exec-utils'; import { AttributeArg as PrismaAttributeArg, AttributeArgValue as PrismaAttributeArgValue, + ContainerAttribute as PrismaModelAttribute, + ContainerDeclaration as PrismaContainerDeclaration, DataSourceUrl as PrismaDataSourceUrl, Enum as PrismaEnum, FieldAttribute as PrismaFieldAttribute, @@ -44,12 +45,15 @@ import { FunctionCall as PrismaFunctionCall, FunctionCallArg as PrismaFunctionCallArg, Model as PrismaDataModel, - ModelAttribute as PrismaModelAttribute, ModelFieldType, + PassThroughAttribute as PrismaPassThroughAttribute, PrismaModel, } from './prisma-builder'; import ZModelCodeGenerator from './zmodel-code-generator'; +const MODEL_PASSTHROUGH_ATTR = '@@prisma.passthrough'; +const FIELD_PASSTHROUGH_ATTR = '@prisma.passthrough'; + /** * Generates Prisma schema file */ @@ -173,18 +177,18 @@ export default class PrismaSchemaGenerator { this.generateModelField(model, field); } + // add an "zenstack_guard" field for dealing with boolean conditions + model.addField(GUARD_FIELD_NAME, 'Boolean', [ + new PrismaFieldAttribute('@default', [ + new PrismaAttributeArg(undefined, new PrismaAttributeArgValue('Boolean', true)), + ]), + ]); + const { allowAll, denyAll, hasFieldValidation } = analyzePolicies(decl); if ((!allowAll && !denyAll) || hasFieldValidation) { // generate auxiliary fields for policy check - // add an "zenstack_guard" field for dealing with pure auth() related conditions - model.addField(GUARD_FIELD_NAME, 'Boolean', [ - new PrismaFieldAttribute('@default', [ - new PrismaAttributeArg(undefined, new PrismaAttributeArgValue('Boolean', true)), - ]), - ]); - // add an "zenstack_transaction" field for tracking records created/updated with nested writes model.addField(TRANSACTION_FIELD_NAME, 'String?'); @@ -199,20 +203,29 @@ export default class PrismaSchemaGenerator { ]); } - for (const attr of decl.attributes.filter((attr) => attr.decl.ref && this.isPrismaAttribute(attr.decl.ref))) { - this.generateModelAttribute(model, attr); + for (const attr of decl.attributes.filter((attr) => this.isPrismaAttribute(attr))) { + this.generateContainerAttribute(model, attr); } decl.attributes - .filter((attr) => attr.decl.ref && !this.isPrismaAttribute(attr.decl.ref)) + .filter((attr) => attr.decl.ref && !this.isPrismaAttribute(attr)) .forEach((attr) => model.addComment('/// ' + this.zModelGenerator.generateAttribute(attr))); // user defined comments pass-through decl.comments.forEach((c) => model.addComment(c)); } - private isPrismaAttribute(attr: Attribute) { - return !!attr.attributes.find((a) => a.decl.ref?.name === '@@@prisma'); + private isPrismaAttribute(attr: DataModelAttribute | DataModelFieldAttribute) { + if (!attr.decl.ref) { + return false; + } + const attrDecl = resolved(attr.decl); + return ( + !!attrDecl.attributes.find((a) => a.decl.ref?.name === '@@@prisma') || + // the special pass-through attribute + attrDecl.name === MODEL_PASSTHROUGH_ATTR || + attrDecl.name === FIELD_PASSTHROUGH_ATTR + ); } private generateModelField(model: PrismaDataModel, field: DataModelField) { @@ -224,12 +237,10 @@ export default class PrismaSchemaGenerator { const type = new ModelFieldType(fieldType, field.type.array, field.type.optional); const attributes = field.attributes - .filter((attr) => attr.decl.ref && this.isPrismaAttribute(attr.decl.ref)) + .filter((attr) => this.isPrismaAttribute(attr)) .map((attr) => this.makeFieldAttribute(attr)); - const nonPrismaAttributes = field.attributes.filter( - (attr) => !attr.decl.ref || !this.isPrismaAttribute(attr.decl.ref) - ); + const nonPrismaAttributes = field.attributes.filter((attr) => attr.decl.ref && !this.isPrismaAttribute(attr)); const documentations = nonPrismaAttributes.map((attr) => '/// ' + this.zModelGenerator.generateAttribute(attr)); @@ -240,10 +251,20 @@ export default class PrismaSchemaGenerator { } private makeFieldAttribute(attr: DataModelFieldAttribute) { - return new PrismaFieldAttribute( - resolved(attr.decl).name, - attr.args.map((arg) => this.makeAttributeArg(arg)) - ); + const attrName = resolved(attr.decl).name; + if (attrName === FIELD_PASSTHROUGH_ATTR) { + const text = getLiteral(attr.args[0].value); + if (text) { + return new PrismaPassThroughAttribute(text); + } else { + throw new PluginError(`Invalid arguments for ${FIELD_PASSTHROUGH_ATTR} attribute`); + } + } else { + return new PrismaFieldAttribute( + attrName, + attr.args.map((arg) => this.makeAttributeArg(arg)) + ); + } } private makeAttributeArg(arg: AttributeArg): PrismaAttributeArg { @@ -295,13 +316,21 @@ export default class PrismaSchemaGenerator { ); } - private generateModelAttribute(model: PrismaDataModel | PrismaEnum, attr: DataModelAttribute) { - model.attributes.push( - new PrismaModelAttribute( - resolved(attr.decl).name, - attr.args.map((arg) => this.makeAttributeArg(arg)) - ) - ); + private generateContainerAttribute(container: PrismaContainerDeclaration, attr: DataModelAttribute) { + const attrName = resolved(attr.decl).name; + if (attrName === MODEL_PASSTHROUGH_ATTR) { + const text = getLiteral(attr.args[0].value); + if (text) { + container.attributes.push(new PrismaPassThroughAttribute(text)); + } + } else { + container.attributes.push( + new PrismaModelAttribute( + attrName, + attr.args.map((arg) => this.makeAttributeArg(arg)) + ) + ); + } } private generateEnum(prisma: PrismaModel, decl: Enum) { @@ -311,12 +340,12 @@ export default class PrismaSchemaGenerator { this.generateEnumField(_enum, field); } - for (const attr of decl.attributes.filter((attr) => attr.decl.ref && this.isPrismaAttribute(attr.decl.ref))) { - this.generateModelAttribute(_enum, attr); + for (const attr of decl.attributes.filter((attr) => this.isPrismaAttribute(attr))) { + this.generateContainerAttribute(_enum, attr); } decl.attributes - .filter((attr) => attr.decl.ref && !this.isPrismaAttribute(attr.decl.ref)) + .filter((attr) => attr.decl.ref && !this.isPrismaAttribute(attr)) .forEach((attr) => _enum.addComment('/// ' + this.zModelGenerator.generateAttribute(attr))); // user defined comments pass-through @@ -325,12 +354,10 @@ export default class PrismaSchemaGenerator { private generateEnumField(_enum: PrismaEnum, field: EnumField) { const attributes = field.attributes - .filter((attr) => attr.decl.ref && this.isPrismaAttribute(attr.decl.ref)) + .filter((attr) => this.isPrismaAttribute(attr)) .map((attr) => this.makeFieldAttribute(attr)); - const nonPrismaAttributes = field.attributes.filter( - (attr) => !attr.decl.ref || !this.isPrismaAttribute(attr.decl.ref) - ); + const nonPrismaAttributes = field.attributes.filter((attr) => attr.decl.ref && !this.isPrismaAttribute(attr)); const documentations = nonPrismaAttributes.map((attr) => '/// ' + this.zModelGenerator.generateAttribute(attr)); _enum.addField(field.name, attributes, documentations); diff --git a/packages/schema/src/res/starter.zmodel b/packages/schema/src/res/starter.zmodel index 6cfee5b0f..75207fc1e 100644 --- a/packages/schema/src/res/starter.zmodel +++ b/packages/schema/src/res/starter.zmodel @@ -10,6 +10,10 @@ datasource db { url = 'file:./todo.db' } +generator client { + provider = "prisma-client-js" +} + /* * User model */ diff --git a/packages/schema/src/res/stdlib.zmodel b/packages/schema/src/res/stdlib.zmodel index cd78b7a4f..940a348b7 100644 --- a/packages/schema/src/res/stdlib.zmodel +++ b/packages/schema/src/res/stdlib.zmodel @@ -39,6 +39,7 @@ enum ReferentialAction { enum AttributeTargetField { StringField IntField + BigIntField FloatField DecimalField BooleanField @@ -51,43 +52,51 @@ enum AttributeTargetField { /* * Reads value from an environment variable. */ -function env(name: String): String {} +function env(name: String): String { +} /* * Gets the current login user. */ -function auth(): Any {} +function auth(): Any { +} /* * Gets current date-time (as DateTime type). */ -function now(): DateTime {} +function now(): DateTime { +} /* * Generates a globally unique identifier based on the UUID specs. */ -function uuid(): String {} +function uuid(): String { +} /* * Generates a globally unique identifier based on the CUID spec. */ -function cuid(): String {} +function cuid(): String { +} /* * Creates a sequence of integers in the underlying database and assign the incremented * values to the ID values of the created records based on the sequence. */ -function autoincrement(): Int {} +function autoincrement(): Int { +} /* * Represents default values that cannot be expressed in the Prisma schema (such as random()). */ -function dbgenerated(expr: String): Any {} +function dbgenerated(expr: String): Any { +} /** * Gets entities value before an update. Only valid when used in a "update" policy rule. */ -function future(): Any {} +function future(): Any { +} /** * Marks an attribute to be only applicable to certain field types. @@ -114,6 +123,11 @@ attribute @default(_ value: ContextType) @@@prisma */ attribute @unique(map: String?) @@@prisma +/* + * Defines a multi-field ID (composite ID) on the model. + */ +attribute @@id(_ fields: FieldReference[], name: String?, map: String?) @@@prisma + /* * Defines a compound unique constraint for the specified fields. */ @@ -144,6 +158,94 @@ attribute @@map(_ name: String) @@@prisma */ attribute @updatedAt() @@@targetField([DateTimeField]) @@@prisma +// String type modifiers + +attribute @db.String(_ x: Int?) @@@targetField([StringField]) @@@prisma +attribute @db.Text() @@@targetField([StringField]) @@@prisma +attribute @db.NText() @@@targetField([StringField]) @@@prisma +attribute @db.Char(_ x: Int) @@@targetField([StringField]) @@@prisma +attribute @db.NChar(_ x: Int) @@@targetField([StringField]) @@@prisma +attribute @db.VarChar(_ x: Int) @@@targetField([StringField]) @@@prisma +attribute @db.NVarChar(_ x: Int) @@@targetField([StringField]) @@@prisma +attribute @db.CatalogSingleChar() @@@targetField([StringField]) @@@prisma +attribute @db.TinyText() @@@targetField([StringField]) @@@prisma +attribute @db.MediumText() @@@targetField([StringField]) @@@prisma +attribute @db.LongText() @@@targetField([StringField]) @@@prisma +attribute @db.Bit(_ x: Int?) @@@targetField([StringField, BooleanField, BytesField]) @@@prisma +attribute @db.VarBit(_ x: Int?) @@@targetField([StringField]) @@@prisma +attribute @db.Uuid() @@@targetField([StringField]) @@@prisma +attribute @db.UniqueIdentifier() @@@targetField([StringField]) @@@prisma +attribute @db.Xml() @@@targetField([StringField]) @@@prisma +attribute @db.Inet() @@@targetField([StringField]) @@@prisma +attribute @db.Citext() @@@targetField([StringField]) @@@prisma + +// Boolean type modifiers + +attribute @db.Boolean() @@@targetField([BooleanField]) @@@prisma +attribute @db.TinyInt(_ x: Int?) @@@targetField([BooleanField]) @@@prisma +attribute @db.Bool() @@@targetField([BooleanField]) @@@prisma + +// Int type modifiers + +attribute @db.Int() @@@targetField([IntField]) @@@prisma +attribute @db.Integer() @@@targetField([IntField]) @@@prisma +attribute @db.SmallInt() @@@targetField([IntField]) @@@prisma +attribute @db.Oid() @@@targetField([IntField]) @@@prisma +attribute @db.UnsignedInt() @@@targetField([IntField]) @@@prisma +attribute @db.UnsignedSmallInt() @@@targetField([IntField]) @@@prisma +attribute @db.MediumInt() @@@targetField([IntField]) @@@prisma +attribute @db.UnsignedMediumInt() @@@targetField([IntField]) @@@prisma +attribute @db.UnsignedTinyInt() @@@targetField([IntField]) @@@prisma +attribute @db.Year() @@@targetField([IntField]) @@@prisma +attribute @db.Int4() @@@targetField([IntField]) @@@prisma +attribute @db.Int2() @@@targetField([IntField]) @@@prisma + +// BigInt type modifiers + +attribute @db.BigInt() @@@targetField([BigIntField]) @@@prisma +attribute @db.UnsignedBigInt() @@@targetField([BigIntField]) @@@prisma +attribute @db.Int8() @@@targetField([BigIntField]) @@@prisma + +// Float/Decimal type modifiers +attribute @db.DoublePrecision() @@@targetField([FloatField, DecimalField]) @@@prisma +attribute @db.Real() @@@targetField([FloatField, DecimalField]) @@@prisma +attribute @db.Float() @@@targetField([FloatField, DecimalField]) @@@prisma +attribute @db.Decimal() @@@targetField([FloatField, DecimalField]) @@@prisma +attribute @db.Double() @@@targetField([FloatField, DecimalField]) @@@prisma +attribute @db.Money() @@@targetField([FloatField, DecimalField]) @@@prisma +attribute @db.SmallMoney() @@@targetField([FloatField, DecimalField]) @@@prisma +attribute @db.Float8() @@@targetField([FloatField, DecimalField]) @@@prisma +attribute @db.Float4() @@@targetField([FloatField, DecimalField]) @@@prisma + +// DateTime type modifiers + +attribute @db.DateTime(x: Int?) @@@targetField([DateTimeField]) @@@prisma +attribute @db.DateTime2() @@@targetField([DateTimeField]) @@@prisma +attribute @db.SmallDateTime() @@@targetField([DateTimeField]) @@@prisma +attribute @db.DateTimeOffset() @@@targetField([DateTimeField]) @@@prisma +attribute @db.Timestamp(_ x: Int?) @@@targetField([DateTimeField]) @@@prisma +attribute @db.Timestamptz(_ x: Int?) @@@targetField([DateTimeField]) @@@prisma +attribute @db.Date() @@@targetField([DateTimeField]) @@@prisma +attribute @db.Time(_ x: Int?) @@@targetField([DateTimeField]) @@@prisma +attribute @db.Timetz(_ x: Int?) @@@targetField([DateTimeField]) @@@prisma + +// Json type modifiers + +attribute @db.Json() @@@targetField([JsonField]) @@@prisma +attribute @db.JsonB() @@@targetField([JsonField]) @@@prisma + +// Bytes type modifiers + +attribute @db.Bytes() @@@targetField([BytesField]) @@@prisma +attribute @db.ByteA() @@@targetField([BytesField]) @@@prisma +attribute @db.LongBlob() @@@targetField([BytesField]) @@@prisma +attribute @db.Binary() @@@targetField([BytesField]) @@@prisma +attribute @db.VarBinary() @@@targetField([BytesField]) @@@prisma +attribute @db.TinyBlob() @@@targetField([BytesField]) @@@prisma +attribute @db.Blob() @@@targetField([BytesField]) @@@prisma +attribute @db.MediumBlob() @@@targetField([BytesField]) @@@prisma +attribute @db.Image() @@@targetField([BytesField]) @@@prisma + /* * Defines an access policy that allows a set of operations when the given condition is true. */ @@ -227,3 +329,13 @@ attribute @lt(_ value: Int) @@@targetField([IntField, FloatField, DecimalField]) * Validates a number field is less than or equal to the given value. */ attribute @lte(_ value: Int) @@@targetField([IntField, FloatField, DecimalField]) + +/* + * A utility attribute to allow passthrough of arbitrary attribute text to the generated Prisma schema. + */ +attribute @prisma.passthrough(_ text: String) + +/* + * A utility attribute to allow passthrough of arbitrary attribute text to the generated Prisma schema. + */ +attribute @@prisma.passthrough(_ text: String) diff --git a/packages/schema/tests/generator/prisma-generator.test.ts b/packages/schema/tests/generator/prisma-generator.test.ts index bffcb69c4..a132a7af8 100644 --- a/packages/schema/tests/generator/prisma-generator.test.ts +++ b/packages/schema/tests/generator/prisma-generator.test.ts @@ -1,3 +1,4 @@ +import { getDMMF } from '@prisma/internals'; import fs from 'fs'; import tmp from 'tmp'; import PrismaSchemaGenerator from '../../src/plugins/prisma/schema-generator'; @@ -13,7 +14,7 @@ describe('Prisma generator test', () => { /// This is a comment model Foo { - id String @id + id String @id /// Comment for field value value Int } @@ -27,6 +28,7 @@ describe('Prisma generator test', () => { }); const content = fs.readFileSync(name, 'utf-8'); + await getDMMF({ datamodel: content }); expect(content).toContain('/// This is a comment'); expect(content).toContain('/// Comment for field value'); }); @@ -57,6 +59,7 @@ describe('Prisma generator test', () => { }); const content = fs.readFileSync(name, 'utf-8'); + await getDMMF({ datamodel: content }); expect(content).toContain(`/// @TypeGraphQL.omit(output: true, input: true)`); expect(content).toContain(`/// @TypeGraphQL.omit(input: ['update', 'where', 'orderBy'])`); expect(content).toContain(`/// @TypeGraphQL.field(name: 'bar')`); @@ -91,10 +94,47 @@ describe('Prisma generator test', () => { }); const content = fs.readFileSync(name, 'utf-8'); + await getDMMF({ datamodel: content }); expect(content).toContain(`@@map("_User")`); expect(content).toContain(`@map("_role")`); expect(content).toContain(`@@map("_Role")`); expect(content).toContain(`@map("admin")`); expect(content).toContain(`@map("customer")`); }); + + it('attribute passthrough', async () => { + const model = await loadModel(` + datasource db { + provider = 'postgresql' + url = env('URL') + } + + model Foo { + id String @id + name String @prisma.passthrough('@unique()') + x Int + y Int + @@prisma.passthrough('@@index([x, y])') + } + + enum Role { + USER @prisma.passthrough('@map("__user")') + ADMIN @prisma.passthrough('@map("__admin")') + + @@prisma.passthrough('@@map("__role")') + } + `); + + const { name } = tmp.fileSync({ postfix: '.prisma' }); + await new PrismaSchemaGenerator().generate(model, { + provider: '@zenstack/prisma', + schemaPath: 'schema.zmodel', + output: name, + }); + + const content = fs.readFileSync(name, 'utf-8'); + await getDMMF({ datamodel: content }); + expect(content).toContain('@unique()'); + expect(content).toContain('@@index([x, y])'); + }); }); diff --git a/packages/schema/tests/schema/cal-com.test.ts b/packages/schema/tests/schema/cal-com.test.ts new file mode 100644 index 000000000..05da241b9 --- /dev/null +++ b/packages/schema/tests/schema/cal-com.test.ts @@ -0,0 +1,12 @@ +import * as fs from 'fs'; +import path from 'path'; +import { loadModel } from '../utils'; + +describe('Cal.com Schema Tests', () => { + it('model loading', async () => { + const content = fs.readFileSync(path.join(__dirname, './cal-com.zmodel'), { + encoding: 'utf-8', + }); + await loadModel(content); + }); +}); diff --git a/packages/schema/tests/schema/cal-com.zmodel b/packages/schema/tests/schema/cal-com.zmodel new file mode 100644 index 000000000..26dea14a7 --- /dev/null +++ b/packages/schema/tests/schema/cal-com.zmodel @@ -0,0 +1,665 @@ +// This is your Prisma schema file, +// learn more about it in the docs: https://pris.ly/d/prisma-schema + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +generator client { + provider = "prisma-client-js" + output = '../.prisma' + previewFeatures = [] +} + +plugin meta { + provider = '@zenstack/model-meta' + output = '.zenstack' +} + +plugin policy { + provider = '@zenstack/access-policy' + output = '.zenstack' +} + +enum SchedulingType { + ROUND_ROBIN @map("roundRobin") + COLLECTIVE @map("collective") +} + +enum PeriodType { + UNLIMITED @map("unlimited") + ROLLING @map("rolling") + RANGE @map("range") +} + +model Host { + id Int @id @default(autoincrement()) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId Int + eventType EventType @relation(fields: [eventTypeId], references: [id], onDelete: Cascade) + eventTypeId Int + isFixed Boolean @default(false) +} + +model EventType { + id Int @id @default(autoincrement()) + /// @zod.min(1) + title String + /// @zod.custom(imports.eventTypeSlug) + slug String + description String? + position Int @default(0) + /// @zod.custom(imports.eventTypeLocations) + locations Json? + length Int + hidden Boolean @default(false) + hosts Host[] + users User[] @relation("user_eventtype") + owner User? @relation("owner", fields: [userId], references: [id], onDelete: Cascade) + userId Int? + team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade) + teamId Int? + hashedLink HashedLink? + bookings Booking[] + availability Availability[] + webhooks Webhook[] + destinationCalendar DestinationCalendar? + eventName String? + customInputs EventTypeCustomInput[] + /// @zod.custom(imports.eventTypeBookingFields) + bookingFields Json? + timeZone String? + periodType PeriodType @default(UNLIMITED) + periodStartDate DateTime? + periodEndDate DateTime? + periodDays Int? + periodCountCalendarDays Boolean? + requiresConfirmation Boolean @default(false) + /// @zod.custom(imports.recurringEventType) + recurringEvent Json? + disableGuests Boolean @default(false) + hideCalendarNotes Boolean @default(false) + minimumBookingNotice Int @default(120) + beforeEventBuffer Int @default(0) + afterEventBuffer Int @default(0) + seatsPerTimeSlot Int? + seatsShowAttendees Boolean? @default(false) + schedulingType SchedulingType? + schedule Schedule? @relation(fields: [scheduleId], references: [id]) + scheduleId Int? + // price is deprecated. It has now moved to metadata.apps.stripe.price. Plan to drop this column. + price Int @default(0) + // currency is deprecated. It has now moved to metadata.apps.stripe.currency. Plan to drop this column. + currency String @default("usd") + slotInterval Int? + /// @zod.custom(imports.EventTypeMetaDataSchema) + metadata Json? + /// @zod.custom(imports.successRedirectUrl) + successRedirectUrl String? + workflows WorkflowsOnEventTypes[] + /// @zod.custom(imports.bookingLimitsType) + bookingLimits Json? + @@unique([userId, slug]) + @@unique([teamId, slug]) +} + +model Credential { + id Int @id @default(autoincrement()) + // @@type is deprecated + type String + key Json + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + userId Int? + app App? @relation(fields: [appId], references: [slug], onDelete: Cascade) + // How to make it a required column? + appId String? + destinationCalendars DestinationCalendar[] + invalid Boolean? @default(false) +} + +enum IdentityProvider { + CAL + GOOGLE + SAML +} + +model DestinationCalendar { + id Int @id @default(autoincrement()) + integration String + externalId String + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + userId Int? @unique + booking Booking[] + eventType EventType? @relation(fields: [eventTypeId], references: [id], onDelete: Cascade) + eventTypeId Int? @unique + credentialId Int? + credential Credential? @relation(fields: [credentialId], references: [id], onDelete: Cascade) +} + +enum UserPermissionRole { + USER + ADMIN +} + +model User { + id Int @id @default(autoincrement()) + username String? @unique + name String? + /// @zod.email() + email String @unique + emailVerified DateTime? + password String? + bio String? + avatar String? + timeZone String @default("Europe/London") + weekStart String @default("Sunday") + // DEPRECATED - TO BE REMOVED + startTime Int @default(0) + endTime Int @default(1440) + // + bufferTime Int @default(0) + hideBranding Boolean @default(false) + theme String? + createdDate DateTime @default(now()) @map(name: "created") + trialEndsAt DateTime? + eventTypes EventType[] @relation("user_eventtype") + credentials Credential[] + teams Membership[] + bookings Booking[] + schedules Schedule[] + defaultScheduleId Int? + selectedCalendars SelectedCalendar[] + completedOnboarding Boolean @default(false) + locale String? + timeFormat Int? @default(12) + twoFactorSecret String? + twoFactorEnabled Boolean @default(false) + identityProvider IdentityProvider @default(CAL) + identityProviderId String? + availability Availability[] + invitedTo Int? + webhooks Webhook[] + brandColor String @default("#292929") + darkBrandColor String @default("#fafafa") + // the location where the events will end up + destinationCalendar DestinationCalendar? + away Boolean @default(false) + // participate in dynamic group booking or not + allowDynamicBooking Boolean? @default(true) + /// @zod.custom(imports.userMetadata) + metadata Json? + verified Boolean? @default(false) + role UserPermissionRole @default(USER) + disableImpersonation Boolean @default(false) + impersonatedUsers Impersonations[] @relation("impersonated_user") + impersonatedBy Impersonations[] @relation("impersonated_by_user") + apiKeys ApiKey[] + accounts Account[] + sessions Session[] + Feedback Feedback[] + ownedEventTypes EventType[] @relation("owner") + workflows Workflow[] + routingForms App_RoutingForms_Form[] @relation("routing-form") + verifiedNumbers VerifiedNumber[] + hosts Host[] + @@map(name: "users") +} + +model Team { + id Int @id @default(autoincrement()) + /// @zod.min(1) + name String + /// @zod.min(1) + slug String? @unique + logo String? + bio String? + hideBranding Boolean @default(false) + hideBookATeamMember Boolean @default(false) + members Membership[] + eventTypes EventType[] + workflows Workflow[] + createdAt DateTime @default(now()) + /// @zod.custom(imports.teamMetadataSchema) + metadata Json? + theme String? + brandColor String @default("#292929") + darkBrandColor String @default("#fafafa") + verifiedNumbers VerifiedNumber[] +} + +enum MembershipRole { + MEMBER + ADMIN + OWNER +} + +model Membership { + teamId Int + userId Int + accepted Boolean @default(false) + role MembershipRole + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + disableImpersonation Boolean @default(false) + @@id([userId, teamId]) +} + +model VerificationToken { + id Int @id @default(autoincrement()) + identifier String + token String @unique + expires DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + @@unique([identifier, token]) +} + +model BookingReference { + id Int @id @default(autoincrement()) + /// @zod.min(1) + type String + /// @zod.min(1) + uid String + meetingId String? + meetingPassword String? + meetingUrl String? + booking Booking? @relation(fields: [bookingId], references: [id], onDelete: Cascade) + bookingId Int? + externalCalendarId String? + deleted Boolean? + credentialId Int? +} + +model Attendee { + id Int @id @default(autoincrement()) + email String + name String + timeZone String + locale String? @default("en") + booking Booking? @relation(fields: [bookingId], references: [id]) + bookingId Int? +} + +enum BookingStatus { + CANCELLED @map("cancelled") + ACCEPTED @map("accepted") + REJECTED @map("rejected") + PENDING @map("pending") +} + +model Booking { + id Int @id @default(autoincrement()) + uid String @unique + user User? @relation(fields: [userId], references: [id]) + userId Int? + references BookingReference[] + eventType EventType? @relation(fields: [eventTypeId], references: [id]) + eventTypeId Int? + title String + description String? + customInputs Json? + /// @zod.custom(imports.bookingResponses) + responses Json? + startTime DateTime + endTime DateTime + attendees Attendee[] + location String? + createdAt DateTime @default(now()) + updatedAt DateTime? + status BookingStatus @default(ACCEPTED) + paid Boolean @default(false) + payment Payment[] + destinationCalendar DestinationCalendar? @relation(fields: [destinationCalendarId], references: [id]) + destinationCalendarId Int? + cancellationReason String? + rejectionReason String? + dynamicEventSlugRef String? + dynamicGroupSlugRef String? + rescheduled Boolean? + fromReschedule String? + recurringEventId String? + smsReminderNumber String? + workflowReminders WorkflowReminder[] + scheduledJobs String[] + /// @zod.custom(imports.bookingMetadataSchema) + metadata Json? +} + +model Schedule { + id Int @id @default(autoincrement()) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId Int + eventType EventType[] + name String + timeZone String? + availability Availability[] + @@index([userId]) +} + +model Availability { + id Int @id @default(autoincrement()) + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + userId Int? + eventType EventType? @relation(fields: [eventTypeId], references: [id]) + eventTypeId Int? + days Int[] + startTime DateTime @db.Time + endTime DateTime @db.Time + date DateTime? @db.Date + Schedule Schedule? @relation(fields: [scheduleId], references: [id]) + scheduleId Int? + @@index([eventTypeId]) + @@index([scheduleId]) +} + +model SelectedCalendar { + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId Int + integration String + externalId String + @@id([userId, integration, externalId]) +} + +enum EventTypeCustomInputType { + TEXT @map("text") + TEXTLONG @map("textLong") + NUMBER @map("number") + BOOL @map("bool") + RADIO @map("radio") + PHONE @map("phone") +} + +model EventTypeCustomInput { + id Int @id @default(autoincrement()) + eventTypeId Int + eventType EventType @relation(fields: [eventTypeId], references: [id], onDelete: Cascade) + label String + type EventTypeCustomInputType + /// @zod.custom(imports.customInputOptionSchema) + options Json? + required Boolean + placeholder String @default("") +} + +model ResetPasswordRequest { + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + email String + expires DateTime +} + +enum ReminderType { + PENDING_BOOKING_CONFIRMATION +} + +model ReminderMail { + id Int @id @default(autoincrement()) + referenceId Int + reminderType ReminderType + elapsedMinutes Int + createdAt DateTime @default(now()) +} + +model Payment { + id Int @id @default(autoincrement()) + uid String @unique + app App? @relation(fields: [appId], references: [slug], onDelete: Cascade) + appId String? + bookingId Int + booking Booking? @relation(fields: [bookingId], references: [id], onDelete: Cascade) + amount Int + fee Int + currency String + success Boolean + refunded Boolean + data Json + externalId String @unique +} + +enum WebhookTriggerEvents { + BOOKING_CREATED + BOOKING_RESCHEDULED + BOOKING_CANCELLED + FORM_SUBMITTED + MEETING_ENDED +} + +model Webhook { + id String @id @unique + userId Int? + eventTypeId Int? + /// @zod.url() + subscriberUrl String + payloadTemplate String? + createdAt DateTime @default(now()) + active Boolean @default(true) + eventTriggers WebhookTriggerEvents[] + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + eventType EventType? @relation(fields: [eventTypeId], references: [id], onDelete: Cascade) + app App? @relation(fields: [appId], references: [slug], onDelete: Cascade) + appId String? + secret String? + @@unique([userId, subscriberUrl], name: "courseIdentifier") +} + +model Impersonations { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) + impersonatedUser User @relation("impersonated_user", fields: [impersonatedUserId], references: [id], onDelete: Cascade) + impersonatedBy User @relation("impersonated_by_user", fields: [impersonatedById], references: [id], onDelete: Cascade) + impersonatedUserId Int + impersonatedById Int +} + +model ApiKey { + id String @id @unique @default(cuid()) + userId Int + note String? + createdAt DateTime @default(now()) + expiresAt DateTime? + lastUsedAt DateTime? + hashedKey String @unique() + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + app App? @relation(fields: [appId], references: [slug], onDelete: Cascade) + appId String? +} + +model HashedLink { + id Int @id @default(autoincrement()) + link String @unique() + eventType EventType @relation(fields: [eventTypeId], references: [id], onDelete: Cascade) + eventTypeId Int @unique +} + +model Account { + id String @id @default(cuid()) + userId Int + type String + provider String + providerAccountId String + refresh_token String? @db.Text + access_token String? @db.Text + expires_at Int? + token_type String? + scope String? + id_token String? @db.Text + session_state String? + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + @@unique([provider, providerAccountId]) +} + +model Session { + id String @id @default(cuid()) + sessionToken String @unique + userId Int + expires DateTime + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) +} + +enum AppCategories { + calendar + messaging + other + payment + video + web3 + automation + analytics +} + +model App { + // The slug for the app store public page inside `/apps/[slug]` + slug String @id @unique + // The directory name for `/packages/app-store/[dirName]` + dirName String @unique + // Needed API Keys + keys Json? + // One or multiple categories to which this app belongs + categories AppCategories[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + credentials Credential[] + payments Payment[] + Webhook Webhook[] + ApiKey ApiKey[] + enabled Boolean @default(false) +} + +model App_RoutingForms_Form { + id String @id @default(cuid()) + description String? + routes Json? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + name String + fields Json? + user User @relation("routing-form", fields: [userId], references: [id], onDelete: Cascade) + userId Int + responses App_RoutingForms_FormResponse[] + disabled Boolean @default(false) + /// @zod.custom(imports.RoutingFormSettings) + settings Json? +} + +model App_RoutingForms_FormResponse { + id Int @id @default(autoincrement()) + formFillerId String @default(cuid()) + form App_RoutingForms_Form @relation(fields: [formId], references: [id], onDelete: Cascade) + formId String + response Json + createdAt DateTime @default(now()) + @@unique([formFillerId, formId]) +} + +model Feedback { + id Int @id @default(autoincrement()) + date DateTime @default(now()) + userId Int + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + rating String + comment String? +} + +enum WorkflowTriggerEvents { + BEFORE_EVENT + EVENT_CANCELLED + NEW_EVENT + AFTER_EVENT + RESCHEDULE_EVENT +} + +enum WorkflowActions { + EMAIL_HOST + EMAIL_ATTENDEE + SMS_ATTENDEE + SMS_NUMBER + EMAIL_ADDRESS +} + +model WorkflowStep { + id Int @id @default(autoincrement()) + stepNumber Int + action WorkflowActions + workflowId Int + workflow Workflow @relation(fields: [workflowId], references: [id], onDelete: Cascade) + sendTo String? + reminderBody String? + emailSubject String? + template WorkflowTemplates @default(REMINDER) + workflowReminders WorkflowReminder[] + numberRequired Boolean? + sender String? + numberVerificationPending Boolean @default(true) +} + +model Workflow { + id Int @id @default(autoincrement()) + name String + userId Int? + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade) + teamId Int? + activeOn WorkflowsOnEventTypes[] + trigger WorkflowTriggerEvents + time Int? + timeUnit TimeUnit? + steps WorkflowStep[] +} + +model WorkflowsOnEventTypes { + id Int @id @default(autoincrement()) + workflow Workflow @relation(fields: [workflowId], references: [id], onDelete: Cascade) + workflowId Int + eventType EventType @relation(fields: [eventTypeId], references: [id], onDelete: Cascade) + eventTypeId Int +} + +model Deployment { + /// This is a single row table, so we use a fixed id + id Int @id @default(1) + logo String? + /// @zod.custom(imports.DeploymentTheme) + theme Json? + licenseKey String? + agreedLicenseAt DateTime? +} + +enum TimeUnit { + DAY @map("day") + HOUR @map("hour") + MINUTE @map("minute") +} + +model WorkflowReminder { + id Int @id @default(autoincrement()) + bookingUid String + booking Booking? @relation(fields: [bookingUid], references: [uid], onDelete: Cascade) + method WorkflowMethods + scheduledDate DateTime + referenceId String? @unique + scheduled Boolean + workflowStepId Int + workflowStep WorkflowStep @relation(fields: [workflowStepId], references: [id], onDelete: Cascade) + cancelled Boolean? +} + +enum WorkflowTemplates { + REMINDER + CUSTOM +} + +enum WorkflowMethods { + EMAIL + SMS +} + +model VerifiedNumber { + id Int @id @default(autoincrement()) + userId Int? + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + teamId Int? + team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade) + phoneNumber String +} diff --git a/packages/schema/tests/schema/sample-todo.test.ts b/packages/schema/tests/schema/sample-todo.test.ts index 721103891..40387604c 100644 --- a/packages/schema/tests/schema/sample-todo.test.ts +++ b/packages/schema/tests/schema/sample-todo.test.ts @@ -2,8 +2,8 @@ import * as fs from 'fs'; import path from 'path'; import { loadModel } from '../utils'; -describe('Basic Tests', () => { - it('sample todo schema', async () => { +describe('Sample Todo Schema Tests', () => { + it('model loading', async () => { const content = fs.readFileSync(path.join(__dirname, './todo.zmodel'), { encoding: 'utf-8', }); diff --git a/packages/schema/tests/schema/validation/attribute-validation.test.ts b/packages/schema/tests/schema/validation/attribute-validation.test.ts index 309d2b5ad..f93cccec1 100644 --- a/packages/schema/tests/schema/validation/attribute-validation.test.ts +++ b/packages/schema/tests/schema/validation/attribute-validation.test.ts @@ -154,6 +154,15 @@ describe('Attribute tests', () => { }); it('model attribute coverage', async () => { + await loadModel(` + ${prelude} + model A { + x Int + y String + @@id([x, y], name: 'x_y', map: '_x_y') + } + `); + await loadModel(` ${prelude} model A { @@ -216,6 +225,111 @@ describe('Attribute tests', () => { `); }); + it('type modifier attribute coverage', async () => { + await loadModel(` + ${prelude} + + model _String { + _string String @db.String + _string1 String @db.String(1) + _text String @db.Text + _ntext String @db.NText + _char String @db.Char(10) + _nchar String @db.NChar(10) + _varchar String @db.VarChar(10) + _nvarChar String @db.NVarChar(10) + _catalogSingleChar String @db.CatalogSingleChar + _tinyText String @db.TinyText + _mediumText String @db.MediumText + _longText String @db.LongText + _bit String @db.Bit + _bit1 String @db.Bit(1) + _varbit String @db.VarBit + _varbit1 String @db.VarBit(1) + _uuid String @db.Uuid + _uniqueIdentifier String @db.UniqueIdentifier + _xml String @db.Xml + _inet String @db.Inet + _citext String @db.Citext + } + + model _Boolean { + _boolean Boolean @db.Boolean + _bit Boolean @db.Bit + _bit1 Boolean @db.Bit(1) + _tinyInt Boolean @db.TinyInt + _tinyInt1 Boolean @db.TinyInt(1) + } + + model _Int { + _int Int @db.Int + _integer Int @db.Integer + _smallInt Int @db.SmallInt + _oid Int @db.Oid + _unsignedInt Int @db.UnsignedInt + _unsignedSmallInt Int @db.UnsignedSmallInt + _mediumInt Int @db.MediumInt + _unsignedMediumInt Int @db.UnsignedMediumInt + _unsignedTinyInt Int @db.UnsignedTinyInt + _year Int @db.Year + _int4 Int @db.Int4 + _int2 Int @db.Int2 + } + + model _BigInt { + _bigInt BigInt @db.BigInt + _unsignedBigInt BigInt @db.UnsignedBigInt + _int8 BigInt @db.Int8 + } + + model _FloatDecimal { + _float Float @db.Float + _decimal Decimal @db.Decimal + _doublePrecision Float @db.DoublePrecision + _real Float @db.Real + _double Float @db.Double + _money Float @db.Money + _money1 Decimal @db.Money + _smallMoney Float @db.SmallMoney + _float8 Float @db.Float8 + _float4 Float @db.Float4 + } + + model _DateTime { + _dateTime DateTime @db.DateTime + _dateTime2 DateTime @db.DateTime2 + _smallDateTime DateTime @db.SmallDateTime + _dateTimeOffset DateTime @db.DateTimeOffset + _timestamp DateTime @db.Timestamp + _timestamp1 DateTime @db.Timestamp(1) + _timestamptz DateTime @db.Timestamptz + _timestamptz1 DateTime @db.Timestamptz(1) + _date DateTime @db.Date + _time DateTime @db.Time + _time1 DateTime @db.Time(1) + _timetz DateTime @db.Timetz + _timetz1 DateTime @db.Timetz(1) + } + + model _Json { + _json Json @db.Json + _jsonb Json @db.JsonB + } + + model _Bytes { + _bytes Bytes @db.Bytes + _byteA Bytes @db.ByteA + _longBlob Bytes @db.LongBlob + _binary Bytes @db.Binary + _varBinary Bytes @db.VarBinary + _tinyBlob Bytes @db.TinyBlob + _blob Bytes @db.Blob + _mediumBlob Bytes @db.MediumBlob + _image Bytes @db.Image + } + `); + }); + it('attribute function coverage', async () => { await loadModel(` ${prelude} diff --git a/packages/schema/tests/schema/validation/datamodel-validation.test.ts b/packages/schema/tests/schema/validation/datamodel-validation.test.ts index fbe7ca4e9..6e0c40b1d 100644 --- a/packages/schema/tests/schema/validation/datamodel-validation.test.ts +++ b/packages/schema/tests/schema/validation/datamodel-validation.test.ts @@ -101,7 +101,7 @@ describe('Data Model Validation Tests', () => { @@allow('all', x > 0) } `) - ).toContain(`Model must include a field with @id attribute`); + ).toContain(`Model must include a field with @id attribute or a model-level @@id attribute`); expect( await loadModelWithError(` @@ -111,7 +111,7 @@ describe('Data Model Validation Tests', () => { @@deny('all', x <= 0) } `) - ).toContain(`Model must include a field with @id attribute`); + ).toContain(`Model must include a field with @id attribute or a model-level @@id attribute`); expect( await loadModelWithError(` @@ -120,7 +120,7 @@ describe('Data Model Validation Tests', () => { x Int @gt(0) } `) - ).toContain(`Model must include a field with @id attribute`); + ).toContain(`Model must include a field with @id attribute or a model-level @@id attribute`); expect( await loadModelWithError(` @@ -132,6 +132,17 @@ describe('Data Model Validation Tests', () => { `) ).toContain(`Model can include at most one field with @id attribute`); + expect( + await loadModelWithError(` + ${prelude} + model M { + x Int @id + y Int + @@id([x, y]) + } + `) + ).toContain(`Model cannot have both field-level @id and model-level @@id attributes`); + expect( await loadModelWithError(` ${prelude} @@ -141,6 +152,16 @@ describe('Data Model Validation Tests', () => { `) ).toContain(`Field with @id attribute must not be optional`); + expect( + await loadModelWithError(` + ${prelude} + model M { + x Int? + @@id([x]) + } + `) + ).toContain(`Field with @id attribute must not be optional`); + expect( await loadModelWithError(` ${prelude} @@ -150,6 +171,16 @@ describe('Data Model Validation Tests', () => { `) ).toContain(`Field with @id attribute must be of scalar type`); + expect( + await loadModelWithError(` + ${prelude} + model M { + x Int[] + @@id([x]) + } + `) + ).toContain(`Field with @id attribute must be of scalar type`); + expect( await loadModelWithError(` ${prelude} @@ -159,6 +190,16 @@ describe('Data Model Validation Tests', () => { `) ).toContain(`Field with @id attribute must be of scalar type`); + expect( + await loadModelWithError(` + ${prelude} + model M { + x Json + @@id([x]) + } + `) + ).toContain(`Field with @id attribute must be of scalar type`); + expect( await loadModelWithError(` ${prelude} @@ -170,6 +211,19 @@ describe('Data Model Validation Tests', () => { } `) ).toContain(`Field with @id attribute must be of scalar type`); + + expect( + await loadModelWithError(` + ${prelude} + model Id { + id String @id + } + model M { + myId Id + @@id([myId]) + } + `) + ).toContain(`Field with @id attribute must be of scalar type`); }); it('relation', async () => { @@ -318,7 +372,9 @@ describe('Data Model Validation Tests', () => { aId String } `) - ).toContain(`Field "aId" is part of a one-to-one relation and must be marked as @unique`); + ).toContain( + `Field "aId" is part of a one-to-one relation and must be marked as @unique or be part of a model-level @@unique attribute` + ); // missing @relation expect( @@ -393,4 +449,93 @@ describe('Data Model Validation Tests', () => { } `); }); + + it('self relation', async () => { + // one-to-one + // https://www.prisma.io/docs/concepts/components/prisma-schema/relations/self-relations#one-to-one-self-relations + await loadModel(` + ${prelude} + model User { + id Int @id @default(autoincrement()) + name String? + successorId Int? @unique + successor User? @relation("BlogOwnerHistory", fields: [successorId], references: [id]) + predecessor User? @relation("BlogOwnerHistory") + } + `); + + // one-to-many + // https://www.prisma.io/docs/concepts/components/prisma-schema/relations/self-relations#one-to-many-self-relations + await loadModel(` + ${prelude} + model User { + id Int @id @default(autoincrement()) + name String? + teacherId Int? + teacher User? @relation("TeacherStudents", fields: [teacherId], references: [id]) + students User[] @relation("TeacherStudents") + } + `); + + // many-to-many + // https://www.prisma.io/docs/concepts/components/prisma-schema/relations/self-relations#many-to-many-self-relations + await loadModel(` + ${prelude} + model User { + id Int @id @default(autoincrement()) + name String? + followedBy User[] @relation("UserFollows") + following User[] @relation("UserFollows") + } + `); + + // many-to-many explicit + // https://www.prisma.io/docs/concepts/components/prisma-schema/relations/self-relations#many-to-many-self-relations + await loadModel(` + ${prelude} + model User { + id Int @id @default(autoincrement()) + name String? + followedBy Follows[] @relation("following") + following Follows[] @relation("follower") + } + + model Follows { + follower User @relation("follower", fields: [followerId], references: [id]) + followerId Int + following User @relation("following", fields: [followingId], references: [id]) + followingId Int + + @@id([followerId, followingId]) + } + `); + + await loadModel(` + ${prelude} + model User { + id Int @id + eventTypes EventType[] @relation("user_eventtype") + } + + model EventType { + id Int @id + users User[] @relation("user_eventtype") + } + `); + + // multiple self relations + // https://www.prisma.io/docs/concepts/components/prisma-schema/relations/self-relations#defining-multiple-self-relations-on-the-same-model + await loadModel(` + ${prelude} + model User { + id Int @id @default(autoincrement()) + name String? + teacherId Int? + teacher User? @relation("TeacherStudents", fields: [teacherId], references: [id]) + students User[] @relation("TeacherStudents") + followedBy User[] @relation("UserFollows") + following User[] @relation("UserFollows") + } + `); + }); }); diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 6e70a5d88..cc50ca25b 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/sdk", - "version": "1.0.0-alpha.55", + "version": "1.0.0-alpha.60", "description": "ZenStack plugin development SDK", "main": "index.js", "scripts": { diff --git a/packages/sdk/src/utils.ts b/packages/sdk/src/utils.ts index 095ba6c46..a1a61a652 100644 --- a/packages/sdk/src/utils.ts +++ b/packages/sdk/src/utils.ts @@ -55,3 +55,27 @@ export function getAttributeArgs(attr: DataModelAttribute | DataModelFieldAttrib } return result; } + +export function getAttributeArg( + attr: DataModelAttribute | DataModelFieldAttribute, + name: string +): Expression | undefined { + for (const arg of attr.args) { + if (arg.$resolvedParam?.name === name) { + return arg.value; + } + } + return undefined; +} + +export function getAttributeArgLiteral( + attr: DataModelAttribute | DataModelFieldAttribute, + name: string +): T | undefined { + for (const arg of attr.args) { + if (arg.$resolvedParam?.name === name) { + return getLiteral(arg.value); + } + } + return undefined; +} diff --git a/packages/testtools/package.json b/packages/testtools/package.json index c9afdabdd..ca9ffc57a 100644 --- a/packages/testtools/package.json +++ b/packages/testtools/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/testtools", - "version": "1.0.0-alpha.55", + "version": "1.0.0-alpha.60", "description": "ZenStack Test Tools", "main": "index.js", "publishConfig": { diff --git a/packages/testtools/src/schema.ts b/packages/testtools/src/schema.ts index d990c0e2e..25391f862 100644 --- a/packages/testtools/src/schema.ts +++ b/packages/testtools/src/schema.ts @@ -64,12 +64,12 @@ plugin policy { } `; -export async function loadSchemaFromFile(schemaFile: string) { +export async function loadSchemaFromFile(schemaFile: string, addPrelude = true, pushDb = true) { const content = fs.readFileSync(schemaFile, { encoding: 'utf-8' }); - return loadSchema(content); + return loadSchema(content, addPrelude, pushDb); } -export async function loadSchema(schema: string) { +export async function loadSchema(schema: string, addPrelude = true, pushDb = true) { const { name: workDir } = tmp.dirSync(); const root = getWorkspaceRoot(__dirname); @@ -87,10 +87,14 @@ export async function loadSchema(schema: string) { console.log('Workdir:', workDir); process.chdir(workDir); - fs.writeFileSync('schema.zmodel', `${MODEL_PRELUDE}\n${schema}`); + const content = addPrelude ? `${MODEL_PRELUDE}\n${schema}` : schema; + fs.writeFileSync('schema.zmodel', content); run('npm install'); run('npx zenstack generate --no-dependency-check'); - run('npx prisma db push'); + + if (pushDb) { + run('npx prisma db push'); + } const PrismaClient = require(path.join(workDir, '.prisma')).PrismaClient; const prisma = new PrismaClient({ log: ['info', 'warn', 'error'] }); diff --git a/tests/integration/test-run/package-lock.json b/tests/integration/test-run/package-lock.json index 74c9c5c8b..be0159d26 100644 --- a/tests/integration/test-run/package-lock.json +++ b/tests/integration/test-run/package-lock.json @@ -126,7 +126,7 @@ }, "../../../packages/runtime/dist": { "name": "@zenstackhq/runtime", - "version": "1.0.0-alpha.55", + "version": "1.0.0-alpha.60", "license": "MIT", "dependencies": { "@types/bcryptjs": "^2.4.2", @@ -156,7 +156,7 @@ }, "../../../packages/schema/dist": { "name": "zenstack", - "version": "1.0.8", + "version": "1.0.0-alpha.60", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/tests/integration/tests/e2e/todo-presets.test.ts b/tests/integration/tests/e2e/todo-presets.test.ts index c12238631..26b9afb73 100644 --- a/tests/integration/tests/e2e/todo-presets.test.ts +++ b/tests/integration/tests/e2e/todo-presets.test.ts @@ -9,7 +9,8 @@ describe('Todo Presets Tests', () => { beforeAll(async () => { const { withPresets, prisma: _prisma } = await loadSchemaFromFile( - path.join(__dirname, '../schema/todo.zmodel') + path.join(__dirname, '../schema/todo.zmodel'), + false ); getDb = withPresets; prisma = _prisma; diff --git a/tests/integration/tests/schema/cal-com.zmodel b/tests/integration/tests/schema/cal-com.zmodel new file mode 100644 index 000000000..26dea14a7 --- /dev/null +++ b/tests/integration/tests/schema/cal-com.zmodel @@ -0,0 +1,665 @@ +// This is your Prisma schema file, +// learn more about it in the docs: https://pris.ly/d/prisma-schema + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +generator client { + provider = "prisma-client-js" + output = '../.prisma' + previewFeatures = [] +} + +plugin meta { + provider = '@zenstack/model-meta' + output = '.zenstack' +} + +plugin policy { + provider = '@zenstack/access-policy' + output = '.zenstack' +} + +enum SchedulingType { + ROUND_ROBIN @map("roundRobin") + COLLECTIVE @map("collective") +} + +enum PeriodType { + UNLIMITED @map("unlimited") + ROLLING @map("rolling") + RANGE @map("range") +} + +model Host { + id Int @id @default(autoincrement()) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId Int + eventType EventType @relation(fields: [eventTypeId], references: [id], onDelete: Cascade) + eventTypeId Int + isFixed Boolean @default(false) +} + +model EventType { + id Int @id @default(autoincrement()) + /// @zod.min(1) + title String + /// @zod.custom(imports.eventTypeSlug) + slug String + description String? + position Int @default(0) + /// @zod.custom(imports.eventTypeLocations) + locations Json? + length Int + hidden Boolean @default(false) + hosts Host[] + users User[] @relation("user_eventtype") + owner User? @relation("owner", fields: [userId], references: [id], onDelete: Cascade) + userId Int? + team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade) + teamId Int? + hashedLink HashedLink? + bookings Booking[] + availability Availability[] + webhooks Webhook[] + destinationCalendar DestinationCalendar? + eventName String? + customInputs EventTypeCustomInput[] + /// @zod.custom(imports.eventTypeBookingFields) + bookingFields Json? + timeZone String? + periodType PeriodType @default(UNLIMITED) + periodStartDate DateTime? + periodEndDate DateTime? + periodDays Int? + periodCountCalendarDays Boolean? + requiresConfirmation Boolean @default(false) + /// @zod.custom(imports.recurringEventType) + recurringEvent Json? + disableGuests Boolean @default(false) + hideCalendarNotes Boolean @default(false) + minimumBookingNotice Int @default(120) + beforeEventBuffer Int @default(0) + afterEventBuffer Int @default(0) + seatsPerTimeSlot Int? + seatsShowAttendees Boolean? @default(false) + schedulingType SchedulingType? + schedule Schedule? @relation(fields: [scheduleId], references: [id]) + scheduleId Int? + // price is deprecated. It has now moved to metadata.apps.stripe.price. Plan to drop this column. + price Int @default(0) + // currency is deprecated. It has now moved to metadata.apps.stripe.currency. Plan to drop this column. + currency String @default("usd") + slotInterval Int? + /// @zod.custom(imports.EventTypeMetaDataSchema) + metadata Json? + /// @zod.custom(imports.successRedirectUrl) + successRedirectUrl String? + workflows WorkflowsOnEventTypes[] + /// @zod.custom(imports.bookingLimitsType) + bookingLimits Json? + @@unique([userId, slug]) + @@unique([teamId, slug]) +} + +model Credential { + id Int @id @default(autoincrement()) + // @@type is deprecated + type String + key Json + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + userId Int? + app App? @relation(fields: [appId], references: [slug], onDelete: Cascade) + // How to make it a required column? + appId String? + destinationCalendars DestinationCalendar[] + invalid Boolean? @default(false) +} + +enum IdentityProvider { + CAL + GOOGLE + SAML +} + +model DestinationCalendar { + id Int @id @default(autoincrement()) + integration String + externalId String + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + userId Int? @unique + booking Booking[] + eventType EventType? @relation(fields: [eventTypeId], references: [id], onDelete: Cascade) + eventTypeId Int? @unique + credentialId Int? + credential Credential? @relation(fields: [credentialId], references: [id], onDelete: Cascade) +} + +enum UserPermissionRole { + USER + ADMIN +} + +model User { + id Int @id @default(autoincrement()) + username String? @unique + name String? + /// @zod.email() + email String @unique + emailVerified DateTime? + password String? + bio String? + avatar String? + timeZone String @default("Europe/London") + weekStart String @default("Sunday") + // DEPRECATED - TO BE REMOVED + startTime Int @default(0) + endTime Int @default(1440) + // + bufferTime Int @default(0) + hideBranding Boolean @default(false) + theme String? + createdDate DateTime @default(now()) @map(name: "created") + trialEndsAt DateTime? + eventTypes EventType[] @relation("user_eventtype") + credentials Credential[] + teams Membership[] + bookings Booking[] + schedules Schedule[] + defaultScheduleId Int? + selectedCalendars SelectedCalendar[] + completedOnboarding Boolean @default(false) + locale String? + timeFormat Int? @default(12) + twoFactorSecret String? + twoFactorEnabled Boolean @default(false) + identityProvider IdentityProvider @default(CAL) + identityProviderId String? + availability Availability[] + invitedTo Int? + webhooks Webhook[] + brandColor String @default("#292929") + darkBrandColor String @default("#fafafa") + // the location where the events will end up + destinationCalendar DestinationCalendar? + away Boolean @default(false) + // participate in dynamic group booking or not + allowDynamicBooking Boolean? @default(true) + /// @zod.custom(imports.userMetadata) + metadata Json? + verified Boolean? @default(false) + role UserPermissionRole @default(USER) + disableImpersonation Boolean @default(false) + impersonatedUsers Impersonations[] @relation("impersonated_user") + impersonatedBy Impersonations[] @relation("impersonated_by_user") + apiKeys ApiKey[] + accounts Account[] + sessions Session[] + Feedback Feedback[] + ownedEventTypes EventType[] @relation("owner") + workflows Workflow[] + routingForms App_RoutingForms_Form[] @relation("routing-form") + verifiedNumbers VerifiedNumber[] + hosts Host[] + @@map(name: "users") +} + +model Team { + id Int @id @default(autoincrement()) + /// @zod.min(1) + name String + /// @zod.min(1) + slug String? @unique + logo String? + bio String? + hideBranding Boolean @default(false) + hideBookATeamMember Boolean @default(false) + members Membership[] + eventTypes EventType[] + workflows Workflow[] + createdAt DateTime @default(now()) + /// @zod.custom(imports.teamMetadataSchema) + metadata Json? + theme String? + brandColor String @default("#292929") + darkBrandColor String @default("#fafafa") + verifiedNumbers VerifiedNumber[] +} + +enum MembershipRole { + MEMBER + ADMIN + OWNER +} + +model Membership { + teamId Int + userId Int + accepted Boolean @default(false) + role MembershipRole + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + disableImpersonation Boolean @default(false) + @@id([userId, teamId]) +} + +model VerificationToken { + id Int @id @default(autoincrement()) + identifier String + token String @unique + expires DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + @@unique([identifier, token]) +} + +model BookingReference { + id Int @id @default(autoincrement()) + /// @zod.min(1) + type String + /// @zod.min(1) + uid String + meetingId String? + meetingPassword String? + meetingUrl String? + booking Booking? @relation(fields: [bookingId], references: [id], onDelete: Cascade) + bookingId Int? + externalCalendarId String? + deleted Boolean? + credentialId Int? +} + +model Attendee { + id Int @id @default(autoincrement()) + email String + name String + timeZone String + locale String? @default("en") + booking Booking? @relation(fields: [bookingId], references: [id]) + bookingId Int? +} + +enum BookingStatus { + CANCELLED @map("cancelled") + ACCEPTED @map("accepted") + REJECTED @map("rejected") + PENDING @map("pending") +} + +model Booking { + id Int @id @default(autoincrement()) + uid String @unique + user User? @relation(fields: [userId], references: [id]) + userId Int? + references BookingReference[] + eventType EventType? @relation(fields: [eventTypeId], references: [id]) + eventTypeId Int? + title String + description String? + customInputs Json? + /// @zod.custom(imports.bookingResponses) + responses Json? + startTime DateTime + endTime DateTime + attendees Attendee[] + location String? + createdAt DateTime @default(now()) + updatedAt DateTime? + status BookingStatus @default(ACCEPTED) + paid Boolean @default(false) + payment Payment[] + destinationCalendar DestinationCalendar? @relation(fields: [destinationCalendarId], references: [id]) + destinationCalendarId Int? + cancellationReason String? + rejectionReason String? + dynamicEventSlugRef String? + dynamicGroupSlugRef String? + rescheduled Boolean? + fromReschedule String? + recurringEventId String? + smsReminderNumber String? + workflowReminders WorkflowReminder[] + scheduledJobs String[] + /// @zod.custom(imports.bookingMetadataSchema) + metadata Json? +} + +model Schedule { + id Int @id @default(autoincrement()) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId Int + eventType EventType[] + name String + timeZone String? + availability Availability[] + @@index([userId]) +} + +model Availability { + id Int @id @default(autoincrement()) + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + userId Int? + eventType EventType? @relation(fields: [eventTypeId], references: [id]) + eventTypeId Int? + days Int[] + startTime DateTime @db.Time + endTime DateTime @db.Time + date DateTime? @db.Date + Schedule Schedule? @relation(fields: [scheduleId], references: [id]) + scheduleId Int? + @@index([eventTypeId]) + @@index([scheduleId]) +} + +model SelectedCalendar { + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId Int + integration String + externalId String + @@id([userId, integration, externalId]) +} + +enum EventTypeCustomInputType { + TEXT @map("text") + TEXTLONG @map("textLong") + NUMBER @map("number") + BOOL @map("bool") + RADIO @map("radio") + PHONE @map("phone") +} + +model EventTypeCustomInput { + id Int @id @default(autoincrement()) + eventTypeId Int + eventType EventType @relation(fields: [eventTypeId], references: [id], onDelete: Cascade) + label String + type EventTypeCustomInputType + /// @zod.custom(imports.customInputOptionSchema) + options Json? + required Boolean + placeholder String @default("") +} + +model ResetPasswordRequest { + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + email String + expires DateTime +} + +enum ReminderType { + PENDING_BOOKING_CONFIRMATION +} + +model ReminderMail { + id Int @id @default(autoincrement()) + referenceId Int + reminderType ReminderType + elapsedMinutes Int + createdAt DateTime @default(now()) +} + +model Payment { + id Int @id @default(autoincrement()) + uid String @unique + app App? @relation(fields: [appId], references: [slug], onDelete: Cascade) + appId String? + bookingId Int + booking Booking? @relation(fields: [bookingId], references: [id], onDelete: Cascade) + amount Int + fee Int + currency String + success Boolean + refunded Boolean + data Json + externalId String @unique +} + +enum WebhookTriggerEvents { + BOOKING_CREATED + BOOKING_RESCHEDULED + BOOKING_CANCELLED + FORM_SUBMITTED + MEETING_ENDED +} + +model Webhook { + id String @id @unique + userId Int? + eventTypeId Int? + /// @zod.url() + subscriberUrl String + payloadTemplate String? + createdAt DateTime @default(now()) + active Boolean @default(true) + eventTriggers WebhookTriggerEvents[] + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + eventType EventType? @relation(fields: [eventTypeId], references: [id], onDelete: Cascade) + app App? @relation(fields: [appId], references: [slug], onDelete: Cascade) + appId String? + secret String? + @@unique([userId, subscriberUrl], name: "courseIdentifier") +} + +model Impersonations { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) + impersonatedUser User @relation("impersonated_user", fields: [impersonatedUserId], references: [id], onDelete: Cascade) + impersonatedBy User @relation("impersonated_by_user", fields: [impersonatedById], references: [id], onDelete: Cascade) + impersonatedUserId Int + impersonatedById Int +} + +model ApiKey { + id String @id @unique @default(cuid()) + userId Int + note String? + createdAt DateTime @default(now()) + expiresAt DateTime? + lastUsedAt DateTime? + hashedKey String @unique() + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + app App? @relation(fields: [appId], references: [slug], onDelete: Cascade) + appId String? +} + +model HashedLink { + id Int @id @default(autoincrement()) + link String @unique() + eventType EventType @relation(fields: [eventTypeId], references: [id], onDelete: Cascade) + eventTypeId Int @unique +} + +model Account { + id String @id @default(cuid()) + userId Int + type String + provider String + providerAccountId String + refresh_token String? @db.Text + access_token String? @db.Text + expires_at Int? + token_type String? + scope String? + id_token String? @db.Text + session_state String? + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + @@unique([provider, providerAccountId]) +} + +model Session { + id String @id @default(cuid()) + sessionToken String @unique + userId Int + expires DateTime + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) +} + +enum AppCategories { + calendar + messaging + other + payment + video + web3 + automation + analytics +} + +model App { + // The slug for the app store public page inside `/apps/[slug]` + slug String @id @unique + // The directory name for `/packages/app-store/[dirName]` + dirName String @unique + // Needed API Keys + keys Json? + // One or multiple categories to which this app belongs + categories AppCategories[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + credentials Credential[] + payments Payment[] + Webhook Webhook[] + ApiKey ApiKey[] + enabled Boolean @default(false) +} + +model App_RoutingForms_Form { + id String @id @default(cuid()) + description String? + routes Json? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + name String + fields Json? + user User @relation("routing-form", fields: [userId], references: [id], onDelete: Cascade) + userId Int + responses App_RoutingForms_FormResponse[] + disabled Boolean @default(false) + /// @zod.custom(imports.RoutingFormSettings) + settings Json? +} + +model App_RoutingForms_FormResponse { + id Int @id @default(autoincrement()) + formFillerId String @default(cuid()) + form App_RoutingForms_Form @relation(fields: [formId], references: [id], onDelete: Cascade) + formId String + response Json + createdAt DateTime @default(now()) + @@unique([formFillerId, formId]) +} + +model Feedback { + id Int @id @default(autoincrement()) + date DateTime @default(now()) + userId Int + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + rating String + comment String? +} + +enum WorkflowTriggerEvents { + BEFORE_EVENT + EVENT_CANCELLED + NEW_EVENT + AFTER_EVENT + RESCHEDULE_EVENT +} + +enum WorkflowActions { + EMAIL_HOST + EMAIL_ATTENDEE + SMS_ATTENDEE + SMS_NUMBER + EMAIL_ADDRESS +} + +model WorkflowStep { + id Int @id @default(autoincrement()) + stepNumber Int + action WorkflowActions + workflowId Int + workflow Workflow @relation(fields: [workflowId], references: [id], onDelete: Cascade) + sendTo String? + reminderBody String? + emailSubject String? + template WorkflowTemplates @default(REMINDER) + workflowReminders WorkflowReminder[] + numberRequired Boolean? + sender String? + numberVerificationPending Boolean @default(true) +} + +model Workflow { + id Int @id @default(autoincrement()) + name String + userId Int? + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade) + teamId Int? + activeOn WorkflowsOnEventTypes[] + trigger WorkflowTriggerEvents + time Int? + timeUnit TimeUnit? + steps WorkflowStep[] +} + +model WorkflowsOnEventTypes { + id Int @id @default(autoincrement()) + workflow Workflow @relation(fields: [workflowId], references: [id], onDelete: Cascade) + workflowId Int + eventType EventType @relation(fields: [eventTypeId], references: [id], onDelete: Cascade) + eventTypeId Int +} + +model Deployment { + /// This is a single row table, so we use a fixed id + id Int @id @default(1) + logo String? + /// @zod.custom(imports.DeploymentTheme) + theme Json? + licenseKey String? + agreedLicenseAt DateTime? +} + +enum TimeUnit { + DAY @map("day") + HOUR @map("hour") + MINUTE @map("minute") +} + +model WorkflowReminder { + id Int @id @default(autoincrement()) + bookingUid String + booking Booking? @relation(fields: [bookingUid], references: [uid], onDelete: Cascade) + method WorkflowMethods + scheduledDate DateTime + referenceId String? @unique + scheduled Boolean + workflowStepId Int + workflowStep WorkflowStep @relation(fields: [workflowStepId], references: [id], onDelete: Cascade) + cancelled Boolean? +} + +enum WorkflowTemplates { + REMINDER + CUSTOM +} + +enum WorkflowMethods { + EMAIL + SMS +} + +model VerifiedNumber { + id Int @id @default(autoincrement()) + userId Int? + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + teamId Int? + team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade) + phoneNumber String +} diff --git a/tests/integration/tests/schema/todo.zmodel b/tests/integration/tests/schema/todo.zmodel index d56717eb8..313cb04ac 100644 --- a/tests/integration/tests/schema/todo.zmodel +++ b/tests/integration/tests/schema/todo.zmodel @@ -2,6 +2,27 @@ * Sample model for a collaborative Todo app */ +datasource db { + provider = 'sqlite' + url = 'file:./test.db' +} + +generator js { + provider = 'prisma-client-js' + output = '../.prisma' + previewFeatures = ['clientExtensions'] +} + +plugin meta { + provider = '@zenstack/model-meta' + output = '.zenstack' +} + +plugin policy { + provider = '@zenstack/access-policy' + output = '.zenstack' +} + /* * Model for a space in which users can collaborate on Lists and Todos */ diff --git a/tests/integration/tests/with-policy/cal-com-sample.test.ts b/tests/integration/tests/with-policy/cal-com-sample.test.ts new file mode 100644 index 000000000..2aa94e4c9 --- /dev/null +++ b/tests/integration/tests/with-policy/cal-com-sample.test.ts @@ -0,0 +1,8 @@ +import { loadSchemaFromFile } from '@zenstackhq/testtools'; +import path from 'path'; + +describe('Cal.com Sample Integration Tests', () => { + it('model loading', async () => { + await loadSchemaFromFile(path.join(__dirname, '../schema/cal-com.zmodel'), false, false); + }); +}); diff --git a/tests/integration/tests/with-policy/multi-id-fields.test.ts b/tests/integration/tests/with-policy/multi-id-fields.test.ts new file mode 100644 index 000000000..f9984f98f --- /dev/null +++ b/tests/integration/tests/with-policy/multi-id-fields.test.ts @@ -0,0 +1,71 @@ +import { loadSchema } from '@zenstackhq/testtools'; +import path from 'path'; + +describe('With Policy: multiple id fields', () => { + let origDir: string; + + beforeAll(async () => { + origDir = path.resolve('.'); + }); + + afterEach(() => { + process.chdir(origDir); + }); + + it('multi-id fields', async () => { + const { prisma, withPolicy } = await loadSchema( + ` + model A { + x String + y Int + value Int + b B? + @@id([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]) + @@id([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/relation-to-one-filter.test.ts b/tests/integration/tests/with-policy/relation-to-one-filter.test.ts index 0eacdf38c..d305a33d4 100644 --- a/tests/integration/tests/with-policy/relation-to-one-filter.test.ts +++ b/tests/integration/tests/with-policy/relation-to-one-filter.test.ts @@ -3,7 +3,6 @@ import path from 'path'; describe('With Policy: relation to-one filter', () => { let origDir: string; - const suite = 'relation-to-one-filter'; beforeAll(async () => { origDir = path.resolve('.'); diff --git a/tests/integration/tests/with-policy/self-relation.test.ts b/tests/integration/tests/with-policy/self-relation.test.ts new file mode 100644 index 000000000..dc7cb96ca --- /dev/null +++ b/tests/integration/tests/with-policy/self-relation.test.ts @@ -0,0 +1,214 @@ +import { loadSchema } from '@zenstackhq/testtools'; +import path from 'path'; + +describe('With Policy: self relations', () => { + let origDir: string; + + beforeAll(async () => { + origDir = path.resolve('.'); + }); + + afterEach(() => { + process.chdir(origDir); + }); + + it('one-to-one', async () => { + const { withPolicy } = await loadSchema( + ` + model User { + id Int @id @default(autoincrement()) + value Int + successorId Int? @unique + successor User? @relation("BlogOwnerHistory", fields: [successorId], references: [id]) + predecessor User? @relation("BlogOwnerHistory") + + @@allow('create', value > 0) + @@allow('read', true) + } + ` + ); + + const db = withPolicy(); + + // create denied + await expect( + db.user.create({ + data: { + value: 0, + }, + }) + ).toBeRejectedByPolicy(); + + await expect( + db.user.create({ + data: { + value: 1, + successor: { + create: { + value: 0, + }, + }, + }, + }) + ).toBeRejectedByPolicy(); + + await expect( + db.user.create({ + data: { + value: 1, + successor: { + create: { + value: 1, + }, + }, + predecessor: { + create: { + value: 0, + }, + }, + }, + }) + ).toBeRejectedByPolicy(); + + await expect( + db.user.create({ + data: { + value: 1, + successor: { + create: { + value: 1, + }, + }, + predecessor: { + create: { + value: 1, + }, + }, + }, + }) + ).toResolveTruthy(); + }); + + it('one-to-many', async () => { + const { withPolicy } = await loadSchema( + ` + model User { + id Int @id @default(autoincrement()) + value Int + teacherId Int? + teacher User? @relation("TeacherStudents", fields: [teacherId], references: [id]) + students User[] @relation("TeacherStudents") + + @@allow('create', value > 0) + @@allow('read', true) + } + ` + ); + + const db = withPolicy(); + + // create denied + await expect( + db.user.create({ + data: { + value: 0, + }, + }) + ).toBeRejectedByPolicy(); + + await expect( + db.user.create({ + data: { + value: 1, + teacher: { + create: { value: 0 }, + }, + }, + }) + ).toBeRejectedByPolicy(); + + await expect( + db.user.create({ + data: { + value: 1, + teacher: { + create: { value: 1 }, + }, + students: { + create: [{ value: 0 }, { value: 1 }], + }, + }, + }) + ).toBeRejectedByPolicy(); + + await expect( + db.user.create({ + data: { + value: 1, + teacher: { + create: { value: 1 }, + }, + students: { + create: [{ value: 1 }, { value: 2 }], + }, + }, + }) + ).toResolveTruthy(); + }); + + it('many-to-many', async () => { + const { withPolicy } = await loadSchema( + ` + model User { + id Int @id @default(autoincrement()) + value Int + followedBy User[] @relation("UserFollows") + following User[] @relation("UserFollows") + + @@allow('create', value > 0) + @@allow('read', true) + } + ` + ); + + const db = withPolicy(); + + // create denied + await expect( + db.user.create({ + data: { + value: 0, + }, + }) + ).toBeRejectedByPolicy(); + + await expect( + db.user.create({ + data: { + value: 1, + followedBy: { create: { value: 0 } }, + }, + }) + ).toBeRejectedByPolicy(); + + await expect( + db.user.create({ + data: { + value: 1, + followedBy: { create: { value: 1 } }, + following: { create: [{ value: 0 }, { value: 1 }] }, + }, + }) + ).toBeRejectedByPolicy(); + + await expect( + db.user.create({ + data: { + value: 1, + followedBy: { create: { value: 1 } }, + following: { create: [{ value: 1 }, { value: 2 }] }, + }, + }) + ).toResolveTruthy(); + }); +}); diff --git a/tests/integration/tests/with-policy/todo-sample.test.ts b/tests/integration/tests/with-policy/todo-sample.test.ts index 6c34f1079..4b61e20a8 100644 --- a/tests/integration/tests/with-policy/todo-sample.test.ts +++ b/tests/integration/tests/with-policy/todo-sample.test.ts @@ -7,7 +7,10 @@ describe('Todo Policy Tests', () => { let prisma: WeakDbClientContract; beforeAll(async () => { - const { withPolicy, prisma: _prisma } = await loadSchemaFromFile(path.join(__dirname, '../schema/todo.zmodel')); + const { withPolicy, prisma: _prisma } = await loadSchemaFromFile( + path.join(__dirname, '../schema/todo.zmodel'), + false + ); getDb = withPolicy; prisma = _prisma; });