From c9738faf4079fbbd36dcf9c45cc4f9525a9812b6 Mon Sep 17 00:00:00 2001 From: Imad Bourouche Date: Fri, 8 Mar 2024 11:28:09 +0100 Subject: [PATCH] Feature: Add URI fetching related triples and serialization in different formats (#125) * Add raptor library to parse ntriples data * Add resource model to fetch id related triples and serialize it * Add and inhance xml, ntriples, turtle and json serializers * Updating rdf version in goo project * updating resource model * Adding tests for resource model and serializers * update the resource test to have a more complete data to test (array, bnodes, typed values) * re-implement xml serializer using RDF/XML parser instead of Raptor * implement array handelling of resource to_object * Enhance and refactor serializers ntriples, turtle and xml * Enhance and refactor serializers ntriples, turtle and xml * Handle blank nodes and reverse triples - handle blank nodes - fetch reverse triples - generate random name for models in to_object, because when two model created the same time one overrides the other - call the new serializer JSONLD and RDF_XML * Impliment new serializers jsonld and rdf_xml - impliment jsonld serializer that uses json-ld library - revert changes in xml.rb file to the original implimentation, and put the new implimentation in rdf_xml.rb file - Add the media types :jsonld and :rdf_xml * Add json-ld gem * Enhance the test resource - Add some cases to the data tests - refactor the test of the serializers formats * Fix test for fetch-related triples and json * clean and refactor the resource serializer code * Removed unused methods * Extracted duplicated code in methods * Removed skip from the tests --------- Co-authored-by: Syphax bouazzouni --- Dockerfile | 1 + Gemfile | 2 + Gemfile.lock | 4 + lib/ontologies_linked_data/media_types.rb | 3 + lib/ontologies_linked_data/models/resource.rb | 187 +++++++++++ .../serializers/jsonld.rb | 40 +++ .../serializers/ntriples.rb | 37 +++ .../serializers/rdf_xml.rb | 43 +++ .../serializers/serializers.rb | 14 +- .../serializers/turtle.rb | 38 +++ test/models/test_resource.rb | 292 ++++++++++++++++++ 11 files changed, 655 insertions(+), 6 deletions(-) create mode 100644 lib/ontologies_linked_data/models/resource.rb create mode 100644 lib/ontologies_linked_data/serializers/jsonld.rb create mode 100644 lib/ontologies_linked_data/serializers/ntriples.rb create mode 100644 lib/ontologies_linked_data/serializers/rdf_xml.rb create mode 100644 lib/ontologies_linked_data/serializers/turtle.rb create mode 100644 test/models/test_resource.rb diff --git a/Dockerfile b/Dockerfile index ccf1defb..42760153 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,6 +7,7 @@ RUN apt-get update -yqq && apt-get install -yqq --no-install-recommends \ openjdk-11-jre-headless \ raptor2-utils \ wait-for-it \ + libraptor2-dev \ && rm -rf /var/lib/apt/lists/* RUN mkdir -p /srv/ontoportal/ontologies_linked_data diff --git a/Gemfile b/Gemfile index c8c821a3..2c0563cc 100644 --- a/Gemfile +++ b/Gemfile @@ -21,8 +21,10 @@ gem 'rubyzip', '~> 1.0' gem 'thin' gem 'request_store' gem 'jwt' +gem 'json-ld', '~> 3.0.2' gem "parallel", "~> 1.24" + # Testing group :test do gem 'email_spec' diff --git a/Gemfile.lock b/Gemfile.lock index 36386a5c..8916ddca 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -92,6 +92,9 @@ GEM i18n (0.9.5) concurrent-ruby (~> 1.0) json (2.7.1) + json-ld (3.0.2) + multi_json (~> 1.12) + rdf (>= 2.2.8, < 4.0) json_pure (2.7.1) jwt (2.8.1) base64 @@ -239,6 +242,7 @@ DEPENDENCIES faraday (~> 1.9) ffi goo! + json-ld (~> 3.0.2) jwt libxml-ruby (~> 2.0) minitest diff --git a/lib/ontologies_linked_data/media_types.rb b/lib/ontologies_linked_data/media_types.rb index d109e80d..01a26480 100644 --- a/lib/ontologies_linked_data/media_types.rb +++ b/lib/ontologies_linked_data/media_types.rb @@ -3,8 +3,11 @@ module MediaTypes HTML = :html JSON = :json JSONP = :jsonp + JSONLD = :jsonld XML = :xml + RDF_XML = :rdf_xml TURTLE = :turtle + NTRIPLES = :ntriples DEFAULT = JSON def self.all diff --git a/lib/ontologies_linked_data/models/resource.rb b/lib/ontologies_linked_data/models/resource.rb new file mode 100644 index 00000000..9bccc785 --- /dev/null +++ b/lib/ontologies_linked_data/models/resource.rb @@ -0,0 +1,187 @@ +require 'rdf/raptor' + +module LinkedData + module Models + + class Resource + + def initialize(graph, id) + @id = id + @graph = graph + @hash = fetch_related_triples(graph, id) + end + + def to_hash + @hash.dup + end + + def to_object + hashes = self.to_hash + class_name = "GeneratedModel_#{Time.now.to_i}_#{rand(10000..99999)}" + model_schema = ::Class.new(LinkedData::Models::Base) + Object.const_set(class_name, model_schema) + + model_schema.model(:resource, name_with: :id, rdf_type: lambda { |*_x| self.to_hash[Goo.namespaces[:rdf][:type].to_s] }) + values_hash = {} + hashes.each do |predicate, value| + namespace, attr = namespace_predicate(predicate) + next if namespace.nil? + + values = Array(value).map do |v| + if v.is_a?(Hash) + Struct.new(*v.keys.map { |k| namespace_predicate(k)[1].to_sym }.compact).new(*v.values) + else + v.is_a?(RDF::URI) ? v.to_s : v.object + end + end.compact + + model_schema.attribute(attr.to_sym, property: namespace.to_s, enforce: get_type(value)) + values_hash[attr.to_sym] = value.is_a?(Array) ? values : values.first + end + + values_hash[:id] = hashes['id'] + model_schema.new(values_hash) + end + + def to_json + LinkedData::Serializers.serialize(to_hash, LinkedData::MediaTypes::JSONLD, namespaces) + end + + def to_xml + LinkedData::Serializers.serialize(to_hash, LinkedData::MediaTypes::RDF_XML, namespaces) + end + + def to_ntriples + LinkedData::Serializers.serialize(to_hash, LinkedData::MediaTypes::NTRIPLES, namespaces) + end + + def to_turtle + LinkedData::Serializers.serialize(to_hash, LinkedData::MediaTypes::TURTLE, namespaces) + end + + def namespaces + prefixes = {} + ns_count = 0 + hash = to_hash + reverse = hash.delete('reverse') + + hash.each do |key, value| + uris = [key] + uris += Array(value).map { |v| v.is_a?(Hash) ? v.to_a.flatten : v }.flatten + prefixes, ns_count = transform_to_prefixes(ns_count, prefixes, uris) + end + + reverse.each { |key, uris| prefixes, ns_count = transform_to_prefixes(ns_count, prefixes, [key] + Array(uris)) } + + prefixes + end + + private + + def transform_to_prefixes(ns_count, prefixes, uris) + uris.each do |uri| + namespace, id = namespace_predicate(uri) + next if namespace.nil? || prefixes.value?(namespace) + + prefix, prefix_namespace = Goo.namespaces.select { |_k, v| v.to_s.eql?(namespace) }.first + if prefix + prefixes[prefix] = prefix_namespace.to_s + else + prefixes["ns#{ns_count}".to_sym] = namespace + ns_count += 1 + end + end + [prefixes, ns_count] + end + + def fetch_related_triples(graph, id) + direct_fetch_query = Goo.sparql_query_client.select(:predicate, :object) + .from(RDF::URI.new(graph)) + .where([RDF::URI.new(id), :predicate, :object]) + + inverse_fetch_query = Goo.sparql_query_client.select(:subject, :predicate) + .from(RDF::URI.new(graph)) + .where([:subject, :predicate, RDF::URI.new(id)]) + + hashes = { 'id' => RDF::URI.new(id) } + + direct_fetch_query.each_solution do |solution| + predicate = solution[:predicate].to_s + value = solution[:object] + + if value.is_a?(RDF::Node) && Array(hashes[predicate]).none? { |x| x.is_a?(Hash) } + value = fetch_b_nodes_triples(graph, id, solution[:predicate]) + elsif value.is_a?(RDF::Node) + next + end + + hashes[predicate] = hashes[predicate] ? (Array(hashes[predicate]) + Array(value)) : value + end + + hashes['reverse'] = {} + inverse_fetch_query.each_solution do |solution| + subject = solution[:subject].to_s + predicate = solution[:predicate] + + if hashes['reverse'][subject] + if hashes['reverse'][subject].is_a?(Array) + hashes['reverse'][subject] << predicate + else + hashes['reverse'][subject] = [predicate, hashes['reverse'][subject]] + end + else + hashes['reverse'][subject] = predicate + end + + end + + hashes + end + + def fetch_b_nodes_triples(graph, id, predicate) + b_node_fetch_query = Goo.sparql_query_client.select(:b, :predicate, :object) + .from(RDF::URI.new(graph)) + .where( + [RDF::URI.new(id), predicate, :b], + %i[b predicate object] + ) + + b_nodes_hash = {} + b_node_fetch_query.each_solution do |s| + b_node_id = s[:b].to_s + s[:predicate].to_s + s[:object] + if b_nodes_hash[b_node_id] + b_nodes_hash[b_node_id][s[:predicate].to_s] = s[:object] + else + b_nodes_hash[b_node_id] = { s[:predicate].to_s => s[:object] } + end + end + b_nodes_hash.values + end + + def get_type(value) + types = [] + types << :list if value.is_a?(Array) + value = Array(value).first + if value.is_a?(RDF::URI) + types << :uri + elsif value.is_a?(Float) + types << :float + elsif value.is_a?(Integer) + types << :integer + elsif value.to_s.eql?('true') || value.to_s.eql?('false') + types << :boolean + end + types + end + + def namespace_predicate(property_url) + regex = /^(?.*[\/#])(?[^\/#]+)$/ + match = regex.match(property_url.to_s) + [match[:namespace], match[:id]] if match + end + + end + end +end \ No newline at end of file diff --git a/lib/ontologies_linked_data/serializers/jsonld.rb b/lib/ontologies_linked_data/serializers/jsonld.rb new file mode 100644 index 00000000..22e6b7d6 --- /dev/null +++ b/lib/ontologies_linked_data/serializers/jsonld.rb @@ -0,0 +1,40 @@ +require 'multi_json' +require 'json/ld' + +module LinkedData + module Serializers + class JSONLD + + def self.serialize(hashes, options = {}) + subject = RDF::URI.new(hashes['id']) + reverse = hashes['reverse'] || {} + hashes.delete('id') + hashes.delete('reverse') + graph = RDF::Graph.new + + hashes.each do |property_url, val| + Array(val).each do |v| + if v.is_a?(Hash) + blank_node = RDF::Node.new + v.each do |blank_predicate, blank_value| + graph << RDF::Statement.new(blank_node, RDF::URI.new(blank_predicate), blank_value) + end + v = blank_node + end + graph << RDF::Statement.new(subject, RDF::URI.new(property_url), v) + end + end + + reverse.each do |reverse_subject, reverse_property| + Array(reverse_property).each do |s| + graph << RDF::Statement.new(RDF::URI.new(reverse_subject), RDF::URI.new(s), subject) + end + end + + context = { '@context' => options.transform_keys(&:to_s) } + compacted = ::JSON::LD::API.compact(::JSON::LD::API.fromRdf(graph), context['@context']) + MultiJson.dump(compacted) + end + end + end +end \ No newline at end of file diff --git a/lib/ontologies_linked_data/serializers/ntriples.rb b/lib/ontologies_linked_data/serializers/ntriples.rb new file mode 100644 index 00000000..c96795a7 --- /dev/null +++ b/lib/ontologies_linked_data/serializers/ntriples.rb @@ -0,0 +1,37 @@ +module LinkedData + module Serializers + class NTRIPLES + + def self.serialize(hashes, options = {}) + subject = RDF::URI.new(hashes['id']) + reverse = hashes['reverse'] || {} + hashes.delete('id') + hashes.delete('reverse') + RDF::Writer.for(:ntriples).buffer(prefixes: options) do |writer| + hashes.each do |p, o| + predicate = RDF::URI.new(p) + Array(o).each do |item| + if item.is_a?(Hash) + blank_node = RDF::Node.new + item.each do |blank_predicate, blank_value| + writer << RDF::Statement.new(blank_node, RDF::URI.new(blank_predicate), blank_value) + end + item = blank_node + end + writer << RDF::Statement.new(subject, predicate, item) + end + end + + reverse.each do |reverse_subject, reverse_property| + Array(reverse_property).each do |s| + writer << RDF::Statement.new(RDF::URI.new(reverse_subject), RDF::URI.new(s), subject) + end + end + end + end + + end + end +end + + \ No newline at end of file diff --git a/lib/ontologies_linked_data/serializers/rdf_xml.rb b/lib/ontologies_linked_data/serializers/rdf_xml.rb new file mode 100644 index 00000000..e06590f0 --- /dev/null +++ b/lib/ontologies_linked_data/serializers/rdf_xml.rb @@ -0,0 +1,43 @@ +module LinkedData + module Serializers + class RDF_XML + def self.serialize(hashes, options = {}) + subject = RDF::URI.new(hashes["id"]) + reverse = hashes["reverse"] || {} + hashes.delete("id") + hashes.delete("reverse") + graph = RDF::Graph.new + + hashes.each do |property_url, val| + Array(val).each do |v| + if v.is_a?(Hash) + blank_node = RDF::Node.new + v.each do |blank_predicate, blank_value| + graph << RDF::Statement.new(blank_node, RDF::URI.new(blank_predicate), blank_value) + end + v = blank_node + end + graph << RDF::Statement.new(subject, RDF::URI.new(property_url), v) + end + end + + inverse_graph = RDF::Graph.new + reverse.each do |reverse_subject, reverse_property| + Array(reverse_property).each do |s| + inverse_graph << RDF::Statement.new(RDF::URI.new(reverse_subject), RDF::URI.new(s), subject) + end + end + + a = RDF::RDFXML::Writer.buffer(prefixes: options) do |writer| + writer << graph + end + + b = RDF::RDFXML::Writer.buffer(prefixes: options) do |writer| + writer << inverse_graph + end + xml_result = "#{a.chomp("\n")}\n#{b.sub!(/^<\?xml[^>]*>\n]*>/, '').gsub(/^$\n/, '')}" + xml_result.gsub(/^$\n/, '') + end + end + end +end \ No newline at end of file diff --git a/lib/ontologies_linked_data/serializers/serializers.rb b/lib/ontologies_linked_data/serializers/serializers.rb index b6006280..1603c1db 100644 --- a/lib/ontologies_linked_data/serializers/serializers.rb +++ b/lib/ontologies_linked_data/serializers/serializers.rb @@ -1,8 +1,12 @@ require 'ontologies_linked_data/media_types' require 'ontologies_linked_data/serializers/xml' +require 'ontologies_linked_data/serializers/rdf_xml' require 'ontologies_linked_data/serializers/json' require 'ontologies_linked_data/serializers/jsonp' +require 'ontologies_linked_data/serializers/jsonld' require 'ontologies_linked_data/serializers/html' +require 'ontologies_linked_data/serializers/ntriples' +require 'ontologies_linked_data/serializers/turtle' module LinkedData module Serializers @@ -10,17 +14,15 @@ def self.serialize(obj, type, options = {}) SERIALIZERS[type].serialize(obj, options) end - class Turtle - def self.serialize(obj, options) - end - end - SERIALIZERS = { LinkedData::MediaTypes::HTML => HTML, LinkedData::MediaTypes::JSON => JSON, LinkedData::MediaTypes::JSONP => JSONP, + LinkedData::MediaTypes::JSONLD => JSONLD, LinkedData::MediaTypes::XML => XML, - LinkedData::MediaTypes::TURTLE => JSON + LinkedData::MediaTypes::RDF_XML => RDF_XML, + LinkedData::MediaTypes::TURTLE => TURTLE, + LinkedData::MediaTypes::NTRIPLES => NTRIPLES } end end \ No newline at end of file diff --git a/lib/ontologies_linked_data/serializers/turtle.rb b/lib/ontologies_linked_data/serializers/turtle.rb new file mode 100644 index 00000000..b0cc9ecf --- /dev/null +++ b/lib/ontologies_linked_data/serializers/turtle.rb @@ -0,0 +1,38 @@ +module LinkedData + module Serializers + class TURTLE + def self.serialize(hashes, options = {}) + subject = RDF::URI.new(hashes['id']) + reverse = hashes['reverse'] || {} + hashes.delete('id') + hashes.delete('reverse') + options.delete(:rdf) + + RDF::Writer.for(:turtle).buffer(prefixes: options) do |writer| + hashes.each do |p, o| + predicate = RDF::URI.new(p) + Array(o).each do |item| + if item.is_a?(Hash) + blank_node = RDF::Node.new + item.each do |blank_predicate, blank_value| + writer << RDF::Statement.new(blank_node, RDF::URI.new(blank_predicate), blank_value) + end + item = blank_node + end + writer << RDF::Statement.new(subject, predicate, item) + end + end + + reverse.each do |reverse_subject, reverse_property| + Array(reverse_property).each do |s| + writer << RDF::Statement.new(RDF::URI.new(reverse_subject), RDF::URI.new(s), subject) + end + end + + end + end + end + end +end + + \ No newline at end of file diff --git a/test/models/test_resource.rb b/test/models/test_resource.rb new file mode 100644 index 00000000..b409ddb1 --- /dev/null +++ b/test/models/test_resource.rb @@ -0,0 +1,292 @@ +require_relative "../test_case" +require_relative './test_ontology_common' + +class TestResource < LinkedData::TestOntologyCommon + + def self.before_suite + LinkedData::TestCase.backend_4s_delete + + # Example + data = %( + . + "John Doe" . + "30"^^ . + "male" . + . + . + _:blanknode1 . + _:blanknode2 . + _:blanknode1 "Jane Smith" . + _:blanknode1 "25"^^ . + _:blanknode1 "female" . + _:blanknode1 . + _:blanknode2 "Jane Smith 2" . + "Hiking" . + "Cooking" . + + . + "Alice Cooper" . + "35"^^ . + "female" . + . + _:skill1, _:skill2 . + _:skill1 "Programming" . + _:skill1 _:skill2 . + _:skill2 "Data Analysis" . + _:skill2 . + "Hiking" . + "Cooking" . + "Photography" . + + . + . + . + + ) + + graph = "http://example.org/test_graph" + Goo.sparql_data_client.execute_append_request(graph, data, '') + + # instance the resource model + @@resource1 = LinkedData::Models::Resource.new("http://example.org/test_graph", "http://example.org/person1") + end + + def self.after_suite + Goo.sparql_data_client.delete_graph("http://example.org/test_graph") + Goo.sparql_data_client.delete_graph("http://data.bioontology.org/ontologies/TEST-TRIPLES/submissions/2") + @resource1&.destroy + end + + def test_generate_model + @object = @@resource1.to_object + @model = @object.class + + assert_equal LinkedData::Models::Base, @model.ancestors[1] + + @model.model_settings[:attributes].map do |property, val| + property_url = "#{val[:property]}#{property}" + assert_includes @@resource1.to_hash.keys, property_url + + hash_value = @@resource1.to_hash[property_url] + object_value = @object.send(property.to_sym) + if property.to_sym == :knows + assert_equal hash_value.map{|x| x.is_a?(Hash) ? x.values : x}.flatten.map(&:to_s).sort, + object_value.map{|x| x.is_a?(String) ? x : x.to_h.values}.flatten.map(&:to_s).sort + else + assert_equal Array(hash_value).map(&:to_s), Array(object_value).map(&:to_s) + end + end + + assert_equal "http://example.org/person1", @object.id.to_s + + assert_equal Goo.namespaces[:foaf][:Person].to_s, @model.type_uri.to_s + end + + def test_resource_fetch_related_triples + result = @@resource1.to_hash + assert_instance_of Hash, result + + refute_empty result + + expected_result = { + "id" => "http://example.org/person1", + "http://www.w3.org/1999/02/22-rdf-syntax-ns#type" => "http://xmlns.com/foaf/0.1/Person", + "http://xmlns.com/foaf/0.1/gender" => "male", + "http://xmlns.com/foaf/0.1/hasInterest" => %w[Cooking Hiking], + "http://xmlns.com/foaf/0.1/age" => "30", + "http://xmlns.com/foaf/0.1/email" => "mailto:john@example.com", + "http://xmlns.com/foaf/0.1/knows" => + ["http://example.org/person3", + { + "http://xmlns.com/foaf/0.1/gender" => "female", + "http://xmlns.com/foaf/0.1/age" => "25", + "http://xmlns.com/foaf/0.1/email" => "mailto:jane@example.com", + "http://xmlns.com/foaf/0.1/name" => "Jane Smith" + }, + { + "http://xmlns.com/foaf/0.1/name" => "Jane Smith 2" + } + ], + "http://xmlns.com/foaf/0.1/name" => "John Doe", + "reverse" => { + "http://example2.org/person2" => "http://xmlns.com/foaf/0.1/mother", + "http://example2.org/person5" => ["http://xmlns.com/foaf/0.1/brother", "http://xmlns.com/foaf/0.1/friend"] + } + } + result = JSON.parse(MultiJson.dump(result)) + a = sort_nested_hash(result) + b = sort_nested_hash(expected_result) + assert_equal b, a + end + + def test_resource_serialization_json + result = @@resource1.to_json + + refute_empty result + expected_result = %( + { + "@context": {"ns0": "http://example.org/", "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#", "foaf": "http://xmlns.com/foaf/0.1/", "ns1": "http://example2.org/"}, + "@graph": [ + { + "@id": "ns0:person1", + "@type": "foaf:Person", + "foaf:name": "John Doe", + "foaf:age": {"@type": "http://www.w3.org/2001/XMLSchema#integer", "@value": "30"}, + "foaf:email": {"@id": "mailto:john@example.com"}, + "foaf:gender": "male", + "foaf:hasInterest": ["Cooking", "Hiking"], + "foaf:knows": [{"@id": "ns0:person3"}, {"@id": "_:g445960"}, {"@id": "_:g445980"}] + }, + { + "@id": "_:g445960", + "foaf:name": "Jane Smith", + "foaf:age": {"@type": "http://www.w3.org/2001/XMLSchema#integer", "@value": "25"}, + "foaf:email": {"@id": "mailto:jane@example.com"}, + "foaf:gender": "female" + }, + {"@id": "_:g445980", "foaf:name": "Jane Smith 2"}, + {"@id": "ns1:person5", "foaf:friend": {"@id": "ns0:person1"}, "foaf:brother": {"@id": "ns0:person1"}}, + {"@id": "ns1:person2", "foaf:mother": {"@id": "ns0:person1"}} + ] + } + ) + result = JSON.parse(result.gsub(' ', '').gsub("\n", '').gsub(/_:g\d+/, 'blanke_nodes')) + expected_result = JSON.parse(expected_result.gsub(' ', '').gsub("\n", '').gsub(/_:g\d+/, 'blanke_nodes')) + + a = sort_nested_hash(result) + b = sort_nested_hash(expected_result) + + assert_equal b, a + end + + def test_resource_serialization_xml + result = @@resource1.to_xml + + refute_empty result + expected_result = %( + + + male + Cooking + Hiking + 30 + + + + + female + 25 + + Jane Smith + + + + + Jane Smith 2 + + + John Doe + + + + + + + + + + ) + a = result.gsub(' ', '').gsub(/rdf:nodeID="[^"]*"/, '').split("\n").reject(&:empty?) + b = expected_result.gsub(' ', '').gsub(/rdf:nodeID="[^"]*"/, '').split("\n").reject(&:empty?) + + assert_equal b.sort, a.sort + end + + def test_resource_serialization_ntriples + result = @@resource1.to_ntriples + + refute_empty result + + expected_result = %( + . + "male" . + "Cooking" . + "Hiking" . + "30"^^ . + . + . + _:g445940 "female" . + _:g445940 "25"^^ . + _:g445940 . + _:g445940 "Jane Smith" . + _:g445940 . + _:g445960 "Jane Smith 2" . + _:g445960 . + "John Doe" . + . + . + . + ) + a = result.gsub(' ', '').gsub(/_:g\d+/, 'blanke_nodes').split("\n").reject(&:empty?) + b = expected_result.gsub(' ', '').gsub(/_:g\d+/, 'blanke_nodes').split("\n").reject(&:empty?) + + assert_equal b.sort, a.sort + end + + def test_resource_serialization_turtle + result = @@resource1.to_turtle + refute_empty result + expected_result = %( + @prefix rdf: . + @prefix ns0: . + @prefix foaf: . + @prefix ns1: . + + ns0:person1 + a foaf:Person ; + foaf:age 30 ; + foaf:email ; + foaf:gender "male" ; + foaf:hasInterest "Cooking", "Hiking" ; + foaf:knows ns0:person3, [ + foaf:age 25 ; + foaf:email ; + foaf:gender "female" ; + foaf:name "Jane Smith" + ], [ + foaf:name "Jane Smith 2" + ] ; + foaf:name "John Doe" . + + ns1:person2 + foaf:mother ns0:person1 . + + ns1:person5 + foaf:brother ns0:person1 ; + foaf:friend ns0:person1 . + ) + a = result.gsub(' ', '').split("\n").reject(&:empty?) + b = expected_result.gsub(' ', '').split("\n").reject(&:empty?) + + assert_equal b.sort, a.sort + end + + private + + def sort_nested_hash(hash) + sorted_hash = {} + + hash.each do |key, value| + if value.is_a?(Hash) + sorted_hash[key] = sort_nested_hash(value) + elsif value.is_a?(Array) + sorted_hash[key] = value.map { |item| item.is_a?(Hash) ? sort_nested_hash(item) : item }.sort_by { |item| item.to_s } + else + sorted_hash[key] = value + end + end + + sorted_hash.sort.to_h + end + +end \ No newline at end of file