lutaml-model 0.8.17 → 0.8.18

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 (192) 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/schema-import.adoc +42 -0
  14. data/docs/_guides/xml-mapping.adoc +21 -0
  15. data/docs/_pages/validation.adoc +65 -0
  16. data/lib/compat/opal/generate_boot.rb +123 -0
  17. data/lib/compat/opal/js_bundle_entry.rb +42 -0
  18. data/lib/compat/opal/lutaml_model_boot.rb +497 -0
  19. data/lib/compat/opal/moxml_boot.rb +68 -0
  20. data/lib/compat/opal/yaml_compat.rb +32 -0
  21. data/lib/lutaml/json.rb +1 -1
  22. data/lib/lutaml/key_value/transform.rb +3 -8
  23. data/lib/lutaml/key_value/transformation/value_serializer.rb +14 -1
  24. data/lib/lutaml/key_value/transformation.rb +17 -5
  25. data/lib/lutaml/model/adapter_resolver.rb +2 -2
  26. data/lib/lutaml/model/attribute.rb +88 -11
  27. data/lib/lutaml/model/cached_type_resolver.rb +1 -1
  28. data/lib/lutaml/model/error/union_schema_unsupported_error.rb +11 -0
  29. data/lib/lutaml/model/global_context.rb +11 -8
  30. data/lib/lutaml/model/instrumentation.rb +2 -2
  31. data/lib/lutaml/model/mapping/mapping_rule.rb +0 -6
  32. data/lib/lutaml/model/register.rb +2 -2
  33. data/lib/lutaml/model/runtime_compatibility.rb +50 -0
  34. data/lib/lutaml/model/schema/class_loader.rb +58 -0
  35. data/lib/lutaml/model/schema/compiled_output.rb +83 -0
  36. data/lib/lutaml/model/schema/definitions/attribute.rb +32 -0
  37. data/lib/lutaml/model/schema/definitions/choice.rb +20 -0
  38. data/lib/lutaml/model/schema/definitions/facet.rb +40 -0
  39. data/lib/lutaml/model/schema/definitions/group_import.rb +22 -0
  40. data/lib/lutaml/model/schema/definitions/member_walk.rb +33 -0
  41. data/lib/lutaml/model/schema/definitions/model.rb +40 -0
  42. data/lib/lutaml/model/schema/definitions/namespace.rb +23 -0
  43. data/lib/lutaml/model/schema/definitions/restricted_type.rb +30 -0
  44. data/lib/lutaml/model/schema/definitions/sequence.rb +19 -0
  45. data/lib/lutaml/model/schema/definitions/simple_content.rb +22 -0
  46. data/lib/lutaml/model/schema/definitions/transform_facet.rb +21 -0
  47. data/lib/lutaml/model/schema/definitions/type_ref.rb +20 -0
  48. data/lib/lutaml/model/schema/definitions/union_type.rb +28 -0
  49. data/lib/lutaml/model/schema/definitions/xml_root.rb +20 -0
  50. data/lib/lutaml/model/schema/definitions.rb +24 -0
  51. data/lib/lutaml/model/schema/file_writer.rb +70 -0
  52. data/lib/lutaml/model/schema/generator/definitions_collection.rb +16 -2
  53. data/lib/lutaml/model/schema/generator/property.rb +21 -4
  54. data/lib/lutaml/model/schema/module_nesting.rb +31 -0
  55. data/lib/lutaml/model/schema/namespace_naming.rb +46 -0
  56. data/lib/lutaml/model/schema/registry_generator.rb +115 -0
  57. data/lib/lutaml/model/schema/renderers/base.rb +40 -0
  58. data/lib/lutaml/model/schema/renderers/mappings.rb +72 -0
  59. data/lib/lutaml/model/schema/renderers/member_decls.rb +115 -0
  60. data/lib/lutaml/model/schema/renderers/model.rb +121 -0
  61. data/lib/lutaml/model/schema/renderers/namespace.rb +30 -0
  62. data/lib/lutaml/model/schema/renderers/registration.rb +64 -0
  63. data/lib/lutaml/model/schema/renderers/required_files_calculator.rb +93 -0
  64. data/lib/lutaml/model/schema/renderers/restricted_type.rb +88 -0
  65. data/lib/lutaml/model/schema/renderers/union.rb +83 -0
  66. data/lib/lutaml/model/schema/renderers.rb +19 -0
  67. data/lib/lutaml/model/schema/rnc_compiler/source_resolver.rb +47 -0
  68. data/lib/lutaml/model/schema/rnc_compiler.rb +62 -0
  69. data/lib/lutaml/model/schema/rng_compiler/define_classifier.rb +91 -0
  70. data/lib/lutaml/model/schema/rng_compiler/element_visitor.rb +392 -0
  71. data/lib/lutaml/model/schema/rng_compiler/member_collector.rb +30 -0
  72. data/lib/lutaml/model/schema/rng_compiler/rng_helpers.rb +152 -0
  73. data/lib/lutaml/model/schema/rng_compiler/value_type_resolver.rb +125 -0
  74. data/lib/lutaml/model/schema/rng_compiler.rb +220 -0
  75. data/lib/lutaml/model/schema/templates.rb +162 -0
  76. data/lib/lutaml/model/schema/xml_compiler/element_order.rb +41 -0
  77. data/lib/lutaml/model/schema/xml_compiler/registry_generator.rb +31 -107
  78. data/lib/lutaml/model/schema/xml_compiler/spec_builder/complex_types.rb +191 -0
  79. data/lib/lutaml/model/schema/xml_compiler/spec_builder/members.rb +216 -0
  80. data/lib/lutaml/model/schema/xml_compiler/spec_builder/simple_types.rb +153 -0
  81. data/lib/lutaml/model/schema/xml_compiler/spec_builder.rb +167 -0
  82. data/lib/lutaml/model/schema/xml_compiler/supported_data_types.rb +87 -0
  83. data/lib/lutaml/model/schema/xml_compiler.rb +38 -517
  84. data/lib/lutaml/model/schema.rb +19 -0
  85. data/lib/lutaml/model/serialize/attribute_definition.rb +14 -2
  86. data/lib/lutaml/model/serialize/builder.rb +15 -2
  87. data/lib/lutaml/model/serialize/value_mapping.rb +9 -57
  88. data/lib/lutaml/model/serialize.rb +6 -9
  89. data/lib/lutaml/model/services/rule_value_extractor.rb +2 -3
  90. data/lib/lutaml/model/services.rb +0 -2
  91. data/lib/lutaml/model/store.rb +2 -1
  92. data/lib/lutaml/model/toml.rb +1 -1
  93. data/lib/lutaml/model/transform.rb +17 -39
  94. data/lib/lutaml/model/transformation_registry.rb +1 -1
  95. data/lib/lutaml/model/type.rb +1 -0
  96. data/lib/lutaml/model/union.rb +320 -0
  97. data/lib/lutaml/model/utils.rb +6 -0
  98. data/lib/lutaml/model/validation.rb +1 -4
  99. data/lib/lutaml/model/version.rb +1 -1
  100. data/lib/lutaml/model.rb +18 -0
  101. data/lib/lutaml/toml.rb +1 -1
  102. data/lib/lutaml/xml/adapter.rb +1 -1
  103. data/lib/lutaml/xml/adapter_loader.rb +1 -1
  104. data/lib/lutaml/xml/builder/base.rb +1 -1
  105. data/lib/lutaml/xml/builder.rb +1 -1
  106. data/lib/lutaml/xml/error/schema_validation_error.rb +25 -0
  107. data/lib/lutaml/xml/model_transform.rb +5 -1
  108. data/lib/lutaml/xml/schema/relaxng_schema.rb +5 -0
  109. data/lib/lutaml/xml/schema/xsd_schema.rb +4 -0
  110. data/lib/lutaml/xml/serialization/format_conversion.rb +48 -0
  111. data/lib/lutaml/xml/serialization/instance_methods.rb +13 -0
  112. data/lib/lutaml/xml/transformation/element_builder.rb +21 -8
  113. data/lib/lutaml/xml/transformation/ordered_applier.rb +6 -0
  114. data/lib/lutaml/xml/xsd_validator.rb +66 -0
  115. data/lib/lutaml/xml.rb +53 -12
  116. data/lutaml-model.gemspec +4 -2
  117. data/spec/fixtures/xml/schema/rnc/address_book.rnc +15 -0
  118. data/spec/fixtures/xml/schema/rnc/book_features.rnc +18 -0
  119. data/spec/fixtures/xml/schema/rnc/includes/book.rnc +6 -0
  120. data/spec/fixtures/xml/schema/rnc/includes/circular_a.rnc +8 -0
  121. data/spec/fixtures/xml/schema/rnc/includes/circular_b.rnc +8 -0
  122. data/spec/fixtures/xml/schema/rnc/includes/library.rnc +8 -0
  123. data/spec/fixtures/xml/schema/rng/address_book.rng +16 -0
  124. data/spec/fixtures/xml/schema/rng/book.rng +27 -0
  125. data/spec/fixtures/xml/schema/rng/fixed_value.rng +11 -0
  126. data/spec/fixtures/xml/schema/rng/fragment_in_choice.rng +30 -0
  127. data/spec/fixtures/xml/schema/rng/full_name.rng +39 -0
  128. data/spec/fixtures/xml/schema/rng/integer_range.rng +32 -0
  129. data/spec/fixtures/xml/schema/rng/list_type.rng +12 -0
  130. data/spec/fixtures/xml/schema/rng/namespaced.rng +9 -0
  131. data/spec/fixtures/xml/schema/rng/paragraph.rng +13 -0
  132. data/spec/fixtures/xml/schema/rng/person.rng +23 -0
  133. data/spec/fixtures/xml/schema/rng/union.rng +14 -0
  134. data/spec/fixtures/xml/validate_xml_with_address.xsd +13 -0
  135. data/spec/fixtures/xml/validate_xml_with_contact.xsd +11 -0
  136. data/spec/fixtures/xml/validate_xml_with_person.xsd +11 -0
  137. data/spec/fixtures/xml/validate_xml_with_person_strict.xsd +17 -0
  138. data/spec/fixtures/xml/validate_xml_with_product.xsd +37 -0
  139. data/spec/lutaml/model/attribute_apply_value_map_spec.rb +134 -0
  140. data/spec/lutaml/model/attribute_spec.rb +169 -12
  141. data/spec/lutaml/model/boot_manifest_spec.rb +30 -0
  142. data/spec/lutaml/model/collection_spec.rb +18 -0
  143. data/spec/lutaml/model/mixed_content_spec.rb +171 -0
  144. data/spec/lutaml/model/opal_smoke_spec.rb +67 -2
  145. data/spec/lutaml/model/ordered_content_spec.rb +30 -0
  146. data/spec/lutaml/model/raw_element_spec.rb +125 -0
  147. data/spec/lutaml/model/rule_value_extractor_spec.rb +7 -14
  148. data/spec/lutaml/model/runtime_compatibility_spec.rb +25 -0
  149. data/spec/lutaml/model/schema/renderers/model_spec.rb +149 -0
  150. data/spec/lutaml/model/schema/renderers/namespace_spec.rb +36 -0
  151. data/spec/lutaml/model/schema/renderers/restricted_type_spec.rb +75 -0
  152. data/spec/lutaml/model/schema/renderers/union_spec.rb +58 -0
  153. data/spec/lutaml/model/transform_apply_value_map_spec.rb +64 -0
  154. data/spec/lutaml/model/union_attribute_spec.rb +658 -0
  155. data/spec/lutaml/model/union_spec.rb +257 -0
  156. data/spec/lutaml/model/utils_spec.rb +14 -0
  157. data/spec/lutaml/xml/opal_xml_spec.rb +22 -20
  158. data/spec/lutaml/xml/schema/compiler_spec.rb +21 -4
  159. data/spec/lutaml/xml/schema/rnc_compiler_spec.rb +404 -0
  160. data/spec/lutaml/xml/schema/rng_compiler_spec.rb +893 -0
  161. data/spec/lutaml/xml/validate_xml_with_constructs_spec.rb +222 -0
  162. data/spec/lutaml/xml/validate_xml_with_spec.rb +186 -0
  163. data/spec/spec_helper.rb +28 -11
  164. data/spec/support/opal.rb +5 -2
  165. metadata +97 -31
  166. data/lib/lutaml/model/schema/xml_compiler/attribute.rb +0 -106
  167. data/lib/lutaml/model/schema/xml_compiler/attribute_group.rb +0 -47
  168. data/lib/lutaml/model/schema/xml_compiler/choice.rb +0 -67
  169. data/lib/lutaml/model/schema/xml_compiler/complex_content.rb +0 -27
  170. data/lib/lutaml/model/schema/xml_compiler/complex_content_restriction.rb +0 -34
  171. data/lib/lutaml/model/schema/xml_compiler/complex_type.rb +0 -173
  172. data/lib/lutaml/model/schema/xml_compiler/element.rb +0 -121
  173. data/lib/lutaml/model/schema/xml_compiler/group.rb +0 -113
  174. data/lib/lutaml/model/schema/xml_compiler/restriction.rb +0 -104
  175. data/lib/lutaml/model/schema/xml_compiler/sequence.rb +0 -52
  176. data/lib/lutaml/model/schema/xml_compiler/simple_content.rb +0 -40
  177. data/lib/lutaml/model/schema/xml_compiler/simple_type.rb +0 -265
  178. data/lib/lutaml/model/schema/xml_compiler/xml_namespace_class.rb +0 -110
  179. data/lib/lutaml/model/services/default_value_resolver.rb +0 -60
  180. data/spec/lutaml/model/services/default_value_resolver_spec.rb +0 -162
  181. data/spec/lutaml/xml/schema/compiler/attribute_group_spec.rb +0 -71
  182. data/spec/lutaml/xml/schema/compiler/attribute_spec.rb +0 -67
  183. data/spec/lutaml/xml/schema/compiler/choice_spec.rb +0 -74
  184. data/spec/lutaml/xml/schema/compiler/complex_content_restriction_spec.rb +0 -60
  185. data/spec/lutaml/xml/schema/compiler/complex_content_spec.rb +0 -41
  186. data/spec/lutaml/xml/schema/compiler/complex_type_spec.rb +0 -202
  187. data/spec/lutaml/xml/schema/compiler/element_spec.rb +0 -68
  188. data/spec/lutaml/xml/schema/compiler/group_spec.rb +0 -90
  189. data/spec/lutaml/xml/schema/compiler/restriction_spec.rb +0 -77
  190. data/spec/lutaml/xml/schema/compiler/sequence_spec.rb +0 -65
  191. data/spec/lutaml/xml/schema/compiler/simple_content_spec.rb +0 -61
  192. data/spec/lutaml/xml/schema/compiler/simple_type_spec.rb +0 -184
