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