Skip to content

Commit d8272c4

Browse files
authored
Add support for pymongo bson ObjectId (#290)
* Add support for pymongo bson ObjectId (#133) * Fix py38 type hint * Add test for json schema
1 parent b7ddcfa commit d8272c4

File tree

6 files changed

+246
-4
lines changed

6 files changed

+246
-4
lines changed

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ sources = pydantic_extra_types tests
77

88
.PHONY: install ## Install the package, dependencies, and pre-commit for local development
99
install: .uv
10-
uv sync --frozen --group all --all-extras
10+
uv sync --frozen --all-groups --all-extras
1111
uv pip install pre-commit
1212
pre-commit install --install-hooks
1313

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
"""
2+
Validation for MongoDB ObjectId fields.
3+
4+
Ref: https://github.com/pydantic/pydantic-extra-types/issues/133
5+
"""
6+
7+
from typing import Any
8+
9+
from pydantic import GetCoreSchemaHandler
10+
from pydantic_core import core_schema
11+
12+
try:
13+
from bson import ObjectId
14+
except ModuleNotFoundError as e: # pragma: no cover
15+
raise RuntimeError(
16+
'The `mongo_object_id` module requires "pymongo" to be installed. You can install it with "pip install '
17+
'pymongo".'
18+
) from e
19+
20+
21+
class MongoObjectId(str):
22+
"""MongoObjectId parses and validates MongoDB bson.ObjectId.
23+
24+
```py
25+
from pydantic import BaseModel
26+
27+
from pydantic_extra_types.mongo_object_id import MongoObjectId
28+
29+
30+
class MongoDocument(BaseModel):
31+
id: MongoObjectId
32+
33+
34+
doc = MongoDocument(id='5f9f2f4b9d3c5a7b4c7e6c1d')
35+
print(doc)
36+
# > id='5f9f2f4b9d3c5a7b4c7e6c1d'
37+
```
38+
39+
Raises:
40+
PydanticCustomError: If the provided value is not a valid MongoDB ObjectId.
41+
"""
42+
43+
OBJECT_ID_LENGTH = 24
44+
45+
@classmethod
46+
def __get_pydantic_core_schema__(cls, _: Any, __: GetCoreSchemaHandler) -> core_schema.CoreSchema:
47+
return core_schema.json_or_python_schema(
48+
json_schema=core_schema.str_schema(min_length=cls.OBJECT_ID_LENGTH, max_length=cls.OBJECT_ID_LENGTH),
49+
python_schema=core_schema.union_schema(
50+
[
51+
core_schema.is_instance_schema(ObjectId),
52+
core_schema.chain_schema(
53+
[
54+
core_schema.str_schema(min_length=cls.OBJECT_ID_LENGTH, max_length=cls.OBJECT_ID_LENGTH),
55+
core_schema.no_info_plain_validator_function(cls.validate),
56+
]
57+
),
58+
]
59+
),
60+
serialization=core_schema.plain_serializer_function_ser_schema(lambda x: str(x)),
61+
)
62+
63+
@classmethod
64+
def validate(cls, value: str) -> ObjectId:
65+
"""Validate the MongoObjectId str is a valid ObjectId instance."""
66+
if not ObjectId.is_valid(value):
67+
raise ValueError(
68+
f"Invalid ObjectId {value} has to be 24 characters long and in the format '5f9f2f4b9d3c5a7b4c7e6c1d'."
69+
)
70+
71+
return ObjectId(value)

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ all = [
5050
'python-ulid>=1,<2; python_version<"3.9"',
5151
'python-ulid>=1,<4; python_version>="3.9"',
5252
'pendulum>=3.0.0,<4.0.0',
53+
'pymongo>=4.0.0,<5.0.0',
5354
'pytz>=2024.1',
5455
'semver~=3.0.2',
5556
'tzdata>=2024.1',

tests/test_json_schema.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Union
1+
from typing import Any, Dict, Union
22

33
import pycountry
44
import pytest
@@ -15,6 +15,7 @@
1515
from pydantic_extra_types.isbn import ISBN
1616
from pydantic_extra_types.language_code import ISO639_3, ISO639_5, LanguageAlpha2, LanguageName
1717
from pydantic_extra_types.mac_address import MacAddress
18+
from pydantic_extra_types.mongo_object_id import MongoObjectId
1819
from pydantic_extra_types.payment import PaymentCardNumber
1920
from pydantic_extra_types.pendulum_dt import DateTime
2021
from pydantic_extra_types.phone_numbers import PhoneNumber, PhoneNumberValidator
@@ -494,9 +495,27 @@
494495
],
495496
},
496497
),
498+
(
499+
MongoObjectId,
500+
{
501+
'title': 'Model',
502+
'type': 'object',
503+
'properties': {
504+
'x': {
505+
'maxLength': MongoObjectId.OBJECT_ID_LENGTH,
506+
'minLength': MongoObjectId.OBJECT_ID_LENGTH,
507+
'title': 'X',
508+
'type': 'string',
509+
},
510+
},
511+
'required': ['x'],
512+
},
513+
),
497514
],
498515
)
499-
def test_json_schema(cls, expected):
516+
def test_json_schema(cls: Any, expected: Dict[str, Any]) -> None:
517+
"""Test the model_json_schema implementation for all extra types."""
518+
500519
class Model(BaseModel):
501520
x: cls
502521

