‫۱ ماه قبل، دوشنبه ۸ مرداد ۱۴۰۳، ساعت ۱۶:۴۴

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

[Index(nameof(PhoneNumber), IsUnique = true)]
public class User
{
    public long Id { get; set; }

    [Required]
    [MaxLength(100)]
    public string FullName { get; set; } = null!;

    [Required]
    [MaxLength(11)]
    public string PhoneNumber { get; set; } = null!;

    public bool IsDeleted { get; set; }
}

در موجودیت User فیلد PhoneNumber منحصر به فرد است.

کاربری با شماره تلفن 123 ثبت نام میکند و بعد حذف میشود

الان شماره تلفن 123 آزاد است و کاربر دیگری میتواند با این شماره ثبت نام کند و این کاربر نیز حذف میشود و یک کاربر جدید با شماره تلفن 123 ثبت نام میکند

FullName    PhoneNumber    IsDeleted
User1       123            True
User2       123            True
User3       123            False

پس با وجود اینکه شماره تلفن Unique است ولی میتوان شماره تلفن تکراری داشت به شرطی که IsDeleted=True باشد و باید فقط یک شماره تلفن 123 با IsDeleted=False داشت.

برای اینکار از Filter ها استفاده میکنیم و تنها کافیست در فایل Configuration موجودیت User این کد را اضافه کرد

public class UserConfiguration : IEntityTypeConfiguration<User>
{
    public void Configure(EntityTypeBuilder<User> builder)
    {
        builder.HasIndex(x => x.PhoneNumber)
            .HasFilter("[IsDeleted] = 0");
    }
}

الان شرط Unique بودن فقط در صورتی کار میکند که IsDeleted=False باشد.

ارتقاء به دات نت 8
اگر این پروژه رو به دات نت 8 ارتقاء بدیم در کلاس TokenValidatorService دیگر نمیتوان با استفاده از accessToken.RawData توکن را خواند که آن را در پایگاه داده مورد بررسی قرار بدیم.
if (!(context.SecurityToken is JwtSecurityToken accessToken) ||
    string.IsNullOrWhiteSpace(accessToken.RawData) ||
    !await _tokenStoreService.IsValidTokenAsync(accessToken.RawData, userId))
{
    context.Fail("This token is not in our database.");
    return;
}
علت اینکار این است که در دات نت 8 کلاس JsonWebToken جایگزین JwtSecurityToken شده است ^ و همچنین برای گرفتن توکن باید از متد UnsafeToString استفاده کرد.
if (!(context.SecurityToken is JsonWebToken accessToken) ||
    string.IsNullOrWhiteSpace(accessToken.UnsafeToString()) ||
    !await tokenStoreService.IsValidTokenAsync(accessToken.UnsafeToString(), userId))
{
    context.Fail("This token is not in our database.");
    return;
}
علت این جایگزینی بهبود عملکرد تا 30 درصد و پردازش Async در کلاس JsonWebToken میباشد.
نحوه نمایش مقدار Display attribute پراپرتی‌ها در تگ‌های Label با استفاده از کامپوننت‌های جنریک

در ASP.NET Core اگه بخوایم Display یک پراپرتی رو نمایش بدیم به این صورت عمل میکنیم:
@model ProjectName.ViewModels.Identity.RegisterViewModel
<label asp-for="PhoneNumber"></label>
در حال حاضر چنین قابلیتی به صورت توکار در Blazor وجود ندارد، برای اینکه این قابلیت رو با استفاده از کامپوننت‌ها پیاده سازی کنیم میتوان از یک کامپوننت جنریک استفاده کرد (اطلاعات بیشتر در مورد کامپوننت‌های جنریک):
@using System.Reflection
@using System.Linq.Expressions
@using System.ComponentModel.DataAnnotations
@typeparam T
@if (ChildContent is null)
{
    <label>@Label</label>
}
else
{
    <label>
        @Label
        @ChildContent
    </label>
}
@code {

    [Parameter, EditorRequired]
    public Expression<Func<T>> DisplayNameFor { get; set; } = default!;

    [Parameter]
    public RenderFragment? ChildContent { get; set; }

    private string Label => GetDisplayName();

    private string GetDisplayName()
    {
        var expression = (MemberExpression)DisplayNameFor.Body;
        var value = expression.Member.GetCustomAttribute(typeof(DisplayAttribute)) as DisplayAttribute;
        return value?.Name ?? expression.Member.Name;
    }
}

