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
4 changes: 2 additions & 2 deletions 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
Expand Up @@ -21,7 +21,7 @@ name = "qn"
path = "src/lib.rs"

[dependencies]
quicknode-sdk = "0.4"
quicknode-sdk = "0.5"
clap = { version = "4", features = ["derive", "env", "wrap_help"] }
clap_complete = "4"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,24 @@ qn kv list contains allowlist 0xabc
qn kv list get allowlist
```

### SQL

```sh
# Run a query inline, from a file, or from stdin (--file -)
qn sql query "SELECT action_type, user FROM hyperliquid_system_actions ORDER BY block_time DESC LIMIT 3" --cluster-id hyperliquid-core-mainnet
qn sql query --file query.sql --cluster-id hyperliquid-core-mainnet
cat query.sql | qn sql query --file - --cluster-id hyperliquid-core-mainnet

# Pipe rows into jq (stats print to stderr, so stdout stays clean)
qn sql query "SELECT 1" --cluster-id hyperliquid-core-mainnet -o json | jq '.data'

# Inspect a cluster's tables, columns, and types
qn sql schema hyperliquid-core-mainnet
```

Queries are read-only (SELECT) and capped at 1000 rows per request; page through
larger result sets with `LIMIT`/`OFFSET` in the SQL.

### Other

```sh
Expand Down
4 changes: 4 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,9 @@ pub enum Command {
/// Manage the Quicknode KV store (sets and lists).
Kv(commands::kv::Args),

/// Run SQL queries and inspect cluster schemas.
Sql(commands::sql::Args),

/// Generate shell completion scripts.
///
/// When installing qn through a package manager, it's possible that no
Expand Down Expand Up @@ -235,6 +238,7 @@ impl Cli {
Command::Stream(args) => commands::stream::run(args, Ctx::from_global(global)?).await,
Command::Webhook(args) => commands::webhook::run(args, Ctx::from_global(global)?).await,
Command::Kv(args) => commands::kv::run(args, Ctx::from_global(global)?).await,
Command::Sql(args) => commands::sql::run(args, Ctx::from_global(global)?).await,
}
}
}
3 changes: 3 additions & 0 deletions src/commands/agent/context.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ if you need it.
`qn endpoint show <id>` reflects whether it took effect.
- `qn stream test-filter` evaluates a filter against historical data and changes
nothing — it is read-only and safe to retry.
- `qn sql query` is read-only but **does not auto-retry**: a query consumes credits,
so a retried query re-bills. `qn sql schema` is a cheap read and retries normally.

## 6. Command catalog

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

Drill into any level with `--help`: `qn endpoint --help`, `qn endpoint security --help`,
`qn endpoint rate-limit --help`. Shell completions: `qn completions <bash|zsh|fish|...>`.
Expand Down
1 change: 1 addition & 0 deletions src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ pub mod chain;
pub mod endpoint;
pub mod kv;
pub mod metrics;
pub mod sql;
pub mod stream;
pub mod team;
pub mod usage;
Expand Down
211 changes: 211 additions & 0 deletions src/commands/sql/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
//! `qn sql …` — run SQL queries and inspect cluster schemas.

mod render;

use std::io::Read;
use std::path::PathBuf;

use clap::{ArgGroup, Args as ClapArgs, Subcommand};
use comfy_table::Cell;
use quicknode_sdk::{ChainSchema, QueryParams, QueryResponse};
use serde::Serialize;
use serde_json::Value;

use crate::context::Ctx;
use crate::errors::CliError;
use crate::output::{new_table, set_header_bold, write_table, Format, Render};
use crate::retry::retrying;
use render::json_cell;

#[derive(Debug, ClapArgs)]
pub struct Args {
#[command(subcommand)]
pub cmd: SqlCmd,
}

#[derive(Debug, Subcommand)]
pub enum SqlCmd {
/// Run a read-only SQL query against a cluster.
///
/// The query may be passed inline, read from a file with --file, or read
/// from stdin with `--file -`. Results are capped at 1000 rows per request;
/// page through larger result sets with LIMIT/OFFSET in the SQL.
#[command(after_help = "Examples:\n \
qn sql query \"SELECT 1\" --cluster-id hyperliquid-core-mainnet\n \
qn sql query --file query.sql --cluster-id hyperliquid-core-mainnet\n \
cat query.sql | qn sql query --file - --cluster-id hyperliquid-core-mainnet")]
Query(QueryArgs),

