إستخدام ODM للتعامل مع Firestore

هذه الحزمة من الفريق خلف FlutterFire، الخاصة بحُزم Firebase لتطبيقات Flutter. الحزمة تساعد مطوري Flutter للتعامل مع Firestore بشكل آمن.

⚠️ ملاحظة.
هذه الحُزمة تحت التطوير. ربما يحدث فيها تغييرات جذرية في المُستقبل، سيتم تحديث الدرس في حال وجود أي تعديلات.

ماذا يعني ODM

Object Document Mapper ببساطة عبارة عن Interface يساعدنا في التعامل مع Firestore والـDocuments الخاصة بها. يقوم ODM بمعاملة documnents كـTree. بحيث كل Node من هده Tree يقدم العنصر كـجزء من document. ايضًا الدوال الخاصة بـODM تسمح برمجيًا بالوصول الى Tree وتجعل من الممكن تعديل وتغيير المحتوى الخاص بـDocuments.

يقوم ODM أيضًا بتعريف المخططات الخاصة بالبيانات schema. عند تعريف schema الخاصة بك سيقوم ODM بتقديم عدة المميزات.

١. التحقق الثنائي للبيانات.

٢. الاستعلامات بشكل آمن. ودعم جميع طرق استعلام Firestore.

٣. يقدم Flutter Widget لعرض البيانات في UI.

٤. امكانية التحكم في إعادة بناء الواجهة للمستخدم.

سنقوم بإستعراض جميع هذه المميزات خلال هذا الدرس، ايضًا سيكون هناك مثال حي ايضًا.

💻 انشاء مشروع جديد
  • انشئ مشروع جديد عن طريق موجهه الآوامر.
flutter create my_app
cd my_app
  • أو على مشروع سابق.

التثبيت

تثبيت FlutterFire.

في وقت سابق قمنا بشرح تثبيت وإاضافة FlutterFire لمشاريع Flutter تستطيع الوصول لهذا الدرس من هنا.

تثبيت cloud_firestore.

بالطبع اذا اردنا التعامل مع Firestore فنحن بحاجة الى اضافته في المشروع الخاص بنا.

اضافة ODM.

لاستخدام ODM نحتاج الى اضافة حُزم خاصة بتوليد الأكواد تلقائيًا. قم باضافة الآوامر التالية عن طريق موجه الآوامر. لك الحرية في طرق اضافة الحُزم. لكن هذه الطريقة هي الأسرع وتضمن لك تثبيت آخر اصدار من كل حُزمة.

flutter pub add cloud_firestore_odm 
flutter pub add json_annotation

ايضًا نقوم بإضافة هذه الحُزم في Dev Dependancies ستلاحظ وجود flag –dev .

flutter pub add --dev build_runner
flutter pub add --dev cloud_firestore_odm_generator
flutter pub add --dev json_serializable

إنشاء كائن

الكائن model في الواقع هو شكل البيانات التي نريد ان نستدعيها من Firestore او نقوم بإضافتها له. المميز ان ODM يتأكد من صحة البيانات قبل إرسالها. في حال وجود مشكلة سيقوم بالتنبية بوجود خطأ.

سنفترض ان لدينا تطبيق مهام tasks . وايضًا نريد انشاء collection في Firestore باسم tasks. كل مهمة تحتوي على عنوان ووقت للتذكير وايضًا هل تم انهائها ام لا. لإنشاء هذا الكائن model سنقوم بتعريف الكود التالي.

import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:cloud_firestore_odm/cloud_firestore_odm.dart';

part 'task.g.dart';


const firestoreSerializable = JsonSerializable(
  converters: firestoreJsonConverters,
  // The following values could alternatively be set inside your `build.yaml`
  explicitToJson: true,
  createFieldMap: true,
);

@firestoreSerializable
class Task {
  Task({
    required this.title,
    required this.reminder,
    this.isDone = false,
  }) {
    _$assertTask(this);
  }

  final String title;

  final DateTime? reminder;
  final bool isDone;

  factory Task.fromJson(Map<String, dynamic> json) => _$TaskFromJson(json);

  Map<String, dynamic> toJson() => _$TaskToJson(this);
}

تلاحظ من الكود السابق ان المهمة يجب ان تحتوي على عنوان و تاريخ ووقت، لكن حالة انهاء المهمة فهي اختيارية.


