Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 0 additions & 13 deletions CHANGELOG.md

This file was deleted.

3 changes: 0 additions & 3 deletions docs/changelog.md

This file was deleted.

7 changes: 7 additions & 0 deletions sqlalchemy_easy_softdelete/handler/rewriter/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from sqlalchemy.sql import Alias, CompoundSelect, Executable, Join, Select, Subquery, TableClause
from sqlalchemy.sql.elements import TextClause
from sqlalchemy.sql.lambdas import LambdaElement
from sqlalchemy.sql.selectable import Values

from sqlalchemy_easy_softdelete.hook import IgnoredTable

Expand Down Expand Up @@ -253,6 +254,12 @@ def analyze_from(self, stmt: Select, from_obj):
# introspect or do anything about this statement.
return stmt

if isinstance(from_obj, Values):
# A VALUES (...) construct is a literal row set with no underlying table,
# so there is nothing to apply a soft-delete filter to. Leave it untouched
# (same as raw-text identifiers above).
return stmt

if isinstance(from_obj, Alias):
if isinstance(from_obj.element, Subquery):
self.rewrite_element(from_obj.element)
Expand Down
81 changes: 80 additions & 1 deletion tests/default_config/test_queries.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Tests for `sqlalchemy_easy_softdelete` package."""

import pytest
from sqlalchemy import func, insert, lambda_stmt, select, table, text
from sqlalchemy import Integer, column, func, insert, lambda_stmt, select, table, text, update, values
from sqlalchemy.orm import Query, joinedload, selectinload, subqueryload
from sqlalchemy.orm.util import LoaderCriteriaOption
from sqlalchemy.sql import CompoundSelect, Select
Expand Down Expand Up @@ -991,3 +991,82 @@ def test_lambda_stmt_nested_joinedload_adds_criteria_for_all_levels(rewriter):
assert len(loader_criteria_options) >= 2, (
f"Expected at least 2 LoaderCriteriaOption for nested eager loading, got {len(loader_criteria_options)}"
)


def test_select_with_top_level_values_from_is_not_rewritten(db_session, db_connection):
"""A SELECT whose FROM is a VALUES construct must pass through untouched.

A VALUES (...) literal row set has no underlying table to soft-delete-filter,
so the rewriter must skip it (instead of raising NotImplementedError) and the
statement must still execute and return its rows.
"""
# SQLite only accepts VALUES inside a CTE, not as a top-level FROM (see CI: Postgres).
if db_connection.dialect.name == "sqlite":
pytest.skip("SQLite does not support a VALUES construct as a top-level FROM")

v = values(column("k", Integer), name="myvals").data([(1,), (2,)])

result = db_session.execute(select(v.c.k)).all() # must not raise NotImplementedError

assert sorted(r[0] for r in result) == [1, 2]


def test_orm_update_referencing_values_does_not_trip_rewriter(db_session, db_connection):
"""An ORM bulk UPDATE ... FROM (VALUES ...) emits a synchronize-session SELECT.

That spawned SELECT carries the VALUES in its FROM; the rewriter must skip it
(not crash with NotImplementedError) and the UPDATE must take effect.

We force ``synchronize_session="fetch"`` so the identity map is reconciled via
a spawned SELECT -- the exact indirect path this issue is about. This is also
required on SQLAlchemy 1.4, whose default ("evaluate") cannot evaluate a VALUES
column reference in Python and would raise UnevaluatableError before any SQL runs
(a SQLAlchemy limitation unrelated to the rewriter). 2.0's default already
resolves to "fetch".
"""
# SQLite cannot execute UPDATE ... FROM (VALUES ...) (see CI: Postgres).
if db_connection.dialect.name == "sqlite":
pytest.skip("SQLite does not support UPDATE ... FROM (VALUES ...)")

db_session.add_all([SDSimpleTable(id=1, int_field=10), SDSimpleTable(id=2, int_field=20)])
db_session.flush()

v = values(column("k", Integer), column("nv", Integer), name="v").data([(1, 99)])
db_session.execute(
update(SDSimpleTable)
.where(SDSimpleTable.id == v.c.k)
.values({SDSimpleTable.int_field: v.c.nv})
.execution_options(synchronize_session="fetch"),
) # must not raise NotImplementedError

# Row matched by the VALUES is updated; the other row is untouched.
assert db_session.get(SDSimpleTable, 1).int_field == 99
assert db_session.get(SDSimpleTable, 2).int_field == 20


def test_table_alongside_values_is_still_soft_delete_filtered(db_session, db_connection):
"""A soft-deletable table that appears alongside a top-level VALUES must still be filtered.

With both a real table and a VALUES construct as top-level FROM entries (joined
in the WHERE), the VALUES is skipped while the real table still gets its
`deleted_at IS NULL` filter -- so soft-deleted rows are excluded but matching
VALUES rows still pass through.
"""
# SQLite only accepts VALUES inside a CTE, not as a top-level FROM (see CI: Postgres).
if db_connection.dialect.name == "sqlite":
pytest.skip("SQLite does not support a VALUES construct as a top-level FROM")

active = SDSimpleTable(id=1, int_field=10)
soft_deleted = SDSimpleTable(id=2, int_field=20)
db_session.add_all([active, soft_deleted])
db_session.flush()
soft_deleted.delete() # soft-delete row id=2
db_session.flush()

v = values(column("k", Integer), name="myvals").data([(10,), (20,)])
# Table and VALUES are both top-level FROMs, linked in the WHERE.
rows = db_session.execute(select(SDSimpleTable.id, v.c.k).where(SDSimpleTable.int_field == v.c.k)).all()

# The active row (id=1, int_field=10) matches VALUES row 10 and is returned;
# the soft-deleted row (id=2, int_field=20) is filtered out despite matching 20.
assert rows == [(1, 10)]
Loading