نظرات مطالب
ذخیره تنظیمات متغیر مربوط به یک وب اپلیکیشن ASP.NET MVC با استفاده از EF
سلام . تشکر از این مطلب مفید و کاربردی .
آیا دو کلاس زیر درست تعریف شده اند یا باید دومی به SEOSetting تبدیل شود ؟
public class GeneralSettings : SettingsBase
   {
       public string SiteName { get; set; }
       public string AdminEmail { get; set; }
       public bool RegisterUsersEnabled { get; set; }
   }
 
public class GeneralSettings : SettingsBase
   {
       public string SiteName { get; set; }
       public string AdminEmail { get; set; }
   }
نظرات مطالب
ASP.NET MVC #12
- از Html.RenderAction استفاده کنید.
+ و یا همچنین layout، مدل محتوای خودش را به ارث می‌برد. یعنی مدلی که در View تنظیم می‌شود، همان مدلی است که layout به آن دسترسی خواهد داشت. به همین جهت مثلا می‌تونید توسط ViewBag، عنوان صفحه را که در layout تعریف شده، مقدار دهی کنید.
اگر می‌خواهید Strongly typed کار کنید، روش Html.RenderAction یک راه حل است و روش دوم به صورت زیر است:
یک کلاس پایه abstract تعریف کنید:

public abstract class BaseViewModel
{
    public string Name { get; set; }
}
بعد تمام مدل‌ها یا ViewModelهایی که قرار است در برنامه شما به Viewها ارسال شوند، باید از این کلاس پایه ارث بری کنند. مثلا:
public class HomeViewModel : BaseViewModel
{
   public int Data1 { set; get;}
   // ...
}
در این حالت و با رعایت این شرط، می‌تونید در فایل layout، نوع مدل را بجای حالت dynamic فعلی، تبدیل کنید به نوع کلاس پایه‌ایی که ذکر شد:
@model BaseViewModel
<!DOCTYPE html>
<html>
    <head>
        <meta name="viewport" content="width=device-width" />
        <title>Test</title>
    </head>
    <body>
        <header>
            Hello @Model.Name
        </header>
        <div>
            @this.RenderBody()
        </div>
    </body>
</html>
الان در layout، نوع کلاس پایه، به عنوان نوع مدل اصلی تعریف شده. بنابراین در این فایل layout مشترک بین تمام Viewها، خواص قرار گرفته شده در کلاس پایه‌ای که توسط ViewModelها به Viewها ارسال می‌شوند، به صورت strongly typed قابل دسترسی خواهند بود.
نظرات مطالب
استفاده از چندین Context در EF 6 Code first
من از دو context در برنامم برای schema‌های مختلف استفاده میکنم مشکلی که دارم اینه که entity یک schema وابستگی به entity شمای دیگر دارد و من entity‌های هر schema را در یک assembly قرار دادم .الان نمیدانم چطور روابط را برقرار کنم
مثلا در یک schema جدول کاربران را دارم که در یک Assembly است و در شمای دیگر جدول مقالات را  در assembly دیگری دارم .ارتباط این دو به این صورت است که وقتی مقاله ای درج می‌شود باید کاربری که مقاله را درج کرده نیز در دیتابیس درج شود.
الان باید یک پروپرتی از جنس مقاله در کلاس کاربر و یک پروپرتی از جنس کاربر در کلاس مقاله در نظر بگیرم.با وجود اینکه هر کدام در یک Assembly هستند باید Refrence از یک اسمبلی در دیگری داشته باشم که این موضوع امکان پذیر نیست لطفا راهنمایی نمائید ممنون
نظرات مطالب
چگونگی گزارشگیری از Business Objects مانند List توسط StimulSoft
با کد زیر
    public class Book
    {
        public string Title { get; set; }
        public int Id { get; set; }
        public Author Author { get; set; }
        public Publisher Publisher { get; set; }

        public IList<Book> GetBooks()
        {
            var books = new List<Book>();

            for (int i = 0; i < 10; i++)
            {
                var book = new Book()
                {
                    Id = i,
                    Title = $"Title {i}",
                    Author = new Author()
                    {
                        FirstName = $"Author {i} First Name",
                        LastName = $"Author {i} Last Name",
                        Person = new Person()
                        {
                            Type = $"Type {i}",
                            Value = $"Value {i}"
                        }
                    },
                    Publisher = new Report.Publisher()
                    {
                        Name = $"Publiser {i}"
                    }
                };
                books.Add(book);
            }
            return books;
        }
    }

    public class Author
    {
        public string FirstName { get; set; }
        public string LastName { get; set; }
        public Person Person { get; set; }
    }

    public class Person
    {
        public string Type { get; set; }
        public string Value { get; set; }
    }

    public class Publisher
    {
        public string Name { get; set; }
    }
و فایل mrt زیر مشکلی دیده نشد.
مطالب
پیاده سازی SoftDelete در EF Core
در مورد حذف منطقی در EF 6x، پیشتر مطالبی را در این سایت مطالعه کرده‌اید:
- «پیاده سازی حذف منطقی در Entity framework» حذف منطقی، یکی از الگوهای بسیار پرکاربرد در برنامه‌های تجاری است. توسط آن بجای حذف فیزیکی اطلاعات، آن‌ها را تنها به عنوان رکوردی حذف شده، «علامتگذاری» می‌کنیم. مزایای آن نیز به شرح زیر هستند:
- داشتن سابقه‌ی حذف اطلاعات
- جلوگیری از cascade delete
- امکان بازیابی رکوردها و امکان ایجاد قسمتی به نام recycle bin در برنامه (شبیه به recycle bin در ویندوز که امکان بازیابی موارد حذف شده را می‌دهد)
- امکان داشتن رکوردهایی که در یک برنامه (به ظاهر) حذف شده‌اند، اما هنوز در برنامه‌ی دیگری در حال استفاده هستند.
- بالابردن میزان امنیت برنامه. فرض کنید سایت شما هک شده و شخصی، دسترسی به پنل مدیریتی و سطوح دسترسی مدیریتی برنامه را پیدا کرده‌است. در این حالت حذف تمام رکوردهای سایت توسط او، تنها به معنای تغییر یک بیت، از یک به صفر است و بازگرداندن این درجه از خسارت، تنها با روشن کردن این بیت، برطرف می‌شود.

پیاده سازی حذف منطقی در EF Core شامل مراحل خاصی است که در این مطلب، جزئیات آن‌ها را بررسی خواهیم کرد.


نیاز به تعریف دو خاصیت جدید در هر جدول

هر جدولی که قرار است soft delete به آن اعمال شود، باید دارای دو فیلد جدید bool IsDeleted و DateTime? DeletedAt باشد. می‌توان این خواص را به هر موجودیتی به صورت دستی اضافه کرد و یا می‌توان ابتدا یک کلاس پایه‌ی abstract را برای آن ایجاد کرد:
using System;

namespace EFCoreSoftDelete.Entities
{
    public abstract class BaseEntity
    {
        public int Id { get; set; }


        public bool IsDeleted { set; get; }
        public DateTime? DeletedAt { set; get; }
    }
}
و سپس موجودیت‌هایی را که قرار است از soft delete پشتیبانی کنند، توسط آن علامتگذاری کرد؛ مانند موجودیت Blog:
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;

namespace EFCoreSoftDelete.Entities
{
    public class Blog : BaseEntity
    {
        public string Name { set; get; }

        public virtual ICollection<Post> Posts { set; get; }
    }

    public class BlogConfiguration : IEntityTypeConfiguration<Blog>
    {
        public void Configure(EntityTypeBuilder<Blog> builder)
        {
            builder.Property(blog => blog.Name).HasMaxLength(450).IsRequired();
            builder.HasIndex(blog => blog.Name).IsUnique();

            builder.HasData(new Blog { Id = 1, Name = "Blog 1" });
            builder.HasData(new Blog { Id = 2, Name = "Blog 2" });
            builder.HasData(new Blog { Id = 3, Name = "Blog 3" });
        }
    }
}
که هر بلاگ از تعدادی مطلب تشکیل شده‌است:
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;

namespace EFCoreSoftDelete.Entities
{
    public class Post : BaseEntity
    {
        public string Title { set; get; }

        public Blog Blog { set; get; }
        public int BlogId { set; get; }
    }

