Skip to content

Backend Training: ActiveRecord Models

Nelson Lee edited this page Nov 30, 2017 · 6 revisions

🔖 : Validation, Scopes, Migration, DB Column Types, Console

Next up, we will be creating the following ActiveRecord models:

  • FAQs
  • News
  • Contacts

Create Faq Model

🔖 : act_as_list

Generate Model

For the Faq model we have the following fields:

  • Question: should be less than 255 characters should with use column type string here.
  • Answer: likely to be longer so we will use column type text here.
  • Position: is required by act_as_list gem(doc) so we can order them by integer value. p.s. For list of column types, please see doc
$ rails g model Faq question:string answer:text position:integer

Then go to the newly generated migration file and default: 0 for position

# db/migrate/[datetime]_create_faqs.rb
class CreateFaqs < ActiveRecord::Migration[5.0]
  def change
    create_table :faqs do |t|
      t.string :question
      t.text :answer
      t.integer :position, default: 0

      t.timestamps
    end
  end
end

Run the migration

$ rake db:migrate

Add Validations

Now let's add some validations. Validations are used to ensure that the record created meet certain criteria. For this example, we want the faq to always have question and answer values.

# app/models/faq.rb
class Faq < ApplicationRecord

  validates :question, :answer,
            presence: true

end

Let's test it out in the rails console

$ rails c
irb(main):001:0> f = Faq.new
=> #<Faq id: nil, question: nil, answer: nil, position: 0, created_at: nil, updated_at: nil>
irb(main):002:0> f.save
   (0.2ms)  BEGIN
   (0.3ms)  ROLLBACK
=> false

The faq instance rolls back when trying to save as expected.

Let's inspect the errors messages

irb(main):003:0> f.errors
=> #<ActiveModel::Errors:0x007fea206ea868 @base=#<Faq id: nil, question: nil, answer: nil, position: 0, created_at: nil, updated_at: nil>, @messages={:question=>["can't be blank"], :answer=>["can't be blank"]}, @details={:question=>[{:error=>:blank}], :answer=>[{:error=>:blank}]}>
irb(main):004:0> f.errors.full_messages
=> ["Question can't be blank", "Answer can't be blank"]

Great! Our validations are working as expected.

Next let's integrate act_as_list into the Faq model. We can do so by add the following line

# app/models/faq.rb
class Faq < ApplicationRecord

  acts_as_list

  ......

end

p.s The act_as_list method includes the callbacks from the gem so whenever you create a new record it will increment the position by 1 starting with 1. Here we also included position column in the validations to ensure we don't accidentally assign nil to position column.

Let's try it out in the rails console. Remember to reload your session to include the model updates by running reload!

irb(main):013:0> reload!
Reloading...
=> true
irb(main):014:0> f = Faq.create(question: 'My question', answer: 'My answer')
   (0.1ms)  BEGIN
  Faq Load (0.3ms)  SELECT  "faqs".* FROM "faqs" WHERE (1 = 1) AND ("faqs"."position" IS NOT NULL) ORDER BY "faqs"."position" DESC LIMIT $1  [["LIMIT", 1]]
  SQL (0.3ms)  INSERT INTO "faqs" ("question", "answer", "position", "created_at", "updated_at") VALUES ($1, $2, $3, $4, $5) RETURNING "id"  [["question", "My question"], ["answer", "My answer"], ["position", 1], ["created_at", "2017-11-06 06:07:59.325964"], ["updated_at", "2017-11-06 06:07:59.325964"]]
   (5.7ms)  COMMIT
=> #<Faq id: 2, question: "My question", answer: "My answer", position: 1, created_at: "2017-11-06 06:07:59", updated_at: "2017-11-06 06:07:59">

Here we see that the position column is automatically assigned with value 1.

Let's create another Faq record

irb(main):018:0> f = Faq.create(question: 'My 2nd question', answer: 'My answer')
   (0.3ms)  BEGIN
  Faq Load (0.9ms)  SELECT  "faqs".* FROM "faqs" WHERE (1 = 1) AND ("faqs"."position" IS NOT NULL) ORDER BY "faqs"."position" DESC LIMIT $1  [["LIMIT", 1]]
  SQL (0.7ms)  INSERT INTO "faqs" ("question", "answer", "position", "created_at", "updated_at") VALUES ($1, $2, $3, $4, $5) RETURNING "id"  [["question", "My 2nd question"], ["answer", "My answer"], ["position", 2], ["created_at", "2017-11-06 06:09:31.394964"], ["updated_at", "2017-11-06 06:09:31.394964"]]
   (5.7ms)  COMMIT
