oedipus 0.0.1.pre1 → 0.0.1.pre2

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.
Files changed (48) hide show
  1. data/.gitignore +2 -0
  2. data/README.md +235 -44
  3. data/Rakefile +25 -0
  4. data/ext/oedipus/extconf.rb +72 -0
  5. data/ext/oedipus/oedipus.c +239 -0
  6. data/ext/oedipus/oedipus.h +50 -0
  7. data/lib/oedipus/comparison/between.rb +26 -0
  8. data/lib/oedipus/comparison/equal.rb +21 -0
  9. data/lib/oedipus/comparison/gt.rb +21 -0
  10. data/lib/oedipus/comparison/gte.rb +21 -0
  11. data/lib/oedipus/comparison/in.rb +21 -0
  12. data/lib/oedipus/comparison/lt.rb +21 -0
  13. data/lib/oedipus/comparison/lte.rb +21 -0
  14. data/lib/oedipus/comparison/not.rb +25 -0
  15. data/lib/oedipus/comparison/not_equal.rb +21 -0
  16. data/lib/oedipus/comparison/not_in.rb +21 -0
  17. data/lib/oedipus/comparison/outside.rb +26 -0
  18. data/lib/oedipus/comparison/shortcuts.rb +144 -0
  19. data/lib/oedipus/comparison.rb +88 -0
  20. data/lib/oedipus/connection.rb +91 -13
  21. data/lib/oedipus/connection_error.rb +14 -0
  22. data/lib/oedipus/index.rb +189 -46
  23. data/lib/oedipus/query_builder.rb +97 -4
  24. data/lib/oedipus/version.rb +1 -1
  25. data/lib/oedipus.rb +24 -7
  26. data/oedipus.gemspec +4 -5
  27. data/spec/integration/connection_spec.rb +58 -0
  28. data/spec/integration/index_spec.rb +353 -0
  29. data/spec/spec_helper.rb +2 -23
  30. data/spec/support/test_harness.rb +30 -9
  31. data/spec/unit/comparison/between_spec.rb +36 -0
  32. data/spec/unit/comparison/equal_spec.rb +22 -0
  33. data/spec/unit/comparison/gt_spec.rb +22 -0
  34. data/spec/unit/comparison/gte_spec.rb +22 -0
  35. data/spec/unit/comparison/in_spec.rb +22 -0
  36. data/spec/unit/comparison/lt_spec.rb +22 -0
  37. data/spec/unit/comparison/lte_spec.rb +22 -0
  38. data/spec/unit/comparison/not_equal_spec.rb +22 -0
  39. data/spec/unit/comparison/not_in_spec.rb +22 -0
  40. data/spec/unit/comparison/not_spec.rb +37 -0
  41. data/spec/unit/comparison/outside_spec.rb +36 -0
  42. data/spec/unit/comparison/shortcuts_spec.rb +125 -0
  43. data/spec/unit/comparison_spec.rb +109 -0
  44. data/spec/unit/query_builder_spec.rb +150 -0
  45. metadata +68 -19
  46. data/lib/oedipus/mysql/client.rb +0 -136
  47. data/spec/unit/connection_spec.rb +0 -36
  48. data/spec/unit/index_spec.rb +0 -85
data/lib/oedipus/index.rb CHANGED
@@ -11,7 +11,6 @@ module Oedipus
11
11
  # Representation of a search index for querying.
12
12
  class Index
13
13
  attr_reader :name
14
- attr_reader :attributes
15
14
 
16
15
  # Initialize the index named +name+ on the connection +conn+.
17
16
  #
@@ -21,16 +20,15 @@ module Oedipus
21
20
  # @param [Connection] conn
22
21
  # an instance of Oedipus::Connection for querying
23
22
  def initialize(name, conn)
24
- @name = name.to_sym
25
- @conn = conn
26
- @attributes = reflect_attributes
27
- @builder = QueryBuilder.new(name, conn)
23
+ @name = name.to_sym
24
+ @conn = conn
25
+ @builder = QueryBuilder.new(name)
28
26
  end
29
27
 
30
28
  # Insert the record with the ID +id+.
31
29
  #
32
30
  # @example
33
- # index.insert(42, :title => "example", :views => 22)
31
+ # index.insert(42, title: "example", views: 22)
34
32
  #
35
33
  # @param [Integer] id
36
34
  # the unique ID of the document in the index
@@ -38,66 +36,211 @@ module Oedipus
38
36
  # @param [Hash] hash
39
37
  # a symbol-keyed hash of data to insert
40
38
  #
