Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support MongoDB with djongo backend #204

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 40 additions & 32 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -151,37 +151,39 @@ and should instead be passed as:
URL schema
----------

+----------------------+-----------------------------------------------+--------------------------------------------------+
| Engine | Django Backend | URL |
+======================+===============================================+==================================================+
| PostgreSQL | ``django.db.backends.postgresql`` [1]_ | ``postgres://USER:PASSWORD@HOST:PORT/NAME`` [2]_ |
+----------------------+-----------------------------------------------+--------------------------------------------------+
| PostGIS | ``django.contrib.gis.db.backends.postgis`` | ``postgis://USER:PASSWORD@HOST:PORT/NAME`` |
+----------------------+-----------------------------------------------+--------------------------------------------------+
| MSSQL | ``sql_server.pyodbc`` | ``mssql://USER:PASSWORD@HOST:PORT/NAME`` |
+----------------------+-----------------------------------------------+--------------------------------------------------+
| MSSQL [5]_ | ``mssql`` | ``mssqlms://USER:PASSWORD@HOST:PORT/NAME`` |
+----------------------+-----------------------------------------------+--------------------------------------------------+
| MySQL | ``django.db.backends.mysql`` | ``mysql://USER:PASSWORD@HOST:PORT/NAME`` [2]_ |
+----------------------+-----------------------------------------------+--------------------------------------------------+
| MySQL (GIS) | ``django.contrib.gis.db.backends.mysql`` | ``mysqlgis://USER:PASSWORD@HOST:PORT/NAME`` |
+----------------------+-----------------------------------------------+--------------------------------------------------+
| SQLite | ``django.db.backends.sqlite3`` | ``sqlite:///PATH`` [3]_ |
+----------------------+-----------------------------------------------+--------------------------------------------------+
| SpatiaLite | ``django.contrib.gis.db.backends.spatialite`` | ``spatialite:///PATH`` [3]_ |
+----------------------+-----------------------------------------------+--------------------------------------------------+
| Oracle | ``django.db.backends.oracle`` | ``oracle://USER:PASSWORD@HOST:PORT/NAME`` [4]_ |
+----------------------+-----------------------------------------------+--------------------------------------------------+
| Oracle (GIS) | ``django.contrib.gis.db.backends.oracle`` | ``oraclegis://USER:PASSWORD@HOST:PORT/NAME`` |
+----------------------+-----------------------------------------------+--------------------------------------------------+
| Redshift | ``django_redshift_backend`` | ``redshift://USER:PASSWORD@HOST:PORT/NAME`` |
+----------------------+-----------------------------------------------+--------------------------------------------------+
| CockroachDB | ``django_cockroachdb`` | ``cockroach://USER:PASSWORD@HOST:PORT/NAME`` |
+----------------------+-----------------------------------------------+--------------------------------------------------+
| Timescale [6]_ | ``timescale.db.backends.postgresql`` | ``timescale://USER:PASSWORD@HOST:PORT/NAME`` |
+----------------------+-----------------------------------------------+--------------------------------------------------+
| Timescale (GIS) [6]_ | ``timescale.db.backend.postgis`` | ``timescalegis://USER:PASSWORD@HOST:PORT/NAME`` |
+----------------------+-----------------------------------------------+--------------------------------------------------+
+----------------------+-----------------------------------------------+-------------------------------------------------------+
| Engine | Django Backend | URL |
+======================+===============================================+=======================================================+
| PostgreSQL | ``django.db.backends.postgresql`` [1]_ | ``postgres://USER:PASSWORD@HOST:PORT/NAME`` [2]_ |
+----------------------+-----------------------------------------------+-------------------------------------------------------+
| PostGIS | ``django.contrib.gis.db.backends.postgis`` | ``postgis://USER:PASSWORD@HOST:PORT/NAME`` |
+----------------------+-----------------------------------------------+-------------------------------------------------------+
| MSSQL | ``sql_server.pyodbc`` | ``mssql://USER:PASSWORD@HOST:PORT/NAME`` |
+----------------------+-----------------------------------------------+-------------------------------------------------------+
| MSSQL [5]_ | ``mssql`` | ``mssqlms://USER:PASSWORD@HOST:PORT/NAME`` |
+----------------------+-----------------------------------------------+-------------------------------------------------------+
| MySQL | ``django.db.backends.mysql`` | ``mysql://USER:PASSWORD@HOST:PORT/NAME`` [2]_ |
+----------------------+-----------------------------------------------+-------------------------------------------------------+
| MySQL (GIS) | ``django.contrib.gis.db.backends.mysql`` | ``mysqlgis://USER:PASSWORD@HOST:PORT/NAME`` |
+----------------------+-----------------------------------------------+-------------------------------------------------------+
| SQLite | ``django.db.backends.sqlite3`` | ``sqlite:///PATH`` [3]_ |
+----------------------+-----------------------------------------------+-------------------------------------------------------+
| SpatiaLite | ``django.contrib.gis.db.backends.spatialite`` | ``spatialite:///PATH`` [3]_ |
+----------------------+-----------------------------------------------+-------------------------------------------------------+
| Oracle | ``django.db.backends.oracle`` | ``oracle://USER:PASSWORD@HOST:PORT/NAME`` [4]_ |
+----------------------+-----------------------------------------------+-------------------------------------------------------+
| Oracle (GIS) | ``django.contrib.gis.db.backends.oracle`` | ``oraclegis://USER:PASSWORD@HOST:PORT/NAME`` |
+----------------------+-----------------------------------------------+-------------------------------------------------------+
| Redshift | ``django_redshift_backend`` | ``redshift://USER:PASSWORD@HOST:PORT/NAME`` |
+----------------------+-----------------------------------------------+-------------------------------------------------------+
| CockroachDB | ``django_cockroachdb`` | ``cockroach://USER:PASSWORD@HOST:PORT/NAME`` |
+----------------------+-----------------------------------------------+-------------------------------------------------------+
| Timescale [6]_ | ``timescale.db.backends.postgresql`` | ``timescale://USER:PASSWORD@HOST:PORT/NAME`` |
+----------------------+-----------------------------------------------+-------------------------------------------------------+
| Timescale (GIS) [6]_ | ``timescale.db.backend.postgis`` | ``timescalegis://USER:PASSWORD@HOST:PORT/NAME`` |
+----------------------+-----------------------------------------------+-------------------------------------------------------+
| MongoDB [7]_ | ``djongo`` | ``mongodb[+srv]://USER:PASSWORD@HOST:PORT/NAME`` [8]_ |
+----------------------+-----------------------------------------------+-------------------------------------------------------+

