Skip to content
This repository

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP

feature multiple selection with serialized arrays #3

Open
wants to merge 1 commit into from

3 participants

Henning Koch meguide23

As discussed, for multiple selection and restricted by assignable_values, let's use a serialized array.

Regarding usage, please check the README.md #multiple-selection. For all cases listed you will find according tests added to the active_record_spec.rb.

For any question or further required changes, don't hesitate to contact me.

Henning Koch

Hey Michael,

thanks for the commit. Code looks good except some minor things, but I'm deeply unhappy that e.g. object.humanized_fields is now ambiguous (list of humanized values that are assignable vs. humanization of chosen values). Since serialized array fields are almost always named in plural form, this will be source for confusion whenever someone uses this new feature.

I would rename the method to something else, but this would break existing API clients.

I need to think about this some more before accepting this, sorry.

Hi Henning,

thanks for the feedback. Please consider, that this problem used to exist before for all attributes that could not be properly pluralized like "news". The small difference was that those attributes basically broke assignable_values.

Similar to the rails approach to give a different name for index actions (suffix "index"), I added that the humanized_XXX methods will be prefixed with "available" in those cases. (I would be very happy with a different naming by default though; without ambiguous pluralize at all.)

Have a look at scalar_attribute.rb:74

def humanized_values_method_name

where the name is set and the test active_record_spec.rb:489

        context 'when the property cannot be pluralized' do

where I test the prefixing.

Thus, it's not API breaking but it actually fixes assignable_values for non-pluralizable attributes like "news", too.

Henning Koch

I agree that the pluralized method name was a poor name choice to begin with.

I also saw that you worked around this in your code. What I don't think is a good idea to mix too many adjectives (assignable / available) and also the method name shouldn't depend on whether the field name is pluralizable or not. This is a source for confusion of errors.

As much as it hurts me to break the API I'm contemplating renaming all the methods like humanized_assignable_foos. Maybe keep the old version around with deprecation warnings.

Good choice! Me, too, I prefer a solution without a confusing / pluralizable-dependent method name and humanized_assignable_foos seems perfect to me.

If it helps to speed up the process, I can update our pull-request to include the above naming and to add deprecation warnings respectively. Or do you prefer that we wait for your change until we update our Pull Request?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Showing 1 unique commit by 1 author.

Dec 04, 2012
meguide23 feature multiple selection with serialized arrays fb5e34e
This page is out of date. Refresh to see the latest.
54  README.md
Source Rendered
@@ -163,6 +163,60 @@ Once a changed value has been saved, the previous value disappears from the list
163 163
 This is to prevent records from becoming invalid as the list of assignable values evolves. This also prevents `<select>` menus with blank selections when opening an old record in a web form.
164 164
 
165 165
 
  166
+Multiple selection
  167
+------------------
  168
+
  169
+You can restrict the values but allow multiple selections using serialized arrays:
  170
+
  171
+    class Recording
  172
+
  173
+      serialize :formats, Array 
  174
+
  175
+      assignable_values_for :formats do
  176
+        %w[ep lp single]
  177
+      end
  178
+
  179
+    end
  180
+    
  181
+Now, each assigned array value is checked during validation:
  182
+
  183
+    Recording.new(:formats => ['lp','ep']).valid?        # => true
  184
