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,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.19"
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
 
data/lib/lutaml/toml.rb CHANGED
@@ -30,7 +30,7 @@ Lutaml::Model::FormatRegistry.register(
30
30
  adapter_class: nil,
31
31
  transformer: Lutaml::Toml::Adapter::Transform,
32
32
  key_value: true,
33
- adapter_options: if Lutaml::Model::RuntimeCompatibility.opal?
33
+ adapter_options: if Lutaml::Model.opal?
34
34
  nil
35
35
  else
36
36
  {
@@ -13,11 +13,11 @@ module Lutaml
13
13
  autoload :NamespaceUriCollector,
14
14
  "#{__dir__}/adapter/namespace_uri_collector"
15
15
  autoload :OgaAdapter, "#{__dir__}/adapter/oga_adapter"
16
+ autoload :RexmlAdapter, "#{__dir__}/adapter/rexml_adapter"
16
17
  Lutaml::Model::RuntimeCompatibility.autoload_native(
17
18
  self,
18
19
  NokogiriAdapter: "#{__dir__}/adapter/nokogiri_adapter",
19
20
  OxAdapter: "#{__dir__}/adapter/ox_adapter",
20
- RexmlAdapter: "#{__dir__}/adapter/rexml_adapter",
21
21
  )
22
22
  end
23
23
  end
@@ -13,7 +13,7 @@ module Lutaml
13
13
  # @param _adapter [String] The adapter format name ("xml")
14
14
  # @param type [String] The normalized type name (e.g., "nokogiri_adapter")
15
15
  def self.load_adapter_file(_adapter, type)
16
- adapter_path = if Lutaml::Model::RuntimeCompatibility.opal?
16
+ adapter_path = if Lutaml::Model.opal?
17
17
  "lutaml/xml/adapter/#{type}"
18
18
  else
19
19
  File.join(File.dirname(__FILE__), "adapter", type)
@@ -14,7 +14,7 @@ module Lutaml
14
14
  class Base
15
15
  def self.build(options = {})
16
16
  context = Moxml.new(moxml_backend)
17
- if Lutaml::Model::RuntimeCompatibility.opal?
17
+ if Lutaml::Model.opal?
18
18
  context.config.namespace_validation_mode = :lenient
19
19
  end
20
20
 
@@ -6,11 +6,11 @@ module Lutaml
6
6
  module Builder
7
7
  autoload :Base, "#{__dir__}/builder/base"
8
8
  autoload :Oga, "#{__dir__}/builder/oga"
9
+ autoload :Rexml, "#{__dir__}/builder/rexml"
9
10
  Lutaml::Model::RuntimeCompatibility.autoload_native(
10
11
  self,
11
12
  Nokogiri: "#{__dir__}/builder/nokogiri",
12
13
  Ox: "#{__dir__}/builder/ox",
13
- Rexml: "#{__dir__}/builder/rexml",
14
14
  )
15
15
  end
16
16
  end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Xml
5
+ module Error
6
+ # Collected when a model's generated XML does not conform to the
7
+ # XSD schema configured via the `validate_xml_with` macro.
8
+ #
9
+ # One instance represents one schema violation reported by the
10
+ # validating parser, so Lutaml::Model::ValidationError#error_messages
11
+ # lists each violation individually.
12
+ #
13
+ # Not to be confused with Schema::Xsd::SchemaValidationError, which
14
+ # reports problems in an XSD document itself.
15
+ class SchemaValidationError < XmlError
16
+ attr_reader :schema_path
17
+
18
+ def initialize(message, schema_path)
19
+ @schema_path = schema_path
20
+ super("XML does not conform to schema #{schema_path}: #{message}")
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -708,7 +708,9 @@ _effective_register)
708
708
  base_cast_options[:resolved_type] = attr_type
709
709
 
710
710
  children.each do |child|
711
- if !rule_has_custom_method && attr_type_is_serializable
711
+ if !rule_has_custom_method &&
712
+ (attr_type_is_serializable ||
713
+ (attr&.union? && ::Lutaml::Model::Type::Union.xml_structured?(child)))
712
714
  # Performance: Build cast_options efficiently (dup + []= cheaper than merge)
713
715
  cast_options = if (child_namespace_uri = child.namespace_uri)
714
716
  ns_type = attr.type_with_namespace(effective_register,
@@ -984,6 +986,8 @@ effective_register = lutaml_register)
984
986
  # @return [Boolean] true if a matching child likely exists
985
987
  def child_matches_rule?(rule, child_names_set, default_namespace)
986
988
  rule_names = rule.namespaced_names(default_namespace)
989
+ # Array#intersect? requires an Array argument; child_names_set may
990
+ # be a Set, so use the block form which works for any includable.
987
991
  rule_names.any? { |rn| child_names_set.include?(rn) } ||
988
992
  child_names_set.include?(rule.name.to_s)
989
993
  end
@@ -31,6 +31,11 @@ module Lutaml
31
31
 
32
32
  def self.generate_attributes(xml, klass, register)
33
33
  klass.attributes.each do |name, attr|
34
+ if attr.union?
35
+ raise Lutaml::Model::UnionSchemaUnsupportedError.new(name,
36
+ "RelaxNG")
37
+ end
38
+
34
39
  attr_type = attr.type(register)
35
40
  if attr_type <= Lutaml::Model::Serialize
36
41
  xml.ref(name: attr_type.name)
@@ -409,6 +409,10 @@ attr_name)
409
409
 
