oplogjam 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,103 @@
1
+ require 'bson'
2
+ require 'oplogjam'
3
+
4
+ module Oplogjam
5
+ RSpec.describe Command do
6
+ describe '.from' do
7
+ it 'converts a BSON command into a Command' do
8
+ bson = BSON::Document.new(
9
+ ts: BSON::Timestamp.new(1_479_420_028, 1),
10
+ t: 1,
11
+ h: -1_789_557_309_812_000_233,
12
+ v: 2,
13
+ op: 'c',
14
+ ns: 'foo.$cmd',
15
+ o: BSON::Document.new(create: 'bar')
16
+ )
17
+
18
+ expect(described_class.from(bson)).to be_a(described_class)
19
+ end
20
+
21
+ it 'raises an error if the command is missing' do
22
+ bson = BSON::Document.new(
23
+ ts: BSON::Timestamp.new(1_479_420_028, 1),
24
+ t: 1,
25
+ h: -1_789_557_309_812_000_233,
26
+ v: 2,
27
+ op: 'c',
28
+ ns: 'foo.$cmd'
29
+ )
30
+
31
+ expect { described_class.from(bson) }.to raise_error(InvalidCommand)
32
+ end
33
+ end
34
+
35
+ describe '#id' do
36
+ it 'returns a unique identifier for the command' do
37
+ bson = BSON::Document.new(
38
+ ts: BSON::Timestamp.new(1_479_420_028, 1),
39
+ t: 1,
40
+ h: -1_789_557_309_812_000_233,
41
+ v: 2,
42
+ op: 'c',
43
+ ns: 'foo.$cmd',
44
+ o: BSON::Document.new(create: 'bar')
45
+ )
46
+ command = described_class.from(bson)
47
+
48
+ expect(command.id).to eq(-1_789_557_309_812_000_233)
49
+ end
50
+ end
51
+
52
+ describe '#namespace' do
53
+ it 'returns the namespace' do
54
+ bson = BSON::Document.new(
55
+ ts: BSON::Timestamp.new(1_479_420_028, 1),
56
+ t: 1,
57
+ h: -1_789_557_309_812_000_233,
58
+ v: 2,
59
+ op: 'c',
60
+ ns: 'foo.$cmd',
61
+ o: BSON::Document.new(create: 'bar')
62
+ )
63
+ command = described_class.from(bson)
64
+
65
+ expect(command.namespace).to eq('foo.$cmd')
66
+ end
67
+ end
68
+
69
+ describe '#timestamp' do
70
+ it 'returns the timestamp as a Time' do
71
+ bson = BSON::Document.new(
72
+ ts: BSON::Timestamp.new(1_479_420_028, 1),
73
+ t: 1,
74
+ h: -1_789_557_309_812_000_233,
75
+ v: 2,
76
+ op: 'c',
77
+ ns: 'foo.$cmd',
78
+ o: BSON::Document.new(create: 'bar')
79
+ )
80
+ command = described_class.from(bson)
81
+
82
+ expect(command.timestamp).to eq(Time.at(1_479_420_028, 1))
83
+ end
84
+ end
85
+
86
+ describe '#command' do
87
+ it 'returns the command' do
88
+ bson = BSON::Document.new(
89
+ ts: BSON::Timestamp.new(1_479_420_028, 1),
90
+ t: 1,
91
+ h: -1_789_557_309_812_000_233,
92
+ v: 2,
93
+ op: 'c',
94
+ ns: 'foo.$cmd',
95
+ o: BSON::Document.new(create: 'bar')
96
+ )
97
+ command = described_class.from(bson)
98
+
99
+ expect(command.command).to eq(BSON::Document.new(create: 'bar'))
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,163 @@
1
+ require 'bson'
2
+ require 'oplogjam'
3
+
4
+ module Oplogjam
5
+ RSpec.describe Delete do
6
+ let(:postgres) { Sequel.connect('postgres:///oplogjam_test') }
7
+ let(:schema) { Schema.new(postgres) }
8
+ let(:table) { postgres.from(:bar) }
9
+
10
+ before(:example, :database) do
11
+ postgres.extension :pg_json
12
+ schema.create_table(:bar)
13
+ schema.add_indexes(:bar)
14
+ end
15
+
16
+ after(:example, :database) do
17
+ table.truncate
18
+ end
19
+
20
+ describe '.from' do
21
+ it 'converts a BSON delete into a Delete' do
22
+ bson = BSON::Document.new(
23
+ ts: BSON::Timestamp.new(1_479_421_186, 1),
24
+ t: 1,
25
+ h: -5_457_382_347_563_537_847,
26
+ v: 2,
27
+ op: 'd',
28
+ ns: 'foo.bar',
29
+ o: BSON::Document.new(_id: BSON::ObjectId('582e287cfedf6fb051b2efdf'))
30
+ )
31
+
32
+ expect(described_class.from(bson)).to be_a(described_class)
33
+ end
34
+
35
+ it 'raises an error if the query is missing' do
36
+ bson = BSON::Document.new(
37
+ ts: BSON::Timestamp.new(1_479_421_186, 1),
38
+ t: 1,
39
+ h: -5_457_382_347_563_537_847,
40
+ v: 2,
41
+ op: 'd',
42
+ ns: 'foo.bar'
43
+ )
44
+
45
+ expect { described_class.from(bson) }.to raise_error(InvalidDelete)
46
+ end
47
+ end
48
+
49
+ describe '#id' do
50
+ it 'returns a unique identifier for the delete' do
51
+ bson = BSON::Document.new(
52
+ ts: BSON::Timestamp.new(1_479_421_186, 1),
53
+ t: 1,
54
+ h: -5_457_382_347_563_537_847,
55
+ v: 2,
56
+ op: 'd',
57
+ ns: 'foo.bar',
58
+ o: BSON::Document.new(_id: BSON::ObjectId('582e287cfedf6fb051b2efdf'))
59
+ )
60
+ delete = described_class.from(bson)
61
+
62
+ expect(delete.id).to eq(-5_457_382_347_563_537_847)
63
+ end
64
+ end
65
+
66
+ describe '#timestamp' do
67
+ it 'returns the timestamp as a Time' do
68
+ bson = BSON::Document.new(
69
+ ts: BSON::Timestamp.new(1_479_421_186, 1),
70
+ t: 1,
71
+ h: -5_457_382_347_563_537_847,
72
+ v: 2,
73
+ op: 'd',
74
+ ns: 'foo.bar',
75
+ o: BSON::Document.new(_id: BSON::ObjectId('582e287cfedf6fb051b2efdf'))
76
+ )
77
+ delete = described_class.from(bson)
78
+
79
+ expect(delete.timestamp).to eq(Time.at(1_479_421_186, 1))
80
+ end
81
+ end
82
+
83
+ describe '#namespace' do
84
+ it 'returns the namespace' do
85
+ bson = BSON::Document.new(
86
+ ts: BSON::Timestamp.new(1_479_421_186, 1),
87
+ t: 1,
88
+ h: -5_457_382_347_563_537_847,
89
+ v: 2,
90
+ op: 'd',
91
+ ns: 'foo.bar',
92
+ o: BSON::Document.new(_id: BSON::ObjectId('582e287cfedf6fb051b2efdf'))
93
+ )
94
+ delete = described_class.from(bson)
95
+
96
+ expect(delete.namespace).to eq('foo.bar')
97
+ end
98
+ end
99
+
100
+ describe '#query' do
101
+ it 'returns the query' do
102
+ bson = BSON::Document.new(
103
+ ts: BSON::Timestamp.new(1_479_421_186, 1),
104
+ t: 1,
105
+ h: -5_457_382_347_563_537_847,
106
+ v: 2,
107
+ op: 'd',
108
+ ns: 'foo.bar',
109
+ o: BSON::Document.new(_id: BSON::ObjectId('582e287cfedf6fb051b2efdf'))
110
+ )
111
+ delete = described_class.from(bson)
112
+
113
+ expect(delete.query).to eq(BSON::Document.new(_id: BSON::ObjectId('582e287cfedf6fb051b2efdf')))
114
+ end
115
+ end
116
+
117
+ describe '#apply', :database do
118
+ it 'sets deleted_at against the row' do
119
+ table.insert(id: '1', document: '{}', created_at: Time.now.utc, updated_at: Time.now.utc)
120
+ delete = build_delete(1)
121
+
122
+ expect { delete.apply('foo.bar' => table) }.to change { table.exclude(deleted_at: nil).count }.by(1)
123
+ end
124
+
125
+ it 'sets updated_at against the row' do
126
+ Timecop.freeze(Time.new(2001, 1, 1, 0, 0, 0)) do
127
+ table.insert(id: '1', document: '{}', created_at: Time.now.utc, updated_at: Time.now.utc)
128
+ delete = build_delete(1)
129
+ delete.apply('foo.bar' => table)
130
+
131
+ expect(table.first).to include(updated_at: Time.new(2001, 1, 1, 0, 0, 0))
132
+ end
133
+ end
134
+
135
+ it 'ignores deletes for rows that do not exist' do
136
+ delete = build_delete(999)
137
+
138
+ expect { delete.apply('foo.bar' => table) }.not_to change { table.count }
139
+ end
140
+
141
+ it 'ignores deletes for unmapped tables' do
142
+ table.insert(id: '1', document: '{}', created_at: Time.now.utc, updated_at: Time.now.utc)
143
+ delete = build_delete(1)
144
+
145
+ expect { delete.apply('foo.baz' => table) }.not_to change { table.count }
146
+ end
147
+ end
148
+
149
+ def build_delete(id = BSON::ObjectId('582e287cfedf6fb051b2efdf'))
150
+ bson = BSON::Document.new(
151
+ ts: BSON::Timestamp.new(1_479_421_186, 1),
152
+ t: 1,
153
+ h: -5_457_382_347_563_537_847,
154
+ v: 2,
155
+ op: 'd',
156
+ ns: 'foo.bar',
157
+ o: BSON::Document.new(_id: id)
158
+ )
159
+
160
+ described_class.from(bson)
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,289 @@
1
+ require 'oplogjam'
2
+
3
+ module Oplogjam
4
+ RSpec.describe Insert do
5
+ let(:postgres) { Sequel.connect('postgres:///oplogjam_test') }
6
+ let(:schema) { Schema.new(postgres) }
7
+ let(:table) { postgres.from(:bar) }
8
+
9
+ before(:example, :database) do
10
+ postgres.extension :pg_json
11
+ schema.create_table(:bar)
12
+ schema.add_indexes(:bar)
13
+ end
14
+
15
+ after(:example, :database) do
16
+ table.truncate
17
+ end
18
+
19
+ describe '.from' do
20
+ it 'converts a BSON insert into an Insert' do
21
+ bson = BSON::Document.new(
22
+ ts: BSON::Timestamp.new(1_496_414_570, 11),
23
+ t: 14,
24
+ h: -3_028_027_288_268_436_781,
25
+ v: 2,
26
+ op: 'i',
27
+ ns: 'foo.bar',
28
+ o: BSON::Document.new(
29
+ _id: BSON::ObjectId('593bac55da605b0dbf3b25a5'),
30
+ baz: 'quux'
31
+ )
32
+ )
33
+
34
+ expect(described_class.from(bson)).to be_a(described_class)
35
+ end
36
+
37
+ it 'raises an error if the document is missing' do
38
+ bson = BSON::Document.new(
39
+ ts: BSON::Timestamp.new(1_496_414_570, 11),
40
+ t: 14,
41
+ h: -3_028_027_288_268_436_781,
42
+ v: 2,
43
+ op: 'i',
44
+ ns: 'foo.bar'
45
+ )
46
+
47
+ expect { described_class.from(bson) }.to raise_error(InvalidInsert)
48
+ end
49
+ end
50
+
51
+ describe '#namespace' do
52
+ it 'returns the namespace' do
53
+ bson = BSON::Document.new(
54
+ ts: BSON::Timestamp.new(1_496_414_570, 11),
55
+ t: 14,
56
+ h: -3_028_027_288_268_436_781,
57
+ v: 2,
58
+ op: 'i',
59
+ ns: 'foo.bar',
60
+ o: BSON::Document.new(
61
+ _id: BSON::ObjectId('593bac55da605b0dbf3b25a5'),
62
+ baz: 'quux'
63
+ )
64
+ )
65
+ insert = described_class.from(bson)
66
+
67
+ expect(insert.namespace).to eq('foo.bar')
68
+ end
69
+ end
70
+
71
+ describe '#id' do
72
+ it 'returns a unique identifier for the insert' do
73
+ bson = BSON::Document.new(
74
+ ts: BSON::Timestamp.new(1_496_414_570, 11),
75
+ t: 14,
76
+ h: -3_028_027_288_268_436_781,
77
+ v: 2,
78
+ op: 'i',
79
+ ns: 'foo.bar',
80
+ o: BSON::Document.new(
81
+ _id: BSON::ObjectId('593bac55da605b0dbf3b25a5'),
82
+ baz: 'quux'
83
+ )
84
+ )
85
+ insert = described_class.from(bson)
86
+
87
+ expect(insert.id).to eq(-3_028_027_288_268_436_781)
88
+ end
89
+ end
90
+
91
+ describe '#document' do
92
+ it 'returns the document being inserted' do
93
+ bson = BSON::Document.new(
94
+ ts: BSON::Timestamp.new(1_496_414_570, 11),
95
+ t: 14,
96
+ h: -3_028_027_288_268_436_781,
97
+ v: 2,
98
+ op: 'i',
99
+ ns: 'foo.bar',
100
+ o: BSON::Document.new(
101
+ _id: BSON::ObjectId('593bac55da605b0dbf3b25a5'),
102
+ baz: 'quux'
103
+ )
104
+ )
105
+ insert = described_class.from(bson)
106
+
107
+ expect(insert.document).to eq(
108
+ BSON::Document.new(
109
+ _id: BSON::ObjectId('593bac55da605b0dbf3b25a5'),
110
+ baz: 'quux'
111
+ )
112
+ )
113
+ end
114
+ end
115
+
116
+ describe '#timestamp' do
117
+ it 'returns the time of the operation as a Time' do
118
+ bson = BSON::Document.new(
119
+ ts: BSON::Timestamp.new(1_496_414_570, 11),
120
+ t: 14,
121
+ h: -3_028_027_288_268_436_781,
122
+ v: 2,
123
+ op: 'i',
124
+ ns: 'foo.bar',
125
+ o: BSON::Document.new(
126
+ _id: BSON::ObjectId('593bac55da605b0dbf3b25a5'),
127
+ baz: 'quux'
128
+ )
129
+ )
130
+ insert = described_class.from(bson)
131
+
132
+ expect(insert.timestamp).to eq(Time.at(1_496_414_570, 11))
133
+ end
134
+ end
135
+
136
+ describe '#ts' do
137
+ it 'returns the raw underlying BSON timestamp' do
138
+ bson = BSON::Document.new(
139
+ ts: BSON::Timestamp.new(1_496_414_570, 11),
140
+ t: 14,
141
+ h: -3_028_027_288_268_436_781,
142
+ v: 2,
143
+ op: 'i',
144
+ ns: 'foo.bar',
145
+ o: BSON::Document.new(
146
+ _id: BSON::ObjectId('593bac55da605b0dbf3b25a5'),
147
+ baz: 'quux'
148
+ )
149
+ )
150
+ insert = described_class.from(bson)
151
+
152
+ expect(insert.ts).to eq(BSON::Timestamp.new(1_496_414_570, 11))
153
+ end
154
+ end
155
+
156
+ describe '#==' do
157
+ it 'is equal to another operation with the same ID' do
158
+ bson = BSON::Document.new(
159
+ ts: BSON::Timestamp.new(1_496_414_570, 11),
160
+ t: 14,
161
+ h: -3_028_027_288_268_436_781,
162
+ v: 2,
163
+ op: 'i',
164
+ ns: 'foo.bar',
165
+ o: BSON::Document.new(
166
+ _id: BSON::ObjectId('593bac55da605b0dbf3b25a5'),
167
+ baz: 'quux'
168
+ )
169
+ )
170
+ another_bson = BSON::Document.new(
171
+ ts: BSON::Timestamp.new(1_496_414_570, 11),
172
+ t: 14,
173
+ h: -3_028_027_288_268_436_781,
174
+ v: 2,
175
+ op: 'i',
176
+ ns: 'foo.bar',
177
+ o: BSON::Document.new(
178
+ _id: BSON::ObjectId('593bac55da605b0dbf3b25a5'),
179
+ baz: 'quux'
180
+ )
181
+ )
182
+ insert = described_class.from(bson)
183
+ another_insert = described_class.from(another_bson)
184
+
185
+ expect(insert).to eq(another_insert)
186
+ end
187
+ end
188
+
189
+ describe '#apply', :database do
190
+ it 'inserts a document as JSONB into the corresponding table' do
191
+ insert = build_insert
192
+
193
+ expect { insert.apply('foo.bar' => table) }.to change { table.count }.by(1)
194
+ end
195
+
196
+ it 'extracts the ID of the document' do
197
+ insert = build_insert(_id: 1)
198
+ insert.apply('foo.bar' => table)
199
+
200
+ expect(table.first).to include(id: 1)
201
+ end
202
+
203
+ it 'can store IDs of different types in the same table' do
204
+ insert1 = build_insert(_id: 1)
205
+ insert2 = build_insert(_id: '1')
206
+
207
+ expect {
208
+ insert1.apply('foo.bar' => table)
209
+ insert2.apply('foo.bar' => table)
210
+ }.to change { table.count }.by(2)
211
+ end
212
+
213
+ it 'stores the original document as JSONB' do
214
+ insert = build_insert(_id: 1, baz: 'quux')
215
+ insert.apply('foo.bar' => table)
216
+
217
+ expect(table.get(Sequel.pg_jsonb_op(:document).get_text('baz'))).to eq('quux')
218
+ end
219
+
220
+ it 'stores BSON Object IDs as equivalent JSON objects' do
221
+ insert = build_insert(_id: BSON::ObjectId.from_string('59b3fbb4c97ba6c8a58a2d0c'))
222
+ insert.apply('foo.bar' => table)
223
+
224
+ expect(table.get(:id)).to eq('$oid' => '59b3fbb4c97ba6c8a58a2d0c')
225
+ end
226
+
227
+ it 'can reuse IDs if a deleted record exists' do
228
+ insert = build_insert(_id: 1)
229
+ insert.apply('foo.bar' => table)
230
+ table.where(id: '1').update(deleted_at: Time.now.utc)
231
+
232
+ expect { insert.apply('foo.bar' => table) }.to change { table.count }.by(1)
233
+ end
234
+
235
+ it 'ignores inserts to unmapped tables' do
236
+ insert = build_insert(_id: 1)
237
+
238
+ expect { insert.apply('foo.baz' => table) }.not_to change { table.count }
239
+ end
240
+
241
+ it 'strips null bytes from the document' do
242
+ insert = build_insert(_id: 1, baz: "quux\x00")
243
+ insert.apply('foo.bar' => table)
244
+
245
+ expect(table.get(Sequel.pg_jsonb_op(:document).get_text('baz'))).to eq('quux')
246
+ end
247
+
248
+ it 'updates any existing, non-deleted records with the same ID' do
249
+ table.insert(id: '1', document: '{}', created_at: Time.now.utc, updated_at: Time.now.utc)
250
+ insert = build_insert(_id: 1, baz: 'quux')
251
+ insert.apply('foo.bar' => table)
252
+
253
+ expect(table.get(Sequel.pg_jsonb_op(:document).get_text('baz'))).to eq('quux')
254
+ end
255
+
256
+ it 'updates updated_at for existing records' do
257
+ Timecop.freeze(Time.new(2001, 1, 1, 0, 0, 0)) do
258
+ table.insert(id: '1', document: '{}', created_at: Time.now.utc, updated_at: Time.now.utc)
259
+ insert = build_insert(_id: 1, baz: 'quux')
260
+ insert.apply('foo.bar' => table)
261
+
262
+ expect(table.first).to include(updated_at: Time.new(2001, 1, 1, 0, 0, 0))
263
+ end
264
+ end
265
+
266
+ it 'only updates non-deleted records with the same ID' do
267
+ table.insert(id: '1', document: '{"a":1}', created_at: Time.now.utc, updated_at: Time.now.utc, deleted_at: Time.now.utc)
268
+ insert = build_insert(_id: 1, a: 2)
269
+ insert.apply('foo.bar' => table)
270
+
271
+ expect(table.where(id: '1').exclude(deleted_at: nil).get(Sequel.pg_jsonb_op(:document).get_text('a'))).to eq('1')
272
+ end
273
+ end
274
+
275
+ def build_insert(attributes = { _id: 1, baz: 'quux' })
276
+ bson = BSON::Document.new(
277
+ ts: BSON::Timestamp.new(1_496_414_570, 11),
278
+ t: 14,
279
+ h: -3_028_027_288_268_436_781,
280
+ v: 2,
281
+ op: 'i',
282
+ ns: 'foo.bar',
283
+ o: BSON::Document.new(attributes)
284
+ )
285
+
286
+ described_class.from(bson)
287
+ end
288
+ end
289
+ end