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.
- data/LICENSE +20 -0
- data/README.md +56 -0
- data/Rakefile +132 -0
- data/gemfiles/ar-2.2.gemfile +6 -0
- data/gemfiles/ar-2.3.gemfile +6 -0
- data/gemfiles/ar-3.0.gemfile +6 -0
- data/gemfiles/ar-3.1.gemfile +6 -0
- data/init.rb +2 -0
- data/lib/serializable_attributes/duplicable.rb +65 -0
- data/lib/serializable_attributes/format/active_support_json.rb +29 -0
- data/lib/serializable_attributes/schema.rb +188 -0
- data/lib/serializable_attributes/types.rb +139 -0
- data/lib/serializable_attributes.rb +66 -0
- data/rails_init.rb +3 -0
- data/script/setup +1 -0
- data/serializable_attributes.gemspec +67 -0
- data/test/serialized_attributes_test.rb +382 -0
- data/test/test_helper.rb +107 -0
- data/test/types_test.rb +42 -0
- metadata +80 -0
data/LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright (c) 2009 rick olson
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOa AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SaALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,56 @@
|
|
1
|
+
# SerializedAttributes
|
2
|
+
|
3
|
+
SerializedAttributes allows you to add an encoded hash to an ActiveRecord model.
|
4
|
+
This is similar to the built-in ActiveRecord serialization, except that the field
|
5
|
+
is converted to JSON, gzipped, and stored in a BLOB field. This uses the json
|
6
|
+
gem which is much faster than YAML serialization. However, JSON is not nearly as
|
7
|
+
flexible, so you're stuck with strings/integers/dates/etc.
|
8
|
+
|
9
|
+
Where possible, ActiveRecord compatible methods are generated so that a migration
|
10
|
+
should be pretty simple. See unit tests for examples.
|
11
|
+
|
12
|
+
Some of the code and most of the ideas are taken from [Heresy][Heresy], a ruby
|
13
|
+
implementation of [how FriendFeed uses MySQL for schema-free storage][schemafree].
|
14
|
+
|
15
|
+
Supports ActiveRecord 2.2 in ruby 1.8.7, and ActiveRecord 2.3-3.1 in ruby 1.9.3.
|
16
|
+
See [Travis CI][travis] to see if we support your version of
|
17
|
+
ActiveRecord and ruby.
|
18
|
+
|
19
|
+
[Heresy]: https://github.com/kabuki/heresy
|
20
|
+
[schemafree]: http://bret.appspot.com/entry/how-friendfeed-uses-mysql
|
21
|
+
[travis]: http://travis-ci.org/#!/technoweenie/serialized_attributes
|
22
|
+
|
23
|
+
## Setup
|
24
|
+
|
25
|
+
Install the plugin into your Rails app.
|
26
|
+
|
27
|
+
## Usage
|
28
|
+
|
29
|
+
```ruby
|
30
|
+
class Profile < ActiveRecord::Base
|
31
|
+
# assumes #data serializes to raw_data blob field
|
32
|
+
serialize_attributes do
|
33
|
+
string :title, :description
|
34
|
+
integer :age
|
35
|
+
float :rank, :percentage
|
36
|
+
time :birthday
|
37
|
+
end
|
38
|
+
|
39
|
+
# Serializes #data to assumed raw_data blob field
|
40
|
+
serialize_attributes :data do
|
41
|
+
string :title, :description
|
42
|
+
integer :age
|
43
|
+
float :rank, :percentage
|
44
|
+
time :birthday
|
45
|
+
end
|
46
|
+
|
47
|
+
# set the blob field
|
48
|
+
serialize_attributes :data, :blob => :serialized_field do
|
49
|
+
string :title, :description
|
50
|
+
integer :age
|
51
|
+
float :rank, :percentage
|
52
|
+
time :birthday
|
53
|
+
end
|
54
|
+
end
|
55
|
+
```
|
56
|
+
|
data/Rakefile
ADDED
@@ -0,0 +1,132 @@
|
|
1
|
+
#!/usr/bin/env rake
|
2
|
+
|
3
|
+
require 'date'
|
4
|
+
|
5
|
+
#############################################################################
|
6
|
+
#
|
7
|
+
# Helper functions
|
8
|
+
#
|
9
|
+
#############################################################################
|
10
|
+
|
11
|
+
def name
|
12
|
+
@name ||= Dir['*.gemspec'].first.split('.').first
|
13
|
+
end
|
14
|
+
|
15
|
+
def version
|
16
|
+
line = File.read("lib/#{name}.rb")[/^\s*VERSION\s*=\s*.*/]
|
17
|
+
line.match(/.*VERSION\s*=\s*['"](.*)['"]/)[1]
|
18
|
+
end
|
19
|
+
|
20
|
+
def date
|
21
|
+
Date.today.to_s
|
22
|
+
end
|
23
|
+
|
24
|
+
def rubyforge_project
|
25
|
+
name
|
26
|
+
end
|
27
|
+
|
28
|
+
def gemspec_file
|
29
|
+
"#{name}.gemspec"
|
30
|
+
end
|
31
|
+
|
32
|
+
def gem_file
|
33
|
+
"#{name}-#{version}.gem"
|
34
|
+
end
|
35
|
+
|
36
|
+
def replace_header(head, header_name)
|
37
|
+
head.sub!(/(\.#{header_name}\s*= ').*'/) { "#{$1}#{send(header_name)}'"}
|
38
|
+
end
|
39
|
+
|
40
|
+
#############################################################################
|
41
|
+
#
|
42
|
+
# Standard tasks
|
43
|
+
#
|
44
|
+
#############################################################################
|
45
|
+
|
46
|
+
task :default => :test
|
47
|
+
|
48
|
+
require 'rake/testtask'
|
49
|
+
Rake::TestTask.new(:test) do |test|
|
50
|
+
test.libs << 'lib' << 'test'
|
51
|
+
test.pattern = 'test/**/*_test.rb'
|
52
|
+
test.verbose = true
|
53
|
+
end
|
54
|
+
|
55
|
+
desc "Open an irb session preloaded with this library"
|
56
|
+
task :console do
|
57
|
+
sh "irb -rubygems -r ./lib/#{name}.rb"
|
58
|
+
end
|
59
|
+
|
60
|
+
#############################################################################
|
61
|
+
#
|
62
|
+
# Custom tasks (add your own tasks here)
|
63
|
+
#
|
64
|
+
#############################################################################
|
65
|
+
|
66
|
+
#############################################################################
|
67
|
+
#
|
68
|
+
# Packaging tasks
|
69
|
+
#
|
70
|
+
#############################################################################
|
71
|
+
|
72
|
+
desc "Create tag v#{version} and build and push #{gem_file} to Rubygems"
|
73
|
+
task :release => :build do
|
74
|
+
unless `git branch` =~ /^\* master$/
|
75
|
+
puts "You must be on the master branch to release!"
|
76
|
+
exit!
|
77
|
+
end
|
78
|
+
sh "git commit --allow-empty -a -m 'Release #{version}'"
|
79
|
+
sh "git tag v#{version}"
|
80
|
+
sh "git push origin master"
|
81
|
+
sh "git push origin v#{version}"
|
82
|
+
sh "gem push pkg/#{gem_file}"
|
83
|
+
end
|
84
|
+
|
85
|
+
desc "Build #{gem_file} into the pkg directory"
|
86
|
+
task :build => :gemspec do
|
87
|
+
sh "mkdir -p pkg"
|
88
|
+
sh "gem build #{gemspec_file}"
|
89
|
+
sh "mv #{gem_file} pkg"
|
90
|
+
end
|
91
|
+
|
92
|
+
desc "Generate #{gemspec_file}"
|
93
|
+
task :gemspec => :validate do
|
94
|
+
# read spec file and split out manifest section
|
95
|
+
spec = File.read(gemspec_file)
|
96
|
+
head, manifest, tail = spec.split(" # = MANIFEST =\n")
|
97
|
+
|
98
|
+
# replace name version and date
|
99
|
+
replace_header(head, :name)
|
100
|
+
replace_header(head, :version)
|
101
|
+
replace_header(head, :date)
|
102
|
+
#comment this out if your rubyforge_project has a different name
|
103
|
+
replace_header(head, :rubyforge_project)
|
104
|
+
|
105
|
+
# determine file list from git ls-files
|
106
|
+
files = `git ls-files`.
|
107
|
+
split("\n").
|
108
|
+
sort.
|
109
|
+
reject { |file| file =~ /^\./ }.
|
110
|
+
reject { |file| file =~ /^(rdoc|pkg)/ }.
|
111
|
+
map { |file| " #{file}" }.
|
112
|
+
join("\n")
|
113
|
+
|
114
|
+
# piece file back together and write
|
115
|
+
manifest = " s.files = %w[\n#{files}\n ]\n"
|
116
|
+
spec = [head, manifest, tail].join(" # = MANIFEST =\n")
|
117
|
+
File.open(gemspec_file, 'w') { |io| io.write(spec) }
|
118
|
+
puts "Updated #{gemspec_file}"
|
119
|
+
end
|
120
|
+
|
121
|
+
desc "Validate #{gemspec_file}"
|
122
|
+
task :validate do
|
123
|
+
libfiles = Dir['lib/*'] - ["lib/#{name}.rb", "lib/#{name}"]
|
124
|
+
unless libfiles.empty?
|
125
|
+
puts "Directory `lib` should only contain a `#{name}.rb` file and `#{name}` dir."
|
126
|
+
exit!
|
127
|
+
end
|
128
|
+
unless Dir['VERSION*'].empty?
|
129
|
+
puts "A `VERSION` file at root level violates Gem best practices."
|
130
|
+
exit!
|
131
|
+
end
|
132
|
+
end
|
data/init.rb
ADDED
@@ -0,0 +1,65 @@
|
|
1
|
+
# Most objects are cloneable, but not all. For example you can't dup +nil+:
|
2
|
+
#
|
3
|
+
# nil.dup # => TypeError: can't dup NilClass
|
4
|
+
#
|
5
|
+
# Classes may signal their instances are not duplicable removing +dup+/+clone+
|
6
|
+
# or raising exceptions from them. So, to dup an arbitrary object you normally
|
7
|
+
# use an optimistic approach and are ready to catch an exception, say:
|
8
|
+
#
|
9
|
+
# arbitrary_object.dup rescue object
|
10
|
+
#
|
11
|
+
# Rails dups objects in a few critical spots where they are not that arbitrary.
|
12
|
+
# That rescue is very expensive (like 40 times slower than a predicate), and it
|
13
|
+
# is often triggered.
|
14
|
+
#
|
15
|
+
# That's why we hardcode the following cases and check duplicable? instead of
|
16
|
+
# using that rescue idiom.
|
17
|
+
class Object
|
18
|
+
# Can you safely .dup this object?
|
19
|
+
# False for nil, false, true, symbols, numbers, class and module objects; true otherwise.
|
20
|
+
def duplicable?
|
21
|
+
true
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
class NilClass #:nodoc:
|
26
|
+
def duplicable?
|
27
|
+
false
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
class FalseClass #:nodoc:
|
32
|
+
def duplicable?
|
33
|
+
false
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
class TrueClass #:nodoc:
|
38
|
+
def duplicable?
|
39
|
+
false
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
class Symbol #:nodoc:
|
44
|
+
def duplicable?
|
45
|
+
false
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
class Numeric #:nodoc:
|
50
|
+
def duplicable?
|
51
|
+
false
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
class Class #:nodoc:
|
56
|
+
def duplicable?
|
57
|
+
false
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
class Module #:nodoc:
|
62
|
+
def duplicable?
|
63
|
+
false
|
64
|
+
end
|
65
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
require 'zlib'
|
2
|
+
require 'stringio'
|
3
|
+
|
4
|
+
module SerializableAttributes
|
5
|
+
module Format
|
6
|
+
module ActiveSupportJson
|
7
|
+
extend self
|
8
|
+
|
9
|
+
def encode(body)
|
10
|
+
return nil if body.blank?
|
11
|
+
s = StringIO.new
|
12
|
+
z = Zlib::GzipWriter.new(s)
|
13
|
+
z.write ActiveSupport::JSON.encode(body)
|
14
|
+
z.close
|
15
|
+
s.string
|
16
|
+
end
|
17
|
+
|
18
|
+
def decode(body)
|
19
|
+
return {} if body.to_s.empty?
|
20
|
+
s = StringIO.new(body)
|
21
|
+
z = Zlib::GzipReader.new(s)
|
22
|
+
hash = ActiveSupport::JSON.decode(z.read)
|
23
|
+
z.close
|
24
|
+
hash
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
@@ -0,0 +1,188 @@
|
|
1
|
+
module SerializableAttributes
|
2
|
+
class Schema
|
3
|
+
class << self
|
4
|
+
attr_writer :default_formatter
|
5
|
+
def default_formatter
|
6
|
+
@default_formatter ||= SerializableAttributes::Format::ActiveSupportJson
|
7
|
+
end
|
8
|
+
end
|
9
|
+
|
10
|
+
attr_accessor :formatter
|
11
|
+
attr_reader :model, :field, :fields
|
12
|
+
|
13
|
+
def all_column_names
|
14
|
+
fields ? fields.keys : []
|
15
|
+
end
|
16
|
+
|
17
|
+
def encode(body)
|
18
|
+
body = body.dup
|
19
|
+
body.each do |key, value|
|
20
|
+
if field = fields[key]
|
21
|
+
body[key] = field.encode(value)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
formatter.encode(body)
|
25
|
+
end
|
26
|
+
|
27
|
+
def decode(data, is_new_record = false)
|
28
|
+
decoded = formatter.decode(data)
|
29
|
+
hash = ::Hash.new do |h, key|
|
30
|
+
if type = fields[key]
|
31
|
+
h[key] = type ? type.default : nil
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
decoded.each do |k, v|
|
36
|
+
next unless include?(k)
|
37
|
+
type = fields[k]
|
38
|
+
hash[k] = type ? type.parse(v) : v
|
39
|
+
end
|
40
|
+
|
41
|
+
if decoded.blank? && is_new_record
|
42
|
+
fields.each do |key, type|
|
43
|
+
hash[key] = type.default if type.default
|
44
|
+
end
|
45
|
+
end
|
46
|
+
hash
|
47
|
+
end
|
48
|
+
|
49
|
+
def include?(key)
|
50
|
+
@fields.include?(key.to_s)
|
51
|
+
end
|
52
|
+
|
53
|
+
# Initializes a new Schema. See `ModelMethods#serialize_attributes`.
|
54
|
+
#
|
55
|
+
# model - The ActiveRecord class.
|
56
|
+
# field - The String name of the ActiveRecord attribute that holds
|
57
|
+
# data.
|
58
|
+
# options - Optional Hash:
|
59
|
+
# :blob - The String name of the actual DB field. Defaults to
|
60
|
+
# "raw_#{field}"
|
61
|
+
# :formatter - The module that handles encoding and decoding the
|
62
|
+
# data. The default is set in
|
63
|
+
# `Schema#default_formatter`.
|
64
|
+
def initialize(model, field, options)
|
65
|
+
@model, @field, @fields = model, field, {}
|
66
|
+
@blob_field = options.delete(:blob) || "raw_#{@field}"
|
67
|
+
@formatter = options.delete(:formatter) || self.class.default_formatter
|
68
|
+
blob_field = @blob_field
|
69
|
+
data_field = @field
|
70
|
+
|
71
|
+
meta_model = class << @model; self; end
|
72
|
+
changed_ivar = "#{data_field}_changed"
|
73
|
+
meta_model.send(:attr_accessor, "#{data_field}_schema")
|
74
|
+
@model.send("#{data_field}_schema=", self)
|
75
|
+
|
76
|
+
@model.class_eval do
|
77
|
+
def reload(options = nil)
|
78
|
+
reset_serialized_data
|
79
|
+
super
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
meta_model.send(:define_method, :attribute_names) do
|
84
|
+
column_names + send("#{data_field}_schema").all_column_names
|
85
|
+
end
|
86
|
+
|
87
|
+
@model.send(:define_method, :reset_serialized_data) do
|
88
|
+
instance_variable_set("@#{data_field}", nil)
|
89
|
+
end
|
90
|
+
|
91
|
+
@model.send(:define_method, :attribute_names) do
|
92
|
+
(super() + send(data_field).keys - [blob_field]).
|
93
|
+
map! { |s| s.to_s }.sort!
|
94
|
+
end
|
95
|
+
|
96
|
+
@model.send(:define_method, data_field) do
|
97
|
+
instance_variable_get("@#{data_field}") || begin
|
98
|
+
instance_variable_get("@#{changed_ivar}").clear if send("#{changed_ivar}?")
|
99
|
+
schema = self.class.send("#{data_field}_schema")
|
100
|
+
hash = schema.decode(send(blob_field), new_record?)
|
101
|
+
instance_variable_set("@#{data_field}", hash)
|
102
|
+
hash
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
@model.send(:define_method, :write_serialized_field) do |name, value|
|
107
|
+
raw_data = send(data_field) # load fields if needed
|
108
|
+
name_str = name.to_s
|
109
|
+
schema = self.class.send("#{data_field}_schema")
|
110
|
+
type = schema.fields[name_str]
|
111
|
+
changed_fields = send(changed_ivar)
|
112
|
+
instance_variable_get("@#{changed_ivar}")[name_str] = raw_data[name_str] unless changed_fields.include?(name_str)
|
113
|
+
parsed_value = type ? type.parse(value) : value
|
114
|
+
if parsed_value.nil?
|
115
|
+
raw_data.delete(name_str)
|
116
|
+
else
|
117
|
+
raw_data[name_str] = parsed_value
|
118
|
+
end
|
119
|
+
parsed_value
|
120
|
+
end
|
121
|
+
|
122
|
+
@model.send(:define_method, changed_ivar) do
|
123
|
+
hash = instance_variable_get("@#{changed_ivar}") || instance_variable_set("@#{changed_ivar}", {})
|
124
|
+
hash.keys
|
125
|
+
end
|
126
|
+
|
127
|
+
@model.send(:define_method, "#{changed_ivar}?") do
|
128
|
+
!send(changed_ivar).empty?
|
129
|
+
end
|
130
|
+
|
131
|
+
@model.before_save do |r|
|
132
|
+
schema = r.class.send("#{data_field}_schema")
|
133
|
+
r.send("#{blob_field}=", schema.encode(r.send(data_field)))
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
137
|
+
# Adds the accessors for a serialized field on this model. Also sets up
|
138
|
+
# the encoders and decoders.
|
139
|
+
#
|
140
|
+
# type_name - The Symbol matching a valid type.
|
141
|
+
# *names - One or more Symbol field names.
|
142
|
+
# options - Optional Hash to be sent to the initialized Type.
|
143
|
+
# :default - Sets the default value.
|
144
|
+
#
|
145
|
+
# Returns nothing.
|
146
|
+
def field(type_name, *names)
|
147
|
+
options = names.extract_options!
|
148
|
+
data_field = @field
|
149
|
+
changed_ivar = "#{data_field}_changed"
|
150
|
+
type = SerializableAttributes.types[type_name].new(options)
|
151
|
+
names.each do |name|
|
152
|
+
name_str = name.to_s
|
153
|
+
@fields[name_str] = type
|
154
|
+
|
155
|
+
@model.send(:define_method, name) do
|
156
|
+
send(data_field)[name_str]
|
157
|
+
end
|
158
|
+
|
159
|
+
if type.is_a? Boolean
|
160
|
+
@model.send :alias_method, "#{name}?", name
|
161
|
+
end
|
162
|
+
|
163
|
+
@model.send(:define_method, "#{name}=") do |value|
|
164
|
+
write_serialized_field name_str, value
|
165
|
+
end
|
166
|
+
|
167
|
+
@model.send(:define_method, "#{name}_changed?") do
|
168
|
+
send(changed_ivar).include?(name_str)
|
169
|
+
end
|
170
|
+
|
171
|
+
@model.send(:define_method, "#{name}_before_type_cast") do
|
172
|
+
value = send(name)
|
173
|
+
value = type.encode(value) if type
|
174
|
+
value.to_s
|
175
|
+
end
|
176
|
+
|
177
|
+
@model.send(:define_method, "#{name}_change") do
|
178
|
+
if send("#{name}_changed?")
|
179
|
+
[instance_variable_get("@#{changed_ivar}")[name_str], send(data_field)[name_str]]
|
180
|
+
else
|
181
|
+
nil
|
182
|
+
end
|
183
|
+
end
|
184
|
+
end
|
185
|
+
end
|
186
|
+
end
|
187
|
+
end
|
188
|
+
|