oedipus 0.0.1.pre1

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/.gitignore ADDED
@@ -0,0 +1,8 @@
1
+ *.gem
2
+ .bundle
3
+ Gemfile.lock
4
+ pkg/*
5
+ spec/data/index/*
6
+ spec/data/binlog/*
7
+ spec/data/searchd.*
8
+ spec/data/sphinx.*
data/.rspec ADDED
@@ -0,0 +1 @@
1
+ --colour
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source "http://rubygems.org"
2
+
3
+ # Specify your gem's dependencies in oedipus.gemspec
4
+ gemspec
data/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright © 2011 Chris Corbyn
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,149 @@
1
+ # Oedipus: Sphinx 2 Search Client for Ruby
2
+
3
+ Oedipus is a client for the Sphinx search engine (>= 2.0.2), with support for
4
+ real-time indexes and multi and/or faceted searches.
5
+
6
+ It is not a clone of the PHP API, rather it is written from the ground up,
7
+ wrapping the SphinxQL API offered by searchd. Nor is it a plugin for
8
+ ActiveRecord or DataMapper... though this is planned in separate gems.
9
+
10
+ It will provide some higher level of abstraction in terms of the ease with
11
+ which faceted search may be implemented, though it will remain light and simple.
12
+
13
+ ## Current Status
14
+
15
+ This gem is in development. It is not ready for production use. I work for
16
+ a company called Flippa.com, which currently implements faceted search in a PHP
17
+ part of the website, using a slightly older version of Sphinx with lesser
18
+ support for SphinxQL. Once a month the developers at Flippa are given three days
19
+ to work on a project of their own choice. This is my 'Triple Time' project.
20
+
21
+ I anticipate another week or two of development before I can consider this project
22
+ production-ready.
23
+
24
+ ## Usage
25
+
26
+ Not all of the following features are currently implemented, but the interface
27
+ style is as follows.
28
+
29
+ ``` ruby
30
+ require "oedipus"
31
+
32
+ sphinx = Oedipus.connect('localhost:9306') # sphinxql host
33
+
34
+ # insert a record into the 'articles' real-time index
35
+ record = sphinx[:articles].insert(
36
+ 7,
37
+ title: "Badgers in the wild",
38
+ body: "A big long wodge of text",
39
+ author_id: 4,
40
+ views: 102
41
+ )
42
+ # The attributes (but not the indexed fields) are returned
43
+ # => { id: 7, author_id: 4, views: 102 }
44
+
45
+ # updating a record
46
+ record = sphinx[:articles].update(7, views: 103)
47
+ # The new attributes (but not the indexed fields) are returned
48
+ # => { id: 7, author_id: 4, views: 103 }
49
+
50
+ # deleting a record
51
+ sphinx[:articles].delete(7)
52
+ # => true
53
+
54
+ # searching the index
55
+ results = sphinx[:articles].search("badgers", limit: 2)
56
+
57
+ # Meta deta indicates the overall number of matched records, while the ':records'
58
+ # array contains the actual data returned.
59
+ #
60
+ # => {
61
+ # total_found: 987,
62
+ # time: 0.000,
63
+ # keyword[0]: "badgers",
64
+ # docs[0]: 987,
65
+ # records: [
66
+ # { id: 7, author_id: 4, views: 102 },
67
+ # { id: 11, author_id: 6, views: 23 }
68
+ # ]
69
+ # }
70
+
71
+ # using attribute filters
72
+ results = sphinx[:articles].search(
73
+ "example",
74
+ author_id: 7
75
+ )
76
+ # => (the same results, filtered by author)
77
+
78
+ # performing a faceted search
79
+ results = sphinx[:articles].facted_search(
80
+ "badgers",
81
+ facets: {
82
+ popular: { views: 100..10000 },
83
+ farming: "farming",
84
+ popular_farming: ["farming", { views: 100..10000 } ]
85
+ }
86
+ )
87
+ # The main results are returned in the ':records' array, and all the facets in
88
+ # the ':facets' Hash.
89
+ # => {
90
+ # total_found: 987,
91
+ # time: 0.000,
92
+ # records: [ ... ],
93
+ # facets: {
94
+ # popular: {
95
+ # total_found: 25,
96
+ # time: 0.000,
97
+ # records: [ ... ]
98
+ # },
99
+ # farming: {
100
+ # total_found: 123,
101
+ # time: 0.000,
102
+ # records: [ ... ]
103
+ # },
104
+ # popular_farming: {
105
+ # total_found: 2,
106
+ # time: 0.000,
107
+ # records: [ ... ]
108
+ # }
109
+ # }
110
+ # }
111
+ #
112
+ # When performing a faceted search, the primary search is used as the basis for
113
+ # each facet, so they can be considered refinements.
114
+
115
+ # performing a mutli-search
116
+ results = sphinx[:articles].multi_search(
117
+ badgers: ["badgers", { limit: 30 }],
118
+ frogs: "frogs AND wetlands",
119
+ rabbits: ["rabbits OR burrows", { view_count: 20..100 }]
120
+ )
121
+ # The results are returned in a 2-dimensional Hash, keyed as sent in the query
122
+ # => {
123
+ # badgers: {
124
+ # ...
125
+ # },
126
+ # frogs: {
127
+ # ...
128
+ # },
129
+ # rabbits: {
130
+ # ...
131
+ # }
132
+ # }
133
+ #
134
+ # Unlike with a faceted search, the queries in a multi-search do not have to be
135
+ # related to one another.
136
+ ```
137
+
138
+ ## Future Plans
139
+
140
+ I plan to release gems for integration with DataMapper and ActiveRecord. DataMapper
141
+ first, since that's what we use.
142
+
143
+ I also intend to remove ruby-mysql from the dependencies, as it doesn't perfectly fit
144
+ the needs of SphinxQL. I will be implementing the limited subset of the MySQL protocol
145
+ by hand (which is not as big a deal as it sounds).
146
+
147
+ ## Copyright and Licensing
148
+
149
+ Refer to the LICENSE file for details.
data/Rakefile ADDED
@@ -0,0 +1 @@
1
+ require "bundler/gem_tasks"
@@ -0,0 +1,50 @@
1
+ # encoding: utf-8
2
+
3
+ ##
4
+ # Oedipus Sphinx 2 Search.
5
+ # Copyright © 2012 Chris Corbyn.
6
+ #
7
+ # See LICENSE file for details.
8
+ ##
9
+
10
+ require "mysql"
11
+
12
+ # SphinxQL greets with charset number 0
13
+ Mysql::Charset::NUMBER_TO_CHARSET[0] = Mysql::Charset::COLLATION_TO_CHARSET["utf8_general_ci"]
14
+
15
+ module Oedipus
16
+ # Provides an interface for talking to SphinxQL.
17
+ class Connection
18
+ # Instantiate a new Connection to a SphinxQL host.
19
+ #
20
+ # @param [Hash]
21
+ # a Hash containing :host and :port
22
+ #
23
+ # The connection will be established on initialization.
24
+ def initialize(options)
25
+ @conn = ::Mysql.new(options[:host], nil, nil, nil, options[:port], nil, ::Mysql::CLIENT_MULTI_STATEMENTS | ::Mysql::CLIENT_MULTI_RESULTS)
26
+ end
27
+
28
+ # Acess a specific index for querying.
29
+ #
30
+ # @param [String] index_name
31
+ # the name of an existing index in Sphinx
32
+ #
33
+ # @return [Index]
34
+ # an index that can be queried
35
+ def [](index_name)
36
+ Index.new(index_name, self)
37
+ end
38
+
39
+ def execute(sql)
40
+ @conn.query(sql)
41
+ end
42
+
43
+ def quote(v)
44
+ case v
45
+ when Numeric then v
46
+ else "'#{::Mysql.quote(v.to_s)}'"
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,103 @@
1
+ # encoding: utf-8
2
+
3
+ ##
4
+ # Oedipus Sphinx 2 Search.
5
+ # Copyright © 2012 Chris Corbyn.
6
+ #
7
+ # See LICENSE file for details.
8
+ ##
9
+
10
+ module Oedipus
11
+ # Representation of a search index for querying.
12
+ class Index
13
+ attr_reader :name
14
+ attr_reader :attributes
15
+
16
+ # Initialize the index named +name+ on the connection +conn+.
17
+ #
18
+ # @param [Symbol] name
19
+ # the name of an existing index in sphinx
20
+ #
21
+ # @param [Connection] conn
22
+ # an instance of Oedipus::Connection for querying
23
+ def initialize(name, conn)
24
+ @name = name.to_sym
25
+ @conn = conn
26
+ @attributes = reflect_attributes
27
+ @builder = QueryBuilder.new(name, conn)
28
+ end
29
+
30
+ # Insert the record with the ID +id+.
31
+ #
32
+ # @example
33
+ # index.insert(42, :title => "example", :views => 22)
34
+ #
35
+ # @param [Integer] id
36
+ # the unique ID of the document in the index
37
+ #
38
+ # @param [Hash] hash
39
+ # a symbol-keyed hash of data to insert
40
+ #
41
+ # @return [Hash]
42
+ # a copy of the inserted record
43
+ def insert(id, hash)
44
+ data = Hash[
45
+ [:id, *hash.keys.map(&:to_sym)].zip \
46
+ [id, *hash.values.map { |v| String === v ? @conn.quote(v) : v }]
47
+ ]
48
+ @conn.execute("INSERT INTO #{name} (#{data.keys.join(', ')}) VALUES (#{data.values.join(', ')})")
49
+ attributes.merge(data.select { |k, _| attributes.key?(k) })
50
+ end
51
+
52
+ def search(*args)
53
+ raise ArgumentError, "Wrong number of arguments (#{args.size} for 1..2)" unless (1..2) === args.size
54
+
55
+ query, attrs = case args.first
56
+ when String then [args[0], args[1] || {}]
57
+ when Hash then ["", args[0]]
58
+ else raise ArgumentError, "Invalid argument type #{args.first.class} for argument 0"
59
+ end
60
+
61
+ results = @conn.execute(@builder.sql(query, attrs))
62
+ meta = @conn.execute("SHOW META")
63
+
64
+ # FIXME: This needs optimizing
65
+ {}.tap do |r|
66
+ meta.each do |k, v|
67
+ r[k.to_sym] = case k
68
+ when 'total', 'total_found' then Integer(v)
69
+ when 'time' then Float(v)
70
+ else v
71
+ end
72
+ end
73
+
74
+ r[:records] = []
75
+
76
+ results.each_hash do |hash|
77
+ r[:records] << Hash[hash.keys.map(&:to_sym).zip(hash.map { |k, v| cast(k, v) })]
78
+ end
79
+ end
80
+ end
81
+
82
+ private
83
+
84
+ def cast(key, value)
85
+ case attributes[key.to_sym]
86
+ when Fixnum then Integer(value)
87
+ else value
88
+ end
89
+ end
90
+
91
+ def reflect_attributes
92
+ rs = @conn.execute("DESC #{name}")
93
+ {}.tap do |attrs|
94
+ rs.each_hash do |row|
95
+ case row['Type']
96
+ when 'uint', 'integer' then attrs[row['Field'].to_sym] = 0
97
+ when 'string' then attrs[row['Field'].to_sym] = ""
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,136 @@
1
+ # encoding: utf-8
2
+
3
+ ##
4
+ # Oedipus Sphinx 2 Search.
5
+ # Copyright © 2012 Chris Corbyn.
6
+ #
7
+ # See LICENSE file for details.
8
+ ##
9
+
10
+ require "socket"
11
+ require "net/protocol"
12
+
13
+ module Oedipus
14
+ module Mysql
15
+ # Limited subset of MySQL protocol for communication with SphinxQL.
16
+ #
17
+ # This needs to exist since other ruby MySQL clients do not provide the relevant features.
18
+ class Client
19
+ # Connect to the SphinxQL server.
20
+ #
21
+ # @param [Hash]
22
+ # a Hash containing :host and :port
23
+ def initialize(options)
24
+ @sock = TCPSocket.new(options[:host], options[:port])
25
+ @seq = 0
26
+ perform_handshake
27
+ end
28
+
29
+ def execute(sql)
30
+ p send_packet(query_packet(sql))
31
+ end
32
+
33
+ private
34
+
35
+ def perform_handshake
36
+ auth_pkt = clnt_authentication_packet(serv_initialization_packet)
37
+ raise "Some sort of error" unless send_packet(auth_pkt)[:type] == :ok
38
+ end
39
+
40
+ def incr_seq(seq)
41
+ raise ProtocolError, "Invalid packet sequence value #{seq} != #{@seq}" unless seq == @seq
42
+ @seq = (seq + 1) % 256
43
+ end
44
+
45
+ def recv_packet
46
+ a, b, seq = @sock.read(4).unpack("CvC")
47
+ incr_seq(seq)
48
+ @sock.read(a | (b << 8))
49
+ end
50
+
51
+ def send_packet(pkt)
52
+ while chunk = pkt.read(2**24 - 1)
53
+ @sock.write([chunk.length % 256, chunk.length / 256, @seq, chunk].pack("CvCZ*"))
54
+ incr_seq(@seq)
55
+ end
56
+
57
+ serv_result_packet
58
+ end
59
+
60
+ def serv_result_packet
61
+ pkt = recv_packet
62
+ case pkt[0]
63
+ when "\x00" then ok_packet(pkt)
64
+ else raise ProtocolError, "Unknown packet type #{pkt[0]}"
65
+ end
66
+ end
67
+
68
+ def scan_lcb(str)
69
+ case v = str.slice!(0)
70
+ when "\xFB" then nil
71
+ when "\xFC" then str.slice!(0, 2).unpack("v").first
72
+ when "\xFD"
73
+ a, b = str.slice!(0, 3).unpack("Cv")
74
+ a | (b << 8)
75
+ when "\xFE"
76
+ a, b = str.slice!(0, 8).unpack("VV")
77
+ a | (b << 32)
78
+ else v.ord
79
+ end
80
+ end
81
+
82
+ def ok_packet(str)
83
+ Hash[
84
+ [
85
+ :field_count,
86
+ :affected_rows,
87
+ :insert_id,
88
+ :serv_stat,
89
+ :warning_count,
90
+ :message
91
+ ].zip([scan_lcb(str), scan_lcb(str), scan_lcb(str)] + str.unpack("vva*"))
92
+ ].tap { |pkt| pkt[:type] = :ok }
93
+ end
94
+
95
+ def serv_initialization_packet
96
+ Hash[
97
+ [
98
+ :prot_ver,
99
+ :serv_ver,
100
+ :thread_id,
101
+ :scramble_buf_a,
102
+ :filler_a,
103
+ :serv_cap_a,
104
+ :serv_enc,
105
+ :serv_stat,
106
+ :serv_cap_b,
107
+ :scramble_len,
108
+ :filler_b,
109
+ :scramble_buf_b
110
+ ].zip(recv_packet.unpack("CZ*Va8CvCvvCa10Z*"))
111
+ ].tap do |pkt|
112
+ raise ProtocolError, "Unsupported MySQL protocol version #{pkt[:prot_ver]}" unless pkt[:prot_ver] == 0x0A
113
+ end
114
+ end
115
+
116
+ def clnt_authentication_packet(init_pkt)
117
+ StringIO.new [
118
+ 197120, # clnt_cap (prot 4.1, multi-stmt, multi-rs)
119
+ 1024**3, # max pkt size
120
+ 0, # charset not used
121
+ '', # filler 23 bytes
122
+ '', # username not used
123
+ '', # scramble buf (no password)
124
+ '' # dbname not used
125
+ ].pack("VVCa23Z*A*Z*")
126
+ end
127
+
128
+ def query_packet(sql)
129
+ StringIO.new [
130
+ 0x03, # COM_QUERY type
131
+ sql
132
+ ].pack("Ca*")
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,41 @@
1
+ # encoding: utf-8
2
+
3
+ ##
4
+ # Oedipus Sphinx 2 Search.
5
+ # Copyright © 2012 Chris Corbyn.
6
+ #
7
+ # See LICENSE file for details.
8
+ ##
9
+
10
+ module Oedipus
11
+ class QueryBuilder
12
+ def initialize(index_name, conn)
13
+ @index_name = index_name
14
+ @conn = conn
15
+ end
16
+
17
+ def sql(query, filters)
18
+ [
19
+ from,
20
+ conditions(query, filters),
21
+ limits(filters)
22
+ ].join(" ")
23
+ end
24
+
25
+ private
26
+
27
+ def from
28
+ "SELECT * FROM #{@index_name}"
29
+ end
30
+
31
+ def conditions(query, filters)
32
+ exprs = []
33
+ exprs << "MATCH(#{@conn.quote(query)})" unless query.empty?
34
+ "WHERE " << exprs.join(" AND ") if exprs.any?
35
+ end
36
+
37
+ def limits(filters)
38
+ "LIMIT #{filters[:offset].to_i}, #{filters[:limit].to_i}" if filters.key?(:limit)
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,12 @@
1
+ # encoding: utf-8
2
+
3
+ ##
4
+ # Oedipus Sphinx 2 Search.
5
+ # Copyright © 2012 Chris Corbyn.
6
+ #
7
+ # See LICENSE file for details.
8
+ ##
9
+
10
+ module Oedipus
11
+ VERSION = "0.0.1.pre1"
12
+ end
data/lib/oedipus.rb ADDED
@@ -0,0 +1,41 @@
1
+ # encoding: utf-8
2
+
3
+ ##
4
+ # Oedipus Sphinx 2 Search.
5
+ # Copyright © 2012 Chris Corbyn.
6
+ #
7
+ # See LICENSE file for details.
8
+ ##
9
+
10
+ require "oedipus/version"
11
+ require "oedipus/query_builder"
12
+ require "oedipus/connection"
13
+ require "oedipus/index"
14
+ require "oedipus/mysql/client"
15
+
16
+ module Oedipus
17
+ class << self
18
+ # Connect to Sphinx running SphinxQL.
19
+ #
20
+ # @example
21
+ # c = Oedipus.connect("localhost:9306")
22
+ # c = Oedipus.connect(:host => "localhost", :port => 9306)
23
+ #
24
+ # @param [String] server
25
+ # a 'hostname:port' string
26
+ #
27
+ # @param [Hash] options
28
+ # a Hash with :host and :port keys
29
+ #
30
+ # @return [Connection]
31
+ # a client connected to SphinxQL
32
+ def connect(options)
33
+ # TODO: Add pooling
34
+ Connection.new(
35
+ options.kind_of?(String) ?
36
+ Hash[ [:host, :port].zip(options.split(":")) ] :
37
+ options
38
+ )
39
+ end
40
+ end
41
+ end
data/oedipus.gemspec ADDED
@@ -0,0 +1,35 @@
1
+ # -*- encoding: utf-8 -*-
2
+ $:.push File.expand_path("../lib", __FILE__)
3
+ require "oedipus/version"
4
+
5
+ Gem::Specification.new do |s|
6
+ s.name = "oedipus"
7
+ s.version = Oedipus::VERSION
8
+ s.authors = ["d11wtq"]
9
+ s.email = ["chris@w3style.co.uk"]
10
+ s.homepage = "https://github.com/d11wtq/oedipus"
11
+ s.summary = "Sphinx 2 Search Client for Ruby"
12
+ s.description = <<-DESC.strip
13
+ Oedipus brings full support for Sphinx 2 to Ruby:
14
+
15
+ * real-time indexes
16
+ * faceted search
17
+ * multi-queries
18
+ * full attribute support
19
+ * optional model-style interaction
20
+
21
+ It works with 'stable' versions of Sphinx 2 (>= 2.0.2). All
22
+ features are implemented entirely through the SphinxQL interface.
23
+ DESC
24
+
25
+ s.rubyforge_project = "oedipus"
26
+
27
+ s.files = `git ls-files`.split("\n")
28
+ s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
29
+ s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
30
+ s.require_paths = ["lib"]
31
+
32
+ s.add_runtime_dependency "ruby-mysql"
33
+
34
+ s.add_development_dependency "rspec"
35
+ end
File without changes
@@ -0,0 +1,39 @@
1
+ # encoding: utf-8
2
+
3
+ ##
4
+ # Oedipus Sphinx 2 Search.
5
+ # Copyright © 2012 Chris Corbyn.
6
+ #
7
+ # See LICENSE file for details.
8
+ ##
9
+
10
+ require "rspec"
11
+ require "bundler/setup"
12
+ require "oedipus"
13
+
14
+ Dir[File.expand_path("../support/**/*rb", __FILE__)].each { |f| require f }
15
+
16
+ RSpec.configure do |config|
17
+ include Oedipus::TestHarness
18
+
19
+ config.before(:suite) do
20
+ unless ENV.key?("SEARCHD")
21
+ raise "You must specify a path to the Sphinx 'searchd' executable (>= 2.0.2)"
22
+ end
23
+
24
+ set_data_dir File.expand_path("../data", __FILE__)
25
+ set_searchd ENV["SEARCHD"]
26
+
27
+ prepare_data_dirs
28
+ write_sphinx_conf
29
+ start_searchd
30
+ end
31
+
32
+ config.before(:each) do
33
+ empty_indexes
34
+ end
35
+
36
+ config.after(:suite) do
37
+ stop_searchd
38
+ end
39
+ end
@@ -0,0 +1,143 @@
1
+ # encoding: utf-8
2
+
3
+ ##
4
+ # Oedipus Sphinx 2 Search.
5
+ # Copyright © 2012 Chris Corbyn.
6
+ #
7
+ # See LICENSE file for details.
8
+ ##
9
+
10
+ module Oedipus
11
+ # Mixed into RSpec suites to manage starting/stopping Sphinx.
12
+ module TestHarness
13
+ # Set the path to the searchd executable.
14
+ #
15
+ # The version of Sphinx must be >= 2.0.2.
16
+ #
17
+ # @param [String] path
18
+ # the absolute path to searchd
19
+ def set_searchd(path)
20
+ @searchd = path
21
+ end
22
+
23
+ # Set the path to a temporary directory for writing test data to.
24
+ #
25
+ # @param [String] path
26
+ # the path to a writable directory whose contents may be completely deleted
27
+ def set_data_dir(path)
28
+ @data_dir = path
29
+ end
30
+
31
+ # Ensure that the temporary data directories exist and are clean.
32
+ def prepare_data_dirs
33
+ Dir.mkdir("#{data_dir}/index") unless Dir.exist?("#{data_dir}/index")
34
+ Dir.mkdir("#{data_dir}/binlog") unless Dir.exist?("#{data_dir}/binlog")
35
+
36
+ clean_data_dirs
37
+ end
38
+
39
+ def empty_indexes
40
+ require "mysql"
41
+ @conn ||= ::Mysql.new(searchd_host[:host], nil, nil, nil, searchd_host[:port])
42
+
43
+ idxs = @conn.query("SHOW TABLES")
44
+
45
+ while idx = idxs.fetch_hash
46
+ docs = @conn.query("SELECT id FROM #{idx['Index']}")
47
+ while hash = docs.fetch_hash
48
+ @conn.query("DELETE FROM #{idx['Index']} WHERE id = #{hash['id']}")
49
+ end
50
+ end
51
+ end
52
+
53
+ # Write the sphinx.conf file, using #index_definiton.
54
+ #
55
+ # Sphinx will listen on localhost port 9399.
56
+ #
57
+ # Any string returned from #index_definition will be used to define one or more indexes.
58
+ def write_sphinx_conf
59
+ File.open(searchd_config, "wb") do |f|
60
+ f <<
61
+ <<-CONF.gsub(/^ {10}/m, "")
62
+ ##
63
+ # This file is automatically generated during tests
64
+ ##
65
+
66
+ index posts_rt
67
+ {
68
+ type = rt
69
+ path = #{data_dir}/index/posts_rt
70
+
71
+ rt_field = title
72
+ rt_field = body
73
+
74
+ rt_attr_uint = user_id
75
+ rt_attr_uint = views
76
+ rt_attr_string = status
77
+ }
78
+
79
+ searchd
80
+ {
81
+ compat_sphinxql_magics = 0
82
+
83
+ max_matches = 2000
84
+ pid_file = #{data_dir}/searchd.pid
85
+ listen = #{searchd_host[:host]}:#{searchd_host[:port]}:mysql41
86
+ workers = threads
87
+ log = #{data_dir}/searchd.log
88
+ query_log = #{data_dir}/searchd.log
89
+ binlog_path = #{data_dir}/binlog
90
+ }
91
+ CONF
92
+ end
93
+ end
94
+
95
+ # Start the sphinx daemon in a child process and return the PID.
96
+ #
97
+ # Output is redirected to searchd.out and searchd.err in the data dir.
98
+ def start_searchd
99
+ @searchd_pid = Process.spawn(
100
+ searchd, "--console", "-c", searchd_config,
101
+ out: "#{data_dir}/searchd.out",
102
+ err: "#{data_dir}/searchd.err"
103
+ )
104
+ sleep 1
105
+ end
106
+
107
+ # Stop an already running sphinx daemon and wait for it to shutdown.
108
+ def stop_searchd
109
+ Process.kill("TERM", @searchd_pid) && Process.wait
110
+ end
111
+
112
+ private
113
+
114
+ def clean_data_dirs
115
+ clean_files("#{data_dir}/index/**/*")
116
+ clean_files("#{data_dir}/binlog/**/*")
117
+ clean_files("#{data_dir}/searchd.pid")
118
+ clean_files("#{data_dir}/searchd.out")
119
+ clean_files("#{data_dir}/searchd.err")
120
+ clean_files("#{data_dir}/sphinx.conf")
121
+ end
122
+
123
+ def searchd_host
124
+ { host: "127.0.0.1", port: 9399 }
125
+ end
126
+
127
+ def searchd
128
+ @searchd ||= "searchd"
129
+ end
130
+
131
+ def searchd_config
132
+ "#{data_dir}/sphinx.conf"
133
+ end
134
+
135
+ def clean_files(path)
136
+ Dir[path].each { |f| File.delete(f) unless File.directory?(f) }
137
+ end
138
+
139
+ def data_dir
140
+ @data_dir ||= "./data"
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,36 @@
1
+ # encoding: utf-8
2
+
3
+ ##
4
+ # Oedipus Sphinx 2 Search.
5
+ # Copyright © 2012 Chris Corbyn.
6
+ #
7
+ # See LICENSE file for details.
8
+ ##
9
+
10
+ require "spec_helper"
11
+
12
+ describe Oedipus::Connection do
13
+ let(:conn) { Oedipus::Connection.new(searchd_host) }
14
+
15
+ describe "#initialize" do
16
+ context "on successful connection" do
17
+ it "returns the connection" do
18
+ Oedipus::Connection.new(searchd_host).should be_a_kind_of(Oedipus::Connection)
19
+ end
20
+ end
21
+
22
+ context "on failed connection" do
23
+ it "raises an error" do
24
+ expect {
25
+ Oedipus::Connection.new(:host => "127.0.0.1", :port => 45346138)
26
+ }.to raise_error
27
+ end
28
+ end
29
+ end
30
+
31
+ describe "#[]" do
32
+ it "returns an index" do
33
+ conn[:posts_rt].should be_a_kind_of(Oedipus::Index)
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,85 @@
1
+ # encoding: utf-8
2
+
3
+ ##
4
+ # Oedipus Sphinx 2 Search.
5
+ # Copyright © 2012 Chris Corbyn.
6
+ #
7
+ # See LICENSE file for details.
8
+ ##
9
+
10
+ require "spec_helper"
11
+
12
+ describe Oedipus::Index do
13
+ let(:conn) { Oedipus::Connection.new(searchd_host) }
14
+ let(:index) { Oedipus::Index.new(:posts_rt, conn) }
15
+
16
+ describe "#insert" do
17
+ context "with valid data" do
18
+ it "returns the inserted attributes as a Hash" do
19
+ index.insert(
20
+ 10,
21
+ title: "Badgers",
22
+ body: "They live in setts, do badgers.",
23
+ views: 721,
24
+ user_id: 7
25
+ ).should == { id: 10, views: 721, user_id: 7, status: "" }
26
+ end
27
+ end
28
+
29
+ context "with invalid data" do
30
+ it "raises an error" do
31
+ expect {
32
+ index.insert(
33
+ 10,
34
+ bad_field: "Invalid",
35
+ body: "They live in setts, do badgers.",
36
+ views: 721,
37
+ user_id: 7
38
+ )
39
+ }.to raise_error
40
+ end
41
+ end
42
+ end
43
+
44
+ describe "#search" do
45
+ before(:each) do
46
+ index.insert(1, title: "Badgers and foxes", views: 150)
47
+ index.insert(2, title: "Rabbits and hares", views: 87)
48
+ index.insert(3, title: "Badgers in the wild", views: 41)
49
+ index.insert(4, title: "Badgers for all!", views: 3003)
50
+ end
51
+
52
+ context "by fulltext matching" do
53
+ it "indicates the number of records found" do
54
+ index.search("badgers")[:total_found].should == 3
55
+ end
56
+
57
+ it "includes the matches records" do
58
+ index.search("badgers")[:records].should == [
59
+ { id: 1, views: 150, user_id: 0, status: "" },
60
+ { id: 3, views: 41, user_id: 0, status: "" },
61
+ { id: 4, views: 3003, user_id: 0, status: "" }
62
+ ]
63
+ end
64
+ end
65
+
66
+ context "with limits" do
67
+ it "still indicates the number of records found" do
68
+ index.search("badgers", limit: 2)[:total_found].should == 3
69
+ end
70
+
71
+ it "returns the limited subset of the results" do
72
+ index.search("badgers", limit: 2)[:records].should == [
73
+ { id: 1, views: 150, user_id: 0, status: "" },
74
+ { id: 3, views: 41, user_id: 0, status: "" }
75
+ ]
76
+ end
77
+
78
+ it "can use an offset" do
79
+ index.search("badgers", limit: 1, offset: 1)[:records].should == [
80
+ { id: 3, views: 41, user_id: 0, status: "" }
81
+ ]
82
+ end
83
+ end
84
+ end
85
+ end
metadata ADDED
@@ -0,0 +1,89 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: oedipus
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1.pre1
5
+ prerelease: 6
6
+ platform: ruby
7
+ authors:
8
+ - d11wtq
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2012-03-30 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: ruby-mysql
16
+ requirement: &20332040 !ruby/object:Gem::Requirement
17
+ none: false
18
+ requirements:
19
+ - - ! '>='
20
+ - !ruby/object:Gem::Version
21
+ version: '0'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: *20332040
25
+ - !ruby/object:Gem::Dependency
26
+ name: rspec
27
+ requirement: &20331620 !ruby/object:Gem::Requirement
28
+ none: false
29
+ requirements:
30
+ - - ! '>='
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: *20331620
36
+ description: ! "Oedipus brings full support for Sphinx 2 to Ruby:\n\n * real-time
37
+ indexes\n * faceted search\n * multi-queries\n * full attribute support\n
38
+ \ * optional model-style interaction\n\n It works with 'stable' versions
39
+ of Sphinx 2 (>= 2.0.2). All\n features are implemented entirely through the SphinxQL
40
+ interface."
41
+ email:
42
+ - chris@w3style.co.uk
43
+ executables: []
44
+ extensions: []
45
+ extra_rdoc_files: []
46
+ files:
47
+ - .gitignore
48
+ - .rspec
49
+ - Gemfile
50
+ - LICENSE
51
+ - README.md
52
+ - Rakefile
53
+ - lib/oedipus.rb
54
+ - lib/oedipus/connection.rb
55
+ - lib/oedipus/index.rb
56
+ - lib/oedipus/mysql/client.rb
57
+ - lib/oedipus/query_builder.rb
58
+ - lib/oedipus/version.rb
59
+ - oedipus.gemspec
60
+ - spec/data/.gitkeep
61
+ - spec/spec_helper.rb
62
+ - spec/support/test_harness.rb
63
+ - spec/unit/connection_spec.rb
64
+ - spec/unit/index_spec.rb
65
+ homepage: https://github.com/d11wtq/oedipus
66
+ licenses: []
67
+ post_install_message:
68
+ rdoc_options: []
69
+ require_paths:
70
+ - lib
71
+ required_ruby_version: !ruby/object:Gem::Requirement
72
+ none: false
73
+ requirements:
74
+ - - ! '>='
75
+ - !ruby/object:Gem::Version
76
+ version: '0'
77
+ required_rubygems_version: !ruby/object:Gem::Requirement
78
+ none: false
79
+ requirements:
80
+ - - ! '>'
81
+ - !ruby/object:Gem::Version
82
+ version: 1.3.1
83
+ requirements: []
84
+ rubyforge_project: oedipus
85
+ rubygems_version: 1.8.11
86
+ signing_key:
87
+ specification_version: 3
88
+ summary: Sphinx 2 Search Client for Ruby
89
+ test_files: []