التخزين المحلي للبيانات

Drift ORM: الاستعلامات والتدفقات والعلاقات

16 دقيقة الدرس 11 من 12

Drift ORM: الاستعلامات والتدفقات والعلاقات

Drift (المعروف سابقاً بـ Moor) هو ORM تفاعلي وآمن الأنواع لقاعدة بيانات SQLite في Flutter. يتميز Drift بثلاث قدرات متقدمة: الاستعلامات المخصصة بفلاتر وترتيب، والتدفقات التفاعلية (Streams) التي تدفع تحديثات مباشرة لواجهة المستخدم عند تغيّر قاعدة البيانات، والربط العلائقي (Joins) الذي ينمذج علاقات الجداول. إتقان هذه الميزات الثلاث يفتح القوة الكاملة لمعماريات التطبيقات المحلية-أولاً (local-first).

كتابة استعلامات Select مخصصة

يولّد Drift دالة مساعدة select() لكل جدول، لكن التطبيقات الحقيقية تحتاج فلاتر وترتيباً وترقيماً للصفحات. تُضاف هذه الخصائص بتسلسل الطرق على SimpleSelectStatement قبل استدعاء get() أو watch().

  • where((tbl) => expr) — يفلتر الصفوف بتعبير منطقي
  • orderBy([...criteria]) — يرتب النتائج تصاعدياً أو تنازلياً
  • limit(n, offset: k) — يرقّم النتائج للصفحات
  • تستخدم التعبيرات موصّلات الأعمدة المُولَّدة: tbl.priority.isBiggerThanValue(2)، tbl.title.like('%Flutter%')، tbl.isCompleted.equals(false)

استعلام مفلتر ومرتب

// في فئة قاعدة بيانات Drift (AppDatabase extends _$AppDatabase)
Future<List<Task>> getHighPriorityTasks() {
  return (select(tasks)
        ..where((t) => t.priority.isBiggerThanValue(2))
        ..orderBy([
          (t) => OrderingTerm(expression: t.dueDate),
          (t) => OrderingTerm.desc(t.priority),
        ])
        ..limit(20))
      .get();
}

Future<List<Task>> searchTasks(String keyword) {
  return (select(tasks)
        ..where((t) => t.title.like('%$keyword%')))
      .get();
}
ملاحظة: عامل التتالي .. يتيح تسلسل محددات متعددة على نفس الاستعلام. كل محدد يعدّل الاستعلام في مكانه ويعيد void، لذا تلتقط الأقواس الخارجية مرجع الاستعلام النهائي قبل استدعاء .get().

التدفقات التفاعلية مع watch()

الميزة الأبرز في Drift هي التفاعلية. استبدل .get() بـ .watch() فيعيد الاستعلام Stream<List<T>>. في كل مرة يتغير فيها أي صف في الجدول — إدراج أو تحديث أو حذف — يعيد Drift تشغيل الاستعلام تلقائياً ويدفع قائمة جديدة لكل المستمعين النشطين. هذا يلغي الحاجة لتحديث البيانات يدوياً بعد الكتابة.

في Flutter، اقرن watch() بـ StreamBuilder للحصول على واجهة مستخدم تفاعلية بالكامل بأدنى قدر من الكود المتكرر:

StreamBuilder تفاعلي مدعوم بـ Drift

// طريقة في DAO أو قاعدة البيانات
Stream<List<Task>> watchIncompleteTasks() {
  return (select(tasks)
        ..where((t) => t.isCompleted.equals(false))
        ..orderBy([(t) => OrderingTerm(expression: t.dueDate)]))
      .watch();
}

// ودجت Flutter
class TaskListScreen extends StatelessWidget {
  const TaskListScreen({super.key});

  @override
  Widget build(BuildContext context) {
    final db = context.read<AppDatabase>();
    return StreamBuilder<List<Task>>(
      stream: db.watchIncompleteTasks(),
      builder: (context, snapshot) {
        if (!snapshot.hasData) return const CircularProgressIndicator();
        final tasks = snapshot.data!;
        return ListView.builder(
          itemCount: tasks.length,
          itemBuilder: (_, i) => ListTile(title: Text(tasks[i].title)),
        );
      },
    );
  }
}
نصيحة: يُخفف Drift من وتيرة الكتابات المتتالية السريعة لكي لا يُطلق التدفق عشرات المرات في الثانية أثناء الإدراج الجماعي. هذا يجعل استخدام watch() آمناً حتى في سيناريوهات الكتابة الكثيفة كمزامنة البيانات البعيدة.

