Skip to content
Open
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
2 changes: 2 additions & 0 deletions reflex/istate/proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,8 @@ def __getattr__(self, __name: str) -> Any:
)
and (func := getattr(value, "__func__", None)) is not None
and not inspect.isclass(getattr(value, "__self__", None))
# skip SQLAlchemy instrumented methods
and not getattr(value, "_sa_instrumented", False)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No regression test for the SQLAlchemy fix

The fix addresses a real bug (issue #6167) but no test was added to tests/units/istate/test_proxy.py (or elsewhere) to assert the behavior. Without a test, a future refactor of the rebinding logic could silently reintroduce the breakage.

Consider adding a minimal test that:

  1. Creates a mock object with a method that has _sa_instrumented = True set on it.
  2. Wraps it in MutableProxy.
  3. Asserts that accessing the method via the proxy does not rebind self (i.e. calling the method works as expected on the underlying object, not the proxy).
def test_mutable_proxy_skips_sa_instrumented():
    class FakeModel:
        def instrumented_method(self):
            return type(self).__name__

    instrumented_method = FakeModel.instrumented_method
    instrumented_method._sa_instrumented = True
    FakeModel.instrumented_method = instrumented_method

    proxy = MutableProxy(FakeModel(), state=None, field_name="")
    # Should return "FakeModel", not "MutableProxy"
    assert proxy.instrumented_method() == "FakeModel"

):
# Rebind `self` to the proxy on methods to capture nested mutations.
return functools.partial(func, self)
Expand Down
42 changes: 41 additions & 1 deletion tests/units/test_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import reflex.model
from reflex.constants.state import FIELD_MARKER
from reflex.model import Model, ModelRegistry
from reflex.state import BaseState
from reflex.state import BaseState, State
from tests.units.test_state import (
mock_app_simple, # noqa: F401 # for pytest.mark.usefixtures
)
Expand Down Expand Up @@ -240,3 +240,43 @@ async def test_upcast_event_handler_arg(handler, payload):
assert update.delta == {
UpcastStateWithSqlAlchemy.get_full_name(): {"passed" + FIELD_MARKER: True}
}


def test_no_rebind_mutable_proxy_for_instrumented_functions():
"""Test that we don't rebind mutable proxies for instrumented functions."""
import sqlalchemy
import sqlalchemy.orm

class SABase(sqlalchemy.orm.MappedAsDataclass, sqlalchemy.orm.DeclarativeBase):
pass

class SAKeyword(SABase):
__tablename__ = "sa_keyword"

id: sqlalchemy.orm.Mapped[int] = sqlalchemy.orm.mapped_column(
primary_key=True, init=False, default=None
)
value: sqlalchemy.orm.Mapped[str] = sqlalchemy.orm.mapped_column(default="")
obj_id: sqlalchemy.orm.Mapped[int] = sqlalchemy.orm.mapped_column(
sqlalchemy.ForeignKey("sa_obj.id"), default=None
)

class SAObj(SABase):
__tablename__ = "sa_obj"

id: sqlalchemy.orm.Mapped[int] = sqlalchemy.orm.mapped_column(
primary_key=True, init=False, default=None
)
keywords: sqlalchemy.orm.Mapped[list[SAKeyword]] = sqlalchemy.orm.relationship(
lazy="selectin", # codespell:ignore
cascade="all, delete",
default_factory=list,
)

class SAState(State):
sa_obj: SAObj = SAObj()

sa_state = SAState()
assert "sa_obj" not in sa_state.dirty_vars
sa_state.sa_obj.keywords.append(SAKeyword(value="test"))
assert "sa_obj" in sa_state.dirty_vars
Loading