Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,42 @@

"""Global Secondary Index (GSI) container definition."""

from typing import Optional
from typing import Any, Mapping, Optional, TypeVar

# Wire key used by the service for the GSI/materialized-view definition.
_MATERIALIZED_VIEW_DEFINITION_KEY = "materializedViewDefinition"
# Public-facing key the SDK surfaces for a GSI definition.
_GLOBAL_SECONDARY_INDEX_DEFINITION_KEY = "globalSecondaryIndexDefinition"

_PropertiesT = TypeVar("_PropertiesT", bound=Optional[Mapping[str, Any]])


def _normalize_gsi_container_properties(properties: _PropertiesT) -> _PropertiesT:
"""Surface ``globalSecondaryIndexDefinition`` from container properties.

Some service versions return the GSI definition under the legacy
``materializedViewDefinition`` key. Promote it to
``globalSecondaryIndexDefinition`` and drop the legacy key so the public
contract never exposes ``materializedViewDefinition``, regardless of the
backend contract version. The mutation is done in place when possible and
the same object is returned for convenience.

:param properties: The container properties returned by the service.
:type properties: Mapping[str, Any] or None
:returns: The container properties with ``globalSecondaryIndexDefinition`` populated.
:rtype: Mapping[str, Any] or None
"""
if properties is None or _MATERIALIZED_VIEW_DEFINITION_KEY not in properties:
return properties
try:
if _GLOBAL_SECONDARY_INDEX_DEFINITION_KEY not in properties:
properties[_GLOBAL_SECONDARY_INDEX_DEFINITION_KEY] = ( # type: ignore[index]
properties[_MATERIALIZED_VIEW_DEFINITION_KEY])
del properties[_MATERIALIZED_VIEW_DEFINITION_KEY] # type: ignore[attr-defined]
except TypeError:
# Read-only mapping; nothing to normalize in place.
pass
return properties


class GlobalSecondaryIndexDefinition:
Expand Down
2 changes: 2 additions & 0 deletions sdk/cosmos/azure-cosmos/azure/cosmos/aio/_container.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
from .._change_feed.feed_range_internal import FeedRangeInternalEpk

