lutaml-model 0.8.14 → 0.8.15

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e6e4dad188f815cf35cc931ab16476a0af158de6c64150f6733ffc5a2683e04a
4
- data.tar.gz: 7ae7d3fc7009a3ac3031f37a4d11786ec503c8ac7d13122b3d03dba6df9f5296
3
+ metadata.gz: 8d6ce7467101e1a845bcd0bfb98b0e878d55d440065a12481576a8aa5c050c98
4
+ data.tar.gz: e534138d7ca3848eaa33681e286584ea385386734617f37c5fa89a7c3f752f79
5
5
  SHA512:
6
- metadata.gz: 0a8a4b84223eb10df5ecc114ee97b2551d62eb7f5550ce73903a90fd8a0b35f236ced31751bbff670f0a46d25c5e0ca3dff805fd370ada00b0b44359fcaf5c48
7
- data.tar.gz: 769f97b0a540b0e1a365bc1ceeeab8b5b45f046356e903d1fd71a52b0f13b8f2b720249a211ad5cbd7e579a6ecd9c1c4a9f981bf4ec374d5a81cfc4377abb3f0
6
+ metadata.gz: 8e872c659c1e147598fffe00ea015ed07dbd16b795c254f4aed6df30d2c6f2c91ab51740f5112b93e67d113d5b7d7f4edef755a2d6fa848053ab885533ac1336
7
+ data.tar.gz: 22a484ca450a64ff4b3b0d426f1982e0e17d90eb422534158cf02ca65b96cc5293f401fec9cbbaa39a0ccaa06c0ef987b9a7f0f02312bc6fd55233b1666191ad
data/Gemfile CHANGED
@@ -5,14 +5,12 @@ source "https://rubygems.org"
5
5
  # Specify your gem's dependencies in lutaml-model.gemspec
6
6
  gemspec
7
7
 
8
- gem "moxml", git: "https://github.com/lutaml/moxml", branch: "main"
9
-
10
8
  # needed for liquid with ruby 3.4
11
9
  gem "base64"
12
10
  gem "benchmark-ips"
13
11
  gem "bigdecimal"
14
12
  gem "canon" # , path: "../canon"
15
- gem "json-ld", "~> 3.3"
13
+ gem "json-ld"
16
14
  gem "liquid", "~> 5"
17
15
  gem "multi_json"
18
16
  gem "nokogiri"
@@ -21,7 +19,7 @@ gem "oj"
21
19
  gem "openssl", "~> 3.0"
22
20
  gem "ox"
23
21
  gem "rake"
24
- gem "rdf-turtle", "~> 3.3"
22
+ gem "rdf-turtle"
25
23
  gem "rexml"
26
24
  gem "rspec"
27
25
  gem "rubocop"
@@ -33,4 +31,4 @@ gem "toml-rb"
33
31
 
34
32
  # ruby-prof works on all platforms including Windows (unlike stackprof)
35
33
  # Provides both CPU and memory profiling
36
- gem "ruby-prof", "~> 2.0", group: :development
34
+ gem "ruby-prof", group: :development
@@ -45,6 +45,12 @@ module Lutaml
45
45
  build_instance(attrs, options)
46
46
  end
47
47
 
48
+ protected
49
+
50
+ def additional_resource_data(_instance, _mapping)
51
+ {}
52
+ end
53
+
48
54
  private
49
55
 
50
56
  def extract_mapping(options)
@@ -52,28 +58,32 @@ module Lutaml
52
58
  end
53
59
 
54
60
  def build_graph_document(mapping, instance)
55
- context = build_merged_context(mapping, instance)
61
+ context = build_merged_context_recursive(mapping, instance)
62
+ graph = collect_resources(mapping, instance)
63
+
64
+ { "@context" => context, "@graph" => graph }
65
+ end
66
+
67
+ def collect_resources(mapping, instance)
56
68
  graph = []
57
69
 
58
- if mapping.rdf_subject
59
- resource = build_resource_data(mapping, instance)
60
- graph << resource unless resource.empty?
61
- end
70
+ resource = build_resource_data(mapping, instance)
71
+ graph << resource unless resource.empty?
62
72
 
63
73
  mapping.rdf_members.each do |member_rule|
64
74
  each_member(instance, member_rule) do |member|
65
75
  member_mapping = member_mapping_for(member, :jsonld)
66
76
  next unless member_mapping
67
77
 
68
- resource = build_resource_data(member_mapping, member)
69
- graph << resource unless resource.empty?
78
+ child_resources = collect_resources(member_mapping, member)
79
+ graph.concat(child_resources)
70
80
  end
71
81
  end
72
82
 
73
- { "@context" => context, "@graph" => graph }
83
+ graph
74
84
  end
75
85
 
76
- def build_merged_context(mapping, instance)
86
+ def build_merged_context_recursive(mapping, instance)
77
87
  context_hash = build_context_from_mapping(mapping).to_hash
78
88
 
79
89
  mapping.rdf_members.each do |member_rule|
@@ -82,6 +92,9 @@ module Lutaml
82
92
  next unless member_mapping
83
93
 
84
94
  context_hash.merge!(build_context_from_mapping(member_mapping).to_hash)
95
+
96
+ child_ctx = build_merged_context_recursive(member_mapping, member)
97
+ context_hash.merge!(child_ctx)
85
98
  end
