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.
- 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
|