lutaml-model 0.3.30 → 0.5.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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +34 -18
  3. data/README.adoc +172 -8
  4. data/lib/lutaml/model/attribute.rb +6 -2
  5. data/lib/lutaml/model/key_value_mapping.rb +0 -1
  6. data/lib/lutaml/model/key_value_mapping_rule.rb +3 -1
  7. data/lib/lutaml/model/mapping_rule.rb +14 -2
  8. data/lib/lutaml/model/serialize.rb +174 -61
  9. data/lib/lutaml/model/type/decimal.rb +5 -0
  10. data/lib/lutaml/model/type/time.rb +4 -4
  11. data/lib/lutaml/model/utils.rb +1 -1
  12. data/lib/lutaml/model/validation.rb +6 -2
  13. data/lib/lutaml/model/version.rb +1 -1
  14. data/lib/lutaml/model/xml_adapter/builder/nokogiri.rb +1 -0
  15. data/lib/lutaml/model/xml_adapter/builder/oga.rb +180 -0
  16. data/lib/lutaml/model/xml_adapter/builder/ox.rb +1 -0
  17. data/lib/lutaml/model/xml_adapter/oga/document.rb +20 -0
  18. data/lib/lutaml/model/xml_adapter/oga/element.rb +117 -0
  19. data/lib/lutaml/model/xml_adapter/oga_adapter.rb +77 -44
  20. data/lib/lutaml/model/xml_adapter/xml_document.rb +11 -9
  21. data/lib/lutaml/model/xml_mapping.rb +0 -1
  22. data/lib/lutaml/model/xml_mapping_rule.rb +16 -4
  23. data/spec/address_spec.rb +1 -0
  24. data/spec/fixtures/sample_model.rb +7 -0
  25. data/spec/lutaml/model/custom_model_spec.rb +47 -1
  26. data/spec/lutaml/model/custom_serialization_spec.rb +16 -0
  27. data/spec/lutaml/model/enum_spec.rb +131 -0
  28. data/spec/lutaml/model/included_spec.rb +192 -0
  29. data/spec/lutaml/model/mixed_content_spec.rb +48 -32
  30. data/spec/lutaml/model/multiple_mapping_spec.rb +329 -0
  31. data/spec/lutaml/model/ordered_content_spec.rb +1 -1
  32. data/spec/lutaml/model/render_nil_spec.rb +3 -2
  33. data/spec/lutaml/model/serializable_spec.rb +3 -3
  34. data/spec/lutaml/model/type/boolean_spec.rb +62 -0
  35. data/spec/lutaml/model/xml_adapter/oga_adapter_spec.rb +11 -11
  36. data/spec/lutaml/model/xml_adapter/xml_namespace_spec.rb +1 -1
  37. data/spec/lutaml/model/xml_adapter_spec.rb +2 -2
  38. data/spec/lutaml/model/xml_mapping_spec.rb +24 -9
  39. data/spec/sample_model_spec.rb +114 -0
  40. metadata +9 -2
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Model
5
+ module XmlAdapter
6
+ module Oga
7
+ class Element < XmlElement
8
+ def initialize(node, parent: nil)
9
+ name = case node
10
+ when ::Oga::XML::Element
11
+ namespace_name = node.namespace_name
12
+ add_namespaces(node)
13
+ children = parse_children(node)
14
+ attributes = node_attributes(node)
15
+ node.name
16
+ when ::Oga::XML::Text
17
+ "text"
18
+ end
19
+ super(
20
+ name,
21
+ Hash(attributes),
22
+ Array(children),
23
+ node.text,
24
+ parent_document: parent,
25
+ namespace_prefix: namespace_name,
26
+ )
27
+ end
28
+
29
+ def text?
30
+ children.empty? && text&.length&.positive?
31
+ end
32
+
33
+ def text
34
+ @text
35
+ end
36
+
37
+ def to_xml(builder = Builder::Oga.build)
38
+ build_xml(builder).to_xml
39
+ end
40
+
41
+ def build_xml(builder = Builder::Oga.build)
42
+ if name == "text"
43
+ builder.add_text(builder.current_node, @text)
44
+ else
45
+ builder.create_element(name, build_attributes(self)) do |xml|
46
+ children.each { |child| child.build_xml(xml) }
47
+ end
48
+ end
49
+
50
+ builder
51
+ end
52
+
53
+ def inner_xml
54
+ children.map(&:to_xml).join
55
+ end
56
+
57
+ private
58
+
59
+ def node_attributes(node)
60
+ node.attributes.each_with_object({}) do |attr, hash|
61
+ next if attr_is_namespace?(attr)
62
+
63
+ name = if attr.namespace
64
+ "#{attr.namespace.name}:#{attr.name}"
65
+ else
66
+ attr.name
67
+ end
68
+ hash[name] = XmlAttribute.new(
69
+ name,
70
+ attr.value,
71
+ namespace: attr.namespace&.uri,
72
+ namespace_prefix: attr.namespace&.name,
73
+ )
74
+ end
75
+ end
76
+
77
+ def parse_children(node)
78
+ node.children.map { |child| self.class.new(child, parent: self) }
79
+ end
80
+
81
+ def add_namespaces(node)
82
+ node.namespaces.each_value do |namespace|
83
+ add_namespace(XmlNamespace.new(namespace.uri, namespace.name))
84
+ end
85
+ end
86
+
87
+ def attr_is_namespace?(attr)
88
+ attribute_is_namespace?(attr.name) ||
89
+ namespaces[attr.name]&.uri == attr.value
90
+ end
91
+
92
+ def build_attributes(node, _options = {})
93
+ attrs = node.attributes.transform_values(&:value)
94
+
95
+ attrs.merge(build_namespace_attributes(node))
96
+ end
97
+
98
+ def build_namespace_attributes(node)
99
+ namespace_attrs = {}
100
+
101
+ node.own_namespaces.each_value do |namespace|
102
+ namespace_attrs[namespace.attr_name] = namespace.uri
103
+ end
104
+
105
+ node.children.each do |child|
106
+ namespace_attrs = namespace_attrs.merge(
107
+ build_namespace_attributes(child),
108
+ )
109
+ end
110
+
111
+ namespace_attrs
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -1,69 +1,102 @@
1
1
  require "oga"
