perpetuity 0.2 → 0.3

Sign up to get free protection for your applications and to get access to all the features.
data/CHANGELOG.md ADDED
@@ -0,0 +1,16 @@
1
+ ## Version 0.3
2
+
3
+ - Add `Mapper#save` to update an object's current state in the DB
4
+ - Fix `select` calls using `id` as criteria
5
+ - Switch from `Mongo::Connection` to `Mongo::MongoClient`
6
+ - This makes MongoDB reads and writes fail fast
7
+ - Add indexing interface
8
+ - Silence warnings
9
+ - Raise when calling Mapper[] with unmapped class
10
+ - Add unions and intersections to select queries for MongoDB adapter
11
+ - This allows for queries like `Perpetuity[Article].select { (created_at < Time.now) & (published == true) }`
12
+ - Update object in memory when calling `Mapper#update`
13
+ - Allow subclassing of `Perpetuity::Mapper` with map macro
14
+ - Use `Perpetuity[]` instead of `Perpetuity::Mapper[]` to get mapper instances
15
+
16
+ *Version 0.2 and 0.1 have no changelog because I am a terrible developer*
data/LICENSE ADDED
@@ -0,0 +1,7 @@
1
+ Copyright (C) Jamie Gaskins
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md CHANGED
@@ -49,8 +49,8 @@ class Article
49
49
  end
50
50
 
51
51
  Perpetuity.generate_mapper_for Article do
52
- attribute :title, String
53
- attribute :body, String
52
+ attribute :title
53
+ attribute :body
54
54
  end
55
55
 
56
56
  article = Article.new
@@ -94,7 +94,9 @@ end
94
94
 
95
95
  ## Associations with Other Objects
96
96
 
97
- If an object references another object (such as an article referencing its author), it must have a relationship identifier in its mapper class. For example:
97
+ The database can natively serialize some objects. For example, MongoDB can serialize `String`, `Numeric`, `Array`, `Hash`, `Time`, `nil`, `true`, `false`, and a few others. If an object references another type of object (such as an article referencing its author, a `User` object), the association is declared just as any other attribute. No special treatment is required.
98
+
99
+ If the associated object's class has a mapper defined, it will be used by the parent object's mapper for serialization. Otherwise, the object will be `Marshal.dump`ed. If the object cannot be marshaled, the object cannot be serialized and an exception will be raised.
98
100
 
99
101
  ```ruby
100
102
  class User
@@ -102,17 +104,13 @@ end
102
104
 
103
105
  class Article
104
106
  attr_accessor :author
105
-
106
- def initialize(author)
107
- self.author = author
108
- end
109
107
  end
110
108
 
111
109
  Perpetuity.generate_mapper_for User do
112
110
  end
113
111
 
114
112
  Perpetuity.generate_mapper_for Article do
115
- attribute :author, User # Notice the author's class
113
+ attribute :author
116
114
  end
117
115
  ```
118
116
 
@@ -125,6 +123,8 @@ article_mapper.load_association! article, :author
125
123
  user = article.author
126
124
  ```
127
125
 
126
+ All loading of associated objects is explicit so that we don't load an entire object graph unnecessarily.
127
+
128
128
  ## Customizing persistence
129
129
 
130
130
  Setting the ID of a record to a custom value rather than using the DB default.
@@ -135,6 +135,48 @@ Perpetuity.generate_mapper_for Article do
135
135
  end
136
136
  ```
137
137
 
