Day29. Rails MVC 的 Model - 与资料库联络的桥梁

碍於篇幅的关系,来不及介绍关於Rails MVC的所有全貌。在Day23介绍了基本的MVC操作,今天我们要来更深入与资料库互通讯息的model层。

对於资料库的行为不外乎是写入资料、删除资料、查询资料。我们可以把资料库想像成是excel表单,我们必须要对表单做操作,而model负责的除了负责透过Rails 层级的操作资料库功能,也提供给开发者更简便对资料库操作的介面。

Column Names

首先先介绍,当我们创建了一个Model,我们如何看订单的所有栏位

Order.column_names
#=> ["id", "customer_id", "created_at", ...]

#===== 找开头为 discount 的栏位
Order.column_names.select{|_| _.start_with? 'discount'}
#=> ["discount_detail"]

#===== 找含有关键字 discount 的栏位
Order.column_names.grep(/discount/)

另外介绍annotated 这个 Gem,安装并下了rails db:migrate 指令之後,就会在model将所有栏位以注解的方式呈现

gem 'annotate'

app/models/blog.rb ⬇️

# == Schema Information
#
# Table name: blogs
#
#  id         :bigint           not null, primary key
#  content    :string(255)
#  genre      :integer
#  title      :string(255)
#  created_at :datetime         not null
#  updated_at :datetime         not null
#  user_id    :bigint           not null
#
# Indexes
#
#  index_blogs_on_user_id  (user_id)
#
class Blog < ApplicationRecord
  belongs_to :user

  validates :title, :content, :genre, presence: true

  enum genre: {
    life: 0,
    casual: 1,
    technology: 2,
  }
end

我们在Day4提过#grep的用法,以及Array of Strings,而#grep用在这里刚刚好,大家要记得回头看?

InstanceMethod

除了使用栏位以外,我们也会在 model 新增实体方法。以下的例子为新增方法判断折让单号有值代表该笔退货单折让成功

class ReturnOrder < ApplicationRecord
  # 折让成功
  define_method :allowance_success?, -> { invoice_allowance_number.present? }
end

下列为购物车的 model 来举例和购物车相关的实体方法,以下为例子上面两个方法的情境

  • changed_in_day? ➡️ 24小时内购物车没有动会回传true
  • subtotal ➡️ 购物车内商品*价钱的总和
class Cart < ApplicationRecord
  define_method :changed_in_day?, -> { cart_items.count.positive? && cart_items.pluck(:updated_at).max > 1.days.ago }

  define_method :subtotal, -> { cart_items.includes(:variant).sum{|item| item.variant.price * item.quantity} }
end

Orm

Rails 提供一些好用的语法,可以使我们不用写SQL Statement。我们可以透过to_sql查看我们写的orm是不是我们预期的Sql Statement,通常工程师都会看Sql Statement作为侦错的依据

Order.all.to_sql
#=> "SELECT `orders`.* FROM `orders`"

以下为针对订单做一系列Orm 语法介绍。为了简单化,今天的Orm不会牵涉到一个以上的资料表

Order.all
#=> "SELECT `orders`.* FROM `orders`"

Order.where(status: 'processing')
#=> "SELECT `orders`.* FROM `orders` WHERE `orders`.`status` = 'processing'"

Order.where(status: ['processing', 'waiting'])
#=> "SELECT `orders`.* FROM `orders` WHERE `orders`.`status` IN ('processing', 'waiting')"

Order.where(status: ['processing', 'waiting']).pluck(:number)
#=> SELECT `orders`.`number` FROM `orders` WHERE `orders`.`status` IN ('processing', 'waiting')

#==== rails 里面若 where 一个阵列代表 in 语法
Order.select(:number).where(status: ['processing', 'waiting'])
#=> SELECT `orders`.`number` FROM `orders` WHERE `orders`.`status` IN ('processing', 'waiting') LIMIT 11

#==== rails not 用法
Order.select(:number).where.not(status: ['processing', 'waiting'])
#=> SELECT `orders`.`number` FROM `orders` WHERE `orders`.`status` NOT IN ('processing', 'waiting') LIMIT 11

#==== rails or 用法
Order.where(status: 'processing').or(Order.where(payment_status: 'unpaid'))
#=> "SELECT `orders`.* FROM `orders` WHERE (`orders`.`status` = 'processing' OR `orders`.`payment_status` = 'unpaid')"

#==== rails count 用法
Order.where(status: 'processing').or(Order.where(payment_status: 'unpaid')).count
#=> SELECT COUNT(*) FROM `orders` WHERE (`orders`.`status` = 'processing' OR `orders`.`payment_status` = 'unpaid')
#=> 64

