# سری بررسی SQL Smell در EF Core - استفاده از مدل Entity Attribute Value - بخش اول

یکی از چالش‌های دیتابیس‌های رابطه‌ایی، ذخیره‌سازی داده‌هایی با ساختار داینامیک است. در حالت عادی، یک جدول مجموعه‌ایی از موجودیت‌ها است. هر موجودیت نیز شامل یکسری ویژگی‌های (Attributes) مشخص می‌باشد. ا

- Published: 2020-08-02
- Language: fa
- Tags: DNTips
- Canonical: https://sirwan.info/blog/fa/dntips-3233

---

> این نوشته نخستین بار در [دات‌نت تیپس](https://www.dntips.ir/post/3233) منتشر شده است.

<div class="postBody">یکی از چالش‌های دیتابیس‌های رابطه‌ایی، ذخیره‌سازی داده‌هایی با ساختار داینامیک است. در حالت عادی، یک جدول مجموعه‌ایی از موجودیت‌ها است. هر موجودیت نیز شامل یکسری ویژگی‌های (Attributes) مشخص می‌باشد. اما شرایطی را در نظر بگیرید که تعداد این ویژگی‌ها به صورت مشخص و ثابتی نباشد؛ یعنی برای هر موجودیت، ویژگی‌های متفاوتی داشته باشیم. یک روش پیاده‌سازی اینچنین سناریوهایی، استفاده از مدلی با نام Entity Attribute Value است. در این روش ستون‌های داینامیک را درون یک جدول جنریک تعریف خواهیم کرد. به عنوان مثال برای ذخیره‌سازی اطلاعات اشخاص، در حالت نرمال، یک جدول با ساختار مشخصی خواهیم داشت: <div> <div align="left" dir="ltr" style="direction: ltr;"> <div align="left" dir="ltr" style="direction:ltr;text-align:left;">
<pre language="Sql" name="code">create table Employees&#10;(&#10;   Id int auto_increment&#10;   primary key,&#10;   FirstName text null,&#10;   LastName text null,&#10;   DateOfBirth timestamp not null&#10;);</pre>
 </div> </div> </div> <div>تعریف جدول فوق نیز در Entity Framework به اینصورت خواهد بود:</div> <div> <div align="left" dir="ltr" style="direction: ltr;"> </div> <div align="left" dir="ltr" style="direction: ltr;">
<pre language="CSharp" name="code">public class Employee&#10;{&#10;    public int Id { get; set; }&#10;    public string FirstName { get; set; }&#10;    public string LastName { get; set; }&#10;    public DateTimeOffset DateOfBirth { get; set; }&#10;}&#10;&#10;public class MyDbContext : DbContext&#10;{&#10;    public DbSet&lt;Employee&gt; Employees { get; set; }&#10;&#10;    protected override void OnConfiguring(DbContextOptionsBuilder options)&#10;    {&#10;        options.UseMySQL(_configuration.GetConnectionString("DataConnection"));&#10;    }&#10;}</pre>
 </div> <br/> </div> <div> <p style="margin-left: auto; margin-right: auto;"> <img src="/img/dntips/482b86079d3fda41c528.png" style="display: block; margin-left: auto; margin-right: auto; cursor: default;"/> </p> <p style="margin-left: auto; margin-right: auto;"> <br/> </p> <p style="margin-left: auto; margin-right: auto;">اما در مدل EAV، خواص داینامیک را به درون جدول دومی منتقل خواهیم کرد: <br/> </p> <div align="left" dir="ltr" style="direction: ltr;"> <div align="left" dir="ltr" style="direction:ltr;text-align:left;">
<pre language="Sql" name="code">create table EmployeeEav&#10;(&#10;   Id int auto_increment&#10;   primary key&#10;);&#10;&#10;create table EmployeeAttributes&#10;(&#10;  Id int auto_increment&#10;  primary key,&#10;  EmployeeId int not null,&#10;  AttributeName text null,&#10;  AttributeValue text null,&#10;  constraint FK_EmployeeAttributes_EmployeeEav_EmployeeId&#10;  foreign key (EmployeeId) references EmployeeEav (Id)&#10;  on delete cascade&#10;);&#10;&#10;create index IX_EmployeeAttributes_EmployeeId&#10;on EmployeeAttributes (EmployeeId);</pre>
 </div> </div> <p style="margin-left: auto; margin-right: auto;">تعریف جداول فوق نیز در Entity Framework به اینصورت خواهند بود:</p> <div align="left" dir="ltr" style="direction: ltr;">
<pre language="CSharp" name="code">public class EmployeeEav&#10;{&#10;    public int Id { get; set; }&#10;    public virtual ICollection&lt;EmployeeAttribute&gt; Attributes { get; set; }&#10;}&#10;&#10;public class EmployeeAttribute&#10;{&#10;    public int Id { get; set; }&#10;    public virtual EmployeeEav Employee { get; set; }&#10;    public int EmployeeId { get; set; }&#10;    public string AttributeName { get; set; }&#10;    public string AttributeValue { get; set; }&#10;}&#10;&#10;public class MyDbContext : DbContext&#10;{&#10;&#10;    public DbSet&lt;EmployeeEav&gt; EmployeeEav { get; set; }&#10;    public DbSet&lt;EmployeeAttribute&gt; EmployeeAttributes { get; set; }&#10;&#10;    protected override void OnConfiguring(DbContextOptionsBuilder options)&#10;    {&#10;        options.UseMySQL(_configuration.GetConnectionString("DataConnection"));&#10;    }&#10;}</pre>
 </div> <p style="margin-left: auto; margin-right: auto;"> <br/> </p> <p style="margin-left: auto; margin-right: auto;"> <img src="/img/dntips/ca814302d8eba16a2d01.png" style="display: block; margin-left: auto; margin-right: auto; cursor: default;"/> </p> <p style="margin-left: auto; margin-right: auto;"> <br/> </p> <div> درون این جدول دوم، سه فیلد اصلی داریم: یکی به عنوان <b>Entity</b> که در اینجا یک ارجاع را به جدول EmployeeEav دارد. یک فیلد به عنوان <b>Attribute</b> که برای تعیین نام ویژگی داینامیک استفاده می‌شود و در نهایت یک <b>Value</b> که برای ذخیره‌سازی مقدار ویژگی مورد استفاده قرار میگیرد. بنابراین به این نوع طراحی، <b>Entity Attribute Value</b> گفته می‌شود. مزیت اصلی این روش، انعطاف زیاد آن است در واقع می‌توانیم N تعداد ویژگی را برای Entity موردنظرمان داشته باشیم. اما این روش یک SQL Smell است و اشکالات زیادی را به همراه دارد: </div> <div> <br/> </div> <div> <ul> <li> <b>کوئری گرفتن در این روش سخت است</b> </li> </ul> </div> <div>یکی از مشکلات اصلی این روش این است امکان کوئری گرفتن از جدول ویژگی‌ها را سخت میکند. در واقع این روش به store everything, query nothing معروف است. مثلاً فرض کنید می‌خواهیم لیست کارمندانی را که تاریخ تولدشان ۲۵ سال پیش است، واکشی کنیم. در حالت عادی با تعداد ستون ثابت می‌توانیم به راحتی اینکار را انجام دهیم: <br/> </div> <div align="left" dir="ltr" style="direction: ltr;">
<pre language="Sql" name="code">SELECT `e`.`Id`, `e`.`DateOfBirth`, `e`.`FirstName`, `e`.`LastName`&#10;FROM `Employees` AS `e`&#10;WHERE `e`.`DateOfBirth` &gt; @__endDate_0</pre>
 </div>

کوئری LINQ کد فوق اینچنین شکلی خواهد داشت: <br/> </div> <div> <div align="left" dir="ltr" style="direction: ltr;">
<pre language="CSharp" name="code">var endDate = DateTimeOffset.Now.AddYears(Convert.ToInt32(-25));&#10;var normalTypes = dbContext.Employees.Where(x =&gt; x.DateOfBirth &gt; endDate).ToList();</pre>
 </div>
اما در مدل EAV نوشتن کوئری فوق خیلی سخت‌تر خواهد بود:  <br/> <div> <div align="left" dir="ltr" style="direction: ltr;">
<pre language="Sql" name="code">SELECT MAX(CASE AttributeName&#10;               WHEN 'FirstName'&#10;                   THEN AttributeValue&#10;    END)        AS FirstName,&#10;       MAX(CASE AttributeName&#10;               WHEN 'LastName'&#10;                   THEN AttributeValue&#10;           END) AS LastName,&#10;       MAX(CASE AttributeName&#10;               WHEN 'DateOfBirth'&#10;                   THEN AttributeValue&#10;           END) AS DateOfBirth&#10;FROM efcoresample.EmployeeAttributes&#10;WHERE EmployeeId IN (SELECT EmployeeId&#10;                     FROM efcoresample.EmployeeAttributes&#10;                     WHERE AttributeName = 'DateOfBirth'&#10;                       AND AttributeValue &gt; DATE_SUB(CURRENT_DATE(), INTERVAL 25 YEAR))&#10;  AND AttributeName IN ('FirstName', 'LastName', 'DateOfBirth')&#10;GROUP BY EmployeeId;</pre>
 </div>
همچنین کوئری LINQ آن نیز به همان اندازه سخت میباشد:  <br/> </div> <div> <div align="left" dir="ltr" style="direction: ltr;">
<pre language="CSharp" name="code">string[] columnNames = {"FirstName", "LastName", "DateOfBirth"};&#10;var employees = dbContext.EmployeeAttributes&#10;    .Where(x =&gt; &#10;                dbContext.EmployeeAttributes&#10;                    .Where(i =&gt; i.AttributeName == "DateOfBirth")&#10;                    .Select(eId =&gt; eId.EmployeeId).Contains(x.EmployeeId) &amp;&amp;&#10;                columnNames.Contains(x.AttributeName))&#10;    .GroupBy(x =&gt; x.EmployeeId)&#10;    .Select(g =&gt; new&#10;    {&#10;        FirstName = g.Max(f =&gt; f.AttributeName == "FirstName" ? f.AttributeValue : ""),&#10;        LastName = g.Max(f =&gt; f.AttributeName == "LastName"? f.AttributeValue : ""),&#10;        DateOfBirth = g.Max(f =&gt; f.AttributeName == "DateOfBirth"? f.AttributeValue : ""),&#10;        Id = g.Key&#10;    })&#10;    .ToList()&#10;    .Where(x =&gt; DateTime.ParseExact(x.DateOfBirth, "yyyy-MM-dd", CultureInfo.InvariantCulture) &gt; DateTime.Now.AddYears(-25));</pre>
 </div> <br/> </div> <div> <div> <ul> <li> <b>امکان تعریف فیلدهای اجباری را نخواهیم داشت</b> </li> </ul> </div> <div>در حالت نرمال و ساختاریافته، برای هرکدام از فیلدها می‌توانیم الزامی و یا اختیاری بودن آنها را به راحتی با NOT NULL تعیین کنیم. اما در مدل EAV این امکان را نخواهیم داشت.  </div> <div> <br/> </div> <div> <div> <ul> <li> <b>امکان تعیین نوع ستون‌ها را نخواهیم داشت</b> </li> </ul> </div> <div>در حالت نرمال به راحتی می‌توانیم نوع فیلد موردنظر را تعیین کنیم. اما در مدل EAV به دلیل ماهیت داینامیک ستون‌ها، این امکان را نداریم. ستون AttributeValue همزمان ممکن است تاریخ، عددی، اعشاری و… باشد در نتیجه چون از ورودی مطمئن نیستیم، مجبوریم تایپ آن را به رشته تنظیم کنیم.  </div> <div> <br/> </div> <div> <div> <ul> <li> <b>امکان تعریف کلیدهای خارجی را نخواهیم داشت</b> </li> </ul> </div> <div>در مدل EAV نمی‌توانیم صحت دیتا را تضمین کنیم؛ زیرا امکان تعریف کلید خارجی را نخواهیم داشت.</div> <div> <br/> </div> <div>بنابراین بهتر است تا حد امکان از مدل EAV استفاده نشود؛ مگر اینکه در شرایطی خاص، مجبور به استفاده‌ی از آن باشید. به عنوان مثال برنامه‌ی شما قرار است قابلیت ایمپورت هر نوع فایل CSV را داشته باشد. هر فایل هم ممکن است به تعداد نامشخصی، یکسری ستون را داشته باشد. در این شرایط می‌توانید با در نظر گرفتن موارد فوق، از مدل مطرح شده استفاده کنید.<br/> </div> </div> </div> </div> </div></div>
