Skip to content

Conversation

Copy link
Contributor

Copilot AI commented Nov 23, 2025

Soft Deletion Support Implementation ✅

Based on sqlalchemy-adapter PR #72, I've successfully implemented soft deletion support for the async-sqlalchemy-adapter.

Implementation Checklist:

  • Understand existing code structure and soft deletion pattern from sqlalchemy-adapter
  • Add soft deletion parameter support to Adapter constructor (db_class_softdelete_attribute)
  • Implement _softdelete_query() helper method to filter out deleted records
  • Update load_policy() to use soft delete filtering
  • Update load_filtered_policy() to use soft delete filtering
  • Update save_policy() to support soft delete logic (set flag instead of deleting)
  • Update remove_policy() to set deletion flag instead of deleting
  • Update remove_policies() to set deletion flag instead of deleting
  • Update remove_filtered_policy() to set deletion flag instead of deleting
  • Update update_policy() to filter out soft deleted records
  • Update _update_filtered_policies() to filter out soft deleted records
  • Create comprehensive test file for soft deletion (test_adapter_softdelete.py)
  • Run tests to verify all changes work correctly (28 tests passing)
  • Update README with soft deletion documentation
  • Address code review feedback
  • Run security checks (0 vulnerabilities found)
  • Fix Black formatter linting issues
  • Finalize implementation

Summary

This PR adds soft deletion support to the async-sqlalchemy-adapter, enabling users to mark database records as deleted instead of physically removing them. This is particularly useful for audit trails, compliance requirements, and undo functionality.

Key Changes:

  1. Adapter constructor: Added optional db_class_softdelete_attribute parameter to accept a Boolean column attribute for soft delete functionality
  2. Query filtering: Added _softdelete_query() helper method that uses SQLAlchemy's not_() operator to filter out soft-deleted records
  3. Delete operations: Modified remove_policy(), remove_policies(), and remove_filtered_policy() to set deletion flags instead of hard deleting when soft delete is enabled
  4. Save policy: Implemented special logic for soft delete mode where removed rules are marked as deleted rather than recreated
  5. Load operations: Updated load_policy() and load_filtered_policy() to automatically filter out soft-deleted records
  6. Update operations: Modified update_policy() and _update_filtered_policies() to work correctly with soft delete filtering
  7. Tests: Created comprehensive test suite with 9 async tests in test_adapter_softdelete.py covering all soft deletion scenarios
  8. Documentation: Added detailed soft deletion section to README with usage examples and explanations
  9. Code quality: Fixed database object to rule list conversion bug and improved variable naming consistency
  10. Formatting: Applied Black formatter to ensure code style consistency

Testing

  • ✅ All 19 existing tests pass
  • ✅ 9 new soft deletion tests added and passing
  • ✅ Total: 28 tests passing
  • ✅ CodeQL security scan: 0 vulnerabilities
  • ✅ Black formatter: All files properly formatted

Backward Compatibility

This feature is fully backward compatible. When db_class_softdelete_attribute is not provided, the adapter continues to use hard deletion as before. Existing code requires no changes.

Security

CodeQL security analysis completed with zero vulnerabilities found.

Original prompt

This section details on the original issue you should resolve

