Una cosa che sto trovando davvero noiosa con il modo in cui EFCore gestisce le relazioni molti-a-molti è l'aggiornamento di entità entrate in collezioni. È un requisito frequente che un viewmodel provenga dal frontend con un nuovo elenco di entità nidificate e devo scrivere un metodo per ogni entità nidificata che elabori ciò che deve essere rimosso, che cosa deve essere aggiunto e quindi rimuove e aggiunge . A volte un'entità ha più relazioni molti-a-molti e devo scrivere più o meno lo stesso codice per ogni raccolta.
Penso che un metodo generico potrebbe essere usato qui per impedirmi di ripetermi, ma sto lottando per capire come.
Prima lascia che ti mostri il modo in cui attualmente lo faccio.
Diciamo che abbiamo questi modelli:
public class Person
{
public int Id { get; set; }
public string Name { get; set; }
public virtual ICollection<PersonCar> PersonCars { get; set; } = new List<PersonCar>();
}
public class Car
{
public int Id { get; set; }
public string Manufacturer { get; set; }
public virtual ICollection<PersonCar> PersonCars { get; set; } = new List<PersonCar>();
}
public class PersonCar
{
public virtual Person Person { get; set; }
public int PersonId { get; set; }
public virtual Car Car { get; set; }
public int CarId { get; set; }
}
E una chiave definita con API fluente
modelBuilder.Entity<PersonCar>().HasKey(t => new { t.PersonId, t.CarId });
E aggiungiamo una nuova persona e un elenco di auto associate:
var person = new Person
{
Name = "John",
PersonCars = new List<PersonCar>
{
new PersonCar { CarId = 1 },
new PersonCar { CarId = 2 },
new PersonCar { CarId = 3 }
}
};
db.Persons.Add(person);
db.SaveChanges();
John possiede auto 1,2,3
. John aggiorna le sue vetture sul frontend, così ora ho passato una nuova lista di id per auto, quindi aggiorno in questo modo (il codice effettivo userebbe un modello e probabilmente chiamerebbe in un metodo come questo):
public static void UpdateCars(int personId, int[] newCars)
{
using (var db = new PersonCarDbContext())
{
var person = db.Persons.Include(x => x.PersonCars).ThenInclude(x => x.Car).Single(x => x.Id == personId);
var toRemove = person.PersonCars.Where(x => !newCars.Contains(x.CarId)).ToList();
var toAdd = newCars.Where(c => !person.PersonCars.Any(x => x.CarId == c)).ToList();
foreach (var pc in toRemove)
{
person.PersonCars.Remove(pc);
}
foreach (var carId in toAdd)
{
var pc = db.PersonCars.Add(new PersonCar { CarId = carId, PersonId = person.Id });
}
db.SaveChanges();
}
}
Eseguo quelli da rimuovere, quelli da aggiungere e poi le azioni. Tutto molto semplice, ma nel mondo reale un'entità può avere più collezioni molti-a-molti, ad esempio tag, categorie, opzioni ecc. E un'applicazione ha diverse entità. Ogni metodo di aggiornamento è praticamente lo stesso e mi ritrovo con lo stesso codice ripetuto più volte. Ad esempio, diciamo che Person ha avuto anche una relazione molti-a-molti di entità di Category
che assomiglierebbe a questa:
public static void UpdateCategory(int personId, int[] newCats)
{
using (var db = new PersonCarDbContext())
{
var person = db.Persons.Include(x => x.PersonCategories).ThenInclude(x => x.Category).Single(x => x.Id == personId);
var toRemove = person.PersonCategories.Where(x => !newCats.Contains(x.CategoryId)).ToList();
var toAdd = newCats.Where(c => !person.PersonCategories.Any(x => x.CategoryId == c)).ToList();
foreach (var pc in toRemove)
{
person.PersonCategories.Remove(pc);
}
foreach (var catId in toAdd)
{
var pc = db.PersonCategories.Add(new PersonCategory { CategoryId = catId, PersonId = person.Id });
}
db.SaveChanges();
}
}
È esattamente lo stesso codice che fa riferimento a diversi tipi e proprietà. Sto finendo con questo codice ripetuto numerose volte. Sto sbagliando o è un buon esempio per un metodo generico?
Ritengo che sia un buon posto per un generico da utilizzare, ma non riesco a capire come farlo.
Avrà bisogno del tipo di entità, tipo di entità di join e tipo di entità esterna, quindi forse qualcosa del tipo:
public T UpdateJoinedEntity<T, TJoin, Touter>(PersonCarDbContext db, int entityId, int[] nestedids)
{
//.. do same logic but with reflection?
}
Il metodo elaborerà quindi la proprietà corretta e eseguirà le operazioni di rimozione e aggiunta richieste.
È fattibile? Non riesco a vedere come farlo ma sembra qualcosa che è possibile.
"Tutto molto semplice" , ma non è così semplice da scomporre, specialmente prendendo in considerazione diversi tipi di chiavi, proprietà FK esplicite o ombreggiate, e allo stesso tempo mantenendo gli argomenti del metodo minimo.
Ecco il metodo più fattorizzato che io possa pensare, che funziona per le entità di collegamento (join) che hanno 2 FK int
espressi:
public static void UpdateLinks<TLink>(this DbSet<TLink> dbSet,
Expression<Func<TLink, int>> fromIdProperty, int fromId,
Expression<Func<TLink, int>> toIdProperty, int[] toIds)
where TLink : class, new()
{
// link => link.FromId == fromId
var filter = Expression.Lambda<Func<TLink, bool>>(
Expression.Equal(fromIdProperty.Body, Expression.Constant(fromId)),
fromIdProperty.Parameters);
var existingLinks = dbSet.Where(filter).ToList();
var toIdFunc = toIdProperty.Compile();
var deleteLinks = existingLinks
.Where(link => !toIds.Contains(toIdFunc(link)));
// toId => new TLink { FromId = fromId, ToId = toId }
var toIdParam = Expression.Parameter(typeof(int), "toId");
var createLink = Expression.Lambda<Func<int, TLink>>(
Expression.MemberInit(
Expression.New(typeof(TLink)),
Expression.Bind(((MemberExpression)fromIdProperty.Body).Member, Expression.Constant(fromId)),
Expression.Bind(((MemberExpression)toIdProperty.Body).Member, toIdParam)),
toIdParam);
var addLinks = toIds
.Where(toId => !existingLinks.Any(link => toIdFunc(link) == toId))
.Select(createLink.Compile());
dbSet.RemoveRange(deleteLinks);
dbSet.AddRange(addLinks);
}
Tutto ciò di cui ha bisogno è l'entità join DbSet
, due espressioni che rappresentano le proprietà FK e i valori desiderati. Le espressioni del selettore di proprietà vengono utilizzate per creare dinamicamente i filtri di query e per comporre e compilare un functor per creare e inizializzare la nuova entità di collegamento.
Il codice non è così difficile, ma richiede la conoscenza dei metodi System.Linq.Expressions.Expression
.
L'unica differenza con il codice scritto a mano è quella
Expression.Constant(fromId)
l'espressione del filter
interno causerà EF generando una query SQL con valore costante anziché parametro, che impedirà la memorizzazione nella cache del piano di query. Può essere risolto sostituendo il precedente con
Expression.Property(Expression.Constant(new { fromId }), "fromId")
Detto questo, l'utilizzo del campione sarà simile a questo:
public static void UpdateCars(int personId, int[] carIds)
{
using (var db = new PersonCarDbContext())
{
db.PersonCars.UpdateLinks(pc => pc.PersonId, personId, pc => pc.CarId, carIds);
db.SaveChanges();
}
}
e anche in altro modo:
public static void UpdatePersons(int carId, int[] personIds)
{
using (var db = new PersonCarDbContext())
{
db.PersonCars.UpdateLinks(pc => pc.CarId, carId, pc => pc.PersonId, personIds);
db.SaveChanges();
}
}