1#![recursion_limit = "256"]
2
3pub 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
83pub use dsl::*;
87
88pub 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#[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
169pub type HelixDBClient = Client;
171
172#[derive(Debug, Error)]
174pub enum HelixError {
175 #[error("Error communicating with server: {0}")]
178 ReqwestError(#[from] reqwest::Error),
179 #[error("Got Error from server: {details}")]
183 RemoteError {
184 details: String,
186 },
187 #[error("Error serializing data: {0}")]
189 SerializationError(#[from] sonic_rs::Error),
190 #[error("Invalid URL: {0}")]
193 InvalidURL(String),
194 #[error("Invalid request: {details}")]
196 InvalidRequest {
197 details: String,
199 },
200 #[cfg(feature = "embedded")]
202 #[error("Embedded DB error: {details}")]
203 EmbeddedError {
204 details: String,
206 },
207}
208
209impl Client {
210 pub fn new(url: Option<&str>) -> Result<Self, HelixError> {
221 Self::server(url)
222 }
223
224 pub fn server(url: Option<&str>) -> Result<Self, HelixError> {
226 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 #[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 #[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 #[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 #[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 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 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 pub fn query_raw(&self, request: QueryRequest) -> QueryExecutionRequest<'_, 'static, Vec<u8>> {
324 QueryBuilder::new(self).query(request)
325 }
326
327 pub fn request_builder<R: for<'de> Deserialize<'de>>(&self) -> QueryBuilder<'_, '_, R> {
351 QueryBuilder::new(self)
352 }
353
354 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
373pub 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 #[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 #[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 #[must_use]
416 pub fn warm_only(mut self) -> Self {
417 self.headers[2] = Some(("x-helix-warm", "true"));
418 self
419 }
420
421 #[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 #[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
449pub 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 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 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 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 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 #[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)] 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 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 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 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 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 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 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 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 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 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 assert!(matches!(
781 q_bytes(vec![1, 2, 3]),
782 Err(QueryError::UnsupportedBytesParameter(name)) if name == "blob"
783 ));
784 }
785
786 #[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 #[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 #[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(¶m).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 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 #[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 #[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 #[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 #[derive(serde::Deserialize)]
1186 struct EmptyResp {}
1187
1188 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 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 #[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}