138
+ ## Indexing
139
+
140
+ Indexes are declared with the `index` method. The simplest way to create an index is just to pass the attribute to be indexed as a parameter:
141
+
142
+ ```ruby
143
+ Perpetuity.generate_mapper_for Article do
144
+ index :title
145
+ end
146
+ ```
147
+
148
+ The following will generate a unique index on an `Article` class so that two articles cannot be added to the database with the same title. This eliminates the need for uniqueness validations (like ActiveRecord has) that check for existence of that value. Uniqueness validations have race conditions and don't protect you at the database level. Using unique indexes is a superior way to do this.
149
+
150
+ ```ruby
151
+ Perpetuity.generate_mapper_for Article do
152
+ index :title, unique: true
153
+ end
154
+ ```
155
+
156
+ Also, MongoDB, as well as some other databases, provide the ability to specify an order for the index. For example, if you want to query your blog with articles in descending order, you can specify a descending-order index on the timestamp for increased query performance.
157
+
158
+ ```ruby
159
+ Perpetuity.generate_mapper_for Article do
160
+ index :timestamp, order: :descending
161
+ end
162
+ ```
163
+
164
+ ### Applying indexes
165
+
166
+ It's very important to keep in mind that specifying an index does not create it on the database immediately. If you did this, you could potentially introduce downtime every time you specify a new index and deploy your application.
167
+
168
+ In order to apply indexes to the database, you must send `reindex!` to the mapper. For example:
169
+
170
+ ```ruby
171
+ class ArticleMapper < Perpetuity::Mapper
172
+ map Article
173
+ attribute :title
174
+ index :title, unique: true
175
+ end
176
+
177
+ Perpetuity[Article].reindex!
178
+ ```
179
+
138
180
  ## Contributing
139
181
 
140
182
  Right now, this code is pretty bare and there are possibly some design decisions that need some more refinement. You can help. If you have ideas to build on this, send some love in the form of pull requests or issues or tweets or e-mails and I'll do what I can for them.
@@ -11,7 +11,11 @@ module Perpetuity
11
11
  end
12
12
 
13
13
  def embedded?
14
- @embedded
14
+ @embedded ||= false
15
+ end
16
+
17
+ def to_s
18
+ name
15
19
  end
16
20
  end
17
21
  end
@@ -17,7 +17,7 @@ module Perpetuity
17
17
  end
18
18
 
19
19
  def each &block
20
- @attributes.each &block
20
+ @attributes.each(&block)
21
21
  end
22
22
  end
23
23
  end
@@ -2,37 +2,50 @@ require 'perpetuity/attribute_set'
2
2
  require 'perpetuity/attribute'
3
3
  require 'perpetuity/validations'
4
4
  require 'perpetuity/data_injectable'
5
- require 'perpetuity/mongodb/query'
5
+ require 'perpetuity/mapper_registry'
6
+ require 'perpetuity/serializer'
6
7
 
7
8
  module Perpetuity
8
9
  class Mapper
9
10
  include DataInjectable
10
- attr_accessor :object, :original_object
11
11
 
12
- def initialize(klass=Object, &block)
12
+ def self.generate_for(klass, &block)
13
+ mapper = Class.new(base_class, &block)
14
+ mapper.map klass
15
+ end
16
+
17
+ def self.map klass
18
+ MapperRegistry[klass] = self
13
19
  @mapped_class = klass
14
- instance_exec &block if block_given?
15
20
  end
16
21
 
17
- def self.generate_for(klass=Object, &block)
18
- mapper = new(klass, &block)
19
- mappers[klass] = mapper
22
+ def self.attribute_set
23
+ @attribute_set ||= AttributeSet.new
20
24
  end
21
25
 
22
- def self.mappers
23
- @mappers ||= {}
26
+ def self.attribute name, options = {}
27
+ type = options.fetch(:type) { nil }
28
+ attribute_set << Attribute.new(name, type, options)
24
29
  end
25
30
 
26
- def attribute_set
27
- @attribute_set ||= AttributeSet.new
31
+ def self.attributes
32
+ attribute_set.map(&:name)
28
33
  end
29
34
 
