Skip to main content

keelson_core/clause/
join.rs

1use std::borrow::Cow;
2
3use crate::error::Error;
4use crate::expr::{Expr, IntoExpr};
5use crate::writer::{Expression, SqlWriter};
6
7use super::from::TableRef;
8use super::{MaybeAbsent, write_quoted_list};
9
10/// `[NATURAL] <kind> <table> [ON a AND b] [USING (cols) [AS alias]]`
11///
12/// From PostgreSQL 17's `from_item`:
13///
14/// ```text
15/// from_item [ NATURAL ] join_type from_item
16///     [ ON join_condition | USING ( join_column [, ...] ) [ AS join_using_alias ] ]
17/// ```
18///
19/// `ON` and `USING` are alternatives, and `NATURAL` excludes both; nothing here
20/// enforces that, because the check belongs to the mods that build a join — a
21/// dialect exposes `join::on(..)` and `join::using(..)` as separate mods and the
22/// caller picks one.
23#[derive(Debug, Clone, Default)]
24pub struct Join {
25    /// Which join.
26    pub kind: JoinKind,
27    /// What is being joined to, with all of its own decorations.
28    pub to: TableRef,
29    /// `NATURAL`, which derives the join columns from the two items' names.
30    pub natural: bool,
31    /// `ON` conditions, `AND`-joined.
32    pub on: Vec<Expr>,
33    /// `USING` columns, quoted on output.
34    pub using: Vec<Cow<'static, str>>,
35    /// `USING (…) AS alias` — a name for the row of merged join columns
36    /// (PostgreSQL 16+). Quoted on output. Belongs to the `USING` clause, so with
37    /// no [`using`](Self::using) columns it is a recorded build error rather than
38    /// something to guess a rendering for.
39    pub using_alias: Option<Cow<'static, str>>,
40}
41
42impl Join {
43    /// A join of `kind` onto `to`, with no condition yet.
44    pub fn new(kind: JoinKind, to: TableRef) -> Self {
45        Join {
46            kind,
47            to,
48            ..Join::default()
49        }
50    }
51
52    /// Append an `ON` condition.
53    pub fn append_on(&mut self, condition: impl IntoExpr) {
54        self.on.push(condition.into_expr());
55    }
56
57    /// Append `USING` columns.
58    pub fn append_using(
59        &mut self,
60        columns: impl IntoIterator<Item = impl Into<Cow<'static, str>>>,
61    ) {
62        self.using.extend(columns.into_iter().map(Into::into));
63    }
64
65    /// Whether there is nothing to join to, so that nothing will be written.
66    pub fn is_empty(&self) -> bool {
67        self.to.is_empty()
68    }
69}
70
71impl Expression for Join {
72    fn write_sql(&self, w: &mut SqlWriter<'_>) {
73        if self.to.is_empty() {
74            // A join keyword with no table is not a fragment of anything.
75            return;
76        }
77
78        if self.natural {
79            w.push_str("NATURAL ");
80        }
81        w.push_str(self.kind.as_str());
82        w.push_str(" ");
83        w.write_expr(&self.to);
84
85        w.write_slice(&self.on, " ON ", " AND ", "");
86        write_quoted_list(w, &self.using, " USING (", ", ", ")");
87        if let Some(alias) = &self.using_alias {
88            if self.using.is_empty() {
89                // The alias names the row `USING` merges; with no USING there is
90                // no such row, and writing ` AS alias` after an ON (or nothing)
91                // would be valid-looking SQL meaning something else.
92                w.record_error(Error::Incomplete("the USING columns its join alias names"));
93                return;
94            }
95            w.push_str(" AS ");
96            w.push_quoted(&[alias]);
97        }
98    }
99}
100
101/// Anything joins can be appended to: a [`TableRef`], or a statement that keeps
102/// its joins beside its table rather than on it.
103pub trait HasJoins {
104    /// The join list to modify.
105    fn joins_mut(&mut self) -> &mut Vec<Join>;
106}
107
108impl HasJoins for TableRef {
109    fn joins_mut(&mut self) -> &mut Vec<Join> {
110        &mut self.joins
111    }
112}
113
114impl HasJoins for Vec<Join> {
115    fn joins_mut(&mut self) -> &mut Vec<Join> {
116        self
117    }
118}
119
120/// The `join_type` of a join.
121///
122/// Closed in the SQL standard, and left open at one point because MySQL's
123/// `STRAIGHT_JOIN` sits in exactly this slot without being a standard join type.
124/// The `OUTER` in `LEFT OUTER JOIN` is noise — the standard makes it optional and
125/// means the same thing — so it is not spelled out.
126#[derive(Debug, Clone, Default, PartialEq, Eq)]
127pub enum JoinKind {
128    /// `INNER JOIN`. The default, matching SQL's own default for a bare `JOIN`.
129    #[default]
130    Inner,
131    /// `LEFT JOIN`.
132    Left,
133    /// `RIGHT JOIN`.
134    Right,
135    /// `FULL JOIN`.
136    Full,
137    /// `CROSS JOIN`, which takes neither `ON` nor `USING`.
138    Cross,
139    /// A dialect's own join keyword, written verbatim — MySQL's `STRAIGHT_JOIN`.
140    Custom(Cow<'static, str>),
141}
142
143impl JoinKind {
144    /// The keyword, as written.
145    pub fn as_str(&self) -> &str {
146        match self {
147            JoinKind::Inner => "INNER JOIN",
148            JoinKind::Left => "LEFT JOIN",
149            JoinKind::Right => "RIGHT JOIN",
150            JoinKind::Full => "FULL JOIN",
151            JoinKind::Cross => "CROSS JOIN",
152            JoinKind::Custom(kind) => kind,
153        }
154    }
155}
156
157impl MaybeAbsent for Join {
158    fn is_absent(&self) -> bool {
159        self.is_empty()
160    }
161}
162
163#[cfg(test)]
164mod tests {
165    use keelson_sqlcheck::testing::assert_frag_sql;
166
167    use super::*;
168    use crate::dialect::testing::Numbered;
169    use crate::expr::{Chain, arg, quote};
170    use crate::value::Value;
171    use crate::writer::build;
172
173    /// A join is a fragment of a `FROM`, so this is the statement it extends.
174    const FRAME: &str = "SELECT * FROM users {}";
175
176    fn to(table: &'static str) -> TableRef {
177        TableRef::new(quote(table))
178    }
179
180    fn sql(j: &impl Expression) -> String {
181        build(&Numbered, j).expect("render").0
182    }
183
184    #[test]
185    fn a_join_with_nothing_to_join_to_writes_nothing() {
186        assert_frag_sql(FRAME, &sql(&Join::default()), "");
187        assert!(Join::default().is_empty());
188    }
189
190    #[test]
191    fn conditions_are_and_separated_after_one_on() {
192        // PostgreSQL 17: `ON join_condition` takes a single boolean expression, so
193        // several appended conditions become one conjunction rather than several
194        // ON clauses.
195        let mut j = Join::new(JoinKind::Inner, to("posts"));
196        j.append_on(quote(("users", "id")).eq(quote(("posts", "user_id"))));
197        j.append_on(quote(("posts", "status")).eq(arg("published")));
198
199        let (rendered, args) = build(&Numbered, &j).unwrap();
200        assert_frag_sql(
201            FRAME,
202            &rendered,
203            r#"INNER JOIN "posts" ON ("users"."id" = "posts"."user_id") AND ("posts"."status" = $1)"#,
204        );
205        assert_eq!(args, vec![Value::Text("published".into())]);
206    }
207
208    #[test]
209    fn using_columns_are_quoted_and_parenthesised() {
210        // `users` and `tags` share both `id` and `name`, which is what a
211        // two-column USING needs to resolve.
212        let mut j = Join::new(JoinKind::Left, to("tags"));
213        j.append_using(["id", "name"]);
214        assert_frag_sql(FRAME, &sql(&j), r#"LEFT JOIN "tags" USING ("id", "name")"#);
215    }
216
217    #[test]
218    fn a_using_alias_names_the_merged_join_columns() {
219        // PostgreSQL 17 from_item (the alias is 16+):
220        //   USING ( join_column [, ...] ) [ AS join_using_alias ]
221        // The alias follows the parenthesised column list.
222        let mut j = Join::new(JoinKind::Inner, to("tags"));
223        j.append_using(["id"]);
224        j.using_alias = Some("t".into());
225        assert_frag_sql(
226            r#"SELECT "t"."id" FROM users {}"#,
227            &sql(&j),
228            r#"INNER JOIN "tags" USING ("id") AS "t""#,
229        );
230    }
231
232    #[test]
233    fn a_using_alias_without_using_columns_is_a_recorded_failure() {
234        // The alias belongs to the USING clause; with no columns there is no
235        // merged row for it to name, and rendering ` AS "t"` after an ON —
236        // or after nothing — would be valid SQL that means something else.
237        let mut j = Join::new(JoinKind::Inner, to("tags"));
238        j.append_on("true");
239        j.using_alias = Some("t".into());
240        let err = build(&Numbered, &j).unwrap_err();
241        // The substring names the SQL concept (the missing USING columns), not
242        // the message wording.
243        assert!(
244            matches!(&err, crate::Error::Incomplete(what) if what.contains("USING")),
245            "got: {err}"
246        );
247    }
248
249    #[test]
250    fn a_cross_join_carries_neither_on_nor_using() {
251        assert_frag_sql(
252            FRAME,
253            &sql(&Join::new(JoinKind::Cross, to("tags"))),
254            r#"CROSS JOIN "tags""#,
255        );
256    }
257
258    #[test]
259    fn natural_precedes_the_join_kind() {
260        // PostgreSQL 17: `from_item [ NATURAL ] join_type from_item`.
261        let j = Join {
262            natural: true,
263            ..Join::new(JoinKind::Full, to("posts"))
264        };
265        assert_frag_sql(FRAME, &sql(&j), r#"NATURAL FULL JOIN "posts""#);
266    }
267
268    #[test]
269    fn every_kind_has_its_standard_spelling() {
270        // The frame supplies the `ON`, because every one of these kinds requires a
271        // join condition — `CROSS JOIN` is the exception and has its own case.
272        for (kind, keyword) in [
273            (JoinKind::Inner, "INNER JOIN"),
274            (JoinKind::Left, "LEFT JOIN"),
275            (JoinKind::Right, "RIGHT JOIN"),
276            (JoinKind::Full, "FULL JOIN"),
277        ] {
278            assert_frag_sql(
279                "SELECT * FROM users {} ON true",
280                &sql(&Join::new(kind, to("posts"))),
281                &format!(r#"{keyword} "posts""#),
282            );
283        }
284
285        // MySQL's `STRAIGHT_JOIN` takes the place of the keyword entirely. Not
286        // framed: PostgreSQL has no such join type, so the psql judge would reject
287        // valid SQL. Its own crate checks it against MySQL.
288        assert_eq!(
289            build(
290                &Numbered,
291                &Join::new(JoinKind::Custom("STRAIGHT_JOIN".into()), to("posts"))
292            )
293            .unwrap()
294            .0,
295            r#"STRAIGHT_JOIN "posts""#
296        );
297        assert_eq!(JoinKind::default(), JoinKind::Inner);
298    }
299
300    #[test]
301    fn a_join_carries_the_whole_table_ref_including_its_own_joins() {
302        // The recursion in PostgreSQL's grammar — a from_item may itself be a join
303        // — is what lets `a JOIN b JOIN c` be expressed at all. Note where the
304        // conditions land: joins nest to the *left*, so the inner join's ON binds
305        // it to `posts`, and the outer INNER JOIN needs its own ON as well. A
306        // single ON would leave the outer join without one, which is a syntax
307        // error rather than a default.
308        let mut inner = to("posts");
309        let mut inner_join = Join::new(JoinKind::Left, to("comments"));
310        inner_join.append_on(quote(("comments", "post_id")).eq(quote(("p", "id"))));
311        inner.append_join(inner_join);
312
313        let mut outer = Join::new(JoinKind::Inner, inner);
314        outer.to.set_alias("p");
315        outer.append_on("true");
316
317        assert_frag_sql(
318            FRAME,
319            &sql(&outer),
320            r#"INNER JOIN "posts" AS "p" LEFT JOIN "comments" ON ("comments"."post_id" = "p"."id") ON true"#,
321        );
322    }
323
324    #[test]
325    fn a_joined_sub_select_shares_the_placeholder_run() {
326        let sub = Expr::group(Expr::join((
327            Expr::raw(r#"SELECT "id" FROM posts WHERE "user_id" ="#),
328            arg(7i32),
329        )));
330        let mut j = Join::new(JoinKind::Inner, TableRef::new(sub));
331        j.to.set_alias("p");
332        j.append_on(quote(("p", "id")).eq(arg(8i32)));
333
334        let (rendered, args) = build(&Numbered, &j).unwrap();
335        assert_frag_sql(
336            FRAME,
337            &rendered,
338            r#"INNER JOIN (SELECT "id" FROM posts WHERE "user_id" = $1) AS "p" ON ("p"."id" = $2)"#,
339        );
340        assert_eq!(args, vec![Value::I32(7), Value::I32(8)]);
341    }
342}