Skip to content

Commit 534ce2f

Browse files
authored
feat(sql): add qn sql for SQL Explorer queries and schema (DX-5876) (#47)
Wraps the new quicknode-sdk 0.5 `sql` sub-client as two verbs: - `qn sql query` runs a read-only query against a cluster. The SQL is passed inline, via `--file <path>`, or from stdin with `--file -` (mutually exclusive, exactly one required). Rows render to stdout (headers from the response `meta`, scalars unquoted, null as `—`, nested values as compact JSON); query stats print to stderr as a note with a truncation hint when the 1000-row cap is hit. Does not auto-retry: a query consumes credits, so a retried query re-bills. - `qn sql schema <CLUSTER_ID>` prints each table as a block (engine, row count, partition/sorting keys, and a COLUMN/TYPE sub-table). Retries like other reads. Bumps quicknode-sdk to 0.5 and routes the SQL base path under the `--base-url` override so integration tests hit the mock, not production. Updates the README and the embedded agent context catalog.
1 parent 858f1d5 commit 534ce2f

13 files changed

Lines changed: 625 additions & 4 deletions

Cargo.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ name = "qn"
2121
path = "src/lib.rs"
2222

2323
[dependencies]
24-
quicknode-sdk = "0.4"
24+
quicknode-sdk = "0.5"
2525
clap = { version = "4", features = ["derive", "env", "wrap_help"] }
2626
clap_complete = "4"
2727
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,24 @@ qn kv list contains allowlist 0xabc
260260
qn kv list get allowlist
261261
```
262262

263+
### SQL
264+
265+
```sh
266+
# Run a query inline, from a file, or from stdin (--file -)
267+
qn sql query "SELECT action_type, user FROM hyperliquid_system_actions ORDER BY block_time DESC LIMIT 3" --cluster-id hyperliquid-core-mainnet
268+
qn sql query --file query.sql --cluster-id hyperliquid-core-mainnet
269+
cat query.sql | qn sql query --file - --cluster-id hyperliquid-core-mainnet
270+
271+
# Pipe rows into jq (stats print to stderr, so stdout stays clean)
272+
qn sql query "SELECT 1" --cluster-id hyperliquid-core-mainnet -o json | jq '.data'
273+
274+
# Inspect a cluster's tables, columns, and types
275+
qn sql schema hyperliquid-core-mainnet
276+
```
277+
278+
Queries are read-only (SELECT) and capped at 1000 rows per request; page through
279+
larger result sets with `LIMIT`/`OFFSET` in the SQL.
280+
263281
### Other
264282

265283
```sh

src/cli.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,9 @@ pub enum Command {
146146
/// Manage the Quicknode KV store (sets and lists).
147147
Kv(commands::kv::Args),
148148

149+
/// Run SQL queries and inspect cluster schemas.
150+
Sql(commands::sql::Args),
151+
149152
/// Generate shell completion scripts.
150153
///
151154
/// When installing qn through a package manager, it's possible that no
@@ -235,6 +238,7 @@ impl Cli {
235238
Command::Stream(args) => commands::stream::run(args, Ctx::from_global(global)?).await,
236239
Command::Webhook(args) => commands::webhook::run(args, Ctx::from_global(global)?).await,
237240
Command::Kv(args) => commands::kv::run(args, Ctx::from_global(global)?).await,
241+
Command::Sql(args) => commands::sql::run(args, Ctx::from_global(global)?).await,
238242
}
239243
}
240244
}

src/commands/agent/context.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,8 @@ if you need it.
9191
`qn endpoint show <id>` reflects whether it took effect.
9292
- `qn stream test-filter` evaluates a filter against historical data and changes
9393
nothing — it is read-only and safe to retry.
94+
- `qn sql query` is read-only but **does not auto-retry**: a query consumes credits,
95+
so a retried query re-bills. `qn sql schema` is a cheap read and retries normally.
9496

9597
## 6. Command catalog
9698

