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/lib/analysand/version.rb
CHANGED
@@ -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
|
-
|
11
|
-
|
12
|
-
|
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
|
-
|
8
|
-
|
9
|
-
|
10
|
-
end
|
10
|
+
let(:resp_with_docs) do
|
11
|
+
'{"rows": [{"id":"foo","key":"foo","value":{},"doc":{"foo":"bar"}}]}'
|
12
|
+
end
|
11
13
|
|
12
|
-
|
13
|
-
|
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
|