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