@@ -111,6 +113,7 @@ Top-level nouns (plurals like `endpoints`/`streams` and `ls` are accepted aliase
111113
enabled-count
112114
- `kv``set` (put, get, list, delete, bulk) and `list` (list, get, create, append,
113115
contains, remove-item, update, delete)
116+
- `sql` — query (inline SQL, `--file <path>`, or `--file -` for stdin), schema
114117

115118
Drill into any level with `--help`: `qn endpoint --help`, `qn endpoint security --help`,
116119
`qn endpoint rate-limit --help`. Shell completions: `qn completions <bash|zsh|fish|...>`.

src/commands/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ pub mod chain;
1010
pub mod endpoint;
1111
pub mod kv;
1212
pub mod metrics;
13+
pub mod sql;
1314
pub mod stream;
1415
pub mod team;
1516
pub mod usage;

src/commands/sql/mod.rs

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
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(&params).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+
}

src/commands/sql/render.rs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
//! Rendering helpers shared by `qn sql query` and `qn sql schema`.
2+
3+
use serde_json::Value;
4+
5+
/// Stringifies a JSON value for a table cell.
6+
///
7+
/// Query rows are arbitrary JSON objects, so cell values can be any JSON type.
8+
/// Scalars render bare (strings without quotes); `null` renders as `—` to match
9+
/// the [`opt_cell`](crate::output::opt_cell) convention; arrays and objects fall
10+
/// back to compact JSON.
11+
pub(crate) fn json_cell(v: &Value) -> String {
12+
match v {
13+
Value::Null => "—".to_string(),
14+
Value::Bool(b) => b.to_string(),
15+
Value::Number(n) => n.to_string(),
16+
Value::String(s) => s.clone(),
17+
Value::Array(_) | Value::Object(_) => v.to_string(),
18+
}
19+
}
20+
21+
#[cfg(test)]
22+
#[allow(clippy::unwrap_used)]
23+
mod tests {
24+
use super::*;
25+
use serde_json::json;
26+
27+
#[test]
28+
fn scalars_render_bare() {
29+
assert_eq!(
30+
json_cell(&json!("SystemSpotSendAction")),
31+
"SystemSpotSendAction"
32+
);
33+
assert_eq!(json_cell(&json!(42)), "42");
34+
assert_eq!(json_cell(&json!(true)), "true");
35+
}
36+
37+
#[test]
38+
fn null_renders_as_dash() {
39+
assert_eq!(json_cell(&Value::Null), "—");
40+
}
41+
42+
#[test]
43+
fn nested_renders_as_compact_json() {
44+
assert_eq!(json_cell(&json!(["a", "b"])), r#"["a","b"]"#);
45+
assert_eq!(json_cell(&json!({"k": 1})), r#"{"k":1}"#);
46+
}
47+
}

src/context.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
use std::io::IsTerminal;
66

77
use quicknode_sdk::{
8-
AdminConfig, HttpConfig, KvStoreConfig, QuicknodeSdk, SdkFullConfig, StreamsConfig,
8+
AdminConfig, HttpConfig, KvStoreConfig, QuicknodeSdk, SdkFullConfig, SqlConfig, StreamsConfig,
99
WebhooksConfig,
1010
};
1111

@@ -162,6 +162,9 @@ impl Ctx {
162162
full.kvstore = Some(KvStoreConfig {
163163
base_url: Some(format!("{trimmed}/kv/rest/v1/")),
164164
});
165+
full.sql = Some(SqlConfig {
166+
base_url: Some(format!("{trimmed}/sql/rest/v1/")),
167+
});
165168
}
166169

167170
let sdk = QuicknodeSdk::new(&full)?;
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
source: tests/table_snapshots.rs
3+
expression: out
4+
---
5+
ACTION_TYPE BLOCK USER
6+
SystemSpotSendAction 1234567 0xabc
7+
SystemSendAssetAction 1234566

0 commit comments

Comments
 (0)