Monday, August 29, 2011

Saving the whole object graph across WCF with Entity Framework via ToTheEfnhX

First of all, we will introduce a repository interface to implement the merge functionality that is glaringly missing from Entity Framework. I decided to name this repository ToTheEfnhX.


What ToTheEfnhX is not:

  • It's not a Unit of Work component
  • It doesn't compete with S#arp, S#arp is tied to NHibernate



What ToTheEfnhX is:

  • It's a repository interface
  • It's a repository interface that supports both Entity Framework and NHibernate




On this topic, we will discuss how to save to whole object graph using Entity Framework via ToTheEfnhX component. The API is patterned after NHibernate session.Merge, this function can save and update the whole object graph to database.

The general pattern of persisting object to database:


public void SaveQuestion(Question question, out int id, out byte[] version)
{
 var repo = QuestionRepository;
 repo.Merge(question, question.RowVersion);
 id = question.QuestionId;
 version = question.RowVersion;
}


IRepository<Question> QuestionRepository
{
   get
   {
    // Entity Framework:
    var x = new EfDbMapper();                                
    return new EfRepository<question>(x);

    // NHibernate:
    // return new NhRepository<Question> (NhDbMapper.GetSession(ConfigurationManager.ConnectionStrings["EfDbMapper"].ConnectionString));
 }
}


Basically that's it! It's similar to NHibernate session.Merge

For a complete step-by-step persisting of object graph across the wires(e.g. WCF), follow the following steps, or you can just download the source code on both projects(see the bottom of the article)



Create a Winforms Project

Add a Class Library Project

Copy these entities on Class1.cs, overwrite all of its content:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Runtime.Serialization;

namespace TheEntities
{
    [DataContract(IsReference=true)]    
    public class Question
    {
        [DataMember] public virtual int QuestionId { get; set; }
        [DataMember] public virtual string Text { get; set; }
        [DataMember] public virtual string Poster { get; set; }

        [DataMember] public virtual IList<QuestionComment> Comments { get; set; }

        [DataMember] public virtual IList<Answer> Answers{ get; set; }



        [DataMember] public virtual byte[] RowVersion { get; set; }


    }

    [DataContract]
    public class QuestionComment
    {
        [DataMember] public virtual Question Question { get; set; }        

        [DataMember] public virtual int QuestionCommentId { get; set; }
        [DataMember] public virtual string Text { get; set; }
        [DataMember] public virtual string Poster { get; set; }


    }


    [DataContract(IsReference = true)]
    public class Answer
    {
        [DataMember] public virtual Question Question { get; set; }

        [DataMember] public virtual int AnswerId { get; set; }
        [DataMember] public virtual string Text { get; set; }
        [DataMember] public virtual string Poster { get; set; }

        [DataMember] public virtual IList<AnswerComment> Comments { get; set; }
        
    }

    [DataContract]
    public class AnswerComment
    {
        [DataMember] public virtual Answer Answer { get; set; }

        [DataMember] public virtual int AnswerCommentId { get; set; }
        [DataMember] public virtual string Text { get; set; }
        [DataMember] public virtual string Poster { get; set; }
    }

}



Add the System.Runtime.Serialization to  TheEntities' References:


Add WCF Service Application project to your solution. Name it TheService



Delete Service1.svc and IService1.svc. Then add new WCF Service, name it Forumer.svc



Add TheEntities to TheService's References



Activate NuGet (Tools > Library Package Manager > Package Manager Console). Then add Entity Framework and Fluent NHibernate to TheService.

PM> install-package EntityFramework
You are downloading EntityFramework from Microsoft, the license agreement to which is available at http://go.microsoft.com/fwlink/?LinkId=224682. Check the package for additional dependencies, which may come with their own license agreement(s). Your use of the package and dependencies constitutes your acceptance of their license agreements. If you do not accept the license agreement(s), then delete the relevant components from your device.
Successfully installed 'EntityFramework 4.1.10715.0'.
Successfully added 'EntityFramework 4.1.10715.0' to TheService.

