Skip to main content

helix_db/
lib.rs

1#![recursion_limit = "256"]
2
3//! # helix-db Rust SDK
4//!
5//! The `helix-db` crate (imported as `helix_db`) is the Rust SDK for
6//! [HelixDB](https://github.com/helixdb/helix-db). It pairs a query-builder DSL
7//! with a small async HTTP client ([`Client`]) for running those queries
8//! against a Helix instance.
9//!
10//! ## Crate layout
11//!
12//! - [`dsl`] — the query-builder DSL: traversals, predicates, batches, and the
13//!   [`QueryRequest`] payload type. This is the bulk of the public API.
14//! - The crate root ([`Client`], [`QueryBuilder`], [`QueryExecutionRequest`],
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 send a [`QueryRequest`] to `/v2/query`:
51//!
52//! ```no_run
53//! #![recursion_limit = "256"]
54//! use helix_db::Client;
55//! use helix_db::dsl::prelude::*;
56//! use serde::Deserialize;
57//!
58//! #[derive(Deserialize)]
59//! struct Friends { friends: Vec<u64> }
60//!
61//! # async fn run(request: QueryRequest) -> Result<(), helix_db::HelixError> {
62//! let client = Client::new(Some("https://cluster.helix-db.com"))?
63//!     .with_api_key(Some("hx_your_api_key"));
64//!
65//! let response: Friends = client.query(request).send().await?;
66//! # let _ = response.friends;
67//! # Ok(())
68//! # }
69//! ```
70//!
71//! See [`Client`] for the full request-building surface and error handling.
72
73pub mod dsl;
74pub mod graph;
75pub mod lifecycle;
76
77pub use lifecycle::*;
78
79#[cfg(feature = "embedded")]
80use std::sync::Arc;
81use std::{fmt, marker::PhantomData};
82
83// Re-export the DSL surface (types, builders, `prelude`, etc.) at the crate
84// root. This is also what makes the `crate::*` paths used inside `dsl.rs`
85// resolve.
86pub use dsl::*;
87
88// Convenience re-export so `helix_db::prelude::*` is reachable directly, in
89// addition to the canonical `helix_db::dsl::prelude::*`.
90pub use dsl::prelude;
91
92#[cfg(feature = "embedded")]
93pub use db::config::{
94    CacheConfig, CacheMode, DbConfig, DiskCacheConfig, SlateHybridCacheConfig,
95    SlateObjectStoreCacheSettings, VectorMemoryBudget, VectorMemorySettings,
96};
97#[cfg(feature = "embedded")]
98pub use db::{HelixDB, HelixDbMode, HelixDbSource};
99
100use reqwest::{Client as ReqwestClient, StatusCode};
101use serde::Deserialize;
102use thiserror::Error;
103
104/// Async HTTP client for running queries against a Helix instance.
105///
106/// A thin async wrapper over [`reqwest`] that knows how to reach a Helix
107/// gateway's query routes. Construct it with [`Client::new`], optionally attach
108/// a bearer API key via [`Client::with_api_key`], then build and send requests
109/// through [`Client::query`].
110///
111/// The client is cheap to [`Clone`] — the underlying `reqwest::Client` shares
112/// its connection pool — so a single instance can be reused across tasks.
113///
114/// Reachable as `helix_db::Client`.
115///
116/// # Examples
117///
118/// ```no_run
119/// use helix_db::Client;
120///
121/// # fn run() -> Result<(), helix_db::HelixError> {
122/// // Defaults to http://localhost:6969 when the URL is `None`.
123/// let local = Client::new(None)?;
124///
125/// // Or point at a remote cluster and attach an API key.
126/// let remote = Client::new(Some("https://cluster.helix-db.com"))?
127///     .with_api_key(Some("hx_your_api_key"));
128/// # let _ = (local, remote);
129/// # Ok(())
130/// # }
131/// ```
132#[derive(Clone)]
133pub struct Client {
134    backend: ClientBackend,
135}
136
137#[derive(Clone)]
138enum ClientBackend {
139    Server(ServerClient),
140    #[cfg(feature = "embedded")]
141    Embedded(Arc<db::HelixDB>),
142}
143
144#[derive(Debug, Clone)]
145struct ServerClient {
146    client: ReqwestClient,
147    url: reqwest::Url,
148    api_key: Option<String>,
149}
150
151impl fmt::Debug for Client {
152    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
153        match &self.backend {
154            ClientBackend::Server(server) => formatter
155                .debug_struct("Client")
156                .field("mode", &"server")
157                .field("url", &server.url)
158                .field("api_key", &server.api_key.as_ref().map(|_| "<redacted>"))
159                .finish(),
160            #[cfg(feature = "embedded")]
161            ClientBackend::Embedded(_) => formatter
162                .debug_struct("Client")
163                .field("mode", &"embedded")
164                .finish(),
165        }
166    }
167}
168
169/// Backwards-compatible alias for [`Client`].
170pub type HelixDBClient = Client;
171
172/// Errors returned while building or executing a query request.
173#[derive(Debug, Error)]
174pub enum HelixError {
175    /// Transport-level failure talking to the server (connection refused,
176    /// timeout, TLS error, …), surfaced from [`reqwest`].
177    #[error("Error communicating with server: {0}")]
178    ReqwestError(#[from] reqwest::Error),
179    /// The server responded with a non-`200` status. `details` carries the
180    /// response body, or the status' canonical reason phrase when no body is
181    /// available.
182    #[error("Got Error from server: {details}")]
183    RemoteError {
184        /// Server-provided error text, or a fallback description of the status.
185        details: String,
186    },
187    /// Failed to (de)serialize a request body or response payload.
188    #[error("Error serializing data: {0}")]
189    SerializationError(#[from] sonic_rs::Error),
190    /// The base URL passed to [`Client::new`] could not be parsed, or the
191    /// resolved query route was not a valid URL.
192    #[error("Invalid URL: {0}")]
193    InvalidURL(String),
194    /// The request uses options that are unavailable for the selected client mode.
195    #[error("Invalid request: {details}")]
196    InvalidRequest {
197        /// Description of the unsupported request shape.
198        details: String,
199    },
200    /// Embedded DB execution failed.
201    #[cfg(feature = "embedded")]
202    #[error("Embedded DB error: {details}")]
203    EmbeddedError {
204        /// Error text from the embedded DB layer.
205        details: String,
206    },
207}
208
209impl Client {
210    /// Create a client pointed at a Helix instance.
211    ///
212    /// `url` is the instance base URL; when `None`, it defaults to
213    /// `http://localhost:6969`. The `/v2/query` base route is resolved up front
214    /// and reused by every request.
215    ///
216    /// # Errors
217    ///
218    /// Returns [`HelixError::InvalidURL`] if `url` (or the resolved query route)
219    /// cannot be parsed.
220    pub fn new(url: Option<&str>) -> Result<Self, HelixError> {
221        Self::server(url)
222    }
223
224    /// Create a server-mode client pointed at a Helix instance.
225    pub fn server(url: Option<&str>) -> Result<Self, HelixError> {
226        // Resolve the query endpoint up front. `send()` reuses it for every request.
227        let url = reqwest::Url::parse(url.unwrap_or("http://localhost:6969"))
228            .map_err(|e| HelixError::InvalidURL(e.to_string()))?
229            .join("/v2/query")
230            .map_err(|e| HelixError::InvalidURL(e.to_string()))?;
231        Ok(Self {
232            backend: ClientBackend::Server(ServerClient {
233                client: ReqwestClient::new(),
234                url,
235                api_key: None,
236            }),
237        })
238    }
239
240    /// Create an embedded-mode writer client backed by the DB crate.
241    #[cfg(feature = "embedded")]
242    pub async fn open(source: HelixDbSource) -> Result<Self, HelixError> {
243        db::HelixDB::open(source)
244            .await
245            .map(|db| Self {
246                backend: ClientBackend::Embedded(Arc::new(db)),
247            })
248            .map_err(embedded_error)
249    }
250
251    /// Create an embedded-mode writer client with explicit DB config.
252    ///
253    /// [`CacheMode::VectorMemoryOnly`] disables SlateDB and object-store
254    /// caches; canonical data still uses the selected [`HelixDbSource`].
255    #[cfg(feature = "embedded")]
256    pub async fn open_with_config(
257        source: HelixDbSource,
258        config: DbConfig,
259    ) -> Result<Self, HelixError> {
260        db::HelixDB::open_with_config(source, config)
261            .await
262            .map(|db| Self {
263                backend: ClientBackend::Embedded(Arc::new(db)),
264            })
265            .map_err(embedded_error)
266    }
267
268    /// Create an embedded-mode read-only client backed by the DB crate.
269    #[cfg(feature = "embedded")]
270    pub async fn open_reader(source: HelixDbSource) -> Result<Self, HelixError> {
271        db::HelixDB::open_reader(source)
272            .await
273            .map(|db| Self {
274                backend: ClientBackend::Embedded(Arc::new(db)),
275            })
276            .map_err(embedded_error)
277    }
278
279    /// Create an embedded-mode read-only client with explicit DB config.
280    #[cfg(feature = "embedded")]
281    pub async fn open_reader_with_config(
282        source: HelixDbSource,
283        config: DbConfig,
284    ) -> Result<Self, HelixError> {
285        db::HelixDB::open_reader_with_config(source, config)
286            .await
287            .map(|db| Self {
288                backend: ClientBackend::Embedded(Arc::new(db)),
289            })
290            .map_err(embedded_error)
291    }
292
293    /// Attach (or clear) the bearer API key sent with every request.
294    ///
295    /// Passing `Some(key)` sets an `Authorization: Bearer <key>` header on each
296    /// request; passing `None` clears any previously set key.
297    pub fn with_api_key(mut self, api_key: Option<&str>) -> Self {
298        match &mut self.backend {
299            ClientBackend::Server(server) => {
300                server.api_key = api_key.map(|key| key.to_string());
301            }
302            #[cfg(feature = "embedded")]
303            ClientBackend::Embedded(_) => {}
304        }
305        self
306    }
307
308    /// Execute an SDK-built query request.
309    ///
310    /// In server mode this posts to `/v2/query`. In embedded mode this executes
311    /// directly against the in-process [`HelixDB`].
312    pub fn query<R: for<'de> Deserialize<'de>>(
313        &self,
314        request: QueryRequest,
315    ) -> QueryExecutionRequest<'_, 'static, R> {
316        QueryBuilder::new(self).query(request)
317    }
318
319    /// Execute a query while retaining the response as raw bytes.
320    ///
321    /// Graph loading uses this path so Rust validates and constructs the graph
322    /// without an intermediate language-level object graph.
323    pub fn query_raw(&self, request: QueryRequest) -> QueryExecutionRequest<'_, 'static, Vec<u8>> {
324        QueryBuilder::new(self).query(request)
325    }
326
327    /// Start building an advanced server request.
328    ///
329    /// `R` is the type the JSON response body is deserialized into by
330    /// [`QueryExecutionRequest::send`]. Returns a [`QueryBuilder`] on which you can toggle
331    /// request headers, then attach a request with [`QueryBuilder::query`].
332    ///
333    /// # Examples
334    ///
335    /// ```no_run
336    /// #![recursion_limit = "256"]
337    /// use helix_db::Client;
338    /// use helix_db::dsl::prelude::*;
339    /// use serde::Deserialize;
340    ///
341    /// #[derive(Deserialize)]
342    /// struct Users { count: u64 }
343    ///
344    /// # async fn run(client: &Client, request: QueryRequest) -> Result<(), helix_db::HelixError> {
345    /// let response: Users = client.query(request).send().await?;
346    /// # let _ = response;
347    /// # Ok(())
348    /// # }
349    /// ```
350    pub fn request_builder<R: for<'de> Deserialize<'de>>(&self) -> QueryBuilder<'_, '_, R> {
351        QueryBuilder::new(self)
352    }
353
354    /// Flush and close an embedded database handle.
355    ///
356    /// Server clients do not own database state, so closing them is a no-op.
357    pub async fn close(&self) -> Result<(), HelixError> {
358        match &self.backend {
359            ClientBackend::Server(_) => Ok(()),
360            #[cfg(feature = "embedded")]
361            ClientBackend::Embedded(database) => database.close().await.map_err(embedded_error),
362        }
363    }
364}
365
366#[cfg(feature = "embedded")]
367fn embedded_error(error: db::error::HelixDbError) -> HelixError {
368    HelixError::EmbeddedError {
369        details: error.to_string(),
370    }
371}
372
373/// Fluent builder for a single request, produced by [`Client::query`].
374///
375/// Optional server header toggles ([`writer_only`](Self::writer_only),
376/// [`warm_only`](Self::warm_only),
377/// [`should_await_durability`](Self::should_await_durability)) can be chained,
378/// then [`query`](Self::query) transitions to a [`QueryExecutionRequest`] ready
379/// to [`send`](QueryExecutionRequest::send).
380///
381/// `R` is the response deserialization target carried through to `send()`.
382pub struct QueryBuilder<'hlx, 'a, R> {
383    client: &'hlx HelixDBClient,
384    headers: [Option<(&'a str, &'a str)>; 4],
385    _phantom: PhantomData<R>,
386}
387
388impl<'hlx, 'a, R> QueryBuilder<'hlx, 'a, R> {
389    /// Create a builder seeded with the `Content-Type: application/json` header.
390    ///
391    /// Prefer [`Client::query`], which calls this for you.
392    #[must_use]
393    pub fn new(client: &'hlx HelixDBClient) -> Self {
394        let mut headers = [None; 4];
395        headers[0] = Some(("Content-Type", "application/json"));
396        Self {
397            client,
398            headers,
399            _phantom: PhantomData,
400        }
401    }
402
403    /// Require the request to be served by a writer node.
404    ///
405    /// Sets the `x-helix-require-writer` header.
406    #[must_use]
407    pub fn writer_only(mut self) -> Self {
408        self.headers[1] = Some(("x-helix-require-writer", "true"));
409        self
410    }
411
412    /// Only execute if the query is already warm (reads only).
413    ///
414    /// Sets the `x-helix-warm` header.
415    #[must_use]
416    pub fn warm_only(mut self) -> Self {
417        self.headers[2] = Some(("x-helix-warm", "true"));
418        self
419    }
420
421    /// Choose whether a write request blocks until the write is durable.
422    ///
423    /// Sets the `x-helix-await-durable` header to `"true"` or `"false"`.
424    #[must_use]
425    pub fn should_await_durability(mut self, should: bool) -> Self {
426        self.headers[3] = Some((
427            "x-helix-await-durable",
428            if should { "true" } else { "false" },
429        ));
430        self
431    }
432
433    /// Target the query route at `/v2/query`.
434    ///
435    /// The [`QueryRequest`] (DSL query plus parameters) is serialized as
436    /// the request body. Build one directly or with a `#[query]` helper, then
437    /// call [`QueryExecutionRequest::send`].
438    #[must_use]
439    pub fn query(self, query: QueryRequest) -> QueryExecutionRequest<'hlx, 'a, R> {
440        QueryExecutionRequest {
441            client: self.client,
442            headers: self.headers,
443            query,
444            _phantom: PhantomData,
445        }
446    }
447}
448
449/// A fully addressed request, ready to [`send`](Self::send).
450///
451/// Produced once a query has been attached via [`QueryBuilder::query`].
452pub struct QueryExecutionRequest<'hlx, 'a, R> {
453    client: &'hlx HelixDBClient,
454    headers: [Option<(&'a str, &'a str)>; 4],
455    query: QueryRequest,
456    _phantom: PhantomData<R>,
457}
458
459impl<'hlx, 'a, R> QueryExecutionRequest<'hlx, 'a, R> {
460    /// Send the request and return the successful response body unchanged.
461    pub async fn send_bytes(self) -> Result<Vec<u8>, HelixError> {
462        match &self.client.backend {
463            ClientBackend::Server(server) => {
464                let mut request = server.client.post(server.url.clone());
465                for (key, value) in self.headers.into_iter().flatten() {
466                    request = request.header(key, value);
467                }
468                if let Some(api_key) = &server.api_key {
469                    request = request.bearer_auth(api_key);
470                }
471                let response = request.body(sonic_rs::to_vec(&self.query)?).send().await?;
472                match response.status() {
473                    StatusCode::OK => response
474                        .bytes()
475                        .await
476                        .map(|bytes| bytes.to_vec())
477                        .map_err(Into::into),
478                    code => match response.text().await {
479                        Ok(details) => Err(HelixError::RemoteError { details }),
480                        Err(_) => Err(HelixError::RemoteError {
481                            details: code.canonical_reason().map_or_else(
482                                || format!("unknown error with code: {code}"),
483                                str::to_string,
484                            ),
485                        }),
486                    },
487                }
488            }
489            #[cfg(feature = "embedded")]
490            ClientBackend::Embedded(db) => {
491                if self.headers.iter().skip(1).any(Option::is_some) {
492                    return Err(HelixError::InvalidRequest {
493                        details: "request options require server mode".to_string(),
494                    });
495                }
496                let request = sonic_rs::to_vec(&self.query)?;
497                db.query_json(&request).await.map_err(embedded_error)
498            }
499        }
500    }
501}
502
503impl<'hlx, 'a, R: for<'de> Deserialize<'de>> QueryExecutionRequest<'hlx, 'a, R> {
504    /// Send the request and deserialize the response body into `R`.
505    ///
506    /// Sends the request to `/v2/query`, applies the toggled headers and bearer
507    /// API key, and awaits the response.
508    ///
509    /// # Errors
510    ///
511    /// - [`HelixError::ReqwestError`] for transport failures.
512    /// - [`HelixError::RemoteError`] for any non-`200` response (carrying the
513    ///   server's body or status reason).
514    /// - [`HelixError::SerializationError`] if the request payload cannot be
515    ///   serialized or the response body cannot be deserialized into `R`.
516    ///
517    /// # Examples
518    ///
519    /// ```no_run
520    /// #![recursion_limit = "256"]
521    /// use helix_db::Client;
522    /// use helix_db::dsl::prelude::*;
523    /// use serde::Deserialize;
524    ///
525    /// #[derive(Deserialize)]
526    /// struct AddUserResponse { user_id: u64 }
527    ///
528    /// # async fn run(client: &Client, request: QueryRequest) -> Result<(), helix_db::HelixError> {
529    /// let response: AddUserResponse = client.query(request).send().await?;
530    /// # let _ = response.user_id;
531    /// # Ok(())
532    /// # }
533    /// ```
534    pub async fn send(self) -> Result<R, HelixError> {
535        let response = self.send_bytes().await?;
536        sonic_rs::from_slice::<R>(&response).map_err(Into::into)
537    }
538}
539
540extern crate self as helix_db;
541
542#[cfg(test)]
543mod tests {
544    use helix_db::dsl::prelude::*;
545    use std::collections::BTreeMap;
546
547    #[query]
548    fn query1(name: String) {
549        // helix_db query that returns a read query or write query
550        read_batch()
551            .var_as("user", g().n_where(SourcePredicate::eq("username", name)))
552            .var_as(
553                "friends",
554                g().n(NodeRef::var("user"))
555                    .out(Some("FOLLOWS"))
556                    .dedup()
557                    .limit(100),
558            )
559            .returning(["user", "friends"])
560    }
561
562    #[test]
563    fn query1_builds_query_request() {
564        // Calling the registered fn with concrete args yields a validated QueryRequest.
565        let query = query1(String::from("alice")).unwrap();
566
567        assert!(matches!(query.request_type(), QueryRequestType::Read));
568        assert_eq!(query.query_name(), Some("query1"));
569        let params = query.parameters().expect("parameters present");
570        assert!(matches!(
571            params.get("name"),
572            Some(QueryValue::String(s)) if s == "alice"
573        ));
574    }
575
576    #[test]
577    fn query_request_serializes_query_name() {
578        let unnamed = QueryRequest::read(
579            read_batch()
580                .var_as("count", g().n_with_label("User").count())
581                .returning(["count"]),
582        )
583        .to_json_string()
584        .expect("serialize unnamed query request");
585        assert!(
586            unnamed.contains(r#""query_name":null"#),
587            "unnamed request should serialize query_name=null: {unnamed}"
588        );
589
590        let named = QueryRequest::read(read_batch())
591            .with_query_name("find_users")
592            .to_json_string()
593            .expect("serialize named query request");
594        assert!(
595            named.contains(r#""query_name":"find_users""#),
596            "named request should serialize query_name: {named}"
597        );
598    }
599
600    // ---- Group 1: every #[query] param type coerces correctly -----------
601
602    #[query]
603    fn q_bool(flag: bool) {
604        read_batch()
605            .var_as("v", g().n_where(SourcePredicate::eq("field", flag)))
606            .returning(["v"])
607    }
608    #[query]
609    fn q_i64(num: i64) {
610        read_batch()
611            .var_as("v", g().n_where(SourcePredicate::eq("field", num)))
612            .returning(["v"])
613    }
614    #[query]
615    fn q_f64(x: f64) {
616        read_batch()
617            .var_as("v", g().n_where(SourcePredicate::eq("field", x)))
618            .returning(["v"])
619    }
620    #[query]
621    fn q_f32(x: f32) {
622        read_batch()
623            .var_as("v", g().n_where(SourcePredicate::eq("field", x)))
624            .returning(["v"])
625    }
626    #[query]
627    fn q_datetime(ts: DateTime) {
628        read_batch()
629            .var_as("v", g().n_where(SourcePredicate::eq("field", ts)))
630            .returning(["v"])
631    }
632    #[query]
633    fn q_value(val: ParamValue) {
634        read_batch()
635            .var_as("v", g().n_where(SourcePredicate::eq("field", val)))
636            .returning(["v"])
637    }
638    #[query]
639    fn q_object(obj: ParamObject) {
640        read_batch()
641            .var_as("v", g().n_where(SourcePredicate::eq("field", obj)))
642            .returning(["v"])
643    }
644    #[query]
645    fn q_array(items: Vec<String>) {
646        read_batch()
647            .var_as("v", g().n_where(SourcePredicate::eq("field", items)))
648            .returning(["v"])
649    }
650    #[query]
651    fn q_map(map: BTreeMap<String, String>) {
652        read_batch()
653            .var_as("v", g().n_where(SourcePredicate::eq("field", map)))
654            .returning(["v"])
655    }
656    #[query]
657    #[allow(unused_variables)] // bytes coercion errors without reading the value (see test below)
658    fn q_bytes(blob: Vec<u8>) {
659        read_batch()
660            .var_as("v", g().n_where(SourcePredicate::eq("field", blob)))
661            .returning(["v"])
662    }
663
664    #[test]
665    fn param_types_coerce_correctly() {
666        // bool
667        let r = q_bool(true).unwrap();
668        assert!(matches!(r.request_type(), QueryRequestType::Read));
669        assert!(matches!(
670            r.parameters().unwrap().get("flag"),
671            Some(QueryValue::Bool(true))
672        ));
673        assert!(matches!(
674            r.parameter_types().unwrap().get("flag"),
675            Some(QueryParamType::Bool)
676        ));
677
678        // i64
679        let r = q_i64(7).unwrap();
680        assert!(matches!(
681            r.parameters().unwrap().get("num"),
682            Some(QueryValue::I64(7))
683        ));
684        assert!(matches!(
685            r.parameter_types().unwrap().get("num"),
686            Some(QueryParamType::I64)
687        ));
688
689        // f64
690        let r = q_f64(1.5).unwrap();
691        assert!(matches!(
692            r.parameters().unwrap().get("x"),
693            Some(QueryValue::F64(v)) if *v == 1.5
694        ));
695        assert!(matches!(
696            r.parameter_types().unwrap().get("x"),
697            Some(QueryParamType::F64)
698        ));
699
700        // f32
701        let r = q_f32(1.5f32).unwrap();
702        assert!(matches!(
703            r.parameters().unwrap().get("x"),
704            Some(QueryValue::F32(v)) if *v == 1.5f32
705        ));
706        assert!(matches!(
707            r.parameter_types().unwrap().get("x"),
708            Some(QueryParamType::F32)
709        ));
710
711        // DateTime -> rfc3339 string
712        let r = q_datetime(DateTime::from_millis(0)).unwrap();
713        let expected = DateTime::from_millis(0).to_rfc3339().unwrap();
714        assert!(matches!(
715            r.parameters().unwrap().get("ts"),
716            Some(QueryValue::String(s)) if *s == expected
717        ));
718        assert!(matches!(
719            r.parameter_types().unwrap().get("ts"),
720            Some(QueryParamType::DateTime)
721        ));
722
723        // ParamValue (PropertyValue)
724        let r = q_value(PropertyValue::I64(5)).unwrap();
725        assert!(matches!(
726            r.parameters().unwrap().get("val"),
727            Some(QueryValue::I64(5))
728        ));
729        assert!(matches!(
730            r.parameter_types().unwrap().get("val"),
731            Some(QueryParamType::Value)
732        ));
733
734        // ParamObject (BTreeMap<String, PropertyValue>)
735        let mut obj = BTreeMap::new();
736        obj.insert("k".to_string(), PropertyValue::String("x".to_string()));
737        let r = q_object(obj).unwrap();
738        assert!(matches!(
739            r.parameters().unwrap().get("obj"),
740            Some(QueryValue::Object(_))
741        ));
742        assert!(matches!(
743            r.parameter_types().unwrap().get("obj"),
744            Some(QueryParamType::Object)
745        ));
746
747        // Vec<String> -> Array(String)
748        let r = q_array(vec!["a".to_string(), "b".to_string()]).unwrap();
749        match r.parameters().unwrap().get("items") {
750            Some(QueryValue::Array(items)) => {
751                assert_eq!(items.len(), 2);
752                assert!(matches!(&items[0], QueryValue::String(s) if s == "a"));
753                assert!(matches!(&items[1], QueryValue::String(s) if s == "b"));
754            }
755            other => panic!("expected array, got {other:?}"),
756        }
757        assert!(matches!(
758            r.parameter_types().unwrap().get("items"),
759            Some(QueryParamType::Array(inner)) if matches!(**inner, QueryParamType::String)
760        ));
761
762        // BTreeMap<String, String> -> Object
763        let mut map = BTreeMap::new();
764        map.insert("k".to_string(), "v".to_string());
765        let r = q_map(map).unwrap();
766        assert!(matches!(
767            r.parameters().unwrap().get("map"),
768            Some(QueryValue::Object(_))
769        ));
770        assert!(matches!(
771            r.parameter_types().unwrap().get("map"),
772            Some(QueryParamType::Object)
773        ));
774    }
775
776    #[test]
777    fn bytes_param_returns_error_without_panicking() {
778        // Bytes cannot be represented by the query JSON route
779        // and the generated callable reports that contract violation.
780        assert!(matches!(
781            q_bytes(vec![1, 2, 3]),
782            Err(QueryError::UnsupportedBytesParameter(name)) if name == "blob"
783        ));
784    }
785
786    // ---- Group 2: Predicate JSON ------------------------------------------
787
788    #[test]
789    fn predicate_literal_json_uses_ast_shape() {
790        assert_eq!(
791            sonic_rs::to_string(&Predicate::eq("username", "alice")).unwrap(),
792            r#"{"eq":{"left":{"property":"username"},"right":{"constant":{"string":"alice"}}}}"#
793        );
794        assert_eq!(
795            sonic_rs::to_string(&Predicate::gt("score", 10i64)).unwrap(),
796            r#"{"gt":{"left":{"property":"score"},"right":{"constant":{"i64":10}}}}"#
797        );
798        assert_eq!(
799            sonic_rs::to_string(&Predicate::between("age", 18i64, 65i64)).unwrap(),
800            r#"{"between":{"value":{"property":"age"},"min":{"constant":{"i64":18}},"max":{"constant":{"i64":65}}}}"#
801        );
802    }
803
804    #[test]
805    fn predicate_param_json_uses_param_exprs() {
806        assert_eq!(
807            sonic_rs::to_string(&Predicate::eq("username", Expr::param("name"))).unwrap(),
808            r#"{"eq":{"left":{"property":"username"},"right":{"param":"name"}}}"#
809        );
810        assert_eq!(
811            sonic_rs::to_string(&Predicate::lte("score", Expr::param("max"))).unwrap(),
812            r#"{"lte":{"left":{"property":"score"},"right":{"param":"max"}}}"#
813        );
814        assert_eq!(
815            sonic_rs::to_string(&Predicate::between("age", Expr::param("lo"), 65i64)).unwrap(),
816            r#"{"between":{"value":{"property":"age"},"min":{"param":"lo"},"max":{"constant":{"i64":65}}}}"#
817        );
818    }
819
820    #[test]
821    fn predicate_json_round_trips() {
822        for predicate in [
823            Predicate::eq("username", "alice"),
824            Predicate::eq("username", Expr::param("name")),
825            Predicate::between("age", Expr::param("lo"), 65i64),
826        ] {
827            let json = sonic_rs::to_string(&predicate).unwrap();
828            let back: Predicate = sonic_rs::from_str(&json).unwrap();
829            assert_eq!(predicate, back);
830        }
831    }
832
833    // ---- Group 3: SourcePredicate JSON -------------------------------------
834
835    #[test]
836    fn source_predicate_literal_json_uses_ast_shape() {
837        assert_eq!(
838            sonic_rs::to_string(&SourcePredicate::eq("username", "alice")).unwrap(),
839            r#"{"eq":{"left":{"property":"username"},"right":{"constant":{"string":"alice"}}}}"#
840        );
841        assert_eq!(
842            sonic_rs::to_string(&SourcePredicate::gt("score", 10i64)).unwrap(),
843            r#"{"gt":{"left":{"property":"score"},"right":{"constant":{"i64":10}}}}"#
844        );
845        assert_eq!(
846            sonic_rs::to_string(&SourcePredicate::between("age", 18i64, 65i64)).unwrap(),
847            r#"{"between":{"value":{"property":"age"},"min":{"constant":{"i64":18}},"max":{"constant":{"i64":65}}}}"#
848        );
849    }
850
851    #[test]
852    fn source_predicate_param_json_uses_param_exprs() {
853        assert_eq!(
854            sonic_rs::to_string(&SourcePredicate::eq("username", Expr::param("name"))).unwrap(),
855            r#"{"eq":{"left":{"property":"username"},"right":{"param":"name"}}}"#
856        );
857        assert_eq!(
858            sonic_rs::to_string(&SourcePredicate::lte("score", Expr::param("max"))).unwrap(),
859            r#"{"lte":{"left":{"property":"score"},"right":{"param":"max"}}}"#
860        );
861        assert_eq!(
862            sonic_rs::to_string(&SourcePredicate::between("age", Expr::param("lo"), 65i64))
863                .unwrap(),
864            r#"{"between":{"value":{"property":"age"},"min":{"param":"lo"},"max":{"constant":{"i64":65}}}}"#
865        );
866    }
867
868    #[test]
869    fn source_predicate_json_round_trips() {
870        for sp in [
871            SourcePredicate::eq("username", "alice"),
872            SourcePredicate::eq("username", Expr::param("name")),
873            SourcePredicate::between("age", Expr::param("lo"), 65i64),
874        ] {
875            let json = sonic_rs::to_string(&sp).unwrap();
876            let back: SourcePredicate = sonic_rs::from_str(&json).unwrap();
877            assert_eq!(sp, back);
878        }
879    }
880
881    // ---- Group 4: full query AST, literal vs param (self-contained) --------
882
883    #[test]
884    fn query_ast_literal_vs_param_json() {
885        let literal = read_batch()
886            .var_as(
887                "user",
888                g().n_where(SourcePredicate::eq("username", "alice")),
889            )
890            .returning(["user"]);
891        let literal_json = sonic_rs::to_string(&literal).unwrap();
892        assert!(
893            literal_json.contains(r#""root":{"nodes_where":{"predicate":{"eq":{"left":{"property":"username"},"right":{"constant":{"string":"alice"}}}}}}"#),
894            "literal nodes_where AST changed shape: {literal_json}"
895        );
896        assert!(!literal_json.contains("steps"));
897
898        let param = read_batch()
899            .var_as(
900                "user",
901                g().n_where(SourcePredicate::eq("username", Expr::param("name"))),
902            )
903            .returning(["user"]);
904        let param_json = sonic_rs::to_string(&param).unwrap();
905        assert!(
906            param_json.contains(r#""root":{"nodes_where":{"predicate":{"eq":{"left":{"property":"username"},"right":{"param":"name"}}}}}}"#),
907            "param nodes_where AST missing param expression: {param_json}"
908        );
909    }
910
911    #[test]
912    fn row_binding_query_uses_public_sdk_prelude_ast_shape() {
913        let query = read_batch()
914            .var_as(
915                "workloads",
916                g().n_with_label("Service")
917                    .bind("service")
918                    .optional(sub().in_(Some("CREATES")).bind("deployment"))
919                    .union(vec![
920                        sub().in_(Some("MANAGES")).bind("owner"),
921                        sub().out(Some("ROUTES_TO")).bind("workload"),
922                    ])
923                    .project_distinct_bindings(vec![
924                        BindingProjection::binding("service", "$id", "service_id"),
925                        BindingProjection::current("$id", "current_id"),
926                        BindingProjection::coalesce(
927                            vec![
928                                BindingValueRef::binding("deployment", "$id"),
929                                BindingValueRef::binding("owner", "$id"),
930                                BindingValueRef::binding("workload", "$id"),
931                            ],
932                            "workload_id",
933                        ),
934                    ]),
935            )
936            .returning(["workloads"]);
937
938        let json = sonic_rs::to_string(&query).unwrap();
939        assert!(json.contains(r#""project_bindings""#));
940        assert!(json.contains(r#""bind":{"input""#));
941        assert!(json.contains(r#""name":"service""#));
942        assert!(json.contains(r#""target":{"binding":"service"}"#));
943        assert!(json.contains(r#""target":"current""#));
944        assert!(json.contains(r#""coalesce""#));
945        assert!(json.contains(r#""distinct":true"#));
946        assert!(!json.contains("steps"));
947    }
948
949    #[test]
950    fn nested_query_property_json() {
951        let metadata = PropertyValue::object(vec![
952            ("externalID", PropertyValue::from("some_id")),
953            ("score", PropertyValue::from(20i64)),
954            (
955                "tags",
956                PropertyValue::array(vec![
957                    PropertyValue::from("alpha"),
958                    PropertyValue::from(7i64),
959                ]),
960            ),
961        ]);
962
963        let write = write_batch()
964            .var_as(
965                "updated",
966                g().add_n(
967                    "User",
968                    vec![
969                        ("name", PropertyInput::from("john")),
970                        ("metadata", PropertyInput::from(metadata)),
971                    ],
972                )
973                .set_property("metadata", PropertyInput::param("metadata"))
974                .value_map(Some(vec!["metadata.externalID"])),
975            )
976            .returning(["updated"]);
977        let write_json = sonic_rs::to_string(&write).unwrap();
978        assert!(
979            write_json
980                .contains(r#""metadata",{"value":{"object":{"externalID":{"string":"some_id"}"#),
981            "AddN nested object value changed shape: {write_json}"
982        );
983        assert!(
984            write_json.contains(r#""tags":{"array":[{"string":"alpha"},{"i64":7}]}"#),
985            "AddN nested array value changed shape: {write_json}"
986        );
987        assert!(
988            write_json.contains(r#""set_property":{"input":{"add_n""#)
989                && write_json
990                    .contains(r#""name":"metadata","value":{"expr":{"param":"metadata"}}"#),
991            "SetProperty param changed shape: {write_json}"
992        );
993        assert!(
994            write_json.contains(r#""value_map":{"input":{"set_property""#)
995                && write_json.contains(r#""properties":["metadata.externalID"]"#),
996            "filtered ValueMap dotted path changed shape: {write_json}"
997        );
998
999        let read = read_batch()
1000            .var_as(
1001                "users",
1002                g().n_where(SourcePredicate::and(vec![
1003                    SourcePredicate::eq("name", "john"),
1004                    SourcePredicate::eq("metadata.externalID", "some_id"),
1005                ]))
1006                .order_by("metadata.score", Order::Desc)
1007                .project(vec![
1008                    Projection::property("metadata.externalID", "external_id"),
1009                    Projection::expr("score_copy", Expr::prop("metadata.score")),
1010                ]),
1011            )
1012            .var_as(
1013                "external_ids",
1014                g().n_with_label("User").values(vec!["metadata.externalID"]),
1015            )
1016            .returning(["users", "external_ids"]);
1017        let read_json = sonic_rs::to_string(&read).unwrap();
1018        assert!(
1019            read_json.contains(r#""eq":{"left":{"property":"metadata.externalID"},"right":{"constant":{"string":"some_id"}}}"#),
1020            "dotted SourcePredicate changed shape: {read_json}"
1021        );
1022        assert!(
1023            read_json.contains(r#""order_by":{"input":{"nodes_where""#)
1024                && read_json.contains(r#""property":"metadata.score","order":"desc""#),
1025            "dotted OrderBy changed shape: {read_json}"
1026        );
1027        assert!(
1028            read_json.contains(r#""source":"metadata.externalID","alias":"external_id""#),
1029            "dotted property projection changed shape: {read_json}"
1030        );
1031        assert!(
1032            read_json.contains(r#""expr":{"property":"metadata.score"}"#),
1033            "dotted expression projection changed shape: {read_json}"
1034        );
1035        assert!(
1036            read_json.contains(r#""values":{"input":{"nodes_where""#)
1037                && read_json.contains(r#""properties":["metadata.externalID"]"#),
1038            "dotted Values changed shape: {read_json}"
1039        );
1040    }
1041}
1042
1043#[cfg(test)]
1044mod client_tests {
1045    //! Tests for the `Client` / `QueryBuilder` request-building surface. These
1046    //! exercise everything up to (but not including) the network round-trip, so
1047    //! they need no running Helix instance. As a child module of the crate root
1048    //! they can read the builder's private fields directly.
1049    use super::*;
1050    use serde::Deserialize;
1051
1052    #[derive(Debug, Deserialize)]
1053    struct Resp;
1054
1055    #[cfg(feature = "embedded")]
1056    #[derive(Debug, Deserialize)]
1057    struct CountResp {
1058        users: u64,
1059    }
1060
1061    fn sample_request() -> QueryRequest {
1062        QueryRequest::read(
1063            read_batch()
1064                .var_as(
1065                    "user",
1066                    g().n_where(SourcePredicate::eq("username", "alice")),
1067                )
1068                .returning(["user"]),
1069        )
1070    }
1071
1072    #[cfg(feature = "embedded")]
1073    fn count_request() -> QueryRequest {
1074        QueryRequest::read(
1075            read_batch()
1076                .var_as("users", g().n_with_label("Missing").count())
1077                .returning(["users"]),
1078        )
1079    }
1080
1081    #[cfg(feature = "embedded")]
1082    fn write_request() -> QueryRequest {
1083        QueryRequest::write(
1084            write_batch()
1085                .var_as(
1086                    "created",
1087                    g().add_n("User", vec![("name", PropertyInput::from("Ada"))]),
1088                )
1089                .returning(["created"]),
1090        )
1091    }
1092
1093    fn server_backend(client: &Client) -> &ServerClient {
1094        match &client.backend {
1095            ClientBackend::Server(server) => server,
1096            #[cfg(feature = "embedded")]
1097            ClientBackend::Embedded(_) => panic!("test expected server-mode client"),
1098        }
1099    }
1100
1101    // ---- Client construction ------------------------------------------------
1102
1103    #[test]
1104    fn new_defaults_to_localhost() {
1105        let client = Client::new(None).unwrap();
1106        let server = server_backend(&client);
1107        assert_eq!(server.url.as_str(), "http://localhost:6969/v2/query");
1108        assert!(server.api_key.is_none());
1109    }
1110
1111    #[test]
1112    fn new_parses_custom_url() {
1113        let client = Client::new(Some("https://cluster.helix-db.com")).unwrap();
1114        assert_eq!(
1115            server_backend(&client).url.as_str(),
1116            "https://cluster.helix-db.com/v2/query"
1117        );
1118    }
1119
1120    #[test]
1121    fn new_rejects_invalid_url() {
1122        let err = Client::new(Some("not a url")).unwrap_err();
1123        assert!(matches!(err, HelixError::InvalidURL(_)));
1124    }
1125
1126    #[test]
1127    fn with_api_key_sets_and_clears() {
1128        let client = Client::new(None).unwrap().with_api_key(Some("hx_secret"));
1129        assert_eq!(
1130            server_backend(&client).api_key.as_deref(),
1131            Some("hx_secret")
1132        );
1133
1134        let cleared = client.with_api_key(None);
1135        assert!(server_backend(&cleared).api_key.is_none());
1136    }
1137
1138    // ---- Header assembly ----------------------------------------------------
1139
1140    #[test]
1141    fn query_builder_starts_with_only_content_type() {
1142        let client = Client::new(None).unwrap();
1143        let builder = client.request_builder::<Resp>();
1144        assert_eq!(
1145            builder.headers[0],
1146            Some(("Content-Type", "application/json"))
1147        );
1148        assert!(builder.headers.iter().skip(1).all(Option::is_none));
1149    }
1150
1151    #[test]
1152    fn header_toggles_populate_slots() {
1153        let client = Client::new(None).unwrap();
1154        let builder = client
1155            .request_builder::<Resp>()
1156            .writer_only()
1157            .warm_only()
1158            .should_await_durability(true);
1159        assert_eq!(builder.headers[1], Some(("x-helix-require-writer", "true")));
1160        assert_eq!(builder.headers[2], Some(("x-helix-warm", "true")));
1161        assert_eq!(builder.headers[3], Some(("x-helix-await-durable", "true")));
1162    }
1163
1164    #[test]
1165    fn should_await_durability_false_sends_false() {
1166        let client = Client::new(None).unwrap();
1167        let builder = client
1168            .request_builder::<Resp>()
1169            .should_await_durability(false);
1170        assert_eq!(builder.headers[3], Some(("x-helix-await-durable", "false")));
1171    }
1172
1173    // ---- Query attachment ---------------------------------------------------
1174
1175    #[test]
1176    fn query_builder_attaches_query_request() {
1177        let client = Client::new(None).unwrap();
1178        let query = sample_request();
1179        let request = client.request_builder::<Resp>().query(query.clone());
1180        assert_eq!(request.query, query);
1181    }
1182
1183    // ---- Request routing (exercises the real `send()` path) -----------------
1184
1185    #[derive(serde::Deserialize)]
1186    struct EmptyResp {}
1187
1188    /// Spawn a one-shot HTTP server on a random port. Returns its base URL and a
1189    /// handle that resolves to the request-target (path) of the first request.
1190    async fn spawn_capture_server() -> (String, tokio::task::JoinHandle<String>) {
1191        use tokio::io::{AsyncReadExt, AsyncWriteExt};
1192        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1193        let base = format!("http://{}", listener.local_addr().unwrap());
1194        let handle = tokio::spawn(async move {
1195            let (mut socket, _) = listener.accept().await.unwrap();
1196            let mut buf = [0u8; 4096];
1197            let n = socket.read(&mut buf).await.unwrap();
1198            let request_line = String::from_utf8_lossy(&buf[..n])
1199                .lines()
1200                .next()
1201                .unwrap()
1202                .to_string();
1203            // `METHOD <target> HTTP/1.1` -> the target.
1204            let target = request_line.split_whitespace().nth(1).unwrap().to_string();
1205            let resp = "HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}";
1206            socket.write_all(resp.as_bytes()).await.unwrap();
1207            target
1208        });
1209        (base, handle)
1210    }
1211
1212    #[tokio::test]
1213    async fn query_posts_to_v2_query() {
1214        let (base, handle) = spawn_capture_server().await;
1215        let client = Client::new(Some(&base)).unwrap();
1216        let _: EmptyResp = client.query(sample_request()).send().await.unwrap();
1217        assert_eq!(handle.await.unwrap(), "/v2/query");
1218    }
1219
1220    // ---- Embedded execution -------------------------------------------------
1221
1222    #[cfg(feature = "embedded")]
1223    #[tokio::test]
1224    async fn embedded_client_query_executes_against_in_memory_db() {
1225        let client = Client::open(HelixDbSource::InMemory {
1226            database: "rust-sdk-embedded-query".to_string(),
1227        })
1228        .await
1229        .expect("embedded client should open");
1230
1231        let response: CountResp = client
1232            .query(count_request())
1233            .send()
1234            .await
1235            .expect("embedded query should execute");
1236
1237        assert_eq!(response.users, 0);
1238    }
1239
1240    #[cfg(feature = "embedded")]
1241    #[tokio::test]
1242    async fn embedded_reader_rejects_write_request() {
1243        let root = tempfile::tempdir().expect("tempdir should be created");
1244        let source = HelixDbSource::Disk {
1245            root: root.path().to_path_buf(),
1246            database: "rust-sdk-reader".to_string(),
1247        };
1248        let writer = HelixDB::open(source.clone())
1249            .await
1250            .expect("writer DB should initialize disk database");
1251        writer.close().await.expect("writer should close cleanly");
1252        let client = Client::open_reader(source)
1253            .await
1254            .expect("embedded reader client should open");
1255
1256        let err = client
1257            .query::<serde_json::Value>(write_request())
1258            .send()
1259            .await
1260            .expect_err("embedded reader should reject writes");
1261
1262        assert!(matches!(err, HelixError::EmbeddedError { .. }));
1263        assert!(err.to_string().contains("writer mode"));
1264    }
1265
1266    #[cfg(feature = "embedded")]
1267    #[tokio::test]
1268    async fn embedded_client_rejects_server_options() {
1269        let client = Client::open(HelixDbSource::InMemory {
1270            database: "rust-sdk-server-options".to_string(),
1271        })
1272        .await
1273        .expect("embedded client should open");
1274
1275        let err = client
1276            .request_builder::<Resp>()
1277            .writer_only()
1278            .query(sample_request())
1279            .send()
1280            .await
1281            .expect_err("server options should require server mode");
1282
1283        assert!(matches!(err, HelixError::InvalidRequest { .. }));
1284        assert!(err
1285            .to_string()
1286            .contains("request options require server mode"));
1287    }
1288}