Skip to content

Commit 1a7a229

Browse files
committed
feat: keep reified definition nodes in the graph, filter them on read
The step no longer removes anything. Unlinking a node once its text had been read left the graph diverging from the source RDF for the sake of a presentation problem, so it is reverted: like every other step of the pipeline this one only appends, and the reified node keeps both its link and its provenance. The URI is instead kept out of Class#definition. skos:definition carries whatever object the graph puts on it, so a reified node arrives as a bare URI that no reader can use; the attribute now hands out only what is readable, while `properties` still reports the raw predicate, node URI included. A resource is only taken for a reified node when it lives in the concept's own URI space. An external reference - a Wikipedia page, a DOI, a term in another vocabulary - is a definition the ontology means to give and comes through untouched. The namespace is taken from the concept rather than from the submission because this runs inside the reader, once per class per page, and reaching for the submission there would lazy load it. Two details the implementation depends on. The reader is wrapped with alias_method, not redefined: Goo generates it with define_method on the class itself, so a bare `def definition` would replace it and silently drop the language flattening and the AttributeNotLoaded guard. And the filter is applied again in index_doc, because it builds from to_hash, which reads the instance variables straight and bypasses every reader - without it a node URI would still be indexed as the text of the definition. Signed-off-by: BOUKERFA Mohamed El Amine <boukerfa.ma@gmail.com>
1 parent 46a1629 commit 1a7a229

3 files changed

Lines changed: 137 additions & 54 deletions

File tree

lib/ontologies_linked_data/models/class.rb

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,60 @@ def obsolete
196196
@obsolete || false
197197
end
198198

199+
# skos:definition carries whatever object the graph puts on it, and
200+
# thesauri that reify their definitions put the URI of a note node there
201+
# instead of the text. That URI is not a definition anyone can read, so it
202+
# is kept out of the attribute.
203+
#
204+
# Nothing is hidden from the graph: ResolveReifiedDefinitions asserts the
205+
# text of those nodes as literals beside the URI without removing
206+
# anything, and `properties` still reports the raw skos:definition
207+
# objects, node URI included.
208+
alias_method :definition_with_reified_nodes, :definition
209+
210+
def definition(*args)
211+
self.class.reject_reified_nodes(definition_with_reified_nodes(*args),
212+
self.class.uri_namespace(id))
213+
end
214+
215+
# The reader hands back an Array, or a { language => values } Hash when
216+
# languages are asked for - which is how the serializer calls it - or a
217+
# lone value. All three shapes have to survive the filter, and a language
218+
# left with nothing but a node URI drops out entirely.
219+
def self.reject_reified_nodes(value, namespace)
220+
case value
221+
when Hash
222+
value.transform_values { |values| reject_reified_nodes(values, namespace) }
223+
.reject { |_, values| values.nil? || (values.respond_to?(:empty?) && values.empty?) }
224+
when Array
225+
value.reject { |v| reified_node?(v, namespace) }
226+
else
227+
reified_node?(value, namespace) ? nil : value
228+
end
229+
end
230+
231+
# A resource-valued definition is only taken for a reified node when it
232+
# lives in the concept's own URI space. An external reference - a
233+
# Wikipedia page, a DOI, a term in another vocabulary - is a definition
234+
# the ontology means to give, and is left exactly as it is.
235+
def self.reified_node?(value, namespace)
236+
return false unless value.is_a?(RDF::Resource)
237+
return true if value.is_a?(RDF::Node) # a blank node is internal by construction
238+
239+
!namespace.empty? && value.to_s.start_with?(namespace)
240+
end
241+
242+
# Everything up to and including a URI's last separator, the way
243+
# LinkedData::Utils::Triples.last_iri_fragment splits it. Taken from the
244+
# concept rather than from the submission on purpose: this runs inside the
245+
# reader, for every class of every page, and reaching for the submission
246+
# there would lazy load it.
247+
def self.uri_namespace(uri)
248+
uri = uri.to_s
249+
separator = uri.index('#') ? uri.rindex('#') : uri.rindex('/')
250+
separator ? uri[0..separator] : ''
251+
end
252+
199253
def index_id()
200254
self.bring(:submission) if self.bring?(:submission)
201255
return nil unless self.submission
@@ -273,6 +327,13 @@ def index_doc(to_set=nil)
273327
doc[:obsolete] = self.obsolete.to_s
274328

