Skip to content
This repository
Browse code

Added extension capabilities to has_many and has_and_belongs_to_many …

…proxies [DHH] Added find_or_create_by_X as a second type of dynamic finder that'll create the record if it doesn't already exist [DHH] Added constrain scoping for creates using a hash of attributes bound to the :creation key [DHH]

git-svn-id: http://svn-commit.rubyonrails.org/rails/trunk@2872 5ecf4fe2-1ee6-0310-87b1-e25e094e27de
  • Loading branch information...
commit a5a82d978bd4a46ce73462a0adcb031aa5919ce4 1 parent 4506a46
David Heinemeier Hansson dhh authored
42 activerecord/CHANGELOG
... ... @@ -1,14 +1,24 @@
1 1 *SVN*
2 2
3   -* Omit internal dtproperties table from SQLServer table list. #2729 [rtomayko@gmail.com]
4   -
5   -* Quote column names in generated SQL. #2728 [rtomayko@gmail.com]
  3 +* Added constrain scoping for creates using a hash of attributes bound to the :creation key [DHH]. Example:
6 4
7   -* Correct the pure-Ruby MySQL 4.1.1 shim's version test. #2718 [Jeremy Kemper]
  5 + Comment.constrain(:creation => { :post_id => 5 }) do
  6 + # Associated with :post_id
  7 + Comment.create :body => "Hello world"
  8 + end
  9 +
  10 + This is rarely used directly, but allows for find_or_create on associations. So you can do:
  11 +
  12 + # If the tag doesn't exist, a new one is created that's associated with the person
  13 + person.tags.find_or_create_by_name("Summer")
8 14
9   -* Add Model.create! to match existing model.save! method. When save! raises RecordInvalid, you can catch the exception, retrieve the invalid record (invalid_exception.record), and see its errors (invalid_exception.record.errors). [Jeremy Kemper]
  15 +* Added find_or_create_by_X as a second type of dynamic finder that'll create the record if it doesn't already exist [DHH]. Example:
10 16
11   -* Correct fixture behavior when table name pluralization is off. #2719 [Rick Bradley <rick@rickbradley.com>]
  17 + # No 'Summer' tag exists
  18 + Tag.find_or_create_by_name("Summer") # equal to Tag.create(:name => "Summer")
  19 +
  20 + # Now the 'Summer' tag does exist
  21 + Tag.find_or_create_by_name("Summer") # equal to Tag.find_by_name("Summer")
12 22
13 23 * Added extension capabilities to has_many and has_and_belongs_to_many proxies [DHH]. Example:
14 24
@@ -17,19 +27,27 @@
17 27 def find_or_create_by_name(name)
18 28 first_name, *last_name = name.split
19 29 last_name = last_name.join " "
20   -
21   - find_by_first_name_and_last_name(first_name, last_name) ||
22   - create({ :first_name => first_name, :last_name => last_name })
  30 +
  31 + find_or_create_by_first_name_and_last_name(first_name, last_name)
23 32 end
24 33 }
25 34 end
26   -
  35 +
27 36 person = Account.find(:first).people.find_or_create_by_name("David Heinemeier Hansson")
28 37 person.first_name # => "David"
29 38 person.last_name # => "Heinemeier Hansson"
30   -
  39 +
31 40 Note that the anoymous module must be declared using brackets, not do/end (due to order of evaluation).
32   -
  41 +
  42 +* Omit internal dtproperties table from SQLServer table list. #2729 [rtomayko@gmail.com]
  43 +
  44 +* Quote column names in generated SQL. #2728 [rtomayko@gmail.com]
  45 +
  46 +* Correct the pure-Ruby MySQL 4.1.1 shim's version test. #2718 [Jeremy Kemper]
  47 +
  48 +* Add Model.create! to match existing model.save! method. When save! raises RecordInvalid, you can catch the exception, retrieve the invalid record (invalid_exception.record), and see its errors (invalid_exception.record.errors). [Jeremy Kemper]
  49 +
  50 +* Correct fixture behavior when table name pluralization is off. #2719 [Rick Bradley <rick@rickbradley.com>]
