├── .DS_Store ├── .coveragerc ├── .flake8 ├── .github └── workflows │ ├── deploy.yml │ └── tests.yml ├── .gitignore ├── .readthedocs.yml ├── LICENSE ├── README.md ├── docs ├── Makefile ├── advanced_user_guide.rst ├── basics.rst ├── conf.py ├── filters.rst ├── index.rst ├── make.bat ├── paginator.rst ├── query.rst ├── sorter.rst └── tutorials.rst ├── fastapi_listing ├── __init__.py ├── abstracts │ ├── __init__.py │ ├── adapters.py │ ├── base_query.py │ ├── dao.py │ ├── filter.py │ ├── interceptor.py │ ├── listing.py │ ├── paginator.py │ └── sorter.py ├── constants.py ├── ctyping.py ├── dao │ ├── __init__.py │ ├── dao_registry.py │ └── generic_dao.py ├── errors.py ├── factory │ ├── __init__.py │ ├── _generic_factory.py │ ├── filter.py │ ├── interceptor.py │ └── strategy.py ├── filters │ ├── __init__.py │ └── generic_filters.py ├── interceptors │ ├── __init__.py │ ├── individual_sorter_interceptor.py │ └── iterative_filter_interceptor.py ├── interface │ ├── __init__.py │ ├── client_site_params_adapter.py │ └── listing_meta_info.py ├── loader.py ├── middlewares.py ├── paginator │ ├── __init__.py │ ├── default_page_format.py │ └── page_builder.py ├── py.typed ├── service │ ├── __init__.py │ ├── _core_listing_service.py │ ├── adapters.py │ ├── config.py │ └── listing_main.py ├── sorter │ ├── __init__.py │ └── page_sorter.py ├── strategies │ ├── __init__.py │ └── query_strategy.py └── utils.py ├── pytest.ini ├── setup.py └── tests ├── __init__.py ├── dao_setup.py ├── fake_listing_setup.py ├── original_responses.py ├── pydantic_setup.py ├── service_setup.py ├── test_fast_listing_compact_version.py ├── test_main.py └── test_main_v2.py /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielhasan1/fastapi-listing/1dd46275adc8f129c0660be1a40b865548f8fd0e/.DS_Store -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [report] 2 | exclude_lines = 3 | pragma: no cover 4 | def __repr__ 5 | if self.debug: 6 | if settings.DEBUG 7 | raise AssertionError 8 | raise NotImplementedError 9 | if 0: 10 | if __name__ == .__main__.: 11 | @abstractmethod 12 | @abstractproperty 13 | @*.setter -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = E501, F401 -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | on: 2 | release: 3 | types: 4 | - published 5 | jobs: 6 | deploy: 7 | name: Build and deploy package 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v3 13 | 14 | - name: Setup Python 15 | uses: actions/setup-python@v4 16 | with: 17 | python-version: '3.9' 18 | 19 | - name: Install build dependencies 20 | run: | 21 | pip install build 22 | - name: Build package 23 | run: | 24 | python -m build 25 | - name: Publish package to PyPI 26 | uses: pypa/gh-action-pypi-publish@release/v1 27 | with: 28 | user: __token__ 29 | password: ${{ secrets.PYPI_UPLOAD_TOKEN }} 30 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | run-name: ${{ github.actor }} 2 | on: [ push, pull_request ] 3 | 4 | jobs: 5 | pytest: 6 | name: Run tests with pytest 7 | runs-on: ubuntu-22.04 8 | strategy: 9 | matrix: 10 | python-version: [ 3.7, 3.8, 3.9, '3.10', '3.11' ] 11 | steps: 12 | - name: Setup Test Db 13 | run: | 14 | docker pull danielhasan1/mysql_employees_test_db 15 | docker run -d -p 3307:3306 --name dazzling_wright danielhasan1/mysql_employees_test_db 16 | sleep 20 17 | docker exec dazzling_wright bash setup-data 18 | - name: Checkout 19 | uses: actions/checkout@v2 20 | 21 | - name: Set up Python ${{ matrix.python-version }} 22 | uses: actions/setup-python@v5 23 | with: 24 | python-version: ${{ matrix.python-version }} 25 | 26 | - name: Install dependencies 27 | run: | 28 | pip install -e .[test] 29 | # - name: Run linting (flake8) 30 | # run: | 31 | # flake8 ./fastapi_listing ./tests 32 | # - name: Run linting (isort) 33 | # run: | 34 | # isort --check-only ./fastapi_listing ./tests 35 | - name: Run tests 36 | run: | 37 | set -e 38 | set -x 39 | echo "ENV=${ENV}" 40 | export PYTHONPATH=. 41 | pytest --cov=fastapi_listing --cov=tests --cov-report=term-missing --cov-fail-under=80 42 | - name: Upload coverage reports to Codecov 43 | uses: codecov/codecov-action@v3 44 | env: 45 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 46 | -------------------------------------------------------------------------------- /.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 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 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 tests / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | ex-docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | .idea/* 131 | docs/_build 132 | docs/_static 133 | docs/_templates 134 | .DS_Store -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yaml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | # Set the version of Python and other tools you might need 9 | build: 10 | os: ubuntu-22.04 11 | tools: 12 | python: "3.11" 13 | # You can also specify other tool versions: 14 | # nodejs: "19" 15 | # rust: "1.64" 16 | # golang: "1.19" 17 | 18 | # Build documentation in the ex-docs/ directory with Sphinx 19 | sphinx: 20 | configuration: docs/conf.py 21 | 22 | # If using Sphinx, optionally build your ex-docs in additional formats such as PDF 23 | # formats: 24 | # - pdf 25 | 26 | # Optionally declare the Python requirements required to build your ex-docs 27 | # python: 28 | # install: 29 | # - requirements: ex-docs/requirements.txt -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Danish Hasan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fastapi-listing 2 | 3 | Advanced items listing library that gives you freedom to design really complex listing APIs using component based architecture. 4 | 5 | [![.github/workflows/deploy.yml](https://github.com/danielhasan1/fastapi-listing/actions/workflows/deploy.yml/badge.svg)](https://github.com/danielhasan1/fastapi-listing/actions/workflows/deploy.yml) 6 | [![.github/workflows/tests.yml](https://github.com/danielhasan1/fastapi-listing/actions/workflows/tests.yml/badge.svg)](https://github.com/danielhasan1/fastapi-listing/actions/workflows/tests.yml) ![PyPI - Programming Language](https://img.shields.io/pypi/pyversions/fastapi-listing.svg?color=%2334D058) 7 | [![codecov](https://codecov.io/gh/danielhasan1/fastapi-listing/branch/dev/graph/badge.svg?token=U29ZRNAH8I)](https://codecov.io/gh/danielhasan1/fastapi-listing) [![Downloads](https://static.pepy.tech/badge/fastapi-listing)](https://pepy.tech/project/fastapi-listing) 8 | 9 | Comes with: 10 | - pre defined filters 11 | - pre defined paginator 12 | - pre defined sorter 13 | 14 | ## Advantage 15 | - simplify the intricate process of designing and developing complex listing APIs 16 | - Design components(USP) and plug them from anywhere 17 | - Components can be **reusable** 18 | - Best for fast changing needs 19 | 20 | ## Installing 21 | 22 | Using [pip](https://pip.pypa.io/): 23 | 24 | ```python 25 | pip install fastapi-listing 26 | ``` 27 | 28 | ## Quick Example 29 | 30 | Attaching example of it running against the [mysql employee db](https://dev.mysql.com/doc/employee/en/) 31 | 32 | There are two ways to implement a listing API using fastapi listing 33 | 34 | - inline implementation 35 | - class based implementation 36 | 37 | for both we will be needing a dao(data access object) class 38 | 39 | ### First let's look at inline implementation. 40 | 41 | ```python 42 | # main.py 43 | 44 | from fastapi import FastAPI 45 | from pydantic import BaseModel, Field 46 | from datetime import date 47 | 48 | from sqlalchemy import Column, Date, Enum, Integer, String 49 | from sqlalchemy.orm import declarative_base 50 | from sqlalchemy.orm import Session 51 | 52 | from fastapi_listing.paginator import ListingPage 53 | from fastapi_listing import FastapiListing, MetaInfo 54 | from fastapi_listing.dao import GenericDao 55 | 56 | 57 | Base = declarative_base() 58 | app = FastAPI() 59 | 60 | 61 | class Employee(Base): 62 | __tablename__ = 'employees' 63 | 64 | emp_no = Column(Integer, primary_key=True) 65 | birth_date = Column(Date, nullable=False) 66 | first_name = Column(String(14), nullable=False) 67 | last_name = Column(String(16), nullable=False) 68 | gender = Column(Enum('M', 'F'), nullable=False) 69 | hire_date = Column(Date, nullable=False) 70 | 71 | # Dao class 72 | class EmployeeDao(GenericDao): 73 | """write your data layer access logic here. keep it raw!""" 74 | name = "employee" 75 | model = Employee # sqlalchemy model class (support for pymongo/tortoise orm is in progress) 76 | 77 | 78 | class EmployeeListDetails(BaseModel): 79 | emp_no: int = Field(alias="empid", title="Employee ID") 80 | birth_date: date = Field(alias="bdt", title="Birth Date") 81 | first_name: str = Field(alias="fnm", title="First Name") 82 | last_name: str = Field(alias="lnm", title="Last Name") 83 | gender: str = Field(alias="gdr", title="Gender") 84 | hire_date: date = Field(alias="hdt", title="Hiring Date") 85 | 86 | class Config: 87 | orm_mode = True 88 | allow_population_by_field_name = True 89 | 90 | 91 | @app.get("/employees", response_model=ListingPage[EmployeeListDetails]) 92 | def get_employees(db: Session): 93 | dao = EmployeeDao(read_db=db) 94 | # passing pydantic serializer is optional, automatically generates a 95 | # select query based on pydantic class fields for easy cases like columns of same table 96 | # if not passed then provide a select query in dao layer 97 | return FastapiListing(dao=dao, pydantic_serializer=EmployeeListDetails 98 | ).get_response(MetaInfo(default_srt_on="emp_no")) # by default sort in desc order 99 | # let's say pydantic class contains compute fields then pass custom_fields=True (by default False) 100 | return FastapiListing(dao=dao, 101 | pydantic_serializer=EmployeeListDetails, 102 | custom_fields=True # here setting custom field True to avoid unknown attributes error 103 | ).get_response(MetaInfo(default_srt_on="emp_no")) 104 | ``` 105 | 106 | Voila 🎉 your very first listing response 107 | 108 | ![](https://drive.google.com/uc?export=view&id=1amgrAdGP7WvXfiNlCYJZPC9fz4_1CidE) 109 | 110 | 111 | Auto generated query doesn't fulfil your use case❓️ 112 | 113 | ```python 114 | # Overwriting default read method in dao class 115 | class EmployeeDao(GenericDao): 116 | """write your data layer access logic here. keep it raw!""" 117 | name = "employee" 118 | model = Employee 119 | 120 | def get_default_read(self, fields_to_read: Optional[list]): 121 | """ 122 | Extend and return your query from here. 123 | Use it when use cases are comparatively easier than complex. 124 | Alternatively fastapi-listing provides a robust way to write performance packed queries 125 | for complex APIs which we will look at later. 126 | """ 127 | query = self._read_db.query(Employee) 128 | return query 129 | 130 | 131 | @app.get("/employees", response_model=ListingPage[EmployeeListDetails]) 132 | def get_employees(db: Session): 133 | dao = EmployeeDao(read_db=db) 134 | # note we removed all optional named params here 135 | return FastapiListing(dao=dao).get_response(MetaInfo(default_srt_on="emp_no")) 136 | ``` 137 | 138 | 139 | # Adding client site features 140 | 141 | Django admin users gonna love filter feature. But before that lets do a little setup which no once can avoid to support a broad spectrum of clients unless you use native query param format which I doubt. 142 | 143 | ## Add your custom adaptor class for reading filter/sorter/paginator client request params 144 | 145 | Below is the default implementation. You will be writing your own adaptor definition 146 | 147 | ```python 148 | from typing import Literal 149 | from fastapi_listing.service.adapters import CoreListingParamsAdapter 150 | from fastapi_listing import utils 151 | 152 | class YourAdapterClass(CoreListingParamsAdapter): # Extend to add your behaviour 153 | """Utilise this adapter class to make your remote client site: 154 | - filter, 155 | - sorter, 156 | - paginator. 157 | query params adapt to fastapi listing library. 158 | With this you can utilise same listing api to multiple remote client 159 | even if it's a front end server or other backend server. 160 | 161 | fastapi listing is always going to request one of the following fundamental key if you want to use it 162 | - sort 163 | - filter 164 | - pagination 165 | 166 | supported formats for 167 | filter: 168 | simple filter - [{"field":"", "value":{"search":""}}, ...] 169 | if you are using a range filter - 170 | [{"field":"", "value":{"start":"", "end": ""}}, ...] 171 | if you are using a list filter i.e. search on given items 172 | [{"field":"", "value":{"list":[""]}}, ...] 173 | 174 | sort: 175 | [{"field":<"key used in sort mapper>", "type":"asc or "dsc"}, ...] 176 | by default single sort allowed you can change it by extending sort interceptor 177 | 178 | pagination: 179 | {"pageSize": , "page": } 180 | """ 181 | 182 | def get(self, key: Literal["sort", "filter", "pagination"]): 183 | """ 184 | @param key: Literal["sort", "filter", "pagination"] 185 | @return: List[Optional[dict]] for filter/sort and dict for paginator 186 | """ 187 | return utils.dictify_query_params(self.dependency.get(key)) 188 | 189 | ``` 190 | ### Once your adaptor class is set 191 | 192 | ## Adding filter feature 193 | 194 | ➡️ lets add filters on Employee for: 195 | 1. gender - return only **Employees** belonging to 'X' gender where X could be anything. 196 | 2. DOB - return **Employees** belonging to a specific range of DOB. 197 | 3. First Name - return **Employees** only starting with specific first names. 198 | ```python 199 | from fastapi import Request 200 | from sqlalchemy.orm import Session 201 | 202 | from fastapi_listing.paginator import ListingPage 203 | from fastapi_listing.filters import generic_filters # collection of inbuilt filters 204 | from fastapi_listing.factory import filter_factory # register filter against a listing 205 | from fastapi_listing import MetaInfo, FastapiListing 206 | 207 | 208 | emp_filter_mapper = { 209 | "gdr": ("Employee.gender", generic_filters.EqualityFilter), 210 | "bdt": ("Employee.birth_date", generic_filters.MySqlNativeDateFormateRangeFilter), 211 | "fnm": ("Employee.first_name", generic_filters.StringStartsWithFilter), 212 | } 213 | filter_factory.register_filter_mapper(emp_filter_mapper) 214 | 215 | 216 | @app.get("/employees", response_model=ListingPage[EmployeeListDetails]) 217 | def get_employees(request: Request, db: Session): 218 | dao = EmployeeDao(read_db=db) 219 | return FastapiListing(request=request, dao=dao).get_response( 220 | MetaInfo(default_srt_on="emp_no", 221 | filter_mapper=emp_filter_mapper, 222 | feature_params_adapter=YourAdapterClass)) 223 | 224 | # or you dont wanna pass request? 225 | # extract required data from reqeust and pass it directly 226 | params = request.query_params 227 | filter_, sort_, pagination = params.get("filter"), params.get("sort"), params.get("paginator") 228 | 229 | dao = EmployeeDao(read_db=db) 230 | return FastapiListing(dao=dao).get_response( 231 | MetaInfo(default_srt_on="emp_no", 232 | filter_mapper=emp_filter_mapper, 233 | feature_params_adapter=YourAdapterClass, 234 | filter=filter_, 235 | sort=sort_, 236 | paginator=pagination)) 237 | 238 | ``` 239 | 240 | ### Let's break it down 241 | 242 | **Filter mapper** - a collection of allowed filters on your listing API. Any request outside of this mapper scope 243 | will not be executed for filtering safeguarding you from creepy API users. 244 | 245 | `generic_filters` a collection of inbuilt filters supported by sqlalchemy orm 246 | A dictionary is defined with structure: 247 | 248 | `{"alias": tuple("sqlalchemy_model.field", filter_implementation)}` 249 | 250 | `alias` - A string used by client in case if you wanna avoid actual column names to client site. 251 | 252 | `tuple` - will contain two items field name and filter implementation 253 | 254 | ```python 255 | from fastapi_listing.filters import generic_filters 256 | 257 | 258 | emp_filter_mapper = { 259 | "gdr": ("Employee.gender", generic_filters.EqualityFilter), 260 | "bdt": ("Employee.birth_date", generic_filters.MySqlNativeDateFormateRangeFilter), 261 | "fnm": ("Employee.first_name", generic_filters.StringStartsWithFilter), 262 | } 263 | ``` 264 | 265 | Register the above mapper with filter factory. 266 | 267 | ```python 268 | from fastapi_listing.factory import filter_factory 269 | 270 | 271 | filter_factory.register_filter_mapper(emp_filter_mapper) # Register in global space or module level. 272 | ``` 273 | 274 | A client could request you like `v1/employees?filter=[{"gdr":"M"}]` 275 | 276 | parse the above query_param in your adapter class like `[{"field":"gdr", "value":{"search":"M"}}]` if passed externally as kwarg then access it via `self.extra_context` in your adapter class or if passed request then 277 | access `self.request` directly there. 278 | 279 | Assuming everything goes right above will produce a response with items filtered on gender field matching rows with 'M' 280 | 281 | **Sort Mapper** - a collection of allowed sort on listing any request outside of this mapper scope will 282 | not be permitted for sort. 283 | 284 | Simply define a dictionary with structure `{"alias": "field"}` if sorting on same column them omit model name & 285 | if sorting on a joined table column then add sqlalchemy class name like we did for filter `{"alias":"sqlalchemy_model.field"}` 286 | 287 | ```python 288 | listing_sort_mapper = { 289 | "code": "emp_no" 290 | } 291 | return FastapiListing(dao=dao).get_response( 292 | MetaInfo(default_srt_on="emp_no", 293 | filter_mapper=emp_filter_mapper, 294 | sort_mapper=listing_sort_mapper, 295 | feature_params_adapter=YourAdapterClass, 296 | filter=filter_, 297 | sort=sort_, 298 | paginator=pagination)) 299 | 300 | # OR if passing request obj 301 | return FastapiListing(request=request, dao=dao).get_response( 302 | MetaInfo(default_srt_on="emp_no", 303 | filter_mapper=emp_filter_mapper, 304 | sort_mapper=listing_sort_mapper, 305 | feature_params_adapter=YourAdapterClass)) 306 | ``` 307 | 308 | A client could request you like `v1/employees?sort={"code":}` or followed by filter `v1/employees?filter=[{"gdr":"M"}]&sort={"code":, "type":"asc"}` 309 | and the response should contain list items sorted by employee code column in ascending order. 310 | 311 | **Note** we didn't registered sort mapper like we did for filter mapper. 312 | 313 | Similarly, for paginator `v1/employees?pagination={"page":1, "pageSize":10}` or followed by filter and sort `v1/employees?filter=[{"gdr":"M"}]&sort={"code":, "type":"asc"}&pagination={"page":1, "pageSize":10}` 314 | 315 | Above will produce listing page of items 10 or dynamically client could change page size. 316 | 317 | One thing to **Note** here is fastapi listing by default limits the client to reuqest maximum of 50 items at a time to safeguard your database 318 | if you want to increase/decrease this default limit then simply pass the limit in `MetaInfo` 319 | 320 | **You can also change the default page size from 10 to anything you would want** 321 | 322 | ```python 323 | return FastapiListing(request=request, dao=dao).get_response( 324 | MetaInfo(default_srt_on="emp_no", 325 | filter_mapper=emp_filter_mapper, 326 | sort_mapper=listing_sort_mapper, 327 | max_page_size=25, # here change max page size 328 | default_page_size=10, # here change default page size 329 | feature_params_adapter=YourAdapterClass)) 330 | ``` 331 | 332 | ### Class Based implementation 333 | Quick Example to convey the context 334 | 335 | ```python 336 | from fastapi import FastAPI 337 | 338 | from sqlalchemy import Column, Date, String, ForeignKey 339 | from sqlalchemy.orm import declarative_base 340 | from sqlalchemy.orm import Session, relationship 341 | from fastapi_listing import ListingService, FastapiListing 342 | from fastapi_listing.filters import generic_filters 343 | from fastapi_listing import loader 344 | from fastapi_listing.paginator import ListingPage 345 | 346 | Base = declarative_base() 347 | app = FastAPI() 348 | 349 | class Title(Base): 350 | __tablename__ = 'titles' 351 | 352 | emp_no = Column(ForeignKey('employees.emp_no', ondelete='CASCADE'), primary_key=True, nullable=False) 353 | title = Column(String(50), primary_key=True, nullable=False) 354 | from_date = Column(Date, primary_key=True, nullable=False) 355 | to_date = Column(Date) 356 | 357 | employee = relationship('Employee') 358 | 359 | 360 | class EmployeeDao(GenericDao): 361 | name = "employee" 362 | model = Employee 363 | 364 | class TitleDao(GenericDao): 365 | name = "title" 366 | model = Title 367 | 368 | @loader.register() 369 | class EmployeeListingService(ListingService): 370 | """Class based listing API implementation""" 371 | filter_mapper = { 372 | "gdr": ("Employee.gender", generic_filters.EqualityFilter), 373 | "bdt": ("Employee.birth_date", generic_filters.MySqlNativeDateFormateRangeFilter), 374 | "fnm": ("Employee.first_name", generic_filters.StringStartsWithFilter), 375 | "lnm": ("Employee.last_name", generic_filters.StringEndsWithFilter), 376 | # below feature will require customisation to work at query level 377 | "desg": ("Employee.Title.title", generic_filters.StringLikeFilter, lambda x: getattr(Title, x)) # registering filter with joined table field 378 | } 379 | 380 | sort_mapper = { 381 | "cd": "emp_no" 382 | } 383 | default_srt_on = "Employee.emp_no" 384 | default_dao = EmployeeDao 385 | 386 | def get_listing(self): 387 | # similar to above inline but instead of passing meta info uncompressed we pass self 388 | # rest is handled implicityly like filter register 389 | # one advantage here is every expect is validated so you get error when running server 390 | resp = FastapiListing(self.request, self.dao, pydantic_serializer=EmployeeListDetails).get_response(self.MetaInfo(self)) 391 | return resp 392 | 393 | 394 | @app.get("/employees", response_model=ListingPage[EmployeeListDetails]) 395 | def get_employees(db: Session): 396 | return EmployeeListingService(read_db=db).get_listing() 397 | ``` 398 | 399 | Check out [docs](https://fastapi-listing.readthedocs.io/en/latest/tutorials.html#adding-filters-to-your-listing-api) for supported list of filters. 400 | Additionally, you can create **custom filters** as well. 401 | 402 | ## Provided features are not meeting your requirements??? 403 | 404 | The Applications are endless with customisations 405 | 406 | ➡️ You can write custom: 407 | 408 | * Query 409 | * Filter 410 | * Sorter 411 | * Paginator 412 | 413 | You can check out customisation section in docs after going through basics and tutorials. 414 | 415 | Check out my other [repo](https://github.com/danielhasan1/test-fastapi-listing/blob/master/app/router/router.py) to see some examples 416 | 417 | ## Features and Readability hand in hand 🤝 418 | 419 | - Well defined interface for filter, sorter, paginator 420 | - Support Dependency Injection for easy testing 421 | - Room to adapt the existing remote client query param semantics 422 | - Write standardise listing APIs that will be understood by generations of upcoming developers 423 | - Write listing features which is easy on human mind to extend or understand 424 | - Break down the most complex listing data APIs into digestible piece of code 425 | 426 | Why readability and code quality matters in one picture... 427 | 428 | 429 | 430 | # Documentation 431 | View full documentation at: https://fastapi-listing.readthedocs.io (A work in progress) 432 | 433 | 434 | 435 | # Feedback, Questions? 436 | 437 | Any form of feedback and questions are welcome! Please create an issue 💭 438 | [here](https://github.com/danielhasan1/fastapi-listing/issues/new). 439 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/advanced_user_guide.rst: -------------------------------------------------------------------------------- 1 | Customisation 2 | ============= 3 | 4 | Learn how to customise your listing service without losing any performance with FastAPI Listing ✨ 5 | 6 | .. toctree:: 7 | :maxdepth: 3 8 | 9 | query 10 | filters 11 | sorter 12 | paginator 13 | -------------------------------------------------------------------------------- /docs/basics.rst: -------------------------------------------------------------------------------- 1 | The Basics 2 | ========== 3 | 4 | Installation 5 | ------------ 6 | 7 | To install FastAPI Listing, run: 8 | 9 | .. code-block:: bash 10 | 11 | pip install fastapi-listing 12 | 13 | 14 | .. _dao overview: 15 | 16 | 17 | The Dao (Data Access Object) layer 18 | ---------------------------------- 19 | 20 | FastAPI Listing uses a `dao `_ 21 | layer. 22 | 23 | Benefits 24 | 25 | * A dedicated place for writing queries 26 | * Better Separation 27 | * Ability to change queries independently 28 | * Provides common code usage for more than one place 29 | * imports look cleaner 30 | 31 | Metaphorically "a dedicated place where you cultivate your ingredients for cooking purpose" (stolen from sqlalchemy docs) 32 | 33 | FastAPI Listing uses single table Dao. Each dao class will be bound with single orm model class 📝. 34 | 35 | Dao objects 36 | ^^^^^^^^^^^ 37 | 38 | .. py:class:: GenericDao 39 | 40 | when creating dao ``class`` extend ``GenericDao`` which comes with necessary setup code. 41 | Each Dao object support two protected (limiting their scope to dao layer only) session attributes. 42 | 43 | ``_read_db`` and ``_write_db`` 44 | 45 | You can use these attributes to communicate with the database. Provides early preparation of when you might need to implement master slave architecture. 46 | Non master slave arch users can point both of these attributes to same db as well. It's simple. 47 | 48 | Dao class attributes 49 | ^^^^^^^^^^^^^^^^^^^^ 50 | 51 | .. py:attribute:: GenericDao.model 52 | 53 | The sqlalchemy model class. Attribute type - **Required** 54 | 55 | .. py:attribute:: GenericDao.name 56 | 57 | User defined name of dao class, should be unique. Attribute type - **Required** 58 | 59 | 60 | The Strategy layer 61 | ------------------- 62 | Encapsulates process of: 63 | * writing data fetch logics (Query Strategy) 64 | * applying sorting(if any) after fetching data (Sorting Strategy) 65 | * paginating data at the end (Paginating Strattegy) 66 | 67 | Inspiration of using strategy pattern for this: 68 | Depending upon logged in user/applied query filter/performance requirement/legacy based database schema(poorly managed)/data visual limiting due to maybe role of user. 69 | You will write multiple ways to prepare queries to fetch data, or different technique to handle sorting, or a lazy paginator etc. 70 | In any case this is a really good way to handle multiple logic implementations and their compositions. 71 | 72 | Query Strategy 73 | ^^^^^^^^^^^^^^ 74 | 75 | Logical layer to decide on a listing query in a context. By default comes with a ``default_query`` strategy which generates a 76 | ``select a,b,c,d from some_table`` query using sqlalchemy where a,b,c,d are columns given by the user. 77 | 78 | .. _querybasics: 79 | 80 | For simple use cases this gets the work done. 81 | 82 | .. py:class:: QueryStrategy 83 | 84 | You can easily create your custom Query Strategy by extending base class. 85 | 86 | ➡️ Taking a real world example where using strategy pattern can be helpful: 87 | 88 | You have an employee table and hierarchy Director*->Assistant Director*->Division Managers*->Managers*->Leads*->teams. 89 | 90 | You need to design an API to show list of employees associated to logged-in user only. For the sake of this example lets focus on query part only. 91 | 92 | With strategy you have two ways of achieving this. 93 | 94 | ➡️ Creating context related query strategies: 95 | 96 | ``class DirectorQueryForEmp(QueryStrategy)`` 97 | 98 | ``class AssistantDirectorQueryForEmp(QueryStrategy)`` 99 | 100 | ``class DivisionManagerQueryForEmp(QueryStrategy)`` 101 | 102 | ``class ManagersQueryForEmp(QueryStrategy)`` 103 | 104 | ``class LeadsQueryForEmp(QueryStrategy)`` 105 | 106 | You can abstract and encapsulate relevant logic to make a decision on logged in user basis. You can choose which one to call at runtime. 107 | 108 | Or 109 | 110 | ➡️ Encapsulate the whole thing into one: 111 | 112 | ``class EmployeeQuery(QueryStrategy)`` 113 | 114 | And implement context based logics in one place. Choosing to write in any of above style is a personal decision based on project requirements. 115 | 116 | 117 | Benefit of above approach: 118 | 119 | - Context is clear by just a look 120 | - light weight containers of logical instructions 121 | - Decoupled and easy to extend 122 | - Much Easier to incorporate new relevant features like adding for new role or super user. 123 | 124 | Sorting Strategy 125 | ^^^^^^^^^^^^^^^^ 126 | 127 | Responsible for applying sorting scheme(sql native sorting) on your query. Simple as it sound, nothing fancy here. 128 | 129 | .. py:class:: SortingOrderStrategy 130 | 131 | **SortingOrderStrategy** ``class`` knows two *client* site keywords ``asc`` or ``dsc`` and applies sorting scheme on basis of this 📝. 132 | 133 | 🤯You are using different keywords to make sorting decision? No worries 😉 :ref:`Make FastAPI Listing adapt to it`. 134 | 135 | 136 | Paginator Strategy 137 | ^^^^^^^^^^^^^^^^^^^ 138 | 139 | Simple Paginator to paginate your database queries and return paginated response to your clients. 140 | 141 | .. py:class:: PaginationStrategy 142 | 143 | * Easily define pagination params. 144 | * Support dynamic page resizing. 145 | * You can configure ``default_page_size`` to return default number of items if client made a request without pagination params 146 | * You can configure ``max_page_size``, to avoid memory choke on absurd page size demands from clients. 147 | * Easily implement your own custom paginator to add more features like lazy loading or range based slicing. 148 | 149 | 🤯You have an existing set of pagination params. can you still use it? Yes! 😉 :ref:`Make FastAPI Listing adapt to it`. 150 | 151 | The Filters layer 152 | ^^^^^^^^^^^^^^^^^ 153 | 154 | The most used feature of any listing service easily, and maintaining filters is an art in itself ❤️. 155 | 156 | Abstracts away the complex procedure of applying filters, No more branching (if else) in your listing API even if you have more than a dozen filters, 157 | with this you can write performance packed robust filters. 158 | 159 | Inspired by **django-admin** design of writing and maintaining filters. Create filter anywhere easy to import ❤️ like any independent 160 | facade API. You will see how inbuilt ``generic_filters`` will make it easy and super fast to integrate filters in your listing APIs. 161 | 162 | 🤯 Can it support your existing clients filter parameters? Ofcourse! 😉 :ref:`Make FastAPI Listing adapt to it`. 163 | 164 | .. _intereptorbasics: 165 | 166 | The Interceptor layer 167 | ^^^^^^^^^^^^^^^^^^^^^ 168 | 169 | Allows users to write custom execution plan for filters/Sorters. 170 | 171 | * Default filter execution plan follows iterative approach when one or more filters are applied by clients. 172 | * Default sorter execution plan allows sort on one param at a time. 173 | 174 | Reason of existence❓️ - In my personal experience there are special situations when applying two or many filters directly could cause 175 | multitude of problems if applied in one by one iterative fashion. Maybe you wanna skip one or combine two filter into one 176 | and form a more optimised and robust query for your db to avoid performance hiccups. 177 | 178 | Or 179 | 180 | Allow sorting on more than one field at a time (I personally don't like the idea as for larger tables it degrades the performance) The best way in my humble opinion 181 | is to shorten your data via filters and then sort on your will. 182 | 183 | So now you know you can intercept the way filters and sorters are applied and add your custom behaviours to it. 184 | 185 | .. _adapterbenefit: 186 | 187 | Params Adapter layer 188 | ^^^^^^^^^^^^^^^^^^^^ 189 | 190 | Everyone implements filter/sorter/paginator layers at their client site differently. For example stackoverflow🧐: 191 | 192 | .. image:: https://drive.google.com/uc?export=view&id=1X1DiX7zRhnmJfw-t71Vgk4jnKVIExJzP 193 | :width: 500 194 | :alt: Stockoverflow client site params study 195 | 196 | You might have a different approach, which is perfectly fine. This is where you can use FastAPI Listing to adjust to the 197 | parameters of your client's site by utilizing ``CoreListingParamsAdapter`` 🤓. With this, you can access your HTTP request 198 | object and parse the query parameters in a way that FastAPI Listing can comprehend. 199 | 200 | FastAPI Listing uses ``sort``, ``filter`` and ``pagination`` as keys for the adapter. The adapter should then return the 201 | translated parameters signaled at the native level. 202 | 203 | Now, you may wonder how FastAPI Listing natively understands the mentioned parameters: 204 | 205 | - Filter: ``[{"field": "", "value": {"search": ""}}]`` - This represents a list of filters applied by clients, where multiple filters can be applied simultaneously. 206 | - Sort: ``[{"field": "", "type": ""}]`` - This indicates a list of sorting instructions. While the default supports single sorting (as explained above), customization is possible. 207 | - Pagination: ``{"pageSize": "", "page": ""}`` - These are pagination parameters that support dynamic resizing of the page. 208 | 209 | This feature proves immensely beneficial for user with existing operational services seeking an enhanced solution to manage 210 | their current codebase. By leveraging this library, user can potentially integrate it without necessitating modificatin to their remote client 211 | site code. Consequently, FastAPI Listing Service can seamlessly adapt to their requirements. 212 | 213 | Moreover, Filters also provide varying semantics for parameters based on ranges and list. 214 | 215 | 216 | Conclusion 217 | ---------- 218 | 219 | That's it folks that's all for the theory. If you were able to come this far I believe you have a basic understanding of all the components. 220 | In the next section we will start with Tutorials. 221 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # For the full list of built-in configuration values, see the documentation: 4 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 5 | 6 | # -- Project information ----------------------------------------------------- 7 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information 8 | 9 | # try: 10 | # from importlib.metadata import version as get_version 11 | # except ImportError: # for Python<3.8 12 | # from importlib_metadata import version as get_version 13 | 14 | # from packaging.version import parse 15 | # v = parse(get_version("fastapi_listing")) 16 | import os 17 | 18 | 19 | def get_version(): 20 | package_init = os.path.join( 21 | os.path.abspath(os.path.dirname(__file__)), "..", "fastapi_listing", "__init__.py" 22 | ) 23 | with open(package_init) as f: 24 | for line in f: 25 | if line.startswith("__version__ ="): 26 | return line.split("=")[1].strip().strip("\"'") 27 | 28 | 29 | project = 'fastapi-listing' 30 | copyright = '2023, Danish Hasan' 31 | author = 'Danish Hasan' 32 | # version = v.base_version 33 | # release = v.public 34 | version = get_version() 35 | release = get_version() 36 | 37 | # -- General configuration --------------------------------------------------- 38 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration 39 | 40 | extensions = [ 41 | "sphinx.ext.autodoc", 42 | "sphinx.ext.intersphinx", 43 | "sphinx_rtd_theme", 44 | "sphinx.ext.autosectionlabel", 45 | ] 46 | 47 | templates_path = ['_templates'] 48 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 49 | 50 | 51 | 52 | # -- Options for HTML output ------------------------------------------------- 53 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output 54 | autodoc_default_options = {"members": True, "show-inheritance": True} 55 | # pygments_style = "sphinx" 56 | html_theme = 'sphinx_rtd_theme' 57 | html_static_path = ['_static'] 58 | intersphinx_mapping = {"python": ("https://docs.python.org/3/", None)} 59 | htmlhelp_basename = "fastapilistingdoc" 60 | todo_include_todos = False 61 | -------------------------------------------------------------------------------- /docs/filters.rst: -------------------------------------------------------------------------------- 1 | .. _learnfilters: 2 | 3 | Adding Filters to your listing API 4 | ---------------------------------- 5 | 6 | The most interesting part of a listing that becomes the most hated part of any listing super easily. 7 | 8 | Starting with an easy request. 9 | 10 | Adding a filter that will filter your employee listing on basis of ``gender``. 11 | 12 | .. code-block:: python 13 | :emphasize-lines: 1, 7 14 | 15 | from fastapi_listing.filters import generic_filters 16 | 17 | 18 | @loader.register() 19 | class EmployeeListingService(ListingService): 20 | 21 | filter_mapper = { 22 | "gdr": ("Employee.gender", generic_filters.EqualityFilter), 23 | } 24 | 25 | # rest of the definition is going to be same no change required. 26 | 27 | In above example we have imported a module ``generic_filters`` which holds some of the very commonly used query filters supported by FastAPI Listing. 28 | These are highly reusable and support a cross model in place hook when you may wanna provide secondary model field. 29 | There are a bunch of filters out of the box to speed up your regular listing API development.😉 30 | 31 | 32 | 33 | .. list-table:: 34 | :widths: auto 35 | 36 | * - ``EqualityFilter`` 37 | - equality filter ``a == b`` 38 | * - ``InEqualityFilter`` 39 | - inequality filter ``a != b`` 40 | * - ``InDataFilter`` 41 | - ``in`` filter ``a in (b)`` 42 | * - ``BetweenUnixMilliSecDateFilter`` 43 | - best way to avoid conflict between date formate awareness. deal in unix timestamp. range filter ``between(start,end)`` 44 | * - ``StringStartsWithFilter`` 45 | - like filter ``a like b%`` 46 | * - ``StringEndsWithFilter`` 47 | - like filter ``a like %b`` 48 | * - ``StringContainsFilter`` 49 | - contains substring filter ``a like %b%``. recommended use on only small tables 50 | * - ``StringLikeFilter`` 51 | - string equality filter ``a like b`` 52 | * - ``DataGreaterThanFilter`` 53 | - greater than filter ``a > b`` 54 | * - ``DataGreaterThanEqualToFilter`` 55 | - greater than equal to filter ``a >= b`` 56 | * - ``DataLessThanFilter`` 57 | - less than filter a < b 58 | * - ``DataLessThanEqualToFilter`` 59 | - less than equal to filter a <= b 60 | * - ``DataGropByElementFilter`` 61 | - aggregation filter ``a group by b`` 62 | * - ``DataDistinctByElementFilter`` 63 | - distinct data filter ``distinct a`` 64 | * - ``HasFieldValue`` 65 | - has field filter ``a is null`` or ``a is not null`` 66 | * - ``MySqlNativeDateFormateRangeFilter`` 67 | - native date formate range filter between(a,b) 68 | 69 | 70 | I hope you still remember :ref:`filter_mapper ` 71 | 72 | Each item of this mapping dict has 3 key components. 73 | 74 | 1. the key itself which will be sent in remote client request. 75 | 2. The tuple 76 | * first item is ``model.field`` -> Field associated to primary table. The filter will be applied on it. 77 | * second item is your filter class definition. 78 | 79 | And that's it you have successfully implemented your first filter. 80 | 81 | 82 | Several benefits of having an alias over your actual fields as shown in the above dict key. 83 | 1. You will never expose your actual field name to the remote client which help to secure your service. 84 | 2. You will have a more cleaner looking request urls which will only make sense to software developers. 85 | 3. It will trim out the extra information exposing from urls. 86 | 87 | How FastAPI Listing reads filter params: 88 | 89 | * when you have a single value filter - ``[{"field": "alias<(filter mapper dict key)>", "value":{"search":}}]`` 📝 90 | * when you have multi value filter - ``[{"field": "alias<(filter mapper dict key)>", "value":{"list":}}]`` 📝 91 | * when you have a range value filter - ``[{"field": "alias<(fileter mapper dict key)>", "value":{"start":, "end":}}]`` 📝 92 | 93 | **If you have an existing running service that means you already have running remote client setup that will be sending different named query params for filter, then 94 | use the :ref:`adapter` to make your existing listing service adapt to your existing code.** 95 | 96 | 97 | Customising your filters 98 | ^^^^^^^^^^^^^^^^^^^^^^^^ 99 | 100 | Using secondary model field. Lets say you wanna use a field from ``DeptEmp`` model. If you give the write your filter like this 101 | 102 | .. code-block:: python 103 | 104 | filter_mapper = { 105 | "gdr": ("Employee.dept_no", generic_filters.EqualityFilter), 106 | } 107 | 108 | it will raise an attribute error which is expected as your primary model doesnt have this field. 109 | We have a rule to only allow a primary model plugged to our listing service. 110 | 111 | To allow passing secondary model field 112 | 113 | .. code-block:: python 114 | :emphasize-lines: 2 115 | 116 | filter_mapper = { 117 | "dpt": ("Employee.DeptEmp.dept_no", generic_filters.EqualityFilter, lambda x: getattr(DeptEmp, x)) 118 | } 119 | 120 | Lets see what extra we have in our tuple above. 121 | 122 | We have an extra lambda definition which tells what model field to use when this filter gets applied. 123 | As to why I chained two model names ``Employee.DeptEmp.dept_no``? 124 | 125 | There is a filter factory which centrally encapsulates all application logic. It works on unique field names(So you can't provide duplicate names). 126 | the **alias(filter mapper dict key)** could be same for multiple listing services and multiple database schema could contain same field names 127 | but any database asks you to provide unique schema(table) name similarly we register the filter under `schema.field` name to reduce for users to always coming 128 | up with random unique names. 129 | Chaining the name like this shows a clear relation that from ``Employee`` to ``DeptEmp`` where field is ``dept_no``. 130 | Though you can argue with it and still choose a different way of adding your filter field. Just make sure it is understandable. 131 | 132 | Note that if we use filter with this query strategy :ref:`dept emp query strategy ` then only this would work. becuase our base query is aware of 133 | ``DeptEmp``. 134 | 135 | Writing a custom filter 136 | ^^^^^^^^^^^^^^^^^^^^^^^ 137 | 138 | You wanna write your own filter because FastAPI Listing default filters were unable to fulfill your use case 🥹. 139 | 140 | Its easy to do as well. You wanna write a filter which does a full name scan combining first_name and last_name columns. 141 | 142 | .. code-block:: python 143 | :emphasize-lines: 2, 4, 6 144 | 145 | from fastapi_listing.filters import generic_filters 146 | from fastapi_listing.dao import dao_factory 147 | 148 | class FullNameFilter(generic_filters.CommonFilterImpl): 149 | 150 | def filter(self, *, field: str = None, value: dict = None, query=None) -> SqlAlchemyQuery: 151 | # field is not necessary here as this is a custom filter and user have full control over its implementation 152 | if value: 153 | emp_dao: EmployeeDao = dao_factory.create("employee", replica=True) 154 | emp_ids: list[int] = emp_dao.get_emp_ids_contain_full_name(value.get("search")) 155 | query = query.filter(self.dao.model.emp_no.in_(emp_ids)) 156 | return query 157 | 158 | As you can see in above filter class we are inheriting from a class which is a part of our ``generic_filters`` module. 159 | In our filter class we have a single filter method with fixed signature. you will receive your filter value as a dict. 160 | We have also used **dao factory** which allows us to use anywhere dao policy. 161 | You basically filter your query and return it. 162 | And just like that voila your custom filter is ready. No need to think how you will call it, this will be handled implicitly by filter mechanics(interceptor). 163 | 164 | Why do we need an interceptor? Just bear with this example to have an idea of when you may wanna use or write your own interceptor. 165 | 166 | Lets say you have a listing of products and a mapping table where products are mapped to some groups and each group belongs to a bigger group. 167 | 168 | Your mapping table looks like this 169 | 170 | .. code-block:: sql 171 | 172 | id | product_id | group_id | sub_group_id 173 | 174 | 175 | You added filters for group sub group and product on your listing. You wrote your custom filters to either apply **lazy join** or resolve mapping data 176 | and then apply the filter. So when: 177 | 178 | * A user applies Group filter - Your custom Group Filter gets called. 179 | * A user applies Sub Group filter - Your custom SubGroup Filter gets called with above Group Filter because user hasn't removed above filter. 180 | * A user applies Product filter with above two filters Your Product filter gets called with maybe with existing ``generic_filters.EqualityFilter`` Filter. 181 | 182 | Group -> Sub Group -> Product 183 | 184 | As the default interceptor runs in an iterative fashion which applies filter one by one you may end up getting different results. Why? lets see: 185 | 186 | You may try to find id of products mapped to Group A and applies filter on these ids. Perfect ✅ 187 | 188 | ``select product_id from mapping where group_id = 'A';`` 189 | 190 | and then feed these product_id into your filter via ``in`` query. 191 | 192 | On application of second filter you will repeat above process to find product ids and apply the filter again but wait will you receive sane results? I doubt it. ❌ 193 | 194 | ``select product_id from mapping where sub_group_id = "A_a";`` 195 | 196 | First your Group Filter is called. It returned product_ids. Then your Sub Group Filter is called and it may return different product_ids 197 | again you will feed these product_ids into your filter via ``in`` query. To avoid this you could create an advanced filter which is combination of both. 198 | Create a custom filter where you could find product_ids with below query 199 | 200 | ``select product from mapping where group_id = 'A' and sub_group_id = 'A_a';`` ✅ 201 | 202 | This will give you accurate product_ids. Once you have a custom filter you could detect if these two filters are applied together 203 | and modify their application by combining these two into one. 204 | 205 | Hope this gives you a more clear picture of situations where filter interceptor could play a significance role in reducing code complexity and 206 | providing a more cleaner approach towards writing your code. 207 | 208 | I've faced situations like this in some system and to resolve such situation interceptor could be a big help. -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. fastapi-listing documentation master file, created by 2 | sphinx-quickstart on Thu May 25 18:05:03 2023. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to fastapi-listing documentation! 7 | ========================================= 8 | 9 | FastAPI Listing is an advanced data listing library that works on top of `fastAPI `_ 10 | to reduce the efforts in writing and maintaining your listing APIs by providing 11 | a highly extensible, decoupled and reusable interface. 12 | 13 | **Component** based Plug & Play architecture allows you to write easy to use and more **quickly** readable block of code. 14 | Inject dependencies or swap components as you write more and more complex logics. 15 | 16 | It uses `SQLAlchemy `_ sqltool at the time but have potential to support multiple ORMs/database 17 | toolkits and that will be coming soon like mongoengine 📝. 18 | 19 | Features 20 | -------- 21 | 22 | * **Component Based Architecture**: Small collection of independent instructions. Easy to create and attach. 23 | * **Maintenance**: Fast to code and maintain, Light weight components are easy to create in case of multiple development iteration/customisations. 24 | * **Fewer Bugs**: Reduce the amount of bugs by always having single responsibility modules, Focus on one sub problem at a time to solve the bigger one. 25 | * **Easy**: Designed to be easy to use and never having the need to extend core modules. 26 | * **Short**: Minimize code duplication. 27 | * **Filters**: A predefined set of filters. Create new one or extend existing ones. An approach Inspired by **django admin**. Allows you to write powerful robust and reusable filters. 28 | * **Backport Compatibility**: Level up your existing listing APIs by using FastAPI Listing without changing any client site dependency utilizing adapters. 29 | * **Anywhere Dao objects**: Dao object powered by sqlalchemy sessions are just an import away. Use them anywhere to interact with database. 30 | 31 | Having some knowledge of design patterns such as strategy pattern, adapter pattern and solid principles could be a plus going forward in this documentation 📚️. 32 | 33 | 34 | The manual 35 | ---------- 36 | 37 | .. toctree:: 38 | :maxdepth: 3 39 | 40 | basics 41 | tutorials 42 | advanced_user_guide 43 | -------------------------------------------------------------------------------- /docs/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=. 11 | set BUILDDIR=_build 12 | 13 | %SPHINXBUILD% >NUL 2>NUL 14 | if errorlevel 9009 ( 15 | echo. 16 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 17 | echo.installed, then set the SPHINXBUILD environment variable to point 18 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 19 | echo.may add the Sphinx directory to PATH. 20 | echo. 21 | echo.If you don't have Sphinx installed, grab it from 22 | echo.https://www.sphinx-doc.org/ 23 | exit /b 1 24 | ) 25 | 26 | if "%1" == "" goto help 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/paginator.rst: -------------------------------------------------------------------------------- 1 | 2 | 3 | Customising Paginator Strategy 4 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 5 | 6 | We have a default pagination class. Which handles slicing of our data into pages with variable size. The provided pagination ``class`` 7 | is simple and gets the work done. If you wanna write your own efficient paginating strategy for huge tables or any other use case 8 | you could write one by extending existing base or abstract paginating strategy ``class``. 9 | 10 | For example you may wanna implement a paginating strategy which works on range ids for huge tables or only `previous` `next` pagination strategy and avoid 11 | any count query. 12 | 13 | 14 | .. code-block:: python 15 | :emphasize-lines: 3, 4 16 | 17 | @loader.register() 18 | class EmployeeListingService(ListingService): 19 | paginate_strategy: str = "default_paginator" 20 | default_page_size: int = 10 # default page size modify this to change default page size. 21 | 22 | 23 | 24 | .. _alias overview: 25 | 26 | Why use alias 27 | ------------- 28 | 29 | * Avoid giving away original column names at client level. A steps towards securing and maintaining abstraction at api level. 30 | * Shorter alias names are light weight. payload looks more friendly. 31 | * Saves a little bit of bandwidth by saving communicating some extra characters. 32 | * save coding time with shorter keys. -------------------------------------------------------------------------------- /docs/query.rst: -------------------------------------------------------------------------------- 1 | Customising your listing query 2 | ------------------------------- 3 | 4 | By default FastAPI Listing prepares simple queries which may look like: 5 | 6 | ``select a,b,c,d from table`` 7 | 8 | where ``a,b,c,d`` are columns that you provide either via pydantic serializer or as a list of strings. 9 | 10 | Remember this? 11 | 12 | ``FastapiListing(self.request, self.dao, pydantic_serializer=EmployeeListindDetail).get_response(self.MetaInfo(self))`` 13 | 14 | ``FastapiListing(self.request, self.dao, fields_to_fetch=['a', 'b', 'c', 'd']).get_response(self.MetaInfo(self))`` 15 | 16 | core ``class`` invokes ``get_default_read`` to prepare above mentioned vanilla query. You can easily overwrite this method 17 | in your dao class to write your custom query. 18 | 19 | You can either pass ``pydantic_serializer``/``fields_to_fetch`` or not as you will be writing custom ``query``. 20 | 21 | 22 | Advanced guide for generating listing query 23 | ------------------------------------------- 24 | 25 | Most of the time you will be writing your own custom optimised queries for retrieving listing data and it isn't unusual to write 26 | multiple queries that gets fired on different context. 27 | 28 | A brief example could be: 29 | 30 | You have a system where users are grouped together in different roles. Each group of user are separated on 31 | different layer of data levels so you need to check two thing in every listing API call 32 | 33 | 1. What role logged in user have, 34 | 35 | 2. On which data layer the user lies and show only relevant or allowed data, 36 | 37 | To tackle this situation you may wanna write different query for each group of users. 38 | Some queries may look simple some may look advanced some may even corporate caching layer. 39 | This part could easily kill your listing API performance if not handled well or a small change could induce huge errors. 40 | 41 | Going back to the topic. 42 | 43 | As mentioned in the basics section you can create :ref:`strategies` encapsulating query generation logics and abstracting query preparation from rest of the code. 44 | 45 | First Example 46 | ^^^^^^^^^^^^^ 47 | 48 | Lets say you have a dept manager table 49 | 50 | .. code-block:: python 51 | 52 | 53 | class DeptManager(Base): 54 | __tablename__ = 'dept_manager' 55 | 56 | emp_no = Column(ForeignKey('employees.emp_no', ondelete='CASCADE'), primary_key=True, nullable=False) 57 | dept_no = Column(ForeignKey('departments.dept_no', ondelete='CASCADE'), primary_key=True, nullable=False, 58 | index=True) 59 | from_date = Column(Date, nullable=False) 60 | to_date = Column(Date, nullable=False) 61 | 62 | department = relationship('Department') 63 | employee = relationship('Employee') 64 | 65 | 66 | Whenever department managers logs into the app they should only see employees who are associated to them (engineering department manager should only see engineering staff) 67 | 68 | Writing your own query strategy 69 | 70 | .. code-block:: python 71 | 72 | 73 | from fastapi_listing.strategies import QueryStrategy 74 | from fastapi_listing.factory import strategy_factory 75 | 76 | 77 | class DepartmentWiseEmployeesQuery(QueryStrategy): 78 | 79 | def get_query(self, *, request: FastapiRequest = None, dao: EmployeeDao = None, 80 | extra_context: dict = None) -> SqlAlchemyQuery: 81 | # as request and dao args are self explanatory 82 | # extra_context is a chained variable that can carry contextual data from one place 83 | # to another place. extremely helpful when passing args from router or client. 84 | dept_no: str = dept_no # assuming we found dept no of logged in user 85 | return dao.get_employees_by_dept(dept_no) # method defined in dao class 86 | 87 | # it is important to register your strategy with factory for use. 88 | strategy_factory.register("", DepartmentWiseEmployeesQuery) 89 | 90 | .. _dept_emp_q_stg: 91 | 92 | Add your new listing query to employee dao 93 | 94 | .. code-block:: python 95 | 96 | 97 | from sqlalchemy.orm import Query 98 | 99 | class EmployeeDao(ClassicDao): 100 | name = "employee" 101 | model = Employee 102 | 103 | def get_employees_by_dept(self, dept_no: str) -> Query: 104 | # assuming we have one to one mapping and we are passing manager department here 105 | query = self._read_db.query(self.model 106 | ).join(DeptEmp, Employee.emp_no == DeptEmp.emp_no 107 | ).filter(DeptEmp.dept_no == dept_no) 108 | return query 109 | 110 | 111 | .. code-block:: python 112 | :emphasize-lines: 9 113 | 114 | @loader.register() 115 | class EmployeeListingService(ListingService): 116 | 117 | default_srt_on = "Employee.emp_no" 118 | default_dao = EmployeeDao 119 | query_strategy = "default_query" # strategy chosen in case runtime switch condition not satisfied 120 | def get_listing(self): 121 | if user == manager: # imaginary conditions 122 | self.switch("query_strategy","") # switch strategy on the fly on object/request level 123 | 124 | resp = FastapiListing(self.request, self.dao).get_response(self.MetaInfo(self)) 125 | return resp 126 | 127 | In above example I have decided to make a switch for query strategy at runtime. So whenever a department manager logs in ``query_strategy`` will be 128 | switched to fetch relative data and whenever other user logs in they will see global data because you have a default ``query_strategy`` placed as well. Lets call it context based switching. 129 | 130 | Second Example 131 | ^^^^^^^^^^^^^^ 132 | 133 | 1. **Different Ways to Handle Queries:** 134 | 135 | If you want to deal with context based switching separately, you can encapsulate logic in a single strategy class. Add instructions to generate context based queries. Inject this class into your listing service ``default_strategy = ``. 136 | 137 | .. code-block:: python 138 | 139 | from fastapi_listing.strategies import QueryStrategy 140 | from fastapi_listing.factory import strategy_factory 141 | from sqlalchemy.orm import Query 142 | 143 | 144 | class EmployeesQuery(QueryStrategy): 145 | 146 | def get_query(self, *, request: FastapiRequest = None, dao: EmployeeDao = None, 147 | extra_context: dict = None) -> Query: 148 | # assuming in this scope we know about logged in user 149 | user = logged_in_user 150 | match user.role: 151 | case "manager" : 152 | query = self.get_manager_query(user) 153 | ... # you define other contexts like manager 154 | ... 155 | ... 156 | case _" : #encountering any unknown context return empty query 157 | query = dao.get_empty_query() # defined in classic dao 158 | 159 | return query 160 | 161 | def get_manager_query(self, user, dao) -> Query: 162 | # assuming we have a way to get dept_no 163 | dept_no = dao.get_dept_no_via_user(user) 164 | return dao.get_employees_by_dept(dept_no) 165 | 166 | # it is important to register your strategy with factory for use. 167 | strategy_factory.register("", EmployeesQuery) 168 | 169 | .. code-block:: python 170 | 171 | class EmployeeListingService(ListingService): 172 | 173 | default_srt_on = "Employee.emp_no" 174 | default_dao = EmployeeDao 175 | query_strategy = "" 176 | def get_listing(self): 177 | # if user == manager: # imaginary conditions 178 | # self.switch("query_strategy","") # switch strategy on the fly on object/request level 179 | 180 | # we made our query strategy class to exhibit different behaviour no need of above code 181 | resp = FastapiListing(self.request, self.dao).get_response(self.MetaInfo(self)) 182 | return resp 183 | 184 | 2. **Two Approaches for Query Handling:** 185 | 186 | Some people might want to decide which query method to use right where the service is like we did in first example. They like to keep the way queries work separate and simple. They can use ``switch`` to easily switch between different methods. 187 | 188 | 3. **Choosing the Right Approach:** 189 | 190 | It's completely a users choice to make their objects behave in a certain way. FastAPI Listing is capable of adhering to users need 😍 whether you wanna keep your context based switching at service level 191 | or at strategy level (query strategy class) inject it in your listing service as mention in first point and make your query strategy ``object`` capable of behaving context wise. 192 | 193 | Personally I mixes both of these when I know strategies are going to be simple I tend to make strategy objects capable of handlind different contexts but 194 | when I know or see my single strategy class is becoming hard to maintain I tend to breakdown them to handle specefic context at a time as a result having 195 | single responsibility objects. -------------------------------------------------------------------------------- /docs/sorter.rst: -------------------------------------------------------------------------------- 1 | 2 | 3 | Adding Sorters to your listing API 4 | ---------------------------------- 5 | 6 | This part is simple. As we leave it in the hand of db to sort our data in its own cluster FastAPI listing provides a strategy class 7 | to apply sort on our listing query. 8 | 9 | .. code-block:: python 10 | :emphasize-lines: 3, 4, 5 11 | 12 | @loader.register() 13 | class EmployeeListingService(ListingService): 14 | default_srt_ord: str = "dsc" # change the value to asc if you want ascending order. default value is dsc for latest data. 15 | default_srt_on = "Employee.emp_no" # default sorting field used when no loading listing with no sorting parameter. 16 | sort_mapper = { 17 | "empid": "emp_no", 18 | } 19 | 20 | ``sort_mapper`` is similar to ``filter_mapper`` where ``empid`` is what remote client sends and ``emp_no`` is what gets used to sort our dataset. 21 | it is a collection of allowed sorting parameters. 22 | 23 | If using primary model you could use it just like shown above. 24 | 25 | Or if sorting is implemented on joined table field and like filter mapper 26 | 27 | .. code-block:: python 28 | :emphasize-lines: 2 29 | 30 | sort_mapper = { 31 | "deptno": ("dept_no", lambda x: getattr(DeptEmp, x)) 32 | } 33 | 34 | like filter mapper there is no central sorter factory. As we leave the heavy lifting to DB. so there is no need to provide unique field names for registration purpose. 35 | Although its better to use ``model.field`` convention like we used in filter mapper to keep the similarity. 36 | 37 | Just like filter interceptor you also have an option of sorter interceptor where you could interrupt the default behaviour of applying sort on your query 38 | and customise how you may wanna apply multi field sorting on your query. 39 | 40 | How FastAPI Listing reads sorter params: 41 | 42 | ``[{"field":"alias", "type":"asc"}]`` or ``[{"field":"alias", "type":"dsc"}]`` 📝 43 | 44 | **If you have an existing running service that means you already have running remote client setup that will be sending different named query params for filter, then 45 | use the** :ref:`adapter ` **to make your existing listing service adapt to your existing code.** -------------------------------------------------------------------------------- /docs/tutorials.rst: -------------------------------------------------------------------------------- 1 | Tutorials 2 | ========= 3 | 4 | Preparations 5 | ------------ 6 | 7 | A simple example showing how easy it is to get started. Lets look at a little bit of context for better understanding. 8 | 9 | **Note** First lets setup the app to use the library 10 | 11 | Your project structure may differ, but all FastAPI related flow is similar in context. 12 | 13 | I'll be using the following structure for this tutorial: 14 | 15 | 16 | .. parsed-literal:: 17 | 18 | employess 19 | \|-- app 20 | | \|-- __init__.py 21 | | \|-- :ref:`dao` 22 | | | \|-- __init__.py 23 | | | \|-- title_dao.py 24 | | | \|-- dept_emp_dao.py 25 | | | \|-- generics 26 | | | | \|-- __init__.py 27 | | | | \`-- dao_generics.py 28 | | | \|-- :ref:`models` 29 | | | | \|-- __init__.py 30 | | | | \|-- title.py 31 | | | | \|-- dept_emp.py 32 | | | | \`-- employee.py 33 | | | \`-- employee_dao.py 34 | | \|-- :ref:`router` 35 | | | \|-- __init__.py 36 | | | \`-- employee_router.py 37 | | \|-- :ref:`schema` 38 | | | \|-- __init__.py 39 | | | \|-- request 40 | | | | \|-- __init__.py 41 | | | | \|-- title_requests.py 42 | | | | \`-- employee_requests.py 43 | | | \`-- response 44 | | | \|-- __init__.py 45 | | | \|-- title_responses.py 46 | | | \`-- employee_responses.py 47 | | \`-- :ref:`service` 48 | | \|-- __init__.py 49 | | \|-- strategies 50 | | \`-- employee_service.py 51 | \|-- main.py 52 | \`-- requirements.txt 53 | 54 | Lets call this app **employees** 55 | 56 | 57 | models 58 | ------ 59 | 60 | model classes 61 | 62 | .. code-block:: python 63 | 64 | 65 | class Employee(Base): 66 | __tablename__ = 'employees' 67 | 68 | emp_no = Column(Integer, primary_key=True) 69 | birth_date = Column(Date, nullable=False) 70 | first_name = Column(String(14), nullable=False) 71 | last_name = Column(String(16), nullable=False) 72 | gender = Column(Enum('M', 'F'), nullable=False) 73 | hire_date = Column(Date, nullable=False) 74 | 75 | 76 | class DeptEmp(Base): 77 | __tablename__ = 'dept_emp' 78 | 79 | emp_no = Column(ForeignKey('employees.emp_no', ondelete='CASCADE'), primary_key=True, nullable=False) 80 | dept_no = Column(ForeignKey('departments.dept_no', ondelete='CASCADE'), primary_key=True, nullable=False, index=True) 81 | from_date = Column(Date, nullable=False) 82 | to_date = Column(Date, nullable=False) 83 | 84 | department = relationship('Department') 85 | employee = relationship('Employee') 86 | 87 | class Title(Base): 88 | __tablename__ = 'titles' 89 | 90 | emp_no = Column(ForeignKey('employees.emp_no', ondelete='CASCADE'), primary_key=True, nullable=False) 91 | title = Column(String(50), primary_key=True, nullable=False) 92 | from_date = Column(Date, primary_key=True, nullable=False) 93 | to_date = Column(Date) 94 | 95 | employee = relationship('Employee') 96 | 97 | Dao 98 | --- 99 | Here we have a local dao package where we will be adding all our :ref:`Dao` classes. 100 | I've additionally added a package to keep generic methods for common use, e.g. ``dao_generics.py`` file which looks something 101 | like this 102 | 103 | .. code-block:: python 104 | 105 | 106 | from fastapi_listing.dao import GenericDao 107 | 108 | 109 | class ClassicDao(GenericDao): # noqa 110 | """ 111 | Not to be used directly as this class is missing required attributes 'model' and 'name' to be given by users. 112 | model class is given when we are linking a new dao class with a new model/table 113 | name is dao name that a user will use to invoke dao objects. 114 | """ 115 | 116 | def check_pk_exist(self, id: int | str) -> bool: 117 | # check if id exists in linked dao table 118 | return self._read_db.query(self._read_db.query(self.model 119 | ).filter(self.model.id == id).exists()).scalar() 120 | 121 | 122 | def get_empty_query(self): 123 | # return empty query 124 | return self._read_db.query(self.model).filter(sqlalchemy.sql.false()) 125 | 126 | Dao classes 127 | 128 | .. code-block:: python 129 | 130 | 131 | # each dao will be placed in their own module/files 132 | from fastapi_listing.dao import dao_factory 133 | 134 | from app.dao import ClassicDao 135 | 136 | class TitleDao(ClassicDao): 137 | name = "title" 138 | model = Title 139 | 140 | dao_factory.register_dao(TitleDao.name, TitleDao) # registering dao with app to use anywhere 141 | 142 | class EmployeeDao(ClassicDao): 143 | name = "employee" 144 | model = Employee 145 | 146 | dao_factory.register_dao(EmployeeDao.name, EmployeeDao) # registering dao with app to use anywhere 147 | 148 | class DeptEmpDao(ClassicDao): 149 | name = "deptemp" 150 | model = DeptEmp 151 | 152 | dao_factory.register_dao(DeptEmpDao.name, DeptEmpDao) # registering dao with app to use anywhere 153 | 154 | 155 | schema 156 | ------ 157 | 158 | Response Schema (Support for pydantic 2 is added.) 159 | 160 | .. code-block:: python 161 | 162 | 163 | class GenderEnum(enum.Enum): 164 | MALE = "M" 165 | FEMALE = "F" 166 | 167 | class EmployeeListDetails(BaseModel): 168 | emp_no: int = Field(alias="empid", title="Employee ID") 169 | birth_date: date = Field(alias="bdt", title="Birth Date") 170 | first_name: str = Field(alias="fnm", title="First Name") 171 | last_name: str = Field(alias="lnm", title="Last Name") 172 | gender: GenderEnum = Field(alias="gdr", title="Gender") 173 | hire_date: date = Field(alias="hdt", title="Hiring Date") 174 | 175 | class Config: 176 | orm_mode = True 177 | allow_population_by_field_name = True 178 | 179 | 180 | main 181 | ---- 182 | Add middleware at main file 183 | 184 | .. code-block:: python 185 | :emphasize-lines: 17 186 | 187 | def get_db() -> Session: 188 | """ 189 | replicating sessionmaker for any fastapi app. 190 | anyone could be using a different way or opensource packages like fastapi-sqlalchemy 191 | it all comes down to a single result that is yielding a session. 192 | for the sake of simplicity and testing purpose I'm replicating this behaviour in this naive way. 193 | :return: Session 194 | """ 195 | engine = create_engine("mysql://root:123456@127.0.0.1:3307/employees", pool_pre_ping=1) 196 | sess = Session(bind=engine) 197 | return sess 198 | 199 | 200 | app = FastAPI() 201 | # fastapi-listing middleware offering anywhere dao usage policy. Just like anywhere door use sessions and dao 202 | # anywhere in your code via single import. 203 | 204 | # if you have a master slave architecture 205 | app.add_middleware(DaoSessionBinderMiddleware, master=get_db, replica=get_db) 206 | 207 | # if you have only a master database 208 | app.add_middleware(DaoSessionBinderMiddleware, master=get_db) 209 | 210 | # if you want fastapi listing to close session when returning a response 211 | app.add_middleware(DaoSessionBinderMiddleware, master=get_db, session_close_implicit=True) 212 | 213 | router 214 | ------ 215 | 216 | Write abstract listing api routers with FastAPI Listing. 217 | calling listing endpoint from routers 218 | 219 | .. code-block:: python 220 | :emphasize-lines: 1, 5, 8 221 | 222 | from fastapi_listing.paginator import ListingPage 223 | from app.schema.response import EmployeeListingDetail 224 | from app.service import EmployeeListingService 225 | 226 | @app.get("/v1/employees", response_model=ListingPage[EmployeeListingDetail]) 227 | def read_main(request: Request): 228 | resp = EmployeeListingService(request).get_listing() 229 | return resp 230 | 231 | service definition is given in below. 232 | 233 | 234 | .. _service: 235 | 236 | 237 | Writing your very first listing API using fastapi-listing 238 | --------------------------------------------------------- 239 | 240 | .. code-block:: python 241 | :emphasize-lines: 1, 6, 10, 13, 14 242 | 243 | 244 | from fastapi_listing import ListingService, FastapiListing, loader 245 | from app.dao import EmployeeDao 246 | from app.schema.response.employee_responses import EmployeeListDetails # optional 247 | 248 | 249 | @loader.register() # run system checks to validate your listing service 250 | class EmployeeListingService(ListingService): 251 | 252 | default_srt_on = "Employee.emp_no" 253 | default_dao = EmployeeDao 254 | 255 | def get_listing(self): 256 | resp = FastapiListing(self.request, self.dao, pydantic_serializer=EmployeeListDetails 257 | ).get_response(self.MetaInfo(self)) 258 | return resp 259 | 260 | # that's it your very first listing api is ready to be serverd. 261 | # 262 | 263 | You actually began writing your listing API here. Before this everything was vanilla FastAPI code excluding doa setup 🤠 264 | 265 | * **loader**: A utility decorator used on startup when classes gets loaded into the memory validates the semantics also helps to identify any abnormality within 266 | your defined listing class. 267 | * **ListingService**: High level base class. All Listing Service classes will extend this. 268 | * **Attributes**: :ref:`attributes overview` 269 | * **EmployeeListDetails**: Optional pydantic class containing required fields to render. These field will get added automatically in vanilla query. 270 | if you are not using pydantic then you could leave it or use list of fields. 271 | * **get_listing**: High level function, entrypoint for listing service. 272 | * **FastapiListing**: Low level class that you will only use as an expression which returns a result. Extending this is forbidden. 273 | 274 | Once you runserver, hit the endpoint ``localhost:8000/v1/employees`` and you will receive a json response with page size 10 (default page size). 275 | 276 | 277 | .. _attributes overview: 278 | 279 | ``ListingService`` high level attributes 280 | ---------------------------------------- 281 | 282 | This library is divided down to fundamental level blocks of any listing API, You can create these blocks independent from each other 283 | inject them into your listing service and their composition will communicate implicitly so you can focus more on writing solutions and leave their communication on the core service. 284 | 285 | .. py:currentmodule:: fastapi_listing.service.listing_main 286 | 287 | .. _filter_mapper_label: 288 | 289 | .. py:attribute:: ListingService.filter_mapper 290 | 291 | A ``dict`` containing allowed filters on the listing. ``{alias: value}`` where key should be an alias of field and value is 292 | a tuple. You can use actual field names in place of alias its a matter of personal preferrence 🤓 293 | 294 | for example: ``{"fnm": ("Employees.first_name", filter_class)}`` 295 | 296 | value ``"Employees.first_name"`` shows relation. ``first_name`` from primary model ``Employees``. 297 | This should always be unique. You could go sane defining your values 298 | like this which will help you when debugging. 299 | 300 | alias/filter field will be sent in request by clients. for those who directly jumped here🤯 checkout :ref:`basics adapter layer` first 301 | to see how FastAPI Listing is capable of adapting to your existing clients without any modification. 302 | 303 | For customising the behaviour you can check out customisation section ✏️. 304 | 305 | :ref:`alias overview`? 306 | 307 | .. py:attribute:: ListingService.sort_mapper 308 | 309 | A ``dict`` containing allowed sorting on the listing. 310 | 311 | for example: ``{"empno": "Employees.emp_no"}`` 312 | 313 | sorter alias/fields will be sent in request by clients and you know FastAPI Listing can :ref:`adapt` to them. 314 | 315 | 316 | .. py:attribute:: ListingService.default_srt_on 317 | 318 | attribute provides field name used to sort listing item by default 319 | 320 | .. py:attribute:: ListingService.default_srt_ord 321 | 322 | attributes provides sorting order, allowed ``asc`` and ``dsc`` 📝. 323 | 324 | .. py:attribute:: ListingService.paginate_strategy 325 | 326 | attribute provides pagination strategy name used by listing service to apply pagination on query. 327 | Default strategy - ``default_paginator`` 328 | 329 | 330 | .. py:attribute:: ListingService.query_strategy 331 | 332 | attribute provides query strategy name, used to get base query for your listing service. 333 | Default strategy - ``default_query`` 334 | 335 | 336 | .. py:attribute:: ListingService.sorting_strategy 337 | 338 | attribute provides sorting strategy name, used to apply sorting on your base query. 339 | Default strategy - ``default_sorter`` 340 | 341 | .. py:attribute:: ListingService.sort_mecha 342 | 343 | attribute provides interceptor name. :ref:`interceptors` ❓️ 344 | Default interceptor - ``indi_sorter_interceptor`` 345 | 346 | .. py:attribute:: ListingService.filter_mecha 347 | 348 | attribute provides interceptor name. :ref:`interceptors` ❓️ 349 | Default interceptor - ``iterative_filter_interceptor`` 350 | 351 | 352 | .. py:attribute:: ListingService.default_dao 353 | 354 | provides listing service :ref:`dao` class. 355 | every listing service should contain one primary doa only. You can use multiple dao/sqlalchemy models/tables in defintion via dao_factory. 356 | 357 | .. py:attribute:: ListingService.default_page_size 358 | 359 | default number of items in a single page. 360 | 361 | .. _adapter_attr: 362 | 363 | .. py:attribute:: ListingService.feature_params_adapter 364 | 365 | default adapter to resolve issue between incompatible objects. Users are advices to design their 366 | own adapters to support their existing remote client site filter/sorter/page params. :ref:`adapters` ❓️ 367 | 368 | -------------------------------------------------------------------------------- /fastapi_listing/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.3.4" 2 | 3 | __all__ = [ 4 | "ListingService", 5 | "FastapiListing", 6 | "MetaInfo" 7 | ] 8 | 9 | from fastapi_listing.factory import strategy_factory, interceptor_factory 10 | from fastapi_listing.strategies import QueryStrategy, PaginationStrategy, SortingOrderStrategy 11 | from fastapi_listing.interceptors import IterativeFilterInterceptor, IndiSorterInterceptor 12 | from fastapi_listing.service.config import MetaInfo 13 | from fastapi_listing.service import ListingService, FastapiListing # noqa: F401 14 | 15 | 16 | strategy_factory.register_strategy("default_paginator", PaginationStrategy) 17 | strategy_factory.register_strategy("default_sorter", SortingOrderStrategy) 18 | strategy_factory.register_strategy("default_query", QueryStrategy) 19 | interceptor_factory.register_interceptor("iterative_filter_interceptor", IterativeFilterInterceptor) 20 | interceptor_factory.register_interceptor("indi_sorter_interceptor", IndiSorterInterceptor) 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /fastapi_listing/abstracts/__init__.py: -------------------------------------------------------------------------------- 1 | from fastapi_listing.abstracts.dao import DaoAbstract 2 | from fastapi_listing.abstracts.base_query import AbsQueryStrategy 3 | from fastapi_listing.abstracts.filter import FilterAbstract 4 | from fastapi_listing.abstracts.paginator import AbsPaginatingStrategy 5 | from fastapi_listing.abstracts.sorter import AbsSortingStrategy 6 | from fastapi_listing.abstracts.interceptor import AbstractFilterInterceptor, AbstractSorterInterceptor 7 | from fastapi_listing.abstracts.adapters import AbstractListingFeatureParamsAdapter 8 | from fastapi_listing.abstracts.listing import ListingBase, ListingServiceBase 9 | -------------------------------------------------------------------------------- /fastapi_listing/abstracts/adapters.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | 4 | class AbstractListingFeatureParamsAdapter(ABC): 5 | 6 | @abstractmethod 7 | def get(self, key: str): 8 | pass 9 | -------------------------------------------------------------------------------- /fastapi_listing/abstracts/base_query.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from abc import ABC, abstractmethod 4 | 5 | from fastapi_listing.abstracts import DaoAbstract 6 | from fastapi_listing.ctyping import FastapiRequest, SqlAlchemyQuery 7 | 8 | 9 | class AbsQueryStrategy(ABC): 10 | 11 | @abstractmethod 12 | def get_query(self, *, request: Optional[FastapiRequest] = None, dao: DaoAbstract, 13 | extra_context: dict) -> SqlAlchemyQuery: 14 | pass 15 | -------------------------------------------------------------------------------- /fastapi_listing/abstracts/dao.py: -------------------------------------------------------------------------------- 1 | from abc import ABCMeta, abstractmethod 2 | from typing import Union, Dict, List 3 | from fastapi_listing.ctyping import SqlAlchemyModel 4 | 5 | 6 | class DaoAbstract(metaclass=ABCMeta): 7 | 8 | @property 9 | @abstractmethod 10 | def model(self) -> SqlAlchemyModel: 11 | pass 12 | 13 | @property 14 | @abstractmethod 15 | def name(self) -> str: 16 | pass 17 | 18 | @abstractmethod 19 | def create(self, values) -> SqlAlchemyModel: 20 | pass 21 | 22 | @abstractmethod 23 | def update(self, identifier, values) -> bool: 24 | pass 25 | 26 | @abstractmethod 27 | def read(self, identifier, fields) -> SqlAlchemyModel: 28 | pass 29 | 30 | @abstractmethod 31 | def delete(self, identifier) -> bool: 32 | pass 33 | -------------------------------------------------------------------------------- /fastapi_listing/abstracts/filter.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from fastapi_listing.ctyping import SqlAlchemyQuery 3 | 4 | 5 | class FilterAbstract(ABC): 6 | 7 | @abstractmethod 8 | def filter(self, *, field: str = None, value: str = None, query: SqlAlchemyQuery = None): 9 | pass 10 | -------------------------------------------------------------------------------- /fastapi_listing/abstracts/interceptor.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod, ABC 2 | from typing import List, Dict 3 | 4 | from fastapi_listing.abstracts import DaoAbstract 5 | from fastapi_listing.abstracts import AbsSortingStrategy 6 | from fastapi_listing.ctyping import SqlAlchemyQuery, FastapiRequest 7 | 8 | 9 | class AbstractFilterInterceptor(ABC): 10 | 11 | @abstractmethod 12 | def apply(self, *, query: SqlAlchemyQuery = None, filter_params: List[Dict[str, str]], dao: DaoAbstract = None, 13 | request: FastapiRequest = None, extra_context: dict = None) -> SqlAlchemyQuery: 14 | pass 15 | 16 | 17 | class AbstractSorterInterceptor(ABC): 18 | 19 | @abstractmethod 20 | def apply(self, *, query: SqlAlchemyQuery = None, strategy: AbsSortingStrategy = None, 21 | sorting_params: List[Dict[str, str]] = None, extra_context: dict = None) -> SqlAlchemyQuery: 22 | pass 23 | -------------------------------------------------------------------------------- /fastapi_listing/abstracts/listing.py: -------------------------------------------------------------------------------- 1 | from typing import Type 2 | from abc import ABC, abstractmethod 3 | 4 | from sqlalchemy.orm import Query 5 | 6 | from fastapi_listing.ctyping import BasePage 7 | from fastapi_listing.abstracts import AbstractListingFeatureParamsAdapter 8 | from fastapi_listing.dao import GenericDao 9 | from fastapi_listing.interface.listing_meta_info import ListingMetaInfo 10 | 11 | 12 | class ListingBase(ABC): 13 | 14 | @abstractmethod 15 | def _prepare_query(self, listing_meta_info: ListingMetaInfo) -> Query: 16 | pass 17 | 18 | @abstractmethod 19 | def _apply_sorting(self, query: Query, listing_meta_info: ListingMetaInfo) -> Query: 20 | pass 21 | 22 | @abstractmethod 23 | def _apply_filters(self, query: Query, listing_meta_info: ListingMetaInfo) -> Query: 24 | pass 25 | 26 | @abstractmethod 27 | def _paginate(self, query: Query, listing_meta_info: ListingMetaInfo) -> BasePage: 28 | pass 29 | 30 | @abstractmethod 31 | def get_response(self, listing_meta_data) -> BasePage: 32 | pass 33 | 34 | 35 | class ListingServiceBase(ABC): 36 | 37 | @property 38 | @abstractmethod 39 | def filter_mapper(self) -> dict: # type:ignore # noqa 40 | pass 41 | 42 | @property 43 | @abstractmethod 44 | def sort_mapper(self) -> dict: # type:ignore # noqa 45 | pass 46 | 47 | @property 48 | @abstractmethod 49 | def default_srt_on(self) -> str: # type:ignore # noqa 50 | pass 51 | 52 | @property 53 | @abstractmethod 54 | def default_srt_ord(self) -> str: # type:ignore # noqa 55 | pass 56 | 57 | @property 58 | @abstractmethod 59 | def paginate_strategy(self) -> str: # type:ignore # noqa 60 | pass 61 | 62 | @property 63 | @abstractmethod 64 | def query_strategy(self) -> str: # type:ignore # noqa 65 | pass 66 | 67 | @property 68 | @abstractmethod 69 | def sorting_strategy(self) -> str: # type:ignore # noqa 70 | pass 71 | 72 | @sorting_strategy.setter 73 | def sorting_strategy(self, value): 74 | pass 75 | 76 | @query_strategy.setter 77 | def query_strategy(self, value): 78 | pass 79 | 80 | @paginate_strategy.setter 81 | def paginate_strategy(self, value): 82 | pass 83 | 84 | @property 85 | @abstractmethod 86 | def sort_mecha(self) -> str: # type:ignore # noqa 87 | pass 88 | 89 | @property 90 | @abstractmethod 91 | def filter_mecha(self) -> str: # type:ignore # noqa 92 | pass 93 | 94 | @property 95 | @abstractmethod 96 | def default_dao(self) -> GenericDao: # type:ignore # noqa 97 | pass 98 | 99 | @property 100 | @abstractmethod 101 | def feature_params_adapter(self) -> Type[AbstractListingFeatureParamsAdapter]: 102 | pass 103 | 104 | @property 105 | @abstractmethod 106 | def allow_count_query_by_paginator(self) -> bool: 107 | pass 108 | 109 | @abstractmethod 110 | def get_listing(self): 111 | pass 112 | 113 | @staticmethod 114 | def _allowed_strategy_types(key: str) -> bool: 115 | if key not in ("paginate_strategy", 116 | "query_strategy", 117 | "sorting_strategy", 118 | ): 119 | return False 120 | return True 121 | 122 | def switch(self, strategy_type: str, strategy_name: str): 123 | if not self._allowed_strategy_types(strategy_type): 124 | raise ValueError("unknown strategy type!") 125 | setattr(self, strategy_type, strategy_name) 126 | 127 | @property 128 | @abstractmethod 129 | def max_page_size(self) -> int: 130 | pass 131 | -------------------------------------------------------------------------------- /fastapi_listing/abstracts/paginator.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from fastapi_listing.ctyping import SqlAlchemyQuery, BasePage 3 | 4 | 5 | class AbsPaginatingStrategy(ABC): 6 | 7 | @abstractmethod 8 | def paginate(self, query: SqlAlchemyQuery, pagination_params: dict, extra_context: dict) -> BasePage: 9 | pass 10 | -------------------------------------------------------------------------------- /fastapi_listing/abstracts/sorter.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from fastapi_listing.ctyping import SqlAlchemyQuery 3 | from typing import Dict 4 | 5 | 6 | class AbsSortingStrategy(ABC): 7 | 8 | @abstractmethod 9 | def sort(self, *, query: SqlAlchemyQuery = None, value: Dict[str, str], 10 | extra_context: dict = None) -> SqlAlchemyQuery: 11 | pass 12 | -------------------------------------------------------------------------------- /fastapi_listing/constants.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielhasan1/fastapi-listing/1dd46275adc8f129c0660be1a40b865548f8fd0e/fastapi_listing/constants.py -------------------------------------------------------------------------------- /fastapi_listing/ctyping.py: -------------------------------------------------------------------------------- 1 | __all__ = [ 2 | "SqlAlchemyQuery", 3 | "SqlAlchemySession", 4 | "FastapiRequest", 5 | "AnySqlAlchemyColumn", 6 | "SqlAlchemyModel", 7 | "BasePage", 8 | "Page", 9 | "PageWithoutCount", 10 | ] 11 | 12 | from typing import TypeVar, List, Dict, Union, Sequence, Generic 13 | from typing_extensions import TypedDict 14 | from fastapi import Request 15 | from abc import ABC 16 | 17 | # will support future imports as well like pymongo and other orm tools 18 | 19 | try: 20 | from sqlalchemy.orm.decl_api import DeclarativeMeta 21 | from sqlalchemy.orm import Query 22 | from sqlalchemy.orm import Session 23 | from sqlalchemy.sql.sqltypes import TypeEngine 24 | from sqlalchemy.sql.schema import Column 25 | from sqlalchemy.engine.row import Row 26 | except ImportError: 27 | DeclarativeMeta = None 28 | Query = None 29 | Session = None 30 | Column = None 31 | Row = False 32 | 33 | T = TypeVar("T") 34 | 35 | 36 | class BasePage(TypedDict): 37 | data: Sequence[T] 38 | 39 | 40 | SqlAlchemyQuery = TypeVar("SqlAlchemyQuery", bound=Query) 41 | SqlAlchemySession = TypeVar("SqlAlchemySession", bound=Session) 42 | FastapiRequest = TypeVar("FastapiRequest", bound=Request) 43 | AnySqlAlchemyColumn = TypeVar("AnySqlAlchemyColumn", bound=Column) 44 | SqlAlchemyModel = TypeVar("SqlAlchemyModel", bound=DeclarativeMeta) 45 | 46 | # sqlalchemy query objects returns mapper Row or Model object, or if extended user could roughly return a dict 47 | 48 | 49 | class Page(BasePage): 50 | hasNext: bool 51 | totalCount: int 52 | currentPageSize: int 53 | currentPageNumber: int 54 | 55 | 56 | class PageWithoutCount(BasePage): 57 | hasNext: bool 58 | currentPageSize: int 59 | currentPageNumber: int 60 | -------------------------------------------------------------------------------- /fastapi_listing/dao/__init__.py: -------------------------------------------------------------------------------- 1 | from .generic_dao import GenericDao 2 | from fastapi_listing.dao.dao_registry import dao_factory 3 | 4 | __all__ = [ 5 | "GenericDao", 6 | "dao_factory" 7 | ] 8 | -------------------------------------------------------------------------------- /fastapi_listing/dao/dao_registry.py: -------------------------------------------------------------------------------- 1 | from fastapi_listing.middlewares import SessionProvider 2 | from fastapi_listing.dao import GenericDao 3 | 4 | 5 | class DaoObjectFactory: 6 | def __init__(self): 7 | self._dao = {} 8 | 9 | def register_dao(self, key: str, builder): 10 | if key is None or not key or type(key) is not str: 11 | raise ValueError(f"Invalid type key, expected str type got {type(key)} for {builder}!") 12 | if key in self._dao: 13 | raise ValueError(f"Dao name {key} already in use with {self._dao[key].__name__}!") 14 | self._dao[key] = builder 15 | 16 | def create(self, key, *, replica=True, master=False, both=False) -> GenericDao: 17 | dao_ = self._dao.get(key) 18 | if not dao_: 19 | raise ValueError(key) 20 | 21 | if both: 22 | dao_obj = dao_(read_db=SessionProvider.read_session, write_db=SessionProvider.session) 23 | 24 | elif master: 25 | dao_obj = dao_(write_db=SessionProvider.session) 26 | 27 | elif replica: 28 | dao_obj = dao_(read_db=SessionProvider.read_session) 29 | else: 30 | raise ValueError("Invalid creation type for dao object allowed types 'replica', 'master', or 'both'") 31 | 32 | return dao_obj 33 | 34 | 35 | dao_factory = DaoObjectFactory() 36 | -------------------------------------------------------------------------------- /fastapi_listing/dao/generic_dao.py: -------------------------------------------------------------------------------- 1 | from fastapi_listing.abstracts import DaoAbstract 2 | from sqlalchemy.orm import Session 3 | 4 | from fastapi_listing.ctyping import SqlAlchemyModel 5 | 6 | 7 | # noinspection PyAbstractClass 8 | class GenericDao(DaoAbstract): 9 | """ 10 | Dao stands for data access object. 11 | This layers encapsulates all logic that a user may write 12 | in order to interact with databases. 13 | Dao has only one responsibility that is to communicate with 14 | database, all logic regarding data manipulation should live at 15 | service layer. This also acts as a gateway to database 16 | a wrapper on top of existing orm where generic logic could be defined. 17 | 18 | This is a demo class showing how one can benefit from it. 19 | 20 | A dao should only have a primary table as we are speaking in terms of orm 21 | we will call it a model. 22 | so a dao should only be associated with single model or model class. 23 | for ex: 24 | model = ABCModelClass 25 | 26 | A naive layer for handling data related ops. 27 | """ 28 | 29 | def __init__(self, read_db=None, write_db=None): 30 | """ 31 | if you have a master slave architecture: 32 | read_db - read database session 33 | write_db - write database session 34 | if you have a single database: 35 | you can still reference read_db = write_db = same database session 36 | which will acts as a preparation to avoid changing pointing for each query, 37 | since we already have the basic setup injecting the right session(read_db = read database session, 38 | write_db = write database session) will save hours of debugging and fixing when needed. 39 | """ 40 | self._read_db: Session = read_db 41 | self._write_db: Session = write_db 42 | 43 | def create(self, values) -> SqlAlchemyModel: 44 | """ 45 | Subclasses can use this method to implement a generic method 46 | used for entering values into the database 47 | """ 48 | raise NotImplementedError 49 | 50 | def update(self, identifier, values) -> bool: 51 | """ 52 | Subclasses can use this method to implement a generic method 53 | used for updating values into the database against provided identifier 54 | which will be used to identify a set of unique records. 55 | The identifier can be used to uniquely identify a unique record or a bunch of records. 56 | """ 57 | raise NotImplementedError 58 | 59 | def read(self, identifier, fields) -> SqlAlchemyModel: 60 | """ 61 | Subclasses can use this method to implement a generic method 62 | used for reading values from the database against provided identifier. 63 | Use "fields" to fetch only specific fields or "__all__" 64 | """ 65 | raise NotImplementedError 66 | 67 | def delete(self, identifier) -> bool: 68 | """ 69 | Subclasses can use this method to implement a generic method 70 | used for deleting values from the database against provided identifier 71 | """ 72 | raise NotImplementedError 73 | 74 | def get_default_read(self, fields_to_read: list): 75 | """ 76 | Returns default model query with provided fields 77 | Subclasses can use this to write custom listing queries when 78 | there is no need for multiple queries. 79 | 80 | fields_to_read can be left or used. 81 | """ 82 | return self._read_db.query(*fields_to_read) 83 | -------------------------------------------------------------------------------- /fastapi_listing/errors.py: -------------------------------------------------------------------------------- 1 | from fastapi import HTTPException 2 | 3 | 4 | class FastapiListingError(BaseException): 5 | pass 6 | 7 | 8 | class ListingFilterError(FastapiListingError): 9 | pass 10 | 11 | 12 | class ListingSorterError(FastapiListingError): 13 | pass 14 | 15 | 16 | class ListingPaginatorError(FastapiListingError): 17 | pass 18 | 19 | 20 | class NotRegisteredApiException(HTTPException): 21 | pass 22 | 23 | 24 | class FastapiListingRequestSemanticApiException(HTTPException): 25 | pass 26 | 27 | 28 | class MissingSessionError(Exception): 29 | """Exception raised for when the user tries to access a database session before it is created.""" 30 | 31 | def __init__(self): 32 | msg = """ 33 | No session found! Either you are not currently in a request context, 34 | or you need to manually create a session context and pass the callable to middleware args 35 | e.g. 36 | callable -> get_db 37 | app.add_middleware(DaoSessionBinderMiddleware, master=get_db, replica=get_db) 38 | or 39 | pass a db session manually to your listing service 40 | e.g. 41 | AbcListingService(read_db=sqlalchemysession) 42 | """ 43 | super().__init__(msg) 44 | 45 | 46 | class MissingExpectedAttribute(Exception): 47 | """Exception raised for when the user misses expected attribute.""" 48 | pass 49 | 50 | 51 | class FastAPIListingWarning(UserWarning): 52 | pass 53 | -------------------------------------------------------------------------------- /fastapi_listing/factory/__init__.py: -------------------------------------------------------------------------------- 1 | from fastapi_listing.factory.filter import filter_factory 2 | from fastapi_listing.factory.strategy import strategy_factory 3 | from fastapi_listing.factory.interceptor import interceptor_factory 4 | 5 | 6 | __all__ = [ 7 | "filter_factory", 8 | "strategy_factory", 9 | "interceptor_factory" 10 | ] 11 | -------------------------------------------------------------------------------- /fastapi_listing/factory/_generic_factory.py: -------------------------------------------------------------------------------- 1 | """Factory for creating a game character.""" 2 | 3 | from typing import Any, Callable, Dict 4 | import types 5 | 6 | object_creation_collector: Dict[str, Callable[..., Any]] = {} 7 | 8 | 9 | def register(key: str, creator: Callable[..., Any]) -> None: 10 | """Register a new game character type.""" 11 | if key in object_creation_collector: 12 | raise ValueError(f"Factory can not have duplicate builder key {key} for instance {creator.__name__}") 13 | object_creation_collector[key] = creator 14 | 15 | 16 | def is_mapper_semantic_valid(mapper_val): 17 | if type(mapper_val) is not tuple: 18 | raise ValueError("Invalid sorter mapper semantic! Expected tuple!") 19 | if len(mapper_val) != 2: 20 | raise ValueError(f"Invalid sorter mapper semantic {mapper_val}! min tuple length should be 2.") 21 | if type(mapper_val[0]) is not str: 22 | raise ValueError(f"Invalid sorter mapper semantic {mapper_val}! first tuple element should be field (str)") 23 | if not isinstance(mapper_val[1], types.FunctionType): 24 | raise ValueError(f"positional arg error, expects a callable but received: {mapper_val[1]}!") 25 | return True 26 | 27 | 28 | def register_sort_mapper(mapper_val): 29 | if is_mapper_semantic_valid(mapper_val): 30 | register(mapper_val[0], mapper_val[1]) 31 | 32 | 33 | def unregister(key: str) -> None: 34 | """Unregister a game character type.""" 35 | object_creation_collector.pop(key, None) 36 | 37 | 38 | def create(builder_key: str, *args, **kwargs) -> Any: 39 | """Create a game character of a specific type, given JSON data.""" 40 | try: 41 | creator = object_creation_collector[builder_key] 42 | except KeyError: 43 | raise ValueError(f"unknown character type {builder_key!r}") 44 | return creator(*args, **kwargs) 45 | -------------------------------------------------------------------------------- /fastapi_listing/factory/filter.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Tuple, Optional, Callable 2 | import inspect 3 | import types 4 | 5 | from fastapi_listing.filters.generic_filters import CommonFilterImpl 6 | from fastapi_listing.ctyping import AnySqlAlchemyColumn 7 | 8 | 9 | class FilterObjectFactory: 10 | def __init__(self): 11 | self._filters = {} 12 | 13 | def register_filter(self, key: str, builder: CommonFilterImpl, 14 | field_extractor_fn: Callable[[str], AnySqlAlchemyColumn] = None): 15 | if key is None or not key: 16 | raise ValueError("Invalid type key!") 17 | if key in self._filters: 18 | raise ValueError(f"filter key {key!r} already in use with {self._filters[key][0].__name__!r}") 19 | self._filters[key] = (builder, field_extractor_fn) 20 | 21 | def is_mapper_semantic_valid(self, mapper_val): 22 | if type(mapper_val) is not tuple: 23 | raise ValueError("Invalid filter mapper semantic! Expected tuple!") 24 | if len(mapper_val) < 2 or len(mapper_val) > 3: 25 | raise ValueError(f"Invalid filter mapper semantic {mapper_val}! min tuple length should be 2.") 26 | if type(mapper_val[0]) is not str: 27 | raise ValueError(f"Invalid filter mapper semantic {mapper_val}! first tuple element should be field (str)") 28 | if not inspect.isclass(mapper_val[1]): 29 | raise ValueError(f"Invalid filter mapper semantic {mapper_val[1]!r}! Expects a class!") 30 | if not issubclass(mapper_val[1], CommonFilterImpl) and mapper_val[1] != CommonFilterImpl: 31 | raise ValueError(f"Invalid filter mapper semantic {mapper_val[1]!r}!" 32 | f" Expects a subclass of CommonFilterImpl") 33 | if len(mapper_val) == 3 and not isinstance(mapper_val[2], types.FunctionType): 34 | raise ValueError(f"positional arg error, expects a callable but received: {mapper_val[2]!r}!") 35 | return True 36 | 37 | def register_filter_mapper( 38 | self, filter_mapper: Dict[str, Tuple[str, CommonFilterImpl, Optional[Callable[[str], AnySqlAlchemyColumn]]]] 39 | ): 40 | for key, val in filter_mapper.items(): 41 | if self.is_mapper_semantic_valid(val): 42 | self.register_filter(val[0], *val[1:]) 43 | 44 | def create(self, key: str, **kwargs): 45 | try: 46 | filter_, field_extractor_fn = self._filters[key] 47 | except KeyError: 48 | raise ValueError(f"filter factory couldn't find registered key {key!r}") 49 | return filter_(**kwargs, field_extract_fn=field_extractor_fn) 50 | 51 | 52 | filter_factory = FilterObjectFactory() 53 | -------------------------------------------------------------------------------- /fastapi_listing/factory/interceptor.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from fastapi_listing.abstracts import AbstractFilterInterceptor, AbstractSorterInterceptor 3 | 4 | 5 | class InterceptorObjectFactory: 6 | def __init__(self): 7 | self._interceptor = {} 8 | 9 | def register_interceptor(self, key: str, builder: type): 10 | if key is None or not key or type(key) is not str: 11 | raise ValueError("Invalid type key!") 12 | if key in self._interceptor: 13 | raise ValueError(f"interceptor name {key!r}, already in use with {self._interceptor[key].__name__!r}!") 14 | if not inspect.isclass(builder): 15 | raise ValueError(f"{builder!r} is not a valid class!") 16 | if issubclass(builder, AbstractSorterInterceptor) and not builder == AbstractSorterInterceptor: 17 | pass 18 | elif issubclass(builder, AbstractFilterInterceptor) and not builder == AbstractFilterInterceptor: 19 | pass 20 | else: 21 | raise ValueError("Invalid interceptor class, expects a subclass of either " 22 | "'AbstractSorterInterceptor' or 'AbstractFilterInterceptor'") 23 | # if (not issubclass(builder, AbstractFilterInterceptor) or not issubclass(builder, AbstractSorterInterceptor) 24 | # or builder == AbstractSorterInterceptor or builder == AbstractFilterInterceptor): 25 | # raise ValueError("Invalid interceptor class, expects a subclass of either " 26 | # "'AbstractSorterInterceptor' or 'AbstractFilterInterceptor'") 27 | self._interceptor[key] = builder 28 | 29 | def create(self, key: str, *args, **kwargs) -> object: 30 | interceptor_ = self._interceptor.get(key) 31 | if not interceptor_: 32 | raise ValueError(f"interceptor factory couldn't find register key {key!r}") 33 | return interceptor_(*args, **kwargs) 34 | 35 | def aware_of(self, key: str) -> bool: 36 | return key in self._interceptor 37 | 38 | 39 | interceptor_factory = InterceptorObjectFactory() 40 | -------------------------------------------------------------------------------- /fastapi_listing/factory/strategy.py: -------------------------------------------------------------------------------- 1 | from typing_extensions import get_args 2 | from typing import Type, TypeVar, Union 3 | 4 | from fastapi_listing.abstracts import AbsQueryStrategy, AbsSortingStrategy, AbsPaginatingStrategy 5 | 6 | x = Union[AbsQueryStrategy, AbsSortingStrategy, AbsPaginatingStrategy] 7 | 8 | T = TypeVar("T", AbsPaginatingStrategy, AbsSortingStrategy, AbsQueryStrategy) 9 | 10 | 11 | class StrategyObjectFactory: 12 | def __init__(self): 13 | self._strategy = {} 14 | 15 | def register_strategy(self, key: str, builder: type): 16 | if key is None or not key: 17 | raise ValueError("Invalid type key!") 18 | if key in self._strategy: 19 | raise ValueError(f"strategy name: {key}, already in use with {self._strategy[key].__name__}!") 20 | if not issubclass(builder, get_args(x)): 21 | raise ValueError(f"builder {builder!r} is not a valid type of strategy, allowed {x}") 22 | self._strategy[key] = builder 23 | 24 | def create(self, key: str, *args, **kwargs) -> T: 25 | strategy_: Type[T] = self._strategy.get(key) 26 | if not strategy_: 27 | raise ValueError(f"no strategy found with name {key!r} in strategy_factory") 28 | return strategy_(*args, **kwargs) 29 | 30 | def aware_of(self, key: str) -> bool: 31 | return key in self._strategy 32 | 33 | 34 | strategy_factory = StrategyObjectFactory() 35 | -------------------------------------------------------------------------------- /fastapi_listing/filters/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielhasan1/fastapi-listing/1dd46275adc8f129c0660be1a40b865548f8fd0e/fastapi_listing/filters/__init__.py -------------------------------------------------------------------------------- /fastapi_listing/filters/generic_filters.py: -------------------------------------------------------------------------------- 1 | __all__ = [ 2 | "CommonFilterImpl", 3 | "EqualityFilter", 4 | "InEqualityFilter", 5 | "InDataFilter", 6 | "BetweenUnixMilliSecDateFilter", 7 | "StringStartsWithFilter", 8 | "StringEndsWithFilter", 9 | "StringContainsFilter", 10 | "StringLikeFilter", 11 | "DataGreaterThanFilter", 12 | "DataGreaterThanEqualToFilter", 13 | "DataLessThanFilter", 14 | "DataLessThanEqualToFilter", 15 | "DataGropByElementFilter", 16 | "DataDistinctByElementFilter", 17 | "HasFieldValue", 18 | "MySqlNativeDateFormateRangeFilter", 19 | ] 20 | 21 | from typing import Callable, Optional 22 | from datetime import datetime 23 | 24 | from fastapi import Request 25 | 26 | from fastapi_listing.abstracts import FilterAbstract 27 | from fastapi_listing.ctyping import SqlAlchemyQuery, AnySqlAlchemyColumn 28 | 29 | 30 | class CommonFilterImpl(FilterAbstract): 31 | 32 | def __init__(self, dao=None, request: Optional[Request] = None, *, extra_context: dict, 33 | field_extract_fn: Callable[[str], AnySqlAlchemyColumn]): 34 | # lambda x: getattr(Model, x) 35 | self.dao = dao 36 | self.request = request 37 | self.extra_context = extra_context 38 | self.custom_field_extractor = field_extract_fn 39 | 40 | def extract_field(self, field: str) -> AnySqlAlchemyColumn: 41 | field = field.split(".")[-1] 42 | if self.custom_field_extractor: 43 | return self.custom_field_extractor(field) 44 | return getattr(self.dao.model, field) 45 | 46 | def filter(self, *, field: str = None, value: dict = None, query=None) -> SqlAlchemyQuery: 47 | raise NotImplementedError("To be implemented in child class!") 48 | 49 | 50 | class EqualityFilter(CommonFilterImpl): 51 | 52 | def filter(self, *, field=None, value=None, query=None) -> SqlAlchemyQuery: 53 | inst_field = self.extract_field(field) 54 | if value: 55 | query = query.filter(inst_field == value.get("search")) 56 | return query 57 | 58 | 59 | class InEqualityFilter(CommonFilterImpl): 60 | 61 | def filter(self, *, field: str = None, value: dict = None, query=None) -> SqlAlchemyQuery: 62 | inst_field = self.extract_field(field) 63 | if value: 64 | query = query.filter(inst_field != value.get("search")) 65 | return query 66 | 67 | 68 | class InDataFilter(CommonFilterImpl): 69 | 70 | def filter(self, *, field: str = None, value: dict = None, query=None) -> SqlAlchemyQuery: 71 | inst_field = self.extract_field(field) 72 | if value: 73 | query = query.filter(inst_field.in_(value.get("list"))) 74 | return query 75 | 76 | 77 | class BetweenUnixMilliSecDateFilter(CommonFilterImpl): 78 | 79 | def filter(self, *, field: str = None, value: dict = None, query=None) -> SqlAlchemyQuery: 80 | inst_field = self.extract_field(field) 81 | if value: 82 | query = query.filter(inst_field.between(datetime.fromtimestamp(int(value.get('start')) / 1000), 83 | datetime.fromtimestamp(int(value.get('end')) / 1000))) 84 | return query 85 | 86 | 87 | class StringStartsWithFilter(CommonFilterImpl): 88 | 89 | def filter(self, *, field: str = None, value: dict = None, query=None) -> SqlAlchemyQuery: 90 | inst_field = self.extract_field(field) 91 | if value: 92 | query = query.filter(inst_field.startswith(value.get("search"))) 93 | return query 94 | 95 | 96 | class StringEndsWithFilter(CommonFilterImpl): 97 | 98 | def filter(self, *, field: str = None, value: dict = None, query=None) -> SqlAlchemyQuery: 99 | inst_field = self.extract_field(field) 100 | if value: 101 | query = query.filter(inst_field.endswith(value.get("search"))) 102 | return query 103 | 104 | 105 | class StringContainsFilter(CommonFilterImpl): 106 | 107 | def filter(self, *, field: str = None, value: dict = None, query=None) -> SqlAlchemyQuery: 108 | inst_field = self.extract_field(field) 109 | if value: 110 | query = query.filter(inst_field.contains(value.get("search"))) 111 | return query 112 | 113 | 114 | class StringLikeFilter(CommonFilterImpl): 115 | 116 | def filter(self, *, field: str = None, value: dict = None, query=None) -> SqlAlchemyQuery: 117 | inst_field = self.extract_field(field) 118 | if value: 119 | query = query.filter(inst_field.like(value.get("search"))) 120 | return query 121 | 122 | 123 | class DataGreaterThanFilter(CommonFilterImpl): 124 | 125 | def filter(self, *, field: str = None, value: dict = None, query=None) -> SqlAlchemyQuery: 126 | inst_field = self.extract_field(field) 127 | if value: 128 | query = query.filter(inst_field > value.get("search")) 129 | return query 130 | 131 | 132 | class DataGreaterThanEqualToFilter(CommonFilterImpl): 133 | 134 | def filter(self, *, field: str = None, value: dict = None, query=None) -> SqlAlchemyQuery: 135 | inst_field = self.extract_field(field) 136 | if value: 137 | query = query.filter(inst_field >= value.get("search")) 138 | return query 139 | 140 | 141 | class DataLessThanFilter(CommonFilterImpl): 142 | 143 | def filter(self, *, field: str = None, value: dict = None, query=None) -> SqlAlchemyQuery: 144 | inst_field = self.extract_field(field) 145 | if value: 146 | query = query.filter(inst_field < value.get("search")) 147 | return query 148 | 149 | 150 | class DataLessThanEqualToFilter(CommonFilterImpl): 151 | 152 | def filter(self, *, field: str = None, value: dict = None, query=None) -> SqlAlchemyQuery: 153 | inst_field = self.extract_field(field) 154 | if value: 155 | query = query.filter(inst_field <= value.get("search")) 156 | return query 157 | 158 | 159 | class DataGropByElementFilter(CommonFilterImpl): 160 | 161 | def filter(self, *, field: str = None, value: dict = None, query=None) -> SqlAlchemyQuery: 162 | inst_field = self.extract_field(field) 163 | query = query.group_by(inst_field) 164 | return query 165 | 166 | 167 | class DataDistinctByElementFilter(CommonFilterImpl): 168 | 169 | def filter(self, *, field: str = None, value: dict = None, query=None) -> SqlAlchemyQuery: 170 | inst_field = self.extract_field(field) 171 | query = query.distinct(inst_field) 172 | return query 173 | 174 | 175 | class HasFieldValue(CommonFilterImpl): 176 | 177 | def filter(self, *, field: str = None, value: dict = None, query=None) -> SqlAlchemyQuery: 178 | inst_field = self.extract_field(field) 179 | if value.get("search"): 180 | query = query.filter(inst_field.is_not(None)) 181 | else: 182 | query = query.filter(inst_field.is_(None)) 183 | return query 184 | 185 | 186 | class MySqlNativeDateFormateRangeFilter(CommonFilterImpl): 187 | 188 | def filter(self, *, field: str = None, value: dict = None, query=None) -> SqlAlchemyQuery: 189 | inst_field = self.extract_field(field) 190 | if value: 191 | query = query.filter(inst_field.between(value.get("start"), value.get("end"))) 192 | return query 193 | -------------------------------------------------------------------------------- /fastapi_listing/interceptors/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = ["IterativeFilterInterceptor", "IndiSorterInterceptor"] 2 | 3 | from fastapi_listing.interceptors.iterative_filter_interceptor import IterativeFilterInterceptor 4 | from fastapi_listing.interceptors.individual_sorter_interceptor import IndiSorterInterceptor 5 | -------------------------------------------------------------------------------- /fastapi_listing/interceptors/individual_sorter_interceptor.py: -------------------------------------------------------------------------------- 1 | from typing import List, Dict 2 | 3 | from fastapi_listing.abstracts import AbstractSorterInterceptor 4 | from fastapi_listing.sorter import SortingOrderStrategy 5 | from fastapi_listing.ctyping import SqlAlchemyQuery 6 | 7 | 8 | class IndiSorterInterceptor(AbstractSorterInterceptor): 9 | """ 10 | Singleton Sorter mechanic. 11 | # ideally sorting should only happen on one field multi field sorting puts 12 | # unwanted strain on table when the size is big and not really popular 13 | # among various clients. Still leaving room for extension won't hurt 14 | # by default even if client is sending multiple sorting params we prioritize 15 | # the latest one which is last column that client requested to sort on. 16 | # if user want they can implement their own asc or dsc sorting order strategy and 17 | # decide how they really want to apply sorting params maybe all maybe none or maybe 18 | # conditional sorting where if one param is applied then don't apply another specific one, etc. 19 | """ 20 | 21 | def apply(self, *, query: SqlAlchemyQuery = None, strategy: SortingOrderStrategy = None, 22 | sorting_params: List[Dict[str, str]] = None, extra_context: dict = None) -> SqlAlchemyQuery: 23 | latest = sorting_params[-1] 24 | query = strategy.sort(query=query, value=latest, extra_context=extra_context) 25 | return query 26 | -------------------------------------------------------------------------------- /fastapi_listing/interceptors/iterative_filter_interceptor.py: -------------------------------------------------------------------------------- 1 | from typing import List, Dict, Optional 2 | 3 | from fastapi_listing.abstracts import AbstractFilterInterceptor 4 | from fastapi_listing.factory import filter_factory 5 | from fastapi_listing.filters.generic_filters import CommonFilterImpl 6 | from fastapi_listing.ctyping import SqlAlchemyQuery, FastapiRequest 7 | 8 | 9 | class IterativeFilterInterceptor(AbstractFilterInterceptor): 10 | """ 11 | Iterative Filter Applicator. 12 | Applies all client site filter in iterative manner. 13 | one by one call is made to registered filters and each filterd query is returned. 14 | 15 | User can write their own applicator if they don't want iterative applicator 16 | or have more complex way to apply filter like 17 | if one filter is applied, then don't apply the other one vice versa. 18 | to give a real world example 19 | if user has applied city, pincode, region filter then 20 | pincode is the most atomic unit here region and city filters are just extra burden on query and db as well. 21 | one can tackle this situation by having a mechanic which will check if specific filter is applied 22 | with other relative filters then don't apply other relative filters... 23 | """ 24 | 25 | def apply(self, *, query: SqlAlchemyQuery = None, filter_params: List[Dict[str, str]], dao=None, 26 | request: Optional[FastapiRequest] = None, extra_context: dict = None) -> SqlAlchemyQuery: 27 | for applied_filter in filter_params: 28 | filter_obj: CommonFilterImpl = filter_factory.create(applied_filter.get("field"), 29 | dao=dao, 30 | request=request, 31 | extra_context=extra_context) 32 | query = filter_obj.filter(field=applied_filter.get("field"), 33 | value=applied_filter.get("value"), 34 | query=query) 35 | return query 36 | -------------------------------------------------------------------------------- /fastapi_listing/interface/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielhasan1/fastapi-listing/1dd46275adc8f129c0660be1a40b865548f8fd0e/fastapi_listing/interface/__init__.py -------------------------------------------------------------------------------- /fastapi_listing/interface/client_site_params_adapter.py: -------------------------------------------------------------------------------- 1 | try: 2 | from typing import Protocol 3 | except ImportError: 4 | from typing_extensions import Protocol 5 | 6 | from typing import List 7 | 8 | 9 | class ClientSiteParamAdapter(Protocol): 10 | 11 | def get(self, key: str): 12 | pass 13 | -------------------------------------------------------------------------------- /fastapi_listing/interface/listing_meta_info.py: -------------------------------------------------------------------------------- 1 | try: 2 | from typing import Protocol 3 | except ImportError: 4 | from typing_extensions import Protocol 5 | from typing import Dict 6 | 7 | try: 8 | from typing import Literal 9 | except ImportError: 10 | from typing_extensions import Literal 11 | 12 | from fastapi_listing.abstracts import (AbsSortingStrategy, AbsPaginatingStrategy, AbsQueryStrategy, 13 | AbstractListingFeatureParamsAdapter) 14 | 15 | 16 | class ListingMetaInfo(Protocol): 17 | 18 | @property 19 | def paginating_strategy(self) -> AbsPaginatingStrategy: # type : ignore # noqa 20 | ... 21 | 22 | @property 23 | def query_strategy(self) -> AbsQueryStrategy: # type:ignore # noqa 24 | ... 25 | 26 | @property 27 | def sorting_column_mapper(self) -> dict: # type:ignore # noqa 28 | ... 29 | 30 | @property 31 | def filter_column_mapper(self) -> dict: # type:ignore # noqa 32 | ... 33 | 34 | @property 35 | def sorting_strategy(self) -> AbsSortingStrategy: # type:ignore # noqa 36 | ... 37 | 38 | @property 39 | def default_sort_val(self) -> Dict[str, Literal["asc", "dsc"]]: # type:ignore # noqa 40 | ... 41 | 42 | @property 43 | def sorter_mechanic(self) -> str: # type:ignore # noqa 44 | ... 45 | 46 | @property 47 | def filter_mechanic(self) -> str: # type:ignore # noqa 48 | ... 49 | 50 | @property 51 | def extra_context(self) -> dict: # type: ignore # noqa 52 | ... 53 | 54 | @property 55 | def feature_params_adapter(self) -> AbstractListingFeatureParamsAdapter: # noqa 56 | ... 57 | 58 | @property 59 | def default_page_size(self) -> int: # noqa 60 | ... 61 | 62 | @property 63 | def max_page_size(self) -> int: # noqa 64 | ... 65 | 66 | @property 67 | def fire_count_qry(self) -> bool: # noqa 68 | pass 69 | -------------------------------------------------------------------------------- /fastapi_listing/loader.py: -------------------------------------------------------------------------------- 1 | __all__ = [ 2 | "register" 3 | ] 4 | 5 | 6 | import inspect 7 | 8 | from fastapi_listing.service import ListingService 9 | from fastapi_listing.factory import filter_factory, _generic_factory, strategy_factory, interceptor_factory 10 | from fastapi_listing.errors import MissingExpectedAttribute 11 | from fastapi_listing.dao import GenericDao 12 | 13 | 14 | def _validate_strategy_attributes(cls: ListingService): 15 | if not cls.default_srt_on: 16 | raise MissingExpectedAttribute("default_srt_on attribute value is not provided! Did you forget to do it?") 17 | if not strategy_factory.aware_of(cls.query_strategy): 18 | missing_strategy = cls.query_strategy 19 | elif not strategy_factory.aware_of(cls.sorting_strategy): 20 | missing_strategy = cls.sorting_strategy 21 | elif not strategy_factory.aware_of(cls.paginate_strategy): 22 | missing_strategy = cls.paginate_strategy 23 | else: 24 | missing_strategy = "" 25 | if missing_strategy: 26 | raise ValueError( 27 | f"{cls.__name__} attribute '{missing_strategy}' is not registered/loaded! Did you forget to do it?") 28 | return True 29 | 30 | 31 | def _validate_dao_attribute(cls: ListingService): 32 | if cls.default_dao == GenericDao: 33 | raise ValueError("Avoid using GenericDao Directly! Extend it!") 34 | 35 | if not inspect.isclass(cls.default_dao): 36 | raise ValueError("Invalid Dao reference Injected!") 37 | 38 | if not issubclass(cls.default_dao, GenericDao): # type: ignore 39 | raise TypeError("Invalid Dao Type! Should Be type of GenericDao") 40 | return True 41 | 42 | 43 | def _validate_miscellaneous_attrs(cls: ListingService): 44 | if not cls.feature_params_adapter: 45 | raise ValueError("Missing Adapter class for client param conversion!") 46 | temp = {type(cls.query_strategy), type(cls.sorting_strategy), type(cls.paginate_strategy), type(cls.sort_mecha), 47 | type(cls.filter_mecha), type(cls.default_srt_ord)} 48 | if {str} != temp: 49 | raise TypeError(f"{cls.__name__} has invalid type attribute! Please refer to docs!") 50 | if cls.default_page_size is None or type(cls.default_page_size) is not int: 51 | raise ValueError(f"{cls.__name__} has invalid default_page_size attribute!") 52 | 53 | if not cls.default_srt_ord: 54 | raise ValueError("Missing default_srt_ord attribute!") 55 | missing_interceptor = "" 56 | if not interceptor_factory.aware_of(cls.filter_mecha): 57 | missing_interceptor = cls.filter_mecha 58 | elif not interceptor_factory.aware_of(cls.sort_mecha): 59 | missing_interceptor = cls.sort_mecha 60 | if missing_interceptor: 61 | raise ValueError(f"{cls.__name__} attribute '{missing_interceptor}' " 62 | f"is not registered/loaded! Did you forget to do it?") 63 | if cls.default_page_size > cls.max_page_size: 64 | raise ValueError(f"default_page_size {cls.default_page_size!r} can not be greater than max_page_size" 65 | f" {cls.max_page_size!r}") 66 | 67 | 68 | def register(): 69 | def _decorator(cls: ListingService): 70 | _validate_miscellaneous_attrs(cls) 71 | _validate_strategy_attributes(cls) 72 | _validate_dao_attribute(cls) 73 | filter_mapper = cls.filter_mapper 74 | sorter_mapper = cls.sort_mapper 75 | filter_factory.register_filter_mapper(filter_mapper) 76 | for key, val in sorter_mapper.items(): 77 | if type(val) is tuple: 78 | _generic_factory.register_sort_mapper(val) 79 | return cls 80 | return _decorator 81 | -------------------------------------------------------------------------------- /fastapi_listing/middlewares.py: -------------------------------------------------------------------------------- 1 | __all__ = ['DaoSessionBinderMiddleware'] 2 | 3 | from contextvars import ContextVar, Token 4 | from typing import Optional, Callable 5 | from contextlib import contextmanager 6 | from warnings import warn 7 | 8 | from sqlalchemy.orm import Session 9 | from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint 10 | from starlette.requests import Request 11 | from starlette.responses import Response 12 | from starlette.types import ASGIApp 13 | 14 | from fastapi_listing.errors import MissingSessionError 15 | 16 | _session: ContextVar[Optional[Session]] = ContextVar("_session", default=None) 17 | 18 | _replica_session: ContextVar[Optional[Session]] = ContextVar("_replica_session", default=None) 19 | 20 | 21 | class DaoSessionBinderMiddleware(BaseHTTPMiddleware): 22 | def __init__( 23 | self, 24 | app: ASGIApp, *, 25 | master: Callable[[], Session] = None, 26 | replica: Callable[[], Session] = None, 27 | session_close_implicit: bool = False, 28 | suppress_warnings: bool = False, 29 | ): 30 | super().__init__(app) 31 | self.close_implicit = session_close_implicit 32 | self.master = master 33 | self.read = replica 34 | self.suppress_warnings = suppress_warnings 35 | 36 | async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response: 37 | # TODO: lazy sessions 38 | with manager(self.read, self.master, self.close_implicit, self.suppress_warnings): 39 | response = await call_next(request) 40 | return response 41 | 42 | 43 | class SessionProviderMeta(type): 44 | 45 | @property 46 | def read_session(cls) -> Session: 47 | read_replica_session = _replica_session.get() 48 | if read_replica_session is None: 49 | raise MissingSessionError 50 | return read_replica_session 51 | 52 | @property 53 | def session(cls) -> Session: 54 | master_session = _session.get() 55 | if master_session is None: 56 | raise MissingSessionError 57 | return master_session 58 | 59 | 60 | class SessionProvider(metaclass=SessionProviderMeta): 61 | pass 62 | 63 | 64 | @contextmanager 65 | def manager(read_ses: Callable[[], Session], master: Callable[[], Session], implicit_close: bool, 66 | suppress_warnings: bool): 67 | global _session 68 | global _replica_session 69 | if read_ses and master: 70 | token_read_session: Token = _replica_session.set(read_ses()) 71 | token_master_session: Token = _session.set(master()) 72 | elif master: 73 | sess = master() 74 | token_read_session: Token = _replica_session.set(sess) 75 | token_master_session: Token = _session.set(sess) 76 | if not suppress_warnings: 77 | warn("Only 'master' session is provided. dao will use master for read executes." 78 | "To suppress this warning add 'suppress_warnings=True'") 79 | elif read_ses: 80 | token_read_session: Token = _replica_session.set(read_ses()) 81 | else: 82 | raise ValueError("Error with DaoSessionBinderMiddleware! " 83 | "Please provide either args read or master session callables.") 84 | try: 85 | yield 86 | finally: 87 | if implicit_close: 88 | if _session.get(): 89 | _session.get().close() 90 | _session.reset(token_master_session) # type: ignore # noqa: F823 91 | if _replica_session.get(): 92 | _replica_session.get().close() 93 | _replica_session.reset(token_read_session) # type: ignore # noqa: F823 94 | -------------------------------------------------------------------------------- /fastapi_listing/paginator/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = ["ListingPage", "BaseListingPage", "PaginationStrategy", "ListingPageWithoutCount"] 2 | 3 | from fastapi_listing.paginator.page_builder import PaginationStrategy 4 | from fastapi_listing.paginator.default_page_format import ListingPage, BaseListingPage, ListingPageWithoutCount 5 | -------------------------------------------------------------------------------- /fastapi_listing/paginator/default_page_format.py: -------------------------------------------------------------------------------- 1 | from typing import Sequence, TypeVar, Generic 2 | import warnings 3 | from fastapi_listing.utils import HAS_PYDANTIC, IS_PYDANTIC_V2 4 | 5 | 6 | if HAS_PYDANTIC: 7 | if IS_PYDANTIC_V2: 8 | from pydantic import BaseModel as GenericModel, Field 9 | else: 10 | from pydantic.generics import GenericModel 11 | from pydantic import Field 12 | 13 | else: 14 | GenericModel = object 15 | warnings.warn("You are using fastapi-listing without pydantic package. Avoid using any pydantic dependent features.") 16 | 17 | T = TypeVar('T') 18 | 19 | 20 | class BaseListingPage(GenericModel, Generic[T]): 21 | """Extend this to customise Page format""" 22 | data: Sequence[T] 23 | 24 | 25 | class ListingPage(BaseListingPage[T], Generic[T]): 26 | hasNext: bool = Field(alias="hasNext") 27 | currentPageSize: int = Field(alias="currentPageSize") 28 | currentPageNumber: int = Field(alias="currentPageNumber") 29 | totalCount: int = Field(alias="totalCount") 30 | 31 | 32 | class ListingPageWithoutCount(BaseListingPage[T], Generic[T]): 33 | hasNext: bool = Field(alias="hasNext") 34 | currentPageSize: int = Field(alias="currentPageSize") 35 | currentPageNumber: int = Field(alias="currentPageNumber") 36 | -------------------------------------------------------------------------------- /fastapi_listing/paginator/page_builder.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Union 2 | 3 | from fastapi_listing.abstracts import AbsPaginatingStrategy 4 | from fastapi_listing.ctyping import SqlAlchemyQuery, FastapiRequest, Page, BasePage, PageWithoutCount 5 | from fastapi_listing.errors import ListingPaginatorError 6 | 7 | 8 | class PaginationStrategy(AbsPaginatingStrategy): 9 | """ 10 | Loosely coupled paginator module. 11 | Type of page should always be maintained for core service to make sense of a page for any post page 12 | spawn processing. 13 | Clients are advised to use any adapter in their listing service for refactoring page response as per 14 | their needs or having a different response structure. 15 | """ 16 | 17 | def __init__(self, request: Optional[FastapiRequest] = None, fire_count_qry: bool = True): 18 | self.request = request 19 | self.page_num = 0 20 | self.page_size = 0 21 | self.count = 0 22 | self.extra_context = None 23 | self.fire_count_qry = fire_count_qry 24 | 25 | def get_count(self, query: SqlAlchemyQuery) -> int: 26 | """ 27 | Override this method to return a dummy count or generate count in more optimized manner. 28 | User may want this to avoid slow count(*) query or double query or have page setup that doesn't require 29 | total_counts. 30 | Overall special checks needs to be setup. 31 | like returning a massive dummy count and then depending upon empty main_data avoiding trip to next page etc. 32 | """ 33 | return query.count() 34 | 35 | def is_next_page_exists(self) -> bool: 36 | """expression results in bool val if count query allowed else None""" 37 | if self.fire_count_qry: 38 | return True if self.count > (self.page_num * self.page_size) else False 39 | else: 40 | return self.count > self.page_size 41 | 42 | def validate_params(self, page_num, page_size): 43 | """validate given 1 based page number and pagesize""" 44 | try: 45 | if isinstance(page_num, float) and not page_num.is_integer(): 46 | raise ValueError 47 | if isinstance(page_size, float) and not page_size.is_integer(): 48 | raise ValueError 49 | page_num, page_size = int(page_num), int(page_size) 50 | except (TypeError, ValueError): 51 | raise ListingPaginatorError("pagination params are not valid integers") 52 | if page_num < 1 or page_size < 1: 53 | raise ListingPaginatorError("page param(s) is less than 1") 54 | 55 | def set_page_num(self, page_num: int): 56 | self.page_num = page_num 57 | 58 | def set_page_size(self, page_size: int): 59 | self.page_size = page_size 60 | 61 | def set_extra_context(self, extra_context): 62 | self.extra_context = extra_context 63 | 64 | def set_count(self, count: int): 65 | self.count = count 66 | 67 | def paginate(self, query: SqlAlchemyQuery, pagination_params: dict, extra_context: dict) -> BasePage: 68 | """Return paginated response""" 69 | page_num = pagination_params.get('page') 70 | page_size = pagination_params.get('pageSize') 71 | try: 72 | self.validate_params(page_num, page_size) 73 | except ListingPaginatorError: 74 | page_num = 1 75 | page_size = 10 76 | self.set_page_num(page_num) 77 | self.set_page_size(page_size) 78 | self.set_extra_context(extra_context) 79 | return self.page(query) 80 | 81 | def page(self, query: SqlAlchemyQuery) -> BasePage: 82 | """Return a Page or BasePage for given 1-based page number.""" 83 | if self.fire_count_qry: 84 | self.set_count(self.get_count(query)) 85 | has_next: bool = self.is_next_page_exists() 86 | query = self._slice_query(query) 87 | return self._get_page(has_next, query) 88 | else: 89 | query = self._slice_query(query) 90 | return self._get_page_without_count(query) 91 | 92 | def _get_page(self, *args, **kwargs) -> Page: 93 | """ 94 | Return a single page of items 95 | this hook can be used by subclasses if you want to 96 | replace Page datastructure with your custom structure extending BasePage. 97 | """ 98 | has_next, query = args 99 | total_count = self.count 100 | return Page( 101 | hasNext=has_next, 102 | totalCount=total_count, 103 | currentPageSize=self.page_size, 104 | currentPageNumber=self.page_num, 105 | data=query.all()) 106 | 107 | def _get_page_without_count(self, *args, **kwargs) -> PageWithoutCount: 108 | """Get Page without total count for avoiding slow count query""" 109 | query = args[0] 110 | data = query.all() 111 | self.set_count(len(data)) 112 | has_next = self.is_next_page_exists() 113 | return PageWithoutCount( 114 | hasNext=has_next, 115 | currentPageSize=self.page_size, 116 | currentPageNumber=self.page_num, 117 | data=data[: self.count - 1] if has_next else data 118 | ) 119 | 120 | 121 | def _slice_query(self, query: SqlAlchemyQuery) -> SqlAlchemyQuery: 122 | """ 123 | Return sliced query. 124 | 125 | This hook can be used by subclasses to slice query in a different manner 126 | like using an id range with the help of shared extra_contex 127 | or using a more advanced offset technique. 128 | """ 129 | if self.fire_count_qry: 130 | return query.limit(self.page_size).offset(max(self.page_num - 1, 0) * self.page_size) 131 | else: 132 | # get +1 than page size to see if next page exists 133 | # a hotfix to avoid total count to determine next page existence 134 | return query.limit(self.page_size + 1).offset(max(self.page_num - 1, 0) * self.page_size) 135 | -------------------------------------------------------------------------------- /fastapi_listing/py.typed: -------------------------------------------------------------------------------- 1 | # marker file for PEP 561 -------------------------------------------------------------------------------- /fastapi_listing/service/__init__.py: -------------------------------------------------------------------------------- 1 | from fastapi_listing.service._core_listing_service import FastapiListing 2 | from fastapi_listing.service.listing_main import ListingService 3 | -------------------------------------------------------------------------------- /fastapi_listing/service/_core_listing_service.py: -------------------------------------------------------------------------------- 1 | from typing import Type, Optional, Dict, List 2 | from warnings import warn 3 | 4 | from fastapi import Request 5 | from sqlalchemy.orm import Query 6 | 7 | from fastapi_listing.dao.generic_dao import GenericDao 8 | from fastapi_listing.errors import FastapiListingRequestSemanticApiException, \ 9 | NotRegisteredApiException, FastAPIListingWarning 10 | from fastapi_listing.factory import interceptor_factory, strategy_factory 11 | from fastapi_listing.interface.listing_meta_info import ListingMetaInfo 12 | from fastapi_listing.ctyping import BasePage 13 | from fastapi_listing.utils import HAS_PYDANTIC, BaseModel 14 | from fastapi_listing.utils import IS_PYDANTIC_V2, Options 15 | from fastapi_listing.service.config import ListingMetaData 16 | from fastapi_listing.abstracts import ListingBase 17 | 18 | 19 | class FastapiListing(ListingBase): 20 | """ 21 | Core class that is responsible for running the show. 22 | Magic happens here! 23 | This class acts as Orchestrator for all the decoupled modules and strategies and runs them in strategic 24 | manner. 25 | 26 | All the dependency lives outside. This design pulls only what it needs to pull or logic requested from 27 | client (Anyone who is requesting for listing response using public method get_response). 28 | 29 | As no user oriented core logic lives inside of this class there should never be any reason to import and 30 | extend this class outside. 31 | """ 32 | 33 | def __init__(self, request: Optional[Request] = None, dao: GenericDao = None, 34 | *, pydantic_serializer: Optional[Type[BaseModel]] = None, 35 | fields_to_fetch: Optional[List[str]] = None, 36 | custom_fields: Optional[bool] = False) -> None: 37 | self.request = request 38 | self.dao = dao 39 | if HAS_PYDANTIC and pydantic_serializer: 40 | if IS_PYDANTIC_V2: 41 | self.fields_to_fetch = list(pydantic_serializer.model_fields.keys()) 42 | else: 43 | self.fields_to_fetch = list(pydantic_serializer.__fields__.keys()) 44 | elif fields_to_fetch: 45 | self.fields_to_fetch = fields_to_fetch 46 | else: 47 | self.fields_to_fetch = [] 48 | self.custom_fields = custom_fields 49 | 50 | @staticmethod 51 | def _replace_aliases(mapper: Dict[str, str], req_params: List[Dict[str, str]]) -> List[Dict[str, str]]: 52 | for param in req_params: 53 | if type(mapper[param["field"]]) is tuple: 54 | param["field"] = mapper[param["field"]][0] 55 | elif type(mapper[param["field"]]) is str: 56 | param["field"] = mapper[param["field"]] 57 | else: 58 | raise ValueError("invalid field mapper") 59 | return req_params 60 | 61 | def _apply_sorting(self, query: Query, listing_meta_info: ListingMetaInfo) -> Query: 62 | try: 63 | sorting_params: List[dict] = listing_meta_info.feature_params_adapter.get("sort") 64 | except Exception: 65 | raise FastapiListingRequestSemanticApiException(status_code=422, detail="Crap! Sorting went wrong.") 66 | temp = set(item.get("field") for item in sorting_params) - set( 67 | listing_meta_info.sorting_column_mapper.keys()) 68 | if temp: 69 | raise NotRegisteredApiException( 70 | status_code=409, detail=f"Sorter(s) not registered with listing: {temp}, Did you forget to do it?") 71 | if sorting_params: 72 | sorting_params = self._replace_aliases(listing_meta_info.sorting_column_mapper, sorting_params) 73 | else: 74 | sorting_params = [listing_meta_info.default_sort_val] 75 | 76 | def launch_mechanics(qry): 77 | mecha: str = listing_meta_info.sorter_mechanic 78 | mecha_obj = interceptor_factory.create(mecha) 79 | qry = mecha_obj.apply(query=qry, strategy=listing_meta_info.sorting_strategy, 80 | sorting_params=sorting_params, extra_context=listing_meta_info.extra_context) 81 | return qry 82 | 83 | query = launch_mechanics(query) 84 | return query 85 | 86 | def _apply_filters(self, query: Query, listing_meta_info: ListingMetaInfo) -> Query: 87 | try: 88 | fltrs: List[dict] = listing_meta_info.feature_params_adapter.get("filter") 89 | except Exception: 90 | raise FastapiListingRequestSemanticApiException(status_code=422, 91 | detail="Crap! Filtering went wrong.") 92 | temp = set(item.get("field") for item in fltrs) - set(listing_meta_info.filter_column_mapper.keys()) 93 | if temp: 94 | raise NotRegisteredApiException( 95 | status_code=409, detail=f"Filter(s) not registered with listing: {temp}, Did you forget to do it?") 96 | 97 | fltrs = self._replace_aliases(listing_meta_info.filter_column_mapper, fltrs) 98 | 99 | def launch_mechanics(qry): 100 | mecha_obj = interceptor_factory.create(listing_meta_info.filter_mechanic) 101 | qry = mecha_obj.apply(query=qry, filter_params=fltrs, dao=self.dao, 102 | request=self.request, extra_context=listing_meta_info.extra_context) 103 | return qry 104 | 105 | query = launch_mechanics(query) 106 | return query 107 | 108 | def _paginate(self, query: Query, listing_meta_info: ListingMetaInfo) -> BasePage: 109 | try: 110 | raw_params: List[dict] = listing_meta_info.feature_params_adapter.get("pagination") 111 | page_params = raw_params if raw_params else {"page": 1, "pageSize": listing_meta_info.default_page_size} 112 | paginator_params: dict = page_params 113 | except Exception: 114 | raise FastapiListingRequestSemanticApiException(status_code=422, 115 | detail="Crap! Pagination went wrong.") 116 | if page_params["pageSize"] > listing_meta_info.max_page_size and \ 117 | not listing_meta_info.extra_context.get(Options.ignore_limiter.value): 118 | warn(f"""requested page size is greater than 'max_page_size', overwriting requested page size 119 | from {page_params['pageSize']} to {listing_meta_info.max_page_size}""", 120 | FastAPIListingWarning, 121 | stacklevel=3, 122 | ) 123 | page_params["pageSize"] = listing_meta_info.max_page_size 124 | 125 | page = listing_meta_info.paginating_strategy.paginate(query, 126 | pagination_params=paginator_params, 127 | extra_context=listing_meta_info.extra_context) 128 | return page 129 | 130 | def _prepare_query(self, listing_meta_info: ListingMetaInfo) -> Query: 131 | base_query: Query = listing_meta_info.query_strategy.get_query(request=self.request, 132 | dao=self.dao, 133 | extra_context=listing_meta_info.extra_context) 134 | if base_query is None or not base_query: 135 | raise ValueError("query strategy returned nothing Query object is expected!") 136 | fltr_query: Query = self._apply_filters(base_query, 137 | listing_meta_info) 138 | if listing_meta_info.extra_context.get(Options.abort_sorting.value): 139 | return fltr_query 140 | 141 | srtd_query: Query = self._apply_sorting(fltr_query, listing_meta_info) 142 | return srtd_query 143 | 144 | @staticmethod 145 | def _set_vals_in_extra_context(extra_context: dict, **kwargs): 146 | extra_context.update(kwargs) 147 | 148 | def _build_from_meta_data(self, meta_data: ListingMetaData) -> ListingMetaInfo: 149 | 150 | class MetaInfo: 151 | 152 | def __init__(self, outer_instance): 153 | """ 154 | @rtype: ListingMetaInfo 155 | """ 156 | self.filter_column_mapper = meta_data["filter_mapper"] 157 | self.query_strategy = strategy_factory.create(meta_data["query_strategy"]) 158 | self.sorting_column_mapper = meta_data["sort_mapper"] 159 | self.default_sort_val = dict(type=meta_data["default_srt_ord"], 160 | field=meta_data["default_srt_on"]) 161 | self.sorting_strategy = strategy_factory.create( 162 | meta_data["sorting_strategy"], 163 | model=outer_instance.dao.model, 164 | request=outer_instance.request, 165 | ) 166 | self.sorter_mechanic = meta_data["sort_mecha"] 167 | self.filter_mechanic = meta_data["filter_mecha"] 168 | self.extra_context = meta_data["extra_context"] 169 | feature_param_class = meta_data["feature_params_adapter"] 170 | self.feature_params_adapter = feature_param_class(outer_instance.request, self.extra_context) 171 | self.default_page_size = meta_data["default_page_size"] 172 | self.max_page_size = meta_data["max_page_size"] 173 | self.fire_count_qry = meta_data["allow_count_query_by_paginator"] 174 | self.paginating_strategy = strategy_factory.create( 175 | meta_data["paginating_strategy"], request=outer_instance.request, fire_count_qry=self.fire_count_qry) 176 | 177 | return MetaInfo(self) # type: ignore 178 | 179 | def get_response(self, listing_meta_data: ListingMetaData) -> BasePage: 180 | self._set_vals_in_extra_context(listing_meta_data["extra_context"], 181 | field_list=self.fields_to_fetch, 182 | custom_fields=self.custom_fields 183 | ) 184 | listing_meta_info = self._build_from_meta_data(listing_meta_data) 185 | fnl_query: Query = self._prepare_query(listing_meta_info) 186 | response: BasePage = self._paginate(fnl_query, listing_meta_info) 187 | return response 188 | -------------------------------------------------------------------------------- /fastapi_listing/service/adapters.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, List 2 | 3 | try: 4 | from typing import Literal 5 | except ImportError: 6 | from typing_extensions import Literal 7 | 8 | from fastapi import Request 9 | 10 | from fastapi_listing import utils 11 | from fastapi_listing.abstracts import AbstractListingFeatureParamsAdapter 12 | 13 | __all__ = [ 14 | "CoreListingParamsAdapter" 15 | ] 16 | 17 | 18 | class CoreListingParamsAdapter(AbstractListingFeatureParamsAdapter): 19 | """Utilise this adapter class to make your remote client site: 20 | - filter, 21 | - sorter, 22 | - paginator. 23 | query params adapt to fastapi listing library. 24 | With this you can utilise same listing api to multiple remote client 25 | even if it's a front end server or other backend server. 26 | 27 | core service is always going to request one of the following fundamental key 28 | - sort 29 | - filter 30 | - pagination 31 | depending upon this return the appropriate transformed client param back to fastapi listing 32 | supported formats for 33 | filter: 34 | simple filter - [{"field":"", "value":{"search":""}}, ...] 35 | if you are using a range filter - 36 | [{"field":"", "value":{"start":"", "end": ""}}, ...] 37 | if you are using a list filter i.e. search on given items 38 | [{"field":"", "value":{"list":[""]}}, ...] 39 | 40 | sort: 41 | [{"field":<"key used in sort mapper>", "type":"asc or "dsc"}, ...] 42 | by default single sort allowed you can change it by extending sort interceptor 43 | 44 | pagination: 45 | {"pageSize": , "page": } 46 | 47 | 48 | """ 49 | def __init__(self, request: Optional[Request], extra_context): 50 | self.request = request 51 | self.extra_context = extra_context 52 | self.dependency = self.request.query_params if self.request else self.extra_context 53 | 54 | def get(self, key: Literal["sort", "filter", "pagination"]): 55 | """ 56 | @param key: Literal["sort", "filter", "pagination"] 57 | @return: List[Optional[dict]] for filter/sort and dict for paginator 58 | """ 59 | return utils.dictify_query_params(self.dependency.get(key)) 60 | -------------------------------------------------------------------------------- /fastapi_listing/service/config.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Type 2 | 3 | try: 4 | from typing import Literal, TypedDict 5 | except ImportError: 6 | from typing_extensions import Literal, TypedDict 7 | 8 | from fastapi_listing.service.adapters import CoreListingParamsAdapter 9 | 10 | 11 | class ListingMetaData(TypedDict): 12 | """A Typedict for configuring fastapi-listing behaviour""" 13 | 14 | filter_mapper: dict 15 | """ 16 | The filter_mapper is a collection of allowed filters on listing that will be used by consumers. Defaults to '{}' 17 | """ 18 | 19 | sort_mapper: dict 20 | """ 21 | The sort_mapper is a collection of fields allowed to be used for sort on listing that will be used by consumer. 22 | Defaults to '{}' 23 | """ 24 | 25 | default_srt_on: str 26 | """primary model field that will be used to sort the listing response by default. No Default value provided.""" 27 | 28 | default_srt_ord: Literal["asc", "dsc"] 29 | """The default order which will be used to return listing response. Defaults to 'dsc' """ 30 | 31 | paginating_strategy: str 32 | """ 33 | Reference of strategy class used to paginate listing response. Must be registered with strategy_factory. 34 | Defaults to 'default_paginator' 35 | """ 36 | 37 | query_strategy: str 38 | """ 39 | Reference of strategy class used to generate listing query object. Must be registered with strategy_factory. 40 | Defaults to 'default_query' 41 | """ 42 | 43 | sorting_strategy: str 44 | """ 45 | Reference of strategy class used to apply sorting on query object. Must be registered with strategy_factory. 46 | Defaults to 'default_sorter' 47 | """ 48 | 49 | sort_mecha: str 50 | """ 51 | Reference of interceptor class that applies sorting requested by client utilising sort_mapper. 52 | Must be registered with interceptor_factory. 53 | Defaults to 'indi_sorter_interceptor' 54 | """ 55 | 56 | filter_mecha: str 57 | """ 58 | Reference of interceptor class that applies filter requested by client utilising filter_mapper. 59 | Must be registered with interceptor factory. 60 | Defaults to 'iterative_filter_interceptor' 61 | """ 62 | 63 | default_page_size: int 64 | """The default number of items that a page should contain. Defaults to '10' """ 65 | 66 | max_page_size: int 67 | """ 68 | Maximum number of items that a page should contain. Ignore any upper page size limit than this. 69 | Defaults to '50' 70 | """ 71 | 72 | feature_params_adapter: Type[CoreListingParamsAdapter] 73 | """ 74 | Reference of the adapter class used to get listing feature(filter/sorter/paginator) parameters. 75 | Lets users make fastapi-listing adapt to their current code base. 76 | Defaults to 'CoreListingParamsAdapter' 77 | """ 78 | 79 | allow_count_query_by_paginator: bool 80 | """ 81 | Restrict/Allow fastapi-listing default paginator to extract total count. This lets you avoid slow 82 | count queries on big table to avoid performance hiccups. 83 | Defaults to 'True' 84 | """ 85 | 86 | extra_context: dict 87 | """ 88 | A common datastructure used to store any context data that a user may wanna pass from router. 89 | Like path params or query params or anything. 90 | Available throughout the entire fastapi-listing lifespan. 91 | User can access it in 92 | strategies 93 | interceptors 94 | filters 95 | or almost anywhere in their code where they are writing their listing API dependency using/extending fastapi-listing 96 | core features. 97 | Defaults to '{}' 98 | """ 99 | 100 | 101 | def MetaInfo( 102 | *, 103 | filter_mapper: Optional[dict] = None, 104 | sort_mapper: Optional[dict] = None, 105 | default_srt_ord: Literal["asc", "dsc"] = "dsc", 106 | default_srt_on: str, 107 | paginating_strategy: str = "default_paginator", 108 | query_strategy: str = "default_query", 109 | sorting_strategy: str = "default_sorter", 110 | sort_mecha: str = "indi_sorter_interceptor", 111 | filter_mecha: str = "iterative_filter_interceptor", 112 | default_page_size: int = 10, 113 | max_page_size: int = 50, 114 | feature_params_adapter=CoreListingParamsAdapter, 115 | allow_count_query_by_paginator: bool = True, 116 | **extra) -> ListingMetaData: 117 | """validate passed args""" 118 | if default_srt_ord not in ["asc", "dsc"]: 119 | raise ValueError(f"default_srt_ord is incorrect expected 'Literal['asc', 'dsc']' got {default_srt_ord!r}") 120 | if not filter_mapper: 121 | filter_mapper = dict() 122 | if not sort_mapper: 123 | sort_mapper = dict() 124 | extra_context = extra or dict() 125 | return ListingMetaData(filter_mapper=filter_mapper, 126 | sort_mapper=sort_mapper, 127 | default_srt_ord=default_srt_ord, 128 | default_srt_on=default_srt_on, 129 | paginating_strategy=paginating_strategy, 130 | query_strategy=query_strategy, 131 | sorting_strategy=sorting_strategy, 132 | sort_mecha=sort_mecha, 133 | filter_mecha=filter_mecha, 134 | default_page_size=default_page_size, 135 | max_page_size=max_page_size, 136 | feature_params_adapter=feature_params_adapter, 137 | allow_count_query_by_paginator=allow_count_query_by_paginator, 138 | extra_context=extra_context) 139 | -------------------------------------------------------------------------------- /fastapi_listing/service/listing_main.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | try: 4 | from typing import Literal 5 | except ImportError: 6 | from typing_extensions import Literal 7 | 8 | from fastapi import Request 9 | 10 | from fastapi_listing.abstracts import ListingServiceBase 11 | from fastapi_listing.dao.generic_dao import GenericDao 12 | from fastapi_listing.dao import dao_factory 13 | from fastapi_listing.service.adapters import CoreListingParamsAdapter 14 | from fastapi_listing.errors import MissingSessionError 15 | from fastapi_listing.service.config import ListingMetaData 16 | 17 | 18 | __all__ = [ 19 | "ListingService" 20 | ] 21 | 22 | 23 | class ListingService(ListingServiceBase): # noqa 24 | filter_mapper: dict = {} 25 | sort_mapper: dict = {} 26 | # here resource creation should be based on factory and not inline as we are separating creation from usage. 27 | # factory should deliver sorting resource 28 | # default_srt_on: str = "created_at" # to be taken by user at child class level 29 | default_srt_ord: Literal["asc", "dsc"] = "dsc" 30 | paginate_strategy: str = "default_paginator" 31 | query_strategy: str = "default_query" 32 | sorting_strategy: str = "default_sorter" 33 | sort_mecha: str = "indi_sorter_interceptor" 34 | filter_mecha: str = "iterative_filter_interceptor" 35 | default_page_size: int = 10 36 | max_page_size: int = 50 37 | default_dao: GenericDao = GenericDao 38 | feature_params_adapter = CoreListingParamsAdapter 39 | allow_count_query_by_paginator: bool = True 40 | 41 | # pydantic_serializer: Type[BaseModel] = None 42 | # allowed_pydantic_custom_fields: bool = False 43 | # it is possible to have more than one serializer for particular endpoint depending upon 44 | # user or a query/path param condition we could switch json schema so allowing this 45 | # flexibility for the user to be able to switch between schema Fastapilisting object 46 | # should get initialized at user level and not implicit. 47 | 48 | def __init__(self, request: Optional[Request] = None, 49 | *, 50 | read_db=None, 51 | write_db=None, 52 | **kwargs) -> None: 53 | self.request = request 54 | self.extra_context = kwargs 55 | try: 56 | dao = dao_factory.create(self.default_dao.name) 57 | except MissingSessionError: 58 | if not read_db: 59 | raise MissingSessionError 60 | dao = self.default_dao(read_db=read_db, write_db=write_db) 61 | self.dao = dao 62 | 63 | def get_listing(self): 64 | """ 65 | implement at child class level. 66 | 67 | FastapiListing(self.request, self.dao, pydantic_serializer=some_pydantic_model_class, 68 | custom_fields=True/False).get_response(self.MetaInfo(self)) 69 | custom_fields can be also called pydantic_custom_fields. 70 | Note: what is pydantic_custom_fields? 71 | These are the fields that gets generated at runtime from existing table/sqlalchemy model fields. 72 | for example: 73 | lets say you have a pydantic model class 74 | class abc(BaseModel): 75 | id: int 76 | code: str 77 | 78 | @root_validator 79 | def generate_code(cls, values): 80 | values["code"] = f'FANCY{values["id"]}CODE' 81 | here code gets generated at runtime, code is not a table field, code is a custom field that is 82 | deduced with the help of id. 83 | :return: page response for client to render 84 | """ 85 | raise NotImplementedError("method should be implemented in child class and not here!") 86 | 87 | def MetaInfo(self, self_copy): 88 | """to support older versions""" 89 | return ListingMetaData(filter_mapper=self.filter_mapper, # type: ignore 90 | sort_mapper=self.sort_mapper, 91 | default_srt_ord=self.default_srt_ord, 92 | default_srt_on=self.default_srt_on, 93 | paginating_strategy=self.paginate_strategy, 94 | query_strategy=self.query_strategy, 95 | sorting_strategy=self.sorting_strategy, 96 | sort_mecha=self.sort_mecha, 97 | filter_mecha=self.filter_mecha, 98 | default_page_size=self.default_page_size, 99 | max_page_size=self.max_page_size, 100 | feature_params_adapter=self.feature_params_adapter, 101 | allow_count_query_by_paginator=self.allow_count_query_by_paginator, 102 | extra_context=self.extra_context) 103 | -------------------------------------------------------------------------------- /fastapi_listing/sorter/__init__.py: -------------------------------------------------------------------------------- 1 | from fastapi_listing.sorter.page_sorter import SortingOrderStrategy 2 | -------------------------------------------------------------------------------- /fastapi_listing/sorter/page_sorter.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | from fastapi_listing.abstracts import AbsSortingStrategy 3 | from fastapi_listing.ctyping import SqlAlchemyModel, FastapiRequest, SqlAlchemyQuery, AnySqlAlchemyColumn 4 | from fastapi_listing.factory import _generic_factory 5 | 6 | 7 | class SortingOrderStrategy(AbsSortingStrategy): 8 | 9 | def __init__(self, model: SqlAlchemyModel = None, request: FastapiRequest = None): 10 | self.model = model 11 | self.request = request 12 | 13 | @staticmethod 14 | def sort_asc_util(query: SqlAlchemyQuery, inst_field: AnySqlAlchemyColumn) -> SqlAlchemyQuery: 15 | query = query.order_by(inst_field.asc()) 16 | return query 17 | 18 | @staticmethod 19 | def sort_dsc_util(query: SqlAlchemyQuery, inst_field: AnySqlAlchemyColumn) -> SqlAlchemyQuery: 20 | query = query.order_by(inst_field.desc()) 21 | return query 22 | 23 | def sort(self, *, query: SqlAlchemyQuery = None, value: Dict[str, str] = None, 24 | extra_context: dict = None) -> SqlAlchemyQuery: 25 | assert value["type"] in ["asc", "dsc"], "invalid sorting style!" 26 | inst_field: AnySqlAlchemyColumn = self.validate_srt_field(self.model, value["field"]) 27 | if value["type"] == "asc": 28 | query = self.sort_asc_util(query, inst_field) 29 | else: 30 | query = self.sort_dsc_util(query, inst_field) 31 | return query 32 | 33 | def validate_srt_field(self, model: SqlAlchemyModel, sort_field: str): 34 | field = sort_field.split(".")[-1] 35 | if sort_field in _generic_factory.object_creation_collector: 36 | inst_field = _generic_factory.create(sort_field, field) 37 | else: 38 | try: 39 | inst_field = getattr(model, field) 40 | except AttributeError: 41 | inst_field = None 42 | if inst_field is None: 43 | raise ValueError( 44 | f"Provided sort field {field!r} is not an attribute of {model.__name__}") # todo improve this by custom exception 45 | return inst_field 46 | -------------------------------------------------------------------------------- /fastapi_listing/strategies/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | __all__ = ['QueryStrategy', 'PaginationStrategy', 'SortingOrderStrategy'] 3 | 4 | from fastapi_listing.strategies.query_strategy import QueryStrategy 5 | from fastapi_listing.paginator import PaginationStrategy 6 | from fastapi_listing.sorter import SortingOrderStrategy 7 | 8 | 9 | class ModuleInterface: 10 | """ 11 | Represents a strategy interface. A strategy should have a constant NAME 12 | This module must be registered by strategy factory 13 | strategy_factory.register(NAME, StrategyClass) 14 | 15 | Once we are done with this we can directly import the module 16 | and inject it with our listing via module.NAME 17 | """ 18 | NAME: str = "abc_strategy" 19 | -------------------------------------------------------------------------------- /fastapi_listing/strategies/query_strategy.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from fastapi_listing.abstracts import AbsQueryStrategy 4 | from fastapi_listing.dao import GenericDao 5 | from fastapi import Query, Request 6 | 7 | 8 | class QueryStrategy(AbsQueryStrategy): 9 | """Default query strategy class. Generates a simple query with requested fields from same model.""" 10 | 11 | def get_inst_attr_to_read(self, custom_fields: bool, field_list: list, dao: GenericDao): 12 | inst_fields = [] 13 | 14 | if custom_fields: 15 | # ("BYPASS CUSTOM PYDANTIC FIELDS ALLOWED.") 16 | # when serializer contains fields that get filled via validators or at runtime 17 | # or fields that get generated from model fields. 18 | for field in field_list: 19 | try: 20 | inst_fields.append(getattr(dao.model, field)) 21 | except AttributeError: 22 | pass 23 | else: 24 | inst_fields = [getattr(dao.model, field) for field in field_list] 25 | return inst_fields 26 | 27 | def get_query(self, *, request: Optional[Request] = None, dao: GenericDao = None, 28 | extra_context: dict = None) -> Query: 29 | inst_fields = self.get_inst_attr_to_read(extra_context.get("custom_fields"), extra_context.get("field_list"), 30 | dao) 31 | query = dao.get_default_read(inst_fields) 32 | return query 33 | -------------------------------------------------------------------------------- /fastapi_listing/utils.py: -------------------------------------------------------------------------------- 1 | __all__ = ['dictify_query_params'] 2 | 3 | import json 4 | from urllib.parse import unquote 5 | from typing import Union, List, Optional, Type 6 | from enum import Enum 7 | 8 | 9 | def dictify_query_params(query_param_string: str) -> Union[dict, List[dict]]: 10 | return json.loads(unquote(query_param_string or "") or "[]") 11 | 12 | 13 | try: 14 | from pydantic import BaseModel, VERSION 15 | IS_PYDANTIC_V2 = VERSION.startswith("2.") 16 | HAS_PYDANTIC = True 17 | except ImportError: 18 | HAS_PYDANTIC = False 19 | BaseModel: Optional[Type] = None 20 | VERSION = "" 21 | IS_PYDANTIC_V2 = None 22 | 23 | 24 | class Options(Enum): 25 | ignore_limiter = "ignore_limiter" 26 | abort_sorting = "abort_sorting" -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | filterwarnings = 3 | error 4 | ignore::UserWarning 5 | ignore:function ham\(\) is deprecated:DeprecationWarning -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import setuptools 4 | 5 | 6 | def get_version(): 7 | package_init = os.path.join( 8 | os.path.abspath(os.path.dirname(__file__)), "fastapi_listing", "__init__.py" 9 | ) 10 | with open(package_init) as f: 11 | for line in f: 12 | if line.startswith("__version__ ="): 13 | return line.split("=")[1].strip().strip("\"'") 14 | 15 | 16 | def get_long_description(): 17 | with open("README.md", "r") as fh: 18 | return fh.read() 19 | 20 | 21 | setuptools.setup( 22 | name="fastapi-listing", 23 | version=get_version(), 24 | author="Danish Hasan", 25 | author_email="dh813030@gmail.com", 26 | description="Advaned Data Listing Library for FastAPI", 27 | long_description=get_long_description(), 28 | long_description_content_type="text/markdown", 29 | url="https://github.com/danielhasan1/fastapi-listing", 30 | packages=setuptools.find_packages(exclude=["tests.*"]), 31 | package_data={"fastapi_listing": ["py.typed"]}, 32 | classifiers=[ 33 | "Development Status :: 5 - Production/Stable", 34 | "Environment :: Web Environment", 35 | "Intended Audience :: Developers", 36 | "License :: OSI Approved :: MIT License", 37 | "Operating System :: OS Independent", 38 | "Programming Language :: Python", 39 | "Programming Language :: Python :: 3", 40 | "Programming Language :: Python :: 3.7", 41 | "Programming Language :: Python :: 3.8", 42 | "Programming Language :: Python :: 3.9", 43 | "Programming Language :: Python :: 3.10", 44 | "Programming Language :: Python :: 3.11", 45 | ], 46 | python_requires=">=3.7", 47 | keywords=["starlette", "fastapi", "pydantic", "sqlalchemy"], 48 | extras_require={ 49 | "test": [ 50 | "requests", 51 | "pytest>=6.2.4", 52 | "mypy>=0.971", 53 | "pytest-env>=0.6.2", 54 | "flake8>=3.9.2", 55 | "isort>=5.10.1", 56 | "pydantic>=1.10.7", 57 | "starlette>=0.21.0", 58 | "sqlalchemy>=2.0.7", 59 | "starlite>=1.38.0", 60 | "httpx>=0.23.0", 61 | "pytest-mock>=3.6.1", 62 | "fastapi>=0.92.0", 63 | "mypy>=0.971", 64 | "pytest-mypy>=0.9.1", 65 | "mysqlclient", 66 | "pytest-cov==4.1.0" 67 | ], 68 | }, 69 | ) 70 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielhasan1/fastapi-listing/1dd46275adc8f129c0660be1a40b865548f8fd0e/tests/__init__.py -------------------------------------------------------------------------------- /tests/dao_setup.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import List, Dict, Union 4 | 5 | from sqlalchemy import CHAR, Column, Date, Enum, ForeignKey, Integer, String, Table 6 | from sqlalchemy.orm import relationship 7 | from sqlalchemy.orm import declarative_base 8 | 9 | from fastapi_listing.ctyping import SqlAlchemyModel 10 | from fastapi_listing.dao import dao_factory, GenericDao 11 | 12 | # ------------------------------------------MODEL LAYER---------------------------------------------------------------- 13 | Base = declarative_base() 14 | metadata = Base.metadata 15 | 16 | t_current_dept_emp = Table( 17 | 'current_dept_emp', metadata, 18 | Column('emp_no', Integer), 19 | Column('dept_no', CHAR(4)), 20 | Column('from_date', Date), 21 | Column('to_date', Date) 22 | ) 23 | 24 | 25 | class Department(Base): 26 | __tablename__ = 'departments' 27 | 28 | dept_no = Column(CHAR(4), primary_key=True) 29 | dept_name = Column(String(40), nullable=False, unique=True) 30 | 31 | 32 | t_dept_emp_latest_date = Table( 33 | 'dept_emp_latest_date', metadata, 34 | Column('emp_no', Integer), 35 | Column('from_date', Date), 36 | Column('to_date', Date) 37 | ) 38 | 39 | 40 | class Employee(Base): 41 | __tablename__ = 'employees' 42 | 43 | emp_no = Column(Integer, primary_key=True) 44 | birth_date = Column(Date, nullable=False) 45 | first_name = Column(String(14), nullable=False) 46 | last_name = Column(String(16), nullable=False) 47 | gender = Column(Enum('M', 'F'), nullable=False) 48 | hire_date = Column(Date, nullable=False) 49 | 50 | 51 | class DeptEmp(Base): 52 | __tablename__ = 'dept_emp' 53 | 54 | emp_no = Column(ForeignKey('employees.emp_no', ondelete='CASCADE'), primary_key=True, nullable=False) 55 | dept_no = Column(ForeignKey('departments.dept_no', ondelete='CASCADE'), primary_key=True, nullable=False, 56 | index=True) 57 | from_date = Column(Date, nullable=False) 58 | to_date = Column(Date, nullable=False) 59 | 60 | department = relationship('Department') 61 | employee = relationship('Employee') 62 | 63 | 64 | class DeptManager(Base): 65 | __tablename__ = 'dept_manager' 66 | 67 | emp_no = Column(ForeignKey('employees.emp_no', ondelete='CASCADE'), primary_key=True, nullable=False) 68 | dept_no = Column(ForeignKey('departments.dept_no', ondelete='CASCADE'), primary_key=True, nullable=False, 69 | index=True) 70 | from_date = Column(Date, nullable=False) 71 | to_date = Column(Date, nullable=False) 72 | 73 | department = relationship('Department') 74 | employee = relationship('Employee') 75 | 76 | 77 | class Salary(Base): 78 | __tablename__ = 'salaries' 79 | 80 | emp_no = Column(ForeignKey('employees.emp_no', ondelete='CASCADE'), primary_key=True, nullable=False) 81 | salary = Column(Integer, nullable=False) 82 | from_date = Column(Date, primary_key=True, nullable=False) 83 | to_date = Column(Date, nullable=False) 84 | 85 | employee = relationship('Employee') 86 | 87 | 88 | class Title(Base): 89 | __tablename__ = 'titles' 90 | 91 | emp_no = Column(ForeignKey('employees.emp_no', ondelete='CASCADE'), primary_key=True, nullable=False) 92 | title = Column(String(50), primary_key=True, nullable=False) 93 | from_date = Column(Date, primary_key=True, nullable=False) 94 | to_date = Column(Date) 95 | 96 | employee = relationship('Employee') 97 | 98 | 99 | # --------------------------------------------DAO LAYER----------------------------------------------------------------- 100 | 101 | class ClassicDao(GenericDao): # noqa 102 | 103 | def create(self, values: Dict[str, Union[str, int]]) -> SqlAlchemyModel: 104 | pass 105 | 106 | def update(self, identifier: Dict[str, Union[str, int, list]], values: dict) -> bool: 107 | pass 108 | 109 | def read(self, identifier: Dict[str, Union[str, int, list]], 110 | fields: Union[list, str] = "__all__") -> SqlAlchemyModel: 111 | pass 112 | 113 | def delete(self, ids: List[int]) -> bool: 114 | pass 115 | 116 | 117 | class TitleDao(ClassicDao): 118 | name = "title" 119 | model = Title 120 | 121 | def get_emp_title_by_id(self, emp_id: int) -> str: 122 | return self._read_db.query(self.model.title).filter(self.model.emp_no == emp_id).first().title 123 | 124 | def get_emp_title_by_id_from_master(self, emp_id: int) -> str: 125 | return self._write_db.query(self.model.title).filter(self.model.emp_no == emp_id).first().title 126 | 127 | 128 | class SalaryDao(ClassicDao): 129 | name = "salary" 130 | model = Salary 131 | 132 | 133 | class DeptManagerDao(ClassicDao): 134 | name = "deptmngr" 135 | model = DeptManager 136 | 137 | 138 | class EmployeeDao(ClassicDao): 139 | name = "employee" 140 | model = Employee 141 | 142 | def get_emp_ids_contain_full_name(self, full_name: str) -> list[int]: 143 | from sqlalchemy import func 144 | objs = self._read_db.query(Employee.emp_no).filter(func.concat(Employee.first_name, ' ', Employee.last_name 145 | ).contains(full_name)).all() 146 | return [obj.emp_no for obj in objs] 147 | 148 | def get_employees_with_designations(self): 149 | query = self._read_db.query(Employee.emp_no, Employee.first_name, Employee.last_name, Employee.gender, 150 | Title.title).join(Title, Employee.emp_no == Title.emp_no) 151 | return query 152 | 153 | 154 | class DeptEmpDao(ClassicDao): 155 | name = "deptemp" 156 | model = DeptEmp 157 | 158 | def get_emp_dept_mapping_base_query(self): 159 | query = self._read_db.query(DeptEmp.from_date, DeptEmp.to_date, Department.dept_name, Employee.first_name, 160 | Employee.last_name, Employee.hire_date 161 | ).join(Employee, DeptEmp.emp_no == Employee.emp_no 162 | ).join(Department, DeptEmp.dept_no == Department.dept_no) 163 | return query 164 | 165 | 166 | class DepartmentDao(ClassicDao): 167 | name = "dept" 168 | model = Department 169 | 170 | 171 | def register(): 172 | dao_factory.register_dao(TitleDao.name, TitleDao) 173 | dao_factory.register_dao(DepartmentDao.name, DepartmentDao) 174 | dao_factory.register_dao(DeptEmpDao.name, DeptEmpDao) 175 | dao_factory.register_dao(EmployeeDao.name, EmployeeDao) 176 | dao_factory.register_dao(DeptManagerDao.name, DeptManagerDao) 177 | dao_factory.register_dao(SalaryDao.name, SalaryDao) 178 | -------------------------------------------------------------------------------- /tests/fake_listing_setup.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | from fastapi_listing import FastapiListing, ListingService 3 | from fastapi_listing.strategies import QueryStrategy, PaginationStrategy, SortingOrderStrategy 4 | from fastapi_listing.factory import strategy_factory, filter_factory 5 | from fastapi_listing.filters import generic_filters 6 | from fastapi_listing.dao import GenericDao 7 | from pydantic import BaseModel, Field, root_validator 8 | from fastapi_listing import utils 9 | from sqlalchemy.orm import declarative_base 10 | from sqlalchemy import Column, String, Integer 11 | from sqlalchemy.dialects.mysql import BIT 12 | from typing import List, Dict 13 | from tests import dao_setup 14 | 15 | Base = declarative_base() 16 | 17 | # fake_db = [ 18 | # { 19 | # "id": 1, 20 | # "product_name": "Hyundai Verna", 21 | # "is_active": 1 22 | # }, 23 | # { 24 | # "id": 2, 25 | # "product_name": "Hyundai Centro", 26 | # "is_active": 0 27 | # }, 28 | # ] 29 | # 30 | # fake_resp_aliased = [ 31 | # { 32 | # "id": 1, 33 | # "pn": "Hyundai Verna", 34 | # "ia": 1 35 | # }, 36 | # { 37 | # "id": 2, 38 | # "pn": "Hyundai Centro", 39 | # "ia": 0 40 | # } 41 | # ] 42 | # 43 | # fake_custom_column_resp_aliased = [ 44 | # { 45 | # "id": 1, 46 | # "pn": "Hyundai Verna", 47 | # "ia": 1, 48 | # "cd": "1-HV" 49 | # }, 50 | # { 51 | # "id": 2, 52 | # "pn": "Hyundai Centro", 53 | # "ia": 0, 54 | # "cd": "2-HC" 55 | # } 56 | # ] 57 | # 58 | # fake_resp_aliased_size_1 = [ 59 | # { 60 | # "id": 1, 61 | # "pn": "Hyundai Verna", 62 | # "ia": 1 63 | # }, 64 | # ] 65 | # fake_db_query1 = "select * from fake_db" 66 | # 67 | # fake_db_response = { 68 | # "data": fake_resp_aliased, 69 | # "totalCount": len(fake_db), 70 | # "currentPageSize": 10, 71 | # "currentPageNumber": 0, 72 | # "hasNext": False 73 | # } 74 | # 75 | # fake_db_response_with_custom_column = { 76 | # "data": fake_custom_column_resp_aliased, 77 | # "totalCount": len(fake_db), 78 | # "currentPageSize": 10, 79 | # "currentPageNumber": 0, 80 | # "hasNext": False 81 | # } 82 | # 83 | # fake_db_response_page_size_1 = { 84 | # "data": fake_resp_aliased_size_1, 85 | # "totalCount": len(fake_resp_aliased_size_1), 86 | # "currentPageSize": 1, 87 | # "currentPageNumber": 0, 88 | # "hasNext": False 89 | # } 90 | # 91 | # 92 | # class ProductDetail(BaseModel): 93 | # id: int 94 | # product_name: str = Field(alias="pn") 95 | # is_active: bool = Field(alias="ia") 96 | # 97 | # class Config: 98 | # allow_population_by_field_name = True 99 | # 100 | # 101 | # class ProductPage(BaseModel): 102 | # data: List[ProductDetail] = [] 103 | # currentPageSize: int 104 | # currentPageNumber: int 105 | # hasNext: bool 106 | # totalCount: int 107 | # 108 | # class Config: 109 | # orm_mode = True 110 | # allow_population_by_field_name = True 111 | # 112 | # 113 | # class ProductDetailWithCustomFields(BaseModel): 114 | # id: int 115 | # product_name: str = Field(alias="pn") 116 | # is_active: bool = Field(alias="ia") 117 | # code: Optional[str] = Field(alias="cd") 118 | # 119 | # @root_validator 120 | # def generate_product_code(cls, values): 121 | # pnm = values["product_name"] 122 | # pnm = pnm.split() 123 | # cd = [] 124 | # for nm in pnm: 125 | # cd.append(nm[:1]) 126 | # values["code"] = f"{values['id']}-{''.join(cd)}" 127 | # return values 128 | # 129 | # class Config: 130 | # allow_population_by_field_name = True 131 | # 132 | # 133 | # class ProductPageWithCustomColumns(BaseModel): 134 | # data: List[ProductDetailWithCustomFields] = [] 135 | # currentPageSize: int 136 | # currentPageNumber: int 137 | # hasNext: bool 138 | # totalCount: int 139 | # 140 | # class Config: 141 | # orm_mode = True 142 | # allow_population_by_field_name = True 143 | # 144 | # 145 | # class Product(Base): 146 | # __tablename__ = 'fake_product' 147 | # id = Column(Integer, primary_key=True) 148 | # product_name = Column(String(500, 'utf8mb4_unicode_520_ci'), index=True) 149 | # is_active = Column(BIT(1), nullable=False, index=True) 150 | # 151 | # 152 | # class FakeProductDao(GenericDao): # noqa 153 | # model = Product 154 | # 155 | # 156 | # class FakeQueryStrategyV1(QueryStrategy): 157 | # 158 | # def get_query(self, *, request=None, dao=None, 159 | # extra_context: dict = None): 160 | # 161 | # assert dao.model == Product 162 | # assert isinstance(dao, FakeProductDao) == True 163 | # if not extra_context.get("custom_fields"): 164 | # assert extra_context.get("field_list") == list(ProductDetail.__fields__.keys()) 165 | # assert [Product.id, Product.product_name, Product.is_active] == \ 166 | # self.get_inst_attr_to_read(custom_fields=False, field_list=extra_context.get("field_list"), dao=dao) 167 | # else: 168 | # assert [Product.id, Product.product_name, Product.is_active] == \ 169 | # self.get_inst_attr_to_read(custom_fields=True, field_list=extra_context.get("field_list"), dao=dao) 170 | # return fake_db_query1 171 | # 172 | # 173 | # class FakeSortingStrategy(SortingOrderStrategy): 174 | # 175 | # def sort(self, *, query=None, value: Dict[str, str] = None, 176 | # extra_context: dict = None): 177 | # assert value["type"] in ["asc", "dsc"] 178 | # assert query == fake_db_query1 179 | # return query 180 | # 181 | # 182 | # class FakePaginationStrategyV1(PaginationStrategy): 183 | # 184 | # def paginate(self, query, request, extra_context: dict): 185 | # pagination_params = utils.dictify_query_params(request.query_params.get("pagination")) 186 | # assert pagination_params == self.default_pagination_params 187 | # assert query == fake_db_query1 188 | # return { 189 | # "data": fake_db, 190 | # "totalCount": len(fake_db), 191 | # "currentPageSize": pagination_params.get("pageSize"), 192 | # "currentPageNumber": pagination_params.get("page"), 193 | # "hasNext": False 194 | # } 195 | # 196 | # 197 | # strategy_factory.register_strategy("fake_query_strategy", FakeQueryStrategyV1) 198 | # strategy_factory.register_strategy("fake_sorting_strategy", FakeSortingStrategy) 199 | # strategy_factory.register_strategy("fake_paginator_strategy", FakePaginationStrategyV1) 200 | # 201 | # 202 | # class TestListingServiceDefaultFlow(ListingService): 203 | # default_srt_on = "id" 204 | # dao_kls = FakeProductDao 205 | # query_strategy = "fake_query_strategy" 206 | # sorting_strategy = "fake_sorting_strategy" 207 | # paginate_strategy = "fake_paginator_strategy" 208 | # 209 | # def get_listing(self): 210 | # return FastapiListing(self.request, self.dao, ProductDetail).get_response(self.MetaInfo(self)) 211 | # 212 | # 213 | # class TestListingServiceDefaultFlowWithCustomColumns(ListingService): 214 | # default_srt_on = "id" 215 | # dao_kls = FakeProductDao 216 | # query_strategy = "fake_query_strategy" 217 | # sorting_strategy = "fake_sorting_strategy" 218 | # paginate_strategy = "fake_paginator_strategy" 219 | # 220 | # def get_listing(self): 221 | # return FastapiListing(self.request, self.dao, ProductDetailWithCustomFields, custom_fields=True).get_response(self.MetaInfo(self)) 222 | 223 | 224 | class FakePaginationStrategyV2(PaginationStrategy): 225 | 226 | def paginate(self, query, request, extra_context: dict): 227 | ... 228 | # pagination_params = utils.dictify_query_params(request.query_params.get("pagination")) 229 | # assert pagination_params == {"pageSize": 1, "page": 0} 230 | # assert query == fake_db_query1 231 | # return { 232 | # "data": fake_db[:1], 233 | # "totalCount": len(fake_db[:1]), 234 | # "currentPageSize": pagination_params.get("pageSize"), 235 | # "currentPageNumber": pagination_params.get("page"), 236 | # "hasNext": False 237 | # } 238 | 239 | 240 | strategy_factory.register_strategy("fake_paginator_strategy_v2", FakePaginationStrategyV2) 241 | 242 | 243 | def spawn_valueerror_for_strategy_registry(strategy1, strategy2): 244 | strategy_factory.register_strategy(strategy1, FakePaginationStrategyV2) 245 | strategy_factory.register_strategy(strategy2, FakePaginationStrategyV2) 246 | 247 | 248 | def spawn_valueerror_for_filter_factory(field1, field2): 249 | filter_factory.register_filter(field1, generic_filters.EqualityFilter) 250 | filter_factory.register_filter(field2, generic_filters.InEqualityFilter) 251 | 252 | 253 | def invalid_type_factory_keys(factory, key): 254 | if factory == "filter": 255 | filter_factory.register_filter(key, generic_filters.EqualityFilter) 256 | elif factory == "strategy": 257 | strategy_factory.register_strategy(key, FakePaginationStrategyV2) 258 | -------------------------------------------------------------------------------- /tests/original_responses.py: -------------------------------------------------------------------------------- 1 | test_default_employee_listing = {'data': [ 2 | {'empid': 499999, 'bdt': '1958-05-01', 'fnm': 'Sachin', 'lnm': 'Tsukuda', 'gdr': 'M', 'hdt': '1997-11-30'}, 3 | {'empid': 499998, 'bdt': '1956-09-05', 'fnm': 'Patricia', 'lnm': 'Breugel', 'gdr': 'M', 'hdt': '1993-10-13'}, 4 | {'empid': 499997, 'bdt': '1961-08-03', 'fnm': 'Berhard', 'lnm': 'Lenart', 'gdr': 'M', 'hdt': '1986-04-21'}, 5 | {'empid': 499996, 'bdt': '1953-03-07', 'fnm': 'Zito', 'lnm': 'Baaz', 'gdr': 'M', 'hdt': '1990-09-27'}, 6 | {'empid': 499995, 'bdt': '1958-09-24', 'fnm': 'Dekang', 'lnm': 'Lichtner', 'gdr': 'F', 'hdt': '1993-01-12'}, 7 | {'empid': 499994, 'bdt': '1952-02-26', 'fnm': 'Navin', 'lnm': 'Argence', 'gdr': 'F', 'hdt': '1990-04-24'}, 8 | {'empid': 499993, 'bdt': '1963-06-04', 'fnm': 'DeForest', 'lnm': 'Mullainathan', 'gdr': 'M', 'hdt': '1997-04-07'}, 9 | {'empid': 499992, 'bdt': '1960-10-12', 'fnm': 'Siamak', 'lnm': 'Salverda', 'gdr': 'F', 'hdt': '1987-05-10'}, 10 | {'empid': 499991, 'bdt': '1962-02-26', 'fnm': 'Pohua', 'lnm': 'Sichman', 'gdr': 'F', 'hdt': '1989-01-12'}, 11 | {'empid': 499990, 'bdt': '1963-11-03', 'fnm': 'Khaled', 'lnm': 'Kohling', 'gdr': 'M', 'hdt': '1985-10-10'}], 12 | 'currentPageSize': 10, 'currentPageNumber': 1, 'hasNext': True, 13 | 'totalCount': 300024} 14 | 15 | test_default_employee_listing_gender_filter = {'data': [ 16 | {'empid': 499999, 'bdt': '1958-05-01', 'fnm': 'Sachin', 'lnm': 'Tsukuda', 'gdr': 'M', 'hdt': '1997-11-30'}], 17 | 'currentPageSize': 1, 'currentPageNumber': 1, 'hasNext': True, 18 | 'totalCount': 179973} 19 | 20 | test_default_employee_listing_birth_date_filter = {'data': [ 21 | {'empid': 499999, 'bdt': '1958-05-01', 'fnm': 'Sachin', 'lnm': 'Tsukuda', 'gdr': 'M', 'hdt': '1997-11-30'}], 22 | 'currentPageSize': 1, 'currentPageNumber': 1, 'hasNext': True, 23 | 'totalCount': 76} 24 | 25 | test_default_employee_listing_first_name_filter = {'data': [ 26 | {'empid': 499999, 'bdt': '1958-05-01', 'fnm': 'Sachin', 'lnm': 'Tsukuda', 'gdr': 'M', 'hdt': '1997-11-30'}], 27 | 'currentPageSize': 1, 'currentPageNumber': 1, 'hasNext': True, 28 | 'totalCount': 955} 29 | 30 | test_default_employee_listing_last_name_filter = {'data': [ 31 | {'empid': 499999, 'bdt': '1958-05-01', 'fnm': 'Sachin', 'lnm': 'Tsukuda', 'gdr': 'M', 'hdt': '1997-11-30'}], 32 | 'currentPageSize': 1, 'currentPageNumber': 1, 'hasNext': True, 33 | 'totalCount': 185} 34 | 35 | test_employee_listing_with_custom_field = {'data': [ 36 | {'empid': 499999, 'bdt': '1958-05-01', 'gdr': 'M', 'hdt': '1997-11-30', 37 | 'flnm': 'Sachin Tsukuda'}], 'currentPageSize': 1, 'currentPageNumber': 1, 'hasNext': True, 'totalCount': 185} 38 | 39 | test_default_employee_listing_asc_sorted = { 40 | 'data': [{'empid': 10001, 'bdt': '1953-09-02', 'fnm': 'Georgi', 'lnm': 'Facello', 'gdr': 'M', 'hdt': '1986-06-26'}, 41 | {'empid': 10002, 'bdt': '1964-06-02', 'fnm': 'Bezalel', 'lnm': 'Simmel', 'gdr': 'F', 'hdt': '1985-11-21'}, 42 | {'empid': 10003, 'bdt': '1959-12-03', 'fnm': 'Parto', 'lnm': 'Bamford', 'gdr': 'M', 'hdt': '1986-08-28'}, 43 | {'empid': 10004, 'bdt': '1954-05-01', 'fnm': 'Chirstian', 'lnm': 'Koblick', 'gdr': 'M', 44 | 'hdt': '1986-12-01'}, 45 | {'empid': 10005, 'bdt': '1955-01-21', 'fnm': 'Kyoichi', 'lnm': 'Maliniak', 'gdr': 'M', 46 | 'hdt': '1989-09-12'}, 47 | {'empid': 10006, 'bdt': '1953-04-20', 'fnm': 'Anneke', 'lnm': 'Preusig', 'gdr': 'F', 'hdt': '1989-06-02'}, 48 | {'empid': 10007, 'bdt': '1957-05-23', 'fnm': 'Tzvetan', 'lnm': 'Zielinski', 'gdr': 'F', 49 | 'hdt': '1989-02-10'}, 50 | {'empid': 10008, 'bdt': '1958-02-19', 'fnm': 'Saniya', 'lnm': 'Kalloufi', 'gdr': 'M', 'hdt': '1994-09-15'}, 51 | {'empid': 10009, 'bdt': '1952-04-19', 'fnm': 'Sumant', 'lnm': 'Peac', 'gdr': 'F', 'hdt': '1985-02-18'}, 52 | {'empid': 10010, 'bdt': '1963-06-01', 'fnm': 'Duangkaew', 'lnm': 'Piveteau', 'gdr': 'F', 53 | 'hdt': '1989-08-24'}], 'currentPageSize': 10, 'currentPageNumber': 1, 'hasNext': True, 54 | 'totalCount': 300024} 55 | 56 | test_dept_emp_mapping_page_resp = {'data': [ 57 | {'fnm': 'Sachin', 'lnm': 'Tsukuda', 'dpnm': 'Production', 'frmdt': '1997-11-30', 'tdt': '9999-01-01', 58 | 'hrdt': '1997-11-30'}], 'currentPageSize': 1, 'currentPageNumber': 1, 'hasNext': True, 'totalCount': 331603} 59 | 60 | test_dept_emp_mapping_full_name_filter_resp = {'data': [ 61 | {'fnm': 'Sumant', 'lnm': 'Prochazka', 'dpnm': 'Sales', 'frmdt': '1999-05-16', 'tdt': '2000-01-24', 62 | 'hrdt': '1986-10-05'}], 'currentPageSize': 1, 'currentPageNumber': 1, 'hasNext': True, 'totalCount': 14} 63 | 64 | test_custom_field_extractor = {'data': [ 65 | {'fnm': 'Sachin', 'lnm': 'Tsukuda', 'dpnm': 'Production', 'frmdt': '1997-11-30', 'tdt': '9999-01-01', 66 | 'hrdt': '1997-11-30'}], 'currentPageSize': 1, 'currentPageNumber': 1, 'hasNext': True, 'totalCount': 198850} 67 | 68 | test_string_contains_filter = {'data': [ 69 | {'fnm': 'Bangqing', 'lnm': 'Kleiser', 'dpnm': 'Sales', 'frmdt': '1988-07-25', 'tdt': '2001-10-09', 70 | 'hrdt': '1986-06-06'}], 'currentPageSize': 1, 'currentPageNumber': 1, 'hasNext': True, 'totalCount': 52245} 71 | 72 | test_string_like_filter = { 73 | 'data': [{'empid': 499999, 'fnm': 'Sachin', 'lnm': 'Tsukuda', 'gdr': 'M', 'desg': 'Engineer'}], 74 | 'currentPageSize': 1, 'currentPageNumber': 1, 'hasNext': True, 'totalCount': 115003} 75 | 76 | test_greater_than_filter = {'data': [ 77 | {'fnm': 'Bikash', 'lnm': 'Covnot', 'dpnm': 'Quality Management', 'frmdt': '2000-02-01', 'tdt': '2000-05-19', 78 | 'hrdt': '2000-01-28'}], 'currentPageSize': 1, 'currentPageNumber': 1, 'hasNext': False, 'totalCount': 1} 79 | 80 | test_less_than_filter = {'data': [ 81 | {'fnm': 'Aimee', 'lnm': 'Baja', 'dpnm': 'Marketing', 'frmdt': '1985-02-06', 'tdt': '1985-02-17', 82 | 'hrdt': '1985-02-06'}], 'currentPageSize': 1, 'currentPageNumber': 1, 'hasNext': False, 'totalCount': 1} 83 | 84 | test_greater_than_equal_to_filter = {'data': [ 85 | {'fnm': 'Sachin', 'lnm': 'Tsukuda', 'dpnm': 'Production', 'frmdt': '1997-11-30', 'tdt': '9999-01-01', 86 | 'hrdt': '1997-11-30'}], 'currentPageSize': 1, 'currentPageNumber': 1, 'hasNext': True, 'totalCount': 240124} 87 | 88 | test_less_than_equal_to_filter = {'data': [ 89 | {'fnm': 'Aimee', 'lnm': 'Baja', 'dpnm': 'Marketing', 'frmdt': '1985-02-06', 'tdt': '1985-02-17', 90 | 'hrdt': '1985-02-06'}], 'currentPageSize': 1, 'currentPageNumber': 1, 'hasNext': False, 'totalCount': 1} 91 | 92 | test_has_field_value_filter = {'data': [ 93 | {'fnm': 'Sachin', 'lnm': 'Tsukuda', 'dpnm': 'Production', 'frmdt': '1997-11-30', 'tdt': '9999-01-01', 94 | 'hrdt': '1997-11-30'}], 'currentPageSize': 1, 'currentPageNumber': 1, 'hasNext': True, 'totalCount': 331603} 95 | 96 | test_has_none_value_filter = {'data': [], 'currentPageSize': 1, 'currentPageNumber': 1, 'hasNext': False, 97 | 'totalCount': 0} 98 | 99 | test_inequality_filter = {'data': [ 100 | {'fnm': 'Dekang', 'lnm': 'Lichtner', 'dpnm': 'Production', 'frmdt': '1997-06-02', 'tdt': '9999-01-01', 101 | 'hrdt': '1993-01-12'}], 'currentPageSize': 1, 'currentPageNumber': 1, 'hasNext': True, 'totalCount': 132753} 102 | 103 | test_indata_filter = {'data': [ 104 | {'fnm': 'Dekang', 'lnm': 'Lichtner', 'dpnm': 'Production', 'frmdt': '1997-06-02', 'tdt': '9999-01-01', 105 | 'hrdt': '1993-01-12'}, 106 | {'fnm': 'Dekang', 'lnm': 'Bage', 'dpnm': 'Production', 'frmdt': '1987-03-24', 'tdt': '9999-01-01', 107 | 'hrdt': '1987-03-24'}], 'currentPageSize': 10, 'currentPageNumber': 1, 'hasNext': False, 'totalCount': 2} 108 | 109 | test_unix_between_filter = {'data': [], 'currentPageSize': 1, 'currentPageNumber': 1, 'hasNext': False, 'totalCount': 0} 110 | 111 | 112 | test_default_employee_listing_without_count = {'data': [ 113 | {'empid': 499999, 'bdt': '1958-05-01', 'fnm': 'Sachin', 'lnm': 'Tsukuda', 'gdr': 'M', 'hdt': '1997-11-30'}, 114 | {'empid': 499998, 'bdt': '1956-09-05', 'fnm': 'Patricia', 'lnm': 'Breugel', 'gdr': 'M', 'hdt': '1993-10-13'}, 115 | {'empid': 499997, 'bdt': '1961-08-03', 'fnm': 'Berhard', 'lnm': 'Lenart', 'gdr': 'M', 'hdt': '1986-04-21'}, 116 | {'empid': 499996, 'bdt': '1953-03-07', 'fnm': 'Zito', 'lnm': 'Baaz', 'gdr': 'M', 'hdt': '1990-09-27'}, 117 | {'empid': 499995, 'bdt': '1958-09-24', 'fnm': 'Dekang', 'lnm': 'Lichtner', 'gdr': 'F', 'hdt': '1993-01-12'}, 118 | {'empid': 499994, 'bdt': '1952-02-26', 'fnm': 'Navin', 'lnm': 'Argence', 'gdr': 'F', 'hdt': '1990-04-24'}, 119 | {'empid': 499993, 'bdt': '1963-06-04', 'fnm': 'DeForest', 'lnm': 'Mullainathan', 'gdr': 'M', 'hdt': '1997-04-07'}, 120 | {'empid': 499992, 'bdt': '1960-10-12', 'fnm': 'Siamak', 'lnm': 'Salverda', 'gdr': 'F', 'hdt': '1987-05-10'}, 121 | {'empid': 499991, 'bdt': '1962-02-26', 'fnm': 'Pohua', 'lnm': 'Sichman', 'gdr': 'F', 'hdt': '1989-01-12'}, 122 | {'empid': 499990, 'bdt': '1963-11-03', 'fnm': 'Khaled', 'lnm': 'Kohling', 'gdr': 'M', 'hdt': '1985-10-10'}], 123 | 'currentPageSize': 10, 'currentPageNumber': 1, 'hasNext': True} 124 | -------------------------------------------------------------------------------- /tests/pydantic_setup.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional, Union 2 | from datetime import date 3 | from pydantic import BaseModel, Field 4 | import enum 5 | 6 | from fastapi_listing.utils import IS_PYDANTIC_V2 7 | 8 | if IS_PYDANTIC_V2: 9 | from pydantic import computed_field 10 | from pydantic import ConfigDict, model_validator as validator 11 | else: 12 | from pydantic import validator 13 | 14 | 15 | class GenderEnum(enum.Enum): 16 | MALE = "M" 17 | FEMALE = "F" 18 | 19 | 20 | class EmployeeListDetails(BaseModel): 21 | emp_no: int = Field(alias="empid", title="Employee ID") 22 | birth_date: date = Field(alias="bdt", title="Birth Date") 23 | first_name: str = Field(alias="fnm", title="First Name") 24 | last_name: str = Field(alias="lnm", title="Last Name") 25 | gender: GenderEnum = Field(alias="gdr", title="Gender") 26 | hire_date: date = Field(alias="hdt", title="Hiring Date") 27 | if IS_PYDANTIC_V2: 28 | # model_config = { 29 | # "orm_mode": True, 30 | # "allow_population_by_field_name": True 31 | # } 32 | model_config = ConfigDict(from_attributes=True, populate_by_name=True) 33 | else: 34 | class Config: 35 | orm_mode = True 36 | allow_population_by_field_name = True 37 | 38 | 39 | class EmployeeListDetailWithCustomFields(EmployeeListDetails): 40 | first_name: str = Field(alias="fnm", title="First Name", exclude=True) 41 | last_name: str = Field(alias="lnm", title="Last Name", exclude=True) 42 | 43 | if IS_PYDANTIC_V2: 44 | @computed_field(alias="flnm") 45 | @property 46 | def full_name(self) -> str: 47 | # data["full_name"] = f"{data.pop('first_name')} {data.pop('last_name')}" 48 | full_name = ' '.join([self.first_name, self.last_name]) 49 | return full_name 50 | 51 | else: 52 | full_name: str = Field('', alias="flnm") 53 | 54 | @validator('full_name', pre=True, always=True) 55 | def generate_full_name(cls, v, values) -> str: 56 | return f"{values.pop('first_name')} {values.pop('last_name')}" 57 | 58 | 59 | class EmployeeListingResponse(BaseModel): 60 | data: List[EmployeeListDetails] = [] 61 | currentPageSize: int 62 | currentPageNumber: int 63 | hasNext: bool 64 | totalCount: int 65 | 66 | 67 | class EmployeeListingResponseWithCustomFields(EmployeeListingResponse): 68 | data: List[EmployeeListDetailWithCustomFields] = [] 69 | 70 | 71 | class DepartMentEmployeeListingDetails(BaseModel): 72 | first_name: str = Field(alias="fnm") 73 | last_name: str = Field(alias="lnm") 74 | dept_name: str = Field(alias="dpnm") 75 | from_date: date = Field(alias="frmdt") 76 | to_date: date = Field(alias="tdt") 77 | hire_date: date = Field(alias="hrdt") 78 | 79 | if IS_PYDANTIC_V2: 80 | # model_config = { 81 | # "orm_mode": True, 82 | # "allow_population_by_field_name": True 83 | # } 84 | model_config = ConfigDict(from_attributes=True, populate_by_name=True) 85 | else: 86 | class Config: 87 | orm_mode = True 88 | allow_population_by_field_name = True 89 | 90 | 91 | class DepartMentEmployeeListingResp(BaseModel): 92 | data: List[DepartMentEmployeeListingDetails] = [] 93 | currentPageSize: int 94 | currentPageNumber: int 95 | hasNext: bool 96 | totalCount: int 97 | 98 | 99 | class TitledEmployeeListingDetails(BaseModel): 100 | emp_no: int = Field(alias="empid", title="Employee ID") 101 | first_name: str = Field(alias="fnm", title="First Name") 102 | last_name: str = Field(alias="lnm", title="Last Name") 103 | gender: GenderEnum = Field(alias="gdr", title="Gender") 104 | title: str = Field(alias="desg", title="designation") 105 | 106 | if IS_PYDANTIC_V2: 107 | # model_config = { 108 | # "orm_mode": True, 109 | # "allow_population_by_field_name": True 110 | # } 111 | model_config = ConfigDict(from_attributes=True, populate_by_name=True) 112 | else: 113 | class Config: 114 | orm_mode = True 115 | allow_population_by_field_name = True 116 | 117 | 118 | class TitledEmployeeListingResp(BaseModel): 119 | data: List[TitledEmployeeListingDetails] = [] 120 | currentPageSize: int 121 | currentPageNumber: int 122 | hasNext: bool 123 | totalCount: int 124 | -------------------------------------------------------------------------------- /tests/service_setup.py: -------------------------------------------------------------------------------- 1 | from fastapi_listing import ListingService, FastapiListing 2 | from fastapi_listing.filters import generic_filters 3 | from fastapi_listing.factory import strategy_factory 4 | from fastapi_listing.strategies import QueryStrategy 5 | from fastapi_listing.ctyping import FastapiRequest, SqlAlchemyQuery 6 | from fastapi_listing.dao import dao_factory 7 | from fastapi_listing import loader 8 | 9 | from .pydantic_setup import EmployeeListDetails, EmployeeListDetailWithCustomFields 10 | from .dao_setup import EmployeeDao, DeptEmpDao 11 | from .dao_setup import Employee, Department, Title 12 | from fastapi_listing.utils import Options 13 | 14 | 15 | class DepartmentEmployeesQueryStrategy(QueryStrategy): 16 | 17 | def get_query(self, *, request: FastapiRequest = None, dao: DeptEmpDao = None, 18 | extra_context: dict = None) -> SqlAlchemyQuery: 19 | return dao.get_emp_dept_mapping_base_query() 20 | 21 | 22 | class EmployeesQueryStrategy(QueryStrategy): 23 | 24 | def get_query(self, *, request: FastapiRequest = None, dao: EmployeeDao = None, 25 | extra_context: dict = None) -> SqlAlchemyQuery: 26 | return dao.get_employees_with_designations() 27 | 28 | 29 | strategy_factory.register_strategy("dept_emp_mapping_query", DepartmentEmployeesQueryStrategy) 30 | strategy_factory.register_strategy("titled_employees_query", EmployeesQueryStrategy) 31 | 32 | 33 | @loader.register() 34 | class EmployeeListingService(ListingService): 35 | """ 36 | Testing vanilla flow, 37 | filters, 38 | default query generation, 39 | default flow, 40 | custom_fields, 41 | """ 42 | filter_mapper = { 43 | "gdr": ("Employee.gender", generic_filters.EqualityFilter), 44 | "bdt": ("Employee.birth_date", generic_filters.MySqlNativeDateFormateRangeFilter), 45 | "fnm": ("Employee.first_name", generic_filters.StringStartsWithFilter), 46 | "lnm": ("Employee.last_name", generic_filters.StringEndsWithFilter), 47 | "desg": ("Employee.Title.title", generic_filters.StringLikeFilter, lambda x: getattr(Title, x)) 48 | } 49 | 50 | sort_mapper = { 51 | "cd": "emp_no" 52 | } 53 | default_srt_on = "Employee.emp_no" 54 | default_dao = EmployeeDao 55 | 56 | def get_listing(self): 57 | resp = {} 58 | if self.extra_context.get("q") == "vanilla": 59 | self.extra_context[Options.abort_sorting.value] = self.request.query_params.get("ignore_sort") 60 | self.extra_context[Options.ignore_limiter.value] = self.request.query_params.get("ignore_limiter") 61 | resp = FastapiListing(self.request, self.dao, pydantic_serializer=EmployeeListDetails).get_response(self.MetaInfo(self)) 62 | elif self.extra_context.get("q") == "custom_fields": 63 | resp = FastapiListing(self.request, self.dao, pydantic_serializer=EmployeeListDetailWithCustomFields, 64 | custom_fields=True).get_response( 65 | self.MetaInfo(self)) 66 | elif self.extra_context.get("q") == "titled_employees": 67 | self.switch("query_strategy", "titled_employees_query") 68 | resp = FastapiListing(self.request, self.dao, pydantic_serializer=EmployeeListDetails).get_response(self.MetaInfo(self)) 69 | elif self.extra_context.get("q") == "incorrect_switch": 70 | self.switch("sdfasd", "sdfsdf") 71 | return resp 72 | 73 | 74 | class FullNameFilter(generic_filters.CommonFilterImpl): 75 | 76 | def filter(self, *, field: str = None, value: dict = None, query=None) -> SqlAlchemyQuery: 77 | # field is not necessary here as this is a custom filter and user have full control over its implementation 78 | if value: 79 | emp_dao: EmployeeDao = dao_factory.create("employee", replica=True) 80 | emp_ids: list[int] = emp_dao.get_emp_ids_contain_full_name(value.get("search")) 81 | query = query.filter(self.dao.model.emp_no.in_(emp_ids)) # noqa 82 | return query 83 | 84 | 85 | @loader.register() 86 | class DepartmentEmployeesListingService(ListingService): 87 | default_srt_on = "DeptEmp.emp_no" 88 | 89 | default_dao = DeptEmpDao 90 | query_strategy = "dept_emp_mapping_query" 91 | filter_mapper = { 92 | "flnm": ("DeptEmp.Employee.full_name", FullNameFilter), 93 | "gdr": ("DeptEmp.Employee.gender", generic_filters.EqualityFilter, lambda x: getattr(Employee, x)), 94 | "dptnm": ( 95 | "DeptEmp.Department.dept_name", generic_filters.StringContainsFilter, lambda x: getattr(Department, x)), 96 | "hrdt": ("DeptEmp.Employee.hire_date", generic_filters.DataGreaterThanFilter, lambda x: getattr(Employee, x)), 97 | "tdt": ("DeptEmp.to_date", generic_filters.DataLessThanFilter), 98 | "tdt1": ("DeptEmp1.to_date", generic_filters.DataGreaterThanEqualToFilter), 99 | "tdt2": ("DeptEmp2.to_date", generic_filters.DataLessThanEqualToFilter), 100 | "lnm": ("DeptEmp.Employee.last_name", generic_filters.HasFieldValue, lambda x: getattr(Employee, x)), 101 | "gdr2": ("DeptEmp.Employee2.gender", generic_filters.InEqualityFilter, lambda x: getattr(Employee, x)), 102 | "empno": ("DeptEmp.emp_no", generic_filters.InDataFilter), 103 | "frmdt": ("DeptEmp.from_date", generic_filters.BetweenUnixMilliSecDateFilter) 104 | } 105 | sort_mapper = { 106 | "empno": ("Employee.emp_no", lambda x: getattr(Employee, x)) 107 | } 108 | 109 | def get_listing(self): 110 | resp = FastapiListing(self.request, self.dao).get_response(self.MetaInfo(self)) 111 | return resp 112 | 113 | 114 | @loader.register() 115 | class ErrorProneListingV1(ListingService): 116 | default_srt_on = "DeptEmp.emp_no" 117 | 118 | default_dao = DeptEmpDao 119 | 120 | sort_mapper = { 121 | "hdt": "Employee.hire_date" 122 | } 123 | 124 | def get_listing(self): 125 | return FastapiListing(self.request, self.dao, fields_to_fetch=["emp_no"]).get_response(self.MetaInfo(self)) 126 | -------------------------------------------------------------------------------- /tests/test_fast_listing_compact_version.py: -------------------------------------------------------------------------------- 1 | import json 2 | from urllib.parse import quote 3 | from typing import Union 4 | 5 | from fastapi import Request, Query 6 | from fastapi import FastAPI 7 | import pytest 8 | from fastapi.testclient import TestClient 9 | from sqlalchemy import create_engine 10 | from sqlalchemy.orm import Session 11 | 12 | from fastapi_listing import FastapiListing, MetaInfo 13 | from fastapi_listing.paginator import ListingPage, ListingPageWithoutCount 14 | 15 | from tests.pydantic_setup import EmployeeListDetails 16 | from tests.dao_setup import EmployeeDao 17 | from tests import original_responses 18 | 19 | 20 | def get_db() -> Session: 21 | """ 22 | replicating sessionmaker for any fastapi app. 23 | anyone could be using a different way or opensource packages like fastapi-sqlalchemy 24 | it all comes down to a single result that is yielding a session. 25 | for the sake of simplicity and testing purpose I'm replicating this behaviour in this naive way. 26 | :return: Session 27 | """ 28 | engine = create_engine("mysql://root:123456@127.0.0.1:3307/employees", pool_pre_ping=1) 29 | sess = Session(bind=engine) 30 | return sess 31 | 32 | 33 | app = FastAPI() 34 | 35 | 36 | def get_url_quoted_string(d): 37 | return quote(json.dumps(d)) 38 | 39 | 40 | @app.get("/v1/employees", response_model=ListingPage[EmployeeListDetails]) 41 | def read_main(request: Request): 42 | dao = EmployeeDao(read_db=get_db()) 43 | resp = FastapiListing(dao=dao, 44 | pydantic_serializer=EmployeeListDetails).get_response(MetaInfo(default_srt_on="emp_no")) 45 | return resp 46 | 47 | 48 | @app.get("/v1/without-count/employees", response_model=ListingPageWithoutCount[EmployeeListDetails]) 49 | def read_main(request: Request): 50 | dao = EmployeeDao(read_db=get_db()) 51 | resp = FastapiListing(dao=dao, 52 | pydantic_serializer=EmployeeListDetails 53 | ).get_response(MetaInfo(default_srt_on="emp_no", allow_count_query_by_paginator=False)) 54 | return resp 55 | 56 | 57 | client = TestClient(app) 58 | 59 | 60 | def test_default_employee_listing(): 61 | response = client.get("/v1/employees") 62 | assert response.status_code == 200 63 | assert response.json() == original_responses.test_default_employee_listing 64 | 65 | 66 | def test_listing_without_total_count(): 67 | response = client.get("/v1/without-count/employees") 68 | assert response.status_code == 200 69 | assert response.json() == original_responses.test_default_employee_listing_without_count 70 | -------------------------------------------------------------------------------- /tests/test_main.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | import pytest 3 | from .fake_listing_setup import \ 4 | spawn_valueerror_for_strategy_registry, spawn_valueerror_for_filter_factory, invalid_type_factory_keys 5 | from .test_main_v2 import get_db 6 | import types 7 | 8 | app = FastAPI() 9 | 10 | 11 | def test_strategy_factory_unique_strategy_register(): 12 | with pytest.raises(ValueError) as e: 13 | spawn_valueerror_for_strategy_registry("same_strategy_key", "same_strategy_key") 14 | assert e.value.args[0] == "strategy name: same_strategy_key, already in use with FakePaginationStrategyV2!" 15 | 16 | 17 | def test_filter_factory_unique_strategy_register(): 18 | with pytest.raises(ValueError) as e: 19 | spawn_valueerror_for_filter_factory("same_field_name", "same_field_name") 20 | assert e.value.args[0] == "filter key 'same_field_name' already in use with 'EqualityFilter'" 21 | 22 | 23 | def test_factory_key_inputs(): 24 | with pytest.raises(ValueError) as e: 25 | invalid_type_factory_keys("filter", None) 26 | assert e.value.args[0] == "Invalid type key!" 27 | 28 | with pytest.raises(ValueError) as e: 29 | invalid_type_factory_keys("filter", "") 30 | assert e.value.args[0] == "Invalid type key!" 31 | 32 | with pytest.raises(ValueError) as e: 33 | invalid_type_factory_keys("strategy", None) 34 | assert e.value.args[0] == "Invalid type key!" 35 | 36 | with pytest.raises(ValueError) as e: 37 | invalid_type_factory_keys("strategy", "") 38 | assert e.value.args[0] == "Invalid type key!" 39 | 40 | 41 | def test_dao_factory_errors(): 42 | from fastapi_listing.dao import dao_factory 43 | from fastapi_listing.errors import MissingSessionError 44 | from .dao_setup import TitleDao 45 | with pytest.raises(ValueError) as e: 46 | dao_factory.register_dao(None, None) 47 | assert e.value.args[0] == "Invalid type key, expected str type got for None!" 48 | dao_factory.register_dao("titlepre", TitleDao) 49 | with pytest.raises(ValueError) as e: 50 | dao_factory.register_dao("titlepre", None) 51 | assert e.value.args[0] == "Dao name titlepre already in use with TitleDao!" 52 | with pytest.raises(ValueError) as e: 53 | dao_factory.create(None) 54 | assert e.value.args[0] is None 55 | 56 | with pytest.raises(MissingSessionError) as e: 57 | dao_factory.create("titlepre") 58 | assert e.value.args[0] == """ 59 | No session found! Either you are not currently in a request context, 60 | or you need to manually create a session context and pass the callable to middleware args 61 | e.g. 62 | callable -> get_db 63 | app.add_middleware(DaoSessionBinderMiddleware, master=get_db, replica=get_db) 64 | or 65 | pass a db session manually to your listing service 66 | e.g. 67 | AbcListingService(read_db=sqlalchemysession) 68 | """ 69 | 70 | 71 | def test_dao_factory_working(): 72 | 73 | from fastapi_listing.dao import dao_factory 74 | from fastapi_listing.middlewares import manager 75 | from .dao_setup import TitleDao 76 | with manager(read_ses=get_db, master=get_db, implicit_close=True, suppress_warnings=False): 77 | dao_factory.register_dao("title_2", TitleDao) 78 | both_dao: TitleDao = dao_factory.create("title_2", both=True) 79 | assert both_dao.get_emp_title_by_id(10001) == "Senior Engineer" 80 | del both_dao 81 | master_dao: TitleDao = dao_factory.create("title_2", master=True) 82 | assert master_dao.get_emp_title_by_id_from_master(10001) == "Senior Engineer" 83 | del master_dao 84 | 85 | with pytest.raises(ValueError) as e: 86 | dao_factory.create("title_2", replica=False) 87 | assert e.value.args[0] == "Invalid creation type for dao object allowed types 'replica', 'master', or 'both'" 88 | 89 | 90 | def test_generic_factory_for_semantics_sorter(): 91 | from fastapi_listing.factory import _generic_factory 92 | _generic_factory.register("test", lambda x: x) # testing callable example 93 | with pytest.raises(ValueError) as e: 94 | _generic_factory.register("test", lambda x: x) 95 | assert e.value.args[0] == "Factory can not have duplicate builder key test for instance " 96 | # checking sort mapper registerer and validator 97 | sort_mapper = {"test_key": "column_1"} 98 | with pytest.raises(ValueError) as e: 99 | _generic_factory.register_sort_mapper(sort_mapper) 100 | assert e.value.args[0] == "Invalid sorter mapper semantic! Expected tuple!" 101 | 102 | sort_val = ("test",) 103 | with pytest.raises(ValueError) as e: 104 | _generic_factory.register_sort_mapper(sort_val) 105 | assert e.value.args[0] == "Invalid sorter mapper semantic ('test',)! min tuple length should be 2." 106 | 107 | sort_val = (1, 1) 108 | with pytest.raises(ValueError) as e: 109 | _generic_factory.register_sort_mapper(sort_val) 110 | assert e.value.args[0] == "Invalid sorter mapper semantic (1, 1)! first tuple element should be field (str)" 111 | 112 | sort_val = ("test", "test") 113 | with pytest.raises(ValueError) as e: 114 | _generic_factory.register_sort_mapper(sort_val) 115 | assert e.value.args[0] == "positional arg error, expects a callable but received: test!" 116 | 117 | with pytest.raises(ValueError) as e: 118 | _generic_factory.create("abc") 119 | assert e.value.args[0] == "unknown character type 'abc'" 120 | 121 | 122 | def test_filter_factory_semantics(): 123 | from fastapi_listing import ListingService, loader 124 | from .dao_setup import TitleDao 125 | 126 | # when filter mapper is not having tuple val 127 | with pytest.raises(ValueError) as e: 128 | @loader.register() 129 | class ABCListing(ListingService): # noqa: F811,F841 130 | default_srt_on = "test" 131 | filter_mapper = { 132 | "test": "abc" 133 | } 134 | default_dao = TitleDao 135 | assert e.value.args[0] == "Invalid filter mapper semantic! Expected tuple!" 136 | 137 | # when filter mapper having incorrect length 138 | with pytest.raises(ValueError) as e: 139 | @loader.register() 140 | class ABCListing(ListingService): # noqa: F811,F841 141 | default_srt_on = "test" 142 | filter_mapper = { 143 | "test": ("abc",) 144 | } 145 | default_dao = TitleDao 146 | assert e.value.args[0] == "Invalid filter mapper semantic ('abc',)! min tuple length should be 2." 147 | 148 | # checking args 149 | with pytest.raises(ValueError) as e: 150 | @loader.register() 151 | class ABCListing(ListingService): # noqa: F811,F841 152 | default_srt_on = "test" 153 | filter_mapper = { 154 | "test": (1, "test") 155 | } 156 | default_dao = TitleDao 157 | 158 | assert e.value.args[0] == "Invalid filter mapper semantic (1, 'test')! first tuple element should be field (str)" 159 | 160 | # checking args 161 | with pytest.raises(ValueError) as e: 162 | @loader.register() 163 | class ABCListing(ListingService): # noqa: F811,F841 164 | default_srt_on = "test" 165 | filter_mapper = { 166 | "test": ("test", "test") 167 | } 168 | default_dao = TitleDao 169 | 170 | assert e.value.args[0] == "Invalid filter mapper semantic 'test'! Expects a class!" 171 | 172 | # checking args 173 | with pytest.raises(ValueError) as e: 174 | @loader.register() 175 | class ABCListing(ListingService): # noqa: F811,F841 176 | default_srt_on = "test" 177 | filter_mapper = { 178 | "test": ("test", object) 179 | } 180 | default_dao = TitleDao 181 | 182 | assert e.value.args[0] == "Invalid filter mapper semantic ! Expects a subclass of CommonFilterImpl" 183 | 184 | # checking args 185 | with pytest.raises(ValueError) as e: 186 | from fastapi_listing.filters import generic_filters 187 | 188 | @loader.register() 189 | class ABCListing(ListingService): # noqa: F811,F841 190 | default_srt_on = "test" 191 | filter_mapper = { 192 | "test": ("test", generic_filters.EqualityFilter, 1) 193 | } 194 | default_dao = TitleDao 195 | 196 | assert e.value.args[0] == "positional arg error, expects a callable but received: 1!" 197 | 198 | # check create error 199 | from fastapi_listing.factory import filter_factory 200 | 201 | with pytest.raises(ValueError) as e: 202 | filter_factory.create("test_unknown") 203 | assert e.value.args[0] == "filter factory couldn't find registered key 'test_unknown'" 204 | 205 | 206 | def test_interceptor_factory(): 207 | from fastapi_listing.factory import interceptor_factory 208 | from fastapi_listing.interceptors import IterativeFilterInterceptor 209 | 210 | with pytest.raises(ValueError) as e: 211 | interceptor_factory.register_interceptor(1, object) 212 | assert e.value.args[0] == "Invalid type key!" 213 | with pytest.raises(ValueError) as e: 214 | interceptor_factory.register_interceptor("test", IterativeFilterInterceptor) 215 | interceptor_factory.register_interceptor("test", IterativeFilterInterceptor) 216 | assert e.value.args[0] == "interceptor name 'test', already in use with 'IterativeFilterInterceptor'!" 217 | with pytest.raises(ValueError) as e: 218 | interceptor_factory.register_interceptor("test2", "abc") 219 | assert e.value.args[0] == "'abc' is not a valid class!" 220 | 221 | with pytest.raises(ValueError) as e: 222 | interceptor_factory.create("unknown_interceptor") 223 | assert e.value.args[0] == "interceptor factory couldn't find register key 'unknown_interceptor'" 224 | 225 | with pytest.raises(ValueError) as e: 226 | interceptor_factory.register_interceptor("testtest", object) 227 | assert e.value.args[0] == ("Invalid interceptor class, expects a subclass of either " 228 | "'AbstractSorterInterceptor' or 'AbstractFilterInterceptor'") 229 | 230 | 231 | # write test for strategy class 232 | --------------------------------------------------------------------------------