lutaml-model 0.8.4 → 0.8.6

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 (94) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/dependent-tests.yml +5 -0
  3. data/.rubocop.yml +18 -0
  4. data/.rubocop_todo.yml +91 -22
  5. data/Gemfile +2 -0
  6. data/README.adoc +114 -2
  7. data/docs/_guides/index.adoc +18 -0
  8. data/docs/_guides/jsonld-serialization.adoc +217 -0
  9. data/docs/_guides/rdf-serialization.adoc +344 -0
  10. data/docs/_guides/turtle-serialization.adoc +224 -0
  11. data/docs/_migrations/0-8-0-namespace-restructuring.adoc +90 -0
  12. data/docs/_pages/serialization_adapters.adoc +31 -0
  13. data/docs/_references/index.adoc +1 -0
  14. data/docs/_references/rdf-namespaces.adoc +243 -0
  15. data/docs/index.adoc +3 -2
  16. data/lib/lutaml/jsonld/adapter.rb +23 -0
  17. data/lib/lutaml/jsonld/context.rb +69 -0
  18. data/lib/lutaml/jsonld/term_definition.rb +39 -0
  19. data/lib/lutaml/jsonld/transform.rb +174 -0
  20. data/lib/lutaml/jsonld.rb +23 -0
  21. data/lib/lutaml/model/format_registry.rb +10 -1
  22. data/lib/lutaml/model/serialize/format_conversion.rb +17 -1
  23. data/lib/lutaml/model/version.rb +1 -1
  24. data/lib/lutaml/model.rb +6 -0
  25. data/lib/lutaml/rdf/error.rb +7 -0
  26. data/lib/lutaml/rdf/iri.rb +44 -0
  27. data/lib/lutaml/rdf/language_tagged.rb +11 -0
  28. data/lib/lutaml/rdf/literal.rb +62 -0
  29. data/lib/lutaml/rdf/mapping.rb +71 -0
  30. data/lib/lutaml/rdf/mapping_rule.rb +35 -0
  31. data/lib/lutaml/rdf/member_rule.rb +13 -0
  32. data/lib/lutaml/rdf/namespace.rb +58 -0
  33. data/lib/lutaml/rdf/namespace_set.rb +69 -0
  34. data/lib/lutaml/rdf/namespaces/dcterms_namespace.rb +12 -0
  35. data/lib/lutaml/rdf/namespaces/owl_namespace.rb +12 -0
  36. data/lib/lutaml/rdf/namespaces/rdf_namespace.rb +14 -0
  37. data/lib/lutaml/rdf/namespaces/rdfs_namespace.rb +12 -0
  38. data/lib/lutaml/rdf/namespaces/skos_namespace.rb +12 -0
  39. data/lib/lutaml/rdf/namespaces/xsd_namespace.rb +12 -0
  40. data/lib/lutaml/rdf/namespaces.rb +14 -0
  41. data/lib/lutaml/rdf/transform.rb +36 -0
  42. data/lib/lutaml/rdf.rb +19 -0
  43. data/lib/lutaml/turtle/adapter.rb +35 -0
  44. data/lib/lutaml/turtle/mapping.rb +7 -0
  45. data/lib/lutaml/turtle/transform.rb +158 -0
  46. data/lib/lutaml/turtle.rb +22 -0
  47. data/lib/lutaml/xml/adapter/adapter_helpers.rb +1 -42
  48. data/lib/lutaml/xml/adapter/base_adapter.rb +48 -458
  49. data/lib/lutaml/xml/adapter/namespace_data.rb +0 -17
  50. data/lib/lutaml/xml/adapter/namespace_uri_collector.rb +71 -0
  51. data/lib/lutaml/xml/adapter/nokogiri_adapter.rb +5 -1110
  52. data/lib/lutaml/xml/adapter/oga_adapter.rb +6 -846
  53. data/lib/lutaml/xml/adapter/ox_adapter.rb +7 -884
  54. data/lib/lutaml/xml/adapter/plan_based_builder.rb +929 -0
  55. data/lib/lutaml/xml/adapter/rexml_adapter.rb +10 -864
  56. data/lib/lutaml/xml/adapter/xml_parser.rb +86 -0
  57. data/lib/lutaml/xml/adapter/xml_serializer.rb +291 -0
  58. data/lib/lutaml/xml/adapter.rb +0 -1
  59. data/lib/lutaml/xml/adapter_element.rb +7 -1
  60. data/lib/lutaml/xml/builder/base.rb +0 -1
  61. data/lib/lutaml/xml/data_model.rb +9 -1
  62. data/lib/lutaml/xml/document.rb +3 -1
  63. data/lib/lutaml/xml/element.rb +13 -10
  64. data/lib/lutaml/xml/serialization/format_conversion.rb +19 -42
  65. data/lib/lutaml/xml/serialization/instance_methods.rb +26 -35
  66. data/lib/lutaml/xml/transformation/custom_method_wrapper.rb +34 -55
  67. data/lib/lutaml/xml/transformation/rule_applier.rb +1 -1
  68. data/lib/lutaml/xml/xml_element.rb +24 -20
  69. data/spec/lutaml/integration/edge_cases_spec.rb +109 -0
  70. data/spec/lutaml/integration/multi_format_spec.rb +106 -0
  71. data/spec/lutaml/integration/round_trip_spec.rb +170 -0
  72. data/spec/lutaml/jsonld/adapter_spec.rb +46 -0
  73. data/spec/lutaml/jsonld/context_spec.rb +114 -0
  74. data/spec/lutaml/jsonld/term_definition_spec.rb +55 -0
  75. data/spec/lutaml/jsonld/transform_spec.rb +211 -0
  76. data/spec/lutaml/rdf/graph_serialization_spec.rb +137 -0
  77. data/spec/lutaml/rdf/iri_spec.rb +73 -0
  78. data/spec/lutaml/rdf/literal_spec.rb +98 -0
  79. data/spec/lutaml/rdf/mapping_spec.rb +164 -0
  80. data/spec/lutaml/rdf/member_rule_spec.rb +17 -0
  81. data/spec/lutaml/rdf/namespace_set_spec.rb +115 -0
  82. data/spec/lutaml/rdf/namespace_spec.rb +241 -0
  83. data/spec/lutaml/rdf/rdf_transform_spec.rb +82 -0
  84. data/spec/lutaml/turtle/adapter_spec.rb +47 -0
  85. data/spec/lutaml/turtle/mapping_spec.rb +123 -0
  86. data/spec/lutaml/turtle/transform_spec.rb +273 -0
  87. data/spec/lutaml/xml/adapter/base_adapter_regression_spec.rb +151 -0
  88. data/spec/lutaml/xml/adapter/order_spec.rb +150 -0
  89. data/spec/lutaml/xml/clear_parse_state_spec.rb +139 -0
  90. data/spec/lutaml/xml/doubly_defined_namespace_spec.rb +0 -2
  91. data/spec/lutaml/xml/schema/compiler_spec.rb +75 -69
  92. data/spec/lutaml/xml/transformation/custom_method_wrapper_spec.rb +213 -14
  93. metadata +58 -3
  94. data/lib/lutaml/xml/adapter/xml_serialization.rb +0 -145
