lutaml-model 0.8.36 → 0.8.38

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 (34) hide show
  1. checksums.yaml +4 -4
  2. data/bench/bench_sts.rb +9 -3
  3. data/bench/gate_config.rb +5 -4
  4. data/lib/compat/opal/lutaml_model_boot.rb +1 -0
  5. data/lib/lutaml/key_value/transform.rb +52 -5
  6. data/lib/lutaml/key_value/transformation.rb +10 -2
  7. data/lib/lutaml/model/adapter_resolver.rb +14 -2
  8. data/lib/lutaml/model/attribute.rb +15 -9
  9. data/lib/lutaml/model/choice.rb +6 -5
  10. data/lib/lutaml/model/format_registry.rb +3 -1
  11. data/lib/lutaml/model/liquefiable.rb +23 -2
  12. data/lib/lutaml/model/mapping/mapping_rule.rb +46 -11
  13. data/lib/lutaml/model/sequence.rb +47 -2
  14. data/lib/lutaml/model/serialize/attribute_definition.rb +176 -47
  15. data/lib/lutaml/model/serialize/deserialization_context.rb +1 -0
  16. data/lib/lutaml/model/serialize.rb +54 -10
  17. data/lib/lutaml/model/services/transformer.rb +30 -11
  18. data/lib/lutaml/model/store.rb +70 -32
  19. data/lib/lutaml/model/version.rb +1 -1
  20. data/lib/lutaml/xml/format.rb +1 -0
  21. data/lib/lutaml/xml/html_entities.rb +290 -0
  22. data/lib/lutaml/xml/mapping.rb +72 -0
  23. data/lib/lutaml/xml/model_transform.rb +210 -72
  24. data/lib/lutaml/xml/parse_session.rb +38 -0
  25. data/lib/lutaml/yaml/format.rb +1 -0
  26. data/spec/lutaml/model/adapter_resolver_configured_spec.rb +38 -0
  27. data/spec/lutaml/model/custom_method_context_spec.rb +62 -0
  28. data/spec/lutaml/model/liquefiable_lazy_spec.rb +36 -0
  29. data/spec/lutaml/model/mapping_type_validation_spec.rb +61 -0
  30. data/spec/lutaml/model/sequence_choice_compositor_spec.rb +66 -0
  31. data/spec/lutaml/model/store_spec.rb +13 -11
  32. data/spec/lutaml/model/yaml_stream_spec.rb +70 -0
  33. data/spec/lutaml/xml/html_entities_spec.rb +45 -0
  34. metadata +9 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 13023a5add888c4affa15d1e7c025548e329b0aad8112582550be7cff794524a
4
- data.tar.gz: 7393786a16da4f06d7962f7395edab78b5985ab1820c98da8b95f66f669be99d
3
+ metadata.gz: ad19014fa289dad72ab50f6908d425ed7708b4f7046432ac05426b65087fc713
4
+ data.tar.gz: 4f2d95722e22e73b3691e6eec7903d3967ec1d9cf9ea2b641b02b77cc59911ef
5
5
  SHA512:
6
- metadata.gz: 03be6773d23935e26c6593d2c966f5063af4840142f988c6db534517b26b7889adc15c11c5d5280b181b9b19e35a971bbbc889ce673d63a750385e5b392d60b7
7
- data.tar.gz: 656f73aab6fe55e9dbe8337a8c6ec76fae5c2cadfade74bdd30500d3ea16105cdf3de1ebce9d837b4ddd3c6851e84ac7b5b7a7f40f2729d79827014e9d0a0b9e
6
+ metadata.gz: 575ac30c297c4d8059bccb27b42b1d1e942ffef3352072f1ebdbff55039073cffb228a2e6873c55afd940bc78aaac1830c5375df0b37ac1b4eb498cea3a73fe2
7
+ data.tar.gz: 4d1c80a2a3fd04e5c461aec33ba2248547ae5f6c877990f457d23160c272a4f67e17ef4ab05ad450265f5c2aa8c5c637deead3c27b20a2ea1978ab4815d6d397
data/bench/bench_sts.rb CHANGED
@@ -9,6 +9,7 @@
9
9
  # BENCH_JSON=/tmp/results.json bundle exec ruby tmp/bench/bench_sts.rb
