Skip to main content

sbol_db_postgres/repo/
graph.rs

1//! Repository for `sbol3`-kind graphs: the named graphs created by importing
2//! an SBOL document. Each is dedicated to one import and carries that import's
3//! metadata (name, serialization format, source, content hash). `GraphId` is
4//! the graph's surrogate id; its IRI is `graph:document:{id}`. Standalone
5//! `verbatim` graphs (written through the Graph Store / SPARQL Update
6//! endpoints) are not managed here.
7
8use sbol_db_core::{DomainError, GraphId, GraphRecord, IriString, NewGraph, SerializationFormat};
9use sbol_db_rdf::GRAPH_IRI_PREFIX;
10use sqlx::Row;
11
12use crate::repo::db_err;
13use crate::PgPool;
14
15use sbol_db_storage::ListGraphsFilter;
16
17#[derive(Clone)]
18pub struct GraphRepository {
19    pool: PgPool,
20}
21
22impl GraphRepository {
23    pub fn new(pool: PgPool) -> Self {
24        Self { pool }
25    }
26
27    /// Create the `sbol3`-kind graph that backs a document. The caller mints
28    /// the `GraphId` so the document's triples can be attributed to its
29    /// `graph:document:{id}` IRI before this write.
30    pub async fn insert(
31        &self,
32        conn: &mut sqlx::PgConnection,
33        id: GraphId,
34        input: NewGraph,
35    ) -> Result<GraphId, DomainError> {
36        let id = id.0;
37        let iri = format!("{GRAPH_IRI_PREFIX}{id}");
38        sqlx::query(
39            r#"
40            INSERT INTO sbol_graphs (
41                iri, id, kind, document_iri, name, description,
42                serialization_format, source_uri, content_hash, created_by
43            ) VALUES ($1, $2, 'sbol3', $3, $4, $5, $6, $7, $8, $9)
44            "#,
45        )
46        .bind(&iri)
47        .bind(id)
48        .bind(input.document_iri.as_ref().map(|i| i.as_str()))
49        .bind(input.name)
50        .bind(input.description)
51        .bind(input.serialization_format.as_db_str())
52        .bind(input.source_uri)
53        .bind(input.content_hash)
54        .bind(input.created_by)
55        .execute(&mut *conn)
56        .await
57        .map_err(db_err)?;
58
59        Ok(GraphId(id))
60    }
61
62    /// Returns true when any document graph already carries this content hash.
63    /// Used by bulk import to dedup re-uploaded files without re-parsing.
64    pub async fn exists_by_hash(&self, hash: &[u8]) -> Result<bool, DomainError> {
65        let row = sqlx::query(
66            "SELECT EXISTS(SELECT 1 FROM sbol_graphs \
67             WHERE kind = 'sbol3' AND content_hash = $1) AS hit",
68        )
69        .bind(hash)
70        .fetch_one(&self.pool)
71        .await
72        .map_err(db_err)?;
73        row.try_get::<bool, _>("hit").map_err(db_err)
74    }
75
76    pub async fn get(&self, id: GraphId) -> Result<Option<GraphRecord>, DomainError> {
77        let row = sqlx::query(
78            r#"
79            SELECT id, document_iri, name, description, serialization_format,
80                   source_uri, content_hash, created_at, updated_at
81            FROM sbol_graphs
82            WHERE id = $1 AND kind = 'sbol3'
83            "#,
84        )
85        .bind(id.0)
86        .fetch_optional(&self.pool)
87        .await
88        .map_err(db_err)?;
89
90        row.map(row_to_record).transpose()
91    }
92
93    /// Paginated newest-first listing for the CLI / lab UI. Filters are
94    /// optional and ANDed; cardinality is small so we don't expose a
95    /// keyset cursor here.
96    pub async fn list(&self, filter: &ListGraphsFilter) -> Result<Vec<GraphRecord>, DomainError> {
97        let format_value = filter.format.map(|f| f.as_db_str().to_owned());
98        let rows = sqlx::query(
99            r#"
100            SELECT id, document_iri, name, description, serialization_format,
101                   source_uri, content_hash, created_at, updated_at
102            FROM sbol_graphs
103            WHERE kind = 'sbol3'
104              AND ($1::text IS NULL OR name ILIKE '%' || $1 || '%')
105              AND ($2::text IS NULL OR serialization_format = $2)
106            ORDER BY created_at DESC
107            LIMIT $3
108            "#,
109        )
110        .bind(filter.name.as_deref())
111        .bind(format_value)
112        .bind(filter.limit as i64)
113        .fetch_all(&self.pool)
114        .await
115        .map_err(db_err)?;
116        rows.into_iter().map(row_to_record).collect()
117    }
118
119    /// Resolve a document graph's surrogate id from its (unique) document IRI.
120    pub async fn id_by_document_iri(
121        &self,
122        document_iri: &str,
123    ) -> Result<Option<GraphId>, DomainError> {
124        let row =
125            sqlx::query("SELECT id FROM sbol_graphs WHERE document_iri = $1 AND kind = 'sbol3'")
126                .bind(document_iri)
127                .fetch_optional(&self.pool)
128                .await
129                .map_err(db_err)?;
130        match row {
131            Some(row) => Ok(Some(GraphId(row.try_get("id").map_err(db_err)?))),
132            None => Ok(None),
133        }
134    }
135
136    /// Delete one document by dropping its graph. The graph's triples cascade
137    /// via their FK; the graph's derived objects (whose `graph_id` FK is
138    /// `ON DELETE SET NULL`) are removed explicitly first, so the object view
139    /// no longer returns them and their revisions cascade away. Both deletes
140    /// share one transaction. Returns `true` if a graph row was removed.
141    pub async fn delete(&self, id: GraphId) -> Result<bool, DomainError> {
142        let mut tx = self.pool.begin().await.map_err(db_err)?;
143        let removed = delete_graph_on(&mut tx, id).await?;
144        tx.commit().await.map_err(db_err)?;
145        Ok(removed)
146    }
147
148    /// Connection-scoped [`id_by_document_iri`](Self::id_by_document_iri), so a
149    /// replace can resolve and delete the old graph inside the import's
150    /// transaction.
151    pub async fn id_by_document_iri_in(
152        &self,
153        conn: &mut sqlx::PgConnection,
154        document_iri: &str,
155    ) -> Result<Option<GraphId>, DomainError> {
156        let row =
157            sqlx::query("SELECT id FROM sbol_graphs WHERE document_iri = $1 AND kind = 'sbol3'")
158                .bind(document_iri)
159                .fetch_optional(&mut *conn)
160                .await
161                .map_err(db_err)?;
162        match row {
163            Some(row) => Ok(Some(GraphId(row.try_get("id").map_err(db_err)?))),
164            None => Ok(None),
165        }
166    }
167
168    /// Connection-scoped [`delete`](Self::delete), for an in-transaction replace.
169    pub async fn delete_in(
170        &self,
171        conn: &mut sqlx::PgConnection,
172        id: GraphId,
173    ) -> Result<bool, DomainError> {
174        delete_graph_on(conn, id).await
175    }
176}
177
178/// Delete a document graph's derived objects then the graph row itself on one
179/// connection. Objects are removed before the graph so their `ON DELETE SET
180/// NULL` `graph_id` still resolves the set; `sbol_object_revisions` cascade
181/// from the object rows.
182async fn delete_graph_on(conn: &mut sqlx::PgConnection, id: GraphId) -> Result<bool, DomainError> {
183    sqlx::query("DELETE FROM sbol_objects WHERE graph_id = $1")
184        .bind(id.0)
185        .execute(&mut *conn)
186        .await
187        .map_err(db_err)?;
188    let affected = sqlx::query("DELETE FROM sbol_graphs WHERE id = $1 AND kind = 'sbol3'")
189        .bind(id.0)
190        .execute(&mut *conn)
191        .await
192        .map_err(db_err)?
193        .rows_affected();
194    Ok(affected > 0)
195}
196
197fn row_to_record(row: sqlx::postgres::PgRow) -> Result<GraphRecord, DomainError> {
198    let id: uuid::Uuid = row.try_get("id").map_err(db_err)?;
199    let document_iri: Option<String> = row.try_get("document_iri").map_err(db_err)?;
200    let name: Option<String> = row.try_get("name").map_err(db_err)?;
201    let description: Option<String> = row.try_get("description").map_err(db_err)?;
202    let serialization_format: Option<String> =
203        row.try_get("serialization_format").map_err(db_err)?;
204    let source_uri: Option<String> = row.try_get("source_uri").map_err(db_err)?;
205    let content_hash: Option<Vec<u8>> = row.try_get("content_hash").map_err(db_err)?;
206    let created_at: chrono::DateTime<chrono::Utc> = row.try_get("created_at").map_err(db_err)?;
207    let updated_at: chrono::DateTime<chrono::Utc> = row.try_get("updated_at").map_err(db_err)?;
208    let serialization_format = serialization_format
209        .as_deref()
210        .map(parse_serialization_format)
211        .transpose()?
212        .unwrap_or(SerializationFormat::Json);
213    Ok(GraphRecord {
214        id: GraphId(id),
215        document_iri: document_iri.map(IriString::unchecked),
216        name,
217        description,
218        serialization_format,
219        source_uri,
220        content_hash: content_hash.unwrap_or_default(),
221        created_at,
222        updated_at,
223    })
224}
225
226fn parse_serialization_format(value: &str) -> Result<SerializationFormat, DomainError> {
227    Ok(match value {
228        "json" => SerializationFormat::Json,
229        "jsonld" => SerializationFormat::JsonLd,
230        "rdfxml" => SerializationFormat::RdfXml,
231        "turtle" => SerializationFormat::Turtle,
232        "trig" => SerializationFormat::TriG,
233        "ntriples" => SerializationFormat::NTriples,
234        "nquads" => SerializationFormat::NQuads,
235        "genbank" => SerializationFormat::GenBank,
236        "fasta" => SerializationFormat::Fasta,
237        other => {
238            return Err(DomainError::Database(format!(
239                "unknown serialization_format: {other}"
240            )))
241        }
242    })
243}