86
99
  end
87
100
 
@@ -100,9 +113,14 @@ module Lutaml
100
113
  mapping.rdf_members.each do |member_rule|
101
114
  next unless member_rule.linked?
102
115
 
103
- context.term(member_rule.predicate_name.to_s,
104
- id: member_rule.linked_predicate_uri,
105
- type: "@id")
116
+ predicate_uri = if member_rule.predicate_name
117
+ member_rule.linked_predicate_uri
118
+ end
119
+ if predicate_uri
120
+ context.term(member_rule.predicate_name.to_s,
121
+ id: predicate_uri,
122
+ type: "@id")
123
+ end
106
124
  end
107
125
 
108
126
  context
@@ -151,9 +169,12 @@ module Lutaml
151
169
  member_refs = collect_member_references(instance, member_rule)
152
170
  next if member_refs.empty?
153
171
 
154
- result[member_rule.predicate_name.to_s] = member_refs
172
+ key = jsonld_member_key(member_rule)
173
+ result[key] = member_refs
155
174
  end
156
175
 
176
+ result.merge!(additional_resource_data(instance, mapping))
177
+
157
178
  result
158
179
  end
159
180
 
@@ -168,6 +189,16 @@ module Lutaml
168
189
  refs
169
190
  end
170
191
 
192
+ def jsonld_member_key(member_rule)
193
+ if member_rule.predicate_name
194
+ member_rule.predicate_name.to_s
195
+ elsif member_rule.link.is_a?(String)
196
+ member_rule.link.split(":").last
197
+ else
198
+ member_rule.attr_name.to_s
199
+ end
200
+ end
201
+
171
202
  def serialize_value(value, rule)
172
203
  case rule.kind
173
204
  when :uri_reference then serialize_uri_reference(value)
@@ -54,6 +54,15 @@ module Lutaml
54
54
  value.to_liquid
55
55
  end
56
56
  end
57
+
58
+ def liquid_method_missing(method)
59
+ if @object.is_a?(::Lutaml::Model::Liquid::IndexedAccess)
60
+ result = @object.liquid_fetch(method)
61
+ result.nil? ? super : liquefy_value(result)
62
+ else
63
+ super
64
+ end
65
+ end
57
66
  end)
58
67
  end
59
68
 
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Model
5
+ module Liquid
6
+ # Module for Lutaml::Model objects that support bracket-based
7
+ # lookup in Liquid templates (e.g., collections with index or key access).
8
+ #
9
+ # Include this module in any Serializable subclass that supports
10
+ # +self[key]+ so that its auto-generated Liquid drop can delegate
11
+ # +drop[key]+ through to the underlying object.
12
+ #
13
+ # Example:
14
+ # class Glossarist::Collections::LocalizationCollection
15
+ # include Lutaml::Model::Liquid::IndexedAccess
16
+ # # ...
17
+ # end
18
+ #
19
+ # drop['eng'] #=> calls drop.liquid_method_missing('eng')
20
+ # #=> calls @object.liquid_fetch('eng')
21
+ # #=> calls @object['eng']
22
+ # #=> returns the localized concept drop
23
+ module IndexedAccess
24
+ # Called by the auto-generated Liquid drop via +liquid_method_missing+.
25
+ # Delegates to +self[key]+ by default. Override in specific classes
26
+ # for custom lookup behavior.
27
+ def liquid_fetch(key)
28
+ self[key]
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -3,6 +3,7 @@
3
3
  module Lutaml
4
4
  module Model
5
5
  module Liquid
6
+ autoload :IndexedAccess, "#{__dir__}/liquid/indexed_access"
6
7
  autoload :Mapping, "#{__dir__}/liquid/mapping"
7
8
  end
8
9
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Model
5
- VERSION = "0.8.14"
5
+ VERSION = "0.8.15"
6
6
  end
7
7
  end
@@ -27,6 +27,14 @@ module Lutaml
27
27
  @rdf_type = Array(value)
28
28
  end
29
29
 
30
+ def types(*values)
31
+ @rdf_type = values.flatten
32
+ end
33
+
34
+ def has_types_or_predicates?
35
+ @rdf_type.any? || @rdf_predicates.any?
36
+ end
37
+
30
38
  def predicate(name, namespace:, to:, lang_tagged: false,
31
39
  uri_reference: false)
32
40
  @rdf_predicates << Lutaml::Rdf::MappingRule.new(
@@ -38,11 +46,12 @@ module Lutaml
38
46
  )
39
47
  end
40
48
 
41
- def members(attr_name, predicate_name: nil, namespace: nil)
49
+ def members(attr_name, predicate_name: nil, namespace: nil, link: nil)
42
50
  @rdf_members << Lutaml::Rdf::MemberRule.new(
43
51
  attr_name,
44
52
  predicate_name: predicate_name,
45
53
  namespace: namespace,
54
+ link: link,
46
55
  )
47
56
  end
48
57
 
@@ -3,28 +3,53 @@
3
3
  module Lutaml
4
4
  module Rdf
5
5
  class MemberRule
6
- attr_reader :attr_name, :predicate_name, :namespace
6
+ attr_reader :attr_name, :predicate_name, :namespace, :link
7
7
 
8
- def initialize(attr_name, predicate_name: nil, namespace: nil)
8
+ def initialize(attr_name, predicate_name: nil, namespace: nil, link: nil)
9
9
  if predicate_name && !namespace