10
10
 
11
11
  require_relative "bench_common"
12
+ require_relative "gate_config"
12
13
  include BenchCommon
13
14
 
14
15
  print_header("STS Ruby Benchmark — ISO/NISO STS document parsing")
@@ -39,11 +40,16 @@ sts_files.each do |label, path|
39
40
  end
40
41
  end
41
42
 
43
+ # The informative print reads the configured gate (bench_compare is the
44
+ # enforcing layer). The old hardcoded 2.0s literal predated the
45
+ # gate_config split and mislabeled every CI run (#313, TODO.max-perf/09).
42
46
  puts "\n Gate checks:"
43
47
  if results["iso-13849-1MB"]
44
- status = results["iso-13849-1MB"][:avg_time] < 2.0 ? "PASS" : "FAIL"
45
- printf " ISO-13849-1MB < 2.0s: %s (%.3fs)\n", status,
46
- results["iso-13849-1MB"][:avg_time]
48
+ gate = GateConfig::GATES.dig(:sts, "iso-13849-1MB") || {}
49
+ limit = gate[:absolute_max]
50
+ avg = results["iso-13849-1MB"][:avg_time]
51
+ status = avg < limit ? "PASS" : "FAIL"
52
+ printf " ISO-13849-1MB < %.1fs: %s (%.3fs)\n", limit, status, avg
47
53
  end
48
54
 
49
55
  write_results_json(json_output_path, results) if json_output_path
data/bench/gate_config.rb CHANGED
@@ -57,10 +57,11 @@ module GateConfig
57
57
  "iso-13849-1MB" => {
58
58
  alloc_ratio: 1.05,
59
59
  time_ratio: 1.15,
60
- # GH-hosted ubuntu runners run this fixture at ~12s min_time
61
- # (observed across moxml 0.1–0.5); 5.0 never passed there and
62
- # fired on every run. 15.0 keeps the 2x-catastrophe safety net.
63
- absolute_max: 15.0,
60
+ # Post-TODO.max-perf/01-03 the fixture sits at ~4.88M allocs and
61
+ # 14.5-16.5s on GH-hosted ubuntu runners (runner-variance band);
62
+ # the old 15.0 missed by 0.06s on a healthy run. 30.0 keeps the
63
+ # 2x-catastrophe safety net over the healthy band (#313).
64
+ absolute_max: 30.0,
64
65
  },
65
66
  "din-iso-1.1MB" => {
66
67
  alloc_ratio: 1.05,
@@ -146,6 +146,7 @@ require "lutaml/xml/encoding_normalizer"
146
146
  require "lutaml/xml/format"
147
147
  require "lutaml/xml/format_chooser"
148
148
  require "lutaml/xml/hoisting_algorithm"
149
+ require "lutaml/xml/html_entities"
149
150
  require "lutaml/xml/listener"
150
151
  require "lutaml/xml/mapping"
151
152
  require "lutaml/xml/mapping_rule"
@@ -68,10 +68,33 @@ module Lutaml
68
68
 
69
69
  def process_mapping_for_instance(instance, hash, format, rule, options)
70
70
  if rule.custom_methods[:to]
71
- return instance.public_send(rule.custom_methods[:to], instance, hash)
71
+ to_method = rule.custom_methods[:to]
72
+ # lutaml-model#550: custom methods may declare a third context
73
+ # parameter to receive the options passed to `to_*`.
74
+ if instance.method(to_method).parameters.size >= 3
75
+ return instance.public_send(to_method, instance, hash,
76
+ options[:context])
77
+ end
78
+
79
+ return instance.public_send(to_method, instance, hash)
72
80
  end
73
81
 
74
82
  attribute = attributes[rule.to]
83
+
84
+ # TODO.max-perf/10: plain scalar/collection rules take a fast
85
+ # lane — memoized wire name, shared render? semantics, one
86
+ # serialize dispatch — collapsing the interpretive branch
87
+ # probes below. Rules with any special feature stay on the
88
+ # full path.
89
+ if (plan = kv_serialize_plan(rule, attribute, instance))
90
+ value = instance.public_send(attribute.name)
91
+ if rule.render?(value, instance)
92
+ hash[plan.wire] = attribute.serialize(value, format,
93
+ lutaml_register, options)
94
+ end
95
+ return
96
+ end
97
+
75
98
  value = rule.serialize(instance)
76
99
 
77
100
  if rule.can_transform_to?(attribute, format)
@@ -90,7 +113,9 @@ module Lutaml
90
113
  end
91
114
 
92
115
  # Use the format parameter passed in instead of hardcoding to :json
93
- value = ExportTransformer.call(value, rule, attribute, format: format)
116
+ value = ExportTransformer.call(value, rule, attribute,
117
+ format: format,
118
+ context: options[:context])
94
119
 
95
120
  value = serialize_value(value, rule, attribute, format, options)
96
121
 
@@ -247,7 +272,7 @@ format)
247
272
  if (plan = kv_rule_plan(format, rule, attr)) &&