以上为Model的基本用法

Orm sum

另外我们分析一下Enumable#sum(Ruby)Orm#sum(Rails) 有什麽不同

Order.all.pluck(:price)
# SELECT `orders`.`price` FROM `orders`
# => [5, 325, 325, 6080, 2850, 88, 23200, 23200, 34700, 50850, 78400, 25800, 1425, 18880, 18880, 8800, 21800, ...] 

#=== ORM sum ===
Order.all.sum(:price)
# SELECT SUM(`orders`.`price`) FROM `orders`
# => 1283728 

#=== 资料全捞又会失败 ===
Order.all.pluck(:price).sum
# SELECT `orders`.`price` FROM `orders`
#=> TypeError (nil can't be coerced into Integer)

ORM 的#sum只捞取一个栏位,且相加若为nil 也不会坏掉,而Enumable#Sum(Ruby)是对阵列做处理的方法,若里面的值含有nil,则会错误。因此如果我们使用Enumable#sum,我们必须使用compact, select(:itself),或类似的reducer 将阵列留下只省下数字,才能进行加法。

ClassMethod & Scope

一般的scope 用法与类别方法几乎无异

class Order < ApplicationRecord
  def self.bar
    "bar"
  end
  
  scope :foo, -> { "foo" }
end

Order.foo #=> "foo"
Order.bar #=> "bar"

另外scope 与类别方法一样,可以使用参数

class Order < ApplicationRecord
  scope :foo, ->(x) { "foo #{x}!" }
end

Order.foo(1) #=> "foo 1!"

惯例上,我们会用scope 作为对该 model 的查询

class Order < ApplicationRecord
  # 还没同步 pos
  scope :yet_not_sync_pos, -> { where(sync_pos_at: nil).where(status: %w[done returned]) }
  # 一个礼拜前
  scope :week_ago, -> { ransack(done_at_lt: Tool.appreciation_period.ago).result }
end

Order.yet_not_sync_pos.week_ago
#=> "SELECT `orders`.* FROM `orders` WHERE `orders`.`sync_pos_at` IS NULL AND `orders`.`status` IN ('done', 'returned') AND `orders`.`done_at` < '2021-09-20 07:42:54'" 

enum

enum的用法为内建的ActiveRecord::Enum,当我们宣告了一个enum方法,我们就可以使用enum 属性。使用enum,我们可以 将型态存为Integer,以增加资料库的速度,不过除了速度以外,重要的更是有一些 Rails提供的方法可以使用,下列例子为enum的一些常见的使用方法

⭐️ enum 会送一些instance method

# models/customer.rb
class Blog < ApplicationRecord
  enum genre: {
    life: 0,
    casual: 1,
    technology: 2,
  }
end

# 单笔
blog = Blog.first
blog.technology?        #=> true
blog.casual?            #=> false
blog.life?              #=> false

⭐️ 若我们不用enum,而使用module 作为状态的话也可以,但就没有额外的helper可以使用

class Order < ApplicationRecord
  module Status
    UNPAID      = :unpaid       # 未付款
    PROCESSING  = :processing   # 处理中
    WAITING     = :waiting      # 已出货
    DONE        = :done         # 已完成
    CANCELED    = :canceled     # 已取消
    RETURNED    = :returned     # 退货/退款
  end
end

⭐️enum 会送scope

class ReturnOrder < ApplicationRecord
  # 退货状态: 申请退货/已确认商品退款中/退款失败/退货失败/已完成退货
  enum status: %i(applied processing done refund_failed return_failed)
end

#========= 查询申请中的表单 =========#
ReturnOrder.applied
#=> "SELECT `return_orders`.* FROM `return_orders` WHERE `return_orders`.`status` = 0"
#=== 等同於 Conversation.where.not(status: :applied)

⭐️ enum 作为常数 ➡️ 可以很方便地做下拉式选单的资料

ReturnOrder.statuses
#=> {"applied"=>0, "processing"=>1, "done"=>2, "refund_failed"=>3, "return_failed"=>4}

另外,enum 可以和很多对象做搭配

  • 透过转阵列,将资料结构组成下拉式选单画面
  • AASM ➡️ 有限状态机
  • Ransack ➡️ Model 层级查找资料的 Gem

life cycle Hook

以下这些为model提供的判断生命周期方法,可以藉由这些方法判别该笔资料位於哪一个生命周期。

以下使用 new_record?, persisted? 判断这一笔是否并没有被存进资料库。

User.first.blogs.create.new_record?   #=> true
User.first.blogs.create.persisted?    #=> false

以下为 ActiveRecord 的生命周期

new record ➡️ 尚未写入阶段会判断为true