نحوه استفاده:
private LoginDto Login { get; } = new();
<CustomDisplayName DisplayNameFor="@(() => Login.UserName)" />
همچنین میتوان ChildContent رو که یک RenderFragment است مقداری دهی کرد که در کنار Display، مقدار ChildContent رو هم نمایش بده.
<CustomDisplayName DisplayNameFor="@(() => Login.UserName)">
    <span class="text-danger">(*)</span>
</CustomDisplayName>

نحوه استفاده در پروژه‌های چند زبانه
کامپوننت فوق برای استفاده در پروژه چند زبانه باید به صورت زیر تغییر پیدا کنه:
@using System.Reflection
@using System.Linq.Expressions;
@using System.ComponentModel.DataAnnotations;
@typeparam T
@if (ChildContent == null)
{
    <label>@Label</label>
}
else
{
    <label>
        @Label
        @ChildContent
    </label>
}
@code {

    [Parameter]
    public Expression<Func<T>> DisplayNameFor { get; set; } = default!;

    [Parameter]
    public RenderFragment? ChildContent { get; set; }

    [Inject]
    public IStringLocalizerFactory LocalizerFactory { get; set; } = default!;

    private string Label => GetDisplayName();

    private string GetDisplayName()
    {
        var expression = (MemberExpression)DisplayNameFor.Body;
        var displayAttribute = expression.Member.GetCustomAttribute(typeof(DisplayAttribute)) as DisplayAttribute;
        if (displayAttribute is {ResourceType: not null })
        {
            // Try to dynamically create an instance of the specified resource type
            var resourceType = displayAttribute.ResourceType;
            var localizer = LocalizerFactory.Create(resourceType);

            return localizer[displayAttribute.Name ?? expression.Member.Name];
        }
        return displayAttribute?.Name ?? expression.Member.Name;
    }
}
برای اتریبیوت Display هم باید مقدار ResourceType مقدار دهی شود:
public class LoginDto
{
    [Display(Name = nameof(LoginDtoResource.UserName), ResourceType = typeof(LoginDtoResource))]
    [Required]
    [MaxLength(100)]
    public string UserName { get; set; } = default!;

    //...
}

LoginDtoResource یک فایل Resource است که باید با باز کردنش در ویژوال استودیو، Access modifier اونو به public تغییر بدید.
نحوه افزودن کلاس به Label ( ساده سازی تعاریف ویژگی‌های المان‌ها )
افزودن یک دیکشنری به کامپوننت:
[Parameter(CaptureUnmatchedValues = true)]
public Dictionary<string, object> InputAttributes { get; set; } = new();
استفاده از InputAttributes در تگ Label:
@if (ChildContent is null)
{
    <label @attributes="InputAttributes">
        @Label
    </label>
}
else
{
    <label @attributes="InputAttributes">
        @Label
        @ChildContent
    </label>
}
الان میتونیم هر تعداد اتریبیوتی رو به کامپوننت پاس بدیم:
<CustomDisplayName DisplayNameFor="@(() => Login.UserName)" class="form-label" id="test" for="UserName" />
ایجاد نام جداول به صورت جمع (Pluralized) و داینامیک :
اگه از روش خودکار کردن تعاریف DbSet ها استفاده کرده باشیم چون دیگر در داخل کلاس Context برنامه، Dbset تعریف نمی‌شود معمولا برای نام گذاری جداول که به صورت جمع باشند از اتریبیوت Table استفاده می‌کنیم.
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

[Table("Users")]
public class User : BaseEntity
{
    public long Id { get; set; }

    [Required]
    public string FullName { get; set; } = null!;
}
برای اینکه نام جداول به صورت خودکار به صورت جمع ایجاد شوند میتوان از این متد استفاده کرد:
using Microsoft.EntityFrameworkCore;
using System.ComponentModel.DataAnnotations.Schema;
using System.Reflection;

namespace ProjectName.Common.EfHelpers;

