lutaml-model 0.8.37 → 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/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/attribute.rb +5 -1
- data/lib/lutaml/model/format_registry.rb +3 -1
- data/lib/lutaml/model/mapping/mapping_rule.rb +39 -11
- data/lib/lutaml/model/serialize/attribute_definition.rb +140 -65
- data/lib/lutaml/model/serialize/deserialization_context.rb +1 -0
- data/lib/lutaml/model/serialize.rb +54 -15
- data/lib/lutaml/model/services/transformer.rb +30 -11
- 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 +174 -53
- data/lib/lutaml/xml/parse_session.rb +24 -11
- 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 +5 -1
|
@@ -12,6 +12,16 @@ 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
|
+
|
|
15
25
|
# Namespaced rule name -> [local_name, rule_uri]. Pure string
|
|
16
26
|
# splitting, deterministic per spelling, shared across parses.
|
|
17
27
|
# Concurrent::Map under threaded MRI, plain Hash under Opal.
|
|
@@ -36,7 +46,19 @@ module Lutaml
|
|
|
36
46
|
|
|
37
47
|
instance_is_serialize = model_class.include?(::Lutaml::Model::Serialize)
|
|
38
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
|
+
|
|
39
60
|
instance = model_class.allocate_for_deserialization(child_register)
|
|
61
|
+
memo[memo_key] = instance
|
|
40
62
|
else
|
|
41
63
|
instance = model_class.new
|
|
42
64
|
register_accessor_methods_for(instance, child_register)
|
|
@@ -281,24 +303,33 @@ module Lutaml
|
|
|
281
303
|
effective_register,
|
|
282
304
|
)
|
|
283
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
|
|
284
311
|
mappings.each do |rule|
|
|
285
|
-
|
|
286
|
-
rule.name
|
|
312
|
+
record = records&.[](rule)
|
|
287
313
|
rule_to = rule.to
|
|
288
|
-
rule_namespace_set = rule.namespace_set?
|
|
289
|
-
rule_namespace_set ? rule.namespace_param : nil
|
|
290
314
|
|
|
291
|
-
|
|
292
|
-
|
|
315
|
+
if record
|
|
316
|
+
attr = record.attr
|
|
317
|
+
next if record.derived
|
|
318
|
+
next if record.group_skip
|
|
293
319
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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?
|
|
298
324
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
|
302
333
|
|
|
303
334
|
# Performance: Use pre-computed frozen Hash for static namespace overrides.
|
|
304
335
|
# Falls back to per-element hash only for the dynamic namespace_uri case.
|
|
@@ -371,6 +402,9 @@ module Lutaml
|
|
|
371
402
|
value = apply_value_map(value, from_map, attr)
|
|
372
403
|
value = normalize_xml_value(value, rule, attr, new_opts,
|
|
373
404
|
effective_register)
|
|
405
|
+
value = decode_html_entities_value(value, decode_html_entities_for(
|
|
406
|
+
instance, effective_register
|
|
407
|
+
))
|
|
374
408
|
value = rule.transform_value(attr, value, :from, :xml)
|
|
375
409
|
# An over-count is normally left for `.validate` to report. A mapped
|
|
376
410
|
# PORO has no `.validate`, so deferring there would discard the
|
|
@@ -379,7 +413,8 @@ module Lutaml
|
|
|
379
413
|
!rule.content_mapping?
|
|
380
414
|
attr.valid_collection!(value, context)
|
|
381
415
|
end
|
|
382
|
-
rule.deserialize(instance, value, attributes, context
|
|
416
|
+
rule.deserialize(instance, value, attributes, context,
|
|
417
|
+
options[:context])
|
|
383
418
|
|
|
384
419
|
instance.value_set_for(rule_to)
|
|
385
420
|
end
|
|
@@ -512,11 +547,13 @@ _effective_register)
|
|
|
512
547
|
# its result array entirely on that path.
|
|
513
548
|
if rule_names.size == 1
|
|
514
549
|
rn = rule_names[0]
|
|
515
|
-
converted = convert_rule_name_to_attribute_name(doc, rn
|
|
550
|
+
converted = convert_rule_name_to_attribute_name(doc, rn,
|
|
551
|
+
session: session)
|
|
516
552
|
value = doc.root.find_attribute_value(converted || rn)
|
|
517
553
|
else
|
|
518
554
|
attribute_names = rule_names.map do |rn|
|
|
519
|
-
converted = convert_rule_name_to_attribute_name(doc, rn
|
|
555
|
+
converted = convert_rule_name_to_attribute_name(doc, rn,
|
|
556
|
+
session: session)
|
|
520
557
|
converted || rn
|
|
521
558
|
end
|
|
522
559
|
|
|
@@ -545,7 +582,7 @@ _effective_register)
|
|
|
545
582
|
# @param doc [XmlElement] the parsed XML document root
|
|
546
583
|
# @param rule_name [String] the rule name (e.g., "urn:...:ext", "http://.../ns:val", "my-ns:val")
|
|
547
584
|
# @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)
|
|
585
|
+
def convert_rule_name_to_attribute_name(doc, rule_name, session: nil)
|
|
549
586
|
return nil unless rule_name.include?(":")
|
|
550
587
|
|
|
551
588
|
# URI-vs-prefix detection works on the whole rule name: the
|
|
@@ -564,13 +601,27 @@ _effective_register)
|
|
|
564
601
|
# match, and unprefixed_name is not called per attribute
|
|
565
602
|
# (it splits prefixed names and allocates).
|
|
566
603
|
local_name = rule_name[(last_colon_index + 1)..]
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
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] == ":"
|
|
571
620
|
|
|
572
|
-
|
|
573
|
-
|
|
621
|
+
attr.unprefixed_name == local_name
|
|
622
|
+
end
|
|
623
|
+
end
|
|
624
|
+
matched&.name
|
|
574
625
|
else
|
|
575
626
|
# Simple prefix format: look up the actual prefix from document's namespace declarations
|
|
576
627
|
# The namespace_part is the namespace URI declared in the document (e.g., "my-ns").
|
|
@@ -594,39 +645,46 @@ _effective_register)
|
|
|
594
645
|
# @return [String, nil] the attribute value or nil
|
|
595
646
|
def find_attribute_by_local_name(doc, rule_names,
|
|
596
647
|
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
|
|
648
|
+
root_index = session&.element_local_attribute_index(doc.root)
|
|
612
649
|
|
|
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
|
|
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
|
|
626
657
|
end
|
|
627
658
|
nil
|
|
628
659
|
end
|
|
629
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
|
|
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
|
+
|
|
630
688
|
# Namespace discipline of the lenient local-name fallback
|
|
631
689
|
# (#744/#758/#790): sole claimants bind any qualification; shared
|
|
632
690
|
# names must resolve to the rule's namespace.
|
|
@@ -648,6 +706,65 @@ _effective_register)
|
|
|
648
706
|
(colon = attr.name.rindex(":")) ? attr.name[(colon + 1)..] : attr.name
|
|
649
707
|
end
|
|
650
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
|
|
758
|
+
nil
|
|
759
|
+
end
|
|
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
|
+
|
|
651
768
|
def value_for_rule(session, rule, options, cached_attr = nil,
|
|
652
769
|
extra_rule_names = nil)
|
|
653
770
|
doc = session.doc
|
|
@@ -682,12 +799,16 @@ _effective_register)
|
|
|
682
799
|
rule_names = rule_names.concat(extra_rule_names).uniq if extra_rule_names
|
|
683
800
|
|
|
684
801
|
if rule.attribute?
|
|
685
|
-
|
|
802
|
+
value = value_for_xml_attribute(
|
|
686
803
|
doc, rule, rule_names,
|
|
687
804
|
flexible_local: attribute_rule_sole_local_claimant?(
|
|
688
805
|
instance, rule, effective_register
|
|
689
|
-
)
|
|
806
|
+
),
|
|
807
|
+
session: session
|
|
690
808
|
)
|
|
809
|
+
return decode_html_entities_value(value, decode_html_entities_for(
|
|
810
|
+
instance, effective_register
|
|
811
|
+
))
|
|
691
812
|
end
|
|
692
813
|
|
|
693
814
|
# 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
|
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,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.
|
|
4
|
+
version: 0.8.38
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ribose Inc.
|
|
@@ -776,6 +776,7 @@ files:
|
|
|
776
776
|
- lib/lutaml/xml/format.rb
|
|
777
777
|
- lib/lutaml/xml/format_chooser.rb
|
|
778
778
|
- lib/lutaml/xml/hoisting_algorithm.rb
|
|
779
|
+
- lib/lutaml/xml/html_entities.rb
|
|
779
780
|
- lib/lutaml/xml/leptris/element.rb
|
|
780
781
|
- lib/lutaml/xml/listener.rb
|
|
781
782
|
- lib/lutaml/xml/mapping.rb
|
|
@@ -1716,6 +1717,7 @@ files:
|
|
|
1716
1717
|
- spec/lutaml/model/custom_bibtex_adapter_spec.rb
|
|
1717
1718
|
- spec/lutaml/model/custom_collection_spec.rb
|
|
1718
1719
|
- spec/lutaml/model/custom_from_empty_values_spec.rb
|
|
1720
|
+
- spec/lutaml/model/custom_method_context_spec.rb
|
|
1719
1721
|
- spec/lutaml/model/custom_model_spec.rb
|
|
1720
1722
|
- spec/lutaml/model/custom_serialization_spec.rb
|
|
1721
1723
|
- spec/lutaml/model/custom_vobject_adapter_spec.rb
|
|
@@ -1859,6 +1861,7 @@ files:
|
|
|
1859
1861
|
- spec/lutaml/model/xsd_type_spec.rb
|
|
1860
1862
|
- spec/lutaml/model/xsd_type_validation_spec.rb
|
|
1861
1863
|
- spec/lutaml/model/yaml_adapter_spec.rb
|
|
1864
|
+
- spec/lutaml/model/yaml_stream_spec.rb
|
|
1862
1865
|
- spec/lutaml/model/yamls/standard_adapter_spec.rb
|
|
1863
1866
|
- spec/lutaml/model/yamls_range_spec.rb
|
|
1864
1867
|
- spec/lutaml/model/yamls_sequence_spec.rb
|
|
@@ -1913,6 +1916,7 @@ files:
|
|
|
1913
1916
|
- spec/lutaml/xml/format_chooser_spec.rb
|
|
1914
1917
|
- spec/lutaml/xml/fpi_namespace_spec.rb
|
|
1915
1918
|
- spec/lutaml/xml/general_namespace_spec.rb
|
|
1919
|
+
- spec/lutaml/xml/html_entities_spec.rb
|
|
1916
1920
|
- spec/lutaml/xml/indent_spec.rb
|
|
1917
1921
|
- spec/lutaml/xml/line_ending_spec.rb
|
|
1918
1922
|
- spec/lutaml/xml/mapping_finalization_guard_spec.rb
|