dbldots_oedipus 0.0.16

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 (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