248
273
  (value = kv_fast_extract(doc, plan))
249
274
  rule.deserialize(instance, kv_fast_cast(value, plan, instance),
250
- attributes, self)
275
+ attributes, self, options[:context])
251
276
  return
252
277
  end
253
278
 
@@ -261,7 +286,9 @@ format)
261
286
  # only nil (non-existent) skips it (lutaml-model#746).
262
287
  return if value.nil?
263
288
 
264
- return rule.deserialize(instance, value, attributes, model_class)
289
+ warn "PROBE-264 keys=#{options.keys.inspect} ctx=#{options[:context].inspect}"
290
+ return rule.deserialize(instance, value, attributes, model_class,
291
+ options[:context])
265
292
  end
266
293
 
267
294
  value = rule.transform_value(attr, value, :from, format)
@@ -279,7 +306,8 @@ format)
279
306
  if attr.collection? || !instance.is_a?(Lutaml::Model::Serialize)
280
307
  attr.valid_collection!(value, context)
281
308
  end
282
- rule.deserialize(instance, value, attributes, self)
309
+ rule.deserialize(instance, value, attributes, self,
310
+ options[:context])
283
311
  end
284
312
 
285
313
  # Compiled rule plans (TODO.perf/07): for plain scalar rules the
@@ -291,6 +319,25 @@ format)
291
319
  # unions, polymorphism) return nil and keep the interpretive path.
292
320
  KvRulePlan = ::Struct.new(:wire, :klass, :collection)
293
321
 
322
+ # Memoized (per transform) wire name for plain rules — the from
323
+ # spelling never changes after definition.
324
+ KvSerializePlan = ::Struct.new(:wire)
325
+
326
+ def kv_serialize_plan(rule, attr, _instance)
327
+ return nil if rule.delegate || rule.raw_mapping? || rule.root_mapping? ||
328
+ rule.hash_mappings || rule.child_mappings ||
329
+ rule.has_custom_method_for_serialization? ||
330
+ rule.multiple_mappings? || polymorphic_rule?(rule) ||
331
+ (rule.transform.is_a?(Hash) && !rule.transform.empty?) ||
332
+ rule.transform.is_a?(Class) || attr.nil? || attr.derived? ||
333
+ attr.union? || attr.polymorphic? || attr.custom_collection? ||
334
+ attr.transform ||
335
+ rule.value_map(:to) != Lutaml::Model::Serialize::DEFAULT_VALUE_MAP
336
+
337
+ @kv_serialize_plans ||= {}.compare_by_identity
338
+ @kv_serialize_plans[rule] ||= KvSerializePlan.new(rule_from_name(rule))
339
+ end
340
+
294
341
  def kv_rule_plan(format, rule, attr)