blog = Item.new
blog.new_record? #=> true

persisted ➡️ 已写入阶段会判断为true

blog.save
blog.persisted? #=> true

changed ➡️ 资料被改写但尚未存进资料库会被判断true

blog.name = "other"
blog.changed? #=> true

destroyed ➡️ 资料被删除但该笔纪录,但还没重整。因此该笔资料暂时存在在model。遇到这种情况则会判断为true

blog.destroy
blog.destroyed? #=> true

delegate

delegate 也是Rails 常见的使用方式!以下面的程序码为例,当我要取得订单订购人地址,可以使用 delegate

# models/customer.rb
class Customer < ApplicationRecord
  def customer_info
    # 顾客资讯
    [name, phone].join('-')
  end
end
  

# models/profile.rb
class Order < ApplicationRecord
  belongs_to :user

  delegate :customer_info, to: :customer
end

# 使用
order = Order.find(1)
order.customer_info    # 陈汉汉-0983168969

delegate & query object with scope

观察 scope 的底层,我们发现scope可以搭配call使用,衍伸出以下用法。

module Orders
  class MoreThanTenThousandQuery
    class << self
      delegate :call, to: :new
    end

    def initialize(relation = Order.all)
      @relation = relation
    end

    def call
      @relation.where(price: 10000..)
    end
  end
end

models/order.rb ,将原本应该要填入Proc的地方填入QueryObject

class Order < ApplicationRecord
  scope :m, Orders::MoreThanTenThousandQuery
end

并在 Rails Console 使用

 Order.m
#=> "SELECT `orders`.* FROM `orders` WHERE `orders`.`price` >= 10000"

callback & validation

验证和回呼是 ActiveRecord 里面相当重要的一环。

在验证方面,开发者可以调用Rails 内建的验证,或者写自定义的验证。这些验证此验证与SQL Constraint不一样,此为Model层级的验证,而SQL ConstraintSQL层级的验证。若两者都加当然最好,这样一来就多一层的保证

⭐️ 以下为Rails 内建的检查值是否为空的验证

class Blog < ApplicationRecord
  belongs_to :user

  validates :title, :content, :genre, presence: true
end

⭐️ 以下为 Rails 内建检查是否为数字的验证

class Order < ApplicationRecord
  validates :phone_area_code, length: { minimum: 2, maximum: 2 }, numericality: true
  validates :phone, length: { minimum: 6, maximum: 8 }, numericality: true
end

⭐️ 以下为Rails 内建对 Email & 网址列的验证

class Customer < ApplicationRecord
  validates :personal_website, format: { with: URI::regexp(%w(http https)) }
  validates :email, format: { with: URI::MailTo::EMAIL_REGEXP }
end

⭐️ 另外,我们也可以写自定义的方法

class Invoice < ApplicationRecord  
  # 验证统一编号
  validate :valid_e_gui_tax_number
  # 验证手机载具
  validate :valid_e_gui_carrier
  
  private

  # 验证统一编号
  def valid_e_gui_tax_number
    return if buyer_ban.blank?

    return errors.add(:buyer_ban, '需为8位数') unless Validator::InvoiceTaxid.at_least_8_digits?(buyer_ban)

    errors.add(:buyer_ban, '不合法') unless Validator::InvoiceTaxid.valid_taxid?(buyer_ban)
  end

  # 验证手机载具
  def valid_e_gui_carrier
    return if /\A[\/][0-9A-Z+-\.]{7}\z/.match?(carrier_id) || carrier_id.blank?

    errors.add(:carrier_id, '不合法')
  end
end

对应的验证为 ⬇️

module Validator
  class InvoiceTaxid
    def self.at_least_8_digits?(taxid)
      taxid.match?(/\A\d{8}\z/)
    end

    def self.valid_taxid?(taxid)
      result = []
      taxid_array = taxid.split('')
      num_array = [1, 2, 1, 2, 1, 2, 4, 1]
      taxid_array.zip(num_array) { |a, b| result << a.to_i * b }

      sum = 0
      result.each do |elm|
        sum += elm.divmod(10).inject { |s, i| s + i }
      end

      ((sum % 10 == 0) || ((sum % 9 == 9) && (taxid[5] == 7)))
    end
  end
end

Rails中存取资料的过程区分不同阶段,不同的阶段组成一个生命周期,Rails 提供了一些方法,使我们可以在各个生命周期中,做我们想要做的事情,而每一个阶段都称为不同的回呼,英文称作callback

回呼的部分,可以参考高见龙的文章。这里只简单介绍,当我们使用的一些Rails 的存取方法,有些会经过回呼、有些不会,以下举例update, update_attribute 的用法

