SQLite مع sqflite: إعداد قاعدة البيانات وتصميم المخطط
SQLite مع sqflite: إعداد قاعدة البيانات وتصميم المخطط
عندما تحتاج التطبيقات إلى تخزين بيانات هيكلية علائقية محلياً — مثل الملاحظات التي ينشئها المستخدم، أو استجابات API المخزنة مؤقتاً، أو السجلات القابلة للعمل دون اتصال — فإن قاعدة بيانات SQL الكاملة هي الأداة المناسبة. sqflite هو المكوّن الإضافي الأكثر استخداماً في Flutter لـ SQLite، محرك قاعدة البيانات العلائقية المستقل الذي يعمل مباشرةً على الجهاز. يغطي هذا الدرس كيفية فتح قاعدة بيانات sqflite، وتعريف مخطط مُصنَّف بالإصدارات باستخدام onCreate، وكتابة جمل DDL صحيحة، وتغليف كل شيء في مساعد singleton آمن للخيوط.
إضافة sqflite إلى مشروعك
أضف الحزمتين المطلوبتين إلى pubspec.yaml:
dependencies:
sqflite: ^2.3.3 # ربط SQLite مع Flutter
path: ^1.9.0 # أدوات مسارات متعددة الأنظمة
شغّل flutter pub get لتثبيتهما. حزمة path ضرورية لبناء مسار الملف المطلق حيث يخزن SQLite ملف .db على الجهاز.
فتح قاعدة بيانات باستخدام openDatabase
استدعاء API الأساسي هو openDatabase(path, version: n, onCreate: callback). يحدد sqflite موقع الملف (أو ينشئه)، ويُشغّل أي استدعاءات ترحيل معلقة، ويُعيد مقبض Database مغلفاً في Future. يُخزَّن ملف قاعدة البيانات في دليل مستندات التطبيق الخاص به، الذي يبقى محفوظاً عبر إعادة تشغيل التطبيق.
getDatabasesPath() من sqflite مع join() من حزمة path لبناء مسار الملف. الترميز الثابت لمسار مطلق أو استخدام مسار نسبي سيفشل على أجهزة وأنظمة تشغيل مختلفة.import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';
Future<Database> openMyDatabase() async {
// تحديد دليل قواعد البيانات الخاص بالنظام
final dbPath = await getDatabasesPath();
// join ينتج مثلاً: /data/user/0/com.example.app/databases/app.db
final path = join(dbPath, 'app.db');
return openDatabase(
path,
version: 1,
onCreate: (Database db, int version) async {
// يُستدعى فقط عند عدم وجود ملف قاعدة البيانات بعد
await db.execute('''
CREATE TABLE notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
body TEXT NOT NULL DEFAULT '',
color INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
)
''');
},
);
}
تعريف مخطط مُصنَّف بالإصدارات باستخدام onCreate
يستقبل استدعاء onCreate كلاً من قاعدة البيانات الجديدة Database ورقم version المستهدف. داخله، تُنفّذ جملة أو أكثر من جمل CREATE TABLE باستخدام db.execute(). اعتبارات DDL الرئيسية:
- INTEGER PRIMARY KEY AUTOINCREMENT — يُسند SQLite تلقائياً معرّفاً فريداً متزايداً لكل صف مُدرج.
- قيود NOT NULL — تفرض تكامل البيانات على مستوى قاعدة البيانات وليس فقط في كود Dart.
- قيم DEFAULT — تضمن قيمة احتياطية منطقية عند حذف أعمدة اختيارية أثناء الإدراج.
- INTEGER للقيم المنطقية والطوابع الزمنية — لا يوجد في SQLite نوع bool أو datetime أصلي؛ خزّن القيم المنطقية كـ
0/1والطوابع الزمنية كميلي ثانية من نظام Unix الزمني. - قيود FOREIGN KEY — تربط الجداول المرتبطة؛ يجب تفعيلها لكل اتصال باستخدام
PRAGMA foreign_keys = ON.
'''...''') لجمل SQL. أسهل في القراءة والتنسيق والمقارنة من نصوص السطر الواحد المتسلسلة.بناء مساعد قاعدة البيانات كـ Singleton
فتح قاعدة البيانات عملية مكلفة وغير متزامنة. النمط الموصى به هو singleton: نسخة واحدة من الفئة تحتفظ بمقبض Database وتُظهره عبر getter يُهيَّأ بشكل كسول. جميع أجزاء التطبيق تشترك في هذا الاتصال الواحد، مما يتجنب مقابض الملفات المكررة وحالات التعارض أثناء بدء التشغيل.
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';
class DatabaseHelper {
// المُنشئ الخاص يمنع الإنشاء الخارجي
DatabaseHelper._internal();
// النسخة الوحيدة، مخزنة كحقل ثابت
static final DatabaseHelper instance = DatabaseHelper._internal();
// حقل احتياطي قابل للإفراغ؛ null تعني "لم تُفتح بعد"
Database? _database;
// getter العام — يفتح قاعدة البيانات في أول استدعاء، ويُعيد المقبض المخزن مؤقتاً بعدها
Future<Database> get database async {
_database ??= await _initDatabase();
return _database!;
}
Future<Database> _initDatabase() async {
final dbPath = await getDatabasesPath();
final path = join(dbPath, 'notes_app.db');
return openDatabase(
path,
version: 2,
onCreate: _onCreate,
onUpgrade: _onUpgrade,
// تفعيل تطبيق المفاتيح الخارجية لكل اتصال
onConfigure: (db) async => await db.execute('PRAGMA foreign_keys = ON'),
);
}
Future<void> _onCreate(Database db, int version) async {
// استخدام Batch لجمل DDL متعددة — تُنفَّذ بشكل ذري
final batch = db.batch();
batch.execute('''
CREATE TABLE categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE
)
''');
batch.execute('''
CREATE TABLE notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
body TEXT NOT NULL DEFAULT '',
is_pinned INTEGER NOT NULL DEFAULT 0,
category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
''');
// يُسرّع عمليات البحث التي تصفّي أو ترتّب حسب الفئة
batch.execute(
'CREATE INDEX idx_notes_category ON notes(category_id)',
);
await batch.commit(noResult: true);
}
Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
if (oldVersion < 2) {
// ترحيل المخطط: إضافة عمود جديد في الإصدار 2
await db.execute(
'ALTER TABLE notes ADD COLUMN updated_at INTEGER NOT NULL DEFAULT 0',
);
}
}
}
استخدام الـ Singleton في التطبيق
يمكن لأي جزء من التطبيق الوصول إلى قاعدة البيانات عبر النسخة المشتركة:
// إدراج صف
Future<int> insertNote(Map<String, dynamic> note) async {
final db = await DatabaseHelper.instance.database;
return db.insert('notes', note, conflictAlgorithm: ConflictAlgorithm.replace);
}
// الاستعلام عن جميع الصفوف
Future<List<Map<String, dynamic>>> getAllNotes() async {
final db = await DatabaseHelper.instance.database;
return db.query('notes', orderBy: 'created_at DESC');
}
أفضل ممارسات تصميم المخطط
- استخدم أسماء جداول بصيغة الجمع بـ snake_case (
notes،categories) وأسماء أعمدة بصيغة المفرد بـ snake_case. - خزّن جميع الطوابع الزمنية كـ ميلي ثانية من نظام Unix الزمني (
DateTime.now().millisecondsSinceEpoch) لضمان الترتيب الموثوق والاتساق عبر الأنظمة. - تجنب تخزين كتل JSON في أعمدة TEXT للبيانات التي تحتاج إلى تصفية أو ربط — بدلاً من ذلك، طبّق التطبيع في جداول منفصلة.
- زِد دائماً رقم الإصدار ونفّذ
onUpgradeقبل شحن تغييرات المخطط؛ لا تُعدّل المخطط داخلonCreateمباشرةً لإصدار موجود.
openDatabase مطلقاً مباشرةً داخل طريقة build() في الودجت أو داخل FutureBuilder بدون تخزين النتيجة مؤقتاً. كل استدعاء لـ build() سيُعيد فتح قاعدة البيانات، منتجاً مقابض ملفات متعددة وفي نهاية المطاف أخطاء DatabaseException: database is locked.ملخص
يبدأ سير عمل sqflite للبيانات المحلية الهيكلية بـ openDatabase(path, version, onCreate). يُشغّل استدعاء onCreate جمل DDL التي تعرّف الجداول والقيود والفهارس. تغليف ذلك في DatabaseHelper singleton مع getter database يُهيَّأ بشكل كسول يضمن أن العملية المكلفة للفتح تحدث مرة واحدة فقط وأن جميع الاستعلامات تشترك في اتصال واحد. تمنحك أرقام الإصدارات واستدعاء onUpgrade مساراً آمناً وتدريجياً لتطوير المخطط بمرور الوقت دون فقدان بيانات المستخدم.