.. [1] The django.db.backends.postgresql backend is named django.db.backends.postgresql_psycopg2 in older releases. For
backwards compatibility, the old name still works in newer versions. (The new name does not work in older versions).
Expand All @@ -199,7 +201,13 @@ URL schema
and provide a full DSN string or TNS name in ``NAME`` part.
.. [5] Microsoft official `mssql-django <https://github.com/microsoft/mssql-django>`_ adapter.
.. [6] Using the django-timescaledb Package which must be installed.

.. [7] Using the `djongo Package <https://pypi.org/project/djongo/>`_ which must be installed.
The URL support both ``mongodb`` and ``mongodb+srv`` protocols.
.. [8] You can add extra query parameters to the URL. For example, to set the ``enforceSchema`` and ``replicaSet`` parameter, you can use the following URL:
``mongodb://user:password@host:port/dbname?enforceSchema=true&replicaSet=rs0``
More information about the extra query parameters can be found in the `djongo documentation <https://www.djongomapper.com/get-started/#database-configuration>`_
and the `mongodb uri format <https://www.mongodb.com/docs/manual/reference/connection-string/#connection-string-uri-format>`_.
Remember convert ``: / ? # [ ] @`` using `percent encoding <https://tools.ietf.org/html/rfc3986#section-2.1>`_.

Contributing
------------
Expand Down
66 changes: 58 additions & 8 deletions dj_database_url.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import os
import urllib.parse as urlparse
from typing import Any, Dict, Optional, Union
from typing import Any, Dict, Optional, Sequence, Union

