diff --git a/Cargo.toml b/Cargo.toml index c67887f..f01dd22 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,9 @@ needless_pass_by_value = "warn" [workspace.dependencies] pyo3 = { version = "0.27", features = ["experimental-async"] } pyo3-async-runtimes = { version = "0.27", features = ["tokio-runtime"] } +# Tracks pyo3 versions in lockstep; converts dynamic SQL result rows +# (serde_json::Value) to native Python objects. +pythonize = { version = "0.27" } napi = { version = "3", features = ["async", "tokio_rt", "compat-mode"] } napi-derive = { version = "3", features = ["compat-mode"] } pyo3-stub-gen = { version = "0.19.0" } diff --git a/crates/core/README.md b/crates/core/README.md index baa7618..761fd28 100644 --- a/crates/core/README.md +++ b/crates/core/README.md @@ -46,6 +46,7 @@ This is one of four language bindings published from the same Rust core. See the - [KV Store Client](#kv-store-client) - [Sets](#sets) - [Lists](#lists) + - [SQL Client](#sql-client) - [Error Handling](#error-handling) - [License](#license) @@ -55,7 +56,7 @@ This is one of four language bindings published from the same Rust core. See the ## Quick Start -Construct the SDK once, then reach into the four sub-clients (`admin`, `streams`, `webhooks`, `kvstore`). Subsequent API Reference snippets assume you have a `qn` handle from one of these blocks. +Construct the SDK once, then reach into the five sub-clients (`admin`, `streams`, `webhooks`, `kvstore`, `sql`). Subsequent API Reference snippets assume you have a `qn` handle from one of these blocks. ```rust // Rust @@ -99,6 +100,7 @@ Environment variables (prefix `QN_SDK__`, separator `__`): | `QN_SDK__STREAMS__BASE_URL` | no | `https://api.quicknode.com/streams/rest/v1/` | Override streams base URL | | `QN_SDK__WEBHOOKS__BASE_URL` | no | `https://api.quicknode.com/webhooks/rest/v1/` | Override webhooks base URL | | `QN_SDK__KVSTORE__BASE_URL` | no | `https://api.quicknode.com/kv/rest/v1/` | Override KV store base URL | +| `QN_SDK__SQL__BASE_URL` | no | `https://api.quicknode.com/sql/rest/v1/` | Override SQL Explorer base URL | | `QN_SDK__HTTP__HEADERS__` | no | — | Custom HTTP header sent on every request. Overrides SDK-managed headers (see below). | ### Custom headers and `User-Agent` @@ -1689,6 +1691,46 @@ Deletes a list and all of its items. qn.kvstore.delete_list("my-list").await?; ``` +--- + +### SQL Client + +Accessed as `qn.sql`. Runs SQL queries against indexed blockchain data and fetches the database schema. Backed by `https://api.quicknode.com/sql/rest/v1/`. + +##### `query` + +Executes a SQL query against a cluster and returns the result set. Paginate by writing `LIMIT`/`OFFSET` into the SQL. + +**Parameters**: `QueryParams` with `query` (String, required) and `cluster_id` (String, required). + +**Returns**: `QueryResponse` — `meta` (`Vec`, each with `name` and `column_type`), `data` (`Vec`, rows as JSON objects keyed by column name), `rows`, `rows_before_limit_at_least`, `statistics` (`QueryStatistics` with `elapsed`, `rows_read`, `bytes_read`), and `credits`. + +```rust +// Rust +let resp = qn + .sql + .query(&QueryParams { + query: "SELECT action_type, user FROM hyperliquid_system_actions ORDER BY block_time DESC LIMIT 100".to_string(), + cluster_id: "hyperliquid-core-mainnet".to_string(), + }) + .await?; +println!("{} rows, {:?}", resp.rows, resp.data.first()); +``` + +##### `get_schema` + +Fetches the database schema for a cluster: table names, columns, types, sort keys, and partition strategies. + +**Parameters**: `cluster_id` (`&str`, required). + +**Returns**: `ChainSchema` — `chain`, `cluster_id`, and `tables` (`Vec`, each with `name`, `engine`, `total_rows`, `partition_key`, `sorting_key`, and `columns` of `ColumnSchema { name, column_type }`). + +```rust +// Rust +let schema = qn.sql.get_schema("hyperliquid-core-mainnet").await?; +println!("{} tables", schema.tables.len()); +``` + ## Error Handling Every binding exposes a typed exception hierarchy derived from the core `SdkError` diff --git a/crates/core/examples/admin_e2e.rs b/crates/core/examples/admin_e2e.rs index 9b7e6e2..0e804cd 100644 --- a/crates/core/examples/admin_e2e.rs +++ b/crates/core/examples/admin_e2e.rs @@ -873,6 +873,7 @@ async fn main() { streams: None, webhooks: None, kvstore: None, + sql: None, }; let headered = QuicknodeSdk::new(&with_headers).expect("build sdk with custom headers"); match headered diff --git a/crates/core/examples/sql.rs b/crates/core/examples/sql.rs new file mode 100644 index 0000000..ff6cf3b --- /dev/null +++ b/crates/core/examples/sql.rs @@ -0,0 +1,70 @@ +use quicknode_sdk::{errors::SdkError, sql::QueryParams, QuicknodeSdk, SdkFullConfig}; + +const CLUSTER_ID: &str = "hyperliquid-core-mainnet"; + +#[tokio::main] +#[allow(clippy::unwrap_used, clippy::expect_used)] +async fn main() { + let config = SdkFullConfig::from_env().expect("Config from env failed"); + let qn = QuicknodeSdk::new(&config).expect("sdk failed to initialize"); + + // ── Query ───────────────────────────────────────────────────────────────── + + let params = QueryParams { + query: "SELECT toDateTime(block_time) AS time, action_type, user \ + FROM hyperliquid_system_actions \ + ORDER BY block_time DESC LIMIT 3" + .to_string(), + cluster_id: CLUSTER_ID.to_string(), + }; + + match qn.sql.query(¶ms).await { + Ok(resp) => { + println!( + "query: {} rows ({} before limit), {} credits, {:.4}s", + resp.rows, resp.rows_before_limit_at_least, resp.credits, resp.statistics.elapsed + ); + println!( + "columns: {:?}", + resp.meta.iter().map(|c| &c.name).collect::>() + ); + // Read a value out of a dynamic row to confirm the conversion works. + if let Some(row) = resp.data.first() { + println!("first row action_type: {}", row["action_type"]); + } + } + Err(e) => eprintln!("query error: {e}"), + } + + // ── Schema ────────────────────────────────────────────────────────────── + + match qn.sql.get_schema(CLUSTER_ID).await { + Ok(schema) => { + println!("schema: {} ({} tables)", schema.chain, schema.tables.len()); + if let Some(table) = schema.tables.first() { + println!( + "first table: {} ({} columns, {} rows)", + table.name, + table.columns.len(), + table.total_rows + ); + } + } + Err(e) => eprintln!("get_schema error: {e}"), + } + + // ── Error handling ──────────────────────────────────────────────────────── + + // An empty query is rejected with a 403 and a JSON body carrying the error + // message. + let bad = QueryParams { + query: String::new(), + cluster_id: CLUSTER_ID.to_string(), + }; + match qn.sql.query(&bad).await { + Err(SdkError::Api { status, body }) => { + println!("api error {status}: {}", &body[..body.len().min(120)]); + } + other => eprintln!("expected Api error, got {other:?}"), + } +} diff --git a/crates/core/src/admin/mod.rs b/crates/core/src/admin/mod.rs index 301d459..e69754f 100644 --- a/crates/core/src/admin/mod.rs +++ b/crates/core/src/admin/mod.rs @@ -1903,6 +1903,7 @@ mod tests { streams: None, webhooks: None, kvstore: None, + sql: None, }) .unwrap() } @@ -3673,6 +3674,7 @@ mod tests { streams: None, webhooks: None, kvstore: None, + sql: None, }); assert!(matches!(result, Err(crate::errors::SdkError::Config(_)))); } diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index 4baa1d5..8cc3eec 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -140,6 +140,26 @@ impl KvStoreConfig { } } +#[cfg_attr(feature = "python", gen_stub_pyclass)] +#[cfg_attr(feature = "python", pyclass(get_all, set_all))] +#[cfg_attr(feature = "node", napi(object))] +#[cfg_attr(feature = "rust", derive(Builder))] +#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] +pub struct SqlConfig { + pub base_url: Option, +} + +#[cfg(feature = "python")] +#[gen_stub_pymethods] +#[pymethods] +impl SqlConfig { + #[new] + #[pyo3(signature = (base_url=None))] + pub fn new(base_url: Option) -> Self { + SqlConfig { base_url } + } +} + #[cfg_attr(feature = "python", gen_stub_pyclass)] #[cfg_attr(feature = "python", pyclass(get_all, set_all))] #[cfg_attr(feature = "node", napi(object))] @@ -152,6 +172,7 @@ pub struct SdkFullConfig { pub streams: Option, pub webhooks: Option, pub kvstore: Option, + pub sql: Option, } impl SdkFullConfig { @@ -163,6 +184,7 @@ impl SdkFullConfig { streams: None, webhooks: None, kvstore: None, + sql: None, } } @@ -189,7 +211,7 @@ impl SdkFullConfig { #[pymethods] impl SdkFullConfig { #[new] - #[pyo3(signature = (api_key, http=None, admin=None, streams=None, webhooks=None, kvstore=None))] + #[pyo3(signature = (api_key, http=None, admin=None, streams=None, webhooks=None, kvstore=None, sql=None))] pub fn new( api_key: String, http: Option, @@ -197,6 +219,7 @@ impl SdkFullConfig { streams: Option, webhooks: Option, kvstore: Option, + sql: Option, ) -> Self { SdkFullConfig { api_key, @@ -205,6 +228,7 @@ impl SdkFullConfig { streams, webhooks, kvstore, + sql, } } } diff --git a/crates/core/src/kvstore/mod.rs b/crates/core/src/kvstore/mod.rs index 55ab344..4d3f564 100644 --- a/crates/core/src/kvstore/mod.rs +++ b/crates/core/src/kvstore/mod.rs @@ -696,6 +696,7 @@ mod tests { kvstore: Some(KvStoreConfig { base_url: Some(base_url), }), + sql: None, }) .unwrap() } diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 06c678e..945bbb0 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -2,11 +2,12 @@ pub mod admin; pub mod config; pub mod errors; pub mod kvstore; +pub mod sql; pub mod streams; pub mod webhooks; pub use config::{ - AdminConfig, ClientInfo, HttpConfig, KvStoreConfig, SdkFullConfig, StreamsConfig, + AdminConfig, ClientInfo, HttpConfig, KvStoreConfig, SdkFullConfig, SqlConfig, StreamsConfig, WebhooksConfig, }; pub use kvstore::{ @@ -15,6 +16,10 @@ pub use kvstore::{ GetSetsParams, GetSetsResponse, KvSetEntry, KvStoreApiClient, ListContainsItemResponse, UpdateListParams, }; +pub use sql::{ + ChainSchema, ColumnMeta, ColumnSchema, QueryParams, QueryResponse, QueryStatistics, + SqlApiClient, TableSchema, +}; use reqwest::header::{HeaderMap, HeaderName, HeaderValue}; use reqwest::Client as ReqwestClient; @@ -64,6 +69,7 @@ impl std::fmt::Debug for SdkConfig { .field("streams_base_url", &self.0.streams.base_url) .field("webhooks_base_url", &self.0.webhooks.base_url) .field("kvstore_base_url", &self.0.kvstore.base_url) + .field("sql_base_url", &self.0.sql.base_url) .finish() } } @@ -74,6 +80,7 @@ struct SdkConfigInner { streams: streams::ResolvedStreamsConfig, webhooks: webhooks::ResolvedWebhooksConfig, kvstore: kvstore::ResolvedKvStoreConfig, + sql: sql::ResolvedSqlConfig, } impl SdkConfig { @@ -159,6 +166,7 @@ impl SdkConfig { streams: streams::ResolvedStreamsConfig::from_config(config.streams.as_ref())?, webhooks: webhooks::ResolvedWebhooksConfig::from_config(config.webhooks.as_ref())?, kvstore: kvstore::ResolvedKvStoreConfig::from_config(config.kvstore.as_ref())?, + sql: sql::ResolvedSqlConfig::from_config(config.sql.as_ref())?, }))) } @@ -181,6 +189,10 @@ impl SdkConfig { pub(crate) fn kvstore(&self) -> &kvstore::ResolvedKvStoreConfig { &self.0.kvstore } + + pub(crate) fn sql(&self) -> &sql::ResolvedSqlConfig { + &self.0.sql + } } /// Top-level entry point for the Quicknode SDK. Holds sub-clients for each @@ -196,6 +208,9 @@ pub struct QuicknodeSdk { /// Key-Value Store client: manages sets (single values) and lists /// (ordered collections) under string keys. pub kvstore: kvstore::KvStoreApiClient, + /// SQL Explorer client: executes SQL queries against indexed blockchain + /// data and fetches the database schema. + pub sql: sql::SqlApiClient, } impl QuicknodeSdk { @@ -216,7 +231,8 @@ impl QuicknodeSdk { admin: admin::AdminApiClient::new(sdk_config.clone()), streams: streams::StreamsApiClient::new(sdk_config.clone()), webhooks: webhooks::WebhooksApiClient::new(sdk_config.clone()), - kvstore: kvstore::KvStoreApiClient::new(sdk_config), + kvstore: kvstore::KvStoreApiClient::new(sdk_config.clone()), + sql: sql::SqlApiClient::new(sdk_config), }) } @@ -247,6 +263,7 @@ mod headers_tests { streams: None, webhooks: None, kvstore: None, + sql: None, } } @@ -335,6 +352,7 @@ mod headers_tests { streams: None, webhooks: None, kvstore: None, + sql: None, }; let sdk = QuicknodeSdk::new(&cfg).unwrap(); diff --git a/crates/core/src/sql/mod.rs b/crates/core/src/sql/mod.rs new file mode 100644 index 0000000..34a7b87 --- /dev/null +++ b/crates/core/src/sql/mod.rs @@ -0,0 +1,466 @@ +#[cfg(feature = "rust")] +use bon::Builder; +#[cfg(feature = "node")] +use napi_derive::napi; +#[cfg(feature = "python")] +use pyo3::{pyclass, pymethods}; +#[cfg(feature = "python")] +use pyo3_stub_gen::derive::{gen_stub_pyclass, gen_stub_pymethods}; +use serde::{Deserialize, Serialize}; + +use crate::{config::SqlConfig, errors::SdkError, SdkConfig}; + +const SQL_BASE_URL: &str = "https://api.quicknode.com/sql/rest/v1/"; + +// ── Resolved config ──────────────────────────────────────────────────────── + +pub(crate) struct ResolvedSqlConfig { + pub(crate) base_url: reqwest::Url, +} + +impl ResolvedSqlConfig { + pub(crate) fn from_config(config: Option<&SqlConfig>) -> Result { + let url_str = config + .and_then(|s| s.base_url.as_deref()) + .unwrap_or(SQL_BASE_URL); + let mut base_url = + reqwest::Url::parse(url_str).map_err(|e| SdkError::Config(e.to_string()))?; + if !base_url.path().ends_with('/') { + base_url.set_path(&format!("{}/", base_url.path())); + } + Ok(Self { base_url }) + } +} + +// ── Request types ────────────────────────────────────────────────────────── + +/// Parameters for `query`. +#[cfg_attr(feature = "rust", derive(Builder))] +#[cfg_attr(feature = "node", napi(object))] +#[cfg_attr(not(feature = "node"), derive(Clone))] +#[derive(Debug, Serialize, Deserialize)] +pub struct QueryParams { + /// The SQL query to execute. Pagination is expressed in the SQL itself via + /// `LIMIT`/`OFFSET`; the API caps results at 1000 rows per request. + pub query: String, + /// The blockchain network identifier (e.g. `"hyperliquid-core-mainnet"`). + // The request body uses camelCase `clusterId`, unlike the schema response + // which returns snake_case `cluster_id`. + #[serde(rename = "clusterId")] + pub cluster_id: String, +} + +// ── Query response types ─────────────────────────────────────────────────── + +/// Metadata describing a single column in a query result set. +#[cfg_attr(feature = "python", gen_stub_pyclass)] +#[cfg_attr(feature = "python", pyclass(get_all, set_all))] +#[cfg_attr(feature = "node", napi(object))] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ColumnMeta { + /// Column name as it appears in the result set. + pub name: String, + /// Column data type (e.g. `"DateTime('UTC')"`, `"LowCardinality(String)"`). + // Field is `column_type` in Rust because `type` is a keyword; serde and the + // Node binding rename it to `type` on their respective surfaces. Using a raw + // `r#type` ident instead breaks pyo3 stub generation, so the Python surface + // exposes this as `column_type`. + #[serde(rename = "type")] + pub column_type: String, +} + +#[cfg(feature = "python")] +#[gen_stub_pymethods] +#[pymethods] +impl ColumnMeta { + #[new] + pub fn new(name: String, column_type: String) -> Self { + Self { name, column_type } + } +} + +/// Execution statistics returned alongside query results. +#[cfg_attr(feature = "python", gen_stub_pyclass)] +#[cfg_attr(feature = "python", pyclass(get_all, set_all))] +#[cfg_attr(feature = "node", napi(object))] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QueryStatistics { + /// Total query execution time in seconds. + pub elapsed: f64, + /// Total number of rows scanned during execution. + pub rows_read: i64, + /// Total data scanned in bytes. + pub bytes_read: i64, +} + +#[cfg(feature = "python")] +#[gen_stub_pymethods] +#[pymethods] +impl QueryStatistics { + #[new] + pub fn new(elapsed: f64, rows_read: i64, bytes_read: i64) -> Self { + Self { + elapsed, + rows_read, + bytes_read, + } + } +} + +/// Response from `query`. +// +// Holds `serde_json::Value` rows whose columns depend on the SQL query, so this +// type cannot derive `#[pyclass]`/`#[napi(object)]`. It stays pure-Rust in core; +// each binding wraps it and exposes `data` as the language's native dynamic type +// (Python via `pythonize`, Node via napi's `serde_json::Value` support, Ruby via +// `serde_magnus`). Mirrors the `DestinationAttributes` wrapping pattern. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QueryResponse { + /// Column metadata for each column in the result set. + pub meta: Vec, + /// Result rows. Each row is a JSON object whose keys are the selected + /// columns; shape varies per query. + pub data: Vec, + /// Number of rows returned in this response. + pub rows: i64, + /// Total rows that matched the query before applying `LIMIT`; use for + /// pagination. + pub rows_before_limit_at_least: i64, + /// Query execution statistics. + pub statistics: QueryStatistics, + /// Credits consumed by the query. + pub credits: i64, +} + +// ── Schema response types ────────────────────────────────────────────────── + +/// A single column in a table schema. +#[cfg_attr(feature = "python", gen_stub_pyclass)] +#[cfg_attr(feature = "python", pyclass(get_all, set_all))] +#[cfg_attr(feature = "node", napi(object))] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ColumnSchema { + /// Column name. + pub name: String, + /// Column data type (e.g. `"UInt64"`, `"FixedString(42)"`). + // See `ColumnMeta::column_type` for why this is not a raw `r#type` ident. + #[serde(rename = "type")] + pub column_type: String, +} + +#[cfg(feature = "python")] +#[gen_stub_pymethods] +#[pymethods] +impl ColumnSchema { + #[new] + pub fn new(name: String, column_type: String) -> Self { + Self { name, column_type } + } +} + +/// Schema for a single table. +#[cfg_attr(feature = "python", gen_stub_pyclass)] +#[cfg_attr(feature = "python", pyclass(get_all, set_all))] +#[cfg_attr(feature = "node", napi(object))] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TableSchema { + /// Table name. + pub name: String, + /// Storage engine backing the table. + pub engine: String, + /// Approximate total number of rows in the table. + pub total_rows: i64, + /// Partition key expression; empty string for views. + pub partition_key: String, + /// Sorting key columns; empty for views. + pub sorting_key: Vec, + /// Columns in the table. + pub columns: Vec, +} + +#[cfg(feature = "python")] +#[gen_stub_pymethods] +#[pymethods] +impl TableSchema { + #[new] + pub fn new( + name: String, + engine: String, + total_rows: i64, + partition_key: String, + sorting_key: Vec, + columns: Vec, + ) -> Self { + Self { + name, + engine, + total_rows, + partition_key, + sorting_key, + columns, + } + } +} + +/// Response from `get_schema`: the schema for a single chain/cluster. +#[cfg_attr(feature = "python", gen_stub_pyclass)] +#[cfg_attr(feature = "python", pyclass(get_all, set_all))] +#[cfg_attr(feature = "node", napi(object))] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChainSchema { + /// Human-readable chain name (e.g. `"Hyperliquid (HyperCore)"`). + pub chain: String, + /// Cluster identifier the schema belongs to. + pub cluster_id: String, + /// Tables available in this cluster. + pub tables: Vec, +} + +#[cfg(feature = "python")] +#[gen_stub_pymethods] +#[pymethods] +impl ChainSchema { + #[new] + pub fn new(chain: String, cluster_id: String, tables: Vec) -> Self { + Self { + chain, + cluster_id, + tables, + } + } +} + +// ── Client ───────────────────────────────────────────────────────────────── + +/// Client for the Quicknode SQL Explorer. Executes SQL queries against indexed +/// blockchain data and fetches the database schema. +#[derive(Debug, Clone)] +pub struct SqlApiClient { + config: SdkConfig, +} + +impl SqlApiClient { + pub fn new(config: SdkConfig) -> Self { + Self { config } + } + + /// Executes a SQL query against the given cluster and returns the result + /// set. + pub async fn query(&self, params: &QueryParams) -> Result { + let url = self.config.sql().base_url.join("query")?; + let resp = self + .config + .http_client() + .post(url) + .json(params) + .send() + .await + .map_err(SdkError::Http)?; + let status = resp.status(); + let body = resp.text().await.map_err(SdkError::Http)?; + if !status.is_success() { + return Err(SdkError::Api { status, body }); + } + serde_json::from_str(&body).map_err(|source| SdkError::Decode { source, body }) + } + + /// Fetches the database schema for a cluster, including table names, + /// columns, types, sort keys, and partition strategies. + pub async fn get_schema(&self, cluster_id: &str) -> Result { + let url = self + .config + .sql() + .base_url + .join(&format!("schema/{cluster_id}"))?; + let resp = self + .config + .http_client() + .get(url) + .send() + .await + .map_err(SdkError::Http)?; + let status = resp.status(); + let body = resp.text().await.map_err(SdkError::Http)?; + if !status.is_success() { + return Err(SdkError::Api { status, body }); + } + serde_json::from_str(&body).map_err(|source| SdkError::Decode { source, body }) + } +} + +// ── Tests ────────────────────────────────────────────────────────────────── + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests { + use super::*; + use crate::{QuicknodeSdk, SdkFullConfig, SqlConfig}; + use wiremock::matchers::{body_json, method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + fn make_sdk(base_url: String) -> QuicknodeSdk { + QuicknodeSdk::new(&SdkFullConfig { + api_key: "test-key".to_string(), + http: None, + admin: None, + streams: None, + webhooks: None, + kvstore: None, + sql: Some(SqlConfig { + base_url: Some(base_url), + }), + }) + .unwrap() + } + + fn query_params() -> QueryParams { + QueryParams { + query: "SELECT 1".to_string(), + cluster_id: "hyperliquid-core-mainnet".to_string(), + } + } + + // ── query ────────────────────────────────────────────────────────────── + + #[tokio::test] + async fn query_success() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/query")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::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 + }))) + .mount(&server) + .await; + let sdk = make_sdk(format!("{}/", server.uri())); + let resp = sdk.sql.query(&query_params()).await.unwrap(); + assert_eq!(resp.rows, 2); + assert_eq!(resp.rows_before_limit_at_least, 18251); + assert_eq!(resp.credits, 135); + assert_eq!(resp.meta.len(), 2); + assert_eq!(resp.meta[0].name, "time"); + assert_eq!(resp.statistics.rows_read, 31341); + // Dynamic row: confirm a value reads through. + assert_eq!(resp.data.len(), 2); + assert_eq!(resp.data[0]["action_type"], "SystemSpotSendAction"); + } + + // Wire-inspection regression: confirm the request body sends `clusterId` + // (camelCase) so a future serde rename of `cluster_id` fails loudly. + #[tokio::test] + async fn query_wire_body_cluster_id() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/query")) + .and(body_json(serde_json::json!({ + "query": "SELECT 1", + "clusterId": "hyperliquid-core-mainnet" + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "meta": [], + "data": [], + "rows": 0, + "rows_before_limit_at_least": 0, + "statistics": {"elapsed": 0.001, "rows_read": 0, "bytes_read": 0}, + "credits": 1 + }))) + .mount(&server) + .await; + let sdk = make_sdk(format!("{}/", server.uri())); + sdk.sql.query(&query_params()).await.unwrap(); + } + + #[tokio::test] + async fn query_api_error() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/query")) + .respond_with(ResponseTemplate::new(403).set_body_json( + serde_json::json!({"statusCode": 403, "message": "only SELECT queries are allowed"}), + )) + .mount(&server) + .await; + let sdk = make_sdk(format!("{}/", server.uri())); + let err = sdk.sql.query(&query_params()).await.unwrap_err(); + assert!(matches!(err, SdkError::Api { .. })); + } + + #[tokio::test] + async fn query_decode_error() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/query")) + .respond_with(ResponseTemplate::new(200).set_body_string("not json")) + .mount(&server) + .await; + let sdk = make_sdk(format!("{}/", server.uri())); + let err = sdk.sql.query(&query_params()).await.unwrap_err(); + assert!(matches!(err, SdkError::Decode { .. })); + } + + // ── get_schema ─────────────────────────────────────────────────────────── + + #[tokio::test] + async fn get_schema_success() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/schema/hyperliquid-core-mainnet")) + .respond_with(ResponseTemplate::new(200).set_body_json(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"} + ] + } + ] + }))) + .mount(&server) + .await; + let sdk = make_sdk(format!("{}/", server.uri())); + let resp = sdk + .sql + .get_schema("hyperliquid-core-mainnet") + .await + .unwrap(); + assert_eq!(resp.cluster_id, "hyperliquid-core-mainnet"); + assert_eq!(resp.tables.len(), 1); + let table = &resp.tables[0]; + assert_eq!(table.name, "hyperliquid_agents"); + assert_eq!(table.total_rows, 3322574607); + assert_eq!(table.sorting_key, vec!["block_number", "agent"]); + assert_eq!(table.columns[0].name, "agent"); + assert_eq!(table.columns[0].column_type, "FixedString(42)"); + } + + #[tokio::test] + async fn get_schema_api_error() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/schema/bad-cluster")) + .respond_with(ResponseTemplate::new(404).set_body_string("Not Found")) + .mount(&server) + .await; + let sdk = make_sdk(format!("{}/", server.uri())); + let err = sdk.sql.get_schema("bad-cluster").await.unwrap_err(); + assert!(matches!(err, SdkError::Api { .. })); + } +} diff --git a/crates/core/src/streams/mod.rs b/crates/core/src/streams/mod.rs index 5b107d2..016775a 100644 --- a/crates/core/src/streams/mod.rs +++ b/crates/core/src/streams/mod.rs @@ -327,6 +327,7 @@ mod tests { }), webhooks: None, kvstore: None, + sql: None, }) .unwrap() } diff --git a/crates/core/src/webhooks/mod.rs b/crates/core/src/webhooks/mod.rs index 0d533d7..74af2da 100644 --- a/crates/core/src/webhooks/mod.rs +++ b/crates/core/src/webhooks/mod.rs @@ -346,6 +346,7 @@ mod tests { base_url: Some(base_url), }), kvstore: None, + sql: None, }) .unwrap() } diff --git a/crates/node/src/lib.rs b/crates/node/src/lib.rs index 855ff51..302c063 100644 --- a/crates/node/src/lib.rs +++ b/crates/node/src/lib.rs @@ -4,6 +4,7 @@ use quicknode_sdk as core; mod errors; mod key_case; +mod sql; mod streams_destination; mod webhooks_template; @@ -15,6 +16,7 @@ pub struct QuicknodeSdk { streams: StreamsApiClient, webhooks: WebhooksApiClient, kvstore: KvStoreApiClient, + sql: SqlApiClient, } /// Build a [`core::ClientInfo`] from the live Node.js runtime so the SDK's @@ -57,7 +59,10 @@ impl QuicknodeSdk { inner: core::streams::StreamsApiClient::new(sdk_config.clone()), }, kvstore: KvStoreApiClient { - inner: core::kvstore::KvStoreApiClient::new(sdk_config), + inner: core::kvstore::KvStoreApiClient::new(sdk_config.clone()), + }, + sql: SqlApiClient { + inner: core::sql::SqlApiClient::new(sdk_config), }, }) } @@ -86,6 +91,12 @@ impl QuicknodeSdk { self.kvstore.clone() } + /// Returns the sql sub-client. + #[napi(getter)] + pub fn sql(&self) -> SqlApiClient { + self.sql.clone() + } + /// Creates a new SDK instance using configuration from environment variables. #[napi(factory)] pub fn from_env() -> Result { @@ -97,6 +108,7 @@ impl QuicknodeSdk { inner: sdk.webhooks, }, kvstore: KvStoreApiClient { inner: sdk.kvstore }, + sql: SqlApiClient { inner: sdk.sql }, }) .map_err(errors::map_sdk_err) } @@ -1392,3 +1404,35 @@ impl KvStoreApiClient { .map_err(errors::map_sdk_err) } } + +// ── SqlApiClient ─────────────────────────────────────────────── + +#[derive(Clone)] +#[napi] +pub struct SqlApiClient { + inner: core::sql::SqlApiClient, +} + +#[napi] +impl SqlApiClient { + /// Executes a SQL query against the given cluster and returns the result set. + #[napi] + pub async fn query(&self, query: String, cluster_id: String) -> Result { + self.inner + .query(&core::sql::QueryParams { query, cluster_id }) + .await + .map(sql::QueryResponseNode::from) + .map_err(errors::map_sdk_err) + } + + /// Fetches the database schema for a cluster, including table names, + /// columns, types, sort keys, and partition strategies. + #[napi] + pub async fn get_schema(&self, cluster_id: String) -> Result { + self.inner + .get_schema(&cluster_id) + .await + .map(sql::ChainSchemaNode::from) + .map_err(errors::map_sdk_err) + } +} diff --git a/crates/node/src/sql.rs b/crates/node/src/sql.rs new file mode 100644 index 0000000..f9e8b04 --- /dev/null +++ b/crates/node/src/sql.rs @@ -0,0 +1,146 @@ +use napi_derive::napi; +use quicknode_sdk as core; + +// Node-facing SQL response types. +// +// Two reasons these wrap the core types rather than exposing them directly: +// 1. `QueryResponse.data` holds `serde_json::Value` rows whose shape depends on +// the SQL query; napi serializes `serde_json::Value` to a plain JS object. +// 2. The column-type field is `column_type` in core (`type` is a Rust keyword). +// napi would emit it as `columnType`; these wrappers expose it as `type` to +// match the REST API and the other language bindings. + +#[napi(object)] +pub struct ColumnMetaNode { + /// Column name as it appears in the result set. + pub name: String, + /// Column data type (e.g. `"DateTime('UTC')"`). + #[napi(js_name = "type")] + pub type_: String, +} + +impl From for ColumnMetaNode { + fn from(c: core::sql::ColumnMeta) -> Self { + Self { + name: c.name, + type_: c.column_type, + } + } +} + +#[napi(object)] +pub struct QueryStatisticsNode { + /// Total query execution time in seconds. + pub elapsed: f64, + /// Total number of rows scanned during execution. + pub rows_read: i64, + /// Total data scanned in bytes. + pub bytes_read: i64, +} + +impl From for QueryStatisticsNode { + fn from(s: core::sql::QueryStatistics) -> Self { + Self { + elapsed: s.elapsed, + rows_read: s.rows_read, + bytes_read: s.bytes_read, + } + } +} + +#[napi(object)] +pub struct QueryResponseNode { + /// Column metadata for each column in the result set. + pub meta: Vec, + /// Result rows. Each row is an object keyed by the selected columns; shape + /// varies per query. + pub data: Vec, + /// Number of rows returned in this response. + pub rows: i64, + /// Total rows that matched the query before applying `LIMIT`. + pub rows_before_limit_at_least: i64, + /// Query execution statistics. + pub statistics: QueryStatisticsNode, + /// Credits consumed by the query. + pub credits: i64, +} + +impl From for QueryResponseNode { + fn from(r: core::sql::QueryResponse) -> Self { + Self { + meta: r.meta.into_iter().map(ColumnMetaNode::from).collect(), + data: r.data, + rows: r.rows, + rows_before_limit_at_least: r.rows_before_limit_at_least, + statistics: r.statistics.into(), + credits: r.credits, + } + } +} + +#[napi(object)] +pub struct ColumnSchemaNode { + /// Column name. + pub name: String, + /// Column data type (e.g. `"UInt64"`, `"FixedString(42)"`). + #[napi(js_name = "type")] + pub type_: String, +} + +impl From for ColumnSchemaNode { + fn from(c: core::sql::ColumnSchema) -> Self { + Self { + name: c.name, + type_: c.column_type, + } + } +} + +#[napi(object)] +pub struct TableSchemaNode { + /// Table name. + pub name: String, + /// Storage engine backing the table. + pub engine: String, + /// Approximate total number of rows in the table. + pub total_rows: i64, + /// Partition key expression; empty string for views. + pub partition_key: String, + /// Sorting key columns; empty for views. + pub sorting_key: Vec, + /// Columns in the table. + pub columns: Vec, +} + +impl From for TableSchemaNode { + fn from(t: core::sql::TableSchema) -> Self { + Self { + name: t.name, + engine: t.engine, + total_rows: t.total_rows, + partition_key: t.partition_key, + sorting_key: t.sorting_key, + columns: t.columns.into_iter().map(ColumnSchemaNode::from).collect(), + } + } +} + +#[napi(object)] +pub struct ChainSchemaNode { + /// Human-readable chain name. + pub chain: String, + /// Cluster identifier the schema belongs to. + pub cluster_id: String, + /// Tables available in this cluster. + pub tables: Vec, +} + +impl From for ChainSchemaNode { + fn from(s: core::sql::ChainSchema) -> Self { + Self { + chain: s.chain, + cluster_id: s.cluster_id, + tables: s.tables.into_iter().map(TableSchemaNode::from).collect(), + } + } +} diff --git a/crates/python/Cargo.toml b/crates/python/Cargo.toml index d656c91..dca48b8 100644 --- a/crates/python/Cargo.toml +++ b/crates/python/Cargo.toml @@ -21,4 +21,5 @@ quicknode-sdk = { path = "../core", features = ["python"] } pyo3 = { workspace = true } pyo3-async-runtimes = { workspace = true, features = ["tokio-runtime"] } pyo3-stub-gen = { workspace = true } +pythonize = { workspace = true } serde_json = "1.0" diff --git a/crates/python/src/lib.rs b/crates/python/src/lib.rs index 09351ec..850ebb3 100644 --- a/crates/python/src/lib.rs +++ b/crates/python/src/lib.rs @@ -6,6 +6,7 @@ use pyo3_stub_gen::{ use quicknode_sdk as core; mod errors; +mod sql; mod streams_destination; mod webhooks_template; @@ -22,6 +23,8 @@ pub struct QuicknodeSdk { webhooks: WebhooksApiClient, #[pyo3(get)] kvstore: KvStoreApiClient, + #[pyo3(get)] + sql: SqlApiClient, } /// Build a [`core::ClientInfo`] from the live Python runtime so the SDK's @@ -73,7 +76,10 @@ impl QuicknodeSdk { inner: core::streams::StreamsApiClient::new(sdk_config.clone()), }, kvstore: KvStoreApiClient { - inner: core::kvstore::KvStoreApiClient::new(sdk_config), + inner: core::kvstore::KvStoreApiClient::new(sdk_config.clone()), + }, + sql: SqlApiClient { + inner: core::sql::SqlApiClient::new(sdk_config), }, }) } @@ -89,6 +95,7 @@ impl QuicknodeSdk { inner: sdk.webhooks, }, kvstore: KvStoreApiClient { inner: sdk.kvstore }, + sql: SqlApiClient { inner: sdk.sql }, }) .map_err(errors::map_sdk_err) } @@ -2357,6 +2364,57 @@ impl KvStoreApiClient { } } +// ── SqlApiClient ─────────────────────────────────────────────── + +#[gen_stub_pyclass] +#[pyclass] +#[derive(Clone)] +pub struct SqlApiClient { + inner: core::sql::SqlApiClient, +} + +#[gen_stub_pymethods] +#[pymethods] +impl SqlApiClient { + /// Executes a SQL query against the given cluster and returns the result + /// set. + #[pyo3(signature = (query, cluster_id))] + #[gen_stub(override_return_type( + type_repr = "typing.Coroutine[typing.Any, typing.Any, QueryResponse]" + ))] + fn query<'py>( + &self, + py: Python<'py>, + query: String, + cluster_id: String, + ) -> PyResult> { + let client = self.inner.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let resp = client + .query(&core::sql::QueryParams { query, cluster_id }) + .await + .map_err(errors::map_sdk_err)?; + Python::attach(|py| sql::PyQueryResponse::from_core(resp, py)) + }) + } + + /// Fetches the database schema for a cluster, including table names, + /// columns, types, sort keys, and partition strategies. + #[pyo3(signature = (cluster_id))] + #[gen_stub(override_return_type( + type_repr = "typing.Coroutine[typing.Any, typing.Any, ChainSchema]" + ))] + fn get_schema<'py>(&self, py: Python<'py>, cluster_id: String) -> PyResult> { + let client = self.inner.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + client + .get_schema(&cluster_id) + .await + .map_err(errors::map_sdk_err) + }) + } +} + // ── Module ───────────────────────────────────────────────────── #[pymodule] @@ -2499,6 +2557,7 @@ fn _core(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; @@ -2566,6 +2625,14 @@ fn _core(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + // sql + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; Ok(()) } diff --git a/crates/python/src/sql.rs b/crates/python/src/sql.rs new file mode 100644 index 0000000..1385a7f --- /dev/null +++ b/crates/python/src/sql.rs @@ -0,0 +1,59 @@ +use pyo3::prelude::*; +use pyo3_stub_gen::derive::{gen_stub_pyclass, gen_stub_pymethods}; +use quicknode_sdk::sql::{ColumnMeta, QueryResponse, QueryStatistics}; + +// Core QueryResponse cannot be #[pyclass] because `data` holds +// serde_json::Value rows whose shape depends on the SQL query. This wrapper +// keeps meta/statistics/counts typed and converts each dynamic row to a native +// Python object via `pythonize`. +#[gen_stub_pyclass] +#[pyclass(name = "QueryResponse")] +pub struct PyQueryResponse { + #[pyo3(get)] + pub meta: Vec, + // Exposed via a #[getter] below so pyo3-stub-gen can override the stub type + // to list[dict[str, Any]]; #[pyo3(get)] on Py would produce Any. + pub data: Vec>, + #[pyo3(get)] + pub rows: i64, + #[pyo3(get)] + pub rows_before_limit_at_least: i64, + #[pyo3(get)] + pub statistics: QueryStatistics, + #[pyo3(get)] + pub credits: i64, +} + +impl PyQueryResponse { + pub fn from_core(resp: QueryResponse, py: Python<'_>) -> PyResult { + let mut data = Vec::with_capacity(resp.data.len()); + for row in resp.data { + // pythonize turns an arbitrary serde_json::Value into the matching + // native Python object (dict/list/str/number/bool/None). + let obj = pythonize::pythonize(py, &row) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + data.push(obj.unbind()); + } + Ok(Self { + meta: resp.meta, + data, + rows: resp.rows, + rows_before_limit_at_least: resp.rows_before_limit_at_least, + statistics: resp.statistics, + credits: resp.credits, + }) + } +} + +#[gen_stub_pymethods] +#[pymethods] +impl PyQueryResponse { + // Exposed as a getter so pyo3-stub-gen can type the rows. Without the + // override the stub would be `Any` and IDEs couldn't surface that rows are + // dicts keyed by column name. + #[getter] + #[gen_stub(override_return_type(type_repr = "list[dict[str, typing.Any]]"))] + fn data<'py>(&self, py: Python<'py>) -> Vec> { + self.data.iter().map(|o| o.clone_ref(py)).collect() + } +} diff --git a/crates/ruby/src/lib.rs b/crates/ruby/src/lib.rs index 2641375..81ca483 100644 --- a/crates/ruby/src/lib.rs +++ b/crates/ruby/src/lib.rs @@ -271,7 +271,9 @@ impl QuicknodeSdk { fn from_config(opts: RHash) -> Result { validate_keys( &opts, - &["api_key", "http", "admin", "streams", "webhooks", "kvstore"], + &[ + "api_key", "http", "admin", "streams", "webhooks", "kvstore", "sql", + ], )?; let config: core::SdkFullConfig = serde_magnus::deserialize(&ruby(), opts).map_err(|e| { @@ -305,6 +307,12 @@ impl QuicknodeSdk { inner: self.inner.kvstore.clone(), } } + + fn sql(&self) -> SqlApiClient { + SqlApiClient { + inner: self.inner.sql.clone(), + } + } } // ── AdminApiClient ────────────────────────────────────────────────────────── @@ -1782,6 +1790,39 @@ impl KvStoreApiClient { } } +// ── SqlApiClient ──────────────────────────────────────────────────────────── + +#[magnus::wrap(class = "QuicknodeSdk::Native::Sql", free_immediately, size)] +#[derive(Clone)] +pub struct SqlApiClient { + inner: core::sql::SqlApiClient, +} + +#[allow(clippy::needless_pass_by_value)] +impl SqlApiClient { + fn query(&self, opts: RHash) -> Result { + validate_keys(&opts, &["query", "cluster_id"])?; + let client = self.inner.clone(); + runtime() + .block_on(client.query(&core::sql::QueryParams { + query: hash_require_string(&opts, "query")?, + cluster_id: hash_require_string(&opts, "cluster_id")?, + })) + .map_err(map_err) + .and_then(to_ruby) + } + + fn get_schema(&self, opts: RHash) -> Result { + validate_keys(&opts, &["cluster_id"])?; + let client = self.inner.clone(); + let cluster_id = hash_require_string(&opts, "cluster_id")?; + runtime() + .block_on(client.get_schema(&cluster_id)) + .map_err(map_err) + .and_then(to_ruby) + } +} + // ── Extension init ────────────────────────────────────────────────────────── #[magnus::init(name = "quicknode_sdk")] @@ -1802,6 +1843,7 @@ fn init(ruby: &Ruby) -> Result<(), Error> { sdk.define_method("streams", method!(QuicknodeSdk::streams, 0))?; sdk.define_method("webhooks", method!(QuicknodeSdk::webhooks, 0))?; sdk.define_method("kvstore", method!(QuicknodeSdk::kvstore, 0))?; + sdk.define_method("sql", method!(QuicknodeSdk::sql, 0))?; // ── Admin ───────────────────────────────────────────────── let admin = native.define_class("Admin", ruby.class_object())?; @@ -2088,5 +2130,10 @@ fn init(ruby: &Ruby) -> Result<(), Error> { )?; kvstore.define_method("delete_list", method!(KvStoreApiClient::delete_list, 1))?; + // ── Sql ─────────────────────────────────────────────────── + let sql = native.define_class("Sql", ruby.class_object())?; + sql.define_method("query", method!(SqlApiClient::query, 1))?; + sql.define_method("get_schema", method!(SqlApiClient::get_schema, 1))?; + Ok(()) } diff --git a/npm/README.md b/npm/README.md index 233d258..e7463e8 100644 --- a/npm/README.md +++ b/npm/README.md @@ -46,6 +46,7 @@ This is one of four language bindings published from the same Rust core. See the - [KV Store Client](#kv-store-client) - [Sets](#sets) - [Lists](#lists) + - [SQL Client](#sql-client) - [Error Handling](#error-handling) - [License](#license) @@ -55,7 +56,7 @@ This is one of four language bindings published from the same Rust core. See the ## Quick Start -Construct the SDK once, then reach into the four sub-clients (`admin`, `streams`, `webhooks`, `kvstore`). Subsequent API Reference snippets assume you have a `qn` handle from one of these blocks. +Construct the SDK once, then reach into the five sub-clients (`admin`, `streams`, `webhooks`, `kvstore`, `sql`). Subsequent API Reference snippets assume you have a `qn` handle from one of these blocks. ```typescript // Node.js @@ -96,6 +97,7 @@ Environment variables (prefix `QN_SDK__`, separator `__`): | `QN_SDK__STREAMS__BASE_URL` | no | `https://api.quicknode.com/streams/rest/v1/` | Override streams base URL | | `QN_SDK__WEBHOOKS__BASE_URL` | no | `https://api.quicknode.com/webhooks/rest/v1/` | Override webhooks base URL | | `QN_SDK__KVSTORE__BASE_URL` | no | `https://api.quicknode.com/kv/rest/v1/` | Override KV store base URL | +| `QN_SDK__SQL__BASE_URL` | no | `https://api.quicknode.com/sql/rest/v1/` | Override SQL Explorer base URL | | `QN_SDK__HTTP__HEADERS__` | no | — | Custom HTTP header sent on every request. Overrides SDK-managed headers (see below). | ### Custom headers and `User-Agent` @@ -1606,6 +1608,43 @@ Deletes a list and all of its items. await qn.kvstore.deleteList("my-list"); ``` +--- + +### SQL Client + +Accessed as `qn.sql`. Runs SQL queries against indexed blockchain data and fetches the database schema. Backed by `https://api.quicknode.com/sql/rest/v1/`. + +##### `query` + +Executes a SQL query against a cluster and returns the result set. Paginate by writing `LIMIT`/`OFFSET` into the SQL. + +**Parameters**: `query` (string, required), `cluster_id` / `clusterId` (string, required). + +**Returns**: a query result — `meta` (column metadata, each with `name` and `type`), `data` (rows as objects keyed by column name), `rows`, `rows_before_limit_at_least` / `rowsBeforeLimitAtLeast`, `statistics` (`elapsed`, `rows_read`/`rowsRead`, `bytes_read`/`bytesRead`), and `credits`. + +```typescript +// Node.js +const resp = await qn.sql.query( + "SELECT action_type, user FROM hyperliquid_system_actions ORDER BY block_time DESC LIMIT 100", + "hyperliquid-core-mainnet", +); +console.log(resp.rows, resp.data[0]); +``` + +##### `get_schema` / `getSchema` + +Fetches the database schema for a cluster: table names, columns, types, sort keys, and partition strategies. + +**Parameters**: `cluster_id` / `clusterId` (string, required). + +**Returns**: a chain schema — `chain`, `cluster_id` / `clusterId`, and `tables` (each with `name`, `engine`, `total_rows` / `totalRows`, `partition_key` / `partitionKey`, `sorting_key` / `sortingKey`, and `columns` of `{ name, type }`). + +```typescript +// Node.js +const schema = await qn.sql.getSchema("hyperliquid-core-mainnet"); +console.log(schema.tables.length); +``` + ## Error Handling Every binding exposes a typed exception hierarchy derived from the core `SdkError` diff --git a/npm/examples/sql.ts b/npm/examples/sql.ts new file mode 100644 index 0000000..a72b169 --- /dev/null +++ b/npm/examples/sql.ts @@ -0,0 +1,45 @@ +import { QuicknodeSdk, ApiError } from "../sdk"; + +const CLUSTER_ID = "hyperliquid-core-mainnet"; + +async function main() { + const qn = QuicknodeSdk.fromEnv(); + + // Query + const resp = await qn.sql.query( + "SELECT toDateTime(block_time) AS time, action_type, user " + + "FROM hyperliquid_system_actions " + + "ORDER BY block_time DESC LIMIT 3", + CLUSTER_ID, + ); + console.log( + `query: ${resp.rows} rows (${resp.rowsBeforeLimitAtLeast} before limit), ` + + `${resp.credits} credits, ${resp.statistics.elapsed.toFixed(4)}s`, + ); + console.log(`columns: ${resp.meta.map((c) => c.name).join(", ")}`); + if (resp.data.length > 0) { + console.log(`response: ${JSON.stringify(resp.data, null, 2)}`); + // data rows are plain objects keyed by column name + console.log(`first row action_type: ${resp.data[0].action_type}`); + } + + // Schema + const schema = await qn.sql.getSchema(CLUSTER_ID); + console.log(`schema: ${schema.chain} (${schema.tables.length} tables)`); + if (schema.tables.length > 0) { + const t = schema.tables[0]; + console.log( + `first table: ${t.name} (${t.columns.length} columns, ${t.totalRows} rows)`, + ); + } + + // Error handling: an empty query is rejected with a 403. + try { + await qn.sql.query("", CLUSTER_ID); + } catch (e) { + if (!(e instanceof ApiError)) throw e; + console.log(`api error ${e.status}: ${e.body.substring(0, 120)}`); + } +} + +main(); diff --git a/npm/index.d.ts b/npm/index.d.ts index 7f5ecca..8e0c342 100644 --- a/npm/index.d.ts +++ b/npm/index.d.ts @@ -201,6 +201,16 @@ export interface ChainNetwork { chainId?: number } +/** Response from `get_schema`: the schema for a single chain/cluster. */ +export interface ChainSchema { + /** Human-readable chain name (e.g. `"Hyperliquid (HyperCore)"`). */ + chain: string + /** Cluster identifier the schema belongs to. */ + clusterId: string + /** Tables available in this cluster. */ + tables: Array +} + /** Per-chain usage row. */ export interface ChainUsage { /** Chain name or slug. */ @@ -209,6 +219,22 @@ export interface ChainUsage { creditsUsed: number } +/** Metadata describing a single column in a query result set. */ +export interface ColumnMeta { + /** Column name as it appears in the result set. */ + name: string + /** Column data type (e.g. `"DateTime('UTC')"`, `"LowCardinality(String)"`). */ + columnType: string +} + +/** A single column in a table schema. */ +export interface ColumnSchema { + /** Column name. */ + name: string + /** Column data type (e.g. `"UInt64"`, `"FixedString(42)"`). */ + columnType: string +} + /** Parameters for `create_domain_mask`. */ export interface CreateDomainMaskRequest { /** Custom domain that will mask the endpoint's Quicknode URL. */ @@ -1301,6 +1327,27 @@ export declare const enum ProductType { Webhook = 'Webhook' } +/** Parameters for `query`. */ +export interface QueryParams { + /** + * The SQL query to execute. Pagination is expressed in the SQL itself via + * `LIMIT`/`OFFSET`; the API caps results at 1000 rows per request. + */ + query: string + /** The blockchain network identifier (e.g. `"hyperliquid-core-mainnet"`). */ + clusterId: string +} + +/** Execution statistics returned alongside query results. */ +export interface QueryStatistics { + /** Total query execution time in seconds. */ + elapsed: number + /** Total number of rows scanned during execution. */ + rowsRead: number + /** Total data scanned in bytes. */ + bytesRead: number +} + /** * A single rate-limit row returned by `get_rate_limits`, identifying the * bucket (`rps`/`rpm`/`rpd`), the value enforced, and whether the value comes @@ -1398,6 +1445,7 @@ export interface SdkFullConfig { streams?: StreamsConfig webhooks?: WebhooksConfig kvstore?: KvStoreConfig + sql?: SqlConfig } /** A single security feature's name, status, and optional value. */ @@ -1484,6 +1532,10 @@ export interface SolanaWalletFilterTemplate { accounts: Array } +export interface SqlConfig { + baseUrl?: string +} + /** ByList form of `StellarWalletTransactionsFilterTemplate`. */ export interface StellarWalletTransactionsFilterByListTemplate { /** Name of the pre-created wallets list. */ @@ -1557,6 +1609,22 @@ export declare const enum StreamStatus { Blocked = 'Blocked' } +/** Schema for a single table. */ +export interface TableSchema { + /** Table name. */ + name: string + /** Storage engine backing the table. */ + engine: string + /** Approximate total number of rows in the table. */ + totalRows: number + /** Partition key expression; empty string for views. */ + partitionKey: string + /** Sorting key columns; empty for views. */ + sortingKey: Array + /** Columns in the table. */ + columns: Array +} + /** Per-tag usage row. */ export interface TagUsage { /** Tag identifier. */ @@ -2291,10 +2359,22 @@ export declare class QuicknodeSdk { get webhooks(): WebhooksApiClient /** Returns the kvstore sub-client. */ get kvstore(): KvStoreApiClient + /** Returns the sql sub-client. */ + get sql(): SqlApiClient /** Creates a new SDK instance using configuration from environment variables. */ static fromEnv(): QuicknodeSdk } +export declare class SqlApiClient { + /** Executes a SQL query against the given cluster and returns the result set. */ + query(query: string, clusterId: string): Promise + /** + * Fetches the database schema for a cluster, including table names, + * columns, types, sort keys, and partition strategies. + */ + getSchema(clusterId: string): Promise +} + export declare class StreamsApiClient { /** * Creates a new Stream on a given blockchain network and dataset, delivering @@ -2419,6 +2499,29 @@ export declare class WebhooksApiClient { updateWebhookTemplate(webhookId: string, params: UpdateWebhookTemplateParamsNode): Promise } +export interface ChainSchemaNode { + /** Human-readable chain name. */ + chain: string + /** Cluster identifier the schema belongs to. */ + clusterId: string + /** Tables available in this cluster. */ + tables: Array +} + +export interface ColumnMetaNode { + /** Column name as it appears in the result set. */ + name: string + /** Column data type (e.g. `"DateTime('UTC')"`). */ + type: string +} + +export interface ColumnSchemaNode { + /** Column name. */ + name: string + /** Column data type (e.g. `"UInt64"`, `"FixedString(42)"`). */ + type: string +} + export interface CreateStreamParamsNode { name: string region: StreamRegion @@ -2460,6 +2563,33 @@ export interface ListStreamsResponseNode { pageInfo: PageInfo } +export interface QueryResponseNode { + /** Column metadata for each column in the result set. */ + meta: Array + /** + * Result rows. Each row is an object keyed by the selected columns; shape + * varies per query. + */ + data: Array + /** Number of rows returned in this response. */ + rows: number + /** Total rows that matched the query before applying `LIMIT`. */ + rowsBeforeLimitAtLeast: number + /** Query execution statistics. */ + statistics: QueryStatisticsNode + /** Credits consumed by the query. */ + credits: number +} + +export interface QueryStatisticsNode { + /** Total query execution time in seconds. */ + elapsed: number + /** Total number of rows scanned during execution. */ + rowsRead: number + /** Total data scanned in bytes. */ + bytesRead: number +} + export interface StreamNode { id: string name: string @@ -2495,6 +2625,21 @@ export interface StreamNode { extraDestinations?: Array } +export interface TableSchemaNode { + /** Table name. */ + name: string + /** Storage engine backing the table. */ + engine: string + /** Approximate total number of rows in the table. */ + totalRows: number + /** Partition key expression; empty string for views. */ + partitionKey: string + /** Sorting key columns; empty for views. */ + sortingKey: Array + /** Columns in the table. */ + columns: Array +} + export interface UpdateStreamParamsNode { name?: string region?: StreamRegion diff --git a/npm/index.js b/npm/index.js index 6acd6cc..298e1e2 100644 --- a/npm/index.js +++ b/npm/index.js @@ -588,5 +588,6 @@ module.exports.WebhookTemplateId = nativeBinding.WebhookTemplateId module.exports.AdminApiClient = nativeBinding.AdminApiClient module.exports.KvStoreApiClient = nativeBinding.KvStoreApiClient module.exports.QuicknodeSdk = nativeBinding.QuicknodeSdk +module.exports.SqlApiClient = nativeBinding.SqlApiClient module.exports.StreamsApiClient = nativeBinding.StreamsApiClient module.exports.WebhooksApiClient = nativeBinding.WebhooksApiClient diff --git a/npm/sdk.d.ts b/npm/sdk.d.ts index a48ba08..4729e82 100644 --- a/npm/sdk.d.ts +++ b/npm/sdk.d.ts @@ -310,6 +310,15 @@ export type { UpdateListParams, AddListItemParams, ListContainsItemResponse, + // sql + SqlConfig, + SqlApiClient, + QueryResponseNode, + ColumnMetaNode, + QueryStatisticsNode, + ChainSchemaNode, + TableSchemaNode, + ColumnSchemaNode, } from "./index"; // const enums must use `export` (not `export type`) so they are usable as values @@ -363,6 +372,19 @@ export interface WebhooksApiClientTyped { ): Promise; } +// Retypes the query response `data` rows from napi's `any[]` to +// `Record[]` (rows are objects keyed by the selected columns; +// shape varies per query). Keep method signatures in sync with the +// napi-generated SqlApiClient in ./index.d.ts. +export interface QueryResult extends Omit { + data: Array>; +} + +export interface SqlApiClientTyped { + query(query: string, clusterId: string): Promise; + getSchema(clusterId: string): Promise; +} + export class QuicknodeSdk { constructor(config: SdkFullConfig); static fromEnv(): QuicknodeSdk; @@ -370,6 +392,7 @@ export class QuicknodeSdk { streams: StreamsApiClientTyped; webhooks: WebhooksApiClientTyped; kvstore: _QuicknodeSdk["kvstore"]; + sql: SqlApiClientTyped; } // Typed static factory methods producing each discriminated variant of diff --git a/npm/sdk.js b/npm/sdk.js index 8ef76e1..670b312 100644 --- a/npm/sdk.js +++ b/npm/sdk.js @@ -15,6 +15,7 @@ class QuicknodeSdk { this.streams = errors.wrapClient(this._inner.streams); this.webhooks = errors.wrapClient(this._inner.webhooks); this.kvstore = errors.wrapClient(this._inner.kvstore); + this.sql = errors.wrapClient(this._inner.sql); } static fromEnv() { @@ -28,6 +29,7 @@ class QuicknodeSdk { instance.streams = errors.wrapClient(instance._inner.streams); instance.webhooks = errors.wrapClient(instance._inner.webhooks); instance.kvstore = errors.wrapClient(instance._inner.kvstore); + instance.sql = errors.wrapClient(instance._inner.sql); return instance; } } diff --git a/npm/sdk.mjs b/npm/sdk.mjs index c904b67..c3921aa 100644 --- a/npm/sdk.mjs +++ b/npm/sdk.mjs @@ -24,6 +24,7 @@ export const { StreamsApiClient, WebhooksApiClient, KvStoreApiClient, + SqlApiClient, QuicknodeError, ConfigError, HttpError, diff --git a/python/README.md b/python/README.md index 2be0dca..6dc00cf 100644 --- a/python/README.md +++ b/python/README.md @@ -46,6 +46,7 @@ This is one of four language bindings published from the same Rust core. See the - [KV Store Client](#kv-store-client) - [Sets](#sets) - [Lists](#lists) + - [SQL Client](#sql-client) - [Error Handling](#error-handling) - [License](#license) @@ -55,7 +56,7 @@ This is one of four language bindings published from the same Rust core. See the ## Quick Start -Construct the SDK once, then reach into the four sub-clients (`admin`, `streams`, `webhooks`, `kvstore`). Subsequent API Reference snippets assume you have a `qn` handle from one of these blocks. +Construct the SDK once, then reach into the five sub-clients (`admin`, `streams`, `webhooks`, `kvstore`, `sql`). Subsequent API Reference snippets assume you have a `qn` handle from one of these blocks. ```python # Python @@ -100,6 +101,7 @@ Environment variables (prefix `QN_SDK__`, separator `__`): | `QN_SDK__STREAMS__BASE_URL` | no | `https://api.quicknode.com/streams/rest/v1/` | Override streams base URL | | `QN_SDK__WEBHOOKS__BASE_URL` | no | `https://api.quicknode.com/webhooks/rest/v1/` | Override webhooks base URL | | `QN_SDK__KVSTORE__BASE_URL` | no | `https://api.quicknode.com/kv/rest/v1/` | Override KV store base URL | +| `QN_SDK__SQL__BASE_URL` | no | `https://api.quicknode.com/sql/rest/v1/` | Override SQL Explorer base URL | | `QN_SDK__HTTP__HEADERS__` | no | — | Custom HTTP header sent on every request. Overrides SDK-managed headers (see below). | ### Custom headers and `User-Agent` @@ -1606,6 +1608,43 @@ Deletes a list and all of its items. await qn.kvstore.delete_list("my-list") ``` +--- + +### SQL Client + +Accessed as `qn.sql`. Runs SQL queries against indexed blockchain data and fetches the database schema. Backed by `https://api.quicknode.com/sql/rest/v1/`. + +##### `query` + +Executes a SQL query against a cluster and returns the result set. Paginate by writing `LIMIT`/`OFFSET` into the SQL. + +**Parameters**: `query` (str, required), `cluster_id` (str, required). + +**Returns**: `QueryResponse` — `meta` (list of `ColumnMeta`, each with `name` and `column_type`), `data` (`list[dict]`, rows keyed by column name), `rows`, `rows_before_limit_at_least`, `statistics` (`QueryStatistics` with `elapsed`, `rows_read`, `bytes_read`), and `credits`. + +```python +# Python +resp = await qn.sql.query( + query="SELECT action_type, user FROM hyperliquid_system_actions ORDER BY block_time DESC LIMIT 100", + cluster_id="hyperliquid-core-mainnet", +) +print(resp.rows, resp.data[0]) +``` + +##### `get_schema` + +Fetches the database schema for a cluster: table names, columns, types, sort keys, and partition strategies. + +**Parameters**: `cluster_id` (str, required). + +**Returns**: `ChainSchema` — `chain`, `cluster_id`, and `tables` (list of `TableSchema`, each with `name`, `engine`, `total_rows`, `partition_key`, `sorting_key`, and `columns` of `ColumnSchema` with `name` and `column_type`). + +```python +# Python +schema = await qn.sql.get_schema("hyperliquid-core-mainnet") +print(len(schema.tables)) +``` + ## Error Handling Every binding exposes a typed exception hierarchy derived from the core `SdkError` diff --git a/python/examples/sql.py b/python/examples/sql.py new file mode 100644 index 0000000..28f0e3c --- /dev/null +++ b/python/examples/sql.py @@ -0,0 +1,42 @@ +import asyncio +from quicknode_sdk import QuicknodeSdk, ApiError + +CLUSTER_ID = "hyperliquid-core-mainnet" + + +async def main(): + qn = QuicknodeSdk.from_env() + + # Query + resp = await qn.sql.query( + query=( + "SELECT toDateTime(block_time) AS time, action_type, user " + "FROM hyperliquid_system_actions " + "ORDER BY block_time DESC LIMIT 3" + ), + cluster_id=CLUSTER_ID, + ) + print( + f"query: {resp.rows} rows ({resp.rows_before_limit_at_least} before limit), " + f"{resp.credits} credits, {resp.statistics.elapsed:.4f}s" + ) + print(f"columns: {[c.name for c in resp.meta]}") + if resp.data: + # data rows are native dicts keyed by column name + print(f"first row action_type: {resp.data[0]['action_type']}") + + # Schema + schema = await qn.sql.get_schema(CLUSTER_ID) + print(f"schema: {schema.chain} ({len(schema.tables)} tables)") + if schema.tables: + t = schema.tables[0] + print(f"first table: {t.name} ({len(t.columns)} columns, {t.total_rows} rows)") + + # Error handling: an empty query is rejected with a 403. + try: + await qn.sql.query(query="", cluster_id=CLUSTER_ID) + except ApiError as e: + print(f"api error {e.status}: {e.body[:120]}") + + +asyncio.run(main()) diff --git a/python/quicknode_sdk/__init__.py b/python/quicknode_sdk/__init__.py index e090fd6..7c2fdd9 100644 --- a/python/quicknode_sdk/__init__.py +++ b/python/quicknode_sdk/__init__.py @@ -109,6 +109,7 @@ AdminConfig, StreamsConfig, KvStoreConfig, + SqlConfig, SdkFullConfig, KvStoreApiClient, KvSetEntry, @@ -119,6 +120,13 @@ GetListData, GetListResponse, ListContainsItemResponse, + SqlApiClient, + QueryResponse, + ColumnMeta, + QueryStatistics, + ChainSchema, + TableSchema, + ColumnSchema, TeamUser, TeamSummary, TeamDetail, @@ -307,6 +315,7 @@ "HttpConfig", "AdminConfig", "KvStoreConfig", + "SqlConfig", "SdkFullConfig", "KvStoreApiClient", "KvSetEntry", @@ -317,6 +326,13 @@ "GetListData", "GetListResponse", "ListContainsItemResponse", + "SqlApiClient", + "QueryResponse", + "ColumnMeta", + "QueryStatistics", + "ChainSchema", + "TableSchema", + "ColumnSchema", "TeamUser", "TeamSummary", "TeamDetail", diff --git a/python/quicknode_sdk/__init__.pyi b/python/quicknode_sdk/__init__.pyi index 5d0558f..95768c8 100644 --- a/python/quicknode_sdk/__init__.pyi +++ b/python/quicknode_sdk/__init__.pyi @@ -111,6 +111,7 @@ from quicknode_sdk._core import ( AdminConfig, StreamsConfig, KvStoreConfig, + SqlConfig, SdkFullConfig, KvStoreApiClient, KvSetEntry, @@ -121,6 +122,13 @@ from quicknode_sdk._core import ( GetListData, GetListResponse, ListContainsItemResponse, + SqlApiClient, + QueryResponse, + ColumnMeta, + QueryStatistics, + ChainSchema, + TableSchema, + ColumnSchema, TeamUser, TeamSummary, TeamDetail, @@ -325,6 +333,7 @@ __all__ = [ "HttpConfig", "AdminConfig", "KvStoreConfig", + "SqlConfig", "SdkFullConfig", "KvStoreApiClient", "KvSetEntry", @@ -335,6 +344,13 @@ __all__ = [ "GetListData", "GetListResponse", "ListContainsItemResponse", + "SqlApiClient", + "QueryResponse", + "ColumnMeta", + "QueryStatistics", + "ChainSchema", + "TableSchema", + "ColumnSchema", "TeamUser", "TeamSummary", "TeamDetail", diff --git a/python/quicknode_sdk/_core/__init__.pyi b/python/quicknode_sdk/_core/__init__.pyi index 1a0260c..0c7825f 100644 --- a/python/quicknode_sdk/_core/__init__.pyi +++ b/python/quicknode_sdk/_core/__init__.pyi @@ -26,7 +26,10 @@ __all__ = [ "BulkUpdateEndpointStatusResponse", "Chain", "ChainNetwork", + "ChainSchema", "ChainUsage", + "ColumnMeta", + "ColumnSchema", "CreateDomainMaskRequest", "CreateEndpointRequest", "CreateEndpointResponse", @@ -142,6 +145,8 @@ __all__ = [ "Pagination", "Payment", "PostgresAttributes", + "QueryResponse", + "QueryStatistics", "QuicknodeSdk", "RateLimitEntry", "RateLimitSettings", @@ -160,6 +165,8 @@ __all__ = [ "SolanaWalletFilterByListArgs", "SolanaWalletFilterByListTemplate", "SolanaWalletFilterTemplate", + "SqlApiClient", + "SqlConfig", "StellarWalletTransactionsFilterArgs", "StellarWalletTransactionsFilterByListArgs", "StellarWalletTransactionsFilterByListTemplate", @@ -172,6 +179,7 @@ __all__ = [ "StreamWebhookDestination", "StreamsApiClient", "StreamsConfig", + "TableSchema", "TagUsage", "TeamDetail", "TeamEndpoint", @@ -1193,6 +1201,43 @@ class ChainNetwork: Numeric chain id, when applicable. """ +@typing.final +class ChainSchema: + r""" + Response from `get_schema`: the schema for a single chain/cluster. + """ + @property + def chain(self) -> builtins.str: + r""" + Human-readable chain name (e.g. `"Hyperliquid (HyperCore)"`). + """ + @chain.setter + def chain(self, value: builtins.str) -> None: + r""" + Human-readable chain name (e.g. `"Hyperliquid (HyperCore)"`). + """ + @property + def cluster_id(self) -> builtins.str: + r""" + Cluster identifier the schema belongs to. + """ + @cluster_id.setter + def cluster_id(self, value: builtins.str) -> None: + r""" + Cluster identifier the schema belongs to. + """ + @property + def tables(self) -> builtins.list[TableSchema]: + r""" + Tables available in this cluster. + """ + @tables.setter + def tables(self, value: builtins.list[TableSchema]) -> None: + r""" + Tables available in this cluster. + """ + def __new__(cls, chain: builtins.str, cluster_id: builtins.str, tables: typing.Sequence[TableSchema]) -> ChainSchema: ... + @typing.final class ChainUsage: r""" @@ -1219,6 +1264,60 @@ class ChainUsage: Credits consumed on the chain. """ +@typing.final +class ColumnMeta: + r""" + Metadata describing a single column in a query result set. + """ + @property + def name(self) -> builtins.str: + r""" + Column name as it appears in the result set. + """ + @name.setter + def name(self, value: builtins.str) -> None: + r""" + Column name as it appears in the result set. + """ + @property + def column_type(self) -> builtins.str: + r""" + Column data type (e.g. `"DateTime('UTC')"`, `"LowCardinality(String)"`). + """ + @column_type.setter + def column_type(self, value: builtins.str) -> None: + r""" + Column data type (e.g. `"DateTime('UTC')"`, `"LowCardinality(String)"`). + """ + def __new__(cls, name: builtins.str, column_type: builtins.str) -> ColumnMeta: ... + +@typing.final +class ColumnSchema: + r""" + A single column in a table schema. + """ + @property + def name(self) -> builtins.str: + r""" + Column name. + """ + @name.setter + def name(self, value: builtins.str) -> None: + r""" + Column name. + """ + @property + def column_type(self) -> builtins.str: + r""" + Column data type (e.g. `"UInt64"`, `"FixedString(42)"`). + """ + @column_type.setter + def column_type(self, value: builtins.str) -> None: + r""" + Column data type (e.g. `"UInt64"`, `"FixedString(42)"`). + """ + def __new__(cls, name: builtins.str, column_type: builtins.str) -> ColumnSchema: ... + @typing.final class CreateDomainMaskRequest: r""" @@ -4802,6 +4901,58 @@ class PostgresAttributes: """ def __new__(cls, host: builtins.str, port: builtins.int, database: builtins.str, username: builtins.str, password: builtins.str, table_name: builtins.str, sslmode: builtins.str, max_retry: builtins.int, retry_interval_sec: builtins.int) -> PostgresAttributes: ... +@typing.final +class QueryResponse: + @property + def meta(self) -> builtins.list[ColumnMeta]: ... + @property + def rows(self) -> builtins.int: ... + @property + def rows_before_limit_at_least(self) -> builtins.int: ... + @property + def statistics(self) -> QueryStatistics: ... + @property + def credits(self) -> builtins.int: ... + @property + def data(self) -> list[dict[str, typing.Any]]: ... + +@typing.final +class QueryStatistics: + r""" + Execution statistics returned alongside query results. + """ + @property + def elapsed(self) -> builtins.float: + r""" + Total query execution time in seconds. + """ + @elapsed.setter + def elapsed(self, value: builtins.float) -> None: + r""" + Total query execution time in seconds. + """ + @property + def rows_read(self) -> builtins.int: + r""" + Total number of rows scanned during execution. + """ + @rows_read.setter + def rows_read(self, value: builtins.int) -> None: + r""" + Total number of rows scanned during execution. + """ + @property + def bytes_read(self) -> builtins.int: + r""" + Total data scanned in bytes. + """ + @bytes_read.setter + def bytes_read(self, value: builtins.int) -> None: + r""" + Total data scanned in bytes. + """ + def __new__(cls, elapsed: builtins.float, rows_read: builtins.int, bytes_read: builtins.int) -> QueryStatistics: ... + @typing.final class QuicknodeSdk: @property @@ -4812,6 +4963,8 @@ class QuicknodeSdk: def webhooks(self) -> WebhooksApiClient: ... @property def kvstore(self) -> KvStoreApiClient: ... + @property + def sql(self) -> SqlApiClient: ... def __new__(cls, config: SdkFullConfig) -> QuicknodeSdk: r""" Creates a new SDK instance from an explicit configuration. @@ -5153,7 +5306,11 @@ class SdkFullConfig: def kvstore(self) -> typing.Optional[KvStoreConfig]: ... @kvstore.setter def kvstore(self, value: typing.Optional[KvStoreConfig]) -> None: ... - def __new__(cls, api_key: builtins.str, http: typing.Optional[HttpConfig] = None, admin: typing.Optional[AdminConfig] = None, streams: typing.Optional[StreamsConfig] = None, webhooks: typing.Optional[WebhooksConfig] = None, kvstore: typing.Optional[KvStoreConfig] = None) -> SdkFullConfig: ... + @property + def sql(self) -> typing.Optional[SqlConfig]: ... + @sql.setter + def sql(self, value: typing.Optional[SqlConfig]) -> None: ... + def __new__(cls, api_key: builtins.str, http: typing.Optional[HttpConfig] = None, admin: typing.Optional[AdminConfig] = None, streams: typing.Optional[StreamsConfig] = None, webhooks: typing.Optional[WebhooksConfig] = None, kvstore: typing.Optional[KvStoreConfig] = None, sql: typing.Optional[SqlConfig] = None) -> SdkFullConfig: ... @typing.final class SecurityOption: @@ -5477,6 +5634,27 @@ class SolanaWalletFilterTemplate: """ def __new__(cls, accounts: typing.Sequence[builtins.str]) -> SolanaWalletFilterTemplate: ... +@typing.final +class SqlApiClient: + def query(self, query: builtins.str, cluster_id: builtins.str) -> typing.Coroutine[typing.Any, typing.Any, QueryResponse]: + r""" + Executes a SQL query against the given cluster and returns the result + set. Only `SELECT` queries are permitted (enforced server-side). + """ + def get_schema(self, cluster_id: builtins.str) -> typing.Coroutine[typing.Any, typing.Any, ChainSchema]: + r""" + Fetches the database schema for a cluster, including table names, + columns, types, sort keys, and partition strategies. + """ + +@typing.final +class SqlConfig: + @property + def base_url(self) -> typing.Optional[builtins.str]: ... + @base_url.setter + def base_url(self, value: typing.Optional[builtins.str]) -> None: ... + def __new__(cls, base_url: typing.Optional[builtins.str] = None) -> SqlConfig: ... + @typing.final class StellarWalletTransactionsFilterArgs: @property @@ -5691,6 +5869,73 @@ class StreamsConfig: def base_url(self, value: typing.Optional[builtins.str]) -> None: ... def __new__(cls, base_url: typing.Optional[builtins.str] = None) -> StreamsConfig: ... +@typing.final +class TableSchema: + r""" + Schema for a single table. + """ + @property + def name(self) -> builtins.str: + r""" + Table name. + """ + @name.setter + def name(self, value: builtins.str) -> None: + r""" + Table name. + """ + @property + def engine(self) -> builtins.str: + r""" + Storage engine backing the table. + """ + @engine.setter + def engine(self, value: builtins.str) -> None: + r""" + Storage engine backing the table. + """ + @property + def total_rows(self) -> builtins.int: + r""" + Approximate total number of rows in the table. + """ + @total_rows.setter + def total_rows(self, value: builtins.int) -> None: + r""" + Approximate total number of rows in the table. + """ + @property + def partition_key(self) -> builtins.str: + r""" + Partition key expression; empty string for views. + """ + @partition_key.setter + def partition_key(self, value: builtins.str) -> None: + r""" + Partition key expression; empty string for views. + """ + @property + def sorting_key(self) -> builtins.list[builtins.str]: + r""" + Sorting key columns; empty for views. + """ + @sorting_key.setter + def sorting_key(self, value: builtins.list[builtins.str]) -> None: + r""" + Sorting key columns; empty for views. + """ + @property + def columns(self) -> builtins.list[ColumnSchema]: + r""" + Columns in the table. + """ + @columns.setter + def columns(self, value: builtins.list[ColumnSchema]) -> None: + r""" + Columns in the table. + """ + def __new__(cls, name: builtins.str, engine: builtins.str, total_rows: builtins.int, partition_key: builtins.str, sorting_key: typing.Sequence[builtins.str], columns: typing.Sequence[ColumnSchema]) -> TableSchema: ... + @typing.final class TagUsage: r""" diff --git a/python/quicknode_sdk/init_manual_override.pyi b/python/quicknode_sdk/init_manual_override.pyi index 5d0558f..95768c8 100644 --- a/python/quicknode_sdk/init_manual_override.pyi +++ b/python/quicknode_sdk/init_manual_override.pyi @@ -111,6 +111,7 @@ from quicknode_sdk._core import ( AdminConfig, StreamsConfig, KvStoreConfig, + SqlConfig, SdkFullConfig, KvStoreApiClient, KvSetEntry, @@ -121,6 +122,13 @@ from quicknode_sdk._core import ( GetListData, GetListResponse, ListContainsItemResponse, + SqlApiClient, + QueryResponse, + ColumnMeta, + QueryStatistics, + ChainSchema, + TableSchema, + ColumnSchema, TeamUser, TeamSummary, TeamDetail, @@ -325,6 +333,7 @@ __all__ = [ "HttpConfig", "AdminConfig", "KvStoreConfig", + "SqlConfig", "SdkFullConfig", "KvStoreApiClient", "KvSetEntry", @@ -335,6 +344,13 @@ __all__ = [ "GetListData", "GetListResponse", "ListContainsItemResponse", + "SqlApiClient", + "QueryResponse", + "ColumnMeta", + "QueryStatistics", + "ChainSchema", + "TableSchema", + "ColumnSchema", "TeamUser", "TeamSummary", "TeamDetail", diff --git a/ruby/README.md b/ruby/README.md index f804bc6..e5aec15 100644 --- a/ruby/README.md +++ b/ruby/README.md @@ -46,6 +46,7 @@ This is one of four language bindings published from the same Rust core. See the - [KV Store Client](#kv-store-client) - [Sets](#sets) - [Lists](#lists) + - [SQL Client](#sql-client) - [Error Handling](#error-handling) - [License](#license) @@ -55,7 +56,7 @@ This is one of four language bindings published from the same Rust core. See the ## Quick Start -Construct the SDK once, then reach into the four sub-clients (`admin`, `streams`, `webhooks`, `kvstore`). Subsequent API Reference snippets assume you have a `qn` handle from one of these blocks. +Construct the SDK once, then reach into the five sub-clients (`admin`, `streams`, `webhooks`, `kvstore`, `sql`). Subsequent API Reference snippets assume you have a `qn` handle from one of these blocks. ```ruby # Ruby @@ -94,6 +95,7 @@ Environment variables (prefix `QN_SDK__`, separator `__`): | `QN_SDK__STREAMS__BASE_URL` | no | `https://api.quicknode.com/streams/rest/v1/` | Override streams base URL | | `QN_SDK__WEBHOOKS__BASE_URL` | no | `https://api.quicknode.com/webhooks/rest/v1/` | Override webhooks base URL | | `QN_SDK__KVSTORE__BASE_URL` | no | `https://api.quicknode.com/kv/rest/v1/` | Override KV store base URL | +| `QN_SDK__SQL__BASE_URL` | no | `https://api.quicknode.com/sql/rest/v1/` | Override SQL Explorer base URL | | `QN_SDK__HTTP__HEADERS__` | no | — | Custom HTTP header sent on every request. Overrides SDK-managed headers (see below). | ### Custom headers and `User-Agent` @@ -1613,6 +1615,44 @@ Deletes a list and all of its items. qn.kvstore.delete_list(key: "my-list") ``` +--- + +### SQL Client + +Accessed as `qn.sql`. Runs SQL queries against indexed blockchain data and fetches the database schema. Backed by `https://api.quicknode.com/sql/rest/v1/`. + +##### `query` + +Executes a SQL query against a cluster and returns the result set. Paginate by writing `LIMIT`/`OFFSET` into the SQL. + +**Parameters** (Hash): `query:` (String, required), `cluster_id:` (String, required). + +**Returns** a Hash with `meta` (column metadata, each with `name` and `type`), `data` (rows as Hashes keyed by column name), `rows`, `rows_before_limit_at_least`, `statistics` (`elapsed`, `rows_read`, `bytes_read`), and `credits`. Access with `[]` or `dig`. + +```ruby +# Ruby +resp = qn.sql.query( + query: "SELECT action_type, user FROM hyperliquid_system_actions ORDER BY block_time DESC LIMIT 100", + cluster_id: "hyperliquid-core-mainnet" +) +puts resp[:rows] +puts resp[:data].first +``` + +##### `get_schema` + +Fetches the database schema for a cluster: table names, columns, types, sort keys, and partition strategies. + +**Parameters** (Hash): `cluster_id:` (String, required). + +**Returns** a Hash with `chain`, `cluster_id`, and `tables` (each with `name`, `engine`, `total_rows`, `partition_key`, `sorting_key`, and `columns` of `{ name, type }`). + +```ruby +# Ruby +schema = qn.sql.get_schema(cluster_id: "hyperliquid-core-mainnet") +puts schema[:tables].length +``` + ## Error Handling Every binding exposes a typed exception hierarchy derived from the core `SdkError` diff --git a/ruby/examples/sql.rb b/ruby/examples/sql.rb new file mode 100644 index 0000000..dd97a26 --- /dev/null +++ b/ruby/examples/sql.rb @@ -0,0 +1,34 @@ +require_relative "../lib/quicknode_sdk" + +CLUSTER_ID = "hyperliquid-core-mainnet" + +qn = QuicknodeSdk::SDK.from_env + +# Query +resp = qn.sql.query( + query: "SELECT toDateTime(block_time) AS time, action_type, user " \ + "FROM hyperliquid_system_actions " \ + "ORDER BY block_time DESC LIMIT 3", + cluster_id: CLUSTER_ID +) +stats = resp[:statistics] +puts "query: #{resp[:rows]} rows (#{resp[:rows_before_limit_at_least]} before limit), " \ + "#{resp[:credits]} credits, #{stats[:elapsed].round(4)}s" +puts "columns: #{resp[:meta].map { |c| c[:name] }.join(', ')}" +first_row = resp[:data].first +puts "first row action_type: #{first_row[:action_type]}" if first_row + +# Schema +schema = qn.sql.get_schema(cluster_id: CLUSTER_ID) +puts "schema: #{schema[:chain]} (#{schema[:tables].length} tables)" +table = schema[:tables].first +if table + puts "first table: #{table[:name]} (#{table[:columns].length} columns, #{table[:total_rows]} rows)" +end + +# Error handling: an empty query is rejected with a 403. +begin + qn.sql.query(query: "", cluster_id: CLUSTER_ID) +rescue QuicknodeSdk::ApiError => e + puts "api error #{e.status}: #{e.body[0, 120]}" +end diff --git a/ruby/lib/quicknode_sdk.rb b/ruby/lib/quicknode_sdk.rb index a26c6ea..4ec085e 100644 --- a/ruby/lib/quicknode_sdk.rb +++ b/ruby/lib/quicknode_sdk.rb @@ -13,4 +13,5 @@ require_relative "quicknode_sdk/clients/streams" require_relative "quicknode_sdk/clients/webhooks" require_relative "quicknode_sdk/clients/kvstore" +require_relative "quicknode_sdk/clients/sql" require_relative "quicknode_sdk/sdk" diff --git a/ruby/lib/quicknode_sdk/clients/sql.rb b/ruby/lib/quicknode_sdk/clients/sql.rb new file mode 100644 index 0000000..1a24a33 --- /dev/null +++ b/ruby/lib/quicknode_sdk/clients/sql.rb @@ -0,0 +1,4 @@ +module QuicknodeSdk + class Sql < NativeDelegator + end +end diff --git a/ruby/lib/quicknode_sdk/sdk.rb b/ruby/lib/quicknode_sdk/sdk.rb index 91a7cce..957d620 100644 --- a/ruby/lib/quicknode_sdk/sdk.rb +++ b/ruby/lib/quicknode_sdk/sdk.rb @@ -34,5 +34,9 @@ def webhooks def kvstore KvStore.new(@native.kvstore) end + + def sql + Sql.new(@native.sql) + end end end diff --git a/ruby/sig/quicknode_sdk.rbs b/ruby/sig/quicknode_sdk.rbs index 2db34fd..f245cb1 100644 --- a/ruby/sig/quicknode_sdk.rbs +++ b/ruby/sig/quicknode_sdk.rbs @@ -33,6 +33,7 @@ module QuicknodeSdk def streams: () -> Streams def webhooks: () -> Webhooks def kvstore: () -> KvStore + def sql: () -> Sql end class DestinationAttributes @@ -158,4 +159,11 @@ module QuicknodeSdk def delete_list_item: (key: String, item: String) -> void def delete_list: (key: String) -> void end + + class Sql + def initialize: (untyped native) -> void + + def query: (query: String, cluster_id: String) -> untyped + def get_schema: (cluster_id: String) -> untyped + end end