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::GraphCommand(_)
273            | QueryExpr::SearchCommand(_)
274            | QueryExpr::CreateIndex(_)
275            | QueryExpr::DropIndex(_)
276            | QueryExpr::ProbabilisticCommand(_)
277            | QueryExpr::Ask(_)
278            | QueryExpr::SetConfig { .. }
279            | QueryExpr::ShowConfig { .. }
280            | QueryExpr::SetSecret { .. }
281            | QueryExpr::DeleteSecret { .. }
282            | QueryExpr::ShowSecrets { .. }
283            | QueryExpr::SetTenant(_)
284            | QueryExpr::ShowTenant
285            | QueryExpr::CreateTimeSeries(_)
286            | QueryExpr::CreateMetric(_)
287            | QueryExpr::AlterMetric(_)
288            | QueryExpr::CreateSlo(_)
289            | QueryExpr::DropTimeSeries(_)
290            | QueryExpr::CreateQueue(_)
291            | QueryExpr::AlterQueue(_)
292            | QueryExpr::DropQueue(_)
293            | QueryExpr::QueueSelect(_)
294            | QueryExpr::QueueCommand(_)
295            | QueryExpr::KvCommand(_)
296            | QueryExpr::ConfigCommand(_)
297            | QueryExpr::CreateTree(_)
298            | QueryExpr::DropTree(_)
299            | QueryExpr::TreeCommand(_)
300            | QueryExpr::ExplainAlter(_)
301            | QueryExpr::TransactionControl(_)
302            | QueryExpr::MaintenanceCommand(_)
303            | QueryExpr::VcsCommand(_)
304            | QueryExpr::CreateSchema(_)
305            | QueryExpr::DropSchema(_)
306            | QueryExpr::CreateSequence(_)
307            | QueryExpr::DropSequence(_)
308            | QueryExpr::CopyFrom(_)
309            | QueryExpr::CreateView(_)
310            | QueryExpr::DropView(_)
311            | QueryExpr::RefreshMaterializedView(_)
312            | QueryExpr::CreatePolicy(_)
313            | QueryExpr::DropPolicy(_)
314            | QueryExpr::CreateServer(_)
315            | QueryExpr::DropServer(_)
316            | QueryExpr::CreateForeignTable(_)
317            | QueryExpr::DropForeignTable(_)
318            | QueryExpr::Grant(_)
319            | QueryExpr::Revoke(_)
320            | QueryExpr::AlterUser(_)
321            | QueryExpr::CreateUser(_)
322            | QueryExpr::CreateIamPolicy { .. }
323            | QueryExpr::DropIamPolicy { .. }
324            | QueryExpr::AttachPolicy { .. }
325            | QueryExpr::DetachPolicy { .. }
326            | QueryExpr::ShowPolicies { .. }
327            | QueryExpr::ShowEffectivePermissions { .. }
328            | QueryExpr::RankOf(_)
329            | QueryExpr::ApproxRankOf(_)
330            | QueryExpr::RankRange(_)
331            | QueryExpr::SimulatePolicy { .. }
332            | QueryExpr::LintPolicy { .. }
333            | QueryExpr::MigratePolicyMode { .. }
334            | QueryExpr::CreateMigration(_)
335            | QueryExpr::ApplyMigration(_)
336            | QueryExpr::RollbackMigration(_)
337            | QueryExpr::ExplainMigration(_)
338            | QueryExpr::EventsBackfill(_)
339            | QueryExpr::EventsBackfillStatus { .. } => {}
340        }
341        self
342    }
343
344    /// Add post-join filter
345    pub fn filter(mut self, f: Filter) -> Self {
346        self.filter = Some(match self.filter.take() {
347            Some(existing) => existing.and(f),
348            None => f,
349        });
350        self
351    }
352
353    /// Add post-join ordering
354    pub fn order_by(mut self, clause: OrderByClause) -> Self {
355        self.order_by.push(clause);
356        self
357    }
358
359    /// Set post-join limit
360    pub fn limit(mut self, n: u64) -> Self {
361        self.limit = Some(n);
362        self
363    }
364
365    /// Set post-join offset
366    pub fn offset(mut self, n: u64) -> Self {
367        self.offset = Some(n);
368        self
369    }
370
371    /// Add post-join projected field
372    pub fn return_field(mut self, field: FieldRef) -> Self {
373        let projection = Projection::from_field(field);
374        if let Some(item) = projection_to_select_item(&projection) {
375            self.return_items.push(item);
376        }
377        self.return_.push(projection);
378        self
379    }
380
381    /// Add post-join projected column
382    pub fn select(mut self, column: &str) -> Self {
383        let projection = Projection::from_field(FieldRef::column("", column));
384        if let Some(item) = projection_to_select_item(&projection) {
385            self.return_items.push(item);
386        }
387        self.return_.push(projection);
388        self
389    }
390
391    /// Build the query expression
392    pub fn build(self) -> QueryExpr {
393        QueryExpr::Join(JoinQuery {
394            left: Box::new(self.left),
395            right: Box::new(self.right),
396            join_type: self.join_type,
397            on: self.on,
398            filter: self.filter,
399            order_by: self.order_by,
400            limit: self.limit,
401            offset: self.offset,
402            return_items: self.return_items,
403            return_: self.return_,
404        })
405    }
406}
407
408/// Builder for path queries
409pub struct PathQueryBuilder {
410    query: PathQuery,
411}
412
413impl PathQueryBuilder {
414    /// Create a new builder
415    pub fn new(from: NodeSelector, to: NodeSelector) -> Self {
416        Self {
417            query: PathQuery::new(from, to),
418        }
419    }
420
421    /// Add edge label string to traverse (preferred).
422    pub fn via_label(mut self, label: impl Into<String>) -> Self {
423        self.query.via.push(label.into());
424        self
425    }
426
427    /// Set max length
428    pub fn max_length(mut self, n: u32) -> Self {
429        self.query.max_length = n;
430        self
431    }
432
433    /// Add filter
434    pub fn filter(mut self, f: Filter) -> Self {
435        self.query.filter = Some(f);
436        self
437    }
438
439    /// Set outer alias
440    pub fn alias(mut self, alias: &str) -> Self {
441        self.query.alias = Some(alias.to_string());
442        self
443    }
444
445    /// Build the query expression
446    pub fn build(self) -> QueryExpr {
447        QueryExpr::Path(self.query)
448    }
449}
450
451// ============================================================================
452// Common Table Expressions (CTEs)
453// ============================================================================
454
455/// A Common Table Expression (CTE) definition
456///
457/// CTEs provide named subqueries that can be referenced multiple times
458/// within the main query. Recursive CTEs enable hierarchical queries.
459///
460/// # Examples
461///
462/// ```text
463/// -- Non-recursive CTE
464/// WITH active_hosts AS (
465///     SELECT * FROM hosts WHERE last_seen > now() - interval '1 hour'
466/// )
467/// SELECT * FROM active_hosts WHERE criticality > 5
468///
469/// -- Recursive CTE for attack paths
470/// WITH RECURSIVE attack_path AS (
471///     -- Base case: starting host
472///     SELECT id, ip, 0 as depth FROM hosts WHERE ip = '192.168.1.1'
473///     UNION ALL
474///     -- Recursive case: follow connections
475///     SELECT h.id, h.ip, ap.depth + 1
476///     FROM attack_path ap
477///     JOIN connections c ON c.source_id = ap.id
478///     JOIN hosts h ON h.id = c.target_id
479///     WHERE ap.depth < 10
480/// )
481/// SELECT * FROM attack_path
482/// ```
483#[derive(Debug, Clone)]
484pub struct CteDefinition {
485    /// Name of the CTE (used to reference it in the main query)
486    pub name: String,
487    /// Optional column aliases for the CTE result
488    pub columns: Vec<String>,
489    /// The query that defines this CTE
490    pub query: Box<QueryExpr>,
491    /// Whether this is a recursive CTE
492    pub recursive: bool,
493}
494
495impl CteDefinition {
496    /// Create a new non-recursive CTE
497    pub fn new(name: &str, query: QueryExpr) -> Self {
498        Self {
499            name: name.to_string(),
500            columns: Vec::new(),
501            query: Box::new(query),
502            recursive: false,
503        }
504    }
505
506    /// Create a recursive CTE
507    pub fn recursive(name: &str, query: QueryExpr) -> Self {
508        Self {
509            name: name.to_string(),
510            columns: Vec::new(),
511            query: Box::new(query),
512            recursive: true,
513        }
514    }
515
516    /// Add column aliases
517    pub fn with_columns(mut self, columns: Vec<String>) -> Self {
518        self.columns = columns;
519        self
520    }
521}
522
523/// WITH clause containing one or more CTEs
524#[derive(Debug, Clone, Default)]
525pub struct WithClause {
526    /// List of CTE definitions
527    pub ctes: Vec<CteDefinition>,
528    /// Whether any CTE in the clause is recursive
529    pub has_recursive: bool,
530}
531
532impl WithClause {
533    /// Create a new WITH clause
534    pub fn new() -> Self {
535        Self::default()
536    }
537
538    /// Add a CTE definition
539    // Builder method appending a CTE to the WITH clause; unrelated to
540    // `std::ops::Add`, so that trait is intentionally not implemented.
541    #[allow(clippy::should_implement_trait)]
542    pub fn add(mut self, cte: CteDefinition) -> Self {
543        if cte.recursive {
544            self.has_recursive = true;
545        }
546        self.ctes.push(cte);
547        self
548    }
549
550    /// Check if empty
551    pub fn is_empty(&self) -> bool {
552        self.ctes.is_empty()
553    }
554
555    /// Get a CTE by name
556    pub fn get(&self, name: &str) -> Option<&CteDefinition> {
557        self.ctes.iter().find(|c| c.name == name)
558    }
559}
560
561/// Query with optional WITH clause
562#[derive(Debug, Clone)]
563pub struct QueryWithCte {
564    /// Optional WITH clause
565    pub with_clause: Option<WithClause>,
566    /// The main query
567    pub query: QueryExpr,
568}
569
570impl QueryWithCte {
571    /// Create a query without CTEs
572    pub fn simple(query: QueryExpr) -> Self {
573        Self {
574            with_clause: None,
575            query,
576        }
577    }
578
579    /// Create a query with CTEs
580    pub fn with_ctes(with_clause: WithClause, query: QueryExpr) -> Self {
581        Self {
582            with_clause: Some(with_clause),
583            query,
584        }
585    }
586}
587
588/// Builder for constructing queries with CTEs
589pub struct CteQueryBuilder {
590    with_clause: WithClause,
591}
592
593impl CteQueryBuilder {
594    /// Start building a WITH clause
595    pub fn new() -> Self {
596        Self {
597            with_clause: WithClause::new(),
598        }
599    }
600
601    /// Add a non-recursive CTE
602    pub fn cte(mut self, name: &str, query: QueryExpr) -> Self {
603        self.with_clause = self.with_clause.add(CteDefinition::new(name, query));
604        self
605    }
606
607    /// Add a recursive CTE
608    pub fn recursive_cte(mut self, name: &str, query: QueryExpr) -> Self {
609        self.with_clause = self.with_clause.add(CteDefinition::recursive(name, query));
610        self
611    }
612
613    /// Add a CTE with column aliases
614    pub fn cte_with_columns(mut self, name: &str, columns: Vec<String>, query: QueryExpr) -> Self {
615        let cte = CteDefinition::new(name, query).with_columns(columns);
616        self.with_clause = self.with_clause.add(cte);
617        self
618    }
619
620    /// Build the query with the main query expression
621    pub fn build(self, main_query: QueryExpr) -> QueryWithCte {
622        QueryWithCte::with_ctes(self.with_clause, main_query)
623    }
624}
625
626impl Default for CteQueryBuilder {
627    fn default() -> Self {
628        Self::new()
629    }
630}
631
632#[cfg(test)]
633mod tests {
634    use super::*;
635
636    fn eq_filter(column: &str, value: Value) -> Filter {
637        Filter::compare(FieldRef::column("", column), CompareOp::Eq, value)
638    }
639
640    fn join_condition() -> JoinCondition {
641        JoinCondition::new(
642            FieldRef::column("left", "id"),
643            FieldRef::column("right", "id"),
644        )
645    }
646
647    #[test]
648    fn table_builder_covers_selection_filters_order_limit_and_offset() {
649        let query = TableQueryBuilder::new("hosts")
650            .alias("h")
651            .select("ip")
652            .filter(eq_filter("os", Value::text("linux")))
653            .filter(eq_filter("active", Value::Boolean(true)))
654            .order_by(OrderByClause::desc(FieldRef::column("h", "last_seen")))
655            .limit(10)
656            .offset(5)
657            .build();
658
659        let QueryExpr::Table(table) = query else {
660            panic!("expected table query");
661        };
662        assert_eq!(table.table, "hosts");
663        assert_eq!(table.alias.as_deref(), Some("h"));
664        assert_eq!(table.select_items.len(), 1);
665        assert_eq!(table.columns.len(), 1);
666        assert!(matches!(table.filter, Some(Filter::And(_, _))));
667        assert!(matches!(
668            table.where_expr,
669            Some(Expr::BinaryOp { op: BinOp::And, .. })
670        ));
671        assert_eq!(table.order_by.len(), 1);
672        assert_eq!(table.limit, Some(10));
673        assert_eq!(table.offset, Some(5));
674
675        let QueryExpr::Table(table) = TableQueryBuilder::new("hosts").select_all().build() else {
676            panic!("expected table query");
677        };
678        assert_eq!(table.select_items, vec![SelectItem::Wildcard]);
679        assert!(table.columns.is_empty());
680    }
681
682    #[test]
683    fn graph_builder_combines_filters_alias_limit_and_returns() {
684        let query = GraphQueryBuilder::new()
685            .node(NodePattern::new("h").of_label("Host"))
686            .edge(EdgePattern::new("h", "s").of_label("HAS_SERVICE"))
687            .filter(eq_filter("critical", Value::Boolean(true)))
688            .filter(eq_filter("active", Value::Boolean(true)))
689            .alias("g")
690            .limit(3)
691            .return_field(FieldRef::node_prop("h", "ip"))
692            .build();
693
694        let QueryExpr::Graph(graph) = query else {
695            panic!("expected graph query");
696        };
697        assert_eq!(graph.alias.as_deref(), Some("g"));
698        assert_eq!(graph.pattern.nodes.len(), 1);
699        assert_eq!(graph.pattern.edges.len(), 1);
700        assert!(matches!(graph.filter, Some(Filter::And(_, _))));
701        assert_eq!(graph.limit, Some(3));
702        assert_eq!(graph.return_.len(), 1);
703    }
704
705    #[test]
706    fn join_builder_aliases_supported_right_sources_and_builds_options() {
707        let condition = join_condition();
708        let cases = vec![
709            TableQueryBuilder::new("hosts").join_table("services", condition.clone()),
710            TableQueryBuilder::new("hosts").join_graph(GraphPattern::new(), condition.clone()),
711            TableQueryBuilder::new("hosts").join_path(
712                PathQuery::new(NodeSelector::by_id("a"), NodeSelector::by_id("b")),
713                condition.clone(),
714            ),
715            TableQueryBuilder::new("hosts").join_vector(
716                VectorQuery::new("embeddings", VectorSource::text("ssh")),
717                condition.clone(),
718            ),
719            TableQueryBuilder::new("hosts").join_hybrid(
720                HybridQuery::new(
721                    QueryExpr::Table(TableQuery::new("hosts")),
722                    VectorQuery::new("embeddings", VectorSource::text("ssh")),
723                ),
724                condition.clone(),
725            ),
726        ];
727
728        for builder in cases {
729            let query = builder
730                .right_alias("rhs")
731                .join_type(JoinType::FullOuter)
732                .filter(eq_filter("ok", Value::Boolean(true)))
733                .order_by(OrderByClause::asc(FieldRef::column("", "id")))
734                .limit(4)
735                .offset(2)
736                .return_field(FieldRef::column("hosts", "id"))
737                .select("name")
738                .build();
739
740            let QueryExpr::Join(join) = query else {
741                panic!("expected join query");
742            };
743            assert_eq!(join.join_type, JoinType::FullOuter);
744            assert!(join.filter.is_some());
745            assert_eq!(join.order_by.len(), 1);
746            assert_eq!(join.limit, Some(4));
747            assert_eq!(join.offset, Some(2));
748            assert_eq!(join.return_.len(), 2);
749            assert_eq!(join.return_items.len(), 2);
750            match *join.right {
751                QueryExpr::Table(table) => assert_eq!(table.alias.as_deref(), Some("rhs")),
752                QueryExpr::Graph(graph) => assert_eq!(graph.alias.as_deref(), Some("rhs")),
753                QueryExpr::Path(path) => assert_eq!(path.alias.as_deref(), Some("rhs")),
754                QueryExpr::Vector(vector) => assert_eq!(vector.alias.as_deref(), Some("rhs")),
755                QueryExpr::Hybrid(hybrid) => assert_eq!(hybrid.alias.as_deref(), Some("rhs")),
756                other => panic!("unexpected right source: {other:?}"),
757            }
758        }
759    }
760
761    #[test]
762    fn join_builder_right_alias_ignores_non_source_variants() {
763        let builder = JoinQueryBuilder {
764            left: QueryExpr::Table(TableQuery::new("left")),
765            right: QueryExpr::SetTenant(Some("acme".to_string())),
766            on: join_condition(),
767            join_type: JoinType::Inner,
768            filter: None,
769            order_by: Vec::new(),
770            limit: None,
771            offset: None,
772            return_items: Vec::new(),
773            return_: Vec::new(),
774        };
775        let query = builder.right_alias("ignored").build();
776        let QueryExpr::Join(join) = query else {
777            panic!("expected join query");
778        };
779        assert!(matches!(*join.right, QueryExpr::SetTenant(Some(ref tenant)) if tenant == "acme"));
780    }
781
782    #[test]
783    fn path_builder_sets_alias_via_filter_and_length() {
784        let query = PathQueryBuilder::new(NodeSelector::by_id("a"), NodeSelector::by_id("b"))
785            .via_label("CONNECTS_TO")
786            .max_length(7)
787            .filter(eq_filter("kind", Value::text("vpn")))
788            .alias("p")
789            .build();
790
791        let QueryExpr::Path(path) = query else {
792            panic!("expected path query");
793        };
794        assert_eq!(path.alias.as_deref(), Some("p"));
795        assert_eq!(path.via, vec!["CONNECTS_TO"]);
796        assert_eq!(path.max_length, 7);
797        assert!(path.filter.is_some());
798    }
799
800    #[test]
801    fn cte_helpers_track_recursive_state_and_lookup() {
802        let base = QueryExpr::Table(TableQuery::new("hosts"));
803        let cte = CteDefinition::new("active", base.clone())
804            .with_columns(vec!["id".to_string(), "ip".to_string()]);
805        assert_eq!(cte.name, "active");
806        assert_eq!(cte.columns, vec!["id", "ip"]);
807        assert!(!cte.recursive);
808
809        let recursive = CteDefinition::recursive("walk", base.clone());
810        assert!(recursive.recursive);
811
812        let clause = WithClause::new().add(cte).add(recursive);
813        assert!(!clause.is_empty());
814        assert!(clause.has_recursive);
815        assert!(clause.get("active").is_some());
816        assert!(clause.get("missing").is_none());
817
818        let simple = QueryWithCte::simple(base.clone());
819        assert!(simple.with_clause.is_none());
820        assert!(matches!(simple.query, QueryExpr::Table(_)));
821
822        let with_ctes = QueryWithCte::with_ctes(clause.clone(), base.clone());
823        assert!(with_ctes.with_clause.is_some());
824
825        let built = CteQueryBuilder::new()
826            .cte("one", base.clone())
827            .recursive_cte("two", base.clone())
828            .cte_with_columns("three", vec!["id".to_string()], base.clone())
829            .build(base);
830        let clause = built.with_clause.expect("with clause");
831        assert_eq!(clause.ctes.len(), 3);
832        assert!(clause.has_recursive);
833        assert_eq!(clause.get("three").expect("cte").columns, vec!["id"]);
834    }
835}