lutaml-model 0.8.4 → 0.8.5

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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/dependent-tests.yml +3 -1
  3. data/.rubocop.yml +18 -0
  4. data/.rubocop_todo.yml +12 -18
  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/_pages/serialization_adapters.adoc +31 -0
  12. data/docs/_references/index.adoc +1 -0
  13. data/docs/_references/rdf-namespaces.adoc +243 -0
  14. data/docs/index.adoc +3 -2
  15. data/lib/lutaml/jsonld/adapter.rb +23 -0
  16. data/lib/lutaml/jsonld/context.rb +69 -0
  17. data/lib/lutaml/jsonld/term_definition.rb +39 -0
  18. data/lib/lutaml/jsonld/transform.rb +174 -0
  19. data/lib/lutaml/jsonld.rb +23 -0
  20. data/lib/lutaml/model/format_registry.rb +10 -1
  21. data/lib/lutaml/model/serialize/format_conversion.rb +17 -1
  22. data/lib/lutaml/model/version.rb +1 -1
  23. data/lib/lutaml/model.rb +6 -0
  24. data/lib/lutaml/rdf/error.rb +7 -0
  25. data/lib/lutaml/rdf/iri.rb +44 -0
  26. data/lib/lutaml/rdf/language_tagged.rb +11 -0
  27. data/lib/lutaml/rdf/literal.rb +62 -0
  28. data/lib/lutaml/rdf/mapping.rb +71 -0
  29. data/lib/lutaml/rdf/mapping_rule.rb +35 -0
  30. data/lib/lutaml/rdf/member_rule.rb +13 -0
  31. data/lib/lutaml/rdf/namespace.rb +58 -0
  32. data/lib/lutaml/rdf/namespace_set.rb +69 -0
  33. data/lib/lutaml/rdf/namespaces/dcterms_namespace.rb +12 -0
  34. data/lib/lutaml/rdf/namespaces/owl_namespace.rb +12 -0
  35. data/lib/lutaml/rdf/namespaces/rdf_namespace.rb +14 -0
  36. data/lib/lutaml/rdf/namespaces/rdfs_namespace.rb +12 -0
  37. data/lib/lutaml/rdf/namespaces/skos_namespace.rb +12 -0
  38. data/lib/lutaml/rdf/namespaces/xsd_namespace.rb +12 -0
  39. data/lib/lutaml/rdf/namespaces.rb +14 -0
  40. data/lib/lutaml/rdf/transform.rb +36 -0
  41. data/lib/lutaml/rdf.rb +19 -0
  42. data/lib/lutaml/turtle/adapter.rb +35 -0
  43. data/lib/lutaml/turtle/mapping.rb +7 -0
  44. data/lib/lutaml/turtle/transform.rb +158 -0
  45. data/lib/lutaml/turtle.rb +22 -0
  46. data/spec/lutaml/integration/edge_cases_spec.rb +109 -0
  47. data/spec/lutaml/integration/multi_format_spec.rb +106 -0
  48. data/spec/lutaml/integration/round_trip_spec.rb +170 -0
  49. data/spec/lutaml/jsonld/adapter_spec.rb +46 -0
  50. data/spec/lutaml/jsonld/context_spec.rb +114 -0
  51. data/spec/lutaml/jsonld/term_definition_spec.rb +55 -0
  52. data/spec/lutaml/jsonld/transform_spec.rb +211 -0
  53. data/spec/lutaml/rdf/graph_serialization_spec.rb +137 -0
  54. data/spec/lutaml/rdf/iri_spec.rb +73 -0
  55. data/spec/lutaml/rdf/literal_spec.rb +98 -0
  56. data/spec/lutaml/rdf/mapping_spec.rb +164 -0
  57. data/spec/lutaml/rdf/member_rule_spec.rb +17 -0
  58. data/spec/lutaml/rdf/namespace_set_spec.rb +115 -0
  59. data/spec/lutaml/rdf/namespace_spec.rb +241 -0
  60. data/spec/lutaml/rdf/rdf_transform_spec.rb +82 -0
  61. data/spec/lutaml/turtle/adapter_spec.rb +47 -0
  62. data/spec/lutaml/turtle/mapping_spec.rb +123 -0
  63. data/spec/lutaml/turtle/transform_spec.rb +273 -0
  64. metadata +50 -1
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Turtle
5
+ class MissingSubjectError < Lutaml::Rdf::Error; end
6
+
7
+ class Transform < Lutaml::Rdf::Transform
8
+ def model_to_data(instance, _format, options = {})
9
+ require "rdf/turtle"
10
+ mapping = extract_turtle_mapping(options)
11
+ return "" unless mapping
12
+
13
+ if !mapping.rdf_subject && mapping.rdf_predicates.any? && mapping.rdf_members.empty?
14
+ raise MissingSubjectError,
15
+ "Turtle mapping requires a subject block"
16
+ end
17
+
18
+ graph = build_graph(mapping, instance)
19
+ return "" if graph.empty?
20
+
21
+ prefixes = build_prefixes(mapping, instance)
22
+ RDF::Turtle::Writer.buffer(prefixes: prefixes) do |writer|
23
+ graph.each_statement { |stmt| writer << stmt }
24
+ end.strip
25
+ end
26
+
27
+ def data_to_model(data, _format, options = {})
28
+ require "rdf/turtle"
29
+ mapping = extract_turtle_mapping(options)
30
+ unless mapping&.rdf_subject
31
+ raise MissingSubjectError,
32
+ "Turtle mapping requires a subject block"
33
+ end
34
+
35
+ graph = data.is_a?(RDF::Graph) ? data : Lutaml::Turtle::Adapter.parse(data)
36
+ attrs = extract_attributes(graph, mapping)
37
+ build_instance(attrs, options)
38
+ end
39
+
40
+ private
41
+
42
+ def extract_turtle_mapping(options)
43
+ options[:mappings] || mappings_for(:turtle, lutaml_register)
44
+ end
45
+
46
+ def build_graph(mapping, instance)
47
+ graph = RDF::Graph.new
48
+
49
+ has_predicates_or_type = mapping.rdf_type || mapping.rdf_predicates.any?
50
+
51
+ if has_predicates_or_type
52
+ subject_uri = if mapping.rdf_subject
53
+ RDF::URI(resolve_subject_uri(mapping, instance))
54
+ else
55
+ RDF::Node.new
56
+ end
57
+
58
+ if mapping.rdf_type
59
+ type_uri = RDF::URI(resolve_type_uri(mapping))
60
+ graph << RDF::Statement.new(subject_uri, RDF.type, type_uri)
61
+ end
62
+
63
+ mapping.rdf_predicates.each do |rule|
64
+ value = instance.public_send(rule.to)
65
+ next if value.nil?
66
+
67
+ Array(value).each do |v|
68
+ object = build_rdf_object(v, rule)
69
+ graph << RDF::Statement.new(subject_uri, RDF::URI(rule.uri),
70
+ object)
71
+ end
72
+ end
73
+ end
74
+
75
+ mapping.rdf_members.each do |member_rule|
76
+ collection = Array(instance.public_send(member_rule.attr_name))
77
+ collection.each do |member|
78
+ member_mapping = member.class.mappings[:turtle]
79
+ next unless member_mapping
80
+
81
+ graph << build_graph(member_mapping, member)
82
+ end
83
+ end
84
+
85
+ graph
86
+ end
87
+
88
+ def build_rdf_object(value, rule)
89
+ if rule.lang_tagged
90
+ lang = extract_language(value)
91
+ RDF::Literal.new(value.to_s, language: lang)
92
+ else
93
+ case value
94
+ when Integer then RDF::Literal.new(value, datatype: RDF::XSD.integer)
95
+ when Float then RDF::Literal.new(value, datatype: RDF::XSD.double)
96
+ when TrueClass, FalseClass then RDF::Literal.new(value, datatype: RDF::XSD.boolean)
97
+ else RDF::Literal.new(value.to_s)
98
+ end
99
+ end
100
+ end
101
+
102
+ def build_prefixes(mapping, instance)
103
+ ns_set = mapping.namespace_set
104
+
105
+ mapping.rdf_members.each do |member_rule|
106
+ collection = Array(instance.public_send(member_rule.attr_name))
107
+ next if collection.empty?
108
+
109
+ member_mapping = collection.first.class.mappings[:turtle]
110
+ next unless member_mapping
111
+
112
+ ns_set = ns_set.merge(member_mapping.namespace_set)
113
+ end
114
+
115
+ ns_set.each.with_object({}) do |ns, h|
116
+ h[ns.prefix.to_sym] = ns.uri if ns.prefix && ns.uri
117
+ end
118
+ end
119
+
120
+ def extract_attributes(graph, mapping)
121
+ attrs = {}
122
+ type_uri = resolve_type_uri(mapping)
123
+
124
+ matching_subjects = find_subjects_by_type(graph, type_uri)
125
+
126
+ matching_subjects.each do |subject|
127
+ mapping.rdf_predicates.each do |rule|
128
+ stmts = graph.query([subject, RDF::URI(rule.uri), nil])
129
+ next if stmts.empty?
130
+
131
+ values = stmts.map { |s| literal_to_ruby(s.object) }
132
+ attrs[rule.to] = values.length == 1 ? values.first : values
133
+ end
134
+ end
135
+
136
+ attrs
137
+ end
138
+
139
+ def find_subjects_by_type(graph, type_uri)
140
+ graph.query([nil, RDF.type, RDF::URI(type_uri)]).map(&:subject).uniq
141
+ end
142
+
143
+ def literal_to_ruby(rdf_object)
144
+ case rdf_object
145
+ when RDF::Literal
146
+ case rdf_object.datatype
147
+ when RDF::XSD.integer then rdf_object.value.to_i
148
+ when RDF::XSD.double, RDF::XSD.decimal, RDF::XSD.float then rdf_object.value.to_f
149
+ when RDF::XSD.boolean then rdf_object.value == "true"
150
+ else rdf_object.value
151
+ end
152
+ else
153
+ rdf_object.to_s
154
+ end
155
+ end
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "model"
4
+ require_relative "rdf"
5
+
6
+ module Lutaml
7
+ module Turtle
8
+ autoload :Mapping, "#{__dir__}/turtle/mapping"
9
+ autoload :Transform, "#{__dir__}/turtle/transform"
10
+ autoload :Adapter, "#{__dir__}/turtle/adapter"
11
+ end
12
+ end
13
+
14
+ Lutaml::Model::FormatRegistry.register(
15
+ :turtle,
16
+ mapping_class: Lutaml::Turtle::Mapping,
17
+ adapter_class: Lutaml::Turtle::Adapter,
18
+ transformer: Lutaml::Turtle::Transform,
19
+ key_value: false,
20
+ rdf: true,
21
+ error_types: ["RDF::ReaderError"],
22
+ )
@@ -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
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "lutaml/turtle"
5
+ require "lutaml/jsonld"
6
+
7
+ RSpec.describe "Round-trip fidelity" 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("TestExNs", Class.new(Lutaml::Rdf::Namespace) do
15
+ uri "http://example.org/"
16
+ prefix "ex"
17
+ end)
18
+ end
19
+
20
+ describe "Turtle round-trip" do
21
+ before do
22
+ stub_const("RtTurtleModel", Class.new(Lutaml::Model::Serializable) do
23
+ attribute :label, :string
24
+ attribute :note, :string
25
+ attribute :code, :integer
26
+
27
+ turtle do
28
+ namespace TestSkosNs
29
+
30
+ subject { |m| "http://example.org/item/#{m.code}" } # rubocop:disable RSpec/NamedSubject
31
+
32
+ type "skos:Concept"
33
+ predicate :prefLabel,
34
+ namespace: TestSkosNs,
35
+ to: :label
36
+ predicate :note,
37
+ namespace: TestSkosNs,
38
+ to: :note
39
+ predicate :notation,
40
+ namespace: TestSkosNs,
41
+ to: :code
42
+ end
43
+ end)
44
+ end
45
+
46
+ it "preserves string and integer attributes" do
47
+ original = RtTurtleModel.new(label: "hello", note: "world", code: 99)
48
+ turtle = original.to_turtle
49
+ restored = RtTurtleModel.from_turtle(turtle)
50
+ expect(restored.label).to eq("hello")
51
+ expect(restored.note).to eq("world")
52
+ expect(restored.code).to eq(99)
53
+ end
54
+
55
+ it "handles special characters in string values" do
56
+ original = RtTurtleModel.new(label: 'say "hi"', note: "line1\nline2",
57
+ code: 1)
58
+ turtle = original.to_turtle
59
+ restored = RtTurtleModel.from_turtle(turtle)
60
+ expect(restored.label).to eq('say "hi"')
61
+ expect(restored.note).to eq("line1\nline2")
62
+ end
63
+
64
+ it "handles nil optional attributes" do
65
+ original = RtTurtleModel.new(label: "test", note: nil, code: 1)
66
+ turtle = original.to_turtle
67
+ restored = RtTurtleModel.from_turtle(turtle)
68
+ expect(restored.label).to eq("test")
69
+ expect(restored.note).to be_nil
70
+ expect(restored.code).to eq(1)
71
+ end
72
+ end
73
+
74
+ describe "JSON-LD round-trip" do
75
+ before do
76
+ stub_const("RtJsonLdModel", Class.new(Lutaml::Model::Serializable) do
77
+ attribute :title, :string
78
+ attribute :body, :string
79
+ attribute :priority, :integer
80
+
81
+ rdf do
82
+ namespace TestExNs
83
+
84
+ subject { |m| "http://example.org/articles/#{m.priority}" } # rubocop:disable RSpec/NamedSubject
85
+
86
+ type "http://example.org/Article"
87
+
88
+ predicate :title, namespace: TestExNs, to: :title
89
+ predicate :body, namespace: TestExNs, to: :body
90
+ predicate :priority, namespace: TestExNs, to: :priority
91
+ end
92
+ end)
93
+ end
94
+
95
+ it "preserves all attributes through serialize → deserialize" do
96
+ original = RtJsonLdModel.new(title: "Test", body: "Content", priority: 5)
97
+ json = original.to_jsonld
98
+ restored = RtJsonLdModel.from_jsonld(json)
99
+ expect(restored.title).to eq("Test")
100
+ expect(restored.body).to eq("Content")
101
+ expect(restored.priority).to eq(5)
102
+ end
103
+
104
+ it "preserves @context structure across round-trip" do
105
+ original = RtJsonLdModel.new(title: "Test", body: "Content", priority: 1)
106
+ json1 = original.to_jsonld
107
+ restored = RtJsonLdModel.from_jsonld(json1)
108
+ json2 = restored.to_jsonld
109
+
110
+ ctx1 = JSON.parse(json1)["@context"]
111
+ ctx2 = JSON.parse(json2)["@context"]
112
+ expect(ctx1).to eq(ctx2)
113
+ end
114
+
115
+ it "handles nil optional attributes" do
116
+ original = RtJsonLdModel.new(title: "Test", body: nil, priority: 1)
117
+ json = original.to_jsonld
118
+ restored = RtJsonLdModel.from_jsonld(json)
119
+ expect(restored.title).to eq("Test")
120
+ expect(restored.body).to be_nil
121
+ end
122
+ end
123
+
124
+ describe "Error handling" do
125
+ it "Turtle raises MissingSubjectError without subject" do
126
+ stub_const("NoSubjModel", Class.new(Lutaml::Model::Serializable) do
127
+ attribute :name, :string
128
+
129
+ turtle do
130
+ namespace TestSkosNs
131
+ type "skos:Concept"
132
+ predicate :prefLabel,
133
+ namespace: TestSkosNs,
134
+ to: :name
135
+ end
136
+ end)
137
+
138
+ expect { NoSubjModel.new(name: "test").to_turtle }
139
+ .to raise_error(Lutaml::Turtle::MissingSubjectError, /subject/)
140
+ end
141
+
142
+ it "JSON-LD handles invalid JSON gracefully" do
143
+ stub_const("SimpleJsonLdModel", Class.new(Lutaml::Model::Serializable) do
144
+ attribute :name, :string
145
+
146
+ rdf do
147
+ namespace TestExNs
148
+ predicate :name, namespace: TestExNs, to: :name
149
+ end
150
+ end)
151
+
152
+ expect { SimpleJsonLdModel.from_jsonld("not valid json!!!") }
153
+ .to raise_error(Lutaml::Model::InvalidFormatError)
154
+ end
155
+
156
+ it "Rdf::MappingRule validates namespace type" do
157
+ mapping = Lutaml::Rdf::Mapping.new
158
+ expect do
159
+ mapping.predicate(:foo, namespace: String, to: :bar)
160
+ end.to raise_error(ArgumentError, /Rdf::Namespace/)
161
+ end
162
+
163
+ it "Rdf::MappingRule requires :to parameter" do
164
+ mapping = Lutaml::Rdf::Mapping.new
165
+ expect do
166
+ mapping.predicate(:foo, namespace: TestSkosNs, to: nil)
167
+ end.to raise_error(ArgumentError, /required/)
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "lutaml/jsonld"
5
+
6
+ RSpec.describe Lutaml::JsonLd::Adapter do
7
+ let(:jsonld_hash) do
8
+ {
9
+ "@context" => { "name" => "http://example.org/name" },
10
+ "@type" => "Thing",
11
+ "name" => "test",
12
+ }
13
+ end
14
+
15
+ describe ".parse" do
16
+ it "parses valid JSON-LD string to hash" do
17
+ json = JSON.generate(jsonld_hash)
18
+ result = described_class.parse(json)
19
+ expect(result).to eq(jsonld_hash.transform_keys(&:to_s))
20
+ end
21
+ end
22
+
23
+ describe "#to_jsonld" do
24
+ it "generates JSON-LD string from hash" do
25
+ adapter = described_class.new(jsonld_hash)
26
+ result = adapter.to_jsonld
27
+ parsed = JSON.parse(result)
28
+ expect(parsed["@context"]).to eq({ "name" => "http://example.org/name" })
29
+ end
30
+
31
+ it "supports pretty generation" do
32
+ adapter = described_class.new(jsonld_hash)
33
+ result = adapter.to_jsonld(pretty: true)
34
+ expect(result).to include("\n")
35
+ end
36
+ end
37
+
38
+ it "round-trips parse → generate" do
39
+ json = JSON.generate(jsonld_hash)
40
+ parsed = described_class.parse(json)
41
+ adapter = described_class.new(parsed)
42
+ result = adapter.to_jsonld
43
+ round_tripped = JSON.parse(result)
44
+ expect(round_tripped).to eq(jsonld_hash.transform_keys(&:to_s))
45
+ end
46
+ end