טיפים לשימוש טוב יותר ב Active Record Validations

23/01/2021

אחד הרעיונות היפים בריילס, ואחד הרעיונות שלקח לי הכי הרבה זמן להבין, הוא ה Active Record Validations. הסיפור בקצרה הוא שלפעמים יש לנו באפליקציה טפסים שמטרתם ליצור או לעדכן מידע בבסיס הנתונים. המידע בבסיס הנתונים בריילס מיוצג על ידי קלאס שנקרא Active Record, ולכן כשצריך לעשות וולידציה על המידע בטפסים אותם Active Records יהיו המקום האידאלי. בפוסט זה אראה בקצרה איך להשתמש ב Active Record Validations ביישום ולאחר מכן אמשיך למספר טיפים לשימוש נכון בהם.

1. איך זה עובד

ב Rails כל טבלה בבסיס הנתונים מיוצגת על ידי מחלקה שיורשת מ Active Record, וכל אוביקט ממחלקה זו מייצג שורה בטבלה. בהגדרת המחלקה אנחנו לא צריכים להגדיר את העמודות (ריילס מזהה אותן אוטומטית). הקוד הבא עשוי להיות קוד שמגדיר את החיבור בין אפליקציית Rails לטבלת ההודעות:

class Message < ApplicationRecord
end

בואו נניח שכל הודעה כוללת את השדות הבאים:

  1. שם השולח
  2. שם הנמען
  3. נושא ההודעה
  4. גוף ההודעה
  5. תאריך יצירת ההודעה
  6. תאריך עדכון אחרון של ההודעה

קוד ריילס שיוצר את טבלת ההודעות נראה כך:

  create_table "messages", force: :cascade do |t|
    t.string "from"
    t.string "to"
    t.string "title"
    t.text "body"
    t.datetime "created_at", precision: 6, null: false
    t.datetime "updated_at", precision: 6, null: false
  end

כמו שהקוד כתוב אין וולידציות כלל על השדות בהודעה. אם נבנה טופס ליצירת הודעה חדשה, משתמש יוכל להזין כל ערך בטופס וגם להשאיר שדות ריקים. ה Active Record Validation נועד לתת את התשתית שמחברת בין וולידציה של השדות לבין הטופס שמשתמש ממלא, וזה עובד כך:

  1. משתמש מזין מידע בטופס והמידע מגיע לשרת

  2. השרת יוצר אוביקט Message וממלא אותו במידע שהמשתמש הקליד בטופס

  3. השרת מפעיל Validation על האוביקט שנוצר. אם היו שגיאות, התוצאה של הוולידציה היא הוספת שדות מידע לאוביקט בצד השרת עם הודעות השגיאה המתאימות לפי השדות של האוביקט.

  4. השרת יוצר HTML או JSON מאוביקט ה Message שנוצר (זה עם השגיאות) והודעות השגיאה המתאימות מוצגות בטופס או בתוך אוביקט ה JSON.

לריילס יש גם תשתית די טובה שמחברת בין טופס לבין השגיאות כך שבקוד ה ERB אנחנו צריכים בסך הכל לכתוב את השדות בטופס באופן הבא:

<%= form_with(model: message, local: true) do |form| %>
  <% if message.errors.any? %>
    <div id="error_explanation">
      <h2><%= pluralize(message.errors.count, "error") %> prohibited this message from being saved:</h2>

      <ul>
        <% message.errors.full_messages.each do |message| %>
          <li><%= message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <div class="field">
    <%= form.label :from %>
    <%= form.text_field :from %>
  </div>

  <div class="field">
    <%= form.label :to %>
    <%= form.text_field :to %>
  </div>

  <div class="field">
    <%= form.label :title %>
    <%= form.text_field :title %>
  </div>

  <div class="field">
    <%= form.label :body %>
    <%= form.text_area :body %>
  </div>

  <div class="actions">
    <%= form.submit %>
  </div>
<% end %>

ובאופן אוטומטי החלק העליון יתמלא בהודעות השגיאה המתאימות שנלקחו ישירות מתוך ה Validation.

