deimos-ruby 1.20.1 → 1.22.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.
@@ -7,7 +7,7 @@ require_relative '../tracing/mock'
7
7
  require 'active_support/core_ext/numeric'
8
8
 
9
9
  # :nodoc:
10
- module Deimos
10
+ module Deimos # rubocop:disable Metrics/ModuleLength
11
11
  include FigTree
12
12
 
13
13
  # :nodoc:
@@ -87,6 +87,12 @@ module Deimos
87
87
  namespace(kafka_config.namespace) if kafka_config.namespace.present?
88
88
  key_config(**kafka_config.key_config) if kafka_config.key_config.present?
89
89
  schema_class_config(kafka_config.use_schema_classes) if kafka_config.use_schema_classes.present?
90
+ if kafka_config.respond_to?(:bulk_import_id_column) # consumer
91
+ klass.config.merge!(
92
+ bulk_import_id_column: kafka_config.bulk_import_id_column,
93
+ replace_associations: kafka_config.replace_associations
94
+ )
95
+ end
90
96
  end
91
97
  end
92
98
 
@@ -402,6 +408,10 @@ module Deimos
402
408
  # Configure the usage of generated schema classes for this producer
403
409
  # @return [Boolean]
404
410
  setting :use_schema_classes
411
+ # If true, and using the multi-table feature of ActiveRecordConsumers, replace associations
412
+ # instead of appending to them.
413
+ # @return [Boolean]
414
+ setting :replace_associations
405
415
  end
406
416
 
407
417
  setting_object :consumer do
@@ -430,6 +440,12 @@ module Deimos
430
440
  # Optional maximum limit for batching database calls to reduce the load on the db.
431
441
  # @return [Integer]
432
442
  setting :max_db_batch_size
443
+ # Column to use for bulk imports, for multi-table feature.
444
+ # @return [String]
445
+ setting :bulk_import_id_column, :bulk_import_id
446
+ # If true, multi-table consumers will blow away associations rather than appending to them.
447
+ # @return [Boolean]
448
+ setting :replace_associations, true
433
449
 
434
450
  # These are the phobos "listener" configs. See CONFIGURATION.md for more
435
451
  # info.
@@ -67,7 +67,8 @@ module Deimos
67
67
  next nil if consumer.disabled
68
68
 
69
69
  hash = consumer.to_h.reject do |k, _|
70
- %i(class_name schema namespace key_config backoff disabled).include?(k)
70
+ %i(class_name schema namespace key_config backoff disabled replace_associations
71
+ bulk_import_id_column).include?(k)
71
72
  end
72
73
  hash = hash.map { |k, v| [k, v.is_a?(Symbol) ? v.to_s : v] }.to_h
73
74
  hash[:handler] = consumer.class_name
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Deimos
4
- VERSION = '1.20.1'
4
+ VERSION = '1.22.1'
5
5
  end
