serializable_attributes 0.9.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,139 @@
1
+ module SerializableAttributes
2
+ class AttributeType
3
+ def initialize(options = {})
4
+ @default = options[:default]
5
+ end
6
+
7
+ def encode(s) s end
8
+
9
+ def type_for(key)
10
+ SerializableAttributes.const_get(key.to_s.classify).new
11
+ end
12
+
13
+ def default
14
+ @default && @default.duplicable? ? @default.dup : @default
15
+ end
16
+ end
17
+
18
+ class Integer < AttributeType
19
+ attr_reader :default
20
+ def parse(input) input.blank? ? nil : input.to_i end
21
+ end
22
+
23
+ class Float < AttributeType
24
+ attr_reader :default
25
+ def parse(input) input.blank? ? nil : input.to_f end
26
+ end
27
+
28
+ class Boolean < AttributeType
29
+ attr_reader :default
30
+ def parse(input)
31
+ return nil if input == ""
32
+ input && input.respond_to?(:to_i) ? (input.to_i > 0) : input
33
+ end
34
+
35
+ def encode(input)
36
+ return nil if input.to_s.empty?
37
+ return 0 if input == 'false'
38
+ input ? 1 : 0
39
+ end
40
+ end
41
+
42
+ class String < AttributeType
43
+ # converts unicode (\u003c) to the actual character
44
+ # http://rishida.net/tools/conversion/
45
+ def parse(str)
46
+ return nil if str.nil?
47
+ str.to_s.gsub(/\\u([0-9a-fA-F]{4})/) do |s|
48
+ int = $1.to_i(16)
49
+ if int.zero? && s != "0000"
50
+ s
51
+ else
52
+ [int].pack("U")
53
+ end
54
+ end
55
+ end
56
+ end
57
+
58
+ class Time < AttributeType
59
+ def parse(input)
60
+ return nil if input.blank?
61
+ case input
62
+ when ::Time then input
63
+ when ::String then ::Time.parse(input)
64
+ else input.to_time
65
+ end
66
+ end
67
+ def encode(input) input ? input.utc.xmlschema : nil end
68
+ end
69
+
70
+ class Array < AttributeType
71
+ def initialize(options = {})
72
+ super
73
+ @item_type = type_for(options[:type] || "String")
74
+ end
75
+
76
+ def parse(input)
77
+ if input.nil?
78
+ nil
79
+ elsif input.blank?
80
+ []
81
+ else
82
+ input.map! { |item| @item_type.parse(item) }
83
+ end
84
+ end
85
+
86
+ def encode(input)
87
+ if input.nil?
88
+ nil
89
+ elsif input.blank?
90
+ []
91
+ else
92
+ input.map! { |item| @item_type.encode(item) }
93
+ end
94
+ end
95
+ end
96
+
97
+ class Hash < AttributeType
98
+ def initialize(options = {})
99
+ super
100
+ @key_type = String.new
101
+ @types = (options[:types] || {})
102
+ @types.keys.each do |key|
103
+ value = @types.delete(key)
104
+ @types[key.to_s] = type_for(value)
105
+ end
106
+ end
107
+
108
+ def parse(input)
109
+ return nil if input.blank?
110
+ input.keys.each do |key|
111
+ value = input.delete(key)
112
+ key_s = @key_type.parse(key)
113
+ type = @types[key_s] || @key_type
114
+ input[key_s] = type.parse(value)
115
+ end
116
+ input
117
+ end
118
+
119
+ def encode(input)
120
+ return nil if input.blank?
121
+ input.each do |key, value|
122
+ type = @types[key] || @key_type
123
+ input[key] = type.encode(value)
124
+ end
125
+ end
126
+ end
127
+
128
+ class << self
129
+ attr_accessor :types
130
+ def add_type(type, object = nil)
131
+ types[type] = object
132
+ Schema.send(:define_method, type) do |*names|
133
+ field type, *names
134
+ end
135
+ end
136
+ end
137
+ self.types = {}
138
+ end
139
+
@@ -0,0 +1,66 @@
1
+ # create a BLOB column and setup your field types for conversion
2
+ # and convenient attr methods
3
+ #
4
+ # class Profile < ActiveRecord::Base
5
+ # # not needed if used as a rails plugin
6
+ # SerializableAttributes.setup(self)
7
+ #
8
+ # # assumes #data serializes to raw_data blob field
9
+ # serialize_attributes do
10
+ # string :title, :description
11
+ # integer :age
12
+ # float :rank, :percentage
13
+ # time :birthday
14
+ # end
15
+ #
16
+ # # Serializes #data to assumed raw_data blob field
17
+ # serialize_attributes :data do
18
+ # string :title, :description
19
+ # integer :age
20
+ # float :rank, :percentage
21
+ # time :birthday
22
+ # end
23
+ #
24
+ # # set the blob field
25
+ # serialize_attributes :data, :blob => :serialized_field do
26
+ # string :title, :description
27
+ # integer :age
28
+ # float :rank, :percentage
29
+ # time :birthday
30
+ # end
31
+ # end
32
+ #
33
+ module SerializableAttributes
34
+ VERSION = "0.9.0"
35
+
36
+ require File.expand_path('../serializable_attributes/types', __FILE__)
37
+ require File.expand_path('../serializable_attributes/schema', __FILE__)
38
+
39
+ if nil.respond_to?(:duplicable?)
40
+ require File.expand_path('../serializable_attributes/duplicable', __FILE__)
41
+ end
42
+
43
+ module Format
44
+ autoload :ActiveSupportJson, File.expand_path('../serializable_attributes/format/active_support_json', __FILE__)
45
+ end
46
+
47
+ add_type :string, String
48
+ add_type :integer, Integer
49
+ add_type :float, Float
50
+ add_type :time, Time
51
+ add_type :boolean, Boolean
52
+ add_type :array, Array
53
+ add_type :hash, Hash
54
+
55
+ module ModelMethods
56
+ def serialize_attributes(field = :data, options = {}, &block)
57
+ schema = Schema.new(self, field, options)
58
+ schema.instance_eval(&block)
59
+ schema.fields.freeze
60
+ schema
61
+ end
62
+ end
63
+ end
64
+
65
+ Object.const_set :SerializedAttributes, SerializableAttributes
66
+
data/rails_init.rb ADDED
@@ -0,0 +1,3 @@
1
+ require File.expand_path('../lib/serializable_attributes', __FILE__)
2
+ ActiveRecord::Base.extend SerializedAttributes::ModelMethods
3
+
data/script/setup ADDED
@@ -0,0 +1 @@
1
+ bundle install --binstubs --path vendor/gems
@@ -0,0 +1,67 @@
1
+ ## This is the rakegem gemspec template. Make sure you read and understand
2
+ ## all of the comments. Some sections require modification, and others can
3
+ ## be deleted if you don't need them. Once you understand the contents of
4
+ ## this file, feel free to delete any comments that begin with two hash marks.
5
+ ## You can find comprehensive Gem::Specification documentation, at
6
+ ## http://docs.rubygems.org/read/chapter/20
7
+ Gem::Specification.new do |s|
8
+ s.specification_version = 2 if s.respond_to? :specification_version=
9
+ s.required_rubygems_version = Gem::Requirement.new(">= 1.3.5") if s.respond_to? :required_rubygems_version=
10
+
11
+ ## Leave these as is they will be modified for you by the rake gemspec task.
12
+ ## If your rubyforge_project name is different, then edit it and comment out
13
+ ## the sub! line in the Rakefile
14
+ s.name = 'serializable_attributes'
15
+ s.version = '0.9.0'
16
+ s.date = '2011-12-05'
17
+ s.rubyforge_project = 'serializable_attributes'
18
+
19
+ ## Make sure your summary is short. The description may be as long
20
+ ## as you like.
21
+ s.summary = "Store a serialized hash of attributes in a single ActiveRecord column."
22
+ s.description = "A bridge between using AR and a full blown schema-free db."
23
+
24
+ ## List the primary authors. If there are a bunch of authors, it's probably
25
+ ## better to set the email to an email list or something. If you don't have
26
+ ## a custom homepage, consider using your GitHub URL or the like.
27
+ s.authors = ["Rick Olson"]
28
+ s.email = 'technoweenie@gmail.com'
29
+ s.homepage = 'http://github.com/technoweenie/serialized_attributes'
30
+
31
+ ## This gets added to the $LOAD_PATH so that 'lib/NAME.rb' can be required as
32
+ ## require 'NAME.rb' or'/lib/NAME/file.rb' can be as require 'NAME/file.rb'
33
+ s.require_paths = %w[lib]
34
+
35
+ s.add_dependency "activerecord", [">= 2.2.0", "< 3.2.0"]
36
+
37
+ ## Leave this section as-is. It will be automatically generated from the
38
+ ## contents of your Git repository via the gemspec task. DO NOT REMOVE
39
+ ## THE MANIFEST COMMENTS, they are used as delimiters by the task.
40
+ # = MANIFEST =
41
+ s.files = %w[
42
+ LICENSE
43
+ README.md
44
+ Rakefile
45
+ gemfiles/ar-2.2.gemfile
46
+ gemfiles/ar-2.3.gemfile
47
+ gemfiles/ar-3.0.gemfile
48
+ gemfiles/ar-3.1.gemfile
49
+ init.rb
50
+ lib/serializable_attributes.rb
51
+ lib/serializable_attributes/duplicable.rb
52
+ lib/serializable_attributes/format/active_support_json.rb
53
+ lib/serializable_attributes/schema.rb
54
+ lib/serializable_attributes/types.rb
55
+ rails_init.rb
56
+ script/setup
57
+ serializable_attributes.gemspec
58
+ test/serialized_attributes_test.rb
59
+ test/test_helper.rb
60
+ test/types_test.rb
61
+ ]
62
+ # = MANIFEST =
63
+
64
+ ## Test files will be grabbed from the file list. Make sure the path glob
65
+ ## matches what you actually use.
66
+ s.test_files = s.files.select { |path| path =~ %r{^test/*/.+\.rb} }
67
+ end
@@ -0,0 +1,382 @@
1
+ # -*- coding: utf-8 -*-
2
+ require File.expand_path("../test_helper", __FILE__)
3
+
4
+ formatters = [SerializedAttributes::Format::ActiveSupportJson]
5
+ formatters.each do |fmt|
6
+ Object.const_set("SerializedAttributeWithSerializedDataTestWith#{fmt.name.demodulize}", Class.new(ActiveSupport::TestCase)).class_eval do
7
+ class << self
8
+ attr_accessor :format, :current_time, :raw_hash, :raw_data
9
+ end
10
+ self.format = fmt
11
+ self.current_time = Time.now.utc.midnight
12
+ self.raw_hash = {:title => 'abc', :age => 5, :average => 5.1, :birthday => current_time.xmlschema, :active => true, :names => %w(d e f), :lottery_picks => [1, 8, 7], :extras => {'b' => 'two'}}
13
+ self.raw_data = format.encode(raw_hash)
14
+
15
+ def setup
16
+ SerializedRecordWithDefaults.data_schema.formatter = SerializedRecord.data_schema.formatter = self.class.format
17
+ @newbie = SerializedRecordWithDefaults.new
18
+ @record = SerializedRecord.new
19
+ @changed = SerializedRecord.new
20
+ @record.raw_data = self.class.raw_data
21
+ @changed.raw_data = self.class.raw_data
22
+ @changed.title = 'def'
23
+ @changed.age = 6
24
+ end
25
+
26
+ test "schema lists attribute names" do
27
+ %w(title body age average birthday active default_in_my_favor names
28
+ lottery_picks extras).each do |attr|
29
+ assert SerializedRecord.data_schema.all_column_names.include?(attr),
30
+ "#{attr} attribute not found"
31
+ assert SerializedRecord.attribute_names.include?(attr),
32
+ "#{attr} attribute not found"
33
+ end
34
+ assert !SerializedRecord.data_schema.all_column_names.include?('raw_data'),
35
+ "raw_data attribute found"
36
+ end
37
+
38
+ test "existing model respects defaults from missing key" do
39
+ assert !@record.data.key?('default_in_my_favor')
40
+ assert @record.default_in_my_favor?
41
+ assert_equal true, @record.data['default_in_my_favor']
42
+ @record.default_in_my_favor = false
43
+ assert !@record.default_in_my_favor?
44
+ @record.default_in_my_favor = nil
45
+ assert @record.default_in_my_favor?
46
+ end
47
+
48
+ test "new model respects array defaults" do
49
+ assert_equal %w(a b c), @newbie.names
50
+ end
51
+
52
+ test "new model respects hash defaults" do
53
+ assert_equal({:a => 1}, @newbie.extras)
54
+ end
55
+
56
+ test "new model respects integer defaults" do
57
+ assert_equal 18, @newbie.age
58
+ end
59
+
60
+ test "new model respects string defaults" do
61
+ assert_equal 'blank', @newbie.title
62
+ assert_equal 'blank', @newbie.body
63
+ end
64
+
65
+ test "new model respects float defaults" do
66
+ assert_equal 5.2, @newbie.average
67
+ end
68
+
69
+ test "new model respects boolean defaults" do
70
+ assert @newbie.active?
71
+ end
72
+
73
+ test "new model respects date defaults" do
74
+ assert_equal Time.utc(2009, 1, 1), @newbie.birthday
75
+ end
76
+
77
+ test "reloads serialized data" do
78
+ @changed.id = 481516
79
+ assert_equal @record.title, @changed.reload(2342).title
80
+ assert_equal @record.age, @changed.age
81
+ end
82
+
83
+ test "initialized model is not changed" do
84
+ @record.data
85
+ assert !@record.data_changed?
86
+ end
87
+
88
+ test "#attribute_names contains serialized fields" do
89
+ assert_equal %w(active age average birthday extras lottery_picks names title), @record.attribute_names
90
+ @record.body = 'a'
91
+ assert_equal %w(active age average birthday body extras lottery_picks names title), @record.attribute_names
92
+ end
93
+
94
+ test "initialization does not call writers" do
95
+ def @record.title=(v)
96
+ raise ArgumentError
97
+ end
98
+ assert_not_nil @record.data
99
+ end
100
+
101
+ test "ignores data with extra keys" do
102
+ @record.raw_data = self.class.format.encode(self.class.raw_hash.merge(:foo => :bar))
103
+ assert_not_nil @record.title # no undefined foo= error
104
+ assert_equal false, @record.save # extra before_save cancels the operation
105
+ assert_equal self.class.raw_hash.merge(:active => 1).stringify_keys.keys.sort, self.class.format.decode(@record.raw_data).keys.sort
106
+ end
107
+
108
+ test "reads strings" do
109
+ assert_equal self.class.raw_hash[:title], @record.title
110
+ end
111
+
112
+ test "parses strings with unicode characters" do
113
+ @record.title = "Encöded ɐ \\u003c \\Upload \\upload" # test unicode char, \u**** code, and legit \U... string
114
+ assert_equal "Encöded ɐ < \\Upload \\upload", @record.title
115
+ end
116
+
117
+ test "clears strings with nil" do
118
+ assert @record.data.key?('title')
119
+ @record.title = nil
120
+ assert !@record.data.key?('title')
121
+ end
122
+
123
+ test "reads arrays" do
124
+ assert_equal self.class.raw_hash[:names], @record.names
125
+ end
126
+
127
+ test "reads arrays with custom type" do
128
+ assert_equal self.class.raw_hash[:lottery_picks], @record.lottery_picks
129
+ end
130
+
131
+ test "clears arrays with nil" do
132
+ assert @record.data.key?('names')
133
+ @record.names = nil
134
+ assert !@record.data.key?('names')
135
+ end
136
+
137
+ test "reads hashes" do
138
+ assert_equal self.class.raw_hash[:extras].stringify_keys, @record.extras
139
+ end
140
+
141
+ test "reads hashes with custom types" do
142
+ now = Time.utc(Time.now.year, 1, 1)
143
+ @record.raw_data = self.class.format.encode('extras' => {:num => "7", :foo => :bar, :started_at => now})
144
+ assert_equal({'num' => 7, 'started_at' => now, 'foo' => 'bar'}, @record.extras)
145
+ end
146
+
147
+ test "clears hashes with nil" do
148
+ assert @record.data.key?('extras')
149
+ @record.extras = nil
150
+ assert !@record.data.key?('extras')
151
+ end
152
+
153
+ test "reads integers" do
154
+ assert_equal self.class.raw_hash[:age], @record.age
155
+ end
156
+
157
+ test "parses integers from strings" do
158
+ @record.age = '5.5'
159
+ assert_equal 5, @record.age
160
+ end
161
+
162
+ test "clears integers with nil" do
163
+ assert @record.data.key?('age')
164
+ @record.age = nil
165
+ assert !@record.data.key?('age')
166
+ end
167
+
168
+ test "clears integers with blank" do
169
+ assert @record.data.key?('age')
170
+ @record.age = ''
171
+ assert !@record.data.key?('age')
172
+ end
173
+
174
+ test "reads floats" do
175
+ assert_equal self.class.raw_hash[:average], @record.average
176
+ end
177
+
178
+ test "parses floats from strings" do
179
+ @record.average = '5.5'
180
+ assert_equal 5.5, @record.average
181
+ end
182
+
183
+ test "clears floats with nil" do
184
+ assert @record.data.key?('average')
185
+ @record.average = nil
186
+ assert !@record.data.key?('average')
187
+ end
188
+
189
+ test "clears floats with blank" do
190
+ assert @record.data.key?('average')
191
+ @record.average = ''
192
+ assert !@record.data.key?('average')
193
+ end
194
+
195
+ test "reads times" do
196
+ assert_equal self.class.current_time, @record.birthday
197
+ end
198
+
199
+ test "parses times from strings" do
200
+ t = 5.years.ago.utc.midnight
201
+ @record.birthday = t.xmlschema
202
+ assert_equal t, @record.birthday
203
+ end
204
+
205
+ test "clears times with nil" do
206
+ assert @record.data.key?('birthday')
207
+ @record.birthday = nil
208
+ assert !@record.data.key?('birthday')
209
+ end
210
+
211
+ test "clears times with blank" do
212
+ assert @record.data.key?('birthday')
213
+ @record.birthday = ''
214
+ assert !@record.data.key?('birthday')
215
+ end
216
+
217
+ test "reads booleans" do
218
+ assert_equal true, @record.active
219
+ end
220
+
221
+ test "parses booleans from strings" do
222
+ @record.active = '1'
223
+ assert_equal true, @record.active
224
+ @record.active = '0'
225
+ assert_equal false, @record.active
226
+ end
227
+
228
+ test "parses booleans from integers" do
229
+ @record.active = 1
230
+ assert_equal true, @record.active
231
+ @record.active = 0
232
+ assert_equal false, @record.active
233
+ end
234
+
235
+ test "converts booleans to false with nil" do
236
+ assert @record.data.key?('active')
237
+ @record.active = nil
238
+ assert !@record.data.key?('active')
239
+ end
240
+
241
+ test "ignores empty strings for booleans" do
242
+ @newbie.clearance = ""
243
+ assert_nil @newbie.clearance
244
+ end
245
+
246
+ test "attempts to re-encode data when saving" do
247
+ assert_not_nil @record.title
248
+ @record.raw_data = nil
249
+ assert_equal false, @record.save # extra before_save cancels the operation
250
+ expected = self.class.raw_hash.merge \
251
+ :active => true,
252
+ :birthday => Time.parse(self.class.raw_hash[:birthday])
253
+ assert_equal expected.stringify_keys, @record.class.data_schema.decode(@record.raw_data)
254
+ end
255
+
256
+ test "knows untouched record is not changed" do
257
+ assert !@record.data_changed?
258
+ assert_equal [], @record.data_changed
259
+ end
260
+
261
+ test "knows updated record is changed" do
262
+ assert @changed.data_changed?
263
+ assert_equal %w(age title), @changed.data_changed.sort
264
+ end
265
+
266
+ test "tracks if field has changed" do
267
+ assert !@record.title_changed?
268
+ assert @changed.title_changed?
269
+ end
270
+
271
+ test "tracks field changes" do
272
+ assert_nil @record.title_change
273
+ assert_equal %w(abc def), @changed.title_change
274
+ end
275
+ end
276
+
277
+ Object.const_set("SerializedAttributeTest#{fmt.name.demodulize}", Class.new(ActiveSupport::TestCase)).class_eval do
278
+ class << self
279
+ attr_accessor :format
280
+ end
281
+ self.format = fmt
282
+
283
+ def setup
284
+ SerializedRecord.data_schema.formatter = self.class.format
285
+ @record = SerializedRecord.new
286
+ end
287
+
288
+ test "encodes and decodes data successfully" do
289
+ hash = {'a' => 1, 'b' => 2}
290
+ encoded = self.class.format.encode(hash)
291
+ assert_equal self.class.format.decode(encoded), hash
292
+ end
293
+
294
+ test "defines #data method on the model" do
295
+ assert @record.respond_to?(:data)
296
+ assert_equal @record.data, {'default_in_my_favor' => true}
297
+ end
298
+
299
+ attributes = {:string => [:title, :body], :integer => [:age], :float => [:average], :time => [:birthday], :boolean => [:active], :array => [:names, :lottery_picks], :hash => [:extras]}
300
+ attributes.values.flatten.each do |attr|
301
+ test "defines ##{attr} method on the model" do
302
+ assert @record.respond_to?(attr)
303
+ assert_nil @record.send(attr)
304
+ end
305
+
306
+ next if attr == :active
307
+ test "defines ##{attr}_before_type_cast method on the model" do
308
+ assert @record.respond_to?("#{attr}_before_type_cast")
309
+ assert_equal "", @record.send("#{attr}_before_type_cast")
310
+ end
311
+ end
312
+
313
+ test "defines #active_before_type_cast method on the model" do
314
+ assert @record.respond_to?(:active_before_type_cast)
315
+ assert_equal "", @record.active_before_type_cast
316
+ end
317
+
318
+ attributes[:string].each do |attr|
319
+ test "defines ##{attr}= method for string fields" do
320
+ assert @record.respond_to?("#{attr}=")
321
+ assert_equal 'abc', @record.send("#{attr}=", "abc")
322
+ assert_equal 'abc', @record.data[attr.to_s]
323
+ end
324
+
325
+ test "does not define ##{attr}? method for string fields" do
326
+ assert !@record.respond_to?("#{attr}?")
327
+ end
328
+ end
329
+
330
+ attributes[:integer].each do |attr|
331
+ test "defines ##{attr}= method for integer fields" do
332
+ assert @record.respond_to?("#{attr}=")
333
+ assert_equal 0, @record.send("#{attr}=", "abc")
334
+ assert_equal 1, @record.send("#{attr}=", "1.2")
335
+ assert_equal 1, @record.data[attr.to_s]
336
+ end
337
+
338
+ test "does not define ##{attr}? method for integer fields" do
339
+ assert !@record.respond_to?("#{attr}?")
340
+ end
341
+ end
342
+
343
+ attributes[:float].each do |attr|
344
+ test "defines ##{attr}= method for float fields" do
345
+ assert @record.respond_to?("#{attr}=")
346
+ assert_equal 0.0, @record.send("#{attr}=", "abc")
347
+ assert_equal 1.2, @record.send("#{attr}=", "1.2")
348
+ assert_equal 1.2, @record.data[attr.to_s]
349
+ end
350
+
351
+ test "does not define ##{attr}? method for float fields" do
352
+ assert !@record.respond_to?("#{attr}?")
353
+ end
354
+ end
355
+
356
+ attributes[:time].each do |attr|
357
+ test "defines ##{attr}= method for time fields" do
358
+ assert @record.respond_to?("#{attr}=")
359
+ t = Time.now.utc.midnight
360
+ assert_equal t, @record.send("#{attr}=", t.xmlschema)
361
+ assert_equal t, @record.data[attr.to_s]
362
+ end
363
+
364
+ test "does not define ##{attr}? method for boolean fields" do
365
+ assert !@record.respond_to?("#{attr}?")
366
+ end
367
+ end
368
+
369
+ attributes[:boolean].each do |attr|
370
+ test "defines ##{attr}= method for boolean fields" do
371
+ assert @record.respond_to?("#{attr}=")
372
+ assert_equal false, @record.send("#{attr}=", 0)
373
+ assert_equal true, @record.send("#{attr}=", "1.2")
374
+ assert_equal true, @record.data[attr.to_s]
375
+ end
376
+
377
+ test "defines ##{attr}? method for float fields" do
378
+ assert @record.respond_to?("#{attr}?")
379
+ end
380
+ end
381
+ end
382
+ end