33 51
34 52 * Changed :dbfile to :database for SQLite adapter for consistency (old key still works as an alias) #2644 [Dan Peterson]
35 53
8 activerecord/lib/active_record/associations/association_collection.rb
@@ -124,14 +124,6 @@ def replace(other_array)
124 124 end
125 125
126 126 private
127   - def method_missing(method, *args, &block)
128   - if @target.respond_to?(method) or (not @association_class.respond_to?(method) and Class.respond_to?(method))
129   - super
130   - else
131   - @association_class.constrain(:conditions => @finder_sql, :joins => @join_sql, :readonly => false) { @association_class.send(method, *args, &block) }
132   - end
133   - end
134   -
135 127 def raise_on_type_mismatch(record)
136 128 raise ActiveRecord::AssociationTypeMismatch, "#{@association_class} expected, got #{record.class}" unless record.is_a?(@association_class)
137 129 end
10 activerecord/lib/active_record/associations/has_and_belongs_to_many_association.rb
@@ -76,6 +76,16 @@ def size
76 76 end
77 77
78 78 protected
  79 + def method_missing(method, *args, &block)
  80 + if @target.respond_to?(method) || (!@association_class.respond_to?(method) && Class.respond_to?(method))
  81 + super
  82 + else
  83 + @association_class.constrain(:conditions => @finder_sql, :joins => @join_sql, :readonly => false) do
  84 + @association_class.send(method, *args, &block)
  85 + end
  86 + end
  87 + end
  88 +
79 89 def find_target
80 90 if @options[:finder_sql]
81 91 records = @association_class.find_by_sql(@finder_sql)
14 activerecord/lib/active_record/associations/has_many_association.rb
@@ -85,6 +85,20 @@ def find(*args)
85 85 end
86 86
87 87 protected
  88 + def method_missing(method, *args, &block)
  89 + if @target.respond_to?(method) || (!@association_class.respond_to?(method) && Class.respond_to?(method))
  90 + super
  91 + else
  92 + @association_class.constrain(
  93 + :conditions => @finder_sql,
  94 + :joins => @join_sql,
  95 + :readonly => false,
  96 + :creation => { @association_class_primary_key_name => @owner.id }) do
  97 + @association_class.send(method, *args, &block)
  98 + end
  99 + end
  100 + end
  101 +
88 102 def find_target
89 103 find_all
90 104 end
70 activerecord/lib/active_record/base.rb
@@ -140,7 +140,7 @@ def initialize(errors)
140 140 #
141 141 # == Dynamic attribute-based finders
142 142 #
143   - # Dynamic attribute-based finders are a cleaner way of getting objects by simple queries without turning to SQL. They work by
  143 + # Dynamic attribute-based finders are a cleaner way of getting (and/or creating) objects by simple queries without turning to SQL. They work by
144 144 # appending the name of an attribute to <tt>find_by_</tt> or <tt>find_all_by_</tt>, so you get finders like Person.find_by_user_name,
145 145 # Person.find_all_by_last_name, Payment.find_by_transaction_id. So instead of writing
146 146 # <tt>Person.find(:first, ["user_name = ?", user_name])</tt>, you just do <tt>Person.find_by_user_name(user_name)</tt>.
@@ -155,6 +155,15 @@ def initialize(errors)
155 155 # is actually Payment.find_all_by_amount(amount, options). And the full interface to Person.find_by_user_name is
156 156 # actually Person.find_by_user_name(user_name, options). So you could call <tt>Payment.find_all_by_amount(50, :order => "created_on")</tt>.
157 157 #
  158 + # The same dynamic finder style can be used to create the object if it doesn't already exist. This dynamic finder is called with
  159 + # <tt>find_or_create_by_</tt> and will return the object if it already exists and otherwise creates it, then returns it. Example:
  160 + #
  161 + # # No 'Summer' tag exists
  162 + # Tag.find_or_create_by_name("Summer") # equal to Tag.create(:name => "Summer")
  163 + #
  164 + # # Now the 'Summer' tag does exist
  165 + # Tag.find_or_create_by_name("Summer") # equal to Tag.find_by_name("Summer")
  166 + #
158 167 # == Saving arrays, hashes, and other non-mappable objects in text columns
159 168 #
160 169 # Active Record can serialize any object in text columns using YAML. To do so, you must specify this with a call to the class method +serialize+.
@@ -451,6 +460,8 @@ def create(attributes = nil)
451 460 if attributes.is_a?(Array)
452 461 attributes.collect { |attr| create(attr) }
453 462 else
  463 + attributes.reverse_merge!(scope_constraints[:creation]) if scope_constraints[:creation]
  464 +
454 465 object = new(attributes)
455 466 object.save
456 467 object
@@ -838,10 +849,10 @@ def silence
838 849 # end
839 850 def constrain(options = {})
840 851 options = options.dup
841   - if !options[:joins].blank? and !options.has_key?(:readonly)
842   - options[:readonly] = true
843   - end
  852 + options[:readonly] = true if !options[:joins].blank? && !options.has_key?(:readonly)
  853 +
