analysand 1.0.1 → 1.1.0
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.
- 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
|