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
48 changes: 48 additions & 0 deletions packages/language/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,54 @@ 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.
*
* The name argument is matched by its literal name (or absence of a name for the positional
* argument) rather than by `$resolvedParam`, because this function is also used during schema
* validation, where `$resolvedParam` may not have been resolved yet for attributes that have
* not been validated.
*/
export function getRelationName(field: DataField): string | undefined {
const relation = getAttribute(field, '@relation');
if (!relation) {
return undefined;
}
for (const arg of relation.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;
}
// the opposite field must belong to the same relation: either both declare the same
// explicit relation name, or both are unnamed
return getRelationName(f) === relationName;
});
}

/**
* 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,28 @@ 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 only allowed for implicit many-to-many relation fields`, {
node: attr,
});
}
}

if (isComputedField(field)) {
Expand Down
195 changes: 194 additions & 1 deletion packages/language/test/attribute-application.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -366,7 +366,200 @@ describe('Attribute application validation tests', () => {
@@allow('all', true)
}
`,
`Field-level policies are not allowed for relation fields`,
`Field-level policies are only allowed for implicit many-to-many relation fields`,
);
});

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 only allowed for implicit many-to-many 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 only allowed for implicit many-to-many relation fields`,
);
});

it('rejects update field-level policy on a one-to-many field that is not many-to-many when a differently-named relation exists on the opposite model', async () => {
await loadSchemaWithError(
`
datasource db {
provider = 'sqlite'
url = 'file:./dev.db'
}

model Foo {
id Int @id @default(autoincrement())
bars Bar[] @allow('update', true)
bar2 Bar @relation("other", fields: [bar2Id], references: [id])
bar2Id Int
@@allow('all', true)
}

model Bar {
id Int @id @default(autoincrement())
foo Foo @relation(fields: [fooId], references: [id])
fooId Int
foos Foo[] @relation("other")
@@allow('all', true)
}
`,
`Field-level policies are only allowed for implicit many-to-many relation fields`,
);
});

Expand Down
Loading