From 26a1ed8de10e1e997ca97c7589f952e80b9242a6 Mon Sep 17 00:00:00 2001 From: Ziyi Xia Date: Fri, 10 Oct 2025 20:09:28 +0800 Subject: [PATCH 1/5] Feat: soft deletion --- casbin_async_sqlalchemy_adapter/adapter.py | 104 ++++++++++++++++----- tests/test_adapter.py | 9 +- 2 files changed, 88 insertions(+), 25 deletions(-) diff --git a/casbin_async_sqlalchemy_adapter/adapter.py b/casbin_async_sqlalchemy_adapter/adapter.py index e919a97..9963666 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, DateTime, delete, insert, update +from sqlalchemy import or_, not_, and_ from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession from sqlalchemy.future import select from sqlalchemy.orm import declarative_base, sessionmaker @@ -37,6 +37,7 @@ class CasbinRule(Base): v3 = Column(String(255)) v4 = Column(String(255)) v5 = Column(String(255)) + is_deleted = Column(Boolean, default=False, nullable=False) def __str__(self): arr = [self.ptype] @@ -67,6 +68,7 @@ def __init__( self, engine, db_class=None, + soft_delete=False, filtered=False, warning=True, db_session: Optional[AsyncSession] = None, @@ -76,6 +78,8 @@ def __init__( else: self._engine = engine + self.softdelete_attribute = False + if db_class is None: db_class = CasbinRule if warning: @@ -85,6 +89,14 @@ def __init__( RuntimeWarning, ) else: + if soft_delete is not None and not isinstance( + soft_delete.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(soft_delete.type))!r} was given." + raise ValueError(msg) + # Softdelete is only supported when using custom class + self.softdelete_attribute = soft_delete for attr in ( "id", "ptype", @@ -130,6 +142,8 @@ 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)) + if self.softdelete_attribute: + lines = await session.execute(select(self._db_class).where((self._db_class.is_deleted == False))) for line in lines.scalars(): persist.load_policy_line(str(line), model) @@ -137,10 +151,11 @@ def is_filtered(self): return self._filtered 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.filter_query(stmt, filter) + if self.softdelete_attribute: + stmt = stmt.where((self._db_class.is_deleted == False)) result = await session.execute(stmt) for line in result.scalars(): persist.load_policy_line(str(line), model) @@ -169,15 +184,44 @@ async def _save_policy_line(self, ptype, rule, session=None): async def save_policy(self, model): """saves all policy rules to the storage.""" + if self.softdelete_attribute == 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 + async with self._session_scope() as session: - stmt = delete(self._db_class) - await session.execute(stmt) + stmt = select(self._db_class).where(not_(self._db_class.is_deleted)) + result = await session.execute(stmt) + db_rules = result.scalars().all() + db_rule_set = {str(rule) for rule in db_rules} + + model_rules_to_add = [] + model_rule_set = set() 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) + rule_str = ", ".join([ptype] + list(rule)) + model_rule_set.add(rule_str) + if rule_str not in db_rule_set: + model_rules_to_add.append((ptype, rule)) + + for ptype, rule in model_rules_to_add: + await self._save_policy_line(ptype, rule, session) + + for db_rule in db_rules: + if str(db_rule) not in model_rule_set: + db_rule.is_deleted = True + session.add(db_rule) + return True async def add_policy(self, sec, ptype, rule): @@ -185,7 +229,6 @@ async def add_policy(self, sec, ptype, rule): await self._save_policy_line(ptype, rule) async def add_policies(self, sec, ptype, rules): - """adds a policy rules to the storage.""" if not rules: return @@ -202,9 +245,11 @@ async def add_policies(self, sec, ptype, rules): await session.execute(stmt, rows) 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) + if self.softdelete_attribute: + stmt = update(self._db_class).where(self._db_class.ptype == ptype).value(is_deleted=True) + else: + 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) @@ -212,22 +257,40 @@ async def remove_policy(self, sec, ptype, rule): return True if r.rowcount > 0 else False async def remove_policies(self, sec, ptype, rules): - """remove policy rules from the storage.""" 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) + conditions = [] + for rule in rules: + rule_conditions = [ + getattr(self._db_class, f"v{i}") == v for i, v in enumerate(rule) + ] + conditions.append(and_(*rule_conditions)) + + if self.softdelete_attribute: + stmt = update(self._db_class).where( + self._db_class.ptype == ptype, + self._db_class.is_deleted == False, + or_(*conditions) + ).values(is_deleted=True) + else: + stmt = delete(self._db_class).where( + self._db_class.ptype == ptype, + or_(*conditions) + ) + + result = await session.execute(stmt) + return result.rowcount > 0 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 self.softdelete_attribute: + stmt = update(self._db_class) \ + .where(self._db_class.ptype == ptype \ + and self._db_class.is_deleted == False)\ + .value(is_deleted=True) + else: + stmt = delete(self._db_class).where(self._db_class.ptype == ptype) if not (0 <= field_index <= 5): return False @@ -238,8 +301,7 @@ async def remove_filtered_policy(self, sec, ptype, field_index, *field_values): v_value = getattr(self._db_class, "v{}".format(field_index + i)) stmt = stmt.where(v_value == v) r = await session.execute(stmt) - - return True if r.rowcount > 0 else False + return r.rowcount > 0 async def update_policy(self, sec: str, ptype: str, old_rule: List[str], new_rule: List[str]) -> None: """ diff --git a/tests/test_adapter.py b/tests/test_adapter.py index 72e2f09..60ccc83 100644 --- a/tests/test_adapter.py +++ b/tests/test_adapter.py @@ -17,7 +17,7 @@ from unittest import IsolatedAsyncioTestCase import casbin -from sqlalchemy import Column, Integer, String, select +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 @@ -31,11 +31,11 @@ def get_fixture(path): return os.path.abspath(dir_path + path) -async def get_enforcer(): +async def get_enforcer(soft_delete=False): engine = create_async_engine("sqlite+aiosqlite://", future=True) # engine = create_async_engine('sqlite+aiosqlite:///' + os.path.split(os.path.realpath(__file__))[0] + '/test.db', # echo=True) - adapter = Adapter(engine) + adapter = Adapter(engine, soft_delete=True) await adapter.create_table() async_session = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession) @@ -66,6 +66,7 @@ class CustomRule(Base): v4 = Column(String(255)) v5 = Column(String(255)) not_exist = Column(String(255)) + is_deleted = Column(Boolean, default=False, nullable=False) engine = create_async_engine("sqlite+aiosqlite://", future=True) async with engine.begin() as conn: @@ -419,4 +420,4 @@ async def test_add_policies_bulk_internal_session(self): if __name__ == "__main__": - unittest.main() + unittest.main() \ No newline at end of file From 363c76eeb67d448fd80b558a6a982b414465ed8b Mon Sep 17 00:00:00 2001 From: Ziyi Xia Date: Fri, 10 Oct 2025 21:00:18 +0800 Subject: [PATCH 2/5] Chore: Format code with black --- casbin_async_sqlalchemy_adapter/adapter.py | 32 ++++++++-------------- tests/test_adapter.py | 2 +- 2 files changed, 12 insertions(+), 22 deletions(-) diff --git a/casbin_async_sqlalchemy_adapter/adapter.py b/casbin_async_sqlalchemy_adapter/adapter.py index 9963666..0dbc717 100644 --- a/casbin_async_sqlalchemy_adapter/adapter.py +++ b/casbin_async_sqlalchemy_adapter/adapter.py @@ -89,9 +89,7 @@ def __init__( RuntimeWarning, ) else: - if soft_delete is not None and not isinstance( - soft_delete.type, Boolean - ): + if soft_delete is not None and not isinstance(soft_delete.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(soft_delete.type))!r} was given." raise ValueError(msg) @@ -195,7 +193,7 @@ async def save_policy(self, model): for rule in ast.policy: await self._save_policy_line(ptype, rule, session) return True - + async with self._session_scope() as session: stmt = select(self._db_class).where(not_(self._db_class.is_deleted)) result = await session.execute(stmt) @@ -262,33 +260,25 @@ async def remove_policies(self, sec, ptype, rules): async with self._session_scope() as session: conditions = [] for rule in rules: - rule_conditions = [ - getattr(self._db_class, f"v{i}") == v for i, v in enumerate(rule) - ] + rule_conditions = [getattr(self._db_class, f"v{i}") == v for i, v in enumerate(rule)] conditions.append(and_(*rule_conditions)) if self.softdelete_attribute: - stmt = update(self._db_class).where( - self._db_class.ptype == ptype, - self._db_class.is_deleted == False, - or_(*conditions) - ).values(is_deleted=True) - else: - stmt = delete(self._db_class).where( - self._db_class.ptype == ptype, - or_(*conditions) + stmt = ( + update(self._db_class) + .where(self._db_class.ptype == ptype, self._db_class.is_deleted == False, or_(*conditions)) + .values(is_deleted=True) ) - + else: + stmt = delete(self._db_class).where(self._db_class.ptype == ptype, or_(*conditions)) + result = await session.execute(stmt) return result.rowcount > 0 async def remove_filtered_policy(self, sec, ptype, field_index, *field_values): async with self._session_scope() as session: if self.softdelete_attribute: - stmt = update(self._db_class) \ - .where(self._db_class.ptype == ptype \ - and self._db_class.is_deleted == False)\ - .value(is_deleted=True) + stmt = update(self._db_class).where(self._db_class.ptype == ptype and self._db_class.is_deleted == False).value(is_deleted=True) else: stmt = delete(self._db_class).where(self._db_class.ptype == ptype) diff --git a/tests/test_adapter.py b/tests/test_adapter.py index 60ccc83..b32503a 100644 --- a/tests/test_adapter.py +++ b/tests/test_adapter.py @@ -420,4 +420,4 @@ async def test_add_policies_bulk_internal_session(self): if __name__ == "__main__": - unittest.main() \ No newline at end of file + unittest.main() From 8ec044a2873f1902d89d7c9b2491307afac81a41 Mon Sep 17 00:00:00 2001 From: Ziyi Xia Date: Sat, 11 Oct 2025 15:16:03 +0800 Subject: [PATCH 3/5] Feat: Soft delete with test and readme updated. --- README.md | 17 ++ casbin_async_sqlalchemy_adapter/adapter.py | 100 ++++++---- tests/__init__.py | 0 tests/test_adapter.py | 5 +- tests/test_adapter_softdelete.py | 201 +++++++++++++++++++++ 5 files changed, 282 insertions(+), 41 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/test_adapter_softdelete.py diff --git a/README.md b/README.md index 2c89798..ff1f96a 100644 --- a/README.md +++ b/README.md @@ -131,7 +131,24 @@ async with async_session() as session: # Commit the transaction await session.commit() ``` +## Soft Delete +Soft Delete for casbin rules is supported, only when using a custom casbin rule model. +The Soft Delete mechanism is enabled by passing the attribute of the flag indicating whether +a rule is deleted to `soft_delete`. +That attribute needs to be of type `sqlalchemy.Boolean`. + +```python +adapter = Adapter( + engine, + db_class=MyCustomCasbinRuleModel, + soft_delete=MyCustomCasbinRuleModel.is_deleted +) +``` + +Please be aware that this adapter only sets a flag like `is_deleted` to `True`. +The provided model needs to handle the update of fields like `deleted_by`, `deleted_at`, etc. +An example for this is given in [softdelete.py](https://github.com/pycasbin/sqlalchemy-adapter/blob/master/examples/softdelete.py). ### Getting Help diff --git a/casbin_async_sqlalchemy_adapter/adapter.py b/casbin_async_sqlalchemy_adapter/adapter.py index 0dbc717..8ecd78f 100644 --- a/casbin_async_sqlalchemy_adapter/adapter.py +++ b/casbin_async_sqlalchemy_adapter/adapter.py @@ -17,7 +17,7 @@ from casbin import persist from casbin.persist.adapters.asyncio import AsyncAdapter -from sqlalchemy import Column, Integer, String, Boolean, DateTime, delete, insert, update +from sqlalchemy import Column, Integer, String, Boolean, delete, insert, update, func from sqlalchemy import or_, not_, and_ from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession from sqlalchemy.future import select @@ -27,6 +27,8 @@ class CasbinRule(Base): + """default casbinrule (not support soft delete)""" + __tablename__ = "casbin_rule" id = Column(Integer, primary_key=True) @@ -37,7 +39,6 @@ class CasbinRule(Base): v3 = Column(String(255)) v4 = Column(String(255)) v5 = Column(String(255)) - is_deleted = Column(Boolean, default=False, nullable=False) def __str__(self): arr = [self.ptype] @@ -68,7 +69,7 @@ def __init__( self, engine, db_class=None, - soft_delete=False, + soft_delete=None, filtered=False, warning=True, db_session: Optional[AsyncSession] = None, @@ -78,7 +79,7 @@ def __init__( else: self._engine = engine - self.softdelete_attribute = False + self.softdelete_attribute = None if db_class is None: db_class = CasbinRule @@ -141,7 +142,7 @@ async def load_policy(self, model): async with self._session_scope() as session: lines = await session.execute(select(self._db_class)) if self.softdelete_attribute: - lines = await session.execute(select(self._db_class).where((self._db_class.is_deleted == False))) + lines = await session.execute(select(self._db_class).where(not_(self.softdelete_attribute))) for line in lines.scalars(): persist.load_policy_line(str(line), model) @@ -153,7 +154,7 @@ async def load_filtered_policy(self, model, filter) -> None: stmt = select(self._db_class) stmt = self.filter_query(stmt, filter) if self.softdelete_attribute: - stmt = stmt.where((self._db_class.is_deleted == False)) + stmt = stmt.where(not_(self.softdelete_attribute)) result = await session.execute(stmt) for line in result.scalars(): persist.load_policy_line(str(line), model) @@ -182,7 +183,44 @@ async def _save_policy_line(self, ptype, rule, session=None): async def save_policy(self, model): """saves all policy rules to the storage.""" - if self.softdelete_attribute == None: + if self.softdelete_attribute: + async with self._session_scope() as session: + # Fetch all currently active rules from the database. + soft_delete_column = getattr(self._db_class, self.softdelete_attribute.name) + stmt = select(self._db_class).where(not_(soft_delete_column)) + result = await session.execute(stmt) + lines_before_changes = result.scalars().all() + + print(lines_before_changes) + + # Add new rules. + 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: + filter_stmt = select(self._db_class).where(self._db_class.ptype == ptype) + for index, value in enumerate(rule): + v_column = getattr(self._db_class, f"v{index}") + filter_stmt = filter_stmt.where(v_column == value) + + count_stmt = select(func.count()).select_from(filter_stmt.subquery()) + count_result = await session.execute(count_stmt) + + if count_result.scalar_one() == 0: + await self._save_policy_line(ptype, rule, session=session) + + # Mark old rules as deleted. + for line in lines_before_changes: + ptype = line.ptype + sec = ptype[0] + rule_parts = [v for v in (line.v0, line.v1, line.v2, line.v3, line.v4, line.v5) if v is not None] + + if not model.has_policy(sec, ptype, rule_parts): + setattr(line, self.softdelete_attribute.name, True) + + return True + else: async with self._session_scope() as session: stmt = delete(self._db_class) await session.execute(stmt) @@ -194,39 +232,12 @@ async def save_policy(self, model): await self._save_policy_line(ptype, rule, session) return True - async with self._session_scope() as session: - stmt = select(self._db_class).where(not_(self._db_class.is_deleted)) - result = await session.execute(stmt) - db_rules = result.scalars().all() - db_rule_set = {str(rule) for rule in db_rules} - - model_rules_to_add = [] - model_rule_set = set() - 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: - rule_str = ", ".join([ptype] + list(rule)) - model_rule_set.add(rule_str) - if rule_str not in db_rule_set: - model_rules_to_add.append((ptype, rule)) - - for ptype, rule in model_rules_to_add: - await self._save_policy_line(ptype, rule, session) - - for db_rule in db_rules: - if str(db_rule) not in model_rule_set: - db_rule.is_deleted = True - session.add(db_rule) - - return True - async def add_policy(self, sec, ptype, rule): """adds a policy rule to the storage.""" await self._save_policy_line(ptype, rule) async def add_policies(self, sec, ptype, rules): + """adds a policy rules to the storage.""" if not rules: return @@ -243,9 +254,11 @@ async def add_policies(self, sec, ptype, rules): await session.execute(stmt, rows) async def remove_policy(self, sec, ptype, rule): + """removes a policy rule from the storage.""" async with self._session_scope() as session: if self.softdelete_attribute: - stmt = update(self._db_class).where(self._db_class.ptype == ptype).value(is_deleted=True) + soft_delete_column = self.softdelete_attribute + stmt = update(self._db_class).where(self._db_class.ptype == ptype).values({soft_delete_column.name: True}) else: stmt = delete(self._db_class).where(self._db_class.ptype == ptype) for i, v in enumerate(rule): @@ -255,6 +268,7 @@ async def remove_policy(self, sec, ptype, rule): return True if r.rowcount > 0 else False async def remove_policies(self, sec, ptype, rules): + """remove policy rules from the storage.""" if not rules: return async with self._session_scope() as session: @@ -264,10 +278,11 @@ async def remove_policies(self, sec, ptype, rules): conditions.append(and_(*rule_conditions)) if self.softdelete_attribute: + soft_delete_column = self.softdelete_attribute stmt = ( update(self._db_class) - .where(self._db_class.ptype == ptype, self._db_class.is_deleted == False, or_(*conditions)) - .values(is_deleted=True) + .where(self._db_class.ptype == ptype, not_(soft_delete_column), or_(*conditions)) + .values({self.softdelete_attribute.name: True}) ) else: stmt = delete(self._db_class).where(self._db_class.ptype == ptype, or_(*conditions)) @@ -276,9 +291,16 @@ async def remove_policies(self, sec, ptype, rules): return result.rowcount > 0 async def remove_filtered_policy(self, sec, ptype, field_index, *field_values): + """removes policy rules that match the filter from the storage.""" async with self._session_scope() as session: if self.softdelete_attribute: - stmt = update(self._db_class).where(self._db_class.ptype == ptype and self._db_class.is_deleted == False).value(is_deleted=True) + soft_delete_column = self.softdelete_attribute + stmt = ( + update(self._db_class) + .where(self._db_class.ptype == ptype) + .where(not_(soft_delete_column)) + .values({soft_delete_column.name: True}) + ) else: stmt = delete(self._db_class).where(self._db_class.ptype == ptype) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_adapter.py b/tests/test_adapter.py index b32503a..0164181 100644 --- a/tests/test_adapter.py +++ b/tests/test_adapter.py @@ -31,11 +31,11 @@ def get_fixture(path): return os.path.abspath(dir_path + path) -async def get_enforcer(soft_delete=False): +async def get_enforcer(): engine = create_async_engine("sqlite+aiosqlite://", future=True) # engine = create_async_engine('sqlite+aiosqlite:///' + os.path.split(os.path.realpath(__file__))[0] + '/test.db', # echo=True) - adapter = Adapter(engine, soft_delete=True) + adapter = Adapter(engine) await adapter.create_table() async_session = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession) @@ -56,6 +56,7 @@ class TestConfig(IsolatedAsyncioTestCase): async def test_custom_db_class(self): class CustomRule(Base): __tablename__ = "casbin_rule2" + __table_args__ = {"extend_existing": True} id = Column(Integer, primary_key=True) ptype = Column(String(255)) diff --git a/tests/test_adapter_softdelete.py b/tests/test_adapter_softdelete.py new file mode 100644 index 0000000..385442a --- /dev/null +++ b/tests/test_adapter_softdelete.py @@ -0,0 +1,201 @@ +import os +from pathlib import Path + +import casbin +from sqlalchemy import Column, Boolean, Integer, String, select +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker + +from casbin_async_sqlalchemy_adapter import Adapter +from casbin_async_sqlalchemy_adapter import Base +from casbin_async_sqlalchemy_adapter.adapter import Filter +from casbin_async_sqlalchemy_adapter import CasbinRule + +from tests.test_adapter import TestConfig + + +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, 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 query_for_rule(adaper, ptype, v0, v1, v2): + rule_filter = Filter() + rule_filter.ptype = [ptype] + rule_filter.v0 = [v0] + rule_filter.v1 = [v1] + rule_filter.v2 = [v2] + + stmt = select(CasbinRuleSoftDelete) + stmt = adaper.filter_query(stmt, rule_filter) + return stmt + + +class TestConfigSoftDelete(TestConfig): + def setUp(self): + """ensure a clean state by deleting the old database file""" + db_file = "./test.db" + if os.path.exists(db_file): + os.remove(db_file) + + def tearDown(self): + """clean up by deleting the database file""" + db_file = "./test.db" + if os.path.exists(db_file): + os.remove(db_file) + + async def get_enforcer(self): + engine = create_async_engine("sqlite+aiosqlite:///./test.db", future=True) + adapter = Adapter(engine, db_class=CasbinRuleSoftDelete, soft_delete=CasbinRuleSoftDelete.is_deleted) + await adapter.create_table() + + async_session_maker = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession) + async with async_session_maker() as s: + 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() + + scriptdir = Path(os.path.dirname(os.path.realpath(__file__))) + model_path = scriptdir / "rbac_model.conf" + e = casbin.AsyncEnforcer(str(model_path), adapter) + await e.load_policy() + + return e + + async def test_custom_db_class(self): + 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) + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + async_session_maker = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession) + async with async_session_maker() as s: + s.add(CustomRule(not_exist="NotNone")) + await s.commit() + a = await s.execute(select(CustomRule)) + self.assertEqual(a.scalars().all()[0].not_exist, "NotNone") + + async def test_softdelete_flag(self): + e = await self.get_enforcer() + async_session_maker = e.adapter.session_local + + self.assertFalse(e.enforce("alice", "data5", "read")) + + async with async_session_maker() as session: + stmt = query_for_rule(e.adapter, "p", "alice", "data5", "read") + result = await session.execute(stmt) + rule = result.scalars().first() + self.assertIsNone(rule) + + await e.add_permission_for_user("alice", "data5", "read") + self.assertTrue(e.enforce("alice", "data5", "read")) + async with async_session_maker() as session: + stmt = query_for_rule(e.adapter, "p", "alice", "data5", "read") + result = await session.execute(stmt) + rule = result.scalars().first() + self.assertIsNotNone(rule) + self.assertFalse(rule.is_deleted) + + await e.delete_permission_for_user("alice", "data5", "read") + self.assertFalse(e.enforce("alice", "data5", "read")) + async with async_session_maker() as session: + stmt = query_for_rule(e.adapter, "p", "alice", "data5", "read") + result = await session.execute(stmt) + rule = result.scalars().first() + self.assertIsNotNone(rule) + self.assertTrue(rule.is_deleted) + + async def test_save_policy_softdelete(self): + e = await self.get_enforcer() + async_session_maker = e.adapter.session_local + + # Turn off auto save + e.enable_auto_save(auto_save=False) + + # Delete some preexisting rules + await e.delete_permission_for_user("alice", "data1", "read") + await e.delete_permission_for_user("bob", "data2", "write") + # Delete a non existing rule + await e.delete_permission_for_user("bob", "data100", "read") + # Add some new rules + await e.add_permission_for_user("alice", "data100", "read") + await e.add_permission_for_user("bob", "data100", "write") + + # Write changes to database + await e.save_policy() + + # Check1: ("alice", "data1", "read") should be marked as deleted + async with async_session_maker() as session: + stmt = query_for_rule(e.adapter, "p", "alice", "data1", "read") + result = await session.execute(stmt) + rule = result.scalars().first() + self.assertIsNotNone(rule) + self.assertTrue(rule.is_deleted) + + # Check2: ("bob", "data2", "write") should be marked as deleted + async with async_session_maker() as session: + stmt = query_for_rule(e.adapter, "p", "bob", "data2", "write") + result = await session.execute(stmt) + rule = result.scalars().first() + self.assertIsNotNone(rule) + self.assertTrue(rule.is_deleted) + + # Check3: ("bob", "data100", "read") should not exist + async with async_session_maker() as session: + stmt = query_for_rule(e.adapter, "p", "bob", "data100", "read") + result = await session.execute(stmt) + rule = result.scalars().first() + self.assertIsNone(rule) + + # Check4: ("alice", "data100", "read") should exist and not be deleted + async with async_session_maker() as session: + stmt = query_for_rule(e.adapter, "p", "alice", "data100", "read") + result = await session.execute(stmt) + rule = result.scalars().first() + self.assertIsNotNone(rule) + self.assertFalse(rule.is_deleted) + + # Check5: ("bob", "data100", "write") should exist and not be deleted + async with async_session_maker() as session: + stmt = query_for_rule(e.adapter, "p", "bob", "data100", "write") + result = await session.execute(stmt) + rule = result.scalars().first() + self.assertIsNotNone(rule) + self.assertFalse(rule.is_deleted) From 205e77aa631a4fdff0a22ddcc42644d4f55e614a Mon Sep 17 00:00:00 2001 From: Ziyi Xia Date: Sat, 11 Oct 2025 15:17:46 +0800 Subject: [PATCH 4/5] Feat: Soft delete with test and readme updated. --- casbin_async_sqlalchemy_adapter/adapter.py | 1 + 1 file changed, 1 insertion(+) diff --git a/casbin_async_sqlalchemy_adapter/adapter.py b/casbin_async_sqlalchemy_adapter/adapter.py index 8ecd78f..88ca0ba 100644 --- a/casbin_async_sqlalchemy_adapter/adapter.py +++ b/casbin_async_sqlalchemy_adapter/adapter.py @@ -150,6 +150,7 @@ def is_filtered(self): return self._filtered 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.filter_query(stmt, filter) From 44e59d9ce6dce8fd49e44d58097e64dbda78867a Mon Sep 17 00:00:00 2001 From: Ziyi Xia Date: Sat, 11 Oct 2025 15:20:22 +0800 Subject: [PATCH 5/5] Feat: Soft delete with test and readme updated. --- casbin_async_sqlalchemy_adapter/adapter.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/casbin_async_sqlalchemy_adapter/adapter.py b/casbin_async_sqlalchemy_adapter/adapter.py index 88ca0ba..3feb66d 100644 --- a/casbin_async_sqlalchemy_adapter/adapter.py +++ b/casbin_async_sqlalchemy_adapter/adapter.py @@ -292,7 +292,9 @@ async def remove_policies(self, sec, ptype, rules): return result.rowcount > 0 async def remove_filtered_policy(self, sec, ptype, field_index, *field_values): - """removes policy rules that match the filter from the storage.""" + """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: if self.softdelete_attribute: soft_delete_column = self.softdelete_attribute