1use super::catalog::{columns_for, objects_for, resolve_object, schemas_for};
18use super::details::details_for;
19use super::engine::{run_plan, QueryPlan};
20use super::sql::quote_identifier;
21use super::types::{CatalogObject, ColumnInfo, ObjectDetails};
22use crate::config::DatabaseDef;
23use serde::Serialize;
24use serde_json::{json, Map, Value};
25
26pub const DEFAULT_ROW_LIMIT: i64 = 100;
28
29#[derive(Debug, Clone, Serialize)]
31#[serde(rename_all = "camelCase")]
32pub struct TableRef {
33 pub name: String,
34 pub qualified_name: String,
35}
36
37pub enum QueryOutcome {
39 Rows(Value),
40 Guidance(String),
42}
43
44pub fn connection<'a>(databases: &'a [DatabaseDef], name: &str) -> Result<&'a DatabaseDef, String> {
46 databases
47 .iter()
48 .find(|database| database.name == name)
49 .ok_or_else(|| format!("Database connection \"{name}\" is not registered."))
50}
51
52pub async fn schemas(database: &DatabaseDef) -> Result<Vec<String>, String> {
53 schemas_for(database).await
54}
55
56pub async fn objects(database: &DatabaseDef, schema: &str) -> Result<Vec<CatalogObject>, String> {
60 if !schemas_for(database)
61 .await?
62 .iter()
63 .any(|candidate| candidate == schema)
64 {
65 return Ok(Vec::new());
66 }
67 objects_for(database, schema).await
68}
69
70pub async fn details(database: &DatabaseDef, key: &str) -> Result<ObjectDetails, String> {
71 details_for(database, &resolve_object(database, key).await?).await
72}
73
74pub async fn tables(database: &DatabaseDef) -> Result<Vec<TableRef>, String> {
77 Ok(catalog_tables(database)
78 .await?
79 .into_iter()
80 .map(|object| TableRef {
81 name: object.name,
82 qualified_name: object.qualified_name,
83 })
84 .collect())
85}
86
87pub async fn sample(
95 database: &DatabaseDef,
96 table: &str,
97 limit: i64,
98 offset: i64,
99) -> Result<Value, String> {
100 let object = catalog_tables(database)
101 .await?
102 .into_iter()
103 .find(|candidate| candidate.qualified_name == table)
104 .ok_or_else(|| format!("Table \"{table}\" not found."))?;
105 let limit = limit.clamp(1, 5_000);
108 let offset = offset.max(0);
109 let columns = columns_for(database, &object).await?;
110 let sql = format!(
111 "SELECT * FROM {} LIMIT {} OFFSET {offset}",
112 qualified_sql(database, &object),
113 placeholder(&database.engine)
114 );
115 let result = run_plan(
116 &database.engine,
117 &database.url,
118 QueryPlan::peek(&sql, Some(limit)),
119 )
120 .await?;
121 let rows = objectify(&result.columns, &result.rows);
122 Ok(json!({
123 "engine": database.engine,
124 "table": TableRef {
125 name: object.name,
126 qualified_name: object.qualified_name,
127 },
128 "columns": columns,
129 "rows": rows,
130 "rowCount": rows.len(),
131 "limit": limit,
132 "offset": offset,
133 }))
134}
135
136pub async fn run_capped_query(
147 database: &DatabaseDef,
148 sql: &str,
149 limit: i64,
150) -> Result<Value, String> {
151 let statement = prepare_user_query(sql)?;
152 let wrapped = format!(
153 "SELECT * FROM ({statement}) LIMIT {}",
154 placeholder(&database.engine)
155 );
156 let plan = QueryPlan::peek(&wrapped, Some(limit + 1));
157 let result = run_plan(&database.engine, &database.url, plan).await?;
158 let mut rows = objectify(&result.columns, &result.rows);
159 let truncated = rows.len() as i64 > limit;
160 rows.truncate(limit.max(0) as usize);
161 let columns: Vec<Value> = result
162 .columns
163 .iter()
164 .map(|name| {
165 json!(ColumnInfo {
166 name: name.clone(),
167 data_type: String::new(),
170 nullable: true,
171 primary_key: false,
172 })
173 })
174 .collect();
175 Ok(json!({
176 "engine": database.engine,
177 "columns": columns,
178 "rows": rows,
179 "rowCount": rows.len(),
180 "truncated": truncated,
181 }))
182}
183
184pub async fn query(database: &DatabaseDef, sql: &str, limit: i64) -> Result<QueryOutcome, String> {
192 match run_capped_query(database, sql, limit).await {
193 Ok(rows) => Ok(QueryOutcome::Rows(rows)),
194 Err(message) => {
195 if !is_read_statement(sql) || mentions_read_only(&message) {
196 Ok(QueryOutcome::Guidance(write_staging_guidance(
197 &database.name,
198 )))
199 } else {
200 Err(message)
201 }
202 }
203 }
204}
205
206fn prepare_user_query(sql: &str) -> Result<String, String> {
213 let trimmed = sql.trim();
214 let trimmed = trimmed.strip_suffix(';').unwrap_or(trimmed).trim();
215 if trimmed.is_empty() {
216 return Err("Query is empty.".to_string());
217 }
218 Ok(trimmed.to_string())
219}
220
221pub fn is_read_statement(sql: &str) -> bool {
228 const READS: [&str; 6] = ["select", "show", "describe", "desc", "explain", "pragma"];
229 let trimmed = sql.trim_start();
230 READS.iter().any(|keyword| {
231 trimmed.len() >= keyword.len()
232 && trimmed[..keyword.len()].eq_ignore_ascii_case(keyword)
233 && !trimmed[keyword.len()..]
234 .chars()
235 .next()
236 .is_some_and(|next| next.is_alphanumeric() || next == '_')
237 })
238}
239
240pub fn write_staging_guidance(connection: &str) -> String {
242 format!(
243 "This connection is read-only, so that statement was NOT executed.\n\
244 Provide the exact SQL statement for the user to review in a standard SQL\n\
245 fenced block, and identify the target connection as `{connection}`:\n\
246 \n\
247 ```sql\n\
248 UPDATE … SET … WHERE …;\n\
249 ```\n\
250 \n\
251 Direct the user to stage and run it through NoMoreIDE's locked SQL console,\n\
252 where they explicitly unlock writes and review an affected-rows preview\n\
253 before committing. Emit exactly one statement, and always scope\n\
254 UPDATE/DELETE with a WHERE clause."
255 )
256}
257
258async fn catalog_tables(database: &DatabaseDef) -> Result<Vec<CatalogObject>, String> {
263 let mut tables = Vec::new();
264 for schema in schemas_for(database).await? {
265 tables.extend(
266 objects_for(database, &schema)
267 .await?
268 .into_iter()
269 .filter(|object| matches!(object.kind.as_str(), "table" | "view")),
270 );
271 }
272 tables.sort_by(|left, right| left.qualified_name.cmp(&right.qualified_name));
273 Ok(tables)
274}
275
276fn placeholder(engine: &str) -> &'static str {
278 if engine == "postgres" {
279 "$1"
280 } else {
281 "?"
282 }
283}
284
285fn qualified_sql(database: &DatabaseDef, object: &CatalogObject) -> String {
286 let name = quote_identifier(&object.name, &database.engine);
287 if database.engine == "sqlite" {
288 name
289 } else {
290 format!(
291 "{}.{name}",
292 quote_identifier(&object.schema, &database.engine)
293 )
294 }
295}
296
297fn objectify(columns: &[String], rows: &[Vec<Value>]) -> Vec<Map<String, Value>> {
304 rows.iter()
305 .map(|row| {
306 columns
307 .iter()
308 .cloned()
309 .zip(row.iter().cloned())
310 .collect::<Map<String, Value>>()
311 })
312 .collect()
313}
314
315fn mentions_read_only(message: &str) -> bool {
316 let lowered = message.to_ascii_lowercase();
317 ["read only", "read-only", "readonly"]
318 .iter()
319 .any(|needle| lowered.contains(needle))
320}