jgre-couchrest 0.12.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.
Files changed (47) hide show
  1. data/LICENSE +176 -0
  2. data/README.md +68 -0
  3. data/Rakefile +66 -0
  4. data/THANKS.md +18 -0
  5. data/examples/model/example.rb +138 -0
  6. data/examples/word_count/markov +38 -0
  7. data/examples/word_count/views/books/chunked-map.js +3 -0
  8. data/examples/word_count/views/books/united-map.js +1 -0
  9. data/examples/word_count/views/markov/chain-map.js +6 -0
  10. data/examples/word_count/views/markov/chain-reduce.js +7 -0
  11. data/examples/word_count/views/word_count/count-map.js +6 -0
  12. data/examples/word_count/views/word_count/count-reduce.js +3 -0
  13. data/examples/word_count/word_count.rb +46 -0
  14. data/examples/word_count/word_count_query.rb +40 -0
  15. data/examples/word_count/word_count_views.rb +26 -0
  16. data/lib/couchrest.rb +139 -0
  17. data/lib/couchrest/commands/generate.rb +71 -0
  18. data/lib/couchrest/commands/push.rb +103 -0
  19. data/lib/couchrest/core/database.rb +241 -0
  20. data/lib/couchrest/core/design.rb +89 -0
  21. data/lib/couchrest/core/document.rb +94 -0
  22. data/lib/couchrest/core/model.rb +613 -0
  23. data/lib/couchrest/core/server.rb +51 -0
  24. data/lib/couchrest/core/view.rb +4 -0
  25. data/lib/couchrest/helper/pager.rb +103 -0
  26. data/lib/couchrest/helper/streamer.rb +44 -0
  27. data/lib/couchrest/monkeypatches.rb +38 -0
  28. data/spec/couchrest/core/couchrest_spec.rb +201 -0
  29. data/spec/couchrest/core/database_spec.rb +629 -0
  30. data/spec/couchrest/core/design_spec.rb +131 -0
  31. data/spec/couchrest/core/document_spec.rb +213 -0
  32. data/spec/couchrest/core/model_spec.rb +859 -0
  33. data/spec/couchrest/helpers/pager_spec.rb +122 -0
  34. data/spec/couchrest/helpers/streamer_spec.rb +23 -0
  35. data/spec/fixtures/attachments/README +3 -0
  36. data/spec/fixtures/attachments/couchdb.png +0 -0
  37. data/spec/fixtures/attachments/test.html +11 -0
  38. data/spec/fixtures/views/lib.js +3 -0
  39. data/spec/fixtures/views/test_view/lib.js +3 -0
  40. data/spec/fixtures/views/test_view/only-map.js +4 -0
  41. data/spec/fixtures/views/test_view/test-map.js +3 -0
  42. data/spec/fixtures/views/test_view/test-reduce.js +3 -0
  43. data/spec/spec.opts +6 -0
  44. data/spec/spec_helper.rb +20 -0
  45. data/utils/remap.rb +27 -0
  46. data/utils/subset.rb +30 -0
  47. metadata +143 -0
