محرك البيانات (Drift & SQLite) The Data Engine (Drift & SQLite)
تعلم كيف يتم تخزين وإدارة البيانات محلياً في MRE CashBook باستخدام Drift. Learn how data is stored and managed locally in MRE CashBook using 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.
| Feature | Advantage (EN) | Benefit (AR) |
|---|---|---|
| Type Safety | Compile-time checks | مستحيل تبعت رقم لمكان محتاج نص. |
| Reactivity | Streams everywhere | الشاشة بتحدث نفسها أول ما الداتا تتغير. |
| Migration | Powerful API | تطوير قاعدة البيانات من نسخة لنسخة بسهولة. |
| Native APIs | Direct SQLite access | سرعة جبارة في العمليات المعقدة. |
ملف app_database.dart هو المركز اللي بيجمع كل الجداول والـ DAOs. تعالو نفهم الكود ده بيعمل إيه بالظبط:
@DriftDatabase(
tables: [Books, Entries, ActivityLogs, EntryFields, EntryFieldValues],
daos: [ActivityLogDao, BookDao, EntryDao],
)
class AppDatabase extends _$AppDatabase {
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
// ...
}
دي الـ Annotation اللي بتقول للـ Build Runner: "يا بيلد رانر، من فضلك ولد لي كود الـ Boilerplate للجداول دي".
ده كلاس "سحري" بيتولد أوتوماتيك في ملف app_database.g.dart وبيحتوي على كل السيكويل (SQL) اللازم للتعامل مع الجداول.
في Drift، بنعرف الجداول عن طريق كلاسات Dart. ده مثال لجدول "الكتب" (Books):
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))();
}
عشان الـ ID يزيد لوحده مع كل كتاب جديد، وميحصلش تكرار.
عشان نضمن إن الصف ميلكنش "ناقص" داتا لو اليوزر مبعتهاش.
لما بنحب نضيف عمود جديد لمستخدمين البرنامج، لازم نعمل "Migration". ده واحد من أخطر وأهم الأجزاء في الكود.
// v5 → v6: إضافة عمود isIncludedInTotal للكتب
if (from < 6) {
await migrator.addColumn(books, books.isIncludedInTotal);
}
تحذير: لو نسيت تعمل Migration ورفعت التحديث، التطبيق هيعمل Crash عند كل اليوزرز! دايماً جرب الـ Migration قبل ما تببلش.
بدل ما نحط كل الكود في ملف واحد، بنقسمه لـ 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.
@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();
}
دي بترجع Stream. يعني لو أي حد مسح أو ضاف كتاب من أي مكان في الأبلكيشن، الشاشة اللي فاتحة ديس لستة الكتب هتتحدث لوحدها فوراً!
لما بتعمل استيراد (Import) لداتا قديمة، الـ IDs ممكن تتعارض مع الداتا الموجودة. عشان كدة عملنا خوارزمية ذكية بتغير الـ IDs وهي بتعمل Import.
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)));
}
لو الـ Entry (الحساب) كان مربوط بالكتاب رقم 12، وفي الداتابيز الجديدة الكتاب ده بقى رقمه 154، الخوارزمية دي بتضمن إن الحساب يفضل مربوط بنفس الكتاب بس بالرقم الجديد.
دريفت مش بس قاعدة بيانات، هي محرك أحداث (Event Engine). أي تعديل في سطر واحد بيبعت إشارة لكل الـ Streams اللي بتراقب الجدول ده.
حتى الـ Joins المعقدة (ربط الجداول) بتكون Reactive. لو غيرت اسم الكتاب، الـ Stream اللي بيعرض الحسابات مع أسماء كتبها هيتحدث أوتوماتيك.
في العمليات المالية، "يا إما كل حاجة تحصل يا إما ولا حاجة تحصل". ده مبدأ الـ Transaction.
await transaction(() async {
await into(entries).insert(newEntry);
await into(activityLogs).insert(log);
// لو الـ log فشل، الـ entry هيتمسح كأنه محصلش (Rollback)
});
عشان تكون محترف، لازم تعرف إزاي تبص على ملف الداتا الحقيقي على جهازك. بما إنك شغال على Mac، دي الخطوات:
sqlite3 ~/Library/Application\ Support/mre_cashbook.db
بتعرض لك كل الجداول اللي Drift عملها في الخلفية.
بتعرض كل الداتا اللي موجودة في جدول الكتب حالياً.
دريفت بيعتمد على الـ Code Generation عشان يضمن الـ Type Safety. أي تعديل في الجداول لازم يتبعه الأمر ده:
dart run build_runner build --delete-conflicting-outputs
الأوبشن ده مهم جداً عشان يمسح أي ملفات قديمة بايظة ويولد ملفات جديدة نضيفة.
في CashBook، مفيش حركة بتتم من غير ما تتسجل. جدول ActivityLogs بيسجل مين عمل إيه وإمتى.
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. يعني مثلاً، مينفعش تضيف "حساب" لكتاب مش موجود أصلاً.
class Entries extends Table {
IntColumn get id => integer().autoIncrement()();
// ربط الحساب بالـ ID بتاع الكتاب
IntColumn get bookId => integer().references(Books, #id, onDelete: KeyAction.cascade)();
}
دي قاعدة "المسح المتتالي". لو مسحت كتاب، كل الحسابات اللي جواه هتتمسح أوتوماتيك من غير ما تعمل مشاكل في الداتابيز.
أحياناً بنحتاج داتا من أكتر من جدول في وقت واحد. 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، الداتابيز هي اللي بتبعتهم لك جاهزين في سطر واحد.
لما الداتا بتكبر (آلاف الحسابات)، البحث فيها بيبقى بطيء. الحل هو الـ Indexing.
@override
List get indexes => [
Index('entries_book_id', 'CREATE INDEX idx_entries_book_id ON entries (book_id);'),
];
الفهرس ده بيخلي الموبايل يوصل للحسابات المطلوبة بسرعة البرق من غير ما يقرأ كل الـ Database.
هل سألت نفسك إيه اللي موجود جوه ملف الـ .g.dart؟ ده ملف كبير جداً فيه كل الـ SQL الحقيقي اللي الموبايل بيفهمه.
جوه الملف ده، Drift بيحول كلاسات Dart لـ CREATE TABLE حقيقية.
بيولد كلاسات زي BooksCompanion. الـ Companion فايدته إنه بيفرق بين "القيمة اللي مش موجودة" (Absent) وبين الـ "null".
ملاحظة: ممنوع تعدل في الملف ده يدوياً! أي حرف هتكتبه هيتمسح أول ما تشغل الـ build_runner.
عشان تضيف حقل جديد بأمان، دي الخطوات الإجبارية:
تضيف العمود في ملف الـ tables/*.dart.
تروح لـ app_config.dart وتزود الـ databaseVersion بمقدار 1.
تستخدم migrator.addColumn جوه app_database.dart.
تشغل أمر الـ build_runner عشان تولد الملفات الجديدة.
في Clean Architecture، الـ Database دايماً بتبعت Models (خاصة بـ Drift)، بس الـ UI بيحب الـ Entities. عشان كدة بنعمل مبرمج صغير اسمه Mapper.
extension BookMapper on Book {
// تحويل من Drift Object لـ Domain Object
BookEntity toEntity() => BookEntity(
id: id,
name: name,
currency: currency,
);
}
الفصل ده بيضمن إن لو غيرنا الداتابيز في يوم من الأيام، الـ UI مش هيحس بأي فرق!
جرب تكتب كود Drift (تخيلي) عشان تعمل العمليات دي:
الإجابة: (select(entries)..where((t) => t.amount.isBiggerThanValue(100))).get()
الإجابة: (update(books)..where((t) => t.id.equals(5))).write(BooksCompanion(name: Value('New Name')))
الإجابة: books.id.count().getSingle()
لو عندك 1000 سطر داتا وعملت Insert لكل واحد لوحده، الموبايل هيهنج. الحل هو الـ batch.
await batch((batch) {
// كل العمليات دي هتحصل في "خبطة واحدة" سريعة جداً
batch.insertAll(entries, companionsList);
});
الـ batch بيقلل وقت الكتابة على القرص (Disk I/O) بنسبة بتوصل لـ 90%!
- أبداً متعملش عمليات داتابيز تقيلة في الـ
buildميثود. - أبداً متخزنش صور كبيرة (Blobs) في الداتابيز. خزن المسار (Path) بس.
- دائماً استخدم الـ
watch()عشان الـ UI يفضل Reactive. - دائماً اعمل نسخة احتياطية (Backup) قبل ما تجرب Migration جديد.
- دائماً استخدم الـ
DAOلعزل كود الداتابيز عن الـ Business Logic.
الـ 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() |
في MRE CashBook، إحنا مهتمين جداً إن الداتا متكونش "يتيمة" (Orphaned Data). لو مسحت كتاب، ليه نسيب الحسابات بتاعته موجودة وبتاخد مساحة؟
دي خاصية بنحطها في الـ Repository أو الـ Table، بتخلي الـ Database engine نفسه هو اللي يمسح الداتا المربوطة. ده أسرع وأضمن بكتير من إنك تمسحهم يدوي في كود الـ Dart.
انتبه: الـ Cascade قوي جداً، اتأكد إنك مش هتمسح داتا اليوزر محتاجها فعلاً قبل ما تفعل الخاصية دي.
إحنا كدة خلصنا أهم جزء تحت الأرض في المشروع. الدروس الجاية هتعتمد كلياً على اللي فهمته هنا:
- الفصل 5: إزاي الـ BLoC بياخد الـ Streams اللي بتطلع من هنا.
- الفصل 6: إزاي بنحقن (Inject) الـ
AppDatabaseفي كل جزء في البرنامج. - الفصل 8: إزاي ميزة التقارير بتعمل Join بين الجداول عشان تطلع إحصائيات.
sqlcipher، ودي مكتبة بتشفر ملف الـ .db بالكامل.sqlcipher, which encrypts the entire .db file.| Command | Usage (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. |
قاعدة البيانات هي ذاكرة البرنامج. في هذا الفصل، رأينا كيف نبني هذه الذاكرة باستخدام Drift.