Skip to content
Open
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
46 changes: 46 additions & 0 deletions packages/language/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,52 @@ export function isRelationshipField(field: DataField) {
return isDataModel(field.type.reference?.ref);
}

/**
* Returns the name of the relation the given field belongs to, as declared in its `@relation`
* attribute, or `undefined` if the field has no `@relation` attribute or no explicit name.
*/
function getRelationName(field: DataField): string | undefined {
const relAttr = field.attributes.find((attr) => attr.decl.ref?.name === '@relation');
if (!relAttr) {
return undefined;
}
for (const arg of relAttr.args) {
if (!arg.name || arg.name === 'name') {
if (isStringLiteral(arg.value)) {
return arg.value.value;
}
}
}
return undefined;
}

/**
* Returns if the given field is a many-to-many relation field, i.e. a relation field that is an
* array and whose opposite relation field on the referenced model (belonging to the same relation)
* is also an array referencing back to the containing model.
*/
export function isManyToManyField(field: DataField) {
if (!isRelationshipField(field) || !field.type.array) {
return false;
}

const oppositeModel = field.type.reference!.ref as DataModel;
const containingModel = field.$container as DataModel;
const relationName = getRelationName(field);

return getAllFields(oppositeModel).some((f) => {
if (f === field || !f.type.array || f.type.reference?.ref?.name !== containingModel.name) {
return false;
}
// if the field declares an explicit relation name, the opposite field must belong to the
// same relation; otherwise any array field referencing back is the opposite
if (relationName !== undefined) {
return getRelationName(f) === relationName;
}
return true;
});
}

/**
* Returns if the given field is a computed field.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
isComputedField,
isDataFieldReference,
isDelegateModel,
isManyToManyField,
isNativeTypeMappingAttribute,
isRelationshipField,
mapBuiltinTypeToExpressionType,
Expand Down Expand Up @@ -348,18 +349,26 @@ export default class AttributeApplicationValidator implements AstValidator<Attri
});
return;
}
this.validatePolicyKinds(kind, ['read', 'update', 'all'], attr, accept);
const kinds = this.validatePolicyKinds(kind, ['read', 'update', 'all'], attr, accept);

const expr = attr.args[1]?.value;
if (expr && AstUtils.streamAst(expr).some((node) => isBeforeInvocation(node))) {
accept('error', `"before()" is not allowed in field-level policies`, { node: expr });
}

// relation fields are not allowed
// relation fields are not allowed, except for many-to-many fields which only support 'update'
const field = attr.$container as DataField;

if (isRelationshipField(field)) {
accept('error', `Field-level policies are not allowed for relation fields.`, { node: attr });
if (isManyToManyField(field)) {
if (kinds.some((k) => k !== 'update')) {
accept('error', `Only 'update' policies are allowed on many-to-many relation fields`, {
node: attr,
});
}
} else {
accept('error', `Field-level policies are not allowed for relation fields.`, { node: attr });
}
}

if (isComputedField(field)) {
Expand Down
165 changes: 165 additions & 0 deletions packages/language/test/attribute-application.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,171 @@ describe('Attribute application validation tests', () => {
);
});

it('accepts update field-level policy on many-to-many relation fields', async () => {
await loadSchema(`
datasource db {
provider = 'sqlite'
url = 'file:./dev.db'
}

model Foo {
id Int @id @default(autoincrement())
bars Bar[]
@@allow('all', true)
}

model Bar {
id Int @id @default(autoincrement())
foos Foo[] @allow('update', true)
@@allow('all', true)
}
`);
});

it('accepts deny update field-level policy on many-to-many relation fields', async () => {
await loadSchema(`
datasource db {
provider = 'sqlite'
url = 'file:./dev.db'
}

model Foo {
id Int @id @default(autoincrement())
bars Bar[]
@@allow('all', true)
}

model Bar {
id Int @id @default(autoincrement())
foos Foo[] @deny('update', false)
@@allow('all', true)
}
`);
});

it('rejects read field-level policy on many-to-many relation fields', async () => {
await loadSchemaWithError(
`
datasource db {
provider = 'sqlite'
url = 'file:./dev.db'
}

model Foo {
id Int @id @default(autoincrement())
bars Bar[]
@@allow('all', true)
}

model Bar {
id Int @id @default(autoincrement())
foos Foo[] @allow('read', true)
@@allow('all', true)
}
`,
`Only 'update' policies are allowed on many-to-many relation fields`,
);
});

it('rejects all field-level policy on many-to-many relation fields', async () => {
await loadSchemaWithError(
`
datasource db {
provider = 'sqlite'
url = 'file:./dev.db'
}

model Foo {
id Int @id @default(autoincrement())
bars Bar[]
@@allow('all', true)
}

model Bar {
id Int @id @default(autoincrement())
foos Foo[] @allow('all', true)
@@allow('all', true)
}
`,
`Only 'update' policies are allowed on many-to-many relation fields`,
);
});

it('rejects comma-separated kinds including non-update on many-to-many relation fields', async () => {
await loadSchemaWithError(
`
datasource db {
provider = 'sqlite'
url = 'file:./dev.db'
}

model Foo {
id Int @id @default(autoincrement())
bars Bar[]
@@allow('all', true)
}

model Bar {
id Int @id @default(autoincrement())
foos Foo[] @allow('read, update', true)
@@allow('all', true)
}
`,
`Only 'update' policies are allowed on many-to-many relation fields`,
);
});

it('rejects update field-level policy on one-to-many relation fields', async () => {
await loadSchemaWithError(
`
datasource db {
provider = 'sqlite'
url = 'file:./dev.db'
}

model Foo {
id Int @id @default(autoincrement())
bar Bar @relation(fields: [barId], references: [id])
barId Int
@@allow('all', true)
}

model Bar {
id Int @id @default(autoincrement())
foos Foo[] @allow('update', true)
@@allow('all', true)
}
`,
`Field-level policies are not allowed for relation fields`,
);
});

it('rejects update field-level policy on one-to-many relation fields when a separate m2m relation exists', async () => {
await loadSchemaWithError(
`
datasource db {
provider = 'sqlite'
url = 'file:./dev.db'
}

model Foo {
id Int @id @default(autoincrement())
bar Bar @relation("one-to-many", fields: [barId], references: [id]) @allow('update', true)
barId Int
bars Bar[] @relation("m2m")
@@allow('all', true)
}

model Bar {
id Int @id @default(autoincrement())
foos Foo[] @relation("m2m")
@@allow('all', true)
}
`,
`Field-level policies are not allowed for relation fields`,
);
});

it('rejects field-level policy on computed fields', async () => {
await loadSchemaWithError(
`
Expand Down
Loading