    public class PostConfiguration : IEntityTypeConfiguration<Post>
    {
        public void Configure(EntityTypeBuilder<Post> builder)
        {
            builder.Property(post => post.Title).HasMaxLength(450);
            builder.HasOne(post => post.Blog).WithMany(blog => blog.Posts).HasForeignKey(post => post.BlogId);

            builder.HasData(new Post { Id = 1, BlogId = 1, Title = "Post 1" });
            builder.HasData(new Post { Id = 2, BlogId = 1, Title = "Post 2" });
            builder.HasData(new Post { Id = 3, BlogId = 1, Title = "Post 3" });
            builder.HasData(new Post { Id = 4, BlogId = 1, Title = "Post 4" });
            builder.HasData(new Post { Id = 5, BlogId = 2, Title = "Post 5" });
        }
    }
}
مزیت علامتگذاری این کلاس‌ها، امکان کوئری گرفتن از آن‌ها نیز می‌باشد که در ادامه از آن استفاده خواهیم کرد.


حذف خودکار رکوردهایی که Soft Delete شده‌اند، از نتیجه‌ی کوئری‌ها و گزارشات

تا اینجا فقط دو خاصیت ساده را به کلاس‌های مدنظر خود اضافه کرده‌ایم. پس از آن یا می‌توان در هر جائی برای مثال شرط context.Blogs.Where(blog => !blog.IsDeleted) را به صورت دستی اعمال کرد و در گزارشات، رکوردهای حذف منطقی شده را نمایش نداد و یا از زمان ارائه‌ی EF Core 2x می‌توان برای آن‌ها Query Filter تعریف کرد. برای مثال می‌توان به تنظیمات موجودیت Blog و یا Post مراجعه نمود و با استفاده از متد HasQueryFilter، همان شرط blog => !blog.IsDeleted را به صورت سراسری به تمام کوئری‌های مرتبط با این موجودیت‌ها اعمال کرد:
    public class BlogConfiguration : IEntityTypeConfiguration<Blog>
    {
        public void Configure(EntityTypeBuilder<Blog> builder)
        {
            // ...
            builder.HasQueryFilter(blog => !blog.IsDeleted);
        }
    }
از این پس ذکر context.Blogs دقیقا معنای context.Blogs.Where(blog => !blog.IsDeleted) را می‌دهد و دیگر نیازی به ذکر صریح شرط متناظر با soft delete نیست.
در این حالت کوئری‌های نهایی به صورت خودکار دارای شرط زیر خواهند شد:
SELECT [b].[Id], [b].[DeletedAt], [b].[IsDeleted], [b].[Name]
FROM [Blogs] AS [b]
WHERE [b].[IsDeleted] <> CAST(1 AS bit)


اعمال خودکار QueryFilter مخصوص Soft Delete به تمام موجودیت‌ها

همانطور که عنوان شد، مزیت علامتگذاری موجودیت‌ها با کلاس پایه‌ی BaseEntity، امکان کوئری گرفتن از آن‌ها است:
namespace EFCoreSoftDelete.DataLayer
{
    public static class GlobalFiltersManager
    {
        public static void ApplySoftDeleteQueryFilters(this ModelBuilder modelBuilder)
        {
            foreach (var entityType in modelBuilder.Model
                                                    .GetEntityTypes()
                                                    .Where(eType => typeof(BaseEntity).IsAssignableFrom(eType.ClrType)))
            {
                entityType.addSoftDeleteQueryFilter();
            }
        }

        private static void addSoftDeleteQueryFilter(this IMutableEntityType entityData)
        {
            var methodToCall = typeof(GlobalFiltersManager)
                                .GetMethod(nameof(getSoftDeleteFilter), BindingFlags.NonPublic | BindingFlags.Static)
                                .MakeGenericMethod(entityData.ClrType);
            var filter = methodToCall.Invoke(null, new object[] { });
            entityData.SetQueryFilter((LambdaExpression)filter);
        }

        private static LambdaExpression getSoftDeleteFilter<TEntity>() where TEntity : BaseEntity
        {
            return (Expression<Func<TEntity, bool>>)(entity => !entity.IsDeleted);
        }
    }
}
در اینجا در ابتدا تمام موجودیت‌هایی که از BaseEntity ارث بری کرده‌اند، یافت می‌شوند. سپس بر روی آن‌ها قرار است متد SetQueryFilter فراخوانی شود. این متد بر اساس تعاریف EF Core، یک LambdaExpression کلی را قبول می‌کند که نمونه‌ی آن در متد getSoftDeleteFilter تعریف شده و سپس توسط متد addSoftDeleteQueryFilter به صورت پویا به modelBuilder اعمال می‌شود.

محل اعمال آن نیز در انتهای متد OnModelCreating است تا به صورت خودکار به تمام موجودیت‌های موجود اعمال شود:
namespace EFCoreSoftDelete.DataLayer
{
    public class ApplicationDbContext : DbContext
    {

        //...


        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            base.OnModelCreating(modelBuilder);

            modelBuilder.ApplyConfigurationsFromAssembly(typeof(BaseEntity).Assembly);
            modelBuilder.ApplySoftDeleteQueryFilters();
        }


مشکل! هنوز هم حذف فیزیکی رخ می‌دهد!

تنظیمات فوق، تنها بر روی کوئری‌های نوشته شده تاثیر دارند؛ اما هیچگونه تاثیری را بر روی متد Remove و سپس SaveChanges نداشته و در این حالت، هنوز هم حذف واقعی و فیزیکی رخ می‌دهد.
 برای رفع این مشکل باید به EF Core گفت، هر چند دستور حذف صادر شده، اما آن‌را تبدیل به دستور Update کن؛ یعنی فیلد IsDelete را به 1 و فیلد DeletedAt را با زمان جاری مقدار دهی کن:
namespace EFCoreSoftDelete.DataLayer
{
    public static class AuditableEntitiesManager
    {
        public static void SetAuditableEntityOnBeforeSaveChanges(this ApplicationDbContext context)
        {
            var now = DateTime.UtcNow;

            foreach (var entry in context.ChangeTracker.Entries<BaseEntity>())
            {
                switch (entry.State)
                {
                    case EntityState.Added:
                        //TODO: ...
                        break;
                    case EntityState.Modified:
                        //TODO: ...
                        break;
                    case EntityState.Deleted:
                        entry.State = EntityState.Unchanged; //NOTE: For soft-deletes to work with the original `Remove` method.

                        entry.Entity.IsDeleted = true;
                        entry.Entity.DeletedAt = now;
                        break;
                }
            }
        }
    }
}
در اینجا با استفاده از سیستم tracking، رکوردهای حذف شده‌ی با وضعیت EntityState.Deleted، به وضعیت EntityState.Unchanged تغییر پیدا می‌کنند، تا دیگر حذف نشوند. اما در ادامه چون دو خاصیت IsDeleted و DeletedAt این موجودیت، ویرایش می‌شوند، وضعیت جدید Modified خواهد بود که به کوئری‌های Update تفسیر می‌شوند. به این ترتیب می‌توان همانند قبل یک رکورد را حذف کرد:
var post1 = context.Posts.Find(1);
if (post1 != null)
{
   context.Remove(post1);

   context.SaveChanges();
}
اما دستوری که توسط EF Core صادر می‌شود، یک Update است:
Executing DbCommand [Parameters=[@p2='1', @p0='2020-09-17T05:11:32' (Nullable = true), @p1='True'], CommandType='Text', CommandTimeout='30']
SET NOCOUNT ON;
UPDATE [Posts] SET [DeletedAt] = @p0, [IsDeleted] = @p1
WHERE [Id] = @p2;
SELECT @@ROWCOUNT;

محل اعمال متد SetAuditableEntityOnBeforeSaveChanges فوق، پیش از فراخوانی SaveChanges و به صورت زیر است:
namespace EFCoreSoftDelete.DataLayer
{
    public class ApplicationDbContext : DbContext
    {
        // ...

        public override int SaveChanges(bool acceptAllChangesOnSuccess)
        {
            ChangeTracker.DetectChanges();

            beforeSaveTriggers();

            ChangeTracker.AutoDetectChangesEnabled = false; // for performance reasons, to avoid calling DetectChanges() again.
            var result = base.SaveChanges(acceptAllChangesOnSuccess);

            ChangeTracker.AutoDetectChangesEnabled = true;
            return result;
        }

        // ...

        private void beforeSaveTriggers()
        {
            setAuditProperties();
        }