275329
all_attrs = self.to_hash
330+
# to_hash reads the instance variables straight, so it bypasses the
331+
# reader that drops reified definition nodes - filter here too, or a
332+
# node URI gets indexed as the text of the definition.
333+
if all_attrs.key?(:definition)
334+
all_attrs[:definition] = self.class.reject_reified_nodes(all_attrs[:definition],
335+
self.class.uri_namespace(id))
336+
end
276337
std = [:id, :prefLabel, :notation, :synonym, :definition, :cui]
277338
multi_language_fields = [:prefLabel, :synonym, :definition]
278339
std.each do |att|

lib/ontologies_linked_data/services/submission_process/operations/submission_reified_definitions.rb

Lines changed: 17 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,11 @@ module Services
2121
# asserts the text back on the concept as a skos:definition literal, keeping
2222
# the language tag.
2323
#
24-
# Once a node is read, the triple pointing at it is dropped, so the concept
25-
# is left with its definition as text instead of a URI nothing can resolve.
26-
# This is the one step of the pipeline that removes a source triple, and it
27-
# removes only what it has replaced: a node whose text could not be read
28-
# keeps its link, and the node's own triples are never touched - the
29-
# provenance stays in the graph, reachable by URI.
24+
# Nothing is removed: like every other step of the pipeline this one only
25+
# appends, so the submission graph stays a faithful copy of the source RDF
26+
# and the reified node keeps both its link and its provenance. A concept
27+
# therefore carries two definition objects afterwards - the node URI it
28+
# always had, and the text read out of it.
3029
class ResolveReifiedDefinitions < OntologySubmissionProcess
3130

3231
# Concepts resolved per round trip to the triple store.
@@ -139,7 +138,7 @@ def process(logger, options = {})
139138
def resolve_reified_definitions(logger, file_path)
140139
properties = definition_properties
141140
save_in_file = file_path.nil? ? nil : File.join(File.dirname(file_path), 'definitions.ttl')
142-
stats = { scanned: 0, asserted: 0, unlinked: 0, unresolved: 0 }
141+
stats = { scanned: 0, asserted: 0, unresolved: 0 }
143142
fsave = nil
144143
after = nil
145144

@@ -148,7 +147,7 @@ def resolve_reified_definitions(logger, file_path)
148147
concepts = reified_definition_concepts(properties, after)
149148
break if concepts.empty?
150149

151-
triples, resolved_links = resolve_concepts(logger, properties, concepts, stats)
150+
triples = resolve_concepts(logger, properties, concepts, stats)
152151
stats[:scanned] += concepts.length
153152

154153
unless triples.empty?
@@ -159,11 +158,6 @@ def resolve_reified_definitions(logger, file_path)
159158
stats[:asserted] += triples.length
160159
end
161160

162-
unless resolved_links.empty?
163-
Goo.sparql_update_client.delete_data(resolved_links, graph: @submission.id)
164-
stats[:unlinked] += resolved_links.length
165-
end
166-
167161
after = concepts.last
168162
break if concepts.length < PAGE_SIZE
169163
end
@@ -179,7 +173,6 @@ def report(logger, stats, save_in_file, time)
179173
else
180174
logger.info("Resolved #{stats[:asserted]} definitions out of the reified nodes of " \
181175
"#{stats[:scanned]} concepts in #{time.round(2)} sec.")
182-
logger.info("Dropped #{stats[:unlinked]} triples pointing at a node that has been read")
183176
logger.info("Saved resolved definitions in #{save_in_file}") if save_in_file && stats[:asserted].positive?
184177
end
185178

@@ -205,10 +198,10 @@ def definition_properties
205198
end
206199

207200
# One page of concepts whose definition is a resource instead of a literal,
208-
# starting after the last concept of the previous page. Paging on the
209-
# cursor rather than on an offset is what makes it safe to unlink resolved
210-
# nodes as we go: the step shrinks the very set it is walking, and an
211-
# OFFSET would skip over as many concepts as it just resolved.
201+
# starting after the last concept of the previous page. A cursor rather
202+
# than an OFFSET: the literals this step asserts never match the pattern
203+
# below, but paging that survives a shrinking match set costs nothing here
204+
# and does not have to be revisited if that ever stops being true.
212205
def reified_definition_concepts(properties, after)
213206
filters = ['!isLiteral(?node)']
214207
# IRIs order by their string value, so the cursor can compare on str().
@@ -231,16 +224,12 @@ def reified_definition_concepts(properties, after)
231224
concepts
232225
end
233226