/// Show a cluster's table schema (tables, engines, columns, types).
Schema(SchemaArgs),
}

#[derive(Debug, ClapArgs)]
#[command(group(ArgGroup::new("source").args(["query", "file"]).required(true)))]
pub struct QueryArgs {
/// The SQL query to run. Mutually exclusive with --file.
#[arg(value_name = "SQL")]
pub query: Option<String>,

/// Read the query from a file, or from stdin when the path is `-`.
#[arg(long, short = 'f', value_name = "PATH")]
pub file: Option<PathBuf>,

/// The cluster to query (e.g. hyperliquid-core-mainnet).
#[arg(long, value_name = "CLUSTER_ID")]
pub cluster_id: String,
}

#[derive(Debug, ClapArgs)]
pub struct SchemaArgs {
/// The cluster whose schema to show.
#[arg(value_name = "CLUSTER_ID")]
pub cluster_id: String,
}

pub async fn run(args: Args, ctx: Ctx) -> Result<(), CliError> {
match args.cmd {
SqlCmd::Query(a) => query(a, ctx).await,
SqlCmd::Schema(a) => schema(a, ctx).await,
}
}

async fn query(a: QueryArgs, ctx: Ctx) -> Result<(), CliError> {
let sql = resolve_query(a.query, a.file)?;
let params = QueryParams {
query: sql,
cluster_id: a.cluster_id,
};
// A query consumes credits and may be expensive; never retry, a retried
// query re-runs and re-bills.
let resp = ctx.sdk.sql.query(&params).await?;

// Stats are diagnostics: they go to stderr (suppressed by --quiet) so stdout
// stays clean for piping. JSON/YAML/TOON already carry the full response, so
// only emit the note for the human-facing table/markdown formats.
if matches!(ctx.out.format, Format::Table | Format::Md) {
ctx.out.note(&stats_line(&resp));
}
crate::output::emit(&ctx.out, &QueryView(resp))
}

async fn schema(a: SchemaArgs, ctx: Ctx) -> Result<(), CliError> {
let resp = retrying(ctx.global.retries, || ctx.sdk.sql.get_schema(&a.cluster_id)).await?;
crate::output::emit(&ctx.out, &SchemaView(resp))
}

/// Resolves the query text from the inline arg, a file, or stdin (`-`). Exactly
/// one of `query`/`file` is guaranteed by the clap `ArgGroup`.
fn resolve_query(query: Option<String>, file: Option<PathBuf>) -> Result<String, CliError> {
if let Some(q) = query {
return Ok(q);
}
let path = file.expect("clap ArgGroup guarantees one of query/file");
if path.as_os_str() == "-" {
let mut buf = String::new();
std::io::stdin()
.read_to_string(&mut buf)
.map_err(|e| CliError::Arg(format!("could not read query from stdin: {e}")))?;
return Ok(buf);
}
std::fs::read_to_string(&path).map_err(|e| {
CliError::Arg(format!(
"could not read query file '{}': {e}",
path.display()
))
})
}

/// Builds the human-facing stats note, e.g.
/// `✓ 2 rows · 135 credits · 0.007s` plus a truncation hint when the result set
/// was capped below the total matched.
fn stats_line(resp: &QueryResponse) -> String {
let mut line = format!(
"✓ {} rows · {} credits · {:.3}s",
resp.rows, resp.credits, resp.statistics.elapsed
);
if resp.rows_before_limit_at_least > resp.rows {
line.push_str(&format!(
" · {} matched (use LIMIT/OFFSET to page)",
resp.rows_before_limit_at_least
));
}
line
}

#[derive(Serialize)]
struct QueryView(QueryResponse);

impl Render for QueryView {
fn render_table(
&self,
w: &mut dyn std::io::Write,
ctx: &crate::output::OutputCtx,
) -> std::io::Result<()> {
let mut t = new_table(ctx);
// Headers come from `meta` (ordered, authoritative) so an empty result
// set still prints its column headers.
set_header_bold(
&mut t,
ctx,
self.0.meta.iter().map(|c| c.name.to_uppercase()),
);
for row in &self.0.data {
let cells = self.0.meta.iter().map(|col| {
let v = row.get(&col.name).unwrap_or(&Value::Null);
Cell::new(json_cell(v))
});
t.add_row(cells);
}
write_table(w, &t)
}
}

