deimos-ruby 1.10.2 → 1.12.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.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +19 -0
  3. data/Gemfile.lock +8 -8
  4. data/README.md +150 -16
  5. data/deimos-ruby.gemspec +1 -1
  6. data/docs/CONFIGURATION.md +4 -0
  7. data/lib/deimos/active_record_consume/batch_consumption.rb +1 -1
  8. data/lib/deimos/active_record_consume/message_consumption.rb +4 -3
  9. data/lib/deimos/active_record_consumer.rb +2 -2
  10. data/lib/deimos/active_record_producer.rb +3 -0
  11. data/lib/deimos/config/configuration.rb +29 -0
  12. data/lib/deimos/consume/batch_consumption.rb +2 -2
  13. data/lib/deimos/consume/message_consumption.rb +2 -2
  14. data/lib/deimos/consumer.rb +10 -0
  15. data/lib/deimos/producer.rb +4 -3
  16. data/lib/deimos/schema_backends/avro_base.rb +64 -1
  17. data/lib/deimos/schema_backends/avro_schema_registry.rb +1 -1
  18. data/lib/deimos/schema_backends/base.rb +18 -2
  19. data/lib/deimos/schema_class/base.rb +67 -0
  20. data/lib/deimos/schema_class/enum.rb +24 -0
  21. data/lib/deimos/schema_class/record.rb +59 -0
  22. data/lib/deimos/shared_config.rb +5 -0
  23. data/lib/deimos/test_helpers.rb +70 -17
  24. data/lib/deimos/utils/schema_class.rb +29 -0
  25. data/lib/deimos/version.rb +1 -1
  26. data/lib/deimos.rb +3 -0
  27. data/lib/generators/deimos/schema_class/templates/schema_class.rb.tt +15 -0
  28. data/lib/generators/deimos/schema_class/templates/schema_enum.rb.tt +21 -0
  29. data/lib/generators/deimos/schema_class/templates/schema_record.rb.tt +65 -0
  30. data/lib/generators/deimos/schema_class_generator.rb +247 -0
  31. data/lib/tasks/deimos.rake +8 -0
  32. data/spec/active_record_batch_consumer_spec.rb +120 -110
  33. data/spec/active_record_consumer_spec.rb +97 -88
  34. data/spec/active_record_producer_spec.rb +38 -27
  35. data/spec/batch_consumer_spec.rb +37 -28
  36. data/spec/config/configuration_spec.rb +10 -3
  37. data/spec/consumer_spec.rb +94 -83
  38. data/spec/generators/active_record_generator_spec.rb +1 -0
  39. data/spec/generators/schema_class/my_schema_with_complex_types_spec.rb +206 -0
  40. data/spec/generators/schema_class_generator_spec.rb +186 -0
  41. data/spec/producer_spec.rb +110 -0
  42. data/spec/schema_classes/generated.rb +156 -0
  43. data/spec/schema_classes/my_nested_schema.rb +114 -0
  44. data/spec/schema_classes/my_schema.rb +53 -0
  45. data/spec/schema_classes/my_schema_key.rb +35 -0
  46. data/spec/schema_classes/my_schema_with_complex_types.rb +172 -0
  47. data/spec/schemas/com/my-namespace/Generated.avsc +6 -0
  48. data/spec/schemas/com/my-namespace/MySchemaWithComplexTypes.avsc +95 -0
  49. data/spec/spec_helper.rb +6 -1
  50. metadata +28 -4