10
10
  raise ArgumentError,
11
11
  "namespace is required when predicate_name is provided"
12
12
  end
13
13
 
14
+ if predicate_name && link
15
+ raise ArgumentError,
16
+ "predicate_name and link are mutually exclusive"
17
+ end
18
+
14
19
  @attr_name = attr_name.to_sym
15
20
  @predicate_name = predicate_name
16
21
  @namespace = namespace
22
+ @link = link
17
23
  end
18
24
 
19
25
  def linked?
20
- !!@predicate_name
26
+ !!(@predicate_name || @link)
21
27
  end
22
28
 
23
29
  def linked_predicate_uri
24
- return nil unless linked?
30
+ return nil unless @predicate_name
25
31
 
26
32
  @namespace[@predicate_name]
27
33
  end
34
+
35
+ def link_predicate_for(member, resolver)
36
+ return nil unless @link
37
+
38
+ case @link
39
+ when String
40
+ resolver.call(@link)
41
+ when Proc
42
+ resolver.call(@link.call(member))
43
+ end
44
+ end
45
+
46
+ def resolve_link_uri(member, resolver)
47
+ if @predicate_name
48
+ linked_predicate_uri
49
+ elsif @link
50
+ link_predicate_for(member, resolver)
51
+ end
52
+ end
28
53
  end
29
54
  end
30
55
  end
@@ -10,7 +10,7 @@ module Lutaml
10
10
  mapping = extract_turtle_mapping(options)
11
11
  return "" unless mapping
12
12
 
13
- if !mapping.rdf_subject && mapping.rdf_predicates.any? && mapping.rdf_members.empty?
13
+ if !mapping.rdf_subject && mapping.has_types_or_predicates? && mapping.rdf_members.empty?
14
14
  raise MissingSubjectError,
15
15
  "Turtle mapping requires a subject block"
16
16
  end
@@ -18,7 +18,7 @@ module Lutaml
18
18
  graph = build_graph(mapping, instance)
19
19
  return "" if graph.empty?
20
20
 
21
- prefixes = build_prefixes(mapping, instance)
21
+ prefixes = collect_all_prefixes(mapping, instance)
22
22
  RDF::Turtle::Writer.buffer(prefixes: prefixes) do |writer|
23
23
  graph.each_statement { |stmt| writer << stmt }
24
24
  end.strip
@@ -37,6 +37,12 @@ module Lutaml
37
37
  build_instance(attrs, options)
38
38
  end
39
39
 
40
+ protected
41
+
42
+ def additional_resource_triples(_instance, _subject_uri, _mapping)
43
+ []
44
+ end
45
+
40
46
  private
41
47
 
42
48
  def extract_turtle_mapping(options)
@@ -47,63 +53,69 @@ module Lutaml
47
53
  graph = RDF::Graph.new
48
54
 
49
55
  has_resource_data =
50
- mapping.rdf_type.any? ||
51
- mapping.rdf_predicates.any? ||
56
+ mapping.has_types_or_predicates? ||
52
57
  mapping.rdf_members.any?(&:linked?)
53
58
 
54
59
  if has_resource_data
55
- subject_uri = resolve_subject(mapping, instance)
56
- build_resource_triples(graph, mapping, instance, subject_uri)
60
+ subject_uri = if mapping.rdf_subject
61
+ RDF::URI(resolve_subject_uri(mapping, instance))
62
+ else
63
+ RDF::Node.new
64
+ end
65
+
66
+ emit_type_statements(graph, subject_uri, mapping)
67
+ emit_predicate_statements(graph, subject_uri, instance, mapping)
68
+ emit_member_link_statements(graph, subject_uri, instance, mapping)
69
+ additional_resource_triples(instance, subject_uri, mapping).each do |stmt|
70
+ graph << stmt
71
+ end
57
72
  end
58
73
 
59
- build_member_subgraphs(graph, mapping, instance)
74
+ emit_child_resources(graph, instance, mapping)
60
75
 
61
76
  graph
62
77
  end
63
78
 
64
- def resolve_subject(mapping, instance)
65
- if mapping.rdf_subject
66
- RDF::URI(resolve_subject_uri(mapping, instance))
67
- else
68
- RDF::Node.new
69
- end
70
- end
71
-
72
- def build_resource_triples(graph, mapping, instance, subject_uri)
79
+ def emit_type_statements(graph, subject_uri, mapping)
73
80
  mapping.rdf_type.each do |type_value|
74
81
  type_uri = RDF::URI(resolve_single_type_uri(mapping, type_value))
75
82
  graph << RDF::Statement.new(subject_uri, RDF.type, type_uri)
76
83
  end
84
+ end
77
85
 
86
+ def emit_predicate_statements(graph, subject_uri, instance, mapping)
78
87
  mapping.rdf_predicates.each do |rule|
79
88
  value = instance.public_send(rule.to)
80
89
  next if value.nil?
81
90
 
82
91
  Array(value).each do |v|
92
+ next if v.is_a?(String) && v.empty?
93
+
83
94
  object = build_rdf_object(v, rule, mapping.namespace_set)
84
95
  graph << RDF::Statement.new(subject_uri, RDF::URI(rule.uri), object)
85
96
  end
86
97
  end