@@ -39,8 +39,10 @@ module Lutaml
39
39
 
40
40
  return self unless block
41
41
 
42
- # Enable order tracking for mixed_content models
43
- @__order_tracking__ = mixed_content?
42
+ # Enable order tracking for ordered and mixed_content models.
43
+ # `ordered?` is true for both `ordered` and `mixed_content`
44
+ # (mixed_content sets @ordered = true in addition to @mixed_content).
45
+ @__order_tracking__ = ordered?
44
46
 
45
47
  # Evaluate the block - use instance_eval for no-receiver style
46
48
  # The block's first parameter determines the style:
@@ -62,6 +64,17 @@ module Lutaml
62
64
  mapping&.mixed_content? || false
63
65
  end
64
66
 
67
+ # Check if this model has ordered or mixed_content enabled.
68
+ # Both set @ordered = true on the XML mapping; mixed_content
69
+ # additionally sets @mixed_content = true. Order tracking is
70
+ # needed for either mode so the builder can emit elements in
71
+ # setter-call order rather than declaration order.
72
+ # @return [Boolean]
73
+ def ordered?
74
+ mapping = self.class.mappings_for(:xml, lutaml_register)
75
+ mapping&.ordered? || false
76
+ end
77
+
65
78
  private
66
79
 
67
80
  # Intercept method calls to track order for mixed_content
