Skip to main content

type_bridge_server/typedb/
client.rs

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