ربط تطبيقات C# و .NET مع SQL Server

ربط تطبيقات C# و .NET مع SQL Server

في عالم تطوير البرمجيات، توجد علاقة قديمة ومتينة بين تطبيقات C# و.NET وقواعد بيانات SQL Server. هذه العلاقة ليست مجرد وسيلة لتخزين بعض السجلات واسترجاعها عند الحاجة، بل هي جزء أساسي من بنية عدد هائل من الأنظمة الإدارية والتجارية والمالية والطبية والتعليمية والخدمية. عندما تبني تطبيقًا لإدارة الموظفين، أو نظامًا للمخزون، أو منصة للمبيعات، أو برنامجًا لإدارة العملاء، أو واجهة API لتطبيق ويب، فإنك في مرحلة ما ستحتاج إلى أن تجعل التطبيق يتحدث مع قاعدة بيانات بشكل موثوق وآمن وسريع. وهنا تظهر أهمية فهم الطريقة الصحيحة لربط تطبيقات C# و.NET مع SQL Server.

ربما يبدو الأمر للوهلة الأولى بسيطًا جدًا. تكتب Connection String، وتنشئ SqlConnection، ثم ترسل استعلام SQL وتقرأ النتيجة. نعم، هذا يكفي لتجربة صغيرة أو مثال تعليمي، لكنه لا يكفي عادة لتطبيق حقيقي يعمل في بيئة إنتاج. التطبيق الحقيقي يحتاج إلى أكثر من مجرد اتصال ناجح؛ يحتاج إلى إدارة للاتصالات، ومعالجة للأخطاء، ومنع لحقن SQL، واستخدام للمعاملات، وفصل منطقي لطبقات المشروع، والاستفادة من البرمجة غير المتزامنة، وتجنب الاستعلامات البطيئة، واختيار الأداة المناسبة للوصول إلى البيانات، والتعامل مع تغيّر مخطط قاعدة البيانات، واختبار طبقة البيانات، وحماية بيانات الاعتماد، وضبط الأداء مع ارتفاع عدد المستخدمين.

والجميل في منظومة .NET أن أمام المطور أكثر من خيار للوصول إلى SQL Server. يمكنك استخدام ADO.NET عندما تحتاج إلى التحكم الدقيق في الاتصال والاستعلام والقراءة. ويمكنك استخدام Entity Framework Core عندما تريد العمل بأسلوب ORM وتقليل كمية SQL التي تكتبها يدويًا. ويمكنك اختيار Dapper عندما تبحث عن حل خفيف وسريع يجمع بين بساطة الاستعلامات وقليل من الـ Mapping التلقائي. ولكل خيار فلسفته ومكانه المناسب. المشكلة ليست في وجود العديد من الخيارات، بل في أن بعض المطورين يستخدمون أداة واحدة لكل شيء، أو يخلطون بين الأساليب دون فهم الفروق الحقيقية بينها.

في هذا المقال سنبني فهمًا متدرجًا يبدأ من الأساسيات ثم يتقدم نحو الممارسات الأكثر احترافية. سنرى كيفية تجهيز SQL Server، وطريقة إنشاء قاعدة بيانات، وكيف نكتب Connection String صحيحة، وكيف نتعامل مع SqlConnection وSqlCommand وSqlDataReader، ثم ننتقل إلى CRUD، والـ Parameters، والإجراءات المخزنة، والمعاملات، والاتصال غير المتزامن، ثم نتوسع إلى Entity Framework Core وDapper، وأخيرًا سنناقش تصميم طبقة الوصول إلى البيانات، وتحسين الأداء، والأمان، والاختبارات، والنشر.

قد تكون بعض الأمثلة صغيرة، لكنها مكتوبة بطريقة يمكن تحويلها بسهولة إلى جزء من مشروع حقيقي. وهذا مهم جدًا، لأن الهدف ليس حفظ بضعة أسطر من الكود، بل تكوين طريقة تفكير تساعدك كلما احتجت إلى ربط تطبيق C# بقاعدة SQL Server، سواء كان المشروع Console Application أو Desktop Application أو ASP.NET Core Web API أو تطبيقًا مؤسسيًا كبيرًا.

لماذا SQL Server خيار شائع مع تطبيقات C# و.NET؟

SQL Server من قواعد البيانات العلائقية التي تعتمد على الجداول والعلاقات والمفاتيح والفهارس والمعاملات والاستعلامات. وعندما يكون التطبيق مصممًا حول بيانات منظمة ذات علاقات واضحة، تصبح هذه الميزات ذات أهمية كبيرة. تخيل مثلًا متجرًا إلكترونيًا يحتوي على العملاء والطلبات والمنتجات وتفاصيل الطلبات والدفع والشحن. في هذه الحالة توجد علاقات واضحة بين الكيانات، وتحتاج إلى ضمان أن البيانات تظل متسقة حتى لو حدثت عمليات متعددة في الوقت نفسه.

من جهة أخرى، تتميز منظومة .NET بوجود دعم واسع للوصول إلى SQL Server، بدءًا من ADO.NET وحتى Entity Framework Core. وهذا يجعل بيئة التطوير متماسكة جدًا، خصوصًا عندما يكون فريق العمل معتادًا على C# وASP.NET Core وVisual Studio وSQL Server.

من المزايا أيضًا أن SQL Server يقدم قدرات متقدمة في الفهارس والاستعلامات والمعاملات والأمان والمراقبة والتحسين، بينما تمنحك .NET أدوات قوية لبناء خدمات وتطبيقات قابلة للتطوير. هذا التكامل لا يعني أن أي تطبيق .NET مع SQL Server سيكون تلقائيًا سريعًا وآمنًا، ولكنه يعطيك أساسًا ممتازًا إذا استخدمته بطريقة صحيحة.

المتطلبات الأساسية قبل البدء

قبل كتابة كود الاتصال، ستحتاج إلى بعض المكونات الأساسية. أولًا يجب أن يكون SQL Server مثبتًا أو متاحًا على شبكة يمكن لتطبيقك الوصول إليها. يمكنك استخدام SQL Server Developer في بيئة التطوير أو نسخة مناسبة لاحتياجاتك في بيئة الاختبار والإنتاج. كما يفيدك وجود SQL Server Management Studio لإدارة قواعد البيانات وتنفيذ الاستعلامات ومراقبة الجداول والفهارس.

أما في جانب C#، فيمكنك استخدام إصدار حديث من .NET وإنشاء المشروع عبر Visual Studio أو Rider أو Visual Studio Code. في التطبيقات الحديثة ستجد أن أسماء حزم SQL Client تختلف عن بعض الأمثلة القديمة الموجودة على الإنترنت، لذلك من المهم الانتباه إلى المكتبة التي تستخدمها. في تطبيقات .NET الحديثة غالبًا ستتعامل مع Microsoft.Data.SqlClient عند استخدام SQL Server مع ADO.NET.

يمكن إنشاء مشروع Console بسيط مثلًا باستخدام:

dotnet new console -n SqlServerDemo
cd SqlServerDemo
dotnet add package Microsoft.Data.SqlClient

وبعدها يمكن تنفيذ المشروع باستخدام:

dotnet run

الفكرة هنا أن الاتصال بقاعدة البيانات ليس جزءًا سحريًا من C#؛ هو عبارة عن طبقة برمجية تستخدم Driver/Provider مناسبًا للتواصل مع SQL Server عبر بروتوكول يفهمه الطرفان.

إنشاء قاعدة بيانات بسيطة للتجارب

حتى تكون الأمثلة عملية، لنفترض أننا نعمل على نظام بسيط لإدارة العملاء. يمكن إنشاء قاعدة بيانات باسم CompanyDb، ثم إنشاء جدول Customers:

CREATE DATABASE CompanyDb;
GO

USE CompanyDb;
GO

CREATE TABLE Customers
(
    Id INT IDENTITY(1,1) PRIMARY KEY,
    FullName NVARCHAR(150) NOT NULL,
    Email NVARCHAR(200) NOT NULL,
    Phone NVARCHAR(30) NULL,
    CreatedAt DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME()
);
GO

ثم يمكن إضافة بيانات تجريبية:

INSERT INTO Customers (FullName, Email, Phone)
VALUES
(N'أحمد محمد', N'ahmed@example.com', N'0600000000'),
(N'سارة علي', N'sara@example.com', N'0611111111'),
(N'يوسف حسن', N'youssef@example.com', N'0622222222');
GO

أصبح لدينا الآن جدول يمكن الاتصال به من تطبيق C# واسترجاع البيانات منه.

فهم Connection String

أحد أهم الأجزاء في ربط C# مع SQL Server هو سلسلة الاتصال أو Connection String. وهي النص الذي يصف للتطبيق أين توجد قاعدة البيانات وكيف يجب الاتصال بها.

مثال تقليدي:

Server=localhost;Database=CompanyDb;Trusted_Connection=True;TrustServerCertificate=True;

في هذا المثال:

  • Server=localhost يعني أن SQL Server يعمل على نفس الجهاز.

  • Database=CompanyDb تحدد قاعدة البيانات.

  • Trusted_Connection=True تعني استخدام مصادقة Windows في بيئة مناسبة.

  • TrustServerCertificate=True تُستخدم غالبًا في بيئة تطوير محلية عندما تكون الشهادة غير موثقة لدى العميل.

ويمكن أيضًا استخدام اسم Instance:

Server=localhost\SQLEXPRESS;Database=CompanyDb;Trusted_Connection=True;TrustServerCertificate=True;

أو SQL Authentication:

Server=localhost;Database=CompanyDb;User Id=myuser;Password=mypassword;TrustServerCertificate=True;

لكن وضع اسم المستخدم وكلمة المرور مباشرة داخل الكود يعد ممارسة غير جيدة. من الأفضل أن تكون معلومات الاتصال خارج الكود المصدري، مثل ملفات الإعدادات أو متغيرات البيئة أو Secret Management المناسب لبيئة النشر.

في ASP.NET Core يمكن وضع Connection String في appsettings.json مثلًا:

{
  "ConnectionStrings": {
    "DefaultConnection": "Server=localhost;Database=CompanyDb;Trusted_Connection=True;TrustServerCertificate=True;"
  }
}

ثم يمكن الوصول إليها عبر Configuration:

var connectionString =
    builder.Configuration.GetConnectionString("DefaultConnection");

وفي الإنتاج ينبغي التفكير بعناية في كيفية تخزين الأسرار بدل الاعتماد على ملف إعدادات مكشوف أو موجود في مستودع Git.

أول اتصال باستخدام SqlConnection

بعد تثبيت Microsoft.Data.SqlClient، يمكن كتابة أول مثال:

using Microsoft.Data.SqlClient;

string connectionString =
    "Server=localhost;Database=CompanyDb;Trusted_Connection=True;TrustServerCertificate=True;";

using SqlConnection connection = new SqlConnection(connectionString);

connection.Open();

Console.WriteLine("Database connection successful!");

هذا المثال بسيط، لكنه يوضح الفكرة الأساسية. يتم إنشاء كائن SqlConnection، ثم يتم فتح الاتصال.

لاحظ أننا استخدمنا using. هذه نقطة مهمة جدًا. اتصالات قواعد البيانات عبارة عن موارد ينبغي التخلص منها بشكل صحيح. استخدام using يضمن استدعاء Dispose() حتى في حالات الخطأ والاستثناء.

يمكن أيضًا استخدام الأسلوب القديم مع try/finally، لكن using أكثر وضوحًا وأمانًا:

SqlConnection? connection = null;

try
{
    connection = new SqlConnection(connectionString);
    connection.Open();

    Console.WriteLine("Connected.");
}
finally
{
    connection?.Dispose();
}

مع ذلك، في الكود الحديث سيظل using هو الخيار الأكثر راحة في أغلب الحالات.

اختبار الاتصال بقاعدة البيانات

من المفيد أن يكون لديك اختبار بسيط يمكنه تحديد ما إذا كانت المشكلة في الشبكة أو الاتصال قبل أن تبدأ بتحليل أخطاء الاستعلامات.

using Microsoft.Data.SqlClient;

string connectionString =
    "Server=localhost;Database=CompanyDb;Trusted_Connection=True;TrustServerCertificate=True;";

try
{
    using SqlConnection connection = new(connectionString);
    connection.Open();

    Console.WriteLine($"State: {connection.State}");
}
catch (SqlException ex)
{
    Console.WriteLine("SQL Server connection failed.");
    Console.WriteLine(ex.Message);
}

من الناحية العملية، الأخطاء المحتملة كثيرة: اسم الخادم قد يكون خاطئًا، SQL Server قد لا يعمل، المنفذ قد يكون محجوبًا، بيانات الاعتماد غير صحيحة، قاعدة البيانات غير موجودة، أو إعدادات TLS قد تمنع الاتصال.

وهنا تظهر أهمية عدم التعامل مع كل الأخطاء على أنها "خطأ في C#". أحيانًا يكون التطبيق سليمًا تمامًا لكن SQL Server نفسه غير متاح.

تنفيذ أول SELECT باستخدام SqlCommand

بعد فتح الاتصال، نحتاج إلى تنفيذ الاستعلام.

using Microsoft.Data.SqlClient;

string connectionString =
    "Server=localhost;Database=CompanyDb;Trusted_Connection=True;TrustServerCertificate=True;";

string sql = "SELECT Id, FullName, Email, Phone FROM Customers";

using SqlConnection connection = new(connectionString);
using SqlCommand command = new(sql, connection);

connection.Open();

using SqlDataReader reader = command.ExecuteReader();

while (reader.Read())
{
    Console.WriteLine(
        $"{reader["Id"]} - {reader["FullName"]} - {reader["Email"]}"
    );
}

هنا ظهر SqlDataReader. وهو طريقة فعالة جدًا لقراءة نتائج الاستعلام صفًا بعد صف.

يمكنك استخدام أنواع القراءة المباشرة:

int id = reader.GetInt32(reader.GetOrdinal("Id"));
string name = reader.GetString(reader.GetOrdinal("FullName"));
string email = reader.GetString(reader.GetOrdinal("Email"));

هذه الطريقة أكثر وضوحًا من الاعتماد على reader["ColumnName"] في التطبيقات الجادة، خصوصًا إذا أردت التحكم في الأنواع والتحقق من NULL.

التعامل مع القيم NULL

إذا كان عمود Phone يسمح بـ NULL، فإن هذه السطر قد يسبب مشكلة:

string phone = reader.GetString(reader.GetOrdinal("Phone"));

لأن SQL Server يمكن أن يعيد قيمة null.

يمكن استخدام:

int phoneOrdinal = reader.GetOrdinal("Phone");

string? phone = reader.IsDBNull(phoneOrdinal)
    ? null
    : reader.GetString(phoneOrdinal);

ويمكن أيضًا كتابة Helper يخفف التكرار في المشاريع الكبيرة.

