Tout est dans le titre… pour les abonnés MSDN.
Pour plus d’infos, reportez-vous au blog de Soma.
Je sais ça fait longtemps que je n’ai pas blogué. Malheureusement, ce ne sont pas les idées de posts qui me manquent mais le temps.
En attendant, de trouver le temps nécessaire pour écrire des posts de qualité (du moins essayer
), je voulais relayer un peu les différentes sessions sur EF des 5 semaines à venir :
On peut aussi rajouter à cela le fait que j’aurai le plaisir de discuter d’EF4 avec Guy Barette et Mario Cardinal dans le cadre des célèbres podcasts Visual Studio Talk Show.
En gros, ce n’est pas les sessions qui manquent. Chaque session sera l’occasion de convaincre les plus récalcitrants que l’Entity Framework est l’ORM qu’il vous faut et que coder avec EF4 et T4, c’est que du bonheur !
L’EDM est très important. En effet, il définit la conception objet de vos entités et le mapping entre celles-ci et la base.
Cependant, parfois, on aimerait enrichir notre modèle en rajoutant des contraintes de validation métier directement dans le modèle (si possible directement dans le designer).
Ceci est maintenant possible avec possibilité de générer des erreurs / warning en fonction des données et des règles que vous avez définies.
Pour plus d’infos, je vous invite à consulter le post de la team ADO.
Ce post est complètement hors propos par rapport à mon blog. Cependant, me sentant particulièrement concerné avec mon fils, il me tenait particulièrement à cœur de relayer ce documentaire à la fois émouvant et plein d’espoir en espérant qu’un jour l’autisme sera pris en charge en France et que les ressources financières des parents ne soient plus le seul critère d’espoir pour l’avenir de leurs enfants.
Si vous voulez travailler avec des entités POCO et garder dans le même temps les fonctionnalités du contexte comme le tracking des modifications, l’attachement automatique des entités liées, le lazy loading, EF va générer un proxy qui hérite de vos entités (un proxy par entité) et qui va ajouter le code pour faire cela. C’est le même fonctionnement qu’avec N-Hibernate. Cependant, je pense que c’est vraiment pas terrible comme approche. En effet, c’est super contraignant : vos entités ne doivent pas être sealed et doivent être public, tous vos get et set doivent être protected ou public et virtual, il faut avoir un constructeur public ou protected sans paramètre. A cuase de ces contraintes, je n’aime pas cette approche.
Je ne pense (j’espère) d’ailleurs ne pas être le seul dans ce cas. Maintenant c’est facile de critiquer mais comment faire mieux ?
Mon idée était de modifier directement l’assembly des entités plutôt que de passer par des proxy qui en héritent. Sur les conseils de Simon, j’ai utilisé Cecil pour cela.
Comment ça marche ?
J’ai défini un programme qui prend deux paramètres dans la méthode Main : le chemin de l’assembly initial et le chemin de l’edmx. Dans mon program, j’injecte de l’IL dans mes entités afin de leur faire implémenter INotifyPropertyChanging, INotifyPropertyChanged, IEntityWithChangeTracker, IEntityWithKey et IEntityWithRelationships.
Je vais vous épargnez l’IL, ça m’évitera les commentaires du style OMG.
Cependant, je vous invite à télécharger le code si cela vous intéresse.
Ensuite, dans mon application, j’utilise le post-build event suivant :
"D:\Documents\Visual Studio 2010\Projects\EF POCO with Cecil\EFPOCOWithCecil\bin\Release\EFPOCOWithCecil.exe" "$(TargetDir)Entities.dll" "$(ProjectDir)..\DAL\Northwind.edmx"
Avec cette implémentation, je peux profiter des fonctionnalités du contexte.
Je n’ai qu’une seule restriction : les navigation properties de type collection doivent être définies comme des ICollection<T> (c’est pareil avec EF mais ça ne me choque pas contrairement aux contraintes que j’évoquais au début).
Le bon point avec cette façon de faire est le fait je n’ai plus les contraintes des proxies. De plus, avec EF, si vous utilisez la méthode CreateObject du contexte, vous allez avoir une instance du proxy. Si vous utilisez le constructeur directement , vous n’allez pas avoir une instance du proxy mais de votre propre classe (donc vous perdez les fonctionnalités du contexte). Ce point est un peu perturbant pour les développeurs débutant. Avec mon approche, ce problème est également résolu.
Vous pouvez télécharger mon code ici.
Les entités Self-Tracking sont vraiment une bonne chose pour les scenarii N-Tiers mais il faut faire attention si on veut réduire l’échange entre le serveur et le client.
Prenons l’EDM suivant :

