enumerate_by 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,109 @@
1
+ module EnumerateBy
2
+ module Extensions #:nodoc:
3
+ # Adds a set of helpers for using enumerations in associations, including
4
+ # named scopes and assignment via enumerators.
5
+ #
6
+ # The examples below assume the following models have been defined:
7
+ #
8
+ # class Color < ActiveRecord::Base
9
+ # enumerate_by :name
10
+ #
11
+ # bootstrap(
12
+ # {:id => 1, :name => 'red'},
13
+ # {:id => 2, :name => 'blue'},
14
+ # {:id => 3, :name => 'green'}
15
+ # )
16
+ # end
17
+ #
18
+ # class Car < ActiveRecord::Base
19
+ # belongs_to :color
20
+ # end
21
+ #
22
+ # == Named scopes
23
+ #
24
+ # A pair of named scopes are generated for each +belongs_to+ association
25
+ # that is identified by an enumeration. In this case, the following
26
+ # named scopes get generated:
27
+ # * +with_color+ / +with_colors+ - Finds all cars with the given color(s)
28
+ # * +without_color+ / +without_colors+ - Finds all cars without the given color(s)
29
+ #
30
+ # For example,
31
+ #
32
+ # Car.with_color('red') # Cars with the color name "red"
33
+ # Car.without_color('red') # Cars without the color name "red"
34
+ # Car.with_colors('red', 'blue') # Cars with either the color names "red" or "blue"
35
+ #
36
+ # == Association assignment
37
+ #
38
+ # Normally, +belongs_to+ associations are assigned with either the actual
39
+ # record or through its primary key. When used with enumerations, support
40
+ # is added for assigning these associations through the enumerators
41
+ # defined for the class.
42
+ #
43
+ # For example,
44
+ #
45
+ # # With valid enumerator
46
+ # car = Car.new # => #<Car id: nil, color_id: nil>
47
+ # car.color = 'red'
48
+ # car.color_id # => 1
49
+ # car.color # => #<Color id: 1, name: "red">
50
+ #
51
+ # # With invalid enumerator
52
+ # car = Car.new # => #<Car id: nil, color_id: nil>
53
+ # car.color = 'invalid'
54
+ # car.color_id # => nil
55
+ # car.color # => nil
56
+ #
57
+ # In the above example, the actual Color association is automatically
58
+ # looked up by finding the Color record identified by the enumerator the
59
+ # given enumerator ("red" in this case).
60
+ module Associations
61
+ def self.extended(base) #:nodoc:
62
+ class << base
63
+ alias_method_chain :belongs_to, :enumerations
64
+ end
65
+ end
66
+
67
+ # Adds support for belongs_to and enumerations
68
+ def belongs_to_with_enumerations(association_id, options = {})
69
+ belongs_to_without_enumerations(association_id, options)
70
+
71
+ # Override accessor if class is valid enumeration
72
+ reflection = reflections[association_id.to_sym]
73
+ if !reflection.options[:polymorphic] && (reflection.klass < ActiveRecord::Base) && reflection.klass.enumeration?
74
+ name = reflection.name
75
+ primary_key_name = reflection.primary_key_name
76
+ class_name = reflection.class_name
77
+ klass = reflection.klass
78
+
79
+ # Inclusion scopes
80
+ %W(with_#{name} with_#{name.to_s.pluralize}).each do |scope_name|
81
+ named_scope scope_name.to_sym, lambda {|*enumerators| {
82
+ :conditions => {primary_key_name => klass.find_all_by_enumerator!(enumerators).map(&:id)}
83
+ }}
84
+ end
85
+
86
+ # Exclusion scopes
87
+ %W(without_#{name} without_#{name.to_s.pluralize}).each do |scope_name|
88
+ named_scope scope_name.to_sym, lambda {|*enumerators| {
89
+ :conditions => ["#{primary_key_name} NOT IN (?)", klass.find_all_by_enumerator!(enumerators).map(&:id)]
90
+ }}
91
+ end
92
+
93
+ # Hook in shortcut writer
94
+ define_method("#{name}_with_enumerators=") do |new_value|
95
+ send("#{name}_without_enumerators=", new_value.is_a?(klass) ? new_value : klass.find_by_enumerator(new_value))
96
+ end
97
+ alias_method_chain "#{name}=", :enumerators
98
+
99
+ # Track the association
100
+ enumeration_associations[primary_key_name.to_s] = name.to_s
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
106
+
107
+ ActiveRecord::Base.class_eval do
108
+ extend EnumerateBy::Extensions::Associations
109
+ end
@@ -0,0 +1,130 @@
1
+ module EnumerateBy
2
+ module Extensions #:nodoc:
3
+ # Adds support for using enumerators in dynamic finders and conditions.
4
+ # For example suppose the following models are defined:
5
+ #
6
+ # class Color < ActiveRecord::Base
7
+ # enumerate_by :name
8
+ #
9
+ # bootstrap(
10
+ # {:id => 1, :name => 'red'},
11
+ # {:id => 2, :name => 'blue'},
12
+ # {:id => 3, :name => 'green'}
13
+ # )
14
+ # end
15
+ #
16
+ # class Car < ActiveRecord::Base
17
+ # belongs_to :color
18
+ # end
19
+ #
20
+ # Normally, looking up all cars associated with a particular color
21
+ # requires either a join or knowing the id of the color upfront:
22
+ #
23
+ # Car.find(:all, :joins => :color, :conditions => {:colors => {:name => 'red}})
24
+ # Car.find_by_color_id(1)
25
+ #
26
+ # Instead of doing this manually, the color can be referenced directly
27
+ # via its enumerator like so:
28
+ #
29
+ # # With dynamic finders
30
+ # Car.find_by_color('red')
31
+ #
32
+ # # With conditions
33
+ # Car.all(:conditions => {:color => 'red'})
34
+ #
35
+ # # With updates
36
+ # Car.update_all(:color => 'red')
37
+ #
38
+ # In the above examples, +color+ is essentially treated like a normal
39
+ # attribute on the class, instead triggering the associated Color record
40
+ # to be looked up and replacing the condition with a +color_id+ condition.
41
+ #
42
+ # *Note* that this does not add an additional join on the +colors+ table
43
+ # since the lookup of the color's id should be relatively fast when it's
44
+ # cached in-memory.
45
+ module BaseConditions
46
+ def self.extended(base) #:nodoc:
47
+ class << base
48
+ alias_method_chain :construct_attributes_from_arguments, :enumerations
49
+ alias_method_chain :sanitize_sql_hash_for_conditions, :enumerations
50
+ alias_method_chain :sanitize_sql_hash_for_assignment, :enumerations
51
+ alias_method_chain :all_attributes_exists?, :enumerations
52
+ end
53
+ end
54
+
55
+ # Add support for dynamic finders
56
+ def construct_attributes_from_arguments_with_enumerations(attribute_names, arguments)
57
+ attributes = construct_attributes_from_arguments_without_enumerations(attribute_names, arguments)
58
+ attribute_names.each_with_index do |name, idx|
59
+ if options = enumerator_options_for(name, arguments[idx])
60
+ attributes.delete(name)
61
+ attributes.merge!(options)
62
+ end
63
+ end
64
+
65
+ attributes
66
+ end
67
+
68
+ # Sanitizes a hash of attribute/value pairs into SQL conditions for a WHERE clause.
69
+ def sanitize_sql_hash_for_conditions_with_enumerations(attrs)
70
+ replace_enumerations_in_hash(attrs)
71
+ sanitize_sql_hash_for_conditions_without_enumerations(attrs)
72
+ end
73
+
74
+ # Sanitizes a hash of attribute/value pairs into SQL conditions for a SET clause.
75
+ def sanitize_sql_hash_for_assignment_with_enumerations(attrs)
76
+ replace_enumerations_in_hash(attrs, false)
77
+ sanitize_sql_hash_for_assignment_without_enumerations(attrs)
78
+ end
79
+
80
+ # Make sure dynamic finders don't fail since it won't find the association
81
+ # name in its columns
82
+ def all_attributes_exists_with_enumerations?(attribute_names)
83
+ exists = all_attributes_exists_without_enumerations?(attribute_names)
84
+ exists ||= attribute_names.all? do |name|
85
+ column_methods_hash.include?(name.to_sym) || reflect_on_enumeration(name)
86
+ end
87
+ end
88
+
89
+ private
90
+ # Finds all of the attributes that are enumerations and replaces them
91
+ # with the correct enumerator id
92
+ def replace_enumerations_in_hash(attrs, allow_multiple = true) #:nodoc:
93
+ attrs.each do |attr, value|
94
+ if options = enumerator_options_for(attr, value, allow_multiple)
95
+ attrs.delete(attr)
96
+ attrs.merge!(options)
97
+ end
98
+ end
99
+ end
100
+
101
+ # Generates the enumerator lookup options for the given association
102
+ # name and enumerator value. If the association is *not* for an
103
+ # enumeration, then this will return nil.
104
+ def enumerator_options_for(name, enumerator, allow_multiple = true)
105
+ if reflection = reflect_on_enumeration(name)
106
+ klass = reflection.klass
107
+ attribute = reflection.primary_key_name
108
+ id = if allow_multiple && enumerator.is_a?(Array)
109
+ enumerator.map {|enumerator| klass.find_by_enumerator!(enumerator).id}
110
+ else
111
+ klass.find_by_enumerator!(enumerator).id
112
+ end
113
+
114
+ {attribute => id}
115
+ end
116
+ end
117
+
118
+ # Attempts to find an association with the given name *and* represents
119
+ # an enumeration
120
+ def reflect_on_enumeration(name)
121
+ reflection = reflect_on_association(name.to_sym)
122
+ reflection if reflection && reflection.klass.enumeration?
123
+ end
124
+ end
125
+ end
126
+ end
127
+
128
+ ActiveRecord::Base.class_eval do
129
+ extend EnumerateBy::Extensions::BaseConditions
130
+ end
@@ -0,0 +1,117 @@
1
+ module EnumerateBy
2
+ module Extensions #:nodoc:
3
+ # Adds support for automatically converting enumeration attributes to the
4
+ # value represented by them.
5
+ #
6
+ # == Examples
7
+ #
8
+ # Suppose the following models are defined:
9
+ #
10
+ # class Color < ActiveRecord::Base
11
+ # enumerate_by :name
12
+ #
13
+ # bootstrap(
14
+ # {:id => 1, :name => 'red'},
15
+ # {:id => 2, :name => 'blue'},
16
+ # {:id => 3, :name => 'green'}
17
+ # )
18
+ # end
19
+ #
20
+ # class Car < ActiveRecord::Base
21
+ # belongs_to :color
22
+ # end
23
+ #
24
+ # Given the above, the enumerator for the car will be automatically
25
+ # used for serialization instead of the foreign key like so:
26
+ #
27
+ # car = Car.create(:color => 'red') # => #<Car id: 1, color_id: 1>
28
+ # car.to_xml # => "<car><id type=\"integer\">1</id><color>red</color></car>"
29
+ # car.to_json # => "{id: 1, color: \"red\"}"
30
+ #
31
+ # == Conversion options
32
+ #
33
+ # The actual conversion of enumeration associations can be controlled
34
+ # using the following options:
35
+ #
36
+ # car.to_json # => "{id: 1, color: \"red\"}"
37
+ # car.to_json(:enumerations => false) # => "{id: 1, color_id: 1}"
38
+ # car.to_json(:only => [:color_id]) # => "{color_id: 1}"
39
+ # car.to_json(:only => [:color]) # => "{color: \"red\"}"
40
+ # car.to_json(:include => :color) # => "{id: 1, color_id: 1, color: {id: 1, name: \"red\"}}"
41
+ #
42
+ # As can be seen from above, enumeration attributes can either be treated
43
+ # as pseudo-attributes on the record or its actual association.
44
+ module Serializer
45
+ def self.included(base) #:nodoc:
46
+ base.class_eval do
47
+ alias_method_chain :serializable_attribute_names, :enumerations
48
+ alias_method_chain :serializable_record, :enumerations
49
+ end
50
+ end
51
+
52
+ # Automatically converted enumeration attributes to their association
53
+ # names so that they *appear* as attributes
54
+ def serializable_attribute_names_with_enumerations
55
+ attribute_names = serializable_attribute_names_without_enumerations
56
+
57
+ # Adjust the serializable attributes by converting primary keys for
58
+ # enumeration associations to their association name (where possible)
59
+ if convert_enumerations?
60
+ @only_attributes = Array(options[:only]).map(&:to_s)
61
+ @include_associations = Array(options[:include]).map(&:to_s)
62
+
63
+ attribute_names.map! {|attribute| enumeration_association_for(attribute) || attribute}
64
+ attribute_names |= @record.class.enumeration_associations.values & @only_attributes
65
+ attribute_names.sort!
66
+ attribute_names -= options[:except].map(&:to_s) unless options[:only]
67
+ end
68
+
69
+ attribute_names
70
+ end
71
+
72
+ # Automatically casts enumerations to their public values
73
+ def serializable_record_with_enumerations
74
+ returning(serializable_record = serializable_record_without_enumerations) do
75
+ serializable_record.each do |attribute, value|
76
+ # Typecast to enumerator value
77
+ serializable_record[attribute] = value.enumerator if typecast_to_enumerator?(attribute, value)
78
+ end if convert_enumerations?
79
+ end
80
+ end
81
+
82
+ private
83
+ # Should enumeration attributes be automatically converted based on
84
+ # the serialization configuration
85
+ def convert_enumerations?
86
+ options[:enumerations] != false
87
+ end
88
+
89
+ # Should the given attribute be converted to the actual enumeration?
90
+ def convert_to_enumeration?(attribute)
91
+ !@only_attributes.include?(attribute)
92
+ end
93
+
94
+ # Gets the association name for the given enumeration attribute, if
95
+ # one exists
96
+ def enumeration_association_for(attribute)
97
+ association = @record.class.enumeration_associations[attribute]
98
+ association if association && convert_to_enumeration?(attribute) && !include_enumeration?(association)
99
+ end
100
+
101
+ # Is the given enumeration attribute being included as a whole record
102
+ # instead of just an individual attribute?
103
+ def include_enumeration?(association)
104
+ @include_associations.include?(association)
105
+ end
106
+
107
+ # Should the given value be typecasted to its enumerator value?
108
+ def typecast_to_enumerator?(association, value)
109
+ value.is_a?(ActiveRecord::Base) && value.class.enumeration? && !include_enumeration?(association)
110
+ end
111
+ end
112
+ end
113
+ end
114
+
115
+ ActiveRecord::Serialization::Serializer.class_eval do
116
+ include EnumerateBy::Extensions::Serializer
117
+ end
@@ -0,0 +1,41 @@
1
+ module EnumerateBy
2
+ module Extensions #:nodoc:
3
+ module XmlSerializer #:nodoc:
4
+ # Adds support for xml serialization of enumeration associations as
5
+ # attributes
6
+ module Attribute
7
+ def self.included(base) #:nodoc:
8
+ base.class_eval do
9
+ alias_method_chain :compute_type, :enumerations
10
+ alias_method_chain :compute_value, :enumerations
11
+ end
12
+ end
13
+
14
+ protected
15
+ # Enumerator types are always strings
16
+ def compute_type_with_enumerations
17
+ enumeration_association? ? :string : compute_type_without_enumerations
18
+ end
19
+
20
+ # Gets the real value representing the enumerator
21
+ def compute_value_with_enumerations
22
+ if enumeration_association?
23
+ association = @record.send(name)
24
+ association.enumerator if association
25
+ else
26
+ compute_value_without_enumerations
27
+ end
28
+ end
29
+
30
+ # Is this attribute defined by an enumeration association?
31
+ def enumeration_association?
32
+ @enumeration_association ||= @record.enumeration_associations.value?(name)
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+
39
+ ActiveRecord::XmlSerializer::Attribute.class_eval do
40
+ include EnumerateBy::Extensions::XmlSerializer::Attribute
41
+ end
@@ -0,0 +1,4 @@
1
+ class Car < ActiveRecord::Base
2
+ belongs_to :color
3
+ belongs_to :feature, :polymorphic => true
4
+ end
@@ -0,0 +1,3 @@
1
+ class Color < ActiveRecord::Base
2
+ enumerate_by :name
3
+ end
@@ -0,0 +1,12 @@
1
+ class CreateColors < ActiveRecord::Migration
2
+ def self.up
3
+ create_table :colors do |t|
4
+ t.string :name, :null => false
5
+ t.string :html
6
+ end
7
+ end
8
+
9
+ def self.down
10
+ drop_table :colors
11
+ end
12
+ end
@@ -0,0 +1,13 @@
1
+ class CreateCars < ActiveRecord::Migration
2
+ def self.up
3
+ create_table :cars do |t|
4
+ t.string :name
5
+ t.references :color
6
+ t.references :feature, :class_name => 'Color', :polymorphic => true
7
+ end
8
+ end
9
+
10
+ def self.down
11
+ drop_table :cars
12
+ end
13
+ end
data/test/factory.rb ADDED
@@ -0,0 +1,48 @@
1
+ module Factory
2
+ # Build actions for the model
3
+ def self.build(model, &block)
4
+ name = model.to_s.underscore
5
+
6
+ define_method("#{name}_attributes", block)
7
+ define_method("valid_#{name}_attributes") {|*args| valid_attributes_for(model, *args)}
8
+ define_method("new_#{name}") {|*args| new_record(model, *args)}
9
+ define_method("create_#{name}") {|*args| create_record(model, *args)}
10
+ end
11
+
12
+ # Get valid attributes for the model
13
+ def valid_attributes_for(model, attributes = {})
14
+ name = model.to_s.underscore
15
+ send("#{name}_attributes", attributes)
16
+ attributes.stringify_keys!
17
+ attributes
18
+ end
19
+
20
+ # Build an unsaved record
21
+ def new_record(model, *args)
22
+ attributes = valid_attributes_for(model, *args)
23
+ record = model.new(attributes)
24
+ attributes.each {|attr, value| record.send("#{attr}=", value) if model.accessible_attributes && !model.accessible_attributes.include?(attr) || model.protected_attributes && model.protected_attributes.include?(attr)}
25
+ record
26
+ end
27
+
28
+ # Build and save/reload a record
29
+ def create_record(model, *args)
30
+ record = new_record(model, *args)
31
+ record.save!
32
+ record.reload
33
+ record
34
+ end
35
+
36
+ build Car do |attributes|
37
+ attributes[:color] = create_color unless attributes.include?(:color)
38
+ attributes.reverse_merge!(
39
+ :name => 'Ford Mustang'
40
+ )
41
+ end
42
+
43
+ build Color do |attributes|
44
+ attributes.reverse_merge!(
45
+ :name => 'red'
46
+ )
47
+ end
48
+ end