@@ -71,71 +71,19 @@ module Lutaml
71
71
  propagated.merge(register: instance.lutaml_register))
72
72
  end
73
73
 
74
- # Apply a value map to transform a value
74
+ # Apply a value map to transform a value.
75
75
  #
76
- # Handles nil, empty, and omitted values according to the value map.
76
+ # Delegates to Attribute#apply_value_map. See attribute.rb for the
77
+ # canonical implementation.
77
78
  #
78
79
  # @param value [Object] The value to transform
79
80
  # @param value_map [Hash] The value map configuration
80
81
  # @param attr [Attribute] The attribute definition
81
82
  # @return [Object] The transformed value
82
83
  def apply_value_map(value, value_map, attr)
83
- if value.nil?
84
- value_for_option(value_map[:nil], attr)
85
- elsif Utils.empty?(value)
86
- # Check for new boolean value_map format (from: { empty: true/false })
87
- # Only use new format if the value is explicitly boolean (TrueClass or FalseClass)
88
- if value_map[:from] && (value_map[:from][:empty].is_a?(TrueClass) || value_map[:from][:empty].is_a?(FalseClass))
89
- return value_map[:from][:empty]
90
- end
91
- # Check for direct boolean format (rule.value_map(:from) returns { empty: true })
92
- # Only return directly if it's a boolean value (TrueClass/FalseClass), not a symbol
93
- if value_map[:empty].is_a?(TrueClass) || value_map[:empty].is_a?(FalseClass)
94
- return value_map[:empty]
95
- end
96
-
97
- # Fall back to legacy value_map format
98
- value_for_option(value_map[:empty], attr, value)
99
- elsif Utils.uninitialized?(value)
100
- # Check for new boolean value_map format (from: { omitted: true/false })
101
- # Only use new format if the value is explicitly boolean (TrueClass or FalseClass)
102
- if value_map[:from] && (value_map[:from][:omitted].is_a?(TrueClass) || value_map[:from][:omitted].is_a?(FalseClass))
103
- return value_map[:from][:omitted]
104
- end
105
- # Check for direct boolean format (rule.value_map(:from) returns { omitted: false })
106
- # Only return directly if it's a boolean value (TrueClass/FalseClass), not a symbol
107
- if value_map[:omitted].is_a?(TrueClass) || value_map[:omitted].is_a?(FalseClass)
108
- return value_map[:omitted]
109
- end
84
+ return value unless attr
110
85
 
