Skip to main content

sbol_db_postgres/repo/
triple.rs

1use sbol_db_core::{DomainError, ObjectTerm, SubjectTerm, Triple};
2use sqlx::{QueryBuilder, Row};
3
4use crate::repo::db_err;
5use crate::PgPool;
6
7use sbol_db_storage::{GraphFilter, PatternObject, PatternSubject};
8
9#[derive(Clone)]
10pub struct TripleRepository {
11    pool: PgPool,
12}
13
14impl TripleRepository {
15    pub fn new(pool: PgPool) -> Self {
16        Self { pool }
17    }
18
19    /// Write a batch of triples in one `UNNEST`-backed round-trip. This is the
20    /// RDF write primitive (Graph Store CRUD `POST`, SPARQL `INSERT`, and SBOL
21    /// document import), tagged with `source` (e.g. `"graph-store"`,
22    /// `"sparql-update"`, `"sbol"`). Each triple's `graph_iri` is its owner (the
23    /// graph row must already exist; see [`Self::ensure_graph`]).
24    ///
25    /// A graph is a set of triples, so a triple already present in its graph is
26    /// not stored again: the `sbol_triples` identity index makes the write a
27    /// no-op via `ON CONFLICT DO NOTHING`, including duplicates within this same
28    /// batch. Returns the count of triples actually inserted (new to the graph).
29    pub async fn insert_triples(
30        &self,
31        conn: &mut sqlx::PgConnection,
32        triples: &[Triple],
33        source: &str,
34    ) -> Result<usize, DomainError> {
35        if triples.is_empty() {
36            return Ok(0);
37        }
38
39        let cols = TripleColumns::from_triples(triples);
40        let inserted = sqlx::query(
41            r#"
42            INSERT INTO sbol_triples (
43                graph_iri,
44                subject_iri,
45                subject_blank,
46                predicate_iri,
47                object_iri,
48                object_blank,
49                object_literal,
50                datatype_iri,
51                language,
52                source
53            )
54            SELECT
55                graph_iri,
56                subject_iri,
57                subject_blank,
58                predicate_iri,
59                object_iri,
60                object_blank,
61                object_literal,
62                datatype_iri,
63                language,
64                $10
65            FROM UNNEST(
66                $1::text[],
67                $2::text[],
68                $3::text[],
69                $4::text[],
70                $5::text[],
71                $6::text[],
72                $7::text[],
73                $8::text[],
74                $9::text[]
75            ) AS u(
76                graph_iri,
77                subject_iri,
78                subject_blank,
79                predicate_iri,
80                object_iri,
81                object_blank,
82                object_literal,
83                datatype_iri,
84                language
85            )
86            ON CONFLICT DO NOTHING
87            "#,
88        )
89        .bind(&cols.graph_iri)
90        .bind(&cols.subject_iri)
91        .bind(&cols.subject_blank)
92        .bind(&cols.predicate_iri)
93        .bind(&cols.object_iri)
94        .bind(&cols.object_blank)
95        .bind(&cols.object_literal)
96        .bind(&cols.datatype_iri)
97        .bind(&cols.language)
98        .bind(source)
99        .execute(&mut *conn)
100        .await
101        .map_err(db_err)?;
102
103        Ok(inserted.rows_affected() as usize)
104    }
105
106    /// Delete every row that exactly matches one of `triples` on all RDF
107    /// positions (graph, subject, predicate, object, datatype, language) in one
108    /// `DELETE ... USING UNNEST` round-trip. `IS NOT DISTINCT FROM` makes NULL
109    /// positions (default graph, blank vs IRI) match by value, not collapse.
110    /// This is the delete half of SPARQL `DELETE`/`DELETE DATA` and Graph Store
111    /// CRUD edits. Ignores `graph_id`/`source`: a triple is matched by its RDF
112    /// content regardless of which writer produced it. Returns the deleted count.
113    pub async fn delete_triples(
114        &self,
115        conn: &mut sqlx::PgConnection,
116        triples: &[Triple],
117    ) -> Result<usize, DomainError> {
118        if triples.is_empty() {
119            return Ok(0);
120        }
121
122        let cols = TripleColumns::from_triples(triples);
123        let deleted = sqlx::query(
124            r#"
125            DELETE FROM sbol_triples q
126            USING UNNEST(
127                $1::text[],
128                $2::text[],
129                $3::text[],
130                $4::text[],
131                $5::text[],
132                $6::text[],
133                $7::text[],
134                $8::text[],
135                $9::text[]
136            ) AS d(
137                graph_iri,
138                subject_iri,
139                subject_blank,
140                predicate_iri,
141                object_iri,
142                object_blank,
143                object_literal,
144                datatype_iri,
145                language
146            )
147            WHERE q.graph_iri IS NOT DISTINCT FROM d.graph_iri
148              AND q.subject_iri IS NOT DISTINCT FROM d.subject_iri
149              AND q.subject_blank IS NOT DISTINCT FROM d.subject_blank
150              AND q.predicate_iri = d.predicate_iri
151              AND q.object_iri IS NOT DISTINCT FROM d.object_iri
152              AND q.object_blank IS NOT DISTINCT FROM d.object_blank
153              AND q.object_literal IS NOT DISTINCT FROM d.object_literal
154              AND q.datatype_iri IS NOT DISTINCT FROM d.datatype_iri
155              AND q.language IS NOT DISTINCT FROM d.language
156            "#,
157        )
158        .bind(&cols.graph_iri)
159        .bind(&cols.subject_iri)
160        .bind(&cols.subject_blank)
161        .bind(&cols.predicate_iri)
162        .bind(&cols.object_iri)
163        .bind(&cols.object_blank)
164        .bind(&cols.object_literal)
165        .bind(&cols.datatype_iri)
166        .bind(&cols.language)
167        .execute(&mut *conn)
168        .await
169        .map_err(db_err)?;
170
171        Ok(deleted.rows_affected() as usize)
172    }
173
174    /// Delete every triple in a named graph. Backs SPARQL `CLEAR`/`DROP GRAPH`
175    /// and Graph Store CRUD `DELETE`. `graph` of `None` clears the default
176    /// (graphless) partition. Returns the deleted count.
177    pub async fn clear_graph(
178        &self,
179        conn: &mut sqlx::PgConnection,
180        graph: Option<&str>,
181    ) -> Result<usize, DomainError> {
182        let deleted = match graph {
183            Some(g) => sqlx::query("DELETE FROM sbol_triples WHERE graph_iri = $1")
184                .bind(g)
185                .execute(&mut *conn)
186                .await
187                .map_err(db_err)?,
188            None => sqlx::query("DELETE FROM sbol_triples WHERE graph_iri IS NULL")
189                .execute(&mut *conn)
190                .await
191                .map_err(db_err)?,
192        };
193        Ok(deleted.rows_affected() as usize)
194    }
195
196    /// Register a named graph if it is not already present. Every triple with a
197    /// named `graph_iri` is owned by its `sbol_graphs` row (FK, `ON DELETE
198    /// CASCADE`), so writers must ensure the graph exists before inserting.
199    /// `kind` is `"document"` for a graph dedicated to one imported SBOL
200    /// document, or `"rdf"` for a standalone/shared graph (the triplestore
201    /// case). A pre-existing graph keeps its original kind.
202    pub async fn ensure_graph(
203        &self,
204        conn: &mut sqlx::PgConnection,
205        iri: &str,
206        kind: &str,
207    ) -> Result<(), DomainError> {
208        sqlx::query(
209            "INSERT INTO sbol_graphs (iri, kind) VALUES ($1, $2) ON CONFLICT (iri) DO NOTHING",
210        )
211        .bind(iri)
212        .bind(kind)
213        .execute(&mut *conn)
214        .await
215        .map_err(db_err)?;
216        Ok(())
217    }
218
219    /// Delete a graph entirely: remove its `sbol_graphs` row, which cascades to
220    /// its triples (derived-view objects and validation runs have `ON DELETE SET
221    /// NULL` and survive). Backs Graph Store CRUD `DELETE`, SPARQL `DROP GRAPH`,
222    /// and document deletion (an `sbol3`-kind graph is a document). Returns
223    /// whether a row was removed.
224    pub async fn delete_graph(
225        &self,
226        conn: &mut sqlx::PgConnection,
227        iri: &str,
228    ) -> Result<bool, DomainError> {
229        let affected = sqlx::query("DELETE FROM sbol_graphs WHERE iri = $1")
230            .bind(iri)
231            .execute(&mut *conn)
232            .await
233            .map_err(db_err)?
234            .rows_affected();
235        Ok(affected > 0)
236    }
237
238    pub async fn triples_for_subject(&self, subject_iri: &str) -> Result<Vec<Triple>, DomainError> {
239        let rows = sqlx::query(
240            r#"
241            SELECT graph_iri, subject_iri, subject_blank, predicate_iri,
242                   object_iri, object_blank, object_literal, datatype_iri, language
243            FROM sbol_triples
244            WHERE subject_iri = $1
245            "#,
246        )
247        .bind(subject_iri)
248        .fetch_all(&self.pool)
249        .await
250        .map_err(db_err)?;
251
252        rows.into_iter().map(row_to_triple).collect()
253    }
254
255    /// Triple-pattern scan over `sbol_triples`.
256    ///
257    /// Any combination of subject / predicate / object / graph positions may be
258    /// bound (`Some(...)`) or wildcarded (`None`). The SQL `WHERE` clause is
259    /// built dynamically so Postgres' planner sees only the bound predicates
260    /// and picks the right SPOG/POSG/OSPG/GSPO index for the access shape.
261    ///
262    /// `limit` caps the rows returned per call (per-pattern scan cap, not a
263    /// total-result cap — that's enforced higher up).
264    pub async fn scan_pattern(
265        &self,
266        subject: Option<&PatternSubject>,
267        predicate: Option<&str>,
268        object: Option<&PatternObject>,
269        graph: Option<&GraphFilter>,
270        limit: i64,
271    ) -> Result<Vec<Triple>, DomainError> {
272        let mut qb: QueryBuilder<sqlx::Postgres> = QueryBuilder::new(
273            "SELECT graph_iri, subject_iri, subject_blank, predicate_iri, \
274             object_iri, object_blank, object_literal, datatype_iri, language \
275             FROM sbol_triples WHERE 1=1",
276        );
277        match subject {
278            Some(PatternSubject::Iri(s)) => {
279                qb.push(" AND subject_iri = ").push_bind(s.clone());
280            }
281            Some(PatternSubject::Blank(b)) => {
282                qb.push(" AND subject_blank = ").push_bind(b.clone());
283            }
284            None => {}
285        }
286        if let Some(p) = predicate {
287            qb.push(" AND predicate_iri = ").push_bind(p.to_owned());
288        }
289        match object {
290            Some(PatternObject::Iri(o)) => {
291                qb.push(" AND object_iri = ").push_bind(o.clone());
292            }
293            Some(PatternObject::Blank(b)) => {
294                qb.push(" AND object_blank = ").push_bind(b.clone());
295            }
296            Some(PatternObject::Literal {
297                value,
298                datatype,
299                language,
300            }) => {
301                qb.push(" AND object_literal = ").push_bind(value.clone());
302                qb.push(" AND datatype_iri = ").push_bind(datatype.clone());
303                match language {
304                    Some(lang) => {
305                        qb.push(" AND language = ").push_bind(lang.clone());
306                    }
307                    None => {
308                        qb.push(" AND language IS NULL");
309                    }
310                }
311            }
312            None => {}
313        }
314        match graph {
315            Some(GraphFilter::AnyNamed) => {
316                qb.push(" AND graph_iri IS NOT NULL");
317            }
318            Some(GraphFilter::DefaultOnly) => {
319                qb.push(" AND graph_iri IS NULL");
320            }
321            Some(GraphFilter::Iri(g)) => {
322                qb.push(" AND graph_iri = ").push_bind(g.clone());
323            }
324            None => {}
325        }
326        qb.push(" LIMIT ").push_bind(limit);
327
328        let rows = qb.build().fetch_all(&self.pool).await.map_err(db_err)?;
329        rows.into_iter().map(row_to_triple).collect()
330    }
331
332    /// Fetch every triple in one named graph through the caller's connection, so
333    /// it sees triples the same transaction has written but not yet committed.
334    /// The accelerator uses this to rebuild a graph's indexes inside the write
335    /// transaction.
336    pub async fn scan_graph_in_conn(
337        &self,
338        conn: &mut sqlx::PgConnection,
339        graph: &str,
340    ) -> Result<Vec<Triple>, DomainError> {
341        let rows = sqlx::query(
342            r#"
343            SELECT graph_iri, subject_iri, subject_blank, predicate_iri,
344                   object_iri, object_blank, object_literal, datatype_iri, language
345            FROM sbol_triples
346            WHERE graph_iri = $1
347            "#,
348        )
349        .bind(graph)
350        .fetch_all(&mut *conn)
351        .await
352        .map_err(db_err)?;
353        rows.into_iter().map(row_to_triple).collect()
354    }
355
356    /// Enumerate distinct named graphs present in `sbol_triples`. Used by SPARQL
357    /// evaluation to discover the named-graph universe.
358    pub async fn distinct_named_graphs(&self) -> Result<Vec<String>, DomainError> {
359        let rows = sqlx::query(
360            "SELECT DISTINCT graph_iri::text AS graph_iri \
361             FROM sbol_triples WHERE graph_iri IS NOT NULL",
362        )
363        .fetch_all(&self.pool)
364        .await
365        .map_err(db_err)?;
366        rows.into_iter()
367            .map(|r| {
368                let g: String = r.try_get("graph_iri").map_err(db_err)?;
369                Ok(g)
370            })
371            .collect()
372    }
373
374    /// Fetch every triple in one named graph (`Some`) or the default/graphless
375    /// partition (`None`). Backs the Graph Store CRUD `GET`. `limit` caps the
376    /// rows returned.
377    pub async fn triples_for_graph(
378        &self,
379        graph: Option<&str>,
380        limit: i64,
381    ) -> Result<Vec<Triple>, DomainError> {
382        let rows = match graph {
383            Some(g) => sqlx::query(
384                r#"
385                SELECT graph_iri, subject_iri, subject_blank, predicate_iri,
386                       object_iri, object_blank, object_literal, datatype_iri, language
387                FROM sbol_triples
388                WHERE graph_iri = $1
389                LIMIT $2
390                "#,
391            )
392            .bind(g)
393            .bind(limit)
394            .fetch_all(&self.pool)
395            .await
396            .map_err(db_err)?,
397            None => sqlx::query(
398                r#"
399                SELECT graph_iri, subject_iri, subject_blank, predicate_iri,
400                       object_iri, object_blank, object_literal, datatype_iri, language
401                FROM sbol_triples
402                WHERE graph_iri IS NULL
403                LIMIT $1
404                "#,
405            )
406            .bind(limit)
407            .fetch_all(&self.pool)
408            .await
409            .map_err(db_err)?,
410        };
411        rows.into_iter().map(row_to_triple).collect()
412    }
413}
414
415/// Column-major decomposition of a triple batch, one parallel `Vec` per
416/// `sbol_triples` column, ready to bind as Postgres array parameters for an
417/// `UNNEST`-backed insert or delete. Shared by [`TripleRepository::insert_triples`]
418/// and [`TripleRepository::delete_triples`] so the term-to-column mapping lives in
419/// exactly one place.
420struct TripleColumns {
421    graph_iri: Vec<Option<String>>,
422    subject_iri: Vec<Option<String>>,
423    subject_blank: Vec<Option<String>>,
424    predicate_iri: Vec<String>,
425    object_iri: Vec<Option<String>>,
426    object_blank: Vec<Option<String>>,
427    object_literal: Vec<Option<String>>,
428    datatype_iri: Vec<Option<String>>,
429    language: Vec<Option<String>>,
430}
431
432impl TripleColumns {
433    fn from_triples(triples: &[Triple]) -> Self {
434        let mut cols = TripleColumns {
435            graph_iri: Vec::with_capacity(triples.len()),
436            subject_iri: Vec::with_capacity(triples.len()),
437            subject_blank: Vec::with_capacity(triples.len()),
438            predicate_iri: Vec::with_capacity(triples.len()),
439            object_iri: Vec::with_capacity(triples.len()),
440            object_blank: Vec::with_capacity(triples.len()),
441            object_literal: Vec::with_capacity(triples.len()),
442            datatype_iri: Vec::with_capacity(triples.len()),
443            language: Vec::with_capacity(triples.len()),
444        };
445        for q in triples {
446            cols.graph_iri
447                .push(q.graph_iri.as_ref().map(|i| i.as_str().to_owned()));
448            match &q.subject {
449                SubjectTerm::Iri(iri) => {
450                    cols.subject_iri.push(Some(iri.as_str().to_owned()));
451                    cols.subject_blank.push(None);
452                }
453                SubjectTerm::BlankNode(node) => {
454                    cols.subject_iri.push(None);
455                    cols.subject_blank.push(Some(node.clone()));
456                }
457            }
458            cols.predicate_iri.push(q.predicate.as_str().to_owned());
459            match &q.object {
460                ObjectTerm::Iri(iri) => {
461                    cols.object_iri.push(Some(iri.as_str().to_owned()));
462                    cols.object_blank.push(None);
463                    cols.object_literal.push(None);
464                    cols.datatype_iri.push(None);
465                    cols.language.push(None);
466                }
467                ObjectTerm::BlankNode(node) => {
468                    cols.object_iri.push(None);
469                    cols.object_blank.push(Some(node.clone()));
470                    cols.object_literal.push(None);
471                    cols.datatype_iri.push(None);
472                    cols.language.push(None);
473                }
474                ObjectTerm::Literal {
475                    value,
476                    datatype,
477                    language: lang,
478                } => {
479                    cols.object_iri.push(None);
480                    cols.object_blank.push(None);
481                    cols.object_literal.push(Some(value.clone()));
482                    cols.datatype_iri.push(Some(datatype.as_str().to_owned()));
483                    cols.language.push(lang.clone());
484                }
485            }
486        }
487        cols
488    }
489}
490
491fn row_to_triple(row: sqlx::postgres::PgRow) -> Result<Triple, DomainError> {
492    use sbol_db_core::IriString;
493    let graph_iri: Option<String> = row.try_get("graph_iri").map_err(db_err)?;
494    let subject_iri: Option<String> = row.try_get("subject_iri").map_err(db_err)?;
495    let subject_blank: Option<String> = row.try_get("subject_blank").map_err(db_err)?;
496    let predicate_iri: String = row.try_get("predicate_iri").map_err(db_err)?;
497    let object_iri: Option<String> = row.try_get("object_iri").map_err(db_err)?;
498    let object_blank: Option<String> = row.try_get("object_blank").map_err(db_err)?;
499    let object_literal: Option<String> = row.try_get("object_literal").map_err(db_err)?;
500    let datatype_iri: Option<String> = row.try_get("datatype_iri").map_err(db_err)?;
501    let language: Option<String> = row.try_get("language").map_err(db_err)?;
502
503    let subject = match (subject_iri, subject_blank) {
504        (Some(iri), _) => SubjectTerm::Iri(IriString::unchecked(iri)),
505        (None, Some(blank)) => SubjectTerm::BlankNode(blank),
506        (None, None) => return Err(DomainError::Database("triple missing subject".into())),
507    };
508
509    let object = match (object_iri, object_blank, object_literal) {
510        (Some(iri), _, _) => ObjectTerm::Iri(IriString::unchecked(iri)),
511        (None, Some(blank), _) => ObjectTerm::BlankNode(blank),
512        (None, None, Some(value)) => ObjectTerm::Literal {
513            value,
514            datatype: IriString::unchecked(
515                datatype_iri
516                    .unwrap_or_else(|| "http://www.w3.org/2001/XMLSchema#string".to_owned()),
517            ),
518            language,
519        },
520        (None, None, None) => return Err(DomainError::Database("triple missing object".into())),
521    };
522
523    Ok(Triple {
524        graph_iri: graph_iri.map(IriString::unchecked),
525        subject,
526        predicate: IriString::unchecked(predicate_iri),
527        object,
528    })
529}