30
- def attribute name, type, options = {}
31
- attribute_set << Attribute.new(name, type, options)
35
+ def self.index attribute
36
+ data_source.index mapped_class, attribute_set[attribute]
37
+ end
38
+
39
+ def indexes
40
+ data_source.indexes(mapped_class)
41
+ end
42
+
43
+ def reindex!
44
+ indexes.each { |index| data_source.activate_index! index }
32
45
  end
33
46
 
34
47
  def attributes
35
- attribute_set.map(&:name)
48
+ self.class.attributes
36
49
  end
37
50
 
38
51
  def delete_all
@@ -40,9 +53,9 @@ module Perpetuity
40
53
  end
41
54
 
42
55
  def insert object
43
- raise "#{object} is invalid and cannot be persisted." unless validations.valid?(object)
56
+ raise "#{object} is invalid and cannot be persisted." unless self.class.validations.valid?(object)
44
57
  serializable_attributes = serialize(object)
45
- if o_id = object.instance_exec(&id)
58
+ if o_id = object.instance_exec(&self.class.id)
46
59
  serializable_attributes[:id] = o_id
47
60
  end
48
61
 
@@ -52,70 +65,29 @@ module Perpetuity
52
65
  end
53
66
 
54
67
  def serialize object
55
- attrs = {}
56
- attribute_set.each do |attrib|
57
- value = object.send(attrib.name)
58
- attrib_name = attrib.name.to_s
59
-
60
- if value.respond_to? :each
61
- attrs[attrib_name] = serialize_enumerable(value)
62
- elsif data_source.can_serialize? value
63
- attrs[attrib_name] = value
64
- elsif Mapper[value.class]
65
- if attrib.embedded?
66
- attrs[attrib_name] = Mapper[value.class].serialize(value).merge '__metadata__' => { 'class' => value.class }
67
- else
68
- attrs[attrib_name] = {
69
- '__metadata__' => {
70
- 'class' => value.class.to_s,
71
- 'id' => value.id
72
- }
73
- }
74
- end
75
- else
76
- if attrib.embedded?
77
- attrs[attrib_name] = Marshal.dump(value)
78
- end
79
- end
80
- end
81
-
82
- attrs
83
- end
84
-
85
- def serialize_enumerable enum
86
- enum.map do |value|
87
- if value.respond_to? :each
88
- serialize_enumerable(value)
89
- elsif data_source.can_serialize? value
90
- value
91
- elsif Mapper[value.class]
92
- {
93
- '__metadata__' => {
94
- 'class' => value.class.to_s
95
- }
96
- }.merge Mapper[value.class].serialize(value)
97
- else
98
- Marshal.dump(value)
99
- end
100
- end
68
+ Serializer.new(self).serialize(object)
101
69
  end
102
70
 
103
- def self.[] klass
104
- mappers[klass]
71
+ def self.data_source
72
+ Perpetuity.configuration.data_source
105
73
  end
106
74
 
107
75
  def data_source
108
- Perpetuity.configuration.data_source
76
+ self.class.data_source
109
77
  end
110
78
 
111
79
  def count
112
80
  data_source.count mapped_class
113
81
  end
114
82
 
115
- def mapped_class
83
+ def self.mapped_class
116
84
  @mapped_class
117
85
  end
118
86
 
87
+ def mapped_class
88
+ self.class.mapped_class
89
+ end
90
+
119
91
  def first
120
92
  data = data_source.first mapped_class
121
93
  object = mapped_class.new
@@ -159,10 +131,10 @@ module Perpetuity
159
131
  klass = reference.klass
160
132
  id = reference.id
161
133
 
162
- inject_attribute object, attribute, Mapper[klass].find(id)
134
+ inject_attribute object, attribute, MapperRegistry[klass].find(id)
163
135
  end
164
136
 
165
- def id &block
137
+ def self.id &block
166
138
  if block_given?
167
139
  @id = block
168
140
  else
@@ -170,21 +142,31 @@ module Perpetuity
170
142
  end
171
143
  end
172
144
 
