use std::path::PathBuf;
use async_trait::async_trait;
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},
};
use crate::platform::sekejap;
pub const NODE_KIND: &str = "sekejap.query.run";
pub const INPUT_PIN_IN: &str = "in";
pub const OUTPUT_PIN_OUT: &str = "out";
pub const CODE: &str = "FW_NODE_SEKEJAP_QUERY_RUN";
pub const CONFIG_CODE: &str = "FW_NODE_SEKEJAP_QUERY_RUN_CONFIG";
const PARAM_CODE: &str = "FW_NODE_SEKEJAP_QUERY_RUN_PARAM";
const LIMIT_CODE: &str = "FW_NODE_SEKEJAP_QUERY_RUN_LIMIT";
pub const WRITE_CODE: &str = "FW_NODE_SEKEJAP_QUERY_RUN_WRITE";
pub fn definition() -> NodeDefinition {
let answer = |columns: &[&str], rows: Value, affected: Option<u64>| {
let n = rows.as_array().map_or(0, Vec::len);
let mut query = json!({ "rows": rows, "columns": columns, "row_count": n, "truncated": false });
if let Some(affected) = affected {
query["rows_affected"] = json!(affected);
}
json!({ "query": query })
};
NodeDefinition {
kind: NODE_KIND.to_string(),
capabilities: vec![NodeCapability::Database, NodeCapability::Process],
title: "Sekejap Query".to_string(),
description:
"Run SQL on the project's built-in database (connection `default-multimodel`; no credential, no setup). SQL goes in the body \
after `--` (or `--query`), values bind as `$1, $2, …` from `--param 1=… --param 2=…` — a literal is text, a whole `{{ expr }}` \
keeps its type. Reads only unless `--write` is set: a write without it is refused. Adds `query: { rows, columns, row_count, \
truncated }` with each row an object keyed by column (`input.query.rows[0].title`), plus `rows_affected` with `--write`; an \
`INSERT … RETURNING _key` answers the rows it wrote. `--limit` caps the rows (default 200, at most 5000). Every row has a \
`_key`: name it in the INSERT, or declare `_key TEXT PRIMARY KEY DEFAULT ulid()` and read the minted key back with \
`RETURNING _key`. A plain INSERT never overwrites (a taken key is 23505); upsert with `ON CONFLICT (_key) DO UPDATE SET c = \
EXCLUDED.c`. Sekejap's SQL is PostgreSQL's outside graphs: `LIKE`/`ILIKE`, `UNIQUE`, `REFERENCES`, `DEFAULT`, `NOT NULL`; \
relations between rows are graph walks, not JOINs — `GRAPH_TABLE (g MATCH (a)-[e:knows]->(b) RETURN b.name AS name)`; pages \
continue after the last row (`WHERE (name, _key) > ($1, $2) ORDER BY name, _key LIMIT 20`), there is no OFFSET. See help topic \
`db/sekejap`. An unknown table or column fails at run time, not at register."
.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("Sekejap SQL"),
query::param_flag("`1=…` binds $1, `2=…` binds $2"),
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 _key, title FROM posts WHERE slug = $1\nINSERT INTO posts (title) VALUES ($1) RETURNING _key — with _key DEFAULT ulid() and Write ticked"
.to_string(),
),
default_value: Some(json!("SELECT *\nFROM items\nLIMIT 20")),
..Default::default()
}];
fields.extend(query::fields("1 binds $1, 2 binds $2"));
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: "sekejap_query".to_string(),
tool_description:
"Execute read-only SQL against the project's embedded Sekejap store. Args: query (required), limit (optional)."
.to_string(),
tool_input_schema: json!({
"type": "object",
"properties": {
"query": { "type": "string", "description": "Sekejap SQL query" },
"limit": { "type": "integer", "description": "Maximum rows to return" }
},
"required": ["query"]
}),
},
examples: vec![
NodeExample::dsl("Read with a bound value", r#"sekejap.query.run --param "1={{ $trigger.params.slug }}" -- "SELECT _key, title, body FROM posts WHERE slug = $1""#)
.output(answer(&["_key", "title", "body"], json!([{ "_key": "hello-world", "title": "Hello", "body": "…" }]), None)),
NodeExample::dsl("Insert from a form, learning the new key", r#"sekejap.query.run --write --param "1={{ $trigger.body.title }}" -- "INSERT INTO posts (title) VALUES ($1) RETURNING _key""#)
.output(answer(&["_key"], json!([{ "_key": "01M3JSKZFJ4AZGVR0PG6ZXMDH5" }]), Some(1)))
.note("The table declares `_key TEXT PRIMARY KEY DEFAULT ulid()`, so the INSERT leaves the key out and `RETURNING` hands it back — `input.query.rows[0]._key` for a redirect."),
NodeExample::dsl("Upsert", r#"sekejap.query.run --write --param "1={{ $trigger.body.slug }}" --param "2={{ $trigger.body.title }}" -- "INSERT INTO posts (_key, title) VALUES ($1, $2) ON CONFLICT (_key) DO UPDATE SET title = EXCLUDED.title""#)
.output(answer(&[], json!([]), Some(1)))
.note("A plain INSERT of a key that exists is refused with 23505; ON CONFLICT is the upsert."),
NodeExample::dsl("Create a table", r#"sekejap.query.run --write -- "CREATE TABLE posts (_key TEXT PRIMARY KEY DEFAULT ulid(), title TEXT NOT NULL, slug TEXT UNIQUE, status TEXT DEFAULT 'draft', created_at TIMESTAMPTZ DEFAULT now()) WITH (fulltext: [title])""#)
.note("Run once from `pipeline_run` or a `jobs/migrate` function pipeline; keep the SQL in `db/001_posts.sql`. Scalar columns are indexed automatically; `WITH` declares full-text, spatial and vector indexes."),
NodeExample::dsl("Search by part of a name", r#"sekejap.query.run --param "1={{ '%' + $trigger.query.q + '%' }}" -- "SELECT _key, name FROM members WHERE name ILIKE $1 ORDER BY name LIMIT 20""#)
.output(answer(&["_key", "name"], json!([{ "_key": "m1", "name": "Alex Doe" }]), None)),
NodeExample::dsl("Newest first, one page at a time", r#"sekejap.query.run --param "1={{ $trigger.query.after || '~' }}" -- "SELECT _key, title FROM posts WHERE _key < $1 ORDER BY _key DESC LIMIT 20""#)
.output(answer(&["_key", "title"], json!([{ "_key": "01M3JSKZFM6MM3GJFF0WKN71DD", "title": "Latest" }]), None))
.note("ULID keys sort by time, so `ORDER BY _key DESC` is newest first. The next page passes the last `_key` shown as `after`; there is no OFFSET."),
NodeExample::dsl("Add an edge", r#"sekejap.query.run --write --param "1={{ $trigger.body.member }}" --param "2={{ $trigger.body.institution }}" --param "3={{ $trigger.body.position }}" -- "INSERT INTO affiliated_with (member_id, institution_id, position) VALUES ($1, $2, $3)""#)
.output(answer(&[], json!([]), Some(1)))
.note("`affiliated_with` is an edge table: a table with two REFERENCES columns declared in `CREATE PROPERTY GRAPH … EDGE TABLES`. UPDATE and DELETE name an end: `DELETE FROM affiliated_with WHERE member_id = $1 AND institution_id = $2`."),
NodeExample::dsl("Walk the graph", r#"sekejap.query.run --param "1={{ $trigger.params.id }}" -- "SELECT name, position FROM GRAPH_TABLE (network MATCH (m WHERE m._key = $1)-[a:affiliated_with]->(i) RETURN i.name AS name, a.position AS position)""#)
.output(answer(&["name", "position"], json!([{ "name": "University One", "position": "Lecturer" }]), None))
.note("Name the edge (`[a:affiliated_with]`) to read its properties. Each matching path is one row; `RETURN DISTINCT` returns a node once."),
],
..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 — use: -- \"SELECT ...\""));
}
let params = match query::params(&self.config.param, false, PARAM_CODE)? {
Params::Positional(values) => values,
Params::Named(_) => return Err(PipelineError::new(PARAM_CODE, "sekejap binds positions only: --param 1=…")),
};
let limit = query::limit(&self.config.limit, LIMIT_CODE)?;
let write = self.config.write;
let data_root = self.data_root.clone();
let owner = owner.to_string();
let project = project.to_string();
let statement = sql.clone();
let result = tokio::task::spawn_blocking(move || {
sekejap::execute_sql(&data_root, &owner, &project, &statement, ¶ms, limit, !write)
})
.await
.map_err(|err| PipelineError::new(CODE, format!("sekejap query task failed: {err}")))?
.map_err(|err| {
if err.code == sekejap::READ_ONLY_CODE {
PipelineError::new(WRITE_CODE, query::write_refusal(NODE_KIND, &err.message))
} else {
PipelineError::new(CODE, with_ddl_hint(&sql, err.to_string()))
}
})?;
let rows = rows_as_objects(&result.columns, &result.rows);
let columns = result.columns.iter().map(|column| column.name.clone()).collect();
let affected = write.then(|| result.affected_rows.unwrap_or(0));
Ok(NodeExecutionOutput {
output_pins: vec![OUTPUT_PIN_OUT.to_string()],
payload: with_answer(&input.payload, query::answer(columns, rows, result.truncated, affected)),
trace: vec![
format!("node_kind={NODE_KIND}"),
format!("row_count={}", result.row_count),
],
})
}
}
fn with_ddl_hint(query: &str, message: String) -> String {
let upper = query.trim_start().to_ascii_uppercase();
if !upper.starts_with("CREATE TABLE") {
return message;
}
format!(
"{message} — Sekejap DDL: `CREATE TABLE t (_key TEXT PRIMARY KEY DEFAULT ulid(), name TEXT NOT NULL, email TEXT UNIQUE, \
status TEXT DEFAULT 'draft', owner_id TEXT REFERENCES users, embedding VECTOR(384)) WITH (fulltext: [name], vector: [embedding])`; \
a VECTOR needs its dimension and CHECK is not supported; an edge table is declared with CREATE PROPERTY GRAPH (help topic db/sekejap)"
)
}
fn rows_as_objects(
columns: &[crate::platform::model::DbQueryColumn],
rows: &[Vec<Value>],
) -> Vec<Value> {
rows.iter()
.map(|row| {
let mut object = serde_json::Map::with_capacity(columns.len());
for (index, column) in columns.iter().enumerate() {
object.insert(
column.name.clone(),
row.get(index).cloned().unwrap_or(Value::Null),
);
}
Value::Object(object)
})
.collect()
}
#[cfg(test)]
mod row_shape_tests {
use super::{rows_as_objects, with_ddl_hint};
use crate::platform::model::DbQueryColumn;
use serde_json::json;
#[test]
fn a_failed_create_table_points_at_the_grammar() {
let hinted = with_ddl_hint("CREATE TABLE users (age INT CHECK (age > 0))", "parse error".into());
assert!(hinted.contains("db/sekejap"), "{hinted}");
assert!(hinted.contains("VECTOR(384)"), "{hinted}");
assert_eq!(with_ddl_hint("SELECT 1", "parse error".into()), "parse error");
}
#[test]
fn rows_are_objects_keyed_by_column_name() {
let columns = vec![
DbQueryColumn { name: "_key".into(), data_type: None },
DbQueryColumn { name: "name".into(), data_type: None },
];
let rows = vec![vec![json!("k1"), json!("Alice")], vec![json!("k2")]];
let out = rows_as_objects(&columns, &rows);
assert_eq!(out[0], json!({ "_key": "k1", "name": "Alice" }));
assert_eq!(out[1], json!({ "_key": "k2", "name": null }));
}
}
#[cfg(test)]
mod node_tests {
use super::*;
use serde_json::json;
async fn exec(root: &std::path::Path, config: Value, payload: Value) -> Result<Value, PipelineError> {
let node = Node::new(serde_json::from_value(config).expect("config"), root.to_path_buf())?;
let out = node
.execute_async(NodeExecutionInput {
node_id: "n0".to_string(),
input_pin: "in".to_string(),
payload,
metadata: json!({ "owner": "demo", "project": "site-a", "pipeline": "t", "request_id": "r" }),
bus: None,
})
.await?;
Ok(out.payload)
}
#[test]
fn the_signature_is_the_shared_query_shape() {
assert_eq!(
crate::pipeline::nodes::node_signature(&definition()),
"sekejap.query.run [--query TEXT] [--param KEY=EXPR…] [--write] [--limit N] → query"
);
}
#[tokio::test]
async fn a_write_needs_write() {
let tmp = tempfile::tempdir().expect("tempdir");
exec(tmp.path(), json!({ "query": "CREATE TABLE posts (_key TEXT PRIMARY KEY, n INT)", "write": true }), json!({}))
.await
.expect("create");
for sql in ["INSERT INTO posts (_key, n) VALUES ('a', 1)", "/* seed */ INSERT INTO posts (_key, n) VALUES ('b', 2)"] {
let err = exec(tmp.path(), json!({ "query": sql }), json!({})).await.unwrap_err();
assert_eq!(err.code, WRITE_CODE, "{sql}: {}", err.message);
assert!(err.message.contains("add --write"), "{}", err.message);
}
let empty = exec(tmp.path(), json!({ "query": "SELECT count(*) AS n FROM posts" }), json!({})).await.unwrap();
assert_eq!(empty["query"]["rows"][0]["n"], 0, "nothing was written");
}
#[tokio::test]
async fn params_bind_by_position_and_the_answer_is_one_key() {
let tmp = tempfile::tempdir().expect("tempdir");
exec(tmp.path(), json!({ "query": "CREATE TABLE posts (_key TEXT PRIMARY KEY, n INT)", "write": true }), json!({}))
.await
.expect("create");
let wrote = exec(
tmp.path(),
json!({ "query": "INSERT INTO posts (_key, n) VALUES ($1, $2) RETURNING _key", "param": { "2": 41, "1": "a" }, "write": true }),
json!({}),
)
.await
.unwrap();
assert_eq!(wrote["query"]["rows"], json!([{ "_key": "a" }]));
assert_eq!(wrote["query"]["rows_affected"], 1);
let read = exec(
tmp.path(),
json!({ "query": "SELECT _key, n FROM posts WHERE n = $1", "param": { "1": 41 } }),
json!({ "webhook": { "path": "/p" } }),
)
.await
.unwrap();
assert_eq!(
read,
json!({ "webhook": { "path": "/p" }, "query": { "rows": [{ "_key": "a", "n": 41 }], "columns": ["_key", "n"], "row_count": 1, "truncated": false } })
);
for param in [json!({ "1": 1, "3": 3 }), json!({ "1": 1, "n": 2 }), json!({ "n": 2 })] {
let err = exec(tmp.path(), json!({ "query": "SELECT $1", "param": param }), json!({})).await.unwrap_err();
assert_eq!(err.code, PARAM_CODE, "{}", err.message);
}
}
}