lutaml-model 0.8.9 → 0.8.11

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 (108) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/dependent-repos.json +1 -0
  3. data/.rubocop_todo.yml +52 -22
  4. data/README.adoc +43 -0
  5. data/docs/_guides/jsonld-serialization.adoc +3 -1
  6. data/docs/_guides/rdf-serialization.adoc +94 -8
  7. data/docs/_guides/turtle-serialization.adoc +17 -4
  8. data/lib/lutaml/jsonld/transform.rb +70 -24
  9. data/lib/lutaml/key_value/transform.rb +5 -5
  10. data/lib/lutaml/key_value/transformation/collection_serializer.rb +25 -11
  11. data/lib/lutaml/key_value/transformation/value_serializer.rb +7 -7
  12. data/lib/lutaml/key_value/transformation.rb +27 -17
  13. data/lib/lutaml/model/adapter_resolver.rb +4 -6
  14. data/lib/lutaml/model/attribute.rb +26 -23
  15. data/lib/lutaml/model/cached_type_resolver.rb +10 -9
  16. data/lib/lutaml/model/cli.rb +1 -1
  17. data/lib/lutaml/model/collection.rb +4 -4
  18. data/lib/lutaml/model/comparable_model.rb +11 -11
  19. data/lib/lutaml/model/config.rb +1 -1
  20. data/lib/lutaml/model/consolidation/dispatcher.rb +1 -1
  21. data/lib/lutaml/model/consolidation/pattern_chunker.rb +3 -3
  22. data/lib/lutaml/model/format_registry.rb +6 -4
  23. data/lib/lutaml/model/global_context.rb +2 -2
  24. data/lib/lutaml/model/global_register.rb +1 -1
  25. data/lib/lutaml/model/instrumentation.rb +1 -1
  26. data/lib/lutaml/model/mapping/mapping_rule.rb +3 -3
  27. data/lib/lutaml/model/mapping/model_mapping.rb +1 -1
  28. data/lib/lutaml/model/mapping/model_mapping_rule.rb +1 -1
  29. data/lib/lutaml/model/register.rb +3 -3
  30. data/lib/lutaml/model/render_policy.rb +11 -17
  31. data/lib/lutaml/model/runtime_compatibility.rb +0 -1
  32. data/lib/lutaml/model/schema/xml_compiler/group.rb +1 -1
  33. data/lib/lutaml/model/schema/xml_compiler/registry_generator.rb +1 -1
  34. data/lib/lutaml/model/schema/xml_compiler/sequence.rb +0 -2
  35. data/lib/lutaml/model/schema/xml_compiler.rb +14 -14
  36. data/lib/lutaml/model/serialize/attribute_definition.rb +1 -1
  37. data/lib/lutaml/model/serialize/deserialization_context.rb +50 -0
  38. data/lib/lutaml/model/serialize/format_conversion.rb +2 -2
  39. data/lib/lutaml/model/serialize/initialization.rb +44 -7
  40. data/lib/lutaml/model/serialize/model_import.rb +1 -1
  41. data/lib/lutaml/model/serialize.rb +8 -1
  42. data/lib/lutaml/model/services/rule_value_extractor.rb +2 -1
  43. data/lib/lutaml/model/store.rb +77 -24
  44. data/lib/lutaml/model/transformation_registry.rb +1 -1
  45. data/lib/lutaml/model/type_context.rb +7 -1
  46. data/lib/lutaml/model/type_resolver.rb +1 -6
  47. data/lib/lutaml/model/utils.rb +19 -6
  48. data/lib/lutaml/model/validation_framework.rb +1 -1
  49. data/lib/lutaml/model/value_transformer.rb +2 -2
  50. data/lib/lutaml/model/version.rb +1 -1
  51. data/lib/lutaml/rdf/mapping.rb +19 -13
  52. data/lib/lutaml/rdf/mapping_rule.rb +19 -2
  53. data/lib/lutaml/rdf/member_rule.rb +19 -2
  54. data/lib/lutaml/rdf/transform.rb +20 -11
  55. data/lib/lutaml/turtle/transform.rb +125 -53
  56. data/lib/lutaml/xml/adapter/adapter_helpers.rb +1 -1
  57. data/lib/lutaml/xml/adapter/base_adapter.rb +10 -14
  58. data/lib/lutaml/xml/adapter/namespace_uri_collector.rb +3 -3
  59. data/lib/lutaml/xml/adapter/plan_based_builder.rb +14 -14
  60. data/lib/lutaml/xml/adapter/xml_serializer.rb +3 -3
  61. data/lib/lutaml/xml/configurable.rb +2 -1
  62. data/lib/lutaml/xml/data_model.rb +2 -2
  63. data/lib/lutaml/xml/decisions/decision_context.rb +3 -3
  64. data/lib/lutaml/xml/decisions/rules/element_form_default_unqualified_rule.rb +1 -1
  65. data/lib/lutaml/xml/decisions/rules/element_form_option_rule.rb +1 -1
  66. data/lib/lutaml/xml/decisions/rules/used_prefix_rule.rb +1 -1
  67. data/lib/lutaml/xml/declaration_plan.rb +2 -2
  68. data/lib/lutaml/xml/declaration_planner.rb +12 -13
  69. data/lib/lutaml/xml/document.rb +13 -13
  70. data/lib/lutaml/xml/format_chooser.rb +3 -3
  71. data/lib/lutaml/xml/hoisting_algorithm.rb +1 -1
  72. data/lib/lutaml/xml/mapping.rb +2 -2
  73. data/lib/lutaml/xml/mapping_rule.rb +16 -3
  74. data/lib/lutaml/xml/model_transform.rb +17 -19
  75. data/lib/lutaml/xml/namespace_collector.rb +10 -10
  76. data/lib/lutaml/xml/namespace_declaration.rb +2 -2
  77. data/lib/lutaml/xml/namespace_declaration_data.rb +5 -8
  78. data/lib/lutaml/xml/namespace_scope_config.rb +3 -2
  79. data/lib/lutaml/xml/namespace_type_resolver.rb +4 -4
  80. data/lib/lutaml/xml/nokogiri/element.rb +2 -2
  81. data/lib/lutaml/xml/polymorphic_value_handler.rb +1 -1
  82. data/lib/lutaml/xml/schema/xsd/base.rb +7 -7
  83. data/lib/lutaml/xml/schema/xsd/choice.rb +2 -2
  84. data/lib/lutaml/xml/schema/xsd/complex_type.rb +5 -5
  85. data/lib/lutaml/xml/schema/xsd/errors/message_builder.rb +3 -3
  86. data/lib/lutaml/xml/schema/xsd/group.rb +2 -2
  87. data/lib/lutaml/xml/schema/xsd/sequence.rb +2 -2
  88. data/lib/lutaml/xml/schema/xsd_schema.rb +5 -5
  89. data/lib/lutaml/xml/serialization/format_conversion.rb +4 -3
  90. data/lib/lutaml/xml/transformation/element_builder.rb +4 -2
  91. data/lib/lutaml/xml/transformation/rule_applier.rb +2 -2
  92. data/lib/lutaml/xml/transformation/value_serializer.rb +4 -6
  93. data/lib/lutaml/xml/transformation.rb +4 -4
  94. data/lib/lutaml/xml/type/configurable.rb +0 -4
  95. data/lib/lutaml/xml/xml_element.rb +21 -13
  96. data/lutaml-model.gemspec +1 -1
  97. data/spec/lutaml/jsonld/transform_spec.rb +239 -0
  98. data/spec/lutaml/model/cached_type_resolver_spec.rb +3 -3
  99. data/spec/lutaml/model/optimization_spec.rb +228 -0
  100. data/spec/lutaml/model/store_spec.rb +195 -0
  101. data/spec/lutaml/rdf/mapping_rule_spec.rb +97 -0
  102. data/spec/lutaml/rdf/mapping_spec.rb +74 -4
  103. data/spec/lutaml/rdf/member_rule_spec.rb +41 -0
  104. data/spec/lutaml/rdf/rdf_transform_spec.rb +95 -29
  105. data/spec/lutaml/turtle/mapping_spec.rb +2 -2
  106. data/spec/lutaml/turtle/transform_spec.rb +315 -0
  107. data/spec/lutaml/xml/data_model_spec.rb +10 -28
  108. metadata +7 -4
