lutaml-model 0.8.47 → 0.8.48

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c4ba22dbb3a53b5ba0cdb11840f7940507da76da3d3b7b3ccd64e52f597bdfe1
4
- data.tar.gz: '09d45f38541abb51224c9af9a4aff11d3207a99c4c9f79206cc0a6041caa382e'
3
+ metadata.gz: a482362c928135d0733413ae200b20e5a9a7f05af8faf57696bc4461b093d691
4
+ data.tar.gz: 3b22e8a6881ad4a832d90a6b064d6999a5e5b8a969d507fd467d44c66f7c0279
5
5
  SHA512:
6
- metadata.gz: 2a9fa0ec81775e5b928dbeb8d75b8b812d8e00c076176c1f6367e024d0bd85fd10ea27b3361160211dc47d275edff90b6d569e2ebf87b63a2f8bbba1ebf12024
7
- data.tar.gz: '089b06566a9ec168c0491b7c584e95e68dc0bcb2c219b8932062aa3b33c3e5d696445e26782107c292c405cdead641ba33ad6571ac31f59403b2ac90d5f9a71d'
6
+ metadata.gz: a6047ddb9f43fa9a7012e794070891a0d997ca261f2c76f324223a1c1414bab39f1f05b5f627ecde1e21a083b971c4cc002d15928db410e97411b5dcca9a5b97
7
+ data.tar.gz: b849ae1daa34c19f6f570d1389a2ea43cf3af5ffba5db32bdef33e53a0b7ba960736b511ecfc5f520a926690134a4247227b1fa7a437923a8774a511569f6e88
data/README.adoc CHANGED
@@ -2693,6 +2693,41 @@ references:
2693
2693
  ====
2694
2694
 
2695
2695
 
2696
+ ==== Attribute-value dispatch on a shared name (`when_attribute`)
2697
+
2698
+ Polymorphism selects the *class* of each item. The other dispatch axis
2699
+ selects *which attribute* an occurrence belongs to, keyed by a sibling
2700
+ value — every `<component>` below goes to a different attribute by its
2701
+ `type`:
2702
+
2703
+ [source,ruby]
2704
+ ----
2705
+ class Requirement < Lutaml::Model::Serializable
2706
+ attribute :guidance, Component, collection: true
2707
+ attribute :purpose, Component, collection: true
2708
+
2709
+ xml do
2710
+ element "requirement"
2711
+ map_element "component", when_attribute: "type",
2712
+ to: { "guidance" => :guidance,
2713
+ "purpose" => :purpose }
2714
+ end
2715
+ end
2716
+ ----
2717
+
2718
+ The same declaration works for key-value formats (`json`/`yaml`/`toml`/
2719
+ `hsh` blocks use `map`), where the discriminator is an object key's
2720
+ value. Every occurrence is captured exactly once, the discriminator is
2721
+ re-emitted on serialization, `unmatched: :raise` fails the parse on
2722
+ values no rule claims, and `ordered` XML mappings round-trip the
2723
+ original interleaving. The two axes compose on one rule — partition by
2724
+ value while dispatching classes with `polymorphic`.
2725
+
2726
+ See
2727
+ link:docs/_guides/advanced-mapping.adoc[the advanced mapping guide]
2728
+ for the full semantics.
2729
+
2730
+
2696
2731
 
2697
2732
  === Union attributes
2698
2733
 
@@ -23,6 +23,10 @@ Task-oriented guides for accomplishing specific goals with Lutaml::Model.
23
23
  * link:../keyvalue-serialization[Key-Value Serialization] - JSON/YAML/TOML/Hash
24
24
  * link:../collection-serialization[Collection Serialization] - JSONL and YAML Stream
25
25
 
26
+ == Advanced mapping
27
+
28
+ * link:../advanced-mapping[Advanced Attribute Mapping] - Same-name dispatch on attribute values (`when_attribute`), grouped declarations, custom methods, delegation
29
+
26
30
  == Performance
27
31
 
28
32
  * link:../native-engines[Native engines (leptris / yeptris / teptris)] - Drop-in XML/YAML/JSON/TOML engine upgrades via bundle opt-in
@@ -116,6 +116,40 @@ desc: A ceramic with a navy blue color and clear glaze.
116
116
  ----
117
117
  ====
118
118
 
