When performing data queries against a DbContext, Entities are either mapped to the current database or they are NOT. There is no grey area on this one. What you are asking is similar to Entity Framework - “Navigation Property” In A Different Data Context?. In this case it is not another DbContext, but the problems are the same.
When you query against the DbContext the following high level steps are involved in servicing that query: (unless the query is against a cached result set)
- EF translates the expression into a SQL command by consulting the mappings expressed in your
DbModelBuilder
configuration
- The SQL is passed to the database and evaluated
- The response is deserialized from the database into the EF Model Objects
So ultimately, we need to tell EF to ignore any query paths (and .Include
s) that need to resolve the Addresses, because it won't be able to find them. The ObsoleteAttribute
is a great tool for this, we can track down any references in the code via warnings, whilst still allowing the old code to compile while you are in a transition state. It is also a hint for any new code, to NOT use this legacy reference.
To try and silently inject this conditional functionality into the DbContext might be possible, but it's not going to be practical. The best solution for a hybrid approach would be to "Bolt On" the new AddressProvider
implementation side by side.
Then after a time, when you have removed all previous references and queries to the old DbSet of Addresses and you want to remove the old Address table you can simply remove it from the UserDbContext
, make sure you also Ignore it in the mapping.
Simply add the new provider implementation into the DbContext
, this way you don't have to copy around references to every call.
public class UserDbContext : IdentityDbContext<AspNetUsers>
{
#region Disposable AddressProvider
AddressProvider AddressProvider { get; private set; } = new AddressProvider(this);
/// <summary>
/// Dispose managed members such as AddressProvider, otherwise they will keep this context active
/// </summary>
protected override void Dispose(bool disposing)
{
if(this.AddressProvider != null)
{
try
{
this.AddressProvider.Dispose();
this.AddressProvider = null;
}
catch (Exception) { /*Ignore errors during dispose*/ }
}
base.Dispose(disposing);
}
#endregion Disposable AddressProvider
public DbSet<Address> Addresses { get; set; }
public DbSet<Person> Persons { get; set; }
}
public class AddressProvider : IDisposable
{
UserDbContext _userContext;
public readonly bool LoadFromService;
public readonly bool UpdateDatabase;
public AddressProvider(UserDbContext dbContext, bool loadFromService = true, bool updateDatabase = true)
{
_userContext = dbContext;
LoadFromService = loadFromService;
UpdateDatabase = updateDatabase;
}
#region IDiposable - Release the dbContext reference
public void Dispose()
{
_userContext = null;
}
#endregion IDiposable - Release the dbContext reference
public Address GetAddress(string userId)
{
if (LoadFromService)
{
return GetAddressFromAPI(userId);
}
return _userContext.Addresses.FirstOrDefault(x => x.UserId == userId);
}
public void SetAddress(Address address)
{
// To support Hybrid operations, we can update both the service and the database
if (LoadFromService)
{
SaveAddressInAPI(address);
}
if (UpdateDatabase)
{
var userId = address.UserId;
// It's probably simpler to delete any existing address for this user
foreach(var a in context.Addresses.Where(x => x.UserId == userId).ToList())
context.Entry(fund).State = System.Data.Entity.EntityState.Deleted;
_userContext.Addresses.Add(address);
_userContext.SaveChanges();
}
}
public void GetAddressFromAPI(string userId)
{
// this will be fetch from api via HttpClient
return new Address { UserId = userId };
//throw new NotImplementedException();
}
public void SaveAddressInAPI(Address address)
{
// this will be sent to API
//throw new NotImplementedException();
}
}
public class AspNetUsers : IdentityUser
{
public virtual ICollection<Person> Persons { get; set; }
[Obsolete("Stop using the Addresses collection directly, see 'GetAddress(UserDbContext)'")]
public virtual ICollection<Address> Addresses { get; set; }
public AspNetUsers()
{
Addresses = new List<Address>();
Persons = new List<Person>();
}
}
public static class AddressExtensions
{
public static Address GetAddress(this AspNetUsers user, UserDbContext context)
{
return context.AddressProvider.GetAddress(user.UserId);
}
public void SetAddress(this AspNetUsers user, Address address, UserDbContext context)
{
// ensure the UserId is set to this user
address.UserId = user.UserId;
context.AddressProvider.SetAddress(address);
}
}
This isn't much different from your original implementation, but atleast the provider implementation is neatly packaged within the DbContext
static void Main(string[] args)
{
var context = new UserDbContext();
var user = context.Users.FirstOrDefault(x => x.UserId == "user12345");
// load the address
var address = user.GetAddress(context);
var newAddress = new Address("street", "city");
user.SetAddress(newAddress, context);
}
I don't reccomend this, but you could also add a LoadAddresses
method to manage the conditional load logic. I don't like this because it allows too much of your code to operate as if it is natively part of the EF context when in fact it is not, but the answer wouldn't be complete without it...
public static class MoreAddressExtensions
{
public static Address LoadAddress(this AspNetUsers user, UserDbContext context)
{
if (context.AddressProvider.LoadFromService)
{
if (this.Addresses == null)
this.Addresses = new HashSet<Address>();
else
this.Addresses.Clear();
var externalAddress = context.AddressProvider.GetAddress(user.UserId));
if (externalAddress != null)
this.Addresses.Add(externalAddress);
}
else
this.Addesses = context.Addresses.Where(x => x.UserId == userId).ToList();
}
}
Then this could be used...
static void Main(string[] args)
{
var context = new UserDbContext();
var user = context.Users.FirstOrDefault(x => x.UserId == "user12345");
// load the address
user.LoadAddress(context);
var address = user.Addresses.FirstOrDefault();
var newAddress = new Address("street", "city");
user.SetAddress(newAddress, context);
}
Later, when you want to remove the table from the context amke sure you remove the public DbSet<Address> Addresses { get; set; }
from the context, AND ignore the type in the model configuration:
modelBuilder.Ignore<Address>();
If instead of a transition, you were interested in a permanent hybrid approach, or you had lots of existing code pathways that might be updating the old address records, then you could also intercept the SaveChanges
method on the DbContext, if you do this, then you need to be aware of and avoid race conditions with the existing code, maybe you remove the previous SetAddress logic, this is an example of how you might go about it:
/// <summary> detect changes to Address Entities and redirect them through the provider </summary>
public override int SaveChanges()
{
foreach (var entry in this.ChangeTracker.Entries())
{
if (entry.Entity is Address a)
{
switch(entry.State)
{
case EntityState.Modified:
case EntityState.Added:
{
if (AddressProvider.LoadFromService)
{
if (String.IsNullOrEmpty(a.UserId))
a.UserId = a.User.Id;
AddressProvider.SaveAddressInAPI(a);
}
if (!AddressProvider.UpdateDatabase)
entry.State = EntityState.Unchanged;
break;
}
case EntityState.Deleted:
{
// no mention of how to handle delete, so we'll add a blank one
if (AddressProvider.LoadFromService)
{
string userId = a.UserId;
if (String.IsNullOrEmpty(a.UserId))
userId = a.User.Id;
AddressProvider.SaveAddressInAPI(new Address { UserId = userId });
}
if (!AddressProvider.UpdateDatabase)
entry.State = EntityState.Unchanged;
break;
}
case EntityState.Unchanged:
default:
continue;
}
}
}
return base.SaveChanges();
}