Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"

Expand Down
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,19 +190,61 @@ 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 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
```

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`, `--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
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
# 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
Expand Down
21 changes: 5 additions & 16 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -199,16 +199,18 @@ pub enum Command {
Query {
#[arg(long)]
database: Option<String>,
/// 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<String>,
/// SQL query to execute
#[arg(long)]
/// SQL to execute. Use "-" to read from stdin.
#[arg(long, short = 'q', visible_alias = "query")]
sql: Option<String>,
/// Append LIMIT N to the query
#[arg(long)]
limit: Option<u64>,
},
/// Format SQL without executing it
Format(crate::commands::format::FormatArgs),
/// Insert data into a table
Insert {
#[arg(long)]
Expand Down Expand Up @@ -1019,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();
Expand Down
14 changes: 13 additions & 1 deletion src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -29,8 +30,19 @@ impl ApiClient {
}

pub fn post<T: DeserializeOwned>(&self, path: &str, body: &Value) -> Result<T> {
self.post_with_timeout(path, body, Duration::from_secs(30))
}

pub fn post_with_timeout<T: DeserializeOwned>(
&self,
path: &str,
body: &Value,
timeout: Duration,
) -> Result<T> {
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);
}
Expand Down
54 changes: 54 additions & 0 deletions src/commands/format.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
use std::io::Write;
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 FormatArgs {
/// Optional database override for authentication scope
#[arg(long)]
pub database: Option<String>,
/// SQL to format. Use "-" or omit SQL to read from piped stdin.
#[arg(value_name = "SQL", conflicts_with = "sql")]
pub sql_positional: Option<String>,
/// SQL to format. Use "-" to read from stdin.
#[arg(long, short = 'q', visible_alias = "query")]
pub sql: Option<String>,
}

#[derive(Deserialize, Serialize)]
struct QueryFormatResponse {
formatted: String,
}

pub fn 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(())
}
1 change: 1 addition & 0 deletions src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
20 changes: 16 additions & 4 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,11 +134,10 @@ fn resolve_effective_org(client: &ApiClient, cli_org: Option<String>) -> Option<
fn read_stdin() -> Result<String> {
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<String>, flag: Option<String>) -> Result<String> {
Expand All @@ -150,7 +149,7 @@ fn resolve_sql(positional: Option<String>, flag: Option<String>) -> Result<Strin
if !io::stdin().is_terminal() {
read_stdin()
} else {
anyhow::bail!("SQL query is required. Provide it as a positional argument, with --sql, or pipe via stdin")
anyhow::bail!("SQL query is required. Provide it as a positional argument, with --sql / --query / -q, or pipe via stdin")
}
}
}
Expand Down Expand Up @@ -656,6 +655,19 @@ fn run(cli: Cli) -> Result<ExitCode> {
json,
)
}
Command::Format(args) => {
let database = resolve_optional_database(args.database);
let sql = resolve_sql(args.sql_positional, args.sql)?;
let effective_org = resolve_effective_org(&client, cli_org.clone());
commands::format::format(
&client,
database.as_deref(),
effective_org.as_deref(),
effective_cluster.as_deref(),
&sql,
json,
)
}
Command::Insert {
database,
table,
Expand Down
34 changes: 33 additions & 1 deletion tests/common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,26 @@ pub fn run_cli_with_progress(
args: &[&str],
original: &Value,
on_progress: Option<ProgressExpectation>,
) -> (Output, Value, Vec<Value>) {
run_cli_with_input(responses, args, original, on_progress, None)
}

#[allow(dead_code)] // Used by the SQL input and format integration test binaries.
pub fn run_cli_with_stdin(
responses: &[(String, &str, Value)],
args: &[&str],
original: &Value,
stdin: &str,
) -> (Output, Value, Vec<Value>) {
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<ProgressExpectation>,
stdin: Option<&str>,
) -> (Output, Value, Vec<Value>) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
listener.set_nonblocking(true).unwrap();
Expand Down Expand Up @@ -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 || {
Expand Down
Loading