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

Drift ORM: قاعدة بيانات آمنة من حيث الأنواع مع توليد الكود

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

Drift ORM: قاعدة بيانات آمنة من حيث الأنواع مع توليد الكود

Drift (المعروف سابقاً بـ Moor) هو مكتبة تخزين تفاعلية وآمنة من حيث الأنواع لـ Flutter وDart. بدلاً من كتابة SQL خام وتعيين الصفوف يدوياً إلى كائنات Dart، تتيح لك Drift تعريف مخطط قاعدة البيانات كفئات Dart عادية. يُنتج مولّد الكود بعد ذلك طبقة قاعدة بيانات مكتوبة بالكامل، وكائنات DAOs، ومساعدات الاستعلام — مما يُزيل فئة كاملة من الأخطاء الزمنية الناجمة عن الأخطاء الإملائية أو عدم تطابق الأنواع في سلاسل SQL.

لماذا Drift بدلاً من sqflite الخام؟ مع sqflite تكتب سلاسل SQL خام، وتُحوّل نتائج Map<String, dynamic> يدوياً، وتكتشف الأخطاء فقط في وقت التشغيل. تكشف Drift تلك الأخطاء في وقت الترجمة: اسم عمود خاطئ أو نوع إرجاع خاطئ يُعدّ خطأ في البناء، وليس تعطلاً في الإنتاج.

إعداد Drift

أضف ما يلي إلى ملف pubspec.yaml:

اعتماديات pubspec.yaml

dependencies:
  drift: ^2.14.0
  drift_flutter: ^0.2.0   # يوفر فاتح قاعدة البيانات الخاص بـ Flutter
  sqlite3_flutter_libs: ^0.5.0

dev_dependencies:
  drift_dev: ^2.14.0
  build_runner: ^2.4.0

شغّل flutter pub get بعد تعديل الملف.

تعريف الجداول كفئات Dart

كل جدول هو فئة Dart تمتد من Table. كل getter تُعلنه يصبح عموداً في مخطط SQL المُولَّد. تستنتج Drift نوع SQL من نوع Dart الذي تستخدمه:

lib/database/tables.dart — تعريف جدول Tasks

import 'package:drift/drift.dart';

// كل فئة = جدول SQL واحد
class Tasks extends Table {
  // INTEGER PRIMARY KEY AUTOINCREMENT
  IntColumn get id => integer().autoIncrement()();

  // TEXT NOT NULL
  TextColumn get title => text().withLength(min: 1, max: 200)();

  // TEXT (قابل للقيمة الفارغة)
  TextColumn get description => text().nullable()();

  // INTEGER (0 أو 1، مخزّن كقيمة منطقية)
  BoolColumn get isCompleted => boolean().withDefault(const Constant(false))();

  // INTEGER يمثل طابعاً زمنياً Unix
  DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
}

class Categories extends Table {
  IntColumn get id => integer().autoIncrement()();
  TextColumn get name => text().withLength(min: 1, max: 100)();
  // مفتاح خارجي للمهام (تتحقق Drift على مستوى Dart)
  IntColumn get taskId => integer().references(Tasks, #id)();
}

إنشاء فئة قاعدة البيانات وتشغيل build_runner

تجمع الجداول في فئة واحدة مُعلَّقة بـ @DriftDatabase. توجيه part يُخبر Dart بتوقع ملف مُولَّد بجانب ملفك:

lib/database/app_database.dart — فئة قاعدة البيانات

import 'package:drift/drift.dart';
import 'package:drift_flutter/drift_flutter.dart';
import 'tables.dart';

part 'app_database.g.dart'; // مُولَّد بواسطة build_runner

@DriftDatabase(tables: [Tasks, Categories])
class AppDatabase extends _$AppDatabase {
  AppDatabase() : super(_openConnection());

  // إصدار المخطط — زِد عند إضافة/تغيير الجداول
  @override
  int get schemaVersion => 1;