استخدام Parameters بدل تركيب SQL يدويًا

هذا من أهم الأجزاء في المقال كله.

الكود التالي غير آمن:

string name = Console.ReadLine()!;

string sql =
    $"SELECT Id, FullName, Email FROM Customers WHERE FullName = '{name}'";

لماذا؟ لأنك تضع قيمة المستخدم داخل النص مباشرة. إذا أدخل المستخدم نصًا خبيثًا، فقد يؤدي إلى SQL Injection أو مشاكل في بناء الاستعلام.

الحل الصحيح هو SqlParameter:

string sql = """
    SELECT Id, FullName, Email
    FROM Customers
    WHERE FullName = @FullName
    """;

using SqlConnection connection = new(connectionString);
using SqlCommand command = new(sql, connection);

command.Parameters.AddWithValue("@FullName", name);

connection.Open();

using SqlDataReader reader = command.ExecuteReader();

while (reader.Read())
{
    Console.WriteLine(reader["Email"]);
}

لكن هناك ملاحظة مهمة: AddWithValue() سهل لكنه ليس دائمًا أفضل خيار، لأنه قد يؤدي إلى اختيار نوع بيانات غير مثالي أو إلى مشكلات في تحويل الأنواع والأداء. في الكود الاحترافي يمكنك تحديد النوع والطول صراحة:

command.Parameters.Add(
    "@FullName",
    System.Data.SqlDbType.NVarChar,
    150
).Value = name;

هذا يمنحك تحكمًا أفضل.

تنفيذ INSERT

لنقل إننا نريد إضافة عميل جديد:

string sql = """
    INSERT INTO Customers (FullName, Email, Phone)
    VALUES (@FullName, @Email, @Phone)
    """;

using SqlConnection connection = new(connectionString);
using SqlCommand command = new(sql, connection);

command.Parameters.Add("@FullName", System.Data.SqlDbType.NVarChar, 150)
    .Value = "محمد خالد";

command.Parameters.Add("@Email", System.Data.SqlDbType.NVarChar, 200)
    .Value = "mohamed@example.com";

command.Parameters.Add("@Phone", System.Data.SqlDbType.NVarChar, 30)
    .Value = "0633333333";

connection.Open();

int affectedRows = command.ExecuteNonQuery();

Console.WriteLine($"Rows inserted: {affectedRows}");

تُرجع ExecuteNonQuery() عدد الصفوف المتأثرة.

الحصول على ID بعد INSERT

غالبًا ما تحتاج إلى الحصول على قيمة IDENTITY التي أنشأها SQL Server.

يمكنك استخدام OUTPUT INSERTED.Id:

string sql = """
    INSERT INTO Customers (FullName, Email, Phone)
    OUTPUT INSERTED.Id
    VALUES (@FullName, @Email, @Phone)
    """;

using SqlConnection connection = new(connectionString);
using SqlCommand command = new(sql, connection);

command.Parameters.Add("@FullName", System.Data.SqlDbType.NVarChar, 150)
    .Value = "ليلى سعيد";

command.Parameters.Add("@Email", System.Data.SqlDbType.NVarChar, 200)
    .Value = "layla@example.com";

command.Parameters.Add("@Phone", System.Data.SqlDbType.NVarChar, 30)
    .Value = "0644444444";

connection.Open();

int newId = (int)command.ExecuteScalar();

Console.WriteLine($"Created customer ID: {newId}");

ExecuteScalar() مناسب عندما تتوقع قيمة واحدة.

UPDATE باستخدام Parameters

string sql = """
    UPDATE Customers
    SET FullName = @FullName,
        Email = @Email,
        Phone = @Phone
    WHERE Id = @Id
    """;

using SqlConnection connection = new(connectionString);
using SqlCommand command = new(sql, connection);

command.Parameters.Add("@Id", System.Data.SqlDbType.Int).Value = 2;
command.Parameters.Add("@FullName", System.Data.SqlDbType.NVarChar, 150)
    .Value = "سارة محمد";
command.Parameters.Add("@Email", System.Data.SqlDbType.NVarChar, 200)
    .Value = "sara.new@example.com";
command.Parameters.Add("@Phone", System.Data.SqlDbType.NVarChar, 30)
    .Value = "0655555555";

connection.Open();

int affectedRows = command.ExecuteNonQuery();

Console.WriteLine($"Rows updated: {affectedRows}");

لاحظ وجود WHERE Id = @Id. حذف WHERE من UPDATE في كود إنتاجي قد يؤدي إلى تحديث جميع الصفوف، وهي من أكثر الأخطاء التي يمكن أن تكون كارثية.

DELETE بطريقة آمنة

string sql = "DELETE FROM Customers WHERE Id = @Id";

using SqlConnection connection = new(connectionString);
using SqlCommand command = new(sql, connection);

command.Parameters.Add("@Id", System.Data.SqlDbType.Int).Value = 3;

connection.Open();

int affectedRows = command.ExecuteNonQuery();

Console.WriteLine($"Rows deleted: {affectedRows}");

لكن في التطبيقات الحقيقية، يجب التفكير في الـ business rules. أحيانًا يكون حذف السجل بشكل نهائي غير مناسب، وقد تفضل Soft Delete عبر عمود مثل IsDeleted.

بناء طبقة بسيطة للوصول إلى البيانات

من الأخطاء الشائعة أن تضع كل استعلامات SQL داخل Controller أو Form أو Command Handler. في البداية قد يبدو هذا سريعًا، لكن المشروع يصبح صعب الصيانة مع الوقت.

يمكن إنشاء class باسم CustomerRepository:

using Microsoft.Data.SqlClient;

public class CustomerRepository
{
    private readonly string _connectionString;

    public CustomerRepository(string connectionString)
    {
        _connectionString = connectionString;
    }

    public List<Customer> GetAll()
    {
        var customers = new List<Customer>();

        const string sql = """
            SELECT Id, FullName, Email, Phone, CreatedAt
            FROM Customers
            ORDER BY Id DESC
            """;

        using SqlConnection connection = new(_connectionString);
        using SqlCommand command = new(sql, connection);

        connection.Open();

        using SqlDataReader reader = command.ExecuteReader();

        while (reader.Read())
        {
            customers.Add(new Customer
            {
                Id = reader.GetInt32(reader.GetOrdinal("Id")),
                FullName = reader.GetString(reader.GetOrdinal("FullName")),
                Email = reader.GetString(reader.GetOrdinal("Email")),
                Phone = reader.IsDBNull(reader.GetOrdinal("Phone"))
                    ? null
                    : reader.GetString(reader.GetOrdinal("Phone")),
                CreatedAt = reader.GetDateTime(
                    reader.GetOrdinal("CreatedAt"))
            });
        }

        return customers;
    }
}

والـ model:

public class Customer
{
    public int Id { get; set; }
    public string FullName { get; set; } = string.Empty;
    public string Email { get; set; } = string.Empty;
    public string? Phone { get; set; }
    public DateTime CreatedAt { get; set; }
}

هذا التصميم يفصل مسؤولية الوصول إلى البيانات عن باقي أجزاء التطبيق.

فصل Connection String عن الكود

بدل:

private const string ConnectionString =
    "Server=localhost;Database=CompanyDb;Trusted_Connection=True;TrustServerCertificate=True;";

يفضل أن تقرأها من Configuration.

في تطبيق Console يمكن استخدام ملف إعدادات، أما في ASP.NET Core فلدينا نظام Configuration وDependency Injection جاهز.

مثلًا:

{
  "ConnectionStrings": {
    "DefaultConnection": "Server=localhost;Database=CompanyDb;Trusted_Connection=True;TrustServerCertificate=True;"
  }
}

ثم:

var connectionString =
    configuration.GetConnectionString("DefaultConnection");

يمكن لاحقًا استبدال القيمة بمتغير بيئة:

ConnectionStrings__DefaultConnection

وهذا يسمح بأن يكون نفس التطبيق قابلًا للنشر في Development وStaging وProduction بدون تعديل الكود.

الاتصال غير المتزامن Async

في تطبيقات الويب خصوصًا، من المهم استخدام العمليات غير المتزامنة عندما يكون ذلك مناسبًا.

بدل:

connection.Open();
SqlDataReader reader = command.ExecuteReader();

يمكن استخدام:

await connection.OpenAsync();
await using SqlDataReader reader =
    await command.ExecuteReaderAsync();

مثال كامل:

public async Task<List<Customer>> GetAllAsync()
{
    var customers = new List<Customer>();

    const string sql = """
        SELECT Id, FullName, Email, Phone, CreatedAt
        FROM Customers
        ORDER BY Id DESC
        """;

    await using SqlConnection connection = new(_connectionString);
    await using SqlCommand command = new(sql, connection);

    await connection.OpenAsync();

    await using SqlDataReader reader =
        await command.ExecuteReaderAsync();

    while (await reader.ReadAsync())
    {
        customers.Add(new Customer
        {
            Id = reader.GetInt32(reader.GetOrdinal("Id")),
            FullName = reader.GetString(
                reader.GetOrdinal("FullName")),
            Email = reader.GetString(
                reader.GetOrdinal("Email")),
            Phone = reader.IsDBNull(reader.GetOrdinal("Phone"))
                ? null
                : reader.GetString(reader.GetOrdinal("Phone")),
            CreatedAt = reader.GetDateTime(
                reader.GetOrdinal("CreatedAt"))
        });
    }

    return customers;
}

الفائدة الرئيسية من Async ليست أن SQL Server صار فجأة أسرع، وإنما أن الخيط الذي يخدم الطلب في تطبيق الويب لا يبقى محجوزًا بشكل غير ضروري أثناء انتظار I/O.

الفرق بين ExecuteNonQuery وExecuteScalar وExecuteReader

هذه الثلاثة تتكرر في أغلب تطبيقات ADO.NET.

ExecuteNonQuery() مناسبة لـ INSERT وUPDATE وDELETE عندما لا تحتاج إلى مجموعة نتائج.

ExecuteScalar() مناسبة عندما تحتاج إلى قيمة واحدة، مثل عدد السجلات أو ID جديد.

ExecuteReader() مناسبة عندما تريد قراءة مجموعة صفوف.

مثال على COUNT(*):

const string sql = "SELECT COUNT(*) FROM Customers";

using SqlConnection connection = new(_connectionString);
using SqlCommand command = new(sql, connection);

connection.Open();

int count = Convert.ToInt32(command.ExecuteScalar());

Console.WriteLine($"Customers count: {count}");

البحث باستخدام LIKE

عند بناء شاشة بحث، قد تستخدم:

const string sql = """
    SELECT Id, FullName, Email
    FROM Customers
    WHERE FullName LIKE @Search
    ORDER BY FullName
    """;

using SqlConnection connection = new(_connectionString);
using SqlCommand command = new(sql, connection);

command.Parameters.Add(
    "@Search",
    System.Data.SqlDbType.NVarChar,
    150
).Value = "%أحمد%";

connection.Open();

using SqlDataReader reader = command.ExecuteReader();

while (reader.Read())
{
    Console.WriteLine(reader["FullName"]);
}

لكن يجب الانتباه إلى الفهارس والأداء. الاستعلامات التي تبدأ بـ % قد لا تستفيد من فهرس تقليدي بالشكل المتوقع، خصوصًا مع البيانات الكبيرة.

في تلك النقطة تبدأ أسئلة أعمق: هل نحتاج إلى Full-Text Search؟ هل شكل البيانات مناسب؟ هل هناك فهرس مناسب؟ هل الاستعلام يعيد عددًا كبيرًا من الصفوف؟

استخدام Stored Procedures

الإجراءات المخزنة من الأساليب المعروفة جدًا في عالم SQL Server.

يمكن إنشاء إجراء:

CREATE PROCEDURE GetCustomerById
    @Id INT
AS
BEGIN
    SET NOCOUNT ON;

    SELECT
        Id,
        FullName,
        Email,
        Phone,
        CreatedAt
    FROM Customers
    WHERE Id = @Id;
END;
GO

ثم استدعاؤه من C#:

using Microsoft.Data.SqlClient;
using System.Data;

using SqlConnection connection = new(connectionString);
using SqlCommand command = new("GetCustomerById", connection);

command.CommandType = CommandType.StoredProcedure;

command.Parameters.Add("@Id", SqlDbType.Int).Value = 1;

connection.Open();

using SqlDataReader reader = command.ExecuteReader();

if (reader.Read())
{
    Console.WriteLine(reader["FullName"]);
}

الإجراء المخزن قد يكون مفيدًا في بعض الأنظمة، خاصة عندما توجد استعلامات معقدة أو منطق SQL يحتاج إلى إدارة مركزية. لكن استخدام Stored Procedures ليس شرطًا دائمًا للحصول على تطبيق جيد. بعض الفرق تفضل الاحتفاظ بالاستعلامات ضمن كود التطبيق باستخدام ORM أو Dapper، بينما تعتمد فرق أخرى على طبقة SQL قوية.

المهم أن يكون القرار مبنيًا على طبيعة المشروع وليس على فكرة أن طريقة واحدة هي الصحيحة دائمًا.

معاملات Transactions

أحيانًا لا تكون العملية عبارة عن استعلام واحد. تخيل متجرًا ينفذ عملية بيع. ربما تحتاج إلى:

  1. إنشاء الطلب.

  2. إضافة عناصر الطلب.

  3. خصم المخزون.

  4. تسجيل عملية الدفع.

إذا نجح إنشاء الطلب وفشل خصم المخزون، فقد ينتهي النظام في حالة غير متسقة.

هنا تستخدم Transaction:

using SqlConnection connection = new(connectionString);

await connection.OpenAsync();

await using SqlTransaction transaction =
    (SqlTransaction)await connection.BeginTransactionAsync();

try
{
    const string orderSql = """
        INSERT INTO Orders (CustomerId, CreatedAt)
        OUTPUT INSERTED.Id
        VALUES (@CustomerId, SYSUTCDATETIME())
        """;

    await using SqlCommand orderCommand =
        new(orderSql, connection, transaction);

    orderCommand.Parameters.Add("@CustomerId", SqlDbType.Int)
        .Value = 10;

    int orderId = Convert.ToInt32(
        await orderCommand.ExecuteScalarAsync()
    );

    const string stockSql = """
        UPDATE Products
        SET Quantity = Quantity - @Quantity
        WHERE Id = @ProductId
          AND Quantity >= @Quantity
        """;

    await using SqlCommand stockCommand =
        new(stockSql, connection, transaction);

    stockCommand.Parameters.Add("@Quantity", SqlDbType.Int)
        .Value = 2;

    stockCommand.Parameters.Add("@ProductId", SqlDbType.Int)
        .Value = 15;

    int updated = await stockCommand.ExecuteNonQueryAsync();

    if (updated != 1)
    {
        throw new InvalidOperationException(
            "Insufficient stock or product not found."
        );
    }

    await transaction.CommitAsync();

    Console.WriteLine($"Order {orderId} committed.");
}
catch
{
    await transaction.RollbackAsync();
    throw;
}