tests/test_mongo_object_id.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
"""Tests for the mongo_object_id module."""
2+
3+
import pytest
4+
from pydantic import BaseModel, GetCoreSchemaHandler, ValidationError
5+
from pydantic.json_schema import JsonSchemaMode
6+
7+
from pydantic_extra_types.mongo_object_id import MongoObjectId
8+
9+
10+
class MongoDocument(BaseModel):
11+
object_id: MongoObjectId
12+
13+
14+
@pytest.mark.parametrize(
15+
'object_id, result, valid',
16+
[
17+
# Valid ObjectId for str format
18+
('611827f2878b88b49ebb69fc', '611827f2878b88b49ebb69fc', True),
19+
('611827f2878b88b49ebb69fd', '611827f2878b88b49ebb69fd', True),
20+
# Invalid ObjectId for str format
21+
('611827f2878b88b49ebb69f', None, False), # Invalid ObjectId (short length)
22+
('611827f2878b88b49ebb69fca', None, False), # Invalid ObjectId (long length)
23+
# Valid ObjectId for bytes format
24+
],
25+
)
26+
def test_format_for_object_id(object_id: str, result: str, valid: bool) -> None:
27+
"""Test the MongoObjectId validation."""
28+
if valid:
29+
assert str(MongoDocument(object_id=object_id).object_id) == result
30+
else:
31+
with pytest.raises(ValidationError):
32+
MongoDocument(object_id=object_id)
33+
with pytest.raises(
34+
ValueError,
35+
match=f"Invalid ObjectId {object_id} has to be 24 characters long and in the format '5f9f2f4b9d3c5a7b4c7e6c1d'.",
36+
):
37+
MongoObjectId.validate(object_id)
38+
39+
40+
@pytest.mark.parametrize(
41+
'schema_mode',
42+
[
43+
'validation',
44+
'serialization',
45+
],
46+
)
47+
def test_json_schema(schema_mode: JsonSchemaMode) -> None:
48+
"""Test the MongoObjectId model_json_schema implementation."""
49+
expected_json_schema = {
50+
'properties': {
51+
'object_id': {
52+
'maxLength': MongoObjectId.OBJECT_ID_LENGTH,
53+
'minLength': MongoObjectId.OBJECT_ID_LENGTH,
54+
'title': 'Object Id',
55+
'type': 'string',
56+
}
57+
},
58+
'required': ['object_id'],
59+
'title': 'MongoDocument',
60+
'type': 'object',
61+
}
62+
assert MongoDocument.model_json_schema(mode=schema_mode) == expected_json_schema
63+
64+
65+
def test_get_pydantic_core_schema() -> None:
66+
"""Test the __get_pydantic_core_schema__ method override."""
67+
schema = MongoObjectId.__get_pydantic_core_schema__(MongoObjectId, GetCoreSchemaHandler())
68+
assert isinstance(schema, dict)
69+
assert 'json_schema' in schema
70+
assert 'python_schema' in schema
71+
assert schema['json_schema']['type'] == 'str'

0 commit comments

Comments
 (0)