diff --git a/README.md b/README.md index 2c89798..aa3da65 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,60 @@ else: > Note that AsyncAdaper must be used for AynscEnforcer. +## Soft Deletion Support + +The adapter supports soft deletion of policies. When enabled, instead of physically deleting policy records from the database, they are marked as deleted using a boolean flag. This preserves the data history and allows for auditing. + +### Usage + +To use soft deletion, you need to: + +1. Define a custom database model with a boolean `is_deleted` column +2. Pass the column attribute to the adapter when initializing + +```python +import casbin_async_sqlalchemy_adapter +import casbin +from sqlalchemy import Column, Integer, String, Boolean +from casbin_async_sqlalchemy_adapter import Base + +# Define your custom model with soft delete support +class CasbinRuleSoftDelete(Base): + __tablename__ = "casbin_rule" + + id = Column(Integer, primary_key=True) + ptype = Column(String(255)) + v0 = Column(String(255)) + v1 = Column(String(255)) + v2 = Column(String(255)) + v3 = Column(String(255)) + v4 = Column(String(255)) + v5 = Column(String(255)) + + # Add the soft delete column + is_deleted = Column(Boolean, default=False, index=True, nullable=False) + +# Create adapter with soft delete support +adapter = casbin_async_sqlalchemy_adapter.Adapter( + 'sqlite+aiosqlite:///test.db', + db_class=CasbinRuleSoftDelete, + db_class_softdelete_attribute=CasbinRuleSoftDelete.is_deleted +) + +e = casbin.AsyncEnforcer('path/to/model.conf', adapter) + +# Now when you remove policies, they will be soft-deleted +await e.remove_policy("alice", "data1", "read") +# The record remains in the database with is_deleted=True +``` + +**Key Features:** +- Records are marked as deleted instead of being removed from the database +- All queries automatically filter out soft-deleted records +- The soft delete column must be of type `Boolean` +- Soft deletion only works with custom database classes (not the default `CasbinRule`) +- Full backward compatibility - works as before when soft delete is not enabled + ## External Session Support The adapter supports using externally managed SQLAlchemy sessions. This feature is useful for: diff --git a/casbin_async_sqlalchemy_adapter/adapter.py b/casbin_async_sqlalchemy_adapter/adapter.py index e919a97..68c0fbe 100644 --- a/casbin_async_sqlalchemy_adapter/adapter.py +++ b/casbin_async_sqlalchemy_adapter/adapter.py @@ -17,8 +17,8 @@ from casbin import persist from casbin.persist.adapters.asyncio import AsyncAdapter -from sqlalchemy import Column, Integer, String, delete, insert -from sqlalchemy import or_ +from sqlalchemy import Column, Integer, String, Boolean, delete, insert, update +from sqlalchemy import or_, not_ from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession from sqlalchemy.future import select from sqlalchemy.orm import declarative_base, sessionmaker @@ -67,6 +67,7 @@ def __init__( self, engine, db_class=None, + db_class_softdelete_attribute=None, filtered=False, warning=True, db_session: Optional[AsyncSession] = None, @@ -76,6 +77,8 @@ def __init__( else: self._engine = engine + self.softdelete_attribute = None + if db_class is None: db_class = CasbinRule if warning: @@ -85,6 +88,15 @@ def __init__( RuntimeWarning, ) else: + if db_class_softdelete_attribute is not None and not isinstance( + db_class_softdelete_attribute.type, Boolean + ): + msg = f"The type of db_class_softdelete_attribute needs to be {str(Boolean)!r}. " + msg += f"An attribute of type {str(type(db_class_softdelete_attribute.type))!r} was given." + raise ValueError(msg) + # Softdelete is only supported when using custom class + self.softdelete_attribute = db_class_softdelete_attribute + for attr in ( "id", "ptype", @@ -129,7 +141,9 @@ async def create_table(self): async def load_policy(self, model): """loads all policy rules from the storage.""" async with self._session_scope() as session: - lines = await session.execute(select(self._db_class)) + stmt = select(self._db_class) + stmt = self._softdelete_query(stmt) + lines = await session.execute(stmt) for line in lines.scalars(): persist.load_policy_line(str(line), model) @@ -140,6 +154,7 @@ async def load_filtered_policy(self, model, filter) -> None: """loads all policy rules from the storage.""" async with self._session_scope() as session: stmt = select(self._db_class) + stmt = self._softdelete_query(stmt) stmt = self.filter_query(stmt, filter) result = await session.execute(stmt) for line in result.scalars(): @@ -152,6 +167,12 @@ def filter_query(self, stmt, filter): stmt = stmt.where(getattr(self._db_class, attr).in_(getattr(filter, attr))) return stmt.order_by(self._db_class.id) + def _softdelete_query(self, stmt): + """Apply soft delete filter to exclude soft-deleted records.""" + if self.softdelete_attribute is not None: + stmt = stmt.where(not_(self.softdelete_attribute)) + return stmt + async def _save_policy_line(self, ptype, rule, session=None): if session is not None: # Use provided session @@ -169,15 +190,61 @@ async def _save_policy_line(self, ptype, rule, session=None): async def save_policy(self, model): """saves all policy rules to the storage.""" + # Use the default strategy when soft delete is not enabled + if self.softdelete_attribute is None: + async with self._session_scope() as session: + stmt = delete(self._db_class) + await session.execute(stmt) + for sec in ["p", "g"]: + if sec not in model.model.keys(): + continue + for ptype, ast in model.model[sec].items(): + for rule in ast.policy: + await self._save_policy_line(ptype, rule, session) + return True + + # Custom strategy for softdelete since it does not make sense to recreate all of the + # entries when using soft delete async with self._session_scope() as session: - stmt = delete(self._db_class) - await session.execute(stmt) + stmt = select(self._db_class) + stmt = self._softdelete_query(stmt) + result = await session.execute(stmt) + lines_before_changes = result.scalars().all() + + # Create new entries in the database for sec in ["p", "g"]: if sec not in model.model.keys(): continue for ptype, ast in model.model[sec].items(): for rule in ast.policy: - await self._save_policy_line(ptype, rule, session) + # Filter for rule in the database + filter_stmt = select(self._db_class) + filter_stmt = self._softdelete_query(filter_stmt) + filter_stmt = filter_stmt.where(self._db_class.ptype == ptype) + for index, value in enumerate(rule): + v_value = getattr(self._db_class, "v{}".format(index)) + filter_stmt = filter_stmt.where(v_value == value) + filter_result = await session.execute(filter_stmt) + # If the rule is not present, create an entry in the database + if filter_result.scalar() is None: + await self._save_policy_line(ptype, rule, session=session) + + for line in lines_before_changes: + ptype = line.ptype + sec = ptype[0] # derived from persist.load_policy_line function + fields_with_None = [ + line.v0, + line.v1, + line.v2, + line.v3, + line.v4, + line.v5, + ] + rule = [element for element in fields_with_None if element is not None] + # If the rule is not part of the model, set the deletion flag to True + if not model.has_policy(sec, ptype, rule): + setattr(line, self.softdelete_attribute.name, True) + return True async def add_policy(self, sec, ptype, rule): @@ -204,10 +271,31 @@ async def add_policies(self, sec, ptype, rules): async def remove_policy(self, sec, ptype, rule): """removes a policy rule from the storage.""" async with self._session_scope() as session: - stmt = delete(self._db_class).where(self._db_class.ptype == ptype) - for i, v in enumerate(rule): - stmt = stmt.where(getattr(self._db_class, "v{}".format(i)) == v) - r = await session.execute(stmt) + if self.softdelete_attribute is None: + stmt = delete(self._db_class).where(self._db_class.ptype == ptype) + for i, v in enumerate(rule): + stmt = stmt.where(getattr(self._db_class, "v{}".format(i)) == v) + r = await session.execute(stmt) + else: + stmt = select(self._db_class) + stmt = self._softdelete_query(stmt) + stmt = stmt.where(self._db_class.ptype == ptype) + for i, v in enumerate(rule): + stmt = stmt.where(getattr(self._db_class, "v{}".format(i)) == v) + result = await session.execute(stmt) + lines = result.scalars().all() + + if not lines: + return False + + update_stmt = ( + update(self._db_class) + .where(self._db_class.ptype == ptype) + ) + for i, v in enumerate(rule): + update_stmt = update_stmt.where(getattr(self._db_class, "v{}".format(i)) == v) + update_stmt = update_stmt.values({self.softdelete_attribute.name: True}) + r = await session.execute(update_stmt) return True if r.rowcount > 0 else False @@ -216,28 +304,60 @@ async def remove_policies(self, sec, ptype, rules): if not rules: return async with self._session_scope() as session: - stmt = delete(self._db_class).where(self._db_class.ptype == ptype) - rules = zip(*rules) - for i, rule in enumerate(rules): - stmt = stmt.where(or_(getattr(self._db_class, "v{}".format(i)) == v for v in rule)) - await session.execute(stmt) + if self.softdelete_attribute is None: + stmt = delete(self._db_class).where(self._db_class.ptype == ptype) + rules = zip(*rules) + for i, rule in enumerate(rules): + stmt = stmt.where(or_(getattr(self._db_class, "v{}".format(i)) == v for v in rule)) + await session.execute(stmt) + else: + stmt = select(self._db_class) + stmt = self._softdelete_query(stmt) + stmt = stmt.where(self._db_class.ptype == ptype) + rules_zip = zip(*rules) + for i, rule in enumerate(rules_zip): + stmt = stmt.where(or_(getattr(self._db_class, "v{}".format(i)) == v for v in rule)) + + update_stmt = update(self._db_class).where(self._db_class.ptype == ptype) + rules_zip = zip(*rules) + for i, rule in enumerate(rules_zip): + update_stmt = update_stmt.where(or_(getattr(self._db_class, "v{}".format(i)) == v for v in rule)) + update_stmt = update_stmt.values({self.softdelete_attribute.name: True}) + await session.execute(update_stmt) async def remove_filtered_policy(self, sec, ptype, field_index, *field_values): """removes policy rules that match the filter from the storage. This is part of the Auto-Save feature. """ async with self._session_scope() as session: - stmt = delete(self._db_class).where(self._db_class.ptype == ptype) - if not (0 <= field_index <= 5): return False if not (1 <= field_index + len(field_values) <= 6): return False - for i, v in enumerate(field_values): - if v != "": - v_value = getattr(self._db_class, "v{}".format(field_index + i)) - stmt = stmt.where(v_value == v) - r = await session.execute(stmt) + + if self.softdelete_attribute is None: + stmt = delete(self._db_class).where(self._db_class.ptype == ptype) + for i, v in enumerate(field_values): + if v != "": + v_value = getattr(self._db_class, "v{}".format(field_index + i)) + stmt = stmt.where(v_value == v) + r = await session.execute(stmt) + else: + stmt = select(self._db_class) + stmt = self._softdelete_query(stmt) + stmt = stmt.where(self._db_class.ptype == ptype) + for i, v in enumerate(field_values): + if v != "": + v_value = getattr(self._db_class, "v{}".format(field_index + i)) + stmt = stmt.where(v_value == v) + + update_stmt = update(self._db_class).where(self._db_class.ptype == ptype) + for i, v in enumerate(field_values): + if v != "": + v_value = getattr(self._db_class, "v{}".format(field_index + i)) + update_stmt = update_stmt.where(v_value == v) + update_stmt = update_stmt.values({self.softdelete_attribute.name: True}) + r = await session.execute(update_stmt) return True if r.rowcount > 0 else False @@ -254,7 +374,9 @@ async def update_policy(self, sec: str, ptype: str, old_rule: List[str], new_rul """ async with self._session_scope() as session: - stmt = select(self._db_class).where(self._db_class.ptype == ptype) + stmt = select(self._db_class) + stmt = self._softdelete_query(stmt) + stmt = stmt.where(self._db_class.ptype == ptype) # locate the old rule for index, value in enumerate(old_rule): @@ -314,7 +436,9 @@ async def _update_filtered_policies(self, new_rules, filter) -> List[List[str]]: async with self._session_scope() as session: # Load old policies - stmt = select(self._db_class).where(self._db_class.ptype == filter.ptype) + stmt = select(self._db_class) + stmt = self._softdelete_query(stmt) + stmt = stmt.where(self._db_class.ptype == filter.ptype) filtered_stmt = self.filter_query(stmt, filter) result = await session.execute(filtered_stmt) old_rules = result.scalars().all() diff --git a/tests/test_adapter_softdelete.py b/tests/test_adapter_softdelete.py new file mode 100644 index 0000000..52a5c69 --- /dev/null +++ b/tests/test_adapter_softdelete.py @@ -0,0 +1,323 @@ +# Copyright 2023 The casbin Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import unittest +from unittest import IsolatedAsyncioTestCase +from pathlib import Path + +import casbin +from sqlalchemy import Column, Integer, String, Boolean, select +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker + +from casbin_async_sqlalchemy_adapter import Adapter, Base +from casbin_async_sqlalchemy_adapter.adapter import Filter + + +class CasbinRuleSoftDelete(Base): + __tablename__ = "casbin_rule_soft_delete" + + id = Column(Integer, primary_key=True) + ptype = Column(String(255)) + v0 = Column(String(255)) + v1 = Column(String(255)) + v2 = Column(String(255)) + v3 = Column(String(255)) + v4 = Column(String(255)) + v5 = Column(String(255)) + + is_deleted = Column(Boolean, default=False, index=True, nullable=False) + + def __str__(self): + arr = [self.ptype] + for v in (self.v0, self.v1, self.v2, self.v3, self.v4, self.v5): + if v is None: + break + arr.append(v) + return ", ".join(arr) + + def __repr__(self): + return ''.format(self.id, str(self)) + + +def get_fixture(path): + dir_path = os.path.split(os.path.realpath(__file__))[0] + "/" + return os.path.abspath(dir_path + path) + + +async def query_for_rule(session, adapter, ptype, v0, v1, v2): + """Query for a specific rule in the database.""" + rule_filter = Filter() + rule_filter.ptype = [ptype] + rule_filter.v0 = [v0] + rule_filter.v1 = [v1] + rule_filter.v2 = [v2] + + stmt = select(CasbinRuleSoftDelete) + stmt = adapter.filter_query(stmt, rule_filter) + result = await session.execute(stmt) + return result.scalars().all() + + +class TestConfigSoftDelete(IsolatedAsyncioTestCase): + async def get_enforcer(self): + engine = create_async_engine("sqlite+aiosqlite://", future=True) + adapter = Adapter(engine, CasbinRuleSoftDelete, CasbinRuleSoftDelete.is_deleted) + + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + async_session = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession) + async with async_session() as s: + # Clear any existing data + await s.execute(select(CasbinRuleSoftDelete)) + + s.add(CasbinRuleSoftDelete(ptype="p", v0="alice", v1="data1", v2="read")) + s.add(CasbinRuleSoftDelete(ptype="p", v0="bob", v1="data2", v2="write")) + s.add(CasbinRuleSoftDelete(ptype="p", v0="data2_admin", v1="data2", v2="read")) + s.add(CasbinRuleSoftDelete(ptype="p", v0="data2_admin", v1="data2", v2="write")) + s.add(CasbinRuleSoftDelete(ptype="g", v0="alice", v1="data2_admin")) + await s.commit() + + e = casbin.AsyncEnforcer(get_fixture("rbac_model.conf"), adapter) + await e.load_policy() + return e + + async def test_custom_db_class(self): + """Test that custom db class with soft delete attribute works.""" + class CustomRule(Base): + __tablename__ = "casbin_rule3" + __table_args__ = {"extend_existing": True} + + id = Column(Integer, primary_key=True) + ptype = Column(String(255)) + v0 = Column(String(255)) + v1 = Column(String(255)) + v2 = Column(String(255)) + v3 = Column(String(255)) + v4 = Column(String(255)) + v5 = Column(String(255)) + is_deleted = Column(Boolean, default=False) + not_exist = Column(String(255)) + + engine = create_async_engine("sqlite+aiosqlite://", future=True) + adapter = Adapter(engine, CustomRule, CustomRule.is_deleted) + + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + async_session = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession) + async with async_session() as s: + s.add(CustomRule(not_exist="NotNone")) + await s.commit() + result = await s.execute(select(CustomRule)) + self.assertEqual(result.scalars().all()[0].not_exist, "NotNone") + + async def test_softdelete_flag(self): + """Test that soft delete flag is set when removing a policy.""" + e = await self.get_enforcer() + engine = e.get_adapter()._engine + async_session = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession) + + async with async_session() as session: + # Initially, rule doesn't exist + query_result = await query_for_rule(session, e.get_adapter(), "p", "alice", "data5", "read") + self.assertFalse(e.enforce("alice", "data5", "read")) + self.assertEqual(len(query_result), 0) + + # Add the rule + await e.add_permission_for_user("alice", "data5", "read") + await session.commit() + + async with async_session() as session: + # Rule exists and is not deleted + query_result = await query_for_rule(session, e.get_adapter(), "p", "alice", "data5", "read") + self.assertTrue(e.enforce("alice", "data5", "read")) + self.assertEqual(len(query_result), 1) + self.assertFalse(query_result[0].is_deleted) + + # Remove the rule (soft delete) + await e.delete_permission_for_user("alice", "data5", "read") + await session.commit() + + async with async_session() as session: + # Rule still exists in DB but is marked as deleted + stmt = select(CasbinRuleSoftDelete).where( + CasbinRuleSoftDelete.ptype == "p", + CasbinRuleSoftDelete.v0 == "alice", + CasbinRuleSoftDelete.v1 == "data5", + CasbinRuleSoftDelete.v2 == "read" + ) + result = await session.execute(stmt) + all_results = result.scalars().all() + self.assertFalse(e.enforce("alice", "data5", "read")) + self.assertEqual(len(all_results), 1) + self.assertTrue(all_results[0].is_deleted) + + async def test_save_policy_softdelete(self): + """Test that save_policy handles soft delete correctly.""" + e = await self.get_enforcer() + engine = e.get_adapter()._engine + async_session = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession) + + # Turn off auto save + e.enable_auto_save(auto_save=False) + + # Get model and manipulate policies directly + model = e.get_model() + + # Delete some preexisting rules from model (not from DB yet) + model.remove_policy("p", "p", ["alice", "data1", "read"]) + model.remove_policy("p", "p", ["bob", "data2", "write"]) + + # Add some new rules to model (not in DB yet) + model.add_policy("p", "p", ["alice", "data100", "read"]) + model.add_policy("p", "p", ["bob", "data100", "write"]) + + # Write changes to database - this should soft-delete removed rules + await e.save_policy() + + async with async_session() as session: + # Check that deleted rules are marked as deleted + stmt1 = select(CasbinRuleSoftDelete).where( + CasbinRuleSoftDelete.ptype == "p", + CasbinRuleSoftDelete.v0 == "alice", + CasbinRuleSoftDelete.v1 == "data1", + CasbinRuleSoftDelete.v2 == "read" + ) + result1 = await session.execute(stmt1) + alice_data1 = result1.scalars().first() + self.assertIsNotNone(alice_data1) + self.assertTrue(alice_data1.is_deleted) + + stmt2 = select(CasbinRuleSoftDelete).where( + CasbinRuleSoftDelete.ptype == "p", + CasbinRuleSoftDelete.v0 == "bob", + CasbinRuleSoftDelete.v1 == "data2", + CasbinRuleSoftDelete.v2 == "write" + ) + result2 = await session.execute(stmt2) + bob_data2 = result2.scalars().first() + self.assertIsNotNone(bob_data2) + self.assertTrue(bob_data2.is_deleted) + + # Check that new rules are not deleted + stmt4 = select(CasbinRuleSoftDelete).where( + CasbinRuleSoftDelete.ptype == "p", + CasbinRuleSoftDelete.v0 == "alice", + CasbinRuleSoftDelete.v1 == "data100", + CasbinRuleSoftDelete.v2 == "read" + ) + result4 = await session.execute(stmt4) + alice_data100 = result4.scalars().first() + self.assertIsNotNone(alice_data100) + self.assertFalse(alice_data100.is_deleted) + + stmt5 = select(CasbinRuleSoftDelete).where( + CasbinRuleSoftDelete.ptype == "p", + CasbinRuleSoftDelete.v0 == "bob", + CasbinRuleSoftDelete.v1 == "data100", + CasbinRuleSoftDelete.v2 == "write" + ) + result5 = await session.execute(stmt5) + bob_data100 = result5.scalars().first() + self.assertIsNotNone(bob_data100) + self.assertFalse(bob_data100.is_deleted) + + async def test_enforcer_basic(self): + """Test that basic enforcement works with soft delete.""" + e = await self.get_enforcer() + self.assertTrue(e.enforce("alice", "data1", "read")) + self.assertFalse(e.enforce("alice", "data1", "write")) + self.assertFalse(e.enforce("bob", "data1", "read")) + self.assertFalse(e.enforce("bob", "data1", "write")) + self.assertTrue(e.enforce("bob", "data2", "write")) + self.assertFalse(e.enforce("bob", "data2", "read")) + self.assertTrue(e.enforce("alice", "data2", "read")) + self.assertTrue(e.enforce("alice", "data2", "write")) + + async def test_remove_policies_softdelete(self): + """Test that remove_policies works with soft delete.""" + e = await self.get_enforcer() + engine = e.get_adapter()._engine + async_session = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession) + + self.assertFalse(e.enforce("alice", "data5", "read")) + self.assertFalse(e.enforce("alice", "data6", "read")) + await e.add_policies((("alice", "data5", "read"), ("alice", "data6", "read"))) + self.assertTrue(e.enforce("alice", "data5", "read")) + self.assertTrue(e.enforce("alice", "data6", "read")) + + await e.remove_policies((("alice", "data5", "read"), ("alice", "data6", "read"))) + self.assertFalse(e.enforce("alice", "data5", "read")) + self.assertFalse(e.enforce("alice", "data6", "read")) + + # Verify soft deletion + async with async_session() as session: + stmt = select(CasbinRuleSoftDelete).where( + CasbinRuleSoftDelete.ptype == "p", + CasbinRuleSoftDelete.v0 == "alice" + ) + result = await session.execute(stmt) + all_alice_rules = result.scalars().all() + + # Find the soft-deleted rules + deleted_rules = [r for r in all_alice_rules if r.v1 in ["data5", "data6"] and r.is_deleted] + self.assertEqual(len(deleted_rules), 2) + + async def test_remove_filtered_policy_softdelete(self): + """Test that remove_filtered_policy works with soft delete.""" + e = await self.get_enforcer() + engine = e.get_adapter()._engine + async_session = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession) + + self.assertTrue(e.enforce("alice", "data1", "read")) + await e.remove_filtered_policy(1, "data1") + self.assertFalse(e.enforce("alice", "data1", "read")) + + # Verify soft deletion + async with async_session() as session: + stmt = select(CasbinRuleSoftDelete).where( + CasbinRuleSoftDelete.ptype == "p", + CasbinRuleSoftDelete.v1 == "data1" + ) + result = await session.execute(stmt) + data1_rules = result.scalars().all() + self.assertTrue(all(r.is_deleted for r in data1_rules)) + + async def test_invalid_softdelete_attribute_type(self): + """Test that invalid soft delete attribute type raises ValueError.""" + class InvalidRule(Base): + __tablename__ = "invalid_rule" + + id = Column(Integer, primary_key=True) + ptype = Column(String(255)) + v0 = Column(String(255)) + v1 = Column(String(255)) + v2 = Column(String(255)) + v3 = Column(String(255)) + v4 = Column(String(255)) + v5 = Column(String(255)) + is_deleted = Column(String(255)) # Wrong type - should be Boolean + + engine = create_async_engine("sqlite+aiosqlite://", future=True) + + with self.assertRaises(ValueError) as context: + adapter = Adapter(engine, InvalidRule, InvalidRule.is_deleted) + + self.assertIn("Boolean", str(context.exception)) + + +if __name__ == "__main__": + unittest.main()