wowlab_sentinel/mcp/tools/query/
execute.rs1use std::time::Duration;
2
3use serde_json::{Map, Value};
4use sqlx::{PgPool, QueryBuilder, Row};
5use tokio::time::timeout;
6
7use super::{
8 build::{build_query, push_filter, validate_columns},
9 types::{BatchQueryResult, CountQuery, CountResult, TableBatchQuery, TableQuery},
10};
11use crate::mcp::schema::{ColType, SchemaCatalog, Table, UnknownTableError, get_column};
12
13const QUERY_TIMEOUT_SECS: u64 = 30;
14const MAX_BATCH_QUERIES: usize = 50;
15
16#[derive(Debug, thiserror::Error)]
17pub(super) enum QueryError {
18 #[error(transparent)]
19 UnknownTable(#[from] UnknownTableError),
20 #[error("{0}")]
21 Invalid(String),
22 #[error("Query timeout: {0}")]
23 Timeout(#[from] tokio::time::error::Elapsed),
24 #[error(transparent)]
25 Database(#[from] sqlx::Error),
26 #[error("Batch requires at least one query")]
27 EmptyBatch,
28 #[error("Batch supports at most 50 queries")]
29 BatchTooLarge,
30}
31
32impl From<String> for QueryError {
33 fn from(message: String) -> Self {
34 Self::Invalid(message)
35 }
36}
37
38impl From<&str> for QueryError {
39 fn from(message: &str) -> Self {
40 Self::Invalid(message.to_owned())
41 }
42}
43
44pub(super) async fn execute(
45 db: &PgPool,
46 catalog: &SchemaCatalog,
47 q: TableQuery,
48) -> Result<Vec<Value>, QueryError> {
49 let table = catalog.table(&q.table)?;
50
51 validate_columns(table, &q)?;
52
53 let mut query = build_query(table, &q)?;
54 let rows = timeout(
55 Duration::from_secs(QUERY_TIMEOUT_SECS),
56 query.build().fetch_all(db),
57 )
58 .await??;
59
60 Ok(rows.iter().map(|row| row_to_json(row, table)).collect())
61}
62
63pub(super) async fn execute_batch(
64 db: &PgPool,
65 catalog: &SchemaCatalog,
66 batch: TableBatchQuery,
67) -> Result<Vec<BatchQueryResult>, QueryError> {
68 if batch.queries.is_empty() {
69 return Err(QueryError::EmptyBatch);
70 }
71
72 if batch.queries.len() > MAX_BATCH_QUERIES {
73 return Err(QueryError::BatchTooLarge);
74 }
75
76 let futs = batch.queries.into_iter().map(|q| {
77 let table_name = q.table.clone();
78
79 async move { (table_name, execute(db, catalog, q).await) }
80 });
81
82 Ok(futures::future::join_all(futs)
83 .await
84 .into_iter()
85 .enumerate()
86 .map(|(index, (table, result))| match result {
87 Ok(rows) => BatchQueryResult {
88 index,
89 table,
90 row_count: rows.len(),
91 results: Some(rows),
92 error: None,
93 },
94 Err(err) => BatchQueryResult {
95 index,
96 table,
97 row_count: 0,
98 results: None,
99 error: Some(err.to_string()),
100 },
101 })
102 .collect())
103}
104
105pub(super) async fn execute_count(
106 db: &PgPool,
107 catalog: &SchemaCatalog,
108 q: CountQuery,
109) -> Result<CountResult, QueryError> {
110 let table = catalog.table(&q.table)?;
111
112 let mut qb = build_count_query(table, &q)?;
113 let row = timeout(
114 Duration::from_secs(QUERY_TIMEOUT_SECS),
115 qb.build().fetch_one(db),
116 )
117 .await??;
118
119 let count: i64 = row.try_get(0)?;
120
121 Ok(CountResult {
122 table: q.table,
123 count,
124 })
125}
126
127fn build_count_query<'a>(
128 table: &'a Table,
129 q: &'a CountQuery,
130) -> Result<QueryBuilder<'a, sqlx::Postgres>, QueryError> {
131 if let Some(filter) = q
132 .filters
133 .iter()
134 .find(|filter| get_column(table, &filter.column).is_none())
135 {
136 return Err(format!(
137 "Column '{}' not filterable on {}",
138 filter.column,
139 table.name()
140 )
141 .into());
142 }
143
144 let mut qb = QueryBuilder::new("SELECT COUNT(*) FROM ");
145
146 qb.push(table.name());
147
148 let mut first = true;
149
150 for f in &q.filters {
152 let col_def = get_column(table, &f.column)
153 .ok_or_else(|| format!("Column '{}' not found after validation", f.column))?;
154
155 qb.push(if first { " WHERE " } else { " AND " });
156 first = false;
157 push_filter(&mut qb, col_def.name, col_def.typ, &f.op, f.value.as_ref())?;
158 }
159
160 Ok(qb)
161}
162
163fn row_to_json(row: &sqlx::postgres::PgRow, table: &Table) -> Value {
164 let mut map = Map::with_capacity(table.columns.len());
165
166 for col in table.columns {
167 let value = match col.typ {
168 ColType::Int => row
169 .try_get::<i64, _>(col.name)
170 .map(Value::from)
171 .or_else(|_| row.try_get::<i32, _>(col.name).map(Value::from))
172 .unwrap_or(Value::Null),
173 ColType::Float => row
174 .try_get::<f64, _>(col.name)
175 .map(Value::from)
176 .or_else(|_| row.try_get::<f32, _>(col.name).map(Value::from))
177 .unwrap_or(Value::Null),
178 ColType::Text => row
179 .try_get::<String, _>(col.name)
180 .map_or(Value::Null, Value::from),
181 ColType::Bool => row
182 .try_get::<bool, _>(col.name)
183 .map_or(Value::Null, Value::from),
184 ColType::Json => row.try_get::<Value, _>(col.name).unwrap_or(Value::Null),
185 ColType::Timestamp => row
186 .try_get::<chrono::DateTime<chrono::Utc>, _>(col.name)
187 .map_or(Value::Null, |v| Value::from(v.to_rfc3339())),
188 ColType::IntArray => row
189 .try_get::<Vec<i32>, _>(col.name)
190 .map_or(Value::Null, |v| {
191 Value::Array(v.into_iter().map(Value::from).collect())
192 }),
193 };
194
195 map.insert(col.name.to_string(), value);
197 }
198
199 Value::Object(map)
200}