2
2
  require_relative "xml_document"
3
+ require_relative "oga/document"
4
+ require_relative "oga/element"
5
+ require_relative "builder/oga"
3
6
 
4
7
  module Lutaml
5
8
  module Model
6
9
  module XmlAdapter
7
10
  class OgaAdapter < XmlDocument
8
- def self.parse(xml, _options = {})
9
- parsed = Oga.parse_xml(xml)
10
- root = OgaElement.new(parsed)
11
- new(root)
12
- end
13
-
14
- def to_h
15
- { @root.name => parse_element(@root) }
11
+ def self.parse(xml, options = {})
12
+ encoding = options[:encoding] || xml.encoding.to_s
13
+ xml = xml.encode("UTF-16").encode("UTF-8") if encoding && encoding != "UTF-8"
14
+ parsed = ::Oga.parse_xml(xml)
15
+ @root = Oga::Element.new(parsed.children.first)
16
+ new(@root, encoding)
16
17
  end
17
18
 
18
19
  def to_xml(options = {})
19
- builder = Oga::XML::Builder.new
20
- build_element(builder, @root, options)
21
- xml_data = builder.to_xml
20
+ builder_options = {}
21
+
22
+ builder_options[:encoding] = if options.key?(:encoding)
23
+ options[:encoding] || "UTF-8"
24
+ elsif options.key?(:parse_encoding)
25
+ options[:parse_encoding]
26
+ else
27
+ "UTF-8"
28
+ end
29
+ builder = Builder::Oga.build(options) do |xml|
30
+ if @root.is_a?(Oga::Element)
31
+ @root.build_xml(xml)
32
+ else
33
+ build_element(xml, @root, options)
34
+ end
35
+ end
36
+ xml_data = builder.to_xml.encode!(builder_options[:encoding])
22
37
  options[:declaration] ? declaration(options) + xml_data : xml_data