119
+ === Dispatching a shared key by a discriminator value
120
+
121
+ When one wire key holds a list of objects distinguished by a sibling
122
+ key's value, `when_attribute` routes each item to its own attribute —
123
+ one declaration covers the whole partition:
124
+
125
+ [source,ruby]
126
+ ----
127
+ class Requirement < Lutaml::Model::Serializable
128
+ attribute :guidance, Component, collection: true
129
+ attribute :purpose, Component, collection: true
130
+
131
+ json do
132
+ map "component", when_attribute: "type",
133
+ to: { "guidance" => :guidance, "purpose" => :purpose }
134
+ end
135
+ end
136
+ ----
137
+
138
+ [source,json]
139
+ ----
140
+ { "component": [
141
+ { "type": "guidance", "text": "g1" },
142
+ { "type": "purpose", "text": "p1" }
143
+ ] }
144
+ ----
145
+
146
+ `guidance` holds `g1`, `purpose` holds `p1`. Serialization merges the
147
+ items back under the one key, each stamped with its discriminator.
148
+ `unmatched: :raise` fails the parse on items no rule claims. Works for
149
+ all key-value formats. See
150
+ link:advanced-mapping.adoc[the advanced mapping guide] for the full
151
+ semantics, including the per-rule form.
152
+
119
153
  === Specific format mappings
120
154
 
121
155
  Specific key value formats can be mapping independently of other formats.
@@ -5,9 +5,12 @@ module Lutaml
5
5
  # Use child's own default register if it has one
6
6
  # This ensures versioned schemas (e.g., MML v2 with lutaml_default_register = :mml_v2)
7
7
  # are instantiated with their native context
8
- child_register = Lutaml::Model::Register.resolve_for_child(
9
- model_class, lutaml_register
10
- )
8
+ # TODO.max-perf/32: constant per (model class, register) — the
9
+ # transform itself is cached per that pair, so resolve once.
10
+ child_register = @kv_child_register ||= Lutaml::Model::Register
11
+ .resolve_for_child(
12
+ model_class, lutaml_register
13
+ )
11
14
 
12
15
  if model_class.include?(Lutaml::Model::Serialize)
13
16
  instance = model_class.new(lutaml_register: child_register)
@@ -449,7 +452,7 @@ partition = nil)
449
452
  if partitioned.nil? && (plan = kv_rule_plan(format, rule, attr)) &&
450
453
  (value = kv_fast_extract(doc, plan))
451
454
  rule.deserialize(instance, kv_fast_cast(value, plan, instance),
452
- attributes, self, options[:context])
455
+ attributes, self, options[:context], pre_cast: true)
453
456
  return
454
457
  end
455
458
 
@@ -485,6 +488,10 @@ partition = nil)
485
488
  if attr.collection? || !instance.is_a?(Lutaml::Model::Serialize)
486
489
  attr.valid_collection!(value, context)
487
490
  end
491
+ # No pre_cast here: the interpretive attr.cast is not the full
492
+ # cast chain — whole-value Type policies (e.g. a custom
493
+ # Type::Value that receives the whole hash) are shaped by the
494
+ # setter's cast_value, which must still run.
488
495
  rule.deserialize(instance, value, attributes, self,
489
496
  options[:context])
490
497
  end
@@ -324,13 +324,15 @@ module Lutaml
324
324
  end
325
325
 
326
326
  def deserialize(model, value, attributes, mapper_class = nil,
327
- context = nil)
327
+ context = nil, pre_cast: false)
328
328
  if @needs_full_deserialize
329
329
  handle_custom_method(model, value, mapper_class, context) ||
330
330
  handle_delegate(model, value, attributes) ||
331
- handle_transform_method(model, value, attributes, context)
331
+ handle_transform_method(model, value, attributes, context,
332
+ pre_cast: pre_cast)
332
333
  else
333
- handle_transform_method(model, value, attributes, context)
334
+ handle_transform_method(model, value, attributes, context,
335
+ pre_cast: pre_cast)
334
336
  end
335
337
  end
336
338
 
@@ -446,6 +448,24 @@ context = nil)
446
448
  # :import — hash/proc transformers go through ImportTransformer.
447
449
  TRANSFORM_DISPATCH = {}.compare_by_identity
448
450
 
451
+ def self.parsed_assign_writer(model_class, target)
452
+ key = [model_class, target]
453
+ writer = PARSED_ASSIGN_WRITERS[key]
454
+ return writer unless writer.nil?
455
+
456
+ name = :"__assign_parsed_#{target}="
457
+ writer = model_class.method_defined?(name) ? name : false
458
+ PARSED_ASSIGN_WRITERS[key] = writer
459
+ writer
460
+ end
461
+
462
+ # [model class, target] -> the parsed-assign writer compiled for
463
+ # that class's attribute, or nil when only a casting writer
464
+ # exists (custom writers, reflective names, enum shorthands).
465
+ # Class-level because mapping rules may be frozen (see the
466
+ # name-string note above TRANSFORM_DISPATCH).
467
+ PARSED_ASSIGN_WRITERS = {}.compare_by_identity
468
+
449
469
  def self.transform_dispatch(rule, attr)
