1use 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 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 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 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 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 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 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 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}