        private void setAuditProperties()
        {
            this.SetAuditableEntityOnBeforeSaveChanges();
        }
    }
}


مشکل! رکوردهای وابسته حذف نمی‌شوند!

حالت پیش‌فرض حذف رکوردها در EFCore به cascade delete تنظیم شده‌است. یعنی اگر blog با id=1 حذف شود، نه فقط این blog، بلکه تمام مطالب وابسته‌ی به آن نیز حذف خواهند شد. اما در اینجا اگر این بلاگ را حذف کنیم:
 ar blog1 = context.Blogs.FirstOrDefault(blog => blog.Id == 1);
if (blog1 != null)
{
   context.Remove(blog1);

   context.SaveChanges();
}
تنها تک رکورد متناظر با آن حذف منطقی شده و مطالب متناظر با آن خیر. برای رفع این مشکل باید به صورت زیر عمل کرد:
var blog1AndItsRelatedPosts = context.Blogs
    .Include(blog => blog.Posts)
    .FirstOrDefault(blog => blog.Id == 1);
if (blog1AndItsRelatedPosts != null)
{
    context.Remove(blog1AndItsRelatedPosts);

    context.SaveChanges();
}
ابتدا باید رکوردهای وابسته را توسط یک Include به حافظه وارد کرد و سپس دستور Delete را بر روی کل آن صادر نمود که یک چنین خروجی را تولید می‌کند:
SELECT [t].[Id], [t].[DeletedAt], [t].[IsDeleted], [t].[Name], [t0].[Id], [t0].[BlogId], [t0].[DeletedAt], [t0].[IsDeleted], [t0].[Title]
FROM (
SELECT TOP(1) [b].[Id], [b].[DeletedAt], [b].[IsDeleted], [b].[Name]
FROM [Blogs] AS [b]
WHERE ([b].[IsDeleted] <> CAST(1 AS bit)) AND ([b].[Id] = 1)
) AS [t]
LEFT JOIN (
SELECT [p].[Id], [p].[BlogId], [p].[DeletedAt], [p].[IsDeleted], [p].[Title]
FROM [Posts] AS [p]
WHERE [p].[IsDeleted] <> CAST(1 AS bit)
) AS [t0] ON [t].[Id] = [t0].[BlogId]
ORDER BY [t].[Id], [t0].[Id]

Executing DbCommand [Parameters=[@p2='1', @p0='2020-09-17T05:25:00' (Nullable = true), @p1='True',
 @p5='2', @p3='2020-09-17T05:25:00' (Nullable = true), @p4='True', @p8='3',
@p6='2020-09-17T05:25:00' (Nullable = true), @p7='True',
 @p11='4', @p9='2020-09-17T05:25:00' (Nullable = true), @p10='True'], CommandType='Text', CommandTimeout='30']

SET NOCOUNT ON;
UPDATE [Blogs] SET [DeletedAt] = @p0, [IsDeleted] = @p1
WHERE [Id] = @p2;
SELECT @@ROWCOUNT;

UPDATE [Posts] SET [DeletedAt] = @p3, [IsDeleted] = @p4
WHERE [Id] = @p5;
SELECT @@ROWCOUNT;

UPDATE [Posts] SET [DeletedAt] = @p6, [IsDeleted] = @p7
WHERE [Id] = @p8;
SELECT @@ROWCOUNT;

UPDATE [Posts] SET [DeletedAt] = @p9, [IsDeleted] = @p10
WHERE [Id] = @p11;
SELECT @@ROWCOUNT;
ابتدا اولین بلاگ را حذف منطقی کرده؛ سپس تمام مطالب متناظر با آن‌را که پیشتر حذف منطقی نشده‌اند، یکی یکی به صورت حذف شده، علامتگذاری می‌کند. به این ترتیب cascade delete منطقی نیز در اینجا میسر می‌شود.


یک نکته: مشکل حذف منطقی و رکوردهای منحصربفرد

فرض کنید در جدولی، فیلد نام کاربری را به عنوان یک فیلد منحصربفرد تعریف کرده‌اید و اکنون رکوردی در این بین، حذف منطقی شده‌است. مشکلی که در آینده بروز خواهد کرد، عدم امکان ثبت رکورد جدیدی با همان نام کاربری است که حذف منطقی شده‌است؛ چون یک unique index بر روی آن وجود دارد. در این حالت اگر از SQL Server استفاده می‌کنید، از قابلیتی به نام filtered indexes پشتیبانی می‌کند که در آن امکان تعریف یک شرط و predicate، در حین تعریف ایندکس‌ها وجود دارد. در این حالت می‌توان رکوردهای حذف منطقی شده را به ایندکس وارد نکرد.



کدهای کامل این مطلب را از اینجا می‌توانید دریافت کنید: EFCoreSoftDelete.zip
مطالب
EF Code First #9

تنظیمات ارث بری کلاس‌ها در EF Code first


بانک‌های اطلاعاتی مبتنی بر SQL، تنها روابطی از نوع «has a» یا «دارای» را پشتیبانی می‌کنند؛ اما در دنیای شیءگرا روابطی مانند «is a» یا «هست» نیز قابل تعریف هستند. برای توضیحات بیشتر به مدل‌های زیر دقت نمائید:


using System;

namespace EF_Sample05.DomainClasses.Models
{
public abstract class Person
{
public int Id { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
public DateTime DateOfBirth { get; set; }
}
}

namespace EF_Sample05.DomainClasses.Models
{
public class Coach : Person
{
public string TeamName { set; get; }
}
}

namespace EF_Sample05.DomainClasses.Models
{
public class Player : Person
{
public int Number { get; set; }
public string Description { get; set; }
}
}

در این مدل‌ها که بر اساس ارث بری از کلاس شخص، تهیه شده‌اند؛ بازیکن، یک شخص است. مربی نیز یک شخص است؛ و به این ترتیب خوانده می‌شوند:

Coach "is a" Person
Player "is a" Person

در EF Code first سه روش جهت کار با این نوع کلاس‌ها و کلا ارث بری وجود دارد که در ادامه به آن‌ها خواهیم پرداخت:

الف) Table per Hierarchy یا TPH

همانطور که از نام آن نیز پیدا است، کل سلسله مراتبی را که توسط ارث بری تعریف شده است، تبدیل به یک جدول در بانک اطلاعاتی می‌کند. این حالت، شیوه برخورد پیش فرض EF Code first با ارث بری کلاس‌ها است و نیاز به هیچگونه تنظیم خاصی ندارد.
برای آزمایش این مساله، کلاس Context را به نحو زیر تعریف نمائید و سپس اجازه دهید تا EF بانک اطلاعاتی معادل آن‌را تولید کند:

using System.Data.Entity;
using EF_Sample05.DomainClasses.Models;

namespace EF_Sample05.DataLayer.Context
{
public class Sample05Context : DbContext
{
public DbSet<Person> People { set; get; }
}
}

ساختار جدول تولید شده آن همانند تصویر زیر است:


همانطور که ملاحظه می‌کنید، تمام کلاس‌های مشتق شده از کلاس شخص را تبدیل به یک جدول کرده است؛ به علاوه یک فیلد جدید را هم به نام Discriminator به این جدول اضافه نموده است. برای درک بهتر عملکرد این فیلد، چند رکورد را توسط برنامه به بانک اطلاعاتی اضافه می‌کنیم. حاصل آن به شکل زیر خواهد بود:


از فیلد Discriminator جهت ثبت نام کلاس‌های متناظر با هر رکورد، استفاده شده است. به این ترتیب EF حین کار با اشیاء دقیقا می‌داند که چگونه باید خواص متناظر با کلاس‌های مختلف را مقدار دهی کند.
به علاوه اگر به ساختار جدول تهیه شده دقت کنید، مشخص است که در حالت TPH، نیاز است فیلدهای متناظر با کلاس‌های مشتق شده از کلاس پایه، همگی null پذیر باشند. برای نمونه فیلد Number که از نوع int تعریف شده، در سمت بانک اطلاعاتی نال پذیر تعریف شده است.
و برای کوئری نوشتن در این حالت می‌توان از متد الحاقی OfType جهت فیلتر کردن اطلاعات بر اساس کلاسی خاص، کمک گرفت:

db.People.OfType<Coach>().FirstOrDefault(x => x.LastName == "Coach L1")


سفارشی سازی نحوه نگاشت TPH

همانطور که عنوان شد، TPH‌ نیاز به تنظیمات خاصی ندارد و حالت پیش فرض است؛ اما برای مثال می‌توان بر روی مقادیر و نوع ستون Discriminator تولیدی، کنترل داشت. برای این منظور باید از Fluent API به نحو زیر استفاده کرد:

using System.Data.Entity.ModelConfiguration;
using EF_Sample05.DomainClasses.Models;

namespace EF_Sample05.DataLayer.Mappings
{
public class CoachConfig : EntityTypeConfiguration<Coach>
{
public CoachConfig()
{
// For TPH
this.Map(m => m.Requires(discriminator: "PersonType").HasValue(1));
}
}
}

using System.Data.Entity.ModelConfiguration;
using EF_Sample05.DomainClasses.Models;

namespace EF_Sample05.DataLayer.Mappings
{
public class PlayerConfig : EntityTypeConfiguration<Player>
{
public PlayerConfig()
{
// For TPH
this.Map(m => m.Requires(discriminator: "PersonType").HasValue(2));
}
}
}

در اینجا توسط متد Map، نام فیلد discriminator به PersonType تغییر کرده. همچنین چون مقدار پیش فرض تعیین شده توسط متد HasValue عددی است، نوع این فیلد در سمت بانک اطلاعاتی به int null تغییر می‌کند.


ب) Table per Type یا TPT

