diff --git a/packages/language/res/stdlib.zmodel b/packages/language/res/stdlib.zmodel index cb604c74a..d6c5bab57 100644 --- a/packages/language/res/stdlib.zmodel +++ b/packages/language/res/stdlib.zmodel @@ -66,6 +66,9 @@ enum ExpressionContext { // used in @@index Index + + // used in @@unique + Unique } /** @@ -249,8 +252,9 @@ attribute @@id(_ fields: FieldReference[], name: String?, map: String?, length: * @param length: Allows you to specify a maximum length for the subpart of the value to be indexed. * @param sort: Allows you to specify in what order the entries of the constraint are stored in the database. The available options are Asc and Desc. * @param clustered: Boolean Defines whether the constraint is clustered or non-clustered. Defaults to false. + * @param where: Filters rows included in the partial unique constraint; accepts a Prisma filter object or raw("SQL"). */ -attribute @@unique(_ fields: FieldReference[], name: String?, map: String?, length: Int?, sort: SortOrder?, clustered: Boolean?) @@@prisma +attribute @@unique(_ fields: FieldReference[], name: String?, map: String?, length: Int?, sort: SortOrder?, clustered: Boolean?, where: Any?) @@@prisma /** * Index types @@ -352,8 +356,9 @@ enum SortOrder { * @params sort: Allows you to specify in what order the entries of the index or constraint are stored in the database. The available options are asc and desc. * @params clustered: Defines whether the index is clustered or non-clustered. Defaults to false. * @params type: Allows you to specify an index access method. Defaults to BTree. + * @params where: Filters rows included in the partial index; accepts a Prisma filter object or raw("SQL"). */ -attribute @@index(_ fields: FieldReference[], name: String?, map: String?, length: Int?, sort: SortOrder?, clustered: Boolean?, type: IndexType?) @@@prisma +attribute @@index(_ fields: FieldReference[], name: String?, map: String?, length: Int?, sort: SortOrder?, clustered: Boolean?, type: IndexType?, where: Any?) @@@prisma /** * Defines meta information about the relation. @@ -631,7 +636,7 @@ attribute @@delegate(_ discriminator: FieldReference) * Used for specifying operator classes for GIN index. */ function raw(value: String): Any { -} @@@expressionContext([Index]) +} @@@expressionContext([Index, Unique]) /** * Marks a field to be strong-typed JSON. diff --git a/packages/language/src/validators/attribute-application-validator.ts b/packages/language/src/validators/attribute-application-validator.ts index 28983e822..c97e15698 100644 --- a/packages/language/src/validators/attribute-application-validator.ts +++ b/packages/language/src/validators/attribute-application-validator.ts @@ -12,14 +12,18 @@ import { DataModelAttribute, InternalAttribute, isArrayExpr, + type Plugin, isAttribute, isConfigArrayExpr, isDataField, isDataModel, isDataSource, isEnum, + isInvocationExpr, isLiteralExpr, isModel, + isObjectExpr, + isPlugin, isReferenceExpr, isStringLiteral, isTypeDef, @@ -28,6 +32,7 @@ import { getAllAttributes, getAttributeArg, getContainingDataModel, + getLiteral, getStringLiteral, hasAttribute, isAuthOrAuthMemberAccess, @@ -310,6 +315,34 @@ export default class AttributeApplicationValidator implements AstValidator isPlugin(d) && (d as Plugin).fields.some((f) => f.name === 'provider' && getLiteral(f.value) === '@core/prisma'), + ); + const prismaVersionStr = prismaPlugin + ? getLiteral(prismaPlugin.fields.find((f) => f.name === 'prismaVersion')?.value) + : undefined; + if (!prismaVersionSupportsPartialIndexes(prismaVersionStr)) { + accept( + 'error', + 'Partial indexes require Prisma 7.4+. Set `prismaVersion = "7.4"` in your `plugin prisma` block to enable this feature.', + { node: whereArg }, + ); + } else { + const isFilterObject = isObjectExpr(whereArg); + const isRawCall = + isInvocationExpr(whereArg) && + whereArg.function.$refText === 'raw' && + whereArg.args.length === 1 && + isStringLiteral(whereArg.args[0]?.value); + if (!isFilterObject && !isRawCall) { + accept('error', '`where` expects a filter object or raw("SQL")', { node: whereArg }); + } + } + } + const fields = getAttributeArg(attr, 'fields'); const attrName = attr.decl.ref?.name; if (!fields) { @@ -622,6 +655,14 @@ export function validateAttributeApplication( new AttributeApplicationValidator().validate(attr, accept, contextDataModel); } +function prismaVersionSupportsPartialIndexes(version: string | undefined): boolean { + if (!version) return false; + const parts = version.split('.').map(Number); + const major = parts[0] ?? 0; + const minor = parts[1] ?? 0; + return major > 7 || (major === 7 && minor >= 4); +} + function isLiteralJsonString(value: Expression) { if (!isStringLiteral(value)) { return false; diff --git a/packages/language/test/attribute-application.test.ts b/packages/language/test/attribute-application.test.ts index 7dde410b7..be236d30c 100644 --- a/packages/language/test/attribute-application.test.ts +++ b/packages/language/test/attribute-application.test.ts @@ -431,6 +431,182 @@ describe('Attribute application validation tests', () => { }); }); + describe('Partial index where argument', () => { + const datasource = ` + datasource db { + provider = 'sqlite' + url = 'file:./dev.db' + } + `; + const header = ` + ${datasource} + plugin prisma { + provider = '@core/prisma' + prismaVersion = "7.4" + } + `; + + it('accepts a filter object in @@index', async () => { + await loadSchema(`${header} + model Foo { + id Int @id @default(autoincrement()) + email String? + @@index([email], where: { email: { not: null } }) + } + `); + }); + + it('accepts raw() in @@index', async () => { + await loadSchema(`${header} + model Foo { + id Int @id @default(autoincrement()) + email String? + @@index([email], where: raw("email IS NOT NULL")) + } + `); + }); + + it('accepts a filter object in @@unique', async () => { + await loadSchema(`${header} + model Foo { + id Int @id @default(autoincrement()) + email String? + @@unique([email], where: { email: { not: null } }) + } + `); + }); + + it('accepts raw() in @@unique', async () => { + await loadSchema(`${header} + model Foo { + id Int @id @default(autoincrement()) + email String? + @@unique([email], where: raw("email IS NOT NULL")) + } + `); + }); + + it('rejects a plain string literal in @@index', async () => { + await loadSchemaWithError( + `${header} + model Foo { + id Int @id @default(autoincrement()) + email String? + @@index([email], where: "email IS NOT NULL") + } + `, + '`where` expects a filter object or raw("SQL")', + ); + }); + + it('rejects a plain string literal in @@unique', async () => { + await loadSchemaWithError( + `${header} + model Foo { + id Int @id @default(autoincrement()) + email String? + @@unique([email], where: "email IS NOT NULL") + } + `, + '`where` expects a filter object or raw("SQL")', + ); + }); + + it('rejects a numeric literal in @@index', async () => { + await loadSchemaWithError( + `${header} + model Foo { + id Int @id @default(autoincrement()) + email String? + @@index([email], where: 42) + } + `, + '`where` expects a filter object or raw("SQL")', + ); + }); + + it('rejects a bare field reference in @@index', async () => { + await loadSchemaWithError( + `${header} + model Foo { + id Int @id @default(autoincrement()) + email String? + @@index([email], where: email) + } + `, + '`where` expects a filter object or raw("SQL")', + ); + }); + + it('rejects partial index without plugin prisma', async () => { + await loadSchemaWithError( + ` + ${datasource} + model Foo { + id Int @id @default(autoincrement()) + email String? + @@index([email], where: { email: { not: null } }) + } + `, + 'Partial indexes require Prisma 7.4+', + ); + }); + + it('rejects partial index with prismaVersion below 7.4', async () => { + await loadSchemaWithError( + ` + ${datasource} + plugin prisma { + provider = '@core/prisma' + prismaVersion = "7.3" + } + model Foo { + id Int @id @default(autoincrement()) + email String? + @@index([email], where: { email: { not: null } }) + } + `, + 'Partial indexes require Prisma 7.4+', + ); + }); + + it('rejects partial index with prismaVersion 7.1.3', async () => { + await loadSchemaWithError( + ` + ${datasource} + plugin prisma { + provider = '@core/prisma' + prismaVersion = "7.1.3" + } + model Foo { + id Int @id @default(autoincrement()) + email String? + @@index([email], where: { email: { not: null } }) + } + `, + 'Partial indexes require Prisma 7.4+', + ); + }); + + it('rejects partial index with prismaVersion 6.8.9', async () => { + await loadSchemaWithError( + ` + ${datasource} + plugin prisma { + provider = '@core/prisma' + prismaVersion = "6.8.9" + } + model Foo { + id Int @id @default(autoincrement()) + email String? + @@index([email], where: { email: { not: null } }) + } + `, + 'Partial indexes require Prisma 7.4+', + ); + }); + }); + it('requires relation and fk to have consistent optionality', async () => { await loadSchemaWithError( ` diff --git a/packages/sdk/src/prisma/prisma-builder.ts b/packages/sdk/src/prisma/prisma-builder.ts index a118a6472..003e53dbb 100644 --- a/packages/sdk/src/prisma/prisma-builder.ts +++ b/packages/sdk/src/prisma/prisma-builder.ts @@ -258,7 +258,7 @@ export class AttributeArg { export class AttributeArgValue { constructor( - public type: 'String' | 'FieldReference' | 'Number' | 'Boolean' | 'Array' | 'FunctionCall', + public type: 'String' | 'FieldReference' | 'Number' | 'Boolean' | 'Array' | 'FunctionCall' | 'Raw', public value: string | number | boolean | FieldReference | FunctionCall | AttributeArgValue[], ) { switch (type) { @@ -282,6 +282,9 @@ export class AttributeArgValue { case 'FunctionCall': if (!(value instanceof FunctionCall)) throw new Error('Value must be FunctionCall'); break; + case 'Raw': + if (typeof value !== 'string') throw new Error('Value must be string'); + break; } } @@ -310,6 +313,8 @@ export class AttributeArgValue { return this.value ? 'true' : 'false'; case 'Array': return '[' + (this.value as AttributeArgValue[]).map((v) => v.toString()).join(', ') + ']'; + case 'Raw': + return this.value as string; default: throw new Error(`Unknown attribute value type ${this.type}`); } diff --git a/packages/sdk/src/prisma/prisma-schema-generator.ts b/packages/sdk/src/prisma/prisma-schema-generator.ts index 815403cac..b33135822 100644 --- a/packages/sdk/src/prisma/prisma-schema-generator.ts +++ b/packages/sdk/src/prisma/prisma-schema-generator.ts @@ -24,6 +24,7 @@ import { isInvocationExpr, isLiteralExpr, isNullExpr, + isObjectExpr, isReferenceExpr, isStringLiteral, isTypeDef, @@ -165,13 +166,26 @@ export class PrismaSchemaGenerator { } private generateGenerator(prisma: PrismaModel, decl: GeneratorDecl) { - prisma.addGenerator( + const gen = prisma.addGenerator( decl.name, decl.fields.map((f) => ({ name: f.name, text: this.configExprToText(f.value), })), ); + + if (this.hasPartialIndex()) { + const existing = gen.fields.find((f) => f.name === 'previewFeatures'); + if (existing) { + const features: string[] = JSON.parse(existing.text); + if (!features.includes('partialIndexes')) { + features.push('partialIndexes'); + existing.text = JSON.stringify(features); + } + } else { + gen.fields.push({ name: 'previewFeatures', text: JSON.stringify(['partialIndexes']) }); + } + } } private generateDefaultGenerator(prisma: PrismaModel) { @@ -188,6 +202,10 @@ export class PrismaSchemaGenerator { previewFeatures.push('views'); } + if (this.hasPartialIndex()) { + previewFeatures.push('partialIndexes'); + } + if (previewFeatures.length > 0) { gen.fields.push({ name: 'previewFeatures', @@ -196,6 +214,19 @@ export class PrismaSchemaGenerator { } } + private hasPartialIndex(): boolean { + const partialIndexAttrs = ['@@index', '@@unique']; + return this.zmodel.declarations.some( + (d) => + isDataModel(d) && + getAllAttributes(d).some( + (attr) => + partialIndexAttrs.includes(attr.decl.ref?.name ?? '') && + attr.args.some((arg) => arg.name === 'where'), + ), + ); + } + private generateModel(prisma: PrismaModel, decl: DataModel) { const model = decl.isView ? prisma.addView(decl.name) : prisma.addModel(decl.name); const allFields = getAllFields(decl, true); @@ -368,6 +399,8 @@ export class PrismaSchemaGenerator { node.args.map((arg) => new PrismaFieldReferenceArg(arg.name, this.exprToText(arg.value))), ), ); + } else if (isObjectExpr(node)) { + return new PrismaAttributeArgValue('Raw', this.exprToText(node)); } else if (isInvocationExpr(node)) { // invocation return new PrismaAttributeArgValue('FunctionCall', this.makeFunctionCall(node)); diff --git a/packages/sdk/src/ts-schema-generator.ts b/packages/sdk/src/ts-schema-generator.ts index d533c93d9..abe7678d9 100644 --- a/packages/sdk/src/ts-schema-generator.ts +++ b/packages/sdk/src/ts-schema-generator.ts @@ -27,9 +27,11 @@ import { isReferenceExpr, isThisExpr, isTypeDef, + isObjectExpr, isUnaryExpr, LiteralExpr, MemberAccessExpr, + ObjectExpr, Procedure, ReferenceExpr, TypeDef, @@ -1282,6 +1284,7 @@ export class TsSchemaGenerator { .when(isInvocationExpr, (expr) => this.createCallExpression(expr)) .when(isReferenceExpr, (expr) => this.createRefExpression(expr)) .when(isArrayExpr, (expr) => this.createArrayExpression(expr)) + .when(isObjectExpr, (expr) => this.createObjectExpression(expr)) .when(isUnaryExpr, (expr) => this.createUnaryExpression(expr)) .when(isBinaryExpr, (expr) => this.createBinaryExpression(expr)) .when(isMemberAccessExpr, (expr) => this.createMemberExpression(expr)) @@ -1292,6 +1295,17 @@ export class TsSchemaGenerator { }); } + private createObjectExpression(expr: ObjectExpr): ts.Expression { + return ts.factory.createObjectLiteralExpression( + expr.fields.map((field) => + ts.factory.createPropertyAssignment( + typeof field.name === 'string' ? field.name : (field.name as string), + this.createExpression(field.value), + ), + ), + ); + } + private createThisExpression() { return this.createExpressionUtilsCall('_this'); } diff --git a/tests/e2e/orm/partial-index.e2e.test.ts b/tests/e2e/orm/partial-index.e2e.test.ts new file mode 100644 index 000000000..d9c0f75ad --- /dev/null +++ b/tests/e2e/orm/partial-index.e2e.test.ts @@ -0,0 +1,35 @@ +import { PrismaSchemaGenerator } from '@zenstackhq/sdk'; +import { loadSchema } from '@zenstackhq/testtools'; +import { describe, expect, it } from 'vitest'; + +describe('e2e: partial index in ZModel', () => { + it('should generate Prisma schema with partial index', async () => { + const model = await loadSchema(` +datasource db { + provider = 'postgresql' + url = 'env("DATABASE_URL")' +} + +plugin prisma { + provider = '@core/prisma' + prismaVersion = "7.4" +} + +model Post { + id Int @id @default(autoincrement()) + title String + published Boolean + + @@index([title], where: { published: true }) + @@unique([title], where: raw("published = true")) +} + `); + + const generator = new PrismaSchemaGenerator(model); + const schema = await generator.generate(); + expect(schema).toContain('previewFeatures = ["partialIndexes"]'); + expect(schema).toContain('@@index([title], where: { published: true })'); + expect(schema).toContain('@@unique([title], where: raw("published = true"))'); + expect(schema).not.toContain('prismaVersion'); + }); +});