deimos-ruby 2.0.3 → 2.0.5

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1fac86a45a84b2698c358a6c94476567047ef006b53dcf4cfd005451d9f78a10
4
- data.tar.gz: b547072ec4e0c9bb18880a497e28d567169da144397bba748d65f5bcf2b53e17
3
+ metadata.gz: 53cd09a846fed14b74b20cad18fc63374887c0a9deae4444f2d6548ea06b5b72
4
+ data.tar.gz: 95f7b6402d43d2c2332f5abbc8c0374655b1ac713b484d3f4b516321b4e2207d
5
5
  SHA512:
6
- metadata.gz: 2e871a8e7aa75c568f6c8cfd0fadce2acf955d6703842b784385d32f55f579f4f239924c589066d088dd9dc3bc88e789e4597494aabc4c61bf6fc37979cca5c6
7
- data.tar.gz: a595322011ec7bda0d80d6bad141c0dfbcc49dff3609c15299d3ad7ff9fab225cb8063343fdcab2443ac7bde60dc4e16557568912a0df8d630b9b65266df9e4e
6
+ metadata.gz: 928b533075a3a98f5731f15e2e9436cd1ad0754006bcf18816bdc19933e0990478aa7cd06152e450f0041524cfcf04da839c1116a4895bf3c9e692e70aefa4f0
7
+ data.tar.gz: b363497c5b9f1e9ea6dd71bf2d4e4bd59e7b96fe229cc58ee9caccb3ca86a7cd1ef4bf0636e7a354d496c790aa867fcad3405bdd4b353e72fce1d1e22ba32876
data/CHANGELOG.md CHANGED
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## UNRELEASED
9
9
 
10
+ ## 2.0.5 - 2025-03-19
11
+
12
+ - Fix: Added support to handle producing union type of multiple records & data types.
13
+
14
+ ## 2.0.4 - 2025-03-17
15
+
16
+ - Feature: Added `producers.truncate_columns` config.
17
+
10
18
  ## 2.0.3 - 2025-03-12
11
19
  - Fix: `ActiveRecordProducer.config` could crash if there were non-producer configs.
12
20
 
@@ -43,11 +43,12 @@ things you need to reference into local variables before calling `configure`.
43
43
 
44
44
  ### Producer Configuration
45
45
 
46
- | Config name | Default | Description |
47
- |------------------------|----------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
48
- | producers.topic_prefix | nil | Add a prefix to all topic names. This can be useful if you're using the same Kafka broker for different environments that are producing the same topics. |
49
- | producers.disabled | false | Disable all actual message producing. Generally more useful to use the `disable_producers` method instead. |
50
- | producers.backend | `:kafka_async` | Currently can be set to `:db`, `:kafka`, or `:kafka_async`. If using Kafka directly, a good pattern is to set to async in your user-facing app, and sync in your consumers or delayed workers. |
46
+ | Config name | Default | Description |
47
+ |----------------------------|----------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
48
+ | producers.topic_prefix | nil | Add a prefix to all topic names. This can be useful if you're using the same Kafka broker for different environments that are producing the same topics. |
49
+ | producers.disabled | false | Disable all actual message producing. Generally more useful to use the `disable_producers` method instead. |
50
+ | producers.backend | `:kafka_async` | Currently can be set to `:db`, `:kafka`, or `:kafka_async`. If using Kafka directly, a good pattern is to set to async in your user-facing app, and sync in your consumers or delayed workers. |
51
+ | producers.truncate_columns | false | If set to true, will truncate values to their database limits when using KafkaSource. |
51
52
 
52
53
  ### Schema Configuration
53
54
 
@@ -133,6 +133,10 @@ module Deimos # rubocop:disable Metrics/ModuleLength
133
133
  # sync in your consumers or delayed workers.
134
134
  # @return [Symbol]
135
135
  setting :backend, :kafka_async
136
+
137
+ # If set to true, KafkaSource will automatically truncate fields to match the column
138
+ # length in the database.
139
+ setting :truncate_columns
136
140
  end
137
141
 
138
142
  setting :schema do
@@ -6,10 +6,6 @@ module Deimos
6
6
  module KafkaSource
7
7
  extend ActiveSupport::Concern
