Skip to main content

sbol_db_postgres/repo/
ontology.rs

1//! Ontology storage + transitive-closure builder, plus a small HTTP loader
2//! that fetches an OBO file from a canonical URL (OBO Foundry / EBI) and
3//! materialises it into `sbol_ontologies` / `sbol_ontology_terms` /
4//! `sbol_ontology_term_aliases` / `sbol_ontology_closure`.
5//!
6//! The closure is computed in Rust at load time -- one BFS per term up
7//! through `is_a` parents -- so role-expansion queries reduce to one
8//! indexed lookup against `sbol_ontology_closure.ancestor_iri`.
9//!
10//! IRI canonicalisation: every term gets its OBO Foundry PURL
11//! (`http://purl.obolibrary.org/obo/{PREFIX}_{NUMBER}`) as its canonical
12//! IRI plus an identifiers.org alias (`http://identifiers.org/{prefix}/{CURIE}`)
13//! since SBOL documents commonly use the latter.
14
15use sbol_db_core::DomainError;
16use sbol_db_derive::build_ontology_plan;
17use sqlx::Row;
18
19use crate::repo::db_err;
20use crate::PgPool;
21
22use sbol_db_storage::{OntologyLoadReport, OntologyRecord, OntologyTermRecord};
23
24#[derive(Clone)]
25pub struct OntologyRepository {
26    pool: PgPool,
27}
28
29impl OntologyRepository {
30    pub fn new(pool: PgPool) -> Self {
31        Self { pool }
32    }
33
34    /// Fetch an OBO file from `source_url`, parse it, and persist the
35    /// ontology + terms + closure in a single transaction. `prefix` (e.g.
36    /// "SO", "SBO") drives IRI generation and is the row key in
37    /// `sbol_ontologies`.
38    pub async fn load_from_url(
39        &self,
40        prefix: &str,
41        name: &str,
42        source_url: &str,
43    ) -> Result<OntologyLoadReport, DomainError> {
44        let client = reqwest::Client::builder()
45            .user_agent("sbol-db/0.1 (+https://github.com/marpaia/sbol-db)")
46            .build()
47            .map_err(|e| DomainError::InvalidInput(format!("reqwest client: {e}")))?;
48        let body = client
49            .get(source_url)
50            .send()
51            .await
52            .map_err(|e| DomainError::InvalidInput(format!("fetch {source_url}: {e}")))?
53            .error_for_status()
54            .map_err(|e| DomainError::InvalidInput(format!("HTTP {source_url}: {e}")))?
55            .text()
56            .await
57            .map_err(|e| DomainError::InvalidInput(format!("decode {source_url}: {e}")))?;
58        self.load_from_text(prefix, name, Some(source_url), &body)
59            .await
60    }
61
62    /// Same as [`load_from_url`] but takes already-fetched OBO text. The OBO
63    /// parse, canonical-IRI/alias generation, and closure are derived by the
64    /// shared [`build_ontology_plan`]; this method only persists the plan.
65    pub async fn load_from_text(
66        &self,
67        prefix: &str,
68        name: &str,
69        source_url: Option<&str>,
70        text: &str,
71    ) -> Result<OntologyLoadReport, DomainError> {
72        let plan = build_ontology_plan(prefix, name, source_url, text);
73
74        let mut tx = self.pool.begin().await.map_err(db_err)?;
75
76        // Replace any previous ontology of this prefix.
77        sqlx::query("DELETE FROM sbol_ontologies WHERE prefix = $1")
78            .bind(&plan.prefix)
79            .execute(&mut *tx)
80            .await
81            .map_err(db_err)?;
82
83        sqlx::query(
84            r#"
85            INSERT INTO sbol_ontologies (prefix, name, source_url, version, term_count, imported_at)
86            VALUES ($1, $2, $3, $4, $5, now())
87            "#,
88        )
89        .bind(&plan.prefix)
90        .bind(&plan.name)
91        .bind(plan.source_url.as_deref())
92        .bind(&plan.version)
93        .bind(plan.terms.len() as i32)
94        .execute(&mut *tx)
95        .await
96        .map_err(db_err)?;
97
98        // Per-term insert. Synonyms are text[]; Postgres rejects ragged
99        // multidim arrays, so we cannot UNNEST text[][] safely -- and an
100        // OBO file is a few thousand rows, well within row-by-row insert
101        // throughput inside one transaction.
102        for t in &plan.terms {
103            sqlx::query(
104                r#"
105                INSERT INTO sbol_ontology_terms
106                    (iri, prefix, curie, name, definition, is_obsolete, synonyms)
107                VALUES ($1, $2, $3, $4, $5, $6, $7)
108                "#,
109            )
110            .bind(&t.canonical_iri)
111            .bind(&plan.prefix)
112            .bind(&t.curie)
113            .bind(&t.name)
114            .bind(&t.definition)
115            .bind(t.is_obsolete)
116            .bind(&t.synonyms)
117            .execute(&mut *tx)
118            .await
119            .map_err(db_err)?;
120        }
121
122        if !plan.aliases.is_empty() {
123            let alias_iri: Vec<String> = plan.aliases.iter().map(|(a, _)| a.clone()).collect();
124            let alias_canon: Vec<String> = plan.aliases.iter().map(|(_, c)| c.clone()).collect();
125            sqlx::query(
126                r#"
127                INSERT INTO sbol_ontology_term_aliases (alias_iri, canonical_iri)
128                SELECT alias, canonical
129                FROM UNNEST($1::text[], $2::text[]) AS u(alias, canonical)
130                ON CONFLICT (alias_iri) DO UPDATE
131                  SET canonical_iri = EXCLUDED.canonical_iri
132                "#,
133            )
134            .bind(&alias_iri)
135            .bind(&alias_canon)
136            .execute(&mut *tx)
137            .await
138            .map_err(db_err)?;
139        }
140
141        if !plan.closure.is_empty() {
142            let mut anc: Vec<String> = Vec::with_capacity(plan.closure.len());
143            let mut des: Vec<String> = Vec::with_capacity(plan.closure.len());
144            let mut dep: Vec<i16> = Vec::with_capacity(plan.closure.len());
145            for (a, d, depth) in &plan.closure {
146                anc.push(a.clone());
147                des.push(d.clone());
148                dep.push(*depth);
149            }
150            sqlx::query(
151                r#"
152                INSERT INTO sbol_ontology_closure (ancestor_iri, descendant_iri, depth)
153                SELECT ancestor, descendant, depth
154                FROM UNNEST($1::text[], $2::text[], $3::int2[])
155                  AS u(ancestor, descendant, depth)
156                "#,
157            )
158            .bind(&anc)
159            .bind(&des)
160            .bind(&dep)
161            .execute(&mut *tx)
162            .await
163            .map_err(db_err)?;
164        }
165
166        tx.commit().await.map_err(db_err)?;
167
168        Ok(plan.report())
169    }
170
171    pub async fn list_ontologies(&self) -> Result<Vec<OntologyRecord>, DomainError> {
172        let rows = sqlx::query(
173            r#"
174            SELECT prefix, name, source_url, version, term_count, imported_at
175            FROM sbol_ontologies
176            ORDER BY prefix
177            "#,
178        )
179        .fetch_all(&self.pool)
180        .await
181        .map_err(db_err)?;
182        let mut out = Vec::with_capacity(rows.len());
183        for row in rows {
184            out.push(OntologyRecord {
185                prefix: row.try_get("prefix").map_err(db_err)?,
186                name: row.try_get("name").map_err(db_err)?,
187                source_url: row.try_get("source_url").map_err(db_err)?,
188                version: row.try_get("version").map_err(db_err)?,
189                term_count: row.try_get("term_count").map_err(db_err)?,
190                imported_at: row.try_get("imported_at").map_err(db_err)?,
191            });
192        }
193        Ok(out)
194    }
195
196    /// Resolve any input IRI (canonical or alias) to a canonical term IRI.
197    pub async fn canonicalize(&self, iri: &str) -> Result<Option<String>, DomainError> {
198        let row = sqlx::query(
199            r#"
200            SELECT iri::text AS canonical
201            FROM sbol_ontology_terms WHERE iri = $1
202            UNION ALL
203            SELECT canonical_iri::text AS canonical
204            FROM sbol_ontology_term_aliases WHERE alias_iri = $1
205            LIMIT 1
206            "#,
207        )
208        .bind(iri)
209        .fetch_optional(&self.pool)
210        .await
211        .map_err(db_err)?;
212        Ok(match row {
213            Some(r) => Some(r.try_get("canonical").map_err(db_err)?),
214            None => None,
215        })
216    }
217
218    /// Return the term plus every descendant IRI under it (transitively).
219    /// The root itself is included with depth 0.
220    pub async fn descendants(&self, iri: &str) -> Result<Vec<(String, i16)>, DomainError> {
221        let canonical = self
222            .canonicalize(iri)
223            .await?
224            .unwrap_or_else(|| iri.to_owned());
225        let rows = sqlx::query(
226            r#"
227            SELECT descendant_iri::text AS iri, depth
228            FROM sbol_ontology_closure
229            WHERE ancestor_iri = $1
230            ORDER BY depth ASC, descendant_iri ASC
231            "#,
232        )
233        .bind(&canonical)
234        .fetch_all(&self.pool)
235        .await
236        .map_err(db_err)?;
237        let mut out = Vec::with_capacity(rows.len());
238        for row in rows {
239            out.push((
240                row.try_get::<String, _>("iri").map_err(db_err)?,
241                row.try_get::<i16, _>("depth").map_err(db_err)?,
242            ));
243        }
244        Ok(out)
245    }
246
247    /// Page through every term that belongs to `prefix`, sorted by
248    /// curie. `search` (when non-empty) restricts by case-insensitive
249    /// substring match on either the curie or the name. Returns the
250    /// page rows plus the total matching count so that callers can render
251    /// a paginated UI without a second round-trip.
252    pub async fn list_terms(
253        &self,
254        prefix: &str,
255        limit: i64,
256        offset: i64,
257        search: Option<&str>,
258    ) -> Result<(Vec<OntologyTermRecord>, i64), DomainError> {
259        let prefix_upper = prefix.to_ascii_uppercase();
260        let pattern = search.map(|s| s.trim()).filter(|s| !s.is_empty()).map(|s| {
261            format!(
262                "%{}%",
263                s.replace('\\', "\\\\")
264                    .replace('%', "\\%")
265                    .replace('_', "\\_")
266            )
267        });
268
269        let total: i64 = if let Some(pat) = pattern.as_deref() {
270            sqlx::query_scalar(
271                r#"
272                SELECT COUNT(*)::bigint
273                FROM sbol_ontology_terms
274                WHERE prefix = $1
275                  AND (curie ILIKE $2 OR name ILIKE $2)
276                "#,
277            )
278            .bind(&prefix_upper)
279            .bind(pat)
280            .fetch_one(&self.pool)
281            .await
282            .map_err(db_err)?
283        } else {
284            sqlx::query_scalar("SELECT COUNT(*)::bigint FROM sbol_ontology_terms WHERE prefix = $1")
285                .bind(&prefix_upper)
286                .fetch_one(&self.pool)
287                .await
288                .map_err(db_err)?
289        };
290
291        let rows = if let Some(pat) = pattern.as_deref() {
292            sqlx::query(
293                r#"
294                SELECT iri::text AS iri, prefix, curie, name, definition,
295                       is_obsolete, synonyms
296                FROM sbol_ontology_terms
297                WHERE prefix = $1
298                  AND (curie ILIKE $2 OR name ILIKE $2)
299                ORDER BY curie ASC
300                LIMIT $3 OFFSET $4
301                "#,
302            )
303            .bind(&prefix_upper)
304            .bind(pat)
305            .bind(limit)
306            .bind(offset)
307            .fetch_all(&self.pool)
308            .await
309            .map_err(db_err)?
310        } else {
311            sqlx::query(
312                r#"
313                SELECT iri::text AS iri, prefix, curie, name, definition,
314                       is_obsolete, synonyms
315                FROM sbol_ontology_terms
316                WHERE prefix = $1
317                ORDER BY curie ASC
318                LIMIT $2 OFFSET $3
319                "#,
320            )
321            .bind(&prefix_upper)
322            .bind(limit)
323            .bind(offset)
324            .fetch_all(&self.pool)
325            .await
326            .map_err(db_err)?
327        };
328
329        let mut out = Vec::with_capacity(rows.len());
330        for row in rows {
331            out.push(OntologyTermRecord {
332                iri: row.try_get("iri").map_err(db_err)?,
333                prefix: row.try_get("prefix").map_err(db_err)?,
334                curie: row.try_get("curie").map_err(db_err)?,
335                name: row.try_get("name").map_err(db_err)?,
336                definition: row.try_get("definition").map_err(db_err)?,
337                is_obsolete: row.try_get("is_obsolete").map_err(db_err)?,
338                synonyms: row.try_get("synonyms").map_err(db_err)?,
339            });
340        }
341        Ok((out, total))
342    }
343
344    pub async fn get_term(&self, iri: &str) -> Result<Option<OntologyTermRecord>, DomainError> {
345        let canonical = self
346            .canonicalize(iri)
347            .await?
348            .unwrap_or_else(|| iri.to_owned());
349        let row = sqlx::query(
350            r#"
351            SELECT iri::text AS iri, prefix, curie, name, definition,
352                   is_obsolete, synonyms
353            FROM sbol_ontology_terms WHERE iri = $1
354            "#,
355        )
356        .bind(&canonical)
357        .fetch_optional(&self.pool)
358        .await
359        .map_err(db_err)?;
360        Ok(match row {
361            Some(r) => Some(OntologyTermRecord {
362                iri: r.try_get("iri").map_err(db_err)?,
363                prefix: r.try_get("prefix").map_err(db_err)?,
364                curie: r.try_get("curie").map_err(db_err)?,
365                name: r.try_get("name").map_err(db_err)?,
366                definition: r.try_get("definition").map_err(db_err)?,
367                is_obsolete: r.try_get("is_obsolete").map_err(db_err)?,
368                synonyms: r.try_get("synonyms").map_err(db_err)?,
369            }),
370            None => None,
371        })
372    }
373}