diff --git a/docs/api-reference.rst b/docs/api-reference.rst index 30f67cd300..41046f78bf 100644 --- a/docs/api-reference.rst +++ b/docs/api-reference.rst @@ -10,6 +10,7 @@ Most likely, you will be interacting almost exclusively with these: client-api instance-api database-api + table-api session-api keyset-api snapshot-api diff --git a/docs/index.rst b/docs/index.rst index cabf56157c..a4ab1b27d7 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -11,6 +11,7 @@ Usage Documentation client-usage instance-usage database-usage + table-usage batch-usage snapshot-usage transaction-usage diff --git a/docs/table-api.rst b/docs/table-api.rst new file mode 100644 index 0000000000..86b81dc86e --- /dev/null +++ b/docs/table-api.rst @@ -0,0 +1,6 @@ +Table API +========= + +.. automodule:: google.cloud.spanner_v1.table + :members: + :show-inheritance: diff --git a/docs/table-usage.rst b/docs/table-usage.rst new file mode 100644 index 0000000000..9d28da1ebb --- /dev/null +++ b/docs/table-usage.rst @@ -0,0 +1,47 @@ +Table Admin +=========== + +After creating an :class:`~google.cloud.spanner_v1.database.Database`, you can +interact with individual tables for that instance. + + +List Tables +----------- + +To iterate over all existing tables for an database, use its +:meth:`~google.cloud.spanner_v1.database.Database.list_tables` method: + +.. code:: python + + for table in database.list_tables(): + # `table` is a `Table` object. + +This method yields :class:`~google.cloud.spanner_v1.table.Table` objects. + + +Table Factory +------------- + +A :class:`~google.cloud.spanner_v1.table.Table` object can be created with the +:meth:`~google.cloud.spanner_v1.database.Database.table` factory method: + +.. code:: python + + table = database.table("my_table_id") + if table.exists(): + print("Table with ID 'my_table' exists.") + else: + print("Table with ID 'my_table' does not exist." + + +Getting the Table Schema +------------------------ + +Use the :attr:`~google.cloud.spanner_v1.table.Table.schema` property to inspect +the columns of a table as a list of +:class:`~google.cloud.spanner_v1.types.StructType.Field` objects. + +.. code:: python + + for field in table.schema + # `field` is a `Field` object. diff --git a/google/cloud/spanner_v1/database.py b/google/cloud/spanner_v1/database.py index 1b3448439c..92c797b987 100644 --- a/google/cloud/spanner_v1/database.py +++ b/google/cloud/spanner_v1/database.py @@ -48,11 +48,12 @@ ) from google.cloud.spanner_admin_database_v1 import CreateDatabaseRequest from google.cloud.spanner_admin_database_v1 import UpdateDatabaseDdlRequest -from google.cloud.spanner_v1 import ExecuteSqlRequest from google.cloud.spanner_v1 import ( + ExecuteSqlRequest, TransactionSelector, TransactionOptions, ) +from google.cloud.spanner_v1.table import Table # pylint: enable=ungrouped-imports @@ -68,6 +69,11 @@ _DATABASE_METADATA_FILTER = "name:{0}/operations/" +_LIST_TABLES_QUERY = """SELECT TABLE_NAME +FROM INFORMATION_SCHEMA.TABLES +WHERE SPANNER_STATE = 'COMMITTED' +""" + DEFAULT_RETRY_BACKOFF = Retry(initial=0.02, maximum=32, multiplier=1.3) @@ -649,6 +655,41 @@ def list_database_operations(self, filter_="", page_size=None): filter_=database_filter, page_size=page_size ) + def table(self, table_id): + """Factory to create a table object within this database. + + Note: This method does not create a table in Cloud Spanner, but it can + be used to check if a table exists. + + .. code-block:: python + + my_table = database.table("my_table") + if my_table.exists(): + print("Table with ID 'my_table' exists.") + else: + print("Table with ID 'my_table' does not exist.") + + :type table_id: str + :param table_id: The ID of the table. + + :rtype: :class:`~google.cloud.spanner_v1.table.Table` + :returns: a table owned by this database. + """ + return Table(table_id, self) + + def list_tables(self): + """List tables within the database. + + :type: Iterable + :returns: + Iterable of :class:`~google.cloud.spanner_v1.table.Table` + resources within the current database. + """ + with self.snapshot() as snapshot: + results = snapshot.execute_sql(_LIST_TABLES_QUERY) + for row in results: + yield self.table(row[0]) + class BatchCheckout(object): """Context manager for using a batch from a database. diff --git a/google/cloud/spanner_v1/instance.py b/google/cloud/spanner_v1/instance.py index de464efe2e..db729d9527 100644 --- a/google/cloud/spanner_v1/instance.py +++ b/google/cloud/spanner_v1/instance.py @@ -361,7 +361,7 @@ def database(self, database_id, ddl_statements=(), pool=None, logger=None): """Factory to create a database within this instance. :type database_id: str - :param database_id: The ID of the instance. + :param database_id: The ID of the database. :type ddl_statements: list of string :param ddl_statements: (Optional) DDL statements, excluding the diff --git a/google/cloud/spanner_v1/table.py b/google/cloud/spanner_v1/table.py new file mode 100644 index 0000000000..4a31446509 --- /dev/null +++ b/google/cloud/spanner_v1/table.py @@ -0,0 +1,126 @@ +# Copyright 2021 Google LLC +# +# 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 Table.""" + +from google.cloud.exceptions import NotFound + +from google.cloud.spanner_v1.types import ( + Type, + TypeCode, +) + + +_EXISTS_TEMPLATE = """ +SELECT EXISTS( + SELECT TABLE_NAME + FROM INFORMATION_SCHEMA.TABLES + WHERE TABLE_NAME = @table_id +) +""" +_GET_SCHEMA_TEMPLATE = "SELECT * FROM {} LIMIT 0" + + +class Table(object): + """Representation of a Cloud Spanner Table. + + :type table_id: str + :param table_id: The ID of the table. + + :type database: :class:`~google.cloud.spanner_v1.database.Database` + :param database: The database that owns the table. + """ + + def __init__(self, table_id, database): + self._table_id = table_id + self._database = database + + # Calculated properties. + self._schema = None + + @property + def table_id(self): + """The ID of the table used in SQL. + + :rtype: str + :returns: The table ID. + """ + return self._table_id + + def exists(self): + """Test whether this table exists. + + :rtype: bool + :returns: True if the table exists, else false. + """ + with self._database.snapshot() as snapshot: + return self._exists(snapshot) + + def _exists(self, snapshot): + """Query to check that the table exists. + + :type snapshot: :class:`~google.cloud.spanner_v1.snapshot.Snapshot` + :param snapshot: snapshot to use for database queries + + :rtype: bool + :returns: True if the table exists, else false. + """ + results = snapshot.execute_sql( + _EXISTS_TEMPLATE, + params={"table_id": self.table_id}, + param_types={"table_id": Type(code=TypeCode.STRING)}, + ) + return next(iter(results))[0] + + @property + def schema(self): + """The schema of this table. + + :rtype: list of :class:`~google.cloud.spanner_v1.types.StructType.Field` + :returns: The table schema. + """ + if self._schema is None: + with self._database.snapshot() as snapshot: + self._schema = self._get_schema(snapshot) + return self._schema + + def _get_schema(self, snapshot): + """Get the schema of this table. + + :type snapshot: :class:`~google.cloud.spanner_v1.snapshot.Snapshot` + :param snapshot: snapshot to use for database queries + + :rtype: list of :class:`~google.cloud.spanner_v1.types.StructType.Field` + :returns: The table schema. + """ + query = _GET_SCHEMA_TEMPLATE.format(self.table_id) + results = snapshot.execute_sql(query) + # Start iterating to force the schema to download. + try: + next(iter(results)) + except StopIteration: + pass + return list(results.fields) + + def reload(self): + """Reload this table. + + Refresh any configured schema into :attr:`schema`. + + :raises NotFound: if the table does not exist + """ + with self._database.snapshot() as snapshot: + if not self._exists(snapshot): + raise NotFound("table '{}' does not exist".format(self.table_id)) + self._schema = self._get_schema(snapshot) diff --git a/tests/system/test_system.py b/tests/system/test_system.py index 6d337e96fb..575f79746e 100644 --- a/tests/system/test_system.py +++ b/tests/system/test_system.py @@ -42,6 +42,7 @@ from google.cloud.spanner_v1 import KeySet from google.cloud.spanner_v1.instance import Backup from google.cloud.spanner_v1.instance import Instance +from google.cloud.spanner_v1.table import Table from test_utils.retry import RetryErrors from test_utils.retry import RetryInstanceState @@ -590,6 +591,65 @@ def _unit_of_work(transaction, name): self.assertEqual(len(rows), 2) +class TestTableAPI(unittest.TestCase, _TestData): + DATABASE_NAME = "test_database" + unique_resource_id("_") + + @classmethod + def setUpClass(cls): + pool = BurstyPool(labels={"testcase": "database_api"}) + ddl_statements = EMULATOR_DDL_STATEMENTS if USE_EMULATOR else DDL_STATEMENTS + cls._db = Config.INSTANCE.database( + cls.DATABASE_NAME, ddl_statements=ddl_statements, pool=pool + ) + operation = cls._db.create() + operation.result(30) # raises on failure / timeout. + + @classmethod + def tearDownClass(cls): + cls._db.drop() + + def test_exists(self): + table = Table("all_types", self._db) + self.assertTrue(table.exists()) + + def test_exists_not_found(self): + table = Table("table_does_not_exist", self._db) + self.assertFalse(table.exists()) + + def test_list_tables(self): + tables = self._db.list_tables() + table_ids = set(table.table_id for table in tables) + self.assertIn("contacts", table_ids) + self.assertIn("contact_phones", table_ids) + self.assertIn("all_types", table_ids) + + def test_list_tables_reload(self): + tables = self._db.list_tables() + for table in tables: + self.assertTrue(table.exists()) + schema = table.schema + self.assertIsInstance(schema, list) + + def test_reload_not_found(self): + table = Table("table_does_not_exist", self._db) + with self.assertRaises(exceptions.NotFound): + table.reload() + + def test_schema(self): + table = Table("all_types", self._db) + schema = table.schema + names_and_types = set((field.name, field.type_.code) for field in schema) + self.assertIn(("pkey", TypeCode.INT64), names_and_types) + self.assertIn(("int_value", TypeCode.INT64), names_and_types) + self.assertIn(("int_array", TypeCode.ARRAY), names_and_types) + self.assertIn(("bool_value", TypeCode.BOOL), names_and_types) + self.assertIn(("bytes_value", TypeCode.BYTES), names_and_types) + self.assertIn(("date_value", TypeCode.DATE), names_and_types) + self.assertIn(("float_value", TypeCode.FLOAT64), names_and_types) + self.assertIn(("string_value", TypeCode.STRING), names_and_types) + self.assertIn(("timestamp_value", TypeCode.TIMESTAMP), names_and_types) + + @unittest.skipIf(USE_EMULATOR, "Skipping backup tests") @unittest.skipIf(SKIP_BACKUP_TESTS, "Skipping backup tests") class TestBackupAPI(unittest.TestCase, _TestData): diff --git a/tests/unit/test_database.py b/tests/unit/test_database.py index 4a7d18e67b..148bb79b0e 100644 --- a/tests/unit/test_database.py +++ b/tests/unit/test_database.py @@ -1293,6 +1293,26 @@ def test_list_database_operations_explicit_filter(self): filter_=expected_filter_, page_size=page_size ) + def test_table_factory_defaults(self): + from google.cloud.spanner_v1.table import Table + + client = _Client() + instance = _Instance(self.INSTANCE_NAME, client=client) + pool = _Pool() + database = self._make_one(self.DATABASE_ID, instance, pool=pool) + my_table = database.table("my_table") + self.assertIsInstance(my_table, Table) + self.assertIs(my_table._database, database) + self.assertEqual(my_table.table_id, "my_table") + + def test_list_tables(self): + client = _Client() + instance = _Instance(self.INSTANCE_NAME, client=client) + pool = _Pool() + database = self._make_one(self.DATABASE_ID, instance, pool=pool) + tables = database.list_tables() + self.assertIsNotNone(tables) + class TestBatchCheckout(_BaseTest): def _get_target_class(self): diff --git a/tests/unit/test_table.py b/tests/unit/test_table.py new file mode 100644 index 0000000000..0a49a9b225 --- /dev/null +++ b/tests/unit/test_table.py @@ -0,0 +1,124 @@ +# Copyright 2021 Google LLC +# +# 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 + +from google.cloud.exceptions import NotFound +import mock + +from google.cloud.spanner_v1.types import ( + StructType, + Type, + TypeCode, +) + + +class _BaseTest(unittest.TestCase): + TABLE_ID = "test_table" + + def _make_one(self, *args, **kwargs): + return self._get_target_class()(*args, **kwargs) + + +class TestTable(_BaseTest): + def _get_target_class(self): + from google.cloud.spanner_v1.table import Table + + return Table + + def test_ctor(self): + from google.cloud.spanner_v1.database import Database + + db = mock.create_autospec(Database, instance=True) + table = self._make_one(self.TABLE_ID, db) + self.assertEqual(table.table_id, self.TABLE_ID) + + def test_exists_executes_query(self): + from google.cloud.spanner_v1.database import Database, SnapshotCheckout + from google.cloud.spanner_v1.snapshot import Snapshot + from google.cloud.spanner_v1.table import _EXISTS_TEMPLATE + + db = mock.create_autospec(Database, instance=True) + checkout = mock.create_autospec(SnapshotCheckout, instance=True) + snapshot = mock.create_autospec(Snapshot, instance=True) + db.snapshot.return_value = checkout + checkout.__enter__.return_value = snapshot + snapshot.execute_sql.return_value = [[False]] + table = self._make_one(self.TABLE_ID, db) + exists = table.exists() + self.assertFalse(exists) + snapshot.execute_sql.assert_called_with( + _EXISTS_TEMPLATE, + params={"table_id": self.TABLE_ID}, + param_types={"table_id": Type(code=TypeCode.STRING)}, + ) + + def test_schema_executes_query(self): + from google.cloud.spanner_v1.database import Database, SnapshotCheckout + from google.cloud.spanner_v1.snapshot import Snapshot + from google.cloud.spanner_v1.table import _GET_SCHEMA_TEMPLATE + + db = mock.create_autospec(Database, instance=True) + checkout = mock.create_autospec(SnapshotCheckout, instance=True) + snapshot = mock.create_autospec(Snapshot, instance=True) + db.snapshot.return_value = checkout + checkout.__enter__.return_value = snapshot + table = self._make_one(self.TABLE_ID, db) + schema = table.schema + self.assertIsInstance(schema, list) + expected_query = _GET_SCHEMA_TEMPLATE.format(self.TABLE_ID) + snapshot.execute_sql.assert_called_with(expected_query) + + def test_schema_returns_cache(self): + from google.cloud.spanner_v1.database import Database + + db = mock.create_autospec(Database, instance=True) + table = self._make_one(self.TABLE_ID, db) + table._schema = [StructType.Field(name="col1")] + schema = table.schema + self.assertEqual(schema, [StructType.Field(name="col1")]) + + def test_reload_raises_notfound(self): + from google.cloud.spanner_v1.database import Database, SnapshotCheckout + from google.cloud.spanner_v1.snapshot import Snapshot + + db = mock.create_autospec(Database, instance=True) + checkout = mock.create_autospec(SnapshotCheckout, instance=True) + snapshot = mock.create_autospec(Snapshot, instance=True) + db.snapshot.return_value = checkout + checkout.__enter__.return_value = snapshot + snapshot.execute_sql.return_value = [[False]] + table = self._make_one(self.TABLE_ID, db) + with self.assertRaises(NotFound): + table.reload() + + def test_reload_executes_queries(self): + from google.cloud.spanner_v1.database import Database, SnapshotCheckout + from google.cloud.spanner_v1.snapshot import Snapshot + from google.cloud.spanner_v1.streamed import StreamedResultSet + + db = mock.create_autospec(Database, instance=True) + checkout = mock.create_autospec(SnapshotCheckout, instance=True) + snapshot = mock.create_autospec(Snapshot, instance=True) + results = mock.create_autospec(StreamedResultSet, instance=True) + db.snapshot.return_value = checkout + checkout.__enter__.return_value = snapshot + results.fields = [StructType.Field(name="col1")] + snapshot.execute_sql.side_effect = [ + [[True]], + results, + ] + table = self._make_one(self.TABLE_ID, db) + table.reload() + self.assertEqual(table.schema, [StructType.Field(name="col1")])