Skip to content
Merged
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
13 changes: 8 additions & 5 deletions .github/workflows/mongodb_settings.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import os

from django_mongodb_backend import parse_uri
from pymongo.uri_parser import parse_uri

if mongodb_uri := os.getenv("MONGODB_URI"):
db_settings = parse_uri(mongodb_uri, db_name="dummy")

db_settings = {
"ENGINE": "django_mongodb_backend",
"HOST": mongodb_uri,
}
# Workaround for https://github.com/mongodb-labs/mongo-orchestration/issues/268
if db_settings["USER"] and db_settings["PASSWORD"]:
db_settings["OPTIONS"].update({"tls": True, "tlsAllowInvalidCertificates": True})
uri = parse_uri(mongodb_uri)
if uri.get("username") and uri.get("password"):
db_settings["OPTIONS"] = {"tls": True, "tlsAllowInvalidCertificates": True}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add a comment here to explain what we are working around? It sounds like this could come out at some point based on @ShaneHarvey 's comment

I think one of the old concerns here was mixing of ssl and tls args. That should no longer be a problem if all drivers have dropped support for ssl=true.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also maybe of note we're only using PyMongo's parse_uri to get the username and password if they are in the URI.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought the linked issue described the issue well, so I don't see a need to repeat it. As far as I understand, the workaround won't be necessary after that issue is closed.

Copy link
Collaborator

@aclark4life aclark4life Sep 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's still confusing to use PyMongo's parse_uri to get the username and password in the context of deprecating our parse_uri because we can't rely on PyMongo's parse_uri when the query string arguments aren't regular characters e.g. I think PyMongo is doing something like this:


>>> URI = "mongodb://example.com/search?time=10:30"
>>> uri = parse_uri(URI)
>>> uri.get("hour")
10
>>> uri.get("minute")
30

Given that we've spent a lot of time getting to the status quo with parse_uri I want to make sure we're not adding more confusion in merging this feature.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parse_uri is only used for MongoDB connection strings, it has nothing to do with http://.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This logic is copied from:

"USER": uri.get("username"),
"PASSWORD": uri.get("password"),

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parse_uri is only used for MongoDB connection strings, it has nothing to do with http://.

I fixed the example but the scheme doesn't matter, whether it's mongodb://, https:// or alex:// the MongoDB URI spec was inspired by but does not conform to the URI spec. To make things even more confusing, we add in dj-database-url's stated purpose of "12 factor app support" and my stated purpose of django_mongodb_backend.parse_uri is like dj-database-url.

E.g.

Feature RFC 3986 MongoDB URI Difference 12‑Factor
One host in authority Yes Multiple hosts with commas Ensure your tooling/stack supports multi-host URIs when setting environment variables.
Reserved char encoding Must percent-encode Allowed unescaped in some drivers Always URL-encode usernames and passwords to avoid parsing errors in env var consumers.
Query param case sensitivity Case-sensitive Case-insensitive Use consistent lower-case keys in env vars for portability across drivers.
Semantics of query component Opaque to RFC MongoDB-specific options Document driver-specific options and keep them minimal in environment variables.
Scheme semantics Generic mongodb / mongodb+srv with DNS expansion Ensure DNS queries are allowed and resolvable in the target runtime environment.

</nit>

That said, we obviously only need to support the MongoDB URI and I think we are well on our way here with HOST.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's still confusing to use PyMongo's parse_uri to get the username and password in the context of deprecating our parse_uri

Are you still confused? I'm confused about what you're confused about. This is the same parsing as before, but without the intermediate call to django_mongodb_backend.parse_uri().

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we calling PyMongo's parse_uri? Is there a scenario in which we don't have to do that and can that scenario happen now before we merge this.

Copy link
Collaborator Author