from .._cosmos_responses import CosmosDict, CosmosList, CosmosAsyncItemPaged
from .._global_secondary_index import _normalize_gsi_container_properties
from .._constants import _Constants as Constants, TimeoutScope
from .._routing.routing_range import Range
from .._session_token_helpers import get_latest_session_token
Expand Down Expand Up @@ -209,6 +210,7 @@ async def read(
if populate_quota_info is not None:
request_options["populateQuotaInfo"] = populate_quota_info
container = await self.client_connection.ReadContainer(self.container_link, options=request_options, **kwargs)
_normalize_gsi_container_properties(container)
# Only cache Container Properties that will not change in the lifetime of the container
self.client_connection._set_container_properties_cache(self.container_link, # pylint: disable=protected-access
_build_properties_cache(container, self.container_link))
Expand Down
38 changes: 31 additions & 7 deletions sdk/cosmos/azure-cosmos/azure/cosmos/aio/_database.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
from ..documents import IndexingMode
from ..partition_key import PartitionKey
from .._cosmos_responses import CosmosDict
from .._global_secondary_index import GlobalSecondaryIndexDefinition
from .._global_secondary_index import GlobalSecondaryIndexDefinition, _normalize_gsi_container_properties


__all__ = ("DatabaseProxy",)
Expand Down Expand Up @@ -469,9 +469,7 @@ async def create_container( # pylint:disable=docstring-should-be-keyword, too-ma
if full_text_policy is not None:
definition["fullTextPolicy"] = full_text_policy
if global_secondary_index_definition is not None:
gsi_dict = (global_secondary_index_definition._to_dict()
if hasattr(global_secondary_index_definition, '_to_dict')
else global_secondary_index_definition)
gsi_dict = await self._resolve_gsi_definition(global_secondary_index_definition)
definition["globalSecondaryIndexDefinition"] = gsi_dict
definition["materializedViewDefinition"] = gsi_dict
request_options = _build_options(kwargs)
Expand All @@ -480,6 +478,7 @@ async def create_container( # pylint:disable=docstring-should-be-keyword, too-ma
data = await self.client_connection.CreateContainer(
database_link=self.database_link, collection=definition, options=request_options, **kwargs
)
_normalize_gsi_container_properties(data)
if not return_properties:
return ContainerProxy(self.client_connection, self.database_link, data["id"], properties=data)
return ContainerProxy(self.client_connection, self.database_link, data["id"], properties=data), data
Expand Down Expand Up @@ -772,6 +771,32 @@ def get_container_client(self, container: Union[str, ContainerProxy, dict[str, A
id_value = str(container['id'])
return ContainerProxy(self.client_connection, self.database_link, id_value)

async def _resolve_gsi_definition(
self,
global_secondary_index_definition: Union["GlobalSecondaryIndexDefinition", dict[str, Any]],
) -> dict[str, Any]:
"""Serialize a GSI definition and populate the source container's resource id (_rid).

The service resolves the source container by its resource id (``sourceCollectionRid``).
When the caller only provides the source container id, read the source container to
obtain its ``_rid`` and populate ``sourceCollectionRid`` on the wire payload.

:param global_secondary_index_definition: The GSI definition object or raw dict.
:type global_secondary_index_definition:
~azure.cosmos.GlobalSecondaryIndexDefinition or dict[str, Any]
:returns: The serialized GSI definition including ``sourceCollectionRid``.
:rtype: dict[str, Any]
"""
gsi_dict = (global_secondary_index_definition._to_dict() # pylint: disable=protected-access
if hasattr(global_secondary_index_definition, '_to_dict')
else dict(global_secondary_index_definition))
if not gsi_dict.get("sourceCollectionRid"):
source_container_id = gsi_dict.get("sourceCollectionId")
if source_container_id:
source_properties = await self.get_container_client(source_container_id).read()
gsi_dict["sourceCollectionRid"] = source_properties["_rid"]
return gsi_dict

@distributed_trace
def list_containers(
self,
Expand Down Expand Up @@ -1109,15 +1134,14 @@ async def replace_container( # pylint:disable=docstring-should-be-keyword
if value is not None
}
if global_secondary_index_definition is not None:
gsi_dict = (global_secondary_index_definition._to_dict()
if hasattr(global_secondary_index_definition, '_to_dict')
else global_secondary_index_definition)
gsi_dict = await self._resolve_gsi_definition(global_secondary_index_definition)
parameters["globalSecondaryIndexDefinition"] = gsi_dict
parameters["materializedViewDefinition"] = gsi_dict

container_properties = await self.client_connection.ReplaceContainer(
container_link, collection=parameters, options=request_options, **kwargs
)
_normalize_gsi_container_properties(container_properties)

if not return_properties:
return ContainerProxy(
Expand Down
2 changes: 2 additions & 0 deletions sdk/cosmos/azure-cosmos/azure/cosmos/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
from ._constants import _Constants as Constants, TimeoutScope
from ._cosmos_client_connection import CosmosClientConnection
from ._cosmos_responses import CosmosDict, CosmosList, CosmosItemPaged
from ._global_secondary_index import _normalize_gsi_container_properties
from ._routing.routing_range import Range
from ._session_token_helpers import get_latest_session_token
from .exceptions import CosmosHttpResponseError
Expand Down Expand Up @@ -208,6 +209,7 @@ def read( # pylint:disable=docstring-missing-param
if populate_quota_info is not None:
request_options["populateQuotaInfo"] = populate_quota_info
container = self.client_connection.ReadContainer(self.container_link, options=request_options, **kwargs)
_normalize_gsi_container_properties(container)
# Only cache Container Properties that will not change in the lifetime of the container
self.client_connection._set_container_properties_cache(self.container_link, # pylint: disable=protected-access
_build_properties_cache(container, self.container_link))
Expand Down
38 changes: 31 additions & 7 deletions sdk/cosmos/azure-cosmos/azure/cosmos/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
from .user import UserProxy
from .documents import IndexingMode
from ._cosmos_responses import CosmosDict
from ._global_secondary_index import GlobalSecondaryIndexDefinition
from ._global_secondary_index import GlobalSecondaryIndexDefinition, _normalize_gsi_container_properties

__all__ = ("DatabaseProxy",)

Expand Down Expand Up @@ -459,16 +459,15 @@ def create_container( # pylint:disable=docstring-missing-param, too-many-statem
if full_text_policy is not None:
definition["fullTextPolicy"] = full_text_policy
if global_secondary_index_definition is not None:
gsi_dict = (global_secondary_index_definition._to_dict()
if hasattr(global_secondary_index_definition, '_to_dict')
else global_secondary_index_definition)
gsi_dict = self._resolve_gsi_definition(global_secondary_index_definition)
definition["globalSecondaryIndexDefinition"] = gsi_dict
definition["materializedViewDefinition"] = gsi_dict
request_options = build_options(kwargs)
_set_throughput_options(offer=offer_throughput, request_options=request_options)
result = self.client_connection.CreateContainer(
database_link=self.database_link, collection=definition, options=request_options, **kwargs
)
_normalize_gsi_container_properties(result)

if not return_properties:
return ContainerProxy(self.client_connection, self.database_link, result["id"], properties=result)
Expand Down Expand Up @@ -812,6 +811,32 @@ def get_container_client(self, container: Union[str, ContainerProxy, Mapping[str
id_value = container["id"]
return ContainerProxy(self.client_connection, self.database_link, id_value)

def _resolve_gsi_definition(
self,
global_secondary_index_definition: Union["GlobalSecondaryIndexDefinition", dict[str, Any]],
) -> dict[str, Any]:
"""Serialize a GSI definition and populate the source container's resource id (_rid).

The service resolves the source container by its resource id (``sourceCollectionRid``).
When the caller only provides the source container id, read the source container to
obtain its ``_rid`` and populate ``sourceCollectionRid`` on the wire payload.

:param global_secondary_index_definition: The GSI definition object or raw dict.
:type global_secondary_index_definition:
~azure.cosmos.GlobalSecondaryIndexDefinition or dict[str, Any]
:returns: The serialized GSI definition including ``sourceCollectionRid``.
:rtype: dict[str, Any]
"""
gsi_dict = (global_secondary_index_definition._to_dict() # pylint: disable=protected-access
if hasattr(global_secondary_index_definition, '_to_dict')
else dict(global_secondary_index_definition))
if not gsi_dict.get("sourceCollectionRid"):
source_container_id = gsi_dict.get("sourceCollectionId")
if source_container_id:
source_properties = self.get_container_client(source_container_id).read()
gsi_dict["sourceCollectionRid"] = source_properties["_rid"]
return gsi_dict

@distributed_trace
def list_containers( # pylint:disable=docstring-missing-param
self,
Expand Down Expand Up @@ -1161,14 +1186,13 @@ def replace_container( # pylint:disable=docstring-missing-param, docstring-shou
if value is not None
}
if global_secondary_index_definition is not None:
gsi_dict = (global_secondary_index_definition._to_dict()
if hasattr(global_secondary_index_definition, '_to_dict')
else global_secondary_index_definition)
gsi_dict = self._resolve_gsi_definition(global_secondary_index_definition)
parameters["globalSecondaryIndexDefinition"] = gsi_dict
parameters["materializedViewDefinition"] = gsi_dict

container_properties = self.client_connection.ReplaceContainer(
container_link, collection=parameters, options=request_options, **kwargs)
_normalize_gsi_container_properties(container_properties)

if not return_properties:
return ContainerProxy(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,19 +54,30 @@ def test_create_gsi_container(self):
source_container_id=source_container.id,
definition="SELECT c.id, c.email, c.name FROM c"
)
gsi_container = self.test_db.create_container(
gsi_container, create_properties = self.test_db.create_container(
id="gsi-container-" + str(uuid.uuid4())[:8],
partition_key=PartitionKey(path="/id"),
global_secondary_index_definition=gsi_definition
global_secondary_index_definition=gsi_definition,
return_properties=True
)

# The create response must surface the public "globalSecondaryIndexDefinition"
# key and must never expose the legacy "materializedViewDefinition" wire key.
self.assertIn("globalSecondaryIndexDefinition", create_properties)
self.assertNotIn("materializedViewDefinition", create_properties)

# Read back the container properties and verify GSI definition is present
properties = gsi_container.read()
self.assertIn("globalSecondaryIndexDefinition", properties)
self.assertNotIn("materializedViewDefinition", properties)
gsi_props = properties["globalSecondaryIndexDefinition"]
self.assertEqual(gsi_props["sourceCollectionId"], source_container.id)
self.assertEqual(gsi_props["definition"], "SELECT c.id, c.email, c.name FROM c")
self.assertIn("status", gsi_props)
# "status" is a read-only, server-populated field that may be absent from the
# response (e.g. before the index build is tracked). Match the Java SDK contract,
# which treats status as optional, and only validate it when the service returns it.
if "status" in gsi_props:
self.assertIsNotNone(gsi_props["status"])

# Clean up - delete GSI container first, then source
self.test_db.delete_container(gsi_container.id)
Expand Down
Loading