public static class EfToolkit
{
    /// <summary>
    /// ایجاد نام موجودیت‌ها
    /// نام موجودیت اگر اتریبیوت تیبل داشته باشد
    /// از همان نام استفاده میشود و اگر نداشته باشد
    /// نامش جمع بسته میشود
    /// </summary>
    /// <param name="builder"></param>
    public static void MakeTableNamesPluralized(this ModelBuilder builder)
    {
        var entityTypes = builder.Model.GetEntityTypes();

        foreach (var entityType in entityTypes)
        {
            // Get the CLR type of the entity
            var entityClrType = entityType.ClrType;
            var hasTableAttribute = entityClrType.GetCustomAttribute<TableAttribute>();

            // Apply the pluralized table name for the entity
            if (hasTableAttribute is null)
            {
                // Get the pluralized table name
                var pluralizedTableName = GetPluralizedTableName(entityClrType);
                builder.Entity(entityClrType).ToTable(pluralizedTableName);
            }
        }
    }

    /// <summary>
    /// گرفتن نام تایپ و عوض کردن نام آن از مفرد به جمع
    /// Singular to plural
    /// Category => Categories
    /// Box => Boxes
    /// Bus => Buses
    /// Computer => Computers
    /// </summary>
    /// <param name="entityClrType"></param>
    /// <returns></returns>
    private static string GetPluralizedTableName(Type entityClrType)
    {
        // Example implementation (Note: This is a simple pluralization logic and might not cover all cases)
        var typeName = entityClrType.Name;
        if (typeName.EndsWith("y"))
        {
            // Substring(0, typeName.Length - 1)
            // Range indexer
            var typeNameWithoutY = typeName[..^1];
            return typeNameWithoutY + "ies";
        }

        if (typeName.EndsWith("s") || typeName.EndsWith("x"))
        {
            return typeName + "es";
        }
        return typeName + "s";
    }
}
نحوه فراخوانی در متد OnModelCreating:
protected override void OnModelCreating(ModelBuilder builder)
{
    // it should be placed here, otherwise it will rewrite the following settings!
    base.OnModelCreating(builder);
    builder.RegisterAllEntities(typeof(BaseEntity));
    builder.MakeTableNamesPluralized();
    builder.ApplyConfigurationsFromAssembly(typeof(ApplicationDbContext).Assembly);
}
هر موجودیتی که اتریبیوت Table داشته باشد از همان نام برای جدول استفاده می‌شود در غیر اینصورت نام انتیتی به صورت جمع ایجاد می‌گردد.
قبل از اینکه نام جداول نیز جمع بسته شود تمامی موجودیت‌ها به صورت خودکار اضافه می‌شوند.
public static class EfToolkit
{
    /// <summary>
    /// ثبت تمامی انتیتی‌ها
    /// <param name="builder"></param>
    /// <param name="type"></param>
    /// </summary>
    public static void RegisterAllEntities(this ModelBuilder builder, Type type)
    {
        var entities = type.Assembly.GetTypes()
            .Where(x => x.BaseType == type);
        foreach (var entity in entities)
            builder.Entity(entity);
    }
}  
هر کلاسی در لایه موجودیت‌ها از BaseEntity ارث بری کرده باشد به عنوان یک Entity شناخته می‌شود.
‫۲ سال و ۷ ماه قبل، چهارشنبه ۶ بهمن ۱۴۰۰، ساعت ۲۳:۴۷
اگر در داخل موجودیتون، همیشه به یکی از پراپرتی‌های موجود در Shadow properties نیاز دارید برای مثال به CreatedDateTime، همون پراپرتی (ها) رو در داخل موجودیت تعریف کنید و به راحتی در هر کوئری به اون دسترسی پیدا کنید.
public class Category : IAuditableEntity
{
    public int Id { get; set; }

    public Category()
    {
        Products = new HashSet<Product>();
    }
        
    public DateTime? CreatedDateTime { get; set; } //Here

    public string Name { get; set; }

    public string Title { get; set; }

