Skip to content

Commit 5688a78

Browse files
authored
Merge pull request #1296 from cwant/cwant/multi_ontology
Multiple ontology support
2 parents dfdb14f + 586ab52 commit 5688a78

9 files changed

Lines changed: 274 additions & 21 deletions

File tree

app/models/concerns/has_edam_terms.rb renamed to app/models/concerns/has_terms_and_synonyms.rb

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
1-
module HasEdamTerms
1+
module HasTermsAndSynonyms
22
extend ActiveSupport::Concern
33

44
def scientific_topics_and_synonyms
5-
edam_term_names_and_synonyms(scientific_topics)
5+
term_names_and_synonyms(scientific_topics)
66
end
77

88
def operations_and_synonyms
9-
edam_term_names_and_synonyms(operations)
9+
term_names_and_synonyms(operations)
1010
end
1111

1212
private
1313

14-
def edam_term_names_and_synonyms(terms)
14+
def term_names_and_synonyms(terms)
1515
terms.map do |term|
1616
[term.preferred_label] + term.has_exact_synonym + term.has_narrow_synonym
1717
end.flatten.uniq

app/models/event.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ class Event < ApplicationRecord
1717
include HasFriendlyId
1818
include FuzzyDictionaryMatch
1919
include WithTimezone
20-
include HasEdamTerms
20+
include HasTermsAndSynonyms
2121
include HasLanguage
2222
include InSpace
2323
include HasPeople

app/models/material.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ class Material < ApplicationRecord
1919
include IdentifiersDotOrg
2020
include HasFriendlyId
2121
include HasDifficultyLevel
22-
include HasEdamTerms
22+
include HasTermsAndSynonyms
2323
include InSpace
2424
include HasPeople
2525

app/models/ontology_term_link.rb

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@ class OntologyTermLink < ApplicationRecord
22
belongs_to :resource, polymorphic: true
33

44
def ontology_term
5-
ontology.lookup(term_uri)
5+
ontology&.lookup(term_uri)
66
end
77

88
def ontology
9-
Edam::Ontology.instance
9+
@ontology ||= Ontology.subclasses.map(&:instance).\
10+
find { |ontology| ontology.term_uri_matches?(term_uri) }
1011
end
1112
end

app/models/workflow.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ class Workflow < ApplicationRecord
1010
include HasFriendlyId
1111
include CurationQueue
1212
include HasDifficultyLevel
13-
include HasEdamTerms
13+
include HasTermsAndSynonyms
1414
include InSpace
1515
include HasPeople
1616

app/ontologies/edam/ontology.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,5 +31,13 @@ def scoped_lookup_by_name(name, subset = :_)
3131
result = graph.query(query).first
3232
lookup(result.u) if result
3333
end
34+
35+
def scoped_lookup_by_name_or_synonym(name, subset = :_)
36+
out = scoped_lookup_by_name(name, subset)
37+
return out unless out.blank?
38+
out = find_by(OBO.hasExactSynonym, name)
39+
return out unless out.blank?
40+
find_by(OBO.hasNarrowSynonym, name)
41+
end
3442
end
3543
end

app/ontologies/ontology.rb

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,20 @@ def initialize(filename, term_class = OntologyTerm)
66
@query_cache = {}
77
end
88

9+
def uri
10+
# Must implement in subclass ...
11+
raise NotImplementedError
12+
end
13+
14+
def scoped_lookup_by_name_or_synonym(name, subset = :_)
15+
# Must implement in subclass ...
16+
raise NotImplementedError
17+
end
18+
19+
def term_uri_matches?(uri)
20+
uri.starts_with?(self.uri)
21+
end
22+
923
def lookup(uri)
1024
@term_cache[RDF::URI(uri)] ||= fetch(uri)
1125
end

lib/has_ontology_terms.rb

Lines changed: 54 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,22 @@ def self.included(mod)
2222
end
2323

