#[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/";
pub(crate) struct ResolvedSqlConfig {
pub(crate) base_url: reqwest::Url,
}
impl ResolvedSqlConfig {
pub(crate) fn from_config(config: Option<&SqlConfig>) -> Result<Self, SdkError> {
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 })
}
}
#[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 {
pub query: String,
#[serde(rename = "clusterId")]
pub cluster_id: String,
}
#[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 {
pub name: String,
#[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 }
}
}
#[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 {
pub elapsed: f64,
pub rows_read: i64,
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,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QueryResponse {
pub meta: Vec<ColumnMeta>,
pub data: Vec<serde_json::Value>,
pub rows: i64,
pub rows_before_limit_at_least: i64,
pub statistics: QueryStatistics,
pub credits: i64,
}
#[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 {
pub name: String,
#[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 }
}
}
#[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 {
pub name: String,
pub engine: String,
pub total_rows: i64,
pub partition_key: String,
pub sorting_key: Vec<String>,
pub columns: Vec<ColumnSchema>,
}
#[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<String>,
columns: Vec<ColumnSchema>,
) -> Self {
Self {
name,
engine,
total_rows,
partition_key,
sorting_key,
columns,
}
}
}
#[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 {
pub chain: String,
pub cluster_id: String,
pub tables: Vec<TableSchema>,
}
#[cfg(feature = "python")]
#[gen_stub_pymethods]
#[pymethods]
impl ChainSchema {
#[new]
pub fn new(chain: String, cluster_id: String, tables: Vec<TableSchema>) -> Self {
Self {
chain,
cluster_id,
tables,
}
}
}
#[derive(Debug, Clone)]
pub struct SqlApiClient {
config: SdkConfig,
}
impl SqlApiClient {
pub fn new(config: SdkConfig) -> Self {
Self { config }
}
pub async fn query(&self, params: &QueryParams) -> Result<QueryResponse, SdkError> {
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 })
}
pub async fn get_schema(&self, cluster_id: &str) -> Result<ChainSchema, SdkError> {
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 })
}
}
#[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: Some("test-key".to_string()),
http: None,
admin: None,
streams: None,
webhooks: None,
kvstore: None,
sql: Some(SqlConfig {
base_url: Some(base_url),
}),
rpc: None,
})
.unwrap()
}
fn query_params() -> QueryParams {
QueryParams {
query: "SELECT 1".to_string(),
cluster_id: "hyperliquid-core-mainnet".to_string(),
}
}
#[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);
assert_eq!(resp.data.len(), 2);
assert_eq!(resp.data[0]["action_type"], "SystemSpotSendAction");
}
#[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 { .. }));
}
#[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 { .. }));
}
}