@@ -0,0 +1,228 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe "Optimization behaviors" do
6
+ before do
7
+ Lutaml::Model::GlobalContext.clear_caches
8
+ Lutaml::Model::TransformationRegistry.instance.clear
9
+ Lutaml::Model::GlobalRegister.instance.reset
10
+ Lutaml::Model::Store.clear
11
+ end
12
+
13
+ after do
14
+ Lutaml::Model::GlobalContext.clear_caches
15
+ Lutaml::Model::TransformationRegistry.instance.clear
16
+ Lutaml::Model::GlobalRegister.instance.reset
17
+ Lutaml::Model::Store.clear
18
+ end
19
+
20
+ describe "DeserializationContext propagation" do
21
+ let(:propagation_keys) do
22
+ Lutaml::Model::Serialize::DeserializationContext::PROPAGATION_KEYS
23
+ end
24
+
25
+ it "propagates only whitelisted keys" do
26
+ options = {
27
+ lutaml_parent: "parent",
28
+ lutaml_root: "root",
29
+ default_namespace: "http://example.com",
30
+ namespace_uri: "http://internal.com",
31
+ resolved_type: String,
32
+ converted: true,
33
+ polymorphic: :by_type,
34
+ collection: true,
35
+ }
36
+
37
+ propagated = Lutaml::Model::Serialize::DeserializationContext.propagate(options)
38
+
39
+ expect(propagated).to eq({
40
+ lutaml_parent: "parent",
41
+ lutaml_root: "root",
42
+ default_namespace: "http://example.com",
43
+ polymorphic: :by_type,
44
+ collection: true,
45
+ })
46
+ end
47
+
48
+ it "excludes parent-internal keys" do
49
+ options = {
50
+ namespace_uri: "http://internal.com",
51
+ resolved_type: String,
52
+ converted: true,
53
+ mappings: double("mappings"),
54
+ }
55
+
56
+ propagated = Lutaml::Model::Serialize::DeserializationContext.propagate(options)
57
+
58
+ expect(propagated).to be_empty
59
+ end
60
+
61
+ it "CHILD_PROPAGATION_KEYS matches PROPAGATION_KEYS" do
62
+ expect(Lutaml::Model::Attribute::CHILD_PROPAGATION_KEYS).to eq(propagation_keys)
63
+ end
64
+ end
65
+
66
+ describe "conditional reference store registration" do
67
+ it "registers instances by default" do
68
+ klass = Class.new(Lutaml::Model::Serializable) do
69
+ attribute :id, :string
70
+ end
71
+
72
+ obj = klass.new(id: "test")
73
+ result = Lutaml::Model::Store.resolve(klass, :id, "test")
74
+ expect(result).to eq(obj)
75
+ end
76
+
77
+ it "skips registration when skip_reference_registration is declared" do
78
+ klass = Class.new(Lutaml::Model::Serializable) do
79
+ attribute :id, :string
80
+ skip_reference_registration
81
+ end
82
+
83
+ klass.new(id: "test")
84
+ result = Lutaml::Model::Store.resolve(klass, :id, "test")
85
+ expect(result).to be_nil
86
+ end
87
+
88
+ it "reference_resolvable? returns true by default" do
89
+ klass = Class.new(Lutaml::Model::Serializable) do
90
+ attribute :id, :string
91
+ end
92
+
93
+ expect(klass.reference_resolvable?).to be true
94
+ end
95
+
96
+ it "reference_resolvable? returns false after skip_reference_registration" do
97
+ klass = Class.new(Lutaml::Model::Serializable) do
98
+ attribute :id, :string
99
+ skip_reference_registration
100
+ end
101
+
102
+ expect(klass.reference_resolvable?).to be false
103
+ end
104
+
105
+ it "skips registration during allocate_for_deserialization" do
106
+ klass = Class.new(Lutaml::Model::Serializable) do
107
+ attribute :id, :string
108
+ skip_reference_registration
109
+ end
110
+
111
+ instance = klass.allocate_for_deserialization
112
+ instance.public_send(:id=, "deserialized")
113
+ result = Lutaml::Model::Store.resolve(klass, :id, "deserialized")
114
+ expect(result).to be_nil
115
+ end
116
+ end
117
+
118
+ describe "class_attributes accessor" do
119
+ it "returns raw attributes without register merging" do
120
+ klass = Class.new(Lutaml::Model::Serializable) do
121
+ attribute :name, :string
122
+ end
123
+
124
+ expect(klass.class_attributes).to be_a(Hash)
125
+ expect(klass.class_attributes.keys).to include(:name)
126
+ end
127
+ end
128
+
129
+ describe "MappingRule#static_namespace_option" do
130
+ it "returns frozen hash for rules with explicit namespace" do
131
+ rule = Lutaml::Xml::MappingRule.new(
132
+ "test",
133
+ to: :test,
134
+ namespace: ExampleComNamespace,
135
+ namespace_set: true,
136
+ )
137
+
138
+ result = rule.static_namespace_option
139
+ expect(result).to eq({ default_namespace: "http://example.com" })
140
+ expect(result).to be_frozen
141
+ end
142
+
143
+ it "returns the same object on repeated calls (cached)" do
144
+ rule = Lutaml::Xml::MappingRule.new(
145
+ "test",
146
+ to: :test,
147
+ namespace: ExampleComNamespace,
148
+ namespace_set: true,
149
+ )
150
+
151
+ first = rule.static_namespace_option
152
+ second = rule.static_namespace_option
153
+ expect(first).to equal(second) # same object identity
154
+ end
155
+
156
+ it "returns nil when no namespace is set" do
157
+ rule = Lutaml::Xml::MappingRule.new("test", to: :test)
158
+
159
+ expect(rule.static_namespace_option).to be_nil
160
+ end
161
+
162
+ it "returns nil when namespace is :inherit" do
163
+ rule = Lutaml::Xml::MappingRule.new(
164
+ "test",
165
+ to: :test,
166
+ namespace: :inherit,
167
+ )
168
+
169
+ expect(rule.static_namespace_option).to be_nil
170
+ end
171
+ end
172
+
173
+ describe "String deduplication in namespace URIs" do
174
+ it "XmlElement#namespace_uri deduplicates via unary minus" do
175
+ ns_instance = ExampleComNamespace.new
176
+ parent = Lutaml::Xml::XmlElement.new(nil, {}, [], nil,
177
+ name: "root",
178
+ parent_document: double(namespaces: { nil => ns_instance }))
179
+ uri = parent.namespace_uri
180
+
181
+ # The returned string should be the deduplicated version (-uri)
182
+ expect(uri).to eq("http://example.com")
183
+ expect(uri).to be_frozen
184
+ end
185
+
186
+ it "XmlElement#namespaced_name deduplicates the result string" do
187
+ ExampleComNamespace.new
188
+ parent = Lutaml::Xml::XmlElement.new(nil, {}, [], nil,
189
+ name: "root",
190
+ default_namespace: "http://example.com",
191
+ parent_document: double(namespaces: {}))
192
+ result = parent.namespaced_name
193
+
194
+ expect(result).to eq("http://example.com:root")
195
+ expect(result).to be_frozen
196
+ end
197
+ end
198
+
199
+ describe "value_set_for" do
200
+ it "transitions from nil (all defaults) to per-attribute tracking" do
201
+ klass = Class.new(Lutaml::Model::Serializable) do
202
+ attribute :name, :string
203
+ attribute :age, :integer
204
+ end
205
+
206
+ obj = klass.allocate_for_deserialization
207
+ expect(obj.using_default?(:name)).to be true
208
+ expect(obj.using_default?(:age)).to be true
209
+
210
+ obj.value_set_for(:name)
211
+ expect(obj.using_default?(:name)).to be false
212
+ expect(obj.using_default?(:age)).to be true
213
+ end
214
+
215
+ it "values_set_for sets multiple attributes at once" do
216
+ klass = Class.new(Lutaml::Model::Serializable) do
217
+ attribute :name, :string
218
+ attribute :age, :integer
219
+ end
220
+
221
+ obj = klass.allocate_for_deserialization
222
+ obj.values_set_for(%i[name age])
223
+
224
+ expect(obj.using_default?(:name)).to be false
225
+ expect(obj.using_default?(:age)).to be false
226
+ end
227
+ end
228
+ end
@@ -69,6 +69,47 @@ RSpec.describe Lutaml::Model::Store do
69
69
  end