41
- # @return [Hash]
42
- # a copy of the inserted record
39
+ # @return [Fixnum]
40
+ # the number of rows inserted (currently always 1)
43
41
  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) })
42
+ @conn.execute(@builder.insert(id, hash))
43
+ end
44
+
45
+ # Update the record with the ID +id+.
46
+ #
47
+ # @example
48
+ # index.update(42, views: 25)
49
+ #
50
+ # @param [Integer] id
51
+ # the unique ID of the document in the index
52
+ #
53
+ # @param [Hash] hash
54
+ # a symbol-keyed hash of data to set
55
+ #
56
+ # @return [Fixnum]
57
+ # the number of rows updated (1 or 0)
58
+ def update(id, hash)
59
+ @conn.execute(@builder.update(id, hash))
50
60
  end
51
61
 
62
+ # Completely replace the record with the ID +id+.
63
+ #
64
+ # @example
65
+ # index.replace(42, title: "New title", views: 25)
66
+ #
67
+ # @param [Integer] id
68
+ # the unique ID of the document in the index
69
+ #
70
+ # @param [Hash] hash
71
+ # a symbol-keyed hash of data to insert
72
+ #
73
+ # @return [Fixnum]
74
+ # the number of rows inserted (currentl always 1)
75
+ def replace(id, hash)
76
+ @conn.execute(@builder.replace(id, hash))
77
+ end
78
+
79
+ # Fetch a single document by its ID.
80
+ #
81
+ # Returns the Hash of attributes if found, otherwise nil.
82
+ #
83
+ # @param [Fixnum] id
84
+ # the ID of the document
85
+ #
86
+ # @return [Hash]
87
+ # the attributes of the record
88
+ def fetch(id)
89
+ search(id: id)[:records].first
90
+ end
91
+
92
+ # Perform a search on the index.
93
+ #
94
+ # Either one or two arguments may be passed, with either one being mutually
95
+ # optional.
96
+ #
97
+ # @example Fulltext search
98
+ # index.search("cats AND dogs")
99
+ #
100
+ # @example Fulltext search with attribute filters
101
+ # index.search("cats AND dogs", author_id: 57)
102
+ #
103
+ # @example Attribute search only
104
+ # index.search(author_id: 57)
105
+ #
106
+ # @param [String] query
107
+ # a fulltext query
108
+ #
109
+ # @param [Hash] filters
110
+ # attribute filters, limits, sorting and other options
111
+ #
112
+ # @return [Hash]
113
+ # a Hash containing meta data, with the records in :records
52
114
  def search(*args)
53
- raise ArgumentError, "Wrong number of arguments (#{args.size} for 1..2)" unless (1..2) === args.size
115
+ multi_search(_main_: args)[:_main_]
116
+ end
117
+
118
+ # Perform a faceted search on the index, using a base query and one or more facets.
119
+ #
120
+ # The base query is inherited by each facet, which may override (or refine) the
121
+ # query.
122
+ #
123
+ # The results returned include a :facets key, containing the results for each facet.
124
+ #
125
+ # @example
126
+ # index.faceted_search(
127
+ # "cats | dogs",
128
+ # category_id: 7,
129
+ # facets: {
130
+ # popular: {views: Oedipus.gt(150)},
131
+ # recent: {published_at: Oedipus.gt(Time.now.to_i - 7 * 86400)}
132
+ # }
133
+ # )
134
+ #
135
+ # @param [String] fulltext_query
136
+ # a fulltext query to search on, optional
137
+ #
138
+ # @param [Hash] options
139
+ # attribute filters and facets in a sub-hash
140
+ #
141
+ # @return [Hash]
142
+ # a Hash whose top-level result set is for the main query and which
143
+ # contains a :facets element with keys mapping 1:1 for all facets
144
+ def faceted_search(*args)
145
+ query, options = extract_query_data(args)
146
+ main_query = [query, options.reject { |k, _| k == :facets }]
147
+ facets = merge_queries(main_query, options.fetch(:facets, {}))
54
148
 
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"
149
+ { facets: {} }.tap do |results|
150
+ multi_search({ _main_: main_query }.merge(facets)).each do |k, v|
151
+ k == :_main_ ? results.merge!(v) : results[:facets].merge!(k => v)
152
+ end
59
153
  end
154
+ end
60
155
 
