avromatic 1.0.0 → 2.0.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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +4 -1
  3. data/Appraisals +2 -0
  4. data/CHANGELOG.md +15 -0
  5. data/Gemfile +2 -0
  6. data/README.md +57 -25
  7. data/Rakefile +2 -0
  8. data/avromatic.gemspec +7 -3
  9. data/lib/avromatic/io/datum_reader.rb +2 -0
  10. data/lib/avromatic/io/datum_writer.rb +7 -1
  11. data/lib/avromatic/io.rb +4 -2
  12. data/lib/avromatic/messaging.rb +2 -0
  13. data/lib/avromatic/model/attributes.rb +112 -158
  14. data/lib/avromatic/model/builder.rb +4 -7
  15. data/lib/avromatic/model/coercion_error.rb +8 -0
  16. data/lib/avromatic/model/configurable.rb +2 -0
  17. data/lib/avromatic/model/configuration.rb +2 -0
  18. data/lib/avromatic/model/{custom_type.rb → custom_type_configuration.rb} +2 -2
  19. data/lib/avromatic/model/{type_registry.rb → custom_type_registry.rb} +15 -18
  20. data/lib/avromatic/model/field_helper.rb +20 -0
  21. data/lib/avromatic/model/message_decoder.rb +2 -0
  22. data/lib/avromatic/model/messaging_serialization.rb +2 -0
  23. data/lib/avromatic/model/nested_models.rb +2 -17
  24. data/lib/avromatic/model/raw_serialization.rb +21 -84
  25. data/lib/avromatic/model/types/abstract_timestamp_type.rb +57 -0
  26. data/lib/avromatic/model/types/abstract_type.rb +37 -0
  27. data/lib/avromatic/model/types/array_type.rb +53 -0
  28. data/lib/avromatic/model/types/boolean_type.rb +39 -0
  29. data/lib/avromatic/model/types/custom_type.rb +64 -0
  30. data/lib/avromatic/model/types/date_type.rb +41 -0
  31. data/lib/avromatic/model/types/enum_type.rb +56 -0
  32. data/lib/avromatic/model/types/fixed_type.rb +45 -0
  33. data/lib/avromatic/model/types/float_type.rb +48 -0
  34. data/lib/avromatic/model/types/integer_type.rb +39 -0
  35. data/lib/avromatic/model/types/map_type.rb +74 -0
  36. data/lib/avromatic/model/types/null_type.rb +39 -0
  37. data/lib/avromatic/model/types/record_type.rb +57 -0
  38. data/lib/avromatic/model/types/string_type.rb +48 -0
  39. data/lib/avromatic/model/types/timestamp_micros_type.rb +32 -0
  40. data/lib/avromatic/model/types/timestamp_millis_type.rb +32 -0
  41. data/lib/avromatic/model/types/type_factory.rb +118 -0
  42. data/lib/avromatic/model/types/union_type.rb +87 -0
  43. data/lib/avromatic/model/unknown_attribute_error.rb +15 -0
  44. data/lib/avromatic/model/validation.rb +57 -36
  45. data/lib/avromatic/model/validation_error.rb +8 -0
  46. data/lib/avromatic/model/value_object.rb +2 -0
  47. data/lib/avromatic/model.rb +6 -2
  48. data/lib/avromatic/model_registry.rb +2 -0
  49. data/lib/avromatic/patches/schema_validator_patch.rb +2 -0
  50. data/lib/avromatic/patches.rb +2 -0
  51. data/lib/avromatic/railtie.rb +2 -0
  52. data/lib/avromatic/rspec.rb +2 -0
  53. data/lib/avromatic/version.rb +3 -1
  54. data/lib/avromatic.rb +9 -4
  55. metadata +32 -21
  56. data/lib/avromatic/model/allowed_type_validator.rb +0 -7
  57. data/lib/avromatic/model/allowed_writer_methods_memoization.rb +0 -16
  58. data/lib/avromatic/model/attribute/abstract_timestamp.rb +0 -26
  59. data/lib/avromatic/model/attribute/record.rb +0 -26
  60. data/lib/avromatic/model/attribute/timestamp_micros.rb +0 -26
  61. data/lib/avromatic/model/attribute/timestamp_millis.rb +0 -26
  62. data/lib/avromatic/model/attribute/union.rb +0 -66
  63. data/lib/avromatic/model/attribute_type/union.rb +0 -29
  64. data/lib/avromatic/model/logical_types.rb +0 -19
  65. data/lib/avromatic/model/null_custom_type.rb +0 -21
  66. data/lib/avromatic/model/passthrough_serializer.rb +0 -10
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'avromatic/model/types/abstract_type'
4
+
5
+ module Avromatic
6
+ module Model
7
+ module Types
8
+ class FloatType < AbstractType
9
+ VALUE_CLASSES = [::Float].freeze
10
+ INPUT_CLASSES = [::Float, ::Integer].freeze
11
+
12
+ def value_classes
13
+ VALUE_CLASSES
14
+ end
15
+
16
+ def input_classes
17
+ INPUT_CLASSES
18
+ end
19
+
20
+ def name
21
+ 'float'
22
+ end
23
+
24
+ def coerce(input)
25
+ if input.nil? || input.is_a?(::Float)
26
+ input
27
+ elsif input.is_a?(::Integer)
28
+ input.to_f
29
+ else
30
+ raise ArgumentError.new("Could not coerce '#{input.inspect}' to #{name}")
31
+ end
32
+ end
33
+
34
+ def coercible?(input)
35
+ input.nil? || input.is_a?(::Float) || input.is_a?(::Integer)
36
+ end
37
+
38
+ def coerced?(input)
39
+ input.nil? || input.is_a?(::Float)
40
+ end
41
+
42
+ def serialize(value, **)
43
+ value
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'avromatic/model/types/abstract_type'
4
+
5
+ module Avromatic
6
+ module Model
7
+ module Types
8
+ class IntegerType < AbstractType
9
+ VALUE_CLASSES = [::Integer].freeze
10
+
11
+ def value_classes
12
+ VALUE_CLASSES
13
+ end
14
+
15
+ def name
16
+ 'integer'
17
+ end
18
+
19
+ def coerce(input)
20
+ if input.nil? || input.is_a?(::Integer)
21
+ input
22
+ else
23
+ raise ArgumentError.new("Could not coerce '#{input.inspect}' to #{name}")
24
+ end
25
+ end
26
+
27
+ def coercible?(input)
28
+ input.nil? || input.is_a?(::Integer)
29
+ end
30
+
31
+ alias_method :coerced?, :coercible?
32
+
33
+ def serialize(value, **)
34
+ value
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'avromatic/model/types/abstract_type'
4
+
5
+ module Avromatic
6
+ module Model
7
+ module Types
8
+ class MapType < AbstractType
9
+ VALUE_CLASSES = [::Hash].freeze
10
+
11
+ attr_reader :value_type, :key_type
12
+
13
+ def initialize(key_type:, value_type:)
14
+ @key_type = key_type
15
+ @value_type = value_type
16
+ end
17
+
18
+ def name
19
+ "map[#{key_type.name} => #{value_type.name}]"
20
+ end
21
+
22
+ def value_classes
23
+ VALUE_CLASSES
24
+ end
25
+
26
+ def coerce(input)
27
+ if input.nil?
28
+ input
29
+ elsif input.is_a?(::Hash)
30
+ input.each_with_object({}) do |(key_input, value_input), result|
31
+ result[key_type.coerce(key_input)] = value_type.coerce(value_input)
32
+ end
33
+ else
34
+ raise ArgumentError.new("Could not coerce '#{input.inspect}' to #{name}")
35
+ end
36
+ end
37
+
38
+ def coercible?(input)
39
+ if input.nil?
40
+ true
41
+ elsif input.is_a?(Hash)
42
+ input.all? do |key_input, value_input|
43
+ key_type.coercible?(key_input) && value_type.coercible?(value_input)
44
+ end
45
+ else
46
+ false
47
+ end
48
+ end
49
+
50
+ def coerced?(value)
51
+ if value.nil?
52
+ true
53
+ elsif value.is_a?(Hash)
54
+ value.all? do |element_key, element_value|
55
+ key_type.coerced?(element_key) && value_type.coerced?(element_value)
56
+ end
57
+ else
58
+ false
59
+ end
60
+ end
61
+
62
+ def serialize(value, strict:)
63
+ if value.nil?
64
+ value
65
+ else
66
+ value.each_with_object({}) do |(element_key, element_value), result|
67
+ result[key_type.serialize(element_key, strict: strict)] = value_type.serialize(element_value, strict: strict)
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'avromatic/model/types/abstract_type'
4
+
5
+ module Avromatic
6
+ module Model
7
+ module Types
8
+ class NullType < AbstractType
9
+ VALUE_CLASSES = [::NilClass].freeze
10
+
11
+ def value_classes
12
+ VALUE_CLASSES
13
+ end
14
+
15
+ def name
16
+ 'null'
17
+ end
18
+
19
+ def coerce(input)
20
+ if input.nil?
21
+ nil
22
+ else
23
+ raise ArgumentError.new("Could not coerce '#{input.inspect}' to #{name}")
24
+ end
25
+ end
26
+
27
+ def coercible?(input)
28
+ input.nil?
29
+ end
30
+
31
+ alias_method :coerced?, :coercible?
32
+
33
+ def serialize(_value, **)
34
+ nil
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'avromatic/model/types/abstract_type'
4
+
5
+ module Avromatic
6
+ module Model
7
+ module Types
8
+ class RecordType < AbstractType
9
+ attr_reader :record_class, :value_classes, :input_classes
10
+
11
+ def initialize(record_class:)
12
+ @record_class = record_class
13
+ @value_classes = [record_class].freeze
14
+ @input_classes = [record_class, Hash].freeze
15
+ end
16
+
17
+ def name
18
+ record_class.name.to_s.freeze
19
+ end
20
+
21
+ def coerce(input)
22
+ if input.nil? || input.is_a?(record_class)
23
+ input
24
+ elsif input.is_a?(Hash)
25
+ record_class.new(input)
26
+ else
27
+ raise ArgumentError.new("Could not coerce '#{input.inspect}' to #{name}")
28
+ end
29
+ end
30
+
31
+ def coercible?(input)
32
+ # TODO: Is there a better way to figure this out?
33
+ input.nil? || input.is_a?(record_class) || coerce(input).valid?
34
+ rescue StandardError
35
+ false
36
+ end
37
+
38
+ def coerced?(value)
39
+ value.nil? || value.is_a?(record_class)
40
+ end
41
+
42
+ def serialize(value, strict:)
43
+ if value.nil?
44
+ value
45
+ elsif !strict && Avromatic.use_custom_datum_writer && Avromatic.use_encoding_providers? && !record_class.config.mutable
46
+ # n.b. Ideally we'd just return value here instead of wrapping it in a
47
+ # hash but then we'd have no place to stash the union member index...
48
+ { Avromatic::IO::ENCODING_PROVIDER => value }
49
+ else
50
+ # This is only used for recursive serialization so validation has already been done
51
+ strict ? value.avro_value_datum(validate: false) : value.value_attributes_for_avro(validate: false)
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'avromatic/model/types/abstract_type'
4
+
5
+ module Avromatic
6
+ module Model
7
+ module Types
8
+ class StringType
9
+ VALUE_CLASSES = [::String].freeze
10
+ INPUT_CLASSES = [::String, ::Symbol].freeze
11
+
12
+ def value_classes
13
+ VALUE_CLASSES
14
+ end
15
+
16
+ def input_classes
17
+ INPUT_CLASSES
18
+ end
19
+
20
+ def name
21
+ 'string'
22
+ end
23
+
24
+ def coerce(input)
25
+ if input.nil? || input.is_a?(::String)
26
+ input
27
+ elsif input.is_a?(::Symbol)
28
+ input.to_s
29
+ else
30
+ raise ArgumentError.new("Could not coerce '#{input.inspect}' to #{name}")
31
+ end
32
+ end
33
+
34
+ def coercible?(input)
35
+ input.nil? || input.is_a?(::String) || input.is_a?(::Symbol)
36
+ end
37
+
38
+ def coerced?(value)
39
+ value.nil? || value.is_a?(::String)
40
+ end
41
+
42
+ def serialize(value, **)
43
+ value
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'avromatic/model/types/abstract_timestamp_type'
4
+
5
+ module Avromatic
6
+ module Model
7
+ module Types
8
+
9
+ # This subclass is used to truncate timestamp values to microseconds.
10
+ class TimestampMicrosType < Avromatic::Model::Types::AbstractTimestampType
11
+
12
+ def name
13
+ 'timestamp-micros'
14
+ end
15
+
16
+ private
17
+
18
+ def truncated?(value)
19
+ value.nsec % 1000 == 0
20
+ end
21
+
22
+ def coerce_time(input)
23
+ # value is coerced to a local Time
24
+ # The Avro representation of a timestamp is Epoch seconds, independent
25
+ # of time zone.
26
+ ::Time.at(input.to_i, input.usec)
27
+ end
28
+
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'avromatic/model/types/abstract_timestamp_type'
4
+
5
+ module Avromatic
6
+ module Model
7
+ module Types
8
+
9
+ # This subclass is used to truncate timestamp values to milliseconds.
10
+ class TimestampMillisType < Avromatic::Model::Types::AbstractTimestampType
11
+
12
+ def name
13
+ 'timestamp-millis'
14
+ end
15
+
16
+ private
17
+
18
+ def truncated?(value)
19
+ value.usec % 1000 == 0
20
+ end
21
+
22
+ def coerce_time(input)
23
+ # value is coerced to a local Time
24
+ # The Avro representation of a timestamp is Epoch seconds, independent
25
+ # of time zone.
26
+ ::Time.at(input.to_i, input.usec / 1000 * 1000)
27
+ end
28
+
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'avromatic/model/types/array_type'
4
+ require 'avromatic/model/types/boolean_type'
5
+ require 'avromatic/model/types/custom_type'
6
+ require 'avromatic/model/types/date_type'
7
+ require 'avromatic/model/types/enum_type'
8
+ require 'avromatic/model/types/fixed_type'
9
+ require 'avromatic/model/types/float_type'
10
+ require 'avromatic/model/types/integer_type'
11
+ require 'avromatic/model/types/map_type'
12
+ require 'avromatic/model/types/null_type'
13
+ require 'avromatic/model/types/record_type'
14
+ require 'avromatic/model/types/string_type'
15
+ require 'avromatic/model/types/timestamp_micros_type'
16
+ require 'avromatic/model/types/timestamp_millis_type'
17
+ require 'avromatic/model/types/union_type'
18
+
19
+ module Avromatic
20
+ module Model
21
+ module Types
22
+ module TypeFactory
23
+ extend self
24
+
25
+ SINGLETON_TYPES = {
26
+ 'date' => Avromatic::Model::Types::DateType.new,
27
+ 'timestamp-micros' => Avromatic::Model::Types::TimestampMicrosType.new,
28
+ 'timestamp-millis' => Avromatic::Model::Types::TimestampMillisType.new,
29
+ 'string' => Avromatic::Model::Types::StringType.new,
30
+ 'bytes' => Avromatic::Model::Types::StringType.new,
31
+ 'boolean' => Avromatic::Model::Types::BooleanType.new,
32
+ 'int' => Avromatic::Model::Types::IntegerType.new,
33
+ 'long' => Avromatic::Model::Types::IntegerType.new,
34
+ 'float' => Avromatic::Model::Types::FloatType.new,
35
+ 'double' => Avromatic::Model::Types::FloatType.new,
36
+ 'null' => Avromatic::Model::Types::NullType.new
37
+ }.deep_freeze
38
+
39
+ def create(schema:, nested_models:, use_custom_types: true)
40
+ if use_custom_types && Avromatic.custom_type_registry.registered?(schema)
41
+ custom_type_configuration = Avromatic.custom_type_registry.fetch(schema)
42
+ default_type = create(
43
+ schema: schema,
44
+ nested_models: nested_models,
45
+ use_custom_types: false
46
+ )
47
+ Avromatic::Model::Types::CustomType.new(
48
+ custom_type_configuration: custom_type_configuration,
49
+ default_type: default_type
50
+ )
51
+ elsif schema.respond_to?(:logical_type) && SINGLETON_TYPES.include?(schema.logical_type)
52
+ SINGLETON_TYPES.fetch(schema.logical_type)
53
+ elsif SINGLETON_TYPES.include?(schema.type)
54
+ SINGLETON_TYPES.fetch(schema.type)
55
+ else
56
+ case schema.type_sym
57
+ when :fixed
58
+ Avromatic::Model::Types::FixedType.new(schema.size)
59
+ when :enum
60
+ Avromatic::Model::Types::EnumType.new(schema.symbols)
61
+ when :array
62
+ value_type = create(schema: schema.items, nested_models: nested_models, use_custom_types: use_custom_types)
63
+ Avromatic::Model::Types::ArrayType.new(value_type: value_type)
64
+ when :map
65
+ value_type = create(schema: schema.values, nested_models: nested_models, use_custom_types: use_custom_types)
66
+ Avromatic::Model::Types::MapType.new(
67
+ key_type: Avromatic::Model::Types::StringType.new,
68
+ value_type: value_type
69
+ )
70
+ when :union
71
+ null_index = schema.schemas.index { |member_schema| member_schema.type_sym == :null }
72
+ raise 'a null type in a union must be the first member' if null_index && null_index > 0
73
+
74
+ member_schemas = schema.schemas.reject { |member_schema| member_schema.type_sym == :null }
75
+ if member_schemas.size == 1
76
+ create(schema: member_schemas.first, nested_models: nested_models)
77
+ else
78
+ member_types = member_schemas.map do |member_schema|
79
+ create(schema: member_schema, nested_models: nested_models, use_custom_types: use_custom_types)
80
+ end
81
+ Avromatic::Model::Types::UnionType.new(member_types: member_types)
82
+ end
83
+ when :record
84
+ record_class = build_nested_model(schema: schema, nested_models: nested_models)
85
+ Avromatic::Model::Types::RecordType.new(record_class: record_class)
86
+ else
87
+ raise ArgumentError.new("Unsupported type #{schema.type_sym}")
88
+ end
89
+ end
90
+ end
91
+
92
+ private
93
+
94
+ def build_nested_model(schema:, nested_models:)
95
+ fullname = nested_models.remove_prefix(schema.fullname)
96
+
97
+ if nested_models.registered?(fullname)
98
+ nested_model = nested_models[fullname]
99
+ unless schema_fingerprint(schema) == schema_fingerprint(nested_model.avro_schema)
100
+ raise "The #{nested_model.name} model is already registered with an incompatible version of the #{schema.fullname} schema"
101
+ end
102
+ nested_model
103
+ else
104
+ Avromatic::Model.model(schema: schema, nested_models: nested_models)
105
+ end
106
+ end
107
+
108
+ def schema_fingerprint(schema)
109
+ if schema.respond_to?(:sha256_resolution_fingerprint)
110
+ schema.sha256_resolution_fingerprint
111
+ else
112
+ schema.sha256_fingerprint
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'avromatic/io'
4
+ require 'avromatic/model/types/abstract_type'
5
+
6
+ module Avromatic
7
+ module Model
8
+ module Types
9
+ class UnionType < AbstractType
10
+ MEMBER_INDEX = ::Avromatic::IO::DatumReader::UNION_MEMBER_INDEX
11
+ attr_reader :member_types, :value_classes, :input_classes
12
+
13
+ def initialize(member_types:)
14
+ @member_types = member_types
15
+ @value_classes = member_types.flat_map(&:value_classes)
16
+ @input_classes = member_types.flat_map(&:input_classes).uniq
17
+ end
18
+
19
+ def name
20
+ "union[#{member_types.map(&:name).join(', ')}]"
21
+ end
22
+
23
+ def coerce(input)
24
+ return input if coerced?(input)
25
+
26
+ result = nil
27
+ if input.is_a?(Hash) && input.key?(MEMBER_INDEX)
28
+ result = member_types[input.delete(MEMBER_INDEX)].coerce(input)
29
+ else
30
+ member_types.find do |member_type|
31
+ result = safe_coerce(member_type, input)
32
+ end
33
+ end
34
+
35
+ unless result
36
+ raise ArgumentError.new("Could not coerce '#{input.inspect}' to #{name}")
37
+ end
38
+
39
+ result
40
+ end
41
+
42
+ def coerced?(value)
43
+ value.nil? || member_types.any? do |member_type|
44
+ member_type.coerced?(value)
45
+ end
46
+ end
47
+
48
+ def coercible?(input)
49
+ coerced?(input) || member_types.any? do |member_type|
50
+ member_type.coercible?(input)
51
+ end
52
+ end
53
+
54
+ def serialize(value, strict:)
55
+ # Avromatic does not treat the null of an optional field as part of the union
56
+ return nil if value.nil?
57
+
58
+ member_index = find_index(value)
59
+ if member_index.nil?
60
+ raise ArgumentError.new("Expected #{value.inspect} to be one of #{value_classes.map(&:name)}")
61
+ end
62
+
63
+ hash = member_types[member_index].serialize(value, strict: strict)
64
+ if !strict && Avromatic.use_custom_datum_writer && value.is_a?(Avromatic::Model::Attributes)
65
+ hash[Avromatic::IO::UNION_MEMBER_INDEX] = member_index
66
+ end
67
+ hash
68
+ end
69
+
70
+ private
71
+
72
+ def find_index(value)
73
+ # TODO: Cache this?
74
+ member_types.find_index do |member_type|
75
+ member_type.value_classes.any? { |value_class| value.is_a?(value_class) }
76
+ end
77
+ end
78
+
79
+ def safe_coerce(member_type, input)
80
+ member_type.coerce(input) if member_type.coercible?(input)
81
+ rescue StandardError
82
+ nil
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Avromatic
4
+ module Model
5
+ class UnknownAttributeError < StandardError
6
+ attr_reader :unknown_attributes, :allowed_attributes
7
+
8
+ def initialize(message, unknown_attributes:, allowed_attributes:)
9
+ super(message)
10
+ @unknown_attributes = unknown_attributes
11
+ @allowed_attributes = allowed_attributes
12
+ end
13
+ end
14
+ end
15
+ end