38
+ rescue Encoding::ConverterNotFoundError
39
+ invalid_encoding!(builder_options[:encoding])
23
40
  end
24
41
 
25
42
  private
26
43
 
27
- def build_element(builder, element, options = {})
28
- attributes = build_attributes(element.attributes)
29
- builder.element(element.name, attributes) do
30
- element.children.each do |child|
31
- build_element(builder, child, options)
32
- end
33
- builder.text(element.text) if element.text
34
- end
35
- end
44
+ def build_ordered_element(builder, element, options = {})
45
+ mapper_class = options[:mapper_class] || element.class
46
+ xml_mapping = mapper_class.mappings_for(:xml)
47
+ return xml unless xml_mapping
36
48
 
37
- def build_attributes(attributes)
38
- attributes.transform_values(&:value)
39
- end
49
+ attributes = build_attributes(element, xml_mapping).compact
40
50
 
41
- def parse_element(element)
42
- result = { "_text" => element.text }
43
- element.children.each do |child|
44
- next if child.is_a?(Oga::XML::Text)
51
+ tag_name = options[:tag_name] || xml_mapping.root_element
52
+ builder.create_and_add_element(tag_name,
53
+ attributes: attributes) do |el|
54
+ index_hash = {}
55
+ content = []
45
56
 
46
- result[child.name] ||= []
47
- result[child.name] << parse_element(child)
48
- end
49
- result
50
- end
51
- end
57
+ element.element_order.each do |name|
58
+ index_hash[name] ||= -1
59
+ curr_index = index_hash[name] += 1
60
+
61
+ element_rule = xml_mapping.find_by_name(name)
62
+ next if element_rule.nil?
63
+
64
+ attribute_def = attribute_definition_for(element, element_rule,
65
+ mapper_class: mapper_class)
66
+ value = attribute_value_for(element, element_rule)
67
+
68
+ next if element_rule == xml_mapping.content_mapping && element_rule.cdata && name == "text"
69
+
70
+ if element_rule == xml_mapping.content_mapping
71
+ text = xml_mapping.content_mapping.serialize(element)
72
+ text = text[curr_index] if text.is_a?(Array)
73
+
74
+ next el.add_text(el, text, cdata: element_rule.cdata) if element.mixed?
52
75
 
53
- class OgaElement < XmlElement
54
- def initialize(node)
55
- attributes = node.attributes.each_with_object({}) do |attr, hash|
56
- hash[attr.name] = XmlAttribute.new(attr.name, attr.value)
76
+ content << text
77
+ elsif !value.nil? || element_rule.render_nil?
78
+ value = value[curr_index] if attribute_def.collection?
79
+
80
+ add_to_xml(
81
+ el,
82
+ element,
83
+ nil,
84
+ value,
85
+ options.merge(
86
+ attribute: attribute_def,
87
+ rule: element_rule,
88
+ mapper_class: mapper_class,
89
+ ),
90
+ )
91
+ end
92
+ end
93
+
94
+ el.add_text(el, content.join)
57
95
  end
58
- super(node.name, attributes, parse_children(node), node.text)
59
96
  end
60
97
 
61
- private
62
-
63
- def parse_children(node)
64
- node.children.select do |child|
65
- child.is_a?(Oga::XML::Element)
66
- end.map { |child| OgaElement.new(child) }
98
+ def invalid_encoding!(encoding)
99
+ raise Error, "unknown encoding name - #{encoding}"
67
100
  end
68
101
  end
69
102
  end
@@ -233,20 +233,16 @@ module Lutaml
233
233
  end
234
234
 
235
235
  process_content_mapping(element, xml_mapping.content_mapping,
236
- prefixed_xml)
236
+ prefixed_xml, mapper_class)
237
237
  end
238
238
  end
239
239
 
240
- def process_content_mapping(element, content_rule, xml)
240
+ def process_content_mapping(element, content_rule, xml, mapper_class)
241
241
  return unless content_rule
