jchris-couchrest 0.9.8 → 0.9.9
Sign up to get free protection for your applications and to get access to all the features.
- data/README.rdoc +5 -0
- data/Rakefile +31 -36
- data/lib/couchrest/core/database.rb +17 -12
- data/lib/couchrest/core/model.rb +352 -0
- data/lib/couchrest/core/server.rb +7 -5
- data/lib/couchrest/helper/streamer.rb +1 -0
- data/lib/couchrest/monkeypatches.rb +12 -10
- data/lib/couchrest.rb +9 -10
- data/spec/couchrest/core/model_spec.rb +292 -0
- data/spec/couchrest_spec.rb +7 -1
- metadata +21 -2
data/README.rdoc
CHANGED
@@ -48,4 +48,9 @@ Creating and Querying Views:
|
|
48
48
|
})
|
49
49
|
puts @db.view('first/test')['rows'].inspect
|
50
50
|
|
51
|
+
== CouchRest::Model
|
51
52
|
|
53
|
+
CouchRest::Model is a module designed along the lines of DataMapper::Resource. By
|
54
|
+
including it in your class, suddenly you get all sorts of magic sugar, so that
|
55
|
+
working with CouchDB in your Rails or Merb app is no harder than working with the
|
56
|
+
standard SQL alternatives. See the CouchRest::Model documentation for and example article class that illustrates usage.
|
data/Rakefile
CHANGED
@@ -5,7 +5,7 @@ require 'spec/rake/spectask'
|
|
5
5
|
|
6
6
|
spec = Gem::Specification.new do |s|
|
7
7
|
s.name = "couchrest"
|
8
|
-
s.version = "0.9.
|
8
|
+
s.version = "0.9.9"
|
9
9
|
s.date = "2008-09-11"
|
10
10
|
s.summary = "Lean and RESTful interface to CouchDB."
|
11
11
|
s.email = "jchris@grabb.it"
|
@@ -22,42 +22,41 @@ spec = Gem::Specification.new do |s|
|
|
22
22
|
s.executables << 'couchapp'
|
23
23
|
s.add_dependency("json", ">= 1.1.2")
|
24
24
|
s.add_dependency("rest-client", ">= 0.5")
|
25
|
+
s.add_dependency("extlib", ">= 0.9.6")
|
25
26
|
end
|
26
27
|
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
integer_fields = %w(specification_version)
|
28
|
+
desc "Update Github Gemspec"
|
29
|
+
task :gemspec do
|
30
|
+
skip_fields = %w(new_platform original_platform)
|
31
|
+
integer_fields = %w(specification_version)
|
32
32
|
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
end
|
43
|
-
else
|
44
|
-
case value
|
45
|
-
when Array
|
46
|
-
value = name != "files" ? value.inspect : value.inspect.split(",").join(",\n")
|
47
|
-
when Fixnum
|
48
|
-
# leave as-is
|
49
|
-
when String
|
50
|
-
value = value.to_i if integer_fields.include?(name)
|
51
|
-
value = value.inspect
|
52
|
-
else
|
53
|
-
value = value.to_s.inspect
|
54
|
-
end
|
55
|
-
result << " s.#{name} = #{value}\n"
|
33
|
+
result = "Gem::Specification.new do |s|\n"
|
34
|
+
spec.instance_variables.each do |ivar|
|
35
|
+
value = spec.instance_variable_get(ivar)
|
36
|
+
name = ivar.split("@").last
|
37
|
+
next if skip_fields.include?(name) || value.nil? || value == "" || (value.respond_to?(:empty?) && value.empty?)
|
38
|
+
if name == "dependencies"
|
39
|
+
value.each do |d|
|
40
|
+
dep, *ver = d.to_s.split(" ")
|
41
|
+
result << " s.add_dependency #{dep.inspect}, [#{ /\(([^\,]*)/ . match(ver.join(" "))[1].inspect}]\n"
|
56
42
|
end
|
43
|
+
else
|
44
|
+
case value
|
45
|
+
when Array
|
46
|
+
value = name != "files" ? value.inspect : value.inspect.split(",").join(",\n")
|
47
|
+
when Fixnum
|
48
|
+
# leave as-is
|
49
|
+
when String
|
50
|
+
value = value.to_i if integer_fields.include?(name)
|
51
|
+
value = value.inspect
|
52
|
+
else
|
53
|
+
value = value.to_s.inspect
|
54
|
+
end
|
55
|
+
result << " s.#{name} = #{value}\n"
|
57
56
|
end
|
58
|
-
result << "end"
|
59
|
-
File.open(File.join(File.dirname(__FILE__), "#{spec.name}.gemspec"), "w"){|f| f << result}
|
60
57
|
end
|
58
|
+
result << "end"
|
59
|
+
File.open(File.join(File.dirname(__FILE__), "#{spec.name}.gemspec"), "w"){|f| f << result}
|
61
60
|
end
|
62
61
|
|
63
62
|
desc "Run all specs"
|
@@ -67,7 +66,7 @@ end
|
|
67
66
|
|
68
67
|
desc "Print specdocs"
|
69
68
|
Spec::Rake::SpecTask.new(:doc) do |t|
|
70
|
-
t.spec_opts = ["--format", "specdoc"
|
69
|
+
t.spec_opts = ["--format", "specdoc"]
|
71
70
|
t.spec_files = FileList['spec/*_spec.rb']
|
72
71
|
end
|
73
72
|
|
@@ -80,8 +79,4 @@ Rake::RDocTask.new do |rdoc|
|
|
80
79
|
end
|
81
80
|
|
82
81
|
desc "Generate the gemspec"
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
82
|
task :default => :spec
|
@@ -5,6 +5,12 @@ module CouchRest
|
|
5
5
|
class Database
|
6
6
|
attr_reader :server, :host, :name, :root
|
7
7
|
|
8
|
+
# Create a CouchRest::Database adapter for the supplied CouchRest::Server and database name.
|
9
|
+
#
|
10
|
+
# ==== Parameters
|
11
|
+
# server<CouchRest::Server>:: database host
|
12
|
+
# name<String>:: database name
|
13
|
+
#
|
8
14
|
def initialize server, name
|
9
15
|
@name = name
|
10
16
|
@server = server
|
@@ -12,52 +18,48 @@ module CouchRest
|
|
12
18
|
@root = "#{host}/#{name}"
|
13
19
|
end
|
14
20
|
|
21
|
+
# returns the database's uri
|
15
22
|
def to_s
|
16
23
|
@root
|
17
24
|
end
|
18
25
|
|
26
|
+
# GET the database info from CouchDB
|
19
27
|
def info
|
20
28
|
CouchRest.get @root
|
21
29
|
end
|
22
30
|
|
31
|
+
# Query the <tt>_all_docs</tt> view. Accepts all the same arguments as view.
|
23
32
|
def documents params = nil
|
24
33
|
url = CouchRest.paramify_url "#{@root}/_all_docs", params
|
25
34
|
CouchRest.get url
|
26
35
|
end
|
27
36
|
|
37
|
+
# POST a temporary view function to CouchDB for querying. This is not recommended, as you don't get any performance benefit from CouchDB's materialized views. Can be quite slow on large databases.
|
28
38
|
def temp_view funcs, params = nil
|
29
39
|
url = CouchRest.paramify_url "#{@root}/_temp_view", params
|
30
40
|
JSON.parse(RestClient.post(url, funcs.to_json, {"Content-Type" => 'application/json'}))
|
31
41
|
end
|
32
42
|
|
43
|
+
# Query a CouchDB view as defined by a <tt>_design</tt> document. Accepts paramaters as described in http://wiki.apache.org/couchdb/HttpViewApi
|
33
44
|
def view name, params = nil
|
34
45
|
url = CouchRest.paramify_url "#{@root}/_view/#{name}", params
|
35
46
|
CouchRest.get url
|
36
47
|
end
|
37
|
-
|
38
|
-
# experimental
|
39
|
-
def search params = nil
|
40
|
-
url = CouchRest.paramify_url "#{@root}/_search", params
|
41
|
-
CouchRest.get url
|
42
|
-
end
|
43
|
-
# experimental
|
44
|
-
def action action, params = nil
|
45
|
-
url = CouchRest.paramify_url "#{@root}/_action/#{action}", params
|
46
|
-
CouchRest.get url
|
47
|
-
end
|
48
48
|
|
49
|
+
# GET a document from CouchDB, by id. Returns a Ruby Hash.
|
49
50
|
def get id
|
50
51
|
slug = CGI.escape(id)
|
51
52
|
CouchRest.get "#{@root}/#{slug}"
|
52
53
|
end
|
53
54
|
|
55
|
+
# GET an attachment directly from CouchDB
|
54
56
|
def fetch_attachment doc, name
|
55
57
|
doc = CGI.escape(doc)
|
56
58
|
name = CGI.escape(name)
|
57
59
|
RestClient.get "#{@root}/#{doc}/#{name}"
|
58
60
|
end
|
59
61
|
|
60
|
-
# PUT or
|
62
|
+
# Save a document to CouchDB. This will use the <tt>_id</tt> field from the document as the id for PUT, or request a new UUID from CouchDB, if no <tt>_id</tt> is present on the document. IDs are attached to documents on the client side because POST has the curious property of being automatically retried by proxies in the event of network segmentation and lost responses.
|
61
63
|
def save doc
|
62
64
|
if doc['_attachments']
|
63
65
|
doc['_attachments'] = encode_attachments(doc['_attachments'])
|
@@ -75,6 +77,7 @@ module CouchRest
|
|
75
77
|
end
|
76
78
|
end
|
77
79
|
|
80
|
+
# POST an array of documents to CouchDB. If any of the documents are missing ids, supply one from the uuid cache.
|
78
81
|
def bulk_save docs
|
79
82
|
ids, noids = docs.partition{|d|d['_id']}
|
80
83
|
uuid_count = [noids.length, @server.uuid_batch_count].max
|
@@ -85,11 +88,13 @@ module CouchRest
|
|
85
88
|
CouchRest.post "#{@root}/_bulk_docs", {:docs => docs}
|
86
89
|
end
|
87
90
|
|
91
|
+
# DELETE the document from CouchDB that has the given <tt>_id</tt> and <tt>_rev</tt>.
|
88
92
|
def delete doc
|
89
93
|
slug = CGI.escape(doc['_id'])
|
90
94
|
CouchRest.delete "#{@root}/#{slug}?rev=#{doc['_rev']}"
|
91
95
|
end
|
92
96
|
|
97
|
+
# DELETE the database itself. This is not undoable and could be rather catastrophic. Use with care!
|
93
98
|
def delete!
|
94
99
|
CouchRest.delete @root
|
95
100
|
end
|
@@ -0,0 +1,352 @@
|
|
1
|
+
# = CouchRest::Model - ORM, the CouchDB way
|
2
|
+
module CouchRest
|
3
|
+
# = CouchRest::Model - ORM, the CouchDB way
|
4
|
+
#
|
5
|
+
# CouchRest::Model provides an ORM-like interface for CouchDB documents. It avoids all usage of <tt>method_missing</tt>, and tries to strike a balance between usability and magic. See CouchRest::Model::MagicViews#view_by for documentation about the view-generation system. For the other class methods, inspiried by DataMapper and ActiveRecord, see CouchRest::Model::ClassMethods. The InstanceMethods are pretty basic.
|
6
|
+
#
|
7
|
+
# ==== Example
|
8
|
+
#
|
9
|
+
# This is an example class using CouchRest::Model. It is taken from the spec/couchrest/core/model_spec.rb file, which may be even more up to date than this example.
|
10
|
+
#
|
11
|
+
# class Article
|
12
|
+
# include CouchRest::Model
|
13
|
+
# use_database CouchRest.database!('http://localhost:5984/couchrest-model-test')
|
14
|
+
# unique_id :slug
|
15
|
+
#
|
16
|
+
# view_by :date, :descending => true
|
17
|
+
# view_by :user_id, :date
|
18
|
+
#
|
19
|
+
# view_by :tags,
|
20
|
+
# :map =>
|
21
|
+
# "function(doc) {
|
22
|
+
# if (doc.type == 'Article' && doc.tags) {
|
23
|
+
# doc.tags.forEach(function(tag){
|
24
|
+
# emit(tag, 1);
|
25
|
+
# });
|
26
|
+
# }
|
27
|
+
# }",
|
28
|
+
# :reduce =>
|
29
|
+
# "function(keys, values, rereduce) {
|
30
|
+
# return sum(values);
|
31
|
+
# }"
|
32
|
+
#
|
33
|
+
# key_writer :date
|
34
|
+
# key_reader :slug, :created_at, :updated_at
|
35
|
+
# key_accessor :title, :tags
|
36
|
+
#
|
37
|
+
# timestamps!
|
38
|
+
#
|
39
|
+
# before(:create, :generate_slug_from_title)
|
40
|
+
# def generate_slug_from_title
|
41
|
+
# doc['slug'] = title.downcase.gsub(/[^a-z0-9]/,'-').squeeze('-').gsub(/^\-|\-$/,'')
|
42
|
+
# end
|
43
|
+
# end
|
44
|
+
module Model
|
45
|
+
class << self
|
46
|
+
# this is the CouchRest::Database that model classes will use unless they override it with <tt>use_database</tt>
|
47
|
+
attr_accessor :default_database
|
48
|
+
end
|
49
|
+
|
50
|
+
# instance methods on the model classes
|
51
|
+
module InstanceMethods
|
52
|
+
attr_accessor :doc
|
53
|
+
|
54
|
+
def initialize keys = {}
|
55
|
+
self.doc = {}
|
56
|
+
keys.each do |k,v|
|
57
|
+
doc[k.to_s] = v
|
58
|
+
end
|
59
|
+
unless doc['_id'] && doc['_rev']
|
60
|
+
init_doc
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
# returns the database used by this model's class
|
65
|
+
def database
|
66
|
+
self.class.database
|
67
|
+
end
|
68
|
+
|
69
|
+
# alias for doc['_id']
|
70
|
+
def id
|
71
|
+
doc['_id']
|
72
|
+
end
|
73
|
+
|
74
|
+
# alias for doc['_rev']
|
75
|
+
def rev
|
76
|
+
doc['_rev']
|
77
|
+
end
|
78
|
+
|
79
|
+
# returns true if the doc has never been saved
|
80
|
+
def new_record?
|
81
|
+
!doc['_rev']
|
82
|
+
end
|
83
|
+
|
84
|
+
# save the doc to the db using create or update
|
85
|
+
def save
|
86
|
+
if new_record?
|
87
|
+
create
|
88
|
+
else
|
89
|
+
update
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
protected
|
94
|
+
|
95
|
+
def create
|
96
|
+
set_unique_id if respond_to?(:set_unique_id) # hack
|
97
|
+
save_doc
|
98
|
+
end
|
99
|
+
|
100
|
+
def update
|
101
|
+
save_doc
|
102
|
+
end
|
103
|
+
|
104
|
+
private
|
105
|
+
|
106
|
+
def save_doc
|
107
|
+
result = database.save doc
|
108
|
+
if result['ok']
|
109
|
+
doc['_id'] = result['id']
|
110
|
+
doc['_rev'] = result['rev']
|
111
|
+
end
|
112
|
+
result['ok']
|
113
|
+
end
|
114
|
+
|
115
|
+
def init_doc
|
116
|
+
doc['type'] = self.class.to_s
|
117
|
+
end
|
118
|
+
end # module InstanceMethods
|
119
|
+
|
120
|
+
# Class methods for models that include CouchRest::Model
|
121
|
+
module ClassMethods
|
122
|
+
# override the CouchRest::Model-wide default_database
|
123
|
+
def use_database db
|
124
|
+
@database = db
|
125
|
+
end
|
126
|
+
|
127
|
+
# returns the CouchRest::Database instance that this class uses
|
128
|
+
def database
|
129
|
+
@database || CouchRest::Model.default_database
|
130
|
+
end
|
131
|
+
|
132
|
+
# load a document from the database
|
133
|
+
def get id
|
134
|
+
doc = database.get id
|
135
|
+
new(doc)
|
136
|
+
end
|
137
|
+
|
138
|
+
# Defines methods for reading and writing from fields in the document. Uses key_writer and key_reader internally.
|
139
|
+
def key_accessor *keys
|
140
|
+
key_writer *keys
|
141
|
+
key_reader *keys
|
142
|
+
end
|
143
|
+
|
144
|
+
# For each argument key, define a method <tt>key=</tt> that sets the corresponding field on the CouchDB document.
|
145
|
+
def key_writer *keys
|
146
|
+
keys.each do |method|
|
147
|
+
key = method.to_s
|
148
|
+
define_method "#{method}=" do |value|
|
149
|
+
doc[key] = value
|
150
|
+
end
|
151
|
+
end
|
152
|
+
end
|
153
|
+
|
154
|
+
# For each argument key, define a method <tt>key</tt> that reads the corresponding field on the CouchDB document.
|
155
|
+
def key_reader *keys
|
156
|
+
keys.each do |method|
|
157
|
+
key = method.to_s
|
158
|
+
define_method method do
|
159
|
+
doc[key]
|
160
|
+
end
|
161
|
+
end
|
162
|
+
end
|
163
|
+
|
164
|
+
# Automatically set <tt>updated_at</tt> and <tt>created_at</tt> fields on the document whenever saving occurs. CouchRest uses a pretty decent time format by default. See Time#to_json
|
165
|
+
def timestamps!
|
166
|
+
before(:create) do
|
167
|
+
doc['updated_at'] = doc['created_at'] = Time.now
|
168
|
+
end
|
169
|
+
before(:update) do
|
170
|
+
doc['updated_at'] = Time.now
|
171
|
+
end
|
172
|
+
end
|
173
|
+
|
174
|
+
# Name a method that will be called before the document is first saved, which returns a string to be used for the document's <tt>_id</tt>. Because CouchDB enforces a constraint that each id must be unique, this can be used to enforce eg: uniq usernames. Note that this id must be globally unique across all document types which share a database, so if you'd like to scope uniqueness to this class, you should use the class name as part of the unique id.
|
175
|
+
def unique_id method
|
176
|
+
define_method :set_unique_id do
|
177
|
+
doc['_id'] ||= self.send(method)
|
178
|
+
end
|
179
|
+
end
|
180
|
+
|
181
|
+
end # module ClassMethods
|
182
|
+
|
183
|
+
module MagicViews
|
184
|
+
|
185
|
+
# Define a CouchDB view. The name of the view will be the concatenation of <tt>by</tt> and the keys joined by <tt>_and_</tt>
|
186
|
+
#
|
187
|
+
# ==== Example views:
|
188
|
+
#
|
189
|
+
# class Post
|
190
|
+
# # view with default options
|
191
|
+
# # query with Post.by_date
|
192
|
+
# view_by :date, :descending => true
|
193
|
+
#
|
194
|
+
# # view with compound sort-keys
|
195
|
+
# # query with Post.by_user_id_and_date
|
196
|
+
# view_by :user_id, :date
|
197
|
+
#
|
198
|
+
# # view with custom map/reduce functions
|
199
|
+
# # query with Post.by_tags :reduce => true
|
200
|
+
# view_by :tags,
|
201
|
+
# :map =>
|
202
|
+
# "function(doc) {
|
203
|
+
# if (doc.type == 'Post' && doc.tags) {
|
204
|
+
# doc.tags.forEach(function(tag){
|
205
|
+
# emit(doc.tag, 1);
|
206
|
+
# });
|
207
|
+
# }
|
208
|
+
# }",
|
209
|
+
# :reduce =>
|
210
|
+
# "function(keys, values, rereduce) {
|
211
|
+
# return sum(values);
|
212
|
+
# }"
|
213
|
+
# end
|
214
|
+
#
|
215
|
+
# <tt>view_by :date</tt> will create a view defined by this Javascript function:
|
216
|
+
#
|
217
|
+
# function(doc) {
|
218
|
+
# if (doc.type == 'Post' && doc.date) {
|
219
|
+
# emit(doc.date, null);
|
220
|
+
# }
|
221
|
+
# }
|
222
|
+
#
|
223
|
+
# It can be queried by calling <tt>Post.by_date</tt> which accepts all valid options for CouchRest::Database#view. In addition, calling with the <tt>:raw => true</tt> option will return the view rows themselves. By default <tt>Post.by_date</tt> will return the documents included in the generated view.
|
224
|
+
#
|
225
|
+
# CouchRest::Database#view options can be applied at view definition time as defaults, and they will be curried and used at view query time. Or they can be overridden at query time.
|
226
|
+
#
|
227
|
+
# Custom views can be queried with <tt>:reduce => true</tt> to return reduce results. The default for custom views is to query with <tt>:reduce => false</tt>.
|
228
|
+
#
|
229
|
+
# Views are generated (on a per-model basis) lazily on first-access. This means that if you are deploying changes to a view, the views for that model won't be available until generation is complete. This can take some time with large databases. Strategies are in the works.
|
230
|
+
#
|
231
|
+
# To understand the capabilities of this view system more compeletly, it is recommended that you read the RSpec file at <tt>spec/core/model.rb</tt>.
|
232
|
+
def view_by *keys
|
233
|
+
opts = keys.pop if keys.last.is_a?(Hash)
|
234
|
+
opts ||= {}
|
235
|
+
type = self.to_s
|
236
|
+
|
237
|
+
method_name = "by_#{keys.join('_and_')}"
|
238
|
+
@@design_doc ||= default_design_doc
|
239
|
+
|
240
|
+
if opts[:map]
|
241
|
+
view = {}
|
242
|
+
view['map'] = opts.delete(:map)
|
243
|
+
if opts[:reduce]
|
244
|
+
view['reduce'] = opts.delete(:reduce)
|
245
|
+
opts[:reduce] = false
|
246
|
+
end
|
247
|
+
@@design_doc['views'][method_name] = view
|
248
|
+
else
|
249
|
+
doc_keys = keys.collect{|k|"doc['#{k}']"}
|
250
|
+
key_protection = doc_keys.join(' && ')
|
251
|
+
key_emit = doc_keys.length == 1 ? "#{doc_keys.first}" : "[#{doc_keys.join(', ')}]"
|
252
|
+
map_function = <<-JAVASCRIPT
|
253
|
+
function(doc) {
|
254
|
+
if (doc.type == '#{type}' && #{key_protection}) {
|
255
|
+
emit(#{key_emit}, null);
|
256
|
+
}
|
257
|
+
}
|
258
|
+
JAVASCRIPT
|
259
|
+
@@design_doc['views'][method_name] = {
|
260
|
+
'map' => map_function
|
261
|
+
}
|
262
|
+
end
|
263
|
+
|
264
|
+
@@design_doc_fresh = false
|
265
|
+
|
266
|
+
self.meta_class.instance_eval do
|
267
|
+
define_method method_name do |*args|
|
268
|
+
query = opts.merge(args[0] || {})
|
269
|
+
query[:raw] = true if query[:reduce]
|
270
|
+
unless @@design_doc_fresh
|
271
|
+
refresh_design_doc
|
272
|
+
end
|
273
|
+
raw = query.delete(:raw)
|
274
|
+
view_name = "#{type}/#{method_name}"
|
275
|
+
|
276
|
+
view = fetch_view(view_name, query)
|
277
|
+
if raw
|
278
|
+
view
|
279
|
+
else
|
280
|
+
# TODO this can be optimized once the include-docs patch is applied
|
281
|
+
view['rows'].collect{|r|new(database.get(r['id']))}
|
282
|
+
end
|
283
|
+
end
|
284
|
+
end
|
285
|
+
end
|
286
|
+
|
287
|
+
private
|
288
|
+
|
289
|
+
def fetch_view view_name, opts
|
290
|
+
retryable = true
|
291
|
+
begin
|
292
|
+
database.view(view_name, opts)
|
293
|
+
# the design doc could have been deleted by a rouge process
|
294
|
+
rescue RestClient::ResourceNotFound => e
|
295
|
+
if retryable
|
296
|
+
refresh_design_doc
|
297
|
+
retryable = false
|
298
|
+
retry
|
299
|
+
else
|
300
|
+
raise e
|
301
|
+
end
|
302
|
+
end
|
303
|
+
end
|
304
|
+
|
305
|
+
def design_doc_id
|
306
|
+
"_design/#{self.to_s}"
|
307
|
+
end
|
308
|
+
|
309
|
+
def default_design_doc
|
310
|
+
{
|
311
|
+
"_id" => design_doc_id,
|
312
|
+
"language" => "javascript",
|
313
|
+
"views" => {}
|
314
|
+
}
|
315
|
+
end
|
316
|
+
|
317
|
+
def refresh_design_doc
|
318
|
+
saved = database.get(design_doc_id) rescue nil
|
319
|
+
if saved
|
320
|
+
@@design_doc['views'].each do |name, view|
|
321
|
+
saved['views'][name] = view
|
322
|
+
end
|
323
|
+
database.save(saved)
|
324
|
+
else
|
325
|
+
database.save(@@design_doc)
|
326
|
+
end
|
327
|
+
@@design_doc_fresh = true
|
328
|
+
end
|
329
|
+
|
330
|
+
end # module MagicViews
|
331
|
+
|
332
|
+
module Callbacks
|
333
|
+
def self.included(model)
|
334
|
+
model.class_eval <<-EOS, __FILE__, __LINE__
|
335
|
+
include Extlib::Hook
|
336
|
+
register_instance_hooks :save, :create, :update #, :destroy
|
337
|
+
EOS
|
338
|
+
end
|
339
|
+
end # module Callbacks
|
340
|
+
|
341
|
+
# bookkeeping section
|
342
|
+
|
343
|
+
# load the code into the model class
|
344
|
+
def self.included(model)
|
345
|
+
model.send(:include, InstanceMethods)
|
346
|
+
model.extend ClassMethods
|
347
|
+
model.extend MagicViews
|
348
|
+
model.send(:include, Callbacks)
|
349
|
+
end
|
350
|
+
|
351
|
+
end # module Model
|
352
|
+
end # module CouchRest
|
@@ -6,37 +6,39 @@ module CouchRest
|
|
6
6
|
@uuid_batch_count = uuid_batch_count
|
7
7
|
end
|
8
8
|
|
9
|
-
#
|
9
|
+
# List all databases on the server
|
10
10
|
def databases
|
11
11
|
CouchRest.get "#{@uri}/_all_dbs"
|
12
12
|
end
|
13
13
|
|
14
|
+
# Returns a CouchRest::Database for the given name
|
14
15
|
def database name
|
15
16
|
CouchRest::Database.new(self, name)
|
16
17
|
end
|
17
18
|
|
18
|
-
#
|
19
|
+
# Creates the database if it doesn't exist
|
19
20
|
def database! name
|
20
21
|
create_db(name) rescue nil
|
21
22
|
database name
|
22
23
|
end
|
23
24
|
|
24
|
-
#
|
25
|
+
# GET the welcome message
|
25
26
|
def info
|
26
27
|
CouchRest.get "#{@uri}/"
|
27
28
|
end
|
28
29
|
|
29
|
-
#
|
30
|
+
# Create a database
|
30
31
|
def create_db name
|
31
32
|
CouchRest.put "#{@uri}/#{name}"
|
32
33
|
database name
|
33
34
|
end
|
34
35
|
|
35
|
-
#
|
36
|
+
# Restart the CouchDB instance
|
36
37
|
def restart!
|
37
38
|
CouchRest.post "#{@uri}/_restart"
|
38
39
|
end
|
39
40
|
|
41
|
+
# Retrive an unused UUID from CouchDB. Server instances manage caching a list of unused UUIDs.
|
40
42
|
def next_uuid count = @uuid_batch_count
|
41
43
|
@uuids ||= []
|
42
44
|
if @uuids.empty?
|
@@ -5,6 +5,7 @@ module CouchRest
|
|
5
5
|
@db = db
|
6
6
|
end
|
7
7
|
|
8
|
+
# Stream a view, yielding one row at a time. Shells out to <tt>curl</tt> to keep RAM usage low when you have millions of rows.
|
8
9
|
def view name, params = nil
|
9
10
|
urlst = /^_/.match(name) ? "#{@db.root}/#{name}" : "#{@db.root}/_view/#{name}"
|
10
11
|
url = CouchRest.paramify_url urlst, params
|
@@ -1,18 +1,20 @@
|
|
1
|
-
|
2
|
-
# this file must be loaded after the JSON gem
|
3
|
-
|
1
|
+
# This file must be loaded after the JSON gem and any other library that beats up the Time class.
|
4
2
|
class Time
|
5
|
-
#
|
6
|
-
# and is compatible with Javascript's new Date(time_string) constructor
|
7
|
-
#
|
8
|
-
#
|
3
|
+
# This date format sorts lexicographically
|
4
|
+
# and is compatible with Javascript's <tt>new Date(time_string)</tt> constructor.
|
5
|
+
# Note this this format stores all dates in UTC so that collation
|
6
|
+
# order is preserved. (There's no longer a need to set <tt>ENV['TZ'] = 'UTC'</tt>
|
7
|
+
# in your application.)
|
9
8
|
|
10
9
|
def to_json(options = nil)
|
11
|
-
|
10
|
+
u = self.utc
|
11
|
+
%("#{u.strftime("%Y/%m/%d %H:%M:%S +0000")}")
|
12
12
|
end
|
13
13
|
|
14
|
-
#
|
15
|
-
#
|
14
|
+
# Decodes the JSON time format to a UTC time.
|
15
|
+
# Based on Time.parse from ActiveSupport. ActiveSupport's version
|
16
|
+
# is more complete, returning a time in your current timezone,
|
17
|
+
# rather than keeping the time in UTC. YMMV.
|
16
18
|
# def self.parse string, fallback=nil
|
17
19
|
# d = DateTime.parse(string).new_offset
|
18
20
|
# self.utc(d.year, d.month, d.day, d.hour, d.min, d.sec)
|
data/lib/couchrest.rb
CHANGED
@@ -15,6 +15,7 @@
|
|
15
15
|
require "rubygems"
|
16
16
|
require 'json'
|
17
17
|
require 'rest_client'
|
18
|
+
require 'extlib'
|
18
19
|
|
19
20
|
$:.unshift File.dirname(__FILE__) unless
|
20
21
|
$:.include?(File.dirname(__FILE__)) ||
|
@@ -23,9 +24,11 @@ $:.unshift File.dirname(__FILE__) unless
|
|
23
24
|
|
24
25
|
require 'couchrest/monkeypatches'
|
25
26
|
|
27
|
+
# = CouchDB, close to the metal
|
26
28
|
module CouchRest
|
27
29
|
autoload :Server, 'couchrest/core/server'
|
28
30
|
autoload :Database, 'couchrest/core/database'
|
31
|
+
autoload :Model, 'couchrest/core/model'
|
29
32
|
autoload :Pager, 'couchrest/helper/pager'
|
30
33
|
autoload :FileManager, 'couchrest/helper/file_manager'
|
31
34
|
autoload :Streamer, 'couchrest/helper/streamer'
|
@@ -76,19 +79,15 @@ module CouchRest
|
|
76
79
|
# creates it if it isn't already there
|
77
80
|
# returns it after it's been created
|
78
81
|
def database! url
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
cr = CouchRest.new(uri.to_s)
|
83
|
-
cr.database!(path)
|
82
|
+
parsed = parse url
|
83
|
+
cr = CouchRest.new(parsed[:host])
|
84
|
+
cr.database!(parsed[:database])
|
84
85
|
end
|
85
86
|
|
86
87
|
def database url
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
cr = CouchRest.new(uri.to_s)
|
91
|
-
cr.database(path)
|
88
|
+
parsed = parse url
|
89
|
+
cr = CouchRest.new(parsed[:host])
|
90
|
+
cr.database(parsed[:database])
|
92
91
|
end
|
93
92
|
|
94
93
|
def put uri, doc = nil
|
@@ -0,0 +1,292 @@
|
|
1
|
+
require File.dirname(__FILE__) + '/../../spec_helper'
|
2
|
+
|
3
|
+
class Basic
|
4
|
+
include CouchRest::Model
|
5
|
+
end
|
6
|
+
|
7
|
+
class Article
|
8
|
+
include CouchRest::Model
|
9
|
+
use_database CouchRest.database!('http://localhost:5984/couchrest-model-test')
|
10
|
+
unique_id :slug
|
11
|
+
|
12
|
+
view_by :date, :descending => true
|
13
|
+
view_by :user_id, :date
|
14
|
+
|
15
|
+
view_by :tags,
|
16
|
+
:map =>
|
17
|
+
"function(doc) {
|
18
|
+
if (doc.type == 'Article' && doc.tags) {
|
19
|
+
doc.tags.forEach(function(tag){
|
20
|
+
emit(tag, 1);
|
21
|
+
});
|
22
|
+
}
|
23
|
+
}",
|
24
|
+
:reduce =>
|
25
|
+
"function(keys, values, rereduce) {
|
26
|
+
return sum(values);
|
27
|
+
}"
|
28
|
+
|
29
|
+
key_writer :date
|
30
|
+
key_reader :slug, :created_at, :updated_at
|
31
|
+
key_accessor :title, :tags
|
32
|
+
|
33
|
+
timestamps!
|
34
|
+
|
35
|
+
before(:create, :generate_slug_from_title)
|
36
|
+
def generate_slug_from_title
|
37
|
+
doc['slug'] = title.downcase.gsub(/[^a-z0-9]/,'-').squeeze('-').gsub(/^\-|\-$/,'')
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
describe CouchRest::Model do
|
42
|
+
before(:all) do
|
43
|
+
@cr = CouchRest.new(COUCHHOST)
|
44
|
+
@db = @cr.database(TESTDB)
|
45
|
+
@db.delete! rescue nil
|
46
|
+
@db = @cr.create_db(TESTDB) rescue nil
|
47
|
+
@adb = @cr.database('couchrest-model-test')
|
48
|
+
@adb.delete! rescue nil
|
49
|
+
CouchRest.database!('http://localhost:5984/couchrest-model-test')
|
50
|
+
CouchRest::Model.default_database = CouchRest.database!('http://localhost:5984/couchrest-test')
|
51
|
+
end
|
52
|
+
|
53
|
+
it "should use the default database" do
|
54
|
+
Basic.database.info['db_name'].should == 'couchrest-test'
|
55
|
+
end
|
56
|
+
|
57
|
+
it "should override the default db" do
|
58
|
+
Article.database.info['db_name'].should == 'couchrest-model-test'
|
59
|
+
end
|
60
|
+
|
61
|
+
describe "a new model" do
|
62
|
+
it "should be a new_record" do
|
63
|
+
@obj = Basic.new
|
64
|
+
@obj.should be_a_new_record
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
describe "a model with key_accessors" do
|
69
|
+
it "should allow reading keys" do
|
70
|
+
@art = Article.new
|
71
|
+
@art.doc['title'] = 'My Article Title'
|
72
|
+
@art.title.should == 'My Article Title'
|
73
|
+
end
|
74
|
+
it "should allow setting keys" do
|
75
|
+
@art = Article.new
|
76
|
+
@art.title = 'My Article Title'
|
77
|
+
@art.doc['title'].should == 'My Article Title'
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
describe "a model with key_writers" do
|
82
|
+
it "should allow setting keys" do
|
83
|
+
@art = Article.new
|
84
|
+
t = Time.now
|
85
|
+
@art.date = t
|
86
|
+
@art.doc['date'].should == t
|
87
|
+
end
|
88
|
+
it "should not allow reading keys" do
|
89
|
+
@art = Article.new
|
90
|
+
t = Time.now
|
91
|
+
@art.date = t
|
92
|
+
lambda{@art.date}.should raise_error
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
describe "a model with key_readers" do
|
97
|
+
it "should allow reading keys" do
|
98
|
+
@art = Article.new
|
99
|
+
@art.doc['slug'] = 'my-slug'
|
100
|
+
@art.slug.should == 'my-slug'
|
101
|
+
end
|
102
|
+
it "should not allow setting keys" do
|
103
|
+
@art = Article.new
|
104
|
+
lambda{@art.slug = 'My Article Title'}.should raise_error
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
describe "getting a model" do
|
109
|
+
before(:all) do
|
110
|
+
@art = Article.new(:title => 'All About Getting')
|
111
|
+
@art.save
|
112
|
+
end
|
113
|
+
it "should load and instantiate it" do
|
114
|
+
foundart = Article.get @art.id
|
115
|
+
foundart.title.should == "All About Getting"
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
describe "saving a model" do
|
120
|
+
before(:all) do
|
121
|
+
@obj = Basic.new
|
122
|
+
@obj.save.should == true
|
123
|
+
end
|
124
|
+
|
125
|
+
it "should save the doc" do
|
126
|
+
doc = @obj.database.get @obj.id
|
127
|
+
doc['_id'].should == @obj.id
|
128
|
+
end
|
129
|
+
|
130
|
+
it "should be set for resaving" do
|
131
|
+
rev = @obj.rev
|
132
|
+
@obj.doc['another-key'] = "some value"
|
133
|
+
@obj.save
|
134
|
+
@obj.rev.should_not == rev
|
135
|
+
end
|
136
|
+
|
137
|
+
it "should set the id" do
|
138
|
+
@obj.id.should be_an_instance_of String
|
139
|
+
end
|
140
|
+
|
141
|
+
it "should set the type" do
|
142
|
+
@obj.doc['type'].should == 'Basic'
|
143
|
+
end
|
144
|
+
end
|
145
|
+
|
146
|
+
describe "saving a model with a unique_id configured" do
|
147
|
+
before(:each) do
|
148
|
+
@art = Article.new
|
149
|
+
@old = Article.database.get('this-is-the-title') rescue nil
|
150
|
+
Article.database.delete(@old) if @old
|
151
|
+
end
|
152
|
+
|
153
|
+
it "should require the title" do
|
154
|
+
lambda{@art.save}.should raise_error
|
155
|
+
@art.title = 'This is the title'
|
156
|
+
@art.save.should == true
|
157
|
+
end
|
158
|
+
|
159
|
+
it "should not change the slug on update" do
|
160
|
+
@art.title = 'This is the title'
|
161
|
+
@art.save.should == true
|
162
|
+
@art.title = 'new title'
|
163
|
+
@art.save.should == true
|
164
|
+
@art.slug.should == 'this-is-the-title'
|
165
|
+
end
|
166
|
+
|
167
|
+
it "should raise an error when the slug is taken" do
|
168
|
+
@art.title = 'This is the title'
|
169
|
+
@art.save.should == true
|
170
|
+
@art2 = Article.new(:title => 'This is the title!')
|
171
|
+
lambda{@art2.save}.should raise_error
|
172
|
+
end
|
173
|
+
|
174
|
+
it "should set the slug" do
|
175
|
+
@art.title = 'This is the title'
|
176
|
+
@art.save.should == true
|
177
|
+
@art.slug.should == 'this-is-the-title'
|
178
|
+
end
|
179
|
+
|
180
|
+
it "should set the id" do
|
181
|
+
@art.title = 'This is the title'
|
182
|
+
@art.save.should == true
|
183
|
+
@art.id.should == 'this-is-the-title'
|
184
|
+
end
|
185
|
+
end
|
186
|
+
|
187
|
+
describe "a model with timestamps" do
|
188
|
+
before(:all) do
|
189
|
+
@art = Article.new(:title => "Saving this")
|
190
|
+
@art.save
|
191
|
+
end
|
192
|
+
it "should set the time on create" do
|
193
|
+
(Time.now - @art.created_at).should < 2
|
194
|
+
foundart = Article.get @art.id
|
195
|
+
foundart.created_at.should == foundart.updated_at
|
196
|
+
end
|
197
|
+
it "should set the time on update" do
|
198
|
+
@art.save
|
199
|
+
@art.created_at.should < @art.updated_at
|
200
|
+
end
|
201
|
+
end
|
202
|
+
|
203
|
+
describe "a model with simple views and a default param" do
|
204
|
+
before(:all) do
|
205
|
+
written_at = Time.now - 24 * 3600 * 7
|
206
|
+
@titles = ["this and that", "also interesting", "more fun", "some junk"]
|
207
|
+
@titles.each do |title|
|
208
|
+
a = Article.new(:title => title)
|
209
|
+
a.date = written_at
|
210
|
+
a.save
|
211
|
+
written_at += 24 * 3600
|
212
|
+
end
|
213
|
+
end
|
214
|
+
|
215
|
+
it "should create the design doc" do
|
216
|
+
Article.by_date rescue nil
|
217
|
+
doc = Article.database.get("_design/Article")
|
218
|
+
doc['views']['by_date'].should_not be_nil
|
219
|
+
end
|
220
|
+
|
221
|
+
it "should return the matching raw view result" do
|
222
|
+
view = Article.by_date :raw => true
|
223
|
+
view['rows'].length.should == 4
|
224
|
+
end
|
225
|
+
|
226
|
+
it "should return the matching objects (with descending)" do
|
227
|
+
articles = Article.by_date
|
228
|
+
articles.collect{|a|a.title}.should == @titles.reverse
|
229
|
+
end
|
230
|
+
|
231
|
+
it "should allow you to override default args" do
|
232
|
+
articles = Article.by_date :descending => false
|
233
|
+
articles.collect{|a|a.title}.should == @titles
|
234
|
+
end
|
235
|
+
end
|
236
|
+
|
237
|
+
describe "a model with a compound key view" do
|
238
|
+
before(:all) do
|
239
|
+
written_at = Time.now - 24 * 3600 * 7
|
240
|
+
@titles = ["uniq one", "even more interesting", "less fun", "not junk"]
|
241
|
+
@user_ids = ["quentin", "aaron"]
|
242
|
+
@titles.each_with_index do |title,i|
|
243
|
+
u = i % 2
|
244
|
+
a = Article.new(:title => title, :user_id => @user_ids[u])
|
245
|
+
a.date = written_at
|
246
|
+
a.save
|
247
|
+
written_at += 24 * 3600
|
248
|
+
end
|
249
|
+
end
|
250
|
+
it "should create the design doc" do
|
251
|
+
Article.by_user_id_and_date rescue nil
|
252
|
+
doc = Article.database.get("_design/Article")
|
253
|
+
doc['views']['by_date'].should_not be_nil
|
254
|
+
end
|
255
|
+
it "should sort correctly" do
|
256
|
+
articles = Article.by_user_id_and_date
|
257
|
+
articles.collect{|a|a.doc['user_id']}.should == ['aaron', 'aaron', 'quentin', 'quentin']
|
258
|
+
articles[1].title.should == 'not junk'
|
259
|
+
end
|
260
|
+
it "should be queryable with couchrest options" do
|
261
|
+
articles = Article.by_user_id_and_date :count => 1, :startkey => 'quentin'
|
262
|
+
articles.length.should == 1
|
263
|
+
articles[0].title.should == "even more interesting"
|
264
|
+
end
|
265
|
+
end
|
266
|
+
|
267
|
+
describe "with a custom view" do
|
268
|
+
before(:all) do
|
269
|
+
@titles = ["very uniq one", "even less interesting", "some fun", "really junk", "crazy bob"]
|
270
|
+
@tags = ["cool", "lame"]
|
271
|
+
@titles.each_with_index do |title,i|
|
272
|
+
u = i % 2
|
273
|
+
a = Article.new(:title => title, :tags => [@tags[u]])
|
274
|
+
a.save
|
275
|
+
end
|
276
|
+
end
|
277
|
+
it "should be available raw" do
|
278
|
+
view = Article.by_tags :raw => true
|
279
|
+
view['rows'].length.should == 5
|
280
|
+
end
|
281
|
+
|
282
|
+
it "should be default to :reduce => false" do
|
283
|
+
ars = Article.by_tags
|
284
|
+
ars.first.tags.first.should == 'cool'
|
285
|
+
end
|
286
|
+
|
287
|
+
it "should be raw when reduce is true" do
|
288
|
+
view = Article.by_tags :reduce => true, :group => true
|
289
|
+
view['rows'].find{|r|r['key'] == 'cool'}['value'].should == 3
|
290
|
+
end
|
291
|
+
end
|
292
|
+
end
|
data/spec/couchrest_spec.rb
CHANGED
@@ -139,8 +139,13 @@ describe CouchRest do
|
|
139
139
|
it "should be possible without an explicit CouchRest instantiation" do
|
140
140
|
db = CouchRest.database "http://localhost:5984/couchrest-test"
|
141
141
|
db.should be_an_instance_of(CouchRest::Database)
|
142
|
-
db.host.should == "
|
142
|
+
db.host.should == "localhost:5984"
|
143
143
|
end
|
144
|
+
# TODO add https support (need test environment...)
|
145
|
+
# it "should work with https" # do
|
146
|
+
# db = CouchRest.database "https://localhost:5984/couchrest-test"
|
147
|
+
# db.host.should == "https://localhost:5984"
|
148
|
+
# end
|
144
149
|
it "should not create the database automatically" do
|
145
150
|
db = CouchRest.database "http://localhost:5984/couchrest-test"
|
146
151
|
lambda{db.info}.should raise_error(RestClient::ResourceNotFound)
|
@@ -150,6 +155,7 @@ describe CouchRest do
|
|
150
155
|
describe "ensuring the db exists" do
|
151
156
|
it "should be super easy" do
|
152
157
|
db = CouchRest.database! "http://localhost:5984/couchrest-test-2"
|
158
|
+
db.name.should == 'couchrest-test-2'
|
153
159
|
db.info["db_name"].should == 'couchrest-test-2'
|
154
160
|
end
|
155
161
|
end
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: jchris-couchrest
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.9.
|
4
|
+
version: 0.9.9
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- J. Chris Anderson
|
@@ -9,7 +9,7 @@ autorequire:
|
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
11
|
|
12
|
-
date: 2008-09-
|
12
|
+
date: 2008-09-11 00:00:00 -07:00
|
13
13
|
default_executable:
|
14
14
|
dependencies:
|
15
15
|
- !ruby/object:Gem::Dependency
|
@@ -30,6 +30,15 @@ dependencies:
|
|
30
30
|
- !ruby/object:Gem::Version
|
31
31
|
version: "0.5"
|
32
32
|
version:
|
33
|
+
- !ruby/object:Gem::Dependency
|
34
|
+
name: extlib
|
35
|
+
version_requirement:
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - ">="
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: 0.9.6
|
41
|
+
version:
|
33
42
|
description: CouchRest provides a simple interface on top of CouchDB's RESTful HTTP API, as well as including some utility scripts for managing views and attachments.
|
34
43
|
email: jchris@grabb.it
|
35
44
|
executables:
|
@@ -70,6 +79,7 @@ files:
|
|
70
79
|
- lib/couchrest/commands/push.rb
|
71
80
|
- lib/couchrest/core
|
72
81
|
- lib/couchrest/core/database.rb
|
82
|
+
- lib/couchrest/core/model.rb
|
73
83
|
- lib/couchrest/core/server.rb
|
74
84
|
- lib/couchrest/helper
|
75
85
|
- lib/couchrest/helper/file_manager.rb
|
@@ -82,6 +92,9 @@ files:
|
|
82
92
|
- lib/couchrest/monkeypatches.rb
|
83
93
|
- lib/couchrest.rb
|
84
94
|
- spec/couchapp_spec.rb
|
95
|
+
- spec/couchrest
|
96
|
+
- spec/couchrest/core
|
97
|
+
- spec/couchrest/core/model_spec.rb
|
85
98
|
- spec/couchrest_spec.rb
|
86
99
|
- spec/database_spec.rb
|
87
100
|
- spec/file_manager_spec.rb
|
@@ -95,6 +108,12 @@ files:
|
|
95
108
|
- spec/fixtures/couchapp/views/example-map.js
|
96
109
|
- spec/fixtures/couchapp/views/example-reduce.js
|
97
110
|
- spec/fixtures/couchapp-test
|
111
|
+
- spec/fixtures/couchapp-test/my-app
|
112
|
+
- spec/fixtures/couchapp-test/my-app/attachments
|
113
|
+
- spec/fixtures/couchapp-test/my-app/attachments/index.html
|
114
|
+
- spec/fixtures/couchapp-test/my-app/views
|
115
|
+
- spec/fixtures/couchapp-test/my-app/views/example-map.js
|
116
|
+
- spec/fixtures/couchapp-test/my-app/views/example-reduce.js
|
98
117
|
- spec/fixtures/views
|
99
118
|
- spec/fixtures/views/lib.js
|
100
119
|
- spec/fixtures/views/test_view
|