Skip to content

Commit 862c2cd

Browse files
committed
INTPYTHON-743 Allow using MongoDB connection string in DATABASES["HOST"]
1 parent 5982f6b commit 862c2cd

File tree

6 files changed

+113
-33
lines changed

6 files changed

+113
-33
lines changed

.github/workflows/mongodb_settings.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
import os
22

3-
from django_mongodb_backend import parse_uri
3+
from pymongo.uri_parser import parse_uri
44

55
if mongodb_uri := os.getenv("MONGODB_URI"):
6-
db_settings = parse_uri(mongodb_uri, db_name="dummy")
7-
6+
db_settings = {
7+
"ENGINE": "django_mongodb_backend",
8+
"HOST": mongodb_uri,
9+
}
810
# Workaround for https://github.com/mongodb-labs/mongo-orchestration/issues/268
9-
if db_settings["USER"] and db_settings["PASSWORD"]:
10-
db_settings["OPTIONS"].update({"tls": True, "tlsAllowInvalidCertificates": True})
11+
uri = parse_uri(mongodb_uri)
12+
if uri.get("username") and uri.get("password"):
13+
db_settings["OPTIONS"] = {"tls": True, "tlsAllowInvalidCertificates": True}
1114
DATABASES = {
1215
"default": {**db_settings, "NAME": "djangotests"},
1316
"other": {**db_settings, "NAME": "djangotests-other"},

README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,11 @@ setting like so:
4545

4646
```python
4747
DATABASES = {
48-
"default": django_mongodb_backend.parse_uri(
49-
"<CONNECTION_STRING_URI>", db_name="example"
50-
),
48+
"default": {
49+
"ENGINE": "django_mongodb_backend",
50+
"HOST": "<CONNECTION_STRING_URI>",
51+
"NAME": "db_name",
52+
},
5153
}
5254
```
5355

django_mongodb_backend/base.py

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from pymongo.collection import Collection
1212
from pymongo.driver_info import DriverInfo
1313
from pymongo.mongo_client import MongoClient
14+
from pymongo.uri_parser import parse_uri
1415

1516
from . import __version__ as django_mongodb_backend_version
1617
from . import dbapi as Database
@@ -157,6 +158,18 @@ def __init__(self, settings_dict, alias=DEFAULT_DB_ALIAS):
157158
self.in_atomic_block_mongo = False
158159
# Current number of nested 'atomic' calls.
159160
self.nested_atomics = 0
161+
# If database "NAME" isn't specified, try to get it from HOST, if it's
162+
# a connection string.
163+
if self.settings_dict["NAME"] == "": # Empty string = unspecified; None = _nodb_cursor()
164+
name_is_missing = True
165+
host = self.settings_dict["HOST"]
166+
if host.startswith(("mongodb://", "mongodb+srv://")):
167+
uri = parse_uri(host)
168+
if database := uri.get("database"):
169+
self.settings_dict["NAME"] = database
170+
name_is_missing = False
171+
if name_is_missing:
172+
raise ImproperlyConfigured('settings.DATABASES is missing the "NAME" value.')
160173

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

184197
def get_connection_params(self):
185198
settings_dict = self.settings_dict
186-
if not settings_dict["NAME"]:
187-
raise ImproperlyConfigured('settings.DATABASES is missing the "NAME" value.')
188-
return {
199+
params = {
189200
"host": settings_dict["HOST"] or None,
190-
"port": int(settings_dict["PORT"] or 27017),
191-
"username": settings_dict.get("USER"),
192-
"password": settings_dict.get("PASSWORD"),
193201
**settings_dict["OPTIONS"],
194202
}
203+
# MongoClient uses any of these parameters (including "OPTIONS" above)
204+
# to override any corresponding values in a connection string "HOST".
205+
if user := settings_dict.get("USER"):
206+
params["username"] = user
207+
if password := settings_dict.get("PASSWORD"):
208+
params["password"] = password
209+
if port := settings_dict.get("PORT"):
210+
params["port"] = int(port)
211+
return params
195212

196213
@async_unsafe
197214
def get_new_connection(self, conn_params):

docs/intro/configure.rst

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -105,8 +105,26 @@ to match the first two numbers from your version.)
105105
Configuring the ``DATABASES`` setting
106106
=====================================
107107

108-
After you've set up a project, configure Django's :setting:`DATABASES` setting
109-
similar to this::
108+
After you've set up a project, configure Django's :setting:`DATABASES` setting.
109+
110+
If you have a connection string, you can provide it like this::
111+
112+
DATABASES = {
113+
"default": {
114+
"ENGINE": "django_mongodb_backend",
115+
"HOST": "mongodb+srv://my_user:[email protected]/?retryWrites=true&w=majority&tls=false",
116+
"NAME": "my_database",
117+
},
118+
}
119+
120+
.. versionchanged:: 5.2.1
121+
122+
Support for the connection string in ``"HOST"`` was added. Previous
123+
versions recommended using :func:`~django_mongodb_backend.utils.parse_uri`.
124+
125+
Alternatively, you can separate the connection string so that your settings
126+
look more like what you usually see with Django. This constructs a
127+
:setting:`DATABASES` setting equivalent to the first example::
110128

111129
DATABASES = {
112130
"default": {
@@ -117,7 +135,6 @@ similar to this::
117135
"PASSWORD": "my_password",
118136
"PORT": 27017,
119137
"OPTIONS": {
120-
# Example:
121138
"retryWrites": "true",
122139
"w": "majority",
123140
"tls": "false",
@@ -128,8 +145,8 @@ similar to this::
128145
For a localhost configuration, you can omit :setting:`HOST` or specify
129146
``"HOST": "localhost"``.
130147

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

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

146-
Alternatively, if you prefer to simply paste in a MongoDB URI rather than parse
147-
it into the format above, you can use
148-
:func:`~django_mongodb_backend.utils.parse_uri`::
149-
150-
import django_mongodb_backend
151-
152-
MONGODB_URI = "mongodb+srv://my_user:[email protected]/myDatabase?retryWrites=true&w=majority&tls=false"
153-
DATABASES["default"] = django_mongodb_backend.parse_uri(MONGODB_URI)
154-
155-
This constructs a :setting:`DATABASES` setting equivalent to the first example.
156-
157163
.. _configuring-database-routers-setting:
158164

159165
Configuring the ``DATABASE_ROUTERS`` setting

docs/releases/5.2.x.rst

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@ Django MongoDB Backend 5.2.x
1010
New features
1111
------------
1212

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

1518
Bug fixes
1619
---------

tests/backend_/test_base.py

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from unittest.mock import patch
2+
13
from django.core.exceptions import ImproperlyConfigured
24
from django.db import connection
35
from django.db.backends.signals import connection_created
@@ -6,14 +8,45 @@
68
from django_mongodb_backend.base import DatabaseWrapper
79

810

9-
class GetConnectionParamsTests(SimpleTestCase):
11+
class DatabaseWrapperTests(SimpleTestCase):
1012
def test_database_name_empty(self):
1113
settings = connection.settings_dict.copy()
1214
settings["NAME"] = ""
1315
msg = 'settings.DATABASES is missing the "NAME" value.'
1416
with self.assertRaisesMessage(ImproperlyConfigured, msg):
15-
DatabaseWrapper(settings).get_connection_params()
17+
DatabaseWrapper(settings)
18+
19+
def test_database_name_empty_and_host_does_not_contain_database(self):
20+
settings = connection.settings_dict.copy()
21+
settings["NAME"] = ""
22+
settings["HOST"] = "mongodb://localhost"
23+
msg = 'settings.DATABASES is missing the "NAME" value.'
24+
with self.assertRaisesMessage(ImproperlyConfigured, msg):
25+
DatabaseWrapper(settings)
26+
27+
def test_database_name_parsed_from_host(self):
28+
settings = connection.settings_dict.copy()
29+
settings["NAME"] = ""
30+
settings["HOST"] = "mongodb://localhost/db"
31+
self.assertEqual(DatabaseWrapper(settings).settings_dict["NAME"], "db")
1632

33+
def test_database_name_parsed_from_srv_host(self):
34+
settings = connection.settings_dict.copy()
35+
settings["NAME"] = ""
36+
settings["HOST"] = "mongodb+srv://localhost/db"
37+
# patch() prevents a crash when PyMongo attempts to resolve the
38+
# nonexistent SRV record.
39+
with patch("dns.resolver.resolve"):
40+
self.assertEqual(DatabaseWrapper(settings).settings_dict["NAME"], "db")
41+
42+
def test_database_name_not_overridden_by_host(self):
43+
settings = connection.settings_dict.copy()
44+
settings["NAME"] = "not overridden"
45+
settings["HOST"] = "mongodb://localhost/db"
46+
self.assertEqual(DatabaseWrapper(settings).settings_dict["NAME"], "not overridden")
47+
48+
49+
class GetConnectionParamsTests(SimpleTestCase):
1750
def test_host(self):
1851
settings = connection.settings_dict.copy()
1952
settings["HOST"] = "host"
@@ -56,6 +89,22 @@ def test_options(self):
5689
params = DatabaseWrapper(settings).get_connection_params()
5790
self.assertEqual(params["extra"], "option")
5891

92+
def test_unspecified_settings_omitted(self):
93+
settings = connection.settings_dict.copy()
94+
# django.db.utils.ConnectionHandler sets unspecified values to an empty
95+
# string.
96+
settings.update(
97+
{
98+
"USER": "",
99+
"PASSWORD": "",
100+
"PORT": "",
101+
}
102+
)
103+
params = DatabaseWrapper(settings).get_connection_params()
104+
self.assertNotIn("username", params)
105+
self.assertNotIn("password", params)
106+
self.assertNotIn("port", params)
107+
59108

60109
class DatabaseWrapperConnectionTests(TestCase):
61110
def test_set_autocommit(self):

0 commit comments

Comments
 (0)