Skip to main content

Hook-style hooks for Django bulk operations like bulk_create and bulk_update.

Project description

django-bulk-hooks

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

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

✨ Features

  • Declarative hook system: @hook(AFTER_UPDATE, condition=...)
  • BEFORE/AFTER hooks for create, update, delete
  • Hook-aware manager that wraps Django's bulk_ operations
  • NEW: HookModelMixin for individual model lifecycle events
  • Hook chaining, hook deduplication, and atomicity
  • Class-based hook handlers with DI support
  • Support for both bulk and individual model operations
  • NEW: Safe handling of related objects to prevent RelatedObjectDoesNotExist errors
  • NEW: @select_related decorator to prevent queries in loops

🚀 Quickstart

pip install django-bulk-hooks

Define Your Model

from django.db import models
from django_bulk_hooks.models import HookModelMixin

class Account(HookModelMixin):
    balance = models.DecimalField(max_digits=10, decimal_places=2)
    # The HookModelMixin automatically provides BulkHookManager

Create a Hook Handler

from django_bulk_hooks import hook, AFTER_UPDATE, select_related
from django_bulk_hooks.conditions import WhenFieldHasChanged
from .models import Account

class AccountHandler:
    @hook(AFTER_UPDATE, model=Account, condition=WhenFieldHasChanged("balance"))
    @select_related("user")  # Preload user to prevent queries in loops
    def notify_balance_change(self, new_records, old_records):
        for account in new_records:
            # This won't cause a query since user is preloaded
            user_email = account.user.email
            self.send_notification(user_email, account.balance)

🔧 Using @select_related to Prevent Queries in Loops

The @select_related decorator is essential when your hook logic needs to access related objects. Without it, you might end up with N+1 query problems.

❌ Without @select_related (causes queries in loops)

@hook(AFTER_CREATE, model=LoanAccount)
def process_accounts(self, new_records, old_records):
    for account in new_records:
        # ❌ This causes a query for each account!
        status_name = account.status.name
        if status_name == "ACTIVE":
            self.activate_account(account)

✅ With @select_related (bulk loads related objects)

@hook(AFTER_CREATE, model=LoanAccount)
@select_related("status")  # Bulk load status objects
def process_accounts(self, new_records, old_records):
    for account in new_records:
        # ✅ No query here - status is preloaded
        status_name = account.status.name
        if status_name == "ACTIVE":
            self.activate_account(account)

Multiple Related Fields

@hook(AFTER_UPDATE, model=Transaction)
@select_related("account", "category", "status")
def process_transactions(self, new_records, old_records):
    for transaction in new_records:
        # All related objects are preloaded - no queries in loops
        account_name = transaction.account.name
        category_type = transaction.category.type
        status_name = transaction.status.name
        
        if status_name == "COMPLETE":
            self.process_complete_transaction(transaction)

Your Original Example (Fixed)

@hook(BEFORE_CREATE, model=LoanAccount, condition=IsEqual("status.name", value=Status.ACTIVE.value))
@hook(
    BEFORE_UPDATE,
    model=LoanAccount,
    condition=HasChanged("status", has_changed=True) & IsEqual("status.name", value=Status.ACTIVE.value),
    priority=Priority.HIGH,
)
@select_related("status")  # This ensures status is preloaded
def _set_activated_date(self, old_records: list[LoanAccount], new_records: list[LoanAccount], **kwargs) -> None:
    logger.info(f"Setting activated date for {new_records}")
    # No queries in loops - status objects are preloaded
    self._loan_account_service.set_activated_date(new_records)

🛡️ Safe Handling of Related Objects

Use the safe_get_related_attr utility function to safely access related object attributes:

from django_bulk_hooks.conditions import safe_get_related_attr

# ✅ SAFE: Use safe_get_related_attr to handle None values
@hook(AFTER_CREATE, model=Transaction)
def process_transaction(self, new_records, old_records):
    for transaction in new_records:
        # Safely get the status name, returns None if status doesn't exist
        status_name = safe_get_related_attr(transaction, 'status', 'name')
        
        if status_name == "COMPLETE":
            # Process the transaction
            pass
        elif status_name is None:
            # Handle case where status is not set
            print(f"Transaction {transaction.id} has no status")

Complete Example

from django.db import models
from django_bulk_hooks import hook, select_related
from django_bulk_hooks.conditions import safe_get_related_attr

class Status(models.Model):
    name = models.CharField(max_length=50)

class Transaction(HookModelMixin, models.Model):
    amount = models.DecimalField(max_digits=10, decimal_places=2)
    status = models.ForeignKey(Status, on_delete=models.CASCADE, null=True, blank=True)
    category = models.ForeignKey('Category', on_delete=models.CASCADE, null=True, blank=True)