from typing_extensions import TypedDict
# Support Python 3.7.
# `try: from typing import Literal` causes:
# error: Module 'typing' has no attribute 'Literal' [attr-defined]
from typing_extensions import Literal, TypedDict

# Register database schemes in URLs.
urlparse.uses_netloc.append("postgres")
Expand All @@ -23,6 +26,8 @@
urlparse.uses_netloc.append("cockroach")
urlparse.uses_netloc.append("timescale")
urlparse.uses_netloc.append("timescalegis")
urlparse.uses_netloc.append("mongodb")
urlparse.uses_netloc.append("mongodb+srv")

DEFAULT_ENV = "DATABASE_URL"

Expand All @@ -45,6 +50,8 @@
"cockroach": "django_cockroachdb",
"timescale": "timescale.db.backends.postgresql",
"timescalegis": "timescale.db.backends.postgis",
"mongodb": "djongo",
"mongodb+srv": "djongo",
}


Expand All @@ -64,6 +71,9 @@ class DBConfig(TypedDict, total=False):
TEST: Dict[str, Any]
TIME_ZONE: str
USER: str
# MongoDB (djongo backend):
CLIENT: Optional[Dict[str, Any]]
ENFORCE_SCHEMA: bool


def config(
Expand Down Expand Up @@ -144,12 +154,18 @@ def parse(
% (spliturl.scheme, ", ".join(sorted(SCHEMES.keys())))
)

port = (
str(spliturl.port)
if spliturl.port
and engine in (SCHEMES["oracle"], SCHEMES["mssql"], SCHEMES["mssqlms"])
else spliturl.port
)
try:
port = (
str(spliturl.port)
if spliturl.port
and engine in (SCHEMES["oracle"], SCHEMES["mssql"], SCHEMES["mssqlms"])
else spliturl.port
)
except Exception as e:
if engine == "djongo": # compatible with multiple host:port
port = None
else:
raise ValueError(f'Port parse error: {e}')

# Update with environment configuration.
parsed_config.update(
Expand Down Expand Up @@ -199,4 +215,38 @@ def parse(
if engine:
parsed_config["ENGINE"] = engine

# MongoDB
if engine == "djongo":
if "enforceSchema" in options:
# Remove the enforceSchema option from the options dict
parsed_config["ENFORCE_SCHEMA"] = (
options.pop("enforceSchema").lower() == "true"
)

if spliturl.query == "":
host = url
else:
host = f"{url.split('?')[0]}?{urlparse.urlencode(options)}"
parsed_config["CLIENT"] = {"host": host}

# default database
if parsed_config['NAME'] == '':
parsed_config['NAME'] = 'db'

# pop unnecessary options
remove_key_list: Sequence[
Literal['USER', 'PASSWORD', 'HOST', 'PORT', 'OPTIONS']
] = [
'USER',
'PASSWORD',
'HOST',
'PORT',
'OPTIONS',
] # cannot use list[str] directly:
# https://github.com/python/mypy/issues/7178#issuecomment-1208364397

for key in remove_key_list:
if key in parsed_config:
parsed_config.pop(key)

return parsed_config
112 changes: 112 additions & 0 deletions test_dj_database_url.py
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,114 @@ def test_sqlite_memory_persistent_connection_variables(self):
assert "CONN_MAX_AGE" not in url
assert "CONN_HEALTH_CHECKS" not in url

def test_mongodb_parsing_least_args(self):
url = "mongodb://10.0.0.100"
url = dj_database_url.parse(url)

self.assertEqual(url["ENGINE"], "djongo")
self.assertEqual(url["NAME"], "db")
self.assertEqual(url["CLIENT"]["host"], "mongodb://10.0.0.100")

def test_mongodb_parsing_full_args(self):
url = "mongodb://foo:[email protected]:27017/database?enforceSchema=true&retryWrites=true&w=majority"
url = dj_database_url.parse(url)

self.assertEqual(url["ENGINE"], "djongo")
self.assertEqual(url["NAME"], "database")
self.assertEqual(
url["CLIENT"]["host"],
"mongodb://foo:[email protected]:27017/database"
"?retryWrites=true&w=majority",
)
self.assertTrue(url["ENFORCE_SCHEMA"])

def test_mongodb_unix_socket_parsing(self):
url = "mongodb://%2Fvar%2Frun%2Fmongo/foo"
url = dj_database_url.parse(url)

self.assertEqual(url["ENGINE"], "djongo")
self.assertEqual(url["NAME"], "foo")
self.assertEqual(url["CLIENT"]["host"], "mongodb://%2Fvar%2Frun%2Fmongo/foo")

def test_mongodb_parsing_with_special_characters(self):
url = "mongodb://%23user:%[email protected]/%23database"
url = dj_database_url.parse(url)

self.assertEqual(url["ENGINE"], "djongo")
self.assertEqual(url["NAME"], "#database")
self.assertEqual(
url["CLIENT"]["host"],
"mongodb://%23user:%[email protected]/%23database",
)

def test_mongodb_replica_set_with_members_on_different_machines(self):
url = "mongodb://db1.example.net,db2.example.com/?replicaSet=test"
url = dj_database_url.parse(url)

self.assertEqual(url["ENGINE"], "djongo")
self.assertEqual(url["NAME"], "db")
self.assertEqual(
url["CLIENT"]["host"],
"mongodb://db1.example.net,db2.example.com/?replicaSet=test",
)

def test_mongodb_replica_set_with_members_on_same_machine(self):
url = "mongodb://localhost,localhost:27018,localhost:27019/?replicaSet=test"
url = dj_database_url.parse(url)

self.assertEqual(url["ENGINE"], "djongo")
self.assertEqual(url["NAME"], "db")
self.assertEqual(
url["CLIENT"]["host"],
"mongodb://localhost,localhost:27018,localhost:27019/?replicaSet=test",
)

def test_mongodb_replica_set_with_read_distribution(self):
url = "mongodb://example1.com,example2.com,example3.com/?replicaSet=test&readPreference=secondary"
url = dj_database_url.parse(url)

self.assertEqual(url["ENGINE"], "djongo")
self.assertEqual(url["NAME"], "db")
self.assertEqual(
url["CLIENT"]["host"],
"mongodb://example1.com,example2.com,example3.com/"
"?replicaSet=test&readPreference=secondary",
)

def test_mongodb_shared_cluster(self):
url = "mongodb://router1.example.com:27017,router2.example2.com:27017,router3.example3.com:27017/"
url = dj_database_url.parse(url)

self.assertEqual(url["ENGINE"], "djongo")
self.assertEqual(url["NAME"], "db")
self.assertEqual(
url["CLIENT"]["host"],
"mongodb://router1.example.com:27017,router2.example2.com:27017,"
"router3.example3.com:27017/",
)

def test_mongodb_srv_parsing_least_args(self):
url = "mongodb+srv://server.example.com/database"
url = dj_database_url.parse(url)

self.assertEqual(url["ENGINE"], "djongo")
self.assertEqual(url["NAME"], "database")
self.assertEqual(
url["CLIENT"]["host"], "mongodb+srv://server.example.com/database"
)

def test_mongodb_srv_parsing_full_args(self):
url = "mongodb+srv://server.example.com/?enforceSchema=false&retryWrites=true&w=majority"
url = dj_database_url.parse(url)

self.assertEqual(url["ENGINE"], "djongo")
self.assertEqual(url["NAME"], "db")
self.assertEqual(
url["CLIENT"]["host"],
"mongodb+srv://server.example.com/?retryWrites=true&w=majority",
)
self.assertFalse(url["ENFORCE_SCHEMA"])

@mock.patch.dict(
os.environ,
{"DATABASE_URL": "postgres://user:[email protected]:5431/d8r8?"},
Expand All @@ -545,6 +653,10 @@ def test_bad_url_parsing(self):
with self.assertRaisesRegex(ValueError, "No support for 'foo'. We support: "):
dj_database_url.parse("foo://bar")

def test_bad_port_parsing(self):
with self.assertRaisesRegex(Exception, "Port parse error"):
dj_database_url.parse("mysql://foo:bar@localhost:65536/db")


if __name__ == "__main__":
unittest.main()