844 854 self.scope_constraints = options
  855 +
845 856 yield if block_given?
846 857 ensure
847 858 self.scope_constraints = nil
@@ -948,27 +959,54 @@ def undecorated_table_name(class_name = class_name_of_active_record_descendant(s
948 959 # It's even possible to use all the additional parameters to find. For example, the full interface for find_all_by_amount
949 960 # is actually find_all_by_amount(amount, options).
950 961 def method_missing(method_id, *arguments)
951   - method_name = method_id.id2name
  962 + if match = /find_(all_by|by)_([_a-zA-Z]\w*)/.match(method_id.to_s)
  963 + finder = determine_finder(match)
952 964
953   - if md = /find_(all_by|by)_([_a-zA-Z]\w*)/.match(method_id.to_s)
954   - finder = md.captures.first == 'all_by' ? :all : :first
955   - attributes = md.captures.last.split('_and_')
956   - attributes.each { |attr_name| super unless column_methods_hash.include?(attr_name.to_sym) }
  965 + attribute_names = extract_attribute_names_from_match(match)
  966 + super unless all_attributes_exists?(attribute_names)
957 967
958   - attr_index = -1
959   - conditions = attributes.collect { |attr_name| attr_index += 1; "#{table_name}.#{attr_name} #{attribute_condition(arguments[attr_index])} " }.join(" AND ")
  968 + conditions = construct_conditions_from_arguments(attribute_names, arguments)
960 969
961   - if arguments[attributes.length].is_a?(Hash)
962   - find(finder, { :conditions => [conditions, *arguments[0...attributes.length]] }.update(arguments[attributes.length]))
  970 + if arguments[attribute_names.length].is_a?(Hash)
  971 + find(finder, { :conditions => conditions }.update(arguments[attribute_names.length]))
963 972 else
964   - # deprecated API
965   - send("find_#{finder}", [conditions, *arguments[0...attributes.length]], *arguments[attributes.length..-1])
  973 + send("find_#{finder}", conditions, *arguments[attribute_names.length..-1]) # deprecated API
966 974 end
  975 + elsif match = /find_or_create_by_([_a-zA-Z]\w*)/.match(method_id.to_s)
  976 + attribute_names = extract_attribute_names_from_match(match)
  977 + super unless all_attributes_exists?(attribute_names)
  978 +
  979 + find(:first, :conditions => construct_conditions_from_arguments(attribute_names, arguments)) ||
  980 + create(construct_attributes_from_arguments(attribute_names, arguments))
967 981 else
968 982 super
969 983 end
970 984 end
971 985
  986 + def determine_finder(match)
  987 + match.captures.first == 'all_by' ? :all : :first
  988 + end
  989 +
  990 + def extract_attribute_names_from_match(match)
  991 + match.captures.last.split('_and_')
  992 + end
  993 +
  994 + def construct_conditions_from_arguments(attribute_names, arguments)
  995 + conditions = []
  996 + attribute_names.each_with_index { |name, idx| conditions << "#{table_name}.#{name} #{attribute_condition(arguments[idx])} " }
  997 + [ conditions.join(" AND "), *arguments[0...attribute_names.length] ]
  998 + end
  999 +
  1000 + def construct_attributes_from_arguments(attribute_names, arguments)
  1001 + attributes = {}
  1002 + attribute_names.each_with_index { |name, idx| attributes[name] = arguments[idx] }
  1003 + attributes
  1004 + end
  1005 +
  1006 + def all_attributes_exists?(attribute_names)
  1007 + attribute_names.all? { |name| column_methods_hash.include?(name.to_sym) }
  1008 + end
  1009 +
972 1010 def attribute_condition(argument)
973 1011 case argument
974 1012 when nil then "IS ?"
@@ -1691,4 +1729,4 @@ def clone_attribute_value(reader_method, attribute_name)
1691 1729 value
1692 1730 end
1693 1731 end
1694   -end
  1732 +end
4 activerecord/test/associations_extensions_test.rb
... ... @@ -1,9 +1,11 @@
1 1 require 'abstract_unit'
  2 +require 'fixtures/post'
  3 +require 'fixtures/comment'
2 4 require 'fixtures/project'
3 5 require 'fixtures/developer'
4 6
5 7 class AssociationsExtensionsTest < Test::Unit::TestCase
6   - fixtures :projects, :developers
  8 + fixtures :projects, :developers, :comments, :posts
7 9
8 10 def test_extension_on_habtm
9 11 assert_equal projects(:action_controller), developers(:david).projects.find_most_recent
8 activerecord/test/associations_test.rb
@@ -490,6 +490,14 @@ def test_create_many
490 490 assert_equal 3, companies(:first_firm).clients_of_firm(true).size
491 491 end
492 492
  493 + def test_find_or_create
  494 + number_of_clients = companies(:first_firm).clients.size
  495 + the_client = companies(:first_firm).clients.find_or_create_by_name("Yet another client")
  496 + assert_equal number_of_clients + 1, companies(:first_firm, :refresh).clients.size
  497 + assert_equal the_client, companies(:first_firm).clients.find_or_create_by_name("Yet another client")
  498 + assert_equal number_of_clients + 1, companies(:first_firm, :refresh).clients.size
  499 + end
  500 +
493 501 def test_deleting
494 502 force_signal37_to_load_all_clients_of_firm
495 503 companies(:first_firm).clients_of_firm.delete(companies(:first_firm).clients_of_firm.first)
13 activerecord/test/conditions_scoping_test.rb
@@ -5,7 +5,7 @@
5 5 require 'fixtures/category'
6 6
7 7 class ConditionsScopingTest < Test::Unit::TestCase
8   - fixtures :developers
  8 + fixtures :developers, :comments, :posts
9 9
10 10 def test_set_conditions
11 11 Developer.constrain(:conditions => 'just a test...') do
@@ -42,6 +42,17 @@ def test_scoped_count
42 42 end
43 43 end
44 44
  45 + def test_scoped_create
  46 + new_comment = nil
  47 +
  48 + VerySpecialComment.constrain(:creation => { :post_id => 1 }) do
  49 + assert_equal({ :post_id => 1 }, Thread.current[:constraints][VerySpecialComment][:creation])
  50 + new_comment = VerySpecialComment.create :body => "Wonderful world"
  51 + end
  52 +
  53 + assert Post.find(1).comments.include?(new_comment)
  54 + end
  55 +
45 56 def test_immutable_constraint
46 57 options = { :conditions => "name = 'David'" }
47 58 Developer.constrain(options) do
14 activerecord/test/finder_test.rb
@@ -278,6 +278,20 @@ def test_find_all_by_nil_and_not_nil_attributes
278 278 assert_equal "Mary", topics[0].author_name
279 279 end
280 280
  281 + def test_find_or_create_from_one_attribute
  282 + number_of_companies = Company.count
  283 + sig38 = Company.find_or_create_by_name("38signals")
  284 + assert_equal number_of_companies + 1, Company.count
  285 + assert_equal sig38, Company.find_or_create_by_name("38signals")
  286 + end
  287 +
  288 + def test_find_or_create_from_two_attributes
  289 + number_of_companies = Company.count
  290 + sig38 = Company.find_or_create_by_name("38signals")
  291 + assert_equal number_of_companies + 1, Company.count
  292 + assert_equal sig38, Company.find_or_create_by_name("38signals")
  293 + end
  294 +
281 295 def test_find_with_bad_sql
282 296 assert_raises(ActiveRecord::StatementInvalid) { Topic.find_by_sql "select 1 from badtable" }
283 297 end
12 activerecord/test/fixtures/comment.rb
@@ -10,18 +10,14 @@ def self.search_by_type(q)
10 10 end
11 11 end
12 12
13   -class SpecialComment < Comment;
14   -
  13 +class SpecialComment < Comment
15 14 def self.what_are_you
16 15 'a special comment...'
17 16 end
18   -
19   -end;
  17 +end
20 18
21   -class VerySpecialComment < Comment;
22   -
  19 +class VerySpecialComment < Comment
23 20 def self.what_are_you
24 21 'a very special comment...'
25 22 end
26   -
27   -end;
  23 +end
3  activerecord/test/fixtures/post.rb
@@ -11,8 +11,9 @@ def find_most_recent
11 11 end
12 12 }
13 13
  14 + has_one :very_special_comment
  15 + has_many :special_comments
14 16
15   - has_many :special_comments, :class_name => "SpecialComment"
16 17 has_and_belongs_to_many :categories
17 18 has_and_belongs_to_many :special_categories, :join_table => "categories_posts"
18 19

0 comments on commit a5a82d9

Please sign in to comment.
Something went wrong with that request. Please try again.