410
410
  def self.get_attribute_xsd_type(attr, attr_type, register,
411
411
  _mapping_rule = nil)
412
+ if attr.union?
413
+ raise Lutaml::Model::UnionSchemaUnsupportedError.new(attr.name, "XSD")
414
+ end
415
+
412
416
  # 1. Check for deprecated attribute-level xsd_type override
413
417
  return attr.options[:xsd_type] if attr.options[:xsd_type]
414
418
 
@@ -44,6 +44,50 @@ module Lutaml
44
44
  @namespace_class&.prefix_default
45
45
  end
46
46
 
47
+ # Configure one or more XSD schemas that the model's generated
48
+ # XML must conform to. Checked during validate/validate!
49
+ # (issue #264). Repeated calls (including in subclasses) append.
50
+ #
51
+ # Relative paths resolve against the file that declares the macro,
52
+ # so a model works regardless of the process working directory.
53
+ #
54
+ # @param paths [Array<String, Pathname>] paths to XSD schema files
55
+ def validate_xml_with(*paths)
56
+ base = File.dirname(caller_locations(1, 1).first.path)
57
+ @own_xml_schema_paths = own_xml_schema_paths +
58
+ paths.flatten.map { |path| File.expand_path(path.to_s, base) }
59
+ end
60
+
61
+ # All configured XSD schema paths, parent-first: inherited paths
62
+ # come before paths declared on this class.
63
+ #
64
+ # @return [Array<String>]
65
+ def xml_schema_paths
66
+ inherited = if superclass.respond_to?(:xml_schema_paths)
67
+ superclass.xml_schema_paths
68
+ else
69
+ []
70
+ end
71
+ inherited + own_xml_schema_paths
72
+ end
73
+
74
+ # Validate a raw XML string against the configured schemas without
75
+ # instantiating a model — the explicit input-side counterpart to
76
+ # the validate/validate! output-side check.
77
+ #
78
+ # @param xml [String] an XML document
79
+ # @return [Array<Lutaml::Xml::Error::SchemaValidationError>]
80
+ def validate_xml(xml)
81
+ XsdValidator.validate(xml, xml_schema_paths)
82
+ end
83
+
84
+ # @raise [Lutaml::Model::ValidationError] when the XML does not
85
+ # conform to every configured schema
86
+ def validate_xml!(xml)
87
+ errors = validate_xml(xml)
88
+ raise Lutaml::Model::ValidationError.new(errors) if errors.any?
89
+ end
90
+
47
91
  # Override choice to set format: :xml so Choice knows which format it belongs to.