61
- results = @conn.execute(@builder.sql(query, attrs))
62
- meta = @conn.execute("SHOW META")
156
+ # Perform a a batch search on the index.
157
+ #
158
+ # A Hash of queries is passed, whose keys are used to collate the results in
159
+ # the return value.
160
+ #
161
+ # Each query may either by a string (fulltext search), a Hash (attribute search)
162
+ # or an array containing both. In other words, the same arguments accepted by
163
+ # the #search method.
164
+ #
165
+ # @example
166
+ # index.multi_search(
167
+ # cat_results: ["cats", { author_id: 57 }],
168
+ # dog_results: ["dogs", { author_id: 57 }]
169
+ # )
170
+ #
171
+ # @param [Hash] queries
172
+ # a hash whose keys map to queries
173
+ #
174
+ # @return [Hash]
175
+ # a Hash whose keys map 1:1 with the input Hash, each element containing the
176
+ # same results as those returned by the #search method.
177
+ def multi_search(queries)
178
+ unless queries.kind_of?(Hash)
179
+ raise ArgumentError, "Argument must be a Hash of named queries (#{queries.class} given)"
180
+ end
63
181
 
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
182
+ rs = @conn.multi_query(
183
+ queries.map { |key, args|
184
+ [@builder.select(*extract_query_data(args)), "SHOW META"]
185
+ }.flatten.join(";\n")
186
+ )
187
+
188
+ Hash[].tap do |result|
189
+ queries.keys.each do |key|
190
+ records, meta = rs.shift, rs.shift
191
+ result[key] = meta_to_hash(meta).tap do |r|
192
+ r[:records] = records.map { |hash|
193
+ hash.inject({}) { |o, (k, v)| o.merge!(k.to_sym => v) }
194
+ }
71
195
  end
72
196
  end
197
+ end
198
+ end
73
199
 
74
- r[:records] = []
200
+ private
75
201
 
76
- results.each_hash do |hash|
77
- r[:records] << Hash[hash.keys.map(&:to_sym).zip(hash.map { |k, v| cast(k, v) })]
202
+ def meta_to_hash(meta)
203
+ Hash[].tap do |hash|
204
+ meta.each do |m|
205
+ n, v = m.values
206
+ case n
207
+ when "total_found", "total" then hash[n.to_sym] = v.to_i
208
+ when "time" then hash[:time] = v.to_f
209
+ when /\Adocs\[\d+\]\Z/ then (hash[:docs] ||= []).tap { |a| a << v.to_i }
210
+ when /\Ahits\[\d+\]\Z/ then (hash[:hits] ||= []).tap { |a| a << v.to_i }
211
+ when /\Akeyword\[\d+\]\Z/ then (hash[:keywords] ||= []).tap { |a| a << v }
212
+ else hash[n.to_sym] = v
213
+ end
214
+ end
215
+
216
+ if hash.key?(:docs) && hash.key?(:hits) && hash.key?(:keywords)
217
+ hash[:docs] = Hash[(hash[:keywords]).zip(hash[:docs])]
218
+ hash[:hits] = Hash[(hash[:keywords]).zip(hash[:hits])]
78
219
  end
79
220
  end
80
221
  end
81
222
 
82
- private
223
+ def extract_query_data(args, default_query = "")
224
+ args = [args] unless Array === args
83
225
 
84
- def cast(key, value)
85
- case attributes[key.to_sym]
86
- when Fixnum then Integer(value)
87
- else value
226
+ unless (1..2) === args.size
227
+ raise ArgumentError, "Wrong number of query arguments (#{args.size} for 1..2)"
88
228
  end
89
- end
90
229
 
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
230
+ case args[0]
231
+ when String then [args[0], args.fetch(1, {})]
232
+ when Hash then [default_query, args[0] ]
233
+ else raise ArgumentError, "Invalid query argument type #{args.first.class}"
100
234
  end
101
235
  end
236
+
237
+ def merge_queries(base, others)
238
+ base_query, base_filters = base
239
+
240
+ Hash[others.map { |k, q|
241
+ query, filters = extract_query_data(q, base_query)
242
+ [k, [query.gsub("%{query}", base_query), base_filters.merge(filters)]]
243
+ }]
244
+ end
102
245
  end
103
246
  end
@@ -8,32 +8,125 @@
8
8
  ##
9
9
 
10
10
  module Oedipus
11
+ # Constructs SphinxQL queries from the internal Hash format.
11
12
  class QueryBuilder
12
- def initialize(index_name, conn)
13
+ # Initialize a new QueryBuilder for +index_name+.
14
+ #
15
+ # @param [Symbol] index_name
16
+ # the name of the index being queried
17
+ def initialize(index_name)
13
18
  @index_name = index_name
14
- @conn = conn
15
19
  end
16
20
 