בשביל הדוגמה אני מוסיף בקוד של message.rb את התוכן הבא:

class Message < ApplicationRecord
  validates :from, presence: true
  validates :to, presence: true
  validates :title, length: { minimum: 5 }
  validates :body, presence: true
end

המשמעות של הוולידציה היא שבעת הגשת הטופס תתווסף Server Side Validation על המידע וההודעה תישמר רק אם כל השדות עומדים בתנאים שהגדרתי, כלומר:

  1. קיים ערך לשדה from
  2. קיים ערך לשדה to
  3. קיים ערך לשדה title באורך מינימלי של 5 תווים
  4. קיים ערך לשדה body

כל אחד מהשדות שלא מתאים לוולידציה יגרום להצגת הודעת שגיאה בראש הטופס ובנוסף ליצירת div חדש בתוך ה div עם הקלאס field שלו הקלאס field_with_errors, מה שיאפשר לצבוע את המקום הרלוונטי בטופס באדום.

ועכשיו שאנחנו מבינים את הסיפור הזה הנה כמה דברים שחשוב לזכור לגבי וולידציות:

2. הוולידציות נבדקות בצד שרת ואינן מחליפות Client Side Validations

ריילס הוא אומנם מהיר אבל גם שליחת הודעה לשרת המהיר בעולם תיקח יותר זמן מוולידציה שרצה אצל הלקוח בלי לדבר עם השרת. כמעט תמיד בכל מקום שיש וולידציה ב Active Record כדאי להוסיף גם וולידציה מקבילה בטופס (אני אומר כמעט כי זה לא תמיד אפשרי - לדוגמה וידוא שערך מסוים הוא ייחודי במערכת זה משהו שאפשר לעשות רק בצד שרת).

בדוגמה שלנו אני יכול לעדכן את הטופס עם הקוד הבא וכך להרוויח גם וולידציה לפני שליחת ההודעה לשרת (כדי לקבל חווית משתמש טובה יותר), וגם וולידציה בצד השרת (בשביל אבטחת מידע ובשביל פרטים שהלקוח לא יודע עדיין):

<%= form_with(model: message, local: true) do |form| %>
  <% if message.errors.any? %>
    <div id="error_explanation">
      <h2><%= pluralize(message.errors.count, "error") %> prohibited this message from being saved:</h2>

      <ul>
        <% message.errors.full_messages.each do |message| %>
          <li><%= message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <div class="field">
    <%= form.label :from %>
    <%= form.text_field :from, required: true %>
  </div>

  <div class="field">
    <%= form.label :to %>
    <%= form.text_field :to, required: true %>
  </div>

  <div class="field">
    <%= form.label :title %>
    <%= form.text_field :title, required: true, minlength: 6 %>
  </div>

  <div class="field">
    <%= form.label :body %>
    <%= form.text_area :body, required: true %>
  </div>

  <div class="actions">
    <%= form.submit %>
  </div>
<% end %>

ספריה בשם clientsidevalidations מתרגמת בצורה אוטומטית את ה Active Record Validations לוולידציות בצד לקוח באמצעות JavaScript ולפעמים כדאי להשתמש בה.

3. וולידציה אינה אינדקס

רעיון שני שחשוב לזכור הוא שהמטרה של וולידציה היא להציג הודעת שגיאה יפה למשתמש כשאותו משתמש מנסה להקליד נתונים לא נכונים בטופס. וולידציה ב Active Record לא תמנע מהמערכת שלכם לשמור מידע שגוי או שלא עובר וולידציה ויש המון דרכים לשמור מידע ולדלג על הוולידציה.

אם חשוב לכם שמידע "לא נכון" לא ייכנס לבסיס הנתונים הדרך היחידה להבטיח את זה היא ליצור אינדקס או Constraint בבסיס הנתונים עצמו.

