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 pub fn new(table: &str) -> Self {
11 Self {
12 query: TableQuery::new(table),
13 }
14 }
15
16 pub fn alias(mut self, alias: &str) -> Self {
18 self.query.alias = Some(alias.to_string());
19 self
20 }
21
22 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 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 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 pub fn order_by(mut self, clause: OrderByClause) -> Self {
59 self.query.order_by.push(clause);
60 self
61 }
62
63 pub fn limit(mut self, n: u64) -> Self {
65 self.query.limit = Some(n);
66 self
67 }
68
69 pub fn offset(mut self, n: u64) -> Self {
71 self.query.offset = Some(n);
72 self
73 }
74
75 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 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 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 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 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 pub fn build(self) -> QueryExpr {
157 QueryExpr::Table(self.query)
158 }
159}
160
161pub struct GraphQueryBuilder {
163 query: GraphQuery,
164}
165
166impl GraphQueryBuilder {
167 pub fn new() -> Self {
169 Self {
170 query: GraphQuery::new(GraphPattern::new()),
171 }
172 }
173
174 pub fn node(mut self, pattern: NodePattern) -> Self {
176 self.query.pattern.nodes.push(pattern);
177 self
178 }
179
180 pub fn edge(mut self, pattern: EdgePattern) -> Self {
182 self.query.pattern.edges.push(pattern);
183 self
184 }
185
186 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 pub fn alias(mut self, alias: &str) -> Self {
197 self.query.alias = Some(alias.to_string());
198 self
199 }
200
201 pub fn limit(mut self, n: u64) -> Self {
203 self.query.limit = Some(n);
204 self
205 }
206
207 pub fn return_field(mut self, field: FieldRef) -> Self {
209 self.query.return_.push(Projection::from_field(field));
210 self
211 }
212
213 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
225pub 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 pub fn join_type(mut self, jt: JoinType) -> Self {
242 self.join_type = jt;
243 self
244 }
245
246 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::Scrub { .. }
284 | QueryExpr::SetSecret { .. }
285 | QueryExpr::DeleteSecret { .. }
286 | QueryExpr::ShowSecrets { .. }
287 | QueryExpr::SetKv { .. }
288 | QueryExpr::DeleteKv { .. }
289 | QueryExpr::SetTenant(_)
290 | QueryExpr::ShowTenant
291 | QueryExpr::CreateTimeSeries(_)
292 | QueryExpr::CreateMetric(_)
293 | QueryExpr::AlterMetric(_)
294 | QueryExpr::CreateSlo(_)
295 | QueryExpr::DropTimeSeries(_)
296 | QueryExpr::CreateQueue(_)
297 | QueryExpr::AlterQueue(_)
298 | QueryExpr::DropQueue(_)
299 | QueryExpr::QueueSelect(_)
300 | QueryExpr::QueueCommand(_)
301 | QueryExpr::KvCommand(_)
302 | QueryExpr::ConfigCommand(_)
303 | QueryExpr::CreateTree(_)
304 | QueryExpr::DropTree(_)
305 | QueryExpr::TreeCommand(_)
306 | QueryExpr::ExplainAlter(_)
307 | QueryExpr::TransactionControl(_)
308 | QueryExpr::MaintenanceCommand(_)
309 | QueryExpr::VcsCommand(_)
310 | QueryExpr::CreateSchema(_)
311 | QueryExpr::DropSchema(_)
312 | QueryExpr::CreateSequence(_)
313 | QueryExpr::DropSequence(_)
314 | QueryExpr::CopyFrom(_)
315 | QueryExpr::CreateView(_)
316 | QueryExpr::DropView(_)
317 | QueryExpr::RefreshMaterializedView(_)
318 | QueryExpr::CreatePolicy(_)
319 | QueryExpr::DropPolicy(_)
320 | QueryExpr::CreateServer(_)
321 | QueryExpr::DropServer(_)
322 | QueryExpr::CreateForeignTable(_)
323 | QueryExpr::DropForeignTable(_)
324 | QueryExpr::Grant(_)
325 | QueryExpr::Revoke(_)
326 | QueryExpr::AlterUser(_)
327 | QueryExpr::CreateUser(_)
328 | QueryExpr::CreateIamPolicy { .. }
329 | QueryExpr::DropIamPolicy { .. }
330 | QueryExpr::AttachPolicy { .. }
331 | QueryExpr::DetachPolicy { .. }
332 | QueryExpr::ShowPolicies { .. }
333 | QueryExpr::ShowEffectivePermissions { .. }
334 | QueryExpr::RankOf(_)
335 | QueryExpr::ApproxRankOf(_)
336 | QueryExpr::RankRange(_)
337 | QueryExpr::SimulatePolicy { .. }
338 | QueryExpr::LintPolicy { .. }
339 | QueryExpr::MigratePolicyMode { .. }
340 | QueryExpr::CreateMigration(_)
341 | QueryExpr::ApplyMigration(_)
342 | QueryExpr::RollbackMigration(_)
343 | QueryExpr::ExplainMigration(_)
344 | QueryExpr::Explain(_)
345 | QueryExpr::EventsBackfill(_)
346 | QueryExpr::EventsBackfillStatus { .. } => {}
347 }
348 self
349 }
350
351 pub fn filter(mut self, f: Filter) -> Self {
353 self.filter = Some(match self.filter.take() {
354 Some(existing) => existing.and(f),
355 None => f,
356 });
357 self
358 }
359
360 pub fn order_by(mut self, clause: OrderByClause) -> Self {
362 self.order_by.push(clause);
363 self
364 }
365
366 pub fn limit(mut self, n: u64) -> Self {
368 self.limit = Some(n);
369 self
370 }
371
372 pub fn offset(mut self, n: u64) -> Self {
374 self.offset = Some(n);
375 self
376 }
377
378 pub fn return_field(mut self, field: FieldRef) -> Self {
380 let projection = Projection::from_field(field);
381 if let Some(item) = projection_to_select_item(&projection) {
382 self.return_items.push(item);
383 }
384 self.return_.push(projection);
385 self
386 }
387
388 pub fn select(mut self, column: &str) -> Self {
390 let projection = Projection::from_field(FieldRef::column("", column));
391 if let Some(item) = projection_to_select_item(&projection) {
392 self.return_items.push(item);
393 }
394 self.return_.push(projection);
395 self
396 }
397
398 pub fn build(self) -> QueryExpr {
400 QueryExpr::Join(JoinQuery {
401 left: Box::new(self.left),
402 right: Box::new(self.right),
403 join_type: self.join_type,
404 on: self.on,
405 filter: self.filter,
406 order_by: self.order_by,
407 limit: self.limit,
408 offset: self.offset,
409 return_items: self.return_items,
410 return_: self.return_,
411 })
412 }
413}
414
415pub struct PathQueryBuilder {
417 query: PathQuery,
418}
419
420impl PathQueryBuilder {
421 pub fn new(from: NodeSelector, to: NodeSelector) -> Self {
423 Self {
424 query: PathQuery::new(from, to),
425 }
426 }
427
428 pub fn via_label(mut self, label: impl Into<String>) -> Self {
430 self.query.via.push(label.into());
431 self
432 }
433
434 pub fn max_length(mut self, n: u32) -> Self {
436 self.query.max_length = n;
437 self
438 }
439
440 pub fn filter(mut self, f: Filter) -> Self {
442 self.query.filter = Some(f);
443 self
444 }
445
446 pub fn alias(mut self, alias: &str) -> Self {
448 self.query.alias = Some(alias.to_string());
449 self
450 }
451
452 pub fn build(self) -> QueryExpr {
454 QueryExpr::Path(self.query)
455 }
456}
457
458#[derive(Debug, Clone)]
491pub struct CteDefinition {
492 pub name: String,
494 pub columns: Vec<String>,
496 pub query: Box<QueryExpr>,
498 pub recursive: bool,
500}
501
502impl CteDefinition {
503 pub fn new(name: &str, query: QueryExpr) -> Self {
505 Self {
506 name: name.to_string(),
507 columns: Vec::new(),
508 query: Box::new(query),
509 recursive: false,
510 }
511 }
512
513 pub fn recursive(name: &str, query: QueryExpr) -> Self {
515 Self {
516 name: name.to_string(),
517 columns: Vec::new(),
518 query: Box::new(query),
519 recursive: true,
520 }
521 }
522
523 pub fn with_columns(mut self, columns: Vec<String>) -> Self {
525 self.columns = columns;
526 self
527 }
528}
529
530#[derive(Debug, Clone, Default)]
532pub struct WithClause {
533 pub ctes: Vec<CteDefinition>,
535 pub has_recursive: bool,
537}
538
539impl WithClause {
540 pub fn new() -> Self {
542 Self::default()
543 }
544
545 #[allow(clippy::should_implement_trait)]
549 pub fn add(mut self, cte: CteDefinition) -> Self {
550 if cte.recursive {
551 self.has_recursive = true;
552 }
553 self.ctes.push(cte);
554 self
555 }
556
557 pub fn is_empty(&self) -> bool {
559 self.ctes.is_empty()
560 }
561
562 pub fn get(&self, name: &str) -> Option<&CteDefinition> {
564 self.ctes.iter().find(|c| c.name == name)
565 }
566}
567
568#[derive(Debug, Clone)]
570pub struct QueryWithCte {
571 pub with_clause: Option<WithClause>,
573 pub query: QueryExpr,
575}
576
577impl QueryWithCte {
578 pub fn simple(query: QueryExpr) -> Self {
580 Self {
581 with_clause: None,
582 query,
583 }
584 }
585
586 pub fn with_ctes(with_clause: WithClause, query: QueryExpr) -> Self {
588 Self {
589 with_clause: Some(with_clause),
590 query,
591 }
592 }
593}
594
595pub struct CteQueryBuilder {
597 with_clause: WithClause,
598}
599
600impl CteQueryBuilder {
601 pub fn new() -> Self {
603 Self {
604 with_clause: WithClause::new(),
605 }
606 }
607
608 pub fn cte(mut self, name: &str, query: QueryExpr) -> Self {
610 self.with_clause = self.with_clause.add(CteDefinition::new(name, query));
611 self
612 }
613
614 pub fn recursive_cte(mut self, name: &str, query: QueryExpr) -> Self {
616 self.with_clause = self.with_clause.add(CteDefinition::recursive(name, query));
617 self
618 }
619
620 pub fn cte_with_columns(mut self, name: &str, columns: Vec<String>, query: QueryExpr) -> Self {
622 let cte = CteDefinition::new(name, query).with_columns(columns);
623 self.with_clause = self.with_clause.add(cte);
624 self
625 }
626
627 pub fn build(self, main_query: QueryExpr) -> QueryWithCte {
629 QueryWithCte::with_ctes(self.with_clause, main_query)
630 }
631}
632
633impl Default for CteQueryBuilder {
634 fn default() -> Self {
635 Self::new()
636 }
637}
638
639#[cfg(test)]
640mod tests {
641 use super::*;
642
643 fn eq_filter(column: &str, value: Value) -> Filter {
644 Filter::compare(FieldRef::column("", column), CompareOp::Eq, value)
645 }
646
647 fn join_condition() -> JoinCondition {
648 JoinCondition::new(
649 FieldRef::column("left", "id"),
650 FieldRef::column("right", "id"),
651 )
652 }
653
654 #[test]
655 fn table_builder_covers_selection_filters_order_limit_and_offset() {
656 let query = TableQueryBuilder::new("hosts")
657 .alias("h")
658 .select("ip")
659 .filter(eq_filter("os", Value::text("linux")))
660 .filter(eq_filter("active", Value::Boolean(true)))
661 .order_by(OrderByClause::desc(FieldRef::column("h", "last_seen")))
662 .limit(10)
663 .offset(5)
664 .build();
665
666 let QueryExpr::Table(table) = query else {
667 panic!("expected table query");
668 };
669 assert_eq!(table.table, "hosts");
670 assert_eq!(table.alias.as_deref(), Some("h"));
671 assert_eq!(table.select_items.len(), 1);
672 assert_eq!(table.columns.len(), 1);
673 assert!(matches!(table.filter, Some(Filter::And(_, _))));
674 assert!(matches!(
675 table.where_expr,
676 Some(Expr::BinaryOp { op: BinOp::And, .. })
677 ));
678 assert_eq!(table.order_by.len(), 1);
679 assert_eq!(table.limit, Some(10));
680 assert_eq!(table.offset, Some(5));
681
682 let QueryExpr::Table(table) = TableQueryBuilder::new("hosts").select_all().build() else {
683 panic!("expected table query");
684 };
685 assert_eq!(table.select_items, vec![SelectItem::Wildcard]);
686 assert!(table.columns.is_empty());
687 }
688
689 #[test]
690 fn graph_builder_combines_filters_alias_limit_and_returns() {
691 let query = GraphQueryBuilder::new()
692 .node(NodePattern::new("h").of_label("Host"))
693 .edge(EdgePattern::new("h", "s").of_label("HAS_SERVICE"))
694 .filter(eq_filter("critical", Value::Boolean(true)))
695 .filter(eq_filter("active", Value::Boolean(true)))
696 .alias("g")
697 .limit(3)
698 .return_field(FieldRef::node_prop("h", "ip"))
699 .build();
700
701 let QueryExpr::Graph(graph) = query else {
702 panic!("expected graph query");
703 };
704 assert_eq!(graph.alias.as_deref(), Some("g"));
705 assert_eq!(graph.pattern.nodes.len(), 1);
706 assert_eq!(graph.pattern.edges.len(), 1);
707 assert!(matches!(graph.filter, Some(Filter::And(_, _))));
708 assert_eq!(graph.limit, Some(3));
709 assert_eq!(graph.return_.len(), 1);
710 }
711
712 #[test]
713 fn join_builder_aliases_supported_right_sources_and_builds_options() {
714 let condition = join_condition();
715 let cases = vec![
716 TableQueryBuilder::new("hosts").join_table("services", condition.clone()),
717 TableQueryBuilder::new("hosts").join_graph(GraphPattern::new(), condition.clone()),
718 TableQueryBuilder::new("hosts").join_path(
719 PathQuery::new(NodeSelector::by_id("a"), NodeSelector::by_id("b")),
720 condition.clone(),
721 ),
722 TableQueryBuilder::new("hosts").join_vector(
723 VectorQuery::new("embeddings", VectorSource::text("ssh")),
724 condition.clone(),
725 ),
726 TableQueryBuilder::new("hosts").join_hybrid(
727 HybridQuery::new(
728 QueryExpr::Table(TableQuery::new("hosts")),
729 VectorQuery::new("embeddings", VectorSource::text("ssh")),
730 ),
731 condition.clone(),
732 ),
733 ];
734
735 for builder in cases {
736 let query = builder
737 .right_alias("rhs")
738 .join_type(JoinType::FullOuter)
739 .filter(eq_filter("ok", Value::Boolean(true)))
740 .order_by(OrderByClause::asc(FieldRef::column("", "id")))
741 .limit(4)
742 .offset(2)
743 .return_field(FieldRef::column("hosts", "id"))
744 .select("name")
745 .build();
746
747 let QueryExpr::Join(join) = query else {
748 panic!("expected join query");
749 };
750 assert_eq!(join.join_type, JoinType::FullOuter);
751 assert!(join.filter.is_some());
752 assert_eq!(join.order_by.len(), 1);
753 assert_eq!(join.limit, Some(4));
754 assert_eq!(join.offset, Some(2));
755 assert_eq!(join.return_.len(), 2);
756 assert_eq!(join.return_items.len(), 2);
757 match *join.right {
758 QueryExpr::Table(table) => assert_eq!(table.alias.as_deref(), Some("rhs")),
759 QueryExpr::Graph(graph) => assert_eq!(graph.alias.as_deref(), Some("rhs")),
760 QueryExpr::Path(path) => assert_eq!(path.alias.as_deref(), Some("rhs")),
761 QueryExpr::Vector(vector) => assert_eq!(vector.alias.as_deref(), Some("rhs")),
762 QueryExpr::Hybrid(hybrid) => assert_eq!(hybrid.alias.as_deref(), Some("rhs")),
763 other => panic!("unexpected right source: {other:?}"),
764 }
765 }
766 }
767
768 #[test]
769 fn join_builder_right_alias_ignores_non_source_variants() {
770 let builder = JoinQueryBuilder {
771 left: QueryExpr::Table(TableQuery::new("left")),
772 right: QueryExpr::SetTenant(Some("acme".to_string())),
773 on: join_condition(),
774 join_type: JoinType::Inner,
775 filter: None,
776 order_by: Vec::new(),
777 limit: None,
778 offset: None,
779 return_items: Vec::new(),
780 return_: Vec::new(),
781 };
782 let query = builder.right_alias("ignored").build();
783 let QueryExpr::Join(join) = query else {
784 panic!("expected join query");
785 };
786 assert!(matches!(*join.right, QueryExpr::SetTenant(Some(ref tenant)) if tenant == "acme"));
787 }
788
789 #[test]
790 fn path_builder_sets_alias_via_filter_and_length() {
791 let query = PathQueryBuilder::new(NodeSelector::by_id("a"), NodeSelector::by_id("b"))
792 .via_label("CONNECTS_TO")
793 .max_length(7)
794 .filter(eq_filter("kind", Value::text("vpn")))
795 .alias("p")
796 .build();
797
798 let QueryExpr::Path(path) = query else {
799 panic!("expected path query");
800 };
801 assert_eq!(path.alias.as_deref(), Some("p"));
802 assert_eq!(path.via, vec!["CONNECTS_TO"]);
803 assert_eq!(path.max_length, 7);
804 assert!(path.filter.is_some());
805 }
806
807 #[test]
808 fn cte_helpers_track_recursive_state_and_lookup() {
809 let base = QueryExpr::Table(TableQuery::new("hosts"));
810 let cte = CteDefinition::new("active", base.clone())
811 .with_columns(vec!["id".to_string(), "ip".to_string()]);
812 assert_eq!(cte.name, "active");
813 assert_eq!(cte.columns, vec!["id", "ip"]);
814 assert!(!cte.recursive);
815
816 let recursive = CteDefinition::recursive("walk", base.clone());
817 assert!(recursive.recursive);
818
819 let clause = WithClause::new().add(cte).add(recursive);
820 assert!(!clause.is_empty());
821 assert!(clause.has_recursive);
822 assert!(clause.get("active").is_some());
823 assert!(clause.get("missing").is_none());
824
825 let simple = QueryWithCte::simple(base.clone());
826 assert!(simple.with_clause.is_none());
827 assert!(matches!(simple.query, QueryExpr::Table(_)));
828
829 let with_ctes = QueryWithCte::with_ctes(clause.clone(), base.clone());
830 assert!(with_ctes.with_clause.is_some());
831
832 let built = CteQueryBuilder::new()
833 .cte("one", base.clone())
834 .recursive_cte("two", base.clone())
835 .cte_with_columns("three", vec!["id".to_string()], base.clone())
836 .build(base);
837 let clause = built.with_clause.expect("with clause");
838 assert_eq!(clause.ctes.len(), 3);
839 assert!(clause.has_recursive);
840 assert_eq!(clause.get("three").expect("cte").columns, vec!["id"]);
841 }
842}