├── .gitignore ├── DEVELOPMENT.md ├── LICENSE ├── README.md ├── django_cf ├── __init__.py ├── d1_api │ ├── __init__.py │ ├── base.py │ ├── client.py │ ├── creation.py │ ├── database.py │ ├── features.py │ ├── introspection.py │ ├── operations.py │ └── schema.py └── d1_binding │ ├── __init__.py │ ├── base.py │ ├── client.py │ ├── creation.py │ ├── database.py │ ├── features.py │ ├── introspection.py │ ├── operations.py │ └── schema.py └── pyproject.toml /.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 | .idea/ 156 | -------------------------------------------------------------------------------- /DEVELOPMENT.md: -------------------------------------------------------------------------------- 1 | # Releasing a new version 2 | 3 | ```bash 4 | pip install build twine 5 | ``` 6 | 7 | ```bash 8 | python3 -m build 9 | python3 -m twine upload dist/* 10 | ``` 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Gabriel Massadas 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-cf 2 | django-cf is a package that integrates Django with Cloudflare products 3 | 4 | Integrations: 5 | - Cloudflare D1 6 | - Cloudflare Workers 7 | 8 | ## Installation 9 | 10 | ```bash 11 | pip install django-cf 12 | ``` 13 | 14 | ## Cloudflare D1 15 | 16 | Cloudflare D1 doesn't support transactions, meaning all execute queries are final and rollbacks are not available. 17 | 18 | A simple tutorial is [available here](https://massadas.com/posts/django-meets-cloudflare-d1/) for you to read. 19 | 20 | **The D1 backend is very limited, a lot of features don't work, Django Admin is also very limited**, but works fine for 21 | simple apps, as you can make full use of Django ORM inside you views. 22 | 23 | ### D1 Binding 24 | 25 | You can now deploy Django into a Cloudflare Python Worker, and in that environment, D1 is available as a Binding for 26 | faster queries. 27 | 28 | ```python 29 | DATABASES = { 30 | 'default': { 31 | 'ENGINE': 'django_cf.d1_binding', 32 | 'CLOUDFLARE_BINDING': 'DB', 33 | } 34 | } 35 | ``` 36 | 37 | ### D1 API 38 | 39 | The D1 engine uses the HTTP api directly from Cloudflare, meaning you only need to create a new D1 database, then 40 | create an API token with `D1 read` and `D1 write` permission, and you are good to go! 41 | 42 | But using an HTTP endpoint for executing queries one by one is very slow, and currently there is no way to speed up 43 | it. 44 | 45 | ```python 46 | DATABASES = { 47 | 'default': { 48 | 'ENGINE': 'django_cf.d1_api', 49 | 'CLOUDFLARE_DATABASE_ID': '', 50 | 'CLOUDFLARE_ACCOUNT_ID': '', 51 | 'CLOUDFLARE_TOKEN': '', 52 | } 53 | } 54 | ``` 55 | 56 | ## Cloudflare Workers 57 | 58 | django-cf includes an adapter that allows you to run Django inside Cloudflare Workers named `DjangoCFAdapter` 59 | 60 | Suggested project structure 61 | ``` 62 | root 63 | |-> src/ 64 | |-> src/manage.py 65 | |-> src/worker.py <-- Wrangler entrypoint 66 | |-> src/your-apps-here/ 67 | |-> src/vendor/... <-- Project dependencies, details bellow 68 | |-> vendor.txt 69 | |-> wrangler.jsonc 70 | ``` 71 | 72 | `vendor.txt` 73 | ```txt 74 | django==5.1.2 75 | django-cf 76 | tzdata 77 | ``` 78 | 79 | `wrangler.jsonc` 80 | ```jsonc 81 | { 82 | "name": "django-on-workers", 83 | "main": "src/worker.py", 84 | "compatibility_flags": [ 85 | "python_workers_20250116", 86 | "python_workers" 87 | ], 88 | "compatibility_date": "2025-04-10", 89 | "assets": { 90 | "directory": "./staticfiles/" 91 | }, 92 | "rules": [ 93 | { 94 | "globs": [ 95 | "vendor/**/*.py", 96 | "vendor/**/*.mo", 97 | "vendor/tzdata/**/", 98 | ], 99 | "type": "Data", 100 | "fallthrough": true 101 | } 102 | ], 103 | "d1_databases": [ 104 | { 105 | "binding": "DB", 106 | "database_name": "my-django-db", 107 | "database_id": "924e612f-6293-4a3f-be66-cce441957b03", 108 | } 109 | ], 110 | "observability": { 111 | "enabled": true 112 | } 113 | } 114 | ``` 115 | 116 | `src/worker.py` 117 | ```python 118 | from django_cf import DjangoCFAdapter 119 | 120 | async def on_fetch(request, env): 121 | from app.wsgi import application # Update acording to your project structure 122 | adapter = DjangoCFAdapter(application) 123 | 124 | return adapter.handle_request(request) 125 | 126 | ``` 127 | 128 | Then run this command to vendor your dependencies: 129 | ```bash 130 | pip install -t src/vendor -r vendor.txt 131 | ``` 132 | 133 | To bundle static assets with your worker, add this line to your `settings.py`, this will place the assets outside the src folder 134 | ```python 135 | STATIC_URL = 'static/' 136 | STATIC_ROOT = BASE_DIR.parent.joinpath('staticfiles').joinpath('static') 137 | ``` 138 | 139 | And this command generate the static assets: 140 | ```bash 141 | python src/manage.py collectstatic 142 | ``` 143 | 144 | Now deploy your worker 145 | ```bash 146 | npx wrangler deploy 147 | ``` 148 | 149 | ### Running migrations and other commands 150 | In the ideal setup, your application will have two settings, one for production and another for development. 151 | 152 | - The production one, will connect to D1 via using the binding, as this is way faster. 153 | - The development one, will connect using the D1 API. 154 | 155 | Using this setup, you can apply the migrations from your local machine. 156 | 157 | In case that is not enought for you, here is a snippet that allows you to apply D1 migrations using a deployed worker: 158 | 159 | Just add these new routes to your `urls.py`: 160 | 161 | ```python 162 | from django.contrib import admin 163 | from django.contrib.auth import get_user_model; 164 | from django.http import JsonResponse 165 | from django.urls import path 166 | 167 | def create_admin(request): 168 | User = get_user_model(); 169 | User.objects.create_superuser('admin', 'admin@do.com', 'password') 170 | return JsonResponse({"user": "ok"}) 171 | 172 | def migrate(request): 173 | from django.core.management import execute_from_command_line 174 | execute_from_command_line(["manage.py", "migrate"]) 175 | 176 | return JsonResponse({"migrations": "ok"}) 177 | 178 | urlpatterns = [ 179 | path('create-admin', create_admin), 180 | path('migrate', migrate), 181 | path('admin/', admin.site.urls), 182 | ] 183 | ``` 184 | 185 | You may now call your worker to apply all missing migrations, ex: `https://django-on-workers.{username}.workers.dev/migrate` 186 | 187 | ## Limitations 188 | 189 | When using D1 engine, queries are expected to be slow, and transactions are disabled. 190 | 191 | A lot of query features are additionally disabled, for example inline sql functions, used extensively inside Django Admin 192 | 193 | Read all Django limitations for SQLite [databases here](https://docs.djangoproject.com/en/5.0/ref/databases/#sqlite-notes). 194 | -------------------------------------------------------------------------------- /django_cf/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | from io import BytesIO 3 | 4 | class DjangoCFAdapter: 5 | def __init__(self, app): 6 | self.app = app 7 | 8 | async def handle_request(self, request): 9 | os.environ.setdefault('DJANGO_ALLOW_ASYNC_UNSAFE', 'false') 10 | from js import Object, Response, URL, console 11 | 12 | headers = [] 13 | for header in request.headers: 14 | headers.append(tuple([header[0], header[1]])) 15 | 16 | url = URL.new(request.url) 17 | assert url.protocol[-1] == ":" 18 | scheme = url.protocol[:-1] 19 | path = url.pathname 20 | assert "?".startswith(url.search[0:1]) 21 | query_string = url.search[1:] 22 | method = str(request.method).upper() 23 | 24 | host = url.host.split(':')[0] 25 | 26 | wsgi_request = { 27 | 'REQUEST_METHOD': method, 28 | 'PATH_INFO': path, 29 | 'QUERY_STRING': query_string, 30 | 'SERVER_NAME': host, 31 | 'SERVER_PORT': url.port, 32 | 'SERVER_PROTOCOL': 'HTTP/1.1', 33 | 'wsgi.input': BytesIO(b''), 34 | 'wsgi.errors': console.error, 35 | 'wsgi.version': (1, 0), 36 | 'wsgi.multithread': False, 37 | 'wsgi.multiprocess': False, 38 | 'wsgi.run_once': True, 39 | 'wsgi.url_scheme': scheme, 40 | } 41 | 42 | if request.headers.get('content-type'): 43 | wsgi_request['CONTENT_TYPE'] = request.headers.get('content-type') 44 | 45 | if request.headers.get('content-length'): 46 | wsgi_request['CONTENT_LENGTH'] = request.headers.get('content-length') 47 | 48 | for header in request.headers: 49 | wsgi_request[f'HTTP_{header[0].upper()}'] = header[1] 50 | 51 | if method in ['POST', 'PUT', 'PATCH']: 52 | body = (await request.arrayBuffer()).to_bytes() 53 | wsgi_request['wsgi.input'] = BytesIO(body) 54 | 55 | def start_response(status_str, response_headers): 56 | nonlocal status, headers 57 | status = status_str 58 | headers = response_headers 59 | 60 | resp = self.app(wsgi_request, start_response) 61 | status = resp.status_code 62 | headers = resp.headers 63 | 64 | final_response = Response.new( 65 | resp.content.decode('utf-8'), headers=Object.fromEntries(headers.items()), status=status 66 | ) 67 | 68 | for k, v in resp.cookies.items(): 69 | value = str(v) 70 | final_response.headers.set('Set-Cookie', value.replace('Set-Cookie: ', '', 1)); 71 | 72 | return final_response 73 | -------------------------------------------------------------------------------- /django_cf/d1_api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/G4brym/django-cf/e20ff635c1a2029e8cbdd68474efc64dad395499/django_cf/d1_api/__init__.py -------------------------------------------------------------------------------- /django_cf/d1_api/base.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ImproperlyConfigured 2 | from django.db.backends.sqlite3.base import DatabaseWrapper as SQLiteDatabaseWrapper 3 | 4 | from .client import DatabaseClient 5 | from .creation import DatabaseCreation 6 | from .database import D1Database as Database 7 | from .features import DatabaseFeatures 8 | from .introspection import DatabaseIntrospection 9 | from .operations import DatabaseOperations 10 | from .schema import DatabaseSchemaEditor 11 | 12 | 13 | class DatabaseWrapper(SQLiteDatabaseWrapper): 14 | vendor = "sqlite" 15 | display_name = "D1" 16 | 17 | Database = Database 18 | SchemaEditorClass = DatabaseSchemaEditor 19 | client_class = DatabaseClient 20 | creation_class = DatabaseCreation 21 | # Classes instantiated in __init__(). 22 | features_class = DatabaseFeatures 23 | introspection_class = DatabaseIntrospection 24 | ops_class = DatabaseOperations 25 | 26 | transaction_modes = frozenset([]) 27 | 28 | def get_database_version(self): 29 | return (4, ) 30 | 31 | def get_connection_params(self): 32 | settings_dict = self.settings_dict 33 | if not settings_dict["CLOUDFLARE_DATABASE_ID"]: 34 | raise ImproperlyConfigured( 35 | "settings.DATABASES is improperly configured. " 36 | "Please supply the CLOUDFLARE_DATABASE_ID value." 37 | ) 38 | if not settings_dict["CLOUDFLARE_ACCOUNT_ID"]: 39 | raise ImproperlyConfigured( 40 | "settings.DATABASES is improperly configured. " 41 | "Please supply the CLOUDFLARE_ACCOUNT_ID value." 42 | ) 43 | if not settings_dict["CLOUDFLARE_TOKEN"]: 44 | raise ImproperlyConfigured( 45 | "settings.DATABASES is improperly configured. " 46 | "Please supply the CLOUDFLARE_TOKEN value." 47 | ) 48 | kwargs = { 49 | "database_id": settings_dict["CLOUDFLARE_DATABASE_ID"], 50 | "account_id": settings_dict["CLOUDFLARE_ACCOUNT_ID"], 51 | "token": settings_dict["CLOUDFLARE_TOKEN"], 52 | } 53 | return kwargs 54 | 55 | def get_new_connection(self, conn_params): 56 | conn = Database.connect(**conn_params) 57 | return conn 58 | 59 | def create_cursor(self, name=None): 60 | return self.connection.cursor() 61 | 62 | def close(self): 63 | return 64 | 65 | def _savepoint_allowed(self): 66 | return False 67 | 68 | def _set_autocommit(self, commit): 69 | return 70 | 71 | def set_autocommit( 72 | self, autocommit, force_begin_transaction_with_broken_autocommit=False 73 | ): 74 | return 75 | 76 | def disable_constraint_checking(self): 77 | self.cursor().defer_foreign_keys(False) 78 | return True 79 | 80 | def enable_constraint_checking(self): 81 | self.cursor().defer_foreign_keys(True) 82 | 83 | def is_usable(self): 84 | return True 85 | -------------------------------------------------------------------------------- /django_cf/d1_api/client.py: -------------------------------------------------------------------------------- 1 | from django.db.backends.sqlite3.client import DatabaseClient as SQLiteDatabaseClient 2 | 3 | 4 | class DatabaseClient(SQLiteDatabaseClient): 5 | pass 6 | -------------------------------------------------------------------------------- /django_cf/d1_api/creation.py: -------------------------------------------------------------------------------- 1 | from django.db.backends.sqlite3.creation import DatabaseCreation as SQLiteDatabaseCreation 2 | 3 | 4 | class DatabaseCreation(SQLiteDatabaseCreation): 5 | pass 6 | -------------------------------------------------------------------------------- /django_cf/d1_api/database.py: -------------------------------------------------------------------------------- 1 | import json 2 | import http.client 3 | 4 | from django.db import DatabaseError, Error, DataError, OperationalError, \ 5 | IntegrityError, InternalError, ProgrammingError, NotSupportedError, InterfaceError 6 | 7 | def retry(times, exceptions): 8 | """ 9 | Retry Decorator 10 | Retries the wrapped function/method `times` times if the exceptions listed 11 | in ``exceptions`` are thrown 12 | :param times: The number of times to repeat the wrapped function/method 13 | :type times: Int 14 | :param Exceptions: Lists of exceptions that trigger a retry attempt 15 | :type Exceptions: Tuple of Exceptions 16 | """ 17 | def decorator(func): 18 | def newfn(*args, **kwargs): 19 | attempt = 0 20 | while attempt < times: 21 | try: 22 | return func(*args, **kwargs) 23 | except exceptions: 24 | print( 25 | 'Exception thrown when attempting to run %s, attempt ' 26 | '%d of %d' % (func, attempt, times) 27 | ) 28 | attempt += 1 29 | return func(*args, **kwargs) 30 | return newfn 31 | return decorator 32 | 33 | class D1Result: 34 | lastrowid = None 35 | rowcount = -1 36 | 37 | def __init__(self, data): 38 | self.data = data 39 | 40 | def __iter__(self): 41 | return iter(self.data) 42 | 43 | def set_lastrowid(self, value): 44 | self.lastrowid = value 45 | 46 | def set_rowcount(self, value): 47 | self.rowcount = value 48 | 49 | @staticmethod 50 | def from_dict(data): 51 | result = [] 52 | 53 | for row in data: 54 | row_items = () 55 | for k, v in row.items(): 56 | row_items += (v,) 57 | 58 | result.append(row_items) 59 | 60 | return D1Result(result) 61 | 62 | 63 | class D1Database: 64 | def __init__(self, database_id, account_id, token): 65 | self.database_id = database_id 66 | self.account_id = account_id 67 | self.token = token 68 | 69 | DataError = DataError 70 | 71 | OperationalError = OperationalError 72 | 73 | IntegrityError = IntegrityError 74 | 75 | InternalError = InternalError 76 | 77 | ProgrammingError = ProgrammingError 78 | 79 | NotSupportedError = NotSupportedError 80 | DatabaseError = DatabaseError 81 | InterfaceError = InterfaceError 82 | Error = Error 83 | 84 | lastrowid = None 85 | 86 | def set_lastrowid(self, value): 87 | self.lastrowid = value 88 | 89 | rowcount = None 90 | 91 | def set_rowcount(self, value): 92 | self.rowcount = value 93 | 94 | _defer_foreign_keys = False 95 | 96 | def defer_foreign_keys(self, state): 97 | _defer_foreign_keys = state 98 | 99 | @staticmethod 100 | def connect(database_id, account_id, token): 101 | return D1Database(database_id, account_id, token) 102 | 103 | def cursor(self): 104 | return self 105 | 106 | def commit(self): 107 | return # No commits allowed 108 | 109 | def rollback(self): 110 | return # No commits allowed 111 | 112 | def process_query(self, query): 113 | query = query.replace('%s', '?') 114 | 115 | if self._defer_foreign_keys: 116 | return f''' 117 | PRAGMA defer_foreign_keys = on 118 | 119 | {query} 120 | 121 | PRAGMA defer_foreign_keys = off 122 | ''' 123 | 124 | return query 125 | 126 | @retry(times=3, exceptions=(InternalError,)) 127 | def run_query(self, query, params=None): 128 | proc_query = self.process_query(query) 129 | 130 | conn = http.client.HTTPSConnection("api.cloudflare.com", timeout=20.0) 131 | 132 | payload = { 133 | "params": params, 134 | "sql": proc_query 135 | } 136 | 137 | headers = { 138 | 'Content-Type': "application/json", 139 | 'Authorization': f"Bearer {self.token}" 140 | } 141 | 142 | conn.request("POST", f"/client/v4/accounts/{self.account_id}/d1/database/{self.database_id}/query", json.dumps(payload), headers) 143 | 144 | res = conn.getresponse() 145 | data = res.read() 146 | 147 | decoded = data.decode("utf-8") 148 | try: 149 | response = json.loads(decoded) 150 | except: 151 | # Couldn't parse the json, probably an internal error 152 | raise InternalError(decoded) 153 | 154 | if response["success"] == False: 155 | errorMsg = response["errors"][0]["message"] 156 | if "unique constraint failed" in errorMsg.lower(): 157 | raise IntegrityError(errorMsg) 158 | 159 | raise DatabaseError(errorMsg) 160 | 161 | query_result = response["result"][0] 162 | if query_result["success"] == False: 163 | raise DatabaseError(query_result) 164 | 165 | result = D1Result.from_dict(query_result["results"]) 166 | 167 | meta = query_result.get("meta") 168 | if query_result["meta"]: 169 | if meta["last_row_id"]: 170 | result.set_lastrowid(meta["last_row_id"]) 171 | self.set_lastrowid(meta["last_row_id"]) 172 | 173 | if meta["rows_read"] or meta["rows_written"]: 174 | result.set_rowcount(meta.get("rows_read", 0) + meta.get("rows_read", 0)) 175 | self.set_rowcount(meta.get("rows_read", 0) + meta.get("rows_read", 0)) 176 | 177 | return result 178 | 179 | query = None 180 | params = None 181 | 182 | def execute(self, query, params=None): 183 | if params: 184 | newParams = [] 185 | for v in list(params): 186 | if v is True: 187 | v = 1 188 | elif v is False: 189 | v = 0 190 | 191 | newParams.append(v) 192 | 193 | params = tuple(newParams) 194 | 195 | self.results = self.run_query(query, params) 196 | 197 | return self 198 | 199 | def fetchone(self): 200 | if len(self.results.data) > 0: 201 | return self.results.data.pop() 202 | return None 203 | 204 | def fetchall(self): 205 | ret = [] 206 | while True: 207 | row = self.fetchone() 208 | if row is None: 209 | break 210 | ret.append(row) 211 | return ret 212 | 213 | def fetchmany(self, size=1): 214 | ret = [] 215 | while size > 0: 216 | row = self.fetchone() 217 | if row is None: 218 | break 219 | ret.append(row) 220 | if size is not None: 221 | size -= 1 222 | 223 | return ret 224 | 225 | def close(self): 226 | return 227 | -------------------------------------------------------------------------------- /django_cf/d1_api/features.py: -------------------------------------------------------------------------------- 1 | from django.db.backends.sqlite3.features import DatabaseFeatures as SQLiteDatabaseFeatures 2 | 3 | 4 | class DatabaseFeatures(SQLiteDatabaseFeatures): 5 | has_select_for_update = True 6 | has_native_uuid_field = False 7 | atomic_transactions = False 8 | supports_transactions = False 9 | can_release_savepoints = False 10 | supports_atomic_references_rename = False 11 | can_clone_databases = False 12 | can_rollback_ddl = False 13 | # Unsupported add column and foreign key in single statement 14 | # https://github.com/pingcap/tidb/issues/45474 15 | can_create_inline_fk = False 16 | order_by_nulls_first = True 17 | create_test_procedure_without_params_sql = None 18 | create_test_procedure_with_int_param_sql = None 19 | supports_aggregate_filter_clause = True 20 | can_defer_constraint_checks = False 21 | supports_pragma_foreign_key_check = False 22 | can_alter_table_rename_column = False 23 | max_query_params = 100 24 | can_clone_databases = False 25 | can_rollback_ddl = False 26 | supports_atomic_references_rename = False 27 | supports_forward_references = False 28 | supports_transactions = False 29 | has_bulk_insert = True 30 | #supports_select_union = False 31 | #supports_select_intersection = False 32 | #supports_select_difference = False 33 | can_return_columns_from_insert = True 34 | 35 | minimum_database_version = (4,) 36 | -------------------------------------------------------------------------------- /django_cf/d1_api/introspection.py: -------------------------------------------------------------------------------- 1 | from django.db.backends.sqlite3.introspection import DatabaseIntrospection as SQLiteDatabaseIntrospection 2 | 3 | 4 | class DatabaseIntrospection(SQLiteDatabaseIntrospection): 5 | pass 6 | -------------------------------------------------------------------------------- /django_cf/d1_api/operations.py: -------------------------------------------------------------------------------- 1 | from django.db.backends.sqlite3.operations import DatabaseOperations as SQLiteDatabaseOperations 2 | 3 | 4 | class DatabaseOperations(SQLiteDatabaseOperations): 5 | # This patches some weird bugs related to the Database class 6 | def _quote_params_for_last_executed_query(self, params): 7 | """ 8 | Only for last_executed_query! Don't use this to execute SQL queries! 9 | """ 10 | # This function is limited both by SQLITE_LIMIT_VARIABLE_NUMBER (the 11 | # number of parameters, default = 999) and SQLITE_MAX_COLUMN (the 12 | # number of return values, default = 2000). Since Python's sqlite3 13 | # module doesn't expose the get_limit() C API, assume the default 14 | # limits are in effect and split the work in batches if needed. 15 | BATCH_SIZE = 999 16 | if len(params) > BATCH_SIZE: 17 | results = () 18 | for index in range(0, len(params), BATCH_SIZE): 19 | chunk = params[index: index + BATCH_SIZE] 20 | results += self._quote_params_for_last_executed_query(chunk) 21 | return results 22 | 23 | sql = "SELECT " + ", ".join(["QUOTE(?)"] * len(params)) 24 | # Bypass Django's wrappers and use the underlying sqlite3 connection 25 | # to avoid logging this query - it would trigger infinite recursion. 26 | 27 | cursor = self.connection.connection.cursor() 28 | # Native sqlite3 cursors cannot be used as context managers. 29 | # try: 30 | # return cursor.execute(sql, params).fetchone() 31 | # finally: 32 | # cursor.close() 33 | 34 | def last_executed_query(self, cursor, sql, params): 35 | # Python substitutes parameters in Modules/_sqlite/cursor.c with: 36 | # bind_parameters(state, self->statement, parameters); 37 | # Unfortunately there is no way to reach self->statement from Python, 38 | # so we quote and substitute parameters manually. 39 | if params: 40 | if isinstance(params, (list, tuple)): 41 | params = self._quote_params_for_last_executed_query(params) 42 | else: 43 | values = tuple(params.values()) 44 | values = self._quote_params_for_last_executed_query(values) 45 | params = dict(zip(params, values)) 46 | try: 47 | return sql % params 48 | except: 49 | return sql 50 | # For consistency with SQLiteCursorWrapper.execute(), just return sql 51 | # when there are no parameters. See #13648 and #17158. 52 | else: 53 | return sql 54 | 55 | def bulk_insert_sql(self, fields, placeholder_rows): 56 | placeholder_rows_sql = (", ".join(row) for row in placeholder_rows) 57 | values_sql = ", ".join("(%s)" % sql for sql in placeholder_rows_sql) 58 | return "VALUES " + values_sql 59 | -------------------------------------------------------------------------------- /django_cf/d1_api/schema.py: -------------------------------------------------------------------------------- 1 | from django.db.backends.sqlite3.schema import DatabaseSchemaEditor as SQLiteDatabaseSchemaEditor 2 | 3 | 4 | class DatabaseSchemaEditor(SQLiteDatabaseSchemaEditor): 5 | def __exit__(self, exc_type, exc_value, traceback): 6 | if exc_type is None: 7 | for sql in self.deferred_sql: 8 | self.execute(sql) 9 | if self.atomic_migration: 10 | self.atomic.__exit__(exc_type, exc_value, traceback) 11 | -------------------------------------------------------------------------------- /django_cf/d1_binding/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/G4brym/django-cf/e20ff635c1a2029e8cbdd68474efc64dad395499/django_cf/d1_binding/__init__.py -------------------------------------------------------------------------------- /django_cf/d1_binding/base.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ImproperlyConfigured 2 | from django.db.backends.sqlite3.base import DatabaseWrapper as SQLiteDatabaseWrapper 3 | 4 | from .client import DatabaseClient 5 | from .creation import DatabaseCreation 6 | from .database import D1Database as Database 7 | from .features import DatabaseFeatures 8 | from .introspection import DatabaseIntrospection 9 | from .operations import DatabaseOperations 10 | from .schema import DatabaseSchemaEditor 11 | 12 | 13 | class DatabaseWrapper(SQLiteDatabaseWrapper): 14 | vendor = "sqlite" 15 | display_name = "D1" 16 | 17 | Database = Database 18 | SchemaEditorClass = DatabaseSchemaEditor 19 | client_class = DatabaseClient 20 | creation_class = DatabaseCreation 21 | # Classes instantiated in __init__(). 22 | features_class = DatabaseFeatures 23 | introspection_class = DatabaseIntrospection 24 | ops_class = DatabaseOperations 25 | 26 | transaction_modes = frozenset([]) 27 | 28 | def get_database_version(self): 29 | return (4, ) 30 | 31 | def get_connection_params(self): 32 | settings_dict = self.settings_dict 33 | if not settings_dict["CLOUDFLARE_BINDING"]: 34 | raise ImproperlyConfigured( 35 | "settings.DATABASES is improperly configured. " 36 | "Please supply the CLOUDFLARE_BINDING value." 37 | ) 38 | kwargs = { 39 | "binding": settings_dict["CLOUDFLARE_BINDING"], 40 | } 41 | return kwargs 42 | 43 | def get_new_connection(self, conn_params): 44 | conn = Database.connect(**conn_params) 45 | return conn 46 | 47 | def create_cursor(self, name=None): 48 | return self.connection.cursor() 49 | 50 | def close(self): 51 | return 52 | 53 | def _savepoint_allowed(self): 54 | return False 55 | 56 | def _set_autocommit(self, commit): 57 | return 58 | 59 | def set_autocommit( 60 | self, autocommit, force_begin_transaction_with_broken_autocommit=False 61 | ): 62 | return 63 | 64 | def disable_constraint_checking(self): 65 | self.cursor().defer_foreign_keys(False) 66 | return True 67 | 68 | def enable_constraint_checking(self): 69 | self.cursor().defer_foreign_keys(True) 70 | 71 | def is_usable(self): 72 | return True 73 | -------------------------------------------------------------------------------- /django_cf/d1_binding/client.py: -------------------------------------------------------------------------------- 1 | from django.db.backends.sqlite3.client import DatabaseClient as SQLiteDatabaseClient 2 | 3 | 4 | class DatabaseClient(SQLiteDatabaseClient): 5 | pass 6 | -------------------------------------------------------------------------------- /django_cf/d1_binding/creation.py: -------------------------------------------------------------------------------- 1 | from django.db.backends.sqlite3.creation import DatabaseCreation as SQLiteDatabaseCreation 2 | 3 | 4 | class DatabaseCreation(SQLiteDatabaseCreation): 5 | pass 6 | -------------------------------------------------------------------------------- /django_cf/d1_binding/database.py: -------------------------------------------------------------------------------- 1 | import json 2 | import http.client 3 | import datetime 4 | import re 5 | 6 | from django.db import DatabaseError, Error, DataError, OperationalError, \ 7 | IntegrityError, InternalError, ProgrammingError, NotSupportedError, InterfaceError 8 | from django.conf import settings 9 | from django.utils import timezone 10 | 11 | try: 12 | from workers import import_from_javascript 13 | from pyodide.ffi import run_sync 14 | except ImportError: 15 | raise Exception("Code not running inside a worker, please change to django_cf.d1_api database backend") 16 | 17 | class D1Result: 18 | lastrowid = None 19 | rowcount = -1 20 | 21 | def __init__(self, data): 22 | self.data = data 23 | 24 | def __iter__(self): 25 | return iter(self.data) 26 | 27 | def set_lastrowid(self, value): 28 | self.lastrowid = value 29 | 30 | def set_rowcount(self, value): 31 | self.rowcount = value 32 | 33 | @staticmethod 34 | def from_dict(data): 35 | result = [] 36 | 37 | for row in data: 38 | row_items = () 39 | for k, v in row.items(): 40 | row_items += (v,) 41 | 42 | result.append(row_items) 43 | 44 | return D1Result(result) 45 | 46 | 47 | class D1Database: 48 | def __init__(self, binding): 49 | self.binding = binding 50 | 51 | DataError = DataError 52 | 53 | OperationalError = OperationalError 54 | 55 | IntegrityError = IntegrityError 56 | 57 | InternalError = InternalError 58 | 59 | ProgrammingError = ProgrammingError 60 | 61 | NotSupportedError = NotSupportedError 62 | DatabaseError = DatabaseError 63 | InterfaceError = InterfaceError 64 | Error = Error 65 | 66 | lastrowid = None 67 | 68 | def set_lastrowid(self, value): 69 | self.lastrowid = value 70 | 71 | rowcount = None 72 | 73 | def set_rowcount(self, value): 74 | self.rowcount = value 75 | 76 | _defer_foreign_keys = False 77 | 78 | def defer_foreign_keys(self, state): 79 | _defer_foreign_keys = state 80 | 81 | @staticmethod 82 | def connect(binding): 83 | return D1Database(binding) 84 | 85 | def cursor(self): 86 | return self 87 | 88 | def commit(self): 89 | return # No commits allowed 90 | 91 | def rollback(self): 92 | return # No commits allowed 93 | 94 | def process_query(self, query, params=None): 95 | if params is None: 96 | query = query.replace('%s', '?') 97 | else: 98 | new_params = [] 99 | for param in params: 100 | if param is None: 101 | query = query.replace('%s', 'null', 1) 102 | else: 103 | new_params.append(param) 104 | query = query.replace('%s', '?', 1) 105 | 106 | params = new_params 107 | 108 | if self._defer_foreign_keys: 109 | return f''' 110 | PRAGMA defer_foreign_keys = on 111 | 112 | {query} 113 | 114 | PRAGMA defer_foreign_keys = off 115 | ''' 116 | 117 | return query, params 118 | 119 | def run_query(self, query, params=None): 120 | proc_query, params = self.process_query(query, params) 121 | 122 | # print(query) 123 | # print(params) 124 | 125 | cf_workers = import_from_javascript("cloudflare:workers") 126 | # print(dir(cf_workers.env)) 127 | db = getattr(cf_workers.env, self.binding) 128 | 129 | if params: 130 | stmt = db.prepare(proc_query).bind(*params); 131 | else: 132 | stmt = db.prepare(proc_query); 133 | 134 | try: 135 | resp = run_sync(stmt.all()) 136 | except: 137 | from js import Error 138 | Error.stackTraceLimit = 1e10 139 | raise Error(Error.new().stack) 140 | 141 | results = self._convert_results(resp.results.to_py()) 142 | 143 | # print(results) 144 | # print(f'rowsRead: {resp.meta.rows_read}') 145 | # print(f'rowsWritten: {resp.meta.rows_written}') 146 | # print('---') 147 | 148 | return results, { 149 | "rows_read": resp.meta.rows_read, 150 | "rows_written": resp.meta.rows_written, 151 | } 152 | 153 | def _convert_results(self, data): 154 | """ 155 | Convert any datetime strings in the result set to actual timezone-aware datetime objects. 156 | """ 157 | # print('before') 158 | # print(data) 159 | result = [] 160 | 161 | for row in data: 162 | row_items = () 163 | for k, v in row.items(): 164 | if isinstance(v, str): 165 | v = self._parse_datetime(v) 166 | row_items += (v,) 167 | 168 | result.append(row_items) 169 | 170 | # print('after') 171 | # print(result) 172 | return result 173 | 174 | query = None 175 | params = None 176 | 177 | def _parse_datetime(self, value): 178 | """ 179 | Parse the string value to a timezone-aware datetime object, if applicable. 180 | Handles both datetime strings with and without milliseconds. 181 | Uses Django's timezone utilities for proper conversion. 182 | """ 183 | datetime_formats = ["%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%d %H:%M:%S"] 184 | 185 | for dt_format in datetime_formats: 186 | try: 187 | naive_dt = datetime.datetime.strptime(value, dt_format) 188 | # If Django is using timezones, convert to an aware datetime object 189 | if timezone.is_naive(naive_dt): 190 | return timezone.make_aware(naive_dt, timezone.get_default_timezone()) 191 | return naive_dt 192 | except (ValueError, TypeError): 193 | continue # Try the next format if parsing fails 194 | 195 | return value # If it's not a datetime string, return the original value 196 | 197 | def execute(self, query, params=None): 198 | if params: 199 | newParams = [] 200 | for v in list(params): 201 | if v is True: 202 | v = 1 203 | elif v is False: 204 | v = 0 205 | 206 | newParams.append(v) 207 | 208 | params = tuple(newParams) 209 | 210 | result, meta = self.run_query(query, params) 211 | 212 | self.results = result 213 | 214 | if meta: 215 | if "INSERT" in query.upper(): 216 | self.rowcount = meta.get("rows_written", 0) 217 | # self.connection.ops.last_insert_id = meta.get("last_insert_id") # TODO: implement last insert id 218 | elif "UPDATE" in query.upper() or "DELETE" in query.upper(): 219 | self.rowcount = meta.get("rows_written", 0) 220 | else: 221 | self.rowcount = meta.get("rows_read", 0) 222 | 223 | return self 224 | 225 | def fetchone(self): 226 | if len(self.results) > 0: 227 | return self.results.pop() 228 | return None 229 | 230 | def fetchall(self): 231 | ret = [] 232 | while True: 233 | row = self.fetchone() 234 | if row is None: 235 | break 236 | ret.append(row) 237 | return ret 238 | 239 | def fetchmany(self, size=1): 240 | ret = [] 241 | while size > 0: 242 | row = self.fetchone() 243 | if row is None: 244 | break 245 | ret.append(row) 246 | if size is not None: 247 | size -= 1 248 | 249 | return ret 250 | 251 | def close(self): 252 | return 253 | -------------------------------------------------------------------------------- /django_cf/d1_binding/features.py: -------------------------------------------------------------------------------- 1 | from django.db.backends.sqlite3.features import DatabaseFeatures as SQLiteDatabaseFeatures 2 | 3 | 4 | class DatabaseFeatures(SQLiteDatabaseFeatures): 5 | has_select_for_update = True 6 | has_native_uuid_field = False 7 | atomic_transactions = False 8 | supports_transactions = False 9 | can_release_savepoints = False 10 | supports_atomic_references_rename = False 11 | can_clone_databases = False 12 | can_rollback_ddl = False 13 | # Unsupported add column and foreign key in single statement 14 | # https://github.com/pingcap/tidb/issues/45474 15 | can_create_inline_fk = False 16 | order_by_nulls_first = True 17 | create_test_procedure_without_params_sql = None 18 | create_test_procedure_with_int_param_sql = None 19 | supports_aggregate_filter_clause = True 20 | can_defer_constraint_checks = False 21 | supports_pragma_foreign_key_check = False 22 | can_alter_table_rename_column = False 23 | max_query_params = 100 24 | can_clone_databases = False 25 | can_rollback_ddl = False 26 | supports_atomic_references_rename = False 27 | supports_forward_references = False 28 | supports_transactions = False 29 | has_bulk_insert = True 30 | #supports_select_union = False 31 | #supports_select_intersection = False 32 | #supports_select_difference = False 33 | can_return_columns_from_insert = True 34 | 35 | minimum_database_version = (4,) 36 | -------------------------------------------------------------------------------- /django_cf/d1_binding/introspection.py: -------------------------------------------------------------------------------- 1 | from django.db.backends.sqlite3.introspection import DatabaseIntrospection as SQLiteDatabaseIntrospection 2 | 3 | 4 | class DatabaseIntrospection(SQLiteDatabaseIntrospection): 5 | pass 6 | -------------------------------------------------------------------------------- /django_cf/d1_binding/operations.py: -------------------------------------------------------------------------------- 1 | from django.db.backends.sqlite3.operations import DatabaseOperations as SQLiteDatabaseOperations 2 | 3 | 4 | class DatabaseOperations(SQLiteDatabaseOperations): 5 | # This patches some weird bugs related to the Database class 6 | def _quote_params_for_last_executed_query(self, params): 7 | """ 8 | Only for last_executed_query! Don't use this to execute SQL queries! 9 | """ 10 | # This function is limited both by SQLITE_LIMIT_VARIABLE_NUMBER (the 11 | # number of parameters, default = 999) and SQLITE_MAX_COLUMN (the 12 | # number of return values, default = 2000). Since Python's sqlite3 13 | # module doesn't expose the get_limit() C API, assume the default 14 | # limits are in effect and split the work in batches if needed. 15 | BATCH_SIZE = 999 16 | if len(params) > BATCH_SIZE: 17 | results = () 18 | for index in range(0, len(params), BATCH_SIZE): 19 | chunk = params[index: index + BATCH_SIZE] 20 | results += self._quote_params_for_last_executed_query(chunk) 21 | return results 22 | 23 | sql = "SELECT " + ", ".join(["QUOTE(?)"] * len(params)) 24 | # Bypass Django's wrappers and use the underlying sqlite3 connection 25 | # to avoid logging this query - it would trigger infinite recursion. 26 | 27 | cursor = self.connection.connection.cursor() 28 | # Native sqlite3 cursors cannot be used as context managers. 29 | # try: 30 | # return cursor.execute(sql, params).fetchone() 31 | # finally: 32 | # cursor.close() 33 | 34 | def last_executed_query(self, cursor, sql, params): 35 | # Python substitutes parameters in Modules/_sqlite/cursor.c with: 36 | # bind_parameters(state, self->statement, parameters); 37 | # Unfortunately there is no way to reach self->statement from Python, 38 | # so we quote and substitute parameters manually. 39 | if params: 40 | if isinstance(params, (list, tuple)): 41 | params = self._quote_params_for_last_executed_query(params) 42 | else: 43 | values = tuple(params.values()) 44 | values = self._quote_params_for_last_executed_query(values) 45 | params = dict(zip(params, values)) 46 | try: 47 | return sql % params 48 | except: 49 | return sql 50 | # For consistency with SQLiteCursorWrapper.execute(), just return sql 51 | # when there are no parameters. See #13648 and #17158. 52 | else: 53 | return sql 54 | 55 | def bulk_insert_sql(self, fields, placeholder_rows): 56 | placeholder_rows_sql = (", ".join(row) for row in placeholder_rows) 57 | values_sql = ", ".join("(%s)" % sql for sql in placeholder_rows_sql) 58 | return "VALUES " + values_sql 59 | -------------------------------------------------------------------------------- /django_cf/d1_binding/schema.py: -------------------------------------------------------------------------------- 1 | from django.db.backends.sqlite3.schema import DatabaseSchemaEditor as SQLiteDatabaseSchemaEditor 2 | 3 | 4 | class DatabaseSchemaEditor(SQLiteDatabaseSchemaEditor): 5 | def __exit__(self, exc_type, exc_value, traceback): 6 | if exc_type is None: 7 | for sql in self.deferred_sql: 8 | self.execute(sql) 9 | if self.atomic_migration: 10 | self.atomic.__exit__(exc_type, exc_value, traceback) 11 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools >= 77.0.3"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "django-cf" 7 | version = "0.1.1" 8 | authors = [ 9 | { name="Gabriel Massadas" }, 10 | ] 11 | dependencies = [ 12 | 'sqlparse', 13 | ] 14 | description = "django-cf is a package that integrates Django with Cloudflare products" 15 | readme = "README.md" 16 | license = "MIT" 17 | requires-python = ">=3.10" 18 | classifiers = [ 19 | "Development Status :: 3 - Alpha", 20 | "Framework :: Django", 21 | "Framework :: Django :: 5.0", 22 | "Operating System :: OS Independent", 23 | "Programming Language :: Python", 24 | "Programming Language :: Python :: 3", 25 | "Programming Language :: Python :: 3.10", 26 | "Programming Language :: Python :: 3.11", 27 | "Programming Language :: Python :: 3.12" 28 | ] 29 | 30 | [project.urls] 31 | "Homepage" = "https://github.com/G4brym/django-cf" 32 | "Bug Reports" = "https://github.com/G4brym/django-cf/issues" 33 | "Source" = "https://github.com/G4brym/django-cf" 34 | 35 | [tool.setuptools] 36 | packages = ["django_cf", "django_cf.d1_api", "django_cf.d1_binding"] 37 | --------------------------------------------------------------------------------