perpetuity 0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (41) hide show
  1. data/.gitignore +4 -0
  2. data/.rspec +1 -0
  3. data/.rvmrc +1 -0
  4. data/.travis.yml +7 -0
  5. data/Gemfile +3 -0
  6. data/Guardfile +24 -0
  7. data/README.md +142 -0
  8. data/Rakefile +6 -0
  9. data/lib/perpetuity/attribute.rb +17 -0
  10. data/lib/perpetuity/attribute_set.rb +23 -0
  11. data/lib/perpetuity/config.rb +11 -0
  12. data/lib/perpetuity/data_injectable.rb +21 -0
  13. data/lib/perpetuity/mapper.rb +195 -0
  14. data/lib/perpetuity/mongodb/query.rb +19 -0
  15. data/lib/perpetuity/mongodb/query_attribute.rb +49 -0
  16. data/lib/perpetuity/mongodb/query_expression.rb +55 -0
  17. data/lib/perpetuity/mongodb.rb +102 -0
  18. data/lib/perpetuity/retrieval.rb +82 -0
  19. data/lib/perpetuity/validations/length.rb +36 -0
  20. data/lib/perpetuity/validations/presence.rb +14 -0
  21. data/lib/perpetuity/validations/validation_set.rb +27 -0
  22. data/lib/perpetuity/validations.rb +1 -0
  23. data/lib/perpetuity/version.rb +3 -0
  24. data/lib/perpetuity.rb +23 -0
  25. data/perpetuity.gemspec +25 -0
  26. data/spec/perpetuity/attribute_set_spec.rb +19 -0
  27. data/spec/perpetuity/attribute_spec.rb +19 -0
  28. data/spec/perpetuity/config_spec.rb +13 -0
  29. data/spec/perpetuity/data_injectable_spec.rb +26 -0
  30. data/spec/perpetuity/mapper_spec.rb +51 -0
  31. data/spec/perpetuity/mongodb/query_attribute_spec.rb +42 -0
  32. data/spec/perpetuity/mongodb/query_expression_spec.rb +49 -0
  33. data/spec/perpetuity/mongodb/query_spec.rb +37 -0
  34. data/spec/perpetuity/mongodb_spec.rb +91 -0
  35. data/spec/perpetuity/retrieval_spec.rb +58 -0
  36. data/spec/perpetuity/validations/length_spec.rb +53 -0
  37. data/spec/perpetuity/validations/presence_spec.rb +30 -0
  38. data/spec/perpetuity/validations_spec.rb +87 -0
  39. data/spec/perpetuity_spec.rb +293 -0
  40. data/spec/test_classes.rb +93 -0
  41. metadata +181 -0
