Skip to content

Commit 944ca3a

Browse files
committed
Batch hasChildren resolution in class tree endpoint
Resolve hasChildren for the whole node set in a single grouped count query instead of one LIMIT 1 SPARQL round-trip per node. hasChildren is exactly (childrenCount > 0), so Class.load_has_children_batch derives it from already-loaded data (the children-count aggregate or loaded children) and falls back to one grouped query for the rest, pre-warming @intlHasChildren so the per-node load_has_children calls become no-ops. Wired into Concept::Tree#tree (path nodes + their children) and OntologySubmission#roots (gated on :hasChildren), removing the per-root N+1 that hits up to FLAT_ROOTS_LIMIT queries for flat ontologies. Adds regression tests asserting hasChildren <-> childrenCount across OWL and SKOS trees and that roots(:hasChildren) preserves loaded attributes. #296
1 parent 752e7df commit 944ca3a

6 files changed

Lines changed: 158 additions & 1 deletion

File tree

lib/ontologies_linked_data/concerns/concepts/concept_tree.rb

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,20 @@ def tree(concept_schemes: [], concept_collections: [], roots: nil)
2424
path.last.instance_variable_set("@children", [])
2525

2626
childrens_hash = {}
27+
path.each do |m|
28+
next if m.id.to_s["#Thing"]
29+
m.children.each { |c| childrens_hash[c.id.to_s] = c }
30+
end
31+
32+
# Resolve hasChildren for every node we are about to compute attributes
33+
# on in a single grouped query, so the per-node load_has_children calls
34+
# made by load_computed_attributes below become no-ops.
35+
has_children_nodes = path.reject { |m| m.id.to_s["#Thing"] } + childrens_hash.values
36+
LinkedData::Models::Class.load_has_children_batch(has_children_nodes, submission)
37+
2738
path.each do |m|
2839
next if m.id.to_s["#Thing"]
2940
m.children.each do |c|
30-
childrens_hash[c.id.to_s] = c
3141
c.load_computed_attributes(to_load:extra_include ,
3242
options: {schemes: concept_schemes, collections: concept_collections})
3343
end

lib/ontologies_linked_data/models/class.rb

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -483,6 +483,40 @@ def self.partially_load_children(models, threshold, submission)
483483
self.in(submission).models(single_load).include({children: ld}).all if single_load.length > 0
484484
end
485485

486+
# Resolves hasChildren for a set of classes, pre-warming each instance's
487+
# @intlHasChildren so subsequent per-node #load_has_children calls become
488+
# no-ops. hasChildren is true iff the class has at least one child, which
489+
# is exactly (children count > 0) -- #children and #hasChildren both use
490+
# tree_view_property -- so we derive it from data already on hand and fall
491+
# back to a single grouped count query for the rest, instead of issuing
492+
# one has_children_query SPARQL round-trip per node.
493+
def self.load_has_children_batch(models, submission)
494+
models = models.to_a.compact.reject do |m|
495+
!m.instance_variable_get("@intlHasChildren").nil? || m.id.to_s["#Thing"]
496+
end
497+
return if models.empty?
498+
499+
remaining = []
500+
models.each do |m|
501+
if m.aggregates
502+
agg = m.aggregates.find { |x| x.attribute == :children && x.aggregate == :count }
503+
m.instance_variable_set("@intlHasChildren", agg.value > 0) if agg
504+
elsif m.loaded_attributes.include?(:children)
505+
m.instance_variable_set("@intlHasChildren", !m.children.empty?)
506+
else
507+
remaining << m
508+
end
509+
end
510+
511+
return if remaining.empty?
512+
513+
self.in(submission).models(remaining).aggregate(:count, :children).all
514+
remaining.each do |m|
515+
agg = m.aggregates&.find { |x| x.attribute == :children && x.aggregate == :count }
516+
m.instance_variable_set("@intlHasChildren", agg.value > 0) if agg
517+
end
518+
end
519+
486520
def load_computed_attributes(to_load:, options:)
487521
self.load_has_children if to_load&.include?(:hasChildren)
488522
self.load_is_in_scheme(options[:schemes]) if to_load&.include?(:isInActiveScheme)

