Skip to main content

sbol_db_postgres/repo/
object.rs

1use sbol_db_core::{DomainError, GraphId, IriString, ObjectId, ObjectSummary, SbolObjectRecord};
2use sqlx::Row;
3use uuid::Uuid;
4
5use crate::repo::db_err;
6use crate::PgPool;
7
8use sbol_db_storage::{ListObjectsFilter, TextSearchQuery};
9
10#[derive(Clone)]
11pub struct SbolObjectRepository {
12    pool: PgPool,
13}
14
15pub struct UpsertResult {
16    pub object_id: ObjectId,
17    pub revision_number: i64,
18    pub previous_content_hash: Option<Vec<u8>>,
19}
20
21impl SbolObjectRepository {
22    pub fn new(pool: PgPool) -> Self {
23        Self { pool }
24    }
25
26    /// Insert or update by IRI. Returns the resulting object id, the new
27    /// revision number, and the previous content hash (if any) so callers
28    /// can detect a no-op.
29    pub async fn upsert(
30        &self,
31        conn: &mut sqlx::PgConnection,
32        summary: &ObjectSummary,
33        graph_id: Option<GraphId>,
34    ) -> Result<UpsertResult, DomainError> {
35        let row = sqlx::query(
36            r#"
37            WITH prev AS (
38                SELECT id, content_hash FROM sbol_objects WHERE iri = $1
39            ),
40            upsert AS (
41                INSERT INTO sbol_objects (
42                    iri, sbol_class, display_id, name, description,
43                    graph_id, types, roles, data, content_hash, updated_at
44                ) VALUES ($1, $2, $3, $4, $5, $6, $7::text[]::sbol_ontology_term[],
45                          $8::text[]::sbol_ontology_term[], $9, $10, now())
46                ON CONFLICT (iri) DO UPDATE SET
47                    sbol_class = EXCLUDED.sbol_class,
48                    display_id = EXCLUDED.display_id,
49                    name = EXCLUDED.name,
50                    description = EXCLUDED.description,
51                    graph_id = EXCLUDED.graph_id,
52                    types = EXCLUDED.types,
53                    roles = EXCLUDED.roles,
54                    data = EXCLUDED.data,
55                    content_hash = EXCLUDED.content_hash,
56                    updated_at = now()
57                RETURNING id
58            ),
59            next_revision AS (
60                INSERT INTO sbol_object_revisions (
61                    object_id, iri, revision_number, data, content_hash
62                )
63                SELECT
64                    (SELECT id FROM upsert),
65                    $1,
66                    COALESCE(
67                        (SELECT MAX(revision_number) + 1 FROM sbol_object_revisions
68                            WHERE object_id = (SELECT id FROM upsert)),
69                        1
70                    ),
71                    $9,
72                    $10
73                RETURNING revision_number
74            )
75            SELECT
76                (SELECT id FROM upsert) AS id,
77                (SELECT revision_number FROM next_revision) AS revision_number,
78                (SELECT content_hash FROM prev) AS previous_content_hash
79            "#,
80        )
81        .bind(summary.iri.as_str())
82        .bind(&summary.sbol_class)
83        .bind(summary.display_id.as_deref())
84        .bind(summary.name.as_deref())
85        .bind(summary.description.as_deref())
86        .bind(graph_id.map(|d| d.0))
87        .bind(&summary.types)
88        .bind(&summary.roles)
89        .bind(&summary.data)
90        .bind(&summary.content_hash)
91        .fetch_one(&mut *conn)
92        .await
93        .map_err(db_err)?;
94
95        let id: Uuid = row.try_get("id").map_err(db_err)?;
96        let revision_number: i64 = row.try_get("revision_number").map_err(db_err)?;
97        let previous_content_hash: Option<Vec<u8>> =
98            row.try_get("previous_content_hash").map_err(db_err)?;
99
100        Ok(UpsertResult {
101            object_id: ObjectId(id),
102            revision_number,
103            previous_content_hash,
104        })
105    }
106
107    pub async fn get_iri_by_id(&self, id: ObjectId) -> Result<Option<String>, DomainError> {
108        let row = sqlx::query("SELECT iri FROM sbol_objects WHERE id = $1")
109            .bind(id.0)
110            .fetch_optional(&self.pool)
111            .await
112            .map_err(db_err)?;
113        match row {
114            Some(row) => Ok(Some(row.try_get::<String, _>("iri").map_err(db_err)?)),
115            None => Ok(None),
116        }
117    }
118
119    pub async fn get_by_iri(&self, iri: &str) -> Result<Option<SbolObjectRecord>, DomainError> {
120        let row = sqlx::query(
121            r#"
122            SELECT id, iri::text AS iri, sbol_class, display_id, name, description,
123                   graph_id, types::text[] AS types, roles::text[] AS roles,
124                   data, content_hash
125            FROM sbol_objects
126            WHERE iri = $1 AND is_deleted = false
127            "#,
128        )
129        .bind(iri)
130        .fetch_optional(&self.pool)
131        .await
132        .map_err(db_err)?;
133
134        row.map(row_to_record).transpose()
135    }
136
137    /// Bulk variant of [`get_by_iri`]. Returns matching live rows in arbitrary
138    /// order; IRIs that don't resolve are simply absent from the result. A
139    /// single indexed scan via `iri = ANY($1)`.
140    pub async fn get_by_iris(&self, iris: &[&str]) -> Result<Vec<SbolObjectRecord>, DomainError> {
141        if iris.is_empty() {
142            return Ok(Vec::new());
143        }
144        let owned: Vec<String> = iris.iter().map(|s| (*s).to_owned()).collect();
145        let rows = sqlx::query(
146            r#"
147            SELECT id, iri::text AS iri, sbol_class, display_id, name, description,
148                   graph_id, types::text[] AS types, roles::text[] AS roles,
149                   data, content_hash
150            FROM sbol_objects
151            WHERE iri::text = ANY($1::text[]) AND is_deleted = false
152            "#,
153        )
154        .bind(&owned)
155        .fetch_all(&self.pool)
156        .await
157        .map_err(db_err)?;
158
159        rows.into_iter().map(row_to_record).collect()
160    }
161
162    /// Paginated listing for corpus-scale export. Keyset cursor on `iri`
163    /// (lexicographic ascending); pass the last `iri` of the prior page as
164    /// `after_iri` to fetch the next. Filters compose: when any of
165    /// `sbol_class`, `role`, or `graph_id` is `Some`, the corresponding
166    /// predicate is added to the WHERE clause.
167    pub async fn list(
168        &self,
169        filter: &ListObjectsFilter,
170    ) -> Result<Vec<SbolObjectRecord>, DomainError> {
171        let limit = filter.limit.clamp(1, 5000) as i64;
172        let rows = sqlx::query(
173            r#"
174            SELECT id, iri::text AS iri, sbol_class, display_id, name, description,
175                   graph_id, types::text[] AS types, roles::text[] AS roles,
176                   data, content_hash
177            FROM sbol_objects
178            WHERE is_deleted = false
179              AND ($1::text IS NULL OR sbol_class = $1)
180              AND ($2::text IS NULL OR $2 = ANY(roles::text[]))
181              AND ($4::text IS NULL OR iri::text > $4)
182              AND ($6::text IS NULL OR strpos(
183                translate(iri::text, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'),
184                $6
185              ) > 0)
186              AND ($3::uuid IS NULL OR EXISTS (
187                SELECT 1 FROM sbol_triples t
188                JOIN sbol_graphs g ON g.iri = t.graph_iri
189                WHERE g.id = $3 AND t.subject_iri = sbol_objects.iri
190              ))
191            ORDER BY iri::text ASC
192            LIMIT $5
193            "#,
194        )
195        .bind(filter.sbol_class.as_deref())
196        .bind(filter.role.as_deref())
197        .bind(filter.graph_id.map(|d| d.0))
198        .bind(filter.after_iri.as_deref())
199        .bind(limit)
200        .bind(filter.iri_contains.as_deref().map(str::to_ascii_lowercase))
201        .fetch_all(&self.pool)
202        .await
203        .map_err(db_err)?;
204
205        rows.into_iter().map(row_to_record).collect()
206    }
207
208    /// Offset-paginated substring search. Without `property_uri`, matches the
209    /// `name`/`display_id`/`description` summary fields (the first two are
210    /// trigram-indexed). With `property_uri`, matches the literal value of that
211    /// predicate on the object via `sbol_triples`. Returns the page plus the
212    /// total match count; when `limit` is 0 the page is empty and only the
213    /// count query runs.
214    pub async fn search(
215        &self,
216        query: &TextSearchQuery,
217    ) -> Result<(Vec<SbolObjectRecord>, i64), DomainError> {
218        let limit = query.limit.clamp(0, 1000);
219        let offset = query.offset.max(0);
220
221        // The two match shapes share their bind order (text, class) so the
222        // count and row queries can reuse the same predicate.
223        let (where_clause, order_by): (&str, &str) = if query.property_uri.is_some() {
224            (
225                "WHERE o.is_deleted = false \
226                 AND ($2::text IS NULL OR o.sbol_class = $2) \
227                 AND EXISTS (SELECT 1 FROM sbol_triples t \
228                             WHERE t.subject_iri = o.iri \
229                               AND t.predicate_iri = $3 \
230                               AND t.object_literal ILIKE '%' || $1 || '%')",
231                "ORDER BY o.iri::text ASC",
232            )
233        } else {
234            (
235                "WHERE o.is_deleted = false \
236                 AND ($2::text IS NULL OR o.sbol_class = $2) \
237                 AND (o.name ILIKE '%' || $1 || '%' \
238                      OR o.display_id ILIKE '%' || $1 || '%' \
239                      OR o.description ILIKE '%' || $1 || '%')",
240                "ORDER BY (o.name ILIKE '%' || $1 || '%') DESC, o.iri::text ASC",
241            )
242        };
243
244        let count_sql = format!("SELECT count(*) AS n FROM sbol_objects o {where_clause}");
245        let total: i64 = sqlx::query(&count_sql)
246            .bind(&query.text)
247            .bind(query.sbol_class.as_deref())
248            .bind(query.property_uri.as_deref())
249            .fetch_one(&self.pool)
250            .await
251            .map_err(db_err)?
252            .try_get("n")
253            .map_err(db_err)?;
254
255        if limit == 0 {
256            return Ok((Vec::new(), total));
257        }
258
259        let rows_sql = format!(
260            r#"
261            SELECT o.id, o.iri::text AS iri, o.sbol_class, o.display_id, o.name, o.description,
262                   o.graph_id, o.types::text[] AS types, o.roles::text[] AS roles,
263                   o.data, o.content_hash
264            FROM sbol_objects o
265            {where_clause}
266            {order_by}
267            LIMIT $4 OFFSET $5
268            "#
269        );
270        let rows = sqlx::query(&rows_sql)
271            .bind(&query.text)
272            .bind(query.sbol_class.as_deref())
273            .bind(query.property_uri.as_deref())
274            .bind(limit)
275            .bind(offset)
276            .fetch_all(&self.pool)
277            .await
278            .map_err(db_err)?;
279
280        let objects = rows
281            .into_iter()
282            .map(row_to_record)
283            .collect::<Result<Vec<_>, _>>()?;
284        Ok((objects, total))
285    }
286}
287
288fn row_to_record(row: sqlx::postgres::PgRow) -> Result<SbolObjectRecord, DomainError> {
289    let id: Uuid = row.try_get("id").map_err(db_err)?;
290    let iri: String = row.try_get("iri").map_err(db_err)?;
291    let sbol_class: String = row.try_get("sbol_class").map_err(db_err)?;
292    let display_id: Option<String> = row.try_get("display_id").map_err(db_err)?;
293    let name: Option<String> = row.try_get("name").map_err(db_err)?;
294    let description: Option<String> = row.try_get("description").map_err(db_err)?;
295    let graph_id: Option<Uuid> = row.try_get("graph_id").map_err(db_err)?;
296    let types: Vec<String> = row.try_get("types").map_err(db_err)?;
297    let roles: Vec<String> = row.try_get("roles").map_err(db_err)?;
298    let data: serde_json::Value = row.try_get("data").map_err(db_err)?;
299    let content_hash: Option<Vec<u8>> = row.try_get("content_hash").map_err(db_err)?;
300    Ok(SbolObjectRecord {
301        id: ObjectId(id),
302        iri: IriString::unchecked(iri),
303        sbol_class,
304        display_id,
305        name,
306        description,
307        graph_id: graph_id.map(GraphId),
308        types,
309        roles,
310        data,
311        content_hash: content_hash.unwrap_or_default(),
312    })
313}