إنشاء مرجع “References”.

اذا سبق وتعاملت مع Firestore فغالبًا لديك معرفة سابقة بـRef وانه هو المرجع او العنوان الخاص بكل Doc في Collection الخاصة بك. عند إنشاء Model فقط لا يمكن ان يقوم بأي شئ مالم يكن مرتبط بـRef. لذلك سنحتاج الى انشاء Refrence ونقوم بربط الـكائن model الخاص بنا فيه. سيقوم الـRefrence بتفعيل ODM وهو المهم في هذه المقالة.

لإنشاء `Refrence` سنقوم بإستخدام Collection والذي سيعمل عمل المؤشر في Firestore.

@Collection<Task>('tasks')
final tasksRef = TaskCollectionReference();

تلاحظ ان الكود قام بعمل ربط بين tasks و model Task٫ .


التحقق من البيانات

الآن نريد ان نقوم بوضع الشروط او التحقق من البيانات قبل إرسالها الى Firestore.لاحظ ان Validator يعمل في اتجاهين. عند ارسال بيانات وايضًا عند قرائتها. فمثلا لو قمت بإنشاء Validator لسعر منتج بحيث ان المنتج اقل سعر له يكون مثلا 10 ريال. فعند اضافة البيانات وقمت بكتابة سعر أقل من 10 سيقوم Flutter بإظهار خطأ في البيانات. ايضًا عندما يقوم بقراءة البيانات من Firestore سيقوم بتجاهل جميع الأسعار أقل من 10 ريال.

بحكم ان تطبيقنا لا يحتوي على أسعار سنقوم بإنشاء Validator يقوم بإجبار المستخدم بإدخال عنوان للملاحظة لايقل عن 10 أحرف. في حال قام بكتابة أقل من 10 أحرف سيظهر له خطأ.

class TitleValidator implements Validator {
  const TitleValidator();
  @override
  void validate(Object? value, String? propertyName) {
    // title must longer than 3 characters
    if (value is String && value.length < 10) {
      throw Exception('Title must be longer than 10 characters');
    }
  }
}

الآن سنقوم سنقوم بالإشارة الى Title لإستخدام كائن Validator .

@firestoreSerializable
class Task {
  Task({
    required this.title,
    required this.reminder,
    this.isDone = false,
  }) {
    _$assertTask(this);
  }

  @TitleValidator()
  final String title;

  final DateTime? reminder;
  final bool isDone;

  factory Task.fromJson(Map<String, dynamic> json) => _$TaskFromJson(json);

  Map<String, dynamic> toJson() => _$TaskToJson(this);
}

تلاحظ في الكود السابق هناك تغيير مختلفة عن الكائن السابق الذي قمنا بإنشائه. اولا اضفنا _$assertTask(this); لتفعيل الـValidator. ايضا وضعنا كائن validator كـAnnotation للـمُتغير Title.

تكوين الأكواد

دائمًا اذا وجدت أي Annotaion في أي من الكائنات فغالبًا ستحتاج الى تكوين هذه الأكواد عن طريق build_runner. وهذا يتم عن طريق موجه الآوامر Terminal. الآن من خلال موجه الآوامر قم بتنفيذ الأمر التالي.

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

ستظهر بعض الرسائل تأكد من جميع التكوينات انتهت بشكل صحيح.


استخدام المرجع Refrences

الآن انشئنا ODM وايضًا قمنا بتكوين الأكواد. المتبقي فقط هو التعامل مع البيانات من عرض وتحديث وحذف. سنقوم اولًا بعرض جميع Task لدينا. سنقوم باستخدام Widget مضمن مع حُزمة ODM وهو FirestoreBuilder. هذا Widget مشابه للـFutureBuilder و StreamBuilder. يوجد فيه العديد من الإحتمالات.

