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
@@ -0,0 +1,533 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ module RawElementSpec
6
+ class SvgContainer < Lutaml::Model::Serializable
7
+ attribute :svg_data, :string
8
+
9
+ xml do
10
+ element "container"
11
+ map_element "svg", to: :svg_data, raw: :element
12
+ end
13
+ end
14
+
15
+ class MathContainer < Lutaml::Model::Serializable
16
+ attribute :formula, :string
17
+
18
+ xml do
19
+ element "container"
20
+ map_element "math", to: :formula, raw: :element
21
+ end
22
+ end
23
+
24
+ class MixedContainer < Lutaml::Model::Serializable
25
+ attribute :name, :string
26
+ attribute :embedded, :string
27
+
28
+ xml do
29
+ element "container"
30
+ map_element "name", to: :name
31
+ map_element "foreign", to: :embedded, raw: :element
32
+ end
33
+ end
34
+
35
+ class NestedRawContainer < Lutaml::Model::Serializable
36
+ attribute :name, :string
37
+ attribute :embedded, :string
38
+
39
+ xml do
40
+ element "container"
41
+ map_element "name", to: :name
42
+ map_element "embedded", to: :embedded, raw: :element
43
+ end
44
+ end
45
+
46
+ class MultiRawContainer < Lutaml::Model::Serializable
47
+ attribute :name, :string
48
+ attribute :svg_data, :string
49
+ attribute :math_data, :string
50
+
51
+ xml do
52
+ element "container"
53
+ map_element "name", to: :name
54
+ map_element "svg", to: :svg_data, raw: :element
55
+ map_element "math", to: :math_data, raw: :element
56
+ end
57
+ end
58
+
59
+ class CollectionRawContainer < Lutaml::Model::Serializable
60
+ attribute :name, :string
61
+ attribute :fragments, :string, collection: true
62
+
63
+ xml do
64
+ element "container"
65
+ map_element "name", to: :name
66
+ map_element "fragment", to: :fragments, raw: :element
67
+ end
68
+ end
69
+
70
+ class EmptyRawContainer < Lutaml::Model::Serializable
71
+ attribute :name, :string
72
+ attribute :embedded, :string
73
+
74
+ xml do
75
+ element "container"
76
+ map_element "name", to: :name
77
+ map_element "foreign", to: :embedded, raw: :element
78
+ end
79
+ end
80
+
81
+ class NormalContainer < Lutaml::Model::Serializable
82
+ attribute :data, :string
83
+
84
+ xml do
85
+ element "container"
86
+ map_element "data", to: :data
87
+ end
88
+ end
89
+
90
+ RSpec.describe "map_element raw: :element" do
91
+ describe "deserialization" do
92
+ describe "foreign namespace element capture" do
93
+ let(:svg_xml) do
94
+ <<~XML
95
+ <container>
96
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
97
+ <rect x="0" y="0" width="100" height="100" fill="red"/>
98
+ </svg>
99
+ </container>
100
+ XML
101
+ end
102
+
103
+ it "captures the full SVG element including its own tags" do
104
+ doc = SvgContainer.from_xml(svg_xml)
105
+ expect(doc.svg_data).to include("<svg")
106
+ expect(doc.svg_data).to include("</svg>")
107
+ expect(doc.svg_data).to include("viewBox")
108
+ expect(doc.svg_data).to include("<rect")
109
+ end
110
+
111
+ it "preserves SVG namespace attribute" do
112
+ doc = SvgContainer.from_xml(svg_xml)
113
+ expect(doc.svg_data).to include("xmlns=\"http://www.w3.org/2000/svg\"")
114
+ end
115
+
116
+ it "preserves child elements" do
117
+ doc = SvgContainer.from_xml(svg_xml)
118
+ expect(doc.svg_data).to include("<rect")
119
+ expect(doc.svg_data).to include("fill=\"red\"")
120
+ end
121
+ end
122
+
123
+ describe "non-namespaced element capture" do
124
+ let(:math_xml) do
125
+ "<container><math><mfrac><mi>x</mi><mn>2</mn></mfrac></math></container>"
126
+ end
127
+
128
+ it "captures the full element" do
129
+ doc = MathContainer.from_xml(math_xml)
130
+ expect(doc.formula).to include("<math>")
131
+ expect(doc.formula).to include("</math>")
132
+ expect(doc.formula).to include("<mfrac>")
133
+ end
134
+ end
135
+
136
+ describe "mixed mapped and raw elements" do
137
+ let(:mixed_xml) do
138
+ <<~XML
139
+ <container>
140
+ <name>test</name>
141
+ <foreign attr="value">
142
+ <child>content</child>
143
+ </foreign>
144
+ </container>
145
+ XML
146
+ end
147
+
148
+ it "parses normal elements normally" do
149
+ doc = MixedContainer.from_xml(mixed_xml)
150
+ expect(doc.name).to eq("test")
151
+ end
152
+
153
+ it "captures raw elements as full XML" do
154
+ doc = MixedContainer.from_xml(mixed_xml)
155
+ expect(doc.embedded).to include("<foreign")
156
+ expect(doc.embedded).to include("</foreign>")
157
+ expect(doc.embedded).to include("attr=\"value\"")
158
+ expect(doc.embedded).to include("<child>content</child>")
159
+ end
160
+ end
161
+
162
+ describe "nested XML in raw element" do
163
+ let(:nested_xml) do
164
+ <<~XML
165
+ <container>
166
+ <name>test</name>
167
+ <embedded>
168
+ <level1>
169
+ <level2 attr="deep">text</level2>
170
+ </level1>
171
+ </embedded>
172
+ </container>
173
+ XML
174
+ end
175
+
176
+ it "preserves full nesting" do
177
+ doc = NestedRawContainer.from_xml(nested_xml)
178
+ expect(doc.embedded).to include("<level1>")
179
+ expect(doc.embedded).to include("<level2 attr=\"deep\">text</level2>")
180
+ expect(doc.embedded).to include("</level1>")
181
+ end
182
+ end
183
+
184
+ describe "multiple raw: :element mappings" do
185
+ let(:multi_xml) do
186
+ <<~XML
187
+ <container>
188
+ <name>mixed content</name>
189
+ <svg xmlns="http://www.w3.org/2000/svg"><circle r="5"/></svg>
190
+ <math><mi>&#x3C0;</mi></math>
191
+ </container>
192
+ XML
193
+ end
194
+
195
+ it "captures each raw element independently" do
196
+ doc = MultiRawContainer.from_xml(multi_xml)
197
+ expect(doc.name).to eq("mixed content")
198
+ expect(doc.svg_data).to include("<svg")
199
+ expect(doc.svg_data).to include("<circle")
200
+ expect(doc.math_data).to include("<math>")
201
+ expect(doc.math_data).to include("<mi>")
202
+ end
203
+ end
204
+
205
+ describe "collection raw: :element" do
206
+ let(:collection_xml) do
207
+ <<~XML
208
+ <container>
209
+ <name>multi</name>
210
+ <fragment id="1">first</fragment>
211
+ <fragment id="2">second</fragment>
212
+ <fragment id="3"><nested>third</nested></fragment>
213
+ </container>
214
+ XML
215
+ end
216
+
217
+ it "captures each occurrence as a collection item" do
218
+ doc = CollectionRawContainer.from_xml(collection_xml)
219
+ expect(doc.name).to eq("multi")
220
+ expect(doc.fragments.length).to eq(3)
221
+ expect(doc.fragments[0]).to include("<fragment")
222
+ expect(doc.fragments[0]).to include("first")
223
+ expect(doc.fragments[1]).to include("second")
224
+ expect(doc.fragments[2]).to include("<nested>third</nested>")
225
+ end
226
+ end
227
+
228
+ describe "missing raw element" do
229
+ it "returns nil when raw element is absent" do
230
+ doc = SvgContainer.from_xml("<container><other>text</other></container>")
231
+ expect(doc.svg_data).to be_nil
232
+ end
233
+ end
234
+
235
+ describe "empty raw element" do
236
+ it "captures self-closing empty element" do
237
+ doc = EmptyRawContainer.from_xml("<container><name>test</name><foreign/></container>")
238
+ expect(doc.embedded).to include("<foreign")
239
+ expect(doc.embedded).to include("/")
240
+ end
241
+ end
242
+
243
+ describe "XML special characters" do
244
+ it "preserves entities in raw element content" do
245
+ xml = "<container><foreign>a &amp; b &lt; c</foreign></container>"
246
+ doc = MixedContainer.from_xml("<container><name>test</name>#{xml.match(/<container>(.*)<\/container>/m)[1]}</container>")
247
+ expect(doc.embedded).to include("<foreign")
248
+ end
249
+ end
250
+
251
+ describe "namespace matching" do
252
+ it "captures element with xmlns declaration on itself" do
253
+ doc = SvgContainer.from_xml(
254
+ '<container><svg xmlns="http://www.w3.org/2000/svg"><rect/></svg></container>',
255
+ )
256
+ expect(doc.svg_data).to include("<svg")
257
+ expect(doc.svg_data).to include('xmlns="http://www.w3.org/2000/svg"')
258
+ end
259
+
260
+ it "captures element with no namespace" do
261
+ doc = SvgContainer.from_xml(
262
+ "<container><svg><rect/></svg></container>",
263
+ )
264
+ expect(doc.svg_data).to include("<svg")
265
+ expect(doc.svg_data).to include("<rect")
266
+ end
267
+
268
+ it "captures explicitly prefixed element" do
269
+ doc = SvgContainer.from_xml(
270
+ '<container xmlns:s="http://www.w3.org/2000/svg"><s:svg><s:rect/></s:svg></container>',
271
+ )
272
+ expect(doc.svg_data).to include("<s:svg")
273
+ expect(doc.svg_data).to include("<s:rect")
274
+ end
275
+ end
276
+ end
277
+
278
+ describe "serialization" do
279
+ describe "round-trip with SVG" do
280
+ let(:svg_xml) do
281
+ <<~XML
282
+ <container>
283
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
284
+ <rect x="0" y="0" width="100" height="100" fill="red"/>
285
+ </svg>
286
+ </container>
287
+ XML
288
+ end
289
+
290
+ it "preserves SVG data through parse -> serialize -> parse" do
291
+ doc = SvgContainer.from_xml(svg_xml)
292
+ output = doc.to_xml
293
+ doc2 = SvgContainer.from_xml(output)
294
+ expect(doc2.svg_data).to include("<svg")
295
+ expect(doc2.svg_data).to include("</svg>")
296
+ expect(doc2.svg_data).to include("viewBox")
297
+ expect(doc2.svg_data).to include("<rect")
298
+ expect(doc2.svg_data).to include("xmlns=\"http://www.w3.org/2000/svg\"")
299
+ end
300
+ end
301
+
302
+ describe "round-trip with mixed content" do
303
+ let(:mixed_xml) do
304
+ '<container><name>test</name><foreign attr="value"><child>content</child></foreign></container>'
305
+ end
306
+
307
+ it "preserves both normal and raw elements" do
308
+ doc = MixedContainer.from_xml(mixed_xml)
309
+ output = doc.to_xml
310
+ doc2 = MixedContainer.from_xml(output)
311
+ expect(doc2.name).to eq("test")
312
+ expect(doc2.embedded).to include("<foreign")
313
+ expect(doc2.embedded).to include("attr=\"value\"")
314
+ expect(doc2.embedded).to include("<child>content</child>")
315
+ end
316
+ end
317
+
318
+ describe "round-trip with nested raw XML" do
319
+ let(:nested_xml) do
320
+ '<container><name>test</name><embedded><level1><level2 attr="deep">text</level2></level1></embedded></container>'
321
+ end
322
+
323
+ it "preserves full nesting depth" do
324
+ doc = NestedRawContainer.from_xml(nested_xml)
325
+ output = doc.to_xml
326
+ doc2 = NestedRawContainer.from_xml(output)
327
+ expect(doc2.embedded).to include("<level1>")
328
+ expect(doc2.embedded).to include("<level2 attr=\"deep\">text</level2>")
329
+ expect(doc2.embedded).to include("</level1>")
330
+ end
331
+ end
332
+
333
+ describe "building from model instance" do
334
+ it "serializes a programmatically created model" do
335
+ model = SvgContainer.new(svg_data: '<svg xmlns="http://www.w3.org/2000/svg"><circle r="5"/></svg>')
336
+ xml = model.to_xml
337
+ expect(xml).to include("<svg")
338
+ expect(xml).to include("<circle")
339
+ expect(xml).to include("</svg>")
340
+ end
341
+
342
+ it "round-trips a programmatically created model" do
343
+ original_svg = '<svg xmlns="http://www.w3.org/2000/svg"><circle r="5"/></svg>'
344
+ model = SvgContainer.new(svg_data: original_svg)
345
+ xml = model.to_xml
346
+ doc = SvgContainer.from_xml(xml)
347
+ expect(doc.svg_data).to include("<svg")
348
+ expect(doc.svg_data).to include("<circle")
349
+ expect(doc.svg_data).to include("</svg>")
350
+ end
351
+
352
+ it "does not double-escape raw element content" do
353
+ model = SvgContainer.new(svg_data: "<svg><rect/></svg>")
354
+ xml = model.to_xml
355
+ expect(xml).not_to include("&lt;svg")
356
+ expect(xml).not_to include("&gt;")
357
+ end
358
+ end
359
+
360
+ describe "round-trip with collection" do
361
+ let(:collection_xml) do
362
+ <<~XML
363
+ <container>
364
+ <name>multi</name>
365
+ <fragment id="1">first</fragment>
366
+ <fragment id="2">second</fragment>
367
+ </container>
368
+ XML
369
+ end
370
+
371
+ it "preserves all collection items" do
372
+ doc = CollectionRawContainer.from_xml(collection_xml)
373
+ output = doc.to_xml
374
+ doc2 = CollectionRawContainer.from_xml(output)
375
+ expect(doc2.fragments.length).to eq(2)
376
+ expect(doc2.fragments[0]).to include("first")
377
+ expect(doc2.fragments[1]).to include("second")
378
+ end
379
+ end
380
+
381
+ describe "nil and empty values" do
382
+ it "omits raw element when value is nil" do
383
+ model = SvgContainer.new(svg_data: nil)
384
+ xml = model.to_xml
385
+ expect(xml).not_to include("<svg")
386
+ end
387
+
388
+ it "omits raw element when value is empty string" do
389
+ model = SvgContainer.new(svg_data: "")
390
+ xml = model.to_xml
391
+ expect(xml).not_to include("<svg")
392
+ end
393
+ end
394
+
395
+ describe "XML special characters round-trip" do
396
+ it "preserves angle brackets in raw element content without double-escaping" do
397
+ doc = MixedContainer.from_xml(
398
+ '<container><name>test</name><foreign><child a="1">x &amp; y</child></foreign></container>',
399
+ )
400
+ output = doc.to_xml
401
+ expect(output).to include("<child")
402
+ expect(output).not_to include("&lt;child")
403
+ doc2 = MixedContainer.from_xml(output)
404
+ expect(doc2.embedded).to include("<child")
405
+ expect(doc2.embedded).to include("x &amp; y")
406
+ end
407
+ end
408
+ end
409
+
410
+ describe "default behavior (no raw)" do
411
+ it "does not capture raw XML for normal map_element" do
412
+ doc = NormalContainer.from_xml("<container><data>text</data></container>")
413
+ expect(doc.data).to eq("text")
414
+ expect(doc.data).not_to include("<data>")
415
+ end
416
+
417
+ it "serializes text content normally" do
418
+ model = NormalContainer.new(data: "hello")
419
+ xml = model.to_xml
420
+ expect(xml).to include("<data>hello</data>")
421
+ expect(xml).not_to include("&lt;")
422
+ end
423
+ end
424
+
425
+ describe "MappingRule attributes" do
426
+ it "defaults raw to nil" do
427
+ rule = MixedContainer.mappings_for(:xml).elements.find do |r|
428
+ r.to == :name
429
+ end
430
+ expect(rule.raw).to be_nil
431
+ end
432
+
433
+ it "sets raw to :element when specified" do
434
+ rule = MixedContainer.mappings_for(:xml).elements.find do |r|
435
+ r.to == :embedded
436
+ end
437
+ expect(rule.raw).to eq(:element)
438
+ end
439
+
440
+ it "provides raw_element backward-compat alias" do
441
+ rule = MixedContainer.mappings_for(:xml).elements.find do |r|
442
+ r.to == :embedded
443
+ end
444
+ expect(rule.raw_element?).to be(true)
445
+ end
446
+
447
+ it "propagates raw through deep_dup" do
448
+ rule = MixedContainer.mappings_for(:xml).elements.find do |r|
449
+ r.to == :embedded
450
+ end
451
+ dup = rule.deep_dup
452
+ expect(dup.raw).to eq(:element)
453
+ end
454
+ end
455
+
456
+ describe "raw parameter validation" do
457
+ it "accepts :element" do
458
+ expect do
459
+ Lutaml::Xml::MappingRule.new("test", to: :x, raw: :element)
460
+ end.not_to raise_error
461
+ end
462
+
463
+ it "accepts :content" do
464
+ expect do
465
+ Lutaml::Xml::MappingRule.new("test", to: :x, raw: :content)
466
+ end.not_to raise_error
467
+ end
468
+
469
+ it "accepts nil" do
470
+ rule = Lutaml::Xml::MappingRule.new("test", to: :x, raw: nil)
471
+ expect(rule.raw).to be_nil
472
+ end
473
+
474
+ it "rejects invalid values" do
475
+ expect do
476
+ Lutaml::Xml::MappingRule.new("test", to: :x, raw: :invalid)
477
+ end.to raise_error(ArgumentError, /raw must be :element or :content/)
478
+ end
479
+
480
+ it "deprecates raw: true with warning" do
481
+ expect do
482
+ Lutaml::Xml::MappingRule.new("test", to: :x, raw: true)
483
+ end.to output(/DEPRECATED.*raw: :element/).to_stderr
484
+ end
485
+
486
+ it "treats raw: true as :element" do
487
+ rule = nil
488
+ expect do
489
+ rule = Lutaml::Xml::MappingRule.new("test", to: :x, raw: true)
490
+ end.to output(/DEPRECATED/).to_stderr
491
+ expect(rule.raw).to eq(:element)
492
+ end
493
+ end
494
+ end
495
+
496
+ # raw: :content mode — inner XML capture
497
+ module RawContentSpec
498
+ class StreetContainer < Lutaml::Model::Serializable
499
+ attribute :street, :string
500
+
501
+ xml do
502
+ element "address"
503
+ map_element "street", to: :street, raw: :content
504
+ end
505
+ end
506
+
507
+ RSpec.describe "map_element raw: :content" do
508
+ describe "deserialization" do
509
+ it "captures inner XML content" do
510
+ doc = StreetContainer.from_xml("<address><street><b>123</b> Main St</street></address>")
511
+ expect(doc.street).to include("<b>123</b>")
512
+ expect(doc.street).to include("Main St")
513
+ end
514
+
515
+ it "does not include wrapper tags" do
516
+ doc = StreetContainer.from_xml("<address><street><b>123</b> Main St</street></address>")
517
+ expect(doc.street).not_to include("<street>")
518
+ expect(doc.street).not_to include("</street>")
519
+ end
520
+ end
521
+
522
+ describe "serialization" do
523
+ it "round-trips inner XML content" do
524
+ doc = StreetContainer.from_xml("<address><street><b>123</b> Main St</street></address>")
525
+ output = doc.to_xml
526
+ doc2 = StreetContainer.from_xml(output)
527
+ expect(doc2.street).to include("<b>123</b>")
528
+ expect(doc2.street).to include("Main St")
529
+ end
530
+ end
531
+ end
532
+ end
533
+ end
@@ -46,6 +46,54 @@ RSpec.describe Lutaml::Rdf::Mapping do
46
46
  end
