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;
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 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 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 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 ($3::uuid IS NULL OR graph_id = $3)
182 AND ($4::text IS NULL OR iri::text > $4)
183 ORDER BY iri::text ASC
184 LIMIT $5
185 "#,
186 )
187 .bind(filter.sbol_class.as_deref())
188 .bind(filter.role.as_deref())
189 .bind(filter.graph_id.map(|d| d.0))
190 .bind(filter.after_iri.as_deref())
191 .bind(limit)
192 .fetch_all(&self.pool)
193 .await
194 .map_err(db_err)?;
195
196 rows.into_iter().map(row_to_record).collect()
197 }
198}
199
200fn row_to_record(row: sqlx::postgres::PgRow) -> Result<SbolObjectRecord, DomainError> {
201 let id: Uuid = row.try_get("id").map_err(db_err)?;
202 let iri: String = row.try_get("iri").map_err(db_err)?;
203 let sbol_class: String = row.try_get("sbol_class").map_err(db_err)?;
204 let display_id: Option<String> = row.try_get("display_id").map_err(db_err)?;
205 let name: Option<String> = row.try_get("name").map_err(db_err)?;
206 let description: Option<String> = row.try_get("description").map_err(db_err)?;
207 let graph_id: Option<Uuid> = row.try_get("graph_id").map_err(db_err)?;
208 let types: Vec<String> = row.try_get("types").map_err(db_err)?;
209 let roles: Vec<String> = row.try_get("roles").map_err(db_err)?;
210 let data: serde_json::Value = row.try_get("data").map_err(db_err)?;
211 let content_hash: Option<Vec<u8>> = row.try_get("content_hash").map_err(db_err)?;
212 Ok(SbolObjectRecord {
213 id: ObjectId(id),
214 iri: IriString::unchecked(iri),
215 sbol_class,
216 display_id,
217 name,
218 description,
219 graph_id: graph_id.map(GraphId),
220 types,
221 roles,
222 data,
223 content_hash: content_hash.unwrap_or_default(),
224 })
225}