dbldots_oedipus 0.0.16

Sign up to get free protection for your applications and to get access to all the features.
Files changed (54) hide show
  1. data/.gitignore +10 -0
  2. data/.rspec +1 -0
  3. data/Gemfile +4 -0
  4. data/LICENSE +20 -0
  5. data/README.md +435 -0
  6. data/Rakefile +26 -0
  7. data/ext/oedipus/extconf.rb +72 -0
  8. data/ext/oedipus/lexing.c +96 -0
  9. data/ext/oedipus/lexing.h +20 -0
  10. data/ext/oedipus/oedipus.c +339 -0
  11. data/ext/oedipus/oedipus.h +58 -0
  12. data/lib/oedipus.rb +40 -0
  13. data/lib/oedipus/comparison.rb +88 -0
  14. data/lib/oedipus/comparison/between.rb +21 -0
  15. data/lib/oedipus/comparison/equal.rb +21 -0
  16. data/lib/oedipus/comparison/gt.rb +21 -0
  17. data/lib/oedipus/comparison/gte.rb +21 -0
  18. data/lib/oedipus/comparison/in.rb +21 -0
  19. data/lib/oedipus/comparison/lt.rb +21 -0
  20. data/lib/oedipus/comparison/lte.rb +21 -0
  21. data/lib/oedipus/comparison/not.rb +25 -0
  22. data/lib/oedipus/comparison/not_equal.rb +21 -0
  23. data/lib/oedipus/comparison/not_in.rb +21 -0
  24. data/lib/oedipus/comparison/outside.rb +21 -0
  25. data/lib/oedipus/comparison/shortcuts.rb +144 -0
  26. data/lib/oedipus/connection.rb +124 -0
  27. data/lib/oedipus/connection/pool.rb +133 -0
  28. data/lib/oedipus/connection/registry.rb +56 -0
  29. data/lib/oedipus/connection_error.rb +14 -0
  30. data/lib/oedipus/index.rb +320 -0
  31. data/lib/oedipus/query_builder.rb +185 -0
  32. data/lib/oedipus/rspec/test_rig.rb +132 -0
  33. data/lib/oedipus/version.rb +12 -0
  34. data/oedipus.gemspec +42 -0
  35. data/spec/data/.gitkeep +0 -0
  36. data/spec/integration/connection/registry_spec.rb +50 -0
  37. data/spec/integration/connection_spec.rb +156 -0
  38. data/spec/integration/index_spec.rb +442 -0
  39. data/spec/spec_helper.rb +16 -0
  40. data/spec/unit/comparison/between_spec.rb +36 -0
  41. data/spec/unit/comparison/equal_spec.rb +22 -0
  42. data/spec/unit/comparison/gt_spec.rb +22 -0
  43. data/spec/unit/comparison/gte_spec.rb +22 -0
  44. data/spec/unit/comparison/in_spec.rb +22 -0
  45. data/spec/unit/comparison/lt_spec.rb +22 -0
  46. data/spec/unit/comparison/lte_spec.rb +22 -0
  47. data/spec/unit/comparison/not_equal_spec.rb +22 -0
  48. data/spec/unit/comparison/not_in_spec.rb +22 -0
  49. data/spec/unit/comparison/not_spec.rb +37 -0
  50. data/spec/unit/comparison/outside_spec.rb +36 -0
  51. data/spec/unit/comparison/shortcuts_spec.rb +125 -0
  52. data/spec/unit/comparison_spec.rb +109 -0
  53. data/spec/unit/query_builder_spec.rb +205 -0
  54. metadata +164 -0