70
70
  end
71
71
 
72
+ describe "multi-class index isolation" do
73
+ let(:other_class) do
74
+ Class.new(Lutaml::Model::Serializable) do
75
+ attribute :id, :string
76
+ attribute :code, :string
77
+ end
78
+ end
79
+
80
+ it "does not mix indices across different classes" do
81
+ model_class.new(id: "a")
82
+ other_class.new(id: "a")
83
+
84
+ result = described_class.resolve(model_class, :id, "a")
85
+ expect(result).to be_a(model_class)
86
+
87
+ result2 = described_class.resolve(other_class, :id, "a")
88
+ expect(result2).to be_a(other_class)
89
+ end
90
+
91
+ it "registering class B does not iterate class A's indices" do
92
+ # Build index for model_class (hold strong ref so GC cannot collect it)
93
+ _obj = model_class.new(id: "x")
94
+ described_class.resolve(model_class, :id, "x")
95
+
96
+ # Registering other_class should not trigger work on model_class indices
97
+ 100.times { |i| other_class.new(id: "other-#{i}", code: "c#{i}") }
98
+ result = described_class.resolve(model_class, :id, "x")
99
+ expect(result.id).to eq("x")
100
+ end
101
+
102
+ it "resolves by different reference keys independently per class" do
103
+ other_class.new(id: "alpha", code: "Z1")
104
+
105
+ result = described_class.resolve(other_class, :id, "alpha")
106
+ expect(result.id).to eq("alpha")
107
+
108
+ result2 = described_class.resolve(other_class, :code, "Z1")
109
+ expect(result2.code).to eq("Z1")
110
+ end
111
+ end
112
+
72
113
  describe "WeakRef behavior" do
