json_record 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- data/MIT_LICENSE +20 -0
- data/README.rdoc +88 -0
- data/Rakefile +44 -0
- data/VERSION +1 -0
- data/init.rb +1 -0
- data/json_record.gemspec +72 -0
- data/lib/json_record/attribute_methods.rb +55 -0
- data/lib/json_record/embedded_document.rb +155 -0
- data/lib/json_record/embedded_document_array.rb +54 -0
- data/lib/json_record/field_definition.rb +87 -0
- data/lib/json_record/json_field.rb +59 -0
- data/lib/json_record/schema.rb +128 -0
- data/lib/json_record/serialized.rb +106 -0
- data/lib/json_record.rb +20 -0
- data/spec/embedded_document_array_spec.rb +64 -0
- data/spec/embedded_document_spec.rb +65 -0
- data/spec/field_definition_spec.rb +29 -0
- data/spec/serialized_spec.rb +430 -0
- data/spec/spec_helper.rb +13 -0
- data/spec/test_models.rb +70 -0
- metadata +111 -0
data/MIT_LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright (c) 2010 Brian Durand
|
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 PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL 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.rdoc
ADDED
@@ -0,0 +1,88 @@
|
|
1
|
+
= JSON Record
|
2
|
+
|
3
|
+
The purpose of this code is to add the ability to represent complex documents in ActiveRecord by using JSON to serialize the documents to a database field. This can be especially useful if you need a flexible schema, but don't want or have the means to utilize one of the new schemaless data stores that all the cool kids are talking about.
|
4
|
+
|
5
|
+
After all, relational databases are pretty rock solid and widely available technology. If you can't get the cool new thing installed, or if you just feel safe sticking with what you know, this gem may work for you. As an added advantage, it is just an extension on top of ActiveRecord, so you can still use all the features of ActiveRecord and add the schemaless functionality only to models where it makes sense.
|
6
|
+
|
7
|
+
== Serialized Fields
|
8
|
+
|
9
|
+
To define a complex document field, simply add this code to your ActiveRecord model definition:
|
10
|
+
|
11
|
+
serialize_to_json(:json_data) do |schema|
|
12
|
+
schema.key :name
|
13
|
+
schema.key :value, Integer
|
14
|
+
end
|
15
|
+
|
16
|
+
This will define for you accessors on your model for name and value and serialize those value in a database columns named json_data. These attributes will work just like other ActiveRecord attributes, so you will be able to track changes to them, include them in mass assignments, etc. Of course, that's not all that interesting since you could easily enough have added columns for name and value.
|
17
|
+
|
18
|
+
== Embedded Documents
|
19
|
+
|
20
|
+
To make you flexible schema really powerful, add some embedded documents to it. Embedded documents are Ruby classes that inherit from JsonRecord::EmbeddedDocument. They work very much like traditional ActiveRecord objects, except that instead of being serialized in a separate table, they are embedded right in the JSON field of their parent record. They can be used to replace has_many and has_one associations and can be far easier to work with.
|
21
|
+
|
22
|
+
Embedded documents have their own schema that is serialized to JSON. This schema can also contain embedded documents allowing you to easily create very rich data structures all with only one database table. And because there is only one table, you don't need to worry at all about ensuring your changes to embedded documents are saved along with the parent record.
|
23
|
+
|
24
|
+
== Example
|
25
|
+
|
26
|
+
class Post < ActiveRecord::Base
|
27
|
+
serialize_to_json(:json_data) do |schema|
|
28
|
+
schema.key :title, :required => true
|
29
|
+
schema.key :body, :required => true
|
30
|
+
schema.key :author, Person, :required => true
|
31
|
+
schema.many :comments, Comment
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
class Person < JsonRecord::EmbeddedDocument
|
36
|
+
schema.key :first_name, :required => true
|
37
|
+
schema.key :last_name
|
38
|
+
end
|
39
|
+
|
40
|
+
class Comment < JsonRecord::EmbeddedDocument
|
41
|
+
schema.key :author, Person, :required => true
|
42
|
+
schema.key :body, :required => true
|
43
|
+
schema.many :replies, Comment
|
44
|
+
end
|
45
|
+
|
46
|
+
Create a new post with a title and author:
|
47
|
+
|
48
|
+
post = Post.create!(:title => "What I think",
|
49
|
+
:body => "Stuff is good",
|
50
|
+
:author => {:first_name => "John", :last_name => "Doe"})
|
51
|
+
|
52
|
+
Change the authors first name:
|
53
|
+
|
54
|
+
post.author.first_name = "Bill"
|
55
|
+
|
56
|
+
Add a couple of comments:
|
57
|
+
|
58
|
+
post.comments.build(:author => {:first_name => "Tony"}, :body => "I like it")
|
59
|
+
post.comments.build(:author => {:first_name => "Jack"}, :body => "I don't like it")
|
60
|
+
|
61
|
+
Add a reply:
|
62
|
+
|
63
|
+
post.comments.first.replies.build(:author => {:first_name => "Ralph"}, :body => "You're and idiot")
|
64
|
+
|
65
|
+
And save it all:
|
66
|
+
|
67
|
+
post.save
|
68
|
+
|
69
|
+
Unlike with traditional association, you don't need any after_save callbacks to ensure that the associations are saved. If we want to remove the last comment, all we need to do is:
|
70
|
+
|
71
|
+
post.comments.pop
|
72
|
+
post.save
|
73
|
+
|
74
|
+
== Limitations
|
75
|
+
|
76
|
+
One thing you cannot do is index the fields in the serialized JSON. If you need to be able to search on those fields, you'll need a separate search engine (i.e. Solr or Sphinx). Or, you could just move it out of the JSON fields and make it a regular database column with an index on it. The interface will be exactly the same.
|
77
|
+
|
78
|
+
In order to conserve space and increase performance, blank values are not serialized to JSON. One side effect is that you cannot have fields with the empty string as the value. Also, if you look at the JSON stored in the database, you won't be able to deduce the schema since blank fields will be missing entirely. If you have any fields that are used to store Arrays or Hashes will never be nil and will always be initialized with an empty Array or Hash.
|
79
|
+
|
80
|
+
== Details, details, details
|
81
|
+
|
82
|
+
For optimal performance when working with JSON, you should really have the +json+ gem installed. This gem does not have a direct dependency on +json+ since it will work just fine without it, but if it is missing, you'll get a warning about it in the ActiveRecord log.
|
83
|
+
|
84
|
+
For performance, attributes from a JSON serialized field are only loaded when they are accessed. When a record is saved, the JSON attributes are translated back to JSON and stored in the serialized field. If a field is encountered in the JSON that has not been declared in the schema, it will persist, but will not be accessible via an accessor.
|
85
|
+
|
86
|
+
The fields you are using to store JSON must be large enough to handle any document. You should provide a :length attribute in your migration to ensure that text fields are long enough. By default, MySQL, for instance, will create a TEXT field limited to 32K. What you really want is a MEDIUMTEXT or LONGTEXT field.
|
87
|
+
|
88
|
+
Since JSON can be kind of wordy and take up a lot more space than a traditional column based approach, you can also specify that the JSON should be compressed when it is stored in the database. To do this, simply create the JSON column as a binary column type instead of a text column. This is the recommended set up unless you need to browse through your database outside of your Ruby application.
|
data/Rakefile
ADDED
@@ -0,0 +1,44 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'rake'
|
3
|
+
require 'rake/rdoctask'
|
4
|
+
|
5
|
+
desc 'Default: run unit tests.'
|
6
|
+
task :default => :test
|
7
|
+
|
8
|
+
begin
|
9
|
+
require 'spec/rake/spectask'
|
10
|
+
desc 'Test json_record.'
|
11
|
+
Spec::Rake::SpecTask.new(:test) do |t|
|
12
|
+
t.spec_files = FileList.new('spec/**/*_spec.rb')
|
13
|
+
end
|
14
|
+
rescue LoadError
|
15
|
+
tast :test do
|
16
|
+
STDERR.puts "You must have rspec >= 1.2.9 to run the tests"
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
desc 'Generate documentation for json_record.'
|
21
|
+
Rake::RDocTask.new(:rdoc) do |rdoc|
|
22
|
+
rdoc.rdoc_dir = 'rdoc'
|
23
|
+
rdoc.options << '--title' << 'JSON Record' << '--line-numbers' << '--inline-source' << '--main' << 'README.rdoc'
|
24
|
+
rdoc.rdoc_files.include('README.rdoc')
|
25
|
+
rdoc.rdoc_files.include('lib/**/*.rb')
|
26
|
+
end
|
27
|
+
|
28
|
+
begin
|
29
|
+
require 'jeweler'
|
30
|
+
Jeweler::Tasks.new do |gem|
|
31
|
+
gem.name = "json_record"
|
32
|
+
gem.summary = %Q{ActiveRecord support for mapping complex documents within a single RDBMS record via JSON serialization.}
|
33
|
+
gem.email = "brian@embellishedvisions.com"
|
34
|
+
gem.homepage = "http://github.com/bdurand/json_record"
|
35
|
+
gem.authors = ["Brian Durand"]
|
36
|
+
|
37
|
+
gem.add_dependency('activerecord', '>= 2.2.2', '< 3.0')
|
38
|
+
gem.add_development_dependency('rspec', '>= 1.2.9')
|
39
|
+
gem.add_development_dependency('jeweler')
|
40
|
+
end
|
41
|
+
|
42
|
+
Jeweler::GemcutterTasks.new
|
43
|
+
rescue LoadError
|
44
|
+
end
|
data/VERSION
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
1.0.0
|
data/init.rb
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
require 'json_record'
|
data/json_record.gemspec
ADDED
@@ -0,0 +1,72 @@
|
|
1
|
+
# Generated by jeweler
|
2
|
+
# DO NOT EDIT THIS FILE DIRECTLY
|
3
|
+
# Instead, edit Jeweler::Tasks in Rakefile, and run the gemspec command
|
4
|
+
# -*- encoding: utf-8 -*-
|
5
|
+
|
6
|
+
Gem::Specification.new do |s|
|
7
|
+
s.name = %q{json_record}
|
8
|
+
s.version = "1.0.0"
|
9
|
+
|
10
|
+
s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
|
11
|
+
s.authors = ["Brian Durand"]
|
12
|
+
s.date = %q{2010-02-07}
|
13
|
+
s.email = %q{brian@embellishedvisions.com}
|
14
|
+
s.extra_rdoc_files = [
|
15
|
+
"README.rdoc"
|
16
|
+
]
|
17
|
+
s.files = [
|
18
|
+
"MIT_LICENSE",
|
19
|
+
"README.rdoc",
|
20
|
+
"Rakefile",
|
21
|
+
"VERSION",
|
22
|
+
"init.rb",
|
23
|
+
"json_record.gemspec",
|
24
|
+
"lib/json_record.rb",
|
25
|
+
"lib/json_record/attribute_methods.rb",
|
26
|
+
"lib/json_record/embedded_document.rb",
|
27
|
+
"lib/json_record/embedded_document_array.rb",
|
28
|
+
"lib/json_record/field_definition.rb",
|
29
|
+
"lib/json_record/json_field.rb",
|
30
|
+
"lib/json_record/schema.rb",
|
31
|
+
"lib/json_record/serialized.rb",
|
32
|
+
"spec/embedded_document_array_spec.rb",
|
33
|
+
"spec/embedded_document_spec.rb",
|
34
|
+
"spec/field_definition_spec.rb",
|
35
|
+
"spec/serialized_spec.rb",
|
36
|
+
"spec/spec_helper.rb",
|
37
|
+
"spec/test_models.rb"
|
38
|
+
]
|
39
|
+
s.homepage = %q{http://github.com/bdurand/json_record}
|
40
|
+
s.rdoc_options = ["--charset=UTF-8"]
|
41
|
+
s.require_paths = ["lib"]
|
42
|
+
s.rubygems_version = %q{1.3.5}
|
43
|
+
s.summary = %q{ActiveRecord support for mapping complex documents within a single RDBMS record via JSON serialization.}
|
44
|
+
s.test_files = [
|
45
|
+
"spec/embedded_document_array_spec.rb",
|
46
|
+
"spec/embedded_document_spec.rb",
|
47
|
+
"spec/field_definition_spec.rb",
|
48
|
+
"spec/serialized_spec.rb",
|
49
|
+
"spec/spec_helper.rb",
|
50
|
+
"spec/test_models.rb"
|
51
|
+
]
|
52
|
+
|
53
|
+
if s.respond_to? :specification_version then
|
54
|
+
current_version = Gem::Specification::CURRENT_SPECIFICATION_VERSION
|
55
|
+
s.specification_version = 3
|
56
|
+
|
57
|
+
if Gem::Version.new(Gem::RubyGemsVersion) >= Gem::Version.new('1.2.0') then
|
58
|
+
s.add_runtime_dependency(%q<activerecord>, [">= 2.2.2", "< 3.0"])
|
59
|
+
s.add_development_dependency(%q<rspec>, [">= 1.2.9"])
|
60
|
+
s.add_development_dependency(%q<jeweler>, [">= 0"])
|
61
|
+
else
|
62
|
+
s.add_dependency(%q<activerecord>, [">= 2.2.2", "< 3.0"])
|
63
|
+
s.add_dependency(%q<rspec>, [">= 1.2.9"])
|
64
|
+
s.add_dependency(%q<jeweler>, [">= 0"])
|
65
|
+
end
|
66
|
+
else
|
67
|
+
s.add_dependency(%q<activerecord>, [">= 2.2.2", "< 3.0"])
|
68
|
+
s.add_dependency(%q<rspec>, [">= 1.2.9"])
|
69
|
+
s.add_dependency(%q<jeweler>, [">= 0"])
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
@@ -0,0 +1,55 @@
|
|
1
|
+
module JsonRecord
|
2
|
+
# Internal methods for reading and writing fields serialized from JSON.
|
3
|
+
module AttributeMethods
|
4
|
+
# Read a field. The field param must be a FieldDefinition and the context should be the record
|
5
|
+
# which is being read from.
|
6
|
+
def read_attribute (field, context)
|
7
|
+
if field.multivalued?
|
8
|
+
arr = json_attributes[field.name]
|
9
|
+
unless arr
|
10
|
+
arr = EmbeddedDocumentArray.new(field.type, context)
|
11
|
+
json_attributes[field.name] = arr
|
12
|
+
end
|
13
|
+
return arr
|
14
|
+
else
|
15
|
+
val = json_attributes[field.name]
|
16
|
+
if val.nil? and !field.default.nil?
|
17
|
+
val = field.default.dup rescue field.default
|
18
|
+
json_attributes[field.name] = val
|
19
|
+
end
|
20
|
+
return val
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
# Write a field. The field param must be a FieldDefinition and the context should be the record
|
25
|
+
# which is being read from. Track_changes indicates if changes to the object should be tracked.
|
26
|
+
def write_attribute (field, val, track_changes, context)
|
27
|
+
if field.multivalued?
|
28
|
+
val = val.values if val.is_a?(Hash)
|
29
|
+
json_attributes[field.name] = EmbeddedDocumentArray.new(field.type, context, val)
|
30
|
+
else
|
31
|
+
old_value = read_attribute(field, context)
|
32
|
+
converted_value = field.convert(val)
|
33
|
+
converted_value.parent = context if converted_value.is_a?(EmbeddedDocument)
|
34
|
+
unless old_value == converted_value
|
35
|
+
if track_changes
|
36
|
+
changes = changed_attributes
|
37
|
+
if changes.include?(field.name)
|
38
|
+
changes.delete(field.name) if converted_value == changes[field.name]
|
39
|
+
else
|
40
|
+
old_value = (old_value.clone rescue old_value) unless old_value.nil?
|
41
|
+
changes[field.name] = old_value
|
42
|
+
end
|
43
|
+
end
|
44
|
+
unless converted_value.nil?
|
45
|
+
json_attributes[field.name] = converted_value
|
46
|
+
else
|
47
|
+
json_attributes.delete(field.name)
|
48
|
+
end
|
49
|
+
end
|
50
|
+
context.attributes_before_type_cast[field.name] = val
|
51
|
+
end
|
52
|
+
return val
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
@@ -0,0 +1,155 @@
|
|
1
|
+
module JsonRecord
|
2
|
+
# OK, this is ugly, but necessary to get ActiveRecord::Errors to be compatible with
|
3
|
+
# EmbeddedDocument. This will all be fixed with Rails 3 and ActiveModel. Until then
|
4
|
+
# we'll just live with this.
|
5
|
+
module ActiveRecordStub #:nodoc:
|
6
|
+
def self.included (base)
|
7
|
+
base.extend(ClassMethods)
|
8
|
+
end
|
9
|
+
|
10
|
+
module ClassMethods
|
11
|
+
def human_name (options = {})
|
12
|
+
name.split('::').last.humanize
|
13
|
+
end
|
14
|
+
|
15
|
+
def human_attribute_name (attribute, options = {})
|
16
|
+
attribute.to_s.humanize
|
17
|
+
end
|
18
|
+
|
19
|
+
def self_and_descendants_from_active_record
|
20
|
+
[self]
|
21
|
+
end
|
22
|
+
|
23
|
+
def self_and_descendents_from_active_record
|
24
|
+
[self]
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def deprecated_callback_method (*args)
|
29
|
+
end
|
30
|
+
|
31
|
+
private
|
32
|
+
def save (*args); end;
|
33
|
+
def save! (*args); end;
|
34
|
+
def destroy (*args); end;
|
35
|
+
def create (*args); end;
|
36
|
+
def update (*args); end;
|
37
|
+
def new_record?; false; end;
|
38
|
+
end
|
39
|
+
|
40
|
+
# Subclasses of EmbeddedDocument can be used as the type for keys or many field definitions
|
41
|
+
# in Schema. Embedded documents are then extensions of the schema. In this way, complex
|
42
|
+
# documents represented in JSON can be deserialized as complex objects.
|
43
|
+
class EmbeddedDocument
|
44
|
+
include ActiveRecordStub
|
45
|
+
include ActiveRecord::Validations
|
46
|
+
include AttributeMethods
|
47
|
+
|
48
|
+
class_inheritable_reader :schema
|
49
|
+
|
50
|
+
class << self
|
51
|
+
# Define a field for the schema. This is a shortcut for calling schema.key.
|
52
|
+
# See Schema#key for details.
|
53
|
+
def key (name, *args)
|
54
|
+
write_inheritable_attribute(:schema, Schema.new(self, nil)) unless schema
|
55
|
+
schema.key(name, *args)
|
56
|
+
end
|
57
|
+
|
58
|
+
# Define a multivalued field for the schema. This is a shortcut for calling schema.many.
|
59
|
+
# See Schema#many for details.
|
60
|
+
def many (name, *args)
|
61
|
+
write_inheritable_attribute(:schema, Schema.new(self, nil)) unless schema
|
62
|
+
schema.many(name, *args)
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
# The parent object of the document.
|
67
|
+
attr_accessor :parent
|
68
|
+
|
69
|
+
# Create an embedded document with the specified attributes.
|
70
|
+
def initialize (attrs = {})
|
71
|
+
@attributes = {}
|
72
|
+
@json_attributes = {}
|
73
|
+
attrs.each_pair do |name, value|
|
74
|
+
field = schema.fields[name.to_s] || FieldDefinition.new(name, :type => value.class)
|
75
|
+
write_attribute(field, value, false, self)
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
# Get the attributes of the document.
|
80
|
+
def attributes
|
81
|
+
@json_attributes.reject{|k,v| !schema.fields.include?(k)}
|
82
|
+
end
|
83
|
+
|
84
|
+
# Get the attribute values of the document before they were type cast.
|
85
|
+
def attributes_before_type_cast
|
86
|
+
@attributes
|
87
|
+
end
|
88
|
+
|
89
|
+
# Determine if the document has been changed.
|
90
|
+
def changed?
|
91
|
+
!changed_attributes.empty?
|
92
|
+
end
|
93
|
+
|
94
|
+
# Get the list of attributes changed.
|
95
|
+
def changed
|
96
|
+
changed_attributes.keys
|
97
|
+
end
|
98
|
+
|
99
|
+
# Get a list of changes to the document.
|
100
|
+
def changes
|
101
|
+
changed.inject({}) {|h, attr| h[attr] = attribute_change(attr); h}
|
102
|
+
end
|
103
|
+
|
104
|
+
def to_json (*args)
|
105
|
+
@json_attributes.to_json(*args)
|
106
|
+
end
|
107
|
+
|
108
|
+
def eql? (val)
|
109
|
+
val.class == self.class && val.attributes == attributes && val.parent == parent
|
110
|
+
end
|
111
|
+
|
112
|
+
def == (val)
|
113
|
+
eql?(val)
|
114
|
+
end
|
115
|
+
|
116
|
+
def hash
|
117
|
+
attributes.hash + parent.hash
|
118
|
+
end
|
119
|
+
|
120
|
+
protected
|
121
|
+
|
122
|
+
def json_attributes
|
123
|
+
@json_attributes
|
124
|
+
end
|
125
|
+
|
126
|
+
def read_json_attribute (json_field_name, field)
|
127
|
+
read_attribute(field, self)
|
128
|
+
end
|
129
|
+
|
130
|
+
def write_json_attribute (json_field_name, field, value, track_changes)
|
131
|
+
write_attribute(field, value, track_changes, self)
|
132
|
+
end
|
133
|
+
|
134
|
+
def changed_attributes
|
135
|
+
@changed_attributes ||= {}
|
136
|
+
end
|
137
|
+
|
138
|
+
def read_attribute_before_type_cast (name)
|
139
|
+
@attributes[name.to_s]
|
140
|
+
end
|
141
|
+
|
142
|
+
def attribute_changed? (name)
|
143
|
+
changed_attributes.include?(name.to_s)
|
144
|
+
end
|
145
|
+
|
146
|
+
def attribute_change (name)
|
147
|
+
name = name.to_s
|
148
|
+
[changed_attributes[name], read_json_attribute(nil, schema.fields[name])] if attribute_changed?(name)
|
149
|
+
end
|
150
|
+
|
151
|
+
def attribute_was (name)
|
152
|
+
changed_attributes[name.to_s]
|
153
|
+
end
|
154
|
+
end
|
155
|
+
end
|
@@ -0,0 +1,54 @@
|
|
1
|
+
module JsonRecord
|
2
|
+
# This is an array of EmbeddedDocument objects. All elments of the array must be of the same class
|
3
|
+
# and all belong to the same parent. If an array of hashes are passed in, they will all be converted
|
4
|
+
# to EmbeddedDocument objects of the class specified.
|
5
|
+
class EmbeddedDocumentArray < Array
|
6
|
+
def initialize (klass, parent, objects = [])
|
7
|
+
@klass = klass
|
8
|
+
@parent = parent
|
9
|
+
objects = [] unless objects
|
10
|
+
objects = [objects] unless objects.is_a?(Array)
|
11
|
+
objects = objects.collect do |obj|
|
12
|
+
obj = @klass.new(obj) if obj.is_a?(Hash)
|
13
|
+
if obj.is_a?(@klass)
|
14
|
+
obj.parent = parent
|
15
|
+
obj
|
16
|
+
else
|
17
|
+
raise ArgumentError.new("#{obj.inspect} is not a #{@klass}") unless obj.is_a?(@klass)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
super(objects)
|
21
|
+
end
|
22
|
+
|
23
|
+
# Append an object to the array. The object must either be an EmbeddedDocument of the
|
24
|
+
# correct class, or a Hash.
|
25
|
+
def << (obj)
|
26
|
+
obj = @klass.new(obj) if obj.is_a?(Hash)
|
27
|
+
raise ArgumentError.new("#{obj.inspect} is not a #{@klass}") unless obj.is_a?(@klass)
|
28
|
+
obj.parent = @parent
|
29
|
+
super(obj)
|
30
|
+
end
|
31
|
+
|
32
|
+
# Concatenate an array of objects to the array. The objects must either be an EmbeddedDocument of the
|
33
|
+
# correct class, or a Hash.
|
34
|
+
def concat (objects)
|
35
|
+
objects = objects.collect do |obj|
|
36
|
+
obj = @klass.new(obj) if obj.is_a?(Hash)
|
37
|
+
raise ArgumentError.new("#{obj.inspect} is not a #{@klass}") unless obj.is_a?(@klass)
|
38
|
+
obj.parent = @parent
|
39
|
+
obj
|
40
|
+
end
|
41
|
+
super(objects)
|
42
|
+
end
|
43
|
+
|
44
|
+
# Similar add an EmbeddedDocument to the array and return the object. If the object passed
|
45
|
+
# in is a Hash, it will be used to make a new EmbeddedDocument.
|
46
|
+
def build (obj)
|
47
|
+
obj = @klass.new(obj) if obj.is_a?(Hash)
|
48
|
+
raise ArgumentError.new("#{obj.inspect} is not a #{@klass}") unless obj.is_a?(@klass)
|
49
|
+
obj.parent = @parent
|
50
|
+
self << obj
|
51
|
+
obj
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
@@ -0,0 +1,87 @@
|
|
1
|
+
module JsonRecord
|
2
|
+
# A definition of a JSON field in a Schema.
|
3
|
+
class FieldDefinition
|
4
|
+
BOOLEAN_MAPPING = {
|
5
|
+
true => true, 'true' => true, 'TRUE' => true, 'True' => true, 't' => true, 'T' => true, '1' => true, 1 => true, 1.0 => true,
|
6
|
+
false => false, 'false' => false, 'FALSE' => false, 'False' => false, 'f' => false, 'F' => false, '0' => false, 0 => false, 0.0 => false, nil => false
|
7
|
+
}
|
8
|
+
|
9
|
+
attr_reader :name, :type
|
10
|
+
|
11
|
+
# Define a field. Options should include :type with the class of the field. Other options available are
|
12
|
+
# :multivalued and :default.
|
13
|
+
def initialize (name, options = {})
|
14
|
+
@name = name.to_s
|
15
|
+
@type = options[:type] || String
|
16
|
+
@multivalued = !!options[:multivalued]
|
17
|
+
@default = options[:default]
|
18
|
+
if [Hash, Array].include?(@type) and @default.nil?
|
19
|
+
@default = @type.new
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
# Get the default value.
|
24
|
+
def default
|
25
|
+
(@default.dup rescue @default )if @default
|
26
|
+
end
|
27
|
+
|
28
|
+
# Indicates the field is multivalued.
|
29
|
+
def multivalued?
|
30
|
+
@multivalued
|
31
|
+
end
|
32
|
+
|
33
|
+
# Convert a value to the proper class for storing it in the field. If the value can't be converted,
|
34
|
+
# the original value will be returned. Blank values are always translated to nil. Hashes will be converted
|
35
|
+
# to EmbeddedDocument objects if the field type extends from EmbeddedDocument.
|
36
|
+
def convert (val)
|
37
|
+
return nil if val.blank?
|
38
|
+
if @type == String
|
39
|
+
return val.to_s
|
40
|
+
elsif @type == Integer
|
41
|
+
return Kernel.Integer(val) rescue val
|
42
|
+
elsif @type == Float
|
43
|
+
return Kernel.Float(val) rescue val
|
44
|
+
elsif @type == Boolean
|
45
|
+
v = BOOLEAN_MAPPING[val]
|
46
|
+
v = val.to_s.downcase == 'true' if v.nil? # Check all mixed case spellings for true
|
47
|
+
return v
|
48
|
+
elsif @type == Date
|
49
|
+
if val.is_a?(Date)
|
50
|
+
return val
|
51
|
+
elsif val.is_a?(Time)
|
52
|
+
return val.to_date
|
53
|
+
else
|
54
|
+
return Date.parse(val.to_s) rescue val
|
55
|
+
end
|
56
|
+
elsif @type == Time
|
57
|
+
if val.is_a?(Time)
|
58
|
+
return Time.at((val.to_i / 60) * 60).utc
|
59
|
+
else
|
60
|
+
return Time.parse(val).utc rescue val
|
61
|
+
end
|
62
|
+
elsif @type == DateTime
|
63
|
+
if val.is_a?(DateTime)
|
64
|
+
return val.utc
|
65
|
+
else
|
66
|
+
return DateTime.parse(val).utc rescue val
|
67
|
+
end
|
68
|
+
elsif @type == Array
|
69
|
+
val = [val] unless val.is_a?(Array)
|
70
|
+
raise ArgumentError.new("#{name} must be an Array") unless val.is_a?(Array)
|
71
|
+
return val
|
72
|
+
elsif @type == Hash
|
73
|
+
raise ArgumentError.new("#{name} must be a Hash") unless val.is_a?(Hash)
|
74
|
+
return val
|
75
|
+
else
|
76
|
+
if val.is_a?(@type)
|
77
|
+
val
|
78
|
+
elsif val.is_a?(Hash) and (@type < EmbeddedDocument)
|
79
|
+
return @type.new(val)
|
80
|
+
else
|
81
|
+
raise ArgumentError.new("#{name} must be a #{@type}")
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
end
|
87
|
+
end
|
@@ -0,0 +1,59 @@
|
|
1
|
+
require 'zlib'
|
2
|
+
|
3
|
+
module JsonRecord
|
4
|
+
class JsonField
|
5
|
+
include AttributeMethods
|
6
|
+
|
7
|
+
def initialize (record, name, schemas)
|
8
|
+
@record = record
|
9
|
+
@name = name
|
10
|
+
@schemas = schemas
|
11
|
+
@attributes = nil
|
12
|
+
@compressed = record.class.columns_hash[name].type == :binary
|
13
|
+
end
|
14
|
+
|
15
|
+
def serialize
|
16
|
+
if @attributes
|
17
|
+
stripped_attributes = {}
|
18
|
+
@attributes.each_pair{|k, v| stripped_attributes[k] = v unless v.blank?}
|
19
|
+
json = stripped_attributes.to_json
|
20
|
+
json = Zlib::Deflate.deflate(json) if json and @compressed
|
21
|
+
@record[@name] = json
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def deserialize
|
26
|
+
@attributes = {}
|
27
|
+
@schemas.each do |schema|
|
28
|
+
schema.fields.values.each do |field|
|
29
|
+
@attributes[field.name] = field.multivalued? ? EmbeddedDocumentArray.new(field.type, self) : field.default
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
unless @record[@name].blank?
|
34
|
+
json = @record[@name]
|
35
|
+
json = Zlib::Inflate.inflate(json) if @compressed
|
36
|
+
ActiveSupport::JSON.decode(json).each_pair do |attr_name, attr_value|
|
37
|
+
field = nil
|
38
|
+
@schemas.each{|schema| field = schema.fields[attr_name]; break if field}
|
39
|
+
field = FieldDefinition.new(attr_name, :type => attr_value.class) unless field
|
40
|
+
write_attribute(field, attr_value, false, @record)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def json_attributes
|
46
|
+
deserialize unless @attributes
|
47
|
+
@attributes
|
48
|
+
end
|
49
|
+
|
50
|
+
def changes
|
51
|
+
@record.changes
|
52
|
+
end
|
53
|
+
|
54
|
+
def changed_attributes
|
55
|
+
@record.send(:changed_attributes)
|
56
|
+
end
|
57
|
+
|
58
|
+
end
|
59
|
+
end
|