المهم في المعاملة أن العمليات المرتبطة بوحدة عمل واحدة يجب أن تكون إما كلها ناجحة أو كلها ملغاة، حسب منطق العمل.

مستويات العزل وفهم التزامن

مع زيادة حجم التطبيق، ستسمع كلمات مثل Read Committed وRepeatable Read وSerializable وSnapshot. هذه الخيارات تؤثر في كيفية تعامل قاعدة البيانات مع العمليات المتزامنة.

لنقل إن مستخدمين يحاولان تحديث نفس المنتج في نفس اللحظة. إذا لم يكن تصميمك جيدًا، فقد يحدث Lost Update أو نتيجة غير متوقعة.

لذلك ليس كافيًا أن تقول "استخدم Transaction". يجب أن تفهم أيضًا طبيعة التزامن في العملية.

في بعض السيناريوهات يمكن أن يكون الحل باستخدام شرط في UPDATE:

UPDATE Products
SET Quantity = Quantity - @Quantity
WHERE Id = @ProductId
  AND Quantity >= @Quantity;

ثم تتحقق من عدد الصفوف المتأثرة. هذا النوع من التصميم يمنع خصم المخزون عندما لا تتوفر الكمية.

Concurrency باستخدام RowVersion

في الأنظمة الإدارية، قد يقوم مستخدمان بفتح نفس السجل ثم يقوم كل واحد منهما بالتعديل.

يمكن إضافة عمود rowversion:

ALTER TABLE Customers
ADD VersionStamp ROWVERSION;

ثم تستخدم القيمة في شرط التحديث:

UPDATE Customers
SET FullName = @FullName,
    Email = @Email
WHERE Id = @Id
  AND VersionStamp = @OriginalVersionStamp;

إذا كانت نتيجة التحديث 0، فهذا يعني غالبًا أن السجل تغيّر منذ أن قرأه المستخدم، ويمكن إظهار رسالة "تم تعديل هذا السجل من مستخدم آخر".

هذه الفكرة تظهر كثيرًا في الأنظمة متعددة المستخدمين.

استخدام Entity Framework Core

إذا كان ADO.NET يعطيك التحكم الكامل، فإن Entity Framework Core يرفع مستوى التجريد ويتيح لك التعامل مع قاعدة البيانات عبر الكائنات وLINQ.

يمكن إضافة الحزمة:

dotnet add package Microsoft.EntityFrameworkCore.SqlServer

ولإنشاء المشروع أو إدارة Migration يمكن إضافة أدوات EF Core حسب طريقة المشروع.

نبدأ بـ Model:

public class Customer
{
    public int Id { get; set; }

    public string FullName { get; set; } = string.Empty;

    public string Email { get; set; } = string.Empty;

    public string? Phone { get; set; }

    public DateTime CreatedAt { get; set; }
}

ثم DbContext:

using Microsoft.EntityFrameworkCore;

public class AppDbContext : DbContext
{
    public DbSet<Customer> Customers =>
        Set<Customer>();

    public AppDbContext(
        DbContextOptions<AppDbContext> options)
        : base(options)
    {
    }
}

وفي ASP.NET Core:

builder.Services.AddDbContext<AppDbContext>(options =>
{
    options.UseSqlServer(
        builder.Configuration.GetConnectionString(
            "DefaultConnection"
        )
    );
});

أصبح بإمكانك استخدام LINQ:

var customers = await db.Customers
    .OrderByDescending(x => x.Id)
    .ToListAsync();

أو:

var customer = await db.Customers
    .FirstOrDefaultAsync(x => x.Id == id);

وهذا يختصر قدرًا كبيرًا من الكود مقارنة بـ ADO.NET.

لماذا Entity Framework Core مهم؟

القيمة الحقيقية في EF Core ليست فقط "كتابة SQL أقل". بل إنه يعطيك نموذجًا متكاملاً لإدارة الكيانات والعلاقات وChange Tracking وMigrations وLINQ والمعاملات وتوليد الاستعلامات.

مثلًا، تستطيع إنشاء Migration:

dotnet ef migrations add InitialCreate

ثم تطبيقها:

dotnet ef database update

ومع تغيّر النموذج:

dotnet ef migrations add AddCustomerPhone
dotnet ef database update

بهذه الطريقة يمكن تتبع تغييرات مخطط قاعدة البيانات ضمن دورة حياة المشروع.

لكن يجب الحذر من التعامل مع EF Core كأنه سحر. الكود التالي:

var customers = await db.Customers.ToListAsync();

قد يكون ممتازًا عندما تحتاج جميع العملاء فعلًا، لكنه قد يكون غير مناسب إذا كانت لديك مئات الآلاف من السجلات.

في كثير من التطبيقات الأفضل تحديد الحقول:

var customers = await db.Customers
    .Select(x => new CustomerListItem
    {
        Id = x.Id,
        FullName = x.FullName,
        Email = x.Email
    })
    .ToListAsync();

وهنا يتم جلب البيانات اللازمة فقط.

أهمية AsNoTracking

عندما تسترجع بيانات للعرض فقط ولا تنوي تعديلها، قد تستخدم:

var customers = await db.Customers
    .AsNoTracking()
    .ToListAsync();

هذا يخبر EF Core بأن هذه الكيانات ليست بحاجة إلى Change Tracking بالطريقة المعتادة.

قد يكون مفيدًا خصوصًا في استعلامات القراءة الكثيفة.

لكن لا ينبغي إضافة AsNoTracking() بشكل أعمى إلى كل شيء، لأنك إذا كنت تريد تعديل الكيان وتحتاج إلى تتبعه، فقد تحتاج إلى Tracking.

العلاقات في Entity Framework Core

لنفرض أن لدينا:

public class Order
{
    public int Id { get; set; }

    public int CustomerId { get; set; }

    public DateTime CreatedAt { get; set; }

    public Customer Customer { get; set; } = null!;

    public ICollection<OrderItem> Items { get; set; } =
        new List<OrderItem>();
}

و:

public class OrderItem
{
    public int Id { get; set; }

    public int OrderId { get; set; }

    public int ProductId { get; set; }

    public int Quantity { get; set; }

    public decimal UnitPrice { get; set; }

    public Order Order { get; set; } = null!;
}

يمكن تعريف العلاقة عبر Fluent API:

protected override void OnModelCreating(
    ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Order>()
        .HasOne(x => x.Customer)
        .WithMany()
        .HasForeignKey(x => x.CustomerId);

    modelBuilder.Entity<OrderItem>()
        .HasOne(x => x.Order)
        .WithMany(x => x.Items)
        .HasForeignKey(x => x.OrderId);
}

ثم يمكن الاستعلام:

var orders = await db.Orders
    .Include(x => x.Items)
    .Include(x => x.Customer)
    .AsNoTracking()
    .ToListAsync();

لكن مرة أخرى، Include ليس شيئًا ينبغي استخدامه بلا تفكير. تحميل علاقات كثيرة دفعة واحدة قد يؤدي إلى استعلامات كبيرة أو نتائج مضاعفة في بعض السيناريوهات. أحيانًا يكون Projection أفضل.

Projection بدل تحميل كائنات ضخمة

بدل:

var orders = await db.Orders
    .Include(x => x.Customer)
    .Include(x => x.Items)
    .ToListAsync();

يمكن استخدام:

var orders = await db.Orders
    .Select(x => new OrderListDto
    {
        Id = x.Id,
        CustomerName = x.Customer.FullName,
        ItemsCount = x.Items.Count,
        CreatedAt = x.CreatedAt
    })
    .ToListAsync();

هذا يجعل الاستعلام أكثر تركيزًا، خصوصًا في واجهات API التي تحتاج فقط إلى بيانات مختصرة.

Dapper كحل وسط

Dapper مكتبة خفيفة جدًا بالنسبة للـ ORM الكامل، وتستخدم SQL الذي تكتبه أنت مع Mapping إلى الكائنات.

يمكن تثبيتها:

dotnet add package Dapper

ثم:

using Dapper;
using Microsoft.Data.SqlClient;

using SqlConnection connection = new(connectionString);

const string sql = """
    SELECT Id, FullName, Email, Phone, CreatedAt
    FROM Customers
    ORDER BY Id DESC
    """;

var customers = await connection.QueryAsync<Customer>(sql);

foreach (var customer in customers)
{
    Console.WriteLine(customer.FullName);
}

هذا مريح جدًا.

يمكنك أيضًا تنفيذ Insert:

const string sql = """
    INSERT INTO Customers (FullName, Email, Phone)
    VALUES (@FullName, @Email, @Phone)
    """;

await connection.ExecuteAsync(
    sql,
    new
    {
        FullName = "نور أحمد",
        Email = "noor@example.com",
        Phone = "0677777777"
    }
);

Dapper تستخدم Parameters نفسها، ولذلك لا تحتاج إلى تركيب SQL بالنص.

متى تستخدم ADO.NET؟

ADO.NET ممتاز عندما تحتاج إلى تحكم دقيق في:

  • SQL.

  • Parameters.

  • Transactions.

  • DataReader.

  • استهلاك منخفض نسبيًا للطبقة الوسيطة.

  • تكامل مع إجراءات مخزنة أو استعلامات معقدة.

كما أنه مناسب عندما تريد معرفة ما يحدث فعليًا بين التطبيق وقاعدة البيانات. وهذا يجعل تعلم ADO.NET مفيدًا حتى لو كنت تستخدم EF Core معظم الوقت.

هناك أمر آخر مهم: فهم ADO.NET يجعلك تفهم ما وراء بعض التجريدات التي يوفرها ORM.

متى تستخدم Entity Framework Core؟

EF Core مناسب عندما:

  • لديك Domain Model غني.

  • توجد علاقات عديدة.

  • تحتاج إلى Migrations.

  • تستخدم LINQ باستمرار.

  • تريد تقليل SQL اليدوي.

  • المشروع كبير وتريد بنية موحدة للوصول إلى البيانات.

لكنه يحتاج إلى وعي بأداء الاستعلامات وتوليد SQL والتحميل الكسول أو الصريح والعلاقات والفهارس وغيرها.

متى تستخدم Dapper؟

Dapper خيار جيد عندما:

  • تريد كتابة SQL بنفسك.

  • تريد Mapping تلقائيًا إلى POCOs.

  • تريد طبقة خفيفة جدًا.

  • توجد استعلامات مخصصة أو تقارير معقدة.

  • تريد الابتعاد عن Change Tracking الكامل للـ ORM.

في بعض المشاريع الكبيرة يتم استخدام EF Core للعمليات التقليدية وDapper لبعض التقارير أو الاستعلامات الخاصة. هذا ممكن، لكن يجب أن يكون تصميم المشروع واضحًا حتى لا تصبح طبقة البيانات خليطًا فوضويًا.

تصميم Repository Pattern

يمكن تعريف واجهة:

public interface ICustomerRepository
{
    Task<Customer?> GetByIdAsync(int id);
    Task<IReadOnlyList<Customer>> GetAllAsync();
    Task<int> CreateAsync(Customer customer);
    Task<bool> UpdateAsync(Customer customer);
    Task<bool> DeleteAsync(int id);
}

ثم تنفيذها باستخدام Dapper مثلًا:

public class CustomerRepository : ICustomerRepository
{
    private readonly string _connectionString;

    public CustomerRepository(string connectionString)
    {
        _connectionString = connectionString;
    }

    public async Task<Customer?> GetByIdAsync(int id)
    {
        const string sql = """
            SELECT Id, FullName, Email, Phone, CreatedAt
            FROM Customers
            WHERE Id = @Id
            """;

        await using SqlConnection connection =
            new(_connectionString);

        return await connection.QuerySingleOrDefaultAsync<Customer>(
            sql,
            new { Id = id }
        );
    }

    public async Task<IReadOnlyList<Customer>> GetAllAsync()
    {
        const string sql = """
            SELECT Id, FullName, Email, Phone, CreatedAt
            FROM Customers
            ORDER BY Id DESC
            """;

        await using SqlConnection connection =
            new(_connectionString);

        var result = await connection.QueryAsync<Customer>(sql);

        return result.AsList();
    }

    public async Task<int> CreateAsync(Customer customer)
    {
        const string sql = """
            INSERT INTO Customers
                (FullName, Email, Phone, CreatedAt)
            OUTPUT INSERTED.Id
            VALUES
                (@FullName, @Email, @Phone, @CreatedAt)
            """;

        await using SqlConnection connection =
            new(_connectionString);

        return await connection.ExecuteScalarAsync<int>(
            sql,
            customer
        );
    }

    public async Task<bool> UpdateAsync(Customer customer)
    {
        const string sql = """
            UPDATE Customers
            SET FullName = @FullName,
                Email = @Email,
                Phone = @Phone
            WHERE Id = @Id
            """;

        await using SqlConnection connection =
            new(_connectionString);

        int rows = await connection.ExecuteAsync(
            sql,
            customer
        );

        return rows > 0;
    }

    public async Task<bool> DeleteAsync(int id)
    {
        const string sql =
            "DELETE FROM Customers WHERE Id = @Id";

        await using SqlConnection connection =
            new(_connectionString);

        int rows = await connection.ExecuteAsync(
            sql,
            new { Id = id }
        );

        return rows > 0;
    }
}

بهذا التصميم يصبح Controller أو Service غير مهتم بتفاصيل Connection وSQL.

Service Layer

ليس كل منطق يجب أن يكون داخل Repository. الـ Repository مسؤول عن الوصول إلى البيانات، أما Business Logic فمن الأفضل أن يكون داخل Service.

مثلًا:

public class CustomerService
{
    private readonly ICustomerRepository _repository;

    public CustomerService(ICustomerRepository repository)
    {
        _repository = repository;
    }

    public async Task<int> RegisterAsync(
        string fullName,
        string email,
        string? phone)
    {
        if (string.IsNullOrWhiteSpace(fullName))
            throw new ArgumentException(
                "Name is required.",
                nameof(fullName));

        if (string.IsNullOrWhiteSpace(email))
            throw new ArgumentException(
                "Email is required.",
                nameof(email));

        Customer customer = new()
        {
            FullName = fullName.Trim(),
            Email = email.Trim(),
            Phone = phone?.Trim(),
            CreatedAt = DateTime.UtcNow
        };

        return await _repository.CreateAsync(customer);
    }
}

هذا فصل مفيد جدًا لأن قواعد العمل لا ينبغي أن تصبح مخلوطة مع استعلامات SQL.

ربط ASP.NET Core Web API مع SQL Server

هذا من أشهر السيناريوهات العملية.

في Program.cs:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();

builder.Services.AddDbContext<AppDbContext>(options =>
{
    options.UseSqlServer(
        builder.Configuration.GetConnectionString(
            "DefaultConnection"
        )
    );
});

