django-bulk-triggers


Namedjango-bulk-triggers JSON
Version 0.2.4 PyPI version JSON
download
home_pagehttps://github.com/AugendLimited/django-bulk-triggers
SummaryTrigger-style triggers for Django bulk operations like bulk_create and bulk_update.
upload_time2025-10-21 14:11:21
maintainerNone
docs_urlNone
authorKonrad Beck
requires_python<4.0,>=3.11
licenseMIT
keywords django bulk triggers
VCS
bugtrack_url
requirements No requirements were recorded.
Travis-CI No Travis.
coveralls test coverage No coveralls.
            
# django-bulk-triggers

⚡ Bulk triggers for Django bulk operations and individual model lifecycle events.

`django-bulk-triggers` brings a declarative, trigger-like experience to Django's `bulk_create`, `bulk_update`, and `bulk_delete` — including support for `BEFORE_` and `AFTER_` triggers, conditions, batching, and transactional safety. It also provides comprehensive lifecycle triggers for individual model operations.

## ✨ Features

- Declarative trigger system: `@trigger(AFTER_UPDATE, condition=...)`
- BEFORE/AFTER triggers for create, update, delete
- Trigger-aware manager that wraps Django's `bulk_` operations
- **NEW**: `TriggerModelMixin` for individual model lifecycle events
- Trigger chaining, trigger deduplication, and atomicity
- Class-based trigger handlers with DI support
- Support for both bulk and individual model operations

## 🚀 Quickstart

```bash
pip install django-bulk-triggers
```

### Define Your Model

```python
from django.db import models
from django_bulk_triggers.models import TriggerModelMixin

class Account(TriggerModelMixin):
    balance = models.DecimalField(max_digits=10, decimal_places=2)
    # The TriggerModelMixin automatically provides BulkTriggerManager
```

### Create a Trigger Handler

```python
from django_bulk_triggers import trigger, AFTER_UPDATE, Trigger
from django_bulk_triggers.conditions import WhenFieldHasChanged
from .models import Account

class AccountTriggers(Trigger):
    @trigger(AFTER_UPDATE, model=Account, condition=WhenFieldHasChanged("balance"))
    def log_balance_change(self, new_records, old_records):
        print("Accounts updated:", [a.pk for a in new_records])
    
    @trigger(BEFORE_CREATE, model=Account)
    def before_create(self, new_records, old_records):
        for account in new_records:
            if account.balance < 0:
                raise ValueError("Account cannot have negative balance")
    
    @trigger(AFTER_DELETE, model=Account)
    def after_delete(self, new_records, old_records):
        print("Accounts deleted:", [a.pk for a in old_records])
```

## 🛠 Supported Trigger Events

- `BEFORE_CREATE`, `AFTER_CREATE`
- `BEFORE_UPDATE`, `AFTER_UPDATE`
- `BEFORE_DELETE`, `AFTER_DELETE`

## 🔄 Lifecycle Events

### Individual Model Operations

The `TriggerModelMixin` automatically triggers triggers for individual model operations:

```python
# These will trigger BEFORE_CREATE and AFTER_CREATE triggers
account = Account.objects.create(balance=100.00)
account.save()  # for new instances

# These will trigger BEFORE_UPDATE and AFTER_UPDATE triggers
account.balance = 200.00
account.save()  # for existing instances

# This will trigger BEFORE_DELETE and AFTER_DELETE triggers
account.delete()
```

### Bulk Operations

Bulk operations also trigger the same triggers:

```python
# Bulk create - triggers BEFORE_CREATE and AFTER_CREATE triggers
accounts = [
    Account(balance=100.00),
    Account(balance=200.00),
]
Account.objects.bulk_create(accounts)

# Bulk update - triggers BEFORE_UPDATE and AFTER_UPDATE triggers
for account in accounts:
    account.balance *= 1.1
Account.objects.bulk_update(accounts)  # fields are auto-detected

# Bulk delete - triggers BEFORE_DELETE and AFTER_DELETE triggers
Account.objects.bulk_delete(accounts)
```

### Queryset Operations

Queryset operations are also supported:

```python
# Queryset update - triggers BEFORE_UPDATE and AFTER_UPDATE triggers
Account.objects.update(balance=0.00)

# Queryset delete - triggers BEFORE_DELETE and AFTER_DELETE triggers
Account.objects.delete()
```

### Subquery Support in Updates

When using `Subquery` objects in update operations, the computed values are automatically available in triggers. The system efficiently refreshes all instances in bulk for optimal performance:

