Skip to main content

helix_db/
lib.rs

1//! # helix-db Rust SDK
2//!
3//! The `helix-db` crate (imported as `helix_db`) is the Rust SDK for
4//! [HelixDB](https://github.com/helixdb/helix-db). It pairs a query-builder DSL
5//! with a small async HTTP client ([`Client`]) for running those queries
6//! against a Helix instance.
7//!
8//! ## Crate layout
9//!
10//! - [`dsl`] — the query-builder DSL: traversals, predicates, batches, and the
11//!   [`DynamicQueryRequest`] payload type. This is the bulk of the public API.
12//! - [`query_generator`] — query-bundle generation, used to emit deployable
13//!   stored queries from `#[register]`-annotated builders.
14//! - The crate root ([`Client`], [`QueryBuilder`], [`QueryRequest`],
15//!   [`HelixError`]) — the async execution surface that sends DSL queries over
16//!   HTTP.
17//!
18//! ## The DSL
19//!
20//! The DSL is centered on two entry points — [`read_batch`] for read-only
21//! transactions and [`write_batch`] for write-capable ones. You attach one or
22//! more named traversals (each usually starting with [`g`]) via `.var_as(...)`,
23//! then choose the result payload with `.returning(...)`:
24//!
25//! ```
26//! use helix_db::dsl::prelude::*;
27//!
28//! let query = read_batch()
29//!     .var_as(
30//!         "user",
31//!         g().n_where(SourcePredicate::eq("username", "alice")),
32//!     )
33//!     .var_as(
34//!         "friends",
35//!         g().n(NodeRef::var("user")).out(Some("FOLLOWS")).dedup().limit(100),
36//!     )
37//!     .returning(["user", "friends"]);
38//! # let _ = query;
39//! ```
40//!
41//! Most application code only needs this curated builder API, so bring the
42//! prelude into scope:
43//!
44//! ```
45//! use helix_db::dsl::prelude::*;
46//! ```
47//!
48//! ## Running queries
49//!
50//! Build a [`Client`], then use its fluent request builder. Pick a query kind —
51//! [`dynamic`](QueryBuilder::dynamic) to POST an inline [`DynamicQueryRequest`]
52//! to `/v1/query`, or [`stored`](QueryBuilder::stored) to call a deployed query
53//! at `/v1/query/<name>` — and `await` the response:
54//!
55//! ```no_run
56//! use helix_db::Client;
57//! use helix_db::dsl::prelude::*;
58//! use serde::Deserialize;
59//!
60//! #[derive(Deserialize)]
61//! struct Friends { friends: Vec<u64> }
62//!
63//! # async fn run(request: DynamicQueryRequest) -> Result<(), helix_db::HelixError> {
64//! let client = Client::new(Some("https://cluster.helix-db.com"))?
65//!     .with_api_key(Some("hx_your_api_key"));
66//!
67//! let response: Friends = client.query().dynamic(request).send().await?;
68//! # let _ = response.friends;
69//! # Ok(())
70//! # }
71//! ```
72//!
73//! See [`Client`] for the full request-building surface (header toggles,
74//! request bodies, error handling).
75
76pub mod dsl;
77pub mod query_generator;
78
79use std::marker::PhantomData;
80
81// Re-export the DSL surface (types, builders, `prelude`, etc.) at the crate
82// root. This is also what makes the `crate::*` paths used inside `dsl.rs` and
83// `query_generator.rs` resolve.
84pub use dsl::*;
85
86// Convenience re-export so `helix_db::prelude::*` is reachable directly, in
87// addition to the canonical `helix_db::dsl::prelude::*`.
88pub use dsl::prelude;
89
90use reqwest::{Client as ReqwestClient, StatusCode};
91use serde::{Deserialize, Serialize};
92use thiserror::Error;
93
94/// Async HTTP client for running queries against a Helix instance.
95///
96/// A thin async wrapper over [`reqwest`] that knows how to reach a Helix
97/// gateway's query routes. Construct it with [`Client::new`], optionally attach
98/// a bearer API key via [`Client::with_api_key`], then build and send requests
99/// through [`Client::query`].
100///
101/// The client is cheap to [`Clone`] — the underlying `reqwest::Client` shares
102/// its connection pool — so a single instance can be reused across tasks.
103///
104/// Reachable as `helix_db::Client`.
105///
106/// # Examples
107///
108/// ```no_run
109/// use helix_db::Client;
110///
111/// # fn run() -> Result<(), helix_db::HelixError> {
112/// // Defaults to http://localhost:6969 when the URL is `None`.
113/// let local = Client::new(None)?;
114///
115/// // Or point at a remote cluster and attach an API key.
116/// let remote = Client::new(Some("https://cluster.helix-db.com"))?
117///     .with_api_key(Some("hx_your_api_key"));
118/// # let _ = (local, remote);
119/// # Ok(())
120/// # }
121/// ```
122#[derive(Debug, Clone)]
123pub struct Client {
124    client: ReqwestClient,
125    url: reqwest::Url,
126    api_key: Option<String>,
127}
128
129/// Backwards-compatible alias for [`Client`].
130pub type HelixDBClient = Client;
131
132/// Errors returned while building or executing a query request.
133#[derive(Debug, Error)]
134pub enum HelixError {
135    /// Transport-level failure talking to the server (connection refused,
136    /// timeout, TLS error, …), surfaced from [`reqwest`].
137    #[error("Error communicating with server: {0}")]
138    ReqwestError(#[from] reqwest::Error),
139    /// The server responded with a non-`200` status. `details` carries the
140    /// response body, or the status' canonical reason phrase when no body is
141    /// available.
142    #[error("Got Error from server: {details}")]
143    RemoteError {
144        /// Server-provided error text, or a fallback description of the status.
145        details: String,
146    },
147    /// Failed to (de)serialize a request body or response payload.
148    #[error("Error serializing data: {0}")]
149    SerializationError(#[from] sonic_rs::Error),
150    /// The base URL passed to [`Client::new`] could not be parsed, or the
151    /// resolved query route was not a valid URL.
152    #[error("Invalid URL: {0}")]
153    InvalidURL(String),
154}
155
156impl Client {
157    /// Create a client pointed at a Helix instance.
158    ///
159    /// `url` is the instance base URL; when `None`, it defaults to
160    /// `http://localhost:6969`. The `/v1/query` base route is resolved up front
161    /// and reused by every request — dynamic queries POST to it directly and
162    /// stored queries append `/<name>`.
163    ///
164    /// # Errors
165    ///
166    /// Returns [`HelixError::InvalidURL`] if `url` (or the resolved query route)
167    /// cannot be parsed.
168    pub fn new(url: Option<&str>) -> Result<Self, HelixError> {
169        // Resolve the base query endpoint up front. `send()` reuses this for
170        // dynamic queries and appends `/<name>` for stored queries.
171        let url = reqwest::Url::parse(url.unwrap_or("http://localhost:6969"))
172            .map_err(|e| HelixError::InvalidURL(e.to_string()))?
173            .join("/v1/query")
174            .map_err(|e| HelixError::InvalidURL(e.to_string()))?;
175        Ok(Self {
176            client: ReqwestClient::new(),
177            url,
178            api_key: None,
179        })
180    }
181
182    /// Attach (or clear) the bearer API key sent with every request.
183    ///
184    /// Passing `Some(key)` sets an `Authorization: Bearer <key>` header on each
185    /// request; passing `None` clears any previously set key.
186    pub fn with_api_key(mut self, api_key: Option<&str>) -> Self {
187        self.api_key = api_key.map(|key| key.to_string());
188        self
189    }
190
191    /// Start building a request.
192    ///
193    /// `R` is the type the JSON response body is deserialized into by
194    /// [`QueryRequest::send`]. Returns a [`QueryBuilder`] on which you can toggle
195    /// request headers, then pick a query kind with [`QueryBuilder::dynamic`] or
196    /// [`QueryBuilder::stored`].
197    ///
198    /// # Examples
199    ///
200    /// ```no_run
201    /// use helix_db::Client;
202    /// use helix_db::dsl::prelude::*;
203    /// use serde::Deserialize;
204    ///
205    /// #[derive(Deserialize)]
206    /// struct Users { count: u64 }
207    ///
208    /// # async fn run(client: &Client, request: DynamicQueryRequest) -> Result<(), helix_db::HelixError> {
209    /// let response: Users = client.query().dynamic(request).send().await?;
210    /// # let _ = response;
211    /// # Ok(())
212    /// # }
213    /// ```
214    pub fn query<R: for<'de> Deserialize<'de>>(&self) -> QueryBuilder<'_, '_, R> {
215        QueryBuilder::new(self)
216    }
217}
218
219/// Fluent builder for a single request, produced by [`Client::query`].
220///
221/// Optional header toggles ([`writer_only`](Self::writer_only),
222/// [`warm_only`](Self::warm_only),
223/// [`should_await_durability`](Self::should_await_durability)) and an optional
224/// [`body`](Self::body) can be chained, then exactly one query kind is selected
225/// with [`stored`](Self::stored) or [`dynamic`](Self::dynamic) — both of which
226/// transition to a [`QueryRequest`] ready to [`send`](QueryRequest::send).
227///
228/// `R` is the response deserialization target carried through to `send()`.
229pub struct QueryBuilder<'hlx, 'a, R> {
230    client: &'hlx HelixDBClient,
231    query_type: QueryType,
232    headers: [Option<(&'a str, &'a str)>; 4],
233    body: Option<Vec<u8>>,
234    _phantom: PhantomData<R>,
235}
236
237/// Which Helix query route a [`QueryBuilder`] targets.
238///
239/// Set internally by [`QueryBuilder::stored`] / [`QueryBuilder::dynamic`]; the
240/// [`Empty`](QueryType::Empty) default is never observed by `send()` because
241/// reaching it requires picking a query kind first.
242#[derive(Default)]
243pub(crate) enum QueryType {
244    /// A deployed stored query, posted to `/v1/query/<name>`.
245    Stored(String),
246    /// An inline dynamic query, posted to `/v1/query`.
247    Dynamic(DynamicQueryRequest),
248    /// No query kind chosen yet (builder default).
249    #[default]
250    Empty,
251}
252
253impl<'hlx, 'a, R> QueryBuilder<'hlx, 'a, R> {
254    /// Create a builder seeded with the `Content-Type: application/json` header.
255    ///
256    /// Prefer [`Client::query`], which calls this for you.
257    #[must_use]
258    pub fn new(client: &'hlx HelixDBClient) -> Self {
259        let mut headers = [None; 4];
260        headers[0] = Some(("Content-Type", "application/json"));
261        Self {
262            client,
263            query_type: QueryType::default(),
264            headers,
265            body: None,
266            _phantom: PhantomData,
267        }
268    }
269
270    /// Require the request to be served by a writer node.
271    ///
272    /// Sets the `x-helix-require-writer` header.
273    #[must_use]
274    pub fn writer_only(mut self) -> Self {
275        self.headers[1] = Some(("x-helix-require-writer", "true"));
276        self
277    }
278
279    /// Only execute if the query is already warm (reads only).
280    ///
281    /// Sets the `x-helix-warm` header.
282    #[must_use]
283    pub fn warm_only(mut self) -> Self {
284        self.headers[2] = Some(("x-helix-warm", "true"));
285        self
286    }
287
288    /// Choose whether a write request blocks until the write is durable.
289    ///
290    /// Sets the `x-helix-await-durable` header to `"true"` or `"false"`.
291    #[must_use]
292    pub fn should_await_durability(mut self, should: bool) -> Self {
293        self.headers[3] = Some((
294            "x-helix-await-durable",
295            if should { "true" } else { "false" },
296        ));
297        self
298    }
299
300    /// Attach a serialized JSON request body.
301    ///
302    /// Used to pass parameters to a [`stored`](Self::stored) query route.
303    /// [`dynamic`](Self::dynamic) requests ignore this body — they serialize the
304    /// [`DynamicQueryRequest`] itself as the payload.
305    ///
306    /// # Errors
307    ///
308    /// Returns [`HelixError::SerializationError`] if `data` cannot be serialized
309    /// to JSON.
310    #[must_use]
311    pub fn body<T: Serialize + Sync>(mut self, data: &T) -> Result<Self, HelixError> {
312        self.body = Some(sonic_rs::to_vec(data)?);
313        Ok(self)
314    }
315
316    /// Target a deployed stored query at `/v1/query/<query_name>`.
317    ///
318    /// Pair with [`body`](Self::body) to supply the query's parameters, then
319    /// call [`QueryRequest::send`].
320    #[must_use]
321    pub fn stored(mut self, query_name: String) -> QueryRequest<'hlx, 'a, R> {
322        self.query_type = QueryType::Stored(query_name);
323        QueryRequest { request: self }
324    }
325
326    /// Target an inline dynamic query at `/v1/query`.
327    ///
328    /// The [`DynamicQueryRequest`] (DSL query plus parameters) is serialized as
329    /// the request body. Build one directly or with a `#[register]` helper, then
330    /// call [`QueryRequest::send`].
331    #[must_use]
332    pub fn dynamic(mut self, query: DynamicQueryRequest) -> QueryRequest<'hlx, 'a, R> {
333        self.query_type = QueryType::Dynamic(query);
334        QueryRequest { request: self }
335    }
336}
337
338/// A fully addressed request, ready to [`send`](Self::send).
339///
340/// Produced once a query kind has been chosen via [`QueryBuilder::stored`] or
341/// [`QueryBuilder::dynamic`]; the only remaining step is `send()`.
342pub struct QueryRequest<'hlx, 'a, R> {
343    request: QueryBuilder<'hlx, 'a, R>,
344}
345
346impl<'hlx, 'a, R: for<'de> Deserialize<'de>> QueryRequest<'hlx, 'a, R> {
347    /// Send the request and deserialize the response body into `R`.
348    ///
349    /// Resolves the route (`/v1/query` for dynamic, `/v1/query/<name>` for
350    /// stored), applies the toggled headers and bearer API key, attaches the
351    /// body, and awaits the response.
352    ///
353    /// # Errors
354    ///
355    /// - [`HelixError::ReqwestError`] for transport failures.
356    /// - [`HelixError::RemoteError`] for any non-`200` response (carrying the
357    ///   server's body or status reason).
358    /// - [`HelixError::SerializationError`] if the request payload cannot be
359    ///   serialized or the response body cannot be deserialized into `R`.
360    ///
361    /// # Examples
362    ///
363    /// ```no_run
364    /// use helix_db::Client;
365    /// use helix_db::dsl::prelude::*;
366    /// use serde::Deserialize;
367    ///
368    /// #[derive(Deserialize)]
369    /// struct AddUserResponse { user_id: u64 }
370    ///
371    /// # async fn run(client: &Client, request: DynamicQueryRequest) -> Result<(), helix_db::HelixError> {
372    /// let response: AddUserResponse = client.query().dynamic(request).send().await?;
373    /// # let _ = response.user_id;
374    /// # Ok(())
375    /// # }
376    /// ```
377    pub async fn send(self) -> Result<R, HelixError> {
378        let query_request = self.request;
379        let (url, body) = match query_request.query_type {
380            QueryType::Dynamic(query) => ("/v1/query".to_string(), Some(sonic_rs::to_vec(&query)?)),
381            QueryType::Stored(name) => (format!("/v1/query/{name}"), query_request.body),
382            QueryType::Empty => {
383                unreachable!("send() is only reachable after stored() or dynamic() sets query_type")
384            }
385        };
386        let url = query_request
387            .client
388            .url
389            .join(&url)
390            .map_err(|e| HelixError::InvalidURL(e.to_string()))?;
391
392        let mut request = query_request.client.client.post(url);
393
394        for (k, v) in query_request.headers.into_iter().flatten() {
395            request = request.header(k, v);
396        }
397        if let Some(ref api_key) = query_request.client.api_key {
398            request = request.bearer_auth(api_key);
399        }
400        if let Some(body) = body {
401            request = request.body(body);
402        }
403
404        let response = request.send().await?;
405
406        match response.status() {
407            StatusCode::OK => {
408                let bytes = response.bytes().await?;
409                sonic_rs::from_slice::<R>(&bytes).map_err(Into::into)
410            }
411            code => match response.text().await {
412                Ok(t) => Err(HelixError::RemoteError { details: t }),
413                Err(_) => match code.canonical_reason() {
414                    Some(r) => Err(HelixError::RemoteError {
415                        details: r.to_string(),
416                    }),
417                    None => Err(HelixError::RemoteError {
418                        details: format!("unkown error with code: {code}"),
419                    }),
420                },
421            },
422        }
423    }
424}
425
426extern crate self as helix_db;
427
428#[cfg(test)]
429mod tests {
430    use helix_db::dsl::prelude::*;
431    use std::collections::BTreeMap;
432
433    #[register]
434    fn query1(name: String) {
435        // helix_db query that returns a read query or write query
436        read_batch()
437            .var_as("user", g().n_where(SourcePredicate::eq("username", name)))
438            .var_as(
439                "friends",
440                g().n(NodeRef::var("user"))
441                    .out(Some("FOLLOWS"))
442                    .dedup()
443                    .limit(100),
444            )
445            .returning(["user", "friends"])
446    }
447
448    #[test]
449    fn query1_builds_dynamic_request() {
450        // Calling the registered fn with concrete args yields a DynamicQueryRequest directly.
451        let query = query1(String::from("alice"));
452
453        assert!(matches!(query.request_type, DynamicQueryRequestType::Read));
454        assert_eq!(query.query_name.as_deref(), Some("query1"));
455        let params = query.parameters.expect("parameters present");
456        assert!(matches!(
457            params.get("name"),
458            Some(DynamicQueryValue::String(s)) if s == "alice"
459        ));
460    }
461
462    #[test]
463    fn dynamic_request_serializes_query_name() {
464        let unnamed = DynamicQueryRequest::read(
465            read_batch()
466                .var_as("count", g().n_with_label("User").count())
467                .returning(["count"]),
468        )
469        .to_json_string()
470        .expect("serialize unnamed dynamic request");
471        assert!(
472            unnamed.contains(r#""query_name":null"#),
473            "unnamed request should serialize query_name=null: {unnamed}"
474        );
475
476        let named = DynamicQueryRequest::read(read_batch())
477            .with_query_name("find_users")
478            .to_json_string()
479            .expect("serialize named dynamic request");
480        assert!(
481            named.contains(r#""query_name":"find_users""#),
482            "named request should serialize query_name: {named}"
483        );
484    }
485
486    // ---- Group 1: every #[register] param type coerces correctly -----------
487
488    #[register]
489    fn q_bool(flag: bool) {
490        read_batch()
491            .var_as("v", g().n_where(SourcePredicate::eq("field", flag)))
492            .returning(["v"])
493    }
494    #[register]
495    fn q_i64(num: i64) {
496        read_batch()
497            .var_as("v", g().n_where(SourcePredicate::eq("field", num)))
498            .returning(["v"])
499    }
500    #[register]
501    fn q_f64(x: f64) {
502        read_batch()
503            .var_as("v", g().n_where(SourcePredicate::eq("field", x)))
504            .returning(["v"])
505    }
506    #[register]
507    fn q_f32(x: f32) {
508        read_batch()
509            .var_as("v", g().n_where(SourcePredicate::eq("field", x)))
510            .returning(["v"])
511    }
512    #[register]
513    fn q_datetime(ts: DateTime) {
514        read_batch()
515            .var_as("v", g().n_where(SourcePredicate::eq("field", ts)))
516            .returning(["v"])
517    }
518    #[register]
519    fn q_value(val: ParamValue) {
520        read_batch()
521            .var_as("v", g().n_where(SourcePredicate::eq("field", val)))
522            .returning(["v"])
523    }
524    #[register]
525    fn q_object(obj: ParamObject) {
526        read_batch()
527            .var_as("v", g().n_where(SourcePredicate::eq("field", obj)))
528            .returning(["v"])
529    }
530    #[register]
531    fn q_array(items: Vec<String>) {
532        read_batch()
533            .var_as("v", g().n_where(SourcePredicate::eq("field", items)))
534            .returning(["v"])
535    }
536    #[register]
537    fn q_map(map: BTreeMap<String, String>) {
538        read_batch()
539            .var_as("v", g().n_where(SourcePredicate::eq("field", map)))
540            .returning(["v"])
541    }
542    #[register]
543    #[allow(unused_variables)] // bytes coercion errors without reading the value (see test below)
544    fn q_bytes(blob: Vec<u8>) {
545        read_batch()
546            .var_as("v", g().n_where(SourcePredicate::eq("field", blob)))
547            .returning(["v"])
548    }
549
550    #[test]
551    fn param_types_coerce_correctly() {
552        // bool
553        let r = q_bool(true);
554        assert!(matches!(r.request_type, DynamicQueryRequestType::Read));
555        assert!(matches!(
556            r.parameters.as_ref().unwrap().get("flag"),
557            Some(DynamicQueryValue::Bool(true))
558        ));
559        assert!(matches!(
560            r.parameter_types.as_ref().unwrap().get("flag"),
561            Some(QueryParamType::Bool)
562        ));
563
564        // i64
565        let r = q_i64(7);
566        assert!(matches!(
567            r.parameters.as_ref().unwrap().get("num"),
568            Some(DynamicQueryValue::I64(7))
569        ));
570        assert!(matches!(
571            r.parameter_types.as_ref().unwrap().get("num"),
572            Some(QueryParamType::I64)
573        ));
574
575        // f64
576        let r = q_f64(1.5);
577        assert!(matches!(
578            r.parameters.as_ref().unwrap().get("x"),
579            Some(DynamicQueryValue::F64(v)) if *v == 1.5
580        ));
581        assert!(matches!(
582            r.parameter_types.as_ref().unwrap().get("x"),
583            Some(QueryParamType::F64)
584        ));
585
586        // f32
587        let r = q_f32(1.5f32);
588        assert!(matches!(
589            r.parameters.as_ref().unwrap().get("x"),
590            Some(DynamicQueryValue::F32(v)) if *v == 1.5f32
591        ));
592        assert!(matches!(
593            r.parameter_types.as_ref().unwrap().get("x"),
594            Some(QueryParamType::F32)
595        ));
596
597        // DateTime -> rfc3339 string
598        let r = q_datetime(DateTime::from_millis(0));
599        let expected = DateTime::from_millis(0).to_rfc3339().unwrap();
600        assert!(matches!(
601            r.parameters.as_ref().unwrap().get("ts"),
602            Some(DynamicQueryValue::String(s)) if *s == expected
603        ));
604        assert!(matches!(
605            r.parameter_types.as_ref().unwrap().get("ts"),
606            Some(QueryParamType::DateTime)
607        ));
608
609        // ParamValue (PropertyValue)
610        let r = q_value(PropertyValue::I64(5));
611        assert!(matches!(
612            r.parameters.as_ref().unwrap().get("val"),
613            Some(DynamicQueryValue::I64(5))
614        ));
615        assert!(matches!(
616            r.parameter_types.as_ref().unwrap().get("val"),
617            Some(QueryParamType::Value)
618        ));
619
620        // ParamObject (BTreeMap<String, PropertyValue>)
621        let mut obj = BTreeMap::new();
622        obj.insert("k".to_string(), PropertyValue::String("x".to_string()));
623        let r = q_object(obj);
624        assert!(matches!(
625            r.parameters.as_ref().unwrap().get("obj"),
626            Some(DynamicQueryValue::Object(_))
627        ));
628        assert!(matches!(
629            r.parameter_types.as_ref().unwrap().get("obj"),
630            Some(QueryParamType::Object)
631        ));
632
633        // Vec<String> -> Array(String)
634        let r = q_array(vec!["a".to_string(), "b".to_string()]);
635        match r.parameters.as_ref().unwrap().get("items") {
636            Some(DynamicQueryValue::Array(items)) => {
637                assert_eq!(items.len(), 2);
638                assert!(matches!(&items[0], DynamicQueryValue::String(s) if s == "a"));
639                assert!(matches!(&items[1], DynamicQueryValue::String(s) if s == "b"));
640            }
641            other => panic!("expected array, got {other:?}"),
642        }
643        assert!(matches!(
644            r.parameter_types.as_ref().unwrap().get("items"),
645            Some(QueryParamType::Array(inner)) if matches!(**inner, QueryParamType::String)
646        ));
647
648        // BTreeMap<String, String> -> Object
649        let mut map = BTreeMap::new();
650        map.insert("k".to_string(), "v".to_string());
651        let r = q_map(map);
652        assert!(matches!(
653            r.parameters.as_ref().unwrap().get("map"),
654            Some(DynamicQueryValue::Object(_))
655        ));
656        assert!(matches!(
657            r.parameter_types.as_ref().unwrap().get("map"),
658            Some(QueryParamType::Object)
659        ));
660    }
661
662    #[test]
663    #[should_panic(expected = "failed to coerce parameter")]
664    fn bytes_param_panics_on_dynamic_call() {
665        // Bytes params register fine for the stored query, but dynamic coercion is unsupported
666        // and the generated callable panics when invoked.
667        let _ = q_bytes(vec![1, 2, 3]);
668    }
669
670    // ---- Group 2: Predicate JSON — old (literal) vs new (param) ------------
671
672    #[test]
673    fn predicate_literal_json_is_unchanged() {
674        assert_eq!(
675            sonic_rs::to_string(&Predicate::eq("username", "alice")).unwrap(),
676            r#"{"Eq":["username",{"String":"alice"}]}"#
677        );
678        assert_eq!(
679            sonic_rs::to_string(&Predicate::gt("score", 10i64)).unwrap(),
680            r#"{"Gt":["score",{"I64":10}]}"#
681        );
682        assert_eq!(
683            sonic_rs::to_string(&Predicate::between("age", 18i64, 65i64)).unwrap(),
684            r#"{"Between":["age",{"I64":18},{"I64":65}]}"#
685        );
686    }
687
688    #[test]
689    fn predicate_param_json_uses_expr_variants() {
690        assert_eq!(
691            sonic_rs::to_string(&Predicate::eq("username", Expr::param("name"))).unwrap(),
692            r#"{"EqExpr":["username",{"Param":"name"}]}"#
693        );
694        assert_eq!(
695            sonic_rs::to_string(&Predicate::lte("score", Expr::param("max"))).unwrap(),
696            r#"{"LteExpr":["score",{"Param":"max"}]}"#
697        );
698        assert_eq!(
699            sonic_rs::to_string(&Predicate::between("age", Expr::param("lo"), 65i64)).unwrap(),
700            r#"{"BetweenExpr":["age",{"Param":"lo"},{"Constant":{"I64":65}}]}"#
701        );
702    }
703
704    #[test]
705    fn predicate_json_round_trips() {
706        for predicate in [
707            Predicate::eq("username", "alice"),
708            Predicate::eq("username", Expr::param("name")),
709            Predicate::between("age", Expr::param("lo"), 65i64),
710        ] {
711            let json = sonic_rs::to_string(&predicate).unwrap();
712            let back: Predicate = sonic_rs::from_str(&json).unwrap();
713            assert_eq!(predicate, back);
714        }
715    }
716
717    // ---- Group 3: SourcePredicate JSON — old (literal) vs new (param) -------
718
719    #[test]
720    fn source_predicate_literal_json_is_unchanged() {
721        assert_eq!(
722            sonic_rs::to_string(&SourcePredicate::eq("username", "alice")).unwrap(),
723            r#"{"Eq":["username",{"String":"alice"}]}"#
724        );
725        assert_eq!(
726            sonic_rs::to_string(&SourcePredicate::gt("score", 10i64)).unwrap(),
727            r#"{"Gt":["score",{"I64":10}]}"#
728        );
729        assert_eq!(
730            sonic_rs::to_string(&SourcePredicate::between("age", 18i64, 65i64)).unwrap(),
731            r#"{"Between":["age",{"I64":18},{"I64":65}]}"#
732        );
733    }
734
735    #[test]
736    fn source_predicate_param_json_uses_expr_variants() {
737        assert_eq!(
738            sonic_rs::to_string(&SourcePredicate::eq("username", Expr::param("name"))).unwrap(),
739            r#"{"EqExpr":["username",{"Param":"name"}]}"#
740        );
741        assert_eq!(
742            sonic_rs::to_string(&SourcePredicate::lte("score", Expr::param("max"))).unwrap(),
743            r#"{"LteExpr":["score",{"Param":"max"}]}"#
744        );
745        assert_eq!(
746            sonic_rs::to_string(&SourcePredicate::between("age", Expr::param("lo"), 65i64))
747                .unwrap(),
748            r#"{"BetweenExpr":["age",{"Param":"lo"},{"Constant":{"I64":65}}]}"#
749        );
750    }
751
752    #[test]
753    fn source_predicate_json_round_trips() {
754        for sp in [
755            SourcePredicate::eq("username", "alice"),
756            SourcePredicate::eq("username", Expr::param("name")),
757            SourcePredicate::between("age", Expr::param("lo"), 65i64),
758        ] {
759            let json = sonic_rs::to_string(&sp).unwrap();
760            let back: SourcePredicate = sonic_rs::from_str(&json).unwrap();
761            assert_eq!(sp, back);
762        }
763    }
764
765    // ---- Group 4: full query AST, literal vs param (self-contained) --------
766
767    #[test]
768    fn query_ast_literal_vs_param_json() {
769        let literal = read_batch()
770            .var_as(
771                "user",
772                g().n_where(SourcePredicate::eq("username", "alice")),
773            )
774            .returning(["user"]);
775        let literal_json = sonic_rs::to_string(&literal).unwrap();
776        assert!(
777            literal_json.contains(r#"{"NWhere":{"Eq":["username",{"String":"alice"}]}}"#),
778            "literal NWhere step changed shape: {literal_json}"
779        );
780        assert!(!literal_json.contains("EqExpr"));
781
782        let param = read_batch()
783            .var_as(
784                "user",
785                g().n_where(SourcePredicate::eq("username", Expr::param("name"))),
786            )
787            .returning(["user"]);
788        let param_json = sonic_rs::to_string(&param).unwrap();
789        assert!(
790            param_json.contains(r#"{"NWhere":{"EqExpr":["username",{"Param":"name"}]}}"#),
791            "param NWhere step missing EqExpr/Param: {param_json}"
792        );
793    }
794
795    #[test]
796    fn nested_dynamic_property_query_json() {
797        let metadata = PropertyValue::object(vec![
798            ("externalID", PropertyValue::from("some_id")),
799            ("score", PropertyValue::from(20i64)),
800            (
801                "tags",
802                PropertyValue::array(vec![
803                    PropertyValue::from("alpha"),
804                    PropertyValue::from(7i64),
805                ]),
806            ),
807        ]);
808
809        let write = write_batch()
810            .var_as(
811                "updated",
812                g().add_n(
813                    "User",
814                    vec![
815                        ("name", PropertyInput::from("john")),
816                        ("metadata", PropertyInput::from(metadata)),
817                    ],
818                )
819                .set_property("metadata", PropertyInput::param("metadata"))
820                .value_map(Some(vec!["metadata.externalID"])),
821            )
822            .returning(["updated"]);
823        let write_json = sonic_rs::to_string(&write).unwrap();
824        assert!(
825            write_json
826                .contains(r#""metadata",{"Value":{"Object":{"externalID":{"String":"some_id"}"#),
827            "AddN nested object value changed shape: {write_json}"
828        );
829        assert!(
830            write_json.contains(r#""tags":{"Array":[{"String":"alpha"},{"I64":7}]}"#),
831            "AddN nested array value changed shape: {write_json}"
832        );
833        assert!(
834            write_json.contains(r#"{"SetProperty":["metadata",{"Expr":{"Param":"metadata"}}]}"#),
835            "SetProperty param changed shape: {write_json}"
836        );
837        assert!(
838            write_json.contains(r#"{"ValueMap":["metadata.externalID"]}"#),
839            "filtered ValueMap dotted path changed shape: {write_json}"
840        );
841
842        let read = read_batch()
843            .var_as(
844                "users",
845                g().n_where(SourcePredicate::and(vec![
846                    SourcePredicate::eq("name", "john"),
847                    SourcePredicate::eq("metadata.externalID", "some_id"),
848                ]))
849                .order_by("metadata.score", Order::Desc)
850                .project(vec![
851                    Projection::property("metadata.externalID", "external_id"),
852                    Projection::expr("score_copy", Expr::prop("metadata.score")),
853                ]),
854            )
855            .var_as(
856                "external_ids",
857                g().n_with_label("User").values(vec!["metadata.externalID"]),
858            )
859            .returning(["users", "external_ids"]);
860        let read_json = sonic_rs::to_string(&read).unwrap();
861        assert!(
862            read_json.contains(r#"{"Eq":["metadata.externalID",{"String":"some_id"}]}"#),
863            "dotted SourcePredicate changed shape: {read_json}"
864        );
865        assert!(
866            read_json.contains(r#"{"OrderBy":["metadata.score","Desc"]}"#),
867            "dotted OrderBy changed shape: {read_json}"
868        );
869        assert!(
870            read_json.contains(r#""source":"metadata.externalID","alias":"external_id""#),
871            "dotted property projection changed shape: {read_json}"
872        );
873        assert!(
874            read_json.contains(r#""expr":{"Property":"metadata.score"}"#),
875            "dotted expression projection changed shape: {read_json}"
876        );
877        assert!(
878            read_json.contains(r#"{"Values":["metadata.externalID"]}"#),
879            "dotted Values changed shape: {read_json}"
880        );
881    }
882}
883
884#[cfg(test)]
885mod client_tests {
886    //! Tests for the `Client` / `QueryBuilder` request-building surface. These
887    //! exercise everything up to (but not including) the network round-trip, so
888    //! they need no running Helix instance. As a child module of the crate root
889    //! they can read the builder's private fields directly.
890    use super::*;
891    use serde::Deserialize;
892
893    #[derive(Deserialize)]
894    struct Resp;
895
896    fn sample_request() -> DynamicQueryRequest {
897        DynamicQueryRequest::read(
898            read_batch()
899                .var_as(
900                    "user",
901                    g().n_where(SourcePredicate::eq("username", "alice")),
902                )
903                .returning(["user"]),
904        )
905    }
906
907    // ---- Client construction ------------------------------------------------
908
909    #[test]
910    fn new_defaults_to_localhost() {
911        let client = Client::new(None).unwrap();
912        assert_eq!(client.url.as_str(), "http://localhost:6969/v1/query");
913        assert!(client.api_key.is_none());
914    }
915
916    #[test]
917    fn new_parses_custom_url() {
918        let client = Client::new(Some("https://cluster.helix-db.com")).unwrap();
919        assert_eq!(client.url.as_str(), "https://cluster.helix-db.com/v1/query");
920    }
921
922    #[test]
923    fn new_rejects_invalid_url() {
924        let err = Client::new(Some("not a url")).unwrap_err();
925        assert!(matches!(err, HelixError::InvalidURL(_)));
926    }
927
928    #[test]
929    fn with_api_key_sets_and_clears() {
930        let client = Client::new(None).unwrap().with_api_key(Some("hx_secret"));
931        assert_eq!(client.api_key.as_deref(), Some("hx_secret"));
932
933        let cleared = client.with_api_key(None);
934        assert!(cleared.api_key.is_none());
935    }
936
937    // ---- Header assembly ----------------------------------------------------
938
939    #[test]
940    fn query_builder_starts_with_only_content_type() {
941        let client = Client::new(None).unwrap();
942        let builder = client.query::<Resp>();
943        assert_eq!(
944            builder.headers[0],
945            Some(("Content-Type", "application/json"))
946        );
947        assert!(builder.headers[1..].iter().all(Option::is_none));
948    }
949
950    #[test]
951    fn header_toggles_populate_slots() {
952        let client = Client::new(None).unwrap();
953        let builder = client
954            .query::<Resp>()
955            .writer_only()
956            .warm_only()
957            .should_await_durability(true);
958        assert_eq!(builder.headers[1], Some(("x-helix-require-writer", "true")));
959        assert_eq!(builder.headers[2], Some(("x-helix-warm", "true")));
960        assert_eq!(builder.headers[3], Some(("x-helix-await-durable", "true")));
961    }
962
963    #[test]
964    fn should_await_durability_false_sends_false() {
965        let client = Client::new(None).unwrap();
966        let builder = client.query::<Resp>().should_await_durability(false);
967        assert_eq!(builder.headers[3], Some(("x-helix-await-durable", "false")));
968    }
969
970    // ---- Query type + body --------------------------------------------------
971
972    #[test]
973    fn dynamic_query_sets_query_type() {
974        let client = Client::new(None).unwrap();
975        let request = client.query::<Resp>().dynamic(sample_request());
976        assert!(matches!(request.request.query_type, QueryType::Dynamic(_)));
977    }
978
979    #[test]
980    fn stored_query_sets_query_type() {
981        let client = Client::new(None).unwrap();
982        let request = client.query::<Resp>().stored("add_user".to_string());
983        assert!(
984            matches!(&request.request.query_type, QueryType::Stored(name) if name == "add_user")
985        );
986    }
987
988    #[derive(serde::Serialize)]
989    struct Payload {
990        name: String,
991    }
992
993    #[test]
994    fn body_serializes_payload() {
995        let client = Client::new(None).unwrap();
996        let payload = Payload {
997            name: "alice".to_string(),
998        };
999        let builder = client.query::<Resp>().body(&payload).unwrap();
1000        assert_eq!(builder.body, Some(sonic_rs::to_vec(&payload).unwrap()));
1001    }
1002
1003    // ---- Request routing (exercises the real `send()` path) -----------------
1004
1005    #[derive(serde::Deserialize)]
1006    struct EmptyResp {}
1007
1008    /// Spawn a one-shot HTTP server on a random port. Returns its base URL and a
1009    /// handle that resolves to the request-target (path) of the first request.
1010    async fn spawn_capture_server() -> (String, tokio::task::JoinHandle<String>) {
1011        use tokio::io::{AsyncReadExt, AsyncWriteExt};
1012        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1013        let base = format!("http://{}", listener.local_addr().unwrap());
1014        let handle = tokio::spawn(async move {
1015            let (mut socket, _) = listener.accept().await.unwrap();
1016            let mut buf = [0u8; 4096];
1017            let n = socket.read(&mut buf).await.unwrap();
1018            let request_line = String::from_utf8_lossy(&buf[..n])
1019                .lines()
1020                .next()
1021                .unwrap()
1022                .to_string();
1023            // `METHOD <target> HTTP/1.1` -> the target.
1024            let target = request_line.split_whitespace().nth(1).unwrap().to_string();
1025            let resp = "HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}";
1026            socket.write_all(resp.as_bytes()).await.unwrap();
1027            target
1028        });
1029        (base, handle)
1030    }
1031
1032    #[tokio::test]
1033    async fn dynamic_query_posts_to_v1_query() {
1034        let (base, handle) = spawn_capture_server().await;
1035        let client = Client::new(Some(&base)).unwrap();
1036        let _: EmptyResp = client
1037            .query()
1038            .dynamic(sample_request())
1039            .send()
1040            .await
1041            .unwrap();
1042        assert_eq!(handle.await.unwrap(), "/v1/query");
1043    }
1044
1045    #[tokio::test]
1046    async fn stored_query_posts_to_named_route() {
1047        let (base, handle) = spawn_capture_server().await;
1048        let client = Client::new(Some(&base)).unwrap();
1049        let _: EmptyResp = client
1050            .query()
1051            .stored("add_user".to_string())
1052            .send()
1053            .await
1054            .unwrap();
1055        assert_eq!(handle.await.unwrap(), "/v1/query/add_user");
1056    }
1057}