var app = builder.Build();

app.MapControllers();

app.Run();

ثم Controller:

[ApiController]
[Route("api/customers")]
public class CustomersController : ControllerBase
{
    private readonly AppDbContext _db;

    public CustomersController(AppDbContext db)
    {
        _db = db;
    }

    [HttpGet]
    public async Task<ActionResult> GetAll()
    {
        var customers = await _db.Customers
            .AsNoTracking()
            .OrderBy(x => x.FullName)
            .ToListAsync();

        return Ok(customers);
    }

    [HttpGet("{id:int}")]
    public async Task<ActionResult> GetById(int id)
    {
        var customer = await _db.Customers
            .AsNoTracking()
            .FirstOrDefaultAsync(x => x.Id == id);

        if (customer is null)
            return NotFound();

        return Ok(customer);
    }
}

في مشروع حقيقي ستفكر في DTOs وValidation وAuthentication وAuthorization وLogging وException Handling وPagination وغيرها، لكن هذا المثال يوضح المسار الأساسي من HTTP إلى EF Core إلى SQL Server.

DTOs وعدم كشف Entities مباشرة

ليس من الضروري أن ترجع Entity نفسها إلى العميل.

بدل:

return Ok(customers);

يمكن تعريف:

public class CustomerDto
{
    public int Id { get; set; }
    public string FullName { get; set; } = string.Empty;
    public string Email { get; set; } = string.Empty;
}

ثم:

var customers = await _db.Customers
    .AsNoTracking()
    .Select(x => new CustomerDto
    {
        Id = x.Id,
        FullName = x.FullName,
        Email = x.Email
    })
    .ToListAsync();

return Ok(customers);

هذا يفصل شكل API عن تصميم قاعدة البيانات، وهو شيء مهم جدًا عندما يكبر المشروع.

Pagination مع SQL Server

إرجاع جميع العملاء في جدول يحتوي على ملايين السجلات فكرة سيئة.

يمكن في EF Core استخدام:

int page = 1;
int pageSize = 20;

var customers = await _db.Customers
    .AsNoTracking()
    .OrderBy(x => x.Id)
    .Skip((page - 1) * pageSize)
    .Take(pageSize)
    .Select(x => new CustomerDto
    {
        Id = x.Id,
        FullName = x.FullName,
        Email = x.Email
    })
    .ToListAsync();

لكن pagination تحتاج إلى تصميم جيد وفهرس مناسب. في الأحجام الكبيرة جدًا قد يصبح Keyset Pagination أكثر ملاءمة من Skip/Take.

مثال:

var customers = await _db.Customers
    .AsNoTracking()
    .Where(x => x.Id > lastId)
    .OrderBy(x => x.Id)
    .Take(20)
    .ToListAsync();

الفكرة أنك تقول "أعطني أول 20 بعد المعرف 5000" بدل أن تقول "تخط 5000 صف".

الفهارس Indexes

الاستعلام الجيد قد يصبح بطيئًا جدًا إذا لم توجد فهارس مناسبة.

مثلًا:

CREATE INDEX IX_Customers_Email
ON Customers (Email);

أو:

CREATE INDEX IX_Customers_FullName
ON Customers (FullName);

لكن إضافة فهارس لكل شيء ليست حلًا سحريًا. الفهرس يستهلك مساحة وله تكلفة عند INSERT وUPDATE وDELETE.

لذلك يجب أن يعتمد تصميم الفهارس على نمط الوصول إلى البيانات.

إذا كان لديك استعلام:

SELECT Id, FullName, Email
FROM Customers
WHERE Email = @Email;

فإن فهرسًا على Email غالبًا يكون مفيدًا.

أما إذا كان الاستعلام:

SELECT Id, FullName, Email
FROM Customers
WHERE FullName = @FullName
ORDER BY CreatedAt DESC;

فقد تحتاج إلى التفكير في Index مركب بحسب حجم البيانات ونمط الاستعلام.

قراءة خطة التنفيذ Execution Plan

عندما يصبح استعلام SQL بطيئًا، لا تبدأ بتغيير الكود عشوائيًا.

افتح Execution Plan في أدوات SQL Server وشاهد كيف ينفذ SQL Server الاستعلام. قد تجد Table Scan أو Index Scan أو Key Lookup أو Sort مكلفًا أو Join غير مناسب.

في المشاريع الكبيرة، فهم خطة التنفيذ يعطيك فرقًا هائلًا في تحسين الأداء.

المطور الجيد ليس فقط من يعرف كتابة SELECT، بل من يعرف أيضًا كيف يفسر سبب بطء هذا SELECT.

مشكلة N+1

من أشهر مشاكل ORM.

تخيل:

var orders = await db.Orders.ToListAsync();

foreach (var order in orders)
{
    Console.WriteLine(order.Customer.FullName);
}

بحسب الإعداد، قد ينتج عن ذلك استعلام للطلبات ثم استعلام منفصل لكل عميل إذا لم تتم إدارة التحميل بطريقة صحيحة.

من الأفضل استخدام Projection:

var orders = await db.Orders
    .Select(x => new
    {
        x.Id,
        CustomerName = x.Customer.FullName
    })
    .ToListAsync();

أو Include عندما يناسب السيناريو.

الهدف ليس حفظ كلمة N+1 فقط، بل التعلم على مراقبة عدد الاستعلامات التي يرسلها التطبيق.

تسجيل SQL أثناء التطوير

عند استخدام EF Core، يمكن تفعيل Logging مناسب لرؤية الاستعلامات.

مثلًا في بيئة التطوير:

builder.Services.AddDbContext<AppDbContext>(options =>
{
    options
        .UseSqlServer(connectionString)
        .EnableDetailedErrors()
        .EnableSensitiveDataLogging();
});

لكن انتبه جدًا إلى EnableSensitiveDataLogging(). هذه الإعدادات مناسبة عند الحاجة في التطوير، ولا ينبغي تشغيلها بلا تفكير في الإنتاج، لأن السجلات قد تحتوي على بيانات حساسة.

التعامل مع Exceptions

يمكن التقاط SqlException في طبقات مناسبة:

try
{
    await connection.OpenAsync();
    await command.ExecuteNonQueryAsync();
}
catch (SqlException ex)
{
    Console.WriteLine(
        $"SQL Error Number: {ex.Number}"
    );

    Console.WriteLine(ex.Message);
}

لكن لا تجعل كل طبقة في النظام تلتقط الاستثناء ثم تخفيه.

هذا سيؤدي إلى كود مثل:

catch
{
    return null;
}

وهو سيئ جدًا لأنه يخفي السبب الحقيقي للمشكلة.

في التطبيقات الاحترافية، من الأفضل تسجيل الاستثناء وتركه يصعد إلى نقطة معالجة مناسبة، أو تحويله إلى Exception خاصة بالمجال إذا كان ذلك مفيدًا.

إعادة المحاولة Retry

في البيئات الموزعة أو بعض البيئات السحابية، قد تحدث أخطاء مؤقتة في الاتصال. لذلك قد تحتاج إلى استراتيجية Retry.

مع EF Core يمكن استخدام سياسات إعادة المحاولة حسب السيناريو والإصدار والبنية المستخدمة.

لكن لا تحاول إعادة تنفيذ كل شيء بلا تفكير.

هذا الكود:

await transaction.CommitAsync();

له طبيعة مختلفة عن استعلام قراءة عادي. إذا أعدت تنفيذ عملية كتابة دون التأكد من أنها آمنة للتكرار، فقد تنشئ بيانات مكررة.

لذلك Retry يحتاج إلى فهم Idempotency.

Idempotency

لنفترض API لإنشاء طلب جديد. العميل أرسل:

POST /api/orders

ثم حدث انقطاع أثناء الاستجابة، فأعاد إرسال نفس الطلب.

إذا لم يكن لديك Idempotency Key أو منطق يحمي العملية، قد يتم إنشاء طلبين.

يمكن استخدام مفتاح فريد للعملية:

CREATE TABLE Orders
(
    Id INT IDENTITY PRIMARY KEY,
    CustomerId INT NOT NULL,
    IdempotencyKey NVARCHAR(100) NOT NULL,
    CreatedAt DATETIME2 NOT NULL,

    CONSTRAINT UQ_Orders_IdempotencyKey
        UNIQUE (IdempotencyKey)
);

هذا مثال على كيف أن تصميم قاعدة البيانات نفسه يمكن أن يساهم في منع أخطاء التطبيق.

الحماية من SQL Injection

لنكرر هذه النقطة لأنها من أهم نقاط الأمان.

لا تفعل:

string sql =
    "SELECT * FROM Customers WHERE Email = '" +
    email +
    "'";

افعل:

const string sql = """
    SELECT *
    FROM Customers
    WHERE Email = @Email
    """;

command.Parameters.Add(
    "@Email",
    SqlDbType.NVarChar,
    200
).Value = email;

الفكرة ليست فقط أن SQL Server "يفهم Parameters"، بل أن البيانات تصبح منفصلة عن بنية SQL.

وحتى مع Dapper أو EF Core، استمر باستخدام الطرق المعتمدة للـ Parameters ولا تبنِ SQL نصيًا من مدخلات المستخدم.

كلمات المرور في SQL Server

إذا كان تطبيقك يملك نظام تسجيل دخول، لا تخزن كلمات المرور كنص صريح في SQL Server.

لا تكتب:

password = "123456"

ولا حتى:

password = SHA256(password)

بشكل بسيط دون فهم تصميم تخزين كلمات المرور.

يجب استخدام آلية مخصصة لتخزين كلمات المرور مثل Password Hashing المعياري في ASP.NET Core Identity أو خوارزمية مناسبة باستخدام Salt وWork Factor.

وبشكل عام، لا ينبغي أن يكون SQL Server هو المكان الذي توجد فيه كلمات المرور الخام للمستخدمين.

أقل صلاحيات ممكنة Least Privilege

حساب قاعدة البيانات الذي يستخدمه تطبيقك لا يحتاج بالضرورة إلى صلاحيات db_owner.

هذه من أسوأ الممارسات الشائعة:

ApplicationUser -> db_owner

إذا تم اختراق التطبيق، فإن المهاجم قد يحصل على قدرات هائلة.

الأفضل أن تمنح هوية التطبيق فقط الصلاحيات المطلوبة، مثل:

GRANT SELECT, INSERT, UPDATE, DELETE
ON dbo.Customers
TO AppUser;

وقد تحتاج إلى تصميم أكثر دقة بحسب الإجراءات والجداول.

في بعض الأنظمة يمكن أن يكون التطبيق مسموحًا له باستدعاء Stored Procedures فقط بدل السماح له بكل شيء.

حماية Connection String

لا تضع:

string password = "SuperSecret123";

داخل Repository.

ولا ترفع:

{
  "ConnectionStrings": {
    "DefaultConnection": "Server=prod;User Id=admin;Password=secret;"
  }
}

إلى Git.

استخدم Secrets أو Environment Variables أو Secret Store مناسبًا للبنية التحتية.

وفي التطوير المحلي يمكنك استخدام User Secrets في ASP.NET Core.

مثلًا:

dotnet user-secrets init
dotnet user-secrets set "ConnectionStrings:DefaultConnection" "..."

المبدأ بسيط: الكود لا ينبغي أن يعرف أسرار الإنتاج.

Connection Pooling

عندما تستدعي:

connection.Open();

قد يبدو أن كل طلب ينشئ اتصالًا جديدًا بالكامل. لكن مزود SQL Client يستخدم Connection Pooling افتراضيًا في الحالات المعتادة.

هذا يعني أن إغلاق الـ Connection باستخدام Dispose لا يعني بالضرورة إغلاق اتصال TCP بالكامل في كل مرة؛ بل يمكن إعادة استخدام الاتصال من الـ pool.

لذلك لا تخف من النمط:

await using SqlConnection connection = new(connectionString);
await connection.OpenAsync();

في كل عملية.

هذا جزء من التصميم الطبيعي للـ ADO.NET.

الأهم هو ألا تحتفظ بالاتصال مفتوحًا أطول من اللازم.

لا تستخدم اتصالًا واحدًا مشتركًا بين كل الطلبات

من الخطأ إنشاء:

public static SqlConnection Connection;

ثم استخدامه من جميع الطلبات في تطبيق ويب متعدد الخيوط.

هذا يسبب مشاكل في التزامن وإدارة الحالة.

اتصالات SQL Client مصممة بحيث تحصل على Connection عند الحاجة وتحررها بعد انتهاء العملية، مع الاستفادة من Pooling.

إدارة DbContext في EF Core

في ASP.NET Core، يتم عادة تسجيل:

builder.Services.AddDbContext<AppDbContext>(...);

وهذا يجعل DbContext Scoped في السيناريو المعتاد.

أي أن كل HTTP Request يحصل على Context مناسب لدورة الطلب.

لا تجعل DbContext Singleton بشكل عشوائي، لأن DbContext ليس مصممًا ليكون كائنًا مشتركًا عالميًا بين الطلبات.

فصل بنية المشروع

من البنى الشائعة:

MyApp/
├── Api/
├── Application/
├── Domain/
├── Infrastructure/
└── Tests/

يمكن أن تحتوي:

Domain على الكيانات وقواعد المجال الأساسية.

Application على Services وUse Cases وDTOs وInterfaces.

Infrastructure على EF Core وDapper وRepositories والوصول الفعلي إلى SQL Server.

Api على Controllers وEndpoints وAuthentication وغيرها.

هذا ليس قانونًا مقدسًا، لكنه يمنع مشروعك من التحول إلى مجلد واحد يحتوي على كل شيء.

مثال على Dependency Injection

يمكن تسجيل Repository:

builder.Services.AddScoped<ICustomerRepository, CustomerRepository>();
builder.Services.AddScoped<CustomerService>();

ثم:

[ApiController]
[Route("api/customers")]
public class CustomersController : ControllerBase
{
    private readonly CustomerService _service;

    public CustomersController(CustomerService service)
    {
        _service = service;
    }
}

بهذا يصبح Controller مسؤولًا عن HTTP فقط تقريبًا، بينما Service يدير منطق العمل وRepository يتعامل مع قاعدة البيانات.

Validation قبل إرسال البيانات إلى SQL Server

لا تعتمد على SQL Server وحده للتحقق من صحة البيانات.

يمكن مثلًا باستخدام DataAnnotations:

public class CreateCustomerRequest
{
    [Required]
    [StringLength(150)]
    public string FullName { get; set; } = string.Empty;

    [Required]
    [EmailAddress]
    [StringLength(200)]
    public string Email { get; set; } = string.Empty;

    [StringLength(30)]
    public string? Phone { get; set; }
}

ثم في ASP.NET Core:

[HttpPost]
public async Task<ActionResult> Create(
    CreateCustomerRequest request)
{
    // validation handled by ASP.NET Core validation pipeline

    // create customer...
    return Ok();
}