Côté client, je veux récupérer un graphe très important d’entités, modifier une propriété d’une seule entité et persister mes modifications :
using (var context = new NorthwindClientContext())
{
var c = context.Categories.AsQueryable().Include("Products.OrderDetails.Order.Employee").Include("Products.OrderDetails.Order.Customer.CustomerDemographics").Include("Products.OrderDetails.Order.Customer.member").First();
var p = c.Products.First();
var pName = p.ProductName;
p.ProductName = "azerty";
context.SaveChanges();
}
Pour persister l’ensemble des modifications de mon contexte client, je passe par une classe ClientContext qui contient une collection de chacun des types de mes EntitySets :
ClientContext SaveChanges(ClientContext context);
[DataContract]
public class ClientContext
{
[DataMember]
public List<Customer> Customers { get; set; }
[DataMember]
public List<OrderDetail> OrderDetailSet { get; set; }
[DataMember]
public List<Order> Orders { get; set; }
[DataMember]
public List<Product> Products { get; set; }
[DataMember]
public List<CustomerDemographic> CustomerDemographics { get; set; }
[DataMember]
public List<Category> Categories { get; set; }
[DataMember]
public List<Employee> Employees { get; set; }
[DataMember]
public List<Member> Members { get; set; }
}
A noter que le résultat de type ClientContext est nécessaire pour les colonnes Identity et Computed.
Cependant, l’idée n’est pas d’échanger l’ensemble des entités pour une petite modification sur une seule propriété d’une seule entité.
Ma première idée a été de réduire le ClientContext envoyé du client vers le serveur :
var clientContext = new ClientContext
{
Customers =
(from e in Customers.AllEntities
where e.ChangeTracker.State != ObjectState.Unchanged || e.ChangeTracker.ObjectsAddedToCollectionProperties.Any() || e.ChangeTracker.ObjectsRemovedFromCollectionProperties.Any()
select e).ToList(),
OrderDetailSet =
(from e in OrderDetailSet.AllEntities
where e.ChangeTracker.State != ObjectState.Unchanged || e.ChangeTracker.ObjectsAddedToCollectionProperties.Any() || e.ChangeTracker.ObjectsRemovedFromCollectionProperties.Any()
select e).ToList(),
Orders =
(from e in Orders.AllEntities
where e.ChangeTracker.State != ObjectState.Unchanged || e.ChangeTracker.ObjectsAddedToCollectionProperties.Any() || e.ChangeTracker.ObjectsRemovedFromCollectionProperties.Any()
select e).ToList(),
Products =
(from e in Products.AllEntities
where e.ChangeTracker.State != ObjectState.Unchanged || e.ChangeTracker.ObjectsAddedToCollectionProperties.Any() || e.ChangeTracker.ObjectsRemovedFromCollectionProperties.Any()
select e).ToList(),
CustomerDemographics =
(from e in CustomerDemographics.AllEntities
where e.ChangeTracker.State != ObjectState.Unchanged || e.ChangeTracker.ObjectsAddedToCollectionProperties.Any() || e.ChangeTracker.ObjectsRemovedFromCollectionProperties.Any()
select e).ToList(),
Categories =
(from e in Categories.AllEntities
where e.ChangeTracker.State != ObjectState.Unchanged || e.ChangeTracker.ObjectsAddedToCollectionProperties.Any() || e.ChangeTracker.ObjectsRemovedFromCollectionProperties.Any()
select e).ToList(),
Employees =
(from e in Employees.AllEntities
where e.ChangeTracker.State != ObjectState.Unchanged || e.ChangeTracker.ObjectsAddedToCollectionProperties.Any() || e.ChangeTracker.ObjectsRemovedFromCollectionProperties.Any()
select e).ToList(),
Members =
(from e in Members.AllEntities
where e.ChangeTracker.State != ObjectState.Unchanged || e.ChangeTracker.ObjectsAddedToCollectionProperties.Any() || e.ChangeTracker.ObjectsRemovedFromCollectionProperties.Any()
select e).ToList()
};
Dans les faits, cela n’est pas efficace car la seule entité modifiée l’est avec son graphe complet, ce qui dans notre cas implique l’ensemble des entités chargées dans le contexte client.
Dans mes templates, dont je vous parle depuis plusieurs posts, j’ai réduit l’échange entre le client et le serveur au strict minimum : Product { ProductID = 1, ProductName = “azerty” }. Comment ais-je fait cela ?
Premièrement, J’ai ajouté une propriété ModifiedProperties (List<string>) sur la classe ObjectChangeTracker. Ensuite dans mon SaveChanges côté client, j’ai ajouté le code suivant :
var sentContext = new ClientContext();
sentContext.Customers =
(from e in clientContext.Customers
select ReduceToModifications(e)).ToList();
sentContext.OrderDetailSet =
(from e in clientContext.OrderDetailSet
select ReduceToModifications(e)).ToList();
sentContext.Orders =
(from e in clientContext.Orders
select ReduceToModifications(e)).ToList();
sentContext.Products =
(from e in clientContext.Products
select ReduceToModifications(e)).ToList();
sentContext.CustomerDemographics =
(from e in clientContext.CustomerDemographics
select ReduceToModifications(e)).ToList();
sentContext.Categories =
(from e in clientContext.Categories
select ReduceToModifications(e)).ToList();
sentContext.Employees =
(from e in clientContext.Employees
select ReduceToModifications(e)).ToList();
sentContext.Members =
(from e in clientContext.Members
select ReduceToModifications(e)).ToList();
Pour le cas de Product, la méthode reduceToModifications est la suivant :
private Product ReduceToModifications(Product entity)
{
Product value = new Product { ProductID = entity.ProductID };
value.ChangeTracker.ChangeTrackingEnabled = true;
value.ChangeTracker.State = entity.ChangeTracker.State;
switch (entity.ChangeTracker.State)
{
case ObjectState.Added:
value.ProductName = entity.ProductName;
value.SupplierID = entity.SupplierID;
value.CategoryID = entity.CategoryID;
value.QuantityPerUnit = entity.QuantityPerUnit;
value.UnitPrice = entity.UnitPrice;
value.UnitsInStock = entity.UnitsInStock;
value.UnitsOnOrder = entity.UnitsOnOrder;
value.ReorderLevel = entity.ReorderLevel;
value.Discontinued = entity.Discontinued;
break;
case ObjectState.Deleted:
break;
case ObjectState.Modified:
value.ChangeTracker.ModifiedProperties = entity.ChangeTracker.ModifiedProperties;
foreach (var modifiedPropery in entity.ChangeTracker.ModifiedProperties)
// switch is more efficient than reflection
switch (modifiedPropery)
{
case "ProductName":
value.ProductName = entity.ProductName;
break;
case "SupplierID":
value.SupplierID = entity.SupplierID;
break;
case "CategoryID":
value.CategoryID = entity.CategoryID;
break;
case "QuantityPerUnit":
value.QuantityPerUnit = entity.QuantityPerUnit;
break;
case "UnitPrice":
value.UnitPrice = entity.UnitPrice;
break;
case "UnitsInStock":
value.UnitsInStock = entity.UnitsInStock;
break;
case "UnitsOnOrder":
value.UnitsOnOrder = entity.UnitsOnOrder;
break;
case "ReorderLevel":
value.ReorderLevel = entity.ReorderLevel;
break;
case "Discontinued":
value.Discontinued = entity.Discontinued;
break;
case "OrderDetails":
value.OrderDetails = entity.OrderDetails;
break;
case "Category":
value.Category = entity.Category;
break;
}
break;
case ObjectState.Unchanged:
break;
default:
throw new InvalidOperationException();
}
return value;
}
Ensuite, je dois jouer avec les propriétés ObjectsAddedToCollectionProperties, ObjectsRemovedFromCollectionProperties et OriginalValues pour les relations. Sans ça, on ne peut pas reporter les relations many to many et on ne peut pas être sûr de l’ordre des commandes SQL ce qui peut entrainer des exceptions (par violation “temporaire” des FK).
Au final ma méthode SaveChanges est la suivante :
public void SaveChanges()
{
var clientContext = new ClientContext
{
Customers =
(from e in Customers.AllEntities
where e.ChangeTracker.State != ObjectState.Unchanged || e.ChangeTracker.ObjectsAddedToCollectionProperties.Any() || e.ChangeTracker.ObjectsRemovedFromCollectionProperties.Any()
select e).ToList(),
OrderDetailSet =
(from e in OrderDetailSet.AllEntities
where e.ChangeTracker.State != ObjectState.Unchanged || e.ChangeTracker.ObjectsAddedToCollectionProperties.Any() || e.ChangeTracker.ObjectsRemovedFromCollectionProperties.Any()
select e).ToList(),
Orders =
(from e in Orders.AllEntities
where e.ChangeTracker.State != ObjectState.Unchanged || e.ChangeTracker.ObjectsAddedToCollectionProperties.Any() || e.ChangeTracker.ObjectsRemovedFromCollectionProperties.Any()
select e).ToList(),
Products =
(from e in Products.AllEntities
where e.ChangeTracker.State != ObjectState.Unchanged || e.ChangeTracker.ObjectsAddedToCollectionProperties.Any() || e.ChangeTracker.ObjectsRemovedFromCollectionProperties.Any()
select e).ToList(),
CustomerDemographics =
(from e in CustomerDemographics.AllEntities
where e.ChangeTracker.State != ObjectState.Unchanged || e.ChangeTracker.ObjectsAddedToCollectionProperties.Any() || e.ChangeTracker.ObjectsRemovedFromCollectionProperties.Any()
select e).ToList(),
Categories =
(from e in Categories.AllEntities
where e.ChangeTracker.State != ObjectState.Unchanged || e.ChangeTracker.ObjectsAddedToCollectionProperties.Any() || e.ChangeTracker.ObjectsRemovedFromCollectionProperties.Any()
select e).ToList(),
Employees =
(from e in Employees.AllEntities
where e.ChangeTracker.State != ObjectState.Unchanged || e.ChangeTracker.ObjectsAddedToCollectionProperties.Any() || e.ChangeTracker.ObjectsRemovedFromCollectionProperties.Any()
select e).ToList(),
Members =
(from e in Members.AllEntities
where e.ChangeTracker.State != ObjectState.Unchanged || e.ChangeTracker.ObjectsAddedToCollectionProperties.Any() || e.ChangeTracker.ObjectsRemovedFromCollectionProperties.Any()
select e).ToList()
};
var sentContext = new ClientContext();
sentContext.Customers =
(from e in clientContext.Customers
select ReduceToModifications(e)).ToList();
sentContext.OrderDetailSet =
(from e in clientContext.OrderDetailSet
select ReduceToModifications(e)).ToList();
sentContext.Orders =
(from e in clientContext.Orders
select ReduceToModifications(e)).ToList();
sentContext.Products =
(from e in clientContext.Products
select ReduceToModifications(e)).ToList();
sentContext.CustomerDemographics =
(from e in clientContext.CustomerDemographics
select ReduceToModifications(e)).ToList();
sentContext.Categories =
(from e in clientContext.Categories
select ReduceToModifications(e)).ToList();
sentContext.Employees =
(from e in clientContext.Employees
select ReduceToModifications(e)).ToList();
sentContext.Members =
(from e in clientContext.Members
select ReduceToModifications(e)).ToList();
int nbCustomers = sentContext.Customers.Count;
for (int index = 0 ; index < nbCustomers ; index ++)
ReduceNavigationProperties(sentContext, clientContext.Customers[index], sentContext.Customers[index]);
int nbOrderDetailSet = sentContext.OrderDetailSet.Count;
for (int index = 0 ; index < nbOrderDetailSet ; index ++)
ReduceNavigationProperties(sentContext, clientContext.OrderDetailSet[index], sentContext.OrderDetailSet[index]);
int nbOrders = sentContext.Orders.Count;
for (int index = 0 ; index < nbOrders ; index ++)
ReduceNavigationProperties(sentContext, clientContext.Orders[index], sentContext.Orders[index]);
int nbProducts = sentContext.Products.Count;
for (int index = 0 ; index < nbProducts ; index ++)
ReduceNavigationProperties(sentContext, clientContext.Products[index], sentContext.Products[index]);
int nbCustomerDemographics = sentContext.CustomerDemographics.Count;
for (int index = 0 ; index < nbCustomerDemographics ; index ++)
ReduceNavigationProperties(sentContext, clientContext.CustomerDemographics[index], sentContext.CustomerDemographics[index]);
int nbCategories = sentContext.Categories.Count;
for (int index = 0 ; index < nbCategories ; index ++)
ReduceNavigationProperties(sentContext, clientContext.Categories[index], sentContext.Categories[index]);
int nbEmployees = sentContext.Employees.Count;
for (int index = 0 ; index < nbEmployees ; index ++)
ReduceNavigationProperties(sentContext, clientContext.Employees[index], sentContext.Employees[index]);
int nbMembers = sentContext.Members.Count;
for (int index = 0 ; index < nbMembers ; index ++)
ReduceNavigationProperties(sentContext, clientContext.Members[index], sentContext.Members[index]);
Refresh(clientContext, base.Channel.SaveChanges(sentContext));
} Pour la classe Product, la méthode ReduceNavigationProperties est la suivante :
private void ReduceNavigationProperties(ClientContext context, Product originalValue, Product newValue)
{
foreach (var relatedEntity in originalValue.ChangeTracker.OriginalValues)
{
switch (relatedEntity.Key)
{
case "Category":
var categoryParentEntity = (Category)relatedEntity.Value;
var newCategoryParentEntity = context.Categories.First(e => e.Id == categoryParentEntity.Id);
newValue.ChangeTracker.OriginalValues.Add("Category", newCategoryParentEntity);
ObjectList categoryParentEntityObjectList;
if (!newCategoryParentEntity.ChangeTracker.ObjectsRemovedFromCollectionProperties.TryGetValue("Products", out categoryParentEntityObjectList))
{
categoryParentEntityObjectList = new ObjectList();
newCategoryParentEntity.ChangeTracker.ObjectsRemovedFromCollectionProperties.Add("Products", categoryParentEntityObjectList);
}
categoryParentEntityObjectList.Add(newValue);
newValue.CategoryID = originalValue.CategoryID;
break;
}
}
switch (originalValue.ChangeTracker.State)
{
case ObjectState.Added:
case ObjectState.Deleted:
foreach (var subEntity in originalValue.OrderDetails.Where(se => se.ChangeTracker.State != ObjectState.Unchanged))
{
var relatedEntity = context.OrderDetailSet.First(e => e.OrderID == subEntity.OrderID && e.ProductID == subEntity.ProductID);
if (! newValue.OrderDetails.Contains(relatedEntity))
newValue.OrderDetails.Attach(relatedEntity);
}
if (originalValue.Category != null && originalValue.ChangeTracker.State == ObjectState.Unchanged)
{
var relatedEntity = context.Categories.First(e => e.Id == originalValue.Category.Id);
if (newValue.Category != relatedEntity)
newValue.Category = relatedEntity;
}
break;
}
}
Avec ce code (entièrement généré avec T4), le flux entre le client et le serveur est réduit au minimum.
Ensuite côté client, nous utilisons ModifiedProperties pour reportés les modifications (comme décrit dans mon dernier post).
A la place de :
context.ObjectStateManager.ChangeObjectState(entity, EntityState.Modified);
J’utilise le code suivant :
context.ObjectStateManager.ChangeObjectState(entity, EntityState.Unchanged);
var ose = context.ObjectStateManager.GetObjectStateEntry(entity);
ose.SetModified();
foreach (var propertyName in entity.ChangeTracker.ModifiedProperties)
ose.SetModifiedProperty(propertyName);
Du coup dans le cadre de mon exemple, l’UPDATE SQL se limite aux colonnes réellement modifiées:
exec sp_executesql N'update [dbo].[Products]
set [ProductName] = @0
where ([ProductID] = @1)
',N'@0 nvarchar(40),@1 int',@0=N'azerty',@1=1
Il reste à traiter la méthode Refresh. Dans celle-ci, il faut raffraîchir les propriétés de type Identity (pour l’ajout), Computed Properties pour le Add et l’Update, et les FK de type Identity quand l’entité liée est dans l’état Added. Il est impératif de se réduire à ces modifications parce que dans la mesure où on s’est limité à un clone réduit de l’entité les autres propriétés n’ayant aucune raison d’être renseignées côté serveur, elles garderont leur valeur par défaut.
Voici la méthode Refresh :
private void Refresh(ClientContext clientContext, ClientContext dbContext)
{
int customersCount = clientContext.Customers.Count;
for (int i = 0 ; i < customersCount ; i ++)
{
var clientEntity = clientContext.Customers
;
if (clientEntity.ChangeTracker.State == ObjectState.Deleted)
{
Customers.Detach(clientEntity);
continue;
}
var dbEntity = dbContext.Customers
;
RefreshComputedValues(clientEntity, dbEntity);
}
int orderDetailSetCount = clientContext.OrderDetailSet.Count;
for (int i = 0 ; i < orderDetailSetCount ; i ++)
{
var clientEntity = clientContext.OrderDetailSet
;
if (clientEntity.ChangeTracker.State == ObjectState.Deleted)
{
OrderDetailSet.Detach(clientEntity);
continue;
}
var dbEntity = dbContext.OrderDetailSet
;
RefreshComputedValues(clientEntity, dbEntity);
}
int ordersCount = clientContext.Orders.Count;
for (int i = 0 ; i < ordersCount ; i ++)
{
var clientEntity = clientContext.Orders
;
if (clientEntity.ChangeTracker.State == ObjectState.Deleted)
{
Orders.Detach(clientEntity);
continue;
}
var dbEntity = dbContext.Orders
;
RefreshComputedValues(clientEntity, dbEntity);
}
int productsCount = clientContext.Products.Count;
for (int i = 0 ; i < productsCount ; i ++)
{
var clientEntity = clientContext.Products
;
if (clientEntity.ChangeTracker.State == ObjectState.Deleted)
{
Products.Detach(clientEntity);
continue;
}
var dbEntity = dbContext.Products
;
RefreshComputedValues(clientEntity, dbEntity);
}
int customerDemographicsCount = clientContext.CustomerDemographics.Count;
for (int i = 0 ; i < customerDemographicsCount ; i ++)
{
var clientEntity = clientContext.CustomerDemographics
;
if (clientEntity.ChangeTracker.State == ObjectState.Deleted)
{
CustomerDemographics.Detach(clientEntity);
continue;
}
var dbEntity = dbContext.CustomerDemographics
;
RefreshComputedValues(clientEntity, dbEntity);
}
int categoriesCount = clientContext.Categories.Count;
for (int i = 0 ; i < categoriesCount ; i ++)
{
var clientEntity = clientContext.Categories
;
if (clientEntity.ChangeTracker.State == ObjectState.Deleted)
{
Categories.Detach(clientEntity);
continue;
}
var dbEntity = dbContext.Categories
;
RefreshComputedValues(clientEntity, dbEntity);
}
int employeesCount = clientContext.Employees.Count;
for (int i = 0 ; i < employeesCount ; i ++)
{
var clientEntity = clientContext.Employees
;
if (clientEntity.ChangeTracker.State == ObjectState.Deleted)
{
Employees.Detach(clientEntity);
continue;
}
var dbEntity = dbContext.Employees
;
bool typeFound = false;
var clientEntityAsEmployeeInActivity = clientEntity as EmployeeInActivity;
if (clientEntityAsEmployeeInActivity != null)
{
RefreshComputedValues(clientEntityAsEmployeeInActivity, (EmployeeInActivity)dbEntity);
typeFound = true;
}
var clientEntityAsFiredEmployee = clientEntity as FiredEmployee;
if (clientEntityAsFiredEmployee != null)
{
RefreshComputedValues(clientEntityAsFiredEmployee, (FiredEmployee)dbEntity);
typeFound = true;
}
var clientEntityAsOutEmployee = clientEntity as OutEmployee;
if (clientEntityAsOutEmployee != null)
{
RefreshComputedValues(clientEntityAsOutEmployee, (OutEmployee)dbEntity);
typeFound = true;
}
if (! typeFound)
RefreshComputedValues(clientEntity, dbEntity);
}
int membersCount = clientContext.Members.Count;
for (int i = 0 ; i < membersCount ; i ++)
{
var clientEntity = clientContext.Members
;
if (clientEntity.ChangeTracker.State == ObjectState.Deleted)
{
Members.Detach(clientEntity);
continue;
}
var dbEntity = dbContext.Members
;
RefreshComputedValues(clientEntity, dbEntity);
}
}
La méthode RefreshComputedValues pour Product est la suivante :
private void RefreshComputedValues(Product entity, Product dbEntity)
{
if (dbEntity.ChangeTracker.State == ObjectState.Added)
{
entity.ProductID = dbEntity.ProductID;
}
entity.IsDeserializing = true;
if (dbEntity.Category != null && dbEntity.Category.ChangeTracker.State == ObjectState.Added)
entity.CategoryID = dbEntity.CategoryID;
entity.IsDeserializing = false;
entity.ChangeTracker.AcceptChanges();
}
Je sais, je vais me répéter mais ce qui est vraiment cool dans mon approche est le fait que tout ce code est entièrement généré. Quand mon modèle d’entité est défini, je peux générer la base et je peux générer la plus grosse partie de mon code. De plus, mes templates générant des classes / interfaces partielles avec parfois des méthodes partielles je peux les étendre sans problème afin de rajouter du code spécifique (pour la logique métier par exemple). La seule chose qu’il me reste ensuite à coder est la couche de présentation.
Outre la productivité, il y a un autre intérêt de passer par T4. Vous avez pu remarquer dans mon précédent post que j’avais fait pas mal de tests sur le code généré à partir de mes T4. // Soit dit en passant j’ai encore augmenté le nombre de mes tests.
Sans m’engager sur le bug free, cela m’assure quand même une importante réduction du risque.
De plus et surtout, si je n’utilisais pas de template de génération de code, il me faudrait tester les relations one to many (un exemple parmi tant d’autre) pour Category / Product, Product / OrderDetail, Employee / Order, Customer / Order, Order / OrderDetail, etc. Avec du code écrit à la main on n’est, en effet, jamais à l’abri de l’erreur humaine (le copier coller foireux par exemple) et il n’est pas toujours possible / conseillé de tout factoriser. Avec du code généré, une fois qu’on a validé le bon fonctionnement des relations one to many, on n’a plus besoin de s’en soucier et ce, indépendamment des entités et indépendamment du projet sur lequel on travaille.
Welcome to the data driven world!
Même si les scenarii N-Tiers sont maintenant plus facile avec EF4 et les Self-Tracking entities, je pense qu’on est encore loin de la solution idéale.
Dans un post précédent, j’expliquais comment en utilisant T4, je pouvais complètement générer mon service WCF. Maintenant, qu’en est-il du tiers client ?
Dans ce même post, j’expliquais comment je pouvais “traduire” les requêtes LINQ par des paramètres de méthodes de mon service WCF.
J’ai procédé à quelques améliorations de cette partie. Cependant, mon objectif principal consistait à avoir un vrai contexte côté client. J’avais déjà fait cela pour ADO.NET Data Services en utilisant T4, J’en ai maintenant fait un pour WCF (toujours en utilisant T4 bien sûr).
Un de mes clients reproche à ADO.NET Data Services le fait que par défaut, quand on accède aux collections du contexte, on a systématiquement une requête vers le serveur. Par défaut, il voulait utiliser le cache. Avec ADO.NET Data Services, l’utilisation du cache est vraiment pas top (idem pour EF d’ailleurs).
Avec mon contexte, j’utilise par défaut le cache et j’utilise l’extension method AsQueryable pour générer une requête côté serveur.
Plutôt qu’un long discours, rien de tel que du code pour comprendre ce que fait mon contexte et en l’occurrence, les tests unitaires qui m’ont permis de valider le bon fonctionnement de mon code.
[TestClass]
public class UnitTest
{
[TestMethod]
public void TestService()
{
INorthwindService context = new NorthwindClientContext();
Assert.AreNotEqual(0, context.GetCustomers().Count);
}
[TestMethod]
public void TestFilter()
{
INorthwindService context = new NorthwindClientContext();
Assert.AreEqual(1, context.GetCustomers(null, "it.CustomerID='ALFKI'", null, null, null).Count);
}
[TestMethod]
public void TestLINQWhere()
{
using (var context = new NorthwindClientContext())
{
Assert.AreEqual(1, (from c in context.Customers.AsQueryable()
where c.CustomerID == "ALFKI"
select c).Count());
}
}
[TestMethod]
public void TestLINQWhere2()
{
using (var context = new NorthwindClientContext())
{
var customerID = "ALFKI";
Assert.AreEqual(1, (from c in context.Customers.AsQueryable()
where c.CustomerID == customerID
select c).Count());
}
}
[TestMethod]
public void TestLINQWhere3()
{
using (var context = new NorthwindClientContext())
{
var customer = new Customer { CustomerID = "ALFKI" };
Assert.AreEqual(1, (from c in context.Customers.AsQueryable()
where c.CustomerID == customer.CustomerID
select c).Count());
}
}
[TestMethod]
public void TestLINQWhere4()
{
using (var context = new NorthwindClientContext())
{
Assert.AreNotEqual(0, (from o in context.Orders.AsQueryable()
where o.OrderDate > new DateTime(1997, 1, 31)
select o).Count());
}
}
[TestMethod]
public void TestLINQWhere5()
{
using (var context = new NorthwindClientContext())
{
var d = new DateTime(1997, 1, 31);
Assert.AreNotEqual(0, (from o in context.Orders.AsQueryable()
where o.OrderDate > d
select o).Count());
}
}
[TestMethod]
public void TestLINQTake()
{
using (var context = new NorthwindClientContext())
{
Assert.AreEqual(2, (from c in context.Customers.AsQueryable()
select c).Take(2).Count());
}
}
[TestMethod]
public void TestFirstAndInclude()
{
using (var context = new NorthwindClientContext())
{
var order = (from o in context.Orders.AsQueryable().Include(Order.CUSTOMER_NAME).Include(Order.ORDERDETAILS_NAME)
where o.ShipCity == "PARIS"
orderby o.OrderDate
select o).Skip(2).First();
Assert.IsNotNull(order.Customer);
Assert.AreEqual(1, context.Customers.Count);
Assert.AreEqual(order.Customer, context.Customers[0]);
Assert.AreEqual(1, context.Orders.Count);
Assert.AreEqual(order, context.Orders[0]);
Assert.AreNotEqual(0, order.OrderDetails);
}
}
[TestMethod]
public void NoTracking()
{
using (var context = new NorthwindClientContext())
{
context.MergeOption = MergeOption.NoTracking;
var l = context.Customers.AsQueryable().ToList();
Assert.AreEqual(0, context.Customers.Count);
Assert.AreNotEqual(0, l.Count);
}
}
[TestMethod]
public void AppendOnly()
{
using (var context = new NorthwindClientContext())
{
context.MergeOption = MergeOption.AppendOnly;
var l = context.Customers.AsQueryable().ToList();
Assert.AreEqual(l.Count, context.Customers.Count);
}
}
[TestMethod]
public void AppendOnly2()
{
using (var context = new NorthwindClientContext())
{
context.MergeOption = MergeOption.AppendOnly;
var c = new Customer { CustomerID = "ALFKI" };
context.Customers.Attach(c);
context.Customers.AsQueryable().ToList();
Assert.IsNull(c.CompanyName);
Assert.AreEqual(0, c.ChangeTracker.ModifiedProperties.Count);
}
}
[TestMethod]
public void OverwriteChanges()
{
using (var context = new NorthwindClientContext())
{
context.MergeOption = MergeOption.OverwriteChanges;
var l = context.Customers.AsQueryable().ToList();
Assert.AreEqual(l.Count, context.Customers.Count);
}
}
[TestMethod]
public void OverwriteChanges2()
{
using (var context = new NorthwindClientContext())
{
context.MergeOption = MergeOption.OverwriteChanges;
var c = new Customer { CustomerID = "ALFKI" };
context.Customers.Attach(c);
context.Customers.AsQueryable().ToList();
Assert.IsNotNull(c.CompanyName);
Assert.AreEqual(0, c.ChangeTracker.ModifiedProperties.Count);
}
}
[TestMethod]
public void PreserveChanges()
{
using (var context = new NorthwindClientContext())
{
context.MergeOption = MergeOption.PreserveChanges;
var l = context.Customers.AsQueryable().ToList();
Assert.AreEqual(l.Count, context.Customers.Count);
}
}
[TestMethod]
public void PreserveChanges2()
{
using (var context = new NorthwindClientContext())
{
context.MergeOption = MergeOption.PreserveChanges;
var c = new Customer { CustomerID = "ALFKI" };
context.Customers.Attach(c);
context.Customers.AsQueryable().ToList();
Assert.IsNull(c.CompanyName);
Assert.AreNotEqual(0, c.ChangeTracker.ModifiedProperties.Count);
Assert.IsTrue(c.ChangeTracker.ModifiedProperties.Contains("CompanyName"));
}
}
[TestMethod]
public void Save()
{
using (var context = new NorthwindClientContext())
{
//Add
var psCount = context.Products.AsQueryable().Count();
var p = new Product { ProductName = "p" };
context.Products.Add(p);
context.SaveChanges();
Assert.AreNotEqual(0, p.ProductID);
Assert.AreEqual(ObjectState.Unchanged, p.ChangeTracker.State);
Assert.AreEqual(psCount + 1, context.Products.AsQueryable().ToList().Count);
//Update
p.ProductName = "p2";
Assert.AreEqual(1, p.ChangeTracker.ModifiedProperties.Count);
Assert.AreEqual("ProductName", p.ChangeTracker.ModifiedProperties[0]);
context.SaveChanges();
context.MergeOption = MergeOption.NoTracking;
var p2 = context.Products.AsQueryable().Where(pr => pr.ProductID == p.ProductID).First();
Assert.AreNotEqual(p, p2);
Assert.AreEqual("p2", p2.ProductName);
//Remove
context.Products.Remove(p);
Assert.AreEqual(ObjectState.Deleted, p.ChangeTracker.State);
context.SaveChanges();
Assert.AreEqual(psCount, context.Products.AsQueryable().ToList().Count);
}
}
[TestMethod]
public void Save2()
{
using (var context = new NorthwindClientContext())
{
var csCount = context.Categories.AsQueryable().Count();
var c1 = new Category { CategoryName = "cn" };
context.Categories.Add(c1);
var psCount = context.Products.AsQueryable().Count();
var p1 = new Product { ProductName = "p1" };
var p2 = new Product { ProductName = "p2" };
context.Products.Add(p1);
context.Products.Add(p2);
context.SaveChanges();
using (var context2 = new NorthwindClientContext())
{
Assert.AreEqual(csCount + 1, context2.Categories.AsQueryable().Count());
Assert.AreEqual(psCount + 2, context2.Products.AsQueryable().Count());
}
context.Categories.Remove(c1);
context.Products.Remove(p1);
p2.ProductName = "p2Bis";
context.SaveChanges();
using (var context2 = new NorthwindClientContext())
{
Assert.AreEqual(csCount, context2.Categories.AsQueryable().Count());
Assert.AreEqual(psCount + 1, context2.Products.AsQueryable().Count());
Assert.AreEqual("p2Bis", context2.Products.Last().ProductName);
}
context.Products.Remove(p2);
context.SaveChanges();
using (var context2 = new NorthwindClientContext())
{
Assert.AreEqual(psCount, context2.Products.AsQueryable().Count());
}
}
}
[TestMethod]
public void TestSaveManyToMany()
{
CustomerDemographic cd;
using (var context = new NorthwindClientContext())
{
cd = new CustomerDemographic { CustomerTypeID = "CD", CustomerDesc = "cd desc" };
context.CustomerDemographics.Add(cd);
context.SaveChanges();
}
using (var context = new NorthwindClientContext())
{
var c = context.Customers.AsQueryable().First();
c.CustomerDemographics.Add(cd);
Assert.AreEqual(ObjectState.Unchanged, cd.ChangeTracker.State);
Assert.AreEqual(1, context.CustomerDemographics.Count);
Assert.AreEqual(1, cd.Customers.Count);
context.SaveChanges();
}
using (var context = new NorthwindClientContext())
{
var c = context.Customers.AsQueryable().Include(Customer.CUSTOMERDEMOGRAPHICS_NAME).First();
Assert.AreNotEqual(0, context.CustomerDemographics.Count);
Assert.AreNotEqual(0, cd.Customers.Count);
context.CustomerDemographics.Remove(context.CustomerDemographics.First(cd2 => cd2.CustomerTypeID.TrimEnd() == "CD"));
context.SaveChanges();
}
}
[TestMethod]
public void TestAddViaCollection()
{
using (var context = new NorthwindClientContext())
{
int psCount;
using (var context2 = new NorthwindClientContext())
{
psCount = context2.Products.AsQueryable().Count();
}
var c = context.Categories.AsQueryable().First();
var p = new Product { ProductName = "pn" };
c.Products.Add(p);
Assert.AreEqual(1, c.Products.Count);
Assert.AreEqual(1, context.Products.Count);
Assert.AreEqual(context.Products[0], p);
Assert.AreEqual(p.Category, c);
Assert.AreEqual(p.CategoryID, c.Id);
Assert.AreEqual(ObjectState.Added, p.ChangeTracker.State);
context.SaveChanges();
using (var context2 = new NorthwindClientContext())
{
Assert.AreEqual(psCount + 1, context2.Products.AsQueryable().Count());
}
Assert.AreEqual(1, c.Products.Count);
context.Products.Remove(p);
Assert.AreEqual(0, c.Products.Count);
context.SaveChanges();
}
}
[TestMethod]
public void TestAddViaCollection2()
{
using (var context = new NorthwindClientContext())
{
int psCount;
using (var context2 = new NorthwindClientContext())
{
psCount = context2.Products.AsQueryable().Count();
}
var c = context.Categories.AsQueryable().First();
var p = new Product { ProductName = "pn", Category = c };
Assert.AreEqual(1, c.Products.Count);
Assert.AreEqual(1, context.Products.Count);
Assert.AreEqual(context.Products[0], p);
Assert.AreEqual(p.Category, c);
Assert.AreEqual(p.CategoryID, c.Id);
Assert.AreEqual(ObjectState.Added, p.ChangeTracker.State);
context.SaveChanges();
using (var context2 = new NorthwindClientContext())
{
Assert.AreEqual(psCount + 1, context2.Products.AsQueryable().Count());
}
Assert.AreEqual(1, c.Products.Count);
context.Products.Remove(p);
Assert.AreEqual(0, c.Products.Count);
context.SaveChanges();
}
}
[TestMethod]
public void TestAddViaCollection3()
{
using (var context = new NorthwindClientContext())
{
var c = new Category { CategoryName = "cn" };
context.Categories.Add(c);
var p = new Product { ProductName = "pn", Category = c };
Assert.AreEqual(1, c.Products.Count);
Assert.AreEqual(1, context.Products.Count);
Assert.AreEqual(context.Products[0], p);
Assert.AreEqual(c, p.Category);
Assert.AreEqual(c.Id, p.CategoryID);
Assert.AreEqual(1, context.Categories.Count);
Assert.AreEqual(c, context.Categories[0]);
context.SaveChanges();
Assert.AreEqual(c, p.Category);
Assert.AreEqual(c.Id, p.CategoryID);
Assert.AreEqual(1, c.Products.Count);
Assert.AreEqual(p, c.Products[0]);
context.Categories.Remove(c);
Assert.IsNull(p.Category);
Assert.IsNull(p.CategoryID);
Assert.AreEqual(ObjectState.Modified, p.ChangeTracker.State);
Assert.AreEqual(1, p.ChangeTracker.ModifiedProperties.Count);
Assert.AreEqual("CategoryID", p.ChangeTracker.ModifiedProperties[0]);
context.SaveChanges();
context.Products.Remove(p);
context.SaveChanges();
}
}
[TestMethod]
public void TestAddViaCollection4()
{
using (var context = new NorthwindClientContext())
{
var c = new Category { CategoryName = "cn" };
var p = new Product { ProductName = "pn", Category = c };
context.Categories.Add(c);
Assert.AreEqual(1, c.Products.Count);
Assert.AreEqual(1, context.Products.Count);
Assert.AreEqual(context.Products[0], p);
Assert.AreEqual(p.Category, c);
Assert.AreEqual(p.CategoryID, c.Id);
Assert.AreEqual(1, context.Categories.Count);
Assert.AreEqual(c, context.Categories[0]);
}
}
[TestMethod]
public void TestAddViaCollection5()
{
using (var context = new NorthwindClientContext())
{
var c = new Customer { CustomerID = "A" };
var cd = new CustomerDemographic();
c.CustomerDemographics.Add(cd);
context.Customers.Attach(c);
Assert.AreEqual(1, context.Customers.Count);
Assert.AreEqual(1, context.CustomerDemographics.Count);
Assert.AreEqual(c, context.Customers[0]);
Assert.AreEqual(cd, context.CustomerDemographics[0]);
Assert.AreEqual(1, cd.Customers.Count);
}
}
[TestMethod]
[ExpectedException(typeof(InvalidOperationException))]
public void TestEntitySetAttachWithSameKey()
{
using (var context = new NorthwindClientContext())
{
context.Categories.Attach(new Category { Id = 1 });
context.Categories.Attach(new Category { Id = 1 });
}
}
[TestMethod]
[ExpectedException(typeof(InvalidOperationException))]
public void TestEntitySetAddWithSameKey()
{
using (var context = new NorthwindClientContext())
{
context.Customers.Add(new Customer { CustomerID = "C" });
context.Customers.Add(new Customer { CustomerID = "C" });
}
}
[TestMethod]
public void TestEntitySetAddWithSameKeyIdentity()
{
using (var context = new NorthwindClientContext())
{
context.Categories.Add(new Category { Id = 0 });
context.Categories.Add(new Category { Id = 0 });
}
}
[TestMethod]
[ExpectedException(typeof(InvalidOperationException))]
public void TestNavigationPropertyManyAttachWithSameKey()
{
using (var context = new NorthwindClientContext())
{
var cd = new CustomerDemographic { CustomerTypeID = "CD" };
context.CustomerDemographics.Attach(cd);
context.Customers.Attach(new Customer { CustomerID = "C" });
cd.Customers.Attach(new Customer { CustomerID = "C" });
}
}
[TestMethod]
[ExpectedException(typeof(InvalidOperationException))]
public void TestNavigationPropertyManyAddWithSameKey()
{
using (var context = new NorthwindClientContext())
{
var cd = new CustomerDemographic { CustomerTypeID = "CD" };
context.CustomerDemographics.Attach(cd);
context.Customers.Attach(new Customer { CustomerID = "C" });
cd.Customers.Add(new Customer { CustomerID = "C" });
}
}
[TestMethod]
public void MultipleAddAttachOfSameObject()
{
using (var context = new NorthwindClientContext())
{
var c = new Customer { CustomerID = "C" };
context.Customers.Add(c);
context.Customers.Add(c);
context.Customers.Attach(c);
new CustomerDemographic { CustomerTypeID = "CD" }.Customers.Add(c);
Assert.AreEqual(1, context.Customers.Count);
}
}
[TestMethod]
public void MultipleRelationAddAttachOfSameObject()
{
using (var context = new NorthwindClientContext())
{
var c = new Customer { CustomerID = "C" };
var cd = new CustomerDemographic { CustomerTypeID = "CD" };
c.CustomerDemographics.Add(cd);
c.CustomerDemographics.Add(cd);
c.CustomerDemographics.Attach(cd);
Assert.AreEqual(1, c.CustomerDemographics.Count);
Assert.AreEqual(1, cd.Customers.Count);
}
}
[TestMethod]
public void DeleteCascade()
{
using (var context = new NorthwindClientContext())
{
var o = new Order();
var od = new OrderDetail { Order = o };
Assert.AreEqual(ObjectState.Detached, o.ChangeTracker.State);
Assert.AreEqual(ObjectState.Detached, od.ChangeTracker.State);
context.Orders.Add(o);
Assert.AreEqual(ObjectState.Added, o.ChangeTracker.State);
Assert.AreEqual(ObjectState.Added, od.ChangeTracker.State);
context.Orders.Remove(o);
Assert.AreEqual(ObjectState.Detached, o.ChangeTracker.State);
Assert.AreEqual(ObjectState.Detached, od.ChangeTracker.State);
}
}
[TestMethod]
public void DeleteCascade2()
{
using (var context = new NorthwindClientContext())
{
var o = new Order();
var od = new OrderDetail { Order = o, Product = context.Products.AsQueryable().First(), Discount = 0, Quantity = 1, UnitPrice = 10 };
context.Orders.Add(o);
context.SaveChanges();
var od2 = new OrderDetail { Order = o };
context.Orders.Remove(o);
Assert.AreEqual(ObjectState.Deleted, o.ChangeTracker.State);
Assert.AreEqual(ObjectState.Deleted, od.ChangeTracker.State);
Assert.AreEqual(ObjectState.Detached, od2.ChangeTracker.State);
context.SaveChanges();
}
}
[TestMethod]
public void AttachRelationInLoad()
{
using (var context = new NorthwindClientContext())
{
var c = new Category { Id = 1 };
context.Categories.Attach(c);
var p = new Product { CategoryID = 1 };
context.Products.Attach(p);
Assert.AreEqual(c, p.Category);
Assert.AreEqual(1, c.Products.Count);
Assert.AreEqual(p, c.Products[0]);
}
}
[TestMethod]
public void AttachRelationInLoad2()
{
using (var context = new NorthwindClientContext())
{
var c = new Category { Id = 1 };
context.Categories.Add(c);
var p = new Product { CategoryID = 1 };
context.Products.Add(p);
Assert.AreEqual(c, p.Category);
Assert.AreEqual(1, c.Products.Count);
Assert.AreEqual(p, c.Products[0]);
}
}
[TestMethod]
public void AttachRelationInLoad3()
{
using (var context = new NorthwindClientContext())
{
var p = new Product { CategoryID = 1 };
context.Products.Attach(p);
var c = new Category { Id = 1 };
context.Categories.Attach(c);
Assert.AreEqual(c, p.Category);
Assert.AreEqual(1, c.Products.Count);
Assert.AreEqual(p, c.Products[0]);
}
}
[TestMethod]
public void AttachRelationInLoad4()
{
using (var context = new NorthwindClientContext())
{
var p = new Product { CategoryID = 1 };
context.Products.Add(p);
var c = new Category { Id = 1 };
context.Categories.Add(c);
Assert.AreEqual(c, p.Category);
Assert.AreEqual(1, c.Products.Count);
Assert.AreEqual(p, c.Products[0]);
}
}
[TestMethod]
public void AttachRelationInLoad5()
{
using (var context = new NorthwindClientContext())
{
context.Products.AsQueryable().ToList();
var c = context.Categories.AsQueryable().First();
Assert.AreEqual(context.Products.Where(p => p.CategoryID == c.Id).Count(), c.Products.Count);
foreach (var p in context.Products.Where(p => p.CategoryID == c.Id))
{
Assert.AreEqual(c, p.Category);
Assert.IsTrue(c.Products.Contains(p));
}
}
}
[TestMethod]
public void AttachRelationInLoad6()
{
using (var context = new NorthwindClientContext())
{
var c = context.Categories.AsQueryable().First();
context.Products.AsQueryable().ToList();
Assert.AreEqual(context.Products.Where(p => p.CategoryID == c.Id).Count(), c.Products.Count);
foreach (var p in context.Products.Where(p => p.CategoryID == c.Id))
{
Assert.AreEqual(c, p.Category);
Assert.IsTrue(c.Products.Contains(p));
}
}
}
[TestMethod]
public void AttachRelationInLoad7()
{
using (var context = new NorthwindClientContext())
{
context.Products.AsQueryable().ToList();
var c = context.Categories.AsQueryable().Include(Category.PRODUCTS_NAME).First();
Assert.AreEqual(context.Products.Where(p => p.CategoryID == c.Id).Count(), c.Products.Count);
foreach (var p in context.Products.Where(p => p.CategoryID == c.Id))
{
Assert.AreEqual(c, p.Category);
Assert.IsTrue(c.Products.Contains(p));
}
}
}
[TestMethod]
public void AttachRelationInLoad8()
{
using (var context = new NorthwindClientContext())
{
context.Products.AsQueryable().ToList();
var c = new Category { Id = 1 };
context.Categories.Attach(c);
var c2 = context.Categories.AsQueryable().Include(Category.PRODUCTS_NAME).First();
Assert.AreEqual(c, c2);
Assert.AreEqual(context.Products.Where(p => p.CategoryID == c.Id).Count(), c.Products.Count);
foreach (var p in context.Products.Where(p => p.CategoryID == c.Id))
{
Assert.AreEqual(c, p.Category);
Assert.IsTrue(c.Products.Contains(p));
}
}
}
[TestMethod]
public void AttachRelationInLoad9()
{
using (var context = new NorthwindClientContext())
{
var c = new Category { CategoryName = "cn" };
var p = new Product { ProductName = "pn", Category = c };
context.Categories.Add(c);
Assert.AreEqual(1, context.Products.Count);
context.SaveChanges();
Product p2;
using (var context2 = new NorthwindClientContext())
{
var c2 = context2.Categories.AsQueryable().Include(Category.PRODUCTS_NAME).Where(c3 => c3.Id == c.Id).First();
p2 = new Product { ProductName = "pn2" };
c2.Products.Add(p2);
context2.SaveChanges();
}
var c4 = context.Categories.AsQueryable().Include(Category.PRODUCTS_NAME).Where(c3 => c3.Id == c.Id).First();
Assert.AreEqual(c, c4);
Assert.IsTrue(c.Products.Contains(p));
Assert.IsFalse(c.Products.Contains(p2));
var p3 = c.Products.First(p4 => p4.ProductID == p2.ProductID);
Assert.IsTrue(c.Products.Contains(p3));
Assert.IsTrue(context.Products.Contains(p3));
context.Products.Remove(p3);
context.Products.Remove(p);
context.Categories.Remove(c);
Assert.AreEqual(c.Id, p.CategoryID);
Assert.AreEqual(c.Id, p3.CategoryID);
context.SaveChanges();
}
}
[TestMethod]
public void AttachRelationInLoad10()
{
using (var context = new NorthwindClientContext())
{
var p = context.Products.AsQueryable().First();
context.Orders.Attach(new Order());
var od = context.OrderDetails.AsQueryable().Where(od2 => od2.ProductID == p.ProductID).First();
var p2 = (from p3 in context.Products.AsQueryable().Include(Product.CATEGORY_NAME).Include(string.Concat(Product.ORDERDETAILS_NAME, ".", OrderDetail.ORDER_NAME))
where p3.ProductID == p.ProductID
select p3).First();
Assert.AreEqual(p, p2);
Assert.IsNotNull(p.Category);
Assert.AreEqual(1, context.Categories.Count);
Assert.AreEqual(p.OrderDetails.Count, context.OrderDetails.Count);
foreach (var od2 in p.OrderDetails)
Assert.IsNotNull(od2.Order);
Assert.AreEqual(p.OrderDetails.Select(od3 => od3.Order).Distinct().Count() + 1, context.Orders.Count);
Assert.AreNotEqual(0, context.Orders.Count);
}
}
[TestMethod]
[ExpectedException(typeof(InvalidOperationException))]
public void AddSameEntityInDifferentContext()
{
var p = new Product();
using (var context1 = new NorthwindClientContext())
{
context1.Products.Add(p);
using (var context2 = new NorthwindClientContext())
{
context2.Products.Add(p);
}
}
}
[TestMethod]
[ExpectedException(typeof(InvalidOperationException))]
public void AddSameEntityFromServiceInDifferentContext()
{
Product p;
using (var context1 = new NorthwindClientContext())
{
p = context1.Products.AsQueryable().First();
using (var context2 = new NorthwindClientContext())
{
context2.Products.Add(p);
}
}
}
[TestMethod]
public void AddSameEntityInDifferentContextWithDispose()
{
var p = new Product();
using (var context1 = new NorthwindClientContext())
{
context1.Products.Add(p);
}
using (var context2 = new NorthwindClientContext())
{
context2.Products.Add(p);
}
}
[TestMethod]
[ExpectedException(typeof(InvalidOperationException))]
public void AddSameEntityInDifferentContextWithDisposeAndConflicts()
{
var c = new Category { Id = 1 };
var p1 = new Product { ProductID = 1, Category = c };
var p2 = new Product { ProductID = 2 , CategoryID = 1};
using (var context1 = new NorthwindClientContext())
{
context1.Categories.Attach(c);
context1.Products.Attach(p2);
Assert.AreEqual(2, context1.Products.Count);
Assert.AreEqual(2, c.Products.Count);
Assert.AreEqual(c, p2.Category);
}
using (var context2 = new NorthwindClientContext())
{
var p3 = new Product { ProductID = 1 , CategoryID = 1};
context2.Products.Attach(p3);
context2.Categories.Attach(c);
}
}
[TestMethod]
public void LoadFromOneToMany()
{
using (var context = new NorthwindClientContext())
{
var c = context.Categories.AsQueryable().First();
Assert.AreEqual(0, c.Products.Count);
Assert.AreEqual(0, context.Products.Count);
var ps = c.LoadProducts();
Assert.AreNotEqual(0, ps.Count);
Assert.AreEqual(ps.Count, c.Products.Count);
Assert.AreEqual(ps.Count, context.Products.Count);
foreach (var p in ps)
Assert.AreEqual(c, p.Category);
}
}
[TestMethod]
[ExpectedException(typeof(InvalidOperationException))]
public void LoadFromOneToManyDetached()
{
Category c;
using (var context = new NorthwindClientContext())
{
c = context.Categories.AsQueryable().First();
}
var ps = c.LoadProducts();
}
[TestMethod]
public void LoadFromManyToOne()
{
using (var context = new NorthwindClientContext())
{
var p = context.Products.AsQueryable().First();
Assert.IsNull(p.Category);
Assert.AreEqual(0, context.Categories.Count);
var c = p.LoadCategory();
Assert.IsNotNull(c);
Assert.AreEqual(c, p.Category);
Assert.AreEqual(1, context.Categories.Count);
Assert.AreEqual(1, c.Products.Count);
Assert.AreEqual(p, c.Products[0]);
}
}
[TestMethod]
public void LoadFromManyToMany()
{
using (var context = new NorthwindClientContext())
{
var c = (from cust in context.Customers.AsQueryable()
where cust.CustomerID == "VINET"
select cust).First();
Assert.AreEqual(0, c.CustomerDemographics.Count);
Assert.AreEqual(0, context.CustomerDemographics.Count);
c.LoadCustomerDemographics();
Assert.AreNotEqual(0, c.CustomerDemographics.Count);
Assert.AreEqual(c.CustomerDemographics.Count, context.CustomerDemographics.Count);
}
}
[TestMethod]
public void LoadFromOneToOne()
{
using (var context = new NorthwindClientContext())
{
var c = context.Customers.AsQueryable().First();
Assert.IsNull(c.Member);
Assert.AreEqual(0, context.Members.Count);
var m = c.LoadMember();
Assert.IsNotNull(c.Member);
Assert.AreEqual(1, context.Members.Count);
Assert.AreEqual(c, m.Customer);
}
}
[TestMethod]
public void OfType()
{
using (var context = new NorthwindClientContext())
{
var employeesInActivity = context.Employees.AsQueryable().OfType<EmployeeInActivity>().ToList();
Assert.AreEqual(employeesInActivity.Count, context.Employees.Count);
Assert.AreEqual(employeesInActivity.Count, context.EmployeeInActivities.Count);
Assert.AreEqual(0, context.OutEmployees.Count);
}
}
[TestMethod]
public void AddIntoBaseEntitySet()
{
using (var context = new NorthwindClientContext())
{
var firedEmployee = new FiredEmployee();
context.Employees.Attach(firedEmployee);
Assert.AreEqual(1, context.Employees.Count);
Assert.AreEqual(1, context.OutEmployees.Count);
Assert.AreEqual(1, context.FiredEmployees.Count);
}
}
[TestMethod]
public void GetAllEntitiesWithInheritance()
{
using (var context = new NorthwindClientContext())
{
var l = context.Employees.AsQueryable().ToList();
Assert.AreEqual(l.Count, context.Employees.Count);
Assert.AreEqual(l.OfType<OutEmployee>().Count(), context.OutEmployees.Count);
Assert.AreEqual(l.OfType<FiredEmployee>().Count(), context.FiredEmployees.Count);
Assert.AreNotEqual(0, context.FiredEmployees.Count);
}
}
[TestMethod]
public void Detach()
{
using (var context = new NorthwindClientContext())
{
var c = new Category { Id = 1 };
context.Categories.Attach(c);
var p = new Product { CategoryID = 1 };
context.Products.Attach(p);
Assert.AreEqual(c, p.Category);
context.Categories.Detach(c);
Assert.IsNull(p.Category);
Assert.AreEqual(c.Id, p.CategoryID);
}
}
[TestMethod]
public void UpdateFK()
{
using (var context = new NorthwindClientContext())
{
var c1 = new Category { Id = 1 };
context.Categories.Attach(c1);
var c2 = new Category { Id = 2 };
context.Categories.Attach(c2);
var p = new Product { CategoryID = 1 };
context.Products.Attach(p);
Assert.AreEqual(c1, p.Category);
Assert.AreEqual(1, c1.Products.Count);
p.CategoryID = 2;
Assert.AreEqual(c2, p.Category);
Assert.AreEqual(0, c1.Products.Count);
Assert.AreEqual(1, c2.Products.Count);
}
}
}
Pour mes tests, j’ai utilisé l’EDM suivant:
En plus d’avoir un vrai contexte côté client, j’ai également procédé à une petite amélioration de l’update. Avec le template self-tracking par défaut, le SQL généré lors d’un update inclut toutes les colonnes mappées sur l’entité même si une seule a été modifiée. Avec mon implémentation, l’update ne va contenir que les colonnes modifiées. A noter que ceci n’est pas vrai pour les complex types. Si une seule propriété du complex type a été modifiée, l’update généré inclura toute les colonnes mappées sur le complex type (c’est une limitation de la version actuelle d’EF).
Pour réduire l’update généré, j’ai ajouté une liste de string ModifiedProperties dans la classe ObjectChangeTracker (pour cela, j’ai mis à jour le template T4 self-tracking types). Ensuite, j’ai également changé le template T4 self-tracking context en remplaçant :
context.ObjectStateManager.ChangeObjectState(entity, EntityState.Modified);
par
context.ObjectStateManager.ChangeObjectState(entity, EntityState.Unchanged);
var ose = context.ObjectStateManager.GetObjectStateEntry(entity);
ose.SetModified();
foreach (var propertyName in entity.ChangeTracker.ModifiedProperties)
ose.SetModifiedProperty(propertyName);
Si vous voulez en savoir plus sur ces templates, je vous donne rendez-vous à ma session sur EF au prochain Microsoft Tech Days à Paris et / ou au confoo à Montreal (en français également).
Je n’avais pas bloggué sur la deuxième partie bien que je la trouvait très bonne (mon blog ayant vocation a être un minimum sérieux). Cependant la troisième est tellement excellente que je ne peux pas résister.
Prochain MVP Summit, je veux rencontrer Tina ! 
Contrairement à l’approche de Julie, je suis parti sur une approche Self-Tracking Entities et T4 à fond.
La première étape consiste créer un projet que nous appellerons DAL et dans lequel, on va intégrer un edmx.
Dans mon exemple, je suis parti sur Northwind avec seulement les tables Customers, Orders et [Order Details].
Une fois cela fait, on va rajouter un nouvel item de type ADO.NET Self-Tracking Entity Generator à notre projet.
Cela aura pour effet de générer deux templates de génération de code T4 :
- un qui contiendra les entités + des classes / interfaces destinées au Self-Tracking
- un qui contiendra notre contexte + une classe d’extension methods.
Nous allons déplacer notre template d’entités dans un projet dédié : Entities.
Après avoir rajouté la référence vers Entities à notre projet DAL, nous allons définir dans nos templates le chemin vers l’edmx.
Ensuite, nous allons définir un projet Repositories. Dans ce projet, nous allons définir une interface INorthwindRepository.
Vous pouvez remarquer la redondance de INorthwindRepository. C’est pourquoi, dans mon exemple, ces deux interfaces sont générées avec un template T4 se basant sur mon edmx. Ainsi, même si je change de modèle de données, je n’aurai rien à changer, juste à regénérer le code de mes templates.
Maintenant, je vais revenir sur notre contexte. En effet, je vais lui faire implémenter INorthwindRepository. Pour cela, je vais modifier le template T4 de manière à obtenir la classe suivante :
Qui dit service WCF dit Service Contract. Je vais donc me créer deux nouveaux projets : Services et ServiceContracts (basés tous les deux sur T4).
Je rappelle qu’à ce moment là, si vous avez déjà les templates T4 (réutilisables d’un projet sur l’autre), vous n’avez toujours pas écrit une ligne de code.
On peut constater l’indépendance des assemblies vis-à-vis de Entity Framework (à l’exception de la couche DAL bien sûr) :
Voulant utiliser Unity, j’ai ensuite choisi l’approche proposée par Alexey Zakharov. J’ai donc intégrer son projet WCFFacility et les classes Bootstrapper et UnityServiceLocatorAdapter dans mon projet WCFService. Une fois le svc et le fichier de config définit, mon service est terminé !
Maintenant côté client, je voulais rajouter l’utilisation d’un “faux” provider LINQ. Pour cela, je me suis basé sur ce que j’avais déjà fait précédemment lors d’une pres pour la communauté ALT.NET française.
Je me suis d’abord créé un projet Client.LINQ dans lequel j’ai défini deux classes : ClientLINQ et MyQueryable.
public static class ClientLINQ
{
public static MyQueryable<T> Where<T>(this MyQueryable<T> source, Expression<Func<T, bool>> where)
{
source.WhereValue = string.Concat(source.WhereValue ?? "", where.Body.ToString().Replace(string.Format("{0}.", where.Parameters[0].Name), "it.").Replace("\"", "'").Replace("||", " OR ").Replace("&&", " AND "));
return source;
}
public static MyQueryable<T> OrderBy<T, T2>(this MyQueryable<T> source, Expression<Func<T, T2>> orderBy)
{
source.OrderByValue = orderBy.Body.ToString().Replace(string.Format("{0}.", orderBy.Parameters[0].Name), "it.");
return source;
}
public static MyQueryable<T> Include<T>(this MyQueryable<T> source, string include)
{
source.IncludeValues.Add(include);
return source;
}
public static MyQueryable<T> Skip<T>(this MyQueryable<T> source, int number)
{
source.SkipValue = number;
return source;
}
public static MyQueryable<T> Take<T>(this MyQueryable<T> source, int number)
{
source.TakeValue = number;
return source;
}
public static T FirstOrDefault<T>(this MyQueryable<T> source)
{
source.TakeValue = 1;
return source.AsEnumerable().FirstOrDefault();
}
public static T First<T>(this MyQueryable<T> source)
{
source.TakeValue = 1;
return source.AsEnumerable().First();
}
public static MyQueryable<T> ToMyQueryable<T>(this IEnumerable<T> source)
{
var value = source as MyQueryable<T>;
if (value == null)
value = new MyQueryable<T>(source);
return value;
}
}
public class MyQueryable<T> : IEnumerable<T>
{
public MyQueryable()
{
}
public MyQueryable(IEnumerable<T> enumerable)
{
Enumerable = enumerable;
}
public IEnumerable<T> Enumerable { get; set; }
public IEnumerator<T> GetEnumerator()
{
return Enumerable.GetEnumerator();
}
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator()
{
return GetEnumerator();
}
private bool _allEntities = true;
public bool AllEntities
{
get { return _allEntities; }
internal set
{
_allEntities = value;
if (value)
{
IncludeValues.Clear();
WhereValue = null;
OrderByValue = null;
SkipValue = null;
TakeValue = null;
}
}
}
private ObservableCollection<string> _includeValues;
public ObservableCollection<string> IncludeValues
{
get
{
if (_includeValues == null)
{
_includeValues = new ObservableCollection<string>();
_includeValues.CollectionChanged += (sender, e) =>
{
if (e.Action == NotifyCollectionChangedAction.Add)
AllEntities = false;
};
}
return _includeValues;
}
}
private string _whereValue;
public string WhereValue
{
get { return _whereValue; }
internal set
{
if (value != null)
AllEntities = false;
_whereValue = value;
}
}
private string _orderByValue;
public string OrderByValue
{
get { return _orderByValue; }
internal set
{
if (value != null)
AllEntities = false;
_orderByValue = value;
}
}
private int? _skipValue;
public int? SkipValue
{
get { return _skipValue; }
internal set
{
if (value.HasValue)
AllEntities = false;
_skipValue = value;
}
}
private int? _takeValue;
public int? TakeValue
{
get { return _takeValue; }
internal set
{
if (value.HasValue)
AllEntities = false;
_takeValue = value;
}
}
}
Cela vous paraît peut-être bizarre pour l’instant mais attendez de voir la suite.
Ensuite, je me suis créé un projet de Test. Dans ce projet, j’ajoute la référence vers mon service wcf en incluant la référence vers mon projet Entities et celle vers Client.LINQ. Pour pouvoir utiliser mon “faux” provider LINQ, il me faut des MyQueryable de mes types d’entités. Là-aussi, (j’espère que vous l’aviez deviné), template T4.
public partial class NorthwindClientContext
{
private INorthwindService _service;
public NorthwindClientContext(INorthwindService service)
{
_service = service;
}
public MyQueryable<Customer> Customers
{
get
{
var value = new MyQueryable<Customer>();
value.Enumerable = GetCustomers(value);
return value;
}
}
private IEnumerable<Customer> GetCustomers(MyQueryable<Customer> myQueryable)
{
IEnumerable<Customer> value;
if (myQueryable.AllEntities)
value = _service.GetAllCustomers();
else
value = _service.GetCustomers(myQueryable.IncludeValues.ToList(), myQueryable.WhereValue, myQueryable.OrderByValue, myQueryable.SkipValue, myQueryable.TakeValue);
foreach (var entity in value)
yield return entity;
}
public MyQueryable<Order> Orders
{
get
{
var value = new MyQueryable<Order>();
value.Enumerable = GetOrders(value);
return value;
}
}
private IEnumerable<Order> GetOrders(MyQueryable<Order> myQueryable)
{
IEnumerable<Order> value;
if (myQueryable.AllEntities)
value = _service.GetAllOrders();
else
value = _service.GetOrders(myQueryable.IncludeValues.ToList(), myQueryable.WhereValue, myQueryable.OrderByValue, myQueryable.SkipValue, myQueryable.TakeValue);
foreach (var entity in value)
yield return entity;
}
public MyQueryable<OrderDetail> OrderDetails
{
get
{
var value = new MyQueryable<OrderDetail>();
value.Enumerable = GetOrderDetails(value);
return value;
}
}
private IEnumerable<OrderDetail> GetOrderDetails(MyQueryable<OrderDetail> myQueryable)
{
IEnumerable<OrderDetail> value;
if (myQueryable.AllEntities)
value = _service.GetAllOrderDetails();
else
value = _service.GetOrderDetails(myQueryable.IncludeValues.ToList(), myQueryable.WhereValue, myQueryable.OrderByValue, myQueryable.SkipValue, myQueryable.TakeValue);
foreach (var entity in value)
yield return entity;
}
}
L’utilisation du yield return me permet une exécution différé et m’assure que les propriétés de MyQueryable sont correctement renseignées lorsque la méthode GetCustomers / GetOrders / GetOrderDetails est appelée.
Ainsi, le code suivant :
var order = (from o in new NorthwindClientContext(service).Orders.Include("Customer").Include("OrderDetails")
where o.ShipCity == "PARIS"
orderby o.OrderDate
select o).Skip(2).First();
génèrera un appel à la méthode
_service.GetOrders(myQueryable.IncludeValues.ToList(), myQueryable.WhereValue, myQueryable.OrderByValue, myQueryable.SkipValue, myQueryable.TakeValue)
avec les paramètres suivants :
Ce qui génèrera la requête SQL suivante incluant tout cela :
SELECT
[Project1].[OrderID] AS [OrderID],
[Project1].[CustomerID] AS [CustomerID],
[Project1].[EmployeeID] AS [EmployeeID],
[Project1].[OrderDate] AS [OrderDate],
[Project1].[RequiredDate] AS [RequiredDate],
[Project1].[ShippedDate] AS [ShippedDate],
[Project1].[ShipVia] AS [ShipVia],
[Project1].[Freight] AS [Freight],
[Project1].[ShipName] AS [ShipName],
[Project1].[ShipAddress] AS [ShipAddress],
[Project1].[ShipCity] AS [ShipCity],
[Project1].[ShipRegion] AS [ShipRegion],
[Project1].[ShipPostalCode] AS [ShipPostalCode],
[Project1].[ShipCountry] AS [ShipCountry],
[Project1].[CustomerID1] AS [CustomerID1],
[Project1].[CompanyName] AS [CompanyName],
[Project1].[ContactName] AS [ContactName],
[Project1].[ContactTitle] AS [ContactTitle],
[Project1].[Address] AS [Address],
[Project1].[City] AS [City],
[Project1].[Region] AS [Region],
[Project1].[PostalCode] AS [PostalCode],
[Project1].[Country] AS [Country],
[Project1].[Phone] AS [Phone],
[Project1].[Fax] AS [Fax],
[Project1].[C1] AS [C1],
[Project1].[OrderID1] AS [OrderID1],
[Project1].[ProductID] AS [ProductID],
[Project1].[UnitPrice] AS [UnitPrice],
[Project1].[Quantity] AS [Quantity],
[Project1].[Discount] AS [Discount]
FROM ( SELECT
[Limit1].[OrderID] AS [OrderID],
[Limit1].[CustomerID1] AS [CustomerID],
[Limit1].[EmployeeID] AS [EmployeeID],
[Limit1].[OrderDate] AS [OrderDate],
[Limit1].[RequiredDate] AS [RequiredDate],
[Limit1].[ShippedDate] AS [ShippedDate],
[Limit1].[ShipVia] AS [ShipVia],
[Limit1].[Freight] AS [Freight],
[Limit1].[ShipName] AS [ShipName],
[Limit1].[ShipAddress] AS [ShipAddress],
[Limit1].[ShipCity] AS [ShipCity],
[Limit1].[ShipRegion] AS [ShipRegion],
[Limit1].[ShipPostalCode] AS [ShipPostalCode],
[Limit1].[ShipCountry] AS [ShipCountry],
[Limit1].[CustomerID2] AS [CustomerID1],
[Limit1].[CompanyName] AS [CompanyName],
[Limit1].[ContactName] AS [ContactName],
[Limit1].[ContactTitle] AS [ContactTitle],
[Limit1].[Address] AS [Address],
[Limit1].[City] AS [City],
[Limit1].[Region] AS [Region],
[Limit1].[PostalCode] AS [PostalCode],
[Limit1].[Country] AS [Country],
[Limit1].[Phone] AS [Phone],
[Limit1].[Fax] AS [Fax],
[Extent3].[OrderID] AS [OrderID1],
[Extent3].[ProductID] AS [ProductID],
[Extent3].[UnitPrice] AS [UnitPrice],
[Extent3].[Quantity] AS [Quantity],
[Extent3].[Discount] AS [Discount],
CASE WHEN ([Extent3].[OrderID] IS NULL) THEN CAST(NULL AS int) ELSE 1 END AS [C1]
FROM (SELECT TOP (1) [Filter1].[OrderID], [Filter1].[CustomerID1], [Filter1].[EmployeeID], [Filter1].[OrderDate], [Filter1].[RequiredDate], [Filter1].[ShippedDate], [Filter1].[ShipVia], [Filter1].[Freight], [Filter1].[ShipName], [Filter1].[ShipAddress], [Filter1].[ShipCity], [Filter1].[ShipRegion], [Filter1].[ShipPostalCode], [Filter1].[ShipCountry], [Filter1].[CustomerID2], [Filter1].[CompanyName], [Filter1].[ContactName], [Filter1].[ContactTitle], [Filter1].[Address], [Filter1].[City], [Filter1].[Region], [Filter1].[PostalCode], [Filter1].[Country], [Filter1].[Phone], [Filter1].[Fax]
FROM ( SELECT [Extent1].[OrderID] AS [OrderID], [Extent1].[CustomerID] AS [CustomerID1], [Extent1].[EmployeeID] AS [EmployeeID], [Extent1].[OrderDate] AS [OrderDate], [Extent1].[RequiredDate] AS [RequiredDate], [Extent1].[ShippedDate] AS [ShippedDate], [Extent1].[ShipVia] AS [ShipVia], [Extent1].[Freight] AS [Freight], [Extent1].[ShipName] AS [ShipName], [Extent1].[ShipAddress] AS [ShipAddress], [Extent1].[ShipCity] AS [ShipCity], [Extent1].[ShipRegion] AS [ShipRegion], [Extent1].[ShipPostalCode] AS [ShipPostalCode], [Extent1].[ShipCountry] AS [ShipCountry], [Extent2].[CustomerID] AS [CustomerID2], [Extent2].[CompanyName] AS [CompanyName], [Extent2].[ContactName] AS [ContactName], [Extent2].[ContactTitle] AS [ContactTitle], [Extent2].[Address] AS [Address], [Extent2].[City] AS [City], [Extent2].[Region] AS [Region], [Extent2].[PostalCode] AS [PostalCode], [Extent2].[Country] AS [Country], [Extent2].[Phone] AS [Phone], [Extent2].[Fax] AS [Fax], row_number() OVER (ORDER BY [Extent1].[OrderDate] ASC) AS [row_number]
FROM [dbo].[Orders] AS [Extent1]
LEFT OUTER JOIN [dbo].[Customers] AS [Extent2] ON [Extent1].[CustomerID] = [Extent2].[CustomerID]
WHERE [Extent1].[ShipCity] = 'PARIS'
) AS [Filter1]
WHERE [Filter1].[row_number] > 2
ORDER BY [Filter1].[OrderDate] ASC ) AS [Limit1]
LEFT OUTER JOIN [dbo].[Order Details] AS [Extent3] ON [Limit1].[OrderID] = [Extent3].[OrderID]
) AS [Project1]
ORDER BY [Project1].[OrderDate] ASC, [Project1].[OrderID] ASC, [Project1].[CustomerID1] ASC, [Project1].[C1] ASC
Si on enlève les Include, la requête SQL est tout de suite plus lisible
:
SELECT TOP (1)
[Filter1].[OrderID] AS [OrderID],
[Filter1].[CustomerID] AS [CustomerID],
[Filter1].[EmployeeID] AS [EmployeeID],
[Filter1].[OrderDate] AS [OrderDate],
[Filter1].[RequiredDate] AS [RequiredDate],
[Filter1].[ShippedDate] AS [ShippedDate],
[Filter1].[ShipVia] AS [ShipVia],
[Filter1].[Freight] AS [Freight],
[Filter1].[ShipName] AS [ShipName],
[Filter1].[ShipAddress] AS [ShipAddress],
[Filter1].[ShipCity] AS [ShipCity],
[Filter1].[ShipRegion] AS [ShipRegion],
[Filter1].[ShipPostalCode] AS [ShipPostalCode],
[Filter1].[ShipCountry] AS [ShipCountry]
FROM ( SELECT [Extent1].[OrderID] AS [OrderID], [Extent1].[CustomerID] AS [CustomerID], [Extent1].[EmployeeID] AS [EmployeeID], [Extent1].[OrderDate] AS [OrderDate], [Extent1].[RequiredDate] AS [RequiredDate], [Extent1].[ShippedDate] AS [ShippedDate], [Extent1].[ShipVia] AS [ShipVia], [Extent1].[Freight] AS [Freight], [Extent1].[ShipName] AS [ShipName], [Extent1].[ShipAddress] AS [ShipAddress], [Extent1].[ShipCity] AS [ShipCity], [Extent1].[ShipRegion] AS [ShipRegion], [Extent1].[ShipPostalCode] AS [ShipPostalCode], [Extent1].[ShipCountry] AS [ShipCountry], row_number() OVER (ORDER BY [Extent1].[OrderDate] ASC) AS [row_number]
FROM [dbo].[Orders] AS [Extent1]
WHERE [Extent1].[ShipCity] = 'PARIS'
) AS [Filter1]
WHERE [Filter1].[row_number] > 2
ORDER BY [Filter1].[OrderDate] ASC
On retrouve bien notre WHERE City = ‘Paris’, notre WHERE row_number > 2 (pour le skip), notre ORDER BY OrderDate et notre TOP 1 (pour le First).
Attention, ce provider LINQ est un POC. Il manque beaucoup de choses (pas forcément très difficile à rajouter d’ailleurs) tel que l’utilisation des variables et des new (pour comparer par rapport à une date par ex, etc.)
Que se passe-t-il si vous intégrez des méthodes non supportées dans ClientLINQ ?
Ca se passe très bien ! 
En effet, le résultat de votre méthode non supportée ne sera pas un MyQueryable. Par conséquent, ce sont les méthodes de LINQ To Object qui seront utilisées.
Par exemple, la requête suivante :
var customerInfos = (from o in new NorthwindClientContext(service).Orders.Include("Customer")
where o.ShipCity == "PARIS"
orderby o.OrderDate
group o by o.Customer into g
select new { g.Key.CompanyName, g.Key.ContactName, OrdersCount = g.Count() }).ToList();
génèrera un appel à la méthode
_service.GetOrders(myQueryable.IncludeValues.ToList(), myQueryable.WhereValue, myQueryable.OrderByValue, myQueryable.SkipValue, myQueryable.TakeValue)
avec les paramètres suivants :
ce qui génèrera la requête SQL suivante :
SELECT
[Extent1].[OrderID] AS [OrderID],
[Extent1].[CustomerID] AS [CustomerID],
[Extent1].[EmployeeID] AS [EmployeeID],
[Extent1].[OrderDate] AS [OrderDate],
[Extent1].[RequiredDate] AS [RequiredDate],
[Extent1].[ShippedDate] AS [ShippedDate],
[Extent1].[ShipVia] AS [ShipVia],
[Extent1].[Freight] AS [Freight],
[Extent1].[ShipName] AS [ShipName],
[Extent1].[ShipAddress] AS [ShipAddress],
[Extent1].[ShipCity] AS [ShipCity],
[Extent1].[ShipRegion] AS [ShipRegion],
[Extent1].[ShipPostalCode] AS [ShipPostalCode],
[Extent1].[ShipCountry] AS [ShipCountry],
[Extent2].[CustomerID] AS [CustomerID1],
[Extent2].[CompanyName] AS [CompanyName],
[Extent2].[ContactName] AS [ContactName],
[Extent2].[ContactTitle] AS [ContactTitle],
[Extent2].[Address] AS [Address],
[Extent2].[City] AS [City],
[Extent2].[Region] AS [Region],
[Extent2].[PostalCode] AS [PostalCode],
[Extent2].[Country] AS [Country],
[Extent2].[Phone] AS [Phone],
[Extent2].[Fax] AS [Fax]
FROM [dbo].[Orders] AS [Extent1]
LEFT OUTER JOIN [dbo].[Customers] AS [Extent2] ON [Extent1].[CustomerID] = [Extent2].[CustomerID]
WHERE [Extent1].[ShipCity] = 'PARIS'
ORDER BY [Extent1].[OrderDate] ASC
Comme vous pouvez le constater, pas de trace de GROUP BY. Pourtant le résultat a bien pris en compte mon group by (en LINQ To Object).
Le but de ce post était certes de présenter un “faux” provider LINQ rigolo en se basant sur le fait que le yield return diffère l’exécution de la méthode mais surtout de vous convaincre (et je suis sûr que vous l’êtes
) par le gain de productivité du couple EF / T4. En effet,
- Vos templates sont réutilisables de projet en projet. Il suffit de changer le path de l’edmx et de dire à Visual Studio de regénérer le code de tous les templates T4.
- Si vous ne les avez pas déjà écrit, le temps de dev n’est pas proportionnel au nombre d’entités ce qui implique un gain de productivité très rapide par rapport à un développement classique.
Les classes / interfaces générées par T4 peuvent être partial. Il sera donc très simple de les compléter pour rajouter une partie custom.
Imaginons une base avec une table Cars avec en PK un Id (nvarchar(8)) basé sur le Regex [1-9][0-9]{1,2}[A-Z]{2,3}[0-9]{2} où les deux derniers chiffres correspondent à un numéro de région.
Dans la base l’incrémentation est faite de la manière suivante : 10AA[région], 11AA[région], …, 999AA[région], 10AB[région], …, 999ZZ[région], 10AAA[région], …
Cette table peut contenir des centaines de millions de lignes.
Dans un premier temps, nous allons écrire une requête LINQ To Entities permettant de connaître les régions étant passés sur trois lettres :
var q1 = (from c in context.Cars
where c.Id.Contains("AAA")
select c.Id.Substring(c.Id.Length - 2)).Distinct();
Cette requête va nous générer la requête SQL suivante :
SELECT
[Distinct1].[C1] AS [C1]
FROM ( SELECT DISTINCT
SUBSTRING([Extent1].[Id], ((LEN([Extent1].[Id])) - 2) + 1, (LEN([Extent1].[Id])) - ((LEN([Extent1].[Id])) - 2)) AS [C1]
FROM [dbo].[Cars] AS [Extent1]
WHERE [Extent1].[Id] LIKE N'%AAA%'
) AS [Distinct1] Naturellement, j’aurais plutôt écrit cela :
SELECT DISTINCT
RIGHT(Id, 2) AS [C1]
FROM [Cars]
WHERE [Id] LIKE N'%AAA%'
Mais le plan d’exécution est le même !
Imaginons que le DBA impose l’utilisation du RIGHT car il suit les requêtes exécutées avec le profiler et qu’il veut se simplifier la vie. Comme il ne faut jamais contrarié un DBA
, nous allons donc le faire.
Cependant, le Right n’existe pas en C#. Par contre, il existe en ESQL. Pour cela, nous allons donc utiliser une nouveauté de EF4 : les CSDL Functions.
Dans le CSDL, vous pouvez rajouter le code suivant :
<Function Name="GetRegion" ReturnType="String">
<Parameter Name="car" Type="Self.Car" />
<DefiningExpression>
Right(car.Id, 2)
</DefiningExpression>
</Function>
Ensuite, nous allons rajouter une extension method afin de pouvoir utiliser cette CSDL Function dans nos requêtes LINQ To Entities :
public static class CarExtension
{
[EdmFunction("CarsModel", "GetRegion")]
public static string GetRegion(this Car car)
{
throw new NotImplementedException("Only used by LINQ To Entities");
}
}
Je peux maintenant écrire ma requête LINQ comme ceci :
var q1 = (from c in context.Cars
where c.Id.Contains("AAA")
select c.GetRegion()).Distinct();
Cette requête sera alors traduire par la requête TSQL suivante :
SELECT
[Distinct1].[C1] AS [C1]
FROM ( SELECT DISTINCT
RIGHT([Extent1].[Id], 2) AS [C1]
FROM [dbo].[Cars] AS [Extent1]
WHERE [Extent1].[Id] LIKE N'%AAA%'
) AS [Distinct1]
Cool !
Maintenant l’idée est de récupérer le dernier id par région. A ma connaissance, il n’est pas possible de faire cela proprement en SQL. En effet, je ne pense pas que SQL Server gère les expressions régulières. Or on a besoin de cette fonctionnalité pour déterminer quel est le dernier id.
Aussi pour faire ceci, je vais utiliser une requête LINQ To Object. L’idée “naturelle” serait d’écrire quelque chose comme ça :
var qL2E = from c in context.Cars
orderby c.GetRegion()
select c.Id;
var qL2O = from id in qL2E.AsEnumerable()
group id by id.Substring(id.Length - 2) into g
select new
{
Region = g.Key,
Id = (from id in g
let letters = Regex.Match(id, "[A-Z]{2,3}")
orderby letters.Length descending, letters descending, Regex.Match(id, "^[0-9]{2,3}") descending
select id).FirstOrDefault()
}.ToDictionary(id => id.Region, id => id.Id); Cependant, dans ce cas, il faut s’attendre à un OutOfMemoryException. En effet, avec cette requête, tous les enregistrements de la base seront chargés en mémoire et on peut avoir des centaines de millions de lignes en base.
Aussi, l’idée dans un premier temps va être de procéder par dichotomie sans charger en mémoire des ids inutiles.
var ids = (from c in context.Cars
select c.GetRegion()).Distinct().AsEnumerable().ToDictionary(region => region, region => GetLastId(region, "", (new[] { "" }.Union(Enumerable.Range('A', 26).Select(i => char.ConvertFromUtf32(i)))), 0));
private static string GetLastId(string region, string letters, IEnumerable<string> possibleChars, int index)
{
if (!possibleChars.Skip(1).Any()) // Count() == 1
{
letters += possibleChars.First();
if (++index == 3)
{
using (var context = new CarsContainer())
{
return
(from c in context.Cars
where c.Id.EndsWith(letters + region)
orderby c.Id.IndexOf(letters) descending, c.Id descending
select c.Id).FirstOrDefault();
}
}
return GetLastId(region, letters, Enumerable.Range('A', 26).Select(i => char.ConvertFromUtf32(i)), index);
}
else
{
var possibleCharsList = possibleChars.ToList();
int middle = possibleCharsList.Count / 2;
using (var context = new CarsContainer())
{
string idBeginning = string.Format("10{0}{1}{2}", letters, possibleCharsList[middle], "AA".Substring(index));
if ((from c in context.Cars
where c.Id.StartsWith(idBeginning) && c.Id.EndsWith(region)
select c.Id).Any())
return GetLastId(region, letters, possibleCharsList.Skip(middle), index);
return GetLastId(region, letters, possibleCharsList.Take(middle), index);
}
}
}
L’exécution de cette requête est particulièrement longue (26 minutes et 18 secondes dans mon test avec 562 012 347 enregistrements).
Quelles sont les pistes d’améliorations ?
Le CPU utilisé par la base est à 100% et la lenteur provient uniquement de la base. Il est donc inutile d’envisager la parallélisassions du code.
On pourrait envisager de redimensionner le serveur de base de données mais ce n’est pas le but de ce post.
Dans notre cas, le DBA (je précise que je ne suis pas un DBA) va sûrement vouloir changer le schéma de la table Cars afin de séparer en 3 colonnes l’Id. Cependant, dans certains cas, il peut être intéressant de conserver la colonne Id qui restera, dans ce cas, la PK. En effet, imaginons que l’on ait déjà des applications existantes. Il faudrait pouvoir rajouter ces colonnes sans les impacter et tout en faisant en sorte que quand ces applications existantes rajoutent un enregistrement, cela renseigne ces colonnes de façon transparente.
On va donc se retrouver avec quatre colonnes (toutes not nullable) :
Pour les renseigner, on va utiliser des fonctions CLR :
public class CarsFunction
{
[SqlFunction]
public static SqlInt16 GetNumber(SqlString id)
{
return new SqlInt16(short.Parse(Regex.Match(id.Value, "^[0-9]{2,3}").Value));
}
[SqlFunction]
public static SqlString GetLetters(SqlString id)
{
return new SqlString(Regex.Match(id.Value, "[A-Z]{2,3}").Value);
}
[SqlFunction]
public static SqlInt16 GetRegion(SqlString id)
{
return new SqlInt16(short.Parse(Regex.Match(id.Value, "[0-9]{2}$").Value));
}
}
Ensuite, dans la base, on va enregistrer l’assembly et les fonctions :
CREATE ASSEMBLY CarsFunctionsAssembly
FROM 'D:\documents\visual studio 2010\Projects\CarsFunctions\CarsFunctions\bin\Debug\CarsFunctions.dll'
GO
CREATE FUNCTION GetNumber(@id AS nvarchar(8)) RETURNS smallint AS EXTERNAL NAME CarsFunctionsAssembly.CarsFunction.GetNumber
GO
CREATE FUNCTION GetLetters(@id AS nvarchar(8)) RETURNS nvarchar(3) AS EXTERNAL NAME CarsFunctionsAssembly.CarsFunction.GetLetters
GO
CREATE FUNCTION GetRegion(@id AS nvarchar(8)) RETURNS smallint AS EXTERNAL NAME CarsFunctionsAssembly.CarsFunction.GetRegion
Maintenant que nous avons nos fonctions, nous allons revenir sur la création de nos trois colonnes. A la place de créer des colonnes “normales”, nous allons en faire des colonnes calculées. Ceci peut se faire avec le designer SSMS en renseignant la Formule de Computed Column Specification à dbo.GetNumber(Id) (resp dbo.GetLetters(Id), dbo.GetRegion(Id)). Dans notre cas, l’idée n’est pas de recalculer à chaque fois ces valeurs mais de les stocker une bonne fois pour toute. Du coup, on va passer la propriété “Is Persisted” à true.
Le problème c’est que pour cela, il faut que la fonction soit déterministe. C’est le cas de notre fonction. En effet, elle retourne toujours la même valeur pour le même Id. Cependant, vu que c’est une fonction CLR, SQL Server ne peut pas le savoir. Il va donc falloir le préciser explicitement.
Pour cela, nous allons modifier notre code comme ceci :
[SqlFunction(IsDeterministic = true, IsPrecise = true)]
public static SqlInt16 GetNumber(SqlString id)
{
return new SqlInt16(short.Parse(Regex.Match(id.Value, "^[0-9]{2,3}").Value));
}
[SqlFunction(IsDeterministic = true, IsPrecise = true)]
public static SqlString GetLetters(SqlString id)
{
return new SqlString(Regex.Match(id.Value, "[A-Z]{2,3}").Value);
}
[SqlFunction(IsDeterministic = true, IsPrecise = true)]
public static SqlInt16 GetRegion(SqlString id)
{
return new SqlInt16(short.Parse(Regex.Match(id.Value, "[0-9]{2}$").Value));
} Maintenant nous pouvons persister ces colonnes.
Attention, si on importe la base dans l’edmx, il va définir StoreGeneratedPattern="Computed" sur les les colonnes Number, Letters et Region. Cela implique donc qu’à chaque update, il va recharger les propriétés Number, Letters et Region. Dans notre cas, nous savons que le calcul ne dépend que de la clé. Avec Entity Framework, il n’est pas possible de modifier la clé. Par conséquent, il est intéressant de modifier le StoreGeneratedPattern de Computed à Identity.
Cool, nous avons donc régler le problème de l’INSERT.
Concentrons nous maintenant sur ce qui nous intéresse : notre requête.
Plus besoin d’utiliser une recherche dichotomique, une simple requête suffit :
var ids = (from c in context.Cars
group c by c.Region into g
select new
{
Region = g.Key,
LastId = (from c in g
orderby c.Letters.Length descending, c.Letters descending, c.Number descending
select c.Id).FirstOrDefault()
}).AsEnumerable().ToDictionary(region => region.Region, region => region.LastId);
Nous venons de passer de 26 minutes et 18 secondes à 1 minute et 42 secondes !
C’est mieux mais ce n’est pas encore fini.
Au niveau de la base, nous allons maintenant passer le cluster de la table Cars sur la colonne Region (à la place de la colonne Id).
Dans ce cas, le temps d’exécution de ma requête tombe à 12 secondes ! (plus de 26 minutes à 12 secondes, dans ces cas-là, le client ne regrette généralement pas d’avoir pris des journées de conseil
).
Attention cependant ! Ceci est certes très intéressant dans le cas présent mais il faudra bien étudier l’ensemble des requêtes des différentes applications utilisant la base avant de prendre la décision de changer le cluster.
Nous venons de démontrer que le DBA reste un rôle important dans la réussite d’un projet.
Comme je vous l’ai dit, je ne suis pas DBA. Cependant, mon expérience m’a permis d’acquérir quelques notions que j’ai mis en place ici. De plus, j’ai la chance d’avoir quelques DBA dans mes contacts
. J’en profite d’ailleurs pour les remercier pour m’avoir aider à acquérir ces notions.
Maintenant, imaginons que l’on veuille garder en cache le dernier id par région. Pour cela, nous allons peut-être passer notre dictionnaire ids dans une variable statique. Très bien mais il reste deux problèmes à traiter :
- Imaginons une application N-Tiers. Si notre application est déployée sur plusieurs serveurs frontaux, il est dommage de devoir calculer ce cache pour chacun d’eux.
- Si une application externe rajoute un enregistrement en base, comment tenir notre dictionnaire à jour ?
Pour résoudre le premier problème, nous allons utiliser Velocity. Pour le second, nous utiliserons SQLDependency.
Velocity est un cache distribué permettant beaucoup de choses très intéressantes comme le load balancing par exemple. Ce cache distribué sera partagé par l’ensemble de nos serveurs frontaux. Cela signifie donc qu’il ne sera plus utile de générer un Dictionnaire de cache par frontal.
Que devons nous faire pour utiliser Velocity ? Premier point : le télécharger et l’installer 
Une fois installé, il va falloir ajouter les références suivantes dans notre projet :
- CacheBaseLibrary.dll
- CASBase.dll
- CASMain.dll
- ClientLibrary.dll
- FabricCommon.dll
Ensuite, il va falloir démarrer le cluster Velocity. Dans cette CTP, toutes les commandes d’administration se font par la console “Administration Tool - Microsoft Distributed Cache” en ligne de commande. Pour démarrer votre cluster, il faut taper la ligne de commande suivante :
start-cachecluster
Ensuite, dans le fichier de config, on va rajouter les lignes qui vont bien :
<configSections>
<section name="dataCacheClient" type="Microsoft.Data.Caching.DataCacheClientSection, CacheBaseLibrary" allowLocation="true" allowDefinition="Everywhere"/>
</configSections>
<dataCacheClient deployment="routing">
<localCache isEnabled="true" sync="TTLBased" ttlValue="60000"/>
<hosts>
<host name="MATTHIEU-PRO" cachePort="22233" cacheHostName="DistributedCacheService"/>
<host name="MATTHIEU-LAPTOP1" cachePort="22233" cacheHostName="DistributedCacheService"/>
<host name="MATTHIEU-LAPTOP2" cachePort="22233" cacheHostName="DistributedCacheService"/>
<host name="MATTHIEU-PC1" cachePort="22233" cacheHostName="DistributedCacheService"/>
<host name="MATTHIEU-PC2" cachePort="22233" cacheHostName="DistributedCacheService"/>
</hosts>
</dataCacheClient>
Ensuite, il suffit dans notre application (sur le Tiers serveur) de requêter le cache.
return new DataCacheFactory().GetDefaultCache().GetObjectsInRegion("LastImmatPerRegion").ToDictionary(keyValuePair => short.Parse(keyValuePair.Key), keyValuePair => (string)keyValuePair.Value);
Avec ces données en cache, notre requête s’exécute en seulement une trentaine de milli-secondes.
Maintenant, il nous reste un dernier point : initialiser ce cache et le maintenir à jour.
Pour le renseigner, c’est facile, il suffit de se baser sur la requête écrite plus haut :
_cache = new DataCacheFactory().GetDefaultCache();
try
{
_cache.RemoveRegion(LAST_IMMAT_PER_REGION);
}
catch
{
}
_cache.CreateRegion(LAST_IMMAT_PER_REGION, false);
foreach (var car in from c in context.Cars
group c by c.Region into g
select new
{
Region = g.Key,
LastId = (from c in g
orderby c.Letters.Length descending, c.Letters descending, c.Number descending
select c.Id).FirstOrDefault()
})
_cache.Put(car.Region.ToString(), car.LastId, LAST_IMMAT_PER_REGION);
Maintenant pour suivre les évolutions, c’est une autre histoire. Mon idée est d’utiliser une SQLDependency. Le problème c’est qu’on ne peut pas, à priori, récupérer les modifications apportées avec les SQLDependency. Aussi mon idée est de rajouter un Trigger sur ma table Cars qui renseignera une table temporaire.
Pour commencer, nous allons créer la table CarsModificationsTmp. Elle comprendra six colonnes :
- TmpId (int Identity(1,1), PK),
- CarId (nvarchar(8), not nullable)
- Number (smallint, not nullable)
- Letters (nvarchar(3), not nullable)
- Region (smallint, not nullable),
- Deleted (bit, not nullable, default=0)
Ensuite, il faut définir les triggers sur la table Cars :
CREATE TRIGGER CarsInserted
ON Cars
FOR INSERT
AS
BEGIN
DECLARE @Id AS nvarchar(8)
DECLARE @Number AS smallint
DECLARE @Letters AS nvarchar(3)
DECLARE @Region AS smallint
SELECT @Id = Id, @Number = Number, @Letters = Letters, @REGION = Region FROM Inserted
INSERT INTO CarsModificationsTmp(CarId, Number, Letters, Region) VALUES(@Id, @Number, @Letters, @Region)
END
GO
CREATE TRIGGER CarsDeleted
ON Cars
FOR DELETE
AS
BEGIN
DECLARE @Region AS smallint
DECLARE @Id AS nvarchar(8)
DECLARE @Number AS smallint
DECLARE @Letters AS nvarchar(3)
SELECT @Id = Id, @Number = Number, @Letters = Letters, @REGION = Region FROM Deleted
INSERT INTO CarsModificationsTmp(CarId, Number, Letters, Region, Deleted) VALUES(@Id, @Number, @Letters, @Region, 1)
END
Enfin, nous allons compléter le renseignement du cache en gérant notre SQL Dependency.
private void Load()
{
using (var context = new CarsContainer())
{
_cache = new DataCacheFactory().GetDefaultCache();
try
{
_cache.RemoveRegion(LAST_IMMAT_PER_REGION);
}
catch
{
}
_cache.CreateRegion(LAST_IMMAT_PER_REGION, false);
foreach (var car in from c in context.Cars
group c by c.Region into g
select new
{
Region = g.Key,
LastId = (from c in g
orderby c.Letters.Length descending, c.Letters descending, c.Number descending
select c.Id).FirstOrDefault()
})
_cache.Put(car.Region.ToString(), car.LastId, LAST_IMMAT_PER_REGION);
_connectionString = ((EntityConnection)context.Connection).StoreConnection.ConnectionString;
SqlDependency.Stop(_connectionString);
SqlDependency.Start(_connectionString);
DefineCarsNotification();
}
}
private void DefineCarsNotification()
{
var connection = new SqlConnection(_connectionString);
var command = connection.CreateCommand();
command.CommandText = "SELECT TmpId, Region, CarId, Deleted FROM CarsModificationsTmp";
command.CommandType = CommandType.Text;
var sqlDependency = new SqlDependency(command);
sqlDependency.OnChange += SqlDependency_OnChange;
connection.Open();
command.ExecuteNonQuery();
connection.Close();
}
private void SqlDependency_OnChange(object sender, SqlNotificationEventArgs e)
{
using (var contextModifications = new CarsContainer())
{
bool any = false;
foreach (var cm in contextModifications.CarsModificationsTmps)
{
any = true;
var cacheModifications = new DataCacheFactory().GetDefaultCache();
var cacheItem = cacheModifications.GetCacheItem(cm.Region.ToString(), LAST_IMMAT_PER_REGION);
if (cm.Deleted)
{
if (cacheItem != null && cacheItem.Value.ToString() == cm.CarId)
{
var newLastId = (from c in contextModifications.Cars
where c.Region == cm.Region
orderby c.Letters.Length descending, c.Letters descending, c.Number descending
select c.Id).FirstOrDefault();
if (newLastId == null)
_cache.Remove(cm.Region.ToString(), LAST_IMMAT_PER_REGION);
else
_cache.Put(cm.Region.ToString(), newLastId, LAST_IMMAT_PER_REGION);
}
}
else
{
string letters;
int lettersCompare = 0;
if (cacheItem == null || (letters = Regex.Match(cacheItem.Value.ToString(), "[A-Z]{2,3}").Value).Length < cm.Letters.Length || (letters.Length == cm.Letters.Length && ((lettersCompare = String.Compare(letters, cm.Letters)) < 0 || lettersCompare == 0 && short.Parse(Regex.Match(cacheItem.Value.ToString(), "^[0-9]{2,3}").Value) < cm.Number)))
_cache.Put(cm.Region.ToString(), cm.CarId, LAST_IMMAT_PER_REGION);
}
contextModifications.DeleteObject(cm);
}
if (any)
contextModifications.SaveChanges();
}
DefineCarsNotification();
} Le problème c’est que l’appel à SqlDependency_OnChange est asynchrone et il peut y avoir plusieurs appels en parallèle avec les problèmes que cela pose. Le DataCache de Velocity est thread-safe. Cependant, avec le code précédent, la concurrence ne nous permet pas d’affimer que l’on a le dernier id dans le cache. Afin de ne pas avoir de bugs liés au parallélisme, nous allons poser un lock.
if (cm.Deleted)
{
lock (_lockObject)
{
if (cacheItem != null && cacheItem.Value.ToString() == cm.CarId)
{
var newLastId = (from c in contextModifications.Cars
where c.Region == cm.Region
orderby c.Letters.Length descending, c.Letters descending, c.Number descending
select c.Id).FirstOrDefault();
if (newLastId == null)
_cache.Remove(cm.Region.ToString(), LAST_IMMAT_PER_REGION);
else
_cache.Put(cm.Region.ToString(), newLastId, LAST_IMMAT_PER_REGION);
}
}
}
else
{
string letters;
int lettersCompare = 0;
lock (_lockObject)
{
if (cacheItem == null || (letters = Regex.Match(cacheItem.Value.ToString(), "[A-Z]{2,3}").Value).Length < cm.Letters.Length || (letters.Length == cm.Letters.Length && ((lettersCompare = String.Compare(letters, cm.Letters)) < 0 || lettersCompare == 0 && short.Parse(Regex.Match(cacheItem.Value.ToString(), "^[0-9]{2,3}").Value) < cm.Number)))
_cache.Put(cm.Region.ToString(), cm.CarId, LAST_IMMAT_PER_REGION);
}
}
Il peut encore y avoir un problème lors du SaveChanges. En effet, avec la concurrence, on peut essayer de supprimer un DataRow déjà supprimé ce qui engendrera une OptimisticConcurrencyException. L’approche que nous allons adopter est la suivante. Si le SaveChanges lève cette exception, on va détacher toutes les entités qui posent problème (ie : qui ont déjà été supprimées).
if (any)
for (; ; )
try
{
contextModifications.SaveChanges();
break;
}
catch (OptimisticConcurrencyException ex)
{
foreach (var ose in ex.StateEntries)
contextModifications.Detach(ose.Entity);
}
Notre solution est maintenant thread-safe et fonctionne sans problème. Cependant, nous pouvons apporter une amélioration. Si on a deux notifications de changement en parallèle qui ne portent pas sur la même région, il est inutile et dommage de devoir attendre que la première soit finie pour traiter la seconde (ce que nous faisons avec notre lock unique). Aussi, nous allons utiliser un dictionnaire de lock. Cependant, un dictionnaire n’est pas thread-safe ! Il faudrait utiliser un autre object pour locker le dictionnaire. Avec .NET 4, on a une nouvelle classe : ConcurrentDictionary :
private ConcurrentDictionary<short, object> _concurrentDictionary = new ConcurrentDictionary<short,object>();
Maintenant, nous allons modifier notre code pour utiliser notre dictionnaire afin d’avoir un lock par région :
object lockRegion = _concurrentDictionary.GetOrAdd(cm.Region, new object());
if (cm.Deleted)
{
lock (lockRegion)
{
if (cacheItem != null && cacheItem.Value.ToString() == cm.CarId)
{
var newLastId = (from c in contextModifications.Cars
where c.Region == cm.Region
orderby c.Letters.Length descending, c.Letters descending, c.Number descending
select c.Id).FirstOrDefault();
if (newLastId == null)
_cache.Remove(cm.Region.ToString(), LAST_IMMAT_PER_REGION);
else
_cache.Put(cm.Region.ToString(), newLastId, LAST_IMMAT_PER_REGION);
}
}
}
else
{
string letters;
int lettersCompare = 0;
lock (lockRegion)
{
if (cacheItem == null || (letters = Regex.Match(cacheItem.Value.ToString(), "[A-Z]{2,3}").Value).Length < cm.Letters.Length || (letters.Length == cm.Letters.Length && ((lettersCompare = String.Compare(letters, cm.Letters)) < 0 || lettersCompare == 0 && short.Parse(Regex.Match(cacheItem.Value.ToString(), "^[0-9]{2,3}").Value) < cm.Number)))
_cache.Put(cm.Region.ToString(), cm.CarId, LAST_IMMAT_PER_REGION);
}
} Voilà, notre travail est (enfin) terminé.
Je pense qu’il illustre assez bien le fait qu’il est possible de faire beaucoup d’optimisations à condition, comme souvent voire comme toujours, de suffisamment maîtriser les différentes technos disponibles.
J’avais loupé l’annonce perdue dans mes milliers de posts en retard de mon aggrégateur de flux RSS ! (no comment)
MS fournit un template de conversion de LINQ To SQL vers Entity Framework.
Donc si vous avez fait l’erreur d’utiliser LINQ To SQL à la place d’Entity Framework, vous pourrez migrer facilement. 
Tout est dans le titre…
Il fait l’installer pour pouvoir bénéficier des templates T4 fournit par MS pour EF (POCO, Self Tracking) avec VS 2010 Beta 2.
Pour plus d’infos vous pouvez consulter le post de l’ADO.NET team.
Une des forces d’Entity Framework est l’éco-système qui gravite autour. Même si ADO .NET Data Services peut être utilisé sans EF, j’incluerais tout de même cette technologie dans l’éco-système. En effet, si vous utilisez EF avec ADO.NET Data Services, vous n’avez presque plus rien à faire pour utiliser des requêtes LINQ côté client ainsi que pour persister les modifications.
Cependant, le proxy généré côté client a plusieurs défauts :
- Prenons Northwind, si on ajoute un orderDetail à un order (order.OrderDetails), la propriété orderDetail.Order reste null. De même, si on affecte un order à l’orderDetails (orderDetail.Order), la collection d’orderDetail de l’order n’est pas modifée.
- Si on charge les orders et ensuite les orderDetails, les collections OrderDetails des orders sont vides et la propriété Order des orderDetails est null. Pourtant, OrderID fait partie de la clé des orderDetails, donc on devrait avoir automatiquement la relation (comme le fait le contexte EF).
//Pour avoir la relation avec ADO.NET Data Services, il faut utiliser la méthode Expand dans la requête ou utiliser la méthode LoadProperty à postériori.
Avec EF4, on peut intégrer dans le modèle toutes les FK (avec la V1, seules celles étant incluses dans la PK l’étaient). Ceci est une super nouvelle dans notre cas car ça signifie qu’en théorie, il est possible de regénérer automatiquement les relations. - Pour ajouter un order avec des (nouveaux) orderDetails, vous devez faire un Add sur l’order, puis sur tous les orderDetails (le contexte MS ignore les relations). Ensuite il faut encore appeler les méthodes SetLink et AddLink (parce que la clé de la table Orders est un Identity). Il faut donc écrire le code suivant:
context.AddToOrders(o);
foreach (var od in o.OrderDetails)
{
context.AddToOrderDetails(od);
context.AddLink(o, "OrderDetails", od);
context.SetLink(od, "Order", o);
}
- Le contexte MS ne gère pas non plus tout seul le tracking des modifications. Il faut manuellement appeler la méthode UpdateObject pour passer une entité dans l’état Modified.
- Dans le cas d’un nouvel order, si on ajoute un orderDetail au contexte et qu’on ajoute seulement après l'order associé, on va avoir une exception lors du SaveChanges car le contexte ne modifie pas l’order des Add
Mon idée était de résoudre tous ces problèmes en codant un nouveau contexte ADO.NET Data Services basé sur le contexte MS. Bien sûr, je voulais une solution totalement générique (ie indépendante du modèle). Pour faire cela, J’ai utilisé un template T4 qui utilise les informations de l’edmx. Cela implique deux contraintes:
- Vous devez utiliser EF côté serveur
- Lorsque vous développez votre client, vous devez avoir accès à l’edmx (côté serveur)
Mon template résoud tous les problèmes énoncés précédemment. Mission remplie ! 
Vous pouvez le télécharger ici.
Vous pouvez ausi télécharger toute la solution (avec les tests unitaires) ici et le script de création de la base là.
//J’admet que quelques parties du code sont assez “crades” mais ADO.NET Data Services est une techno extrêmement “fermée” et cela m’a parfois empêché de respecter les règles de l’art. J’aurais peut-être aussi eu de meilleures idées si je n’avais pas codé aussi tard la nuit 
J’avais récemment publié un template T4 permettant de générer un service WCF. Je l’ai complété afin de pouvoir également, à partir d’une entité, charger ses relations.
Une fois le template écrit (ce que j’ai déjà fait), il ne reste plus qu’à lui laisser générer pour nous le code de notre service WCF (la seule chose à faire c’est définir les endpoints dans le fichier de config).
Comme je l’explique dans mon précédent post sur le sujet, cela a l’énorme avantage de rendre le temps d’écriture de notre code indépendant du nombre d’entité. Si on rajoute à cela le fait que le template ainsi écrit est généralement (c’est le cas ici) indépendant du modèle ce qui veut donc dire qu’on peut le réutiliser quelque soit le modèle et plus généralement quelque soit le projet.
Je ne sais pas ce que vous en pensez mais moi je trouve qu’EF couplé avec T4 ça “poutre grave” !
Rappelez-vous l’année dernière j’avais publié un article sur Tech Head Brothers sur le mot clé dynamic. A ce moment, j’avais regretté de ne pouvoir écrire directement
dynamicSpBase [ name ];
et d’être obligé de passer par la méthode
get_Item
J’avais alors envoyé un mail à MS pour que cela devienne possible. Je ne sais pas si mon mail a eu une quelconque influence mais quoi qu’il en soit, je suis très heureux de vous annoncer qu’avec la Beta2 de .NET 4, cela devient possible.
Une des principales forces de l’Entity Framework est le gain de productivité pour le développeur. Couplé avec le template de génération de code T4, ce gain explose. Imaginons que l’on veuille développer un service WCF exposant des données. Pour chaque type d’entité, vous allez très probablement vouloir faire un Get retournant la liste des entités, peut-être un deuxième Get prenant en paramètre l’id et chargeant l’entité avec ses relations, un Add, un Update, peut-être un Delete.
EF apporte un gain de productivité très important dans le développement des entités ainsi que dans l’utilisation de celles-ci. Cependant, dans notre cas, le code à écrire est particulièrement redondant : il faut écrire quasiment la même chose pour chacune des entités. Et c’est là que le T4 rentre en jeu.
Dans l’ensemble des exemples que j’ai pu regarder, ce template n’est utilisé que pour la génération d’entités. Essayons d’aller plus loin.
Grâce au template T4, vous allez écrire un meta-code qui va générer pour vous votre service WCF ! Et le mieux dans tout ça c’est que votre template est ensuite réutilisable. Pour ma part, je me suis amusé à faire un test de productivité entre un code utilisant l’ADO .NET 2.0 et un code utilisant l’Entity Framework. Première constatation : j’avais oublié que c’était si long de faire de l’ADO “classique”.
Avec EF et T4, la seule chose que je n’ai pas généré dans mon template est la liste des relations à charger avec l’entité (j’aurais pu utiliser une profondeur fixe avec T4 mais je voulais garder une logique métier pour le chargement des relations). Avec ADO .NET 2.0, il faut tout écrire !
De plus, qui dit ADO “classique” implique requête sous la forme d’une chaîne de caractères implique possibilité de faute de frappe dans les requêtes SQL implique des tests unitaires ce qui prend du temps.
Un des premiers points que l’on constate c’est l’apport en visibilité du code avec EF :
Pour la méthode GetOrder(orderId), le code ADO 2.0 (un peu factorisé) est le suivant :
private const string SELECT_ORDERS = "OrderID, CustomerID, EmployeeID, OrderDate, RequiredDate, ShippedDate, ShipVia, Freight, ShipName, ShipAddress, ShipCity, ShipRegion, ShipPostalCode, ShipCountry FROM ORDERS";
private const string SELECT_ORDERDETAILS = "OrderID, ProductID, UnitPrice, Quantity, Discount FROM [Order Details]";
private const string SELECT_CUSTOMERS = "C.CustomerID, CompanyName, ContactName, ContactTitle, [Address], City, Region, PostalCode, Country, Phone, Fax, Since, Points, CardNumber, CAST((CASE M.CustomerID when NULL then 0 else 1 END) AS bit) AS IsMember FROM Customers AS C LEFT OUTER JOIN Members AS M ON C.CustomerID = M.CustomerID";
public Order GetOrder(int orderID)
{
return ReadEntity<Order>(string.Format("SELECT TOP 1 {0} WHERE OrderID = @OrderID; \n SELECT {1} WHERE OrderID = @OrderID;", SELECT_ORDERS, SELECT_ORDERDETAILS), new[] { new SqlParameter("OrderID", orderID) }, reader => GetOrder(reader), (o, reader, connection) =>
{
o.OrderDetails = new List<OrderDetail>();
reader.NextResult();
while (reader.Read())
{
var orderDetail = GetOrderDetail(reader);
orderDetail.Order = o;
o.OrderDetails.Add(orderDetail);
}
if (o.CustomerID != null)
o.Customer = ReadEntity<Customer>(string.Format("SELECT TOP 1 {0} WHERE C.CustomerID = @CustomerID", SELECT_CUSTOMERS), new[] { new SqlParameter("CustomerID", o.CustomerID) }, subReader => GetCustomer(subReader), connection);
var pq = o.OrderDetails.Select(od => od.ProductID.ToString());
if (pq.Any())
{
var products = ReadEntities<Product>(string.Format("SELECT {0} WHERE ProductID IN ({1})", SELECT_PRODUCTS, pq.Skip(1).Any() ? pq.Aggregate((p1, p2) => p1.Contains(string.Format(" {0} ", p2)) ? p1 : string.Concat(" ", p1, " , ", p2)) : pq.First()), subReader => GetProduct(subReader), connection);
foreach (var p in products)
p.OrderDetails = o.OrderDetails.Where(od => od.ProductID == p.ProductID).Select(od => { od.Product = p; return od; }).ToList();
}
});
}
private T ReadEntity<T>(string commandText, SqlParameter[] parameters, Func<SqlDataReader, T> getEntityFromReader, Action<T, SqlDataReader, SqlConnection> moreAction = null) where T : class
{
SqlConnection connection = new SqlConnection(ConfigurationManager.ConnectionStrings["NorthwindEntities"].ToString());
connection.Open();
try
{
return ReadEntity(commandText, parameters, getEntityFromReader, connection, moreAction);
}
finally
{
connection.Close();
}
}
private T ReadEntity<T>(string commandText, SqlParameter[] parameters, Func<SqlDataReader, T> getEntityFromReader, SqlConnection connection, Action<T, SqlDataReader, SqlConnection> moreAction = null) where T : class
{
SqlCommand command = connection.CreateCommand();
command.CommandText = commandText;
command.Parameters.AddRange(parameters);
SqlDataReader reader = command.ExecuteReader();
try
{
if (reader.Read())
{
var value = getEntityFromReader(reader);
if (moreAction != null)
moreAction(value, reader, connection);
return value;
}
return null;
}
finally
{
reader.Close();
}
}
private List<T> ReadEntities<T>(string commandText, Func<SqlDataReader, T> getEntityFromReader, SqlConnection connection) where T : class
{
var value = new List<T>();
SqlCommand command = connection.CreateCommand();
command.CommandText = commandText;
SqlDataReader reader = command.ExecuteReader();
try
{
while (reader.Read())
value.Add(getEntityFromReader(reader));
return value;
}
finally
{
reader.Close();
}
}
private Order GetOrder(SqlDataReader reader)
{
return new Order { OrderID = reader.GetInt32(0), CustomerID = reader.IsDBNull(1) ? null : reader.GetString(1), EmployeeID = reader.IsDBNull(2) ? null : (int?)reader.GetInt32(2), OrderDate = reader.IsDBNull(3) ? null : (DateTime?)reader.GetDateTime(3), RequiredDate = reader.IsDBNull(4) ? null : (DateTime?)reader.GetDateTime(4), ShippedDate = reader.IsDBNull(5) ? null : (DateTime?)reader.GetDateTime(5), ShipVia = reader.IsDBNull(6) ? null : (int?)reader.GetInt32(6), Freight = reader.IsDBNull(7) ? null : (decimal?)reader.GetDecimal(7), ShipName = reader.IsDBNull(8) ? null : reader.GetString(8), ShipAddress = reader.IsDBNull(9) ? null : reader.GetString(9), ShipCity = reader.IsDBNull(10) ? null : reader.GetString(10), ShipRegion = reader.IsDBNull(11) ? null : reader.GetString(11), ShipPostalCode = reader.IsDBNull(12) ? null : reader.GetString(12), ShipCountry = reader.IsDBNull(13) ? null : reader.GetString(13) };
}
private OrderDetail GetOrderDetail(SqlDataReader reader)
{
return new OrderDetail { OrderID = reader.GetInt32(0), ProductID = reader.GetInt32(1), UnitPrice = reader.GetDecimal(2), Quantity = reader.GetInt16(3), Discount = reader.GetFloat(4) };
}
private Customer GetCustomer(SqlDataReader reader)
{
if (reader.GetBoolean(14))
return GetMember(reader);
return GetCustomer<Customer>(reader);
}
private T GetCustomer<T>(SqlDataReader reader) where T : Customer, new()
{
return new T { CustomerID = reader.GetString(0), CompanyName = reader.GetString(1), ContactName = reader.IsDBNull(2) ? null : reader.GetString(2), ContactTitle = reader.IsDBNull(3) ? null : reader.GetString(3), Address = reader.IsDBNull(4) ? null : reader.GetString(4), City = reader.IsDBNull(5) ? null : reader.GetString(5), Region = reader.IsDBNull(6) ? null : reader.GetString(6), PostalCode = reader.IsDBNull(7) ? null : reader.GetString(7), Country = reader.IsDBNull(8) ? null : reader.GetString(8), Phone = reader.IsDBNull(9) ? null : reader.GetString(9), Fax = reader.IsDBNull(10) ? null : reader.GetString(10) };
}
private Member GetMember(SqlDataReader reader)
{
var member = GetCustomer<Member>(reader);
member.Since = reader.IsDBNull(11) ? null : (DateTime?)reader.GetDateTime(11);
member.Points = reader.IsDBNull(12) ? null : (int?)reader.GetInt32(12);
member.CardNumber = reader.IsDBNull(13) ? null : reader.GetString(13);
return member;
}
private Product GetProduct(SqlDataReader dataReader)
{
return new Product { ProductID = dataReader.GetInt32(0), ProductName = dataReader.GetString(1), SupplierID = dataReader.IsDBNull(2) ? null : (int?)dataReader.GetInt32(2), CategoryID = dataReader.IsDBNull(3) ? null : (int?)dataReader.GetInt32(3), QuantityPerUnit = dataReader.IsDBNull(4) ? null : dataReader.GetString(4), UnitPrice = dataReader.IsDBNull(5) ? null : (decimal?)dataReader.GetDecimal(5), UnitsInStock = dataReader.IsDBNull(6) ? null : (short?)dataReader.GetInt16(6), UnitsOnOrder = dataReader.IsDBNull(7) ? null : (short?)dataReader.GetInt16(7), ReorderLevel = dataReader.IsDBNull(8) ? null : (short?)dataReader.GetInt16(8), Discontinued = dataReader.GetBoolean(9) };
}
Tout simplement hallucinant !
Avec EF, cela donne tout simplement ceci :
//Generated file
partial class NorthwindService
{
private static Func<NorthwindEntities, System.Int32, Order> GetOrderCQ = CompiledQuery.Compile<NorthwindEntities, System.Int32, Order>((context, OrderID) => context.Orders.OfType<Order>().FirstOrDefault(e => e.OrderID == OrderID));
private static Func<NorthwindEntities, System.Int32, Order> GetOrderWithIncludeCQ { get; set; }
public Order GetOrder(System.Int32 OrderID)
{
using (var context = new NorthwindEntities())
{
if (GetOrderWithIncludeCQ != null)
return GetOrderWithIncludeCQ(context, OrderID);
return GetOrderCQ(context, OrderID);
}
}
}
//My partial part
partial class NorthwindService
{
static NorthwindService()
{
GetOrderWithIncludeCQ = (context, orderID) => context.Orders.Include("OrderDetails.Product").Include("Customer").FirstOrDefault(o => o.OrderID == orderID);
}
}
Je ne sais pas ce que vous en pensez mais perso je trouve ça un peu plus facile à lire… et un peu plus rapide à écrire
D’autre part, comme je l’ai expliqué plus haut, finalement je n’ai écrit qu’une seule ligne de code :
GetOrderWithIncludeCQ = (context, orderID) => context.Orders.Include("OrderDetails.Product").Include("Customer").FirstOrDefault(o => o.OrderID == orderID);
Vive la productivité !
Cela nous donne donc le temps de dev en fonction du développement suivant :
| Nb entités / temps de dev | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 100 | 500 | 1000 |
| EF | 3 | 3 | 4 | 4 | 4 | 4 | 5 | 5 | 5 | 5 | 6 | 6 | 6 | 6 | 28 | 128 | 253 |
| ADO .NET 2.0 | 35 | 65 | 95 | 125 | 155 | 185 | 215 | 245 | 275 | 305 | 335 | 365 | 395 | 425 | 3005 | 15005 | 30005 |
C’est à dire que pour 1000 entités, avec ADO 2.0, cela prend plus de 66 jours / homme (à raison de 7.5 h par jour) contre… 4 heures pour Entity Framework. Essayons d’être honnête dans les estimations, dans le cas de 1000 entités, il est souhaitable de de découper nos entités en plusieurs modèles. On va donc grossir le trait à 2 jours / homme pour EF et arrondir le développement avec ADO 2.0 à 2.5 mois. Impressionnant non ? CQFD
Si on rajoute à ça le fait que le code est plus lisible avec EF, je ne vois plus beaucoup de raison de vouloir continuer à faire de l’ADO .NET 2.0.
Il y a tout de même un point très important à préciser : pensez à vous former avant de réellement développer avec EF (vous pouvez d’ailleurs me contacter pour cela (matthieu.mezil at live.fr)). Ceci est très important car même si ça a l’air simple d’utilisation, il y a des concepts à maîtriser pour :
- ne pas perdre du temps lors du développement
- obtenir les résultats attendus (notamment au niveau de la gestion des entités non persistées)
- ne pas dégrader les perfs
Vous trouverez le tt que j’utilise ici.
Plusieurs personnes m’ont demandé s’il était possible de rajouter une condition sur la méthode Include et la réponse est non.
Cependant, il est bien entendu possible de résoudre cette problématique comme ceci :
from cWithP in
(from c in context.Categories
select new
{
Category = c,
Products = from p in c.Products
where p.UnitPrice > 20
select p
}).AsEnumerable()
select cWithP.Category;
ou de façon condensée comme ceci :
context.Categories.Select(c => new { Category = c, Products = c.Products.Where(p => p.UnitPrice > 20) }).AsEnumerable().Select(cp => cp.Category);
J’ai postulé pour une session Birds of Feather pour le Tech Ed.
Tout dépendra maintenant de vos votes.
Je pense que je vous trouverez tout seul quelle est la session que j’ai proposé 
Merci d’avance pour vos votes.
On m’a récemment posé la question suivante : comment dans les templates T4 d’EF récupérer l’EntitySet d’un EntityType et comment récupérer les EntityTypes d’un EntitySet ?.
J’ai donc réalisé un petit POC pour cela.
J’ai pour cela changé deux classes incluses dans le template :
private class EntitySetObjectSetPropertyWrapper : MetadataItemWrapper
{
public EntitySetObjectSetPropertyWrapper(EntitySet entitySet, WrapperBase wrapper)
: base(entitySet, wrapper)
{
}
public new EntitySet Source { get { return (EntitySet)base.Source; } }
public string ModelName { get { return Source.Name; } }
public string PropertyName { get { return Escape(Source.Name); } }
public string FieldName { get { return "_" + Source.Name; } }
public string TypeName { get { return GetStructuralTypeName(Source.ElementType); } }
public string GetterAccessibility { get { return GetAccessibility(Source, GETTER_ACCESS); } }
public string NewModifier { get { return HasBaseMemberWithMatchingName(typeof(ObjectContext), Source.Name) ? "new " : ""; } }
public IEnumerable<EntityType> GetAllEntityTypes(ItemCollectionWrapper edm)
{
var entityType = Source.ElementType;
yield return entityType;
var entityTypeWrapper = edm.SourceEntities.First(e => e.Source == entityType);
foreach (var subEntityType in entityTypeWrapper.SubEntityTypes(edm))
yield return subEntityType;
}
}
private class EntityTypeWrapper : StructuralTypeWrapper
{
public EntityTypeWrapper(EntityType entity, WrapperBase wrapper)
: base(entity, wrapper)
{
}
public new EntityType Source { get { return (EntityType)base.Source; } }
public string AbstractOption { get { return Source.Abstract ? "abstract " : ""; } }
public bool IsAbstract { get { return Source.Abstract; } }
public string BaseTypeName { get { return Source.BaseType == null ? "EntityObject" : GetStructuralTypeName((StructuralType)Source.BaseType); } }
public string TypeReferenceName { get { return GetStructuralTypeName(Source); } }
public IEnumerable<NavigationPropertyWrapper> NavigationProperties { get { return Source.NavigationProperties.Where(n => n.DeclaringType == Source).Select(n => new NavigationPropertyWrapper(n, this)); } }
public EntitySet GetEntitySet(ItemCollectionWrapper edm)
{
return edm.SourceEntityContainers.SelectMany(c => c.ObjectSetProperties).First(es => es.GetAllEntityTypes(edm).Contains(Source)).Source;
}
public IEnumerable<EntityType> SubEntityTypes(ItemCollectionWrapper edm, bool recursivity = true)
{
return edm.SourceEntities.Where(et => et.AllBaseEntityTypes.Contains(Source)).Select(et => et.Source);
}
public IEnumerable<EntityType> AllBaseEntityTypes
{
get { return GetBaseEntityTypes(Source); }
}
private static IEnumerable<EntityType> GetBaseEntityTypes(EntityType entityType)
{
EntityType baseType;
if (entityType == null || (baseType = (EntityType)entityType.BaseType) == null)
yield break;
yield return baseType;
foreach (var subEntityType in GetBaseEntityTypes(baseType))
yield return subEntityType;
}
}
A noter que dans l’édition de votre template, vous n’avez probablement pas les couleurs syntaxiques.
Vous pouvez maintenant utiliser ces nouvelles méthodes dans votre template comme je l’ai fait dans mon POC :
/// <summary>
/// <#=set.SummaryComment#>
/// </summary><#=set.GetLongDescriptionComment(_regionIndentLevel)#>
/// <remarks>
/// Entity types:
<# foreach (var entityType in set.GetAllEntityTypes(Edm))
{ #>
/// <#= entityType.Name #>
<# } #>
/// </remarks>
<#=set.NewModifier#><#=set.GetterAccessibility#> ObjectSet<<#=set.TypeName#>> <#=set.PropertyName#>
{
get
{
if ((<#=set.FieldName#> == null))
{
<#=set.FieldName#> = base.CreateObjectSet<<#=set.TypeName#>>("<#=set.ModelName#>");
}
return <#=set.FieldName#>;
}
}
[…]
/// <summary>
/// <#=entity.SummaryComment#>
/// </summary><#=entity.GetLongDescriptionComment(_regionIndentLevel)#>
/// <remarks>EntitySet <#= entity.GetEntitySet(Edm).Name #></remarks>
[EdmEntityTypeAttribute(NamespaceName="<#=entity.ModelNamespace#>", Name="<#=entity.ModelName#>")]
[Serializable()]
[DataContractAttribute(IsReference=true)]
<#
foreach(EntityTypeWrapper subType in Edm.GetAllDirectSubTypes(entity.Source))
{
#>
[KnownTypeAttribute(typeof(<#=subType.TypeReferenceName#>))]
<#
}
#>
<#=entity.TypeAccessibility#> <#=entity.AbstractOption#>partial class <#=entity.ClassName#> : <#=entity.BaseTypeName#>
{
<#
if(!entity.IsAbstract)
{
WriteFactoryMethod(entity.FactoryMethodWrapper);
}
WritePrimitiveTypeProperties(entity, typeof(EntityObject));
WriteComplexTypeProperties(entity, typeof(EntityObject));
#>
J’en profite pour remercier Jeff Reed de l’EF team qui m’a confirmé que mon approche était bien la bonne et Julien qui m’a récupéré le template public.
Nous sommes beaucoup à en être persuadé : le Parallel Framework (PFx) va prendre de plus en plus d’importance dans les développements futurs.
Cependant, les démos sont souvent déconnectées de la réalité. C’est vrai les démos de fractales sur une machine avec 10000 cœurs s’est l’idéal pour une démo sur la parallélisation cependant force est de constater que dans la vraie vie, les fractales, je n’en ai jamais eu besoin et je ne pense pas être le seul.
Mon idée était donc de réaliser un exemple un peu plus inspiré de mon travail quotidien. Aussi ais-je fait un exemple qui tourne autour de… l’Entity Framework. 
Prenons un exemple très simple : une base avec une table Customers avec potentiellement beaucoup de row (100 000 dans mon cas ce qui n’a rien de monstrueux), une application WPF avec une DataGrid pour afficher les clients. Soit dit en passant, la grille WPF est beaucoup plus performante que la grille Winforms pour afficher le résultat puisqu’elle ne charge que les éléments visibles.
La classe ObjectQuery implémentant IListSource, pas de problème. (la propriété Customers est de type ObjectSet<Customer> qui hérite de ObjectQuery). Maintenant ajoutons des controls pour pouvoir filtrer les résultats (par exemple, un TextBox par colonne de type string). Dans l’exemple, nous n’utiliserons pas le pattern MVVM afin d’éviter de complexifié inutilement l’exemple. Bien entendu, on peut changer le DataSource de la grille afin de ne plus prendre l’ObjectSet Customers mais Customers.Where(…). Le résultat sera également un ObjectQuery donc pas de soucis. Sauf que… cela implique une nouvelle requête en base. Il vaut mieux éviter d’exécuter une nouvelle requête en base à chaque fois que la valeur d’un des control de filtre change. En effet, lors de l’ouverture de la Window, tous les clients ont déjà été récupérés.
En plus du problème de requête superflue, cela pose un autre problème : les nouveaux clients pas encore persistés en base ne seront plus présents dans la grille dès lors qu’un filtre sera appliqué (et ceci de façon irréversible tant qu’on n’appelle pas la méthode SaveChanges du contexte). A l’inverse, les clients supprimés seront présents tant que la suppression ne sera pas persistée. En effet, avec EF, on ne récupère que les données présentes en base. Pour contourner cela, on pourrait persister systématiquement les ajouts et suppressions et inclure le tout dans une transaction afin de pouvoir annuler les modifications cependant cette solution n’est vraiment pas idéale.
Ce qu’il faudrait c’est un système qui permette de travailler sur le cache de l’ObjectContext une fois les entités chargées. Pour cela, on peut utiliser la propriété ObjectStateManager sauf que… en travaillant sur l’ObjectStateManager, on aura qu’un IEnumerable<Customer>, il n’est donc plus possible d’automatiser l’ajout et la suppression.
Afin de contourner cela, j’ai décidé de créer une nouvelle classe :
public class ObjectSetDataSource<T> : IList<T>, ICollection<T>, IEnumerable<T>, IList, ICollection, IEnumerable, INotifyCollectionChanged where T : class, new()
{
[…]
private IEnumerable<T> AllEntities
{
get
{
if (_isLoaded == false)
{
foreach (var e in ObjectSet)
yield return e;
_isLoaded = true;
}
else
{
foreach (var e in ObjectSet.Context.ObjectStateManager.GetObjectStateEntries(EntityState.Added | EntityState.Modified | EntityState.Unchanged).Select(ose => ose.Entity).OfType<T>())
yield return e;
}
}
}
private void CreateEntitiesList()
{
_entities = null;
if (_allEntities == null)
_allEntities = AllEntities;
if (Predicate == null)
{
_entities = _allEntities.ToList();
return;
}
_entities = _allEntities.Where(Predicate).ToList();
}
public Func<T, bool> Predicate
{
get { return _predicate; }
set
{
_predicate = value;
CreateEntitiesList();
OnCollectionChanged(NotifyCollectionChangedAction.Reset);
}
}
}
Ok ça marche mais maintenant imaginons que l’on fasse le filtre à l’aide du TextChanged comme ceci :
private void lastNameTB_TextChanged(object sender, TextChangedEventArgs e)
{
Filter();
}
private void firstNameTB_TextChanged(object sender, TextChangedEventArgs e)
{
Filter();
}
private void Filter()
{
string lastName = lastNameTB.Text;
string firstName = firstNameTB.Text;
_customers.Predicate = c =>
{
int i = 0;
while (i < 100000) i++;
return c.LastName.StartsWith(lastName) && c.FirstName.StartsWith(firstName);
};
}
Notez au passage la boucle destiné à ralentir le traitement afin de bien observer ce qui se passe.
On va très vite observer des problèmes de performances. Imaginons que l’on veuille récupérer les clients dont le nom commence par “MEZ”.
Que va-t-il se passer ?
Premier problème : lorsque l’on va taper MEZ, à moins de faire un copier coller, on va probablement taper M puis E puis Z. Or avec notre traitement séquentiel on va d’abord récupérer la liste des clients dont le nom commence par M puis ceux dont le nom commence par ME et enfin ceux dont le nom commence par MEZ. Le problème vient du fait qu’il n’est pas possible d’annuler le traitement car l’évènement TextChanged ne sera pas déclenché tant qu’on n’aura pas fini. L’autre problème vient du fait que notre code n’a utilisé qu’un seul cœur de notre processeur et donc 50% du CPU dans le cas de mon dual core :
Le fait de paralléliser notre code va nous permettre de résoudre ce deuxième problème :
Pour utiliser 100% du CPU, il faudrait exécuter notre requête LINQ sur plusieurs threads. En effet, le filtrage des clients est indépendant entre chaque client. Cependant, hors de question de créer un thread par client ! D’où l’intérêt des tâches et dans le cas précis de PLINQ. Voici donc le code que je propose :
public class ObjectSetDataSource<T> : IList<T>, ICollection<T>, IEnumerable<T>, IList, ICollection, IEnumerable, INotifyCollectionChanged where T : class, new()
{
[…]
private void CreateEntitiesList()
{
_entities = null;
if (_allEntities == null)
_allEntities = AllEntities;
if (Predicate == null)
{
_entities = _allEntities.ToList();
return;
}
try
{
_entities = _allEntities.AsParallel.Where(Predicate).ToList();
}
catch (OperationCanceledException)
{
}
}
public Func<T, bool> Predicate
{
get { return _predicate; }
set
{
_predicate = value;
CreateEntitiesList();
OnCollectionChanged(NotifyCollectionChangedAction.Reset);
}
}
}
L’utilisation du CPU montre une réelle amélioration :
Tout ça uniquement grâce à l’extension method AsParallel, dur de faire plus simple !
Du coup notre requête LINQ ne va plus s’exécuter sur un seul cœur mais sur les n cœurs de l’ordinateur. L’extension method AsParallel va répartir le traitement en divisant la source de données en n (2 dans le cas de mon dual core). Il faut noter que du fait de l’exécution sur deux threads, le résultat (sans OrderBy) ne se retrouve pas forcément dans le même ordre que dans notre application mono-threadée mais dans notre cas, cela n’a aucune importance.
Cependant il reste un problème : il faudrait pouvoir annuler le calcul de la liste si le filtre change afin de ne pas avoir à calculer la liste pour M et ME quand le filtre souhaité est MEZ. Bien entendu, PFx propose tout ce qu’il faut pour cela :
public class ObjectSetDataSource<T> : IList<T>, ICollection<T>, IEnumerable<T>, IList, ICollection, IEnumerable, INotifyCollectionChanged where T : class, new()
{
[…]
private void CreateEntitiesList(cancellationToken = null)
{
_entities = null;
if (_allEntities == null)
_allEntities = AllEntities;
if (Predicate == null)
{
_entities = _allEntities.ToList();
return;
}
try
{
_entities = _allEntities.AsParallel().WithCancellation(cancellationToken.Token).Where(Predicate).ToList();
}
catch (OperationCanceledException)
{
}
}
public Func<T, bool> Predicate
{
get { return _predicate; }
set
{
_predicate = value;
if (_cancellationToken != null)
_cancellationToken.Cancel();
_cancellationToken = new CancellationTokenSource();
var _cancellationToken = _cancellationToken
new Task(t =>
{
CreateEntitiesList();
if (cancellationToken.IsCancellationRequested)
return;
var app = Application.Current;
if (app != null)
app.Dispatcher.BeginInvoke((Action)(() => OnCollectionChanged(NotifyCollectionChangedAction.Reset)));
}, cancellationToken).Start();
}
}
}
L’utilisation du CPU montre une nouvelle amélioration significative :
L’extension method WithCancellation permet d’annuler la requête LINQ durant son exécution.
L’idée de créer la tâche dans la propriété Predicate est de ne pas être obligé d’attendre la fin du traitement avec M comme filtre puis ME pour finalement filtrer par MEZ. Pour cela, nous nous exécutons le calcul de la liste en asynchrone. Cela permettra aussi de ne pas figer l’UI.
Enfin, la méthode BeginInvoke permet de déclencher l’évènement CollectionChanged dans le thread principal ce qui est obligatoire afin d’éviter l’exception “This type of CollectionView does not support changes to its SourceCollection from a thread different from the Dispatcher thread”.
Cet exemple, très courant, nous montre rapidement l’intérêt de PFx. Attention cependant, tous les algorithmes ne sont pas parralélisables. De plus, la parralélisation d’un code ajoute de la complexité et implique de bien maîtriser la programmation parallèle. Cependant si vous avez du temps pour vous former, je vous recommande très très très vivement de vous penchez sur PFx et la programmation parallèle en général. En effet, comme je le précisais au tout début, le Parallel Framework (PFx) va prendre de plus en plus d’importance dans les développements futurs.
Vous pouvez télécharger la classe complète ici.
Les 10 derniers blogs postés
-
TechDays Paris 2010 : La BI dans SharePoint 2010 par
Blog Technique de Romelard Fabrice le il y a 1 heure et 29 minutes
-
TechDays Paris 2010 : Déploiement de nouvelles technologies – Retour d’expérience par l’informatique de Microsoft par
Blog Technique de Romelard Fabrice le il y a 2 heures et 56 minutes
-
TechDays Paris 2010 : Plan de migration vers SharePoint 2010 par
Blog Technique de Romelard Fabrice le il y a 6 heures et 39 minutes
-
TechDays Paris 2010 : La pleinière du second jour par
Blog Technique de Romelard Fabrice le il y a 7 heures et 44 minutes
-
Visual Studio 2010 and .NET Framework 4 Release Candidate now available par
Matthieu MEZIL le il y a 10 heures et 50 minutes
-
Création d’une base de donnée sous SQL Azure par
Le Blog (Vert) d'Arnaud JUND le il y a 11 heures et 47 minutes
-
TechDays Paris 2010 : Les Services d’applications dans SharePoint 2010 par
Blog Technique de Romelard Fabrice le il y a 21 heures et 46 minutes
-
TechDays Paris 2010 : La GED et SharePoint 2010 par
Blog Technique de Romelard Fabrice le 02-08-2010, 16:54
-
TechDays Paris 2010 : SharePoint 2010 et Les réseaux sociaux par
Blog Technique de Romelard Fabrice le 02-08-2010, 15:40
-
TechDays Paris 2010 : SharePoint 2010 – Description et nouveautés par
Blog Technique de Romelard Fabrice le 02-08-2010, 14:33