73
114
  it "uses WeakRef for storage (objects can be collected when unreferenced)" do
74
115
  obj = model_class.new(id: "alive")
@@ -97,6 +138,160 @@ RSpec.describe Lutaml::Model::Store do
97
138
  end
98
139
  end
99
140
 
141
+ describe "compaction amortisation" do
142
+ it "bounds the number of full-array compactions across many live registers" do
143
+ threshold = Lutaml::Model::Store::COMPACTION_THRESHOLD
144
+ interval = Lutaml::Model::Store::COMPACTION_INTERVAL
145
+ n = threshold + (3 * interval)
146
+
147
+ instance = described_class.instance
148
+
149
+ # Holding strong refs so WeakRefs stay alive across the registers.
150
+ objects = Array.new(n) { |i| model_class.new(id: "amortise-#{i}") }
151
+ expect(objects.size).to eq(n)
152
+
153
+ # Without amortisation this would be ~3000 compactions (one per register
154
+ # past threshold). With amortisation it fires once per INTERVAL inserts,
155
+ # so ~3 compactions plus a small slack.
156
+ expect(instance.compaction_count).to be <= 5
157
+
158
+ # Correctness: the most recently registered object still resolves.
159
+ expect(described_class.resolve(model_class, :id, "amortise-#{n - 1}").id)
160
+ .to eq("amortise-#{n - 1}")
161
+ end
162
+
163
+ it "resets the per-class insertion counter on clear" do
164
+ threshold = Lutaml::Model::Store::COMPACTION_THRESHOLD
165
+ objects = Array.new(threshold + 10) do |i|
166
+ model_class.new(id: "pre-#{i}")
167
+ end
168
+ expect(objects.size).to eq(threshold + 10)
169
+ described_class.clear
170
+
171
+ expect(described_class.instance.inserts_since_compaction).to be_empty
172
+ end
173
+
174
+ it "resets the compaction counter on clear" do
175
+ threshold = Lutaml::Model::Store::COMPACTION_THRESHOLD
176
+ interval = Lutaml::Model::Store::COMPACTION_INTERVAL
177
+ _objects = Array.new(threshold + interval + 10) do |i|
178
+ model_class.new(id: "pre-#{i}")
179
+ end
180
+
181
+ described_class.clear
182
+
183
+ expect(described_class.instance.compaction_count).to eq(0)
184
+ end
185
+
186
+ it "removes dead refs during compaction" do
187
+ threshold = Lutaml::Model::Store::COMPACTION_THRESHOLD
188
+ interval = Lutaml::Model::Store::COMPACTION_INTERVAL
189
+ instance = described_class.instance
190
+
191
+ # Register enough objects to trigger compaction, then release them.
192
+ # Use .times so each object is immediately unreferenced after the
193
+ # iteration — no Array holds strong refs, so GC can collect them.
194
+ (threshold + 1).times { |i| model_class.new(id: "die-#{i}") }
195
+ GC.start
196
+
197
+ # Register enough more to cross the interval gate and trigger compaction.
198
+ Array.new(interval) { |i| model_class.new(id: "live-#{i}") }
199
+
200
+ # After compaction, the refs array should be smaller than before
201
+ # (some dead refs removed). Exact count depends on GC timing,
202
+ # but the live refs must still be present.
203
+ live = instance.refs_for(model_class.to_s)
204
+ alive_count = live.count do |ref|
205
+ ref.weakref_alive?
206
+ rescue WeakRef::RefError
207
+ false
208
+ end
209
+ expect(alive_count).to be < (threshold + 1 + interval)
210
+ end
211
+
212
+ it "maintains per-class counter independence" do
213
+ other_class = Class.new(Lutaml::Model::Serializable) do
214
+ attribute :id, :string
215
+ end
216
+
217
+ # Register 5 model_class objects and 3 other_class objects.
218
+ _objects_a = Array.new(5) { |i| model_class.new(id: "a-#{i}") }
219
+ _objects_b = Array.new(3) { |i| other_class.new(id: "b-#{i}") }
220
+
221
+ counters = described_class.instance.inserts_since_compaction
222
+ expect(counters[model_class.to_s]).to eq(5)
223
+ expect(counters[other_class.to_s]).to eq(3)
224
+ end
225
+
226
+ it "does not compact when exactly at threshold" do
227
+ threshold = Lutaml::Model::Store::COMPACTION_THRESHOLD
228
+ instance = described_class.instance
229
+
230
+ _objects = Array.new(threshold) { |i| model_class.new(id: "edge-#{i}") }
231
+
232
+ # refs.size == threshold, which does not satisfy size > threshold
233
+ expect(instance.compaction_count).to eq(0)
234
+ end
235
+ end
236
+
237
+ describe "index pruning" do
238
+ it "removes stale entry on resolve" do
239
+ instance = described_class.instance
240
+
241
+ _obj = model_class.new(id: "stale")
242
+ described_class.resolve(model_class, :id, "stale")
243
+ expect(instance.index_entry_count(model_class.to_s)).to eq(1)
244
+
245
+ _obj = nil
246
+ GC.start
247
+
248
+ expect(described_class.resolve(model_class, :id, "stale")).to be_nil
249
+ expect(instance.index_entry_count(model_class.to_s)).to eq(0)
250
+ end
251
+
252
+ it "prunes dead index entries during compaction" do
253
+ threshold = described_class::COMPACTION_THRESHOLD
254
+ interval = described_class::COMPACTION_INTERVAL
255
+ instance = described_class.instance
256
+
257
+ # Register and index a batch of objects
258
+ _batch = Array.new(threshold + 1) { |i| model_class.new(id: "die-#{i}") }
259
+ described_class.resolve(model_class, :id, "die-0")
260
+ expect(instance.index_entry_count(model_class.to_s)).to eq(threshold + 1)
261
+
262
+ # Release and trigger compaction
263
+ _batch = nil
264
+ GC.start
265
+ Array.new(interval) { |i| model_class.new(id: "live-#{i}") }
266
+
267
+ # Dead entries should be pruned; only live entries remain
268
+ expect(instance.index_entry_count(model_class.to_s)).to be <= interval + 50
269
+ end
270
+
271
+ it "rebuilds index after pruning removes all entries for a reference key" do
272
+ threshold = described_class::COMPACTION_THRESHOLD
273
+ interval = described_class::COMPACTION_INTERVAL
274
+
275
+ # Register and index by :name only
276
+ _batch = Array.new(threshold + 1) do |i|
277
+ model_class.new(id: "die-#{i}", name: "n-#{i}")
278
+ end
279
+ described_class.resolve(model_class, :name, "n-0")
280
+
281
+ # Release all and trigger compaction
282
+ _batch = nil
283
+ GC.start
284
+ Array.new(interval) do |i|
285
+ model_class.new(id: "live-#{i}", name: "live-n-#{i}")
286
+ end
287
+
288
+ # Index for :name should be rebuilt on next resolve
289
+ new_obj = model_class.new(id: "fresh", name: "fresh-name")
290
+ expect(described_class.resolve(model_class, :name,
291
+ "fresh-name")).to eq(new_obj)
292
+ end
293
+ end
294
+
100
295
  describe "#clear" do