نمذجة علاقات واحد-إلى-كثير بالـ Joins

البيانات العلائقية هي القاعدة في التطبيقات الحقيقية. النمط الشائع هو فئة واحدة تمتلك مهام عديدة. يتعامل Drift مع هذا عبر واجهة join() التي تعكس SQL JOIN مع البقاء آمنة الأنواع بالكامل.

خطوات نمذجة علاقة واحد-إلى-كثير:

  • أعلن كلا الجدولين بتعليقات Drift المناسبة (@DataClassName)
  • أضف عمود مفتاح خارجي في الجدول الابن: IntColumn get categoryId => integer().references(Categories, #id)()
  • استخدم select(tasks).join([innerJoin(categories, categories.id.equalsExp(tasks.categoryId))]) لجلب الصفوف المربوطة
  • اقرأ أعمدة كل جدول عبر row.readTable(tasks) وrow.readTable(categories)

Join واحد-إلى-كثير: المهام مع فئتها

// تعريفات الجداول
class Categories extends Table {
  IntColumn get id => integer().autoIncrement()();
  TextColumn get name => text().withLength(min: 1, max: 64)();
}

class Tasks extends Table {
  IntColumn get id => integer().autoIncrement()();
  TextColumn get title => text()();
  IntColumn get categoryId => integer().references(Categories, #id)();
  BoolColumn get isCompleted => boolean().withDefault(const Constant(false))();
}

// نوع النتيجة يجمع كلا الصفين
class TaskWithCategory {
  final Task task;
  final Category category;
  const TaskWithCategory(this.task, this.category);
}

// استعلام قاعدة البيانات باستخدام join
Future<List<TaskWithCategory>> getTasksWithCategory() async {
  final query = select(tasks).join([
    innerJoin(categories, categories.id.equalsExp(tasks.categoryId)),
  ]);

  final rows = await query.get();
  return rows.map((row) {
    return TaskWithCategory(
      row.readTable(tasks),
      row.readTable(categories),
    );
  }).toList();
}

// النسخة التفاعلية — تتحدث مباشرة عند تغيّر أي من الجدولين
Stream<List<TaskWithCategory>> watchTasksWithCategory() {
  final query = select(tasks).join([
    innerJoin(categories, categories.id.equalsExp(tasks.categoryId)),
  ]);
  return query.watch().map((rows) => rows.map((row) {
        return TaskWithCategory(
          row.readTable(tasks),
          row.readTable(categories),
        );
      }).toList());
}
تحذير: استخدام innerJoin يستبعد المهام التي لا يقابل categoryId الخاص بها صفاً في جدول categories (الصفوف اليتيمة). استخدم leftOuterJoin بدلاً من ذلك إذا أردت تضمين المهام حتى حين يُحذف فئتها، وتعامل مع نتيجة Category? القابلة للقيمة الخالية وفقاً لذلك.

استخدام كائنات الوصول للبيانات (DAOs)

مع نمو قاعدة بياناتك، حافظ على التنظيم بتجميع الاستعلامات ذات الصلة في فئات DAO. يُعلَّق DAO بـ @DriftAccessor(tables: [Tasks, Categories]) ويدمج الـ mixin المُولَّد _$TaskDaoMixin. تسجّل فئة قاعدة البيانات الرئيسية جميع DAOs عبر @DriftDatabase(daos: [TaskDao])، ويمكن الوصول إليها بـ db.taskDao. يحافظ هذا الفصل في المسؤوليات على نظافة فئة قاعدة البيانات وقابلية اختبار منطق الاستعلام بشكل مستقل.

الملخص

تتيح واجهة استعلامات Drift كتابة SQL معبّر وآمن الأنواع عبر تسلسل طرق Dart. يحوّل استبدال .get() بـ .watch() أي استعلام إلى تدفق تفاعلي يقود واجهة Flutter تلقائياً. تتعامل واجهة join() مع البيانات العلائقية بنظافة، محققةً مجموعات نتائج مكتّبة من جداول متعددة. بالاقتران مع DAOs، تشكّل هذه الأنماط الأساس لطبقات البيانات المحلية في تطبيقات Flutter الاحترافية.