@@ -0,0 +1,247 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+ require 'deimos'
5
+ require 'deimos/schema_backends/avro_base'
6
+ require 'deimos/config/configuration'
7
+
8
+ # Generates new schema classes.
9
+ module Deimos
10
+ module Generators
11
+ # Generator for Schema Classes used for the IDE and consumer/producer interfaces
12
+ class SchemaClassGenerator < Rails::Generators::Base
13
+
14
+ SPECIAL_TYPES = %i(record enum).freeze
15
+ INITIALIZE_WHITESPACE = "\n#{' ' * 19}"
16
+ IGNORE_DEFAULTS = %w(message_id timestamp).freeze
17
+ SCHEMA_CLASS_FILE = 'schema_class.rb'
18
+ SCHEMA_RECORD_PATH = File.expand_path('schema_class/templates/schema_record.rb.tt', __dir__).freeze
19
+ SCHEMA_ENUM_PATH = File.expand_path('schema_class/templates/schema_enum.rb.tt', __dir__).freeze
20
+
21
+ source_root File.expand_path('schema_class/templates', __dir__)
22
+
23
+ no_commands do
24
+ # Retrieve the fields from this Avro Schema
25
+ # @param schema [Avro::Schema::NamedSchema]
26
+ # @return [Array<SchemaField>]
27
+ def fields(schema)
28
+ schema.fields.map do |field|
29
+ Deimos::SchemaField.new(field.name, field.type, [], field.default)
30
+ end
31
+ end
32
+
33
+ # Converts Deimos::SchemaField's to String form for generated YARD docs
34
+ # @param schema_field [Deimos::SchemaField]
35
+ # @return [String] A string representation of the Type of this SchemaField
36
+ def deimos_field_type(schema_field)
37
+ _field_type(schema_field.type)
38
+ end
39
+
40
+ # Generate a Schema Model Class and all of its Nested Records from a
41
+ # Deimos Consumer or Producer Configuration object
42
+ # @param schema_name [String]
43
+ # @param namespace [String]
44
+ # @param key_schema_name [String]
45
+ def generate_classes(schema_name, namespace, key_schema_name)
46
+ schema_base = Deimos.schema_backend(schema: schema_name, namespace: namespace)
47
+ schema_base.load_schema
48
+ if key_schema_name.present?
49
+ key_schema_base = Deimos.schema_backend(schema: key_schema_name, namespace: namespace)
50
+ key_schema_base.load_schema
51
+ generate_class_from_schema_base(key_schema_base)
52
+ end
53
+ generate_class_from_schema_base(schema_base, key_schema_base: key_schema_base)
54
+ end
55
+
56
+ # @param schema_base [Deimos::SchemaBackends::Base]
57
+ # @param key_schema_base[Avro::Schema::NamedSchema]
58
+ def generate_class_from_schema_base(schema_base, key_schema_base: nil)
59
+ schemas = schema_base.schema_store.schemas.values
60
+ sub_schemas = schemas.reject { |s| s.name == schema_base.schema }
61
+ @sub_schema_templates = sub_schemas.map do |schema|
62
+ _generate_class_template_from_schema(schema)
63
+ end
64
+
65
+ main_schema = schemas.find { |s| s.name == schema_base.schema }
66
+ class_template = _generate_class_template_from_schema(main_schema, key_schema_base)
67
+ @main_class_definition = class_template
68
+
69
+ file_prefix = main_schema.name.underscore
70
+ namespace_path = main_schema.namespace.tr('.', '/')
71
+ filename = "#{Deimos.config.schema.generated_class_path}/#{namespace_path}/#{file_prefix}.rb"
72
+ template(SCHEMA_CLASS_FILE, filename, force: true)
73
+ end
74
+
75
+ # Format a given field into its appropriate to_h representation.
76
+ # @param field[Deimos::SchemaField]
77
+ # @return [String]
78
+ def field_to_h(field)
79
+ res = "'#{field.name}' => @#{field.name}"
80
+ field_base_type = _schema_base_class(field.type).type_sym
81
+
82
+ if %i(record enum).include?(field_base_type)
83
+ res += case field.type.type_sym
84
+ when :array
85
+ '.map { |v| v&.to_h }'
86
+ when :map
87
+ '.transform_values { |v| v&.to_h }'
88
+ else
89
+ '&.to_h'
90
+ end
91
+ end
92
+
93
+ res + (field.name == @fields.last.name ? '' : ',')
94
+ end
95
+
96
+ end
97
+
98
+ desc 'Generate a class based on configured consumer and producers.'
99
+ # :nodoc:
100
+ def generate
101
+ _validate
102
+ Rails.logger.info("Generating schemas from Deimos.config to #{Deimos.config.schema.generated_class_path}")
103
+ Deimos.config.producer_objects.each do |config|
104
+ schema_name = config.schema
105
+ namespace = config.namespace || Deimos.config.producers.schema_namespace
106
+ key_schema_name = config.key_config[:schema]
107
+ generate_classes(schema_name, namespace, key_schema_name)
108
+ end
109
+
110
+ Deimos.config.consumer_objects.each do |config|
111
+ schema_name = config.schema
112
+ namespace = config.namespace
113
+ key_schema_name = config.key_config[:schema]
114
+ generate_classes(schema_name, namespace, key_schema_name)
115
+ end
116
+ end
117
+
118
+ private
119
+
120
+ # Determines if Schema Class Generation can be run.
121
+ # @raise if Schema Backend is not of a Avro-based class
122
+ def _validate
123
+ backend = Deimos.config.schema.backend.to_s
124
+ raise 'Schema Class Generation requires an Avro-based Schema Backend' if backend !~ /^avro/
125
+ end
126
+
127
+ # @param schema[Avro::Schema::NamedSchema]
128
+ # @param key_schema_base[Avro::Schema::NamedSchema]
129
+ # @return [String]
130
+ def _generate_class_template_from_schema(schema, key_schema_base=nil)
131
+ _set_instance_variables(schema, key_schema_base)
132
+
133
+ temp = schema.is_a?(Avro::Schema::RecordSchema) ? _record_class_template : _enum_class_template
134
+ res = ERB.new(temp, nil, '-')
135
+ res.result(binding)
136
+ end
137
+
138
+ # @param schema[Avro::Schema::NamedSchema]
139
+ # @param key_schema_base[Avro::Schema::NamedSchema]
140
+ def _set_instance_variables(schema, key_schema_base=nil)
141
+ schema_is_record = schema.is_a?(Avro::Schema::RecordSchema)
142
+ @current_schema = schema
143
+ return unless schema_is_record
144
+
145
+ @fields = fields(schema)
146
+ if key_schema_base.present?
147
+ key_schema_base.load_schema
148
+ key_schema = key_schema_base.schema_store.schemas.values.first
149
+ @fields << Deimos::SchemaField.new('payload_key', key_schema, [], nil)
150
+ end
151
+ @initialization_definition = _initialization_definition
152
+ @field_assignments = _field_assignments
153
+ end
154
+
155
+ # Defines the initialization method for Schema Records with one keyword argument per line
156
+ # @return [String] A string which defines the method signature for the initialize method
157
+ def _initialization_definition
158
+ arguments = @fields.map do |schema_field|
159
+ arg = "#{schema_field.name}:"
160
+ arg += _field_default(schema_field)
161
+ arg.strip
162
+ end
163
+
164
+ result = "def initialize(#{arguments.first}"
165
+ arguments[1..-1].each_with_index do |arg, _i|
166
+ result += ",#{INITIALIZE_WHITESPACE}#{arg}"
167
+ end
168
+ "#{result})"
169
+ end
170
+
171
+ # @param [SchemaField]
172
+ # @return [String]
173
+ def _field_default(field)
174
+ default = field.default
175
+ return ' nil' if default == :no_default || default.nil? || IGNORE_DEFAULTS.include?(field.name)
176
+
177
+ case field.type.type_sym
178
+ when :string
179
+ " '#{default}'"
180
+ when :record
181
+ schema_name = Deimos::SchemaBackends::AvroBase.schema_classname(field.type)
182
+ class_instance = Utils::SchemaClass.instance(field.default, schema_name)
183
+ " #{class_instance.to_h}"
184
+ else
185
+ " #{default}"
186
+ end
187
+ end
188
+
189
+ # Overrides default attr accessor methods
190
+ # @return [Array<String>]
191
+ def _field_assignments
192
+ result = []
193
+ @fields.each do |field|
194
+ field_type = field.type.type_sym # Record, Union, Enum, Array or Map
195
+ schema_base_type = _schema_base_class(field.type)
196
+ field_base_type = _field_type(schema_base_type)
197
+ method_argument = %i(array map).include?(field_type) ? 'values' : 'value'
198
+ is_schema_class = %i(record enum).include?(schema_base_type.type_sym)
199
+
200
+ field_initialization = method_argument
201
+
202
+ if is_schema_class
203
+ field_initialization = "#{field_base_type}.initialize_from_value(value)"
204
+ end
205
+
206
+ result << {
207
+ field: field,
208
+ field_type: field_type,
209
+ is_schema_class: is_schema_class,
210
+ method_argument: method_argument,
211
+ deimos_type: deimos_field_type(field),
212
+ field_initialization: field_initialization
213
+ }
214
+ end
215
+
216
+ result
217
+ end
218
+
219
+ # Converts Avro::Schema::NamedSchema's to String form for generated YARD docs.
220
+ # Recursively handles the typing for Arrays, Maps and Unions.
221
+ # @param avro_schema [Avro::Schema::NamedSchema]
222
+ # @return [String] A string representation of the Type of this SchemaField
223
+ def _field_type(avro_schema)
224
+ Deimos::SchemaBackends::AvroBase.field_type(avro_schema)
225
+ end
226
+
227
+ # Returns the base class for this schema. Decodes Arrays, Maps and Unions
228
+ # @param avro_schema [Avro::Schema::NamedSchema]
229
+ # @return [Avro::Schema::NamedSchema]
230
+ def _schema_base_class(avro_schema)
231
+ Deimos::SchemaBackends::AvroBase.schema_base_class(avro_schema)
232
+ end
233
+
234
+ # An ERB template for schema record classes
235
+ # @return [String]
236
+ def _record_class_template
237
+ File.read(SCHEMA_RECORD_PATH).strip
238
+ end
239
+
240
+ # An ERB template for schema enum classes
241
+ # @return [String]
242
+ def _enum_class_template
243
+ File.read(SCHEMA_ENUM_PATH).strip
244
+ end
245
+ end
246
+ end
247
+ end
@@ -2,6 +2,8 @@
2
2
 