هذا يجعل الأخطاء تعود للمستخدم بطريقة واضحة بدل إرسال بيانات غير صالحة إلى قاعدة البيانات.

Unique Constraints

لنفترض أن البريد الإلكتروني يجب أن يكون فريدًا.

يمكن وضع شرط في SQL Server:

CREATE UNIQUE INDEX UX_Customers_Email
ON Customers (Email);

لا تعتمد فقط على:

if (await db.Customers.AnyAsync(x => x.Email == email))
{
    // create
}

لأن طلبين متزامنين قد ينجحان في فحص AnyAsync في الوقت نفسه ثم يحاولان الإدخال.

القيد في قاعدة البيانات هو الحماية النهائية.

التحقق من نتيجة UPDATE

هذا النمط مهم:

int rows = await connection.ExecuteAsync(
    sql,
    customer
);

if (rows == 0)
{
    throw new KeyNotFoundException(
        "Customer was not found."
    );
}

لا تفترض أن UPDATE نجح لمجرد أن SQL لم يرمِ Exception.

قد ينفذ الاستعلام بنجاح لكنه لا يطابق أي سجل.

Soft Delete

بدل:

DELETE FROM Customers WHERE Id = @Id

يمكن إضافة:

ALTER TABLE Customers
ADD IsDeleted BIT NOT NULL DEFAULT 0;

ثم:

UPDATE Customers
SET IsDeleted = 1
WHERE Id = @Id;

ويصبح الاستعلام:

SELECT Id, FullName, Email
FROM Customers
WHERE IsDeleted = 0;

في EF Core يمكن إضافة Global Query Filter:

modelBuilder.Entity<Customer>()
    .HasQueryFilter(x => !x.IsDeleted);

لكن Soft Delete ليس مناسبًا لكل نظام. أحيانًا تكون الحاجة القانونية أو التشغيلية هي الحذف الفعلي.

Audit Fields

من المفيد وجود:

CreatedAt
CreatedBy
UpdatedAt
UpdatedBy

في الأنظمة الإدارية.

مثال:

ALTER TABLE Customers
ADD UpdatedAt DATETIME2 NULL,
    UpdatedBy NVARCHAR(100) NULL;

ثم تسجل التعديلات.

هذا يساعد جدًا عندما يقول المستخدم: "من غيّر البريد الإلكتروني؟"

والخبرة العملية هنا أن المطورين غالبًا لا يهتمون بالتدقيق Audit في بداية المشروع، ثم يكتشفون بعد أشهر أنهم يتمنون لو سجلوه منذ اليوم الأول.

Logging

لا يكفي تسجيل:

Something went wrong.

أفضل:

_logger.LogError(
    ex,
    "Failed to update customer {CustomerId}",
    customerId
);

بهذا يصبح من السهل ربط المشكلة بالسجل المحدد.

لكن لا تسجل كلمات المرور أو Connection Strings أو بيانات حساسة بلا داعٍ.

مراقبة أداء SQL

إذا كان Endpoint:

GET /api/customers

يستغرق 5 ثوانٍ، لا تفترض أن المشكلة في ASP.NET.

قد يكون SQL يستغرق 4.8 ثوانٍ.

راقب:

  • عدد الاستعلامات.

  • زمن كل استعلام.

  • Execution Plan.

  • حجم النتائج.

  • الفهارس.

  • عمليات Join.

  • عمليات Sort.

  • Locking.

  • Blocking.

التطبيق وقاعدة البيانات نظام واحد من منظور الأداء.

Async ليس علاجًا لكل مشاكل الأداء

هذه نقطة تستحق التوضيح.

تحويل:

connection.Open();

إلى:

await connection.OpenAsync();

لا يعني أن الاستعلام أصبح أسرع.

Async يساعد في استغلال موارد الخادم بشكل أفضل أثناء انتظار عمليات I/O، لكنه لا يصلح SQL سيئًا.

إذا كان الاستعلام يحتاج إلى 12 ثانية بسبب Missing Index، فسيظل يحتاج وقتًا كبيرًا.

Command Timeout

يمكن التحكم في مهلة تنفيذ الأمر:

command.CommandTimeout = 60;

لكن لا تجعل الحل دائمًا:

command.CommandTimeout = 600;

إذا كان الاستعلام بطيئًا، أصلح السبب بدل إخفاء المشكلة برفع الـ timeout.

قد تكون هناك تقارير تحتاج إلى وقت طويل فعلًا، وفي هذه الحالة يمكن أن يكون Timeout أكبر منطقيًا حسب السيناريو.

CancellationToken

في ASP.NET Core، إذا أغلق المستخدم الصفحة أو انتهى الطلب، يمكن تمرير CancellationToken.

مثال مع EF Core:

public async Task<List<CustomerDto>> GetCustomersAsync(
    CancellationToken cancellationToken)
{
    return await _db.Customers
        .AsNoTracking()
        .Select(x => new CustomerDto
        {
            Id = x.Id,
            FullName = x.FullName,
            Email = x.Email
        })
        .ToListAsync(cancellationToken);
}

وفي Controller:

[HttpGet]
public async Task<ActionResult> Get(
    CancellationToken cancellationToken)
{
    var result = await service.GetCustomersAsync(
        cancellationToken
    );

    return Ok(result);
}

هذا مفيد للطلبات الطويلة، خصوصًا عندما لا توجد قيمة في إكمال العمل بعد إلغاء العميل للطلب.

التعامل مع البيانات الكبيرة

ماذا لو كان جدول Customers يحتوي على 10 ملايين صف؟

لا تفعل:

var all = await db.Customers.ToListAsync();

إلا إذا كان هذا ما تحتاجه فعلًا.

فكر في:

  • Pagination.

  • Filtering.

  • Projection.

  • Streaming في حالات مناسبة.

  • Batch Processing.

  • Background Jobs.

  • SQL Aggregation بدل نقل بيانات ضخمة إلى التطبيق.

مثال:

var statistics = await db.Customers
    .GroupBy(x => x.CreatedAt.Date)
    .Select(x => new
    {
        Date = x.Key,
        Count = x.Count()
    })
    .OrderBy(x => x.Date)
    .ToListAsync();

هنا يتم الحساب على مستوى SQL قدر الإمكان بدل تحميل كل عميل إلى الذاكرة.

Bulk Operations

عند إدخال آلاف السجلات، تنفيذ:

INSERT ...

واحدًا تلو الآخر قد يكون بطيئًا.

يمكن التفكير في SQL Server bulk loading أو أساليب متخصصة حسب الأداة المستخدمة.

في ADO.NET يوجد SqlBulkCopy:

using Microsoft.Data.SqlClient;

using SqlBulkCopy bulkCopy =
    new SqlBulkCopy(connectionString);

bulkCopy.DestinationTableName = "Customers";

bulkCopy.ColumnMappings.Add(
    "FullName",
    "FullName"
);

bulkCopy.ColumnMappings.Add(
    "Email",
    "Email"
);

bulkCopy.ColumnMappings.Add(
    "Phone",
    "Phone"
);

bulkCopy.WriteToServer(dataTable);

هذا مفيد في عمليات الاستيراد الكبيرة.

DataTable

في بعض عمليات الاستيراد، يمكن تجهيز DataTable:

DataTable table = new();

table.Columns.Add("FullName", typeof(string));
table.Columns.Add("Email", typeof(string));
table.Columns.Add("Phone", typeof(string));

table.Rows.Add(
    "أسماء علي",
    "asma@example.com",
    "0688888888"
);

table.Rows.Add(
    "خالد يوسف",
    "khaled@example.com",
    "0699999999"
);

ثم إرسالها باستخدام SqlBulkCopy.

لكن لاحظ أن DataTable تعتمد على الذاكرة، لذلك في البيانات الضخمة جدًا يجب التفكير في Streaming أو معالجة على دفعات.

Table-Valued Parameters

إذا كنت تريد تمرير مجموعة سجلات إلى Stored Procedure، يمكن استخدام Table-Valued Parameter.

تعريف Type في SQL Server:

CREATE TYPE dbo.CustomerTableType AS TABLE
(
    FullName NVARCHAR(150),
    Email NVARCHAR(200),
    Phone NVARCHAR(30)
);
GO

ثم Stored Procedure:

CREATE PROCEDURE InsertCustomers
    @Customers dbo.CustomerTableType READONLY
AS
BEGIN
    SET NOCOUNT ON;

    INSERT INTO Customers
        (FullName, Email, Phone)
    SELECT
        FullName,
        Email,
        Phone
    FROM @Customers;
END;
GO

يمكن استخدام TVP عندما تحتاج إلى إرسال مجموعة بيانات منظمة بين التطبيق وقاعدة البيانات.

التعامل مع DateTime

من الأخطاء الشائعة عدم التفكير في المناطق الزمنية.

في أغلب الأنظمة الخلفية، تخزين الوقت بصيغة UTC يساعد على توحيد البيانات:

DateTime.UtcNow

وفي SQL Server يمكن استخدام:

SYSUTCDATETIME()

ثم تحويل الوقت إلى المنطقة الزمنية المحلية عند العرض.

إذا كان نظامك عالميًا، هذه النقطة تصبح مهمة جدًا. موعد المستخدم في الدار البيضاء ليس بالضرورة نفس التوقيت الذي يظهر لمستخدم في منطقة أخرى.

Decimal والمال

عند التعامل مع الأسعار، لا تستخدم double بشكل عشوائي.

في C#:

decimal price = 149.95m;

وفي SQL Server:

Price DECIMAL(18, 2)

يمكنك ربطه:

command.Parameters.Add(
    "@Price",
    SqlDbType.Decimal
).Value = price;

ويُفضل ضبط Precision وScale حسب السيناريو.

GUID مقابل INT

يمكن أن تستخدم:

Id INT IDENTITY PRIMARY KEY

أو:

Id UNIQUEIDENTIFIER NOT NULL
    DEFAULT NEWSEQUENTIALID()
    PRIMARY KEY

كل تصميم له إيجابيات وسلبيات.

INT صغير وسهل وفعال جدًا كمفتاح داخلي في كثير من الحالات.

GUID مفيد أحيانًا عندما تريد معرفات يمكن توليدها خارج قاعدة البيانات أو استخدامها في أنظمة موزعة.

لكن GUID العشوائي تمامًا قد يؤثر في ترتيب صفحات الفهرس، لذلك تظهر تقنيات مثل Sequential GUID.

لا يوجد "أفضل نوع دائمًا". الاختيار يعتمد على طبيعة النظام.

العلاقات والمفاتيح الخارجية

مثال:

CREATE TABLE Orders
(
    Id INT IDENTITY(1,1) PRIMARY KEY,
    CustomerId INT NOT NULL,
    CreatedAt DATETIME2 NOT NULL,

    CONSTRAINT FK_Orders_Customers
        FOREIGN KEY (CustomerId)
        REFERENCES Customers(Id)
);

المفتاح الخارجي يمنع إنشاء طلب يشير إلى عميل غير موجود، بحسب القيود والسياسة التي تستخدمها.

وهنا نرى شيئًا مهمًا: قواعد البيانات ليست مجرد Storage. قاعدة البيانات أيضًا مكان لفرض Integrity.

لا تضع كل Business Rules في SQL

قد يبدو من المغري وضع كل المنطق داخل Stored Procedures وTriggers.

لكن إذا أصبح المشروع يعتمد على مئات الإجراءات والمشغلات دون توثيق، فسيصبح من الصعب جدًا فهم النظام.

في المقابل، وضع كل شيء في C# أيضًا ليس دائمًا جيدًا.

أفضل توازن هو أن تعرف ما الذي يجب أن يكون:

  • Constraint في قاعدة البيانات.

  • Business Rule في Service.

  • Query في Repository.

  • Aggregation في SQL عندما يكون أكثر كفاءة.

  • Validation في API.

  • Transaction في وحدة العمل المناسبة.

Triggers

يمكن لـ SQL Server تنفيذ Trigger مثل:

CREATE TRIGGER TR_Customers_AfterInsert
ON Customers
AFTER INSERT
AS
BEGIN
    SET NOCOUNT ON;

    -- audit logic
END;

Triggers مفيدة في بعض السيناريوهات، لكنها قد تضيف سلوكًا مخفيًا يجعل تصحيح الأخطاء أصعب.

عندما ينفذ المطور:

INSERT INTO Customers ...

قد لا يتوقع أن Trigger سيقوم بتحديث جدول آخر.

لذلك استخدمها عندما يكون هناك سبب واضح، ووثقها جيدًا.

Deadlocks

مع تعدد المستخدمين، قد تواجه Deadlock.

تخيل معاملة أولى تقفل صفًا A وتنتظر B، ومعاملة ثانية تقفل B وتنتظر A.

SQL Server قد ينهي إحدى المعاملتين لكسر الحلقة.

إذا واجهت ذلك، لا تضع Retry فقط بلا تحليل.

يجب معرفة سبب ترتيب الأقفال، ومحاولة توحيد ترتيب الوصول إلى الموارد وتقليل زمن المعاملة وتحسين الاستعلامات والفهارس.

Optimistic Concurrency

في كثير من تطبيقات الويب لا نريد قفل الصف لفترة طويلة بينما المستخدم يفكر.

بدل ذلك نستخدم Optimistic Concurrency باستخدام rowversion.

في EF Core يمكن استخدام:

[Timestamp]
public byte[] VersionStamp { get; set; } = [];

وعند حدوث تعارض، يمكن لـ EF Core اكتشاف أن الصف تغير.

هذه التقنية تناسب الشاشات الإدارية التي تفتح السجل لفترة ثم تحفظه لاحقًا.

Migrations أم Database-First؟

في EF Core هناك أكثر من أسلوب.

في Code First، يكون نموذج C# هو المصدر الأساسي ثم تتولد migrations.

في Database First، تكون قاعدة البيانات هي المصدر الأساسي ويمكن توليد Models منها.

لا يوجد خيار عالمي صحيح.

إذا كانت قاعدة البيانات موجودة مسبقًا وتدار من فريق DBA، Database First قد يكون أكثر منطقية.

إذا كان المشروع جديدًا ويُدار التطوير فيه من خلال C# وEF Core، Code First قد يكون مناسبًا جدًا.

Reverse Engineering

يمكن توليد DbContext والنماذج من SQL Server باستخدام EF Core Tools.

مثلًا:

dotnet ef dbcontext scaffold \
"Server=localhost;Database=CompanyDb;Trusted_Connection=True;TrustServerCertificate=True;" \
Microsoft.EntityFrameworkCore.SqlServer

والنتيجة ستكون Classes تمثل الجداول وDbContext.

لكن بعد التوليد، من المهم فهم الكود الناتج وعدم التعامل معه ككتلة مجهولة.

التعامل مع SQL Server في Docker

