الحذف الناعم ليس حذفًا، وتلك هي المشكلة
الـ trait سطر واحد ويغيّر معنى كل قيد تفرّد وكل مفتاح أجنبي وكل طلب محو في التطبيق. وأغلب الشيفرات تتبنّاه دون أن تقرر أيًّا من ذلك.
إضافة SoftDeletes إلى نموذج تكلّف سطرًا واحدًا، ويقدّمها كل درس على أنها تراجع
مجاني. وهي ليست مجانية. إنها تغيّر معنى الحذف في التطبيق كله، والعواقب تهبط في
أماكن لا صلة لها بالنموذج الذي أضفتها إليه.
ماذا يحدث فعلًا
الدالة delete() تكفّ عن الحذف. تضبط deleted_at ويبقى الصف. ونطاق عام يخفيه
عن الاستعلامات. تلك هي الآلية كلها، وكل مشكلة أدناه تتبع من بقاء الصف هناك.
قيود التفرّد تكفّ عن العمل
يسجّل مستخدم بعنوان، ثم يحذف حسابه، ثم يحاول التسجيل مجددًا بالعنوان نفسه. الصف لا يزال في الجدول، والفهرس الفريد لا يزال على العمود، ويفشل التسجيل بخطأ قاعدة بيانات عن سجل لا يراه المستخدم ولا يجده الدعم.
يقول التطبيق إن الحساب غير موجود. وتقول قاعدة البيانات إنه موجود. وكلاهما يقول الحقيقة عن شيء مختلف.
مخرجان، وهما خياران لا إصلاحان:
// صف حي واحد، وأي عدد من المحذوفة
$table->unique(['email', 'deleted_at']);أو حرّر القيمة عند الحذف بإعادة كتابتها:
public function delete(): bool
{
$this->email = "{$this->email}#deleted-{$this->id}";
$this->save();
return parent::delete();
}الأول يحفظ القيمة الأصلية ولا يستطيع فرض التفرّد بين الصفوف الحية والمحذوفة معًا. والثاني يحرّر العنوان ويفقد الأصل - وذلك يهم إن احتجت يومًا أن تعرف لمن كان ذلك الحساب. لا أحدهما خطأ؛ لكن عدم اختيار أيّهما خطأ.
العلاقات تعود بمظهر خاطئ
الأب المحذوف ناعمًا لا يزال يحقق مفاتيحه الأجنبية، لأن الصف موجود. فلا يصير صف الابن يتيمًا في قاعدة البيانات، وضمٌّ لا يعلم بالحذف الناعم سيعيد بكل ارتياح الأبَ المحذوف.
نطاق Eloquent يخفيه حين تستعلم عن النموذج. أما الاستعلامات الخام والتقارير المكتوبة بـ SQL والاستعلامات التجميعية المبنية يدويًا فلا - وهناك تكفّ الأرقام عن التطابق بين شاشتين تعرضان الشيء نفسه.
والأسوأ أن withTrashed() على جانب من العلاقة دون الآخر ينتج نتيجة ليست صحيحة
في أي اتجاه.
المحو ليس حذفًا
يمارس شخص حقه في محو بياناته. فيستدعي التطبيق delete(). ويظل الصف هناك، ويظل
مقروءًا لكل من يملك وصولًا إلى قاعدة البيانات، ويظل في كل نسخة احتياطية.
لم يُمحَ شيء. لم يُستوفَ الالتزام القانوني ويبلّغ النظام أنه استُوفي، وذلك أسوأ من الفشل بصوت عالٍ.
يجب أن يتخطى مسار المحو الآلية عمدًا:
$user->forceDelete();أو، حيث يجب أن تبقى السجلات لأسباب ضريبية أو تدقيقية، جهّل بدل أن تزيل - استبدل الحقول الشخصية، واحتفظ بالصف، وسجّل أن ذلك حدث. وهذا قرار فيه محامٍ، وعلى الشيفرة تنفيذ الجواب الذي يعطيه لا الجواب الافتراضي.
الجدول ينمو فقط
لا شيء يقلّم الصفوف المحذوفة ناعمًا ما لم يكتب أحد المهمة. وبعد سنوات يصير
الجدول في جزء كبير منه صفوفًا لا يراها أحد، وتصير الفهارس أكبر مما تحتاج، ويحمل
كل استعلام شرط deleted_at is null على المخطِّط أن يحسب حسابه.
إن كان الـ trait على نموذج، فينبغي أن توجد سياسة حفظ ومهمة تفرضها. و"نحتفظ بكل شيء إلى الأبد" سياسة صالحة؛ أما ألا توجد سياسة فهو كيف يبلغ جدول أربعين مليون صف منها ستة ملايين تهم.
متى يكون صحيحًا فعلًا
حيث يكون التراجع ميزة يتوقعها المستخدمون - أرشيف، أو سلة مهملات، أو حذف بالخطأ ينبغي أن يستطيع الدعم عكسه.
حيث يكون السجل مشارًا إليه من تاريخ يجب أن يبقى مقروءًا: فاتورة تسمّي عميلًا أُزيل منذئذٍ ينبغي أن تظل تسمّيه.
حيث يكون الحذف سير عمل لا حدثًا، بخطوة موافقة بين التعليم والإزالة.
تلك مجموعة أضيق من "كل نموذج"، وهي حيث ينتهي الـ trait عادةً. ينبغي أن يكون الافتراض حذفًا حقيقيًا، مع إضافة الـ trait حيث يستطيع أحد أن يقول ما الذي يعنيه التراجع لذلك الجدول - وحيث أجاب أحد سؤال التفرّد وسؤال التقارير وسؤال الحفظ قبل حذف أول صف لا بعد أول تذكرة دعم.
قيود التفرد والتقارير أسئلة عن المخطط. وإضافة أيٍّ منها لاحقًا إلى جدول يحذف حذفًا ناعمًا منذ سنتين عملُ بيانات، ويستغرق أطول مما يتوقع الناس. أما سؤال المحو فمعلَّق به موعد قانوني، فموضعه مع القرارات الأخرى التي لا رجعة فيها.
