|
| 1 | +//! `qn sql …` — run SQL queries and inspect cluster schemas. |
| 2 | +
|
| 3 | +mod render; |
| 4 | + |
| 5 | +use std::io::Read; |
| 6 | +use std::path::PathBuf; |
| 7 | + |
| 8 | +use clap::{ArgGroup, Args as ClapArgs, Subcommand}; |
| 9 | +use comfy_table::Cell; |
| 10 | +use quicknode_sdk::{ChainSchema, QueryParams, QueryResponse}; |
| 11 | +use serde::Serialize; |
| 12 | +use serde_json::Value; |
| 13 | + |
| 14 | +use crate::context::Ctx; |
| 15 | +use crate::errors::CliError; |
| 16 | +use crate::output::{new_table, set_header_bold, write_table, Format, Render}; |
| 17 | +use crate::retry::retrying; |
| 18 | +use render::json_cell; |
| 19 | + |
| 20 | +#[derive(Debug, ClapArgs)] |
| 21 | +pub struct Args { |
| 22 | + #[command(subcommand)] |
| 23 | + pub cmd: SqlCmd, |
| 24 | +} |
| 25 | + |
| 26 | +#[derive(Debug, Subcommand)] |
| 27 | +pub enum SqlCmd { |
| 28 | + /// Run a read-only SQL query against a cluster. |
| 29 | + /// |
| 30 | + /// The query may be passed inline, read from a file with --file, or read |
| 31 | + /// from stdin with `--file -`. Results are capped at 1000 rows per request; |
| 32 | + /// page through larger result sets with LIMIT/OFFSET in the SQL. |
| 33 | + #[command(after_help = "Examples:\n \ |
| 34 | + qn sql query \"SELECT 1\" --cluster-id hyperliquid-core-mainnet\n \ |
| 35 | + qn sql query --file query.sql --cluster-id hyperliquid-core-mainnet\n \ |
| 36 | + cat query.sql | qn sql query --file - --cluster-id hyperliquid-core-mainnet")] |
| 37 | + Query(QueryArgs), |
| 38 | + |
| 39 | + /// Show a cluster's table schema (tables, engines, columns, types). |
| 40 | + Schema(SchemaArgs), |
| 41 | +} |
| 42 | + |
| 43 | +#[derive(Debug, ClapArgs)] |
| 44 | +#[command(group(ArgGroup::new("source").args(["query", "file"]).required(true)))] |
| 45 | +pub struct QueryArgs { |
| 46 | + /// The SQL query to run. Mutually exclusive with --file. |
| 47 | + #[arg(value_name = "SQL")] |
| 48 | + pub query: Option<String>, |
| 49 | + |
| 50 | + /// Read the query from a file, or from stdin when the path is `-`. |
| 51 | + #[arg(long, short = 'f', value_name = "PATH")] |
| 52 | + pub file: Option<PathBuf>, |
| 53 | + |
| 54 | + /// The cluster to query (e.g. hyperliquid-core-mainnet). |
| 55 | + #[arg(long, value_name = "CLUSTER_ID")] |
| 56 | + pub cluster_id: String, |
| 57 | +} |
| 58 | + |
| 59 | +#[derive(Debug, ClapArgs)] |
| 60 | +pub struct SchemaArgs { |
| 61 | + /// The cluster whose schema to show. |
| 62 | + #[arg(value_name = "CLUSTER_ID")] |
| 63 | + pub cluster_id: String, |
| 64 | +} |
| 65 | + |
| 66 | +pub async fn run(args: Args, ctx: Ctx) -> Result<(), CliError> { |
| 67 | + match args.cmd { |
| 68 | + SqlCmd::Query(a) => query(a, ctx).await, |
| 69 | + SqlCmd::Schema(a) => schema(a, ctx).await, |
| 70 | + } |
| 71 | +} |
| 72 | + |
| 73 | +async fn query(a: QueryArgs, ctx: Ctx) -> Result<(), CliError> { |
| 74 | + let sql = resolve_query(a.query, a.file)?; |
| 75 | + let params = QueryParams { |
| 76 | + query: sql, |
| 77 | + cluster_id: a.cluster_id, |
| 78 | + }; |
| 79 | + // A query consumes credits and may be expensive; never retry, a retried |
| 80 | + // query re-runs and re-bills. |
| 81 | + let resp = ctx.sdk.sql.query(¶ms).await?; |
| 82 | + |
| 83 | + // Stats are diagnostics: they go to stderr (suppressed by --quiet) so stdout |
| 84 | + // stays clean for piping. JSON/YAML/TOON already carry the full response, so |
| 85 | + // only emit the note for the human-facing table/markdown formats. |
| 86 | + if matches!(ctx.out.format, Format::Table | Format::Md) { |
| 87 | + ctx.out.note(&stats_line(&resp)); |
| 88 | + } |
| 89 | + crate::output::emit(&ctx.out, &QueryView(resp)) |
| 90 | +} |
| 91 | + |
| 92 | +async fn schema(a: SchemaArgs, ctx: Ctx) -> Result<(), CliError> { |
| 93 | + let resp = retrying(ctx.global.retries, || ctx.sdk.sql.get_schema(&a.cluster_id)).await?; |
| 94 | + crate::output::emit(&ctx.out, &SchemaView(resp)) |
| 95 | +} |
| 96 | + |
| 97 | +/// Resolves the query text from the inline arg, a file, or stdin (`-`). Exactly |
| 98 | +/// one of `query`/`file` is guaranteed by the clap `ArgGroup`. |
| 99 | +fn resolve_query(query: Option<String>, file: Option<PathBuf>) -> Result<String, CliError> { |
| 100 | + if let Some(q) = query { |
| 101 | + return Ok(q); |
| 102 | + } |
| 103 | + let path = file.expect("clap ArgGroup guarantees one of query/file"); |
| 104 | + if path.as_os_str() == "-" { |
| 105 | + let mut buf = String::new(); |
| 106 | + std::io::stdin() |
| 107 | + .read_to_string(&mut buf) |
| 108 | + .map_err(|e| CliError::Arg(format!("could not read query from stdin: {e}")))?; |
| 109 | + return Ok(buf); |
| 110 | + } |
| 111 | + std::fs::read_to_string(&path).map_err(|e| { |
| 112 | + CliError::Arg(format!( |
| 113 | + "could not read query file '{}': {e}", |
| 114 | + path.display() |
| 115 | + )) |
| 116 | + }) |
| 117 | +} |
| 118 | + |
| 119 | +/// Builds the human-facing stats note, e.g. |
| 120 | +/// `✓ 2 rows · 135 credits · 0.007s` plus a truncation hint when the result set |
| 121 | +/// was capped below the total matched. |
| 122 | +fn stats_line(resp: &QueryResponse) -> String { |
| 123 | + let mut line = format!( |
| 124 | + "✓ {} rows · {} credits · {:.3}s", |
| 125 | + resp.rows, resp.credits, resp.statistics.elapsed |
| 126 | + ); |
| 127 | + if resp.rows_before_limit_at_least > resp.rows { |
| 128 | + line.push_str(&format!( |
| 129 | + " · {} matched (use LIMIT/OFFSET to page)", |
| 130 | + resp.rows_before_limit_at_least |
| 131 | + )); |
| 132 | + } |
| 133 | + line |
| 134 | +} |
| 135 | + |
| 136 | +#[derive(Serialize)] |
| 137 | +struct QueryView(QueryResponse); |
| 138 | + |
| 139 | +impl Render for QueryView { |
| 140 | + fn render_table( |
| 141 | + &self, |
| 142 | + w: &mut dyn std::io::Write, |
| 143 | + ctx: &crate::output::OutputCtx, |
| 144 | + ) -> std::io::Result<()> { |
| 145 | + let mut t = new_table(ctx); |
| 146 | + // Headers come from `meta` (ordered, authoritative) so an empty result |
| 147 | + // set still prints its column headers. |
| 148 | + set_header_bold( |
| 149 | + &mut t, |
| 150 | + ctx, |
| 151 | + self.0.meta.iter().map(|c| c.name.to_uppercase()), |
| 152 | + ); |
| 153 | + for row in &self.0.data { |
| 154 | + let cells = self.0.meta.iter().map(|col| { |
| 155 | + let v = row.get(&col.name).unwrap_or(&Value::Null); |
| 156 | + Cell::new(json_cell(v)) |
| 157 | + }); |
| 158 | + t.add_row(cells); |
| 159 | + } |
| 160 | + write_table(w, &t) |
| 161 | + } |
| 162 | +} |
| 163 | + |
| 164 | +#[derive(Serialize)] |
| 165 | +struct SchemaView(ChainSchema); |
| 166 | + |
| 167 | +impl Render for SchemaView { |
| 168 | + fn render_table( |
| 169 | + &self, |
| 170 | + w: &mut dyn std::io::Write, |
| 171 | + ctx: &crate::output::OutputCtx, |
| 172 | + ) -> std::io::Result<()> { |
| 173 | + let s = &self.0; |
| 174 | + let n = s.tables.len(); |
| 175 | + writeln!( |
| 176 | + w, |
| 177 | + "{} · {} · {} table{}", |
| 178 | + s.chain, |
| 179 | + s.cluster_id, |
| 180 | + n, |
| 181 | + if n == 1 { "" } else { "s" } |
| 182 | + )?; |
| 183 | + for table in &s.tables { |
| 184 | + writeln!(w)?; |
| 185 | + writeln!( |
| 186 | + w, |
| 187 | + "{} ({}, {} rows)", |
| 188 | + table.name, table.engine, table.total_rows |
| 189 | + )?; |
| 190 | + let partition = if table.partition_key.is_empty() { |
| 191 | + "—".to_string() |
| 192 | + } else { |
| 193 | + table.partition_key.clone() |
| 194 | + }; |
| 195 | + let sorting = if table.sorting_key.is_empty() { |
| 196 | + "—".to_string() |
| 197 | + } else { |
| 198 | + table.sorting_key.join(", ") |
| 199 | + }; |
| 200 | + writeln!(w, " partition: {partition}")?; |
| 201 | + writeln!(w, " sorting: {sorting}")?; |
| 202 | + let mut t = new_table(ctx); |
| 203 | + set_header_bold(&mut t, ctx, vec!["COLUMN", "TYPE"]); |
| 204 | + for col in &table.columns { |
| 205 | + t.add_row(vec![Cell::new(&col.name), Cell::new(&col.column_type)]); |
| 206 | + } |
| 207 | + write_table(w, &t)?; |
| 208 | + } |
| 209 | + Ok(()) |
| 210 | + } |
| 211 | +} |
0 commit comments