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 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
@@ -0,0 +1,6 @@
1
+ source "http://rubygems.org"
2
+
3
+ gem "rake"
4
+ gem "activerecord", "~> 2.2.3"
5
+
6
+ gemspec :path=>"../"
@@ -0,0 +1,6 @@
1
+ source "http://rubygems.org"
2
+
3
+ gem "rake"
4
+ gem "activerecord", "~> 2.3.14"
5
+
6
+ gemspec :path=>"../"
@@ -0,0 +1,6 @@
1
+ source "http://rubygems.org"
2
+
3
+ gem "rake"
4
+ gem "activerecord", "~> 3.0.11"
5
+
6
+ gemspec :path=>"../"
@@ -0,0 +1,6 @@
1
+ source "http://rubygems.org"
2
+
3
+ gem "rake"
4
+ gem "activerecord", "~> 3.1.3"
5
+
6
+ gemspec :path=>"../"
data/init.rb ADDED
@@ -0,0 +1,2 @@
1
+ require File.expand_path('../rails_init', __FILE__)
2
+
@@ -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
+