<issue_title>Is there a plan to support soft deletion?</issue_title>
<issue_description>Is there a plan to support soft deletion? Similar to the soft deletion feature in sqlalchemy-adapter (PR: pycasbin/sqlalchemy-adapter#72)?</issue_description>

Comments on the Issue (you are @copilot in this section)

@hsluoyz @pingzonglangji why close?

💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more Copilot coding agent tips in the docs.

@nomeguy nomeguy marked this pull request as ready for review November 23, 2025 07:08
Copilot AI and others added 3 commits November 23, 2025 07:11
Co-authored-by: nomeguy <85475922+nomeguy@users.noreply.github.com>
Co-authored-by: nomeguy <85475922+nomeguy@users.noreply.github.com>
…variable naming

Co-authored-by: nomeguy <85475922+nomeguy@users.noreply.github.com>
Copilot AI changed the title [WIP] Add support for soft deletion feature Add soft deletion support for async-sqlalchemy-adapter Nov 23, 2025
Copilot finished work on behalf of nomeguy November 23, 2025 07:19
Copilot AI requested a review from nomeguy November 23, 2025 07:19
@nomeguy
Copy link

nomeguy commented Nov 23, 2025

@copilot fix failed linter check:

Run github/super-linter@v4.2.2
/usr/bin/docker run --name ghcriogithubsuperlinterv422_976938 --label 1d1b0d --workdir /github/workspace --rm -e "VALIDATE_PYTHON_BLACK" -e "DEFAULT_BRANCH" -e "GITHUB_TOKEN" -e "LINTER_RULES_PATH" -e "PYTHON_BLACK_CONFIG_FILE" -e "HOME" -e "GITHUB_JOB" -e "GITHUB_REF" -e "GITHUB_SHA" -e "GITHUB_REPOSITORY" -e "GITHUB_REPOSITORY_OWNER" -e "GITHUB_REPOSITORY_OWNER_ID" -e "GITHUB_RUN_ID" -e "GITHUB_RUN_NUMBER" -e "GITHUB_RETENTION_DAYS" -e "GITHUB_RUN_ATTEMPT" -e "GITHUB_ACTOR_ID" -e "GITHUB_ACTOR" -e "GITHUB_WORKFLOW" -e "GITHUB_HEAD_REF" -e "GITHUB_BASE_REF" -e "GITHUB_EVENT_NAME" -e "GITHUB_SERVER_URL" -e "GITHUB_API_URL" -e "GITHUB_GRAPHQL_URL" -e "GITHUB_REF_NAME" -e "GITHUB_REF_PROTECTED" -e "GITHUB_REF_TYPE" -e "GITHUB_WORKFLOW_REF" -e "GITHUB_WORKFLOW_SHA" -e "GITHUB_REPOSITORY_ID" -e "GITHUB_TRIGGERING_ACTOR" -e "GITHUB_WORKSPACE" -e "GITHUB_ACTION" -e "GITHUB_EVENT_PATH" -e "GITHUB_ACTION_REPOSITORY" -e "GITHUB_ACTION_REF" -e "GITHUB_PATH" -e "GITHUB_ENV" -e "GITHUB_STEP_SUMMARY" -e "GITHUB_STATE" -e "GITHUB_OUTPUT" -e "RUNNER_OS" -e "RUNNER_ARCH" -e "RUNNER_NAME" -e "RUNNER_ENVIRONMENT" -e "RUNNER_TOOL_CACHE" -e "RUNNER_TEMP" -e "RUNNER_WORKSPACE" -e "ACTIONS_RUNTIME_URL" -e "ACTIONS_RUNTIME_TOKEN" -e "ACTIONS_CACHE_URL" -e "ACTIONS_RESULTS_URL" -e GITHUB_ACTIONS=true -e CI=true -v "/var/run/docker.sock":"/var/run/docker.sock" -v "/home/runner/work/_temp/_github_home":"/github/home" -v "/home/runner/work/_temp/_github_workflow":"/github/workflow" -v "/home/runner/work/_temp/_runner_file_commands":"/github/file_commands" -v "/home/runner/work/async-sqlalchemy-adapter/async-sqlalchemy-adapter":"/github/workspace" ghcr.io/github/super-linter:v4.2.2
--------------------------------------------------------------------------------

                              /@@#///////@@/(@//@%/(@.@(       @@
                          @@//////////////////////////////#*  @@@
                        @////@//(///////////@@@@@///@//@/@**//@@(
                      @///////@///////////////@@@@    (           @,
                     @/(&/@////////////////////                     @
                    @////////////////////////@@                      @
                  @%////////(//////////%/////&@            @@       *,@           ______________
             @@@@@/@/#/////(&//////////////////                       .@         /              \
        *@@@@@.    .%///(//@//////////////////&.   .@@,                 @%      / Don't mind me  \
      @@%           .&@&&/@.@//&/////(//////////    @@@@@@@@@         .. &@    / I'm just looking \
    @@%               @@@@@   @&/////////////////#   @/       V  @@/ ,@@@ @   <  for some trash... |
@@@%                   @@@@        .%@@@@//////#@ @   @@         @     .,.     \__________________/
                                          @@@/@(  (@@@@% @/\      %
                                           @@@@(    .     .@@/\   #
                                             @                  %@%

--------------------------------------------------------------------------------
2025-11-23 07:31:20 [INFO]   ---------------------------------------------
2025-11-23 07:31:20 [INFO]   --- GitHub Actions Multi Language Linter ----
2025-11-23 07:31:20 [INFO]    - Image Creation Date:[2021-06-10T18:15:15Z]
2025-11-23 07:31:20 [INFO]    - Image Revision:[c4280ea4e19a1d73ba41dbf44ea5de0b0dc165ce]
2025-11-23 07:31:20 [INFO]    - Image Version:[c4280ea4e19a1d73ba41dbf44ea5de0b0dc165ce]
2025-11-23 07:31:20 [INFO]   ---------------------------------------------
2025-11-23 07:31:20 [INFO]   ---------------------------------------------
2025-11-23 07:31:20 [INFO]   The Super-Linter source code can be found at:
2025-11-23 07:31:20 [INFO]    - https://github.com/github/super-linter
2025-11-23 07:31:20 [INFO]   ---------------------------------------------
2025-11-23 07:31:20 [INFO]   --------------------------------------------
2025-11-23 07:31:20 [INFO]   Gathering GitHub information...
2025-11-23 07:31:20 [INFO]   Successfully found:[GITHUB_SHA], value:[67ef788bf0cb0da1321c4371ed9da7d8ceacc27d]
2025-11-23 07:31:20 [INFO]   Successfully found:[GITHUB_WORKSPACE], value:[/github/workspace]
2025-11-23 07:31:20 [INFO]   Successfully found:[GITHUB_EVENT_PATH], value:[/github/workflow/event.json]
2025-11-23 07:31:20 [INFO]   Successfully found:[GITHUB_ORG], value:[officialpycasbin]
2025-11-23 07:31:20 [INFO]   Successfully found:[GITHUB_REPO], value:[async-sqlalchemy-adapter]
2025-11-23 07:31:20 [INFO]   Successfully found:[GITHUB_TOKEN]
2025-11-23 07:31:20 [INFO]   Successfully found:[GITHUB_REPOSITORY], value:[officialpycasbin/async-sqlalchemy-adapter]
2025-11-23 07:31:20 [INFO]   Successfully found:[GITHUB_RUN_ID], value:[19607689567]
2025-11-23 07:31:20 [INFO]   --------------------------------------------
2025-11-23 07:31:20 [INFO]   Gathering user validation information...
2025-11-23 07:31:20 [INFO]   - Validating ALL files in code base...
2025-11-23 07:31:22 [INFO]   ----------------------------------------------
2025-11-23 07:31:22 [INFO]   User provided file:[/github/workspace/pyproject.toml] exists, setting rules file...
2025-11-23 07:31:23 [INFO]   ---------------------------------
2025-11-23 07:31:23 [INFO]   ------ File list to check: ------
2025-11-23 07:31:23 [INFO]   ---------------------------------
2025-11-23 07:31:24 [INFO]   ----------------------------------------------
2025-11-23 07:31:24 [INFO]   Successfully gathered list of files...
2025-11-23 07:31:24 [INFO]   
2025-11-23 07:31:24 [INFO]   ----------------------------------------------
2025-11-23 07:31:24 [INFO]   ----------------------------------------------
2025-11-23 07:31:24 [INFO]   Linting [PYTHON_BLACK] files...
2025-11-23 07:31:24 [INFO]   ----------------------------------------------
2025-11-23 07:31:24 [INFO]   ----------------------------------------------
2025-11-23 07:31:24 [INFO]   ---------------------------
2025-11-23 07:31:24 [INFO]   File:[/github/workspace/casbin_async_sqlalchemy_adapter/__init__.py]
2025-11-23 07:31:24 [INFO]    - File:[__init__.py] was linted with [black] successfully
2025-11-23 07:31:25 [INFO]   ---------------------------
2025-11-23 07:31:25 [INFO]   File:[/github/workspace/casbin_async_sqlalchemy_adapter/adapter.py]
2025-11-23 07:31:25 [ERROR]   Found errors in [black] linter!
2025-11-23 07:31:25 [ERROR]   Error code: 1. Command output:
------
would reformat /github/workspace/casbin_async_sqlalchemy_adapter/adapter.py
Oh no! 💥 💔 💥
1 file would be reformatted.
--- /github/workspace/casbin_async_sqlalchemy_adapter/adapter.py	2025-11-23 07:31:19.896199 +0000
+++ /github/workspace/casbin_async_sqlalchemy_adapter/adapter.py	2025-11-23 07:31:25.494888 +0000
@@ -78,13 +78,11 @@
         self.softdelete_attribute = None
 
         if db_class is None:
             db_class = CasbinRule
         else:
-            if db_class_softdelete_attribute is not None and not isinstance(
-                db_class_softdelete_attribute.type, Boolean
-            ):
+            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
------
2025-11-23 07:31:25 [INFO]   ---------------------------
2025-11-23 07:31:25 [INFO]   File:[/github/workspace/tests/test_adapter.py]
2025-11-23 07:31:25 [INFO]    - File:[test_adapter.py] was linted with [black] successfully
2025-11-23 07:31:25 [INFO]   ---------------------------
2025-11-23 07:31:25 [INFO]   File:[/github/workspace/tests/test_adapter_softdelete.py]
2025-11-23 07:31:26 [ERROR]   Found errors in [black] linter!
2025-11-23 07:31:26 [ERROR]   Error code: 1. Command output:
------
--- /github/workspace/tests/test_adapter_softdelete.py	2025-11-23 07:31:19.897199 +0000
+++ /github/workspace/tests/test_adapter_softdelete.py	2025-11-23 07:31:26.396031 +0000
@@ -91,10 +91,11 @@
         await e.load_policy()
         return e
 
     async def test_custom_db_class(self):
         """Test that custom database class with softdelete works."""
+
         class CustomRule(Base):
             __tablename__ = "casbin_rule3"
             __table_args__ = {"extend_existing": True}
 
             id = Column(Integer, primary_key=True)
@@ -123,30 +124,30 @@
 
     async def test_softdelete_flag(self):
         """Test that softdelete flag is set correctly when removing policies."""
         e = await self.get_enforcer()
         session_maker = e.adapter.session_local
-        
+
         async with session_maker() as session:
             # Verify rule does not exist initially
             self.assertFalse(e.enforce("alice", "data5", "read"))
             rule = await query_for_rule(session, e.adapter, "p", "alice", "data5", "read")
             self.assertIsNone(rule)
 
         # Add new permission
         await e.add_permission_for_user("alice", "data5", "read")
         self.assertTrue(e.enforce("alice", "data5", "read"))
-        
+
         async with session_maker() as session:
             rule = await query_for_rule(session, e.adapter, "p", "alice", "data5", "read")
             self.assertIsNotNone(rule)
             self.assertFalse(rule.is_deleted)
 
         # Delete permission - should soft delete
         await e.delete_permission_for_user("alice", "data5", "read")
         self.assertFalse(e.enforce("alice", "data5", "read"))
-        
+
         async with session_maker() as session:
             rule = await query_for_rule(session, e.adapter, "p", "alice", "data5", "read")
             self.assertIsNotNone(rule)
             self.assertTrue(rule.is_deleted)
 
@@ -172,29 +173,30 @@
 
         async with session_maker() as session:
             # Check deleted rules are marked as deleted
             rule1 = await query_for_rule(session, e.adapter, "p", "alice", "data1", "read")
             self.assertTrue(rule1.is_deleted)
-            
+
             rule2 = await query_for_rule(session, e.adapter, "p", "bob", "data2", "write")
             self.assertTrue(rule2.is_deleted)
-            
+
             # Non-existent rule should not be in DB
             rule3 = await query_for_rule(session, e.adapter, "p", "bob", "data100", "read")
             self.assertIsNone(rule3)
-            
+
             # New rules should not be deleted
             rule4 = await query_for_rule(session, e.adapter, "p", "alice", "data100", "read")
             self.assertIsNotNone(rule4)
             self.assertFalse(rule4.is_deleted)
-            
+
             rule5 = await query_for_rule(session, e.adapter, "p", "bob", "data100", "write")
             self.assertIsNotNone(rule5)
             self.assertFalse(rule5.is_deleted)
 
     async def test_softdelete_type_validation(self):
         """Test that non-Boolean softdelete attribute raises ValueError."""
+
         class InvalidRule(Base):
             __tablename__ = "invalid_rule"
 
             id = Column(Integer, primary_key=True)
             ptype = Column(String(255))
@@ -205,53 +207,46 @@
             v4 = Column(String(255))
             v5 = Column(String(255))
             is_deleted = Column(String(255))  # Wrong type!
 
         engine = create_async_engine("sqlite+aiosqlite://", future=True)
-        
+
         with self.assertRaises(ValueError) as context:
             Adapter(engine, InvalidRule, InvalidRule.is_deleted)
-        
+
         self.assertIn("Boolean", str(context.exception))
 
     async def test_remove_policies_with_softdelete(self):
         """Test that remove_policies correctly soft-deletes multiple rules."""
         e = await self.get_enforcer()
         session_maker = e.adapter.session_local
 
         # Add multiple policies
-        await e.add_policies([
-            ["alice", "data10", "read"],
-            ["bob", "data10", "write"],
-            ["carol", "data10", "read"]
-        ])
+        await e.add_policies([["alice", "data10", "read"], ["bob", "data10", "write"], ["carol", "data10", "read"]])
 
         # Verify they exist
         self.assertTrue(e.enforce("alice", "data10", "read"))
         self.assertTrue(e.enforce("bob", "data10", "write"))
         self.assertTrue(e.enforce("carol", "data10", "read"))
 
         # Remove multiple policies
-        await e.remove_policies([
-            ["alice", "data10", "read"],
-            ["bob", "data10", "write"]
-        ])
+        await e.remove_policies([["alice", "data10", "read"], ["bob", "data10", "write"]])
 
         # Verify they are soft-deleted
         self.assertFalse(e.enforce("alice", "data10", "read"))
         self.assertFalse(e.enforce("bob", "data10", "write"))
         self.assertTrue(e.enforce("carol", "data10", "read"))
 
         async with session_maker() as session:
             rule1 = await query_for_rule(session, e.adapter, "p", "alice", "data10", "read")
             self.assertIsNotNone(rule1)
             self.assertTrue(rule1.is_deleted)
-            
+
             rule2 = await query_for_rule(session, e.adapter, "p", "bob", "data10", "write")
             self.assertIsNotNone(rule2)
             self.assertTrue(rule2.is_deleted)
-            
+
             rule3 = await query_for_rule(session, e.adapter, "p", "carol", "data10", "read")
             self.assertIsNotNone(rule3)
             self.assertFalse(rule3.is_deleted)
 
     async def test_remove_filtered_policy_with_softdelete(self):
@@ -273,11 +268,11 @@
         async with session_maker() as session:
             # All data2 policies should be soft-deleted
             rule1 = await query_for_rule(session, e.adapter, "p", "data2_admin", "data2", "read")
             self.assertIsNotNone(rule1)
             self.assertTrue(rule1.is_deleted)
-            
+
             rule2 = await query_for_rule(session, e.adapter, "p", "data2_admin", "data2", "write")
             self.assertIsNotNone(rule2)
             self.assertTrue(rule2.is_deleted)
 
     async def test_update_policy_with_softdelete(self):
@@ -307,11 +302,11 @@
         e = await self.get_enforcer()
         session_maker = e.adapter.session_local
 
         # Delete a policy
         await e.delete_permission_for_user("alice", "data1", "read")
-        
+
         async with session_maker() as session:
             rule = await query_for_rule(session, e.adapter, "p", "alice", "data1", "read")
             self.assertIsNotNone(rule)
             self.assertTrue(rule.is_deleted)
 
@@ -327,11 +322,11 @@
         self.assertTrue(e2.enforce("bob", "data2", "write"))
 
     async def test_load_filtered_policy_ignores_soft_deleted(self):
         """Test that load_filtered_policy ignores soft-deleted rules."""
         e = await self.get_enforcer()
-        
+
         # Delete a policy
         await e.delete_permission_for_user("bob", "data2", "write")
 
         # Create filter for data2
         filter = Filter()
would reformat /github/workspace/tests/test_adapter_softdelete.py
Oh no! 💥 💔 💥
1 file would be reformatted.
------
2025-11-23 07:31:26 [INFO]   ---------------------------
2025-11-23 07:31:26 [INFO]   File:[/github/workspace/tests/test_external_session.py]
2025-11-23 07:31:26 [INFO]    - File:[test_external_session.py] was linted with [black] successfully
2025-11-23 07:31:26 [INFO]   ----------------------------------------------
2025-11-23 07:31:26 [INFO]   ----------------------------------------------
2025-11-23 07:31:26 [INFO]   The script has completed
2025-11-23 07:31:26 [INFO]   ----------------------------------------------
2025-11-23 07:31:26 [INFO]   ----------------------------------------------
2025-11-23 07:31:26 [ERROR]   ERRORS FOUND in PYTHON_BLACK:[2]
2025-11-23 07:31:27 [FATAL]   Exiting with errors found!

Co-authored-by: nomeguy <85475922+nomeguy@users.noreply.github.com>
Copy link
Contributor Author

Copilot AI commented Nov 23, 2025

@copilot fix failed linter check:

...

Fixed the Black linter issues in commit 0047426. Both files now pass Black formatting checks with the configured line-length of 150.

Copilot finished work on behalf of nomeguy November 23, 2025 07:46
@nomeguy nomeguy changed the title Add soft deletion support for async-sqlalchemy-adapter feat: add soft deletion support Nov 23, 2025
@nomeguy nomeguy merged commit 8c105ef into master Nov 23, 2025
13 checks passed
hsluoyz added a commit that referenced this pull request Nov 23, 2025
github-actions bot pushed a commit that referenced this pull request Nov 23, 2025
# [1.15.0](v1.14.0...v1.15.0) (2025-11-23)

### Features

* add soft deletion support ([#20](#20)) ([da6a819](da6a819))
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Is there a plan to support soft deletion?

2 participants