shanna-dm-sphinx-adapter 0.4

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,74 @@
1
+ require 'rubygems'
2
+
3
+ gem 'extlib', '~> 0.9.7'
4
+ require 'extlib'
5
+
6
+ module DataMapper
7
+ module Adapters
8
+ module Sphinx
9
+ class Config
10
+ include Extlib::Assertions
11
+ attr_reader :config, :address, :log, :port
12
+
13
+ ##
14
+ # Read a sphinx configuration file.
15
+ #
16
+ # This class just gives you access to handy searchd {} configuration options.
17
+ #
18
+ # @see http://www.sphinxsearch.com/doc.html#confgroup-searchd
19
+ def initialize(uri_or_options = {})
20
+ assert_kind_of 'uri_or_options', uri_or_options, Addressable::URI, DataObjects::URI, Hash, String, Pathname
21
+
22
+ options = normalize_options(uri_or_options)
23
+ config = parse_config("#{options[:path]}") # Pathname#to_s is broken?
24
+ @address = options[:host] || config['address'] || '0.0.0.0'
25
+ @port = options[:port] || config['port'] || 3312
26
+ @log = options[:log] || config['log'] || 'searchd.log'
27
+ @pid_file = options[:pid_file] || config['pid_file']
28
+ end
29
+
30
+ ##
31
+ # Indexer binary full path name and config argument.
32
+ def indexer_bin(use_config = true)
33
+ path = 'indexer' # TODO: Real.
34
+ path << " --config #{config}" if config
35
+ path
36
+ end
37
+
38
+ ##
39
+ # Searchd binary full path name and config argument.
40
+ def searchd_bin(use_config = true)
41
+ path = 'searchd' # TODO: Real.
42
+ path << " --config #{config}" if config
43
+ path
44
+ end
45
+
46
+ def pid_file
47
+ @pid_file or raise "Mandatory pid_file option missing from searchd configuration."
48
+ end
49
+
50
+ protected
51
+ def normalize_options(uri_or_options)
52
+ case uri_or_options
53
+ when String, Addressable::URI then DataObjects::URI.parse(uri_or_options).attributes
54
+ when DataObjects::URI then uri_or_options.attributes
55
+ when Pathname then {:path => uri_or_options}
56
+ else
57
+ uri_or_options[:path] ||= uri_or_options.delete(:config) || uri_or_options.delete(:database)
58
+ uri_or_options
59
+ end
60
+ end
61
+
62
+ def parse_config(path)
63
+ paths = []
64
+ paths.push(path, path.gsub(%r{^/}, './'), path.gsub(%r{^\./}, '/')) unless path.blank?
65
+ paths.push('/usr/local/etc/sphinx.conf', './sphinx.conf')
66
+ paths.map!{|path| Pathname.new(path).expand_path}
67
+
68
+ @config = paths.find{|path| path.readable? && `#{indexer_bin} --config #{path}` !~ /fatal|error/i}
69
+ @config ? ConfigParser.parse(@config) : {}
70
+ end
71
+ end # Config
72
+ end # Sphinx
73
+ end # Adapters
74
+ end # DataMapper
@@ -0,0 +1,67 @@
1
+ require 'rubygems'
2
+
3
+ gem 'extlib', '~> 0.9.7'
4
+ require 'extlib'
5
+
6
+ require 'pathname'
7
+ require 'strscan'
8
+
9
+ module DataMapper
10
+ module Adapters
11
+ module Sphinx
12
+ module ConfigParser
13
+ extend Extlib::Assertions
14
+
15
+ ##
16
+ # Parse a sphinx config file and return searchd options as a hash.
17
+ #
18
+ # @param [String] path Searches path, ./#{path}, /#{path}, /usr/local/etc/sphinx.conf, ./sphinx.conf in
19
+ # that order.
20
+ # @return [Hash]
21
+ def self.parse(path)
22
+ assert_kind_of 'path', path, Pathname, String
23
+
24
+ config = Pathname(path).read
25
+ config.gsub!(/\r\n|\r|\n/, "\n") # Everything in \n
26
+ config.gsub!(/\s*\\\n\s*/, ' ') # Remove unixy line wraps.
27
+
28
+ blocks(StringScanner.new(config), out = [])
29
+ out.find{|c| c['type'] =~ /searchd/i} || {}
30
+ end
31
+
32
+ protected
33
+ def self.blocks(conf, out = []) #:nodoc:
34
+ if conf.scan(/\#[^\n]*\n/) || conf.scan(/\s+/)
35
+ blocks(conf, out)
36
+ elsif conf.scan(/indexer|searchd|source|index/i)
37
+ out << group = {'type' => conf.matched}
38
+ if conf.matched =~ /^(?:index|source)$/i
39
+ conf.scan(/\s* ([\w_\-]+) (?:\s*:\s*([\w_\-]+))? \s*/x) or raise "Expected #{group[:type]} name."
40
+ group['name'] = conf[1]
41
+ group['ancestor'] = conf[2]
42
+ end
43
+ conf.scan(/\s*\{/) or raise %q{Expected '\{'.}
44
+ pairs(conf, kv = {})
45
+ group.merge!(kv)
46
+ conf.scan(/\s*\}/) or raise %q{Expected '\}'.}
47
+ blocks(conf, out)
48
+ else
49
+ raise "Unknown near: #{conf.peek(30)}" unless conf.eos?
50
+ end
51
+ end
52
+
53
+ def self.pairs(conf, out = {}) #:nodoc:
54
+ if conf.scan(/\#[^\n]*\n/) || conf.scan(/\s+/)
55
+ pairs(conf, out)
56
+ elsif conf.scan(/[\w_-]+/)
57
+ key = conf.matched
58
+ conf.scan(/\s*=/) or raise %q{Expected '='.}
59
+ out[key] = conf.scan(/[^\n]*\n/).strip
60
+ pairs(conf, out)
61
+ end
62
+ end
63
+ end # ConfigParser
64
+ end # Sphinx
65
+ end # Adapters
66
+ end # DataMapper
67
+
@@ -0,0 +1,25 @@
1
+ module DataMapper
2
+ module Adapters
3
+ module Sphinx
4
+ class Index
5
+ include Assertions
6
+
7
+ attr_reader :model, :name, :options
8
+
9
+ def initialize(model, name, options = {})
10
+ assert_kind_of 'model', model, Model
11
+ assert_kind_of 'name', name, Symbol, String
12
+ assert_kind_of 'options', options, Hash
13
+
14
+ @model = model
15
+ @name = name.to_sym
16
+ @delta = options.fetch(:delta, nil)
17
+ end
18
+
19
+ def delta?
20
+ !!@delta
21
+ end
22
+ end # Index
23
+ end # Sphinx
24
+ end # Adapters
25
+ end # DataMapper
@@ -0,0 +1,96 @@
1
+ module DataMapper
2
+ module Adapters
3
+ module Sphinx
4
+
5
+ ##
6
+ # Declare Sphinx indexes in your resource.
7
+ #
8
+ # model Items
9
+ # include DataMapper::SphinxResource
10
+ #
11
+ # # .. normal properties and such for :default
12
+ #
13
+ # repository(:search) do
14
+ # # Query some_index, some_index_delta in that order.
15
+ # index :some_index
16
+ # index :some_index_delta, :delta => true
17
+ #
18
+ # # Sortable by some attributes.
19
+ # attribute :updated_at, DateTime # sql_attr_timestamp
20
+ # attribute :age, Integer # sql_attr_uint
21
+ # attribute :deleted, Boolean # sql_attr_bool
22
+ # end
23
+ # end
24
+ module Resource
25
+ def self.included(model) #:nodoc:
26
+ model.class_eval do
27
+ include DataMapper::Resource
28
+ extend ClassMethods
29
+ end
30
+ end
31
+
32
+ module ClassMethods
33
+ def self.extended(model) #:nodoc:
34
+ model.instance_variable_set(:@sphinx_indexes, {})
35
+ model.instance_variable_set(:@sphinx_attributes, {})
36
+ end
37
+
38
+ ##
39
+ # Defines a sphinx index on the resource.
40
+ #
41
+ # Indexes are naturally ordered, with delta indexes at the end of the list so that duplicate document IDs in
42
+ # delta indexes override your main indexes.
43
+ #
44
+ # @param [Symbol] name the name of a sphinx index to search for this resource
45
+ # @param [Hash(Symbol => String)] options a hash of available options
46
+ # @see DataMapper::Adapters::Sphinx::Index
47
+ def index(name, options = {})
48
+ index = Index.new(self, name, options)
49
+ indexes = sphinx_indexes(repository_name)
50
+ indexes << index
51
+
52
+ # TODO: I'm such a Ruby nub. In the meantime I've gone back to my Perl roots.
53
+ # This is a Schwartzian transform to sort delta indexes to the bottom and natural sort by name.
54
+ mapped = indexes.map{|i| [(i.delta? ? 1 : 0), i.name, i]}
55
+ sorted = mapped.sort{|a, b| a[0] <=> b[0] || a[1] <=> b[1]}
56
+ indexes.replace(sorted.map{|i| i[2]})
57
+
58
+ index
59
+ end
60
+
61
+ ##
62
+ # List of declared sphinx indexes for this model.
63
+ def sphinx_indexes(repository_name = default_repository_name)
64
+ @sphinx_indexes[repository_name] ||= []
65
+ end
66
+
67
+ ##
68
+ # Defines a sphinx attribute on the resource.
69
+ #
70
+ # @param [Symbol] name the name of a sphinx attribute to order/restrict by for this resource
71
+ # @param [Class] type the type to define this attribute as
72
+ # @param [Hash(Symbol => String)] options a hash of available options
73
+ # @see DataMapper::Adapters::Sphinx::Attribute
74
+ def attribute(name, type, options = {})
75
+ # Attributes are just properties without a getter/setter in the model.
76
+ # This keeps DataMapper::Query happy when building queries.
77
+ attribute = Sphinx::Attribute.new(self, name, type, options)
78
+ properties(repository_name)[attribute.name] = attribute
79
+ attribute
80
+ end
81
+
82
+ ##
83
+ # List of declared sphinx attributes for this model.
84
+ def sphinx_attributes(repository_name = default_repository_name)
85
+ properties(repository_name).grep{|p| p.kind_of? Sphinx::Attribute}
86
+ end
87
+
88
+ end # ClassMethods
89
+ end # Resource
90
+ end # Sphinx
91
+ end # Adapters
92
+
93
+ # Follow DM naming convention.
94
+ SphinxResource = Adapters::Sphinx::Resource
95
+ end # DataMapper
96
+
@@ -0,0 +1,18 @@
1
+ require 'rubygems'
2
+
3
+ # TODO: Hide the shitload of dm-core warnings or at least try to?
4
+ $VERBOSE = nil
5
+ gem 'dm-core', '~> 0.9.7'
6
+ require 'dm-core'
7
+
8
+ # TODO: I think I might move everything to DataMapper::Sphinx::* and ignore the default naming convention.
9
+ require 'pathname'
10
+ dir = Pathname(__FILE__).dirname.expand_path / 'dm-sphinx-adapter'
11
+ require dir / 'config'
12
+ require dir / 'config_parser'
13
+ require dir / 'client'
14
+ require dir / 'adapter'
15
+ require dir / 'index'
16
+ require dir / 'attribute'
17
+ require dir / 'resource'
18
+
@@ -0,0 +1,21 @@
1
+ drop table if exists delta;
2
+ create table delta (
3
+ name varchar(50) not null,
4
+ updated_on datetime,
5
+ primary key (name)
6
+ ) engine=innodb default charset=utf8;
7
+
8
+ drop table if exists items;
9
+ create table items (
10
+ id int(11) not null auto_increment,
11
+ name varchar(50) not null,
12
+ likes text not null,
13
+ updated_on datetime,
14
+ primary key (id),
15
+ index (updated_on)
16
+ ) engine=innodb default charset=utf8;
17
+
18
+ insert into items (name, likes, updated_on) values
19
+ ('one', 'I really like one!', now()),
20
+ ('two', 'I really like two!', now()),
21
+ ('three', 'I really like three!', now());
@@ -0,0 +1,25 @@
1
+ require 'rubygems'
2
+ require 'dm-is-searchable'
3
+
4
+ class Explicit
5
+ include DataMapper::Resource
6
+ include DataMapper::SphinxResource
7
+
8
+ property :id, Serial
9
+ property :name, String
10
+ property :likes, Text, :lazy => false
11
+ property :updated_on, DateTime
12
+
13
+ is :searchable
14
+ repository(:search) do
15
+ properties(:search).clear
16
+ index :items
17
+ index :items_delta, :delta => true
18
+ property :name, String
19
+ attribute :updated_on, DateTime
20
+ end
21
+
22
+ def self.default_storage_name
23
+ 'item'
24
+ end
25
+ end # Explicit
@@ -0,0 +1,19 @@
1
+ require 'rubygems'
2
+ require 'dm-is-searchable'
3
+
4
+ class Resource
5
+ include DataMapper::Resource
6
+ include DataMapper::SphinxResource
7
+
8
+ property :id, Serial
9
+ property :name, String
10
+ property :likes, Text, :lazy => false
11
+ property :updated_on, DateTime
12
+
13
+ is :searchable
14
+
15
+ def self.default_storage_name
16
+ 'item'
17
+ end
18
+ end # Resource
19
+
@@ -0,0 +1,16 @@
1
+ require 'rubygems'
2
+ require 'dm-is-searchable'
3
+
4
+ class Searchable
5
+ include DataMapper::Resource
6
+ property :id, Serial
7
+ property :name, String
8
+ property :likes, Text, :lazy => false
9
+ property :updated_on, DateTime
10
+
11
+ is :searchable
12
+
13
+ def self.default_storage_name
14
+ 'item'
15
+ end
16
+ end # Searchable
@@ -0,0 +1,11 @@
1
+ class StorageName
2
+ include DataMapper::Resource
3
+ property :id, Serial
4
+ property :name, String
5
+ property :likes, Text, :lazy => false
6
+ property :updated_on, DateTime
7
+
8
+ def self.default_storage_name
9
+ 'item'
10
+ end
11
+ end # StorageName
@@ -0,0 +1,7 @@
1
+ class Vanilla
2
+ include DataMapper::Resource
3
+ property :id, Serial
4
+ property :name, String
5
+ property :likes, Text, :lazy => false
6
+ property :updated_on, DateTime
7
+ end # Vanilla
@@ -0,0 +1,78 @@
1
+ # searchd and indexer must be run from the root directory of this lib.
2
+
3
+ indexer
4
+ {
5
+ mem_limit = 64M
6
+ }
7
+
8
+ searchd
9
+ {
10
+ address = localhost
11
+ port = 3312
12
+ log = test/var/sphinx.log
13
+ query_log = test/var/sphinx.query.log
14
+ read_timeout = 5
15
+ pid_file = test/var/sphinx.pid
16
+ max_matches = 1000
17
+ }
18
+
19
+ source items
20
+ {
21
+ type = mysql
22
+ sql_host = localhost
23
+ sql_user = root
24
+ sql_pass =
25
+ sql_db = dm_sphinx_adapter_test
26
+
27
+ sql_query_pre = set names utf8
28
+ sql_query_pre = \
29
+ replace into delta (name, updated_on) ( \
30
+ select 'items', updated_on \
31
+ from items \
32
+ order by updated_on desc \
33
+ limit 1\
34
+ )
35
+
36
+ sql_query = \
37
+ select id, name, likes, unix_timestamp(updated_on) as updated_on \
38
+ from items \
39
+ where updated_on <= ( \
40
+ select updated_on \
41
+ from delta \
42
+ where name = 'items'\
43
+ )
44
+
45
+ sql_query_info = select * from items where id = $id
46
+ sql_attr_timestamp = updated_on
47
+ }
48
+
49
+ source items_delta : items
50
+ {
51
+ sql_query_pre = set names utf8
52
+ sql_query = \
53
+ select id, name, likes, unix_timestamp(updated_on) as updated_on \
54
+ from items \
55
+ where updated_on > ( \
56
+ select updated_on \
57
+ from delta \
58
+ where name = 'items'\
59
+ )
60
+ }
61
+
62
+ index items
63
+ {
64
+ source = items
65
+ path = test/var/sphinx/items
66
+ }
67
+
68
+ index items_delta : items
69
+ {
70
+ source = items_delta
71
+ path = test/var/sphinx/items_delta
72
+ }
73
+
74
+ index vanillas
75
+ {
76
+ type = distributed
77
+ local = items
78
+ }
@@ -0,0 +1,38 @@
1
+ require 'dm-sphinx-adapter'
2
+ require 'test/unit'
3
+
4
+ # DataMapper::Logger.new(STDOUT, :debug)
5
+
6
+ class TestAdapter < Test::Unit::TestCase
7
+ def setup
8
+ # TODO: A little too brutal even by my standards.
9
+ Dir.chdir(File.join(File.dirname(__FILE__), 'files')) do
10
+ system 'mysql -u root dm_sphinx_adapter_test < dm_sphinx_adapter_test.sql' \
11
+ or raise %q{Tests require the dm_sphinx_adapter_test database.}
12
+ end
13
+
14
+ DataMapper.setup(:default, 'mysql://localhost/dm_sphinx_adapter_test')
15
+
16
+ @config = Pathname.new(__FILE__).dirname.expand_path / 'files' / 'sphinx.conf'
17
+ @client = DataMapper::Adapters::Sphinx::ManagedClient.new(:config => @config)
18
+ @client.index
19
+ sleep 1
20
+ end
21
+
22
+ def test_unmanaged_setup
23
+ assert DataMapper.setup(:sphinx, :adapter => 'sphinx')
24
+ assert_kind_of DataMapper::Adapters::SphinxAdapter, repository(:sphinx).adapter
25
+ assert_kind_of DataMapper::Adapters::Sphinx::Client, repository(:sphinx).adapter.client
26
+ end
27
+
28
+ def test_managed_setup
29
+ assert DataMapper.setup(:sphinx, :adapter => 'sphinx', :config => @config, :managed => true)
30
+ assert_kind_of DataMapper::Adapters::SphinxAdapter, repository(:sphinx).adapter
31
+ assert_kind_of DataMapper::Adapters::Sphinx::ManagedClient, repository(:sphinx).adapter.client
32
+ end
33
+
34
+ def teardown
35
+ @client.stop
36
+ sleep 1
37
+ end
38
+ end
@@ -0,0 +1,48 @@
1
+ require 'test_adapter'
2
+ require 'files/resource_explicit'
3
+
4
+ class TestAdapterExplicit < TestAdapter
5
+ def setup
6
+ super
7
+ DataMapper.setup(:search, :adapter => 'sphinx', :config => @config, :managed => true)
8
+ end
9
+
10
+ def teardown
11
+ DataMapper.repository(:search).adapter.client.stop
12
+ super
13
+ end
14
+
15
+ def test_initialize
16
+ assert_nothing_raised{ Explicit.new }
17
+ end
18
+
19
+ def test_search_properties
20
+ assert_equal Explicit.all, Explicit.search
21
+ assert_equal [Explicit.first(:id => 2)], Explicit.search(:name => 'two')
22
+ end
23
+
24
+ def test_search_delta
25
+ resource = Explicit.create(:name => 'four', :likes => 'chicken', :updated_on => Time.now)
26
+ DataMapper.repository(:search).adapter.client.index('items_delta')
27
+ assert_equal [resource], Explicit.search(:name => 'four')
28
+ end
29
+
30
+ def test_search_attribute_timestamp
31
+ time = Time.now
32
+ resource = Explicit.create(:name => 'four', :likes => 'chicken', :updated_on => time)
33
+ DataMapper.repository(:search).adapter.client.index('items_delta')
34
+
35
+ assert_equal [resource], Explicit.search(:updated_on => time.to_i)
36
+ assert_equal [resource], Explicit.search(:updated_on => (time .. time + 1))
37
+ assert_equal [], Explicit.search(:updated_on => (time - 60 * 60))
38
+ assert_equal [], Explicit.search(:updated_on => (time + 60 * 60))
39
+ end
40
+
41
+ def test_search_attribute_boolean
42
+ # TODO:
43
+ end
44
+
45
+ def test_search_attribute_integer
46
+ # TODO
47
+ end
48
+ end # TestAdapterExplicit
@@ -0,0 +1,25 @@
1
+ require 'test_adapter'
2
+ require 'files/resource_resource'
3
+
4
+ class TestAdapterResource < TestAdapter
5
+ def setup
6
+ super
7
+ DataMapper.setup(:search, :adapter => 'sphinx', :config => @config, :managed => true)
8
+ end
9
+
10
+ def teardown
11
+ DataMapper.repository(:search).adapter.client.stop
12
+ super
13
+ end
14
+
15
+ def test_initialize
16
+ assert_nothing_raised{ Resource.new }
17
+ end
18
+
19
+ def test_search_properties
20
+ assert_equal Resource.all, Resource.search
21
+ assert_equal [Resource.first(:id => 2)], Resource.search(:name => 'two')
22
+ assert_equal [Resource.first(:id => 2)], Resource.search(:conditions => ['two'])
23
+ end
24
+ end # TestAdapterResource
25
+
@@ -0,0 +1,23 @@
1
+ require 'test_adapter'
2
+ require 'files/resource_searchable'
3
+
4
+ class TestAdapterSearchable < TestAdapter
5
+ def setup
6
+ super
7
+ DataMapper.setup(:search, :adapter => 'sphinx', :config => @config, :managed => true)
8
+ end
9
+
10
+ def teardown
11
+ DataMapper.repository(:search).adapter.client.stop
12
+ super
13
+ end
14
+
15
+ def test_initialize
16
+ assert_nothing_raised{ Searchable.new }
17
+ end
18
+
19
+ def test_search
20
+ assert_equal Searchable.all, Searchable.search
21
+ assert_equal [Searchable.first(:id => 2)], Searchable.search(:name => 'two')
22
+ end
23
+ end # TestAdapterSearchable
@@ -0,0 +1,46 @@
1
+ require 'test_adapter'
2
+ require 'files/resource_vanilla'
3
+ require 'files/resource_storage_name'
4
+
5
+ class TestAdapterVanilla < TestAdapter
6
+ def setup
7
+ super
8
+ DataMapper.setup(:default, :adapter => 'sphinx', :config => @config, :managed => true)
9
+ end
10
+
11
+ def teardown
12
+ DataMapper.repository(:default).adapter.client.stop
13
+ super
14
+ end
15
+
16
+ def test_initialize
17
+ assert_nothing_raised{ Vanilla.new }
18
+ assert_nothing_raised{ StorageName.new }
19
+ end
20
+
21
+ def test_all
22
+ assert_equal [{:id => 1}, {:id => 2}, {:id => 3}], Vanilla.all
23
+ assert_equal [{:id => 1}], Vanilla.all(:name => 'one')
24
+ end
25
+
26
+ def test_all_limit
27
+ assert_equal [{:id => 1}], Vanilla.all(:limit => 1)
28
+ assert_equal [{:id => 1}, {:id => 2}], Vanilla.all(:limit => 2)
29
+ end
30
+
31
+ def test_all_offset
32
+ assert_equal [{:id => 1}, {:id => 2}, {:id => 3}], Vanilla.all(:offset => 0)
33
+ assert_equal [{:id => 2}, {:id => 3}], Vanilla.all(:offset => 1)
34
+ assert_equal [], Vanilla.all(:offset => 3)
35
+ end
36
+
37
+ def test_first
38
+ assert_equal({:id => 1}, Vanilla.first(:name => 'one'))
39
+ assert_nil Vanilla.first(:name => 'missing')
40
+ end
41
+
42
+ def test_storage_name
43
+ assert_equal Vanilla.all, StorageName.all
44
+ assert_equal Vanilla.first, StorageName.first
45
+ end
46
+ end
@@ -0,0 +1,31 @@
1
+ require 'test_adapter'
2
+
3
+ class TestClient < TestAdapter
4
+ def test_initialize
5
+ assert_nothing_raised { DataMapper::Adapters::Sphinx::Client.new(@config) }
6
+ end
7
+
8
+ def test_index
9
+ client = DataMapper::Adapters::Sphinx::Client.new(@config)
10
+ assert_nothing_raised{ client.index }
11
+ assert_nothing_raised{ client.index 'items' }
12
+ assert_nothing_raised{ client.index '*' }
13
+ assert_nothing_raised{ client.index ['items', 'items_delta'] }
14
+ end
15
+
16
+ def test_managed_initialize
17
+ assert_nothing_raised { DataMapper::Adapters::Sphinx::ManagedClient.new(@config) }
18
+ end
19
+
20
+ def test_search
21
+ begin
22
+ client = DataMapper::Adapters::Sphinx::ManagedClient.new(@config)
23
+ client.index
24
+ assert match = client.search('two')
25
+ assert_equal 1, match[:total]
26
+ assert_equal 2, match[:matches][0][:doc]
27
+ ensure
28
+ client.stop
29
+ end
30
+ end
31
+ end # TestClient