48
92
  def choice(min: 1, max: 1, format: :xml, &)
49
93
  super
@@ -204,6 +248,10 @@ module Lutaml
204
248
 
205
249
  private
206
250
 
251
+ def own_xml_schema_paths
252
+ @own_xml_schema_paths ||= []
253
+ end
254
+
207
255
  # Handle XML mapping class inheritance pattern (e.g., `xml SomeMapping`)
208
256
  #
209
257
  # When a mapping class is passed, inherit mappings from it directly.
@@ -263,6 +263,19 @@ module Lutaml
263
263
  self.class.mappings_for(:xml, register)&.element_sequence
264
264
  end
265
265
 
266
+ # Append XSD schema errors (validate_xml_with macro) to the
267
+ # standard validation errors. No-op unless schemas are configured.
268
+ #
269
+ # @see Lutaml::Model::Validation#validate
270
+ def validate(register: Lutaml::Model::Config.default_register)
271
+ errors = super
272
+ return errors if self.class.xml_schema_paths.empty?
273
+
274
+ errors.concat(
275
+ self.class.validate_xml(to_xml(register: register)),
276
+ )
277
+ end
278
+
266
279
  private
267
280
 
268
281
  # Build declaration plan from stored element reference (lazy mode).
@@ -28,13 +28,22 @@ register_id, register)
28
28
  # class AND the value is not a nil/empty marker from collection handlers.
29
29
  # Nil and empty string markers should create simple elements, not attempt
30
30
  # nested model transformation.
31
- is_nested_model = rule.attribute_type.is_a?(Class) &&
32
- rule.attribute_type < Lutaml::Model::Serialize &&
33
- !value.nil? &&
34
- !(value.is_a?(String) && value.empty?)
31
+ # Union: dispatch on the value's own class (stateless). A model member
32
+ # becomes a nested element; a scalar member a simple value element.
33
+ union = Lutaml::Model::Type::Union.rule?(rule)
34
+
35
+ is_nested_model = !value.nil? &&
36
+ !(value.is_a?(String) && value.empty?) &&
37
+ if union
38
+ value.is_a?(Lutaml::Model::Serialize)
39
+ else
40
+ rule.attribute_type.is_a?(Class) &&
41
+ rule.attribute_type < Lutaml::Model::Serialize
42
+ end
35
43
 
36
44
  if is_nested_model
37
- create_nested_model_element(rule, value, options, register)
45
+ create_nested_model_element(rule, value, options, register,
46
+ union: union)
38
47
  else
39
48
  create_simple_value_element(rule, value, options, model_class,
40
49
  register_id)
@@ -119,7 +128,8 @@ parent_element_form_default)
119
128
  # @param options [Hash] Options
120
129
  # @param register [Register, nil] The register
121
130
  # @return [::Lutaml::Xml::DataModel::XmlElement] The created element
122
- def create_nested_model_element(rule, value, options, register)
131
+ def create_nested_model_element(rule, value, options, register,
132
+ union: false)
123
133
  # Resolve polymorphic configuration
124
134
  polymorphic_config = rule.options[:polymorphic]
125
135
  is_polymorphic = polymorphic_config.is_a?(::Hash) ? !polymorphic_config.empty? : !!polymorphic_config
@@ -130,8 +140,11 @@ parent_element_form_default)
130
140
  actual_class = resolve_polymorphic_class(rule, value, is_polymorphic,
131
141
  is_polymorphic_subtype)
132
142
 
133
- # Get transformation for the actual class
134
- child_transformation = if is_polymorphic || is_polymorphic_subtype
143
+ # Get transformation for the actual class. Unions resolve the member
144
+ # from the value's own class, like polymorphism.
145
+ child_transformation = if union
146
+ value.class.transformation_for(:xml, register)
147
+ elsif is_polymorphic || is_polymorphic_subtype
135
148
  actual_class.transformation_for(:xml,
136
149
  register)
137
150
  else