173
- def update object, new_data
145
+ def update object, new_data, update_in_memory = true
174
146
  id = object.is_a?(mapped_class) ? object.id : object
175
147
 
148
+ inject_data object, new_data if update_in_memory
176
149
  data_source.update mapped_class, id, new_data
177
150
  end
178
151
 
179
- def validate &block
152
+ def save object
153
+ update object, serialize(object), false
154
+ end
155
+
156
+ def self.validate &block
180
157
  @validations ||= ValidationSet.new
181
158
 
182
159
  validations.instance_exec(&block)
183
160
  end
184
161
 
185
- def validations
162
+ def self.validations
186
163
  @validations ||= ValidationSet.new
187
164
  end
165
+
166
+ private
167
+ def self.base_class
168
+ Mapper
169
+ end
188
170
  end
189
171
  end
190
172
 
@@ -0,0 +1,17 @@
1
+ module Perpetuity
2
+ class MapperRegistry
3
+ @mappers = Hash.new { |_, klass| raise KeyError, "No mapper for #{klass}" }
4
+
5
+ def self.has_mapper? klass
6
+ @mappers.has_key? klass
7
+ end
8
+
9
+ def self.[] klass
10
+ @mappers[klass].new
11
+ end
12
+
13
+ def self.[]= klass, mapper
14
+ @mappers[klass] = mapper
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,48 @@
1
+ module Perpetuity
2
+ class MongoDB
3
+ class Index
4
+ KEY_ORDERS = { 1 => :ascending, -1 => :descending }
5
+ attr_reader :collection, :attribute
6
+
7
+ def initialize klass, attribute, options={}
8
+ @collection = klass
9
+ @attribute = attribute
10
+ @unique = options.fetch(:unique) { false }
11
+ @order = options.fetch(:order) { :ascending }
12
+ @activated = false
13
+ end
14
+
15
+ def active?
16
+ @activated
17
+ end
18
+
19
+ def inactive?
20
+ !active?
21
+ end
22
+
23
+ def activate!
24
+ @activated = true
25
+ end
26
+
27
+ def unique?
28
+ @unique
29
+ end
30
+
31
+ def order
32
+ @order
33
+ end
34
+
35
+ def == other
36
+ hash == other.hash
37
+ end
38
+
39
+ def eql? other
40
+ self == other
41
+ end
42
+
43
+ def hash
44
+ "#{collection}/#{attribute}:#{unique?}:#{order}".hash
45
+ end
46
+ end
47
+ end
48
+ end
@@ -1,3 +1,6 @@
1
+ require 'perpetuity/mongodb/query_union'
2
+ require 'perpetuity/mongodb/query_intersection'
3
+
1
4
  module Perpetuity
2
5
  class MongoDB
3
6
  class QueryExpression
@@ -50,6 +53,14 @@ module Perpetuity
50
53
  def matches
51
54
  { @attribute => @value }
52
55
  end
56
+
57
+ def | other
58
+ QueryUnion.new(self, other)
59
+ end
60
+
61
+ def & other
62
+ QueryIntersection.new(self, other)
63
+ end
53
64
  end
54
65
  end
55
66
  end
@@ -0,0 +1,16 @@
1
+ module Perpetuity
2
+ class MongoDB
3
+ class QueryIntersection
4
+ attr_reader :lhs, :rhs
5
+
6
+ def initialize lhs, rhs
7
+ @lhs = lhs
8
+ @rhs = rhs
9
+ end
10
+
11
+ def to_db
12
+ { '$and' => [lhs.to_db, rhs.to_db] }
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,16 @@
1
+ module Perpetuity
2
+ class MongoDB
3
+ class QueryUnion
4
+ attr_reader :lhs, :rhs
5
+
6
+ def initialize lhs, rhs
7
+ @lhs = lhs
8
+ @rhs = rhs
9
+ end
10
+
11
+ def to_db
12
+ { '$or' => [lhs.to_db, rhs.to_db] }
13
+ end
14
+ end
15
+ end
16
+ end
@@ -1,4 +1,6 @@
1
1
  require 'mongo'