17
- def sql(query, filters)
21
+ # Build a SphinxQL query for the fulltext search +query+ and filters in +filters+.
22
+ #
23
+ # @param [String] query
24
+ # the fulltext query to execute (may be empty)
25
+ #
26
+ # @param [Hash] filters
27
+ # additional attribute filters and other options
28
+ #
29
+ # @return [String]
30
+ # a SphinxQL query
31
+ def select(query, filters)
18
32
  [
19
33
  from,
20
34
  conditions(query, filters),
35
+ order_by(filters),
21
36
  limits(filters)
22
37
  ].join(" ")
23
38
  end
24
39
 
40
+ # Build a SphinxQL query to insert the record identified by +id+ with the given attributes.
41
+ #
42
+ # @param [Fixnum] id
43
+ # the unique ID of the document to insert
44
+ #
45
+ # @param [Hash] attributes
46
+ # a Hash of attributes
47
+ #
48
+ # @return [String]
49
+ # the SphinxQL to insert the record
50
+ def insert(id, attributes)
51
+ into("INSERT", id, attributes)
52
+ end
53
+
54
+ # Build a SphinxQL query to update the record identified by +id+ with the given attributes.
55
+ #
56
+ # @param [Fixnum] id
57
+ # the unique ID of the document to update
58
+ #
59
+ # @param [Hash] attributes
60
+ # a Hash of attributes
61
+ #
62
+ # @return [String]
63
+ # the SphinxQL to update the record
64
+ def update(id, attributes)
65
+ [
66
+ "UPDATE #{@index_name} SET",
67
+ update_attributes(attributes),
68
+ "WHERE id = #{Connection.quote(id)}"
69
+ ].join(" ")
70
+ end
71
+
72
+ # Build a SphinxQL query to replace the record identified by +id+ with the given attributes.
73
+ #
74
+ # @param [Fixnum] id
75
+ # the unique ID of the document to replace
76
+ #
77
+ # @param [Hash] attributes
78
+ # a Hash of attributes
79
+ #
80
+ # @return [String]
81
+ # the SphinxQL to replace the record
82
+ def replace(id, attributes)
83
+ into("REPLACE", id, attributes)
84
+ end
85
+
25
86
  private
26
87
 
27
88
  def from
28
89
  "SELECT * FROM #{@index_name}"
29
90
  end
30
91
 
92
+ def into(type, id, attributes)
93
+ [
94
+ type,
95
+ "INTO #{@index_name}",
96
+ "(#{([:id] + attributes.keys).join(', ')})",
97
+ "VALUES",
98
+ "(#{([id] + attributes.values).map { |v| Connection.quote(v) }.join(', ')})"
99
+ ].join(" ")
100
+ end
101
+
31
102
  def conditions(query, filters)
32
103
  exprs = []
33
- exprs << "MATCH(#{@conn.quote(query)})" unless query.empty?
104
+ exprs << "MATCH(#{Connection.quote(query)})" unless query.empty?
105
+ exprs += attribute_conditions(filters)
34
106
  "WHERE " << exprs.join(" AND ") if exprs.any?
35
107
  end
36
108
 
109
+ def attribute_conditions(filters)
110
+ filters \
111
+ .reject { |k, v| [:limit, :offset, :order].include?(k.to_sym) } \
112
+ .map { |k, v| "#{k} #{Comparison.of(v)}" }
113
+ end
114
+
115
+ def update_attributes(attributes)
116
+ attributes \
117
+ .map { |k, v| "#{k} = #{Connection.quote(v)}" } \
118
+ .join(", ")
119
+ end
120
+
121
+ def order_by(filters)
122
+ return unless filters.key?(:order)
123
+
124
+ [
125
+ "ORDER BY",
126
+ Array(filters[:order]).map { |k, dir| "#{k} #{dir ? dir.to_s.upcase : 'ASC'}" }.join(", ")
127
+ ].join(" ")
128
+ end
129
+
37
130
  def limits(filters)
38
131
  "LIMIT #{filters[:offset].to_i}, #{filters[:limit].to_i}" if filters.key?(:limit)
39
132
  end
@@ -8,5 +8,5 @@
8
8
  ##
9
9
 
10
10
  module Oedipus
11
- VERSION = "0.0.1.pre1"
11
+ VERSION = "0.0.1.pre2"
12
12
  end
data/lib/oedipus.rb CHANGED
@@ -8,18 +8,39 @@
8
8
  ##
9
9
 
10
10
  require "oedipus/version"