2424
module ClassMethods
25-
def has_ontology_terms(association_name, ontology: Edam::Ontology.instance, branch: :_) # :_ is essentially a wildcard, meaning it will match any branch.
25+
def has_ontology_terms(association_name,
26+
ontology: nil,
27+
branch: nil,
28+
ontologies: nil)
29+
unless ontologies
30+
ontology ||= Edam::Ontology.instance
31+
# :_ is essentially a wildcard, meaning it will match any branch.
32+
branch ||= :_
33+
else
34+
# ontologies is an array of hashes with keys :ontology and :branch
35+
ontologies = ontologies.map do |ontology_specification|
36+
{ ontology: ontology_specification[:ontology] || Edam::Ontology.instance,
37+
branch: ontology_specification[:branch] || :_ }
38+
end
39+
end
40+
2641
method = association_name.to_s
2742
singular = association_name.to_s.singularize
2843
links_method = "#{singular}_links"
@@ -43,9 +58,21 @@ def has_ontology_terms(association_name, ontology: Edam::Ontology.instance, bran
4358
dependent: :destroy,
4459
inverse_of: :resource
4560

46-
cattr_accessor :ontology_term_fields
47-
self.ontology_term_fields ||= []
48-
self.ontology_term_fields << method.to_sym
61+
# Previously used cattr_accessor, which uses "@@" variables that mess with inheritance.
62+
# So we do this instead (use "@" class vars), while explicitly merging inherited
63+
# values so STI subclasses still see ontology term fields declared on parent classes.
64+
def self.ontology_term_fields
65+
inherited_fields = superclass.respond_to?(:ontology_term_fields) ? superclass.ontology_term_fields : []
66+
own_fields = @ontology_term_fields ||= []
67+
(inherited_fields + own_fields).uniq
68+
end
69+
70+
def self.add_ontology_term_field(field)
71+
@ontology_term_fields ||= []
72+
@ontology_term_fields << field unless ontology_term_fields.include?(field)
73+
end
74+
75+
self.add_ontology_term_field(method.to_sym)
4976

5077
define_method "#{links_method}=" do |links|
5178
send(links_method).reset
@@ -69,7 +96,7 @@ def has_ontology_terms(association_name, ontology: Edam::Ontology.instance, bran
6996

7097
# OntologyTerm objects
7198
define_method method do
72-
send(links_method).map(&:ontology_term).uniq
99+
send(links_method)&.compact&.map(&:ontology_term)&.uniq&.compact
73100
end
74101

75102
define_method "#{method}=" do |terms|
@@ -78,29 +105,44 @@ def has_ontology_terms(association_name, ontology: Edam::Ontology.instance, bran
78105

79106
# Names/Labels
80107
define_method names_method do
81-
send(method).map(&:preferred_label).uniq
108+
send(method)&.compact&.map(&:preferred_label)&.uniq
82109
end
83110

84111
define_method "#{names_method}=" do |names|
85112
terms = []
86113
[names].flatten.each do |name|
87114
unless name.blank?
88-
st = [ontology.scoped_lookup_by_name(name, branch)].compact # FIXME: This is probably too EDAM specific
89-
st = ontology.find_by(OBO.hasExactSynonym, name) if st.empty?
90-
st = ontology.find_by(OBO.hasNarrowSynonym, name) if st.empty?
115+
st = if ontologies
116+
# TODO: if name is found in first ontology, should it skip others?
117+
ontologies.map do |ontology_specification|
118+
[ontology_specification[:ontology].\
119+
scoped_lookup_by_name_or_synonym(name,
120+
ontology_specification[:branch])]
121+
end
122+
else
123+
[ontology.scoped_lookup_by_name_or_synonym(name, branch)]
124+
end
91125
terms += st
92126
end
93127
end
94-
send("#{method}=", terms.uniq)
128+
send("#{method}=", terms.flatten.compact.uniq)
95129
end
96130

97131
# URIs
98132
define_method uris_method do
99-
send(method).map(&:uri).uniq
133+
send(method)&.compact&.map(&:uri)&.uniq
100134
end
101135

102136
define_method "#{uris_method}=" do |uris|
103-
send("#{method}=", uris.map { |uri| ontology.lookup(uri) })
137+
terms = if ontologies
138+
ontologies.map do |ontology_specification|
139+
uris.map { |uri| ontology_specification[:ontology].lookup(uri) }
140+
end.flatten
141+
else
142+
uris.map { |uri| ontology.lookup(uri) }
143+
end
144+
145+
send("#{method}=", terms)
104146
end
105147
end
106148
end
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
require 'test_helper'
2+
3+
class HasOntologyTermsTest < ActiveSupport::TestCase
4+
# Summary: we create attributes 'test_topics' and 'multi_test_topics' for
5+
# the fake model DummyMaterial. 'test_topics' uses DummyOntology,
6+
# 'multi_test_topics' uses both DummyOntology and Edam::Ontology
7+
8+
teardown do
9+
DummyMaterial.clear_index!
10+
end
11+
12+
class DummyTerm
13+
attr_reader :label, :uri
14+
def initialize(term)
15+
@label = term
16+
@uri = "http://dummy/#{term}"
17+
end
18+
alias_method :preferred_label, :label
19+
end
20+
21+
class DummyOntology < ::Ontology
22+
# A very permissive ontology: it allows any term as long as it doesn't have Chemistry in it
23+
include Singleton
24+
25+
def initialize
26+
end
27+
28+
def uri
29+
'http://dummy/'
30+
end
31+
32+
def scoped_lookup_by_name(term, subset = :_)
33+
return DummyTerm.new(term) unless term =~ /chemistry/i
34+
end
35+
alias_method :scoped_lookup_by_name_or_synonym, :scoped_lookup_by_name
36+
37+
def lookup(uri)
38+
term = uri[/http:\/\/dummy\/(.*)/,1]
39+
return DummyTerm.new(term) unless term.blank?
40+
end
41+
end
42+
43+
class DummyMaterial < ::Material
44+
has_ontology_terms(:test_topics, ontology: DummyOntology.instance)
45+
has_ontology_terms(:multi_test_topics,
46+
ontologies: [{ ontology: Edam::Ontology.instance,
47+
branch: EDAM.topics},
48+
{ ontology: DummyOntology.instance}])
49+
50+
# TODO: see similar tests with model subclasses, maybe can be in a module?
51+
def self.index
52+
(@index ||= Hash.new).values.flatten.uniq
53+
end
54+
55+
def self.add_to_index(m)
56+
index
57+
@index[m.id] = m.reload.collections.to_a
58+
end
59+
60+
def self.clear_index!
61+
@index = Hash.new
62+
end
63+
64+
def solr_index
65+
self.class.add_to_index(self)
66+
end
67+
end
68+
69+
test 'can create an attribute with terms from a single ontology' do
70+
# See the Event/Material model tests for many examples of this ...
71+
dummy = materials(:good_material).becomes(DummyMaterial)
72+
73+
# This is found in the ontology ...
74+
dummy.test_topic_names = ['Bioinformatics']
75+
dummy.save!
76+
assert_equal dummy.test_topics.count, 1
77+
assert_equal dummy.test_topic_names, ['Bioinformatics']
78+
assert_equal dummy.test_topic_uris, ['http://dummy/Bioinformatics']
79+
80+
# This is not
81+
dummy.test_topic_names = ['Biochemistry']
82+
dummy.save!
83+
assert_equal dummy.test_topics.count, 0
84+
assert_equal dummy.test_topic_names, []
85+
assert_equal dummy.test_topic_uris, []
86+
end
87+
88+
test 'can create an attribute with terms from multiple ontologies' do
89+
dummy = materials(:good_material).becomes(DummyMaterial)
90+
91+
# This is found in both ontologies ...
92+
dummy.multi_test_topic_names = ['Bioinformatics']
93+
dummy.save!
94+
assert_equal dummy.multi_test_topics.count, 2
95+
assert_equal Set.new(dummy.multi_test_topic_uris),
96+
Set.new(['http://edamontology.org/topic_0091', 'http://dummy/Bioinformatics'])
97+
# The two exact names collapse into one ...
98+
assert_equal dummy.multi_test_topic_names, ['Bioinformatics']
99+
100+
# This is found in only in Edam ...
101+
dummy.multi_test_topic_names = ['Biochemistry']
102+
dummy.save!
103+
assert_equal dummy.multi_test_topics.count, 1
104+
assert_equal dummy.multi_test_topic_names, ['Biochemistry']
105+
assert_equal dummy.multi_test_topic_uris, ['http://edamontology.org/topic_3292']
106+
107+
# This is found only in DummyOntology ...
108+
dummy.multi_test_topic_names = ['Poodles']
109+
dummy.save!
110+
assert_equal dummy.multi_test_topics.count, 1
111+
assert_equal dummy.multi_test_topic_names, ['Poodles']
112+
assert_equal dummy.multi_test_topic_uris, ['http://dummy/Poodles']
113+
114+
# This is found in neither ...
115+
dummy.multi_test_topic_names = ['Poodle Chemistry']
116+
dummy.save!
117+
assert_equal dummy.multi_test_topics.count, 0
118+
assert_equal dummy.multi_test_topic_names, []
119+
assert_equal dummy.multi_test_topic_uris, []
120+
121+
# Set via URIs
122+
dummy.multi_test_topic_uris = ['http://dummy/Poodles',
123+
'http://edamontology.org/topic_3292']
124+
dummy.save!
125+
assert_equal dummy.multi_test_topics.count, 2
126+
assert_equal Set.new(dummy.multi_test_topic_names),
127+
Set.new(['Biochemistry', 'Poodles'])
128+
assert_equal Set.new(dummy.multi_test_topic_uris),
129+
Set.new(['http://dummy/Poodles',
130+
'http://edamontology.org/topic_3292'])
131+
assert_equal dummy.ontology_term_links.map(&:field), ["multi_test_topics",
132+
"multi_test_topics"]
133+
assert_equal Set.new(dummy.ontology_term_links.map(&:term_uri)),
134+
Set.new(['http://dummy/Poodles',
135+
'http://edamontology.org/topic_3292'])
136+
end
137+
138+
test "Ignores attributes that don't come from any ontology" do
139+
dummy = materials(:good_material).becomes(DummyMaterial)
140+
dummy.ontology_term_links.create(field: :test_topics, term_uri: 'http://not-a-term.com')
141+
dummy.ontology_term_links.create(field: :multi_test_topics, term_uri: 'http://also-not-a-term.com')
142+
143+
assert_equal dummy.ontology_term_links.count, 2
144+
145+
assert_equal dummy.test_topics, []
146+
assert_equal dummy.test_topic_names, []
147+
assert_equal dummy.test_topic_uris, []
148+
149+
assert_equal dummy.multi_test_topics, []
150+
assert_equal dummy.multi_test_topic_names, []
151+
assert_equal dummy.multi_test_topic_uris, []
152+
153+
# Setting URI manually wipes out the ontology_term_links
154+
dummy.test_topic_uris = ['http://not-a-term.com']
155+
dummy.multi_test_topic_uris = ['http://also-not-a-term.com']
156+
157+
assert_equal dummy.ontology_term_links.count, 0
158+
159+
# What if there is a term in here already, plus a bogus term link?
160+
# (perhaps bogus because a previous ontology was take out).
161+
dummy.test_topic_names = ['Bioinformatics']
162+
dummy.multi_test_topic_names = ['Biochemistry', 'Bioinformatics', 'Poodles']
163+
assert_equal dummy.ontology_term_links.count, 5
164+
assert_equal dummy.test_topic_links.count, 1
165+
assert_equal dummy.multi_test_topic_links.count, 4
166+
167+
dummy.ontology_term_links.create(field: :test_topics, term_uri: 'http://not-a-term.com')
168+
dummy.ontology_term_links.create(field: :multi_test_topics, term_uri: 'http://also-not-a-term.com')
169+
assert_equal dummy.ontology_term_links.count, 7
170+
assert_equal dummy.test_topic_links.count, 2
171+
assert_equal dummy.multi_test_topic_links.count, 5
172+
173+
# Terms with bogus URIs don't appear here
174+
assert_equal dummy.test_topics.count, 1
175+
assert_equal dummy.test_topic_names, ['Bioinformatics']
176+
assert_equal dummy.test_topic_uris, ['http://dummy/Bioinformatics']
177+
178+
assert_equal dummy.multi_test_topics.count, 4
179+
# Bioinformatics is in both ontologies
180+
assert_equal Set.new(dummy.multi_test_topic_names), Set.new(['Biochemistry', 'Bioinformatics', 'Poodles'])
181+
assert_equal Set.new(dummy.multi_test_topic_uris),
182+
Set.new(['http://edamontology.org/topic_3292',
183+
'http://edamontology.org/topic_0091',
184+
'http://dummy/Bioinformatics',
185+
'http://dummy/Poodles'])
186+
end
187+
188+
end

0 commit comments

Comments
 (0)