serializable_attributes 0.9.0

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.
@@ -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