=> #<Faq id: 4, question: "My 2nd question", answer: "My answer", position: 2, created_at: "2017-11-06 06:09:31", updated_at: "2017-11-06 06:09:31">

Here the position is automatically assigned to 2 for the new record.

Add Scopes

Scopes are pre-written queries that can be used through your applications. Here we need to be able to sort the faqs by position column.

class Faq < ApplicationRecord

  ......

  scope :by_position, -> { order(position: :asc) }

end

Now let's test it

irb(main):005:0> reload!
Reloading...
=> true
irb(main):006:0> Faq.by_position
  Faq Load (0.5ms)  SELECT "faqs".* FROM "faqs" ORDER BY "faqs"."position" ASC
=> #<ActiveRecord::Relation [#<Faq id: 15, question: "My question", answer: "My answer", position: 1, created_at: "2017-11-29 01:49:48", updated_at: "2017-11-29 01:49:48">, #<Faq id: 16, question: "My 2nd question", answer: "My answer", position: 2, created_at: "2017-11-29 01:49:53", updated_at: "2017-11-29 01:49:53">]>

You see that the faqs are sorted by position column in ascending order. Now exit your rails console by running

irb(main):007:0> exit

Now let's commit the code before moving on.

$ git add -A
$ git commit -m 'Create Faq model'

Create News Model

🔖 : friendly_id, Rails Reserved Words, Callbacks

Here, we named the model post instead of new since new doesn't seem like a right word for the model and is likely to violate Rails' reserver words (doc). Also, we should be able to use this model for other things like announcements, blogs, and wikis so here we will name it to post.

$ rails g model Post title:string content:text published_date:datetime published:boolean slug:string
# db/migrate/[datetime]_create_posts.rb
class CreatePosts < ActiveRecord::Migration[5.0]
  def change
    create_table :posts do |t|
      ......
      t.boolean :published, default: false
      ......
    end

    add_index :posts, :slug, unique: true
  end
end

We add index slug here since we are using friendly_id gem (doc), and will be finding the record by the slug so indexing it will make the query faster.

Run the migrations

$ rake db:migrate

Add friendly_id to Post model

# app/models/post.rb
class Post < ApplicationRecord

  extend FriendlyId
  friendly_id :slug_candidates, use: %i[slugged finders]

  validates :title,
            presence: true

  private

  def slug_candidates
    [
      :title,
      %i[title slug_sequence]
    ]
  end

  def should_generate_new_friendly_id?
    slug.blank?
  end

  def slug_sequence
    slug = normalize_friendly_id(title)
    self.class.where(['slug LIKE ?', "%#{slug}%"]).count + 1
  end

end

Let's try creating post records inside the rails console

$ rails c
irb(main):002:0> p = Post.create(title: 'My first post')
   (0.2ms)  BEGIN
   (7.2ms)  SELECT COUNT(*) FROM "posts" WHERE (slug LIKE '%my-first-post%')
  Post Exists (0.8ms)  SELECT  1 AS one FROM "posts" WHERE ("posts"."id" IS NOT NULL) AND "posts"."slug" = $1 LIMIT $2  [["slug", "my-first-post"], ["LIMIT", 1]]
  SQL (0.6ms)  INSERT INTO "posts" ("title", "slug", "created_at", "updated_at") VALUES ($1, $2, $3, $4) RETURNING "id"  [["title", "My first post"], ["slug", "my-first-post"], ["created_at", "2017-11-06 19:10:15.502415"], ["updated_at", "2017-11-06 19:10:15.502415"]]
   (0.5ms)  COMMIT
=> #<Post id: 1, title: "My first post", content: nil, published_date: nil, published: false, slug: "my-first-post", created_at: "2017-11-06 19:10:15", updated_at: "2017-11-06 19:10:15">

We can see that slug "my-first-post" is automatically added for us.

Next, we need to add published_date when the record is created since we will be using it to filter our posts.

class Post < ApplicationRecord

  extend FriendlyId
  friendly_id :slug_candidates, use: %i[slugged finders]

  before_create :update_published_date

  validates :title,
            presence: true

  private

  ......

  def update_published_date
    return if published_date.present?
    self.published_date = created_at
  end

end

Now we need to update our posts records in the database so they get a published_date. We can do so by running.

