├── tests ├── __init__.py ├── minipet_app.py ├── test_filter.py ├── test_filter_schema.py ├── test_ordered_search.py ├── test_query_with_filters.py ├── test_flask_filter.py └── test_filter_typecheck.py ├── setup.cfg ├── documentation └── source │ ├── _static │ ├── routes.png │ ├── filter-flask.png │ └── routes-search.png │ ├── changelog.rst │ ├── index.rst │ ├── dev-guide.rst │ ├── conf.py │ └── tutorial.rst ├── flask_filter ├── __init__.py ├── query_filter.py ├── filters │ ├── __init__.py │ └── filters.py ├── base.py └── schemas.py ├── Makefile ├── setup.py ├── make.bat ├── requirements.txt ├── .github └── workflows │ ├── python-package.yml │ └── python-publish.yml ├── LICENSE ├── publish.sh ├── .gitignore └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [aliases] 2 | test=pytest 3 | 4 | [tool:pytest] 5 | norecursedirs=venv/* -------------------------------------------------------------------------------- /documentation/source/_static/routes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/exleym/Flask-Filter/HEAD/documentation/source/_static/routes.png -------------------------------------------------------------------------------- /documentation/source/_static/filter-flask.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/exleym/Flask-Filter/HEAD/documentation/source/_static/filter-flask.png -------------------------------------------------------------------------------- /documentation/source/_static/routes-search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/exleym/Flask-Filter/HEAD/documentation/source/_static/routes-search.png -------------------------------------------------------------------------------- /flask_filter/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '0.1.1' 2 | __author__ = 'Exley McCormick' 3 | __email__ = 'exleym@gmail.com' 4 | 5 | from .base import FlaskFilter 6 | -------------------------------------------------------------------------------- /flask_filter/query_filter.py: -------------------------------------------------------------------------------- 1 | from .schemas import deserialize_filters 2 | 3 | 4 | def query_with_filters(class_, filters, schema=None, order_by=None): 5 | _filters = deserialize_filters(filters, many=True) 6 | query = class_.query 7 | for f in _filters: 8 | query = f.apply(query, class_, schema) 9 | if order_by: 10 | query = query.order_by(order_by) 11 | return query.all() 12 | -------------------------------------------------------------------------------- /flask_filter/filters/__init__.py: -------------------------------------------------------------------------------- 1 | from .filters import ( 2 | LTFilter, 3 | LTEFilter, 4 | EqualsFilter, 5 | GTFilter, 6 | GTEFilter, 7 | InFilter, 8 | NotEqualsFilter, 9 | LikeFilter, 10 | ContainsFilter 11 | ) 12 | 13 | 14 | FILTERS = [ 15 | LTFilter, 16 | LTEFilter, 17 | EqualsFilter, 18 | GTFilter, 19 | GTEFilter, 20 | InFilter, 21 | NotEqualsFilter, 22 | LikeFilter, 23 | ContainsFilter 24 | ] 25 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SOURCEDIR = documentation/source 8 | BUILDDIR = documentation/build 9 | 10 | # Put it first so that "make" without argument is like "make help". 11 | help: 12 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 13 | 14 | .PHONY: help Makefile 15 | 16 | # Catch-all target: route all unknown targets to Sphinx using the new 17 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 18 | %: Makefile 19 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /documentation/source/changelog.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | The Change Log for this project contains all meaningful changes to the function 4 | of the code base. It does not necessarily include changes to the form of the 5 | code, i.e. refactoring. 6 | 7 | * 2022-09-08 (v0.1.1): support nullable equals and not-equals operators and 8 | move off of Travis CI and onto GitHub Actions. This update was created 9 | to resolve an issue reported by @topermaper 10 | 11 | * 2020-09-08 (v0.1.0dev5): added support for Marshmallow 2, along with a 12 | deprecation warning that it will be removed in future versions. This was 13 | implemented via a deserializer function located in the "schemas" module. 14 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | 2 | from setuptools import find_packages, setup 3 | 4 | setup( 5 | name='Flask-Filter', 6 | version='0.1.1', 7 | author="Exley McCormick", 8 | author_email="exleym@gmail.com", 9 | description="A Flask extension for creating standard resource searches", 10 | packages=find_packages(exclude=["tests", "docs", "contrib"]), 11 | license='Creative Commons Attribution-Noncommercial-Share Alike license', 12 | long_description=open('README.md').read(), 13 | long_description_content_type="text/markdown", 14 | url="https://github.com/exleym/Flask-Filter ", 15 | setup_requires=["pytest-runner"], 16 | tests_require=["pytest", "marshmallow", "sqlalchemy", "Flask-SQLAlchemy"], 17 | ) 18 | -------------------------------------------------------------------------------- /make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | alabaster==0.7.12 2 | atomicwrites==1.3.0 3 | attrs==19.3.0 4 | Babel==2.10.3 5 | bleach==5.0.1 6 | certifi==2018.11.29 7 | chardet==3.0.4 8 | charset-normalizer==2.1.1 9 | click==8.1.3 10 | coverage==4.5.4 11 | docutils==0.14 12 | Flask==2.2.2 13 | flask-marshmallow==0.9.0 14 | Flask-SQLAlchemy==2.5.1 15 | idna==2.8 16 | imagesize==1.1.0 17 | importlib-metadata==4.12.0 18 | iniconfig==1.1.1 19 | itsdangerous==2.1.2 20 | Jinja2==3.1.2 21 | MarkupSafe==2.1.1 22 | marshmallow==3.2.0 23 | marshmallow-sqlalchemy==0.15.0 24 | more-itertools==7.2.0 25 | packaging==18.0 26 | pkginfo==1.5.0.1 27 | pluggy==0.13.0 28 | py==1.11.0 29 | Pygments==2.13.0 30 | pyparsing==2.3.0 31 | pytest==7.1.3 32 | pytest-cov==2.8.1 33 | pytz==2018.7 34 | readme-renderer==24.0 35 | requests==2.28.1 36 | requests-toolbelt==0.9.1 37 | six==1.11.0 38 | snowballstemmer==1.2.1 39 | Sphinx==1.8.2 40 | sphinxcontrib-websupport==1.1.0 41 | SQLAlchemy==1.3.19 42 | tomli==2.0.1 43 | tqdm==4.36.1 44 | twine==2.0.0 45 | urllib3==1.26.5 46 | wcwidth==0.1.7 47 | webencodings==0.5.1 48 | Werkzeug==2.2.2 49 | zipp==0.6.0 50 | -------------------------------------------------------------------------------- /documentation/source/index.rst: -------------------------------------------------------------------------------- 1 | .. Flask-Filter documentation master file, created by 2 | sphinx-quickstart on Thu Dec 20 08:32:01 2018. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Flask-Filter: A simple search extension 7 | ======================================= 8 | Flask-Filter provides a simple extension to the Flask web framework that 9 | provides detailed search functionality for REST APIs. 10 | 11 | The search functionality turns a JSON list of `Filter` objects of the form 12 | 13 | `{"field": "name", "op": "=", "value": "Fido"}` 14 | 15 | into chained filters on a SQLAlchemy query. By leveraging the great work done 16 | by the SQLAlchemy and Marshmallow teams, we are able to easily provide a 17 | standardized search function to REST APIs. 18 | 19 | .. toctree:: 20 | :maxdepth: 2 21 | :caption: Contents: 22 | 23 | tutorial 24 | dev-guide 25 | changelog 26 | 27 | 28 | 29 | Indices and tables 30 | ================== 31 | 32 | * :ref:`genindex` 33 | * :ref:`modindex` 34 | * :ref:`search` 35 | -------------------------------------------------------------------------------- /.github/workflows/python-package.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Python package 5 | 6 | on: 7 | push: 8 | branches: [ "master" ] 9 | pull_request: 10 | branches: [ "master" ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | python-version: ["3.7", "3.8", "3.9", "3.10"] 20 | 21 | steps: 22 | - uses: actions/checkout@v3 23 | - name: Set up Python ${{ matrix.python-version }} 24 | uses: actions/setup-python@v3 25 | with: 26 | python-version: ${{ matrix.python-version }} 27 | - name: Install dependencies 28 | run: | 29 | python -m pip install --upgrade pip 30 | python -m pip install pytest 31 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 32 | - name: Test with pytest 33 | run: | 34 | pytest 35 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | # This workflow uses actions that are not certified by GitHub. 5 | # They are provided by a third-party and are governed by 6 | # separate terms of service, privacy policy, and support 7 | # documentation. 8 | 9 | name: Upload Python Package 10 | 11 | on: push 12 | 13 | permissions: 14 | contents: read 15 | 16 | jobs: 17 | deploy: 18 | 19 | runs-on: ubuntu-latest 20 | 21 | steps: 22 | - uses: actions/checkout@v3 23 | - name: Set up Python 24 | uses: actions/setup-python@v3 25 | with: 26 | python-version: '3.x' 27 | - name: Install dependencies 28 | run: | 29 | python -m pip install --upgrade pip 30 | pip install build 31 | - name: Build package 32 | run: python -m build 33 | - name: Publish distribution 📦 to Test PyPI 34 | uses: pypa/gh-action-pypi-publish@release/v1 35 | with: 36 | user: __token__ 37 | password: ${{ secrets.TEST_PYPI_API_TOKEN }} 38 | repository_url: https://test.pypi.org/legacy/ 39 | - name: Publish Distribution to PyPI 40 | if: startsWith(github.ref, 'refs/tags') 41 | uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 42 | with: 43 | user: __token__ 44 | password: ${{ secrets.PYPI_API_TOKEN }} 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2018, Exley McCormick 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /publish.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | 4 | set -e # exit with nonzero exit code if anything fails 5 | 6 | 7 | if [[ $TRAVIS_BRANCH == "master" && $TRAVIS_PULL_REQUEST == "false" ]]; then 8 | #if [[ 1 == 1 ]]; then 9 | echo "Starting to update gh-pages\n" 10 | 11 | # Make static docs with Sphinx-Build and copy it to clean mydoc/ 12 | make clean 13 | make html 14 | rm -rf mydoc 15 | mkdir mydoc 16 | 17 | # Clone gh-pages branch and copy static docs into new repo 18 | cd mydoc 19 | #using token clone gh-pages branch 20 | git clone --quiet --branch=gh-pages https://${GH_TOKEN}@github.com/${GH_USER}/${GH_REPO}.git gh-pages > /dev/null 21 | echo "cloned gh-pages branch from ${GH_REPO}" 22 | echo "Directory looks like:" 23 | ls -al 24 | cd gh-pages 25 | echo "Clearing directory and adding new documentation" 26 | rm -r * 27 | touch .nojekyll 28 | cp -r ../../documentation/build/html/* . 29 | 30 | # Commit changes back to GitHub 31 | if [ -z "$(git status --porcelain)" ]; then 32 | # Working directory clean 33 | echo "nothing to commit. skipping publish stage" 34 | else 35 | # Uncommitted changes 36 | echo "Beginning publishing stage ..." 37 | echo "staging files ..." 38 | git add -f . 39 | echo "successful" 40 | 41 | echo "committing ..." 42 | git commit -m "sphinx documentation changes" 43 | echo "successul" 44 | 45 | echo "pushing to remote repository ..." 46 | git push -fq origin gh-pages > /dev/null 47 | echo "successful" 48 | fi 49 | 50 | echo "Done updating gh-pages" 51 | 52 | else 53 | echo "Skipped updating gh-pages, because build is not triggered from the master branch." 54 | fi; 55 | -------------------------------------------------------------------------------- /flask_filter/base.py: -------------------------------------------------------------------------------- 1 | from flask import Flask 2 | from flask_sqlalchemy import Model 3 | from marshmallow import Schema 4 | from typing import Union 5 | from flask_filter.schemas import deserialize_filters 6 | 7 | 8 | class FlaskFilter(object): 9 | __SCHEMA_MAP = {} 10 | 11 | def __init__(self, app: Flask = None): 12 | self.app = app 13 | if self.app: 14 | self.init_app(app) 15 | 16 | def init_app(self, app: Flask): 17 | """Callback for initializing application """ 18 | self.app = app 19 | 20 | def register_model(self, DbModel, ModelSchema): 21 | self.__SCHEMA_MAP[DbModel] = ModelSchema 22 | 23 | def search(self, DbModel: Model, filters: list, 24 | ModelSchema: Union[Schema, None] = None, 25 | limit: int = None, order_by=None): 26 | filters = deserialize_filters(filters, many=True) 27 | schema = ModelSchema or self._lookup_schema(DbModel) 28 | query = DbModel.query 29 | for f in filters: 30 | query = f.apply(query, DbModel, schema) 31 | if order_by: 32 | query = query.order_by(order_by) 33 | if limit: 34 | query = query.limit(limit) 35 | return query.all() 36 | 37 | def _lookup_schema(self, DbModel): 38 | model = self.__SCHEMA_MAP.get(DbModel) 39 | if not model: 40 | raise TypeError('You must either map a schema to Model: {} using `register_model`' 41 | 'or pass a schema to the `ModelSchema` parameter' 42 | 'when calling `search`'.format(DbModel)) 43 | return model 44 | -------------------------------------------------------------------------------- /flask_filter/schemas.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import marshmallow as ma 3 | from marshmallow.exceptions import ValidationError 4 | from flask_filter.filters import FILTERS 5 | 6 | 7 | __FILTER_MAP = {c.OP: c for c in FILTERS} 8 | __VALID_OPERATORS = {x.OP for x in FILTERS} 9 | _mm2 = ma.__version_info__[0] == 2 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | def _get_filter_class(operator): 14 | return __FILTER_MAP.get(operator) 15 | 16 | 17 | def validate_operator(value): 18 | if value not in __VALID_OPERATORS: 19 | message = {'op': [f"operator {value} is not supported"]} 20 | raise ValidationError(message) 21 | 22 | 23 | class FilterSchema(ma.Schema): 24 | field = ma.fields.String(required=True, allow_none=False) 25 | op = ma.fields.String(required=True, attribute="OP", validate=validate_operator) 26 | value = ma.fields.Field(required=True, allow_none=True) 27 | 28 | @ma.post_load 29 | def make_object(self, json, *args, **kwargs): 30 | op = json.get("OP") 31 | field = json.get("field") 32 | value = json.get("value") 33 | Class = _get_filter_class(op) 34 | return Class(field=field, value=value) 35 | 36 | 37 | _schema = FilterSchema() 38 | 39 | 40 | def deserialize_filters(data, *args, **kwargs): 41 | """ centralizes marshmallow v2/v3 api change handling to one place. 42 | in future version of this we can remove all mm2 support and this 43 | function will be a one-liner. 44 | """ 45 | data = _schema.load(data, *args, **kwargs) 46 | if _mm2: 47 | logger.warning("Marshmallow v2 is deprecated and will not be " 48 | "supported in future versions of FlaskFilter. " 49 | "Please upgrade to Marshmallow 3+") 50 | data = data.data 51 | return data 52 | -------------------------------------------------------------------------------- /tests/minipet_app.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from flask import Flask 3 | from flask_sqlalchemy import SQLAlchemy 4 | from marshmallow import Schema, fields 5 | 6 | from flask_filter import FlaskFilter 7 | 8 | 9 | db = SQLAlchemy() 10 | filtr = FlaskFilter() 11 | 12 | 13 | def create_app(env='test'): 14 | app = Flask('Mini Pet Store') 15 | app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:" 16 | app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False 17 | db.init_app(app) 18 | filtr.init_app(app) 19 | filtr.register_model(Dog, DogSchema) 20 | return app 21 | 22 | 23 | dog_toys = db.Table( 24 | "dog_toys", 25 | db.Column("dog_id", db.Integer, db.ForeignKey("dog.id")), 26 | db.Column("toy_id", db.Integer, db.ForeignKey("toy.id")) 27 | ) 28 | 29 | 30 | class Dog(db.Model): 31 | id = db.Column(db.Integer, primary_key=True) 32 | name = db.Column(db.String, unique=True) 33 | dob = db.Column(db.Date) 34 | weight = db.Column(db.Float) 35 | 36 | toys = db.relationship("Toy", secondary="dog_toys", backref="dogs") 37 | 38 | @property 39 | def age(self): 40 | return int((datetime.date.today() - self.dob).days / 365.25) 41 | 42 | def __repr__(self): 43 | return f"" 44 | 45 | 46 | class DogSchema(Schema): 47 | id = fields.Integer() 48 | name = fields.String() 49 | dateOfBirth = fields.Date(attribute='dob') 50 | weight = fields.Float() 51 | toys = fields.List(fields.Nested("ToySchema")) 52 | 53 | 54 | class Toy(db.Model): 55 | id = db.Column(db.Integer, primary_key=True) 56 | name = db.Column(db.String(32), unique=True) 57 | 58 | def __repr__(self): 59 | return f"" 60 | 61 | 62 | class ToySchema(Schema): 63 | id = fields.Integer() 64 | name = fields.String() 65 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | mydoc/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | .idea/ 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | .hypothesis/ 50 | .pytest_cache/ 51 | 52 | # Translations 53 | *.mo 54 | *.pot 55 | 56 | # Django stuff: 57 | *.log 58 | local_settings.py 59 | db.sqlite3 60 | 61 | # Flask stuff: 62 | instance/ 63 | .webassets-cache 64 | 65 | # Scrapy stuff: 66 | .scrapy 67 | 68 | # Sphinx documentation 69 | docs/_build/ 70 | 71 | # PyBuilder 72 | target/ 73 | 74 | # Jupyter Notebook 75 | .ipynb_checkpoints 76 | 77 | # pyenv 78 | .python-version 79 | 80 | # celery beat schedule file 81 | celerybeat-schedule 82 | 83 | # SageMath parsed files 84 | *.sage.py 85 | 86 | # Environments 87 | .env 88 | .venv 89 | env/ 90 | venv/ 91 | ENV/ 92 | env.bak/ 93 | venv.bak/ 94 | venv-*/ 95 | 96 | # Spyder project settings 97 | .spyderproject 98 | .spyproject 99 | 100 | # Rope project settings 101 | .ropeproject 102 | 103 | # mkdocs documentation 104 | /site 105 | 106 | # mypy 107 | .mypy_cache/ 108 | 109 | # OSX Bullshit 110 | *.DS_Store 111 | 112 | -------------------------------------------------------------------------------- /tests/test_filter.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from datetime import date 3 | import unittest 4 | 5 | from marshmallow.exceptions import ValidationError 6 | 7 | from flask_filter.schemas import FilterSchema 8 | from flask_filter.filters import * 9 | 10 | 11 | class FilterSchemaTestClass(unittest.TestCase): 12 | 13 | def setUp(self): 14 | self.schema = FilterSchema() 15 | 16 | def tearDown(self): 17 | self.schema = None 18 | 19 | def test_ltfilter_accepts_floats(self): 20 | json = {"field": "weight", "op": "<", "value": 10.24} 21 | lt = self.schema.load(json) 22 | self.assertEqual(hash(lt), hash(("weight", "<", 10.24))) 23 | 24 | def test_filter_comparator_equal_for_identical_instances(self): 25 | json = {"field": "weight", "op": "<", "value": 10.24} 26 | instance_1 = self.schema.load(json) 27 | instance_2 = self.schema.load(json) 28 | self.assertEqual(instance_1, instance_2) 29 | 30 | def test_filter_comparator_not_equal_for_different_values(self): 31 | json_1 = {"field": "weight", "op": "<", "value": 10.24} 32 | json_2 = {"field": "weight", "op": "<", "value": 10.25} 33 | instance_1 = self.schema.load(json_1) 34 | instance_2 = self.schema.load(json_2) 35 | self.assertNotEqual(instance_1, instance_2) 36 | 37 | def test_filter_comparator_not_equal_for_different_op(self): 38 | json_1 = {"field": "weight", "op": "<", "value": 10.24} 39 | json_2 = {"field": "weight", "op": ">", "value": 10.24} 40 | instance_1 = self.schema.load(json_1) 41 | instance_2 = self.schema.load(json_2) 42 | self.assertNotEqual(instance_1, instance_2) 43 | 44 | def test_filter_comparator_not_equal_for_different_fields(self): 45 | json_1 = {"field": "weight", "op": "<", "value": 45} 46 | json_2 = {"field": "id", "op": "<", "value": 45} 47 | instance_1 = self.schema.load(json_1) 48 | instance_2 = self.schema.load(json_2) 49 | self.assertNotEqual(instance_1, instance_2) 50 | -------------------------------------------------------------------------------- /documentation/source/dev-guide.rst: -------------------------------------------------------------------------------- 1 | Developer's Guide 2 | ================= 3 | This section will outline the tools and processes used to maintain the 4 | project and ensure seamless publication of changes and improvements to 5 | the Python Package Index for easy installation with Pip. 6 | 7 | Testing 8 | ------- 9 | Testing is currently being done in old-style unittest.TestCase 10 | classes. Need to modernize this to pytest at some point. The tests 11 | reside in a top-level ``tests/`` directory and rely on a simple 12 | "MiniPet" pet-store application. This app has data-models and schemas, 13 | but basically nothing else (not even endpoints right now). 14 | 15 | Test coverage is calculated using the coverage module of Pytest, and a 16 | coverage badge is generated and hosted by `Coveralls`_. 17 | 18 | 19 | Building with Travis 20 | -------------------- 21 | The CI process for this library is run through travis.org. Thanks 22 | to the folks over at Travis CI for hosting open-source projects for 23 | free. 24 | 25 | * `Travis CI Build Dashboard`_ 26 | * `Travis Build Config`_ 27 | 28 | On pushing a commit to GitHub, Travis CI kicks off a build that runs 29 | tests, checks coverage, and pushes badge info where it needs to go. 30 | 31 | Deploying built Packages to PyPI 32 | -------------------------------- 33 | Currently, this part of the CI / CD process is not automated. To publish 34 | a feature / fix / change to PyPI as a new version of the library, you have 35 | to manually increment the build number, build the shippable package, and 36 | publish it to Test PyPI and followed by Prod PyPI. This section outlines 37 | the requirements to publish a release and provides some links. The 38 | `Python Packaging User Guide`_ provided by the Python Packaging Authority 39 | (PyPA) has everything you need to know, but the quick-and-dirty is here: 40 | 41 | 1. Increment the distribution version in both the ``__version__`` variable 42 | in the top-level ``__init__.py`` and in ``setup.py`` 43 | 2. Build the project locally, saving output to the ``dist/`` directory. 44 | 45 | ``$ python setup.py sdist bdist_wheel`` 46 | 47 | 3. Push these packages to Test PyPI. 48 | 49 | ``$ python -m twine upload --repository testpypi dist/*`` 50 | 51 | 4. Check that the pushed libraries work and can be installed via pip. 52 | 5. Push the validated packages to Production PyPI. 53 | 54 | ``$ python -m twine upload dist/*`` 55 | 56 | Both steps (3) and (5) will prompt you for your username and password, 57 | which are different for the production and test instances of PyPI. 58 | 59 | 60 | .. _Travis CI Build Dashboard: https://travis-ci.org/github/exleym/Flask-Filter 61 | .. _Travis Build Config: https://github.com/exleym/Flask-Filter/blob/master/.travis.yml 62 | .. _Python Packaging User Guide: https://packaging.python.org/tutorials/packaging-projects/ 63 | .. _Coveralls: https://coveralls.io/github/exleym/Flask-Filter?branch=master 64 | -------------------------------------------------------------------------------- /tests/test_filter_schema.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from flask_filter.schemas import FilterSchema 4 | from flask_filter.filters import * 5 | 6 | 7 | class FilterSchemaTestClass(unittest.TestCase): 8 | 9 | def setUp(self): 10 | self.schema = FilterSchema() 11 | 12 | def tearDown(self): 13 | self.schema = None 14 | 15 | def test_filter_schema_init(self): 16 | self.assertIsInstance(self.schema, FilterSchema) 17 | self.assertEqual(self.schema.many, False) 18 | 19 | def test_filter_schema_deserializes_ltfilter(self): 20 | json = {"field": "age", "op": "<", "value": 3} 21 | filter = self.schema.load(json) 22 | self.assertIsInstance(filter, LTFilter) 23 | self.assertEqual(filter.field, "age") 24 | self.assertEqual(filter.OP, "<") 25 | self.assertEqual(filter.value, 3) 26 | 27 | def test_filter_schema_deserializes_ltefilter(self): 28 | json = {"field": "age", "op": "<=", "value": 4} 29 | filter = self.schema.load(json) 30 | self.assertIsInstance(filter, LTEFilter) 31 | self.assertEqual(filter.field, "age") 32 | self.assertEqual(filter.OP, "<=") 33 | self.assertEqual(filter.value, 4) 34 | 35 | def test_filter_schema_deserializes_equalsfilter(self): 36 | json = {"field": "age", "op": "=", "value": 2} 37 | filter = self.schema.load(json) 38 | self.assertIsInstance(filter, EqualsFilter) 39 | self.assertEqual(filter.field, "age") 40 | self.assertEqual(filter.OP, "=") 41 | self.assertEqual(filter.value, 2) 42 | 43 | def test_filter_schema_deserializes_gtfilter(self): 44 | json = {"field": "age", "op": ">", "value": 1} 45 | filter = self.schema.load(json) 46 | self.assertIsInstance(filter, GTFilter) 47 | self.assertEqual(filter.field, "age") 48 | self.assertEqual(filter.OP, ">") 49 | self.assertEqual(filter.value, 1) 50 | 51 | def test_filter_schema_deserializes_gtefilter(self): 52 | json = {"field": "age", "op": ">=", "value": 3} 53 | filter = self.schema.load(json) 54 | self.assertIsInstance(filter, GTEFilter) 55 | self.assertEqual(filter.field, "age") 56 | self.assertEqual(filter.OP, ">=") 57 | self.assertEqual(filter.value, 3) 58 | 59 | def test_filter_schema_deserializes_infilter(self): 60 | json = {"field": "age", "op": "in", "value": [3, 4, 5]} 61 | filter = self.schema.load(json) 62 | self.assertIsInstance(filter, InFilter) 63 | self.assertEqual(filter.field, "age") 64 | self.assertEqual(filter.OP, "in") 65 | self.assertEqual(filter.value, [3, 4, 5]) 66 | 67 | def test_filter_schema_deserializes_notequalsfilter(self): 68 | json = {"field": "age", "op": "!=", "value": 3} 69 | filter = self.schema.load(json) 70 | self.assertIsInstance(filter, NotEqualsFilter) 71 | self.assertEqual(filter.field, "age") 72 | self.assertEqual(filter.OP, "!=") 73 | self.assertEqual(filter.value, 3) 74 | 75 | def test_filter_schema_deserializes_likefilter(self): 76 | json = {"field": "name", "op": "like", "value": "charlie"} 77 | filter = self.schema.load(json) 78 | self.assertIsInstance(filter, LikeFilter) 79 | self.assertEqual(filter.field, "name") 80 | self.assertEqual(filter.OP, "like") 81 | self.assertEqual(filter.value, "charlie") 82 | -------------------------------------------------------------------------------- /tests/test_ordered_search.py: -------------------------------------------------------------------------------- 1 | """ Test module for validating the order_by functionality provided 2 | as part of the search queries 3 | """ 4 | import unittest 5 | from datetime import date 6 | 7 | from flask_filter.query_filter import query_with_filters 8 | from tests.minipet_app import create_app, filtr, Dog, DogSchema, db, Toy, ToySchema 9 | 10 | 11 | class FlaskFilterOrderTestClass(unittest.TestCase): 12 | 13 | def setUp(self): 14 | self.app = create_app() 15 | self.db = db 16 | self.filtr = filtr 17 | with self.app.app_context(): 18 | self.db.create_all() 19 | self.make_dogs() 20 | self.make_toys() 21 | self.associate_dogs_with_toys() 22 | 23 | def tearDown(self): 24 | with self.app.app_context(): 25 | self.db.drop_all() 26 | self.app = None 27 | self.filtr = None 28 | self.db = None 29 | 30 | def make_dogs(self): 31 | doggos = [ 32 | Dog(name="Xocomil", dob=date(1990, 12, 16), weight=100), 33 | Dog(name="Jasmine", dob=date(1997, 4, 20), weight=40), 34 | Dog(name="Quick", dob=date(2000, 5, 24), weight=90), 35 | Dog(name="Jinx", dob=date(2005, 12, 31), weight=55), 36 | Dog(name="Kaya", dob=date(2009, 3, 15), weight=50) 37 | ] 38 | self.db.session.add_all(doggos) 39 | self.db.session.commit() 40 | 41 | def make_toys(self): 42 | toys = [ 43 | Toy(name="Rock"), 44 | Toy(name="Tennis Ball"), 45 | Toy(name="Knotted Rope"), 46 | Toy(name="Kong") 47 | ] 48 | self.db.session.add_all(toys) 49 | self.db.session.commit() 50 | 51 | def associate_dogs_with_toys(self): 52 | self.associate("Xocomil", "Rock") 53 | self.associate("Xocomil", "Tennis Ball") 54 | self.associate("Quick", "Tennis Ball") 55 | self.associate("Jinx", "Kong") 56 | 57 | def associate(self, dog_name, toy_name): 58 | dog = Dog.query.filter_by(name=dog_name).one() 59 | toy = Toy.query.filter_by(name=toy_name).one() 60 | dog.toys.append(toy) 61 | db.session.add(dog) 62 | db.session.commit() 63 | 64 | def test_no_filter_no_order(self): 65 | xfilters = [] 66 | with self.app.app_context(): 67 | dags = self.filtr.search(Dog, xfilters, DogSchema) 68 | self.assertEqual(len(dags), 5) 69 | self.assertListEqual([d.id for d in dags], [1, 2, 3, 4, 5]) 70 | 71 | def test_no_filter_order_by_name(self): 72 | xfilters = [] 73 | expected_order = [2, 4, 5, 3, 1] 74 | with self.app.app_context(): 75 | dags = self.filtr.search(Dog, xfilters, DogSchema, 76 | order_by=Dog.name) 77 | self.assertEqual(len(dags), 5) 78 | self.assertListEqual([d.id for d in dags], expected_order) 79 | 80 | def test_no_filter_order_by_name_as_string(self): 81 | xfilters = [] 82 | expected_order = [2, 4, 5, 3, 1] 83 | with self.app.app_context(): 84 | dags = self.filtr.search(Dog, xfilters, DogSchema, 85 | order_by="name") 86 | self.assertEqual(len(dags), 5) 87 | self.assertListEqual([d.id for d in dags], expected_order) 88 | 89 | def test_no_filter_order_by_weight(self): 90 | xfilters = [] 91 | expected_order = [2, 5, 4, 3, 1] 92 | with self.app.app_context(): 93 | dags = self.filtr.search(Dog, xfilters, DogSchema, 94 | order_by=Dog.weight) 95 | self.assertEqual(len(dags), 5) 96 | self.assertListEqual([d.id for d in dags], expected_order) 97 | 98 | def test_no_filter_order_by_weight_as_string(self): 99 | xfilters = [] 100 | expected_order = [2, 5, 4, 3, 1] 101 | with self.app.app_context(): 102 | dags = self.filtr.search(Dog, xfilters, DogSchema, 103 | order_by="weight") 104 | self.assertEqual(len(dags), 5) 105 | self.assertListEqual([d.id for d in dags], expected_order) 106 | 107 | def test_query_with_filters_function(self): 108 | xfilters = [] 109 | expected_order = [2, 5, 4, 3, 1] 110 | with self.app.app_context(): 111 | dags = query_with_filters(Dog, xfilters, DogSchema, 112 | order_by=Dog.weight) 113 | self.assertEqual(len(dags), 5) 114 | self.assertListEqual([d.id for d in dags], expected_order) 115 | -------------------------------------------------------------------------------- /tests/test_query_with_filters.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from datetime import date 3 | 4 | from flask_filter.query_filter import query_with_filters 5 | 6 | from tests.minipet_app import create_app, Dog, DogSchema, db 7 | 8 | 9 | class QueryWithFiltersTestClass(unittest.TestCase): 10 | 11 | def setUp(self): 12 | self.app = create_app() 13 | self.db = db 14 | with self.app.app_context(): 15 | self.db.create_all() 16 | self.make_dogs() 17 | 18 | def tearDown(self): 19 | with self.app.app_context(): 20 | self.db.drop_all() 21 | self.app = None 22 | self.db = None 23 | 24 | def make_dogs(self): 25 | doggos = [ 26 | Dog(name="Xocomil", dob=date(1990, 12, 16), weight=100), 27 | Dog(name="Jasmine", dob=date(1997, 4, 20), weight=40), 28 | Dog(name="Quick", dob=date(2000, 5, 24), weight=90), 29 | Dog(name="Jinx", dob=date(2005, 12, 31), weight=55), 30 | Dog(name="Kaya", dob=None, weight=50) 31 | ] 32 | self.db.session.add_all(doggos) 33 | self.db.session.commit() 34 | 35 | def test_name_equalsfilter(self): 36 | xfilters = [{"field": "name", "op": "=", "value": "Xocomil"}] 37 | with self.app.app_context(): 38 | xoco = query_with_filters(Dog, xfilters, DogSchema) 39 | self.assertEqual(len(xoco), 1) 40 | self.assertEqual(xoco[0].name, "Xocomil") 41 | 42 | def test_name_likefilter(self): 43 | filters = [{"field": "name", "op": "like", "value": "J%"}] 44 | with self.app.app_context(): 45 | j_dogs = query_with_filters(Dog, filters, DogSchema) 46 | self.assertEqual(len(j_dogs), 2) 47 | self.assertEqual(j_dogs[0].name, "Jasmine") 48 | self.assertEqual(j_dogs[1].name, "Jinx") 49 | 50 | def test_name_notequalsfilter(self): 51 | xfilters = [{"field": "name", "op": "!=", "value": "Xocomil"}] 52 | with self.app.app_context(): 53 | not_xoco = query_with_filters(Dog, xfilters, DogSchema) 54 | self.assertEqual(len(not_xoco), 4) 55 | 56 | def test_name_infilter(self): 57 | f = [{"field": "name", "op": "in", "value": ["Jinx", "Kaya"]}] 58 | with self.app.app_context(): 59 | jinx_and_kaya = query_with_filters(Dog, f, DogSchema) 60 | self.assertEqual(len(jinx_and_kaya), 2) 61 | 62 | def test_dob_filter(self): 63 | min_date = date(2002, 1, 1).isoformat() 64 | f = [{"field": "dateOfBirth", "op": "<", "value": min_date}] 65 | with self.app.app_context(): 66 | old_dogs = query_with_filters(Dog, f, DogSchema) 67 | self.assertEqual(len(old_dogs), 3) 68 | 69 | def test_dob_null_equalsfilter(self): 70 | f = [{"field": "dateOfBirth", "op": "=", "value": None}] 71 | with self.app.app_context(): 72 | kaya = query_with_filters(Dog, f, DogSchema) 73 | self.assertEqual(len(kaya), 1) 74 | self.assertEqual(kaya[0].name, "Kaya") 75 | 76 | def test_dob_null_notequalsfilter(self): 77 | f = [{"field": "dateOfBirth", "op": "!=", "value": None}] 78 | with self.app.app_context(): 79 | not_kaya = query_with_filters(Dog, f, DogSchema) 80 | self.assertEqual(len(not_kaya), 4) 81 | 82 | def test_weight_ltfilter(self): 83 | f = [{"field": "weight", "op": "<", "value": 50}] 84 | with self.app.app_context(): 85 | skinny_dogs = query_with_filters(Dog, f, DogSchema) 86 | self.assertEqual(len(skinny_dogs), 1) 87 | 88 | def test_weight_ltefilter(self): 89 | f = [{"field": "weight", "op": "<=", "value": 50}] 90 | with self.app.app_context(): 91 | skinnyish_dogs = query_with_filters(Dog, f, DogSchema) 92 | self.assertEqual(len(skinnyish_dogs), 2) 93 | 94 | def test_weight_gtfilter(self): 95 | f = [{"field": "weight", "op": ">", "value": 90}] 96 | with self.app.app_context(): 97 | fat_dogs = query_with_filters(Dog, f, DogSchema) 98 | self.assertEqual(len(fat_dogs), 1) 99 | 100 | def test_weight_gtefilter(self): 101 | f = [{"field": "weight", "op": ">=", "value": 90}] 102 | with self.app.app_context(): 103 | fatish_dogs = query_with_filters(Dog, f, DogSchema) 104 | self.assertEqual(len(fatish_dogs), 2) 105 | 106 | def test_registered_schema_against_dob(self): 107 | min_date = date(2002, 1, 1).isoformat() 108 | f = [{"field": "dateOfBirth", "op": "<", "value": min_date}] 109 | with self.app.app_context(): 110 | old_dogs = query_with_filters(Dog, f, DogSchema) 111 | self.assertEqual(len(old_dogs), 3) 112 | -------------------------------------------------------------------------------- /documentation/source/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Configuration file for the Sphinx documentation builder. 4 | # 5 | # This file does only contain a selection of the most common options. For a 6 | # full list see the documentation: 7 | # http://www.sphinx-doc.org/en/master/config 8 | 9 | # -- Path setup -------------------------------------------------------------- 10 | 11 | # If extensions (or modules to document with autodoc) are in another directory, 12 | # add these directories to sys.path here. If the directory is relative to the 13 | # documentation root, use os.path.abspath to make it absolute, like shown here. 14 | # 15 | # import os 16 | # import sys 17 | # sys.path.insert(0, os.path.abspath('.')) 18 | 19 | 20 | # -- Project information ----------------------------------------------------- 21 | 22 | project = 'Flask-Filter' 23 | copyright = '2018, Exley McCormick' 24 | author = 'Exley McCormick' 25 | 26 | # The short X.Y version 27 | version = '' 28 | # The full version, including alpha/beta/rc tags 29 | release = '0.1.1' 30 | 31 | 32 | # -- General configuration --------------------------------------------------- 33 | 34 | # If your documentation needs a minimal Sphinx version, state it here. 35 | # 36 | # needs_sphinx = '1.0' 37 | 38 | # Add any Sphinx extension module names here, as strings. They can be 39 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 40 | # ones. 41 | extensions = [ 42 | 'sphinx.ext.autodoc', 43 | 'sphinx.ext.githubpages', 44 | ] 45 | 46 | # Add any paths that contain templates here, relative to this directory. 47 | templates_path = ['_templates'] 48 | 49 | # The suffix(es) of source filenames. 50 | # You can specify multiple suffix as a list of string: 51 | # 52 | # source_suffix = ['.rst', '.md'] 53 | source_suffix = '.rst' 54 | 55 | # The master toctree document. 56 | master_doc = 'index' 57 | 58 | # The language for content autogenerated by Sphinx. Refer to documentation 59 | # for a list of supported languages. 60 | # 61 | # This is also used if you do content translation via gettext catalogs. 62 | # Usually you set "language" from the command line for these cases. 63 | language = None 64 | 65 | # List of patterns, relative to source directory, that match files and 66 | # directories to ignore when looking for source files. 67 | # This pattern also affects html_static_path and html_extra_path. 68 | exclude_patterns = [] 69 | 70 | # The name of the Pygments (syntax highlighting) style to use. 71 | pygments_style = None 72 | 73 | 74 | # -- Options for HTML output ------------------------------------------------- 75 | 76 | # The theme to use for HTML and HTML Help pages. See the documentation for 77 | # a list of builtin themes. 78 | # 79 | html_theme = 'alabaster' 80 | 81 | # Theme options are theme-specific and customize the look and feel of a theme 82 | # further. For a list of options available for each theme, see the 83 | # documentation. 84 | # 85 | # html_theme_options = {} 86 | 87 | # Add any paths that contain custom static files (such as style sheets) here, 88 | # relative to this directory. They are copied after the builtin static files, 89 | # so a file named "default.css" will overwrite the builtin "default.css". 90 | html_static_path = ['_static'] 91 | 92 | # Custom sidebar templates, must be a dictionary that maps document names 93 | # to template names. 94 | # 95 | # The default sidebars (for documents that don't match any pattern) are 96 | # defined by theme itself. Builtin themes are using these templates by 97 | # default: ``['localtoc.html', 'relations.html', 'sourcelink.html', 98 | # 'searchbox.html']``. 99 | # 100 | # html_sidebars = {} 101 | 102 | 103 | # -- Options for HTMLHelp output --------------------------------------------- 104 | 105 | # Output file base name for HTML help builder. 106 | htmlhelp_basename = 'Flask-Filterdoc' 107 | 108 | 109 | # -- Options for LaTeX output ------------------------------------------------ 110 | 111 | latex_elements = { 112 | # The paper size ('letterpaper' or 'a4paper'). 113 | # 114 | # 'papersize': 'letterpaper', 115 | 116 | # The font size ('10pt', '11pt' or '12pt'). 117 | # 118 | # 'pointsize': '10pt', 119 | 120 | # Additional stuff for the LaTeX preamble. 121 | # 122 | # 'preamble': '', 123 | 124 | # Latex figure (float) alignment 125 | # 126 | # 'figure_align': 'htbp', 127 | } 128 | 129 | # Grouping the document tree into LaTeX files. List of tuples 130 | # (source start file, target name, title, 131 | # author, documentclass [howto, manual, or own class]). 132 | latex_documents = [ 133 | (master_doc, 'Flask-Filter.tex', 'Flask-Filter Documentation', 134 | 'Exley McCormick', 'manual'), 135 | ] 136 | 137 | 138 | # -- Options for manual page output ------------------------------------------ 139 | 140 | # One entry per manual page. List of tuples 141 | # (source start file, name, description, authors, manual section). 142 | man_pages = [ 143 | (master_doc, 'flask-filter', 'Flask-Filter Documentation', 144 | [author], 1) 145 | ] 146 | 147 | 148 | # -- Options for Texinfo output ---------------------------------------------- 149 | 150 | # Grouping the document tree into Texinfo files. List of tuples 151 | # (source start file, target name, title, author, 152 | # dir menu entry, description, category) 153 | texinfo_documents = [ 154 | (master_doc, 'Flask-Filter', 'Flask-Filter Documentation', 155 | author, 'Flask-Filter', 'One line description of project.', 156 | 'Miscellaneous'), 157 | ] 158 | 159 | 160 | # -- Options for Epub output ------------------------------------------------- 161 | 162 | # Bibliographic Dublin Core info. 163 | epub_title = project 164 | 165 | # The unique identifier of the text. This can be a ISBN number 166 | # or the project homepage. 167 | # 168 | # epub_identifier = '' 169 | 170 | # A unique identification for the text. 171 | # 172 | # epub_uid = '' 173 | 174 | # A list of files that should not be packed into the epub file. 175 | epub_exclude_files = ['search.html'] 176 | 177 | 178 | # -- Extension configuration ------------------------------------------------- -------------------------------------------------------------------------------- /flask_filter/filters/filters.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import datetime 3 | import logging 4 | import re 5 | 6 | from typing import Any 7 | from numbers import Number 8 | from marshmallow.exceptions import ValidationError 9 | 10 | 11 | logger = logging.getLogger(__name__) 12 | RE_DATE = "^([0-9]{4})-([0-9]|1[0-2]|0[1-9])-([1-9]|0[1-9]|1[0-9]|2[1-9]|3[0-1])$" 13 | 14 | 15 | class Filter(abc.ABC): 16 | OP = None 17 | 18 | def __init__(self, field: str, value: Any): 19 | self.nested = None 20 | self.set_field(field) 21 | self.value = self._date_or_value(value) 22 | self.is_valid() 23 | 24 | def __repr__(self): 25 | return f"<{type(self).__name__}(field='{self.field}', op='{self.OP}'" \ 26 | f", value={self.value})>" 27 | 28 | def __eq__(self, other): 29 | return hash(self) == hash(other) 30 | 31 | def __hash__(self): 32 | return hash((self.field, self.OP, self.value)) 33 | 34 | def set_field(self, field): 35 | f = field.split(".") 36 | self.field = f[0] 37 | if len(f) == 2: 38 | self.nested = f[1] 39 | elif len(f) > 2: 40 | logger.warning( 41 | f"you supplied nested fields {f}. Only one level of nesting " 42 | f"is currently supported. ignoring fields {f[2:]}." 43 | ) 44 | 45 | @abc.abstractmethod 46 | def apply(self, query, class_, schema): 47 | raise NotImplementedError('apply is an abstract method') 48 | 49 | @abc.abstractmethod 50 | def is_valid(self): 51 | raise NotImplementedError('is_valid is an abstract method') 52 | 53 | def _get_db_field(self, schema): 54 | """ private method to convert JSON field to SQL column 55 | 56 | :param schema: optional Marshmallow schema to map field -> column 57 | :return: string field name 58 | """ 59 | if not schema: 60 | return self.field 61 | attr = schema._declared_fields.get(self.field) 62 | if not attr: 63 | raise ValidationError(f"'{attr}' is not a valid field") 64 | return attr.attribute or self.field 65 | 66 | def _date_or_value(self, value): 67 | if not isinstance(value, str): 68 | return value 69 | if re.match(RE_DATE, value): 70 | return datetime.datetime.strptime(value, "%Y-%m-%d").date() 71 | return value 72 | 73 | 74 | class RelativeComparator(Filter): 75 | 76 | def is_valid(self): 77 | try: 78 | allowed = (Number, datetime.date, datetime.datetime) 79 | assert isinstance(self.value, allowed) 80 | except AssertionError: 81 | raise ValidationError(f"{self} requires an ordinal value") 82 | 83 | 84 | class LTFilter(RelativeComparator): 85 | OP = "<" 86 | 87 | def apply(self, query, class_, schema=None): 88 | field = self._get_db_field(schema) 89 | return query.filter(getattr(class_, field) < self.value) 90 | 91 | 92 | class LTEFilter(RelativeComparator): 93 | OP = "<=" 94 | 95 | def apply(self, query, class_, schema=None): 96 | field = self._get_db_field(schema) 97 | return query.filter(getattr(class_, field) <= self.value) 98 | 99 | 100 | class GTFilter(RelativeComparator): 101 | OP = ">" 102 | 103 | def apply(self, query, class_, schema=None): 104 | field = self._get_db_field(schema) 105 | return query.filter(getattr(class_, field) > self.value) 106 | 107 | 108 | class GTEFilter(RelativeComparator): 109 | OP = ">=" 110 | 111 | def apply(self, query, class_, schema=None): 112 | field = self._get_db_field(schema) 113 | return query.filter(getattr(class_, field) >= self.value) 114 | 115 | 116 | class EqualsFilter(Filter): 117 | OP = "=" 118 | 119 | def apply(self, query, class_, schema=None): 120 | field = self._get_db_field(schema) 121 | return query.filter(getattr(class_, field) == self.value) 122 | 123 | def is_valid(self): 124 | allowed = (str, int, datetime.date, None.__class__) 125 | try: 126 | assert isinstance(self.value, allowed) 127 | except AssertionError: 128 | raise ValidationError(f"{self} requires a string or int value") 129 | 130 | 131 | class InFilter(Filter): 132 | OP = "in" 133 | 134 | def __init__(self, field: str, value: Any): 135 | if isinstance(value, str): 136 | value = [value] 137 | super().__init__(field, value) 138 | 139 | def apply(self, query, class_, schema=None): 140 | field = self._get_db_field(schema) 141 | return query.filter(getattr(class_, field).in_(list(self.value))) 142 | 143 | def is_valid(self): 144 | try: 145 | _ = (e for e in self.value) 146 | except TypeError: 147 | raise ValidationError(f"{self} must be an iterable") 148 | 149 | 150 | class NotEqualsFilter(Filter): 151 | OP = "!=" 152 | 153 | def apply(self, query, class_, schema=None): 154 | field = self._get_db_field(schema) 155 | return query.filter(getattr(class_, field) != self.value) 156 | 157 | def is_valid(self): 158 | allowed = (str, int, datetime.date, None.__class__) 159 | try: 160 | assert isinstance(self.value, allowed) 161 | except AssertionError: 162 | raise ValidationError(f"{self} requires a string or int value") 163 | 164 | 165 | class LikeFilter(Filter): 166 | OP = "like" 167 | 168 | def apply(self, query, class_, schema=None): 169 | field = self._get_db_field(schema) 170 | return query.filter(getattr(class_, field).like(self.value)) 171 | 172 | def is_valid(self): 173 | try: 174 | assert isinstance(self.value, str) 175 | except AssertionError: 176 | raise ValidationError(f"{self} requires a string with a wildcard") 177 | 178 | 179 | class ContainsFilter(Filter): 180 | OP = "contains" 181 | 182 | def apply(self, query, class_, schema=None): 183 | subfield = self.nested or "id" 184 | q = {subfield: self.value} 185 | field = self._get_db_field(schema) 186 | return query.filter(getattr(class_, field).any(**q)) 187 | 188 | def is_valid(self): 189 | pass 190 | -------------------------------------------------------------------------------- /tests/test_flask_filter.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from datetime import date 3 | 4 | from flask_filter import FlaskFilter 5 | from tests.minipet_app import create_app, filtr, Dog, DogSchema, db, Toy, ToySchema 6 | 7 | 8 | class FlaskFilterTestClass(unittest.TestCase): 9 | 10 | def setUp(self): 11 | self.app = create_app() 12 | self.db = db 13 | self.filtr = filtr 14 | with self.app.app_context(): 15 | self.db.create_all() 16 | self.make_dogs() 17 | self.make_toys() 18 | self.associate_dogs_with_toys() 19 | 20 | def tearDown(self): 21 | with self.app.app_context(): 22 | self.db.drop_all() 23 | self.app = None 24 | self.filtr = None 25 | self.db = None 26 | 27 | def make_dogs(self): 28 | doggos = [ 29 | Dog(name="Xocomil", dob=date(1990, 12, 16), weight=100), 30 | Dog(name="Jasmine", dob=date(1997, 4, 20), weight=40), 31 | Dog(name="Quick", dob=date(2000, 5, 24), weight=90), 32 | Dog(name="Jinx", dob=date(2005, 12, 31), weight=55), 33 | Dog(name="Kaya", dob=None, weight=50) 34 | ] 35 | self.db.session.add_all(doggos) 36 | self.db.session.commit() 37 | 38 | def make_toys(self): 39 | toys = [ 40 | Toy(name="Rock"), 41 | Toy(name="Tennis Ball"), 42 | Toy(name="Knotted Rope"), 43 | Toy(name="Kong") 44 | ] 45 | self.db.session.add_all(toys) 46 | self.db.session.commit() 47 | 48 | def associate_dogs_with_toys(self): 49 | self.associate("Xocomil", "Rock") 50 | self.associate("Xocomil", "Tennis Ball") 51 | self.associate("Quick", "Tennis Ball") 52 | self.associate("Jinx", "Kong") 53 | 54 | def associate(self, dog_name, toy_name): 55 | dog = Dog.query.filter_by(name=dog_name).one() 56 | toy = Toy.query.filter_by(name=toy_name).one() 57 | dog.toys.append(toy) 58 | db.session.add(dog) 59 | db.session.commit() 60 | 61 | def test_doggos_exist(self): 62 | with self.app.app_context(): 63 | doggos = Dog.query.all() 64 | self.assertEqual(len(doggos), 5) 65 | 66 | def test_name_equalsfilter(self): 67 | xfilters = [{"field": "name", "op": "=", "value": "Xocomil"}] 68 | with self.app.app_context(): 69 | xoco = self.filtr.search(Dog, xfilters, DogSchema) 70 | self.assertEqual(len(xoco), 1) 71 | self.assertEqual(xoco[0].name, "Xocomil") 72 | 73 | def test_name_likefilter(self): 74 | filters = [{"field": "name", "op": "like", "value": "J%"}] 75 | with self.app.app_context(): 76 | j_dogs = self.filtr.search(Dog, filters, DogSchema) 77 | self.assertEqual(len(j_dogs), 2) 78 | self.assertEqual(j_dogs[0].name, "Jasmine") 79 | self.assertEqual(j_dogs[1].name, "Jinx") 80 | 81 | def test_name_notequalsfilter(self): 82 | xfilters = [{"field": "name", "op": "!=", "value": "Xocomil"}] 83 | with self.app.app_context(): 84 | not_xoco = self.filtr.search(Dog, xfilters, DogSchema) 85 | self.assertEqual(len(not_xoco), 4) 86 | 87 | def test_name_infilter(self): 88 | f = [{"field": "name", "op": "in", "value": ["Jinx", "Kaya"]}] 89 | with self.app.app_context(): 90 | jinx_and_kaya = self.filtr.search(Dog, f, DogSchema) 91 | self.assertEqual(len(jinx_and_kaya), 2) 92 | 93 | def test_dob_filter(self): 94 | min_date = date(2002, 1, 1).isoformat() 95 | f = [{"field": "dateOfBirth", "op": "<", "value": min_date}] 96 | with self.app.app_context(): 97 | old_dogs = self.filtr.search(Dog, f, DogSchema) 98 | self.assertEqual(len(old_dogs), 3) 99 | 100 | def test_weight_ltfilter(self): 101 | f = [{"field": "weight", "op": "<", "value": 50}] 102 | with self.app.app_context(): 103 | skinny_dogs = self.filtr.search(Dog, f, DogSchema) 104 | self.assertEqual(len(skinny_dogs), 1) 105 | 106 | def test_weight_ltefilter(self): 107 | f = [{"field": "weight", "op": "<=", "value": 50}] 108 | with self.app.app_context(): 109 | skinnyish_dogs = self.filtr.search(Dog, f, DogSchema) 110 | self.assertEqual(len(skinnyish_dogs), 2) 111 | 112 | def test_weight_gtfilter(self): 113 | f = [{"field": "weight", "op": ">", "value": 90}] 114 | with self.app.app_context(): 115 | fat_dogs = self.filtr.search(Dog, f, DogSchema) 116 | self.assertEqual(len(fat_dogs), 1) 117 | 118 | def test_weight_gtefilter(self): 119 | f = [{"field": "weight", "op": ">=", "value": 90}] 120 | with self.app.app_context(): 121 | fatish_dogs = self.filtr.search(Dog, f, DogSchema) 122 | self.assertEqual(len(fatish_dogs), 2) 123 | 124 | def test_registered_schema_against_dob(self): 125 | min_date = date(2002, 1, 1).isoformat() 126 | f = [{"field": "dateOfBirth", "op": "<", "value": min_date}] 127 | with self.app.app_context(): 128 | old_dogs = self.filtr.search(Dog, f) 129 | self.assertEqual(len(old_dogs), 3) 130 | 131 | def test_registered_schema_against_weight(self): 132 | f = [{"field": "weight", "op": "<=", "value": 50}] 133 | with self.app.app_context(): 134 | skinnyish_dogs = self.filtr.search(Dog, f) 135 | self.assertEqual(len(skinnyish_dogs), 2) 136 | 137 | def test_flaskfilter_direct_init(self): 138 | filtr = FlaskFilter(self.app) 139 | f = [{"field": "weight", "op": "<=", "value": 50}] 140 | with self.app.app_context(): 141 | skinnyish_dogs = filtr.search(Dog, f, DogSchema) 142 | self.assertEqual(len(skinnyish_dogs), 2) 143 | 144 | def test_flaskfilter_search_limit(self): 145 | f = [] 146 | with self.app.app_context(): 147 | all_dogs = self.filtr.search(Dog, f) 148 | three_dogs = self.filtr.search(Dog, f, limit=3) 149 | self.assertEqual(len(all_dogs), 5) 150 | self.assertEqual(len(three_dogs), 3) 151 | 152 | def test_flaskfilter_contains(self): 153 | f = [{"field": "toys.id", "op": "contains", "value": 2}] 154 | with self.app.app_context(): 155 | ball_dogs = self.filtr.search(Dog, f) 156 | self.assertEqual(len(ball_dogs), 2) 157 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Flask-Filter 2 | Filtering Extension for Flask / SQLAlchemy 3 | 4 | Check out our 5 | [GitHub Pages site](https://exleym.github.io/Flask-Filter/) for the full documentation. 6 | 7 | [![Python package](https://github.com/exleym/Flask-Filter/actions/workflows/python-package.yml/badge.svg)](https://github.com/exleym/Flask-Filter/actions/workflows/python-package.yml) 8 | [![Coverage Status](https://coveralls.io/repos/github/exleym/Flask-Filter/badge.svg?branch=master)](https://coveralls.io/github/exleym/Flask-Filter?branch=master) 9 | [![PyPi][pypi-badge]][pypi] 10 | 11 | Flask-Filter is a simple [Flask](http://flask.pocoo.org/) extension for 12 | standardizing behavior of REST API resource search endpoints. It is 13 | designed to integrate with the [Flask-SQLAlchemy](http://flask-sqlalchemy.pocoo.org/2.3/) 14 | extension and [Marshmallow](https://marshmallow.readthedocs.io/en/3.0/), 15 | a popular serialization library. 16 | 17 | Out-of-the-box, Flask-Filter provides search functionality on top-level 18 | object fields via an array of filter objects provided in the JSON body 19 | of a POST request. For configuring filtering on derived or nested fields 20 | see the "Filtering on Nested Fields" section of the documentation. 21 | 22 | # Installation 23 | Flask-Filter is available on [PyPi][pypi]. To use this library, we recommend you 24 | install it via pip: 25 | 26 | ```bash 27 | (venv)$ pip install flask-filter 28 | ``` 29 | 30 | # Default Filters 31 | Flask-Filter supports searching resources based on an array of filters, 32 | JSON objects with the following structure: 33 | 34 | ```json 35 | {"field": "", "op": "", "value": ""} 36 | ``` 37 | 38 | The built-in filters support the following operators: 39 | 40 | | symbol | operator | python filter class | 41 | |----------|------------------------------|-----------------------| 42 | | < | less-than | `LTFilter` | 43 | | <= | less-than or equal to | `LTEFilter` | 44 | | = | equal to | `EqualsFilter` | 45 | | > | greater-than | `GTFilter` | 46 | | >= | greater-than or equal to | `GTEFilter` | 47 | | in | in | `InFilter` | 48 | | != | not equal to | `NotEqualsFilter` | 49 | | like | like | `LikeFilter` | 50 | | contains | many-to-many associated | `ContainsFilter` | 51 | 52 | Note: Be careful with typing around comparator operators. This version 53 | does not provide rigorous type-checking, which could cause problems for 54 | a user who submits a search like "find Pets with name greater than 55 | 'Fido'" 56 | 57 | Many-to-many associations can be searched using the `contains` operator. 58 | For a Dog object with a many-to-many relationship with "favorite toys" 59 | defined as Dog.toys = [Toy(), Toy()], you can set the field to "toys.name", 60 | the operator to "contains" and the value to "Tennis Ball". This will perform 61 | a SQL "any" search on that field / value and return any Dog objects who like 62 | tennis balls. 63 | 64 | # Examples 65 | This section demonstrates simplified use-cases for Flask-Filter. For 66 | a complete example app (a Pet Store API), see the `/example` folder. 67 | 68 | Note: examples in this readme define simple `/search` endpoints that 69 | assume a working Flask app has already been initialized, and other 70 | required classes have been defined in a `pet_store` directory. To see 71 | a full implementation, go to `/examples/pet_store` 72 | 73 | ### Example 1: Manually implementing filters in a flask view 74 | Using the `FilterSchema` class directly, you can deserialize an 75 | array of JSON filters into a list of `flask_filter.Filter` objects 76 | and directly apply the filters using `Filter.apply` to craft a 77 | SQLAlchemy query with a complex set of filters. 78 | 79 | ```python 80 | filter_schema = FilterSchema() 81 | pet_schema = PetSchema() 82 | 83 | @app.route('/api/v1/pets/search', methods=['POST']) 84 | def pet_search(): 85 | filters = filter_schema.load(request.json.get("filters"), many=True) 86 | query = Pet.query 87 | for f in filters: 88 | query = f.apply(query, Pet, PetSchema) 89 | return jsonify(pet_schema.dump(query.all())), 200 90 | ``` 91 | 92 | ### Example 2: Automatically filtering using the `query_with_filters` function 93 | 94 | ```python 95 | from flask_filter import query_with_filters 96 | pet_schema = PetSchema() 97 | 98 | @app.route('/api/v1/pets/search', methods=['POST'] 99 | def pet_search(): 100 | pets = query_with_filters(Pet, request.json.get("filters"), PetSchema) 101 | return jsonify(pet_schema.dump(pets)), 200 102 | ``` 103 | 104 | 105 | ### Example 3: Initializing and using the Flask extension object 106 | 107 | ```python 108 | from flask import Flask 109 | 110 | from pet_store import Pet, PetSchema # Model defined as subclass of `db.Model` 111 | from pet_store.extensions import db, filtr # SQLAlchemy and FlaskFilter objects 112 | 113 | app = Flask(__name__) 114 | db.init_app(app) 115 | filtr.init_app(app) 116 | 117 | 118 | @app.route('/api/v1/pets/search', methods=['POST']) 119 | def pet_search(): 120 | pets = filtr.search(Pet, request.json.get("filters"), PetSchema) 121 | return jsonify(pet_schema.dump(pets)), 200 122 | ``` 123 | 124 | or alternatively, if you pre-register the Model and Schema with the 125 | `FlaskFilter` object you do not need to pass the `Schema` directly to 126 | the `search` method: 127 | 128 | ```python 129 | filtr.register_model(Dog, DogSchema) # Register in the app factory 130 | ``` 131 | 132 | followed by the search execution (without an explicitly-defined schema): 133 | 134 | ```python 135 | pets = filtr.search(Pet, request.json.get("filters")) 136 | ``` 137 | 138 | ### Example 4: Ordering Search Responses 139 | By default, searches return objects ordered on `id`, ascending. This behavior 140 | can be customized with the optional `order_by` argument. 141 | 142 | If you don't have an `id` parameter for your database objects or you wish to 143 | sort by other fields, you should populate the `order_by` argument to the search 144 | function when you call it. 145 | 146 | This approach does not allow API consumers to set the order_by argument, but 147 | allows the developer to override the default id ordering. 148 | ```python 149 | @app.route('/api/v1/pets/search', methods=['POST']) 150 | def pet_search(): 151 | pets = filtr.search(Pet, request.json.get("filters"), PetSchema, 152 | order_by=Pet.name) 153 | return jsonify(pet_schema.dump(pets)), 200 154 | ``` 155 | 156 | Alternatively, if you wish to allow users to customize the order of the 157 | objects in the response, use a string for the `order_by` argument. 158 | 159 | ```python 160 | @app.route('/api/v1/pets/search', methods=['POST']) 161 | def pet_search(): 162 | order_by = json.get("orderBy") or "name" 163 | pets = filtr.search(Pet, request.json.get("filters"), PetSchema, 164 | order_by=order_by) 165 | return jsonify(pet_schema.dump(pets)), 200 166 | ``` 167 | 168 | 169 | [pypi-badge]: https://badge.fury.io/py/Flask-Filter.svg 170 | [pypi]: https://pypi.org/project/Flask-Filter/ -------------------------------------------------------------------------------- /tests/test_filter_typecheck.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from datetime import date 3 | import unittest 4 | 5 | from marshmallow.exceptions import ValidationError 6 | 7 | from flask_filter.schemas import FilterSchema 8 | from flask_filter.filters import * 9 | 10 | 11 | class FilterSchemaTestClass(unittest.TestCase): 12 | 13 | def setUp(self): 14 | self.schema = FilterSchema() 15 | 16 | def tearDown(self): 17 | self.schema = None 18 | 19 | def test_ltfilter_accepts_floats(self): 20 | json = {"field": "weight", "op": "<", "value": 10.24} 21 | lt = self.schema.load(json) 22 | self.assertIsInstance(lt, LTFilter) 23 | 24 | def test_ltfilter_accepts_ints(self): 25 | json = {"field": "weight", "op": "<", "value": 10} 26 | lt = self.schema.load(json) 27 | self.assertIsInstance(lt, LTFilter) 28 | 29 | def test_ltfilter_accepts_dates(self): 30 | json = {"field": "dateOfBirth", "op": "<", "value": "2018-12-15"} 31 | lt = self.schema.load(json) 32 | self.assertIsInstance(lt, LTFilter) 33 | self.assertIsInstance(lt.value, datetime.date) 34 | 35 | def test_ltfilter_raises_validationerror_against_string(self): 36 | json = {"field": "name", "op": "<", "value": "Fido"} 37 | with self.assertRaises(ValidationError): 38 | self.schema.load(json) 39 | 40 | def test_ltefilter_accepts_floats(self): 41 | json = {"field": "weight", "op": "<=", "value": 10.24} 42 | lte = self.schema.load(json) 43 | self.assertIsInstance(lte, LTEFilter) 44 | 45 | def test_ltefilter_accepts_ints(self): 46 | json = {"field": "weight", "op": "<=", "value": 10} 47 | lte = self.schema.load(json) 48 | self.assertIsInstance(lte, LTEFilter) 49 | 50 | def test_ltefilter_accepts_dates(self): 51 | json = {"field": "dateOfBirth", "op": "<=", "value": "2018-12-15"} 52 | lte = self.schema.load(json) 53 | self.assertIsInstance(lte, LTEFilter) 54 | self.assertIsInstance(lte.value, datetime.date) 55 | 56 | def test_ltefilter_raises_validationerror_against_string(self): 57 | json = {"field": "name", "op": "<=", "value": "Fido"} 58 | with self.assertRaises(ValidationError): 59 | self.schema.load(json) 60 | 61 | def test_gtfilter_accepts_floats(self): 62 | json = {"field": "weight", "op": ">", "value": 10.24} 63 | gt = self.schema.load(json) 64 | self.assertIsInstance(gt, GTFilter) 65 | 66 | def test_gtfilter_accepts_ints(self): 67 | json = {"field": "weight", "op": ">", "value": 10} 68 | gt = self.schema.load(json) 69 | self.assertIsInstance(gt, GTFilter) 70 | 71 | def test_gtfilter_accepts_dates(self): 72 | json = {"field": "dateOfBirth", "op": ">", "value": "2018-12-15"} 73 | gt = self.schema.load(json) 74 | self.assertIsInstance(gt, GTFilter) 75 | self.assertIsInstance(gt.value, datetime.date) 76 | 77 | def test_gtfilter_raises_validationerror_against_string(self): 78 | json = {"field": "name", "op": ">", "value": "Fido"} 79 | with self.assertRaises(ValidationError): 80 | self.schema.load(json) 81 | 82 | def test_gtefilter_accepts_floats(self): 83 | json = {"field": "weight", "op": ">=", "value": 10.24} 84 | gte = self.schema.load(json) 85 | self.assertIsInstance(gte, GTEFilter) 86 | 87 | def test_gtefilter_accepts_ints(self): 88 | json = {"field": "weight", "op": ">=", "value": 10} 89 | gte = self.schema.load(json) 90 | self.assertIsInstance(gte, GTEFilter) 91 | 92 | def test_gtefilter_accepts_dates(self): 93 | json = {"field": "dateOfBirth", "op": ">=", "value": "2018-12-15"} 94 | gte = self.schema.load(json) 95 | self.assertIsInstance(gte, GTEFilter) 96 | self.assertIsInstance(gte.value, datetime.date) 97 | 98 | def test_gtefilter_raises_validationerror_against_string(self): 99 | json = {"field": "name", "op": ">=", "value": "Fido"} 100 | with self.assertRaises(ValidationError): 101 | self.schema.load(json) 102 | 103 | def test_equalfilter_accepts_ints(self): 104 | json = {"field": "weight", "op": "=", "value": 10} 105 | eq = self.schema.load(json) 106 | self.assertIsInstance(eq, EqualsFilter) 107 | 108 | def test_equalfilter_accepts_strings(self): 109 | json = {"field": "name", "op": "=", "value": "Fido"} 110 | eq = self.schema.load(json) 111 | self.assertIsInstance(eq, EqualsFilter) 112 | 113 | def test_equalfilter_accepts_dates(self): 114 | json = {"field": "name", "op": "=", "value": "2018-12-15"} 115 | eq = self.schema.load(json) 116 | self.assertIsInstance(eq, EqualsFilter) 117 | 118 | def test_equalfilter_accepts_none(self): 119 | json = {"field": "name", "op": "=", "value": None} 120 | eq = self.schema.load(json) 121 | self.assertIsInstance(eq, EqualsFilter) 122 | 123 | def test_equalsfilter_raises_validationerror_against_float(self): 124 | json = {"field": "weight", "op": "=", "value": 12.345} 125 | with self.assertRaises(ValidationError): 126 | self.schema.load(json) 127 | 128 | def test_infilter_accepts_list_of_ints(self): 129 | json = {"field": "weight", "op": "in", "value": [1, 2, 3]} 130 | infilter = self.schema.load(json) 131 | self.assertIsInstance(infilter, InFilter) 132 | 133 | def test_infilter_accepts_list_of_strings(self): 134 | json = {"field": "weight", "op": "in", "value": ['A', 'B', 'C']} 135 | infilter = self.schema.load(json) 136 | self.assertIsInstance(infilter, InFilter) 137 | 138 | def test_infilter_accepts_list_of_dates(self): 139 | dates = [date(2018, 12, 15), date(2018, 12, 16)] 140 | json = {"field": "weight", "op": "in", "value": dates} 141 | infilter = self.schema.load(json) 142 | self.assertIsInstance(infilter, InFilter) 143 | 144 | def test_infilter_raises_typeerror_on_non_iterable(self): 145 | dates = date(2019, 3, 17) 146 | json = {"field": "dateOfBirth", "op": "in", "value": dates} 147 | with self.assertRaises(ValidationError): 148 | self.schema.load(json) 149 | 150 | def test_infilter_accepts_string_and_converts_to_list(self): 151 | json = {"field": "name", "op": "in", "value": "Fido"} 152 | infilter = self.schema.load(json) 153 | self.assertIsInstance(infilter, InFilter) 154 | self.assertEqual(infilter.value, ["Fido"]) 155 | 156 | def test_notequalsfilter_accepts_string(self): 157 | json = {"field": "name", "op": "!=", "value": "Fido"} 158 | notequalsfilter = self.schema.load(json) 159 | self.assertIsInstance(notequalsfilter, NotEqualsFilter) 160 | 161 | def test_notequalsfilter_accepts_int(self): 162 | json = {"field": "age", "op": "!=", "value": 10} 163 | notequalsfilter = self.schema.load(json) 164 | self.assertIsInstance(notequalsfilter, NotEqualsFilter) 165 | 166 | def test_notequalsfilter_accepts_date(self): 167 | json = {"field": "name", "op": "!=", "value": date(2018, 12, 16)} 168 | notequalsfilter = self.schema.load(json) 169 | self.assertIsInstance(notequalsfilter, NotEqualsFilter) 170 | 171 | def test_notequalsfilter_accepts_none(self): 172 | json = {"field": "name", "op": "!=", "value": None} 173 | notequalsfilter = self.schema.load(json) 174 | self.assertIsInstance(notequalsfilter, NotEqualsFilter) 175 | 176 | def test_notequalsfilter_fails_on_float(self): 177 | json = {"field": "age", "op": "!=", "value": 10.234} 178 | with self.assertRaises(ValidationError): 179 | self.schema.load(json) 180 | 181 | def test_likefilter_accepts_strings(self): 182 | json = {"field": "name", "op": "like", "value": "Fido%"} 183 | likefilter = self.schema.load(json) 184 | self.assertIsInstance(likefilter, LikeFilter) 185 | 186 | def test_likefilter_fails_on_int(self): 187 | json = {"field": "age", "op": "like", "value": 4} 188 | with self.assertRaises(ValidationError): 189 | self.schema.load(json) 190 | 191 | def test_likefilter_fails_on_float(self): 192 | json = {"field": "age", "op": "like", "value": 4.20} 193 | with self.assertRaises(ValidationError): 194 | self.schema.load(json) 195 | 196 | def test_likefilter_fails_on_date(self): 197 | json = {"field": "dateOfBirth", "op": "like", "value": date(2018, 12, 17)} 198 | with self.assertRaises(ValidationError): 199 | self.schema.load(json) 200 | 201 | def test_filter_schema_raises_validationerror_on_bad_op(self): 202 | json = {"field": "weight", "op": "ne", "value": 124} 203 | with self.assertRaises(ValidationError): 204 | self.schema.load(json) 205 | -------------------------------------------------------------------------------- /documentation/source/tutorial.rst: -------------------------------------------------------------------------------- 1 | Tutorial 2 | ======== 3 | This will eventually be a tutorial for using Flask-Filter. Woot! 4 | 5 | The best way to use this library (when working with a normal Flask project) is 6 | to use the ``FlaskFilter`` extension object. Typical use of this object (as 7 | with all Flask extensions) is to instantiate a singleton object in an 8 | ``extensions.py`` module in your project. Register the extension in your 9 | application factory, and then import the singleton wherever you need to 10 | perform a filtered search. 11 | 12 | 13 | 1. The Flask Extension 14 | ---------------------- 15 | The simplest and most effective way to use this library is through the 16 | ``FlaskFilter`` extension. Instantiate this object as a singleton and 17 | register it with the ``Flask`` application object, and you can query 18 | resources with the ``search`` method from any view. 19 | 20 | .. code-block:: python 21 | 22 | from flask import Flask 23 | 24 | # Pet is a Model defined as subclass of db.Model 25 | # db is a SQLAlchemy and filter is a FlaskFilter object 26 | # SQLAlchemy and FlaskFilter objects created as singletons 27 | from pet_store import Pet, PetSchema 28 | from pet_store.extensions import db, filtr 29 | 30 | app = Flask(__name__) 31 | db.init_app(app) 32 | filtr.init_app(app) 33 | 34 | 35 | @app.route('/api/v1/pets/search', methods=['POST']) 36 | def pet_search(): 37 | pets = filtr.search(Pet, request.json.get("filters"), 38 | PetSchema) 39 | return jsonify(pet_schema.dump(pets)), 200 40 | 41 | 42 | You can also pre-register your models and schemas with the ``FlaskFilter`` 43 | object, after which you will not need to pass the schema directly to the 44 | ``search()`` method. This feature could be helpful if you are attempting to 45 | auto-generate CRUD endpoints. 46 | 47 | .. code-block:: python 48 | 49 | # Register in the app factory 50 | filtr.register_model(Dog, DogSchema) 51 | 52 | # in some endpoint down the line, you can now search like this: 53 | filters = filter_schema.load(request.json.get("filters")) 54 | pets = filtr.search(Pet, filters) 55 | 56 | 57 | 2. Ordering Responses 58 | --------------------- 59 | By default, searches return objects ordered on ``id``, ascending. This behavior 60 | can be customized with the optional ``order_by`` argument. 61 | 62 | If you don't have an ``id`` parameter for your database objects or you wish to 63 | sort by other fields, you should populate the ``order_by`` argument to the 64 | search function when you call it. 65 | 66 | This approach does not allow API consumers to set the order_by argument, but 67 | allows the developer to override the default id ordering. 68 | 69 | .. code-block:: python 70 | 71 | @app.route('/api/v1/pets/search', methods=['POST']) 72 | def pet_search(): 73 | pets = filtr.search(Pet, request.json.get("filters"), 74 | PetSchema, order_by=Pet.name) 75 | return jsonify(pet_schema.dump(pets)), 200 76 | 77 | 78 | Alternatively, if you wish to allow users to customize the order of the 79 | objects in the response, use a string for the ``order_by`` argument. 80 | 81 | .. code-block:: python 82 | 83 | @app.route('/api/v1/pets/search', methods=['POST']) 84 | def pet_search(): 85 | order_by = json.get("orderBy") or "name" 86 | pets = filtr.search(Pet, request.json.get("filters"), 87 | PetSchema, order_by=order_by) 88 | return jsonify(pet_schema.dump(pets)), 200 89 | 90 | 91 | 3. Code Generation 92 | ------------------ 93 | If you are using Flask-RestX to build resource-driven APIs, ``Flask-Filter`` 94 | allows you to automatically generate search endpoints and register them 95 | with your Swagger documentation: 96 | 97 | .. note:: 98 | 99 | Note: this example is built with Flask-RestX and Flask-Accepts 100 | to pair Marshmallow directly with Flask-RestX and avoid the 101 | duplication of serializers common with Flask-RestX. 102 | 103 | Use a simple search-endpoint factory function like this one to easily 104 | create search endpoints for all of your resources. Pair this with some 105 | other factory functions for a full auto-CRUD tool. 106 | 107 | .. code-block:: python 108 | 109 | def generate_search(api, namespace, Schema, Model, filtr, 110 | name: str = None) -> type(Resource): 111 | """ factory function for creating a Resource search Manager in 112 | Flask-Restplus API application. Pass me an `api` object and a 113 | Restplus resource namespace along with a Marshmallow Schema and 114 | a SQLAlchemy model, and I will generate / register your search 115 | endpoint. 116 | """ 117 | res_name = name or Schema.__name__.replace("Schema", "") 118 | schema = Schema() 119 | 120 | @namespace.route("/search") 121 | class ResourceSearch(Resource): 122 | 123 | @namespace.doc(f"execute a resource search for {res_name}") 124 | @accepts(schema=SearchSchema, api=api) 125 | @responds( 126 | schema=Schema(many=True), 127 | status_code=200, 128 | api=api 129 | ) 130 | def post(self): 131 | filters = namespace.payload.get("filters") 132 | return filtr.search( 133 | DbModel=Model, 134 | filters=filters, 135 | ModelSchema=schema 136 | ) 137 | 138 | return ResourceSearch 139 | 140 | # Now generate some search endpoints with this factory function 141 | generate_search(api, ns_dogs, DogSchema, Dog, filtr) 142 | generate_search(api, ns_toys, ToySchema, Toy, filtr) 143 | generate_search(api, ns_usr, UserSchema, User, filtr) 144 | 145 | .. image:: _static/routes.png 146 | 147 | The generate_search function shown above will register endpoints and 148 | document them in the swagger spec. If you click on the "search" endpoint 149 | in that image, you get the detailed documentation of your post request 150 | body. 151 | 152 | .. image:: _static/routes-search.png 153 | 154 | 155 | 4. Extending FlaskFilter 156 | ------------------------ 157 | There may come a situation where the default filters provided by 158 | this library do not meet the needs of your application. In this 159 | event, you can extend the filter set provided by the ``FlaskFilter`` 160 | library and create your own filters that can be added to a search 161 | endpoint. 162 | 163 | The default filters provided in version 0.1 of this library are 164 | as follows: 165 | 166 | - LTFilter 167 | - LTEFilter 168 | - EqualsFilter 169 | - GTFilter 170 | - GTEFilter 171 | - InFilter 172 | - NotEqualsFilter 173 | - LikeFilter 174 | - ContainsFilter 175 | 176 | These should cover most situations, but let's imaging a business 177 | need for a search endpoint that allows users to query with a 178 | ``NotInFilter`` to complement the ``InFilter``. 179 | 180 | .. code-block:: python 181 | 182 | from flask_filter.filters.filters import Filter 183 | from flask_filter.filters import FILTERS 184 | 185 | class NotInFilter(Filter): 186 | """ Custom not-in filter extending FlaskFilter search """ 187 | OP = "!in" 188 | 189 | def __init__(self, field: str, value: Any): 190 | if isinstance(value, str): 191 | value = [value] 192 | super().__init__(field, value) 193 | 194 | def apply(self, query, class_, schema=None): 195 | field = self._get_db_field(schema) 196 | return query.filter( 197 | getattr(class_, field).notin_(list(self.value)) 198 | ) 199 | 200 | def is_valid(self): 201 | try: 202 | _ = (e for e in self.value) 203 | except TypeError: 204 | raise ValidationError(f"{self} must be an iterable") 205 | 206 | FILTERS.append(NotInFilter) 207 | 208 | This implementation works fine, but let's look at the similarities 209 | between what we just wrote and the existing InFilter -- maybe we 210 | would be better off extending the InFilter to create a more easily 211 | maintained class: 212 | 213 | .. code-block:: python 214 | 215 | class InFilter(Filter): 216 | OP = "in" 217 | 218 | def __init__(self, field: str, value: Any): 219 | if isinstance(value, str): 220 | value = [value] 221 | super().__init__(field, value) 222 | 223 | def apply(self, query, class_, schema=None): 224 | field = self._get_db_field(schema) 225 | return query.filter( 226 | getattr(class_, field).in_(list(self.value)) 227 | ) 228 | 229 | def is_valid(self): 230 | try: 231 | _ = (e for e in self.value) 232 | except TypeError: 233 | raise ValidationError(f"{self} must be an iterable") 234 | 235 | Whoops! There's only a few tiny changes that had to be made to the 236 | existing InFilter. Looking closely the only things that differ are: 237 | 238 | - the class name itself 239 | - the ``OP`` class variable 240 | - the filter used in the apply method 241 | 242 | This extension could be accomplished much more effectively by 243 | sub-classing the ``InFilter`` class. 244 | 245 | .. code-block:: python 246 | 247 | class NotInFilter(InFilter): 248 | OP = "!in" 249 | 250 | def apply(self, query, class_, schema=None): 251 | field = self._get_db_field(schema) 252 | return query.filter( 253 | getattr(class_, field).notin_(list(self.value)) 254 | ) 255 | 256 | FILTERS.append(NotInFilter) 257 | --------------------------------------------------------------------------------