diff --git a/.evergreen/run-tests.sh b/.evergreen/run-tests.sh index 700f9bf98..46f02be16 100644 --- a/.evergreen/run-tests.sh +++ b/.evergreen/run-tests.sh @@ -3,13 +3,13 @@ set -eux # Install django-mongodb-backend -/opt/python/3.10/bin/python3 -m venv venv +/opt/python/3.12/bin/python3 -m venv venv . venv/bin/activate python -m pip install -U pip pip install -e . # Install django and test dependencies -git clone --branch mongodb-5.2.x https://github.com/mongodb-forks/django django_repo +git clone --branch mongodb-6.0.x https://github.com/mongodb-forks/django django_repo pushd django_repo/tests/ pip install -e .. pip install -r requirements/py3.txt diff --git a/.github/workflows/linters.yml b/.github/workflows/linters.yml index 144bde09d..34a45f5c2 100644 --- a/.github/workflows/linters.yml +++ b/.github/workflows/linters.yml @@ -17,7 +17,7 @@ jobs: persist-credentials: false - uses: actions/setup-python@v6 with: - python-version: '3.10' + python-version: '3.12' cache: 'pip' cache-dependency-path: 'pyproject.toml' - name: Install Python dependencies @@ -39,7 +39,7 @@ jobs: with: cache: 'pip' cache-dependency-path: 'pyproject.toml' - python-version: '3.10' + python-version: '3.12' - name: Install dependencies run: | pip install -U pip diff --git a/.github/workflows/test-python-atlas.yml b/.github/workflows/test-python-atlas.yml index 643ec0424..bbda0f9f4 100644 --- a/.github/workflows/test-python-atlas.yml +++ b/.github/workflows/test-python-atlas.yml @@ -33,7 +33,7 @@ jobs: uses: actions/checkout@v6 with: repository: 'mongodb-forks/django' - ref: 'mongodb-5.2.x' + ref: 'mongodb-6.0.x' path: 'django_repo' persist-credentials: false - name: Install system packages for Django's Python test dependencies diff --git a/.github/workflows/test-python-geo.yml b/.github/workflows/test-python-geo.yml index 2b18ec867..e76818d7c 100644 --- a/.github/workflows/test-python-geo.yml +++ b/.github/workflows/test-python-geo.yml @@ -34,7 +34,7 @@ jobs: uses: actions/checkout@v6 with: repository: 'mongodb-forks/django' - ref: 'mongodb-5.2.x' + ref: 'mongodb-6.0.x' path: 'django_repo' persist-credentials: false - name: Install system packages for Django's Python test dependencies diff --git a/.github/workflows/test-python.yml b/.github/workflows/test-python.yml index 47d7c523e..19173239a 100644 --- a/.github/workflows/test-python.yml +++ b/.github/workflows/test-python.yml @@ -33,7 +33,7 @@ jobs: uses: actions/checkout@v6 with: repository: 'mongodb-forks/django' - ref: 'mongodb-5.2.x' + ref: 'mongodb-6.0.x' path: 'django_repo' persist-credentials: false - name: Install system packages for Django's Python test dependencies diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 1ab8dc416..055443596 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -21,6 +21,6 @@ python: - docs build: - os: ubuntu-22.04 + os: ubuntu-24.04 tools: - python: "3.11" + python: "3.12" diff --git a/README.md b/README.md index 66e98d501..0fb234009 100644 --- a/README.md +++ b/README.md @@ -17,21 +17,21 @@ https://django-mongodb-backend.readthedocs.io/en/latest/. ### Install Use the version of `django-mongodb-backend` that corresponds to your version of -Django. For example, to get the latest compatible release for Django 5.2.x: +Django. For example, to get the latest compatible release for Django 6.0.x: ```bash -pip install django-mongodb-backend==5.2.* +pip install django-mongodb-backend==6.0.* ``` ### Create a project From your shell, run the following command to create a new Django project called `example` using our project template. Make sure the end of the template -URL corresponds to your version of Django (e.g. `5.2.x.zip` for any Django -5.2.x version). +URL corresponds to your version of Django (e.g. `6.0.x.zip` for any Django +6.0.x version). ```bash -django-admin startproject example --template https://github.com/mongodb-labs/django-mongodb-project/archive/refs/heads/5.2.x.zip +django-admin startproject example --template https://github.com/mongodb-labs/django-mongodb-project/archive/refs/heads/6.0.x.zip ``` You can check what version of Django you're using with: diff --git a/django_mongodb_backend/__init__.py b/django_mongodb_backend/__init__.py index 1a22f5033..6f44f5e11 100644 --- a/django_mongodb_backend/__init__.py +++ b/django_mongodb_backend/__init__.py @@ -1,8 +1,8 @@ -__version__ = "5.2.4.dev0" +__version__ = "6.0.0.dev0" # Check Django compatibility before other imports which may fail if the # wrong version of Django is installed. -from .utils import check_django_compatability, parse_uri +from .utils import check_django_compatability check_django_compatability() @@ -15,8 +15,6 @@ from .lookups import register_lookups # noqa: E402 from .query import register_nodes # noqa: E402 -__all__ = ["parse_uri"] - register_aggregates() register_checks() register_expressions() diff --git a/django_mongodb_backend/aggregates.py b/django_mongodb_backend/aggregates.py index 1262f14b4..7d8808d86 100644 --- a/django_mongodb_backend/aggregates.py +++ b/django_mongodb_backend/aggregates.py @@ -1,4 +1,11 @@ -from django.db.models.aggregates import Aggregate, Count, StdDev, Variance +from django.db import NotSupportedError +from django.db.models.aggregates import ( + Aggregate, + Count, + StdDev, + StringAgg, + Variance, +) from django.db.models.expressions import Case, Value, When from django.db.models.lookups import IsNull from django.db.models.sql.where import WhereNode @@ -11,13 +18,19 @@ def aggregate(self, compiler, connection, operator=None, resolve_inner_expression=False): agg_expression, *_ = self.get_source_expressions() - if self.filter: - agg_expression = Case( - When(self.filter, then=agg_expression), - # Skip rows that don't meet the criteria. - default=Remove(), - ) - lhs_mql = agg_expression.as_mql(compiler, connection, as_expr=True) + lhs_mql = None + if self.filter is not None: + try: + lhs_mql = self.filter.as_mql(compiler, connection, as_expr=True) + except NotSupportedError: + # Generate a CASE statement for this AggregateFilter. + agg_expression = Case( + When(self.filter.condition, then=agg_expression), + # Skip rows that don't meet the criteria. + default=Remove(), + ) + if lhs_mql is None: + lhs_mql = agg_expression.as_mql(compiler, connection, as_expr=True) if resolve_inner_expression: return lhs_mql operator = operator or MONGO_AGGREGATIONS.get(self.__class__, self.function.lower()) @@ -32,18 +45,30 @@ def count(self, compiler, connection, resolve_inner_expression=False): """ agg_expression, *_ = self.get_source_expressions() if not self.distinct or resolve_inner_expression: + lhs_mql = None conditions = [IsNull(agg_expression, False)] if self.filter: - conditions.append(self.filter) - inner_expression = Case( - When(WhereNode(conditions), then=agg_expression if self.distinct else Value(1)), - # Skip rows that don't meet the criteria. - default=Remove(), - ) - inner_expression = inner_expression.as_mql(compiler, connection, as_expr=True) + try: + lhs_mql = self.filter.as_mql(compiler, connection, as_expr=True) + except NotSupportedError: + # Generate a CASE statement for this AggregateFilter. + conditions.append(self.filter.condition) + condition = When( + WhereNode(conditions), + then=agg_expression if self.distinct else Value(1), + ) + inner_expression = Case(condition, default=Remove()) + else: + inner_expression = Case( + When(WhereNode(conditions), then=agg_expression if self.distinct else Value(1)), + # Skip rows that don't meet the criteria. + default=Remove(), + ) + if lhs_mql is None: + lhs_mql = inner_expression.as_mql(compiler, connection, as_expr=True) if resolve_inner_expression: - return inner_expression - return {"$sum": inner_expression} + return lhs_mql + return {"$sum": lhs_mql} # If distinct=True or resolve_inner_expression=False, sum the size of the # set. return {"$size": agg_expression.as_mql(compiler, connection, as_expr=True)} @@ -57,8 +82,13 @@ def stddev_variance(self, compiler, connection): return aggregate(self, compiler, connection, operator=operator) +def string_agg(self, compiler, connection): # noqa: ARG001 + raise NotSupportedError("StringAgg is not supported.") + + def register_aggregates(): Aggregate.as_mql_expr = aggregate Count.as_mql_expr = count StdDev.as_mql_expr = stddev_variance + StringAgg.as_mql_expr = string_agg Variance.as_mql_expr = stddev_variance diff --git a/django_mongodb_backend/features.py b/django_mongodb_backend/features.py index 18a048bf6..265ed3a5c 100644 --- a/django_mongodb_backend/features.py +++ b/django_mongodb_backend/features.py @@ -99,6 +99,13 @@ class DatabaseFeatures(GISFeatures, BaseDatabaseFeatures): "model_fields.test_jsonfield.TestSaveLoad.test_bulk_update_custom_get_prep_value", # To debug: https://github.com/mongodb/django-mongodb-backend/issues/362 "constraints.tests.UniqueConstraintTests.test_validate_case_when", + # bulk_create() population of _order doesn't work because of ObjectId + # type mismatch when querying object_id CharField. + # https://github.com/django/django/commit/953095d1e603fe0f8f01175b1409ca23818dcff9 + "contenttypes_tests.test_order_with_respect_to.OrderWithRespectToGFKTests.test_bulk_create_allows_duplicate_order_values", + "contenttypes_tests.test_order_with_respect_to.OrderWithRespectToGFKTests.test_bulk_create_mixed_scenario", + "contenttypes_tests.test_order_with_respect_to.OrderWithRespectToGFKTests.test_bulk_create_respects_mixed_manual_order", + "contenttypes_tests.test_order_with_respect_to.OrderWithRespectToGFKTests.test_bulk_create_with_existing_children", } # $bitAnd, #bitOr, and $bitXor are new in MongoDB 6.3. _django_test_expected_failures_bitwise = { @@ -139,6 +146,7 @@ def django_test_expected_failures(self): "validation.test_unique.PerformUniqueChecksTest.test_unique_db_default", }, "Insert expressions aren't supported.": { + "basic.tests.ModelTest.test_save_expressions", "bulk_create.tests.BulkCreateTests.test_bulk_insert_now", "bulk_create.tests.BulkCreateTests.test_bulk_insert_expressions", "expressions.tests.BasicExpressionsTests.test_new_object_create", @@ -201,6 +209,7 @@ def django_test_expected_failures(self): "prefetch_related.tests.LookupOrderingTest.test_order", "prefetch_related.tests.MultiDbTests.test_using_is_honored_m2m", "prefetch_related.tests.MultiTableInheritanceTest", + "prefetch_related.tests.PrefetchRelatedMTICacheTests", "prefetch_related.tests.PrefetchRelatedTests", "prefetch_related.tests.ReadPrefetchedObjectsCacheTests", "prefetch_related.tests.Ticket21410Tests", @@ -563,6 +572,7 @@ def django_test_expected_failures(self): "Custom lookups are not supported.": { "custom_lookups.tests.BilateralTransformTests", "custom_lookups.tests.LookupTests.test_basic_lookup", + "custom_lookups.tests.LookupTests.test_custom_lookup_with_subquery", "custom_lookups.tests.LookupTests.test_custom_name_lookup", "custom_lookups.tests.LookupTests.test_div3_extract", "custom_lookups.tests.SubqueryTransformTests.test_subquery_usage", @@ -580,6 +590,16 @@ def django_test_expected_failures(self): "test_utils.tests.DisallowedDatabaseQueriesTests.test_disallowed_database_queries", "test_utils.tests.DisallowedDatabaseQueriesTests.test_disallowed_thread_database_connection", }, + "search lookup not supported on non-Atlas.": { + "expressions.tests.BasicExpressionsTests.test_lookups_subquery", + }, + "StringAgg is not supported.": { + "aggregation.tests.AggregateTestCase.test_distinct_on_stringagg", + "aggregation.tests.AggregateTestCase.test_string_agg_escapes_delimiter", + "aggregation.tests.AggregateTestCase.test_string_agg_filter", + "aggregation.tests.AggregateTestCase.test_string_agg_filter_in_subquery", + "aggregation.tests.AggregateTestCase.test_stringagg_default_value", + }, } @cached_property diff --git a/django_mongodb_backend/fields/json.py b/django_mongodb_backend/fields/json.py index fb369c8da..60908966f 100644 --- a/django_mongodb_backend/fields/json.py +++ b/django_mongodb_backend/fields/json.py @@ -24,7 +24,7 @@ def valid_path_key_name(key_name): # A lookup can use path syntax (field.subfield) unless it contains a dollar # sign or period. - return not any(char in key_name for char in ("$", ".")) + return not key_name.startswith("-") and not any(char in key_name for char in ("$", ".")) def build_json_mql_path(lhs, key_transforms, as_expr=False): @@ -36,7 +36,12 @@ def build_json_mql_path(lhs, key_transforms, as_expr=False): get_field = {"$getField": {"input": result, "field": key}} # Handle array indexing if the key is a digit. If key is something # like '001', it's not an array index despite isdigit() returning True. - if key.isdigit() and str(int(key)) == key: + try: + int(key) + is_digit = str(int(key)) == key + except ValueError: + is_digit = False + if is_digit: result = { "$cond": { "if": {"$isArray": result}, diff --git a/django_mongodb_backend/gis/operations.py b/django_mongodb_backend/gis/operations.py index b5d5df1d5..729ea197e 100644 --- a/django_mongodb_backend/gis/operations.py +++ b/django_mongodb_backend/gis/operations.py @@ -40,6 +40,7 @@ def gis_operators(self): "FromWKT", "GeoHash", "GeometryDistance", + "GeometryType", "Intersection", "IsEmpty", "IsValid", @@ -52,6 +53,7 @@ def gis_operators(self): "Perimeter", "PointOnSurface", "Reverse", + "Rotate", "Scale", "SnapToGrid", "SymDifference", diff --git a/django_mongodb_backend/query_utils.py b/django_mongodb_backend/query_utils.py index ccb55d49b..c06b42dc7 100644 --- a/django_mongodb_backend/query_utils.py +++ b/django_mongodb_backend/query_utils.py @@ -1,4 +1,4 @@ -from django.core.exceptions import FullResultSet +from django.core.exceptions import EmptyResultSet, FullResultSet from django.db.models import F from django.db.models.expressions import CombinedExpression, Func, Value from django.db.models.sql.query import Query @@ -19,6 +19,8 @@ def process_lhs(node, compiler, connection, as_expr=False): result.append(expr.as_mql(compiler, connection, as_expr=as_expr)) except FullResultSet: result.append(Value(True).as_mql(compiler, connection, as_expr=as_expr)) + except EmptyResultSet: + result.append(Value(None).as_mql(compiler, connection, as_expr=as_expr)) return result # node is a Transform with just one source expression, aliased as "lhs". if is_direct_value(node.lhs): diff --git a/django_mongodb_backend/utils.py b/django_mongodb_backend/utils.py index 0240250cf..8c68bf442 100644 --- a/django_mongodb_backend/utils.py +++ b/django_mongodb_backend/utils.py @@ -1,16 +1,13 @@ import copy import time -import warnings import django from django.conf import settings from django.core.exceptions import ImproperlyConfigured, ValidationError from django.db.backends.utils import logger -from django.utils.deprecation import RemovedInDjango60Warning from django.utils.functional import SimpleLazyObject from django.utils.text import format_lazy from django.utils.version import get_version_tuple -from pymongo.uri_parser import parse_uri as pymongo_parse_uri def check_django_compatability(): @@ -30,50 +27,6 @@ def check_django_compatability(): ) -def parse_uri(uri, *, db_name=None, options=None, test=None): - """ - Convert the given uri into a dictionary suitable for Django's DATABASES - setting. - """ - warnings.warn( - 'parse_uri() is deprecated. Put the connection string in DATABASES["HOST"] instead.', - RemovedInDjango60Warning, - stacklevel=2, - ) - uri = pymongo_parse_uri(uri) - host = None - port = None - if uri["fqdn"]: - # This is a SRV URI and the host is the fqdn. - host = f"mongodb+srv://{uri['fqdn']}" - else: - nodelist = uri.get("nodelist") - if len(nodelist) == 1: - host, port = nodelist[0] - elif len(nodelist) > 1: - host = ",".join([f"{host}:{port}" for host, port in nodelist]) - db_name = db_name or uri["database"] - if not db_name: - raise ImproperlyConfigured("You must provide the db_name parameter.") - opts = uri.get("options") - if options: - opts.update(options) - settings_dict = { - "ENGINE": "django_mongodb_backend", - "NAME": db_name, - "HOST": host, - "PORT": port, - "USER": uri.get("username"), - "PASSWORD": uri.get("password"), - "OPTIONS": opts, - } - if "authSource" not in settings_dict["OPTIONS"] and uri["database"]: - settings_dict["OPTIONS"]["authSource"] = uri["database"] - if test: - settings_dict["TEST"] = test - return settings_dict - - def prefix_validation_error(error, prefix, code, params): """ Prefix a validation error message while maintaining the existing diff --git a/docs/conf.py b/docs/conf.py index dfe3a3289..35f8ab351 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -42,8 +42,8 @@ intersphinx_mapping = { "django": ( - "https://docs.djangoproject.com/en/5.2/", - "https://docs.djangoproject.com/en/5.2/_objects/", + "https://docs.djangoproject.com/en/6.0/", + "https://docs.djangoproject.com/en/6.0/_objects/", ), "mongodb": ("https://www.mongodb.com/docs/languages/python/django-mongodb/v5.2/", None), "pymongo": ("https://www.mongodb.com/docs/languages/python/pymongo-driver/current/", None), diff --git a/docs/index.rst b/docs/index.rst index a5a60e84f..dc2124f85 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -2,7 +2,7 @@ Django MongoDB Backend ====================== -version 5.2.x for Django 5.2.x +version 6.0.x for Django 6.0.x .. rubric:: Everything you need to know about Django MongoDB Backend. diff --git a/docs/internals/contributing/unit-tests.rst b/docs/internals/contributing/unit-tests.rst index 1804fe273..6bdc5e9dd 100644 --- a/docs/internals/contributing/unit-tests.rst +++ b/docs/internals/contributing/unit-tests.rst @@ -35,13 +35,13 @@ Third, clone your fork and install it: Next, get and install a copy of MongoDB's Django fork. This fork has some test suite adaptions for Django MongoDB Backend. There is a branch for each -feature release of Django (e.g. ``mongodb-5.2.x`` below). +feature release of Django (e.g. ``mongodb-6.0.x`` below). .. code-block:: bash $ git clone https://github.com/mongodb-forks/django.git django-repo $ cd django-repo - $ git checkout -t origin/mongodb-5.2.x + $ git checkout -t origin/mongodb-6.0.x $ python -m pip install -e . $ python -m pip install -r tests/requirements/py3.txt diff --git a/docs/intro/configure.rst b/docs/intro/configure.rst index d915547da..2f94af342 100644 --- a/docs/intro/configure.rst +++ b/docs/intro/configure.rst @@ -18,9 +18,9 @@ project template: .. code-block:: bash - $ django-admin startproject mysite --template https://github.com/mongodb-labs/django-mongodb-project/archive/refs/heads/5.2.x.zip + $ django-admin startproject mysite --template https://github.com/mongodb-labs/django-mongodb-project/archive/refs/heads/6.0.x.zip -(If you're using a version of Django other than 5.2.x, replace the two numbers +(If you're using a version of Django other than 6.0.x, replace the two numbers to match the first two numbers from your version.) This template includes the following line in ``settings.py``:: @@ -85,22 +85,25 @@ third-party apps. For example: Creating Django applications ============================ -Whenever you run ``python manage.py startapp``, you must remove the line:: +To create a new application, use ``python manage.py startapp``. No extra steps +are necessary. - default_auto_field = 'django.db.models.BigAutoField' +.. versionchanged:: 6.0 -from the new application's ``apps.py`` file (or change it to reference -``"django_mongodb_backend.fields.ObjectIdAutoField"``). + In Django 5.2.x and older, whenever you run ``python manage.py startapp``, + you must remove the line:: -Alternatively, you can use the following :djadmin:`startapp` template which -includes this change: + default_auto_field = 'django.db.models.BigAutoField' -.. code-block:: bash + from the new application's ``apps.py`` file (or change it to reference + ``"django_mongodb_backend.fields.ObjectIdAutoField"``). - $ python manage.py startapp myapp --template https://github.com/mongodb-labs/django-mongodb-app/archive/refs/heads/5.2.x.zip + Alternatively, you can use the following :djadmin:`startapp` template which + includes this change: -(If you're using a version of Django other than 5.2.x, replace the two numbers -to match the first two numbers from your version.) + .. code-block:: bash + + $ python manage.py startapp myapp --template https://github.com/mongodb-labs/django-mongodb-app/archive/refs/heads/5.2.x.zip .. _configuring-databases-setting: @@ -122,7 +125,7 @@ If you have a connection string, you can provide it like this:: .. versionchanged:: 5.2.1 Support for the connection string in ``"HOST"`` was added. Previous - versions recommended using :func:`~django_mongodb_backend.utils.parse_uri`. + versions recommended using ``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 diff --git a/docs/intro/install.rst b/docs/intro/install.rst index 019bc877e..17d6b3e95 100644 --- a/docs/intro/install.rst +++ b/docs/intro/install.rst @@ -3,11 +3,11 @@ Installing Django MongoDB Backend ================================= Use the version of ``django-mongodb-backend`` that corresponds to your version -of Django. For example, to get the latest compatible release for Django 5.2.x: +of Django. For example, to get the latest compatible release for Django 6.0.x: .. code-block:: bash - $ pip install django-mongodb-backend==5.2.* + $ pip install django-mongodb-backend==6.0.* The minor release number of Django doesn't correspond to the minor release number of ``django-mongodb-backend``. Use the latest minor release of each. diff --git a/docs/ref/utils.rst b/docs/ref/utils.rst index 5cdb0ccf3..144c4969c 100644 --- a/docs/ref/utils.rst +++ b/docs/ref/utils.rst @@ -9,42 +9,4 @@ This document covers the public API parts of ``django_mongodb_backend.utils``. Most of the module's contents are designed for internal use and only the following parts can be considered stable. -``parse_uri()`` -=============== - -.. function:: parse_uri(uri, db_name=None, options=None, test=None) - - .. deprecated:: 5.2.2 - - ``parse_uri()`` is deprecated in favor of putting the connection string - in ``DATABASES["HOST"]``. See :ref:`the deprecation timeline - ` for upgrade instructions. - - Parses a MongoDB `connection string`_ into a dictionary suitable for - Django's :setting:`DATABASES` setting. - - .. _connection string: https://www.mongodb.com/docs/manual/reference/connection-string/ - - Example:: - - import django_mongodb_backend - - MONGODB_URI = "mongodb+srv://my_user:my_password@cluster0.example.mongodb.net/defaultauthdb?retryWrites=true&w=majority&tls=false" - DATABASES["default"] = django_mongodb_backend.parse_uri(MONGODB_URI, db_name="example") - - You must specify ``db_name`` (the :setting:`NAME` of your database) if the - URI doesn't specify ``defaultauthdb``. - - You can use the parameters to customize the resulting :setting:`DATABASES` - setting: - - - Use ``options`` to provide a dictionary of parameters to - :class:`~pymongo.mongo_client.MongoClient`. These will be merged with - (and, in the case of duplicates, take precedence over) any options - specified in the URI. - - - Use ``test`` to provide a dictionary of settings for test databases in - the format of :setting:`TEST `. - - But for maximum flexibility, construct :setting:`DATABASES` manually as - described in :ref:`configuring-databases-setting`. +(Currently there is nothing.) diff --git a/docs/releases/5.0.x.rst b/docs/releases/5.0.x.rst index c27c5f39f..06b29bd16 100644 --- a/docs/releases/5.0.x.rst +++ b/docs/releases/5.0.x.rst @@ -16,7 +16,7 @@ Django MongoDB Backend 5.0.x `. - Added :doc:`async ` support. - Added the ``db_name`` parameter to - :func:`~django_mongodb_backend.utils.parse_uri`. + ``django_mongodb_backend.utils.parse_uri()``. - Added ``django_mongodb_backend.routers.MongoRouter`` to allow :djadmin:`dumpdata` to ignore embedded models. See :ref:`configuring-database-routers-setting`. diff --git a/docs/releases/5.1.x.rst b/docs/releases/5.1.x.rst index e92ba52dc..a12580d2a 100644 --- a/docs/releases/5.1.x.rst +++ b/docs/releases/5.1.x.rst @@ -61,7 +61,7 @@ Django MongoDB Backend 5.1.x `. - Added :doc:`async ` support. - Added the ``db_name`` parameter to - :func:`~django_mongodb_backend.utils.parse_uri`. + ``django_mongodb_backend.utils.parse_uri()``. - Added ``django_mongodb_backend.routers.MongoRouter`` to allow :djadmin:`dumpdata` to ignore embedded models. See :ref:`configuring-database-routers-setting`. diff --git a/docs/releases/5.2.x.rst b/docs/releases/5.2.x.rst index f4f60e591..ea4bb4b00 100644 --- a/docs/releases/5.2.x.rst +++ b/docs/releases/5.2.x.rst @@ -23,6 +23,8 @@ Performance improvements - Improved ``QuerySet`` join (``$lookup``) performance by pushing complex conditions from the ``WHERE`` (``$match``) clause to the ``$lookup`` stage. +.. _django-mongodb-backend-5.2.3: + 5.2.3 ===== @@ -95,7 +97,7 @@ New features - Allowed :ref:`specifying the MongoDB connection string ` in ``DATABASES["HOST"]``, eliminating the - need to use :func:`~django_mongodb_backend.utils.parse_uri` to configure the + need to use ``django_mongodb_backend.utils.parse_uri()`` to configure the :setting:`DATABASES` setting. Bug fixes @@ -144,7 +146,7 @@ New features - Added support for :doc:`Atlas Search queries `. - Added subquery support for :class:`~.fields.EmbeddedModelArrayField`. - Added the ``options`` parameter to - :func:`~django_mongodb_backend.utils.parse_uri`. + ``django_mongodb_backend.utils.parse_uri()``. - Added support for :ref:`database transactions `. - Added :class:`~.fields.PolymorphicEmbeddedModelField` and :class:`~.fields.PolymorphicEmbeddedModelArrayField` for storing a model @@ -231,7 +233,7 @@ Backwards incompatible changes - The minimum supported version of ``pymongo`` is increased from 4.6 to 4.7. - The ``conn_max_age`` parameter of - :func:`~django_mongodb_backend.utils.parse_uri` is removed because persistent + ``django_mongodb_backend.utils.parse_uri()`` is removed because persistent connections are now used by default. Bug fixes diff --git a/docs/releases/6.0.x.rst b/docs/releases/6.0.x.rst new file mode 100644 index 000000000..38209a143 --- /dev/null +++ b/docs/releases/6.0.x.rst @@ -0,0 +1,20 @@ +============================ +Django MongoDB Backend 6.0.x +============================ + +6.0.0 +===== + +*Unreleased* + +Initial release from the state of :ref:`django-mongodb-backend 5.2.3 +`. + +Regarding new features in Django 6.0, the :class:`django.db.models.StringAgg` +function isn't supported. + +Backwards incompatible changes +------------------------------ + +- ``django_mongodb_backend.utils.parse_uri()`` is removed as per the + :ref:`deprecation timeline `. diff --git a/docs/releases/index.rst b/docs/releases/index.rst index bc14e221e..2856e74db 100644 --- a/docs/releases/index.rst +++ b/docs/releases/index.rst @@ -5,12 +5,13 @@ Release notes The release notes will tell you what's new in each version and will also describe any backwards-incompatible changes. -Below are release notes through Django MongoDB backend 5.2.x. Newer versions of +Below are release notes through Django MongoDB backend 6.0.x. Newer versions of the documentation contain the release notes for any later releases. .. toctree:: :maxdepth: 1 + 6.0.x 5.2.x 5.1.x 5.0.x diff --git a/docs/topics/known-issues.rst b/docs/topics/known-issues.rst index e4c401a3c..01259b49a 100644 --- a/docs/topics/known-issues.rst +++ b/docs/topics/known-issues.rst @@ -45,6 +45,9 @@ Querying :meth:`~django.db.models.query.QuerySet.update` do not support queries that span multiple collections. +- The :class:`~django.db.models.StringAgg` aggregation function isn't + supported. + - When querying :class:`~django.db.models.JSONField`: - There is no way to distinguish between a JSON ``"null"`` (represented by diff --git a/pyproject.toml b/pyproject.toml index 0549f02ef..acecf20fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ dynamic = ["version", "dependencies"] description = "Django MongoDB Backend" readme = "README.md" license = {file="LICENSE"} -requires-python = ">=3.10" +requires-python = ">=3.12" authors = [ { name = "The MongoDB Python Team" }, ] @@ -21,14 +21,12 @@ keywords = [ classifiers = [ "Development Status :: 5 - Production/Stable", "Framework :: Django", - "Framework :: Django :: 5.2", + "Framework :: Django :: 6.0", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", diff --git a/requirements.txt b/requirements.txt index b69357196..0ef2d8e28 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ -django>=5.2,<6.0 +django>=6.0,<6.1 pymongo>=4.7,<5.0 diff --git a/tests/backend_/utils/__init__.py b/tests/aggregation_/__init__.py similarity index 100% rename from tests/backend_/utils/__init__.py rename to tests/aggregation_/__init__.py diff --git a/tests/aggregation_/models.py b/tests/aggregation_/models.py new file mode 100644 index 000000000..6a161aac4 --- /dev/null +++ b/tests/aggregation_/models.py @@ -0,0 +1,25 @@ +from django.db import models + + +class Author(models.Model): + name = models.CharField(max_length=100) + age = models.IntegerField() + friends = models.ManyToManyField("self", blank=True) + rating = models.FloatField(null=True) + + def __str__(self): + return self.name + + +class Book(models.Model): + isbn = models.CharField(max_length=9) + name = models.CharField(max_length=255) + pages = models.IntegerField() + rating = models.FloatField() + price = models.DecimalField(decimal_places=2, max_digits=6) + authors = models.ManyToManyField(Author) + contact = models.ForeignKey(Author, models.CASCADE, related_name="book_contact_set") + pubdate = models.DateField() + + def __str__(self): + return self.name diff --git a/tests/aggregation_/test_stringagg.py b/tests/aggregation_/test_stringagg.py new file mode 100644 index 000000000..908a3f41d --- /dev/null +++ b/tests/aggregation_/test_stringagg.py @@ -0,0 +1,11 @@ +from django.db import NotSupportedError +from django.db.models import StringAgg, Value +from django.test import TestCase + +from .models import Author + + +class StringAggTests(TestCase): + def test_not_supprted(self): + with self.assertRaisesMessage(NotSupportedError, "StringAgg is not supported."): + list(Author.objects.aggregate(all_names=StringAgg("name", Value(",")))) diff --git a/tests/aggregation_/tests.py b/tests/aggregation_/tests.py new file mode 100644 index 000000000..4abfcb733 --- /dev/null +++ b/tests/aggregation_/tests.py @@ -0,0 +1,72 @@ +import datetime +from decimal import Decimal + +from django.db.models import Count, Max, Q +from django.test import TestCase + +from .models import Author, Book + + +class FilteredAggregateTests(TestCase): + @classmethod + def setUpTestData(cls): + cls.a1 = Author.objects.create(name="test", age=40) + cls.a2 = Author.objects.create(name="test2", age=60) + cls.a3 = Author.objects.create(name="test3", age=40) + cls.b1 = Book.objects.create( + isbn="159059725", + name="The Definitive Guide to Django: Web Development Done Right", + pages=447, + rating=4.5, + price=Decimal("30.00"), + contact=cls.a1, + pubdate=datetime.date(2007, 12, 6), + ) + cls.b2 = Book.objects.create( + isbn="067232959", + name="Sams Teach Yourself Django in 24 Hours", + pages=528, + rating=3.0, + price=Decimal("30.00"), + contact=cls.a2, + pubdate=datetime.date(2008, 3, 3), + ) + cls.b3 = Book.objects.create( + isbn="159059996", + name="Practical Django Projects", + pages=600, + rating=40.5, + price=Decimal("30.00"), + contact=cls.a3, + pubdate=datetime.date(2008, 6, 23), + ) + cls.a1.friends.add(cls.a2) + cls.a1.friends.add(cls.a3) + cls.b1.authors.add(cls.a1) + cls.b1.authors.add(cls.a3) + cls.b2.authors.add(cls.a2) + cls.b3.authors.add(cls.a3) + + def test_filtered_aggregate_empty_condition_distinct(self): + book = Book.objects.annotate( + ages=Count("authors__age", filter=Q(authors__in=[]), distinct=True), + ).get(pk=self.b1.pk) + self.assertEqual(book.ages, 0) + aggregate = Book.objects.aggregate(max_rating=Max("rating", filter=Q(rating__in=[]))) + self.assertEqual(aggregate, {"max_rating": None}) + + def test_filtered_aggregate_full_condition(self): + book = Book.objects.annotate( + ages=Count("authors__age", filter=~Q(pk__in=[])), + ).get(pk=self.b1.pk) + self.assertEqual(book.ages, 2) + aggregate = Book.objects.aggregate(max_rating=Max("rating", filter=~Q(rating__in=[]))) + self.assertEqual(aggregate, {"max_rating": 40.5}) + + def test_filtered_aggregate_full_condition_distinct(self): + book = Book.objects.annotate( + ages=Count("authors__age", filter=~Q(authors__in=[]), distinct=True), + ).get(pk=self.b1.pk) + self.assertEqual(book.ages, 1) + aggregate = Book.objects.aggregate(max_rating=Max("rating", filter=~Q(rating__in=[]))) + self.assertEqual(aggregate, {"max_rating": 40.5}) diff --git a/tests/backend_/utils/test_parse_uri.py b/tests/backend_/utils/test_parse_uri.py deleted file mode 100644 index a87154162..000000000 --- a/tests/backend_/utils/test_parse_uri.py +++ /dev/null @@ -1,117 +0,0 @@ -from unittest.mock import patch - -import pymongo -from django.core.exceptions import ImproperlyConfigured -from django.test import SimpleTestCase -from django.test.utils import ignore_warnings -from django.utils.deprecation import RemovedInDjango60Warning - -from django_mongodb_backend import parse_uri - - -@ignore_warnings(category=RemovedInDjango60Warning) -class ParseURITests(SimpleTestCase): - def test_simple_uri(self): - settings_dict = parse_uri("mongodb://cluster0.example.mongodb.net/myDatabase") - self.assertEqual(settings_dict["ENGINE"], "django_mongodb_backend") - self.assertEqual(settings_dict["NAME"], "myDatabase") - self.assertEqual(settings_dict["HOST"], "cluster0.example.mongodb.net") - self.assertEqual(settings_dict["OPTIONS"], {"authSource": "myDatabase"}) - - def test_db_name(self): - settings_dict = parse_uri("mongodb://cluster0.example.mongodb.net/", db_name="myDatabase") - self.assertEqual(settings_dict["ENGINE"], "django_mongodb_backend") - self.assertEqual(settings_dict["NAME"], "myDatabase") - self.assertEqual(settings_dict["HOST"], "cluster0.example.mongodb.net") - self.assertEqual(settings_dict["OPTIONS"], {}) - - def test_db_name_overrides_default_auth_db(self): - settings_dict = parse_uri( - "mongodb://cluster0.example.mongodb.net/default_auth_db", db_name="myDatabase" - ) - self.assertEqual(settings_dict["ENGINE"], "django_mongodb_backend") - self.assertEqual(settings_dict["NAME"], "myDatabase") - self.assertEqual(settings_dict["HOST"], "cluster0.example.mongodb.net") - self.assertEqual(settings_dict["OPTIONS"], {"authSource": "default_auth_db"}) - - def test_no_database(self): - msg = "You must provide the db_name parameter." - with self.assertRaisesMessage(ImproperlyConfigured, msg): - parse_uri("mongodb://cluster0.example.mongodb.net") - - def test_srv_uri_with_options(self): - uri = "mongodb+srv://my_user:my_password@cluster0.example.mongodb.net/my_database?retryWrites=true&w=majority" - # patch() prevents a crash when PyMongo attempts to resolve the - # nonexistent SRV record. - with patch("dns.resolver.resolve"): - settings_dict = parse_uri(uri) - self.assertEqual(settings_dict["NAME"], "my_database") - self.assertEqual(settings_dict["HOST"], "mongodb+srv://cluster0.example.mongodb.net") - self.assertEqual(settings_dict["USER"], "my_user") - self.assertEqual(settings_dict["PASSWORD"], "my_password") - self.assertIsNone(settings_dict["PORT"]) - self.assertEqual( - settings_dict["OPTIONS"], - {"authSource": "my_database", "retryWrites": True, "w": "majority", "tls": True}, - ) - - def test_localhost(self): - settings_dict = parse_uri("mongodb://localhost/db") - self.assertEqual(settings_dict["HOST"], "localhost") - self.assertEqual(settings_dict["PORT"], 27017) - - def test_localhost_with_port(self): - settings_dict = parse_uri("mongodb://localhost:27018/db") - self.assertEqual(settings_dict["HOST"], "localhost") - self.assertEqual(settings_dict["PORT"], 27018) - - def test_hosts_with_ports(self): - settings_dict = parse_uri("mongodb://localhost:27017,localhost:27018/db") - self.assertEqual(settings_dict["HOST"], "localhost:27017,localhost:27018") - self.assertEqual(settings_dict["PORT"], None) - - def test_hosts_without_ports(self): - settings_dict = parse_uri("mongodb://host1.net,host2.net/db") - self.assertEqual(settings_dict["HOST"], "host1.net:27017,host2.net:27017") - self.assertEqual(settings_dict["PORT"], None) - - def test_auth_source_in_query_string(self): - settings_dict = parse_uri("mongodb://localhost/?authSource=auth", db_name="db") - self.assertEqual(settings_dict["NAME"], "db") - self.assertEqual(settings_dict["OPTIONS"], {"authSource": "auth"}) - - def test_auth_source_in_query_string_overrides_defaultauthdb(self): - settings_dict = parse_uri("mongodb://localhost/db?authSource=auth") - self.assertEqual(settings_dict["NAME"], "db") - self.assertEqual(settings_dict["OPTIONS"], {"authSource": "auth"}) - - def test_options_kwarg(self): - options = {"authSource": "auth", "retryWrites": True} - settings_dict = parse_uri( - "mongodb://cluster0.example.mongodb.net/myDatabase?retryWrites=false&retryReads=true", - options=options, - ) - self.assertEqual( - settings_dict["OPTIONS"], - {"authSource": "auth", "retryWrites": True, "retryReads": True}, - ) - - def test_test_kwarg(self): - settings_dict = parse_uri("mongodb://localhost/db", test={"NAME": "test_db"}) - self.assertEqual(settings_dict["TEST"], {"NAME": "test_db"}) - - def test_invalid_credentials(self): - msg = "The empty string is not valid username" - with self.assertRaisesMessage(pymongo.errors.InvalidURI, msg): - parse_uri("mongodb://:@localhost") - - def test_no_scheme(self): - with self.assertRaisesMessage(pymongo.errors.InvalidURI, "Invalid URI scheme"): - parse_uri("cluster0.example.mongodb.net") - - -class ParseURIDeprecationTests(SimpleTestCase): - def test_message(self): - msg = 'parse_uri() is deprecated. Put the connection string in DATABASES["HOST"] instead.' - with self.assertRaisesMessage(RemovedInDjango60Warning, msg): - parse_uri("mongodb://cluster0.example.mongodb.net/")