# FIRM::Serializable - serializable mixin
# Copyright (c) M.J.N. Corino, The Netherlands
require 'set'
module FIRM
# Mixin module providing (de-)serialization support for user defined classes.
module Serializable
class Exception < RuntimeError; end
class Property
def initialize(klass, prop, proc=nil, force: false, handler: nil, &block)
::Kernel.raise ArgumentError, "Invalid property id [#{prop}]" unless ::String === prop || ::Symbol === prop
::Kernel.raise ArgumentError, "Duplicate property id [#{prop}]" if klass.has_serializer_property?(prop)
@klass = klass
@id = prop.to_sym
@forced = force
if block || handler
if handler
::Kernel.raise ArgumentError,
"Invalid property handler #{handler} for #{prop}" unless ::Proc === handler || ::Symbol === handler
if handler.is_a?(::Proc)
::Kernel.raise ArgumentError, "Invalid property block #{proc} for #{prop}" unless block.arity == -3
@getter = ->(obj) { handler.call(@id, obj) }
@setter = ->(obj, val) { handler.call(@id, obj, val) }
else
@getter = ->(obj) { obj.send(handler, @id) }
@setter = ->(obj, val) { obj.send(handler, @id, val) }
end
else
# any property block MUST accept 2 or 3 args; property name, instance and value (for setter)
::Kernel.raise ArgumentError, "Invalid property block #{proc} for #{prop}" unless block.arity == -3
@getter = ->(obj) { block.call(@id, obj) }
@setter = ->(obj, val) { block.call(@id, obj, val) }
end
elsif proc
::Kernel.raise ArgumentError,
"Invalid property proc #{proc} for #{prop}" unless ::Proc === proc || ::Symbol === proc
if ::Proc === proc
# any property proc should be callable with a single arg (instance)
@getter = proc
# a property proc combining getter/setter functionality should accept a single or more args (instance + value)
@setter = (proc.arity == -2) ? proc : nil
else
@getter = ->(obj) { obj.send(proc) }
@setter = ->(obj, val) { obj.send(proc, val) }
end
end
end
attr_reader :id
def serialize(obj, data, excludes)
unless excludes.include?(@id)
val = getter.call(obj)
unless Serializable === val && val.serialize_disabled? && !@forced
data[@id] = case val
when ::Array
val.select { |elem| !(Serializable === elem && elem.serialize_disabled?) }
when ::Set
::Set.new(val.select { |elem| !(Serializable === elem && elem.serialize_disabled?) })
else
val
end
end
end
end
def deserialize(obj, data)
if data.has_key?(@id)
setter.call(obj, data[@id])
end
end
def get(obj)
getter.call(obj)
end
def get_method(id)
begin
@klass.instance_method(id)
rescue NameError
nil
end
end
private :get_method
def getter
unless @getter
inst_meth = get_method(@id)
inst_meth = get_method("get_#{@id}") unless inst_meth
if inst_meth
@getter = ->(obj) { inst_meth.bind(obj).call }
else
return self.method(:getter_fail)
end
end
@getter
end
private :getter
def setter
unless @setter
inst_meth = get_method("#{@id}=")
inst_meth = get_method("set_#{@id}") unless inst_meth
unless inst_meth
im = get_method(@id)
if im && im.arity == -1
inst_meth = im
else
inst_meth = nil
end
end
if inst_meth
@setter = ->(obj, val) { inst_meth.bind(obj).call(val) }
else
return self.method(:setter_noop)
end
end
@setter
end
private :setter
def getter_fail(_obj)
::Kernel.raise Serializable::Exception, "Missing getter for property #{@id} of #{@klass}"
end
private :getter_fail
def setter_noop(_, _)
# do nothing
end
private :setter_noop
end
# Serializable unique ids.
# This class makes sure to maintain uniqueness across serialization/deserialization cycles
# and keeps all shared instances within a single (serialized/deserialized) object set in
# sync.
class ID; end
class << self
TLS_VARS_KEY = :firm_tls_vars.freeze
def tls_vars
Thread.current[TLS_VARS_KEY] ||= {}
end
def serializables
@serializables ||= ::Set.new
end
def formatters
@formatters ||= {}
end
private :formatters
# Registers a serialization formatting engine
# @param [Symbol,String] format format id
# @param [Object] engine formatting engine
def register(format, engine)
if formatters.has_key?(format.to_s.downcase)
::Kernel.raise ArgumentError,
"Duplicate serialization formatter registration for #{format}"
end
formatters[format.to_s.downcase] = engine
end
# Return a serialization formatting engine
# @param [Symbol,String] format format id
# @return [Object] formatting engine
def [](format)
::Kernel.raise ArgumentError, "Format #{format} is not supported." unless formatters.has_key?(format.to_s.downcase)
formatters[format.to_s.downcase]
end
# Return the default output format symbol id (:json, :yaml, :xml).
# By default returns :json.
# @return [Symbol]
def default_format
@default_format ||= :json
end
# Set the default output format.
# @param [Symbol] format Output format id. By default :json, :yaml and :xml (if nokogiri gem is installed) are supported.
# @return [Symbol] default format
def default_format=(format)
@default_format = format
end
end
# This module provides alias (de-)serialization management functionality for
# output engines that do not provide this support out of the box.
module AliasManagement
TLS_ANCHOR_OBJECTS_KEY = :firm_anchors_objects.freeze
private_constant :TLS_ANCHOR_OBJECTS_KEY
TLS_ALIAS_STACK_KEY = :firm_anchor_reference_stack.freeze
private_constant :TLS_ALIAS_STACK_KEY
def anchor_object_registry_stack
Serializable.tls_vars[TLS_ANCHOR_OBJECTS_KEY] ||= []
end
private :anchor_object_registry_stack
def start_anchor_object_registry
anchor_object_registry_stack.push({})
end
def clear_anchor_object_registry
anchor_object_registry_stack.pop
end
def anchor_object_registry
anchor_object_registry_stack.last
end
private :anchor_object_registry
def class_anchor_objects(klass)
anchor_object_registry[klass] ||= {}
end
private :class_anchor_objects
# Registers a new anchor object.
# @param [Object] object anchor instance
# @param [Object] data serialized property collection object
# @return [Object] serialized property collection object
def register_anchor_object(object, data)
anchors = class_anchor_objects(object.class)
raise Serializable::Exception, "Duplicate anchor creation for #{object}" if anchors.has_key?(object.object_id)
anchors[object.object_id] = data
end
# Returns true if the object has an anchor registration, false otherwise.
# @return [Boolean]
def anchored?(object)
class_anchor_objects(object.class).has_key?(object.object_id)
end
# Returns the anchor id if anchored, nil otherwise.
# @param [Object] object anchor instance
# @return [Integer, nil]
def get_anchor(object)
anchored?(object) ? object.object_id : nil
end
# Retrieves the anchor serialization collection data for an anchored object.
# Returns nil if the object is not anchored.
# @return [nil,Object]
def get_anchor_data(object)
anchors = class_anchor_objects(object.class)
anchors[object.object_id]
end
def anchor_references_stack
Serializable.tls_vars[TLS_ALIAS_STACK_KEY] ||= []
end
private :anchor_references_stack
def start_anchor_references
anchor_references_stack.push({})
end
def clear_anchor_references
anchor_references_stack.pop
end
def anchor_references
anchor_references_stack.last
end
private :anchor_references
def class_anchor_references(klass)
anchor_references[klass] ||= {}
end
private :class_anchor_references
# Registers a restored anchor object and it's ID.
# @param [Integer] id anchor ID
# @param [Object] object anchor instance
# @return [Object] anchor instance
def restore_anchor(id, object)
class_anchor_references(object.class)[id] = object
end
# Returns true if the anchor object for the given class and id has been restored, false otherwise.
# @param [Class] klass aliasable class of the anchor instance
# @param [Integer] id anchor id
# @return [Boolean]
def restored?(klass, id)
class_anchor_references(klass).has_key?(id)
end
# Resolves a referenced anchor instance.
# Returns the instance if found, nil otherwise.
# @param [Class] klass aliasable class of the anchor instance
# @param [Integer] id anchor id
# @return [nil,Object]
def resolve_anchor(klass, id)
class_anchor_references(klass)[id]
end
end
# Mixin module for classes that get FIRM::Serializable included.
# This module is used to extend the class methods of the serializable class.
module SerializeClassMethods
# Adds (a) serializable property(-ies) for instances of his class (and derived classes)
# @overload property(*props, force: false)
# Specifies one or more serialized properties.
# The serialization framework will determine the availability of setter and getter methods
# automatically by looking for methods "#{prop_id}=(v)"
, "#set_{prop_id}(v)"
or "#{prop_id}(v)"
# for setters and "#{prop_id}()"
or "#get_{prop_id}"
for getters.
# @param [Symbol,String] props one or more ids of serializable properties
# @param [Boolean] force overrides any #disable_serialize for the properties specified
# @return [void]
# @overload property(hash, force: false)
# Specifies one or more serialized properties with associated setter/getter method ids/procs/lambda-s.
# @example
# property(
# prop_a: ->(obj, *val) {
# obj.my_prop_a_setter(val.first) unless val.empty?
# obj.my_prop_a_getter
# },
# prop_b: Proc.new { |obj, *val|
# obj.my_prop_b_setter(val.first) unless val.empty?
# obj.my_prop_b_getter
# },
# prop_c: :serialization_method)
# Procs with setter support MUST accept 1 or 2 arguments (1 for getter, 2 for setter) where the first
# argument will always be the property owner's object instance and the second (in case of a setter proc) the
# value to restore.
# @note Use `*val` to specify the optional value argument for setter requests instead of `val=nil`
# to be able to support setting explicit nil values.
# @param [Hash] hash a hash of pairs of property ids and getter/setter procs
# @param [Boolean] force overrides any #disable_serialize for the properties specified
# @return [void]
# @overload property(*props, force: false, handler: nil, &block)
# Specifies one or more serialized properties with a getter/setter handler proc/method/block.
# The getter/setter proc or block should accept either 2 (property id and object for getter) or 3 arguments
# (property id, object and value for setter) and is assumed to handle getter/setter requests
# for all specified properties.
# The getter/setter method should accept either 1 (property id for getter) or 2 arguments
# (property id and value for setter) and is assumed to handle getter/setter requests
# for all specified properties.
# @example
# property(:property_a, :property_b, :property_c) do |id, obj, *val|
# case id
# when :property_a
# ...
# when :property_b
# ...
# when :property_c
# ...
# end
# end
# @note Use `*val` to specify the optional value argument for setter requests instead of `val=nil`
# to be able to support setting explicit nil values.
# @param [Symbol,String] props one or more ids of serializable properties
# @param [Boolean] force overrides any #disable_serialize for the properties specified
# @yieldparam [Symbol,String] id property id
# @yieldparam [Object] obj object instance
# @yieldparam [Object] val optional property value to set in case of setter request
# @return [void]
def property(*props, **kwargs, &block)
forced = !!kwargs.delete(:force)
if block || kwargs[:handler]
props.each do |prop|
serializer_properties << Property.new(self, prop, force: forced, handler: kwargs[:handler], &block)
end
else
props.flatten.each do |prop|
if ::Hash === prop
prop.each_pair do |pn, pp|
serializer_properties << Property.new(self, pn, pp, force: forced)
end
else
serializer_properties << Property.new(self, prop, force: forced)
end
end
unless kwargs.empty?
kwargs.each_pair do |pn, pp|
serializer_properties << Property.new(self, pn, pp, force: forced)
end
end
end
end
alias :properties :property
alias :contains :property
# Excludes a serializable property for instances of this class.
# (mostly/only useful to exclude properties from base classes which
# do not require serialization for derived class)
# @param [Symbol,String] props one or more ids of serializable properties
# @return [void]
def excluded_property(*props)
excluded_serializer_properties.merge props.flatten.collect { |prop| prop }
end
alias :excluded_properties :excluded_property
alias :excludes :excluded_property
# Defines a finalizer method/proc/block to be called after all properties
# have been deserialized and restored.
# Procs or blocks will be called with the deserialized object as the single argument.
# Unbound methods will be bound to the deserialized object before calling.
# Explicitly specifying nil will undefine the finalizer.
# @param [Symbol, String, Proc, UnboundMethod, nil] meth name of instance method, proc or method to call for finalizing
# @yieldparam [Object] obj deserialized object to finalize
# @return [void]
def define_deserialize_finalizer(meth=nil, &block)
if block and meth.nil?
# the given block should expect and use the given object instance
set_deserialize_finalizer(block)
elsif meth and block.nil?
h_meth = case meth
when ::Symbol, ::String
Serializable::MethodResolver.new(self, meth)
when ::Proc
# check arity == 1
if meth.arity != 1
Kernel.raise ArgumentError,
"Deserialize finalizer Proc should expect a single argument",
caller
end
meth
when ::UnboundMethod
# check arity == 0
if meth.arity>0
Kernel.raise ArgumentError,
"Deserialize finalizer method should not expect any argument",
caller
end
->(obj) { meth.bind(obj).call }
else
Kernel.raise ArgumentError,
"Specify deserialize finalizer with a method, name, proc OR block",
caller
end
set_deserialize_finalizer(h_meth)
elsif meth.nil? and block.nil?
set_deserialize_finalizer(nil)
else
Kernel.raise ArgumentError,
"Specify deserialize finalizer with a method, name, proc OR block",
caller
end
nil
end
alias :deserialize_finalizer :define_deserialize_finalizer
# Deserializes object from source data
# @param [IO,String] source source data (String or IO(-like object))
# @param [Symbol, String] format data format of source
# @return [Object] deserialized object
def deserialize(source, format: Serializable.default_format)
Serializable.deserialize(source, format: format)
end
end
# Mixin module for classes that get FIRM::Serializable included.
# This module is used to extend the instance methods of the serializable class.
module SerializeInstanceMethods
# Serialize this object
# @overload serialize(pretty: false, format: Serializable.default_format)
# @param [Boolean] pretty if true specifies to generate pretty formatted output if possible
# @param [Symbol,String] format specifies output format
# @return [String] serialized data
# @overload serialize(io, pretty: false, format: Serializable.default_format)
# @param [IO] io output stream to write serialized data to
# @param [Boolean] pretty if true specifies to generate pretty formatted output if possible
# @param [Symbol,String] format specifies output format
# @return [IO]
def serialize(io = nil, pretty: false, format: Serializable.default_format)
Serializable[format].dump(self, io, pretty: pretty)
end
# Returns true if regular serialization for this object has been disabled, false otherwise (default).
# Disabled serialization can be overridden for single objects (not objects maintained in property containers
# like arrays and sets).
# @return [true,false]
def serialize_disabled?
!!@serialize_disabled # true for any value but false
end
# Disables serialization for this object as a single property or as part of a property container
# (array or set).
# @return [void]
def disable_serialize
# by default unset (nil) so serializing enabled
@serialize_disabled = true
end
# @!method for_serialize(hash, excludes = Set.new)
# Serializes the properties of a serializable instance to the given hash
# except when the property id is included in excludes.
# @param [Object] hash hash-like property serialization container
# @param [Set] excludes set with excluded property ids
# @return [Object] hash-like property serialization container
# @!method from_serialized(hash)
# Restores the properties of a deserialized instance.
# @param [Object] hash hash-like property deserialization container
# @return [self]
# #!method finalize_from_serialized()
# Finalizes the instance initialization after property restoration.
# Calls any user defined finalizer.
# @return [self]
end
# Serialize the given object
# @overload serialize(obj, pretty: false, format: Serializable.default_format)
# @param [Object] obj object to serialize
# @param [Boolean] pretty if true specifies to generate pretty formatted output if possible
# @param [Symbol,String] format specifies output format
# @return [String] serialized data
# @overload serialize(obj, io, pretty: false, format: Serializable.default_format)
# @param [Object] obj object to serialize
# @param [IO] io output stream to write serialized data to
# @param [Boolean] pretty if true specifies to generate pretty formatted output if possible
# @param [Symbol,String] format specifies output format
# @return [IO]
def self.serialize(obj, io = nil, pretty: false, format: Serializable.default_format)
self[format].dump(obj, io, pretty: pretty)
end
# Deserializes object from source data
# @param [IO,String] source source data (stream)
# @param [Symbol, String] format data format of source
# @return [Object] deserialized object
def self.deserialize(source, format: Serializable.default_format)
self[format].load(::IO === source || source.respond_to?(:read) ? source.read : source)
end
# Small utility class for delayed method resolving
class MethodResolver
def initialize(klass, mtd_id, default=false)
@klass = klass
@mtd_id = mtd_id
@default = default
end
def resolve
m = @klass.instance_method(@mtd_id) rescue nil
if m
# check arity == 0
if m.arity>0
unless @default
Kernel.raise ArgumentError,
"Deserialize finalizer method #{@klass}#{@mtd_id} should not expect any argument",
caller
end
else
return ->(obj) { m.bind(obj).call }
end
end
nil
end
end
def self.included(base)
::Kernel.raise RuntimeError, "#{self} should only be included in classes" if base.instance_of?(::Module)
::Kernel.raise RuntimeError, "#{self} should be included only once in #{base}" if Serializable.serializables.include?(base.name)
# register as serializable class
Serializable.serializables << base
return if base == Serializable::ID # special case which does not need the rest
# provide serialized property definition support
# provide serialized classes with their own serialized properties (exclusion) list
# and a deserialization finalizer setter/getter
base.singleton_class.class_eval do
def serializer_properties
@serializer_props ||= []
end
def excluded_serializer_properties
@excluded_serializer_props ||= ::Set.new
end
def set_deserialize_finalizer(fin)
@finalize_from_deserialized = fin
end
private :set_deserialize_finalizer
def get_deserialize_finalizer
case @finalize_from_deserialized
when Serializable::MethodResolver
@finalize_from_deserialized = @finalize_from_deserialized.resolve
else
@finalize_from_deserialized
end
end
private :get_deserialize_finalizer
def find_deserialize_finalizer
get_deserialize_finalizer
end
end
base.class_eval do
# Initializes a newly allocated instance for subsequent deserialization (optionally initializing
# using the given data hash).
# The default implementation calls the standard #initialize method without arguments (default constructor)
# and leaves the property restoration to a subsequent call to the instance method #from_serialized(data).
# Classes that do not support a default constructor can override this class method and
# implement a custom initialization scheme.
# @param [Object] _data hash-like object containing deserialized property data (symbol keys)
# @return [Object] the initialized object
def init_from_serialized(_data)
initialize
self
end
protected :init_from_serialized
# Check if the class has the default deserialize finalizer method defined (a #create method
# without arguments). If so install that method as the deserialize finalizer.
set_deserialize_finalizer(Serializable::MethodResolver.new(self, :create, true))
end
# add class methods
base.extend(SerializeClassMethods)
# add instance property (de-)serialization methods for base class
base.class_eval <<~__CODE
def for_serialize(hash, excludes = ::Set.new)
#{base.name}.serializer_properties.each { |prop, h| prop.serialize(self, hash, excludes) }
hash
end
protected :for_serialize
def from_serialized(hash)
#{base.name}.serializer_properties.each { |prop| prop.deserialize(self, hash) }
self
end
protected :from_serialized
def finalize_from_serialized
if (f = self.class.find_deserialize_finalizer)
f.call(self)
end
self
end
protected :finalize_from_serialized
def self.has_serializer_property?(id)
self.serializer_properties.any? { |p| p.id == id.to_sym }
end
__CODE
# add inheritance support
base.class_eval do
def self.inherited(derived)
# add instance property (de-)serialization methods for derived classes
derived.class_eval <<~__CODE
module SerializerMethods
def for_serialize(hash, excludes = ::Set.new)
hash = super(hash, excludes | #{derived.name}.excluded_serializer_properties)
#{derived.name}.serializer_properties.each { |prop| prop.serialize(self, hash, excludes) }
hash
end
protected :for_serialize
def from_serialized(hash)
#{derived.name}.serializer_properties.each { |prop| prop.deserialize(self, hash) }
super(hash)
end
protected :from_serialized
end
include SerializerMethods
__CODE
derived.class_eval do
def self.has_serializer_property?(id)
self.serializer_properties.any? { |p| p.id == id.to_sym } || self.superclass.has_serializer_property?(id)
end
end
# add derived class support for deserialization finalizer
derived.singleton_class.class_eval <<~__CODE
def find_deserialize_finalizer
get_deserialize_finalizer || #{derived.name}.superclass.find_deserialize_finalizer
end
__CODE
# Check if the derived class has the default deserialize finalizer method defined (a #create method
# without arguments) defined. If so install that method as the deserialize finalizer (it is expected
# this method will call any superclass finalizer that may be defined).
derived.class_eval do
set_deserialize_finalizer(Serializable::MethodResolver.new(self, :create, true))
end
# register as serializable class
Serializable.serializables << derived
end
end
# add instance serialization method
base.include(SerializeInstanceMethods)
end
end # module Serializable
# Serialize the given object
# @overload serialize(obj, pretty: false, format: Serializable.default_format)
# @param [Object] obj object to serialize
# @param [Boolean] pretty if true specifies to generate pretty formatted output if possible
# @param [Symbol,String] format specifies output format
# @return [String] serialized data
# @overload serialize(obj, io, pretty: false, format: Serializable.default_format)
# @param [Object] obj object to serialize
# @param [IO] io output stream (IO(-like object)) to write serialized data to
# @param [Boolean] pretty if true specifies to generate pretty formatted output if possible
# @param [Symbol,String] format specifies output format
# @return [IO]
def self.serialize(obj, io = nil, pretty: false, format: Serializable.default_format)
Serializable.serialize(obj, io, pretty: pretty, format: format)
end
# Deserializes object from source data
# @param [IO,String] source source data (String or IO(-like object))
# @param [Symbol, String] format data format of source
# @return [Object] deserialized object
def self.deserialize(source, format: Serializable.default_format)
Serializable.deserialize(source, format: format)
end
end # module FIRM
Dir[File.join(__dir__, 'serializer', '*.rb')].each { |fnm| require "firm/serializer/#{File.basename(fnm)}" }
Dir[File.join(__dir__, 'serialize', '*.rb')].each { |fnm| require "firm/serialize/#{File.basename(fnm)}" }