@timgraham timgraham Sep 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To check if the URI contains a username and password (and therefore, this is an ssl connection). Jib authored this workaround. I can try to find another workaround or try to solve the issue in mongo-orchestration (I'm unsure if there is still a blocker there), but I don't see why this should hold up this PR.

DATABASES = {
"default": {**db_settings, "NAME": "djangotests"},
"other": {**db_settings, "NAME": "djangotests-other"},
Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,11 @@ setting like so:

```python
DATABASES = {
"default": django_mongodb_backend.parse_uri(
"<CONNECTION_STRING_URI>", db_name="example"
),
"default": {
"ENGINE": "django_mongodb_backend",
"HOST": "<CONNECTION_STRING_URI>",
"NAME": "db_name",
},
}
```

Expand Down
29 changes: 23 additions & 6 deletions django_mongodb_backend/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from pymongo.collection import Collection
from pymongo.driver_info import DriverInfo
from pymongo.mongo_client import MongoClient
from pymongo.uri_parser import parse_uri

from . import __version__ as django_mongodb_backend_version
from . import dbapi as Database
Expand Down Expand Up @@ -157,6 +158,18 @@ def __init__(self, settings_dict, alias=DEFAULT_DB_ALIAS):
self.in_atomic_block_mongo = False
# Current number of nested 'atomic' calls.
self.nested_atomics = 0
# If database "NAME" isn't specified, try to get it from HOST, if it's
# a connection string.
if self.settings_dict["NAME"] == "": # Empty string = unspecified; None = _nodb_cursor()
name_is_missing = True
host = self.settings_dict["HOST"]
if host.startswith(("mongodb://", "mongodb+srv://")):
uri = parse_uri(host)
if database := uri.get("database"):
self.settings_dict["NAME"] = database
name_is_missing = False
if name_is_missing:
raise ImproperlyConfigured('settings.DATABASES is missing the "NAME" value.')

def get_collection(self, name, **kwargs):
collection = Collection(self.database, name, **kwargs)
Expand All @@ -183,15 +196,19 @@ def init_connection_state(self):

def get_connection_params(self):
settings_dict = self.settings_dict
if not settings_dict["NAME"]:
raise ImproperlyConfigured('settings.DATABASES is missing the "NAME" value.')
return {
params = {
"host": settings_dict["HOST"] or None,
"port": int(settings_dict["PORT"] or 27017),
"username": settings_dict.get("USER"),
"password": settings_dict.get("PASSWORD"),
**settings_dict["OPTIONS"],
}
# MongoClient uses any of these parameters (including "OPTIONS" above)
# to override any corresponding values in a connection string "HOST".
if user := settings_dict.get("USER"):
params["username"] = user
if password := settings_dict.get("PASSWORD"):
params["password"] = password
if port := settings_dict.get("PORT"):
params["port"] = int(port)
return params

@async_unsafe
def get_new_connection(self, conn_params):
Expand Down
38 changes: 22 additions & 16 deletions docs/intro/configure.rst
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,26 @@ to match the first two numbers from your version.)
Configuring the ``DATABASES`` setting
=====================================

After you've set up a project, configure Django's :setting:`DATABASES` setting
similar to this::
After you've set up a project, configure Django's :setting:`DATABASES` setting.

If you have a connection string, you can provide it like this::

DATABASES = {
"default": {
"ENGINE": "django_mongodb_backend",
"HOST": "mongodb+srv://my_user:my_password@cluster0.example.mongodb.net/?retryWrites=true&w=majority&tls=false",
"NAME": "my_database",
},
}

.. versionchanged:: 5.2.1

Support for the connection string in ``"HOST"`` was added. Previous
versions recommended using :func:`~django_mongodb_backend.utils.parse_uri`.

Alternatively, you can separate the connection string so that your settings
look more like what you usually see with Django. This constructs a
:setting:`DATABASES` setting equivalent to the first example::

DATABASES = {
"default": {
Expand All @@ -117,7 +135,6 @@ similar to this::
"PASSWORD": "my_password",
"PORT": 27017,
"OPTIONS": {
# Example:
"retryWrites": "true",
"w": "majority",
"tls": "false",
Expand All @@ -128,8 +145,8 @@ similar to this::
For a localhost configuration, you can omit :setting:`HOST` or specify
``"HOST": "localhost"``.

:setting:`HOST` only needs a scheme prefix for SRV connections
(``mongodb+srv://``). A ``mongodb://`` prefix is never required.
If you provide a connection string in ``HOST``, any of the other values below
will override the values in the connection string.

:setting:`OPTIONS` is an optional dictionary of parameters that will be passed
to :class:`~pymongo.mongo_client.MongoClient`.
Expand All @@ -143,17 +160,6 @@ For a replica set or sharded cluster where you have multiple hosts, include
all of them in :setting:`HOST`, e.g.
``"mongodb://mongos0.example.com:27017,mongos1.example.com:27017"``.

Alternatively, if you prefer to simply paste in a MongoDB URI rather than parse
it into the format above, you can use
:func:`~django_mongodb_backend.utils.parse_uri`::

import django_mongodb_backend

MONGODB_URI = "mongodb+srv://my_user:my_password@cluster0.example.mongodb.net/myDatabase?retryWrites=true&w=majority&tls=false"
DATABASES["default"] = django_mongodb_backend.parse_uri(MONGODB_URI)

This constructs a :setting:`DATABASES` setting equivalent to the first example.

.. _configuring-database-routers-setting:

Configuring the ``DATABASE_ROUTERS`` setting
Expand Down
5 changes: 4 additions & 1 deletion docs/releases/5.2.x.rst
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ Django MongoDB Backend 5.2.x
New features
------------

- ...
- Allowed :ref:`specifying the MongoDB connection string
<configuring-databases-setting>` in ``DATABASES["HOST"]``, eliminating the
need to use :func:`~django_mongodb_backend.utils.parse_uri` to configure the
:setting:`DATABASES` setting.

Bug fixes
---------
Expand Down
93 changes: 92 additions & 1 deletion tests/backend_/test_base.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from unittest.mock import patch

from django.core.exceptions import ImproperlyConfigured
from django.db import connection
from django.db.backends.signals import connection_created
Expand All @@ -12,7 +14,96 @@ def test_database_name_empty(self):
settings["NAME"] = ""
msg = 'settings.DATABASES is missing the "NAME" value.'
with self.assertRaisesMessage(ImproperlyConfigured, msg):
DatabaseWrapper(settings).get_connection_params()
DatabaseWrapper(settings)

def test_database_name_empty_and_host_does_not_contain_database(self):
settings = connection.settings_dict.copy()
settings["NAME"] = ""
settings["HOST"] = "mongodb://localhost"
msg = 'settings.DATABASES is missing the "NAME" value.'
with self.assertRaisesMessage(ImproperlyConfigured, msg):
DatabaseWrapper(settings)

def test_database_name_parsed_from_host(self):
settings = connection.settings_dict.copy()
settings["NAME"] = ""
settings["HOST"] = "mongodb://localhost/db"
self.assertEqual(DatabaseWrapper(settings).settings_dict["NAME"], "db")

def test_database_name_parsed_from_srv_host(self):
settings = connection.settings_dict.copy()
settings["NAME"] = ""
settings["HOST"] = "mongodb+srv://localhost/db"
# patch() prevents a crash when PyMongo attempts to resolve the
# nonexistent SRV record.
with patch("dns.resolver.resolve"):
self.assertEqual(DatabaseWrapper(settings).settings_dict["NAME"], "db")

def test_database_name_not_overridden_by_host(self):
settings = connection.settings_dict.copy()
settings["NAME"] = "not overridden"
settings["HOST"] = "mongodb://localhost/db"
self.assertEqual(DatabaseWrapper(settings).settings_dict["NAME"], "not overridden")


class GetConnectionParamsTests(SimpleTestCase):
def test_host(self):
settings = connection.settings_dict.copy()
settings["HOST"] = "host"
params = DatabaseWrapper(settings).get_connection_params()
self.assertEqual(params["host"], "host")

def test_host_empty(self):
settings = connection.settings_dict.copy()
settings["HOST"] = ""
params = DatabaseWrapper(settings).get_connection_params()
self.assertIsNone(params["host"])

def test_user(self):
settings = connection.settings_dict.copy()
settings["USER"] = "user"
params = DatabaseWrapper(settings).get_connection_params()
self.assertEqual(params["username"], "user")

def test_password(self):
settings = connection.settings_dict.copy()
settings["PASSWORD"] = "password" # noqa: S105
params = DatabaseWrapper(settings).get_connection_params()
self.assertEqual(params["password"], "password")

def test_port(self):
settings = connection.settings_dict.copy()
settings["PORT"] = 123
params = DatabaseWrapper(settings).get_connection_params()
self.assertEqual(params["port"], 123)

def test_port_as_string(self):
settings = connection.settings_dict.copy()
settings["PORT"] = "123"
params = DatabaseWrapper(settings).get_connection_params()
self.assertEqual(params["port"], 123)

def test_options(self):
settings = connection.settings_dict.copy()
settings["OPTIONS"] = {"extra": "option"}
params = DatabaseWrapper(settings).get_connection_params()
self.assertEqual(params["extra"], "option")

def test_unspecified_settings_omitted(self):
settings = connection.settings_dict.copy()
# django.db.utils.ConnectionHandler sets unspecified values to an empty
# string.
settings.update(
{
"USER": "",
"PASSWORD": "",
"PORT": "",
}
)
params = DatabaseWrapper(settings).get_connection_params()
self.assertNotIn("username", params)
self.assertNotIn("password", params)
self.assertNotIn("port", params)


class DatabaseWrapperConnectionTests(TestCase):
Expand Down