11
+
12
+ require "oedipus/oedipus"
13
+
14
+ require "oedipus/comparison"
15
+ require "oedipus/comparison/equal"
16
+ require "oedipus/comparison/not_equal"
17
+ require "oedipus/comparison/between"
18
+ require "oedipus/comparison/outside"
19
+ require "oedipus/comparison/in"
20
+ require "oedipus/comparison/not_in"
21
+ require "oedipus/comparison/gte"
22
+ require "oedipus/comparison/gt"
23
+ require "oedipus/comparison/lte"
24
+ require "oedipus/comparison/lt"
25
+ require "oedipus/comparison/not"
26
+ require "oedipus/comparison/shortcuts"
27
+
11
28
  require "oedipus/query_builder"
29
+
30
+ require "oedipus/connection_error"
12
31
  require "oedipus/connection"
32
+
13
33
  require "oedipus/index"
14
- require "oedipus/mysql/client"
15
34
 
16
35
  module Oedipus
36
+ extend Comparison::Shortcuts
37
+
17
38
  class << self
18
39
  # Connect to Sphinx running SphinxQL.
19
40
  #
20
41
  # @example
21
42
  # c = Oedipus.connect("localhost:9306")
22
- # c = Oedipus.connect(:host => "localhost", :port => 9306)
43
+ # c = Oedipus.connect(host: "localhost", port: 9306)
23
44
  #
24
45
  # @param [String] server
25
46
  # a 'hostname:port' string
@@ -31,11 +52,7 @@ module Oedipus
31
52
  # a client connected to SphinxQL
32
53
  def connect(options)
33
54
  # TODO: Add pooling
34
- Connection.new(
35
- options.kind_of?(String) ?
36
- Hash[ [:host, :port].zip(options.split(":")) ] :
37
- options
38
- )
55
+ Connection.new(options)
39
56
  end
40
57
  end
41
58
  end
data/oedipus.gemspec CHANGED
@@ -9,7 +9,7 @@ Gem::Specification.new do |s|
9
9
  s.email = ["chris@w3style.co.uk"]
10
10
  s.homepage = "https://github.com/d11wtq/oedipus"
11
11
  s.summary = "Sphinx 2 Search Client for Ruby"
12
- s.description = <<-DESC.strip
12
+ s.description = <<-DESC.gsub(/^ {4}/m, "")
13
13
  Oedipus brings full support for Sphinx 2 to Ruby:
14
14
 
15
15
  * real-time indexes
@@ -25,11 +25,10 @@ Gem::Specification.new do |s|
25
25
  s.rubyforge_project = "oedipus"
26
26
 
27
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) }
28
+ s.test_files = `git ls-files -- spec/*`.split("\n")
29
+ s.extensions = ["ext/oedipus/extconf.rb"]
30
30
  s.require_paths = ["lib"]
31
31
 
32
- s.add_runtime_dependency "ruby-mysql"
33
-
34
32
  s.add_development_dependency "rspec"
33
+ s.add_development_dependency "rake-compiler"
35
34
  end
@@ -0,0 +1,58 @@
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
+ include Oedipus::TestHarness
14
+
15
+ let(:conn) { Oedipus::Connection.new(searchd_host) }
16
+
17
+ describe "#initialize" do
18
+ context "with a hosname:port string" do
19
+ context "on successful connection" do
20
+ it "returns the connection" do
21
+ Oedipus::Connection.new(searchd_host.values.join(":")).should be_a_kind_of(Oedipus::Connection)
22
+ end
23
+ end
24
+
25
+ context "on failed connection" do
26
+ it "raises an error" do
27
+ expect {
28
+ Oedipus::Connection.new("127.0.0.1:45346138")
29
+ }.to raise_error(Oedipus::ConnectionError)
30
+ end
31
+ end
32
+ end
33
+
34
+ context "with an options Hash" do
35
+ context "on successful connection" do
36
+ it "returns the connection" do
37
+ Oedipus::Connection.new(searchd_host).should be_a_kind_of(Oedipus::Connection)
38
+ end
39
+ end
40
+
41
+ context "on failed connection" do
42
+ it "raises an error" do
43
+ expect {
44
+ Oedipus::Connection.new(:host => "127.0.0.1", :port => 45346138)
45
+ }.to raise_error(Oedipus::ConnectionError)
46
+ end
47
+ end
48
+ end
49
+ end
50
+
51
+ describe "#[]" do
52
+ let(:conn) { Oedipus::Connection.new(searchd_host) }
53
+
54
+ it "returns an index" do
55
+ conn[:posts_rt].should be_a_kind_of(Oedipus::Index)
56
+ end
57
+ end
58
+ end