در حالت TPT، به ازای هر کلاس موجود در سلسله مراتب تعیین شده، یک جدول در سمت بانک اطلاعاتی تشکیل می‌گردد.
در جداول متناظر با Sub classes، تنها همان فیلدهایی وجود خواهند داشت که در کلاس‌های هم نام وجود دارد و فیلدهای کلاس پایه در آن‌ها ذکر نخواهد گردید. همچنین این جداول دارای یک Primary key نیز خواهند بود (که دقیقا همان کلید اصلی جدول پایه است که به آن Shared primary key هم گفته می‌شود). این کلید اصلی، به عنوان کلید خارجی اشاره کننده به کلاس یا جدول پایه نیز تنظیم می‌گردد:


برای تنظیم این نوع ارث بری، تنها کافی است ویژگی Table را بر روی Sub classes قرار داد:

using System.ComponentModel.DataAnnotations;

namespace EF_Sample05.DomainClasses.Models
{
[Table("Coaches")]
public class Coach : Person
{
public string TeamName { set; get; }
}
}

using System.ComponentModel.DataAnnotations;

namespace EF_Sample05.DomainClasses.Models
{
[Table("Players")]
public class Player : Person
{
public int Number { get; set; }
public string Description { get; set; }
}
}

یا اگر حالت Fluent API را ترجیح می‌دهید، همانطور که در قسمت‌های قبل نیز ذکر شد، معادل ویژگی Table در اینجا، متد ToTable است.

ج) Table per Concrete type یا TPC

در تعاریف ارث بری که تاکنون بررسی کردیم، مرسوم است کلاس پایه را از نوع abstract تعریف کنند. به این ترتیب هدف اصلی، Sub classes تعریف شده خواهند بود؛ چون نمی‌توان مستقیما وهله‌ای را از کلاس abstract تعریف شده ایجاد کرد.
در حالت TPC، به ازای هر sub class غیر abstract، یک جدول ایجاد می‌شود. هر جدول نیز حاوی فیلدهای کلاس پایه می‌باشد (برخلاف حالت TPT که جداول متناظر با کلاس‌های مشتق شده، تنها حاوی همان خواص و فیلدهای کلاس‌های متناظر بودند و نه بیشتر). به این ترتیب عملا جداول تشکیل شده در بانک اطلاعاتی، از وجود ارث بری در سمت کدهای ما بی‌خبر خواهند بود.


برای پیاده سازی TPC نیاز است از Fluent API استفاده شود:

using System.ComponentModel.DataAnnotations;
using System.Data.Entity.ModelConfiguration;
using EF_Sample05.DomainClasses.Models;

namespace EF_Sample05.DataLayer.Mappings
{
public class PersonConfig : EntityTypeConfiguration<Person>
{
public PersonConfig()
{
// for TPC
this.Property(x => x.Id).HasDatabaseGeneratedOption(DatabaseGeneratedOption.None);
}
}
}

using System.Data.Entity.ModelConfiguration;
using EF_Sample05.DomainClasses.Models;

namespace EF_Sample05.DataLayer.Mappings
{
public class CoachConfig : EntityTypeConfiguration<Coach>
{
public CoachConfig()
{
// For TPH
//this.Map(m => m.Requires(discriminator: "PersonType").HasValue(1));

// for TPT
//this.ToTable("Coaches");

//for TPC
this.Map(m =>
{
m.MapInheritedProperties();
m.ToTable("Coaches");
});
}
}
}

using System.Data.Entity.ModelConfiguration;
using EF_Sample05.DomainClasses.Models;

namespace EF_Sample05.DataLayer.Mappings
{
public class PlayerConfig : EntityTypeConfiguration<Player>
{
public PlayerConfig()
{
// For TPH
//this.Map(m => m.Requires(discriminator: "PersonType").HasValue(2));

// for TPT
//this.ToTable("Players");

//for TPC
this.Map(m =>
{
m.MapInheritedProperties();
m.ToTable("Players");
});
}
}
}

ابتدا نوع فیلد Id از حالت Identity خارج شده است. این مورد جهت کار با TPC ضروری است در غیراینصورت EF هنگام ثبت، به مشکل بر می‌خورد، از این لحاظ که برای دو شیء، به یک Id خواهد رسید و امکان ثبت را نخواهد داد. بنابراین در یک چنین حالتی استفاده از نوع Guid برای تعریف primary key شاید بهتر باشد. بدیهی است در این حالت باید Id را به صورت دستی مقدار دهی نمود.
در ادامه توسط متد MapInheritedProperties، به همان مقصود لحاظ کردن تمام فیلدهای ارث بری شده در جدول حاصل، خواهیم رسید. همچنین نام جداول متناظر نیز ذکر گردیده است.


سؤال : از این بین، بهتر است از کدامیک استفاده شود؟

- برای حالت‌های ساده از TPH استفاده کنید. برای مثال یک بانک اطلاعاتی قدیمی دارید که هر جدول آن 200 تا یا شاید بیشتر فیلد دارد! امکان تغییر طراحی آن هم وجود ندارد. برای اینکه بتوان به حس بهتری حین کارکردن با این نوع سیستم‌های قدیمی رسید، می‌شود از ترکیب TPH و ComplexTypes (که در قسمت‌های قبل در مورد آن بحث شد) برای مدیریت بهتر این نوع جداول در سمت کدهای برنامه استفاده کرد.
- اگر علاقمند به استفاده از روابط پلی‌مرفیک هستید ( برای مثال در کلاسی دیگر، ارجاعی به کلاس پایه Person وجود دارد) و sub classes دارای تعداد فیلدهای کمی هستند، از TPH استفاده کنید.
- اگر تعداد فیلدهای sub classes زیاد است و بسیار بیشتر است از کلاس پایه، از روش TPT استفاده کنید.
- اگر عمق ارث بری و تعداد سطوح تعریف شده بالا است، بهتر است از TPC استفاده کنید. حالت TPT از join استفاده می‌کند و حالت TPC از union برای تشکیل کوئری‌ها کمک خواهد گرفت
مطالب دوره‌ها
خلاصه‌ای از اعمال متداول با AutoMapper و Entity Framework
فرض کنید کلاس‌های مدل برنامه از سه کلاس مشتری، سفارشات مشتری‌ها و اقلام هر سفارش تشکیل شده‌است:
public class Customer
{
    public int Id { set; get; }
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public string Bio { get; set; }
 
    public virtual ICollection<Order> Orders { get; set; }
 
    [Computed]
    [NotMapped]
    public string FullName
    {
        get { return FirstName + ' ' + LastName; }
    }
}

public class Order
{
    public int Id { set; get; }
    public string OrderNo { get; set; }
    public DateTime PurchaseDate { get; set; }
    public bool ShipToHomeAddress { get; set; }
 
    public virtual ICollection<OrderItem> OrderItems { get; set; }
 
    [ForeignKey("CustomerId")]
    public virtual Customer Customer { get; set; }
    public int CustomerId { get; set; }
 
    [Computed]
    [NotMapped]
    public decimal Total
    {
        get { return OrderItems.Sum(x => x.TotalPrice); }
    }
}

public class OrderItem
{
    public int Id { get; set; }
    public decimal Price { get; set; }
    public string Name { get; set; }
    public int Quantity { get; set; }
 
    [ForeignKey("OrderId")]
    public virtual Order Order { get; set; }
    public int OrderId { get; set; }
 
    [Computed]
    [NotMapped]
    public decimal TotalPrice
    {
        get { return Price * Quantity; }
    }
}
در اینجا برای پیاده سازی خواص محاسباتی، از نکته‌ی مطرح شده‌ی در مطلب «نگاشت خواص محاسبه شده به کمک AutoMapper و DelegateDecompiler» استفاده شده‌است.
در ادامه می‌خواهیم اطلاعات حاصل از این کلاس‌ها را با شرایط خاصی به ViewModelهای مشخصی جهت نمایش در برنامه نگاشت کنیم.


نمایش اطلاعات مشتری‌ها