98
+ end
87
99
 
100
+ def emit_member_link_statements(graph, subject_uri, instance, mapping)
88
101
  mapping.rdf_members.each do |member_rule|
89
102
  next unless member_rule.linked?
90
103
 
91
104
  each_member(instance, member_rule) do |member|
92
105
  member_mapping = member_mapping_for(member, :turtle)
93
- next unless member_mapping
106
+ next unless member_mapping&.rdf_subject
107
+
108
+ child_uri = RDF::URI(resolve_subject_uri(member_mapping, member))
109
+ resolver = mapping.namespace_set.method(:resolve_compact_iri)
110
+ link_uri = RDF::URI(member_rule.resolve_link_uri(member, resolver))
111
+ next unless link_uri
94
112
 
95
- member_subject = RDF::URI(resolve_subject_uri(member_mapping,
96
- member))
97
- graph << RDF::Statement.new(
98
- subject_uri,
99
- RDF::URI(member_rule.linked_predicate_uri),
100
- member_subject,
101
- )
113
+ graph << RDF::Statement.new(subject_uri, link_uri, child_uri)
102
114
  end
103
115
  end
104
116
  end
105
117
 
106
- def build_member_subgraphs(graph, mapping, instance)
118
+ def emit_child_resources(graph, instance, mapping)
107
119
  mapping.rdf_members.each do |member_rule|
108
120
  each_member(instance, member_rule) do |member|
109
121
  member_mapping = member_mapping_for(member, :turtle)
@@ -144,7 +156,14 @@ module Lutaml
144
156
  end
145
157
  end
146
158
 
147
- def build_prefixes(mapping, instance)
159
+ def collect_all_prefixes(mapping, instance)
160
+ ns_set = collect_namespaces_recursive(mapping, instance)
161
+ ns_set.each.with_object({}) do |ns, h|
162
+ h[ns.prefix.to_sym] = ns.uri if ns.prefix && ns.uri
163
+ end
164
+ end
165
+
166
+ def collect_namespaces_recursive(mapping, instance)
148
167
  ns_set = mapping.namespace_set
149
168
 
150
169
  mapping.rdf_members.each do |member_rule|
@@ -153,12 +172,12 @@ module Lutaml
153
172
  next unless member_mapping
154
173
 
155
174
  ns_set = ns_set.merge(member_mapping.namespace_set)
175
+ child_ns = collect_namespaces_recursive(member_mapping, member)
176
+ ns_set = ns_set.merge(child_ns)
156
177
  end
157
178
  end
158
179
 
159
- ns_set.each.with_object({}) do |ns, h|
160
- h[ns.prefix.to_sym] = ns.uri if ns.prefix && ns.uri
161
- end
180
+ ns_set
162
181
  end
163
182
 
164
183
  def extract_attributes(graph, mapping)
@@ -175,6 +194,12 @@ module Lutaml
175
194
  attrs
176
195
  end
177
196
 
197
+ def find_subjects_by_types(graph, type_uris)
198
+ type_uris.flat_map do |type_uri|
199
+ graph.query([nil, RDF.type, RDF::URI(type_uri)]).map(&:subject).uniq
200
+ end.uniq
201
+ end
202
+
178
203
  def extract_predicate_attributes(graph, subject, mapping, attrs)
179
204
  mapping.rdf_predicates.each do |rule|
180
205
  stmts = graph.query([subject, RDF::URI(rule.uri), nil])
@@ -187,12 +212,6 @@ module Lutaml
187
212
  end
188
213
  end
189
214
 
190
- def find_subjects_by_types(graph, type_uris)
191
- type_uris.flat_map do |type_uri|
192
- graph.query([nil, RDF.type, RDF::URI(type_uri)]).map(&:subject).uniq
193
- end.uniq
194
- end
195
-
196
215
  def literal_to_ruby(rdf_object, rule, namespace_set)
197
216
  case rdf_object
198
217
  when RDF::URI
@@ -221,7 +221,7 @@ module Lutaml
221
221
  end
222
222
 
223
223
  def text_content_for_xml(value)
224
- ::Moxml::Adapter::Base.preprocess_entities(value.to_s)
224
+ ::Moxml.preprocess_entities(value.to_s)
225
225
  end
226
226
 
