lutaml-model 0.8.32 → 0.8.34

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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +5 -0
  3. data/Gemfile +4 -0
  4. data/README.adoc +39 -0
  5. data/docs/conversion_caching.adoc +143 -0
  6. data/lib/compat/opal/lutaml_model_boot.rb +15 -0
  7. data/lib/lutaml/model/attribute.rb +89 -0
  8. data/lib/lutaml/model/config.rb +13 -0
  9. data/lib/lutaml/model/configuration.rb +23 -0
  10. data/lib/lutaml/model/error/fraction_digits_error.rb +20 -0
  11. data/lib/lutaml/model/error/length_error.rb +19 -0
  12. data/lib/lutaml/model/error/max_exclusive_error.rb +19 -0
  13. data/lib/lutaml/model/error/max_inclusive_error.rb +20 -0
  14. data/lib/lutaml/model/error/max_length_error.rb +20 -0
  15. data/lib/lutaml/model/error/min_exclusive_error.rb +19 -0
  16. data/lib/lutaml/model/error/min_inclusive_error.rb +20 -0
  17. data/lib/lutaml/model/error/min_length_error.rb +20 -0
  18. data/lib/lutaml/model/error/restriction_error.rb +8 -0
  19. data/lib/lutaml/model/error/total_digits_error.rb +20 -0
  20. data/lib/lutaml/model/error/type/max_exclusive_error.rb +20 -0
  21. data/lib/lutaml/model/error/type/min_exclusive_error.rb +20 -0
  22. data/lib/lutaml/model/error/type.rb +2 -0
  23. data/lib/lutaml/model/restriction_validation.rb +437 -0
  24. data/lib/lutaml/model/schema/definitions/restricted_type.rb +4 -2
  25. data/lib/lutaml/model/schema/renderers/restricted_type.rb +134 -4
  26. data/lib/lutaml/model/schema/rng_compiler/define_classifier.rb +3 -0
  27. data/lib/lutaml/model/schema/rng_compiler/rng_helpers.rb +9 -1
  28. data/lib/lutaml/model/schema/rng_compiler/value_type_resolver.rb +2 -0
  29. data/lib/lutaml/model/schema/templates.rb +2 -0
  30. data/lib/lutaml/model/schema/xml_compiler/spec_builder/simple_types.rb +39 -7
  31. data/lib/lutaml/model/schema/xml_compiler/supported_data_types.rb +3 -0
  32. data/lib/lutaml/model/serialize/attribute_definition.rb +18 -7
  33. data/lib/lutaml/model/serialize/builder.rb +4 -5
  34. data/lib/lutaml/model/serialize/conversion_caching.rb +124 -0
  35. data/lib/lutaml/model/serialize/enum_handling.rb +50 -11
  36. data/lib/lutaml/model/serialize/format_conversion.rb +46 -38
  37. data/lib/lutaml/model/serialize/initialization.rb +4 -2
  38. data/lib/lutaml/model/serialize.rb +61 -0
  39. data/lib/lutaml/model/services/type/validator.rb +23 -0
  40. data/lib/lutaml/model/type/string.rb +28 -3
  41. data/lib/lutaml/model/type/value.rb +231 -0
  42. data/lib/lutaml/model/type.rb +2 -0
  43. data/lib/lutaml/model/validation.rb +2 -1
  44. data/lib/lutaml/model/version.rb +1 -1
  45. data/lib/lutaml/model.rb +16 -0
  46. data/lib/lutaml/xml/model_transform.rb +47 -6
  47. data/lib/lutaml/xml/schema/xsd/max_exclusive.rb +1 -1
  48. data/lib/lutaml/xml/schema/xsd/min_exclusive.rb +1 -1
  49. data/lib/lutaml/xml/schema/xsd_schema.rb +183 -11
  50. data/lib/lutaml/xml/transformation/order_reconciler.rb +267 -0
  51. data/lib/lutaml/xml/transformation/ordered_applier.rb +80 -122
  52. data/lib/lutaml/xml/transformation/rule_compiler.rb +2 -0
  53. data/lib/lutaml/xml/transformation.rb +2 -0
  54. data/lib/lutaml/xml/transformation_support.rb +1 -0
  55. data/spec/fixtures/xml/restriction_facets.xsd +46 -0
  56. data/spec/lutaml/model/attribute_restriction_spec.rb +472 -0
  57. data/spec/lutaml/model/attribute_spec.rb +7 -2
  58. data/spec/lutaml/model/collection_reader_liveness_spec.rb +516 -0
  59. data/spec/lutaml/model/conversion_caching_spec.rb +280 -0
  60. data/spec/lutaml/model/lazy_collection_spec.rb +28 -3
  61. data/spec/lutaml/model/parsed_model_mutation_spec.rb +486 -0
  62. data/spec/lutaml/model/phantom_value_spec.rb +799 -0
  63. data/spec/lutaml/model/schema/renderers/restricted_type_spec.rb +1 -0
  64. data/spec/lutaml/model/schema/xsd_schema_spec.rb +340 -695
  65. data/spec/lutaml/model/serialize_perf_guard_spec.rb +53 -1
  66. data/spec/lutaml/model/type_digit_facet_spec.rb +229 -0
  67. data/spec/lutaml/model/type_facet_restriction_spec.rb +667 -0
  68. data/spec/lutaml/model/type_white_space_facet_spec.rb +132 -0
  69. data/spec/lutaml/xml/schema/compiler_spec.rb +302 -0
  70. data/spec/lutaml/xml/schema/facet_round_trip_spec.rb +153 -0
  71. metadata +27 -1