می‌خواهیم اطلاعات مشتری‌ها را مطابق فرمت کلاس ذیل بازگشت دهیم:
public class CustomerViewModel
{
    public string Bio { get; set; }
    public string CustomerName { get; set; }
}
با این شرایط که
- اگر Bio نال بود، بجای آن N/A نمایش داده شود.
- CustomerName از خاصیت محاسباتی FullName کلاس مشتری تامین گردد.

برای حل این مساله، نیاز است نگاشت زیر را تهیه کنیم:
this.CreateMap<Customer, CustomerViewModel>()
   .ForMember(dest => dest.CustomerName, opt => opt.MapFrom(entity => entity.FullName))
   .ForMember(dest => dest.Bio, opt => opt.MapFrom(entity => entity.Bio ?? "N/A"));
AutoMapper برای جایگزین کردن خواص با مقدار نال، با یک مقدار مشخص، از متدی به نام NullSubstitute استفاده می‌کند. اما در این حالت خاص که قصد داریم از Project To استفاده کنیم، این روش پاسخ نمی‌دهد و محدودیت‌هایی دارد. به همین جهت از روش map from و بررسی مقدار خاصیت، استفاده شده‌است.
همچنین در اینجا مطابق نگاشت فوق، خاصیت CustomerName از خاصیت FullName کلاس مشتری دریافت می‌شود.

کوئری نهایی استفاده کننده‌ی از این اطلاعات به شکل زیر خواهد بود:
using (var context = new MyContext())
{
    var viewCustomers = context.Customers
        .Project()
        .To<CustomerViewModel>()
        .Decompile()
        .ToList();
    // don't use
    // var viewCustomers = Mapper.Map<IEnumerable<Customer>, IEnumerable<CustomerViewModel>>(dbCustomers);
    foreach (var customer in viewCustomers)
    {
        Console.WriteLine("{0} - {1}", customer.CustomerName, customer.Bio);
    }
}
در اینجا از متدهای Project To و همچنین Decompile استفاده شده‌است (جهت پردازش خاصیت محاسباتی).


نمایش اطلاعات سفارش‌های مشتری‌ها

در ادامه قصد داریم اطلاعات سفارش‌ها را با فرمت ViewModel ذیل نمایش دهیم:
public class OrderViewModel
{
    public string CustomerName { get; set; }
    public decimal Total { get; set; }
    public string OrderNumber { get; set; }
    public IEnumerable<OrderItemsViewModel> OrderItems { get; set; }
}

public class OrderItemsViewModel
{
    public string Name { get; set; }
    public int Quantity { get; set; }
    public decimal Price { get; set; }
}
با این شرایط که
- CustomerName از خاصیت محاسباتی FullName کلاس مشتری تامین گردد.
- خاصیت OrderNumber آن از خاصیت OrderNo تهیه گردد.

به همین جهت کار را با تهیه‌ی نگاشت ذیل ادامه می‌دهیم:
this.CreateMap<Order, OrderViewModel>()
  .ForMember(dest => dest.OrderNumber, opt => opt.MapFrom(src => src.OrderNo))
  .ForMember(dest => dest.CustomerName, opt => opt.MapFrom(src => src.Customer.FullName));
بر این اساس کوئری مورد استفاده نیز به نحو ذیل تشکیل می‌شود:
using (var context = new MyContext())
{
    var viewOrders = context.Orders
        .Project()
        .To<OrderViewModel>()
        .Decompile()
        .ToList();
    // don't use
    // var viewOrders = Mapper.Map<IEnumerable<Order>, IEnumerable<OrderViewModel>>(dbOrders);
    foreach (var order in viewOrders)
    {
        Console.WriteLine("{0} - {1} - {2}", order.OrderNumber, order.CustomerName, order.Total);
    }
}
در اینجا چون از خاصیت OrderItems کلاس ViewModel صرفنظر نشده‌است، اطلاعات آن نیز به همراه viewOrders موجود است. یعنی می‌توان چنین کوئری را نیز جهت نمایش اطلاعات تو در توی اقلام هر سفارش نیز نوشت:
using (var context = new MyContext())
{
    var viewOrders = context.Orders
        .Project()
        .To<OrderViewModel>()
        .Decompile()
        .ToList();
    // don't use
    // var viewOrders = Mapper.Map<IEnumerable<Order>, IEnumerable<OrderViewModel>>(dbOrders);
    foreach (var order in viewOrders)
    {
        Console.WriteLine("{0} - {1} - {2}", order.OrderNumber, order.CustomerName, order.Total);
        foreach (var item in order.OrderItems)
        {
            Console.WriteLine("({0}) {1} - {2}", item.Quantity, item.Name, item.Price);
        }
    }
}
اگر می‌خواهید OrderItems به صورت خودکار واکشی نشود، نیاز است در نگاشت تهیه شده، توسط متد Ignore از آن صرفنظر کنید:
this.CreateMap<Order, OrderViewModel>()
  .ForMember(dest => dest.OrderNumber, opt => opt.MapFrom(src => src.OrderNo))
  .ForMember(dest => dest.OrderItems, opt => opt.Ignore())
  .ForMember(dest => dest.CustomerName, opt => opt.MapFrom(src => src.Customer.FullName));


نمایش اطلاعات یک سفارش، با فرمتی خاص

تا اینجا نگاشت‌های انجام شده بر روی لیستی از اشیاء صورت گرفتند. در ادامه می‌خواهیم اولین سفارش ثبت شده را با فرمت ذیل نمایش دهیم:
public class OrderDateViewModel
{
    public int PurchaseHour { get; set; }
    public int PurchaseMinute { get; set; }
    public string CustomerName { get; set; }
}
به همین منظور ابتدا نگاشت ذیل را تهیه می‌کنیم:
this.CreateMap<Order, OrderDateViewModel>()
  .ForMember(dest => dest.PurchaseHour, opt => opt.MapFrom(src => src.PurchaseDate.Hour))
  .ForMember(dest => dest.PurchaseMinute, opt => opt.MapFrom(src => src.PurchaseDate.Minute))
  .ForMember(dest => dest.CustomerName, opt => opt.MapFrom(src => src.Customer.FullName));
در اینجا ساعت و دقیقه‌ی خرید، از خاصیت PurchaseDate استخراج شده‌اند. همچنین CustomerName نیز از خاصیت FullName کلاس مشتری دریافت گردیده‌است.
پس از این تنظیمات، کوئری نهایی به شکل ذیل خواهد بود:
using (var context = new MyContext())
{
    var viewOrder = context.Orders
        .Project()
        .To<OrderDateViewModel>()
        .Decompile()
        .FirstOrDefault();
    // don't use
    // var viewOrder = Mapper.Map<Order, OrderDateViewModel>(dbOrder);
 
    if (viewOrder != null)
    {
        Console.WriteLine("{0}, {1}:{2}", viewOrder.CustomerName, viewOrder.PurchaseHour, viewOrder.PurchaseMinute);
    }
}


فرمت کردن سفارشی اطلاعات در حین نگاشت‌ها

در مورد فرمت کننده‌های سفارشی و تبدیلگرها پیشتر بحث کرده‌ایم. اما اغلب آن‌ها را در حالت خاص LINQ to Entities نمی‌توان بکار برد، زیرا قابلیت تبدیل به SQL را ندارند. برای مثال فرض کنید می‌خواهیم خاصیت ShipToHomeAddress کلاس Order را به خاصیت ShipHome کلاس ذیل نگاشت کنیم:
public class OrderShipViewModel
{
    public string ShipHome { get; set; }
    public string CustomerName { get; set; }
}
با این شرط که اگر مقدار آن True بود، Yes را نمایش دهد. با توجه به ساختار مدنظر، نگاشت ذیل را می‌توان تهیه کرد که در آن فرمت کردن سفارشی، به متد MapFrom واگذار شده‌است:
this.CreateMap<Order, OrderShipViewModel>()
   .ForMember(dest => dest.ShipHome, opt => opt.MapFrom(src=>src.ShipToHomeAddress? "Yes": "No"))
   .ForMember(dest => dest.CustomerName, opt => opt.MapFrom(src => src.Customer.FullName));
با این کوئری جهت استفاده‌ی از این تنظیمات:
using (var context = new MyContext())
{
    var viewOrders = context.Orders
        .Project()
        .To<OrderShipViewModel>()
        .Decompile()
        .ToList();
    // don't use
    // var viewOrders = Mapper.Map<IEnumerable<Order>, IEnumerable<OrderShipViewModel>>(dbOrders);
    foreach (var order in viewOrders)
    {
        Console.WriteLine("{0} - {1}", order.CustomerName, order.ShipHome);
    }
}

