diff --git a/packages/plugins/openapi/src/rpc-generator.ts b/packages/plugins/openapi/src/rpc-generator.ts index e80570e53..24cb41f94 100644 --- a/packages/plugins/openapi/src/rpc-generator.ts +++ b/packages/plugins/openapi/src/rpc-generator.ts @@ -399,6 +399,13 @@ export class RPCOpenAPIGenerator extends OpenAPIGeneratorBase { security: read === true ? [] : undefined, }); + // OrderByWithRelationInput's name is different when "fullTextSearch" is enabled + const orderByWithRelationInput = this.inputObjectTypes + .map((o) => upperCaseFirst(o.name)) + .includes(`${modelName}OrderByWithRelationInput`) + ? `${modelName}OrderByWithRelationInput` + : `${modelName}OrderByWithRelationAndSearchRelevanceInput`; + if (ops['aggregate']) { definitions.push({ method: 'get', @@ -409,7 +416,7 @@ export class RPCOpenAPIGenerator extends OpenAPIGeneratorBase { type: 'object', properties: { where: this.ref(`${modelName}WhereInput`), - orderBy: this.ref(`${modelName}OrderByWithRelationInput`), + orderBy: this.ref(orderByWithRelationInput), cursor: this.ref(`${modelName}WhereUniqueInput`), take: { type: 'integer' }, skip: { type: 'integer' }, @@ -435,7 +442,7 @@ export class RPCOpenAPIGenerator extends OpenAPIGeneratorBase { type: 'object', properties: { where: this.ref(`${modelName}WhereInput`), - orderBy: this.ref(`${modelName}OrderByWithRelationInput`), + orderBy: this.ref(orderByWithRelationInput), by: this.ref(`${modelName}ScalarFieldEnum`), having: this.ref(`${modelName}ScalarWhereWithAggregatesInput`), take: { type: 'integer' }, diff --git a/packages/plugins/openapi/tests/openapi-rpc.test.ts b/packages/plugins/openapi/tests/openapi-rpc.test.ts index cb9202863..bff96ed33 100644 --- a/packages/plugins/openapi/tests/openapi-rpc.test.ts +++ b/packages/plugins/openapi/tests/openapi-rpc.test.ts @@ -376,6 +376,53 @@ model Foo { const baseline = YAML.parse(fs.readFileSync(`${__dirname}/baseline/rpc-type-coverage.baseline.yaml`, 'utf-8')); expect(parsed).toMatchObject(baseline); }); + + it('full-text search', async () => { + const { model, dmmf, modelFile } = await loadZModelAndDmmf(` +generator js { + provider = 'prisma-client-js' + previewFeatures = ['fullTextSearch'] +} + +plugin openapi { + provider = '${process.cwd()}/dist' +} + +enum role { + USER + ADMIN +} + +model User { + id String @id + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + email String @unique + role role @default(USER) + posts post_Item[] +} + +model post_Item { + id String @id + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + title String + author User? @relation(fields: [authorId], references: [id]) + authorId String? + published Boolean @default(false) + viewCount Int @default(0) +} + `); + + const { name: output } = tmp.fileSync({ postfix: '.yaml' }); + + const options = buildOptions(model, modelFile, output); + await generate(model, options, dmmf); + + console.log('OpenAPI specification generated:', output); + + await OpenAPIParser.validate(output); + }); }); function buildOptions(model: Model, modelFile: string, output: string) { diff --git a/packages/schema/src/plugins/zod/generator.ts b/packages/schema/src/plugins/zod/generator.ts index 49b5f9326..33557de9d 100644 --- a/packages/schema/src/plugins/zod/generator.ts +++ b/packages/schema/src/plugins/zod/generator.ts @@ -81,6 +81,7 @@ export async function generate(model: Model, options: PluginOptions, dmmf: DMMF. aggregateOperationSupport, project, zmodel: model, + inputObjectTypes, }); await transformer.generateInputSchemas(); } @@ -149,6 +150,7 @@ async function generateEnumSchemas( enumTypes, project, zmodel, + inputObjectTypes: [], }); await transformer.generateEnumSchemas(); } @@ -163,7 +165,7 @@ async function generateObjectSchemas( for (let i = 0; i < inputObjectTypes.length; i += 1) { const fields = inputObjectTypes[i]?.fields; const name = inputObjectTypes[i]?.name; - const transformer = new Transformer({ name, fields, project, zmodel }); + const transformer = new Transformer({ name, fields, project, zmodel, inputObjectTypes }); const moduleName = transformer.generateObjectSchema(); moduleNames.push(moduleName); } diff --git a/packages/schema/src/plugins/zod/transformer.ts b/packages/schema/src/plugins/zod/transformer.ts index a05f6d27d..ea3b76afb 100644 --- a/packages/schema/src/plugins/zod/transformer.ts +++ b/packages/schema/src/plugins/zod/transformer.ts @@ -1,9 +1,9 @@ /* eslint-disable @typescript-eslint/ban-ts-comment */ -import type { DMMF as PrismaDMMF } from '@prisma/generator-helper'; +import type { DMMF, DMMF as PrismaDMMF } from '@prisma/generator-helper'; import { Model } from '@zenstackhq/language/ast'; import { AUXILIARY_FIELDS, getPrismaClientImportSpec, getPrismaVersion } from '@zenstackhq/sdk'; import { checkModelHasModelRelation, findModelByName, isAggregateInputType } from '@zenstackhq/sdk/dmmf-helpers'; -import indentString from '@zenstackhq/sdk/utils'; +import { indentString } from '@zenstackhq/sdk/utils'; import path from 'path'; import * as semver from 'semver'; import { Project } from 'ts-morph'; @@ -28,6 +28,7 @@ export default class Transformer { private hasDecimal = false; private project: Project; private zmodel: Model; + private inputObjectTypes: DMMF.InputType[]; constructor(params: TransformerParams) { this.originalName = params.name ?? ''; @@ -39,6 +40,7 @@ export default class Transformer { this.enumTypes = params.enumTypes ?? []; this.project = params.project; this.zmodel = params.zmodel; + this.inputObjectTypes = params.inputObjectTypes; } static setOutputPath(outPath: string) { @@ -420,6 +422,13 @@ export const ${this.name}ObjectSchema: SchemaType = ${schema} as SchemaType;`; let codeBody = ''; const operations: [string, string][] = []; + // OrderByWithRelationInput's name is different when "fullTextSearch" is enabled + const orderByWithRelationInput = this.inputObjectTypes + .map((o) => upperCaseFirst(o.name)) + .includes(`${modelName}OrderByWithRelationInput`) + ? `${modelName}OrderByWithRelationInput` + : `${modelName}OrderByWithRelationAndSearchRelevanceInput`; + if (findUnique) { imports.push( `import { ${modelName}WhereUniqueInputObjectSchema } from '../objects/${modelName}WhereUniqueInput.schema'` @@ -431,22 +440,22 @@ export const ${this.name}ObjectSchema: SchemaType = ${schema} as SchemaType;`; if (findFirst) { imports.push( `import { ${modelName}WhereInputObjectSchema } from '../objects/${modelName}WhereInput.schema'`, - `import { ${modelName}OrderByWithRelationInputObjectSchema } from '../objects/${modelName}OrderByWithRelationInput.schema'`, + `import { ${orderByWithRelationInput}ObjectSchema } from '../objects/${orderByWithRelationInput}.schema'`, `import { ${modelName}WhereUniqueInputObjectSchema } from '../objects/${modelName}WhereUniqueInput.schema'`, `import { ${modelName}ScalarFieldEnumSchema } from '../enums/${modelName}ScalarFieldEnum.schema'` ); - codeBody += `findFirst: z.object({ ${selectZodSchemaLineLazy} ${includeZodSchemaLineLazy} where: ${modelName}WhereInputObjectSchema.optional(), orderBy: z.union([${modelName}OrderByWithRelationInputObjectSchema, ${modelName}OrderByWithRelationInputObjectSchema.array()]).optional(), cursor: ${modelName}WhereUniqueInputObjectSchema.optional(), take: z.number().optional(), skip: z.number().optional(), distinct: z.array(${modelName}ScalarFieldEnumSchema).optional() }),`; + codeBody += `findFirst: z.object({ ${selectZodSchemaLineLazy} ${includeZodSchemaLineLazy} where: ${modelName}WhereInputObjectSchema.optional(), orderBy: z.union([${orderByWithRelationInput}ObjectSchema, ${orderByWithRelationInput}ObjectSchema.array()]).optional(), cursor: ${modelName}WhereUniqueInputObjectSchema.optional(), take: z.number().optional(), skip: z.number().optional(), distinct: z.array(${modelName}ScalarFieldEnumSchema).optional() }),`; operations.push(['findFirst', origModelName]); } if (findMany) { imports.push( `import { ${modelName}WhereInputObjectSchema } from '../objects/${modelName}WhereInput.schema'`, - `import { ${modelName}OrderByWithRelationInputObjectSchema } from '../objects/${modelName}OrderByWithRelationInput.schema'`, + `import { ${orderByWithRelationInput}ObjectSchema } from '../objects/${orderByWithRelationInput}.schema'`, `import { ${modelName}WhereUniqueInputObjectSchema } from '../objects/${modelName}WhereUniqueInput.schema'`, `import { ${modelName}ScalarFieldEnumSchema } from '../enums/${modelName}ScalarFieldEnum.schema'` ); - codeBody += `findMany: z.object({ ${selectZodSchemaLineLazy} ${includeZodSchemaLineLazy} where: ${modelName}WhereInputObjectSchema.optional(), orderBy: z.union([${modelName}OrderByWithRelationInputObjectSchema, ${modelName}OrderByWithRelationInputObjectSchema.array()]).optional(), cursor: ${modelName}WhereUniqueInputObjectSchema.optional(), take: z.number().optional(), skip: z.number().optional(), distinct: z.array(${modelName}ScalarFieldEnumSchema).optional() }),`; + codeBody += `findMany: z.object({ ${selectZodSchemaLineLazy} ${includeZodSchemaLineLazy} where: ${modelName}WhereInputObjectSchema.optional(), orderBy: z.union([${orderByWithRelationInput}ObjectSchema, ${orderByWithRelationInput}ObjectSchema.array()]).optional(), cursor: ${modelName}WhereUniqueInputObjectSchema.optional(), take: z.number().optional(), skip: z.number().optional(), distinct: z.array(${modelName}ScalarFieldEnumSchema).optional() }),`; operations.push(['findMany', origModelName]); } @@ -557,11 +566,11 @@ export const ${this.name}ObjectSchema: SchemaType = ${schema} as SchemaType;`; if (aggregate) { imports.push( `import { ${modelName}WhereInputObjectSchema } from '../objects/${modelName}WhereInput.schema'`, - `import { ${modelName}OrderByWithRelationInputObjectSchema } from '../objects/${modelName}OrderByWithRelationInput.schema'`, + `import { ${orderByWithRelationInput}ObjectSchema } from '../objects/${orderByWithRelationInput}.schema'`, `import { ${modelName}WhereUniqueInputObjectSchema } from '../objects/${modelName}WhereUniqueInput.schema'` ); - codeBody += `aggregate: z.object({ where: ${modelName}WhereInputObjectSchema.optional(), orderBy: z.union([${modelName}OrderByWithRelationInputObjectSchema, ${modelName}OrderByWithRelationInputObjectSchema.array()]).optional(), cursor: ${modelName}WhereUniqueInputObjectSchema.optional(), take: z.number().optional(), skip: z.number().optional(), ${aggregateOperations.join( + codeBody += `aggregate: z.object({ where: ${modelName}WhereInputObjectSchema.optional(), orderBy: z.union([${orderByWithRelationInput}ObjectSchema, ${orderByWithRelationInput}ObjectSchema.array()]).optional(), cursor: ${modelName}WhereUniqueInputObjectSchema.optional(), take: z.number().optional(), skip: z.number().optional(), ${aggregateOperations.join( ', ' )} }),`; operations.push(['aggregate', modelName]); diff --git a/packages/schema/src/plugins/zod/types.ts b/packages/schema/src/plugins/zod/types.ts index 33a377a29..72564c7ef 100644 --- a/packages/schema/src/plugins/zod/types.ts +++ b/packages/schema/src/plugins/zod/types.ts @@ -1,4 +1,4 @@ -import { DMMF as PrismaDMMF } from '@prisma/generator-helper'; +import { DMMF, DMMF as PrismaDMMF } from '@prisma/generator-helper'; import { Model } from '@zenstackhq/language/ast'; import { Project } from 'ts-morph'; @@ -13,6 +13,7 @@ export type TransformerParams = { prismaClientOutputPath?: string; project: Project; zmodel: Model; + inputObjectTypes: DMMF.InputType[]; }; export type AggregateOperationSupport = { diff --git a/packages/schema/src/res/stdlib.zmodel b/packages/schema/src/res/stdlib.zmodel index 84431274c..57cca4f49 100644 --- a/packages/schema/src/res/stdlib.zmodel +++ b/packages/schema/src/res/stdlib.zmodel @@ -239,12 +239,12 @@ attribute @map(_ name: String) @@@prisma attribute @@map(_ name: String) @@@prisma /** -* Exclude a field from the Prisma Client (for example, a field that you do not want Prisma users to update). + * Exclude a field from the Prisma Client (for example, a field that you do not want Prisma users to update). */ attribute @ignore() @@@prisma /** -* Exclude a model from the Prisma Client (for example, a model that you do not want Prisma users to update). + * Exclude a model from the Prisma Client (for example, a model that you do not want Prisma users to update). */ attribute @@ignore() @@@prisma @@ -253,6 +253,12 @@ attribute @@ignore() @@@prisma */ attribute @updatedAt() @@@targetField([DateTimeField]) @@@prisma +/** + * Add full text index (MySQL only). + */ +attribute @@fulltext(_ fields: FieldReference[]) @@@prisma + + // String type modifiers attribute @db.String(_ x: Int?) @@@targetField([StringField]) @@@prisma @@ -352,7 +358,7 @@ attribute @@schema(_ name: String) @@@prisma attribute @@allow(_ operation: String, _ condition: Boolean) /** - * Defines an access policy that allows a set of operations when the given condition is true. + * Defines an access policy that allows the annotated field to be read or updated. */ attribute @allow(_ operation: String, _ condition: Boolean) @@ -362,7 +368,7 @@ attribute @allow(_ operation: String, _ condition: Boolean) attribute @@deny(_ operation: String, _ condition: Boolean) /** - * Defines an access policy that denies a set of operations when the given condition is true. + * Defines an access policy that denies the annotated field to be read or updated. */ attribute @deny(_ operation: String, _ condition: Boolean) diff --git a/packages/sdk/src/utils.ts b/packages/sdk/src/utils.ts index fe0e37b4c..7b3ae141a 100644 --- a/packages/sdk/src/utils.ts +++ b/packages/sdk/src/utils.ts @@ -8,11 +8,13 @@ import { EnumField, Expression, FunctionDecl, + GeneratorDecl, InternalAttribute, isArrayExpr, isDataModel, isDataModelField, isEnumField, + isGeneratorDecl, isInvocationExpr, isLiteralExpr, isModel, @@ -88,7 +90,7 @@ export function getObjectLiteral(expr: Expression | undefined): T | undefined return result as T; } -export default function indentString(string: string, count = 4): string { +export function indentString(string: string, count = 4): string { const indent = ' '; return string.replace(/^(?!\s*$)/gm, indent.repeat(count)); } @@ -298,3 +300,20 @@ export function getContainingModel(node: AstNode | undefined): Model | null { } return isModel(node) ? node : getContainingModel(node.$container); } + +export function getPreviewFeatures(model: Model) { + const jsGenerator = model.declarations.find( + (d) => + isGeneratorDecl(d) && + d.fields.some((f) => f.name === 'provider' && getLiteral(f.value) === 'prisma-client-js') + ) as GeneratorDecl | undefined; + + if (jsGenerator) { + const previewFeaturesField = jsGenerator.fields.find((f) => f.name === 'previewFeatures'); + if (previewFeaturesField) { + return getLiteralArray(previewFeaturesField.value); + } + } + + return [] as string[]; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 39e590778..a99834b76 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -124,7 +124,7 @@ importers: version: 0.2.1 ts-jest: specifier: ^29.0.5 - version: 29.0.5(@babel/core@7.22.5)(esbuild@0.18.13)(jest@29.5.0)(typescript@4.9.5) + version: 29.0.5(@babel/core@7.22.9)(esbuild@0.18.13)(jest@29.5.0)(typescript@4.9.5) typescript: specifier: ^4.9.5 version: 4.9.5 @@ -201,7 +201,7 @@ importers: version: 2.0.3(react@18.2.0) ts-jest: specifier: ^29.0.5 - version: 29.0.5(@babel/core@7.22.9)(esbuild@0.18.13)(jest@29.5.0)(typescript@4.9.4) + version: 29.0.5(@babel/core@7.22.5)(esbuild@0.18.13)(jest@29.5.0)(typescript@4.9.4) typescript: specifier: ^4.9.4 version: 4.9.4 @@ -10800,7 +10800,7 @@ packages: yargs-parser: 21.1.1 dev: true - /ts-jest@29.0.5(@babel/core@7.22.5)(esbuild@0.18.13)(jest@29.5.0)(typescript@4.9.5): + /ts-jest@29.0.5(@babel/core@7.22.5)(esbuild@0.18.13)(jest@29.5.0)(typescript@4.9.4): resolution: {integrity: sha512-PL3UciSgIpQ7f6XjVOmbi96vmDHUqAyqDr8YxzopDqX3kfgYtX1cuNeBjP+L9sFXi6nzsGGA6R3fP3DDDJyrxA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} hasBin: true @@ -10831,7 +10831,7 @@ packages: lodash.memoize: 4.1.2 make-error: 1.3.6 semver: 7.5.3 - typescript: 4.9.5 + typescript: 4.9.4 yargs-parser: 21.1.1 dev: true @@ -10870,6 +10870,41 @@ packages: yargs-parser: 21.1.1 dev: true + /ts-jest@29.0.5(@babel/core@7.22.9)(esbuild@0.18.13)(jest@29.5.0)(typescript@4.9.5): + resolution: {integrity: sha512-PL3UciSgIpQ7f6XjVOmbi96vmDHUqAyqDr8YxzopDqX3kfgYtX1cuNeBjP+L9sFXi6nzsGGA6R3fP3DDDJyrxA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + peerDependencies: + '@babel/core': '>=7.0.0-beta.0 <8' + '@jest/types': ^29.0.0 + babel-jest: ^29.0.0 + esbuild: '*' + jest: ^29.0.0 + typescript: '>=4.3' + peerDependenciesMeta: + '@babel/core': + optional: true + '@jest/types': + optional: true + babel-jest: + optional: true + esbuild: + optional: true + dependencies: + '@babel/core': 7.22.9 + bs-logger: 0.2.6 + esbuild: 0.18.13 + fast-json-stable-stringify: 2.1.0 + jest: 29.5.0(@types/node@18.0.0) + jest-util: 29.5.0 + json5: 2.2.3 + lodash.memoize: 4.1.2 + make-error: 1.3.6 + semver: 7.5.3 + typescript: 4.9.5 + yargs-parser: 21.1.1 + dev: true + /ts-morph@16.0.0: resolution: {integrity: sha512-jGNF0GVpFj0orFw55LTsQxVYEUOCWBAbR5Ls7fTYE5pQsbW18ssTb/6UXx/GYAEjS+DQTp8VoTw0vqYMiaaQuw==} dependencies: diff --git a/tests/integration/tests/plugins/zod.test.ts b/tests/integration/tests/plugins/zod.test.ts index db1fa58f8..40d0cded7 100644 --- a/tests/integration/tests/plugins/zod.test.ts +++ b/tests/integration/tests/plugins/zod.test.ts @@ -367,4 +367,50 @@ describe('Zod plugin tests', () => { expect(schema.safeParse({ arr: [4] }).error.toString()).toMatch(/condition4/); expect(schema.safeParse({ arr: [1, 2, 3] }).success).toBeTruthy(); }); + + it('full-text search', async () => { + const model = ` + datasource db { + provider = 'postgresql' + url = env('DATABASE_URL') + } + + generator js { + provider = 'prisma-client-js' + previewFeatures = ["fullTextSearch"] + } + + plugin zod { + provider = "@core/zod" + } + + enum Role { + USER + ADMIN + } + + model User { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + email String @unique @email @endsWith('@zenstack.dev') + password String @omit + role Role @default(USER) + posts post_Item[] + } + + model post_Item { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + title String @length(5, 10) + author User? @relation(fields: [authorId], references: [id]) + authorId Int? + published Boolean @default(false) + viewCount Int @default(0) + } + `; + + await loadSchema(model, { addPrelude: false, pushDb: false }); + }); });