├── .github └── workflows │ ├── publish.yml │ └── test.yml ├── .gitignore ├── LICENSE ├── README.md ├── django_query_prefixer ├── __init__.py ├── backends │ ├── mysql │ │ └── base.py │ ├── postgis │ │ └── base.py │ ├── postgresql │ │ └── base.py │ └── sqlite3 │ │ └── base.py ├── cursor.py └── middlewares │ └── __init__.py ├── example ├── example │ ├── __init__.py │ ├── asgi.py │ ├── settings.py │ ├── urls.py │ └── wsgi.py └── manage.py ├── pyproject.toml └── tests ├── conftest.py ├── test_default_prefixer_fn.py ├── test_middlewares.py └── test_set_and_get_prefixes.py /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Python Package 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | deploy: 9 | runs-on: ubuntu-latest 10 | environment: Release 11 | steps: 12 | - uses: actions/checkout@v3 13 | 14 | - name: Install Hatch 15 | run: pip install --upgrade hatch 16 | 17 | - name: Build 18 | run: hatch build 19 | 20 | - name: Publish 21 | env: 22 | HATCH_INDEX_USER: ${{ vars.HATCH_INDEX_USER }} 23 | HATCH_INDEX_AUTH: ${{ secrets.HATCH_INDEX_AUTH }} 24 | run: hatch publish -y -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | concurrency: 10 | group: test-${{ github.head_ref }} 11 | cancel-in-progress: true 12 | 13 | env: 14 | PYTHONUNBUFFERED: "1" 15 | FORCE_COLOR: "1" 16 | 17 | jobs: 18 | run: 19 | name: Python ${{ matrix.python-version }} on ${{ startsWith(matrix.os, 'macos-') && 'macOS' || startsWith(matrix.os, 'windows-') && 'Windows' || 'Linux' }} 20 | runs-on: ${{ matrix.os }} 21 | strategy: 22 | fail-fast: false 23 | matrix: 24 | os: [ubuntu-latest, windows-latest, macos-latest] 25 | python-version: ['3.8', '3.9', '3.10', '3.11'] 26 | 27 | steps: 28 | - uses: actions/checkout@v3 29 | 30 | - name: Set up Python ${{ matrix.python-version }} 31 | uses: actions/setup-python@v4 32 | with: 33 | python-version: ${{ matrix.python-version }} 34 | 35 | - name: Install Hatch 36 | run: pip install --upgrade hatch 37 | 38 | - name: Run tests 39 | run: hatch run test:cov 40 | -------------------------------------------------------------------------------- /.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 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 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 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Buser 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 | # django-query-prefixer 2 | 3 | The Django Query Prefixer allows you to prepend annotations to every query 4 | executed within your Django project. This can be useful for monitoring. By 5 | adding custom prefixes to queries, you can gain insights into their origin, 6 | and can track their performance in a more organized manner. 7 | 8 | ## Installation 9 | 10 | You can install `django-query-prefixer` using pip: 11 | 12 | ```shell 13 | pip install django-query-prefixer 14 | ``` 15 | 16 | ## Usage 17 | 18 | 1. Change database engine in your `settings.py` to 19 | `django_query_prefixer.backends.`. 20 | 21 | ```python 22 | DATABASES = { 23 | "default": { 24 | "ENGINE": "django_query_prefixer.backends.postgresql", 25 | "HOST": "127.0.0.1", 26 | "NAME": "postgres", 27 | "PASSWORD": "postgres", 28 | "USER": "postgres", 29 | } 30 | } 31 | ``` 32 | 33 | 2. (Optional) Add `django_query_prefixer.middlewares.request_route` to the 34 | `MIDDLEWARE` list. This middleware adds `route` and `view_name` prefixes to 35 | SQL queries. 36 | 37 | ```python 38 | MIDDLEWARE = [ 39 | "django_query_prefixer.middlewares.request_route", 40 | # ... 41 | ] 42 | ``` 43 | 44 | 3. Now, whenever queries are executed in your Django project, the configured prefixes 45 | will be automatically added to those queries. For example, a query like this: 46 | 47 | ```python 48 | User.objects.filter(username='bob') 49 | ``` 50 | 51 | will be executed as: 52 | 53 | ```sql 54 | /* view_name=example route=/example */ SELECT ... FROM "auth_user" WHERE ("auth_user"."username" = 'bob') 55 | ``` 56 | 57 | You can add additional context to queries using the `sql_prefixes` _contextmanager_: 58 | 59 | ```python 60 | from django_query_prefixer import sql_prefixes 61 | 62 | with sql_prefixes(user_id=request.user.id, foo="bar"): 63 | User.objects.filter(username='bob') 64 | ```` 65 | 66 | ```sql 67 | /* user_id=X foo=bar view_name=example route=/example */ SELECT ... FROM "auth_user" WHERE ("auth_user"."username" = 'bob') 68 | ``` 69 | 70 | ## Contributing 71 | 72 | Contributions to `django-query-prefixer` are welcome! If you find a bug, want to 73 | add a new feature, or improve the documentation, please open an issue or submit 74 | a pull request in the GitHub repository. 75 | -------------------------------------------------------------------------------- /django_query_prefixer/__init__.py: -------------------------------------------------------------------------------- 1 | from contextlib import contextmanager 2 | from typing import Dict 3 | from contextvars import ContextVar 4 | 5 | __version__ = "0.1.3" 6 | 7 | 8 | @contextmanager 9 | def sql_prefixes(**kwargs): 10 | for key, value in kwargs.items(): 11 | if not isinstance(value, str): 12 | value = repr(value) 13 | set_prefix(key, value) 14 | 15 | yield 16 | 17 | for key in kwargs.keys(): 18 | remove_prefix(key) 19 | 20 | 21 | def set_prefix(key: str, value: str): 22 | prefixes = _prefixes.get({}) 23 | prefixes[key] = value 24 | _prefixes.set(prefixes) 25 | 26 | 27 | def get_prefixes() -> Dict[str, str]: 28 | return _prefixes.get({}) 29 | 30 | 31 | def remove_prefix(key: str): 32 | prefixes = _prefixes.get({}) 33 | prefixes.pop(key, None) 34 | _prefixes.set(prefixes) 35 | 36 | 37 | def default_prefixer_fn() -> str: 38 | prefixes = get_prefixes() 39 | return " ".join(f"{key}={value}" for key, value in prefixes.items()) 40 | 41 | 42 | _prefixes: ContextVar[Dict[str, str]] = ContextVar("sql_prefixes") 43 | -------------------------------------------------------------------------------- /django_query_prefixer/backends/mysql/base.py: -------------------------------------------------------------------------------- 1 | from django.db.backends.mysql.base import DatabaseWrapper as BaseDatabaseWrapper 2 | 3 | from ...cursor import CursorWrapper 4 | 5 | 6 | class DatabaseWrapper(BaseDatabaseWrapper): 7 | def create_cursor(self, name=None): 8 | cursor = super().create_cursor(name) 9 | return CursorWrapper(cursor) 10 | -------------------------------------------------------------------------------- /django_query_prefixer/backends/postgis/base.py: -------------------------------------------------------------------------------- 1 | from django.contrib.gis.db.backends.postgis.base import DatabaseWrapper as BaseDatabaseWrapper 2 | 3 | from ...cursor import CursorWrapper 4 | 5 | 6 | class DatabaseWrapper(BaseDatabaseWrapper): 7 | def create_cursor(self, name=None): 8 | cursor = super().create_cursor(name) 9 | return CursorWrapper(cursor) 10 | -------------------------------------------------------------------------------- /django_query_prefixer/backends/postgresql/base.py: -------------------------------------------------------------------------------- 1 | from django.db.backends.postgresql.base import DatabaseWrapper as BaseDatabaseWrapper 2 | 3 | from ...cursor import CursorWrapper 4 | 5 | 6 | class DatabaseWrapper(BaseDatabaseWrapper): 7 | def create_cursor(self, name=None): 8 | cursor = super().create_cursor(name) 9 | return CursorWrapper(cursor) 10 | -------------------------------------------------------------------------------- /django_query_prefixer/backends/sqlite3/base.py: -------------------------------------------------------------------------------- 1 | from django.db.backends.sqlite3.base import DatabaseWrapper as BaseDatabaseWrapper 2 | 3 | from ...cursor import CursorWrapper 4 | 5 | 6 | class DatabaseWrapper(BaseDatabaseWrapper): 7 | def create_cursor(self, name=None): 8 | cursor = super().create_cursor(name) 9 | return CursorWrapper(cursor) 10 | -------------------------------------------------------------------------------- /django_query_prefixer/cursor.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.utils.module_loading import import_string 3 | 4 | 5 | class CursorWrapper: 6 | def __init__(self, cursor): 7 | self.cursor = cursor 8 | 9 | def execute(self, sql, params=None): 10 | prefix = self.generate_prefix() 11 | sql = f"/* {prefix} */\n{sql}" 12 | return self.cursor.execute(sql, params) 13 | 14 | def executemany(self, sql, param_list): 15 | prefix = self.generate_prefix() 16 | sql = f"/* {prefix} */\n{sql}" 17 | return self.cursor.executemany(sql, param_list) 18 | 19 | def generate_prefix(self): 20 | prefixer_fn = getattr(settings, "DJANGO_QUERY_PREFIXER_FUNCTION", "django_query_prefixer.default_prefixer_fn") 21 | fn = import_string(prefixer_fn) 22 | return fn() 23 | 24 | def __getattr__(self, attr): 25 | if attr in self.__dict__: 26 | return self.__dict__[attr] 27 | return getattr(self.cursor, attr) 28 | 29 | def __iter__(self): 30 | return iter(self.cursor) 31 | 32 | def __enter__(self): 33 | return self 34 | 35 | def __exit__(self, exc_type, exc_value, traceback): 36 | self.close() 37 | -------------------------------------------------------------------------------- /django_query_prefixer/middlewares/__init__.py: -------------------------------------------------------------------------------- 1 | from django_query_prefixer import remove_prefix, set_prefix 2 | 3 | 4 | class RequestRouteMiddleware: 5 | def __init__(self, get_response): 6 | self.get_response = get_response 7 | 8 | def __call__(self, request): 9 | response = self.get_response(request) 10 | remove_prefix("view_name") 11 | remove_prefix("route") 12 | return response 13 | 14 | def process_view(self, request, view_func, view_args, view_kwargs): 15 | set_prefix(key="view_name", value=f"{view_func.__module__}.{view_func.__name__}") 16 | set_prefix(key="route", value=escape_comment_markers(request.resolver_match.route)) 17 | 18 | 19 | def request_route(get_response): 20 | return RequestRouteMiddleware(get_response) 21 | 22 | 23 | def escape_comment_markers(value): 24 | return value.replace("/*", r"\/\*").replace("*/", r"\*\/") 25 | -------------------------------------------------------------------------------- /example/example/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/buserbrasil/django-query-prefixer/273facc330480d7b1c4cab56ebd7ae768370bc77/example/example/__init__.py -------------------------------------------------------------------------------- /example/example/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for example project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.2/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example.settings") 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /example/example/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for example project. 3 | 4 | Generated by 'django-admin startproject' using Django 4.2.4. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.2/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/4.2/ref/settings/ 11 | """ 12 | 13 | from pathlib import Path 14 | 15 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 16 | BASE_DIR = Path(__file__).resolve().parent.parent 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = "django-insecure-wor1o+#(&3up9uglhkz748o-z9u05khnhj=x-e83)z2y+rkk8r" 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = True 27 | 28 | ALLOWED_HOSTS = [] 29 | 30 | 31 | # Application definition 32 | 33 | INSTALLED_APPS = [ 34 | "django.contrib.admin", 35 | "django.contrib.auth", 36 | "django.contrib.contenttypes", 37 | "django.contrib.sessions", 38 | "django.contrib.messages", 39 | "django.contrib.staticfiles", 40 | ] 41 | 42 | MIDDLEWARE = [ 43 | "django.middleware.security.SecurityMiddleware", 44 | "django.contrib.sessions.middleware.SessionMiddleware", 45 | "django.middleware.common.CommonMiddleware", 46 | "django.middleware.csrf.CsrfViewMiddleware", 47 | "django.contrib.auth.middleware.AuthenticationMiddleware", 48 | "django.contrib.messages.middleware.MessageMiddleware", 49 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 50 | "django_query_prefixer.middlewares.request_route", 51 | ] 52 | 53 | ROOT_URLCONF = "example.urls" 54 | 55 | TEMPLATES = [ 56 | { 57 | "BACKEND": "django.template.backends.django.DjangoTemplates", 58 | "DIRS": [], 59 | "APP_DIRS": True, 60 | "OPTIONS": { 61 | "context_processors": [ 62 | "django.template.context_processors.debug", 63 | "django.template.context_processors.request", 64 | "django.contrib.auth.context_processors.auth", 65 | "django.contrib.messages.context_processors.messages", 66 | ], 67 | }, 68 | }, 69 | ] 70 | 71 | WSGI_APPLICATION = "example.wsgi.application" 72 | 73 | 74 | # Database 75 | # https://docs.djangoproject.com/en/4.2/ref/settings/#databases 76 | 77 | DATABASES = { 78 | "default": { 79 | "ENGINE": "django_query_prefixer.backends.postgresql", 80 | "HOST": "127.0.0.1", 81 | "NAME": "postgres", 82 | "PASSWORD": "postgres", 83 | "USER": "postgres", 84 | } 85 | } 86 | 87 | DJANGO_QUERY_PREFIXER_FUNCTION = "django_query_prefixer.default_prefixer_fn" 88 | 89 | 90 | # Password validation 91 | # https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators 92 | 93 | AUTH_PASSWORD_VALIDATORS = [ 94 | { 95 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 96 | }, 97 | { 98 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 99 | }, 100 | { 101 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 102 | }, 103 | { 104 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 105 | }, 106 | ] 107 | 108 | 109 | # Internationalization 110 | # https://docs.djangoproject.com/en/4.2/topics/i18n/ 111 | 112 | LANGUAGE_CODE = "en-us" 113 | 114 | TIME_ZONE = "UTC" 115 | 116 | USE_I18N = True 117 | 118 | USE_TZ = True 119 | 120 | 121 | # Static files (CSS, JavaScript, Images) 122 | # https://docs.djangoproject.com/en/4.2/howto/static-files/ 123 | 124 | STATIC_URL = "static/" 125 | 126 | # Default primary key field type 127 | # https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field 128 | 129 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" 130 | 131 | LOGGING = { 132 | "version": 1, 133 | "handlers": { 134 | "console": { 135 | "level": "DEBUG", 136 | "filters": [], 137 | "class": "logging.StreamHandler", 138 | } 139 | }, 140 | "loggers": { 141 | "django.db.backends": { 142 | "level": "DEBUG", 143 | "handlers": ["console"], 144 | } 145 | }, 146 | } 147 | -------------------------------------------------------------------------------- /example/example/urls.py: -------------------------------------------------------------------------------- 1 | """ 2 | URL configuration for example project. 3 | 4 | The `urlpatterns` list routes URLs to views. For more information please see: 5 | https://docs.djangoproject.com/en/4.2/topics/http/urls/ 6 | Examples: 7 | Function views 8 | 1. Add an import: from my_app import views 9 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 10 | Class-based views 11 | 1. Add an import: from other_app.views import Home 12 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 13 | Including another URLconf 14 | 1. Import the include() function: from django.urls import include, path 15 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 16 | """ 17 | from django.contrib import admin 18 | from django.urls import path 19 | 20 | 21 | urlpatterns = [ 22 | path("admin/", admin.site.urls), 23 | ] 24 | -------------------------------------------------------------------------------- /example/example/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for example project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /example/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example.settings") 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == "__main__": 22 | main() 23 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "django-query-prefixer" 3 | dynamic = ["version"] 4 | authors = [ 5 | { name="Erle Carrara", email="carrara.erle@gmail.com" }, 6 | ] 7 | description = "Add helpful annotations to SQL queries." 8 | readme = "README.md" 9 | requires-python = ">=3.8" 10 | keywords = [] 11 | classifiers = [ 12 | "Programming Language :: Python :: 3", 13 | "License :: OSI Approved :: MIT License", 14 | "Operating System :: OS Independent", 15 | "Framework :: Django", 16 | "Framework :: Django :: 4" 17 | ] 18 | 19 | [project.urls] 20 | Source = "https://github.com/ecarrara/django-query-prefixer" 21 | Issues = "https://github.com/ecarrara/django-query-prefixer/issues" 22 | 23 | [project.optional-dependencies] 24 | 25 | [build-system] 26 | requires = ["hatchling"] 27 | build-backend = "hatchling.build" 28 | 29 | [tool.hatch.version] 30 | path = "django_query_prefixer/__init__.py" 31 | 32 | [tool.hatch.envs.test] 33 | dependencies = [ 34 | "pytest", 35 | "pytest-cov", 36 | "django>=4.2" 37 | ] 38 | 39 | [[tool.hatch.envs.test.matrix]] 40 | python = ["3.11", "3.10", "3.9", "3.8"] 41 | 42 | [tool.hatch.envs.test.scripts] 43 | cov = "pytest --cov=django_query_prefixer {args:tests}" 44 | 45 | [tool.hatch.envs.default] 46 | dependencies = [ 47 | "black>=23.3", 48 | "pyright>=1.1", 49 | "ruff>=0.0.274", 50 | "pytest", 51 | "sphinx", 52 | "django>=4.2" 53 | ] 54 | 55 | [tool.hatch.envs.default.scripts] 56 | typing = "pyright {args:django-query-prefixer tests}" 57 | style = [ 58 | "ruff {args:.}", 59 | "black --check --diff {args:.}" 60 | ] 61 | fmt = [ 62 | "black {args:.}", 63 | "ruff --fix {args:.}", 64 | "style" 65 | ] 66 | doc = [ 67 | "sphinx-build -b html docs _build/html" 68 | ] 69 | 70 | [tool.black] 71 | target-version = ["py38"] 72 | line-length = 120 73 | 74 | [tool.ruff] 75 | target-version = "py38" 76 | line-length = 120 77 | 78 | [tool.ruff.isort] 79 | known-first-party = ["django-query-prefixer"] 80 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django_query_prefixer import _prefixes 3 | 4 | 5 | @pytest.fixture(autouse=True) 6 | def reset_context(): 7 | yield 8 | _prefixes.set({}) 9 | -------------------------------------------------------------------------------- /tests/test_default_prefixer_fn.py: -------------------------------------------------------------------------------- 1 | from django_query_prefixer import default_prefixer_fn, set_prefix 2 | 3 | 4 | def test_default_prefixer_fn(): 5 | set_prefix("test1", "value1") 6 | set_prefix("test2", "value2") 7 | 8 | assert default_prefixer_fn() == "test1=value1 test2=value2" 9 | -------------------------------------------------------------------------------- /tests/test_middlewares.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | from django_query_prefixer.middlewares import request_route 4 | 5 | 6 | def test_request_route_middleware(): 7 | response = object() 8 | get_response = mock.MagicMock(return_value=response) 9 | middleware = request_route(get_response=get_response) 10 | 11 | request = mock.MagicMock() 12 | request.resolver_match.route = "/hello" 13 | 14 | def hello_world(): 15 | pass 16 | 17 | with mock.patch("django_query_prefixer.middlewares.set_prefix") as mock_set_prefix, mock.patch( 18 | "django_query_prefixer.middlewares.remove_prefix" 19 | ) as mock_remove_prefix: 20 | middleware.process_view(request, hello_world, [], {}) 21 | assert middleware(request) == response 22 | 23 | mock_set_prefix.assert_has_calls( 24 | [ 25 | mock.call(key="view_name", value=f"test_middlewares.hello_world"), 26 | mock.call(key="route", value=f"/hello"), 27 | ] 28 | ) 29 | mock_remove_prefix.assert_has_calls( 30 | [ 31 | mock.call("view_name"), 32 | mock.call("route"), 33 | ], 34 | ) 35 | -------------------------------------------------------------------------------- /tests/test_set_and_get_prefixes.py: -------------------------------------------------------------------------------- 1 | from django_query_prefixer import get_prefixes, set_prefix, sql_prefixes 2 | 3 | 4 | def test_sql_prefixes_context_manager(): 5 | with sql_prefixes(test="dummy", n=42): 6 | assert get_prefixes() == {"test": "dummy", "n": "42"} 7 | assert get_prefixes() == {} 8 | 9 | 10 | def test_set_prefixes(): 11 | set_prefix("key1", "value1") 12 | 13 | assert get_prefixes() == {"key1": "value1"} 14 | 15 | 16 | def test_set_prefixes_override_previous_value(): 17 | set_prefix("key1", "value1") 18 | set_prefix("key1", "value2") 19 | 20 | assert get_prefixes() == {"key1": "value2"} 21 | 22 | 23 | def test_set_prefixes_multiple(): 24 | set_prefix("key1", "value1") 25 | set_prefix("key2", "value2") 26 | 27 | assert get_prefixes() == { 28 | "key1": "value1", 29 | "key2": "value2", 30 | } 31 | --------------------------------------------------------------------------------