کدهای کامل این مطلب را از اینجا می‌توانید دریافت کنید.
نظرات مطالب
Asp.Net Identity #3
سلام. کلاس AppIdentityDbContext تعریف شده در Asp.net Identity #2  هیچ تفاوتی با dbContext تعریف شده توسط EF ندارد. شما میتونید DbSet‌های خودتون رو توی کلاس AppIdentityDbContext تعریف کنید.
ublic class AppIdentityDbContext : IdentityDbContext<AppUser> 
{
   public AppIdentityDbContext() 
              : base("IdentityDb") { }

    // DbSet Definition
   public DbSet<Product> Products { get; set; }

    static AppIdentityDbContext() 
    {
        Database.SetInitializer<AppIdentityDbContext>(new IdentityDbInit());
    }
    public static AppIdentityDbContext Create() 
    {
        return new AppIdentityDbContext();
    }
 }
البته همونطور که دوستمون محسن خان فرمودن بایستی با مباحث Ef Code First آشنایی داشته باشید که در اینجا یک دوره کامل تدارک دیده شده.
مطالب
C# 7 - More Expression-Bodied Members
یکی از امکانات جالب سی‌شارپ که در نسخه 6 معرفی شد، قابلیت Expression-Bodied Members بود. در نسخه 7 سی‌شارپ، امکانات جدیدتری اضافه شده است؛ به عنوان مثال اکنون می‌توان برای constructors, finalizers و همچنین get and set برای پراپرتی‌ها و ایندکسرها نیز از این قابلیت استفاده کرد.

 
استفاده از expression body برای constructors 
public class Person
{
    public string FirstName { get; set; }
    public Person(string firstName)
    {
        this.FirstName = firstName;
    }
}
به عنوان مثال اکنون سازنده‌ی کلاس فوق را می‌توانیم از روش block body متداول، به روش expression body، به صورت خلاصه‌تری بنویسیم:
public class Person
{
     public string FirstName { get; set; }
     public Person(string firstName) => this.FirstName = firstName;
}
البته محدودیت این روش این است که تنها برای یک پارامتر می‌توانیم به اینصورت عمل کنیم؛ اما در نسخه‌ 7.1  قرار است قابلیت استفاده از expression body برای بیشتر از یک پارامتر نیز اضافه شود:
public class Person
{
    public string Name { get; }
    public int Age { get; }

    public Person(string name, int age) => (Name, Age) = (name, age);
}

اما اگر نیاز داشتید برای بیشتر از دو متغیر از expression body استفاده کنید می‌توانید از Tuple برای شبیه‌سازی آن استفاده کنید(+):
public class Person
{
    private readonly (string name, int age) _tuple;    

    public string Name => _tuple.name;
    public int Age => _tuple.age;

    public Person(string name, int age) => _tuple = (name, age);
}

استفاده از expression body برای destructors 
public class Resource
{
    ~Resource() => Console.WriteLine("destructor");
}


 استفاده از expression body در get / set accessors 
 در سی‌شارپ 7 برای accessors نیز می‌توانیم از سینتکس جدید expression body استفاده کنیم. به عنوان مثال کد زیر را در نظر بگیرید:
private int _x;
public int X 
{
    get
    {
        return _x;
    }
    set
    {
        _x = value;
    }
}
کد فوق را می‌توانیم در سی‌شارپ 7 به صورت خلاصه‌تری بنویسیم:
private int _x;
public int X 
{
    get => _x;
    set => _x = value;
}

در ویژوال‌استودیوی 2017 نیز با قرار دادن ماوس بر روی پراپرتی x_، استفاده‌ی از سینتکس expression body به شما پیشنهاد داده خواهد شد:


همچنین برای Event Accessors نیز می‌توانیم از این قابلیت استفاده کنیم:

private EventHandler _someEvent;
public event EventHandler SomeEvent
{
    add => _someEvent += value;
    remove => _someEvent -= value;
}


مطالب دوره‌ها
پیاده سازی امتیاز دهی ستاره‌ای به مطالب به کمک jQuery در ASP.NET MVC
در این قسمت قصد داریم با نحوه پیاده سازی امتیاز دهی ستاره‌ای به مطالب، که نمونه‌ای از آن‌را در سایت جاری در قسمت‌های مختلف آن مشاهده می‌کنید، آشنا شویم.


مدل برنامه

در ابتدای کار نیاز است تا ساختاری را جهت ارائه لیستی از مطالب که دارای گزینه امتیاز دهی می‌باشند، تهیه کنیم:
namespace jQueryMvcSample03.Models
{
    public class BlogPost
    {
        public int Id { set; get; }
        public string Title { set; get; }
        public string Body { set; get; }

        /// <summary>
        /// اطلاعات رای گیری یک مطلب به صورت یک خاصیت تو در تو یا پیچیده
        /// </summary>
        public Rating Rating { set; get; }

        public BlogPost()
        {
            Rating = new Rating();
        }
    }
}

namespace jQueryMvcSample03.Models
{
    //[ComplexType]
    public class Rating
    {
        public double? TotalRating { get; set; }
        public int? TotalRaters { get; set; }
        public double? AverageRating { get; set; }
    }
}
اگر با EF Code first آشنا باشید، خاصیت Rating تعریف شده در اینجا می‌تواند از نوع ComplexType تعریف شود که شامل جمع امتیازهای داده شده، تعداد کل رای دهنده‌ها و همچنین میانگین امتیازهای حاصل است.


منبع داده فرضی برنامه

using System.Collections.Generic;
using System.Linq;
using jQueryMvcSample03.Models;

namespace jQueryMvcSample03.DataSource
{
    /// <summary>
    /// منبع داده فرضی
    /// </summary>
    public static class BlogPostDataSource
    {
        private static IList<BlogPost> _cachedItems;
        /// <summary>
        /// با توجه به استاتیک بودن سازنده کلاس، تهیه کش، پیش از سایر فراخوانی‌ها صورت خواهد گرفت
        /// باید دقت داشت که این فقط یک مثال است و چنین کشی به معنای
        /// تهیه یک لیست برای تمام کاربران سایت است
        /// </summary>
        static BlogPostDataSource()
        {
            _cachedItems = createBlogPostsInMemoryDataSource();
        }

        /// <summary>
        /// هدف صرفا تهیه یک منبع داده آزمایشی ساده تشکیل شده در حافظه است
        /// </summary>        
        private static IList<BlogPost> createBlogPostsInMemoryDataSource()
        {
            var results = new List<BlogPost>();
            for (int i = 1; i < 30; i++)
            {
                results.Add(new BlogPost { Id = i, Title = "عنوان " + i, Body = "متن ... متن ... متن " + i, Rating = new Rating { TotalRaters = i + 1, AverageRating = 3.5 } });
            }
            return results;
        }

        /// <summary>
        /// پارامترهای شماره صفحه و تعداد رکورد به ازای یک صفحه برای صفحه بندی نیاز هستند
        /// شماره صفحه از یک شروع می‌شود
        /// </summary>
        public static IList<BlogPost> GetLatestBlogPosts(int pageNumber, int recordsPerPage = 4)
        {
            var skipRecords = pageNumber * recordsPerPage;
            return _cachedItems
                        .OrderByDescending(x => x.Id)
                        .Skip(skipRecords)
                        .Take(recordsPerPage)
                        .ToList();
        }
    }
}
در این مثال نیز از یک منبع داده فرضی تشکیل شده در حافظه استفاده خواهیم کرد تا امکان اجرای پروژه پیوستی را بدون نیاز به بانک اطلاعاتی خاصی و بدون نیاز به مقدمات برپایی آن، به سادگی داشته باشید.
در این منبع داده ابتدا لیستی از مطالب تهیه شده و سپس کش می‌شوند. در ادامه توسط متد GetLatestBlogPosts بازه‌ای از این اطلاعات قابل بازیابی خواهند بود که برای استفاده در حالات صفحه بندی اطلاعات بهینه سازی شده است.


آشنایی با طراحی افزونه jQuery Star Rating

افزودن CSS نمایش امتیازها در ذیل هر مطلب

/* star rating system */
.post_rating
{
direction: ltr;
}
.rating
{
text-indent: -99999px;
overflow: hidden;
background-repeat: no-repeat;
display: inline-block;
width: 8px;
height: 16px;
}
.rating.stars
{
background-image: url('Images/star_rating.png');
}
.rating.stars.active
{
cursor: pointer;
}
.star-left_off
{
background-position: -0px -0px;
}
.star-left_on
{
background-position: -16px -0px;
}
.star-right_off
{
background-position: -8px -0px;
}
.star-right_on
{
background-position: -24px -0px;
}
برای نمایش ستاره‌ها و کار با تصویر Images/star_rating.png (که در پروژه پیوست قرار دارد) ابتدا نیاز است CSS فوق را به پروژه خود اضافه نمائید.