227
227
  def build_plan_node(xml, xml_element, element_node, plan: nil,
data/lutaml-model.gemspec CHANGED
@@ -36,7 +36,7 @@ Gem::Specification.new do |spec|
36
36
  spec.add_dependency "canon"
37
37
  spec.add_dependency "concurrent-ruby"
38
38
  spec.add_dependency "liquid", ">= 4.0", "< 6.0"
39
- spec.add_dependency "moxml", ">= 0.1.22"
39
+ spec.add_dependency "moxml", "~> 0.1.23"
40
40
  spec.add_dependency "ostruct"
41
41
  spec.add_dependency "rubyzip", "~> 2.3"
42
42
  spec.add_dependency "thor"
@@ -447,4 +447,151 @@ 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 { |r| r["@type"] == "skos:Collection" }
496
+ expect(parent_resource["member"]).to eq([
497
+ { "@id" => "http://example.org/item/a" },
498
+ { "@id" => "http://example.org/item/b" },
499
+ ])
500
+ end
501
+
502
+ it "includes member resources in @graph" do
503
+ parent = JsonLdDynParent.new(
504
+ name: "grp1",
505
+ children: [JsonLdDynChild.new(cid: "a", label: "Alpha")],
506
+ )
507
+ parsed = JSON.parse(parent.to_jsonld)
508
+ member = parsed["@graph"].find { |r| r["prefLabel"] == "Alpha" }
509
+ expect(member).not_to be_nil
510
+ end
511
+ end
512
+
513
+ describe "recursive context and resource collection" do
514
+ before do
515
+ stub_const("JsonLdLeaf", Class.new(Lutaml::Model::Serializable) do
516
+ attribute :value, :string
517
+ attribute :lid, :string
518
+
519
+ rdf do
520
+ namespace TestExNs
521
+
522
+ subject { |m| "http://example.org/leaf/#{m.lid}" } # rubocop:disable RSpec/NamedSubject, RSpec/MultipleSubjects
523
+
524
+ type "ex:Leaf"
525
+
526
+ predicate :name, namespace: TestExNs, to: :value
527
+ end
528
+ end)
529
+
530
+ stub_const("JsonLdMid", Class.new(Lutaml::Model::Serializable) do
531
+ attribute :label, :string
532
+ attribute :mid, :string
533
+ attribute :leaves, JsonLdLeaf, collection: true
534
+
535
+ rdf do
536
+ namespace TestSkosNs, TestExNs
537
+
538
+ subject { |m| "http://example.org/mid/#{m.mid}" } # rubocop:disable RSpec/NamedSubject, RSpec/MultipleSubjects
539
+
540
+ type "skos:Concept"
541
+
542
+ predicate :prefLabel, namespace: TestSkosNs, to: :label
543
+
544
+ members :leaves, link: "skos:member"
545
+ end
546
+ end)
547
+
548
+ stub_const("JsonLdRoot", Class.new(Lutaml::Model::Serializable) do
549
+ attribute :title, :string
550
+ attribute :mids, JsonLdMid, collection: true
551
+
552
+ rdf do
553
+ namespace TestSkosNs
554
+
555
+ subject { |m| "http://example.org/root/#{m.title}" } # rubocop:disable RSpec/NamedSubject, RSpec/MultipleSubjects
556
+
557
+ type "skos:Collection"
558
+
559
+ predicate :prefLabel, namespace: TestSkosNs, to: :title
560
+
561
+ members :mids, link: "skos:member"
562
+ end
563
+ end)
564
+ end
565
+
566
+ it "collects @context from all nesting levels" do
567
+ root = JsonLdRoot.new(
568
+ title: "r1",
569
+ mids: [JsonLdMid.new(
570
+ label: "m1",
571
+ mid: "a",
572
+ leaves: [JsonLdLeaf.new(value: "l1", lid: "x")],
573
+ )],
574
+ )
575
+ parsed = JSON.parse(root.to_jsonld)
576
+ expect(parsed["@context"]["skos"]).to eq("http://www.w3.org/2004/02/skos/core#")
577
+ expect(parsed["@context"]["ex"]).to eq("http://example.org/")
578
+ end
579
+
580
+ it "includes resources from all nesting levels in @graph" do
581
+ root = JsonLdRoot.new(
582
+ title: "r1",
583
+ mids: [JsonLdMid.new(
584
+ label: "m1",
585
+ mid: "a",
586
+ leaves: [JsonLdLeaf.new(value: "l1", lid: "x")],
587
+ )],
588
+ )
589
+ parsed = JSON.parse(root.to_jsonld)
590
+ graph = parsed["@graph"]
591
+ types = graph.map { |r| r["@type"] }
592
+ expect(types).to include("skos:Collection")
593
+ expect(types).to include("skos:Concept")
594
+ expect(types).to include("ex:Leaf")
595
+ end
596
+ end
450
597
  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
@@ -46,6 +46,53 @@ 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", "foaf:Person"])
63
+ end
64
+
65
+ it "overwrites previous types on subsequent call" do
66
+ mapping.types("skos:Concept")
67
+ mapping.types("dcterms:Agent")
68
+ expect(mapping.rdf_type).to eq(["dcterms:Agent"])
69
+ end
70
+ end
71
+
72
+ describe "#has_types_or_predicates?" do
73
+ it "returns false when no types or predicates" do
74
+ expect(mapping.has_types_or_predicates?).to be(false)
75
+ end
76
+
77
+ it "returns true when types are present" do
78
+ mapping.type("skos:Concept")
79
+ expect(mapping.has_types_or_predicates?).to be(true)
80
+ end
81
+
82
+ it "returns true when predicates are present" do
83
+ mapping.predicate(:prefLabel,
84
+ namespace: Lutaml::Rdf::Namespaces::SkosNamespace, to: :name)
85
+ expect(mapping.has_types_or_predicates?).to be(true)
86
+ end
87
+
88
+ it "returns true when both types and predicates present" do
89
+ mapping.type("skos:Concept")
90
+ mapping.predicate(:prefLabel,
91
+ namespace: Lutaml::Rdf::Namespaces::SkosNamespace, to: :name)
92
+ expect(mapping.has_types_or_predicates?).to be(true)
93
+ end
94
+ end
95
+
49
96
  describe "#predicate" do
50
97
  it "creates MappingRule with namespace reference" do
51
98
  mapping.predicate(
@@ -128,13 +175,13 @@ RSpec.describe Lutaml::Rdf::Mapping do
128
175
  end
129
176
 
130
177
  describe "#members" do
131
- it "creates MemberRule" do
178
+ it "creates MemberRule without linking predicate" do
132
179
  mapping.members(:items)
133
180
  expect(mapping.rdf_members.length).to eq(1)
134
181
  expect(mapping.rdf_members.first.attr_name).to eq(:items)
135
182
  end
136
183
 
137
- it "creates MemberRule with linking predicate" do
184
+ it "creates MemberRule with static linking predicate" do
138
185
  mapping.members(:items,
139
186
  predicate_name: :member,
140
187
  namespace: Lutaml::Rdf::Namespaces::SkosNamespace)
@@ -143,11 +190,19 @@ RSpec.describe Lutaml::Rdf::Mapping do
143
190
  expect(rule.linked_predicate_uri).to eq("http://www.w3.org/2004/02/skos/core#member")
144
191
  end
145
192
 
146
- it "creates MemberRule without linking predicate" do
147
- mapping.members(:items)
193
+ it "creates MemberRule with link as String" do
194
+ mapping.members(:items, link: "skos:member")
148
195
  rule = mapping.rdf_members.first
149
- expect(rule.linked?).to be(false)
150
- expect(rule.linked_predicate_uri).to be_nil
196
+ expect(rule.linked?).to be(true)
197
+ expect(rule.link).to eq("skos:member")
198
+ end
199
+
200
+ it "creates MemberRule with link as Proc" do
201
+ resolver = ->(item) { "skos:#{item.type}" }
202
+ mapping.members(:items, link: resolver)
203
+ rule = mapping.rdf_members.first
204
+ expect(rule.linked?).to be(true)
205
+ expect(rule.link).to eq(resolver)
151
206
  end
152
207
 
153
208
  it "raises when predicate_name given without namespace" do
@@ -155,6 +210,15 @@ RSpec.describe Lutaml::Rdf::Mapping do
155
210
  mapping.members(:items, predicate_name: :member)
156
211
  end.to raise_error(ArgumentError, /namespace is required/)
157
212
  end
213
+
214
+ it "raises when predicate_name and link both given" do
215
+ expect do
216
+ mapping.members(:items,
217
+ predicate_name: :member,
218
+ namespace: Lutaml::Rdf::Namespaces::SkosNamespace,
219
+ link: "skos:member")
220
+ end.to raise_error(ArgumentError, /mutually exclusive/)
221
+ end
158
222
  end
159
223
 
160
224
  describe "#mappings" do
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "spec_helper"
4
+ require_relative "../../../lib/lutaml/rdf"
4
5
 
5
6
  RSpec.describe Lutaml::Rdf::MemberRule do
6
7
  describe ".new" do
@@ -26,10 +27,19 @@ RSpec.describe Lutaml::Rdf::MemberRule do
26
27
  namespace: Lutaml::Rdf::Namespaces::SkosNamespace)
27
28
  expect(rule.predicate_name).to eq(:member)
28
29
  end
30
+
31
+ it "raises ArgumentError when predicate_name and link both given" do
32
+ expect do
33
+ described_class.new(:items,
34
+ predicate_name: :member,
35
+ namespace: Lutaml::Rdf::Namespaces::SkosNamespace,
36
+ link: "skos:member")
37
+ end.to raise_error(ArgumentError, /mutually exclusive/)
38
+ end
29
39
  end
30
40
 
31
41
  describe "#linked?" do
32
- it "returns false when no predicate_name" do
42
+ it "returns false when no linking option" do
33
43
  rule = described_class.new(:items)
34
44
  expect(rule.linked?).to be(false)
35
45
  end
@@ -40,6 +50,16 @@ RSpec.describe Lutaml::Rdf::MemberRule do
40
50
  namespace: Lutaml::Rdf::Namespaces::SkosNamespace)
