Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add Database.list_tables method #219

Merged
merged 15 commits into from Mar 1, 2021
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/api-reference.rst
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/index.rst
Expand Up @@ -11,6 +11,7 @@ Usage Documentation
client-usage
instance-usage
database-usage
table-usage
batch-usage
snapshot-usage
transaction-usage
Expand Down
6 changes: 6 additions & 0 deletions docs/table-api.rst
@@ -0,0 +1,6 @@
Table API
=========

.. automodule:: google.cloud.spanner_v1.table
:members:
:show-inheritance:
46 changes: 46 additions & 0 deletions docs/table-usage.rst
@@ -0,0 +1,46 @@
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 my_table.exists():
larkee marked this conversation as resolved.
Show resolved Hide resolved
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.
43 changes: 42 additions & 1 deletion google/cloud/spanner_v1/database.py
Expand Up @@ -47,11 +47,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

Expand All @@ -67,6 +68,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)


Expand Down Expand Up @@ -596,6 +602,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.
Expand Down
2 changes: 1 addition & 1 deletion google/cloud/spanner_v1/instance.py
Expand Up @@ -361,7 +361,7 @@ def database(self, database_id, ddl_statements=(), pool=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
Expand Down
126 changes: 126 additions & 0 deletions 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.
"""
larkee marked this conversation as resolved.
Show resolved Hide resolved

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)
60 changes: 60 additions & 0 deletions tests/system/test_system.py
Expand Up @@ -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
Expand Down Expand Up @@ -478,6 +479,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):
Expand Down
20 changes: 20 additions & 0 deletions tests/unit/test_database.py
Expand Up @@ -1243,6 +1243,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):
Expand Down