lutaml-model 0.8.41 → 0.8.43
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/docs/_guides/advanced-mapping.adoc +32 -0
- data/lib/lutaml/key_value/mapping.rb +3 -1
- data/lib/lutaml/key_value/mapping_rule.rb +4 -1
- data/lib/lutaml/key_value/transform.rb +2 -0
- data/lib/lutaml/key_value/transformation/rule_compiler.rb +3 -0
- data/lib/lutaml/model/attribute.rb +71 -32
- data/lib/lutaml/model/collection_handler.rb +14 -2
- data/lib/lutaml/model/global_context.rb +25 -1
- data/lib/lutaml/model/mapping/mapping_rule.rb +86 -20
- data/lib/lutaml/model/register.rb +7 -1
- data/lib/lutaml/model/serialize/initialization.rb +17 -3
- data/lib/lutaml/model/serialize.rb +12 -3
- data/lib/lutaml/model/type/string.rb +1 -1
- data/lib/lutaml/model/version.rb +1 -1
- data/lib/lutaml/xml/mapping.rb +20 -1
- data/lib/lutaml/xml/mapping_rule.rb +13 -1
- data/lib/lutaml/xml/model_transform.rb +90 -14
- data/lib/lutaml/xml/parse_session.rb +11 -2
- data/lib/lutaml/xml/transformation/element_builder.rb +25 -6
- data/lib/lutaml/xml/transformation/rule_compiler.rb +2 -0
- data/spec/lutaml/key_value/transformation/rule_compiler_spec.rb +16 -6
- data/spec/lutaml/key_value/transformation_spec.rb +4 -1
- data/spec/lutaml/model/attributes_memo_clear_race_spec.rb +90 -0
- data/spec/lutaml/model/hydrate_only_mapping_spec.rb +47 -0
- data/spec/lutaml/model/parse_dispatch_hoist_spec.rb +173 -0
- data/spec/lutaml/xml/when_attribute_spec.rb +133 -0
- metadata +6 -2
data/lib/lutaml/xml/mapping.rb
CHANGED
|
@@ -527,6 +527,7 @@ module Lutaml
|
|
|
527
527
|
nil),
|
|
528
528
|
transform: {},
|
|
529
529
|
value_map: {},
|
|
530
|
+
when_attribute: {},
|
|
530
531
|
form: nil,
|
|
531
532
|
documentation: nil,
|
|
532
533
|
xsd_type: (xsd_type_provided = false
|
|
@@ -537,6 +538,8 @@ module Lutaml
|
|
|
537
538
|
name, to, with, render_nil, render_empty, type: TYPES[:element]
|
|
538
539
|
)
|
|
539
540
|
|
|
541
|
+
validate_when_attribute!(when_attribute) unless when_attribute.empty?
|
|
542
|
+
|
|
540
543
|
# Warn if prefix parameter is provided
|
|
541
544
|
if prefix_provided != false
|
|
542
545
|
warn "[DEPRECATED] The prefix parameter on map_element is deprecated. " \
|
|
@@ -584,6 +587,7 @@ module Lutaml
|
|
|
584
587
|
form: form,
|
|
585
588
|
documentation: documentation,
|
|
586
589
|
raw: raw,
|
|
590
|
+
when_attribute: when_attribute,
|
|
587
591
|
)
|
|
588
592
|
# Store rules with the same element name in an array to support
|
|
589
593
|
# multiple mapping rules for the same element name with different target types
|
|
@@ -607,6 +611,20 @@ module Lutaml
|
|
|
607
611
|
end
|
|
608
612
|
end
|
|
609
613
|
|
|
614
|
+
# lutaml-model#88: attribute-value discriminator. Keys are wire
|
|
615
|
+
# attribute names, values the expected string value.
|
|
616
|
+
def validate_when_attribute!(when_attribute)
|
|
617
|
+
when_attribute.each do |k, v|
|
|
618
|
+
next if (k.is_a?(::String) || k.is_a?(::Symbol)) &&
|
|
619
|
+
(v.is_a?(::String) || v.is_a?(::Symbol))
|
|
620
|
+
|
|
621
|
+
raise Lutaml::Model::IncorrectMappingArgumentsError,
|
|
622
|
+
"when_attribute expects string/symbol attribute names " \
|
|
623
|
+
"mapped to string/symbol values, got " \
|
|
624
|
+
"#{k.inspect} => #{v.inspect}"
|
|
625
|
+
end
|
|
626
|
+
end
|
|
627
|
+
|
|
610
628
|
def map_attribute(
|
|
611
629
|
name,
|
|
612
630
|
to: nil,
|
|
@@ -1150,7 +1168,8 @@ module Lutaml
|
|
|
1150
1168
|
grouped = {}
|
|
1151
1169
|
mappings(reg_key).each do |r|
|
|
1152
1170
|
next if r.attribute? || r.raw_mapping? || r.content_mapping? ||
|
|
1153
|
-
r.cdata || r.
|
|
1171
|
+
r.cdata || r.when_attribute? ||
|
|
1172
|
+
r.has_custom_method_for_deserialization? ||
|
|
1154
1173
|
(r.transform.is_a?(Hash) && !r.transform.empty?) ||
|
|
1155
1174
|
r.transform.is_a?(Class)
|
|
1156
1175
|
|
|
@@ -16,7 +16,7 @@ module Lutaml
|
|
|
16
16
|
# Writers for deep_dup (preserves exact object references)
|
|
17
17
|
attr_accessor :namespace, :prefix, :namespace_class
|
|
18
18
|
|
|
19
|
-
def initialize(
|
|
19
|
+
def initialize( # rubocop:disable Metrics/ParameterLists -- mirrors the DSL surface
|
|
20
20
|
name,
|
|
21
21
|
to:,
|
|
22
22
|
render_nil: false,
|
|
@@ -37,6 +37,7 @@ module Lutaml
|
|
|
37
37
|
polymorphic_map: {},
|
|
38
38
|
transform: {},
|
|
39
39
|
value_map: {},
|
|
40
|
+
when_attribute: {},
|
|
40
41
|
as_list: nil,
|
|
41
42
|
delimiter: nil,
|
|
42
43
|
form: nil,
|
|
@@ -59,6 +60,7 @@ module Lutaml
|
|
|
59
60
|
polymorphic_map: polymorphic_map,
|
|
60
61
|
transform: transform,
|
|
61
62
|
value_map: value_map,
|
|
63
|
+
when_attribute: when_attribute,
|
|
62
64
|
)
|
|
63
65
|
|
|
64
66
|
# Store original namespace parameter to preserve :inherit symbol
|
|
@@ -225,6 +227,15 @@ module Lutaml
|
|
|
225
227
|
end
|
|
226
228
|
end
|
|
227
229
|
|
|
230
|
+
# Whether a parsed element satisfies this rule's when_attribute
|
|
231
|
+
# discriminator: every expected attribute value matches.
|
|
232
|
+
def matches_when_attribute?(element)
|
|
233
|
+
when_attribute.all? do |name, expected|
|
|
234
|
+
actual = element.find_attribute_value(name.to_s)
|
|
235
|
+
!actual.nil? && actual.to_s == expected.to_s
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
228
239
|
def deep_dup
|
|
229
240
|
# Preserve @namespace_param exactly as it was (string, Class, :inherit, or nil)
|
|
230
241
|
# This ensures the duplicate has the same internal state as the original
|
|
@@ -261,6 +272,7 @@ module Lutaml
|
|
|
261
272
|
form: @form,
|
|
262
273
|
documentation: @documentation,
|
|
263
274
|
raw: @raw,
|
|
275
|
+
when_attribute: when_attribute,
|
|
264
276
|
).tap do |dup_rule|
|
|
265
277
|
# Manually preserve the exact @namespace_class object to avoid
|
|
266
278
|
# recreating anonymous classes (which would have different object_ids)
|
|
@@ -40,6 +40,10 @@ module Lutaml
|
|
|
40
40
|
# Performance: Frozen empty hash to reduce allocations
|
|
41
41
|
EMPTY_HASH = {}.freeze
|
|
42
42
|
|
|
43
|
+
# ":".getbyte(0) — getbyte comparisons avoid the per-candidate
|
|
44
|
+
# string slice in namespace-prefix checks on the lenient paths.
|
|
45
|
+
COLON_BYTE = 58
|
|
46
|
+
|
|
43
47
|
def data_to_model(data, _format, options = {})
|
|
44
48
|
# Use child's own default register if it has one
|
|
45
49
|
# This ensures versioned schemas (e.g., MML v2 with lutaml_default_register = :mml_v2)
|
|
@@ -315,6 +319,13 @@ module Lutaml
|
|
|
315
319
|
compiled_rule_records(instance.class, effective_register,
|
|
316
320
|
grouped_plain_rules)
|
|
317
321
|
end
|
|
322
|
+
# Loop invariants: the entity-decode flag is a per-(class, register)
|
|
323
|
+
# fact and the attributes hash is memoized per register — both were
|
|
324
|
+
# re-asked per rule application (942k + 751k calls per ISO-13849
|
|
325
|
+
# parse).
|
|
326
|
+
decode_entities_flag = decode_html_entities_for(instance,
|
|
327
|
+
effective_register)
|
|
328
|
+
deserialize_attributes = attributes
|
|
318
329
|
mappings.each do |rule|
|
|
319
330
|
record = records&.[](rule)
|
|
320
331
|
rule_to = rule.to
|
|
@@ -376,6 +387,9 @@ module Lutaml
|
|
|
376
387
|
default_namespace)
|
|
377
388
|
# Pre-match: no child element matches this rule.
|
|
378
389
|
# Skip expensive value_for_rule, handle defaults inline.
|
|
390
|
+
# NOTE: do not "read the instance" here (to_value_for) —
|
|
391
|
+
# the compiled getters materialize lazy collections on
|
|
392
|
+
# first read, which tripled parse allocations when tried.
|
|
379
393
|
if instance.using_default?(rule_to) || rule.render_default
|
|
380
394
|
defaults_used << rule_to
|
|
381
395
|
attr&.default(effective_register) || rule.to_value_for(instance)
|
|
@@ -409,9 +423,7 @@ module Lutaml
|
|
|
409
423
|
value = apply_value_map(value, from_map, attr)
|
|
410
424
|
value = normalize_xml_value(value, rule, attr, new_opts,
|
|
411
425
|
effective_register)
|
|
412
|
-
value = decode_html_entities_value(value,
|
|
413
|
-
instance, effective_register
|
|
414
|
-
))
|
|
426
|
+
value = decode_html_entities_value(value, decode_entities_flag)
|
|
415
427
|
value = rule.transform_value(attr, value, :from, :xml)
|
|
416
428
|
# An over-count is normally left for `.validate` to report. A mapped
|
|
417
429
|
# PORO has no `.validate`, so deferring there would discard the
|
|
@@ -420,7 +432,7 @@ module Lutaml
|
|
|
420
432
|
!rule.content_mapping?
|
|
421
433
|
attr.valid_collection!(value, context)
|
|
422
434
|
end
|
|
423
|
-
rule.deserialize(instance, value,
|
|
435
|
+
rule.deserialize(instance, value, deserialize_attributes, context,
|
|
424
436
|
options[:context])
|
|
425
437
|
|
|
426
438
|
instance.value_set_for(rule_to)
|
|
@@ -592,6 +604,21 @@ _effective_register)
|
|
|
592
604
|
def convert_rule_name_to_attribute_name(doc, rule_name, session: nil)
|
|
593
605
|
return nil unless rule_name.include?(":")
|
|
594
606
|
|
|
607
|
+
# Pure function of (element attributes, spelling) — the same
|
|
608
|
+
# conversions repeat per rule application (~450k find/slice
|
|
609
|
+
# allocations per ISO-13849 parse before this memo).
|
|
610
|
+
conversions = session&.rule_name_resolution(doc.root)
|
|
611
|
+
if conversions
|
|
612
|
+
return conversions[rule_name] if conversions.key?(rule_name)
|
|
613
|
+
|
|
614
|
+
converted = convert_rule_name_uncached(doc, rule_name, session)
|
|
615
|
+
conversions[rule_name] = converted
|
|
616
|
+
else
|
|
617
|
+
convert_rule_name_uncached(doc, rule_name, session)
|
|
618
|
+
end
|
|
619
|
+
end
|
|
620
|
+
|
|
621
|
+
def convert_rule_name_uncached(doc, rule_name, session)
|
|
595
622
|
# URI-vs-prefix detection works on the whole rule name: the
|
|
596
623
|
# namespace part is a prefix of rule_name, so "://" anywhere in
|
|
597
624
|
# the name means a URI-form namespace, and a "urn:" namespace
|
|
@@ -612,10 +639,13 @@ _effective_register)
|
|
|
612
639
|
candidates = session.element_local_attribute_index(doc.root)[local_name]
|
|
613
640
|
# No candidate carries this local name: nothing in the
|
|
614
641
|
# element can match, the scan is skipped entirely.
|
|
642
|
+
# getbyte, not a [i] slice — the slice allocated one
|
|
643
|
+
# string per probed candidate (~380k per ISO-13849 parse).
|
|
615
644
|
matched = candidates&.find do |attr|
|
|
616
645
|
ns = attr.namespace
|
|
617
646
|
next false if ns.nil? || ns.length > last_colon_index
|
|
618
|
-
next false unless rule_name.start_with?(ns) &&
|
|
647
|
+
next false unless rule_name.start_with?(ns) &&
|
|
648
|
+
rule_name.getbyte(ns.length) == COLON_BYTE
|
|
619
649
|
|
|
620
650
|
attr.unprefixed_name == local_name
|
|
621
651
|
end
|
|
@@ -623,7 +653,8 @@ _effective_register)
|
|
|
623
653
|
matched = doc.root.attributes.each_value.find do |attr|
|
|
624
654
|
ns = attr.namespace
|
|
625
655
|
next false if ns.nil? || ns.length > last_colon_index
|
|
626
|
-
next false unless rule_name.start_with?(ns) &&
|
|
656
|
+
next false unless rule_name.start_with?(ns) &&
|
|
657
|
+
rule_name.getbyte(ns.length) == COLON_BYTE
|
|
627
658
|
|
|
628
659
|
attr.unprefixed_name == local_name
|
|
629
660
|
end
|
|
@@ -653,13 +684,27 @@ _effective_register)
|
|
|
653
684
|
def find_attribute_by_local_name(doc, rule_names,
|
|
654
685
|
flexible_local: false, session: nil)
|
|
655
686
|
root_index = session&.element_local_attribute_index(doc.root)
|
|
687
|
+
# Matched VALUE per (element, spelling, flexible flag) — pure
|
|
688
|
+
# while the element is parse-frozen.
|
|
689
|
+
memo = if session
|
|
690
|
+
session.lenient_local_matches(doc.root,
|
|
691
|
+
flexible_local)
|
|
692
|
+
else
|
|
693
|
+
nil
|
|
694
|
+
end
|
|
656
695
|
|
|
657
696
|
rule_names.each do |rule_name|
|
|
658
697
|
next unless rule_name.include?(":")
|
|
659
698
|
|
|
660
|
-
resolved =
|
|
661
|
-
|
|
662
|
-
|
|
699
|
+
resolved = if memo&.key?(rule_name)
|
|
700
|
+
memo[rule_name]
|
|
701
|
+
else
|
|
702
|
+
match = match_attribute_by_local_name(
|
|
703
|
+
doc, rule_name, root_index, flexible_local
|
|
704
|
+
)
|
|
705
|
+
memo[rule_name] = match if memo
|
|
706
|
+
match
|
|
707
|
+
end
|
|
663
708
|
return resolved if resolved
|
|
664
709
|
end
|
|
665
710
|
nil
|
|
@@ -678,6 +723,11 @@ _effective_register)
|
|
|
678
723
|
local_name, rule_uri = parts
|
|
679
724
|
|
|
680
725
|
candidates = root_index&.[](local_name)
|
|
726
|
+
# The index carries every local form of every attribute on the
|
|
727
|
+
# element — a miss means no attribute can match, so the full
|
|
728
|
+
# each_value scan below is dead whenever the index exists.
|
|
729
|
+
return nil if root_index && !candidates
|
|
730
|
+
|
|
681
731
|
matched_attr = if candidates
|
|
682
732
|
candidates.find do |attr|
|
|
683
733
|
local_name_match?(attr, local_name, rule_uri,
|
|
@@ -786,10 +836,27 @@ _effective_register)
|
|
|
786
836
|
end
|
|
787
837
|
|
|
788
838
|
def decode_html_entities_for(instance, register)
|
|
839
|
+
# Asked per rule application (942k per ISO-13849 parse) for a
|
|
840
|
+
# per-(class, register) fact; the transform instance itself is
|
|
841
|
+
# cached per (model class, register), so the memo lives here.
|
|
842
|
+
per_class = (@decode_html_entities_cache ||= {}.compare_by_identity)
|
|
843
|
+
per_register = (per_class[instance.class] ||= {})
|
|
844
|
+
return per_register[register] if per_register.key?(register)
|
|
845
|
+
|
|
789
846
|
klass = instance.class
|
|
790
|
-
|
|
847
|
+
per_register[register] = klass.is_a?(Class) &&
|
|
848
|
+
klass.include?(Lutaml::Model::Serialize) &&
|
|
849
|
+
(klass.mappings_for(:xml, register)&.decode_html_entities? || false)
|
|
850
|
+
end
|
|
791
851
|
|
|
792
|
-
|
|
852
|
+
# Serializable type class → its XML namespace class, asked per
|
|
853
|
+
# (rule × element) in value_for_rule / resolve_rule_names_with_type
|
|
854
|
+
# for a per-type static.
|
|
855
|
+
def type_namespace_class_of(type_class)
|
|
856
|
+
cache = (@type_ns_class_cache ||= {}.compare_by_identity)
|
|
857
|
+
return cache[type_class] if cache.key?(type_class)
|
|
858
|
+
|
|
859
|
+
cache[type_class] = type_class.mappings_for(:xml)&.namespace_class
|
|
793
860
|
end
|
|
794
861
|
|
|
795
862
|
def value_for_rule(session, rule, options, cached_attr = nil,
|
|
@@ -847,7 +914,7 @@ _effective_register)
|
|
|
847
914
|
type_ns_all_uris = nil
|
|
848
915
|
if attr_type_is_serializable
|
|
849
916
|
type_ns_class = if attr_type_is_class
|
|
850
|
-
attr_type
|
|
917
|
+
type_namespace_class_of(attr_type)
|
|
851
918
|
else
|
|
852
919
|
attr.type_namespace_class(effective_register)
|
|
853
920
|
end
|
|
@@ -984,6 +1051,15 @@ _effective_register)
|
|
|
984
1051
|
end
|
|
985
1052
|
end
|
|
986
1053
|
|
|
1054
|
+
# lutaml-model#88: attribute-value discriminator — among the
|
|
1055
|
+
# name-matched occurrences, keep only those whose sibling
|
|
1056
|
+
# attributes carry the expected values.
|
|
1057
|
+
if rule.when_attribute?
|
|
1058
|
+
children = children.select do |child|
|
|
1059
|
+
rule.matches_when_attribute?(child)
|
|
1060
|
+
end
|
|
1061
|
+
end
|
|
1062
|
+
|
|
987
1063
|
# Performance: Cache rule method check before loop to avoid repeated method calls
|
|
988
1064
|
rule_has_custom_method = rule.has_custom_method_for_deserialization?
|
|
989
1065
|
if rule_has_custom_method || attr_type == ::Lutaml::Model::Type::Hash
|
|
@@ -1292,7 +1368,7 @@ effective_register = lutaml_register)
|
|
|
1292
1368
|
elsif precomputed_type.is_a?(Class) &&
|
|
1293
1369
|
precomputed_type.include?(::Lutaml::Model::Serialize)
|
|
1294
1370
|
# Use pre-computed type directly — avoids redundant attr.type() call
|
|
1295
|
-
attr_type_ns_class = precomputed_type
|
|
1371
|
+
attr_type_ns_class = type_namespace_class_of(precomputed_type)
|
|
1296
1372
|
if attr_type_ns_class
|
|
1297
1373
|
return ["#{attr_type_ns_class.uri}:#{rule.name}"]
|
|
1298
1374
|
end
|
|
@@ -1309,7 +1385,7 @@ effective_register = lutaml_register)
|
|
|
1309
1385
|
if attr_type_class
|
|
1310
1386
|
attr_type_ns_class = if attr_type_class.is_a?(Class) &&
|
|
1311
1387
|
attr_type_class.include?(::Lutaml::Model::Serialize)
|
|
1312
|
-
attr_type_class
|
|
1388
|
+
type_namespace_class_of(attr_type_class)
|
|
1313
1389
|
end
|
|
1314
1390
|
if attr_type_ns_class
|
|
1315
1391
|
return ["#{attr_type_ns_class.uri}:#{rule.name}"]
|
|
@@ -29,8 +29,17 @@ module Lutaml
|
|
|
29
29
|
# functions of the element and the name; ISO-13849-shaped
|
|
30
30
|
# documents repeat the same resolutions per rule application, so
|
|
31
31
|
# the computed answer is memoized per element for the parse.
|
|
32
|
-
def rule_name_resolution
|
|
33
|
-
@rule_name_resolution ||= {}
|
|
32
|
+
def rule_name_resolution(element)
|
|
33
|
+
@rule_name_resolution ||= {}.compare_by_identity
|
|
34
|
+
@rule_name_resolution[element] ||= {}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Lenient local-name fallback matches, per (element, flexible_local):
|
|
38
|
+
# spelling -> matched attribute VALUE. Same purity argument as
|
|
39
|
+
# rule_name_resolution — elements are parse-frozen.
|
|
40
|
+
def lenient_local_matches(element, flexible_local)
|
|
41
|
+
@lenient_local_matches ||= {}.compare_by_identity
|
|
42
|
+
(@lenient_local_matches[element] ||= {})[flexible_local] ||= {}
|
|
34
43
|
end
|
|
35
44
|
|
|
36
45
|
# Local-name -> [attributes] index over one element's attributes,
|
|
@@ -42,12 +42,31 @@ register_id, register)
|
|
|
42
42
|
rule.attribute_type < Lutaml::Model::Serialize
|
|
43
43
|
end
|
|
44
44
|
|
|
45
|
-
if is_nested_model
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
45
|
+
element = if is_nested_model
|
|
46
|
+
create_nested_model_element(rule, value, options,
|
|
47
|
+
register, union: union)
|
|
48
|
+
else
|
|
49
|
+
create_simple_value_element(rule, value, options,
|
|
50
|
+
model_class, register_id)
|
|
51
|
+
end
|
|
52
|
+
apply_when_attribute(element, rule)
|
|
53
|
+
element
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# lutaml-model#88: re-emit the discriminator attributes on the
|
|
57
|
+
# element created for this rule, unless the value's own mapping
|
|
58
|
+
# already wrote them.
|
|
59
|
+
def apply_when_attribute(element, rule)
|
|
60
|
+
pairs = rule.option(:when_attribute)
|
|
61
|
+
return if pairs.nil? || pairs.empty?
|
|
62
|
+
return if element.nil?
|
|
63
|
+
|
|
64
|
+
pairs.each do |name, expected|
|
|
65
|
+
next if element.attributes.any? { |a| a.name == name.to_s }
|
|
66
|
+
|
|
67
|
+
element.attributes << ::Lutaml::Xml::DataModel::XmlAttribute.new(
|
|
68
|
+
name.to_s, expected.to_s
|
|
69
|
+
)
|
|
51
70
|
end
|
|
52
71
|
end
|
|
53
72
|
|
|
@@ -337,6 +337,7 @@ register_id, register, attr_name, custom_methods_value)
|
|
|
337
337
|
value_map: value_map,
|
|
338
338
|
custom_methods: custom_methods_value,
|
|
339
339
|
polymorphic: mapping_rule.polymorphic,
|
|
340
|
+
when_attribute: mapping_rule.when_attribute,
|
|
340
341
|
form: mapping_rule.form,
|
|
341
342
|
delegate_from: delegate_target,
|
|
342
343
|
)
|
|
@@ -417,6 +418,7 @@ register_id, register, custom_methods_value)
|
|
|
417
418
|
value_map: value_map,
|
|
418
419
|
custom_methods: custom_methods_value,
|
|
419
420
|
polymorphic: mapping_rule.polymorphic,
|
|
421
|
+
when_attribute: mapping_rule.when_attribute,
|
|
420
422
|
form: mapping_rule.form,
|
|
421
423
|
alias_names: alias_names,
|
|
422
424
|
)
|
|
@@ -137,26 +137,36 @@ RSpec.describe Lutaml::KeyValue::Transformation::RuleCompiler do
|
|
|
137
137
|
end
|
|
138
138
|
|
|
139
139
|
describe "#valid_mapping?" do
|
|
140
|
-
|
|
140
|
+
def compiled_rule(serialize: true)
|
|
141
|
+
Lutaml::Model::CompiledRule.new(
|
|
142
|
+
attribute_name: :name,
|
|
143
|
+
serialized_name: "name",
|
|
144
|
+
serialize: serialize,
|
|
145
|
+
)
|
|
146
|
+
end
|
|
141
147
|
|
|
142
148
|
it "returns true when no only/except options" do
|
|
143
|
-
expect(compiler.valid_mapping?(
|
|
149
|
+
expect(compiler.valid_mapping?(compiled_rule, {})).to be true
|
|
144
150
|
end
|
|
145
151
|
|
|
146
152
|
it "returns true when attribute is in only list" do
|
|
147
|
-
expect(compiler.valid_mapping?(
|
|
153
|
+
expect(compiler.valid_mapping?(compiled_rule, { only: [:name] })).to be true
|
|
148
154
|
end
|
|
149
155
|
|
|
150
156
|
it "returns false when attribute is not in only list" do
|
|
151
|
-
expect(compiler.valid_mapping?(
|
|
157
|
+
expect(compiler.valid_mapping?(compiled_rule, { only: [:age] })).to be false
|
|
152
158
|
end
|
|
153
159
|
|
|
154
160
|
it "returns true when attribute is not in except list" do
|
|
155
|
-
expect(compiler.valid_mapping?(
|
|
161
|
+
expect(compiler.valid_mapping?(compiled_rule, { except: [:age] })).to be true
|
|
156
162
|
end
|
|
157
163
|
|
|
158
164
|
it "returns false when attribute is in except list" do
|
|
159
|
-
expect(compiler.valid_mapping?(
|
|
165
|
+
expect(compiler.valid_mapping?(compiled_rule, { except: [:name] })).to be false
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
it "returns false for a hydrate-only rule (serialize: false)" do
|
|
169
|
+
expect(compiler.valid_mapping?(compiled_rule(serialize: false), {})).to be false
|
|
160
170
|
end
|
|
161
171
|
end
|
|
162
172
|
|
|
@@ -283,7 +283,10 @@ RSpec.describe Lutaml::KeyValue::Transformation do
|
|
|
283
283
|
|
|
284
284
|
expect(transformation).to be_frozen
|
|
285
285
|
|
|
286
|
-
rule =
|
|
286
|
+
rule = Lutaml::Model::CompiledRule.new(
|
|
287
|
+
attribute_name: :name,
|
|
288
|
+
serialized_name: "name",
|
|
289
|
+
)
|
|
287
290
|
expect do
|
|
288
291
|
transformation.send(:valid_mapping?, rule, {})
|
|
289
292
|
end.not_to raise_error
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
# lutaml-model#815: standalone `SomeModel.new.to_xml` crashed on 0.8.42 when
|
|
6
|
+
# the model's attributes are merged through register records whose imports
|
|
7
|
+
# clear @merged_attributes_cache re-entrantly from inside ensure_imports! —
|
|
8
|
+
# the memo store was materialized before ensure_imports! and the write after
|
|
9
|
+
# it hit nil[].
|
|
10
|
+
RSpec.describe "attributes memo across import-triggered cache clears" do
|
|
11
|
+
let(:namespace_class) do
|
|
12
|
+
Class.new(Lutaml::Xml::W3c::XmlNamespace) do
|
|
13
|
+
uri "https://example.com/ns815"
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
let(:register) do
|
|
18
|
+
Lutaml::Model::Register.new(:attrs815, fallback: [:default]).tap do |reg|
|
|
19
|
+
reg.bind_namespace(namespace_class)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
let(:base_model) do
|
|
24
|
+
Class.new(Lutaml::Model::Serializable) do
|
|
25
|
+
attribute :shared, :string
|
|
26
|
+
|
|
27
|
+
xml do
|
|
28
|
+
element "Base815"
|
|
29
|
+
namespace namespace_class
|
|
30
|
+
map_element "shared", to: :shared
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
let(:consumer_model) do
|
|
36
|
+
Class.new(Lutaml::Model::Serializable) do
|
|
37
|
+
attribute :own, :string
|
|
38
|
+
|
|
39
|
+
xml do
|
|
40
|
+
element "Consumer815"
|
|
41
|
+
namespace namespace_class
|
|
42
|
+
map_element "own", to: :own
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
before do
|
|
48
|
+
stub_const("Attrs815::NamespaceClass", namespace_class)
|
|
49
|
+
Lutaml::Model::GlobalContext.reset!
|
|
50
|
+
Lutaml::Model::GlobalRegister.instance.register(register)
|
|
51
|
+
# Populate @register_records through the import the parse path uses.
|
|
52
|
+
base_model.import_model_attributes(consumer_model, register.id)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
after do
|
|
56
|
+
Lutaml::Model::GlobalContext.reset!
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
it "serializes a register-bound model as its own document root" do
|
|
60
|
+
instance = base_model.new(shared: "x")
|
|
61
|
+
expect(instance.to_xml).to include("<shared>x</shared>")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
it "merges register attributes after an import-triggered cache clear" do
|
|
65
|
+
instance = consumer_model.new(own: "y")
|
|
66
|
+
expect(instance.to_xml).to include("<own>y</own>")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
it "keeps answering attributes() from the memo after imports" do
|
|
70
|
+
first = consumer_model.attributes(:attrs815)
|
|
71
|
+
expect(consumer_model.attributes(:attrs815)).to equal(first)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
it "survives a re-entrant cache clear from inside ensure_imports!" do
|
|
75
|
+
# uniword's register-bound models (#815): choice/restrict resolution
|
|
76
|
+
# runs clear_cache while ensure_imports! is still on the stack, which
|
|
77
|
+
# nils @merged_attributes_cache after the reader materialized it.
|
|
78
|
+
reentrant = Class.new(base_model) do
|
|
79
|
+
def self.finalized? = true
|
|
80
|
+
|
|
81
|
+
def self.ensure_format_mapping_imports!(_register = nil)
|
|
82
|
+
clear_cache
|
|
83
|
+
super
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
expect { reentrant.attributes(:attrs815) }.not_to raise_error
|
|
88
|
+
expect(reentrant.attributes(:attrs815)[:shared].name).to eq(:shared)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
require_relative "../../../lib/lutaml/model"
|
|
5
|
+
|
|
6
|
+
module HydrateOnlyMapping
|
|
7
|
+
class Message < Lutaml::Model::Serializable
|
|
8
|
+
attribute :body, :string
|
|
9
|
+
attribute :source_offset, :integer
|
|
10
|
+
|
|
11
|
+
key_value do
|
|
12
|
+
map "body", to: :body
|
|
13
|
+
map "source_offset", to: :source_offset, serialize: false
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
RSpec.describe "hydrate-only mappings (serialize: false)" do
|
|
19
|
+
let(:input) { { "body" => "hello", "source_offset" => 42 } }
|
|
20
|
+
|
|
21
|
+
it "hydrates the attribute from input" do
|
|
22
|
+
message = HydrateOnlyMapping::Message.from_hash(input)
|
|
23
|
+
|
|
24
|
+
expect(message.body).to eq("hello")
|
|
25
|
+
expect(message.source_offset).to eq(42)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
it "omits the attribute from serialized output" do
|
|
29
|
+
message = HydrateOnlyMapping::Message.from_hash(input)
|
|
30
|
+
|
|
31
|
+
expect(message.to_hash).to eq("body" => "hello")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
it "round-trips through JSON without the hidden key" do
|
|
35
|
+
message = HydrateOnlyMapping::Message.from_json(input.to_json)
|
|
36
|
+
|
|
37
|
+
expect(JSON.parse(message.to_json)).to eq("body" => "hello")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it "keeps the mapping through deep-copied mappings" do
|
|
41
|
+
mapping = HydrateOnlyMapping::Message.mappings_for(:hash)
|
|
42
|
+
rule = mapping.mappings.find { |r| r.to == :source_offset }
|
|
43
|
+
|
|
44
|
+
expect(mapping.deep_dup.mappings.find { |r| r.to == :source_offset }
|
|
45
|
+
.serialize?).to eq(rule.serialize?)
|
|
46
|
+
end
|
|
47
|
+
end
|