active_nomad 0.0.4 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/CHANGELOG CHANGED
@@ -1,3 +1,8 @@
1
+ == 0.1.0 2010-10-05
2
+
3
+ * Provide multiple serialization formats (query string, json).
4
+ * Add #{to,from}_serialized_attributes to easily define more.
5
+
1
6
  == 0.0.4 2010-10-01
2
7
 
3
8
  * Attributes are inheritable.
data/README.markdown CHANGED
@@ -6,16 +6,15 @@ ActiveRecord objects with a customizable persistence strategy.
6
6
 
7
7
  Sometimes you want an Active Record object that does not live in the database.
8
8
  Perhaps it never needs to be persisted, or you'd like to store it in a cookie,
9
- or some other store, but it would still be handy to have ActiveRecord's ability
9
+ or a key-value store, but it would still be handy to have ActiveRecord's ability
10
10
  to cast values, run validations, or fire callbacks.
11
11
 
12
- Ideally, the persistence strategy would be pluggable. With Active Nomad, it is!
12
+ If only the persistence strategy was pluggable...
13
13
 
14
14
  ## How
15
15
 
16
- Subclass from ActiveNomad::Base and declare your attributes with the
17
- `attribute` class method. The arguments look just like creating a column in a
18
- migration:
16
+ Subclass from ActiveNomad::Base and declare your attributes with the `attribute`
17
+ class method. The arguments look just like creating a column in a migration:
19
18
 
20
19
  class Thing < ActiveNomad::Base
21
20
  attribute :name, :string, :limit => 20
@@ -25,27 +24,38 @@ To persist the record, Active Nomad calls `persist`, which calls a
25
24
  Proc registered by `to_save`. For example, here's how you could
26
25
  persist to a cookie:
27
26
 
28
- thing = Thing.deserialize(cookies[:thing])
27
+ thing = Thing.from_json(cookies[:thing])
29
28
  thing.to_save do |thing|
30
- cookies[:thing] = thing.serialize
29
+ cookies[:thing] = thing.to_json
31
30
  true
32
31
  end
33
32
 
34
33
  Things to note:
35
34
 
36
- * Active Nomad defines `serialize` and `deserialize` which will
37
- serialize to and from a valid query string with predictable
38
- attribute order (i.e., appropriate for a cookie).
39
35
  * The proc should return true if persistence was successful, false
40
36
  otherwise. This will be the return value of `save`, etc.
41
- * You may alternatively override `persist` in a subclass if you
42
- don't want to register a proc for every instance.
37
+ * Active Nomad defines `to_json` and `from_json` which will serialize to and
38
+ from a JSON string with predictable attribute order (i.e., appropriate for a
39
+ cookie).
40
+ * You may alternatively override `persist` in a subclass if you don't want to
41
+ register a proc for every instance.
43
42
 
44
- ## Transactions
43
+ ### Serialization formats
45
44
 
46
- In addition to customizing persistence, you can also customize
47
- transaction semantics by overriding the `transaction` class method in
48
- a base class. Example:
45
+ Active Nomad provides serialization to and from:
46
+
47
+ * JSON (`to_json` and `from_json`)
48
+ * Query string (`to_query_string` and `from_query_string`)
49
+
50
+ You can define your own formats easily using `to_serialized_attributes` and
51
+ `from_serialized_attributes`. The former returns an `ActiveSupport::OrderedHash`
52
+ of attribute names to serialized values (`String`s or `nil`s).
53
+
54
+ ### Transactions
55
+
56
+ In addition to customizing persistence, you can also customize transaction
57
+ semantics by overriding the `transaction` class method in a base class. Here's
58
+ an example that implements transactions with Redis:
49
59
 
50
60
  class RedisNomad < ActiveNomad::Base
51
61
  def self.transaction
@@ -64,8 +74,8 @@ a base class. Example:
64
74
  end
65
75
  end
66
76
 
67
- `ActiveNomad::Base.transaction` simply calls the given block with no
68
- real transaction semantics.
77
+ `ActiveNomad::Base.transaction` simply calls the given block with no real
78
+ transaction semantics.
69
79
 
70
80
  ## Notes
