diff --git a/packages/language/src/validators/attribute-application-validator.ts b/packages/language/src/validators/attribute-application-validator.ts index 9710d4c16..b490e20d4 100644 --- a/packages/language/src/validators/attribute-application-validator.ts +++ b/packages/language/src/validators/attribute-application-validator.ts @@ -26,7 +26,6 @@ import { } from '../generated/ast'; import { getAllAttributes, - getAllFields, getAttributeArg, getContainingDataModel, getDataSourceProvider, @@ -82,7 +81,6 @@ export default class AttributeApplicationValidator implements AstValidator(); @@ -165,31 +163,6 @@ export default class AttributeApplicationValidator implements AstValidator a.decl.ref?.name === '@@@onceInModel')) { - return; - } - - // only meaningful for field-level attributes within a data model - const field = attr.$container; - if (!isDataField(field)) { - return; - } - const dataModel = getContainingDataModel(attr); - if (!dataModel) { - return; - } - - // count distinct fields (including inherited) carrying this attribute - const fieldsWithAttr = getAllFields(dataModel).filter((f) => - f.attributes.some((a) => a.decl.ref === attrDecl), - ); - if (fieldsWithAttr.length > 1) { - accept('error', `Attribute "${attrDecl.name}" can only be applied to one field per model`, { node: attr }); - } - } - // TODO: design a way to let plugin register validation @check('@@allow') @check('@@deny') diff --git a/packages/language/src/validators/datamodel-validator.ts b/packages/language/src/validators/datamodel-validator.ts index e8ddbe89c..5fc093da1 100644 --- a/packages/language/src/validators/datamodel-validator.ts +++ b/packages/language/src/validators/datamodel-validator.ts @@ -3,7 +3,9 @@ import { AstUtils, type AstNode, type DiagnosticInfo, type ValidationAcceptor } import { IssueCodes, SCALAR_TYPES } from '../constants'; import { ArrayExpr, + Attribute, DataField, + DataFieldAttribute, DataModel, DataModelAttribute, ReferenceExpr, @@ -38,6 +40,7 @@ export default class DataModelValidator implements AstValidator { validateDuplicatedDeclarations(dm, getAllFields(dm), accept); this.validateAttributes(dm, accept); this.validateFields(dm, accept); + this.validateOnceInModelAttributes(dm, accept); if (dm.mixins.length > 0) { this.validateMixins(dm, accept); } @@ -143,6 +146,40 @@ export default class DataModelValidator implements AstValidator { getAllAttributes(dm).forEach((attr) => validateAttributeApplication(attr, accept, dm)); } + // Validates field-level attributes marked with `@@@onceInModel`, which may be applied to at + // most one field per model (including fields inherited from base models and mixins). This must + // run at the model level so that duplicates which only co-occur through inheritance are detected + // — per-field validation only sees the model that physically declares each field. + private validateOnceInModelAttributes(dm: DataModel, accept: ValidationAcceptor) { + // group field attributes carrying `@@@onceInModel` by their attribute declaration + const occurrences = new Map(); + for (const field of getAllFields(dm)) { + for (const attr of field.attributes) { + const decl = attr.decl.ref; + if (decl && hasAttribute(decl, '@@@onceInModel')) { + const list = occurrences.get(decl) ?? []; + list.push(attr); + occurrences.set(decl, list); + } + } + } + + for (const [decl, attrs] of occurrences) { + if (attrs.length <= 1) { + continue; + } + const message = `Attribute "${decl.name}" can only be applied to one field per model`; + // prefer reporting on offending attributes declared on this model's own fields; if all + // offending fields are inherited, report on the model declaration itself + const local = attrs.filter((attr) => dm.fields.includes(attr.$container as DataField)); + if (local.length > 0) { + local.forEach((attr) => accept('error', message, { node: attr })); + } else { + accept('error', message, { node: dm }); + } + } + } + private parseRelation(field: DataField, accept?: ValidationAcceptor) { const relAttr = field.attributes.find((attr) => attr.decl.ref?.name === '@relation'); diff --git a/packages/language/test/attribute-application.test.ts b/packages/language/test/attribute-application.test.ts index 542b4681c..973c11479 100644 --- a/packages/language/test/attribute-application.test.ts +++ b/packages/language/test/attribute-application.test.ts @@ -558,6 +558,34 @@ describe('Attribute application validation tests', () => { /Attribute "@softDelete" can only be applied to one field per model/, ); }); + + // Duplicates that only co-occur through inheritance (none declared on the leaf model itself) + // are detected at the model level via `getAllFields`. + it('rejects when the attribute arrives only through inheritance (two mixins)', async () => { + await loadSchemaWithError( + ` + datasource db { + provider = 'sqlite' + url = 'file:./dev.db' + } + + attribute @softDelete() @@@targetField([DateTimeField]) @@@onceInModel + + type Base1 { + deletedAt DateTime? @softDelete + } + + type Base2 { + removedAt DateTime? @softDelete + } + + model Foo with Base1 Base2 { + id Int @id @default(autoincrement()) + } + `, + /Attribute "@softDelete" can only be applied to one field per model/, + ); + }); }); it('requires relation and fk to have consistent optionality', async () => {