#[derive(Serialize)]
struct SchemaView(ChainSchema);

impl Render for SchemaView {
fn render_table(
&self,
w: &mut dyn std::io::Write,
ctx: &crate::output::OutputCtx,
) -> std::io::Result<()> {
let s = &self.0;
let n = s.tables.len();
writeln!(
w,
"{} · {} · {} table{}",
s.chain,
s.cluster_id,
n,
if n == 1 { "" } else { "s" }
)?;
for table in &s.tables {
writeln!(w)?;
writeln!(
w,
"{} ({}, {} rows)",
table.name, table.engine, table.total_rows
)?;
let partition = if table.partition_key.is_empty() {
"—".to_string()
} else {
table.partition_key.clone()
};
let sorting = if table.sorting_key.is_empty() {
"—".to_string()
} else {
table.sorting_key.join(", ")
};
writeln!(w, " partition: {partition}")?;
writeln!(w, " sorting: {sorting}")?;
let mut t = new_table(ctx);
set_header_bold(&mut t, ctx, vec!["COLUMN", "TYPE"]);
for col in &table.columns {
t.add_row(vec![Cell::new(&col.name), Cell::new(&col.column_type)]);
}
write_table(w, &t)?;
}
Ok(())
}
}
47 changes: 47 additions & 0 deletions src/commands/sql/render.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
//! Rendering helpers shared by `qn sql query` and `qn sql schema`.

use serde_json::Value;

/// Stringifies a JSON value for a table cell.
///
/// Query rows are arbitrary JSON objects, so cell values can be any JSON type.
/// Scalars render bare (strings without quotes); `null` renders as `—` to match
/// the [`opt_cell`](crate::output::opt_cell) convention; arrays and objects fall
/// back to compact JSON.
pub(crate) fn json_cell(v: &Value) -> String {
match v {
Value::Null => "—".to_string(),
Value::Bool(b) => b.to_string(),
Value::Number(n) => n.to_string(),
Value::String(s) => s.clone(),
Value::Array(_) | Value::Object(_) => v.to_string(),
}
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use serde_json::json;

#[test]
fn scalars_render_bare() {
assert_eq!(
json_cell(&json!("SystemSpotSendAction")),
"SystemSpotSendAction"
);
assert_eq!(json_cell(&json!(42)), "42");
assert_eq!(json_cell(&json!(true)), "true");
}

#[test]
fn null_renders_as_dash() {
assert_eq!(json_cell(&Value::Null), "—");
}

#[test]
fn nested_renders_as_compact_json() {
assert_eq!(json_cell(&json!(["a", "b"])), r#"["a","b"]"#);
assert_eq!(json_cell(&json!({"k": 1})), r#"{"k":1}"#);
}
}
5 changes: 4 additions & 1 deletion src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
use std::io::IsTerminal;

use quicknode_sdk::{
AdminConfig, HttpConfig, KvStoreConfig, QuicknodeSdk, SdkFullConfig, StreamsConfig,
AdminConfig, HttpConfig, KvStoreConfig, QuicknodeSdk, SdkFullConfig, SqlConfig, StreamsConfig,
WebhooksConfig,
};

Expand Down Expand Up @@ -162,6 +162,9 @@ impl Ctx {
full.kvstore = Some(KvStoreConfig {
base_url: Some(format!("{trimmed}/kv/rest/v1/")),
});
full.sql = Some(SqlConfig {
base_url: Some(format!("{trimmed}/sql/rest/v1/")),
});
}

let sdk = QuicknodeSdk::new(&full)?;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
source: tests/table_snapshots.rs
expression: out
---
ACTION_TYPE BLOCK USER
SystemSpotSendAction 1234567 0xabc
SystemSendAssetAction 1234566 —
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
source: tests/table_snapshots.rs
expression: out
---
Hyperliquid (HyperCore) · hyperliquid-core-mainnet · 2 tables

hyperliquid_agents (SharedReplacingMergeTree, 3322574607 rows)
partition: toYYYYMM(snapshot_time)
sorting: block_number, agent
COLUMN TYPE
agent FixedString(42)
block_number UInt64

hyperliquid_agents_view (View, 0 rows)
partition: —
sorting: —
COLUMN TYPE
agent FixedString(42)
Loading
Loading