analysand 1.0.1 → 1.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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