101
296
  it "removes all registered objects" do
102
297
  model_class.new(id: "a")
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "lutaml/rdf"
5
+
6
+ RSpec.describe Lutaml::Rdf::MappingRule do
7
+ subject(:rule) do
8
+ described_class.new(
9
+ :prefLabel,
10
+ namespace: Lutaml::Rdf::Namespaces::SkosNamespace,
11
+ to: :name,
12
+ )
13
+ end
14
+
15
+ describe ".new" do
16
+ it "stores predicate_name as frozen string" do
17
+ expect(rule.predicate_name).to eq("prefLabel")
18
+ expect(rule.predicate_name).to be_frozen
19
+ end
20
+
21
+ it "stores to as symbol" do
22
+ expect(rule.to).to eq(:name)
23
+ end
24
+
25
+ it "defaults lang_tagged to false" do
26
+ expect(rule.lang_tagged).to be(false)
27
+ end
28
+
29
+ it "defaults uri_reference to false" do
30
+ expect(rule.uri_reference).to be(false)
31
+ end
32
+
33
+ it "raises when predicate_name is nil" do
34
+ expect do
35
+ described_class.new(nil,
36
+ namespace: Lutaml::Rdf::Namespaces::SkosNamespace,
37
+ to: :name)
38
+ end.to raise_error(ArgumentError, /predicate_name is required/)
39
+ end
40
+
41
+ it "raises when namespace is not a Rdf::Namespace subclass" do
42
+ expect do
43
+ described_class.new(:foo, namespace: String, to: :bar)
44
+ end.to raise_error(ArgumentError, /Rdf::Namespace/)
45
+ end
46
+
47
+ it "raises when to is nil" do
48
+ expect do
49
+ described_class.new(:foo,
50
+ namespace: Lutaml::Rdf::Namespaces::SkosNamespace,
51
+ to: nil)
52
+ end.to raise_error(ArgumentError, /required/)
53
+ end
54
+
55
+ it "raises when both lang_tagged and uri_reference are true" do
56
+ expect do
57
+ described_class.new(:foo,
58
+ namespace: Lutaml::Rdf::Namespaces::SkosNamespace,
59
+ to: :bar,
60
+ lang_tagged: true,
61
+ uri_reference: true)
62
+ end.to raise_error(ArgumentError, /mutually exclusive/)
63
+ end
64
+ end
65
+
66
+ describe "#kind" do
67
+ it "returns :plain by default" do
68
+ expect(rule.kind).to eq(:plain)
69
+ end
70
+
71
+ it "returns :lang_tagged when lang_tagged" do
72
+ r = described_class.new(:prefLabel,
73
+ namespace: Lutaml::Rdf::Namespaces::SkosNamespace,
74
+ to: :name, lang_tagged: true)
75
+ expect(r.kind).to eq(:lang_tagged)
76
+ end
77
+
78
+ it "returns :uri_reference when uri_reference" do
79
+ r = described_class.new(:related,
80
+ namespace: Lutaml::Rdf::Namespaces::SkosNamespace,
81
+ to: :related, uri_reference: true)
82
+ expect(r.kind).to eq(:uri_reference)
83
+ end
84
+ end
85
+
86
+ describe "#uri" do
87
+ it "resolves predicate name to full URI via namespace" do
88
+ expect(rule.uri).to eq("http://www.w3.org/2004/02/skos/core#prefLabel")
89
+ end
90
+ end
91
+
92
+ describe "#prefixed_name" do
93
+ it "returns prefix:local form" do
94
+ expect(rule.prefixed_name).to eq("skos:prefLabel")
95
+ end
96
+ end
97
+ end
@@ -29,9 +29,20 @@ RSpec.describe Lutaml::Rdf::Mapping do
29
29
  end