111
- # Fall back to legacy value_map format
112
- value_for_option(value_map[:omitted], attr)
113
- else
114
- value
115
- end
116
- end
117
-
118
- # Get the value for a specific option in a value map
119
- #
120
- # @param option [Symbol] The option (:nil, :empty, etc.)
121
- # @param attr [Attribute] The attribute definition
122
- # @param empty_value [Object] The empty value to use (optional)
123
- # @return [Object, nil, UninitializedClass] The appropriate value
124
- def value_for_option(option, attr, empty_value = nil)
125
- return nil if option == :nil
126
- return empty_value || empty_object(attr) if option == :empty
127
-
128
- Lutaml::Model::UninitializedClass.instance
129
- end
130
-
131
- # Get an empty object for an attribute
132
- #
133
- # @param attr [Attribute] The attribute definition
134
- # @return [String, Array] Empty string or collection
135
- def empty_object(attr)
136
- return attr.build_collection if attr.collection?
137
-
138
- ""
86
+ attr.apply_value_map(value, value_map)
139
87
  end
140
88
 
141
89
  # Ensure a value is UTF-8 encoded
@@ -145,6 +93,10 @@ module Lutaml
145
93
  def ensure_utf8(value)
146
94
  case value
147
95
  when String
96
+ # Opal strings are JS strings with no per-string encoding;
97
+ # String#encode there doesn't accept MRI's keyword options.
98
+ return value if Lutaml::Model.opal?
99
+
148
100
  value.encode("UTF-8", invalid: :replace, undef: :replace,
149
101
  replace: "")
150
102
  when Array
@@ -202,9 +202,7 @@ module Lutaml
202
202
  def validate_attribute!(attr_name)
203
203
  attr = self.class.attributes[attr_name]
204
204
  value = instance_variable_get(:"@#{attr_name}")
205
- resolver = Services::DefaultValueResolver.new(attr, lutaml_register,
206
- self)
207
- attr.validate_value!(value, lutaml_register, resolver)
205
+ attr.validate_value!(value, lutaml_register, instance_object: self)
208
206
  end
209
207
 
210
208
  def key_exist?(hash, key)
@@ -301,12 +299,11 @@ module Lutaml
301
299
  return attr_value(attrs, name, attr)
302
300
  end
303
301
 
304
- if attr.default_set?(lutaml_register, self)
305
- using_default_for(name)
306
- attr.default(lutaml_register, self)
307
- else
308
- Lutaml::Model::UninitializedClass.instance
309
- end
302
+ resolved = attr.default_value(lutaml_register, self)
303
+ return Lutaml::Model::UninitializedClass.instance if Lutaml::Model::Utils.uninitialized?(resolved)
304
+
305
+ using_default_for(name)
306
+ attr.cast_value(resolved, lutaml_register)
310
307
  end
311
308
 
312
309
  def resolve_reference_key(ref)
@@ -50,9 +50,8 @@ instance_object)
50
50
  return fetch_value(name) if Utils.string_or_symbol_key?(doc, name)
51
51
 
52
52
  if attr
53
- resolver = Services::DefaultValueResolver.new(attr, register,
54
- instance_object)
55
- return resolver.default_value if resolver.default_set?
53
+ resolved = attr.default_value(register, instance_object)
54
+ return resolved unless Utils.uninitialized?(resolved)
56
55
  end
