Skip to content

Commit a7514df

Browse files
fix(generator): wrap bare boolean in T-SQL condition position as = 1 (CR-018)
SQL Server has no native boolean type, so a bare boolean expression in a search-condition position (WHERE/HAVING/QUALIFY, JOIN ON, searched CASE WHEN, UPDATE/DELETE WHERE, MERGE AND, and AND/OR/NOT operands) is rejected with error 4145. Add a recursive gen_condition helper that, for the T-SQL family only, wraps a bare boolean as '<expr> = 1', lowers bare TRUE/FALSE to '1 = 1'/'1 = 0', recurses through logical connectives, and leaves existing predicates untouched. Simple CASE (CASE <operand> WHEN <value>) WHEN values are not conditions and are left alone. Non-T-SQL output is unchanged.
1 parent 5fdd474 commit a7514df

2 files changed

Lines changed: 251 additions & 13 deletions

File tree

src/generator/sql_generator.rs

Lines changed: 96 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -331,11 +331,11 @@ impl Generator {
331331
if self.pretty {
332332
self.indent_up();
333333
self.newline();
334-
self.gen_expr(wh);
334+
self.gen_condition(wh);
335335
self.indent_down();
336336
} else {
337337
self.write(" ");
338-
self.gen_expr(wh);
338+
self.gen_condition(wh);
339339
}
340340
}
341341

@@ -359,11 +359,11 @@ impl Generator {
359359
if self.pretty {
360360
self.indent_up();
361361
self.newline();
362-
self.gen_expr(having);
362+
self.gen_condition(having);
363363
self.indent_down();
364364
} else {
365365
self.write(" ");
366-
self.gen_expr(having);
366+
self.gen_condition(having);
367367
}
368368
}
369369

@@ -373,11 +373,11 @@ impl Generator {
373373
if self.pretty {
374374
self.indent_up();
375375
self.newline();
376-
self.gen_expr(qualify);
376+
self.gen_condition(qualify);
377377
self.indent_down();
378378
} else {
379379
self.write(" ");
380-
self.gen_expr(qualify);
380+
self.gen_condition(qualify);
381381
}
382382
}
383383

