الفصل 04Chapter 04

محرك البيانات (Drift & SQLite) The Data Engine (Drift & SQLite)

تعلم كيف يتم تخزين وإدارة البيانات محلياً في MRE CashBook باستخدام Drift. Learn how data is stored and managed locally in MRE CashBook using Drift.

💾 ليه اخترنا Drift؟ Why Drift?

في تطبيقات الـ Fintech زي CashBook، استقرار الداتا هو أهم حاجة. Drift هي مكتبة بتخلينا نتعامل مع الـ SQLite بطريقة Type-Safe وبنفس روح الـ Reactive Programming.

In Fintech apps like CashBook, data stability is paramount. Drift allows us to interact with SQLite in a type-safe way, following Reactive Programming principles.

FeatureAdvantage (EN)Benefit (AR)
Type SafetyCompile-time checksمستحيل تبعت رقم لمكان محتاج نص.
ReactivityStreams everywhereالشاشة بتحدث نفسها أول ما الداتا تتغير.
MigrationPowerful APIتطوير قاعدة البيانات من نسخة لنسخة بسهولة.
Native APIsDirect SQLite accessسرعة جبارة في العمليات المعقدة.
🏗️ تشريح ملف AppDatabase AppDatabase Anatomy

ملف app_database.dart هو المركز اللي بيجمع كل الجداول والـ DAOs. تعالو نفهم الكود ده بيعمل إيه بالظبط:

📁 lib/core/database/app_database.dart
@DriftDatabase(
  tables: [Books, Entries, ActivityLogs, EntryFields, EntryFieldValues],
  daos: [ActivityLogDao, BookDao, EntryDao],
)
class AppDatabase extends _$AppDatabase {
  AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
  
  // ...
}
@DriftDatabase(...)
🔍 بتعمل إيه؟🔍 What does it do?

دي الـ Annotation اللي بتقول للـ Build Runner: "يا بيلد رانر، من فضلك ولد لي كود الـ Boilerplate للجداول دي".

_$AppDatabase
🔍 بتعمل إيه؟🔍 What does it do?

ده كلاس "سحري" بيتولد أوتوماتيك في ملف app_database.g.dart وبيحتوي على كل السيكويل (SQL) اللازم للتعامل مع الجداول.

📋 تعريف الجداول (Tables Definition) Tables Definition

في Drift، بنعرف الجداول عن طريق كلاسات Dart. ده مثال لجدول "الكتب" (Books):

📁 lib/core/database/tables/books.dart
class Books extends Table {
  IntColumn get id => integer().autoIncrement()();
  TextColumn get name => text().withLength(min: 1, max: 255)();
  TextColumn get currency => text().withDefault(const Constant('EGP'))();
  BoolColumn get isIncludedInTotal => boolean().withDefault(const Constant(true))();
}
1
autoIncrement()

عشان الـ ID يزيد لوحده مع كل كتاب جديد، وميحصلش تكرار.

2
withDefault(...)

عشان نضمن إن الصف ميلكنش "ناقص" داتا لو اليوزر مبعتهاش.

🔄 نظام الهجرة (Migrations) The Migration Strategy

لما بنحب نضيف عمود جديد لمستخدمين البرنامج، لازم نعمل "Migration". ده واحد من أخطر وأهم الأجزاء في الكود.

// v5 → v6: إضافة عمود isIncludedInTotal للكتب
if (from < 6) {
  await migrator.addColumn(books, books.isIncludedInTotal);
}

تحذير: لو نسيت تعمل Migration ورفعت التحديث، التطبيق هيعمل Crash عند كل اليوزرز! دايماً جرب الـ Migration قبل ما تببلش.

🛡️ عزل الأوامر (DAOs Deep Dive) Data Access Objects (DAOs)

بدل ما نحط كل الكود في ملف واحد، بنقسمه لـ DAOs. الـ DAO هو المسئول الوحيد عن ميزة معينة في الداتابيز.

Instead of putting all the code in one file, we split it into DAOs. A DAO is solely responsible for a specific feature in the database.

📁 lib/core/database/daos/book_dao.dart
@DriftAccessor(tables: [Books])
class BookDao extends DatabaseAccessor with _$BookDaoMixin {
  BookDao(AppDatabase db) : super(db);

  // جلب كل الكتب كـ Stream (محدث لحظياً)
  Stream> watchAllBooks() => select(books).watch();

  // إضافة كتاب جديد
  Future insertBook(BooksCompanion book) => into(books).insert(book);
  
  // مسح كتاب
  Future deleteBook(int id) => (delete(books)..where((t) => t.id.equals(id))).go();
}
watchAllBooks()
🔍 بتعمل إيه؟🔍 What does it do?

