From eb72e62c3042c0df989d951b1d12291395ebdb8e Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Fri, 19 Oct 2012 13:18:47 +0100 Subject: [PATCH] Add Relation#find_or_create_by and friends This is similar to #first_or_create, but slightly different and a nicer API. See the CHANGELOG/docs in the commit. Fixes #7853 --- activerecord/CHANGELOG.md | 34 ++++++++++- activerecord/lib/active_record/querying.rb | 1 + activerecord/lib/active_record/relation.rb | 26 ++++++++ activerecord/test/cases/relations_test.rb | 23 +++++++ guides/source/active_record_querying.md | 71 ++++++++++++++++------ 5 files changed, 133 insertions(+), 22 deletions(-) diff --git a/activerecord/CHANGELOG.md b/activerecord/CHANGELOG.md index 186c7bf244..1edcd7cfc8 100644 --- a/activerecord/CHANGELOG.md +++ b/activerecord/CHANGELOG.md @@ -1,5 +1,35 @@ ## Rails 4.0.0 (unreleased) ## +* Add `find_or_create_by`, `find_or_create_by!` and + `find_or_initialize_by` methods to `Relation`. + + These are similar to the `first_or_create` family of methods, but + the behaviour when a record is created is slightly different: + + User.where(first_name: 'Penélope').first_or_create + + will execute: + + User.where(first_name: 'Penélope').create + + Causing all the `create` callbacks to execute within the context of + the scope. This could affect queries that occur within callbacks. + + User.find_or_create_by(first_name: 'Penélope') + + will execute: + + User.create(first_name: 'Penélope') + + Which obviously does not affect the scoping of queries within + callbacks. + + The `find_or_create_by` version also reads better, frankly. But note + that it does not allow attributes to be specified for the `create` + that are not included in the `find_by`. + + *Jon Leighton* + * Fix bug with presence validation of associations. Would incorrectly add duplicated errors when the association was blank. Bug introduced in 1fab518c6a75dac5773654646eb724a59741bc13. @@ -607,9 +637,9 @@ * `find_or_initialize_by_...` can be rewritten using `where(...).first_or_initialize` * `find_or_create_by_...` can be rewritten using - `where(...).first_or_create` + `find_or_create_by(...)` or where(...).first_or_create` * `find_or_create_by_...!` can be rewritten using - `where(...).first_or_create!` + `find_or_create_by!(...) or `where(...).first_or_create!` The implementation of the deprecated dynamic finders has been moved to the `activerecord-deprecated_finders` gem. See below for details. diff --git a/activerecord/lib/active_record/querying.rb b/activerecord/lib/active_record/querying.rb index 13e09eda53..45f6a78428 100644 --- a/activerecord/lib/active_record/querying.rb +++ b/activerecord/lib/active_record/querying.rb @@ -3,6 +3,7 @@ module ActiveRecord module Querying delegate :find, :take, :take!, :first, :first!, :last, :last!, :exists?, :any?, :many?, :to => :all delegate :first_or_create, :first_or_create!, :first_or_initialize, :to => :all + delegate :find_or_create_by, :find_or_create_by!, :find_or_initialize_by, :to => :all delegate :find_by, :find_by!, :to => :all delegate :destroy, :destroy_all, :delete, :delete_all, :update, :update_all, :to => :all delegate :find_each, :find_in_batches, :to => :all diff --git a/activerecord/lib/active_record/relation.rb b/activerecord/lib/active_record/relation.rb index ecce7c703b..d106fceca2 100644 --- a/activerecord/lib/active_record/relation.rb +++ b/activerecord/lib/active_record/relation.rb @@ -133,6 +133,10 @@ def create!(*args, &block) # # Expects arguments in the same format as +Base.create+. # + # Note that the create will execute within the context of this scope, and that may for example + # affect the result of queries within callbacks. If you don't want this, use the find_or_create_by + # method. + # # ==== Examples # # Find the first user named Penélope or create a new one. # User.where(:first_name => 'Penélope').first_or_create @@ -171,6 +175,28 @@ def first_or_initialize(attributes = nil, &block) first || new(attributes, &block) end + # Finds the first record with the given attributes, or creates it if one does not exist. + # + # See also first_or_create. + # + # ==== Examples + # # Find the first user named Penélope or create a new one. + # User.find_or_create_by(first_name: 'Penélope') + # # => + def find_or_create_by(attributes, &block) + find_by(attributes) || create(attributes, &block) + end + + # Like find_or_create_by, but calls create! so an exception is raised if the created record is invalid. + def find_or_create_by!(attributes, &block) + find_by(attributes) || create!(attributes, &block) + end + + # Like find_or_create_by, but calls new instead of create. + def find_or_initialize_by(attributes, &block) + find_by(attributes) || new(attributes, &block) + end + # Runs EXPLAIN on the query or queries triggered by this relation and # returns the result as a string. The string is formatted imitating the # ones printed by the database shell. diff --git a/activerecord/test/cases/relations_test.rb b/activerecord/test/cases/relations_test.rb index fdbc0a3fdb..7504da01d5 100644 --- a/activerecord/test/cases/relations_test.rb +++ b/activerecord/test/cases/relations_test.rb @@ -1058,6 +1058,29 @@ def test_first_or_initialize_with_block assert_equal 'parrot', parrot.name end + def test_find_or_create_by + assert_nil Bird.find_by(name: 'bob') + + bird = Bird.find_or_create_by(name: 'bob') + assert bird.persisted? + + assert_equal bird, Bird.find_or_create_by(name: 'bob') + end + + def test_find_or_create_by! + assert_raises(ActiveRecord::RecordInvalid) { Bird.find_or_create_by!(color: 'green') } + end + + def test_find_or_initialize_by + assert_nil Bird.find_by(name: 'bob') + + bird = Bird.find_or_initialize_by(name: 'bob') + assert bird.new_record? + bird.save! + + assert_equal bird, Bird.find_or_initialize_by(name: 'bob') + end + def test_explicit_create_scope hens = Bird.where(:name => 'hen') assert_equal 'hen', hens.new.name diff --git a/guides/source/active_record_querying.md b/guides/source/active_record_querying.md index 66e6390f67..5e6799e786 100644 --- a/guides/source/active_record_querying.md +++ b/guides/source/active_record_querying.md @@ -1225,17 +1225,17 @@ WARNING: Up to and including Rails 3.1, when the number of arguments passed to a Find or build a new object -------------------------- -It's common that you need to find a record or create it if it doesn't exist. You can do that with the `first_or_create` and `first_or_create!` methods. +It's common that you need to find a record or create it if it doesn't exist. You can do that with the `find_or_create_by` and `find_or_create_by!` methods. -### `first_or_create` +### `find_or_create_by` and `first_or_create` -The `first_or_create` method checks whether `first` returns `nil` or not. If it does return `nil`, then `create` is called. This is very powerful when coupled with the `where` method. Let's see an example. +The `find_or_create_by` method checks whether a record with the attributes exists. If it doesn't, then `create` is called. Let's see an example. -Suppose you want to find a client named 'Andy', and if there's none, create one and additionally set his `locked` attribute to false. You can do so by running: +Suppose you want to find a client named 'Andy', and if there's none, create one. You can do so by running: ```ruby -Client.where(:first_name => 'Andy').first_or_create(:locked => false) -# => # +Client.find_or_create_by(first_name: 'Andy') +# => # ``` The SQL generated by this method looks like this: @@ -1243,27 +1243,50 @@ The SQL generated by this method looks like this: ```sql SELECT * FROM clients WHERE (clients.first_name = 'Andy') LIMIT 1 BEGIN -INSERT INTO clients (created_at, first_name, locked, orders_count, updated_at) VALUES ('2011-08-30 05:22:57', 'Andy', 0, NULL, '2011-08-30 05:22:57') +INSERT INTO clients (created_at, first_name, locked, orders_count, updated_at) VALUES ('2011-08-30 05:22:57', 'Andy', 1, NULL, '2011-08-30 05:22:57') COMMIT ``` -`first_or_create` returns either the record that already exists or the new record. In our case, we didn't already have a client named Andy so the record is created and returned. +`find_or_create_by` returns either the record that already exists or the new record. In our case, we didn't already have a client named Andy so the record is created and returned. The new record might not be saved to the database; that depends on whether validations passed or not (just like `create`). -It's also worth noting that `first_or_create` takes into account the arguments of the `where` method. In the example above we didn't explicitly pass a `:first_name => 'Andy'` argument to `first_or_create`. However, that was used when creating the new record because it was already passed before to the `where` method. +Suppose we want to set the 'locked' attribute to true, if we're +creating a new record, but we don't want to include it in the query. So +we want to find the client named "Andy", or if that client doesn't +exist, create a client named "Andy" which is not locked. -You can do the same with the `find_or_create_by` method: +We can achive this in two ways. The first is passing a block to the +`find_or_create_by` method: ```ruby -Client.find_or_create_by_first_name(:first_name => "Andy", :locked => false) +Client.find_or_create_by(first_name: 'Andy') do |c| + c.locked = false +end ``` -This method still works, but it's encouraged to use `first_or_create` because it's more explicit on which arguments are used to _find_ the record and which are used to _create_, resulting in less confusion overall. +The block will only be executed if the client is being created. The +second time we run this code, the block will be ignored. -### `first_or_create!` +The second way is using the `first_or_create` method: -You can also use `first_or_create!` to raise an exception if the new record is invalid. Validations are not covered on this guide, but let's assume for a moment that you temporarily add +```ruby +Client.where(first_name: 'Andy').first_or_create(locked: false) +``` + +In this version, we are building a scope to search for Andy, and getting +the first record if it existed, or else creating it with `locked: +false`. + +Note that these two are slightly different. In the second version, the +scope that we build will affect any other queries that may happens while +creating the record. For example, if we had a callback that ran +another query, that would execute within the `Client.where(first_name: +'Andy')` scope. + +### `find_or_create_by!` and `first_or_create!` + +You can also use `find_or_create_by!` to raise an exception if the new record is invalid. Validations are not covered on this guide, but let's assume for a moment that you temporarily add ```ruby validates :orders_count, :presence => true @@ -1272,19 +1295,24 @@ validates :orders_count, :presence => true to your `Client` model. If you try to create a new `Client` without passing an `orders_count`, the record will be invalid and an exception will be raised: ```ruby -Client.where(:first_name => 'Andy').first_or_create!(:locked => false) +Client.find_or_create_by!(first_name: 'Andy') # => ActiveRecord::RecordInvalid: Validation failed: Orders count can't be blank ``` -As with `first_or_create` there is a `find_or_create_by!` method but the `first_or_create!` method is preferred for clarity. +There is also a `first_or_create!` method which does a similar thing for +`first_or_create`. -### `first_or_initialize` +### `find_or_initialize_by` and `first_or_initialize` -The `first_or_initialize` method will work just like `first_or_create` but it will not call `create` but `new`. This means that a new model instance will be created in memory but won't be saved to the database. Continuing with the `first_or_create` example, we now want the client named 'Nick': +The `find_or_initialize_by` method will work just like +`find_or_create_by` but it will call `new` instead of `create`. This +means that a new model instance will be created in memory but won't be +saved to the database. Continuing with the `find_or_create_by` example, we +now want the client named 'Nick': ```ruby -nick = Client.where(:first_name => 'Nick').first_or_initialize(:locked => false) -# => +nick = Client.find_or_initialize_by(first_name: 'Nick') +# => nick.persisted? # => false @@ -1306,6 +1334,9 @@ nick.save # => true ``` +There is also a `first_or_initialize` method which does a similar thing +for `first_or_create`. + Finding by SQL --------------