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
@@ -12,6 +12,25 @@ module Lutaml
12
12
  # - Used by model's serialization pipeline via Transform.for(:xml)
13
13
  #
14
14
  class ModelTransform < ::Lutaml::Model::Transform
15
+ require "cgi" unless Lutaml::Model.opal?
16
+
17
+ # Per (model class, register): compiled records for the mapping
18
+ # rules — attribute, derived/valid flags, and #765 group-skip —
19
+ # keyed on the mapping's finalize version so late changes rebuild.
20
+ # The loop body used to re-derive these per parsed instance.
21
+ # Concurrent::Map has no compare_by_identity; class identity is
22
+ # the key semantic here.
23
+ RULE_RECORDS = Concurrent::Map.new # rubocop:disable Lint/HashCompareByIdentity
24
+
25
+ # Namespaced rule name -> [local_name, rule_uri]. Pure string
26
+ # splitting, deterministic per spelling, shared across parses.
27
+ # Concurrent::Map under threaded MRI, plain Hash under Opal.
28
+ NAMESPACED_NAME_PARTS = if Lutaml::Model.opal?
29
+ {}
30
+ else
31
+ Lutaml::Model::RuntimeCompatibility.require_native("concurrent")
32
+ Concurrent::Map.new
33
+ end
15
34
  include NestedCollectionAttribute
16
35
 
17
36
  # Performance: Frozen empty hash to reduce allocations
@@ -27,7 +46,19 @@ module Lutaml
27
46
 
28
47
  instance_is_serialize = model_class.include?(::Lutaml::Model::Serialize)
29
48
  if instance_is_serialize
49
+ # TODO.max-perf/17: the same child element was being re-hydrated
50
+ # per matching rule (593k instantiations for 12k elements on the
51
+ # ISO-13849 profile). Memoize one instance per (element, class)
52
+ # for the whole parse; the memo travels in the propagated
53
+ # deserialization options.
54
+ memo = options[:__lutaml_child_memo] ||= {}
55
+ memo_key = [data.object_id, model_class.object_id]
56
+ if (existing = memo[memo_key])
57
+ return existing
58
+ end
59
+
30
60
  instance = model_class.allocate_for_deserialization(child_register)
61
+ memo[memo_key] = instance
31
62
  else
32
63
  instance = model_class.new
33
64
  register_accessor_methods_for(instance, child_register)
@@ -272,24 +303,33 @@ module Lutaml
272
303
  effective_register,
273
304
  )
274
305
 
306
+ serializable = instance.class.include?(Lutaml::Model::Serialize)
307
+ records = if serializable
308
+ compiled_rule_records(instance.class, effective_register,
309
+ grouped_plain_rules)
310
+ end
275
311
  mappings.each do |rule|
276
- # Performance: Cache rule properties accessed multiple times
277
- rule.name
312
+ record = records&.[](rule)
278
313
  rule_to = rule.to
279
- rule_namespace_set = rule.namespace_set?
280
- rule_namespace_set ? rule.namespace_param : nil
281
314
 
282
- attr = attribute_for_rule(rule)
283
- next if attr&.derived?
315
+ if record
316
+ attr = record.attr
317
+ next if record.derived
318
+ next if record.group_skip
284
319
 
285
- if (group = grouped_plain_rules[rule_to]) &&
286
- group.size > 1 && attr&.collection? && !group.first.equal?(rule)
287
- next
288
- end
320
+ raise "Attribute '#{rule_to}' not found in #{context}" unless record.valid
321
+ else
322
+ attr = attribute_for_rule(rule)
323
+ next if attr&.derived?
289
324
 
290
- raise "Attribute '#{rule_to}' not found in #{context}" unless valid_rule?(
291
- rule, attr
292
- )
325
+ group = grouped_plain_rules[rule_to]
326
+ next if group && group.size > 1 && attr&.collection? &&
327
+ !group.first.equal?(rule)
328
+
329
+ raise "Attribute '#{rule_to}' not found in #{context}" unless valid_rule?(
330
+ rule, attr
331
+ )
332
+ end
293
333
 
