diff --git a/mindsdb/utilities/render/sqlalchemy_render.py b/mindsdb/utilities/render/sqlalchemy_render.py index b07c34296d..9342877b5e 100644 --- a/mindsdb/utilities/render/sqlalchemy_render.py +++ b/mindsdb/utilities/render/sqlalchemy_render.py @@ -284,6 +284,15 @@ def to_expression(self, t): arg1 = self.to_expression(t.args[1]) op = t.op.lower() + if ( + op in ("is", "is not") + and isinstance(t.args[1], ast.Constant) + and t.args[1].value is None + ): + # to_expression wraps constants in a label; with a labeled NULL + # operand SQLAlchemy cannot negate the predicate, so NOT (x IS NULL) + # lost its NOT. A bare sa.null() keeps is_/is_not invertible. + arg1 = sa.null() if op in ("in", "not in"): if t.args[1].parentheses: arg1 = [arg1] diff --git a/tests/unit/render/test_sqlalchemyrender.py b/tests/unit/render/test_sqlalchemyrender.py index fd01bd4b2f..fc6de031d3 100644 --- a/tests/unit/render/test_sqlalchemyrender.py +++ b/tests/unit/render/test_sqlalchemyrender.py @@ -17,6 +17,21 @@ class TestMysqlRender: + def test_not_is_null(self): + # NOT must survive rendering of negated IS [NOT] NULL predicates + # (regression: a labeled NULL operand made the inversion a no-op) + cases = { + "SELECT * FROM tb1 WHERE NOT (x IS NULL)": "SELECT * FROM tb1 WHERE x IS NOT NULL", + "SELECT * FROM tb1 WHERE NOT x IS NULL": "SELECT * FROM tb1 WHERE x IS NOT NULL", + "SELECT * FROM tb1 WHERE NOT (x IS NOT NULL)": "SELECT * FROM tb1 WHERE x IS NULL", + "SELECT * FROM tb1 WHERE x IS NULL": "SELECT * FROM tb1 WHERE x IS NULL", + "SELECT * FROM tb1 WHERE x IS NOT NULL": "SELECT * FROM tb1 WHERE x IS NOT NULL", + } + for sql_in, expected in cases.items(): + query = parse_sql(sql_in) + sql = SqlalchemyRender("mysql").get_string(query, with_failback=False) + assert sql.replace("\n", "").replace("\t", "") == expected, sql_in + def test_create_table(self): query = CreateTable( name="tbl1",