@@ -24,10 +24,14 @@ module Lutaml
24
24
  # @yield Block to apply individual rules
25
25
  def apply_rules_in_order(root, model_instance, options, compiled_rules,
26
26
  model_class, register_id)
27
- element_order = model_instance.element_order
27
+ element_order, fallback_rules = reconciled_element_order(
28
+ model_instance, compiled_rules, options
29
+ )
28
30
  mapping = model_class.mappings_for(:xml, register_id)
29
31
 
30
- # Track index per element type for collection attributes
32
+ # Track index per compiled rule for collection attributes. Keyed
33
+ # by rule, not by element name: one rule can appear under several
34
+ # names once aliases are in play.
31
35
  element_indices = ::Hash.new(0)
32
36
 
33
37
  # Track text node index for content-mapped attribute access.
@@ -47,13 +51,21 @@ model_class, register_id)
47
51
  # in element_order. When they match, mutations to the content array
48
52
  # are reflected in serialization. When they don't match (e.g., CDATA
49
53
  # creates extra whitespace text nodes), we fall back to element_order.
50
- text_node_count = element_order.count { |o| o.type == "Text" }
51
- content_value = content_rule && model_instance&.public_send(content_rule.attribute_name)
54
+ text_node_count = element_order.count do |o|
55
+ order_entry?(o) && o.type == "Text"
56
+ end
57
+ content_value = content_rule &&
58
+ model_instance&.public_send(content_rule.attribute_name)
52
59
  use_content_index = content_rule && content_value.is_a?(Array) &&
53
60
  content_value.length == text_node_count
54
61
 
55
62
  # Iterate through element_order to preserve original sequence
56
63
  element_order.each do |object|
64
+ # element_order is a plain accessor, so entries that are not
65
+ # order entries (no #type) exist in the wild. Nothing can be
66
+ # matched or emitted for them; skip instead of crashing.
67
+ next unless order_entry?(object)
68
+
57
69
  result = process_element_order_item(
58
70
  object, root, model_instance, options,
59
71
  compiled_rules, mapping, element_indices,
@@ -66,9 +78,11 @@ model_class, register_id)
66
78
  processed_text_nodes = true if result == :text_node
67
79
  end
68
80
 
69
- # Apply remaining rules that weren't in element_order (attributes only)
70
- apply_remaining_rules(root, model_instance, options, compiled_rules,
71
- mapping, processed_text_nodes) do |action, rule, value|
81
+ # Apply the rules element_order does not cover: attributes,
82
+ # content and raw.
83
+ apply_remaining_rules(model_instance, options, compiled_rules,
84
+ mapping, processed_text_nodes,
85
+ fallback_rules) do |action, rule, value|
72
86
  yield(action, rule, value) if block_given?