FirestoreBuilder<TaskQuerySnapshot>(
                  ref: tasksRef,
                  builder: (context, AsyncSnapshot<TaskQuerySnapshot> snapshot,
                      Widget? child) {
                        // check if there is an error
                    if (snapshot.hasError) {
                      return Text('Something went wrong! ${snapshot.error}');
                    }
                    // Access the QuerySnapshot
                    TaskQuerySnapshot querySnapshot = snapshot.requireData;
                    return ListView.builder(
                      itemCount: querySnapshot.docs.length,
                      itemBuilder: (context, index) {
                        Task task = querySnapshot.docs[index].data;
                        return Card(
                          child: ListTile(
                            leading: checkboxWidget(task),
                            title: Text(
                              task.title,
                              style: TextStyle(
                                decoration: task.isDone
                                    ? TextDecoration.lineThrough
                                    : TextDecoration.none,
                              ),
                            ),
                            subtitle: Text(task.reminder.toString()),
                            trailing: trailingWidget(context, task),
                          ),
                        );
                      },
                    );
                  })

الآن عند تشغيل التطبيق ستظهر لك شاشة فارغة لاتحتوي على أي tasks. الآن قمنا بقراءة البيانات.

🔴 ملاحظة

لم اتطرق الى جميع Widgets الخاصة بـFlutter.حتى لا يطول الدرس في غير المخصص له. 👩🏻‍💻🧑🏻‍💻

إضافة مهمة.

نستطيع إضافة بيانات الى Firestore بإستخدام الـtodosRef، ستكون الإضافة سهله جدا مثل المعمول به في Firestore. ولكن هنا سيتم التأكد من Validator قبل الإضافة. اذا كنت تتذكر اننا قمنا بوضع شرط ان طول العنوان لا يقل عن 10 أحرف، سنقوم الآن بعرض شاشة الإضافة وايضا في حال اننا قمنا بإضافة نص قليل.

final task = Task(
                            title: _controller.text,
                            reminder: DateTime.tryParse(
                              _reminderController.text,
                            ),
                          );
                          tasksRef.add(task);

انشئنا Object Task وقمنا بإضافة البيانات له من TextController وبعد ذلك قمنا بإضافتها الى tasksRef. عند القيام بذلك ستكون النتيجة كما في الأسفل.

تلاحظ تم عرض الملاحظة لأنها أكبر من 10 أحرف.

سنقوم الآن بمحاولة كتابة ملاحظة اقل من 10 أحرف وسنقوم بالإضافة ومشاهدة النتائج.


تعديل مهمة.

تعديل البيانات مشابهه للإضافة، الإختلاف هنا ان نريد الوصول للـid الخاص الـDoc او بـTask, لكن لم نقم بإضافة اي شئ يخص id في task model مالحل؟ حزمة ODM لا تقم بإعادة الـid مع الـDoc، نحتاج الى تعديل task model ليتناسب مع التطبيق الخاص بنا. يجب ان يكون id من نوع String أيضًا لا يمكن ان يكون هناك أكثر من @id annotaion . سيكون الشكل النهائي للـ ـtask model الخاص بنا كما ستلاحظ.

@Collection<Task>('tasks')
@firestoreSerializable
class Task {
  Task({
    required this.title,
    required this.reminder,
    this.id,
    this.isDone = false,
  }) {
    _$assertTask(this);
  }

  @Id()
  final String? id;
  @TitleValidator()
  final String title;

  final DateTime? reminder;
  final bool isDone;

  factory Task.fromJson(Map<String, dynamic> json) => _$TaskFromJson(json);

  Map<String, dynamic> toJson() => _$TaskToJson(this);
}

الآن يمكننا التعديل وايضًا الحذف بإستخدام id الخاص بالـtask.

tasksRef.doc(task.id).update(
                                    title: task.title,
                                    reminder: task.reminder,
                              );
رسالة منبثقة لتعديل الملاحظات.

حذف مهمة.

حذف المهمة ربما هو من اسهل الاسطر. فقط مرر id الخاص بالمهمة ملحقًا بكلمة delete

 tasksRef.doc(task.id).delete();

الآن استعرضنا تقريبًا جميع اوامر CRUD الخاصة بـFirestore. وسنعود الآن للهم وهو اجراء الاستعلامات queries. ذكرنا من مميزات ODM انها آمنة للاستعلامات وللبيانات. الآمان هنا خاص بنوعية البيانات والإضافة والحذف ليس الآمان الخاص بالتشفير وخلافه.

الاستعلامات Queries:

الاستعلام باستخدام ODM واضح جدا وصريخ. لو افترضنا اننا نريد ان نعرض فقط المهام المكتملة. ونتجاهل المهام غير المكتملة كـFilter للبيانات. سيكون البحث بهذا الشكل.

