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
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ sources = sqlalchemy_easy_softdelete
lint:
uv run pre-commit run --all-files

# Run type checking (mypy)
# Run type checking (mypy) on source code and tests
typecheck:
uv run mypy $(sources)
uv run mypy $(sources) tests/

# Quick test with SQLite (no docker needed)
test:
Expand Down
30 changes: 22 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,18 +27,32 @@ pip install sqlalchemy-easy-softdelete
```py
from sqlalchemy_easy_softdelete.mixin import generate_soft_delete_mixin_class
from sqlalchemy_easy_softdelete.hook import IgnoredTable
from sqlalchemy.orm import declarative_base
from sqlalchemy.orm import declarative_base, Mapped
from sqlalchemy import Column, Integer
from datetime import datetime

# Create a Class that inherits from our class builder
class SoftDeleteMixin(generate_soft_delete_mixin_class(
# This table will be ignored by the hook
# even if the table has the soft-delete column
ignored_tables=[IgnoredTable(table_schema="public", name="cars"),]
)):
# type hint for autocomplete IDE support
deleted_at: datetime
class SoftDeleteMixin(
generate_soft_delete_mixin_class( # type: ignore[misc]
# This table will be ignored by the hook
# even if the table has the soft-delete column
ignored_tables=[IgnoredTable(table_schema="public", name="cars"),]
)
):
# type: ignore[misc] is required because the mixin is dynamically generated

# Type hint for IDE autocomplete and type checker support.
# Using Mapped[T | None] ensures type checkers understand this is a
# SQLAlchemy column that supports query operations like .where()
deleted_at: Mapped[datetime | None]

# Optional: Add method stubs for delete/undelete for type checker support.
# The actual implementations are provided by the generated mixin class.
def delete(self, v: datetime | None = None) -> None:
super().delete(v) # type: ignore[misc]

def undelete(self) -> None:
super().undelete() # type: ignore[misc]

# Apply the mixin to your Models
Base = declarative_base()
Expand Down
10 changes: 5 additions & 5 deletions mypy.ini
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
[mypy]
packages = sqlalchemy_easy_softdelete
packages = sqlalchemy_easy_softdelete,tests
python_version = 3.10

# Lenient settings - this codebase wasn't originally typed
disallow_untyped_calls = false
# Strictness settings
disallow_untyped_calls = true
disallow_untyped_defs = false
disallow_untyped_decorators = false
disallow_untyped_decorators = true
check_untyped_defs = false
ignore_missing_imports = true
ignore_missing_imports = false
allow_redefinition = true
warn_unused_configs = true
17 changes: 9 additions & 8 deletions sqlalchemy_easy_softdelete/handler/sqlalchemy_easy_softdelete.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,15 @@
from sqlalchemy_easy_softdelete.handler.rewriter import SoftDeleteQueryRewriter
from sqlalchemy_easy_softdelete.hook import IgnoredTable

global_rewriter: SoftDeleteQueryRewriter | None = None


def activate_soft_delete_hook(
deleted_field_name: str, disable_soft_delete_option_name: str, ignored_tables: list[IgnoredTable]
):
"""Activate an event hook to rewrite the queries."""
) -> SoftDeleteQueryRewriter:
"""Activate an event hook to rewrite the queries.

global global_rewriter
global_rewriter = SoftDeleteQueryRewriter(
Returns the SoftDeleteQueryRewriter instance for use by the mixin class.
"""
rewriter = SoftDeleteQueryRewriter(
deleted_field_name=deleted_field_name,
disable_soft_delete_option_name=disable_soft_delete_option_name,
ignored_tables=ignored_tables,
Expand All @@ -30,10 +29,12 @@ def soft_delete_execute(state: ORMExecuteState):
if not state.is_select:
return

# Rewrite the statement
adapted = global_rewriter.rewrite_statement(state.statement)
# Rewrite the statement (closure captures local `rewriter`)
adapted = rewriter.rewrite_statement(state.statement)

# Replace the statement
# Cast needed because Statement type includes LambdaElement which mypy
# doesn't recognize as Executable (even though it is at runtime)
state.statement = cast(Executable, adapted)

return rewriter
6 changes: 5 additions & 1 deletion sqlalchemy_easy_softdelete/mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,11 @@ def undelete_method(_self):

class_attributes[undelete_method_name] = undelete_method

activate_soft_delete_hook(deleted_field_name, disable_soft_delete_filtering_option_name, ignored_tables)
# Activate the soft delete hook and get the rewriter instance
rewriter = activate_soft_delete_hook(deleted_field_name, disable_soft_delete_filtering_option_name, ignored_tables)

# Store rewriter on the generated class for testing purposes
class_attributes["_sqlalchemy_easy_softdelete_rewriter"] = rewriter

generated_class = type(class_name, tuple(), class_attributes)

Expand Down
38 changes: 4 additions & 34 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,33 +1,28 @@
import os
from collections.abc import Generator

import pytest
from sqlalchemy import create_engine
from sqlalchemy.engine import Connection, Engine
from sqlalchemy.orm import Session, sessionmaker

from sqlalchemy_easy_softdelete.handler.rewriter import SoftDeleteQueryRewriter
from tests.model import TestModelBase
from tests.seed_data import generate_table_with_inheritance_obj
from tests.seed_data.parent_child_childchild import generate_parent_child_object_hierarchy

env_connection_string = os.environ.get("TEST_CONNECTION_STRING", None)


@pytest.fixture
def sqla2_warnings() -> Engine:
def sqla2_warnings() -> None:
# Enable SQLAlchemy 2.0 Warnings mode to help with 2.0 support
os.environ["SQLALCHEMY_WARN_20"] = "1"


@pytest.fixture
def db_engine(sqla2_warnings) -> Engine:
def db_engine(sqla2_warnings: None) -> Engine:
test_db_url = env_connection_string or "sqlite://"
print(f"connection_string={test_db_url}")
return create_engine(test_db_url, future=True)


@pytest.fixture
def db_connection(db_engine) -> Connection:
def db_connection(db_engine: Engine) -> Generator[Connection, None, None]:
connection = db_engine.connect()

# start a transaction
Expand All @@ -38,28 +33,3 @@ def db_connection(db_engine) -> Connection:
finally:
transaction.rollback()
connection.close()


@pytest.fixture
def db_session(db_connection) -> Session:
TestModelBase.metadata.create_all(db_connection)
return sessionmaker(autocommit=False, autoflush=False, bind=db_connection)()


@pytest.fixture
def seeded_session(db_session) -> Session:
generate_parent_child_object_hierarchy(db_session, 1000)
generate_parent_child_object_hierarchy(db_session, 1001)
generate_parent_child_object_hierarchy(db_session, 1002, parent_deleted=True)

generate_table_with_inheritance_obj(db_session, 1000, deleted=False)
generate_table_with_inheritance_obj(db_session, 1001, deleted=False)
generate_table_with_inheritance_obj(db_session, 1002, deleted=True)
return db_session


@pytest.fixture
def rewriter() -> SoftDeleteQueryRewriter:
from sqlalchemy_easy_softdelete.handler.sqlalchemy_easy_softdelete import global_rewriter

return global_rewriter
Empty file.
19 changes: 19 additions & 0 deletions tests/custom_default_value/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from typing import cast

import pytest
from sqlalchemy.engine import Connection
from sqlalchemy.orm import Session, sessionmaker

from sqlalchemy_easy_softdelete.handler.rewriter import SoftDeleteQueryRewriter
from tests.custom_default_value.model import CDVModelBase, CDVSoftDeleteMixin


@pytest.fixture
def db_session(db_connection: Connection) -> Session:
CDVModelBase.metadata.create_all(db_connection) # type: ignore[attr-defined]
return sessionmaker(autocommit=False, autoflush=False, bind=db_connection)()


@pytest.fixture
def rewriter() -> SoftDeleteQueryRewriter:
return cast(SoftDeleteQueryRewriter, CDVSoftDeleteMixin._sqlalchemy_easy_softdelete_rewriter)
26 changes: 26 additions & 0 deletions tests/custom_default_value/model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from datetime import datetime, timezone

from sqlalchemy import Column, Integer
from sqlalchemy.orm import Mapped, as_declarative

from sqlalchemy_easy_softdelete.mixin import generate_soft_delete_mixin_class


@as_declarative()
class CDVModelBase:
"""CDV = Custom Default Value"""

id = Column(Integer, primary_key=True, autoincrement=True)


class CDVSoftDeleteMixin(
generate_soft_delete_mixin_class( # type: ignore[misc]
delete_method_default_value=lambda: datetime(2000, 1, 1, tzinfo=timezone.utc),
)
):
deleted_at: Mapped[datetime | None]


class CDVTable(CDVModelBase, CDVSoftDeleteMixin):
__tablename__ = "cdvtable"
value = Column(Integer)
31 changes: 31 additions & 0 deletions tests/custom_default_value/test_custom_default_value.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"""Tests for custom default value option."""

from datetime import datetime, timezone

from tests.custom_default_value.model import CDVTable


def test_delete_uses_custom_default_value(db_session):
"""Verify delete() uses the custom default value function."""
obj = CDVTable(value=1)
db_session.add(obj)
db_session.commit()

obj.delete()

# Should use our custom date (2000-01-01)
# SQLite doesn't preserve timezone, so compare without it
assert obj.deleted_at.replace(tzinfo=None) == datetime(2000, 1, 1)


def test_delete_with_explicit_value_overrides_default(db_session):
"""Verify delete(value) uses the passed value instead of default."""
obj = CDVTable(value=1)
db_session.add(obj)
db_session.commit()

custom_date = datetime(2020, 6, 15, 12, 30, tzinfo=timezone.utc)
obj.delete(custom_date)

# SQLite doesn't preserve timezone, so compare without it
assert obj.deleted_at.replace(tzinfo=None) == custom_date.replace(tzinfo=None)
Empty file.
19 changes: 19 additions & 0 deletions tests/custom_deleted_field_name/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from typing import cast

import pytest
from sqlalchemy.engine import Connection
from sqlalchemy.orm import Session, sessionmaker

from sqlalchemy_easy_softdelete.handler.rewriter import SoftDeleteQueryRewriter
from tests.custom_deleted_field_name.model import CFNModelBase, CFNSoftDeleteMixin


@pytest.fixture
def db_session(db_connection: Connection) -> Session:
CFNModelBase.metadata.create_all(db_connection) # type: ignore[attr-defined]
return sessionmaker(autocommit=False, autoflush=False, bind=db_connection)()


@pytest.fixture
def rewriter() -> SoftDeleteQueryRewriter:
return cast(SoftDeleteQueryRewriter, CFNSoftDeleteMixin._sqlalchemy_easy_softdelete_rewriter)
26 changes: 26 additions & 0 deletions tests/custom_deleted_field_name/model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from datetime import datetime

from sqlalchemy import Column, Integer
from sqlalchemy.orm import Mapped, as_declarative

from sqlalchemy_easy_softdelete.mixin import generate_soft_delete_mixin_class


@as_declarative()
class CFNModelBase:
"""CFN = Custom Field Name"""

id = Column(Integer, primary_key=True, autoincrement=True)


class CFNSoftDeleteMixin(
generate_soft_delete_mixin_class( # type: ignore[misc]
deleted_field_name="removed_at",
)
):
removed_at: Mapped[datetime | None]


class CFNTable(CFNModelBase, CFNSoftDeleteMixin):
__tablename__ = "cfntable"
value = Column(Integer)
57 changes: 57 additions & 0 deletions tests/custom_deleted_field_name/test_custom_field_name.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""Tests for custom deleted_field_name option."""

from datetime import datetime, timezone

from tests.custom_deleted_field_name.model import CFNTable


def test_custom_field_name_column_exists():
"""Verify the column uses the custom field name."""
assert "removed_at" in CFNTable.__table__.columns
assert "deleted_at" not in CFNTable.__table__.columns


def test_rewriter_has_correct_field_name(rewriter):
"""Verify the rewriter is configured with the custom field name."""
assert rewriter.deleted_field_name == "removed_at"


def test_delete_sets_custom_field(db_session):
"""Verify delete() sets the custom field."""
obj = CFNTable(value=1)
db_session.add(obj)
db_session.commit()

assert obj.removed_at is None
obj.delete()
assert obj.removed_at is not None


def test_undelete_clears_custom_field(db_session):
"""Verify undelete() clears the custom field."""
obj = CFNTable(value=1)
db_session.add(obj)
db_session.commit()

obj.delete()
assert obj.removed_at is not None

obj.undelete()
assert obj.removed_at is None


def test_soft_delete_filtering_uses_custom_field(db_session):
"""Verify soft-delete filtering works with custom field name."""
active = CFNTable(value=1)
deleted = CFNTable(value=2)
deleted.removed_at = datetime.now(timezone.utc)

db_session.add_all([active, deleted])
db_session.commit()

results = db_session.query(CFNTable).all()
assert len(results) == 1
assert results[0].value == 1

all_results = db_session.query(CFNTable).execution_options(include_deleted=True).all()
assert len(all_results) == 2
Empty file.
19 changes: 19 additions & 0 deletions tests/custom_method_names/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from typing import cast

import pytest
from sqlalchemy.engine import Connection
from sqlalchemy.orm import Session, sessionmaker

from sqlalchemy_easy_softdelete.handler.rewriter import SoftDeleteQueryRewriter
from tests.custom_method_names.model import CMNModelBase, CMNSoftDeleteMixin


@pytest.fixture
def db_session(db_connection: Connection) -> Session:
CMNModelBase.metadata.create_all(db_connection) # type: ignore[attr-defined]
return sessionmaker(autocommit=False, autoflush=False, bind=db_connection)()


@pytest.fixture
def rewriter() -> SoftDeleteQueryRewriter:
return cast(SoftDeleteQueryRewriter, CMNSoftDeleteMixin._sqlalchemy_easy_softdelete_rewriter)
Loading