450
470
  per_attr = TRANSFORM_DISPATCH[rule]
451
471
  if per_attr.nil?
@@ -556,7 +576,8 @@ context = nil)
556
576
  attributes[delegate].type(model.lutaml_register).new)
557
577
  end
558
578
 
559
- def handle_transform_method(model, value, attributes, context = nil)
579
+ def handle_transform_method(model, value, attributes, context = nil,
580
+ pre_cast: false)
560
581
  attr = attributes[to]
561
582
  # The transform verdict (none / class-based already applied /
562
583
  # hash-proc via ImportTransformer) is static per (rule, attr) —
@@ -566,12 +587,28 @@ context = nil)
566
587
  transformed = ImportTransformer.call(value, self, attr,
567
588
  context: context)
568
589
  assign_value(model, transformed)
590
+ elsif pre_cast && collection_shaped?(value, attr) &&
591
+ (writer = self.class.parsed_assign_writer(model.class, to))
592
+ # TODO.max-perf/31: the deserializing transform already cast
593
+ # the value through the format-aware path — assign without
594
+ # repeating the cast. Custom writers and reflective names
595
+ # resolve nil here and keep the casting setter.
596
+ model.public_send(writer, value)
569
597
  else
570
598
  assign_value(model, value)
571
599
  end
572
600
  true
573
601
  end
574
602
 
603
+ # Whether the value already carries the attribute's final shape:
604
+ # a bare single occurrence still owes the setter's one-element
605
+ # collection coercion, so only collection instances (and every
606
+ # singular value, whose cast is complete) take the no-recast lane.
607
+ def collection_shaped?(value, attr)
608
+ attr.nil? || !attr.collection? || value.is_a?(::Array) ||
609
+ value.is_a?(Lutaml::Model::Collection)
610
+ end
611
+
575
612
  def assign_value(model, value)
576
613
  model.public_send(@setter_name, value)
577
614
  end
@@ -297,6 +297,21 @@ module Lutaml
297
297
  end
298
298
  record_mutation_collection(:#{name}, value)
299
299
  end
300
+
301
+ # TODO.max-perf/31: the deserialization transforms already
302
+ # cast through the format-aware path; this writer skips the
303
+ # setter's re-cast while keeping value_set_for marking and
304
+ # mutation recording. Public assignment keeps the caster.
305
+ def __assign_parsed_#{name}=(value)
306
+ value_set_for(:#{name})
307
+ current = @#{name}
308
+ if current.equal?(Lutaml::Model::Serialize::LAZY_EMPTY_COLLECTION) &&
309
+ (value.nil? || Lutaml::Model::Utils.uninitialized?(value))
310
+ else
311
+ @#{name} = value
312
+ end
313
+ record_mutation_collection(:#{name}, value)
314
+ end
300
315
  RUBY
301
316
  else
302
317
  class_eval(<<~RUBY, __FILE__, __LINE__ + 1) # rubocop:disable Style/DocumentDynamicEvalDefinition
@@ -306,6 +321,13 @@ module Lutaml
306
321
  @#{name} = value
307
322
  record_mutation(:#{name}, value)
308
323
  end
324
+
325
+ # TODO.max-perf/31: see the collection branch above.
326
+ def __assign_parsed_#{name}=(value)
327
+ value_set_for(:#{name})
328
+ @#{name} = value
329
+ record_mutation(:#{name}, value)
330
+ end
309
331
  RUBY
310
332
  end
311
333
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Model
5
- VERSION = "0.8.47"
5
+ VERSION = "0.8.48"
6
6
  end
7
7
  end
@@ -48,9 +48,12 @@ module Lutaml
48
48
  # Use child's own default register if it has one
49
49
  # This ensures versioned schemas (e.g., MML v2 with lutaml_default_register = :mml_v2)
50
50
  # are instantiated with their native context
51
- child_register = Lutaml::Model::Register.resolve_for_child(
52
- model_class, lutaml_register
53
- )
51
+ # TODO.max-perf/32: constant per (model class, register) — the
52
+ # transform itself is cached per that pair, so resolve once.
53
+ child_register = @xml_child_register ||= Lutaml::Model::Register
54
+ .resolve_for_child(
55
+ model_class, lutaml_register
56
+ )
54
57
 
55
58
  instance_is_serialize = model_class.include?(::Lutaml::Model::Serialize)
56
59
  if instance_is_serialize
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lutaml-model
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.47
4
+ version: 0.8.48
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.