295
342
  @kv_rule_plans ||= {}
296
343
  @kv_rule_plans[[format, rule]] ||= build_kv_rule_plan(format, rule,
@@ -220,9 +220,17 @@ register = self.register)
220
220
 
221
221
  # Handle custom serialization methods (e.g., with: { to: ... })
222
222
  if rule.has_custom_methods? && rule.custom_methods[:to]
223
+ to_method = rule.custom_methods[:to]
224
+ # lutaml-model#550: custom methods may declare a third context
225
+ # parameter to receive the options passed to `to_*`.
226
+ if model_instance.method(to_method).parameters.size >= 3
227
+ return model_instance.public_send(to_method, model_instance,
228
+ parent, options[:context])
229
+ end
230
+
223
231
  # Call custom method which directly modifies the parent element
224
- return model_instance.public_send(rule.custom_methods[:to],
225
- model_instance, parent)
232
+ return model_instance.public_send(to_method, model_instance,
233
+ parent)
226
234
  end
227
235
 
228
236
  # Handle delegation - extract value from delegated object
@@ -2,6 +2,10 @@
2
2
 
3
3
  module Lutaml
4
4
  module Model
5
+ # Marker stored by set_adapter_class: the resolved slot holds a
6
+ # pre-resolved class (or nil, meaning "unset — keep detecting").
7
+ FIXED_ADAPTER_TYPE = :__fixed__
8
+
5
9
  # Single authority for adapter resolution: (format, type_name) → adapter class.
6
10
  #
7
11
  # Consolidates adapter metadata, loading, validation, and auto-detection
@@ -38,11 +42,19 @@ module Lutaml
38
42
  return resolved_adapter_class(format, scope_override)
39
43
  end
40
44
 
41
- # 2. Explicitly configured type
45
+ # 2. Explicitly configured type — a named type is an explicit pin
46
+ # and must never fall through to auto-detection: detection loads
47
+ # optional engines (e.g. yeptris) into the process even when the
48
+ # user pinned a standard adapter, which crashes platforms where
49
+ # the engine's build is broken. FIXED_ADAPTER_TYPE with no
50
+ # resolved class means "unset" (set_adapter_class(format, nil))
51
+ # and keeps the old detect-through behavior.
42
52
  configured = configured_type(format)
43
53
  if configured
44
54
  adapter = resolved[format]
45
55
  return adapter if adapter
56
+
57
+ return load_and_cache(format, configured) if configured != FIXED_ADAPTER_TYPE
46
58
  end
47
59
 
48
60
  # 3. Lazy auto-detection (cached)
@@ -91,7 +103,7 @@ module Lutaml
91
103
  # @return [void]
92
104
  def set_adapter_class(format, adapter_class)
93
105
  resolved[format] = adapter_class
94
- configured_types[format] = :__fixed__
106
+ configured_types[format] = FIXED_ADAPTER_TYPE
95
107
  end
96
108
 
97
109
  # Load and resolve an adapter class by type name.
@@ -471,7 +471,11 @@ module Lutaml
471
471
  @options[:ref_model_class], @options[:ref_key_attribute])
472
472
  end
473
473
 
474
- validate_attr_type!(resolved_type)
474
+ # The castability verdict is a class-hierarchy fact, immutable
475
+ # per resolved type — check once, not per value (TODO.max-perf/15).
476
+ checked = (@castable_types ||= {}.compare_by_identity)
477
+ validate_attr_type!(resolved_type) unless checked[resolved_type]
478
+ checked[resolved_type] = true
475
479
 
476
480
  resolved_type.cast(value)
477
481
  end
@@ -1029,6 +1033,16 @@ instance_object = nil)
1029
1033
  @default_type_context = nil
1030
1034
  end
1031
1035
 