57
56
 
58
57
  uninitialized_value
@@ -4,8 +4,6 @@ module Lutaml
4
4
  module Model
5
5
  module Services
6
6
  autoload :Base, "#{__dir__}/services/base"
7
- autoload :DefaultValueResolver,
8
- "#{__dir__}/services/default_value_resolver"
9
7
  autoload :Type, "#{__dir__}/services/type"
10
8
  end
11
9
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "weakref"
3
+ # Opal has no `weakref` stdlib; runtime_compatibility.rb provides a stub.
4
+ require "weakref" unless Lutaml::Model.opal?
4
5
 
5
6
  module Lutaml
6
7
  module Model
@@ -16,7 +16,7 @@ module Lutaml
16
16
  Transform = ::Lutaml::Toml::Adapter::Transform
17
17
 
18
18
  def self.detect_toml_adapter
19
- return nil if Lutaml::Model::RuntimeCompatibility.opal?
19
+ return nil if Lutaml::Model.opal?
20
20
 
21
21
  # Skip tomlib on Windows entirely due to segfault issues
22
22
  if Lutaml::Model::RuntimeCompatibility.windows?
@@ -85,48 +85,26 @@ module Lutaml
85
85
  protected
86
86
 
87
87
  def apply_value_map(value, value_map, attr)
88
- if value.nil?
89
- value_for_option(value_map[:nil], attr)
90
- elsif Utils.empty?(value)
91
- # Check for boolean value_map format (value_map[:empty] is true/false)
92
- # Only apply for Boolean type attributes
93
- if value_map[:empty].is_a?(TrueClass) || value_map[:empty].is_a?(FalseClass)
94
- # Check if attribute is a Boolean type
95
- attr_type = attr&.type || attr&.unresolved_type
96
- if attr_type == Lutaml::Model::Type::Boolean
97
- return value_map[:empty]
98
- end
99
- end
100
- value_for_option(value_map[:empty], attr, value)
101
- elsif Utils.uninitialized?(value)
102
- # Check for boolean value_map format (value_map[:omitted] is true/false)
103
- # Only apply for Boolean type attributes
104
- if value_map[:omitted].is_a?(TrueClass) || value_map[:omitted].is_a?(FalseClass)
105
- # Check if attribute is a Boolean type
106
- attr_type = attr&.type || attr&.unresolved_type
107
- if attr_type == Lutaml::Model::Type::Boolean
108
- return value_map[:omitted]
109
- end
110
- end
111
- value_for_option(value_map[:omitted], attr)
112
- else
113
- value
88
+ return attr.apply_value_map(value, value_map) if attr
89
+
90
+ # Attribute-less dispatch for custom-method-only rules. Mirrors
91
+ # Attribute#apply_value_map for the options that need no attribute
92
+ # info: :nil -> nil and :omitted -> uninitialized. A typed :empty needs
93
+ # an Attribute (a crash case pre-consolidation), so the value passes
94
+ # through unchanged.
95
+ key = if value.nil? then :nil
96
+ elsif Utils.empty?(value) then :empty
97
+ elsif Utils.uninitialized?(value) then :omitted
98
+ end
99
+ return value unless key
100
+
101
+ case value_map[key]
102
+ when :nil then nil
103
+ when :empty then value
104
+ else Lutaml::Model::UninitializedClass.instance
114
105
  end
115
106
  end
116
107
 
117
- def value_for_option(option, attr, empty_value = nil)
118
- return nil if option == :nil
119
- return empty_value || empty_object(attr) if option == :empty
120
-
121
- Lutaml::Model::UninitializedClass.instance
122
- end
123
-
124
- def empty_object(attr)
125
- return [] if attr.collection?
126
-
127
- ""
128
- end
129
-
130
108
  def mappings_for(format, register = nil)
131
109
  context.mappings_for(format, register)
132
110
  end
@@ -90,7 +90,7 @@ module Lutaml
90
90
 
91
91
  def initialize
92
92
  @transformations = {} # Cache for transformation instances (mutex-protected for cycle detection)
93
- @mappings = if RuntimeCompatibility.opal?
93
+ @mappings = if Lutaml::Model.opal?
94
94
  {}
95
95
  else
96
96
  Concurrent::Map.new
@@ -16,6 +16,7 @@ module Lutaml
16
16
  autoload :Boolean, "#{__dir__}/type/boolean"
17
17
  autoload :Decimal, "#{__dir__}/type/decimal"
18
18
  autoload :Reference, "#{__dir__}/type/reference"
19
+ autoload :Union, "#{__dir__}/union"
19
20
  autoload :Hash, "#{__dir__}/type/hash"
20
21
  autoload :Symbol, "#{__dir__}/type/symbol"
21
22
  autoload :Duration, "#{__dir__}/type/duration"
