deimos-ruby 1.20.1 → 1.22

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'
5
5
  end
@@ -0,0 +1,288 @@
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
+ stub_const('MyBatchConsumer', consumer_class)
78
+ end
79
+
80
+ # Helper to publish a list of messages and call the consumer
81
+ def publish_batch(messages)
82
+ keys = messages.map { |m| m[:key] }
83
+ payloads = messages.map { |m| m[:payload] }
84
+
85
+ test_consume_batch(MyBatchConsumer, payloads, keys: keys, call_original: true)
86
+ end
87
+
88
+ let(:consumer_class) do
89
+ klass = Class.new(described_class) do
90
+ cattr_accessor :record_attributes_proc
91
+ cattr_accessor :should_consume_proc
92
+ schema 'MySchema'
93
+ namespace 'com.my-namespace'
94
+ key_config plain: true
95
+ record_class Widget
96
+
97
+ def should_consume?(record)
98
+ if self.should_consume_proc
99
+ return self.should_consume_proc.call(record)
100
+ end
101
+
102
+ true
103
+ end
104
+
105
+ def record_attributes(payload, _key)
106
+ if self.record_attributes_proc
107
+ return self.record_attributes_proc.call(payload)
108
+ end
109
+
110
+ {
111
+ test_id: payload['test_id'],
112
+ some_int: payload['some_int'],
113
+ detail: {
114
+ title: payload['title']
115
+ }
116
+ }
117
+ end
118
+
119
+ def key_columns(klass)
120
+ case klass.to_s
121
+ when Widget.to_s
122
+ nil
123
+ when Detail.to_s
124
+ %w(title widget_id)
125
+ when Locale.to_s
126
+ %w(widget_id title language)
127
+ else
128
+ []
129
+ end
130
+ end
131
+
132
+ def columns(record_class)
133
+ all_cols = record_class.columns.map(&:name)
134
+
135
+ case record_class.to_s
136
+ when Widget.to_s
137
+ nil
138
+ when Detail.to_s, Locale.to_s
139
+ all_cols - ['id']
140
+ else
141
+ []
142
+ end
143
+ end
144
+ end
145
+ klass
146
+ end
147
+
148
+ context 'when association configured in consumer without model changes' do
149
+ before(:each) do
150
+ consumer_class.config[:bulk_import_id_column] = :bulk_import_id
151
+ ActiveRecord::Base.connection.remove_column(:widgets, :bulk_import_id)
152
+ Widget.reset_column_information
153
+ end
154
+
155
+ after(:each) do
156
+ ActiveRecord::Base.connection.add_column(:widgets, :bulk_import_id, :string)
157
+ end
158
+
159
+ it 'should raise error when bulk_import_id is not found' do
160
+ stub_const('MyBatchConsumer', consumer_class)
161
+ expect {
162
+ publish_batch([{ key: 2,
163
+ payload: { test_id: 'xyz', some_int: 5, title: 'Widget Title' } }])
164
+ }.to raise_error('Create bulk_import_id on the widgets table. Run rails g deimos:bulk_import_id {table}'\
165
+ ' to create the migration.')
166
+ end
167
+ end
168
+
169
+ context 'with one-to-one relation in association and custom bulk_import_id' do
170
+ before(:each) do
171
+ consumer_class.config[:bulk_import_id_column] = :custom_id
172
+ end
173
+
174
+ before(:all) do
175
+ ActiveRecord::Base.connection.add_column(:widgets, :custom_id, :string, if_not_exists: true)
176
+ Widget.reset_column_information
177
+ end
178
+
179
+ it 'should save item to widget and associated detail' do
180
+ stub_const('MyBatchConsumer', consumer_class)
181
+ publish_batch([{ key: 2,
182
+ payload: { test_id: 'xyz', some_int: 5, title: 'Widget Title' } }])
183
+ expect(Widget.count).to eq(2)
184
+ expect(Detail.count).to eq(1)
185
+ expect(Widget.last.id).to eq(Detail.first.widget_id)
186
+ end
187
+ end
188
+
189
+ context 'with one-to-many relationship in association and default bulk_import_id' do
190
+ before(:each) do
191
+ consumer_class.config[:bulk_import_id_column] = :bulk_import_id
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
+ stub_const('MyBatchConsumer', consumer_class)
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[:bulk_import_id_column] = :bulk_import_id
233
+ consumer_class.config[:replace_associations] = true
234
+ consumer_class.record_attributes_proc = proc do |payload|
235
+ {
236
+ test_id: payload['test_id'],
237
+ some_int: payload['some_int'],
238
+ locales: [
239
+ {
240
+ title: payload['title'],
241
+ language: 'en'
242
+ },
243
+ {
244
+ title: payload['title'],
245
+ language: 'fr'
246
+ }
247
+ ]
248
+ }
249
+ end
250
+ end
251
+
252
+ it 'should save item to widget and replace associated details' do
253
+ stub_const('MyBatchConsumer', consumer_class)
254
+ publish_batch([{ key: 2,
255
+ payload: { test_id: 'xyz', some_int: 5, title: 'Widget Title' } }])
256
+ expect(Widget.count).to eq(2)
257
+ expect(Locale.count).to eq(2)
258
+ expect(Widget.last.id).to eq(Locale.first.widget_id)
259
+ expect(Widget.last.id).to eq(Locale.second.widget_id)
260
+
261
+ # publish again - should replace locales
262
+ publish_batch([{ key: 2,
263
+ payload: { test_id: 'xyz', some_int: 7, title: 'Widget Title 2' } }])
264
+ expect(Widget.count).to eq(2)
265
+ expect(Widget.last.some_int).to eq(7)
266
+ expect(Locale.count).to eq(2)
267
+ expect(Locale.all.map(&:title).uniq).to contain_exactly('Widget Title 2')
268
+ expect(Locale.all.map(&:widget_id).uniq).to contain_exactly(Widget.last.id)
269
+ end
270
+ end
271
+
272
+ context 'with invalid models' do
273
+ before(:each) do
274
+ consumer_class.config[:bulk_import_id_column] = :bulk_import_id
275
+ consumer_class.should_consume_proc = proc { |val| val.some_int <= 10 }
276
+ end
277
+
278
+ it 'should only save valid models' do
279
+ stub_const('MyBatchConsumer', consumer_class)
280
+ publish_batch([{ key: 2,
281
+ payload: { test_id: 'xyz', some_int: 5, title: 'Widget Title' } },
282
+ { key: 3,
283
+ payload: { test_id: 'abc', some_int: 15, title: 'Widget Title 2' } }])
284
+ expect(Widget.count).to eq(2)
285
+ end
286
+ end
287
+ end
288
+ end
@@ -337,6 +337,9 @@ module ActiveRecordBatchConsumerTest
337
337
  Widget.create!(test_id: 'xxx', some_int: 2, part_one: 'ghi', part_two: 'jkl')
338
338
  Widget.create!(test_id: 'yyy', some_int: 7, part_one: 'mno', part_two: 'pqr')
339
339
 
340
+ allow_any_instance_of(MyBatchConsumer).to receive(:key_columns).
341
+ and_return(%w(part_one part_two))
342
+
340
343
  publish_batch(
341
344
  [
342
345
  { key: { part_one: 'abc', part_two: 'def' }, # To be created
@@ -488,24 +491,5 @@ module ActiveRecordBatchConsumerTest
488
491
  end
489
492
  end
490
493
 
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
494
  end
511
495
  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'
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-01 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