@@ -0,0 +1,185 @@
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
+ # Constructs SphinxQL queries from the internal Hash format.
12
+ class QueryBuilder
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)
18
+ @index_name = index_name
19
+ end
20
+
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)
32
+ where, *bind_values = conditions(query, filters)
33
+ [
34
+ [
35
+ from(filters),
36
+ where,
37
+ order_by(filters),
38
+ limits(filters)
39
+ ].join(" "),
40
+ *bind_values
41
+ ]
42
+ end
43
+
44
+ # Build a SphinxQL query to insert the record identified by +id+ with the given attributes.
45
+ #
46
+ # @param [Fixnum] id
47
+ # the unique ID of the document to insert
48
+ #
49
+ # @param [Hash] attributes
50
+ # a Hash of attributes
51
+ #
52
+ # @return [String]
53
+ # the SphinxQL to insert the record
54
+ def insert(id, attributes)
55
+ into("INSERT", id, attributes)
56
+ end
57
+
58
+ # Build a SphinxQL query to update the record identified by +id+ with the given attributes.
59
+ #
60
+ # @param [Fixnum] id
61
+ # the unique ID of the document to update
62
+ #
63
+ # @param [Hash] attributes
64
+ # a Hash of attributes
65
+ #
66
+ # @return [String]
67
+ # the SphinxQL to update the record
68
+ def update(id, attributes)
69
+ set_attrs, *bind_values = update_attributes(attributes)
70
+ [
71
+ [
72
+ "UPDATE #{@index_name} SET",
73
+ set_attrs,
74
+ "WHERE id = ?"
75
+ ].join(" "),
76
+ *bind_values.push(id)
77
+ ]
78
+ end
79
+
80
+ # Build a SphinxQL query to replace the record identified by +id+ with the given attributes.
81
+ #
82
+ # @param [Fixnum] id
83
+ # the unique ID of the document to replace
84
+ #
85
+ # @param [Hash] attributes
86
+ # a Hash of attributes
87
+ #
88
+ # @return [String]
89
+ # the SphinxQL to replace the record
90
+ def replace(id, attributes)
91
+ into("REPLACE", id, attributes)
92
+ end
93
+
94
+ # Build a SphinxQL query to delete the record identified by +id+.
95
+ #
96
+ # @param [Fixnum] id
97
+ # the unique ID of the document to delete
98
+ #
99
+ # @return [String]
100
+ # the SphinxQL to delete the record
101
+ def delete(id)
102
+ ["DELETE FROM #{@index_name} WHERE id = ?", id]
103
+ end
104
+
105
+ private
106
+
107
+ RESERVED = [:attrs, :limit, :offset, :order]
108
+
109
+ def fields(filters)
110
+ filters.fetch(:attrs, [:*]).dup.tap do |fields|
111
+ if fields.none? { |a| /\brelevance\n/ === a } && normalize_order(filters).key?(:relevance)
112
+ fields << "WEIGHT() AS relevance"
113
+ end
114
+ end
115
+ end
116
+
117
+ def from(filters)
118
+ [
119
+ "SELECT",
120
+ fields(filters).join(", "),
121
+ "FROM",
122
+ @index_name
123
+ ].join(" ")
124
+ end
125
+
126
+ def into(type, id, attributes)
127
+ attrs, values = attributes.inject([[:id], [id]]) do |(a, b), (k, v)|
128
+ [a.push(k), b.push(v)]
129
+ end
130
+
131
+ [
132
+ [
133
+ type,
134
+ "INTO #{@index_name}",
135
+ "(#{attrs.join(', ')})",
136
+ "VALUES",
137
+ "(#{(['?'] * attrs.size).join(', ')})"
138
+ ].join(" "),
139
+ *values
140
+ ]
141
+ end
142
+
143
+ def conditions(query, filters)
144
+ sql = []
145
+ sql << ["MATCH(?)", query] unless query.empty?
146
+ sql.push(*attribute_conditions(filters))
147
+
148
+ exprs, bind_values = sql.inject([[], []]) do |(strs, values), v|
149
+ [strs.push(v.shift), values.push(*v)]
150
+ end
151
+
152
+ ["WHERE " << exprs.join(" AND "), *bind_values] if exprs.any?
153
+ end
154
+
155
+ def attribute_conditions(filters)
156
+ filters.reject{ |k, v| RESERVED.include?(k.to_sym) }.map do |k, v|
157
+ Comparison.of(v).to_sql.tap { |c| c[0].insert(0, "#{k} ") }
158
+ end
159
+ end
160
+
161
+ def update_attributes(attributes)
162
+ [
163
+ attributes.keys.map{ |k| "#{k} = ?" }.join(", "),
164
+ *attributes.values
165
+ ]
166
+ end
167
+
168
+ def order_by(filters)
169
+ return unless (order = normalize_order(filters)).any?
170
+
171
+ [
172
+ "ORDER BY",
173
+ order.map { |k, dir| "#{k} #{dir.to_s.upcase}" }.join(", ")
174
+ ].join(" ")
175
+ end
176
+
177
+ def normalize_order(filters)
178
+ Hash[Array(filters[:order]).map { |k, v| [k.to_sym, v || :asc] }]
179
+ end
180
+
181
+ def limits(filters)
182
+ "LIMIT #{filters[:offset].to_i}, #{filters[:limit].to_i}" if filters.key?(:limit)
183
+ end
184
+ end
185
+ end
@@ -0,0 +1,132 @@
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/core"
11
+ require "tmpdir"
12
+
13
+ # RSpec shared context that may be included to provide a pre-defined index.
14
+ shared_context "oedipus posts_rt" do
15
+ def sphinx_indexes
16
+ <<-STR
17
+ index posts_rt
18
+ {
19
+ type = rt
20
+ path = #{data_dir}/posts_rt
21
+
22
+ rt_field = title
23
+ rt_field = body
24
+
25
+ rt_attr_uint = user_id
26
+ rt_attr_uint = views
27
+ rt_attr_string = state
28
+ }
29
+ STR
30
+ end
31
+ end
32
+
33
+ # RSpec shared context that may be included to start and stop sphinx between example groups.
34
+ shared_context "oedipus test rig" do
35
+ before(:all) do
36
+ write_sphinx_config
37
+ start_sphinx
38
+ end
39
+
40
+ after(:all) do
41
+ stop_sphinx
42
+ clean_data_dir
43
+ end
44
+
45
+ after(:each) { empty_indexes }
46
+
47
+ # It is intended that any or all of the following be overridden in tests.
48
+ def connection
49
+ @connection ||= Oedipus::Connection.new(host: "127.0.0.1", port: 9399, verify: false)
50
+ end
51
+
52
+ def data_dir
53
+ @data_dir ||= Dir.mktmpdir("oedipus")
54
+ end
55
+
56
+ def searchd
57
+ ENV["SEARCHD"] || "searchd"
58
+ end
59
+
60
+ def sphinx_indexes
61
+ <<-IDX.strip.gsub(/^ {4}/, "")
62
+ index test_rt {
63
+ type = rt
64
+ path = #{data_dir}/test_rt
65
+ rt_field = test"
66
+ }
67
+ IDX
68
+ end
69
+
70
+ def sphinx_conf
71
+ <<-CONF.strip.gsub(/^ {4}/, "")
72
+ ##
73
+ # This file is automatically generated during tests
74
+ ##
75
+
76
+ #{sphinx_indexes}
77
+
78
+ searchd
79
+ {
80
+ compat_sphinxql_magics = 0
81
+
82
+ max_matches = 2000
83
+ pid_file = #{data_dir}/searchd.pid
84
+ listen = #{connection.options[:host]}:#{connection.options[:port]}:mysql41
85
+ workers = threads
86
+ log = #{data_dir}/searchd.log
87
+ query_log = #{data_dir}/searchd.log
88
+ binlog_path = #{data_dir}
89
+ }
90
+ CONF
91
+ end
92
+
93
+ def write_sphinx_config
94
+ File.open("#{data_dir}/sphinx.conf", "w") do |f|
95
+ f << sphinx_conf
96
+ end
97
+ end
98
+
99
+ def start_sphinx
100
+ @pid = Process.spawn(
101
+ searchd, "-c", "#{data_dir}/sphinx.conf",
102
+ out: "#{data_dir}/searchd.out",
103
+ err: "#{data_dir}/searchd.err"
104
+ )
105
+ Process.wait(@pid)
106
+ raise "Couldn't start sphinx" unless $? == 0
107
+ end
108
+
109
+ def stop_sphinx
110
+ @pid = Process.spawn(
111
+ searchd, "-c", "#{data_dir}/sphinx.conf", "--stopwait",
112
+ out: "#{data_dir}/searchd.out",
113
+ err: "#{data_dir}/searchd.err"
114
+ )
115
+ Process.wait(@pid)
116
+ raise "Couldn't stop sphinx" unless $? == 0
117
+ end
118
+
119
+ def clean_data_dir
120
+ Dir["#{data_dir}/**/*"].each do |path|
121
+ File.delete(path)
122
+ end
123
+ end
124
+
125
+ def empty_indexes
126
+ connection.query("SHOW TABLES").each do |idx|
127
+ connection.query("SELECT id FROM #{idx['Index']}").each do |hash|
128
+ connection.execute("DELETE FROM #{idx['Index']} WHERE id = #{hash['id']}")
129
+ end
130
+ end
131
+ end
132
+ 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.16"
12
+ end
@@ -0,0 +1,42 @@
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 = "dbldots_oedipus"
7
+ s.version = Oedipus::VERSION
8
+ s.authors = ["d11wtq", "dbldots"]
9
+ s.email = ["dbldots@gmail.com"]
10
+ s.homepage = "https://github.com/d11wtq/oedipus"
11
+ s.summary = "Sphinx 2 Search Client for Ruby"
12
+ s.description = <<-DESC.gsub(/^ {4}/m, "")
13
+ == Sphinx 2 Comes to Ruby
14
+
15
+ Oedipus brings full support for Sphinx 2 to Ruby:
16
+
17
+ - real-time indexes (insert, replace, update, delete)
18
+ - faceted search (variations on a base query)
19
+ - multi-queries (multiple queries executed in a batch)
20
+ - full attribute filtering support
21
+
22
+ It works with 'stable' versions of Sphinx 2 (>= 2.0.2). All
23
+ features are implemented entirely through the SphinxQL interface.
24
+
25
+ -- dbldots: --
26
+ this gem release in general shouldn't be used. it fixes an issue
27
+ on mac os x where multi queries are broken.
28
+
29
+ this gem will be deleted as soon as the bug is fixed in the
30
+ original version.
31
+ DESC
32
+
33
+ s.rubyforge_project = "dbldots_oedipus"
34
+
35
+ s.files = `git ls-files`.split("\n")
36
+ s.test_files = `git ls-files -- spec/*`.split("\n")
37
+ s.extensions = ["ext/oedipus/extconf.rb"]
38
+ s.require_paths = ["lib"]
39
+
40
+ s.add_development_dependency "rspec"
41
+ s.add_development_dependency "rake-compiler"
42
+ end
File without changes
@@ -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 "spec_helper"
11
+ require "oedipus/rspec/test_rig"
12
+
13
+ describe Oedipus::Connection::Registry do
14
+ include_context "oedipus test rig"
15
+ include_context "oedipus posts_rt"
16
+
17
+ let(:registry) do
18
+ Object.new.tap { |o| o.send(:extend, Oedipus::Connection::Registry) }
19
+ end
20
+
21
+ describe "#connect" do
22
+ it "makes a new connection to a SphinxQL host" do
23
+ registry.connect(connection.options).should be_a_kind_of(Oedipus::Connection)
24
+ end
25
+ end
26
+
27
+ describe "#connection" do
28
+ context "without a name" do
29
+ let(:conn) { registry.connect(connection.options) }
30
+
31
+ it "returns an existing connection" do
32
+ conn.should equal registry.connection
33
+ end
34
+ end
35
+
36
+ context "with a name" do
37
+ let(:conn) { registry.connect(connection.options, :bob) }
38
+
39
+ it "returns the named connection" do
40
+ conn.should equal registry.connection(:bob)
41
+ end
42
+ end
43
+
44
+ context "with a bad name" do
45
+ it "raises an ArgumentError" do
46
+ expect { registry.connection(:wrong) }.to raise_error(ArgumentError)
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,156 @@
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
+ require "oedipus/rspec/test_rig"
12
+
13
+ describe Oedipus::Connection do
14
+ include_context "oedipus test rig"
15
+ include_context "oedipus posts_rt"
16
+
17
+ let(:conn) { Oedipus::Connection.new(connection.options) }
18
+
19
+ describe "#initialize" do
20
+ context "with a hosname:port string" do
21
+ context "on successful connection" do
22
+ it "returns the connection" do
23
+ Oedipus::Connection.new(
24
+ "#{connection.options[:host]}:#{connection.options[:port]}"
25
+ ).should be_a_kind_of(Oedipus::Connection)
26
+ end
27
+ end
28
+
29
+ context "on failed connection" do
30
+ it "raises an error" do
31
+ expect {
32
+ Oedipus::Connection.new("127.0.0.1:45346138")
33
+ }.to raise_error(Oedipus::ConnectionError)
34
+ end
35
+ end
36
+ end
37
+
38
+ context "with an options Hash" do
39
+ context "on successful connection" do
40
+ it "returns the connection" do
41
+ Oedipus::Connection.new(connection.options).should be_a_kind_of(Oedipus::Connection)
42
+ end
43
+ end
44
+
45
+ context "on failed connection" do
46
+ it "raises an error" do
47
+ expect {
48
+ Oedipus::Connection.new(:host => "127.0.0.1", :port => 45346138)
49
+ }.to raise_error(Oedipus::ConnectionError)
50
+ end
51
+ end
52
+ end
53
+ end
54
+
55
+ describe "#[]" do
56
+ it "returns an index" do
57
+ conn[:posts_rt].should be_a_kind_of(Oedipus::Index)
58
+ end
59
+ end
60
+
61
+ describe "#query" do
62
+ it "accepts integer bind parameters" do
63
+ conn.query("SELECT * FROM posts_rt WHERE views = ? AND user_id = ?", 1, 7)
64
+ end
65
+
66
+ xit "accepts float bind parameters" do
67
+ conn.query("SELECT * FROM posts_rt WHERE views = ? AND user_id = ?", 1.2, 7.2)
68
+ end
69
+
70
+ xit "accepts decimal bind parameters" do
71
+ require "bigdecimal"
72
+ conn.query("SELECT * FROM posts_rt WHERE views = ? AND user_id = ?", BigDecimal("1.2"), BigDecimal("7.2"))
73
+ end
74
+
75
+ xit "accepts string bind parameters" do
76
+ conn.query("SELECT * FROM posts_rt WHERE state = ?", "something")
77
+ end
78
+ end
79
+
80
+ describe "#multi_query" do
81
+ it "accepts integer bind parameters" do
82
+ conn.multi_query("SELECT * FROM posts_rt WHERE views = ? AND user_id = ?", 1, 7)
83
+ end
84
+
85
+ xit "accepts float bind parameters" do
86
+ conn.multi_query("SELECT * FROM posts_rt WHERE views = ? AND user_id = ?", 1.2, 7.2)
87
+ end
88
+
89
+ xit "accepts decimal bind parameters" do
90
+ require "bigdecimal"
91
+ conn.multi_query("SELECT * FROM posts_rt WHERE views = ? AND user_id = ?", BigDecimal("1.2"), BigDecimal("7.2"))
92
+ end
93
+
94
+ xit "accepts string bind parameters" do
95
+ conn.multi_query("SELECT * FROM posts_rt WHERE state = ?", "something")
96
+ end
97
+ end
98
+
99
+ describe "#execute" do
100
+ it "accepts integer bind parameters" do
101
+ conn.execute("REPLACE INTO posts_rt (id, views) VALUES (?, ?)", 1, 7)
102
+ end
103
+
104
+ it "accepts float bind parameters" do
105
+ conn.execute("REPLACE INTO posts_rt (id, views) VALUES (?, ?)", 1, 7.2)
106
+ end
107
+
108
+ it "accepts decimal bind parameters" do
109
+ require "bigdecimal"
110
+ conn.execute("REPLACE INTO posts_rt (id, views) VALUES (?, ?)", 1, BigDecimal("7.2"))
111
+ end
112
+
113
+ it "accepts string bind parameters" do
114
+ conn.execute("REPLACE INTO posts_rt (id, title) VALUES (?, ?)", 1, "an example with `\"this (quoted) string\\'")
115
+ end
116
+
117
+ it "doesn't confuse bind parameters in replacements" do
118
+ conn.execute("REPLACE INTO posts_rt (id, title, body) VALUES (?, ?, ?)", 1, "question?", "I think not")
119
+ end
120
+
121
+ it "ignores bind markers inside strings" do
122
+ conn.execute("REPLACE INTO posts_rt (id, title, state, body) VALUES (?, 'question?', 'other?', ?)", 1, "I think not")
123
+ end
124
+
125
+ it "ignores bind markers inside comments" do
126
+ conn.execute <<-SQL, 1, "A string"
127
+ /* is this a comment? *//* another comment ? */
128
+ REPLACE INTO posts_rt (id, title) VALUES (?, ?)
129
+ SQL
130
+ end
131
+
132
+ it "handles nil" do
133
+ conn.execute("REPLACE INTO posts_rt (id, title, state, body) VALUES (?, 'question?', 'other?', ?)", 1, nil)
134
+ end
135
+
136
+ it "handles true" do
137
+ conn.execute("REPLACE INTO posts_rt (id, views) VALUES (?, ?)", 1, true)
138
+ end
139
+
140
+ it "handles false" do
141
+ conn.execute("REPLACE INTO posts_rt (id, views) VALUES (?, ?)", 1, false)
142
+ end
143
+
144
+ it "handles really long strings" do
145
+ conn.execute("REPLACE INTO posts_rt (id, title, state, body) VALUES (?, 'question?', 'other?', ?)", 1, 'a' * 2_000_000)
146
+ end
147
+ end
148
+
149
+ describe "#close" do
150
+ before(:each) { conn.close }
151
+
152
+ it "disposes the internal pool" do
153
+ conn.instance_variable_get(:@pool).should be_empty
154
+ end
155
+ end
156
+ end