Skip to main content

keelson_core/clause/
cte.rs

1use std::borrow::Cow;
2
3use crate::error::Error;
4use crate::expr::{Expr, IntoExpr};
5use crate::writer::{Expression, SqlWriter};
6
7use super::{MaybeAbsent, write_quoted_list};
8
9/// One common table expression.
10///
11/// From PostgreSQL 17, <https://www.postgresql.org/docs/17/sql-select.html>:
12///
13/// ```text
14/// with_query_name [ ( column_name [, ...] ) ] AS [ [ NOT ] MATERIALIZED ] ( select | … )
15///     [ SEARCH { BREADTH | DEPTH } FIRST BY column_name [, ...] SET search_seq_col_name ]
16///     [ CYCLE column_name [, ...] SET cycle_mark_col_name
17///       [ TO cycle_mark_value DEFAULT cycle_mark_default ] USING cycle_path_col_name ]
18/// ```
19///
20/// The SQL standard allows only a `SELECT` here; PostgreSQL also allows
21/// `INSERT`/`UPDATE`/`DELETE`, which is why [`query`](Self::query) is an ordinary
22/// expression and not something narrower.
23#[derive(Debug, Clone, Default)]
24pub struct Cte {
25    /// The name the rest of the statement refers to. Quoted on output.
26    pub name: Cow<'static, str>,
27    /// Column names for the result. Quoted.
28    pub columns: Vec<Cow<'static, str>>,
29    /// The query, rendered inside parentheses.
30    pub query: Option<Expr>,
31    /// `MATERIALIZED` / `NOT MATERIALIZED`. `None` writes neither and leaves the
32    /// choice to the planner — which is not the same as either, so this is
33    /// genuinely three-valued.
34    pub materialized: Option<bool>,
35    /// `SEARCH …`, for a recursive CTE.
36    pub search: CteSearch,
37    /// `CYCLE …`, for a recursive CTE over cyclic data.
38    pub cycle: CteCycle,
39}
40
41impl Cte {
42    /// A named CTE over `query`.
43    pub fn new(name: impl Into<Cow<'static, str>>, query: impl IntoExpr) -> Self {
44        Cte {
45            name: name.into(),
46            query: Some(query.into_expr()),
47            ..Cte::default()
48        }
49    }
50
51    /// Whether this is an untouched CTE, so that nothing will be written.
52    pub fn is_empty(&self) -> bool {
53        self.name.is_empty() && self.query.is_none()
54    }
55}
56
57impl Expression for Cte {
58    fn write_sql(&self, w: &mut SqlWriter<'_>) {
59        if self.is_empty() {
60            return;
61        }
62
63        let Some(query) = &self.query else {
64            // A named CTE with no query is a caller error rather than an absent
65            // clause: there is no rendering of it that parses.
66            w.record_error(Error::Incomplete("the query of a CTE"));
67            return;
68        };
69
70        w.push_quoted(&[&self.name]);
71        write_quoted_list(w, &self.columns, " (", ", ", ")");
72        w.push_str(" AS ");
73
74        match self.materialized {
75            None => {}
76            Some(true) => w.push_str("MATERIALIZED "),
77            Some(false) => w.push_str("NOT MATERIALIZED "),
78        }
79
80        w.push_str("(");
81        w.write_expr(query);
82        w.push_str(")");
83
84        w.write_if(!self.search.is_empty(), " ", &self.search, "");
85        w.write_if(!self.cycle.is_empty(), " ", &self.cycle, "");
86    }
87}
88
89/// `SEARCH { BREADTH | DEPTH } FIRST BY <cols> SET <col>`
90///
91/// PostgreSQL rewrites a recursive CTE with this into one that carries an ordering
92/// column, so the column list decides whether the clause exists at all.
93#[derive(Debug, Clone, Default)]
94pub struct CteSearch {
95    /// Breadth-first or depth-first.
96    pub order: SearchOrder,
97    /// The columns that identify a row, quoted.
98    pub columns: Vec<Cow<'static, str>>,
99    /// The name of the ordering column to add, quoted.
100    pub set: Cow<'static, str>,
101}
102
103impl CteSearch {
104    /// A search clause over `columns`, adding `set`.
105    pub fn new(
106        order: SearchOrder,
107        columns: impl IntoIterator<Item = impl Into<Cow<'static, str>>>,
108        set: impl Into<Cow<'static, str>>,
109    ) -> Self {
110        CteSearch {
111            order,
112            columns: columns.into_iter().map(Into::into).collect(),
113            set: set.into(),
114        }
115    }
116
117    /// Whether the clause is absent.
118    pub fn is_empty(&self) -> bool {
119        self.columns.is_empty()
120    }
121}
122
123impl Expression for CteSearch {
124    fn write_sql(&self, w: &mut SqlWriter<'_>) {
125        if self.is_empty() {
126            return;
127        }
128        if self.set.is_empty() {
129            // The SET column is not optional in the grammar, and it is the whole
130            // point of the clause — it is what the outer query orders by.
131            w.record_error(Error::Incomplete("the SET column of a CTE SEARCH clause"));
132            return;
133        }
134
135        w.push_str("SEARCH ");
136        w.push_str(self.order.as_str());
137        w.push_str(" FIRST BY ");
138        write_quoted_list(w, &self.columns, "", ", ", "");
139        w.push_str(" SET ");
140        w.push_quoted(&[&self.set]);
141    }
142}
143
144/// Which way a recursive CTE's `SEARCH` walks.
145#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
146pub enum SearchOrder {
147    /// `BREADTH FIRST`.
148    #[default]
149    Breadth,
150    /// `DEPTH FIRST`.
151    Depth,
152}
153
154impl SearchOrder {
155    /// The keyword, as written between `SEARCH` and `FIRST`.
156    pub fn as_str(self) -> &'static str {
157        match self {
158            SearchOrder::Breadth => "BREADTH",
159            SearchOrder::Depth => "DEPTH",
160        }
161    }
162}
163
164/// `CYCLE <cols> SET <col> [TO <value> DEFAULT <value>] USING <col>`
165///
166/// Stops a recursive CTE from looping forever over cyclic data by marking the row
167/// that closes a cycle.
168///
169/// # The mark values must be constants
170///
171/// PostgreSQL's grammar spells the optional group
172/// `TO AexprConst DEFAULT AexprConst` — a *literal constant*, not an expression, so
173/// a bound argument in either slot is rejected by the server with a syntax error at
174/// the placeholder. libpg_query confirms it. The fields are [`Expr`]s because
175/// everything else in this module is, and because a constant is written
176/// [`expr::literal`](crate::expr::literal) or [`expr::raw`](crate::expr::raw); a
177/// dialect's `cycle` mod is where the narrowing belongs.
178#[derive(Debug, Clone, Default)]
179pub struct CteCycle {
180    /// The columns that identify a row, quoted.
181    pub columns: Vec<Cow<'static, str>>,
182    /// The name of the cycle-mark column to add, quoted.
183    pub set: Cow<'static, str>,
184    /// The name of the path column to add, quoted.
185    pub using: Cow<'static, str>,
186    /// The constant the mark column takes on a cycle. The grammar pairs this with
187    /// [`default_val`](Self::default_val): both or neither.
188    pub to: Option<Expr>,
189    /// The constant the mark column takes otherwise.
190    pub default_val: Option<Expr>,
191}
192
193impl CteCycle {
194    /// A cycle clause over `columns`, adding the mark column `set` and the path
195    /// column `using`.
196    pub fn new(
197        columns: impl IntoIterator<Item = impl Into<Cow<'static, str>>>,
198        set: impl Into<Cow<'static, str>>,
199        using: impl Into<Cow<'static, str>>,
200    ) -> Self {
201        CteCycle {
202            columns: columns.into_iter().map(Into::into).collect(),
203            set: set.into(),
204            using: using.into(),
205            ..CteCycle::default()
206        }
207    }
208
209    /// Whether the clause is absent.
210    pub fn is_empty(&self) -> bool {
211        self.columns.is_empty()
212    }
213}
214
215impl Expression for CteCycle {
216    fn write_sql(&self, w: &mut SqlWriter<'_>) {
217        if self.is_empty() {
218            return;
219        }
220        if self.set.is_empty() || self.using.is_empty() {
221            w.record_error(Error::Incomplete(
222                "the SET and USING columns of a CTE CYCLE clause",
223            ));
224            return;
225        }
226        // `[ TO value DEFAULT value ]` is one optional group, so half of it is
227        // unrenderable rather than merely unusual.
228        if self.to.is_some() != self.default_val.is_some() {
229            w.record_error(Error::Incomplete(
230                "both TO and DEFAULT of a CTE CYCLE clause",
231            ));
232            return;
233        }
234
235        w.push_str("CYCLE ");
236        write_quoted_list(w, &self.columns, "", ", ", "");
237        w.push_str(" SET ");
238        w.push_quoted(&[&self.set]);
239
240        if let (Some(to), Some(default_val)) = (&self.to, &self.default_val) {
241            w.push_str(" TO ");
242            w.write_expr(to);
243            w.push_str(" DEFAULT ");
244            w.write_expr(default_val);
245        }
246
247        w.push_str(" USING ");
248        w.push_quoted(&[&self.using]);
249    }
250}
251
252impl MaybeAbsent for Cte {
253    fn is_absent(&self) -> bool {
254        self.is_empty()
255    }
256}
257
258#[cfg(test)]
259mod tests {
260    use keelson_sqlcheck::testing::assert_frag_sql;
261
262    use super::*;
263    use crate::dialect::testing::Numbered;
264    use crate::expr::arg;
265    use crate::value::Value;
266    use crate::writer::build;
267
268    /// A CTE is a fragment of a `WITH`, which is itself a prefix. These are the
269    /// statements the cases below are judged inside.
270    const FRAME: &str = r#"WITH {} SELECT * FROM "c""#;
271    const RECURSIVE_FRAME: &str = r#"WITH RECURSIVE {} SELECT * FROM "c""#;
272    /// For a `SEARCH` / `CYCLE` clause on its own: the CTE it qualifies is in the
273    /// frame, since both are only legal on a recursive one.
274    const AFTER_RECURSIVE_CTE: &str = concat!(
275        r#"WITH RECURSIVE "c" AS ("#,
276        r#"SELECT 1 AS "id" UNION ALL SELECT "id" + 1 FROM "c" WHERE "id" < 5"#,
277        r#") {} SELECT * FROM "c""#
278    );
279
280    /// A one-column sub-query. The placeholder is compared against a column so
281    /// PostgreSQL can infer a type for it.
282    fn sub() -> Expr {
283        Expr::join((
284            Expr::raw(r#"SELECT "id" FROM posts WHERE "id" ="#),
285            arg(1i32),
286        ))
287    }
288
289    const SUB_SQL: &str = r#"SELECT "id" FROM posts WHERE "id" = $1"#;
290
291    /// The recursive query a SEARCH or CYCLE clause needs under it.
292    fn recursive_sub() -> Expr {
293        Expr::raw(r#"SELECT 1 AS "id" UNION ALL SELECT "id" + 1 FROM "c" WHERE "id" < 5"#)
294    }
295
296    const RECURSIVE_SUB_SQL: &str =
297        r#"SELECT 1 AS "id" UNION ALL SELECT "id" + 1 FROM "c" WHERE "id" < 5"#;
298
299    fn sql(e: &impl Expression) -> String {
300        build(&Numbered, e).expect("render").0
301    }
302
303    #[test]
304    fn an_untouched_cte_writes_nothing() {
305        // Not framed: `WITH  SELECT …` is not a statement, which is the reason an
306        // empty CTE has to be skipped by `With` rather than written.
307        assert_eq!(build(&Numbered, &Cte::default()).unwrap().0, "");
308        assert!(Cte::default().is_empty());
309    }
310
311    #[test]
312    fn a_bare_cte_is_name_as_query() {
313        let (rendered, args) = build(&Numbered, &Cte::new("c", sub())).unwrap();
314        assert_frag_sql(FRAME, &rendered, &format!(r#""c" AS ({SUB_SQL})"#));
315        assert_eq!(args, vec![Value::I32(1)]);
316    }
317
318    #[test]
319    fn column_names_follow_the_cte_name() {
320        // PostgreSQL 17: with_query_name [ ( column_name [, ...] ) ] AS ( … )
321        // As many names as the query has columns, so the two-column query.
322        let two_cols = Expr::raw(r#"SELECT "id", "title" FROM posts"#);
323        let cte = Cte {
324            columns: vec!["id".into(), "data".into()],
325            ..Cte::new("c", two_cols)
326        };
327        assert_frag_sql(
328            FRAME,
329            &sql(&cte),
330            r#""c" ("id", "data") AS (SELECT "id", "title" FROM posts)"#,
331        );
332    }
333
334    #[test]
335    fn materialisation_is_three_valued() {
336        // `AS ( … )`, `AS MATERIALIZED ( … )` and `AS NOT MATERIALIZED ( … )` are
337        // three different instructions to the planner, so None is not a synonym
338        // for either of the others.
339        let base = Cte::new("c", sub());
340        assert_frag_sql(FRAME, &sql(&base), &format!(r#""c" AS ({SUB_SQL})"#));
341
342        let yes = Cte {
343            materialized: Some(true),
344            ..base.clone()
345        };
346        assert_frag_sql(
347            FRAME,
348            &sql(&yes),
349            &format!(r#""c" AS MATERIALIZED ({SUB_SQL})"#),
350        );
351
352        let no = Cte {
353            materialized: Some(false),
354            ..base
355        };
356        assert_frag_sql(
357            FRAME,
358            &sql(&no),
359            &format!(r#""c" AS NOT MATERIALIZED ({SUB_SQL})"#),
360        );
361    }
362
363    #[test]
364    fn a_named_cte_with_no_query_is_a_recorded_failure() {
365        let cte = Cte {
366            name: "c".into(),
367            ..Cte::default()
368        };
369        let err = build(&Numbered, &cte).unwrap_err();
370        // The substring names the SQL concept (a CTE's body), not the message
371        // wording.
372        assert!(
373            matches!(&err, Error::Incomplete(what) if what.contains("CTE")),
374            "got: {err}"
375        );
376    }
377
378    #[test]
379    fn search_and_cycle_follow_the_query_and_hinge_on_their_columns() {
380        let mut cte = Cte::new("c", recursive_sub());
381        cte.search = CteSearch::new(SearchOrder::Depth, ["id"], "ordercol");
382        // No columns, so the whole CYCLE clause stays out even though the two
383        // column names are filled in.
384        cte.cycle = CteCycle {
385            set: "is_cycle".into(),
386            using: "path".into(),
387            ..CteCycle::default()
388        };
389
390        assert_frag_sql(
391            RECURSIVE_FRAME,
392            &sql(&cte),
393            &format!(r#""c" AS ({RECURSIVE_SUB_SQL}) SEARCH DEPTH FIRST BY "id" SET "ordercol""#),
394        );
395
396        cte.cycle.columns = vec!["id".into()];
397        assert_frag_sql(
398            RECURSIVE_FRAME,
399            &sql(&cte),
400            &format!(
401                concat!(
402                    r#""c" AS ({}) SEARCH DEPTH FIRST BY "id" SET "ordercol""#,
403                    r#" CYCLE "id" SET "is_cycle" USING "path""#
404                ),
405                RECURSIVE_SUB_SQL
406            ),
407        );
408    }
409
410    #[test]
411    fn breadth_is_the_default_search_order() {
412        let search = CteSearch::new(SearchOrder::default(), ["id"], "seq");
413        assert_frag_sql(
414            AFTER_RECURSIVE_CTE,
415            &sql(&search),
416            r#"SEARCH BREADTH FIRST BY "id" SET "seq""#,
417        );
418    }
419
420    #[test]
421    fn a_search_clause_without_its_set_column_is_a_recorded_failure() {
422        let search = CteSearch {
423            columns: vec!["id".into()],
424            ..CteSearch::default()
425        };
426        let err = build(&Numbered, &search).unwrap_err();
427        // The substrings name the SQL concepts (SET column, SEARCH clause), not
428        // the message wording.
429        assert!(
430            matches!(&err, Error::Incomplete(what)
431                if what.contains("SET") && what.contains("SEARCH")),
432            "got: {err}"
433        );
434    }
435
436    #[test]
437    fn the_cycle_mark_values_are_written_as_one_optional_group() {
438        // PostgreSQL 17: `[ TO cycle_mark_value DEFAULT cycle_mark_default ]` — one
439        // bracket around both, so one is never legal without the other. Both are
440        // `AexprConst` in gram.y, which is why these are literals: libpg_query
441        // rejects `TO $1` outright.
442        let mut cycle = CteCycle::new(["id"], "is_cycle", "path");
443        cycle.to = Some(Expr::literal("Y"));
444        cycle.default_val = Some(Expr::literal("N"));
445
446        let (rendered, args) = build(&Numbered, &cycle).unwrap();
447        assert_frag_sql(
448            AFTER_RECURSIVE_CTE,
449            &rendered,
450            r#"CYCLE "id" SET "is_cycle" TO 'Y' DEFAULT 'N' USING "path""#,
451        );
452        assert!(args.is_empty(), "a constant binds nothing");
453
454        cycle.default_val = None;
455        let err = build(&Numbered, &cycle).unwrap_err();
456        // The substrings name the SQL concepts (TO/DEFAULT of a CYCLE clause),
457        // not the message wording.
458        assert!(
459            matches!(&err, Error::Incomplete(what)
460                if what.contains("TO") && what.contains("DEFAULT") && what.contains("CYCLE")),
461            "got: {err}"
462        );
463    }
464
465    #[test]
466    fn a_cycle_clause_without_its_added_columns_is_a_recorded_failure() {
467        let cycle = CteCycle {
468            columns: vec!["id".into()],
469            ..CteCycle::default()
470        };
471        let err = build(&Numbered, &cycle).unwrap_err();
472        // The substrings name the SQL concepts (SET/USING of a CYCLE clause),
473        // not the message wording.
474        assert!(
475            matches!(&err, Error::Incomplete(what)
476                if what.contains("SET") && what.contains("USING") && what.contains("CYCLE")),
477            "got: {err}"
478        );
479    }
480}