paquito 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.github/workflows/ci.yml +26 -0
- data/.gitignore +8 -0
- data/.rubocop.yml +5 -0
- data/Gemfile +21 -0
- data/Gemfile.lock +78 -0
- data/LICENSE.txt +21 -0
- data/README.md +175 -0
- data/Rakefile +19 -0
- data/bin/console +15 -0
- data/bin/setup +8 -0
- data/dev.yml +12 -0
- data/lib/paquito/active_record_coder.rb +141 -0
- data/lib/paquito/allow_nil.rb +19 -0
- data/lib/paquito/codec_factory.rb +39 -0
- data/lib/paquito/coder_chain.rb +21 -0
- data/lib/paquito/comment_prefix_version.rb +54 -0
- data/lib/paquito/conditional_compressor.rb +42 -0
- data/lib/paquito/deflater.rb +17 -0
- data/lib/paquito/errors.rb +19 -0
- data/lib/paquito/safe_yaml.rb +74 -0
- data/lib/paquito/serialized_column.rb +52 -0
- data/lib/paquito/single_byte_prefix_version.rb +27 -0
- data/lib/paquito/struct.rb +87 -0
- data/lib/paquito/translate_errors.rb +25 -0
- data/lib/paquito/typed_struct.rb +53 -0
- data/lib/paquito/types/active_record_packer.rb +51 -0
- data/lib/paquito/types.rb +242 -0
- data/lib/paquito/version.rb +5 -0
- data/lib/paquito.rb +47 -0
- data/paquito.gemspec +32 -0
- metadata +90 -0
@@ -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
|