71
81
 
data/lib/active_nomad.rb CHANGED
@@ -13,34 +13,74 @@ module ActiveNomad
13
13
  end
14
14
 
15
15
  #
16
- # Return the attributes of this object serialized as a valid query
17
- # string.
16
+ # Return an ActiveSupport::OrderedHash of serialized attributes.
18
17
  #
19
- # Attributes are sorted by name.
18
+ # Attributes are sorted by name. Each value is either a string or
19
+ # nil.
20
20
  #
21
- def serialize
22
- self.class.columns.map do |column|
23
- name = column.name
24
- value = serialize_value(send(name), column.type) or
21
+ def to_serialized_attributes
22
+ attributes = ActiveSupport::OrderedHash.new
23
+ columns = self.class.columns_hash
24
+ self.class.column_names.sort.each do |name|
25
+ column = columns[name] or
25
26
  next
26
- "#{CGI.escape(name)}=#{value}"
27
- end.compact.sort.join('&')
27
+ attributes[name] = serialize_value(send(name), column.type)
28
+ end
29
+ attributes
28
30
  end
29
31
 
30
32
  #
31
33
  # Recreate an object from a serialized string.
32
34
  #
33
- def self.deserialize(string)
34
- params = string ? CGI.parse(string.strip) : {}
35
+ def self.from_serialized_attributes(deserialized_attributes)
35
36
  instance = new
36
- columns.map do |column|
37
- next if !params.key?(column.name)
38
- value = params[column.name].first
39
- instance.send "#{column.name}=", deserialize_value(value, column.type)
37
+ deserialized_attributes.each do |name, serialized_value|
38
+ column = columns_hash[name.to_s] or
39
+ next
40
+ instance.send "#{column.name}=", deserialize_value(serialized_value, column.type)
40
41
  end
41
42
  instance
42
43
  end
43
44
 
45
+ #
46
+ # Serialize this record as a query string.
47
+ #
48
+ def to_query_string
49
+ to_serialized_attributes.map do |name, value|
50
+ next nil if value.nil?
51
+ "#{CGI.escape(name)}=#{CGI.escape(value)}"
52
+ end.compact.sort.join('&')
53
+ end
54
+
55
+ #
56
+ # Deserialize this record from a query string returned by
57
+ # #to_query_string.
58
+ #
59
+ def self.from_query_string(string)
60
+ return new if string.blank?
61
+ serialized_attributes = string.strip.split(/&/).map do |pair|
62
+ name, value = pair.split(/=/, 2)
63
+ [CGI.unescape(name), CGI.unescape(value)]
64
+ end
65
+ from_serialized_attributes(serialized_attributes)
66
+ end
67
+
68
+ #
69
+ # Serialize this record as a JSON string.
70
+ #
71
+ def to_json
72
+ to_serialized_attributes.to_json
73
+ end
74
+
75
+ #
76
+ # Deserialize this record from a JSON string returned by #to_json.
77
+ #
78
+ def self.from_json(string)
79
+ return new if string.blank?
80
+ serialized_attributes = JSON.parse(string)
81
+ from_serialized_attributes(serialized_attributes)
82
+ end
83
+
44
84
  protected
45
85
 
46
86
  #
@@ -115,11 +155,12 @@ module ActiveNomad
115
155
  return nil if value.nil?
116
156
  case type
117
157
  when :datetime, :timestamp, :time
118
- value.to_time.to_i.to_s
158
+ # The day in RFC 2822 is optional - chop it.
159
+ value.rfc2822.sub(/\A[A-Za-z]{3}, /, '')
119
160
  when :date
120
- (value.to_date - DATE_EPOCH).to_i.to_s
161
+ value.to_date.strftime(DATE_FORMAT)
121
162
  else
122
- CGI.escape(value.to_s)
163
+ value.to_s
123
164
  end
124
165
  end
125
166
 
@@ -127,14 +168,14 @@ module ActiveNomad
127
168
  return nil if string.nil?
128
169
  case type
129
170
  when :datetime, :timestamp, :time
130
- Time.at(string.to_i)
171
+ Time.parse(string)
131
172
  when :date
