lutaml-model 0.8.14 → 0.8.16
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/.rubocop_todo.yml +14 -73
- data/Gemfile +3 -5
- data/README.adoc +188 -25
- data/docs/_guides/xml-mapping.adoc +178 -24
- data/docs/_pages/importable_models.adoc +7 -1
- data/lib/lutaml/jsonld/transform.rb +44 -13
- data/lib/lutaml/model/attribute.rb +4 -0
- data/lib/lutaml/model/liquefiable.rb +9 -0
- data/lib/lutaml/model/liquid/indexed_access.rb +33 -0
- data/lib/lutaml/model/liquid.rb +1 -0
- data/lib/lutaml/model/version.rb +1 -1
- data/lib/lutaml/model.rb +2 -1
- data/lib/lutaml/rdf/mapping.rb +10 -1
- data/lib/lutaml/rdf/member_rule.rb +29 -4
- data/lib/lutaml/turtle/transform.rb +55 -35
- data/lib/lutaml/xml/adapter/plan_based_builder.rb +3 -1
- data/lib/lutaml/xml/adapter/xml_serializer.rb +15 -5
- data/lib/lutaml/xml/adapter.rb +2 -1
- data/lib/lutaml/xml/builder/base.rb +2 -1
- data/lib/lutaml/xml/data_model.rb +19 -3
- data/lib/lutaml/xml/mapping.rb +3 -1
- data/lib/lutaml/xml/mapping_rule.rb +28 -2
- data/lib/lutaml/xml/model_transform.rb +9 -1
- data/lib/lutaml/xml/serialization/instance_methods.rb +16 -9
- data/lib/lutaml/xml/transformation/element_builder.rb +1 -3
- data/lib/lutaml/xml/transformation/rule_applier.rb +21 -0
- data/lib/lutaml/xml/transformation/rule_compiler.rb +12 -3
- data/lutaml-model.gemspec +1 -1
- data/spec/lutaml/jsonld/transform_spec.rb +149 -0
- data/spec/lutaml/model/liquid/indexed_access_spec.rb +135 -0
- data/spec/lutaml/model/mixed_content_spec.rb +48 -7
- data/spec/lutaml/model/raw_element_spec.rb +533 -0
- data/spec/lutaml/rdf/mapping_spec.rb +71 -6
- data/spec/lutaml/rdf/member_rule_spec.rb +103 -1
- data/spec/lutaml/turtle/transform_spec.rb +144 -0
- metadata +9 -6
|
@@ -447,4 +447,153 @@ RSpec.describe Lutaml::JsonLd::Transform do
|
|
|
447
447
|
expect(parsed).not_to have_key("@type")
|
|
448
448
|
end
|
|
449
449
|
end
|
|
450
|
+
|
|
451
|
+
describe "dynamic link predicates (String)" do
|
|
452
|
+
before do
|
|
453
|
+
stub_const("JsonLdDynChild", Class.new(Lutaml::Model::Serializable) do
|
|
454
|
+
attribute :cid, :string
|
|
455
|
+
attribute :label, :string
|
|
456
|
+
|
|
457
|
+
rdf do
|
|
458
|
+
namespace TestSkosNs, TestExNs
|
|
459
|
+
|
|
460
|
+
subject { |m| "http://example.org/item/#{m.cid}" } # rubocop:disable RSpec/NamedSubject, RSpec/MultipleSubjects
|
|
461
|
+
|
|
462
|
+
type "skos:Concept"
|
|
463
|
+
|
|
464
|
+
predicate :prefLabel, namespace: TestSkosNs, to: :label
|
|
465
|
+
end
|
|
466
|
+
end)
|
|
467
|
+
|
|
468
|
+
stub_const("JsonLdDynParent", Class.new(Lutaml::Model::Serializable) do
|
|
469
|
+
attribute :name, :string
|
|
470
|
+
attribute :children, JsonLdDynChild, collection: true
|
|
471
|
+
|
|
472
|
+
rdf do
|
|
473
|
+
namespace TestSkosNs, TestExNs
|
|
474
|
+
|
|
475
|
+
subject { |m| "http://example.org/group/#{m.name}" } # rubocop:disable RSpec/NamedSubject, RSpec/MultipleSubjects
|
|
476
|
+
|
|
477
|
+
type "skos:Collection"
|
|
478
|
+
|
|
479
|
+
predicate :prefLabel, namespace: TestSkosNs, to: :name
|
|
480
|
+
|
|
481
|
+
members :children, link: "skos:member"
|
|
482
|
+
end
|
|
483
|
+
end)
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
it "generates @id references for linked members" do
|
|
487
|
+
parent = JsonLdDynParent.new(
|
|
488
|
+
name: "grp1",
|
|
489
|
+
children: [
|
|
490
|
+
JsonLdDynChild.new(cid: "a", label: "Alpha"),
|
|
491
|
+
JsonLdDynChild.new(cid: "b", label: "Beta"),
|
|
492
|
+
],
|
|
493
|
+
)
|
|
494
|
+
parsed = JSON.parse(parent.to_jsonld)
|
|
495
|
+
parent_resource = parsed["@graph"].find do |r|
|
|
496
|
+
r["@type"] == "skos:Collection"
|
|
497
|
+
end
|
|
498
|
+
expect(parent_resource["member"]).to eq([
|
|
499
|
+
{ "@id" => "http://example.org/item/a" },
|
|
500
|
+
{ "@id" => "http://example.org/item/b" },
|
|
501
|
+
])
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
it "includes member resources in @graph" do
|
|
505
|
+
parent = JsonLdDynParent.new(
|
|
506
|
+
name: "grp1",
|
|
507
|
+
children: [JsonLdDynChild.new(cid: "a", label: "Alpha")],
|
|
508
|
+
)
|
|
509
|
+
parsed = JSON.parse(parent.to_jsonld)
|
|
510
|
+
member = parsed["@graph"].find { |r| r["prefLabel"] == "Alpha" }
|
|
511
|
+
expect(member).not_to be_nil
|
|
512
|
+
end
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
describe "recursive context and resource collection" do
|
|
516
|
+
before do
|
|
517
|
+
stub_const("JsonLdLeaf", Class.new(Lutaml::Model::Serializable) do
|
|
518
|
+
attribute :value, :string
|
|
519
|
+
attribute :lid, :string
|
|
520
|
+
|
|
521
|
+
rdf do
|
|
522
|
+
namespace TestExNs
|
|
523
|
+
|
|
524
|
+
subject { |m| "http://example.org/leaf/#{m.lid}" } # rubocop:disable RSpec/NamedSubject, RSpec/MultipleSubjects
|
|
525
|
+
|
|
526
|
+
type "ex:Leaf"
|
|
527
|
+
|
|
528
|
+
predicate :name, namespace: TestExNs, to: :value
|
|
529
|
+
end
|
|
530
|
+
end)
|
|
531
|
+
|
|
532
|
+
stub_const("JsonLdMid", Class.new(Lutaml::Model::Serializable) do
|
|
533
|
+
attribute :label, :string
|
|
534
|
+
attribute :mid, :string
|
|
535
|
+
attribute :leaves, JsonLdLeaf, collection: true
|
|
536
|
+
|
|
537
|
+
rdf do
|
|
538
|
+
namespace TestSkosNs, TestExNs
|
|
539
|
+
|
|
540
|
+
subject { |m| "http://example.org/mid/#{m.mid}" } # rubocop:disable RSpec/NamedSubject, RSpec/MultipleSubjects
|
|
541
|
+
|
|
542
|
+
type "skos:Concept"
|
|
543
|
+
|
|
544
|
+
predicate :prefLabel, namespace: TestSkosNs, to: :label
|
|
545
|
+
|
|
546
|
+
members :leaves, link: "skos:member"
|
|
547
|
+
end
|
|
548
|
+
end)
|
|
549
|
+
|
|
550
|
+
stub_const("JsonLdRoot", Class.new(Lutaml::Model::Serializable) do
|
|
551
|
+
attribute :title, :string
|
|
552
|
+
attribute :mids, JsonLdMid, collection: true
|
|
553
|
+
|
|
554
|
+
rdf do
|
|
555
|
+
namespace TestSkosNs
|
|
556
|
+
|
|
557
|
+
subject { |m| "http://example.org/root/#{m.title}" } # rubocop:disable RSpec/NamedSubject, RSpec/MultipleSubjects
|
|
558
|
+
|
|
559
|
+
type "skos:Collection"
|
|
560
|
+
|
|
561
|
+
predicate :prefLabel, namespace: TestSkosNs, to: :title
|
|
562
|
+
|
|
563
|
+
members :mids, link: "skos:member"
|
|
564
|
+
end
|
|
565
|
+
end)
|
|
566
|
+
end
|
|
567
|
+
|
|
568
|
+
it "collects @context from all nesting levels" do
|
|
569
|
+
root = JsonLdRoot.new(
|
|
570
|
+
title: "r1",
|
|
571
|
+
mids: [JsonLdMid.new(
|
|
572
|
+
label: "m1",
|
|
573
|
+
mid: "a",
|
|
574
|
+
leaves: [JsonLdLeaf.new(value: "l1", lid: "x")],
|
|
575
|
+
)],
|
|
576
|
+
)
|
|
577
|
+
parsed = JSON.parse(root.to_jsonld)
|
|
578
|
+
expect(parsed["@context"]["skos"]).to eq("http://www.w3.org/2004/02/skos/core#")
|
|
579
|
+
expect(parsed["@context"]["ex"]).to eq("http://example.org/")
|
|
580
|
+
end
|
|
581
|
+
|
|
582
|
+
it "includes resources from all nesting levels in @graph" do
|
|
583
|
+
root = JsonLdRoot.new(
|
|
584
|
+
title: "r1",
|
|
585
|
+
mids: [JsonLdMid.new(
|
|
586
|
+
label: "m1",
|
|
587
|
+
mid: "a",
|
|
588
|
+
leaves: [JsonLdLeaf.new(value: "l1", lid: "x")],
|
|
589
|
+
)],
|
|
590
|
+
)
|
|
591
|
+
parsed = JSON.parse(root.to_jsonld)
|
|
592
|
+
graph = parsed["@graph"]
|
|
593
|
+
types = graph.map { |r| r["@type"] }
|
|
594
|
+
expect(types).to include("skos:Collection")
|
|
595
|
+
expect(types).to include("skos:Concept")
|
|
596
|
+
expect(types).to include("ex:Leaf")
|
|
597
|
+
end
|
|
598
|
+
end
|
|
450
599
|
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
require "liquid"
|
|
5
|
+
|
|
6
|
+
RSpec.describe Lutaml::Model::Liquid::IndexedAccess do
|
|
7
|
+
describe "integration with auto-generated drops" do
|
|
8
|
+
before do
|
|
9
|
+
stub_const("IndexedSpec::Item", Class.new(Lutaml::Model::Serializable) do
|
|
10
|
+
attribute :name, :string
|
|
11
|
+
attribute :value, :string
|
|
12
|
+
end)
|
|
13
|
+
|
|
14
|
+
stub_const("IndexedSpec::ItemCollection", Class.new(Lutaml::Model::Serializable) do
|
|
15
|
+
include Lutaml::Model::Liquid::IndexedAccess
|
|
16
|
+
|
|
17
|
+
attribute :items, IndexedSpec::Item, collection: true
|
|
18
|
+
|
|
19
|
+
def [](key)
|
|
20
|
+
case key
|
|
21
|
+
when Integer then items[key]
|
|
22
|
+
when String then items.find { |i| i.name == key }
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
let(:collection) do
|
|
29
|
+
IndexedSpec::ItemCollection.new(
|
|
30
|
+
items: [
|
|
31
|
+
IndexedSpec::Item.new(name: "alpha", value: "A"),
|
|
32
|
+
IndexedSpec::Item.new(name: "beta", value: "B"),
|
|
33
|
+
IndexedSpec::Item.new(name: "gamma", value: "C"),
|
|
34
|
+
],
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
let(:drop) { collection.to_liquid }
|
|
39
|
+
|
|
40
|
+
describe "#liquid_method_missing" do
|
|
41
|
+
it "resolves string key via liquid_fetch" do
|
|
42
|
+
result = drop["alpha"]
|
|
43
|
+
expect(result).to be_a(Liquid::Drop)
|
|
44
|
+
expect(result.name).to eq("alpha")
|
|
45
|
+
expect(result.value).to eq("A")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it "resolves integer index via liquid_fetch" do
|
|
49
|
+
result = drop[0]
|
|
50
|
+
expect(result).to be_a(Liquid::Drop)
|
|
51
|
+
expect(result.name).to eq("alpha")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
it "returns nil for unknown key" do
|
|
55
|
+
result = drop["nonexistent"]
|
|
56
|
+
expect(result).to be_nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
it "returns nil for out-of-bounds index" do
|
|
60
|
+
result = drop[99]
|
|
61
|
+
expect(result).to be_nil
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
describe "Liquid template rendering" do
|
|
66
|
+
it "resolves bracket access in templates" do
|
|
67
|
+
template = Liquid::Template.parse("{{ collection['beta'].value }}")
|
|
68
|
+
result = template.render("collection" => drop)
|
|
69
|
+
expect(result).to eq("B")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
it "resolves integer bracket access in templates" do
|
|
73
|
+
template = Liquid::Template.parse("{{ collection[2].name }}")
|
|
74
|
+
result = template.render("collection" => drop)
|
|
75
|
+
expect(result).to eq("gamma")
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
it "renders empty string for unknown key" do
|
|
79
|
+
template = Liquid::Template.parse("{{ collection['missing'].name }}")
|
|
80
|
+
result = template.render("collection" => drop)
|
|
81
|
+
expect(result).to eq("")
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
describe "coexistence with declared attribute methods" do
|
|
86
|
+
it "still exposes declared attributes normally" do
|
|
87
|
+
expect(drop.items).to be_a(Array)
|
|
88
|
+
expect(drop.items.size).to eq(3)
|
|
89
|
+
expect(drop.items[0].name).to eq("alpha")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
it "prefers declared methods over indexed access" do
|
|
93
|
+
# 'items' is a declared attribute, so invoke_drop('items') calls
|
|
94
|
+
# the generated method, not liquid_fetch
|
|
95
|
+
result = drop.items
|
|
96
|
+
expect(result).to be_a(Array)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
describe "objects without IndexedAccess" do
|
|
102
|
+
before do
|
|
103
|
+
stub_const("PlainSpec::Model", Class.new(Lutaml::Model::Serializable) do
|
|
104
|
+
attribute :name, :string
|
|
105
|
+
end)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
let(:instance) { PlainSpec::Model.new(name: "test") }
|
|
109
|
+
let(:drop) { instance.to_liquid }
|
|
110
|
+
|
|
111
|
+
it "does not attempt bracket access on non-indexed objects" do
|
|
112
|
+
result = drop["anything"]
|
|
113
|
+
expect(result).to be_nil
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
it "still exposes declared attributes" do
|
|
117
|
+
expect(drop.name).to eq("test")
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
describe "IndexedAccess module" do
|
|
122
|
+
it "provides liquid_fetch that delegates to []" do
|
|
123
|
+
klass = Class.new do
|
|
124
|
+
include Lutaml::Model::Liquid::IndexedAccess
|
|
125
|
+
|
|
126
|
+
def [](key)
|
|
127
|
+
"value_for_#{key}"
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
instance = klass.new
|
|
132
|
+
expect(instance.liquid_fetch("test")).to eq("value_for_test")
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -1111,19 +1111,22 @@ RSpec.describe "MixedContent" do
|
|
|
1111
1111
|
expect(enum).to be_a(Enumerator)
|
|
1112
1112
|
end
|
|
1113
1113
|
|
|
1114
|
-
it "
|
|
1114
|
+
it "yields whitespace text nodes in mixed content" do
|
|
1115
1115
|
parsed = MixedContentSpec::RootMixedContent.from_xml(xml)
|
|
1116
1116
|
|
|
1117
1117
|
results = []
|
|
1118
1118
|
parsed.each_mixed_content do |node|
|
|
1119
|
-
results << node if node.is_a?(String)
|
|
1119
|
+
results << node if node.is_a?(String)
|
|
1120
1120
|
end
|
|
1121
1121
|
|
|
1122
|
-
expect(results).
|
|
1122
|
+
expect(results).not_to be_empty
|
|
1123
|
+
text_joined = results.join
|
|
1124
|
+
expect(text_joined).to include("Hello")
|
|
1125
|
+
expect(text_joined).to include("and")
|
|
1126
|
+
expect(text_joined).to include("!")
|
|
1123
1127
|
end
|
|
1124
1128
|
|
|
1125
1129
|
context "with ordered-only content (no mixed)" do
|
|
1126
|
-
# Test that ordered content (elements only, no text) works
|
|
1127
1130
|
let(:xml) do
|
|
1128
1131
|
<<~XML
|
|
1129
1132
|
<RootMixedContentNested id="outer">
|
|
@@ -1134,15 +1137,17 @@ RSpec.describe "MixedContent" do
|
|
|
1134
1137
|
XML
|
|
1135
1138
|
end
|
|
1136
1139
|
|
|
1137
|
-
it "yields element
|
|
1140
|
+
it "yields element values in document order" do
|
|
1138
1141
|
parsed = MixedContentSpec::RootMixedContentNested.from_xml(xml)
|
|
1139
1142
|
|
|
1140
1143
|
results = []
|
|
1141
1144
|
parsed.content.each_mixed_content { |node| results << node }
|
|
1142
1145
|
|
|
1143
|
-
#
|
|
1146
|
+
# RootMixedContent has mixed_content, so whitespace text nodes
|
|
1147
|
+
# ARE yielded alongside element values
|
|
1144
1148
|
string_results = results.grep(String)
|
|
1145
|
-
expect(string_results
|
|
1149
|
+
expect(string_results).to include("first")
|
|
1150
|
+
expect(string_results).to include("second")
|
|
1146
1151
|
end
|
|
1147
1152
|
end
|
|
1148
1153
|
|
|
@@ -1152,6 +1157,42 @@ RSpec.describe "MixedContent" do
|
|
|
1152
1157
|
expect(parsed.each_mixed_content.to_a).to eq([])
|
|
1153
1158
|
end
|
|
1154
1159
|
end
|
|
1160
|
+
|
|
1161
|
+
context "with ordered-only model (no mixed_content)" do
|
|
1162
|
+
before do
|
|
1163
|
+
stub_const("OrderedOnlyContainer", Class.new(Lutaml::Model::Serializable) do
|
|
1164
|
+
attribute :items, :string, collection: true
|
|
1165
|
+
|
|
1166
|
+
xml do
|
|
1167
|
+
element "container"
|
|
1168
|
+
ordered
|
|
1169
|
+
map_element "item", to: :items
|
|
1170
|
+
end
|
|
1171
|
+
end)
|
|
1172
|
+
end
|
|
1173
|
+
|
|
1174
|
+
it "skips whitespace-only text nodes between elements" do
|
|
1175
|
+
xml = <<~XML
|
|
1176
|
+
<container>
|
|
1177
|
+
<item>first</item>
|
|
1178
|
+
<item>second</item>
|
|
1179
|
+
</container>
|
|
1180
|
+
XML
|
|
1181
|
+
|
|
1182
|
+
parsed = OrderedOnlyContainer.from_xml(xml)
|
|
1183
|
+
results = []
|
|
1184
|
+
parsed.each_mixed_content { |node| results << node }
|
|
1185
|
+
|
|
1186
|
+
# Ordered-only: whitespace between elements is formatting noise, not content
|
|
1187
|
+
whitespace_only = results.select do |n|
|
|
1188
|
+
n.is_a?(String) && n.strip.empty?
|
|
1189
|
+
end
|
|
1190
|
+
expect(whitespace_only).to eq([])
|
|
1191
|
+
|
|
1192
|
+
string_results = results.grep(String)
|
|
1193
|
+
expect(string_results).to eq(%w[first second])
|
|
1194
|
+
end
|
|
1195
|
+
end
|
|
1155
1196
|
end
|
|
1156
1197
|
|
|
1157
1198
|
# Issue #630: Mutation after deserialization should update serialization output
|