lutaml-model 0.8.37 → 0.8.39
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/gate_config.rb +5 -4
- data/docs/_guides/custom-methods.adoc +71 -0
- data/docs/_guides/index.adoc +1 -0
- data/lib/compat/opal/lutaml_model_boot.rb +1 -0
- data/lib/lutaml/key_value/transform.rb +51 -5
- data/lib/lutaml/key_value/transformation.rb +10 -2
- data/lib/lutaml/model/attribute.rb +5 -1
- data/lib/lutaml/model/compiled_rule.rb +9 -0
- data/lib/lutaml/model/format_registry.rb +3 -1
- data/lib/lutaml/model/mapping/mapping_rule.rb +51 -11
- data/lib/lutaml/model/serialize/attribute_definition.rb +143 -65
- data/lib/lutaml/model/serialize/deserialization_context.rb +1 -0
- data/lib/lutaml/model/serialize.rb +66 -17
- data/lib/lutaml/model/services/transformer.rb +30 -11
- data/lib/lutaml/model/type/string.rb +4 -1
- 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 +19 -0
- data/lib/lutaml/xml/model_transform.rb +181 -53
- data/lib/lutaml/xml/parse_session.rb +24 -11
- data/lib/lutaml/xml/transformation/rule_applier.rb +1 -7
- data/lib/lutaml/yaml/format.rb +1 -0
- data/spec/lutaml/model/custom_method_context_spec.rb +62 -0
- data/spec/lutaml/model/yaml_stream_spec.rb +70 -0
- data/spec/lutaml/xml/html_entities_spec.rb +45 -0
- metadata +7 -2
|
@@ -12,6 +12,20 @@ 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 under threaded MRI, plain Hash under Opal
|
|
22
|
+
# (Concurrent is unavailable there); class identity keys either.
|
|
23
|
+
RULE_RECORDS = if Lutaml::Model.opal?
|
|
24
|
+
{}
|
|
25
|
+
else
|
|
26
|
+
Concurrent::Map.new # rubocop:disable Lint/HashCompareByIdentity
|
|
27
|
+
end
|
|
28
|
+
|
|
15
29
|
# Namespaced rule name -> [local_name, rule_uri]. Pure string
|
|
16
30
|
# splitting, deterministic per spelling, shared across parses.
|
|
17
31
|
# Concurrent::Map under threaded MRI, plain Hash under Opal.
|
|
@@ -36,7 +50,19 @@ module Lutaml
|
|
|
36
50
|
|
|
37
51
|
instance_is_serialize = model_class.include?(::Lutaml::Model::Serialize)
|
|
38
52
|
if instance_is_serialize
|
|
53
|
+
# TODO.max-perf/17: the same child element was being re-hydrated
|
|
54
|
+
# per matching rule (593k instantiations for 12k elements on the
|
|
55
|
+
# ISO-13849 profile). Memoize one instance per (element, class)
|
|
56
|
+
# for the whole parse; the memo travels in the propagated
|
|
57
|
+
# deserialization options.
|
|
58
|
+
memo = options[:__lutaml_child_memo] ||= {}
|
|
59
|
+
memo_key = [data.object_id, model_class.object_id]
|
|
60
|
+
if (existing = memo[memo_key])
|
|
61
|
+
return existing
|
|
62
|
+
end
|
|
63
|
+
|
|
39
64
|
instance = model_class.allocate_for_deserialization(child_register)
|
|
65
|
+
memo[memo_key] = instance
|
|
40
66
|
else
|
|
41
67
|
instance = model_class.new
|
|
42
68
|
register_accessor_methods_for(instance, child_register)
|
|
@@ -281,24 +307,33 @@ module Lutaml
|
|
|
281
307
|
effective_register,
|
|
282
308
|
)
|
|
283
309
|
|
|
310
|
+
serializable = instance.class.include?(Lutaml::Model::Serialize)
|
|
311
|
+
records = if serializable
|
|
312
|
+
compiled_rule_records(instance.class, effective_register,
|
|
313
|
+
grouped_plain_rules)
|
|
314
|
+
end
|
|
284
315
|
mappings.each do |rule|
|
|
285
|
-
|
|
286
|
-
rule.name
|
|
316
|
+
record = records&.[](rule)
|
|
287
317
|
rule_to = rule.to
|
|
288
|
-
rule_namespace_set = rule.namespace_set?
|
|
289
|
-
rule_namespace_set ? rule.namespace_param : nil
|
|
290
318
|
|
|
291
|
-
|
|
292
|
-
|
|
319
|
+
if record
|
|
320
|
+
attr = record.attr
|
|
321
|
+
next if record.derived
|
|
322
|
+
next if record.group_skip
|
|
293
323
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
324
|
+
raise "Attribute '#{rule_to}' not found in #{context}" unless record.valid
|
|
325
|
+
else
|
|
326
|
+
attr = attribute_for_rule(rule)
|
|
327
|
+
next if attr&.derived?
|
|
298
328
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
329
|
+
group = grouped_plain_rules[rule_to]
|
|
330
|
+
next if group && group.size > 1 && attr&.collection? &&
|
|
331
|
+
!group.first.equal?(rule)
|
|
332
|
+
|
|
333
|
+
raise "Attribute '#{rule_to}' not found in #{context}" unless valid_rule?(
|
|
334
|
+
rule, attr
|
|
335
|
+
)
|
|
336
|
+
end
|
|
302
337
|
|
|
303
338
|
# Performance: Use pre-computed frozen Hash for static namespace overrides.
|
|
304
339
|
# Falls back to per-element hash only for the dynamic namespace_uri case.
|
|
@@ -371,6 +406,9 @@ module Lutaml
|
|
|
371
406
|
value = apply_value_map(value, from_map, attr)
|
|
372
407
|
value = normalize_xml_value(value, rule, attr, new_opts,
|
|
373
408
|
effective_register)
|
|
409
|
+
value = decode_html_entities_value(value, decode_html_entities_for(
|
|
410
|
+
instance, effective_register
|
|
411
|
+
))
|
|
374
412
|
value = rule.transform_value(attr, value, :from, :xml)
|
|
375
413
|
# An over-count is normally left for `.validate` to report. A mapped
|
|
376
414
|
# PORO has no `.validate`, so deferring there would discard the
|
|
@@ -379,7 +417,8 @@ module Lutaml
|
|
|
379
417
|
!rule.content_mapping?
|
|
380
418
|
attr.valid_collection!(value, context)
|
|
381
419
|
end
|
|
382
|
-
rule.deserialize(instance, value, attributes, context
|
|
420
|
+
rule.deserialize(instance, value, attributes, context,
|
|
421
|
+
options[:context])
|
|
383
422
|
|
|
384
423
|
instance.value_set_for(rule_to)
|
|
385
424
|
end
|
|
@@ -512,11 +551,13 @@ _effective_register)
|
|
|
512
551
|
# its result array entirely on that path.
|
|
513
552
|
if rule_names.size == 1
|
|
514
553
|
rn = rule_names[0]
|
|
515
|
-
converted = convert_rule_name_to_attribute_name(doc, rn
|
|
554
|
+
converted = convert_rule_name_to_attribute_name(doc, rn,
|
|
555
|
+
session: session)
|
|
516
556
|
value = doc.root.find_attribute_value(converted || rn)
|
|
517
557
|
else
|
|
518
558
|
attribute_names = rule_names.map do |rn|
|
|
519
|
-
converted = convert_rule_name_to_attribute_name(doc, rn
|
|
559
|
+
converted = convert_rule_name_to_attribute_name(doc, rn,
|
|
560
|
+
session: session)
|
|
520
561
|
converted || rn
|
|
521
562
|
end
|
|
522
563
|
|
|
@@ -545,7 +586,7 @@ _effective_register)
|
|
|
545
586
|
# @param doc [XmlElement] the parsed XML document root
|
|
546
587
|
# @param rule_name [String] the rule name (e.g., "urn:...:ext", "http://.../ns:val", "my-ns:val")
|
|
547
588
|
# @return [String, nil] the attribute name to look up, or nil if no conversion needed
|
|
548
|
-
def convert_rule_name_to_attribute_name(doc, rule_name)
|
|
589
|
+
def convert_rule_name_to_attribute_name(doc, rule_name, session: nil)
|
|
549
590
|
return nil unless rule_name.include?(":")
|
|
550
591
|
|
|
551
592
|
# URI-vs-prefix detection works on the whole rule name: the
|
|
@@ -564,13 +605,27 @@ _effective_register)
|
|
|
564
605
|
# match, and unprefixed_name is not called per attribute
|
|
565
606
|
# (it splits prefixed names and allocates).
|
|
566
607
|
local_name = rule_name[(last_colon_index + 1)..]
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
608
|
+
if session
|
|
609
|
+
candidates = session.element_local_attribute_index(doc.root)[local_name]
|
|
610
|
+
# No candidate carries this local name: nothing in the
|
|
611
|
+
# element can match, the scan is skipped entirely.
|
|
612
|
+
matched = candidates&.find do |attr|
|
|
613
|
+
ns = attr.namespace
|
|
614
|
+
next false if ns.nil? || ns.length > last_colon_index
|
|
615
|
+
next false unless rule_name.start_with?(ns) && rule_name[ns.length] == ":"
|
|
616
|
+
|
|
617
|
+
attr.unprefixed_name == local_name
|
|
618
|
+
end
|
|
619
|
+
else
|
|
620
|
+
matched = doc.root.attributes.each_value.find do |attr|
|
|
621
|
+
ns = attr.namespace
|
|
622
|
+
next false if ns.nil? || ns.length > last_colon_index
|
|
623
|
+
next false unless rule_name.start_with?(ns) && rule_name[ns.length] == ":"
|
|
571
624
|
|
|
572
|
-
|
|
573
|
-
|
|
625
|
+
attr.unprefixed_name == local_name
|
|
626
|
+
end
|
|
627
|
+
end
|
|
628
|
+
matched&.name
|
|
574
629
|
else
|
|
575
630
|
# Simple prefix format: look up the actual prefix from document's namespace declarations
|
|
576
631
|
# The namespace_part is the namespace URI declared in the document (e.g., "my-ns").
|
|
@@ -594,39 +649,46 @@ _effective_register)
|
|
|
594
649
|
# @return [String, nil] the attribute value or nil
|
|
595
650
|
def find_attribute_by_local_name(doc, rule_names,
|
|
596
651
|
flexible_local: false, session: nil)
|
|
597
|
-
root_index = session&.
|
|
598
|
-
|
|
599
|
-
rule_names.each do |rn|
|
|
600
|
-
next unless rn.include?(":")
|
|
601
|
-
|
|
602
|
-
# Splitting a namespaced rule name is pure string work repeated
|
|
603
|
-
# per element; memoized per spelling (bounded by the model's
|
|
604
|
-
# distinct rule names).
|
|
605
|
-
parts = NAMESPACED_NAME_PARTS[rn]
|
|
606
|
-
unless parts
|
|
607
|
-
last_colon_index = rn.rindex(":")
|
|
608
|
-
parts = [rn[(last_colon_index + 1)..], rn[0...last_colon_index]]
|
|
609
|
-
NAMESPACED_NAME_PARTS[rn] = parts
|
|
610
|
-
end
|
|
611
|
-
local_name, rule_uri = parts
|
|
652
|
+
root_index = session&.element_local_attribute_index(doc.root)
|
|
612
653
|
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
doc.root.attributes.each_value.find do |attr|
|
|
621
|
-
local_name_match?(attr, local_name, rule_uri,
|
|
622
|
-
flexible_local)
|
|
623
|
-
end
|
|
624
|
-
end
|
|
625
|
-
return matched_attr&.value if matched_attr
|
|
654
|
+
rule_names.each do |rule_name|
|
|
655
|
+
next unless rule_name.include?(":")
|
|
656
|
+
|
|
657
|
+
resolved = match_attribute_by_local_name(
|
|
658
|
+
doc, rule_name, root_index, flexible_local
|
|
659
|
+
)
|
|
660
|
+
return resolved if resolved
|
|
626
661
|
end
|
|
627
662
|
nil
|
|
628
663
|
end
|
|
629
664
|
|
|
665
|
+
def match_attribute_by_local_name(doc, rule_name, root_index, flexible_local)
|
|
666
|
+
# Splitting a namespaced rule name is pure string work repeated
|
|
667
|
+
# per element; memoized per spelling (bounded by the model's
|
|
668
|
+
# distinct rule names).
|
|
669
|
+
parts = NAMESPACED_NAME_PARTS[rule_name]
|
|
670
|
+
unless parts
|
|
671
|
+
last_colon_index = rule_name.rindex(":")
|
|
672
|
+
parts = [rule_name[(last_colon_index + 1)..], rule_name[0...last_colon_index]]
|
|
673
|
+
NAMESPACED_NAME_PARTS[rule_name] = parts
|
|
674
|
+
end
|
|
675
|
+
local_name, rule_uri = parts
|
|
676
|
+
|
|
677
|
+
candidates = root_index&.[](local_name)
|
|
678
|
+
matched_attr = if candidates
|
|
679
|
+
candidates.find do |attr|
|
|
680
|
+
local_name_match?(attr, local_name, rule_uri,
|
|
681
|
+
flexible_local)
|
|
682
|
+
end
|
|
683
|
+
else
|
|
684
|
+
doc.root.attributes.each_value.find do |attr|
|
|
685
|
+
local_name_match?(attr, local_name, rule_uri,
|
|
686
|
+
flexible_local)
|
|
687
|
+
end
|
|
688
|
+
end
|
|
689
|
+
matched_attr&.value
|
|
690
|
+
end
|
|
691
|
+
|
|
630
692
|
# Namespace discipline of the lenient local-name fallback
|
|
631
693
|
# (#744/#758/#790): sole claimants bind any qualification; shared
|
|
632
694
|
# names must resolve to the rule's namespace.
|
|
@@ -648,6 +710,68 @@ _effective_register)
|
|
|
648
710
|
(colon = attr.name.rindex(":")) ? attr.name[(colon + 1)..] : attr.name
|
|
649
711
|
end
|
|
650
712
|
|
|
713
|
+
# lutaml-model#154: decode HTML entities for mappings that opted in
|
|
714
|
+
# (`html_entities` in the xml block). Values stay untouched
|
|
715
|
+
# otherwise — XML keeps undefined entities literal by design.
|
|
716
|
+
def decode_html_entities_value(value, enabled)
|
|
717
|
+
return value if value.nil? || !value.is_a?(String)
|
|
718
|
+
return value unless enabled
|
|
719
|
+
return value if Lutaml::Model.opal?
|
|
720
|
+
|
|
721
|
+
Lutaml::Xml::HtmlEntities.decode(value)
|
|
722
|
+
end
|
|
723
|
+
|
|
724
|
+
Record = Struct.new(:attr, :derived, :group_skip, :valid)
|
|
725
|
+
|
|
726
|
+
def compiled_rule_records(model_class, register, grouped_plain_rules)
|
|
727
|
+
return {}.compare_by_identity unless model_class.include?(Lutaml::Model::Serialize)
|
|
728
|
+
|
|
729
|
+
mapping = model_class.mappings_for(:xml, register)
|
|
730
|
+
version = mapping&.rule_records_version.to_i
|
|
731
|
+
per_class = RULE_RECORDS[model_class.object_id] # rubocop:disable Lint/HashCompareByIdentity
|
|
732
|
+
unless per_class
|
|
733
|
+
per_class = Lutaml::Model.opal? ? {} : Concurrent::Map.new
|
|
734
|
+
RULE_RECORDS[model_class.object_id] = per_class # rubocop:disable Lint/HashCompareByIdentity
|
|
735
|
+
end
|
|
736
|
+
cached = per_class[register]
|
|
737
|
+
return cached.records if cached && cached.version == version
|
|
738
|
+
|
|
739
|
+
records = {}.compare_by_identity
|
|
740
|
+
mapping&.mappings(register)&.each do |rule| # rubocop:disable Style/SafeNavigationChain,Style/SafeNavigation
|
|
741
|
+
attr = attribute_for_rule_static(model_class, rule, register)
|
|
742
|
+
group = grouped_plain_rules[rule.to]
|
|
743
|
+
records[rule] = Record.new(
|
|
744
|
+
attr,
|
|
745
|
+
attr&.derived? || false,
|
|
746
|
+
!!(group && group.size > 1 && attr&.collection? &&
|
|
747
|
+
!group.first.equal?(rule)),
|
|
748
|
+
!!(attr || rule.custom_methods[:from]),
|
|
749
|
+
)
|
|
750
|
+
end
|
|
751
|
+
entry = Struct.new(:version, :records).new(version, records)
|
|
752
|
+
per_class[register] = entry
|
|
753
|
+
records
|
|
754
|
+
end
|
|
755
|
+
|
|
756
|
+
def attribute_for_rule_static(model_class, rule, register)
|
|
757
|
+
if rule.delegate
|
|
758
|
+
delegate_attr = model_class.attributes(register)[rule.delegate]
|
|
759
|
+
delegate_type = delegate_attr&.type(register)
|
|
760
|
+
delegate_type&.attributes(register)&.[](rule.to) # rubocop:disable Style/SafeNavigationChain
|
|
761
|
+
else
|
|
762
|
+
model_class.attributes(register)[rule.to]
|
|
763
|
+
end
|
|
764
|
+
rescue StandardError
|
|
765
|
+
nil
|
|
766
|
+
end
|
|
767
|
+
|
|
768
|
+
def decode_html_entities_for(instance, register)
|
|
769
|
+
klass = instance.class
|
|
770
|
+
return false unless klass.is_a?(Class) && klass.include?(Lutaml::Model::Serialize)
|
|
771
|
+
|
|
772
|
+
klass.mappings_for(:xml, register)&.decode_html_entities?
|
|
773
|
+
end
|
|
774
|
+
|
|
651
775
|
def value_for_rule(session, rule, options, cached_attr = nil,
|
|
652
776
|
extra_rule_names = nil)
|
|
653
777
|
doc = session.doc
|
|
@@ -682,12 +806,16 @@ _effective_register)
|
|
|
682
806
|
rule_names = rule_names.concat(extra_rule_names).uniq if extra_rule_names
|
|
683
807
|
|
|
684
808
|
if rule.attribute?
|
|
685
|
-
|
|
809
|
+
value = value_for_xml_attribute(
|
|
686
810
|
doc, rule, rule_names,
|
|
687
811
|
flexible_local: attribute_rule_sole_local_claimant?(
|
|
688
812
|
instance, rule, effective_register
|
|
689
|
-
)
|
|
813
|
+
),
|
|
814
|
+
session: session
|
|
690
815
|
)
|
|
816
|
+
return decode_html_entities_value(value, decode_html_entities_for(
|
|
817
|
+
instance, effective_register
|
|
818
|
+
))
|
|
691
819
|
end
|
|
692
820
|
|
|
693
821
|
# Performance: Pre-compute type-related values used in the hot loop
|
|
@@ -24,18 +24,31 @@ module Lutaml
|
|
|
24
24
|
@instance_is_serialize ||= instance.is_a?(::Lutaml::Model::Serialize)
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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|
|
|
37
50
|
local_names(attr).each do |key|
|
|
38
|
-
entries = index[key]
|
|
51
|
+
entries = (index[key] ||= [])
|
|
39
52
|
entries << attr unless entries.include?(attr)
|
|
40
53
|
end
|
|
41
54
|
end
|
|
@@ -227,13 +227,7 @@ register_id)
|
|
|
227
227
|
# @param rule [CompiledRule] The rule
|
|
228
228
|
# @return [Boolean] true if custom method only
|
|
229
229
|
def custom_method_only?(rule)
|
|
230
|
-
|
|
231
|
-
return true if rule.attribute_name.to_s.start_with?("__") &&
|
|
232
|
-
rule.attribute_name.to_s.end_with?("__")
|
|
233
|
-
|
|
234
|
-
# Also check if rule has custom methods but attribute_type is nil
|
|
235
|
-
# This handles cases where we inferred an attribute name for custom methods
|
|
236
|
-
rule.has_custom_methods? && rule.attribute_type.nil?
|
|
230
|
+
rule.custom_method_only?
|
|
237
231
|
end
|
|
238
232
|
|
|
239
233
|
# Extract value for a rule
|
data/lib/lutaml/yaml/format.rb
CHANGED
|
@@ -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,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
# lutaml-model#436: multi-document YAML streams with per-document model
|
|
6
|
+
# dispatch (the glossarist-ruby#132 / ISO 5843-6 shape).
|
|
7
|
+
RSpec.describe "YAML streams" do
|
|
8
|
+
let(:concept_class) do
|
|
9
|
+
Class.new(Lutaml::Model::Serializable) do
|
|
10
|
+
attribute :id, :string
|
|
11
|
+
attribute :language_code, :string
|
|
12
|
+
|
|
13
|
+
key_value do
|
|
14
|
+
map "id", to: :id
|
|
15
|
+
map "language_code", to: :language_code
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
let(:localized_class) do
|
|
21
|
+
Class.new(Lutaml::Model::Serializable) do
|
|
22
|
+
attribute :id, :string
|
|
23
|
+
attribute :status, :string
|
|
24
|
+
|
|
25
|
+
key_value do
|
|
26
|
+
map "id", to: :id
|
|
27
|
+
map "status", to: :status
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
let(:stream) do
|
|
33
|
+
<<~YAML
|
|
34
|
+
---
|
|
35
|
+
id: 1-EN
|
|
36
|
+
language_code: eng
|
|
37
|
+
---
|
|
38
|
+
id: 3a75
|
|
39
|
+
status: accepted
|
|
40
|
+
---
|
|
41
|
+
id: 1-FR
|
|
42
|
+
language_code: fra
|
|
43
|
+
YAML
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
it "parses every document in the stream" do
|
|
47
|
+
models = concept_class.from_yaml_stream(stream)
|
|
48
|
+
expect(models.map(&:id)).to eq(%w[1-EN 3a75 1-FR])
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
it "dispatches documents polymorphically via discriminator" do
|
|
52
|
+
models = concept_class.from_yaml_stream(
|
|
53
|
+
stream,
|
|
54
|
+
discriminator: lambda { |doc|
|
|
55
|
+
doc.key?("language_code") ? concept_class : localized_class
|
|
56
|
+
},
|
|
57
|
+
)
|
|
58
|
+
expect(models.map(&:class)).to eq([concept_class, localized_class,
|
|
59
|
+
concept_class])
|
|
60
|
+
expect(models[1].status).to eq("accepted")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
it "round-trips through to_yaml_stream" do
|
|
64
|
+
models = concept_class.from_yaml_stream(stream)
|
|
65
|
+
out = concept_class.to_yaml_stream(models)
|
|
66
|
+
reparsed = concept_class.from_yaml_stream(out)
|
|
67
|
+
expect(reparsed.map(&:id)).to eq(models.map(&:id))
|
|
68
|
+
expect(out).to include("---")
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
# lutaml-model#154: HTML named/numeric entities decode only for
|
|
6
|
+
# mappings that opt in via `html_entities` — XML otherwise keeps
|
|
7
|
+
# undefined entities literal.
|
|
8
|
+
RSpec.describe "Opt-in HTML entity decoding" do
|
|
9
|
+
context "when the mapping opts in" do
|
|
10
|
+
let(:model_class) do
|
|
11
|
+
Class.new(Lutaml::Model::Serializable) do
|
|
12
|
+
attribute :t, :string
|
|
13
|
+
|
|
14
|
+
xml do
|
|
15
|
+
element "doc"
|
|
16
|
+
map_element "t", to: :t
|
|
17
|
+
html_entities
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
it "decodes named and numeric entities in element text" do
|
|
23
|
+
parsed = model_class.from_xml("<doc><t>a & b — c © d</t></doc>")
|
|
24
|
+
expect(parsed.t).to eq("a & b — c © d")
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
context "when the mapping does not opt in" do
|
|
29
|
+
let(:model_class) do
|
|
30
|
+
Class.new(Lutaml::Model::Serializable) do
|
|
31
|
+
attribute :t, :string
|
|
32
|
+
|
|
33
|
+
xml do
|
|
34
|
+
element "doc"
|
|
35
|
+
map_element "t", to: :t
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it "keeps undefined entities literal" do
|
|
41
|
+
parsed = model_class.from_xml("<doc><t>a & b © d</t></doc>")
|
|
42
|
+
expect(parsed.t).to eq("a & b © d")
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: lutaml-model
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.8.
|
|
4
|
+
version: 0.8.39
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ribose Inc.
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-09-
|
|
11
|
+
date: 2026-09-19 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: base64
|
|
@@ -202,6 +202,7 @@ files:
|
|
|
202
202
|
- docs/_guides/character-encoding.adoc
|
|
203
203
|
- docs/_guides/collection-serialization.adoc
|
|
204
204
|
- docs/_guides/creating-xsd.adoc
|
|
205
|
+
- docs/_guides/custom-methods.adoc
|
|
205
206
|
- docs/_guides/document-validation.adoc
|
|
206
207
|
- docs/_guides/index.adoc
|
|
207
208
|
- docs/_guides/jsonld-serialization.adoc
|
|
@@ -776,6 +777,7 @@ files:
|
|
|
776
777
|
- lib/lutaml/xml/format.rb
|
|
777
778
|
- lib/lutaml/xml/format_chooser.rb
|
|
778
779
|
- lib/lutaml/xml/hoisting_algorithm.rb
|
|
780
|
+
- lib/lutaml/xml/html_entities.rb
|
|
779
781
|
- lib/lutaml/xml/leptris/element.rb
|
|
780
782
|
- lib/lutaml/xml/listener.rb
|
|
781
783
|
- lib/lutaml/xml/mapping.rb
|
|
@@ -1716,6 +1718,7 @@ files:
|
|
|
1716
1718
|
- spec/lutaml/model/custom_bibtex_adapter_spec.rb
|
|
1717
1719
|
- spec/lutaml/model/custom_collection_spec.rb
|
|
1718
1720
|
- spec/lutaml/model/custom_from_empty_values_spec.rb
|
|
1721
|
+
- spec/lutaml/model/custom_method_context_spec.rb
|
|
1719
1722
|
- spec/lutaml/model/custom_model_spec.rb
|
|
1720
1723
|
- spec/lutaml/model/custom_serialization_spec.rb
|
|
1721
1724
|
- spec/lutaml/model/custom_vobject_adapter_spec.rb
|
|
@@ -1859,6 +1862,7 @@ files:
|
|
|
1859
1862
|
- spec/lutaml/model/xsd_type_spec.rb
|
|
1860
1863
|
- spec/lutaml/model/xsd_type_validation_spec.rb
|
|
1861
1864
|
- spec/lutaml/model/yaml_adapter_spec.rb
|
|
1865
|
+
- spec/lutaml/model/yaml_stream_spec.rb
|
|
1862
1866
|
- spec/lutaml/model/yamls/standard_adapter_spec.rb
|
|
1863
1867
|
- spec/lutaml/model/yamls_range_spec.rb
|
|
1864
1868
|
- spec/lutaml/model/yamls_sequence_spec.rb
|
|
@@ -1913,6 +1917,7 @@ files:
|
|
|
1913
1917
|
- spec/lutaml/xml/format_chooser_spec.rb
|
|
1914
1918
|
- spec/lutaml/xml/fpi_namespace_spec.rb
|
|
1915
1919
|
- spec/lutaml/xml/general_namespace_spec.rb
|
|
1920
|
+
- spec/lutaml/xml/html_entities_spec.rb
|
|
1916
1921
|
- spec/lutaml/xml/indent_spec.rb
|
|
1917
1922
|
- spec/lutaml/xml/line_ending_spec.rb
|
|
1918
1923
|
- spec/lutaml/xml/mapping_finalization_guard_spec.rb
|