From 8afc27dc6ba24bcb25de0354871f97ce4f21518c Mon Sep 17 00:00:00 2001 From: SeaStar Deng <37767638+DSeaStar@users.noreply.github.com> Date: Fri, 14 Aug 2026 17:43:32 +0000 Subject: [PATCH 1/3] Fix inc/dec rejecting deltas outside a field's min_value/max_value $inc/$dec apply a delta, not the stored value, so min/max validation must not run against the increment (see #2339). --- AUTHORS | 1 + docs/changelog.rst | 1 + mongoengine/base/fields.py | 5 ++++- tests/queryset/test_transform.py | 26 ++++++++++++++++++++++++++ 4 files changed, 32 insertions(+), 1 deletion(-) diff --git a/AUTHORS b/AUTHORS index 17fae84ea..a5d228c39 100644 --- a/AUTHORS +++ b/AUTHORS @@ -266,3 +266,4 @@ that much better: * Terence Honles (https://github.com/terencehonles) * Sean Bermejo (https://github.com/seanbermejo) * Juan Gutierrez (https://github.com/juannyg) + * DSeaStar (https://github.com/DSeaStar) diff --git a/docs/changelog.rst b/docs/changelog.rst index fab41b7b6..b2d7546a6 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -7,6 +7,7 @@ Changelog Development =========== - (Fill this out as you fix issues and develop your features). +- Fix inc/dec atomic updates rejecting deltas outside a field's min_value/max_value #2339 - Add a warning that ``mongoengine.org`` is no longer controlled by the MongoEngine project and appears to be an expired domain takeover. - Fix querying GenericReferenceField with __in operator #2886 diff --git a/mongoengine/base/fields.py b/mongoengine/base/fields.py index 07962ecde..1b273ba01 100644 --- a/mongoengine/base/fields.py +++ b/mongoengine/base/fields.py @@ -236,7 +236,10 @@ def _to_mongo_safe_call(self, value, use_db_field=True, fields=None): def prepare_query_value(self, op, value): """Prepare a value that is being used in a query for PyMongo.""" - if op in UPDATE_OPERATORS: + # $inc/$dec apply a delta, not the stored value. Checking min_value + # / max_value against the delta rejects legitimate decrements on + # fields with min_value=0 (see #2339). + if op in UPDATE_OPERATORS and op not in ("inc", "dec"): self.validate(value) return value diff --git a/tests/queryset/test_transform.py b/tests/queryset/test_transform.py index 8cb8ad426..a24107624 100644 --- a/tests/queryset/test_transform.py +++ b/tests/queryset/test_transform.py @@ -83,6 +83,32 @@ class BlogPost(Document): update = transform.update(BlogPost, push_all__tags=["mongo", "db"]) assert update == {"$push": {"tags": {"$each": ["mongo", "db"]}}} + def test_transform_update_inc_dec_ignores_min_max(self): + """inc/dec pass a delta; min_value/max_value apply to stored values (#2339).""" + + class Account(Document): + amount = FloatField(min_value=0, required=True) + count = IntField(min_value=0, max_value=100) + money = DecimalField(min_value=0) + + update = transform.update(Account, dec__amount=10) + assert update == {"$inc": {"amount": -10.0}} + + update = transform.update(Account, inc__count=1) + assert update == {"$inc": {"count": 1}} + + update = transform.update(Account, dec__count=5) + assert update == {"$inc": {"count": -5}} + + update = transform.update(Account, dec__money=3) + assert update == {"$inc": {"money": -3.0}} + + with pytest.raises(ValidationError): + transform.update(Account, set__amount=-1) + + with pytest.raises(ValidationError): + transform.update(Account, set__count=101) + def test_transform_update_no_operator_default_to_set(self): """Ensure the differences in behvaior between 'push' and 'push_all'""" From 54fc64e40e82f86ebb5dccbda8d52d96c7b6895b Mon Sep 17 00:00:00 2001 From: Bastien Gerard Date: Thu, 20 Aug 2026 12:38:16 +0200 Subject: [PATCH 2/3] Improve implementation of DSeaStar-fix-2339-inc-dec-min-value --- docs/changelog.rst | 4 --- docs/guide/querying.rst | 8 +++++ mongoengine/base/fields.py | 7 ++--- mongoengine/queryset/transform.py | 2 +- tests/queryset/test_queryset.py | 49 +++++++++++++++++++++++++++++++ tests/queryset/test_transform.py | 21 +++++++++++-- 6 files changed, 80 insertions(+), 11 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 3d7e8d078..960a0ff2c 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -10,10 +10,6 @@ Development Changes in 1.0.0 ================ -- Add a warning that ``mongoengine.org`` is no longer controlled by the MongoEngine - project and appears to be an expired domain takeover. -- Fix querying GenericReferenceField with __in operator #2886 -- Fix Document.compare_indexes() not working correctly for text indexes on multiple fields #2612 - Add support for transaction through run_in_transaction (kudos to juannyG for this) #2569 Some considerations: diff --git a/docs/guide/querying.rst b/docs/guide/querying.rst index b9eb6c293..1d1f8c53e 100644 --- a/docs/guide/querying.rst +++ b/docs/guide/querying.rst @@ -596,6 +596,7 @@ There are several different "modifiers" that you may use with these methods: * ``min`` -- update only if value is smaller * ``inc`` -- increment a value by a given amount * ``dec`` -- decrement a value by a given amount +* ``mul`` -- multiply a value by a given amount * ``push`` -- append a value to a list * ``push_all`` -- append several values to a list * ``pop`` -- remove the first or last element of a list `depending on the value`_ @@ -604,6 +605,13 @@ There are several different "modifiers" that you may use with these methods: * ``add_to_set`` -- add value to a list only if its not in the list already * ``rename`` -- rename the key name +.. note:: + + The operands passed to ``inc``, ``dec``, and ``mul`` are deltas or + multipliers, not replacement field values. MongoEngine therefore does not + validate them against the field's ``min_value`` or ``max_value`` as this would require a lookup. These + atomic updates can leave the stored value outside those bounds. + .. _need to add upsert=True: http://docs.mongodb.org/manual/reference/operator/update/setOnInsert .. _depending on the value: http://docs.mongodb.org/manual/reference/operator/update/pop/ diff --git a/mongoengine/base/fields.py b/mongoengine/base/fields.py index 1b273ba01..308530046 100644 --- a/mongoengine/base/fields.py +++ b/mongoengine/base/fields.py @@ -236,10 +236,9 @@ def _to_mongo_safe_call(self, value, use_db_field=True, fields=None): def prepare_query_value(self, op, value): """Prepare a value that is being used in a query for PyMongo.""" - # $inc/$dec apply a delta, not the stored value. Checking min_value - # / max_value against the delta rejects legitimate decrements on - # fields with min_value=0 (see #2339). - if op in UPDATE_OPERATORS and op not in ("inc", "dec"): + # Do not validate $inc/$mul operands against stored-value min/max bounds. + # dec is normalized to inc with a negative value before this point. + if op in UPDATE_OPERATORS and op not in ("inc", "mul"): self.validate(value) return value diff --git a/mongoengine/queryset/transform.py b/mongoengine/queryset/transform.py index 701ca649b..09dac1c16 100644 --- a/mongoengine/queryset/transform.py +++ b/mongoengine/queryset/transform.py @@ -365,7 +365,7 @@ def update(_doc_cls=None, **update): value = field.prepare_query_value(op, value) elif op == "unset": value = 1 - elif op == "inc": + elif op in ("inc", "mul"): value = field.prepare_query_value(op, value) if match: diff --git a/tests/queryset/test_queryset.py b/tests/queryset/test_queryset.py index d64384670..fae2c0ff8 100644 --- a/tests/queryset/test_queryset.py +++ b/tests/queryset/test_queryset.py @@ -2148,6 +2148,55 @@ class BlogPost(Document): post.reload() assert post.hits == 11 + def test_update_number_operators__operand_outside_bounds__bypasses_validation(self): + """inc, dec, and mul operands are not stored values and bypass bounds.""" + + class Counter(Document): + number = IntField(min_value=1, max_value=5) + + Counter.drop_collection() + counter = Counter(number=1).save() + + Counter.objects.update(inc__number=5) + counter.reload() + assert counter.number == 6 + + Counter.objects.update(dec__number=6) + counter.reload() + assert counter.number == 0 + + counter.number = 2 + counter.save() + + Counter.objects.update(mul__number=10) + counter.reload() + assert counter.number == 20 + + def test_update_inc__numeric_string__converts_to_number(self): + """inc converts a numeric string through IntField before updating.""" + + class Counter(Document): + number = IntField() + + Counter.drop_collection() + counter = Counter(number=1).save() + + Counter.objects.update(inc__number="5") + counter.reload() + assert counter.number == 6 + + def test_update_inc__string_field__relies_on_server_validation(self): + """MongoDB rejects inc after MongoEngine skips client-side validation.""" + + class Person(Document): + name = StringField() + + Person.drop_collection() + Person(name="Alice").save() + + with pytest.raises(OperationError): + Person.objects.update(inc__name=1) + def test_update_decimalfield_operator(self): class BlogPost(Document): review = DecimalField() diff --git a/tests/queryset/test_transform.py b/tests/queryset/test_transform.py index a24107624..db4ad8fc2 100644 --- a/tests/queryset/test_transform.py +++ b/tests/queryset/test_transform.py @@ -1,6 +1,7 @@ import unittest import pytest +from bson.decimal128 import Decimal128 from bson.son import SON from mongoengine import * @@ -90,12 +91,16 @@ class Account(Document): amount = FloatField(min_value=0, required=True) count = IntField(min_value=0, max_value=100) money = DecimalField(min_value=0) + money128 = Decimal128Field(min_value=0) update = transform.update(Account, dec__amount=10) assert update == {"$inc": {"amount": -10.0}} - update = transform.update(Account, inc__count=1) - assert update == {"$inc": {"count": 1}} + update = transform.update(Account, inc__count=200) + assert update == {"$inc": {"count": 200}} + + update = transform.update(Account, mul__count="200") + assert update == {"$mul": {"count": 200}} update = transform.update(Account, dec__count=5) assert update == {"$inc": {"count": -5}} @@ -103,12 +108,24 @@ class Account(Document): update = transform.update(Account, dec__money=3) assert update == {"$inc": {"money": -3.0}} + update = transform.update(Account, dec__money128=3) + assert update == {"$inc": {"money128": Decimal128("-3")}} + with pytest.raises(ValidationError): transform.update(Account, set__amount=-1) with pytest.raises(ValidationError): transform.update(Account, set__count=101) + def test_transform_update__inc_on_string_field__skips_validation(self): + """inc leaves nonnumeric field validation to MongoDB.""" + + class Person(Document): + name = StringField() + + update = transform.update(Person, inc__name=1) + assert update == {"$inc": {"name": 1}} + def test_transform_update_no_operator_default_to_set(self): """Ensure the differences in behvaior between 'push' and 'push_all'""" From e02e6021d8fe63ac82a2adb73d71146f67196bcd Mon Sep 17 00:00:00 2001 From: Bastien Gerard Date: Thu, 20 Aug 2026 15:38:19 +0200 Subject: [PATCH 3/3] minor comment for 'dec' --- mongoengine/queryset/transform.py | 1 + 1 file changed, 1 insertion(+) diff --git a/mongoengine/queryset/transform.py b/mongoengine/queryset/transform.py index 09dac1c16..c0e58eb45 100644 --- a/mongoengine/queryset/transform.py +++ b/mongoengine/queryset/transform.py @@ -365,6 +365,7 @@ def update(_doc_cls=None, **update): value = field.prepare_query_value(op, value) elif op == "unset": value = 1 + # dec is normalized to inc above. elif op in ("inc", "mul"): value = field.prepare_query_value(op, value)