41
51
  expect(rule.linked?).to be(true)
42
52
  end
53
+
54
+ it "returns true when link is a String" do
55
+ rule = described_class.new(:items, link: "skos:member")
56
+ expect(rule.linked?).to be(true)
57
+ end
58
+
59
+ it "returns true when link is a Proc" do
60
+ rule = described_class.new(:items, link: ->(m) { "skos:#{m}" })
61
+ expect(rule.linked?).to be(true)
62
+ end
43
63
  end
44
64
 
45
65
  describe "#linked_predicate_uri" do
@@ -54,5 +74,87 @@ RSpec.describe Lutaml::Rdf::MemberRule do
54
74
  namespace: Lutaml::Rdf::Namespaces::SkosNamespace)
55
75
  expect(rule.linked_predicate_uri).to eq("http://www.w3.org/2004/02/skos/core#member")
56
76
  end
77
+
78
+ it "returns nil when link is a String (not static)" do
79
+ rule = described_class.new(:items, link: "skos:member")
80
+ expect(rule.linked_predicate_uri).to be_nil
81
+ end
82
+ end
83
+
84
+ describe "#link_predicate_for" do
85
+ let(:mapping) do
86
+ instance = Lutaml::Rdf::Mapping.new
87
+ instance.namespace(
88
+ Lutaml::Rdf::Namespaces::SkosNamespace,
89
+ Lutaml::Rdf::Namespaces::DctermsNamespace,
90
+ )
91
+ instance
92
+ end
93
+
94
+ let(:resolver) { mapping.namespace_set.method(:resolve_compact_iri) }
95
+
96
+ it "resolves String link via resolver" do
97
+ rule = described_class.new(:items, link: "skos:member")
98
+ expect(rule.link_predicate_for(nil, resolver))
99
+ .to eq("http://www.w3.org/2004/02/skos/core#member")
100
+ end
101
+
102
+ it "resolves Proc link by calling with member" do
103
+ rule = described_class.new(:items,
104
+ link: ->(m) { "skos:#{m.type}" })
105
+ member = Struct.new(:type).new("Concept")
106
+ expect(rule.link_predicate_for(member, resolver))
107
+ .to eq("http://www.w3.org/2004/02/skos/core#Concept")
108
+ end
109
+
110
+ it "returns URI as-is from Proc when prefix not found" do
111
+ rule = described_class.new(:items,
112
+ link: ->(m) { "http://example.org/#{m.id}" })
113
+ member = Struct.new(:id).new("42")
114
+ expect(rule.link_predicate_for(member, resolver))
115
+ .to eq("http://example.org/42")
116
+ end
117
+
118
+ it "returns nil when no link" do
119
+ rule = described_class.new(:items)
120
+ expect(rule.link_predicate_for(nil, resolver)).to be_nil
121
+ end
122
+ end
123
+
124
+ describe "#resolve_link_uri" do
125
+ let(:mapping) do
126
+ instance = Lutaml::Rdf::Mapping.new
127
+ instance.namespace(Lutaml::Rdf::Namespaces::SkosNamespace)
128
+ instance
129
+ end
130
+
131
+ let(:resolver) { mapping.namespace_set.method(:resolve_compact_iri) }
132
+
133
+ it "uses linked_predicate_uri for static links" do
134
+ rule = described_class.new(:items,
135
+ predicate_name: :member,
136
+ namespace: Lutaml::Rdf::Namespaces::SkosNamespace)
137
+ expect(rule.resolve_link_uri(nil, resolver))
138
+ .to eq("http://www.w3.org/2004/02/skos/core#member")
139
+ end
140
+
141
+ it "uses link_predicate_for for String links" do
142
+ rule = described_class.new(:items, link: "skos:member")
143
+ expect(rule.resolve_link_uri(nil, resolver))
144
+ .to eq("http://www.w3.org/2004/02/skos/core#member")
145
+ end
146
+
147
+ it "uses link_predicate_for for Proc links" do
148
+ rule = described_class.new(:items,
149
+ link: ->(m) { "skos:#{m.type}" })
150
+ member = Struct.new(:type).new("Concept")
151
+ expect(rule.resolve_link_uri(member, resolver))
152
+ .to eq("http://www.w3.org/2004/02/skos/core#Concept")
153
+ end
154
+
155
+ it "returns nil when not linked" do
156
+ rule = described_class.new(:items)
157
+ expect(rule.resolve_link_uri(nil, resolver)).to be_nil
158
+ end
57
159
  end