73
87
  end
74
88
  end
@@ -112,6 +126,7 @@ model_class, register_id)
112
126
  # @param compiled_rules [Array<CompiledRule>] The compiled rules
113
127
  # @return [CompiledRule, nil] The matching rule or nil
114
128
  def find_rule_for_element(object, compiled_rules)
129
+ return nil unless order_entry?(object)
115
130
  return nil unless object.type == "Element"
116
131
 
117
132
  object_ns_uri = object.namespace_uri # nil if old element_order (backward compat)
@@ -125,6 +140,18 @@ model_class, register_id)
125
140
 
126
141
  private
127
142
 
143
+ # Whether an element_order entry is something the ordered path can
144
+ # interpret. element_order is a plain public accessor, so arrays
145
+ # holding foreign objects (Integers, strings, ...) exist in the
146
+ # wild. Anything without #type cannot be matched to a rule or
147
+ # emitted, and must be skipped rather than crash serialization.
148
+ #
149
+ # @param object [Object] An entry from element_order
150
+ # @return [Boolean] true when the entry is a usable order entry
151
+ def order_entry?(object)
152
+ object.respond_to?(:type)
153
+ end
154
+
128
155
  # Match by name AND namespace when object has a namespace URI.
129
156
  # Falls back to name-only match for backward compatibility.
130
157
  # Also checks uri_aliases for namespace alias support (e.g., ReqIF versions).
@@ -183,8 +210,8 @@ text_node_count = 0, use_content_index = false)
183
210
 
184
211
  # For collection attributes, get the specific item at the tracked index
185
212
  if rule.collection? && value.respond_to?(:each) && !value.is_a?(String)
186
- process_collection_item(root, rule, value, object, element_indices,
187
- options) do |action, r, v, xsi_nil_flag|
213
+ process_collection_item(rule, value,
214
+ element_indices) do |action, r, v, xsi_nil_flag|
188
215
  yield(action, r, v, xsi_nil_flag) if block_given?
189
216
  end
190
217
  elsif block_given?
@@ -265,20 +292,21 @@ text_node_count = 0, use_content_index = false)
265
292
 
266
293
  # Process a single item from a collection
267
294
  #
268
- # @param root [XmlElement] Root element
295
+ # Indices are keyed by the compiled rule, not the element name: a
296
+ # rule can appear in element_order under several names, because
297
+ # matches_name? accepts aliases and reconciliation inserts entries
298
+ # under the canonical name. One rule must mean one counter.
299
+ #
269
300
  # @param rule [CompiledRule] The rule
270
301
  # @param value [Array] The collection value
271
- # @param object [Object] The element order object
272
- # @param element_indices [Hash] Index tracker
273
- # @param options [Hash] Options
274
- def process_collection_item(_root, rule, value, object, element_indices,
275
- _options)
276
- index = element_indices[object.name]
302
+ # @param element_indices [Hash] Index tracker, keyed by rule
303
+ def process_collection_item(rule, value, element_indices)
304
+ index = element_indices[rule]
277
305
  value_length = value.respond_to?(:length) ? value.length : value.size
278
306
 
279
307
  if index < value_length
280
308
  single_value = value[index]
281
- element_indices[object.name] += 1
309
+ element_indices[rule] += 1
282
310
 
283
311
  # Skip individual nil items when value_map says nil is omitted
284
312
  to_map = (rule.option(:value_map) || {})[:to] || {}
@@ -286,36 +314,49 @@ _options)
286
314
  yield(:apply_single, rule, single_value)
287
315
  end
288
316
  elsif index.zero? && value_length.zero?