@@ -0,0 +1,284 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecordBatchConsumerTest # rubocop:disable Metrics/ModuleLength
4
+ describe Deimos::ActiveRecordConsumer,
5
+ 'Batch Consumer with MySQL handling associations',
6
+ :integration,
7
+ db_config: DbConfigs::DB_OPTIONS.second do
8
+ include_context('with DB')
9
+
10
+ before(:all) do
11
+ ActiveRecord::Base.connection.create_table(:widgets, force: true) do |t|
12
+ t.string(:test_id)
13
+ t.string(:part_one)
14
+ t.string(:part_two)
15
+ t.integer(:some_int)
16
+ t.string(:bulk_import_id)
17
+ t.boolean(:deleted, default: false)
18
+ t.timestamps
19
+
20
+ t.index(%i(part_one part_two), unique: true)
21
+ end
22
+
23
+ # create one-to-one association -- Details
24
+ ActiveRecord::Base.connection.create_table(:details, force: true) do |t|
25
+ t.string(:title)
26
+ t.string(:bulk_import_id)
27
+ t.belongs_to(:widget)
28
+
29
+ t.index(%i(title), unique: true)
30
+ end
31
+
32
+ # Create one-to-many association Locales
33
+ ActiveRecord::Base.connection.create_table(:locales, force: true) do |t|
34
+ t.string(:title)
35
+ t.string(:language)
36
+ t.string(:bulk_import_id)
37
+ t.belongs_to(:widget)
38
+
39
+ t.index(%i(widget_id title language), unique: true)
40
+ end
41
+
42
+ class Detail < ActiveRecord::Base
43
+ validates :title, presence: true
44
+ end
45
+
46
+ class Locale < ActiveRecord::Base
47
+ validates :title, presence: true
48
+ validates :language, presence: true
49
+ end
50
+
51
+ # Sample model
52
+ class Widget < ActiveRecord::Base
53
+ has_one :detail
54
+ has_many :locales, dependent: :destroy
55
+ validates :test_id, presence: true
56
+
57
+ default_scope -> { where(deleted: false) }
58
+ end
59
+
60
+ Widget.reset_column_information
61
+ Detail.reset_column_information
62
+ Locale.reset_column_information
63
+ end
64
+
65
+ after(:all) do
66
+ ActiveRecord::Base.connection.drop_table(:widgets)
67
+ ActiveRecord::Base.connection.drop_table(:details)
68
+ ActiveRecord::Base.connection.drop_table(:locales)
69
+ end
70
+
71
+ before(:each) do
72
+ ActiveRecord::Base.connection.truncate_tables(%i(widgets details locales))
73
+ Widget.create!(test_id: 'bad_id', some_int: 100) # should not show up
74
+ end
75
+
76
+ prepend_before(:each) do
77
+ consumer_class.config[:bulk_import_id_column] = :bulk_import_id
78
+ stub_const('MyBatchConsumer', consumer_class)
79
+ stub_const('ConsumerTest::MyBatchConsumer', consumer_class)
80
+ end
81
+
82
+ # Helper to publish a list of messages and call the consumer
83
+ def publish_batch(messages)
84
+ keys = messages.map { |m| m[:key] }
85
+ payloads = messages.map { |m| m[:payload] }
86
+
87
+ test_consume_batch(MyBatchConsumer, payloads, keys: keys, call_original: true)
88
+ end
89
+
90
+ let(:consumer_class) do
91
+ klass = Class.new(described_class) do
92
+ cattr_accessor :record_attributes_proc
93
+ cattr_accessor :should_consume_proc
94
+ schema 'MySchema'
95
+ namespace 'com.my-namespace'
96
+ key_config plain: true
97
+ record_class Widget
98
+
99
+ def should_consume?(record)
100
+ if self.should_consume_proc
101
+ return self.should_consume_proc.call(record)
102
+ end
103
+
104
+ true
105
+ end
106
+
107
+ def record_attributes(payload, _key)
108
+ if self.record_attributes_proc
109
+ return self.record_attributes_proc.call(payload)
110
+ end
111
+
112
+ {
113
+ test_id: payload['test_id'],
114
+ some_int: payload['some_int'],
115
+ detail: {
116
+ title: payload['title']
117
+ }
118
+ }
119
+ end
120
+
121
+ def key_columns(klass)
122
+ case klass.to_s
123
+ when Widget.to_s
124
+ nil
125
+ when Detail.to_s
126
+ %w(title widget_id)
127
+ when Locale.to_s
128
+ %w(widget_id title language)
129
+ else
130
+ []
131
+ end
132
+ end
133
+
134
+ def columns(record_class)
135
+ all_cols = record_class.columns.map(&:name)
136
+
137
+ case record_class.to_s
138
+ when Widget.to_s
139
+ nil
140
+ when Detail.to_s, Locale.to_s
141
+ all_cols - ['id']
142
+ else
143
+ []
144
+ end
145
+ end
146
+ end
147
+ klass
148
+ end
149
+
150
+ context 'when association configured in consumer without model changes' do
151
+ before(:each) do
152
+ consumer_class.config[:bulk_import_id_column] = :bulk_import_id
153
+ ActiveRecord::Base.connection.remove_column(:widgets, :bulk_import_id)
154
+ Widget.reset_column_information
155
+ end
156
+
157
+ after(:each) do
158
+ ActiveRecord::Base.connection.add_column(:widgets, :bulk_import_id, :string)
159
+ end
160
+
161
+ it 'should raise error when bulk_import_id is not found' do
162
+ expect {
163
+ publish_batch([{ key: 2,
164
+ payload: { test_id: 'xyz', some_int: 5, title: 'Widget Title' } }])
165
+ }.to raise_error('Create bulk_import_id on the widgets table. Run rails g deimos:bulk_import_id {table}'\
166
+ ' to create the migration.')
167
+ end
168
+ end
169
+
170
+ context 'with one-to-one relation in association and custom bulk_import_id' do
171
+ before(:each) do
172
+ consumer_class.config[:bulk_import_id_column] = :custom_id
173
+ consumer_class.config[:replace_associations] = false
174
+ end
175
+
176
+ before(:all) do
177
+ ActiveRecord::Base.connection.add_column(:widgets, :custom_id, :string, if_not_exists: true)
178
+ Widget.reset_column_information
179
+ end
180
+
181
+ it 'should save item to widget and associated detail' do
182
+ publish_batch([{ key: 2,
183
+ payload: { test_id: 'xyz', some_int: 5, title: 'Widget Title' } }])
184
+ expect(Widget.count).to eq(2)
185
+ expect(Detail.count).to eq(1)
186
+ expect(Widget.last.id).to eq(Detail.first.widget_id)
187
+ end
188
+ end
189
+
190
+ context 'with one-to-many relationship in association and default bulk_import_id' do
191
+ before(:each) do
192
+ consumer_class.config[:replace_associations] = false
193
+ consumer_class.record_attributes_proc = proc do |payload|
194
+ {
195
+ test_id: payload['test_id'],
196
+ some_int: payload['some_int'],
197
+ locales: [
198
+ {
199
+ title: payload['title'],
200
+ language: 'en'
201
+ },
202
+ {
203
+ title: payload['title'],
204
+ language: 'fr'
205
+ }
206
+ ]
207
+ }
208
+ end
209
+ end
210
+
211
+ it 'should save item to widget and associated details' do
212
+ consumer_class.config[:replace_associations] = false
213
+ publish_batch([{ key: 2,
214
+ payload: { test_id: 'xyz', some_int: 5, title: 'Widget Title' } }])
215
+ expect(Widget.count).to eq(2)
216
+ expect(Locale.count).to eq(2)
217
+ expect(Widget.last.id).to eq(Locale.first.widget_id)
218
+ expect(Widget.last.id).to eq(Locale.second.widget_id)
219
+
220
+ # publish again - should add locales to the widget
221
+ publish_batch([{ key: 2,
222
+ payload: { test_id: 'xyz', some_int: 7, title: 'Widget Title 2' } }])
223
+ expect(Widget.count).to eq(2)
224
+ expect(Widget.last.some_int).to eq(7)
225
+ expect(Locale.count).to eq(4)
226
+ expect(Locale.all.map(&:widget_id).uniq).to eq([Widget.last.id])
227
+ end
228
+ end
229
+
230
+ context 'with replace_associations on' do
231
+ before(:each) do
232
+ consumer_class.config[:replace_associations] = true
233
+ consumer_class.record_attributes_proc = proc do |payload|
234
+ {
235
+ test_id: payload['test_id'],
236
+ some_int: payload['some_int'],
237
+ locales: [
238
+ {
239
+ title: payload['title'],
240
+ language: 'en'
241
+ },
242
+ {
243
+ title: payload['title'],
244
+ language: 'fr'
245
+ }
246
+ ]
247
+ }
248
+ end
249
+ end
250
+
251
+ it 'should save item to widget and replace associated details' do
252
+ publish_batch([{ key: 2,
253
+ payload: { test_id: 'xyz', some_int: 5, title: 'Widget Title' } }])
254
+ expect(Widget.count).to eq(2)
255
+ expect(Locale.count).to eq(2)
256
+ expect(Widget.last.id).to eq(Locale.first.widget_id)
257
+ expect(Widget.last.id).to eq(Locale.second.widget_id)
258
+
259
+ # publish again - should replace locales
260
+ publish_batch([{ key: 2,
261
+ payload: { test_id: 'xyz', some_int: 7, title: 'Widget Title 2' } }])
262
+ expect(Widget.count).to eq(2)
263
+ expect(Widget.last.some_int).to eq(7)
264
+ expect(Locale.count).to eq(2)
265
+ expect(Locale.all.map(&:title).uniq).to contain_exactly('Widget Title 2')
266
+ expect(Locale.all.map(&:widget_id).uniq).to contain_exactly(Widget.last.id)
267
+ end
268
+ end
269
+
270
+ context 'with invalid models' do
271
+ before(:each) do
272
+ consumer_class.should_consume_proc = proc { |val| val.some_int <= 10 }
273
+ end
274
+
275
+ it 'should only save valid models' do
276
+ publish_batch([{ key: 2,
277
+ payload: { test_id: 'xyz', some_int: 5, title: 'Widget Title' } },
278
+ { key: 3,
279
+ payload: { test_id: 'abc', some_int: 15, title: 'Widget Title 2' } }])
280
+ expect(Widget.count).to eq(2)
281
+ end
282
+ end
283
+ end
284
+ end
@@ -32,6 +32,8 @@ module ActiveRecordBatchConsumerTest
32
32
 
