syphon 0.0.1

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.
@@ -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