3
3
  require 'phobos'
4
4
  require 'phobos/cli'
5
+ require 'generators/deimos/schema_class_generator'
6
+ require 'optparse'
5
7
 
6
8
  namespace :deimos do
7
9
  desc 'Starts Deimos in the rails environment'
@@ -31,4 +33,10 @@ namespace :deimos do
31
33
  Deimos::Utils::DbPoller.start!
32
34
  end
33
35
 
36
+ desc 'Run Schema Model Generator'
37
+ task generate_schema_classes: :environment do
38
+ Rails.logger.info("Running deimos:generate_schema_classes")
39
+ Deimos::Generators::SchemaClassGenerator.start
40
+ end
41
+
34
42
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  # Wrapped in a module to prevent class leakage
4
4
  module ActiveRecordBatchConsumerTest
5
- describe Deimos::ActiveRecordConsumer do
5
+ describe Deimos::ActiveRecordConsumer, 'Batch Consumer' do
6
6
  # Create ActiveRecord table and model
7
7
  before(:all) do
8
8
  ActiveRecord::Base.connection.create_table(:widgets, force: true) do |t|
@@ -68,133 +68,143 @@ module ActiveRecordBatchConsumerTest
68
68
  test_consume_batch(MyBatchConsumer, payloads, keys: keys, call_original: true)