@@ -0,0 +1,320 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Model
5
+ module Type
6
+ # Functional xsd:union: an attribute value conforms to one of several
7
+ # declared member types. A single shared class recognized by identity
8
+ # (mirrors Type::Reference); the per-attribute member list lives in the
9
+ # attribute's @options[:union_member_types]. Stateless: serialization is
10
+ # driven by value.class, never by per-instance tracking.
11
+ #
12
+ # This class owns ALL union knowledge: the conformance engine, the
13
+ # definition-time validation, and the per-format mapped-field source.
14
+ # Every serializer touchpoint is a thin identity check that delegates here.
15
+ class Union < Value
16
+ # Options that cannot be combined with a union member list.
17
+ INCOMPATIBLE_OPTIONS = %i[polymorphic raw with child_mappings].freeze
18
+
19
+ # Native Ruby class a parsed/plain-Ruby value already has when it
20
+ # conforms to a scalar member without needing a lexical-space parse.
21
+ NATIVE_CLASSES = {
22
+ Lutaml::Model::Type::Integer => ::Integer,
23
+ Lutaml::Model::Type::Float => ::Float,
24
+ Lutaml::Model::Type::Boolean => [::TrueClass, ::FalseClass],
25
+ Lutaml::Model::Type::String => ::String,
26
+ }.freeze
27
+
28
+ # Native classes for value types whose dependency loads lazily
29
+ # (require "bigdecimal" / "date"). Looked up by constant name so the
30
+ # union never force-loads an optional dependency; if such a value
31
+ # exists at runtime, its constant is necessarily already defined.
32
+ # Decimal's native class (BigDecimal) loads lazily (require
33
+ # "bigdecimal"); looked up by constant name so the union never
34
+ # force-loads it. (Decimal is the only supported value type with a
35
+ # lazily-loaded native class.)
36
+ LAZY_NATIVE_CLASS_NAMES = {
37
+ Lutaml::Model::Type::Decimal => :BigDecimal,
38
+ }.freeze
39
+
40
+ INTEGER_LEXICAL = /\A\s*[+-]?\d+\s*\z/
41
+ # Matches Type::Boolean.cast (XSD true/false/1/0 plus the library's
42
+ # t/f/yes/no/y/n forms), so a union :boolean accepts the same inputs as
43
+ # a plain :boolean attribute.
44
+ BOOLEAN_LEXICAL = /\A(true|false|t|f|yes|no|y|n|1|0)\z/i
45
+
46
+ FLOAT_LEXICAL = lambda do |string|
47
+ Float(string).finite?
48
+ rescue ArgumentError, TypeError
49
+ false
50
+ end
51
+
52
+ # Whether a String input belongs to a scalar member's lexical space.
53
+ # Keyed by member class (identity), parallel to NATIVE_CLASSES.
54
+ LEXICAL_PREDICATES = {
55
+ Lutaml::Model::Type::Integer => ->(s) { s.match?(INTEGER_LEXICAL) },
56
+ Lutaml::Model::Type::Float => FLOAT_LEXICAL,
57
+ Lutaml::Model::Type::Decimal => FLOAT_LEXICAL,
58
+ Lutaml::Model::Type::Boolean => ->(s) { s.match?(BOOLEAN_LEXICAL) },
59
+ Lutaml::Model::Type::String => ->(_s) { true },
60
+ }.freeze
61
+
62
+ # Sound scalar union members are exactly the value types with a lexical
63
+ # predicate above: each maps to a distinct, stable Ruby runtime class
64
+ # (Integer, Float, BigDecimal, TrueClass/FalseClass, String), so the
65
+ # stateless value.class-driven serialization can always recover the
66
+ # member. Other value types are excluded because they cannot round-trip
67
+ # soundly in a union — e.g. Type::Date.cast can yield a DateTime, and
68
+ # Time / TimeWithoutDate share a Ruby Time class. Models are always OK.
69
+ SUPPORTED_VALUE_TYPES = LEXICAL_PREDICATES.keys.freeze
70
+
71
+ # Lossless numeric widening: a source value type may conform to a
72
+ # broader member. Narrowing (Float -> integer) and unrelated types are
73
+ # absent and therefore rejected. Exact-class matches are handled
74
+ # earlier by the instance_of? check in conform_scalar.
75
+ WIDENS_INTO = {
76
+ Integer => [Float, Decimal],
77
+ Float => [Decimal],
78
+ }.freeze
79
+
80
+ class << self
81
+ # Resolve the first member (in declared order) whose lexical/structural
82
+ # space the value belongs to, and the value cast to that member.
83
+ #
84
+ # @param value [Object] raw value (scalar, Hash sub-tree, or XmlElement)
85
+ # @param members [Array<Class>] resolved members, declared order
86
+ # @param format [Symbol, nil] serialization format being deserialized
87
+ # @param register [Symbol, nil] register for nested-model resolution
88
+ # @return [Array(Class, Object), nil] [member, casted] or nil (no match)
89
+ def conforming_member(value, members, format:, register:)
90
+ return nil if value.nil? || Utils.uninitialized?(value)
91
+
92
+ members.each do |member|
93
+ casted = if model_member?(member)
94
+ conform_model(member, value, format, register)
95
+ else
96
+ conform_scalar(member, value)
97
+ end
98
+ return [member, casted] unless casted == :no_match
99
+ end
100
+ nil
101
+ end
102
+
103
+ # Whether a compiled mapping rule's attribute is a union — the single
104
+ # identity check every serializer touchpoint shares.
105
+ def rule?(rule)
106
+ rule.attribute_type == self
107
+ end
108
+
109
+ # An XML child is routed to member resolution (vs the plain-text
110
+ # scalar path) when it has child elements or attributes. Resolution
111
+ # tries model members by key coverage, then falls back to the scalar
112
+ # members on the element's text — so a text-only element with an
113
+ # unrelated attribute (e.g. xml:lang) still resolves to a scalar.
114
+ def xml_structured?(element)
115
+ return false unless element.respond_to?(:children)
116
+
117
+ element.children.any?(&:element?) || element.attributes.any?
118
+ end
119
+
120
+ # Serialize a scalar union value through the value type matching its
121
+ # own class, so output is canonical/portable (a BigDecimal emits
122
+ # "1.5", not a raw Ruby object). value.class drives the type —
123
+ # stateless, identical to how a plain scalar attribute serializes.
124
+ # Falls back to the value as-is when it maps to no known value type.
125
+ def serialize_scalar(value, format)
126
+ type = scalar_type_for(value)
127
+ return value unless type
128
+
129
+ type.new(value).public_send(:"to_#{format}")
130
+ end
131
+
132
+ def validate_members!(members)
133
+ unless members.is_a?(::Array) && !members.empty?
134
+ raise ArgumentError, "union type must be a non-empty array of types"
135
+ end
136
+
137
+ resolved = members.map { |member| resolve_member!(member) }
138
+ validate_catch_all_position!(resolved)
139
+ resolved
140
+ end
141
+
142
+ def validate_combo!(options)
143
+ present = INCOMPATIBLE_OPTIONS.select { |key| options[key] }
144
+ return if present.empty?
145
+
146
+ raise ArgumentError,
147
+ "union type cannot be combined with: #{present.join(', ')}"
148
+ end
149
+
150
+ private
151
+
152
+ def model_member?(member)
153
+ member.is_a?(::Class) && member.include?(Serialize)
154
+ end
155
+
156
+ # Structural (key-coverage) selection: every input key maps to one of
157
+ # the member's mapped fields, with no input key left unmapped. Once
158
+ # selected, field values cast leniently via the member's normal path.
159
+ # An existing member instance conforms as-is, keeping conformance
160
+ # idempotent — values can re-enter casting (e.g. a value transformer
161
+ # builds the member before the cast runs).
162
+ def conform_model(member, value, format, register)
163
+ return value if value.is_a?(member)
164
+
165
+ keys = input_keys(value, format)
166
+ return :no_match if keys.nil? || keys.empty?
167
+
168
+ field_names = member_field_names(member, format, register)
169
+ return :no_match unless keys.all? { |key| field_names.include?(key) }
170
+
171
+ # Plain-Ruby assignment (format nil) builds straight from the
172
+ # attribute hash, mirroring how non-union model attributes accept
173
+ # `Model.new(child: { ... })`; a format builds via the mappings.
174
+ return member.new(value) if format.nil?
175
+
176
+ member.apply_mappings(value, format, register: register)
177
+ end
178
+
179
+ # A scalar conforms if the value already is the member's native Ruby
180
+ # class (covers parsed and plain-Ruby values), or if a String input
181
+ # belongs to the member's space. Lenient built-ins (Integer/Float/
182
+ # Decimal/Boolean/String) are gated by an explicit lexical predicate
183
+ # so their forgiving casts can't false-match; every other value type
184
+ # owns its space via its own (strict) cast — nil/raise means no match.
185
+ def conform_scalar(member, value)
186
+ # Scalar payload: an XML element contributes its text (so a scalar
187
+ # member matches when no model member covers it); Hash and primitive
188
+ # inputs pass through unchanged.
189
+ value = value.text if xml_element?(value)
190
+
191
+ # Exact native class (instance_of?): the value is precisely the
192
+ # member's native Ruby class (covers parsed and plain-Ruby values).
193
+ native = Array(NATIVE_CLASSES[member] || lazy_native_class(member))
194
+ return value if native.any? { |klass| value.instance_of?(klass) }
195
+
196
+ predicate = LEXICAL_PREDICATES[member]
197
+ return :no_match unless predicate
198
+
199
+ # String input: accept only within the member's lexical space.
200
+ if value.is_a?(::String)
201
+ return predicate.call(value) ? member.cast(value) : :no_match
202
+ end
203
+
204
+ # Native non-string value (e.g. a parsed Integer for a :float
205
+ # member): accept only as a lossless widening into a broader member,
206
+ # so a lenient built-in can't narrow or distort it — :float accepts
207
+ # 42, but :integer won't swallow 3.7 or 3.0. lossless_cast then
208
+ # guards precision (e.g. 2**60+1 is not exactly representable as
209
+ # Float).
210
+ source = scalar_type_for(value)
211
+ return :no_match unless WIDENS_INTO[source]&.include?(member)
212
+
213
+ lossless_cast(member, value)
214
+ end
215
+
216
+ # A native non-string value conforms to a lenient built-in only if the
217
+ # member's cast preserves it (value-equal round-trip), so cross-numeric
218
+ # widening is accepted but narrowing/distortion is not.
219
+ def lossless_cast(member, value)
220
+ casted = member.cast(value)
221
+ casted == value ? casted : :no_match
222
+ rescue StandardError
223
+ :no_match
224
+ end
225
+
226
+ def lazy_native_class(member)
227
+ const_name = LAZY_NATIVE_CLASS_NAMES[member]
228
+ return nil unless const_name && Object.const_defined?(const_name)
229
+
230
+ Object.const_get(const_name)
231
+ end
232
+
233
+ # The value type whose native Ruby class the value is an instance of
234
+ # (reverse of NATIVE_CLASSES plus the lazily-loaded native classes).
235
+ def scalar_type_for(value)
236
+ NATIVE_CLASSES.each do |type, klasses|
237
+ return type if Array(klasses).any? { |k| value.instance_of?(k) }
238
+ end
239
+ LAZY_NATIVE_CLASS_NAMES.each_key do |type|
240
+ klass = lazy_native_class(type)
241
+ return type if klass && value.instance_of?(klass)
242
+ end
243
+ nil
244
+ end
245
+
246
+ # Input keys by format: Hash keys for key-value; child element local
247
+ # names + attribute names for XML; nil otherwise.
248
+ def input_keys(value, format)
249
+ return value.keys.map(&:to_s) if value.is_a?(::Hash)
250
+
251
+ if format == :xml && xml_element?(value)
252
+ names = value.children.select(&:element?).map(&:unprefixed_name) +
253
+ value.attributes.values.map(&:unprefixed_name)
254
+ return names.map(&:to_s)
255
+ end
256
+
257
+ nil
258
+ end
259
+
260
+ def xml_element?(value)
261
+ value.respond_to?(:children) && value.respond_to?(:attributes)
262
+ end
263
+
264
+ # The set of mapped field names for a member in the given format.
265
+ # One source, parameterized by format. A nil format is plain-Ruby
266
+ # assignment, which has no mapping context — match on attribute names.
267
+ def member_field_names(member, format, register)
268
+ return member.attributes.keys.map(&:to_s) if format.nil?
269
+
270
+ mapping = member.mappings_for(format, register)
271
+ rules = if format == :xml
272
+ mapping.elements(register) + mapping.attributes(register)
273
+ else
274
+ mapping.mappings(register)
275
+ end
276
+ rules.flat_map do |rule|
277
+ (rule.multiple_mappings? ? rule.name : [rule.name]).map(&:to_s)
278
+ end
279
+ end
280
+
281
+ def resolve_member!(member)
282
+ if member.is_a?(::Hash)
283
+ raise ArgumentError,
284
+ "union members must be types or symbols; option hashes " \
285
+ "(e.g. { ref: ... }) are not supported as union members"
286
+ end
287
+
288
+ klass =
289
+ begin
290
+ Lutaml::Model::Attribute.cast_type!(member)
291
+ rescue ArgumentError
292
+ nil
293
+ end
294
+ unless klass
295
+ raise ArgumentError, "invalid union member: #{member.inspect}"
296
+ end
297
+ return klass if klass.is_a?(::Class) && klass.include?(Serialize)
298
+ return klass if SUPPORTED_VALUE_TYPES.include?(klass)
299
+
300
+ raise ArgumentError,
301
+ "unsupported union member type: #{member.inspect}. Supported " \
302
+ "scalar members are :string, :integer, :float, :decimal, " \
303
+ ":boolean, or a Serializable model"
304
+ end
305
+
306
+ # The catch-all must be the last member overall (not merely the last
307
+ # value type): XML scalar matching reads element.text, so a :string
308
+ # placed before a model member would swallow structured elements too.
309
+ def validate_catch_all_position!(resolved)
310
+ return unless resolved.include?(Lutaml::Model::Type::String)
311
+ return if resolved.last == Lutaml::Model::Type::String
312
+
313
+ raise ArgumentError,
314
+ "a universal catch-all type must be the last union member"
315
+ end
316
+ end
317
+ end
318
+ end
319
+ end
320
+ end
@@ -102,6 +102,12 @@ module Lutaml
102
102
  snake_case(base_class_name(klass))
