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 14, 2019
0 parents commit a7a7b7e
Show file tree
Hide file tree
Showing 13 changed files with 556 additions and 0 deletions.
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
__pycache__
*.pyc

_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: .
17 changes: 17 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
django-flexquery
================

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)
* 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.
10 changes: 10 additions & 0 deletions django_flexquery/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
try:
from .flexquery import Manager, Q, FlexQuery, QuerySet
except ImportError:
# Django is missing, just provide version
__all__ = []
else:
__all__ = ["FlexQuery", "Manager", "Q", "QuerySet"]


__version__ = "0.0.0"
194 changes: 194 additions & 0 deletions django_flexquery/flexquery.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
"""
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):
return "<Unbound %s %r>" % (
self.__name__,
(self.filter_func if self.q_func is None else self.q_func).__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__,
(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__, (cls,), {"q_func": staticmethod(func)}
)


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
20 changes: 20 additions & 0 deletions docs/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Minimal makefile for Sphinx documentation
#

# You can set these variables from the command line, and also
# from the environment for the first two.
SPHINXOPTS ?=
SPHINXBUILD ?= sphinx-build
SOURCEDIR = .
BUILDDIR = _build

# Put it first so that "make" without argument is like "make help".
help:
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)

.PHONY: help Makefile

# Catch-all target: route all unknown targets to Sphinx using the new
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
%: Makefile
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
5 changes: 5 additions & 0 deletions docs/api.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
API Documentation
=================

.. automodule:: django_flexquery
:members:
62 changes: 62 additions & 0 deletions docs/conf.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Configuration file for the Sphinx documentation builder.
#
# This file only contains a selection of the most common options. For a full
# list see the documentation:
# http://www.sphinx-doc.org/en/master/config

# -- Path setup --------------------------------------------------------------

# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
#
import os
import sys
sys.path.insert(0, os.path.abspath('..'))


# -- Project information -----------------------------------------------------

project = 'django-flexquery'
import datetime
copyright = '2019-{}, Robert Schindler'.format(datetime.date.today().year)
author = 'Robert Schindler'

from django_flexquery import __version__
# The full version, including alpha/beta/rc tags
release = __version__


# -- General configuration ---------------------------------------------------

# Add any Sphinx extension module names here, as strings. They can be
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones.
extensions = [
'sphinx.ext.autodoc',
'sphinx.ext.napoleon',
]

# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']

# The master toctree document.
master_doc = 'index'

# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
# This pattern also affects html_static_path and html_extra_path.
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']


# -- Options for HTML output -------------------------------------------------

# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
#
html_theme = 'sphinx_rtd_theme'

# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ['_static']
50 changes: 50 additions & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
django-flexquery
================

.. toctree::
:hidden:

Introduction <self>
usage
api

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)
* Fully documented code, formatted using the excellent `Black Code Formatter
<https://github.com/python/black>`_.

When referencing a related model in a database query, you usually have the choice
between using a JOIN (``X.objects.filter(y__z=2)``) or performing a sub-query
(``X.objects.filter(y__in=Y.objects.filter(z=2))``).

We don't want to judge which one is better, because that depends on the concrete
query and how the database engine in use optimizes it. In many cases, it will hardly
make a noticeable difference at all. However, when the amount of data grows, doing
queries right can save you and the users of your application several seconds, and
that is what django-flexquery is for.

Have a look at the :doc:`usage` and :doc:`api` to get an idea of how it works.


Requirements
------------

Django 2.0+ is required.


Installation
------------

::

pip install 'git+git://github.com/efficiosoft/django-flexquery#master'

No changes to your Django settings are required; no ``INSTALLED_APPS``, no
``MIDDLEWARE_CLASSES``.

0 comments on commit a7a7b7e

Please sign in to comment.