132
- DATE_EPOCH + string.to_i
173
+ Date.parse(string)
133
174
  else
134
- CGI.unescape(string)
175
+ string
135
176
  end
136
177
  end
137
178
 
138
- DATE_EPOCH = Date.parse('1970-01-01')
179
+ DATE_FORMAT = '%d %b %Y'.freeze
139
180
  end
140
181
  end
@@ -1,5 +1,5 @@
1
1
  module ActiveNomad
2
- VERSION = [0, 0, 4]
2
+ VERSION = [0, 1, 0]
3
3
 
4
4
  class << VERSION
5
5
  include Comparable
@@ -123,23 +123,138 @@ describe ActiveNomad::Base do
123
123
  end
124
124
  end
125
125
 
126
- describe "#serialize" do
126
+ describe "#to_serialized_attributes" do
127
+ it "should return a list of attribute names with serialized attributes, sorted by name" do
128
+ klass = Class.new(ActiveNomad::Base) do
129
+ attribute :integer_attribute, :integer
130
+ attribute :string_attribute, :string
131
+ attribute :text_attribute, :text
132
+ attribute :float_attribute, :float
133
+ attribute :decimal_attribute, :decimal
134
+ attribute :datetime_attribute, :datetime
135
+ attribute :timestamp_attribute, :timestamp
136
+ attribute :time_attribute, :time
137
+ attribute :date_attribute, :date
138
+ attribute :binary_attribute, :binary
139
+ attribute :boolean_attribute, :boolean
140
+ attribute :nil_attribute, :string
141
+ end
142
+ instance = klass.new(
143
+ :integer_attribute => 5,
144
+ :string_attribute => 'string',
145
+ :text_attribute => 'text',
146
+ :float_attribute => 1.23,
147
+ :decimal_attribute => BigDecimal.new('123.45'),
148
+ :datetime_attribute => Time.parse('03 Feb 2001 12:34:56 -0000'),
149
+ :timestamp_attribute => Time.parse('03 Feb 2001 12:34:56 -0000'),
150
+ :time_attribute => Time.parse('03 Feb 2001 12:34:56 -0000'),
151
+ :date_attribute => Date.parse('03 Feb 2001'),
152
+ :binary_attribute => "\0\1",
153
+ :boolean_attribute => true,
154
+ :nil_attribute => nil
155
+ )
156
+ instance.to_serialized_attributes.to_a.should == [
157
+ ['binary_attribute', "\0\1"],
158
+ ['boolean_attribute', 'true'],
159
+ ['date_attribute', '03 Feb 2001'],
160
+ ['datetime_attribute', '03 Feb 2001 12:34:56 -0000'],
161
+ ['decimal_attribute', '123.45'],
162
+ ['float_attribute', '1.23'],
163
+ ['integer_attribute', '5'],
164
+ ['nil_attribute', nil],
165
+ ['string_attribute', 'string'],
166
+ ['text_attribute', 'text'],
167
+ ['time_attribute', '03 Feb 2001 12:34:56 -0000'],
168
+ ['timestamp_attribute', '03 Feb 2001 12:34:56 -0000'],
169
+ ]
170
+ end
171
+ end
172
+
173
+ describe ".from_serialized_attributes" do
174
+ it "should create a new record with attributes deserialized from the given parameter list" do
175
+ klass = Class.new(ActiveNomad::Base) do
176
+ attribute :integer_attribute, :integer
177
+ attribute :string_attribute, :string
178
+ attribute :text_attribute, :text
179
+ attribute :float_attribute, :float
180
+ attribute :decimal_attribute, :decimal
181
+ attribute :datetime_attribute, :datetime
182
+ attribute :timestamp_attribute, :timestamp
183
+ attribute :time_attribute, :time
184
+ attribute :date_attribute, :date
185
+ attribute :binary_attribute, :binary
186
+ attribute :boolean_attribute, :boolean
187
+ attribute :nil_attribute, :boolean
188
+ end
189
+ instance = klass.from_serialized_attributes([
190
+ [:integer_attribute, '5'],
191
+ [:string_attribute, 'string'],
192
+ [:text_attribute, 'text'],
193
+ [:float_attribute, '1.23'],
194
+ [:decimal_attribute, '123.45'],
195
+ [:datetime_attribute, '03 Feb 2001 12:34:56 -0000'],
196
+ [:timestamp_attribute, '03 Feb 2001 12:34:56 -0000'],
197
+ [:time_attribute, '03 Feb 2001 12:34:56 -0000'],
198
+ [:date_attribute, '03 Feb 2001'],
199
+ [:binary_attribute, "\0\1"],
200
+ [:boolean_attribute, 'true'],
201
+ [:nil_attribute, nil]
202
+ ])
203
+ instance.integer_attribute.should == 5
204
+ instance.string_attribute.should == 'string'
205
+ instance.text_attribute.should == 'text'
206
+ instance.float_attribute.should == 1.23
207
+ instance.decimal_attribute.should == BigDecimal.new('123.45')
208
+ instance.datetime_attribute.should == Time.parse('03 Feb 2001 12:34:56 -0000')
209
+ instance.timestamp_attribute.should == Time.parse('03 Feb 2001 12:34:56 -0000')
210
+ instance.time_attribute.should == Time.parse('03 Feb 2001 12:34:56 -0000')
211
+ instance.date_attribute.should == Date.parse('03 Feb 2001')
212
+ instance.binary_attribute.should == "\0\1"
213
+ instance.boolean_attribute.should be_true
214
+ instance.nil_attribute.should be_nil
215
+ end
216
+
217
+ it "should work with any enumerable" do
218
+ klass = Class.new(ActiveNomad::Base) do
219
+ attribute :name, :string
220
+ end
221
+ params = Object.new
222
+ class << params
223
+ def each
224
+ yield :name, 'joe'
225
+ end
226
+ end
227
+ params.extend Enumerable
228
+ instance = klass.from_serialized_attributes(params)
229
+ instance.name.should == 'joe'
230
+ end
231
+
232
+ it "should leave defaults alone for attributes which are not set" do
233
+ klass = Class.new(ActiveNomad::Base) do
234
+ attribute :name, :string, :default => 'Joe'
235
+ end
236
+ instance = klass.from_serialized_attributes({})
237
+ instance.name.should == 'Joe'
238
+ end
239
+ end
240
+
241
+ describe "#to_query_string" do
127
242
  it "should serialize the attributes as a query string" do
