use std::path::{Path, PathBuf};
use async_trait::async_trait;
use rusqlite::{OpenFlags, types::ValueRef};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::pipeline::model::{LayoutItem, NodeCapability, NodeExample, NodeFieldDef, NodeFieldType};
use crate::pipeline::nodes::shared::query::{self, Params};
use crate::pipeline::nodes::shared::util::{metadata_scope, with_answer};
use crate::pipeline::{
NodeDefinition, PipelineError,
nodes::{NodeExecutionInput, NodeExecutionOutput, NodeHandler},
};
pub const NODE_KIND: &str = "sqlite.query.run";
pub const INPUT_PIN_IN: &str = "in";
pub const OUTPUT_PIN_OUT: &str = "out";
pub const CODE: &str = "FW_NODE_SQLITE_QUERY_RUN";
pub const CONFIG_CODE: &str = "FW_NODE_SQLITE_QUERY_RUN_CONFIG";
const MIGRATE_CODE: &str = "FW_NODE_SQLITE_QUERY_RUN_MIGRATE";
const PARAM_CODE: &str = "FW_NODE_SQLITE_QUERY_RUN_PARAM";
const LIMIT_CODE: &str = "FW_NODE_SQLITE_QUERY_RUN_LIMIT";
pub const WRITE_CODE: &str = "FW_NODE_SQLITE_QUERY_RUN_WRITE";
const PLACEHOLDERS: &str = "`1=…` binds ?1, `name=…` binds :name (or @name, $name)";
pub fn definition() -> NodeDefinition {
NodeDefinition {
kind: NODE_KIND.to_string(),
capabilities: vec![NodeCapability::Database, NodeCapability::Process],
title: "SQLite Query".to_string(),
description: "Run SQL on the project's built-in SQLite database (connection `default`; no credential). SQL in the body after `--` \
(or `--query`); values bind from `--param`: `1=…` binds `?1`, `name=…` binds `:name` — a literal is text, a whole `{{ expr }}` \
keeps its type. The database is opened read-only unless `--write` is set, so an INSERT/UPDATE/DELETE/CREATE without it is \
refused. Adds `query: { rows, columns, row_count, truncated }` (`input.query.rows[0].name`), plus `rows_affected` with `--write`; \
`INSERT … RETURNING id` answers the rows it wrote. `--limit` caps the rows (default 200, at most 5000). Standard SQLite DDL \
and constraints work (unlike Sekejap)."
.to_string(),
input_schema: json!({
"type": "object",
"description": "Input context — values reach the SQL only through --param."
}),
output_schema: query::output_schema(),
input_pins: vec![INPUT_PIN_IN.to_string()],
output_pins: vec![OUTPUT_PIN_OUT.to_string()],
script_available: false,
script_bridge: None,
config_schema: Default::default(),
dsl_flags: vec![
query::query_flag("SQLite"),
query::param_flag(PLACEHOLDERS),
query::write_flag(),
query::limit_flag(),
],
fields: {
let mut fields = vec![NodeFieldDef {
name: "query".to_string(),
label: "Query".to_string(),
field_type: NodeFieldType::CodeEditor,
language: Some("sql".to_string()),
span: Some("full".to_string()),
help: Some(
"SELECT id, title FROM posts WHERE id = ?1\n\
Read-only unless Write is ticked; bind values from Params."
.to_string(),
),
default_value: Some(json!("SELECT id\nFROM items\nLIMIT 20")),
..Default::default()
}];
fields.extend(query::fields("1 binds ?1, a name binds :name"));
fields
},
layout: vec![
LayoutItem::Field("query".to_string()),
LayoutItem::Field("param".to_string()),
LayoutItem::Row { row: vec![LayoutItem::Field("limit".to_string()), LayoutItem::Field("write".to_string())] },
],
ai_tool: crate::pipeline::model::NodeAiToolDefinition {
registered: true,
tool_name: "sqlite_query".to_string(),
tool_description:
"Run a read-only SQL query against the project's embedded SQLite database. \
Arg: query (required) — SQL SELECT string."
.to_string(),
tool_input_schema: json!({
"type": "object",
"properties": {
"query": { "type": "string", "description": "SQL SELECT query" }
},
"required": ["query"]
}),
},
examples: vec![
NodeExample::dsl("Read with a bound value", r#"sqlite.query.run --param "1={{ $trigger.body.email }}" -- "SELECT id, name FROM users WHERE email = ?1""#)
.output(json!({ "query": { "rows": [{ "id": 1, "name": "Ana" }], "columns": ["id", "name"], "row_count": 1, "truncated": false } })),
NodeExample::dsl("Insert from a form", r#"sqlite.query.run --write --param "email={{ $trigger.body.email }}" --param "name={{ $trigger.body.name }}" -- "INSERT INTO users (email, name) VALUES (:email, :name) RETURNING id""#)
.output(json!({ "query": { "rows": [{ "id": 2 }], "columns": ["id"], "row_count": 1, "truncated": false, "rows_affected": 1 } })),
NodeExample::dsl("Create a table", r#"sqlite.query.run --write -- "CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, email TEXT UNIQUE NOT NULL, name TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP)""#)
.output(json!({ "query": { "rows": [], "columns": [], "row_count": 0, "truncated": false, "rows_affected": 0 } })),
],
..Default::default()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Config {
#[serde(default)]
pub query: String,
#[serde(default)]
pub param: Value,
#[serde(default)]
pub write: bool,
#[serde(default)]
pub limit: Value,
}
pub struct Node {
config: Config,
data_root: PathBuf,
}
impl Node {
pub fn new(config: Config, data_root: PathBuf) -> Result<Self, PipelineError> {
if config.query.trim().is_empty() {
return Err(PipelineError::new(CONFIG_CODE, "--query (or the body after --) must not be empty"));
}
Ok(Self { config, data_root })
}
}
#[async_trait]
impl NodeHandler for Node {
fn kind(&self) -> &'static str {
NODE_KIND
}
fn input_pins(&self) -> &'static [&'static str] {
&[INPUT_PIN_IN]
}
fn output_pins(&self) -> &'static [&'static str] {
&[OUTPUT_PIN_OUT]
}
async fn execute_async(
&self,
input: NodeExecutionInput,
) -> Result<NodeExecutionOutput, PipelineError> {
let (owner, project, _pipeline, _request_id) = metadata_scope(&input.metadata)?;
let sql = self.config.query.trim().to_string();
if sql.is_empty() {
return Err(PipelineError::new(CONFIG_CODE, "--query resolved empty"));
}
let params = query::params(&self.config.param, true, PARAM_CODE)?;
let limit = query::limit(&self.config.limit, LIMIT_CODE)?;
let write = self.config.write;
crate::platform::sqlite_schema::ensure_local_db_migrated(&self.data_root, owner, project)
.map_err(|err| PipelineError::new(MIGRATE_CODE, err.message))?;
let db_path = crate::platform::sqlite_schema::local_db_path(&self.data_root, owner, project);
let answer = tokio::task::spawn_blocking(move || run(&db_path, &sql, ¶ms, write, limit))
.await
.map_err(|err| PipelineError::new(CODE, format!("task: {err}")))??;
Ok(NodeExecutionOutput {
output_pins: vec![OUTPUT_PIN_OUT.to_string()],
payload: with_answer(&input.payload, answer),
trace: vec![format!("node_kind={NODE_KIND}")],
})
}
}
pub fn run(db_path: &Path, sql: &str, params: &Params, write: bool, limit: usize) -> Result<Value, PipelineError> {
let conn = open(db_path, write)?;
let mut stmt = conn.prepare(sql).map_err(|err| sqlite_error(&err, "prepare"))?;
bind(&mut stmt, params)?;
let columns: Vec<String> = stmt.column_names().into_iter().map(str::to_string).collect();
let mut rows = Vec::new();
let mut truncated = false;
{
let mut cursor = stmt.raw_query();
while let Some(row) = cursor.next().map_err(|err| sqlite_error(&err, "run"))? {
if rows.len() < limit {
let mut object = serde_json::Map::with_capacity(columns.len());
for (index, name) in columns.iter().enumerate() {
let cell = row.get_ref(index).map_err(|err| sqlite_error(&err, "row"))?;
object.insert(name.clone(), cell_to_json(cell));
}
rows.push(Value::Object(object));
} else {
truncated = true;
if !write {
break;
}
}
}
}
let affected = write.then(|| conn.changes());
Ok(query::answer(columns, rows, truncated, affected))
}
fn open(db_path: &Path, write: bool) -> Result<rusqlite::Connection, PipelineError> {
if let Some(parent) = db_path.parent() {
std::fs::create_dir_all(parent).map_err(|err| PipelineError::new(CODE, format!("open db: {err}")))?;
}
if write || !db_path.exists() {
let conn = rusqlite::Connection::open(db_path).map_err(|err| PipelineError::new(CODE, format!("open db: {err}")))?;
conn.execute_batch("PRAGMA journal_mode=WAL;").map_err(|err| PipelineError::new(CODE, format!("pragma: {err}")))?;
if write {
return Ok(no_attach(conn));
}
}
let flags = OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX;
rusqlite::Connection::open_with_flags(db_path, flags)
.map(no_attach)
.map_err(|err| PipelineError::new(CODE, format!("open db: {err}")))
}
fn no_attach(conn: rusqlite::Connection) -> rusqlite::Connection {
conn.set_limit(rusqlite::limits::Limit::SQLITE_LIMIT_ATTACHED, 0);
conn
}
fn bind(stmt: &mut rusqlite::Statement<'_>, params: &Params) -> Result<(), PipelineError> {
let wanted = stmt.parameter_count();
match params {
Params::Positional(values) => {
if values.len() != wanted {
return Err(PipelineError::new(
PARAM_CODE,
format!("the statement has {wanted} placeholder(s); --param gives {}", values.len()),
));
}
for (index, value) in values.iter().enumerate() {
bind_one(stmt, index + 1, value)?;
}
}
Params::Named(values) => {
if values.len() != wanted {
return Err(PipelineError::new(
PARAM_CODE,
format!("the statement has {wanted} placeholder(s); --param names {}", values.len()),
));
}
for (name, value) in values {
let index = [":", "@", "$"]
.iter()
.find_map(|prefix| stmt.parameter_index(&format!("{prefix}{name}")).ok().flatten())
.ok_or_else(|| PipelineError::new(PARAM_CODE, format!("the statement has no :{name} placeholder")))?;
bind_one(stmt, index, value)?;
}
}
}
Ok(())
}
fn bind_one(stmt: &mut rusqlite::Statement<'_>, index: usize, value: &Value) -> Result<(), PipelineError> {
let bound = match value {
Value::Null => stmt.raw_bind_parameter(index, rusqlite::types::Null),
Value::Bool(b) => stmt.raw_bind_parameter(index, *b),
Value::Number(n) => match (n.as_i64(), n.as_f64()) {
(Some(i), _) => stmt.raw_bind_parameter(index, i),
(None, Some(f)) => stmt.raw_bind_parameter(index, f),
_ => stmt.raw_bind_parameter(index, n.to_string()),
},
Value::String(s) => stmt.raw_bind_parameter(index, s.as_str()),
other => stmt.raw_bind_parameter(index, other.to_string()),
};
bound.map_err(|err| PipelineError::new(PARAM_CODE, format!("bind {index}: {err}")))
}
fn sqlite_error(err: &rusqlite::Error, stage: &str) -> PipelineError {
if let rusqlite::Error::SqliteFailure(code, _) = err {
if code.code == rusqlite::ErrorCode::ReadOnly {
return PipelineError::new(WRITE_CODE, query::write_refusal(NODE_KIND, &err.to_string()));
}
}
PipelineError::new(CODE, format!("{stage}: {err}"))
}
fn cell_to_json(v: ValueRef<'_>) -> Value {
match v {
ValueRef::Null => Value::Null,
ValueRef::Integer(i) => json!(i),
ValueRef::Real(f) => json!(f),
ValueRef::Text(t) => Value::String(String::from_utf8_lossy(t).into_owned()),
ValueRef::Blob(b) => Value::String(hex::encode(b)),
}
}
#[cfg(test)]
mod tests;