58
160
  end
@@ -585,4 +585,148 @@ RSpec.describe Lutaml::Turtle::Transform do
585
585
  skip "Heterogeneous collection requires union-typed attribute (not yet supported)"
586
586
  end
587
587
  end
588
+
589
+ describe "dynamic link predicates" do
590
+ before do
591
+ stub_const("DynChild", Class.new(Lutaml::Model::Serializable) do
592
+ attribute :label, :string
593
+ attribute :cid, :string
594
+
595
+ turtle do
596
+ namespace Lutaml::Rdf::Namespaces::SkosNamespace
597
+
598
+ subject { |m| "http://example.org/child/#{m.cid}" } # rubocop:disable RSpec/NamedSubject, RSpec/MultipleSubjects
599
+
600
+ type "skos:Concept"
601
+
602
+ predicate :prefLabel,
603
+ namespace: Lutaml::Rdf::Namespaces::SkosNamespace,
604
+ to: :label
605
+ end
606
+ end)
607
+
608
+ stub_const("DynParent", Class.new(Lutaml::Model::Serializable) do
609
+ attribute :name, :string
610
+ attribute :children, DynChild, collection: true
611
+
612
+ turtle do
613
+ namespace Lutaml::Rdf::Namespaces::SkosNamespace
614
+
615
+ subject { |m| "http://example.org/parent/#{m.name}" }
616
+
617
+ type "skos:Collection"
618
+
619
+ predicate :prefLabel,
620
+ namespace: Lutaml::Rdf::Namespaces::SkosNamespace,
621
+ to: :name
622
+
623
+ members :children, link: "skos:member"
624
+ end
625
+ end)
626
+ end
627
+
628
+ it "generates linking triples from String link" do
629
+ parent = DynParent.new(
630
+ name: "p1",
631
+ children: [DynChild.new(label: "c1", cid: "a")],
632
+ )
633
+ result = parent.to_turtle
634
+ expect(result).to include("skos:member <http://example.org/child/a>")
635
+ end
636
+
637
+ it "includes child subgraph data" do
638
+ parent = DynParent.new(
639
+ name: "p1",
640
+ children: [DynChild.new(label: "c1", cid: "a")],
641
+ )
642
+ result = parent.to_turtle
643
+ expect(result).to include("skos:prefLabel \"c1\"")
644
+ end
645
+ end
646
+
647
+ describe "recursive prefix collection" do
648
+ before do
649
+ stub_const("SkosNs", Lutaml::Rdf::Namespaces::SkosNamespace)
650
+ stub_const("DctermsNs", Lutaml::Rdf::Namespaces::DctermsNamespace)
651
+
652
+ stub_const("LeafModel", Class.new(Lutaml::Model::Serializable) do
653
+ attribute :value, :string
654
+ attribute :lid, :string
655
+
656
+ turtle do
657
+ namespace DctermsNs
658
+
659
+ subject { |m| "http://example.org/leaf/#{m.lid}" } # rubocop:disable RSpec/NamedSubject, RSpec/MultipleSubjects
660
+
661
+ type "dcterms:Agent"
662
+
663
+ predicate :title, namespace: DctermsNs, to: :value
664
+ end
665
+ end)
666
+
667
+ stub_const("MidModel", Class.new(Lutaml::Model::Serializable) do
668
+ attribute :name, :string
669
+ attribute :mid, :string
670
+ attribute :leaves, LeafModel, collection: true
671
+
672
+ turtle do
673
+ namespace SkosNs, DctermsNs
674
+
675
+ subject { |m| "http://example.org/mid/#{m.mid}" } # rubocop:disable RSpec/NamedSubject, RSpec/MultipleSubjects
676
+
677
+ type "skos:Concept"
678
+
679
+ predicate :prefLabel, namespace: SkosNs, to: :name
680
+
681
+ members :leaves, link: "skos:member"
682
+ end
683
+ end)
684
+
685
+ stub_const("RootModel", Class.new(Lutaml::Model::Serializable) do
686
+ attribute :title, :string
687
+ attribute :mids, MidModel, collection: true
688
+
689
+ turtle do
690
+ namespace SkosNs
691
+
692
+ subject { |m| "http://example.org/root/#{m.title}" }
693
+
694
+ type "skos:Collection"
695
+
696
+ predicate :prefLabel, namespace: SkosNs, to: :title
697
+
698
+ members :mids, link: "skos:member"
699
+ end
700
+ end)
701
+ end
702
+
703
+ it "collects prefixes from all nesting levels" do
704
+ root = RootModel.new(
705
+ title: "r1",
706
+ mids: [MidModel.new(
707
+ name: "m1",
708
+ mid: "a",
709
+ leaves: [LeafModel.new(value: "l1", lid: "x")],
710
+ )],
711
+ )
712
+ result = root.to_turtle
713
+ expect(result).to include("@prefix skos:")
714
+ expect(result).to include("@prefix dcterms:")
715
+ end
716
+
717
+ it "emits triples from all nesting levels" do
718
+ root = RootModel.new(
719
+ title: "r1",
720
+ mids: [MidModel.new(
721
+ name: "m1",
722
+ mid: "a",
723
+ leaves: [LeafModel.new(value: "l1", lid: "x")],
724
+ )],
725
+ )
726
+ result = root.to_turtle
727
+ expect(result).to include("skos:prefLabel \"r1\"")
728
+ expect(result).to include("skos:prefLabel \"m1\"")
729
+ expect(result).to include("dcterms:title \"l1\"")
730
+ end
731
+ end
588
732
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lutaml-model
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.14
4
+ version: 0.8.15
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-05-31 00:00:00.000000000 Z
11
+ date: 2026-06-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: base64
@@ -90,16 +90,16 @@ dependencies:
90
90
  name: moxml