30
30
 
31
31
  describe "#type" do
32
- it "stores RDF type" do
32
+ it "stores single RDF type as array" do
33
33
  mapping.type("skos:Concept")
34
- expect(mapping.rdf_type).to eq("skos:Concept")
34
+ expect(mapping.rdf_type).to eq(["skos:Concept"])
35
+ end
36
+
37
+ it "stores multiple RDF types" do
38
+ mapping.type(["skos:Concept", "dcterms:Agent"])
39
+ expect(mapping.rdf_type).to eq(["skos:Concept", "dcterms:Agent"])
40
+ end
41
+
42
+ it "overwrites previous type on subsequent call" do
43
+ mapping.type("skos:Concept")
44
+ mapping.type("dcterms:Agent")
45
+ expect(mapping.rdf_type).to eq(["dcterms:Agent"])
35
46
  end
36
47
  end
37
48
 
@@ -60,6 +71,28 @@ RSpec.describe Lutaml::Rdf::Mapping do
60
71
  expect(mapping.rdf_predicates.first.lang_tagged).to be(true)
61
72
  end
62
73
 
74
+ it "creates MappingRule with uri_reference option" do
75
+ mapping.predicate(
76
+ :related,
77
+ namespace: Lutaml::Rdf::Namespaces::SkosNamespace,
78
+ to: :related,
79
+ uri_reference: true,
80
+ )
81
+ expect(mapping.rdf_predicates.first.uri_reference).to be(true)
82
+ end
83
+
84
+ it "rejects lang_tagged combined with uri_reference" do
85
+ expect do
86
+ mapping.predicate(
87
+ :related,
88
+ namespace: Lutaml::Rdf::Namespaces::SkosNamespace,
89
+ to: :related,
90
+ lang_tagged: true,
91
+ uri_reference: true,
92
+ )
93
+ end.to raise_error(ArgumentError, /mutually exclusive/)
94
+ end
95
+
63
96
  it "registers multiple predicates" do
