active_nomad 0.0.4 → 0.1.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.
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