From 1827d977cda91dfd5469995b530d71e473aa7519 Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Wed, 24 Jun 2026 21:03:35 -0400 Subject: [PATCH] feat(sql): add SQL sub-client across all languages Adds a sql sub-client exposing query and get_schema against the public Quicknode SQL REST API. Query rows are returned as native dynamic objects keyed by column name in each language. Wired through core config (ResolvedSqlConfig), the four binding crates, the five READMEs, and the type stubs. --- Cargo.toml | 3 + crates/core/README.md | 44 +- crates/core/examples/admin_e2e.rs | 1 + crates/core/examples/sql.rs | 70 +++ crates/core/src/admin/mod.rs | 2 + crates/core/src/config.rs | 26 +- crates/core/src/kvstore/mod.rs | 1 + crates/core/src/lib.rs | 22 +- crates/core/src/sql/mod.rs | 466 ++++++++++++++++++ crates/core/src/streams/mod.rs | 1 + crates/core/src/webhooks/mod.rs | 1 + crates/node/src/lib.rs | 46 +- crates/node/src/sql.rs | 146 ++++++ crates/python/Cargo.toml | 1 + crates/python/src/lib.rs | 69 ++- crates/python/src/sql.rs | 59 +++ crates/ruby/src/lib.rs | 49 +- npm/README.md | 41 +- npm/examples/sql.ts | 45 ++ npm/index.d.ts | 145 ++++++ npm/index.js | 1 + npm/sdk.d.ts | 23 + npm/sdk.js | 2 + npm/sdk.mjs | 1 + python/README.md | 41 +- python/examples/sql.py | 42 ++ python/quicknode_sdk/__init__.py | 16 + python/quicknode_sdk/__init__.pyi | 16 + python/quicknode_sdk/_core/__init__.pyi | 247 +++++++++- python/quicknode_sdk/init_manual_override.pyi | 16 + ruby/README.md | 42 +- ruby/examples/sql.rb | 34 ++ ruby/lib/quicknode_sdk.rb | 1 + ruby/lib/quicknode_sdk/clients/sql.rb | 4 + ruby/lib/quicknode_sdk/sdk.rb | 4 + ruby/sig/quicknode_sdk.rbs | 8 + 36 files changed, 1725 insertions(+), 11 deletions(-) create mode 100644 crates/core/examples/sql.rs create mode 100644 crates/core/src/sql/mod.rs create mode 100644 crates/node/src/sql.rs create mode 100644 crates/python/src/sql.rs create mode 100644 npm/examples/sql.ts create mode 100644 python/examples/sql.py create mode 100644 ruby/examples/sql.rb create mode 100644 ruby/lib/quicknode_sdk/clients/sql.rb 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