Skip to main content

type_bridge_server/typedb/
client.rs

1use super::backend::{DriverBackend, QueryResultKind, TransactionType};
2use super::real_driver::RealTypeDBBackend;
3use crate::config::TypeDBSection;
4use crate::error::PipelineError;
5use crate::executor::QueryExecutor;
6
7/// Wrapper around the TypeDB Rust driver providing a clean async API
8/// for query execution and schema retrieval.
9pub struct TypeDBClient {
10    backend: Box<dyn DriverBackend>,
11}
12
13impl TypeDBClient {
14    /// Connect to a TypeDB server using the provided configuration.
15    #[cfg_attr(coverage_nightly, coverage(off))]
16    pub async fn connect(config: &TypeDBSection) -> Result<Self, PipelineError> {
17        let backend = RealTypeDBBackend::connect(config).await?;
18        Ok(Self {
19            backend: Box::new(backend),
20        })
21    }
22
23    /// Create a TypeDBClient with a custom backend (for testing).
24    #[cfg(test)]
25    pub(crate) fn with_backend(backend: Box<dyn DriverBackend>) -> Self {
26        Self { backend }
27    }
28
29    /// Execute a TypeQL query and return results as JSON.
30    ///
31    /// For read transactions, the transaction is used directly.
32    /// For write and schema transactions, the transaction is committed after execution.
33    pub async fn execute(
34        &self,
35        database: &str,
36        typeql: &str,
37        tx_type: &str,
38    ) -> Result<serde_json::Value, PipelineError> {
39        let transaction_type = parse_transaction_type(tx_type)?;
40
41        let mut tx = self
42            .backend
43            .open_transaction(database, transaction_type)
44            .await?;
45
46        let answer = tx.query(typeql).await?;
47
48        let needs_commit = matches!(
49            transaction_type,
50            TransactionType::Write | TransactionType::Schema
51        );
52
53        let results = match answer {
54            QueryResultKind::Ok => {
55                if needs_commit {
56                    tx.commit().await?;
57                }
58                serde_json::json!({ "ok": true })
59            }
60            QueryResultKind::Rows(rows) => {
61                if needs_commit {
62                    tx.commit().await?;
63                }
64                serde_json::Value::Array(rows)
65            }
66            QueryResultKind::Documents(docs) => {
67                if needs_commit {
68                    let _ = tx.commit().await;
69                }
70                serde_json::Value::Array(docs)
71            }
72        };
73
74        Ok(results)
75    }
76
77    /// Check if the driver connection is open.
78    pub fn is_connected(&self) -> bool {
79        self.backend.is_open()
80    }
81}
82
83impl QueryExecutor for TypeDBClient {
84    fn execute<'a>(
85        &'a self,
86        database: &'a str,
87        typeql: &'a str,
88        transaction_type: &'a str,
89    ) -> std::pin::Pin<
90        Box<dyn std::future::Future<Output = Result<serde_json::Value, PipelineError>> + Send + 'a>,
91    > {
92        Box::pin(async move { self.execute(database, typeql, transaction_type).await })
93    }
94
95    fn is_connected(&self) -> bool {
96        self.is_connected()
97    }
98}
99
100/// Parse a transaction type string into a TypeDB TransactionType.
101pub(crate) fn parse_transaction_type(tx_type: &str) -> Result<TransactionType, PipelineError> {
102    match tx_type {
103        "read" => Ok(TransactionType::Read),
104        "write" => Ok(TransactionType::Write),
105        "schema" => Ok(TransactionType::Schema),
106        other => Err(PipelineError::QueryExecution(format!(
107            "Unknown transaction type: {other}"
108        ))),
109    }
110}
111
112/// Convert a band-7 TypeDB concept to a JSON value.
113///
114/// Output shape is identical to [`concept_to_json_b8`] for all common concepts.
115#[cfg(feature = "band7")]
116pub(crate) fn concept_to_json_b7(
117    concept: &type_bridge_typedb_driver_b7::concept::Concept,
118) -> serde_json::Value {
119    let mut obj = serde_json::Map::new();
120    obj.insert(
121        "category".to_string(),
122        serde_json::Value::String(concept.get_category().name().to_string()),
123    );
124    obj.insert(
125        "label".to_string(),
126        serde_json::Value::String(concept.get_label().to_string()),
127    );
128    if let Some(iid) = concept.try_get_iid() {
129        obj.insert(
130            "iid".to_string(),
131            serde_json::Value::String(iid.to_string()),
132        );
133    }
134    if let Some(value) = concept.try_get_value() {
135        obj.insert("value".to_string(), value_to_json_b7(value));
136    }
137    if let Some(value_type) = concept.try_get_value_type() {
138        obj.insert(
139            "value_type".to_string(),
140            serde_json::Value::String(value_type.name().to_string()),
141        );
142    }
143    serde_json::Value::Object(obj)
144}
145
146/// Convert a band-7 TypeDB value to a JSON value.
147#[cfg(feature = "band7")]
148#[cfg_attr(coverage_nightly, coverage(off))]
149fn value_to_json_b7(value: &type_bridge_typedb_driver_b7::concept::Value) -> serde_json::Value {
150    if let Some(b) = value.get_boolean() {
151        return serde_json::Value::Bool(b);
152    }
153    if let Some(i) = value.get_integer() {
154        return serde_json::json!(i);
155    }
156    if let Some(d) = value.get_double() {
157        return serde_json::json!(d);
158    }
159    if let Some(s) = value.get_string() {
160        return serde_json::Value::String(s.to_string());
161    }
162    if let Some(date) = value.get_date() {
163        return serde_json::Value::String(date.to_string());
164    }
165    if let Some(dt) = value.get_datetime() {
166        return serde_json::Value::String(dt.to_string());
167    }
168    if let Some(dt_tz) = value.get_datetime_tz() {
169        return serde_json::Value::String(dt_tz.to_string());
170    }
171    if let Some(dec) = value.get_decimal() {
172        return serde_json::Value::String(dec.to_string());
173    }
174    if let Some(dur) = value.get_duration() {
175        return serde_json::Value::String(dur.to_string());
176    }
177    serde_json::Value::String(value.to_string())
178}
179
180/// Convert a band-8 TypeDB concept to a JSON value.
181///
182/// Output shape is identical to [`concept_to_json_b7`] for all common concepts.
183#[cfg(feature = "band8")]
184pub(crate) fn concept_to_json_b8(concept: &typedb_driver::concept::Concept) -> serde_json::Value {
185    let mut obj = serde_json::Map::new();
186    obj.insert(
187        "category".to_string(),
188        serde_json::Value::String(concept.get_category().name().to_string()),
189    );
190    obj.insert(
191        "label".to_string(),
192        serde_json::Value::String(concept.get_label().to_string()),
193    );
194    if let Some(iid) = concept.try_get_iid() {
195        obj.insert(
196            "iid".to_string(),
197            serde_json::Value::String(iid.to_string()),
198        );
199    }
200    if let Some(value) = concept.try_get_value() {
201        obj.insert("value".to_string(), value_to_json_b8(value));
202    }
203    if let Some(value_type) = concept.try_get_value_type() {
204        obj.insert(
205            "value_type".to_string(),
206            serde_json::Value::String(value_type.name().to_string()),
207        );
208    }
209    serde_json::Value::Object(obj)
210}
211
212/// Convert a band-8 TypeDB value to a JSON value.
213#[cfg(feature = "band8")]
214#[cfg_attr(coverage_nightly, coverage(off))]
215pub(crate) fn value_to_json_b8(value: &typedb_driver::concept::Value) -> serde_json::Value {
216    if let Some(b) = value.get_boolean() {
217        return serde_json::Value::Bool(b);
218    }
219    if let Some(i) = value.get_integer() {
220        return serde_json::json!(i);
221    }
222    if let Some(d) = value.get_double() {
223        return serde_json::json!(d);
224    }
225    if let Some(s) = value.get_string() {
226        return serde_json::Value::String(s.to_string());
227    }
228    if let Some(date) = value.get_date() {
229        return serde_json::Value::String(date.to_string());
230    }
231    if let Some(dt) = value.get_datetime() {
232        return serde_json::Value::String(dt.to_string());
233    }
234    if let Some(dt_tz) = value.get_datetime_tz() {
235        return serde_json::Value::String(dt_tz.to_string());
236    }
237    if let Some(dec) = value.get_decimal() {
238        return serde_json::Value::String(dec.to_string());
239    }
240    if let Some(dur) = value.get_duration() {
241        return serde_json::Value::String(dur.to_string());
242    }
243    // Fallback: use Display representation
244    serde_json::Value::String(value.to_string())
245}
246
247#[cfg(test)]
248#[cfg(feature = "band8")]
249#[cfg_attr(coverage_nightly, coverage(off))]
250mod tests {
251    use std::future::Future;
252    use std::pin::Pin;
253    use std::sync::Arc;
254    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
255
256    use typedb_driver::IID;
257    use typedb_driver::concept::value::{Decimal, Duration, TimeZone};
258    use typedb_driver::concept::{
259        Attribute, AttributeType, Concept, Entity, EntityType, Value, ValueType,
260    };
261
262    use super::*;
263    use crate::error::PipelineError;
264    use crate::typedb::backend::TransactionOps;
265
266    // =============================================
267    // Mock infrastructure
268    // =============================================
269
270    struct MockTransaction {
271        query_result: Option<QueryResultKind>,
272        query_error: Option<String>,
273        commit_error: Option<String>,
274        committed: Arc<AtomicBool>,
275        query_called: Arc<AtomicBool>,
276    }
277
278    impl MockTransaction {
279        fn new(result: QueryResultKind) -> Self {
280            Self {
281                query_result: Some(result),
282                query_error: None,
283                commit_error: None,
284                committed: Arc::new(AtomicBool::new(false)),
285                query_called: Arc::new(AtomicBool::new(false)),
286            }
287        }
288
289        fn failing_query(msg: &str) -> Self {
290            Self {
291                query_result: None,
292                query_error: Some(msg.to_string()),
293                commit_error: None,
294                committed: Arc::new(AtomicBool::new(false)),
295                query_called: Arc::new(AtomicBool::new(false)),
296            }
297        }
298
299        fn with_commit_error(mut self, msg: &str) -> Self {
300            self.commit_error = Some(msg.to_string());
301            self
302        }
303    }
304
305    impl TransactionOps for MockTransaction {
306        fn query(
307            &mut self,
308            _typeql: &str,
309        ) -> Pin<Box<dyn Future<Output = Result<QueryResultKind, PipelineError>> + Send + '_>>
310        {
311            self.query_called.store(true, Ordering::SeqCst);
312            let result = self.query_result.take();
313            let error = self.query_error.take();
314            Box::pin(async move {
315                if let Some(msg) = error {
316                    return Err(PipelineError::QueryExecution(msg));
317                }
318                Ok(result.expect("MockTransaction::query called more than once"))
319            })
320        }
321
322        fn commit(
323            &mut self,
324        ) -> Pin<Box<dyn Future<Output = Result<(), PipelineError>> + Send + '_>> {
325            self.committed.store(true, Ordering::SeqCst);
326            let error = self.commit_error.take();
327            Box::pin(async move {
328                if let Some(msg) = error {
329                    return Err(PipelineError::QueryExecution(msg));
330                }
331                Ok(())
332            })
333        }
334    }
335
336    struct MockBackend {
337        transaction: std::sync::Mutex<Option<MockTransaction>>,
338        open_error: Option<String>,
339        is_open: bool,
340        open_called: Arc<AtomicUsize>,
341    }
342
343    impl MockBackend {
344        fn new(tx: MockTransaction) -> Self {
345            Self {
346                transaction: std::sync::Mutex::new(Some(tx)),
347                open_error: None,
348                is_open: true,
349                open_called: Arc::new(AtomicUsize::new(0)),
350            }
351        }
352
353        fn failing(msg: &str) -> Self {
354            Self {
355                transaction: std::sync::Mutex::new(None),
356                open_error: Some(msg.to_string()),
357                is_open: true,
358                open_called: Arc::new(AtomicUsize::new(0)),
359            }
360        }
361    }
362
363    impl DriverBackend for MockBackend {
364        fn open_transaction(
365            &self,
366            _database: &str,
367            _tx_type: TransactionType,
368        ) -> Pin<Box<dyn Future<Output = Result<Box<dyn TransactionOps>, PipelineError>> + Send + '_>>
369        {
370            self.open_called.fetch_add(1, Ordering::SeqCst);
371            let tx = self.transaction.lock().unwrap().take();
372            let error = self.open_error.clone();
373            Box::pin(async move {
374                if let Some(msg) = error {
375                    return Err(PipelineError::QueryExecution(msg));
376                }
377                Ok(
378                    Box::new(tx.expect("MockBackend: no transaction configured"))
379                        as Box<dyn TransactionOps>,
380                )
381            })
382        }
383
384        fn is_open(&self) -> bool {
385            self.is_open
386        }
387    }
388
389    fn make_client(backend: MockBackend) -> TypeDBClient {
390        TypeDBClient::with_backend(Box::new(backend))
391    }
392
393    // =============================================
394    // parse_transaction_type tests
395    // =============================================
396
397    #[test]
398    fn parse_transaction_type_read() {
399        let result = parse_transaction_type("read").unwrap();
400        assert_eq!(result, TransactionType::Read);
401    }
402
403    #[test]
404    fn parse_transaction_type_write() {
405        let result = parse_transaction_type("write").unwrap();
406        assert_eq!(result, TransactionType::Write);
407    }
408
409    #[test]
410    fn parse_transaction_type_schema() {
411        let result = parse_transaction_type("schema").unwrap();
412        assert_eq!(result, TransactionType::Schema);
413    }
414
415    #[test]
416    fn parse_transaction_type_unknown() {
417        let result = parse_transaction_type("unknown");
418        let err = result.unwrap_err();
419        assert!(
420            matches!(&err, PipelineError::QueryExecution(msg) if msg.contains("Unknown transaction type: unknown"))
421        );
422    }
423
424    #[test]
425    fn parse_transaction_type_empty() {
426        let result = parse_transaction_type("");
427        assert!(result.is_err());
428    }
429
430    #[test]
431    fn parse_transaction_type_case_sensitive() {
432        let result = parse_transaction_type("Read");
433        assert!(result.is_err());
434    }
435
436    // =============================================
437    // execute tests (via MockBackend)
438    // =============================================
439
440    #[tokio::test]
441    async fn execute_ok_read_no_commit() {
442        let tx = MockTransaction::new(QueryResultKind::Ok);
443        let committed = tx.committed.clone();
444        let client = make_client(MockBackend::new(tx));
445
446        let result = client
447            .execute("db", "match $x isa thing;", "read")
448            .await
449            .unwrap();
450        assert_eq!(result, serde_json::json!({"ok": true}));
451        assert!(!committed.load(Ordering::SeqCst));
452    }
453
454    #[tokio::test]
455    async fn execute_ok_write_commits() {
456        let tx = MockTransaction::new(QueryResultKind::Ok);
457        let committed = tx.committed.clone();
458        let client = make_client(MockBackend::new(tx));
459
460        let result = client
461            .execute("db", "insert $x isa thing;", "write")
462            .await
463            .unwrap();
464        assert_eq!(result, serde_json::json!({"ok": true}));
465        assert!(committed.load(Ordering::SeqCst));
466    }
467
468    #[tokio::test]
469    async fn execute_ok_schema_commits() {
470        let tx = MockTransaction::new(QueryResultKind::Ok);
471        let committed = tx.committed.clone();
472        let client = make_client(MockBackend::new(tx));
473
474        let result = client
475            .execute("db", "define entity thing;", "schema")
476            .await
477            .unwrap();
478        assert_eq!(result, serde_json::json!({"ok": true}));
479        assert!(committed.load(Ordering::SeqCst));
480    }
481
482    #[tokio::test]
483    async fn execute_rows_read_no_commit() {
484        let rows = vec![
485            serde_json::json!({"name": "Alice"}),
486            serde_json::json!({"name": "Bob"}),
487        ];
488        let tx = MockTransaction::new(QueryResultKind::Rows(rows.clone()));
489        let committed = tx.committed.clone();
490        let client = make_client(MockBackend::new(tx));
491
492        let result = client
493            .execute("db", "match $p isa person;", "read")
494            .await
495            .unwrap();
496        assert_eq!(result, serde_json::Value::Array(rows));
497        assert!(!committed.load(Ordering::SeqCst));
498    }
499
500    #[tokio::test]
501    async fn execute_rows_write_commits() {
502        let rows = vec![serde_json::json!({"id": 1})];
503        let tx = MockTransaction::new(QueryResultKind::Rows(rows));
504        let committed = tx.committed.clone();
505        let client = make_client(MockBackend::new(tx));
506
507        let result = client
508            .execute("db", "insert $x isa thing;", "write")
509            .await
510            .unwrap();
511        assert!(result.is_array());
512        assert!(committed.load(Ordering::SeqCst));
513    }
514
515    #[tokio::test]
516    async fn execute_rows_data_preserved() {
517        let rows = vec![
518            serde_json::json!({"name": "Alice", "age": 30}),
519            serde_json::json!({"name": "Bob", "age": 25}),
520        ];
521        let tx = MockTransaction::new(QueryResultKind::Rows(rows.clone()));
522        let client = make_client(MockBackend::new(tx));
523
524        let result = client
525            .execute("db", "match $p isa person;", "read")
526            .await
527            .unwrap();
528        let arr = result.as_array().unwrap();
529        assert_eq!(arr.len(), 2);
530        assert_eq!(arr[0]["name"], "Alice");
531        assert_eq!(arr[1]["age"], 25);
532    }
533
534    #[tokio::test]
535    async fn execute_docs_read_no_commit() {
536        let docs = vec![serde_json::json!({"doc": "data"})];
537        let tx = MockTransaction::new(QueryResultKind::Documents(docs.clone()));
538        let committed = tx.committed.clone();
539        let client = make_client(MockBackend::new(tx));
540
541        let result = client
542            .execute("db", "match $p isa person; fetch {};", "read")
543            .await
544            .unwrap();
545        assert_eq!(result, serde_json::Value::Array(docs));
546        assert!(!committed.load(Ordering::SeqCst));
547    }
548
549    #[tokio::test]
550    async fn execute_docs_write_commits() {
551        let docs = vec![serde_json::json!({"doc": "data"})];
552        let tx = MockTransaction::new(QueryResultKind::Documents(docs));
553        let committed = tx.committed.clone();
554        let client = make_client(MockBackend::new(tx));
555
556        let result = client
557            .execute("db", "insert $x isa thing;", "write")
558            .await
559            .unwrap();
560        assert!(result.is_array());
561        assert!(committed.load(Ordering::SeqCst));
562    }
563
564    #[tokio::test]
565    async fn execute_docs_commit_error_ignored() {
566        let docs = vec![serde_json::json!({"doc": "data"})];
567        let tx = MockTransaction::new(QueryResultKind::Documents(docs.clone()))
568            .with_commit_error("commit failed");
569        let client = make_client(MockBackend::new(tx));
570
571        // Documents + write: commit error is intentionally ignored (let _ = ...)
572        let result = client
573            .execute("db", "insert $x isa thing;", "write")
574            .await
575            .unwrap();
576        assert_eq!(result, serde_json::Value::Array(docs));
577    }
578
579    #[tokio::test]
580    async fn execute_transaction_open_failure() {
581        let client = make_client(MockBackend::failing("connection refused"));
582
583        let result = client.execute("db", "match $x isa thing;", "read").await;
584        let err = result.unwrap_err();
585        assert!(
586            matches!(&err, PipelineError::QueryExecution(msg) if msg.contains("connection refused"))
587        );
588    }
589
590    #[tokio::test]
591    async fn execute_query_failure() {
592        let tx = MockTransaction::failing_query("syntax error");
593        let client = make_client(MockBackend::new(tx));
594
595        let result = client.execute("db", "bad query", "read").await;
596        let err = result.unwrap_err();
597        assert!(matches!(&err, PipelineError::QueryExecution(msg) if msg.contains("syntax error")));
598    }
599
600    #[tokio::test]
601    async fn execute_commit_failure_ok_propagated() {
602        let tx = MockTransaction::new(QueryResultKind::Ok).with_commit_error("commit failed");
603        let client = make_client(MockBackend::new(tx));
604
605        // Ok + write: commit error IS propagated
606        let result = client.execute("db", "insert $x isa thing;", "write").await;
607        let err = result.unwrap_err();
608        assert!(
609            matches!(&err, PipelineError::QueryExecution(msg) if msg.contains("commit failed"))
610        );
611    }
612
613    #[tokio::test]
614    async fn execute_commit_failure_rows_propagated() {
615        let tx =
616            MockTransaction::new(QueryResultKind::Rows(vec![])).with_commit_error("commit failed");
617        let client = make_client(MockBackend::new(tx));
618
619        // Rows + write: commit error IS propagated
620        let result = client.execute("db", "insert $x isa thing;", "write").await;
621        let err = result.unwrap_err();
622        assert!(
623            matches!(&err, PipelineError::QueryExecution(msg) if msg.contains("commit failed"))
624        );
625    }
626
627    #[tokio::test]
628    async fn execute_invalid_transaction_type() {
629        let tx = MockTransaction::new(QueryResultKind::Ok);
630        let backend = MockBackend::new(tx);
631        let open_called = backend.open_called.clone();
632        let client = make_client(backend);
633
634        let result = client.execute("db", "match $x;", "invalid").await;
635        assert!(result.is_err());
636        // Backend should never be called if transaction type is invalid
637        assert_eq!(open_called.load(Ordering::SeqCst), 0);
638    }
639
640    #[test]
641    fn is_connected_delegates_to_backend() {
642        let mut backend = MockBackend::new(MockTransaction::new(QueryResultKind::Ok));
643        backend.is_open = true;
644        let client = make_client(backend);
645        assert!(client.is_connected());
646    }
647
648    #[test]
649    fn is_connected_false_when_backend_closed() {
650        let mut backend = MockBackend::new(MockTransaction::new(QueryResultKind::Ok));
651        backend.is_open = false;
652        let client = make_client(backend);
653        assert!(!client.is_connected());
654    }
655
656    // =============================================
657    // value_to_json tests
658    // =============================================
659
660    #[test]
661    fn value_to_json_boolean_true() {
662        let value = Value::Boolean(true);
663        let json = value_to_json_b8(&value);
664        assert_eq!(json, serde_json::Value::Bool(true));
665    }
666
667    #[test]
668    fn value_to_json_boolean_false() {
669        let value = Value::Boolean(false);
670        let json = value_to_json_b8(&value);
671        assert_eq!(json, serde_json::Value::Bool(false));
672    }
673
674    #[test]
675    fn value_to_json_integer() {
676        let value = Value::Integer(42);
677        let json = value_to_json_b8(&value);
678        assert_eq!(json, serde_json::json!(42));
679    }
680
681    #[test]
682    fn value_to_json_integer_negative() {
683        let value = Value::Integer(-100);
684        let json = value_to_json_b8(&value);
685        assert_eq!(json, serde_json::json!(-100));
686    }
687
688    #[test]
689    fn value_to_json_double() {
690        let value = Value::Double(3.15);
691        let json = value_to_json_b8(&value);
692        assert_eq!(json, serde_json::json!(3.15));
693    }
694
695    #[test]
696    fn value_to_json_string() {
697        let value = Value::String("hello".to_string());
698        let json = value_to_json_b8(&value);
699        assert_eq!(json, serde_json::Value::String("hello".to_string()));
700    }
701
702    #[test]
703    fn value_to_json_string_empty() {
704        let value = Value::String(String::new());
705        let json = value_to_json_b8(&value);
706        assert_eq!(json, serde_json::Value::String(String::new()));
707    }
708
709    #[test]
710    fn value_to_json_date() {
711        let date = chrono::NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
712        let value = Value::Date(date);
713        let json = value_to_json_b8(&value);
714        assert_eq!(json, serde_json::Value::String("2024-01-15".to_string()));
715    }
716
717    #[test]
718    fn value_to_json_datetime() {
719        let dt = chrono::NaiveDate::from_ymd_opt(2024, 1, 15)
720            .unwrap()
721            .and_hms_opt(10, 30, 0)
722            .unwrap();
723        let value = Value::Datetime(dt);
724        let json = value_to_json_b8(&value);
725        let s = json.as_str().unwrap();
726        assert!(s.contains("2024-01-15"));
727    }
728
729    #[test]
730    fn value_to_json_decimal() {
731        let dec = Decimal::new(42, 0);
732        let value = Value::Decimal(dec);
733        let json = value_to_json_b8(&value);
734        assert!(json.is_string());
735    }
736
737    #[test]
738    fn value_to_json_duration() {
739        let dur = Duration::new(1, 2, 3_000_000_000);
740        let value = Value::Duration(dur);
741        let json = value_to_json_b8(&value);
742        assert!(json.is_string());
743    }
744
745    #[test]
746    fn value_to_json_datetime_tz() {
747        use chrono::TimeZone as _;
748        let tz = TimeZone::Fixed(chrono::FixedOffset::east_opt(3600).unwrap());
749        let dt = tz.with_ymd_and_hms(2024, 6, 15, 12, 30, 0).unwrap();
750        let value = Value::DatetimeTZ(dt);
751        let json = value_to_json_b8(&value);
752        let s = json.as_str().unwrap();
753        assert!(s.contains("2024"));
754    }
755
756    // =============================================
757    // concept_to_json tests
758    // =============================================
759
760    #[test]
761    fn concept_to_json_entity_type() {
762        let concept = Concept::EntityType(EntityType {
763            label: "person".to_string(),
764        });
765        let json = concept_to_json_b8(&concept);
766        assert_eq!(json["category"], "EntityType");
767        assert_eq!(json["label"], "person");
768        assert!(json.get("iid").is_none());
769        assert!(json.get("value").is_none());
770    }
771
772    #[test]
773    fn concept_to_json_attribute_type() {
774        let concept = Concept::AttributeType(AttributeType {
775            label: "name".to_string(),
776            value_type: Some(ValueType::String),
777        });
778        let json = concept_to_json_b8(&concept);
779        assert_eq!(json["category"], "AttributeType");
780        assert_eq!(json["label"], "name");
781        assert_eq!(json["value_type"], "string");
782    }
783
784    #[test]
785    fn concept_to_json_value_boolean() {
786        let concept = Concept::Value(Value::Boolean(true));
787        let json = concept_to_json_b8(&concept);
788        assert_eq!(json["category"], "Value");
789        assert_eq!(json["value"], true);
790    }
791
792    #[test]
793    fn concept_to_json_value_integer() {
794        let concept = Concept::Value(Value::Integer(42));
795        let json = concept_to_json_b8(&concept);
796        assert_eq!(json["value"], 42);
797    }
798
799    #[test]
800    fn concept_to_json_value_string() {
801        let concept = Concept::Value(Value::String("hello".to_string()));
802        let json = concept_to_json_b8(&concept);
803        assert_eq!(json["value"], "hello");
804    }
805
806    #[test]
807    fn concept_to_json_entity_with_iid() {
808        let iid: IID = vec![0x01, 0x02, 0x03].into();
809        let concept = Concept::Entity(Entity {
810            iid,
811            type_: Some(EntityType {
812                label: "person".to_string(),
813            }),
814        });
815        let json = concept_to_json_b8(&concept);
816        assert_eq!(json["category"], "Entity");
817        assert_eq!(json["label"], "person");
818        // IID should be present
819        let iid_str = json["iid"].as_str().unwrap();
820        assert!(iid_str.starts_with("0x"));
821    }
822
823    #[test]
824    fn concept_to_json_attribute_with_value() {
825        let iid: IID = vec![0xAA, 0xBB].into();
826        let concept = Concept::Attribute(Attribute {
827            iid,
828            value: Value::String("hello".to_string()),
829            type_: Some(AttributeType {
830                label: "name".to_string(),
831                value_type: Some(ValueType::String),
832            }),
833        });
834        let json = concept_to_json_b8(&concept);
835        assert_eq!(json["category"], "Attribute");
836        assert_eq!(json["label"], "name");
837        // Attribute IID is not exposed via try_get_iid()
838        assert!(json.get("iid").is_none());
839        assert_eq!(json["value"], "hello");
840        assert_eq!(json["value_type"], "string");
841    }
842
843    #[test]
844    fn concept_to_json_attribute_type_without_value_type() {
845        let concept = Concept::AttributeType(AttributeType {
846            label: "abstract_attr".to_string(),
847            value_type: None,
848        });
849        let json = concept_to_json_b8(&concept);
850        assert_eq!(json["label"], "abstract_attr");
851        assert!(json.get("value_type").is_none());
852    }
853
854    // =============================================
855    // QueryExecutor trait impl tests
856    // =============================================
857
858    #[test]
859    fn type_db_client_implements_query_executor() {
860        fn assert_executor<T: QueryExecutor>() {}
861        assert_executor::<TypeDBClient>();
862    }
863
864    #[tokio::test]
865    async fn query_executor_execute_delegates_to_client() {
866        let tx = MockTransaction::new(QueryResultKind::Rows(vec![serde_json::json!({"x": 1})]));
867        let client = make_client(MockBackend::new(tx));
868        let executor: Box<dyn QueryExecutor> = Box::new(client);
869
870        let result = executor
871            .execute("db", "match $x isa thing;", "read")
872            .await
873            .unwrap();
874        assert!(result.is_array());
875        assert_eq!(result.as_array().unwrap().len(), 1);
876    }
877
878    #[test]
879    fn query_executor_is_connected_delegates_to_client() {
880        let mut backend = MockBackend::new(MockTransaction::new(QueryResultKind::Ok));
881        backend.is_open = true;
882        let client = make_client(backend);
883        let executor: Box<dyn QueryExecutor> = Box::new(client);
884        assert!(executor.is_connected());
885    }
886
887    // =============================================
888    // Integration tests (require running TypeDB)
889    // =============================================
890
891    #[tokio::test]
892    #[ignore = "requires running TypeDB server"]
893    #[cfg_attr(coverage_nightly, coverage(off))]
894    async fn integration_connect_invalid_address() {
895        let config = TypeDBSection {
896            address: "localhost:99999".to_string(),
897            database: "test".to_string(),
898            username: "admin".to_string(),
899            password: "password".to_string(),
900            http_port: 8000,
901        };
902        let result = TypeDBClient::connect(&config).await;
903        assert!(result.is_err());
904    }
905
906    /// Live-test target resolved from the environment so the suite can point
907    /// at any disposable TypeDB container instead of a fixed local install.
908    fn live_config() -> TypeDBSection {
909        TypeDBSection {
910            address: std::env::var("TYPEDB_ADDRESS")
911                .unwrap_or_else(|_| "localhost:1729".to_string()),
912            database: std::env::var("TYPEDB_DATABASE").unwrap_or_else(|_| "test".to_string()),
913            username: "admin".to_string(),
914            password: "password".to_string(),
915            http_port: std::env::var("TYPEDB_HTTP_PORT")
916                .ok()
917                .and_then(|port| port.parse().ok())
918                .unwrap_or(8000),
919        }
920    }
921
922    #[tokio::test]
923    #[ignore = "requires running TypeDB server"]
924    #[cfg_attr(coverage_nightly, coverage(off))]
925    async fn integration_connect_success() {
926        let result = TypeDBClient::connect(&live_config()).await;
927        assert!(result.is_ok());
928        assert!(result.unwrap().is_connected());
929    }
930
931    #[tokio::test]
932    #[ignore = "requires running TypeDB server"]
933    #[cfg_attr(coverage_nightly, coverage(off))]
934    async fn integration_execute_roundtrip() {
935        let config = live_config();
936        let client = TypeDBClient::connect(&config)
937            .await
938            .expect("connect failed");
939
940        client
941            .execute(&config.database, "define entity smoke_marker;", "schema")
942            .await
943            .expect("schema define failed");
944
945        let rows = client
946            .execute(&config.database, "match entity $t;", "read")
947            .await
948            .expect("read query failed");
949        let rows = rows.as_array().expect("read result must be a JSON array");
950        assert!(
951            !rows.is_empty(),
952            "expected at least the smoke_marker entity type"
953        );
954    }
955}