8
8
 
9
- # @return [String]
10
- DEPRECATION_WARNING = 'The kafka_producer interface will be deprecated ' \
11
- 'in future releases. Please use kafka_producers instead.'
12
-
13
9
  included do
14
10
  after_create(:send_kafka_event_on_create)
15
11
  after_update(:send_kafka_event_on_update)
@@ -22,6 +18,7 @@ module Deimos
22
18
  return unless self.persisted?
23
19
  return unless self.class.kafka_config[:create]
24
20
 
21
+ self.truncate_columns if Deimos.config.producers.truncate_columns
25
22
  self.class.kafka_producers.each { |p| p.send_event(self) }
26
23
  end
27
24
 
@@ -39,6 +36,7 @@ module Deimos
39
36
  field_change.present? && field_change[0] != field_change[1]
40
37
  end
41
38
  return unless any_changes
39
+ self.truncate_columns if Deimos.config.producers.truncate_columns
42
40
 
43
41
  producers.each { |p| p.send_event(self) }
44
42
  end
@@ -124,5 +122,17 @@ module Deimos
124
122
  results
125
123
  end
126
124
  end
125
+
126
+ # check if any field has value longer than the field limit
127
+ def truncate_columns
128
+ self.class.columns.each do |col|
129
+ next unless col.type == :string
130
+ next if self[col.name].blank?
131
+ if self[col.name].to_s.length > col.limit
132
+ self[col.name] = self[col.name][0..col.limit - 1]
133
+ end
134
+ end
135
+ false
136
+ end
127
137
  end
128
138
  end
@@ -18,10 +18,48 @@ module Deimos
18
18
  union_types = type.schemas.map { |s| s.type.to_sym }
19
19
  return nil if val.nil? && union_types.include?(:null)
20
20
 
21
- schema_type = type.schemas.find { |s| s.type.to_sym != :null }
21
+ schema_type = find_schema_type(type, val)
22
22
  coerce_type(schema_type, val)
23
23
  end
24
24
 
25
+ # Find the right schema for val from a UnionSchema.
26
+ # @param type [Avro::Schema::UnionSchema]
27
+ # @param val [Object]
28
+ # @return [Avro::Schema::PrimitiveSchema]
29
+ def find_schema_type(type, val)
30
+ int_classes = [Time, ActiveSupport::TimeWithZone]
31
+
32
+ schema_type = type.schemas.find do |schema|
33
+ field_type = schema.type.to_sym
34
+
35
+ case field_type
36
+ when :int, :long
37
+ val.is_a?(Integer) ||
38
+ _is_integer_string?(val) ||
39
+ int_classes.any? { |klass| val.is_a?(klass) }
40
+ when :float, :double
41
+ val.is_a?(Numeric) || _is_float_string?(val)
42
+ when :array
43
+ val.is_a?(Array)
44
+ when :record
45
+ if val.is_a?(Hash)
46
+ schema_fields_set = Set.new(schema.fields.map(&:name))
47
+ Set.new(val.keys).subset?(schema_fields_set)
48
+ else
49
+ # If the value is not a hash, we can't coerce it to a record.
50
+ # Keep looking for another schema
51
+ false
52
+ end
53
+ else
54
+ schema.type.to_sym != :null
55
+ end
56
+ end
57
+
58
+ raise "No Schema type found for VALUE: #{val}\n TYPE: #{type}" if schema_type.nil?
59
+
60
+ schema_type
61
+ end
62
+
25
63
  # Coerce sub-records in a payload to match the schema.
26
64
  # @param type [Avro::Schema::RecordSchema]
27
65
  # @param val [Object]
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Deimos
4
- VERSION = '2.0.3'
4
+ VERSION = '2.0.5'
5
5
  end
@@ -44,6 +44,24 @@
44
44
  end
45
45
 
46
46
  <%- end -%>