PM> install-package FluentNHibernate
'NHibernate (≥ 3.2.0.4000)' not installed. Attempting to retrieve dependency from source...
Done.
'Iesi.Collections (≥ 3.2.0.4000)' not installed. Attempting to retrieve dependency from source...
Done.
Successfully installed 'Iesi.Collections 3.2.0.4000'.
Successfully installed 'NHibernate 3.2.0.4000'.
You are downloading FluentNHibernate from James Gregory and contributors, the license agreement to which is available at http://github.com/jagregory/fluent-nhibernate/raw/master/LICENSE.txt. Check the package for additional dependencies, which may come with their own license agreement(s). Your use of the package and dependencies constitutes your acceptance of their license agreements. If you do not accept the license agreement(s), then delete the relevant components from your device.
Successfully installed 'FluentNHibernate 1.3.0.717'.
Successfully added 'Iesi.Collections 3.2.0.4000' to TheService.
Successfully added 'NHibernate 3.2.0.4000' to TheService.
Successfully added 'FluentNHibernate 1.3.0.717' to TheService.


Then copy this in IForumer.cs:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.Serialization;
using System.ServiceModel;
using System.ServiceModel.Web;
using System.Text;
using TheEntities;

namespace TheService
{
    // NOTE: You can use the "Rename" command on the "Refactor" menu to change the interface name "IService1" in both code and config file together.
    [ServiceContract]
    public interface IForumer
    {

        [OperationContract]
        string GetData(int value);



        [OperationContract]
        Question OpenQuestion(int id);

        [OperationContract]
        void SaveQuestion(Question question, out int id, out byte[] version);

        // TODO: Add your service operations here

        [OperationContract]
        void DeleteQuestion(int questionId, byte[] version);
    }
}


Copy this to your Forumer.svc.cs:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.Serialization;
using System.ServiceModel;
using System.ServiceModel.Web;
using System.Text;
using System.Configuration;

using NHibernate;
using NHibernate.Linq;
using System.Data.Entity;

using Ienablemuch.ToTheEfnhX;
using Ienablemuch.ToTheEfnhX.EntityFramework;
using Ienablemuch.ToTheEfnhX.NHibernate;



using TheEntities;



namespace TheService
{
    // NOTE: You can use the "Rename" command on the "Refactor" menu to change the class name "Service1" in code, svc and config file together.
    public class Forumer : IForumer
    {

        
        public Forumer()
        {
        }

        IRepository<Question> QuestionRepository
        {
            get
            {
                // choose between these two

                // Entity Framework:
                /*var x = new EfDbMapper();                                
                return new EfRepository<Question>(x);*/

                // NHibernate:
                return new NhRepository<Question>(NhDbMapper.GetSession(ConfigurationManager.ConnectionStrings["EfDbMapper"].ConnectionString));
            }
        }
        
        public Question OpenQuestion(int id)
        {
            var repo = QuestionRepository;

            var query = repo.All.Where(y => y.QuestionId == id);


            if (QuestionRepository.GetType() == typeof(EfRepository<Question>))
            {                
                query = query
                        .Include("Answers")
                            .Include("Answers.Comments")
                        .Include("Comments");

                return query.Single();
            }
            else if (QuestionRepository.GetType() == typeof(NhRepository<Question>))
            {               
                 
                /* 
                // Nested FetchMany produces duplicate data. See solution below(solution obtained from stackoverflow).
                query = query
                        .FetchMany(x => x.Answers)
                            .ThenFetchMany(x => x.Comments)
                        .FetchMany(x => x.Comments);
                */



                // Good thing there's Stackoverflow, here's one way to solve it:
                // http://stackoverflow.com/questions/7028705/is-nhibernate-fetchmany-on-more-than-two-tables-broken

                repo.All.Where(y => y.QuestionId == id)
                        .FetchMany(x => x.Answers)
                            .ThenFetchMany(x => x.Comments)
                        .ToFuture();

                query = repo.All.Where(y => y.QuestionId == id)
                        .FetchMany(x => x.Comments);

                
                 
            }
            else
                throw new Exception("Something unsupported. Contact the developer");

            return query.Single();

        }


        public string GetData(int value)
        {
            
            // z.Database.Create();

            var x = QuestionRepository;

            return x.Get(1).Text;

            return string.Format("You entered: {0}", value);
        }



        public void SaveQuestion(Question question, out int id, out byte[] version)
        {
            var repo = QuestionRepository;
            repo.Merge(question, question.RowVersion);
            id = question.QuestionId;
            version = question.RowVersion;
        }



        public void DeleteQuestion(int questionId, byte[] version)
        {
            var repo = QuestionRepository;
            repo.DeleteCascade(questionId, version);
        }
    }
}



Add Entity Framework database mapper to TheService. Name it EfDbMapper.







