PYTHON-5328 CRUD Support in Driver for Prefix/Suffix/Substring Indexes (#2521)

This commit is contained in:
Steven Silvester 2025-09-10 10:35:35 -05:00 committed by GitHub
parent 7580309e99
commit d7316afb63
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 710 additions and 7 deletions

View File

@ -4,6 +4,13 @@ Changes in Version 4.15.0 (XXXX/XX/XX)
--------------------------------------
PyMongo 4.15 brings a number of changes including:
- Added :class:`~pymongo.encryption_options.TextOpts`,
:attr:`~pymongo.encryption.Algorithm.TEXTPREVIEW`,
:attr:`~pymongo.encryption.QueryType.PREFIXPREVIEW`,
:attr:`~pymongo.encryption.QueryType.SUFFIXPREVIEW`,
:attr:`~pymongo.encryption.QueryType.SUBSTRINGPREVIEW`,
as part of the experimental Queryable Encryption text queries beta.
``pymongocrypt>=1.16`` is required for text query support.
- Added :class:`bson.decimal128.DecimalEncoder` and :class:`bson.decimal128.DecimalDecoder`
to support encoding and decoding of BSON Decimal128 values to decimal.Decimal values using the TypeRegistry API.

View File

@ -67,7 +67,7 @@ from pymongo.asynchronous.mongo_client import AsyncMongoClient
from pymongo.asynchronous.pool import AsyncBaseConnection
from pymongo.common import CONNECT_TIMEOUT
from pymongo.daemon import _spawn_daemon
from pymongo.encryption_options import AutoEncryptionOpts, RangeOpts
from pymongo.encryption_options import AutoEncryptionOpts, RangeOpts, TextOpts
from pymongo.errors import (
ConfigurationError,
EncryptedCollectionError,
@ -516,6 +516,11 @@ class Algorithm(str, enum.Enum):
.. versionadded:: 4.4
"""
TEXTPREVIEW = "TextPreview"
"""**BETA** - TextPreview.
.. versionadded:: 4.15
"""
class QueryType(str, enum.Enum):
@ -541,6 +546,24 @@ class QueryType(str, enum.Enum):
.. versionadded:: 4.4
"""
PREFIXPREVIEW = "prefixPreview"
"""**BETA** - Used to encrypt a value for a prefixPreview query.
.. versionadded:: 4.15
"""
SUFFIXPREVIEW = "suffixPreview"
"""**BETA** - Used to encrypt a value for a suffixPreview query.
.. versionadded:: 4.15
"""
SUBSTRINGPREVIEW = "substringPreview"
"""**BETA** - Used to encrypt a value for a substringPreview query.
.. versionadded:: 4.15
"""
def _create_mongocrypt_options(**kwargs: Any) -> MongoCryptOptions:
# For compat with pymongocrypt <1.13, avoid setting the default key_expiration_ms.
@ -876,6 +899,7 @@ class AsyncClientEncryption(Generic[_DocumentType]):
contention_factor: Optional[int] = None,
range_opts: Optional[RangeOpts] = None,
is_expression: bool = False,
text_opts: Optional[TextOpts] = None,
) -> Any:
self._check_closed()
if isinstance(key_id, uuid.UUID):
@ -895,6 +919,12 @@ class AsyncClientEncryption(Generic[_DocumentType]):
range_opts.document,
codec_options=self._codec_options,
)
text_opts_bytes = None
if text_opts:
text_opts_bytes = encode(
text_opts.document,
codec_options=self._codec_options,
)
with _wrap_encryption_errors():
encrypted_doc = await self._encryption.encrypt(
value=doc,
@ -905,6 +935,7 @@ class AsyncClientEncryption(Generic[_DocumentType]):
contention_factor=contention_factor,
range_opts=range_opts_bytes,
is_expression=is_expression,
text_opts=text_opts_bytes,
)
return decode(encrypted_doc)["v"]
@ -917,6 +948,7 @@ class AsyncClientEncryption(Generic[_DocumentType]):
query_type: Optional[str] = None,
contention_factor: Optional[int] = None,
range_opts: Optional[RangeOpts] = None,
text_opts: Optional[TextOpts] = None,
) -> Binary:
"""Encrypt a BSON value with a given key and algorithm.
@ -937,9 +969,14 @@ class AsyncClientEncryption(Generic[_DocumentType]):
used.
:param range_opts: Index options for `range` queries. See
:class:`RangeOpts` for some valid options.
:param text_opts: Index options for `textPreview` queries. See
:class:`TextOpts` for some valid options.
:return: The encrypted value, a :class:`~bson.binary.Binary` with subtype 6.
.. versionchanged:: 4.9
Added the `text_opts` parameter.
.. versionchanged:: 4.9
Added the `range_opts` parameter.
@ -960,6 +997,7 @@ class AsyncClientEncryption(Generic[_DocumentType]):
contention_factor=contention_factor,
range_opts=range_opts,
is_expression=False,
text_opts=text_opts,
),
)

View File

@ -18,7 +18,7 @@
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Mapping, Optional
from typing import TYPE_CHECKING, Any, Mapping, Optional, TypedDict
from pymongo.uri_parser_shared import _parse_kms_tls_options
@ -295,3 +295,85 @@ class RangeOpts:
if v is not None:
doc[k] = v
return doc
class TextOpts:
"""**BETA** Options to configure encrypted queries using the text algorithm.
TextOpts is currently unstable API and subject to backwards breaking changes."""
def __init__(
self,
substring: Optional[SubstringOpts] = None,
prefix: Optional[PrefixOpts] = None,
suffix: Optional[SuffixOpts] = None,
case_sensitive: Optional[bool] = None,
diacritic_sensitive: Optional[bool] = None,
) -> None:
"""Options to configure encrypted queries using the text algorithm.
:param substring: Further options to support substring queries.
:param prefix: Further options to support prefix queries.
:param suffix: Further options to support suffix queries.
:param case_sensitive: Whether text indexes for this field are case sensitive.
:param diacritic_sensitive: Whether text indexes for this field are diacritic sensitive.
.. versionadded:: 4.15
"""
self.substring = substring
self.prefix = prefix
self.suffix = suffix
self.case_sensitive = case_sensitive
self.diacritic_sensitive = diacritic_sensitive
@property
def document(self) -> dict[str, Any]:
doc = {}
for k, v in [
("substring", self.substring),
("prefix", self.prefix),
("suffix", self.suffix),
("caseSensitive", self.case_sensitive),
("diacriticSensitive", self.diacritic_sensitive),
]:
if v is not None:
doc[k] = v
return doc
class SubstringOpts(TypedDict):
"""**BETA** Options for substring text queries.
SubstringOpts is currently unstable API and subject to backwards breaking changes.
"""
# strMaxLength is the maximum allowed length to insert. Inserting longer strings will error.
strMaxLength: int
# strMinQueryLength is the minimum allowed query length. Querying with a shorter string will error.
strMinQueryLength: int
# strMaxQueryLength is the maximum allowed query length. Querying with a longer string will error.
strMaxQueryLength: int
class PrefixOpts(TypedDict):
"""**BETA** Options for prefix text queries.
PrefixOpts is currently unstable API and subject to backwards breaking changes.
"""
# strMinQueryLength is the minimum allowed query length. Querying with a shorter string will error.
strMinQueryLength: int
# strMaxQueryLength is the maximum allowed query length. Querying with a longer string will error.
strMaxQueryLength: int
class SuffixOpts(TypedDict):
"""**BETA** Options for suffix text queries.
SuffixOpts is currently unstable API and subject to backwards breaking changes.
"""
# strMinQueryLength is the minimum allowed query length. Querying with a shorter string will error.
strMinQueryLength: int
# strMaxQueryLength is the maximum allowed query length. Querying with a longer string will error.
strMaxQueryLength: int

View File

@ -61,7 +61,7 @@ from bson.raw_bson import DEFAULT_RAW_BSON_OPTIONS, RawBSONDocument, _inflate_bs
from pymongo import _csot
from pymongo.common import CONNECT_TIMEOUT
from pymongo.daemon import _spawn_daemon
from pymongo.encryption_options import AutoEncryptionOpts, RangeOpts
from pymongo.encryption_options import AutoEncryptionOpts, RangeOpts, TextOpts
from pymongo.errors import (
ConfigurationError,
EncryptedCollectionError,
@ -513,6 +513,11 @@ class Algorithm(str, enum.Enum):
.. versionadded:: 4.4
"""
TEXTPREVIEW = "TextPreview"
"""**BETA** - TextPreview.
.. versionadded:: 4.15
"""
class QueryType(str, enum.Enum):
@ -538,6 +543,24 @@ class QueryType(str, enum.Enum):
.. versionadded:: 4.4
"""
PREFIXPREVIEW = "prefixPreview"
"""**BETA** - Used to encrypt a value for a prefixPreview query.
.. versionadded:: 4.15
"""
SUFFIXPREVIEW = "suffixPreview"
"""**BETA** - Used to encrypt a value for a suffixPreview query.
.. versionadded:: 4.15
"""
SUBSTRINGPREVIEW = "substringPreview"
"""**BETA** - Used to encrypt a value for a substringPreview query.
.. versionadded:: 4.15
"""
def _create_mongocrypt_options(**kwargs: Any) -> MongoCryptOptions:
# For compat with pymongocrypt <1.13, avoid setting the default key_expiration_ms.
@ -869,6 +892,7 @@ class ClientEncryption(Generic[_DocumentType]):
contention_factor: Optional[int] = None,
range_opts: Optional[RangeOpts] = None,
is_expression: bool = False,
text_opts: Optional[TextOpts] = None,
) -> Any:
self._check_closed()
if isinstance(key_id, uuid.UUID):
@ -888,6 +912,12 @@ class ClientEncryption(Generic[_DocumentType]):
range_opts.document,
codec_options=self._codec_options,
)
text_opts_bytes = None
if text_opts:
text_opts_bytes = encode(
text_opts.document,
codec_options=self._codec_options,
)
with _wrap_encryption_errors():
encrypted_doc = self._encryption.encrypt(
value=doc,
@ -898,6 +928,7 @@ class ClientEncryption(Generic[_DocumentType]):
contention_factor=contention_factor,
range_opts=range_opts_bytes,
is_expression=is_expression,
text_opts=text_opts_bytes,
)
return decode(encrypted_doc)["v"]
@ -910,6 +941,7 @@ class ClientEncryption(Generic[_DocumentType]):
query_type: Optional[str] = None,
contention_factor: Optional[int] = None,
range_opts: Optional[RangeOpts] = None,
text_opts: Optional[TextOpts] = None,
) -> Binary:
"""Encrypt a BSON value with a given key and algorithm.
@ -930,9 +962,14 @@ class ClientEncryption(Generic[_DocumentType]):
used.
:param range_opts: Index options for `range` queries. See
:class:`RangeOpts` for some valid options.
:param text_opts: Index options for `textPreview` queries. See
:class:`TextOpts` for some valid options.
:return: The encrypted value, a :class:`~bson.binary.Binary` with subtype 6.
.. versionchanged:: 4.9
Added the `text_opts` parameter.
.. versionchanged:: 4.9
Added the `range_opts` parameter.
@ -953,6 +990,7 @@ class ClientEncryption(Generic[_DocumentType]):
contention_factor=contention_factor,
range_opts=range_opts,
is_expression=False,
text_opts=text_opts,
),
)

