Skip to main content

quicknode_sdk/sql/
mod.rs

1#[cfg(feature = "rust")]
2use bon::Builder;
3#[cfg(feature = "node")]
4use napi_derive::napi;
5#[cfg(feature = "python")]
6use pyo3::{pyclass, pymethods};
7#[cfg(feature = "python")]
8use pyo3_stub_gen::derive::{gen_stub_pyclass, gen_stub_pymethods};
9use serde::{Deserialize, Serialize};
10
11use crate::{config::SqlConfig, errors::SdkError, SdkConfig};
12
13const SQL_BASE_URL: &str = "https://api.quicknode.com/sql/rest/v1/";
14
15// ── Resolved config ────────────────────────────────────────────────────────
16
17pub(crate) struct ResolvedSqlConfig {
18    pub(crate) base_url: reqwest::Url,
19}
20
21impl ResolvedSqlConfig {
22    pub(crate) fn from_config(config: Option<&SqlConfig>) -> Result<Self, SdkError> {
23        let url_str = config
24            .and_then(|s| s.base_url.as_deref())
25            .unwrap_or(SQL_BASE_URL);
26        let mut base_url =
27            reqwest::Url::parse(url_str).map_err(|e| SdkError::Config(e.to_string()))?;
28        if !base_url.path().ends_with('/') {
29            base_url.set_path(&format!("{}/", base_url.path()));
30        }
31        Ok(Self { base_url })
32    }
33}
34
35// ── Request types ──────────────────────────────────────────────────────────
36
37/// Parameters for `query`.
38#[cfg_attr(feature = "rust", derive(Builder))]
39#[cfg_attr(feature = "node", napi(object))]
40#[cfg_attr(not(feature = "node"), derive(Clone))]
41#[derive(Debug, Serialize, Deserialize)]
42pub struct QueryParams {
43    /// The SQL query to execute. Pagination is expressed in the SQL itself via
44    /// `LIMIT`/`OFFSET`; the API caps results at 1000 rows per request.
45    pub query: String,
46    /// The blockchain network identifier (e.g. `"hyperliquid-core-mainnet"`).
47    // The request body uses camelCase `clusterId`, unlike the schema response
48    // which returns snake_case `cluster_id`.
49    #[serde(rename = "clusterId")]
50    pub cluster_id: String,
51}
52
53// ── Query response types ───────────────────────────────────────────────────
54
55/// Metadata describing a single column in a query result set.
56#[cfg_attr(feature = "python", gen_stub_pyclass)]
57#[cfg_attr(feature = "python", pyclass(get_all, set_all))]
58#[cfg_attr(feature = "node", napi(object))]
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct ColumnMeta {
61    /// Column name as it appears in the result set.
62    pub name: String,
63    /// Column data type (e.g. `"DateTime('UTC')"`, `"LowCardinality(String)"`).
64    // Field is `column_type` in Rust because `type` is a keyword; serde and the
65    // Node binding rename it to `type` on their respective surfaces. Using a raw
66    // `r#type` ident instead breaks pyo3 stub generation, so the Python surface
67    // exposes this as `column_type`.
68    #[serde(rename = "type")]
69    pub column_type: String,
70}
71
72#[cfg(feature = "python")]
73#[gen_stub_pymethods]
74#[pymethods]
75impl ColumnMeta {
76    #[new]
77    pub fn new(name: String, column_type: String) -> Self {
78        Self { name, column_type }
79    }
80}
81
82/// Execution statistics returned alongside query results.
83#[cfg_attr(feature = "python", gen_stub_pyclass)]
84#[cfg_attr(feature = "python", pyclass(get_all, set_all))]
85#[cfg_attr(feature = "node", napi(object))]
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct QueryStatistics {
88    /// Total query execution time in seconds.
89    pub elapsed: f64,
90    /// Total number of rows scanned during execution.
91    pub rows_read: i64,
92    /// Total data scanned in bytes.
93    pub bytes_read: i64,
94}
95
96#[cfg(feature = "python")]
97#[gen_stub_pymethods]
98#[pymethods]
99impl QueryStatistics {
100    #[new]
101    pub fn new(elapsed: f64, rows_read: i64, bytes_read: i64) -> Self {
102        Self {
103            elapsed,
104            rows_read,
105            bytes_read,
106        }
107    }
108}
109
110/// Response from `query`.
111//
112// Holds `serde_json::Value` rows whose columns depend on the SQL query, so this
113// type cannot derive `#[pyclass]`/`#[napi(object)]`. It stays pure-Rust in core;
114// each binding wraps it and exposes `data` as the language's native dynamic type
115// (Python via `pythonize`, Node via napi's `serde_json::Value` support, Ruby via
116// `serde_magnus`). Mirrors the `DestinationAttributes` wrapping pattern.
117#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct QueryResponse {
119    /// Column metadata for each column in the result set.
120    pub meta: Vec<ColumnMeta>,
121    /// Result rows. Each row is a JSON object whose keys are the selected
122    /// columns; shape varies per query.
123    pub data: Vec<serde_json::Value>,
124    /// Number of rows returned in this response.
125    pub rows: i64,
126    /// Total rows that matched the query before applying `LIMIT`; use for
127    /// pagination.
128    pub rows_before_limit_at_least: i64,
129    /// Query execution statistics.
130    pub statistics: QueryStatistics,
131    /// Credits consumed by the query.
132    pub credits: i64,
133}
134
135// ── Schema response types ──────────────────────────────────────────────────
136
137/// A single column in a table schema.
138#[cfg_attr(feature = "python", gen_stub_pyclass)]
139#[cfg_attr(feature = "python", pyclass(get_all, set_all))]
140#[cfg_attr(feature = "node", napi(object))]
141#[derive(Debug, Clone, Serialize, Deserialize)]
142pub struct ColumnSchema {
143    /// Column name.
144    pub name: String,
145    /// Column data type (e.g. `"UInt64"`, `"FixedString(42)"`).
146    // See `ColumnMeta::column_type` for why this is not a raw `r#type` ident.
147    #[serde(rename = "type")]
148    pub column_type: String,
149}
150
151#[cfg(feature = "python")]
152#[gen_stub_pymethods]
153#[pymethods]
154impl ColumnSchema {
155    #[new]
156    pub fn new(name: String, column_type: String) -> Self {
157        Self { name, column_type }
158    }
159}
160
161/// Schema for a single table.
162#[cfg_attr(feature = "python", gen_stub_pyclass)]
163#[cfg_attr(feature = "python", pyclass(get_all, set_all))]
164#[cfg_attr(feature = "node", napi(object))]
165#[derive(Debug, Clone, Serialize, Deserialize)]
166pub struct TableSchema {
167    /// Table name.
168    pub name: String,
169    /// Storage engine backing the table.
170    pub engine: String,
171    /// Approximate total number of rows in the table.
172    pub total_rows: i64,
173    /// Partition key expression; empty string for views.
174    pub partition_key: String,
175    /// Sorting key columns; empty for views.
176    pub sorting_key: Vec<String>,
177    /// Columns in the table.
178    pub columns: Vec<ColumnSchema>,
179}
180
181#[cfg(feature = "python")]
182#[gen_stub_pymethods]
183#[pymethods]
184impl TableSchema {
185    #[new]
186    pub fn new(
187        name: String,
188        engine: String,
189        total_rows: i64,
190        partition_key: String,
191        sorting_key: Vec<String>,
192        columns: Vec<ColumnSchema>,
193    ) -> Self {
194        Self {
195            name,
196            engine,
197            total_rows,
198            partition_key,
199            sorting_key,
200            columns,
201        }
202    }
203}
204
205/// Response from `get_schema`: the schema for a single chain/cluster.
206#[cfg_attr(feature = "python", gen_stub_pyclass)]
207#[cfg_attr(feature = "python", pyclass(get_all, set_all))]
208#[cfg_attr(feature = "node", napi(object))]
209#[derive(Debug, Clone, Serialize, Deserialize)]
210pub struct ChainSchema {
211    /// Human-readable chain name (e.g. `"Hyperliquid (HyperCore)"`).
212    pub chain: String,
213    /// Cluster identifier the schema belongs to.
214    pub cluster_id: String,
215    /// Tables available in this cluster.
216    pub tables: Vec<TableSchema>,
217}
218
219#[cfg(feature = "python")]
220#[gen_stub_pymethods]
221#[pymethods]
222impl ChainSchema {
223    #[new]
224    pub fn new(chain: String, cluster_id: String, tables: Vec<TableSchema>) -> Self {
225        Self {
226            chain,
227            cluster_id,
228            tables,
229        }
230    }
231}
232
233// ── Client ─────────────────────────────────────────────────────────────────
234
235/// Client for the Quicknode SQL Explorer. Executes SQL queries against indexed
236/// blockchain data and fetches the database schema.
237#[derive(Debug, Clone)]
238pub struct SqlApiClient {
239    config: SdkConfig,
240}
241
242impl SqlApiClient {
243    pub fn new(config: SdkConfig) -> Self {
244        Self { config }
245    }
246
247    /// Executes a SQL query against the given cluster and returns the result
248    /// set.
249    pub async fn query(&self, params: &QueryParams) -> Result<QueryResponse, SdkError> {
250        let url = self.config.sql().base_url.join("query")?;
251        let resp = self
252            .config
253            .http_client()
254            .post(url)
255            .json(params)
256            .send()
257            .await
258            .map_err(SdkError::Http)?;
259        let status = resp.status();
260        let body = resp.text().await.map_err(SdkError::Http)?;
261        if !status.is_success() {
262            return Err(SdkError::Api { status, body });
263        }
264        serde_json::from_str(&body).map_err(|source| SdkError::Decode { source, body })
265    }
266
267    /// Fetches the database schema for a cluster, including table names,
268    /// columns, types, sort keys, and partition strategies.
269    pub async fn get_schema(&self, cluster_id: &str) -> Result<ChainSchema, SdkError> {
270        let url = self
271            .config
272            .sql()
273            .base_url
274            .join(&format!("schema/{cluster_id}"))?;
275        let resp = self
276            .config
277            .http_client()
278            .get(url)
279            .send()
280            .await
281            .map_err(SdkError::Http)?;
282        let status = resp.status();
283        let body = resp.text().await.map_err(SdkError::Http)?;
284        if !status.is_success() {
285            return Err(SdkError::Api { status, body });
286        }
287        serde_json::from_str(&body).map_err(|source| SdkError::Decode { source, body })
288    }
289}
290
291// ── Tests ──────────────────────────────────────────────────────────────────
292
293#[cfg(test)]
294#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
295mod tests {
296    use super::*;
297    use crate::{QuicknodeSdk, SdkFullConfig, SqlConfig};
298    use wiremock::matchers::{body_json, method, path};
299    use wiremock::{Mock, MockServer, ResponseTemplate};
300
301    fn make_sdk(base_url: String) -> QuicknodeSdk {
302        QuicknodeSdk::new(&SdkFullConfig {
303            api_key: "test-key".to_string(),
304            http: None,
305            admin: None,
306            streams: None,
307            webhooks: None,
308            kvstore: None,
309            sql: Some(SqlConfig {
310                base_url: Some(base_url),
311            }),
312            rpc: None,
313        })
314        .unwrap()
315    }
316
317    fn query_params() -> QueryParams {
318        QueryParams {
319            query: "SELECT 1".to_string(),
320            cluster_id: "hyperliquid-core-mainnet".to_string(),
321        }
322    }
323
324    // ── query ──────────────────────────────────────────────────────────────
325
326    #[tokio::test]
327    async fn query_success() {
328        let server = MockServer::start().await;
329        Mock::given(method("POST"))
330            .and(path("/query"))
331            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
332                "meta": [
333                    {"name": "time", "type": "DateTime('UTC')"},
334                    {"name": "action_type", "type": "LowCardinality(String)"}
335                ],
336                "data": [
337                    {"time": "2026-06-24 19:43:44", "action_type": "SystemSpotSendAction"},
338                    {"time": "2026-06-24 19:43:42", "action_type": "SystemSendAssetAction"}
339                ],
340                "rows": 2,
341                "rows_before_limit_at_least": 18251,
342                "statistics": {"elapsed": 0.0067, "rows_read": 31341, "bytes_read": 1247178},
343                "credits": 135
344            })))
345            .mount(&server)
346            .await;
347        let sdk = make_sdk(format!("{}/", server.uri()));
348        let resp = sdk.sql.query(&query_params()).await.unwrap();
349        assert_eq!(resp.rows, 2);
350        assert_eq!(resp.rows_before_limit_at_least, 18251);
351        assert_eq!(resp.credits, 135);
352        assert_eq!(resp.meta.len(), 2);
353        assert_eq!(resp.meta[0].name, "time");
354        assert_eq!(resp.statistics.rows_read, 31341);
355        // Dynamic row: confirm a value reads through.
356        assert_eq!(resp.data.len(), 2);
357        assert_eq!(resp.data[0]["action_type"], "SystemSpotSendAction");
358    }
359
360    // Wire-inspection regression: confirm the request body sends `clusterId`
361    // (camelCase) so a future serde rename of `cluster_id` fails loudly.
362    #[tokio::test]
363    async fn query_wire_body_cluster_id() {
364        let server = MockServer::start().await;
365        Mock::given(method("POST"))
366            .and(path("/query"))
367            .and(body_json(serde_json::json!({
368                "query": "SELECT 1",
369                "clusterId": "hyperliquid-core-mainnet"
370            })))
371            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
372                "meta": [],
373                "data": [],
374                "rows": 0,
375                "rows_before_limit_at_least": 0,
376                "statistics": {"elapsed": 0.001, "rows_read": 0, "bytes_read": 0},
377                "credits": 1
378            })))
379            .mount(&server)
380            .await;
381        let sdk = make_sdk(format!("{}/", server.uri()));
382        sdk.sql.query(&query_params()).await.unwrap();
383    }
384
385    #[tokio::test]
386    async fn query_api_error() {
387        let server = MockServer::start().await;
388        Mock::given(method("POST"))
389            .and(path("/query"))
390            .respond_with(ResponseTemplate::new(403).set_body_json(
391                serde_json::json!({"statusCode": 403, "message": "only SELECT queries are allowed"}),
392            ))
393            .mount(&server)
394            .await;
395        let sdk = make_sdk(format!("{}/", server.uri()));
396        let err = sdk.sql.query(&query_params()).await.unwrap_err();
397        assert!(matches!(err, SdkError::Api { .. }));
398    }
399
400    #[tokio::test]
401    async fn query_decode_error() {
402        let server = MockServer::start().await;
403        Mock::given(method("POST"))
404            .and(path("/query"))
405            .respond_with(ResponseTemplate::new(200).set_body_string("not json"))
406            .mount(&server)
407            .await;
408        let sdk = make_sdk(format!("{}/", server.uri()));
409        let err = sdk.sql.query(&query_params()).await.unwrap_err();
410        assert!(matches!(err, SdkError::Decode { .. }));
411    }
412
413    // ── get_schema ───────────────────────────────────────────────────────────
414
415    #[tokio::test]
416    async fn get_schema_success() {
417        let server = MockServer::start().await;
418        Mock::given(method("GET"))
419            .and(path("/schema/hyperliquid-core-mainnet"))
420            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
421                "chain": "Hyperliquid (HyperCore)",
422                "cluster_id": "hyperliquid-core-mainnet",
423                "tables": [
424                    {
425                        "name": "hyperliquid_agents",
426                        "engine": "SharedReplacingMergeTree",
427                        "total_rows": 3322574607i64,
428                        "partition_key": "toYYYYMM(snapshot_time)",
429                        "sorting_key": ["block_number", "agent"],
430                        "columns": [
431                            {"name": "agent", "type": "FixedString(42)"},
432                            {"name": "block_number", "type": "UInt64"}
433                        ]
434                    }
435                ]
436            })))
437            .mount(&server)
438            .await;
439        let sdk = make_sdk(format!("{}/", server.uri()));
440        let resp = sdk
441            .sql
442            .get_schema("hyperliquid-core-mainnet")
443            .await
444            .unwrap();
445        assert_eq!(resp.cluster_id, "hyperliquid-core-mainnet");
446        assert_eq!(resp.tables.len(), 1);
447        let table = &resp.tables[0];
448        assert_eq!(table.name, "hyperliquid_agents");
449        assert_eq!(table.total_rows, 3322574607);
450        assert_eq!(table.sorting_key, vec!["block_number", "agent"]);
451        assert_eq!(table.columns[0].name, "agent");
452        assert_eq!(table.columns[0].column_type, "FixedString(42)");
453    }
454
455    #[tokio::test]
456    async fn get_schema_api_error() {
457        let server = MockServer::start().await;
458        Mock::given(method("GET"))
459            .and(path("/schema/bad-cluster"))
460            .respond_with(ResponseTemplate::new(404).set_body_string("Not Found"))
461            .mount(&server)
462            .await;
463        let sdk = make_sdk(format!("{}/", server.uri()));
464        let err = sdk.sql.get_schema("bad-cluster").await.unwrap_err();
465        assert!(matches!(err, SdkError::Api { .. }));
466    }
467}