Skip to content
Permalink
Browse files
feat: add Database.list_tables method (#219)
* feat: add `Database.list_tables` method

* update docs, add tests

* remove numeric from get_schema test

The NUMERIC column is not included in the emulator system tests.

* add unit tests

* add docs for table api and usage

* fix link to Field class

* typo in table constructor docs

* add reload and exists methods

* feat: add table method to database

* update usage docs to use factory method

* address warning in GitHub UI for sphinx header

* Update docs/table-usage.rst

Co-authored-by: larkee <31196561+larkee@users.noreply.github.com>
  • Loading branch information
tswast and larkee committed Mar 1, 2021
1 parent 1343656 commit 28bde8c18fd76b25ec1b64c44db7c1600255256f
@@ -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
@@ -11,6 +11,7 @@ Usage Documentation
client-usage
instance-usage
database-usage
table-usage
batch-usage
snapshot-usage
transaction-usage
@@ -0,0 +1,6 @@
Table API
=========

.. automodule:: google.cloud.spanner_v1.table
:members:
:show-inheritance:
@@ -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.
@@ -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.
@@ -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
@@ -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)
@@ -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):
@@ -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):

0 comments on commit 28bde8c

Please sign in to comment.