47
+ <% end -%>
48
+ <%- if @field_assignments.select{ |h| h[:is_complex_union] }.any? -%>
49
+ <%- @field_assignments.select{ |h| h[:is_complex_union] }.each do |method_definition| -%>
50
+ # Helper method to determine which schema type to use for <%= method_definition[:field].name %>
51
+ # @param value [Hash, nil]
52
+ # @return [Object, nil]
53
+ def initialize_<%= method_definition[:field].name %>_type(value)
54
+ return nil if value.nil?
55
+
56
+ klass = [<%= method_definition[:field].type.schemas.reject { |s| s.type_sym == :null }.select { |s| s.type_sym == :record }.map { |s| Deimos::SchemaBackends::AvroBase.schema_classname(s) }.join(', ') %>].find do |candidate|
57
+ fields = candidate.new.as_json.keys
58
+ (value.keys - fields).empty?
59
+ end
60
+
61
+ klass.initialize_from_value(value)
62
+ end
63
+
64
+ <%- end -%>
47
65
  <% end -%>
48
66
  # @override
49
67
  <%= @initialization_definition %>
@@ -298,7 +298,9 @@ module Deimos
298
298
 
299
299
  field_initialization = method_argument
300
300
 
301
- if is_schema_class
301
+ if _is_complex_union?(field)
302
+ field_initialization = "initialize_#{field.name}_type(value)"
303
+ elsif is_schema_class
302
304
  field_initialization = "#{field_base_type}.initialize_from_value(value)"
303
305
  end
304
306
 
@@ -308,13 +310,26 @@ module Deimos
308
310
  is_schema_class: is_schema_class,
309
311
  method_argument: method_argument,
310
312
  deimos_type: deimos_field_type(field),
311
- field_initialization: field_initialization
313
+ field_initialization: field_initialization,
314
+ is_complex_union: _is_complex_union?(field)
312
315
  }
313
316
  end
314
317
 
315
318
  result
316
319
  end
317
320
 
321
+ # Helper method to detect if a field is a complex union type with multiple record schemas
322
+ # @param field [Deimos::SchemaField]
323
+ # @return [Boolean]
324
+ def _is_complex_union?(field)
325
+ return false unless field.type.type_sym == :union
326
+
327
+ non_null_schemas = field.type.schemas.reject { |s| s.type_sym == :null }
328
+
329
+ record_schemas = non_null_schemas.select { |s| s.type_sym == :record }
330
+ record_schemas.length > 1
331
+ end
332
+
318
333
  # Converts Avro::Schema::NamedSchema's to String form for generated YARD docs.
319
334
  # Recursively handles the typing for Arrays, Maps and Unions.
320
335
  # @param avro_schema [Avro::Schema::NamedSchema]
@@ -3,6 +3,7 @@
3
3
  describe Deimos::ActiveRecordProducer do
4
4
 
5
5
  include_context 'with widgets'
6
+ include_context 'with widget_with_union_types'
6
7
 
7
8
  prepend_before(:each) do
8
9
  producer_class = Class.new(Deimos::ActiveRecordProducer)
@@ -40,6 +41,12 @@ describe Deimos::ActiveRecordProducer do
40
41
  end
41
42
 
42
43
  stub_const('MyProducerWithPostProcess', producer_class)
44
+
45
+ producer_class = Class.new(Deimos::ActiveRecordProducer) do
46
+ record_class WidgetWithUnionType
47
+ end
48
+ stub_const('MyProducerWithUnionType', producer_class)
49
+
43
50
  Karafka::App.routes.redraw do
44
51
  topic 'my-topic' do
45
52
  schema 'MySchema'
@@ -71,6 +78,13 @@ describe Deimos::ActiveRecordProducer do
71
78
  key_config none: true
72
79
  producer_class MyProducerWithPostProcess
73
80
  end
81
+ topic 'my-topic-with-union-type' do
82
+ schema 'MySchemaWithUnionType'
83
+ namespace 'com.my-namespace'
84
+ key_config none: true
85
+ producer_class MyProducerWithUnionType
86
+ end
87
+
74
88
  end
75
89
 
76
90
  end
@@ -90,6 +104,98 @@ describe Deimos::ActiveRecordProducer do
90
104
  expect('my-topic').to have_sent(test_id: 'abc', some_int: 3)
91
105
  end
92
106
 