افزودن افزونه jQuery Star rating

// <![CDATA[
(function ($) {
    $.fn.StarRating = function (options) {
        var defaults = {            
            ratingStarsSpan: '.rating.stars',
            postInfoUrl: '/',
            loginUrl: '/login',
            errorHandler: null,
            completeHandler: null,
            onlyOneTimeHandler: null
        };
        var options = $.extend(defaults, options);

        return this.each(function () {
            var ratingStars = $(this);

            $(ratingStars).unbind('mouseover');
            $(ratingStars).mouseover(function () {
                var span = $(this).parent("span");
                var newRating = $(this).attr("value");
                setRating(span, newRating);
            });

            $(ratingStars).unbind('mouseout');
            $(ratingStars).mouseout(function () {
                var span = $(this).parent("span");
                var rating = span.attr("rating");
                setRating(span, rating);
            });

            $(ratingStars).unbind('click');
            $(ratingStars).click(function () {
                var span = $(this).parent("span");
                var newRating = $(this).attr("value");
                var text = span.children("span");
                var pID = span.attr("post");
                var type = span.attr("sectiontype");
                postData({ postID: pID, rating: newRating, sectionType: type });
                span.attr("rating", newRating);
                setRating(span, newRating);
            });

            function setRating(span, rating) {
                span.find(options.ratingStarsSpan).each(function () {
                    var value = parseFloat($(this).attr("value"));
                    var imgSrc = $(this).attr("class");
                    if (value <= rating)
                        $(this).attr("class", imgSrc.replace("_off", "_on"));
                    else
                        $(this).attr("class", imgSrc.replace("_on", "_off"));
                });
            }

            function postData(dataJsonArray) {
                $.ajax({
                    type: "POST",
                    url: options.postInfoUrl,
                    data: JSON.stringify(dataJsonArray),
                    contentType: "application/json; charset=utf-8",
                    dataType: "json",
                    complete: function (xhr, status) {
                        var data = xhr.responseText;
                        if (xhr.status == 403) {
                            window.location = options.loginUrl;
                        }
                        else if (status === 'error' || !data) {
                            if (options.errorHandler)
                                options.errorHandler(this);
                        }
                        else if (data == "nok") {
                            if (options.onlyOneTimeHandler)
                                options.onlyOneTimeHandler(this);
                        }
                        else {
                            if (options.completeHandler)
                                options.completeHandler(this);
                        }
                    }
                });
            }
        });
    };
})(jQuery);
// ]]>
اطلاعات فوق، فایل jquery.StarRating.js را تشکیل می‌دهند که باید به پروژه اضافه گردند.
کاری که این افزونه انجام می‌دهد ردیابی حرکت ماوس بر روی ستاره‌های نمایش داده شده و سپس ارسال سه پارامتر ذیل به اکشن متدی که توسط پارامتر postInfoUrl مشخص می‌گردد، پس از کلیک کاربر می‌باشد:
 { postID: pID, rating: newRating, sectionType: type }
همانطور که ملاحظه می‌کنید به ازای هر قطعه رای گیری که به صفحه اضافه می‌شود، Id مطلب، رای داده شده و نام قسمت جاری، به اکشن متدی خاص ارسال خواهند گردید. sectionType از این جهت اضافه گردیده است تا بتوانید با بیش از یک جدول کار کنید و از این افزونه در قسمت‌های مختلف سایت به سادگی بتوانید استفاده نمائید.
در اینجا از errorHandler برای نمایش خطاها، از completeHandler برای نمایش تشکر به کاربر و از onlyOneTimeHandler برای نمایش اخطار مثلا «یکبار بیشتر مجاز نیستید به ازای یک مطلب رای دهید»، می‌توان استفاده کرد.

بنابراین تا اینجا فایل layout برنامه تقریبا چنین مداخلی را خواهد داشت:
<head>
    <title>@ViewBag.Title</title>    
    <link href="@Url.Content("Content/starRating.css")" rel="stylesheet" type="text/css" />
    <link href="@Url.Content("Content/Site.css")" rel="stylesheet" type="text/css" />
    <script src="@Url.Content("~/Scripts/jquery-1.9.1.min.js")" type="text/javascript"></script>
    <script src="@Url.Content("~/Scripts/jquery.validate.min.js")" type="text/javascript"></script>
    <script src="@Url.Content("~/Scripts/jquery.unobtrusive-ajax.min.js")" type="text/javascript"></script>
    <script src="@Url.Content("~/Scripts/jquery.validate.unobtrusive.min.js")" type="text/javascript"></script>
    <script src="@Url.Content("~/Scripts/jquery.StarRating.js")" type="text/javascript"></script>
    @RenderSection("JavaScript", required: false)
</head>

طراحی یک HTML helper برای نمایش ستاره‌های امتیاز دهی

ابتدا پوشه استاندارد app_code را به پروژه اضافه کرده و سپس فایلی را به نام StarRatingHelper.cshtml، با محتوای ذیل به آن اضافه نمائید:
@using System.Globalization
@helper AddStarRating(int postId,
                      double? average = 0, int? postRatingsCount = 0, string type = "BlogPost",
                      string tooltip = "لطفا جهت رای دادن کلیک نمائید")
    {
        string actIt = "active ";
        if (!average.HasValue) { average = 0; }
        if (!postRatingsCount.HasValue) { postRatingsCount = 0; }
    
    <span class='postRating' rating='@average' post='@postId' title='@tooltip' sectiontype='@type'>
        @for (double i = .5; i <= 5.0; i = i + .5)
        {
            string left;
            if (i <= average)
            {
                left = (i * 2) % 2 == 1 ? "left_on" : "right_on";
            }
            else
            {
                left = (i * 2) % 2 == 1 ? "left_off" : "right_off";
            }
            <span class='rating stars @(actIt)star-@left' value='@i'></span>
        }
        &nbsp;
        @if (postRatingsCount > 0)
        {
            var ratingInfo = string.Format(CultureInfo.InvariantCulture, "امتیاز {0:0.00} از 5 توسط {1} نفر", average, postRatingsCount);
            <span>@ratingInfo</span>                
        }
        else
        {
            <span></span>
        }
    </span>
}
از این Html helper برای تشکیل ساختار نمایش قطعه امتیاز دهی به یک مطلب استفاده خواهیم کرد که توسط افزونه جی‌کوئری فوق ردیابی می‌شود.


کنترلر ذخیره سازی اطلاعات دریافتی برنامه

using System.Web.Mvc;
using System.Web.UI;
using jQueryMvcSample03.DataSource;
using jQueryMvcSample03.Security;

namespace jQueryMvcSample03.Controllers
{
    public class HomeController : Controller
    {
        public ActionResult Index()
        {
            var postsList = BlogPostDataSource.GetLatestBlogPosts(pageNumber: 0);
            return View(postsList); //نمایش صفحه اصلی
        }


        [HttpPost]
        [AjaxOnly]
        [OutputCache(Location = OutputCacheLocation.None, NoStore = true)]
        public ActionResult SaveRatings(int? postId, double? rating, string sectionType)
        {
            if (postId == null || rating == null || string.IsNullOrWhiteSpace(sectionType))
                return Content(null); //اعلام بروز خطا

            if (!this.HttpContext.CanUserVoteBasedOnCookies(postId.Value, sectionType))
                return Content("nok"); //اعلام فقط یکبار مجاز هستید رای دهید

            switch (sectionType) //قسمت‌های مختلف سایت که در جداول مختلفی قرار دارند نیز می‌توانند گزینه امتیاز دهی داشته باشند
            {
                case "BlogPost":
                    //الان شماره مطلب و رای ارسالی را داریم که می‌توان نسبت به ذخیره آن اقدام کرد
                    //مثلا
                    //_blogPostsService.SaveRating(postId.Value, rating.Value);
                    break;

                //... سایر قسمت‌های دیگر سایت

                default:
                    return Content(null); //اعلام بروز خطا
            }

            return Content("ok"); //اعلام موفقیت آمیز بودن ثبت اطلاعات
        }