242
242
 
243
243
  if content_rule.custom_methods[:to]
244
- @root.send(
245
- content_rule.custom_methods[:to],
246
- element,
247
- xml.parent,
248
- xml,
249
- )
244
+ mapper_class.new.send(content_rule.custom_methods[:to], element,
245
+ xml.parent, xml)
250
246
  else
251
247
  text = content_rule.serialize(element)
252
248
  text = text.join if text.is_a?(Array)
@@ -340,7 +336,9 @@ module Lutaml
340
336
  next if options[:except]&.include?(mapping_rule.to)
341
337
  next if mapping_rule.custom_methods[:to]
342
338
 
343
- if mapping_rule.namespace && mapping_rule.prefix && mapping_rule.name != "lang"
339
+ mapping_rule_name = mapping_rule.multiple_mappings? ? mapping_rule.name.first : mapping_rule.name
340
+
341
+ if mapping_rule.namespace && mapping_rule.prefix && mapping_rule_name != "lang"
344
342
  hash["xmlns:#{mapping_rule.prefix}"] = mapping_rule.namespace
345
343
  end
346
344
 
@@ -378,6 +376,10 @@ module Lutaml
378
376
  key = ["xmlns", xml_mapping.namespace_prefix].compact.join(":")
379
377
  { key => xml_mapping.namespace_uri }
380
378
  end
379
+
380
+ def self.type
381
+ Utils.snake_case(self).split("/").last.split("_").first
382
+ end
381
383
  end
382
384
  end
383
385
  end
@@ -92,7 +92,6 @@ module Lutaml
92
92
  nil)
93
93
  )
94
94
  validate!(name, to, with, type: TYPES[:attribute])
95
-
96
95
  rule = XmlMappingRule.new(
97
96
  name,
98
97
  to: to,
@@ -19,7 +19,8 @@ module Lutaml
19
19
  namespace_set: false,
20
20
  prefix_set: false,
21
21
  attribute: false,
22
- default_namespace: nil
22
+ default_namespace: nil,
23
+ id: nil
23
24
  )
24
25
  super(
25
26
  name,
@@ -29,6 +30,7 @@ module Lutaml
29
30
  with: with,
30
31
  delegate: delegate,
31
32
  attribute: attribute,
33
+ id: id
32
34
  )
33
35
 
34
36
  @namespace = if namespace.to_s == "inherit"
@@ -45,6 +47,7 @@ module Lutaml
45
47
 
46
48
  @namespace_set = namespace_set
47
49
  @prefix_set = prefix_set
50
+ @id = id
48
51
  end
49
52
 
50
53
  def namespace_set?
@@ -72,14 +75,23 @@ module Lutaml
72
75
  end
73
76
 
74
77
  def prefixed_name
78
+ rule_name = multiple_mappings? ? name.first : name
75
79
  if prefix
76
- "#{prefix}:#{name}"
80
+ "#{prefix}:#{rule_name}"
81
+ else
82
+ rule_name
83
+ end
84
+ end
85
+
86
+ def namespaced_names(parent_namespace = nil)
87
+ if multiple_mappings?
88
+ name.map { |rule_name| namespaced_name(parent_namespace, rule_name) }
77
89
  else
78
- name
90
+ [namespaced_name(parent_namespace)]
79
91
  end
80
92
  end
81
93
 
82
- def namespaced_name(parent_namespace = nil)
94
+ def namespaced_name(parent_namespace = nil, name = self.name)
83
95
  if name == "lang"
84
96
  "#{prefix}:#{name}"
85
97
  elsif namespace_set? || @attribute
data/spec/address_spec.rb CHANGED
@@ -100,6 +100,7 @@ RSpec.describe Address do
100
100
  expect(address_from_json.post_code).to eq("01001")
101
101
  expect(address_from_json.person.first.first_name).to eq("Tom")
102
102
  expect(address_from_json.person.last.first_name).to eq("Jack")
103
+ expect(address_from_json.person.last.active).to be(false)
103
104
  end