294
334
  # Performance: Use pre-computed frozen Hash for static namespace overrides.
295
335
  # Falls back to per-element hash only for the dynamic namespace_uri case.
@@ -362,6 +402,9 @@ module Lutaml
362
402
  value = apply_value_map(value, from_map, attr)
363
403
  value = normalize_xml_value(value, rule, attr, new_opts,
364
404
  effective_register)
405
+ value = decode_html_entities_value(value, decode_html_entities_for(
406
+ instance, effective_register
407
+ ))
365
408
  value = rule.transform_value(attr, value, :from, :xml)
366
409
  # An over-count is normally left for `.validate` to report. A mapped
367
410
  # PORO has no `.validate`, so deferring there would discard the
@@ -370,7 +413,8 @@ module Lutaml
370
413
  !rule.content_mapping?
371
414
  attr.valid_collection!(value, context)
372
415
  end
373
- rule.deserialize(instance, value, attributes, context)
416
+ rule.deserialize(instance, value, attributes, context,
417
+ options[:context])
374
418
 
375
419
  instance.value_set_for(rule_to)
376
420
  end
@@ -487,16 +531,11 @@ _effective_register)
487
531
  mapping = klass.mappings_for(:xml, effective_register)
488
532
  return true unless mapping
489
533
 
490
- expected = Array(rule.name).map(&:to_s)
491
- mapping.mappings.none? do |sib|
492
- next false unless sib.attribute? && !sib.equal?(rule)
493
-
494
- Array(sib.name).map(&:to_s) == expected
495
- end
534
+ mapping.sole_local_attribute_claimant?(rule, effective_register)
496
535
  end
497
536
 
498
537
  def value_for_xml_attribute(doc, rule, rule_names,
499
- flexible_local: false)
538
+ flexible_local: false, session: nil)
500
539
  # For attributes, rule_names may contain namespaced names, but find_attribute_value
501
540
  # expects prefix:name or just name, so we need to convert namespaced names to prefix format.
502
541
  #
@@ -508,11 +547,13 @@ _effective_register)
508
547
  # its result array entirely on that path.
509
548
  if rule_names.size == 1
510
549
  rn = rule_names[0]
511
- converted = convert_rule_name_to_attribute_name(doc, rn)
550
+ converted = convert_rule_name_to_attribute_name(doc, rn,
551
+ session: session)
512
552
  value = doc.root.find_attribute_value(converted || rn)
513
553
  else
514
554
  attribute_names = rule_names.map do |rn|
515
- converted = convert_rule_name_to_attribute_name(doc, rn)
555
+ converted = convert_rule_name_to_attribute_name(doc, rn,
556
+ session: session)
516
557
  converted || rn
517
558
  end
518
559
 
@@ -524,7 +565,8 @@ _effective_register)
524
565
  # (e.g., v:ext without xmlns:v="...").
525
566
  if value.nil? && rule_names.any? { |rn| rn.include?(":") }
526
567
  value = find_attribute_by_local_name(doc, rule_names,
527
- flexible_local: flexible_local)
568
+ flexible_local: flexible_local,
569
+ session: session)
528
570
  end
529
571
 
530
572
  value = value&.split(rule.delimiter) if rule.delimiter
@@ -540,7 +582,7 @@ _effective_register)
540
582
  # @param doc [XmlElement] the parsed XML document root
541
583
  # @param rule_name [String] the rule name (e.g., "urn:...:ext", "http://.../ns:val", "my-ns:val")
542
584
  # @return [String, nil] the attribute name to look up, or nil if no conversion needed
543
- def convert_rule_name_to_attribute_name(doc, rule_name)
585
+ def convert_rule_name_to_attribute_name(doc, rule_name, session: nil)
544
586
  return nil unless rule_name.include?(":")
545
587
 
546
588
  # URI-vs-prefix detection works on the whole rule name: the
@@ -559,13 +601,27 @@ _effective_register)
559
601
  # match, and unprefixed_name is not called per attribute
560
602
  # (it splits prefixed names and allocates).
561
603
  local_name = rule_name[(last_colon_index + 1)..]
562
- doc.root.attributes.each_value.find do |attr|
563
- ns = attr.namespace
564
- next false if ns.nil? || ns.length > last_colon_index
565
- next false unless rule_name.start_with?(ns) && rule_name[ns.length] == ":"
604
+ if session
605
+ candidates = session.element_local_attribute_index(doc.root)[local_name]
606
+ # No candidate carries this local name: nothing in the
607
+ # element can match, the scan is skipped entirely.
608
+ matched = candidates&.find do |attr|
609
+ ns = attr.namespace
610
+ next false if ns.nil? || ns.length > last_colon_index
611
+ next false unless rule_name.start_with?(ns) && rule_name[ns.length] == ":"
612
+
613
+ attr.unprefixed_name == local_name
614
+ end
615
+ else
616
+ matched = doc.root.attributes.each_value.find do |attr|
617
+ ns = attr.namespace
618
+ next false if ns.nil? || ns.length > last_colon_index
619
+ next false unless rule_name.start_with?(ns) && rule_name[ns.length] == ":"
566
620
 
567
- attr.unprefixed_name == local_name
568
- end&.name
621
+ attr.unprefixed_name == local_name
622
+ end
623
+ end
624
+ matched&.name
569
625
  else
570
626
  # Simple prefix format: look up the actual prefix from document's namespace declarations
571
627
  # The namespace_part is the namespace URI declared in the document (e.g., "my-ns").
@@ -588,49 +644,127 @@ _effective_register)
588
644
  # @param rule_names [Array<String>] the rule names to match
589
645
  # @return [String, nil] the attribute value or nil
590
646
  def find_attribute_by_local_name(doc, rule_names,
591
- flexible_local: false)
592
- rule_names.each do |rn|
593
- next unless rn.include?(":")
594
-
595
- last_colon_index = rn.rindex(":")
596
- local_name = rn[(last_colon_index + 1)..]
597
- rule_uri = rn[0...last_colon_index]
598
-
599
- matched_attr = doc.root.attributes.each_value.find do |attr|
600
- # Local-name fallback serves two lenient cases only:
601
- # - an attribute with an undeclared prefix (raw name keeps the
602
- # colon, namespace unresolvable), the historical purpose;
603
- # - an attribute whose resolved namespace IS one of the rule's
604
- # namespace URIs (prefix rebinding, lutaml-model#744).
605
- # Everything else — including a definitively namespace-less
606
- # attribute and an attribute from a foreign namespace — must
607
- # not satisfy the fallback, or a plain rule steals a
608
- # namespaced sibling attribute (lutaml-model#758). An
609
- # XmlAttribute with a resolved namespace has namespaced_name
610
- # "URI:local", so this comparison is allocation-free.
611
- if flexible_local
612
- # Sole claimant: any qualification of the local name binds
613
- # (element namespace, foreign extension namespace, none).
614
- attr.unprefixed_name == local_name ||
615
- ((colon = attr.name.rindex(":")) ? attr.name[(colon + 1)..] : attr.name) == local_name
616
- elsif attr.namespace.nil?
617
- next false if attr.namespace_prefix.nil? &&
618
- !attr.name.include?(":")
619
-
620
- attr.unprefixed_name == local_name ||
621
- ((colon = attr.name.rindex(":")) ? attr.name[(colon + 1)..] : attr.name) == local_name
622
- else
623
- # Prefixed attributes carry namespaced_name "prefix:local",
624
- # which a URI-form rule name can never equal — match by the
625
- # attribute's resolved namespace instead.
626
- attr.namespace == rule_uri && attr.unprefixed_name == local_name
627
- end
628
- end
629
- return matched_attr&.value if matched_attr
647
+ flexible_local: false, session: nil)
648
+ root_index = session&.element_local_attribute_index(doc.root)
649
+
650
+ rule_names.each do |rule_name|
651
+ next unless rule_name.include?(":")
652
+
653
+ resolved = match_attribute_by_local_name(
654
+ doc, rule_name, root_index, flexible_local
655
+ )
656
+ return resolved if resolved
657
+ end
658
+ nil
659
+ end
660
+
661
+ def match_attribute_by_local_name(doc, rule_name, root_index, flexible_local)
662
+ # Splitting a namespaced rule name is pure string work repeated
663
+ # per element; memoized per spelling (bounded by the model's
664
+ # distinct rule names).
665
+ parts = NAMESPACED_NAME_PARTS[rule_name]
666
+ unless parts
667
+ last_colon_index = rule_name.rindex(":")
668
+ parts = [rule_name[(last_colon_index + 1)..], rule_name[0...last_colon_index]]
669
+ NAMESPACED_NAME_PARTS[rule_name] = parts
630
670
  end
671
+ local_name, rule_uri = parts
672
+
673
+ candidates = root_index&.[](local_name)
674
+ matched_attr = if candidates
675
+ candidates.find do |attr|
676
+ local_name_match?(attr, local_name, rule_uri,
677
+ flexible_local)
678
+ end
679
+ else
680
+ doc.root.attributes.each_value.find do |attr|
681
+ local_name_match?(attr, local_name, rule_uri,
682
+ flexible_local)
683
+ end
684
+ end
685
+ matched_attr&.value
686
+ end
687
+
688
+ # Namespace discipline of the lenient local-name fallback
689
+ # (#744/#758/#790): sole claimants bind any qualification; shared
690
+ # names must resolve to the rule's namespace.
691
+ def local_name_match?(attr, local_name, rule_uri, flexible_local)
692
+ if flexible_local
693
+ attr.unprefixed_name == local_name ||
694
+ attr_name_local(attr) == local_name
695
+ elsif attr.namespace.nil?
696
+ return false if attr.namespace_prefix.nil? && !attr.name.include?(":")
697
+
698
+ attr.unprefixed_name == local_name ||
699
+ attr_name_local(attr) == local_name
700
+ else
701
+ attr.namespace == rule_uri && attr.unprefixed_name == local_name
702
+ end
703
+ end
704
+
705
+ def attr_name_local(attr)
706
+ (colon = attr.name.rindex(":")) ? attr.name[(colon + 1)..] : attr.name
707
+ end
708
+
709
+ # lutaml-model#154: decode HTML entities for mappings that opted in
710
+ # (`html_entities` in the xml block). Values stay untouched
711
+ # otherwise — XML keeps undefined entities literal by design.
712
+ def decode_html_entities_value(value, enabled)
713
+ return value if value.nil? || !value.is_a?(String)
714
+ return value unless enabled
715
+ return value if Lutaml::Model.opal?
716
+
717
+ Lutaml::Xml::HtmlEntities.decode(value)
718
+ end
719
+
720
+ Record = Struct.new(:attr, :derived, :group_skip, :valid)
721
+
722
+ def compiled_rule_records(model_class, register, grouped_plain_rules)
723
+ return {}.compare_by_identity unless model_class.include?(Lutaml::Model::Serialize)
724
+
725
+ mapping = model_class.mappings_for(:xml, register)
726
+ version = mapping&.rule_records_version.to_i
727
+ per_class = RULE_RECORDS[model_class.object_id] || # rubocop:disable Lint/HashCompareByIdentity
728
+ (RULE_RECORDS[model_class.object_id] = Concurrent::Map.new) # rubocop:disable Lint/HashCompareByIdentity,Layout/MultilineAssignmentLayout
729
+ cached = per_class[register]
730
+ return cached.records if cached && cached.version == version
731
+
732
+ records = {}.compare_by_identity
733
+ mapping&.mappings(register)&.each do |rule| # rubocop:disable Style/SafeNavigationChain,Style/SafeNavigation
734
+ attr = attribute_for_rule_static(model_class, rule, register)
735
+ group = grouped_plain_rules[rule.to]
736
+ records[rule] = Record.new(
737
+ attr,
738
+ attr&.derived? || false,
739
+ !!(group && group.size > 1 && attr&.collection? &&
740
+ !group.first.equal?(rule)),
741
+ !!(attr || rule.custom_methods[:from]),
742
+ )
743
+ end
744
+ entry = Struct.new(:version, :records).new(version, records)
745
+ per_class[register] = entry
746
+ records
747
+ end
748
+
749
+ def attribute_for_rule_static(model_class, rule, register)
750
+ if rule.delegate
751
+ delegate_attr = model_class.attributes(register)[rule.delegate]
752
+ delegate_type = delegate_attr&.type(register)
753
+ delegate_type&.attributes(register)&.[](rule.to) # rubocop:disable Style/SafeNavigationChain
754
+ else
755
+ model_class.attributes(register)[rule.to]
756
+ end
757
+ rescue StandardError
631
758
  nil
632
759
  end
633
760
 
761
+ def decode_html_entities_for(instance, register)
762
+ klass = instance.class
763
+ return false unless klass.is_a?(Class) && klass.include?(Lutaml::Model::Serialize)
764
+
765
+ klass.mappings_for(:xml, register)&.decode_html_entities?
766
+ end
767
+
634
768
  def value_for_rule(session, rule, options, cached_attr = nil,
635
769
  extra_rule_names = nil)
636
770
  doc = session.doc
@@ -665,12 +799,16 @@ _effective_register)
665
799
  rule_names = rule_names.concat(extra_rule_names).uniq if extra_rule_names
666
800
 
667
801
  if rule.attribute?
668
- return value_for_xml_attribute(
802
+ value = value_for_xml_attribute(
669
803
  doc, rule, rule_names,
670
804
  flexible_local: attribute_rule_sole_local_claimant?(
671
805
  instance, rule, effective_register
672
- )
806
+ ),
807
+ session: session
673
808
  )
809
+ return decode_html_entities_value(value, decode_html_entities_for(
810
+ instance, effective_register
811
+ ))
674
812
  end
675
813
 
676
814
  # Performance: Pre-compute type-related values used in the hot loop
@@ -24,6 +24,38 @@ module Lutaml
24
24
  @instance_is_serialize ||= instance.is_a?(::Lutaml::Model::Serialize)
25
25
  end
26
26
 
27
+ # (element, rule name) -> resolved attribute name / matched
28
+ # attribute VALUE for the lenient lookup paths. Both are pure
29
+ # functions of the element and the name; ISO-13849-shaped
30
+ # documents repeat the same resolutions per rule application, so
31
+ # the computed answer is memoized per element for the parse.
32
+ def rule_name_resolution
33
+ @rule_name_resolution ||= {}
34
+ end
35
+
36
+ # Local-name -> [attributes] index over one element's attributes,
37
+ # built on first lenient lookup (TODO.max-perf/08) and memoized per
38
+ # element for the life of the session. The URI-form attribute
39
+ # conversion and the lenient fallback used to rescan every
40
+ # attribute per rule per element (the ISO-13849 pathology);
41
+ # elements are parse-frozen, so each index builds at most once.
42
+ def element_local_attribute_index(element)
43
+ @element_local_attribute_index ||= {}.compare_by_identity
44
+ @element_local_attribute_index[element] ||= begin
45
+ # Plain hash: a miss must stay a nil lookup, not allocate an
46
+ # empty default array — misses dominate (the ISO-13849 shape
47
+ # resolves ~390k lenient lookups that match nothing).
48
+ index = {}
49
+ element.attributes.each_value do |attr|
50
+ local_names(attr).each do |key|
51
+ entries = (index[key] ||= [])
52
+ entries << attr unless entries.include?(attr)
53
+ end
54
+ end
55
+ index
56
+ end
57
+ end
58
+
27
59
  def model_class
28
60
  @model_class ||= instance.class
29
61
  end
@@ -57,3 +89,9 @@ module Lutaml
57
89
  end
58
90
  end
59
91
  end
92
+
93
+ def local_names(attr)
94
+ colon = attr.name.rindex(":")
95
+ split_local = colon ? attr.name[(colon + 1)..] : attr.name
96
+ [attr.unprefixed_name, split_local].compact.uniq
97
+ end
@@ -24,6 +24,7 @@ Lutaml::Model::FormatRegistry.register(
24
24
  available: %i[standard standard_yaml yeptris],
25
25
  default: :standard,
26
26
  },
27
+ stream_methods: true,
27
28
  )
28
29
 
29
30
  require_relative "type/serializers"
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe Lutaml::Model::AdapterResolver, ".adapter_for with a configured type" do
6
+ after do
7
+ Lutaml::Model::Config.configure do |config|
8
+ config.xml_adapter_type = :nokogiri
9
+ config.yaml_adapter_type = :standard
10
+ config.json_adapter_type = :standard
11
+ end
12
+ end
13
+
14
+ it "resolves the configured adapter without running auto-detection" do
15
+ Lutaml::Model::Config.configure do |config|
16
+ config.json_adapter_type = :standard
17
+ end
18
+
19
+ adapter = described_class.adapter_for(:json)
20
+
21
+ expect(described_class.configured_type(:json)).to eq(:standard)
22
+ expect(adapter.name).to include("Json::Standard")
23
+ end
24
+
25
+ it "does not load the optional yeptris engine when a standard adapter is configured" do
26
+ skip "yeptris already loaded by an earlier spec" if Object.const_defined?(:Yeptris)
27
+
28
+ Lutaml::Model::Config.configure do |config|
29
+ config.yaml_adapter_type = :standard
30
+ config.json_adapter_type = :standard
31
+ end
32
+
33
+ described_class.adapter_for(:yaml)
34
+ described_class.adapter_for(:json)
35
+
36
+ expect(Object.const_defined?(:Yeptris)).to be(false)
37
+ end
38
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # lutaml-model#550: options passed to `from_*` / `to_*` flow to custom
6
+ # methods that declare the extra context parameter (arity-aware; the
7
+ # historical signatures keep working).
8
+ RSpec.describe "Custom method context forwarding" do
9
+ let(:model_class) do
10
+ Class.new(Lutaml::Model::Serializable) do
11
+ attribute :name, :string
12
+
13
+ def name_from(model, value, ctx = nil)
14
+ model.name = "#{value} (#{ctx ? ctx[:schema] : 'none'})"
15
+ end
16
+
17
+ def name_to(model, hash, ctx = nil)
18
+ hash["name"] = "#{model.name} <#{ctx ? ctx[:schema] : 'none'}>"
19
+ end
20
+
21
+ key_value do
22
+ map "name", to: :name,
23
+ with: { from: :name_from, to: :name_to }
24
+ end
25
+ end
26
+ end
27
+
28
+ it "passes context to custom methods on parse when accepted" do
29
+ parsed = model_class.from_yaml({ "name" => "Alpine" }.to_yaml,
30
+ context: { schema: "ISO" })
31
+ expect(parsed.name).to eq("Alpine (ISO)")
32
+ end
33
+
34
+ it "passes context to custom methods on serialize when accepted" do
35
+ instance = model_class.from_yaml({ "name" => "Alpine" }.to_yaml)
36
+ out = instance.to_yaml(context: { schema: "ISO" })
37
+ expect(out).to include("Alpine (none) <ISO>")
38
+ end
39
+
40
+ it "keeps two-parameter custom methods working without context" do
41
+ parsed = model_class.from_yaml({ "name" => "Alpine" }.to_yaml)
42
+ expect(parsed.name).to eq("Alpine (none)")
43
+
44
+ out = parsed.to_yaml
45
+ expect(out).to include("Alpine (none) <none>")
46
+ end
47
+
48
+ it "forwards context to with-lambdas of exact arity 2" do
49
+ klass = Class.new(Lutaml::Model::Serializable) do
50
+ attribute :code, :string
51
+
52
+ key_value do
53
+ map "code", to: :code,
54
+ with: { from: ->(value, ctx) { "#{value}-#{ctx[:rev]}" } }
55
+ end
56
+ end
57
+
58
+ parsed = klass.from_yaml({ "code" => "A1" }.to_yaml,
59
+ context: { rev: 7 })
60
+ expect(parsed.code).to eq("A1-7")
61
+ end
62
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # lutaml-model#800: `liquid do ... end` blocks survive a boot order in
6
+ # which Liquid is required after lutaml-model; the drop class
7
+ # materializes on first use instead of requiring Liquid at definition.
8
+ RSpec.describe "Liquefiable lazy registration" do
9
+ it "materializes the drop class on first to_liquid" do
10
+ klass = Class.new(Lutaml::Model::Serializable) do
11
+ attribute :name, :string
12
+
13
+ liquid do
14
+ map "full_name", to: :name
15
+ end
16
+ end
17
+
18
+ # Mapping stored at definition; drop registration is lazy
19
+ expect(klass.liquid_mappings.mappings).not_to be_empty
20
+
21
+ drop = klass.new(name: "Apollo").to_liquid
22
+ expect(drop).to be_a(Liquid::Drop)
23
+ expect(drop.full_name).to eq("Apollo")
24
+ end
25
+
26
+ it "keeps ensure_liquid_registered! idempotent" do
27
+ klass = Class.new(Lutaml::Model::Serializable) do
28
+ attribute :name, :string
29
+ end
30
+
31
+ klass.ensure_liquid_registered!
32
+ first = klass.base_drop_class
33
+ klass.ensure_liquid_registered!
34
+ expect(klass.base_drop_class).to equal(first)
35
+ end
36
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # lutaml-model#296: a mapped rule whose attribute type can never accept
6
+ # the serialization shape fails at mapping definition, naming the
7
+ # attribute and the type, instead of surfacing mid-parse.
8
+ RSpec.describe "Mapping-time attribute type validation" do
9
+ it "raises InvalidAttributeTypeError at definition for a non-castable type" do
10
+ expect do
11
+ Class.new(Lutaml::Model::Serializable) do
12
+ attribute :ends, Object
13
+
14
+ xml do
15
+ element "doc"
16
+ map_element "ends", to: :ends
17
+ end
18
+ end
19
+ end.to raise_error(Lutaml::Model::InvalidAttributeTypeError) do |err|
20
+ expect(err.message).to include("attribute `ends`")
21
+ expect(err.message).to include("Object")
22
+ end
23
+ end
24
+
25
+ it "accepts Value, Serializable, and collection types" do
26
+ item = Class.new(Lutaml::Model::Serializable) do
27
+ attribute :name, :string
28
+ xml do
29
+ element "item"
30
+ map_element "name", to: :name
31
+ end
32
+ end
33
+
34
+ expect do
35
+ Class.new(Lutaml::Model::Serializable) do
36
+ attribute :count, :integer
37
+ attribute :name, :string
38
+ attribute :items, item, collection: true
39
+
40
+ xml do
41
+ element "doc"
42
+ map_element "count", to: :count
43
+ map_element "items", to: :items
44
+ end
45
+ end
46
+ end.not_to raise_error
47
+ end
48
+
49
+ it "leaves undeclared (deferred) types to import resolution" do
50
+ expect do
51
+ Class.new(Lutaml::Model::Serializable) do
52
+ attribute :later, :missing_type_name
53
+
54
+ xml do
55
+ element "doc"
56
+ map_element "later", to: :later
57
+ end
58
+ end
59
+ end.not_to raise_error
60
+ end
61
+ end