Skip to main content

reddb_rql/
builders.rs

1use super::*;
2use crate::sql_lowering::{filter_to_expr, projection_to_select_item};
3
4pub struct TableQueryBuilder {
5    query: TableQuery,
6}
7
8impl TableQueryBuilder {
9    /// Create a new builder
10    pub fn new(table: &str) -> Self {
11        Self {
12            query: TableQuery::new(table),
13        }
14    }
15
16    /// Set alias
17    pub fn alias(mut self, alias: &str) -> Self {
18        self.query.alias = Some(alias.to_string());
19        self
20    }
21
22    /// Add column to select
23    pub fn select(mut self, column: &str) -> Self {
24        let field = FieldRef::column(
25            self.query.alias.as_deref().unwrap_or(&self.query.table),
26            column,
27        );
28        self.query.select_items.push(SelectItem::Expr {
29            expr: Expr::col(field.clone()),
30            alias: None,
31        });
32        self.query.columns.push(Projection::from_field(field));
33        self
34    }
35
36    /// Add all columns
37    pub fn select_all(mut self) -> Self {
38        self.query.select_items = vec![SelectItem::Wildcard];
39        self.query.columns.clear();
40        self
41    }
42
43    /// Add filter
44    pub fn filter(mut self, f: Filter) -> Self {
45        let f_expr = filter_to_expr(&f);
46        self.query.where_expr = Some(match self.query.where_expr.take() {
47            Some(existing) => Expr::binop(BinOp::And, existing, f_expr),
48            None => f_expr,
49        });
50        self.query.filter = Some(match self.query.filter.take() {
51            Some(existing) => existing.and(f),
52            None => f,
53        });
54        self
55    }
56
57    /// Add order by
58    pub fn order_by(mut self, clause: OrderByClause) -> Self {
59        self.query.order_by.push(clause);
60        self
61    }
62
63    /// Set limit
64    pub fn limit(mut self, n: u64) -> Self {
65        self.query.limit = Some(n);
66        self
67    }
68
69    /// Set offset
70    pub fn offset(mut self, n: u64) -> Self {
71        self.query.offset = Some(n);
72        self
73    }
74
75    /// Join with a graph pattern
76    pub fn join_graph(self, pattern: GraphPattern, on: JoinCondition) -> JoinQueryBuilder {
77        JoinQueryBuilder {
78            left: QueryExpr::Table(self.query),
79            right: QueryExpr::Graph(GraphQuery::new(pattern)),
80            on,
81            join_type: JoinType::Inner,
82            filter: None,
83            order_by: Vec::new(),
84            limit: None,
85            offset: None,
86            return_items: Vec::new(),
87            return_: Vec::new(),
88        }
89    }
90
91    /// Join with another table source
92    pub fn join_table(self, table: &str, on: JoinCondition) -> JoinQueryBuilder {
93        JoinQueryBuilder {
94            left: QueryExpr::Table(self.query),
95            right: QueryExpr::Table(TableQuery::new(table)),
96            on,
97            join_type: JoinType::Inner,
98            filter: None,
99            order_by: Vec::new(),
100            limit: None,
101            offset: None,
102            return_items: Vec::new(),
103            return_: Vec::new(),
104        }
105    }
106
107    /// Join with a vector query
108    pub fn join_vector(self, query: VectorQuery, on: JoinCondition) -> JoinQueryBuilder {
109        JoinQueryBuilder {
110            left: QueryExpr::Table(self.query),
111            right: QueryExpr::Vector(query),
112            on,
113            join_type: JoinType::Inner,
114            filter: None,
115            order_by: Vec::new(),
116            limit: None,
117            offset: None,
118            return_items: Vec::new(),
119            return_: Vec::new(),
120        }
121    }
122
123    /// Join with a path query
124    pub fn join_path(self, query: PathQuery, on: JoinCondition) -> JoinQueryBuilder {
125        JoinQueryBuilder {
126            left: QueryExpr::Table(self.query),
127            right: QueryExpr::Path(query),
128            on,
129            join_type: JoinType::Inner,
130            filter: None,
131            order_by: Vec::new(),
132            limit: None,
133            offset: None,
134            return_items: Vec::new(),
135            return_: Vec::new(),
136        }
137    }
138
139    /// Join with a hybrid query
140    pub fn join_hybrid(self, query: HybridQuery, on: JoinCondition) -> JoinQueryBuilder {
141        JoinQueryBuilder {
142            left: QueryExpr::Table(self.query),
143            right: QueryExpr::Hybrid(query),
144            on,
145            join_type: JoinType::Inner,
146            filter: None,
147            order_by: Vec::new(),
148            limit: None,
149            offset: None,
150            return_items: Vec::new(),
151            return_: Vec::new(),
152        }
153    }
154
155    /// Build the query expression
156    pub fn build(self) -> QueryExpr {
157        QueryExpr::Table(self.query)
158    }
159}
160
161/// Builder for graph queries
162pub struct GraphQueryBuilder {
163    query: GraphQuery,
164}
165
166impl GraphQueryBuilder {
167    /// Create a new builder
168    pub fn new() -> Self {
169        Self {
170            query: GraphQuery::new(GraphPattern::new()),
171        }
172    }
173
174    /// Add node pattern
175    pub fn node(mut self, pattern: NodePattern) -> Self {
176        self.query.pattern.nodes.push(pattern);
177        self
178    }
179
180    /// Add edge pattern
181    pub fn edge(mut self, pattern: EdgePattern) -> Self {
182        self.query.pattern.edges.push(pattern);
183        self
184    }
185
186    /// Add filter
187    pub fn filter(mut self, f: Filter) -> Self {
188        self.query.filter = Some(match self.query.filter.take() {
189            Some(existing) => existing.and(f),
190            None => f,
191        });
192        self
193    }
194
195    /// Set outer alias
196    pub fn alias(mut self, alias: &str) -> Self {
197        self.query.alias = Some(alias.to_string());
198        self
199    }
200
201    /// Set row limit
202    pub fn limit(mut self, n: u64) -> Self {
203        self.query.limit = Some(n);
204        self
205    }
206
207    /// Add return projection
208    pub fn return_field(mut self, field: FieldRef) -> Self {
209        self.query.return_.push(Projection::from_field(field));
210        self
211    }
212
213    /// Build the query expression
214    pub fn build(self) -> QueryExpr {
215        QueryExpr::Graph(self.query)
216    }
217}
218
219impl Default for GraphQueryBuilder {
220    fn default() -> Self {
221        Self::new()
222    }
223}
224
225/// Builder for join queries
226pub struct JoinQueryBuilder {
227    left: QueryExpr,
228    right: QueryExpr,
229    on: JoinCondition,
230    join_type: JoinType,
231    filter: Option<Filter>,
232    order_by: Vec<OrderByClause>,
233    limit: Option<u64>,
234    offset: Option<u64>,
235    return_items: Vec<SelectItem>,
236    return_: Vec<Projection>,
237}
238
239impl JoinQueryBuilder {
240    /// Set join type
241    pub fn join_type(mut self, jt: JoinType) -> Self {
242        self.join_type = jt;
243        self
244    }
245
246    /// Set alias for the right-hand source
247    pub fn right_alias(mut self, alias: &str) -> Self {
248        let alias = alias.to_string();
249        match &mut self.right {
250            QueryExpr::Table(table) => table.alias = Some(alias.clone()),
251            QueryExpr::Graph(graph) => graph.alias = Some(alias.clone()),
252            QueryExpr::Path(path) => path.alias = Some(alias.clone()),
253            QueryExpr::Vector(vector) => vector.alias = Some(alias.clone()),
254            QueryExpr::Hybrid(hybrid) => hybrid.alias = Some(alias.clone()),
255            QueryExpr::Join(_)
256            | QueryExpr::Insert(_)
257            | QueryExpr::Update(_)
258            | QueryExpr::Delete(_)
259            | QueryExpr::CreateTable(_)
260            | QueryExpr::CreateCollection(_)
261            | QueryExpr::CreateVector(_)
262            | QueryExpr::DropTable(_)
263            | QueryExpr::DropGraph(_)
264            | QueryExpr::DropVector(_)
265            | QueryExpr::DropDocument(_)
266            | QueryExpr::DropKv(_)
267            | QueryExpr::DropCollection(_)
268            | QueryExpr::Truncate(_)
269            | QueryExpr::AlterTable(_)
270            | QueryExpr::CreateVcsRef(_)
271            | QueryExpr::DropVcsRef(_)
272            | QueryExpr::ForkStore(_)
273            | QueryExpr::PromoteFork(_)
274            | QueryExpr::DropFork(_)
275            | QueryExpr::GraphCommand(_)
276            | QueryExpr::SearchCommand(_)
277            | QueryExpr::CreateIndex(_)
278            | QueryExpr::DropIndex(_)
279            | QueryExpr::ProbabilisticCommand(_)
280            | QueryExpr::Ask(_)
281            | QueryExpr::SetConfig { .. }
282            | QueryExpr::ShowConfig { .. }
283            | QueryExpr::SetSecret { .. }
284            | QueryExpr::DeleteSecret { .. }
285            | QueryExpr::ShowSecrets { .. }
286            | QueryExpr::SetKv { .. }
287            | QueryExpr::DeleteKv { .. }
288            | QueryExpr::SetTenant(_)
289            | QueryExpr::ShowTenant
290            | QueryExpr::CreateTimeSeries(_)
291            | QueryExpr::CreateMetric(_)
292            | QueryExpr::AlterMetric(_)
293            | QueryExpr::CreateSlo(_)
294            | QueryExpr::DropTimeSeries(_)
295            | QueryExpr::CreateQueue(_)
296            | QueryExpr::AlterQueue(_)
297            | QueryExpr::DropQueue(_)
298            | QueryExpr::QueueSelect(_)
299            | QueryExpr::QueueCommand(_)
300            | QueryExpr::KvCommand(_)
301            | QueryExpr::ConfigCommand(_)
302            | QueryExpr::CreateTree(_)
303            | QueryExpr::DropTree(_)
304            | QueryExpr::TreeCommand(_)
305            | QueryExpr::ExplainAlter(_)
306            | QueryExpr::TransactionControl(_)
307            | QueryExpr::MaintenanceCommand(_)
308            | QueryExpr::VcsCommand(_)
309            | QueryExpr::CreateSchema(_)
310            | QueryExpr::DropSchema(_)
311            | QueryExpr::CreateSequence(_)
312            | QueryExpr::DropSequence(_)
313            | QueryExpr::CopyFrom(_)
314            | QueryExpr::CreateView(_)
315            | QueryExpr::DropView(_)
316            | QueryExpr::RefreshMaterializedView(_)
317            | QueryExpr::CreatePolicy(_)
318            | QueryExpr::DropPolicy(_)
319            | QueryExpr::CreateServer(_)
320            | QueryExpr::DropServer(_)
321            | QueryExpr::CreateForeignTable(_)
322            | QueryExpr::DropForeignTable(_)
323            | QueryExpr::Grant(_)
324            | QueryExpr::Revoke(_)
325            | QueryExpr::AlterUser(_)
326            | QueryExpr::CreateUser(_)
327            | QueryExpr::CreateIamPolicy { .. }
328            | QueryExpr::DropIamPolicy { .. }
329            | QueryExpr::AttachPolicy { .. }
330            | QueryExpr::DetachPolicy { .. }
331            | QueryExpr::ShowPolicies { .. }
332            | QueryExpr::ShowEffectivePermissions { .. }
333            | QueryExpr::RankOf(_)
334            | QueryExpr::ApproxRankOf(_)
335            | QueryExpr::RankRange(_)
336            | QueryExpr::SimulatePolicy { .. }
337            | QueryExpr::LintPolicy { .. }
338            | QueryExpr::MigratePolicyMode { .. }
339            | QueryExpr::CreateMigration(_)
340            | QueryExpr::ApplyMigration(_)
341            | QueryExpr::RollbackMigration(_)
342            | QueryExpr::ExplainMigration(_)
343            | QueryExpr::Explain(_)
344            | QueryExpr::EventsBackfill(_)
345            | QueryExpr::EventsBackfillStatus { .. } => {}
346        }
347        self
348    }
349
350    /// Add post-join filter
351    pub fn filter(mut self, f: Filter) -> Self {
352        self.filter = Some(match self.filter.take() {
353            Some(existing) => existing.and(f),
354            None => f,
355        });
356        self
357    }
358
359    /// Add post-join ordering
360    pub fn order_by(mut self, clause: OrderByClause) -> Self {
361        self.order_by.push(clause);
362        self
363    }
364
365    /// Set post-join limit
366    pub fn limit(mut self, n: u64) -> Self {
367        self.limit = Some(n);
368        self
369    }
370
371    /// Set post-join offset
372    pub fn offset(mut self, n: u64) -> Self {
373        self.offset = Some(n);
374        self
375    }
376
377    /// Add post-join projected field
378    pub fn return_field(mut self, field: FieldRef) -> Self {
379        let projection = Projection::from_field(field);
380        if let Some(item) = projection_to_select_item(&projection) {
381            self.return_items.push(item);
382        }
383        self.return_.push(projection);
384        self
385    }
386
387    /// Add post-join projected column
388    pub fn select(mut self, column: &str) -> Self {
389        let projection = Projection::from_field(FieldRef::column("", column));
390        if let Some(item) = projection_to_select_item(&projection) {
391            self.return_items.push(item);
392        }
393        self.return_.push(projection);
394        self
395    }
396
397    /// Build the query expression
398    pub fn build(self) -> QueryExpr {
399        QueryExpr::Join(JoinQuery {
400            left: Box::new(self.left),
401            right: Box::new(self.right),
402            join_type: self.join_type,
403            on: self.on,
404            filter: self.filter,
405            order_by: self.order_by,
406            limit: self.limit,
407            offset: self.offset,
408            return_items: self.return_items,
409            return_: self.return_,
410        })
411    }
412}
413
414/// Builder for path queries
415pub struct PathQueryBuilder {
416    query: PathQuery,
417}
418
419impl PathQueryBuilder {
420    /// Create a new builder
421    pub fn new(from: NodeSelector, to: NodeSelector) -> Self {
422        Self {
423            query: PathQuery::new(from, to),
424        }
425    }
426
427    /// Add edge label string to traverse (preferred).
428    pub fn via_label(mut self, label: impl Into<String>) -> Self {
429        self.query.via.push(label.into());
430        self
431    }
432
433    /// Set max length
434    pub fn max_length(mut self, n: u32) -> Self {
435        self.query.max_length = n;
436        self
437    }
438
439    /// Add filter
440    pub fn filter(mut self, f: Filter) -> Self {
441        self.query.filter = Some(f);
442        self
443    }
444
445    /// Set outer alias
446    pub fn alias(mut self, alias: &str) -> Self {
447        self.query.alias = Some(alias.to_string());
448        self
449    }
450
451    /// Build the query expression
452    pub fn build(self) -> QueryExpr {
453        QueryExpr::Path(self.query)
454    }
455}
456
457// ============================================================================
458// Common Table Expressions (CTEs)
459// ============================================================================
460
461/// A Common Table Expression (CTE) definition
462///
463/// CTEs provide named subqueries that can be referenced multiple times
464/// within the main query. Recursive CTEs enable hierarchical queries.
465///
466/// # Examples
467///
468/// ```text
469/// -- Non-recursive CTE
470/// WITH active_hosts AS (
471///     SELECT * FROM hosts WHERE last_seen > now() - interval '1 hour'
472/// )
473/// SELECT * FROM active_hosts WHERE criticality > 5
474///
475/// -- Recursive CTE for attack paths
476/// WITH RECURSIVE attack_path AS (
477///     -- Base case: starting host
478///     SELECT id, ip, 0 as depth FROM hosts WHERE ip = '192.168.1.1'
479///     UNION ALL
480///     -- Recursive case: follow connections
481///     SELECT h.id, h.ip, ap.depth + 1
482///     FROM attack_path ap
483///     JOIN connections c ON c.source_id = ap.id
484///     JOIN hosts h ON h.id = c.target_id
485///     WHERE ap.depth < 10
486/// )
487/// SELECT * FROM attack_path
488/// ```
489#[derive(Debug, Clone)]
490pub struct CteDefinition {
491    /// Name of the CTE (used to reference it in the main query)
492    pub name: String,
493    /// Optional column aliases for the CTE result
494    pub columns: Vec<String>,
495    /// The query that defines this CTE
496    pub query: Box<QueryExpr>,
497    /// Whether this is a recursive CTE
498    pub recursive: bool,
499}
500
501impl CteDefinition {
502    /// Create a new non-recursive CTE
503    pub fn new(name: &str, query: QueryExpr) -> Self {
504        Self {
505            name: name.to_string(),
506            columns: Vec::new(),
507            query: Box::new(query),
508            recursive: false,
509        }
510    }
511
512    /// Create a recursive CTE
513    pub fn recursive(name: &str, query: QueryExpr) -> Self {
514        Self {
515            name: name.to_string(),
516            columns: Vec::new(),
517            query: Box::new(query),
518            recursive: true,
519        }
520    }
521
522    /// Add column aliases
523    pub fn with_columns(mut self, columns: Vec<String>) -> Self {
524        self.columns = columns;
525        self
526    }
527}
528
529/// WITH clause containing one or more CTEs
530#[derive(Debug, Clone, Default)]
531pub struct WithClause {
532    /// List of CTE definitions
533    pub ctes: Vec<CteDefinition>,
534    /// Whether any CTE in the clause is recursive
535    pub has_recursive: bool,
536}
537
538impl WithClause {
539    /// Create a new WITH clause
540    pub fn new() -> Self {
541        Self::default()
542    }
543
544    /// Add a CTE definition
545    // Builder method appending a CTE to the WITH clause; unrelated to
546    // `std::ops::Add`, so that trait is intentionally not implemented.
547    #[allow(clippy::should_implement_trait)]
548    pub fn add(mut self, cte: CteDefinition) -> Self {
549        if cte.recursive {
550            self.has_recursive = true;
551        }
552        self.ctes.push(cte);
553        self
554    }
555
556    /// Check if empty
557    pub fn is_empty(&self) -> bool {
558        self.ctes.is_empty()
559    }
560
561    /// Get a CTE by name
562    pub fn get(&self, name: &str) -> Option<&CteDefinition> {
563        self.ctes.iter().find(|c| c.name == name)
564    }
565}
566
567/// Query with optional WITH clause
568#[derive(Debug, Clone)]
569pub struct QueryWithCte {
570    /// Optional WITH clause
571    pub with_clause: Option<WithClause>,
572    /// The main query
573    pub query: QueryExpr,
574}
575
576impl QueryWithCte {
577    /// Create a query without CTEs
578    pub fn simple(query: QueryExpr) -> Self {
579        Self {
580            with_clause: None,
581            query,
582        }
583    }
584
585    /// Create a query with CTEs
586    pub fn with_ctes(with_clause: WithClause, query: QueryExpr) -> Self {
587        Self {
588            with_clause: Some(with_clause),
589            query,
590        }
591    }
592}
593
594/// Builder for constructing queries with CTEs
595pub struct CteQueryBuilder {
596    with_clause: WithClause,
597}
598
599impl CteQueryBuilder {
600    /// Start building a WITH clause
601    pub fn new() -> Self {
602        Self {
603            with_clause: WithClause::new(),
604        }
605    }
606
607    /// Add a non-recursive CTE
608    pub fn cte(mut self, name: &str, query: QueryExpr) -> Self {
609        self.with_clause = self.with_clause.add(CteDefinition::new(name, query));
610        self
611    }
612
613    /// Add a recursive CTE
614    pub fn recursive_cte(mut self, name: &str, query: QueryExpr) -> Self {
615        self.with_clause = self.with_clause.add(CteDefinition::recursive(name, query));
616        self
617    }
618
619    /// Add a CTE with column aliases
620    pub fn cte_with_columns(mut self, name: &str, columns: Vec<String>, query: QueryExpr) -> Self {
621        let cte = CteDefinition::new(name, query).with_columns(columns);
622        self.with_clause = self.with_clause.add(cte);
623        self
624    }
625
626    /// Build the query with the main query expression
627    pub fn build(self, main_query: QueryExpr) -> QueryWithCte {
628        QueryWithCte::with_ctes(self.with_clause, main_query)
629    }
630}
631
632impl Default for CteQueryBuilder {
633    fn default() -> Self {
634        Self::new()
635    }
636}
637
638#[cfg(test)]
639mod tests {
640    use super::*;
641
642    fn eq_filter(column: &str, value: Value) -> Filter {
643        Filter::compare(FieldRef::column("", column), CompareOp::Eq, value)
644    }
645
646    fn join_condition() -> JoinCondition {
647        JoinCondition::new(
648            FieldRef::column("left", "id"),
649            FieldRef::column("right", "id"),
650        )
651    }
652
653    #[test]
654    fn table_builder_covers_selection_filters_order_limit_and_offset() {
655        let query = TableQueryBuilder::new("hosts")
656            .alias("h")
657            .select("ip")
658            .filter(eq_filter("os", Value::text("linux")))
659            .filter(eq_filter("active", Value::Boolean(true)))
660            .order_by(OrderByClause::desc(FieldRef::column("h", "last_seen")))
661            .limit(10)
662            .offset(5)
663            .build();
664
665        let QueryExpr::Table(table) = query else {
666            panic!("expected table query");
667        };
668        assert_eq!(table.table, "hosts");
669        assert_eq!(table.alias.as_deref(), Some("h"));
670        assert_eq!(table.select_items.len(), 1);
671        assert_eq!(table.columns.len(), 1);
672        assert!(matches!(table.filter, Some(Filter::And(_, _))));
673        assert!(matches!(
674            table.where_expr,
675            Some(Expr::BinaryOp { op: BinOp::And, .. })
676        ));
677        assert_eq!(table.order_by.len(), 1);
678        assert_eq!(table.limit, Some(10));
679        assert_eq!(table.offset, Some(5));
680
681        let QueryExpr::Table(table) = TableQueryBuilder::new("hosts").select_all().build() else {
682            panic!("expected table query");
683        };
684        assert_eq!(table.select_items, vec![SelectItem::Wildcard]);
685        assert!(table.columns.is_empty());
686    }
687
688    #[test]
689    fn graph_builder_combines_filters_alias_limit_and_returns() {
690        let query = GraphQueryBuilder::new()
691            .node(NodePattern::new("h").of_label("Host"))
692            .edge(EdgePattern::new("h", "s").of_label("HAS_SERVICE"))
693            .filter(eq_filter("critical", Value::Boolean(true)))
694            .filter(eq_filter("active", Value::Boolean(true)))
695            .alias("g")
696            .limit(3)
697            .return_field(FieldRef::node_prop("h", "ip"))
698            .build();
699
700        let QueryExpr::Graph(graph) = query else {
701            panic!("expected graph query");
702        };
703        assert_eq!(graph.alias.as_deref(), Some("g"));
704        assert_eq!(graph.pattern.nodes.len(), 1);
705        assert_eq!(graph.pattern.edges.len(), 1);
706        assert!(matches!(graph.filter, Some(Filter::And(_, _))));
707        assert_eq!(graph.limit, Some(3));
708        assert_eq!(graph.return_.len(), 1);
709    }
710
711    #[test]
712    fn join_builder_aliases_supported_right_sources_and_builds_options() {
713        let condition = join_condition();
714        let cases = vec![
715            TableQueryBuilder::new("hosts").join_table("services", condition.clone()),
716            TableQueryBuilder::new("hosts").join_graph(GraphPattern::new(), condition.clone()),
717            TableQueryBuilder::new("hosts").join_path(
718                PathQuery::new(NodeSelector::by_id("a"), NodeSelector::by_id("b")),
719                condition.clone(),
720            ),
721            TableQueryBuilder::new("hosts").join_vector(
722                VectorQuery::new("embeddings", VectorSource::text("ssh")),
723                condition.clone(),
724            ),
725            TableQueryBuilder::new("hosts").join_hybrid(
726                HybridQuery::new(
727                    QueryExpr::Table(TableQuery::new("hosts")),
728                    VectorQuery::new("embeddings", VectorSource::text("ssh")),
729                ),
730                condition.clone(),
731            ),
732        ];
733
734        for builder in cases {
735            let query = builder
736                .right_alias("rhs")
737                .join_type(JoinType::FullOuter)
738                .filter(eq_filter("ok", Value::Boolean(true)))
739                .order_by(OrderByClause::asc(FieldRef::column("", "id")))
740                .limit(4)
741                .offset(2)
742                .return_field(FieldRef::column("hosts", "id"))
743                .select("name")
744                .build();
745
746            let QueryExpr::Join(join) = query else {
747                panic!("expected join query");
748            };
749            assert_eq!(join.join_type, JoinType::FullOuter);
750            assert!(join.filter.is_some());
751            assert_eq!(join.order_by.len(), 1);
752            assert_eq!(join.limit, Some(4));
753            assert_eq!(join.offset, Some(2));
754            assert_eq!(join.return_.len(), 2);
755            assert_eq!(join.return_items.len(), 2);
756            match *join.right {
757                QueryExpr::Table(table) => assert_eq!(table.alias.as_deref(), Some("rhs")),
758                QueryExpr::Graph(graph) => assert_eq!(graph.alias.as_deref(), Some("rhs")),
759                QueryExpr::Path(path) => assert_eq!(path.alias.as_deref(), Some("rhs")),
760                QueryExpr::Vector(vector) => assert_eq!(vector.alias.as_deref(), Some("rhs")),
761                QueryExpr::Hybrid(hybrid) => assert_eq!(hybrid.alias.as_deref(), Some("rhs")),
762                other => panic!("unexpected right source: {other:?}"),
763            }
764        }
765    }
766
767    #[test]
768    fn join_builder_right_alias_ignores_non_source_variants() {
769        let builder = JoinQueryBuilder {
770            left: QueryExpr::Table(TableQuery::new("left")),
771            right: QueryExpr::SetTenant(Some("acme".to_string())),
772            on: join_condition(),
773            join_type: JoinType::Inner,
774            filter: None,
775            order_by: Vec::new(),
776            limit: None,
777            offset: None,
778            return_items: Vec::new(),
779            return_: Vec::new(),
780        };
781        let query = builder.right_alias("ignored").build();
782        let QueryExpr::Join(join) = query else {
783            panic!("expected join query");
784        };
785        assert!(matches!(*join.right, QueryExpr::SetTenant(Some(ref tenant)) if tenant == "acme"));
786    }
787
788    #[test]
789    fn path_builder_sets_alias_via_filter_and_length() {
790        let query = PathQueryBuilder::new(NodeSelector::by_id("a"), NodeSelector::by_id("b"))
791            .via_label("CONNECTS_TO")
792            .max_length(7)
793            .filter(eq_filter("kind", Value::text("vpn")))
794            .alias("p")
795            .build();
796
797        let QueryExpr::Path(path) = query else {
798            panic!("expected path query");
799        };
800        assert_eq!(path.alias.as_deref(), Some("p"));
801        assert_eq!(path.via, vec!["CONNECTS_TO"]);
802        assert_eq!(path.max_length, 7);
803        assert!(path.filter.is_some());
804    }
805
806    #[test]
807    fn cte_helpers_track_recursive_state_and_lookup() {
808        let base = QueryExpr::Table(TableQuery::new("hosts"));
809        let cte = CteDefinition::new("active", base.clone())
810            .with_columns(vec!["id".to_string(), "ip".to_string()]);
811        assert_eq!(cte.name, "active");
812        assert_eq!(cte.columns, vec!["id", "ip"]);
813        assert!(!cte.recursive);
814
815        let recursive = CteDefinition::recursive("walk", base.clone());
816        assert!(recursive.recursive);
817
818        let clause = WithClause::new().add(cte).add(recursive);
819        assert!(!clause.is_empty());
820        assert!(clause.has_recursive);
821        assert!(clause.get("active").is_some());
822        assert!(clause.get("missing").is_none());
823
824        let simple = QueryWithCte::simple(base.clone());
825        assert!(simple.with_clause.is_none());
826        assert!(matches!(simple.query, QueryExpr::Table(_)));
827
828        let with_ctes = QueryWithCte::with_ctes(clause.clone(), base.clone());
829        assert!(with_ctes.with_clause.is_some());
830
831        let built = CteQueryBuilder::new()
832            .cte("one", base.clone())
833            .recursive_cte("two", base.clone())
834            .cte_with_columns("three", vec!["id".to_string()], base.clone())
835            .build(base);
836        let clause = built.with_clause.expect("with clause");
837        assert_eq!(clause.ctes.len(), 3);
838        assert!(clause.has_recursive);
839        assert_eq!(clause.get("three").expect("cte").columns, vec!["id"]);
840    }
841}