use std::borrow::Cow;
use crate::error::Error;
use crate::expr::{Expr, IntoExpr};
use crate::writer::{Expression, SqlWriter};
use super::from::TableRef;
use super::{MaybeAbsent, write_quoted_list};
#[derive(Debug, Clone, Default)]
pub struct Join {
pub kind: JoinKind,
pub to: TableRef,
pub natural: bool,
pub on: Vec<Expr>,
pub using: Vec<Cow<'static, str>>,
pub using_alias: Option<Cow<'static, str>>,
}
impl Join {
pub fn new(kind: JoinKind, to: TableRef) -> Self {
Join {
kind,
to,
..Join::default()
}
}
pub fn append_on(&mut self, condition: impl IntoExpr) {
self.on.push(condition.into_expr());
}
pub fn append_using(
&mut self,
columns: impl IntoIterator<Item = impl Into<Cow<'static, str>>>,
) {
self.using.extend(columns.into_iter().map(Into::into));
}
pub fn is_empty(&self) -> bool {
self.to.is_empty()
}
}
impl Expression for Join {
fn write_sql(&self, w: &mut SqlWriter<'_>) {
if self.to.is_empty() {
return;
}
if self.natural {
w.push_str("NATURAL ");
}
w.push_str(self.kind.as_str());
w.push_str(" ");
w.write_expr(&self.to);
w.write_slice(&self.on, " ON ", " AND ", "");
write_quoted_list(w, &self.using, " USING (", ", ", ")");
if let Some(alias) = &self.using_alias {
if self.using.is_empty() {
w.record_error(Error::Incomplete("the USING columns its join alias names"));
return;
}
w.push_str(" AS ");
w.push_quoted(&[alias]);
}
}
}
pub trait HasJoins {
fn joins_mut(&mut self) -> &mut Vec<Join>;
}
impl HasJoins for TableRef {
fn joins_mut(&mut self) -> &mut Vec<Join> {
&mut self.joins
}
}
impl HasJoins for Vec<Join> {
fn joins_mut(&mut self) -> &mut Vec<Join> {
self
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum JoinKind {
#[default]
Inner,
Left,
Right,
Full,
Cross,
Custom(Cow<'static, str>),
}
impl JoinKind {
pub fn as_str(&self) -> &str {
match self {
JoinKind::Inner => "INNER JOIN",
JoinKind::Left => "LEFT JOIN",
JoinKind::Right => "RIGHT JOIN",
JoinKind::Full => "FULL JOIN",
JoinKind::Cross => "CROSS JOIN",
JoinKind::Custom(kind) => kind,
}
}
}
impl MaybeAbsent for Join {
fn is_absent(&self) -> bool {
self.is_empty()
}
}
#[cfg(test)]
mod tests {
use keelson_sqlcheck::testing::assert_frag_sql;
use super::*;
use crate::dialect::testing::Numbered;
use crate::expr::{Chain, arg, quote};
use crate::value::Value;
use crate::writer::build;
const FRAME: &str = "SELECT * FROM users {}";
fn to(table: &'static str) -> TableRef {
TableRef::new(quote(table))
}
fn sql(j: &impl Expression) -> String {
build(&Numbered, j).expect("render").0
}
#[test]
fn a_join_with_nothing_to_join_to_writes_nothing() {
assert_frag_sql(FRAME, &sql(&Join::default()), "");
assert!(Join::default().is_empty());
}
#[test]
fn conditions_are_and_separated_after_one_on() {
let mut j = Join::new(JoinKind::Inner, to("posts"));
j.append_on(quote(("users", "id")).eq(quote(("posts", "user_id"))));
j.append_on(quote(("posts", "status")).eq(arg("published")));
let (rendered, args) = build(&Numbered, &j).unwrap();
assert_frag_sql(
FRAME,
&rendered,
r#"INNER JOIN "posts" ON ("users"."id" = "posts"."user_id") AND ("posts"."status" = $1)"#,
);
assert_eq!(args, vec![Value::Text("published".into())]);
}
#[test]
fn using_columns_are_quoted_and_parenthesised() {
let mut j = Join::new(JoinKind::Left, to("tags"));
j.append_using(["id", "name"]);
assert_frag_sql(FRAME, &sql(&j), r#"LEFT JOIN "tags" USING ("id", "name")"#);
}
#[test]
fn a_using_alias_names_the_merged_join_columns() {
let mut j = Join::new(JoinKind::Inner, to("tags"));
j.append_using(["id"]);
j.using_alias = Some("t".into());
assert_frag_sql(
r#"SELECT "t"."id" FROM users {}"#,
&sql(&j),
r#"INNER JOIN "tags" USING ("id") AS "t""#,
);
}
#[test]
fn a_using_alias_without_using_columns_is_a_recorded_failure() {
let mut j = Join::new(JoinKind::Inner, to("tags"));
j.append_on("true");
j.using_alias = Some("t".into());
let err = build(&Numbered, &j).unwrap_err();
assert!(
matches!(&err, crate::Error::Incomplete(what) if what.contains("USING")),
"got: {err}"
);
}
#[test]
fn a_cross_join_carries_neither_on_nor_using() {
assert_frag_sql(
FRAME,
&sql(&Join::new(JoinKind::Cross, to("tags"))),
r#"CROSS JOIN "tags""#,
);
}
#[test]
fn natural_precedes_the_join_kind() {
let j = Join {
natural: true,
..Join::new(JoinKind::Full, to("posts"))
};
assert_frag_sql(FRAME, &sql(&j), r#"NATURAL FULL JOIN "posts""#);
}
#[test]
fn every_kind_has_its_standard_spelling() {
for (kind, keyword) in [
(JoinKind::Inner, "INNER JOIN"),
(JoinKind::Left, "LEFT JOIN"),
(JoinKind::Right, "RIGHT JOIN"),
(JoinKind::Full, "FULL JOIN"),
] {
assert_frag_sql(
"SELECT * FROM users {} ON true",
&sql(&Join::new(kind, to("posts"))),
&format!(r#"{keyword} "posts""#),
);
}
assert_eq!(
build(
&Numbered,
&Join::new(JoinKind::Custom("STRAIGHT_JOIN".into()), to("posts"))
)
.unwrap()
.0,
r#"STRAIGHT_JOIN "posts""#
);
assert_eq!(JoinKind::default(), JoinKind::Inner);
}
#[test]
fn a_join_carries_the_whole_table_ref_including_its_own_joins() {
let mut inner = to("posts");
let mut inner_join = Join::new(JoinKind::Left, to("comments"));
inner_join.append_on(quote(("comments", "post_id")).eq(quote(("p", "id"))));
inner.append_join(inner_join);
let mut outer = Join::new(JoinKind::Inner, inner);
outer.to.set_alias("p");
outer.append_on("true");
assert_frag_sql(
FRAME,
&sql(&outer),
r#"INNER JOIN "posts" AS "p" LEFT JOIN "comments" ON ("comments"."post_id" = "p"."id") ON true"#,
);
}
#[test]
fn a_joined_sub_select_shares_the_placeholder_run() {
let sub = Expr::group(Expr::join((
Expr::raw(r#"SELECT "id" FROM posts WHERE "user_id" ="#),
arg(7i32),
)));
let mut j = Join::new(JoinKind::Inner, TableRef::new(sub));
j.to.set_alias("p");
j.append_on(quote(("p", "id")).eq(arg(8i32)));
let (rendered, args) = build(&Numbered, &j).unwrap();
assert_frag_sql(
FRAME,
&rendered,
r#"INNER JOIN (SELECT "id" FROM posts WHERE "user_id" = $1) AS "p" ON ("p"."id" = $2)"#,
);
assert_eq!(args, vec![Value::I32(7), Value::I32(8)]);
}
}