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.
- checksums.yaml +4 -4
- data/.github/workflows/dependent-tests.yml +3 -1
- data/.rubocop.yml +18 -0
- data/.rubocop_todo.yml +12 -18
- data/Gemfile +2 -0
- data/README.adoc +114 -2
- data/docs/_guides/index.adoc +18 -0
- data/docs/_guides/jsonld-serialization.adoc +217 -0
- data/docs/_guides/rdf-serialization.adoc +344 -0
- data/docs/_guides/turtle-serialization.adoc +224 -0
- data/docs/_pages/serialization_adapters.adoc +31 -0
- data/docs/_references/index.adoc +1 -0
- data/docs/_references/rdf-namespaces.adoc +243 -0
- data/docs/index.adoc +3 -2
- data/lib/lutaml/jsonld/adapter.rb +23 -0
- data/lib/lutaml/jsonld/context.rb +69 -0
- data/lib/lutaml/jsonld/term_definition.rb +39 -0
- data/lib/lutaml/jsonld/transform.rb +174 -0
- data/lib/lutaml/jsonld.rb +23 -0
- data/lib/lutaml/model/format_registry.rb +10 -1
- data/lib/lutaml/model/serialize/format_conversion.rb +17 -1
- data/lib/lutaml/model/version.rb +1 -1
- data/lib/lutaml/model.rb +6 -0
- data/lib/lutaml/rdf/error.rb +7 -0
- data/lib/lutaml/rdf/iri.rb +44 -0
- data/lib/lutaml/rdf/language_tagged.rb +11 -0
- data/lib/lutaml/rdf/literal.rb +62 -0
- data/lib/lutaml/rdf/mapping.rb +71 -0
- data/lib/lutaml/rdf/mapping_rule.rb +35 -0
- data/lib/lutaml/rdf/member_rule.rb +13 -0
- data/lib/lutaml/rdf/namespace.rb +58 -0
- data/lib/lutaml/rdf/namespace_set.rb +69 -0
- data/lib/lutaml/rdf/namespaces/dcterms_namespace.rb +12 -0
- data/lib/lutaml/rdf/namespaces/owl_namespace.rb +12 -0
- data/lib/lutaml/rdf/namespaces/rdf_namespace.rb +14 -0
- data/lib/lutaml/rdf/namespaces/rdfs_namespace.rb +12 -0
- data/lib/lutaml/rdf/namespaces/skos_namespace.rb +12 -0
- data/lib/lutaml/rdf/namespaces/xsd_namespace.rb +12 -0
- data/lib/lutaml/rdf/namespaces.rb +14 -0
- data/lib/lutaml/rdf/transform.rb +36 -0
- data/lib/lutaml/rdf.rb +19 -0
- data/lib/lutaml/turtle/adapter.rb +35 -0
- data/lib/lutaml/turtle/mapping.rb +7 -0
- data/lib/lutaml/turtle/transform.rb +158 -0
- data/lib/lutaml/turtle.rb +22 -0
- data/spec/lutaml/integration/edge_cases_spec.rb +109 -0
- data/spec/lutaml/integration/multi_format_spec.rb +106 -0
- data/spec/lutaml/integration/round_trip_spec.rb +170 -0
- data/spec/lutaml/jsonld/adapter_spec.rb +46 -0
- data/spec/lutaml/jsonld/context_spec.rb +114 -0
- data/spec/lutaml/jsonld/term_definition_spec.rb +55 -0
- data/spec/lutaml/jsonld/transform_spec.rb +211 -0
- data/spec/lutaml/rdf/graph_serialization_spec.rb +137 -0
- data/spec/lutaml/rdf/iri_spec.rb +73 -0
- data/spec/lutaml/rdf/literal_spec.rb +98 -0
- data/spec/lutaml/rdf/mapping_spec.rb +164 -0
- data/spec/lutaml/rdf/member_rule_spec.rb +17 -0
- data/spec/lutaml/rdf/namespace_set_spec.rb +115 -0
- data/spec/lutaml/rdf/namespace_spec.rb +241 -0
- data/spec/lutaml/rdf/rdf_transform_spec.rb +82 -0
- data/spec/lutaml/turtle/adapter_spec.rb +47 -0
- data/spec/lutaml/turtle/mapping_spec.rb +123 -0
- data/spec/lutaml/turtle/transform_spec.rb +273 -0
- 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
|