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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The "ts-schema-generator.ts" file has a member getRelationName doing the same thing but more robust. Shall we move that implementation here and avoid the duplicate?

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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I believe this condition is too loose here and it'll allow a false negative for the following case (field bars accidentally matching opposite field foos even though foos is a relation with a different name):

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

model Bar {
    id    Int @id
    foo   Foo   @relation(fields: [fooId], references: [id])
    fooId Int
    foos  Foo[] @relation("other")
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

return getRelationName(f) === relationName should be good enough.

});
}

/**
* 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 });

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Shall we update this error to "Field-level policies are only allowed for implicit many-to-many relation fields"?

}
}

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
Loading