104
105
 
105
106
  it "serializes to XML with a collection of persons" do
@@ -36,5 +36,12 @@ class SampleModel < Lutaml::Model::Serializable
36
36
  yaml do
37
37
  map "name", to: :name
38
38
  map "age", to: :age
39
+ map "balance", to: :balance
40
+ map "tags", to: :tags
41
+ map "preferences", to: :preferences
42
+ map "status", to: :status
43
+ map "large_number", to: :large_number
44
+ map "email", to: :email
45
+ map "role", to: :role
39
46
  end
40
47
  end
@@ -78,7 +78,7 @@ module CustomModelSpecs
78
78
  end
79
79
 
80
80
  class Id
81
- attr_accessor :id
81
+ attr_accessor :id, :prefix
82
82
  end
83
83
 
84
84
  class Docid < Lutaml::Model::Serializable
@@ -147,6 +147,28 @@ module CustomModelSpecs
147
147
  end
148
148
  end
149
149
  end
150
+
151
+ class CustomId < Lutaml::Model::Serializable
152
+ model Id
153
+ attribute :id, :string
154
+ attribute :prefix, :string
155
+
156
+ xml do
157
+ root "custom-id"
158
+ map_attribute "prefix", to: :prefix
159
+ map_content with: { to: :id_to_xml, from: :id_from_xml }
160
+ end
161
+
162
+ def id_to_xml(model, _parent, doc)
163
+ content = "ABC-#{model.id}"
164
+ doc.add_text(doc, content)
165
+ end
166
+
167
+ def id_from_xml(model, value)
168
+ id = value.split("-").last
169
+ model.id = id.to_i
170
+ end
171
+ end
150
172
  end
151
173
 
152
174
  RSpec.describe "CustomModel" do
@@ -407,4 +429,28 @@ RSpec.describe "CustomModel" do
407
429
  end
408
430
  end
409
431
  end
432
+
433
+ context "with custom methods" do
434
+ describe ".xml serialization" do
435
+ it "handles custom content mapping methods" do
436
+ xml = '<custom-id prefix="ABC">ABC-123</custom-id>'
437
+
438
+ instance = CustomModelSpecs::Id.new
439
+ instance.id = 123
440
+ instance.prefix = "ABC"
441
+ result_xml = CustomModelSpecs::CustomId.to_xml(instance)
442
+ expect(result_xml).to eq(xml)
443
+ end
444
+ end
445
+
446
+ describe ".xml deserialization" do
447
+ it "handles custom content mapping methods" do
448
+ xml = '<custom-id prefix="ABC">ABC-123</custom-id>'
449
+ instance = CustomModelSpecs::CustomId.from_xml(xml)
450
+
451
+ expect(instance.id).to eq(123)
452
+ expect(instance.prefix).to eq("ABC")
453
+ end
454
+ end
455
+ end
410
456
  end
@@ -138,6 +138,22 @@ RSpec.describe CustomSerialization do
138
138
  end
139
139
  end
140
140
 
141
+ context "with partial JSON input" do
142
+ it "deserializes from JSON with missing attributes" do
143
+ json = {
144
+ name: "JSON Masterpiece: Vase",
145
+ color: "BLUE",
146
+ }.to_json
147
+
148
+ ceramic = described_class.from_json(json)
149
+
150
+ expect(ceramic.full_name).to eq("Vase")
151
+ expect(ceramic.color).to eq("blue")
152
+ expect(ceramic.size).to be_nil
153
+ expect(ceramic.description).to be_nil
154
+ end
155
+ end
156
+
141
157
  context "with XML serialization" do
142
158
  it "serializes to XML with custom methods" do
143
159
  expected_xml = <<~XML