@@ -682,7 +682,7 @@ impl Generator {
682682
self.write(" ");
683683
}
684684
self.write_keyword("ON ");
685-
self.gen_expr(on);
685+
self.gen_condition(on);
686686
}
687687
if !join.using.is_empty() {
688688
if self.pretty {
@@ -934,11 +934,11 @@ impl Generator {
934934
if self.pretty {
935935
self.indent_up();
936936
self.newline();
937-
self.gen_expr(wh);
937+
self.gen_condition(wh);
938938
self.indent_down();
939939
} else {
940940
self.write(" ");
941-
self.gen_expr(wh);
941+
self.gen_condition(wh);
942942
}
943943
}
944944

@@ -984,11 +984,11 @@ impl Generator {
984984
if self.pretty {
985985
self.indent_up();
986986
self.newline();
987-
self.gen_expr(wh);
987+
self.gen_condition(wh);
988988
self.indent_down();
989989
} else {
990990
self.write(" ");
991-
self.gen_expr(wh);
991+
self.gen_condition(wh);
992992
}
993993
}
994994

@@ -1096,7 +1096,7 @@ impl Generator {
10961096

10971097
if let Some(cond) = &clause.condition {
10981098
self.write_keyword(" AND ");
1099-
self.gen_expr(cond);
1099+
self.gen_condition(cond);
11001100
}
11011101

11021102
self.write_keyword(" THEN");
@@ -1702,6 +1702,81 @@ impl Generator {
17021702
}
17031703
}
17041704

1705+
/// Emit `e` in a *condition* (search-condition) position.
1706+
///
1707+
/// SQL Server has no native boolean type: a bare boolean expression (a
1708+
/// `bit` column, function result, or boolean literal) is not a valid
1709+
/// predicate and is rejected with error 4145 ("An expression of non-boolean
1710+
/// type specified in a context where a condition is expected"). For the
1711+
/// T-SQL family we wrap such a bare boolean as `<e> = 1`, recursing through
1712+
/// the logical connectives (`AND`/`OR`/`XOR`/`NOT`/parentheses) so each
1713+
/// nested operand is fixed, and lowering a bare boolean literal to `1 = 1`
1714+
/// / `1 = 0`. Expressions that are already predicates (comparisons,
1715+
/// `IS NULL`, `IN`, `LIKE`, `BETWEEN`, `EXISTS`, …) are emitted unchanged.
1716+
/// Every non-T-SQL dialect delegates straight to `gen_expr`, so their
1717+
/// output is byte-for-byte identical.
1718+
fn gen_condition(&mut self, e: &Expr) {
1719+
if !matches!(self.dialect, Some(d) if crate::dialects::is_tsql_family(d)) {
1720+
self.gen_expr(e);
1721+
return;
1722+
}
1723+
match e {
1724+
// Logical connectives: each operand is itself a condition position.
1725+
Expr::BinaryOp { left, op, right }
1726+
if matches!(
1727+
op,
1728+
BinaryOperator::And | BinaryOperator::Or | BinaryOperator::Xor
1729+
) =>
1730+
{
1731+
self.gen_condition(left);
1732+
self.write(Self::binary_op_str(op));
1733+
self.gen_condition(right);
1734+
}
1735+
Expr::UnaryOp {
1736+
op: UnaryOperator::Not,
1737+
expr,
1738+
} => {
1739+
self.write("NOT ");
1740+
self.gen_condition(expr);
1741+
}
1742+
Expr::Nested(inner) => {
1743+
self.write("(");
1744+
self.gen_condition(inner);
1745+
self.write(")");
1746+
}
1747+
// Already boolean-typed predicates: emit unchanged.
1748+
Expr::BinaryOp {
1749+
op:
1750+
BinaryOperator::Eq
1751+
| BinaryOperator::Neq
1752+
| BinaryOperator::Lt
1753+
| BinaryOperator::Gt
1754+
| BinaryOperator::LtEq
1755+
| BinaryOperator::GtEq,
1756+
..
1757+
}
1758+
| Expr::Between { .. }
1759+
| Expr::IsNull { .. }
1760+
| Expr::IsBool { .. }
1761+
| Expr::InList { .. }
1762+
| Expr::InSubquery { .. }
1763+
| Expr::Like { .. }
1764+
| Expr::ILike { .. }
1765+
| Expr::SimilarTo { .. }
1766+
| Expr::Exists { .. }
1767+
| Expr::AnyOp { .. }
1768+
| Expr::AllOp { .. } => self.gen_expr(e),
1769+
// Bare boolean literal in a condition position → `1 = 1` / `1 = 0`.
1770+
Expr::Boolean(b) => self.write(if *b { "1 = 1" } else { "1 = 0" }),
1771+
// Any other bare scalar (column, function, cast, scalar subquery,
1772+
// …) is a PostgreSQL boolean here; wrap it into a predicate.
1773+
_ => {
1774+
self.gen_expr(e);
1775+
self.write(" = 1");
1776+
}
1777+
}
1778+
}
1779+
17051780
fn gen_expr(&mut self, expr: &Expr) {
17061781
match expr {
17071782
Expr::Column {
@@ -2011,7 +2086,15 @@ impl Generator {
20112086
for (cond, result) in when_clauses {
20122087
self.write(" ");
20132088
self.write_keyword("WHEN ");
2014-
self.gen_expr(cond);
2089+
// A simple CASE (`CASE <operand> WHEN <value>`) compares each
2090+
// WHEN value against the operand, so it is NOT a condition
2091+
// position. Only a searched CASE (`CASE WHEN <cond>`) puts a
2092+
// boolean search condition here and needs T-SQL wrapping.
2093+
if operand.is_some() {
2094+
self.gen_expr(cond);
2095+
} else {
2096+
self.gen_condition(cond);
2097+
}
20152098
self.write(" ");
20162099
self.write_keyword("THEN ");
20172100
self.gen_expr(result);

tests/test_transpile.rs

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2654,3 +2654,158 @@ fn cr014_transpile_paren_setop_pg_identity() {
26542654
);
26552655
}
26562656
}
2657+
2658+
// ── CR-018: bare boolean in a condition position wrapped to `= 1` for T-SQL ──
2659+
//
2660+
// SQL Server has no native boolean type, so a bare boolean expression in a
2661+
// search-condition position (WHERE / HAVING / QUALIFY / JOIN … ON / searched
2662+
// CASE WHEN / AND / OR / NOT) is rejected with error 4145. The generator wraps
2663+
// such a bare boolean as `<expr> = 1` for the T-SQL family only.
2664+
2665+
#[test]
2666+
fn cr018_where_bare_boolean_pg_to_tsql() {
2667+
validate_with_dialect(
2668+
"SELECT 1 FROM t WHERE b",
2669+
"SELECT 1 FROM t WHERE b = 1",
2670+
Dialect::Postgres,
2671+
Dialect::Tsql,
2672+
);
2673+
}
2674+
2675+
#[test]
2676+
fn cr018_where_not_bare_boolean() {
2677+
// `NOT b` → `NOT b = 1`, which T-SQL parses as `NOT (b = 1)` (`=` binds
2678+
// tighter than `NOT`) — identical 3-valued logic to PostgreSQL `NOT b`.
2679+
validate_with_dialect(
2680+
"SELECT 1 FROM t WHERE NOT b",
2681+
"SELECT 1 FROM t WHERE NOT b = 1",
2682+
Dialect::Postgres,
2683+
Dialect::Tsql,
2684+
);
2685+
}
2686+
2687+
#[test]
2688+
fn cr018_where_and_mixed_with_predicate() {
2689+
// Only the bare operand is wrapped; the existing IS NOT NULL predicate stays.
2690+
validate_with_dialect(
2691+
"SELECT 1 FROM t WHERE b AND x IS NOT NULL",
2692+
"SELECT 1 FROM t WHERE b = 1 AND x IS NOT NULL",
2693+
Dialect::Postgres,
2694+
Dialect::Tsql,
2695+
);
2696+
}
2697+
2698+
#[test]
2699+
fn cr018_where_or_both_bare() {
2700+
validate_with_dialect(
2701+
"SELECT 1 FROM t WHERE b OR c",
2702+
"SELECT 1 FROM t WHERE b = 1 OR c = 1",
2703+
Dialect::Postgres,
2704+
Dialect::Tsql,
2705+
);
2706+
}
2707+
2708+
#[test]
2709+
fn cr018_searched_case_when_bare_boolean() {
2710+
// The smoking-gun case from the ticket: bare boolean in a searched CASE WHEN.
2711+
let out = transpile(
2712+
"SELECT SUM(CASE WHEN b THEN 1 ELSE 0 END) FROM t",
2713+
Dialect::Postgres,
2714+
Dialect::Tsql,
2715+
)
2716+
.unwrap();
2717+
assert!(out.contains("WHEN b = 1 THEN"), "got: {out}");
2718+
assert!(!out.contains("WHEN b THEN"), "must not stay bare: {out}");
2719+
}
2720+
2721+
#[test]
2722+
fn cr018_join_on_bare_boolean() {
2723+
let out = transpile(
2724+
"SELECT 1 FROM t1 JOIN t2 ON t2.b",
2725+
Dialect::Postgres,
2726+
Dialect::Tsql,
2727+
)
2728+
.unwrap();
2729+
assert!(out.contains("ON t2.b = 1"), "got: {out}");
2730+
}
2731+
2732+
#[test]
2733+
fn cr018_where_boolean_literals() {
2734+
// Bare TRUE / FALSE in a condition position → `1 = 1` / `1 = 0`, not the
2735+
// invalid bare `1` / `0` produced by the value-context Boolean arm.
2736+
validate_with_dialect(
2737+
"SELECT 1 FROM t WHERE TRUE",
2738+
"SELECT 1 FROM t WHERE 1 = 1",
2739+
Dialect::Postgres,
2740+
Dialect::Tsql,
2741+
);
2742+
validate_with_dialect(
2743+
"SELECT 1 FROM t WHERE FALSE",
2744+
"SELECT 1 FROM t WHERE 1 = 0",
2745+
Dialect::Postgres,
2746+
Dialect::Tsql,
2747+
);
2748+
}
2749+
2750+
// ── CR-018 controls: predicates already valid must NOT be double-wrapped ──
2751+
2752+
#[test]
2753+
fn cr018_control_existing_predicate_not_double_wrapped() {
2754+
validate_with_dialect(
2755+
"SELECT 1 FROM t WHERE b = 1",
2756+
"SELECT 1 FROM t WHERE b = 1",
2757+
Dialect::Postgres,
2758+
Dialect::Tsql,
2759+
);
2760+
validate_with_dialect(
2761+
"SELECT 1 FROM t WHERE x IS NULL",
2762+
"SELECT 1 FROM t WHERE x IS NULL",
2763+
Dialect::Postgres,
2764+
Dialect::Tsql,
2765+
);
2766+
validate_with_dialect(
2767+
"SELECT 1 FROM t WHERE x BETWEEN 1 AND 9",
2768+
"SELECT 1 FROM t WHERE x BETWEEN 1 AND 9",
2769+
Dialect::Postgres,
2770+
Dialect::Tsql,
2771+
);
2772+
}
2773+
2774+
#[test]
2775+
fn cr018_control_simple_case_when_value_not_wrapped() {
2776+
// A simple CASE (`CASE <operand> WHEN <value>`) compares each WHEN value
2777+
// against the operand — it is NOT a condition position and must be left
2778+
// alone (wrapping it would produce the broken `WHEN 1 = 1`).
2779+
let out = transpile(
2780+
"SELECT CASE status WHEN 1 THEN 'a' ELSE 'b' END FROM t",
2781+
Dialect::Postgres,
2782+
Dialect::Tsql,
2783+
)
2784+
.unwrap();
2785+
assert!(out.contains("WHEN 1 THEN"), "got: {out}");
2786+
assert!(!out.contains("1 = 1"), "simple CASE wrongly wrapped: {out}");
2787+
}
2788+
2789+
#[test]
2790+
fn cr018_control_non_tsql_passthrough() {
2791+
// Non-T-SQL targets must be byte-for-byte unchanged (the helper delegates
2792+
// straight to gen_expr off the T-SQL family).
2793+
validate_with_dialect(
2794+
"SELECT 1 FROM t WHERE b",
2795+
"SELECT 1 FROM t WHERE b",
2796+
Dialect::Postgres,
2797+
Dialect::Postgres,
2798+
);
2799+
validate_with_dialect(
2800+
"SELECT 1 FROM t WHERE TRUE",
2801+
"SELECT 1 FROM t WHERE TRUE",
2802+
Dialect::Postgres,
2803+
Dialect::Postgres,
2804+
);
2805+
validate_with_dialect(
2806+
"SELECT SUM(CASE WHEN b THEN 1 ELSE 0 END) FROM t",
2807+
"SELECT SUM(CASE WHEN b THEN 1 ELSE 0 END) FROM t",
2808+
Dialect::Postgres,
2809+
Dialect::Postgres,
2810+
);
2811+
}

0 commit comments

Comments
 (0)