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.
@@ -1,3 +1,3 @@
1
1
  module Analysand
2
- VERSION = "1.0.1"
2
+ VERSION = "1.1.0"
3
3
  end
@@ -0,0 +1,142 @@
1
+ require 'analysand/errors'
2
+ require 'json/stream'
3
+
4
+ module Analysand
5
+ module ViewStreaming
6
+ # Private: A wrapper around JSON::Stream::Parser that extracts data from a
7
+ # CouchDB view document.
8
+ class Builder
9
+ attr_reader :offset
10
+ attr_reader :total_rows
11
+
12
+ # Rows constructed by the JSON parser that are ready for
13
+ # StreamingViewResponse#each.
14
+ attr_reader :staged_rows
15
+
16
+ # JSON::Stream::Parser callback methods.
17
+ CALLBACK_METHODS = %w(
18
+ start_object end_object start_array end_array key value
19
+ )
20
+
21
+ # If we find a key in the toplevel view object that isn't one of these,
22
+ # we raise UnexpectedViewKey.
23
+ KNOWN_KEYS = %w(
24
+ total_rows offset rows
25
+ )
26
+
27
+ def initialize
28
+ @in_rows = false
29
+ @parser = JSON::Stream::Parser.new
30
+ @stack = []
31
+ @staged_rows = []
32
+
33
+ CALLBACK_METHODS.each { |name| @parser.send(name, &method(name)) }
34
+ end
35
+
36
+ def <<(data)
37
+ @parser << data
38
+ end
39
+
40
+ def start_object
41
+ # We don't need to do anything for the toplevel view object, so just
42
+ # focus on the objects in rows.
43
+ if @in_rows
44
+ @stack.push ObjectNode.new
45
+ end
46
+ end
47
+
48
+ def end_object
49
+ if @in_rows
50
+ # If the stack's empty and we've come to the end of an object, assume
51
+ # we've exited the rows key. Trailing keys are handled by
52
+ # check_toplevel_key_validity.
53
+ if @stack.empty?
54
+ @in_rows = false
55
+ else
56
+ obj = @stack.pop.to_object
57
+
58
+ # If obj was the only thing on the stack and we're processing rows,
59
+ # then we've completed an object and need to stage it for the
60
+ # object stream.
61
+ if @stack.empty?
62
+ staged_rows << obj
63
+ else
64
+ @stack.last << obj
65
+ end
66
+ end
67
+ end
68
+ end
69
+
70
+ def start_array
71
+ # If we're not in the rows array but "rows" was the last key we've
72
+ # seen, then we're entering the rows array. Otherwise, we're building
73
+ # an array in a row.
74
+ if !@in_rows && @stack.pop == 'rows'
75
+ @in_rows = true
76
+ elsif @in_rows
77
+ @stack.push []
78
+ end
79
+ end
80
+
81
+ def end_array
82
+ if @in_rows
83
+ obj = @stack.pop
84
+
85
+ # If there's nothing on the row stack, it means that we've hit the
86
+ # end of the rows array.
87
+ if @stack.empty?
88
+ @in_rows = false
89
+ else
90
+ @stack.last << obj
91
+ end
92
+ end
93
+ end
94
+
95
+ def key(k)
96
+ if !@in_rows
97
+ check_toplevel_key_validity(k)
98
+ @stack.push k
99
+ else
100
+ @stack.last << k
101
+ end
102
+ end
103
+
104
+ def value(v)
105
+ if !@in_rows
106
+ case @stack.pop
107
+ when 'total_rows'; @total_rows = v
108
+ when 'offset'; @offset = v
109
+ end
110
+ else
111
+ @stack.last << v
112
+ end
113
+ end
114
+
115
+ def check_toplevel_key_validity(k)
116
+ if !KNOWN_KEYS.include?(k)
117
+ raise UnexpectedViewKey, "Unexpected key #{k} in top-level view object"
118
+ end
119
+ end
120
+
121
+ # Simplifies key/value pair construction.
122
+ class ObjectNode
123
+ def initialize
124
+ @obj = {}
125
+ end
126
+
127
+ def to_object
128
+ @obj
129
+ end
130
+
131
+ def <<(term)
132
+ if !@key
133
+ @key = term
134
+ elsif @key
135
+ @obj[@key] = term
136
+ @key = nil
137
+ end
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,95 @@
1
+ require 'analysand/errors'
2
+ require 'analysand/streaming_view_response'
3
+ require 'analysand/view_response'
4
+ require 'fiber'
5
+
6
+ module Analysand
7
+ module Viewing
8
+ JSON_VALUE_PARAMETERS = %w(key keys startkey endkey).map(&:to_sym)
9
+
10
+ def all_docs(parameters = {}, credentials = nil)
11
+ view('_all_docs', parameters, credentials)
12
+ end
13
+
14
+ def all_docs!(parameters = {}, credentials = nil)
15
+ view!('_all_docs', parameters, credentials)
16
+ end
17
+
18
+ def view(view_name, parameters = {}, credentials = nil)
19
+ stream = parameters.delete(:stream)
20
+ view_path = expand_view_path(view_name)
21
+
22
+ if stream
23
+ stream_view(view_path, parameters, credentials)
24
+ else
25
+ return_view(view_path, parameters, credentials)
26
+ end
27
+ end
28
+
29
+ def view!(view_name, parameters = {}, credentials = nil)
30
+ view(view_name, parameters, credentials).tap do |resp|
31
+ raise ex(CannotAccessView, resp) unless resp.success?
32
+ end
33
+ end
34
+
35
+ def stream_view(view_path, parameters, credentials)
36
+ StreamingViewResponse.new do |sresp|
37
+ do_view_query(view_path, parameters, credentials) do |resp|
38
+ sresp.http_response = resp
39
+ Fiber.yield
40
+ resp.read_body { |data| Fiber.yield(data) }
41
+ end
42
+ end
43
+ end
44
+
45
+ def return_view(view_path, parameters, credentials)
46
+ resp = do_view_query(view_path, parameters, credentials)
47
+
48
+ ViewResponse.new resp
49
+ end
50
+
51
+ def do_view_query(view_path, parameters, credentials, &block)
52
+ use_post = parameters.delete(:post)
53
+
54
+ if use_post
55
+ post_view(view_path, parameters, credentials, block)
56
+ else
57
+ get_view(view_path, parameters, credentials, block)
58
+ end
59
+ end
60
+
61
+ def get_view(view_path, parameters, credentials, block)
62
+ encode_parameters(parameters)
63
+ _get(view_path, credentials, parameters, {}, nil, block)
64
+ end
65
+
66
+ def post_view(view_path, parameters, credentials, block)
67
+ body = {
68
+ 'keys' => parameters.delete(:keys)
69
+ }.reject { |_, v| v.nil? }
70
+
71
+ encode_parameters(parameters)
72
+
73
+ _post(view_path, credentials, parameters, json_headers, body.to_json, block)
74
+ end
75
+
76
+ def encode_parameters(parameters)
77
+ JSON_VALUE_PARAMETERS.each do |p|
78
+ if parameters.has_key?(p)
79
+ parameters[p] = parameters[p].to_json
80
+ end
81
+ end
82
+ end
83
+
84
+ def expand_view_path(view_name)
85
+ if view_name.include?('/')
86
+ design_doc, view_name = view_name.split('/', 2)
87
+ "_design/#{design_doc}/_view/#{view_name}"
88
+ else
89
+ view_name
90
+ end
91
+ end
92
+ end
93
+ end
94
+
95
+ # vim:ts=2:sw=2:et:tw=78
@@ -0,0 +1,71 @@
1
+ require 'analysand/bulk_response'
2
+ require 'analysand/errors'
3
+ require 'analysand/response'
4
+
5
+ module Analysand
6
+ module Writing
7
+ def put(doc_id, doc, credentials = nil, options = {})
8
+ query = options
9
+
10
+ Response.new _put(doc_id, credentials, options, json_headers, doc.to_json)
11
+ end
12
+
13
+ def put!(doc_id, doc, credentials = nil, options = {})
14
+ put(doc_id, doc, credentials, options).tap do |resp|
15
+ raise ex(DocumentNotSaved, resp) unless resp.success?
16
+ end
17
+ end
18
+
19
+ def ensure_full_commit(credentials = nil, options = {})
20
+ Response.new _post('_ensure_full_commit', credentials, options, json_headers, {}.to_json)
21
+ end
22
+
23
+ def bulk_docs(docs, credentials = nil, options = {})
24
+ body = { 'docs' => docs }
25
+ body['all_or_nothing'] = true if options[:all_or_nothing]
26
+
27
+ BulkResponse.new _post('_bulk_docs', credentials, {}, json_headers, body.to_json)
28
+ end
29
+
30
+ def bulk_docs!(docs, credentials = nil, options = {})
31
+ bulk_docs(docs, credentials, options).tap do |resp|
32
+ raise ex(BulkOperationFailed, resp) unless resp.success?
33
+ end
34
+ end
35
+
36
+ def copy(source, destination, credentials = nil)
37
+ headers = { 'Destination' => destination }
38
+
39
+ Response.new _copy(source, credentials, {}, headers, nil)
40
+ end
41
+
42
+ def put_attachment(loc, io, credentials = nil, options = {})
43
+ query = {}
44
+ headers = {}
45
+
46
+ if options[:rev]
47
+ query['rev'] = options[:rev]
48
+ end
49
+
50
+ if options[:content_type]
51
+ headers['Content-Type'] = options[:content_type]
52
+ end
53
+
54
+ Response.new _put(loc, credentials, query, headers, io.read)
55
+ end
56
+
57
+ def delete(doc_id, rev, credentials = nil)
58
+ headers = { 'If-Match' => rev }
59
+
60
+ Response.new _delete(doc_id, credentials, {}, headers, nil)
61
+ end
62
+
63
+ def delete!(doc_id, rev, credentials = nil)
64
+ delete(doc_id, rev, credentials).tap do |resp|
65
+ raise ex(DocumentNotDeleted, resp) unless resp.success?
66
+ end
67
+ end
68
+ end
69
+ end
70
+
71
+ # vim:ts=2:sw=2:et:tw=78
@@ -0,0 +1,19 @@
1
+ require 'spec_helper'
2
+
3
+ shared_examples_for 'a response' do
4
+ %w(etag success? code).each do |m|
5
+ it "responds to ##{m}" do
6
+ response.should respond_to(m)
7
+ end
8
+ end
9
+
10
+ describe '#etag' do
11
+ it 'returns a string' do
12
+ response.etag.should be_instance_of(String)
13
+ end
14
+
15
+ it 'returns ETags without quotes' do
16
+ response.etag.should_not include('"')
17
+ end
18
+ end
19
+ end
@@ -39,6 +39,24 @@ module Analysand
39
39
  drop_databases!
