oplogjam 0.1.0

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