Search code examples
entity-frameworkef-code-firstfluent-interfaceobject-relationships

EF CF Mapping complex relationship with Fluent API


I am trying to create the following constraint in my model so that a Tag object's TagType is valid. A valid TagType is one whose OperatingCompanyId matches the Tag's Website's OperatingCompanyId. I realize that this seems convoluted however it makes sense from a business standpoint:

An Operating Company has WebSites. Websites contain Tags. Tags have a TagType(singular). TagTypes are the same across Operating Companies, meaning that if one Operating Company has twenty TagTypes and five WebSites, those twenty TagTypes should be able to be used across all fives of those WebSites. I want to ensure that a Tag's TagType cannot be one associated with another OperatingCompany.

What is the best way to create this constraint in the model? Do I need to change my POCO, or use the Fluent API?

Thanks in advance!

[Table("OperatingCompanies")]
public class OperatingCompany : ConfigObject
{
    public OperatingCompany()
    {
        WebSites = new List<WebSite>();
    }

    [Required(ErrorMessage = "Name is a required field for an operating company.")]
    [MaxLength(100, ErrorMessage = "Name cannot exceed 100 characters.")]
    public string Name { get; set; }

    public virtual ICollection<WebSite> WebSites { get; set; }
}

[Table("Websites")]
public class WebSite : ConfigObject
{
    public WebSite()
    {
        WebObjects = new List<WebObject>();
    }

    [Required(ErrorMessage = "URL is a required field for a web site.")]
    [MaxLength(100, ErrorMessage = "URL cannot exceed 100 characters for a web site.")]
    [RegularExpression(@"\b(https?|ftp|file)://[-A-Za-z0-9+&@#/%?=~_|!:,.;]*[-A-Za-z0-9+&@#/%=~_|]", ErrorMessage = "The value entered is not a valid URL.")]
    public string Url { get; set; }

    public OperatingCompany OperatingCompany { get; set; }

    [Required(ErrorMessage = "You must associate a web site with an operating company.")]
    public Guid OperatingCompanyId { get; set; }

    [InverseProperty("Website")]
    public virtual ICollection<WebObject> WebObjects { get; set; }
}

[Table("Tags")]
public class Tag : ConfigObject
{
    [Required(ErrorMessage = "Name is a required field for a tag.")]
    [MaxLength(100, ErrorMessage = "Name cannot exceed 100 characters for a tag.")]
    public string Name { get; set; }

    public TagType TagType { get; set; }

    [Required(ErrorMessage = "You must associate a tag with a tag type.")]
    public Guid TagTypeId { get; set; }

    public WebSite WebSite { get; set; }

    [Required(ErrorMessage = "You must associate a tag with a web site.")]
    public Guid WebSiteId { get; set; }
}

[Table("TagTypes")]
public class TagType : ConfigObject
{
    [Required(ErrorMessage = "Name is a required field for a tag.")]
    [MaxLength(100, ErrorMessage = "Name cannot exceed 100 characters for a tag type.")]
    public string Name { get; set; }

    public OperatingCompany OperatingCompany { get; set; }

    [Required(ErrorMessage = "You must associate a tag type with an operating company.")]
    public Guid OperatingCompanyId { get; set; }
}

Solution

  • One way to enforce this constraint is to take advantage of the new validation feature introduced as part of new DbContext API in EF 4.1. You can write a custom validation rule to make sure that tag types for any given company's website are selected from the valid tag types for that company. The following shows how it can be done:

    public abstract class ConfigObject
    {
        public Guid Id { get; set; }
    }
    
    public class OperatingCompany : ConfigObject, IValidatableObject
    {
        public string Name { get; set; }
    
        public virtual ICollection<WebSite> WebSites { get; set; }
        public virtual List<TagType> TagTypes { get; set; }
    
        public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
        {
            var allTagTypes = (from w in WebSites from t in w.Tags select t.TagType);
    
            if (!allTagTypes.All(wtt => TagTypes.Exists(tt => tt.Id == wtt.Id)))
            {
                yield return new ValidationResult("One or more of the website's tag types don't belong to this company");
            }            
        }
    }
    
    public class WebSite : ConfigObject
    {
        public string Url { get; set; }                
        public Guid OperatingCompanyId { get; set; }
    
        public virtual ICollection<Tag> Tags { get; set; }
        public OperatingCompany OperatingCompany { get; set; }                
    }
    
    public class Tag : ConfigObject
    {
        public string Name { get; set; }
        public Guid TagTypeId { get; set; }
        public Guid WebSiteId { get; set; } 
    
        public TagType TagType { get; set; }               
        public WebSite WebSite { get; set; }
    }
    
    public class TagType : ConfigObject
    {
        public string Name { get; set; }
        public Guid OperatingCompanyId { get; set; }
    
        public OperatingCompany OperatingCompany { get; set; }                
    }
    
    public class Context : DbContext
    {
        public DbSet<OperatingCompany> OperatingCompanies { get; set; }
        public DbSet<WebSite> WebSites { get; set; }
        public DbSet<Tag> Tags { get; set; }
        public DbSet<TagType> TagTypes { get; set; }
    
        protected override void OnModelCreating(DbModelBuilder modelBuilder)
        {
            modelBuilder.Entity<Tag>().HasRequired(t => t.WebSite)
                                      .WithMany(w => w.Tags)
                                      .HasForeignKey(t => t.WebSiteId)
                                      .WillCascadeOnDelete(false);
        }
    }
    

    As a result, EF will invoke that validate method each time you call DbContext.SaveChanges() to save an OperatingCompany object into database and EF will throw (and abort the transaction) if the method yields back any validation error. You can also proactively check for validation errors by calling the GetValidationErrors method on the DbContext class to retrieve a list of validation errors within the model objects you are working with.

    It also worth noting that since you use your domain model as also a View Model for your MVC layer, MVC will recognize and honor this Validation rule and you can check for the validation result by looking into the ModelState in the controller. So it really get checked in two places, once in your presentation layer by MVC and once in the back end by EF.

    Hope this helps.