Then copy this mapping:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Data.Entity;
using System.Data.Entity.ModelConfiguration.Conventions;
using TheEntities;

namespace TheService
{
    public class EfDbMapper : DbContext
    {


        public EfDbMapper() 
        {
            this.Configuration.ProxyCreationEnabled = false;
        }
        

        protected override void OnModelCreating(DbModelBuilder modelBuilder)
        {
            base.OnModelCreating(modelBuilder);
            
            modelBuilder.Conventions.Remove<PluralizingTableNameConvention>();

            modelBuilder.Entity<Question>().Property(x => x.RowVersion).IsRowVersion();

            modelBuilder.Entity<Question>().HasMany(x => x.Comments).WithRequired(x => x.Question).Map(x => x.MapKey("Question_QuestionId"));
            
            modelBuilder.Entity<Question>().HasMany(x => x.Answers).WithRequired(x => x.Question).Map(x => x.MapKey("Question_QuestionId"));

            modelBuilder.Entity<Answer>().HasMany(x => x.Comments).WithRequired(x => x.Answer).Map(x => x.MapKey("Answer_AnswerId"));

            

        }
    }

}

Add NHibernate database mapper to TheService. Name it NhDbMapper:




Then copy this mapping:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;

using NHibernate;

using FluentNHibernate.Cfg;
using FluentNHibernate.Cfg.Db;
using FluentNHibernate.Automapping;
using FluentNHibernate.Conventions;
using FluentNHibernate.Conventions.Instances;

using TheEntities;

namespace TheService
{
    internal static class NhDbMapper
    {
        public static ISession GetSession(string connectionString)
        {
            return GetSessionFactory(connectionString).OpenSession();
        }


        static ISessionFactory _sf = null;
        private static ISessionFactory GetSessionFactory(string connectionString)
        {
            if (_sf != null) return _sf;





            var fc = Fluently.Configure()
                    .Database(MsSqlConfiguration.MsSql2008.ConnectionString(connectionString))
                    .Mappings
                    (m =>


                            m.AutoMappings.Add
                            (
                                AutoMap.AssemblyOf<Question>(new CustomConfiguration())

                                // .Conventions.Add(ForeignKey.EndsWith("Id"))
                               .Conventions.Add<CustomForeignKeyConvention>()

                               .Conventions.Add<HasManyConvention>()
                               .Conventions.Add<RowversionConvention>()

                               .Override<Question>(x =>
                                    {
                                       

                                        x.Id(z => z.QuestionId).GeneratedBy.Identity();

                                        x.HasMany(z => z.Comments).KeyColumn("Question_QuestionId");
                                        x.HasMany(z => z.Answers).KeyColumn("Question_QuestionId");

                                    })
                                .Override<Answer>(x =>
                                   {
                                       x.References(z => z.Question).Column("Question_QuestionId");

                                       x.HasMany(z => z.Comments).KeyColumn("Answer_AnswerId");
                                   })

                                .Override<QuestionComment>(x =>
                                    {
                                        x.References(z => z.Question).Column("Question_QuestionId");
                                    })
                                .Override<AnswerComment>(x =>
                                    {
                                        x.References(z => z.Answer).Column("Answer_AnswerId");
                                    })

                            )


           );



            _sf = fc.BuildSessionFactory();
            return _sf;
        }


        class CustomConfiguration : DefaultAutomappingConfiguration
        {
            IList<Type> _objectsToMap = new List<Type>()
            {
                // whitelisted objects to map
                typeof(Question), typeof(QuestionComment), typeof(Answer), typeof(AnswerComment)
            };
            public override bool ShouldMap(Type type) { return _objectsToMap.Any(x => x == type); }
            public override bool IsId(FluentNHibernate.Member member) { return member.Name == member.DeclaringType.Name + "Id"; }

            public override bool IsVersion(FluentNHibernate.Member member) { return member.Name == "RowVersion"; }
        }




        public class CustomForeignKeyConvention : ForeignKeyConvention
        {
            protected override string GetKeyName(FluentNHibernate.Member property, Type type)
            {
                if (property == null)
                    return type.Name + "Id";


                // make foreign key compatible with Entity Framework
                return type.Name + "_" + property.Name + "Id";
            }
        }


        class HasManyConvention : IHasManyConvention
        {

            public void Apply(IOneToManyCollectionInstance instance)
            {
                instance.Inverse();
                instance.Cascade.AllDeleteOrphan();
            }


        }

