Skip to content

Commit 137049f

Browse files
mbhaskarCopilot
andcommitted
Add GlobalSecondaryIndexDefinition for GSI container support
Implement client-side support for Global Secondary Index (GSI) containers in the Python Cosmos SDK, mirroring the Java SDK implementation. Changes: - Add GlobalSecondaryIndexDefinition class with serialization/deserialization - Add global_secondary_index_definition keyword to create_container, create_container_if_not_exists, and replace_container (sync and async) - Implement dual-write pattern: writes to both globalSecondaryIndexDefinition and materializedViewDefinition keys for backward compatibility - Add comprehensive unit tests (20 tests) - Update CHANGELOG Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent f42a76a commit 137049f

6 files changed

Lines changed: 403 additions & 1 deletion

File tree

sdk/cosmos/azure-cosmos/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
* Added `get_response_headers()` and `get_last_response_headers()` methods to the `CosmosItemPaged` and `CosmosAsyncItemPaged` objects returned by `query_items()`, allowing access to response headers from query operations. See [PR 44593](https://github.com/Azure/azure-sdk-for-python/pull/44593)
88
* Added InferenceRequestTimeout property for HttpTimeout Policy to Reranking API. See [45469](https://github.com/Azure/azure-sdk-for-python/pull/45469)
99
* Added `full_text_score_scope` parameter to `query_items()` for controlling BM25 statistics scope in hybrid search queries. Supports "Local" and "Global" (default) scopes. See [45686](https://github.com/Azure/azure-sdk-for-python/pull/45686)
10+
* Added `GlobalSecondaryIndexDefinition` class and `global_secondary_index_definition` keyword to `create_container`, `create_container_if_not_exists`, and `replace_container` methods for creating Global Secondary Index (GSI) containers.
1011

1112
#### Breaking Changes
1213

sdk/cosmos/azure-cosmos/azure/cosmos/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
)
4444
from .partition_key import PartitionKey
4545
from .permission import Permission
46+
from ._global_secondary_index import GlobalSecondaryIndexDefinition
4647

4748
__all__ = (
4849
"CosmosClient",
@@ -66,6 +67,7 @@
6667
"ConnectionRetryPolicy",
6768
"ThroughputProperties",
6869
"CosmosDict",
69-
"CosmosList"
70+
"CosmosList",
71+
"GlobalSecondaryIndexDefinition"
7072
)
7173
__version__ = VERSION
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
# The MIT License (MIT)
2+
# Copyright (c) 2014 Microsoft Corporation
3+
4+
# Permission is hereby granted, free of charge, to any person obtaining a copy
5+
# of this software and associated documentation files (the "Software"), to deal
6+
# in the Software without restriction, including without limitation the rights
7+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8+
# copies of the Software, and to permit persons to whom the Software is
9+
# furnished to do so, subject to the following conditions:
10+
11+
# The above copyright notice and this permission notice shall be included in all
12+
# copies or substantial portions of the Software.
13+
14+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20+
# SOFTWARE.
21+
22+
"""Global Secondary Index (GSI) container definition."""
23+
24+
from typing import Optional
25+
26+
27+
class GlobalSecondaryIndexDefinition:
28+
"""Definition for a Global Secondary Index (GSI) container.
29+
30+
A GSI container is a derived container built from a source container
31+
using a SQL-like projection query. The GSI definition is immutable after creation.
32+
33+
.. note::
34+
A maximum of 5 GSI containers can be created per source container.
35+
All GSI containers must be deleted before deleting the source container.
36+
37+
:param str source_container_id: The ID of the source container the GSI is derived from. Required.
38+
:param str definition: The SQL-like projection query that defines the GSI. Required.
39+
"""
40+
41+
def __init__(self, source_container_id: str, definition: str):
42+
if not source_container_id or not source_container_id.strip():
43+
raise ValueError("source_container_id cannot be None or empty.")
44+
if not definition or not definition.strip():
45+
raise ValueError("definition cannot be None or empty.")
46+
self._source_container_id = source_container_id
47+
self._definition = definition
48+
self._source_container_rid: Optional[str] = None
49+
self._status: Optional[str] = None
50+
51+
@property
52+
def source_container_id(self) -> str:
53+
"""The ID of the source container.
54+
55+
:returns: The source container ID.
56+
:rtype: str
57+
"""
58+
return self._source_container_id
59+
60+
@property
61+
def definition(self) -> str:
62+
"""The SQL-like projection query that defines the GSI.
63+
64+
:returns: The projection query.
65+
:rtype: str
66+
"""
67+
return self._definition
68+
69+
@property
70+
def source_container_rid(self) -> Optional[str]:
71+
"""The server-populated resource ID (_rid) of the source container. Read-only.
72+
73+
:returns: The source container resource ID, or None if not yet populated.
74+
:rtype: str or None
75+
"""
76+
return self._source_container_rid
77+
78+
@property
79+
def status(self) -> Optional[str]:
80+
"""The GSI build status. Read-only, server-populated.
81+
82+
Possible values: "Initializing", "InitialBuildAfterCreate",
83+
"InitialBuildAfterRestore", "Active", "DeleteInProgress"
84+
85+
:returns: The GSI status, or None if not yet populated.
86+
:rtype: str or None
87+
"""
88+
return self._status
89+
90+
def _to_dict(self) -> dict:
91+
"""Serialize to wire format dict.
92+
93+
:returns: A dictionary representation of the GSI definition.
94+
:rtype: dict
95+
"""
96+
result: dict = {
97+
"sourceCollectionId": self._source_container_id,
98+
"definition": self._definition,
99+
}
100+
if self._source_container_rid is not None:
101+
result["sourceCollectionRid"] = self._source_container_rid
102+
if self._status is not None:
103+
result["status"] = self._status
104+
return result
105+
106+
@classmethod
107+
def _from_dict(cls, data: Optional[dict]) -> Optional["GlobalSecondaryIndexDefinition"]:
108+
"""Deserialize from wire format dict.
109+
110+
:param dict data: The wire format dictionary.
111+
:returns: A GlobalSecondaryIndexDefinition instance, or None if data is None or invalid.
112+
:rtype: ~azure.cosmos.GlobalSecondaryIndexDefinition or None
113+
"""
114+
if data is None:
115+
return None
116+
source_container_id = data.get("sourceCollectionId")
117+
definition_query = data.get("definition")
118+
if not source_container_id or not definition_query:
119+
return None
120+
instance = cls(source_container_id, definition_query)
121+
instance._source_container_rid = data.get("sourceCollectionRid") # pylint: disable=protected-access
122+
instance._status = data.get("status") # pylint: disable=protected-access
123+
return instance

sdk/cosmos/azure-cosmos/azure/cosmos/aio/_database.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ async def create_container(
178178
vector_embedding_policy: Optional[dict[str, Any]] = None,
179179
change_feed_policy: Optional[dict[str, Any]] = None,
180180
full_text_policy: Optional[dict[str, Any]] = None,
181+
global_secondary_index_definition: Optional[Any] = None,
181182
return_properties: Literal[False] = False,
182183
**kwargs: Any
183184
) -> ContainerProxy:
@@ -255,6 +256,7 @@ async def create_container( # pylint: disable=too-many-statements
255256
vector_embedding_policy: Optional[dict[str, Any]] = None,
256257
change_feed_policy: Optional[dict[str, Any]] = None,
257258
full_text_policy: Optional[dict[str, Any]] = None,
259+
global_secondary_index_definition: Optional[Any] = None,
258260
return_properties: Literal[True],
259261
**kwargs: Any
260262
) -> tuple[ContainerProxy, CosmosDict]:
@@ -353,6 +355,9 @@ async def create_container( # pylint:disable=docstring-should-be-keyword, too-ma
353355
:keyword dict[str, Any] full_text_policy: **provisional** The full text policy for the container.
354356
Used to denote the default language to be used for all full text indexes, or to individually
355357
assign a language to each full text index path.
358+
:keyword global_secondary_index_definition: The global secondary index definition for the container.
359+
Used to create a GSI container derived from a source container via a SQL projection query.
360+
:paramtype global_secondary_index_definition: ~azure.cosmos.GlobalSecondaryIndexDefinition or dict[str, Any]
356361
:keyword bool return_properties: Specifies whether to return either a ContainerProxy
357362
or a Tuple of a ContainerProxy and the container properties.
358363
:raises ~azure.cosmos.exceptions.CosmosHttpResponseError: The container creation failed.
@@ -393,6 +398,7 @@ async def create_container( # pylint:disable=docstring-should-be-keyword, too-ma
393398
computed_properties = kwargs.pop('computed_properties', None)
394399
change_feed_policy = kwargs.pop('change_feed_policy', None)
395400
full_text_policy = kwargs.pop('full_text_policy', None)
401+
global_secondary_index_definition = kwargs.pop('global_secondary_index_definition', None)
396402
return_properties = kwargs.pop('return_properties', False)
397403

398404
session_token = kwargs.get('session_token')
@@ -440,6 +446,12 @@ async def create_container( # pylint:disable=docstring-should-be-keyword, too-ma
440446
definition["changeFeedPolicy"] = change_feed_policy
441447
if full_text_policy is not None:
442448
definition["fullTextPolicy"] = full_text_policy
449+
if global_secondary_index_definition is not None:
450+
gsi_dict = (global_secondary_index_definition._to_dict()
451+
if hasattr(global_secondary_index_definition, '_to_dict')
452+
else global_secondary_index_definition)
453+
definition["globalSecondaryIndexDefinition"] = gsi_dict
454+
definition["materializedViewDefinition"] = gsi_dict
443455
request_options = _build_options(kwargs)
444456
_set_throughput_options(offer=offer_throughput, request_options=request_options)
445457

@@ -467,6 +479,7 @@ async def create_container_if_not_exists(
467479
vector_embedding_policy: Optional[dict[str, Any]] = None,
468480
change_feed_policy: Optional[dict[str, Any]] = None,
469481
full_text_policy: Optional[dict[str, Any]] = None,
482+
global_secondary_index_definition: Optional[Any] = None,
470483
return_properties: Literal[False] = False,
471484
**kwargs: Any
472485
) -> ContainerProxy:
@@ -528,6 +541,7 @@ async def create_container_if_not_exists(
528541
vector_embedding_policy: Optional[dict[str, Any]] = None,
529542
change_feed_policy: Optional[dict[str, Any]] = None,
530543
full_text_policy: Optional[dict[str, Any]] = None,
544+
global_secondary_index_definition: Optional[Any] = None,
531545
return_properties: Literal[True],
532546
**kwargs: Any
533547
) -> tuple[ContainerProxy, CosmosDict]:
@@ -612,6 +626,9 @@ async def create_container_if_not_exists( # pylint:disable=docstring-should-be-k
612626
:keyword dict[str, Any] full_text_policy: **provisional** The full text policy for the container.
613627
Used to denote the default language to be used for all full text indexes, or to individually
614628
assign a language to each full text index path.
629+
:keyword global_secondary_index_definition: The global secondary index definition for the container.
630+
Used to create a GSI container derived from a source container via a SQL projection query.
631+
:paramtype global_secondary_index_definition: ~azure.cosmos.GlobalSecondaryIndexDefinition or dict[str, Any]
615632
:keyword bool return_properties: Specifies whether to return either a ContainerProxy
616633
or a Tuple of a ContainerProxy and the container properties.
617634
:raises ~azure.cosmos.exceptions.CosmosHttpResponseError: The container creation failed.
@@ -635,6 +652,7 @@ async def create_container_if_not_exists( # pylint:disable=docstring-should-be-k
635652
computed_properties = kwargs.pop('computed_properties', None)
636653
change_feed_policy = kwargs.pop('change_feed_policy', None)
637654
full_text_policy = kwargs.pop('full_text_policy', None)
655+
global_secondary_index_definition = kwargs.pop('global_secondary_index_definition', None)
638656
return_properties = kwargs.pop('return_properties', False)
639657

640658
session_token = kwargs.get('session_token')
@@ -679,6 +697,7 @@ async def create_container_if_not_exists( # pylint:disable=docstring-should-be-k
679697
vector_embedding_policy=vector_embedding_policy,
680698
change_feed_policy=change_feed_policy,
681699
full_text_policy=full_text_policy,
700+
global_secondary_index_definition=global_secondary_index_definition,
682701
return_properties=return_properties,
683702
**kwargs
684703
)
@@ -816,6 +835,7 @@ async def replace_container(
816835
analytical_storage_ttl: Optional[int] = None,
817836
computed_properties: Optional[list[dict[str, str]]] = None,
818837
full_text_policy: Optional[dict[str, Any]] = None,
838+
global_secondary_index_definition: Optional[Any] = None,
819839
return_properties: Literal[False] = False,
820840
vector_embedding_policy: Optional[dict[str, Any]] = None,
821841
**kwargs: Any
@@ -877,6 +897,7 @@ async def replace_container( # pylint:disable=docstring-missing-param
877897
analytical_storage_ttl: Optional[int] = None,
878898
computed_properties: Optional[list[dict[str, str]]] = None,
879899
full_text_policy: Optional[dict[str, Any]] = None,
900+
global_secondary_index_definition: Optional[Any] = None,
880901
return_properties: Literal[True],
881902
vector_embedding_policy: Optional[dict[str, Any]] = None,
882903
**kwargs: Any
@@ -959,6 +980,9 @@ async def replace_container( # pylint:disable=docstring-should-be-keyword
959980
:keyword dict[str, Any] full_text_policy: **provisional** The full text policy for the container.
960981
Used to denote the default language to be used for all full text indexes, or to individually
961982
assign a language to each full text index path.
983+
:keyword global_secondary_index_definition: The global secondary index definition for the container.
984+
Used to create a GSI container derived from a source container via a SQL projection query.
985+
:paramtype global_secondary_index_definition: ~azure.cosmos.GlobalSecondaryIndexDefinition or dict[str, Any]
962986
:keyword bool return_properties: Specifies whether to return either a ContainerProxy
963987
or a Tuple of a ContainerProxy and the container properties.
964988
:returns: A `ContainerProxy` instance representing the new container or a tuple of the ContainerProxy
@@ -989,6 +1013,7 @@ async def replace_container( # pylint:disable=docstring-should-be-keyword
9891013
analytical_storage_ttl = kwargs.pop('analytical_storage_ttl', None)
9901014
computed_properties = kwargs.pop('computed_properties', None)
9911015
full_text_policy = kwargs.pop('full_text_policy', None)
1016+
global_secondary_index_definition = kwargs.pop('global_secondary_index_definition', None)
9921017
return_properties = kwargs.pop('return_properties', False)
9931018
vector_embedding_policy = kwargs.pop('vector_embedding_policy', None)
9941019

@@ -1031,6 +1056,12 @@ async def replace_container( # pylint:disable=docstring-should-be-keyword
10311056
}.items()
10321057
if value is not None
10331058
}
1059+
if global_secondary_index_definition is not None:
1060+
gsi_dict = (global_secondary_index_definition._to_dict()
1061+
if hasattr(global_secondary_index_definition, '_to_dict')
1062+
else global_secondary_index_definition)
1063+
parameters["globalSecondaryIndexDefinition"] = gsi_dict
1064+
parameters["materializedViewDefinition"] = gsi_dict
10341065

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

0 commit comments

Comments
 (0)