1036
+ # Public validation contract: mappings check their targets at
1037
+ # finalize (lutaml-model#296), the cast path checks per value.
1038
+ def validate_attr_type!(resolved_type)
1039
+ return true if resolved_type <= Serializable || resolved_type <= Type::Value
1040
+ return true if resolved_type.include?(Serialize)
1041
+
1042
+ raise Lutaml::Model::InvalidAttributeTypeError.new(name,
1043
+ resolved_type.name)
1044
+ end
1045
+
1032
1046
  private
1033
1047
 
1034
1048
  attr_writer :raw, :validations
@@ -1040,14 +1054,6 @@ instance_object = nil)
1040
1054
  match&.last
1041
1055
  end
1042
1056
 
1043
- def validate_attr_type!(resolved_type)
1044
- return true if resolved_type <= Serializable || resolved_type <= Type::Value
1045
- return true if resolved_type.include?(Serialize)
1046
-
1047
- raise Lutaml::Model::InvalidAttributeTypeError.new(name,
1048
- resolved_type.name)
1049
- end
1050
-
1051
1057
  # validated_range_object is provided by CollectionHandler module
1052
1058
 
1053
1059
  def validate_name!(name, reserved_methods:)
@@ -3,11 +3,12 @@
3
3
  module Lutaml
4
4
  module Model
5
5
  class Choice
6
- attr_reader :attributes,
7
- :model,
8
- :min,
9
- :max
10
- attr_accessor :format
6
+ attr_reader :attributes, :min, :max
7
+ attr_accessor :model, :format
8
+
9
+ # Rebound at mapping finalize: sequence-nested choices are built
10
+ # against the mapping object, validation resolves through the
11
+ # mapper class (lutaml-model#687).
11
12
 
12
13
  INTERNAL_ATTRIBUTES = %i[@flat_attributes].freeze
13
14
 
@@ -23,7 +23,8 @@ module Lutaml
23
23
  # @param adapter_options [Hash, nil] { available: [...], default: :name }
24
24
  def register(format, mapping_class:, adapter_class:, transformer:,
25
25
  adapter_loader: nil, castable_type: nil, key_value: nil,
26
- rdf: nil, error_types: nil, adapter_options: nil)
26
+ rdf: nil, error_types: nil, adapter_options: nil,
27
+ stream_methods: false)
27
28
  validate_registration!(format, mapping_class, transformer)
28
29
 