107
+ it 'should coerce values for a UnionSchema' do
108
+ MyProducerWithUnionType.send_event(WidgetWithUnionType.new(
109
+ test_id: "abc",
110
+ test_long: 399999,
111
+ test_union_type: %w(hello world)
112
+ ))
113
+
114
+ expect('my-topic-with-union-type').to have_sent(
115
+ test_id: "abc",
116
+ test_long: 399999,
117
+ test_union_type: %w(hello world)
118
+ )
119
+
120
+ MyProducerWithUnionType.send_event(WidgetWithUnionType.new(
121
+ test_id: "abc",
122
+ test_long: 399999,
123
+ test_union_type: {
124
+ record1_map:{ a:9999, b:234 },
125
+ record1_id: 567
126
+ }
127
+ ))
128
+
129
+ expect('my-topic-with-union-type').to have_sent(
130
+ test_id: "abc",
131
+ test_long: 399999,
132
+ test_union_type:{
133
+ record1_map:{ a:9999, b:234 },
134
+ record1_id: 567
135
+ }
136
+ )
137
+
138
+ MyProducerWithUnionType.send_event(WidgetWithUnionType.new(
139
+ test_id: "abc",
140
+ test_long: 399999,
141
+ test_union_type: 1010101
142
+ ))
143
+
144
+ expect('my-topic-with-union-type').to have_sent(
145
+ test_id: "abc",
146
+ test_long: 399999,
147
+ test_union_type:1010101
148
+ )
149
+
150
+ MyProducerWithUnionType.send_event(WidgetWithUnionType.new(
151
+ test_id: "abc",
152
+ test_long: 399999,
153
+ test_union_type: {
154
+ record2_id: "hello world"
155
+ }
156
+ ))
157
+
158
+ expect('my-topic-with-union-type').to have_sent(
159
+ test_id: "abc",
160
+ test_long: 399999,
161
+ test_union_type: {
162
+ record2_id: "hello world"
163
+ }
164
+ )
165
+
166
+ MyProducerWithUnionType.send_event(WidgetWithUnionType.new(
167
+ test_id: "abc",
168
+ test_long: 399999,
169
+ test_union_type: {
170
+ record3_id:10.1010
171
+ }
172
+ ))
173
+
174
+ expect('my-topic-with-union-type').to have_sent(
175
+ test_id: "abc",
176
+ test_long: 399999,
177
+ test_union_type: {
178
+ record3_id:10.1010
179
+ }
180
+ )
181
+
182
+ MyProducerWithUnionType.send_event(WidgetWithUnionType.new(
183
+ test_id: "abc",
184
+ test_long: 399999,
185
+ test_union_type: {
186
+ record4_id:101010
187
+ }
188
+ ))
189
+
190
+ expect('my-topic-with-union-type').to have_sent(
191
+ test_id: "abc",
192
+ test_long: 399999,
193
+ test_union_type: {
194
+ record4_id:101010
195
+ }
196
+ )
197
+ end
198
+
93
199
  it 'should coerce values' do
94
200
  MyProducer.send_event(Widget.new(test_id: 'abc', some_int: '3'))
95
201
  MyProducer.send_event(Widget.new(test_id: 'abc', some_int: 4.5))
@@ -109,11 +215,11 @@ describe Deimos::ActiveRecordProducer do
109
215
  widget = Widget.create!(test_id: 'abc2', some_int: 3)
110
216
  MyProducerWithID.send_event({id: widget.id, test_id: 'abc2', some_int: 3})
111
217
  expect('my-topic-with-id').to have_sent(
112
- test_id: 'abc2',
113
- some_int: 3,
114
- message_id: 'generated_id',
115
- timestamp: anything
116
- )
218
+ test_id: 'abc2',
219
+ some_int: 3,
220
+ message_id: 'generated_id',
221
+ timestamp: anything
222
+ )
117
223
  end
118
224
 
119
225
  it 'should post process the batch of records in #send_events' do
@@ -10,7 +10,7 @@ module KafkaSourceSpec
10
10
  t.integer(:widget_id)
11
11
  t.string(:description)
12
12
  t.string(:model_id, default: '')
13
- t.string(:name)
13
+ t.string(:name, limit: 100)
14
14
  t.timestamps
15
15
  end
16
16
  ActiveRecord::Base.connection.add_index(:widgets, :widget_id)