```python
from django.db.models import Subquery, OuterRef, Sum

def aggregate_revenue_by_ids(self, ids: Iterable[int]) -> int:
    return self.find_by_ids(ids).update(
        revenue=Subquery(
            FinancialTransaction.objects.filter(daily_financial_aggregate_id=OuterRef("pk"))
            .filter(is_revenue=True)
            .values("daily_financial_aggregate_id")
            .annotate(revenue_sum=Sum("amount"))
            .values("revenue_sum")[:1],
        ),
    )

# In your triggers, you can now access the computed revenue value:
class FinancialAggregateTriggers(Trigger):
    @trigger(AFTER_UPDATE, model=DailyFinancialAggregate)
    def log_revenue_update(self, new_records, old_records):
        for new_record in new_records:
            # This will now contain the computed value, not the Subquery object
            print(f"Updated revenue: {new_record.revenue}")

# Bulk operations are optimized for performance:
def bulk_aggregate_revenue(self, ids: Iterable[int]) -> int:
    # This will efficiently refresh all instances in a single query
    return self.filter(id__in=ids).update(
        revenue=Subquery(
            FinancialTransaction.objects.filter(daily_financial_aggregate_id=OuterRef("pk"))
            .filter(is_revenue=True)
            .values("daily_financial_aggregate_id")
            .annotate(revenue_sum=Sum("amount"))
            .values("revenue_sum")[:1],
        ),
    )
```

## 🧠 Why?

Django's `bulk_` methods bypass signals and `save()`. This package fills that gap with:

- Triggers that behave consistently across creates/updates/deletes
- **NEW**: Individual model lifecycle triggers that work with `save()` and `delete()`
- Scalable performance via chunking (default 200)
- Support for `@trigger` decorators and centralized trigger classes
- **NEW**: Automatic trigger triggering for admin operations and other Django features
- **NEW**: Proper ordering guarantees for old/new record pairing in triggers (Salesforce-like behavior)

## 📦 Usage Examples

### Individual Model Operations

```python
# These automatically trigger triggers
account = Account.objects.create(balance=100.00)
account.balance = 200.00
account.save()
account.delete()
```

### Bulk Operations

```python
# These also trigger triggers
Account.objects.bulk_create(accounts)
Account.objects.bulk_update(accounts)  # fields are auto-detected
Account.objects.bulk_delete(accounts)
```

### Advanced Trigger Usage

```python
class AdvancedAccountTriggers(Trigger):
    @trigger(BEFORE_UPDATE, model=Account, condition=WhenFieldHasChanged("balance"))
    def validate_balance_change(self, new_records, old_records):
        for new_account, old_account in zip(new_records, old_records):
            if new_account.balance < 0 and old_account.balance >= 0:
                raise ValueError("Cannot set negative balance")
    
    @trigger(AFTER_CREATE, model=Account)
    def send_welcome_email(self, new_records, old_records):
        for account in new_records:
            # Send welcome email logic here
            pass
```

### Salesforce-like Ordering Guarantees

The system ensures that `old_records` and `new_records` are always properly paired, regardless of the order in which you pass objects to bulk operations:

```python
class LoanAccountTriggers(Trigger):
    @trigger(BEFORE_UPDATE, model=LoanAccount)
    def validate_account_number(self, new_records, old_records):
        # old_records[i] always corresponds to new_records[i]
        for new_account, old_account in zip(new_records, old_records):
            if old_account.account_number != new_account.account_number:
                raise ValidationError("Account number cannot be changed")

# This works correctly even with reordered objects:
accounts = [account1, account2, account3]  # IDs: 1, 2, 3
reordered = [account3, account1, account2]  # IDs: 3, 1, 2

# The trigger will still receive properly paired old/new records
LoanAccount.objects.bulk_update(reordered)  # fields are auto-detected
```

## 🧩 Integration with Other Managers

You can extend from `BulkTriggerManager` to work with other manager classes. The manager uses a cooperative approach that dynamically injects bulk trigger functionality into any queryset, ensuring compatibility with other managers.

```python
from django_bulk_triggers.manager import BulkTriggerManager
from queryable_properties.managers import QueryablePropertiesManager

class MyManager(BulkTriggerManager, QueryablePropertiesManager):
    pass
```

This approach uses the industry-standard injection pattern, similar to how `QueryablePropertiesManager` works, ensuring both functionalities work seamlessly together without any framework-specific knowledge.

Framework needs to:
Register these methods
Know when to execute them (BEFORE_UPDATE, AFTER_UPDATE)
Execute them in priority order
Pass ChangeSet to them
Handle errors (rollback on failure)

## 📝 License

