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#[derive(Debug, Clone, Default)]
24pub struct Join {
25 pub kind: JoinKind,
27 pub to: TableRef,
29 pub natural: bool,
31 pub on: Vec<Expr>,
33 pub using: Vec<Cow<'static, str>>,
35 pub using_alias: Option<Cow<'static, str>>,
40}
41
42impl Join {
43 pub fn new(kind: JoinKind, to: TableRef) -> Self {
45 Join {
46 kind,
47 to,
48 ..Join::default()
49 }
50 }
51
52 pub fn append_on(&mut self, condition: impl IntoExpr) {
54 self.on.push(condition.into_expr());
55 }
56
57 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 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 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 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
101pub trait HasJoins {
104 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
127pub enum JoinKind {
128 #[default]
130 Inner,
131 Left,
133 Right,
135 Full,
137 Cross,
139 Custom(Cow<'static, str>),
141}
142
143impl JoinKind {
144 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 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 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 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 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 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 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 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 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 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 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}