Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions packages/language/res/stdlib.zmodel
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ enum ExpressionContext {

// used in @@index
Index

// used in @@unique
Unique
}

/**
Expand Down Expand Up @@ -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
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess there is no support for union types to be able to be more strict here.


/**
* Index types
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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])
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* Marks a field to be strong-typed JSON.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,18 @@ import {
DataModelAttribute,
InternalAttribute,
isArrayExpr,
type Plugin,
isAttribute,
isConfigArrayExpr,
isDataField,
isDataModel,
isDataSource,
isEnum,
isInvocationExpr,
isLiteralExpr,
isModel,
isObjectExpr,
isPlugin,
isReferenceExpr,
isStringLiteral,
isTypeDef,
Expand All @@ -28,6 +32,7 @@ import {
getAllAttributes,
getAttributeArg,
getContainingDataModel,
getLiteral,
getStringLiteral,
hasAttribute,
isAuthOrAuthMemberAccess,
Expand Down Expand Up @@ -310,6 +315,34 @@ export default class AttributeApplicationValidator implements AstValidator<Attri
accept('error', `\`${attr.decl.$refText}\` is not allowed for views`, { node: attr });
}

const whereArg = getAttributeArg(attr, 'where');
if (whereArg) {
const zmodel = AstUtils.getContainerOfType(attr, isModel)!;
const prismaPlugin = zmodel.declarations.find(
(d): d is Plugin => isPlugin(d) && (d as Plugin).fields.some((f) => f.name === 'provider' && getLiteral<string>(f.value) === '@core/prisma'),
);
const prismaVersionStr = prismaPlugin
? getLiteral<string>(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 });
}
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const fields = getAttributeArg(attr, 'fields');
const attrName = attr.decl.ref?.name;
if (!fields) {
Expand Down Expand Up @@ -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;
Expand Down
176 changes: 176 additions & 0 deletions packages/language/test/attribute-application.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
`
Expand Down
7 changes: 6 additions & 1 deletion packages/sdk/src/prisma/prisma-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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;
}
}

Expand Down Expand Up @@ -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}`);
}
Expand Down
Loading
Loading