في بعض بيئات التطوير، يمكن تشغيل SQL Server داخل Docker.

مثال مبسط:

docker run \
  -e "ACCEPT_EULA=Y" \
  -e "MSSQL_SA_PASSWORD=YourStrongPasswordHere" \
  -p 1433:1433 \
  --name sqlserver \
  -d mcr.microsoft.com/mssql/server

بعد ذلك يمكن للتطبيق الاتصال بالمنفذ المناسب حسب البيئة.

لكن عند استخدام Docker Compose، قد يكون اسم الخدمة هو اسم المضيف بدل localhost.

مثلًا:

services:
  sqlserver:
    image: mcr.microsoft.com/mssql/server
    environment:
      ACCEPT_EULA: "Y"
      MSSQL_SA_PASSWORD: "YourStrongPasswordHere"
    ports:
      - "1433:1433"

من داخل Container آخر، قد يكون الاتصال:

Server=sqlserver;Database=CompanyDb;...

بدل:

Server=localhost;...

وهذا فرق صغير لكنه يسبب كثيرًا من المشاكل للمطورين عند الانتقال من تشغيل محلي إلى Compose.

Health Checks

في ASP.NET Core يمكن إضافة Health Check لقاعدة البيانات.

الفكرة هي أن التطبيق يستطيع إخبار منصة النشر ما إذا كانت قاعدة البيانات متاحة.

مثلًا:

builder.Services
    .AddHealthChecks()
    .AddSqlServer(
        builder.Configuration.GetConnectionString(
            "DefaultConnection"
        )!
    );

ثم:

app.MapHealthChecks("/health");

هذا مفيد في البيئات التي تستخدم Container Orchestration أو Load Balancing.

الاختبارات

طبقة الوصول إلى البيانات تحتاج إلى اختبارات أيضًا.

يمكن مثلًا اختبار Service باستخدام Mock للـ Repository:

[Fact]
public async Task RegisterAsync_ShouldCreateCustomer()
{
    var repository =
        new Mock<ICustomerRepository>();

    repository
        .Setup(x => x.CreateAsync(
            It.IsAny<Customer>()
        ))
        .ReturnsAsync(42);

    var service =
        new CustomerService(repository.Object);

    int id = await service.RegisterAsync(
        "Test User",
        "test@example.com",
        null
    );

    Assert.Equal(42, id);
}

لكن الاختبار الحقيقي لـ SQL لا يمكن أن يعتمد كله على Mock.

من المفيد وجود Integration Tests تختبر SQL Server فعلًا.

Integration Testing

يمكن تشغيل SQL Server في بيئة اختبار، مثل Container، ثم تشغيل الاختبارات ضد قاعدة حقيقية.

الفائدة هي اكتشاف مشاكل مثل:

  • SQL خاطئ.

  • Migration مكسورة.

  • Constraint غير متوقع.

  • Type mismatch.

  • اختلاف في سلوك SQL Server لا يظهر في Mock.

وهذا مهم جدًا لأن Mock قد يخبرك أن method تُستدعى، لكنه لا يخبرك أن الاستعلام فعليًا صالح للتنفيذ.

استخدام LocalDB

في بعض بيئات Windows development يمكن استخدام SQL Server LocalDB للاختبارات أو التطوير المحلي.

Connection String يمكن أن تكون مثل:

Server=(localdb)\MSSQLLocalDB;Database=CompanyDb;Integrated Security=True;

هذا مريح في بعض حالات التطوير المحلي، لكنه ليس بديلًا عن اختبار البيئة الفعلية التي سيعمل عليها التطبيق.

مشاكل SSL والشهادات

قد ترى خطأ يتعلق بالثقة في شهادة الخادم، خصوصًا في التطوير المحلي.

قد تستخدم:

TrustServerCertificate=True;

لأغراض التطوير، لكن في الإنتاج لا ينبغي أن يكون الحل التلقائي هو تعطيل التحقق أو قبول أي شهادة دون فهم.

في بيئة إنتاجية حقيقية، يجب تكوين TLS والشهادات بطريقة مناسبة.

حالات فشل الاتصال الشائعة

عندما تظهر رسالة مثل:

A network-related or instance-specific error

ابدأ بالأسئلة الأساسية:

هل SQL Server يعمل؟

هل اسم السيرفر صحيح؟

هل الـ Instance صحيحة؟

هل المنفذ مفتوح؟

هل Database موجودة؟

هل Authentication صحيح؟

هل الـ firewall يسمح بالاتصال؟

هل التطبيق داخل Container؟

هل Connection String تناسب البيئة الحالية؟

هل الشهادة تسبب خطأ TLS؟

غالبًا حل المشكلة يصبح أسهل عندما تتعامل مع التشخيص كطبقات بدل تغيير عشر أشياء في وقت واحد.

مشكلة localhost داخل Docker

هذه نقطة أود التأكيد عليها لأنها سببت لي ولغيري من المطورين ساعات من التفكير في مشاكل "غريبة".

إذا كان تطبيقك داخل Container وكتبت:

Server=localhost

فأنت تقول "SQL Server موجود داخل نفس الـ Container".

إذا كان SQL Server في Container آخر، فإن localhost ليس الاسم الذي تحتاجه. يجب استخدام اسم الخدمة في شبكة Docker.

أي:

Server=sqlserver

إذا كان اسم الخدمة sqlserver.

استخدام Environment Variables

يمكن مثلًا:

ConnectionStrings__DefaultConnection=Server=sqlserver;Database=CompanyDb;...

ثم في ASP.NET Core:

string? connectionString =
    builder.Configuration.GetConnectionString(
        "DefaultConnection"
    );

نظام Configuration يدمج مصادر الإعداد المختلفة حسب ترتيبها وأولوية القيم.

هذا يجعل نشر التطبيق أكثر مرونة.

تطبيق كامل بسيط باستخدام ADO.NET

لنكتب مثالًا صغيرًا يجمع الأفكار.

using Microsoft.Data.SqlClient;
using System.Data;

public sealed class CustomerRepository
{
    private readonly string _connectionString;

    public CustomerRepository(string connectionString)
    {
        _connectionString = connectionString;
    }

    public async Task<List<Customer>> GetAllAsync(
        CancellationToken cancellationToken = default)
    {
        const string sql = """
            SELECT
                Id,
                FullName,
                Email,
                Phone,
                CreatedAt
            FROM Customers
            WHERE IsDeleted = 0
            ORDER BY Id DESC
            """;

        var result = new List<Customer>();

        await using SqlConnection connection =
            new(_connectionString);

        await using SqlCommand command =
            new(sql, connection);

        await connection.OpenAsync(cancellationToken);

        await using SqlDataReader reader =
            await command.ExecuteReaderAsync(
                cancellationToken
            );

        int idOrdinal = reader.GetOrdinal("Id");
        int nameOrdinal = reader.GetOrdinal("FullName");
        int emailOrdinal = reader.GetOrdinal("Email");
        int phoneOrdinal = reader.GetOrdinal("Phone");
        int createdOrdinal = reader.GetOrdinal("CreatedAt");

        while (await reader.ReadAsync(cancellationToken))
        {
            result.Add(new Customer
            {
                Id = reader.GetInt32(idOrdinal),
                FullName = reader.GetString(nameOrdinal),
                Email = reader.GetString(emailOrdinal),
                Phone = reader.IsDBNull(phoneOrdinal)
                    ? null
                    : reader.GetString(phoneOrdinal),
                CreatedAt = reader.GetDateTime(createdOrdinal)
            });
        }

        return result;
    }

    public async Task<int> CreateAsync(
        Customer customer,
        CancellationToken cancellationToken = default)
    {
        const string sql = """
            INSERT INTO Customers
                (FullName, Email, Phone, CreatedAt)
            OUTPUT INSERTED.Id
            VALUES
                (@FullName, @Email, @Phone, @CreatedAt)
            """;

        await using SqlConnection connection =
            new(_connectionString);

        await using SqlCommand command =
            new(sql, connection);

        command.Parameters.Add(
            "@FullName",
            SqlDbType.NVarChar,
            150
        ).Value = customer.FullName;

        command.Parameters.Add(
            "@Email",
            SqlDbType.NVarChar,
            200
        ).Value = customer.Email;

        command.Parameters.Add(
            "@Phone",
            SqlDbType.NVarChar,
            30
        ).Value = (object?)customer.Phone ?? DBNull.Value;

        command.Parameters.Add(
            "@CreatedAt",
            SqlDbType.DateTime2
        ).Value = customer.CreatedAt;

        await connection.OpenAsync(cancellationToken);

        return await command.ExecuteScalarAsync(
            cancellationToken
        ) is int id
            ? id
            : throw new InvalidOperationException(
                "Failed to create customer."
            );
    }
}

هذا المثال صغير لكنه يتضمن أفكارًا مهمة جدًا: Parameters، Async، CancellationToken، Nullable handling، Disposal، Projection يدوي، والحفاظ على SQL منفصلًا نسبيًا عن بقية النظام.

مثال باستخدام Dapper بشكل أنظف

using Dapper;
using Microsoft.Data.SqlClient;

public sealed class CustomerRepository
{
    private readonly string _connectionString;

    public CustomerRepository(string connectionString)
    {
        _connectionString = connectionString;
    }

    public async Task<IReadOnlyList<Customer>> GetAllAsync(
        CancellationToken cancellationToken = default)
    {
        const string sql = """
            SELECT
                Id,
                FullName,
                Email,
                Phone,
                CreatedAt
            FROM Customers
            WHERE IsDeleted = 0
            ORDER BY Id DESC
            """;

        await using SqlConnection connection =
            new(_connectionString);

        var customers = await connection.QueryAsync<Customer>(
            new CommandDefinition(
                sql,
                cancellationToken: cancellationToken
            )
        );

        return customers.AsList();
    }

    public async Task<int> CreateAsync(
        Customer customer,
        CancellationToken cancellationToken = default)
    {
        const string sql = """
            INSERT INTO Customers
                (FullName, Email, Phone, CreatedAt)
            OUTPUT INSERTED.Id
            VALUES
                (@FullName, @Email, @Phone, @CreatedAt)
            """;

        await using SqlConnection connection =
            new(_connectionString);

        return await connection.ExecuteScalarAsync<int>(
            new CommandDefinition(
                sql,
                customer,
                cancellationToken: cancellationToken
            )
        );
    }
}

Dapper يقلل كمية الـ boilerplate مع الحفاظ على SQL واضح.

التعامل مع Concurrency في Repository

يمكن تمرير CancellationToken في كل العمليات غير المتزامنة:

public async Task<Customer?> GetByIdAsync(
    int id,
    CancellationToken cancellationToken)
{
    const string sql = """
        SELECT
            Id,
            FullName,
            Email,
            Phone,
            CreatedAt
        FROM Customers
        WHERE Id = @Id
        """;

    await using SqlConnection connection =
        new(_connectionString);

    return await connection.QuerySingleOrDefaultAsync<Customer>(
        new CommandDefinition(
            sql,
            new { Id = id },
            cancellationToken: cancellationToken
        )
    );
}

هذا الأسلوب يصبح مفيدًا جدًا عند بناء Web APIs.

Generic Repository: هل هو ضروري؟

ستجد كثيرًا من الأمثلة على الإنترنت تقترح:

IRepository<T>

مع:

GetAll()
GetById()
Add()
Update()
Delete()

هذا قد يبدو أنيقًا، لكنه ليس دائمًا أفضل تصميم.

لماذا؟

لأن Customer قد يحتاج إلى:

FindByEmail
Search
GetRecent
GetByPhone

بينما Order يحتاج إلى:

GetByCustomer
GetOpenOrders
GetWithItems

إذا حاولت جعل كل شيء Generic، قد ينتهي بك الأمر بتجريد غير طبيعي يخفي احتياجات المجال بدل أن يوضحها.

أحيانًا يكون Repository متخصصًا أكثر وضوحًا.

لماذا لا ينبغي تخزين SQL داخل Controllers؟

هذا:

[HttpGet]
public async Task<IActionResult> Get()
{
    using var connection = ...
    var sql = "SELECT ...";
    ...
}

يمكن أن يعمل.

لكن مع الوقت ستصبح Controller خليطًا من:

  • HTTP.

  • Validation.

  • SQL.

  • Transactions.

  • Logging.

  • Business Rules.

  • Mapping.

وبعدها يصبح اختبار الكود وصيانته أصعب.

الفصل ليس مجرد "Clean Architecture لأجل Clean Architecture"، بل محاولة للحفاظ على الحدود بين المسؤوليات.

متى تكون البساطة أفضل؟

من المهم أيضًا ألا نذهب إلى الطرف الآخر.

تطبيق صغير جدًا قد لا يحتاج:

Controller
Service
Repository
UnitOfWork
CQRS
MediatR
Handler
Specification
GenericRepository
...

لمجرد أن هذه الأدوات موجودة.

إذا كان لديك تطبيق صغير من 5 جداول، فإن إنشاء 15 طبقة قد يجعل المشروع أصعب بدل أن يجعله أفضل.

التصميم الجيد هو الذي يخدم المشكلة الحقيقية.

Unit of Work

في بعض التصاميم، يستخدم Unit of Work لتنسيق مجموعة من عمليات التعديل.

مع EF Core، يمكن أن يكون DbContext نفسه بمثابة Unit of Work إلى حد كبير، لأنك تضيف تعديلات متعددة ثم تستدعي:

await db.SaveChangesAsync();

وتحفظ التغييرات كوحدة.

لذلك لا تضف Unit of Work فوق EF Core إلا إذا كان لديه قيمة حقيقية في التصميم.

Transactions في EF Core

مثال:

await using var transaction =
    await db.Database.BeginTransactionAsync();

try
{
    var order = new Order
    {
        CustomerId = customerId,
        CreatedAt = DateTime.UtcNow
    };

    db.Orders.Add(order);

    await db.SaveChangesAsync();

    var item = new OrderItem
    {
        OrderId = order.Id,
        ProductId = productId,
        Quantity = quantity,
        UnitPrice = price
    };

    db.OrderItems.Add(item);

    await db.SaveChangesAsync();

    await transaction.CommitAsync();
}
catch
{
    await transaction.RollbackAsync();
    throw;
}

وفي كثير من العمليات البسيطة، يمكن أن يقوم SaveChangesAsync() بإدارة المعاملة المناسبة على مستوى التغيير الواحد، لكن عندما تكون لديك عدة خطوات مرتبطة أو أكثر من عملية Save، تصبح المعاملة الصريحة مفيدة.

الاستعلامات الخام في EF Core

قد توجد حالات يكون فيها SQL اليدوي هو الحل الأنسب.

يمكن استخدام:

var customers = await db.Customers
    .FromSqlRaw(
        """
        SELECT Id, FullName, Email, Phone, CreatedAt
        FROM Customers
        WHERE Email LIKE {0}
        """,
        "%example.com"
    )
    .ToListAsync();