128
243
  klass = Class.new(ActiveNomad::Base) do
129
244
  attribute :first_name, :string
130
245
  attribute :last_name, :string
131
246
  end
132
247
  instance = klass.new(:first_name => 'Joe', :last_name => 'Blow')
133
- instance.serialize.should == 'first_name=Joe&last_name=Blow'
248
+ instance.to_query_string.should == 'first_name=Joe&last_name=Blow'
134
249
  end
135
250
  end
136
251
 
137
- describe ".deserialize" do
252
+ describe ".from_query_string" do
138
253
  it "should create a new record with no attributes set if nil is given" do
139
254
  klass = Class.new(ActiveNomad::Base) do
140
255
  attribute :name, :string
141
256
  end
142
- instance = klass.deserialize(nil)
257
+ instance = klass.from_query_string(nil)
143
258
  instance.name.should be_nil
144
259
  end
145
260
 
@@ -147,7 +262,7 @@ describe ActiveNomad::Base do
147
262
  klass = Class.new(ActiveNomad::Base) do
148
263
  attribute :name, :string
149
264
  end
150
- instance = klass.deserialize('')
265
+ instance = klass.from_query_string('')
151
266
  instance.name.should be_nil
152
267
  end
153
268
 
@@ -155,7 +270,7 @@ describe ActiveNomad::Base do
155
270
  klass = Class.new(ActiveNomad::Base) do
156
271
  attribute :name, :string
157
272
  end
158
- instance = klass.deserialize(" \t")
273
+ instance = klass.from_query_string(" \t")
159
274
  instance.name.should be_nil
160
275
  end
161
276
 
@@ -163,19 +278,84 @@ describe ActiveNomad::Base do
163
278
  klass = Class.new(ActiveNomad::Base) do
164
279
  attribute :name, :string, :default => 'Joe'