69
69
  end
70
70
 
71
- it 'should handle an empty batch' do
72
- expect { publish_batch([]) }.not_to raise_error
73
- end
71
+ describe 'consume_batch' do
72
+ SCHEMA_CLASS_SETTINGS.each do |setting, use_schema_classes|
73
+ context "with Schema Class consumption #{setting}" do
74
+ before(:each) do
75
+ Deimos.configure { |config| config.schema.use_schema_classes = use_schema_classes }
76
+ end
74
77
 
75
- it 'should create records from a batch' do
76
- publish_batch(
77
- [
78
- { key: 1,
79
- payload: { test_id: 'abc', some_int: 3 } },
80
- { key: 2,
81
- payload: { test_id: 'def', some_int: 4 } }
82
- ]
83
- )
84
-
85
- expect(all_widgets).
86
- to match_array(
87
- [
88
- have_attributes(id: 1, test_id: 'abc', some_int: 3, updated_at: start, created_at: start),
89
- have_attributes(id: 2, test_id: 'def', some_int: 4, updated_at: start, created_at: start)
90
- ]
91
- )
92
- end
78
+ it 'should handle an empty batch' do
79
+ expect { publish_batch([]) }.not_to raise_error
80
+ end
93
81
 
94
- it 'should handle deleting a record that doesn\'t exist' do
95
- publish_batch(
96
- [
97
- { key: 1,
98
- payload: nil }
99
- ]
100
- )
82
+ it 'should create records from a batch' do
83
+ publish_batch(
84
+ [
85
+ { key: 1,
86
+ payload: { test_id: 'abc', some_int: 3 } },
87
+ { key: 2,
88
+ payload: { test_id: 'def', some_int: 4 } }
89
+ ]
90
+ )
101
91
 
