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 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 ($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 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 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}