באג ריפורט: מחיקה מרובה וחיפוש
פעמיים בתקופה האחרונה נתקלתי באותו באג בשתי מערכות שונות ובשני המקרים עם תוצאה הרסנית, וזה לבד מצדיק פוסט קטן בתקווה שיעזור גם לכן להימנע מבעיות דומות.
1. מה קרה
במסך רשימת פריטים במערכת היתה לנו תיבת חיפוש וגם אפשרות לביצוע פעולה (למשל מחיקה) על כל הפריטים. בעזרת תיבת החיפוש משתמשים יכלו לקבל רק פריטים שמתאימים למילת חיפוש מסוימת. ברגע שמשתמש סינן חלק מהפריטים בתיבת החיפוש ואז לחץ "בחר הכל" לצורך ביצוע פעולה על כל הפריטים בתוצאות החיפוש, המערכת "שכחה" שאנחנו בתוך חיפוש וביצעה את הפעולה על כל הפריטים.
2. למה זה קרה
בשביל לממש ביצוע פעולה על מספר פריטים מרשימה, אם כל הפריטים מופיעים בקלאיינט, אפשר לשלוח לשרת את רשימת כל הפריטים (או מזהי הפריטים) עליהם רוצים לבצע את הפעולה. במקרה שלנו, ובדומה לממשק בג'ימייל ובמערכות נוספות, הרשימה כוללת המון המון פריטים מחולקים לעמודים. אפשרות "בחר הכל" אמורה לסמן את כל הפריטים מכל הדפים, כולל אלה שעכשיו אין עליהם מידע בקלאיינט.
הפיתרון שנבחר היה לשלוח פרמטר מיוחד שמציין שהפעולה שאנחנו מבצעים צריכה לרוץ על כל הפריטים כולל אלה שהקלאיינט לא רואה כרגע כי הם נמצאים בדפים אחרים.
הבעיה שלא היה תיאום בין פרמטר זה לבין פרמטר החיפוש: בקשה אחת סיננה את הפריטים לפי מילת חיפוש מסוימת, ובקשה אחריה ביצעה "מחיקה מרובה" ומחקה את כל הפריטים, בלי שהשרת יוכל להבין ש"כל הפריטים" אמורים היו להיות רק "כל הפריטים שמתאימים לחיפוש".
3. איך כדאי למנוע בעיות דומות בעתיד
המקור האמיתי לבעיה הוא מימוש לא נכון של מנגנון החיפוש. אם ניקח דוגמה מ Rails אפשר לדמיין קונטרולר בשם ItemsController עם פעולת index שמציגה את כל הפריטים ומטפלת בחיפוש:
class ItemsController < ApplicationController
def index
@items = Item.where(owner: current_user)
if params[:search]
@items = @items.where("name like '%?%'", params[:search])
end
end
end
ופה הבעיה - חיפוש הוא לא "עוד דרך" להציג תוצאות אלא הוא שינוי Context, הוא ממש משפיע על כל הפעולות על המסך שעכשיו צריכות להיות מושפעות מרשימת התוצאות.
גישה טובה יותר לכתיבת קוד החיפוש היא לבנות אותו בתור Controller נפרד עם פעולת index משלו:
class ItemsController < ApplicationController
def index
@items = Item.where(owner: current_user)
end
end
class ItemsSearchController < ApplicationController
def index
@items = Item.where(owner: current_user).where("name like '%?%'", params[:search])
end
end
בגישה כזאת כל הכפתורים ב View יכולים לשלוח אותנו ל Actions מתאימים ב Controller שלהם, ואז כפתור של Delete All היה מגיע ל ItemsSearchController#delete_all
וזורק שגיאה כי הפעולה לא מומשה עדיין, במקום למחוק את כל הפריטים בלי קשר לתוצאות החיפוש.