enumerate_by 0.4.0
Sign up to get free protection for your applications and to get access to all the features.
- data/CHANGELOG.rdoc +101 -0
- data/LICENSE +20 -0
- data/README.rdoc +117 -0
- data/Rakefile +88 -0
- data/init.rb +1 -0
- data/lib/enumerate_by.rb +366 -0
- data/lib/enumerate_by/extensions/associations.rb +109 -0
- data/lib/enumerate_by/extensions/base_conditions.rb +130 -0
- data/lib/enumerate_by/extensions/serializer.rb +117 -0
- data/lib/enumerate_by/extensions/xml_serializer.rb +41 -0
- data/test/app_root/app/models/car.rb +4 -0
- data/test/app_root/app/models/color.rb +3 -0
- data/test/app_root/db/migrate/001_create_colors.rb +12 -0
- data/test/app_root/db/migrate/002_create_cars.rb +13 -0
- data/test/factory.rb +48 -0
- data/test/test_helper.rb +28 -0
- data/test/unit/assocations_test.rb +70 -0
- data/test/unit/base_conditions_test.rb +46 -0
- data/test/unit/enumerate_by_test.rb +350 -0
- data/test/unit/json_serializer_test.rb +18 -0
- data/test/unit/serializer_test.rb +166 -0
- data/test/unit/xml_serializer_test.rb +71 -0
- metadata +87 -0
@@ -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,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
|