        [HttpGet]
        public ActionResult Post(int? id)
        {
            if (id == null)
                return Redirect("/");

            //todo: show the content here
            return Content("Post " + id.Value);
        }
    }
}
در اینجا کنترلری را که کار پردازش کلیک کاربر را بر روی امتیازی خاص انجام می‌دهد، ملاحظه می‌کنید.
امضای اکشن متد SaveRatings دقیقا بر اساس سه پارامتر ارسالی توسط jquery.StarRating.js که پیشتر توضیح داده شد، تعیین گردیده است. در این متد ابتدا بررسی می‌شود که آیا اطلاعاتی دریافت شده است یا خیر. اگر خیر، null را بازگشت خواهد داد. سپس توسط متد CanUserVoteBasedOnCookies بررسی می‌شود که آیا کاربر می‌تواند (خصوصا مجددا) رای دهد یا خیر. این افزونه برای رای دهی کاربران وارد نشده به سیستم نیز مناسب است. به همین جهت از کوکی‌ها برای ثبت اطلاعات رای دادن کاربران استفاده گردیده است. پیاده سازی متد CanUserVoteBasedOnCookies را در ادامه ملاحظه خواهید نمود.
در ادامه در متد SaveRatings، یک switch تشکیل شده است تا بر اساس نام قسمت مرتبط به رای گیری، اطلاعات را بتوان به سرویس خاصی در برنامه هدایت کرد. مثلا اطلاعات قسمت مطالب به سرویس مطالب و قسمت نظرات به سرویس نظرات هدایت شوند.


متدهایی برای کار با کوکی‌ها در ASP.NET MVC

using System;
using System.Web;

namespace jQueryMvcSample03.Security
{
    public static class CookieHelper
    {
        public static bool CanUserVoteBasedOnCookies(this HttpContextBase httpContext, int postId, string sectionType)
        {
            string key = sectionType + "-" + postId;
            var value = httpContext.GetCookieValue(key);
            if (string.IsNullOrWhiteSpace(value))
            {
                httpContext.AddCookie(key, key);
                return true;
            }
            return false;
        }

        public static void AddCookie(this HttpContextBase httpContextBase, string cookieName, string value)
        {
            httpContextBase.AddCookie(cookieName, value, DateTime.Now.AddDays(30));
        }

        public static void AddCookie(this HttpContextBase httpContextBase, string cookieName, string value, DateTime expires)
        {
            var cookie = new HttpCookie(cookieName)
            {
                Expires = expires,
                Value = httpContextBase.Server.UrlEncode(value) // For Cookies and Unicode characters
            };
            httpContextBase.Response.Cookies.Add(cookie);
        }

        public static string GetCookieValue(this HttpContextBase httpContext, string cookieName)
        {
            var cookie = httpContext.Request.Cookies[cookieName];
            if (cookie == null)
                return string.Empty; //cookie doesn't exist

            // For Cookies and Unicode characters
            return httpContext.Server.UrlDecode(cookie.Value);
        }
    }
}
در اینجا یک سری متد الحاقی را ملاحظه می‌کنید که برای ثبت اطلاعات رای داده شده یک کاربر بر اساس Id مطلب و نام قسمت متناظر با آن در یک کوکی طراحی شده‌اند. بدیهی است اگر تمام قسمت‌های برنامه شما محافظت شده هستند و کاربران حتما نیاز است ابتدا به سیستم لاگین نمایند، می‌توانید این قسمت را حذف کرده و اطلاعات postId و SectionType را به ازای هر کاربر، جداگانه در بانک اطلاعاتی ثبت و بازیابی نمائید (دقیق‌ترین حالت ممکن؛ البته برای سیستمی بسته که حتما تمام قسمت‌های آن نیاز به اعتبار سنجی دارند).


پیشنهادی در مورد نحوه ذخیره سازی اطلاعات دریافتی

using jQueryMvcSample03.Models;

namespace jQueryMvcSample03.DataSource
{
    public interface IBlogPostsService
    {
        void SaveRating(int postId, double rating);
    }

    public class SampleService : IBlogPostsService
    {
        /// <summary>
        /// یک نمونه از متد ذخیره سازی اطلاعات پیشنهادی
        /// فقط برای ایده گرفتن
        /// بدیهی است محل قرارگیری اصلی آن در لایه سرویس برنامه شما خواهد بود
        /// </summary>
        public void SaveRating(int postId, double rating)
        {
            BlogPost post = null;
            //post = _blogCtx.Find(postId); // بر اساس شماره مطلب، مطلب یافت شده و فیلدهای آن تنظیم می‌شوند
            if (post == null) return;

            if (!post.Rating.TotalRaters.HasValue) post.Rating.TotalRaters = 0;
            if (!post.Rating.TotalRating.HasValue) post.Rating.TotalRating = 0;
            if (!post.Rating.AverageRating.HasValue) post.Rating.AverageRating = 0;

            post.Rating.TotalRaters++;
            post.Rating.TotalRating += rating;
            post.Rating.AverageRating = post.Rating.TotalRating / post.Rating.TotalRaters;

            // todo: call save changes at the end.
        }
    }
}
همانطور که عنوان شد، سه داده Id مطلب، رای داده شده و نام قسمت متناظر به اکشن متد ارسال می‌شود. از نام قسمت، برای انتخاب سرویس ذخیره سازی اطلاعات استفاده خواهیم کرد. این سرویس می‌تواند شامل متدی به نام SaveRating، همانند کدهای فوق باشد که Id مطلب و عدد رای حاصل به آن ارسال می‌گردند. ابتدا بر اساس این Id، مطلب متناظر یافت شده و سپس اطلاعات Rating آن به روز خواهد شد. در پایان هم ذخیره سازی اطلاعات باید صورت گیرد.



Viewهای برنامه

قسمت پایانی کار ما در اینجا تهیه دو View است:
الف) یک Partial view که لیست مطالب را به همراه گزینه رای دهی به آن‌ها رندر می‌کند.
ب) View کاملی که از این Partial View استفاده کرده و همچنین افزونه jquery.StarRating.js را فراخوانی می‌کند.
@using System.Text.RegularExpressions
@model IList<jQueryMvcSample03.Models.BlogPost>
<ul>
    @foreach (var item in Model)
    {
        <li>
            <fieldset>
            <legend>مطلب @item.Id</legend>
                <h5>
                    @Html.ActionLink(linkText: item.Title,
                                 actionName: "Post",
                                 controllerName: "Home",
                                 routeValues: new { id = item.Id },
                                 htmlAttributes: null)
                </h5>
                @item.Body
                <div class="post_rating">
                    @Html.Raw(Regex.Replace(@StarRatingHelper.AddStarRating(item.Id, item.Rating.AverageRating, item.Rating.TotalRaters, "BlogPost").ToHtmlString(), @">\s+<", "><"))
                </div>
            </fieldset>
        </li>
    }
</ul>
کدهای _ItemsList.cshtml را در اینجا ملاحظه می‌کند که در آن نحوه فراخوانی متد کمکی StarRatingHelper.AddStarRating ذکر شده است.
اگر به کدهای آن دقت کنید از Regex.Replace برای حذف فاصله‌های خالی و خطوط جدید بین تگ‌ها استفاده گردیده است. اگر اینکار انجام نشود، نیمه‌های ستاره‌های نمایش داده شده، با فاصله از یکدیگر رندر می‌شوند که صورت خوشایندی ندارد.

و نهایتا View ایی که از این اطلاعات استفاده می‌کنید ساختار زیر را خواهد داشت:
@model IList<jQueryMvcSample03.Models.BlogPost>
@{
    ViewBag.Title = "Index";
    var postInfoUrl = Url.Action(actionName: "SaveRatings", controllerName: "Home");
}
<h2>
    سیستم امتیاز دهی</h2>
@{ Html.RenderPartial("_ItemsList", Model); }
@section JavaScript
{
    <script type="text/javascript">
        $(document).ready(function () {
            $(".rating.stars.active").StarRating({
                ratingStarsSpan: '.rating.stars',
                postInfoUrl: '@postInfoUrl',
                loginUrl: '/login',
                errorHandler: function () {
                    alert('خطایی رخ داده است');
                },
                completeHandler: function () {
                    alert('با تشکر! رای شما با موفقیت ثبت شد');
                },
                onlyOneTimeHandler: function () {
                    alert('فقط یکبار می‌توانید به ازای هر مطلب رای دهید');
                }
            });
        });
    </script>
}
در این View لیستی از مطالب دریافت و به partial view طراحی شده برای نمایش ارسال می‌شود. سپس افزونه StarRating نیز تنظیم و به صفحه اضافه خواهد گردید. نکته مهم آن تعیین صحیح اکشن متدی است که قرار است اطلاعات را دریافت کند و نحوه مقدار دهی آن‌را توسط متغیر postInfoUrl مشاهده می‌کنید.

دریافت کدها و پروژه کامل این قسمت
jQueryMvcSample03.zip