lib/ontologies_linked_data/models/ontology_submission.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -769,6 +769,12 @@ def roots(extra_include = [], page = nil, pagesize = nil, concept_schemes: [], c
769769

770770
LinkedData::Models::Class.partially_load_children(classes, 99, self) if load_children.length > 0
771771

772+
# Resolve hasChildren for all roots in one grouped query rather than one
773+
# has_children_query per root (up to FLAT_ROOTS_LIMIT for flat ontologies).
774+
if extra_include&.include?(:hasChildren)
775+
LinkedData::Models::Class.load_has_children_batch(classes, self)
776+
end
777+
772778
classes.delete_if { |c|
773779
obs = !c.obsolete.nil? && c.obsolete == true
774780
if !obs

test/models/test_class.rb

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,42 @@ def test_bro_tree
260260
end
261261
end
262262

263+
# Guards the hasChildren <-> childrenCount contract on every node of a built
264+
# tree. The batched hasChildren optimization derives hasChildren from the
265+
# child-count aggregate, so this asserts the two stay consistent and that
266+
# hasChildren is always loaded (never raises) on tree nodes.
267+
def test_bro_tree_has_children
268+
if !LinkedData::Models::Ontology.find("BROTEST123").first
269+
submission_parse("BROTEST123", "SOME BROTEST Bla", "./test/data/ontology_files/BRO_v3.2.owl", 123,
270+
process_rdf: true, index_search: false,
271+
run_metrics: false, reasoning: true)
272+
end
273+
os = LinkedData::Models::Ontology.find("BROTEST123").first.latest_submission(status: [:rdf])
274+
statistical_Text_Analysis = "http://bioontology.org/ontologies/BiomedicalResourceOntology.owl#Statistical_Text_Analysis"
275+
cls = LinkedData::Models::Class.find(RDF::URI.new(statistical_Text_Analysis)).in(os).first
276+
277+
tree_root = cls.tree
278+
279+
checked = 0
280+
stack = [tree_root]
281+
until stack.empty?
282+
node = stack.pop
283+
284+
hc = node.hasChildren
285+
assert_includes [true, false], hc, "hasChildren not a boolean for #{node.id}"
286+
287+
cc = node.aggregates ? node.childrenCount : nil
288+
unless cc.nil?
289+
assert_equal((cc > 0), hc, "hasChildren/childrenCount mismatch for #{node.id}")
290+
checked += 1
291+
end
292+
293+
stack.concat(node.children)
294+
end
295+
296+
assert checked > 0, "expected at least one node with a child-count aggregate"
297+
end
298+
263299

264300
def test_include_ancestors
265301
if !LinkedData::Models::Ontology.find("BROTEST123").first

test/models/test_ontology_submission.rb

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -870,6 +870,26 @@ def test_submission_root_classes
870870
assert_equal 6, roots.length
871871
end
872872

873+
# roots(:hasChildren) is the path the class-tree endpoint takes. Guards that
874+
# the batched hasChildren resolution leaves the other loaded attributes
875+
# (prefLabel) intact and stays consistent with childrenCount.
876+
def test_submission_root_classes_has_children
877+
acr = "CSTPROPS"
878+
init_test_ontology_msotest acr
879+
os = LinkedData::Models::OntologySubmission.where(ontology: [acronym: acr], submissionId: 1).all.first
880+
881+
roots = os.roots([:hasChildren])
882+
refute_empty roots
883+
884+
roots.each do |r|
885+
assert_includes r.loaded_attributes.to_a, :prefLabel, "prefLabel not loaded for #{r.id}"
886+
refute_nil r.prefLabel, "prefLabel nil for #{r.id}"
887+
assert_includes [true, false], r.hasChildren, "hasChildren not a boolean for #{r.id}"
888+
cc = r.aggregates ? r.childrenCount : nil
889+
assert_equal((cc > 0), r.hasChildren, "hasChildren/childrenCount mismatch for #{r.id}") unless cc.nil?
890+
end
891+
end
892+
873893
#escaping sequences
874894
def test_submission_parse_sbo
875895
acronym = "SBO-TST"

test/models/test_skos_submission.rb

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,5 +131,56 @@ def test_roots_of_scheme_collections
131131
assert_equal r.isInActiveCollection, selected_collections unless selected_collections.empty?
132132
end
133133
end
134+
135+
# Builds a tree from a SKOS concept and asserts the structural invariants the
136+
# tree endpoint relies on. Guards the SKOS branch of Class#tree (computed
137+
# attributes, isInActiveScheme) and the hasChildren <-> childrenCount
138+
# contract that the batched hasChildren optimization must preserve.
139+
def test_skos_class_tree
140+
sub = before_suite
141+
roots = sub.roots
142+
refute_empty roots, 'expected SKOS roots'
143+
144+
# Find a root that has children and descend one level so the path to root
145+
# is non-trivial (root -> target).
146+
target = nil
147+
roots.each do |r|
148+
LinkedData::Models::Class.in(sub).models([r]).include(children: [:prefLabel]).all
149+
next if r.children.empty?
150+
151+
target = LinkedData::Models::Class.find(r.children.first.id).in(sub).first
152+
break
153+
end
154+
refute_nil target, 'expected a SKOS root with at least one child'
155+
156+
tree_root = target.tree
157+
158+
# The tree must terminate at one of the submission roots.
159+
assert_includes roots.map { |x| x.id.to_s }, tree_root.id.to_s
160+
161+
# Walk the whole tree and check the invariants on every node.
162+
seen_target = false
163+
stack = [tree_root]
164+
until stack.empty?
165+
node = stack.pop
166+
seen_target ||= node.id.to_s == target.id.to_s
167+
168+
# hasChildren must be loaded (no raise) and a boolean.
169+
hc = node.hasChildren
170+
assert_includes [true, false], hc, "hasChildren not a boolean for #{node.id}"
171+
172+
# When the child-count aggregate is present it must agree with hasChildren.
173+
# This is the semantic contract the batched optimization preserves.
174+
cc = node.aggregates ? node.childrenCount : nil
175+
assert_equal((cc > 0), hc, "hasChildren/childrenCount mismatch for #{node.id}") unless cc.nil?
176+
177+
# SKOS computed scheme attribute must be loaded on tree nodes.
178+
assert_kind_of Array, node.isInActiveScheme
179+
180+
stack.concat(node.children)
181+
end
182+
183+
assert seen_target, 'target class not found within its own tree'
184+
end
134185
end
135186

0 commit comments

Comments
 (0)