Skip to content

Commit

Permalink
Initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
Robert Schindler committed Jul 15, 2019
0 parents commit c8fcdf1
Show file tree
Hide file tree
Showing 21 changed files with 850 additions and 0 deletions.
2 changes: 2 additions & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[run]
source=django_flexquery
14 changes: 14 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
__pycache__
*.pyc

build
dist
*.egg-info

.coverage
.tox

_build

*.swp
*.swo
14 changes: 14 additions & 0 deletions .readthedocs.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
version: 2

sphinx:
configuration: docs/conf.py

# Optionally build your docs in additional formats such as PDF and ePub
formats: all

python:
version: 3.7
install:
- requirements: docs/requirements.txt
- method: pip
path: .
25 changes: 25 additions & 0 deletions .travis.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
language: python
dist: xenial

cache:
directories:
- "$HOME/.cache/pip"

python:
- "3.7"

install:
- pip install tox-travis

jobs:
include:
- stage: style
script: tox -e style

- stage: test
script: tox -e django22
- script: tox -e django21
- script: tox -e django20

- stage: coveralls
script: tox -e coveralls
25 changes: 25 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
django-flexquery
================

.. image:: https://travis-ci.org/efficiosoft/django-flexquery.svg?branch=master
:alt: Build Status
:target: https://travis-ci.org/efficiosoft/django-flexquery
.. image:: https://coveralls.io/repos/github/efficiosoft/django-flexquery/badge.svg?branch=master
:alt: Test Coverage
:target: https://coveralls.io/github/efficiosoft/django-flexquery?branch=master

This library aims to provide a new way of declaring reusable QuerySet filtering
logic in your Django project, incorporating the DRY principle and maximizing user
experience and performance by allowing you to decide between sub-queries and JOINs.

Its strengths are, among others:

* Easy to learn in minutes
* Cleanly integrates with Django's ORM
* Small code footprint, less bugs - ~150 lines of code (LoC)
* 100% test coverage
* Fully documented code, formatted using the excellent `Black Code Formatter
<https://github.com/python/black>`_.

See the `documentation at Read The Docs <https://django-flexquery.readthedocs.org>`_
to convince yourself.
14 changes: 14 additions & 0 deletions django_flexquery/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
"""
Reusable QuerySet filtering logic for Django.
"""

try:
from .flexquery import Manager, Q, FlexQuery, QuerySet
except ImportError:
# Django is missing, just provide version
__all__ = []
else:
__all__ = ["FlexQuery", "Manager", "Q", "QuerySet"]


__version__ = "1.0.0"
220 changes: 220 additions & 0 deletions django_flexquery/flexquery.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
"""
This module provides a convenient way to declare custom filtering logic with Django's
model managers in a reusable fashion using Q objects.
"""

import inspect

from django.core.exceptions import ImproperlyConfigured
from django.db.models import Manager as _Manager, Q as _Q, QuerySet as _QuerySet
from django.db.models.constants import LOOKUP_SEP
from django.utils.decorators import classonlymethod


class FlexQueryType(type):
"""
Custom metaclass for FlexQuery that implements the descriptor pattern.
"""

def __get__(self, instance, owner):
if isinstance(instance, (_Manager, _QuerySet)):
# Return a new FlexQuery instance bound to the holding object
return self(instance)
# Otherwise, preserve the unbound FlexQuery type
return self

def __repr__(self):
if issubclass(self, FlexQuerySubTypeMixin):
return "<Unbound %s %r>" % (
self.__name__,
(self.filter_func if self.q_func is None else self.q_func).__name__,
)
return "<Unbound %s>" % self.__name__


class FlexQuery(metaclass=FlexQueryType):
"""
Flexibly provides model-specific query constraints as an attribute of Manager
or QuerySet objects.
When a sub-type of FlexQuery is accessed as class attribute of a Manager or
QuerySet object, its metaclass, which is implemented as a descriptor, will
automatically initialize and return an instance of the FlexQuery type bound to
the holding Manager or QuerySet.
.. automethod:: __call__
.. automethod:: __init__
"""

# Will be set when creating sub-types
filter_func = None
q_func = None

def __call__(self, *args, **kwargs):
"""Filters the base QuerySet with the provided filter function or Q object.
All arguments are passed through to the FlexQuery's custom function.
:returns QuerySet:
"""
if self.filter_func is not None:
return self.filter_func(self.base.all(), *args, **kwargs)
return self.base.filter(self.q_func(*args, **kwargs))