102
- expect(all_widgets).to be_empty
103
- end
92
+ expect(all_widgets).
93
+ to match_array(
94
+ [
95
+ have_attributes(id: 1, test_id: 'abc', some_int: 3, updated_at: start, created_at: start),
96
+ have_attributes(id: 2, test_id: 'def', some_int: 4, updated_at: start, created_at: start)
97
+ ]
98
+ )
99
+ end
104
100
 
105
- it 'should handle an update, followed by a delete in the correct order' do
106
- Widget.create!(id: 1, test_id: 'abc', some_int: 2)
101
+ it 'should handle deleting a record that doesn\'t exist' do
102
+ publish_batch(
103
+ [
104
+ { key: 1,
105
+ payload: nil }
106
+ ]
107
+ )
107
108
 
108
- publish_batch(
109
- [
110
- { key: 1,
111
- payload: { test_id: 'abc', some_int: 3 } },
112
- { key: 1,
113
- payload: nil }
114
- ]
115
- )
109
+ expect(all_widgets).to be_empty
110
+ end
116
111
 
117
- expect(all_widgets).to be_empty
118
- end
112
+ it 'should handle an update, followed by a delete in the correct order' do
113
+ Widget.create!(id: 1, test_id: 'abc', some_int: 2)
114
+
115
+ publish_batch(
116
+ [
117
+ { key: 1,
118
+ payload: { test_id: 'abc', some_int: 3 } },
119
+ { key: 1,
120
+ payload: nil }
121
+ ]
122
+ )
119
123
 
120
- it 'should handle a delete, followed by an update in the correct order' do
121
- Widget.create!(id: 1, test_id: 'abc', some_int: 2)
124
+ expect(all_widgets).to be_empty
125
+ end
122
126
 
123
- travel 1.day
127
+ it 'should handle a delete, followed by an update in the correct order' do
128
+ Widget.create!(id: 1, test_id: 'abc', some_int: 2)
124
129
 
125
- publish_batch(
126
- [
127
- { key: 1,
128
- payload: nil },
129
- { key: 1,
130
- payload: { test_id: 'abc', some_int: 3 } }
131
- ]
132
- )
130
+ travel 1.day
133
131
 
134
- expect(all_widgets).
135
- to match_array(
136
- [
137
- have_attributes(id: 1, test_id: 'abc', some_int: 3, updated_at: Time.zone.now, created_at: Time.zone.now)
138
- ]
139
- )
140
- end
132
+ publish_batch(
133
+ [
134
+ { key: 1,
135
+ payload: nil },
136
+ { key: 1,
137
+ payload: { test_id: 'abc', some_int: 3 } }
138
+ ]
139
+ )
141
140
 
