Files
notion-sql/src/cli.rs
T

722 lines
29 KiB
Rust

// SPDX-License-Identifier: MIT
// Copyright (c) 2026 James Maes
//! 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; this module wires them together and
//! enforces the cross-cutting policies that do not belong to any single layer.
//!
//! # Responsibilities
//! - Define the CLI surface ([`CliArgs`]) and the extended SQL help text
//! (`SQL_HELP`) presented by `--help`.
//! - Parse arguments, construct the [`NotionClient`], and dispatch the requested
//! action ([`run`]).
//! - Execute a parsed [`SqlStatement`] against Notion (`execute`), translating
//! friendly column names to canonical Notion property names only *after* the
//! schema has been fetched.
//! - Enforce the destructive-mutation safety policy
//! ([`guard_applied_full_table_mutation`]).
//! - Emit optional human-readable progress to stderr (`ProgressReporter`).
//!
//! # Key invariant: dry-run by default
//! INSERT, UPDATE, and DELETE always perform the read (query) path so the user
//! can preview the affected rows. The Notion write calls only happen when
//! `--apply` is passed. This keeps preview and apply behavior identical apart
//! from the final mutating call, so a dry-run faithfully predicts an apply.
//!
//! # Why translate filters/sorts after schema lookup
//! SQL identifiers in the user's statement are friendly column names. Notion's
//! query API requires canonical property names (and their types). Filters and
//! sorts therefore cannot be built until the database schema has been retrieved,
//! which is why every branch resolves the database, fetches the schema, and only
//! then translates `WHERE`/`ORDER BY`.
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.
///
/// Derived from `clap`; field-level `#[arg(...)]` attributes define the public
/// CLI contract (flag names, mutual exclusions, and the "required unless
/// `--list-databases`" rule for the positional SQL argument). The doc comment on
/// each field is what `clap` surfaces as that flag's `--help` blurb, so the
/// wording is user-facing, not just internal.
#[derive(Debug, Parser)]
#[command(author, version, about, after_long_help = SQL_HELP)]
pub struct CliArgs {
/// SQL statement to execute.
///
/// Optional only because `--list-databases` is a standalone mode that needs
/// no SQL; `required_unless_present` enforces that exactly one of the two is
/// supplied so the binary always has something to do.
#[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.
///
/// Mutually exclusive with `--csv`; `clap` rejects passing both before this
/// struct is ever constructed.
#[arg(long, conflicts_with = "csv")]
pub json: bool,
/// Print SELECT results as CSV.
///
/// Mutually exclusive with `--json` (see [`CliArgs::json`]).
#[arg(long, conflicts_with = "json")]
pub csv: bool,
/// Show progress for long-running queries and mutations on stderr.
///
/// Progress is written to stderr (not stdout) so it never contaminates the
/// machine-readable result stream produced by `--json`/`--csv`.
#[arg(long)]
pub progress: bool,
/// Allow applied UPDATE or DELETE statements without a WHERE clause.
///
/// The escape hatch for the full-table safety guard; only consulted when
/// `--apply` is also set (see [`guard_applied_full_table_mutation`]).
#[arg(long)]
pub force_all: bool,
}
/// Extended help text documenting the supported SQL surface.
///
/// Wired into `clap` via `after_long_help` so it appears only under
/// `--help` (the long form), keeping the short `-h` output terse. Kept as a
/// hand-maintained string because the supported SQL grammar is narrower than
/// what `sqlparser` accepts and must be described in user terms.
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.
///
/// `--json` and `--csv` are guaranteed mutually exclusive by `clap`, so the
/// precedence here (json, then csv, then the table default) only matters for
/// readability; both cannot be set at once. Returns [`OutputFormat::Table`]
/// when neither flag is present.
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.
///
/// This is the crate's top-level entrypoint, intended to be called from `main`.
/// It handles the `--list-databases` mode inline (it is a self-contained action
/// that needs no SQL) and otherwise parses the SQL statement and hands off to
/// `execute`.
///
/// # Errors
/// Returns an error if `NOTION_TOKEN` is unset, if the Notion client cannot be
/// constructed, if listing databases fails, if no SQL was supplied outside
/// `--list-databases` mode, if the SQL fails to parse, or if `execute`
/// propagates a failure.
pub fn run() -> Result<()> {
let args = CliArgs::parse();
// The integration token is read from the environment rather than a flag so
// it never lands in shell history or process listings.
let token = env::var("NOTION_TOKEN").context("NOTION_TOKEN is required")?;
let mut client = NotionClient::new(token)?;
// `--list-databases` is a standalone mode: short-circuit before requiring SQL.
if args.list_databases {
let databases = client.list_databases()?;
println!("{}", render_databases(&databases, args.output_format())?);
return Ok(());
}
// `clap`'s `required_unless_present` already guarantees `sql` is set here;
// the `context` is defense-in-depth in case that invariant ever changes.
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.
///
/// Dispatches on the statement variant. For every variant the database is
/// resolved and its schema fetched first, because filter/sort/value translation
/// all depend on canonical property names and types. Mutating variants (INSERT,
/// UPDATE, DELETE) always perform the read path so the affected rows can be
/// previewed; the actual Notion writes are gated behind `apply`.
///
/// # Parameters
/// - `statement`: the parsed SQL to run.
/// - `client`: the Notion client; taken by `&mut` because resolution and schema
/// lookups may populate internal caches.
/// - `apply`: when `true`, mutations are written to Notion; when `false`, they
/// are previewed only (dry-run).
/// - `force_all`: opt-in that lets an applied UPDATE/DELETE run without a
/// `WHERE` clause; see [`guard_applied_full_table_mutation`].
/// - `progress_enabled`: when `true`, progress lines are emitted to stderr.
/// - `output_format`: how SELECT/COUNT results are rendered.
///
/// # Errors
/// Returns an error if the full-table guard rejects the statement, if the
/// database cannot be resolved, if schema retrieval fails, if `WHERE`/`ORDER BY`
/// translation or value coercion fails, if the Notion query or any mutation call
/// fails, or if rendering the result fails.
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 {
// COUNT(*) only needs the row tally, not the row contents.
SelectColumns::Count => {
println!("{}", render_count(rows.len(), output_format)?);
}
// `*` or an explicit projection: resolve the column list, then render.
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())?;
// Notion has no bulk-delete API, so each matched page is trashed
// individually. "Trash" (not hard-delete) mirrors Notion semantics:
// pages move to the trash and remain recoverable.
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())?;
}
// Always print the plan, whether applied or dry-run, so the user sees
// exactly which rows were (or would be) affected.
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()?;
// Build the shared property payload once; it is identical for every
// matched row and is cloned per page below.
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)?;
// INSERT has no `WHERE`, so there is no full-table guard here; the
// user explicitly enumerated the rows to create.
let payloads = build_insert_payloads(&columns, &rows, &schema)?;
// One create_page call per VALUES row; Notion has no bulk insert.
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.
///
/// Guards against the classic "forgot the `WHERE`" footgun: an applied UPDATE or
/// DELETE that would touch every row in a database. The guard only fires for
/// *applied* mutations (`apply == true`); dry-runs are always allowed because
/// they make no changes, and a filtered statement is always allowed because it
/// is scoped. Made `pub` so it can be exercised directly in tests.
///
/// # Parameters
/// - `statement`: the SQL verb (e.g. `"UPDATE"`/`"DELETE"`), used only in the
/// error message.
/// - `apply`: whether the statement would actually write to Notion.
/// - `force_all`: the user's explicit opt-in to affect every row.
/// - `has_filter`: whether the statement carried a `WHERE` clause.
///
/// # Errors
/// Returns an error when `apply` is set, `force_all` is not, and there is no
/// `WHERE` clause — i.e. an applied, unscoped, non-forced mutation.
pub fn guard_applied_full_table_mutation(
statement: &str,
apply: bool,
force_all: bool,
has_filter: bool,
) -> Result<()> {
// Only an applied, unfiltered, non-forced mutation is dangerous; every other
// combination is either harmless (dry-run), scoped (has filter), or
// intentional (force_all).
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.
///
/// A thin wrapper whose only state is the enabled flag. Every reporting method
/// is a no-op when disabled, so callers can sprinkle progress calls
/// unconditionally without branching on whether `--progress` was passed. All
/// output goes to stderr to keep stdout clean for results.
struct ProgressReporter {
/// Whether progress output is enabled for this run.
///
/// When `false`, every reporting method returns `Ok(())` without writing.
enabled: bool,
}
impl ProgressReporter {
/// Creates a progress reporter that either emits to stderr or stays silent.
///
/// `enabled` is typically the value of the `--progress` flag.
fn new(enabled: bool) -> Self {
Self { enabled }
}
/// Reports the start of a database query.
///
/// `database_id` is the resolved Notion ID; `limit` is the row cap, where
/// `None` means "all matching rows" and changes the wording accordingly.
///
/// # Errors
/// Returns an error if writing to stderr fails (only when enabled).
fn query_started(&mut self, database_id: &str, limit: Option<usize>) -> Result<()> {
if !self.enabled {
return Ok(());
}
// Distinct wording for capped vs. uncapped queries so the user knows
// whether a LIMIT is in effect.
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.
///
/// `rows` is the total matched count.
///
/// # Errors
/// Returns an error if writing to stderr fails (only when enabled).
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.
///
/// Invoked once per paginated API response. `pages` is the running page
/// count and `rows` the running matched-row count.
///
/// # Errors
/// Returns an error if writing to stderr fails (only when enabled).
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.
///
/// `verb` is the present-tense action (e.g. `"update"`, `"trash"`) and
/// `total` the number of rows about to be mutated.
///
/// # Errors
/// Returns an error if writing to stderr fails (only when enabled).
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.
///
/// Emits a `current/total` line identifying the affected page by title and
/// ID so the user can correlate progress with specific rows.
///
/// `verb` is the gerund action (e.g. `"updating"`); `current` is the 1-based
/// position; `total` is the row count; `row` is the page being mutated.
///
/// # Errors
/// Returns an error if writing to stderr fails (only when enabled).
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.
///
/// Separate from [`ProgressReporter::mutation_row`] because inserted rows
/// have no existing page identity (title/ID) to display yet; only the
/// `current`/`total` counters are known.
///
/// # Errors
/// Returns an error if writing to stderr fails (only when enabled).
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.
///
/// `verb` is the past-tense action (e.g. `"updated"`); `total` is the count.
/// Renders as `total/total` to signal completion.
///
/// # Errors
/// Returns an error if writing to stderr fails (only when enabled).
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.
///
/// The shared sink for every reporting method. It locks stderr for the
/// single write and flushes right away so progress appears in real time
/// rather than being buffered until the program exits. The `[progress]`
/// prefix makes these lines easy to distinguish (and filter out).
///
/// # Errors
/// Returns an error if the write or flush to stderr fails.
fn line(&mut self, message: &str) -> Result<()> {
let mut stderr = io::stderr().lock();
writeln!(stderr, "[progress] {message}")?;
// Flush eagerly: stderr is line-buffered or block-buffered depending on
// the target, and we want progress visible while the work is ongoing.
stderr.flush()?;
Ok(())
}
}
/// Resolves `SELECT *` or explicit projection items into canonical Notion property names.
///
/// Returns the ordered list of canonical property names to render.
///
/// # Behavior by variant
/// - [`SelectColumns::All`] (`*`): expands to every schema property, but only if
/// none of them are of an unsupported Notion type. Unsupported types are
/// rejected here (rather than silently dropped) so `*` never produces a
/// misleadingly partial result; the user must list supported columns instead.
/// - [`SelectColumns::Columns`]: resolves each user-supplied name to its
/// canonical property name.
/// - [`SelectColumns::Count`]: returns the single synthetic `"count"` column.
/// Reachable defensively even though the COUNT path in `execute` renders via
/// `render_count` and does not call this function.
///
/// # Errors
/// Returns an error if `SELECT *` encounters unsupported property types, or if
/// any explicitly named column cannot be resolved against the schema.
fn selected_columns(columns: &SelectColumns, schema: &DatabaseSchema) -> Result<Vec<String>> {
match columns {
SelectColumns::All => {
// `*` must be all-or-nothing: refuse rather than quietly omit columns
// whose Notion type the renderer cannot represent.
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())
}
// Explicit columns are resolved through the schema so friendly names map
// to Notion's canonical property names; an unknown name is an error.
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.
///
/// Maps each [`SortSpec`] to the `{ "property", "direction" }` JSON shape the
/// Notion query API expects, resolving the column name and translating the
/// `ascending` boolean to Notion's string direction. Preserves the original sort
/// order so multi-column `ORDER BY` precedence is honored.
///
/// # Errors
/// Returns an error if any sort column cannot be resolved against the schema.
fn build_sorts(sorts: &[SortSpec], schema: &DatabaseSchema) -> Result<Vec<Value>> {
sorts
.iter()
.map(|sort| {
let property = schema.resolve_property(&sort.column)?;
// Notion expects "ascending"/"descending" strings, not a boolean.
Ok(json!({
"property": property.name,
"direction": if sort.ascending { "ascending" } else { "descending" }
}))
})
.collect()
}
/// Converts `UPDATE SET` assignments into a Notion `properties` payload.
///
/// Resolves each assignment's column against the schema and coerces its value to
/// the property's Notion type, yielding the `properties` object expected by the
/// page-update API. A [`BTreeMap`] is used so the resulting JSON has a stable,
/// deterministic key order (helpful for reproducible dry-run plans and tests).
///
/// # Errors
/// Returns an error if any column cannot be resolved or if a value cannot be
/// coerced to its property's type.
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)?;
// Coerce the literal to the property's Notion type before inserting; a
// later duplicate column would overwrite an earlier one in the map.
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.
///
/// Produces one `properties` JSON object per VALUES row, each created by zipping
/// the shared `columns` list with that row's expressions, resolving each column,
/// and coercing each value. As in [`build_assignment_payload`], a [`BTreeMap`]
/// gives deterministic key ordering.
///
/// # Parameters
/// - `columns`: the target column names, shared across all rows.
/// - `rows`: the per-row value expressions parsed by `sqlparser`.
/// - `schema`: the database schema used to resolve columns and coerce values.
///
/// # Errors
/// Returns an error if any row's value count does not match the column count, if
/// a column cannot be resolved, or if a value cannot be coerced to its type.
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()
);
}
// Pair each column with its positional value; `zip` is safe because
// the length check above guarantees equal lengths.
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()
}