diff --git a/packages/google-cloud-bigtable/docs/data_client/async_data_materialized_view.rst b/packages/google-cloud-bigtable/docs/data_client/async_data_materialized_view.rst new file mode 100644 index 000000000000..5392400a2ce9 --- /dev/null +++ b/packages/google-cloud-bigtable/docs/data_client/async_data_materialized_view.rst @@ -0,0 +1,11 @@ +Materialized View Async +~~~~~~~~~~~~~~~~~~~~~~~ + + .. note:: + + It is generally not recommended to use the async client in an otherwise synchronous codebase. To make use of asyncio's + performance benefits, the codebase should be designed to be async from the ground up. + +.. autoclass:: google.cloud.bigtable.data._async.client.MaterializedViewAsync + :members: + :inherited-members: diff --git a/packages/google-cloud-bigtable/docs/data_client/data_client_usage.rst b/packages/google-cloud-bigtable/docs/data_client/data_client_usage.rst index 708dafc621cd..45b3c5bbe878 100644 --- a/packages/google-cloud-bigtable/docs/data_client/data_client_usage.rst +++ b/packages/google-cloud-bigtable/docs/data_client/data_client_usage.rst @@ -10,6 +10,7 @@ Sync Surface sync_data_client sync_data_table sync_data_authorized_view + sync_data_materialized_view sync_data_mutations_batcher sync_data_execute_query_iterator @@ -22,6 +23,7 @@ Async Surface async_data_client async_data_table async_data_authorized_view + async_data_materialized_view async_data_mutations_batcher async_data_execute_query_iterator diff --git a/packages/google-cloud-bigtable/docs/data_client/sync_data_materialized_view.rst b/packages/google-cloud-bigtable/docs/data_client/sync_data_materialized_view.rst new file mode 100644 index 000000000000..0ae924182386 --- /dev/null +++ b/packages/google-cloud-bigtable/docs/data_client/sync_data_materialized_view.rst @@ -0,0 +1,6 @@ +Materialized View +~~~~~~~~~~~~~~~~~ + +.. autoclass:: google.cloud.bigtable.data._sync_autogen.client.MaterializedView + :members: + :inherited-members: diff --git a/packages/google-cloud-bigtable/google/cloud/bigtable/data/__init__.py b/packages/google-cloud-bigtable/google/cloud/bigtable/data/__init__.py index 0cec39103b8e..b65d2a012fa2 100644 --- a/packages/google-cloud-bigtable/google/cloud/bigtable/data/__init__.py +++ b/packages/google-cloud-bigtable/google/cloud/bigtable/data/__init__.py @@ -19,6 +19,7 @@ from google.cloud.bigtable.data._async.client import ( AuthorizedViewAsync, BigtableDataClientAsync, + MaterializedViewAsync, TableAsync, ) from google.cloud.bigtable.data._async.mutations_batcher import MutationsBatcherAsync @@ -33,6 +34,7 @@ from google.cloud.bigtable.data._sync_autogen.client import ( AuthorizedView, BigtableDataClient, + MaterializedView, Table, ) from google.cloud.bigtable.data._sync_autogen.mutations_batcher import MutationsBatcher @@ -80,10 +82,12 @@ "BigtableDataClientAsync", "TableAsync", "AuthorizedViewAsync", + "MaterializedViewAsync", "MutationsBatcherAsync", "BigtableDataClient", "Table", "AuthorizedView", + "MaterializedView", "MutationsBatcher", "RowKeySamples", "ReadRowsQuery", diff --git a/packages/google-cloud-bigtable/google/cloud/bigtable/data/_async/client.py b/packages/google-cloud-bigtable/google/cloud/bigtable/data/_async/client.py index fefa480e8ad7..409038ec9f6c 100644 --- a/packages/google-cloud-bigtable/google/cloud/bigtable/data/_async/client.py +++ b/packages/google-cloud-bigtable/google/cloud/bigtable/data/_async/client.py @@ -708,6 +708,62 @@ def get_authorized_view( **kwargs, ) + @CrossSync.convert( + replace_symbols={"MaterializedViewAsync": "MaterializedView"}, + docstring_format_vars={ + "LOOP_MESSAGE": ( + "Must be created within an async context (running event loop)", + "", + ), + "RAISE_NO_LOOP": ( + "RuntimeError: if called outside of an async context (no running event loop)", + "None", + ), + }, + ) + def get_materialized_view( + self, instance_id: str, materialized_view_id: str, *args, **kwargs + ) -> MaterializedViewAsync: + """ + Returns a materialized view instance for making read requests. All arguments are passed + directly to the MaterializedViewAsync constructor. + + {LOOP_MESSAGE} + + Args: + instance_id: The Bigtable instance ID to associate with this client. + instance_id is combined with the client's project to fully + specify the instance + materialized_view_id: The id for the materialized view to use for requests + app_profile_id: The app profile to associate with requests. + https://cloud.google.com/bigtable/docs/app-profiles + default_read_rows_operation_timeout: The default timeout for read rows + operations, in seconds. If not set, defaults to 600 seconds (10 minutes) + default_read_rows_attempt_timeout: The default timeout for individual + read rows rpc requests, in seconds. If not set, defaults to 20 seconds + default_operation_timeout: The default timeout for all other operations, in + seconds. If not set, defaults to 60 seconds + default_attempt_timeout: The default timeout for all other individual rpc + requests, in seconds. If not set, defaults to 20 seconds + default_read_rows_retryable_errors: a list of errors that will be retried + if encountered during read_rows and related operations. + Defaults to 4 (DeadlineExceeded), 14 (ServiceUnavailable), and 10 (Aborted) + default_retryable_errors: a list of errors that will be retried if + encountered during all other operations. + Defaults to 4 (DeadlineExceeded) and 14 (ServiceUnavailable) + Returns: + MaterializedViewAsync: a materialized view instance for making read requests + Raises: + {RAISE_NO_LOOP} + """ + return CrossSync.MaterializedView( + self, + instance_id, + materialized_view_id, + *args, + **kwargs, + ) + @CrossSync.convert( replace_symbols={"ExecuteQueryIteratorAsync": "ExecuteQueryIterator"} ) @@ -928,7 +984,7 @@ class _DataApiTargetAsync(abc.ABC): """ Abstract class containing API surface for BigtableDataClient. Should not be created directly - Can be instantiated as a Table or an AuthorizedView + Can be instantiated as a Table, an AuthorizedView, or a MaterializedView """ @CrossSync.convert( @@ -948,7 +1004,6 @@ def __init__( self, client: BigtableDataClientAsync, instance_id: str, - table_id: str, app_profile_id: str | None = None, *, default_read_rows_operation_timeout: float = 600, @@ -973,7 +1028,7 @@ def __init__( ), ): """ - Initialize a Table instance + Initialize a data API target instance {LOOP_MESSAGE} @@ -981,8 +1036,6 @@ def __init__( instance_id: The Bigtable instance ID to associate with this client. instance_id is combined with the client's project to fully specify the instance - table_id: The ID of the table. table_id is combined with the - instance_id and the client's project to fully specify the table app_profile_id: The app profile to associate with requests. https://cloud.google.com/bigtable/docs/app-profiles default_read_rows_operation_timeout: The default timeout for read rows @@ -1009,8 +1062,6 @@ def __init__( Raises: {RAISE_NO_LOOP} """ - # NOTE: any changes to the signature of this method should also be reflected - # in client.get_table() # validate timeouts _validate_timeouts( default_operation_timeout, default_attempt_timeout, allow_none=True @@ -1031,10 +1082,6 @@ def __init__( self.instance_name = self.client._gapic_client.instance_path( self.client.project, instance_id ) - self.table_id = table_id - self.table_name = self.client._gapic_client.table_path( - self.client.project, instance_id, table_id - ) self.app_profile_id: str | None = app_profile_id self.default_operation_timeout: float = default_operation_timeout @@ -1081,7 +1128,11 @@ def __init__( @abc.abstractmethod def _request_path(self) -> dict[str, str]: """ - Used to populate table_name or authorized_view_name for rpc requests, depending on the subclass + Used to populate table_name, authorized_view_name, or materialized_view_name + for rpc requests, depending on the subclass + + The returned key must be a valid field on each request proto it is passed to; + mutation requests only accept table_name and authorized_view_name Unimplemented in base class """ @@ -1852,6 +1903,73 @@ class TableAsync(_DataApiTargetAsync): each call """ + @CrossSync.convert( + replace_symbols={"BigtableDataClientAsync": "BigtableDataClient"}, + docstring_format_vars={ + "LOOP_MESSAGE": ( + "Must be created within an async context (running event loop)", + "", + ), + "RAISE_NO_LOOP": ( + "RuntimeError: if called outside of an async context (no running event loop)", + "None", + ), + }, + ) + def __init__( + self, + client: BigtableDataClientAsync, + instance_id: str, + table_id: str, + app_profile_id: str | None = None, + **kwargs, + ): + """ + Initialize a Table instance + + {LOOP_MESSAGE} + + Args: + instance_id: The Bigtable instance ID to associate with this client. + instance_id is combined with the client's project to fully + specify the instance + table_id: The ID of the table. table_id is combined with the + instance_id and the client's project to fully specify the table + app_profile_id: The app profile to associate with requests. + https://cloud.google.com/bigtable/docs/app-profiles + default_read_rows_operation_timeout: The default timeout for read rows + operations, in seconds. If not set, defaults to 600 seconds (10 minutes) + default_read_rows_attempt_timeout: The default timeout for individual + read rows rpc requests, in seconds. If not set, defaults to 20 seconds + default_mutate_rows_operation_timeout: The default timeout for mutate rows + operations, in seconds. If not set, defaults to 600 seconds (10 minutes) + default_mutate_rows_attempt_timeout: The default timeout for individual + mutate rows rpc requests, in seconds. If not set, defaults to 60 seconds + default_operation_timeout: The default timeout for all other operations, in + seconds. If not set, defaults to 60 seconds + default_attempt_timeout: The default timeout for all other individual rpc + requests, in seconds. If not set, defaults to 20 seconds + default_read_rows_retryable_errors: a list of errors that will be retried + if encountered during read_rows and related operations. + Defaults to 4 (DeadlineExceeded), 14 (ServiceUnavailable), and 10 (Aborted) + default_mutate_rows_retryable_errors: a list of errors that will be retried + if encountered during mutate_rows and related operations. + Defaults to 4 (DeadlineExceeded) and 14 (ServiceUnavailable) + default_retryable_errors: a list of errors that will be retried if + encountered during all other operations. + Defaults to 4 (DeadlineExceeded) and 14 (ServiceUnavailable) + Raises: + {RAISE_NO_LOOP} + """ + # NOTE: any changes to the signature of this method should also be reflected + # in client.get_table() + # build paths before super().__init__, which registers this instance with the client + self.table_id = table_id + self.table_name = client._gapic_client.table_path( + client.project, instance_id, table_id + ) + super().__init__(client, instance_id, app_profile_id, **kwargs) + @property def _request_path(self) -> dict[str, str]: return {"table_name": self.table_name} @@ -1932,12 +2050,145 @@ def __init__( Raises: {RAISE_NO_LOOP} """ - super().__init__(client, instance_id, table_id, app_profile_id, **kwargs) + # build paths before super().__init__, which registers this instance with the client + self.table_id = table_id + self.table_name = client._gapic_client.table_path( + client.project, instance_id, table_id + ) self.authorized_view_id = authorized_view_id - self.authorized_view_name: str = self.client._gapic_client.authorized_view_path( - self.client.project, instance_id, table_id, authorized_view_id + self.authorized_view_name: str = client._gapic_client.authorized_view_path( + client.project, instance_id, table_id, authorized_view_id ) + super().__init__(client, instance_id, app_profile_id, **kwargs) @property def _request_path(self) -> dict[str, str]: return {"authorized_view_name": self.authorized_view_name} + + +@CrossSync.convert_class( + sync_name="MaterializedView", + add_mapping_for_name="MaterializedView", + replace_symbols={"_DataApiTargetAsync": "_DataApiTarget"}, +) +class MaterializedViewAsync(_DataApiTargetAsync): + """ + Provides read access to a materialized view of a table. + + A materialized view is a pre-computed, read-only view over a table, defined + by a SQL query. Materialized views support read operations only; mutation + methods raise NotImplementedError. + + MaterializedView object maintains materialized_view_id and app_profile_id context, + and passes them with each call + """ + + @CrossSync.convert( + replace_symbols={"BigtableDataClientAsync": "BigtableDataClient"}, + docstring_format_vars={ + "LOOP_MESSAGE": ( + "Must be created within an async context (running event loop)", + "", + ), + "RAISE_NO_LOOP": ( + "RuntimeError: if called outside of an async context (no running event loop)", + "None", + ), + }, + ) + def __init__( + self, + client: BigtableDataClientAsync, + instance_id: str, + materialized_view_id: str, + app_profile_id: str | None = None, + **kwargs, + ): + """ + Initialize a MaterializedView instance + + {LOOP_MESSAGE} + + Args: + instance_id: The Bigtable instance ID to associate with this client. + instance_id is combined with the client's project to fully + specify the instance + materialized_view_id: The id for the materialized view to use for requests + app_profile_id: The app profile to associate with requests. + https://cloud.google.com/bigtable/docs/app-profiles + default_read_rows_operation_timeout: The default timeout for read rows + operations, in seconds. If not set, defaults to 600 seconds (10 minutes) + default_read_rows_attempt_timeout: The default timeout for individual + read rows rpc requests, in seconds. If not set, defaults to 20 seconds + default_operation_timeout: The default timeout for all other operations, in + seconds. If not set, defaults to 60 seconds + default_attempt_timeout: The default timeout for all other individual rpc + requests, in seconds. If not set, defaults to 20 seconds + default_read_rows_retryable_errors: a list of errors that will be retried + if encountered during read_rows and related operations. + Defaults to 4 (DeadlineExceeded), 14 (ServiceUnavailable), and 10 (Aborted) + default_retryable_errors: a list of errors that will be retried if + encountered during all other operations. + Defaults to 4 (DeadlineExceeded) and 14 (ServiceUnavailable) + Raises: + {RAISE_NO_LOOP} + """ + # build paths before super().__init__, which registers this instance with the client + self.materialized_view_id = materialized_view_id + self.materialized_view_name: str = client._gapic_client.materialized_view_path( + client.project, instance_id, materialized_view_id + ) + super().__init__(client, instance_id, app_profile_id, **kwargs) + + @property + def _request_path(self) -> dict[str, str]: + return {"materialized_view_name": self.materialized_view_name} + + def mutations_batcher(self, *args, **kwargs): + """ + Mutations are not supported for materialized views + + Raises: + NotImplementedError: always; materialized views are read-only + """ + raise NotImplementedError("Mutations are not supported for materialized views") + + @CrossSync.convert + async def mutate_row(self, *args, **kwargs): + """ + Mutations are not supported for materialized views + + Raises: + NotImplementedError: always; materialized views are read-only + """ + raise NotImplementedError("Mutations are not supported for materialized views") + + @CrossSync.convert + async def bulk_mutate_rows(self, *args, **kwargs): + """ + Mutations are not supported for materialized views + + Raises: + NotImplementedError: always; materialized views are read-only + """ + raise NotImplementedError("Mutations are not supported for materialized views") + + @CrossSync.convert + async def check_and_mutate_row(self, *args, **kwargs): + """ + Mutations are not supported for materialized views + + Raises: + NotImplementedError: always; materialized views are read-only + """ + raise NotImplementedError("Mutations are not supported for materialized views") + + @CrossSync.convert + async def read_modify_write_row(self, *args, **kwargs): + """ + Mutations are not supported for materialized views + + Raises: + NotImplementedError: always; materialized views are read-only + """ + raise NotImplementedError("Mutations are not supported for materialized views") diff --git a/packages/google-cloud-bigtable/google/cloud/bigtable/data/_sync_autogen/client.py b/packages/google-cloud-bigtable/google/cloud/bigtable/data/_sync_autogen/client.py index 77d8cd7df7b0..6029d7f75f7c 100644 --- a/packages/google-cloud-bigtable/google/cloud/bigtable/data/_sync_autogen/client.py +++ b/packages/google-cloud-bigtable/google/cloud/bigtable/data/_sync_autogen/client.py @@ -526,6 +526,43 @@ def get_authorized_view( self, instance_id, table_id, authorized_view_id, *args, **kwargs ) + def get_materialized_view( + self, instance_id: str, materialized_view_id: str, *args, **kwargs + ) -> MaterializedView: + """Returns a materialized view instance for making read requests. All arguments are passed + directly to the MaterializedView constructor. + + + + Args: + instance_id: The Bigtable instance ID to associate with this client. + instance_id is combined with the client's project to fully + specify the instance + materialized_view_id: The id for the materialized view to use for requests + app_profile_id: The app profile to associate with requests. + https://cloud.google.com/bigtable/docs/app-profiles + default_read_rows_operation_timeout: The default timeout for read rows + operations, in seconds. If not set, defaults to 600 seconds (10 minutes) + default_read_rows_attempt_timeout: The default timeout for individual + read rows rpc requests, in seconds. If not set, defaults to 20 seconds + default_operation_timeout: The default timeout for all other operations, in + seconds. If not set, defaults to 60 seconds + default_attempt_timeout: The default timeout for all other individual rpc + requests, in seconds. If not set, defaults to 20 seconds + default_read_rows_retryable_errors: a list of errors that will be retried + if encountered during read_rows and related operations. + Defaults to 4 (DeadlineExceeded), 14 (ServiceUnavailable), and 10 (Aborted) + default_retryable_errors: a list of errors that will be retried if + encountered during all other operations. + Defaults to 4 (DeadlineExceeded) and 14 (ServiceUnavailable) + Returns: + MaterializedView: a materialized view instance for making read requests + Raises: + None""" + return CrossSync._Sync_Impl.MaterializedView( + self, instance_id, materialized_view_id, *args, **kwargs + ) + def execute_query( self, query: str, @@ -732,14 +769,13 @@ class _DataApiTarget(abc.ABC): """ Abstract class containing API surface for BigtableDataClient. Should not be created directly - Can be instantiated as a Table or an AuthorizedView + Can be instantiated as a Table, an AuthorizedView, or a MaterializedView """ def __init__( self, client: BigtableDataClient, instance_id: str, - table_id: str, app_profile_id: str | None = None, *, default_read_rows_operation_timeout: float = 600, @@ -763,7 +799,7 @@ def __init__( ServiceUnavailable, ), ): - """Initialize a Table instance + """Initialize a data API target instance @@ -771,8 +807,6 @@ def __init__( instance_id: The Bigtable instance ID to associate with this client. instance_id is combined with the client's project to fully specify the instance - table_id: The ID of the table. table_id is combined with the - instance_id and the client's project to fully specify the table app_profile_id: The app profile to associate with requests. https://cloud.google.com/bigtable/docs/app-profiles default_read_rows_operation_timeout: The default timeout for read rows @@ -816,10 +850,6 @@ def __init__( self.instance_name = self.client._gapic_client.instance_path( self.client.project, instance_id ) - self.table_id = table_id - self.table_name = self.client._gapic_client.table_path( - self.client.project, instance_id, table_id - ) self.app_profile_id: str | None = app_profile_id self.default_operation_timeout: float = default_operation_timeout self.default_attempt_timeout: float | None = default_attempt_timeout @@ -861,7 +891,11 @@ def __init__( @property @abc.abstractmethod def _request_path(self) -> dict[str, str]: - """Used to populate table_name or authorized_view_name for rpc requests, depending on the subclass + """Used to populate table_name, authorized_view_name, or materialized_view_name + for rpc requests, depending on the subclass + + The returned key must be a valid field on each request proto it is passed to; + mutation requests only accept table_name and authorized_view_name Unimplemented in base class""" raise NotImplementedError @@ -1557,6 +1591,55 @@ class Table(_DataApiTarget): each call """ + def __init__( + self, + client: BigtableDataClient, + instance_id: str, + table_id: str, + app_profile_id: str | None = None, + **kwargs, + ): + """Initialize a Table instance + + + + Args: + instance_id: The Bigtable instance ID to associate with this client. + instance_id is combined with the client's project to fully + specify the instance + table_id: The ID of the table. table_id is combined with the + instance_id and the client's project to fully specify the table + app_profile_id: The app profile to associate with requests. + https://cloud.google.com/bigtable/docs/app-profiles + default_read_rows_operation_timeout: The default timeout for read rows + operations, in seconds. If not set, defaults to 600 seconds (10 minutes) + default_read_rows_attempt_timeout: The default timeout for individual + read rows rpc requests, in seconds. If not set, defaults to 20 seconds + default_mutate_rows_operation_timeout: The default timeout for mutate rows + operations, in seconds. If not set, defaults to 600 seconds (10 minutes) + default_mutate_rows_attempt_timeout: The default timeout for individual + mutate rows rpc requests, in seconds. If not set, defaults to 60 seconds + default_operation_timeout: The default timeout for all other operations, in + seconds. If not set, defaults to 60 seconds + default_attempt_timeout: The default timeout for all other individual rpc + requests, in seconds. If not set, defaults to 20 seconds + default_read_rows_retryable_errors: a list of errors that will be retried + if encountered during read_rows and related operations. + Defaults to 4 (DeadlineExceeded), 14 (ServiceUnavailable), and 10 (Aborted) + default_mutate_rows_retryable_errors: a list of errors that will be retried + if encountered during mutate_rows and related operations. + Defaults to 4 (DeadlineExceeded) and 14 (ServiceUnavailable) + default_retryable_errors: a list of errors that will be retried if + encountered during all other operations. + Defaults to 4 (DeadlineExceeded) and 14 (ServiceUnavailable) + Raises: + None""" + self.table_id = table_id + self.table_name = client._gapic_client.table_path( + client.project, instance_id, table_id + ) + super().__init__(client, instance_id, app_profile_id, **kwargs) + @property def _request_path(self) -> dict[str, str]: return {"table_name": self.table_name} @@ -1619,12 +1702,110 @@ def __init__( Defaults to 4 (DeadlineExceeded) and 14 (ServiceUnavailable) Raises: None""" - super().__init__(client, instance_id, table_id, app_profile_id, **kwargs) + self.table_id = table_id + self.table_name = client._gapic_client.table_path( + client.project, instance_id, table_id + ) self.authorized_view_id = authorized_view_id - self.authorized_view_name: str = self.client._gapic_client.authorized_view_path( - self.client.project, instance_id, table_id, authorized_view_id + self.authorized_view_name: str = client._gapic_client.authorized_view_path( + client.project, instance_id, table_id, authorized_view_id ) + super().__init__(client, instance_id, app_profile_id, **kwargs) @property def _request_path(self) -> dict[str, str]: return {"authorized_view_name": self.authorized_view_name} + + +@CrossSync._Sync_Impl.add_mapping_decorator("MaterializedView") +class MaterializedView(_DataApiTarget): + """ + Provides read access to a materialized view of a table. + + A materialized view is a pre-computed, read-only view over a table, defined + by a SQL query. Materialized views support read operations only; mutation + methods raise NotImplementedError. + + MaterializedView object maintains materialized_view_id and app_profile_id context, + and passes them with each call + """ + + def __init__( + self, + client: BigtableDataClient, + instance_id: str, + materialized_view_id: str, + app_profile_id: str | None = None, + **kwargs, + ): + """Initialize a MaterializedView instance + + + + Args: + instance_id: The Bigtable instance ID to associate with this client. + instance_id is combined with the client's project to fully + specify the instance + materialized_view_id: The id for the materialized view to use for requests + app_profile_id: The app profile to associate with requests. + https://cloud.google.com/bigtable/docs/app-profiles + default_read_rows_operation_timeout: The default timeout for read rows + operations, in seconds. If not set, defaults to 600 seconds (10 minutes) + default_read_rows_attempt_timeout: The default timeout for individual + read rows rpc requests, in seconds. If not set, defaults to 20 seconds + default_operation_timeout: The default timeout for all other operations, in + seconds. If not set, defaults to 60 seconds + default_attempt_timeout: The default timeout for all other individual rpc + requests, in seconds. If not set, defaults to 20 seconds + default_read_rows_retryable_errors: a list of errors that will be retried + if encountered during read_rows and related operations. + Defaults to 4 (DeadlineExceeded), 14 (ServiceUnavailable), and 10 (Aborted) + default_retryable_errors: a list of errors that will be retried if + encountered during all other operations. + Defaults to 4 (DeadlineExceeded) and 14 (ServiceUnavailable) + Raises: + None""" + self.materialized_view_id = materialized_view_id + self.materialized_view_name: str = client._gapic_client.materialized_view_path( + client.project, instance_id, materialized_view_id + ) + super().__init__(client, instance_id, app_profile_id, **kwargs) + + @property + def _request_path(self) -> dict[str, str]: + return {"materialized_view_name": self.materialized_view_name} + + def mutations_batcher(self, *args, **kwargs): + """Mutations are not supported for materialized views + + Raises: + NotImplementedError: always; materialized views are read-only""" + raise NotImplementedError("Mutations are not supported for materialized views") + + def mutate_row(self, *args, **kwargs): + """Mutations are not supported for materialized views + + Raises: + NotImplementedError: always; materialized views are read-only""" + raise NotImplementedError("Mutations are not supported for materialized views") + + def bulk_mutate_rows(self, *args, **kwargs): + """Mutations are not supported for materialized views + + Raises: + NotImplementedError: always; materialized views are read-only""" + raise NotImplementedError("Mutations are not supported for materialized views") + + def check_and_mutate_row(self, *args, **kwargs): + """Mutations are not supported for materialized views + + Raises: + NotImplementedError: always; materialized views are read-only""" + raise NotImplementedError("Mutations are not supported for materialized views") + + def read_modify_write_row(self, *args, **kwargs): + """Mutations are not supported for materialized views + + Raises: + NotImplementedError: always; materialized views are read-only""" + raise NotImplementedError("Mutations are not supported for materialized views") diff --git a/packages/google-cloud-bigtable/tests/unit/data/_async/test_client.py b/packages/google-cloud-bigtable/tests/unit/data/_async/test_client.py index 04e09230fa35..62ab9b5f96f0 100644 --- a/packages/google-cloud-bigtable/tests/unit/data/_async/test_client.py +++ b/packages/google-cloud-bigtable/tests/unit/data/_async/test_client.py @@ -839,11 +839,13 @@ async def test__multiple_instance_registration(self): assert len(client._instance_owners[instance_1_key]) == 0 assert len(client._instance_owners[instance_2_key]) == 0 - @pytest.mark.parametrize("method", ["get_table", "get_authorized_view"]) + @pytest.mark.parametrize( + "method", ["get_table", "get_authorized_view", "get_materialized_view"] + ) @CrossSync.pytest async def test_get_api_surface(self, method): """ - test client.get_table and client.get_authorized_view + test client.get_table, client.get_authorized_view, and client.get_materialized_view """ from google.cloud.bigtable.data._helpers import _WarmedInstanceKey @@ -871,14 +873,28 @@ async def test_get_api_surface(self, method): surface.authorized_view_name == f"projects/{client.project}/instances/{expected_instance_id}/tables/{expected_table_id}/authorizedViews/view_id" ) + elif method == "get_materialized_view": + surface = client.get_materialized_view( + expected_instance_id, + "view_id", + expected_app_profile_id, + ) + assert isinstance( + surface, CrossSync.TestMaterializedView._get_target_class() + ) + assert ( + surface.materialized_view_name + == f"projects/{client.project}/instances/{expected_instance_id}/materializedViews/view_id" + ) else: raise TypeError(f"unexpected method: {method}") await CrossSync.yield_to_event_loop() - assert surface.table_id == expected_table_id - assert ( - surface.table_name - == f"projects/{client.project}/instances/{expected_instance_id}/tables/{expected_table_id}" - ) + if method != "get_materialized_view": + assert surface.table_id == expected_table_id + assert ( + surface.table_name + == f"projects/{client.project}/instances/{expected_instance_id}/tables/{expected_table_id}" + ) assert surface.instance_id == expected_instance_id assert ( surface.instance_name @@ -891,16 +907,21 @@ async def test_get_api_surface(self, method): assert client._instance_owners[instance_key] == {id(surface)} await client.close() - @pytest.mark.parametrize("method", ["get_table", "get_authorized_view"]) + @pytest.mark.parametrize( + "method", ["get_table", "get_authorized_view", "get_materialized_view"] + ) @CrossSync.pytest async def test_api_surface_arg_passthrough(self, method): """ - All arguments passed in get_table and get_authorized_view should be sent to constructor + All arguments passed in get_table, get_authorized_view, and get_materialized_view + should be sent to constructor """ if method == "get_table": surface_type = CrossSync.TestTable._get_target_class() elif method == "get_authorized_view": surface_type = CrossSync.TestAuthorizedView._get_target_class() + elif method == "get_materialized_view": + surface_type = CrossSync.TestMaterializedView._get_target_class() else: raise TypeError(f"unexpected method: {method}") @@ -929,11 +950,13 @@ async def test_api_surface_arg_passthrough(self, method): **expected_kwargs, ) - @pytest.mark.parametrize("method", ["get_table", "get_authorized_view"]) + @pytest.mark.parametrize( + "method", ["get_table", "get_authorized_view", "get_materialized_view"] + ) @CrossSync.pytest async def test_api_surface_context_manager(self, method): """ - get_table and get_authorized_view should work as context managers + get_table, get_authorized_view, and get_materialized_view should work as context managers """ from functools import partial @@ -948,6 +971,8 @@ async def test_api_surface_context_manager(self, method): surface_type = CrossSync.TestTable._get_target_class() elif method == "get_authorized_view": surface_type = CrossSync.TestAuthorizedView._get_target_class() + elif method == "get_materialized_view": + surface_type = CrossSync.TestMaterializedView._get_target_class() else: raise TypeError(f"unexpected method: {method}") @@ -968,16 +993,24 @@ async def test_api_surface_context_manager(self, method): "view_id", expected_app_profile_id, ) + elif method == "get_materialized_view": + fn = partial( + client.get_materialized_view, + expected_instance_id, + "view_id", + expected_app_profile_id, + ) else: raise TypeError(f"unexpected method: {method}") async with fn() as table: await CrossSync.yield_to_event_loop() assert isinstance(table, surface_type) - assert table.table_id == expected_table_id - assert ( - table.table_name - == f"projects/{expected_project_id}/instances/{expected_instance_id}/tables/{expected_table_id}" - ) + if method != "get_materialized_view": + assert table.table_id == expected_table_id + assert ( + table.table_name + == f"projects/{expected_project_id}/instances/{expected_instance_id}/tables/{expected_table_id}" + ) assert table.instance_id == expected_instance_id assert ( table.instance_name @@ -1624,6 +1657,201 @@ async def test_ctor(self): await client.close() +@CrossSync.convert_class( + "TestMaterializedView", add_mapping_for_name="TestMaterializedView" +) +class TestMaterializedViewsAsync(CrossSync.TestTable): + """ + Inherit tests from TestTableAsync, with some modifications + """ + + @staticmethod + @CrossSync.convert + def _get_target_class(): + return CrossSync.MaterializedView + + def _make_one( + self, + client, + instance_id="instance", + view_id="view", + app_profile_id=None, + **kwargs, + ): + return self._get_target_class()( + client, instance_id, view_id, app_profile_id, **kwargs + ) + + @CrossSync.pytest + async def test_ctor(self): + from google.cloud.bigtable.data._helpers import _WarmedInstanceKey + from google.cloud.bigtable.data._metrics import ( + BigtableClientSideMetricsController, + ) + + expected_instance_id = "instance-id" + expected_view_id = "view_id" + expected_app_profile_id = "app-profile-id" + expected_operation_timeout = 123 + expected_attempt_timeout = 12 + expected_read_rows_operation_timeout = 1.5 + expected_read_rows_attempt_timeout = 0.5 + expected_mutate_rows_operation_timeout = 2.5 + expected_mutate_rows_attempt_timeout = 0.75 + client = self._make_client() + assert not client._active_instances + + view = self._get_target_class()( + client, + expected_instance_id, + expected_view_id, + expected_app_profile_id, + default_operation_timeout=expected_operation_timeout, + default_attempt_timeout=expected_attempt_timeout, + default_read_rows_operation_timeout=expected_read_rows_operation_timeout, + default_read_rows_attempt_timeout=expected_read_rows_attempt_timeout, + default_mutate_rows_operation_timeout=expected_mutate_rows_operation_timeout, + default_mutate_rows_attempt_timeout=expected_mutate_rows_attempt_timeout, + ) + await CrossSync.yield_to_event_loop() + assert view.instance_id == expected_instance_id + assert ( + view.instance_name + == f"projects/{client.project}/instances/{expected_instance_id}" + ) + assert view.materialized_view_id == expected_view_id + assert ( + view.materialized_view_name + == f"projects/{client.project}/instances/{expected_instance_id}/materializedViews/{expected_view_id}" + ) + assert view.app_profile_id == expected_app_profile_id + assert view.client is client + instance_key = _WarmedInstanceKey(view.instance_name, view.app_profile_id) + assert instance_key in client._active_instances + assert client._instance_owners[instance_key] == {id(view)} + assert isinstance(view._metrics, BigtableClientSideMetricsController) + assert view.default_operation_timeout == expected_operation_timeout + assert view.default_attempt_timeout == expected_attempt_timeout + assert ( + view.default_read_rows_operation_timeout + == expected_read_rows_operation_timeout + ) + assert ( + view.default_read_rows_attempt_timeout == expected_read_rows_attempt_timeout + ) + assert ( + view.default_mutate_rows_operation_timeout + == expected_mutate_rows_operation_timeout + ) + assert ( + view.default_mutate_rows_attempt_timeout + == expected_mutate_rows_attempt_timeout + ) + # ensure task reaches completion + await view._register_instance_future + assert view._register_instance_future.done() + assert not view._register_instance_future.cancelled() + assert view._register_instance_future.exception() is None + await client.close() + + @pytest.mark.parametrize( + "fn_name,fn_args,gapic_fn", + [ + ("read_rows_stream", (ReadRowsQuery(),), "read_rows"), + ("read_rows", (ReadRowsQuery(),), "read_rows"), + ("read_row", (b"row_key",), "read_rows"), + ("read_rows_sharded", ([ReadRowsQuery()],), "read_rows"), + ("row_exists", (b"row_key",), "read_rows"), + ("sample_row_keys", (), "sample_row_keys"), + ], + ) + @pytest.mark.parametrize("include_app_profile", [True, False]) + @CrossSync.pytest + @CrossSync.convert + async def test_call_metadata(self, include_app_profile, fn_name, fn_args, gapic_fn): + """ + Materialized views only support read operations, and route requests + using the instance name rather than a table name + """ + profile = "profile" if include_app_profile else None + client = self._make_client() + # create mock for rpc stub + transport_mock = mock.MagicMock() + rpc_mock = CrossSync.Mock() + transport_mock._wrapped_methods.__getitem__.return_value = rpc_mock + gapic_client = client._gapic_client + if CrossSync.is_async: + # inner BigtableClient is held as ._client for BigtableAsyncClient + gapic_client = gapic_client._client + gapic_client._transport = transport_mock + gapic_client._is_universe_domain_valid = True + view = self._make_one(client, app_profile_id=profile) + try: + test_fn = view.__getattribute__(fn_name) + result = await test_fn(*fn_args) + if fn_name == "read_rows_stream": + # only streams are async-iterable; the other methods return + # concrete values, and iterating them would raise a TypeError + # that the except block below hides + [i async for i in result] + except Exception: + # we expect an exception from attempting to call the mock + pass + assert rpc_mock.call_count == 1 + kwargs = rpc_mock.call_args_list[0][1] + metadata = kwargs["metadata"] + # expect single metadata entry + assert len(metadata) == 1 + # expect x-goog-request-params tag + assert metadata[0][0] == "x-goog-request-params" + routing_str = metadata[0][1] + # exact match, to catch routing accidentally sending the full view path + assert f"name={view.instance_name}" in routing_str.split("&") + if include_app_profile: + assert "app_profile_id=profile" in routing_str + else: + # empty app_profile_id should send empty string + assert "app_profile_id=" in routing_str + + @pytest.mark.parametrize( + "fn_name,fn_args", + [ + ("mutate_row", (b"row_key", [mutations.DeleteAllFromRow()])), + ( + "bulk_mutate_rows", + ([mutations.RowMutationEntry(b"key", [mutations.DeleteAllFromRow()])],), + ), + ("check_and_mutate_row", (b"row_key", None)), + ("read_modify_write_row", (b"row_key", IncrementRule("f", "q"))), + ], + ) + @CrossSync.pytest + async def test_mutation_methods_unsupported(self, fn_name, fn_args): + """ + Mutation methods should raise NotImplementedError instead of building + a request the service can't route + """ + client = self._make_client() + view = self._make_one(client) + with pytest.raises(NotImplementedError) as e: + await view.__getattribute__(fn_name)(*fn_args) + assert "not supported for materialized views" in str(e.value) + await client.close() + + @CrossSync.pytest + async def test_mutations_batcher_unsupported(self): + """ + mutations_batcher should raise NotImplementedError immediately, rather + than deferring the failure to a background flush + """ + client = self._make_client() + view = self._make_one(client) + with pytest.raises(NotImplementedError) as e: + view.mutations_batcher() + assert "not supported for materialized views" in str(e.value) + await client.close() + + @CrossSync.convert_class( "TestReadRows", add_mapping_for_name="TestReadRows", diff --git a/packages/google-cloud-bigtable/tests/unit/data/_sync_autogen/test_client.py b/packages/google-cloud-bigtable/tests/unit/data/_sync_autogen/test_client.py index 5d6836ccbb89..eb954beb5113 100644 --- a/packages/google-cloud-bigtable/tests/unit/data/_sync_autogen/test_client.py +++ b/packages/google-cloud-bigtable/tests/unit/data/_sync_autogen/test_client.py @@ -685,9 +685,11 @@ def test__multiple_instance_registration(self): assert len(client._instance_owners[instance_1_key]) == 0 assert len(client._instance_owners[instance_2_key]) == 0 - @pytest.mark.parametrize("method", ["get_table", "get_authorized_view"]) + @pytest.mark.parametrize( + "method", ["get_table", "get_authorized_view", "get_materialized_view"] + ) def test_get_api_surface(self, method): - """test client.get_table and client.get_authorized_view""" + """test client.get_table, client.get_authorized_view, and client.get_materialized_view""" from google.cloud.bigtable.data._helpers import _WarmedInstanceKey client = self._make_client(project="project-id") @@ -716,14 +718,26 @@ def test_get_api_surface(self, method): surface.authorized_view_name == f"projects/{client.project}/instances/{expected_instance_id}/tables/{expected_table_id}/authorizedViews/view_id" ) + elif method == "get_materialized_view": + surface = client.get_materialized_view( + expected_instance_id, "view_id", expected_app_profile_id + ) + assert isinstance( + surface, CrossSync._Sync_Impl.TestMaterializedView._get_target_class() + ) + assert ( + surface.materialized_view_name + == f"projects/{client.project}/instances/{expected_instance_id}/materializedViews/view_id" + ) else: raise TypeError(f"unexpected method: {method}") CrossSync._Sync_Impl.yield_to_event_loop() - assert surface.table_id == expected_table_id - assert ( - surface.table_name - == f"projects/{client.project}/instances/{expected_instance_id}/tables/{expected_table_id}" - ) + if method != "get_materialized_view": + assert surface.table_id == expected_table_id + assert ( + surface.table_name + == f"projects/{client.project}/instances/{expected_instance_id}/tables/{expected_table_id}" + ) assert surface.instance_id == expected_instance_id assert ( surface.instance_name @@ -736,13 +750,18 @@ def test_get_api_surface(self, method): assert client._instance_owners[instance_key] == {id(surface)} client.close() - @pytest.mark.parametrize("method", ["get_table", "get_authorized_view"]) + @pytest.mark.parametrize( + "method", ["get_table", "get_authorized_view", "get_materialized_view"] + ) def test_api_surface_arg_passthrough(self, method): - """All arguments passed in get_table and get_authorized_view should be sent to constructor""" + """All arguments passed in get_table, get_authorized_view, and get_materialized_view + should be sent to constructor""" if method == "get_table": surface_type = CrossSync._Sync_Impl.TestTable._get_target_class() elif method == "get_authorized_view": surface_type = CrossSync._Sync_Impl.TestAuthorizedView._get_target_class() + elif method == "get_materialized_view": + surface_type = CrossSync._Sync_Impl.TestMaterializedView._get_target_class() else: raise TypeError(f"unexpected method: {method}") with self._make_client(project="project-id") as client: @@ -764,9 +783,11 @@ def test_api_surface_arg_passthrough(self, method): client, *expected_args, **expected_kwargs ) - @pytest.mark.parametrize("method", ["get_table", "get_authorized_view"]) + @pytest.mark.parametrize( + "method", ["get_table", "get_authorized_view", "get_materialized_view"] + ) def test_api_surface_context_manager(self, method): - """get_table and get_authorized_view should work as context managers""" + """get_table, get_authorized_view, and get_materialized_view should work as context managers""" from functools import partial from google.cloud.bigtable.data._helpers import _WarmedInstanceKey @@ -779,6 +800,8 @@ def test_api_surface_context_manager(self, method): surface_type = CrossSync._Sync_Impl.TestTable._get_target_class() elif method == "get_authorized_view": surface_type = CrossSync._Sync_Impl.TestAuthorizedView._get_target_class() + elif method == "get_materialized_view": + surface_type = CrossSync._Sync_Impl.TestMaterializedView._get_target_class() else: raise TypeError(f"unexpected method: {method}") with mock.patch.object(surface_type, "close") as close_mock: @@ -798,16 +821,24 @@ def test_api_surface_context_manager(self, method): "view_id", expected_app_profile_id, ) + elif method == "get_materialized_view": + fn = partial( + client.get_materialized_view, + expected_instance_id, + "view_id", + expected_app_profile_id, + ) else: raise TypeError(f"unexpected method: {method}") with fn() as table: CrossSync._Sync_Impl.yield_to_event_loop() assert isinstance(table, surface_type) - assert table.table_id == expected_table_id - assert ( - table.table_name - == f"projects/{expected_project_id}/instances/{expected_instance_id}/tables/{expected_table_id}" - ) + if method != "get_materialized_view": + assert table.table_id == expected_table_id + assert ( + table.table_name + == f"projects/{expected_project_id}/instances/{expected_instance_id}/tables/{expected_table_id}" + ) assert table.instance_id == expected_instance_id assert ( table.instance_name @@ -1329,6 +1360,173 @@ def test_ctor(self): client.close() +@CrossSync._Sync_Impl.add_mapping_decorator("TestMaterializedView") +class TestMaterializedView(CrossSync._Sync_Impl.TestTable): + """ + Inherit tests from TestTableAsync, with some modifications + """ + + @staticmethod + def _get_target_class(): + return CrossSync._Sync_Impl.MaterializedView + + def _make_one( + self, + client, + instance_id="instance", + view_id="view", + app_profile_id=None, + **kwargs, + ): + return self._get_target_class()( + client, instance_id, view_id, app_profile_id, **kwargs + ) + + def test_ctor(self): + from google.cloud.bigtable.data._helpers import _WarmedInstanceKey + from google.cloud.bigtable.data._metrics import ( + BigtableClientSideMetricsController, + ) + + expected_instance_id = "instance-id" + expected_view_id = "view_id" + expected_app_profile_id = "app-profile-id" + expected_operation_timeout = 123 + expected_attempt_timeout = 12 + expected_read_rows_operation_timeout = 1.5 + expected_read_rows_attempt_timeout = 0.5 + expected_mutate_rows_operation_timeout = 2.5 + expected_mutate_rows_attempt_timeout = 0.75 + client = self._make_client() + assert not client._active_instances + view = self._get_target_class()( + client, + expected_instance_id, + expected_view_id, + expected_app_profile_id, + default_operation_timeout=expected_operation_timeout, + default_attempt_timeout=expected_attempt_timeout, + default_read_rows_operation_timeout=expected_read_rows_operation_timeout, + default_read_rows_attempt_timeout=expected_read_rows_attempt_timeout, + default_mutate_rows_operation_timeout=expected_mutate_rows_operation_timeout, + default_mutate_rows_attempt_timeout=expected_mutate_rows_attempt_timeout, + ) + CrossSync._Sync_Impl.yield_to_event_loop() + assert view.instance_id == expected_instance_id + assert ( + view.instance_name + == f"projects/{client.project}/instances/{expected_instance_id}" + ) + assert view.materialized_view_id == expected_view_id + assert ( + view.materialized_view_name + == f"projects/{client.project}/instances/{expected_instance_id}/materializedViews/{expected_view_id}" + ) + assert view.app_profile_id == expected_app_profile_id + assert view.client is client + instance_key = _WarmedInstanceKey(view.instance_name, view.app_profile_id) + assert instance_key in client._active_instances + assert client._instance_owners[instance_key] == {id(view)} + assert isinstance(view._metrics, BigtableClientSideMetricsController) + assert view.default_operation_timeout == expected_operation_timeout + assert view.default_attempt_timeout == expected_attempt_timeout + assert ( + view.default_read_rows_operation_timeout + == expected_read_rows_operation_timeout + ) + assert ( + view.default_read_rows_attempt_timeout == expected_read_rows_attempt_timeout + ) + assert ( + view.default_mutate_rows_operation_timeout + == expected_mutate_rows_operation_timeout + ) + assert ( + view.default_mutate_rows_attempt_timeout + == expected_mutate_rows_attempt_timeout + ) + view._register_instance_future + assert view._register_instance_future.done() + assert not view._register_instance_future.cancelled() + assert view._register_instance_future.exception() is None + client.close() + + @pytest.mark.parametrize( + "fn_name,fn_args,gapic_fn", + [ + ("read_rows_stream", (ReadRowsQuery(),), "read_rows"), + ("read_rows", (ReadRowsQuery(),), "read_rows"), + ("read_row", (b"row_key",), "read_rows"), + ("read_rows_sharded", ([ReadRowsQuery()],), "read_rows"), + ("row_exists", (b"row_key",), "read_rows"), + ("sample_row_keys", (), "sample_row_keys"), + ], + ) + @pytest.mark.parametrize("include_app_profile", [True, False]) + def test_call_metadata(self, include_app_profile, fn_name, fn_args, gapic_fn): + """Materialized views only support read operations, and route requests + using the instance name rather than a table name""" + profile = "profile" if include_app_profile else None + client = self._make_client() + transport_mock = mock.MagicMock() + rpc_mock = CrossSync._Sync_Impl.Mock() + transport_mock._wrapped_methods.__getitem__.return_value = rpc_mock + gapic_client = client._gapic_client + gapic_client._transport = transport_mock + gapic_client._is_universe_domain_valid = True + view = self._make_one(client, app_profile_id=profile) + try: + test_fn = view.__getattribute__(fn_name) + result = test_fn(*fn_args) + if fn_name == "read_rows_stream": + [i for i in result] + except Exception: + pass + assert rpc_mock.call_count == 1 + kwargs = rpc_mock.call_args_list[0][1] + metadata = kwargs["metadata"] + assert len(metadata) == 1 + assert metadata[0][0] == "x-goog-request-params" + routing_str = metadata[0][1] + assert f"name={view.instance_name}" in routing_str.split("&") + if include_app_profile: + assert "app_profile_id=profile" in routing_str + else: + assert "app_profile_id=" in routing_str + + @pytest.mark.parametrize( + "fn_name,fn_args", + [ + ("mutate_row", (b"row_key", [mutations.DeleteAllFromRow()])), + ( + "bulk_mutate_rows", + ([mutations.RowMutationEntry(b"key", [mutations.DeleteAllFromRow()])],), + ), + ("check_and_mutate_row", (b"row_key", None)), + ("read_modify_write_row", (b"row_key", IncrementRule("f", "q"))), + ], + ) + def test_mutation_methods_unsupported(self, fn_name, fn_args): + """Mutation methods should raise NotImplementedError instead of building + a request the service can't route""" + client = self._make_client() + view = self._make_one(client) + with pytest.raises(NotImplementedError) as e: + view.__getattribute__(fn_name)(*fn_args) + assert "not supported for materialized views" in str(e.value) + client.close() + + def test_mutations_batcher_unsupported(self): + """mutations_batcher should raise NotImplementedError immediately, rather + than deferring the failure to a background flush""" + client = self._make_client() + view = self._make_one(client) + with pytest.raises(NotImplementedError) as e: + view.mutations_batcher() + assert "not supported for materialized views" in str(e.value) + client.close() + + @CrossSync._Sync_Impl.add_mapping_decorator("TestReadRows") class TestReadRows: """ @@ -2998,15 +3196,11 @@ def test_execute_query_with_params(self, client, execute_query_mock, prepare_moc def test_execute_query_with_view_parameters( self, client, execute_query_mock, prepare_mock ): - values = [ - *chunked_responses(2, str_val("test2"), int_val(9), token=b"r2"), - ] + values = [*chunked_responses(2, str_val("test2"), int_val(9), token=b"r2")] execute_query_mock.return_value = self._make_gapic_stream(values) query_str = f"SELECT a, b FROM {self.TABLE_NAME} WHERE user_id = VIEW_PARAMETERS('user_id')" result = client.execute_query( - query_str, - self.INSTANCE_NAME, - view_parameters={"user_id": "alice"}, + query_str, self.INSTANCE_NAME, view_parameters={"user_id": "alice"} ) results = [r for r in result] assert len(results) == 1 @@ -3015,7 +3209,6 @@ def test_execute_query_with_view_parameters( assert execute_query_mock.call_count == 1 assert prepare_mock.call_count == 1 assert prepare_mock.call_args[1]["request"]["query"] == query_str - request = execute_query_mock.call_args[0][0] assert "user_id" in request.view_parameters assert request.view_parameters["user_id"].string_value == "alice"