From e871c735048e51a03ef4e36c21e59f5fcc5abe1b Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Mon, 26 Oct 2026 15:06:36 -0700 Subject: [PATCH] Fix where_not_null adding a stray binding The qmark branch of BaseGrammar.process_wheres skipped the binding for NULL clauses but not NOT NULL ones, so where_not_null/or_where_not_null appended a None with no placeholder and shifted every later binding. Co-Authored-By: Claude Opus 5.5 --- .../masoniteorm/query/grammars/BaseGrammar.py | 7 +- .../test_null_where_bindings_grammar.py | 80 +++++++++++++++++++ .../test_sqlite_null_where_bindings.py | 46 +++++++++++ 3 files changed, 127 insertions(+), 6 deletions(-) create mode 100644 fastapi_startkit/tests/masoniteorm/query/grammars/test_null_where_bindings_grammar.py create mode 100644 fastapi_startkit/tests/masoniteorm/sqlite/builder/test_sqlite_null_where_bindings.py diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/query/grammars/BaseGrammar.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/query/grammars/BaseGrammar.py index 1f5d3c10..6bfd2fd7 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/query/grammars/BaseGrammar.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/query/grammars/BaseGrammar.py @@ -765,12 +765,7 @@ def process_wheres(self, query=None, qmark=False, strip_first_where=False): query_value = 0 elif qmark and value_type != "column": query_value = "?" - if ( - value is not True - and value_type != "value_equals" - and value_type != "NULL" - and value_type != "BETWEEN" - ): + if value_type not in ("value_equals", "NULL", "NOT NULL", "BETWEEN"): self.add_binding(value) elif value_type == "value": if qmark: diff --git a/fastapi_startkit/tests/masoniteorm/query/grammars/test_null_where_bindings_grammar.py b/fastapi_startkit/tests/masoniteorm/query/grammars/test_null_where_bindings_grammar.py new file mode 100644 index 00000000..0851dbd0 --- /dev/null +++ b/fastapi_startkit/tests/masoniteorm/query/grammars/test_null_where_bindings_grammar.py @@ -0,0 +1,80 @@ +import datetime +import unittest + +from fastapi_startkit.masoniteorm.models.builder import QueryBuilder +from fastapi_startkit.masoniteorm.query.grammars.MSSQLGrammar import MSSQLGrammar +from fastapi_startkit.masoniteorm.query.grammars.MySQLGrammar import MySQLGrammar +from fastapi_startkit.masoniteorm.query.grammars.PostgresGrammar import PostgresGrammar +from fastapi_startkit.masoniteorm.query.grammars.SQLiteGrammar import SQLiteGrammar + +GRAMMARS = { + "sqlite": SQLiteGrammar, + "mysql": MySQLGrammar, + "postgres": PostgresGrammar, + "mssql": MSSQLGrammar, +} + +NULL_METHODS = ("where_null", "where_not_null", "or_where_null", "or_where_not_null") + +SINCE = datetime.date(2026, 1, 1) + + +def qb(grammar, table="users"): + q = QueryBuilder(connection=None, grammar=grammar, processor=None) + q._table = table + return q + + +class TestNullWhereBindingsGrammar(unittest.TestCase): + def assert_bindings(self, query, expected): + sql = query.to_qmark() + bindings = list(query.get_bindings()) + self.assertEqual(sql.count("?"), len(bindings), sql) + self.assertEqual(bindings, expected) + return sql + + def test_null_clauses_followed_by_where_bind_only_the_value(self): + for method in NULL_METHODS: + for name, grammar in GRAMMARS.items(): + with self.subTest(method=method, grammar=name): + query = getattr(qb(grammar), method)("deleted_at").where("created_at", ">=", SINCE) + sql = self.assert_bindings(query, [SINCE]) + self.assertIn("IS NOT NULL" if "not" in method else "IS NULL", sql) + + def test_null_clauses_between_bound_values_keep_order(self): + for method in NULL_METHODS: + for name, grammar in GRAMMARS.items(): + with self.subTest(method=method, grammar=name): + query = getattr(qb(grammar).where("name", "Joe"), method)("deleted_at") + query.where("created_at", ">=", SINCE).where_in("id", [1, 2]).or_where("age", "<", 30) + self.assert_bindings(query, ["Joe", SINCE, 1, 2, 30]) + + def test_chained_null_clauses_bind_nothing(self): + for name, grammar in GRAMMARS.items(): + with self.subTest(grammar=name): + query = qb(grammar).where_not_null("a").where_null("b").or_where_not_null("c").or_where_null("d") + self.assert_bindings(query, []) + + def test_no_value_clauses_mixed_with_null_clauses(self): + for name, grammar in GRAMMARS.items(): + with self.subTest(grammar=name): + query = ( + qb(grammar) + .where_not_null("deleted_at") + .where_column("updated_at", ">", "created_at") + .where_raw("1 = 1") + .where("created_at", ">=", SINCE) + .where_raw("age > ?", (18,)) + ) + self.assert_bindings(query, [SINCE, 18]) + + def test_exists_subquery_with_null_clause(self): + for name, grammar in GRAMMARS.items(): + with self.subTest(grammar=name): + inner = qb(grammar, "posts").where_not_null("published_at").where("votes", ">", 5) + query = qb(grammar).where_exists(inner).where("created_at", ">=", SINCE) + self.assert_bindings(query, [5, SINCE]) + + +if __name__ == "__main__": + unittest.main() diff --git a/fastapi_startkit/tests/masoniteorm/sqlite/builder/test_sqlite_null_where_bindings.py b/fastapi_startkit/tests/masoniteorm/sqlite/builder/test_sqlite_null_where_bindings.py new file mode 100644 index 00000000..c507f981 --- /dev/null +++ b/fastapi_startkit/tests/masoniteorm/sqlite/builder/test_sqlite_null_where_bindings.py @@ -0,0 +1,46 @@ +from fastapi_startkit.masoniteorm import Model + +from ..fixtures.db import DB +from ..test_case import TestCase + + +class Ticket(Model): + __table__ = "tickets" + __timestamps__ = None + id: int + closed_at: str + created_at: str + + +class TestSqliteNullWhereBindings(TestCase): + async def asyncSetUp(self): + await super().asyncSetUp() + conn = DB.connection("default") + await conn.execute("CREATE TABLE tickets (id INTEGER PRIMARY KEY, closed_at TEXT, created_at TEXT)") + await conn.execute( + "INSERT INTO tickets (id, closed_at, created_at) VALUES " + "(1, '2026-02-01', '2026-01-15'), " + "(2, NULL, '2026-01-20'), " + "(3, '2025-12-01', '2025-11-30'), " + "(4, '2026-03-01', '2026-02-10')" + ) + + async def test_where_not_null_then_date_filter(self): + rows = ( + await Ticket.query() + .where_not_null("closed_at") + .where("created_at", ">=", "2026-01-01") + .order_by("id") + .get() + ) + self.assertEqual([t.id for t in rows], [1, 4]) + + async def test_where_null_then_date_filter(self): + rows = await Ticket.query().where_null("closed_at").where("created_at", ">=", "2026-01-01").get() + self.assertEqual([t.id for t in rows], [2]) + + async def test_or_where_not_null_then_value_filter(self): + rows = ( + await Ticket.query().where("id", 2).or_where_not_null("closed_at").where("id", "<", 4).order_by("id").get() + ) + self.assertEqual([t.id for t in rows], [1, 2, 3])