لكن يجب الحذر من طرق تركيب SQL النصي. استخدم API التي تبقي Parameters منفصلة عندما يكون ذلك مناسبًا.

SQL Injection حتى مع ORM

من الأخطاء الشائعة الاعتقاد أن استخدام ORM يعني انتهاء مشاكل SQL Injection.

إذا صنعت SQL يدويًا بطريقة غير آمنة داخل ORM، يمكن أن تعود المشكلة.

القاعدة الذهبية ما زالت نفسها:

بيانات المستخدم يجب ألا تُدمج في SQL كنص خام بشكل غير آمن.

Security Headers ليست بديلًا عن Database Security

قد يكون لديك HTTPS وAuthentication وAuthorization، ومع ذلك تكون قاعدة البيانات مكشوفة عبر مستخدم DB بصلاحيات عالية جدًا.

الأمان متعدد الطبقات.

طبقة API تمنع المستخدم غير المصرح له.

طبقة Application تفرض Business Rules.

طبقة Database تفرض Constraints والصلاحيات.

طبقة الشبكة تتحكم بمن يستطيع الوصول إلى SQL Server.

طبقة الأسرار تحمي بيانات الاعتماد.

لا توجد طبقة واحدة تكفي وحدها.

إعداد SQL Server للإنتاج

في بيئة الإنتاج، من الأفضل التفكير في:

  • Authentication مناسب.

  • TLS.

  • Firewall.

  • Backup.

  • Restore Testing.

  • Monitoring.

  • Least Privilege.

  • Auditing.

  • Index Maintenance وفق الحاجة.

  • Capacity Planning.

  • Alerts.

  • Disaster Recovery.

وجود ملف .bak لا يعني أن لديك Backup Strategy جيدة إذا لم تختبر الاستعادة.

النسخ الاحتياطي والاستعادة

في التطبيقات التجارية، البيانات عادة أهم من الكود نفسه.

يمكن استخدام نماذج Backup المختلفة في SQL Server وفق متطلبات Recovery Point Objective وRecovery Time Objective.

المبرمج الذي يبني التطبيق ويترك موضوع Backup بالكامل دون التفكير في الاستعادة قد يكتشف قيمة هذا الموضوع بعد أول حادثة حقيقية.

اسأل دائمًا:

كم مقدار البيانات التي يمكننا خسارتها؟

كم نحتاج من الوقت للعودة إلى الخدمة؟

هل جربنا Restore فعلًا؟

Migrations في Pipeline

عند النشر يمكن تشغيل migrations بطريقة مضبوطة، لكن في البيئات الكبيرة قد تكون عملية مخطط قاعدة البيانات جزءًا من Deployment مستقل.

لا تفترض أن:

dotnet ef database update

هو دائمًا الحل الصحيح في Production.

في بعض الفرق، يتم توليد Script ومراجعته ثم تطبيقه عبر Pipeline controlled.

يمكن مثلًا:

dotnet ef migrations script

ثم مراجعة SQL قبل التنفيذ.

التغييرات غير المتوافقة Backward Compatibility

تخيل أنك تريد حذف عمود:

Phone

لكن إصدارًا قديمًا من التطبيق لا يزال يقرأه.

إذا حذفت العمود أولًا، قد يتعطل الإصدار القديم.

في الأنظمة التي تستخدم Zero-Downtime Deployment، يجب التفكير في توافق الإصدارات.

أحيانًا تكون أفضل استراتيجية:

  1. إضافة العمود الجديد.

  2. دعم القديم والجديد في التطبيق.

  3. ترحيل البيانات.

  4. نشر الإصدار الجديد.

  5. التوقف عن استخدام القديم.

  6. حذف القديم في مرحلة لاحقة.

هذه طريقة مختلفة قليلًا عن مشاريع التطوير الفردية، لكنها مهمة جدًا في الأنظمة الإنتاجية.

مراقبة Connection Pool

إذا كان التطبيق يعاني من:

Timeout expired

قد لا يكون السبب SQL نفسه. ربما الاتصالات لا يتم تحريرها أو أن النظام يواجه ضغطًا كبيرًا على الـ Pool أو اتصالًا محجوزًا لفترة طويلة.

استخدام:

await using SqlConnection connection = ...

والحرص على إنهاء DataReader وTransactions يساعد في إدارة الموارد.

DataReader وأهمية إغلاقه

إذا فتحت Reader واحتفظت به بينما تنفذ عمليات أخرى على نفس الاتصال، قد تقيد استخدام الاتصال.

النمط الصحي هو:

await using SqlDataReader reader =
    await command.ExecuteReaderAsync();

while (await reader.ReadAsync())
{
    // process
}

ثم ينتهي scope ويتم تحرير الموارد.

MARS

قد تصادف Multiple Active Result Sets.

لكن لا تستخدم MARS كحل تلقائي لكل مشكلة في التعامل مع Reader والاتصالات. في كثير من الأحيان يكون التصميم الأفضل هو إنهاء الاستعلام الأول أو استخدام اتصال آخر أو إعادة هيكلة العملية.

التعامل مع NULL في Parameters

إذا كانت القيمة:

string? phone = null;

لا تمررها مباشرة كـ null بطريقة قد لا يفهمها ADO.NET كما تتوقع.

استخدم:

parameter.Value =
    (object?)phone ?? DBNull.Value;

مثال:

command.Parameters.Add(
    "@Phone",
    SqlDbType.NVarChar,
    30
).Value =
    (object?)customer.Phone ?? DBNull.Value;

هذه تفصيلة صغيرة لكنها تظهر كثيرًا في التطبيقات الواقعية.

Stored Procedure مع Output Parameter

يمكن أن يكون:

CREATE PROCEDURE CreateCustomer
    @FullName NVARCHAR(150),
    @Email NVARCHAR(200),
    @NewId INT OUTPUT
AS
BEGIN
    SET NOCOUNT ON;

    INSERT INTO Customers
        (FullName, Email, CreatedAt)
    VALUES
        (@FullName, @Email, SYSUTCDATETIME());

    SET @NewId = CAST(SCOPE_IDENTITY() AS INT);
END;
GO

ومن C#:

using SqlCommand command =
    new("CreateCustomer", connection);

command.CommandType =
    CommandType.StoredProcedure;

command.Parameters.Add(
    "@FullName",
    SqlDbType.NVarChar,
    150
).Value = "هدى محمد";

command.Parameters.Add(
    "@Email",
    SqlDbType.NVarChar,
    200
).Value = "huda@example.com";

var output = command.Parameters.Add(
    "@NewId",
    SqlDbType.Int
);

output.Direction =
    ParameterDirection.Output;

connection.Open();

command.ExecuteNonQuery();

int newId = (int)output.Value;

التعامل مع Multiple Result Sets

قد يعيد Stored Procedure أكثر من مجموعة نتائج.

مثلًا:

SELECT * FROM Customers;
SELECT * FROM Orders;

وفي C#:

using SqlDataReader reader =
    command.ExecuteReader();

while (reader.Read())
{
    // Customers
}

if (reader.NextResult())
{
    while (reader.Read())
    {
        // Orders
    }
}

هذه القدرة مفيدة لبعض سيناريوهات التقارير، لكنها تحتاج إلى وضوح في عقد النتائج.

استدعاء Function

SQL Server يحتوي على Functions، لكن لا ينبغي الخلط بينها وبين Stored Procedures.

يمكن مثلًا استخدام Scalar Function داخل SQL.

لكن الإفراط في Functions على مستوى كل صف قد يؤدي إلى أداء غير جيد في بعض الحالات، لذلك يجب دراسة Execution Plan وسلوك الاستعلام.

استخدام Views

يمكن أن تنشئ View:

CREATE VIEW vw_CustomerSummary
AS
SELECT
    c.Id,
    c.FullName,
    c.Email,
    COUNT(o.Id) AS OrdersCount
FROM Customers c
LEFT JOIN Orders o
    ON o.CustomerId = c.Id
GROUP BY
    c.Id,
    c.FullName,
    c.Email;
GO

ثم تقرأ:

SELECT *
FROM vw_CustomerSummary;

الـ View يمكن أن تكون مفيدة لتبسيط استعلامات القراءة أو تقديم واجهة مستقرة نسبيًا، لكنها ليست بديلًا تلقائيًا لـ Views Materialized أو حلول التحليلات الأخرى.

العمل مع JSON في SQL Server

يمكن لـ SQL Server التعامل مع JSON، وهذا مفيد في بعض الحالات.

مثال:

DECLARE @Data NVARCHAR(MAX) = N'
{
  "name": "Ahmed",
  "email": "ahmed@example.com"
}';

SELECT
    JSON_VALUE(@Data, '$.name') AS Name,
    JSON_VALUE(@Data, '$.email') AS Email;

لكن وجود دعم JSON لا يعني أنه يجب تحويل كل قاعدة البيانات إلى JSON.

إذا كانت البيانات علائقية بطبيعتها، فالجداول والعلاقات لا تزال من الأدوات الأساسية.

تخزين الملفات في SQL Server أم نظام ملفات؟

إذا كان لديك صور وPDFs وفيديوهات، قد تسأل: هل أخزنها في SQL Server؟

الجواب يعتمد على النظام.

يمكن تخزين البيانات الثنائية في قاعدة البيانات، لكن في بعض الأنظمة قد يكون الأفضل تخزين الملفات في Object Storage وإبقاء Metadata فقط في SQL Server.

مثلًا:

Documents
---------
Id
FileName
MimeType
StorageKey
CreatedAt

ويبقى الملف نفسه خارج قاعدة البيانات.

لا توجد قاعدة واحدة لكل المشاريع.

مثال Architecture لتطبيق حقيقي

يمكن أن يكون النظام:

Browser / Mobile App
        |
        v
ASP.NET Core Web API
        |
        v
Application Services
        |
        v
Repositories
        |
        +------ EF Core
        |
        +------ Dapper
        |
        +------ ADO.NET
        |
        v
SQL Server

ويمكن إضافة:

Redis
Message Queue
Background Workers
Blob Storage
External APIs

عندما يكبر المشروع.

المهم أن كل مكون يعرف مسؤولية دوره.

فصل Read وWrite

في بعض المشاريع الكبيرة، قد تكون عمليات القراءة كثيرة جدًا، بينما عمليات الكتابة أقل.

هنا قد تفكر في CQRS، بحيث توجد نماذج ومسارات مختلفة للقراءة والكتابة.

لكن لا تستخدم CQRS لمجرد أن المصطلح يبدو احترافيًا.

إذا كان التطبيق صغيرًا، فقد تزيد التعقيد أكثر مما تحسن التصميم.

البحث المتقدم

إذا كان لديك نظام يبحث في المقالات أو المنتجات أو العملاء، يمكن أن تصبح جملة:

WHERE Name LIKE '%something%'

غير كافية عند حجم كبير.

يمكن التفكير في Full-Text Search أو محرك بحث خارجي بحسب طبيعة التطبيق.

SQL Server يملك قدرات بحث نصي، لكن مرة أخرى، الحل يجب أن يعتمد على طبيعة البيانات ومتطلبات البحث.

Partitioning

في الجداول الكبيرة جدًا قد يظهر Partitioning.

مثلًا جدول سجلات حجمه مئات الملايين من الصفوف.

يمكن تقسيم البيانات حسب التاريخ أو مفتاح معين لتسهيل الإدارة وبعض أنماط الاستعلام.

لكن Partitioning ليس علاجًا لكل استعلام بطيء. بل هو تقنية متقدمة تحتاج إلى تصميم مدروس.

Archiving

بدل أن يبقى جدول العمليات يحتوي على كل شيء إلى الأبد، يمكن نقل البيانات القديمة إلى جداول Archive أو نظام تخزين آخر وفق احتياجات العمل.

هذا قد يحسن حجم البيانات النشطة ويجعل الاستعلامات اليومية أكثر بساطة.

التعامل مع السجلات التاريخية

في الأنظمة التي تحتاج إلى Audit كامل، يمكن إنشاء:

CustomerAudit
-------------
Id
CustomerId
Action
ChangedBy
ChangedAt
OldValue
NewValue

أو تصميم أكثر تنظيمًا حسب نوع البيانات.

وهذا يساعد في معرفة ما حدث على مستوى النظام.

SQL Server وASP.NET Core Identity

إذا كان التطبيق يحتاج إلى Authentication كامل، يمكن استخدام ASP.NET Core Identity مع SQL Server كمخزن للهوية.

في هذه الحالة لا تحتاج إلى اختراع كل شيء من الصفر.

Identity يوفر Models ومكونات للمستخدمين والأدوار والمطالبات وإدارة كلمات المرور وغيرها.

وهذا مثال ممتاز على الفرق بين "أستطيع كتابة هذا يدويًا" و"هل ينبغي أن أكتبه يدويًا؟"

JWT وقاعدة البيانات

إذا كنت تبني API تعتمد JWT، يمكن أن تبقى بيانات المستخدمين والأدوار في SQL Server، بينما يتم توقيع Access Tokens والتحقق منها في التطبيق.

لكن لا تخلط بين Token Storage وUser Data.

في بعض الأنظمة تحتاج Refresh Tokens إلى تخزين وقواعد إبطال ومراقبة.

SQL Server مع Background Services

يمكن لخدمة خلفية معالجة البيانات:

public class CustomerCleanupService
    : BackgroundService
{
    protected override async Task ExecuteAsync(
        CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            // Query SQL Server
            // process records
            // update status

            await Task.Delay(
                TimeSpan.FromMinutes(5),
                stoppingToken
            );
        }
    }
}

لكن يجب تصميم Jobs بحيث يمكن تشغيل أكثر من نسخة من التطبيق دون أن تعالج نفس المهمة بشكل متزامن بطريقة غير مقصودة.

وهنا تظهر أفكار مثل Distributed Locks وJob Queues وStatus Columns.

صف انتظار باستخدام SQL Server

يمكن تمثيل مهمة:

CREATE TABLE Jobs
(
    Id BIGINT IDENTITY PRIMARY KEY,
    Payload NVARCHAR(MAX) NOT NULL,
    Status TINYINT NOT NULL DEFAULT 0,
    CreatedAt DATETIME2 NOT NULL,
    LockedAt DATETIME2 NULL
);

ثم يحتاج Worker إلى حجز مهمة بطريقة آمنة.

هذه الأنماط تصبح أكثر تعقيدًا مع التوسع، وقد يكون استخدام Message Queue أنسب من جعل SQL Server نفسه Message Broker.

عدم استخدام SQL Server لكل شيء

SQL Server ممتاز كقاعدة علائقية، لكنه ليس دائمًا أفضل مكان لكل أنواع البيانات أو كل أنواع الرسائل.

يمكن استخدام:

  • Redis للكاش.

  • RabbitMQ أو خدمة Queue للرسائل.

  • Object Storage للملفات.

  • Search Engine للبحث المتقدم.