91
91
  requirement: !ruby/object:Gem::Requirement
92
92
  requirements:
93
- - - ">="
93
+ - - "~>"
94
94
  - !ruby/object:Gem::Version
95
- version: 0.1.22
95
+ version: 0.1.23
96
96
  type: :runtime
97
97
  prerelease: false
98
98
  version_requirements: !ruby/object:Gem::Requirement
99
99
  requirements:
100
- - - ">="
100
+ - - "~>"
101
101
  - !ruby/object:Gem::Version
102
- version: 0.1.22
102
+ version: 0.1.23
103
103
  - !ruby/object:Gem::Dependency
104
104
  name: ostruct
105
105
  requirement: !ruby/object:Gem::Requirement
@@ -476,6 +476,7 @@ files:
476
476
  - lib/lutaml/model/jsonl.rb
477
477
  - lib/lutaml/model/liquefiable.rb
478
478
  - lib/lutaml/model/liquid.rb
479
+ - lib/lutaml/model/liquid/indexed_access.rb
479
480
  - lib/lutaml/model/liquid/mapping.rb
480
481
  - lib/lutaml/model/mapping/listener.rb
481
482
  - lib/lutaml/model/mapping/mapping.rb
@@ -1611,6 +1612,7 @@ files:
1611
1612
  - spec/lutaml/model/key_value_mapping_spec.rb
1612
1613
  - spec/lutaml/model/lazy_collection_spec.rb
1613
1614
  - spec/lutaml/model/liquefiable_spec.rb
1615
+ - spec/lutaml/model/liquid/indexed_access_spec.rb
1614
1616
  - spec/lutaml/model/liquid_compatibility_spec.rb
1615
1617
  - spec/lutaml/model/logger_spec.rb
1616
1618
  - spec/lutaml/model/map_all_spec.rb