lutaml-model 0.8.17 → 0.8.19

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 (195) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/downstream-performance.yml +27 -1
  3. data/.github/workflows/js-pr-check.yml +102 -0
  4. data/.github/workflows/js-sync.yml +51 -0
  5. data/.github/workflows/opal.yml +26 -6
  6. data/.github/workflows/performance.yml +32 -7
  7. data/.github/workflows/rake.yml +78 -32
  8. data/.gitmodules +6 -0
  9. data/.rubocop_todo.yml +8 -0
  10. data/Gemfile +16 -1
  11. data/README.adoc +267 -0
  12. data/Rakefile +166 -4
  13. data/docs/_guides/builder-dsl.adoc +127 -0
  14. data/docs/_guides/schema-import.adoc +42 -0
  15. data/docs/_guides/xml-mapping.adoc +21 -0
  16. data/docs/_pages/validation.adoc +65 -0
  17. data/lib/compat/opal/generate_boot.rb +123 -0
  18. data/lib/compat/opal/js_bundle_entry.rb +42 -0
  19. data/lib/compat/opal/lutaml_model_boot.rb +497 -0
  20. data/lib/compat/opal/moxml_boot.rb +68 -0
  21. data/lib/compat/opal/yaml_compat.rb +32 -0
  22. data/lib/lutaml/json.rb +1 -1
  23. data/lib/lutaml/key_value/transform.rb +3 -8
  24. data/lib/lutaml/key_value/transformation/value_serializer.rb +14 -1
  25. data/lib/lutaml/key_value/transformation.rb +17 -5
  26. data/lib/lutaml/model/adapter_resolver.rb +2 -2
  27. data/lib/lutaml/model/attribute.rb +88 -11
  28. data/lib/lutaml/model/cached_type_resolver.rb +1 -1
  29. data/lib/lutaml/model/error/union_schema_unsupported_error.rb +11 -0
  30. data/lib/lutaml/model/global_context.rb +11 -8
  31. data/lib/lutaml/model/instrumentation.rb +2 -2
  32. data/lib/lutaml/model/mapping/mapping_rule.rb +0 -6
  33. data/lib/lutaml/model/register.rb +2 -2
  34. data/lib/lutaml/model/runtime_compatibility.rb +50 -0
  35. data/lib/lutaml/model/schema/class_loader.rb +58 -0
  36. data/lib/lutaml/model/schema/compiled_output.rb +83 -0
  37. data/lib/lutaml/model/schema/definitions/attribute.rb +32 -0
  38. data/lib/lutaml/model/schema/definitions/choice.rb +20 -0
  39. data/lib/lutaml/model/schema/definitions/facet.rb +40 -0
  40. data/lib/lutaml/model/schema/definitions/group_import.rb +22 -0
  41. data/lib/lutaml/model/schema/definitions/member_walk.rb +33 -0
  42. data/lib/lutaml/model/schema/definitions/model.rb +40 -0
  43. data/lib/lutaml/model/schema/definitions/namespace.rb +23 -0
  44. data/lib/lutaml/model/schema/definitions/restricted_type.rb +30 -0
  45. data/lib/lutaml/model/schema/definitions/sequence.rb +19 -0
  46. data/lib/lutaml/model/schema/definitions/simple_content.rb +22 -0
  47. data/lib/lutaml/model/schema/definitions/transform_facet.rb +21 -0
  48. data/lib/lutaml/model/schema/definitions/type_ref.rb +20 -0
  49. data/lib/lutaml/model/schema/definitions/union_type.rb +28 -0
  50. data/lib/lutaml/model/schema/definitions/xml_root.rb +20 -0
  51. data/lib/lutaml/model/schema/definitions.rb +24 -0
  52. data/lib/lutaml/model/schema/file_writer.rb +70 -0
  53. data/lib/lutaml/model/schema/generator/definitions_collection.rb +16 -2
  54. data/lib/lutaml/model/schema/generator/property.rb +21 -4
  55. data/lib/lutaml/model/schema/module_nesting.rb +31 -0
  56. data/lib/lutaml/model/schema/namespace_naming.rb +46 -0
  57. data/lib/lutaml/model/schema/registry_generator.rb +115 -0
  58. data/lib/lutaml/model/schema/renderers/base.rb +40 -0
  59. data/lib/lutaml/model/schema/renderers/mappings.rb +72 -0
  60. data/lib/lutaml/model/schema/renderers/member_decls.rb +115 -0
  61. data/lib/lutaml/model/schema/renderers/model.rb +121 -0
  62. data/lib/lutaml/model/schema/renderers/namespace.rb +30 -0
  63. data/lib/lutaml/model/schema/renderers/registration.rb +64 -0
  64. data/lib/lutaml/model/schema/renderers/required_files_calculator.rb +93 -0
  65. data/lib/lutaml/model/schema/renderers/restricted_type.rb +88 -0
  66. data/lib/lutaml/model/schema/renderers/union.rb +83 -0
  67. data/lib/lutaml/model/schema/renderers.rb +19 -0
  68. data/lib/lutaml/model/schema/rnc_compiler/source_resolver.rb +47 -0
  69. data/lib/lutaml/model/schema/rnc_compiler.rb +62 -0
  70. data/lib/lutaml/model/schema/rng_compiler/define_classifier.rb +91 -0
  71. data/lib/lutaml/model/schema/rng_compiler/element_visitor.rb +392 -0
  72. data/lib/lutaml/model/schema/rng_compiler/member_collector.rb +30 -0
  73. data/lib/lutaml/model/schema/rng_compiler/rng_helpers.rb +152 -0
  74. data/lib/lutaml/model/schema/rng_compiler/value_type_resolver.rb +125 -0
  75. data/lib/lutaml/model/schema/rng_compiler.rb +220 -0
  76. data/lib/lutaml/model/schema/templates.rb +162 -0
  77. data/lib/lutaml/model/schema/xml_compiler/element_order.rb +41 -0
  78. data/lib/lutaml/model/schema/xml_compiler/registry_generator.rb +31 -107
  79. data/lib/lutaml/model/schema/xml_compiler/spec_builder/complex_types.rb +191 -0
  80. data/lib/lutaml/model/schema/xml_compiler/spec_builder/members.rb +216 -0
  81. data/lib/lutaml/model/schema/xml_compiler/spec_builder/simple_types.rb +153 -0
  82. data/lib/lutaml/model/schema/xml_compiler/spec_builder.rb +167 -0
  83. data/lib/lutaml/model/schema/xml_compiler/supported_data_types.rb +87 -0
  84. data/lib/lutaml/model/schema/xml_compiler.rb +38 -517
  85. data/lib/lutaml/model/schema.rb +19 -0
  86. data/lib/lutaml/model/serialize/attribute_definition.rb +23 -11
  87. data/lib/lutaml/model/serialize/builder.rb +70 -2
  88. data/lib/lutaml/model/serialize/initialization.rb +3 -2
  89. data/lib/lutaml/model/serialize/value_mapping.rb +9 -57
  90. data/lib/lutaml/model/serialize.rb +6 -9
  91. data/lib/lutaml/model/services/rule_value_extractor.rb +2 -3
  92. data/lib/lutaml/model/services.rb +0 -2
  93. data/lib/lutaml/model/store.rb +2 -1
  94. data/lib/lutaml/model/toml.rb +1 -1
  95. data/lib/lutaml/model/transform.rb +17 -39
  96. data/lib/lutaml/model/transformation_registry.rb +1 -1
  97. data/lib/lutaml/model/type.rb +1 -0
  98. data/lib/lutaml/model/union.rb +320 -0
  99. data/lib/lutaml/model/utils.rb +6 -0
  100. data/lib/lutaml/model/validation.rb +1 -4
  101. data/lib/lutaml/model/version.rb +1 -1
  102. data/lib/lutaml/model.rb +18 -0
  103. data/lib/lutaml/toml.rb +1 -1
  104. data/lib/lutaml/xml/adapter.rb +1 -1
  105. data/lib/lutaml/xml/adapter_loader.rb +1 -1
  106. data/lib/lutaml/xml/builder/base.rb +1 -1
  107. data/lib/lutaml/xml/builder.rb +1 -1
  108. data/lib/lutaml/xml/error/schema_validation_error.rb +25 -0
  109. data/lib/lutaml/xml/model_transform.rb +5 -1
  110. data/lib/lutaml/xml/schema/relaxng_schema.rb +5 -0
  111. data/lib/lutaml/xml/schema/xsd_schema.rb +4 -0
  112. data/lib/lutaml/xml/serialization/format_conversion.rb +48 -0
  113. data/lib/lutaml/xml/serialization/instance_methods.rb +13 -0
  114. data/lib/lutaml/xml/transformation/element_builder.rb +21 -8
  115. data/lib/lutaml/xml/transformation/ordered_applier.rb +104 -4
  116. data/lib/lutaml/xml/xsd_validator.rb +66 -0
  117. data/lib/lutaml/xml.rb +53 -12
  118. data/lutaml-model.gemspec +4 -2
  119. data/spec/fixtures/xml/schema/rnc/address_book.rnc +15 -0
  120. data/spec/fixtures/xml/schema/rnc/book_features.rnc +18 -0
  121. data/spec/fixtures/xml/schema/rnc/includes/book.rnc +6 -0
  122. data/spec/fixtures/xml/schema/rnc/includes/circular_a.rnc +8 -0
  123. data/spec/fixtures/xml/schema/rnc/includes/circular_b.rnc +8 -0
  124. data/spec/fixtures/xml/schema/rnc/includes/library.rnc +8 -0
  125. data/spec/fixtures/xml/schema/rng/address_book.rng +16 -0
  126. data/spec/fixtures/xml/schema/rng/book.rng +27 -0
  127. data/spec/fixtures/xml/schema/rng/fixed_value.rng +11 -0
  128. data/spec/fixtures/xml/schema/rng/fragment_in_choice.rng +30 -0
  129. data/spec/fixtures/xml/schema/rng/full_name.rng +39 -0
  130. data/spec/fixtures/xml/schema/rng/integer_range.rng +32 -0
  131. data/spec/fixtures/xml/schema/rng/list_type.rng +12 -0
  132. data/spec/fixtures/xml/schema/rng/namespaced.rng +9 -0
  133. data/spec/fixtures/xml/schema/rng/paragraph.rng +13 -0
  134. data/spec/fixtures/xml/schema/rng/person.rng +23 -0
  135. data/spec/fixtures/xml/schema/rng/union.rng +14 -0
  136. data/spec/fixtures/xml/validate_xml_with_address.xsd +13 -0
  137. data/spec/fixtures/xml/validate_xml_with_contact.xsd +11 -0
  138. data/spec/fixtures/xml/validate_xml_with_person.xsd +11 -0
  139. data/spec/fixtures/xml/validate_xml_with_person_strict.xsd +17 -0
  140. data/spec/fixtures/xml/validate_xml_with_product.xsd +37 -0
  141. data/spec/lutaml/model/attribute_apply_value_map_spec.rb +134 -0
  142. data/spec/lutaml/model/attribute_spec.rb +169 -12
  143. data/spec/lutaml/model/boot_manifest_spec.rb +30 -0
  144. data/spec/lutaml/model/collection_spec.rb +18 -0
  145. data/spec/lutaml/model/mixed_content_spec.rb +171 -0
  146. data/spec/lutaml/model/opal_smoke_spec.rb +67 -2
  147. data/spec/lutaml/model/ordered_content_spec.rb +41 -0
  148. data/spec/lutaml/model/raw_element_spec.rb +125 -0
  149. data/spec/lutaml/model/rule_value_extractor_spec.rb +7 -14
  150. data/spec/lutaml/model/runtime_compatibility_spec.rb +25 -0
  151. data/spec/lutaml/model/schema/renderers/model_spec.rb +149 -0
  152. data/spec/lutaml/model/schema/renderers/namespace_spec.rb +36 -0
  153. data/spec/lutaml/model/schema/renderers/restricted_type_spec.rb +75 -0
  154. data/spec/lutaml/model/schema/renderers/union_spec.rb +58 -0
  155. data/spec/lutaml/model/serialize/builder_spec.rb +358 -0
  156. data/spec/lutaml/model/transform_apply_value_map_spec.rb +64 -0
  157. data/spec/lutaml/model/union_attribute_spec.rb +658 -0
  158. data/spec/lutaml/model/union_spec.rb +257 -0
  159. data/spec/lutaml/model/utils_spec.rb +14 -0
  160. data/spec/lutaml/xml/opal_xml_spec.rb +22 -20
  161. data/spec/lutaml/xml/schema/compiler_spec.rb +21 -4
  162. data/spec/lutaml/xml/schema/rnc_compiler_spec.rb +404 -0
  163. data/spec/lutaml/xml/schema/rng_compiler_spec.rb +893 -0
  164. data/spec/lutaml/xml/validate_xml_with_constructs_spec.rb +222 -0
  165. data/spec/lutaml/xml/validate_xml_with_spec.rb +186 -0
  166. data/spec/spec_helper.rb +28 -11
  167. data/spec/support/opal.rb +5 -2
  168. metadata +99 -31
  169. data/lib/lutaml/model/schema/xml_compiler/attribute.rb +0 -106
  170. data/lib/lutaml/model/schema/xml_compiler/attribute_group.rb +0 -47
  171. data/lib/lutaml/model/schema/xml_compiler/choice.rb +0 -67
  172. data/lib/lutaml/model/schema/xml_compiler/complex_content.rb +0 -27
  173. data/lib/lutaml/model/schema/xml_compiler/complex_content_restriction.rb +0 -34
  174. data/lib/lutaml/model/schema/xml_compiler/complex_type.rb +0 -173
  175. data/lib/lutaml/model/schema/xml_compiler/element.rb +0 -121
  176. data/lib/lutaml/model/schema/xml_compiler/group.rb +0 -113
  177. data/lib/lutaml/model/schema/xml_compiler/restriction.rb +0 -104
  178. data/lib/lutaml/model/schema/xml_compiler/sequence.rb +0 -52
  179. data/lib/lutaml/model/schema/xml_compiler/simple_content.rb +0 -40
  180. data/lib/lutaml/model/schema/xml_compiler/simple_type.rb +0 -265
  181. data/lib/lutaml/model/schema/xml_compiler/xml_namespace_class.rb +0 -110
  182. data/lib/lutaml/model/services/default_value_resolver.rb +0 -60
  183. data/spec/lutaml/model/services/default_value_resolver_spec.rb +0 -162
  184. data/spec/lutaml/xml/schema/compiler/attribute_group_spec.rb +0 -71
  185. data/spec/lutaml/xml/schema/compiler/attribute_spec.rb +0 -67
  186. data/spec/lutaml/xml/schema/compiler/choice_spec.rb +0 -74
  187. data/spec/lutaml/xml/schema/compiler/complex_content_restriction_spec.rb +0 -60
  188. data/spec/lutaml/xml/schema/compiler/complex_content_spec.rb +0 -41
  189. data/spec/lutaml/xml/schema/compiler/complex_type_spec.rb +0 -202
  190. data/spec/lutaml/xml/schema/compiler/element_spec.rb +0 -68
  191. data/spec/lutaml/xml/schema/compiler/group_spec.rb +0 -90
  192. data/spec/lutaml/xml/schema/compiler/restriction_spec.rb +0 -77
  193. data/spec/lutaml/xml/schema/compiler/sequence_spec.rb +0 -65
  194. data/spec/lutaml/xml/schema/compiler/simple_content_spec.rb +0 -61
  195. data/spec/lutaml/xml/schema/compiler/simple_type_spec.rb +0 -184
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "lutaml/model/schema/definitions"
5
+ require "lutaml/model/schema/renderers/namespace"
6
+
7
+ RSpec.describe Lutaml::Model::Schema::Renderers::Namespace do
8
+ let(:defs) { Lutaml::Model::Schema::Definitions }
9
+
10
+ it "renders a namespace class with uri and prefix_default" do
11
+ spec = defs::Namespace.new(
12
+ class_name: "ExampleNs",
13
+ uri: "http://example.com/ns",
14
+ prefix_default: "ex",
15
+ )
16
+
17
+ output = described_class.render(spec)
18
+
19
+ expect(output).to include("class ExampleNs < Lutaml::Xml::W3c::XmlNamespace")
20
+ expect(output).to include(%(uri "http://example.com/ns"))
21
+ expect(output).to include(%(prefix_default "ex"))
22
+ end
23
+
24
+ it "wraps in module when module_namespace is supplied" do
25
+ spec = defs::Namespace.new(
26
+ class_name: "Inner",
27
+ uri: "http://example.com/inner",
28
+ prefix_default: nil,
29
+ )
30
+
31
+ output = described_class.render(spec, module_namespace: "Mod::Sub")
32
+
33
+ expect(output).to include("module Mod")
34
+ expect(output).to include("module Sub")
35
+ end
36
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "lutaml/model/schema/definitions"
5
+ require "lutaml/model/schema/renderers/restricted_type"
6
+
7
+ RSpec.describe Lutaml::Model::Schema::Renderers::RestrictedType do
8
+ let(:defs) { Lutaml::Model::Schema::Definitions }
9
+
10
+ it "renders min/max facets in the cast body" do
11
+ spec = defs::RestrictedType.new(
12
+ class_name: "PositiveInt",
13
+ parent_class: "Lutaml::Model::Type::Integer",
14
+ facets: defs::Facet.new(min_inclusive: 1, max_inclusive: 100),
15
+ )
16
+
17
+ output = described_class.render(spec)
18
+
19
+ expect(output).to include("class PositiveInt < Lutaml::Model::Type::Integer")
20
+ expect(output).to include("def self.cast(value, options = {})")
21
+ expect(output).to include("options[:min] = 1")
22
+ expect(output).to include("options[:max] = 100")
23
+ expect(output).to include("value = super(value, options)")
24
+ end
25
+
26
+ it "renders enumerations facet" do
27
+ spec = defs::RestrictedType.new(
28
+ class_name: "Color",
29
+ parent_class: "Lutaml::Model::Type::String",
30
+ facets: defs::Facet.new(enumerations: %w[red green blue]),
31
+ )
32
+
33
+ output = described_class.render(spec)
34
+
35
+ expect(output).to include('options[:values] = [super("red"), super("green"), super("blue")]')
36
+ expect(output).not_to include("options[:min]")
37
+ end
38
+
39
+ it "renders pattern facet as a regexp literal" do
40
+ spec = defs::RestrictedType.new(
41
+ class_name: "Slug",
42
+ parent_class: "Lutaml::Model::Type::String",
43
+ facets: defs::Facet.new(pattern: "[a-z0-9-]+"),
44
+ )
45
+
46
+ output = described_class.render(spec)
47
+
48
+ expect(output).to include("options[:pattern] = %r{[a-z0-9-]+}")
49
+ end
50
+
51
+ it "renders transform facet" do
52
+ spec = defs::RestrictedType.new(
53
+ class_name: "Upper",
54
+ parent_class: "Lutaml::Model::Type::String",
55
+ facets: defs::Facet.new,
56
+ transform_facet: defs::TransformFacet.new(expression: "value.upcase"),
57
+ )
58
+
59
+ output = described_class.render(spec)
60
+
61
+ expect(output).to include("value = value.upcase")
62
+ end
63
+
64
+ it "uses lazy register memoisation" do
65
+ spec = defs::RestrictedType.new(
66
+ class_name: "Tiny",
67
+ parent_class: "Lutaml::Model::Type::Integer",
68
+ facets: defs::Facet.new(max_inclusive: 5),
69
+ )
70
+
71
+ output = described_class.render(spec)
72
+
73
+ expect(output).to include("@register ||= Lutaml::Model::Config.default_register")
74
+ end
75
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "lutaml/model/schema/definitions"
5
+ require "lutaml/model/schema/renderers/union"
6
+
7
+ RSpec.describe Lutaml::Model::Schema::Renderers::Union do
8
+ let(:defs) { Lutaml::Model::Schema::Definitions }
9
+
10
+ it "renders the :class_refs strategy as an each/begin/rescue loop" do
11
+ spec = defs::UnionType.new(
12
+ class_name: "NumberOrString",
13
+ members: [
14
+ defs::TypeRef.new(kind: :class_ref, value: "Integer"),
15
+ defs::TypeRef.new(kind: :class_ref, value: "String"),
16
+ ],
17
+ cast_strategy: :class_refs,
18
+ )
19
+
20
+ output = described_class.render(spec)
21
+
22
+ expect(output).to include("class NumberOrString < Lutaml::Model::Type::Value")
23
+ expect(output).to include("[Integer, String].each do |t|")
24
+ expect(output).to include("casted = t.cast(value, options)")
25
+ expect(output).to include("return casted unless casted.nil?")
26
+ expect(output).to include("rescue StandardError")
27
+ end
28
+
29
+ it "renders the :resolve_type strategy as a ||-chained resolve_type call" do
30
+ spec = defs::UnionType.new(
31
+ class_name: "IntegerOrToken",
32
+ members: [
33
+ defs::TypeRef.new(kind: :symbol, value: "integer"),
34
+ defs::TypeRef.new(kind: :symbol, value: "token"),
35
+ ],
36
+ cast_strategy: :resolve_type,
37
+ )
38
+
39
+ output = described_class.render(spec)
40
+
41
+ expect(output).to include("Lutaml::Model::GlobalContext.resolve_type(:integer, @register).cast(value, options)")
42
+ expect(output).to include("Lutaml::Model::GlobalContext.resolve_type(:token, @register).cast(value, options)")
43
+ expect(output).to include(" ||\n")
44
+ end
45
+
46
+ it "emits required_files lines above the module wrap" do
47
+ spec = defs::UnionType.new(
48
+ class_name: "AnyOf",
49
+ members: [defs::TypeRef.new(kind: :class_ref, value: "Integer")],
50
+ cast_strategy: :class_refs,
51
+ required_files: ['require_relative "integer"'],
52
+ )
53
+
54
+ output = described_class.render(spec)
55
+
56
+ expect(output).to include('require_relative "integer"')
57
+ end
58
+ end
@@ -0,0 +1,358 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require_relative "../../../../lib/lutaml/model"
5
+ require "lutaml/xml/adapter/nokogiri_adapter"
6
+
7
+ # Specs for Lutaml::Model::Serialize::Builder — the module that gives
8
+ # Serializable the `Klass.new do |x| ... end` block syntax.
9
+ #
10
+ # These specs exist because of a regression where direct setters
11
+ # (`x.attr = v`) inside the builder block silently dropped attributes
12
+ # from serialized output while appender calls (`x.attr(v)`) worked.
13
+ # The root cause was that only getter-with-arg paths called track_order;
14
+ # generated setters did not. The fix centralises mutation recording via
15
+ # Builder#record_mutation / record_mutation_collection, and the
16
+ # serializer's apply_remaining_rules acts as a safety net so element
17
+ # values are never silently dropped.
18
+ #
19
+ # These specs cover the invariant ("no silent data loss"), call-order
20
+ # preservation, round-trip parity, equivalence between .tap and block,
21
+ # the no-op guarantee for non-ordered models, and the wholesale
22
+ # collection assignment case.
23
+ RSpec.describe "Lutaml::Model::Serialize::Builder" do
24
+ let(:ordered_klass) do
25
+ Class.new(Lutaml::Model::Serializable) do
26
+ attribute :singular, :string
27
+ attribute :items, :string, collection: true
28
+
29
+ xml do
30
+ element "container"
31
+ ordered
32
+ map_element "singular", to: :singular
33
+ map_element "items", to: :items
34
+ end
35
+ end
36
+ end
37
+
38
+ let(:mixed_klass) do
39
+ Class.new(Lutaml::Model::Serializable) do
40
+ attribute :singular, :string
41
+ attribute :items, :string, collection: true
42
+
43
+ xml do
44
+ element "container"
45
+ mixed_content
46
+ map_element "singular", to: :singular
47
+ map_element "items", to: :items
48
+ end
49
+ end
50
+ end
51
+
52
+ let(:plain_klass) do
53
+ Class.new(Lutaml::Model::Serializable) do
54
+ attribute :singular, :string
55
+ attribute :items, :string, collection: true
56
+
57
+ xml do
58
+ element "container"
59
+ map_element "singular", to: :singular
60
+ map_element "items", to: :items
61
+ end
62
+ end
63
+ end
64
+
65
+ shared_examples "no silent data loss across mutation styles" do
66
+ it "appender-only: emits every call in order" do
67
+ obj = ordered_klass.new do |x|
68
+ x.singular "first"
69
+ x.items "a"
70
+ x.items "b"
71
+ end
72
+
73
+ xml = obj.to_xml
74
+ expect(xml).to be_xml_equivalent_to(<<~XML)
75
+ <container>
76
+ <singular>first</singular>
77
+ <items>a</items>
78
+ <items>b</items>
79
+ </container>
80
+ XML
81
+ expect(obj.element_order.map(&:name)).to eq(%w[singular items items])
82
+ end
83
+
84
+ it "direct setter for singular: still emits the element" do
85
+ obj = ordered_klass.new do |x|
86
+ x.singular = "first"
87
+ x.items "a"
88
+ end
89
+
90
+ xml = obj.to_xml
91
+ expect(xml).to be_xml_equivalent_to(<<~XML)
92
+ <container>
93
+ <singular>first</singular>
94
+ <items>a</items>
95
+ </container>
96
+ XML
97
+ expect(obj.element_order.map(&:name)).to eq(%w[singular items])
98
+ end
99
+
100
+ it "wholesale collection assignment: emits one element per item" do
101
+ obj = ordered_klass.new do |x|
102
+ x.singular "first"
103
+ x.items = %w[a b c]
104
+ end
105
+
106
+ xml = obj.to_xml
107
+ expect(xml).to be_xml_equivalent_to(<<~XML)
108
+ <container>
109
+ <singular>first</singular>
110
+ <items>a</items>
111
+ <items>b</items>
112
+ <items>c</items>
113
+ </container>
114
+ XML
115
+ expect(obj.element_order.map(&:name)).to eq(%w[singular items items items])
116
+ end
117
+
118
+ it "all direct setters: emits in call order" do
119
+ obj = ordered_klass.new do |x|
120
+ x.singular = "first"
121
+ x.items = %w[a b]
122
+ end
123
+
124
+ xml = obj.to_xml
125
+ expect(xml).to be_xml_equivalent_to(<<~XML)
126
+ <container>
127
+ <singular>first</singular>
128
+ <items>a</items>
129
+ <items>b</items>
130
+ </container>
131
+ XML
132
+ end
133
+
134
+ it "mixed style preserves call order across setters and appenders" do
135
+ obj = ordered_klass.new do |x|
136
+ x.items "a"
137
+ x.singular = "middle"
138
+ x.items "b"
139
+ end
140
+
141
+ xml = obj.to_xml
142
+ expect(xml).to be_xml_equivalent_to(<<~XML)
143
+ <container>
144
+ <items>a</items>
145
+ <singular>middle</singular>
146
+ <items>b</items>
147
+ </container>
148
+ XML
149
+ end
150
+ end
151
+
152
+ describe "no silent data loss" do
153
+ it_behaves_like "no silent data loss across mutation styles"
154
+
155
+ it "mixed_content model emits setter-only attributes too" do
156
+ obj = mixed_klass.new do |x|
157
+ x.singular = "first"
158
+ x.items "a"
159
+ end
160
+
161
+ xml = obj.to_xml
162
+ expect(xml).to include("<singular>first</singular>")
163
+ expect(xml).to include("<items>a</items>")
164
+ expect(obj.element_order.map(&:name)).to eq(%w[singular items])
165
+ end
166
+ end
167
+
168
+ describe "call order preservation" do
169
+ it "reverse declaration order is honoured via direct setters" do
170
+ obj = ordered_klass.new do |x|
171
+ x.items = %w[a b]
172
+ x.singular = "last"
173
+ end
174
+
175
+ xml = obj.to_xml
176
+ items_pos = xml.index("<items>")
177
+ singular_pos = xml.index("<singular>")
178
+ expect(items_pos).to be < singular_pos
179
+ expect(obj.element_order.map(&:name)).to eq(%w[items items singular])
180
+ end
181
+
182
+ it "reverse declaration order is honoured via appenders" do
183
+ obj = ordered_klass.new do |x|
184
+ x.items "a"
185
+ x.singular "last"
186
+ end
187
+
188
+ xml = obj.to_xml
189
+ items_pos = xml.index("<items>")
190
+ singular_pos = xml.index("<singular>")
191
+ expect(items_pos).to be < singular_pos
192
+ expect(obj.element_order.map(&:name)).to eq(%w[items singular])
193
+ end
194
+ end
195
+
196
+ describe "round-trip parity" do
197
+ it "ordered: parses and re-emits equivalent XML" do
198
+ original = <<~XML
199
+ <container>
200
+ <items>a</items>
201
+ <singular>middle</singular>
202
+ <items>b</items>
203
+ </container>
204
+ XML
205
+
206
+ parsed = ordered_klass.from_xml(original)
207
+ expect(parsed.to_xml).to be_xml_equivalent_to(original)
208
+ end
209
+
210
+ it "mixed_content: parses and re-emits equivalent XML" do
211
+ original = <<~XML
212
+ <container>before <items>a</items> mid <singular>x</singular> end</container>
213
+ XML
214
+
215
+ parsed = mixed_klass.from_xml(original)
216
+ roundtrip = parsed.to_xml
217
+ expect(roundtrip).to include("<items>a</items>")
218
+ expect(roundtrip).to include("<singular>x</singular>")
219
+ end
220
+ end
221
+
222
+ describe ".tap vs builder block equivalence" do
223
+ it "produces identical element_order presence and XML shape" do
224
+ via_tap = ordered_klass.new.tap do |x|
225
+ x.singular = "first"
226
+ x.items = %w[a b]
227
+ end
228
+
229
+ via_block = ordered_klass.new do |x|
230
+ x.singular = "first"
231
+ x.items = %w[a b]
232
+ end
233
+
234
+ # .tap path does not enable tracking; both must still emit the same XML.
235
+ expect(via_tap.element_order).to be_nil
236
+ expect(via_block.element_order).not_to be_nil
237
+ expect(via_tap.to_xml).to be_xml_equivalent_to(via_block.to_xml)
238
+ end
239
+ end
240
+
241
+ describe "setter return value contract" do
242
+ # Ruby's setter contract: `obj.foo = v` evaluates to `v`. Callers
243
+ # like `obj.foo || (obj.foo = [])` depend on this. Generated
244
+ # setters must continue to honour this contract regardless of
245
+ # whether order tracking is enabled.
246
+ it "singular setter returns the assigned value" do
247
+ obj = plain_klass.new
248
+ expect((obj.singular = "v")).to eq("v")
249
+ end
250
+
251
+ it "collection setter returns the assigned value" do
252
+ obj = plain_klass.new
253
+ expect((obj.items = %w[a b])).to eq(%w[a b])
254
+ end
255
+
256
+ it "supports the `obj.foo || (obj.foo = [])` pattern from downstream consumers" do
257
+ obj = plain_klass.new
258
+ # Mirror Docbook::Elements::Para#try_add_inline, which relies on
259
+ # the setter returning the array (not nil) when the getter is nil.
260
+ # The bug: when the setter returned nil (because record_mutation
261
+ # returned nil with tracking disabled), `collection` was nil and
262
+ # `collection << element` failed with NoMethodError.
263
+ collection = obj.items || (obj.items = [])
264
+ expect(collection).to eq([])
265
+ expect { collection << "x" }.not_to raise_error
266
+ end
267
+ end
268
+
269
+ describe "no-op guarantee when not tracking" do
270
+ it "plain (non-ordered) models do not allocate element_order entries" do
271
+ obj = plain_klass.new do |x|
272
+ x.singular = "first"
273
+ x.items "a"
274
+ end
275
+
276
+ expect(obj.element_order).to be_nil
277
+ expect { obj.to_xml }.not_to raise_error
278
+ end
279
+
280
+ it "ordered models constructed without a block do not track" do
281
+ obj = ordered_klass.new
282
+ obj.singular = "first"
283
+ obj.items = %w[a b]
284
+
285
+ expect(obj.element_order).to be_nil
286
+ expect(obj.to_xml).to include("<singular>first</singular>")
287
+ end
288
+ end
289
+
290
+ describe "wholesale collection reassignment" do
291
+ it "tracks one entry per item across multiple assignments" do
292
+ obj = ordered_klass.new do |x|
293
+ x.items = %w[a b]
294
+ x.items = %w[c d e]
295
+ end
296
+
297
+ # element_order is a log of mutations, not a reflection of final state,
298
+ # so both assignments are recorded. The serializer emits the current
299
+ # value of the attribute via element_order in recorded order; the
300
+ # safety net guarantees no data loss.
301
+ expect(obj.element_order.map(&:name)).to eq(%w[items items items items items])
302
+ xml = obj.to_xml
303
+ # The serialized output must contain all current items
304
+ expect(xml).to include("<items>c</items>")
305
+ expect(xml).to include("<items>d</items>")
306
+ expect(xml).to include("<items>e</items>")
307
+ end
308
+ end
309
+
310
+ describe "no-silent-drop invariant (cross-cutting)" do
311
+ # The bug class: any attribute with a non-default value MUST appear in
312
+ # the serialized output, regardless of how it was set. This spec is the
313
+ # regression guard for the whole class of bug, not just one instance.
314
+ shared_examples "no silent drop" do |description, mutation_block|
315
+ it "does not silently drop any attribute set via: #{description}" do
316
+ obj = ordered_klass.new(&mutation_block)
317
+ xml = obj.to_xml
318
+
319
+ if !obj.singular.nil? && !obj.singular.to_s.empty?
320
+ expect(xml).to include("<singular>"), "singular was dropped from output"
321
+ end
322
+ return if obj.items.nil? || obj.items.empty?
323
+
324
+ obj.items.each do |item|
325
+ expect(xml).to include("<items>#{item}</items>"),
326
+ "items element for #{item.inspect} was dropped from output"
327
+ end
328
+ end
329
+ end
330
+
331
+ it_behaves_like "no silent drop",
332
+ "singular setter + wholesale items setter",
333
+ lambda { |x|
334
+ x.singular = "v"
335
+ x.items = %w[a b]
336
+ }
337
+ it_behaves_like "no silent drop",
338
+ "all appenders",
339
+ lambda { |x|
340
+ x.singular "v"
341
+ x.items "a"
342
+ x.items "b"
343
+ }
344
+ it_behaves_like "no silent drop",
345
+ "singular setter + items appender (the original bug)",
346
+ lambda { |x|
347
+ x.singular = "v"
348
+ x.items "a"
349
+ x.items "b"
350
+ }
351
+ it_behaves_like "no silent drop",
352
+ "singular appender + wholesale items setter",
353
+ lambda { |x|
354
+ x.singular "v"
355
+ x.items = %w[a b]
356
+ }
357
+ end
358
+ end
@@ -0,0 +1,64 @@
1
+ require "spec_helper"
2
+
3
+ # Regression: Transform#apply_value_map must handle the nil-attr case used
4
+ # by custom-method-only key-value rules. Pre-consolidation behavior:
5
+ # the default { omitted: :nil, nil: :nil, empty: :empty } map converted
6
+ # UninitializedClass.instance to nil so the downstream Utils.present? guard
7
+ # in KeyValue::Transform#process_mapping_rule would skip the custom method
8
+ # for absent input keys.
9
+ RSpec.describe Lutaml::Model::Transform, "#apply_value_map (nil attr)" do
10
+ let(:transform_subclass) do
11
+ Class.new(described_class) do
12
+ # Expose the protected delegator for direct testing.
13
+ public :apply_value_map
14
+ end
15
+ end
16
+
17
+ let(:context) { Class.new(Lutaml::Model::Serializable) }
18
+ let(:register) { Lutaml::Model::Config.default_register }
19
+ let(:transform) { transform_subclass.new(context, register) }
20
+
21
+ let(:uninit) { Lutaml::Model::UninitializedClass.instance }
22
+ let(:default_vmap) { { omitted: :nil, nil: :nil, empty: :empty } }
23
+
24
+ describe "default mapping with nil attr" do
25
+ it "converts UninitializedClass to nil via the :omitted => :nil mapping" do
26
+ # Before the fix: returned uninit unchanged, causing Utils.present? to
27
+ # be true and the custom method to run for absent fields.
28
+ expect(transform.apply_value_map(uninit, default_vmap, nil)).to be_nil
29
+ end
30
+
31
+ it "converts nil to nil via the :nil => :nil mapping" do
32
+ expect(transform.apply_value_map(nil, default_vmap, nil)).to be_nil
33
+ end
34
+
35
+ it "passes initialized values through unchanged" do
36
+ expect(transform.apply_value_map("hello", default_vmap, nil)).to eq("hello")
37
+ end
38
+ end
39
+
40
+ describe "explicit :omitted => :omitted with nil attr" do
41
+ let(:vmap) { { omitted: :omitted, nil: :nil, empty: :empty } }
42
+
43
+ it "preserves UninitializedClass for explicit omitted: :omitted" do
44
+ expect(transform.apply_value_map(uninit, vmap, nil)).to eq(uninit)
45
+ end
46
+ end
47
+
48
+ # Behavior-preservation: the pre-consolidation attribute-less path mapped the
49
+ # :omitted option to UninitializedClass.instance (present? => true, so the
50
+ # custom method runs), and resolved :nil/:empty consistently for empty inputs.
51
+ describe "non-:nil options with nil attr" do
52
+ it "maps a nil value to uninitialized via nil: :omitted" do
53
+ expect(transform.apply_value_map(nil, { nil: :omitted }, nil)).to eq(uninit)
54
+ end
55
+
56
+ it "maps an empty value to uninitialized via empty: :omitted" do
57
+ expect(transform.apply_value_map("", { empty: :omitted }, nil)).to eq(uninit)
58
+ end
59
+
60
+ it "maps an empty value to nil via empty: :nil" do
61
+ expect(transform.apply_value_map("", { empty: :nil }, nil)).to be_nil
62
+ end
63
+ end
64
+ end