paquito 0.1.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.
@@ -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