couch_crumbs 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,471 @@
1
+ require "facets/string"
2
+ require "english/inflect"
3
+
4
+ module CouchCrumbs
5
+
6
+ # Document is an abstract base module that you mixin to your own classes
7
+ # to gain access to CouchDB document instances.
8
+ #
9
+ module Document
10
+
11
+ module InstanceMethods
12
+
13
+ include CouchCrumbs::Query
14
+
15
+ # Return the class-based database
16
+ def database
17
+ self.class.database
18
+ end
19
+
20
+ # Return document id (typically a UUID)
21
+ #
22
+ def id
23
+ raw["_id"]
24
+ end
25
+
26
+ # Set the document id
27
+ def id=(new_id)
28
+ raise "only new documents may set an id" unless new_document?
29
+
30
+ raw["_id"] = new_id
31
+ end
32
+
33
+ # Return document revision
34
+ #
35
+ def rev
36
+ raw["_rev"]
37
+ end
38
+
39
+ # Return the CouchCrumb document type
40
+ #
41
+ def crumb_type
42
+ raw["crumb_type"]
43
+ end
44
+
45
+ # Save a document to a database
46
+ #
47
+ def save!
48
+ raise "unable to save frozen documents" if frozen?
49
+
50
+ # Before Callback
51
+ before_save
52
+
53
+ # Update timestamps
54
+ raw["updated_at"] = Time.now if self.class.properties.include?(:updated_at)
55
+
56
+ # Save to the DB
57
+ result = JSON.parse(RestClient.put(uri, raw.to_json))
58
+
59
+ # Update ID and Rev properties
60
+ raw["_id"] = result["id"]
61
+ raw["_rev"] = result["rev"]
62
+
63
+ # After callback
64
+ after_save
65
+
66
+ result["ok"]
67
+ end
68
+
69
+ # Update and save the named properties
70
+ #
71
+ def update_attributes!(attributes = {})
72
+ attributes.each_pair do |key, value|
73
+ raw[key.to_s] = value
74
+ end
75
+
76
+ save!
77
+ end
78
+
79
+ # Return true prior to document being saved
80
+ #
81
+ def new_document?
82
+ raw["_rev"].eql?(nil)
83
+ end
84
+
85
+ # Remove document from the database
86
+ #
87
+ def destroy!
88
+ before_destroy
89
+
90
+ freeze
91
+
92
+ # destruction status
93
+ status = nil
94
+
95
+ # Since new documents haven't been saved yet, and frozen documents
96
+ # *can't* be saved, simply return true here.
97
+ if new_document?
98
+ status = true
99
+ else
100
+ result = JSON.parse(RestClient.delete(File.join(uri, "?rev=#{ rev }")))
101
+
102
+ status = result["ok"]
103
+ end
104
+
105
+ after_destroy
106
+
107
+ status
108
+ end
109
+
110
+ # Hook called after a document has been initialized
111
+ #
112
+ def after_initialize
113
+ nil
114
+ end
115
+
116
+ # Hook called during #create! before a document is #saved!
117
+ #
118
+ def before_create
119
+ nil
120
+ end
121
+
122
+ # Hook called during #create! after a document has #saved!
123
+ #
124
+ def after_create
125
+ nil
126
+ end
127
+
128
+ # Hook called during #save! before a document has #saved!
129
+ #
130
+ def before_save
131
+ nil
132
+ end
133
+
134
+ # Hook called during #save! after a document has #saved!
135
+ #
136
+ def after_save
137
+ nil
138
+ end
139
+
140
+ # Hook called during #destroy! before a document has been destroyed
141
+ #
142
+ def before_destroy
143
+ nil
144
+ end
145
+
146
+ # Hook called during #destroy! after a document has been destroyed
147
+ #
148
+ def after_destroy
149
+ nil
150
+ end
151
+
152
+ end
153
+
154
+ module ClassMethods
155
+
156
+ include CouchCrumbs::Query
157
+
158
+ # Return the useful portion of module/class type
159
+ # @todo cache crumb_type on the including base class
160
+ #
161
+ def crumb_type
162
+ class_variable_get(:@@crumb_type)
163
+ end
164
+
165
+ # Return the database to use for this class
166
+ #
167
+ def database
168
+ class_variable_get(:@@database)
169
+ end
170
+
171
+ # Set the database that documents of this type will use (will create
172
+ # a new database if name does not exist)
173
+ #
174
+ def use_database(name)
175
+ class_variable_set(:@@database, Database.new(:name => name))
176
+ end
177
+
178
+ # Return all named properties for this document type
179
+ #
180
+ def properties
181
+ class_variable_get(:@@properties)
182
+ end
183
+
184
+ # Add a named property to a document type
185
+ #
186
+ def property(name, opts = {})
187
+ name = name.to_sym
188
+ properties << name
189
+
190
+ class_eval do
191
+ # getter
192
+ define_method(name) do
193
+ raw[name.to_s]
194
+ end
195
+ # setter
196
+ define_method("#{ name }=".to_sym) do |new_value|
197
+ raw[name.to_s] = new_value
198
+ end
199
+ end
200
+ end
201
+
202
+ # Append default timestamps as named properties
203
+ # @todo - add :created_at as a read-only property
204
+ #
205
+ def timestamps!
206
+ [:created_at, :updated_at].each do |name|
207
+ property(name)
208
+ end
209
+ end
210
+
211
+ # Return the design doc for this class
212
+ #
213
+ def design_doc
214
+ Design.get!(database, :name => crumb_type)
215
+ end
216
+
217
+ # Return an array of all views for this class
218
+ #
219
+ def views(opts = {})
220
+ design_doc.views(opts)
221
+ end
222
+
223
+ # Create a default view on a given property, returning documents
224
+ #
225
+ def doc_view(*args)
226
+ # Get the design doc for this document type
227
+ design = design_doc
228
+
229
+ # Create simple views for the named properties
230
+ args.each do |prop|
231
+ view = View.create!(design, prop.to_s, View.simple_json(crumb_type, prop))
232
+
233
+ self.class.instance_eval do
234
+ define_method("by_#{ prop }".to_sym) do |opts|
235
+ query_docs(view.uri, {:descending => false}.merge(opts||{})).collect do |doc|
236
+ if doc["crumb_type"]
237
+ new(:hash => doc)
238
+ else
239
+ warn "skipping unknown document: #{ document }"
240
+ end
241
+ end
242
+ end
243
+ end
244
+ end
245
+
246
+ nil
247
+ end
248
+
249
+ # Create an advanced view from a given :template
250
+ #
251
+ def custom_view(opts = {})
252
+ raise ArgumentError.new("opts must contain a :name key") unless opts.has_key?(:name)
253
+ raise ArgumentError.new("opts must contain a :template key") unless opts.has_key?(:template)
254
+
255
+ view = View.create!(design_doc, opts[:name], View.advanced_json(opts[:template], opts))
256
+
257
+ self.class.instance_eval do
258
+ define_method("#{ opts[:name] }".to_sym) do
259
+ if view.has_reduce?
260
+ query_values(view.uri)
261
+ else
262
+ query_docs(view.uri, :descending => false).collect do |doc|
263
+ if doc["crumb_type"]
264
+ new(:hash => doc)
265
+ else
266
+ warn "skipping unknown document: #{ doc }"
267
+ end
268
+ end
269
+ end
270
+ end
271
+ end
272
+ end
273
+
274
+ # Create and save a new document
275
+ # @todo - add before_create and after_create callbacks
276
+ #
277
+ def create!(opts = {})
278
+ document = new(opts)
279
+
280
+ yield document if block_given?
281
+
282
+ document.before_create
283
+
284
+ document.save!
285
+
286
+ document.after_create
287
+
288
+ document
289
+ end
290
+
291
+ # Return a specific document given an exact id
292
+ #
293
+ def get!(id)
294
+ raise ArgumentError.new("id must not be blank") if id.empty? or id.nil?
295
+
296
+ json = RestClient.get(File.join(database.uri, id))
297
+
298
+ result = JSON.parse(json)
299
+
300
+ document = new(
301
+ :json => json
302
+ )
303
+
304
+ document
305
+ end
306
+
307
+ # Return an array of all documents of this type
308
+ #
309
+ def all(opts = {})
310
+ # Add the #all method
311
+ view = design_doc.views(:name => "all")
312
+
313
+ query_docs("#{ view.uri }".downcase, opts).collect do |doc|
314
+ if doc["crumb_type"]
315
+ get!(doc["_id"])
316
+ else
317
+ warn "skipping unknown document: #{ doc }"
318
+
319
+ nil
320
+ end
321
+ end
322
+ end
323
+
324
+ # Like parent_document :person
325
+ #
326
+ def parent_document(model, opts = {})
327
+ model = model.to_s.downcase
328
+
329
+ property("#{ model }_parent_id")
330
+
331
+ begin
332
+ parent_class = eval(model.modulize)
333
+ rescue
334
+ require "#{ model.methodize }.rb"
335
+ retry
336
+ end
337
+
338
+ self.class_eval do
339
+ define_method(model.to_sym) do
340
+ parent_class.get!(raw["#{ model }_parent_id"])
341
+ end
342
+
343
+ define_method("#{ model }=".to_sym) do |new_parent|
344
+ raise ArgumentError.new("parent documents must be saved before children") if new_parent.new_document?
345
+
346
+ raw["#{ model }_parent_id"] = new_parent.id
347
+ end
348
+ end
349
+
350
+ nil
351
+ end
352
+
353
+ # Like child_document :address
354
+ #
355
+ def child_document(model, opts = {})
356
+ model = model.to_s.downcase
357
+
358
+ property("#{ model }_child_id")
359
+
360
+ begin
361
+ child_class = eval(model.modulize)
362
+ rescue
363
+ require "#{ model.methodize }.rb"
364
+ retry
365
+ end
366
+
367
+ self.class_eval do
368
+ define_method(model.to_sym) do
369
+ child_class.get!(raw["#{ model }_child_id"])
370
+ end
371
+
372
+ define_method("#{ model }=".to_sym) do |new_child|
373
+ raise ArgumentError.new("parent documents must be saved before adding children") if new_document?
374
+
375
+ raw["#{ model }_child_id"] = new_child.id
376
+ end
377
+ end
378
+
379
+ nil
380
+ end
381
+
382
+ # Like has_many :projects
383
+ #
384
+ def child_documents(model, opts = {})
385
+ model = model.to_s.downcase
386
+
387
+ begin
388
+ child_class = eval(model.modulize)
389
+ rescue
390
+ require "#{ model.methodize }.rb"
391
+ retry
392
+ end
393
+
394
+ # Add a view to the child class
395
+ View.create!(child_class.design_doc, "#{ crumb_type }_parent_id", View.advanced_json(File.join(File.dirname(__FILE__), "json", "children.json"), :parent => self.crumb_type, :child => model))
396
+
397
+ # Add a method to access the model's new view
398
+ self.class_eval do
399
+ define_method(English::Inflect.plural(model)) do
400
+ query_docs(eval(model.modulize).views(:name => "#{ self.class.crumb_type }_parent_id").uri).collect do |doc|
401
+ child_class.get!(doc["_id"])
402
+ end
403
+ end
404
+
405
+ define_method("add_#{ model }") do |new_child|
406
+ new_child.send("#{ self.class.crumb_type }_parent_id=", self.id)
407
+ new_child.save!
408
+ end
409
+ end
410
+
411
+ nil
412
+ end
413
+
414
+ end
415
+
416
+ # Mixin our document methods
417
+ #
418
+ def self.included(base)
419
+ base.send(:include, InstanceMethods)
420
+ base.extend(ClassMethods)
421
+ # Override #initialize
422
+ base.class_eval do
423
+
424
+ # Set class variables
425
+ class_variable_set(:@@crumb_type, base.name.split('::').last.downcase)
426
+ class_variable_set(:@@database, CouchCrumbs::default_database)
427
+ class_variable_set(:@@properties, [])
428
+
429
+ # Accessors
430
+ attr_accessor :uri, :raw
431
+
432
+ # Override document #initialize
433
+ def initialize(opts = {})
434
+ raise ArgumentError.new("opts must be hash-like: #{ opts }") unless opts.respond_to?(:[])
435
+
436
+ # If :json is present, we just parse it as an existing document
437
+ if opts[:json]
438
+ self.raw = JSON.parse(opts[:json])
439
+ elsif opts[:hash]
440
+ self.raw = opts[:hash]
441
+ else
442
+ self.raw = {}
443
+
444
+ # Init special values
445
+ raw["_id"] = opts[:id] || database.server.uuids
446
+ raw["_rev"] = opts[:rev] unless opts[:rev].eql?(nil)
447
+ raw["crumb_type"] = self.class.crumb_type
448
+ raw["created_at"] = Time.now if self.class.properties.include?(:created_at)
449
+
450
+ # Init named properties
451
+ opts.each_pair do |name, value|
452
+ send("#{ name }=", value)
453
+ end
454
+ end
455
+
456
+ # This specific CouchDB document URI
457
+ self.uri = File.join(database.uri, id)
458
+
459
+ # Callback
460
+ after_initialize
461
+ end
462
+
463
+ end
464
+
465
+ # Create an advanced "all" view
466
+ View.create!(base.design_doc, "all", View.advanced_json(File.join(File.dirname(__FILE__), "json", "all.json"), :crumb_type => base.crumb_type))
467
+ end
468
+
469
+ end
470
+
471
+ end
@@ -0,0 +1,5 @@
1
+ {
2
+ "all": {
3
+ "map": "function(doc) { if (doc.crumb_type == '#crumb_type') {emit(null, doc); } }"
4
+ }
5
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "#parent_parent_id": {
3
+ "map": "function(doc) { if(doc.crumb_type == '#child'){ emit(doc['#parent_parent_id'], doc); }}"
4
+ }
5
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "_id": "#design_id",
3
+ "language": "javascript",
4
+ "views": {}
5
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "#name": {
3
+ "map": "function(doc) { if (doc.crumb_type == '#crumb_type') { emit(doc['#property'], doc); } }"
4
+ }
5
+ }
@@ -0,0 +1,95 @@
1
+ module CouchCrumbs
2
+
3
+ # Mixin to query databases and views
4
+ module Query
5
+
6
+ # Query an URI with opts and return an array of ruby hashes
7
+ # representing JSON docs.
8
+ #
9
+ # === Parameters (see: http://wiki.apache.org/couchdb/HTTP_view_API)
10
+ # key=keyvalue
11
+ # startkey=keyvalue
12
+ # startkey_docid=docid
13
+ # endkey=keyvalue
14
+ # endkey_docid=docid
15
+ # limit=max rows to return This used to be called "count" previous to Trunk SVN r731159
16
+ # stale=ok
17
+ # descending=true
18
+ # skip=number of rows to skip (very slow)
19
+ # group=true Version 0.8.0 and forward
20
+ # group_level=int
21
+ # reduce=false Trunk only (0.9)
22
+ # include_docs=true Trunk only (0.9)
23
+ #
24
+ def query_docs(uri, opts = {})
25
+ opts = {} unless opts
26
+
27
+ # Build our view query string
28
+ query_params = "?"
29
+
30
+ if opts.has_key?(:key)
31
+ query_params << %(key="#{ opts.delete(:key) }")
32
+ elsif opts.has_key?(:startkey)
33
+ query_params << %(startkey="#{ opts.delete(:startkey) }")
34
+ if opts.has_key?(:startkey_docid)
35
+ query_params << %(&startkey_docid="#{ opts.delete(:startkey_docid) }")
36
+ end
37
+ if opts.has_key?(:endkey)
38
+ query_params << %(&endkey="#{ opts.delete(:endkey) }")
39
+ if opts.has_key?(:endkey_docid)
40
+ query_params << %(&endkey_docid="#{ opts.delete(:endkey_docid) }")
41
+ end
42
+ end
43
+ end
44
+
45
+ # Escape the quoted JSON query keys
46
+ query_params = URI::escape(query_params)
47
+
48
+ # Default options
49
+ (@@default_options ||= {
50
+ :limit => 25, # limit => 0 will return metadata only
51
+ :stale => false,
52
+ :descending => false,
53
+ :skip => nil, # The skip option should only be used with small values
54
+ :group => nil,
55
+ :group_level => nil,
56
+ :include_docs => true
57
+ }).merge(opts).each do |key, value|
58
+ query_params << %(&#{ key }=#{ value }) if value
59
+ end
60
+
61
+ query_string = "#{ uri }#{ query_params }"
62
+
63
+ # Query the server and return an array of documents (will include design docs)
64
+ JSON.parse(RestClient.get(query_string))["rows"].collect do |row|
65
+ row["doc"]
66
+ end
67
+ end
68
+
69
+ # For querying views with a reduce function or other value-based views
70
+ # opts => :raw will return the raw view result, otherwise we try to
71
+ # extract a value
72
+ #
73
+ def query_values(uri, opts = {})
74
+ query_params = "?"
75
+
76
+ opts.each do |key, value|
77
+ query_params << %(&#{ key }=#{ value }) if value
78
+ end
79
+
80
+ query_string = "#{ uri }#{ query_params }"
81
+
82
+ result = JSON.parse(RestClient.get(query_string))
83
+
84
+ # Extract "value" key/value
85
+ if opts[:raw]
86
+ result
87
+ else
88
+ result["rows"].first["value"]
89
+ end
90
+ end
91
+
92
+
93
+ end
94
+
95
+ end
@@ -0,0 +1,45 @@
1
+ require "rest_client"
2
+ require "json"
3
+
4
+ module CouchCrumbs
5
+
6
+ # Represents an instance of a live running CouchDB server
7
+ #
8
+ class Server
9
+
10
+ DEFAULT_URI = "http://couchdb.local:5984".freeze
11
+
12
+ attr_accessor :uri, :status
13
+
14
+ # Create a new instance of Server
15
+ #
16
+ def initialize(opts = {})
17
+ self.uri = opts[:uri] || DEFAULT_URI
18
+
19
+ self.status = JSON.parse(RestClient.get(self.uri))
20
+ end
21
+
22
+ # Return an array of databases
23
+ # @todo - add a :refresh argument with a 10 second cache of the DBs
24
+ #
25
+ def databases
26
+ JSON.parse(RestClient.get(File.join(self.uri, "_all_dbs"))).collect do |database_name|
27
+ Database.new(:name => database_name)
28
+ end
29
+ end
30
+
31
+ # Return a new random UUID for use in documents
32
+ #
33
+ def uuids(count = 1)
34
+ uuids = JSON.parse(RestClient.get(File.join(self.uri, "_uuids?count=#{ count }")))["uuids"]
35
+
36
+ if count > 1
37
+ uuids
38
+ else
39
+ uuids.first
40
+ end
41
+ end
42
+
43
+ end
44
+
45
+ end
@@ -0,0 +1,73 @@
1
+ module CouchCrumbs
2
+
3
+ # Based on the raw JSON that make up each view in a design doc.
4
+ #
5
+ class View
6
+
7
+ include CouchCrumbs::Query
8
+
9
+ attr_accessor :raw, :uri, :name
10
+
11
+ # Return or create a new view object
12
+ #
13
+ def initialize(design, name, json)
14
+ self.name = name
15
+ self.uri = File.join(design.uri, "_view", name)
16
+ self.raw = JSON.parse(json)
17
+ end
18
+
19
+ # Create a new view and save the containing design doc
20
+ def self.create!(design, name, json)
21
+ view = new(design, name, json)
22
+
23
+ design.add_view(view)
24
+
25
+ design.save!
26
+
27
+ view
28
+ end
29
+
30
+ # Return a view as a JSON hash
31
+ #
32
+ def self.simple_json(type, property)
33
+ # Read the 'simple' template (stripping newlines and tabs)
34
+ template = File.read(File.join(File.dirname(__FILE__), "json", "simple.json")).gsub!(/(\n|\r|\t)/, '')
35
+
36
+ template.gsub!(/\#name/, property.to_s.downcase)
37
+ template.gsub!(/\#crumb_type/, type.to_s)
38
+ template.gsub!(/\#property/, property.to_s.downcase)
39
+
40
+ template
41
+ end
42
+
43
+ # Return an advanced view as a JSON hash
44
+ # template => path to a .json template
45
+ # opts => options to gsub into the template
46
+ #
47
+ def self.advanced_json(template, opts = {})
48
+ # Read the given template (strip newlines to avoid JSON parser errors)
49
+ template = File.read(template).gsub(/(\n|\r|\t|\s{2,})/, '')
50
+
51
+ # Sub in any opts
52
+ opts.each do |key, value|
53
+ template.gsub!(/\##{ key }/, value.to_s)
54
+ end
55
+
56
+ template
57
+ end
58
+
59
+ # Return a unique hash of the raw json
60
+ #
61
+ def hash
62
+ raw.hash
63
+ end
64
+
65
+ # Return true if this view will reduce values
66
+ #
67
+ def has_reduce?
68
+ raw[raw.keys.first].has_key?("reduce")
69
+ end
70
+
71
+ end
72
+
73
+ end