clean-bitmask-attribute 2.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,162 @@
1
+ require 'bitmask_attribute/value_proxy'
2
+ require 'bitmask_attribute/attribute'
3
+ require 'bitmask_attribute/core_ext/hash_with_indifferent_access' unless defined?(HashWithIndifferentAccess)
4
+ require 'bitmask_attribute/core_ext/returning' unless Object.respond_to?(:returning)
5
+ require 'bitmask_attribute/core_ext/blank' unless Object.respond_to?(:blank?)
6
+
7
+ module BitmaskAttribute
8
+
9
+ class Definition
10
+
11
+ attr_reader :attribute, :values, :extension
12
+ def initialize(attribute, values=[], &extension)
13
+ @attribute = attribute
14
+ @values = values
15
+ @extension = extension
16
+ end
17
+
18
+ def install_on(model)
19
+ validate_for model
20
+ generate_bitmasks_on model
21
+ override model
22
+ create_convenience_class_method_on(model)
23
+ create_convenience_instance_methods_on(model)
24
+ create_named_scopes_on(model) if defined?(ActiveRecord::Base) && model.respond_to?(scope_method)
25
+ end
26
+
27
+ #######
28
+ private
29
+ #######
30
+
31
+ def validate_for(model)
32
+ unless (model.columns.detect { |col| col.name == attribute.to_s } rescue true)
33
+ raise ArgumentError, "`#{attribute}' is not an attribute of `#{model}'"
34
+ end
35
+ end
36
+
37
+ def generate_bitmasks_on(model)
38
+ model.bitmasks[attribute] = returning HashWithIndifferentAccess.new do |mapping|
39
+ values.each_with_index do |value, index|
40
+ mapping[value] = 0b1 << index
41
+ end
42
+ end
43
+ end
44
+
45
+ def override(model)
46
+ override_getter_on(model)
47
+ override_setter_on(model)
48
+ end
49
+
50
+ def override_getter_on(model)
51
+ model.class_eval %(
52
+ def #{attribute}
53
+ @#{attribute} ||= BitmaskAttribute::ValueProxy.new(self, :#{attribute}, &self.class.bitmask_definitions[:#{attribute}].extension)
54
+ end
55
+ )
56
+ end
57
+
58
+ def override_setter_on(model)
59
+ model.class_eval %(
60
+ def #{attribute}=(raw_value)
61
+ values = raw_value.kind_of?(Array) ? raw_value : [raw_value]
62
+ self.#{attribute}.replace(values.reject(&:blank?))
63
+ end
64
+ )
65
+ end
66
+
67
+ def create_convenience_class_method_on(model)
68
+ model.class_eval %(
69
+ def self.bitmask_for_#{attribute}(*values)
70
+ values.inject(0) do |bitmask, value|
71
+ unless (bit = bitmasks[:#{attribute}][value])
72
+ raise ArgumentError, "Unsupported value for #{attribute}: \#{value.inspect}"
73
+ end
74
+ bitmask | bit
75
+ end
76
+ end
77
+ )
78
+ end
79
+
80
+
81
+ def create_convenience_instance_methods_on(model)
82
+ values.each do |value|
83
+ model.class_eval %(
84
+ def #{attribute}_for_#{value}?
85
+ self.#{attribute}?(:#{value})
86
+ end
87
+ )
88
+ end
89
+ model.class_eval %(
90
+ def #{attribute}?(*values)
91
+ if !values.blank?
92
+ values.all? do |value|
93
+ self.#{attribute}.include?(value.to_sym)
94
+ end
95
+ else
96
+ self.#{attribute}.present?
97
+ end
98
+ end
99
+ )
100
+ end
101
+
102
+ def scope_method
103
+ ActiveRecord::VERSION::STRING >= "3" ? :scope : :named_scope
104
+ end
105
+
106
+ def create_named_scopes_on(model)
107
+ model.class_eval %(
108
+ #{scope_method} :with_#{attribute},
109
+ proc { |*values|
110
+ if values.blank?
111
+ {:conditions => '#{attribute} > 0 OR #{attribute} IS NOT NULL'}
112
+ else
113
+ sets = values.map do |value|
114
+ mask = #{model}.bitmask_for_#{attribute}(value)
115
+ "#{attribute} & \#{mask} <> 0"
116
+ end
117
+ {:conditions => sets.join(' AND ')}
118
+ end
119
+ }
120
+ #{scope_method} :without_#{attribute}, :conditions => "#{attribute} == 0 OR #{attribute} IS NULL"
121
+ #{scope_method} :no_#{attribute}, :conditions => "#{attribute} == 0 OR #{attribute} IS NULL"
122
+ )
123
+ values.each do |value|
124
+ model.class_eval %(
125
+ #{scope_method} :#{attribute}_for_#{value},
126
+ :conditions => ['#{attribute} & ? <> 0', #{model}.bitmask_for_#{attribute}(:#{value})]
127
+ )
128
+ end
129
+ end
130
+
131
+ end
132
+
133
+ def self.included(model)
134
+ model.extend ClassMethods
135
+ # Include basic attributes support for clean class
136
+ # TODO improve attributes detection
137
+ model.send(:include, BitmaskAttribute::Attribute) unless model.included_modules.detect{|m| m.to_s.include? 'AttributeMethods'}
138
+ end
139
+
140
+ module ClassMethods
141
+
142
+ def bitmask(attribute, options={}, &extension)
143
+ unless options[:as] && options[:as].kind_of?(Array)
144
+ raise ArgumentError, "Must provide an Array :as option"
145
+ end
146
+ bitmask_definitions[attribute] = BitmaskAttribute::Definition.new(attribute, options[:as].to_a, &extension)
147
+ bitmask_definitions[attribute].install_on(self)
148
+ end
149
+
150
+ def bitmask_definitions
151
+ @bitmask_definitions ||= {}
152
+ end
153
+
154
+ def bitmasks
155
+ @bitmasks ||= {}
156
+ end
157
+
158
+ end
159
+
160
+ end
161
+
162
+ ActiveRecord::Base.send :include, BitmaskAttribute if defined? ActiveRecord::Base
data/rails/init.rb ADDED
@@ -0,0 +1,3 @@
1
+ ActiveRecord::Base.instance_eval do
2
+ include BitmaskAttribute
3
+ end
@@ -0,0 +1,223 @@
1
+ require 'test_helper'
2
+
3
+ class BitmaskAttributeTest < Test::Unit::TestCase
4
+
5
+ context "Campaign" do
6
+
7
+ teardown do
8
+ Company.destroy_all
9
+ Campaign.destroy_all
10
+ end
11
+
12
+ should "can assign single value to bitmask" do
13
+ assert_stored Campaign.new(:medium => :web), :web
14
+ end
15
+
16
+ should "can assign multiple values to bitmask" do
17
+ assert_stored Campaign.new(:medium => [:web, :print]), :web, :print
18
+ end
19
+
20
+ should "can add single value to bitmask" do
21
+ campaign = Campaign.new(:medium => [:web, :print])
22
+ assert_stored campaign, :web, :print
23
+ campaign.medium << :phone
24
+ assert_stored campaign, :web, :print, :phone
25
+ end
26
+
27
+ should "ignores duplicate values added to bitmask" do
28
+ campaign = Campaign.new(:medium => [:web, :print])
29
+ assert_stored campaign, :web, :print
30
+ campaign.medium << :phone
31
+ assert_stored campaign, :web, :print, :phone
32
+ campaign.medium << :phone
33
+ assert_stored campaign, :web, :print, :phone
34
+ assert_equal 1, campaign.medium.select { |value| value == :phone }.size
35
+ end
36
+
37
+ should "can assign new values at once to bitmask" do
38
+ campaign = Campaign.new(:medium => [:web, :print])
39
+ assert_stored campaign, :web, :print
40
+ campaign.medium = [:phone, :email]
41
+ assert_stored campaign, :phone, :email
42
+ end
43
+
44
+ should "can save bitmask to db and retrieve values transparently" do
45
+ campaign = Campaign.new(:medium => [:web, :print])
46
+ assert_stored campaign, :web, :print
47
+ assert campaign.save
48
+ assert_stored Campaign.find(campaign.id), :web, :print
49
+ end
50
+
51
+ should "can add custom behavor to value proxies during bitmask definition" do
52
+ campaign = Campaign.new(:medium => [:web, :print])
53
+ assert_raises NoMethodError do
54
+ campaign.medium.worked?
55
+ end
56
+ assert_nothing_raised do
57
+ campaign.misc.worked?
58
+ end
59
+ assert campaign.misc.worked?
60
+ end
61
+
62
+ should "cannot use unsupported values" do
63
+ assert_unsupported { Campaign.new(:medium => [:web, :print, :this_will_fail]) }
64
+ campaign = Campaign.new(:medium => :web)
65
+ assert_unsupported { campaign.medium << :this_will_fail_also }
66
+ assert_unsupported { campaign.medium = [:so_will_this] }
67
+ end
68
+
69
+ should "can determine bitmasks using convenience method" do
70
+ assert Campaign.bitmask_for_medium(:web, :print)
71
+ assert_equal(
72
+ Campaign.bitmasks[:medium][:web] | Campaign.bitmasks[:medium][:print],
73
+ Campaign.bitmask_for_medium(:web, :print)
74
+ )
75
+ end
76
+
77
+ should "assert use of unknown value in convenience method will result in exception" do
78
+ assert_unsupported { Campaign.bitmask_for_medium(:web, :and_this_isnt_valid) }
79
+ end
80
+
81
+ should "hash of values is with indifferent access" do
82
+ string_bit = nil
83
+ assert_nothing_raised do
84
+ assert (string_bit = Campaign.bitmask_for_medium('web', 'print'))
85
+ end
86
+ assert_equal Campaign.bitmask_for_medium(:web, :print), string_bit
87
+ end
88
+
89
+ should "save bitmask with non-standard attribute names" do
90
+ campaign = Campaign.new(:Legacy => [:upper, :case])
91
+ assert campaign.save
92
+ assert_equal [:upper, :case], Campaign.find(campaign.id).Legacy
93
+ end
94
+
95
+ should "ignore blanks fed as values" do
96
+ campaign = Campaign.new(:medium => [:web, :print, ''])
97
+ assert_stored campaign, :web, :print
98
+ end
99
+
100
+ should "convert values passed as strings to symbols" do
101
+ campaign = Campaign.new
102
+ campaign.medium << "web"
103
+ assert_equal [:web], campaign.medium
104
+ assert_equal true, campaign.medium?("web")
105
+ end
106
+
107
+ context "checking" do
108
+
109
+ setup { @campaign = Campaign.new(:medium => [:web, :print]) }
110
+
111
+ context "for a single value" do
112
+
113
+ should "be supported by an attribute_for_value convenience method" do
114
+ assert @campaign.medium_for_web?
115
+ assert @campaign.medium_for_print?
116
+ assert !@campaign.medium_for_email?
117
+ end
118
+
119
+ should "be supported by the simple predicate method" do
120
+ assert @campaign.medium?(:web)
121
+ assert @campaign.medium?(:print)
122
+ assert !@campaign.medium?(:email)
123
+ end
124
+
125
+ end
126
+
127
+ context "for multiple values" do
128
+
129
+ should "be supported by the simple predicate method" do
130
+ assert @campaign.medium?(:web, :print)
131
+ assert !@campaign.medium?(:web, :email)
132
+ end
133
+
134
+ end
135
+
136
+ end
137
+
138
+ context "named scopes" do
139
+
140
+ setup do
141
+ @company = Company.create(:name => "Test Co, Intl.")
142
+ @campaign1 = @company.campaigns.create :medium => [:web, :print]
143
+ @campaign2 = @company.campaigns.create
144
+ @campaign3 = @company.campaigns.create :medium => [:web, :email]
145
+ end
146
+
147
+ should "support retrieval by any value" do
148
+ assert_equal [@campaign1, @campaign3], @company.campaigns.with_medium
149
+ end
150
+
151
+ should "support retrieval by one matching value" do
152
+ assert_equal [@campaign1], @company.campaigns.with_medium(:print)
153
+ end
154
+
155
+ should "support retrieval by all matching values" do
156
+ assert_equal [@campaign1], @company.campaigns.with_medium(:web, :print)
157
+ assert_equal [@campaign3], @company.campaigns.with_medium(:web, :email)
158
+ end
159
+
160
+ should "support retrieval for no values" do
161
+ assert_equal [@campaign2], @company.campaigns.without_medium
162
+ end
163
+
164
+ end
165
+
166
+ should "can check if at least one value is set" do
167
+ campaign = Campaign.new(:medium => [:web, :print])
168
+
169
+ assert campaign.medium?
170
+
171
+ campaign = Campaign.new
172
+
173
+ assert !campaign.medium?
174
+ end
175
+
176
+ should "find by bitmask values" do
177
+ campaign = Campaign.new(:medium => [:web, :print])
178
+ assert campaign.save
179
+
180
+ assert_equal(
181
+ Campaign.find(:all, :conditions => ['medium & ? <> 0', Campaign.bitmask_for_medium(:print)]),
182
+ Campaign.medium_for_print
183
+ )
184
+
185
+ assert_equal Campaign.medium_for_print.all, Campaign.medium_for_print.medium_for_web.all
186
+
187
+ assert_equal [], Campaign.medium_for_email
188
+ assert_equal [], Campaign.medium_for_web.medium_for_email
189
+ end
190
+
191
+ should "find no values" do
192
+ campaign = Campaign.create(:medium => [:web, :print])
193
+ assert campaign.save
194
+
195
+ assert_equal [], Campaign.no_medium
196
+
197
+ campaign.medium = []
198
+ assert campaign.save
199
+
200
+ assert_equal [campaign], Campaign.no_medium
201
+ end
202
+
203
+ #######
204
+ private
205
+ #######
206
+
207
+ def assert_unsupported(&block)
208
+ assert_raises(ArgumentError, &block)
209
+ end
210
+
211
+ def assert_stored(record, *values)
212
+ values.each do |value|
213
+ assert record.medium.any? { |v| v.to_s == value.to_s }, "Values #{record.medium.inspect} does not include #{value.inspect}"
214
+ end
215
+ full_mask = values.inject(0) do |mask, value|
216
+ mask | Campaign.bitmasks[:medium][value]
217
+ end
218
+ assert_equal full_mask, record.medium.to_i
219
+ end
220
+
221
+ end
222
+
223
+ end
@@ -0,0 +1,155 @@
1
+ require 'clean_test_helper'
2
+
3
+ class CleanBitmaskAttributeTest < Test::Unit::TestCase
4
+
5
+ context "CleanCampaign" do
6
+
7
+ should "can assign single value to bitmask" do
8
+ assert_stored CleanCampaign.new(:medium => :web), :web
9
+ end
10
+
11
+ should "can assign multiple values to bitmask" do
12
+ assert_stored CleanCampaign.new(:medium => [:web, :print]), :web, :print
13
+ end
14
+
15
+ should "can add single value to bitmask" do
16
+ campaign = CleanCampaign.new(:medium => [:web, :print])
17
+ assert_stored campaign, :web, :print
18
+ campaign.medium << :phone
19
+ assert_stored campaign, :web, :print, :phone
20
+ end
21
+
22
+ should "ignores duplicate values added to bitmask" do
23
+ campaign = CleanCampaign.new(:medium => [:web, :print])
24
+ assert_stored campaign, :web, :print
25
+ campaign.medium << :phone
26
+ assert_stored campaign, :web, :print, :phone
27
+ campaign.medium << :phone
28
+ assert_stored campaign, :web, :print, :phone
29
+ assert_equal 1, campaign.medium.select { |value| value == :phone }.size
30
+ end
31
+
32
+ should "can assign new values at once to bitmask" do
33
+ campaign = CleanCampaign.new(:medium => [:web, :print])
34
+ assert_stored campaign, :web, :print
35
+ campaign.medium = [:phone, :email]
36
+ assert_stored campaign, :phone, :email
37
+ end
38
+
39
+ should "can add custom behavor to value proxies during bitmask definition" do
40
+ campaign = CleanCampaign.new(:medium => [:web, :print])
41
+ assert_raises NoMethodError do
42
+ campaign.medium.worked?
43
+ end
44
+ assert_nothing_raised do
45
+ campaign.misc.worked?
46
+ end
47
+ assert campaign.misc.worked?
48
+ end
49
+
50
+ should "cannot use unsupported values" do
51
+ assert_unsupported { CleanCampaign.new(:medium => [:web, :print, :this_will_fail]) }
52
+ campaign = CleanCampaign.new(:medium => :web)
53
+ assert_unsupported { campaign.medium << :this_will_fail_also }
54
+ assert_unsupported { campaign.medium = [:so_will_this] }
55
+ end
56
+
57
+ should "can determine bitmasks using convenience method" do
58
+ assert CleanCampaign.bitmask_for_medium(:web, :print)
59
+ assert_equal(
60
+ CleanCampaign.bitmasks[:medium][:web] | CleanCampaign.bitmasks[:medium][:print],
61
+ CleanCampaign.bitmask_for_medium(:web, :print)
62
+ )
63
+ end
64
+
65
+ should "assert use of unknown value in convenience method will result in exception" do
66
+ assert_unsupported { CleanCampaign.bitmask_for_medium(:web, :and_this_isnt_valid) }
67
+ end
68
+
69
+ should "hash of values is with indifferent access" do
70
+ string_bit = nil
71
+ assert_nothing_raised do
72
+ assert (string_bit = CleanCampaign.bitmask_for_medium('web', 'print'))
73
+ end
74
+ assert_equal CleanCampaign.bitmask_for_medium(:web, :print), string_bit
75
+ end
76
+
77
+ should "save bitmask with non-standard attribute names" do
78
+ campaign = CleanCampaign.new(:Legacy => [:upper, :case])
79
+ assert_equal [:upper, :case], campaign.Legacy
80
+ end
81
+
82
+ should "ignore blanks fed as values" do
83
+ campaign = CleanCampaign.new(:medium => [:web, :print, ''])
84
+ assert_stored campaign, :web, :print
85
+ end
86
+
87
+ should "convert values passed as strings to symbols" do
88
+ campaign = CleanCampaign.new
89
+ campaign.medium << "web"
90
+ assert_equal [:web], campaign.medium
91
+ assert_equal true, campaign.medium?("web")
92
+ end
93
+
94
+ context "checking" do
95
+
96
+ setup { @campaign = CleanCampaign.new(:medium => [:web, :print]) }
97
+
98
+ context "for a single value" do
99
+
100
+ should "be supported by an attribute_for_value convenience method" do
101
+ assert @campaign.medium_for_web?
102
+ assert @campaign.medium_for_print?
103
+ assert !@campaign.medium_for_email?
104
+ end
105
+
106
+ should "be supported by the simple predicate method" do
107
+ assert @campaign.medium?(:web)
108
+ assert @campaign.medium?(:print)
109
+ assert !@campaign.medium?(:email)
110
+ end
111
+
112
+ end
113
+
114
+ context "for multiple values" do
115
+
116
+ should "be supported by the simple predicate method" do
117
+ assert @campaign.medium?(:web, :print)
118
+ assert !@campaign.medium?(:web, :email)
119
+ end
120
+
121
+ end
122
+
123
+ end
124
+
125
+ should "can check if at least one value is set" do
126
+ campaign = CleanCampaign.new(:medium => [:web, :print])
127
+
128
+ assert campaign.medium?
129
+
130
+ campaign = CleanCampaign.new
131
+
132
+ assert !campaign.medium?
133
+ end
134
+
135
+ #######
136
+ private
137
+ #######
138
+
139
+ def assert_unsupported(&block)
140
+ assert_raises(ArgumentError, &block)
141
+ end
142
+
143
+ def assert_stored(record, *values)
144
+ values.each do |value|
145
+ assert record.medium.any? { |v| v.to_s == value.to_s }, "Values #{record.medium.inspect} does not include #{value.inspect}"
146
+ end
147
+ full_mask = values.inject(0) do |mask, value|
148
+ mask | CleanCampaign.bitmasks[:medium][value]
149
+ end
150
+ assert_equal full_mask, record.medium.to_i
151
+ end
152
+
153
+ end
154
+
155
+ end
@@ -0,0 +1,42 @@
1
+ require 'rubygems'
2
+ require 'test/unit'
3
+ require 'shoulda'
4
+ begin
5
+ require 'redgreen'
6
+ rescue LoadError
7
+ end
8
+
9
+ $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
10
+ $LOAD_PATH.unshift(File.dirname(__FILE__))
11
+ require 'bitmask-attribute'
12
+ require File.dirname(__FILE__) + '/../rails/init'
13
+
14
+ # Pseudo model for testing purposes
15
+ class CleanCampaign
16
+ include BitmaskAttribute
17
+ bitmask :medium, :as => [:web, :print, :email, :phone]
18
+ bitmask :misc, :as => %w(some useless values) do
19
+ def worked?
20
+ true
21
+ end
22
+ end
23
+ bitmask :Legacy, :as => [:upper, :case]
24
+ end
25
+
26
+ class Test::Unit::TestCase
27
+
28
+ def assert_unsupported(&block)
29
+ assert_raises(ArgumentError, &block)
30
+ end
31
+
32
+ def assert_stored(record, *values)
33
+ values.each do |value|
34
+ assert record.medium.any? { |v| v.to_s == value.to_s }, "Values #{record.medium.inspect} does not include #{value.inspect}"
35
+ end
36
+ full_mask = values.inject(0) do |mask, value|
37
+ mask | Campaign.bitmasks[:medium][value]
38
+ end
39
+ assert_equal full_mask, record.medium.to_i
40
+ end
41
+
42
+ end
@@ -0,0 +1,69 @@
1
+ require 'rubygems'
2
+ require 'test/unit'
3
+ require 'shoulda'
4
+
5
+ require 'active_support'
6
+ require 'active_support/core_ext/object'
7
+ require 'active_record'
8
+
9
+ begin
10
+ require 'redgreen'
11
+ require 'active_support/hash_with_indifferent_access'
12
+ rescue LoadError
13
+ end
14
+
15
+ $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
16
+ $LOAD_PATH.unshift(File.dirname(__FILE__))
17
+ require 'bitmask-attribute'
18
+ require File.dirname(__FILE__) + '/../rails/init'
19
+
20
+ # ActiveRecord::Base.logger = Logger.new(STDOUT)
21
+
22
+ ActiveRecord::Base.establish_connection(
23
+ :adapter => 'sqlite3',
24
+ :database => ':memory:'
25
+ )
26
+
27
+ ActiveRecord::Schema.define do
28
+ create_table :campaigns do |t|
29
+ t.integer :company_id
30
+ t.integer :medium, :misc, :Legacy
31
+ end
32
+ create_table :companies do |t|
33
+ t.string :name
34
+ end
35
+ end
36
+
37
+ class Company < ActiveRecord::Base
38
+ has_many :campaigns
39
+ end
40
+
41
+ # Pseudo model for testing purposes
42
+ class Campaign < ActiveRecord::Base
43
+ belongs_to :company
44
+ bitmask :medium, :as => [:web, :print, :email, :phone]
45
+ bitmask :misc, :as => %w(some useless values) do
46
+ def worked?
47
+ true
48
+ end
49
+ end
50
+ bitmask :Legacy, :as => [:upper, :case]
51
+ end
52
+
53
+ class Test::Unit::TestCase
54
+
55
+ def assert_unsupported(&block)
56
+ assert_raises(ArgumentError, &block)
57
+ end
58
+
59
+ def assert_stored(record, *values)
60
+ values.each do |value|
61
+ assert record.medium.any? { |v| v.to_s == value.to_s }, "Values #{record.medium.inspect} does not include #{value.inspect}"
62
+ end
63
+ full_mask = values.inject(0) do |mask, value|
64
+ mask | Campaign.bitmasks[:medium][value]
65
+ end
66
+ assert_equal full_mask, record.medium.to_i
67
+ end
68
+
69
+ end