mongocore 0.1.0 → 0.1.1

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.
@@ -0,0 +1,301 @@
1
+ module Mongocore
2
+ module Document
3
+ extend ActiveSupport::Concern
4
+
5
+ # # # # # # # # #
6
+ # The Document module holds data and methods for your models:
7
+ #
8
+ # class Model
9
+ # include Mongocore::Document
10
+ # end
11
+ #
12
+ # Then after that create a model with m = Model.new
13
+ #
14
+ # The Model class, accessible from Model or m.class, holds the data
15
+ # for your models like the schema and the keys.
16
+ #
17
+ # The model instance, m, lets you do operations on a single model
18
+ # like m.save, m.update, m.delete
19
+ #
20
+
21
+ included do
22
+
23
+ # Accessors, everything is writable if you need something dynamic.
24
+ class << self
25
+ attr_accessor :schema, :access, :filters
26
+ end
27
+
28
+ # Schema
29
+ @schema = Mongocore::Schema.new(self)
30
+
31
+ # Access
32
+ @access = Mongocore::Access.new(@schema)
33
+
34
+ # Filters
35
+ @filters = Mongocore::Filters.new(self)
36
+
37
+ # # # # # # # # # # #
38
+ # Instance variables
39
+ # @errors is used for validates
40
+ # @changes keeps track of object changes
41
+ # @saved indicates whether this is saved or not
42
+ #
43
+
44
+ attr_accessor :errors, :changes, :saved
45
+
46
+
47
+ # # # # # # # # # # #
48
+ # The class initializer, called when you write Model.new
49
+ # Pass in attributes you want to set: Model.new(:duration => 60)
50
+ # Defaults are filled in automatically.
51
+ #
52
+
53
+ def initialize(a = {})
54
+ a = a.deep_symbolize_keys
55
+
56
+ # The _id has the BSON object, create new unless it exists
57
+ a[:_id] ? @saved = true : a[:_id] = BSON::ObjectId.new
58
+
59
+ # The errors hash
60
+ @errors = Hash.new{|h, k| h[k] = []}
61
+
62
+ # Defaults
63
+ self.class.schema.defaults.each{|k, v| write(k, v)}
64
+
65
+ # Set the attributes
66
+ a.each{|k, v| write(k, v)}
67
+
68
+ # The changes hash
69
+ @changes = Hash.new{|h, k| h[k] = []}
70
+ end
71
+
72
+ # # # # # # # # # # # #
73
+ # Model methods are called with m = Model.new, m.method_name
74
+ # # # # # # # # # # # # # # # # # #
75
+ #
76
+ # Database methods
77
+ #
78
+
79
+ # Save attributes to db
80
+ def save(o = {})
81
+ # Send :validate => true to validate
82
+ return nil unless valid? if o[:validate]
83
+
84
+ # Create a new query
85
+ filter(:save){mq(self.class, {:_id => @_id}).update(attributes)}
86
+ end
87
+
88
+ # Update document in db
89
+ def update(a = {})
90
+ a.each{|k, v| write(k, v)}; filter(:update){single.update(a)}
91
+ end
92
+
93
+ # Delete a document in db
94
+ def delete
95
+ filter(:delete, false){single.delete}
96
+ end
97
+
98
+ # Run filters before and after accessing the db
99
+ def filter(cmd, saved = true, &block)
100
+ run(:before, cmd); yield.tap{@saved = saved; run(:after, cmd)}
101
+ end
102
+
103
+ # Reload the document from db and update attributes
104
+ def reload
105
+ single.first.tap{|m| attributes = m.attributes}
106
+ end
107
+
108
+ # Set the timestamps if enabled
109
+ def timestamps
110
+ t = Time.now.utc; @updated_at = t; @created_at = t if unsaved?
111
+ end
112
+
113
+
114
+ # # # # # # # # # # # # # # # #
115
+ # Attribute methods
116
+ #
117
+
118
+ # Collect the attributes, pass tags like defined in your model yml
119
+ def attributes(*tags)
120
+ a = {}; self.class.schema.attributes(tags.map(&:to_s)).each{|k| a[k] = read!(k)}; a
121
+ end
122
+
123
+ # Set the attributes
124
+ def attributes=(a)
125
+ a.each{|k, v| write!(k, v)}
126
+ end
127
+
128
+ # Changed?
129
+ def changed?
130
+ changes.any?
131
+ end
132
+
133
+ # JSON format, pass tags as symbols: to_json(:badge, :gun)
134
+ def to_json(*args)
135
+ attributes(*args).to_json
136
+ end
137
+
138
+ # # # # # # # # # # # # # # # #
139
+ # Validation methods
140
+ #
141
+
142
+ # Valid?
143
+ def valid?
144
+ self.class.filters.valid?(self)
145
+ end
146
+
147
+ # Available filters are :save, :update, :delete
148
+ def run(filter, key = nil)
149
+ self.class.filters.run(self, filter, key)
150
+ end
151
+
152
+
153
+ # # # # # # # # # # # # # # # #
154
+ # Convenience methods
155
+ #
156
+
157
+ # Saved? Persisted?
158
+ def saved?; !!@saved; end
159
+
160
+ # Unsaved? New record?
161
+ def unsaved?; !@saved; end
162
+
163
+ # Short cut for setting up a Mongocore::Query object
164
+ def mq(m, q = {}, o = {}, s = {})
165
+ Mongocore::Query.new(m, q, o, {:source => self}.merge(s))
166
+ end
167
+
168
+ # Short cut for simple query with cache buster
169
+ def single(s = {:cache => false})
170
+ mq(self.class, {:_id => @_id}, {}, s)
171
+ end
172
+
173
+
174
+ # # # # # # # # # # # # # # # #
175
+ # Read and write instance variables
176
+ #
177
+
178
+ # Get attribute if access
179
+ def read(key)
180
+ self.class.access.read?(key) ? read!(key) : nil
181
+ end
182
+
183
+ # Get attribute
184
+ def read!(key)
185
+ instance_variable_get("@#{key}")
186
+ end
187
+
188
+ # Set attribute if access
189
+ def write(key, val)
190
+ return nil unless self.class.access.write?(key)
191
+
192
+ # Convert to type as in schema yml
193
+ v = self.class.schema.convert(key, val)
194
+
195
+ # Record change for dirty attributes
196
+ read!(key).tap{|r| @changes[key] = r if v != r} if @changes
197
+
198
+ # Write attribute
199
+ write!(key, v)
200
+ end
201
+
202
+ # Set attribute
203
+ def write!(key, v)
204
+ instance_variable_set("@#{key}", v)
205
+ end
206
+
207
+ # Dynamically read or write attributes
208
+ def method_missing(name, *args, &block)
209
+ # Extract name and write mode
210
+ name =~ /([^=]+)(=)?/
211
+
212
+ # Write or read
213
+ if self.class.schema.keys.has_key?(key = $1.to_sym)
214
+ return write(key, args.first) if $2
215
+ return read(key)
216
+ end
217
+
218
+ # Attributes changed?
219
+ return changes.has_key?($1.to_sym) if key =~ /(.+)_changed\?/
220
+
221
+ # Attributes was
222
+ return changes[$1.to_sym] if key =~ /(.+)_was/
223
+
224
+ # Pass if nothing found
225
+ super
226
+ end
227
+
228
+ end
229
+
230
+
231
+ # # # # # # # # # # # # # # #
232
+ # Class methods are mostly database lookups and filters
233
+ #
234
+
235
+ class_methods do
236
+
237
+ # Find, takes an id or a hash
238
+ def find(*args)
239
+ mq(self, *args)
240
+ end
241
+
242
+ # Count
243
+ def count(*args)
244
+ find(*args).count
245
+ end
246
+
247
+ # First
248
+ def first(*args)
249
+ find(*args).first
250
+ end
251
+
252
+ # Last
253
+ def last
254
+ sort(:_id => -1).limit(1).first
255
+ end
256
+
257
+ # All
258
+ def all(*args)
259
+ find(*args).all
260
+ end
261
+
262
+ # Sort
263
+ def sort(o = {})
264
+ find({}, {}, :sort => o)
265
+ end
266
+
267
+ # Limit
268
+ def limit(n = 1)
269
+ find({}, {}, :limit => n)
270
+ end
271
+
272
+ # # # # # # # # #
273
+ # After, before and validation filters
274
+ # Pass a method name as symbol or a block
275
+ #
276
+ # Possible events for after and before are :save, :update, :delete
277
+ #
278
+
279
+ # After
280
+ def after(*args, &block)
281
+ filters.after[args[0]] << (args[1] || block)
282
+ end
283
+
284
+ # Before
285
+ def before(*args, &block)
286
+ filters.before[args[0]] << (args[1] || block)
287
+ end
288
+
289
+ # Validate
290
+ def validate(*args, &block)
291
+ filters.validate << (args[0] || block)
292
+ end
293
+
294
+ # Short cut for setting up a Mongocore::Query object
295
+ def mq(*args)
296
+ Mongocore::Query.new(*args)
297
+ end
298
+
299
+ end
300
+ end
301
+ end
@@ -0,0 +1,45 @@
1
+ module Mongocore
2
+ class Filters
3
+
4
+ # # # # # # # #
5
+ # The Filters class is responsible for the before, after and validate filters.
6
+ #
7
+
8
+ # Accessors
9
+ attr_accessor :klass, :before, :after, :validate
10
+
11
+ # Init
12
+ def initialize(klass)
13
+ # Save model class
14
+ @klass = klass
15
+
16
+ # The before filters
17
+ @before = Hash.new{|h, k| h[k] = []}
18
+
19
+ # Add timestamp filters if enabled
20
+ [:save, :update].each{|f| @before[f] << :timestamps} if Mongocore.timestamps
21
+
22
+ # The after filters
23
+ @after = Hash.new{|h, k| h[k] = []}
24
+
25
+ # The validators
26
+ @validate = []
27
+ end
28
+
29
+ # Valid?
30
+ def valid?(m)
31
+ @validate.each{|k| call(k, m)}; m.errors.empty?
32
+ end
33
+
34
+ # Available filters are :save, :update, :delete
35
+ def run(m, f, key = nil)
36
+ send(f)[key].each{|k| call(k, m)}
37
+ end
38
+
39
+ # Execute a proc or a method
40
+ def call(k, m)
41
+ k.is_a?(Proc) ? m.instance_eval(&k) : m.send(k)
42
+ end
43
+
44
+ end
45
+ end
@@ -0,0 +1,125 @@
1
+ module Mongocore
2
+ class Query
3
+
4
+ # # # # # # # #
5
+ # The Query class keeps the cursor and handles the connection with the
6
+ # underlying MongoDB database. A new query is created every time you call
7
+ # find, sort, limit, count, update, scopes and associations.
8
+ #
9
+ # Every query can be chained, but only one find is ever done to the database,
10
+ # it's only the parameters that change.
11
+ #
12
+
13
+ attr_accessor :model, :collection, :colname, :query, :options, :store, :cache
14
+
15
+ # These options will be deleted before doing the find
16
+ def initialize(m, q = {}, o = {}, s = {})
17
+ # Support find passing a ID
18
+ q = {:_id => oid(q)} unless q.is_a?(Hash)
19
+
20
+ # Storing model class. The instance can be found in store[:source]
21
+ @model = m
22
+
23
+ # The model name is singular, the collection name is plural
24
+ @colname = "#{m.to_s.downcase}s".to_sym
25
+
26
+ # Storing the Mongo::Collection object
27
+ @collection = Mongocore.db[@colname]
28
+
29
+ # Storing query and options. Sort and limit is stored in options
30
+ s[:sort] ||= {}; s[:limit] ||= 0; s[:chain] ||= []; s[:source] ||= nil
31
+ @query, @options, @store = q, o, s
32
+
33
+ # Set up cache
34
+ @cache = Mongocore::Cache.new(self)
35
+ end
36
+
37
+ # Find. Returns a Mongocore::Query
38
+ def find(q = {}, o = {}, s = {})
39
+ Mongocore::Query.new(@model, @query.merge(q), @options.merge(o), @store.merge(s))
40
+ end
41
+
42
+ # Cursor
43
+ def cursor
44
+ @collection.find(@query, @options).sort(@store[:sort]).limit(@store[:limit])
45
+ end
46
+
47
+ # Update
48
+ def update(a)
49
+ # We do $set on non nil, $unset on nil
50
+ u = {
51
+ :$set => a.select{|k, v| !v.nil?}, :$unset => a.select{|k, v| v.nil?}
52
+ }.delete_if{|k, v| v.empty?}
53
+
54
+ # Update the collection
55
+ @collection.update_one(@query, u, :upsert => true)
56
+ end
57
+
58
+ # Delete
59
+ def delete
60
+ @collection.delete_one(@query)
61
+ end
62
+
63
+ # Count. Returns the number of documents as an integer
64
+ def count
65
+ counter || fetch(:count)
66
+ end
67
+
68
+ # Check if there's a corresponding counter for this count
69
+ def counter(s = @store[:source], c = @store[:chain])
70
+ s.send(%{#{@colname}#{c.present? ? "_#{c.join('_')}" : ''}_count}) rescue nil
71
+ end
72
+
73
+ # Return first document
74
+ def first(doc = nil)
75
+ (doc ||= fetch(:first)) ? @model.new(doc.to_hash) : nil
76
+ end
77
+
78
+ # Return last document
79
+ def last
80
+ sort(:_id => -1).limit(1).first
81
+ end
82
+
83
+ # Return all documents
84
+ def all
85
+ fetch(:to_a).map{|d| first(d)}
86
+ end
87
+
88
+ # Fetch docs, pass type :first, :to_a or :count
89
+ def fetch(t)
90
+ cache.get(t) if Mongocore.cache
91
+
92
+ # Fetch from mongodb and add to cache
93
+ cursor.send(t).tap{|r| cache.set(t, r) if Mongocore.cache}
94
+ end
95
+
96
+ # Sort
97
+ def sort(o = {})
98
+ find(@query, options, @store.tap{store[:sort].merge!(o)})
99
+ end
100
+
101
+ # Limit
102
+ def limit(n = 1)
103
+ find(@query, @options, @store.tap{store[:limit] = n})
104
+ end
105
+
106
+ # Cache key
107
+ def key
108
+ @key ||= "#{@model}#{@query.sort}#{@options.sort}#{@store.values}"
109
+ end
110
+
111
+ # String id to BSON::ObjectId, or create a new by passing nothing or nil
112
+ def oid(id = nil)
113
+ return id if id.is_a?(BSON::ObjectId)
114
+ return BSON::ObjectId.new if !id
115
+ BSON::ObjectId.from_string(id) rescue id
116
+ end
117
+
118
+ # Call and return the scope if it exists
119
+ def method_missing(name, *arguments, &block)
120
+ return @model.send(name, @query, @options, @store.tap{@store[:chain] << name}) if @model.schema.scopes.has_key?(name)
121
+ super
122
+ end
123
+
124
+ end
125
+ end
@@ -0,0 +1,117 @@
1
+ module Mongocore
2
+ class Schema
3
+
4
+ # # # # # # # #
5
+ # The Schema class is responsible for the schema handling.
6
+ #
7
+
8
+ # Accessors
9
+ attr_accessor :klass, :path, :schema, :meta, :accessors, :keys, :many, :scopes, :defaults
10
+
11
+ # Init
12
+ def initialize(klass)
13
+ # Store the document
14
+ @klass = klass
15
+
16
+ # Schema path
17
+ @path = File.join(Mongocore.schema, "#{@klass.to_s.downcase}.yml")
18
+
19
+ # Load schema
20
+ @schema = YAML.load(File.read(@path)).deep_symbolize_keys
21
+
22
+ # Meta
23
+ @meta = @schema[:meta] || {}
24
+
25
+ # Keys
26
+ @keys = @schema[:keys] || {}
27
+
28
+ # Accessors
29
+ (@accessors = @schema[:accessor] || []).each{|a| @klass.send(:attr_accessor, a)}
30
+
31
+ # Many
32
+ (@many = @schema[:many] || {}).each{|k, v| many(k, v)}
33
+
34
+ # Scopes
35
+ (@scopes = @schema[:scopes] || {}).each{|k, v| scope(k, v)}
36
+
37
+ # Defaults and foreign keys
38
+ @defaults = {}; @keys.each{|k, v| foreign(k, v); @defaults[k] = v[:default]}
39
+ end
40
+
41
+ # Get attributes that has these tags
42
+ def attributes(tags)
43
+ (tags[0] ? @keys.select{|k, v| v[:tags] & tags} : @keys).keys
44
+ end
45
+
46
+ # Convert type if val and schema type is set
47
+ def convert(key, val)
48
+ return nil if val.nil?
49
+ type = @keys[key][:type].to_sym rescue nil
50
+ return val if type.nil?
51
+
52
+ # Convert to the same type as in the schema
53
+ return val.to_i if type == :integer
54
+ return val.to_f if type == :float
55
+ return !!val if type == :boolean
56
+ if type == :object_id and !val.is_a?(BSON::ObjectId)
57
+ return BSON::ObjectId.from_string(val) rescue nil
58
+ end
59
+ val
60
+ end
61
+
62
+ # # # # # # # # #
63
+ # Templates for foreign key, many-associations and scopes.
64
+ #
65
+
66
+ # Foreign keys
67
+ def foreign(key, data)
68
+ return if key !~ /(.+)_id/
69
+ t = %Q{
70
+ def #{$1}
71
+ @#{$1} ||= mq(#{$1.capitalize}, :_id => @#{key}).first
72
+ end
73
+
74
+ def #{$1}=(m)
75
+ @#{key} = m._id
76
+ @#{$1} = m
77
+ end
78
+ }
79
+ @klass.class_eval t
80
+ end
81
+
82
+ # Many
83
+ def many(key, data)
84
+ t = %Q{
85
+ def #{key}
86
+ mq(#{key[0..-2].capitalize}, {:#{@klass.to_s.downcase}_id => @_id}, {}, :source => self)
87
+ end
88
+ }
89
+ @klass.class_eval t
90
+ end
91
+
92
+ # Set up scope and insert it
93
+ def scope(key, data)
94
+ # Extract the parameters
95
+ pm = data.delete(:params) || []
96
+
97
+ # Replace data if we are using parameters
98
+ d = %{#{data}}
99
+ pm.each do |a|
100
+ d.scan(%r{(=>"(#{a})(\.[a-z0-9]+)?")}).each do |n|
101
+ d.gsub!(n[0], %{=>#{n[1]}#{n[2]}})
102
+ end
103
+ end
104
+
105
+ # Define the scope method so we can call it
106
+ j = pm.any? ? %{#{pm.join(', ')},} : ''
107
+ t = %Q{
108
+ def #{key}(#{j} q = {}, o = {}, s = {})
109
+ mq(self, q.merge(#{d}), o, {:scope => [:#{key}]}.merge(s))
110
+ end
111
+ }
112
+ @klass.instance_eval t
113
+ end
114
+
115
+
116
+ end
117
+ end
data/lib/mongocore.rb CHANGED
@@ -1,7 +1,12 @@
1
1
  require 'active_support'
2
2
  require 'active_support/core_ext'
3
+ require 'yaml'
4
+ require 'json'
5
+ require 'mongo'
6
+ require 'request_store'
3
7
 
4
8
  module Mongocore
9
+ VERSION = '0.1.1'
5
10
 
6
11
  # # # # # #
7
12
  # Mongocore Ruby Database Driver.
data/models/model.rb ADDED
@@ -0,0 +1,44 @@
1
+ class Model
2
+ include Mongocore::Document
3
+
4
+ # Just define a validate method and call it when needed
5
+ # Use the errors hash to add your errors to it
6
+ validate do
7
+ errors[:duration] << 'duration must be greater than 0' if duration and duration < 1
8
+ errors[:goal] << 'you need a higher goal' if goal and goal < 5
9
+ end
10
+
11
+ attr_accessor :list
12
+
13
+ before :save do
14
+ (@list ||= []) << 'before_save'
15
+ end
16
+
17
+ before :update do
18
+ (@list ||= []) << 'before_update'
19
+ end
20
+
21
+ before :delete do
22
+ (@list ||= []) << 'before_delete'
23
+ end
24
+
25
+ after :save do
26
+ (@list ||= []) << 'after_save'
27
+ end
28
+
29
+ after :update do
30
+ (@list ||= []) << 'after_update'
31
+ end
32
+
33
+ after :delete do
34
+ (@list ||= []) << 'after_delete'
35
+ end
36
+
37
+ # Save, update, delete
38
+ # before :delete, :hello
39
+ # after(:delete){ puts "Hello" }
40
+
41
+ # def hello
42
+ # puts "HELLO"
43
+ # end
44
+ end
data/models/parent.rb ADDED
@@ -0,0 +1,30 @@
1
+ class Parent
2
+ include Mongocore::Document
3
+
4
+ attr_accessor :list
5
+
6
+ before :save do
7
+ (@list ||= []) << 'before_save'
8
+ end
9
+
10
+ before :update do
11
+ (@list ||= []) << 'before_update'
12
+ end
13
+
14
+ before :delete do
15
+ (@list ||= []) << 'before_delete'
16
+ end
17
+
18
+ after :save do
19
+ (@list ||= []) << 'after_save'
20
+ end
21
+
22
+ after :update do
23
+ (@list ||= []) << 'after_update'
24
+ end
25
+
26
+ after :delete do
27
+ (@list ||= []) << 'after_delete'
28
+ end
29
+
30
+ end
data/mongocore.gemspec ADDED
@@ -0,0 +1,22 @@
1
+ Gem::Specification.new do |s|
2
+ s.name = 'mongocore'
3
+ s.version = '0.1.1'
4
+ s.date = '2017-01-05'
5
+ s.summary = "MongoDB ORM implementation on top of the Ruby MongoDB driver"
6
+ s.description = "Does validations, associations, scopes, filters, counter cache, request cache, and nested queries. Using a YAML schema file, which supports default values, data types, and security levels for each key."
7
+ s.authors = ["Fugroup Limited"]
8
+ s.email = 'mail@fugroup.net'
9
+
10
+ s.add_runtime_dependency 'mongo', '~> 2.2'
11
+ s.add_runtime_dependency 'request_store', '>= 0'
12
+ s.add_runtime_dependency 'activesupport', '>= 0'
13
+ s.add_development_dependency 'futest', '>= 0'
14
+
15
+ s.homepage = 'https://github.com/fugroup/mongocore'
16
+ s.license = 'MIT'
17
+
18
+ s.require_paths = ['lib']
19
+ s.files = `git ls-files -z`.split("\x0").reject do |f|
20
+ f.match(%r{^(test|spec|features)/})
21
+ end
22
+ end