+    Recording.new(:formats => ['lp', 'elephant').valid?  # => false
  185
+    
  186
+An error is added for every invalid array element.
  187
+
  188
+
  189
+### blank values
  190
+
  191
+For convenience, restricted multiple selection attributes allow the blank value (the empty array) by default. Keep in mind that `nil` will result in an empty array for serialized arrays.  
  192
+
  193
+If you would like to change this behavior and disallow blank values, set `:allow_blank` option to false.
  194
+
  195
+Remember that values are only validated when they change. Thus, if a blank array remains unchanged it is still valid, regardingless the `:allow_blank` option.    
  196
+
  197
+### Humanized output for multiple selection
  198
+
  199
+By default, the humanized output is the array of the humanized values joined by ", ".
  200
+
  201
+    recording = Recording.new(:formats => ['lp', 'ep'])
  202
+    recording.humanized_formats                          # => "Long play, Extended play"
  203
+
  204
+You can override the separator with the `:separator` option:
  205
+
  206
+    class Recording
  207
+
  208
+      serialize :formats, Array 
  209
+
  210
+      assignable_values_for :formats, :separator => "/" do
  211
+        %w[ep lp single]
  212
+      end
  213
+
  214
+    end
  215
+    
  216
+    recording = Recording.new(:formats => ['lp', 'ep'])
  217
+    recording.humanized_formats                          # => "Long play/Extended play"
  218
+
  219
+
166 220
 Restricting belongs_to associations
167 221
 -----------------------------------
168 222
 
5  lib/assignable_values.rb
... ...
@@ -1,7 +1,6 @@
1 1
 require 'assignable_values/errors'
2 2
 require 'assignable_values/active_record'
3  
-require 'assignable_values/active_record/restriction/base'
4  
-require 'assignable_values/active_record/restriction/belongs_to_association'
5  
-require 'assignable_values/active_record/restriction/scalar_attribute'
  3
+require 'assignable_values/active_record/restriction'
  4
+require 'assignable_values/active_record/restriction/type_factory'
6 5
 require 'assignable_values/humanized_value'
7 6
 
7  lib/assignable_values/active_record.rb
@@ -4,15 +4,10 @@ module ActiveRecord
4 4
     private
5 5
 
6 6
     def assignable_values_for(property, options = {}, &values)
7  
-      restriction_type = belongs_to_association?(property) ? Restriction::BelongsToAssociation : Restriction::ScalarAttribute
  7
+      restriction_type = Restriction.type_for(self, property)
8 8
       restriction_type.new(self, property, options, &values)
9 9
     end
10 10
 
11  
-    def belongs_to_association?(property)
12  
-      reflection = reflect_on_association(property)
13  
-      reflection && reflection.macro == :belongs_to
14  
-    end
15  
-
16 11
   end
17 12
 end
18 13
 
13  lib/assignable_values/active_record/restriction.rb
... ...
@@ -0,0 +1,13 @@
  1
+module AssignableValues
  2
+  module ActiveRecord
  3
+    module Restriction
  4
+      
  5
+      def self.type_for(model, property)
  6
+        factory = TypeFactory.new(model)
  7
+        factory.get(property)
  8
+      end
  9
+            
  10
+    end
  11
+  end
  12
+end
  13
+
22  lib/assignable_values/active_record/restriction/base.rb
@@ -20,27 +20,33 @@ def validate_record(record)
20 20
           value = current_value(record)
21 21
           unless allow_blank? && value.blank?
22 22
             begin
23  
-              unless assignable_value?(record, value)
24  
-                record.errors.add(property, not_included_error_message)
25  
-              end
  23
+              validate_value(record, value)
26 24
             rescue DelegateUnavailable
27 25
               # if the delegate is unavailable, the validation is skipped
28 26
             end
29 27
           end
30 28
         end
  29
+        
  30
+        def validate_value(record, value)
  31
+          unless assignable_value?(record, value)
  32
+            record.errors.add(property, not_included_error_message)
  33
+          end
  34
+        end
31 35
 
32 36
         def not_included_error_message
33 37
           I18n.t('errors.messages.inclusion', :default => 'is not included in the list')
34 38
         end
  39
+        
  40
+        def cant_be_blank_error_message
  41
+          I18n.t('errors.messages.blank', :default => "can't be blank")
  42
+        end
35 43
 
36 44
         def assignable_value?(record, value)
37 45
           assignable_values(record).include?(value)
38 46
         end
39 47
 
40 48
         def assignable_values(record, decorate = false)
41  
-          assignable_values = []
42  
-          old_value = previously_saved_value(record)
43  
-          assignable_values << old_value if old_value.present?
  49
+          assignable_values = values_to_skip_validation(record)
44 50
           parsed_values = parsed_assignable_values(record)
45 51
           assignable_values |= parsed_values.delete(:values)
46 52
           parsed_values.each do |meta_name, meta_content|
@@ -71,6 +77,10 @@ def set_default(record)
71 77
         end
72 78
 
73 79
         private
  80
+        
  81
+        def values_to_skip_validation(record)
  82
+          [previously_saved_value(record)].compact
  83
+        end
74 84
 
75 85
         def evaluate_default(record, value_or_proc)
76 86
           if value_or_proc.is_a?(Proc)
17  lib/assignable_values/active_record/restriction/scalar_attribute.rb
@@ -8,13 +8,16 @@ def initialize(*args)
8 8
           define_humanized_value_method
9 9
           define_humanized_values_method
10 10
         end
  11
+        
  12
+        def dictionary_scope
  13
+          "assignable_values.#{model.name.underscore}.#{property}"
  14
+        end
11 15
 
12 16
         def humanized_value(values, value) # we take the values because that contains the humanizations in case humanizations are hard-coded as a hash
13 17
           if value.present?
14 18
             if values.respond_to?(:humanizations)
15 19
               values.humanizations[value]
16 20
             else
17  
-              dictionary_scope = "assignable_values.#{model.name.underscore}.#{property}"
18 21
               I18n.t(value, :scope => dictionary_scope, :default => default_humanization_for_value(value))
19 22
             end
20 23
           end
@@ -67,11 +70,21 @@ def define_humanized_value_method
67 70
             end
68 71
           end
69 72
         end
  73
+        
  74
+        def humanized_values_method_name
  75
+          restriction = self
  76
+          if restriction.property.to_s.pluralize == restriction.property.to_s
  77
+            "available_humanized_#{restriction.property.to_s.pluralize}"
  78
+          else
  79
+            "humanized_#{restriction.property.to_s.pluralize}"
  80
+          end
  81
+        end
70 82
 
71 83
         def define_humanized_values_method
72 84
           restriction = self
  85
+          name = humanized_values_method_name
73 86
           enhance_model do
74  
-            define_method "humanized_#{restriction.property.to_s.pluralize}" do
  87
+            define_method name do
75 88
               restriction.humanized_values(self)
76 89
             end
77 90
           end
51  lib/assignable_values/active_record/restriction/serialized_array_attribute.rb
... ...
@@ -0,0 +1,51 @@
  1
+module AssignableValues
  2
+  module ActiveRecord
  3
+    module Restriction
  4
+      class SerializedArrayAttribute < ScalarAttribute
  5
+        
  6
+        def humanized_value(values, value)
  7
+          value.map{|v| super(values, v)}.join(separator)
  8
+        end
  9
+        
  10
+        def validate_value(record, values)
  11
+          if values.blank? && ! (allow_blank? || skip_blank?(record))
  12
+            record.errors.add(property, cant_be_blank_error_message)
  13
+          else
  14
+            values.each do |value|
  15
+              unless assignable_value?(record, value)
  16
+                record.errors.add(property, not_included_error_message)
  17
+              end
  18
+            end
  19
+          end
  20
+        end
  21
+                
  22
+        private
  23
+        
  24
+        def values_to_skip_validation(record)
  25
+          previously_saved_value(record) || []
  26
+        end
  27
+        
  28
+        # for multiple selections, allow_blank == true is a convenient default 
  29
+        def allow_blank?
  30
+          if @options.has_key?(:allow_blank)
  31
+            super
  32
+          else
  33
+            true
  34
+          end
  35
+        end
  36
+        
  37
+        # if the previous value was blank, we still want to stay with 
  38
+        # the default to skip validation for unchanged values
  39
+        def skip_blank?(record)
  40
+          return false if record.new_record?
  41
+          ! previously_saved_value(record).present?
  42
+        end
  43
+        
  44
+        def separator
  45
+          @options[:separator] || ', '
  46
+        end
  47
+        
  48
+      end
  49
+    end
  50
+  end
  51
+end
46  lib/assignable_values/active_record/restriction/type_factory.rb
... ...
@@ -0,0 +1,46 @@
  1
+require 'assignable_values/active_record/restriction/base'
  2
+require 'assignable_values/active_record/restriction/belongs_to_association'
  3
+require 'assignable_values/active_record/restriction/scalar_attribute'
  4
+require 'assignable_values/active_record/restriction/serialized_array_attribute'
  5
+module AssignableValues
  6
+  module ActiveRecord
  7
+    module Restriction
  8
+      class TypeFactory
  9
+
  10
+        attr_reader :model
  11
+        
  12
+        def initialize(model)
  13
+          @model = model
  14
+        end
  15
+        
  16
+        def get(property)
  17
+          if belongs_to_association?(property)
  18
+            BelongsToAssociation
  19
+          elsif serialized_array?(property)
  20
+            SerializedArrayAttribute 
  21
+          else
  22
+            ScalarAttribute
  23
+          end
  24
+        end
  25
+        
  26
+        private
  27
+        
  28
+        def belongs_to_association?(property)
  29
+          reflection = model.reflect_on_association(property)
  30
+          reflection && reflection.macro == :belongs_to
  31
+        end
  32
+        
  33
+        def serialized_array?(property)
  34
+          serialization = model.serialized_attributes[property.to_s]
  35
+          if serialization.respond_to?(:object_class) # since rails 3.2
  36
+            serialization.object_class == Array
  37
+          else 
  38
+            serialization == Array
  39
+          end
  40
+        end
  41
+
  42
+      end
  43
+    end
  44
+  end
  45
+end
  46
+
2  spec/shared/app_root/app/models/recording/vinyl.rb
... ...
@@ -1,5 +1,7 @@
1 1
 module Recording
2 2
   class Vinyl < ActiveRecord::Base
3 3
     self.table_name = 'vinyl_recordings'
  4
+    
  5
+    serialize :formats, Array
4 6
   end
5 7
 end
5  spec/shared/app_root/config/locales/en.yml
@@ -6,6 +6,7 @@ en:
6 6
   errors:
7 7
     messages:
8 8
       inclusion: "is not included in the list"
  9
+      blank: "can't be blank"
9 10
 
10 11
   assignable_values:
11 12
     song:
@@ -21,3 +22,7 @@ en:
21 22
         '1977': 'The year a new hope was born'
22 23
         '1980': 'The year the Empire stroke back'
23 24
         '1983': 'The year the Jedi returned'
  25
+      formats:
  26
+        lp: 'Long play'
  27
+        ep: 'Extended play'
  28
+        single: 'Single'
1  spec/shared/app_root/db/migrate/003_create_recordings.rb
@@ -3,6 +3,7 @@ class CreateRecordings < ActiveRecord::Migration
3 3
   def self.up
4 4
     create_table :vinyl_recordings do |t|
5 5
       t.integer :year
  6
+      t.string :formats
6 7
     end
7 8
   end
8 9
 
107  spec/shared/assignable_values/active_record_spec.rb
@@ -181,6 +181,101 @@
181 181
       end
182 182
 
183 183
     end
  184
+    
  185
+    context 'when validating serialized arrays' do
  186
+      
  187
+      context 'without options' do
  188
+
  189
+        before :each do
  190
+          @klass = Recording::Vinyl.disposable_copy do
  191
+            assignable_values_for :formats do
  192
+              %w[lp ep single]
  193
+            end
  194
+          end
  195
+        end
  196
+
  197
+        it 'should validate that the attribute is allowed' do
  198
+          @klass.new(:formats => ['ep']).should be_valid
  199
+          @klass.new(:formats => ['ep', 'lp', 'single']).should be_valid
  200
+          @klass.new(:formats => ['disallowed value']).should_not be_valid
  201
+          @klass.new(:formats => ['disallowed value', 'ep']).should_not be_valid
  202
+        end
  203
+
  204
+        it 'should use the same error message as validates_inclusion_of' do
  205
+          record = @klass.new(:formats => ['disallowed value'])
  206
+          record.valid?
  207
+          errors = record.errors[:formats]
  208
+          error = errors.is_a?(Array) ? errors.first : errors # the return value sometimes was a string, sometimes an Array in Rails
  209
+          error.should == I18n.t('errors.messages.inclusion')
  210
+          error.should == 'is not included in the list'
  211
+        end
  212
+        
  213
+        it 'should accept an empty array' do
  214
+          @klass.new(:formats => []).should be_valid
  215
+        end
  216
+        
  217
+        it 'should allow a previously saved value even if that value is no longer allowed' do
  218
+          record = @klass.create!(:formats => ['ep'])
  219
+          @klass.update_all(:formats => ['disallowed value', 'ep'].to_yaml) # update without validations for the sake of this test
  220
+          record.reload.should be_valid
  221
+        end
  222
+        
  223
+        context "#humanized value" do
  224
+        
  225
+          it 'should generate a method returning an array with the humanized values' do
  226
+            vinyl = @klass.new(:formats => ['ep'])
  227
+            vinyl.humanized_formats.should == "Extended play"
  228
+          end
  229
+          
  230
+          it 'should join the values by ", "' do
  231
+            vinyl = @klass.new(:formats => ['lp', 'ep'])
  232
+            vinyl.humanized_formats.should == "Long play, Extended play"
  233
+          end
  234
+          
  235
+        end
  236
+        
  237
+      end
  238
+      
  239
+      context 'if the :allow_blank option is set to false' do
  240
+        
  241
+        before :each do
  242
+          @klass = Recording::Vinyl.disposable_copy do
  243
+            assignable_values_for :formats, :allow_blank => false do
  244
+              %w[lp ep single]
  245
+            end
  246
+          end
  247
+        end
  248
+        
  249
+        it 'should not accept an empty array' do
  250
+          @klass.new(:formats => []).should_not be_valid
  251
+        end
  252
+        
  253
+        it 'should accept an empty array if the previous value was empty' do
  254
+          vinyl = @klass.create!(:formats =>['lp', 'ep'])
  255
+          @klass.update_all(:formats => []) # bypass validations
  256
+          vinyl.reload
  257
+          vinyl.formats = []
  258
+          vinyl.should be_valid
  259
+        end
  260
+        
  261
+      end
  262
+      
  263
+      context 'if the :separator option is set to "/"' do
  264
+        before :each do
  265
+          @klass = Recording::Vinyl.disposable_copy do
  266
+            assignable_values_for :formats, :separator => "/" do
  267
+              %w[lp ep single]
  268
+            end
  269
+          end
  270
+        end
  271
+        
  272
+        it 'should join the values by "/"' do
  273
+          vinyl = @klass.new(:formats => ['lp', 'ep'])
  274
+          vinyl.humanized_formats.should == "Long play/Extended play"
  275
+        end
  276
+      end
  277
+      
  278
+    end
184 279
 
185 280
     context 'when delegating using the :through option' do
186 281
 
@@ -390,6 +485,18 @@ def genres
390 485
           genres.collect(&:humanized).should == ['Pop music', 'Rock music']
391 486
           genres.collect(&:to_s).should == ['Pop music', 'Rock music']
392 487
         end
  488
+        
  489
+        context 'when the property cannot be pluralized' do
  490
+          it 'should prefix the method returning all pairs with "available"' do
  491
+            klass = Recording::Vinyl.disposable_copy do
  492
+              assignable_values_for :formats do
  493
+                %w[ep lp single]
  494
+              end
  495
+            end
  496
+            klass.new.should respond_to :available_humanized_formats
  497
+          end
  498
+        end
  499
+        
393 500
 
394 501
         it 'should use String#humanize as a default translation' do
395 502
           klass = Song.disposable_copy do
Commit_comment_tip

Tip: You can add notes to lines in a file. Hover to the left of a line to make a note

Something went wrong with that request. Please try again.