289
- # Handle empty collections with value_map
290
- to_map = (rule.option(:value_map) || {})[:to] || {}
291
- if to_map[:empty] == :nil
292
- yield(:apply_single, rule, nil, true) if block_given?
293
- elsif to_map[:empty] == :blank
294
- yield(:apply_single, rule, "") if block_given?
317
+ # 0 items means 0 child elements, unless value_map renders the
318
+ # empty collection as a nil or blank element.
319
+ case empty_collection_render_mode(rule)
320
+ when :nil then yield(:apply_single, rule, nil, true) if block_given?
321
+ when :blank then yield(:apply_single, rule, "") if block_given?
295
322
  end
296
- # For :empty and :omitted: skip — 0 items = 0 child elements
297
323
  end
298
324
  end
299
325
 
300
- # Apply remaining rules (attributes, content/raw, and any element
301
- # rules not represented in element_order).
326
+ # What an empty collection serializes to, per value_map.
302
327
  #
303
- # The element-type skip that used to live here caused silent data
304
- # loss whenever an element-typed attribute was missing from
305
- # element_order (e.g. when a direct setter forgot to call
306
- # track_order). After the builder-side fix all mutation paths
307
- # record into element_order, so this branch is a defense-in-depth
308
- # safety net: emit any element-typed rule whose value would
309
- # otherwise vanish.
328
+ # @param rule [CompiledRule] The rule
329
+ # @return [Symbol, nil] :nil, :blank, or the omitting mode
330
+ def empty_collection_render_mode(rule)
331
+ ((rule.option(:value_map) || {})[:to] || {})[:empty]
332
+ end
333
+
334
+ # Whether an empty collection still produces one child element.
335
+ #
336
+ # @param rule [CompiledRule] The rule
337
+ # @return [Boolean]
338
+ def empty_collection_renders_element?(rule)
339
+ %i[nil blank].include?(empty_collection_render_mode(rule))
340
+ end
341
+
342
+ # Apply the rules element_order does not represent: attributes,
343
+ # content and raw.
344
+ #
345
+ # Element rules are applied here only when reconciliation handed
346
+ # them over. It covers every element value it can place, and
347
+ # emitting one of those again would duplicate it; the few it cannot
348
+ # place (ambiguous names, custom-method rules with no attribute)
349
+ # arrive as fallback_rules and would otherwise be dropped.
310
350
  #
311
- # @param root [XmlElement] Root element
312
351
  # @param model_instance [Object] The model instance
313
352
  # @param options [Hash] Options
314
353
  # @param compiled_rules [Array<CompiledRule>] The compiled rules
315
354
  # @param mapping [Xml::Mapping] The mapping
316
355
  # @param processed_text_nodes [Boolean] Whether text nodes were processed
317
- def apply_remaining_rules(_root, model_instance, options,
318
- compiled_rules, mapping, processed_text_nodes)
356
+ # @param fallback_rules [Array<CompiledRule>] Element rules
357
+ # reconciliation could not place
358
+ def apply_remaining_rules(model_instance, options, compiled_rules,
359
+ mapping, processed_text_nodes, fallback_rules)
319
360
  attr_order = model_instance.respond_to?(:attribute_order) &&
320
361
  model_instance.attribute_order
321
362
 
@@ -326,11 +367,11 @@ compiled_rules, mapping, processed_text_nodes)
326
367
  compiled_rules
327
368
  end
328
369
 
329
- emitted_counts = element_order_coverage(model_instance,
330
- compiled_rules)
331
-
332
370
  rules_to_apply.each do |rule|
333
371
  mapping_type = rule.option(:mapping_type)
372
+ if mapping_type == :element && !fallback_rules.include?(rule)
373
+ next
374
+ end
334
375
 
335
376
  # Skip content/raw if mixed or text nodes were processed
336
377
  if %i[content raw].include?(mapping_type) &&
@@ -338,95 +379,12 @@ compiled_rules, mapping, processed_text_nodes)
338
379
  next
339
380
  end
340
381
 
341
- if mapping_type == :element &&
342
- element_rule_already_emitted?(rule, model_instance,
343
- emitted_counts)
344
- next
345
- end
346
-
347
382
  next unless valid_mapping?(rule, options)
348
383
 
349
384
  yield(:apply_rule, rule, nil) if block_given?