@@ -0,0 +1,241 @@
1
+ require 'cgi'
2
+ require "base64"
3
+
4
+ module CouchRest
5
+ class Database
6
+ attr_reader :server, :host, :name, :root
7
+ attr_accessor :bulk_save_cache_limit
8
+
9
+ # Create a CouchRest::Database adapter for the supplied CouchRest::Server
10
+ # and database name.
11
+ #
12
+ # ==== Parameters
13
+ # server<CouchRest::Server>:: database host
14
+ # name<String>:: database name
15
+ #
16
+ def initialize server, name
17
+ @name = name
18
+ @server = server
19
+ @host = server.uri
20
+ @root = "#{host}/#{name}"
21
+ @streamer = Streamer.new(self)
22
+ @bulk_save_cache = []
23
+ @bulk_save_cache_limit = 50
24
+ end
25
+
26
+ # returns the database's uri
27
+ def to_s
28
+ @root
29
+ end
30
+
31
+ # GET the database info from CouchDB
32
+ def info
33
+ CouchRest.get @root
34
+ end
35
+
36
+ # Query the <tt>_all_docs</tt> view. Accepts all the same arguments as view.
37
+ def documents params = {}
38
+ keys = params.delete(:keys)
39
+ url = CouchRest.paramify_url "#{@root}/_all_docs", params
40
+ if keys
41
+ CouchRest.post(url, {:keys => keys})
42
+ else
43
+ CouchRest.get url
44
+ end
45
+ end
46
+
47
+ # POST a temporary view function to CouchDB for querying. This is not
48
+ # recommended, as you don't get any performance benefit from CouchDB's
49
+ # materialized views. Can be quite slow on large databases.
50
+ def slow_view funcs, params = {}
51
+ keys = params.delete(:keys)
52
+ funcs = funcs.merge({:keys => keys}) if keys
53
+ url = CouchRest.paramify_url "#{@root}/_temp_view", params
54
+ JSON.parse(RestClient.post(url, funcs.to_json, {"Content-Type" => 'application/json'}))
55
+ end
56
+
57
+ # backwards compatibility is a plus
58
+ alias :temp_view :slow_view
59
+
60
+ # Query a CouchDB view as defined by a <tt>_design</tt> document. Accepts
61
+ # paramaters as described in http://wiki.apache.org/couchdb/HttpViewApi
62
+ def view name, params = {}, &block
63
+ keys = params.delete(:keys)
64
+ url = CouchRest.paramify_url "#{@root}/_view/#{name}", params
65
+ if keys
66
+ CouchRest.post(url, {:keys => keys})
67
+ else
68
+ if block_given?
69
+ @streamer.view(name, params, &block)
70
+ else
71
+ CouchRest.get url
72
+ end
73
+ end
74
+ end
75
+
76
+ # GET a document from CouchDB, by id. Returns a Ruby Hash.
77
+ def get id
78
+ slug = escape_docid(id)
79
+ hash = CouchRest.get("#{@root}/#{slug}")
80
+ doc = if /^_design/ =~ hash["_id"]
81
+ Design.new(hash)
82
+ else
83
+ Document.new(hash)
84
+ end
85
+ doc.database = self
86
+ doc
87
+ end
88
+
89
+ # GET an attachment directly from CouchDB
90
+ def fetch_attachment docid, name
91
+ slug = escape_docid(docid)
92
+ name = CGI.escape(name)
93
+ RestClient.get "#{@root}/#{slug}/#{name}"
94
+ end
95
+
96
+ # PUT an attachment directly to CouchDB
97
+ def put_attachment doc, name, file, options = {}
98
+ docid = escape_docid(doc['_id'])
99
+ name = CGI.escape(name)
100
+ uri = if doc['_rev']
101
+ "#{@root}/#{docid}/#{name}?rev=#{doc['_rev']}"
102
+ else
103
+ "#{@root}/#{docid}/#{name}"
104
+ end
105
+
106
+ JSON.parse(RestClient.put(uri, file, options))
107
+ end
108
+
109
+ # Save a document to CouchDB. This will use the <tt>_id</tt> field from
110
+ # the document as the id for PUT, or request a new UUID from CouchDB, if
111
+ # no <tt>_id</tt> is present on the document. IDs are attached to
112
+ # documents on the client side because POST has the curious property of
113
+ # being automatically retried by proxies in the event of network
114
+ # segmentation and lost responses.
115
+ #
116
+ # If <tt>bulk</tt> is true (false by default) the document is cached for bulk-saving later.
117
+ # Bulk saving happens automatically when #bulk_save_cache limit is exceded, or on the next non bulk save.
118
+ def save (doc, bulk = false)
119
+ if doc['_attachments']
120
+ doc['_attachments'] = encode_attachments(doc['_attachments'])
121
+ end
122
+ if bulk
123
+ @bulk_save_cache << doc
124
+ return bulk_save if @bulk_save_cache.length >= @bulk_save_cache_limit
125
+ return {"ok" => true} # Compatibility with Document#save
126
+ elsif !bulk && @bulk_save_cache.length > 0
127
+ bulk_save
128
+ end
129
+ result = if doc['_id']
130
+ slug = escape_docid(doc['_id'])
131
+ CouchRest.put "#{@root}/#{slug}", doc
132
+ else
133
+ begin
134
+ slug = doc['_id'] = @server.next_uuid
135
+ CouchRest.put "#{@root}/#{slug}", doc
136
+ rescue #old version of couchdb
137
+ CouchRest.post @root, doc
138
+ end
139
+ end
140
+ if result['ok']
141
+ doc['_id'] = result['id']
142
+ doc['_rev'] = result['rev']
143
+ doc.database = self if doc.respond_to?(:database=)
144
+ end
145
+ result
146
+ end
147
+
148
+ # POST an array of documents to CouchDB. If any of the documents are
149
+ # missing ids, supply one from the uuid cache.
150
+ #
151
+ # If called with no arguments, bulk saves the cache of documents to be bulk saved.
152
+ def bulk_save(docs = nil, use_uuids = true)
153
+ if docs.nil?
154
+ docs = @bulk_save_cache
155
+ @bulk_save_cache = []
156
+ end
157
+ if (use_uuids)
158
+ ids, noids = docs.partition{|d|d['_id']}
159
+ uuid_count = [noids.length, @server.uuid_batch_count].max
160
+ noids.each do |doc|
161
+ nextid = @server.next_uuid(uuid_count) rescue nil
162
+ doc['_id'] = nextid if nextid
163
+ end
164
+ end
165
+ CouchRest.post "#{@root}/_bulk_docs", {:docs => docs}
166
+ end
167
+
168
+ # DELETE the document from CouchDB that has the given <tt>_id</tt> and
169
+ # <tt>_rev</tt>.
170
+ #
171
+ # If <tt>bulk</tt> is true (false by default) the deletion is recorded for bulk-saving (bulk-deletion :) later.
172
+ # Bulk saving happens automatically when #bulk_save_cache limit is exceded, or on the next non bulk save.
173
+ def delete (doc, bulk = false)
174
+ raise ArgumentError, "_id and _rev required for deleting" unless doc['_id'] && doc['_rev']
175
+ if bulk
176
+ @bulk_save_cache << { '_id' => doc['_id'], '_rev' => doc['_rev'], '_deleted' => true }
177
+ return bulk_save if @bulk_save_cache.length >= @bulk_save_cache_limit
178
+ return { "ok" => true } # Mimic the non-deferred version
179
+ end
180
+ slug = escape_docid(doc['_id'])
181
+ CouchRest.delete "#{@root}/#{slug}?rev=#{doc['_rev']}"
182
+ end
183
+
184
+ # COPY an existing document to a new id. If the destination id currently exists, a rev must be provided.
185
+ # <tt>dest</tt> can take one of two forms if overwriting: "id_to_overwrite?rev=revision" or the actual doc
186
+ # hash with a '_rev' key
187
+ def copy doc, dest
188
+ raise ArgumentError, "_id is required for copying" unless doc['_id']
189
+ slug = escape_docid(doc['_id'])
190
+ destination = if dest.respond_to?(:has_key?) && dest['_id'] && dest['_rev']
191
+ "#{dest['_id']}?rev=#{dest['_rev']}"
192
+ else
193
+ dest
194
+ end
195
+ CouchRest.copy "#{@root}/#{slug}", destination
196
+ end
197
+
198
+ # MOVE an existing document to a new id. If the destination id currently exists, a rev must be provided.
199
+ # <tt>dest</tt> can take one of two forms if overwriting: "id_to_overwrite?rev=revision" or the actual doc
200
+ # hash with a '_rev' key
201
+ def move doc, dest
202
+ raise ArgumentError, "_id and _rev are required for moving" unless doc['_id'] && doc['_rev']
203
+ slug = escape_docid(doc['_id'])
204
+ destination = if dest.respond_to?(:has_key?) && dest['_id'] && dest['_rev']
205
+ "#{dest['_id']}?rev=#{dest['_rev']}"
206
+ else
207
+ dest
208
+ end
209
+ CouchRest.move "#{@root}/#{slug}?rev=#{doc['_rev']}", destination
210
+ end
211
+
212
+ # Compact the database, removing old document revisions and optimizing space use.
213
+ def compact!
214
+ CouchRest.post "#{@root}/_compact"
215
+ end
216
+
217
+ # DELETE the database itself. This is not undoable and could be rather
218
+ # catastrophic. Use with care!
219
+ def delete!
220
+ CouchRest.delete @root
221
+ end
222
+
223
+ private
224
+
225
+ def escape_docid id
226
+ /^_design\/(.*)/ =~ id ? "_design/#{CGI.escape($1)}" : CGI.escape(id)
227
+ end
228
+
229
+ def encode_attachments attachments
230
+ attachments.each do |k,v|
231
+ next if v['stub']
232
+ v['data'] = base64(v['data'])
233
+ end
234
+ attachments
235
+ end
236
+
237
+ def base64 data
238
+ Base64.encode64(data).gsub(/\s/,'')
239
+ end
240
+ end
241
+ end
@@ -0,0 +1,89 @@
1
+ module CouchRest
2
+ class Design < Document
3
+ def view_by *keys
4
+ opts = keys.pop if keys.last.is_a?(Hash)
5
+ opts ||= {}
6
+ self['views'] ||= {}
7
+ method_name = "by_#{keys.join('_and_')}"
8
+
9
+ if opts[:map]
10
+ view = {}
11
+ view['map'] = opts.delete(:map)
12
+ if opts[:reduce]
13
+ view['reduce'] = opts.delete(:reduce)
14
+ opts[:reduce] = false
15
+ end
16
+ self['views'][method_name] = view
17
+ else
18
+ doc_keys = keys.collect{|k|"doc['#{k}']"} # this is where :require => 'doc.x == true' would show up
19
+ key_emit = doc_keys.length == 1 ? "#{doc_keys.first}" : "[#{doc_keys.join(', ')}]"
20
+ guards = opts.delete(:guards) || []
21
+ guards.concat doc_keys
22
+ map_function = <<-JAVASCRIPT
23
+ function(doc) {
24
+ if (#{guards.join(' && ')}) {
25
+ emit(#{key_emit}, null);
26
+ }
27
+ }
28
+ JAVASCRIPT
29
+ self['views'][method_name] = {
30
+ 'map' => map_function
31
+ }
32
+ end
33
+ self['views'][method_name]['couchrest-defaults'] = opts unless opts.empty?
34
+ method_name
35
+ end
36
+
37
+ # Dispatches to any named view.
38
+ def view view_name, query={}, &block
39
+ view_name = view_name.to_s
40
+ view_slug = "#{name}/#{view_name}"
41
+ defaults = (self['views'][view_name] && self['views'][view_name]["couchrest-defaults"]) || {}
42
+ fetch_view(view_slug, defaults.merge(query), &block)
43
+ end
44
+
45
+ def name
46
+ id.sub('_design/','') if id
47
+ end
48
+
49
+ def name= newname
50
+ self['_id'] = "_design/#{newname}"
51
+ end
52
+
53
+ def save
54
+ raise ArgumentError, "_design docs require a name" unless name && name.length > 0
55
+ super
56
+ end
57
+
58
+ private
59
+
60
+ # returns stored defaults if the there is a view named this in the design doc
61
+ def has_view?(view)
62
+ view = view.to_s
63
+ self['views'][view] &&
64
+ (self['views'][view]["couchrest-defaults"]||{})
65
+ end
66
+
67
+ # def fetch_view_with_docs name, opts, raw=false, &block
68
+ # if raw
69
+ # fetch_view name, opts, &block
70
+ # else
71
+ # begin
72
+ # view = fetch_view name, opts.merge({:include_docs => true}), &block
73
+ # view['rows'].collect{|r|new(r['doc'])} if view['rows']
74
+ # rescue
75
+ # # fallback for old versions of couchdb that don't
76
+ # # have include_docs support
77
+ # view = fetch_view name, opts, &block
78
+ # view['rows'].collect{|r|new(database.get(r['id']))} if view['rows']
79
+ # end
80
+ # end
81
+ # end
82
+
83
+ def fetch_view view_name, opts, &block
84
+ database.view(view_name, opts, &block)
85
+ end
86
+
87
+ end
88
+
89
+ end
@@ -0,0 +1,94 @@
1
+ module CouchRest
2
+ class Response
3
+ def initialize keys = {}
4
+ @hash = {}
5
+ keys.each do |k,v|
6
+ self[k.to_s] = v
7
+ end
8
+ end
9
+ attr_reader :hash
10
+ def ==(other)
11
+ @hash == other.hash
12
+ end
13
+ def each(&blk)
14
+ @hash.each(&blk)
15
+ end
16
+ def []= key, value
17
+ @hash[key.to_s] = value
18
+ end
19
+ def [] key
20
+ @hash[key.to_s]
21
+ end
22
+ def to_json
23
+ @hash.to_json
24
+ end
25
+ def delete key
26
+ @hash.delete key
27
+ end
28
+ def key? key
29
+ @hash.key? key
30
+ end
31
+ def has_key? key
32
+ @hash.has_key key
33
+ end
34
+ end
35
+
36
+ class Document < Response
37
+
38
+ attr_accessor :database
39
+
40
+ # alias for self['_id']
41
+ def id
42
+ self['_id']
43
+ end
44
+
45
+ # alias for self['_rev']
46
+ def rev
47
+ self['_rev']
48
+ end
49
+
50
+ # returns true if the document has never been saved
51
+ def new_document?
52
+ !rev
53
+ end
54
+
55
+ # Saves the document to the db using create or update. Also runs the :save
56
+ # callbacks. Sets the <tt>_id</tt> and <tt>_rev</tt> fields based on
57
+ # CouchDB's response.
58
+ # If <tt>bulk</tt> is <tt>true</tt> (defaults to false) the document is cached for bulk save.
59
+ def save(bulk = false)
60
+ raise ArgumentError, "doc.database required for saving" unless database
61
+ result = database.save @hash, bulk
62
+ result['ok']
63
+ end
64
+
65
+ # Deletes the document from the database. Runs the :delete callbacks.
66
+ # Removes the <tt>_id</tt> and <tt>_rev</tt> fields, preparing the
67
+ # document to be saved to a new <tt>_id</tt>.
68
+ # If <tt>bulk</tt> is <tt>true</tt> (defaults to false) the document won't
69
+ # actually be deleted from the db until bulk save.
70
+ def destroy(bulk = false)
71
+ raise ArgumentError, "doc.database required to destroy" unless database
72
+ result = database.delete(@hash, bulk)
73
+ if result['ok']
74
+ self['_rev'] = nil
75
+ self['_id'] = nil
76
+ end
77
+ result['ok']
78
+ end
79
+
80
+ def copy(dest)
81
+ raise ArgumentError, "doc.database required to copy" unless database
82
+ result = database.copy(@hash, dest)
83
+ result['ok']
84
+ end
85
+
86
+ def move(dest)
87
+ raise ArgumentError, "doc.database required to copy" unless database
88
+ result = database.move(@hash, dest)
89
+ result['ok']
90
+ end
91
+
92
+ end
93
+
94
+ end
@@ -0,0 +1,613 @@
1
+ require 'rubygems'
2
+ begin
3
+ require 'extlib'
4
+ rescue
5
+ puts "CouchRest::Model requires extlib. This is left out of the gemspec on purpose."
6
+ raise
7
+ end
8
+ require 'digest/md5'
9
+ require File.dirname(__FILE__) + '/document'
10
+ require 'mime/types'
11
+
12
+ # = CouchRest::Model - Document modeling, the CouchDB way
13
+ module CouchRest
14
+ # = CouchRest::Model - Document modeling, the CouchDB way
15
+ #
16
+ # CouchRest::Model provides an ORM-like interface for CouchDB documents. It
17
+ # avoids all usage of <tt>method_missing</tt>, and tries to strike a balance
18
+ # between usability and magic. See CouchRest::Model#view_by for
19
+ # documentation about the view-generation system.
20
+ #
21
+ # ==== Example
22
+ #
23
+ # This is an example class using CouchRest::Model. It is taken from the
24
+ # spec/couchrest/core/model_spec.rb file, which may be even more up to date
25
+ # than this example.
26
+ #
27
+ # class Article < CouchRest::Model
28
+ # use_database CouchRest.database!('http://127.0.0.1:5984/couchrest-model-test')
29
+ # unique_id :slug
30
+ #
31
+ # view_by :date, :descending => true
32
+ # view_by :user_id, :date
33
+ #
34
+ # view_by :tags,
35
+ # :map =>
36
+ # "function(doc) {
37
+ # if (doc['couchrest-type'] == 'Article' && doc.tags) {
38
+ # doc.tags.forEach(function(tag){
39
+ # emit(tag, 1);
40
+ # });
41
+ # }
42
+ # }",
43
+ # :reduce =>
44
+ # "function(keys, values, rereduce) {
45
+ # return sum(values);
46
+ # }"
47
+ #
48
+ # key_writer :date
49
+ # key_reader :slug, :created_at, :updated_at
50
+ # key_accessor :title, :tags
51
+ #
52
+ # timestamps!
53
+ #
54
+ # before(:create, :generate_slug_from_title)
55
+ # def generate_slug_from_title
56
+ # self['slug'] = title.downcase.gsub(/[^a-z0-9]/,'-').squeeze('-').gsub(/^\-|\-$/,'')
57
+ # end
58
+ # end
59
+ #
60
+ # ==== Examples of finding articles with these views:
61
+ #
62
+ # * All the articles by Barney published in the last 24 hours. Note that we
63
+ # use <tt>{}</tt> as a special value that sorts after all strings,
64
+ # numbers, and arrays.
65
+ #
66
+ # Article.by_user_id_and_date :startkey => ["barney", Time.now - 24 * 3600], :endkey => ["barney", {}]
67
+ #
68
+ # * The most recent 20 articles. Remember that the <tt>view_by :date</tt>
69
+ # has the default option <tt>:descending => true</tt>.
70
+ #
71
+ # Article.by_date :limit => 20
72
+ #
73
+ # * The raw CouchDB view reduce result for the custom <tt>:tags</tt> view.
74
+ # In this case we'll get a count of the number of articles tagged "ruby".
75
+ #
76
+ # Article.by_tags :key => "ruby", :reduce => true
77
+ #
78
+ class Model < Document
79
+
80
+ # instantiates the hash by converting all the keys to strings.
81
+ def initialize keys = {}
82
+ super(keys)
83
+ apply_defaults
84
+ cast_keys
85
+ unless self['_id'] && self['_rev']
86
+ self['couchrest-type'] = self.class.to_s
87
+ end
88
+ end
89
+
90
+ # this is the CouchRest::Database that model classes will use unless
91
+ # they override it with <tt>use_database</tt>
92
+ cattr_accessor :default_database
93
+
94
+ class_inheritable_accessor :casts
95
+ class_inheritable_accessor :default_obj
96
+ class_inheritable_accessor :class_database
97
+ class_inheritable_accessor :design_doc
98
+ class_inheritable_accessor :design_doc_slug_cache
99
+ class_inheritable_accessor :design_doc_fresh
100
+
101
+ class << self
102
+ # override the CouchRest::Model-wide default_database
103
+ def use_database db
104
+ self.class_database = db
105
+ end
106
+
107
+ # returns the CouchRest::Database instance that this class uses
108
+ def database
109
+ self.class_database || CouchRest::Model.default_database
110
+ end
111
+
112
+ # Load a document from the database by id
113
+ def get id
114
+ doc = database.get id
115
+ new(doc)
116
+ end
117
+
118
+ # Load all documents that have the "couchrest-type" field equal to the
119
+ # name of the current class. Take the standard set of
120
+ # CouchRest::Database#view options.
121
+ def all opts = {}, &block
122
+ self.design_doc ||= Design.new(default_design_doc)
123
+ unless design_doc_fresh
124
+ refresh_design_doc
125
+ end
126
+ view :all, opts, &block
127
+ end
128
+
129
+ # Load the first document that have the "couchrest-type" field equal to
130
+ # the name of the current class.
131
+ #
132
+ # ==== Returns
133
+ # Object:: The first object instance available
134
+ # or
135
+ # Nil:: if no instances available
136
+ #
137
+ # ==== Parameters
138
+ # opts<Hash>::
139
+ # View options, see <tt>CouchRest::Database#view</tt> options for more info.
140
+ def first opts = {}
141
+ first_instance = self.all(opts.merge!(:limit => 1))
142
+ first_instance.empty? ? nil : first_instance.first
143
+ end
144
+
145
+ # Cast a field as another class. The class must be happy to have the
146
+ # field's primitive type as the argument to it's constuctur. Classes
147
+ # which inherit from CouchRest::Model are happy to act as sub-objects
148
+ # for any fields that are stored in JSON as object (and therefore are
149
+ # parsed from the JSON as Ruby Hashes).
150
+ #
151
+ # Example:
152
+ #
153
+ # class Post < CouchRest::Model
154
+ #
155
+ # key_accessor :title, :body, :author
156
+ #
157
+ # cast :author, :as => 'Author'
158
+ #
159
+ # end
160
+ #
161
+ # post.author.class #=> Author
162
+ #
163
+ # Using the same example, if a Post should have many Comments, we
164
+ # would declare it like this:
165
+ #
166
+ # class Post < CouchRest::Model
167
+ #
168
+ # key_accessor :title, :body, :author, comments
169
+ #
170
+ # cast :author, :as => 'Author'
171
+ # cast :comments, :as => ['Comment']
172
+ #
173
+ # end
174
+ #
175
+ # post.author.class #=> Author
176
+ # post.comments.class #=> Array
177
+ # post.comments.first #=> Comment
178
+ #
179
+ def cast field, opts = {}
180
+ self.casts ||= {}
181
+ self.casts[field.to_s] = opts
182
+ end
183
+
184
+ # Defines methods for reading and writing from fields in the document.
185
+ # Uses key_writer and key_reader internally.
186
+ def key_accessor *keys
187
+ key_writer *keys
188
+ key_reader *keys
189
+ end
190
+
191
+ # For each argument key, define a method <tt>key=</tt> that sets the
192
+ # corresponding field on the CouchDB document.
193
+ def key_writer *keys
194
+ keys.each do |method|
195
+ key = method.to_s
196
+ define_method "#{method}=" do |value|
197
+ self[key] = value
198
+ end
199
+ end
200
+ end
201
+
202
+ # For each argument key, define a method <tt>key</tt> that reads the
203
+ # corresponding field on the CouchDB document.
204
+ def key_reader *keys
205
+ keys.each do |method|
206
+ key = method.to_s
207
+ define_method method do
208
+ self[key]
209
+ end
210
+ end
211
+ end
212
+
213
+ def default
214
+ self.default_obj
215
+ end
216
+
217
+ def set_default hash
218
+ self.default_obj = hash
219
+ end
220
+
221
+ # Automatically set <tt>updated_at</tt> and <tt>created_at</tt> fields
222
+ # on the document whenever saving occurs. CouchRest uses a pretty
223
+ # decent time format by default. See Time#to_json
224
+ def timestamps!
225
+ before(:save) do
226
+ self['updated_at'] = Time.now
227
+ self['created_at'] = self['updated_at'] if new_document?
228
+ end
229
+ end
230
+
231
+ # Name a method that will be called before the document is first saved,
232
+ # which returns a string to be used for the document's <tt>_id</tt>.
233
+ # Because CouchDB enforces a constraint that each id must be unique,
234
+ # this can be used to enforce eg: uniq usernames. Note that this id
235
+ # must be globally unique across all document types which share a
236
+ # database, so if you'd like to scope uniqueness to this class, you
237
+ # should use the class name as part of the unique id.
238
+ def unique_id method = nil, &block
239
+ if method
240
+ define_method :set_unique_id do
241
+ self['_id'] ||= self.send(method)
242
+ end
243
+ elsif block
244
+ define_method :set_unique_id do
245
+ uniqid = block.call(self)
246
+ raise ArgumentError, "unique_id block must not return nil" if uniqid.nil?
247
+ self['_id'] ||= uniqid
248
+ end
249
+ end
250
+ end
251
+
252
+ # Define a CouchDB view. The name of the view will be the concatenation
253
+ # of <tt>by</tt> and the keys joined by <tt>_and_</tt>
254
+ #
255
+ # ==== Example views:
256
+ #
257
+ # class Post
258
+ # # view with default options
259
+ # # query with Post.by_date
260
+ # view_by :date, :descending => true
261
+ #
262
+ # # view with compound sort-keys
263
+ # # query with Post.by_user_id_and_date
264
+ # view_by :user_id, :date
265
+ #
266
+ # # view with custom map/reduce functions
267
+ # # query with Post.by_tags :reduce => true
268
+ # view_by :tags,
269
+ # :map =>
270
+ # "function(doc) {
271
+ # if (doc['couchrest-type'] == 'Post' && doc.tags) {
272
+ # doc.tags.forEach(function(tag){
273
+ # emit(doc.tag, 1);
274
+ # });
275
+ # }
276
+ # }",
277
+ # :reduce =>
278
+ # "function(keys, values, rereduce) {
279
+ # return sum(values);
280
+ # }"
281
+ # end
282
+ #
283
+ # <tt>view_by :date</tt> will create a view defined by this Javascript
284
+ # function:
285
+ #
286
+ # function(doc) {
287
+ # if (doc['couchrest-type'] == 'Post' && doc.date) {
288
+ # emit(doc.date, null);
289
+ # }
290
+ # }
291
+ #
292
+ # It can be queried by calling <tt>Post.by_date</tt> which accepts all
293
+ # valid options for CouchRest::Database#view. In addition, calling with
294
+ # the <tt>:raw => true</tt> option will return the view rows
295
+ # themselves. By default <tt>Post.by_date</tt> will return the
296
+ # documents included in the generated view.
297
+ #
298
+ # CouchRest::Database#view options can be applied at view definition
299
+ # time as defaults, and they will be curried and used at view query
300
+ # time. Or they can be overridden at query time.
301
+ #
302
+ # Custom views can be queried with <tt>:reduce => true</tt> to return
303
+ # reduce results. The default for custom views is to query with
304
+ # <tt>:reduce => false</tt>.
305
+ #
306
+ # Views are generated (on a per-model basis) lazily on first-access.
307
+ # This means that if you are deploying changes to a view, the views for
308
+ # that model won't be available until generation is complete. This can
309
+ # take some time with large databases. Strategies are in the works.
310
+ #
311
+ # To understand the capabilities of this view system more compeletly,
312
+ # it is recommended that you read the RSpec file at
313
+ # <tt>spec/core/model_spec.rb</tt>.
314
+
315
+ def view_by *keys
316
+ self.design_doc ||= Design.new(default_design_doc)
317
+ opts = keys.pop if keys.last.is_a?(Hash)
318
+ opts ||= {}
319
+ ducktype = opts.delete(:ducktype)
320
+ unless ducktype || opts[:map]
321
+ opts[:guards] ||= []
322
+ opts[:guards].push "(doc['couchrest-type'] == '#{self.to_s}')"
323
+ end
324
+ keys.push opts
325
+ self.design_doc.view_by(*keys)
326
+ self.design_doc_fresh = false
327
+ end
328
+
329
+ def method_missing m, *args
330
+ if has_view?(m)
331
+ query = args.shift || {}
332
+ view(m, query, *args)
333
+ else
334
+ super
335
+ end
336
+ end
337
+
338
+ # returns stored defaults if the there is a view named this in the design doc
339
+ def has_view?(view)
340
+ view = view.to_s
341
+ design_doc && design_doc['views'] && design_doc['views'][view]
342
+ end
343
+
344
+ # Dispatches to any named view.
345
+ def view name, query={}, &block
346
+ unless design_doc_fresh
347
+ refresh_design_doc
348
+ end
349
+ query[:raw] = true if query[:reduce]
350
+ raw = query.delete(:raw)
351
+ fetch_view_with_docs(name, query, raw, &block)
352
+ end
353
+
354
+ def all_design_doc_versions
355
+ database.documents :startkey => "_design/#{self.to_s}-",
356
+ :endkey => "_design/#{self.to_s}-\u9999"
357
+ end
358
+
359
+ # Deletes any non-current design docs that were created by this class.
360
+ # Running this when you're deployed version of your application is steadily
361
+ # and consistently using the latest code, is the way to clear out old design
362
+ # docs. Running it to early could mean that live code has to regenerate
363
+ # potentially large indexes.
364
+ def cleanup_design_docs!
365
+ ddocs = all_design_doc_versions
366
+ ddocs["rows"].each do |row|
367
+ if (row['id'] != design_doc_id)
368
+ database.delete({
369
+ "_id" => row['id'],
370
+ "_rev" => row['value']['rev']
371
+ })
372
+ end
373
+ end
374
+ end
375
+
376
+ private
377
+
378
+ def fetch_view_with_docs name, opts, raw=false, &block
379
+ if raw
380
+ fetch_view name, opts, &block
381
+ else
382
+ begin
383
+ view = fetch_view name, opts.merge({:include_docs => true}), &block
384
+ view['rows'].collect{|r|new(r['doc'])} if view['rows']
385
+ rescue
386
+ # fallback for old versions of couchdb that don't
387
+ # have include_docs support
388
+ view = fetch_view name, opts, &block
389
+ view['rows'].collect{|r|new(database.get(r['id']))} if view['rows']
390
+ end
391
+ end
392
+ end
393
+
394
+ def fetch_view view_name, opts, &block
395
+ retryable = true
396
+ begin
397
+ design_doc.view(view_name, opts, &block)
398
+ # the design doc could have been deleted by a rouge process
399
+ rescue RestClient::ResourceNotFound => e
400
+ if retryable
401
+ refresh_design_doc
402
+ retryable = false
403
+ retry
404
+ else
405
+ raise e
406
+ end
407
+ end
408
+ end
409
+
410
+ def design_doc_id
411
+ "_design/#{design_doc_slug}"
412
+ end
413
+
414
+ def design_doc_slug
415
+ return design_doc_slug_cache if design_doc_slug_cache && design_doc_fresh
416
+ funcs = []
417
+ design_doc['views'].each do |name, view|
418
+ funcs << "#{name}/#{view['map']}#{view['reduce']}"
419
+ end
420
+ md5 = Digest::MD5.hexdigest(funcs.sort.join(''))
421
+ self.design_doc_slug_cache = "#{self.to_s}-#{md5}"
422
+ end
423
+
424
+ def default_design_doc
425
+ {
426
+ "language" => "javascript",
427
+ "views" => {
428
+ 'all' => {
429
+ 'map' => "function(doc) {
430
+ if (doc['couchrest-type'] == '#{self.to_s}') {
431
+ emit(null,null);
432
+ }
433
+ }"
434
+ }
435
+ }
436
+ }
437
+ end
438
+
439
+ def refresh_design_doc
440
+ did = design_doc_id
441
+ saved = database.get(did) rescue nil
442
+ if saved
443
+ design_doc['views'].each do |name, view|
444
+ saved['views'][name] = view
445
+ end
446
+ database.save(saved)
447
+ self.design_doc = saved
448
+ else
449
+ design_doc['_id'] = did
450
+ design_doc.delete('_rev')
451
+ design_doc.database = database
452
+ design_doc.save
453
+ end
454
+ self.design_doc_fresh = true
455
+ end
456
+
457
+ end # class << self
458
+
459
+ # returns the database used by this model's class
460
+ def database
461
+ self.class.database
462
+ end
463
+
464
+ # Takes a hash as argument, and applies the values by using writer methods
465
+ # for each key. It doesn't save the document at the end. Raises a NoMethodError if the corresponding methods are
466
+ # missing. In case of error, no attributes are changed.
467
+ def update_attributes_without_saving hash
468
+ hash.each do |k, v|
469
+ raise NoMethodError, "#{k}= method not available, use key_accessor or key_writer :#{k}" unless self.respond_to?("#{k}=")
470
+ end
471
+ hash.each do |k, v|
472
+ self.send("#{k}=",v)
473
+ end
474
+ end
475
+
476
+ # Takes a hash as argument, and applies the values by using writer methods
477
+ # for each key. Raises a NoMethodError if the corresponding methods are
478
+ # missing. In case of error, no attributes are changed.
479
+ def update_attributes hash
480
+ update_attributes_without_saving hash
481
+ save
482
+ end
483
+
484
+ # for compatibility with old-school frameworks
485
+ alias :new_record? :new_document?
486
+
487
+ # Overridden to set the unique ID.
488
+ def save bulk = false
489
+ set_unique_id if new_document? && self.respond_to?(:set_unique_id)
490
+ super(bulk)
491
+ end
492
+
493
+ # Saves the document to the db using create or update. Raises an exception
494
+ # if the document is not saved properly.
495
+ def save!
496
+ raise "#{self.inspect} failed to save" unless self.save
497
+ end
498
+
499
+ # Deletes the document from the database. Runs the :destroy callbacks.
500
+ # Removes the <tt>_id</tt> and <tt>_rev</tt> fields, preparing the
501
+ # document to be saved to a new <tt>_id</tt>.
502
+ def destroy
503
+ result = database.delete self
504
+ if result['ok']
505
+ self['_rev'] = nil
506
+ self['_id'] = nil
507
+ end
508
+ result['ok']
509
+ end
510
+
511
+ # creates a file attachment to the current doc
512
+ def create_attachment(args={})
513
+ raise ArgumentError unless args[:file] && args[:name]
514
+ return if has_attachment?(args[:name])
515
+ self['_attachments'] ||= {}
516
+ set_attachment_attr(args)
517
+ rescue ArgumentError => e
518
+ raise ArgumentError, 'You must specify :file and :name'
519
+ end
520
+
521
+ # reads the data from an attachment
522
+ def read_attachment(attachment_name)
523
+ Base64.decode64(database.fetch_attachment(self.id, attachment_name))
524
+ end
525
+
526
+ # modifies a file attachment on the current doc
527
+ def update_attachment(args={})
528
+ raise ArgumentError unless args[:file] && args[:name]
529
+ return unless has_attachment?(args[:name])
530
+ delete_attachment(args[:name])
531
+ set_attachment_attr(args)
532
+ rescue ArgumentError => e
533
+ raise ArgumentError, 'You must specify :file and :name'
534
+ end
535
+
536
+ # deletes a file attachment from the current doc
537
+ def delete_attachment(attachment_name)
538
+ return unless self['_attachments']
539
+ self['_attachments'].delete attachment_name
540
+ end
541
+
542
+ # returns true if attachment_name exists
543
+ def has_attachment?(attachment_name)
544
+ !!(self['_attachments'] && self['_attachments'][attachment_name] && !self['_attachments'][attachment_name].empty?)
545
+ end
546
+
547
+ # returns URL to fetch the attachment from
548
+ def attachment_url(attachment_name)
549
+ return unless has_attachment?(attachment_name)
550
+ "#{database.root}/#{self.id}/#{attachment_name}"
551
+ end
552
+
553
+ private
554
+
555
+ def apply_defaults
556
+ return unless new_document?
557
+ if self.class.default
558
+ self.class.default.each do |k,v|
559
+ unless self.key?(k.to_s)
560
+ if v.class == Proc
561
+ self[k.to_s] = v.call
562
+ else
563
+ self[k.to_s] = Marshal.load(Marshal.dump(v))
564
+ end
565
+ end
566
+ end
567
+ end
568
+ end
569
+
570
+ def cast_keys
571
+ return unless self.class.casts
572
+ # TODO move the argument checking to the cast method for early crashes
573
+ self.class.casts.each do |k,v|
574
+ next unless self[k]
575
+ target = v[:as]
576
+ v[:send] || 'new'
577
+ if target.is_a?(Array)
578
+ klass = ::Extlib::Inflection.constantize(target[0])
579
+ self[k] = self[k].collect do |value|
580
+ (!v[:send] && klass == Time) ? Time.parse(value) : klass.send((v[:send] || 'new'), value)
581
+ end
582
+ else
583
+ self[k] = if (!v[:send] && target == 'Time')
584
+ Time.parse(self[k])
585
+ else
586
+ ::Extlib::Inflection.constantize(target).send((v[:send] || 'new'), self[k])
587
+ end
588
+ end
589
+ end
590
+ end
591
+
592
+ def encode_attachment(data)
593
+ Base64.encode64(data).gsub(/\r|\n/,'')
594
+ end
595
+
596
+ def get_mime_type(file)
597
+ MIME::Types.type_for(file.path).empty? ?
598
+ 'text\/plain' : MIME::Types.type_for(file.path).first.content_type.gsub(/\//,'\/')
599
+ end
600
+
601
+ def set_attachment_attr(args)
602
+ content_type = args[:content_type] ? args[:content_type] : get_mime_type(args[:file])
603
+ self['_attachments'][args[:name]] = {
604
+ 'content-type' => content_type,
605
+ 'data' => encode_attachment(args[:file].read)
606
+ }
607
+ end
608
+
609
+ include ::Extlib::Hook
610
+ register_instance_hooks :save, :destroy
611
+
612
+ end # class Model
613
+ end # module CouchRest