33
33
  prepend_before(:each) do
34
34
  stub_const('MyBatchConsumer', consumer_class)
35
+ stub_const('ConsumerTest::MyBatchConsumer', consumer_class)
36
+ consumer_class.config[:bulk_import_id_column] = :bulk_import_id # default
35
37
  end
36
38
 
37
39
  around(:each) do |ex|
@@ -337,6 +339,9 @@ module ActiveRecordBatchConsumerTest
337
339
  Widget.create!(test_id: 'xxx', some_int: 2, part_one: 'ghi', part_two: 'jkl')
338
340
  Widget.create!(test_id: 'yyy', some_int: 7, part_one: 'mno', part_two: 'pqr')
339
341
 
342
+ allow_any_instance_of(MyBatchConsumer).to receive(:key_columns).
343
+ and_return(%w(part_one part_two))
344
+
340
345
  publish_batch(
341
346
  [
342
347
  { key: { part_one: 'abc', part_two: 'def' }, # To be created
@@ -488,24 +493,5 @@ module ActiveRecordBatchConsumerTest
488
493
  end
489
494
  end
490
495
 
491
- describe 'association_list feature for SQLite database' do
492
- let(:consumer_class) do
493
- Class.new(described_class) do
494
- schema 'MySchema'
495
- namespace 'com.my-namespace'
496
- key_config plain: true
497
- record_class Widget
498
- association_list :locales
499
- end
500
- end
501
-
502
- it 'should throw NotImplemented error' do
503
- stub_const('MyBatchConsumer', consumer_class)
504
- expect {
505
- publish_batch([{ key: 2, payload: { test_id: 'xyz', some_int: 5, title: 'Widget Title' } }])
506
- }.to raise_error(Deimos::MissingImplementationError)
507
- end
508
- end
509
-
510
496
  end
511
497
  end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Deimos::ActiveRecordConsume::MassUpdater do
4
+
5
+ before(:all) do
6
+ ActiveRecord::Base.connection.create_table(:widgets, force: true) do |t|
7
+ t.string(:test_id)
8
+ t.integer(:some_int)
9
+ t.string(:bulk_import_id)
10
+ t.timestamps
11
+ end
12
+
13
+ # create one-to-one association -- Details
14
+ ActiveRecord::Base.connection.create_table(:details, force: true) do |t|
15
+ t.string(:title)
16
+ t.string(:bulk_import_id)
17
+ t.belongs_to(:widget)
18
+
19
+ t.index(%i(title), unique: true)
20
+ end
21
+ end
22
+
23
+ after(:all) do
24
+ ActiveRecord::Base.connection.drop_table(:widgets)
25
+ ActiveRecord::Base.connection.drop_table(:details)
26
+ end
27
+
28
+ let(:detail_class) do
29
+ Class.new(ActiveRecord::Base) do
30
+ self.table_name = 'details'
31
+ belongs_to :widget
32
+ end
33
+ end
34
+
35
+ let(:widget_class) do
36
+ Class.new(ActiveRecord::Base) do
37
+ self.table_name = 'widgets'
38
+ has_one :detail
39
+ end
40
+ end
41
+
42
+ before(:each) do
43
+ stub_const('Widget', widget_class)
44
+ stub_const('Detail', detail_class)
45
+ Widget.reset_column_information
46
+ end
47
+
48
+ describe '#mass_update' do
49
+ let(:batch) do
50
+ Deimos::ActiveRecordConsume::BatchRecordList.new(
51
+ [
52
+ Deimos::ActiveRecordConsume::BatchRecord.new(
53
+ klass: Widget,
54
+ attributes: { test_id: 'id1', some_int: 5, detail: { title: 'Title 1' } },
55
+ bulk_import_column: 'bulk_import_id'
56
+ ),
57
+ Deimos::ActiveRecordConsume::BatchRecord.new(
58
+ klass: Widget,
59
+ attributes: { test_id: 'id2', some_int: 10, detail: { title: 'Title 2' } },
60
+ bulk_import_column: 'bulk_import_id'
61
+ )
62
+ ]
63
+ )
64
+ end
65
+
66
+ it 'should mass update the batch' do
67
+ described_class.new(Widget).mass_update(batch)
68
+ expect(Widget.count).to eq(2)
69
+ expect(Detail.count).to eq(2)
70
+ expect(Widget.first.detail).not_to be_nil
71
+ expect(Widget.last.detail).not_to be_nil
72
+ end
73
+
74
+ end
75
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: deimos-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.20.1
4
+ version: 1.22.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Daniel Orner
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2023-04-18 00:00:00.000000000 Z
11
+ date: 2023-05-04 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: avro_turf
@@ -410,7 +410,6 @@ files:
410
410
  - ".rubocop_todo.yml"
411
411
  - ".ruby-version"
412
412
  - CHANGELOG.md
413
- - CHANGELOG.md.orig
414
413
  - CODE_OF_CONDUCT.md
415
414
  - Dockerfile
416
415
  - Gemfile
@@ -431,7 +430,10 @@ files:
431
430
  - docs/UPGRADING.md
432
431
  - lib/deimos.rb
433
432
  - lib/deimos/active_record_consume/batch_consumption.rb
433
+ - lib/deimos/active_record_consume/batch_record.rb
434
+ - lib/deimos/active_record_consume/batch_record_list.rb
434
435
  - lib/deimos/active_record_consume/batch_slicer.rb
436
+ - lib/deimos/active_record_consume/mass_updater.rb
435
437
  - lib/deimos/active_record_consume/message_consumption.rb
436
438
  - lib/deimos/active_record_consume/schema_model_converter.rb
437
439
  - lib/deimos/active_record_consumer.rb
@@ -509,10 +511,11 @@ files:
509
511
  - sig/avro.rbs
510
512
  - sig/defs.rbs
511
513
  - sig/fig_tree.rbs
512
- - spec/active_record_batch_consumer_mysql_spec.rb
514
+ - spec/active_record_batch_consumer_association_spec.rb
513
515
  - spec/active_record_batch_consumer_spec.rb
514
516
  - spec/active_record_consume/batch_consumption_spec.rb
515
517
  - spec/active_record_consume/batch_slicer_spec.rb
518
+ - spec/active_record_consume/mass_updater_spec.rb
516
519
  - spec/active_record_consume/schema_model_converter_spec.rb
517
520
  - spec/active_record_consumer_spec.rb
518
521
  - spec/active_record_producer_spec.rb
@@ -635,10 +638,11 @@ signing_key:
635
638
  specification_version: 4
636
639
  summary: Kafka libraries for Ruby.
637
640
  test_files:
638
- - spec/active_record_batch_consumer_mysql_spec.rb
641
+ - spec/active_record_batch_consumer_association_spec.rb
639
642
  - spec/active_record_batch_consumer_spec.rb
640
643
  - spec/active_record_consume/batch_consumption_spec.rb
641
644
  - spec/active_record_consume/batch_slicer_spec.rb
645
+ - spec/active_record_consume/mass_updater_spec.rb
642
646
  - spec/active_record_consume/schema_model_converter_spec.rb
643
647
  - spec/active_record_consumer_spec.rb
644
648
  - spec/active_record_producer_spec.rb