165
280
  end
166
- instance = klass.deserialize(" \t")
281
+ instance = klass.from_query_string(" \t")
167
282
  instance.name.should == 'Joe'
168
283
  end
169
284
  end
170
285
 
171
- describe "roundtripping through #serialize and .deserialize" do
286
+ def self.it_should_roundtrip_through(serializer, deserializer, &block)
287
+ describe "roundtripping through ##{serializer} and .#{deserializer}" do
288
+ class_eval(&block) if block
289
+
290
+ (class << self; self; end).class_eval do
291
+ define_method :it_should_roundtrip do |type, value|
292
+ value = Time.at(value.to_i) if value.is_a?(Time) # chop off subseconds
293
+ it "should roundtrip #{value.inspect} correctly as a #{type}" do
294
+ klass = Class.new(ActiveNomad::Base) do
295
+ attribute :value, type
296
+ end
297
+ instance = klass.new(:value => value)
298
+ roundtripped = klass.send(deserializer, instance.send(serializer))
299
+ roundtripped.value.should == value
300
+ end
301
+ end
302
+ end
303
+
304
+ it_should_roundtrip :integer, nil
305
+ it_should_roundtrip :integer, 0
306
+ it_should_roundtrip :integer, 123
307
+
308
+ it_should_roundtrip :string, nil
309
+ it_should_roundtrip :string, ''
310
+ it_should_roundtrip :string, 'hi'
311
+
312
+ it_should_roundtrip :text, nil
313
+ it_should_roundtrip :text, ''
314
+ it_should_roundtrip :text, 'hi'
315
+
316
+ it_should_roundtrip :float, nil
317
+ it_should_roundtrip :float, 0
318
+ it_should_roundtrip :float, 0.123
319
+
320
+ it_should_roundtrip :decimal, nil
321
+ it_should_roundtrip :decimal, BigDecimal.new('0')
322
+ it_should_roundtrip :decimal, BigDecimal.new('123.45')
323
+
324
+ it_should_roundtrip :datetime, nil
325
+ it_should_roundtrip :datetime, Time.now.in_time_zone
326
+ # TODO: Support DateTime here, which is used when the value is
327
+ # outside the range of a Time.
328
+
329
+ it_should_roundtrip :timestamp, nil
330
+ it_should_roundtrip :timestamp, Time.now.in_time_zone
331
+
332
+ it_should_roundtrip :time, nil
333
+ it_should_roundtrip :time, Time.parse('2000-01-01 01:23:34').in_time_zone
334
+
335
+ it_should_roundtrip :date, nil
336
+ it_should_roundtrip :date, Date.today
337
+
338
+ it_should_roundtrip :binary, nil
339
+ it_should_roundtrip :binary, ''
340
+ it_should_roundtrip :binary, "\t\n"
341
+ #it_should_roundtrip :binary, "\0\1" # TODO: the JSON gem does not handle this
342
+
343
+ it_should_roundtrip :boolean, nil
344
+ it_should_roundtrip :boolean, true
345
+ it_should_roundtrip :boolean, false
346
+ end
347
+ end
348
+
349
+ it_should_roundtrip_through(:to_serialized_attributes, :from_serialized_attributes)
350
+
351
+ it_should_roundtrip_through(:to_query_string, :from_query_string) do
172
352
  it "should not be tripped up by delimiters in the keys" do
173
353
  klass = Class.new(ActiveNomad::Base) do
174
354
  attribute :'a=x', :string
175
355
  attribute :'b&x', :string
176
356
  end
177
357
  original = klass.new("a=x" => "1", "b&x" => "2")
178
- roundtripped = klass.deserialize(original.serialize)
358
+ roundtripped = klass.from_query_string(original.to_query_string)
179
359
  roundtripped.send("a=x").should == "1"
180
360
  roundtripped.send("b&x").should == "2"
181
361
  end
@@ -186,64 +366,30 @@ describe ActiveNomad::Base do
186
366
  attribute :b, :string
187
367
  end
188
368
  original = klass.new(:a => "1=2", :b => "3&4")