@@ -105,6 +105,54 @@ module KafkaSourceSpec
105
105
  expect('my-topic-the-second').to have_sent(nil, widget.id)
106
106
  end
107
107
 
108
+ context 'with truncation off' do
109
+ before(:each) do
110
+ Deimos.config.producers.truncate_columns = false
111
+ end
112
+ it 'should not truncate values' do
113
+ widget = Widget.create!(widget_id: 1, name: 'a'*500)
114
+ expect('my-topic').to have_sent({
115
+ widget_id: 1,
116
+ name: 'a'*500,
117
+ id: widget.id,
118
+ created_at: anything,
119
+ updated_at: anything
120
+ }, 1)
121
+ widget.update_attribute(:name, 'b'*500)
122
+ expect('my-topic').to have_sent({
123
+ widget_id: 1,
124
+ name: 'b'*500,
125
+ id: widget.id,
126
+ created_at: anything,
127
+ updated_at: anything
128
+ }, 1)
129
+ end
130
+ end
131
+
132
+ context 'with truncation on' do
133
+ before(:each) do
134
+ Deimos.config.producers.truncate_columns = true
135
+ end
136
+ it 'should truncate values' do
137
+ widget = Widget.create!(widget_id: 1, name: 'a'*500)
138
+ expect('my-topic').to have_sent({
139
+ widget_id: 1,
140
+ name: 'a'*100,
141
+ id: widget.id,
142
+ created_at: anything,
143
+ updated_at: anything
144
+ }, 1)
145
+ widget.update_attribute(:name, 'b'*500)
146
+ expect('my-topic').to have_sent({
147
+ widget_id: 1,
148
+ name: 'b'*100,
149
+ id: widget.id,
150
+ created_at: anything,
151
+ updated_at: anything
152
+ }, 1)
153
+ end
154
+ end
155
+
108
156
  it 'should send events on import' do
109
157
  widgets = (1..3).map do |i|
110
158
  Widget.new(widget_id: i, name: "Widget #{i}")
@@ -0,0 +1,91 @@
1
+ {
2
+ "namespace": "com.my-namespace",
3
+ "name": "MySchemaWithUnionType",
4
+ "type": "record",
5
+ "doc": "Test schema",
6
+ "fields": [
7
+ {
8
+ "name": "test_id",
9
+ "type": "string",
10
+ "default": ""
11
+ },
12
+ {
13
+ "name": "test_long",
14
+ "type": [
15
+ "null",
16
+ "long"
17
+ ],
18
+ "default": null
19
+ },
20
+ {
21
+ "name": "test_union_type",
22
+ "type": [
23
+ "null",
24
+ {
25
+ "type": "record",
26
+ "name": "Record1",
27
+ "namespace": "com.flipp.content",
28
+ "fields": [
29
+ {
30
+ "name": "record1_map",
31
+ "type": {
32
+ "type": "map",
33
+ "values": "long"
34
+ },
35
+ "default": {}
36
+ },
37
+ {
38
+ "name": "record1_id",
39
+ "type": "int",
40
+ "default": 0
41
+ }
42
+ ]
43
+ },
44
+ {
45
+ "type": "record",
46
+ "name": "Record2",
47
+ "namespace": "com.flipp.content",
48
+ "fields": [
49
+ {
50
+ "name": "record2_id",
51
+ "type": "string",
52
+ "default": ""
53
+ }
54
+ ]
55
+ },
56
+ {
57
+ "type": "record",
58
+ "name": "Record3",
59
+ "namespace": "com.flipp.content",
60
+ "fields": [
61
+ {
62
+ "name": "record3_id",
63
+ "type": "float",
64
+ "default": 0.0
65
+ }
66
+ ]
67
+ },
68
+ {
69
+ "type": "record",
70
+ "name": "Record4",
71
+ "namespace": "com.flipp.content",
72
+ "fields": [
73
+ {
74
+ "name": "record4_id",
75
+ "type": "int",
76
+ "default": 0
77
+ }
78
+ ]
79
+ },
80
+ "int",
81
+ {
82
+ "name": "test_array_of_strings",
83
+ "type": "array",
84
+ "default": [],
85
+ "items":"string"
86
+ }
87
+ ],
88
+ "default": null
89
+ }
90
+ ]
91
+ }