oedipus 0.0.1.pre1 → 0.0.1.pre2

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