1use crate::error::Error;
2use crate::expr::{Expr, IntoExpr};
3use crate::writer::{Expression, SqlWriter};
4
5use super::fetch::Fetch;
6use super::limit::Limit;
7use super::offset::Offset;
8use super::order_by::OrderBy;
9use super::{MaybeAbsent, write_present};
10
11#[derive(Debug, Clone, Default)]
40pub struct Combines {
41 pub queries: Vec<Combine>,
43 pub order_by: OrderBy,
45 pub limit: Limit,
47 pub offset: Offset,
49 pub fetch: Fetch,
51}
52
53impl Combines {
54 pub fn append_combine(&mut self, combine: Combine) {
56 self.queries.push(combine);
57 }
58
59 pub fn is_empty(&self) -> bool {
61 self.queries.is_empty()
62 && self.order_by.is_empty()
63 && self.limit.is_empty()
64 && self.offset.is_empty()
65 && self.fetch.is_empty()
66 }
67
68 pub fn parenthesises_leading_query(&self, leading_has_tail_clauses: bool) -> bool {
80 !self.queries.is_empty() && leading_has_tail_clauses
81 }
82}
83
84impl Expression for Combines {
85 fn write_sql(&self, w: &mut SqlWriter<'_>) {
86 if self.queries.is_empty() {
95 if self.is_empty() {
96 return;
97 }
98 let missing = if !self.order_by.is_empty() {
99 "the set operation its combined ORDER BY applies to"
100 } else if !self.limit.is_empty() {
101 "the set operation its combined LIMIT applies to"
102 } else if !self.offset.is_empty() {
103 "the set operation its combined OFFSET applies to"
104 } else {
105 "the set operation its combined FETCH applies to"
106 };
107 w.record_error(Error::Incomplete(missing));
108 return;
109 }
110
111 if !self.limit.is_empty() && !self.fetch.is_empty() {
115 w.record_error(Error::conflicting_clauses("LIMIT", "FETCH"));
116 return;
117 }
118
119 let mut written = !self.queries.is_empty();
121 write_present(w, &self.queries, "", " ", "");
122
123 for (present, clause) in [
124 (!self.order_by.is_empty(), &self.order_by as &dyn Expression),
125 (!self.limit.is_empty(), &self.limit),
126 (!self.offset.is_empty(), &self.offset),
127 (!self.fetch.is_empty(), &self.fetch),
128 ] {
129 if !present {
130 continue;
131 }
132 if written {
133 w.push_str(" ");
134 }
135 w.write_expr(clause);
136 written = true;
137 }
138 }
139}
140
141pub trait HasCombines {
143 fn combines_mut(&mut self) -> &mut Combines;
145}
146
147impl HasCombines for Combines {
148 fn combines_mut(&mut self) -> &mut Combines {
149 self
150 }
151}
152
153#[derive(Debug, Clone, Default)]
160pub struct Combine {
161 pub op: Option<SetOp>,
164 pub query: Option<Expr>,
166 pub all: bool,
168}
169
170impl Combine {
171 pub fn new(op: SetOp, query: impl IntoExpr) -> Self {
173 Combine {
174 op: Some(op),
175 query: Some(query.into_expr()),
176 all: false,
177 }
178 }
179
180 pub fn is_empty(&self) -> bool {
182 self.op.is_none() && self.query.is_none()
183 }
184}
185
186impl Expression for Combine {
187 fn write_sql(&self, w: &mut SqlWriter<'_>) {
188 if self.is_empty() {
189 return;
190 }
191
192 let Some(op) = &self.op else {
195 w.record_error(Error::Incomplete("the operator of a set operation"));
196 return;
197 };
198 let Some(query) = &self.query else {
199 w.record_error(Error::Incomplete("the query of a set operation"));
200 return;
201 };
202
203 w.push_str(op.as_str());
204 w.push_str(if self.all { " ALL (" } else { " (" });
205 w.write_expr(query);
206 w.push_str(")");
207 }
208}
209
210#[derive(Debug, Clone, Copy, PartialEq, Eq)]
212pub enum SetOp {
213 Union,
215 Intersect,
217 Except,
219}
220
221impl SetOp {
222 pub fn as_str(self) -> &'static str {
224 match self {
225 SetOp::Union => "UNION",
226 SetOp::Intersect => "INTERSECT",
227 SetOp::Except => "EXCEPT",
228 }
229 }
230}
231
232impl MaybeAbsent for Combine {
233 fn is_absent(&self) -> bool {
234 self.is_empty()
235 }
236}
237
238#[cfg(test)]
239mod tests {
240 use keelson_sqlcheck::testing::assert_frag_sql;
241
242 use super::*;
243 use crate::dialect::testing::Numbered;
244 use crate::expr::arg;
245 use crate::value::Value;
246 use crate::writer::build;
247
248 const FRAME: &str = r#"SELECT "id" FROM users {}"#;
253
254 fn sub(v: i32) -> Expr {
257 Expr::join((Expr::raw(r#"SELECT "id" FROM posts WHERE "id" ="#), arg(v)))
258 }
259
260 fn sub_sql(n: usize) -> String {
261 format!(r#"SELECT "id" FROM posts WHERE "id" = ${n}"#)
262 }
263
264 fn sql(e: &impl Expression) -> String {
265 build(&Numbered, e).expect("render").0
266 }
267
268 #[test]
269 fn an_empty_combines_writes_nothing() {
270 assert_frag_sql(FRAME, &sql(&Combines::default()), "");
271 assert_frag_sql(FRAME, &sql(&Combine::default()), "");
272 assert!(Combines::default().is_empty());
273 assert!(Combine::default().is_empty());
274 }
275
276 #[test]
277 fn all_goes_between_the_operator_and_the_operand() {
278 let mut c = Combine::new(SetOp::Union, sub(1));
279 assert_frag_sql(FRAME, &sql(&c), &format!("UNION ({})", sub_sql(1)));
280
281 c.all = true;
282 assert_frag_sql(FRAME, &sql(&c), &format!("UNION ALL ({})", sub_sql(1)));
283 }
284
285 #[test]
286 fn every_operator_has_its_spelling() {
287 for (op, keyword) in [
288 (SetOp::Union, "UNION"),
289 (SetOp::Intersect, "INTERSECT"),
290 (SetOp::Except, "EXCEPT"),
291 ] {
292 assert_frag_sql(
293 FRAME,
294 &sql(&Combine::new(op, Expr::raw("SELECT 1"))),
295 &format!("{keyword} (SELECT 1)"),
296 );
297 }
298 }
299
300 #[test]
301 fn a_half_filled_combine_is_a_recorded_failure_not_a_broken_fragment() {
302 let no_op = Combine {
303 query: Some(sub(1)),
304 ..Combine::default()
305 };
306 let err = build(&Numbered, &no_op).unwrap_err();
307 assert!(
310 matches!(&err, Error::Incomplete(what) if what.contains("operator")),
311 "got: {err}"
312 );
313
314 let no_query = Combine {
315 op: Some(SetOp::Except),
316 ..Combine::default()
317 };
318 let err = build(&Numbered, &no_query).unwrap_err();
319 assert!(
322 matches!(&err, Error::Incomplete(what) if what.contains("query")),
323 "got: {err}"
324 );
325 }
326
327 #[test]
328 fn chained_operations_keep_one_placeholder_run() {
329 let mut cs = Combines::default();
330 cs.append_combine(Combine::new(SetOp::Union, sub(1)));
331 cs.append_combine(Combine::new(SetOp::Intersect, sub(2)));
332
333 let (rendered, args) = build(&Numbered, &cs).unwrap();
334 assert_frag_sql(
335 FRAME,
336 &rendered,
337 &format!("UNION ({}) INTERSECT ({})", sub_sql(1), sub_sql(2)),
338 );
339 assert_eq!(args, vec![Value::I32(1), Value::I32(2)]);
340 }
341
342 #[test]
343 fn the_combinations_own_tail_clauses_follow_the_last_operand() {
344 let mut cs = Combines::default();
347 cs.append_combine(Combine::new(SetOp::Union, sub(1)));
348 cs.order_by.append_order("1");
349 cs.limit.set_limit(10i64);
350 cs.offset.set_offset(5i64);
351
352 assert_frag_sql(
353 FRAME,
354 &sql(&cs),
355 &format!("UNION ({}) ORDER BY 1 LIMIT 10 OFFSET 5", sub_sql(1)),
356 );
357 }
358
359 #[test]
360 fn a_tail_clause_without_a_set_operation_is_a_recorded_failure() {
361 let mut cs = Combines::default();
366 cs.fetch.set_fetch(2i64);
367 assert!(!cs.is_empty());
368 let err = build(&Numbered, &cs).unwrap_err();
369 assert!(
372 matches!(&err, Error::Incomplete(what)
373 if what.contains("set operation") && what.contains("FETCH")),
374 "got: {err}"
375 );
376
377 let mut cs = Combines::default();
378 cs.order_by.append_order("1");
379 let err = build(&Numbered, &cs).unwrap_err();
380 assert!(
381 matches!(&err, Error::Incomplete(what)
382 if what.contains("set operation") && what.contains("ORDER BY")),
383 "got: {err}"
384 );
385 }
386
387 #[test]
388 fn a_combined_limit_and_fetch_together_are_a_recorded_failure() {
389 let mut cs = Combines::default();
393 cs.append_combine(Combine::new(SetOp::Union, sub(1)));
394 cs.limit.set_limit(10i64);
395 cs.fetch.set_fetch(2i64);
396 let err = build(&Numbered, &cs).unwrap_err();
397 assert!(
398 matches!(
399 &err,
400 Error::ConflictingClauses {
401 first: "LIMIT",
402 second: "FETCH"
403 }
404 ),
405 "got: {err}"
406 );
407 }
408
409 #[test]
410 fn the_leading_query_is_wrapped_only_when_both_conditions_hold() {
411 let mut cs = Combines::default();
412 assert!(
413 !cs.parenthesises_leading_query(true),
414 "nothing combined: the parentheses would say nothing"
415 );
416
417 cs.append_combine(Combine::new(SetOp::Union, sub(1)));
418 assert!(
419 !cs.parenthesises_leading_query(false),
420 "no tail clause on the leading query: nothing to protect"
421 );
422 assert!(cs.parenthesises_leading_query(true));
423 }
424}