        class RowversionConvention : IVersionConvention
        {
            public void Apply(IVersionInstance instance) { instance.Generated.Always(); }
        }

    }//ModelsMapper


}


Add Ienablemuch.ToTheEfnhX.dll and SystemLinqDynamic.dll to TheService's References:



Add Ienablemuch.ToTheEfnhX.EntityFramework.dll and Ienablemuch.ToTheEfnhX.NHibernate.dll to  TheService's References





Compile TheService (WCF Service project).


On your front-end(Winforms app), add Service Reference to TheService, name it UseService



Then configure your Service Reference:




Add BindingSource to your form, then name it to bdsQuestion, on your bdsQuestion Properties, click DataSource, then point it to UseService.Question:





Your bdsQuestion's DataSource should look like this:



Add another binding source to your form, name it bdsQuestionComment, point its DataSource to bdsQuestion, and its DataMember to Comments.

It should look like this:



Add another binding source to your form, name it bdsAnswer, point its DataSource to bdsQuestion, and its DataMember to Answers.

It should look like this:



Add another binding source to your form, name it bdsAnswerComment, point its DataSource to bdsAnswer, and its DataMember to Comments.

It should look like this:



Add these controls, and set the controls' property's BindingSource accordingly




Question's Text

Question's Poster

Question's Comments

Question's Answers' Text

Question's Answers' Poster

Question's Answers' Comments

Answer's BindingNavigator


