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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +14 -73
  3. data/Gemfile +3 -5
  4. data/README.adoc +188 -25
  5. data/docs/_guides/xml-mapping.adoc +178 -24
  6. data/docs/_pages/importable_models.adoc +7 -1
  7. data/lib/lutaml/jsonld/transform.rb +44 -13
  8. data/lib/lutaml/model/attribute.rb +4 -0
  9. data/lib/lutaml/model/liquefiable.rb +9 -0
  10. data/lib/lutaml/model/liquid/indexed_access.rb +33 -0
  11. data/lib/lutaml/model/liquid.rb +1 -0
  12. data/lib/lutaml/model/version.rb +1 -1
  13. data/lib/lutaml/model.rb +2 -1
  14. data/lib/lutaml/rdf/mapping.rb +10 -1
  15. data/lib/lutaml/rdf/member_rule.rb +29 -4
  16. data/lib/lutaml/turtle/transform.rb +55 -35
  17. data/lib/lutaml/xml/adapter/plan_based_builder.rb +3 -1
  18. data/lib/lutaml/xml/adapter/xml_serializer.rb +15 -5
  19. data/lib/lutaml/xml/adapter.rb +2 -1
  20. data/lib/lutaml/xml/builder/base.rb +2 -1
  21. data/lib/lutaml/xml/data_model.rb +19 -3
  22. data/lib/lutaml/xml/mapping.rb +3 -1
  23. data/lib/lutaml/xml/mapping_rule.rb +28 -2
  24. data/lib/lutaml/xml/model_transform.rb +9 -1
  25. data/lib/lutaml/xml/serialization/instance_methods.rb +16 -9
  26. data/lib/lutaml/xml/transformation/element_builder.rb +1 -3
  27. data/lib/lutaml/xml/transformation/rule_applier.rb +21 -0
  28. data/lib/lutaml/xml/transformation/rule_compiler.rb +12 -3
  29. data/lutaml-model.gemspec +1 -1
  30. data/spec/lutaml/jsonld/transform_spec.rb +149 -0
  31. data/spec/lutaml/model/liquid/indexed_access_spec.rb +135 -0
  32. data/spec/lutaml/model/mixed_content_spec.rb +48 -7
  33. data/spec/lutaml/model/raw_element_spec.rb +533 -0
  34. data/spec/lutaml/rdf/mapping_spec.rb +71 -6
  35. data/spec/lutaml/rdf/member_rule_spec.rb +103 -1
  36. data/spec/lutaml/turtle/transform_spec.rb +144 -0
  37. 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 "skips whitespace-only text nodes" do
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) && node.strip.empty?
1119
+ results << node if node.is_a?(String)
1120
1120
  end
1121
1121
 
1122
- expect(results).to eq([])
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 objects in document order" do
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
- # Should yield element objects in order (bold, italic strings)
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.length).to eq(2)
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