From 1b8c0dec09a284e34019dfd0ecaf3d554c72db4a Mon Sep 17 00:00:00 2001 From: Rafa Moreno Date: Fri, 16 Oct 2026 15:34:15 +0200 Subject: [PATCH 1/3] Add native query formatting command --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 28 +++++++ src/cli.rs | 2 + src/client.rs | 14 +++- src/commands/mod.rs | 1 + src/commands/query_format.rs | 58 ++++++++++++++ src/main.rs | 21 ++++- tests/common/mod.rs | 34 +++++++- tests/query_format.rs | 148 +++++++++++++++++++++++++++++++++++ 10 files changed, 303 insertions(+), 7 deletions(-) create mode 100644 src/commands/query_format.rs create mode 100644 tests/query_format.rs diff --git a/Cargo.lock b/Cargo.lock index 2d0ee03..fb37b9d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1502,7 +1502,7 @@ dependencies = [ [[package]] name = "rawtree-cli" -version = "0.6.14" +version = "0.6.15" dependencies = [ "anyhow", "axoupdater", diff --git a/Cargo.toml b/Cargo.toml index ba48682..db300d4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rawtree-cli" -version = "0.6.14" +version = "0.6.15" edition = "2021" repository = "https://github.com/rawtreedb/rawtree-cli" diff --git a/README.md b/README.md index e2e6eb2..1fc679d 100644 --- a/README.md +++ b/README.md @@ -190,6 +190,34 @@ With `--json`, creation returns `{"database":{"name":"analytics"}}`; listing ret `{"databases":[{"name":"analytics","s3_storage":null}]}`. Database output no longer adds organization metadata that is absent from the API response. +### Formatting SQL + +```sh +rtree query-format "select 1" +rtree query-format --sql "select 1; select 2" +cat query.sql | rtree query-format - +rtree query-format < query.sql +rtree query-format --file query.sql > formatted.sql +rtree query-format --file query.sql --json +``` + +Use stdin or `--file` for large SQL documents to avoid shell argument limits. +Plain output is the complete formatted SQL, with its whitespace preserved. +`--json` returns `{ "formatted": "..." }`. SQL arguments and `--file` are mutually +exclusive. `--database`, `--org`, and `--cluster` honor the usual environment and +saved defaults; a database is optional for formatting. + +Formatting uses the native RawTree formatter without executing SQL or adding +database query logs. Multiple statements and literal values are preserved. +Inline `INSERT ... VALUES` data is copied verbatim without completeness or schema +validation. The API accepts a 50 MiB JSON request, allows 100 MiB of formatted +output, and times formatting out after 30 seconds. The CLI allows 120 seconds +for the entire HTTP request, including upload/download. Ctrl-C cancels the CLI +request. Errors produce no partial formatted output. + +Requires deployment of [Platform PR #1468](https://github.com/rawtreedb/rawtree-platform/pull/1468), +which adds `POST /v1/query-format`. + ### Querying ```sh diff --git a/src/cli.rs b/src/cli.rs index d386dec..8509c7a 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -209,6 +209,8 @@ pub enum Command { #[arg(long)] limit: Option, }, + /// Format SQL without executing it + QueryFormat(crate::commands::query_format::QueryFormatArgs), /// Insert data into a table Insert { #[arg(long)] diff --git a/src/client.rs b/src/client.rs index eb41c22..5da9ccc 100644 --- a/src/client.rs +++ b/src/client.rs @@ -6,6 +6,7 @@ use reqwest::blocking::{Client, Response}; use serde::de::DeserializeOwned; use serde_json::Value; use std::io::Write; +use std::time::Duration; const RAWTREE_CLIENT_HEADER: &str = "x-rawtree-client"; const RAWTREE_CLIENT_VALUE: &str = "cli"; @@ -29,8 +30,19 @@ impl ApiClient { } pub fn post(&self, path: &str, body: &Value) -> Result { + self.post_with_timeout(path, body, Duration::from_secs(30)) + } + + pub fn post_with_timeout( + &self, + path: &str, + body: &Value, + timeout: Duration, + ) -> Result { let url = format!("{}{}", self.base_url, path); - let mut req = with_client_header(self.client.post(&url)).json(body); + let mut req = with_client_header(self.client.post(&url)) + .json(body) + .timeout(timeout); if let Some(ref token) = self.token { req = req.bearer_auth(token); } diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 8d63e77..43726fc 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -9,6 +9,7 @@ pub mod open; pub mod organization; pub mod ping; pub mod query; +pub mod query_format; pub mod status; pub mod table; mod table_output; diff --git a/src/commands/query_format.rs b/src/commands/query_format.rs new file mode 100644 index 0000000..6f16261 --- /dev/null +++ b/src/commands/query_format.rs @@ -0,0 +1,58 @@ +use std::io::Write; +use std::path::PathBuf; +use std::time::Duration; + +use anyhow::Result; +use clap::Args; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +use crate::{client::ApiClient, org}; + +#[derive(Args)] +pub struct QueryFormatArgs { + /// Optional database override for authentication scope + #[arg(long)] + pub database: Option, + /// SQL to format. Use "-" or omit SQL to read from piped stdin. + #[arg(value_name = "SQL", conflicts_with_all = ["sql", "file"])] + pub sql_positional: Option, + /// SQL to format. Use "-" to read from stdin. + #[arg(long, conflicts_with = "file")] + pub sql: Option, + /// Read the complete SQL document from a UTF-8 file + #[arg(long, value_name = "PATH")] + pub file: Option, +} + +#[derive(Deserialize, Serialize)] +struct QueryFormatResponse { + formatted: String, +} + +pub fn query_format( + client: &ApiClient, + database: Option<&str>, + organization: Option<&str>, + cluster: Option<&str>, + sql: &str, + json_mode: bool, +) -> Result<()> { + let path = match database { + Some(database) => { + org::database_scoped_path(database, "/query-format", organization, cluster) + } + None => org::scoped_path("/v1/query-format", organization, cluster), + }; + // Allow upload/download time in addition to the server's 30-second formatter timeout. + let response: QueryFormatResponse = + client.post_with_timeout(&path, &json!({ "sql": sql }), Duration::from_secs(120))?; + let mut stdout = std::io::stdout().lock(); + if json_mode { + serde_json::to_writer_pretty(&mut stdout, &response)?; + writeln!(stdout)?; + } else { + stdout.write_all(response.formatted.as_bytes())?; + } + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index 1f8ba85..400d5ce 100644 --- a/src/main.rs +++ b/src/main.rs @@ -134,11 +134,10 @@ fn resolve_effective_org(client: &ApiClient, cli_org: Option) -> Option< fn read_stdin() -> Result { let mut buf = String::new(); io::stdin().read_to_string(&mut buf)?; - let trimmed = buf.trim().to_string(); - if trimmed.is_empty() { + if buf.trim().is_empty() { anyhow::bail!("no SQL provided on stdin"); } - Ok(trimmed) + Ok(buf) } fn resolve_sql(positional: Option, flag: Option) -> Result { @@ -656,6 +655,22 @@ fn run(cli: Cli) -> Result { json, ) } + Command::QueryFormat(args) => { + let database = resolve_optional_database(args.database); + let sql = match args.file { + Some(path) => std::fs::read_to_string(path)?, + None => resolve_sql(args.sql_positional, args.sql)?, + }; + let effective_org = resolve_effective_org(&client, cli_org.clone()); + commands::query_format::query_format( + &client, + database.as_deref(), + effective_org.as_deref(), + effective_cluster.as_deref(), + &sql, + json, + ) + } Command::Insert { database, table, diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 8945c1b..ef1a0e0 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -23,6 +23,26 @@ pub fn run_cli_with_progress( args: &[&str], original: &Value, on_progress: Option, +) -> (Output, Value, Vec) { + run_cli_with_input(responses, args, original, on_progress, None) +} + +#[allow(dead_code)] // Used by the query-format integration test binary. +pub fn run_cli_with_stdin( + responses: &[(String, &str, Value)], + args: &[&str], + original: &Value, + stdin: &str, +) -> (Output, Value, Vec) { + run_cli_with_input(responses, args, original, None, Some(stdin)) +} + +fn run_cli_with_input( + responses: &[(String, &str, Value)], + args: &[&str], + original: &Value, + on_progress: Option, + stdin: Option<&str>, ) -> (Output, Value, Vec) { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); listener.set_nonblocking(true).unwrap(); @@ -108,11 +128,23 @@ pub fn run_cli_with_progress( .env("HOME", home.path()) .args(["--api-url", &url]) .args(args) - .stdin(Stdio::null()) + .stdin(if stdin.is_some() { + Stdio::piped() + } else { + Stdio::null() + }) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .unwrap(); + if let Some(input) = stdin { + child + .stdin + .take() + .unwrap() + .write_all(input.as_bytes()) + .unwrap(); + } let progress = on_progress.map(|progress| { let stderr = child.stderr.take().unwrap(); std::thread::spawn(move || { diff --git a/tests/query_format.rs b/tests/query_format.rs new file mode 100644 index 0000000..4ed701e --- /dev/null +++ b/tests/query_format.rs @@ -0,0 +1,148 @@ +mod common; + +use serde_json::{json, Value}; + +const PATH: &str = "POST /v1/query-format?organization=team&cluster=prod"; + +fn config() -> Value { + json!({"token": "fixture-session", "default_organization": "team", "cluster": "prod"}) +} + +#[test] +fn formats_complete_positional_sql_with_optional_database() { + let sql = " -- café\nselect 'a\\b'; INSERT INTO t VALUES (1), ( \n"; + let formatted = "-- café\nSELECT 'a\\b';\n\nINSERT INTO t VALUES (1), ( \n"; + let (output, saved, bodies) = common::run_cli( + &[(PATH.to_string(), "200 OK", json!({"formatted": formatted}))], + &["query-format", sql], + &config(), + ); + assert!(output.status.success(), "{:?}", output); + assert_eq!(output.stdout, formatted.as_bytes()); + assert_eq!(bodies, vec![json!({"sql": sql})]); + assert_eq!(saved, config()); +} + +#[test] +fn json_output_and_scope_overrides_preserve_the_document() { + let formatted = "SELECT 1;\n\n"; + let (output, _, bodies) = common::run_cli( + &[( + "POST /v1/query-format?database=analytics%20db&organization=team%20alpha&cluster=prod%2Feu".to_string(), + "200 OK", + json!({"formatted": formatted}), + )], + &["query-format", "--sql", "select 1", "--database", "analytics db", "--org", "team alpha", "--cluster", "prod/eu", "--json"], + &config(), + ); + assert!(output.status.success(), "{:?}", output); + assert_eq!( + serde_json::from_slice::(&output.stdout).unwrap(), + json!({"formatted": formatted}) + ); + assert_eq!(bodies, vec![json!({"sql": "select 1"})]); +} + +#[test] +fn preserves_large_sql_from_stdin_and_files_and_complete_output() { + let sql = format!( + " INSERT INTO t VALUES ('{}'), ( \n", + "café\\\"".repeat(200_000) + ); + let formatted = format!( + "INSERT INTO t VALUES ('{}'), ( \n", + "café\\\"".repeat(200_000) + ); + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("query.sql"); + std::fs::write(&path, &sql).unwrap(); + for args in [ + vec!["query-format", "-"], + vec!["query-format"], + vec!["query-format", "--sql", "-"], + vec!["query-format", "--file", path.to_str().unwrap()], + ] { + let responses = [(PATH.to_string(), "200 OK", json!({"formatted": formatted}))]; + let (output, _, bodies) = if args.contains(&"--file") { + common::run_cli(&responses, &args, &config()) + } else { + common::run_cli_with_stdin(&responses, &args, &config(), &sql) + }; + assert!(output.status.success(), "{:?}", output.status); + assert_eq!(output.stdout, formatted.as_bytes()); + assert_eq!(bodies, vec![json!({"sql": sql})]); + } +} + +#[test] +fn uses_saved_database_scope() { + let mut configuration = config(); + configuration["database"] = json!("saved db"); + let (output, _, _) = common::run_cli( + &[( + "POST /v1/query-format?database=saved%20db&organization=team&cluster=prod".to_string(), + "200 OK", + json!({"formatted": "SELECT 1"}), + )], + &["query-format", "select 1"], + &configuration, + ); + assert!(output.status.success()); + assert_eq!(output.stdout, b"SELECT 1"); +} + +#[test] +fn surfaces_api_errors_without_partial_output_or_query_fallback() { + for status in [ + "400 Bad Request", + "401 Unauthorized", + "403 Forbidden", + "408 Request Timeout", + "413 Payload Too Large", + "503 Service Unavailable", + ] { + let (output, _, bodies) = common::run_cli( + &[( + PATH.to_string(), + status, + json!({"message": "Formatting failed", "hint": "Try a smaller selection"}), + )], + &["query-format", "select 1"], + &config(), + ); + assert!(!output.status.success()); + assert!(output.stdout.is_empty()); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!(stderr.contains(&status[..3]), "{stderr}"); + assert!(stderr.contains("Formatting failed"), "{stderr}"); + assert!(stderr.contains("Try a smaller selection"), "{stderr}"); + assert_eq!(bodies.len(), 1); + } +} + +#[test] +fn rejects_invalid_response_and_conflicting_inputs() { + let (output, _, _) = common::run_cli( + &[(PATH.to_string(), "200 OK", json!({"formatted": null}))], + &["query-format", "select 1"], + &config(), + ); + assert!(!output.status.success()); + assert!(output.stdout.is_empty()); + + for args in [ + vec!["query-format", "select 1", "--sql", "select 2"], + vec!["query-format", "select 1", "--file", "query.sql"], + vec!["query-format", "--sql", "select 1", "--file", "query.sql"], + vec![ + "query-format", + "--file", + "/missing/query-format-fixture.sql", + ], + ] { + let (output, _, bodies) = common::run_cli(&[], &args, &config()); + assert!(!output.status.success()); + assert!(output.stdout.is_empty()); + assert!(bodies.is_empty()); + } +} From 59cc2ff3a15c893fa02038261401a4aaf73be448 Mon Sep 17 00:00:00 2001 From: Rafa Moreno Date: Fri, 16 Oct 2026 15:59:43 +0200 Subject: [PATCH 2/3] Rename SQL formatter command to format --- README.md | 15 ++++--- src/cli.rs | 2 +- src/commands/{query_format.rs => format.rs} | 12 ++---- src/commands/mod.rs | 2 +- src/main.rs | 9 ++--- tests/common/mod.rs | 2 +- tests/{query_format.rs => format.rs} | 43 +++++++-------------- 7 files changed, 32 insertions(+), 53 deletions(-) rename src/commands/{query_format.rs => format.rs} (82%) rename tests/{query_format.rs => format.rs} (73%) diff --git a/README.md b/README.md index 1fc679d..200080b 100644 --- a/README.md +++ b/README.md @@ -193,17 +193,16 @@ adds organization metadata that is absent from the API response. ### Formatting SQL ```sh -rtree query-format "select 1" -rtree query-format --sql "select 1; select 2" -cat query.sql | rtree query-format - -rtree query-format < query.sql -rtree query-format --file query.sql > formatted.sql -rtree query-format --file query.sql --json +rtree format "select 1" +rtree format --sql "select 1; select 2" +cat query.sql | rtree format - +rtree format < query.sql > formatted.sql +rtree format --json < query.sql ``` -Use stdin or `--file` for large SQL documents to avoid shell argument limits. +Use stdin redirection or a pipe for large SQL documents to avoid shell argument limits. Plain output is the complete formatted SQL, with its whitespace preserved. -`--json` returns `{ "formatted": "..." }`. SQL arguments and `--file` are mutually +`--json` returns `{ "formatted": "..." }`. Positional SQL and `--sql` are mutually exclusive. `--database`, `--org`, and `--cluster` honor the usual environment and saved defaults; a database is optional for formatting. diff --git a/src/cli.rs b/src/cli.rs index 8509c7a..45b5641 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -210,7 +210,7 @@ pub enum Command { limit: Option, }, /// Format SQL without executing it - QueryFormat(crate::commands::query_format::QueryFormatArgs), + Format(crate::commands::format::FormatArgs), /// Insert data into a table Insert { #[arg(long)] diff --git a/src/commands/query_format.rs b/src/commands/format.rs similarity index 82% rename from src/commands/query_format.rs rename to src/commands/format.rs index 6f16261..f686e38 100644 --- a/src/commands/query_format.rs +++ b/src/commands/format.rs @@ -1,5 +1,4 @@ use std::io::Write; -use std::path::PathBuf; use std::time::Duration; use anyhow::Result; @@ -10,19 +9,16 @@ use serde_json::json; use crate::{client::ApiClient, org}; #[derive(Args)] -pub struct QueryFormatArgs { +pub struct FormatArgs { /// Optional database override for authentication scope #[arg(long)] pub database: Option, /// SQL to format. Use "-" or omit SQL to read from piped stdin. - #[arg(value_name = "SQL", conflicts_with_all = ["sql", "file"])] + #[arg(value_name = "SQL", conflicts_with = "sql")] pub sql_positional: Option, /// SQL to format. Use "-" to read from stdin. - #[arg(long, conflicts_with = "file")] + #[arg(long)] pub sql: Option, - /// Read the complete SQL document from a UTF-8 file - #[arg(long, value_name = "PATH")] - pub file: Option, } #[derive(Deserialize, Serialize)] @@ -30,7 +26,7 @@ struct QueryFormatResponse { formatted: String, } -pub fn query_format( +pub fn format( client: &ApiClient, database: Option<&str>, organization: Option<&str>, diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 43726fc..ac63f50 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -2,6 +2,7 @@ pub mod auth; pub mod cluster; pub mod database; pub mod docs; +pub mod format; pub mod insert; pub mod keys; pub mod logs; @@ -9,7 +10,6 @@ pub mod open; pub mod organization; pub mod ping; pub mod query; -pub mod query_format; pub mod status; pub mod table; mod table_output; diff --git a/src/main.rs b/src/main.rs index 400d5ce..01ea805 100644 --- a/src/main.rs +++ b/src/main.rs @@ -655,14 +655,11 @@ fn run(cli: Cli) -> Result { json, ) } - Command::QueryFormat(args) => { + Command::Format(args) => { let database = resolve_optional_database(args.database); - let sql = match args.file { - Some(path) => std::fs::read_to_string(path)?, - None => resolve_sql(args.sql_positional, args.sql)?, - }; + let sql = resolve_sql(args.sql_positional, args.sql)?; let effective_org = resolve_effective_org(&client, cli_org.clone()); - commands::query_format::query_format( + commands::format::format( &client, database.as_deref(), effective_org.as_deref(), diff --git a/tests/common/mod.rs b/tests/common/mod.rs index ef1a0e0..cc33f22 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -27,7 +27,7 @@ pub fn run_cli_with_progress( run_cli_with_input(responses, args, original, on_progress, None) } -#[allow(dead_code)] // Used by the query-format integration test binary. +#[allow(dead_code)] // Used by the format integration test binary. pub fn run_cli_with_stdin( responses: &[(String, &str, Value)], args: &[&str], diff --git a/tests/query_format.rs b/tests/format.rs similarity index 73% rename from tests/query_format.rs rename to tests/format.rs index 4ed701e..d5cb54c 100644 --- a/tests/query_format.rs +++ b/tests/format.rs @@ -5,7 +5,7 @@ use serde_json::{json, Value}; const PATH: &str = "POST /v1/query-format?organization=team&cluster=prod"; fn config() -> Value { - json!({"token": "fixture-session", "default_organization": "team", "cluster": "prod"}) + json!({"token": "fixture-token", "default_organization": "team", "cluster": "prod"}) } #[test] @@ -14,7 +14,7 @@ fn formats_complete_positional_sql_with_optional_database() { let formatted = "-- café\nSELECT 'a\\b';\n\nINSERT INTO t VALUES (1), ( \n"; let (output, saved, bodies) = common::run_cli( &[(PATH.to_string(), "200 OK", json!({"formatted": formatted}))], - &["query-format", sql], + &["format", sql], &config(), ); assert!(output.status.success(), "{:?}", output); @@ -32,7 +32,7 @@ fn json_output_and_scope_overrides_preserve_the_document() { "200 OK", json!({"formatted": formatted}), )], - &["query-format", "--sql", "select 1", "--database", "analytics db", "--org", "team alpha", "--cluster", "prod/eu", "--json"], + &["format", "--sql", "select 1", "--database", "analytics db", "--org", "team alpha", "--cluster", "prod/eu", "--json"], &config(), ); assert!(output.status.success(), "{:?}", output); @@ -44,7 +44,7 @@ fn json_output_and_scope_overrides_preserve_the_document() { } #[test] -fn preserves_large_sql_from_stdin_and_files_and_complete_output() { +fn preserves_large_sql_from_stdin_and_complete_output() { let sql = format!( " INSERT INTO t VALUES ('{}'), ( \n", "café\\\"".repeat(200_000) @@ -53,21 +53,13 @@ fn preserves_large_sql_from_stdin_and_files_and_complete_output() { "INSERT INTO t VALUES ('{}'), ( \n", "café\\\"".repeat(200_000) ); - let directory = tempfile::tempdir().unwrap(); - let path = directory.path().join("query.sql"); - std::fs::write(&path, &sql).unwrap(); for args in [ - vec!["query-format", "-"], - vec!["query-format"], - vec!["query-format", "--sql", "-"], - vec!["query-format", "--file", path.to_str().unwrap()], + vec!["format", "-"], + vec!["format"], + vec!["format", "--sql", "-"], ] { let responses = [(PATH.to_string(), "200 OK", json!({"formatted": formatted}))]; - let (output, _, bodies) = if args.contains(&"--file") { - common::run_cli(&responses, &args, &config()) - } else { - common::run_cli_with_stdin(&responses, &args, &config(), &sql) - }; + let (output, _, bodies) = common::run_cli_with_stdin(&responses, &args, &config(), &sql); assert!(output.status.success(), "{:?}", output.status); assert_eq!(output.stdout, formatted.as_bytes()); assert_eq!(bodies, vec![json!({"sql": sql})]); @@ -84,7 +76,7 @@ fn uses_saved_database_scope() { "200 OK", json!({"formatted": "SELECT 1"}), )], - &["query-format", "select 1"], + &["format", "select 1"], &configuration, ); assert!(output.status.success()); @@ -107,7 +99,7 @@ fn surfaces_api_errors_without_partial_output_or_query_fallback() { status, json!({"message": "Formatting failed", "hint": "Try a smaller selection"}), )], - &["query-format", "select 1"], + &["format", "select 1"], &config(), ); assert!(!output.status.success()); @@ -121,24 +113,19 @@ fn surfaces_api_errors_without_partial_output_or_query_fallback() { } #[test] -fn rejects_invalid_response_and_conflicting_inputs() { +fn rejects_invalid_response_conflicting_inputs_and_removed_options() { let (output, _, _) = common::run_cli( &[(PATH.to_string(), "200 OK", json!({"formatted": null}))], - &["query-format", "select 1"], + &["format", "select 1"], &config(), ); assert!(!output.status.success()); assert!(output.stdout.is_empty()); for args in [ - vec!["query-format", "select 1", "--sql", "select 2"], - vec!["query-format", "select 1", "--file", "query.sql"], - vec!["query-format", "--sql", "select 1", "--file", "query.sql"], - vec![ - "query-format", - "--file", - "/missing/query-format-fixture.sql", - ], + vec!["format", "select 1", "--sql", "select 2"], + vec!["format", "--file", "query.sql"], + vec!["query-format", "select 1"], ] { let (output, _, bodies) = common::run_cli(&[], &args, &config()); assert!(!output.status.success()); From aadb16ff1b75370f864c2c5bb729507b82197c84 Mon Sep 17 00:00:00 2001 From: Rafa Moreno Date: Fri, 16 Oct 2026 16:56:13 +0200 Subject: [PATCH 3/3] Support native-style SQL input flags for query and format --- README.md | 19 ++++++++- src/cli.rs | 19 ++------- src/commands/format.rs | 2 +- src/main.rs | 2 +- tests/common/mod.rs | 2 +- tests/sql_input.rs | 87 ++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 110 insertions(+), 21 deletions(-) create mode 100644 tests/sql_input.rs diff --git a/README.md b/README.md index 200080b..5a83e10 100644 --- a/README.md +++ b/README.md @@ -194,7 +194,10 @@ adds organization metadata that is absent from the API response. ```sh rtree format "select 1" +rtree format -q "select 1" +rtree format --query "select 1" rtree format --sql "select 1; select 2" +cat query.sql | rtree format cat query.sql | rtree format - rtree format < query.sql > formatted.sql rtree format --json < query.sql @@ -202,8 +205,10 @@ rtree format --json < query.sql Use stdin redirection or a pipe for large SQL documents to avoid shell argument limits. Plain output is the complete formatted SQL, with its whitespace preserved. -`--json` returns `{ "formatted": "..." }`. Positional SQL and `--sql` are mutually -exclusive. `--database`, `--org`, and `--cluster` honor the usual environment and +`--json` returns `{ "formatted": "..." }`. `--sql`, `--query`, and `-q` are aliases; +provide SQL once, either positionally or with one flag. Omit SQL or use `-` to +read from stdin, including `--sql -`, `--query -`, or `-q -`. +`--database`, `--org`, and `--cluster` honor the usual environment and saved defaults; a database is optional for formatting. Formatting uses the native RawTree formatter without executing SQL or adding @@ -223,13 +228,23 @@ which adds `POST /v1/query-format`. # Positional SQL rtree query "SELECT * FROM events LIMIT 10" +# Equivalent named forms +rtree query -q "SELECT * FROM events LIMIT 10" +rtree query --query "SELECT * FROM events LIMIT 10" +rtree query --sql "SELECT * FROM events LIMIT 10" + # SQL from stdin +rtree query < query.sql +cat query.sql | rtree query cat query.sql | rtree query - # JSON output rtree query --json --sql "SELECT * FROM events LIMIT 10" ``` +As with `format`, provide SQL once. Stdin also works with `--sql -`, `--query -`, +or `-q -`. + ### Data ingestion ```sh diff --git a/src/cli.rs b/src/cli.rs index 45b5641..14ea328 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -199,11 +199,11 @@ pub enum Command { Query { #[arg(long)] database: Option, - /// SQL query to execute (positional or --sql). Use "-" to read from stdin. + /// SQL to execute. Use "-" or omit SQL to read from piped stdin. #[arg(value_name = "SQL", conflicts_with = "sql")] sql_positional: Option, - /// SQL query to execute - #[arg(long)] + /// SQL to execute. Use "-" to read from stdin. + #[arg(long, short = 'q', visible_alias = "query")] sql: Option, /// Append LIMIT N to the query #[arg(long)] @@ -1021,19 +1021,6 @@ mod tests { assert!(result.is_err(), "query --format should not be supported"); } - #[test] - fn query_named_query_flag_is_rejected() { - let result = Cli::try_parse_from([ - "rtree", - "query", - "--database", - "analytics", - "--query", - "SELECT 1", - ]); - assert!(result.is_err(), "query --query should not be supported"); - } - #[test] fn key_command_is_singular() { let cli = Cli::try_parse_from(["rtree", "key", "list"]).unwrap(); diff --git a/src/commands/format.rs b/src/commands/format.rs index f686e38..85429ad 100644 --- a/src/commands/format.rs +++ b/src/commands/format.rs @@ -17,7 +17,7 @@ pub struct FormatArgs { #[arg(value_name = "SQL", conflicts_with = "sql")] pub sql_positional: Option, /// SQL to format. Use "-" to read from stdin. - #[arg(long)] + #[arg(long, short = 'q', visible_alias = "query")] pub sql: Option, } diff --git a/src/main.rs b/src/main.rs index 01ea805..6dd6e55 100644 --- a/src/main.rs +++ b/src/main.rs @@ -149,7 +149,7 @@ fn resolve_sql(positional: Option, flag: Option) -> Result Value { + json!({"token": "fixture-token", "database": "analytics", "default_organization": "team", "cluster": "prod"}) +} + +fn response(command: &str) -> (String, &'static str, Value) { + let (endpoint, body) = match command { + "query" => ("query", json!({"data": [{"n": 1}]})), + "format" => ("query-format", json!({"formatted": "SELECT 1\n"})), + _ => unreachable!(), + }; + ( + format!("POST /v1/{endpoint}?database=analytics&organization=team&cluster=prod"), + "200 OK", + body, + ) +} + +#[test] +fn both_commands_accept_positional_sql_and_all_named_forms() { + let sql = " SELECT 'café', 'a\\b';\nSELECT 2;\n"; + for command in ["query", "format"] { + for input in [ + vec![sql], + vec!["--sql", sql], + vec!["--query", sql], + vec!["-q", sql], + ] { + let mut args = vec![command, "--json"]; + args.extend(input); + let response = response(command); + let expected_output = response.2.clone(); + let (output, _, bodies) = common::run_cli(&[response], &args, &config()); + assert!(output.status.success(), "{args:?}: {output:?}"); + assert_eq!(bodies, vec![json!({"sql": sql})]); + assert_eq!( + serde_json::from_slice::(&output.stdout).unwrap(), + expected_output + ); + } + } +} + +#[test] +fn both_commands_read_complete_sql_from_implicit_and_explicit_stdin() { + let sql = " -- Preserve whitespace and Unicode\nSELECT 'café';\n\n"; + for command in ["query", "format"] { + for input in [ + vec![], + vec!["-"], + vec!["--sql", "-"], + vec!["--query", "-"], + vec!["-q", "-"], + ] { + let mut args = vec![command]; + args.extend(input); + let (output, _, bodies) = + common::run_cli_with_stdin(&[response(command)], &args, &config(), sql); + assert!(output.status.success(), "{args:?}: {output:?}"); + assert_eq!(bodies, vec![json!({"sql": sql})]); + } + } +} + +#[test] +fn both_commands_reject_multiple_sql_sources() { + for command in ["query", "format"] { + for input in [ + vec!["SELECT 1", "--sql", "SELECT 2"], + vec!["SELECT 1", "--query", "SELECT 2"], + vec!["SELECT 1", "-q", "SELECT 2"], + vec!["--sql", "SELECT 1", "--query", "SELECT 2"], + vec!["--sql", "SELECT 1", "-q", "SELECT 2"], + vec!["--query", "SELECT 1", "-q", "SELECT 2"], + ] { + let mut args = vec![command]; + args.extend(input); + let (output, _, bodies) = common::run_cli(&[], &args, &config()); + assert_eq!(output.status.code(), Some(2), "{args:?}: {output:?}"); + assert!(output.stdout.is_empty()); + assert!(bodies.is_empty()); + } + } +}