View File

@ -32,6 +32,7 @@ import unittest
import warnings
from inspect import iscoroutinefunction
from pymongo.encryption_options import _HAVE_PYMONGOCRYPT
from pymongo.errors import AutoReconnect
from pymongo.synchronous.uri_parser import parse_uri
@ -524,6 +525,19 @@ class ClientContext:
"Server version must be at most %s" % str(other_version),
)
def require_libmongocrypt_min(self, *ver):
other_version = Version(*ver)
if not _HAVE_PYMONGOCRYPT:
version = Version.from_string("0.0.0")
else:
from pymongocrypt import libmongocrypt_version
version = Version.from_string(libmongocrypt_version())
return self._require(
lambda: version >= other_version,
"Libmongocrypt version must be at least %s" % str(other_version),
)
def require_auth(self, func):
"""Run a test only if the server is running with auth enabled."""
return self._require(

View File

@ -33,6 +33,7 @@ import warnings
from inspect import iscoroutinefunction
from pymongo.asynchronous.uri_parser import parse_uri
from pymongo.encryption_options import _HAVE_PYMONGOCRYPT
from pymongo.errors import AutoReconnect
try:
@ -524,6 +525,19 @@ class AsyncClientContext:
"Server version must be at most %s" % str(other_version),
)
def require_libmongocrypt_min(self, *ver):
other_version = Version(*ver)
if not _HAVE_PYMONGOCRYPT:
version = Version.from_string("0.0.0")
else:
from pymongocrypt import libmongocrypt_version
version = Version.from_string(libmongocrypt_version())
return self._require(
lambda: version >= other_version,
"Libmongocrypt version must be at least %s" % str(other_version),
)
def require_auth(self, func):
"""Run a test only if the server is running with auth enabled."""
return self._require(

View File

@ -89,7 +89,7 @@ from pymongo.asynchronous import encryption
from pymongo.asynchronous.encryption import Algorithm, AsyncClientEncryption, QueryType
from pymongo.asynchronous.mongo_client import AsyncMongoClient
from pymongo.cursor_shared import CursorType
from pymongo.encryption_options import _HAVE_PYMONGOCRYPT, AutoEncryptionOpts, RangeOpts
from pymongo.encryption_options import _HAVE_PYMONGOCRYPT, AutoEncryptionOpts, RangeOpts, TextOpts
from pymongo.errors import (
AutoReconnect,
BulkWriteError,
@ -3443,6 +3443,261 @@ class TestAutomaticDecryptionKeys(AsyncEncryptionIntegrationTest):
self.assertIsInstance(exc.exception.encrypted_fields["fields"][0]["keyId"], Binary)
# https://github.com/mongodb/specifications/blob/master/source/client-side-encryption/tests/README.md#27-text-explicit-encryption
class TestExplicitTextEncryptionProse(AsyncEncryptionIntegrationTest):
@async_client_context.require_no_standalone
@async_client_context.require_version_min(8, 2, -1)
@async_client_context.require_libmongocrypt_min(1, 15, 1)
async def asyncSetUp(self):
await super().asyncSetUp()
# Load the file key1-document.json as key1Document.
self.key1_document = json_data("etc", "data", "keys", "key1-document.json")
# Read the "_id" field of key1Document as key1ID.
self.key1_id = self.key1_document["_id"]
# Drop and create the collection keyvault.datakeys.
# Insert key1Document in keyvault.datakeys with majority write concern.
self.key_vault = await create_key_vault(self.client.keyvault.datakeys, self.key1_document)
self.addAsyncCleanup(self.key_vault.drop)
# Create a ClientEncryption object named clientEncryption with these options.
self.kms_providers = {"local": {"key": LOCAL_MASTER_KEY}}
self.client_encryption = self.create_client_encryption(
self.kms_providers,
self.key_vault.full_name,
self.client,
OPTS,
)
# Create a MongoClient named encryptedClient with these AutoEncryptionOpts.
opts = AutoEncryptionOpts(
self.kms_providers,
"keyvault.datakeys",
bypass_query_analysis=True,
)
self.client_encrypted = await self.async_rs_or_single_client(auto_encryption_opts=opts)
# Using QE CreateCollection() and Collection.Drop(), drop and create the following collections with majority write concern:
# db.prefix-suffix using the encryptedFields option set to the contents of encryptedFields-prefix-suffix.json.
db = self.client_encrypted.db
await db.drop_collection("prefix-suffix")
encrypted_fields = json_data("etc", "data", "encryptedFields-prefix-suffix.json")
await self.client_encryption.create_encrypted_collection(
db, "prefix-suffix", kms_provider="local", encrypted_fields=encrypted_fields
)
# db.substring using the encryptedFields option set to the contents of encryptedFields-substring.json.
await db.drop_collection("substring")
encrypted_fields = json_data("etc", "data", "encryptedFields-substring.json")
await self.client_encryption.create_encrypted_collection(
db, "substring", kms_provider="local", encrypted_fields=encrypted_fields
)
# Use clientEncryption to encrypt the string "foobarbaz" with the following EncryptOpts.
text_opts = TextOpts(
case_sensitive=True,
diacritic_sensitive=True,
prefix=dict(strMaxQueryLength=10, strMinQueryLength=2),
suffix=dict(strMaxQueryLength=10, strMinQueryLength=2),
)
encrypted_value = await self.client_encryption.encrypt(
"foobarbaz",
key_id=self.key1_id,
algorithm=Algorithm.TEXTPREVIEW,
contention_factor=0,
text_opts=text_opts,
)
# Use encryptedClient to insert the following document into db.prefix-suffix with majority write concern.
coll = self.client_encrypted.db["prefix-suffix"].with_options(
write_concern=WriteConcern(w="majority")
)
await coll.insert_one({"_id": 0, "encryptedText": encrypted_value})
# Use clientEncryption to encrypt the string "foobarbaz" with the following EncryptOpts.
text_opts = TextOpts(
case_sensitive=True,
diacritic_sensitive=True,
substring=dict(strMaxLength=10, strMaxQueryLength=10, strMinQueryLength=2),
)
encrypted_value = await self.client_encryption.encrypt(
"foobarbaz",
key_id=self.key1_id,
algorithm=Algorithm.TEXTPREVIEW,
contention_factor=0,
text_opts=text_opts,
)
# Use encryptedClient to insert the following document into db.substring with majority write concern.
coll = self.client_encrypted.db["substring"].with_options(
write_concern=WriteConcern(w="majority")
)
await coll.insert_one({"_id": 0, "encryptedText": encrypted_value})
async def test_01_can_find_a_document_by_prefix(self):
# Use clientEncryption.encrypt() to encrypt the string "foo" with the following EncryptOpts.
text_opts = TextOpts(
case_sensitive=True,
diacritic_sensitive=True,
prefix=dict(strMaxQueryLength=10, strMinQueryLength=2),
)
encrypted_value = await self.client_encryption.encrypt(
"foo",
key_id=self.key1_id,
algorithm=Algorithm.TEXTPREVIEW,
query_type=QueryType.PREFIXPREVIEW,
contention_factor=0,
text_opts=text_opts,
)
# Use encryptedClient to run a "find" operation on the db.prefix-suffix collection with the following filter.
value = await self.client_encrypted.db["prefix-suffix"].find_one(
{"$expr": {"$encStrStartsWith": {"input": "$encryptedText", "prefix": encrypted_value}}}
)
# Assert the following document is returned.
expected = {"_id": 0, "encryptedText": "foobarbaz"}
value.pop("__safeContent__", None)
self.assertEqual(value, expected)
async def test_02_can_find_a_document_by_suffix(self):
# Use clientEncryption.encrypt() to encrypt the string "baz" with the following EncryptOpts:
text_opts = TextOpts(
case_sensitive=True,
diacritic_sensitive=True,
suffix=dict(strMaxQueryLength=10, strMinQueryLength=2),
)
encrypted_value = await self.client_encryption.encrypt(
"baz",
key_id=self.key1_id,
algorithm=Algorithm.TEXTPREVIEW,
query_type=QueryType.SUFFIXPREVIEW,
contention_factor=0,
text_opts=text_opts,
)
# Use encryptedClient to run a "find" operation on the db.prefix-suffix collection with the following filter:
value = await self.client_encrypted.db["prefix-suffix"].find_one(
{"$expr": {"$encStrEndsWith": {"input": "$encryptedText", "suffix": encrypted_value}}}
)
# Assert the following document is returned.
expected = {"_id": 0, "encryptedText": "foobarbaz"}
value.pop("__safeContent__", None)
self.assertEqual(value, expected)
async def test_03_no_document_found_by_prefix(self):
# Use clientEncryption.encrypt() to encrypt the string "baz" with the following EncryptOpts:
text_opts = TextOpts(
case_sensitive=True,
diacritic_sensitive=True,
prefix=dict(strMaxQueryLength=10, strMinQueryLength=2),
)
encrypted_value = await self.client_encryption.encrypt(
"baz",
key_id=self.key1_id,
algorithm=Algorithm.TEXTPREVIEW,
query_type=QueryType.PREFIXPREVIEW,
contention_factor=0,
text_opts=text_opts,
)
# Use encryptedClient to run a "find" operation on the db.prefix-suffix collection with the following filter:
value = await self.client_encrypted.db["prefix-suffix"].find_one(
{"$expr": {"$encStrStartsWith": {"input": "$encryptedText", "prefix": encrypted_value}}}
)
# Assert that no documents are returned.
self.assertIsNone(value)
async def test_04_no_document_found_by_suffix(self):
# Use clientEncryption.encrypt() to encrypt the string "foo" with the following EncryptOpts:
text_opts = TextOpts(
case_sensitive=True,
diacritic_sensitive=True,
suffix=dict(strMaxQueryLength=10, strMinQueryLength=2),
)
encrypted_value = await self.client_encryption.encrypt(
"foo",
key_id=self.key1_id,
algorithm=Algorithm.TEXTPREVIEW,
query_type=QueryType.SUFFIXPREVIEW,
contention_factor=0,
text_opts=text_opts,
)
# Use encryptedClient to run a "find" operation on the db.prefix-suffix collection with the following filter:
value = await self.client_encrypted.db["prefix-suffix"].find_one(
{"$expr": {"$encStrEndsWith": {"input": "$encryptedText", "suffix": encrypted_value}}}
)
# Assert that no documents are returned.
self.assertIsNone(value)
async def test_05_can_find_a_document_by_substring(self):
# Use clientEncryption.encrypt() to encrypt the string "bar" with the following EncryptOpts:
text_opts = TextOpts(
case_sensitive=True,
diacritic_sensitive=True,
substring=dict(strMaxLength=10, strMaxQueryLength=10, strMinQueryLength=2),
)
encrypted_value = await self.client_encryption.encrypt(
"bar",
key_id=self.key1_id,
algorithm=Algorithm.TEXTPREVIEW,
query_type=QueryType.SUBSTRINGPREVIEW,
contention_factor=0,
text_opts=text_opts,
)
# Use encryptedClient to run a "find" operation on the db.substring collection with the following filter:
value = await self.client_encrypted.db["substring"].find_one(
{
"$expr": {
"$encStrContains": {"input": "$encryptedText", "substring": encrypted_value}
}
}
)
# Assert the following document is returned:
expected = {"_id": 0, "encryptedText": "foobarbaz"}
value.pop("__safeContent__", None)
self.assertEqual(value, expected)
async def test_06_no_document_found_by_substring(self):
# Use clientEncryption.encrypt() to encrypt the string "qux" with the following EncryptOpts:
text_opts = TextOpts(
case_sensitive=True,
diacritic_sensitive=True,
substring=dict(strMaxLength=10, strMaxQueryLength=10, strMinQueryLength=2),
)
encrypted_value = await self.client_encryption.encrypt(
"qux",
key_id=self.key1_id,
algorithm=Algorithm.TEXTPREVIEW,
query_type=QueryType.SUBSTRINGPREVIEW,
contention_factor=0,
text_opts=text_opts,
)
# Use encryptedClient to run a "find" operation on the db.substring collection with the following filter:
value = await self.client_encrypted.db["substring"].find_one(
{
"$expr": {
"$encStrContains": {"input": "$encryptedText", "substring": encrypted_value}
}
}
)
# Assert that no documents are returned.
self.assertIsNone(value)
async def test_07_contentionFactor_is_required(self):
from pymongocrypt.errors import MongoCryptError
# Use clientEncryption.encrypt() to encrypt the string "foo" with the following EncryptOpts:
text_opts = TextOpts(
case_sensitive=True,
diacritic_sensitive=True,
prefix=dict(strMaxQueryLength=10, strMinQueryLength=2),
)
with self.assertRaises(EncryptionError) as ctx:
await self.client_encryption.encrypt(
"foo",
key_id=self.key1_id,
algorithm=Algorithm.TEXTPREVIEW,
query_type=QueryType.PREFIXPREVIEW,
text_opts=text_opts,
)
# Expect an error from libmongocrypt with a message containing the string: "contention factor is required for textPreview algorithm".
self.assertIsInstance(ctx.exception.cause, MongoCryptError)
self.assertEqual(
str(ctx.exception), "contention factor is required for textPreview algorithm"
)
def start_mongocryptd(port) -> None:
args = ["mongocryptd", f"--port={port}", "--idleShutdownTimeoutSecs=60"]
_spawn_daemon(args)

View File

@ -86,7 +86,7 @@ from bson.json_util import JSONOptions
from bson.son import SON
from pymongo import ReadPreference
from pymongo.cursor_shared import CursorType
from pymongo.encryption_options import _HAVE_PYMONGOCRYPT, AutoEncryptionOpts, RangeOpts
from pymongo.encryption_options import _HAVE_PYMONGOCRYPT, AutoEncryptionOpts, RangeOpts, TextOpts
from pymongo.errors import (
AutoReconnect,
BulkWriteError,
@ -3425,6 +3425,261 @@ class TestAutomaticDecryptionKeys(EncryptionIntegrationTest):
self.assertIsInstance(exc.exception.encrypted_fields["fields"][0]["keyId"], Binary)
# https://github.com/mongodb/specifications/blob/master/source/client-side-encryption/tests/README.md#27-text-explicit-encryption
class TestExplicitTextEncryptionProse(EncryptionIntegrationTest):
@client_context.require_no_standalone
@client_context.require_version_min(8, 2, -1)
@client_context.require_libmongocrypt_min(1, 15, 1)
def setUp(self):
super().setUp()
# Load the file key1-document.json as key1Document.
self.key1_document = json_data("etc", "data", "keys", "key1-document.json")
# Read the "_id" field of key1Document as key1ID.
self.key1_id = self.key1_document["_id"]
# Drop and create the collection keyvault.datakeys.
# Insert key1Document in keyvault.datakeys with majority write concern.
self.key_vault = create_key_vault(self.client.keyvault.datakeys, self.key1_document)
self.addCleanup(self.key_vault.drop)
# Create a ClientEncryption object named clientEncryption with these options.
self.kms_providers = {"local": {"key": LOCAL_MASTER_KEY}}
self.client_encryption = self.create_client_encryption(
self.kms_providers,
self.key_vault.full_name,
self.client,
OPTS,
)
# Create a MongoClient named encryptedClient with these AutoEncryptionOpts.
opts = AutoEncryptionOpts(
self.kms_providers,
"keyvault.datakeys",
bypass_query_analysis=True,
)
self.client_encrypted = self.rs_or_single_client(auto_encryption_opts=opts)
# Using QE CreateCollection() and Collection.Drop(), drop and create the following collections with majority write concern:
# db.prefix-suffix using the encryptedFields option set to the contents of encryptedFields-prefix-suffix.json.
db = self.client_encrypted.db
db.drop_collection("prefix-suffix")
encrypted_fields = json_data("etc", "data", "encryptedFields-prefix-suffix.json")
self.client_encryption.create_encrypted_collection(
db, "prefix-suffix", kms_provider="local", encrypted_fields=encrypted_fields
)
# db.substring using the encryptedFields option set to the contents of encryptedFields-substring.json.
db.drop_collection("substring")
encrypted_fields = json_data("etc", "data", "encryptedFields-substring.json")
self.client_encryption.create_encrypted_collection(
db, "substring", kms_provider="local", encrypted_fields=encrypted_fields
)
# Use clientEncryption to encrypt the string "foobarbaz" with the following EncryptOpts.
text_opts = TextOpts(
case_sensitive=True,
diacritic_sensitive=True,
prefix=dict(strMaxQueryLength=10, strMinQueryLength=2),
suffix=dict(strMaxQueryLength=10, strMinQueryLength=2),
)
encrypted_value = self.client_encryption.encrypt(
"foobarbaz",
key_id=self.key1_id,
algorithm=Algorithm.TEXTPREVIEW,
contention_factor=0,
text_opts=text_opts,
)
# Use encryptedClient to insert the following document into db.prefix-suffix with majority write concern.
coll = self.client_encrypted.db["prefix-suffix"].with_options(
write_concern=WriteConcern(w="majority")
)
coll.insert_one({"_id": 0, "encryptedText": encrypted_value})
# Use clientEncryption to encrypt the string "foobarbaz" with the following EncryptOpts.
text_opts = TextOpts(
case_sensitive=True,
diacritic_sensitive=True,
substring=dict(strMaxLength=10, strMaxQueryLength=10, strMinQueryLength=2),
)
encrypted_value = self.client_encryption.encrypt(
"foobarbaz",
key_id=self.key1_id,
algorithm=Algorithm.TEXTPREVIEW,
contention_factor=0,
text_opts=text_opts,
)
# Use encryptedClient to insert the following document into db.substring with majority write concern.
coll = self.client_encrypted.db["substring"].with_options(
write_concern=WriteConcern(w="majority")
)
coll.insert_one({"_id": 0, "encryptedText": encrypted_value})
def test_01_can_find_a_document_by_prefix(self):
# Use clientEncryption.encrypt() to encrypt the string "foo" with the following EncryptOpts.
text_opts = TextOpts(
case_sensitive=True,
diacritic_sensitive=True,
prefix=dict(strMaxQueryLength=10, strMinQueryLength=2),
)
encrypted_value = self.client_encryption.encrypt(
"foo",
key_id=self.key1_id,
algorithm=Algorithm.TEXTPREVIEW,
query_type=QueryType.PREFIXPREVIEW,
contention_factor=0,
text_opts=text_opts,
)
# Use encryptedClient to run a "find" operation on the db.prefix-suffix collection with the following filter.
value = self.client_encrypted.db["prefix-suffix"].find_one(
{"$expr": {"$encStrStartsWith": {"input": "$encryptedText", "prefix": encrypted_value}}}
)
# Assert the following document is returned.
expected = {"_id": 0, "encryptedText": "foobarbaz"}
value.pop("__safeContent__", None)
self.assertEqual(value, expected)
def test_02_can_find_a_document_by_suffix(self):
# Use clientEncryption.encrypt() to encrypt the string "baz" with the following EncryptOpts:
text_opts = TextOpts(
case_sensitive=True,
diacritic_sensitive=True,
suffix=dict(strMaxQueryLength=10, strMinQueryLength=2),
)
encrypted_value = self.client_encryption.encrypt(
"baz",
key_id=self.key1_id,
algorithm=Algorithm.TEXTPREVIEW,
query_type=QueryType.SUFFIXPREVIEW,
contention_factor=0,
text_opts=text_opts,
)
# Use encryptedClient to run a "find" operation on the db.prefix-suffix collection with the following filter:
value = self.client_encrypted.db["prefix-suffix"].find_one(
{"$expr": {"$encStrEndsWith": {"input": "$encryptedText", "suffix": encrypted_value}}}
)
# Assert the following document is returned.
expected = {"_id": 0, "encryptedText": "foobarbaz"}
value.pop("__safeContent__", None)
self.assertEqual(value, expected)
def test_03_no_document_found_by_prefix(self):
# Use clientEncryption.encrypt() to encrypt the string "baz" with the following EncryptOpts:
text_opts = TextOpts(
case_sensitive=True,
diacritic_sensitive=True,
prefix=dict(strMaxQueryLength=10, strMinQueryLength=2),
)
encrypted_value = self.client_encryption.encrypt(
"baz",
key_id=self.key1_id,
algorithm=Algorithm.TEXTPREVIEW,
query_type=QueryType.PREFIXPREVIEW,
contention_factor=0,
text_opts=text_opts,
)
# Use encryptedClient to run a "find" operation on the db.prefix-suffix collection with the following filter:
value = self.client_encrypted.db["prefix-suffix"].find_one(
{"$expr": {"$encStrStartsWith": {"input": "$encryptedText", "prefix": encrypted_value}}}
)
# Assert that no documents are returned.
self.assertIsNone(value)
def test_04_no_document_found_by_suffix(self):
# Use clientEncryption.encrypt() to encrypt the string "foo" with the following EncryptOpts:
text_opts = TextOpts(
case_sensitive=True,
diacritic_sensitive=True,
suffix=dict(strMaxQueryLength=10, strMinQueryLength=2),
)
encrypted_value = self.client_encryption.encrypt(
"foo",
key_id=self.key1_id,
algorithm=Algorithm.TEXTPREVIEW,
query_type=QueryType.SUFFIXPREVIEW,
contention_factor=0,
text_opts=text_opts,
)
# Use encryptedClient to run a "find" operation on the db.prefix-suffix collection with the following filter:
value = self.client_encrypted.db["prefix-suffix"].find_one(
{"$expr": {"$encStrEndsWith": {"input": "$encryptedText", "suffix": encrypted_value}}}
)
# Assert that no documents are returned.
self.assertIsNone(value)
def test_05_can_find_a_document_by_substring(self):
# Use clientEncryption.encrypt() to encrypt the string "bar" with the following EncryptOpts:
text_opts = TextOpts(
case_sensitive=True,
diacritic_sensitive=True,
substring=dict(strMaxLength=10, strMaxQueryLength=10, strMinQueryLength=2),
)
encrypted_value = self.client_encryption.encrypt(
"bar",
key_id=self.key1_id,
algorithm=Algorithm.TEXTPREVIEW,
query_type=QueryType.SUBSTRINGPREVIEW,
contention_factor=0,
text_opts=text_opts,
)
# Use encryptedClient to run a "find" operation on the db.substring collection with the following filter:
value = self.client_encrypted.db["substring"].find_one(
{
"$expr": {
"$encStrContains": {"input": "$encryptedText", "substring": encrypted_value}
}
}
)
# Assert the following document is returned:
expected = {"_id": 0, "encryptedText": "foobarbaz"}
value.pop("__safeContent__", None)
self.assertEqual(value, expected)
def test_06_no_document_found_by_substring(self):
# Use clientEncryption.encrypt() to encrypt the string "qux" with the following EncryptOpts:
text_opts = TextOpts(
case_sensitive=True,
diacritic_sensitive=True,
substring=dict(strMaxLength=10, strMaxQueryLength=10, strMinQueryLength=2),
)
encrypted_value = self.client_encryption.encrypt(
"qux",
key_id=self.key1_id,
algorithm=Algorithm.TEXTPREVIEW,
query_type=QueryType.SUBSTRINGPREVIEW,
contention_factor=0,
text_opts=text_opts,
)
# Use encryptedClient to run a "find" operation on the db.substring collection with the following filter:
value = self.client_encrypted.db["substring"].find_one(
{
"$expr": {
"$encStrContains": {"input": "$encryptedText", "substring": encrypted_value}
}
}
)
# Assert that no documents are returned.
self.assertIsNone(value)
def test_07_contentionFactor_is_required(self):
from pymongocrypt.errors import MongoCryptError
# Use clientEncryption.encrypt() to encrypt the string "foo" with the following EncryptOpts:
text_opts = TextOpts(
case_sensitive=True,
diacritic_sensitive=True,
prefix=dict(strMaxQueryLength=10, strMinQueryLength=2),
)
with self.assertRaises(EncryptionError) as ctx:
self.client_encryption.encrypt(
"foo",
key_id=self.key1_id,
algorithm=Algorithm.TEXTPREVIEW,
query_type=QueryType.PREFIXPREVIEW,
text_opts=text_opts,
)
# Expect an error from libmongocrypt with a message containing the string: "contention factor is required for textPreview algorithm".
self.assertIsInstance(ctx.exception.cause, MongoCryptError)
self.assertEqual(
str(ctx.exception), "contention factor is required for textPreview algorithm"
)
def start_mongocryptd(port) -> None:
args = ["mongocryptd", f"--port={port}", "--idleShutdownTimeoutSecs=60"]
_spawn_daemon(args)

4
uv.lock generated
View File

@ -1350,8 +1350,8 @@ wheels = [
[[package]]
name = "pymongocrypt"
version = "1.14.2.dev0"
source = { git = "https://github.com/mongodb/libmongocrypt?subdirectory=bindings%2Fpython&rev=master#56048cf426bfeffa0805934b668a7af5ed8e907c" }
version = "1.16.0"
source = { git = "https://github.com/mongodb/libmongocrypt?subdirectory=bindings%2Fpython&rev=master#63d2591b84a9d4348cbe1c74556e266cd560ac5b" }
dependencies = [
{ name = "cffi", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.14.*'" },
{ name = "cffi", version = "2.0.0b1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.14.*'" },