المهم هو ألا تحول SQL Server إلى "مخزن لكل شيء" لمجرد أنك معتاد عليه.

Cache

إذا كان لديك استعلام:

SELECT Settings FROM SystemSettings

ويتم تنفيذه آلاف المرات، قد يكون تخزين القيمة في Memory أو Redis مناسبًا.

لكن الكاش يخلق تحدي Invalidating.

يجب أن تعرف متى تصبح القيمة قديمة، وكيف يتم تحديثها.

Database Cache مقابل Application Cache

لا تجعل كل البيانات تأتي من Redis فقط لتقول إن المشروع "سريع".

الكاش مفيد عندما توجد قراءة متكررة وبيانات يمكن إعادة استخدامها.

لكن إذا كانت البيانات تتغير باستمرار وتحتاج إلى دقة لحظية، قد لا يكون التخزين المؤقت مناسبًا.

مراقبة الاستعلامات البطيئة

عندما يصبح التطبيق كبيرًا، ضع آلية لاكتشاف الاستعلامات التي تستغرق وقتًا طويلًا.

في طبقة التطبيق يمكنك تسجيل الزمن:

var stopwatch = Stopwatch.StartNew();

var result = await repository.GetAllAsync();

stopwatch.Stop();

_logger.LogInformation(
    "GetAllCustomers took {ElapsedMs} ms",
    stopwatch.ElapsedMilliseconds
);

ثم تربط ذلك بمراقبة قاعدة البيانات.

Metrics

أرقام مثل:

Database Queries/sec
Average Query Duration
P95 Duration
P99 Duration
Connection Pool Usage
Deadlocks
Timeouts
Failed Queries

قد تعطيك صورة أكثر وضوحًا من مجرد "المستخدمون يقولون إن النظام بطيء".

اختبار الضغط

قبل إطلاق تطبيق تجاري، يمكن تشغيل Load Tests لترى:

  • كم طلبًا في الثانية؟

  • أين الاختناق؟

  • هل SQL Server هو عنق الزجاجة؟

  • هل Connection Pool ممتلئ؟

  • هل هناك استعلامات N+1؟

  • هل الذاكرة ترتفع؟

  • هل عدد الاتصالات يصبح كبيرًا؟

الأداء لا يجب أن يكون شيئًا نكتشفه بعد الإنتاج فقط.

نصيحة عملية من عالم المشاريع الحقيقية

عندما تعمل على تطبيق C# مرتبط بـ SQL Server، حاول أن تفصل ثلاث مشاكل عن بعضها في ذهنك:

المشكلة الأولى: هل التطبيق قادر على الاتصال؟

المشكلة الثانية: هل SQL صحيح؟

المشكلة الثالثة: هل النظام كله مصمم ليبقى سريعًا وآمنًا عند الحمل؟

هذه الثلاث ليست نفس الشيء.

قد يكون الاتصال يعمل ولكن الاستعلام خاطئ.

وقد يكون الاستعلام صحيحًا لكنه بطيء.

وقد يكون التطبيق سريعًا في التطوير لكنه ينهار عندما يصبح لديك 500 مستخدم متزامن.

أخطاء شائعة يجب تجنبها

من الأخطاء الشائعة وضع Connection String داخل الكود.

ومن الأخطاء تركيب SQL باستخدام Interpolation:

$"SELECT * FROM Users WHERE Id = {id}"

حتى لو كان id رقمًا اليوم، لا تجعل هذا عادة عندما يمكن استخدام Parameters.

ومن الأخطاء تجاهل CancellationToken.

ومن الأخطاء جلب جميع الصفوف بدون Pagination.

ومن الأخطاء استخدام SELECT * في كل استعلام.

ومن الأخطاء تحميل علاقات ضخمة دون حاجة.

ومن الأخطاء إعطاء التطبيق db_owner.

ومن الأخطاء تسجيل بيانات حساسة في Logs.

ومن الأخطاء تشغيل EnableSensitiveDataLogging() في الإنتاج بلا ضرورة.

ومن الأخطاء إخفاء Exceptions بدل تسجيلها.

ومن الأخطاء استخدام DbContext كـ Singleton.

ومن الأخطاء استخدام Connection عالمي مشترك.

ومن الأخطاء اعتبار زيادة Timeout حلًا دائمًا.

ومن الأخطاء إضافة Index لكل Column.

ومن الأخطاء الاعتماد على Mock دون أي Integration Test.

ومن الأخطاء عدم اختبار استعادة Backup.

ومن الأخطاء حذف أو تغيير Database Schema دون التفكير في توافق الإصدارات.

ماذا تختار: ADO.NET أم Dapper أم EF Core؟

يمكن التفكير في الاختيار هكذا:

إذا كنت تريد أقصى تحكم في SQL والاتصالات وعمليات القراءة، ADO.NET مناسب جدًا.

إذا كنت تريد كتابة SQL يدويًا مع Mapping بسيط، Dapper خيار ممتاز.

إذا كنت تريد ORM كاملًا مع LINQ وMigrations وإدارة العلاقات وChange Tracking، EF Core خيار قوي.

وفي بعض المشاريع يمكنك استخدام أكثر من أداة، لكن يجب أن توجد حدود واضحة.

مثلًا:

CRUD -> EF Core
Complex Reports -> Dapper
Specialized Low-Level Operations -> ADO.NET

هذه ليست قاعدة عامة، لكنها فكرة عملية.

كيف تبدأ مشروعك بطريقة صحيحة؟

ابدأ من قاعدة البيانات أو نموذج المجال بحسب طبيعة المشروع.

حدد الجداول والعلاقات.

حدد Constraints.

حدد الحقول التي يجب أن تكون فريدة.

حدد أنواع البيانات الصحيحة.

فكر في الفهارس بناءً على الاستعلامات الفعلية.

بعد ذلك أنشئ طبقة الوصول إلى البيانات.

اختر ADO.NET أو EF Core أو Dapper بناءً على الحاجة.

اجعل Connection String خارج الكود.

استخدم Parameters.

أضف Logging.

استخدم Async في عمليات I/O المناسبة.

أضف Tests.

وأهم شيء: اختبر التطبيق ضد البيانات الواقعية أو بيانات قريبة من حجم الإنتاج.

مثال نهائي لتطبيق صغير

لنفترض أن لدينا:

ASP.NET Core API
    |
    +-- Controllers
    |
    +-- Services
    |
    +-- Repositories
    |
    +-- SQL Server

وفي SQL Server:

Customers
Orders
OrderItems
Products

يمكن أن يكون المسار:

POST /api/customers
        |
        v
CustomersController
        |
        v
CustomerService
        |
        v
CustomerRepository
        |
        v
SQL Server

وعند الحصول على الطلب:

GET /api/orders/100

يذهب إلى:

OrdersController
        |
OrdersService
        |
OrdersRepository
        |
SQL Server

وإذا كانت العملية تنشئ Order وتخصم Stock، فإن Service أو طبقة Unit of Work المناسبة تنسق Transaction.

بهذا تصبح الصورة واضحة، وحتى إذا تغيّر SQL Server أو طريقة التخزين مستقبلًا، تكون معظم طبقات التطبيق معزولة نسبيًا عن التفاصيل.

Checklist ذهنية للمطور

عندما تكتب أي كود يتصل بـ SQL Server، اسأل نفسك:

هل Connection String خارج الكود؟

هل أستخدم Parameters؟

هل الاتصال والـ Reader والـ Transaction يتم تحريرهم؟

هل أحتاج إلى Async؟

هل الاستعلام يعيد البيانات التي أحتاجها فقط؟

هل يوجد Index مناسب؟

هل توجد Pagination؟

هل توجد احتمالية N+1؟

هل Transaction مطلوبة؟

هل البيانات يمكن أن تكون NULL؟

هل أتعامل مع Concurrency؟

هل حساب قاعدة البيانات يملك صلاحيات زائدة؟

هل هناك Logging جيد؟

هل يمكن اختبار هذه العملية؟

هذه الأسئلة البسيطة ستمنع الكثير من المشاكل قبل حدوثها.

خلاصة عملية

ربط تطبيقات C# و.NET مع SQL Server ليس مجرد معرفة كيفية كتابة:

var connection = new SqlConnection(connectionString);
connection.Open();

هذه مجرد البداية.

الجزء المهم هو ما يأتي بعدها: كيف تنفذ الاستعلامات بأمان؟ كيف تستخدم Parameters؟ كيف تحمي النظام من SQL Injection؟ كيف تتعامل مع Transactions؟ كيف تتعامل مع القيم NULL؟ كيف تنفذ العمليات بشكل Async؟ كيف تفصل Repository عن Service وController؟ كيف تختار بين ADO.NET وDapper وEntity Framework Core؟ كيف تصمم الفهارس؟ كيف تمنع N+1؟ كيف تدير Concurrency؟ كيف تحمي Connection String؟ كيف تختبر طبقة البيانات؟ وكيف تضمن أن النظام سيظل قابلًا للتوسع عندما ينتقل من عشرات المستخدمين إلى آلافهم؟

المميز في .NET أن لديك منظومة ناضجة جدًا لهذا النوع من التطبيقات. ويمكنك البدء من ADO.NET لفهم التفاصيل الأساسية، ثم الانتقال إلى EF Core عندما تحتاج إلى مستوى أعلى من التجريد، أو إلى Dapper عندما تريد الجمع بين SQL اليدوي والـ Mapping البسيط. ومع الخبرة ستكتشف أن الأداة بحد ذاتها ليست هي العامل الحاسم. العامل الأهم هو أن تفهم البيانات نفسها، وأن تعرف كيف تتدفق من المستخدم إلى التطبيق إلى قاعدة البيانات ثم تعود بشكل آمن ومنظم.

خذ وقتك في فهم SQL Server نفسه، وليس فقط مكتبة C# التي تتصل به. تعلم الفهارس، Execution Plans، Transactions، Constraints، Isolation Levels، Locks، Blocking، Deadlocks، Backup، Restore، وأساسيات تصميم الجداول. وبالمقابل، افهم في .NET كيفية إدارة الموارد، Dependency Injection، Async/Await، Configuration، Logging، Validation، Testing، وAPI Design. عندما تجمع الطرفين، يصبح لديك أساس قوي لبناء تطبيقات عملية فعلًا.

وفي النهاية، أفضل تطبيق لا يكون هو الذي يحتوي على أكبر عدد من الطبقات أو أحدث المكتبات. أفضل تطبيق هو الذي تكون فيه المسؤوليات واضحة، والاستعلامات مفهومة، والبيانات محمية، والأخطاء قابلة للتشخيص، والأداء مقبولًا، والتصميم قادرًا على النمو دون أن يتحول كل تعديل صغير إلى معركة. SQL Server و.NET يعطيانك الأدوات، لكن جودة النظام النهائي تعتمد على الطريقة التي تستخدم بها هذه الأدوات.

إذا كنت تتعلم هذا المجال من الصفر، ابدأ بمشروع صغير حقيقي: نظام عملاء أو مخزون أو فواتير. ابنِ الجداول بنفسك، ثم نفذ الاتصال باستخدام ADO.NET. جرّب SqlConnection وSqlCommand وSqlDataReader. بعدها أضف Parameters وTransactions وAsync. ثم أعد المشروع نفسه باستخدام Dapper. وبعدها استخدم Entity Framework Core للمقارنة. عندما تنفذ المثال نفسه بثلاث طرق، ستبدأ في فهم الاختلافات الحقيقية بدل حفظ أسماء الأدوات.

وربما بعد بضعة أيام من ذلك ستجلس أمام مشروعك وتكتب:

await db.SaveChangesAsync();

ثم تتذكر أن خلف هذا السطر هناك Connection Pool، وSQL Generation، وParameters، وTransaction، وNetwork I/O، وSQL Server، وIndexes، وConstraints، وLocks، وStorage. وهنا بالضبط ينتقل تعلمك من مجرد كتابة الكود إلى فهم ما يحدث فعلًا خلف الكواليس. وهذه هي النقطة التي يبدأ فيها مطور C# في بناء أنظمة قواعد بيانات ناضجة، وليست مجرد تطبيقات "تعمل على جهازه".

إن بناء علاقة قوية بين C# و.NET وSQL Server هو استثمار طويل الأمد. كلما فهمت الطبقات التي تقع تحت الكود، أصبحت قراراتك أفضل، وتشخيصك للمشاكل أسرع، وتصميمك أكثر ثباتًا. وفي المشاريع الحقيقية، هذه المهارة قد تكون الفارق بين تطبيق يعمل في العرض التجريبي وتطبيق يمكن لفريق كامل وشركة كاملة الاعتماد عليه لسنوات.

لذلك لا تتعامل مع SQL Server باعتباره مجرد مكان تضع فيه البيانات، ولا تتعامل مع .NET باعتباره مجرد أداة لإرسال Queries. اعتبرهما جزءين من نظام واحد يحتاج إلى تصميم وتفكير واختبار ومراقبة. ومع هذه العقلية، ستتمكن من بناء تطبيقات C# و.NET مرتبطة بـ SQL Server بطريقة أكثر أمانًا، وأكثر وضوحًا، وأكثر قابلية للتوسع والصيانة.

وفي كل مرة تواجه فيها مشكلة في الاتصال أو الاستعلام أو الأداء، عد إلى الأساسيات: هل الاتصال صحيح؟ هل الاستعلام صحيح؟ هل البيانات صحيحة؟ هل الفهارس مناسبة؟ هل الموارد يتم تحريرها؟ هل يوجد تزامن أو Blocking؟ هل التصميم نفسه مناسب؟ هذه الأسئلة، رغم بساطتها، قادرة على حل نسبة كبيرة من المشاكل التي قد تبدو معقدة في البداية.

وهنا تكمن القيمة الحقيقية لتعلم ربط C# و.NET مع SQL Server: ليست في حفظ API واحد أو كتابة Connection String، بل في بناء فهم متكامل لكيفية انتقال البيانات بين عالم التطبيق وعالم قاعدة البيانات، وكيف تجعل هذا الانتقال آمنًا وفعالًا وقابلًا للتوسع. وعندما تصل إلى هذه المرحلة، لن تكون فقط قادرًا على ربط تطبيق بـ SQL Server، بل ستكون قادرًا على تصميم أنظمة تعتمد على البيانات بثقة أكبر، واتخاذ قرارات تقنية مبنية على فهم وليس على التخمين.

#SQL Server #ربط C# مع SQL Server #ربط .NET مع SQL Server #C# SQL Server #ADO.NET #SQL Server connection string #SqlConnection #SqlCommand #SqlParameter #Repository Pattern #.NET database #C# database programming #برمجة قواعد البيانات

اشترك في نشرتنا البريدية

12k+

المشتركون

أسبوعيًا

التكرار

مجاني

دائمًا