@@ -0,0 +1,131 @@
1
+ require "spec_helper"
2
+ require "lutaml/model"
3
+
4
+ module EnumSpec
5
+ class WithEnum < Lutaml::Model::Serializable
6
+ attribute :without_enum, :string
7
+ attribute :single_value, :string, values: %w[user admin super_admin]
8
+ attribute :multi_value, :string, values: %w[singular dual plural], collection: true
9
+ end
10
+ end
11
+
12
+ RSpec.describe "Enum" do
13
+ let(:single_value_attr) do
14
+ EnumSpec::WithEnum.attributes[:single_value]
15
+ end
16
+
17
+ let(:multi_value_attr) do
18
+ EnumSpec::WithEnum.attributes[:multi_value]
19
+ end
20
+
21
+ let(:without_enum_attr) do
22
+ EnumSpec::WithEnum.attributes[:without_enum]
23
+ end
24
+
25
+ let(:object) do
26
+ EnumSpec::WithEnum.new
27
+ end
28
+
29
+ context "when values are provided for an attribute" do
30
+ it "is marked as enum for single_value" do
31
+ expect(single_value_attr.enum?).to be(true)
32
+ end
33
+
34
+ it "is marked as enum for multi_value" do
35
+ expect(multi_value_attr.enum?).to be(true)
36
+ end
37
+
38
+ context "with enum convinience methods" do
39
+ describe "#single_value" do
40
+ it "returns single value" do
41
+ expect { object.single_value = "user" }
42
+ .to change(object, :single_value)
43
+ .from(nil)
44
+ .to("user")
45
+ end
46
+ end
47
+
48
+ describe "#multi_value" do
49
+ it "returns single value in array" do
50
+ expect { object.multi_value = "dual" }
51
+ .to change(object, :multi_value)
52
+ .from([])
53
+ .to(["dual"])
54
+ end
55
+
56
+ it "returns multiple value in array" do
57
+ expect { object.multi_value = %w[dual plural] }
58
+ .to change(object, :multi_value)
59
+ .from([])
60
+ .to(%w[dual plural])
61
+ end
62
+ end
63
+
64
+ EnumSpec::WithEnum.enums.each_value do |enum_attr|
65
+ enum_attr.enum_values.each do |value|
66
+ describe "##{value}=" do
67
+ it "sets the #{value} if true" do
68
+ expect { object.public_send(:"#{value}=", true) }
69
+ .to change { object.public_send(:"#{value}?") }
70
+ .from(false)
71
+ .to(true)
72
+ end
73
+
74
+ it "unsets the #{value} if false" do
75
+ object.public_send(:"#{value}=", true)
76
+
77
+ expect { object.public_send(:"#{value}=", false) }
78
+ .to change(object, "#{value}?")
79
+ .from(true)
80
+ .to(false)
81
+ end
82
+ end
83
+
84
+ describe "##{value}!" do
85
+ it "method #{value}? should be present" do
86
+ expect(object.respond_to?(:"#{value}!")).to be(true)
87
+ end
88
+
89
+ it "sets #{value} to true for enum" do
90
+ expect { object.public_send(:"#{value}!") }
91
+ .to change(object, "#{value}?")
92
+ .from(false)
93
+ .to(true)
94
+ end
95
+ end
96
+
97
+ describe "##{value}?" do
98
+ it "method #{value}? should be present" do
99
+ expect(object.respond_to?(:"#{value}?")).to be(true)
100
+ end
101
+
102
+ it "is false if role is not #{value}" do
103
+ expect(object.public_send(:"#{value}?")).to be(false)
104
+ end
105
+
106
+ it "is true if role is set to #{value}" do
107
+ expect { object.public_send(:"#{value}=", value) }
108
+ .to change(object, "#{value}?")
109
+ .from(false)
110
+ .to(true)
111
+ end
112
+ end
113
+
114
+ it "adds a method named #{value}=" do
115
+ expect(object.respond_to?(:"#{value}?")).to be(true)
116
+ end
117
+
118
+ it "adds a method named #{value}!" do
119
+ expect(object.respond_to?(:"#{value}!")).to be(true)
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
125
+
126
+ context "when values are not provided for an attribute" do
127
+ it "is not marked as enum" do
128
+ expect(without_enum_attr.enum?).to be(false)
129
+ end
130
+ end
131
+ end