234-
# Returns the definition triples to assert and the reified links they
235-
# replace, which the caller drops from the graph.
236227
def resolve_concepts(logger, properties, concepts, stats)
237228
values = concepts.map(&:to_ntriples).join(' ')
238229
existing = existing_definitions(properties, values)
239-
nodes, links = definition_nodes(properties, values)
240230
triples = []
241-
resolved_links = []
242231

243-
nodes.each do |concept, concept_nodes|
232+
definition_nodes(properties, values).each do |concept, concept_nodes|
244233
concept_nodes.each do |node, predicates|
245234
literals = definition_text(predicates)
246235

@@ -259,23 +248,18 @@ def resolve_concepts(logger, properties, concepts, stats)
259248
Goo.vocabulary(:skos)[:definition],
260249
definition_literal(literal))
261250
end
262-
263-
# Read, so the concept no longer needs to point at it.
264-
resolved_links.concat(links[[concept, node]].to_a)
265251
end
266252
end
267253

268-
[triples, resolved_links]
254+
triples
269255
end
270256

271257
# The literals hanging off the reified nodes of a page of concepts, as
272-
# { concept => { node => { predicate => [literal] } } }, along with the
273-
# triples that link each concept to each of its nodes, as
274-
# { [concept, node] => #<Set: statements> }. Nodes carrying no literal at
275-
# all are kept (empty), they are the ones to report.
258+
# { concept => { node => { predicate => [literal] } } }. Nodes carrying no
259+
# literal at all are kept (empty), they are the ones to report.
276260
def definition_nodes(properties, values)
277261
query = <<~SPARQL
278-
SELECT ?concept ?definitionProperty ?node ?p ?o
262+
SELECT ?concept ?node ?p ?o
279263
FROM #{@submission.id.to_ntriples}
280264
WHERE {
281265
VALUES ?concept { #{values} }
@@ -290,23 +274,13 @@ def definition_nodes(properties, values)
290274
SPARQL
291275

292276
nodes = Hash.new { |h, k| h[k] = Hash.new { |h2, k2| h2[k2] = Hash.new { |h3, k3| h3[k3] = [] } } }
293-
links = Hash.new { |h, k| h[k] = Set.new }
294277
Goo.sparql_query_client.query(query).each_solution do |sol|
295278
predicates = nodes[sol[:concept].to_s][sol[:node].to_s]
296-
# The same concept can reach one node through more than one definition
297-
# property, and each of those links has to go once the node is read.
298-
# Only IRI nodes: DELETE DATA cannot name a blank node, so a blank
299-
# reified definition gets its text read but keeps its link.
300-
if sol[:node].is_a?(RDF::URI)
301-
links[[sol[:concept].to_s, sol[:node].to_s]] << RDF::Statement.new(sol[:concept],
302-
sol[:definitionProperty],
303-
sol[:node])
304-
end
305279
next if sol[:p].nil?
306280

307281
predicates[sol[:p].to_s] << sol[:o] if text_literal?(sol[:o])
308282
end
309-
[nodes, links]
283+
nodes
310284
end
311285

312286
# The definitions a page of concepts already holds as literals, so nothing

test/models/skos/test_resolve_reified_definitions.rb

Lines changed: 59 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -90,10 +90,55 @@ def test_literal_handling
9090
assert_equal RDF::XSD.string, exotic.datatype
9191
end
9292

93+
# Backend-free: the reader-side filter that keeps node URIs out of the
94+
# definition attribute, across the shapes the Goo reader can return - an
95+
# Array, or a { language => values } Hash when languages are asked for, which
96+
# is how the serializer calls it.
97+
def test_reject_reified_nodes
98+
cls = LinkedData::Models::Class
99+
ns = cls.uri_namespace(REIFIED_CONCEPT)
100+
node = RDF::URI.new(REIFIED_NODE)
101+
102+
assert_equal 'http://opendata.inrae.fr/thesaurusINRAE/', ns
103+
assert_equal 'http://ex.org/onto#', cls.uri_namespace('http://ex.org/onto#c_1')
104+
105+
assert_equal ['texte'], cls.reject_reified_nodes([node, 'texte'], ns)
106+
assert_empty cls.reject_reified_nodes([node], ns)
107+
assert_empty cls.reject_reified_nodes([RDF::Node.new('b1')], ns), 'blank nodes go too'
108+
assert_equal({ fr: ['texte'] }, cls.reject_reified_nodes({ fr: [node, 'texte'] }, ns))
109+
assert_equal({ en: ['text'] }, cls.reject_reified_nodes({ fr: [node], en: ['text'] }, ns),
110+
'a language left with nothing readable drops out')
111+
assert_nil cls.reject_reified_nodes(node, ns)
112+
assert_equal 'texte', cls.reject_reified_nodes('texte', ns)
113+
114+
untouched = [node, 'texte']
115+
cls.reject_reified_nodes(untouched, ns)
116+
assert_equal [node, 'texte'], untouched, 'the filter must not mutate what it is given'
117+
end
118+
119+
# A definition that points outside the concept's URI space is a reference the
120+
# ontology means to give, not a reified node, and must come through untouched.
121+
def test_external_definition_links_are_kept
122+
cls = LinkedData::Models::Class
123+
ns = cls.uri_namespace(REIFIED_CONCEPT)
124+
node = RDF::URI.new(REIFIED_NODE)
125+
wikipedia = RDF::URI.new('https://en.wikipedia.org/wiki/Crop_establishment')
126+
doi = RDF::URI.new('https://doi.org/10.15454/QHFTMX')
127+
128+
assert_equal [wikipedia], cls.reject_reified_nodes([wikipedia], ns)
129+
assert_equal [doi, 'texte'], cls.reject_reified_nodes([doi, 'texte'], ns)
130+
assert_equal [wikipedia, 'texte'], cls.reject_reified_nodes([node, wikipedia, 'texte'], ns)
131+
assert_equal({ '@none' => [wikipedia], fr: ['texte'] },
132+
cls.reject_reified_nodes({ '@none' => [wikipedia], fr: ['texte'] }, ns),
133+
'an untagged external link keeps its bucket')
134+
assert_equal [node], cls.reject_reified_nodes([node], ''),
135+
'with no namespace to compare against, nothing is taken for a node'
136+
end
137+
93138
# The whole step against the triple store: a reified definition becomes a
94-
# literal on the concept, the link to the node it replaces is dropped, the
95-
# provenance is not mistaken for the text, and replaying the step does not
96-
# duplicate anything.
139+
# literal on the concept, nothing is removed from the graph, the provenance is
140+
# not mistaken for the text, and replaying the step does not duplicate
141+
# anything.
97142
def test_resolve_reified_definitions
98143
sub = submission
99144
sub.bring_remaining # the step writes its triples next to the master file
@@ -109,20 +154,23 @@ def test_resolve_reified_definitions
109154
assert_equal :fr, definition.language, 'the language of the reified text is kept'
110155
refute definition.value.start_with?(EXPECTED_SOURCE_START), 'dcterms:source is not the definition'
111156

112-
# The link to the node that has been read is gone: the text is the only
113-
# definition of the concept now.
114-
assert_equal 1, definition_objects(sub, REIFIED_CONCEPT).length
115-
refute_includes definition_objects(sub, REIFIED_CONCEPT).map(&:to_s), REIFIED_NODE
116-
117-
# Only the link is dropped, the node keeps its own triples.
118-
assert node_still_described?(sub, REIFIED_NODE),
119-
'the provenance node stays in the graph, reachable by URI'
157+
# The step only appends: the reified triple and the node it points at are
158+
# both still there.
159+
assert_includes definition_objects(sub, REIFIED_CONCEPT).map(&:to_s), REIFIED_NODE
160+
assert node_still_described?(sub, REIFIED_NODE)
120161

162+
# ... but the attribute only hands out what is readable.
121163
RequestStore.store[:requested_lang] = :FR
122164
cls = LinkedData::Models::Class.find(REIFIED_CONCEPT).in(sub).include(:definition).first
123165
assert_equal 1, cls.definition.length
124166
assert cls.definition.first.to_s.start_with?(EXPECTED_TEXT_START),
125167
'the concept exposes its definition as text, not as a URI'
168+
refute_includes cls.definition.map(&:to_s), REIFIED_NODE
169+
170+
# The raw predicate is still reachable, node URI included.
171+
raw = LinkedData::Models::Class.find(REIFIED_CONCEPT).in(sub).include(:unmapped).first.properties
172+
assert_includes raw[RDF::URI.new(SKOS_DEFINITION)].map(&:to_s), REIFIED_NODE,
173+
'properties reports the graph as it is'
126174

127175
# Replayed on an already enriched graph, the step adds nothing.
128176
sub.resolve_reified_definitions(Logger.new(TestLogFile.new))

0 commit comments

Comments
 (0)