142
- it 'should handle a double update' do
143
- Widget.create!(id: 1, test_id: 'abc', some_int: 2)
141
+ expect(all_widgets).
142
+ to match_array(
143
+ [
144
+ have_attributes(id: 1, test_id: 'abc', some_int: 3, updated_at: Time.zone.now, created_at: Time.zone.now)
145
+ ]
146
+ )
147
+ end
144
148
 
145
- travel 1.day
149
+ it 'should handle a double update' do
150
+ Widget.create!(id: 1, test_id: 'abc', some_int: 2)
146
151
 
147
- publish_batch(
148
- [
149
- { key: 1,
150
- payload: { test_id: 'def', some_int: 3 } },
151
- { key: 1,
152
- payload: { test_id: 'ghi', some_int: 4 } }
153
- ]
154
- )
152
+ travel 1.day
155
153
 
156
- expect(all_widgets).
157
- to match_array(
158
- [
159
- have_attributes(id: 1, test_id: 'ghi', some_int: 4, updated_at: Time.zone.now, created_at: start)
160
- ]
161
- )
162
- end
154
+ publish_batch(
155
+ [
156
+ { key: 1,
157
+ payload: { test_id: 'def', some_int: 3 } },
158
+ { key: 1,
159
+ payload: { test_id: 'ghi', some_int: 4 } }
160
+ ]
161
+ )
163
162
 
164
- it 'should handle a double deletion' do
165
- Widget.create!(id: 1, test_id: 'abc', some_int: 2)
163
+ expect(all_widgets).
164
+ to match_array(
165
+ [
166
+ have_attributes(id: 1, test_id: 'ghi', some_int: 4, updated_at: Time.zone.now, created_at: start)
167
+ ]
168
+ )
169
+ end
166
170
 
167
- publish_batch(
168
- [
169
- { key: 1,
170
- payload: nil },
171
- { key: 1,
172
- payload: nil }
173
- ]
174
- )
171
+ it 'should handle a double deletion' do
172
+ Widget.create!(id: 1, test_id: 'abc', some_int: 2)
175
173
 
176
- expect(all_widgets).to be_empty
177
- end
174
+ publish_batch(
175
+ [
176
+ { key: 1,
177
+ payload: nil },
178
+ { key: 1,
179
+ payload: nil }
180
+ ]
181
+ )
178
182
 
179
- it 'should ignore default scopes' do
180
- Widget.create!(id: 1, test_id: 'abc', some_int: 2, deleted: true)
181
- Widget.create!(id: 2, test_id: 'def', some_int: 3, deleted: true)
182
-
183
- publish_batch(
184
- [
185
- { key: 1,
186
- payload: nil },
187
- { key: 2,
188
- payload: { test_id: 'def', some_int: 5 } }
189
- ]
190
- )
191
-
192
- expect(all_widgets).
193
- to match_array(
194
- [
195
- have_attributes(id: 2, test_id: 'def', some_int: 5)
196
- ]
197
- )
183
+ expect(all_widgets).to be_empty
184
+ end
185
+
186
+ it 'should ignore default scopes' do
187
+ Widget.create!(id: 1, test_id: 'abc', some_int: 2, deleted: true)
188
+ Widget.create!(id: 2, test_id: 'def', some_int: 3, deleted: true)
189
+
190
+ publish_batch(
191
+ [
192
+ { key: 1,
193
+ payload: nil },
194
+ { key: 2,
195
+ payload: { test_id: 'def', some_int: 5 } }
196
+ ]
197
+ )
198
+
199
+ expect(all_widgets).
200
+ to match_array(
201
+ [
202
+ have_attributes(id: 2, test_id: 'def', some_int: 5)
203
+ ]
204
+ )
205
+ end
206
+ end
207
+ end
198
208
  end
199
209
 
200
210
  describe 'compacted mode' do