def __init__(self, base):
"""Initializes a FlexQuery that will perform its filtering on the given base.
:param base: Instance to use as base for filtering.
:type base: Manager, QuerySet
:raises ImproperlyConfigured:
If this FlexQuery type wasn't created by one of the from_*() classmethods.
:raises TypeError: If base is of unsupported type.
"""
if self.filter_func is None and self.q_func is None:
raise ImproperlyConfigured(
"Use one of the from_*() classmethods to create a FlexQuery type."
)
if not isinstance(base, (_Manager, _QuerySet)):
raise TypeError(
"Can only bind %s to a Manager or QuerySet, but not to %r."
% (self.__class__.__name__, base)
)
self.base = base

def __repr__(self):
return "<Bound %s %r, base=%r>" % (
self.__class__.__name__,
(self.filter_func if self.q_func is None else self.q_func).__name__,
self.base,
)

def as_q(self, *args, **kwargs):
"""Returns a Q object representing the custom filters.
The Q object is either retrieved from the configured Q function or, if a
filter function was provided instead, will contain a sub-query ensuring the
object is in the base QuerySet filtered by that filter function.
All arguments are passed through to the FlexQuery's custom function.
:returns Q:
"""
if self.filter_func is not None:
return Q(pk__in=self.filter_func(self.base.all(), *args, **kwargs))
return self.q_func(*args, **kwargs)

@classonlymethod
def from_filter(cls, func):
"""Creates a FlexQuery type from a filter function.
:param func:
Callable taking a QuerySet as first positional argument, applying some
filtering on it and returning it back.
:type func: callable
:returns FlexQueryType:
"""
return type(cls)(
"%sFromFilterFunction" % cls.__name__,
(FlexQuerySubTypeMixin, cls),
{"filter_func": staticmethod(func)},
)

@classonlymethod
def from_q(cls, func):
"""Creates a FlexQuery type from a Q function.
:param func: Callable returning a Q object.
:type func: callable
:returns FlexQueryType:
"""
return type(cls)(
"%sFromQFunction" % cls.__name__,
(FlexQuerySubTypeMixin, cls),
{"q_func": staticmethod(func)},
)


class FlexQuerySubTypeMixin:
"""
Mixin that prevents further usage of the from_*() classmethods on sub-types
of FlexQuery.
"""

@classonlymethod
def _already_initialized(cls):
raise NotImplementedError(
"%r was already initialized with a function. Use FlexQuery.from_*() "
"directly to derive new FlexQuery types." % cls
)

@classonlymethod
def from_filter(cls, func):
cls._already_initialized()

@classonlymethod
def from_q(cls, func):
cls._already_initialized()


class Manager(_Manager):
"""
Use this Manager class' from_queryset() method if you want to derive a Manager from
a QuerySet that has FlexQuery's defined. If Django's native Manager.from_queryset()
was used instead, all FlexQuery's would be lost.
"""

@classmethod
def _get_queryset_methods(cls, queryset_class):
methods = super()._get_queryset_methods(queryset_class)
methods.update(
inspect.getmembers(
queryset_class,
predicate=lambda member: isinstance(member, FlexQueryType)
and not getattr(member, "queryset_only", None),
)
)
return methods


class Q(_Q):
"""
A custom Q implementation that allows prefixing existing Q objects with some
related field name dynamically.
"""

def prefix(self, prefix):
"""Recursively copies the Q object, prefixing all lookup keys.
The prefix and the existing filter key are delimited by the lookup separator __.
Use this feature to delegate existing query constraints to a related field.
:param prefix:
Name of the related field to prepend to existing lookup keys. This isn't
restricted to a single relation level, something like "tree__fruit"
is perfectly valid as well.
:type prefix: str
:returns Q:
"""
return type(self)(
*(
child.prefix(prefix)
if isinstance(child, Q)
else (prefix + LOOKUP_SEP + child[0], child[1])
for child in self.children
),
_connector=self.connector,
_negated=self.negated,
)


class QuerySet(_QuerySet):
"""
Adds support for deriving a Manager from QuerySet via as_manager(), preserving
the FlexQuery's.
"""

@classmethod
def as_manager(cls):
manager = Manager.from_queryset(cls)()
manager._built_with_as_manager = True
return manager
Empty file.
1 change: 1 addition & 0 deletions django_flexquery_tests/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .tests import AModel
8 changes: 8 additions & 0 deletions django_flexquery_tests/settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
"""
Django settings for tests of the django-flexquery project.
"""

SECRET_KEY = "om-l3b3%$jutfygk%pyg#i0hp@g$v4b=-jcsa_)+%^7nig@up4"
DEBUG = True
INSTALLED_APPS = ["django_flexquery_tests"]
DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}}

0 comments on commit c8fcdf1

Please sign in to comment.