Skip to main content

A flexible query language for Django. Enable the frontend to fetch exactly what it needs

Project description

Django-Flex

A flexible query language for Django — let your frontend dynamically construct database queries

PyPI version Python versions License


Django-Flex enables frontends to send flexible, dynamic queries to your Django backend — think of it as a simpler alternative to GraphQL that feels native to Django.

Features

  • Field Selection — Request only the fields you need, including nested relations
  • JSONField Support — Seamless dot notation for nested JSON data
  • Dynamic Filtering — Full Django ORM operator support with composable AND/OR/NOT
  • Smart Pagination — Limit/offset with cursor-based continuation
  • Built-in Security — Row-level, field-level, and operation-level permissions
  • Automatic Optimization — N+1 prevention with smart select_related
  • Django-Native — Feels like a natural extension of Django

Installation

pip install django-flex

Add to your Django settings:

# settings.py
INSTALLED_APPS = [
    ...
    'django_flex',
]

# Optional: Configure permissions and defaults
DJANGO_FLEX = {
    'DEFAULT_LIMIT': 50,
    'MAX_LIMIT': 200,
    'ALWAYS_HTTP_200': False,  # When True, all responses return HTTP 200
    'EXPOSE': {
        # See Permission Configuration below
    },
}

Response Modes

By default, django-flex uses standard HTTP status codes (200, 400, 404, etc.).

Set ALWAYS_HTTP_200 = True to always return HTTP 200 with the status code in the payload:

# settings.py
DJANGO_FLEX = {
    'ALWAYS_HTTP_200': True,  # All responses return HTTP 200
}

When ALWAYS_HTTP_200 = True:

// HTTP 200 (always)
{"status_code": 404, "error": "Object not found"}
{"status_code": 200, "id": 1, "name": "Test"}

When ALWAYS_HTTP_200 = False (default):

// HTTP 404
{"error": "Object not found"}

// HTTP 200
{"id": 1, "name": "Test"}

Quick Start

1. Class-Based View (Recommended)

# views.py
from django_flex import FlexQueryView
from myapp.models import Booking

class BookingQueryView(FlexQueryView):
    model = Booking

    # Define permissions for this view
    flex_permissions = {
        'authenticated': {
            'rows': lambda user: Q(team__members=user),
            'fields': ['id', 'status', 'customer.name', 'customer.email'],
            'filters': ['status', 'status.in', 'customer.name.icontains'],
            'order_by': ['created_at', '-created_at'],
            'ops': ['get', 'list'],
        },
    }
# urls.py
from django.urls import path
from myapp.views import BookingQueryView

urlpatterns = [
    path('api/bookings/', BookingQueryView.as_view()),
    path('api/bookings/<int:pk>/', BookingQueryView.as_view()),  # Single object by ID
]

2. Make Queries from Frontend

// List bookings with field selection and filtering (JSON body)
const response = await fetch('/api/bookings/', {
    method: 'GET',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
        fields: 'id, status, customer.name, customer.email',
        filters: {
            'status.in': ['confirmed', 'completed'],
            'customer.name.icontains': 'khan',
        },
        order_by: '-created_at',
        limit: 20,
    }),
});

const data = await response.json();
// {
//     "pagination": {"offset": 0, "limit": 20, "has_more": true},
//     "results": {
//         "1": {"id": 1, "status": "confirmed", "customer": {"name": "Aisha Khan", "email": "aisha@example.com"}},
//         "2": {"id": 2, "status": "completed", "customer": {"name": "Omar Khan", "email": "omar@example.com"}}
//     }
// }

3. Query Params (Alternative)

Query params can be used instead of JSON body. Query params override body params.

GET /api/bookings/?fields=id,status,customer.name&filters.status=confirmed&filters.customer.name.icontains=khan&order_by=-created_at&limit=20

Equivalent to:

{
    fields: 'id, status, customer.name',
    filters: { status: 'confirmed', 'customer.name.icontains': 'khan' },
    order_by: '-created_at',
    limit: 20
}
Query Param Body Equivalent
fields=id,name {fields: 'id, name'}
limit=20 {limit: 20}
offset=10 {offset: 10}
order_by=-created_at {order_by: '-created_at'}
filters.status=pending {filters: {status: 'pending'}}
filters.name.icontains=khan {filters: {'name.icontains': 'khan'}}
// Get single object by ID (using URL)
const booking = await fetch('/api/bookings/1/', {
    method: 'GET',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
        fields: 'id, status, customer.*, address.*',
    }),
});
// Returns: {"id": 1, "status": "confirmed", "customer": {...}, "address": {...}}

Query Language Reference

Field Selection

// All fields on the model
{
    fields: '*';
}

// Specific fields
{
    fields: 'id, name, email';
}

// Nested relation fields (dot notation)
{
    fields: 'id, customer.name, customer.email';
}

// Relation wildcards
{
    fields: 'id, status, customer.*, address.*';
}

JSONField Support

Django-Flex seamlessly supports JSONField — the frontend uses the same dot notation without knowing the difference between relations and JSON keys:

// Assume Customer model has: metadata = JSONField(default=dict)
// metadata = {"settings": {"theme": "dark", "lang": "en"}, "tags": ["vip"]}

// Select nested JSON values (same syntax as relations)
{ fields: 'name, metadata.settings.theme, metadata.tags' }
// Returns: {"name": "Alice", "metadata": {"settings": {"theme": "dark"}, "tags": ["vip"]}}

// Select entire JSONField
{ fields: 'name, metadata' }
// Returns: {"name": "Alice", "metadata": {"settings": {...}, "tags": [...]}}

// Filter on nested JSON values
{ filters: { 'metadata.settings.theme': 'dark' } }

// With operators (all Django operators work)
{
    filters: {
        'metadata.level.gte': 5,
        'metadata.tags.icontains': 'vip'
    }
}

How it works: Dot notation is transparently converted to Django's double-underscore format, which works for both ForeignKey relations AND JSONField nested keys.

Permissions: JSONField paths work with the same permission patterns:

DJANGO_FLEX = {
    'EXPOSE': {
        'customer': {
            'staff': {
                'fields': [
                    'name',
                    'metadata',              # Entire JSONField
                    'metadata.settings',     # Specific key
                    'metadata.settings.*',   # All keys under settings (any depth)
                ],
                'filters': [
                    'metadata.settings.theme',
                    'metadata.level',
                ],
            }
        }
    }
}

ForeignKey Interchangeability

ForeignKey fields work interchangeably with integer IDs — no need to pass full objects or deal with Django's internal representations.

Core Principle: company = company_id

// Creating with FK as integer - just works!
{ _model: 'service', _action: 'add', company: 1, name: 'Deep Cleaning', price: '120' }
// ✅ Django-Flex converts company: 1 → company_id: 1 internally

// Reading: 'company' returns the raw FK ID from the database
{ fields: 'id, name, company' }
// Returns: {"id": 1, "name": "Deep Cleaning", "company": 1}  (integer, no object fetch!)

// Expanding: Only fetches related object when explicitly requested
{ fields: 'id, name, company.name, company.address' }
// Returns: {"id": 1, "name": "Deep Cleaning", "company": {"name": "Acme Corp", "address": "..."}}

Benefits:

  • Efficient: Requesting company returns the raw company_id column — no database join
  • Simple: Pass FK values as integers directly
  • Explicit: Related objects only fetched when you explicitly request nested fields

This applies to all CRUD operations (add, edit, get, list).

Filtering

// Simple equality
{ filters: { status: 'confirmed' } }


// With operators
{ filters: { 'price.gte': 100, 'price.lte': 500 } }

// Text search
{ filters: { 'name.icontains': 'khan' } }

// List membership
{ filters: { 'status.in': ['pending', 'confirmed', 'completed'] } }

// OR conditions
{ filters: { or: { status: 'pending', 'customer.vip': true } } }

// NOT conditions
{ filters: {
    not: {
        status: 'cancelled'
        }
    }
}

// Complex composition
{
    filters: {
        'created_at.gte': '2024-01-01',
        or: {
            status: 'confirmed' ,
            and: {
                status: 'pending',
                urgent: true
            }
        }
    }
}

Supported Operators:

Category Operators
Comparison lt, lte, gt, gte, exact, iexact, in, isnull, range
Text contains, icontains, startswith, istartswith, endswith, iendswith, regex, iregex
Date/Time date, year, month, day, week_day, hour, minute, second

Pagination

{
    limit: 20,      // Number of results (default: 50, max: 200)
    offset: 0,      // Starting position
    order_by: '-created_at'  // Sort order (prefix with - for descending)
}

Response includes pagination info:

{
    "pagination": {
        "offset": 0,
        "limit": 20,
        "has_more": true,
        "next": {
            "fields": "...",
            "filters": {...},
            "limit": 20,
            "offset": 20
        }
    }
}

Permission Configuration

Django-Flex uses a strict deny-by-default security model. Nothing is allowed unless explicitly granted.

Quick Reference

Config Value Meaning
"*" Allow all (full access)
{}, [], "", None Deny all (no access)

The "*" Shorthand

Use "*" to grant full access to a role:

DJANGO_FLEX = {
    'EXPOSE': {
        'user': {
            'superuser': '*',  # Full access to everything
        },
        'booking': {
            'admin': '*',      # Full access to bookings
            'staff': {...},    # Explicit permissions
        },
    },
}

The "*" shorthand expands to:

{
    'rows': '*',      # All rows (no filter)
    'fields': ['*'],  # All fields including nested
    'filters': '*',   # All filters
    'order_by': '*',  # All order_by
    'ops': ['get', 'list', 'add', 'edit', 'delete'],
}

Explicit Permissions

DJANGO_FLEX = {
    'EXPOSE': {
        'booking': {
            # Fields excluded from wildcard expansion
            'exclude': ['internal_notes', 'stripe_payment_id'],

            'owner': {
                # Row-level: which rows can this role see?
                'rows': lambda user: Q(created_by=user),

                # Field-level: which fields can they access?
                # "*" matches ALL fields including nested
                'fields': ['*'],

                # Filter-level: EACH filter+operator must be listed
                'filters': [
                    'status',              # Only exact: status=X
                    'status.in',           # status.in=[A,B]
                    'status.icontains',    # status.icontains=X
                    'created_at.gte',      # created_at.gte=DATE
                    'created_at.lte',      # created_at.lte=DATE
                ],

                # Order-level: which fields can they sort by?
                'order_by': ['created_at', '-created_at'],

                # Operation-level: which actions?
                'ops': ['get', 'list'],
            },

            # Empty config = NO ACCESS
            'viewer': {},

            # Roles not listed = NO ACCESS
        },
    },
}

Important: Filters require explicit operator grants. 'status' does NOT auto-allow 'status.in' or 'status.gte'. Each must be listed separately.

Custom Role Resolution (ROLE_RESOLVER)

By default, Django-Flex resolves roles using Django's built-in auth system (superuser → staff → groups → authenticated → anon).

For custom logic, configure ROLE_RESOLVER — a callable that takes (user, model_name) and returns either:

  • String: Just the role name
  • Tuple: (role_name, row_filter) for centralized row-level security
# settings.py
from django.db.models import Q

def my_role_resolver(user, model_name):
    """Custom role resolver with row-level security."""
    if not user.is_authenticated:
        return 'anon'

    # Get user's company membership
    membership = user.company_memberships.filter(is_active=True).first()
    if not membership:
        return 'authenticated'

    # Return role AND row filter (centralized security)
    company_filter = Q(company_id=membership.company_id)

    if membership.role == 'admin':
        return ('admin', company_filter)
    elif membership.role == 'staff':
        return ('staff', company_filter)
    else:
        return ('viewer', company_filter)

DJANGO_FLEX = {
    'ROLE_RESOLVER': my_role_resolver,
    'EXPOSE': {
        'booking': {
            'admin': {
                'fields': ['*'],
                'ops': ['get', 'list', 'add', 'edit', 'delete'],
                # No 'rows' needed — resolver provides it!
            },
            'staff': {
                'fields': ['id', 'status', 'customer.name'],
                'ops': ['get', 'list'],
                # No 'rows' needed — resolver provides it!
            },
        },
    },
}

Row Filter Precedence:

  1. Config rows (if specified) — overrides resolver
  2. Resolver's row_filter — used if no config rows
  3. No filter — all rows allowed

