-
Notifications
You must be signed in to change notification settings - Fork 0
Backend Training: ActiveRecord Models
🔖 : Validation
, Scopes
, Migration
, DB Column Types
, Console
Next up, we will be creating the following ActiveRecord models:
- FAQs
- News
- Contacts
🔖 : act_as_list
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
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.
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'
🔖 : 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.
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'
$ 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'