sequel 3.3.0 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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