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.
@@ -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: http://travis-ci.org/#!/yipdw/analysand
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
 
@@ -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'
@@ -2,13 +2,12 @@
2
2
 
3
3
  $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__)
4
4
 
5
- require 'analysand/database'
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
 
@@ -1,5 +1,3 @@
1
- require "analysand/version"
2
-
3
- module Analysand
4
- # Your code goes here...
5
- end
1
+ require 'analysand/database'
2
+ require 'analysand/instance'
3
+ require 'analysand/version'
@@ -129,7 +129,9 @@ module Analysand
129
129
  # ok && my_other_test
130
130
  # end
131
131
  def connection_ok
132
- test_http_connection(changes_feed_uri)
132
+ test_http_connection(changes_feed_uri) do |req|
133
+ customize_request(req)
134
+ end
133
135
  end
134
136
 
135
137
  def start
@@ -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) { |h| h.head(uri.request_uri) }
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
@@ -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/view_response'
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. The view method returns a
145
- # ViewResponse, which may be accessed like this:
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 # => [ { 'id' => ... }, ... } ]
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
- JSON_VALUE_PARAMETERS = %w(key keys startkey endkey).map(&:to_sym)
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('catalog_database')
285
+ @http = Net::HTTP::Persistent.new('analysand_database')
272
286
  @uri = uri
273
287
 
274
- # URI.join (used to calculate a document URI) will replace the database
275
- # name unless we make it clear that the database is part of the path
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
- req = Net::HTTP::Get.new(uri.to_s)
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
- req = Net::HTTP::Put.new(uri.to_s)
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
- req = Net::HTTP::Delete.new(uri.to_s)
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 = URI(self.uri.to_s + URI.escape(doc_id))
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)
@@ -23,4 +23,7 @@ module Analysand
23
23
 
24
24
  class BulkOperationFailed < DatabaseError
25
25
  end
26
+
27
+ class UnexpectedViewKey < StandardError
28
+ end
26
29
  end
@@ -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