عنوان:

‫طراحی سیستماتیک خصیصه‌های تاریخچه‌دار (Historized Attributes) در EF Core


نویسنده: وحید نصیری
تاریخ: ۱۴۰۵/۰۴/۰۷ ۱۰:۲۰
آدرس: www.dntips.ir
یک چالش رایج در طراحی پایگاه‌داده‌های سازمانی، حفظ و ردیابی تاریخچه تغییرات مقادیر یک ویژگی (Attribute) خاص است. به عنوان مثال، ثبت تاریخچه قیمت محصولات با گذشت زمان، یا حفظ آدرس‌های قبلی کاربران در سیستم.
اگرچه برخی از پایگاه‌های داده مانند SQL Server قابلیتی بومی به نام Temporal Tables دارند، اما در بسیاری از پروژه‌ها (به ویژه هنگام استفاده از پایگاه‌های داده‌ای مثل PostgreSQL یا MySQL) نیاز به یک طراحی مستقل، بهینه و قابل پیاده‌سازی با الگوهای شیء‌گرا داریم. در این مقاله، نحوه طراحی سیستماتیک این ساختار را بررسی کرده و پیاده‌سازی آن را در Entity Framework Core مرور خواهیم کرد.

مدل‌سازی منطقی: از مفهوم تا موجودیت (Entity)
برای درک بهتر، بیایید سناریوی یک فروشگاه میوه را در نظر بگیریم. قیمت هر میوه ممکن است بر اساس شرایط بازار تغییر کند و ما می‌خواهیم علاوه بر قیمت فعلی، تاریخچه تغییرات قیمت را نیز داشته باشیم.
در گام اول، دو موجودیت اصلی یا اصطلاحاً Anchor تعریف می‌کنیم:
  • نوع میوه (Fruit): موجودیت اصلی که نام میوه را نگه می‌دارد.
  • قیمت تاریخچه‌دار (FruitPrice): خصیصه‌ای که ارزش مالی و بازه زمانی اعتبار آن قیمت را ثبت می‌کند.

ساختار مدل منطقی و ارتباطات
ارتباط بین میوه و قیمت‌های آن یک ارتباط یک‌به‌چند (1:N) است؛ یعنی یک میوه می‌تواند در طول زمان چندین قیمت داشته باشد، اما هر رکوردِ قیمت، دقیقاً متعلق به یک میوه است.
برای هر رکورد قیمت، به دو ستون زمانی نیاز داریم:
  • ValidFrom: زمان شروع اعتبار قیمت (UTC).
  • ValidTo: زمان پایان اعتبار قیمت (UTC).

پیاده‌سازی مدل فیزیکی در EF Core
برای تبدیل این مدل منطقی به کد، ابتدا کلاس‌های دامین (Domain Classes) را تعریف می‌کنیم. به جای استفاده از مقادیر NULL برای قیمت‌های فعلی (که کوئری‌ها را پیچیده می‌کند)، از یک مقدار نگهبان (Sentinel Value) مانند DateTime.MaxValue استفاده می‌کنیم. این کار نشان می‌دهد که قیمت مورد نظر تا آینده نامعلوم معتبر است.

تعریف موجودیت‌ها (Entities)
public class Fruit
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    
    // Navigation Property
    public List<FruitPrice> Prices { get; set; } = new();
}

public class FruitPrice
{
    public int Id { get; set; }
    public int FruitId { get; set; }
    public decimal Value { get; set; }
    public DateTime ValidFrom { get; set; }
    public DateTime ValidTo { get; set; } // استفاده از مقدار نگهبان به جای Null

    // Navigation Property
    public Fruit? Fruit { get; set; }
}

تنظیمات Fluent API و بهینه‌سازی ایندکس‌ها
برای بالا بردن کارایی کوئری‌ها در حجم داده بالا، تعریف یک ایندکس ترکیبی (Composite Index) روی کلید خارجی و زمان شروع اعتبار (FruitId, ValidFrom) حیاتی است. بدون این ایندکس، دیتابیس مجبور به اسکن کامل جدول (Full Table Scan) خواهد شد.
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;

public class FruitPriceConfiguration : IEntityTypeConfiguration<FruitPrice>
{
    public void Configure(EntityTypeBuilder<FruitPrice> builder)
    {
        builder.HasKey(fp => fp.Id);

        builder.Property(fp => fp.Value)
            .HasPrecision(18, 2);

        // تعریف ایندکس ترکیبی برای بهبود کارایی کوئری‌های زمانی
        builder.HasIndex(fp => new { fp.FruitId, fp.ValidFrom });

        // ارتباط یک به چند
        builder.HasOne(fp => fp.Fruit)
            .WithMany(f => f.Prices)
            .HasForeignKey(fp => fp.FruitId);
    }
}