נשים לב שבסיסי נתונים לא יודעים לעשות כל מה שהשרת שלנו יודע לעשות, וברובם אין דרך קלה להגדיר לדוגמה אורך מינימלי למחרוזת בעמודה. זה אומר שבהחלט יהיו מצבים בהם תהיה לכם וולידציה אבל חלק מהמידע בבסיס הנתונים לא יעבור אותה. במצב כזה משתמש יצטרך לעדכן ערכים בטופס גם אם הוא לא שינה אותם רק בשביל לשמור את המידע.

בדוגמה שלנו, אני יכול ליצור מתוך Rails Console אוביקט הודעה תוך דילוג על הוולידציה:

m=Message.new(to: 'ynon')
m.save(validate: false)

באפליקציה עצמה אני אראה את ההודעה הזו ברשימת ההודעות ואוכל להיכנס לטופס עריכה כדי לעדכן אותה, אבל בתוך אותו טופס אני אהיה חייב לעדכן את השדות from, title ו body כדי שאוכל לשמור.

4. היו זהירים כשאתם מוסיפים וולידציה חדשה למודל

אין בעיה להוסיף וולידציה למודל שיש בו כבר נתונים (למשל מגירסה קודמת של האפליקציה) אבל עליכם להיות זהירים ואף פעם לא להניח שכל הוולידציות עוברות על נתונים קיימים. כל הוספה של וולידציה צריכה להתחיל ממחשבה על טופס ועל הודעה שמשתמש יראה כשהוא ממלא את הטופס. אם מוסיפים וולידציה על נתונים קיימים יש לוודא שמשתמש יכול לפתוח את האוביקט במצב עריכה ולהוסיף את הערכים החסרים כדי לעבור וולידציה (אחרת אי אפשר יהיה לשמור או לעדכן אפילו שדות אחרים).

5. אפשר להגביל וולידציה ל Context מסוים

המצב הזה של וולידציות חדשות הופך מבלבל במיוחד כשיש מספר דרכים לעדכן אוביקט במערכת. לדוגמה במערכת ההודעות שלנו אפשר לדמיין שלכל הודעה יהיה כפתור עם אייקון של כוכב במסך רשימת ההודעות שיסמן אותה בתור הודעה "חשובה".

עכשיו יש לנו 2 דרכים לעדכן כל הודעה:

  1. טופס העדכון הרגיל של הודעה

  2. לחיצה על כפתור "כוכב" שמעדכנת רק את שדה "important" בהודעה, שדה חדש שיצרנו בשביל הפיצ'ר.

אם באמת נלך לבנות את פיצ'ר הכוכבים נגלה שאנחנו לא יכולים לסמן בתור "חשוב" את ההודעה הלא וולידית שיצרנו בסעיף הקודם. ריילס ייכשל בטענה שהוא לא יכול לעדכן את האוביקט כי האוביקט נכשל בוולידציה, למרות שאנחנו עובדים מתוך ממשק אחר בו אי אפשר לשנות את השדות שלא עברו וולידציה.

אפשר לראות את זה אם נריץ את הקוד הבא מתוך הקונסול:

m = Message.first
m.important = true
m.save
 => false

הדרך להתמודד עם מספר מקורות עדכון בריילס היא לייצר מספר Validation Contexts. כל Validation Context מייצג את הטופס או המסך בו אנחנו רוצים לעדכן את ההודעה בצורה מסוימת, והוולידציות שבו יתאימו לאפשרויות באותו המסך. בחזרה להודעה אפשר לעדכן את הקוד בצורה הבאה:

class Message < ApplicationRecord
  validates :from, presence: true, on: :message_form
  validates :to, presence: true, on: :message_form
  validates :title, length: { minimum: 5 }, on: :message_form
  validates :body, presence: true, on: :message_form
end

בעת שמירת הודעה אנחנו נעביר עכשיו את הקונטקסט המתאים כדי שריילס ידע איזה וולידציות להפעיל:

m = Message.first
m.important = true
m.save(context: :message_index)
 => true

