oedipus 0.0.1.pre1

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