From 62ecc6d4ee773172e4680d23fc946070aea2d7be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Knut=20Olav=20L=C3=B8ite?= Date: Mon, 9 Nov 2026 17:15:34 +0100 Subject: [PATCH] fix(spanner-sqlalchemy): add WHERE 1=1 to unqualified DELETE and UPDATE Cloud Spanner requires a WHERE clause for all DELETE and UPDATE statements to prevent accidental full-table modifications. Previously, unqualified statements relied on the low-level DB-API driver to rewrite SQL strings and append "WHERE 1=1". The dialect now generates this clause directly during query compilation. This ensures statements with THEN RETURN clauses have valid syntax and prepares the library for removing automatic SQL rewriting from the DB-API driver. --- .../sqlalchemy_spanner/sqlalchemy_spanner.py | 34 +++- .../tests/unit/test_dialect.py | 149 ++++++++++++++++++ 2 files changed, 180 insertions(+), 3 deletions(-) diff --git a/packages/sqlalchemy-spanner/google/cloud/sqlalchemy_spanner/sqlalchemy_spanner.py b/packages/sqlalchemy-spanner/google/cloud/sqlalchemy_spanner/sqlalchemy_spanner.py index 70666366fe80..203597a060f7 100644 --- a/packages/sqlalchemy-spanner/google/cloud/sqlalchemy_spanner/sqlalchemy_spanner.py +++ b/packages/sqlalchemy-spanner/google/cloud/sqlalchemy_spanner/sqlalchemy_spanner.py @@ -394,6 +394,34 @@ def __init__(self, *args, **kwargs): self.tablealiases = {} super().__init__(*args, **kwargs) + def _ensure_where_clause(self, statement): + where_criteria = getattr(statement, "_where_criteria", None) + where_clause = getattr(statement, "whereclause", None) + has_where = bool(where_criteria) or (where_clause is not None) + if not has_where: + return statement.where(expression.literal_column("1=1")) + return statement + + def visit_delete(self, delete_stmt, **kw): + """Compile a DELETE statement. + + Cloud Spanner requires a WHERE clause for all DELETE statements. + For SQLAlchemy Core/ORM AST delete statements (e.g. sa.delete(table)), + automatically inject 'WHERE 1=1' if no WHERE criteria were supplied. + """ + delete_stmt = self._ensure_where_clause(delete_stmt) + return super().visit_delete(delete_stmt, **kw) + + def visit_update(self, update_stmt, **kw): + """Compile an UPDATE statement. + + Cloud Spanner requires a WHERE clause for all UPDATE statements. + For SQLAlchemy Core/ORM AST update statements (e.g. sa.update(table)), + automatically inject 'WHERE 1=1' if no WHERE criteria were supplied. + """ + update_stmt = self._ensure_where_clause(update_stmt) + return super().visit_update(update_stmt, **kw) + def get_from_hint_text(self, _, text): """Return a hint text. @@ -562,9 +590,9 @@ def returning_clause(self, stmt, returning_cols, **kw): for c in expression._select_iterables( filter( lambda col: ( - not col.dialect_options.get("spanner", {}).get( - "exclude_from_returning", False - ) + not (getattr(col, "dialect_options", None) or {}) + .get("spanner", {}) + .get("exclude_from_returning", False) ), returning_cols, ) diff --git a/packages/sqlalchemy-spanner/tests/unit/test_dialect.py b/packages/sqlalchemy-spanner/tests/unit/test_dialect.py index d073c53b090a..eeb9dfb38ad2 100644 --- a/packages/sqlalchemy-spanner/tests/unit/test_dialect.py +++ b/packages/sqlalchemy-spanner/tests/unit/test_dialect.py @@ -354,3 +354,152 @@ def test_render_literal_value_triple_quote_and_trailing_escape(self): compiler.render_literal_value('100% "quoted" mid', sa.String()), 'r"""100%% "quoted" mid"""', ) + + def test_unqualified_delete_compiles_with_dummy_where(self): + """Test that unqualified DELETE statements automatically append WHERE 1=1.""" + dialect = SpannerDialect() + test_table = sa.table("my_table", sa.column("id")) + statement = sa.delete(test_table) + compiled = str(statement.compile(dialect=dialect)) + eq_(compiled, "DELETE FROM my_table WHERE 1=1") + + def test_qualified_delete_compiles_without_dummy_where(self): + """Test that qualified DELETE statements do not append extra WHERE 1=1.""" + dialect = SpannerDialect() + test_table = sa.table("my_table", sa.column("id")) + statement = sa.delete(test_table).where(test_table.c.id == 1) + compiled = str(statement.compile(dialect=dialect)) + eq_(compiled, "DELETE FROM my_table WHERE my_table.id = :id_1") + + def test_unqualified_update_compiles_with_dummy_where(self): + """Test that unqualified UPDATE statements automatically append WHERE 1=1.""" + dialect = SpannerDialect() + test_table = sa.table("my_table", sa.column("id")) + statement = sa.update(test_table).values(id=2) + compiled = str(statement.compile(dialect=dialect)) + eq_(compiled, "UPDATE my_table SET id=:id WHERE 1=1") + + def test_qualified_update_compiles_without_dummy_where(self): + """Test that qualified UPDATE statements do not append extra WHERE 1=1.""" + dialect = SpannerDialect() + test_table = sa.table("my_table", sa.column("id")) + statement = sa.update(test_table).where(test_table.c.id == 1).values(id=2) + compiled = str(statement.compile(dialect=dialect)) + eq_(compiled, "UPDATE my_table SET id=:id WHERE my_table.id = :id_1") + + def test_unqualified_delete_with_returning_compiles_with_dummy_where( + self, + ): + """Test unqualified DELETE with RETURNING has WHERE 1=1 before THEN RETURN.""" + dialect = SpannerDialect() + test_table = sa.table("my_table", sa.column("id")) + statement = sa.delete(test_table).returning(test_table.c.id) + compiled = str(statement.compile(dialect=dialect)) + eq_(compiled, "DELETE FROM my_table WHERE 1=1 THEN RETURN id") + + def test_unqualified_update_with_returning_compiles_with_dummy_where( + self, + ): + """Test unqualified UPDATE with RETURNING has WHERE 1=1 before THEN RETURN.""" + dialect = SpannerDialect() + test_table = sa.table("my_table", sa.column("id")) + statement = sa.update(test_table).values(id=2).returning(test_table.c.id) + compiled = str(statement.compile(dialect=dialect)) + eq_(compiled, "UPDATE my_table SET id=:id WHERE 1=1 THEN RETURN id") + + def test_delete_with_comparison_operators_does_not_raise_type_error(self): + """Test that DELETE with >, IN, LIKE operators compiles without TypeError.""" + dialect = SpannerDialect() + test_table = sa.table("my_table", sa.column("id"), sa.column("val")) + + stmt_gt = sa.delete(test_table).where(test_table.c.id > 1) + eq_( + str(stmt_gt.compile(dialect=dialect)), + "DELETE FROM my_table WHERE my_table.id > :id_1", + ) + + stmt_in = sa.delete(test_table).where(test_table.c.id.in_([1, 2])) + eq_( + str(stmt_in.compile(dialect=dialect)), + "DELETE FROM my_table WHERE my_table.id IN (__[POSTCOMPILE_id_1])", + ) + + def test_update_with_comparison_operators_does_not_raise_type_error(self): + """Test that UPDATE with >, IN, LIKE operators compiles without TypeError.""" + dialect = SpannerDialect() + test_table = sa.table("my_table", sa.column("id"), sa.column("val")) + + stmt_gt = sa.update(test_table).where(test_table.c.id > 1).values(val="x") + eq_( + str(stmt_gt.compile(dialect=dialect)), + "UPDATE my_table SET val=:val WHERE my_table.id > :id_1", + ) + + def test_manual_raw_sql_compiles_without_dummy_where(self): + """Test that manually written raw SQL text constructs compile as written. + + SpannerSQLCompiler injects WHERE 1=1 only for SQLAlchemy Core/ORM ASTs + (e.g. delete(table) or update(table)). Manually written raw SQL text + constructs (e.g. text("DELETE FROM my_table")) are compiled without + modification. + """ + dialect = SpannerDialect() + + # 1. Raw SQL text without WHERE clause is compiled as written + raw_delete = sa.text("DELETE FROM my_table") + eq_(str(raw_delete.compile(dialect=dialect)), "DELETE FROM my_table") + + raw_update = sa.text("UPDATE my_table SET id = 1") + eq_(str(raw_update.compile(dialect=dialect)), "UPDATE my_table SET id = 1") + + # 2. Raw SQL text with explicit WHERE 1=1 or WHERE TRUE is compiled as written + raw_delete_with_where = sa.text("DELETE FROM my_table WHERE 1=1") + eq_( + str(raw_delete_with_where.compile(dialect=dialect)), + "DELETE FROM my_table WHERE 1=1", + ) + + raw_update_with_where = sa.text("UPDATE my_table SET id = 1 WHERE TRUE") + eq_( + str(raw_update_with_where.compile(dialect=dialect)), + "UPDATE my_table SET id = 1 WHERE TRUE", + ) + + # 3. Contrast with ORM/Core ASTs which automatically generate WHERE 1=1 + test_table = sa.table("my_table", sa.column("id")) + ast_delete = sa.delete(test_table) + eq_(str(ast_delete.compile(dialect=dialect)), "DELETE FROM my_table WHERE 1=1") + + ast_update = sa.update(test_table).values(id=1) + eq_( + str(ast_update.compile(dialect=dialect)), + "UPDATE my_table SET id=:id WHERE 1=1", + ) + + def test_empty_where_appends_dummy_where(self): + """Test that calling .where() without criteria appends WHERE 1=1.""" + dialect = SpannerDialect() + test_table = sa.table("my_table", sa.column("id")) + + delete_stmt = sa.delete(test_table).where() + eq_(str(delete_stmt.compile(dialect=dialect)), "DELETE FROM my_table WHERE 1=1") + + update_stmt = sa.update(test_table).values(id=1).where() + eq_( + str(update_stmt.compile(dialect=dialect)), + "UPDATE my_table SET id=:id WHERE 1=1", + ) + + def test_explicit_literal_where_does_not_duplicate(self): + """Test that explicit WHERE 1=1 does not produce WHERE 1=1 AND 1=1.""" + dialect = SpannerDialect() + test_table = sa.table("my_table", sa.column("id")) + + delete_stmt = sa.delete(test_table).where(sa.literal_column("1=1")) + eq_(str(delete_stmt.compile(dialect=dialect)), "DELETE FROM my_table WHERE 1=1") + + update_stmt = sa.update(test_table).values(id=1).where(sa.literal_column("1=1")) + eq_( + str(update_stmt.compile(dialect=dialect)), + "UPDATE my_table SET id=:id WHERE 1=1", + )