כרגע פונקציית update של ריילס לא יודעת לעבוד עם Custom Validation Context ולכן כדי לעבוד איתם בדרך כלל נרצה להוסיף פונקציה משלנו לכל ה Active Records ביישום:

class ApplicationRecord < ActiveRecord::Base
  self.abstract_class = true

  def update_attibutes_with_context(attributes, *contexts)
    with_transaction_returning_status do
      assign_attributes(attributes)
      save(context: contexts)
    end
  end
end

ולסיום ב Controller נצטרך להוסיף את ה Context המתאים בכל פעולה:

class MessagesController < ApplicationController
  before_action :set_message, only: [:show, :edit, :update, :destroy, :toggle_important]

  # GET /messages
  # GET /messages.json
  def index
    @messages = Message.all
  end

  # GET /messages/1
  # GET /messages/1.json
  def show
  end

  # GET /messages/new
  def new
    @message = Message.new
  end

  # GET /messages/1/edit
  def edit
  end

  def toggle_important
    @message.important = !@message.important
    @message.save(context: :messages_index)
  end

  # POST /messages
  # POST /messages.json
  def create
    @message = Message.new(message_params)

    respond_to do |format|
      if @message.save(context: :message_form)
        format.html { redirect_to @message, notice: 'Message was successfully created.' }
        format.json { render :show, status: :created, location: @message }
      else
        format.html { render :new }
        format.json { render json: @message.errors, status: :unprocessable_entity }
      end
    end
  end

  # PATCH/PUT /messages/1
  # PATCH/PUT /messages/1.json
  def update
    respond_to do |format|
      if @message.update_attributes_with_context(message_params, :message_form)
        format.html { redirect_to @message, notice: 'Message was successfully updated.' }
        format.json { render :show, status: :ok, location: @message }
      else
        format.html { render :edit }
        format.json { render json: @message.errors, status: :unprocessable_entity }
      end
    end
  end

  # DELETE /messages/1
  # DELETE /messages/1.json
  def destroy
    @message.destroy
    respond_to do |format|
      format.html { redirect_to messages_url, notice: 'Message was successfully destroyed.' }
      format.json { head :no_content }
    end
  end

  private
    # Use callbacks to share common setup or constraints between actions.
    def set_message
      @message = Message.find(params[:id])
    end

    # Only allow a list of trusted parameters through.
    def message_params
      params.require(:message).permit(:from, :to, :title, :body)
    end
end

6. אפשר ליצור וולידציה כללית (למרות שזה מבלבל את המשתמשים)

ריילס מאפשר לנו ליצור וולידציה "כללית" על אוביקט שהתוצאה שלה תוצג ברשימת הודעות השגיאה של הטופס אבל לא עבור שדה מסוים. כמעט תמיד כשאתם באים לכתוב וולידציה כללית כזו שווה להשקיע עוד כמה דקות של מחשבה ולמצוא דרך טובה יותר לביצוע המשימה.

לדוגמה נניח שיש לי מערכת פירסום בה משתמשים יכולים ליצור פירסומות שיוצגו בכל מיני אתרים. אני רוצה לוודא שיש לי פרטי כרטיס אשראי של המשתמש לפני שאני נותן לו להעלות פירסומת לאוויר. אפשר לדמיין וולידציה כללית על "פירסומת" שמוודאת שיש לי במערכת את פרטי האשראי של המשתמש לפני שאני נותן לו לשמור אוביקט פירסומת, אבל זאת תהיה חוויה די גרועה למשתמש. הרבה יותר הגיוני לתת למשתמש לבנות ולשמור פירסומות, וליצור מסך ייעודי עבור "העלאה לאוויר" של הפירסומות, ובאותו מסך לדאוג לקבלת אמצעי תשלום אם הוא לא שמור אצלנו.

בגדול כשאנחנו רואים את הפקודה:

errors.add(:base, '...')

בתור קוד של Active Record זה כמעט תמיד נקודת פתיחה טובה ל Refactoring.

יש לכם טיפים נוספים על Active Record Validations? אשמח לשמוע, מוזמנים לשתף בתגובות.