relaxo-query-server 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/README.md ADDED
@@ -0,0 +1,133 @@
1
+ Relaxo Query Server
2
+ ===================
3
+
4
+ * Author: Samuel G. D. Williams (<http://www.oriontransfer.co.nz>)
5
+ * Copyright (C) 2012 Samuel G. D. Williams.
6
+ * Released under the MIT license.
7
+
8
+ The Relaxo Query Server implements the CouchDB Query Server protocol for CouchDB 1.1.0+. It provides a comprehensive Ruby-style view server along with full support for Design Document based processing.
9
+
10
+ *This software is currently under development and not ready for stable production usage.*
11
+
12
+ Installation
13
+ ------------
14
+
15
+ Install the ruby gem as follows:
16
+
17
+ sudo gem install relaxo-query-server
18
+
19
+ Edit your etc/couchdb/local.ini and add the following:
20
+
21
+ [query_servers]
22
+ relaxo-ruby = relaxo-query-server
23
+
24
+ Make sure the `relaxo-query-server` executable is accessible from `$PATH`.
25
+
26
+ To build and install the gem from source:
27
+
28
+ cd build/
29
+ sudo GEM=gem1.9 rake1.9 install_gem
30
+
31
+ Usage
32
+ -----
33
+
34
+ ### Mapping Function ###
35
+
36
+ Select documents of `type == 'user'`:
37
+
38
+ lambda do |document|
39
+ if document['type'] == 'user'
40
+ emit(document['_id'])
41
+ end
42
+ end
43
+
44
+ ### Reduction Function ###
45
+
46
+ Calculate the sum:
47
+
48
+ lambda do |keys,values,rereduce|
49
+ values.inject &:+
50
+ end
51
+
52
+ ### Design Document ###
53
+
54
+ A simple application:
55
+
56
+ # design.yaml
57
+ - _id: "_design/users"
58
+ language: 'relaxo-ruby'
59
+ views:
60
+ service:
61
+ map: |
62
+ lambda do |doc|
63
+ emit(doc['_id']) if doc['type'] == 'user'
64
+ end
65
+ validate_doc_update: |
66
+ lambda do |new_document, old_document, user_context|
67
+ if !user_context.admin && new_document['admin'] == true
68
+ raise ValidationError.new(:forbidden => "Cannot create admin user!")
69
+ end
70
+ end
71
+ updates:
72
+ user: |
73
+ lambda do |doc, request|
74
+ doc['updated_date'] = Date.today.iso8061; [doc, 'OK']
75
+ end
76
+ lists:
77
+ user: |
78
+ lambda do |head, request|
79
+ send "<ul>"
80
+ each do |user|
81
+ send "<li>" + user['name'] + "</li>"
82
+ end
83
+ send "</ul>"
84
+ end
85
+ filters:
86
+ regular_users: |
87
+ lambda do |doc, request|
88
+ doc['admin'] == false
89
+ end
90
+ shows:
91
+ user: |
92
+ lambda do |doc, request|
93
+ {
94
+ :code => 200,
95
+ :headers => {"Content-Type" => "text/html"}},
96
+ :body => "User: #{doc['name']}"
97
+ }
98
+ end
99
+
100
+ The previous `design.yaml` document can be loaded using the `relaxo` client command:
101
+
102
+ relaxo http://localhost:5984/test design.yaml
103
+
104
+ To Do
105
+ -----
106
+
107
+ - Improve documentation, including better sample code.
108
+ - More tests, including performance benchmarks.
109
+ - Explore `parse_function` to improve performance of code execution by caching functions.
110
+ - Helper methods for commonly used mapping functions.
111
+
112
+ License
113
+ -------
114
+
115
+ Copyright (c) 2010, 2011 Samuel G. D. Williams. <http://www.oriontransfer.co.nz>
116
+
117
+ Permission is hereby granted, free of charge, to any person obtaining a copy
118
+ of this software and associated documentation files (the "Software"), to deal
119
+ in the Software without restriction, including without limitation the rights
120
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
121
+ copies of the Software, and to permit persons to whom the Software is
122
+ furnished to do so, subject to the following conditions:
123
+
124
+ The above copyright notice and this permission notice shall be included in
125
+ all copies or substantial portions of the Software.
126
+
127
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
128
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
129
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
130
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
131
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
132
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
133
+ THE SOFTWARE.
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'relaxo/query_server'
4
+ require 'optparse'
5
+
6
+ OPTIONS = {
7
+ :safe => 4,
8
+ }
9
+
10
+ ARGV.options do |o|
11
+ script_name = File.basename($0)
12
+
13
+ o.set_summary_indent(' ')
14
+ o.banner = "Usage: #{script_name} [options] [directory]"
15
+ o.define_head "This program is designed to be used with CouchDB."
16
+
17
+ o.separator ""
18
+ o.separator "Help and Copyright information"
19
+
20
+ o.on("--safe [level]", "Set the ruby $SAFE level to product the execution environment.") do |level|
21
+ OPTIONS[:safe] = level.to_i
22
+ end
23
+
24
+ o.separator ""
25
+
26
+ o.on_tail("--copy", "Display copyright information") do
27
+ puts "#{script_name}. Copyright (c) 2012 Samuel Williams. Released under the MIT license."
28
+ puts "See http://www.oriontransfer.co.nz/ for more information."
29
+
30
+ exit
31
+ end
32
+
33
+ o.on_tail("-h", "--help", "Show this help message.") do
34
+ puts o
35
+ exit
36
+ end
37
+ end.parse!
38
+
39
+ Relaxo::QueryServer.run!(OPTIONS)
@@ -0,0 +1,40 @@
1
+ # Copyright (c) 2012 Samuel G. D. Williams. <http://www.oriontransfer.co.nz>
2
+ #
3
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ # of this software and associated documentation files (the "Software"), to deal
5
+ # in the Software without restriction, including without limitation the rights
6
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ # copies of the Software, and to permit persons to whom the Software is
8
+ # furnished to do so, subject to the following conditions:
9
+ #
10
+ # The above copyright notice and this permission notice shall be included in
11
+ # all copies or substantial portions of the Software.
12
+ #
13
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19
+ # THE SOFTWARE.
20
+
21
+ require 'relaxo/query_server/context'
22
+ require 'relaxo/query_server/shell'
23
+
24
+ # Provide useful standard functionality:
25
+ require 'bigdecimal'
26
+ require 'date'
27
+
28
+ module Relaxo
29
+ module QueryServer
30
+ # Run the query server using `$stdin` and `$stdout` for communication.
31
+ def self.run!(options = {})
32
+ shell = Shell.new($stdin, $stdout)
33
+ context = Context.new(shell, options)
34
+
35
+ shell.run do |command|
36
+ context.run(command)
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,101 @@
1
+ # Copyright (c) 2012 Samuel G. D. Williams. <http://www.oriontransfer.co.nz>
2
+ #
3
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ # of this software and associated documentation files (the "Software"), to deal
5
+ # in the Software without restriction, including without limitation the rights
6
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ # copies of the Software, and to permit persons to whom the Software is
8
+ # furnished to do so, subject to the following conditions:
9
+ #
10
+ # The above copyright notice and this permission notice shall be included in
11
+ # all copies or substantial portions of the Software.
12
+ #
13
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19
+ # THE SOFTWARE.
20
+
21
+ require 'relaxo/query_server/shell'
22
+ require 'relaxo/query_server/mapper'
23
+ require 'relaxo/query_server/reducer'
24
+ require 'relaxo/query_server/designer'
25
+
26
+ module Relaxo
27
+ module QueryServer
28
+ class CompilationError < StandardError
29
+ end
30
+
31
+ # A query server context includes all state required for implementing the query server protocol.
32
+ class Context
33
+ # Given a function as text, and an execution scope, return a callable object.
34
+ def parse_function(text, scope, filename = 'query-server')
35
+ safe_level = @options[:safe] || 0
36
+
37
+ function = lambda { $SAFE = safe_level; eval(text, scope.send(:binding), filename) }.call
38
+
39
+ unless function.respond_to? :call
40
+ raise CompilationError.new('Expression does not evaluate to procedure!')
41
+ end
42
+
43
+ return function
44
+ end
45
+
46
+ def initialize(shell, options = {})
47
+ @shell = shell
48
+ @options = options
49
+
50
+ @mapper = Mapper.new(self)
51
+ @reducer = Reducer.new(self)
52
+ @designer = Designer.new(self)
53
+
54
+ @config = {}
55
+ end
56
+
57
+ attr :config
58
+ attr :shell
59
+
60
+ # Return an error structure from the given exception.
61
+ def error_for_exception(exception)
62
+ ["error", exception.class.to_s, exception.message, exception.backtrace]
63
+ end
64
+
65
+ # Process a single command as per the query server protocol.
66
+ def run(command)
67
+ case command[0]
68
+ # ** Map functionality
69
+ when 'add_fun'
70
+ @mapper.add_function command[1]; true
71
+ when 'map_doc'
72
+ @mapper.map command[1]
73
+ when 'reset'
74
+ @config = command[1] || {}
75
+ @mapper = Mapper.new(self); true
76
+
77
+ # ** Reduce functionality
78
+ when 'reduce'
79
+ @reducer.reduce command[1], command[2]
80
+ when 'rereduce'
81
+ @reducer.rereduce command[1], command[2]
82
+
83
+ # ** Design document functionality
84
+ when 'ddoc'
85
+ if command[1] == 'new'
86
+ @designer.create(command[2], command[3]); true
87
+ else
88
+ @designer.run(command[1], command[2], command[3])
89
+ end
90
+ end
91
+ rescue Exception => exception
92
+ error_for_exception(exception)
93
+ end
94
+
95
+ # Write a log message back to the shell.
96
+ def log(message)
97
+ @shell.write_object ['log', message]
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,249 @@
1
+ # Copyright (c) 2012 Samuel G. D. Williams. <http://www.oriontransfer.co.nz>
2
+ #
3
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ # of this software and associated documentation files (the "Software"), to deal
5
+ # in the Software without restriction, including without limitation the rights
6
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ # copies of the Software, and to permit persons to whom the Software is
8
+ # furnished to do so, subject to the following conditions:
9
+ #
10
+ # The above copyright notice and this permission notice shall be included in
11
+ # all copies or substantial portions of the Software.
12
+ #
13
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19
+ # THE SOFTWARE.
20
+
21
+ require 'relaxo/query_server/shell'
22
+ require 'relaxo/query_server/mapper'
23
+ require 'relaxo/query_server/reducer'
24
+
25
+ module Relaxo
26
+ module QueryServer
27
+ # Indicates that a validation error has occured.
28
+ class ValidationError < StandardError
29
+ def initialize(details)
30
+ super "Validation failed!"
31
+
32
+ @details = details
33
+ end
34
+
35
+ # The details of the validation error, typically:
36
+ # {:forbidden => "Message"}
37
+ # or
38
+ # {:unauthorized => "Message"}
39
+ attr :details
40
+ end
41
+
42
+ # Indicates that the request was invalid for some reason (e.g. lacking appropriate authentication)
43
+ class InvalidRequestError < StandardError
44
+ def initilize(message, code = 400, details = {})
45
+ super message
46
+
47
+ @code = 400
48
+ @details = details
49
+ end
50
+
51
+ # Returns a response suitable for passing back to the client.
52
+ def to_response
53
+ @details.merge(:code => @code)
54
+ end
55
+ end
56
+
57
+ # Supports the `list` action which consumes rows and outputs encoded text in chunks.
58
+ class ListRenderer < Process
59
+ def initialize(context, function)
60
+ super(context, function)
61
+
62
+ @started = false
63
+ @fetched_row = false
64
+ @start_response = {:headers => {}}
65
+ @chunks = []
66
+ end
67
+
68
+ def run(head, request)
69
+ super(head, request)
70
+
71
+ # Ensure that at least one row is read from input:
72
+ get_row unless @fetched_row
73
+
74
+ return ["end", @chunks]
75
+ end
76
+
77
+ def send(chunk)
78
+ @chunks << chunk
79
+ false
80
+ end
81
+
82
+ def each
83
+ while row = get_row
84
+ yield row
85
+ end
86
+ end
87
+
88
+ def get_row
89
+ flush
90
+
91
+ row = @context.shell.read_object
92
+ @fetched_row = true
93
+
94
+ case command = row[0]
95
+ when "list_row"
96
+ row[1]
97
+ when "list_end"
98
+ false
99
+ else
100
+ raise RuntimeError.new("Input is not a row!")
101
+ end
102
+ end
103
+
104
+ def start(response)
105
+ raise RuntimeError.new("List already started!") if @started
106
+
107
+ @start_response = response
108
+ end
109
+
110
+ def flush
111
+ if @started
112
+ @context.shell.write_object ["chunks", @chunks]
113
+ else
114
+ @context.shell.write_object ["start", @chunks, @start_response]
115
+
116
+ @started = true
117
+ end
118
+
119
+ @chunks = []
120
+ end
121
+ end
122
+
123
+ # Represents a design document which includes a variety of functionality for processing documents.
124
+ class DesignDocument
125
+ VALIDATED = 1
126
+
127
+ def initialize(context, name, attributes = {})
128
+ @context = context
129
+
130
+ @name = name
131
+ @attributes = attributes
132
+ end
133
+
134
+ # Lookup the given key in the design document's attributes.
135
+ def [] key
136
+ @attributes[key]
137
+ end
138
+
139
+ # Runs the given function with the given arguments.
140
+ def run(function, arguments)
141
+ action = function[0]
142
+ function = function_for(function)
143
+
144
+ self.send(action, function, *arguments)
145
+ end
146
+
147
+ # Implements the `filters` action.
148
+ def filters(function, documents, request)
149
+ results = documents.map{|document| !!function.call(document, request)}
150
+
151
+ return [true, results]
152
+ end
153
+
154
+ # Implements the `shows` action.
155
+ def shows(function, document, request)
156
+ response = function.call(document, request)
157
+
158
+ return ["resp", wrap_response(response)]
159
+ end
160
+
161
+ # Implements the `updates` action.
162
+ def updates(function, document, request)
163
+ raise InvalidRequestError.new("Unsupported method #{request['method']}") unless request['method'] == 'POST'
164
+
165
+ document, response = function.call(document, request)
166
+
167
+ return ["up", document, wrap_response(response)]
168
+ rescue InvalidRequestError => error
169
+ return ["up", null, error.to_response]
170
+ end
171
+
172
+ # Implements the `lists` action.
173
+ def lists(function, head, request)
174
+ ListRenderer.new(@context, function).run(head, request)
175
+ end
176
+
177
+ # Implements the `validates_doc_update` action.
178
+ def validates(function, new_document, old_document, user_context)
179
+ ValidationProcess.new(@context, function).run(new_document, old_document, user_context)
180
+
181
+ # Unless ValidationError was raised, we are okay.
182
+ return VALIDATED
183
+ rescue ValidationError => error
184
+ error.details
185
+ end
186
+
187
+ alias validate_doc_update validates
188
+
189
+ # Ensures that the response is the correct form.
190
+ def wrap_response(response)
191
+ String === response ? {"body" => response} : response
192
+ end
193
+
194
+ # Looks a up a function given a key path into the design document.
195
+ def function_for(path)
196
+ parent = self
197
+
198
+ function = path.inject(parent) do |current, key|
199
+ parent = current
200
+
201
+ throw ArgumentError.new("Invalid function name #{path.join(".")}") unless current
202
+
203
+ current[key]
204
+ end
205
+
206
+ # Compile the function if required:
207
+ if String === function
208
+ parent[path.last] = @context.parse_function(function, self, 'design-document')
209
+ else
210
+ function
211
+ end
212
+ end
213
+ end
214
+
215
+ # Implements the design document state and interface.
216
+ class Designer
217
+ def initialize(context)
218
+ @context = context
219
+ @documents = {}
220
+ end
221
+
222
+ # Create a new design document.
223
+ #
224
+ # @param [String] name
225
+ # The name of the design document.
226
+ # @param [Hash] attributes
227
+ # The contents of the design document.
228
+ def create(name, attributes)
229
+ @documents[name] = DesignDocument.new(@context, name, attributes)
230
+ end
231
+
232
+ # Run a function on a given design document.
233
+ #
234
+ # @param [String] name
235
+ # The name of the design document.
236
+ # @param [Array] function
237
+ # A key path to the function to execute.
238
+ # @param [Array] arguments
239
+ # The arguments to provide to the function.
240
+ def run(name, function, arguments)
241
+ document = @documents[name]
242
+
243
+ raise ArgumentError.new("Invalid document name #{name}") unless document
244
+
245
+ document.run(function, arguments)
246
+ end
247
+ end
248
+ end
249
+ end
@@ -0,0 +1,68 @@
1
+ # Copyright (c) 2012 Samuel G. D. Williams. <http://www.oriontransfer.co.nz>
2
+ #
3
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ # of this software and associated documentation files (the "Software"), to deal
5
+ # in the Software without restriction, including without limitation the rights
6
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ # copies of the Software, and to permit persons to whom the Software is
8
+ # furnished to do so, subject to the following conditions:
9
+ #
10
+ # The above copyright notice and this permission notice shall be included in
11
+ # all copies or substantial portions of the Software.
12
+ #
13
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19
+ # THE SOFTWARE.
20
+
21
+ require 'relaxo/query_server/process'
22
+
23
+ module Relaxo
24
+ module QueryServer
25
+ # Supports `Mapper` by providing a context with an `emit` method that collects the results from the mapping function.
26
+ class MappingProcess < Process
27
+ def initialize(context, function)
28
+ super(context, function)
29
+ @results = []
30
+ end
31
+
32
+ # Emit a result
33
+ def emit(key, value = nil)
34
+ @results << [key, value]
35
+ end
36
+
37
+ def run(*args)
38
+ begin
39
+ call(*args)
40
+ rescue Exception => exception
41
+ # If the mapping function throws an error, report the error for this document:
42
+ return @context.error_for_exception(exception)
43
+ end
44
+
45
+ return @results
46
+ end
47
+ end
48
+
49
+ class Mapper
50
+ def initialize(context)
51
+ @context = context
52
+ @functions = []
53
+ end
54
+
55
+ # Adds a function by parsing the text, typically containing a textual representation of a lambda.
56
+ def add_function text
57
+ @functions << @context.parse_function(text, self)
58
+ end
59
+
60
+ # Map a document to a set of results by appling all functions.
61
+ def map(document)
62
+ @functions.map do |function|
63
+ MappingProcess.new(@context, function).run(document)
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,43 @@
1
+ # Copyright (c) 2012 Samuel G. D. Williams. <http://www.oriontransfer.co.nz>
2
+ #
3
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ # of this software and associated documentation files (the "Software"), to deal
5
+ # in the Software without restriction, including without limitation the rights
6
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ # copies of the Software, and to permit persons to whom the Software is
8
+ # furnished to do so, subject to the following conditions:
9
+ #
10
+ # The above copyright notice and this permission notice shall be included in
11
+ # all copies or substantial portions of the Software.
12
+ #
13
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19
+ # THE SOFTWARE.
20
+
21
+ module Relaxo
22
+ module QueryServer
23
+ # A simple high level functional process attached to a given `context`. Typically used for executing functions from map/reduce and design documents.
24
+ class Process
25
+ def initialize(context, function)
26
+ @context = context
27
+ @function = function
28
+ end
29
+
30
+ def log(message)
31
+ @context.log message
32
+ end
33
+
34
+ def call(*args)
35
+ instance_exec *args, &@function
36
+ end
37
+
38
+ def run(*args)
39
+ call(*args)
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,94 @@
1
+ # Copyright (c) 2012 Samuel G. D. Williams. <http://www.oriontransfer.co.nz>
2
+ #
3
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ # of this software and associated documentation files (the "Software"), to deal
5
+ # in the Software without restriction, including without limitation the rights
6
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ # copies of the Software, and to permit persons to whom the Software is
8
+ # furnished to do so, subject to the following conditions:
9
+ #
10
+ # The above copyright notice and this permission notice shall be included in
11
+ # all copies or substantial portions of the Software.
12
+ #
13
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19
+ # THE SOFTWARE.
20
+
21
+ require 'relaxo/query_server/process'
22
+
23
+ module Relaxo
24
+ module QueryServer
25
+ # Call the reduction function, and if it fails, respond with an error message.
26
+ class ReducingProcess < Process
27
+ def evaluate(*args)
28
+ begin
29
+ call(*args)
30
+ rescue Exception => exception
31
+ # If the mapping function throws an error, report the error for this document:
32
+ @context.error_for_exception(exception)
33
+ end
34
+ end
35
+ end
36
+
37
+ # Implements the `reduce` and `rereduce` functions along with all associated state.
38
+ class Reducer
39
+ # Create a reducer attached to the given context.
40
+ def initialize(context)
41
+ @context = context
42
+ end
43
+
44
+ # Apply the reduce function to a given list of items. Functions are typically in the form of:
45
+ # functions = [lambda{|keys,values,rereduce| ...}]
46
+ #
47
+ # such that:
48
+ # items = [[key1, value1], [key2, value2], [key3, value3]]
49
+ # functions.map{|function| function.call(all keys, all values, false)}
50
+ #
51
+ # @param [Array] functions
52
+ # An array of functions to apply.
53
+ # @param [Array] items
54
+ # A composite list of items.
55
+ def reduce(functions, items)
56
+ functions = functions.collect do |function_text|
57
+ @context.parse_function(function_text, self)
58
+ end
59
+
60
+ keys, values = [], []
61
+
62
+ items.each do |value|
63
+ keys << value[0]
64
+ values << value[1]
65
+ end
66
+
67
+ result = functions.map do |function|
68
+ ReducingProcess.new(self, function).run(keys, values, false)
69
+ end
70
+
71
+ return [true, result]
72
+ end
73
+
74
+ # Apply the rereduce functions to a given list of values.
75
+ # lambda{|keys,values,rereduce| ...}.call([], values, true)
76
+ #
77
+ # @param [Array] functions
78
+ # An array of functions to apply, in the form of:
79
+ # @param [Array] values
80
+ # An array of values to reduce
81
+ def rereduce(functions, values)
82
+ functions = functions.collect do |function_text|
83
+ @context.parse_function(function_text, self)
84
+ end
85
+
86
+ result = functions.map do |function|
87
+ ReducingProcess.new(self, function).run([], values, true)
88
+ end
89
+
90
+ return [true, result]
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,82 @@
1
+ # Copyright (c) 2012 Samuel G. D. Williams. <http://www.oriontransfer.co.nz>
2
+ #
3
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ # of this software and associated documentation files (the "Software"), to deal
5
+ # in the Software without restriction, including without limitation the rights
6
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ # copies of the Software, and to permit persons to whom the Software is
8
+ # furnished to do so, subject to the following conditions:
9
+ #
10
+ # The above copyright notice and this permission notice shall be included in
11
+ # all copies or substantial portions of the Software.
12
+ #
13
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19
+ # THE SOFTWARE.
20
+
21
+ require 'json'
22
+ require 'logger'
23
+
24
+ module Relaxo
25
+ module QueryServer
26
+ # A simple wrapper that reads and writes objects using JSON serialization to the given `input` and `output` `IO`s.
27
+ class Shell
28
+ def initialize(input, output)
29
+ @input = input
30
+ @output = output
31
+ end
32
+
33
+ attr :input
34
+ attr :output
35
+
36
+ # Read a JSON serialised object from `input`.
37
+ def read_object
38
+ JSON.parse @input.readline
39
+ end
40
+
41
+ # Write a JSON serialized object to `output`.
42
+ def write_object object
43
+ @output.puts object.to_json
44
+ @output.flush
45
+ end
46
+
47
+ # Read commands from `input`, execute them and then write out the results.
48
+ def run
49
+ begin
50
+ while true
51
+ command = read_object
52
+
53
+ result = yield command
54
+
55
+ write_object(result)
56
+ end
57
+ rescue EOFError
58
+ # Finish...
59
+ end
60
+ end
61
+ end
62
+
63
+ # Used primarily for testing, allows the input and output of the server to be provided directly.
64
+ class MockShell < Shell
65
+ def initialize
66
+ super [], []
67
+ end
68
+
69
+ def read_object
70
+ if @input.size > 0
71
+ @input.shift
72
+ else
73
+ raise EOFError.new("No more objects")
74
+ end
75
+ end
76
+
77
+ def write_object object
78
+ @output << object
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,31 @@
1
+ # Copyright (c) 2012 Samuel G. D. Williams. <http://www.oriontransfer.co.nz>
2
+ #
3
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ # of this software and associated documentation files (the "Software"), to deal
5
+ # in the Software without restriction, including without limitation the rights
6
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ # copies of the Software, and to permit persons to whom the Software is
8
+ # furnished to do so, subject to the following conditions:
9
+ #
10
+ # The above copyright notice and this permission notice shall be included in
11
+ # all copies or substantial portions of the Software.
12
+ #
13
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19
+ # THE SOFTWARE.
20
+
21
+ module Relaxo
22
+ module QueryServer
23
+ module VERSION
24
+ MAJOR = 0
25
+ MINOR = 1
26
+ TINY = 0
27
+
28
+ STRING = [MAJOR, MINOR, TINY].join('.')
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,25 @@
1
+
2
+ $LOAD_PATH.unshift File.expand_path("../../lib/", __FILE__)
3
+
4
+ require 'test/unit'
5
+ require 'stringio'
6
+
7
+ require "relaxo/query_server"
8
+
9
+ class ContextTest < Test::Unit::TestCase
10
+ def setup_context(options)
11
+ @shell = Relaxo::QueryServer::MockShell.new
12
+ @context = Relaxo::QueryServer::Context.new(@shell, options)
13
+ end
14
+
15
+ def create_design_document name, attributes
16
+ @context.run ['ddoc', 'new', name, attributes]
17
+ end
18
+
19
+ def setup
20
+ setup_context :safe => 0
21
+ end
22
+
23
+ def teardown
24
+ end
25
+ end
@@ -0,0 +1,24 @@
1
+
2
+ require 'context_test'
3
+
4
+ class FiltersTest < ContextTest
5
+ BASIC = "lambda{|doc, req| doc['good'] == true}"
6
+
7
+ def run_filter(opts={})
8
+ opts[:docs] ||= []
9
+ opts[:req] ||= {}
10
+
11
+ @context.run ["ddoc", "foo", ["filters", "basic"], [opts[:docs], opts[:req]]]
12
+ end
13
+
14
+ def test_filters_updated
15
+ @context.run ["ddoc", "new", "foo", {"filters" => {"basic" => BASIC}}]
16
+
17
+ docs = (1..3).map do |i|
18
+ {"good" => i.odd?}
19
+ end
20
+
21
+ results = run_filter({:docs => docs})
22
+ assert_equal [true, [true, false, true]], results
23
+ end
24
+ end
@@ -0,0 +1,46 @@
1
+
2
+ require 'context_test'
3
+
4
+ class ListsTest < ContextTest
5
+ ENTIRE = <<-RUBY
6
+ lambda{|head,request|
7
+ send "<ul>"
8
+ each do |row|
9
+ send "<li>" + row['count'] + "</li>"
10
+ end
11
+ send "</ul>"
12
+ }
13
+ RUBY
14
+
15
+ def setup
16
+ super
17
+
18
+ @rows = [
19
+ ["list_row", {"count"=>"Neko"}],
20
+ ["list_row", {"count"=>"Nezumi"}],
21
+ ["list_row", {"count"=>"Zoe"}],
22
+ ["list_end"]
23
+ ]
24
+ end
25
+
26
+ def test_lists_rows
27
+ create_design_document "test", {
28
+ 'lists' => {
29
+ 'entire' => ENTIRE
30
+ }
31
+ }
32
+
33
+ @rows.each {|row| @shell.input << row}
34
+
35
+ result = @context.run ['ddoc', 'test', ['lists', 'entire'], [{}, {}]]
36
+
37
+ assert_equal [
38
+ ["start", ["<ul>"], {:headers=>{}}],
39
+ ["chunks", ["<li>Neko</li>"]],
40
+ ["chunks", ["<li>Nezumi</li>"]],
41
+ ["chunks", ["<li>Zoe</li>"]]
42
+ ], @shell.output
43
+
44
+ assert_equal ["end", ["</ul>"]], result
45
+ end
46
+ end
data/test/test_map.rb ADDED
@@ -0,0 +1,20 @@
1
+
2
+ require 'context_test'
3
+
4
+ class MapTest < ContextTest
5
+ def test_map
6
+ response = @context.run ["add_fun", "lambda{|doc| emit('foo', doc['a']); emit('bar', doc['a'])}"]
7
+ assert_equal true, response
8
+
9
+ response = @context.run ["add_fun", "lambda{|doc| emit('baz', doc['a'])}"]
10
+ assert_equal true, response
11
+
12
+ response = @context.run(["map_doc", {"a" => "b"}])
13
+ expected = [
14
+ [["foo", "b"], ["bar", "b"]],
15
+ [["baz", "b"]]
16
+ ]
17
+
18
+ assert_equal expected, response
19
+ end
20
+ end
@@ -0,0 +1,15 @@
1
+
2
+ require 'context_test'
3
+
4
+ class ReduceTest < ContextTest
5
+ SUM = "lambda{|k,v,r| v.inject &:+ }"
6
+ CONCAT = "lambda{|k,v,r| r ? v.join('_') : v.join(':') }"
7
+
8
+ def test_reduce
9
+ response = @context.run ["reduce", [SUM, CONCAT], (0...10).map{|i|[i,i*2]}]
10
+ assert_equal [true, [90, "0:2:4:6:8:10:12:14:16:18"]], response
11
+
12
+ response = @context.run ["rereduce", [SUM, CONCAT], (0...10).map{|i|i}]
13
+ assert_equal [true, [45, "0_1_2_3_4_5_6_7_8_9"]], response
14
+ end
15
+ end
@@ -0,0 +1,53 @@
1
+
2
+ require 'context_test'
3
+
4
+ class ShowsTest < ContextTest
5
+ STRING = "lambda{|doc, req| [doc['title'], doc['body']].join(' - ') }"
6
+ HASH = <<-EOF
7
+ lambda{|doc, req|
8
+ resp = {"code" => 200, "headers" => {"X-Foo" => "Bar"}}
9
+ resp["body"] = [doc['title'], doc['body']].join(' - ')
10
+ resp
11
+ }
12
+ EOF
13
+ ERROR = "lambda{|doc,req| raise StandardError.new('error message') }"
14
+
15
+ def setup
16
+ super
17
+
18
+ @context.run ["ddoc", "new", "foo", {
19
+ "shows" => {
20
+ "string" => STRING,
21
+ "hash" => HASH,
22
+ "error" => ERROR
23
+ }
24
+ }
25
+ ]
26
+ end
27
+
28
+ def run_show(opts={})
29
+ opts[:doc] ||= {"title" => "foo", "body" => "bar"}
30
+ opts[:req] ||= {}
31
+ opts[:design] ||= "string"
32
+
33
+ @context.run(["ddoc", "foo", ["shows", opts[:design]], [opts[:doc], opts[:req]]])
34
+ end
35
+
36
+ def test_string
37
+ result = run_show
38
+ expected = ["resp", {"body" => "foo - bar"}]
39
+ assert_equal expected, result
40
+ end
41
+
42
+ def test_hash
43
+ result = run_show({:design => "hash"})
44
+ expected = ["resp", {"body" => "foo - bar", "headers" => {"X-Foo" => "Bar"}, "code" => 200}]
45
+ assert_equal expected, result
46
+ end
47
+
48
+ def test_error
49
+ result = run_show({:design => "error"})
50
+ expected = ["error", "StandardError", "error message"]
51
+ assert_equal expected, result[0...3]
52
+ end
53
+ end
@@ -0,0 +1,19 @@
1
+
2
+ require 'context_test'
3
+
4
+ class UpdatesTest < ContextTest
5
+ def test_compiles_functions
6
+ create_design_document "test", {
7
+ "updates" => {
8
+ "bar" => %q{
9
+ lambda{|doc, request| doc['updated'] = true; [doc, 'OK']}
10
+ }
11
+ }
12
+ }
13
+
14
+ document = {"foo" => "bar"}
15
+ response = @context.run ['ddoc', 'test', ['updates', 'bar'], [document, {'method' => 'POST'}]]
16
+
17
+ assert_equal ["up", document.update('updated' => true), {'body' => 'OK'}], response
18
+ end
19
+ end
@@ -0,0 +1,22 @@
1
+
2
+ require 'context_test'
3
+
4
+ class ValidationsTest < ContextTest
5
+ def test_compiles_functions
6
+ create_design_document "test", {
7
+ "validate_doc_update" => %q{
8
+ lambda{|new_document, old_document, user_context|
9
+ raise ValidationError.new('forbidden' => "bad") if new_document['bad']
10
+
11
+ true
12
+ }
13
+ }
14
+ }
15
+
16
+ response = @context.run ['ddoc', 'test', ['validate_doc_update'], [{'good' => true}, {'good' => true}, {}]]
17
+ assert_equal true, response
18
+
19
+ response = @context.run ['ddoc', 'test', ['validate_doc_update'], [{'bad' => true}, {'good' => true}, {}]]
20
+ assert_equal({'forbidden' => 'bad'}, response)
21
+ end
22
+ end
@@ -0,0 +1,17 @@
1
+
2
+ require 'context_test'
3
+
4
+ class ViewsTest < ContextTest
5
+ def test_compiles_functions
6
+ response = @context.run ["add_fun", "lambda {|doc| emit(nil, nil)}"]
7
+ assert_equal true, response
8
+
9
+ response = @context.run ["add_fun", "lambda {"]
10
+ assert_equal "error", response[0]
11
+ assert_equal "SyntaxError", response[1]
12
+
13
+ response = @context.run ["add_fun", "10"]
14
+ assert_equal "error", response[0]
15
+ assert_equal "Relaxo::QueryServer::CompilationError", response[1]
16
+ end
17
+ end
metadata ADDED
@@ -0,0 +1,98 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: relaxo-query-server
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - Samuel Williams
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2012-07-22 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: relaxo
16
+ requirement: !ruby/object:Gem::Requirement
17
+ none: false
18
+ requirements:
19
+ - - ~>
20
+ - !ruby/object:Gem::Version
21
+ version: 0.3.1
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ none: false
26
+ requirements:
27
+ - - ~>
28
+ - !ruby/object:Gem::Version
29
+ version: 0.3.1
30
+ - !ruby/object:Gem::Dependency
31
+ name: json
32
+ requirement: !ruby/object:Gem::Requirement
33
+ none: false
34
+ requirements:
35
+ - - ~>
36
+ - !ruby/object:Gem::Version
37
+ version: 1.7.3
38
+ type: :runtime
39
+ prerelease: false
40
+ version_requirements: !ruby/object:Gem::Requirement
41
+ none: false
42
+ requirements:
43
+ - - ~>
44
+ - !ruby/object:Gem::Version
45
+ version: 1.7.3
46
+ description:
47
+ email: samuel.williams@oriontransfer.co.nz
48
+ executables:
49
+ - relaxo-query-server
50
+ extensions: []
51
+ extra_rdoc_files: []
52
+ files:
53
+ - bin/relaxo-query-server
54
+ - lib/relaxo/query_server/context.rb
55
+ - lib/relaxo/query_server/designer.rb
56
+ - lib/relaxo/query_server/mapper.rb
57
+ - lib/relaxo/query_server/process.rb
58
+ - lib/relaxo/query_server/reducer.rb
59
+ - lib/relaxo/query_server/shell.rb
60
+ - lib/relaxo/query_server/version.rb
61
+ - lib/relaxo/query_server.rb
62
+ - test/context_test.rb
63
+ - test/test_filters.rb
64
+ - test/test_lists.rb
65
+ - test/test_map.rb
66
+ - test/test_reduce.rb
67
+ - test/test_shows.rb
68
+ - test/test_updates.rb
69
+ - test/test_validations.rb
70
+ - test/test_views.rb
71
+ - README.md
72
+ homepage: http://www.oriontransfer.co.nz/gems/relaxo
73
+ licenses: []
74
+ post_install_message:
75
+ rdoc_options: []
76
+ require_paths:
77
+ - lib
78
+ required_ruby_version: !ruby/object:Gem::Requirement
79
+ none: false
80
+ requirements:
81
+ - - ! '>='
82
+ - !ruby/object:Gem::Version
83
+ version: '0'
84
+ required_rubygems_version: !ruby/object:Gem::Requirement
85
+ none: false
86
+ requirements:
87
+ - - ! '>='
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ requirements: []
91
+ rubyforge_project:
92
+ rubygems_version: 1.8.23
93
+ signing_key:
94
+ specification_version: 3
95
+ summary: Relaxo Query Server provides support for executing CouchDB functions using
96
+ Ruby.
97
+ test_files: []
98
+ has_rdoc: