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.
- checksums.yaml +4 -4
- data/bench/bench_sts.rb +9 -3
- data/bench/gate_config.rb +5 -4
- data/lib/compat/opal/lutaml_model_boot.rb +1 -0
- data/lib/lutaml/key_value/transform.rb +52 -5
- data/lib/lutaml/key_value/transformation.rb +10 -2
- data/lib/lutaml/model/adapter_resolver.rb +14 -2
- data/lib/lutaml/model/attribute.rb +15 -9
- data/lib/lutaml/model/choice.rb +6 -5
- data/lib/lutaml/model/format_registry.rb +3 -1
- data/lib/lutaml/model/liquefiable.rb +23 -2
- data/lib/lutaml/model/mapping/mapping_rule.rb +46 -11
- data/lib/lutaml/model/sequence.rb +47 -2
- data/lib/lutaml/model/serialize/attribute_definition.rb +176 -47
- data/lib/lutaml/model/serialize/deserialization_context.rb +1 -0
- data/lib/lutaml/model/serialize.rb +54 -10
- data/lib/lutaml/model/services/transformer.rb +30 -11
- data/lib/lutaml/model/store.rb +70 -32
- data/lib/lutaml/model/version.rb +1 -1
- data/lib/lutaml/xml/format.rb +1 -0
- data/lib/lutaml/xml/html_entities.rb +290 -0
- data/lib/lutaml/xml/mapping.rb +72 -0
- data/lib/lutaml/xml/model_transform.rb +210 -72
- data/lib/lutaml/xml/parse_session.rb +38 -0
- data/lib/lutaml/yaml/format.rb +1 -0
- data/spec/lutaml/model/adapter_resolver_configured_spec.rb +38 -0
- data/spec/lutaml/model/custom_method_context_spec.rb +62 -0
- data/spec/lutaml/model/liquefiable_lazy_spec.rb +36 -0
- data/spec/lutaml/model/mapping_type_validation_spec.rb +61 -0
- data/spec/lutaml/model/sequence_choice_compositor_spec.rb +66 -0
- data/spec/lutaml/model/store_spec.rb +13 -11
- data/spec/lutaml/model/yaml_stream_spec.rb +70 -0
- data/spec/lutaml/xml/html_entities_spec.rb +45 -0
- 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
|
-
|
|
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
|
-
|
|
283
|
-
|
|
315
|
+
if record
|
|
316
|
+
attr = record.attr
|
|
317
|
+
next if record.derived
|
|
318
|
+
next if record.group_skip
|
|
284
319
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
291
|
-
|
|
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
|
-
|
|
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
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
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
|
-
|
|
568
|
-
|
|
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
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
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
|
-
|
|
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
|
data/lib/lutaml/yaml/format.rb
CHANGED
|
@@ -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
|