analysand 1.0.1 → 1.1.0
Sign up to get free protection for your applications and to get access to all the features.
- data/CHANGELOG +16 -0
- data/README +3 -2
- data/analysand.gemspec +4 -1
- data/bin/analysand +2 -3
- data/lib/analysand.rb +3 -5
- data/lib/analysand/change_watcher.rb +3 -1
- data/lib/analysand/connection_testing.rb +7 -1
- data/lib/analysand/database.rb +38 -147
- data/lib/analysand/errors.rb +3 -0
- data/lib/analysand/reading.rb +26 -0
- data/lib/analysand/streaming_view_response.rb +100 -0
- data/lib/analysand/version.rb +1 -1
- data/lib/analysand/view_streaming/builder.rb +142 -0
- data/lib/analysand/viewing.rb +95 -0
- data/lib/analysand/writing.rb +71 -0
- data/spec/analysand/a_response.rb +19 -0
- data/spec/analysand/change_watcher_spec.rb +18 -0
- data/spec/analysand/database_spec.rb +7 -0
- data/spec/analysand/response_spec.rb +9 -5
- data/spec/analysand/view_response_spec.rb +17 -6
- data/spec/analysand/view_streaming/builder_spec.rb +73 -0
- data/spec/analysand/view_streaming_spec.rb +122 -0
- data/spec/fixtures/vcr_cassettes/view.yml +40 -0
- data/spec/support/database_access.rb +2 -2
- metadata +90 -63
data/CHANGELOG
ADDED
@@ -0,0 +1,16 @@
|
|
1
|
+
Issue numbers refer to issues on Analysand's Github tracker:
|
2
|
+
https://github.com/yipdw/analysand/issues
|
3
|
+
|
4
|
+
1.1.0 (2012-11-03)
|
5
|
+
------------------
|
6
|
+
|
7
|
+
* View streaming (#3)
|
8
|
+
* ChangeWatchers now pass credentials when checking CouchDB status (#4)
|
9
|
+
* Some code organization cleanups
|
10
|
+
* require "analysand" now loads the Database and Instance classes
|
11
|
+
|
12
|
+
|
13
|
+
1.0.1 (2012-10-01)
|
14
|
+
------------------
|
15
|
+
|
16
|
+
* Initial release
|
data/README
CHANGED
@@ -12,9 +12,10 @@ Features:
|
|
12
12
|
* GET, PUT, DELETE on databases
|
13
13
|
* GET, PUT, DELETE, HEAD, COPY on documents
|
14
14
|
* GET, PUT on document attachments
|
15
|
-
* GET on views
|
15
|
+
* GET, POST on views
|
16
16
|
* POST /_session
|
17
17
|
* POST /_bulk_docs
|
18
|
+
* View streaming
|
18
19
|
* Celluloid::IO-based change feed watchers
|
19
20
|
* Cookie and HTTP Basic authentication for all of the above
|
20
21
|
* Database objects can be safely shared across threads
|
@@ -31,7 +32,7 @@ information.
|
|
31
32
|
|
32
33
|
Naturally, we hang with all the cool kids:
|
33
34
|
|
34
|
-
* Travis CI:
|
35
|
+
* Travis CI: https://travis-ci.org/#!/yipdw/analysand
|
35
36
|
* Code Climate: https://codeclimate.com/github/yipdw/analysand
|
36
37
|
* Gemnasium: https://gemnasium.com/yipdw/analysand
|
37
38
|
|
data/analysand.gemspec
CHANGED
@@ -6,7 +6,7 @@ Gem::Specification.new do |gem|
|
|
6
6
|
gem.email = ["yipdw@member.fsf.org"]
|
7
7
|
gem.description = %q{A terrible burden for a couch}
|
8
8
|
gem.summary = %q{A CouchDB client of dubious worth}
|
9
|
-
gem.homepage = ""
|
9
|
+
gem.homepage = "https://github.com/yipdw/analysand"
|
10
10
|
|
11
11
|
gem.files = `git ls-files`.split($\)
|
12
12
|
gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
|
@@ -15,10 +15,13 @@ Gem::Specification.new do |gem|
|
|
15
15
|
gem.require_paths = ["lib"]
|
16
16
|
gem.version = Analysand::VERSION
|
17
17
|
|
18
|
+
gem.required_ruby_version = '>= 1.9'
|
19
|
+
|
18
20
|
gem.add_dependency 'celluloid', '>= 0.12'
|
19
21
|
gem.add_dependency 'celluloid-io'
|
20
22
|
gem.add_dependency 'http_parser.rb'
|
21
23
|
gem.add_dependency 'json'
|
24
|
+
gem.add_dependency 'json-stream'
|
22
25
|
gem.add_dependency 'net-http-persistent'
|
23
26
|
gem.add_dependency 'rack'
|
24
27
|
gem.add_dependency 'yajl-ruby'
|
data/bin/analysand
CHANGED
@@ -2,13 +2,12 @@
|
|
2
2
|
|
3
3
|
$LOAD_PATH.unshift File.expand_path('../../lib', __FILE__)
|
4
4
|
|
5
|
-
require 'analysand
|
5
|
+
require 'analysand'
|
6
6
|
require 'irb'
|
7
7
|
require 'uri'
|
8
8
|
|
9
9
|
$URI = URI('http://localhost:5984/analysand_test')
|
10
10
|
|
11
|
-
|
12
11
|
def make_db(uri = $URI)
|
13
12
|
Analysand::Database.new(uri)
|
14
13
|
end
|
@@ -21,7 +20,7 @@ Type make_db to make an Analysand::Database object. The default URI is
|
|
21
20
|
|
22
21
|
To point at different databases, supply a URI object to make_db, e.g.
|
23
22
|
|
24
|
-
make_db(URI('https://couchdb.example.org:6984/supersekrit'))
|
23
|
+
make_db(URI('https://couchdb.example.org:6984/supersekrit'))
|
25
24
|
------------------------------------------------------------------------------
|
26
25
|
END
|
27
26
|
|
data/lib/analysand.rb
CHANGED
@@ -7,9 +7,15 @@ module Analysand
|
|
7
7
|
##
|
8
8
|
# Issues a HEAD request to the given URI. If it responds with a success or
|
9
9
|
# redirection code, returns true; otherwise, returns false.
|
10
|
+
#
|
11
|
+
# If a block is given, yields the request object for customization.
|
10
12
|
def test_http_connection(uri)
|
11
13
|
begin
|
12
|
-
resp = Net::HTTP.start(uri.host, uri.port)
|
14
|
+
resp = Net::HTTP.start(uri.host, uri.port) do |h|
|
15
|
+
req = Net::HTTP::Head.new(uri.request_uri)
|
16
|
+
yield req if block_given?
|
17
|
+
h.request(req)
|
18
|
+
end
|
13
19
|
|
14
20
|
case resp
|
15
21
|
when Net::HTTPSuccess then true
|
data/lib/analysand/database.rb
CHANGED
@@ -1,9 +1,11 @@
|
|
1
|
-
require 'analysand/bulk_response'
|
2
1
|
require 'analysand/errors'
|
2
|
+
require 'analysand/reading'
|
3
3
|
require 'analysand/response'
|
4
|
-
require 'analysand/
|
4
|
+
require 'analysand/viewing'
|
5
|
+
require 'analysand/writing'
|
5
6
|
require 'net/http/persistent'
|
6
7
|
require 'rack/utils'
|
8
|
+
require 'uri'
|
7
9
|
|
8
10
|
module Analysand
|
9
11
|
##
|
@@ -141,15 +143,26 @@ module Analysand
|
|
141
143
|
# vdb.view('video/recent', :key => ['member1'])
|
142
144
|
# vdb.view('video/by_artist', :startkey => 'a', :endkey => 'b')
|
143
145
|
#
|
144
|
-
# Keys are automatically JSON-encoded
|
145
|
-
#
|
146
|
+
# Keys are automatically JSON-encoded, as required by CouchDB.
|
147
|
+
#
|
148
|
+
# If you're running into problems with large key sets generating very long
|
149
|
+
# query strings, you can use POST mode (CouchDB 0.9+):
|
150
|
+
#
|
151
|
+
# vdb.view('video/by_artist', :keys => many_keys, :post => true)
|
152
|
+
#
|
153
|
+
# If you're reading many records from a view, you may want to stream them
|
154
|
+
# in:
|
155
|
+
#
|
156
|
+
# vdb.view('video/all', :stream => true)
|
157
|
+
#
|
158
|
+
# View data and metadata may be accessed as follows:
|
146
159
|
#
|
147
160
|
# resp = vdb.view('video/recent', :limit => 10)
|
148
161
|
# resp.total_rows # => 16
|
149
162
|
# resp.offset # => 0
|
150
|
-
# resp.rows # =>
|
163
|
+
# resp.rows # => an Enumerable
|
151
164
|
#
|
152
|
-
# See ViewResponse for more details.
|
165
|
+
# See ViewResponse and StreamingViewResponse for more details.
|
153
166
|
#
|
154
167
|
# You can also use view!, which will raise Analysand::CannotAccessView on a
|
155
168
|
# non-success response.
|
@@ -251,8 +264,9 @@ module Analysand
|
|
251
264
|
# is also done.
|
252
265
|
class Database
|
253
266
|
include Rack::Utils
|
254
|
-
|
255
|
-
|
267
|
+
include Reading
|
268
|
+
include Viewing
|
269
|
+
include Writing
|
256
270
|
|
257
271
|
attr_reader :http
|
258
272
|
attr_reader :uri
|
@@ -268,21 +282,18 @@ module Analysand
|
|
268
282
|
def initialize(uri)
|
269
283
|
raise InvalidURIError, 'You must supply an absolute URI' unless uri.absolute?
|
270
284
|
|
271
|
-
@http = Net::HTTP::Persistent.new('
|
285
|
+
@http = Net::HTTP::Persistent.new('analysand_database')
|
272
286
|
@uri = uri
|
273
287
|
|
274
|
-
#
|
275
|
-
#
|
288
|
+
# Document IDs and other database bits are appended to the URI path,
|
289
|
+
# so we need to make sure that it ends in a /.
|
276
290
|
unless uri.path.end_with?('/')
|
277
291
|
uri.path += '/'
|
278
292
|
end
|
279
293
|
end
|
280
294
|
|
281
295
|
def ping(credentials = nil)
|
282
|
-
|
283
|
-
set_credentials(req, credentials)
|
284
|
-
|
285
|
-
Response.new(http.request(uri, req))
|
296
|
+
Response.new _get('', credentials)
|
286
297
|
end
|
287
298
|
|
288
299
|
def status(credentials = nil)
|
@@ -293,130 +304,8 @@ module Analysand
|
|
293
304
|
http.shutdown
|
294
305
|
end
|
295
306
|
|
296
|
-
def put(doc_id, doc, credentials = nil, options = {})
|
297
|
-
query = options
|
298
|
-
headers = { 'Content-Type' => 'application/json' }
|
299
|
-
|
300
|
-
Response.new _put(doc_id, credentials, options, headers, doc.to_json)
|
301
|
-
end
|
302
|
-
|
303
|
-
def put!(doc_id, doc, credentials = nil, options = {})
|
304
|
-
put(doc_id, doc, credentials, options).tap do |resp|
|
305
|
-
raise ex(DocumentNotSaved, resp) unless resp.success?
|
306
|
-
end
|
307
|
-
end
|
308
|
-
|
309
|
-
def ensure_full_commit(credentials = nil, options = {})
|
310
|
-
headers = { 'Content-Type' => 'application/json' }
|
311
|
-
|
312
|
-
Response.new _post('_ensure_full_commit', credentials, options, headers, {}.to_json)
|
313
|
-
end
|
314
|
-
|
315
|
-
def bulk_docs(docs, credentials = nil, options = {})
|
316
|
-
headers = { 'Content-Type' => 'application/json' }
|
317
|
-
body = { 'docs' => docs }
|
318
|
-
body['all_or_nothing'] = true if options[:all_or_nothing]
|
319
|
-
|
320
|
-
BulkResponse.new _post('_bulk_docs', credentials, {}, headers, body.to_json)
|
321
|
-
end
|
322
|
-
|
323
|
-
def bulk_docs!(docs, credentials = nil, options = {})
|
324
|
-
bulk_docs(docs, credentials, options).tap do |resp|
|
325
|
-
raise ex(BulkOperationFailed, resp) unless resp.success?
|
326
|
-
end
|
327
|
-
end
|
328
|
-
|
329
|
-
def copy(source, destination, credentials = nil)
|
330
|
-
headers = { 'Destination' => destination }
|
331
|
-
|
332
|
-
Response.new _copy(source, credentials, {}, headers, nil)
|
333
|
-
end
|
334
|
-
|
335
|
-
def put_attachment(loc, io, credentials = nil, options = {})
|
336
|
-
query = {}
|
337
|
-
headers = {}
|
338
|
-
|
339
|
-
if options[:rev]
|
340
|
-
query['rev'] = options[:rev]
|
341
|
-
end
|
342
|
-
|
343
|
-
if options[:content_type]
|
344
|
-
headers['Content-Type'] = options[:content_type]
|
345
|
-
end
|
346
|
-
|
347
|
-
Response.new _put(loc, credentials, query, headers, io.read)
|
348
|
-
end
|
349
|
-
|
350
|
-
def delete(doc_id, rev, credentials = nil)
|
351
|
-
headers = { 'If-Match' => rev }
|
352
|
-
|
353
|
-
Response.new _delete(doc_id, credentials, {}, headers, nil)
|
354
|
-
end
|
355
|
-
|
356
|
-
def delete!(doc_id, rev, credentials = nil)
|
357
|
-
delete(doc_id, rev, credentials).tap do |resp|
|
358
|
-
raise ex(DocumentNotDeleted, resp) unless resp.success?
|
359
|
-
end
|
360
|
-
end
|
361
|
-
|
362
|
-
def get(doc_id, credentials = nil)
|
363
|
-
Response.new(_get(doc_id, credentials))
|
364
|
-
end
|
365
|
-
|
366
|
-
def get!(doc_id, credentials = nil)
|
367
|
-
get(doc_id, credentials).tap do |resp|
|
368
|
-
raise ex(CannotAccessDocument, resp) unless resp.success?
|
369
|
-
end
|
370
|
-
end
|
371
|
-
|
372
|
-
def head(doc_id, credentials = nil)
|
373
|
-
Response.new(_head(doc_id, credentials))
|
374
|
-
end
|
375
|
-
|
376
|
-
def get_attachment(loc, credentials = nil)
|
377
|
-
_get(loc, credentials)
|
378
|
-
end
|
379
|
-
|
380
|
-
def all_docs(parameters = {}, credentials = nil)
|
381
|
-
view('_all_docs', parameters, credentials)
|
382
|
-
end
|
383
|
-
|
384
|
-
def all_docs!(parameters = {}, credentials = nil)
|
385
|
-
view!('_all_docs', parameters, credentials)
|
386
|
-
end
|
387
|
-
|
388
|
-
def view(view_name, parameters = {}, credentials = nil)
|
389
|
-
view_path = expand_view_path(view_name)
|
390
|
-
|
391
|
-
JSON_VALUE_PARAMETERS.each do |p|
|
392
|
-
if parameters.has_key?(p)
|
393
|
-
parameters[p] = parameters[p].to_json
|
394
|
-
end
|
395
|
-
end
|
396
|
-
|
397
|
-
ViewResponse.new _get(view_path, credentials, parameters, {})
|
398
|
-
end
|
399
|
-
|
400
|
-
def expand_view_path(view_name)
|
401
|
-
if view_name.include?('/')
|
402
|
-
design_doc, view_name = view_name.split('/', 2)
|
403
|
-
"_design/#{design_doc}/_view/#{view_name}"
|
404
|
-
else
|
405
|
-
view_name
|
406
|
-
end
|
407
|
-
end
|
408
|
-
|
409
|
-
def view!(view_name, parameters = {}, credentials = nil)
|
410
|
-
view(view_name, parameters, credentials).tap do |resp|
|
411
|
-
raise ex(CannotAccessView, resp) unless resp.success?
|
412
|
-
end
|
413
|
-
end
|
414
|
-
|
415
307
|
def create(credentials = nil)
|
416
|
-
|
417
|
-
set_credentials(req, credentials)
|
418
|
-
|
419
|
-
Response.new(http.request(uri, req))
|
308
|
+
Response.new _put('', credentials)
|
420
309
|
end
|
421
310
|
|
422
311
|
def create!(credentials = nil)
|
@@ -426,10 +315,7 @@ module Analysand
|
|
426
315
|
end
|
427
316
|
|
428
317
|
def drop(credentials = nil)
|
429
|
-
|
430
|
-
set_credentials(req, credentials)
|
431
|
-
|
432
|
-
Response.new(http.request(uri, req))
|
318
|
+
Response.new _delete('', credentials)
|
433
319
|
end
|
434
320
|
|
435
321
|
def drop!(credentials = nil)
|
@@ -440,8 +326,8 @@ module Analysand
|
|
440
326
|
|
441
327
|
%w(Head Get Put Post Delete Copy).each do |m|
|
442
328
|
str = <<-END
|
443
|
-
def _#{m.downcase}(doc_id, credentials, query = {}, headers = {}, body = nil)
|
444
|
-
_req(Net::HTTP::#{m}, doc_id, credentials, query, headers, body)
|
329
|
+
def _#{m.downcase}(doc_id, credentials, query = {}, headers = {}, body = nil, block = nil)
|
330
|
+
_req(Net::HTTP::#{m}, doc_id, credentials, query, headers, body, block)
|
445
331
|
end
|
446
332
|
END
|
447
333
|
|
@@ -450,8 +336,9 @@ module Analysand
|
|
450
336
|
|
451
337
|
##
|
452
338
|
# @private
|
453
|
-
def _req(klass, doc_id, credentials, query, headers, body)
|
454
|
-
uri =
|
339
|
+
def _req(klass, doc_id, credentials, query, headers, body, block)
|
340
|
+
uri = self.uri.dup
|
341
|
+
uri.path += URI.escape(doc_id)
|
455
342
|
uri.query = build_query(query) unless query.empty?
|
456
343
|
|
457
344
|
req = klass.new(uri.request_uri)
|
@@ -460,7 +347,7 @@ module Analysand
|
|
460
347
|
req.body = body if body && req.request_body_permitted?
|
461
348
|
set_credentials(req, credentials)
|
462
349
|
|
463
|
-
http.request(uri, req)
|
350
|
+
http.request(uri, req, &block)
|
464
351
|
end
|
465
352
|
|
466
353
|
##
|
@@ -479,6 +366,10 @@ module Analysand
|
|
479
366
|
end
|
480
367
|
end
|
481
368
|
|
369
|
+
def json_headers
|
370
|
+
{ 'Content-Type' => 'application/json' }
|
371
|
+
end
|
372
|
+
|
482
373
|
##
|
483
374
|
# @private
|
484
375
|
def ex(klass, response)
|
data/lib/analysand/errors.rb
CHANGED
@@ -0,0 +1,26 @@
|
|
1
|
+
require 'analysand/errors'
|
2
|
+
require 'analysand/response'
|
3
|
+
|
4
|
+
module Analysand
|
5
|
+
module Reading
|
6
|
+
def get(doc_id, credentials = nil)
|
7
|
+
Response.new(_get(doc_id, credentials))
|
8
|
+
end
|
9
|
+
|
10
|
+
def get!(doc_id, credentials = nil)
|
11
|
+
get(doc_id, credentials).tap do |resp|
|
12
|
+
raise ex(CannotAccessDocument, resp) unless resp.success?
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
def head(doc_id, credentials = nil)
|
17
|
+
Response.new(_head(doc_id, credentials))
|
18
|
+
end
|
19
|
+
|
20
|
+
def get_attachment(loc, credentials = nil)
|
21
|
+
_get(loc, credentials)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
# vim:ts=2:sw=2:et:tw=78
|
@@ -0,0 +1,100 @@
|
|
1
|
+
require 'analysand/view_streaming/builder'
|
2
|
+
require 'fiber'
|
3
|
+
|
4
|
+
module Analysand
|
5
|
+
# Public: Controls streaming of view data.
|
6
|
+
#
|
7
|
+
# This class is meant to be used by Analysand::Database#view. It exports the
|
8
|
+
# same interface as ViewResponse.
|
9
|
+
#
|
10
|
+
# Examples:
|
11
|
+
#
|
12
|
+
# resp = db.view('view/something', :stream => true)
|
13
|
+
#
|
14
|
+
# resp.total_rows # => 1000000
|
15
|
+
# resp.offset # => 0
|
16
|
+
# resp.rows.take(100) # => first 100 rows
|
17
|
+
class StreamingViewResponse
|
18
|
+
include Enumerable
|
19
|
+
|
20
|
+
# Private: The HTTP response.
|
21
|
+
#
|
22
|
+
# This is set by Analysand::Database#stream_view. The #etag and #code
|
23
|
+
# methods use this for header information.
|
24
|
+
attr_accessor :http_response
|
25
|
+
|
26
|
+
def initialize
|
27
|
+
@reader = Fiber.new { yield self; "" }
|
28
|
+
@generator = ViewStreaming::Builder.new
|
29
|
+
|
30
|
+
# Analysand::Database#stream_view issues the request. When the response
|
31
|
+
# arrives, it yields control back here. Subsequent resumes read the
|
32
|
+
# body.
|
33
|
+
#
|
34
|
+
# We do this to provide the response headers as soon as possible.
|
35
|
+
@reader.resume
|
36
|
+
end
|
37
|
+
|
38
|
+
def etag
|
39
|
+
http_response.get_fields('ETag').first.gsub('"', '')
|
40
|
+
end
|
41
|
+
|
42
|
+
# Public: Yields documents in the view stream.
|
43
|
+
#
|
44
|
+
# Note that #docs and #rows advance the same stream, so expect to miss half
|
45
|
+
# your rows if you do something like
|
46
|
+
#
|
47
|
+
# resp.docs.zip(resp.rows)
|
48
|
+
#
|
49
|
+
# If this is a problem for you, let me know and we can work out a solution.
|
50
|
+
def docs
|
51
|
+
to_enum(:get_docs)
|
52
|
+
end
|
53
|
+
|
54
|
+
def get_docs
|
55
|
+
each { |r| yield r['doc'] if r.has_key?('doc') }
|
56
|
+
end
|
57
|
+
|
58
|
+
def code
|
59
|
+
http_response.code
|
60
|
+
end
|
61
|
+
|
62
|
+
def success?
|
63
|
+
c = code.to_i
|
64
|
+
|
65
|
+
c >= 200 && c <= 299
|
66
|
+
end
|
67
|
+
|
68
|
+
def total_rows
|
69
|
+
read until @generator.total_rows
|
70
|
+
|
71
|
+
@generator.total_rows
|
72
|
+
end
|
73
|
+
|
74
|
+
def offset
|
75
|
+
read until @generator.offset
|
76
|
+
|
77
|
+
@generator.offset
|
78
|
+
end
|
79
|
+
|
80
|
+
def read
|
81
|
+
@generator << @reader.resume
|
82
|
+
end
|
83
|
+
|
84
|
+
def each
|
85
|
+
return to_enum unless block_given?
|
86
|
+
|
87
|
+
while @reader.alive?
|
88
|
+
read while @reader.alive? && @generator.staged_rows.empty?
|
89
|
+
|
90
|
+
until @generator.staged_rows.empty?
|
91
|
+
yield @generator.staged_rows.shift
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
def rows
|
97
|
+
self
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|