47
47
  end
48
48
 
49
+ describe "#types" do
50
+ it "stores multiple types from splat arguments" do
51
+ mapping.types("skos:Concept", "dcterms:Agent")
52
+ expect(mapping.rdf_type).to eq(["skos:Concept", "dcterms:Agent"])
53
+ end
54
+
55
+ it "stores single type" do
56
+ mapping.types("skos:Concept")
57
+ expect(mapping.rdf_type).to eq(["skos:Concept"])
58
+ end
59
+
60
+ it "flattens nested arrays" do
61
+ mapping.types(["skos:Concept", "owl:Thing"], "foaf:Person")
62
+ expect(mapping.rdf_type).to eq(["skos:Concept", "owl:Thing",
63
+ "foaf:Person"])
64
+ end
65
+
66
+ it "overwrites previous types on subsequent call" do
67
+ mapping.types("skos:Concept")
68
+ mapping.types("dcterms:Agent")
69
+ expect(mapping.rdf_type).to eq(["dcterms:Agent"])
70
+ end
71
+ end
72
+
73
+ describe "#has_types_or_predicates?" do
74
+ it "returns false when no types or predicates" do
75
+ expect(mapping.has_types_or_predicates?).to be(false)
76
+ end
77
+
78
+ it "returns true when types are present" do
79
+ mapping.type("skos:Concept")
80
+ expect(mapping.has_types_or_predicates?).to be(true)
81
+ end
82
+
83
+ it "returns true when predicates are present" do
84
+ mapping.predicate(:prefLabel,
85
+ namespace: Lutaml::Rdf::Namespaces::SkosNamespace, to: :name)
86
+ expect(mapping.has_types_or_predicates?).to be(true)
87
+ end
88
+
89
+ it "returns true when both types and predicates present" do
90
+ mapping.type("skos:Concept")
91
+ mapping.predicate(:prefLabel,
92
+ namespace: Lutaml::Rdf::Namespaces::SkosNamespace, to: :name)
93
+ expect(mapping.has_types_or_predicates?).to be(true)
94
+ end
95
+ end
96
+
49
97
  describe "#predicate" do
