Skip to main content

linkrs_server/graph_service/
explain.rs

1use std::collections::HashMap;
2
3use linkrs_api::api_core::QueryResult;
4
5use crate::storage::{
6    StorageClient, StorageOperationContextOps, StorageSchemaContextOps, StorageSyncContextOps,
7};
8
9use super::error::GraphServiceError;
10use super::GraphService;
11
12impl<
13        S: StorageClient
14            + StorageSchemaContextOps
15            + StorageSyncContextOps
16            + StorageOperationContextOps
17            + Clone
18            + 'static,
19    > GraphService<S>
20{
21    /// Best-effort output row estimate for automatic routing.
22    pub async fn estimate_rows(&self, session_id: i64, stmt: &str) -> Option<u64> {
23        let trimmed = stmt.trim();
24        if trimmed.is_empty() {
25            return None;
26        }
27        let explain = if trimmed.len() >= 7 && trimmed[..7].eq_ignore_ascii_case("explain") {
28            trimmed.to_string()
29        } else {
30            format!("EXPLAIN {trimmed}")
31        };
32        let result = self
33            .execute_with_params(session_id, &explain, None, None)
34            .await
35            .ok()?;
36        let text = match result.first_value() {
37            Some(value) => crate::value::to_json(value.clone()),
38            None => return None,
39        };
40        Self::extract_root_estimate(text.as_str()?)
41    }
42
43    /// Plan a statement via `EXPLAIN` without executing it.
44    pub async fn explain(
45        &self,
46        session_id: i64,
47        stmt: &str,
48        parameters: Option<HashMap<String, linkrs_core::Value>>,
49        session_variables: Option<HashMap<String, linkrs_core::Value>>,
50    ) -> Result<QueryResult, GraphServiceError> {
51        let trimmed = stmt.trim();
52        if trimmed.is_empty() {
53            return Err(GraphServiceError::new("query must not be empty"));
54        }
55        let already_explain = trimmed.len() >= 7 && trimmed[..7].eq_ignore_ascii_case("explain");
56        let explain_stmt = if already_explain {
57            trimmed.to_string()
58        } else {
59            format!("EXPLAIN {trimmed}")
60        };
61        match self
62            .execute_with_params(session_id, &explain_stmt, parameters, session_variables)
63            .await
64        {
65            Ok(result) => Ok(result),
66            Err(e) => {
67                if already_explain {
68                    return Err(e);
69                }
70                const PREFIX_LEN: usize = "EXPLAIN ".len();
71                if let Some(pos) = e.position() {
72                    if pos.line == 1 && pos.column > PREFIX_LEN {
73                        return Err(GraphServiceError::with_position(
74                            e.message().to_string(),
75                            Some(linkrs_core::types::Position::new(
76                                1,
77                                pos.column - PREFIX_LEN,
78                            )),
79                        ));
80                    }
81                    if pos.line == 1 {
82                        return Err(GraphServiceError::new(e.message().to_string()));
83                    }
84                }
85                Err(e)
86            }
87        }
88    }
89
90    /// Scan an EXPLAIN table for `est_rows:<n>` markers and return the
91    /// last one: the description lists producers before consumers, so the
92    /// final marker belongs to the operator closest to the output.
93    pub(crate) fn extract_root_estimate(plan_text: &str) -> Option<u64> {
94        const MARKER: &str = "est_rows:";
95        let mut estimate: Option<u64> = None;
96        let mut rest = plan_text;
97        while let Some(pos) = rest.find(MARKER) {
98            rest = &rest[pos + MARKER.len()..];
99            let len = rest
100                .chars()
101                .take_while(|c| c.is_ascii_digit() || *c == '.')
102                .map(|c| c.len_utf8())
103                .sum::<usize>();
104            if len > 0 {
105                if let Ok(parsed) = rest[..len].parse::<f64>() {
106                    estimate = Some(parsed.round().max(0.0) as u64);
107                }
108                rest = &rest[len..];
109            } else {
110                match rest.chars().next() {
111                    Some(c) => rest = &rest[c.len_utf8()..],
112                    None => break,
113                }
114            }
115        }
116        estimate
117    }
118}