@@ -19,15 +19,24 @@ module Lutaml
19
19
  attr_accessor :element_order, :attribute_order, :schema_location,
20
20
  :encoding, :doctype
21
21
 
22
- # Store pre-collected namespace data for lazy plan building.
23
- # This is a plain Hash (no adapter objects) collected during from_xml.
24
- attr_accessor :pending_namespace_data
25
-
26
22
  # Store root element reference for truly lazy plan building.
27
23
  # Set in :lazy mode during deserialization; consumed on first to_xml.
28
24
  # Released after plan is built to allow GC of the DOM tree.
29
25
  attr_accessor :pending_plan_root_element
30
26
 
27
+ # Clear all internal XML parse state on this instance.
28
+ #
29
+ # Call before re-serializing a previously-parsed instance when the
30
+ # serialization context has changed (e.g. different namespace config,
31
+ # different XML document structure).
32
+ #
33
+ # @return [self]
34
+ def clear_xml_parse_state!
35
+ @import_declaration_plan = nil
36
+ @pending_plan_root_element = nil
37
+ self
38
+ end
39
+
31
40
  # XML namespace metadata for doubly-defined and alias support.
32
41
  # These carry information from deserialization to serialization.
33
42
  # Accessor methods use the @__ prefixed ivars for backward compatibility.