MIT © 2024 Augend / Konrad Beck

            

Raw data

            {
    "_id": null,
    "home_page": "https://github.com/AugendLimited/django-bulk-triggers",
    "name": "django-bulk-triggers",
    "maintainer": null,
    "docs_url": null,
    "requires_python": "<4.0,>=3.11",
    "maintainer_email": null,
    "keywords": "django, bulk, triggers",
    "author": "Konrad Beck",
    "author_email": "konrad.beck@merchantcapital.co.za",
    "download_url": "https://files.pythonhosted.org/packages/88/6c/887cad3aff17469d5a171d9d99932b29ccea5c488e4cbf6d57672fb525e6/django_bulk_triggers-0.2.4.tar.gz",
    "platform": null,
    "description": "\n# django-bulk-triggers\n\n\u26a1 Bulk triggers for Django bulk operations and individual model lifecycle events.\n\n`django-bulk-triggers` brings a declarative, trigger-like experience to Django's `bulk_create`, `bulk_update`, and `bulk_delete` \u2014 including support for `BEFORE_` and `AFTER_` triggers, conditions, batching, and transactional safety. It also provides comprehensive lifecycle triggers for individual model operations.\n\n## \u2728 Features\n\n- Declarative trigger system: `@trigger(AFTER_UPDATE, condition=...)`\n- BEFORE/AFTER triggers for create, update, delete\n- Trigger-aware manager that wraps Django's `bulk_` operations\n- **NEW**: `TriggerModelMixin` for individual model lifecycle events\n- Trigger chaining, trigger deduplication, and atomicity\n- Class-based trigger handlers with DI support\n- Support for both bulk and individual model operations\n\n## \ud83d\ude80 Quickstart\n\n```bash\npip install django-bulk-triggers\n```\n\n### Define Your Model\n\n```python\nfrom django.db import models\nfrom django_bulk_triggers.models import TriggerModelMixin\n\nclass Account(TriggerModelMixin):\n    balance = models.DecimalField(max_digits=10, decimal_places=2)\n    # The TriggerModelMixin automatically provides BulkTriggerManager\n```\n\n### Create a Trigger Handler\n\n```python\nfrom django_bulk_triggers import trigger, AFTER_UPDATE, Trigger\nfrom django_bulk_triggers.conditions import WhenFieldHasChanged\nfrom .models import Account\n\nclass AccountTriggers(Trigger):\n    @trigger(AFTER_UPDATE, model=Account, condition=WhenFieldHasChanged(\"balance\"))\n    def log_balance_change(self, new_records, old_records):\n        print(\"Accounts updated:\", [a.pk for a in new_records])\n    \n    @trigger(BEFORE_CREATE, model=Account)\n    def before_create(self, new_records, old_records):\n        for account in new_records:\n            if account.balance < 0:\n                raise ValueError(\"Account cannot have negative balance\")\n    \n    @trigger(AFTER_DELETE, model=Account)\n    def after_delete(self, new_records, old_records):\n        print(\"Accounts deleted:\", [a.pk for a in old_records])\n```\n\n## \ud83d\udee0 Supported Trigger Events\n\n- `BEFORE_CREATE`, `AFTER_CREATE`\n- `BEFORE_UPDATE`, `AFTER_UPDATE`\n- `BEFORE_DELETE`, `AFTER_DELETE`\n\n## \ud83d\udd04 Lifecycle Events\n\n### Individual Model Operations\n\nThe `TriggerModelMixin` automatically triggers triggers for individual model operations:\n\n```python\n# These will trigger BEFORE_CREATE and AFTER_CREATE triggers\naccount = Account.objects.create(balance=100.00)\naccount.save()  # for new instances\n\n# These will trigger BEFORE_UPDATE and AFTER_UPDATE triggers\naccount.balance = 200.00\naccount.save()  # for existing instances\n\n# This will trigger BEFORE_DELETE and AFTER_DELETE triggers\naccount.delete()\n```\n\n### Bulk Operations\n\nBulk operations also trigger the same triggers:\n\n```python\n# Bulk create - triggers BEFORE_CREATE and AFTER_CREATE triggers\naccounts = [\n    Account(balance=100.00),\n    Account(balance=200.00),\n]\nAccount.objects.bulk_create(accounts)\n\n# Bulk update - triggers BEFORE_UPDATE and AFTER_UPDATE triggers\nfor account in accounts:\n    account.balance *= 1.1\nAccount.objects.bulk_update(accounts)  # fields are auto-detected\n\n# Bulk delete - triggers BEFORE_DELETE and AFTER_DELETE triggers\nAccount.objects.bulk_delete(accounts)\n```\n\n### Queryset Operations\n\nQueryset operations are also supported:\n\n```python\n# Queryset update - triggers BEFORE_UPDATE and AFTER_UPDATE triggers\nAccount.objects.update(balance=0.00)\n\n# Queryset delete - triggers BEFORE_DELETE and AFTER_DELETE triggers\nAccount.objects.delete()\n```\n\n### Subquery Support in Updates\n\nWhen using `Subquery` objects in update operations, the computed values are automatically available in triggers. The system efficiently refreshes all instances in bulk for optimal performance:\n\n```python\nfrom django.db.models import Subquery, OuterRef, Sum\n\ndef aggregate_revenue_by_ids(self, ids: Iterable[int]) -> int:\n    return self.find_by_ids(ids).update(\n        revenue=Subquery(\n            FinancialTransaction.objects.filter(daily_financial_aggregate_id=OuterRef(\"pk\"))\n            .filter(is_revenue=True)\n            .values(\"daily_financial_aggregate_id\")\n            .annotate(revenue_sum=Sum(\"amount\"))\n            .values(\"revenue_sum\")[:1],\n        ),\n    )\n\n# In your triggers, you can now access the computed revenue value:\nclass FinancialAggregateTriggers(Trigger):\n    @trigger(AFTER_UPDATE, model=DailyFinancialAggregate)\n    def log_revenue_update(self, new_records, old_records):\n        for new_record in new_records:\n            # This will now contain the computed value, not the Subquery object\n            print(f\"Updated revenue: {new_record.revenue}\")\n\n# Bulk operations are optimized for performance:\ndef bulk_aggregate_revenue(self, ids: Iterable[int]) -> int:\n    # This will efficiently refresh all instances in a single query\n    return self.filter(id__in=ids).update(\n        revenue=Subquery(\n            FinancialTransaction.objects.filter(daily_financial_aggregate_id=OuterRef(\"pk\"))\n            .filter(is_revenue=True)\n            .values(\"daily_financial_aggregate_id\")\n            .annotate(revenue_sum=Sum(\"amount\"))\n            .values(\"revenue_sum\")[:1],\n        ),\n    )\n```\n\n## \ud83e\udde0 Why?\n\nDjango's `bulk_` methods bypass signals and `save()`. This package fills that gap with:\n\n- Triggers that behave consistently across creates/updates/deletes\n- **NEW**: Individual model lifecycle triggers that work with `save()` and `delete()`\n- Scalable performance via chunking (default 200)\n- Support for `@trigger` decorators and centralized trigger classes\n- **NEW**: Automatic trigger triggering for admin operations and other Django features\n- **NEW**: Proper ordering guarantees for old/new record pairing in triggers (Salesforce-like behavior)\n\n## \ud83d\udce6 Usage Examples\n\n### Individual Model Operations\n\n```python\n# These automatically trigger triggers\naccount = Account.objects.create(balance=100.00)\naccount.balance = 200.00\naccount.save()\naccount.delete()\n```\n\n### Bulk Operations\n\n```python\n# These also trigger triggers\nAccount.objects.bulk_create(accounts)\nAccount.objects.bulk_update(accounts)  # fields are auto-detected\nAccount.objects.bulk_delete(accounts)\n```\n\n### Advanced Trigger Usage\n\n```python\nclass AdvancedAccountTriggers(Trigger):\n    @trigger(BEFORE_UPDATE, model=Account, condition=WhenFieldHasChanged(\"balance\"))\n    def validate_balance_change(self, new_records, old_records):\n        for new_account, old_account in zip(new_records, old_records):\n            if new_account.balance < 0 and old_account.balance >= 0:\n                raise ValueError(\"Cannot set negative balance\")\n    \n    @trigger(AFTER_CREATE, model=Account)\n    def send_welcome_email(self, new_records, old_records):\n        for account in new_records:\n            # Send welcome email logic here\n            pass\n```\n\n### Salesforce-like Ordering Guarantees\n\nThe system ensures that `old_records` and `new_records` are always properly paired, regardless of the order in which you pass objects to bulk operations:\n\n```python\nclass LoanAccountTriggers(Trigger):\n    @trigger(BEFORE_UPDATE, model=LoanAccount)\n    def validate_account_number(self, new_records, old_records):\n        # old_records[i] always corresponds to new_records[i]\n        for new_account, old_account in zip(new_records, old_records):\n            if old_account.account_number != new_account.account_number:\n                raise ValidationError(\"Account number cannot be changed\")\n\n# This works correctly even with reordered objects:\naccounts = [account1, account2, account3]  # IDs: 1, 2, 3\nreordered = [account3, account1, account2]  # IDs: 3, 1, 2\n\n# The trigger will still receive properly paired old/new records\nLoanAccount.objects.bulk_update(reordered)  # fields are auto-detected\n```\n\n## \ud83e\udde9 Integration with Other Managers\n\nYou can extend from `BulkTriggerManager` to work with other manager classes. The manager uses a cooperative approach that dynamically injects bulk trigger functionality into any queryset, ensuring compatibility with other managers.\n\n```python\nfrom django_bulk_triggers.manager import BulkTriggerManager\nfrom queryable_properties.managers import QueryablePropertiesManager\n\nclass MyManager(BulkTriggerManager, QueryablePropertiesManager):\n    pass\n```\n\nThis approach uses the industry-standard injection pattern, similar to how `QueryablePropertiesManager` works, ensuring both functionalities work seamlessly together without any framework-specific knowledge.\n\nFramework needs to:\nRegister these methods\nKnow when to execute them (BEFORE_UPDATE, AFTER_UPDATE)\nExecute them in priority order\nPass ChangeSet to them\nHandle errors (rollback on failure)\n\n## \ud83d\udcdd License\n\nMIT \u00a9 2024 Augend / Konrad Beck\n",
    "bugtrack_url": null,
    "license": "MIT",
    "summary": "Trigger-style triggers for Django bulk operations like bulk_create and bulk_update.",
    "version": "0.2.4",
    "project_urls": {
        "Homepage": "https://github.com/AugendLimited/django-bulk-triggers",
        "Repository": "https://github.com/AugendLimited/django-bulk-triggers"
    },
    "split_keywords": [
        "django",
        " bulk",
        " triggers"
    ],
    "urls": [
        {
            "comment_text": "",
            "digests": {
                "blake2b_256": "9afdc7ee85f6470a21b9d7a7833886d3185e4418bf09bd1d02b015d8d4308050",
                "md5": "ebe656632d945c2e4a94aa9a71458438",
                "sha256": "b08ed50b0c906678e169f17f87a5bf9f8dc0764e3ce783be72b5ce85ca811686"
            },
            "downloads": -1,
            "filename": "django_bulk_triggers-0.2.4-py3-none-any.whl",
            "has_sig": false,
            "md5_digest": "ebe656632d945c2e4a94aa9a71458438",
            "packagetype": "bdist_wheel",
            "python_version": "py3",
            "requires_python": "<4.0,>=3.11",
            "size": 37910,
            "upload_time": "2025-10-21T14:11:19",
            "upload_time_iso_8601": "2025-10-21T14:11:19.840455Z",
            "url": "https://files.pythonhosted.org/packages/9a/fd/c7ee85f6470a21b9d7a7833886d3185e4418bf09bd1d02b015d8d4308050/django_bulk_triggers-0.2.4-py3-none-any.whl",
            "yanked": false,
            "yanked_reason": null
        },
        {
            "comment_text": "",
            "digests": {
                "blake2b_256": "886c887cad3aff17469d5a171d9d99932b29ccea5c488e4cbf6d57672fb525e6",
                "md5": "2d2be5a83881d2c0d7ad23b4636ab7da",
                "sha256": "6a481312fa12781785c3861976c7219899ae2956e2b90b0644592b0fd93db464"
            },
            "downloads": -1,
            "filename": "django_bulk_triggers-0.2.4.tar.gz",
            "has_sig": false,
            "md5_digest": "2d2be5a83881d2c0d7ad23b4636ab7da",
            "packagetype": "sdist",
            "python_version": "source",
            "requires_python": "<4.0,>=3.11",
            "size": 28668,
            "upload_time": "2025-10-21T14:11:21",
            "upload_time_iso_8601": "2025-10-21T14:11:21.233558Z",
            "url": "https://files.pythonhosted.org/packages/88/6c/887cad3aff17469d5a171d9d99932b29ccea5c488e4cbf6d57672fb525e6/django_bulk_triggers-0.2.4.tar.gz",
            "yanked": false,
            "yanked_reason": null
        }
    ],
    "upload_time": "2025-10-21 14:11:21",
    "github": true,
    "gitlab": false,
    "bitbucket": false,
    "codeberg": false,
    "github_user": "AugendLimited",
    "github_project": "django-bulk-triggers",
    "travis_ci": false,
    "coveralls": false,
    "github_actions": false,
    "lcname": "django-bulk-triggers"
}
        
Elapsed time: 3.84882s