189
- roundtripped = klass.deserialize(original.serialize)
369
+ roundtripped = klass.from_query_string(original.to_query_string)
190
370
  roundtripped.a.should == "1=2"
191
371
  roundtripped.b.should == "3&4"
192
372
  end
373
+ end
193
374
 
194
- def self.it_should_roundtrip(type, value)
195
- value = Time.at(value.to_i) if value.is_a?(Time) # chop off subseconds
196
- it "should roundtrip #{value.inspect} correctly as a #{type}" do
197
- klass = Class.new(ActiveNomad::Base) do
198
- attribute :value, type
199
- end
200
- instance = klass.new(:value => value)
201
- roundtripped = klass.deserialize(instance.serialize)
202
- roundtripped.value.should == value
375
+ it_should_roundtrip_through(:to_json, :from_json) do
376
+ it "should not be tripped up by delimiters in the keys" do
377
+ klass = Class.new(ActiveNomad::Base) do
378
+ attribute :"'a':b,c", :string
203
379
  end
380
+ original = klass.new("'a':b,c" => "1")
381
+ roundtripped = klass.from_json(original.to_json)
382
+ roundtripped.send("'a':b,c").should == "1"
204
383
  end
205
384
 
206
- it_should_roundtrip :integer, nil
207
- it_should_roundtrip :integer, 0
208
- it_should_roundtrip :integer, 123
209
-
210
- it_should_roundtrip :string, nil
211
- it_should_roundtrip :string, ''
212
- it_should_roundtrip :string, 'hi'
213
-
214
- it_should_roundtrip :text, nil
215
- it_should_roundtrip :text, ''
216
- it_should_roundtrip :text, 'hi'
217
-
218
- it_should_roundtrip :float, nil
219
- it_should_roundtrip :float, 0
220
- it_should_roundtrip :float, 0.123
221
-
222
- it_should_roundtrip :decimal, nil
223
- it_should_roundtrip :decimal, BigDecimal.new('0')
224
- it_should_roundtrip :decimal, BigDecimal.new('123.45')
225
-
226
- it_should_roundtrip :datetime, nil
227
- it_should_roundtrip :datetime, Time.now.in_time_zone
228
- # TODO: Support DateTime here, which is used when the value is
229
- # outside the range of a Time.
230
-
231
- it_should_roundtrip :timestamp, nil
232
- it_should_roundtrip :timestamp, Time.now.in_time_zone
233
-
234
- it_should_roundtrip :time, nil
235
- it_should_roundtrip :time, Time.parse('2000-01-01 01:23:34').in_time_zone
236
-
237
- it_should_roundtrip :date, nil
238
- it_should_roundtrip :date, Date.today
239
-
240
- it_should_roundtrip :binary, nil
241
- it_should_roundtrip :binary, ''
242
- it_should_roundtrip :binary, "\0\1"
243
-
244
- it_should_roundtrip :boolean, nil
245
- it_should_roundtrip :boolean, true
246
- it_should_roundtrip :boolean, false
385
+ it "should not be tripped up by delimiters in the values" do
386
+ klass = Class.new(ActiveNomad::Base) do
387
+ attribute :a, :string
388
+ end
389
+ original = klass.new(:a => "'a':b,c")
390
+ roundtripped = klass.from_json(original.to_json)
391
+ roundtripped.a.should == "'a':b,c"
392
+ end
247
393
  end
248
394
 
249
395
  describe ".transaction" do
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: active_nomad
3
3
  version: !ruby/object:Gem::Version
4
- hash: 23
4
+ hash: 27
5
5
  prerelease: false
6
6
  segments:
7
7
  - 0
8
+ - 1
8
9
  - 0
9
- - 4
10
- version: 0.0.4
10
+ version: 0.1.0
11
11
  platform: ruby
12
12
  authors:
13
13
  - George Ogata
@@ -15,7 +15,7 @@ autorequire:
15
15
  bindir: bin
16
16
  cert_chain: []
17
17
 
18
- date: 2010-10-01 00:00:00 -04:00
18
+ date: 2010-10-05 00:00:00 -04:00
19
19
  default_executable:
20
20
  dependencies:
21
21
  - !ruby/object:Gem::Dependency