64
97
  mapping.predicate(:prefLabel,
65
98
  namespace: Lutaml::Rdf::Namespaces::SkosNamespace, to: :name)
@@ -100,6 +133,28 @@ RSpec.describe Lutaml::Rdf::Mapping do
100
133
  expect(mapping.rdf_members.length).to eq(1)
101
134
  expect(mapping.rdf_members.first.attr_name).to eq(:items)
102
135
  end
136
+
137
+ it "creates MemberRule with linking predicate" do
138
+ mapping.members(:items,
139
+ predicate_name: :member,
140
+ namespace: Lutaml::Rdf::Namespaces::SkosNamespace)
141
+ rule = mapping.rdf_members.first
142
+ expect(rule.linked?).to be(true)
143
+ expect(rule.linked_predicate_uri).to eq("http://www.w3.org/2004/02/skos/core#member")
144
+ end
145
+
146
+ it "creates MemberRule without linking predicate" do
147
+ mapping.members(:items)
148
+ rule = mapping.rdf_members.first
149
+ expect(rule.linked?).to be(false)
150
+ expect(rule.linked_predicate_uri).to be_nil
151
+ end
152
+
153
+ it "raises when predicate_name given without namespace" do
154
+ expect do
155
+ mapping.members(:items, predicate_name: :member)
156
+ end.to raise_error(ArgumentError, /namespace is required/)
157
+ end
103
158
  end
