Skip to main content

nomoreide_core/db/
peek.rs

1//! The database read path an agent reaches, as distinct from the dashboard's.
2//!
3//! Both halves of the read-safe layer answer the same questions, but not with
4//! the same words, and the difference is not cosmetic. The dashboard renders a
5//! catalog into a table a person scrolls; an agent is handed a payload it has
6//! to reason over. So a row is an object keyed by column name rather than a
7//! positional array, and a statement that is not a read is answered with
8//! instructions instead of an error. Keeping that in its own module is what
9//! lets the dashboard's shapes change without changing what an agent has
10//! already learned to expect.
11//!
12//! Nothing here can write. The engines enforce that underneath — a read-only
13//! transaction on Postgres and MySQL, a read-only connection on SQLite — so
14//! [`query`] does not have to be trusted to recognise every way to spell a
15//! write. What it recognises is only used to decide *how* to refuse.
16
17use 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
26/// The default row cap. Both `sample` and `query` share it.
27pub const DEFAULT_ROW_LIMIT: i64 = 100;
28
29/// A table or view as the tool listing spells it.
30#[derive(Debug, Clone, Serialize)]
31#[serde(rename_all = "camelCase")]
32pub struct TableRef {
33    pub name: String,
34    pub qualified_name: String,
35}
36
37/// What [`query`] came back with.
38pub enum QueryOutcome {
39    Rows(Value),
40    /// The statement was not run, and this is what to tell the agent instead.
41    Guidance(String),
42}
43
44/// The connection a name refers to, refused in the reference's own words.
45pub 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
56/// The objects in one schema, or nothing when the schema is not one of this
57/// connection's. An unknown schema is empty rather than an error: the agent
58/// asked what is in a place, and the answer is that nothing is.
59pub 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
74/// Every table and view this connection holds, in one flat list ordered the
75/// way a schema listing would be.
76pub 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
87/// Rows from one named table, with the column schema that explains them.
88///
89/// Deliberately *not* [`crate::db::sample_object`]: that one resolves an opaque
90/// catalog key and bullets out a column whose name looks like a secret. This
91/// one is reached by a table's own name and reports what is stored. Both are
92/// the reference's, one per route, and unifying them would either hide a column
93/// from a caller who named it or expose one to a caller who did not.
94pub 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    // The same bounds the browser's own row reader uses. A caller naming a
106    // table can ask for as many rows as one paging through a catalog can.
107    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
136/// One caller-written statement, capped and wrapped.
137///
138/// The cap travels as a bind rather than as text in the statement, which is the
139/// habit worth keeping even though the value is a validated integer. It is not
140/// a guarantee: a statement that closes the wrapper's parenthesis and comments
141/// out the rest of the line takes the cap with it, and what happens then is the
142/// driver's business — SQLite runs the shortened statement, and the reference's
143/// driver refuses it because a parameter it was handed no longer has a place to
144/// go. Either way the connection is still read-only, so the worst case is a
145/// caller returning more of their own rows than they asked for.
146pub 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                // A query's columns are named by the statement, not by the
168                // catalog, so nothing here knows their declared types.
169                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
184/// The same statement, answered the way an *agent* is answered.
185///
186/// The only difference from [`run_capped_query`] is what a failure becomes: a
187/// refusal is worth more to an agent than the driver's complaint, but only when
188/// the statement is one this connection was never going to run. A malformed
189/// read is still just malformed. The dashboard's own query route wants the
190/// driver's wording instead, so it takes the raw function.
191pub 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
206/// A caller's statement, ready to be wrapped.
207///
208/// One trailing semicolon is dropped, because that is what a person typing into
209/// a SQL console types and the wrapper would choke on it. Only one: a statement
210/// that ends in two is two statements, and the second is the caller's to
211/// explain.
212fn 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
221/// Whether a statement is one this connection would have run.
222///
223/// Deliberately a look at the first word and nothing more. It never decides
224/// whether a statement is *safe* — the connection already decided that — only
225/// whether a failure is worth explaining as a refusal. Reading further would
226/// make it look like a security boundary, which it is not.
227pub 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
240/// What to say instead of running a write.
241pub 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
258// ---------------------------------------------------------------------------
259
260/// Every table and view across every schema, ordered by qualified name so that
261/// the listing reads the same whether one schema holds it all or ten do.
262async 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
276/// How this engine spells a bound value.
277fn 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
297/// Positional rows as objects keyed by whatever the driver called each column.
298///
299/// Nothing is done here about two columns sharing a name. SQLite has already
300/// dealt with it — it suffixes a repeated result name with `:1`, `:2` — and on
301/// an engine that has not, inventing a different suffix would only mean the two
302/// runtimes handed an agent different keys for the same query.
303fn 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}