دي بترجع Stream. يعني لو أي حد مسح أو ضاف كتاب من أي مكان في الأبلكيشن، الشاشة اللي فاتحة ديس لستة الكتب هتتحدث لوحدها فوراً!

💾🔄 خوارزمية إعادة تعيين الـ IDs (النسخ الاحتياطي) The ID Remapping Algorithm (Backup/Restore)

لما بتعمل استيراد (Import) لداتا قديمة، الـ IDs ممكن تتعارض مع الداتا الموجودة. عشان كدة عملنا خوارزمية ذكية بتغير الـ IDs وهي بتعمل Import.

📁 lib/core/database/app_database.dart (Import Logic)
final bookIdMap = {};

// 1. تسجيل الكتاب الجديد وحفظ الـ ID القديم والجديد
for (final rawBook in oldBooks) {
  final newId = await into(books).insert(companion);
  bookIdMap[oldId] = newId; // Map {12: 154}
}

// 2. تحديث الحسابات (Entries) بالـ ID الجديد للكتب
for (final rawEntry in oldEntries) {
  final newBookId = bookIdMap[oldBookId];
  await into(entries).insert(companion.copyWith(bookId: Value(newBookId)));
}
!
لماذا الـ Mapping؟

لو الـ Entry (الحساب) كان مربوط بالكتاب رقم 12، وفي الداتابيز الجديدة الكتاب ده بقى رقمه 154، الخوارزمية دي بتضمن إن الحساب يفضل مربوط بنفس الكتاب بس بالرقم الجديد.

📡 القوة التفاعلية والـ Streams Reactive Power: Streams & Watching

دريفت مش بس قاعدة بيانات، هي محرك أحداث (Event Engine). أي تعديل في سطر واحد بيبعت إشارة لكل الـ Streams اللي بتراقب الجدول ده.

select(entries).join([...]).watch()
🔍 الإبداع هنا:🔍 The Innovation:

حتى الـ Joins المعقدة (ربط الجداول) بتكون Reactive. لو غيرت اسم الكتاب، الـ Stream اللي بيعرض الحسابات مع أسماء كتبها هيتحدث أوتوماتيك.

🛡️ العمليات الآمنة (Transactions) Atomic Transactions

في العمليات المالية، "يا إما كل حاجة تحصل يا إما ولا حاجة تحصل". ده مبدأ الـ Transaction.

await transaction(() async {
  await into(entries).insert(newEntry);
  await into(activityLogs).insert(log);
  // لو الـ log فشل، الـ entry هيتمسح كأنه محصلش (Rollback)
});
🖥️🔬 معمل السيكويل (SQL Lab) SQL Lab: Inspecting the Database

عشان تكون محترف، لازم تعرف إزاي تبص على ملف الداتا الحقيقي على جهازك. بما إنك شغال على Mac، دي الخطوات:

🖥️ Terminal Command: Open SQLite DB
sqlite3 ~/Library/Application\ Support/mre_cashbook.db
.tables
🔍 بتعمل إيه؟🔍 What does it do?

بتعرض لك كل الجداول اللي Drift عملها في الخلفية.

SELECT * FROM books;
🔍 بتعمل إيه؟🔍 What does it do?

بتعرض كل الداتا اللي موجودة في جدول الكتب حالياً.

⚙️ توليد الكود (Build Runner) Code Generation (Build Runner)

دريفت بيعتمد على الـ Code Generation عشان يضمن الـ Type Safety. أي تعديل في الجداول لازم يتبعه الأمر ده:

🖥️ Terminal Command: Build Runner
dart run build_runner build --delete-conflicting-outputs
--delete-conflicting-outputs

الأوبشن ده مهم جداً عشان يمسح أي ملفات قديمة بايظة ويولد ملفات جديدة نضيفة.

🕵️‍♂️ سجل النشاطات (Audit Trail) Audit Trail: Activity Logs

في CashBook، مفيش حركة بتتم من غير ما تتسجل. جدول ActivityLogs بيسجل مين عمل إيه وإمتى.

