sequel 3.3.0 → 3.4.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (58) hide show
  1. data/CHANGELOG +62 -0
  2. data/README.rdoc +4 -4
  3. data/doc/release_notes/3.3.0.txt +1 -1
  4. data/doc/release_notes/3.4.0.txt +325 -0
  5. data/doc/sharding.rdoc +3 -3
  6. data/lib/sequel/adapters/amalgalite.rb +1 -1
  7. data/lib/sequel/adapters/firebird.rb +4 -9
  8. data/lib/sequel/adapters/jdbc.rb +21 -7
  9. data/lib/sequel/adapters/mysql.rb +2 -1
  10. data/lib/sequel/adapters/odbc.rb +7 -21
  11. data/lib/sequel/adapters/oracle.rb +1 -1
  12. data/lib/sequel/adapters/postgres.rb +6 -1
  13. data/lib/sequel/adapters/shared/mssql.rb +11 -0
  14. data/lib/sequel/adapters/shared/mysql.rb +8 -12
  15. data/lib/sequel/adapters/shared/oracle.rb +13 -0
  16. data/lib/sequel/adapters/shared/postgres.rb +5 -10
  17. data/lib/sequel/adapters/shared/sqlite.rb +21 -1
  18. data/lib/sequel/adapters/sqlite.rb +2 -2
  19. data/lib/sequel/core.rb +147 -11
  20. data/lib/sequel/database.rb +21 -9
  21. data/lib/sequel/dataset.rb +31 -6
  22. data/lib/sequel/dataset/convenience.rb +1 -1
  23. data/lib/sequel/dataset/sql.rb +76 -18
  24. data/lib/sequel/extensions/inflector.rb +2 -51
  25. data/lib/sequel/model.rb +16 -10
  26. data/lib/sequel/model/associations.rb +4 -1
  27. data/lib/sequel/model/base.rb +13 -6
  28. data/lib/sequel/model/default_inflections.rb +46 -0
  29. data/lib/sequel/model/inflections.rb +1 -51
  30. data/lib/sequel/plugins/boolean_readers.rb +52 -0
  31. data/lib/sequel/plugins/instance_hooks.rb +57 -0
  32. data/lib/sequel/plugins/lazy_attributes.rb +13 -1
  33. data/lib/sequel/plugins/nested_attributes.rb +171 -0
  34. data/lib/sequel/plugins/serialization.rb +35 -16
  35. data/lib/sequel/plugins/timestamps.rb +87 -0
  36. data/lib/sequel/plugins/validation_helpers.rb +8 -1
  37. data/lib/sequel/sql.rb +33 -0
  38. data/lib/sequel/version.rb +1 -1
  39. data/spec/adapters/sqlite_spec.rb +11 -6
  40. data/spec/core/core_sql_spec.rb +29 -0
  41. data/spec/core/database_spec.rb +16 -7
  42. data/spec/core/dataset_spec.rb +264 -20
  43. data/spec/extensions/boolean_readers_spec.rb +86 -0
  44. data/spec/extensions/inflector_spec.rb +67 -4
  45. data/spec/extensions/instance_hooks_spec.rb +133 -0
  46. data/spec/extensions/lazy_attributes_spec.rb +45 -5
  47. data/spec/extensions/nested_attributes_spec.rb +272 -0
  48. data/spec/extensions/serialization_spec.rb +64 -1
  49. data/spec/extensions/timestamps_spec.rb +150 -0
  50. data/spec/extensions/validation_helpers_spec.rb +18 -0
  51. data/spec/integration/dataset_test.rb +79 -2
  52. data/spec/integration/schema_test.rb +17 -0
  53. data/spec/integration/timezone_test.rb +55 -0
  54. data/spec/model/associations_spec.rb +19 -7
  55. data/spec/model/model_spec.rb +29 -0
  56. data/spec/model/record_spec.rb +36 -0
  57. data/spec/spec_config.rb +1 -1
  58. metadata +14 -2
@@ -80,57 +80,8 @@ class String
80
80
  (@uncountables << words).flatten!
81
81
  end
82
82
 