29
30
  registered_formats[format] = {
@@ -44,6 +45,7 @@ module Lutaml
44
45
  ::Lutaml::Model::Serialize.register_format_mapping_method(format)
45
46
  ::Lutaml::Model::Serialize.register_from_format_method(format)
46
47
  ::Lutaml::Model::Serialize.register_to_format_method(format)
48
+ ::Lutaml::Model::Serialize.register_stream_methods(format) if stream_methods
47
49
 
48
50
  ::Lutaml::Model::Attribute.format_specific_warn_names.push(:"to_#{format}")
49
51
 
@@ -25,6 +25,27 @@ module Lutaml
25
25
  register_liquid_drop_class
26
26
  end
27
27
 
28
+ # lutaml-model#800: models defined before `require "liquid"` keep
29
+ # their `liquid do ... end` mappings; the drop class materializes
30
+ # on first use. Consumers that require Liquid first keep the
31
+ # eager path unchanged.
32
+ def ensure_liquid_registered!
33
+ if !Object.const_defined?(:Liquid) && !liquid_loadable?
34
+ raise Lutaml::Model::LiquidNotEnabledError
35
+ end
36
+
37
+ register_class_if_liquid_defined
38
+ end
39
+
40
+ # Load the Liquid gem on first use; a missing gem surfaces as
41
+ # LiquidNotEnabledError at the point a drop is actually needed.
42
+ def liquid_loadable?
43
+ require "liquid"
44
+ true
45
+ rescue LoadError
46
+ false
47
+ end
48
+
28
49
  def liquid_class(class_name)
29
50
  @custom_liquid_class_name = class_name
30
51
  end
@@ -94,6 +115,7 @@ module Lutaml
94
115
  end
95
116
 
96
117
  def to_liquid_class
118
+ ensure_liquid_registered!
97
119
  register_methods unless @methods_generated
98
120
 
99
121
  base_drop_class
@@ -132,8 +154,6 @@ module Lutaml
132
154
  end
133
155
 
134
156
  def liquid(&block)
135
- return unless Object.const_defined?(:Liquid)
136
-
137
157
  mappings[:liquid] ||= ::Lutaml::Model::Liquid::Mapping.new
138
158
  mappings[:liquid].instance_eval(&block) if block
139
159
  mappings[:liquid]
@@ -155,6 +175,7 @@ module Lutaml
155
175
  end
156
176
 
157
177
  def to_liquid
178
+ self.class.ensure_liquid_registered!
158
179
  self.class.validate_liquid!
159
180
 
160
181
  self.class.register_methods
@@ -45,6 +45,13 @@ module Lutaml
45
45
  end
46
46
  end
47
47
 
48
+ # Normalized name spellings for matching, computed once. The name
49
+ # is fixed after construction (attr_reader only), so the memo is
50
+ # safe. Hot loops used to redo Array(name).map(&:to_s) per element.
51
+ def name_strings
52
+ @name_strings ||= Array(name).map(&:to_s).freeze
53
+ end
54
+
48
55
  def initialize(
49
56
  name,
50
57
  to:,
@@ -261,9 +268,16 @@ module Lutaml
261
268
  end
262
269
  end
263
270
 
264
- def serialize(model, parent = nil, doc = nil)
271
+ def serialize(model, parent = nil, doc = nil, context = nil)
265
272
  if custom_methods[:to]
266
- model.public_send(custom_methods[:to], model, parent, doc)
273
+ to_method = custom_methods[:to]
274
+ # lutaml-model#550: custom methods may declare a fourth
275
+ # context parameter to receive the options passed to `to_*`.
276
+ if model.method(to_method).parameters.size >= 4
277
+ model.public_send(to_method, model, parent, doc, context)
278
+ else
279
+ model.public_send(to_method, model, parent, doc)
280
+ end
267
281
  else
268
282
  value = to_value_for(model)
269
283
 
@@ -282,13 +296,14 @@ module Lutaml
282
296
  end
283
297
  end
284
298
 
285
- def deserialize(model, value, attributes, mapper_class = nil)
299
+ def deserialize(model, value, attributes, mapper_class = nil,
300
+ context = nil)
286
301
  if @needs_full_deserialize
287
- handle_custom_method(model, value, mapper_class) ||
302
+ handle_custom_method(model, value, mapper_class, context) ||
288
303
  handle_delegate(model, value, attributes) ||
289
- handle_transform_method(model, value, attributes)
304
+ handle_transform_method(model, value, attributes, context)
290
305
  else
291
- handle_transform_method(model, value, attributes)
306
+ handle_transform_method(model, value, attributes, context)
292
307
  end
293
308
  end
294
309
 
@@ -409,10 +424,29 @@ module Lutaml
409
424
  end
410
425
  end
411
426
 
412
- def handle_custom_method(model, value, mapper_class)
413
- return if !custom_methods[:from] || value.nil?
427
+ def handle_custom_method(model, value, mapper_class, context = nil)
428
+ custom = custom_methods[:from]
429
+ return if !custom || value.nil?
414
430
 
415
- mapper_class.new.public_send(custom_methods[:from], model, value)
431
+ if custom.is_a?(String) || custom.is_a?(Symbol)
432
+ target = mapper_class.new
433
+ # lutaml-model#550: custom methods may declare a third context
434
+ # parameter to receive the options passed to `from_*`.
435
+ if target.method(custom).parameters.size >= 3
436
+ target.public_send(custom, model, value, context)
437
+ else
438
+ target.public_send(custom, model, value)
439
+ end
440
+ else
441
+ # Callable (proc/lambda): transforms the value and the rule
442
+ # assigns it. Exact arity 2 receives the `from_*` context.
443
+ transformed = if custom.arity == 2
444
+ custom.call(value, context)
445
+ else
446
+ custom.call(value)
447
+ end
448
+ assign_value(model, transformed)
449
+ end
416
450
  true
417
451
  end
418
452
 
@@ -432,7 +466,7 @@ module Lutaml
432
466
  attributes[delegate].type(model.lutaml_register).new)
433
467
  end
434
468
 
435
- def handle_transform_method(model, value, attributes)
469
+ def handle_transform_method(model, value, attributes, context = nil)
436
470
  attr = attributes[to]
437
471
  # Fast path: no transforms at all (covers 95%+ of rules)
438
472
  # transform defaults to {} which is truthy but semantically empty
@@ -453,7 +487,8 @@ module Lutaml
453
487
  assign_value(model, value)
454
488
  else
455
489
  # Hash/proc transformers need ImportTransformer
456
- transformed = ImportTransformer.call(value, self, attr)
490
+ transformed = ImportTransformer.call(value, self, attr,
491
+ context: context)
457
492
  assign_value(model, transformed)
458
493
  end
459
494
  true
@@ -5,11 +5,12 @@ module Lutaml
5
5
  class Sequence
6
6
  include DeepDupable
7
7
 
8
- attr_accessor :model, :attributes
8
+ attr_accessor :model, :attributes, :compositors
9
9
  attr_reader :format
10
10
 
11
11
  def initialize(model, format: nil)
12
12
  @attributes = []
13
+ @compositors = []
13
14
  @model = model
14
15
  @format = format
15
16
  end
@@ -19,6 +20,7 @@ module Lutaml
19
20
  def deep_dup(new_model = nil)
20
21
  dup_seq = Sequence.new(new_model || @model, format: @format)
21
22
  dup_seq.attributes = Utils.deep_dup(@attributes)
23
+ dup_seq.compositors = Utils.deep_dup(@compositors)
22
24
  dup_seq
23
25
  end
24
26
 
@@ -32,7 +34,50 @@ module Lutaml
32
34
  end
33
35
 
34
36
  def map_element(name, **options)
35
- @attributes << @model.map_element(name, **options)
37
+ rule = @model.map_element(name, **options)
38
+ @attributes << rule
39
+ if (nested = @choice_in_progress)
40
+ nested.attributes << rule
41
+ end
42
+ rule
43
+ end
44
+
45
+ # An xs:choice compositor nested inside an xs:sequence
46
+ # (lutaml-model#687). Element rules mapped in the block take
47
+ # their positional place in this sequence AND carry the choice
48
+ # on their model attributes, so validation applies order and
49
+ # exclusivity exactly as a model-level choice does. The choice
50
+ # itself is remembered in #compositors — the compositor shape
51
+ # stays queryable next to the flat rule order.
52
+ def choice(min: 1, max: 1, &block)
53
+ nested = Choice.new(@model, min, max, format: @format)
54
+ previous = @choice_in_progress
55
+ @choice_in_progress = nested
56
+ begin
57
+ instance_eval(&block)
58
+ ensure
59
+ @choice_in_progress = previous
60
+ end
61
+ @compositors << nested
62
+ nested
63
+ end
64
+
65
+ # Bind nested choice compositors to the mapper's model attributes
66
+ # (options[:choice]), so validation applies order and exclusivity
67
+ # the same way a model-level choice does. Runs at mapping finalize,
68
+ # when the mapper class is known — the DSL block runs earlier.
69
+ def bind_choice_compositors!(mapper_class)
70
+ @compositors.each do |choice|
71
+ choice.model = mapper_class
72
+ # The choice's contents become the model attributes its rules
73
+ # target — the same shape a model-level choice carries — so
74
+ # validation machinery works unchanged.
75
+ bound = choice.attributes.filter_map do |rule|
76
+ mapper_class.attributes[rule.to]
77
+ end.uniq
78
+ choice.attributes.replace(bound)
79
+ bound.each { |target| target.options[:choice] = choice }
80
+ end
36
81
  end
37
82
 
38
83
  def import_model_mappings(model, register = nil)