This allows centralized, model-agnostic row security while still permitting per-model overrides when needed.

Usage Patterns

1. Class-Based View (Recommended)

from django_flex import FlexQueryView

class BookingQueryView(FlexQueryView):
    model = Booking
    require_auth = True
    allowed_actions = ['get', 'list']
    flex_permissions = {...}

2. Function Decorator

from django_flex import flex_query
from django.http import JsonResponse

@flex_query(
    model=Booking,
    allowed_fields=['id', 'status', 'customer.name'],
    allowed_filters=['status', 'status.in'],
)
def booking_list(request, result, query_spec):
    return JsonResponse(result.to_dict())

3. Programmatic Usage

from django_flex import FlexQuery

def my_view(request):
    result = FlexQuery(Booking).execute({
        'fields': 'id, customer.name',
        'filters': {'status': 'confirmed'},
        'limit': 20,
    }, user=request.user)

    return JsonResponse(result.to_dict())

4. Middleware (Single Endpoint)

# settings.py
MIDDLEWARE = [
    ...
    'django_flex.middleware.FlexQueryMiddleware',
]

DJANGO_FLEX = {
    'MIDDLEWARE_PATH': '/api/',
    ...
}

The middleware supports two styles of API access:

Style A: JSON Body (Single Endpoint)

All requests go to /api/ with model and action in the body:

fetch('/api/', {
    method: 'POST',
    body: JSON.stringify({
        _model: 'booking',
        _action: 'list',
        fields: 'id, status',
        limit: 20,
    }),
});

Style B: RESTful URLs (Recommended)

Use standard REST patterns with HTTP method mapping:

# Query all bookings (GET → query action)
curl http://localhost:8000/api/bookings/

# Get single booking by ID (GET with ID → get action)
curl http://localhost:8000/api/bookings/1

# Create booking (POST → create action)
curl -X POST http://localhost:8000/api/bookings/ \
  -H "Content-Type: application/json" \
  -d '{"customer_id": 1, "status": "pending"}'

# Update booking (PUT/PATCH → update action)
curl -X PUT http://localhost:8000/api/bookings/1 \
  -H "Content-Type: application/json" \
  -d '{"status": "confirmed"}'

# Delete booking (DELETE → delete action)
curl -X DELETE http://localhost:8000/api/bookings/1

HTTP Method Mapping:

Method URL Pattern Action
GET /api/{model}/ query
GET /api/{model}/{id} get
POST /api/{model}/ create
PUT/PATCH /api/{model}/{id} update
DELETE /api/{model}/{id} delete

You can still pass query options in the body for RESTful requests:

// GET /api/bookings/ with body for filtering
fetch('/api/bookings/', {
    method: 'GET',
    body: JSON.stringify({
        fields: 'id, status, customer.name',
        filters: { status: 'confirmed' },
        limit: 20,
    }),
});

Configuration Reference

DJANGO_FLEX = {
    # Pagination
    'DEFAULT_LIMIT': 50,        # Default page size
    'MAX_LIMIT': 200,           # Maximum page size (hard cap)

    # Security
    'MAX_RELATION_DEPTH': 2,    # Max depth for nested fields/filters
    'REQUIRE_AUTHENTICATION': True,  # Require auth by default
    'AUDIT_QUERIES': False,     # Log all queries (for debugging)

    # Response behavior
    'ALWAYS_HTTP_200': False,   # When True, all responses return HTTP 200

    # Role resolution
    'ROLE_RESOLVER': None,      # Optional: callable(user, model_name) -> str or (str, Q)

    # Middleware
    'MIDDLEWARE_PATH': '/api/',  # Path for middleware endpoint

    # Optional: versioned APIs with independent settings
    'VERSIONS': {
        'v1': {'path': '/api/v1/', 'EXPOSE': {...}},
        'v2': {'path': '/api/v2/', 'EXPOSE': {...}},
    },

    # Model permissions (see Permission Configuration above)
    'EXPOSE': {...},
}

API Versioning

Run unversioned /api/ alongside versioned /api/v1/, /api/v2/ with different settings per version:

DJANGO_FLEX = {
    'MIDDLEWARE_PATH': '/api/',  # Unversioned endpoint
    'EXPOSE': {...},        # Top-level = unversioned settings
    'MAX_LIMIT': 200,

    'VERSIONS': {
        'v1': {
            'path': '/api/v1/',
            'EXPOSE': {...},  # v1-specific permissions
            'MAX_LIMIT': 100,      # v1-specific limit
        },
        'v2': {
            'path': '/api/v2/',
            'EXPOSE': {...},  # v2-specific permissions
            'MAX_LIMIT': 200,
        },
    },
}

Rate Limiting

Rate limits can be configured at multiple levels (most specific wins):

DJANGO_FLEX = {
    'EXPOSE': {
        'booking': {
            # Model-level: integer = same for all ops
            'rate_limit': 60,

            # OR dict for per-operation limits
            # 'rate_limit': {'default': 60, 'list': 30, 'get': 120},

            # Anonymous users - very restricted
            'anon': {
                'fields': ['id', 'status'],
                'ops': ['list'],
                'rate_limit': 5,  # Only 5 requests/minute for anon
            },

            'authenticated': {
                'fields': ['*'],
                'ops': ['get', 'list'],
                'rate_limit': 50,
            },

            'staff': {
                'fields': ['*'],
                'ops': ['get', 'list'],
                'rate_limit': 200,  # Staff gets higher limits
            },
        },
    },
}

When rate limit is exceeded, returns HTTP 429 with Retry-After header:

{ "error": "Rate limit exceeded", "retry_after": 45 }

Response Format

Responses use HTTP status codes (200, 400, 401, 403, 404) to indicate success/failure.

Successful Single Object (get) - HTTP 200

{
    "id": 1,
    "status": "confirmed",
    "customer": {
        "name": "Aisha Khan",
        "email": "aisha@example.com"
    }
}

Successful Query (query) - HTTP 200

{
    "pagination": {
        "offset": 0,
        "limit": 20,
        "has_more": true,
        "next": {...}
    },
    "results": {
        "1": {...},
        "2": {...}
    }
}

Error Response - HTTP 400/401/403/404

{
    "error": "Access denied: field 'secret_field' not accessible"
}

Why Django-Flex?

Feature Django-Flex GraphQL REST
Learning curve Low (Django-native) High Low
Field selection ❌ (fixed endpoints)
Dynamic filtering Limited
Built-in security Manual Manual
Django integration Native Requires graphene Native
Schema definition Optional Required N/A
N+1 prevention Automatic Manual Manual

Contributing

Contributions are welcome! Please see CONTRIBUTING.md for guidelines.

License

MIT License — see LICENSE for details.

Project details


Download files

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

Source Distribution

django_flex-26.1.7.tar.gz (68.3 kB view details)

Uploaded Source

Built Distribution

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

django_flex-26.1.7-py3-none-any.whl (52.4 kB view details)

Uploaded Python 3

File details

Details for the file django_flex-26.1.7.tar.gz.

File metadata

  • Download URL: django_flex-26.1.7.tar.gz
  • Upload date:
  • Size: 68.3 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.14.2

File hashes

Hashes for django_flex-26.1.7.tar.gz
Algorithm Hash digest
SHA256 c5f53179fe21e830a838286447b49d7a15116229ff58fa76c398938dfcab6b1b
MD5 3ddc22dba639d4a2f2a87395fad1847b
BLAKE2b-256 57787b852bc95dfab8cd6539b299d16813d4f55c8b82034cbfd31d49dc5a6f10

See more details on using hashes here.

File details

Details for the file django_flex-26.1.7-py3-none-any.whl.

File metadata

  • Download URL: django_flex-26.1.7-py3-none-any.whl
  • Upload date:
  • Size: 52.4 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.14.2

File hashes

Hashes for django_flex-26.1.7-py3-none-any.whl
Algorithm Hash digest
SHA256 9c537294ff296196e171208097ce1c2aed674176dfdf9b0ed3fea0147d61ce31
MD5 7dacb4d430361f9509096ac578afde74
BLAKE2b-256 3035ec787cfcb438b4d5b94b811e5b6c4f55cb30649ef1f4563c7bfca3ff66ce

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