2

I'm attempting to implement a multi-tenant application where I query the db via the tenant object, instead of directly off the context. Before I had this:

public User GetUserByEmail(string email)
    {
        using (var db = CreateContext())
        {
            return db.Users.FirstOrDefault(u => u.Email.Equals(email, StringComparison.OrdinalIgnoreCase));
        }
    }

Now I have this:

public User GetUserByEmail(string email)
    {
        using (var db = CreateContext())
        {
            return _tenant.Users.FirstOrDefault(u => u.Email.Equals(email, StringComparison.OrdinalIgnoreCase));
        }
    }

Where Tenant is the following:

public class Tenant
{
    public Tenant()
    {
    }

    [Key]
    [Required]
    public int TenantId { get; set; }

    public virtual DbSet<User> Users { get; set; }
    // etc
}

Where my User model has the following:

public virtual List<Tenant> Tenants { get; set; }

And in my Context configuration, I have the following:

modelBuilder.Entity<Tenant>()
        .HasMany(e => e.Users)
        .WithMany()
        .Map(m =>
        {
            m.ToTable("UserTenantJoin");
            m.MapLeftKey("TenantId");
            m.MapRightKey("UserId");
        });

But I'm running into a problem with the fact that DbSet is incompatible with the ModelBuilder above - it chokes on HasMany saying that the use of DbSet cannot be inferred from usage.

I played with using ICollection instead, but then in my service layer all calls to _tenant.Users.Include(stuff), or Find(), and other db queries break.

Example of a service method that breaks if I use ICollection:

   public User GetUserWithInterestsAndAptitudes(string username)
    {
        using (var db = CreateContext())
        {
            return _tenant.Users.  // can't use .Include on ICollection
                Include(u => u.Relationships).
                Include(u => u.Interests).
                Include(u => u.Interests.Select(s => s.Subject)).
                Include(u => u.Interests.Select(s => s.Aptitude)).
                FirstOrDefault(s => s.Username.Equals(username, StringComparison.OrdinalIgnoreCase));
        }
    }

I'm hoping there's a solution that will allow me to keep the navigation properties queryable without re-architecting my service layer.

One option is that I revert everything back to using the context via db.Users, and then add another condition to every single query .Where(u => u.TenantId == _tenant.TenantId) - but I'm trying to avoid this.

Any help here would be much appreciated.

SB2055
  • 10,654
  • 29
  • 87
  • 185

1 Answers1

3

I have a solution similar to what you are trying to avoid.

I have a real DbContext that is only accessed via a TenantContext.

public class RealContext
{
     public DbSet<User> Users { get; set; }
     [...]
}

public class TenantContext 
{
    private RealContext realContext;
    private int tenantId;
    public TenantContext(int tenantId)
    {
        realContext = new RealContext();
        this.tenantId= tenantId;
    }
    public IQueryable<User> Users { get { FilterTenant(realContext.Users); }     }

    private IQueryable<T> FilterTenant<T>(IQueryable<T> values) where T : class, ITenantData
    {
         return values.Where(x => x.TenantId == tenantId);
    }
    public int SaveChanges()
    {
        ApplyTenantIds();
        return realContext.SaveChanges();
    }
}

Using this method I'm sure that there is no was a query can be sent without getting the correct tenants. For adding and removing items from the context I' using those two generic methods.

public void Remove<T>(params T[] items) where T : class, ITenantData
{
    var set = realContext.Set<T>();
    foreach(var item in items)
        set.Remove(item);
}

public void Add<T>(params T[] items) where T : class, ITenantData
{
    var set = realContext.Set<T>();
    foreach (var item in items)
        set.Add(item);
}
Alexandre Rondeau
  • 2,527
  • 21
  • 30
  • Thanks, this is interesting. I'm curious - when you new up your TenantContext from the service/repository layer, how are you accessing tenantId? I posted another question regarding that, in case you have a minute: http://stackoverflow.com/questions/19756369/making-an-object-accessible-by-service-layer-without-passing-as-parameter-in-mvc – SB2055 Nov 03 '13 at 22:50
  • In my case the tenantId is stored in a column of the usertable. – Alexandre Rondeau Nov 04 '13 at 02:25
  • What are your thoughts on casting the output of FilterTenant to a DbSet, so that I don't have to write generics for Remove and Add? – SB2055 Nov 04 '13 at 18:16
  • Never thought of that idea. Is it even possible? If the cast works I don't see any problem with it. – Alexandre Rondeau Nov 05 '13 at 02:15
  • I got scared and didn't try it. I do think it's possible (resharper suggested it at one point), but don't know how safe it is so I'm taking your original advice :). So far so good! Thank you for the help. – SB2055 Nov 05 '13 at 02:17
  • btw - I don't know if this is circumstancial, but I had to add 'class' to the FilterTenant method: http://stackoverflow.com/questions/19778098/linq-to-entities-casting-issues-unable-to-cast-object-to-generic-type?lq=1 – SB2055 Nov 05 '13 at 02:19
  • You are right, I forgot it. I simplified my code before posting it – Alexandre Rondeau Nov 06 '13 at 11:38
  • @allo_man - Have you ever dealt with navigation properties in EF via this mechanism? I just posted a question here: http://stackoverflow.com/questions/19826316/virtual-navigation-properties-and-multi-tenancy – RobVious Nov 07 '13 at 01:30
  • via this mecanism navigation properties work exactly as they should. – Alexandre Rondeau Nov 07 '13 at 14:32