40
40
  end
41
41
 
42
+ describe '#connection_ok' do
43
+ describe 'with a non-public change feed' do
44
+ before do
45
+ set_security({ 'names' => [admin_username] })
46
+ end
47
+
48
+ after do
49
+ clear_security
50
+ end
51
+
52
+ it 'passes credentials' do
53
+ watcher = TestWatcher.new(db, admin_credentials)
54
+
55
+ watcher.connection_ok.should be_true
56
+ end
57
+ end
58
+ end
59
+
42
60
  describe '#changes_feed_uri' do
43
61
  let!(:watcher) { TestWatcher.new(db, admin_credentials) }
44
62
 
@@ -185,6 +185,13 @@ module Analysand
185
185
  resp.rows.length.should == 1
186
186
  end
187
187
 
188
+ it 'can issue POSTs' do
189
+ resp = db.view('doc/a_view', :keys => ['abc123', 'abc456'], :post => true)
190
+
191
+ resp.code.should == '200'
192
+ resp.rows.length.should == 2
193
+ end
194
+
188
195
  it 'passes credentials' do
189
196
  security = {
190
197
  'members' => {
@@ -3,17 +3,21 @@ require 'spec_helper'
3
3
  require 'analysand/database'
4
4
  require 'analysand/response'
5
5
 
6
+ require File.expand_path('../a_response', __FILE__)
7
+
6
8
  module Analysand
7
9
  describe Response do
8
10
  let(:db) { Database.new(database_uri) }
9
11
 
10
- describe '#etag' do
11
- let(:response) do
12
- VCR.use_cassette('head_request_with_etag') do
13
- db.head('abc123', admin_credentials)
14
- end
12
+ let(:response) do
13
+ VCR.use_cassette('head_request_with_etag') do
14
+ db.head('abc123', admin_credentials)
15
15
  end
16
+ end
16
17
 
18
+ it_should_behave_like 'a response'
19
+
20
+ describe '#etag' do
17
21
  it 'removes quotes from ETags' do
18
22
  response.etag.should == '1-967a00dff5e02add41819138abb3284d'
19
23
  end
@@ -1,18 +1,29 @@
1
1
  require 'spec_helper'
2
2
 
3
+ require 'analysand/database'
3
4
  require 'analysand/view_response'
4
5
 
6
+ require File.expand_path('../a_response', __FILE__)
7
+
5
8
  module Analysand
6
9
  describe ViewResponse do
7
- describe '#docs' do
8
- let(:resp_with_docs) do
9
- '{"rows": [{"id":"foo","key":"foo","value":{},"doc":{"foo":"bar"}}]}'
10
- end
10
+ let(:resp_with_docs) do
11
+ '{"rows": [{"id":"foo","key":"foo","value":{},"doc":{"foo":"bar"}}]}'
12
+ end
11
13
 
12
- let(:resp_without_docs) do
13
- '{"rows": [{"id":"foo","key":"foo","value":{}}]}'
14
+ let(:resp_without_docs) do
15
+ '{"rows": [{"id":"foo","key":"foo","value":{}}]}'
16
+ end
17
+
18
+ let(:db) { Database.new(database_uri) }
19
+
20
+ it_should_behave_like 'a response' do
21
+ let(:response) do
22
+ VCR.use_cassette('view') { db.view('doc/a_view') }
14
23
  end
24
+ end
15
25
 
26
+ describe '#docs' do
16
27
  describe 'if the view includes docs' do
17
28
  subject { ViewResponse.new(stub(:body => resp_with_docs)) }
18
29
 
@@ -0,0 +1,73 @@
1
+ require 'spec_helper'
2
+
3
+ require 'analysand/view_streaming/builder'
4
+
5
+ module Analysand
6
+ module ViewStreaming
7
+ describe Builder do
8
+ let(:builder) { Builder.new }
9
+
10
+ it 'recognizes the "total_rows" key' do
11
+ builder << '{"total_rows":10000}'
12
+
13
+ builder.total_rows.should == 10000
14
+ end
15
+
16
+ it 'recognizes the "offset" key' do
17
+ builder << '{"offset":20}'
18
+
19
+ builder.offset.should == 20
20
+ end
21
+
22
+ describe 'for rows' do
23
+ before do
24
+ builder << '{"total_rows":10000,"offset":20,"rows":'
25
+ end
26
+
27
+ it 'builds objects' do
28
+ builder << '[{"id":"foo","key":"bar","value":1}]'
29
+
30
+ builder.staged_rows.should == [
31
+ { 'id' => 'foo', 'key' => 'bar', 'value' => 1 }
32
+ ]
33
+ end
34
+
35
+ it 'builds objects containing other objects' do
36
+ builder << '[{"id":"foo","key":"bar","value":{"total_rows":1}}]'
37
+
38
+ builder.staged_rows.should == [
39
+ { 'id' => 'foo',
40
+ 'key' => 'bar',
41
+ 'value' => {
42
+ 'total_rows' => 1
43
+ }
44
+ }
45
+ ]
46
+ end
47
+
48
+ it 'builds objects containing arrays' do
49
+ builder << '[{"id":"foo","key":"bar","value":{"things":[1,2,3]}}]'
50
+
51
+ builder.staged_rows.should == [
52
+ { 'id' => 'foo',
53
+ 'key' => 'bar',
54
+ 'value' => {
55
+ 'things' => [1, 2, 3]
56
+ }
57
+ }
58
+ ]
59
+ end
60
+ end
61
+
62
+ describe 'given unexpected top-level keys' do
63
+ before do
64
+ builder << '{"total_rows":10000,"offset":20,"rows":[],'
65
+ end
66
+
67
+ it 'raises Analysand::UnexpectedViewKey' do
68
+ lambda { builder << '"abc":"def"}' }.should raise_error(Analysand::UnexpectedViewKey)
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end