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
15pub(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#[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 pub query: String,
46 #[serde(rename = "clusterId")]
50 pub cluster_id: String,
51}
52
53#[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 pub name: String,
63 #[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#[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 pub elapsed: f64,
90 pub rows_read: i64,
92 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#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct QueryResponse {
119 pub meta: Vec<ColumnMeta>,
121 pub data: Vec<serde_json::Value>,
124 pub rows: i64,
126 pub rows_before_limit_at_least: i64,
129 pub statistics: QueryStatistics,
131 pub credits: i64,
133}
134
135#[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 pub name: String,
145 #[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#[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 pub name: String,
169 pub engine: String,
171 pub total_rows: i64,
173 pub partition_key: String,
175 pub sorting_key: Vec<String>,
177 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#[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 pub chain: String,
213 pub cluster_id: String,
215 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#[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 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 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#[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: Some("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 #[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 assert_eq!(resp.data.len(), 2);
357 assert_eq!(resp.data[0]["action_type"], "SystemSpotSendAction");
358 }
359
360 #[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 #[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}