syphon 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,144 @@
1
+ require_relative '../test_helper'
2
+
3
+ describe Syphon::Index do
4
+ before do
5
+ Object.const_set(:TestIndex, Class.new)
6
+ TestIndex.send :include, Syphon::Index
7
+ end
8
+
9
+ after do
10
+ Object.send(:remove_const, :TestIndex)
11
+ end
12
+
13
+ describe ".index_name" do
14
+ describe "when no index namespace is set" do
15
+ use_attribute_value Syphon, :index_namespace, nil
16
+
17
+ it "it derived from the class name" do
18
+ TestIndex.index_name.must_equal 'tests'
19
+ end
20
+ end
21
+
22
+ describe "when the index namespace is empty" do
23
+ use_attribute_value Syphon, :index_namespace, ''
24
+
25
+ it "it is treated the same as nil" do
26
+ TestIndex.index_name.must_equal 'tests'
27
+ end
28
+ end
29
+
30
+ describe "when an index namespace is set" do
31
+ it "prefixes with the namespace and an underscore" do
32
+ TestIndex.index_name.must_equal 'syphon_tests'
33
+ end
34
+ end
35
+
36
+ it "can be overridden by the class" do
37
+ TestIndex.class_eval { self.index_name = 'wibble' }
38
+ TestIndex.index_name.must_equal 'wibble'
39
+ end
40
+ end
41
+
42
+ describe ".define_source" do
43
+ it "defaults the name and type" do
44
+ TestIndex.class_eval do
45
+ define_source
46
+ end
47
+
48
+ source = TestIndex.source
49
+ source.name.must_be_nil
50
+ source.type.must_equal :test
51
+ end
52
+
53
+ it "defines a source with the given name and fields" do
54
+ TestIndex.class_eval do
55
+ define_source :custom_name, type: :thing do
56
+ string :value, 'x'
57
+ end
58
+ end
59
+
60
+ source = TestIndex.source(:custom_name)
61
+ source.name.must_equal :custom_name
62
+ source.type.must_equal :thing
63
+ source.schema.fields.keys.must_equal [:value]
64
+ end
65
+ end
66
+
67
+ describe ".build" do
68
+ uses_users_table
69
+ uses_elasticsearch
70
+
71
+ before do
72
+ clear_indices
73
+
74
+ TestIndex.class_eval do
75
+ define_source do
76
+ string :login, "users.login"
77
+ from 'users'
78
+ end
79
+ end
80
+ end
81
+
82
+ it "builds the index (as an alias of an underlying index) if it does not yet exist" do
83
+ db.query "INSERT INTO users(login) VALUES('alice')"
84
+ TestIndex.build
85
+
86
+ hits = TestIndex.search['hits']['hits']
87
+ hits.map { |doc| doc['_source']['login'] }.must_equal ['alice']
88
+
89
+ db.query "DELETE FROM users"
90
+ db.query "INSERT INTO users(login) VALUES('bob')"
91
+
92
+ TestIndex.build
93
+
94
+ hits = TestIndex.search['hits']['hits']
95
+ hits.map { |doc| doc['_source']['login'] }.must_equal ['bob']
96
+ end
97
+
98
+ it "runs all warmups between building the new index and rotating it in" do
99
+ this = self
100
+ runs = []
101
+ TestIndex.class_eval do
102
+ define_warmup do |new_index|
103
+ client.indices.exists(index: new_index)
104
+ -> { client.indices.get_alias(name: TestIndex.index_name) }.
105
+ must_raise(Elasticsearch::Transport::Transport::Errors::NotFound)
106
+ runs << 1
107
+ end
108
+
109
+ define_warmup do |new_index|
110
+ runs << 2
111
+ end
112
+ end
113
+
114
+ TestIndex.build
115
+ runs.must_equal [1, 2]
116
+ end
117
+ end
118
+
119
+ describe ".destroy" do
120
+ uses_users_table
121
+ uses_elasticsearch
122
+
123
+ before do
124
+ TestIndex.class_eval do
125
+ define_source do
126
+ string :login, "users.login"
127
+ from 'users'
128
+ end
129
+ end
130
+ TestIndex.build
131
+ end
132
+
133
+ it "deletes the index and any aliases to it" do
134
+ client.indices.exists(index: TestIndex.index_name).must_equal true
135
+ client.indices.get_alias(name: TestIndex.index_name).size.must_equal 1
136
+
137
+ TestIndex.destroy
138
+
139
+ -> { client.indices.get_alias(name: TestIndex.index_name) }.
140
+ must_raise(Elasticsearch::Transport::Transport::Errors::NotFound)
141
+ client.indices.exists(index: TestIndex.index_name).must_equal false
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,271 @@
1
+ require_relative '../test_helper'
2
+
3
+ describe Syphon::Schema do
4
+ describe "#initialize" do
5
+ it "configures the schema with the given block" do
6
+ schema = Syphon::Schema.new do
7
+ string :s, 'STRING'
8
+ end
9
+ schema.fields.values.map(&:name).must_equal [:s]
10
+ end
11
+ end
12
+
13
+ describe "#configure" do
14
+ describe "type methods" do
15
+ it "builds fields with type methods" do
16
+ schema = Syphon::Schema.new do
17
+ string :string_field, 'STRING'
18
+ short :short_field, 'SHORT'
19
+ byte :byte_field, 'BYTE'
20
+ integer :integer_field, 'INTEGER'
21
+ long :long_field, 'LONG'
22
+ float :float_field, 'FLOAT'
23
+ double :double_field, 'DOUBLE'
24
+ date :date_field, 'DATE'
25
+ boolean :boolean_field, 'BOOLEAN'
26
+ binary :binary_field, 'BINARY'
27
+ geo_point :geo_point_field, 'GEO_POINT'
28
+ nested :nested_field do
29
+ string :string_field, 'NESTED.STRING'
30
+ end
31
+ end
32
+ schema.fields.values.map(&:name).must_equal [
33
+ :string_field, :short_field, :byte_field, :integer_field,
34
+ :long_field, :float_field, :double_field, :date_field,
35
+ :boolean_field, :binary_field, :geo_point_field, :nested_field,
36
+ ]
37
+ schema.fields.values.map(&:type).must_equal [
38
+ :string, :short, :byte, :integer, :long, :float, :double, :date,
39
+ :boolean, :binary, :geo_point, :nested,
40
+ ]
41
+ schema.fields.values.map(&:expression).must_equal [
42
+ 'STRING', 'SHORT', 'BYTE', 'INTEGER', 'LONG', 'FLOAT', 'DOUBLE',
43
+ 'DATE', 'BOOLEAN', 'BINARY', 'GEO_POINT', nil,
44
+ ]
45
+ nested = schema.fields[:nested_field]
46
+ nested.nested_schema.fields.values.map(&:name).must_equal [:string_field]
47
+ nested.nested_schema.fields.values.map(&:type).must_equal [:string]
48
+ nested.nested_schema.fields.values.map(&:expression).must_equal ['NESTED.STRING']
49
+ end
50
+
51
+ it "supports dynamic expressions by passing a proc for a field expression" do
52
+ i = nil
53
+ schema = Syphon::Schema.new do
54
+ string :string_field, -> { i.to_s }
55
+ end
56
+ i = 1
57
+ schema.fields.values.map { |v| v.expression.call }.must_equal ['1']
58
+ i = 2
59
+ schema.fields.values.map { |v| v.expression.call }.must_equal ['2']
60
+ end
61
+ end
62
+
63
+ describe "#from" do
64
+ it "sets the relation" do
65
+ schema = Syphon::Schema.new do
66
+ from 'things'
67
+ end
68
+ schema.relation.must_equal 'things'
69
+ end
70
+
71
+ it "supports a dynamic value by passing a block" do
72
+ i = nil
73
+ schema = Syphon::Schema.new do
74
+ from { i.to_s }
75
+ end
76
+ i = 1
77
+ schema.relation.call.must_equal '1'
78
+ i = 2
79
+ schema.relation.call.must_equal '2'
80
+ end
81
+ end
82
+
83
+ describe "#join" do
84
+ it "accumulates joins" do
85
+ schema = Syphon::Schema.new do
86
+ from 'things'
87
+ join 'INNER JOIN a ON 1'
88
+ join 'INNER JOIN b ON 2'
89
+ end
90
+ schema.joins.must_equal ['INNER JOIN a ON 1', 'INNER JOIN b ON 2']
91
+ end
92
+
93
+ it "supports a dynamic value by passing a block" do
94
+ i = j = nil
95
+ schema = Syphon::Schema.new do
96
+ from 'things'
97
+ join { i.to_s }
98
+ join { j.to_s }
99
+ end
100
+ i, j = 1, 2
101
+ schema.joins.map(&:call).must_equal ['1', '2']
102
+ i, j = 3, 4
103
+ schema.joins.map(&:call).must_equal ['3', '4']
104
+ end
105
+ end
106
+
107
+ describe "#where" do
108
+ it "sets the conditions" do
109
+ schema = Syphon::Schema.new do
110
+ where 'x = 1'
111
+ end
112
+ schema.conditions.must_equal 'x = 1'
113
+ end
114
+
115
+ it "supports a dynamic value by passing a block" do
116
+ i = nil
117
+ schema = Syphon::Schema.new do
118
+ where { i.to_s }
119
+ end
120
+ i = 1
121
+ schema.conditions.call.must_equal '1'
122
+ i = 2
123
+ schema.conditions.call.must_equal '2'
124
+ end
125
+ end
126
+
127
+ describe "#group_by" do
128
+ it "sets the group clause" do
129
+ schema = Syphon::Schema.new do
130
+ group_by 'x, y'
131
+ end
132
+ schema.group_clause.must_equal 'x, y'
133
+ end
134
+
135
+ it "supports a dynamic value by passing a block" do
136
+ i = nil
137
+ schema = Syphon::Schema.new do
138
+ group_by { i.to_s }
139
+ end
140
+ i = 1
141
+ schema.group_clause.call.must_equal '1'
142
+ i = 2
143
+ schema.group_clause.call.must_equal '2'
144
+ end
145
+ end
146
+
147
+ describe "#having" do
148
+ it "sets the having clause" do
149
+ schema = Syphon::Schema.new do
150
+ having 'x = 1'
151
+ end
152
+ schema.having_clause.must_equal 'x = 1'
153
+ end
154
+
155
+ it "supports a dynamic value by passing a block" do
156
+ i = nil
157
+ schema = Syphon::Schema.new do
158
+ having { i.to_s }
159
+ end
160
+ i = 1
161
+ schema.having_clause.call.must_equal '1'
162
+ i = 2
163
+ schema.having_clause.call.must_equal '2'
164
+ end
165
+ end
166
+ end
167
+
168
+ describe "#query" do
169
+ it "returns the complete query" do
170
+ schema = Syphon::Schema.new do
171
+ string :s, 'S'
172
+ nested :inner do
173
+ string :t, 'T'
174
+ end
175
+ from 'things'
176
+ join 'INNER JOIN a ON 1'
177
+ join 'INNER JOIN b ON 2'
178
+ where 'a = 1'
179
+ group_by 'x'
180
+ having 'count(*) = 1'
181
+ end
182
+ schema.query.must_equal <<-EOS.strip.gsub(/\s+/, ' ')
183
+ SELECT S AS `s`, T AS `inner[t]`
184
+ FROM things
185
+ INNER JOIN a ON 1
186
+ INNER JOIN b ON 2
187
+ WHERE a = 1
188
+ GROUP BY x
189
+ HAVING count(*) = 1
190
+ EOS
191
+ end
192
+
193
+ it "omits optional clauses when in the minimal case" do
194
+ schema = Syphon::Schema.new do
195
+ string :s, 'S'
196
+ from 'things'
197
+ end
198
+ schema.query.must_equal "SELECT S AS `s` FROM things"
199
+ end
200
+
201
+ it "supports overriding the select expression" do
202
+ schema = Syphon::Schema.new do
203
+ string :s, 'S'
204
+ from 'things'
205
+ end
206
+ schema.query(select: '1').must_equal "SELECT 1 FROM things"
207
+ end
208
+
209
+ it "applies the given extra scope, order, and limit" do
210
+ schema = Syphon::Schema.new do
211
+ string :s, 'S'
212
+ from 'things'
213
+ where 'a = 1'
214
+ group_by 'x'
215
+ having 'count(*) = 1'
216
+ end
217
+ options = {scope: 'b = 2', order: 'c DESC', limit: 2}
218
+ schema.query(options).must_equal <<-EOS.strip.gsub(/\s+/, ' ')
219
+ SELECT S AS `s`
220
+ FROM things
221
+ WHERE (a = 1) AND (b = 2)
222
+ GROUP BY x
223
+ HAVING count(*) = 1
224
+ ORDER BY c DESC
225
+ LIMIT 2
226
+ EOS
227
+ end
228
+
229
+ it "inverts the condition if :invert is true" do
230
+ schema = Syphon::Schema.new do
231
+ string :s, 'S'
232
+ from 'things'
233
+ where 'a = 1'
234
+ end
235
+ schema.query(invert: true).must_equal "SELECT S AS `s` FROM things WHERE NOT (a = 1)"
236
+ end
237
+
238
+ it "does not invert the scope" do
239
+ schema = Syphon::Schema.new do
240
+ string :s, 'S'
241
+ from 'things'
242
+ where 'a = 1'
243
+ end
244
+ schema.query(invert: true, scope: 'id = 1').must_equal "SELECT S AS `s` FROM things WHERE (NOT (a = 1)) AND (id = 1)"
245
+ end
246
+ end
247
+
248
+ describe "#properties" do
249
+ it "returns the properties hash for the fields" do
250
+ schema = Syphon::Schema.new do
251
+ string :name, 'x'
252
+ integer :value, 'x'
253
+ nested :inner do
254
+ string :s, 'x'
255
+ end
256
+ end
257
+
258
+ schema.properties.must_equal({
259
+ name: {type: :string},
260
+ value: {type: :integer},
261
+ inner: {
262
+ type: :nested,
263
+ properties: {
264
+ s: {type: :string},
265
+ },
266
+ },
267
+ })
268
+ end
269
+ end
270
+ end
271
+
@@ -0,0 +1,141 @@
1
+ require_relative '../test_helper'
2
+
3
+ describe Syphon::Source do
4
+ before do
5
+ Object.const_set(:TestIndex, Class.new)
6
+ TestIndex.send :include, Syphon::Index
7
+ end
8
+
9
+ after do
10
+ Object.send(:remove_const, :TestIndex)
11
+ end
12
+
13
+ let(:source) { TestIndex.source }
14
+
15
+ describe "#initialize" do
16
+ it "sets a default type" do
17
+ source = Syphon::Source.new(TestIndex, :things_source)
18
+ source.name.must_equal :things_source
19
+ source.type.must_equal :test
20
+ end
21
+
22
+ it "sets the name and type" do
23
+ source = Syphon::Source.new(TestIndex, :things_source, type: :custom_type)
24
+ source.name.must_equal :things_source
25
+ source.type.must_equal :custom_type
26
+ end
27
+
28
+ it "initializes the schema from the given block" do
29
+ source = Syphon::Source.new(TestIndex, :things_source) do
30
+ string :name, 'x'
31
+ end
32
+ source.schema.fields.keys.must_equal [:name]
33
+ end
34
+ end
35
+
36
+ describe "#mapping" do
37
+ it "returns the ElasticSearch mapping" do
38
+ source = Syphon::Source.new(TestIndex, :things_source, type: :thing) do
39
+ string :name, 'x'
40
+ end
41
+
42
+ source.mapping.must_equal({
43
+ thing: {
44
+ properties: {
45
+ name: {
46
+ type: :string
47
+ },
48
+ },
49
+ },
50
+ })
51
+ end
52
+ end
53
+
54
+ describe "#import" do
55
+ uses_users_table
56
+ uses_elasticsearch
57
+
58
+ before do
59
+ TestIndex.class_eval do
60
+ define_source do
61
+ integer :id, 'id'
62
+ string :login, 'login', stored: true
63
+ from 'users'
64
+ end
65
+ end
66
+ TestIndex.build(schema_only: true)
67
+ end
68
+
69
+ it "imports the data as configured by the SQL query" do
70
+ db.query "INSERT INTO users(login) VALUES('alice')"
71
+ source.import
72
+
73
+ hits = client.search(index: TestIndex.index_name)['hits']['hits']
74
+ hits.map { |doc| doc['_source']['login'] }.must_equal ['alice']
75
+ end
76
+
77
+ it "imports data correctly the second time" do
78
+ db.query "INSERT INTO users(login) VALUES('alice')"
79
+ source.import
80
+
81
+ db.query "INSERT INTO users(login) VALUES('bob')"
82
+ source.import
83
+
84
+ hits = client.search(index: TestIndex.index_name)['hits']['hits']
85
+ hits.map { |doc| doc['_source']['login'] }.sort.must_equal ['alice', 'bob']
86
+ end
87
+
88
+ it "uses the given index name" do
89
+ client.indices.create(index: 'syphon_custom')
90
+ db.query "INSERT INTO users(login) VALUES('alice')"
91
+ source.import(index: 'syphon_custom')
92
+
93
+ hits = client.search(index: 'syphon_custom')['hits']['hits']
94
+ hits.map { |doc| doc['_source']['login'] }.must_equal ['alice']
95
+ end
96
+ end
97
+
98
+ describe "#update" do
99
+ uses_users_table
100
+ uses_elasticsearch
101
+
102
+ before do
103
+ TestIndex.class_eval do
104
+ define_source do
105
+ integer :id, 'id'
106
+ string :login, 'login', stored: true
107
+ from 'users'
108
+ end
109
+ end
110
+ TestIndex.build(schema_only: true)
111
+ end
112
+
113
+ it "updates the index from the database, scoping with the given conditions" do
114
+ db.query "INSERT INTO users(login) VALUES('alice'), ('bob')"
115
+ TestIndex.build
116
+
117
+ hits = client.search(index: TestIndex.index_name)['hits']['hits']
118
+ hits.map { |doc| doc['_source']['login'] }.sort.must_equal ['alice', 'bob']
119
+
120
+ db.query "UPDATE users SET login = 'superalice' WHERE login = 'alice'"
121
+ db.query "UPDATE users SET login = 'wonderbob' WHERE login = 'bob'"
122
+ bob_id = db.query("SELECT id FROM users WHERE login = 'wonderbob'", as: :array).to_a[0][0]
123
+ source.update_ids([bob_id])
124
+
125
+ hits = client.search(index: TestIndex.index_name)['hits']['hits']
126
+ hits.map { |doc| doc['_source']['login'] }.sort.must_equal ['alice', 'wonderbob']
127
+ end
128
+
129
+ it "deletes records that should no longer exist" do
130
+ db.query "INSERT INTO users(login) VALUES('alice'), ('bob')"
131
+ TestIndex.build
132
+
133
+ bob_id = db.query("SELECT id FROM users WHERE login = 'bob'", as: :array).to_a[0][0]
134
+ db.query "DELETE FROM users WHERE id = #{bob_id}"
135
+ source.update_ids([bob_id])
136
+
137
+ hits = client.search(index: TestIndex.index_name)['hits']['hits']
138
+ hits.map { |doc| doc['_source']['login'] }.must_equal ['alice']
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,42 @@
1
+ ROOT = File.expand_path('..', File.dirname(__FILE__))
2
+
3
+ $:.unshift "#{ROOT}/lib"
4
+ require 'minitest/spec'
5
+ require 'yaml'
6
+ require 'temporaries'
7
+ require 'debugger' if RUBY_VERSION < '2.0'
8
+ require 'looksee'
9
+
10
+ require 'syphon'
11
+
12
+ config = YAML.load_file("#{ROOT}/test/config.yml")
13
+ Syphon.database_configuration = config['database']
14
+ Syphon.configuration = config['elasticsearch']
15
+ Syphon.index_namespace = 'syphon'
16
+
17
+ MiniTest::Spec.class_eval do
18
+ def self.uses_users_table
19
+ let(:db) { Syphon.database_connection }
20
+
21
+ before do
22
+ columns = "id int auto_increment PRIMARY KEY, login VARCHAR(20)"
23
+ db.query "CREATE TABLE IF NOT EXISTS users(#{columns})"
24
+ end
25
+
26
+ after do
27
+ db.query "DROP TABLE IF EXISTS users"
28
+ end
29
+ end
30
+
31
+ def self.uses_elasticsearch
32
+ let(:client) { Syphon.client }
33
+
34
+ before { clear_indices }
35
+ after { clear_indices }
36
+ end
37
+
38
+ def clear_indices
39
+ client.indices.status['indices'].keys.grep(/\Asyphon_/).
40
+ each { |name| client.indices.delete(index: name) }
41
+ end
42
+ end
@@ -0,0 +1,28 @@
1
+ require_relative 'test_helper'
2
+
3
+ describe Syphon do
4
+ describe ".configuration" do
5
+ use_attribute_value Syphon, :configuration, nil
6
+
7
+ it "defaults to an empty hash" do
8
+ Syphon.configuration.must_equal({})
9
+ end
10
+ end
11
+
12
+ describe ".database_configuration" do
13
+ use_instance_variable_value Syphon, :database_configuration, nil
14
+
15
+ it "defaults to an empty hash" do
16
+ Syphon.database_configuration.must_equal({})
17
+ end
18
+ end
19
+
20
+ describe ".index_namespace" do
21
+ use_instance_variable_value Syphon, :index_namespace, nil
22
+ use_instance_variable_value Syphon, :configuration, {index_namespace: 'NAMESPACE'}
23
+
24
+ it "defaults to the configured index namespace" do
25
+ Syphon.index_namespace.must_equal('NAMESPACE')
26
+ end
27
+ end
28
+ end