Skip to content
Closed
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
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
119 changes: 98 additions & 21 deletions casbin_async_sqlalchemy_adapter/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, func
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
Expand All @@ -27,6 +27,8 @@


class CasbinRule(Base):
"""default casbinrule (not support soft delete)"""

__tablename__ = "casbin_rule"

id = Column(Integer, primary_key=True)
Expand Down Expand Up @@ -67,6 +69,7 @@ def __init__(
self,
engine,
db_class=None,
soft_delete=None,
filtered=False,
warning=True,
db_session: Optional[AsyncSession] = None,
Expand All @@ -76,6 +79,8 @@ def __init__(
else:
self._engine = engine

self.softdelete_attribute = None

if db_class is None:
db_class = CasbinRule
if warning:
Expand All @@ -85,6 +90,12 @@ 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",
Expand Down Expand Up @@ -130,6 +141,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(not_(self.softdelete_attribute)))
for line in lines.scalars():
persist.load_policy_line(str(line), model)

Expand All @@ -141,6 +154,8 @@ async def load_filtered_policy(self, model, filter) -> None:
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(not_(self.softdelete_attribute))
result = await session.execute(stmt)
for line in result.scalars():
persist.load_policy_line(str(line), model)
Expand Down Expand Up @@ -169,16 +184,54 @@ async def _save_policy_line(self, ptype, rule, session=None):

async def save_policy(self, model):
"""saves all policy rules to the storage."""
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
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)
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 def add_policy(self, sec, ptype, rule):
"""adds a policy rule to the storage."""
Expand All @@ -204,7 +257,11 @@ 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)
if self.softdelete_attribute:
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):
stmt = stmt.where(getattr(self._db_class, "v{}".format(i)) == v)
r = await session.execute(stmt)
Expand All @@ -216,18 +273,39 @@ 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)
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:
soft_delete_column = self.softdelete_attribute
stmt = (
update(self._db_class)
.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))

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:
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)

if not (0 <= field_index <= 5):
return False
Expand All @@ -238,8 +316,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:
"""
Expand Down
Empty file added tests/__init__.py
Empty file.
4 changes: 3 additions & 1 deletion tests/test_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
Expand All @@ -66,6 +67,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:
Expand Down
Loading