83
- # Setup the default inflections
84
- plural(/$/, 's')
85
- plural(/s$/i, 's')
86
- plural(/(ax|test)is$/i, '\1es')
87
- plural(/(octop|vir)us$/i, '\1i')
88
- plural(/(alias|status)$/i, '\1es')
89
- plural(/(bu)s$/i, '\1ses')
90
- plural(/(buffal|tomat)o$/i, '\1oes')
91
- plural(/([ti])um$/i, '\1a')
92
- plural(/sis$/i, 'ses')
93
- plural(/(?:([^f])fe|([lr])f)$/i, '\1\2ves')
94
- plural(/(hive)$/i, '\1s')
95
- plural(/([^aeiouy]|qu)y$/i, '\1ies')
96
- plural(/(x|ch|ss|sh)$/i, '\1es')
97
- plural(/(matr|vert|ind)ix|ex$/i, '\1ices')
98
- plural(/([m|l])ouse$/i, '\1ice')
99
- plural(/^(ox)$/i, '\1en')
100
- plural(/(quiz)$/i, '\1zes')
101
-
102
- singular(/s$/i, '')
103
- singular(/(n)ews$/i, '\1ews')
104
- singular(/([ti])a$/i, '\1um')
105
- singular(/((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)ses$/i, '\1\2sis')
106
- singular(/(^analy)ses$/i, '\1sis')
107
- singular(/([^f])ves$/i, '\1fe')
108
- singular(/(hive)s$/i, '\1')
109
- singular(/(tive)s$/i, '\1')
110
- singular(/([lr])ves$/i, '\1f')
111
- singular(/([^aeiouy]|qu)ies$/i, '\1y')
112
- singular(/(s)eries$/i, '\1eries')
113
- singular(/(m)ovies$/i, '\1ovie')
114
- singular(/(x|ch|ss|sh)es$/i, '\1')
115
- singular(/([m|l])ice$/i, '\1ouse')
116
- singular(/(bus)es$/i, '\1')
117
- singular(/(o)es$/i, '\1')
118
- singular(/(shoe)s$/i, '\1')
119
- singular(/(cris|ax|test)es$/i, '\1is')
120
- singular(/(octop|vir)i$/i, '\1us')
121
- singular(/(alias|status)es$/i, '\1')
122
- singular(/^(ox)en/i, '\1')
123
- singular(/(vert|ind)ices$/i, '\1ex')
124
- singular(/(matr)ices$/i, '\1ix')
125
- singular(/(quiz)zes$/i, '\1')
126
-
127
- irregular('person', 'people')
128
- irregular('man', 'men')
129
- irregular('child', 'children')
130
- irregular('sex', 'sexes')
131
- irregular('move', 'moves')
132
-
133
- uncountable(%w(equipment information rice money species series fish sheep))
83
+ Sequel.require('default_inflections', 'model')
84
+ instance_eval(&Sequel::DEFAULT_INFLECTIONS_PROC)
134
85
  end
135
86
 
136
87
  # Yield the Inflections module if a block is given, and return
data/lib/sequel/model.rb CHANGED
@@ -17,7 +17,13 @@ module Sequel
17
17
  # table_name # => :something
18
18
  # end
19
19
  def self.Model(source)
20
- Model::ANONYMOUS_MODEL_CLASSES[source] ||= Class.new(Model).set_dataset(source)
20
+ Model::ANONYMOUS_MODEL_CLASSES[source] ||= if source.is_a?(Database)
21
+ c = Class.new(Model)
22
+ c.db = source
23
+ c
24
+ else
25
+ Class.new(Model).set_dataset(source)
26
+ end
21
27
  end
22
28
 
23
29
  # Sequel::Model is an object relational mapper built on top of Sequel core. Each
@@ -38,15 +44,15 @@ module Sequel
38
44
  ANONYMOUS_MODEL_CLASSES = {}
39
45
 
40
46
  # Class methods added to model that call the method of the same name on the dataset
41
- DATASET_METHODS = %w'<< all avg count delete distinct eager eager_graph
42
- each each_page empty? except exclude filter first from from_self
47
+ DATASET_METHODS = %w'<< add_graph_aliases all avg count delete distinct
48
+ each each_page eager eager_graph empty? except exclude filter first from from_self
43
49
  full_outer_join get graph grep group group_and_count group_by having import
44
- inner_join insert insert_multiple intersect interval join join_table
45
- last left_outer_join limit map multi_insert naked order order_by
46
- order_more paginate print qualify query range reverse_order right_outer_join
47
- select select_all select_more server set set_graph_aliases
48
- single_value to_csv to_hash union unfiltered unordered
49
- update where with with_sql'.map{|x| x.to_sym}
50
+ inner_join insert insert_multiple intersect interval invert join join_table
51
+ last left_outer_join limit map max min multi_insert naked order order_by
52
+ order_more paginate print qualify query range reverse reverse_order right_outer_join
53
+ select select_all select_more server set set_defaults set_graph_aliases set_overrides
54
+ single_value sum to_csv to_hash truncate unfiltered ungraphed ungrouped union unlimited unordered
55
+ update where with with_recursive with_sql'.map{|x| x.to_sym}
50
56
 
51
57
  # Class instance variables to set to nil when a subclass is created, for -w compliance
52
58
  EMPTY_INSTANCE_VARIABLES = [:@overridable_methods_module, :@db]
@@ -102,7 +108,7 @@ module Sequel
102
108
  @use_transactions = true
103
109
  end
104
110
 
105
- require %w"inflections plugins base exceptions errors", "model"
111
+ require %w"default_inflections inflections plugins base exceptions errors", "model"
106
112
  if !defined?(::SEQUEL_NO_ASSOCIATIONS) && !ENV.has_key?('SEQUEL_NO_ASSOCIATIONS')
107
113
  require 'associations', 'model'
108
114
  Model.plugin Model::Associations
@@ -882,7 +882,10 @@ module Sequel
882
882
  # Add the given associated object to the given association
883
883
  def add_associated_object(opts, o, *args)
884
884
  raise(Sequel::Error, "model object #{model} does not have a primary key") unless pk
885
- raise(Sequel::Error, "associated object #{o.model} does not have a primary key") if opts.need_associated_primary_key? && !o.pk
885
+ if opts.need_associated_primary_key?
886
+ o.save if o.new?
887
+ raise(Sequel::Error, "associated object #{o.model} does not have a primary key") unless o.pk
888
+ end
886
889
  return if run_association_callbacks(opts, :before_add, o) == false
887
890
  send(opts._add_method, o, *args)
888
891
  associations[opts[:name]].push(o) if associations.include?(opts[:name])
@@ -184,10 +184,10 @@ module Sequel
184
184
  unless ivs.include?("@dataset")
185
185
  db
186
186
  begin
187
- if self == Model
187
+ if self == Model || !@dataset
188
188
  subclass.set_dataset(subclass.implicit_table_name) unless subclass.name.empty?
189
- elsif ds = instance_variable_get(:@dataset)
190
- subclass.set_dataset(ds.clone, :inherited=>true)
189
+ elsif @dataset
190
+ subclass.set_dataset(@dataset.clone, :inherited=>true)
191
191
  end
192
192
  rescue
193
193
  nil
@@ -260,6 +260,7 @@ module Sequel
260
260
  # If a dataset is used, the model's database is changed to the given
261
261
  # dataset. If a symbol is used, a dataset is created from the current
262
262
  # database with the table name given. Other arguments raise an Error.
263
+ # Returns self.
263
264
  #
264
265
  # This changes the row_proc of the given dataset to return
265
266
  # model objects, extends the dataset with the dataset_method_modules,
@@ -652,6 +653,12 @@ module Sequel
652
653
  def keys
653
654
  @values.keys
654
655
  end
656
+
657
+ # Whether this object has been modified since last saved, used by
658
+ # save_changes to determine whether changes should be saved
659
+ def modified?
660
+ !changed_columns.empty?
661
+ end
655
662
 
656
663
  # Returns true if the current instance represents a new record.
657
664
  def new?
@@ -705,11 +712,11 @@ module Sequel
705
712
  use_transaction ? db.transaction(opts){_save(columns, opts)} : _save(columns, opts)
706
713
  end
707
714
 
708
- # Saves only changed columns or does nothing if no columns are marked as
709
- # chanaged. If no columns have been changed, returns nil. If unable to
715
+ # Saves only changed columns if the object has been modified.
716
+ # If the object has not been modified, returns nil. If unable to
710
717
  # save, returns false unless raise_on_save_failure is true.
711
718
  def save_changes
712
- save(:changed=>true) || false unless changed_columns.empty?
719
+ save(:changed=>true) || false if modified?
713
720
  end
714
721
 
715
722
  # Updates the instance with the supplied values with support for virtual
@@ -0,0 +1,46 @@
1
+ module Sequel
2
+ # Proc that is instance evaled to create the default inflections for both the
3
+ # model inflector and the inflector extension.
4
+ DEFAULT_INFLECTIONS_PROC = lambda do
5
+ plural(/$/, 's')
6
+ plural(/s$/i, 's')
7
+ plural(/(alias|(?:stat|octop|vir|b)us)$/i, '\1es')
8
+ plural(/(buffal|tomat)o$/i, '\1oes')
9
+ plural(/([ti])um$/i, '\1a')
10
+ plural(/sis$/i, 'ses')
11
+ plural(/(?:([^f])fe|([lr])f)$/i, '\1\2ves')
12
+ plural(/(hive)$/i, '\1s')
13
+ plural(/([^aeiouy]|qu)y$/i, '\1ies')
14
+ plural(/(x|ch|ss|sh)$/i, '\1es')
15
+ plural(/(matr|vert|ind)ix|ex$/i, '\1ices')
16
+ plural(/([m|l])ouse$/i, '\1ice')
17
+
18
+ singular(/s$/i, '')
19
+ singular(/([ti])a$/i, '\1um')
20
+ singular(/(analy|ba|cri|diagno|parenthe|progno|synop|the)ses$/i, '\1sis')
21
+ singular(/([^f])ves$/i, '\1fe')
22
+ singular(/([h|t]ive)s$/i, '\1')
23
+ singular(/([lr])ves$/i, '\1f')
24
+ singular(/([^aeiouy]|qu)ies$/i, '\1y')
25
+ singular(/(m)ovies$/i, '\1ovie')
26
+ singular(/(x|ch|ss|sh)es$/i, '\1')
27
+ singular(/([m|l])ice$/i, '\1ouse')
28
+ singular(/buses$/i, 'bus')
29
+ singular(/oes$/i, 'o')
30
+ singular(/shoes$/i, 'shoe')
31
+ singular(/(alias|(?:stat|octop|vir|b)us)es$/i, '\1')
32
+ singular(/(vert|ind)ices$/i, '\1ex')
33
+ singular(/matrices$/i, 'matrix')
34
+
35
+ irregular('person', 'people')
36
+ irregular('man', 'men')
37
+ irregular('child', 'children')
38
+ irregular('sex', 'sexes')
39
+ irregular('move', 'moves')
40
+ irregular('ox', 'oxen')
41
+ irregular('quiz', 'quizzes')
42
+ irregular('testis', 'testes')
43
+
44
+ uncountable(%w(equipment information rice money species series fish sheep news))
45
+ end
46
+ end
@@ -96,57 +96,7 @@ module Sequel
96
96
  (@uncountables << words).flatten!
97
97
  end
98
98
 
99
- # Setup the default inflections
100
- plural(/$/, 's')
101
- plural(/s$/i, 's')
102
- plural(/(ax|test)is$/i, '\1es')
103
- plural(/(octop|vir)us$/i, '\1i')
104
- plural(/(alias|status)$/i, '\1es')
105
- plural(/(bu)s$/i, '\1ses')
106
- plural(/(buffal|tomat)o$/i, '\1oes')
107
- plural(/([ti])um$/i, '\1a')
108
- plural(/sis$/i, 'ses')
109
- plural(/(?:([^f])fe|([lr])f)$/i, '\1\2ves')
110
- plural(/(hive)$/i, '\1s')
111
- plural(/([^aeiouy]|qu)y$/i, '\1ies')
112
- plural(/(x|ch|ss|sh)$/i, '\1es')
113
- plural(/(matr|vert|ind)ix|ex$/i, '\1ices')
114
- plural(/([m|l])ouse$/i, '\1ice')
115
- plural(/^(ox)$/i, '\1en')
116
- plural(/(quiz)$/i, '\1zes')
117
-
118
- singular(/s$/i, '')
119
- singular(/(n)ews$/i, '\1ews')
120
- singular(/([ti])a$/i, '\1um')
121
- singular(/((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)ses$/i, '\1\2sis')
122
- singular(/(^analy)ses$/i, '\1sis')
123
- singular(/([^f])ves$/i, '\1fe')
124
- singular(/(hive)s$/i, '\1')
125
- singular(/(tive)s$/i, '\1')
126
- singular(/([lr])ves$/i, '\1f')
127
- singular(/([^aeiouy]|qu)ies$/i, '\1y')
128
- singular(/(s)eries$/i, '\1eries')
129
- singular(/(m)ovies$/i, '\1ovie')
130
- singular(/(x|ch|ss|sh)es$/i, '\1')
131
- singular(/([m|l])ice$/i, '\1ouse')
132
- singular(/(bus)es$/i, '\1')
133
- singular(/(o)es$/i, '\1')
134
- singular(/(shoe)s$/i, '\1')
135
- singular(/(cris|ax|test)es$/i, '\1is')
136
- singular(/(octop|vir)i$/i, '\1us')
137
- singular(/(alias|status)es$/i, '\1')
138
- singular(/^(ox)en/i, '\1')
139
- singular(/(vert|ind)ices$/i, '\1ex')
140
- singular(/(matr)ices$/i, '\1ix')
141
- singular(/(quiz)zes$/i, '\1')
142
-
143
- irregular('person', 'people')
144
- irregular('man', 'men')
145
- irregular('child', 'children')
146
- irregular('sex', 'sexes')
147
- irregular('move', 'moves')
148
-
149
- uncountable(%w(equipment information rice money species series fish sheep))
99
+ instance_eval(&DEFAULT_INFLECTIONS_PROC)
150
100
 
151
101
  private
152
102
 
@@ -0,0 +1,52 @@
1
+ module Sequel
2
+ module Plugins
3
+ # The BooleaReaders plugin allows for the creation of attribute? methods
4
+ # for boolean columns, which provide a nicer API. By default, the accessors
5
+ # are created for all columns of type :boolean. However, you can provide a
6
+ # block to the plugin to change the criteria used to determine if a
7
+ # column is boolean:
8
+ #
9
+ # Sequel::Model.plugin(:boolean_readers){|c| db_schema[c][:db_type] =~ /\Atinyint/}
10
+ #
11
+ # This may be useful if you are using MySQL and have some tinyint columns
12
+ # that represent booleans and others that represent integers. You can turn
13
+ # the convert_tinyint_to_bool setting off and use the attribute methods for
14
+ # the integer value and the attribute? methods for the boolean value.
15
+ module BooleanReaders
16
+ # Default proc for determining if given column is a boolean, which
17
+ # just checks that the :type is boolean.
18
+ DEFAULT_BOOLEAN_ATTRIBUTE_PROC = lambda{|c| db_schema[c][:type] == :boolean}
19
+
20
+ # Add the boolean_attribute? class method to the model, and create
21
+ # attribute? boolean reader methods for the class's columns if the class has a dataset.
22
+ def self.configure(model, &block)
23
+ model.meta_def(:boolean_attribute?, &(block || DEFAULT_BOOLEAN_ATTRIBUTE_PROC))
24
+ model.instance_eval{send(:create_boolean_readers) if @dataset}
25
+ end
26
+
27
+ module ClassMethods
28
+ # Create boolean readers for the class using the columns from the new dataset.
29
+ def set_dataset(*args)
30
+ super
31
+ create_boolean_readers
32
+ self
33
+ end
34
+
35
+ private
36
+
37
+ # Add a attribute? method for the column to a module included in the class.
38
+ def create_boolean_reader(column)
39
+ overridable_methods_module.module_eval do
40
+ define_method("#{column}?"){model.db.typecast_value(:boolean, send(column))}
41
+ end
42
+ end
43
+
44
+ # Add attribute? methods for all of the boolean attributes for this model.
45
+ def create_boolean_readers
46
+ im = instance_methods.collect{|x| x.to_s}
47
+ columns.each{|c| create_boolean_reader(c) if boolean_attribute?(c) && !im.include?("#{c}?")}
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,57 @@
1
+ module Sequel
2
+ module Plugins
3
+ # The instance_hooks plugin allows you to add hooks to specific instances,
4
+ # by passing a block to a _hook method (e.g. before_save_hook{do_something}).
5
+ # The block executed when the hook is called (e.g. before_save).
6
+ #
7
+ # All of the standard hooks are supported, except for after_initialize.
8
+ # Instance level before hooks are executed in reverse order of addition before
9
+ # calling super. Instance level after hooks are executed in order of addition
10
+ # after calling super. If any of the instance level before hook blocks return
11
+ # false, no more instance level before hooks are called and false is returned.
12
+ #
13
+ # Instance level hooks are cleared when the object is saved successfully.
14
+ module InstanceHooks
15
+ module InstanceMethods
16
+ HOOKS = Sequel::Model::HOOKS - [:after_initialize]
17
+ HOOKS.each{|h| class_eval("def #{h}_hook(&block); add_instance_hook(:#{h}, &block) end", __FILE__, __LINE__)}
18
+
19
+ BEFORE_HOOKS, AFTER_HOOKS = HOOKS.partition{|hook| hook.to_s =~ /\Abefore_/}
20
+ BEFORE_HOOKS.each{|h| class_eval("def #{h}; run_instance_hooks(:#{h}) == false ? false : super end", __FILE__, __LINE__)}
21
+ AFTER_HOOKS.each{|h| class_eval("def #{h}; super; run_instance_hooks(:#{h}) end", __FILE__, __LINE__)}
22
+
23
+ # Clear the instance level hooks after saving the object.
24
+ def after_save
25
+ super
26
+ run_instance_hooks(:after_save)
27
+ @instance_hooks.clear if @instance_hooks
28
+ end
29
+
30
+ private
31
+
32
+ # Add the block as an instance level hook. For before hooks, add it to
33
+ # the beginning of the instance hook's array. For after hooks, add it
34
+ # to the end.
35
+ def add_instance_hook(hook, &block)
36
+ instance_hooks(hook).send(BEFORE_HOOKS.include?(hook) ? :unshift : :push, block)
37
+ end
38
+
39
+ # An array of instance level hook blocks for the given hook type.
40
+ def instance_hooks(hook)
41
+ @instance_hooks ||= {}
42
+ @instance_hooks[hook] ||= []
43
+ end
44
+
45
+ # Run all hook blocks of the given hook type. If a before hook,
46
+ # immediately return false if any hook block call returns false.
47
+ def run_instance_hooks(hook)
48
+ if BEFORE_HOOKS.include?(hook)
49
+ instance_hooks(hook).each{|b| return false if b.call == false}
50
+ else
51
+ instance_hooks(hook).each{|b| b.call}
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -29,12 +29,24 @@ module Sequel
29
29
  end
30
30
 
31
31
  module ClassMethods
32
+ # Module to store the lazy attribute getter methods, so they can
33
+ # be overridden and call super to get the lazy attribute behavior
34
+ attr_accessor :lazy_attributes_module
35
+
32
36
  # Remove the given attributes from the list of columns selected by default.
33
37
  # For each attribute given, create an accessor method that allows a lazy
34
38
  # lookup of the attribute. Each attribute should be given as a symbol.
35
39
  def lazy_attributes(*attrs)
36
40
  set_dataset(dataset.select(*(columns - attrs)))
37
- attrs.each do |a|
41
+ attrs.each{|a| define_lazy_attribute_getter(a)}
42
+ end
43
+
44
+ private
45
+
46
+ # Add a lazy attribute getter method to the lazy_attributes_module
47
+ def define_lazy_attribute_getter(a)
48
+ include(self.lazy_attributes_module ||= Module.new) unless lazy_attributes_module
49
+ lazy_attributes_module.class_eval do
38
50
  define_method(a) do
39
51
  if !values.include?(a) && !new?
40
52
  lazy_attribute_lookup(a)
@@ -0,0 +1,171 @@
1
+ module Sequel
2
+ module Plugins
3
+ # The nested_attributes plugin allows you to update attributes for associated
4
+ # objects directly through the parent object, similar to ActiveRecord's
5
+ # Nested Attributes feature.
6
+ #
7
+ # Nested attributes are created using the nested_attributes method:
8
+ #
9
+ # Artist.one_to_many :albums
10
+ # Artist.nested_attributes :albums
11
+ # a = Artist.new(:name=>'YJM',
12
+ # :albums_attributes=>[{:name=>'RF'}, {:name=>'MO'}])
13
+ # # No database activity yet
14
+ #
15
+ # a.save # Saves artist and both albums
16
+ # a.albums.map{|x| x.name} # ['RF', 'MO']
17
+ module NestedAttributes
18
+ # Depend on the instance_hooks plugin.
19
+ def self.apply(model)
20
+ model.plugin(:instance_hooks)
21
+ end
22
+
23
+ module ClassMethods
24
+ # Module to store the nested_attributes setter methods, so they can
25
+ # call be overridden and call super to get the default behavior
26
+ attr_accessor :nested_attributes_module
27
+
28
+ # Allow nested attributes to be set for the given associations. Options:
29
+ # * :destroy - Allow destruction of nested records.
30
+ # * :limit - For *_to_many associations, a limit on the number of records
31
+ # that will be processed, to prevent denial of service attacks.
32
+ # * :remove - Allow disassociation of nested records (can remove the associated
33
+ # object from the parent object, but not destroy the associated object).
34
+ # * :strict - Set to false to not raise an error message if a primary key
35
+ # is provided in a record, but it doesn't match an existing associated
36
+ # object.
37
+ #
38
+ # If a block is provided, it is passed each nested attribute hash. If
39
+ # the hash should be ignored, the block should return anything except false or nil.
40
+ def nested_attributes(*associations, &block)
41
+ include(self.nested_attributes_module ||= Module.new) unless nested_attributes_module
42
+ opts = associations.last.is_a?(Hash) ? associations.pop : {}
43
+ reflections = associations.map{|a| association_reflection(a) || raise(Error, "no association named #{a} for #{self}")}
44
+ reflections.each do |r|
45
+ r[:nested_attributes] = opts
46
+ r[:nested_attributes][:reject_if] ||= block
47
+ def_nested_attribute_method(r)
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ # Add a nested attribute setter method to a module included in the
54
+ # class.
55
+ def def_nested_attribute_method(reflection)
56
+ nested_attributes_module.class_eval do
57
+ if reflection.returns_array?
58
+ define_method("#{reflection[:name]}_attributes=") do |array|
59
+ nested_attributes_list_setter(reflection, array)
60
+ end
61
+ else
62
+ define_method("#{reflection[:name]}_attributes=") do |h|
63
+ nested_attributes_setter(reflection, h)
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
69
+
70
+ module InstanceMethods
71
+ # Assume if an instance has nested attributes set on it, that it has
72
+ # been modified even if none of the instance's columns have been modified.
73
+ def modified?
74
+ super || @has_nested_attributes
75
+ end
76
+
77
+ private
78
+
79
+ # Create a new associated object with the given attributes, validate
80
+ # it when the parent is validated, and save it when the object is saved.
81
+ def nested_attributes_create(reflection, attributes)
82
+ obj = reflection.associated_class.new(attributes)
83
+ after_validation_hook{validate_associated_object(reflection, obj)}
84
+ if reflection.returns_array?
85
+ after_save_hook{send(reflection.add_method, obj)}
86
+ else
87
+ before_save_hook{send(reflection.setter_method, obj.save)}
88
+ end
89
+ end
90
+
91
+ # Find an associated object with the matching pk. If a matching option
92
+ # is not found and the :strict option is not false, raise an Error.
93
+ def nested_attributes_find(reflection, pk)
94
+ pk = pk.to_s
95
+ unless obj = Array(associated_objects = send(reflection[:name])).find{|x| x.pk.to_s == pk}
96
+ raise(Error, 'no associated object with that primary key does not exist') unless reflection[:nested_attributes][:strict] == false
97
+ end
98
+ obj
99
+ end
100
+
101
+ # Take an array or hash of attribute hashes and set each one individually.
102
+ # If a hash is provided it, sort it by key and then use the values.
103
+ # If there is a limit on the nested attributes for this association,
104
+ # make sure the length of the attributes_list is not greater than the limit.
105
+ def nested_attributes_list_setter(reflection, attributes_list)
106
+ attributes_list = attributes_list.sort_by{|x| x.to_s}.map{|k,v| v} if attributes_list.is_a?(Hash)
107
+ if (limit = reflection[:nested_attributes][:limit]) && attributes_list.length > limit
108
+ raise(Error, "number of nested attributes (#{attributes_list.length}) exceeds the limit (#{limit})")
109
+ end
110
+ attributes_list.each{|a| nested_attributes_setter(reflection, a)}
111
+ end
112
+
113
+ # Remove the matching associated object from the current object.
114
+ # If the :destroy option is given, destroy the object after disassociating it.
115
+ def nested_attributes_remove(reflection, pk, opts={})
116
+ if obj = nested_attributes_find(reflection, pk)
117
+ before_save_hook do
118
+ if reflection.returns_array?
119
+ send(reflection.remove_method, obj)
120
+ else
121
+ send(reflection.setter_method, nil)
122
+ end
123
+ end
124
+ after_save_hook{obj.destroy} if opts[:destroy]
125
+ end
126
+ end
127
+
128
+ # Modify the associated object based on the contents of the attribtues hash:
129
+ # * If a block was given to nested_attributes, call it with the attributes and return immediately if the block returns true.
130
+ # * If no primary key exists in the attributes hash, create a new object.
131
+ # * If _delete is a key in the hash and the :destroy option is used, destroy the matching associated object.
132
+ # * If _remove is a key in the hash and the :remove option is used, disassociated the matching associated object.
133
+ # * Otherwise, update the matching associated object with the contents of the hash.
134
+ def nested_attributes_setter(reflection, attributes)
135
+ return if (b = reflection[:nested_attributes][:reject_if]) && b.call(attributes)
136
+ @has_nested_attributes = true
137
+ klass = reflection.associated_class
138
+ if pk = attributes.delete(klass.primary_key) || attributes.delete(klass.primary_key.to_s)
139
+ if klass.db.send(:typecast_value_boolean, attributes[:_delete] || attributes['_delete']) && reflection[:nested_attributes][:destroy]
140
+ nested_attributes_remove(reflection, pk, :destroy=>true)
141
+ elsif klass.db.send(:typecast_value_boolean, attributes[:_remove] || attributes['_remove']) && reflection[:nested_attributes][:remove]
142
+ nested_attributes_remove(reflection, pk)
143
+ else
144
+ nested_attributes_update(reflection, pk, attributes)
145
+ end
146
+ else
147
+ nested_attributes_create(reflection, attributes)
148
+ end
149
+ end
150
+
151
+ # Update the matching associated object with the attributes,
152
+ # validating it when the parent object is validated and saving it
153
+ # when the parent is saved.
154
+ def nested_attributes_update(reflection, pk, attributes)
155
+ if obj = nested_attributes_find(reflection, pk)
156
+ obj.set(attributes)
157
+ after_validation_hook{validate_associated_object(reflection, obj)}
158
+ after_save_hook{obj.save}
159
+ end
160
+ end
161
+
162
+ # Validate the given associated object, adding any validation error messages from the
163
+ # given object to the parent object.
164
+ def validate_associated_object(reflection, obj)
165
+ association = reflection[:name]
166
+ obj.errors.full_messages.each{|m| errors.add(association, m)} unless obj.valid?
167
+ end
168
+ end
169
+ end
170
+ end
171
+ end