50
98
  it "creates MappingRule with namespace reference" do
51
99
  mapping.predicate(
@@ -128,13 +176,13 @@ RSpec.describe Lutaml::Rdf::Mapping do
128
176
  end
129
177
 
130
178
  describe "#members" do
131
- it "creates MemberRule" do
179
+ it "creates MemberRule without linking predicate" do
132
180
  mapping.members(:items)
133
181
  expect(mapping.rdf_members.length).to eq(1)
134
182
  expect(mapping.rdf_members.first.attr_name).to eq(:items)
135
183
  end
136
184
 
137
- it "creates MemberRule with linking predicate" do
185
+ it "creates MemberRule with static linking predicate" do
138
186
  mapping.members(:items,
139
187
  predicate_name: :member,
140
188
  namespace: Lutaml::Rdf::Namespaces::SkosNamespace)
@@ -143,11 +191,19 @@ RSpec.describe Lutaml::Rdf::Mapping do
143
191
  expect(rule.linked_predicate_uri).to eq("http://www.w3.org/2004/02/skos/core#member")
144
192
  end
145
193
 
146
- it "creates MemberRule without linking predicate" do
147
- mapping.members(:items)
194
+ it "creates MemberRule with link as String" do
195
+ mapping.members(:items, link: "skos:member")
148
196
  rule = mapping.rdf_members.first
149
- expect(rule.linked?).to be(false)
150
- expect(rule.linked_predicate_uri).to be_nil
197
+ expect(rule.linked?).to be(true)
198
+ expect(rule.link).to eq("skos:member")
199
+ end
200
+
201
+ it "creates MemberRule with link as Proc" do
202
+ resolver = ->(item) { "skos:#{item.type}" }
203
+ mapping.members(:items, link: resolver)
204
+ rule = mapping.rdf_members.first
205
+ expect(rule.linked?).to be(true)
206
+ expect(rule.link).to eq(resolver)
151
207
  end
152
208
 
153
209
  it "raises when predicate_name given without namespace" do
@@ -155,6 +211,15 @@ RSpec.describe Lutaml::Rdf::Mapping do
155
211
  mapping.members(:items, predicate_name: :member)
156
212
  end.to raise_error(ArgumentError, /namespace is required/)
157
213
  end
214
+
215
+ it "raises when predicate_name and link both given" do
216
+ expect do
217
+ mapping.members(:items,
218
+ predicate_name: :member,
219
+ namespace: Lutaml::Rdf::Namespaces::SkosNamespace,
220
+ link: "skos:member")
221
+ end.to raise_error(ArgumentError, /mutually exclusive/)
222
+ end
158
223
  end
159
224
 
160
225
  describe "#mappings" do