require_relative "yaml_adapter"
require_relative "xml_adapter"
require_relative "config"
require_relative "type"
require_relative "attribute"
require_relative "mapping_rule"
require_relative "mapping_hash"
require_relative "xml_mapping"
require_relative "key_value_mapping"
require_relative "json_adapter"
require_relative "comparable_model"
module Lutaml
module Model
module Serialize
FORMATS = %i[xml json yaml toml].freeze
include ComparableModel
def self.included(base)
base.extend(ClassMethods)
end
module ClassMethods
attr_accessor :attributes, :mappings
def inherited(subclass)
super
@mappings ||= {}
@attributes ||= {}
subclass.instance_variable_set(:@attributes, @attributes.dup)
subclass.instance_variable_set(:@mappings, @mappings.dup)
subclass.instance_variable_set(:@model, subclass)
end
def model(klass = nil)
if klass
@model = klass
else
@model
end
end
# Define an attribute for the model
def attribute(name, type, options = {})
attr = Attribute.new(name, type, options)
attributes[name] = attr
define_method(name) do
instance_variable_get(:"@#{name}")
end
define_method(:"#{name}=") do |value|
instance_variable_set(:"@#{name}", value)
validate
end
end
# Check if the value to be assigned is valid for the attribute
def attr_value_valid?(name, value)
attr = attributes[name]
return true unless attr.options[:values]
# Allow nil values if there's no default
return true if value.nil? && !attr.default
# Use the default value if the value is nil
value = attr.default if value.nil?
attr.options[:values].include?(value)
end
FORMATS.each do |format|
define_method(format) do |&block|
klass = format == :xml ? XmlMapping : KeyValueMapping
mappings[format] = klass.new
mappings[format].instance_eval(&block)
if format == :xml && !mappings[format].root_element
mappings[format].root(model.to_s)
end
end
define_method(:"from_#{format}") do |data|
adapter = Lutaml::Model::Config.send(:"#{format}_adapter")
doc = adapter.parse(data)
mapped_attrs = apply_mappings(doc.to_h, format)
# apply_content_mapping(doc, mapped_attrs) if format == :xml
generate_model_object(self, mapped_attrs)
end
define_method(:"to_#{format}") do |instance|
unless instance.is_a?(model)
msg = "argument is a '#{instance.class}' but should be a '#{model}'"
raise Lutaml::Model::IncorrectModelError, msg
end
adapter = Lutaml::Model::Config.public_send(:"#{format}_adapter")
if format == :xml
xml_options = { mapper_class: self }
adapter.new(instance).public_send(:"to_#{format}", xml_options)
else
hash = hash_representation(instance, format)
adapter.new(hash).public_send(:"to_#{format}")
end
end
end
def hash_representation(instance, format, options = {})
only = options[:only]
except = options[:except]
mappings = mappings_for(format).mappings
mappings.each_with_object({}) do |rule, hash|
name = rule.to
next if except&.include?(name) || (only && !only.include?(name))
next handle_delegate(instance, rule, hash) if rule.delegate
value = if rule.custom_methods[:to]
instance.send(rule.custom_methods[:to], instance, instance.send(name))
else
instance.send(name)
end
next if value.nil? && !rule.render_nil
attribute = attributes[name]
hash[rule.from] = if rule.child_mappings
generate_hash_from_child_mappings(value, rule.child_mappings)
elsif value.is_a?(Array)
value.map do |v|
if attribute.type <= Serialize
attribute.type.hash_representation(v, format, options)
else
attribute.type.serialize(v)
end
end
elsif attribute.type <= Serialize
attribute.type.hash_representation(value, format, options)
else
attribute.type.serialize(value)
end
end
end
def handle_delegate(instance, rule, hash)
name = rule.to
value = instance.send(rule.delegate).send(name)
return if value.nil? && !rule.render_nil
attribute = instance.send(rule.delegate).class.attributes[name]
hash[rule.from] = case value
when Array
value.map do |v|
if v.is_a?(Serialize)
hash_representation(v, format, options)
else
attribute.type.serialize(v)
end
end
else
if value.is_a?(Serialize)
hash_representation(value, format, options)
else
attribute.type.serialize(value)
end
end
end
def mappings_for(format)
mappings[format] || default_mappings(format)
end
def generate_model_object(type, mapped_attrs)
return type.model.new(mapped_attrs) if self == model
instance = type.model.new
type.attributes.each do |name, attr|
value = attr_value(mapped_attrs, name, attr)
instance.send(:"#{name}=", ensure_utf8(value))
end
instance
end
def attr_value(attrs, name, attr_rule)
value = if attrs.key?(name)
attrs[name]
elsif attrs.key?(name.to_sym)
attrs[name.to_sym]
elsif attrs.key?(name.to_s)
attrs[name.to_s]
else
attr_rule.default
end
if attr_rule.collection? || value.is_a?(Array)
(value || []).map do |v|
if v.is_a?(Hash)
attr_rule.type.new(v)
else
# TODO: This code is problematic because Type.cast does not know
# about all the types.
Lutaml::Model::Type.cast(v, attr_rule.type)
end
end
elsif value.is_a?(Hash) && attr_rule.type != Lutaml::Model::Type::Hash
generate_model_object(attr_rule.type, value)
else
# TODO: This code is problematic because Type.cast does not know
# about all the types.
Lutaml::Model::Type.cast(value, attr_rule.type)
end
end
def default_mappings(format)
klass = format == :xml ? XmlMapping : KeyValueMapping
klass.new.tap do |mapping|
attributes&.each do |name, attr|
mapping.map_element(
name.to_s,
to: name,
render_nil: attr.render_nil?,
)
end
end
end
def apply_child_mappings(hash, child_mappings)
return hash unless child_mappings
hash.map do |key, value|
child_mappings.to_h do |attr_name, path|
attr_value = if path == :key
key
elsif path == :value
value
else
path = [path] unless path.is_a?(Array)
value.dig(*path.map(&:to_s))
end
[attr_name, attr_value]
end
end
end
def generate_hash_from_child_mappings(value, child_mappings)
return value unless child_mappings
hash = {}
value.each do |child_obj|
map_key = nil
map_value = {}
child_mappings.each do |attr_name, path|
if path == :key
map_key = child_obj.send(attr_name)
elsif path == :value
map_value = child_obj.send(attr_name)
else
path = [path] unless path.is_a?(Array)
path[0...-1].inject(map_value) do |acc, k|
acc[k.to_s] ||= {}
end.public_send(:[]=, path.last.to_s, child_obj.send(attr_name))
end
end
hash[map_key] = map_value
end
hash
end
def apply_mappings(doc, format)
return apply_xml_mapping(doc) if format == :xml
mappings = mappings_for(format).mappings
mappings.each_with_object(Lutaml::Model::MappingHash.new) do |rule, hash|
attr = if rule.delegate
attributes[rule.delegate].type.attributes[rule.to]
else
attributes[rule.to]
end
raise "Attribute '#{rule.to}' not found in #{self}" unless attr
value = if rule.custom_methods[:from]
new.send(rule.custom_methods[:from], hash, doc)
elsif doc.key?(rule.name) || doc.key?(rule.name.to_sym)
doc[rule.name] || doc[rule.name.to_sym]
else
attr.default
end
value = apply_child_mappings(value, rule.child_mappings)
if attr.collection?
value = (value || []).map do |v|
attr.type <= Serialize ? attr.type.apply_mappings(v, format) : v
end
elsif value.is_a?(Hash) && attr.type != Lutaml::Model::Type::Hash
value = attr.type.apply_mappings(value, format)
end
if rule.delegate
hash[rule.delegate] ||= {}
hash[rule.delegate][rule.to] = value
else
hash[rule.to] = value
end
end
end
def apply_xml_mapping(doc, caller_class: nil, mixed_content: false)
return unless doc
mappings = mappings_for(:xml).mappings
if doc.is_a?(Array)
raise "May be `collection: true` is" \
"missing for #{self} in #{caller_class}"
end
mapping_hash = Lutaml::Model::MappingHash.new
mapping_hash.item_order = doc.item_order
mapping_hash.ordered = mappings_for(:xml).mixed_content? || mixed_content
mapping_from = []
mappings.each_with_object(mapping_hash) do |rule, hash|
attr = attributes[rule.to]
raise "Attribute '#{rule.to}' not found in #{self}" unless attr
is_content_mapping = rule.name.nil?
value = if is_content_mapping
doc["text"]
else
doc[rule.name.to_s] || doc[rule.name.to_sym]
end
if attr.collection?
if value && !value.is_a?(Array)
value = [value]
end
value = (value || []).map do |v|
if attr.type <= Serialize
attr.type.apply_xml_mapping(v, caller_class: self, mixed_content: rule.mixed_content)
elsif v.is_a?(Hash)
v["text"]
else
v
end
end
elsif attr.type <= Serialize
value = attr.type.apply_xml_mapping(value, caller_class: self, mixed_content: rule.mixed_content)
else
if value.is_a?(Hash) && attr.type != Lutaml::Model::Type::Hash
value = value["text"]
end
value = attr.type.cast(value) unless is_content_mapping
end
mapping_from << rule if rule.custom_methods[:from]
hash[rule.to] = value
end
mapping_from.each do |rule|
value = if rule.name.nil?
mapping_hash[rule.to].join("\n").strip
else
mapping_hash[rule.to]
end
mapping_hash[rule.to] = new.send(rule.custom_methods[:from], mapping_hash, value)
end
mapping_hash
end
def ensure_utf8(value)
case value
when String
value.encode("UTF-8", invalid: :replace, undef: :replace, replace: "")
when Array
value.map { |v| ensure_utf8(v) }
when Hash
value.transform_keys do |k|
ensure_utf8(k)
end.transform_values do |v|
ensure_utf8(v)
end
else
value
end
end
end
attr_reader :element_order
def initialize(attrs = {})
return unless self.class.attributes
if attrs.is_a?(Lutaml::Model::MappingHash)
@ordered = attrs.ordered?
@element_order = attrs.item_order
end
self.class.attributes.each do |name, attr|
value = self.class.attr_value(attrs, name, attr)
send(:"#{name}=", self.class.ensure_utf8(value))
end
validate
end
def ordered?
@ordered
end
def key_exist?(hash, key)
hash.key?(key) || hash.key?(key.to_sym) || hash.key?(key.to_s)
end
def key_value(hash, key)
hash[key] || hash[key.to_sym] || hash[key.to_s]
end
FORMATS.each do |format|
define_method(:"to_#{format}") do |options = {}|
validate
adapter = Lutaml::Model::Config.public_send(:"#{format}_adapter")
representation = if format == :xml
self
else
self.class.hash_representation(self, format, options)
end
adapter.new(representation).public_send(:"to_#{format}", options)
end
end
def validate
self.class.attributes.each do |name, attr|
value = send(name)
unless self.class.attr_value_valid?(name, value)
raise Lutaml::Model::InvalidValueError.new(name, value, attr.options[:values])
end
end
end
end
end
end