paquito 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Paquito
4
+ class ConditionalCompressor
5
+ UNCOMPRESSED = 0
6
+ COMPRESSED = 1
7
+
8
+ def initialize(compressor, compress_threshold)
9
+ @compressor = Paquito.cast(compressor)
10
+ @compress_threshold = compress_threshold
11
+ end
12
+
13
+ def dump(uncompressed)
14
+ uncompressed_size = uncompressed.bytesize
15
+ version = UNCOMPRESSED
16
+ value = uncompressed
17
+
18
+ if @compress_threshold && uncompressed_size > @compress_threshold
19
+ compressed = @compressor.dump(uncompressed)
20
+ if compressed.bytesize < uncompressed_size
21
+ version = COMPRESSED
22
+ value = compressed
23
+ end
24
+ end
25
+
26
+ version.chr(Encoding::BINARY) << value
27
+ end
28
+
29
+ def load(payload)
30
+ payload_version = payload.getbyte(0)
31
+ data = payload.byteslice(1..-1)
32
+ case payload_version
33
+ when UNCOMPRESSED
34
+ data
35
+ when COMPRESSED
36
+ @compressor.load(data)
37
+ else
38
+ raise ArgumentError, "invalid ConditionalCompressor version"
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Paquito
4
+ class Deflater
5
+ def initialize(deflater)
6
+ @deflater = deflater
7
+ end
8
+
9
+ def dump(serial)
10
+ @deflater.deflate(serial)
11
+ end
12
+
13
+ def load(payload)
14
+ @deflater.inflate(payload)
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Paquito
4
+ Error = Class.new(StandardError)
5
+
6
+ class PackError < Error
7
+ attr_reader :receiver
8
+ def initialize(msg, receiver = nil)
9
+ super(msg)
10
+ @receiver = receiver
11
+ end
12
+ end
13
+
14
+ UnpackError = Class.new(Error)
15
+ ClassMissingError = Class.new(Error)
16
+ UnsupportedType = Class.new(Error)
17
+ UnsupportedCodec = Class.new(Error)
18
+ VersionMismatchError = Class.new(Error)
19
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Paquito
4
+ class SafeYAML
5
+ ALL_SYMBOLS = [].freeze # Restricting symbols isn't really useful since symbols are no longer immortal
6
+ BASE_PERMITTED_CLASSNAMES = ["TrueClass", "FalseClass", "NilClass", "Numeric", "String", "Array", "Hash",
7
+ "Integer", "Float",].freeze
8
+
9
+ def initialize(permitted_classes: [], deprecated_classes: [], aliases: false)
10
+ permitted_classes += BASE_PERMITTED_CLASSNAMES
11
+ @dumpable_classes = permitted_classes
12
+ @loadable_classes = permitted_classes + deprecated_classes
13
+ @aliases = aliases
14
+
15
+ @dump_options = {
16
+ permitted_classes: permitted_classes,
17
+ permitted_symbols: ALL_SYMBOLS,
18
+ aliases: true,
19
+ line_width: -1, # Disable YAML line-wrapping because it causes extremely obscure issues.
20
+ }.freeze
21
+ end
22
+
23
+ def load(serial)
24
+ Psych.safe_load(
25
+ serial,
26
+ permitted_classes: @loadable_classes,
27
+ permitted_symbols: ALL_SYMBOLS,
28
+ aliases: @aliases,
29
+ )
30
+ rescue Psych::DisallowedClass => psych_error
31
+ raise UnsupportedType, psych_error.message
32
+ rescue Psych::Exception => psych_error
33
+ raise UnpackError, psych_error.message
34
+ end
35
+
36
+ def dump(obj)
37
+ visitor = RestrictedYAMLTree.create(@dump_options)
38
+ visitor << obj
39
+ visitor.tree.yaml(nil, @dump_options)
40
+ rescue Psych::Exception => psych_error
41
+ raise PackError, psych_error.message
42
+ end
43
+
44
+ class RestrictedYAMLTree < Psych::Visitors::YAMLTree
45
+ class DispatchCache
46
+ def initialize(visitor, cache, permitted_classes)
47
+ @visitor = visitor
48
+ @cache = cache
49
+ @permitted_classes = permitted_classes
50
+
51
+ @permitted_cache = Hash.new do |h, klass|
52
+ unless @permitted_classes.include?(klass.name)
53
+ raise UnsupportedType, "Tried to dump unspecified class: #{klass.name.inspect}"
54
+ end
55
+
56
+ h[klass] = true
57
+ end.compare_by_identity
58
+ end
59
+
60
+ def [](klass)
61
+ if @permitted_cache[klass]
62
+ @cache[klass]
63
+ end
64
+ end
65
+ end
66
+
67
+ def initialize(...)
68
+ super
69
+ @permitted_classes = Set.new(@options[:permitted_classes])
70
+ @dispatch_cache = DispatchCache.new(self, @dispatch_cache, @permitted_classes)
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Paquito
4
+ class SerializedColumn
5
+ def initialize(coder, type = nil, attribute_name: nil)
6
+ @coder = coder
7
+ @type = type
8
+ @attribute_name = attribute_name || "Attribute"
9
+ check_arity_of_constructor
10
+ @default_value = type&.new
11
+ end
12
+
13
+ def object_class
14
+ @type || Object
15
+ end
16
+
17
+ def load(payload)
18
+ return @type&.new if payload.nil?
19
+
20
+ object = @coder.load(payload)
21
+ check_type(object)
22
+ object || @type&.new
23
+ end
24
+
25
+ def dump(object)
26
+ return if object.nil? || object == @default_value
27
+
28
+ check_type(object)
29
+ @coder.dump(object)
30
+ end
31
+
32
+ private
33
+
34
+ def check_arity_of_constructor
35
+ load(nil)
36
+ rescue ArgumentError
37
+ raise ArgumentError,
38
+ "Cannot serialize #{object_class}. Classes passed to `serialize` must have a 0 argument constructor."
39
+ end
40
+
41
+ def default_value?(object)
42
+ object == @type&.new
43
+ end
44
+
45
+ def check_type(object)
46
+ unless @type.nil? || object.is_a?(@type) || object.nil?
47
+ raise ActiveRecord::SerializationTypeMismatch, "#{@attribute_name} was supposed to be a #{object_class}, " \
48
+ "but was a #{object.class}. -- #{object.inspect}"
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Paquito
4
+ class SingleBytePrefixVersion
5
+ def initialize(current_version, coders)
6
+ @current_version = current_version
7
+ @coders = coders.transform_values { |c| Paquito.cast(c) }
8
+ @current_coder = coders.fetch(current_version)
9
+ end
10
+
11
+ def dump(object)
12
+ @current_version.chr(Encoding::BINARY) << @current_coder.dump(object)
13
+ end
14
+
15
+ def load(payload)
16
+ payload_version = payload.getbyte(0)
17
+ unless payload_version
18
+ raise UnsupportedCodec, "Missing version byte."
19
+ end
20
+
21
+ coder = @coders.fetch(payload_version) do
22
+ raise UnsupportedCodec, "Unsupported packer version #{payload_version}"
23
+ end
24
+ coder.load(payload.byteslice(1..-1))
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+ require "paquito/errors"
3
+
4
+ module Paquito
5
+ # To make a Struct class cacheable, include Paquito::Struct:
6
+ #
7
+ # MyStruct = Struct.new(:foo, :bar)
8
+ # MyStruct.include(Paquito::Struct)
9
+ #
10
+ # Alternatively, declare the struct with Paquito::Struct#new:
11
+ #
12
+ # MyStruct = Paquito::Struct.new(:foo, :bar)
13
+ #
14
+ # The struct defines #as_pack and .from_pack methods:
15
+ #
16
+ # my_struct = MyStruct.new("foo", "bar")
17
+ # my_struct.as_pack
18
+ # => [26450, "foo", "bar"]
19
+ #
20
+ # MyStruct.from_pack([26450, "foo", "bar"])
21
+ # => #<struct FooStruct foo="foo", bar="bar">
22
+ #
23
+ # The Paquito::Struct module can be used in non-Struct classes, so long
24
+ # as the class:
25
+ #
26
+ # - defines a #values instance method
27
+ # - defines a .members class method
28
+ # - has an #initialize method that accepts the values as its arguments
29
+ #
30
+ # If the last condition is _not_ met, you can override .from_pack on the
31
+ # class and initialize the instance however you like, optionally using the
32
+ # private extract_packed_values method to extract values from the payload.
33
+ #
34
+ module Struct
35
+ class << self
36
+ def included(base)
37
+ base.class_eval do
38
+ @__kw_init__ = inspect.include?("keyword_init: true")
39
+ end
40
+ base.extend(ClassMethods)
41
+ end
42
+ end
43
+
44
+ def as_pack
45
+ [self.class.pack_digest, *values]
46
+ end
47
+
48
+ class << self
49
+ def new(*members, keyword_init: false, &block)
50
+ struct = ::Struct.new(*members, keyword_init: keyword_init, &block)
51
+ struct.include(Paquito::Struct)
52
+ struct
53
+ end
54
+
55
+ def digest(attr_names)
56
+ ::Digest::MD5.digest(attr_names.map(&:to_s).join(",")).unpack1("s")
57
+ end
58
+ end
59
+
60
+ module ClassMethods
61
+ def from_pack(packed)
62
+ values = extract_packed_values(packed, as_hash: @__kw_init__)
63
+
64
+ if @__kw_init__
65
+ new(**values)
66
+ else
67
+ new(*values)
68
+ end
69
+ end
70
+
71
+ def pack_digest
72
+ @pack_digest ||= ::Paquito::Struct.digest(members)
73
+ end
74
+
75
+ private
76
+
77
+ def extract_packed_values(packed, as_hash:)
78
+ digest, *values = packed
79
+ if pack_digest != digest
80
+ raise(VersionMismatchError, "#{self} digests do not match")
81
+ end
82
+
83
+ as_hash ? members.zip(values).to_h : values
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Paquito
4
+ class TranslateErrors
5
+ def initialize(coder)
6
+ @coder = coder
7
+ end
8
+
9
+ def dump(object)
10
+ @coder.dump(object)
11
+ rescue Paquito::Error
12
+ raise
13
+ rescue => error
14
+ raise PackError, error.message
15
+ end
16
+
17
+ def load(payload)
18
+ @coder.load(payload)
19
+ rescue Paquito::Error
20
+ raise
21
+ rescue => error
22
+ raise UnpackError, error.message
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,53 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ return unless defined?(T::Props)
5
+
6
+ module Paquito
7
+ # To make a T::Struct class serializable, include Paquito::TypedStruct:
8
+ #
9
+ # class MyStruct < T::Struct
10
+ # include Paquito::TypedStruct
11
+ #
12
+ # prop :foo, String
13
+ # prop :bar, Integer
14
+ # end
15
+ #
16
+ # my_struct = MyStruct.new(foo: "foo", bar: 1)
17
+ # my_struct.as_pack
18
+ # => [26450, "foo", 1]
19
+ #
20
+ # MyStruct.from_pack([26450, "foo", 1])
21
+ # => <MyStruct bar=1, foo="foo">
22
+ #
23
+ module TypedStruct
24
+ extend T::Sig
25
+ include T::Props::Plugin
26
+
27
+ sig { returns(Array).checked(:never) }
28
+ def as_pack
29
+ decorator = self.class.decorator
30
+ props = decorator.props.keys
31
+ values = props.map { |prop| decorator.get(self, prop) }
32
+ [self.class.pack_digest, *values]
33
+ end
34
+
35
+ module ClassMethods
36
+ extend T::Sig
37
+
38
+ sig { params(packed: Array).returns(T.untyped).checked(:never) }
39
+ def from_pack(packed)
40
+ digest, *values = packed
41
+ if pack_digest != digest
42
+ raise(VersionMismatchError, "#{self} digests do not match")
43
+ end
44
+ new(**props.keys.zip(values).to_h)
45
+ end
46
+
47
+ sig { returns(Integer).checked(:never) }
48
+ def pack_digest
49
+ @pack_digest ||= Paquito::Struct.digest(props.keys)
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "paquito/errors"
4
+ require "paquito/active_record_coder"
5
+
6
+ module Paquito
7
+ module Types
8
+ class ActiveRecordPacker
9
+ factory = MessagePack::Factory.new
10
+ # These are the types available when packing/unpacking ActiveRecord::Base instances.
11
+ Types.register(factory, [Symbol, Time, DateTime, Date, BigDecimal, ActiveSupport::TimeWithZone])
12
+ FACTORY = factory
13
+ # Raise on any undeclared type
14
+ factory.register_type(
15
+ 0x7f,
16
+ Object,
17
+ packer: ->(value) { raise PackError.new("undeclared type", value) },
18
+ unpacker: ->(*) {}
19
+ )
20
+
21
+ core_types = [String, Integer, TrueClass, FalseClass, NilClass, Float, Array, Hash]
22
+ ext_types = FACTORY.registered_types.map { |t| t[:class] }
23
+ VALID_CLASSES = core_types + ext_types
24
+
25
+ def self.dump(value)
26
+ coded = ActiveRecordCoder.dump(value)
27
+ FACTORY.dump(coded)
28
+ rescue NoMethodError, PackError => e
29
+ raise unless PackError === e || e.name == :to_msgpack
30
+
31
+ class_name = value.class.name
32
+ receiver_name = e.receiver.class.name
33
+ error_attrs = coded[1][1].select { |_, attr_value| VALID_CLASSES.exclude?(attr_value.class) }
34
+
35
+ Rails.logger.warn(<<~LOG.squish)
36
+ [MessagePackCodecTypes]
37
+ Failed to encode record with ActiveRecordCoder
38
+ class=#{class_name}
39
+ error_class=#{receiver_name}
40
+ error_attrs=#{error_attrs.keys.join(", ")}
41
+ LOG
42
+
43
+ raise PackError.new("failed to pack ActiveRecord object", e.receiver)
44
+ end
45
+
46
+ def self.load(value)
47
+ ActiveRecordCoder.load(FACTORY.load(value))
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,242 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "paquito/errors"
4
+
5
+ module Paquito
6
+ module Types
7
+ autoload :ActiveRecordPacker, "paquito/types/active_record_packer"
8
+
9
+ # Do not change those formats, this would break current codecs.
10
+ TIME_FORMAT = "q< L<"
11
+ TIME_WITH_ZONE_FORMAT = "q< L< a*"
12
+ DATE_TIME_FORMAT = "s< C C C C q< L< c C"
13
+ DATE_FORMAT = "s< C C"
14
+
15
+ SERIALIZE_METHOD = :as_pack
16
+ SERIALIZE_PROC = SERIALIZE_METHOD.to_proc
17
+ DESERIALIZE_METHOD = :from_pack
18
+
19
+ class CustomTypesRegistry
20
+ class << self
21
+ def packer(value)
22
+ packers.fetch(klass = value.class) do
23
+ if packable?(value) && unpackable?(klass)
24
+ @packers[klass] = SERIALIZE_PROC
25
+ end
26
+ end
27
+ end
28
+
29
+ def unpacker(klass)
30
+ unpackers.fetch(klass) do
31
+ if unpackable?(klass)
32
+ @unpackers[klass] = klass.method(DESERIALIZE_METHOD).to_proc
33
+ end
34
+ end
35
+ end
36
+
37
+ def register(klass, packer: nil, unpacker:)
38
+ if packer
39
+ raise ArgumentError, "packer for #{klass} already defined" if packers.key?(klass)
40
+ packers[klass] = packer
41
+ end
42
+
43
+ raise ArgumentError, "unpacker for #{klass} already defined" if unpackers.key?(klass)
44
+ unpackers[klass] = unpacker
45
+
46
+ self
47
+ end
48
+
49
+ private
50
+
51
+ def packable?(value)
52
+ value.class.method_defined?(SERIALIZE_METHOD) ||
53
+ raise(PackError.new("#{value.class} is not serializable", value))
54
+ end
55
+
56
+ def unpackable?(klass)
57
+ klass.respond_to?(DESERIALIZE_METHOD) ||
58
+ raise(UnpackError, "#{klass} is not deserializable")
59
+ end
60
+
61
+ def packers
62
+ @packers ||= {}
63
+ end
64
+
65
+ def unpackers
66
+ @unpackers ||= {}
67
+ end
68
+ end
69
+ end
70
+
71
+ # Do not change any #code, this would break current codecs.
72
+ # New types can be added as long as they have unique #code.
73
+ TYPES = {
74
+ "Symbol" => {
75
+ code: 0x00,
76
+ packer: :to_s,
77
+ unpacker: :to_sym,
78
+ }.freeze,
79
+ "Time" => {
80
+ code: 0x01,
81
+ packer: ->(value) do
82
+ rational = value.utc.to_r
83
+ [rational.numerator, rational.denominator].pack(TIME_FORMAT)
84
+ end,
85
+ unpacker: ->(value) do
86
+ numerator, denominator = value.unpack(TIME_FORMAT)
87
+ Time.at(Rational(numerator, denominator)).utc
88
+ end,
89
+ }.freeze,
90
+ "DateTime" => {
91
+ code: 0x02,
92
+ packer: ->(value) do
93
+ sec = value.sec + value.sec_fraction
94
+ offset = value.offset
95
+ [
96
+ value.year,
97
+ value.month,
98
+ value.day,
99
+ value.hour,
100
+ value.minute,
101
+ sec.numerator,
102
+ sec.denominator,
103
+ offset.numerator,
104
+ offset.denominator,
105
+ ].pack(DATE_TIME_FORMAT)
106
+ end,
107
+ unpacker: ->(value) do
108
+ (
109
+ year,
110
+ month,
111
+ day,
112
+ hour,
113
+ minute,
114
+ sec_numerator,
115
+ sec_denominator,
116
+ offset_numerator,
117
+ offset_denominator,
118
+ ) = value.unpack(DATE_TIME_FORMAT)
119
+ DateTime.new( # rubocop:disable Style/DateTime
120
+ year,
121
+ month,
122
+ day,
123
+ hour,
124
+ minute,
125
+ Rational(sec_numerator, sec_denominator),
126
+ Rational(offset_numerator, offset_denominator),
127
+ )
128
+ end,
129
+ }.freeze,
130
+ "Date" => {
131
+ code: 0x03,
132
+ packer: ->(value) do
133
+ [value.year, value.month, value.day].pack(DATE_FORMAT)
134
+ end,
135
+ unpacker: ->(value) do
136
+ year, month, day = value.unpack(DATE_FORMAT)
137
+ Date.new(year, month, day)
138
+ end,
139
+ }.freeze,
140
+ "BigDecimal" => {
141
+ code: 0x04,
142
+ packer: :_dump,
143
+ unpacker: BigDecimal.method(:_load),
144
+ }.freeze,
145
+ # Range => { code: 0x05 }, do not recycle that code
146
+ "ActiveRecord::Base" => {
147
+ code: 0x6,
148
+ packer: ->(value) { ActiveRecordPacker.dump(value) },
149
+ unpacker: ->(value) { ActiveRecordPacker.load(value) },
150
+ }.freeze,
151
+ "ActiveSupport::HashWithIndifferentAccess" => {
152
+ code: 0x7,
153
+ packer: ->(factory, value) do
154
+ unless value.instance_of?(ActiveSupport::HashWithIndifferentAccess)
155
+ raise PackError.new("cannot pack HashWithIndifferentClass subclass", value)
156
+ end
157
+ factory.dump(value.to_h)
158
+ end,
159
+ unpacker: ->(factory, value) { HashWithIndifferentAccess.new(factory.load(value)) },
160
+ },
161
+ "ActiveSupport::TimeWithZone" => {
162
+ code: 0x8,
163
+ packer: ->(value) do
164
+ [
165
+ value.utc.to_i,
166
+ (value.time.sec_fraction * 1_000_000_000).to_i,
167
+ value.time_zone.name,
168
+ ].pack(TIME_WITH_ZONE_FORMAT)
169
+ end,
170
+ unpacker: ->(value) do
171
+ sec, nsec, time_zone_name = value.unpack(TIME_WITH_ZONE_FORMAT)
172
+ time = Time.at(sec, nsec, :nsec, in: 0).utc
173
+ time_zone = ::Time.find_zone(time_zone_name)
174
+ ActiveSupport::TimeWithZone.new(time, time_zone)
175
+ end,
176
+ },
177
+ "Set" => {
178
+ code: 0x9,
179
+ packer: ->(factory, value) { factory.dump(value.to_a) },
180
+ unpacker: ->(factory, value) { factory.load(value).to_set },
181
+ },
182
+ # Object => { code: 0x7f }, reserved for serializable Object type
183
+ }.freeze
184
+
185
+ class << self
186
+ def register(factory, types)
187
+ types.each do |type|
188
+ name = type.name
189
+
190
+ # Up to Rails 7 ActiveSupport::TimeWithZone#name returns "Time"
191
+ if name == "Time" && defined?(ActiveSupport::TimeWithZone)
192
+ name = "ActiveSupport::TimeWithZone" if type == ActiveSupport::TimeWithZone
193
+ end
194
+
195
+ type_attributes = TYPES.fetch(name)
196
+ factory.register_type(
197
+ type_attributes.fetch(:code),
198
+ type,
199
+ packer: curry_callback(type_attributes.fetch(:packer), factory),
200
+ unpacker: curry_callback(type_attributes.fetch(:unpacker), factory),
201
+ )
202
+ end
203
+ end
204
+
205
+ def register_serializable_type(factory)
206
+ factory.register_type(
207
+ 0x7f,
208
+ Object,
209
+ packer: ->(value) do
210
+ packer = CustomTypesRegistry.packer(value)
211
+ class_name = value.class.to_s
212
+ factory.dump([packer.call(value), class_name])
213
+ end,
214
+ unpacker: ->(value) do
215
+ payload, class_name = factory.load(value)
216
+
217
+ begin
218
+ klass = Object.const_get(class_name)
219
+ rescue NameError
220
+ raise ClassMissingError, "missing #{class_name} class"
221
+ end
222
+
223
+ unpacker = CustomTypesRegistry.unpacker(klass)
224
+ unpacker.call(payload)
225
+ end
226
+ )
227
+ end
228
+
229
+ def define_custom_type(klass, packer: nil, unpacker:)
230
+ CustomTypesRegistry.register(klass, packer: packer, unpacker: unpacker)
231
+ end
232
+
233
+ private
234
+
235
+ def curry_callback(callback, factory)
236
+ return callback.to_proc if callback.is_a?(Symbol)
237
+ return callback if callback.arity == 1
238
+ callback.curry.call(factory)
239
+ end
240
+ end
241
+ end
242
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Paquito
4
+ VERSION = "0.1.0"
5
+ end