From 39288e784826c5accca71096be11f99ad7f930f4 Mon Sep 17 00:00:00 2001 From: larkee <31196561+larkee@users.noreply.github.com> Date: Fri, 13 Mar 2020 17:35:03 +1100 Subject: [PATCH] feat: add support for backups (#35) * feat: implement backup support * Apply suggestions from code review Co-Authored-By: skuruppu * refactor restore to use source Co-authored-by: larkee Co-authored-by: skuruppu --- google/cloud/spanner_v1/backup.py | 275 +++++++++++++ google/cloud/spanner_v1/database.py | 116 ++++++ google/cloud/spanner_v1/instance.py | 167 ++++++++ tests/unit/test_backup.py | 590 ++++++++++++++++++++++++++++ tests/unit/test_database.py | 251 +++++++++++- tests/unit/test_instance.py | 327 +++++++++++++++ 6 files changed, 1725 insertions(+), 1 deletion(-) create mode 100644 google/cloud/spanner_v1/backup.py create mode 100644 tests/unit/test_backup.py diff --git a/google/cloud/spanner_v1/backup.py b/google/cloud/spanner_v1/backup.py new file mode 100644 index 0000000000..2aaa1c0f5c --- /dev/null +++ b/google/cloud/spanner_v1/backup.py @@ -0,0 +1,275 @@ +# Copyright 2020 Google LLC All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""User friendly container for Cloud Spanner Backup.""" + +import re + +from google.cloud._helpers import _datetime_to_pb_timestamp, _pb_timestamp_to_datetime +from google.cloud.exceptions import NotFound + +from google.cloud.spanner_admin_database_v1.gapic import enums +from google.cloud.spanner_v1._helpers import _metadata_with_prefix + +_BACKUP_NAME_RE = re.compile( + r"^projects/(?P[^/]+)/" + r"instances/(?P[a-z][-a-z0-9]*)/" + r"backups/(?P[a-z][a-z0-9_\-]*[a-z0-9])$" +) + + +class Backup(object): + """Representation of a Cloud Spanner Backup. + + We can use a :class`Backup` to: + + * :meth:`create` the backup + * :meth:`update` the backup + * :meth:`delete` the backup + + :type backup_id: str + :param backup_id: The ID of the backup. + + :type instance: :class:`~google.cloud.spanner_v1.instance.Instance` + :param instance: The instance that owns the backup. + + :type database: str + :param database: (Optional) The URI of the database that the backup is + for. Required if the create method needs to be called. + + :type expire_time: :class:`datetime.datetime` + :param expire_time: (Optional) The expire time that will be used to + create the backup. Required if the create method + needs to be called. + """ + + def __init__(self, backup_id, instance, database="", expire_time=None): + self.backup_id = backup_id + self._instance = instance + self._database = database + self._expire_time = expire_time + self._create_time = None + self._size_bytes = None + self._state = None + self._referencing_databases = None + + @property + def name(self): + """Backup name used in requests. + + The backup name is of the form + + ``"projects/../instances/../backups/{backup_id}"`` + + :rtype: str + :returns: The backup name. + """ + return self._instance.name + "/backups/" + self.backup_id + + @property + def database(self): + """Database name used in requests. + + The database name is of the form + + ``"projects/../instances/../backups/{backup_id}"`` + + :rtype: str + :returns: The database name. + """ + return self._database + + @property + def expire_time(self): + """Expire time used in creation requests. + + :rtype: :class:`datetime.datetime` + :returns: a datetime object representing the expire time of + this backup + """ + return self._expire_time + + @property + def create_time(self): + """Create time of this backup. + + :rtype: :class:`datetime.datetime` + :returns: a datetime object representing the create time of + this backup + """ + return self._create_time + + @property + def size_bytes(self): + """Size of this backup in bytes. + + :rtype: int + :returns: the number size of this backup measured in bytes + """ + return self._size_bytes + + @property + def state(self): + """State of this backup. + + :rtype: :class:`~google.cloud.spanner_admin_database_v1.gapic.enums.Backup.State` + :returns: an enum describing the state of the backup + """ + return self._state + + @property + def referencing_databases(self): + """List of databases referencing this backup. + + :rtype: list of strings + :returns: a list of database path strings which specify the databases still + referencing this backup + """ + return self._referencing_databases + + @classmethod + def from_pb(cls, backup_pb, instance): + """Create an instance of this class from a protobuf message. + + :type backup_pb: :class:`~google.spanner.admin.database.v1.Backup` + :param backup_pb: A backup protobuf object. + + :type instance: :class:`~google.cloud.spanner_v1.instance.Instance` + :param instance: The instance that owns the backup. + + :rtype: :class:`Backup` + :returns: The backup parsed from the protobuf response. + :raises ValueError: + if the backup name does not match the expected format or if + the parsed project ID does not match the project ID on the + instance's client, or if the parsed instance ID does not match + the instance's ID. + """ + match = _BACKUP_NAME_RE.match(backup_pb.name) + if match is None: + raise ValueError( + "Backup protobuf name was not in the expected format.", backup_pb.name + ) + if match.group("project") != instance._client.project: + raise ValueError( + "Project ID on backup does not match the project ID" + "on the instance's client" + ) + instance_id = match.group("instance_id") + if instance_id != instance.instance_id: + raise ValueError( + "Instance ID on database does not match the instance ID" + "on the instance" + ) + backup_id = match.group("backup_id") + return cls(backup_id, instance) + + def create(self): + """Create this backup within its instance. + + :rtype: :class:`~google.api_core.operation.Operation` + :returns: a future used to poll the status of the create request + :raises Conflict: if the backup already exists + :raises NotFound: if the instance owning the backup does not exist + :raises BadRequest: if the database or expire_time values are invalid + or expire_time is not set + """ + if not self._expire_time: + raise ValueError("expire_time not set") + if not self._database: + raise ValueError("database not set") + api = self._instance._client.database_admin_api + metadata = _metadata_with_prefix(self.name) + backup = { + "database": self._database, + "expire_time": _datetime_to_pb_timestamp(self.expire_time), + } + + future = api.create_backup( + self._instance.name, self.backup_id, backup, metadata=metadata + ) + return future + + def exists(self): + """Test whether this backup exists. + + :rtype: bool + :returns: True if the backup exists, else False. + """ + api = self._instance._client.database_admin_api + metadata = _metadata_with_prefix(self.name) + + try: + api.get_backup(self.name, metadata=metadata) + except NotFound: + return False + return True + + def reload(self): + """Reload this backup. + + Refresh the stored backup properties. + + :raises NotFound: if the backup does not exist + """ + api = self._instance._client.database_admin_api + metadata = _metadata_with_prefix(self.name) + pb = api.get_backup(self.name, metadata=metadata) + self._database = pb.database + self._expire_time = _pb_timestamp_to_datetime(pb.expire_time) + self._create_time = _pb_timestamp_to_datetime(pb.create_time) + self._size_bytes = pb.size_bytes + self._state = enums.Backup.State(pb.state) + self._referencing_databases = pb.referencing_databases + + def update_expire_time(self, new_expire_time): + """Update the expire time of this backup. + + :type new_expire_time: :class:`datetime.datetime` + :param new_expire_time: the new expire time timestamp + """ + api = self._instance._client.database_admin_api + metadata = _metadata_with_prefix(self.name) + backup_update = { + "name": self.name, + "expire_time": _datetime_to_pb_timestamp(new_expire_time), + } + update_mask = {"paths": ["expire_time"]} + api.update_backup(backup_update, update_mask, metadata=metadata) + self._expire_time = new_expire_time + + def is_ready(self): + """Test whether this backup is ready for use. + + :rtype: bool + :returns: True if the backup state is READY, else False. + """ + return self.state == enums.Backup.State.READY + + def delete(self): + """Delete this backup.""" + api = self._instance._client.database_admin_api + metadata = _metadata_with_prefix(self.name) + api.delete_backup(self.name, metadata=metadata) + + +class BackupInfo(object): + def __init__(self, backup, create_time, source_database): + self.backup = backup + self.create_time = _pb_timestamp_to_datetime(create_time) + self.source_database = source_database + + @classmethod + def from_pb(cls, pb): + return cls(pb.backup, pb.create_time, pb.source_database) diff --git a/google/cloud/spanner_v1/database.py b/google/cloud/spanner_v1/database.py index 9ee046e094..5785953bd7 100644 --- a/google/cloud/spanner_v1/database.py +++ b/google/cloud/spanner_v1/database.py @@ -30,11 +30,13 @@ import six # pylint: disable=ungrouped-imports +from google.cloud.spanner_admin_database_v1.gapic import enums from google.cloud.spanner_v1._helpers import ( _make_value_pb, _merge_query_options, _metadata_with_prefix, ) +from google.cloud.spanner_v1.backup import BackupInfo from google.cloud.spanner_v1.batch import Batch from google.cloud.spanner_v1.gapic.spanner_client import SpannerClient from google.cloud.spanner_v1.gapic.transports import spanner_grpc_transport @@ -49,6 +51,7 @@ TransactionSelector, TransactionOptions, ) +from google.cloud._helpers import _pb_timestamp_to_datetime # pylint: enable=ungrouped-imports @@ -62,6 +65,7 @@ r"databases/(?P[a-z][a-z0-9_\-]*[a-z0-9])$" ) +_DATABASE_METADATA_FILTER = "name:{0}/operations/" _RESOURCE_ROUTING_PERMISSIONS_WARNING = ( "The client library attempted to connect to an endpoint closer to your Cloud Spanner data " @@ -110,6 +114,9 @@ def __init__(self, database_id, instance, ddl_statements=(), pool=None): self._instance = instance self._ddl_statements = _check_ddl_statements(ddl_statements) self._local = threading.local() + self._state = None + self._create_time = None + self._restore_info = None if pool is None: pool = BurstyPool() @@ -179,6 +186,34 @@ def name(self): """ return self._instance.name + "/databases/" + self.database_id + @property + def state(self): + """State of this database. + + :rtype: :class:`~google.cloud.spanner_admin_database_v1.gapic.enums.Database.State` + :returns: an enum describing the state of the database + """ + return self._state + + @property + def create_time(self): + """Create time of this database. + + :rtype: :class:`datetime.datetime` + :returns: a datetime object representing the create time of + this database + """ + return self._create_time + + @property + def restore_info(self): + """Restore info for this database. + + :rtype: :class:`~google.cloud.spanner_v1.database.RestoreInfo` + :returns: an object representing the restore info for this database + """ + return self._restore_info + @property def ddl_statements(self): """DDL Statements used to define database schema. @@ -316,6 +351,10 @@ def reload(self): metadata = _metadata_with_prefix(self.name) response = api.get_database_ddl(self.name, metadata=metadata) self._ddl_statements = tuple(response.statements) + response = api.get_database(self.name, metadata=metadata) + self._state = enums.Database.State(response.state) + self._create_time = _pb_timestamp_to_datetime(response.create_time) + self._restore_info = response.restore_info def update_ddl(self, ddl_statements, operation_id=""): """Update DDL for this database. @@ -521,6 +560,73 @@ def run_in_transaction(self, func, *args, **kw): finally: self._local.transaction_running = False + def restore(self, source): + """Restore from a backup to this database. + + :type backup: :class:`~google.cloud.spanner_v1.backup.Backup` + :param backup: the path of the backup being restored from. + + :rtype: :class:'~google.api_core.operation.Operation` + :returns: a future used to poll the status of the create request + :raises Conflict: if the database already exists + :raises NotFound: + if the instance owning the database does not exist, or + if the backup being restored from does not exist + :raises ValueError: if backup is not set + """ + if source is None: + raise ValueError("Restore source not specified") + api = self._instance._client.database_admin_api + metadata = _metadata_with_prefix(self.name) + future = api.restore_database( + self._instance.name, self.database_id, backup=source.name, metadata=metadata + ) + return future + + def is_ready(self): + """Test whether this database is ready for use. + + :rtype: bool + :returns: True if the database state is READY_OPTIMIZING or READY, else False. + """ + return ( + self.state == enums.Database.State.READY_OPTIMIZING + or self.state == enums.Database.State.READY + ) + + def is_optimized(self): + """Test whether this database has finished optimizing. + + :rtype: bool + :returns: True if the database state is READY, else False. + """ + return self.state == enums.Database.State.READY + + def list_database_operations(self, filter_="", page_size=None): + """List database operations for the database. + + :type filter_: str + :param filter_: + Optional. A string specifying a filter for which database operations to list. + + :type page_size: int + :param page_size: + Optional. The maximum number of operations in each page of results from this + request. Non-positive values are ignored. Defaults to a sensible value set + by the API. + + :type: :class:`~google.api_core.page_iterator.Iterator` + :returns: + Iterator of :class:`~google.api_core.operation.Operation` + resources within the current instance. + """ + database_filter = _DATABASE_METADATA_FILTER.format(self.name) + if filter_: + database_filter = "({0}) AND ({1})".format(filter_, database_filter) + return self._instance.list_database_operations( + filter_=database_filter, page_size=page_size + ) + class BatchCheckout(object): """Context manager for using a batch from a database. @@ -906,3 +1012,13 @@ def _check_ddl_statements(value): raise ValueError("Do not pass a 'CREATE DATABASE' statement") return tuple(value) + + +class RestoreInfo(object): + def __init__(self, source_type, backup_info): + self.source_type = enums.RestoreSourceType(source_type) + self.backup_info = BackupInfo.from_pb(backup_info) + + @classmethod + def from_pb(cls, pb): + return cls(pb.source_type, pb.backup_info) diff --git a/google/cloud/spanner_v1/instance.py b/google/cloud/spanner_v1/instance.py index 05e596622c..4a14032c13 100644 --- a/google/cloud/spanner_v1/instance.py +++ b/google/cloud/spanner_v1/instance.py @@ -14,16 +14,23 @@ """User friendly container for Cloud Spanner Instance.""" +import google.api_core.operation import re from google.cloud.spanner_admin_instance_v1.proto import ( spanner_instance_admin_pb2 as admin_v1_pb2, ) +from google.cloud.spanner_admin_database_v1.proto import ( + backup_pb2, + spanner_database_admin_pb2, +) +from google.protobuf.empty_pb2 import Empty from google.protobuf.field_mask_pb2 import FieldMask # pylint: disable=ungrouped-imports from google.cloud.exceptions import NotFound from google.cloud.spanner_v1._helpers import _metadata_with_prefix +from google.cloud.spanner_v1.backup import Backup from google.cloud.spanner_v1.database import Database from google.cloud.spanner_v1.pool import BurstyPool @@ -36,6 +43,33 @@ DEFAULT_NODE_COUNT = 1 +_OPERATION_METADATA_MESSAGES = ( + backup_pb2.Backup, + backup_pb2.CreateBackupMetadata, + spanner_database_admin_pb2.CreateDatabaseMetadata, + spanner_database_admin_pb2.Database, + spanner_database_admin_pb2.OptimizeRestoredDatabaseMetadata, + spanner_database_admin_pb2.RestoreDatabaseMetadata, + spanner_database_admin_pb2.UpdateDatabaseDdlMetadata, +) + +_OPERATION_METADATA_TYPES = { + "type.googleapis.com/{}".format(message.DESCRIPTOR.full_name): message + for message in _OPERATION_METADATA_MESSAGES +} + +_OPERATION_RESPONSE_TYPES = { + backup_pb2.CreateBackupMetadata: backup_pb2.Backup, + spanner_database_admin_pb2.CreateDatabaseMetadata: spanner_database_admin_pb2.Database, + spanner_database_admin_pb2.OptimizeRestoredDatabaseMetadata: spanner_database_admin_pb2.Database, + spanner_database_admin_pb2.RestoreDatabaseMetadata: spanner_database_admin_pb2.Database, + spanner_database_admin_pb2.UpdateDatabaseDdlMetadata: Empty, +} + + +def _type_string_to_type_pb(type_string): + return _OPERATION_METADATA_TYPES.get(type_string, Empty) + class Instance(object): """Representation of a Cloud Spanner Instance. @@ -379,3 +413,136 @@ def _item_to_database(self, iterator, database_pb): :returns: The next database in the page. """ return Database.from_pb(database_pb, self, pool=BurstyPool()) + + def backup(self, backup_id, database="", expire_time=None): + """Factory to create a backup within this instance. + + :type backup_id: str + :param backup_id: The ID of the backup. + + :type database: :class:`~google.cloud.spanner_v1.database.Database` + :param database: + Optional. The database that will be used when creating the backup. + Required if the create method needs to be called. + + :type expire_time: :class:`datetime.datetime` + :param expire_time: + Optional. The expire time that will be used when creating the backup. + Required if the create method needs to be called. + """ + try: + return Backup( + backup_id, self, database=database.name, expire_time=expire_time + ) + except AttributeError: + return Backup(backup_id, self, database=database, expire_time=expire_time) + + def list_backups(self, filter_="", page_size=None): + """List backups for the instance. + + :type filter_: str + :param filter_: + Optional. A string specifying a filter for which backups to list. + + :type page_size: int + :param page_size: + Optional. The maximum number of databases in each page of results + from this request. Non-positive values are ignored. Defaults to a + sensible value set by the API. + + :rtype: :class:`~google.api_core.page_iterator.Iterator` + :returns: + Iterator of :class:`~google.cloud.spanner_v1.backup.Backup` + resources within the current instance. + """ + metadata = _metadata_with_prefix(self.name) + page_iter = self._client.database_admin_api.list_backups( + self.name, filter_, page_size=page_size, metadata=metadata + ) + page_iter.item_to_value = self._item_to_backup + return page_iter + + def _item_to_backup(self, iterator, backup_pb): + """Convert a backup protobuf to the native object. + + :type iterator: :class:`~google.api_core.page_iterator.Iterator` + :param iterator: The iterator that is currently in use. + + :type backup_pb: :class:`~google.spanner.admin.database.v1.Backup` + :param backup_pb: A backup returned from the API. + + :rtype: :class:`~google.cloud.spanner_v1.backup.Backup` + :returns: The next backup in the page. + """ + return Backup.from_pb(backup_pb, self) + + def list_backup_operations(self, filter_="", page_size=None): + """List backup operations for the instance. + + :type filter_: str + :param filter_: + Optional. A string specifying a filter for which backup operations + to list. + + :type page_size: int + :param page_size: + Optional. The maximum number of operations in each page of results + from this request. Non-positive values are ignored. Defaults to a + sensible value set by the API. + + :rtype: :class:`~google.api_core.page_iterator.Iterator` + :returns: + Iterator of :class:`~google.api_core.operation.Operation` + resources within the current instance. + """ + metadata = _metadata_with_prefix(self.name) + page_iter = self._client.database_admin_api.list_backup_operations( + self.name, filter_, page_size=page_size, metadata=metadata + ) + page_iter.item_to_value = self._item_to_operation + return page_iter + + def list_database_operations(self, filter_="", page_size=None): + """List database operations for the instance. + + :type filter_: str + :param filter_: + Optional. A string specifying a filter for which database operations + to list. + + :type page_size: int + :param page_size: + Optional. The maximum number of operations in each page of results + from this request. Non-positive values are ignored. Defaults to a + sensible value set by the API. + + :rtype: :class:`~google.api_core.page_iterator.Iterator` + :returns: + Iterator of :class:`~google.api_core.operation.Operation` + resources within the current instance. + """ + metadata = _metadata_with_prefix(self.name) + page_iter = self._client.database_admin_api.list_database_operations( + self.name, filter_, page_size=page_size, metadata=metadata + ) + page_iter.item_to_value = self._item_to_operation + return page_iter + + def _item_to_operation(self, iterator, operation_pb): + """Convert an operation protobuf to the native object. + + :type iterator: :class:`~google.api_core.page_iterator.Iterator` + :param iterator: The iterator that is currently in use. + + :type operation_pb: :class:`~google.longrunning.operations.Operation` + :param operation_pb: An operation returned from the API. + + :rtype: :class:`~google.api_core.operation.Operation` + :returns: The next operation in the page. + """ + operations_client = self._client.database_admin_api.transport._operations_client + metadata_type = _type_string_to_type_pb(operation_pb.metadata.type_url) + response_type = _OPERATION_RESPONSE_TYPES[metadata_type] + return google.api_core.operation.from_gapic( + operation_pb, operations_client, response_type, metadata_type=metadata_type + ) diff --git a/tests/unit/test_backup.py b/tests/unit/test_backup.py new file mode 100644 index 0000000000..a3b559b763 --- /dev/null +++ b/tests/unit/test_backup.py @@ -0,0 +1,590 @@ +# Copyright 2020 Google LLC All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +import unittest + +import mock + + +class _BaseTest(unittest.TestCase): + PROJECT_ID = "project-id" + PARENT = "projects/" + PROJECT_ID + INSTANCE_ID = "instance-id" + INSTANCE_NAME = PARENT + "/instances/" + INSTANCE_ID + DATABASE_ID = "database_id" + DATABASE_NAME = INSTANCE_NAME + "/databases/" + DATABASE_ID + BACKUP_ID = "backup_id" + BACKUP_NAME = INSTANCE_NAME + "/backups/" + BACKUP_ID + + def _make_one(self, *args, **kwargs): + return self._get_target_class()(*args, **kwargs) + + @staticmethod + def _make_timestamp(): + import datetime + from google.cloud._helpers import UTC + + return datetime.datetime.utcnow().replace(tzinfo=UTC) + + +class TestBackup(_BaseTest): + def _get_target_class(self): + from google.cloud.spanner_v1.backup import Backup + + return Backup + + @staticmethod + def _make_database_admin_api(): + from google.cloud.spanner_v1.client import DatabaseAdminClient + + return mock.create_autospec(DatabaseAdminClient, instance=True) + + def test_ctor_defaults(self): + instance = _Instance(self.INSTANCE_NAME) + + backup = self._make_one(self.BACKUP_ID, instance) + + self.assertEqual(backup.backup_id, self.BACKUP_ID) + self.assertIs(backup._instance, instance) + self.assertEqual(backup._database, "") + self.assertIsNone(backup._expire_time) + + def test_ctor_non_defaults(self): + instance = _Instance(self.INSTANCE_NAME) + timestamp = self._make_timestamp() + + backup = self._make_one( + self.BACKUP_ID, instance, database=self.DATABASE_NAME, expire_time=timestamp + ) + + self.assertEqual(backup.backup_id, self.BACKUP_ID) + self.assertIs(backup._instance, instance) + self.assertEqual(backup._database, self.DATABASE_NAME) + self.assertIsNotNone(backup._expire_time) + self.assertIs(backup._expire_time, timestamp) + + def test_from_pb_project_mismatch(self): + from google.cloud.spanner_admin_database_v1.proto import backup_pb2 + + ALT_PROJECT = "ALT_PROJECT" + client = _Client(project=ALT_PROJECT) + instance = _Instance(self.INSTANCE_NAME, client) + backup_pb = backup_pb2.Backup(name=self.BACKUP_NAME) + backup_class = self._get_target_class() + + with self.assertRaises(ValueError): + backup_class.from_pb(backup_pb, instance) + + def test_from_pb_instance_mismatch(self): + from google.cloud.spanner_admin_database_v1.proto import backup_pb2 + + ALT_INSTANCE = "/projects/%s/instances/ALT-INSTANCE" % (self.PROJECT_ID,) + client = _Client() + instance = _Instance(ALT_INSTANCE, client) + backup_pb = backup_pb2.Backup(name=self.BACKUP_NAME) + backup_class = self._get_target_class() + + with self.assertRaises(ValueError): + backup_class.from_pb(backup_pb, instance) + + def test_from_pb_invalid_name(self): + from google.cloud.spanner_admin_database_v1.proto import backup_pb2 + + client = _Client() + instance = _Instance(self.INSTANCE_NAME, client) + backup_pb = backup_pb2.Backup(name="invalid_format") + backup_class = self._get_target_class() + + with self.assertRaises(ValueError): + backup_class.from_pb(backup_pb, instance) + + def test_from_pb_success(self): + from google.cloud.spanner_admin_database_v1.proto import backup_pb2 + + client = _Client() + instance = _Instance(self.INSTANCE_NAME, client) + backup_pb = backup_pb2.Backup(name=self.BACKUP_NAME) + backup_class = self._get_target_class() + + backup = backup_class.from_pb(backup_pb, instance) + + self.assertTrue(isinstance(backup, backup_class)) + self.assertEqual(backup._instance, instance) + self.assertEqual(backup.backup_id, self.BACKUP_ID) + self.assertEqual(backup._database, "") + self.assertIsNone(backup._expire_time) + + def test_name_property(self): + instance = _Instance(self.INSTANCE_NAME) + backup = self._make_one(self.BACKUP_ID, instance) + expected_name = self.BACKUP_NAME + self.assertEqual(backup.name, expected_name) + + def test_database_property(self): + instance = _Instance(self.INSTANCE_NAME) + backup = self._make_one(self.BACKUP_ID, instance) + expected = backup._database = self.DATABASE_NAME + self.assertEqual(backup.database, expected) + + def test_expire_time_property(self): + instance = _Instance(self.INSTANCE_NAME) + backup = self._make_one(self.BACKUP_ID, instance) + expected = backup._expire_time = self._make_timestamp() + self.assertEqual(backup.expire_time, expected) + + def test_create_time_property(self): + instance = _Instance(self.INSTANCE_NAME) + backup = self._make_one(self.BACKUP_ID, instance) + expected = backup._create_time = self._make_timestamp() + self.assertEqual(backup.create_time, expected) + + def test_size_bytes_property(self): + instance = _Instance(self.INSTANCE_NAME) + backup = self._make_one(self.BACKUP_ID, instance) + expected = backup._size_bytes = 10 + self.assertEqual(backup.size_bytes, expected) + + def test_state_property(self): + from google.cloud.spanner_admin_database_v1.gapic import enums + + instance = _Instance(self.INSTANCE_NAME) + backup = self._make_one(self.BACKUP_ID, instance) + expected = backup._state = enums.Backup.State.READY + self.assertEqual(backup.state, expected) + + def test_referencing_databases_property(self): + instance = _Instance(self.INSTANCE_NAME) + backup = self._make_one(self.BACKUP_ID, instance) + expected = backup._referencing_databases = [self.DATABASE_NAME] + self.assertEqual(backup.referencing_databases, expected) + + def test_create_grpc_error(self): + from google.api_core.exceptions import GoogleAPICallError + from google.api_core.exceptions import Unknown + + client = _Client() + api = client.database_admin_api = self._make_database_admin_api() + api.create_backup.side_effect = Unknown("testing") + + instance = _Instance(self.INSTANCE_NAME, client=client) + timestamp = self._make_timestamp() + backup = self._make_one( + self.BACKUP_ID, instance, database=self.DATABASE_NAME, expire_time=timestamp + ) + + from google.cloud._helpers import _datetime_to_pb_timestamp + + backup_pb = { + "database": self.DATABASE_NAME, + "expire_time": _datetime_to_pb_timestamp(timestamp), + } + + with self.assertRaises(GoogleAPICallError): + backup.create() + + api.create_backup.assert_called_once_with( + parent=self.INSTANCE_NAME, + backup_id=self.BACKUP_ID, + backup=backup_pb, + metadata=[("google-cloud-resource-prefix", backup.name)], + ) + + def test_create_already_exists(self): + from google.cloud.exceptions import Conflict + + client = _Client() + api = client.database_admin_api = self._make_database_admin_api() + api.create_backup.side_effect = Conflict("testing") + + instance = _Instance(self.INSTANCE_NAME, client=client) + timestamp = self._make_timestamp() + backup = self._make_one( + self.BACKUP_ID, instance, database=self.DATABASE_NAME, expire_time=timestamp + ) + + from google.cloud._helpers import _datetime_to_pb_timestamp + + backup_pb = { + "database": self.DATABASE_NAME, + "expire_time": _datetime_to_pb_timestamp(timestamp), + } + + with self.assertRaises(Conflict): + backup.create() + + api.create_backup.assert_called_once_with( + parent=self.INSTANCE_NAME, + backup_id=self.BACKUP_ID, + backup=backup_pb, + metadata=[("google-cloud-resource-prefix", backup.name)], + ) + + def test_create_instance_not_found(self): + from google.cloud.exceptions import NotFound + + client = _Client() + api = client.database_admin_api = self._make_database_admin_api() + api.create_backup.side_effect = NotFound("testing") + + instance = _Instance(self.INSTANCE_NAME, client=client) + timestamp = self._make_timestamp() + backup = self._make_one( + self.BACKUP_ID, instance, database=self.DATABASE_NAME, expire_time=timestamp + ) + + from google.cloud._helpers import _datetime_to_pb_timestamp + + backup_pb = { + "database": self.DATABASE_NAME, + "expire_time": _datetime_to_pb_timestamp(timestamp), + } + + with self.assertRaises(NotFound): + backup.create() + + api.create_backup.assert_called_once_with( + parent=self.INSTANCE_NAME, + backup_id=self.BACKUP_ID, + backup=backup_pb, + metadata=[("google-cloud-resource-prefix", backup.name)], + ) + + def test_create_expire_time_not_set(self): + instance = _Instance(self.INSTANCE_NAME) + backup = self._make_one(self.BACKUP_ID, instance, database=self.DATABASE_NAME) + + with self.assertRaises(ValueError): + backup.create() + + def test_create_database_not_set(self): + instance = _Instance(self.INSTANCE_NAME) + timestamp = self._make_timestamp() + backup = self._make_one(self.BACKUP_ID, instance, expire_time=timestamp) + + with self.assertRaises(ValueError): + backup.create() + + def test_create_success(self): + op_future = object() + client = _Client() + api = client.database_admin_api = self._make_database_admin_api() + api.create_backup.return_value = op_future + + instance = _Instance(self.INSTANCE_NAME, client=client) + timestamp = self._make_timestamp() + backup = self._make_one( + self.BACKUP_ID, instance, database=self.DATABASE_NAME, expire_time=timestamp + ) + + from google.cloud._helpers import _datetime_to_pb_timestamp + + backup_pb = { + "database": self.DATABASE_NAME, + "expire_time": _datetime_to_pb_timestamp(timestamp), + } + + future = backup.create() + self.assertIs(future, op_future) + + api.create_backup.assert_called_once_with( + parent=self.INSTANCE_NAME, + backup_id=self.BACKUP_ID, + backup=backup_pb, + metadata=[("google-cloud-resource-prefix", backup.name)], + ) + + def test_exists_grpc_error(self): + from google.api_core.exceptions import Unknown + + client = _Client() + api = client.database_admin_api = self._make_database_admin_api() + api.get_backup.side_effect = Unknown("testing") + + instance = _Instance(self.INSTANCE_NAME, client=client) + backup = self._make_one(self.BACKUP_ID, instance) + + with self.assertRaises(Unknown): + backup.exists() + + api.get_backup.assert_called_once_with( + self.BACKUP_NAME, metadata=[("google-cloud-resource-prefix", backup.name)] + ) + + def test_exists_not_found(self): + from google.api_core.exceptions import NotFound + + client = _Client() + api = client.database_admin_api = self._make_database_admin_api() + api.get_backup.side_effect = NotFound("testing") + + instance = _Instance(self.INSTANCE_NAME, client=client) + backup = self._make_one(self.BACKUP_ID, instance) + + self.assertFalse(backup.exists()) + + api.get_backup.assert_called_once_with( + self.BACKUP_NAME, metadata=[("google-cloud-resource-prefix", backup.name)] + ) + + def test_exists_success(self): + from google.cloud.spanner_admin_database_v1.proto import backup_pb2 + + client = _Client() + backup_pb = backup_pb2.Backup(name=self.BACKUP_NAME) + api = client.database_admin_api = self._make_database_admin_api() + api.get_backup.return_value = backup_pb + + instance = _Instance(self.INSTANCE_NAME, client=client) + backup = self._make_one(self.BACKUP_ID, instance) + + self.assertTrue(backup.exists()) + + api.get_backup.assert_called_once_with( + self.BACKUP_NAME, metadata=[("google-cloud-resource-prefix", backup.name)] + ) + + def test_delete_grpc_error(self): + from google.api_core.exceptions import Unknown + + client = _Client() + api = client.database_admin_api = self._make_database_admin_api() + api.delete_backup.side_effect = Unknown("testing") + instance = _Instance(self.INSTANCE_NAME, client=client) + backup = self._make_one(self.BACKUP_ID, instance) + + with self.assertRaises(Unknown): + backup.delete() + + api.delete_backup.assert_called_once_with( + self.BACKUP_NAME, metadata=[("google-cloud-resource-prefix", backup.name)] + ) + + def test_delete_not_found(self): + from google.api_core.exceptions import NotFound + + client = _Client() + api = client.database_admin_api = self._make_database_admin_api() + api.delete_backup.side_effect = NotFound("testing") + instance = _Instance(self.INSTANCE_NAME, client=client) + backup = self._make_one(self.BACKUP_ID, instance) + + with self.assertRaises(NotFound): + backup.delete() + + api.delete_backup.assert_called_once_with( + self.BACKUP_NAME, metadata=[("google-cloud-resource-prefix", backup.name)] + ) + + def test_delete_success(self): + from google.protobuf.empty_pb2 import Empty + + client = _Client() + api = client.database_admin_api = self._make_database_admin_api() + api.delete_backup.return_value = Empty() + instance = _Instance(self.INSTANCE_NAME, client=client) + backup = self._make_one(self.BACKUP_ID, instance) + + backup.delete() + + api.delete_backup.assert_called_once_with( + self.BACKUP_NAME, metadata=[("google-cloud-resource-prefix", backup.name)] + ) + + def test_reload_grpc_error(self): + from google.api_core.exceptions import Unknown + + client = _Client() + api = client.database_admin_api = self._make_database_admin_api() + api.get_backup.side_effect = Unknown("testing") + instance = _Instance(self.INSTANCE_NAME, client=client) + backup = self._make_one(self.BACKUP_ID, instance) + + with self.assertRaises(Unknown): + backup.reload() + + api.get_backup.assert_called_once_with( + self.BACKUP_NAME, metadata=[("google-cloud-resource-prefix", backup.name)] + ) + + def test_reload_not_found(self): + from google.api_core.exceptions import NotFound + + client = _Client() + api = client.database_admin_api = self._make_database_admin_api() + api.get_backup.side_effect = NotFound("testing") + instance = _Instance(self.INSTANCE_NAME, client=client) + backup = self._make_one(self.BACKUP_ID, instance) + + with self.assertRaises(NotFound): + backup.reload() + + api.get_backup.assert_called_once_with( + self.BACKUP_NAME, metadata=[("google-cloud-resource-prefix", backup.name)] + ) + + def test_reload_success(self): + from google.cloud.spanner_admin_database_v1.proto import backup_pb2 + from google.cloud.spanner_admin_database_v1.gapic import enums + from google.cloud._helpers import _datetime_to_pb_timestamp + + timestamp = self._make_timestamp() + + client = _Client() + backup_pb = backup_pb2.Backup( + name=self.BACKUP_NAME, + database=self.DATABASE_NAME, + expire_time=_datetime_to_pb_timestamp(timestamp), + create_time=_datetime_to_pb_timestamp(timestamp), + size_bytes=10, + state=1, + referencing_databases=[], + ) + api = client.database_admin_api = self._make_database_admin_api() + api.get_backup.return_value = backup_pb + instance = _Instance(self.INSTANCE_NAME, client=client) + backup = self._make_one(self.BACKUP_ID, instance) + + backup.reload() + self.assertEqual(backup.name, self.BACKUP_NAME) + self.assertEqual(backup.database, self.DATABASE_NAME) + self.assertEqual(backup.expire_time, timestamp) + self.assertEqual(backup.create_time, timestamp) + self.assertEqual(backup.size_bytes, 10) + self.assertEqual(backup.state, enums.Backup.State.CREATING) + self.assertEqual(backup.referencing_databases, []) + + api.get_backup.assert_called_once_with( + self.BACKUP_NAME, metadata=[("google-cloud-resource-prefix", backup.name)] + ) + + def test_update_expire_time_grpc_error(self): + from google.api_core.exceptions import Unknown + from google.cloud._helpers import _datetime_to_pb_timestamp + + client = _Client() + api = client.database_admin_api = self._make_database_admin_api() + api.update_backup.side_effect = Unknown("testing") + instance = _Instance(self.INSTANCE_NAME, client=client) + backup = self._make_one(self.BACKUP_ID, instance) + expire_time = self._make_timestamp() + + with self.assertRaises(Unknown): + backup.update_expire_time(expire_time) + + backup_update = { + "name": self.BACKUP_NAME, + "expire_time": _datetime_to_pb_timestamp(expire_time), + } + update_mask = {"paths": ["expire_time"]} + api.update_backup.assert_called_once_with( + backup_update, + update_mask, + metadata=[("google-cloud-resource-prefix", backup.name)], + ) + + def test_update_expire_time_not_found(self): + from google.api_core.exceptions import NotFound + from google.cloud._helpers import _datetime_to_pb_timestamp + + client = _Client() + api = client.database_admin_api = self._make_database_admin_api() + api.update_backup.side_effect = NotFound("testing") + instance = _Instance(self.INSTANCE_NAME, client=client) + backup = self._make_one(self.BACKUP_ID, instance) + expire_time = self._make_timestamp() + + with self.assertRaises(NotFound): + backup.update_expire_time(expire_time) + + backup_update = { + "name": self.BACKUP_NAME, + "expire_time": _datetime_to_pb_timestamp(expire_time), + } + update_mask = {"paths": ["expire_time"]} + api.update_backup.assert_called_once_with( + backup_update, + update_mask, + metadata=[("google-cloud-resource-prefix", backup.name)], + ) + + def test_update_expire_time_success(self): + from google.cloud._helpers import _datetime_to_pb_timestamp + from google.cloud.spanner_admin_database_v1.proto import backup_pb2 + + client = _Client() + api = client.database_admin_api = self._make_database_admin_api() + api.update_backup.return_type = backup_pb2.Backup(name=self.BACKUP_NAME) + instance = _Instance(self.INSTANCE_NAME, client=client) + backup = self._make_one(self.BACKUP_ID, instance) + expire_time = self._make_timestamp() + + backup.update_expire_time(expire_time) + + backup_update = { + "name": self.BACKUP_NAME, + "expire_time": _datetime_to_pb_timestamp(expire_time), + } + update_mask = {"paths": ["expire_time"]} + api.update_backup.assert_called_once_with( + backup_update, + update_mask, + metadata=[("google-cloud-resource-prefix", backup.name)], + ) + + def test_is_ready(self): + from google.cloud.spanner_admin_database_v1.gapic import enums + + client = _Client() + instance = _Instance(self.INSTANCE_NAME, client=client) + backup = self._make_one(self.BACKUP_ID, instance) + backup._state = enums.Backup.State.READY + self.assertTrue(backup.is_ready()) + backup._state = enums.Backup.State.CREATING + self.assertFalse(backup.is_ready()) + + +class TestBackupInfo(_BaseTest): + def test_from_pb(self): + from google.cloud.spanner_admin_database_v1.proto import backup_pb2 + from google.cloud.spanner_v1.backup import BackupInfo + from google.cloud._helpers import _datetime_to_pb_timestamp + + backup_name = "backup_name" + timestamp = self._make_timestamp() + database_name = "database_name" + + pb = backup_pb2.BackupInfo( + backup=backup_name, + create_time=_datetime_to_pb_timestamp(timestamp), + source_database=database_name, + ) + backup_info = BackupInfo.from_pb(pb) + + self.assertEqual(backup_info.backup, backup_name) + self.assertEqual(backup_info.create_time, timestamp) + self.assertEqual(backup_info.source_database, database_name) + + +class _Client(object): + def __init__(self, project=TestBackup.PROJECT_ID): + self.project = project + self.project_name = "projects/" + self.project + + +class _Instance(object): + def __init__(self, name, client=None): + self.name = name + self.instance_id = name.rsplit("/", 1)[1] + self._client = client diff --git a/tests/unit/test_database.py b/tests/unit/test_database.py index 2d7e2e1888..4b343c2fd9 100644 --- a/tests/unit/test_database.py +++ b/tests/unit/test_database.py @@ -53,6 +53,8 @@ class _BaseTest(unittest.TestCase): SESSION_ID = "session_id" SESSION_NAME = DATABASE_NAME + "/sessions/" + SESSION_ID TRANSACTION_ID = b"transaction_id" + BACKUP_ID = "backup_id" + BACKUP_NAME = INSTANCE_NAME + "/backups/" + BACKUP_ID def _make_one(self, *args, **kwargs): return self._get_target_class()(*args, **kwargs) @@ -230,6 +232,33 @@ def test_name_property(self): expected_name = self.DATABASE_NAME self.assertEqual(database.name, expected_name) + def test_create_time_property(self): + instance = _Instance(self.INSTANCE_NAME) + pool = _Pool() + database = self._make_one(self.DATABASE_ID, instance, pool=pool) + expected_create_time = database._create_time = self._make_timestamp() + self.assertEqual(database.create_time, expected_create_time) + + def test_state_property(self): + from google.cloud.spanner_admin_database_v1.gapic import enums + + instance = _Instance(self.INSTANCE_NAME) + pool = _Pool() + database = self._make_one(self.DATABASE_ID, instance, pool=pool) + expected_state = database._state = enums.Database.State.READY + self.assertEqual(database.state, expected_state) + + def test_restore_info(self): + from google.cloud.spanner_v1.database import RestoreInfo + + instance = _Instance(self.INSTANCE_NAME) + pool = _Pool() + database = self._make_one(self.DATABASE_ID, instance, pool=pool) + restore_info = database._restore_info = mock.create_autospec( + RestoreInfo, instance=True + ) + self.assertEqual(database.restore_info, restore_info) + def test_spanner_api_property_w_scopeless_creds(self): from google.cloud.spanner_admin_instance_v1.proto import ( spanner_instance_admin_pb2 as admin_v1_pb2, @@ -766,24 +795,41 @@ def test_reload_success(self): from google.cloud.spanner_admin_database_v1.proto import ( spanner_database_admin_pb2 as admin_v1_pb2, ) + from google.cloud.spanner_admin_database_v1.gapic import enums + from google.cloud._helpers import _datetime_to_pb_timestamp from tests._fixtures import DDL_STATEMENTS + timestamp = self._make_timestamp() + restore_info = admin_v1_pb2.RestoreInfo() + client = _Client() ddl_pb = admin_v1_pb2.GetDatabaseDdlResponse(statements=DDL_STATEMENTS) api = client.database_admin_api = self._make_database_admin_api() api.get_database_ddl.return_value = ddl_pb + db_pb = admin_v1_pb2.Database( + state=2, + create_time=_datetime_to_pb_timestamp(timestamp), + restore_info=restore_info, + ) + api.get_database.return_value = db_pb instance = _Instance(self.INSTANCE_NAME, client=client) pool = _Pool() database = self._make_one(self.DATABASE_ID, instance, pool=pool) database.reload() - + self.assertEqual(database._state, enums.Database.State.READY) + self.assertEqual(database._create_time, timestamp) + self.assertEqual(database._restore_info, restore_info) self.assertEqual(database._ddl_statements, tuple(DDL_STATEMENTS)) api.get_database_ddl.assert_called_once_with( self.DATABASE_NAME, metadata=[("google-cloud-resource-prefix", database.name)], ) + api.get_database.assert_called_once_with( + self.DATABASE_NAME, + metadata=[("google-cloud-resource-prefix", database.name)], + ) def test_update_ddl_grpc_error(self): from google.api_core.exceptions import Unknown @@ -1195,6 +1241,180 @@ def nested_unit_of_work(): database.run_in_transaction(nested_unit_of_work) self.assertEqual(inner.call_count, 0) + def test_restore_backup_unspecified(self): + instance = _Instance(self.INSTANCE_NAME, client=_Client()) + database = self._make_one(self.DATABASE_ID, instance) + + with self.assertRaises(ValueError): + database.restore(None) + + def test_restore_grpc_error(self): + from google.api_core.exceptions import Unknown + + client = _Client() + api = client.database_admin_api = self._make_database_admin_api() + api.restore_database.side_effect = Unknown("testing") + instance = _Instance(self.INSTANCE_NAME, client=client) + pool = _Pool() + database = self._make_one(self.DATABASE_ID, instance, pool=pool) + backup = _Backup(self.BACKUP_NAME) + + with self.assertRaises(Unknown): + database.restore(backup) + + api.restore_database.assert_called_once_with( + parent=self.INSTANCE_NAME, + database_id=self.DATABASE_ID, + backup=self.BACKUP_NAME, + metadata=[("google-cloud-resource-prefix", database.name)], + ) + + def test_restore_not_found(self): + from google.api_core.exceptions import NotFound + + client = _Client() + api = client.database_admin_api = self._make_database_admin_api() + api.restore_database.side_effect = NotFound("testing") + instance = _Instance(self.INSTANCE_NAME, client=client) + pool = _Pool() + database = self._make_one(self.DATABASE_ID, instance, pool=pool) + backup = _Backup(self.BACKUP_NAME) + + with self.assertRaises(NotFound): + database.restore(backup) + + api.restore_database.assert_called_once_with( + parent=self.INSTANCE_NAME, + database_id=self.DATABASE_ID, + backup=self.BACKUP_NAME, + metadata=[("google-cloud-resource-prefix", database.name)], + ) + + def test_restore_success(self): + op_future = object() + client = _Client() + api = client.database_admin_api = self._make_database_admin_api() + api.restore_database.return_value = op_future + instance = _Instance(self.INSTANCE_NAME, client=client) + pool = _Pool() + database = self._make_one(self.DATABASE_ID, instance, pool=pool) + backup = _Backup(self.BACKUP_NAME) + + future = database.restore(backup) + + self.assertIs(future, op_future) + + api.restore_database.assert_called_once_with( + parent=self.INSTANCE_NAME, + database_id=self.DATABASE_ID, + backup=self.BACKUP_NAME, + metadata=[("google-cloud-resource-prefix", database.name)], + ) + + def test_is_ready(self): + from google.cloud.spanner_admin_database_v1.gapic import enums + + client = _Client() + instance = _Instance(self.INSTANCE_NAME, client=client) + pool = _Pool() + database = self._make_one(self.DATABASE_ID, instance, pool=pool) + database._state = enums.Database.State.READY + self.assertTrue(database.is_ready()) + database._state = enums.Database.State.READY_OPTIMIZING + self.assertTrue(database.is_ready()) + database._state = enums.Database.State.CREATING + self.assertFalse(database.is_ready()) + + def test_is_optimized(self): + from google.cloud.spanner_admin_database_v1.gapic import enums + + client = _Client() + instance = _Instance(self.INSTANCE_NAME, client=client) + pool = _Pool() + database = self._make_one(self.DATABASE_ID, instance, pool=pool) + database._state = enums.Database.State.READY + self.assertTrue(database.is_optimized()) + database._state = enums.Database.State.READY_OPTIMIZING + self.assertFalse(database.is_optimized()) + database._state = enums.Database.State.CREATING + self.assertFalse(database.is_optimized()) + + def test_list_database_operations_grpc_error(self): + from google.api_core.exceptions import Unknown + from google.cloud.spanner_v1.database import _DATABASE_METADATA_FILTER + + client = _Client() + instance = _Instance(self.INSTANCE_NAME, client=client) + instance.list_database_operations = mock.MagicMock( + side_effect=Unknown("testing") + ) + pool = _Pool() + database = self._make_one(self.DATABASE_ID, instance, pool=pool) + + with self.assertRaises(Unknown): + database.list_database_operations() + + instance.list_database_operations.assert_called_once_with( + filter_=_DATABASE_METADATA_FILTER.format(database.name), page_size=None + ) + + def test_list_database_operations_not_found(self): + from google.api_core.exceptions import NotFound + from google.cloud.spanner_v1.database import _DATABASE_METADATA_FILTER + + client = _Client() + instance = _Instance(self.INSTANCE_NAME, client=client) + instance.list_database_operations = mock.MagicMock( + side_effect=NotFound("testing") + ) + pool = _Pool() + database = self._make_one(self.DATABASE_ID, instance, pool=pool) + + with self.assertRaises(NotFound): + database.list_database_operations() + + instance.list_database_operations.assert_called_once_with( + filter_=_DATABASE_METADATA_FILTER.format(database.name), page_size=None + ) + + def test_list_database_operations_defaults(self): + from google.cloud.spanner_v1.database import _DATABASE_METADATA_FILTER + + client = _Client() + instance = _Instance(self.INSTANCE_NAME, client=client) + instance.list_database_operations = mock.MagicMock(return_value=[]) + pool = _Pool() + database = self._make_one(self.DATABASE_ID, instance, pool=pool) + + database.list_database_operations() + + instance.list_database_operations.assert_called_once_with( + filter_=_DATABASE_METADATA_FILTER.format(database.name), page_size=None + ) + + def test_list_database_operations_explicit_filter(self): + from google.cloud.spanner_v1.database import _DATABASE_METADATA_FILTER + + client = _Client() + instance = _Instance(self.INSTANCE_NAME, client=client) + instance.list_database_operations = mock.MagicMock(return_value=[]) + pool = _Pool() + database = self._make_one(self.DATABASE_ID, instance, pool=pool) + + expected_filter_ = "({0}) AND ({1})".format( + "metadata.@type:type.googleapis.com/google.spanner.admin.database.v1.RestoreDatabaseMetadata", + _DATABASE_METADATA_FILTER.format(database.name), + ) + page_size = 10 + database.list_database_operations( + filter_="metadata.@type:type.googleapis.com/google.spanner.admin.database.v1.RestoreDatabaseMetadata", + page_size=page_size, + ) + + instance.list_database_operations.assert_called_once_with( + filter_=expected_filter_, page_size=page_size + ) + class TestBatchCheckout(_BaseTest): def _get_target_class(self): @@ -1810,6 +2030,30 @@ def _make_instance_api(): return mock.create_autospec(InstanceAdminClient) +class TestRestoreInfo(_BaseTest): + def test_from_pb(self): + from google.cloud.spanner_v1.database import RestoreInfo + from google.cloud.spanner_admin_database_v1.gapic import enums + from google.cloud.spanner_admin_database_v1.proto import ( + backup_pb2, + spanner_database_admin_pb2 as admin_v1_pb2, + ) + from google.cloud._helpers import _datetime_to_pb_timestamp + + timestamp = self._make_timestamp() + restore_pb = admin_v1_pb2.RestoreInfo( + source_type=1, + backup_info=backup_pb2.BackupInfo( + backup="backup_path", + create_time=_datetime_to_pb_timestamp(timestamp), + source_database="database_path", + ), + ) + restore_info = RestoreInfo.from_pb(restore_pb) + self.assertEqual(restore_info.source_type, enums.RestoreSourceType.BACKUP) + self.assertEqual(restore_info.backup_info.create_time, timestamp) + + class _Client(object): def __init__(self, project=TestDatabase.PROJECT_ID): from google.cloud.spanner_v1.proto.spanner_pb2 import ExecuteSqlRequest @@ -1831,6 +2075,11 @@ def __init__(self, name, client=None, emulator_host=None): self.emulator_host = emulator_host +class _Backup(object): + def __init__(self, name): + self.name = name + + class _Database(object): def __init__(self, name, instance=None): self.name = name diff --git a/tests/unit/test_instance.py b/tests/unit/test_instance.py index 0e7bc99df4..b71445d835 100644 --- a/tests/unit/test_instance.py +++ b/tests/unit/test_instance.py @@ -579,6 +579,333 @@ def test_list_databases_w_options(self): timeout=mock.ANY, ) + def test_backup_factory_defaults(self): + from google.cloud.spanner_v1.backup import Backup + + client = _Client(self.PROJECT) + instance = self._make_one(self.INSTANCE_ID, client, self.CONFIG_NAME) + BACKUP_ID = "backup-id" + + backup = instance.backup(BACKUP_ID) + + self.assertIsInstance(backup, Backup) + self.assertEqual(backup.backup_id, BACKUP_ID) + self.assertIs(backup._instance, instance) + self.assertEqual(backup._database, "") + self.assertIsNone(backup._expire_time) + + def test_backup_factory_explicit(self): + import datetime + from google.cloud._helpers import UTC + from google.cloud.spanner_v1.backup import Backup + + client = _Client(self.PROJECT) + instance = self._make_one(self.INSTANCE_ID, client, self.CONFIG_NAME) + BACKUP_ID = "backup-id" + DATABASE_NAME = "database-name" + timestamp = datetime.datetime.utcnow().replace(tzinfo=UTC) + + backup = instance.backup( + BACKUP_ID, database=DATABASE_NAME, expire_time=timestamp + ) + + self.assertIsInstance(backup, Backup) + self.assertEqual(backup.backup_id, BACKUP_ID) + self.assertIs(backup._instance, instance) + self.assertEqual(backup._database, DATABASE_NAME) + self.assertIs(backup._expire_time, timestamp) + + def test_list_backups_defaults(self): + from google.cloud.spanner_admin_database_v1.gapic import database_admin_client + from google.cloud.spanner_admin_database_v1.proto import backup_pb2 + from google.cloud.spanner_v1.backup import Backup + + api = database_admin_client.DatabaseAdminClient(mock.Mock()) + client = _Client(self.PROJECT) + client.database_admin_api = api + instance = self._make_one(self.INSTANCE_ID, client) + + backups_pb = backup_pb2.ListBackupsResponse( + backups=[ + backup_pb2.Backup(name=instance.name + "/backups/op1"), + backup_pb2.Backup(name=instance.name + "/backups/op2"), + backup_pb2.Backup(name=instance.name + "/backups/op3"), + ] + ) + + ldo_api = api._inner_api_calls["list_backups"] = mock.Mock( + return_value=backups_pb + ) + + backups = instance.list_backups() + + for backup in backups: + self.assertIsInstance(backup, Backup) + + expected_metadata = [ + ("google-cloud-resource-prefix", instance.name), + ("x-goog-request-params", "parent={}".format(instance.name)), + ] + ldo_api.assert_called_once_with( + backup_pb2.ListBackupsRequest(parent=self.INSTANCE_NAME), + metadata=expected_metadata, + retry=mock.ANY, + timeout=mock.ANY, + ) + + def test_list_backups_w_options(self): + from google.cloud.spanner_admin_database_v1.gapic import database_admin_client + from google.cloud.spanner_admin_database_v1.proto import backup_pb2 + from google.cloud.spanner_v1.backup import Backup + + api = database_admin_client.DatabaseAdminClient(mock.Mock()) + client = _Client(self.PROJECT) + client.database_admin_api = api + instance = self._make_one(self.INSTANCE_ID, client) + + backups_pb = backup_pb2.ListBackupsResponse( + backups=[ + backup_pb2.Backup(name=instance.name + "/backups/op1"), + backup_pb2.Backup(name=instance.name + "/backups/op2"), + backup_pb2.Backup(name=instance.name + "/backups/op3"), + ] + ) + + ldo_api = api._inner_api_calls["list_backups"] = mock.Mock( + return_value=backups_pb + ) + + backups = instance.list_backups(filter_="filter", page_size=10) + + for backup in backups: + self.assertIsInstance(backup, Backup) + + expected_metadata = [ + ("google-cloud-resource-prefix", instance.name), + ("x-goog-request-params", "parent={}".format(instance.name)), + ] + ldo_api.assert_called_once_with( + backup_pb2.ListBackupsRequest( + parent=self.INSTANCE_NAME, filter="filter", page_size=10 + ), + metadata=expected_metadata, + retry=mock.ANY, + timeout=mock.ANY, + ) + + def test_list_backup_operations_defaults(self): + from google.api_core.operation import Operation + from google.cloud.spanner_admin_database_v1.gapic import database_admin_client + from google.cloud.spanner_admin_database_v1.proto import backup_pb2 + from google.longrunning import operations_pb2 + from google.protobuf.any_pb2 import Any + + api = database_admin_client.DatabaseAdminClient(mock.Mock()) + client = _Client(self.PROJECT) + client.database_admin_api = api + instance = self._make_one(self.INSTANCE_ID, client) + + create_backup_metadata = Any() + create_backup_metadata.Pack(backup_pb2.CreateBackupMetadata()) + + operations_pb = backup_pb2.ListBackupOperationsResponse( + operations=[ + operations_pb2.Operation(name="op1", metadata=create_backup_metadata) + ] + ) + + ldo_api = api._inner_api_calls["list_backup_operations"] = mock.Mock( + return_value=operations_pb + ) + + operations = instance.list_backup_operations() + + for op in operations: + self.assertIsInstance(op, Operation) + + expected_metadata = [ + ("google-cloud-resource-prefix", instance.name), + ("x-goog-request-params", "parent={}".format(instance.name)), + ] + ldo_api.assert_called_once_with( + backup_pb2.ListBackupOperationsRequest(parent=self.INSTANCE_NAME), + metadata=expected_metadata, + retry=mock.ANY, + timeout=mock.ANY, + ) + + def test_list_backup_operations_w_options(self): + from google.api_core.operation import Operation + from google.cloud.spanner_admin_database_v1.gapic import database_admin_client + from google.cloud.spanner_admin_database_v1.proto import backup_pb2 + from google.longrunning import operations_pb2 + from google.protobuf.any_pb2 import Any + + api = database_admin_client.DatabaseAdminClient(mock.Mock()) + client = _Client(self.PROJECT) + client.database_admin_api = api + instance = self._make_one(self.INSTANCE_ID, client) + + create_backup_metadata = Any() + create_backup_metadata.Pack(backup_pb2.CreateBackupMetadata()) + + operations_pb = backup_pb2.ListBackupOperationsResponse( + operations=[ + operations_pb2.Operation(name="op1", metadata=create_backup_metadata) + ] + ) + + ldo_api = api._inner_api_calls["list_backup_operations"] = mock.Mock( + return_value=operations_pb + ) + + operations = instance.list_backup_operations(filter_="filter", page_size=10) + + for op in operations: + self.assertIsInstance(op, Operation) + + expected_metadata = [ + ("google-cloud-resource-prefix", instance.name), + ("x-goog-request-params", "parent={}".format(instance.name)), + ] + ldo_api.assert_called_once_with( + backup_pb2.ListBackupOperationsRequest( + parent=self.INSTANCE_NAME, filter="filter", page_size=10 + ), + metadata=expected_metadata, + retry=mock.ANY, + timeout=mock.ANY, + ) + + def test_list_database_operations_defaults(self): + from google.api_core.operation import Operation + from google.cloud.spanner_admin_database_v1.gapic import database_admin_client + from google.cloud.spanner_admin_database_v1.proto import ( + spanner_database_admin_pb2, + ) + from google.longrunning import operations_pb2 + from google.protobuf.any_pb2 import Any + + api = database_admin_client.DatabaseAdminClient(mock.Mock()) + client = _Client(self.PROJECT) + client.database_admin_api = api + instance = self._make_one(self.INSTANCE_ID, client) + + create_database_metadata = Any() + create_database_metadata.Pack( + spanner_database_admin_pb2.CreateDatabaseMetadata() + ) + + optimize_database_metadata = Any() + optimize_database_metadata.Pack( + spanner_database_admin_pb2.OptimizeRestoredDatabaseMetadata() + ) + + databases_pb = spanner_database_admin_pb2.ListDatabaseOperationsResponse( + operations=[ + operations_pb2.Operation(name="op1", metadata=create_database_metadata), + operations_pb2.Operation( + name="op2", metadata=optimize_database_metadata + ), + ] + ) + + ldo_api = api._inner_api_calls["list_database_operations"] = mock.Mock( + return_value=databases_pb + ) + + operations = instance.list_database_operations() + + for op in operations: + self.assertIsInstance(op, Operation) + + expected_metadata = [ + ("google-cloud-resource-prefix", instance.name), + ("x-goog-request-params", "parent={}".format(instance.name)), + ] + ldo_api.assert_called_once_with( + spanner_database_admin_pb2.ListDatabaseOperationsRequest( + parent=self.INSTANCE_NAME + ), + metadata=expected_metadata, + retry=mock.ANY, + timeout=mock.ANY, + ) + + def test_list_database_operations_w_options(self): + from google.api_core.operation import Operation + from google.cloud.spanner_admin_database_v1.gapic import database_admin_client + from google.cloud.spanner_admin_database_v1.proto import ( + spanner_database_admin_pb2, + ) + from google.longrunning import operations_pb2 + from google.protobuf.any_pb2 import Any + + api = database_admin_client.DatabaseAdminClient(mock.Mock()) + client = _Client(self.PROJECT) + client.database_admin_api = api + instance = self._make_one(self.INSTANCE_ID, client) + + restore_database_metadata = Any() + restore_database_metadata.Pack( + spanner_database_admin_pb2.RestoreDatabaseMetadata() + ) + + update_database_metadata = Any() + update_database_metadata.Pack( + spanner_database_admin_pb2.UpdateDatabaseDdlMetadata() + ) + + databases_pb = spanner_database_admin_pb2.ListDatabaseOperationsResponse( + operations=[ + operations_pb2.Operation( + name="op1", metadata=restore_database_metadata + ), + operations_pb2.Operation(name="op2", metadata=update_database_metadata), + ] + ) + + ldo_api = api._inner_api_calls["list_database_operations"] = mock.Mock( + return_value=databases_pb + ) + + operations = instance.list_database_operations(filter_="filter", page_size=10) + + for op in operations: + self.assertIsInstance(op, Operation) + + expected_metadata = [ + ("google-cloud-resource-prefix", instance.name), + ("x-goog-request-params", "parent={}".format(instance.name)), + ] + ldo_api.assert_called_once_with( + spanner_database_admin_pb2.ListDatabaseOperationsRequest( + parent=self.INSTANCE_NAME, filter="filter", page_size=10 + ), + metadata=expected_metadata, + retry=mock.ANY, + timeout=mock.ANY, + ) + + def test_type_string_to_type_pb_hit(self): + from google.cloud.spanner_admin_database_v1.proto import ( + spanner_database_admin_pb2, + ) + from google.cloud.spanner_v1 import instance + + type_string = "type.googleapis.com/google.spanner.admin.database.v1.OptimizeRestoredDatabaseMetadata" + self.assertIn(type_string, instance._OPERATION_METADATA_TYPES) + self.assertEqual( + instance._type_string_to_type_pb(type_string), + spanner_database_admin_pb2.OptimizeRestoredDatabaseMetadata, + ) + + def test_type_string_to_type_pb_miss(self): + from google.cloud.spanner_v1 import instance + from google.protobuf.empty_pb2 import Empty + + self.assertEqual(instance._type_string_to_type_pb("invalid_string"), Empty) + class _Client(object): def __init__(self, project, timeout_seconds=None):