use std::path::{Path, PathBuf};
use duckdb::{AccessMode, Config, Connection};
use serde_json::{Value, json};
use crate::error::{Error, Result};
const SAMPLE_ROWS: usize = 5;
pub(crate) fn open_read_only(dir: &Path) -> Result<(Connection, PathBuf)> {
let manifest = crate::manifest::Manifest::load(dir)?;
let path = manifest.db_path(dir)?;
if !path.is_file() {
return Err(refusal(format!(
"no database for this Protocol yet at {}: run the Protocol (arc run) to make it",
path.display()
)));
}
let config = Config::default()
.access_mode(AccessMode::ReadOnly)
.and_then(|c| c.with("autoinstall_known_extensions", "false"))
.map_err(db_error)?;
let conn = Connection::open_with_flags(&path, config).map_err(|e| {
if is_lock_error(&e.to_string()) {
refusal(format!(
"the Protocol's database at {} is in use by another process; try again when the run or app holding it has finished",
path.display()
))
} else {
db_error(e)
}
})?;
Ok((conn, path))
}
const LOCK_WORDING: [&str; 2] = ["Could not set lock on file", "Conflicting lock is held"];
fn is_lock_error(message: &str) -> bool {
LOCK_WORDING.iter().any(|w| message.contains(w))
}
fn refusal(message: String) -> Error {
Error::Io(std::io::Error::other(message))
}
fn db_error(e: duckdb::Error) -> Error {
refusal(format!("database: {e}"))
}
fn quote(identifier: &str) -> String {
format!("\"{}\"", identifier.replace('"', "\"\""))
}
fn json_value_sql(expr: &str, ty: &str) -> String {
const EXACT: u64 = 1 << 53;
let exact = match ty.trim().to_ascii_uppercase().as_str() {
"HUGEINT" => format!("{expr} BETWEEN -{EXACT} AND {EXACT}"),
"UHUGEINT" => format!("{expr} <= {EXACT}"),
_ if is_wide_decimal(ty) => return format!("to_json({expr}::VARCHAR)"),
_ => return format!("to_json({expr})"),
};
format!("CASE WHEN {exact} THEN to_json({expr}::BIGINT) ELSE to_json({expr}::VARCHAR) END")
}
fn is_wide_decimal(ty: &str) -> bool {
let ty = ty.trim().to_ascii_uppercase();
if !ty.ends_with(')') {
return false;
}
let precision = ty
.strip_prefix("DECIMAL(")
.or_else(|| ty.strip_prefix("NUMERIC("))
.and_then(|rest| rest.split([',', ')']).next())
.and_then(|p| p.trim().parse::<u32>().ok());
precision.is_some_and(|p| p > 15)
}
fn cell_value(text: Option<String>) -> Value {
match text {
None => Value::Null,
Some(text) => serde_json::from_str(&text).unwrap_or(Value::String(text)),
}
}
fn json_rows(
conn: &Connection,
sql: &str,
width: usize,
) -> std::result::Result<Vec<Value>, duckdb::Error> {
let mut stmt = conn.prepare(sql)?;
stmt.query_map([], |r| {
(0..width)
.map(|i| r.get::<_, Option<String>>(i).map(cell_value))
.collect::<std::result::Result<Vec<_>, _>>()
.map(Value::Array)
})?
.collect()
}
pub(crate) fn describe_table(dir: &Path, table: &str) -> Result<Value> {
let (conn, path) = open_read_only(dir)?;
describe_in(&conn, &path, table).map_err(|e| match e {
DescribeError::Ours(e) => e,
DescribeError::Duck(e) => db_error(e),
})
}
enum DescribeError {
Ours(Error),
Duck(duckdb::Error),
}
impl From<duckdb::Error> for DescribeError {
fn from(e: duckdb::Error) -> Self {
DescribeError::Duck(e)
}
}
fn ours(message: String) -> DescribeError {
DescribeError::Ours(refusal(message))
}
fn display(schema: &str, name: &str) -> String {
if schema == "main" {
name.to_string()
} else {
format!("{schema}.{name}")
}
}
fn resolve(
entries: &[(String, String)],
table: &str,
path: &Path,
) -> std::result::Result<(String, String), DescribeError> {
let same = |a: &str, b: &str| a.eq_ignore_ascii_case(b);
if let Some((schema, name)) = table.split_once('.')
&& let Some((s, n)) = entries
.iter()
.find(|(s, n)| same(s, schema) && same(n, name))
{
return Ok((s.clone(), n.clone()));
}
let candidates: Vec<&(String, String)> =
entries.iter().filter(|(_, n)| same(n, table)).collect();
if let Some((s, n)) = candidates.iter().find(|(s, _)| s == "main") {
return Ok((s.clone(), n.clone()));
}
match candidates.as_slice() {
[(s, n)] => Ok((s.clone(), n.clone())),
[] => {
let mut names: Vec<String> = entries.iter().map(|(s, n)| display(s, n)).collect();
names.sort();
names.dedup();
Err(ours(format!(
"no table or view named {table} in {}; the tables are: {}",
path.display(),
names.join(", ")
)))
}
several => {
let mut names: Vec<String> = several.iter().map(|(s, n)| format!("{s}.{n}")).collect();
names.sort();
Err(ours(format!(
"{table} is in more than one schema in {}; name one of: {}",
path.display(),
names.join(", ")
)))
}
}
}
fn describe_in(
conn: &Connection,
path: &Path,
table: &str,
) -> std::result::Result<Value, DescribeError> {
let mut stmt = conn.prepare(
"SELECT table_schema, table_name FROM information_schema.tables \
WHERE table_schema NOT IN ('information_schema', 'pg_catalog')",
)?;
let entries: Vec<(String, String)> = stmt
.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?
.collect::<std::result::Result<_, _>>()?;
let (schema, name) = resolve(&entries, table, path)?;
let quoted = format!("{}.{}", quote(&schema), quote(&name));
let mut stmt = conn.prepare(&format!(
"SELECT column_name, column_type FROM (DESCRIBE {quoted})"
))?;
let described: Vec<(String, String)> = stmt
.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?
.collect::<std::result::Result<_, _>>()?;
let mut select = String::from("SELECT count(*)");
for (column, _) in &described {
select.push_str(&format!(", count(*) - count({})", quote(column)));
}
select.push_str(&format!(" FROM {quoted}"));
let counts: Vec<i64> = conn.query_row(&select, [], |r| {
(0..=described.len()).map(|i| r.get::<_, i64>(i)).collect()
})?;
let columns: Vec<Value> = described
.iter()
.zip(&counts[1..])
.map(|((column, ty), nulls)| json!({ "name": column, "type": ty, "null_count": nulls }))
.collect();
let values: Vec<String> = described
.iter()
.map(|(column, ty)| json_value_sql("e(column), ty))
.collect();
let sample = json_rows(
conn,
&format!(
"SELECT {} FROM {quoted} LIMIT {SAMPLE_ROWS}",
values.join(", ")
),
described.len(),
)?;
Ok(json!({
"table": table,
"database": path.display().to_string(),
"row_count": counts[0],
"columns": columns,
"sample": sample,
}))
}
const KEEP: &str =
"to keep a query as a step: `arc sql record '<statement>' --name <name>` (MCP: sql_record)";
const GATE_REFUSAL: &str =
"only read-only SELECT statements can be queried; refused before anything ran";
pub(crate) const MAX_LIMIT: usize = 100_000;
const SELECT_NODES: [&str; 4] = [
"SELECT_NODE",
"SET_OPERATION_NODE",
"CTE_NODE",
"RECURSIVE_CTE_NODE",
];
pub(crate) fn query(dir: &Path, sql: &str, limit: usize) -> Result<Value> {
if limit > MAX_LIMIT {
return Err(refusal(format!(
"the limit is at most {MAX_LIMIT} rows for each statement; {limit} was asked for"
)));
}
let (conn, _path) = open_read_only(dir)?;
let statements = gate(&conn, sql)?;
let results: Vec<Value> = statements
.iter()
.enumerate()
.map(|(index, statement)| {
run_statement(&conn, index + 1, statement, limit)
.unwrap_or_else(|e| json!({ "statement": index + 1, "error": e.to_string() }))
})
.collect();
Ok(json!({ "results": results, "keep": KEEP }))
}
pub(crate) fn failed_statements(answer: &Value) -> Vec<u64> {
answer["results"]
.as_array()
.into_iter()
.flatten()
.filter(|r| r.get("error").is_some())
.filter_map(|r| r["statement"].as_u64())
.collect()
}
fn gate(conn: &Connection, sql: &str) -> Result<Vec<String>> {
let serialised: String = conn
.query_row("SELECT json_serialize_sql(?::VARCHAR)", [sql], |r| r.get(0))
.map_err(db_error)?;
let parsed: Value = serde_json::from_str(&serialised)
.map_err(|e| refusal(format!("database: json_serialize_sql gave no JSON: {e}")))?;
let refuse = |why: String| refusal(format!("{GATE_REFUSAL}: {why}"));
if parsed["error"] != Value::Bool(false) {
let message = parsed["error_message"]
.as_str()
.unwrap_or("DuckDB could not read the text");
if message.contains("Only SELECT statements can be serialized") {
return Err(refuse("a statement is not a SELECT".to_string()));
}
return Err(refuse(message.to_string()));
}
let statements = parsed["statements"].as_array().cloned().unwrap_or_default();
if statements.is_empty() {
return Err(refuse("the text holds no SQL statement".to_string()));
}
for (index, statement) in statements.iter().enumerate() {
let node = statement["node"]["type"].as_str().unwrap_or("");
if !SELECT_NODES.contains(&node) {
return Err(refuse(format!(
"statement {} is not a SELECT (DuckDB reads it as {node})",
index + 1
)));
}
}
statements
.into_iter()
.map(|statement| {
let one = json!({ "error": false, "statements": [statement] }).to_string();
conn.query_row("SELECT json_deserialize_sql(?::JSON)", [one], |r| {
r.get::<_, String>(0)
})
.map_err(db_error)
})
.collect()
}
fn run_statement(
conn: &Connection,
number: usize,
statement: &str,
limit: usize,
) -> std::result::Result<Value, duckdb::Error> {
let mut stmt = conn.prepare(&format!(
"SELECT column_name, column_type FROM (DESCRIBE ({statement}))"
))?;
let described: Vec<(String, String)> = stmt
.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?
.collect::<std::result::Result<_, _>>()?;
let renamed: Vec<String> = (0..described.len()).map(|i| format!("c{i}")).collect();
let values: Vec<String> = described
.iter()
.zip(&renamed)
.map(|((_, ty), column)| json_value_sql(&format!("q.{column}"), ty))
.collect();
let wrapped = format!("({statement}) AS q({})", renamed.join(", "));
let fetch = limit.saturating_add(1);
let mut rows = json_rows(
conn,
&format!("SELECT {} FROM {wrapped} LIMIT {fetch}", values.join(", ")),
described.len(),
)?;
let capped = rows.len() > limit;
let held_back = if capped {
rows.truncate(limit);
let total: i64 =
conn.query_row(&format!("SELECT count(*) FROM {wrapped}"), [], |r| r.get(0))?;
json!((total - limit as i64).max(0))
} else {
Value::Null
};
let columns: Vec<Value> = described
.iter()
.map(|(name, ty)| json!({ "name": name, "type": ty }))
.collect();
Ok(json!({
"statement": number,
"columns": columns,
"row_count": rows.len(),
"rows": rows,
"capped": capped,
"rows_held_back": held_back,
}))
}