104
159
 
105
160
  describe "#mappings" do
@@ -140,7 +195,7 @@ RSpec.describe Lutaml::Rdf::Mapping do
140
195
  Lutaml::Rdf::Namespaces::DctermsNamespace,
141
196
  )
142
197
  mapping.subject { |m| "http://example.org/#{m.name}" }
143
- mapping.type("skos:Concept")
198
+ mapping.type(["skos:Concept", "dcterms:Agent"])
144
199
  mapping.predicate(:prefLabel,
145
200
  namespace: Lutaml::Rdf::Namespaces::SkosNamespace, to: :name)
146
201
  end
@@ -149,7 +204,7 @@ RSpec.describe Lutaml::Rdf::Mapping do
149
204
  duped = mapping.deep_dup
150
205
  expect(duped.namespace_set.size).to eq(2)
151
206
  expect(duped.rdf_subject).to be_a(Proc)
152
- expect(duped.rdf_type).to eq("skos:Concept")
207
+ expect(duped.rdf_type).to eq(["skos:Concept", "dcterms:Agent"])
153
208
  expect(duped.rdf_predicates.length).to eq(1)
154
209
  end
155
210
 
@@ -160,5 +215,20 @@ RSpec.describe Lutaml::Rdf::Mapping do
160
215
  expect(mapping.rdf_predicates.length).to eq(1)
161
216
  expect(duped.rdf_predicates.length).to eq(2)
162
217
  end
218
+
219
+ it "does not share type array with original" do
220
+ duped = mapping.deep_dup
221
+ duped.type("skos:Collection")
222
+ expect(mapping.rdf_type).to eq(["skos:Concept", "dcterms:Agent"])
223
+ expect(duped.rdf_type).to eq(["skos:Collection"])
224
+ end
225
+
226
+ it "does not share member state with original" do
227
+ mapping.members(:items)
228
+ duped = mapping.deep_dup
229
+ duped.members(:more_items)
230
+ expect(mapping.rdf_members.length).to eq(1)
231
+ expect(duped.rdf_members.length).to eq(2)
232
+ end
163
233
  end
164
234
  end
@@ -13,5 +13,46 @@ RSpec.describe Lutaml::Rdf::MemberRule do
13
13
  rule = described_class.new("concepts")
14
14
  expect(rule.attr_name).to eq(:concepts)
15
15
  end
16
+
17
+ it "raises ArgumentError when predicate_name given without namespace" do
18
+ expect do
19
+ described_class.new(:items, predicate_name: :member)
20
+ end.to raise_error(ArgumentError, /namespace is required/)
21
+ end
22
+
23
+ it "allows both predicate_name and namespace together" do
24
+ rule = described_class.new(:items,
25
+ predicate_name: :member,
26
+ namespace: Lutaml::Rdf::Namespaces::SkosNamespace)
27
+ expect(rule.predicate_name).to eq(:member)
28
+ end
29
+ end
30
+
31
+ describe "#linked?" do
32
+ it "returns false when no predicate_name" do
33
+ rule = described_class.new(:items)
34
+ expect(rule.linked?).to be(false)
35
+ end
36
+
37
+ it "returns true when predicate_name is set" do
38
+ rule = described_class.new(:items,
39
+ predicate_name: :member,
40
+ namespace: Lutaml::Rdf::Namespaces::SkosNamespace)
41
+ expect(rule.linked?).to be(true)
42
+ end
43
+ end
44
+
45
+ describe "#linked_predicate_uri" do
46
+ it "returns nil when no linking predicate" do
47
+ rule = described_class.new(:items)
48
+ expect(rule.linked_predicate_uri).to be_nil
49
+ end
50
+
51
+ it "resolves the linking predicate URI" do
52
+ rule = described_class.new(:items,
53
+ predicate_name: :member,
54
+ namespace: Lutaml::Rdf::Namespaces::SkosNamespace)
55
+ expect(rule.linked_predicate_uri).to eq("http://www.w3.org/2004/02/skos/core#member")
56
+ end
16
57
  end
17
58
  end