irb(main):013:0> reload!
Reloading...
=> true
irb(main):013:0> Post.destroy_all
irb(main):013:0> p = Post.create(title: 'My first post')
   (0.2ms)  BEGIN
   (7.2ms)  SELECT COUNT(*) FROM "posts" WHERE (slug LIKE '%my-first-post%')
  Post Exists (0.8ms)  SELECT  1 AS one FROM "posts" WHERE ("posts"."id" IS NOT NULL) AND "posts"."slug" = $1 LIMIT $2  [["slug", "my-first-post"], ["LIMIT", 1]]
  SQL (0.6ms)  INSERT INTO "posts" ("title", "slug", "created_at", "updated_at") VALUES ($1, $2, $3, $4) RETURNING "id"  [["title", "My first post"], ["slug", "my-first-post"], ["created_at", "2017-11-06 19:10:15.502415"], ["updated_at", "2017-11-06 19:10:15.502415"]]
   (0.5ms)  COMMIT
=> #<Post id: 1, title: "My first post", content: nil, published_date: "2017-11-06 19:10:15", published: false, slug: "my-first-post", created_at: "2017-11-06 19:10:15", updated_at: "2017-11-06 19:10:15">

Now let's see if published_date is added for us

irb(main):009:0> Post.pluck(:published_date)
   (0.6ms)  SELECT "posts"."published_date" FROM "posts"
=> [Mon, 06 Nov 2017 19:10:15 UTC +00:00]

Next we can add some scopes to post model. From the mockup we see that posts are ordered by published_date and there is also a sidebar that filters them by date. post#index

So let's add some scopes for this

class Post < ApplicationRecord

  ......

  scope :by_published_date, -> { order(published_date: :desc) }
  scope :published, lambda {
    where('published = ? AND published_date <= ?', true, Time.zone.now)
  }
  scope :published_in, lambda { |date|
    where(published_date: date.beginning_of_month..date.end_of_month).
      by_published_date
  }

  private

  ......

end

Let's create another post.

irb(main):010:0> Post.create(title: 'My 2nd Post')
   (0.4ms)  BEGIN
   (6.8ms)  SELECT COUNT(*) FROM "posts" WHERE (slug LIKE '%my-2nd-post%')
  Post Exists (0.6ms)  SELECT  1 AS one FROM "posts" WHERE ("posts"."id" IS NOT NULL) AND "posts"."slug" = $1 LIMIT $2  [["slug", "my-2nd-post"], ["LIMIT", 1]]
  SQL (0.5ms)  INSERT INTO "posts" ("title", "slug", "created_at", "updated_at") VALUES ($1, $2, $3, $4) RETURNING "id"  [["title", "My 2nd Post"], ["slug", "my-2nd-post"], ["created_at", "2017-11-06 19:25:31.571429"], ["updated_at", "2017-11-06 19:25:31.571429"]]
   (0.7ms)  COMMIT
=> #<Post id: 2, title: "My 2nd Post", content: nil, published_date: nil, published: false, slug: "my-2nd-post", created_at: "2017-11-06 19:25:31", updated_at: "2017-11-06 19:25:31">
irb(main):011:0> Post.all
  Post Load (0.8ms)  SELECT "posts".* FROM "posts"
=> #<ActiveRecord::Relation [#<Post id: 1, title: "My first post", content: nil, published_date: "2017-11-06 19:10:15", published: false, slug: "my-first-post", created_at: "2017-11-06 19:10:15", updated_at: "2017-11-06 19:17:55">, #<Post id: 2, title: "My 2nd Post", content: nil, published_date: nil, published: false, slug: "my-2nd-post", created_at: "2017-11-06 19:25:31", updated_at: "2017-11-06 19:25:31">]>

Ok we now have 2 posts. Now let's test out the scopes

# by_published_date
irb(main):015:0> reload!
Reloading...
=> true
irb(main):015:0> Post.last.update(published_date: DateTime.now - 1.year)
  Post Load (0.3ms)  SELECT  "posts".* FROM "posts" ORDER BY "posts"."id" DESC LIMIT $1  [["LIMIT", 1]]
   (0.1ms)  BEGIN
  SQL (0.9ms)  UPDATE "posts" SET "published_date" = $1, "updated_at" = $2 WHERE "posts"."id" = $3  [["published_date", "2016-11-06 19:27:56.932922"], ["updated_at", "2017-11-06 19:27:56.934205"], ["id", 2]]
   (6.4ms)  COMMIT