update             #=> 经过验证、回呼
update_attribute   #=> 没有经过验证、回呼

经过回呼或验证的存取,会因为经过 ActiveRecord 设定的各种生命周期,也就是Rails的验证与回呼,因此会跑得比较慢,但资料的存取比起直接对资料库存取,可以更确保该笔Record更完整。

Store Attributes (JSON)

Postgres 比起 Mysql 处理 Json, Array 更有优势,Drifting Ruby 的某一篇 Episode 把处理 Json 的内容写得很好。不过这里指针对 Store Attributes 来讲

如何在model里面处理json型态的资料是门艺术,Store Attributes帮我们做好一部分的工作。跟大家介绍之前在某个任务中,为了做时间线的效果,我将切换状态的同时,将资料存进 Json 里面,并用Store Attribute 写好的方法,来更轻松的取值。

以下为范例程序码

# migration file (mysql)
class AddShippingStatusRecordToOrders < ActiveRecord::Migration[6.1]
  def change
    add_column :orders, :shipping_status_record, :json, comment: '物流时间纪录'
  end
end

# models/order.rb
class Order < ActiveRecord::Base
  include ApplicationHelper
  before_create :shipping_updated

  module ShippingStatus
    READY = "ready"
    PARTIAL = "partial"
    SHIPPED = "shipped"
    ARRIVED = "arrived"
    RETURNED = "returned"
    RECEIVED = "received"
  end

  # 物流时间纪录
  store :shipping_status_record,
        accessors: Order::ShippingStatus.constants.map { |c| Order::ShippingStatus.const_get(c).to_sym },
        coder: JSON, suffix: :at
  ...
  # 货运时间画押日期
  def shipping_updated
    self.ready_at = DateTime.now if self.ready_at.nil?

    if self.shipping_status_changed?
      if self.shipping_status == Order::ShippingStatus::SHIPPED
        self.shipped_at = DateTime.now
      end

      self.shipment_updated_at = DateTime.now

      # [:shipped, :ready, :partial, :arrived, :returned, :received]
      return unless self.shipping_status.in? Order.stored_attributes[:shipping_status_record].map(&:to_s)

      # 纪录时间动态方法
      self.send(:"#{self.shipping_status}_at=", DateTime.now.strftime("%F"))
    end
  end

  ...

end

首先我们在Order开了一个shipping_status_record 的栏位,并且在资料被创建前 before_create的回呼存取json值,并且这些存取的值我们可以使用利用store 这个方法将[:shipped, :ready, :partial, :arrived, :returned, :received] 转为实体可以使用的方法

order.shipped_at
order.ready_at
order.partial_at 
order.arrived_at
order.returned_at
order.received_at

结论

这章所介绍的内容是Model基本中的基本的观念,这些基础身为 Rails 的工程师都应该要会。

  • Enum 一定要会用
  • Instance Method 一定要会
  • ClassMethod & Scope 也一定要会
  • 验证跟回呼在Rails 占了举足轻重的地位,一定要会
  • Store Attributes 这种好用的工具一定要知道
  • delegate 不会没关系,但偶尔会在专案看到这种写法

Rails 还有许多用法没有提到,像是

  • bang的用法(加!) ➡️ 会引发Raise

    搭配 ActiveRecord::Base.transaction 区块一连串存取动作的某一动失败时资料库状态全部复原

  • ModelSTI, Polymorphic

  • counter_cache

明天会介绍关联,以及基本的has_many, has_one, belongs_to 的用法

参考资料


<<:  Progressive Web App 唤醒锁: 维持萤幕长亮的方法 (16)

>>:  Angular 转换 API 资料格式 (Adapter)

Flutter基础介绍与实作-Day25 旅游笔记的实作(6)

终於到最後一个区域了,我有加一些新的东西在上面这边一起跟大家说 一样先到assets资料夹下的Foo...

Day 16 self-attention的实作准备(二) 设定tensorflow和keras的环境

前言 昨天在建立环境的时候发现有很多相容性的问题,因此今天我想说这几天先来学习一下tensorflo...

[Day 1] 身为一名普通 iOS 开发者所需的程序知识 Intro

前言 Hi 我是一名普通的 iOS 开发者,兴趣使然的 UI 设计师。不小心参与了几年 iOS 开发...

Day 8 - 用 canvas 复刻 小画家 绘制圆形/椭圆形

圆形 尝试ellipse 按照搜寻结果,我们一开始可能很直觉的会想到使用 ellipse ellip...

Day 27:Google Map 范本学习(2)

本篇文章同步发表在 HKT 线上教室 部落格,线上影音教学课程已上架至 Udemy 和 Youtu...