diff --git a/Cargo.lock b/Cargo.lock index 8742271c..6608b338 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -605,7 +605,7 @@ dependencies = [ [[package]] name = "polyglot-sql" -version = "0.13.2" +version = "0.13.3" dependencies = [ "criterion", "once_cell", @@ -624,7 +624,7 @@ dependencies = [ [[package]] name = "polyglot-sql-ast-derive" -version = "0.13.2" +version = "0.13.3" dependencies = [ "proc-macro2", "quote", @@ -633,7 +633,7 @@ dependencies = [ [[package]] name = "polyglot-sql-ffi" -version = "0.13.2" +version = "0.13.3" dependencies = [ "cbindgen", "polyglot-sql", @@ -643,11 +643,11 @@ dependencies = [ [[package]] name = "polyglot-sql-function-catalogs" -version = "0.13.2" +version = "0.13.3" [[package]] name = "polyglot-sql-python" -version = "0.13.2" +version = "0.13.3" dependencies = [ "polyglot-sql", "pyo3", @@ -658,7 +658,7 @@ dependencies = [ [[package]] name = "polyglot-sql-wasm" -version = "0.13.2" +version = "0.13.3" dependencies = [ "console_error_panic_hook", "js-sys", diff --git a/Cargo.toml b/Cargo.toml index 8ac4bad7..27bbfc0b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ exclude = [ ] [workspace.package] -version = "0.13.2" +version = "0.13.3" edition = "2021" license = "MIT" authors = ["polyglot contributors"] diff --git a/README.md b/README.md index 6f43e276..585f2147 100644 --- a/README.md +++ b/README.md @@ -306,7 +306,7 @@ If you want to disable `stacker` for a native Rust build, turn off default featu ```toml [dependencies] -polyglot-sql = { version = "0.13.2", default-features = false, features = ["all-dialects", "transpile"] } +polyglot-sql = { version = "0.13.3", default-features = false, features = ["all-dialects", "transpile"] } ``` That can reduce overhead slightly on trusted inputs, but you lose the default stack-growth protection for deeply nested SQL. diff --git a/crates/polyglot-sql-function-catalogs/README.md b/crates/polyglot-sql-function-catalogs/README.md index 1c5552a3..1a8675a3 100644 --- a/crates/polyglot-sql-function-catalogs/README.md +++ b/crates/polyglot-sql-function-catalogs/README.md @@ -25,7 +25,7 @@ With `polyglot-sql` compile-time wiring (recommended): ```toml [dependencies] -polyglot-sql = { version = "0.13.2", features = ["function-catalog-clickhouse"] } +polyglot-sql = { version = "0.13.3", features = ["function-catalog-clickhouse"] } ``` Then run schema validation with type checks: diff --git a/crates/polyglot-sql/Cargo.toml b/crates/polyglot-sql/Cargo.toml index 1b496459..f30f7275 100644 --- a/crates/polyglot-sql/Cargo.toml +++ b/crates/polyglot-sql/Cargo.toml @@ -100,14 +100,14 @@ function-catalog-all-dialects = [ [dependencies] regex = { version = "1", optional = true } -polyglot-sql-ast-derive = { path = "../polyglot-sql-ast-derive", version = "0.13.2" } +polyglot-sql-ast-derive = { path = "../polyglot-sql-ast-derive", version = "0.13.3" } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } unicode-segmentation = { workspace = true } stacker = { version = "0.1", optional = true } ts-rs = { version = "12.0", features = ["serde-compat"], optional = true } -polyglot-sql-function-catalogs = { path = "../polyglot-sql-function-catalogs", version = "0.13.2", optional = true, default-features = false } +polyglot-sql-function-catalogs = { path = "../polyglot-sql-function-catalogs", version = "0.13.3", optional = true, default-features = false } [dev-dependencies] pretty_assertions = "1.4" diff --git a/crates/polyglot-sql/README.md b/crates/polyglot-sql/README.md index a446b206..3921fd11 100644 --- a/crates/polyglot-sql/README.md +++ b/crates/polyglot-sql/README.md @@ -25,12 +25,12 @@ By default, `polyglot-sql` enables the full public API. Parser-only consumers ca disable default features and opt into only the dialect parsers they need: ```toml -polyglot-sql = { version = "0.13.2", default-features = false } +polyglot-sql = { version = "0.13.3", default-features = false } ``` ```toml polyglot-sql = { - version = "0.13.2", + version = "0.13.3", default-features = false, features = ["dialect-clickhouse"], } @@ -44,14 +44,14 @@ Examples: ```toml # Parse and generate SQL for one dialect. polyglot-sql = { - version = "0.13.2", + version = "0.13.3", default-features = false, features = ["generate", "dialect-clickhouse"], } # Cross-dialect transpilation. polyglot-sql = { - version = "0.13.2", + version = "0.13.3", default-features = false, features = ["transpile", "dialect-clickhouse", "dialect-postgresql"], } diff --git a/crates/polyglot-sql/src/ast_mutation.rs b/crates/polyglot-sql/src/ast_mutation.rs index af217504..27fb9f11 100644 --- a/crates/polyglot-sql/src/ast_mutation.rs +++ b/crates/polyglot-sql/src/ast_mutation.rs @@ -77,6 +77,7 @@ pub(crate) fn binary(kind: BinaryKind, left: Expression, right: Expression) -> E BinaryKind::Div => Expression::Div(Box::new(operation)), BinaryKind::Mod => Expression::Mod(Box::new(operation)), BinaryKind::Like => Expression::Like(Box::new(LikeOp { + negated: false, left: operation.left, right: operation.right, escape: None, @@ -84,6 +85,7 @@ pub(crate) fn binary(kind: BinaryKind, left: Expression, right: Expression) -> E inferred_type: None, })), BinaryKind::ILike => Expression::ILike(Box::new(LikeOp { + negated: false, left: operation.left, right: operation.right, escape: None, diff --git a/crates/polyglot-sql/src/dialects/athena.rs b/crates/polyglot-sql/src/dialects/athena.rs index f2cd3808..d92e8b16 100644 --- a/crates/polyglot-sql/src/dialects/athena.rs +++ b/crates/polyglot-sql/src/dialects/athena.rs @@ -107,8 +107,10 @@ impl DialectImpl for AthenaDialect { // ILike -> LOWER() LIKE LOWER() (Trino doesn't support ILIKE) Expression::ILike(op) => { let lower_left = Expression::Lower(Box::new(UnaryFunc::new(op.left.clone()))); - let lower_right = Expression::Lower(Box::new(UnaryFunc::new(op.right.clone()))); + let lower_right = + super::lower_like_pattern(op.right.clone(), op.quantifier.is_some()); Ok(Expression::Like(Box::new(LikeOp { + negated: op.negated, left: lower_left, right: lower_right, escape: op.escape, diff --git a/crates/polyglot-sql/src/dialects/bigquery.rs b/crates/polyglot-sql/src/dialects/bigquery.rs index b3ccaa47..60dca5ad 100644 --- a/crates/polyglot-sql/src/dialects/bigquery.rs +++ b/crates/polyglot-sql/src/dialects/bigquery.rs @@ -172,8 +172,9 @@ impl DialectImpl for BigQueryDialect { // ILIKE -> LOWER() LIKE LOWER() in BigQuery (no ILIKE support) Expression::ILike(op) => { let lower_left = Expression::Lower(Box::new(UnaryFunc::new(op.left))); - let lower_right = Expression::Lower(Box::new(UnaryFunc::new(op.right))); + let lower_right = super::lower_like_pattern(op.right, op.quantifier.is_some()); Ok(Expression::Like(Box::new(LikeOp { + negated: op.negated, left: lower_left, right: lower_right, escape: op.escape, diff --git a/crates/polyglot-sql/src/dialects/clickhouse.rs b/crates/polyglot-sql/src/dialects/clickhouse.rs index 030cef5c..ef37a6b6 100644 --- a/crates/polyglot-sql/src/dialects/clickhouse.rs +++ b/crates/polyglot-sql/src/dialects/clickhouse.rs @@ -119,8 +119,6 @@ impl DialectImpl for ClickHouseDialect { | Expression::IsTrue(_) | Expression::IsFalse(_) | Expression::IsJson(_) - | Expression::Like(_) - | Expression::ILike(_) | Expression::SimilarTo(_) | Expression::Glob(_) | Expression::RegexpLike(_) diff --git a/crates/polyglot-sql/src/dialects/hive.rs b/crates/polyglot-sql/src/dialects/hive.rs index a096eef6..871ba314 100644 --- a/crates/polyglot-sql/src/dialects/hive.rs +++ b/crates/polyglot-sql/src/dialects/hive.rs @@ -120,8 +120,9 @@ impl DialectImpl for HiveDialect { // ILIKE -> LOWER() LIKE LOWER() in Hive (no ILIKE support) Expression::ILike(op) => { let lower_left = Expression::Lower(Box::new(UnaryFunc::new(op.left))); - let lower_right = Expression::Lower(Box::new(UnaryFunc::new(op.right))); + let lower_right = super::lower_like_pattern(op.right, op.quantifier.is_some()); Ok(Expression::Like(Box::new(LikeOp { + negated: op.negated, left: lower_left, right: lower_right, escape: op.escape, diff --git a/crates/polyglot-sql/src/dialects/mod.rs b/crates/polyglot-sql/src/dialects/mod.rs index 04e49a82..36c2d4a6 100644 --- a/crates/polyglot-sql/src/dialects/mod.rs +++ b/crates/polyglot-sql/src/dialects/mod.rs @@ -631,6 +631,45 @@ fn is_default_presto_date_format(fmt: &str) -> bool { fmt == "%Y-%m-%d" || fmt == "%F" } +/// Lower each literal quantified pattern, not its containing tuple/array. +#[cfg(feature = "transpile")] +pub(crate) fn lower_like_pattern(pattern: Expression, quantified: bool) -> Expression { + if quantified { + match pattern { + Expression::Paren(mut paren) => { + paren.this = lower_like_pattern(paren.this, true); + return Expression::Paren(paren); + } + Expression::Tuple(mut tuple) => { + tuple.expressions = tuple + .expressions + .into_iter() + .map(|value| lower_like_pattern(value, false)) + .collect(); + return Expression::Tuple(tuple); + } + Expression::Array(mut array) => { + array.expressions = array + .expressions + .into_iter() + .map(|value| lower_like_pattern(value, false)) + .collect(); + return Expression::Array(array); + } + Expression::ArrayFunc(mut array) => { + array.expressions = array + .expressions + .into_iter() + .map(|value| lower_like_pattern(value, false)) + .collect(); + return Expression::ArrayFunc(array); + } + other => return lower_like_pattern(other, false), + } + } + Expression::Lower(Box::new(crate::expressions::UnaryFunc::new(pattern))) +} + /// Applies a transform function bottom-up through an entire expression tree. /// /// The public entrypoint uses an explicit task stack for the recursion-heavy shapes diff --git a/crates/polyglot-sql/src/dialects/mysql.rs b/crates/polyglot-sql/src/dialects/mysql.rs index 7a58c82e..cb202298 100644 --- a/crates/polyglot-sql/src/dialects/mysql.rs +++ b/crates/polyglot-sql/src/dialects/mysql.rs @@ -162,8 +162,9 @@ impl DialectImpl for MySQLDialect { Expression::ILike(op) => { // Transform ILIKE to: LOWER(left) LIKE LOWER(right) let lower_left = Expression::Lower(Box::new(UnaryFunc::new(op.left))); - let lower_right = Expression::Lower(Box::new(UnaryFunc::new(op.right))); + let lower_right = super::lower_like_pattern(op.right, op.quantifier.is_some()); Ok(Expression::Like(Box::new(LikeOp { + negated: op.negated, left: lower_left, right: lower_right, escape: op.escape, diff --git a/crates/polyglot-sql/src/dialects/normalization/mod.rs b/crates/polyglot-sql/src/dialects/normalization/mod.rs index 1ca6eb0e..c13b98bc 100644 --- a/crates/polyglot-sql/src/dialects/normalization/mod.rs +++ b/crates/polyglot-sql/src/dialects/normalization/mod.rs @@ -152,10 +152,18 @@ pub(super) fn normalize( if matches!(source, DialectType::Generic) && matches!(target, DialectType::Drill) { if let Expression::ILike(ref like) = e { - return Ok(Expression::Function(Box::new(Function::new( + if like.quantifier.is_some() { + return Ok(e); + } + let function = Expression::Function(Box::new(Function::new( "ILIKE".to_string(), vec![like.left.clone(), like.right.clone()], - )))); + ))); + return Ok(if like.negated { + Expression::Not(Box::new(crate::expressions::UnaryOp::new(function))) + } else { + function + }); } } @@ -3977,11 +3985,16 @@ pub(super) fn normalize( "LOWER".to_string(), vec![like.left], ))); - let lower_right = Expression::Function(Box::new(Function::new( - "LOWER".to_string(), - vec![like.right], - ))); + let lower_right = if like.quantifier.is_some() { + super::lower_like_pattern(like.right, true) + } else { + Expression::Function(Box::new(Function::new( + "LOWER".to_string(), + vec![like.right], + ))) + }; return Ok(Expression::Like(Box::new(crate::expressions::LikeOp { + negated: like.negated, left: lower_left, right: lower_right, escape: like.escape, diff --git a/crates/polyglot-sql/src/dialects/normalization/operators.rs b/crates/polyglot-sql/src/dialects/normalization/operators.rs index 2a95794c..61b4e04d 100644 --- a/crates/polyglot-sql/src/dialects/normalization/operators.rs +++ b/crates/polyglot-sql/src/dialects/normalization/operators.rs @@ -335,6 +335,7 @@ pub(super) fn rewrite( Action::SimilarToToTsqlLike => match e { Expression::SimilarTo(f) => { let like = Expression::Like(Box::new(LikeOp { + negated: false, left: f.this, right: f.pattern, escape: f.escape, diff --git a/crates/polyglot-sql/src/dialects/normalization/scalar.rs b/crates/polyglot-sql/src/dialects/normalization/scalar.rs index ac339d69..bb43e006 100644 --- a/crates/polyglot-sql/src/dialects/normalization/scalar.rs +++ b/crates/polyglot-sql/src/dialects/normalization/scalar.rs @@ -3080,6 +3080,7 @@ pub(super) fn rewrite( None }; Ok(Expression::Like(Box::new(crate::expressions::LikeOp { + negated: false, left: this, right: pattern, escape, @@ -3097,6 +3098,7 @@ pub(super) fn rewrite( None }; Ok(Expression::ILike(Box::new(crate::expressions::LikeOp { + negated: false, left: this, right: pattern, escape, diff --git a/crates/polyglot-sql/src/dialects/oracle.rs b/crates/polyglot-sql/src/dialects/oracle.rs index e3340c1a..582db399 100644 --- a/crates/polyglot-sql/src/dialects/oracle.rs +++ b/crates/polyglot-sql/src/dialects/oracle.rs @@ -92,8 +92,9 @@ impl DialectImpl for OracleDialect { // ILIKE -> LOWER() LIKE LOWER() in Oracle (no ILIKE support) Expression::ILike(op) => { let lower_left = Expression::Lower(Box::new(UnaryFunc::new(op.left))); - let lower_right = Expression::Lower(Box::new(UnaryFunc::new(op.right))); + let lower_right = super::lower_like_pattern(op.right, op.quantifier.is_some()); Ok(Expression::Like(Box::new(LikeOp { + negated: op.negated, left: lower_left, right: lower_right, escape: op.escape, diff --git a/crates/polyglot-sql/src/dialects/presto.rs b/crates/polyglot-sql/src/dialects/presto.rs index 43057a20..466a0f27 100644 --- a/crates/polyglot-sql/src/dialects/presto.rs +++ b/crates/polyglot-sql/src/dialects/presto.rs @@ -74,8 +74,10 @@ impl DialectImpl for PrestoDialect { // ILike -> LOWER() LIKE LOWER() (Presto doesn't support ILIKE) Expression::ILike(op) => { let lower_left = Expression::Lower(Box::new(UnaryFunc::new(op.left.clone()))); - let lower_right = Expression::Lower(Box::new(UnaryFunc::new(op.right.clone()))); + let lower_right = + super::lower_like_pattern(op.right.clone(), op.quantifier.is_some()); Ok(Expression::Like(Box::new(LikeOp { + negated: op.negated, left: lower_left, right: lower_right, escape: op.escape, diff --git a/crates/polyglot-sql/src/dialects/snowflake.rs b/crates/polyglot-sql/src/dialects/snowflake.rs index 7deabe77..c61bd36f 100644 --- a/crates/polyglot-sql/src/dialects/snowflake.rs +++ b/crates/polyglot-sql/src/dialects/snowflake.rs @@ -2432,6 +2432,7 @@ impl SnowflakeDialect { let right = args.next().unwrap(); let escape = args.next(); Ok(Expression::Like(Box::new(crate::expressions::LikeOp { + negated: false, left, right, escape, @@ -2447,6 +2448,7 @@ impl SnowflakeDialect { let right = args.next().unwrap(); let escape = args.next(); Ok(Expression::ILike(Box::new(crate::expressions::LikeOp { + negated: false, left, right, escape, diff --git a/crates/polyglot-sql/src/dialects/sqlite.rs b/crates/polyglot-sql/src/dialects/sqlite.rs index 3a068f99..2d867af4 100644 --- a/crates/polyglot-sql/src/dialects/sqlite.rs +++ b/crates/polyglot-sql/src/dialects/sqlite.rs @@ -82,8 +82,10 @@ impl DialectImpl for SQLiteDialect { // ILike -> LOWER() LIKE LOWER() (SQLite doesn't support ILIKE) Expression::ILike(op) => { let lower_left = Expression::Lower(Box::new(UnaryFunc::new(op.left.clone()))); - let lower_right = Expression::Lower(Box::new(UnaryFunc::new(op.right.clone()))); + let lower_right = + super::lower_like_pattern(op.right.clone(), op.quantifier.is_some()); Ok(Expression::Like(Box::new(LikeOp { + negated: op.negated, left: lower_left, right: lower_right, escape: op.escape, @@ -375,6 +377,7 @@ impl SQLiteDialect { let escape = args.remove(0); Ok(Expression::Like(Box::new(LikeOp { left: string, + negated: false, right: pattern, escape: Some(escape), quantifier: None, diff --git a/crates/polyglot-sql/src/dialects/trino.rs b/crates/polyglot-sql/src/dialects/trino.rs index 8eb0b5bb..77af5466 100644 --- a/crates/polyglot-sql/src/dialects/trino.rs +++ b/crates/polyglot-sql/src/dialects/trino.rs @@ -80,8 +80,10 @@ impl DialectImpl for TrinoDialect { // ILike -> LOWER() LIKE LOWER() (Trino doesn't support ILIKE) Expression::ILike(op) => { let lower_left = Expression::Lower(Box::new(UnaryFunc::new(op.left.clone()))); - let lower_right = Expression::Lower(Box::new(UnaryFunc::new(op.right.clone()))); + let lower_right = + super::lower_like_pattern(op.right.clone(), op.quantifier.is_some()); Ok(Expression::Like(Box::new(LikeOp { + negated: op.negated, left: lower_left, right: lower_right, escape: op.escape, diff --git a/crates/polyglot-sql/src/dialects/tsql.rs b/crates/polyglot-sql/src/dialects/tsql.rs index ec8146c9..17334881 100644 --- a/crates/polyglot-sql/src/dialects/tsql.rs +++ b/crates/polyglot-sql/src/dialects/tsql.rs @@ -343,8 +343,9 @@ impl DialectImpl for TSQLDialect { // SQL Server is case-insensitive by default based on collation // But for explicit case-insensitive matching, use LOWER let lower_left = Expression::Lower(Box::new(UnaryFunc::new(op.left))); - let lower_right = Expression::Lower(Box::new(UnaryFunc::new(op.right))); + let lower_right = super::lower_like_pattern(op.right, op.quantifier.is_some()); Ok(Expression::Like(Box::new(LikeOp { + negated: op.negated, left: lower_left, right: lower_right, escape: op.escape, diff --git a/crates/polyglot-sql/src/expressions.rs b/crates/polyglot-sql/src/expressions.rs index bebf9786..ab024b71 100644 --- a/crates/polyglot-sql/src/expressions.rs +++ b/crates/polyglot-sql/src/expressions.rs @@ -4727,6 +4727,10 @@ impl BinaryOp { #[derive(polyglot_sql_ast_derive::AstNode, Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "bindings", derive(TS))] pub struct LikeOp { + /// Infix NOT LIKE negates each comparison, before ANY/ALL reduction. + /// Prefix NOT is represented separately as Expression::Not(Like(...)). + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub negated: bool, pub left: Expression, pub right: Expression, /// ESCAPE character/expression @@ -4744,6 +4748,7 @@ pub struct LikeOp { impl LikeOp { pub fn new(left: Expression, right: Expression) -> Self { Self { + negated: false, left, right, escape: None, @@ -4754,6 +4759,7 @@ impl LikeOp { pub fn with_escape(left: Expression, right: Expression, escape: Expression) -> Self { Self { + negated: false, left, right, escape: Some(escape), diff --git a/crates/polyglot-sql/src/generator.rs b/crates/polyglot-sql/src/generator.rs index 049aad21..357aad8d 100644 --- a/crates/polyglot-sql/src/generator.rs +++ b/crates/polyglot-sql/src/generator.rs @@ -3382,11 +3382,7 @@ impl Generator { Expression::JSONBDeleteAtPath(op) => self.generate_binary_op(op, "#-"), Expression::ExtendsLeft(op) => self.generate_binary_op(op, "&<"), Expression::ExtendsRight(op) => self.generate_binary_op(op, "&>"), - Expression::Not(op) => match &op.this { - Expression::Like(like) => self.generate_like_op_negated(like, "LIKE"), - Expression::ILike(like) => self.generate_like_op_negated(like, "ILIKE"), - _ => self.generate_unary_op(op, "NOT"), - }, + Expression::Not(op) => self.generate_unary_op(op, "NOT"), Expression::Neg(op) => self.generate_unary_op(op, "-"), Expression::BitwiseNot(op) => { // Presto/Trino use BITWISE_NOT function @@ -23869,7 +23865,8 @@ impl Generator { ) -> Result<()> { let wrap_left = infix_operator.is_some_and(|parent| { Self::infix_operand_needs_parentheses(parent, &op.left, OperandSide::Left) - }); + }) || (matches!(&op.left, Expression::Not(_)) + && !matches!(operator, "AND" | "OR" | "XOR")); if wrap_left { self.write("("); } @@ -23947,7 +23944,8 @@ impl Generator { self.write_space(); let wrap_right = infix_operator.is_some_and(|parent| { Self::infix_operand_needs_parentheses(parent, &op.right, OperandSide::Right) - }); + }) || (matches!(&op.right, Expression::Not(_)) + && !matches!(operator, "AND" | "OR" | "XOR")); if wrap_right { self.write("("); } @@ -24165,28 +24163,41 @@ impl Generator { /// Generate LIKE/ILIKE operation with optional ESCAPE clause fn generate_like_op(&mut self, op: &LikeOp, operator: &str) -> Result<()> { - self.generate_like_op_inner(op, operator, false) - } - - fn generate_like_op_negated(&mut self, op: &LikeOp, operator: &str) -> Result<()> { - self.generate_like_op_inner(op, operator, true) + self.generate_like_op_inner(op, operator, op.negated) } fn generate_like_op_inner(&mut self, op: &LikeOp, operator: &str, negated: bool) -> Result<()> { + // DataFusion's established unquantified spelling is prefix NOT. This + // equivalence must never be applied before an ANY/ALL reduction. if negated - && matches!( - self.config.dialect, - Some(DialectType::ClickHouse) - | Some(DialectType::DataFusion) - | Some(DialectType::TSQL) - | Some(DialectType::Fabric) - ) + && op.quantifier.is_none() + && self.config.dialect == Some(DialectType::DataFusion) { self.write_keyword("NOT"); self.write_space(); return self.generate_like_op_inner(op, operator, false); } - + // Snowflake does not accept infix NOT LIKE ANY/ALL. Negating every + // comparison is equivalent to negating the dual positive quantifier. + if negated && self.config.dialect == Some(DialectType::Snowflake) { + let dual = match op.quantifier.as_deref() { + Some(q) if q.eq_ignore_ascii_case("ALL") => Some("ANY"), + Some(q) if q.eq_ignore_ascii_case("ANY") || q.eq_ignore_ascii_case("SOME") => { + Some("ALL") + } + _ => None, + }; + if let Some(dual) = dual { + let mut positive = op.clone(); + positive.negated = false; + positive.quantifier = Some(dual.into()); + self.write_keyword("NOT"); + self.write(" ("); + self.generate_like_op_inner(&positive, operator, false)?; + self.write(")"); + return Ok(()); + } + } if matches!(self.config.dialect, Some(DialectType::ClickHouse)) { if let Expression::Star(star) = &op.left { if star @@ -24304,7 +24315,8 @@ impl Generator { ) -> Result<()> { let wrap_left = infix_operator.is_some_and(|parent| { Self::infix_operand_needs_parentheses(parent, &op.left, OperandSide::Left) - }); + }) || (matches!(&op.left, Expression::Not(_)) + && !matches!(operator, "AND" | "OR" | "XOR")); if wrap_left { self.write("("); } @@ -24361,7 +24373,8 @@ impl Generator { self.write_space(); let wrap_right = infix_operator.is_some_and(|parent| { Self::infix_operand_needs_parentheses(parent, &op.right, OperandSide::Right) - }); + }) || (matches!(&op.right, Expression::Not(_)) + && !matches!(operator, "AND" | "OR" | "XOR")); if wrap_right { self.write("("); } @@ -24401,7 +24414,20 @@ impl Generator { self.write_space(); } } - self.generate_expression(&op.this) + let wrap = operator == "NOT" + && (matches!( + &op.this, + Expression::And(_) | Expression::Or(_) | Expression::Xor(_) + ) || (self.config.dialect == Some(DialectType::ClickHouse) + && matches!(&op.this, Expression::Like(like) | Expression::ILike(like) if like.quantifier.is_some()))); + if wrap { + self.write("("); + } + self.generate_expression(&op.this)?; + if wrap { + self.write(")"); + } + Ok(()) } fn generate_in(&mut self, in_expr: &In) -> Result<()> { diff --git a/crates/polyglot-sql/src/parser.rs b/crates/polyglot-sql/src/parser.rs index fdc73b08..9af1ab4e 100644 --- a/crates/polyglot-sql/src/parser.rs +++ b/crates/polyglot-sql/src/parser.rs @@ -29249,12 +29249,7 @@ impl Parser { /// Parse NOT expressions fn parse_not(&mut self) -> Result { - if !self.check(TokenType::Not) - || matches!( - self.config.dialect, - Some(crate::dialects::DialectType::ClickHouse) - ) - { + if !self.check(TokenType::Not) { return self.parse_comparison(); } @@ -29271,21 +29266,11 @@ impl Parser { } let _scope = self.enter_parser_depth(starts.len())?; let mut expr = self.parse_comparison()?; - for raw_start in starts.into_iter().rev() { - let preserve_typed_not_like = matches!( - self.config.dialect, - Some(crate::dialects::DialectType::TSQL) - | Some(crate::dialects::DialectType::Fabric) - ); - if matches!(expr, Expression::Like(_) | Expression::ILike(_)) - && !preserve_typed_not_like - { - expr = Expression::Raw(Raw { - sql: self.tokens_to_sql(raw_start, self.current), - }); - } else { - expr = Expression::Not(Box::new(UnaryOp::new(expr))); - } + for _ in starts { + // Comparisons (including quantified LIKE and ESCAPE) bind before + // prefix NOT. Keep their typed AST rather than hiding the predicate + // from validation, lineage and downstream expression visitors. + expr = Expression::Not(Box::new(UnaryOp::new(expr))); } Ok(expr) } @@ -29645,6 +29630,7 @@ impl Parser { None }; Expression::Like(Box::new(LikeOp { + negated: false, left, right, escape, @@ -29669,6 +29655,7 @@ impl Parser { None }; Expression::ILike(Box::new(LikeOp { + negated: false, left, right, escape, @@ -29736,13 +29723,14 @@ impl Parser { None }; let like_expr = Expression::Like(Box::new(LikeOp { + negated: true, left, right, escape, quantifier: None, inferred_type: None, })); - Expression::Not(Box::new(UnaryOp::new(like_expr))) + like_expr } else if self.match_token(TokenType::NotILike) { // PostgreSQL !~~* (NOT ILIKE) operator let right = self.parse_bitwise_or()?; @@ -29752,13 +29740,14 @@ impl Parser { None }; let ilike_expr = Expression::ILike(Box::new(LikeOp { + negated: true, left, right, escape, quantifier: None, inferred_type: None, })); - Expression::Not(Box::new(UnaryOp::new(ilike_expr))) + ilike_expr } else if self.match_token(TokenType::NotRLike) { // PostgreSQL !~ (NOT regexp match) operator let right = self.parse_bitwise_or()?; @@ -29998,13 +29987,14 @@ impl Parser { None }; let like_expr = Expression::Like(Box::new(LikeOp { + negated: true, left, right, escape, quantifier, inferred_type: None, })); - Expression::Not(Box::new(UnaryOp::new(like_expr))) + like_expr } else if self.match_token(TokenType::ILike) { let quantifier = if self.match_token(TokenType::Any) { Some("ANY".to_string()) @@ -30022,13 +30012,14 @@ impl Parser { None }; let ilike_expr = Expression::ILike(Box::new(LikeOp { + negated: true, left, right, escape, quantifier, inferred_type: None, })); - Expression::Not(Box::new(UnaryOp::new(ilike_expr))) + ilike_expr } else if self.check_identifier("SIMILAR") && self.check_next(TokenType::To) { // NOT SIMILAR TO self.skip(); // consume SIMILAR @@ -33196,6 +33187,7 @@ impl Parser { let pattern = self.parse_expression()?; // Create an ILike expression with Star as left side Expression::ILike(Box::new(LikeOp { + negated: false, left: Expression::Star(Star { table: None, except: None, @@ -58693,20 +58685,14 @@ impl Parser { .ok_or_else(|| self.parse_error("Expected expression after LIKE"))?; let escape = self.parse_escape()?; let like = Expression::Like(Box::new(LikeOp { + negated: negate, left, right, escape, quantifier: None, inferred_type: None, })); - this = if negate { - Some(Expression::Not(Box::new(UnaryOp { - this: like, - inferred_type: None, - }))) - } else { - Some(like) - }; + this = Some(like); return Ok(this); } @@ -58718,20 +58704,14 @@ impl Parser { .ok_or_else(|| self.parse_error("Expected expression after ILIKE"))?; let escape = self.parse_escape()?; let ilike = Expression::ILike(Box::new(LikeOp { + negated: negate, left, right, escape, quantifier: None, inferred_type: None, })); - this = if negate { - Some(Expression::Not(Box::new(UnaryOp { - this: ilike, - inferred_type: None, - }))) - } else { - Some(ilike) - }; + this = Some(ilike); return Ok(this); } @@ -60437,6 +60417,7 @@ impl Parser { span: None, }); let like = LikeOp { + negated: false, left, right: pattern, escape: None, diff --git a/crates/polyglot-sql/src/transforms.rs b/crates/polyglot-sql/src/transforms.rs index 1ffb8489..91c1f45d 100644 --- a/crates/polyglot-sql/src/transforms.rs +++ b/crates/polyglot-sql/src/transforms.rs @@ -3760,19 +3760,24 @@ pub fn no_ilike_sql(expr: Expression) -> Result { inferred_type: None, })); - let lower_right = Expression::Function(Box::new(crate::expressions::Function { - name: "LOWER".to_string(), - args: vec![ilike.right], - distinct: false, - trailing_comments: vec![], - use_bracket_syntax: false, - no_parens: false, - quoted: false, - span: None, - inferred_type: None, - })); + let lower_right = if ilike.quantifier.is_some() { + crate::dialects::lower_like_pattern(ilike.right, true) + } else { + Expression::Function(Box::new(crate::expressions::Function { + name: "LOWER".to_string(), + args: vec![ilike.right], + distinct: false, + trailing_comments: vec![], + use_bracket_syntax: false, + no_parens: false, + quoted: false, + span: None, + inferred_type: None, + })) + }; Ok(Expression::Like(Box::new(crate::expressions::LikeOp { + negated: ilike.negated, left: lower_left, right: lower_right, escape: ilike.escape, @@ -6030,6 +6035,8 @@ pub fn expand_like_any(expr: Expression) -> Result { let inner = unwrap_parens(e); match inner { Expression::Tuple(t) => Some(t.expressions.clone()), + Expression::Array(a) => Some(a.expressions.clone()), + Expression::ArrayFunc(a) => Some(a.expressions.clone()), // Single value in parens: treat as single-element list _ if !matches!(e, Expression::Tuple(_)) => Some(vec![inner.clone()]), _ => None, @@ -6049,6 +6056,7 @@ pub fn expand_like_any(expr: Expression) -> Result { for val in values { let like = if is_ilike { Expression::ILike(Box::new(LikeOp { + negated: op.negated, left: op.left.clone(), right: val, escape: op.escape.clone(), @@ -6057,6 +6065,7 @@ pub fn expand_like_any(expr: Expression) -> Result { })) } else { Expression::Like(Box::new(LikeOp { + negated: op.negated, left: op.left.clone(), right: val, escape: op.escape.clone(), @@ -6105,7 +6114,9 @@ pub fn expand_like_any(expr: Expression) -> Result { let result = transform_recursive(expr, &|e| { match e { // LIKE ANY -> OR chain (bare) - Expression::Like(ref op) if op.quantifier.as_deref() == Some("ANY") => { + Expression::Like(ref op) + if matches!(op.quantifier.as_deref(), Some("ANY" | "SOME")) => + { if let Some(values) = extract_tuple_values(&op.right) { if values.is_empty() { return Ok(e); @@ -6139,7 +6150,9 @@ pub fn expand_like_any(expr: Expression) -> Result { } } // ILIKE ANY -> OR chain (bare) - Expression::ILike(ref op) if op.quantifier.as_deref() == Some("ANY") => { + Expression::ILike(ref op) + if matches!(op.quantifier.as_deref(), Some("ANY" | "SOME")) => + { if let Some(values) = extract_tuple_values(&op.right) { if values.is_empty() { return Ok(e); @@ -6806,6 +6819,7 @@ mod tests { // Test ILIKE conversion to LOWER+LIKE let ilike_expr = Expression::ILike(Box::new(LikeOp { + negated: false, left: Expression::boxed_column(Column { name: Identifier::new("name".to_string()), table: None, diff --git a/crates/polyglot-sql/src/traversal.rs b/crates/polyglot-sql/src/traversal.rs index 3db9fd71..8caac42c 100644 --- a/crates/polyglot-sql/src/traversal.rs +++ b/crates/polyglot-sql/src/traversal.rs @@ -1307,6 +1307,7 @@ mod tests { #[test] fn test_traversal_covers_previously_omitted_expression_fields() { let like = Expression::Like(Box::new(LikeOp { + negated: false, left: make_column("name"), right: Expression::Literal(Box::new(Literal::String("x%".to_string()))), escape: Some(Expression::Literal(Box::new(Literal::String( diff --git a/crates/polyglot-sql/tests/fabric_regression.rs b/crates/polyglot-sql/tests/fabric_regression.rs index 62bba6c3..838b1197 100644 --- a/crates/polyglot-sql/tests/fabric_regression.rs +++ b/crates/polyglot-sql/tests/fabric_regression.rs @@ -416,7 +416,7 @@ fn postgres_bytea_hex_literals_map_to_fabric_binary_literals() { #[test] fn issue_388_fabric_negated_like_preserves_typed_operands_on_roundtrip() { let source = r"SELECT '\x616263'::bytea NOT LIKE '\x5f625f'::bytea"; - let expected = "SELECT CAST(CASE WHEN NOT 0x616263 LIKE 0x5f625f THEN 1 WHEN NOT NOT 0x616263 LIKE 0x5f625f THEN 0 ELSE NULL END AS BIT)"; + let expected = "SELECT CAST(CASE WHEN 0x616263 NOT LIKE 0x5f625f THEN 1 WHEN NOT 0x616263 NOT LIKE 0x5f625f THEN 0 ELSE NULL END AS BIT)"; let first_pass = pg_to_fabric_strict(source); assert_eq!(first_pass, expected); diff --git a/crates/polyglot-sql/tests/prefix_not_regression.rs b/crates/polyglot-sql/tests/prefix_not_regression.rs new file mode 100644 index 00000000..a86af711 --- /dev/null +++ b/crates/polyglot-sql/tests/prefix_not_regression.rs @@ -0,0 +1,469 @@ +use polyglot_sql::traversal::ExpressionWalk; +use polyglot_sql::{format, generate, parse_one, DialectType, Expression}; + +fn assert_typed(query: &Expression) { + assert!( + !query + .dfs() + .any(|node| matches!(node, Expression::Raw(_) | Expression::Command(_))), + "opaque predicate: {query:?}" + ); +} + +fn predicate(query: &Expression) -> &Expression { + let Expression::Select(select) = query else { + panic!("expected SELECT") + }; + &select.where_clause.as_ref().unwrap().this +} + +fn stable(sql: &str, dialect: DialectType) -> Expression { + let parsed = + parse_one(sql, dialect).unwrap_or_else(|error| panic!("{dialect:?}: {sql}: {error}")); + assert_typed(&parsed); + let generated = generate(&parsed, dialect).unwrap(); + let reparsed = parse_one(&generated, dialect) + .unwrap_or_else(|error| panic!("{dialect:?}: {generated}: {error}")); + assert_typed(&reparsed); + assert_eq!( + generate(&reparsed, dialect).unwrap(), + generated, + "unstable: {dialect:?}: {sql}" + ); + let formatted = format(&generated, dialect).unwrap().remove(0); + let formatted_ast = parse_one(&formatted, dialect).unwrap(); + assert_typed(&formatted_ast); + assert_eq!( + generate(&formatted_ast, dialect).unwrap(), + generated, + "formatter changed predicate: {dialect:?}: {sql}" + ); + parsed +} + +#[test] +fn prefix_not_like_is_typed_across_dialects() { + for dialect in [ + DialectType::Generic, + DialectType::Snowflake, + DialectType::DuckDB, + DialectType::PostgreSQL, + DialectType::SQLite, + DialectType::MySQL, + DialectType::TSQL, + DialectType::Fabric, + DialectType::BigQuery, + DialectType::Spark, + DialectType::Databricks, + DialectType::Trino, + DialectType::Redshift, + DialectType::Oracle, + ] { + for operand in ["x", "SPLIT_PART('a:b', ':', 2)", "UPPER(x)"] { + for operator in ["LIKE", "ILIKE"] { + let sql = format!("SELECT 1 WHERE NOT {operand} {operator} 'b%' ESCAPE '#'"); + let parsed = stable(&sql, dialect); + let Expression::Not(not) = predicate(&parsed) else { + panic!("NOT must enclose LIKE: {sql}") + }; + assert!(matches!( + ¬.this, + Expression::Like(_) | Expression::ILike(_) + )); + } + } + } +} + +#[test] +fn prefix_not_quantified_like_and_boolean_precedence() { + for dialect in [ + DialectType::Generic, + DialectType::Snowflake, + DialectType::DuckDB, + DialectType::PostgreSQL, + ] { + for operator in ["LIKE", "ILIKE"] { + for quantifier in ["ANY", "ALL", "SOME"] { + let sql = format!( + "SELECT 1 WHERE NOT UPPER(x) {operator} {quantifier} ('a%', 'b%') ESCAPE '#'" + ); + let parsed = stable(&sql, dialect); + let Expression::Not(not) = predicate(&parsed) else { + panic!("{sql}") + }; + let like = match ¬.this { + Expression::Like(like) | Expression::ILike(like) => like, + _ => panic!("{sql}"), + }; + assert_eq!(like.quantifier.as_deref(), Some(quantifier)); + assert!(like.escape.is_some()); + } + } + let parsed = stable("SELECT 1 WHERE NOT x LIKE 'a%' AND y = 1 OR z = 2", dialect); + let Expression::Or(or) = predicate(&parsed) else { + panic!() + }; + let Expression::And(and) = &or.left else { + panic!() + }; + assert!( + matches!(&and.left, Expression::Not(not) if matches!(¬.this, Expression::Like(_))) + ); + let parsed = stable("SELECT 1 WHERE NOT NOT x LIKE 'a%'", dialect); + assert!( + matches!(predicate(&parsed), Expression::Not(a) if matches!(&a.this, Expression::Not(b) if matches!(&b.this, Expression::Like(_)))) + ); + stable("SELECT 1 WHERE NOT x NOT LIKE 'a%'", dialect); + stable("SELECT 1 WHERE NOT (x LIKE 'a%' OR y ILIKE 'b%')", dialect); + stable( + "SELECT 1 WHERE NOT UPPER(x) /* pattern */ LIKE 'a%'", + dialect, + ); + } +} + +#[test] +fn prefix_not_other_predicates_remain_structured() { + for (dialect, predicates) in [ + ( + DialectType::DuckDB, + vec![ + "x SIMILAR TO 'a.*'", + "x GLOB 'a*'", + "x IS DISTINCT FROM y", + "EXISTS (SELECT 1)", + "x IN (SELECT y FROM orders)", + "x BETWEEN 1 AND 2", + "x IS NULL", + "x = y", + ], + ), + ( + DialectType::PostgreSQL, + vec![ + "x SIMILAR TO 'a%' ESCAPE '#'", + "x IS DISTINCT FROM y", + "EXISTS (SELECT 1)", + "x IN (SELECT y FROM orders)", + ], + ), + ( + DialectType::Snowflake, + vec![ + "x RLIKE 'a.*'", + "x REGEXP 'a.*'", + "x IS DISTINCT FROM y", + "EXISTS (SELECT 1)", + "x IN (SELECT y FROM orders)", + ], + ), + ( + DialectType::SQLite, + vec![ + "x GLOB 'a*'", + "x MATCH 'orders'", + "x REGEXP 'a.*'", + "x IS DISTINCT FROM y", + "EXISTS (SELECT 1)", + "x IN (SELECT y FROM orders)", + ], + ), + ] { + for expression in predicates { + let sql = format!("SELECT 1 WHERE NOT {expression}"); + let parsed = stable(&sql, dialect); + assert!( + matches!(predicate(&parsed), Expression::Not(_)), + "{sql}: {parsed:?}" + ); + } + } +} + +#[test] +fn clickhouse_prefix_not_encloses_the_predicate() { + let parsed = stable("SELECT 1 WHERE NOT x LIKE 'b%'", DialectType::ClickHouse); + assert!( + matches!(predicate(&parsed), Expression::Not(not) if matches!(¬.this, Expression::Like(_))) + ); + let parsed = stable("SELECT 1 WHERE NOT (x LIKE 'b%')", DialectType::ClickHouse); + assert!(matches!(predicate(&parsed), Expression::Not(_))); + // ClickHouse's operator table gives NOT priority 5 and comparisons 9. + // Engine control: NOT 2 = 1 -> 1, while (NOT 2) = 1 -> 0. + let parsed = stable("SELECT 1 WHERE NOT 2 = 1", DialectType::ClickHouse); + assert!( + matches!(predicate(&parsed), Expression::Not(not) if matches!(¬.this, Expression::Eq(_))) + ); +} + +#[test] +fn clickhouse_not_like_function_normalization_is_idempotent() { + for sql in [ + "SELECT notLike(status, 'a%') FROM orders", + "SELECT sum(notLike(status, 'a%') != notLike(upper(status), 'a%')) FROM orders", + "SELECT throwIf(notLike(status, 'a%'), 'invalid status') FROM orders", + "SELECT sum(throwIf(notLike(status, 'a%'), 'invalid status')) FROM orders", + ] { + let first = + polyglot_sql::transpile(sql, DialectType::ClickHouse, DialectType::ClickHouse).unwrap(); + let second = + polyglot_sql::transpile(&first[0], DialectType::ClickHouse, DialectType::ClickHouse) + .unwrap(); + assert_eq!(first, second, "{sql}"); + stable(&first[0], DialectType::ClickHouse); + } + let sql = "SELECT notLike(status, 'a%') != notLike(upper(status), 'a%') FROM orders"; + let generated = polyglot_sql::transpile(sql, DialectType::ClickHouse, DialectType::ClickHouse) + .unwrap() + .remove(0); + assert_eq!( + generated, + "SELECT (NOT status LIKE 'a%') <> (NOT UPPER(status) LIKE 'a%') FROM orders" + ); +} + +#[test] +fn prefix_not_like_exposes_columns_to_schema_validation() { + let schema = serde_json::from_value(serde_json::json!({"tables":[{"name":"orders","columns":[{"name":"status","type":"VARCHAR"}]}],"strict":true})).unwrap(); + let options = polyglot_sql::SchemaValidationOptions { + semantic: true, + check_types: true, + check_references: true, + ..Default::default() + }; + for dialect in [DialectType::Snowflake, DialectType::DuckDB] { + for (column, valid) in [("status", true), ("missing", false)] { + let sql = format!("SELECT status FROM orders WHERE NOT {column} LIKE 'b%'"); + let result = polyglot_sql::validate_with_schema(&sql, dialect, &schema, &options); + assert_eq!(result.valid, valid, "{sql}: {:?}", result.errors); + if !valid { + assert!(result.errors.iter().any(|error| error.code == "E201")); + } + } + } +} + +#[test] +fn quantified_prefix_and_infix_negation_are_distinct() { + for dialect in [ + DialectType::Generic, + DialectType::PostgreSQL, + DialectType::ClickHouse, + DialectType::TSQL, + ] { + for operator in ["LIKE", "ILIKE"] { + for quantifier in ["", " ANY", " ALL", " SOME"] { + let rhs = if quantifier.is_empty() { + "'a%'" + } else { + "('a%', 'x%')" + }; + let prefix = if dialect == DialectType::ClickHouse { + format!("SELECT 1 WHERE NOT ('abc' {operator}{quantifier} {rhs})") + } else { + format!("SELECT 1 WHERE NOT 'abc' {operator}{quantifier} {rhs}") + }; + let infix = format!("SELECT 1 WHERE 'abc' NOT {operator}{quantifier} {rhs}"); + let a = stable(&prefix, dialect); + let b = stable(&infix, dialect); + assert!(matches!(predicate(&a), Expression::Not(_))); + assert!( + matches!(predicate(&b), Expression::Like(op) | Expression::ILike(op) if op.negated) + ); + assert_ne!( + generate(&a, dialect).unwrap(), + generate(&b, dialect).unwrap() + ); + let b = parse_one(&generate(&b, dialect).unwrap(), dialect).unwrap(); + assert!( + matches!(predicate(&b), Expression::Like(op) | Expression::ILike(op) if op.negated) + ); + } + } + } + let sql = "SELECT NOT 'testa 1' LIKE ALL (ARRAY['testa%', 'testb%'])"; + assert_eq!( + generate( + &parse_one(sql, DialectType::PostgreSQL).unwrap(), + DialectType::PostgreSQL + ) + .unwrap(), + sql + ); +} + +#[test] +fn datafusion_only_canonicalizes_unquantified_infix_not_like() { + for operator in ["LIKE", "ILIKE"] { + let sql = format!("SELECT * FROM orders WHERE status NOT {operator} '%foo%'"); + let parsed = parse_one(&sql, DialectType::DataFusion).unwrap(); + assert_eq!( + generate(&parsed, DialectType::DataFusion).unwrap(), + format!("SELECT * FROM orders WHERE NOT status {operator} '%foo%'") + ); + for quantifier in ["ANY", "ALL", "SOME"] { + let sql = format!("SELECT 1 WHERE status NOT {operator} {quantifier} ('a%', 'x%')"); + let parsed = stable(&sql, DialectType::DataFusion); + assert!(generate(&parsed, DialectType::DataFusion) + .unwrap() + .contains(&format!("status NOT {operator} {quantifier}"))); + } + } +} + +#[test] +fn snowflake_quantified_infix_negation_uses_the_dual_prefix() { + for operator in ["LIKE", "ILIKE"] { + for (quantifier, dual) in [("ALL", "ANY"), ("ANY", "ALL"), ("SOME", "ALL")] { + let sql = format!("SELECT 'abc' NOT {operator} {quantifier} ('a%', 'x%') ESCAPE '#'"); + let parsed = parse_one(&sql, DialectType::Generic).unwrap(); + let expected = format!("SELECT NOT ('abc' {operator} {dual} ('a%', 'x%') ESCAPE '#')"); + assert_eq!(generate(&parsed, DialectType::Snowflake).unwrap(), expected); + assert_eq!( + polyglot_sql::transpile(&sql, DialectType::Generic, DialectType::Snowflake) + .unwrap(), + vec![expected.clone()] + ); + stable(&expected, DialectType::Snowflake); + } + } + for quantifier in ["ALL", "ANY"] { + let sql = format!("SELECT NOT 'abc' LIKE {quantifier} ('a%', 'x%')"); + assert_eq!( + generate( + &parse_one(&sql, DialectType::Snowflake).unwrap(), + DialectType::Snowflake + ) + .unwrap(), + sql + ); + } +} + +#[test] +fn clickhouse_quantified_prefix_not_requires_parentheses() { + for operator in ["LIKE", "ILIKE"] { + for quantifier in ["ANY", "ALL", "SOME"] { + let sql = format!("SELECT 1 WHERE NOT 'abc' {operator} {quantifier} ('a%', 'x%')"); + let parsed = parse_one(&sql, DialectType::Generic).unwrap(); + let expected = + format!("SELECT 1 WHERE NOT ('abc' {operator} {quantifier} ('a%', 'x%'))"); + assert_eq!( + generate(&parsed, DialectType::ClickHouse).unwrap(), + expected + ); + let reparsed = stable(&expected, DialectType::ClickHouse); + assert!(matches!(predicate(&reparsed), Expression::Not(_))); + } + } +} + +#[test] +fn quantified_negation_duckdb_execution() { + use std::io::Write; + use std::process::{Command, Stdio}; + let mut cases = Vec::new(); + for operator in ["LIKE", "ILIKE"] { + for quantifier in ["ANY", "ALL", "SOME"] { + let all = quantifier == "ALL"; + for prefix in [false, true] { + for array in [false, true] { + let rhs = if array { + "(ARRAY['a%', 'x%'])" + } else { + "('a%', 'x%')" + }; + let source = if array { + DialectType::PostgreSQL + } else if !prefix { + DialectType::Generic + } else { + DialectType::Snowflake + }; + let sql = if prefix { + format!("SELECT NOT 'abc' {operator} {quantifier} {rhs}") + } else { + format!("SELECT 'abc' NOT {operator} {quantifier} {rhs}") + }; + let reference = format!( + "SELECT {}('abc' {}{operator} 'a%' {} 'abc' {}{operator} 'x%')", + if prefix { "NOT " } else { "" }, + if prefix { "" } else { "NOT " }, + if all { "AND" } else { "OR" }, + if prefix { "" } else { "NOT " } + ); + let generated = polyglot_sql::transpile(&sql, source, DialectType::DuckDB) + .unwrap() + .remove(0); + let pretty = format(&generated, DialectType::DuckDB).unwrap().remove(0); + stable(&generated, DialectType::DuckDB); + let ast = parse_one(&generated, DialectType::DuckDB).unwrap(); + assert_typed(&ast); + // A prefix reduction must not become per-pattern negation. + let Expression::Select(select) = &ast else { + panic!() + }; + assert_eq!( + matches!(&select.expressions[0], Expression::Not(_)), + prefix, + "{sql} -> {generated}" + ); + cases.push(serde_json::json!({"source":sql,"reference":reference,"generated":generated,"pretty":pretty,"expected":if prefix { all } else { !all }})); + for intermediate in [ + DialectType::PostgreSQL, + DialectType::Snowflake, + DialectType::MySQL, + ] { + let intermediate_sql = polyglot_sql::transpile(&sql, source, intermediate) + .unwrap() + .remove(0); + let generated = polyglot_sql::transpile( + &intermediate_sql, + intermediate, + DialectType::DuckDB, + ) + .unwrap() + .remove(0); + let pretty = format(&generated, DialectType::DuckDB).unwrap().remove(0); + cases.push(serde_json::json!({"source":sql,"reference":reference,"generated":generated,"pretty":pretty,"expected":if prefix { all } else { !all }})); + } + } + } + } + } + // CI always exercises lowering and precedence above; opt into real engine + // execution with an interpreter containing DuckDB (no warehouse required). + let Ok(python) = std::env::var("POLYGLOT_DUCKDB_PYTHON") else { + return; + }; + let script = r#" +import json, sys, duckdb +cases = json.load(sys.stdin) +unsupported = 0 +with duckdb.connect() as con: + for case in cases: + try: + original = con.execute(case['source']).fetchone()[0] + except duckdb.ParserException: + unsupported += 1 + else: + assert original == case['expected'], (case['source'], original) + for field in ['reference', 'generated', 'pretty']: + actual = con.execute(case[field]).fetchone()[0] + assert actual == case['expected'], (case['source'], field, case[field], actual, case['expected']) +print('DuckDB', duckdb.__version__, len(cases), 'differing-meaning cases passed;', unsupported, 'native quantified forms unsupported') +"#; + let mut child = Command::new(python) + .args(["-c", script]) + .stdin(Stdio::piped()) + .spawn() + .unwrap(); + child + .stdin + .take() + .unwrap() + .write_all(serde_json::to_string(&cases).unwrap().as_bytes()) + .unwrap(); + assert!(child.wait().unwrap().success()); +} diff --git a/crates/polyglot-sql/tests/transform_regression.rs b/crates/polyglot-sql/tests/transform_regression.rs index ca0118a4..de0294ef 100644 --- a/crates/polyglot-sql/tests/transform_regression.rs +++ b/crates/polyglot-sql/tests/transform_regression.rs @@ -467,6 +467,7 @@ fn transform_recursive_rewrites_nested_cast_data_types() { #[test] fn transform_recursive_visits_generated_child_metadata() { let expression = Expression::Like(Box::new(LikeOp { + negated: false, left: Expression::column("name"), right: Expression::Literal(Box::new(Literal::String("x%".to_string()))), escape: Some(Expression::Literal(Box::new(Literal::String( diff --git a/crates/polyglot-sql/tests/tsql_regression.rs b/crates/polyglot-sql/tests/tsql_regression.rs index 923e4fb6..058d0a37 100644 --- a/crates/polyglot-sql/tests/tsql_regression.rs +++ b/crates/polyglot-sql/tests/tsql_regression.rs @@ -1380,7 +1380,7 @@ fn tsql_hex_literals_parse_and_roundtrip() { #[test] fn issue_388_tsql_negated_like_preserves_typed_operands_on_roundtrip() { let source = r"SELECT '\x616263'::bytea NOT LIKE '\x5f625f'::bytea"; - let expected = "SELECT CAST(CASE WHEN NOT 0x616263 LIKE 0x5f625f THEN 1 WHEN NOT NOT 0x616263 LIKE 0x5f625f THEN 0 ELSE NULL END AS BIT)"; + let expected = "SELECT CAST(CASE WHEN 0x616263 NOT LIKE 0x5f625f THEN 1 WHEN NOT 0x616263 NOT LIKE 0x5f625f THEN 0 ELSE NULL END AS BIT)"; let first_pass = pg_to_tsql_strict(source); assert_eq!(first_pass, expected); diff --git a/examples/rust/Cargo.lock b/examples/rust/Cargo.lock index d699cd6b..fb16826d 100644 --- a/examples/rust/Cargo.lock +++ b/examples/rust/Cargo.lock @@ -71,7 +71,7 @@ dependencies = [ [[package]] name = "polyglot-sql" -version = "0.13.2" +version = "0.13.3" dependencies = [ "polyglot-sql-ast-derive", "polyglot-sql-function-catalogs", @@ -85,7 +85,7 @@ dependencies = [ [[package]] name = "polyglot-sql-ast-derive" -version = "0.13.2" +version = "0.13.3" dependencies = [ "proc-macro2", "quote", @@ -101,7 +101,7 @@ dependencies = [ [[package]] name = "polyglot-sql-function-catalogs" -version = "0.13.2" +version = "0.13.3" [[package]] name = "proc-macro2" diff --git a/examples/rust/Cargo.toml b/examples/rust/Cargo.toml index bc174ec2..fce60de1 100644 --- a/examples/rust/Cargo.toml +++ b/examples/rust/Cargo.toml @@ -5,4 +5,4 @@ edition = "2021" publish = false [dependencies] -polyglot-sql = { version = "0.13.2", path = "../../crates/polyglot-sql" } +polyglot-sql = { version = "0.13.3", path = "../../crates/polyglot-sql" } diff --git a/packages/documentation/package.json b/packages/documentation/package.json index 40467b02..63a3c413 100644 --- a/packages/documentation/package.json +++ b/packages/documentation/package.json @@ -1,6 +1,6 @@ { "name": "@polyglot-sql/documentation", - "version": "0.13.2", + "version": "0.13.3", "private": true, "type": "module", "scripts": { diff --git a/packages/go/README.md b/packages/go/README.md index a236f9e2..5346f093 100644 --- a/packages/go/README.md +++ b/packages/go/README.md @@ -21,7 +21,7 @@ go get github.com/tobilg/polyglot/packages/go ``` Go module releases use nested tags that match the root Polyglot release, for -example `packages/go/v0.13.2`. +example `packages/go/v0.13.3`. ## Native Library Setup diff --git a/packages/go/types.go b/packages/go/types.go index 1c56de0c..c0cc222c 100644 --- a/packages/go/types.go +++ b/packages/go/types.go @@ -2,7 +2,7 @@ package polyglot import "encoding/json" -const sdkVersion = "0.13.2" +const sdkVersion = "0.13.3" func Version() string { return sdkVersion diff --git a/packages/playground/package.json b/packages/playground/package.json index 4d01562d..4ccbe990 100644 --- a/packages/playground/package.json +++ b/packages/playground/package.json @@ -1,6 +1,6 @@ { "name": "@polyglot-sql/playground", - "version": "0.13.2", + "version": "0.13.3", "private": true, "type": "module", "scripts": { diff --git a/packages/python-docs/package.json b/packages/python-docs/package.json index 73b7f670..0ab7ccb0 100644 --- a/packages/python-docs/package.json +++ b/packages/python-docs/package.json @@ -1,6 +1,6 @@ { "name": "@polyglot-sql/python-docs", - "version": "0.13.2", + "version": "0.13.3", "private": true, "type": "module", "scripts": { diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 41628dc2..bfbf5a35 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@polyglot-sql/sdk", - "version": "0.13.2", + "version": "0.13.3", "description": "SQL dialect translator powered by WebAssembly", "type": "module", "main": "./dist/index.cjs",