Files
notion-sql/src/cli.rs
T
2026-06-02 11:07:56 -05:00

509 lines
17 KiB
Rust

//! Command line entrypoint and statement execution coordinator.
//!
//! This module owns the boundary between parsed SQL, Notion API operations, and
//! console output. Lower-level modules parse statements, translate filters,
//! coerce values, and render results.
use std::collections::BTreeMap;
use std::env;
use std::io::{self, Write};
use anyhow::{bail, Context, Result};
use clap::Parser;
use serde_json::{json, Value};
use crate::filter::translate_where;
use crate::notion::NotionClient;
use crate::output::{
print_delete_plan, print_insert_plan, print_update_plan, render_count, render_databases,
render_select, OutputFormat,
};
use crate::schema::DatabaseSchema;
use crate::sql::{parse_statement, SelectColumns, SortSpec, SqlStatement};
use crate::value::coerce_property_value;
/// Parsed command line flags for the `notion-sql` binary.
#[derive(Debug, Parser)]
#[command(author, version, about, after_long_help = SQL_HELP)]
pub struct CliArgs {
/// SQL statement to execute.
#[arg(required_unless_present = "list_databases")]
pub sql: Option<String>,
/// List Notion databases visible to the integration token.
#[arg(long)]
pub list_databases: bool,
/// Actually execute INSERT, UPDATE, and DELETE. Without this flag they run as dry-runs.
#[arg(long)]
pub apply: bool,
/// Print SELECT results as JSON.
#[arg(long, conflicts_with = "csv")]
pub json: bool,
/// Print SELECT results as CSV.
#[arg(long, conflicts_with = "json")]
pub csv: bool,
/// Show progress for long-running queries and mutations on stderr.
#[arg(long)]
pub progress: bool,
/// Allow applied UPDATE or DELETE statements without a WHERE clause.
#[arg(long)]
pub force_all: bool,
}
/// Extended help text documenting the supported SQL surface.
const SQL_HELP: &str = r#"SQL:
SELECT <cols|*> FROM <db> [WHERE ...] [ORDER BY col [ASC|DESC]] [LIMIT n]
SELECT COUNT(*) FROM <db> [WHERE ...]
INSERT INTO <db> (col1, col2) VALUES (v1, v2), ...
UPDATE <db> SET col=val[, col2=val2 ...] [WHERE ...]
DELETE FROM <db> [WHERE ...]
Database references:
Use a Notion database ID or exact database name.
Quote database names with spaces:
notion-sql "SELECT * FROM \"Task List\" LIMIT 5"
Examples:
notion-sql --list-databases
notion-sql "SELECT Name, Status FROM \"Task List\" WHERE Status='Done'"
notion-sql "SELECT COUNT(*) FROM \"Task List\" WHERE Status='Done'"
notion-sql "INSERT INTO \"Task List\" (Name, Status) VALUES ('New task', 'To Do')"
notion-sql "UPDATE \"Task List\" SET Status='Archived' WHERE Priority='Low'" --apply
notion-sql "DELETE FROM \"Task List\" WHERE Status='Done'" --apply
WHERE support:
=, !=, >, <, >=, <=, LIKE, IN (...), IS NULL, IS NOT NULL
AND/OR grouping with parentheses is supported.
Output:
SELECT, COUNT, and --list-databases default to a table.
Add --json or --csv for machine-readable output.
Safety:
INSERT, UPDATE, and DELETE are dry-run by default.
Add --apply to write changes to Notion.
UPDATE and DELETE require WHERE with --apply unless --force-all is also passed.
Add --progress to show long-running query and mutation progress on stderr."#;
impl CliArgs {
/// Converts the mutually exclusive format flags into the renderer enum.
fn output_format(&self) -> OutputFormat {
if self.json {
OutputFormat::Json
} else if self.csv {
OutputFormat::Csv
} else {
OutputFormat::Table
}
}
}
/// Parses CLI arguments, builds the Notion client, and dispatches the requested action.
pub fn run() -> Result<()> {
let args = CliArgs::parse();
let token = env::var("NOTION_TOKEN").context("NOTION_TOKEN is required")?;
let mut client = NotionClient::new(token)?;
if args.list_databases {
let databases = client.list_databases()?;
println!("{}", render_databases(&databases, args.output_format())?);
return Ok(());
}
let sql = args
.sql
.as_deref()
.context("SQL statement is required unless --list-databases is used")?;
let statement = parse_statement(sql)?;
execute(
statement,
&mut client,
args.apply,
args.force_all,
args.progress,
args.output_format(),
)
}
/// Executes a parsed SQL statement against Notion, preserving dry-run behavior for mutations.
fn execute(
statement: SqlStatement,
client: &mut NotionClient,
apply: bool,
force_all: bool,
progress_enabled: bool,
output_format: OutputFormat,
) -> Result<()> {
let mut progress = ProgressReporter::new(progress_enabled);
match statement {
SqlStatement::Select {
database,
columns,
filter,
sorts,
limit,
} => {
let database_id = client.resolve_database(&database)?;
let schema = client.retrieve_schema(&database_id)?;
// Filters and sorts must be built after schema lookup so friendly
// column names are resolved to Notion's canonical property names.
let filter = filter
.as_ref()
.map(|expr| translate_where(expr, &schema))
.transpose()?;
let sorts = build_sorts(&sorts, &schema)?;
progress.query_started(&database_id, limit)?;
let rows = client.query_database_with_progress(
&database_id,
filter,
sorts,
limit,
|pages, rows| progress.query_page(pages, rows),
)?;
progress.query_finished(rows.len())?;
match columns {
SelectColumns::Count => {
println!("{}", render_count(rows.len(), output_format)?);
}
columns => {
let selected_columns = selected_columns(&columns, &schema)?;
println!(
"{}",
render_select(&rows, &selected_columns, output_format)?
);
}
}
}
SqlStatement::Delete { database, filter } => {
guard_applied_full_table_mutation("DELETE", apply, force_all, filter.is_some())?;
let database_id = client.resolve_database(&database)?;
let schema = client.retrieve_schema(&database_id)?;
let filter = filter
.as_ref()
.map(|expr| translate_where(expr, &schema))
.transpose()?;
progress.query_started(&database_id, None)?;
let rows = client.query_database_with_progress(
&database_id,
filter,
Vec::new(),
None,
|pages, rows| progress.query_page(pages, rows),
)?;
progress.query_finished(rows.len())?;
if apply {
progress.mutation_started("trash", rows.len())?;
for (index, row) in rows.iter().enumerate() {
progress.mutation_row("trashing", index + 1, rows.len(), row)?;
client.trash_page(&row.id)?;
}
progress.mutation_finished("trashed", rows.len())?;
}
print_delete_plan(&rows, apply);
}
SqlStatement::Update {
database,
assignments,
filter,
} => {
guard_applied_full_table_mutation("UPDATE", apply, force_all, filter.is_some())?;
let database_id = client.resolve_database(&database)?;
let schema = client.retrieve_schema(&database_id)?;
let filter = filter
.as_ref()
.map(|expr| translate_where(expr, &schema))
.transpose()?;
let properties = build_assignment_payload(&assignments, &schema)?;
progress.query_started(&database_id, None)?;
let rows = client.query_database_with_progress(
&database_id,
filter,
Vec::new(),
None,
|pages, rows| progress.query_page(pages, rows),
)?;
progress.query_finished(rows.len())?;
// Mutating statements intentionally share the read path with dry-runs
// so users can inspect the affected rows before adding `--apply`.
if apply {
progress.mutation_started("update", rows.len())?;
for (index, row) in rows.iter().enumerate() {
progress.mutation_row("updating", index + 1, rows.len(), row)?;
client.update_page_properties(&row.id, properties.clone())?;
}
progress.mutation_finished("updated", rows.len())?;
}
print_update_plan(&rows, &properties, apply)?;
}
SqlStatement::Insert {
database,
columns,
rows,
} => {
let database_id = client.resolve_database(&database)?;
let schema = client.retrieve_schema(&database_id)?;
let payloads = build_insert_payloads(&columns, &rows, &schema)?;
if apply {
progress.mutation_started("insert", payloads.len())?;
for (index, payload) in payloads.iter().enumerate() {
progress.insert_row(index + 1, payloads.len())?;
client.create_page(&database_id, payload.clone())?;
}
progress.mutation_finished("inserted", payloads.len())?;
}
print_insert_plan(&payloads, apply)?;
}
}
Ok(())
}
/// Rejects applied full-table mutations unless the user explicitly opts in.
fn guard_applied_full_table_mutation(
statement: &str,
apply: bool,
force_all: bool,
has_filter: bool,
) -> Result<()> {
if apply && !force_all && !has_filter {
bail!(
"{statement} with --apply requires a WHERE clause. Add --force-all only if you intend to affect every row."
);
}
Ok(())
}
/// Optional stderr progress renderer for slow Notion operations.
struct ProgressReporter {
/// Whether progress output is enabled for this run.
enabled: bool,
}
impl ProgressReporter {
/// Creates a progress reporter that either emits to stderr or stays silent.
fn new(enabled: bool) -> Self {
Self { enabled }
}
/// Reports the start of a database query.
fn query_started(&mut self, database_id: &str, limit: Option<usize>) -> Result<()> {
if !self.enabled {
return Ok(());
}
match limit {
Some(limit) => self.line(&format!("querying {database_id}, limit {limit} rows")),
None => self.line(&format!("querying {database_id}, all matching rows")),
}
}
/// Reports the final number of rows fetched by a query.
fn query_finished(&mut self, rows: usize) -> Result<()> {
if !self.enabled {
return Ok(());
}
self.line(&format!("query complete, {rows} rows matched"))
}
/// Reports one completed Notion query page fetch.
fn query_page(&mut self, pages: usize, rows: usize) -> Result<()> {
if !self.enabled {
return Ok(());
}
self.line(&format!("fetched {pages} query pages, {rows} rows matched"))
}
/// Reports the start of a row-by-row mutation.
fn mutation_started(&mut self, verb: &str, total: usize) -> Result<()> {
if !self.enabled {
return Ok(());
}
self.line(&format!("starting {verb} for {total} rows"))
}
/// Reports progress for one page mutation.
fn mutation_row(
&mut self,
verb: &str,
current: usize,
total: usize,
row: &crate::notion::PageRow,
) -> Result<()> {
if !self.enabled {
return Ok(());
}
self.line(&format!(
"{verb} {current}/{total}: {} ({})",
row.title, row.id
))
}
/// Reports progress for one inserted row.
fn insert_row(&mut self, current: usize, total: usize) -> Result<()> {
if !self.enabled {
return Ok(());
}
self.line(&format!("inserting {current}/{total}"))
}
/// Reports the end of a row-by-row mutation.
fn mutation_finished(&mut self, verb: &str, total: usize) -> Result<()> {
if !self.enabled {
return Ok(());
}
self.line(&format!("{verb} {total}/{total} rows"))
}
/// Writes one progress line to stderr and flushes immediately.
fn line(&mut self, message: &str) -> Result<()> {
let mut stderr = io::stderr().lock();
writeln!(stderr, "[progress] {message}")?;
stderr.flush()?;
Ok(())
}
}
/// Resolves `SELECT *` or explicit projection items into canonical Notion property names.
fn selected_columns(columns: &SelectColumns, schema: &DatabaseSchema) -> Result<Vec<String>> {
match columns {
SelectColumns::All => {
let unsupported = schema.unsupported_columns();
if !unsupported.is_empty() {
bail!(
"SELECT * includes unsupported Notion property types: {}. Select supported columns explicitly.",
unsupported.join(", ")
);
}
Ok(schema
.properties()
.map(|property| property.name.clone())
.collect())
}
SelectColumns::Columns(columns) => columns
.iter()
.map(|column| Ok(schema.resolve_property(column)?.name.clone()))
.collect(),
SelectColumns::Count => Ok(vec!["count".to_string()]),
}
}
/// Builds Notion sort objects from parsed `ORDER BY` items.
fn build_sorts(sorts: &[SortSpec], schema: &DatabaseSchema) -> Result<Vec<Value>> {
sorts
.iter()
.map(|sort| {
let property = schema.resolve_property(&sort.column)?;
Ok(json!({
"property": property.name,
"direction": if sort.ascending { "ascending" } else { "descending" }
}))
})
.collect()
}
/// Converts `UPDATE SET` assignments into a Notion `properties` payload.
fn build_assignment_payload(
assignments: &[crate::sql::Assignment],
schema: &DatabaseSchema,
) -> Result<Value> {
let mut properties = BTreeMap::new();
for assignment in assignments {
let property = schema.resolve_property(&assignment.column)?;
properties.insert(
property.name.clone(),
coerce_property_value(property, &assignment.value)?,
);
}
Ok(json!(properties))
}
/// Converts all `INSERT ... VALUES` rows into per-page Notion property payloads.
fn build_insert_payloads(
columns: &[String],
rows: &[Vec<sqlparser::ast::Expr>],
schema: &DatabaseSchema,
) -> Result<Vec<Value>> {
rows.iter()
.enumerate()
.map(|(row_index, row)| {
if row.len() != columns.len() {
// `sqlparser` accepts ragged VALUES lists, but Notion needs one
// value per target column when creating page properties.
bail!(
"INSERT row {} has {} values but {} columns were specified",
row_index + 1,
row.len(),
columns.len()
);
}
let mut properties = BTreeMap::new();
for (column, expr) in columns.iter().zip(row) {
let property = schema.resolve_property(column)?;
properties.insert(
property.name.clone(),
coerce_property_value(property, expr)?,
);
}
Ok(json!(properties))
})
.collect()
}
#[cfg(test)]
mod tests {
//! Tests for CLI-level safety checks that do not require Notion HTTP access.
use super::*;
/// Verifies applied full-table updates and deletes require an explicit override.
#[test]
fn rejects_applied_full_table_mutation_without_force_all() {
let error = guard_applied_full_table_mutation("DELETE", true, false, false).unwrap_err();
assert!(error.to_string().contains("requires a WHERE clause"));
}
/// Verifies dry-runs and explicit full-table overrides remain available.
#[test]
fn allows_dry_run_or_forced_full_table_mutation() {
guard_applied_full_table_mutation("DELETE", false, false, false).unwrap();
guard_applied_full_table_mutation("DELETE", true, true, false).unwrap();
guard_applied_full_table_mutation("DELETE", true, false, true).unwrap();
}
/// Verifies wildcard selection rejects unsupported Notion property types.
#[test]
fn rejects_select_all_with_unsupported_columns() {
let schema = DatabaseSchema::from_notion_database(&json!({
"properties": {
"Name": { "type": "title", "title": {} },
"Formula": { "type": "formula", "formula": {} }
}
}))
.unwrap();
let error = selected_columns(&SelectColumns::All, &schema).unwrap_err();
assert!(error.to_string().contains("SELECT * includes unsupported"));
assert!(error.to_string().contains("Formula (formula)"));
}
}