class TransactionHandler:
    @hook(Transaction, "before_create")
    def set_default_status(self, new_records, old_records=None):
        """Set default status for new transactions."""
        default_status = Status.objects.filter(name="PENDING").first()
        for transaction in new_records:
            if transaction.status is None:
                transaction.status = default_status
    
    @hook(Transaction, "after_create")
    @select_related("status", "category")  # Preload related objects
    def process_transactions(self, new_records, old_records=None):
        """Process transactions based on their status."""
        for transaction in new_records:
            # ✅ SAFE: Get status name safely (no queries in loops)
            status_name = safe_get_related_attr(transaction, 'status', 'name')
            
            if status_name == "COMPLETE":
                self._process_complete_transaction(transaction)
            elif status_name == "FAILED":
                self._process_failed_transaction(transaction)
            elif status_name is None:
                print(f"Transaction {transaction.id} has no status")
            
            # ✅ SAFE: Check for related object existence (no queries in loops)
            category = safe_get_related_attr(transaction, 'category')
            if category:
                print(f"Transaction {transaction.id} belongs to category: {category.name}")
    
    def _process_complete_transaction(self, transaction):
        # Process complete transaction logic
        pass
    
    def _process_failed_transaction(self, transaction):
        # Process failed transaction logic
        pass

Best Practices for Related Objects

  1. Always use @select_related when accessing related object attributes in hooks
  2. Use safe_get_related_attr for safe access to related object attributes
  3. Set default values in BEFORE_CREATE hooks to ensure related objects exist
  4. Handle None cases explicitly to avoid unexpected behavior
  5. Use bulk operations efficiently by fetching related objects once and reusing them

🔍 Performance Tips

Monitor Query Count

from django.db import connection, reset_queries

# Before your bulk operation
reset_queries()

# Your bulk operation
accounts = Account.objects.bulk_create(account_list)

# After your bulk operation
print(f"Total queries: {len(connection.queries)}")

Use @select_related Strategically

# Only select_related fields you actually use
@select_related("status")  # Good - only what you need
@select_related("status", "category", "user", "account")  # Only if you use all of them

Avoid Nested Loops with Related Objects

# ❌ Bad - nested loops with related objects
@hook(AFTER_CREATE, model=Order)
def process_orders(self, new_records, old_records):
    for order in new_records:
        for item in order.items.all():  # This causes queries!
            process_item(item)

# ✅ Good - use prefetch_related for many-to-many/one-to-many
@hook(AFTER_CREATE, model=Order)
@select_related("customer")
def process_orders(self, new_records, old_records):
    # Prefetch items for all orders at once
    from django.db.models import Prefetch
    orders_with_items = Order.objects.prefetch_related(
        Prefetch('items', queryset=Item.objects.select_related('product'))
    ).filter(id__in=[order.id for order in new_records])
    
    for order in orders_with_items:
        for item in order.items.all():  # No queries here
            process_item(item)

📚 API Reference

Decorators

  • @hook(event, model, condition=None, priority=DEFAULT_PRIORITY) - Register a hook
  • @select_related(*fields) - Preload related fields to prevent queries in loops

Conditions

  • IsEqual(field, value) - Check if field equals value
  • HasChanged(field, has_changed=True) - Check if field has changed
  • safe_get_related_attr(instance, field, attr=None) - Safely get related object attributes

Events

  • BEFORE_CREATE, AFTER_CREATE
  • BEFORE_UPDATE, AFTER_UPDATE
  • BEFORE_DELETE, AFTER_DELETE
  • VALIDATE_CREATE, VALIDATE_UPDATE, VALIDATE_DELETE

🤝 Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

📄 License

This project is licensed under the MIT License - see the LICENSE file for details.

Project details


Release history Release notifications | RSS feed

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

django_bulk_hooks-0.1.101.tar.gz (15.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

django_bulk_hooks-0.1.101-py3-none-any.whl (19.5 kB view details)

Uploaded Python 3

File details

Details for the file django_bulk_hooks-0.1.101.tar.gz.

File metadata

  • Download URL: django_bulk_hooks-0.1.101.tar.gz
  • Upload date:
  • Size: 15.6 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: poetry/1.8.4 CPython/3.11.9 Windows/10

File hashes

Hashes for django_bulk_hooks-0.1.101.tar.gz
Algorithm Hash digest
SHA256 ff09793acab55665aac250741e967a9417b16d54cdc81f89bee083696ee86704
MD5 453781135aafc80d0ab6e4494690cf73
BLAKE2b-256 b992e23ec97cce67fd69a9804ed07d2c45cfe9592af82e2485ebfbb61a2f3d70

See more details on using hashes here.

File details

Details for the file django_bulk_hooks-0.1.101-py3-none-any.whl.

File metadata

File hashes

Hashes for django_bulk_hooks-0.1.101-py3-none-any.whl
Algorithm Hash digest
SHA256 6b18605ca8ce84322b250f0489a690e298d9d6fc9758d5e35be74d84a4bd2571
MD5 3339a1098d778b980df91fd0c3132ecb
BLAKE2b-256 d29edf1cec753b6210197a49842e908391690e9a41cf47dadf5f6f070f7aee1e

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page