103
103
  end
104
104
 
105
+ # Return the final segment of a `prefix:local` style qualified name,
106
+ # e.g. `"xs:string" -> "string"`. Returns nil for nil input.
107
+ def last_of_split(field)
108
+ field&.split(":")&.last
109
+ end
110
+
105
111
  def initialized?(value)
106
112
  !value.equal?(UNINITIALIZED)
107
113
  end
@@ -14,10 +14,7 @@ module Lutaml
14
14
  sub_errors = value.validate
15
15
  errors.concat(sub_errors) if sub_errors.is_a?(Array)
16
16
  else
17
- resolver = Lutaml::Model::Services::DefaultValueResolver.new(
18
- attr, register, self
19
- )
20
- attr.validate_value!(value, register, resolver)
17
+ attr.validate_value!(value, register, instance_object: self)
21
18
  end
22
19
  rescue Lutaml::Model::CollectionCountOutOfRangeError => e
23
20
  errors << e unless attr.choice
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Model
5
- VERSION = "0.8.17"
5
+ VERSION = "0.8.18"
6
6
  end
7
7
  end
data/lib/lutaml/model.rb CHANGED
@@ -142,6 +142,8 @@ module Lutaml
142
142
  autoload :UnknownTypeError, "#{__dir__}/model/error/unknown_type_error"
143
143
  autoload :RequiredAttributeMissingError,
144
144
  "#{__dir__}/model/error/required_attribute_missing_error"
145
+ autoload :UnionSchemaUnsupportedError,
146
+ "#{__dir__}/model/error/union_schema_unsupported_error"
145
147
  autoload :MultipleMappingsError,
146
148
  "#{__dir__}/model/error/multiple_mappings_error"
147
149
  autoload :CollectionTrueMissingError,
@@ -235,6 +237,22 @@ module Lutaml
235
237
  def self.reset_configuration!
236
238
  @configuration = nil
237
239
  end
240
+
241
+ # Convenience accessors for runtime/platform detection.
242
+ # Delegates to RuntimeCompatibility so call sites can write
243
+ # `Lutaml::Model.opal?` (or `Model.opal?` inside the namespace)
244
+ # instead of the long `Lutaml::Model::RuntimeCompatibility.opal?`.
245
+ def self.opal?
246
+ RuntimeCompatibility.opal?
247
+ end
248
+
249
+ def self.native?
250
+ RuntimeCompatibility.native?
251
+ end
252
+
253
+ def self.windows?
254
+ RuntimeCompatibility.windows?
255
+ end
238
256
  end
239
257
  end
240
258