2
+ require 'perpetuity/mongodb/query'
3
+ require 'perpetuity/mongodb/index'
2
4
 
3
5
  module Perpetuity
4
6
  class MongoDB
@@ -12,11 +14,12 @@ module Perpetuity
12
14
  @username = options[:username]
13
15
  @password = options[:password]
14
16
  @connection = nil
17
+ @indexes = Hash.new { |hash, key| hash[key] = active_indexes(key) }
15
18
  end
16
19
 
17
20
  def connect
18
21
  database.authenticate(@username, @password) if @username and @password
19
- @connection ||= Mongo::Connection.new @host, @port, pool_size: @pool_size
22
+ @connection ||= Mongo::MongoClient.new @host, @port, pool_size: @pool_size
20
23
  end
21
24
 
22
25
  def connected?
@@ -61,12 +64,11 @@ module Perpetuity
61
64
 
62
65
  # MongoDB uses '_id' as its ID field.
63
66
  if criteria.has_key?(:id)
64
- criteria = {
65
- '$or' => [
66
- { _id: BSON::ObjectId.from_string(criteria[:id].to_s) },
67
- { _id: criteria[:id].to_s }
68
- ]
69
- }
67
+ if criteria[:id].is_a? String
68
+ criteria = { _id: BSON::ObjectId.from_string(criteria[:id].to_s) }
69
+ else
70
+ criteria[:_id] = criteria.delete(:id)
71
+ end
70
72
  end
71
73
 
72
74
  sort_field = options[:attribute]
@@ -103,6 +105,41 @@ module Perpetuity
103
105
  serializable_types.include? value.class
104
106
  end
105
107
 
108
+ def drop_collection to_be_dropped
109
+ collection(to_be_dropped).drop
110
+ end
111
+
112
+ def index klass, attribute, options={}
113
+ @indexes[klass] ||= Set.new
114
+
115
+ index = Index.new(klass, attribute, options)
116
+ @indexes[klass] << index
117
+ index
118
+ end
119
+
120
+ def indexes klass
121
+ @indexes[klass]
122
+ end
123
+
124
+ def active_indexes klass
125
+ indexes = collection(klass).index_information
126
+ indexes.map do |name, index|
127
+ key = index['key'].keys.first
128
+ direction = index['key'][key]
129
+ unique = index['unique']
130
+ Index.new(klass, key, order: Index::KEY_ORDERS[direction], unique: unique)
131
+ end.to_set
132
+ end
133
+
134
+ def activate_index! index
135
+ attribute = index.attribute.to_s
136
+ order = index.order == :ascending ? 1 : -1
137
+ unique = index.unique?
138
+
139
+ collection(index.collection).create_index [[attribute, order]], unique: unique
140
+ index.activate!
141
+ end
142
+
106
143
  private
107
144
  def serializable_types
108
145
  @serializable_types ||= [NilClass, TrueClass, FalseClass, Fixnum, Float, String, Array, Hash, Time]