Copy the following to your Form1.cs, and bind the events to their corresponding controls(I think this is where VB.NET trumps C#, VB.NET has declarative way of binding events to objects(via Handles keyword), VB.NET event has more one-stop-shop feel into it, anyway I still love C# :-)

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Linq;
using System.Text;
using System.Windows.Forms;

using System.Threading;

using ObjectGraphToTheEfnhX.UseService;


namespace ObjectGraphToTheEfnhX
{



    public partial class Form1 : Form
    {

        ForumerClient _svc = new ForumerClient();
        public Form1()
        {
            InitializeComponent();

            
            
        }


        private void uxNew_Click(object sender, EventArgs e)
        {
            bdsQuestion.DataSource = new Question();

            IndicateAnswerInputsAvailability();
        }

        private void uxSave_Click(object sender, EventArgs e)
        {
            bdsQuestion.EndEdit();
            bdsAnswerComment.EndEdit();
            bdsAnswer.EndEdit();

            ThreadPool.QueueUserWorkItem(o =>
            {
                Question question = (Question)bdsQuestion.Current;
                int id;
                byte[] rowVersion;
                id = _svc.SaveQuestion(out rowVersion, question);

                this.Invoke((MethodInvoker)delegate
                {
                    Question q = (Question)bdsQuestion.Current;
                    q.QuestionId = id;
                    q.RowVersion = rowVersion;
                });

                MessageBox.Show("Saved.");
            });
        }



        private void uxOpen_Click(object sender, EventArgs e)
        {
            ThreadPool.QueueUserWorkItem(o =>
            {

                int n;
                bool isOk = int.TryParse(uxQuestionNavigator.Text, out n);
                if (isOk)
                {
                    Question q = _svc.OpenQuestion(n);

                    this.Invoke((MethodInvoker)delegate
                    {
                        bdsQuestion.DataSource = q;
                        IndicateAnswerInputsAvailability();
                    });
                }
            });




        }


        private void bdsQuestion_CurrentChanged(object sender, EventArgs e)
        {
            Console.WriteLine("bdsQuestion");

            var c = (Question)bdsQuestion.Current;

            if (c.Answers == null) c.Answers = new List<Answer>();
            if (c.Comments == null) c.Comments = new List<QuestionComment>();


        }

        void IndicateAnswerInputsAvailability()
        {
            bool readOnly = bdsAnswer.Current == null;


            uxAnswerText.ReadOnly = readOnly;
            uxAnswerPoster.ReadOnly = readOnly;

            grdAnswerComment.ReadOnly = readOnly;
            grdAnswerComment.DefaultCellStyle.BackColor = readOnly ? Color.LightGray : Color.White;
        }

        private void bdsAnswer_CurrentChanged(object sender, EventArgs e)
        {
            Console.WriteLine("bdsAnswer_CurrentChanged");




            var a = (Answer)bdsAnswer.Current;
            if (a == null)
            {
                IndicateAnswerInputsAvailability();
                return;
            }

            IndicateAnswerInputsAvailability();

            if (a.Question == null)
            {
                a.Question = (Question)bdsQuestion.Current; // link to parent

                // http://www.ienablemuch.com/2011/08/dont-initialize-collection-on-your.html
                a.Comments = new List<AnswerComment>();
            }
        }


        private void bdsQuestionComment_CurrentChanged(object sender, EventArgs e)
        {
            var c = (QuestionComment)bdsQuestionComment.Current;
            if (c == null) return;

            if (c.Question == null)
            {
                c.Question = (Question)bdsQuestion.Current; // link to parent                
            }

        }

        private void bdsAnswerComment_CurrentChanged(object sender, EventArgs e)
        {
            var ac = (AnswerComment)bdsAnswerComment.Current;
            if (ac == null) return;

            if (ac.Answer == null)
            {
                ac.Answer = (Answer)bdsAnswer.Current; // link to parent
            }



        }

        private void uxDelete_Click(object sender, EventArgs e)
        {
            ThreadPool.QueueUserWorkItem(o =>
            {
                Question question = (Question)bdsQuestion.Current;

                _svc.DeleteQuestion(question.QuestionId, question.RowVersion);

                this.Invoke((MethodInvoker)delegate
                {
                    uxNew.PerformClick();
                });

                MessageBox.Show("Deleted.");
            });
        }

        private void Form1_Shown(object sender, EventArgs e)
        {
            _svc = new UseService.ForumerClient();

            uxNew.PerformClick();
        }





    }

    public static class Console
    {
        static int i = 0;
        public static void WriteLine(string s)
        {
            global::System.Console.WriteLine(s + " " + ++i);
        }
    }
}



Run this on SQL Management Studio:
drop database ObjectGraph;
create database ObjectGraph;
use ObjectGraph;

/*
drop table AnswerComment;
drop table Answer;
drop table QuestionComment;
drop table Question;
*/


-- Primary key and foreign key fields naming convention is patterned after Entity Framework's DB creation style,
-- except for the constraint name of primary key and foreign key

-- make the link to parent obvious, field(s) that comes before primary key are foreign key(which typically is immutable too)
-- other foreign key that comes after primary key are mutable

create table Question
(
QuestionId int identity(1,1) not null,
Text nvarchar(max),
Poster nvarchar(max),
RowVersion rowversion,

constraint pk_Question primary key(QuestionId)
);

create table QuestionComment
(
Question_QuestionId int not null, 

QuestionCommentId int identity(1,1) not null,
Text nvarchar(max),
Poster nvarchar(max),

constraint pk_QuestionComment primary key(QuestionCommentId),
constraint fk_QuestionComment__Question foreign key(Question_QuestionId) references Question(QuestionId)
);


create table Answer
(
Question_QuestionId int not null,

AnswerId int identity(1,1) not null,
Text nvarchar(max),
Poster nvarchar(max),

constraint pk_Answer primary key(AnswerId),
constraint fk_Answer__Question foreign key(Question_QuestionId) references Question(QuestionId)
);


create table AnswerComment
(
Answer_AnswerId int not null,

AnswerCommentId int identity(1,1) not null,
Text nvarchar(max),
Poster nvarchar(max),

constraint pk_AnswerComment primary key(AnswerCommentId),
constraint fk_AnswerComment__Answer foreign key(Answer_AnswerId) references Answer(AnswerId)
);


Then put this in Web.Config or your WCF Service Project

<connectionStrings>
    <add name="EfDbMapper"
         providerName="System.Data.SqlClient"
         connectionString="Data Source=localhost; Initial Catalog=ObjectGraph; User id=sa; Password=P@$$w0rd"/>
  </connectionStrings>


Sample output:





As you can see, it can save the whole object graph.

Now let's try to delete the first answer(its corresponding comments will be deleted too), and let's try to modify the second's answer's text, and add another comment to second's answer. This is where ToTheEfnhX dutifully do its job, this Merge functionality is glaringly missing in Entity Framework. All changes (delete,update,insert) to the entities can be merged back to database.



This is the output:




Run the app. Happy Computing!


The sample project code:
http://code.google.com/p/to-the-efnh-x-sample-project/downloads/list

ToTheEfnhX code:
http://code.google.com/p/to-the-efnh-x/downloads/list


Updated ToTheEfnhX code: https://github.com/MichaelBuen/ToTheEfnhX

No comments:

Post a Comment