تلاحظ عن الضغط على Filter يقوم بالتبديل بين المهام المنجزه وغير المنجزه. الكود يكون كما في الأسفل.

irestoreBuilder<TaskQuerySnapshot>(
                    ref: tasksRef.whereIsDone(isEqualTo: _isDone),
                    builder: (context,
                        AsyncSnapshot<TaskQuerySnapshot> snapshot,
                        Widget? child) {
                      // check if there is an error
                      if (snapshot.hasError) {
                        return Text('Something went wrong! ${snapshot.error}');
                      }
                      // check if data is loading
                      if (!snapshot.hasData) {
                        return const Text('Loading Tasks...');
                      }
                      // check if there is no data
                      if (snapshot.data!.docs.isEmpty) {
                        // center card with empty icon
                        return const Center(
                          child: Card(
                            child: Padding(
                              padding: EdgeInsets.all(8.0),
                              child: Icon(
                                Icons.hourglass_empty,
                                size: 180,
                              ),
                            ),
                          ),
                        );
                      }
                      // Access the QuerySnapshot
                      TaskQuerySnapshot querySnapshot = snapshot.requireData;
                      return ListView.builder(
                        itemCount: querySnapshot.docs.length,
                        itemBuilder: (context, index) {
                          Task task = querySnapshot.docs[index].data;
                          return Card(
                            child: ListTile(
                              leading: checkboxWidget(task),
                              title: Text(
                                task.title,
                                style: TextStyle(
                                  decoration: task.isDone
                                      ? TextDecoration.lineThrough
                                      : TextDecoration.none,
                                ),
                              ),
                              subtitle: Text(task.reminder.toString()),
                              trailing: trailingWidget(context, task),
                            ),
                          );
                        },
                      );
                    }))

يمكنك القيام بالاستعلام عن التاريخ ايضا والعنوان باستخدام الأكواد التالية.

 ref: tasksRef.whereReminder(isGreaterThan: ,isLessThan: ),

التعامل مع المجموعات الفرعية. Subcollection

تحتاج في بعض التطبيقات الى subcollection ، فمثلا لو اردت ان تكون المهام متشاركة مع عدد من الأشخاص وكل شخص مطلوب منه انجاز مهمة او التعاون على مهمة معينة. نستطيع انشاء subcollection يوجد فيه الاعضاء المشاركين.

سنقوم بإفتراض ان لدينا مستخدمين وسنقوم بإضافتهم كـمجموعة فرعية للمهام لدينا. سنقوم بجميع الخطوات السابقة عدا اننا سنظيف annotaion الى collection الـملاحظات.

@firestoreSerializable
class Member {
  Member({
    required this.name,
    required this.email,
    this.id,
  });
  @Id()
  final String? id;
  final String name;
  final String email;

  factory Member.fromJson(Map<String, dynamic> json) => _$MemberFromJson(json);

  Map<String, dynamic> toJson() => _$MemberToJson(this);
}

الآن سنقوم بالتعديل على task ليتناسب مع المجموعة الفرعية.

@Collection<Task>('tasks')
@Collection<Member>('tasks/*/members')
@firestoreSerializable
class Task {
  Task({
    required this.title,
    required this.reminder,
    this.id,
    this.isDone = false,
  }) {
    _$assertTask(this);
  }

  @Id()
  final String? id;
  @TitleValidator()
  final String title;

  final DateTime? reminder;
  final bool isDone;

  factory Task.fromJson(Map<String, dynamic> json) => _$TaskFromJson(json);

  Map<String, dynamic> toJson() => _$TaskToJson(this);
}

يتم الوصول الى المجموعة الفرعية باستخدام الكود التالي.

                          MemberCollectionReference membersRef = tasksRef.doc(task.id).members;

جميع عمليات Refrences يتم تطبيقها أيضًا على Subcollection من إضافة وحذف وتعديل وعرض.

الخاتمة.

يستخدم Object Document Mapper للعمل بشكل وسريع وآمن مع Firestore. ويقدم العديد من المميزات والخيرات. يمكن ايضًا التعامل مع subcollection وتنفيذ جميع عمليات Refrence عليه أيضًا. مازالت الحزمة تحت التطوير ولكنها واعده جدًا.

اترك تعليقاً

لن يتم نشر عنوان بريدك الإلكتروني. الحقول الإلزامية مشار إليها بـ *