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
17pub 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
25pub struct ValidateInput {
27 pub clauses: Vec<Clause>,
28}
29
30#[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#[derive(Debug)]
41pub struct ValidateOutput {
42 pub is_valid: bool,
43 pub errors: Vec<ValidationErrorDetail>,
44}
45
46#[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
60pub 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 #[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 #[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 #[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 #[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 #[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 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 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 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 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 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 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 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 pub fn schema(&self) -> Option<&TypeSchema> {
263 self.schema.as_ref()
264 }
265
266 pub fn is_connected(&self) -> bool {
268 self.executor.is_connected()
269 }
270
271 pub fn default_database(&self) -> &str {
273 &self.default_database
274 }
275}
276
277pub 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 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 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 pub fn with_interceptor(mut self, interceptor: impl Interceptor + 'static) -> Self {
318 self.interceptors.push(Box::new(interceptor));
319 self
320 }
321
322 pub fn with_default_database(mut self, database: impl Into<String>) -> Self {
324 self.default_database = database.into();
325 self
326 }
327
328 pub fn with_crud_interceptor(self, interceptor: impl CrudInterceptor + 'static) -> Self {
334 self.with_interceptor(CrudInterceptorAdapter::new(interceptor))
335 }
336
337 pub fn with_skip_validation(mut self) -> Self {
342 self.skip_validation = true;
343 self
344 }
345
346 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 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 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 #[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 #[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"); }
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 #[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 #[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}