Skip to main content

sbol_db_postgres/repo/
neighborhood.rs

1//! Graph neighborhood traversal over `sbol_triples`.
2//!
3//! Uses a recursive CTE bounded by depth and an optional node-count cap.
4//! Forward, backward, and bidirectional walks share one query; an additional
5//! pass joins back to `sbol_objects` for human-friendly node metadata.
6
7use std::collections::{BTreeMap, BTreeSet};
8
9use sbol_db_core::{
10    Direction, DomainError, EdgeInfo, EdgeObject, NeighborhoodQuery, NeighborhoodResult, NodeInfo,
11};
12use sqlx::Row;
13
14use crate::repo::db_err;
15use crate::PgPool;
16
17#[derive(Clone)]
18pub struct NeighborhoodRepository {
19    pool: PgPool,
20}
21
22impl NeighborhoodRepository {
23    pub fn new(pool: PgPool) -> Self {
24        Self { pool }
25    }
26
27    pub async fn walk(&self, query: &NeighborhoodQuery) -> Result<NeighborhoodResult, DomainError> {
28        let depth_cap = i32::try_from(query.depth).unwrap_or(i32::MAX);
29        let max_nodes_cap = query
30            .max_nodes
31            .map(|m| i32::try_from(m).unwrap_or(i32::MAX))
32            .unwrap_or(i32::MAX);
33        let predicate_filter: Option<Vec<String>> = if query.predicate_allowlist.is_empty() {
34            None
35        } else {
36            Some(
37                query
38                    .predicate_allowlist
39                    .iter()
40                    .map(|i| i.as_str().to_owned())
41                    .collect(),
42            )
43        };
44
45        // The CTE walks resource-position edges only (literals never widen the
46        // frontier). `dir` selects which direction we follow on each step.
47        // `Both` is modelled as UNIONing the forward and backward expansions.
48        let dir_tag = match query.direction {
49            Direction::Forward => "forward",
50            Direction::Backward => "backward",
51            Direction::Both => "both",
52        };
53
54        let rows = sqlx::query(SQL_WALK)
55            .bind(query.root_iri.as_str())
56            .bind(depth_cap)
57            .bind(predicate_filter.as_ref())
58            .bind(dir_tag)
59            .bind(max_nodes_cap)
60            .fetch_all(&self.pool)
61            .await
62            .map_err(db_err)?;
63
64        let mut nodes_by_id: BTreeMap<String, NodeInfo> = BTreeMap::new();
65        let mut edges: Vec<EdgeInfo> = Vec::new();
66        let mut seen_edges: BTreeSet<(String, String, String)> = BTreeSet::new();
67        let mut max_depth = 0u32;
68
69        // The walk SELECT returns one row per reached node *and* the edge that
70        // led to it (subject, predicate, object position). The root has
71        // depth=0 and no edge.
72        for row in &rows {
73            let depth: i32 = row.try_get("depth").map_err(db_err)?;
74            let depth_u: u32 = depth.max(0) as u32;
75            max_depth = max_depth.max(depth_u);
76
77            let node_id: String = row.try_get("node_id").map_err(db_err)?;
78            let is_blank: bool = row.try_get("node_is_blank").map_err(db_err)?;
79            nodes_by_id
80                .entry(node_id.clone())
81                .or_insert_with(|| NodeInfo {
82                    id: node_id.clone(),
83                    depth: depth_u,
84                    is_blank,
85                    sbol_class: None,
86                    display_id: None,
87                    name: None,
88                });
89
90            // The seed row has no edge — depth=0, edge_subject NULL.
91            let edge_subject: Option<String> = row.try_get("edge_subject").map_err(db_err)?;
92            if let Some(subject) = edge_subject {
93                let subject_blank: bool = row.try_get("edge_subject_is_blank").map_err(db_err)?;
94                let predicate: String = row.try_get("edge_predicate").map_err(db_err)?;
95                let object_iri: Option<String> = row.try_get("edge_object_iri").map_err(db_err)?;
96                let object_blank: Option<String> =
97                    row.try_get("edge_object_blank").map_err(db_err)?;
98                let object_literal: Option<String> =
99                    row.try_get("edge_object_literal").map_err(db_err)?;
100                let object_datatype: Option<String> =
101                    row.try_get("edge_object_datatype").map_err(db_err)?;
102                let object_language: Option<String> =
103                    row.try_get("edge_object_language").map_err(db_err)?;
104                let object = if let Some(iri) = object_iri.clone() {
105                    EdgeObject::Iri { value: iri }
106                } else if let Some(blank) = object_blank.clone() {
107                    EdgeObject::BlankNode { value: blank }
108                } else if let Some(lit) = object_literal.clone() {
109                    EdgeObject::Literal {
110                        value: lit,
111                        datatype: object_datatype.unwrap_or_else(|| {
112                            "http://www.w3.org/2001/XMLSchema#string".to_owned()
113                        }),
114                        language: object_language,
115                    }
116                } else {
117                    continue;
118                };
119                let object_key = match &object {
120                    EdgeObject::Iri { value } | EdgeObject::BlankNode { value } => value.clone(),
121                    EdgeObject::Literal { value, .. } => format!("\u{1}lit\u{1}{value}"),
122                };
123                if seen_edges.insert((subject.clone(), predicate.clone(), object_key)) {
124                    edges.push(EdgeInfo {
125                        subject,
126                        subject_is_blank: subject_blank,
127                        predicate,
128                        object,
129                        depth: depth_u,
130                    });
131                }
132            }
133        }
134
135        // Optionally include literal-position edges by sweeping all literals
136        // out of each visited subject. Done as a second query so the CTE
137        // stays focused on resource walks.
138        if query.include_literals {
139            let ids: Vec<&str> = nodes_by_id.keys().map(String::as_str).collect();
140            let literal_rows = sqlx::query(SQL_LITERAL_EDGES)
141                .bind(&ids)
142                .fetch_all(&self.pool)
143                .await
144                .map_err(db_err)?;
145            for row in literal_rows {
146                let subject: String = row.try_get("subject").map_err(db_err)?;
147                let predicate: String = row.try_get("predicate").map_err(db_err)?;
148                let value: String = row.try_get("value").map_err(db_err)?;
149                let datatype: Option<String> = row.try_get("datatype").map_err(db_err)?;
150                let language: Option<String> = row.try_get("language").map_err(db_err)?;
151                let depth = nodes_by_id.get(&subject).map(|n| n.depth).unwrap_or(0);
152                let key = (
153                    subject.clone(),
154                    predicate.clone(),
155                    format!("\u{1}lit\u{1}{value}"),
156                );
157                if seen_edges.insert(key) {
158                    edges.push(EdgeInfo {
159                        subject,
160                        subject_is_blank: false,
161                        predicate,
162                        object: EdgeObject::Literal {
163                            value,
164                            datatype: datatype.unwrap_or_else(|| {
165                                "http://www.w3.org/2001/XMLSchema#string".to_owned()
166                            }),
167                            language,
168                        },
169                        depth,
170                    });
171                }
172            }
173        }
174
175        // Decorate IRI nodes with sbol_class/display_id/name in one query.
176        let iri_keys: Vec<&str> = nodes_by_id
177            .values()
178            .filter(|n| !n.is_blank)
179            .map(|n| n.id.as_str())
180            .collect();
181        if !iri_keys.is_empty() {
182            let meta_rows = sqlx::query(SQL_NODE_METADATA)
183                .bind(&iri_keys)
184                .fetch_all(&self.pool)
185                .await
186                .map_err(db_err)?;
187            for row in meta_rows {
188                let iri: String = row.try_get("iri").map_err(db_err)?;
189                if let Some(node) = nodes_by_id.get_mut(&iri) {
190                    node.sbol_class = row.try_get("sbol_class").map_err(db_err)?;
191                    node.display_id = row.try_get("display_id").map_err(db_err)?;
192                    node.name = row.try_get("name").map_err(db_err)?;
193                }
194            }
195        }
196
197        let nodes: Vec<NodeInfo> = nodes_by_id.into_values().collect();
198        let truncated = nodes.len() as i32 >= max_nodes_cap;
199
200        Ok(NeighborhoodResult {
201            root_iri: query.root_iri.clone(),
202            nodes,
203            edges,
204            max_depth_reached: max_depth,
205            truncated,
206        })
207    }
208}
209
210/// The recursive walk. Postgres only allows one self-reference in the
211/// recursive term, so we normalize both directions into a non-recursive
212/// `edges` CTE and walk that. Each `edges` row is the *outgoing* view of a
213/// single triple: `(from_id, predicate, to_id)` plus the original subject /
214/// object position for surfacing the edge in the result.
215///
216/// `$1 = root iri, $2 = depth cap, $3 = predicate allowlist (nullable),
217/// $4 = direction ('forward'|'backward'|'both'), $5 = node count cap.`
218const SQL_WALK: &str = r#"
219WITH RECURSIVE
220edges AS (
221    SELECT
222        COALESCE(subject_iri::text, subject_blank) AS from_id,
223        (subject_iri IS NULL AND subject_blank IS NOT NULL) AS from_is_blank,
224        predicate_iri::text                        AS predicate,
225        COALESCE(object_iri::text, object_blank)   AS to_id,
226        (object_iri IS NULL AND object_blank IS NOT NULL) AS to_is_blank,
227        subject_iri::text                          AS edge_subject_iri,
228        subject_blank                              AS edge_subject_blank,
229        object_iri::text                           AS edge_object_iri,
230        object_blank                               AS edge_object_blank
231    FROM sbol_triples
232    WHERE $4 IN ('forward', 'both')
233      AND (object_iri IS NOT NULL OR object_blank IS NOT NULL)
234
235    UNION ALL
236
237    SELECT
238        COALESCE(object_iri::text, object_blank),
239        (object_iri IS NULL AND object_blank IS NOT NULL),
240        predicate_iri::text,
241        COALESCE(subject_iri::text, subject_blank),
242        (subject_iri IS NULL AND subject_blank IS NOT NULL),
243        subject_iri::text,
244        subject_blank,
245        object_iri::text,
246        object_blank
247    FROM sbol_triples
248    WHERE $4 IN ('backward', 'both')
249      AND (object_iri IS NOT NULL OR object_blank IS NOT NULL)
250),
251walk AS (
252    SELECT
253        $1::text AS node_id,
254        false    AS node_is_blank,
255        0        AS depth,
256        ARRAY[$1::text] AS path,
257        NULL::text AS edge_subject,
258        false      AS edge_subject_is_blank,
259        NULL::text AS edge_predicate,
260        NULL::text AS edge_object_iri,
261        NULL::text AS edge_object_blank,
262        NULL::text AS edge_object_literal,
263        NULL::text AS edge_object_datatype,
264        NULL::text AS edge_object_language
265
266    UNION ALL
267
268    SELECT
269        e.to_id,
270        e.to_is_blank,
271        w.depth + 1,
272        w.path || e.to_id,
273        COALESCE(e.edge_subject_iri, e.edge_subject_blank),
274        (e.edge_subject_iri IS NULL AND e.edge_subject_blank IS NOT NULL),
275        e.predicate,
276        e.edge_object_iri,
277        e.edge_object_blank,
278        NULL::text,
279        NULL::text,
280        NULL::text
281    FROM walk w
282    JOIN edges e
283      ON e.from_id = w.node_id
284     AND e.from_is_blank = w.node_is_blank
285    WHERE w.depth < $2
286      AND ($3::text[] IS NULL OR e.predicate = ANY($3))
287      AND NOT e.to_id = ANY(w.path)
288)
289SELECT *
290FROM walk
291LIMIT $5
292"#;
293
294const SQL_LITERAL_EDGES: &str = r#"
295SELECT
296    COALESCE(subject_iri::text, subject_blank) AS subject,
297    predicate_iri::text                        AS predicate,
298    object_literal                             AS value,
299    datatype_iri::text                         AS datatype,
300    language                                   AS language
301FROM sbol_triples
302WHERE object_literal IS NOT NULL
303  AND COALESCE(subject_iri::text, subject_blank) = ANY($1::text[])
304"#;
305
306const SQL_NODE_METADATA: &str = r#"
307SELECT iri::text AS iri, sbol_class, display_id, name
308FROM sbol_objects
309WHERE iri::text = ANY($1::text[])
310  AND is_deleted = false
311"#;