mongoscript 0.0.8

Sign up to get free protection for your applications and to get access to all the features.
data/.gitignore ADDED
@@ -0,0 +1,19 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ pkg
13
+ rdoc
14
+ spec/reports
15
+ test/tmp
16
+ test/version_tmp
17
+ tmp
18
+
19
+ .rvmrc
data/Gemfile ADDED
@@ -0,0 +1,32 @@
1
+ source 'http://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in mongoscript.gemspec
4
+ gemspec
5
+
6
+ group :development do
7
+ gem "yard"
8
+ end
9
+
10
+ group :development, :test do
11
+ # ORM
12
+ gem "mongoid", "~> 2.2"
13
+ gem "bson_ext"
14
+
15
+ # Testing infrastructure
16
+ gem 'rspec'
17
+ gem 'mocha'
18
+ gem 'guard'
19
+ gem 'guard-rspec'
20
+ gem "parallel_tests"
21
+ gem "fuubar"
22
+ gem "rake"
23
+
24
+ # testing bundled Javascripts
25
+ gem "jasmine"
26
+
27
+ if RUBY_PLATFORM =~ /darwin/
28
+ # OS X integration
29
+ gem "ruby_gntp"
30
+ gem "rb-fsevent", "~> 0.4.3.1"
31
+ end
32
+ end
data/Guardfile ADDED
@@ -0,0 +1,19 @@
1
+ # A sample Guardfile
2
+ # More info at https://github.com/guard/guard#readme
3
+
4
+ guard 'rspec', :version => 2, :cli => "--colour --format Fuubar" 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
+ # Capybara request specs
17
+ watch(%r{^app/views/(.+)/.*\.(erb|haml)$}) { |m| "spec/requests/#{m[1]}_spec.rb" }
18
+ end
19
+
data/Rakefile ADDED
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env rake
2
+ require "bundler/gem_tasks"
3
+
4
+ begin
5
+ require 'jasmine'
6
+ load 'jasmine/tasks/jasmine.rake'
7
+ rescue LoadError
8
+ task :jasmine do
9
+ abort "Jasmine is not available. In order to run jasmine, you must: (sudo) gem install jasmine"
10
+ end
11
+ end
@@ -0,0 +1,90 @@
1
+ require 'digest/md5'
2
+
3
+ module MongoScript
4
+ # Execute raw code on the Mongo server
5
+ # code_for can
6
+ # longer term, we could store functions on the server
7
+ # and have deploy tasks that delete all and reinstall stored method on deploys
8
+ # see http://neovintage.blogspot.com/2010/07/mongodb-stored-javascript-functions.html
9
+ # 10gen recommends not using stored functions, but that's mainly b/c
10
+ # they should be stored and versioned with other code
11
+ # but a cool 6W gem for automatically clearing and installing such code would meet that objection
12
+ # see http://www.mongodb.org/display/DOCS/Server-side+Code+Execution#Server-sideCodeExecution-Storingfunctionsserverside
13
+
14
+ module Execution
15
+
16
+ LOADED_SCRIPTS = {}
17
+
18
+ class ScriptNotFound < Errno::ENOENT; end
19
+ class NoScriptDirectory < ArgumentError; end
20
+ class ExecutionFailure < RuntimeError; end
21
+
22
+ def self.included(base)
23
+ base.class_eval do
24
+ class << self
25
+ attr_accessor :script_dirs
26
+ end
27
+
28
+ def self.gem_path
29
+ mongoscript = Bundler.load.specs.find {|s| s.name == "mongoscript"}
30
+ File.join(mongoscript.full_gem_path, "lib", "mongoscript", "javascripts")
31
+ end
32
+
33
+ # start out with the scripts provided by the gem
34
+ @script_dirs = [gem_path]
35
+
36
+ extend MongoScript::Execution::ClassMethods
37
+ end
38
+ end
39
+
40
+ module ClassMethods
41
+ # code from stored files
42
+ def code_for(script_name)
43
+ script_name = script_name.to_s
44
+ dir = @script_dirs.find {|d| File.exists?(File.join(d, "#{script_name}.js"))}
45
+ raise ScriptNotFound, "Unable to find script #{script_name}" unless dir
46
+ code = File.read(File.join(dir, "#{script_name}.js"))
47
+ LOADED_SCRIPTS[script_name] ||= code
48
+
49
+ # for future extension
50
+ # code_hash = Digest::MD5.hex_digest(code)
51
+ # LOADED_SCRIPTS[script_name] = {
52
+ # :code => code,
53
+ # :hash => code_hash,
54
+ # :installed => false
55
+ # }
56
+ # code
57
+ end
58
+
59
+ def execute_readonly_routine(script_name, *args)
60
+ execute_readonly_code(code_for(script_name), *args)
61
+ end
62
+
63
+ def execute_readwrite_routine(script_name, *args)
64
+ execute_readwrite_code(code_for(script_name), *args)
65
+ end
66
+
67
+ # raw code
68
+ # note: to pass in Mongo options for the $exec call, like nolock, you need to call execute directly
69
+ # since otherwise we have no way to tell Mongo options from an argument list ending in a JS hash
70
+ def execute_readonly_code(code, *args)
71
+ # for readonly operations, set nolock to true to improve concurrency
72
+ # http://www.mongodb.org/display/DOCS/Server-side+Code+Execution#Server-sideCodeExecution-NotesonConcurrency
73
+ execute(code, args, {:nolock => true})
74
+ end
75
+
76
+ def execute_readwrite_code(code, *args)
77
+ execute(code, args)
78
+ end
79
+
80
+ def execute(code, args = [], options = {})
81
+ # see http://mrdanadams.com/2011/mongodb-eval-ruby-driver/
82
+ result = MongoScript.database.command({:$eval => code, args: resolve_arguments(args)}.merge(options))
83
+ unless result["ok"] == 1.0
84
+ raise ExecutionFailure, "MongoScript.execute JS didn't return {ok: 1.0}! Result: #{result.inspect}"
85
+ end
86
+ result["retval"]
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,40 @@
1
+ function multiquery(queries) {
2
+ var results = {}, collectionObject;
3
+
4
+ // extract all the collection names,
5
+ // so we can return an error if one is provided that doesn't exist
6
+ // since db[invalidName] returns a collection object
7
+ // we could perhaps make this more optimized by building a hash,
8
+ // but it shouldn't be a problem in most cases
9
+ var collectionNames = db["system.namespaces"].find().map(function(ns) { return ns.name }),
10
+ dbName = db.toString(),
11
+ query, base, modifiers, collection;
12
+
13
+ for (var queryName in queries) {
14
+ try {
15
+ query = queries[queryName];
16
+ modifiers = query.modifiers || [];
17
+ collection = query.collection;
18
+
19
+ if (collectionNames.indexOf(dbName + "." + collection) !== -1) {
20
+ base = db[collection].find(query.selector, query.fields || null)
21
+ // apply any number of modifiers, such as sort, limit, etc.
22
+ for (var modifier in modifiers) {
23
+ base = base[modifier](modifiers[modifier])
24
+ }
25
+ results[queryName] = base.toArray();
26
+ }
27
+ else {
28
+ // if the collection doesn't exist, return an error
29
+ // (rather than null -- the DB allows queries on non-existent collections)
30
+ // doing this here saves us a database call in Ruby
31
+ results[queryName] = {error: "Unable to locate collection " + (collection ? collection.toString() : collection)}
32
+ }
33
+ }
34
+ catch(e) {
35
+ results[queryName] = {error: e};
36
+ }
37
+ }
38
+
39
+ return results;
40
+ }
@@ -0,0 +1,171 @@
1
+ module MongoScript
2
+ module Multiquery
3
+
4
+ class QueryFailedError < RuntimeError
5
+ # The original query whose execution failed.
6
+ attr_accessor :query_parameters
7
+ # The name of the original query
8
+ attr_accessor :query_name
9
+ # The response from the multiquery Javascript.
10
+ attr_accessor :db_response
11
+
12
+ def initialize(name, query, response)
13
+ @query_name = name
14
+ @query_parameters = query
15
+ @db_response = response
16
+ super("Query #{@query_name} failed with the following response: #{response.inspect}")
17
+ # set the backtrace to everything that's going on except this initialize method
18
+ set_backtrace(caller[1...caller.length])
19
+ end
20
+ end
21
+
22
+ # @private
23
+ def self.included(base)
24
+ base.class_eval do
25
+ extend MongoScript::Multiquery::ClassMethods
26
+ end
27
+ end
28
+
29
+ module ClassMethods
30
+
31
+ # Runs multiple find queries at once,
32
+ # returning the results keyed to the original query names.
33
+ # If a query produces an error, its result will be a QueryFailedError object
34
+ # with the appropriate details; other queries will be unaffected.
35
+ #
36
+ # @example
37
+ # MongoScript.multiquery({
38
+ # # simplest form -- the name is used as the collection to be queried
39
+ # :cars => {:query => {:_id => {$in: my_ids}}},
40
+ # # the name can also be arbitrary if you explicitly specify a collection
41
+ # # (allowing you to query the same collection twice)
42
+ # :my_cool_query => {:collection => :books, :objects }
43
+ # # you can also pass in Mongoid criteria
44
+ # :planes => Plane.where(manufacturer: "Boeing").sort("created_at desc").only(:_id),
45
+ # })
46
+ # => {
47
+ # # results get automatically turned into
48
+ # :cars => [#<Car: details>, #<Car: details>],
49
+ # :my_cool_query => [#<Book: details>],
50
+ # :planes => #<QueryFailedError>
51
+ # }
52
+ #
53
+ # @raises ArgumentError if the input isn't valid (see #normalize_queries) (see #validate_queries!)
54
+ #
55
+ # @returns Hash a set of database results/errors for each query
56
+ def multiquery(queries)
57
+ # don't do anything if we don't get any queries
58
+ return {} if queries == {}
59
+
60
+ # resolve all the
61
+ queries = normalize_queries(queries)
62
+ validate_queries!(queries)
63
+ results = MongoScript.execute_readonly_routine("multiquery", mongoize_queries(queries))
64
+ process_results(results, queries)
65
+ end
66
+
67
+ # Standardize a set of queries into a form we can use.
68
+ # Specifically, ensure each query has a database collection
69
+ # and an ORM class, and resolve any ORM criteria into hashes.
70
+ #
71
+ # @param queries a set of query_name => hash_or_orm_criteria pairs
72
+ #
73
+ # @raises ArgumentError if the query details can't be processed (aren't a hash or Mongoid::Criteria)
74
+ #
75
+ # @returns [Hash] a set of hashes that can be fed to mongoize_queries
76
+ # and later used for processing
77
+ def normalize_queries(queries)
78
+ # need to also ensure we have details[:klass]
79
+ queries.inject({}) do |normalized_queries, data|
80
+ name, details = data
81
+
82
+ if details.is_a?(Hash)
83
+ # duplicate the details so we don't make changes to the original query data
84
+ details = details.dup.with_indifferent_access
85
+
86
+ # if no collection is specified, assume it's the same as the name
87
+ details[:collection] ||= name
88
+
89
+ # ensure that we know which class the collection maps to
90
+ # so we can rehydrate the resulting data
91
+ unless details[:klass]
92
+ expected_class_name = details[:collection].to_s.singularize.titlecase
93
+ # if the class doesn't exist, this'll be false (and we'll raise an error later)
94
+ details[:klass] = Object.const_defined?(expected_class_name) && Object.const_get(expected_class_name)
95
+ end
96
+ elsif processable_into_parameters?(details)
97
+ # process Mongo ORM selectors into JS-compatible hashes
98
+ details = MongoScript.build_multiquery_parameters(details)
99
+ else
100
+ raise ArgumentError, "Invalid selector type provided to multiquery for #{name}, expected hash or Mongoid::Criteria, got #{data.class}"
101
+ end
102
+
103
+ normalized_queries[name] = details
104
+ normalized_queries.with_indifferent_access
105
+ end
106
+ end
107
+
108
+ # Validate that all the queries are well formed.
109
+ # We could do this when building them,
110
+ # but doing it afterward allows us to present a single, comprehensive error message
111
+ # (in case multiple queries have problems).
112
+ #
113
+ # @param queries a set of normalized queries
114
+ #
115
+ # @raises ArgumentError if any of the queries are missing Ruby class or database collection info
116
+ #
117
+ # @returns true if the queries are well-formatted
118
+ def validate_queries!(queries)
119
+ errors = {:collection => [], :klass => []}
120
+ queries.each_pair do |name, details|
121
+ errors[:collection] << name unless details[:collection]
122
+ errors[:klass] << name unless details[:klass]
123
+ end
124
+ error_text = ""
125
+ error_text += "Missing collection details: #{errors[:collection].join(", ")}." if errors[:collection].length > 0
126
+ error_text += "Missing Ruby class details: #{errors[:klass].join(", ")}." if errors[:klass].length > 0
127
+ if error_text.length > 0
128
+ raise ArgumentError, "Unable to execute multiquery. #{error_text}"
129
+ end
130
+ true
131
+ end
132
+
133
+ # Prepare normalized queries for use in Mongo.
134
+ # Currently, this involves deleting parameters that can't be
135
+ # turned into BSON.
136
+ # (We can't act directly on the normalized queries,
137
+ # since they contain data used later to rehydrate models.)
138
+ #
139
+ # @param queries previously normalized queries
140
+ #
141
+ # @returns [Hash] a set of queries that can be passed to MongoScript#execute
142
+ def mongoize_queries(queries)
143
+ # delete any information not needed by/compatible with Mongoid execution
144
+ mongoized_queries = queries.dup
145
+ mongoized_queries.each_pair do |name, details|
146
+ # have to dup the query details to avoid changing the original hashes
147
+ mongoized_queries[name] = details.dup.tap {|t| t.delete(:klass) }
148
+ end
149
+ end
150
+
151
+ # If any results failed, create appropriate QueryFailedError objects.
152
+ #
153
+ # @param results the results of the multiquery Javascript routine
154
+ # @param queries the original queries
155
+ #
156
+ # @returns the multiquery results, with any error hashes replaced by proper Ruby objects
157
+ def process_results(results, queries)
158
+ processed_results = {}
159
+ results.each_pair do |name, response|
160
+ processed_results[name] = if response.is_a?(Hash) && response["error"]
161
+ QueryFailedError.new(name, queries[name], response)
162
+ elsif response
163
+ # turn all the individual responses into real objects
164
+ response.map {|data| MongoScript.rehydrate(queries[name][:klass], data)}
165
+ end
166
+ end
167
+ processed_results
168
+ end
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,82 @@
1
+ module MongoScript
2
+ module ORM
3
+ module MongoidAdapter
4
+
5
+ def self.included(base)
6
+ base.class_eval do
7
+ extend MongoScript::ORM::MongoidAdapter::ClassMethods
8
+ end
9
+ end
10
+
11
+ module ClassMethods
12
+ def database
13
+ Mongoid::Config.database
14
+ end
15
+
16
+ def rehydrate(klass, data)
17
+ Mongoid::Factory.from_db(klass, data)
18
+ end
19
+
20
+ # This resolves an array of Javascript arguments into
21
+ # hashes that can be used with MongoScript.execute.
22
+ # In particular, it turns Mongoid complex criteria (_id.in => ...)
23
+ # into regular Mongo-style hashes.
24
+ #
25
+ # @params args an array of arguments
26
+ #
27
+ # @returns an array in which all hashes have been expanded.
28
+ def resolve_arguments(args)
29
+ args.map {|arg| arg.is_a?(Hash) ? resolve_complex_criteria(arg) : arg}
30
+ end
31
+
32
+ # Recursively make sure any Mongoid complex critiera (like :_id.in => ...)
33
+ # are expanded into regular hashes (see criteria_helpers.rb in Mongoid).
34
+ # The built-in function only goes one level in, which doesn't work
35
+ # for hashes containing multiple levels with Mongoid helpers.
36
+ # (Am I missing where Mongoid handles this?)
37
+ #
38
+ # @note this doesn't (yet) check for circular dependencies, so don't use them!
39
+ #
40
+ # @params hash a hash that can contain Mongo-style
41
+ #
42
+ # @returns a hash that maps directly to Mongo query parameters,
43
+ # which can be used by the Mongo DB driver
44
+ def resolve_complex_criteria(hash)
45
+ result = {}
46
+ hash.expand_complex_criteria.each_pair do |k, v|
47
+ result[k] = v.is_a?(Hash) ? resolve_complex_criteria(v) : v
48
+ end
49
+ result
50
+ end
51
+
52
+ # Turn a Mongoid::Criteria into a hash useful for multiquery.
53
+ #
54
+ # @param criteria any Mongoid::Criteria object
55
+ #
56
+ # @returns a hash containing the extracted information ready for use in multiquery
57
+ def build_multiquery_parameters(criteria)
58
+ if criteria.is_a?(Mongoid::Criteria)
59
+ opts = criteria.options.dup
60
+ # make sure the sort options are in a Mongo-compatible format
61
+ opts[:sort] = Mongo::Support::array_as_sort_parameters(opts[:sort] || [])
62
+ {
63
+ :selector => criteria.selector,
64
+ :collection => criteria.collection.name,
65
+ # used for rehydration
66
+ :klass => criteria.klass,
67
+ # fields are specified as a second parameter to the db[collection].find JS call
68
+ :fields => opts[:fields],
69
+ # everything in the options besides fields should be a modifier
70
+ # i.e. a function that can be applied via a method to a db[collection].find query
71
+ :modifiers => opts.tap { |o| o.delete(:fields) }
72
+ }
73
+ end
74
+ end
75
+
76
+ def processable_into_parameters?(object)
77
+ object.is_a?(Mongoid::Criteria)
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,11 @@
1
+ # TBD: do we need this?
2
+ module MongoidDocumentMethods
3
+ module ClassMethods
4
+ def find_by_javascript(script_name, *args)
5
+ args = args.unshift(script_name)
6
+ # get a bunch of results via a Mongoid Javascript call,
7
+ # then turn each hash result into a Mongoid document
8
+ MongoScript.execute_readonly_routine(*args).map { |hash| rehydrate(self, hash) }
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,3 @@
1
+ module Mongoscript
2
+ VERSION = "0.0.8"
3
+ end
@@ -0,0 +1,28 @@
1
+ require "mongoscript/orm/mongoid_adapter"
2
+ require "mongoscript/version"
3
+ require "mongoscript/execution"
4
+ require 'mongoscript/multiquery'
5
+
6
+ module MongoScript
7
+ class NoORMError < StandardError; end
8
+
9
+ # Returns the MongoScript adapter module for
10
+ # whichever Mongo ORM is loaded (Mongoid, MongoMapper).
11
+ #
12
+ # @note: currently only Mongoid is supported.
13
+ #
14
+ # @raises NoORMError if no ORM module can be detected.
15
+ #
16
+ # @returns MongoScript::ORM::Mongoid if Mongoid is detected
17
+ def self.orm_adapter
18
+ if const_defined? "Mongoid"
19
+ MongoScript::ORM::MongoidAdapter
20
+ else
21
+ raise NoORMError, "Unable to locate Mongoid!"
22
+ end
23
+ end
24
+
25
+ include orm_adapter
26
+ include Execution
27
+ include Multiquery
28
+ end
@@ -0,0 +1,20 @@
1
+ # -*- encoding: utf-8 -*-
2
+ require File.expand_path('../lib/mongoscript/version', __FILE__)
3
+
4
+ Gem::Specification.new do |gem|
5
+ gem.authors = ["Alex Koppel"]
6
+ gem.email = ["alex+git@alexkoppel.com"]
7
+ gem.description = %q{An experimental Ruby library for running serverside Javascript in MongoDB.}
8
+ gem.summary = %q{An experimental Ruby library for running serverside Javascript in MongoDB.}
9
+ gem.homepage = ""
10
+
11
+ gem.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
12
+ gem.files = `git ls-files`.split("\n")
13
+ gem.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
14
+ gem.name = "mongoscript"
15
+ gem.require_paths = ["lib"]
16
+ gem.version = Mongoscript::VERSION
17
+
18
+ # we use activesupport's with_indifferent_access
19
+ gem.add_runtime_dependency(%q<activesupport>, ["~> 3.0"])
20
+ end
data/readme.md ADDED
@@ -0,0 +1,22 @@
1
+ #MongoScript#
2
+
3
+ An experimental library designed to make server-side Javascript execution in MongoDB easy, fun, and profitable.
4
+
5
+ ###Hey kids, this toy is for novelty use only###
6
+
7
+ This library isn't "experimental" only in the sense that I'm trying out new code to see where it leads -- MongoScript is a thought experiment more than a production helper, for one simple reason. As the MongoDB [server-side code execution](http://www.mongodb.org/display/DOCS/Server-side+Code+Execution) puts it:
8
+
9
+ > Note also that [Javascript] eval doesn't work with sharding. If you expect your system to later be sharded, it's probably best to avoid eval altogether.
10
+
11
+ If you're building a small-to-medium-sized system that you know will never grow huge, you may be able to use MongoScript to very cool effect. I wouldn't recommend it for any kind of "we're gonna scale it to the moon!" kind of product, though. Disentangling Javascript functions and reimplementing them in Ruby so you can shard your growing database doesn't sound like my kind of fun.
12
+
13
+ ###Performance###
14
+
15
+ There's also no guarantee that using Javascript to perform semi-complex queries is actually worth it -- Javascript can be significantly slower. I'll post some performance statistics soon.
16
+
17
+ ###The cool stuff###
18
+
19
+ You understand all that, you just want to hack some server-side code for the fun of it? Let's get to it!
20
+
21
+
22
+ ####More readme coming soon!####