samflores-couch_surfer 0.0.6

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,639 @@
1
+ require 'rubygems'
2
+ require 'extlib'
3
+ require 'couchrest'
4
+ require 'digest/md5'
5
+ require 'mime/types'
6
+
7
+ module CouchSurfer
8
+ module Model
9
+ @database = nil
10
+ def self.default_database
11
+ @database
12
+ end
13
+
14
+ def self.default_database= db
15
+ @database = db
16
+ end
17
+
18
+ # Adapted from ActiveSupport Time#formatted_offset
19
+ def self.format_utc_offset(time)
20
+ seconds_offset_from_utc = time.utc_offset
21
+ sign = (seconds_offset_from_utc < 0 ? -1 : 1)
22
+ hours = seconds_offset_from_utc.abs / 3600
23
+ minutes = (seconds_offset_from_utc.abs % 3600) / 60
24
+ "%+03d%02d" % [ hours * sign, minutes ]
25
+ end
26
+
27
+ module ClassMethods
28
+ # override the CouchSurfer::Model-wide default_database
29
+ def use_database db
30
+ self.class_database = db
31
+ end
32
+
33
+ # returns the CouchRest::Database instance that this class uses
34
+ def database
35
+ self.class_database || CouchSurfer::Model.default_database
36
+ end
37
+
38
+ # Load a document from the database by id
39
+ def get id
40
+ doc = database.get id
41
+ new(doc)
42
+ end
43
+
44
+ def create(attrs = {})
45
+ instance = new(attrs)
46
+ instance.save
47
+ instance
48
+ end
49
+
50
+ # Load all documents that have the "couchrest-type" field equal to the
51
+ # name of the current class. Take the standard set of
52
+ # CouchRest::Database#view options.
53
+ def all opts = {}, &block
54
+ self.design_doc ||= CouchRest::Design.new(default_design_doc)
55
+ unless design_doc_fresh
56
+ refresh_design_doc
57
+ end
58
+ view :all, opts, &block
59
+ end
60
+
61
+ # Load the first document that have the "couchrest-type" field equal to
62
+ # the name of the current class.
63
+ #
64
+ # ==== Returns
65
+ # Object:: The first object instance available
66
+ # or
67
+ # Nil:: if no instances available
68
+ #
69
+ # ==== Parameters
70
+ # opts<Hash>::
71
+ # View options, see <tt>CouchRest::Database#view</tt> options for more info.
72
+ def first opts = {}
73
+ first_instance = self.all(opts.merge!(:limit => 1))
74
+ first_instance.empty? ? nil : first_instance.first
75
+ end
76
+
77
+ # Cast a field as another class. The class must be happy to have the
78
+ # field's primitive type as the argument to it's constuctur. Classes
79
+ # which inherit from CouchRest::Model are happy to act as sub-objects
80
+ # for any fields that are stored in JSON as object (and therefore are
81
+ # parsed from the JSON as Ruby Hashes).
82
+ #
83
+ # Example:
84
+ #
85
+ # class Post < CouchRest::Model
86
+ #
87
+ # key_accessor :title, :body, :author
88
+ #
89
+ # cast :author, :as => 'Author'
90
+ #
91
+ # end
92
+ #
93
+ # post.author.class #=> Author
94
+ #
95
+ # Using the same example, if a Post should have many Comments, we
96
+ # would declare it like this:
97
+ #
98
+ # class Post < CouchRest::Model
99
+ #
100
+ # key_accessor :title, :body, :author, comments
101
+ #
102
+ # cast :author, :as => 'Author'
103
+ # cast :comments, :as => ['Comment']
104
+ #
105
+ # end
106
+ #
107
+ # post.author.class #=> Author
108
+ # post.comments.class #=> Array
109
+ # post.comments.first #=> Comment
110
+ #
111
+ def cast field, opts = {}
112
+ self.casts ||= {}
113
+ self.casts[field.to_s] = opts
114
+ end
115
+
116
+ # Defines methods for reading and writing from fields in the document.
117
+ # Uses key_writer and key_reader internally.
118
+ def key_accessor *keys
119
+ key_writer *keys
120
+ key_reader *keys
121
+ end
122
+
123
+ # For each argument key, define a method <tt>key=</tt> that sets the
124
+ # corresponding field on the CouchDB document.
125
+ def key_writer *keys
126
+ keys.each do |method|
127
+ key = method.to_s
128
+ define_method "#{method}=" do |value|
129
+ #puts "#@attributes['#{method}'] = #{value}"
130
+ @attributes[method] = value# ||= nil
131
+ end
132
+ end
133
+ end
134
+
135
+ # For each argument key, define a method <tt>key</tt> that reads the
136
+ # corresponding field on the CouchDB document.
137
+ def key_reader *keys
138
+ keys.each do |method|
139
+ key = method.to_s
140
+ define_method method do
141
+ @attributes[method]
142
+ end
143
+ end
144
+ end
145
+
146
+ def set_default hash
147
+ self.default_obj = hash
148
+ end
149
+
150
+ def default
151
+ self.default_obj
152
+ end
153
+
154
+ # Automatically set <tt>updated_at</tt> and <tt>created_at</tt> fields
155
+ # on the document whenever saving occurs. Save in a format parseable by Time
156
+ # and including milliseconds
157
+
158
+ def timestamps!
159
+ %w(updated_at created_at).each do |method|
160
+ define_method method do
161
+ Time.parse(@attributes[method])
162
+ end
163
+ end
164
+ before(:save) do
165
+ time = Time.now
166
+ utc_offset = CouchSurfer::Model.format_utc_offset(time)
167
+ self['updated_at'] = time.strftime("%Y/%m/%d %H:%M:%S.#{time.usec} #{utc_offset}")
168
+ self['created_at'] = self['updated_at'] if new_document?
169
+ end
170
+ end
171
+
172
+ # Name a method that will be called before the document is first saved,
173
+ # which returns a string to be used for the document's <tt>_id</tt>.
174
+ # Because CouchDB enforces a constraint that each id must be unique,
175
+ # this can be used to enforce eg: uniq usernames. Note that this id
176
+ # must be globally unique across all document types which share a
177
+ # database, so if you'd like to scope uniqueness to this class, you
178
+ # should use the class name as part of the unique id.
179
+ def unique_id method = nil, &block
180
+ if method
181
+ define_method :set_unique_id do
182
+ self['_id'] ||= self.send(method)
183
+ end
184
+ elsif block
185
+ define_method :set_unique_id do
186
+ uniqid = block.call(self)
187
+ raise ArgumentError, "unique_id block must not return nil" if uniqid.nil?
188
+ self['_id'] ||= uniqid
189
+ end
190
+ end
191
+ end
192
+
193
+ # Define a CouchDB view. The name of the view will be the concatenation
194
+ # of <tt>by</tt> and the keys joined by <tt>_and_</tt>
195
+ #
196
+ # ==== Example views:
197
+ #
198
+ # class Post
199
+ # # view with default options
200
+ # # query with Post.by_date
201
+ # view_by :date, :descending => true
202
+ #
203
+ # # view with compound sort-keys
204
+ # # query with Post.by_user_id_and_date
205
+ # view_by :user_id, :date
206
+ #
207
+ # # view with custom map/reduce functions
208
+ # # query with Post.by_tags :reduce => true
209
+ # view_by :tags,
210
+ # :map =>
211
+ # "function(doc) {
212
+ # if (doc['couchrest-type'] == 'Post' && doc.tags) {
213
+ # doc.tags.forEach(function(tag){
214
+ # emit(doc.tag, 1);
215
+ # });
216
+ # }
217
+ # }",
218
+ # :reduce =>
219
+ # "function(keys, values, rereduce) {
220
+ # return sum(values);
221
+ # }"
222
+ # end
223
+ #
224
+ # <tt>view_by :date</tt> will create a view defined by this Javascript
225
+ # function:
226
+ #
227
+ # function(doc) {
228
+ # if (doc['couchrest-type'] == 'Post' && doc.date) {
229
+ # emit(doc.date, null);
230
+ # }
231
+ # }
232
+ #
233
+ # It can be queried by calling <tt>Post.by_date</tt> which accepts all
234
+ # valid options for CouchRest::Database#view. In addition, calling with
235
+ # the <tt>:raw => true</tt> option will return the view rows
236
+ # themselves. By default <tt>Post.by_date</tt> will return the
237
+ # documents included in the generated view.
238
+ #
239
+ # CouchRest::Database#view options can be applied at view definition
240
+ # time as defaults, and they will be curried and used at view query
241
+ # time. Or they can be overridden at query time.
242
+ #
243
+ # Custom views can be queried with <tt>:reduce => true</tt> to return
244
+ # reduce results. The default for custom views is to query with
245
+ # <tt>:reduce => false</tt>.
246
+ #
247
+ # Views are generated (on a per-model basis) lazily on first-access.
248
+ # This means that if you are deploying changes to a view, the views for
249
+ # that model won't be available until generation is complete. This can
250
+ # take some time with large databases. Strategies are in the works.
251
+ #
252
+ # To understand the capabilities of this view system more compeletly,
253
+ # it is recommended that you read the RSpec file at
254
+ # <tt>spec/core/model_spec.rb</tt>.
255
+ def view_by *keys
256
+ self.design_doc ||= CouchRest::Design.new(default_design_doc)
257
+ opts = keys.pop if keys.last.is_a?(Hash)
258
+ opts ||= {}
259
+ ducktype = opts.delete(:ducktype)
260
+ unless ducktype || opts[:map]
261
+ opts[:guards] ||= []
262
+ opts[:guards].push "(doc['couchrest-type'] == '#{self.to_s}')"
263
+ end
264
+ keys.push opts
265
+ self.design_doc.view_by(*keys)
266
+ self.design_doc_fresh = false
267
+ end
268
+
269
+ def method_missing m, *args
270
+ if has_view?(m)
271
+ query = args.shift || {}
272
+ view(m, query, *args)
273
+ else
274
+ CouchRest::Document.send(:method_missing, m, *args)
275
+ end
276
+ end
277
+
278
+ # returns stored defaults if the there is a view named this in the design doc
279
+ def has_view?(view)
280
+ view = view.to_s
281
+ design_doc && design_doc['views'] && design_doc['views'][view]
282
+ end
283
+
284
+ # Dispatches to any named view.
285
+ def view name, query={}, &block
286
+ unless design_doc_fresh
287
+ refresh_design_doc
288
+ end
289
+ query[:raw] = true if query[:reduce]
290
+ raw = query.delete(:raw)
291
+ fetch_view_with_docs(name, query, raw, &block)
292
+ end
293
+
294
+ def all_design_doc_versions
295
+ database.documents :startkey => "_design/#{self.to_s}-",
296
+ :endkey => "_design/#{self.to_s}-\u9999"
297
+ end
298
+
299
+ # Deletes any non-current design docs that were created by this class.
300
+ # Running this when you're deployed version of your application is steadily
301
+ # and consistently using the latest code, is the way to clear out old design
302
+ # docs. Running it too early could mean that live code has to regenerate
303
+ # potentially large indexes.
304
+ def cleanup_design_docs!
305
+ ddocs = all_design_doc_versions
306
+ ddocs["rows"].each do |row|
307
+ if (row['id'] != design_doc_id)
308
+ database.delete({
309
+ "_id" => row['id'],
310
+ "_rev" => row['value']['rev']
311
+ })
312
+ end
313
+ end
314
+ end
315
+
316
+ private
317
+
318
+ def fetch_view_with_docs name, opts, raw=false, &block
319
+ if raw
320
+ fetch_view name, opts, &block
321
+ else
322
+ begin
323
+ view = fetch_view name, opts.merge({:include_docs => true}), &block
324
+ view['rows'].collect{|r|new(r['doc'])} if view['rows']
325
+ rescue
326
+ # fallback for old versions of couchdb that don't
327
+ # have include_docs support
328
+ view = fetch_view name, opts, &block
329
+ view['rows'].collect{|r|new(database.get(r['id']))} if view['rows']
330
+ end
331
+ end
332
+ end
333
+
334
+ def fetch_view view_name, opts, &block
335
+ retryable = true
336
+ begin
337
+ design_doc.view(view_name, opts, &block)
338
+ # the design doc could have been deleted by a rouge process
339
+ rescue RestClient::ResourceNotFound => e
340
+ if retryable
341
+ refresh_design_doc
342
+ retryable = false
343
+ retry
344
+ else
345
+ raise e
346
+ end
347
+ end
348
+ end
349
+
350
+ def design_doc_id
351
+ "_design/#{design_doc_slug}"
352
+ end
353
+
354
+ def design_doc_slug
355
+ return design_doc_slug_cache if design_doc_slug_cache && design_doc_fresh
356
+ funcs = []
357
+ design_doc['views'].each do |name, view|
358
+ funcs << "#{name}/#{view['map']}#{view['reduce']}"
359
+ end
360
+ md5 = Digest::MD5.hexdigest(funcs.sort.join(''))
361
+ self.design_doc_slug_cache = "#{self.to_s}-#{md5}"
362
+ end
363
+
364
+ def default_design_doc
365
+ {
366
+ "language" => "javascript",
367
+ "views" => {
368
+ 'all' => {
369
+ 'map' => "function(doc) {
370
+ if (doc['couchrest-type'] == '#{self.to_s}') {
371
+ emit(doc['_id'],null);
372
+ }
373
+ }"
374
+ }
375
+ }
376
+ }
377
+ end
378
+
379
+ def refresh_design_doc
380
+ did = design_doc_id
381
+ saved = database.get(did) rescue nil
382
+ if saved
383
+ design_doc['views'].each do |name, view|
384
+ saved['views'][name] = view
385
+ end
386
+ database.save(saved)
387
+ design_doc['_id'] = did
388
+ design_doc.database = database
389
+ else
390
+ design_doc['_id'] = did
391
+ design_doc.delete('_rev')
392
+ design_doc.database = database
393
+ design_doc.save
394
+ end
395
+ self.design_doc_fresh = true
396
+ end
397
+
398
+ end
399
+
400
+ module InstanceMethods
401
+
402
+ # returns the database used by this model's class
403
+ def database
404
+ self.class.database
405
+ end
406
+
407
+ # Takes a hash as argument, and applies the values by using writer methods
408
+ # for each key. It doesn't save the document at the end. Raises a NoMethodError if the corresponding methods are
409
+ # missing. In case of error, no attributes are changed.
410
+ def update_attributes_without_saving hash
411
+ hash.each do |k, v|
412
+ raise NoMethodError, "#{k}= method not available, use key_accessor or key_writer :#{k}" unless self.respond_to?("#{k}=")
413
+ end
414
+ hash.each do |k, v|
415
+ @attributes[k] = v
416
+ end
417
+ end
418
+
419
+ # Takes a hash as argument, and applies the values by using writer methods
420
+ # for each key. Raises a NoMethodError if the corresponding methods are
421
+ # missing. In case of error, no attributes are changed.
422
+ def update_attributes hash
423
+ update_attributes_without_saving hash
424
+ save
425
+ end
426
+
427
+ def []= key, value
428
+ @attributes[key.to_s] = value
429
+ end
430
+
431
+ def [] key
432
+ @attributes[key.to_s]
433
+ end
434
+
435
+ def key? key
436
+ @attributes.key? key
437
+ end
438
+
439
+ def new_document?
440
+ !@attributes['_rev']
441
+ end
442
+
443
+ def id
444
+ @attributes.id
445
+ end
446
+
447
+ def identity_field
448
+ [CouchSurfer::Model, :id]
449
+ end
450
+
451
+ def rev
452
+ @attributes.rev
453
+ end
454
+
455
+ # Is this OK?
456
+ def == other_object
457
+ return false unless other_object.respond_to?(:attributes)
458
+ attributes == other_object.attributes
459
+ end
460
+
461
+ # def save
462
+ # @attributes.database = self.class.database
463
+ # @attributes.save
464
+ # end
465
+
466
+ # Overridden to set the unique ID.
467
+ def save bulk = false
468
+ if new_document?
469
+ create(bulk)
470
+ else
471
+ update(bulk)
472
+ end
473
+ end
474
+
475
+ # Saves the document to the db using create or update. Raises an exception
476
+ # if the document is not saved properly.
477
+ def save!
478
+ raise "#{self.inspect} failed to save" unless self.save
479
+ end
480
+
481
+ # Deletes the document from the database. Runs the :destroy callbacks.
482
+ # Removes the <tt>_id</tt> and <tt>_rev</tt> fields, preparing the
483
+ # document to be saved to a new <tt>_id</tt>.
484
+ def destroy
485
+ result = database.delete self
486
+ if result['ok']
487
+ self['_rev'] = nil
488
+ self['_id'] = nil
489
+ end
490
+ result['ok']
491
+ end
492
+
493
+ # creates a file attachment to the current doc
494
+ def create_attachment(args={})
495
+ raise ArgumentError unless args[:file] && args[:name]
496
+ return if has_attachment?(args[:name])
497
+ self['_attachments'] ||= {}
498
+ set_attachment_attr(args)
499
+ rescue ArgumentError => e
500
+ raise ArgumentError, 'You must specify :file and :name'
501
+ end
502
+
503
+ # reads the data from an attachment
504
+ def read_attachment(attachment_name)
505
+ Base64.decode64(database.fetch_attachment(self.id, attachment_name))
506
+ end
507
+
508
+ # modifies a file attachment on the current doc
509
+ def update_attachment(args={})
510
+ raise ArgumentError unless args[:file] && args[:name]
511
+ return unless has_attachment?(args[:name])
512
+ delete_attachment(args[:name])
513
+ set_attachment_attr(args)
514
+ rescue ArgumentError => e
515
+ raise ArgumentError, 'You must specify :file and :name'
516
+ end
517
+
518
+ # deletes a file attachment from the current doc
519
+ def delete_attachment(attachment_name)
520
+ return unless self['_attachments']
521
+ self['_attachments'].delete attachment_name
522
+ end
523
+
524
+ # returns true if attachment_name exists
525
+ def has_attachment?(attachment_name)
526
+ !!(self['_attachments'] && self['_attachments'][attachment_name] && !self['_attachments'][attachment_name].empty?)
527
+ end
528
+
529
+ # returns URL to fetch the attachment from
530
+ def attachment_url(attachment_name)
531
+ return unless has_attachment?(attachment_name)
532
+ "#{database.root}/#{self.id}/#{attachment_name}"
533
+ end
534
+
535
+
536
+ protected
537
+
538
+ def create bulk = false
539
+ set_unique_id if self.respond_to?(:set_unique_id)
540
+ save_doc(bulk)
541
+ end
542
+
543
+ def update bulk = false
544
+ save_doc(bulk)
545
+ end
546
+
547
+ def save_doc bulk = false
548
+ @attributes.database = self.class.database
549
+ @attributes.save(bulk)
550
+ end
551
+
552
+ private
553
+
554
+ def apply_defaults
555
+ return unless new_document?
556
+ if self.class.default
557
+ self.class.default.each do |k,v|
558
+ unless self.key?(k.to_s)
559
+ if v.class == Proc
560
+ self[k.to_s] = v.call
561
+ else
562
+ self[k.to_s] = Marshal.load(Marshal.dump(v))
563
+ end
564
+ end
565
+ end
566
+ end
567
+ end
568
+
569
+ def cast_keys
570
+ return unless self.class.casts
571
+ # TODO move the argument checking to the cast method for early crashes
572
+ self.class.casts.each do |k,v|
573
+ next unless self[k]
574
+ target = v[:as]
575
+ v[:send] || 'new'
576
+ if target.is_a?(Array)
577
+ klass = ::Extlib::Inflection.constantize(target[0])
578
+ self[k] = self[k].collect do |value|
579
+ (!v[:send] && klass == Time) ? Time.parse(value) : klass.send((v[:send] || 'new'), value)
580
+ end
581
+ else
582
+ self[k] = if (!v[:send] && target == 'Time')
583
+ Time.parse(self[k])
584
+ else
585
+ ::Extlib::Inflection.constantize(target).send((v[:send] || 'new'), self[k])
586
+ end
587
+ end
588
+ end
589
+ end
590
+
591
+ def encode_attachment(data)
592
+ Base64.encode64(data).gsub(/\r|\n/,'')
593
+ end
594
+
595
+ def get_mime_type(file)
596
+ MIME::Types.type_for(file.path).empty? ?
597
+ 'text\/plain' : MIME::Types.type_for(file.path).first.content_type.gsub(/\//,'\/')
598
+ end
599
+
600
+ def set_attachment_attr(args)
601
+ content_type = args[:content_type] ? args[:content_type] : get_mime_type(args[:file])
602
+ self['_attachments'][args[:name]] = {
603
+ 'content-type' => content_type,
604
+ 'data' => encode_attachment(args[:file].read)
605
+ }
606
+ end
607
+ end
608
+
609
+ def self.included(receiver)
610
+ receiver.extend ClassMethods
611
+ receiver.send :include, InstanceMethods
612
+ receiver.class_eval do
613
+
614
+ attr_reader :attributes
615
+ cattr_accessor :default_database
616
+ class_inheritable_accessor :class_database
617
+ class_inheritable_accessor :default_obj
618
+ class_inheritable_accessor :casts
619
+ class_inheritable_accessor :design_doc
620
+ class_inheritable_accessor :design_doc_slug_cache
621
+ class_inheritable_accessor :design_doc_fresh
622
+
623
+ alias :new_record? :new_document?
624
+
625
+ def initialize attrs = {}
626
+ @attributes = CouchRest::Document.new(attrs)
627
+ apply_defaults
628
+ cast_keys
629
+ unless self['_id'] && self['_rev']
630
+ @attributes['couchrest-type'] = self.class.to_s
631
+ end
632
+ end
633
+
634
+ include ::Extlib::Hook
635
+ register_instance_hooks :save, :create, :update, :destroy
636
+ end
637
+ end
638
+ end
639
+ end