    public virtual ICollection<Product> Products { get; set; }
}
اما اگر فقط یکبار به اون نیاز دارید، از متود های GetShadowPropertyValue و نوع جنریک اون استفاده کنید. 
‫۳ سال قبل، پنجشنبه ۱۱ شهریور ۱۴۰۰، ساعت ۰۴:۰۶
نکته تکمیلی
پر کردن مقدار SelectListItem سمت سرور با متود سفارشی :
public static class CommonExtensionMethods
{
    public static List<SelectListItem> CreateSelectListItem<T>(
        this List<T> items,
        object selectedItem = null,
        bool addChooseOneItem = true,
        string firstItemText = "انتخاب کنید",
        string firstItemValue = 0
    )
    {
        var modelType = items.First().GetType();

        var idProperty = modelType.GetProperty("Id");
        var titleProperty = modelType.GetProperty("Title");
        if (idProperty is null || titleProperty is null)
            throw new ArgumentNullException(
                $"{typeof(T).Name} must have ```Id``` and ```Title``` propeties");

        var result = new List<SelectListItem>();
        if (addChooseOneItem)
            result.Add(new SelectListItem(firstItemText, firstItemValue));
        foreach (var item in items)
        {
            var id = idProperty.GetValue(item)?.ToString();
            var text = titleProperty.GetValue(item)?.ToString();
            var selected = selectedItem?.ToString() == id;
            result.Add(new SelectListItem(text, id, selected));
        }

        return result;
    }
}  
نحوه استفاده :
مدلی که AllMainCategories برگشت میدهد:
public class ShowCategory
{
    public int Id { get; set; }

    public string Title { get; set; }
}
public async Task<IActionResult> Add()
{
    var categories = await _categoryService.AllMainCategories();
    ViewBag.MainCategories = categories.ToList().CreateSelectListItem(firstItemText: "خودش سر دسته باشد");
    return View();
}

[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> Add(AddCategoryViewModel model)
{
    if (!ModelState.IsValid)
    {
        var categories = await _categoryService.AllMainCategories();
        ViewBag.MainCategories = categories.ToList()
            .CreateSelectListItem(model.ParentId, firstItemText: "خودش سر دسته باشد");
        ModelState.AddModelError(string.Empty, PublicConstantStrings.ModelStateErrorMessage);
        return View(model);
    }
    await _categoryService.AddAsync(new Category()
    {
        Title = model.Title,
        ParentId = model.ParentId == 0 ? null : model.ParentId
    });
    await _uow.SaveChangesAsync();
    return RedirectToAction(nameof(Index));
}
‫۳ سال و ۱ ماه قبل، پنجشنبه ۲۴ تیر ۱۴۰۰، ساعت ۰۳:۳۶
نکته تکمیلی
از نسخه 3.1 به بعد اگر فایل‌های منبع SharedResource.xx.resx و کلاس SharedResouce کنار هم باشند فضا‌های نام دچار Error میشوند. برای حل این مشکل کلاس SharedResource را در ریشه پروژه ساخته و فایل‌های منبع SharedResouce.xx.resx را داخل پوشه Resources.
ماخذ : ^  ^
‫۳ سال و ۱ ماه قبل، سه‌شنبه ۲۲ تیر ۱۴۰۰، ساعت ۰۵:۱۹
نکته تکمیلی
در صورتیکه قبل از فراخوانی delegate زیر
await next();
بخواید کاری انجام بدید که اکشن متود فراخوانی نشه نباید next رو فراخوانی کنید، چون با خطا مواجه میشوید؛ برای مثال اکشن فیلتری رو نوشتید که اگه آی پی کاربر جزء آی پی‌های بن شده سایت بود صفحه غیر مجاز رو به اون نمایش بده
public class CheckUserIp : IAsyncActionFilter
{
    private readonly string[] _bannedIps;

    public CheckUserIp(params string[] bannedIps)
    {
        _bannedIps = bannedIps;
    }
    public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
    {
        var userIp = context.HttpContext.Connection.RemoteIpAddress?.ToString() ?? string.Empty;
        if (_bannedIps.Contains(userIp))
        {
            context.Result = new ViewResult()
            {
                ViewName = "Forbidden"
            };
        }
        else
        {
            await next();
        }
        // await next(); => throw exception
    }
}

در مثال بالا در صورتیکه آی پی کاربر بن شده باشد دیگر next فراخوانی نمیشود و از بروز خطا جلوگیری میکند.
نحوه فراخوانی اکشن فیلتر بالا
[TypeFilter(typeof(CheckUserIp), Arguments = new object[]{ new[] { "::1", "134.56.110.44" } })]
public IActionResult Index()
{
    return View();
}
‫۳ سال و ۲ ماه قبل، شنبه ۵ تیر ۱۴۰۰، ساعت ۰۰:۲۷
نکته تکمیلی
برای ثبت لاگ در Windows event log در سطح Information و Information به پایین باید بخش Logging در appsettings.json را به شکل زیر تغییر داد :
{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Information"
    },
    "EventLog": { // here ...
      "LogLevel": {
        "Default": "Information" //Trace, Debug
      }
    }
  }
}