manveru-makura 2009.02.18

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.
data/COPYING ADDED
@@ -0,0 +1,18 @@
1
+ Copyright (c) 2008 Michael Fellinger <m.fellinger@gmail.com>
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
5
+ deal in the Software without restriction, including without limitation the
6
+ rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
7
+ sell 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
16
+ THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
17
+ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
18
+ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,41 @@
1
+ # Makura
2
+
3
+ Makura is a Ruby wrapper around the CouchDB REST API.
4
+
5
+ It doesn't provide a lot of bells and whistles, but aims to be as close to the
6
+ original API as possible, while taking advantage of Ruby's expressive power.
7
+
8
+ Most ideas for this have been gathered while trying other libraries such as
9
+ CouchObject, CouchRest, and RelaxDB.
10
+
11
+ It does so with almost no modification of ruby libraries and makes it simple
12
+ to switch the HTTP library used by changing one method.
13
+ Eventually Makura will be using an evented http library to provide better
14
+ performance.
15
+
16
+ We are using the json library, which adds following methods:
17
+ To Kernel: #j, #jj, #JSON
18
+ To Object and most core classes: #to_json
19
+ To String: #to_json_raw_object, #to_json, #to_json_raw
20
+
21
+ ## Dependencies
22
+
23
+ * CouchDB - 0.9 trunk (rev 725909 and higher)
24
+ * rest-client
25
+ * rack
26
+ * json
27
+
28
+ ## Features
29
+
30
+ * Simple Models, the CouchDB way and without magic.
31
+ * Free choice of inheritance, just include the Makura::Model module.
32
+ * Smart interpretation of returned JSON.
33
+ * Direct mapping of javascript files to map/reduce functions for views.
34
+ * CouchDB specific error reporting, no meaningless HTTP status code.
35
+ * Live update of views during runtime.
36
+ * Easy configuration, possibility to use different servers and databases each
37
+ model.
38
+
39
+ ## Usage
40
+
41
+ See the /example/blog.rb
data/bin/makura ADDED
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'rubygems'
4
+ require 'makura'
5
+
6
+ Makura::Model.database = ARGV.shift
7
+ DB = Makura::Model.database
8
+
9
+ puts "Your database is assigned to DB"
10
+
11
+ require 'irb'
12
+ IRB.start
data/example/blog.rb ADDED
@@ -0,0 +1,53 @@
1
+ require 'makura'
2
+
3
+ # Setting up everything
4
+
5
+ # Makura::Model.server = 'http://localhost:5984'
6
+ Makura::Model.database = 'mydb'
7
+
8
+ class Post
9
+ include Makura::Model
10
+
11
+ properties :title, :text, :tags
12
+ belongs_to :author
13
+
14
+ layout :all
15
+
16
+ validates(:title){ presence and length :within => (3..100) }
17
+ validates(:text){ presence }
18
+
19
+ save # submit design docs to CouchDB
20
+ end
21
+
22
+ class Author
23
+ include Makura::Model
24
+
25
+ property :name
26
+
27
+ layout :posts, :reduce => :sum_length
28
+ layout :all
29
+
30
+ save
31
+ end
32
+
33
+ class Comment
34
+ include Makura::Model
35
+
36
+ property :text
37
+ end
38
+
39
+ # And here it goes.
40
+
41
+ author = Author.new('name' => 'Michael Fellinger')
42
+ author.save
43
+
44
+ post = Post.new(
45
+ :title => 'Hello, World!',
46
+ :text => 'This is my first post',
47
+ :author => author)
48
+ post.save
49
+
50
+ Post.view(:all).each do |post|
51
+ p post
52
+ p post.author
53
+ end
@@ -0,0 +1,5 @@
1
+ function(doc){
2
+ if(doc.type == 'Author'){
3
+ emit(doc._id, doc);
4
+ }
5
+ }
@@ -0,0 +1,5 @@
1
+ function(doc){
2
+ if(doc.type == 'Post' && doc.user){
3
+ emit(doc.user, doc);
4
+ }
5
+ }
@@ -0,0 +1,5 @@
1
+ function(doc){
2
+ if(doc.type == 'Post'){
3
+ emit(doc._id, doc);
4
+ }
5
+ }
@@ -0,0 +1,5 @@
1
+ function(doc){
2
+ if(doc.type == 'Comment'){
3
+ emit(doc._id, doc);
4
+ }
5
+ }
@@ -0,0 +1,7 @@
1
+ function(keys, values, rereduce){
2
+ if(rereduce){
3
+ return sum(values);
4
+ } else {
5
+ return values.length;
6
+ }
7
+ }
@@ -0,0 +1,216 @@
1
+ module Makura
2
+ class Database
3
+ include HTTPMethods
4
+ attr_accessor :server, :name
5
+
6
+ # Initialize instance of Database and create if it doesn't exist yet.
7
+ # To prevent automatic creation, pass false as 3rd parameter
8
+ #
9
+ # Usage:
10
+ # server = Makura::Server.new
11
+ # # #<URI::HTTP:0xb7788234 URL:http://localhost:5984>
12
+ # database = Makura::Database.new(server, 'foo')
13
+ # # #<Makura::Database 'http://localhost:5984/foo'>
14
+
15
+ def initialize(server, name, auto_create = true)
16
+ @server, @name = server, name
17
+ create if auto_create
18
+ end
19
+
20
+ # Create the database if it doesn't exist already.
21
+ #
22
+ # Usage:
23
+ # server = Makura::Server.new
24
+ # # #<URI::HTTP:0xb76a4a98 URL:http://localhost:5984>
25
+ #
26
+ # database = Makura::Database.new(server, 'foo', false)
27
+ # # #<Makura::Database 'http://localhost:5984/foo'>
28
+ #
29
+ # database.create
30
+ # # {"update_seq"=>0, "doc_count"=>0, "purge_seq"=>0, "disk_size"=>4096,
31
+ # # "compact_running"=>false, "db_name"=>"foo", "doc_del_count"=>0}
32
+
33
+ def create
34
+ info
35
+ rescue Error::ResourceNotFound
36
+ put("/", :payload => '')
37
+ end
38
+
39
+ # Will delete document in the CouchDB corresponding to given +doc+.
40
+ # Use #destroy to delete the database itself.
41
+ # Use #delete! to automatically rescue exceptions on conflicts.
42
+ #
43
+ # Possible variations (User is a Makura::Model) are:
44
+ #
45
+ # # deleting based on explicit _id and :rev option.
46
+ # database.delete('manveru', :rev => 123134)
47
+ #
48
+ # # deleting based on a Hash
49
+ # database.delete('_id' => 'manveru', '_rev' => 123134)
50
+ #
51
+ # user = User.new(:name => 'manveru')
52
+ # user.save
53
+ # database.delete(user)
54
+ #
55
+ # Usage when deleting document:
56
+ # doc = database.save('name' => 'manveru', 'time' => Time.now)
57
+ # # {"rev"=>"484030692", "id"=>"67e086087d5b7e7196b5c99174b0b66c", "ok"=>true}
58
+ #
59
+ # database[doc['id']]
60
+ # # {"name"=>"manveru", "_rev"=>"484030692",
61
+ # "time"=>"Sat Nov 22 16:37:50 +0900 2008",
62
+ # "_id"=>"67e086087d5b7e7196b5c99174b0b66c"}
63
+ #
64
+ # database.delete(doc['id'], :rev => doc['rev'])
65
+ # # {"rev"=>"2034883605", "id"=>"67e086087d5b7e7196b5c99174b0b66c", "ok"=>true}
66
+ #
67
+ # database[doc['id']]
68
+ # RestClient::ResourceNotFound: RestClient::ResourceNotFound
69
+ #
70
+ # database.delete(doc['id'], :rev => doc['rev'])
71
+ # Makura::RequestFailed: {"reason"=>"Document update conflict.", "error"=>"conflict"}
72
+
73
+ def delete(doc, opts = {})
74
+ case doc
75
+ when Makura::Model
76
+ doc_id, doc_rev = doc._id, doc._rev
77
+ when Hash
78
+ doc_id = doc['_id'] || doc['id'] || doc[:_id] || doc[:id]
79
+ doc_rev = doc['_rev'] || doc['rev'] || doc[:_rev] || doc[:rev]
80
+ else
81
+ doc_id = doc
82
+ end
83
+
84
+ raise(ArgumentError, "document _id wasn't passed") unless doc_id
85
+
86
+ doc_id = Makura.escape(doc_id)
87
+ opts[:rev] ||= doc_rev if doc_rev
88
+
89
+ request(:delete, doc_id.to_s, opts)
90
+ end
91
+
92
+ def delete!(doc, opts = {})
93
+ delete(doc, opts)
94
+ rescue Error::Conflict, Error::ResourceNotFound
95
+ end
96
+
97
+ # Delete the database itself.
98
+ #
99
+ # Usage:
100
+ # database.destroy
101
+ # # {"ok"=>true}
102
+ # database.info
103
+ # # RestClient::ResourceNotFound: RestClient::ResourceNotFound
104
+
105
+ def destroy(opts = {})
106
+ request(:delete, '/', opts)
107
+ end
108
+
109
+ def destroy!(opts = {})
110
+ destroy(opts)
111
+ rescue Error::ResourceNotFound
112
+ end
113
+
114
+ def info
115
+ get('/')
116
+ end
117
+
118
+ def all_docs(params = {})
119
+ get('_all_docs')
120
+ end
121
+ alias documents all_docs
122
+
123
+ def [](id, rev = nil)
124
+ id = Makura.escape(id)
125
+ if rev
126
+ get(id, :rev => rev)
127
+ else
128
+ get(id)
129
+ end
130
+ end
131
+
132
+ def []=(id, doc)
133
+ id = Makura.escape(id)
134
+ put(id, :payload => prepare_doc(doc))
135
+ end
136
+
137
+ def temp_view(params = {})
138
+ params[:payload] = functions = {}
139
+ functions[:map] = params.delete(:map) if params[:map]
140
+ functions[:reduce] = params.delete(:reduce) if params[:reduce]
141
+ params['Content-Type'] = 'application/json'
142
+
143
+ post('_temp_view', params)
144
+ end
145
+
146
+ def view(layout, params = {})
147
+ get("_view/#{layout}", params)
148
+ end
149
+
150
+ def save(doc)
151
+ if id = doc['_id']
152
+ id = Makura.escape(id)
153
+ put(id, :payload => prepare_doc(doc))
154
+ else
155
+ id = doc['_id'] = @server.next_uuid
156
+ id = Makura.escape(id)
157
+ put(id, :payload => prepare_doc(doc))
158
+ end
159
+ end
160
+
161
+ # NOTE:
162
+ # * Seems like we don't even need to check _id, CouchDB will assign it.
163
+ # But in order to use our own uuids we still do it.
164
+ def bulk_docs(docs)
165
+ docs.each{|doc| doc['_id'] ||= @server.next_uuid }
166
+ post("_bulk_docs", :payload => {:docs => docs})
167
+ end
168
+ alias bulk_save bulk_docs
169
+
170
+ def get_attachment(doc, file_id)
171
+ doc_id = doc.respond_to?(:_id) ? doc._id : doc.to_str
172
+ file_id, doc_id = Makura.escape(file_id), Makura.escape(doc_id)
173
+
174
+ get("#{doc_id}/#{file_id}", :raw => true)
175
+ end
176
+
177
+ # PUT an attachment directly to CouchDB
178
+ def put_attachment(doc, file_id, file, options = {})
179
+ doc_id, file_id = Makura.escape(doc._id), Makura.escape(file_id)
180
+
181
+ options[:payload] = file
182
+ options[:raw] = true
183
+ options[:rev] = doc._rev if doc._rev
184
+
185
+ put("#{doc_id}/#{file_id}", options)
186
+ end
187
+
188
+ def prepare_doc(doc)
189
+ if attachments = doc['_attachments']
190
+ doc['_attachments'] = encode_attachments(attachments)
191
+ end
192
+
193
+ return doc
194
+ end
195
+
196
+ def request(method, path, params = {})
197
+ @server.send(:request, method, "/#{name}/#{path}", params)
198
+ end
199
+
200
+ def encode_attachments(attachments)
201
+ attachments.each do |key, value|
202
+ next if value['stub']
203
+ value['data'] = base64(value['data'])
204
+ end
205
+ attachments
206
+ end
207
+
208
+ def base64(data)
209
+ [data.to_s].pack('m').delete("\n")
210
+ end
211
+
212
+ def inspect
213
+ "#<Makura::Database '#{@server.uri(name)}'>"
214
+ end
215
+ end
216
+ end
@@ -0,0 +1,37 @@
1
+ module Makura
2
+ class Design
3
+ attr_accessor :database, :name, :language
4
+ attr_reader :layouts
5
+
6
+ def initialize(name, database = nil)
7
+ @name, @database = name, database
8
+ @language = 'javascript'
9
+ @layouts = {}
10
+ end
11
+
12
+ def save
13
+ hash = to_hash
14
+ doc = @database[hash['_id']]
15
+ doc['views'] = hash['views']
16
+ @database.save(doc)
17
+ rescue Makura::Error::ResourceNotFound
18
+ @database.save(to_hash)
19
+ end
20
+
21
+ def [](layout_name)
22
+ @layouts[layout_name.to_s]
23
+ end
24
+
25
+ def []=(layout_name, layout)
26
+ @layouts[layout_name.to_s] = layout
27
+ end
28
+
29
+ def to_hash
30
+ views = {}
31
+ @layouts.each{|name, layout| views[name] = layout.to_hash }
32
+ views.delete_if{|k,v| !(v[:map] || v['map']) }
33
+
34
+ {'language' => @language, '_id' => "_design/#{@name}", 'views' => views}
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,14 @@
1
+ module Makura
2
+ # Mother and namespace of all exceptions
3
+ class Error < ::RuntimeError
4
+ class ConnectionRefused < Error; end
5
+ class RequestFailed < Error; end
6
+ class ResourceNotFound < RequestFailed; end
7
+ class Conflict < RequestFailed; end
8
+ class MissingRevision < RequestFailed; end
9
+ class BadRequest < RequestFailed; end
10
+ class Authorization < RequestFailed; end
11
+ class NotFound < RequestFailed; end
12
+ class FileExists < RequestFailed; end
13
+ end
14
+ end
@@ -0,0 +1,19 @@
1
+ module Makura
2
+ module HTTPMethods
3
+ def delete(path, params = {})
4
+ request(:delete, path, params)
5
+ end
6
+
7
+ def get(path, params = {})
8
+ request(:get, path, params)
9
+ end
10
+
11
+ def post(path, params = {})
12
+ request(:post, path, params)
13
+ end
14
+
15
+ def put(path, params = {})
16
+ request(:put, path, params)
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,62 @@
1
+ module Makura
2
+ class Layout
3
+ attr_accessor :design, :name, :map, :reduce
4
+
5
+ PATH = [
6
+ './couch',
7
+ File.join(Makura::ROOT, '../couch')
8
+ ]
9
+
10
+ def initialize(name, design = nil)
11
+ @name, @design = name, design
12
+ @design[name] = self
13
+ @map = @reduce = nil
14
+ @options = {}
15
+ end
16
+
17
+ def load_proto_map(file_or_function, replace = {})
18
+ return unless common_load(:proto_map, file_or_function)
19
+ replace.each{|from, to| @proto_map.gsub!(/"\{\{#{from}\}\}"/, to) }
20
+ @map = @proto_map
21
+ end
22
+
23
+ def load_proto_reduce(file_or_function, replace = {})
24
+ return unless common_load(:proto_reduce, file_or_function)
25
+ replace.each{|from, to| @proto_reduce.gsub!(/"\{\{#{from}\}\}"/, to) }
26
+ @reduce = @proto_reduce
27
+ end
28
+
29
+ def load_map(file_or_function)
30
+ common_load(:map, file_or_function)
31
+ end
32
+
33
+ def load_reduce(file_or_function)
34
+ common_load(:reduce, file_or_function)
35
+ end
36
+
37
+ def common_load(root, file_or_function)
38
+ return unless file_or_function
39
+
40
+ if file_or_function =~ /function\(.*\)/
41
+ function = file_or_function
42
+ else
43
+ filename = "#{root}/#{file_or_function}.js"
44
+
45
+ if pathname = PATH.find{|pa| File.file?(File.join(pa, filename)) }
46
+ function = File.read(File.join(pathname, filename))
47
+ end
48
+ end
49
+
50
+ instance_variable_set("@#{root}", function) if function
51
+ end
52
+
53
+ def save
54
+ @design[@name] = self.to_hash
55
+ @design.save
56
+ end
57
+
58
+ def to_hash
59
+ {:map => @map, :reduce => @reduce, :makura_options => @options}
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,372 @@
1
+ module Makura
2
+ module Model
3
+ KEY = 'makura_type'
4
+
5
+ class << self
6
+ attr_reader :server, :database
7
+
8
+ def database=(name)
9
+ @database = server.database(name)
10
+ end
11
+
12
+ def server=(obj)
13
+ case obj
14
+ when Makura::Server
15
+ @server = obj
16
+ when String, URI
17
+ @server = Makura::Server.new(obj)
18
+ else
19
+ raise ArgumentError
20
+ end
21
+ end
22
+
23
+ def server
24
+ return @server if @server
25
+ self.server = Makura::Server.new
26
+ end
27
+
28
+ def included(into)
29
+ into.extend(SingletonMethods)
30
+ into.send(:include, InstanceMethods)
31
+ into.makura_relation = {:belongs_to => {}, :has_many => {}}
32
+ into.property_type = {}
33
+ into.defaults = {'type' => into.name}
34
+ into.properties(:_id, :_rev, :type)
35
+ end
36
+ end
37
+
38
+ module InstanceMethods
39
+ def initialize(hash = {})
40
+ @_hash = self.class.defaults.dup
41
+ merge!(hash)
42
+ end
43
+
44
+ def merge!(hash)
45
+ case hash
46
+ when Makura::Model
47
+ merge!(hash.to_hash)
48
+ when Hash
49
+ hash.each{|key, value|
50
+ meth = "#{key}="
51
+
52
+ if respond_to?(meth)
53
+ self.send("#{key}=", value)
54
+ else
55
+ self[key.to_s] = value
56
+ end
57
+ }
58
+ else
59
+ raise ArgumentError, "This is neither relation data nor an Hash"
60
+ end
61
+ end
62
+
63
+ def [](key)
64
+ @_hash[key.to_s]
65
+ end
66
+
67
+ def []=(key, value)
68
+ @_hash[key.to_s] = value
69
+ end
70
+
71
+ def to_hash
72
+ @_hash.dup
73
+ end
74
+
75
+ def to_json
76
+ @_hash.to_json
77
+ end
78
+
79
+ def inspect
80
+ "#<#{self.class} #{@_hash.inspect}>"
81
+ end
82
+
83
+ def pretty_print(o)
84
+ ["#<#{self.class} ", @_hash, ">"].each{|e| e.pretty_print(o) }
85
+ end
86
+
87
+ def saved?
88
+ self['_rev']
89
+ end
90
+
91
+ def save
92
+ return if not valid? if respond_to?(:valid)
93
+ return if saved?
94
+ save!
95
+ end
96
+
97
+ def save!
98
+ hash = self.to_hash
99
+
100
+ self.class.makura_relation.each do |kind, relation_hash|
101
+ relation_hash.each do |key, value|
102
+ hash[key.to_s] = hash[key.to_s] #._id
103
+ end
104
+ end
105
+
106
+ response = self.class.database.save(hash)
107
+ self._rev = response['rev']
108
+ self._id = response['id']
109
+
110
+ return self
111
+ end
112
+
113
+ # path, file, args = {})
114
+ def attach(*args)
115
+ self.class.database.put_attachment(self, *args)
116
+ end
117
+
118
+ # delete attachment by name.
119
+ # we make sure the parameter is given and a nonempty string to avoid
120
+ # destroying the document itself
121
+ def detach(name)
122
+ name.strip!
123
+ return if name.empty?
124
+ self.class.database.request(:delete, "#{_id}/#{name}", :rev => _rev)
125
+ rescue Makura::Error::Conflict
126
+ self['_rev'] = self.class[self._id]['_rev']
127
+ retry
128
+ end
129
+
130
+ def destroy
131
+ self.class.database.delete(_id, :rev => _rev)
132
+ end
133
+
134
+ def ==(obj)
135
+ self.class == obj.class and self._id == obj._id
136
+ end
137
+
138
+ def hash
139
+ @_hash.hash
140
+ end
141
+
142
+ def eql?(other)
143
+ other == self && other.hash == self.hash
144
+ end
145
+
146
+ def clone
147
+ hash = @_hash.dup
148
+ hash.delete('_id')
149
+ hash.delete('_rev')
150
+ self.class.new(hash)
151
+ end
152
+ end
153
+
154
+ module SingletonMethods
155
+ attr_accessor :defaults, :makura_relation, :property_type
156
+
157
+ def plugin(name)
158
+ require "makura/plugin/#{name}".downcase
159
+
160
+ name = name.to_s.capitalize
161
+ mod = Makura::Plugin.const_get(name)
162
+
163
+ include(mod::InstanceMethods) if defined?(mod::InstanceMethods)
164
+ extend(mod::SingletonMethods) if defined?(mod::SingletonMethods)
165
+ end
166
+
167
+ def database=(name)
168
+ @database = Makura::Model.server.database(name)
169
+ end
170
+
171
+ def database
172
+ @database || Makura::Model.database
173
+ end
174
+
175
+ def properties(*names)
176
+ names.each{|name| property(name) }
177
+ end
178
+
179
+ def property(name, opts = {})
180
+ name = name.to_s
181
+ defaults[name] = default = opts.delete(:default) if opts[:default]
182
+ property_type[name] = type = opts.delete(:type) if opts[:type]
183
+
184
+ if type == Time
185
+ code = "
186
+ def #{name}()
187
+ Time.at(@_hash[#{name.dump}].to_i)
188
+ end
189
+ def #{name}=(obj)
190
+ @_hash[#{name.dump}] = obj.to_i
191
+ end"
192
+ class_eval(code)
193
+ else
194
+ code = "
195
+ def #{name}() @_hash[#{name.dump}] end
196
+ def #{name}=(obj) @_hash[#{name.dump}] = obj end"
197
+ end
198
+
199
+ class_eval(code)
200
+ end
201
+
202
+ def id(name)
203
+ @id = name
204
+ class_eval("
205
+ alias #{name} _id
206
+ alias #{name}= _id=")
207
+ end
208
+
209
+ def belongs_to(name, model = nil)
210
+ name = name.to_s
211
+ klass = (model || name.capitalize).to_s
212
+ @makura_relation[:belongs_to][name] = klass
213
+
214
+ class_eval("
215
+ def #{name}()
216
+ @#{name} ||= #{klass}[self[#{name.dump}]]
217
+ end
218
+ def #{name}=(obj)
219
+ if obj.respond_to?(:_id)
220
+ @_hash[#{name.dump}] = obj._id
221
+ else
222
+ @_hash[#{name.dump}] = obj
223
+ end
224
+ end")
225
+ end
226
+
227
+ def has_many(name, model = nil)
228
+ name = name.to_s
229
+ klass = (model || name.capitalize).to_s
230
+ @makura_relation[:has_many][name] = klass
231
+
232
+ class_eval("
233
+ def #{name}()
234
+ @#{name} ||= #{klass}[self[#{name.dump}]]
235
+ end
236
+ def #{name}=(obj)
237
+ return unless obj
238
+ raise RuntimeError, 'You many not assign here'
239
+ end")
240
+ end
241
+
242
+ def [](id, rev = nil)
243
+ new(database[id, rev])
244
+ rescue Error::ResourceNotFound
245
+ nil
246
+ end
247
+
248
+ def design
249
+ @design ||= Design.new(name.to_s, database)
250
+ end
251
+
252
+ def layout(name, opts = {})
253
+ design[name] = layout = Layout.new(name, design)
254
+
255
+ map_name = opts[:map] || "#{self.name}_#{name}".downcase
256
+ reduce_name = opts[:reduce] || "#{self.name}_#{name}".downcase
257
+
258
+ layout.load_map(map_name)
259
+ layout.load_reduce(reduce_name)
260
+
261
+ return layout
262
+ end
263
+
264
+ def proto_layout(common, name, opts = {})
265
+ design[name] = layout = Layout.new(name, design)
266
+
267
+ map_name = opts.delete(:map) || "#{self.name}_#{common}".downcase
268
+ reduce_name = opts.delete(:reduce) || "#{self.name}_#{common}".downcase
269
+
270
+ layout.load_proto_map(map_name, opts)
271
+ layout.load_proto_reduce(reduce_name, opts)
272
+
273
+ return layout
274
+ end
275
+
276
+ def save
277
+ design.save
278
+ end
279
+
280
+ # +opts+ must include a :keys or 'keys' key with something that responds
281
+ # to #to_a as value
282
+ #
283
+ # Usage given a map named `Post/by_tags' that does something like:
284
+ #
285
+ # for(t in doc.tags){ emit([doc.tags[t]], null); }
286
+ #
287
+ # You can use this like:
288
+ #
289
+ # keys = ['ruby', 'couchdb']
290
+ # Post.multi_fetch(:by_tags, :keys => keys)
291
+ #
292
+ # And it will return all docs with the tags 'ruby' OR 'couchdb'
293
+ # This can be extended to match even more complex things
294
+ #
295
+ # for(t in doc.tags){ emit([doc.author, doc.tags[t]], null); }
296
+ #
297
+ # Now we do
298
+ #
299
+ # keys = [['manveru', 'ruby'], ['mika', 'couchdb']]
300
+ # Post.multi_fetch(:by_tags, :keys => keys)
301
+ #
302
+ # This will return all docs match following:
303
+ # ((author == 'manveru' && tags.include?('ruby')) ||
304
+ # (author == 'mika' && tags.include?('couchdb')))
305
+ #
306
+ # Of course you can add as many keys as you like:
307
+ #
308
+ # keys = [['manveru', 'ruby'],
309
+ # ['manveru', 'couchdb'],
310
+ # ['mika', 'design']]
311
+ # ['mika', 'couchdb']]
312
+ # Post.multi_fetch(:by_tags, :keys => keys)
313
+ #
314
+ #
315
+ # From http://wiki.apache.org/couchdb/HTTP_view_API
316
+ # A JSON structure of {"keys": ["key1", "key2", ...]} can be posted to
317
+ # any user defined view or _all_docs to retrieve just the view rows
318
+ # matching that set of keys. Rows are returned in the order of the keys
319
+ # specified. Combining this feature with include_docs=true results in
320
+ # the so-called multi-document-fetch feature.
321
+
322
+ def multi_fetch(name, opts = {})
323
+ keys = opts.delete(:keys) || opts.delete('keys')
324
+ opts.merge!(:payload => {'keys' => keys.to_a})
325
+ hash = database.post("_view/#{self}/#{name}", opts)
326
+ convert_raw(hash['rows'])
327
+ end
328
+
329
+ def multi_fetch_with_docs(name, opts = {})
330
+ opts.merge!(:include_docs => true, :reduce => false)
331
+ multi_fetch(name, opts)
332
+ end
333
+ alias multi_document_fetch multi_fetch_with_docs
334
+
335
+ # It is generally recommended not to include the doc in the emit of the
336
+ # map function but to use include_docs=true.
337
+ # To make using this approach more convenient use this method.
338
+
339
+ def view_with_docs(name, opts = {})
340
+ opts.merge!(:include_docs => true, :reduce => false)
341
+ view(name, opts)
342
+ end
343
+
344
+ alias view_docs view_with_docs
345
+
346
+ def view(name, opts = {})
347
+ flat = opts.delete(:flat)
348
+ hash = database.view("#{self}/#{name}", opts)
349
+
350
+ convert_raw(hash['rows'], flat)
351
+ end
352
+
353
+ def convert_raw(rows, flat = false)
354
+ rows.map do |row|
355
+ value = row['doc'] || row['value']
356
+
357
+ if value.respond_to?(:to_hash)
358
+ if type = value['type'] and not flat
359
+ const_get(type).new(value)
360
+ else
361
+ row
362
+ end
363
+ elsif not row['key']
364
+ value
365
+ else
366
+ row
367
+ end
368
+ end
369
+ end
370
+ end
371
+ end
372
+ end
@@ -0,0 +1,56 @@
1
+ module Makura
2
+ module Plugin
3
+ module Pager
4
+ module SingletonMethods
5
+ def pager(page, limit)
6
+ Makura::Plugin::Pager::Pagination.new(self, :pager, page, limit)
7
+ end
8
+ end
9
+
10
+ class Pagination
11
+ def initialize(model, view, page, limit)
12
+ @model, @view, @page, @limit = model, view, page, limit
13
+ end
14
+
15
+ # /pager/_all_docs?count=10&group=true
16
+ # /pager/_all_docs?startkey=%224f9dca1c66121f9320a69553546db07a%22&startkey_docid=4f9dca1c66121f9320a69553546db07a&skip=1&descending=false&count=10&group=true
17
+ # /pager/_all_docs?startkey=%22_design%2FUser%22&startkey_docid=_design%2FUser&skip=1&descending=false&count=10&group=true
18
+ # /pager/_all_docs?startkey=%22d850f0801686b85035680bb6f38d5c5c%22&startkey_docid=d850f0801686b85035680bb6f38d5c5c&skip=1&descending=false&count=10&group=true
19
+
20
+ # NOTE:
21
+ # * descending should be true if you page backwards
22
+
23
+ include Enumerable
24
+
25
+ def each(start_id = nil, descending = false, &block)
26
+ opts = {
27
+ :count => @limit,
28
+ :group => true,
29
+ :descending => descending,
30
+ # :include_docs => true,
31
+ }
32
+
33
+ if start_id
34
+ opts[:skip] = 1
35
+ opts[:startkey_docid] = start_id
36
+ opts[:startkey] = start_id
37
+ end
38
+
39
+ @model.view(@view, opts).each(&block)
40
+ end
41
+
42
+ def count
43
+ end
44
+
45
+ def first_page?
46
+ end
47
+
48
+ def last_page?
49
+ end
50
+
51
+ def empty?
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,203 @@
1
+ module Makura
2
+ class Server
3
+ include HTTPMethods
4
+ attr_accessor :uri, :cache_ttl, :cache_tries
5
+
6
+ COUCHDB_URI = 'http://localhost:5984'
7
+ CACHE_TTL = 5
8
+ CACHE_TRIES = 2
9
+
10
+ # Usage:
11
+ # server = Makura::Server.new
12
+ # #<URI::HTTP:0xb778ce38 URL:http://localhost:5984>
13
+ # server.info
14
+ # {"couchdb"=>"Welcome", "version"=>"0.9.0a718650-incubating"}
15
+
16
+ def initialize(uri = COUCHDB_URI, cache_ttl = CACHE_TTL, cache_tries = CACHE_TRIES)
17
+ @uri = URI(uri.to_s)
18
+ @cache_ttl = cache_ttl
19
+ @cache_tries = cache_tries
20
+ @uuids = UUIDCache.new(self)
21
+ end
22
+
23
+ def inspect
24
+ @uri.inspect
25
+ end
26
+
27
+ # General queries
28
+
29
+ # Answers with general couchdb info, looks like:
30
+ #
31
+ # Usage:
32
+ # server.info
33
+ # # {'couchdb' => 'Welcome', 'version' => '0.9.0a718650-incubating'}
34
+ def info
35
+ get('/')
36
+ end
37
+
38
+ # Answers with configuration info.
39
+ #
40
+ # Usage:
41
+ # server.config
42
+ #
43
+ def config
44
+ get('/_config')
45
+ end
46
+
47
+ # Issue restart of the CouchDB daemon.
48
+ #
49
+ # Usage:
50
+ # server.restart
51
+ # # {'ok' => true}
52
+ def restart
53
+ post('/_restart')
54
+ end
55
+
56
+ # Array of names of databases on the server
57
+ #
58
+ # Usage:
59
+ # server.databases
60
+ # # ["another", "blog", "makura-spec"]
61
+ def databases
62
+ get('/_all_dbs')
63
+ end
64
+
65
+ # Return new database instance using this server instance.
66
+ #
67
+ # Usage:
68
+ # foo = server.database('foo')
69
+ # # #<Makura::Database 'http://localhost:5984/foo'>
70
+ # server.databases
71
+ # # ["another", "blog", "foo", "makura-spec"]
72
+
73
+ def database(name)
74
+ Database.new(self, name)
75
+ end
76
+
77
+ # Answers with an uuid from the UUIDCache.
78
+ #
79
+ # Usage:
80
+ # server.next_uuid
81
+ # # "55fdca746fa5a5b56f5270875477a2cc"
82
+
83
+ def next_uuid
84
+ @uuids.next
85
+ end
86
+
87
+ def start_cache(namespace = 'makura', *servers)
88
+ servers << 'localhost:11211' if servers.empty?
89
+ @cache = MemCache.new(servers, :namespace => namespace, :multithread => true)
90
+ end
91
+
92
+ def stop_cache
93
+ @cache = nil
94
+ end
95
+
96
+ def cached(request, ttl = cache_ttl, tries = cache_tries)
97
+ key = request[:url]
98
+
99
+ unless response = @cache.get(key)
100
+ response = execute(request)
101
+ @cache.add(key, response, ttl)
102
+ end
103
+
104
+ return response
105
+ rescue MemCache::MemCacheError => error
106
+ servers = @cache.servers.map{|s| "#{s.host}:#{s.port}"}
107
+ start_cache(@cache.namespace, *servers)
108
+ tries -= 1
109
+ retry if tries > 0
110
+ warn "[makura caching disabled] #{error.message}"
111
+ @cache = nil
112
+ execute(request)
113
+ end
114
+
115
+ # Helpers
116
+
117
+ def request(method, path, params = {})
118
+ keep_raw = params.delete(:raw)
119
+ payload = params.delete(:payload)
120
+ payload = payload.to_json if payload and not keep_raw
121
+ headers = {}
122
+
123
+ if content_type = params.delete('Content-Type')
124
+ headers['Content-Type'] = content_type
125
+ end
126
+
127
+ params.delete_if{|k,v| v.nil? }
128
+ uri = uri(path, params).to_s
129
+
130
+ request = {
131
+ :method => method,
132
+ :url => uri,
133
+ :payload => payload,
134
+ :headers => headers}
135
+
136
+ if @cache and request[:method] == :get
137
+ raw = cached(request)
138
+ else
139
+ raw = execute(request)
140
+ end
141
+
142
+ return raw if keep_raw
143
+ json = JSON.parse(raw)
144
+ rescue JSON::ParserError
145
+ return raw
146
+ rescue RestClient::RequestFailed => ex
147
+ raise appropriate_error(ex)
148
+ rescue RestClient::ResourceNotFound => ex
149
+ raise Error::ResourceNotFound, request[:url], ex.backtrace
150
+ rescue Errno::ECONNREFUSED
151
+ raise Error::ConnectionRefused, "Is CouchDB running at #{@uri}?"
152
+ end
153
+
154
+ def execute(request)
155
+ RestClient::Request.execute(request)
156
+ end
157
+
158
+ def appropriate_error(exception)
159
+ body = exception.response.body if exception.respond_to?(:response)
160
+ backtrace = exception.backtrace
161
+
162
+ raise(Error::RequestFailed, exception.message, backtrace) unless body
163
+ raise(Error::RequestFailed, exception.message, backtrace) if body.empty?
164
+
165
+ json = JSON.parse(body)
166
+ error, reason = json['error'], json['reason']
167
+
168
+ case error
169
+ when 'bad_request'
170
+ raise(Error::BadRequest, reason, backtrace)
171
+ when 'authorization'
172
+ raise(Error::Authorization, reason, backtrace)
173
+ when 'not_found'
174
+ raise(Error::NotFound, reason, backtrace)
175
+ when 'file_exists'
176
+ raise(Error::FileExists, reason, backtrace)
177
+ when 'missing_rev'
178
+ raise(Error::MissingRevision, reason, backtrace)
179
+ when 'conflict'
180
+ raise(Error::Conflict, reason, backtrace)
181
+ else
182
+ raise(Error::RequestFailed, json.inspect, backtrace)
183
+ end
184
+ end
185
+
186
+ JSON_PARAMS = %w[key startkey endkey]
187
+
188
+ def paramify(hash)
189
+ hash.map{|k,v|
190
+ k = k.to_s
191
+ v = v.to_json if JSON_PARAMS.include?(k)
192
+ "#{Makura.escape(k)}=#{Makura.escape(v)}"
193
+ }.join('&')
194
+ end
195
+
196
+ def uri(path = '/', params = {})
197
+ uri = @uri.dup
198
+ uri.path = (path[0,1] == '/' ? path : "/#{path}").squeeze('/')
199
+ uri.query = paramify(params) unless params.empty?
200
+ uri
201
+ end
202
+ end
203
+ end
@@ -0,0 +1,23 @@
1
+ module Makura
2
+ class UUIDCache
3
+ attr_accessor :min, :max, :server, :pretty
4
+
5
+ def initialize(server, min = 500, max = 1500, pretty = true)
6
+ @server, @min, @max, @pretty = server, min, max, pretty
7
+ @uuids = []
8
+ end
9
+
10
+ def next
11
+ fetch if @uuids.size < min
12
+ @uuids.shift
13
+ end
14
+
15
+ def fetch(count = 0)
16
+ todo = max - @uuids.size
17
+ count = [min, todo, max].sort[1]
18
+ uuids = @server.get('/_uuids', :count => count)['uuids']
19
+ uuids.map!{|u| Makura.pretty_from_md5(u) } if pretty
20
+ @uuids.concat(uuids)
21
+ end
22
+ end
23
+ end
data/lib/makura.rb ADDED
@@ -0,0 +1,62 @@
1
+ require 'pp'
2
+ require 'uri'
3
+
4
+ begin
5
+ require 'rubygems'
6
+ rescue LoadError
7
+ end
8
+
9
+ require 'rest_client'
10
+ require 'json'
11
+
12
+ module Makura
13
+ VERSION = '2008.01.15'
14
+ ROOT = File.expand_path(File.dirname(__FILE__))
15
+ end
16
+
17
+ unless $LOAD_PATH.any?{|lp| File.expand_path(lp) == Makura::ROOT }
18
+ $LOAD_PATH.unshift(Makura::ROOT)
19
+ end
20
+
21
+ require 'makura/error'
22
+ require 'makura/http_methods'
23
+ require 'makura/server'
24
+ require 'makura/database'
25
+ require 'makura/uuid_cache'
26
+ require 'makura/model'
27
+ require 'makura/design'
28
+ require 'makura/layout'
29
+
30
+ module Makura
31
+ CHARS = (48..128).map{|c| c.chr}.grep(/[[:alnum:]]/)
32
+ MOD = CHARS.size
33
+
34
+ module_function
35
+
36
+ # From Rack
37
+ def escape(s)
38
+ s.to_s.gsub(/([^ a-zA-Z0-9_.-]+)/n) {
39
+ '%'+$1.unpack('H2'*$1.size).join('%').upcase
40
+ }.tr(' ', '+')
41
+ end
42
+
43
+ def pretty_from_md5(md5)
44
+ id = md5.to_i(16)
45
+ o = []
46
+ while id > 0
47
+ id, r = id.divmod(MOD)
48
+ o.unshift CHARS[r]
49
+ end
50
+ o.join
51
+ end
52
+
53
+ def pretty_to_md5(id)
54
+ i = 0
55
+ id.scan(/./) do |c|
56
+ i = i * MOD + CHARS.index(c)
57
+ end
58
+ i.to_s(16)
59
+ end
60
+ end
61
+
62
+ Sofa = Makura # be backwards compatible
data/makura.gemspec ADDED
@@ -0,0 +1,41 @@
1
+ Gem::Specification.new do |s|
2
+ s.name = "makura"
3
+ s.version = "2009.02.18"
4
+
5
+ s.summary = "Ruby wrapper around the CouchDB REST API."
6
+ s.description = "Ruby wrapper around the CouchDB REST API."
7
+ s.platform = "ruby"
8
+ s.has_rdoc = true
9
+ s.author = "Michael 'manveru' Fellinger"
10
+ s.email = "m.fellinger@gmail.com"
11
+ s.homepage = "http://github.com/manveru/makura"
12
+ s.executables = ['makura']
13
+ s.bindir = "bin"
14
+ s.require_path = "lib"
15
+
16
+ s.add_dependency('rest-client', '>= 0.8.1')
17
+ s.add_dependency('json', '>= 1.1.3')
18
+
19
+ s.files = [
20
+ "COPYING",
21
+ "README.md",
22
+ "bin/makura",
23
+ "example/blog.rb",
24
+ "example/couch/map/author_all.js",
25
+ "example/couch/map/author_posts.js",
26
+ "example/couch/map/post_all.js",
27
+ "example/couch/map/post_comments.js",
28
+ "example/couch/reduce/sum_length.js",
29
+ "lib/makura/database.rb",
30
+ "lib/makura/design.rb",
31
+ "lib/makura/error.rb",
32
+ "lib/makura/http_methods.rb",
33
+ "lib/makura/layout.rb",
34
+ "lib/makura/model.rb",
35
+ "lib/makura/plugin/pager.rb",
36
+ "lib/makura/server.rb",
37
+ "lib/makura/uuid_cache.rb",
38
+ "lib/makura.rb",
39
+ "makura.gemspec"
40
+ ]
41
+ end
metadata ADDED
@@ -0,0 +1,91 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: manveru-makura
3
+ version: !ruby/object:Gem::Version
4
+ version: 2009.02.18
5
+ platform: ruby
6
+ authors:
7
+ - Michael 'manveru' Fellinger
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+
12
+ date: 2009-02-13 00:00:00 -08:00
13
+ default_executable:
14
+ dependencies:
15
+ - !ruby/object:Gem::Dependency
16
+ name: rest-client
17
+ type: :runtime
18
+ version_requirement:
19
+ version_requirements: !ruby/object:Gem::Requirement
20
+ requirements:
21
+ - - ">="
22
+ - !ruby/object:Gem::Version
23
+ version: 0.8.1
24
+ version:
25
+ - !ruby/object:Gem::Dependency
26
+ name: json
27
+ type: :runtime
28
+ version_requirement:
29
+ version_requirements: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: 1.1.3
34
+ version:
35
+ description: Ruby wrapper around the CouchDB REST API.
36
+ email: m.fellinger@gmail.com
37
+ executables:
38
+ - makura
39
+ extensions: []
40
+
41
+ extra_rdoc_files: []
42
+
43
+ files:
44
+ - COPYING
45
+ - README.md
46
+ - bin/makura
47
+ - example/blog.rb
48
+ - example/couch/map/author_all.js
49
+ - example/couch/map/author_posts.js
50
+ - example/couch/map/post_all.js
51
+ - example/couch/map/post_comments.js
52
+ - example/couch/reduce/sum_length.js
53
+ - lib/makura/database.rb
54
+ - lib/makura/design.rb
55
+ - lib/makura/error.rb
56
+ - lib/makura/http_methods.rb
57
+ - lib/makura/layout.rb
58
+ - lib/makura/model.rb
59
+ - lib/makura/plugin/pager.rb
60
+ - lib/makura/server.rb
61
+ - lib/makura/uuid_cache.rb
62
+ - lib/makura.rb
63
+ - makura.gemspec
64
+ has_rdoc: true
65
+ homepage: http://github.com/manveru/makura
66
+ post_install_message:
67
+ rdoc_options: []
68
+
69
+ require_paths:
70
+ - lib
71
+ required_ruby_version: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: "0"
76
+ version:
77
+ required_rubygems_version: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: "0"
82
+ version:
83
+ requirements: []
84
+
85
+ rubyforge_project:
86
+ rubygems_version: 1.2.0
87
+ signing_key:
88
+ specification_version: 2
89
+ summary: Ruby wrapper around the CouchDB REST API.
90
+ test_files: []
91
+