350
385
  end
351
386
  end
352
387
 
353
- # Count, per element-typed rule, how many entries in element_order
354
- # already cover it. Returns a Hash keyed by CompiledRule identity.
355
- #
356
- # @param model_instance [Object] The model instance
357
- # @param compiled_rules [Array<CompiledRule>] The compiled rules
358
- # @return [Hash<CompiledRule, Integer>] Coverage counts
359
- def element_order_coverage(model_instance, compiled_rules)
360
- counts = ::Hash.new(0)
361
- return counts unless model_instance.respond_to?(:element_order)
362
- return counts unless (order = model_instance.element_order)
363
-
364
- element_rules = compiled_rules.select do |r|
365
- r.is_a?(::Lutaml::Model::CompiledRule) &&
366
- r.option(:mapping_type) == :element
367
- end
368
-
369
- order.each do |object|
370
- next unless object.type == "Element"
371
-
372
- object_ns_uri = object.namespace_uri
373
- matched = element_rules.find do |r|
374
- matches_element_rule?(r, object.name, object_ns_uri)
375
- end
376
- counts[matched] += 1 if matched
377
- end
378
-
379
- counts
380
- end
381
-
382
- # Whether an element-typed rule has already been fully emitted
383
- # via element_order (or should not be emitted at all by the safety
384
- # net). Returns true when:
385
- # - the model was not constructed via builder block
386
- # (`@__order_tracking__` is nil/false): parsed models trust
387
- # element_order as the complete source of truth, so the safety
388
- # net must not second-guess it by emitting defaults/uninitialized
389
- # values that the standard path would otherwise have skipped.
390
- # - the standard skip logic says the value should be skipped
391
- # (handles defaults, render_nil/render_empty, value_map, etc.)
392
- # - or the rule was fully covered by element_order entries
393
- #
394
- # The safety net targets the bug class where a builder-block
395
- # construction bypassed element_order tracking (e.g. via a
396
- # mutation path that forgot to call record_mutation). After
397
- # Option A, all setter/getter paths record into element_order,
398
- # so this branch is defense-in-depth rather than the common
399
- # path.
400
- #
401
- # @param rule [CompiledRule] The element rule
402
- # @param model_instance [Object] The model instance
403
- # @param emitted_counts [Hash<CompiledRule, Integer>] Coverage map
404
- # @return [Boolean]
405
- def element_rule_already_emitted?(rule, model_instance,
406
- emitted_counts)
407
- return true unless model_order_tracking_enabled?(model_instance)
408
-
409
- value = extract_ordered_rule_value(rule, model_instance)
410
- return true if should_skip_value?(value, rule, model_instance)
411
-
412
- emitted = emitted_counts[rule]
413
- if rule.collection?
414
- value_length = value.respond_to?(:length) ? value.length : 0
415
- emitted >= value_length
416
- else
417
- emitted.positive?
418
- end
419
- end
420
-
421
- # Whether the model was constructed via a builder block and thus
422
- # has order tracking enabled. Only such models are candidates for
423
- # the safety net; parsed models trust element_order as-is.
424
- def model_order_tracking_enabled?(model_instance)
425
- return false unless model_instance.respond_to?(:order_tracking_enabled?)
426
-
427
- model_instance.order_tracking_enabled?
428
- end
429
-
430
388
  # Sort compiled rules so attribute rules follow the captured attribute_order.
431
389
  # Non-attribute rules (content, raw) maintain their original position.
432
390
  #
@@ -318,10 +318,12 @@ register_id, register, attr_name, custom_methods_value)
318
318
  value_transformer = build_value_transformer(mapping_rule, attr)
319
319
  value_map = mapping_rule.raw_value_map
320
320
  rule_name = mapping_rule.multiple_mappings? ? mapping_rule.name.first : mapping_rule.name
321
+ alias_names = mapping_rule.multiple_mappings? ? mapping_rule.name[1..].map(&:to_s) : nil
321
322
 
