django-readers

repository·main·Indexed 42 days ago

https://github.com/dabapps/django-readers

A lightweight, function-oriented toolkit for Django projects designed to organize business logic and optimize data selection and projection. It replaces the 'fat models' pattern with queryset preparation functions, producers, and projectors to prevent N+1 query problems and ensure only required data is fetched. Compatible with Django 4.2, 5.1, and 5.2, and Python 3.10 through 3.14.

Tokens
14.8K
Snippets
40
Records
64
Agent score
53%

What's inside django-readers

  1. What is django-readers?

    main

    django-readers is a lightweight, function-oriented toolkit designed to improve the organization of business logic and the efficiency of data selection and projection in Django projects. It is specifically focused on 'read' operations: querying the database and presenting data.

    Key capabilities include:

    • Efficient Querying: Specify precise fields and relationships to load, helping to eliminate N+1 query problems.
    • DRF Integration: Create Django REST framework API endpoints using declarative data shape specifications instead of writing manual serializer classes.
    • Code Organization: Encourages function-oriented idioms to prevent 'fat models' and improve maintainability and testability.

    The library is designed to be non-intrusive; you can use specific parts of it or follow its patterns incrementally within existing Django projects.

  2. Overview of django-readers

    main

    django-readers is a lightweight, function-oriented toolkit designed for Django projects. It helps developers achieve better organization of business logic and provides efficient methods for the selection and projection of data.

    It is compatible with:

    • Django: 4.2, 5.1, and 5.2
    • Python: 3.10, 3.11, 3.12, 3.13, and 3.14
  3. Use Pairs to optimize database queries

    main

    A Pair is a tuple of (prepare, produce) or (prepare, project). The prepare function handles the database optimization (like include_fields or prefetch_related), while the produce/project function handles the data transformation. This ensures that only the necessary fields are loaded from the database.

    Key functions in django_readers.pairs:

    • pairs.field("field_name"): Returns a (prepare, produce) pair that only selects the specified field.
    • pairs.producer_to_projector("key", producer_func): Converts a producer into a projector pair.
    • pairs.combine(pair_a, pair_b, ...): Composes multiple pairs into a single (prepare, project) pair.
    • pairs.relationship("rel_name", projector_pair): Creates a pair for a relationship, automatically handling prefetching and nested projection.
    from django_readers import pairs
    
    # A simple field pair
    prepare, produce = pairs.field("name")
    queryset = prepare(Author.objects.all())
    print(produce(queryset.first()))
  4. Understand prepare, project, and produce

    main

    The django-readers workflow relies on three key functional concepts:

    ConceptTypeDescription
    prepareQueryset FunctionTakes a queryset and returns a clone with optimizations applied (e.g., prefetch_related, filter, only).
    projectProjectorTakes a model instance and returns a dictionary (the "projection") mapping names to values derived from the instance.
    produceProducerTakes a model instance and returns a single value derived from it (e.g., an attribute value).

    Note: A projector is often created using the producer_to_projector utility, which takes a producer and a name to create a function that returns a dictionary mapping that name to the producer's result.

  5. Understand the role of django-readers in your architecture

    main

    Mental Models for django-readers

    django-readers is not a strict "service layer," but it provides a way to structure business logic focused on database reads (selecting and transforming data). It is designed to complement, rather than hide, standard Django concepts.

    If you are familiar with established design patterns, you can map django-readers concepts to the following:

    • CQRS (Command Query Responsibility Segregation): You can implement a version of CQRS by dividing your logic into readers (for read operations) and actions (for write operations).
    • Data Transfer Objects (DTOs): The dictionaries returned by projector functions can be treated as simple DTOs used to pass data to clients or templates.

    Relationship with Serialization

    django-readers is not a serialization or data conversion library (like pydantic or attrs).

    • Focus: It focuses on the shape of the data and efficient extraction from the Django ORM.
    • Style: It uses plain, composable functions and plain data structures (like dictionaries) instead of class-oriented models or heavy type validation.
    • Best Practice: To render data to JSON, use django-readers to project the model field values into a dictionary, then use Django or Django REST framework's built-in encoders to handle type conversion (e.g., converting datetime or UUID to strings).
  6. Represent relationships in a spec

    main

    To include related models in your data projection, use a dictionary within your spec list. The dictionary key should be the name of the relationship field on the model, and the value should be a list (the sub-spec) defining the fields required from the related model.

    django-readers automatically handles the optimization by adding prefetch_related to the queryset to avoid N+1 query problems. Note that django-readers uses prefetch_related even for ForeignKey and OneToOneField relationships to maintain a fractal, recursive spec structure. This means the join is performed in Python rather than the database.

    spec = [
        "title",
        {
            "publisher": [
                "name",
            ]
        },
    ]
  7. Prevent inefficient queries using django-zen-queries

    main

    To maintain high performance, it is recommended to isolate data fetching from data rendering. You can use django-zen-queries to ensure no database queries are executed during the prepare and project phases of a django-readers spec process.

    If django-zen-queries is installed in your environment, django-readers automatically applies zen_queries.queries_disabled() to the prepare and project functions returned by specs.process(). You do not need to wrap them manually.

    Manual pattern (for reference/understanding):

    import zen_queries
    
    prepare, project = specs.process([
        # some spec
    ])
    
    with zen_queries.queries_disabled():
        queryset = prepare(Author.objects.all())
    
    queryset = zen_queries.fetch(queryset)  # execute the database queries
    
    with zen_queries.queries_disabled():
        result = [project(instance) for instance in queryset]
  8. How django-readers solves the N+1 queries problem

    main

    In standard Django, passing a queryset to a template can lead to the N+1 queries problem if the template accesses related model attributes (e.g., {{ book.publisher.name }}). This happens because the ORM issues a new query for every iteration of the loop to fetch related data.

    django-readers prevents this by decoupling the database layer from the presentation layer. Instead of passing model instances to templates, you use a spec to define exactly which fields and relationships are needed. django-readers then:

    1. Prepares an optimized queryset (using only, select_related, etc.) to fetch only the required data.
    2. Projects the model instances into simple Python dictionaries containing only the requested data.

    Because the template receives dictionaries instead of model instances, it cannot accidentally trigger additional database queries by following relationships that weren't explicitly requested in the spec.

  9. How Producers and Projectors work

    main

    django-readers uses two main abstractions to transform model instances into lightweight domain objects (dictionaries):

    1. Producers: Functions that take a model instance and return a single value (e.g., a calculated age or a specific attribute).
    2. Projectors: Functions that take a model instance and return a dictionary mapping keys to values produced by one or more producers.

    Key functions:

    • producers.attr("field_name"): Creates a producer that returns the value of a specific attribute.
    • projectors.producer_to_projector("key", producer_func): Converts a producer into a projector that returns {"key": value}.
    • projectors.combine(projector_a, projector_b, ...): Composes multiple projectors into one that returns a dictionary containing all keys from the child projectors.
    • producers.relationship("rel_name", projector_func): Creates a producer for a related object, using the provided projector_func to project each item in the relationship (resulting in a nested list of dictionaries).
    from datetime import datetime
    from django_readers import producers, projectors
    
    def produce_age(instance):
        return datetime.now().year - instance.birth_year
    
    # Creating a combined projector
    project = projectors.combine(
        projectors.producer_to_projector("name", producers.attr("name")),
        projectors.producer_to_projector("age", produce_age),
    )
    
    # Result: {'name': 'Some Author', 'age': 37}
  10. How the `specs` module works

    main

    The specs module provides a high-level syntactic sugar layer for defining the shape of data you want to select and project from complex trees of related objects. It acts as a lightweight wrapper around pairs.

    A spec is defined as a list containing one or more of the following:

    • Strings: Interpreted as field names (automatically converted to pairs.field).
    • Dictionaries:
      • If the value is a list, it is treated as a relationship (a shortcut for pairs.relationship). The key is the relationship name, and the list is the "child spec" for the related objects.
      • If the value is anything else (a string or a (prepare, produce) pair), it is treated as a producer-to-projector mapping (a shortcut for pairs.producer_to_projector). The key is the name under which the projected value will appear.
    • Projector pairs: Explicit (prepare, project) tuples are passed through as-is for custom logic.

    Import the module using: from django_readers import specs.

  11. Create custom reader pairs for complex logic

    main

    For business logic that cannot be expressed by simple field selection or built-in aggregations, you can create a reader pair (or "pair"). A pair consists of two functions:

    1. A Queryset Function (prepare): Uses django_readers.qs to ensure the necessary data dependencies (fields) are included in the database query. For example, qs.include_fields("field_name").
    2. A Producer Function (produce): A standalone function that takes a model instance and returns the calculated value.

    To use the pair in a spec, provide it as a dictionary where the key is the desired output field name and the value is the tuple (prepare_function, produce_function).

    from datetime import date
    from django_readers import qs, specs
    
    # 1. The Queryset Function (prepares the data dependencies)
    prepare_is_vintage = qs.include_fields("publication_date")
    
    # 2. The Producer Function (the business logic)
    def produce_is_vintage(book):
        years_since_publication = date.today().year - book.publication_date.year
        return years_since_publication > 5
    
    # 3. The Reader Pair
    is_vintage = (prepare_is_vintage, produce_is_vintage)
    
    def book_list(request):
        spec = [
            "title",
            {"is_vintage": is_vintage}
        ]
        prepare, project = specs.process(spec)
        queryset = prepare(Book.objects.all())
        # ...
  12. What are Projectors in django-readers

    main

    A Projector is a function that takes a single model instance as an argument and returns a dictionary. This dictionary maps one or more keys to values derived from that instance (often using producer functions). Projectors are used to define how a model instance should be transformed into a data dictionary for output.

    To use the projectors module, import it as follows:

    from django_readers import projectors