couch_crumbs 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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