@@ -0,0 +1,75 @@
1
+ require 'perpetuity/mapper_registry'
2
+
3
+ module Perpetuity
4
+ class Serializer
5
+ attr_reader :mapper
6
+
7
+ def initialize(mapper)
8
+ @mapper = mapper
9
+ end
10
+
11
+ def attribute_for object, attribute_name
12
+ if object.respond_to? attribute_name
13
+ object.send(attribute_name)
14
+ else
15
+ object.instance_variable_get("@#{attribute_name}")
16
+ end
17
+ end
18
+
19
+ def serialize object
20
+ attrs = mapper.class.attribute_set.map do |attrib|
21
+ value = attribute_for object, attrib.name
22
+
23
+ serialized_value = if value.is_a? Array
24
+ serialize_array(value)
25
+ elsif mapper.data_source.can_serialize? value
26
+ value
27
+ elsif MapperRegistry.has_mapper?(value.class)
28
+ serialize_with_foreign_mapper(value, attrib.embedded?)
29
+ else
30
+ if attrib.embedded?
31
+ Marshal.dump(value)
32
+ end
33
+ end
34
+
35
+ [attrib.name.to_s, serialized_value]
36
+ end
37
+
38
+ Hash[attrs]
39
+ end
40
+
41
+ def serialize_with_foreign_mapper value, embedded = false
42
+ if embedded
43
+ value_mapper = MapperRegistry[value.class]
44
+ value_serializer = Serializer.new(value_mapper)
45
+ attr = value_serializer.serialize(value)
46
+ attr.merge '__metadata__' => { 'class' => value.class }
47
+ else
48
+ {
49
+ '__metadata__' => {
50
+ 'class' => value.class.to_s,
51
+ 'id' => value.id
52
+ }
53
+ }
54
+ end
55
+ end
56
+
57
+ def serialize_array enum
58
+ enum.map do |value|
59
+ if value.is_a? Array
60
+ serialize_array(value)
61
+ elsif mapper.data_source.can_serialize? value
62
+ value
63
+ elsif MapperRegistry.has_mapper?(value.class)
64
+ {
65
+ '__metadata__' => {
66
+ 'class' => value.class.to_s
67
+ }
68
+ }.merge MapperRegistry[value.class].serialize(value)
69
+ else
70
+ Marshal.dump(value)
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -1,3 +1,3 @@
1
1
  module Perpetuity
2
- VERSION = "0.2"
2
+ VERSION = "0.3"
3
3
  end
data/lib/perpetuity.rb CHANGED
@@ -10,7 +10,7 @@ module Perpetuity
10
10
  end
11
11
 
12
12
  def self.configuration
13
- @@configuration ||= Configuration.new
13
+ @configuration ||= Configuration.new
14
14
  end
15
15
 
16
16
  def self.generate_mapper_for klass, &block
@@ -18,6 +18,6 @@ module Perpetuity
18
18
  end
19
19
 
20
20
  def self.[] klass
21
- Mapper[klass]
21
+ MapperRegistry[klass]
22
22
  end
23
23
  end
data/perpetuity.gemspec CHANGED
@@ -9,7 +9,7 @@ Gem::Specification.new do |s|
9
9
  s.email = ["jgaskins@gmail.com"]
10
10
  s.homepage = "https://github.com/jgaskins/perpetuity.git"
11
11
  s.summary = %q{Persistence library allowing serialization of Ruby objects}
12
- s.description = %q{Persistence layer Ruby objects}
12
+ s.description = %q{Persistence layer for Ruby objects}
13
13
 
14
14
  s.files = `git ls-files`.split("\n")
15
15
  s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
@@ -19,7 +19,6 @@ Gem::Specification.new do |s|
19
19
  # specify any dependencies here; for example:
20
20
  s.add_development_dependency "rake"
21
21
  s.add_development_dependency "rspec", "~> 2.8.0"
22
- s.add_development_dependency "guard-rspec"
23
- s.add_runtime_dependency "mongo"
22
+ s.add_runtime_dependency "mongo", ">= 1.8.0"
24
23
  s.add_runtime_dependency "bson_ext"
25
24
  end
@@ -0,0 +1,15 @@
1
+ require 'perpetuity/mapper_registry'
2
+
3
+ module Perpetuity
4
+ describe MapperRegistry do
5
+ subject { described_class }
6
+ let(:mapper) { Class.new }
7
+
8
+ before { MapperRegistry[Object] = mapper }
9
+
10
+ it { should have_mapper Object }
11
+ it 'maps classes to instances of their mappers' do
12
+ MapperRegistry[Object].should be_a mapper
13
+ end
14
+ end
15
+ end