📁 lib/core/database/tables/activity_logs.dart
class ActivityLogs extends Table {
  IntColumn get id => integer().autoIncrement()();
  IntColumn get entryId => integer().nullable().references(Entries, #id)();
  TextColumn get action => text()(); // ADD, EDIT, DELETE
  TextColumn get oldValue => text().nullable()();
  TextColumn get newValue => text().nullable()();
  DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
}

ليه بنعمل كدة؟ عشان لو اليوزر مسح حاجة وحب يرجعها، أو حب يشوف تاريخ التعديلات على حساب معين، الداتا تكون موجودة وجاهزة.

🔗 أمان العلاقات: المفاتيح الخارجية (Foreign Keys) Safety First: Foreign Keys

عشان بياناتك متضيعش أو يحصل فيها تضارب، بنستخدم الـ Foreign Keys. يعني مثلاً، مينفعش تضيف "حساب" لكتاب مش موجود أصلاً.

📁 lib/core/database/tables/entries.dart
class Entries extends Table {
  IntColumn get id => integer().autoIncrement()();
  // ربط الحساب بالـ ID بتاع الكتاب
  IntColumn get bookId => integer().references(Books, #id, onDelete: KeyAction.cascade)();
}
onDelete: KeyAction.cascade
🔍 بتعمل إيه؟🔍 What does it do?

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

🤝 القوة القصوى: الاستعلامات المدمجة (Joined Queries) Advanced Power: Joined Queries

أحياناً بنحتاج داتا من أكتر من جدول في وقت واحد. Drift بيخلي الموضوع ده سهل وآمن جداً.

Stream> watchEntriesWithBook() {
  final query = select(entries).join([
    innerJoin(books, books.id.equalsExp(entries.bookId)),
  ]);
  
  return query.watch().map((rows) {
    return rows.map((row) {
      return EntryWithBook(
        entry: row.readTable(entries),
        book: row.readTable(books),
      );
    }).toList();
  });
}

ليه مهمة؟ عشان بدل ما تعمل Query للكتب و Query للحسابات وتجمعهم في الـ UI، الداتابيز هي اللي بتبعتهم لك جاهزين في سطر واحد.

🚀 الأداء: الفهارس (Database Indexing) Performance: Database Indexing

لما الداتا بتكبر (آلاف الحسابات)، البحث فيها بيبقى بطيء. الحل هو الـ Indexing.

📁 lib/core/database/app_database.dart
@override
List get indexes => [
  Index('entries_book_id', 'CREATE INDEX idx_entries_book_id ON entries (book_id);'),
];

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

السحر المتولد (The .g.dart Deep Dive) The Generated Magic: .g.dart Deep Dive

هل سألت نفسك إيه اللي موجود جوه ملف الـ .g.dart؟ ده ملف كبير جداً فيه كل الـ SQL الحقيقي اللي الموبايل بيفهمه.

1
Raw SQL Statements

جوه الملف ده، Drift بيحول كلاسات Dart لـ CREATE TABLE حقيقية.

2
Type-Safe Companions

بيولد كلاسات زي BooksCompanion. الـ Companion فايدته إنه بيفرق بين "القيمة اللي مش موجودة" (Absent) وبين الـ "null".

ملاحظة: ممنوع تعدل في الملف ده يدوياً! أي حرف هتكتبه هيتمسح أول ما تشغل الـ build_runner.

🧬 دورة حياة الـ Migration: من الصفر للاحتراف Lifecycle of a Migration: From Zero to Pro

عشان تضيف حقل جديد بأمان، دي الخطوات الإجبارية:

Step 1
تعديل كلاس الجدول (Table Class)

تضيف العمود في ملف الـ tables/*.dart.

Step 2
زيادة رقم النسخة (Version Update)

تروح لـ app_config.dart وتزود الـ databaseVersion بمقدار 1.

Step 3
كتابة كود الـ Upgrade

تستخدم migrator.addColumn جوه app_database.dart.

Step 4
توليد الكود (Generation)

تشغل أمر الـ build_runner عشان تولد الملفات الجديدة.

🔄 التحويل بين الـ Models والـ Entities Relational Mapping: Models vs Entities

في Clean Architecture، الـ Database دايماً بتبعت Models (خاصة بـ Drift)، بس الـ UI بيحب الـ Entities. عشان كدة بنعمل مبرمج صغير اسمه Mapper.

📁 lib/features/books/data/models/book_mapper.dart
extension BookMapper on Book {
  // تحويل من Drift Object لـ Domain Object
  BookEntity toEntity() => BookEntity(
    id: id,
    name: name,
    currency: currency,
  );
}

الفصل ده بيضمن إن لو غيرنا الداتابيز في يوم من الأيام، الـ UI مش هيحس بأي فرق!

🎯 تحدي السيكويل (The SQL Challenge) Interactive Exercise: The SQL Challenge

جرب تكتب كود Drift (تخيلي) عشان تعمل العمليات دي:

1
هات كل الحسابات اللي أكتر من 100 جنيه

الإجابة: (select(entries)..where((t) => t.amount.isBiggerThanValue(100))).get()

2
حدث اسم كتاب معين بالـ ID بتاعه

الإجابة: (update(books)..where((t) => t.id.equals(5))).write(BooksCompanion(name: Value('New Name')))

3
عد كل الكتب الموجودة في الداتابيز

الإجابة: books.id.count().getSingle()

🚀 العمليات الجماعية (Batch Operations) Performance: Batch Operations

لو عندك 1000 سطر داتا وعملت Insert لكل واحد لوحده، الموبايل هيهنج. الحل هو الـ batch.

await batch((batch) {
  // كل العمليات دي هتحصل في "خبطة واحدة" سريعة جداً
  batch.insertAll(entries, companionsList);
});

الـ batch بيقلل وقت الكتابة على القرص (Disk I/O) بنسبة بتوصل لـ 90%!

قائمة أفضل الممارسات (Best Practices) The database_best_practices.md
  • أبداً متعملش عمليات داتابيز تقيلة في الـ build ميثود.
  • أبداً متخزنش صور كبيرة (Blobs) في الداتابيز. خزن المسار (Path) بس.
  • دائماً استخدم الـ watch() عشان الـ UI يفضل Reactive.
  • دائماً اعمل نسخة احتياطية (Backup) قبل ما تجرب Migration جديد.
  • دائماً استخدم الـ DAO لعزل كود الداتابيز عن الـ Business Logic.
📊 التحويل بين Dart و SQLite Dart vs SQLite: Type Mapping Table

الـ SQLite مفيهوش أنواع كتير زي Dart. Drift هو اللي بيقوم بمهمة المترجم بينهم:

Dart Type SQLite Type Column Method
int INTEGER integer()
String TEXT text()
bool INTEGER (0 or 1) boolean()
DateTime INTEGER (Unix Ts) dateTime()
double REAL real()
Uint8List BLOB blob()
🗑️🔗 منطق المسح المتسلسل (Cascading Deletes) Relational Integrity: Cascading Deletes

في MRE CashBook، إحنا مهتمين جداً إن الداتا متكونش "يتيمة" (Orphaned Data). لو مسحت كتاب، ليه نسيب الحسابات بتاعته موجودة وبتاخد مساحة؟

!
KeyAction.cascade

دي خاصية بنحطها في الـ Repository أو الـ Table، بتخلي الـ Database engine نفسه هو اللي يمسح الداتا المربوطة. ده أسرع وأضمن بكتير من إنك تمسحهم يدوي في كود الـ Dart.

انتبه: الـ Cascade قوي جداً، اتأكد إنك مش هتمسح داتا اليوزر محتاجها فعلاً قبل ما تفعل الخاصية دي.

🗺️ خارطة طريق طبقة البيانات Final Database Roadmap

إحنا كدة خلصنا أهم جزء تحت الأرض في المشروع. الدروس الجاية هتعتمد كلياً على اللي فهمته هنا:

  • الفصل 5: إزاي الـ BLoC بياخد الـ Streams اللي بتطلع من هنا.
  • الفصل 6: إزاي بنحقن (Inject) الـ AppDatabase في كل جزء في البرنامج.
  • الفصل 8: إزاي ميزة التقارير بتعمل Join بين الجداول عشان تطلع إحصائيات.
الأسئلة الشائعة (Database FAQ) FAQ: Common Database Questions
هل ينفع استخدم Firebase بدل Drift؟
Can I use Firebase instead of Drift?
ينفع، بس Drift أحسن في حالة الـ Offline-first والتطبيقات اللي مش محتاجة تزامن لحظي مع السيرفر لكل تفصيلة.
Yes, but Drift is better for Offline-first scenarios and apps that don't need real-time sync for every single detail.
إزاي أحمي قاعدة البيانات بكلمة سر؟
How do I password-protect the database?
الدريفت بيدعم sqlcipher، ودي مكتبة بتشفر ملف الـ .db بالكامل.
Drift supports sqlcipher, which encrypts the entire .db file.
يعني إيه Reactive Database بالظبط؟
What exactly is a Reactive Database?
يعني قاعدة بيانات "حية". أول ما الداتا تتغير، هي بتبعت تنبيه لكل الكود اللي مهتم بالتغيير ده عشان يحدث نفسه.
It's a "live" database. As soon as data changes, it notifies all interested code to update itself.
جدول المراجع السريعة (Quick Reference) Quick Reference: Drift Command Table
CommandUsage (AR)Usage (EN)
select(t).get()جلب البيانات مرة واحدة.Fetch data once.
select(t).watch()مراقبة التغييرات (Stream).Watch for changes (Stream).
into(t).insert(c)إضافة سطر جديد.Insert a new row.
update(t).replace(s)تحديث سطر بالكامل.Update an entire row.
delete(t).go()حذف بيانات.Delete data.
📝 الملخص Summary

قاعدة البيانات هي ذاكرة البرنامج. في هذا الفصل، رأينا كيف نبني هذه الذاكرة باستخدام Drift.

الفصل السابقPrevious الفصل التاليNext