mschuerig-enumerate_by 0.4.2
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.
- data/CHANGELOG.rdoc +109 -0
- data/LICENSE +20 -0
- data/README.rdoc +117 -0
- data/Rakefile +96 -0
- data/lib/enumerate_by.rb +395 -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/rails/init.rb +1 -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 +442 -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(*args)
|
70
|
+
replace_enumerations_in_hash(args.first)
|
71
|
+
sanitize_sql_hash_for_conditions_without_enumerations(*args)
|
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(*args)
|
76
|
+
replace_enumerations_in_hash(args.first, false)
|
77
|
+
sanitize_sql_hash_for_assignment_without_enumerations(*args)
|
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
|
data/rails/init.rb
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
require 'enumerate_by'
|
@@ -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
|