@@ -74,8 +83,8 @@ module Lutaml
74
83
  # Build or return the cached declaration plan.
75
84
  #
76
85
  # When import_declaration_plan: :lazy (default), builds the plan from
77
- # pre-collected namespace data on first call. No-op when no pending
78
- # data exists (:eager already set, :skip, or programmatic creation).
86
+ # the stored element reference on first call. No-op when no pending
87
+ # element exists (:eager already set, :skip, or programmatic creation).
79
88
  #
80
89
  # @return [DeclarationPlan, nil] The plan or nil
81
90
  def import_declaration_plan
@@ -178,8 +187,7 @@ module Lutaml
178
187
 
179
188
  # Extend INTERNAL_ATTRIBUTES with XML-specific ones
180
189
  def pretty_print_instance_variables
181
- xml_internals = %i[@import_declaration_plan @xml_input_namespaces
182
- @pending_namespace_data @pending_plan_root_element
190
+ xml_internals = %i[@import_declaration_plan @pending_plan_root_element
183
191
  @__xml_namespace_prefix
184
192
  @__xml_ns_prefixes @__xml_original_namespace_uri
185
193
  @xml_declaration @raw_schema_location]
@@ -232,11 +240,6 @@ module Lutaml
232
240
  options[:xml_declaration] = @xml_declaration
233
241
  end
234
242
 
235
- # Pass input namespaces for Namespace Preservation
236
- if instance_variable_defined?(:@xml_input_namespaces) && @xml_input_namespaces&.any?
237
- options[:input_namespaces] = @xml_input_namespaces
238
- end
239
-
240
243
  # Pass stored DeclarationPlan for format preservation.
241
244
  if import_declaration_plan
242
245
  options[:stored_xml_declaration_plan] = import_declaration_plan
@@ -253,39 +256,27 @@ module Lutaml
253
256
 
254
257
  private
255
258
 
256
- # Build declaration plan from pre-collected namespace data or stored
257
- # element reference (lazy mode). Called by xml_declaration_plan getter
258
- # on first access.
259
+ # Build declaration plan from stored element reference (lazy mode).
260
+ # Called by import_declaration_plan getter on first access.
259
261
  # @return [DeclarationPlan, nil]
260
262
  def build_pending_declaration_plan
261
- # Truly lazy: build namespace data from stored element reference
262
- if @pending_plan_root_element
263
- element = @pending_plan_root_element
264
- @pending_plan_root_element = nil # Release reference (allows GC of DOM)
265
- ns_data = Lutaml::Xml::ModelTransform.collect_element_namespaces(element)
266
- if ns_data && !ns_data.empty?
267
- xml_mapping = self.class.mappings_for(:xml)
268
- return Lutaml::Xml::DeclarationPlan.from_input_with_locations(ns_data,
269
- xml_mapping)
270
- end
271
- return nil
272
- end
263
+ return nil unless @pending_plan_root_element
273
264
 
274
- # Fallback: use pre-collected namespace data (eager-lazy hybrid)
275
- ns = @pending_namespace_data
276
- return nil unless ns
265
+ element = @pending_plan_root_element
266
+ @pending_plan_root_element = nil
267
+ ns_data = Lutaml::Xml::ModelTransform.collect_element_namespaces(element)
268
+ return nil unless ns_data && !ns_data.empty?
277
269
 
278
- @pending_namespace_data = nil
279
270
  xml_mapping = self.class.mappings_for(:xml)
280
- Lutaml::Xml::DeclarationPlan.from_input_with_locations(ns,
271
+ Lutaml::Xml::DeclarationPlan.from_input_with_locations(ns_data,
281
272
  xml_mapping)
282
273
  end
283
274
 
284
275
  def set_ordering(attrs)
285
- return unless attrs.respond_to?(:item_order)
276
+ return unless attrs.is_a?(Lutaml::Xml::XmlElement)
286
277
 
287
278
  @element_order = attrs.item_order
288
- @attribute_order = attrs.attribute_order if attrs.respond_to?(:attribute_order)
279
+ @attribute_order = attrs.attribute_order
289
280
  end
290
281
 
291
282
  def set_schema_location(attrs)
@@ -13,14 +13,10 @@ module Lutaml
13
13
  # is called with a block, the new element becomes the context for the duration
14
14
  # of the block, allowing nested element creation.
15
15
  class CustomMethodWrapper
16
- # Initialize the wrapper
17
- #
18
16
  # @param parent [XmlDataModel::XmlElement] Parent element to add children to
19
- # @param rule [CompiledRule] The transformation rule
20
- def initialize(parent, rule)
17
+ def initialize(parent)
21
18
  @parent = parent
22
- @rule = rule
23
- @context_stack = [parent] # Stack of context elements for nested creation
19
+ @context_stack = [parent]
24
20
  end
25
21
 
26
22
  # Get the current context element (top of stack)
@@ -70,9 +66,12 @@ module Lutaml
70
66
 
71
67
  if element_or_string.is_a?(String)
72
68
  add_xml_fragment_or_raw_content(parent, element_or_string)
73
- else
74
- # Add as child element
69
+ elsif element_or_string.is_a?(::Lutaml::Xml::DataModel::XmlElement)
75
70
  parent.add_child(element_or_string)
71
+ else
72
+ raise TypeError,
73
+ "add_element expects a String or XmlElement, got " \
74
+ "#{element_or_string.class}. Call .to_xml on the element first."
76
75
  end
77
76
  element_or_string
78
77
  end
@@ -81,7 +80,7 @@ module Lutaml
81
80
  require "moxml" unless defined?(Moxml)
82
81
  fragment_doc = Moxml.new.parse(fragment_string, fragment: true)
83
82
  add_fragment_children_to_parent(fragment_doc, parent)
84
- rescue LoadError, StandardError
83
+ rescue LoadError
85
84
  append_raw_content(parent, fragment_string)
86
85
  end
87
86
 
@@ -135,12 +134,12 @@ module Lutaml
135
134
 
136
135
  # Add text to element (mimics old adapter API)
137
136
  #
138
- # @param element [XmlDataModel::XmlElement, CustomMethodWrapper, nil] Element to add text to
137
+ # @param element [XmlDataModel::XmlElement, CustomMethodWrapper, nil]
138
+ # Element to add text to. When the wrapper itself or nil is passed,
139
+ # text is added to the current context element.
139
140
  # @param text [String] Text content
140
141
  def add_text(element, text)
141
- # Handle case where element is the wrapper itself (for content mapping)
142
- # or when element is nil (add to current context)
143
- target = if element.is_a?(CustomMethodWrapper) || element.nil?
142
+ target = if element == self || element.nil?
144
143
  current_context
145
144
  else
146
145
  element
@@ -169,32 +168,14 @@ module Lutaml
169
168
  # @yield [ElementWrapper] The created element for customization
170
169
  # @return [XmlElement] The created element
171
170
  def create_and_add_element(name, attributes: {})
172
- # Create XmlDataModel element
173
- element = Lutaml::Xml::DataModel::XmlElement.new(name)
174
-
175
- # Add attributes if provided
176
- attributes&.each do |attr_name, attr_value|
177
- attr = Lutaml::Xml::DataModel::XmlAttribute.new(
178
- attr_name.to_s, attr_value.to_s
179
- )
180
- element.add_attribute(attr)
181
- end
182
-
183
- # Add to current context
171
+ element = self.class.build_element(name, attributes)
184
172
  current_context.add_child(element)
185
173
 
186
174
  if block_given?
187
- # Push this element as the new context for nested operations
188
175
  push_context(element)
189
-
190
176
  begin
191
- # Create wrapper for the element
192
- wrapped_element = ElementWrapper.new(element, self)
193
-
194
- # Yield for customization (e.g., adding text, more nested elements)
195
- yield wrapped_element
177
+ yield ElementWrapper.new(element, self)
196
178
  ensure
197
- # Restore previous context
198
179
  pop_context
199
180
  end
200
181
  end
@@ -216,13 +197,7 @@ module Lutaml
216
197
  # @param text [String] Text content
217
198
  # @param cdata [Boolean, Hash] Whether to use CDATA (true or {cdata: true})
218
199
  def add_text(_self, text, cdata: false)
219
- # Handle both cdata: true and cdata: {cdata: true} formats
220
- use_cdata = if cdata.is_a?(Hash)
221
- cdata[:cdata] || false
222
- else
223
- cdata
224
- end
225
-
200
+ use_cdata = cdata.is_a?(Hash) ? cdata[:cdata] || false : cdata
226
201
  @element.text_content = text
227
202
  @element.cdata = use_cdata
228
203
  end
@@ -234,29 +209,33 @@ module Lutaml
234
209
  # @yield [ElementWrapper] The created element for customization
235
210
  # @return [XmlElement] The created element
236
211
  def create_and_add_element(name, attributes: {})
237
- # Create XmlDataModel element
238
- child = Lutaml::Xml::DataModel::XmlElement.new(name)
239
-
240
- # Add attributes if provided
241
- attributes&.each do |attr_name, attr_value|
242
- attr = Lutaml::Xml::DataModel::XmlAttribute.new(
243
- attr_name.to_s, attr_value.to_s
244
- )
245
- child.add_attribute(attr)
246
- end
247
-
248
- # Add to this element
212
+ child = CustomMethodWrapper.build_element(name, attributes)
249
213
  @element.add_child(child)
250
214
 
251
215
  if block_given?
252
- # Wrap the child and yield
253
- wrapped_child = ElementWrapper.new(child, @parent_wrapper)
254
- yield wrapped_child
216
+ yield ElementWrapper.new(child, @parent_wrapper)
255
217
  end
256
218
 
257
219
  child
258
220
  end
259
221
  end
222
+
223
+ # Shared factory: create an XmlElement with optional attributes.
224
+ # Public so ElementWrapper can call it without an instance.
225
+ #
226
+ # @param name [String] Element name
227
+ # @param attributes [Hash] Optional attributes
228
+ # @return [DataModel::XmlElement]
229
+ def self.build_element(name, attributes)
230
+ element = Lutaml::Xml::DataModel::XmlElement.new(name)
231
+ attributes&.each do |attr_name, attr_value|
232
+ attr = Lutaml::Xml::DataModel::XmlAttribute.new(
233
+ attr_name.to_s, attr_value.to_s
234
+ )
235
+ element.add_attribute(attr)
236
+ end
237
+ element
238
+ end
260
239
  end
261
240
  end
262
241
  end
@@ -239,7 +239,7 @@ register_id)
239
239
  # @param model_class [Class] The model class
240
240
  # @param model_instance [Object] The model instance
241
241
  def apply_custom_method(parent, rule, model_class, model_instance)
242
- wrapper = ::Lutaml::Xml::CustomMethodWrapper.new(parent, rule)
242
+ wrapper = ::Lutaml::Xml::CustomMethodWrapper.new(parent)
243
243
  mapper_instance = model_class.new
244
244
  mapper_instance.send(rule.custom_methods[:to], model_instance,
245
245
  parent, wrapper)
@@ -257,33 +257,26 @@ module Lutaml
257
257
  return @order_cache if @order_cache
258
258
 
259
259
  @order_cache = children.filter_map do |child|
260
- if child.text?
260
+ if child.cdata?
261
+ Lutaml::Xml::Element.new("Text", "#cdata-section",
262
+ text_content: child.text,
263
+ node_type: :cdata)
264
+ elsif child.text?
261
265
  next if child.text.nil?
262
266
 
263
- # For text nodes:
264
- # - name is "text" for backward compatibility with tests
265
- # - text_content contains the actual text for round-trip serialization
266
- # - node_type explicitly marks this as a text node
267
267
  Lutaml::Xml::Element.new("Text", "text",
268
268
  text_content: child.text,
269
269
  node_type: :text)
270
- elsif child.cdata?
271
- # For CDATA sections:
272
- # - name is "#cdata-section" for backward compatibility
273
- # - text_content contains the actual CDATA content
274
- # - node_type explicitly marks this as CDATA
275
- Lutaml::Xml::Element.new("Text", "#cdata-section",
276
- text_content: child.text,
277
- node_type: :cdata)
278
270
  elsif child.comment?
279
271
  Lutaml::Xml::Element.new("Comment", "comment",
280
272
  text_content: child.text,
281
273
  node_type: :comment)
274
+ elsif child.processing_instruction?
275
+ Lutaml::Xml::Element.new("ProcessingInstruction",
276
+ child.unprefixed_name,
277
+ text_content: child.text,
278
+ node_type: :processing_instruction)
282
279
  else
283
- # For regular elements:
284
- # - name is the actual element name
285
- # - node_type explicitly marks this as an element
286
- # - namespace_uri and namespace_prefix preserve namespace info for rule matching
287
280
  Lutaml::Xml::Element.new("Element", child.unprefixed_name,
288
281
  node_type: :element,
289
282
  namespace_uri: child.namespace_uri,
@@ -303,14 +296,14 @@ module Lutaml
303
296
 
304
297
  def text
305
298
  return @text if children.empty?
306
- return text_children.map(&:text) if children.count > 1
299
+ return text_children.map(&:text) if content_bearing_children_count > 1
307
300
 
308
301
  text_children.map(&:text).join
309
302
  end
310
303
 
311
304
  def cdata
312
305
  return @text if children.empty?
313
- return cdata_children.map(&:text) if children.count > 1
306
+ return cdata_children.map(&:text) if content_bearing_children_count > 1
314
307
 
315
308
  cdata_children.map(&:text).join
316
309
  end
@@ -330,7 +323,8 @@ module Lutaml
330
323
 
331
324
  @element_children = children.reject do |child|
332
325
  child.is_a?(String) || child.is_a?(Symbol) ||
333
- (child.is_a?(XmlElement) && child.text?)
326
+ (child.is_a?(XmlElement) &&
327
+ (child.text? || child.processing_instruction?))
334
328
  end
335
329
  end
336
330
 
@@ -389,6 +383,8 @@ module Lutaml
389
383
 
390
384
  @children_index = {}
391
385
  @children.each do |child|
386
+ next if child.is_a?(XmlElement) && child.processing_instruction?
387
+
392
388
  key = child.namespaced_name
393
389
  @children_index[key] ||= []
394
390
  @children_index[key] << child
@@ -432,6 +428,14 @@ module Lutaml
432
428
 
433
429
  private
434
430
 
431
+ # Count children that bear content (excludes processing instructions).
432
+ # Used to determine if content is mixed (multiple content nodes).
433
+ def content_bearing_children_count
434
+ children.count do |child|
435
+ !child.is_a?(XmlElement) || !child.processing_instruction?
436
+ end
437
+ end
438
+
435
439
  # Backward compatibility: infer node_type from name
436
440
  # This allows old code that doesn't pass node_type to still work
437
441
  def infer_node_type_from_name(name)
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "lutaml/rdf"
5
+
6
+ RSpec.describe "RDF Namespace edge cases" do
7
+ describe "Namespace immutability" do
8
+ it "prevents URI change after initial set" do
9
+ ns_class = Class.new(Lutaml::Rdf::Namespace)
10
+ ns_class.uri "http://example.org/"
11
+ expect { ns_class.uri "http://other.org/" }.to raise_error(FrozenError)
12
+ end
13
+
14
+ it "prevents prefix change after initial set" do
15
+ ns_class = Class.new(Lutaml::Rdf::Namespace)
16
+ ns_class.prefix "ex"
17
+ expect { ns_class.prefix "other" }.to raise_error(FrozenError)
18
+ end
19
+ end
20
+
21
+ describe "NamespaceSet collision detection" do
22
+ it "raises when adding two different classes with same prefix" do
23
+ ns1 = Class.new(Lutaml::Rdf::Namespace)
24
+ ns1.uri "http://one.org/"
25
+ ns1.prefix "ex"
26
+
27
+ ns2 = Class.new(Lutaml::Rdf::Namespace)
28
+ ns2.uri "http://two.org/"
29
+ ns2.prefix "ex"
30
+
31
+ set = Lutaml::Rdf::NamespaceSet.new(ns1)
32
+ expect { set.add(ns2) }.to raise_error(ArgumentError, /conflicts/)
33
+ end
34
+
35
+ it "allows adding the same class twice" do
36
+ ns = Class.new(Lutaml::Rdf::Namespace)
37
+ ns.uri "http://example.org/"
38
+ ns.prefix "ex"
39
+
40
+ set = Lutaml::Rdf::NamespaceSet.new(ns)
41
+ expect { set.add(ns) }.not_to raise_error
42
+ expect(set.size).to eq(1)
43
+ end
44
+ end
45
+
46
+ describe "NamespaceSet edge cases" do
47
+ it "returns nil for unknown prefix lookup" do
48
+ set = Lutaml::Rdf::NamespaceSet.new
49
+ expect(set["unknown"]).to be_nil
50
+ end
51
+
52
+ it "returns nil for unknown URI compaction" do
53
+ set = Lutaml::Rdf::NamespaceSet.new
54
+ expect(set.compact("http://unknown.org/thing")).to be_nil
55
+ end
56
+
57
+ it "handles empty namespace set" do
58
+ set = Lutaml::Rdf::NamespaceSet.new
59
+ expect(set.size).to eq(0)
60
+ expect(set.empty?).to be(true)
61
+ expect(set.to_a).to eq([])
62
+ expect(set.to_hash).to eq({})
63
+ end
64
+
65
+ it "returns value as-is when no colon in compact IRI" do
66
+ set = Lutaml::Rdf::NamespaceSet.new
67
+ expect(set.resolve_compact_iri("plain_name")).to eq("plain_name")
68
+ end
69
+ end
70
+
71
+ describe "Iri value object edge cases" do
72
+ it "stores frozen string value" do
73
+ iri = Lutaml::Rdf::Iri.new("http://example.org/")
74
+ expect(iri.value).to be_frozen
75
+ end
76
+
77
+ it "compares with Comparable" do
78
+ a = Lutaml::Rdf::Iri.new("http://a.org/")
79
+ b = Lutaml::Rdf::Iri.new("http://b.org/")
80
+ expect(a < b).to be(true)
81
+ expect(b < a).to be(false)
82
+ end
83
+
84
+ it "returns nil compact when no namespace matches" do
85
+ iri = Lutaml::Rdf::Iri.new("http://unknown.org/thing")
86
+ set = Lutaml::Rdf::NamespaceSet.new
87
+ expect(iri.compact(set)).to be_nil
88
+ end
89
+ end
90
+
91
+ describe "Literal value object edge cases" do
92
+ it "plain literal has no datatype or language" do
93
+ lit = Lutaml::Rdf::Literal.new("hello")
94
+ expect(lit.datatype).to be_nil
95
+ expect(lit.language).to be_nil
96
+ end
97
+
98
+ it "handles empty string value" do
99
+ lit = Lutaml::Rdf::Literal.new("")
100
+ expect(lit.to_turtle).to eq('""')
101
+ expect(lit.to_jsonld_term).to eq("")
102
+ end
103
+
104
+ it "escapes tabs in Turtle output" do
105
+ lit = Lutaml::Rdf::Literal.new("tab\there")
106
+ expect(lit.to_turtle).to include("\\t")
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "lutaml/turtle"
5
+ require "lutaml/jsonld"
6
+
7
+ RSpec.describe "Multi-format model" do
8
+ before do
9
+ stub_const("TestSkosNs", Class.new(Lutaml::Rdf::Namespace) do
10
+ uri "http://www.w3.org/2004/02/skos/core#"
11
+ prefix "skos"
12
+ end)
13
+
14
+ stub_const("MultiFormatModel", Class.new(Lutaml::Model::Serializable) do
15
+ attribute :name, :string
16
+ attribute :description, :string
17
+ attribute :code, :string
18
+
19
+ json do
20
+ map "name", to: :name
21
+ map "description", to: :description
22
+ map "code", to: :code
23
+ end
24
+
25
+ rdf do
26
+ namespace TestSkosNs
27
+
28
+ subject { |m| "http://example.org/concept/#{m.code}" } # rubocop:disable RSpec/NamedSubject
29
+
30
+ type "skos:Concept"
31
+
32
+ predicate :prefLabel, namespace: TestSkosNs, to: :name
33
+ predicate :definition, namespace: TestSkosNs, to: :description
34
+ predicate :notation, namespace: TestSkosNs, to: :code
35
+ end
36
+ end)
37
+ end
38
+
39
+ let(:instance) do
40
+ MultiFormatModel.new(name: "test", description: "desc", code: "42")
41
+ end
42
+
43
+ describe "JSON format" do
44
+ it "serializes without @context" do
45
+ json = instance.to_json
46
+ parsed = JSON.parse(json)
47
+ expect(parsed).not_to have_key("@context")
48
+ expect(parsed["name"]).to eq("test")
49
+ expect(parsed["code"]).to eq("42")
50
+ end
51
+
52
+ it "round-trips" do
53
+ restored = MultiFormatModel.from_json(instance.to_json)
54
+ expect(restored.name).to eq("test")
55
+ expect(restored.code).to eq("42")
56
+ end
57
+ end
58
+
59
+ describe "JSON-LD format" do
60
+ it "serializes with @type and @id" do
61
+ jsonld = instance.to_jsonld
62
+ parsed = JSON.parse(jsonld)
63
+ expect(parsed["@type"]).to eq("skos:Concept")
64
+ expect(parsed["@id"]).to eq("http://example.org/concept/42")
65
+ expect(parsed["prefLabel"]).to eq("test")
66
+ end
67
+
68
+ it "round-trips" do
69
+ restored = MultiFormatModel.from_jsonld(instance.to_jsonld)
70
+ expect(restored.name).to eq("test")
71
+ expect(restored.code).to eq("42")
72
+ end
73
+ end
74
+
75
+ describe "Turtle format" do
76
+ it "serializes with prefixes and type" do
77
+ turtle = instance.to_turtle
78
+ expect(turtle).to include("@prefix skos:")
79
+ expect(turtle).to include("a skos:Concept")
80
+ expect(turtle).to include("<http://example.org/concept/42>")
81
+ expect(turtle).to include("skos:prefLabel \"test\"")
82
+ end
83
+
84
+ it "round-trips" do
85
+ restored = MultiFormatModel.from_turtle(instance.to_turtle)
86
+ expect(restored.name).to eq("test")
87
+ expect(restored.code).to eq("42")
88
+ end
89
+ end
90
+
91
+ describe "cross-format independence" do
92
+ it "JSON serialization does not affect JSON-LD" do
93
+ json_parsed = JSON.parse(instance.to_json)
94
+ jsonld_parsed = JSON.parse(instance.to_jsonld)
95
+ expect(json_parsed).not_to have_key("@type")
96
+ expect(jsonld_parsed).to have_key("@type")
97
+ end
98
+
99
+ it "JSON-LD serialization does not affect Turtle" do
100
+ instance.to_jsonld
101
+ turtle = instance.to_turtle
102
+ expect(turtle).not_to include("@context")
103
+ expect(turtle).to include("@prefix")
104
+ end
105
+ end
106
+ end