samflores-couch_surfer 0.0.6

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,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