serializable_attributes 0.9.0

Sign up to get free protection for your applications and to get access to all the features.
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
+