Skip to main content

type_bridge_server/
pipeline.rs

1use std::collections::HashMap;
2use std::time::Instant;
3
4use type_bridge_core_lib::ast::Clause;
5use type_bridge_core_lib::compiler::QueryCompiler;
6use type_bridge_core_lib::schema::TypeSchema;
7use type_bridge_core_lib::validation::ValidationEngine;
8
9use crate::error::PipelineError;
10use crate::executor::QueryExecutor;
11use crate::interceptor::crud_interceptor::{CrudInterceptor, CrudInterceptorAdapter};
12use crate::interceptor::{Interceptor, InterceptorChain, RequestContext};
13#[cfg(feature = "v2-query")]
14use crate::interceptor::{V2PolicyOutcome, V2PolicyRequest};
15use crate::schema_source::SchemaSource;
16
17/// Input for a structured (AST-based) query.
18pub struct QueryInput {
19    pub database: Option<String>,
20    pub transaction_type: String,
21    pub clauses: Vec<Clause>,
22    pub metadata: HashMap<String, serde_json::Value>,
23}
24
25/// Input for a validation-only request.
26pub struct ValidateInput {
27    pub clauses: Vec<Clause>,
28}
29
30/// Output from a successful pipeline execution.
31#[derive(Debug)]
32pub struct QueryOutput {
33    pub results: serde_json::Value,
34    pub request_id: String,
35    pub execution_time_ms: u64,
36    pub interceptors_applied: Vec<String>,
37}
38
39/// Output from a validation-only request.
40#[derive(Debug)]
41pub struct ValidateOutput {
42    pub is_valid: bool,
43    pub errors: Vec<ValidationErrorDetail>,
44}
45
46/// A single validation error.
47#[derive(Debug)]
48pub struct ValidationErrorDetail {
49    pub code: String,
50    pub message: String,
51    pub path: String,
52}
53
54#[cfg_attr(coverage_nightly, coverage(off))]
55fn log_query_execution(database: &str, transaction_type: &str, typeql: &str) {
56    tracing::info!(database, transaction_type, "Executing query");
57    tracing::debug!(typeql, "Compiled TypeQL");
58}
59
60/// Transport-agnostic query pipeline.
61///
62/// Encapsulates the full query lifecycle: validate → intercept → compile → execute → intercept.
63/// Use [`PipelineBuilder`] to construct an instance.
64///
65/// # Example
66///
67/// ```rust,ignore
68/// use type_bridge_server::{PipelineBuilder, QueryInput};
69///
70/// let pipeline = PipelineBuilder::new(my_executor)
71///     .with_schema_source(my_schema_source)
72///     .with_default_database("my_db")
73///     .build()?;
74///
75/// let output = pipeline.execute_query(QueryInput { ... }).await?;
76/// ```
77pub struct QueryPipeline {
78    schema: Option<TypeSchema>,
79    validation_engine: ValidationEngine,
80    interceptor_chain: InterceptorChain,
81    default_database: String,
82    executor: Box<dyn QueryExecutor>,
83    skip_validation: bool,
84}
85
86impl QueryPipeline {
87    /// Prove every configured policy explicitly covers typed V2 requests.
88    ///
89    /// Server startup calls this before constructing the V2 router/listener;
90    /// request admission rechecks it defensively through
91    /// [`Self::v2_request_context`].
92    #[cfg(feature = "v2-query")]
93    pub fn validate_v2_coverage(&self) -> Result<(), PipelineError> {
94        self.interceptor_chain
95            .ensure_v2_coverage()
96            .map_err(|error| PipelineError::Interceptor(error.to_string()))
97    }
98
99    /// Construct one V2 policy context after proving every configured
100    /// interceptor explicitly supports typed plans.
101    #[cfg(feature = "v2-query")]
102    pub fn v2_request_context(
103        &self,
104        metadata: HashMap<String, serde_json::Value>,
105    ) -> Result<RequestContext, PipelineError> {
106        self.validate_v2_coverage()?;
107        Ok(RequestContext {
108            request_id: uuid::Uuid::new_v4().to_string(),
109            client_id: "unknown".to_owned(),
110            database: self.default_database.clone(),
111            transaction_type: "read".to_owned(),
112            metadata,
113            timestamp: chrono::Utc::now(),
114            crud_info: None,
115        })
116    }
117
118    /// Authenticate and rate-limit V2 transport metadata before envelope
119    /// decoding or other attacker-controlled contract work.
120    #[cfg(feature = "v2-query")]
121    pub async fn begin_v2_request(
122        &self,
123        context: &mut RequestContext,
124    ) -> Result<(), PipelineError> {
125        self.interceptor_chain
126            .execute_v2_transport(context)
127            .await
128            .map_err(|error| PipelineError::Interceptor(error.to_string()))
129    }
130
131    /// Authorize one exact validated V2 plan before replay/provider admission.
132    #[cfg(feature = "v2-query")]
133    pub async fn authorize_v2_request(
134        &self,
135        request: &V2PolicyRequest<'_>,
136        context: &mut RequestContext,
137    ) -> Result<(), PipelineError> {
138        self.interceptor_chain
139            .execute_v2_request(request, context)
140            .await
141            .map_err(|error| PipelineError::Interceptor(error.to_string()))
142    }
143
144    /// Run configured response and audit policies for every V2 outcome.
145    #[cfg(feature = "v2-query")]
146    pub async fn finish_v2_request(
147        &self,
148        outcome: &V2PolicyOutcome<'_>,
149        context: &RequestContext,
150    ) -> Result<(), PipelineError> {
151        self.interceptor_chain
152            .execute_v2_response(outcome, context)
153            .await
154            .map_err(|error| PipelineError::Interceptor(error.to_string()))
155    }
156
157    /// Execute a structured (AST-based) query through the full pipeline.
158    pub async fn execute_query(&self, input: QueryInput) -> Result<QueryOutput, PipelineError> {
159        let start = Instant::now();
160        let request_id = uuid::Uuid::new_v4().to_string();
161        let database = input
162            .database
163            .unwrap_or_else(|| self.default_database.clone());
164
165        let mut ctx = RequestContext {
166            request_id: request_id.clone(),
167            client_id: "unknown".to_string(),
168            database: database.clone(),
169            transaction_type: input.transaction_type.clone(),
170            metadata: input.metadata,
171            timestamp: chrono::Utc::now(),
172            crud_info: None,
173        };
174
175        // Validate against schema
176        if !self.skip_validation
177            && let Some(schema) = &self.schema
178        {
179            let result = self
180                .validation_engine
181                .validate_query(&input.clauses, schema);
182            if !result.is_valid {
183                return Err(PipelineError::Validation(format!(
184                    "{} validation error(s)",
185                    result.errors.len()
186                )));
187            }
188        }
189
190        // Run request interceptors
191        let clauses = self
192            .interceptor_chain
193            .execute_request(input.clauses, &mut ctx)
194            .await
195            .map_err(|e| PipelineError::Interceptor(e.to_string()))?;
196
197        // Compile to TypeQL
198        let compiler = QueryCompiler::new();
199        let typeql = compiler.compile(&clauses);
200        ctx.metadata.insert(
201            "compiled_typeql".to_string(),
202            serde_json::Value::String(typeql.clone()),
203        );
204
205        // Execute
206        log_query_execution(&database, &input.transaction_type, &typeql);
207
208        let results = self
209            .executor
210            .execute(&database, &typeql, &input.transaction_type)
211            .await?;
212
213        // Run response interceptors
214        self.interceptor_chain
215            .execute_response(&results, &ctx)
216            .await
217            .map_err(|e| PipelineError::Interceptor(e.to_string()))?;
218
219        let elapsed = start.elapsed().as_millis() as u64;
220
221        Ok(QueryOutput {
222            results,
223            request_id,
224            execution_time_ms: elapsed,
225            interceptors_applied: self
226                .interceptor_chain
227                .interceptor_names()
228                .into_iter()
229                .map(String::from)
230                .collect(),
231        })
232    }
233
234    /// Validate clauses against the loaded schema without executing.
235    pub fn validate(&self, input: &ValidateInput) -> Result<ValidateOutput, PipelineError> {
236        let schema = self
237            .schema
238            .as_ref()
239            .ok_or_else(|| PipelineError::Schema("No schema loaded".to_string()))?;
240
241        let result = self
242            .validation_engine
243            .validate_query(&input.clauses, schema);
244
245        let errors = result
246            .errors
247            .iter()
248            .map(|e| ValidationErrorDetail {
249                code: e.code.clone(),
250                message: e.message.clone(),
251                path: e.path.clone(),
252            })
253            .collect();
254
255        Ok(ValidateOutput {
256            is_valid: result.is_valid,
257            errors,
258        })
259    }
260
261    /// Get the loaded schema, if any.
262    pub fn schema(&self) -> Option<&TypeSchema> {
263        self.schema.as_ref()
264    }
265
266    /// Check if the backend executor is connected.
267    pub fn is_connected(&self) -> bool {
268        self.executor.is_connected()
269    }
270
271    /// Get the default database name.
272    pub fn default_database(&self) -> &str {
273        &self.default_database
274    }
275}
276
277/// Builder for constructing a [`QueryPipeline`].
278///
279/// # Example
280///
281/// ```rust,ignore
282/// use type_bridge_server::PipelineBuilder;
283///
284/// let pipeline = PipelineBuilder::new(my_executor)
285///     .with_schema_source(FileSchemaSource::new("schema.tql"))
286///     .with_interceptor(AuditLogInterceptor::new(&config)?)
287///     .with_default_database("my_db")
288///     .build()?;
289/// ```
290pub struct PipelineBuilder {
291    executor: Box<dyn QueryExecutor>,
292    schema_source: Option<Box<dyn SchemaSource>>,
293    interceptors: Vec<Box<dyn Interceptor>>,
294    default_database: String,
295    skip_validation: bool,
296}
297
298impl PipelineBuilder {
299    /// Create a new builder with the given query executor.
300    pub fn new(executor: impl QueryExecutor + 'static) -> Self {
301        Self {
302            executor: Box::new(executor),
303            schema_source: None,
304            interceptors: Vec::new(),
305            default_database: String::new(),
306            skip_validation: false,
307        }
308    }
309
310    /// Set the schema source. The schema will be loaded during [`build()`](Self::build).
311    pub fn with_schema_source(mut self, source: impl SchemaSource + 'static) -> Self {
312        self.schema_source = Some(Box::new(source));
313        self
314    }
315
316    /// Add an interceptor to the pipeline chain.
317    pub fn with_interceptor(mut self, interceptor: impl Interceptor + 'static) -> Self {
318        self.interceptors.push(Box::new(interceptor));
319        self
320    }
321
322    /// Set the default database name used when requests don't specify one.
323    pub fn with_default_database(mut self, database: impl Into<String>) -> Self {
324        self.default_database = database.into();
325        self
326    }
327
328    /// Add a CRUD-aware interceptor to the pipeline chain.
329    ///
330    /// The interceptor is automatically wrapped in a [`CrudInterceptorAdapter`]
331    /// that extracts [`CrudInfo`](crate::interceptor::CrudInfo) and delegates
332    /// to the CRUD-specific hooks.
333    pub fn with_crud_interceptor(self, interceptor: impl CrudInterceptor + 'static) -> Self {
334        self.with_interceptor(CrudInterceptorAdapter::new(interceptor))
335    }
336
337    /// Skip schema validation during query execution.
338    ///
339    /// The schema is still loaded (and accessible via [`QueryPipeline::schema`]),
340    /// but queries are not validated against it before execution.
341    pub fn with_skip_validation(mut self) -> Self {
342        self.skip_validation = true;
343        self
344    }
345
346    /// Build the pipeline, loading the schema if a source was provided.
347    pub fn build(self) -> Result<QueryPipeline, PipelineError> {
348        let schema = match self.schema_source {
349            Some(source) => Some(source.load()?),
350            None => None,
351        };
352
353        Ok(QueryPipeline {
354            schema,
355            validation_engine: ValidationEngine::new(),
356            interceptor_chain: InterceptorChain::new(self.interceptors),
357            default_database: self.default_database,
358            executor: self.executor,
359            skip_validation: self.skip_validation,
360        })
361    }
362}
363
364#[cfg(test)]
365#[cfg_attr(coverage_nightly, coverage(off))]
366mod tests {
367    use std::future::Future;
368    use std::pin::Pin;
369    use std::sync::Arc;
370    use std::sync::atomic::{AtomicUsize, Ordering};
371
372    use type_bridge_core_lib::ast::{Constraint, Pattern, Value};
373
374    use super::*;
375    use crate::interceptor::traits::InterceptError;
376    use crate::test_helpers::{MockExecutor, make_pipeline, make_simple_clauses};
377
378    fn init_tracing() -> tracing::subscriber::DefaultGuard {
379        let subscriber = tracing_subscriber::fmt()
380            .with_max_level(tracing::Level::DEBUG)
381            .with_test_writer()
382            .finish();
383        tracing::subscriber::set_default(subscriber)
384    }
385
386    // --- Helper interceptors ---
387
388    struct PassthroughInterceptor {
389        name: String,
390    }
391
392    impl Interceptor for PassthroughInterceptor {
393        fn name(&self) -> &str {
394            &self.name
395        }
396        fn on_request<'a>(
397            &'a self,
398            clauses: Vec<Clause>,
399            _ctx: &'a mut RequestContext,
400        ) -> Pin<Box<dyn Future<Output = Result<Vec<Clause>, InterceptError>> + Send + 'a>>
401        {
402            Box::pin(async move { Ok(clauses) })
403        }
404    }
405
406    struct RejectingRequestInterceptor;
407
408    impl Interceptor for RejectingRequestInterceptor {
409        fn name(&self) -> &str {
410            "rejector"
411        }
412        fn on_request<'a>(
413            &'a self,
414            _clauses: Vec<Clause>,
415            _ctx: &'a mut RequestContext,
416        ) -> Pin<Box<dyn Future<Output = Result<Vec<Clause>, InterceptError>> + Send + 'a>>
417        {
418            Box::pin(async {
419                Err(InterceptError::AccessDenied {
420                    reason: "test rejection".into(),
421                })
422            })
423        }
424
425        #[cfg(feature = "v2-query")]
426        fn supports_v2(&self) -> bool {
427            true
428        }
429
430        #[cfg(feature = "v2-query")]
431        fn on_v2_transport<'a>(
432            &'a self,
433            _ctx: &'a mut RequestContext,
434        ) -> Pin<Box<dyn Future<Output = Result<(), InterceptError>> + Send + 'a>> {
435            Box::pin(async {
436                Err(InterceptError::AccessDenied {
437                    reason: "test rejection".into(),
438                })
439            })
440        }
441    }
442
443    struct RejectingResponseInterceptor;
444
445    impl Interceptor for RejectingResponseInterceptor {
446        fn name(&self) -> &str {
447            "resp-rejector"
448        }
449        fn on_request<'a>(
450            &'a self,
451            clauses: Vec<Clause>,
452            _ctx: &'a mut RequestContext,
453        ) -> Pin<Box<dyn Future<Output = Result<Vec<Clause>, InterceptError>> + Send + 'a>>
454        {
455            Box::pin(async move { Ok(clauses) })
456        }
457        fn on_response<'a>(
458            &'a self,
459            _result: &'a serde_json::Value,
460            _ctx: &'a RequestContext,
461        ) -> Pin<Box<dyn Future<Output = Result<(), InterceptError>> + Send + 'a>> {
462            Box::pin(async { Err(InterceptError::Internal("response rejected".into())) })
463        }
464    }
465
466    struct CountingInterceptor {
467        name: String,
468        count: Arc<AtomicUsize>,
469    }
470
471    impl Interceptor for CountingInterceptor {
472        fn name(&self) -> &str {
473            &self.name
474        }
475        fn on_request<'a>(
476            &'a self,
477            clauses: Vec<Clause>,
478            _ctx: &'a mut RequestContext,
479        ) -> Pin<Box<dyn Future<Output = Result<Vec<Clause>, InterceptError>> + Send + 'a>>
480        {
481            Box::pin(async move {
482                self.count.fetch_add(1, Ordering::SeqCst);
483                Ok(clauses)
484            })
485        }
486    }
487
488    /// SchemaSource that always fails.
489    struct FailingSchemaSource;
490
491    impl crate::schema_source::SchemaSource for FailingSchemaSource {
492        fn load(&self) -> Result<TypeSchema, PipelineError> {
493            Err(PipelineError::Schema("source failed".into()))
494        }
495    }
496
497    fn make_query_input(clauses: Vec<Clause>) -> QueryInput {
498        QueryInput {
499            database: None,
500            transaction_type: "read".to_string(),
501            clauses,
502            metadata: HashMap::new(),
503        }
504    }
505
506    fn make_query_input_with_db(clauses: Vec<Clause>, db: &str) -> QueryInput {
507        QueryInput {
508            database: Some(db.to_string()),
509            transaction_type: "read".to_string(),
510            clauses,
511            metadata: HashMap::new(),
512        }
513    }
514
515    // =============================================
516    // PipelineBuilder tests
517    // =============================================
518
519    #[test]
520    fn builder_without_schema_source() {
521        let pipeline = PipelineBuilder::new(MockExecutor::new()).build().unwrap();
522        assert!(pipeline.schema().is_none());
523    }
524
525    #[test]
526    fn builder_with_valid_schema_source() {
527        let pipeline = make_pipeline(MockExecutor::new(), true);
528        assert!(pipeline.schema().is_some());
529        let schema = pipeline.schema().unwrap();
530        assert!(schema.entities.contains_key("person"));
531    }
532
533    #[test]
534    fn builder_with_failing_schema_source() {
535        let result = PipelineBuilder::new(MockExecutor::new())
536            .with_schema_source(FailingSchemaSource)
537            .build();
538        let err = result.err().expect("Expected build error");
539        assert!(matches!(&err, PipelineError::Schema(msg) if msg.contains("source failed")));
540    }
541
542    #[test]
543    fn builder_with_default_database() {
544        let pipeline = PipelineBuilder::new(MockExecutor::new())
545            .with_default_database("mydb")
546            .build()
547            .unwrap();
548        assert_eq!(pipeline.default_database(), "mydb");
549    }
550
551    #[test]
552    fn builder_default_empty_database() {
553        let pipeline = PipelineBuilder::new(MockExecutor::new()).build().unwrap();
554        assert_eq!(pipeline.default_database(), "");
555    }
556
557    #[tokio::test]
558    async fn builder_with_interceptors() {
559        let pipeline = PipelineBuilder::new(MockExecutor::new())
560            .with_interceptor(PassthroughInterceptor {
561                name: "first".into(),
562            })
563            .with_interceptor(PassthroughInterceptor {
564                name: "second".into(),
565            })
566            .build()
567            .unwrap();
568        assert!(pipeline.schema().is_none());
569
570        let input = make_query_input(vec![]);
571        let output = pipeline.execute_query(input).await.unwrap();
572        assert_eq!(output.interceptors_applied, vec!["first", "second"]);
573    }
574
575    #[tokio::test]
576    #[cfg(feature = "v2-query")]
577    async fn v2_policy_rejection_is_fail_closed() {
578        let pipeline = PipelineBuilder::new(MockExecutor::new())
579            .with_interceptor(RejectingRequestInterceptor)
580            .build()
581            .unwrap();
582        let mut context = pipeline
583            .v2_request_context(HashMap::from([(
584                "transport".to_owned(),
585                serde_json::json!("v2"),
586            )]))
587            .expect("V2-aware policy coverage");
588        let error = pipeline
589            .begin_v2_request(&mut context)
590            .await
591            .expect_err("the same request policy must gate V2 envelopes");
592        assert!(
593            matches!(error, PipelineError::Interceptor(message) if message.contains("test rejection"))
594        );
595    }
596
597    #[cfg(feature = "v2-query")]
598    struct RewritingInterceptor;
599
600    #[cfg(feature = "v2-query")]
601    impl Interceptor for RewritingInterceptor {
602        fn name(&self) -> &str {
603            "rewriter"
604        }
605
606        fn on_request<'a>(
607            &'a self,
608            _clauses: Vec<Clause>,
609            _ctx: &'a mut RequestContext,
610        ) -> Pin<Box<dyn Future<Output = Result<Vec<Clause>, InterceptError>> + Send + 'a>>
611        {
612            Box::pin(async { Ok(make_simple_clauses()) })
613        }
614    }
615
616    #[tokio::test]
617    #[cfg(feature = "v2-query")]
618    async fn v2_rejects_legacy_ast_policies_without_typed_coverage() {
619        let pipeline = PipelineBuilder::new(MockExecutor::new())
620            .with_interceptor(RewritingInterceptor)
621            .build()
622            .unwrap();
623        let startup_error = pipeline
624            .validate_v2_coverage()
625            .expect_err("startup must reject a legacy-only policy before routing");
626        assert!(
627            matches!(startup_error, PipelineError::Interceptor(message) if message.contains("does not declare typed V2 coverage"))
628        );
629        let error = pipeline
630            .v2_request_context(HashMap::new())
631            .expect_err("request admission must retain the defensive recheck");
632        assert!(
633            matches!(error, PipelineError::Interceptor(message) if message.contains("does not declare typed V2 coverage"))
634        );
635    }
636
637    // =============================================
638    // execute_query tests
639    // =============================================
640
641    #[tokio::test]
642    async fn execute_query_uses_input_database() {
643        let executor = MockExecutor::new();
644        let calls = executor.calls.clone();
645        let pipeline = make_pipeline(executor, false);
646
647        let input = make_query_input_with_db(vec![], "custom_db");
648        pipeline.execute_query(input).await.unwrap();
649
650        let recorded = calls.lock().unwrap();
651        assert_eq!(recorded[0].0, "custom_db");
652    }
653
654    #[tokio::test]
655    async fn execute_query_uses_default_database_when_none() {
656        let executor = MockExecutor::new();
657        let calls = executor.calls.clone();
658        let pipeline = make_pipeline(executor, false);
659
660        let input = make_query_input(vec![]);
661        pipeline.execute_query(input).await.unwrap();
662
663        let recorded = calls.lock().unwrap();
664        assert_eq!(recorded[0].0, "test_db"); // from make_pipeline
665    }
666
667    #[tokio::test]
668    async fn execute_query_skips_validation_when_no_schema() {
669        let pipeline = make_pipeline(MockExecutor::new(), false);
670        let clauses = vec![Clause::Match(vec![Pattern::Entity {
671            variable: "x".to_string(),
672            type_name: "nonexistent_type".to_string(),
673            constraints: vec![],
674            is_strict: false,
675        }])];
676        let input = make_query_input(clauses);
677        let result = pipeline.execute_query(input).await;
678        assert!(result.is_ok());
679    }
680
681    #[tokio::test]
682    async fn execute_query_validates_when_schema_present_valid() {
683        let pipeline = make_pipeline(MockExecutor::new(), true);
684        let input = make_query_input(make_simple_clauses());
685        let result = pipeline.execute_query(input).await;
686        assert!(result.is_ok());
687    }
688
689    #[tokio::test]
690    async fn execute_query_validates_when_schema_present_invalid() {
691        let pipeline = make_pipeline(MockExecutor::new(), true);
692        let clauses = vec![Clause::Match(vec![Pattern::Entity {
693            variable: "p".to_string(),
694            type_name: "person".to_string(),
695            constraints: vec![Constraint::Has {
696                attr_name: "nonexistent_attr".to_string(),
697                value: Value::Literal(type_bridge_core_lib::ast::LiteralValue {
698                    value: serde_json::json!("val"),
699                    value_type: "string".to_string(),
700                }),
701            }],
702            is_strict: false,
703        }])];
704        let input = make_query_input(clauses);
705        let result = pipeline.execute_query(input).await;
706        let err = result.unwrap_err();
707        assert!(matches!(&err, PipelineError::Validation(msg) if msg.contains("validation error")));
708    }
709
710    #[tokio::test]
711    async fn execute_query_request_interceptor_failure() {
712        assert_eq!(RejectingRequestInterceptor.name(), "rejector");
713        let pipeline = PipelineBuilder::new(MockExecutor::new())
714            .with_interceptor(RejectingRequestInterceptor)
715            .build()
716            .unwrap();
717        let input = make_query_input(vec![]);
718        let result = pipeline.execute_query(input).await;
719        let err = result.unwrap_err();
720        assert!(matches!(&err, PipelineError::Interceptor(msg) if msg.contains("test rejection")));
721    }
722
723    #[tokio::test]
724    async fn execute_query_executor_failure() {
725        let pipeline = make_pipeline(MockExecutor::failing("db crash"), false);
726        let input = make_query_input(vec![]);
727        let result = pipeline.execute_query(input).await;
728        let err = result.unwrap_err();
729        assert!(matches!(&err, PipelineError::QueryExecution(msg) if msg.contains("db crash")));
730    }
731
732    #[tokio::test]
733    async fn execute_query_response_interceptor_failure() {
734        assert_eq!(RejectingResponseInterceptor.name(), "resp-rejector");
735        let pipeline = PipelineBuilder::new(MockExecutor::new())
736            .with_interceptor(RejectingResponseInterceptor)
737            .build()
738            .unwrap();
739        let input = make_query_input(vec![]);
740        let result = pipeline.execute_query(input).await;
741        let err = result.unwrap_err();
742        assert!(
743            matches!(&err, PipelineError::Interceptor(msg) if msg.contains("response rejected"))
744        );
745    }
746
747    #[tokio::test]
748    async fn execute_query_success_output_fields() {
749        let _guard = init_tracing();
750        let count = Arc::new(AtomicUsize::new(0));
751        let pipeline =
752            PipelineBuilder::new(MockExecutor::with_result(serde_json::json!({"ok": true})))
753                .with_default_database("test_db")
754                .with_interceptor(CountingInterceptor {
755                    name: "counter".into(),
756                    count: count.clone(),
757                })
758                .build()
759                .unwrap();
760
761        let input = make_query_input(vec![]);
762        let output = pipeline.execute_query(input).await.unwrap();
763
764        assert!(!output.request_id.is_empty());
765        assert_eq!(output.results, serde_json::json!({"ok": true}));
766        assert_eq!(output.interceptors_applied, vec!["counter"]);
767        assert_eq!(count.load(Ordering::SeqCst), 1);
768    }
769
770    #[tokio::test]
771    async fn execute_query_empty_clauses_success() {
772        let pipeline = make_pipeline(MockExecutor::new(), false);
773        let input = make_query_input(vec![]);
774        let result = pipeline.execute_query(input).await;
775        assert!(result.is_ok());
776    }
777
778    #[tokio::test]
779    async fn execute_query_compiled_typeql_in_metadata() {
780        let executor = MockExecutor::new();
781        let calls = executor.calls.clone();
782        let pipeline = make_pipeline(executor, false);
783
784        let clauses = make_simple_clauses();
785        let input = make_query_input(clauses);
786        pipeline.execute_query(input).await.unwrap();
787
788        let recorded = calls.lock().unwrap();
789        assert!(!recorded[0].1.is_empty());
790    }
791
792    #[tokio::test]
793    async fn execute_query_passes_transaction_type() {
794        let executor = MockExecutor::new();
795        let calls = executor.calls.clone();
796        let pipeline = make_pipeline(executor, false);
797
798        let input = QueryInput {
799            database: None,
800            transaction_type: "write".to_string(),
801            clauses: vec![],
802            metadata: HashMap::new(),
803        };
804        pipeline.execute_query(input).await.unwrap();
805
806        let recorded = calls.lock().unwrap();
807        assert_eq!(recorded[0].2, "write");
808    }
809
810    // =============================================
811    // validate tests
812    // =============================================
813
814    #[test]
815    fn validate_no_schema_returns_error() {
816        let pipeline = make_pipeline(MockExecutor::new(), false);
817        let input = ValidateInput { clauses: vec![] };
818        let result = pipeline.validate(&input);
819        let err = result.unwrap_err();
820        assert!(matches!(&err, PipelineError::Schema(msg) if msg.contains("No schema loaded")));
821    }
822
823    #[test]
824    fn validate_valid_clauses() {
825        let pipeline = make_pipeline(MockExecutor::new(), true);
826        let input = ValidateInput {
827            clauses: make_simple_clauses(),
828        };
829        let result = pipeline.validate(&input).unwrap();
830        assert!(result.is_valid);
831        assert!(result.errors.is_empty());
832    }
833
834    #[test]
835    fn validate_invalid_clauses() {
836        let pipeline = make_pipeline(MockExecutor::new(), true);
837        let input = ValidateInput {
838            clauses: vec![Clause::Match(vec![Pattern::Entity {
839                variable: "p".to_string(),
840                type_name: "person".to_string(),
841                constraints: vec![Constraint::Has {
842                    attr_name: "nonexistent_attr".to_string(),
843                    value: Value::Literal(type_bridge_core_lib::ast::LiteralValue {
844                        value: serde_json::json!("val"),
845                        value_type: "string".to_string(),
846                    }),
847                }],
848                is_strict: false,
849            }])],
850        };
851        let result = pipeline.validate(&input).unwrap();
852        assert!(!result.is_valid);
853        assert!(!result.errors.is_empty());
854    }
855
856    #[test]
857    fn validate_error_detail_fields() {
858        let pipeline = make_pipeline(MockExecutor::new(), true);
859        let input = ValidateInput {
860            clauses: vec![Clause::Match(vec![Pattern::Entity {
861                variable: "x".to_string(),
862                type_name: "person".to_string(),
863                constraints: vec![Constraint::Has {
864                    attr_name: "nonexistent_attr".to_string(),
865                    value: Value::Literal(type_bridge_core_lib::ast::LiteralValue {
866                        value: serde_json::json!("val"),
867                        value_type: "string".to_string(),
868                    }),
869                }],
870                is_strict: false,
871            }])],
872        };
873        let result = pipeline.validate(&input).unwrap();
874        assert!(!result.is_valid);
875        let error = &result.errors[0];
876        assert!(!error.code.is_empty());
877        assert!(!error.message.is_empty());
878    }
879
880    #[test]
881    fn validate_empty_clauses_with_schema() {
882        let pipeline = make_pipeline(MockExecutor::new(), true);
883        let input = ValidateInput { clauses: vec![] };
884        let result = pipeline.validate(&input).unwrap();
885        assert!(result.is_valid);
886    }
887
888    // =============================================
889    // Accessor tests
890    // =============================================
891
892    #[test]
893    fn schema_returns_some_when_loaded() {
894        let pipeline = make_pipeline(MockExecutor::new(), true);
895        assert!(pipeline.schema().is_some());
896    }
897
898    #[test]
899    fn schema_returns_none_when_not_loaded() {
900        let pipeline = make_pipeline(MockExecutor::new(), false);
901        assert!(pipeline.schema().is_none());
902    }
903
904    #[test]
905    fn is_connected_delegates_to_executor() {
906        let executor = MockExecutor::new();
907        *executor.connected.lock().unwrap() = true;
908        let pipeline = make_pipeline(executor, false);
909        assert!(pipeline.is_connected());
910    }
911
912    #[test]
913    fn is_connected_false_when_executor_disconnected() {
914        let executor = MockExecutor::new();
915        *executor.connected.lock().unwrap() = false;
916        let pipeline = make_pipeline(executor, false);
917        assert!(!pipeline.is_connected());
918    }
919
920    #[test]
921    fn default_database_returns_configured_value() {
922        let pipeline = PipelineBuilder::new(MockExecutor::new())
923            .with_default_database("my_database")
924            .build()
925            .unwrap();
926        assert_eq!(pipeline.default_database(), "my_database");
927    }
928
929    #[test]
930    fn default_database_empty_when_not_set() {
931        let pipeline = PipelineBuilder::new(MockExecutor::new()).build().unwrap();
932        assert_eq!(pipeline.default_database(), "");
933    }
934}