data/.gitignore ADDED
@@ -0,0 +1,4 @@
1
+ *.gem
2
+ .bundle
3
+ Gemfile.lock
4
+ pkg/*
data/.rspec ADDED
@@ -0,0 +1 @@
1
+ --colour
data/.rvmrc ADDED
@@ -0,0 +1 @@
1
+ rvm use @perpetuity --create
data/.travis.yml ADDED
@@ -0,0 +1,7 @@
1
+ language: ruby
2
+ rvm:
3
+ - 1.9.2
4
+ - 1.9.3
5
+ - rbx-19mode
6
+ services:
7
+ - mongodb
data/Gemfile ADDED
@@ -0,0 +1,3 @@
1
+ source "http://rubygems.org"
2
+
3
+ gemspec
data/Guardfile ADDED
@@ -0,0 +1,24 @@
1
+ # A sample Guardfile
2
+ # More info at https://github.com/guard/guard#readme
3
+
4
+ guard 'rspec', :version => 2 do
5
+ watch(%r{^spec/.+_spec\.rb$})
6
+ watch(%r{^lib/(.+)\.rb$}) { |m| "spec/lib/#{m[1]}_spec.rb" }
7
+ watch('spec/spec_helper.rb') { "spec" }
8
+
9
+ # Rails example
10
+ watch(%r{^app/(.+)\.rb$}) { |m| "spec/#{m[1]}_spec.rb" }
11
+ watch(%r{^app/(.*)(\.erb|\.haml)$}) { |m| "spec/#{m[1]}#{m[2]}_spec.rb" }
12
+ watch(%r{^app/controllers/(.+)_(controller)\.rb$}) { |m| ["spec/routing/#{m[1]}_routing_spec.rb", "spec/#{m[2]}s/#{m[1]}_#{m[2]}_spec.rb", "spec/acceptance/#{m[1]}_spec.rb"] }
13
+ watch(%r{^spec/support/(.+)\.rb$}) { "spec" }
14
+ watch('config/routes.rb') { "spec/routing" }
15
+ watch('app/controllers/application_controller.rb') { "spec/controllers" }
16
+
17
+ # Capybara request specs
18
+ watch(%r{^app/views/(.+)/.*\.(erb|haml)$}) { |m| "spec/requests/#{m[1]}_spec.rb" }
19
+
20
+ # Turnip features and steps
21
+ watch(%r{^spec/acceptance/(.+)\.feature$})
22
+ watch(%r{^spec/acceptance/steps/(.+)_steps\.rb$}) { |m| Dir[File.join("**/#{m[1]}.feature")][0] || 'spec/acceptance' }
23
+ end
24
+
data/README.md ADDED
@@ -0,0 +1,142 @@
1
+ # Perpetuity [![Build Status](https://secure.travis-ci.org/jgaskins/perpetuity.png)](http://travis-ci.org/jgaskins/perpetuity)
2
+
3
+ Perpetuity is a simple Ruby object persistence layer that attempts to follow Martin Fowler's Data Mapper pattern, allowing you to use plain-old Ruby objects in your Ruby apps in order to decouple your domain logic from the database as well as speed up your tests. There is no need for your model classes to inherit from another class or even include a mix-in.
4
+
5
+ Your objects will hopefully eventually be able to be persisted into whichever database you like. Right now, only MongoDB is supported. Other persistence solutions will come later.
6
+
7
+ This gem was inspired by [a blog post by Steve Klabnik](http://blog.steveklabnik.com/posts/2011-12-30-active-record-considered-harmful).
8
+
9
+ ## How it works
10
+
11
+ In the Data Mapper pattern, the objects you work with don't understand how to persist themselves. They interact with other objects just as in any other object-oriented application, leaving all persistence logic to mapper objects. This decouples them from the database and allows you to write your code without it in mind.
12
+
13
+ ## Installation
14
+
15
+ Add the following to your Gemfile and run `bundle` to install it.
16
+
17
+ ```ruby
18
+ gem 'perpetuity', github: 'jgaskins/perpetuity'
19
+ ```
20
+
21
+ Once it's got enough functionality to release, you'll be able to remove the `github` parameter.
22
+
23
+ ## Configuration
24
+
25
+ The only currently supported persistence method is MongoDB. Other schemaless solutions can probably be implemented easily.
26
+
27
+ ```ruby
28
+ mongodb = Perpetuity::MongoDB.new host: 'mongodb.example.com', db: 'example_db'
29
+ Perpetuity.configure do
30
+ data_source mongodb
31
+ end
32
+ ```
33
+
34
+ ## Setting up object mappers
35
+
36
+ Object mappers are generated by the following:
37
+
38
+ ```ruby
39
+ Perpetuity.generate_mapper_for MyClass do
40
+ # individual mapper configuration goes here
41
+ end
42
+ ```
43
+
44
+ The primary mapper configuration will be configuring attributes to be persisted. This is done using the `attribute` method. Calling `attribute` will add the specified attribute and its class to the mapper's attribute set. This is how the mapper knows what to store and how to store it. Here is an example of an `Article` class, its mapper and how it can be saved to the database.
45
+
46
+ Accessing mappers after they've been generated is done through the use of the subscript operator on the `Perpetuity` module. For example, if you generate a mapper for an `Article` class, you can access it by calling `Perpetuity[Article]`.
47
+
48
+ ```ruby
49
+ class Article
50
+ attr_accessor :title, :body
51
+ end
52
+
53
+ Perpetuity.generate_mapper_for Article do
54
+ attribute :title, String
55
+ attribute :body, String
56
+ end
57
+
58
+ article = Article.new
59
+ article.title = 'New Article'
60
+ article.body = 'This is an article.'
61
+
62
+ Perpetuity[Article].insert article
63
+ ```
64
+
65
+ ## Loading Objects
66
+
67
+ You can load all persisted objects of a particular class by sending `all` to the mapper object. Example:
68
+
69
+ ```ruby
70
+ Perpetuity[Article].all
71
+ ```
72
+
73
+ You can load specific objects by calling the `find` method with an ID param on that class's mapper class and passing in the criteria. You may also specify more general criteria using the `select` method with a block similar to `Enumerable#select`.
74
+
75
+ ```ruby
76
+ article = Perpetuity[Article].find params[:id]
77
+ users = Perpetuity[User].select { email == 'me@example.com' }
78
+ articles = Perpetuity[Article].select { published_at < Time.now }
79
+ comments = Perpetuity[Comment].select { article_id.in articles.map(&:id) }
80
+ ```
81
+
82
+ Unfortunately, due to limitations in the Ruby language itself, this is as close as I could get to a true `Enumerable`-style select method. Once I can override `&&` and `||`, we can put more Rubyesque code in here.
83
+
84
+ These methods will return a Perpetuity::Retrieval object, which will lazily retrieve the objects from the database. They will wait to hit the DB when you begin iterating over the objects so you can continue chaining methods.
85
+
86
+ ```ruby
87
+ article_mapper = Perpetuity[Article]
88
+ articles = article_mapper.select { published_at < Time.now }
89
+ articles = articles.sort(:published_at).reverse
90
+ articles = articles.page(2).per_page(10) # built-in pagination
91
+
92
+ articles.each do |article| # This is when the DB gets hit
93
+ # Display the pretty articles
94
+ end
95
+ ```
96
+
97
+ ## Associations with Other Objects
98
+
99
+ 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:
100
+
101
+ ```ruby
102
+ class User
103
+ end
104
+
105
+ class Article
106
+ attr_accessor :author
107
+
108
+ def initialize(author)
109
+ self.author = author
110
+ end
111
+ end
112
+
113
+ Perpetuity.generate_mapper_for User do
114
+ end
115
+
116
+ Perpetuity.generate_mapper_for Article do
117
+ attribute :author, User # Notice the author's class
118
+ end
119
+ ```
120
+
121
+ This allows you to write the following:
122
+
123
+ ```ruby
124
+ article_mapper = Perpetuity[Article]
125
+ article = article_mapper.first
126
+ article_mapper.load_association! article, :author
127
+ user = article.author
128
+ ```
129
+
130
+ ## Customizing persistence
131
+
132
+ Setting the ID of a record to a custom value rather than using the DB default.
133
+
134
+ ```ruby
135
+ Perpetuity.generate_mapper_for Article do
136
+ id { title.gsub(/\W+/, '-') } # use the article's parameterized title attribute as its ID
137
+ end
138
+ ```
139
+
140
+ ## Contributing
141
+
142
+ 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.
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require "bundler/gem_tasks"
2
+ require 'rspec/core/rake_task'
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task :default => :spec
@@ -0,0 +1,17 @@
1
+ module Perpetuity
2
+ class Attribute
3
+ attr_reader :name, :type
4
+ def initialize(name, type, options = {})
5
+ @name = name
6
+ @type = type
7
+
8
+ options.each do |option, value|
9
+ instance_variable_set "@#{option}", value
10
+ end
11
+ end
12
+
13
+ def embedded?
14
+ @embedded
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,23 @@
1
+ require 'set'
2
+
3
+ module Perpetuity
4
+ class AttributeSet
5
+ include Enumerable
6
+
7
+ def initialize
8
+ @attributes = Set.new
9
+ end
10
+
11
+ def << attribute
12
+ @attributes << attribute
13
+ end
14
+
15
+ def [] name
16
+ @attributes.find { |attr| attr.name == name }
17
+ end
18
+
19
+ def each &block
20
+ @attributes.each &block
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,11 @@
1
+ module Perpetuity
2
+ class Configuration
3
+ def data_source *args
4
+ if args.any?
5
+ @db = args.pop
6
+ end
7
+
8
+ @db
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,21 @@
1
+ module Perpetuity
2
+ module DataInjectable
3
+ def inject_data object, data
4
+ data.each_pair do |attribute,value|
5
+ if object.respond_to?("#{attribute}=")
6
+ object.send("#{attribute}=", value)
7
+ else
8
+ attribute = "@#{attribute}" unless attribute[0] == '@'
9
+ object.instance_variable_set(attribute, value)
10
+ end
11
+ end
12
+ give_id_to object if object.instance_variables.include?(:@id)
13
+ end
14
+
15
+ def give_id_to object, *args
16
+ object.define_singleton_method :id do
17
+ args.first || object.instance_variable_get(:@id)
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,195 @@
1
+ require 'perpetuity/attribute_set'
2
+ require 'perpetuity/attribute'
3
+ require 'perpetuity/validations'
4
+ require 'perpetuity/data_injectable'
5
+ require 'perpetuity/mongodb/query'
6
+
7
+ module Perpetuity
8
+ class Mapper
9
+ include DataInjectable
10
+ attr_accessor :object, :original_object
11
+
12
+ def initialize(klass=Object, &block)
13
+ @mapped_class = klass
14
+ instance_exec &block if block_given?
15
+ end
16
+
17
+ def self.generate_for(klass=Object, &block)
18
+ mapper = new(klass, &block)
19
+ mappers[klass] = mapper
20
+ end
21
+
22
+ def self.mappers
23
+ @mappers ||= {}
24
+ end
25
+
26
+ def attribute_set
27
+ @attribute_set ||= AttributeSet.new
28
+ end
29
+
30
+ def attribute name, type, options = {}
31
+ attribute_set << Attribute.new(name, type, options)
32
+ end
33
+
34
+ def attributes
35
+ attribute_set.map(&:name)
36
+ end
37
+
38
+ def delete_all
39
+ data_source.delete_all mapped_class
40
+ end
41
+
42
+ def serializable_types
43
+ @serializable_types ||= [NilClass, TrueClass, FalseClass, Fixnum, Bignum, Float, String, Array, Hash, Time, Date]
44
+ end
45
+
46
+ def insert object
47
+ raise "#{object} is invalid and cannot be persisted." unless validations.valid?(object)
48
+ serializable_attributes = {}
49
+ if o_id = object.instance_exec(&id)
50
+ serializable_attributes[:id] = o_id
51
+ end
52
+
53
+ attributes_for(object).each_pair do |attribute, value|
54
+ if serializable_types.include? value.class
55
+ serializable_attributes[attribute] = value
56
+ elsif value.respond_to?(:id)
57
+ serializable_attributes[attribute] = value.id
58
+ else
59
+ raise "Must persist #{attribute} (#{value.inspect}) before persisting this #{object.inspect}."
60
+ end
61
+ end
62
+
63
+ new_id = data_source.insert mapped_class, serializable_attributes
64
+ give_id_to object, new_id
65
+ new_id
66
+ end
67
+
68
+ def attributes_for object
69
+ attrs = {}
70
+ attribute_set.each do |attrib|
71
+ value = object.send(attrib.name)
72
+
73
+ if attrib.type == Array
74
+ new_array = []
75
+ value.each do |i|
76
+ if serializable_types.include? i.class
77
+ new_array << i
78
+ else
79
+ if attrib.embedded?
80
+ new_array << Marshal.dump(i)
81
+ else
82
+ new_array << i.id
83
+ end
84
+ end
85
+ end
86
+
87
+ attrs[attrib.name] = new_array
88
+ else
89
+ attrs[attrib.name] = value
90
+ end
91
+ end
92
+ attrs
93
+ end
94
+
95
+ def unserialize(data)
96
+ if data.is_a?(String) && data.start_with?("\u0004")
97
+ Marshal.load(data)
98
+ elsif data.is_a? Array
99
+ data.map { |i| unserialize i }
100
+ elsif data.is_a? Hash
101
+ Hash[data.map{|k,v| [k, unserialize(v)]}]
102
+ else
103
+ data
104
+ end
105
+ end
106
+
107
+ def self.[] klass
108
+ mappers[klass]
109
+ end
110
+
111
+ def data_source
112
+ Perpetuity.configuration.data_source
113
+ end
114
+
115
+ def count
116
+ data_source.count mapped_class
117
+ end
118
+
119
+ def mapped_class
120
+ @mapped_class
121
+ end
122
+
123
+ def first
124
+ data = data_source.first mapped_class
125
+ object = mapped_class.new
126
+ inject_data object, data
127
+
128
+ object
129
+ end
130
+
131
+ def all
132
+ results = data_source.all mapped_class
133
+ objects = []
134
+ results.each do |result|
135
+ object = mapped_class.new
136
+ inject_data object, result
137
+
138
+ objects << object
139
+ end
140
+
141
+ objects
142
+ end
143
+
144
+ def retrieve criteria={}
145
+ Perpetuity::Retrieval.new mapped_class, criteria
146
+ end
147
+
148
+ def select &block
149
+ query = data_source.class::Query.new(&block).to_db
150
+ retrieve query
151
+ end
152
+
153
+ def find id
154
+ retrieve(id: id).first
155
+ end
156
+
157
+ def delete object
158
+ data_source.delete object, mapped_class
159
+ end
160
+
161
+ def load_association! object, attribute
162
+ class_name = attribute_set[attribute].type
163
+ id = object.send(attribute)
164
+
165
+ mapper = Mapper[class_name]
166
+ associated_object = mapper.find(id)
167
+ object.send("#{attribute}=", associated_object)
168
+ end
169
+
170
+ def id &block
171
+ if block_given?
172
+ @id = block
173
+ else
174
+ @id ||= -> { nil }
175
+ end
176
+ end
177
+
178
+ def update object, new_data
179
+ id = object.is_a?(mapped_class) ? object.id : object
180
+
181
+ data_source.update mapped_class, id, new_data
182
+ end
183
+
184
+ def validate &block
185
+ @validations ||= ValidationSet.new
186
+
187
+ validations.instance_exec(&block)
188
+ end
189
+
190
+ def validations
191
+ @validations ||= ValidationSet.new
192
+ end
193
+ end
194
+ end
195
+
@@ -0,0 +1,19 @@
1
+ require 'perpetuity/mongodb/query_attribute'
2
+
3
+ module Perpetuity
4
+ class MongoDB
5
+ class Query
6
+ def initialize &block
7
+ @query = instance_exec(&block)
8
+ end
9
+
10
+ def to_db
11
+ @query.to_db
12
+ end
13
+
14
+ def method_missing missing_method
15
+ QueryAttribute.new missing_method
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,49 @@
1
+ require 'perpetuity/mongodb/query_expression'
2
+
3
+ module Perpetuity
4
+ class MongoDB
5
+ class QueryAttribute
6
+ attr_reader :name
7
+
8
+ def initialize name
9
+ @name = name
10
+ end
11
+
12
+ def == value
13
+ QueryExpression.new self, :equals, value
14
+ end
15
+
16
+ def < value
17
+ QueryExpression.new self, :less_than, value
18
+ end
19
+
20
+ def >= value
21
+ QueryExpression.new self, :gte, value
22
+ end
23
+
24
+ def > value
25
+ QueryExpression.new self, :greater_than, value
26
+ end
27
+
28
+ def <= value
29
+ QueryExpression.new self, :lte, value
30
+ end
31
+
32
+ def not_equal? value
33
+ QueryExpression.new self, :not_equal, value
34
+ end
35
+
36
+ def =~ regexp
37
+ QueryExpression.new self, :matches, regexp
38
+ end
39
+
40
+ def in collection
41
+ QueryExpression.new self, :in, collection
42
+ end
43
+
44
+ def to_sym
45
+ name
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,55 @@
1
+ module Perpetuity
2
+ class MongoDB
3
+ class QueryExpression
4
+ attr_accessor :comparator
5
+
6
+ def initialize attribute, comparator, value
7
+ @attribute = attribute
8
+ @comparator = comparator
9
+ @value = value
10
+
11
+ @attribute = @attribute.to_sym if @attribute.respond_to? :to_sym
12
+ end
13
+
14
+ def to_db
15
+ send @comparator
16
+ end
17
+
18
+ def equals
19
+ { @attribute => @value }
20
+ end
21
+
22
+ def function func
23
+ { @attribute => { func => @value } }
24
+ end
25
+
26
+ def less_than
27
+ function '$lt'
28
+ end
29
+
30
+ def lte
31
+ function '$lte'
32
+ end
33
+
34
+ def greater_than
35
+ function '$gt'
36
+ end
37
+
38
+ def gte
39
+ function '$gte'
40
+ end
41
+
42
+ def not_equal
43
+ function '$ne'
44
+ end
45
+
46
+ def in
47
+ function '$in'
48
+ end
49
+
50
+ def matches
51
+ { @attribute => @value }
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,102 @@
1
+ require 'mongo'
2
+
3
+ module Perpetuity
4
+ class MongoDB
5
+ attr_accessor :connection, :host, :port, :db, :pool_size, :username, :password
6
+
7
+ def initialize options
8
+ @host = options.fetch(:host, 'localhost')
9
+ @port = options.fetch(:port, 27017)
10
+ @db = options.fetch(:db)
11
+ @pool_size = options.fetch(:pool_size, 5)
12
+ @username = options[:username]
13
+ @password = options[:password]
14
+ @connection = nil
15
+ end
16
+
17
+ def connect
18
+ database.authenticate(@username, @password) if @username and @password
19
+ @connection ||= Mongo::Connection.new @host, @port, pool_size: @pool_size
20
+ end
21
+
22
+ def connected?
23
+ !!@connection
24
+ end
25
+
26
+ def database
27
+ connect unless connected?
28
+ @connection.db(@db)
29
+ end
30
+
31
+ def collection klass
32
+ database.collection(klass.to_s)
33
+ end
34
+
35
+ def insert klass, attributes
36
+ if attributes.has_key? :id
37
+ attributes[:_id] = attributes[:id]
38
+ attributes.delete :id
39
+ end
40
+
41
+ collection(klass).insert attributes
42
+ end
43
+
44
+ def count klass
45
+ collection(klass).count
46
+ end
47
+
48
+ def delete_all klass
49
+ database.collection(klass.to_s).remove
50
+ end
51
+
52
+ def first klass
53
+ document = database.collection(klass.to_s).find_one
54
+ document[:id] = document.delete("_id")
55
+
56
+ document
57
+ end
58
+
59
+ def retrieve klass, criteria, options = {}
60
+ objects = []
61
+
62
+ # MongoDB uses '_id' as its ID field.
63
+ 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
+ }
70
+ end
71
+
72
+ sort_field = options[:attribute]
73
+ sort_direction = options[:direction]
74
+ sort_criteria = [[sort_field, sort_direction]]
75
+ other_options = { limit: options[:limit] }
76
+ if options[:page]
77
+ other_options = other_options.merge skip: (options[:page] - 1) * options[:limit]
78
+ end
79
+
80
+ database.collection(klass.to_s).find(criteria, other_options).sort(sort_criteria).each do |document|
81
+ document[:id] = document.delete("_id")
82
+ objects << document
83
+ end
84
+
85
+ objects
86
+ end
87
+
88
+ def all klass
89
+ retrieve klass, {}, {}
90
+ end
91
+
92
+ def delete object, klass=nil
93
+ id = object.respond_to?(:id) ? object.id : object
94
+ klass ||= object.class
95
+ collection(klass.to_s).remove "_id" => id
96
+ end
97
+
98
+ def update klass, id, new_data
99
+ collection(klass).update({ _id: id }, new_data)
100
+ end
101
+ end
102
+ end