322
323
  ::Lutaml::Model::CompiledRule.new(
323
324
  attribute_name: attr_name,
324
325
  serialized_name: rule_name.to_s,
326
+ alias_names: alias_names,
325
327
  attribute_type: attr_type,
326
328
  child_transformation: child_transformation,
327
329
  value_transformer: value_transformer,
@@ -14,11 +14,13 @@ module Lutaml
14
14
  # - TransformationSupport::ValueSerializer: Serializes values to XML strings
15
15
  # - TransformationSupport::ElementBuilder: Creates XML elements from values
16
16
  # - TransformationSupport::OrderedApplier: Applies rules in element order for round-trip
17
+ # - TransformationSupport::OrderReconciler: Aligns element_order with current values
17
18
  # - TransformationSupport::RuleApplier: Dispatches rule application to handlers
18
19
  class Transformation < Lutaml::Model::Transformation
19
20
  include TransformationSupport::RuleCompiler
20
21
  include TransformationSupport::RuleApplier
21
22
  include TransformationSupport::OrderedApplier
23
+ include TransformationSupport::OrderReconciler
22
24
 
23
25
  # Transform a model instance into XmlElement tree
24
26
  #
@@ -8,6 +8,7 @@ module Lutaml
8
8
  autoload :ValueSerializer, "#{__dir__}/transformation/value_serializer"
9
9
  autoload :ElementBuilder, "#{__dir__}/transformation/element_builder"
10
10
  autoload :OrderedApplier, "#{__dir__}/transformation/ordered_applier"
11
+ autoload :OrderReconciler, "#{__dir__}/transformation/order_reconciler"
11
12
  autoload :RuleApplier, "#{__dir__}/transformation/rule_applier"
12
13
  end
13
14
  end
@@ -0,0 +1,46 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <xsd:schema xmlns:xsd="http://www.w3.org/2001/XMLSchema">
3
+ <xsd:simpleType name="ST_Code">
4
+ <xsd:restriction base="xsd:string">
5
+ <xsd:minLength value="2" />
6
+ <xsd:maxLength value="8" />
7
+ </xsd:restriction>
8
+ </xsd:simpleType>
9
+ <xsd:simpleType name="ST_Ratio">
10
+ <xsd:restriction base="xsd:integer">
11
+ <xsd:minExclusive value="0" />
12
+ <xsd:maxExclusive value="100" />
13
+ </xsd:restriction>
14
+ </xsd:simpleType>
15
+ <xsd:simpleType name="ST_Price">
16
+ <xsd:restriction base="xsd:decimal">
17
+ <xsd:totalDigits value="5" />
18
+ <xsd:fractionDigits value="2" />
19
+ </xsd:restriction>
20
+ </xsd:simpleType>
21
+ <xsd:simpleType name="ST_Label">
22
+ <xsd:restriction base="xsd:string">
23
+ <xsd:whiteSpace value="collapse" />
24
+ </xsd:restriction>
25
+ </xsd:simpleType>
26
+ <xsd:simpleType name="ST_Moment">
27
+ <xsd:restriction base="xsd:dateTime">
28
+ <xsd:minInclusive value="2020-01-01T00:00:00" />
29
+ <xsd:maxInclusive value="2020-12-31T23:59:59" />
30
+ </xsd:restriction>
31
+ </xsd:simpleType>
32
+ <xsd:simpleType name="ST_Flag">
33
+ <xsd:restriction base="xsd:boolean">
34
+ <xsd:enumeration value="true" />
35
+ </xsd:restriction>
36
+ </xsd:simpleType>
37
+ <xsd:complexType name="CT_Item">
38
+ <xsd:attribute name="code" type="ST_Code" />
39
+ <xsd:attribute name="ratio" type="ST_Ratio" />
40
+ <xsd:attribute name="price" type="ST_Price" />
41
+ <xsd:attribute name="label" type="ST_Label" />
42
+ <xsd:attribute name="moment" type="ST_Moment" />
43
+ <xsd:attribute name="flag" type="ST_Flag" />
44
+ </xsd:complexType>
45
+ <xsd:element name="Item" type="CT_Item" />
46
+ </xsd:schema>