کوئری گرفتن از داده‌های تاریخچه‌دار در LINQ
یکی از بزرگ‌ترین مزایای رویکرد دو زمانه (ValidFrom / ValidTo) همراه با مقدار نگهبان، سادگی و کارایی بالای کوئری‌های LINQ است.

۱. دریافت قیمت یک محصول در یک زمان مشخص در گذشته
فرض کنید می‌خواهید قیمت میوه‌ای با شناسه 3 را در تاریخ 2023-06-01 به دست آورید:
var targetDate = new DateTime(2023, 6, 1, 0, 0, 0, DateTimeKind.Utc);

var priceAtTime = await context.FruitPrices
    .Where(fp => fp.FruitId == 3 
              && fp.ValidFrom <= targetDate 
              && fp.ValidTo > targetDate)
    .FirstOrDefaultAsync();
نکته فنی مهم: به عملگرها دقت کنید. برای زمان شروع از >= و برای زمان پایان از < استفاده شده است. این فرمولاسیون تضمین می‌کند که بازه‌های زمانی با هم تداخل نداشته باشند.

۲. دریافت قیمت فعلی (Current Price)
به دلیل استفاده از مقدار نگهبان (DateTime.MaxValue) برای رکوردهای جاری، دریافت قیمت فعلی دقیقاً مشابه کوئری بالا با زمان DateTime.UtcNow است و نیازی به بررسی Is Null ندارد:
var now = DateTime.UtcNow;

var currentPrice = await context.FruitPrices
    .Where(fp => fp.FruitId == 3 
              && fp.ValidFrom <= now 
              && fp.ValidTo > now)
    .FirstOrDefaultAsync();

چرا دو زمان بهتر از یک زمان است؟
شاید این سوال پیش بیاید که چرا جدول را خلوت‌تر نکنیم و فقط از یک ستون ValidFrom استفاده نکنیم؟ در نگاه اول، با داشتن یک ستون هم می‌توان فهمید که قیمت از چه زمانی تغییر کرده است. اما بیایید پیاده‌سازی آن را در LINQ ببینیم:
// رویکرد تک زمانه (توصیه نمی‌شود)
var price = await context.FruitPrices
    .Where(fp => fp.FruitId == 3 && fp.ValidFrom <= targetDate)
    .OrderByDescending(fp => fp.ValidFrom)
    .FirstOrDefaultAsync();

معایب رویکرد تک زمانه (Single Timestamp):
  • پیچیدگی در سناریوهای نبود داده (Gaps): اگر در کل سال ۲۰۲۳ به دلیل کمبود بار، این میوه را نمی‌فروختیم و قیمتی برای آن وجود نداشت، در ساختار تک‌زمانه نمی‌توان این «نبودِ قیمت» را نشان داد؛ دیتابیس به اشتباه آخرین قیمت سال ۲۰۲۲ را برای سال ۲۰۲۳ هم معتبر می‌داند. اما در ساختار دو زمانه، کافیست برای آن بازه رکوردی ثبت نشود.
  • کارایی (Performance): در کوئری‌های پیچیده‌تر یا ساب‌کوئری‌ها، رویکرد تک‌زمانه دیتابیس را مجبور به اجرای توابع محاسباتی مانند MAX یا سورتهای سنگین می‌کند، در حالی که ساختار دو زمانه با یک فیلتر بازه‌ای ساده (Between منطقی) روی ایندکس ترکیبی، به سرعت پاسخ را می‌یابد.

نتیجه‌گیری و الگوی عمومی (Template)
طراحی سیستماتیک خصیصه‌های تاریخچه‌دار به کمک دو ستونِ زمان و مقدار نگهبان، یک الگوی قابل اعتماد، منعطف و با کارایی بالا در ابزار EF Core به شمار می‌رود. شما می‌توانید این الگو را برای هر ویژگی تغییرپذیر دیگری (مانند آدرس کاربر، عنوان شغلی، یا وضعیت‌های یک سفارش) تعمیم دهید.
به عنوان یک قاعده کلی، هر زمان نیاز به تاریخچه‌دار کردن یک ویژگی داشتید، کافیست کلاس زیر را به عنوان الگوی پایه در نظر بگیرید:
public class HistorizedAttribute<TValue>
{
    public int Id { get; set; }
    public int AnchorId { get; set; } // کلید خارجی به موجودیت اصلی
    public TValue Value { get; set; } = default!;
    public DateTime ValidFrom { get; set; }
    public DateTime ValidTo { get; set; } = DateTime.MaxValue; // مقدار نگهبان
}