=> true
irb(main):016:0> Post.by_published_date
  Post Load (1.0ms)  SELECT "posts".* FROM "posts" ORDER BY "posts"."published_date" DESC
=> #<ActiveRecord::Relation [#<Post id: 1, title: "My first post", content: nil, published_date: "2017-11-06 19:10:15", published: false, slug: "my-first-post", created_at: "2017-11-06 19:10:15", updated_at: "2017-11-06 19:17:55">, #<Post id: 2, title: "My 2nd Post", content: nil, published_date: "2016-11-06 19:27:56", published: false, slug: "my-2nd-post", created_at: "2017-11-06 19:25:31", updated_at: "2017-11-06 19:27:56">]>
# published
irb(main):021:0> Post.first.update(published: true)
  Post Load (0.3ms)  SELECT  "posts".* FROM "posts" ORDER BY "posts"."id" ASC LIMIT $1  [["LIMIT", 1]]
   (0.7ms)  BEGIN
   (0.7ms)  COMMIT
=> true
irb(main):022:0> Post.published
  Post Load (1.2ms)  SELECT "posts".* FROM "posts" WHERE (published = 't' AND published_date <= '2017-11-06 19:29:17.563466')
=> #<ActiveRecord::Relation [#<Post id: 1, title: "My first post", content: nil, published_date: "2017-11-06 19:10:15", published: true, slug: "my-first-post", created_at: "2017-11-06 19:10:15", updated_at: "2017-11-06 19:29:07">]>
# published_in
irb(main):015:0> Post.published_in Time.zone.now
  Post Load (0.7ms)  SELECT "posts".* FROM "posts" WHERE ("posts"."published_date" BETWEEN $1 AND $2) ORDER BY "posts"."published_date" DESC  [["published_date", "2017-11-01 00:00:00"], ["published_date", "2017-11-30 23:59:59.999999"]]
=> #<ActiveRecord::Relation [#<Post id: 68, title: "My First Post", content: nil, published_date: "2017-11-06 22:47:16", published: false, slug: "my-first-post", created_at: "2017-11-06 22:47:16", updated_at: "2017-11-06 22:47:16">]>

Great! The scopes are working. Let's commit and move on..

$ git add -A
$ git commit -m 'Create Post model'

Create Contact Model

🔖 : Custom Validation

$ rails g model Contact name:string email:string message:text
$ rake db:migrate

Add validations

# app/models/contact.rb
class Contact < ApplicationRecord

  validates :name, :email, :message,
            presence: true

end

We also want to validate email format so let's add a email format validator.

Add file

# app/validators/email_format_validator.rb
# Email Format Regex Validator
class EmailFormatValidator < ActiveModel::EachValidator

  EMAIL_REGEX = /^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,8}$/i

  def validate_each(object, attribute, value)
    unless value =~ EMAIL_REGEX
      object.errors[attribute] << (
        options[:message] || I18n.t('activerecord.errors.format')
      )
    end
    object.errors
  end

end

Add translations for t('activerecord.errors.format')

# config/locales/en.yml
en:
  activerecord:
    errors:
      format: format not valid

  components:
    ......

Add email format validation to contact model

# app/models/contact.rb
class Contact < ApplicationRecord

  validates :name, :message,
            presence: true
  validates :email,
            presence: true,
            email_format: true

end

Let's test out our validators

irb(main):010:0> c = Contact.create
   (0.1ms)  BEGIN
   (0.1ms)  ROLLBACK
=> #<Contact id: nil, name: nil, email: nil, message: nil, created_at: nil, updated_at: nil>
irb(main):011:0> c.errors
=> #<ActiveModel::Errors:0x007fb4979f8a28 @base=#<Contact id: nil, name: nil, email: nil, message: nil, created_at: nil, updated_at: nil>, @messages={:name=>["can't be blank"], :message=>["can't be blank"], :email=>["can't be blank", "format not valid"]}, @details={:name=>[{:error=>:blank}], :message=>[{:error=>:blank}], :email=>[{:error=>:blank}]}>
irb(main):012:0> c.errors.full_messages
=> ["Name can't be blank", "Message can't be blank", "Email can't be blank", "Email format not valid"]

Great! Validators are working. Let's commit and move on..

$ git add -A
$ git commit -m 'Create Contact model'

↪️ Next Section: Seed Data