diff --git a/Cargo.lock b/Cargo.lock index e0742da..bd42ba3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1508,9 +1508,9 @@ dependencies = [ [[package]] name = "quicknode-sdk" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2493a477a7bbc09fadf8f0eef58d3407b8739465b30b40af5e27e5d37cbf40f1" +checksum = "721404747d5a5ecc4d0d8c655d0ca1c776f7394cc03609db42bf921bebacafae" dependencies = [ "config", "reqwest 0.13.4", diff --git a/Cargo.toml b/Cargo.toml index d237f6e..09462bf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } diff --git a/README.md b/README.md index 409e92d..5418107 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/cli.rs b/src/cli.rs index 6e21cee..4c865a9 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -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 @@ -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, } } } diff --git a/src/commands/agent/context.md b/src/commands/agent/context.md index ea00f80..d8dd1f7 100644 --- a/src/commands/agent/context.md +++ b/src/commands/agent/context.md @@ -91,6 +91,8 @@ if you need it. `qn endpoint show ` 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 @@ -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 `, 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 `. diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 3d6209b..5f2a5aa 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -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; diff --git a/src/commands/sql/mod.rs b/src/commands/sql/mod.rs new file mode 100644 index 0000000..0e63010 --- /dev/null +++ b/src/commands/sql/mod.rs @@ -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, + + /// Read the query from a file, or from stdin when the path is `-`. + #[arg(long, short = 'f', value_name = "PATH")] + pub file: Option, + + /// 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(¶ms).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, file: Option) -> Result { + 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(()) + } +} diff --git a/src/commands/sql/render.rs b/src/commands/sql/render.rs new file mode 100644 index 0000000..64af501 --- /dev/null +++ b/src/commands/sql/render.rs @@ -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}"#); + } +} diff --git a/src/context.rs b/src/context.rs index 378d671..07033b7 100644 --- a/src/context.rs +++ b/src/context.rs @@ -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, }; @@ -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)?; diff --git a/tests/snapshots/table_snapshots__sql_query_table_renders_unquoted_scalars_and_null_dash.snap b/tests/snapshots/table_snapshots__sql_query_table_renders_unquoted_scalars_and_null_dash.snap new file mode 100644 index 0000000..9f0bb1f --- /dev/null +++ b/tests/snapshots/table_snapshots__sql_query_table_renders_unquoted_scalars_and_null_dash.snap @@ -0,0 +1,7 @@ +--- +source: tests/table_snapshots.rs +expression: out +--- +ACTION_TYPE BLOCK USER +SystemSpotSendAction 1234567 0xabc +SystemSendAssetAction 1234566 — diff --git a/tests/snapshots/table_snapshots__sql_schema_table_renders_nested_table_blocks.snap b/tests/snapshots/table_snapshots__sql_schema_table_renders_nested_table_blocks.snap new file mode 100644 index 0000000..1942cfb --- /dev/null +++ b/tests/snapshots/table_snapshots__sql_schema_table_renders_nested_table_blocks.snap @@ -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) diff --git a/tests/sql.rs b/tests/sql.rs new file mode 100644 index 0000000..4d31260 --- /dev/null +++ b/tests/sql.rs @@ -0,0 +1,209 @@ +//! Integration tests for `qn sql …`. + +mod common; + +use common::run_qn; +use serde_json::json; +use std::io::Write; +use wiremock::matchers::{body_partial_json, method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +fn query_body() -> serde_json::Value { + json!({ + "meta": [ + {"name": "time", "type": "DateTime('UTC')"}, + {"name": "action_type", "type": "LowCardinality(String)"} + ], + "data": [ + {"time": "2026-06-24 19:43:44", "action_type": "SystemSpotSendAction"}, + {"time": "2026-06-24 19:43:42", "action_type": "SystemSendAssetAction"} + ], + "rows": 2, + "rows_before_limit_at_least": 18251, + "statistics": {"elapsed": 0.0067, "rows_read": 31341, "bytes_read": 1247178}, + "credits": 135 + }) +} + +#[tokio::test] +async fn query_inline_sends_camel_case_cluster_id() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/sql/rest/v1/query")) + .and(body_partial_json(json!({ + "query": "SELECT 1", + "clusterId": "hyperliquid-core-mainnet" + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(query_body())) + .mount(&server) + .await; + let out = run_qn( + &server.uri(), + &[ + "sql", + "query", + "SELECT 1", + "--cluster-id", + "hyperliquid-core-mainnet", + ], + ) + .await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); +} + +#[tokio::test] +async fn query_reads_from_file() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/sql/rest/v1/query")) + .and(body_partial_json(json!({ "query": "SELECT 42 FROM t" }))) + .respond_with(ResponseTemplate::new(200).set_body_json(query_body())) + .mount(&server) + .await; + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("query.sql"); + let mut f = std::fs::File::create(&path).unwrap(); + write!(f, "SELECT 42 FROM t").unwrap(); + + let out = run_qn( + &server.uri(), + &[ + "sql", + "query", + "--file", + path.to_str().unwrap(), + "--cluster-id", + "c1", + ], + ) + .await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); +} + +#[tokio::test] +async fn query_missing_file_is_arg_error() { + let server = MockServer::start().await; + // No request should reach the server when the file can't be read. + Mock::given(method("POST")) + .and(path("/sql/rest/v1/query")) + .respond_with(ResponseTemplate::new(200).set_body_json(query_body())) + .expect(0) + .mount(&server) + .await; + let out = run_qn( + &server.uri(), + &[ + "sql", + "query", + "--file", + "/no/such/query.sql", + "--cluster-id", + "c1", + ], + ) + .await; + assert_eq!(out.exit_code, 1, "stderr={}", out.stderr); +} + +#[tokio::test] +async fn query_requires_a_source() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/sql/rest/v1/query")) + .respond_with(ResponseTemplate::new(200).set_body_json(query_body())) + .expect(0) + .mount(&server) + .await; + // Neither inline SQL nor --file: clap ArgGroup rejects (parse error, exit 1). + let out = run_qn(&server.uri(), &["sql", "query", "--cluster-id", "c1"]).await; + assert_eq!(out.exit_code, 1, "stderr={}", out.stderr); +} + +#[tokio::test] +async fn query_inline_and_file_conflict() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/sql/rest/v1/query")) + .respond_with(ResponseTemplate::new(200).set_body_json(query_body())) + .expect(0) + .mount(&server) + .await; + // Both inline SQL and --file: clap ArgGroup rejects (parse error, exit 1). + let out = run_qn( + &server.uri(), + &[ + "sql", + "query", + "SELECT 1", + "--file", + "q.sql", + "--cluster-id", + "c1", + ], + ) + .await; + assert_eq!(out.exit_code, 1, "stderr={}", out.stderr); +} + +#[tokio::test] +async fn query_api_error_maps_to_exit_2() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/sql/rest/v1/query")) + .respond_with(ResponseTemplate::new(403).set_body_json( + json!({"statusCode": 403, "message": "only SELECT queries are allowed"}), + )) + .mount(&server) + .await; + let out = run_qn( + &server.uri(), + &["sql", "query", "DELETE FROM t", "--cluster-id", "c1"], + ) + .await; + assert_eq!(out.exit_code, 2, "stderr={}", out.stderr); +} + +#[tokio::test] +async fn schema_happy_path() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/sql/rest/v1/schema/hyperliquid-core-mainnet")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "chain": "Hyperliquid (HyperCore)", + "cluster_id": "hyperliquid-core-mainnet", + "tables": [ + { + "name": "hyperliquid_agents", + "engine": "SharedReplacingMergeTree", + "total_rows": 3322574607i64, + "partition_key": "toYYYYMM(snapshot_time)", + "sorting_key": ["block_number", "agent"], + "columns": [ + {"name": "agent", "type": "FixedString(42)"}, + {"name": "block_number", "type": "UInt64"} + ] + } + ] + }))) + .mount(&server) + .await; + let out = run_qn( + &server.uri(), + &["sql", "schema", "hyperliquid-core-mainnet"], + ) + .await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); +} + +#[tokio::test] +async fn schema_not_found_maps_to_exit_2() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/sql/rest/v1/schema/bad-cluster")) + .respond_with(ResponseTemplate::new(404).set_body_string("Not Found")) + .mount(&server) + .await; + let out = run_qn(&server.uri(), &["sql", "schema", "bad-cluster"]).await; + assert_eq!(out.exit_code, 2, "stderr={}", out.stderr); +} diff --git a/tests/table_snapshots.rs b/tests/table_snapshots.rs index 97d8bcd..815feb8 100644 --- a/tests/table_snapshots.rs +++ b/tests/table_snapshots.rs @@ -363,3 +363,103 @@ async fn endpoint_show_minimal_table_omits_security_and_rate_limit_rows() { let out = table_stdout("/v0/endpoints/ep-1", body, &["endpoint", "show", "ep-1"]).await; insta::assert_snapshot!(out); } + +/// Like [`table_stdout`] but mounts the body at `POST url_path`, for commands +/// that issue a POST (e.g. `sql query`). +async fn table_stdout_post(url_path: &str, body: serde_json::Value, args: &[&str]) -> String { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path(url_path)) + .respond_with(ResponseTemplate::new(200).set_body_json(body)) + .mount(&server) + .await; + + let uri = server.uri(); + let mut argv = vec![ + "--api-key", + "test", + "--base-url", + uri.as_str(), + "--no-input", + "--format", + "table", + ]; + argv.extend(args); + let output = assert_cmd::Command::cargo_bin("qn") + .unwrap() + .env_remove("HOME") + .env("HOME", std::env::temp_dir()) + .args(&argv) + .output() + .unwrap(); + assert!( + output.status.success(), + "stderr={}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).unwrap() +} + +#[tokio::test] +async fn sql_query_table_renders_unquoted_scalars_and_null_dash() { + let body = serde_json::json!({ + "meta": [ + {"name": "action_type", "type": "LowCardinality(String)"}, + {"name": "block", "type": "UInt64"}, + {"name": "user", "type": "Nullable(String)"} + ], + "data": [ + {"action_type": "SystemSpotSendAction", "block": 1234567, "user": "0xabc"}, + {"action_type": "SystemSendAssetAction", "block": 1234566, "user": null} + ], + "rows": 2, + "rows_before_limit_at_least": 2, + "statistics": {"elapsed": 0.0067, "rows_read": 31341, "bytes_read": 1247178}, + "credits": 135 + }); + let out = table_stdout_post( + "/sql/rest/v1/query", + body, + &["sql", "query", "SELECT 1", "--cluster-id", "c1"], + ) + .await; + insta::assert_snapshot!(out); +} + +#[tokio::test] +async fn sql_schema_table_renders_nested_table_blocks() { + let body = serde_json::json!({ + "chain": "Hyperliquid (HyperCore)", + "cluster_id": "hyperliquid-core-mainnet", + "tables": [ + { + "name": "hyperliquid_agents", + "engine": "SharedReplacingMergeTree", + "total_rows": 3322574607i64, + "partition_key": "toYYYYMM(snapshot_time)", + "sorting_key": ["block_number", "agent"], + "columns": [ + {"name": "agent", "type": "FixedString(42)"}, + {"name": "block_number", "type": "UInt64"} + ] + }, + { + "name": "hyperliquid_agents_view", + "engine": "View", + "total_rows": 0, + "partition_key": "", + "sorting_key": [], + "columns": [ + {"name": "agent", "type": "FixedString(42)"} + ] + } + ] + }); + let out = table_stdout( + "/sql/rest/v1/schema/hyperliquid-core-mainnet", + body, + &["sql", "schema", "hyperliquid-core-mainnet"], + ) + .await; + insta::assert_snapshot!(out); +}