-
Notifications
You must be signed in to change notification settings - Fork 0
10 Text Posts
In this unit, we are going to cover the following topics:
- Active Record Enums (new in Rails 4.1)
- Altering an existing table in a migration
- Altering data in a migration
- Manually specifying the up and down actions in a migration
- Conditional model validations
We've brushed aside text posts—via the body
attribute—and just dealt with links. No more! It's time to deal with text posts.
Text posts are still just instances of Post
, and should probably still be created via the form at /posts/new
. We just need to hide the link
field and show the body
field at the appropriate time.
What are some ways we could achieve that?
Let's start by actually adding a column to the database to distinguish between text posts and links.
Let's add a post_type
column. The type can be either "link" or "text", but rather than storing the data as a string, we're going to take advantage of a new feature in Rails 4.1: ActiveRecord::Enum
.
Declare an enum attribute where the values map to integers in the database, but can be queried by name.
In other words, our post_type
column will store integer values. In our Post
model, we define what these values mean. Let's make 1
represent a link, and 2
represent a text post.
Go to the shell—not the Rails console— and generate a new migration. When adding a column to an existing table, I use the naming convention add_column_name_to_table_name. In this case, we'll name the migration add_post_type_to_posts
.
$ bin/rails generate migration add_post_type_to_posts
invoke active_record
create db/migrate/20141103231948_add_post_type_to_posts.rb
Let's edit the new migration.
class AddPostTypeToPosts < ActiveRecord::Migration
def change
add_column :posts, :post_type, :integer, default: 0
end
end
We're adding to the posts
table; we're adding a column named post_type
; that column will hold an integer
; and the default value will be 0
.
Go ahead and migrate the database now.
$ bin/rake db:migrate
== 20141103231948 AddPostTypeToPosts: migrating ===============================
-- add_column(:posts, :post_type, :integer, {:default=>0})
-> 0.0443s
== 20141103231948 AddPostTypeToPosts: migrated (0.0444s) ======================
Among our existing Post
records, we likely have a mix of links and text posts. We can use our migration to update existing records based on appropriate logic, rather than assigning the default to all existing records.
Let's rollback our database.
$ bin/rake db:rollback
== 20141103231948 AddPostTypeToPosts: reverting ===============================
-- remove_column(:posts, :post_type, :integer, {:default=>0})
-> 0.0083s
== 20141103231948 AddPostTypeToPosts: reverted (0.0103s) ======================
Our migration currently uses the change
method to make changes to the database.
http://guides.rubyonrails.org/migrations.html#using-the-change-method
The
change
method is the primary way of writing migrations. It works for the majority of cases, where Active Record knows how to reverse the migration automatically.
Our original migration did nothing more than add_column
. Unsurprisingly, Active Record know that to reverse that migration, it just needs to run remove_column
. When it comes to messing with data, however, we need to specify migrating up
and migrating down
separately.
http://guides.rubyonrails.org/migrations.html#using-the-up-down-methods
The
up
method should describe the transformation you'd like to make to your schema, and thedown
method of your migration should revert the transformations done by theup
method. In other words, the database schema should be unchanged if you do anup
followed by adown
.
Armed with this knowledge, let's edit the migration again.
class AddPostTypeToPosts < ActiveRecord::Migration
def up
add_column :posts, :post_type, :integer, default: 0
end
def down
remove_column :posts, :post_type
end
end
To get the same behavior as before, but using the up
and down
methods, we define our up
method to do exactly what change did before. But now we'll have to add the down
method so we're able to rollback. down
needs to do nothing more or less than remove the new column.
Before we start messing with data, let's migrate and rollback again to make sure we didn't break anything. It should behave exactly as it did when we used change
.
$ bin/rake db:migrate
== 20141103231948 AddPostTypeToPosts: migrating ===============================
-- add_column(:posts, :post_type, :integer, {:default=>0})
-> 0.0144s
== 20141103231948 AddPostTypeToPosts: migrated (0.0145s) ======================
$ bin/rake db:rollback
== 20141103231948 AddPostTypeToPosts: reverting ===============================
-- remove_column(:posts, :post_type)
-> 0.0007s
== 20141103231948 AddPostTypeToPosts: reverted (0.0008s) ======================
Since that worked, we'll move on to altering the data. Upon adding the new column, we want to assign existing records with a non-blank link
value to have a post_type
of 0
. We want existing records with non-blank body
value to have a post_type
of 1
. If we happen to have any old records with both fields filled in, we'll make them links, simply by making that assignment second.
To rollback that change, we can still just drop the post_type
column.
class AddPostTypeToPosts < ActiveRecord::Migration
def up
add_column :posts, :post_type, :integer, default: 0
Post.find_each do |post|
post.post_type = 1 unless post.body.blank?
post.post_type = 0 unless post.link.blank?
post.save!
end
end
def down
remove_column :posts, :post_type
end
end
We can do a tad better than that though.
- We can add an additional condition to ensure that we don't unnecessarily update the same record twice.
- We can wipe out the
body
values for those posts which are links. If you think you'll ever want to roll this migration back, you won't be able to undo this. But if you're sure, go for it. - We can change our conditions from
unless
toif
, as it's less confusing to read.
def up
add_column :posts, :post_type, :integer, default: 0
Post.find_each do |post|
post.post_type = 1 if post.body.present? && post.link.blank?
post.update_attributes(post_type: 0, body: '') if post.link.present?
post.save!
end
end
Now try migrating again.
$ bin/rake db:migrate
== 20141103231948 AddPostTypeToPosts: migrating ===============================
-- add_column(:posts, :post_type, :integer, {:default=>0})
-> 0.0121s
== 20141103231948 AddPostTypeToPosts: migrated (0.0244s) ======================
The output won't look any different than it did before. But try poking around at your existing posts in the Rails console (exit the console and restart it first). You should find that your existing posts have values for post_type
, unless they had blank values for both link
and body
.
Notice the use of the save!
method on our Active Record model. While save
will return false if the record fails to save, save!
will raise an exception. When run as part of a database transaction, the exception will rollback the entire transaction. Migrations are always executed in a transaction, so the migration will fail should there be a problem updating existing data.
Now we need to update our model to treat post_type
as an enum
, rather than a normal integer.
app/models/post.rb
class Post < ActiveRecord::Base
validates :title, length: { maximum: 255 }, presence: true
enum post_type: [:link, :text]
end
Because :link
is at postition 0 in the array, it will correspond to a post_type
column value of 0
. Likewise for :text
in array position 1
Now that we have a place in our model to differentiate between posts of different types, we need to show two different versions of our form. Let's add a second link to the sidebar for creating text posts.
app/views/layouts/application.html.erb
<nav id="sidebar">
<%= link_to 'Submit a new link', new_post_path %>
<%= link_to 'Submit a new text post', new_post_path %>
</nav>
The links go to exactly the same page. So how do we make sure the right form fields are showing on /posts/new
? We can make them show based on the current value of post_type
in the form object (@post
). But first, we have to make sure that value is being set appropriately in PostsController
.
Posts are links by default. Let's add a query string to our "text post" link, which we can then use in the controller.
The documentation for link_to
demonstrates adding a query string to a path via the options hash on a path helper:
link_to "Nonsense search", searches_path(foo: "bar", baz: "quux")
# => <a href="/searches?foo=bar&baz=quux">Nonsense search</a>
Let's make a new sidebar link that take us to new_post_path
, but with a query string parameter specifying that it's to be a text post.
app/views/layouts/application.html.erb
<nav id="sidebar">
<%= link_to 'Submit a new link', new_post_path %>
<%= link_to 'Submit a new text post', new_post_path(post_type: :text) %>
</nav>
If you follow that link, you'll find that it sends us to http://localhost:3000/posts/new?post_type=text
. Nothing has changed on our form yet, naturally. But with the query parameter, we can make it happen.
Let's put a binding.pry
in our controller action.
app/controllers/posts_controller.rb
7 def new
8 @post = Post.new
9 binding.pry
10 end
Click the link to submit a new text post again, and head over to the server output.
7: def new
8: @post = Post.new
=> 9: binding.pry
10: end
[1] pry(#<PostsController>)>
Let's see what @post.post_type
is at the moment.
[1] pry(#<PostsController>)> @post.post_type
=> "link"
The enum
macro provides another way to check each label:
[1] pry(#<PostsController>)> @post.link?
=> true
Whichever way we check, we see that post_type
is set to the default, link
. When we've been passed a post_type in the query string, we want to assign the value from the query string instead. Let's have a look at our params hash.
[2] pry(#<PostsController>)> params
=> {"post_type"=>"text", "action"=>"new", "controller"=>"posts"}
Our query string param showed up as expected. Now we can assign the value from that param to @post.post_type
. That param won't always be there, however, so we'll want to check for its presence before making the assignment.
[3] pry(#<PostsController>)> @post.post_type = params[:post_type] if params[:post_type].present?
=> "text"
Seems to work. Let's quit pry
and update the controller.
app/controllers/posts_controller.rb
7 def new
8 @post = Post.new
9 @post.post_type = params[:post_type] if params[:post_type].present?
10 end
Now, a fat lot of good this does us, as we haven't changed anything in the view. First, let's add a hidden field for post_type
, so that we know the value will persist when the form is submitted.
We'll add it just above the submit button.
<%= f.hidden_field :post_type %>
<%= f.submit %>
Refresh the ?post_type=text
version of your form, and have a look at the source in your browser. You should see that the hidden field for post_type
is set correctly.
<div>
<input id="post_post_type" name="post[post_type]" type="hidden" value="text" />
<input name="commit" type="submit" value="Create Post" />
</div>
That's great! Of course, we don't want to start littering our data with a bunch of text posts that have link values, but no body. It's time to hide and show form fields appropriately.
Let's only show the link
field if our post is of type "link". We could check the instance variable (@post
) directly, but we really want to check the form object. We can access that via the object
method on our FormBuilder
object:
f.object
It's easy to display our link
field only when f.object
is a link.
app/views/posts/new.html.erb
<% if f.object.link? -%>
<fieldset>
<%= f.label :link %>
<%= f.text_field :link %>
</fieldset>
<% end -%>
Now take a look at both versions of the form. The link
field should appear and disappear appropriately. Now, we need to do the same for the body
field.
We need to remove the HTML comment, and change the ERB comments into ERB expressions.
app/views/posts/new.html.erb
<fieldset>
<%= f.label :body %>
<%= f.text_area :body %>
</fieldset>
Now we just need to wrap it in a condition similar to the one we used for link
.
app/views/posts/new.html.erb
<% if f.object.text? -%>
<fieldset>
<%= f.label :body %>
<%= f.text_area :body %>
</fieldset>
<% end -%>
Now both versions of the form should have appropriate fields. Now try adding some posts of each type.
Check out your posts in the console. You can quickly see all link
posts with Post.link
and Post.text
. The enum
macro gives us those finders for free. Cool, huh?
It shouldn't take long to discover that post_type
is actually not getting set correctly for text posts. What gives? The param is being passed to the controller with the correct value. Hmm.
The issue is that we are not permitting the post_type
attribute to be mass assigned. We need to update the first line of create
to permit this new attribute.
app/controllers/posts_controller.rb
13 post = Post.new(params.require(:post).permit(:title, :link, :body, :post_type))
We didn't encounter this issue in posts#new
, because it wasn't part of a mass-assignment.
After making that change, try creating some new text posts, and check them out in the console.
If it's working, now is a good time to commit. Don't forget to check your git status, and maybe even git diff, to make sure you're committing what you expect.
$ git add .
$ git commit -m "Add post#post_type and allow creation of text posts."
Before we move on, let's think about our validations. Right now, title
is the only required attribute. Really, link
should be required for link posts, and body
should be required for text posts. Thankfully, we can add conditional validations.
app/models/post.rb
validates :link, presence: true, if: :link?
validates :body, presence: true, if: :text?
The symbols at the end of each of those lines represents a method to be called. If that method returns a truthy value, then the validation will be enforced. If not, the validation will be skipped.
Try it out. All good? Let's commit again.
$ git add .
$ git commit -m "Add conditional validations for links and text posts."