  static QueryExecutor _openConnection() {
    return driftDatabase(name: 'app_db');
  }
}

ولّد الكود الجاهز بتشغيل:

الطرفية — توليد طبقة قاعدة البيانات

flutter pub run build_runner build --delete-conflicting-outputs

يُنشئ هذا app_database.g.dart يحتوي على الـ mixin الخاص بـ _$AppDatabase، وفئات الصف المكتوبة (Task، Category)، وفئات الرفيق للإدراج/التحديث (TasksCompanion، CategoriesCompanion).

نصيحة: أثناء التطوير النشط استخدم build_runner watch بدلاً من build. يُعيد توليد الملف تلقائياً في كل مرة تحفظ فيها تغييراً على تعريفات الجداول، لذا لديك دائماً ملف مُولَّد محدّث دون تشغيل الأمر يدوياً.

CRUD الآمن من حيث الأنواع مع DAOs

DAO (كائن الوصول إلى البيانات) يجمع الاستعلامات ذات الصلة في فئة واحدة ويبقي منطق قاعدة البيانات بعيداً عن الودجات. عَلِّقه بـ @DriftAccessor:

lib/database/task_dao.dart — DAO مع CRUD كامل

import 'package:drift/drift.dart';
import 'app_database.dart';
import 'tables.dart';

part 'task_dao.g.dart';

@DriftAccessor(tables: [Tasks])
class TaskDao extends DatabaseAccessor<AppDatabase> with _$TaskDaoMixin {
  TaskDao(super.db);

  // SELECT * FROM tasks ORDER BY created_at DESC
  Stream<List<Task>> watchAllTasks() =>
      (select(tasks)..orderBy([(t) => OrderingTerm.desc(t.createdAt)])).watch();

  // SELECT * FROM tasks WHERE is_completed = 0
  Future<List<Task>> getIncompleteTasks() =>
      (select(tasks)..where((t) => t.isCompleted.equals(false))).get();

  // INSERT — يستخدم companion لتوفير الأعمدة التي تعرفها فقط بأمان
  Future<int> insertTask(TasksCompanion entry) => into(tasks).insert(entry);

  // UPDATE — فقط الأعمدة الموجودة في companion تتغير
  Future<bool> updateTask(TasksCompanion entry) => update(tasks).replace(entry);

  // DELETE بالمفتاح الأساسي
  Future<int> deleteTask(int id) =>
      (delete(tasks)..where((t) => t.id.equals(id))).go();
}

في الودجت تستهلك الـ DAO بشكل تفاعلي باستخدام StreamBuilder:

الودجت — مراقبة المهام باستخدام StreamBuilder

class TaskListPage extends StatelessWidget {
  final TaskDao dao;
  const TaskListPage({super.key, required this.dao});

  @override
  Widget build(BuildContext context) {
    return StreamBuilder<List<Task>>(
      stream: dao.watchAllTasks(),
      builder: (context, snapshot) {
        if (!snapshot.hasData) return const CircularProgressIndicator();
        final tasks = snapshot.data!;
        return ListView.builder(
          itemCount: tasks.length,
          itemBuilder: (context, index) {
            final task = tasks[index];
            return ListTile(
              title: Text(task.title),
              subtitle: Text(task.description ?? ''),
              trailing: Checkbox(
                value: task.isCompleted,
                onChanged: (_) => dao.updateTask(
                  TasksCompanion(
                    id: Value(task.id),
                    isCompleted: Value(!task.isCompleted),
                  ),
                ),
              ),
              onLongPress: () => dao.deleteTask(task.id),
            );
          },
        );
      },
    );
  }
}
تحذير: لا تنسَ أبداً إعادة تشغيل build_runner بعد تعديل جدول أو DAO. إذا كان الملف المُولَّد قديماً، ستظهر أخطاء ترجمة مُربِكة بشأن أعضاء مفقودين. عند الشك، احذف ملفات .g.dart وشغّل build_runner build --delete-conflicting-outputs من البداية.

التهجيرات — التعامل مع تغييرات المخطط

عند إضافة عمود أو جدول، زِد schemaVersion وتجاوز migration في فئة قاعدة البيانات:

إضافة عمود priority في الإصدار 2

@override
int get schemaVersion => 2;

@override
MigrationStrategy get migration => MigrationStrategy(
  onUpgrade: (migrator, from, to) async {
    if (from < 2) {
      // إضافة العمود الجديد؛ الصفوف الموجودة تأخذ القيمة الافتراضية 0
      await migrator.addColumn(tasks, tasks.priority);
    }
  },
);

ملخص

تُحوّل Drift الوصول إلى قاعدة بيانات SQLite في Flutter من SQL خام عرضة للأخطاء إلى تجربة يتم التحقق منها بالكامل في وقت الترجمة. تُعرّف الجداول كفئات Dart، وتشغّل build_runner مرة واحدة لتوليد قاعدة البيانات وكود الـ DAO الجاهز، ثم تُجري عمليات CRUD باستخدام أساليب مكتوبة بشدة وكائنات companion. تتيح التدفقات التفاعلية (عبر watch()) لواجهة المستخدم إعادة البناء تلقائياً عند تغيير البيانات الأساسية — مما يجعل Drift الخيار الأكثر قوةً لمتطلبات البيانات المحلية المعقدة.