Skip to content

Commit 28bde8c

Browse files
tswastlarkee
andauthored
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 <[email protected]>
1 parent 1343656 commit 28bde8c

File tree

10 files changed

+428
-2
lines changed

10 files changed

+428
-2
lines changed

docs/api-reference.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ Most likely, you will be interacting almost exclusively with these:
1010
client-api
1111
instance-api
1212
database-api
13+
table-api
1314
session-api
1415
keyset-api
1516
snapshot-api

docs/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ Usage Documentation
1111
client-usage
1212
instance-usage
1313
database-usage
14+
table-usage
1415
batch-usage
1516
snapshot-usage
1617
transaction-usage

docs/table-api.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
Table API
2+
=========
3+
4+
.. automodule:: google.cloud.spanner_v1.table
5+
:members:
6+
:show-inheritance:

docs/table-usage.rst

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
Table Admin
2+
===========
3+
4+
After creating an :class:`~google.cloud.spanner_v1.database.Database`, you can
5+
interact with individual tables for that instance.
6+
7+
8+
List Tables
9+
-----------
10+
11+
To iterate over all existing tables for an database, use its
12+
:meth:`~google.cloud.spanner_v1.database.Database.list_tables` method:
13+
14+
.. code:: python
15+
16+
for table in database.list_tables():
17+
# `table` is a `Table` object.
18+
19+
This method yields :class:`~google.cloud.spanner_v1.table.Table` objects.
20+
21+
22+
Table Factory
23+
-------------
24+
25+
A :class:`~google.cloud.spanner_v1.table.Table` object can be created with the
26+
:meth:`~google.cloud.spanner_v1.database.Database.table` factory method:
27+
28+
.. code:: python
29+
30+
table = database.table("my_table_id")
31+
if table.exists():
32+
print("Table with ID 'my_table' exists.")
33+
else:
34+
print("Table with ID 'my_table' does not exist."
35+
36+
37+
Getting the Table Schema
38+
------------------------
39+
40+
Use the :attr:`~google.cloud.spanner_v1.table.Table.schema` property to inspect
41+
the columns of a table as a list of
42+
:class:`~google.cloud.spanner_v1.types.StructType.Field` objects.
43+
44+
.. code:: python
45+
46+
for field in table.schema
47+
# `field` is a `Field` object.

google/cloud/spanner_v1/database.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,12 @@
4848
)
4949
from google.cloud.spanner_admin_database_v1 import CreateDatabaseRequest
5050
from google.cloud.spanner_admin_database_v1 import UpdateDatabaseDdlRequest
51-
from google.cloud.spanner_v1 import ExecuteSqlRequest
5251
from google.cloud.spanner_v1 import (
52+
ExecuteSqlRequest,
5353
TransactionSelector,
5454
TransactionOptions,
5555
)
56+
from google.cloud.spanner_v1.table import Table
5657

5758
# pylint: enable=ungrouped-imports
5859

@@ -68,6 +69,11 @@
6869

6970
_DATABASE_METADATA_FILTER = "name:{0}/operations/"
7071

72+
_LIST_TABLES_QUERY = """SELECT TABLE_NAME
73+
FROM INFORMATION_SCHEMA.TABLES
74+
WHERE SPANNER_STATE = 'COMMITTED'
75+
"""
76+
7177
DEFAULT_RETRY_BACKOFF = Retry(initial=0.02, maximum=32, multiplier=1.3)
7278

7379

@@ -649,6 +655,41 @@ def list_database_operations(self, filter_="", page_size=None):
649655
filter_=database_filter, page_size=page_size
650656
)
651657

658+
def table(self, table_id):
659+
"""Factory to create a table object within this database.
660+
661+
Note: This method does not create a table in Cloud Spanner, but it can
662+
be used to check if a table exists.
663+
664+
.. code-block:: python
665+
666+
my_table = database.table("my_table")
667+
if my_table.exists():
668+
print("Table with ID 'my_table' exists.")
669+
else:
670+
print("Table with ID 'my_table' does not exist.")
671+
672+
:type table_id: str
673+
:param table_id: The ID of the table.
674+
675+
:rtype: :class:`~google.cloud.spanner_v1.table.Table`
676+
:returns: a table owned by this database.
677+
"""
678+
return Table(table_id, self)
679+
680+
def list_tables(self):
681+
"""List tables within the database.
682+
683+
:type: Iterable
684+
:returns:
685+
Iterable of :class:`~google.cloud.spanner_v1.table.Table`
686+
resources within the current database.
687+
"""
688+
with self.snapshot() as snapshot:
689+
results = snapshot.execute_sql(_LIST_TABLES_QUERY)
690+
for row in results:
691+
yield self.table(row[0])
692+
652693

653694
class BatchCheckout(object):
654695
"""Context manager for using a batch from a database.

google/cloud/spanner_v1/instance.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -361,7 +361,7 @@ def database(self, database_id, ddl_statements=(), pool=None, logger=None):
361361
"""Factory to create a database within this instance.
362362
363363
:type database_id: str
364-
:param database_id: The ID of the instance.
364+
:param database_id: The ID of the database.
365365
366366
:type ddl_statements: list of string
367367
:param ddl_statements: (Optional) DDL statements, excluding the

google/cloud/spanner_v1/table.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
# Copyright 2021 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""User friendly container for Cloud Spanner Table."""
16+
17+
from google.cloud.exceptions import NotFound
18+
19+
from google.cloud.spanner_v1.types import (
20+
Type,
21+
TypeCode,
22+
)
23+
24+
25+
_EXISTS_TEMPLATE = """
26+
SELECT EXISTS(
27+
SELECT TABLE_NAME
28+
FROM INFORMATION_SCHEMA.TABLES
29+
WHERE TABLE_NAME = @table_id
30+
)
31+
"""
32+
_GET_SCHEMA_TEMPLATE = "SELECT * FROM {} LIMIT 0"
33+
34+
35+
class Table(object):
36+
"""Representation of a Cloud Spanner Table.
37+
38+
:type table_id: str
39+
:param table_id: The ID of the table.
40+
41+
:type database: :class:`~google.cloud.spanner_v1.database.Database`
42+
:param database: The database that owns the table.
43+
"""
44+
45+
def __init__(self, table_id, database):
46+
self._table_id = table_id
47+
self._database = database
48+
49+
# Calculated properties.
50+
self._schema = None
51+
52+
@property
53+
def table_id(self):
54+
"""The ID of the table used in SQL.
55+
56+
:rtype: str
57+
:returns: The table ID.
58+
"""
59+
return self._table_id
60+
61+
def exists(self):
62+
"""Test whether this table exists.
63+
64+
:rtype: bool
65+
:returns: True if the table exists, else false.
66+
"""
67+
with self._database.snapshot() as snapshot:
68+
return self._exists(snapshot)
69+
70+
def _exists(self, snapshot):
71+
"""Query to check that the table exists.
72+
73+
:type snapshot: :class:`~google.cloud.spanner_v1.snapshot.Snapshot`
74+
:param snapshot: snapshot to use for database queries
75+
76+
:rtype: bool
77+
:returns: True if the table exists, else false.
78+
"""
79+
results = snapshot.execute_sql(
80+
_EXISTS_TEMPLATE,
81+
params={"table_id": self.table_id},
82+
param_types={"table_id": Type(code=TypeCode.STRING)},
83+
)
84+
return next(iter(results))[0]
85+
86+
@property
87+
def schema(self):
88+
"""The schema of this table.
89+
90+
:rtype: list of :class:`~google.cloud.spanner_v1.types.StructType.Field`
91+
:returns: The table schema.
92+
"""
93+
if self._schema is None:
94+
with self._database.snapshot() as snapshot:
95+
self._schema = self._get_schema(snapshot)
96+
return self._schema
97+
98+
def _get_schema(self, snapshot):
99+
"""Get the schema of this table.
100+
101+
:type snapshot: :class:`~google.cloud.spanner_v1.snapshot.Snapshot`
102+
:param snapshot: snapshot to use for database queries
103+
104+
:rtype: list of :class:`~google.cloud.spanner_v1.types.StructType.Field`
105+
:returns: The table schema.
106+
"""
107+
query = _GET_SCHEMA_TEMPLATE.format(self.table_id)
108+
results = snapshot.execute_sql(query)
109+
# Start iterating to force the schema to download.
110+
try:
111+
next(iter(results))
112+
except StopIteration:
113+
pass
114+
return list(results.fields)
115+
116+
def reload(self):
117+
"""Reload this table.
118+
119+
Refresh any configured schema into :attr:`schema`.
120+
121+
:raises NotFound: if the table does not exist
122+
"""
123+
with self._database.snapshot() as snapshot:
124+
if not self._exists(snapshot):
125+
raise NotFound("table '{}' does not exist".format(self.table_id))
126+
self._schema = self._get_schema(snapshot)

tests/system/test_system.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
from google.cloud.spanner_v1 import KeySet
4343
from google.cloud.spanner_v1.instance import Backup
4444
from google.cloud.spanner_v1.instance import Instance
45+
from google.cloud.spanner_v1.table import Table
4546

4647
from test_utils.retry import RetryErrors
4748
from test_utils.retry import RetryInstanceState
@@ -590,6 +591,65 @@ def _unit_of_work(transaction, name):
590591
self.assertEqual(len(rows), 2)
591592

592593

594+
class TestTableAPI(unittest.TestCase, _TestData):
595+
DATABASE_NAME = "test_database" + unique_resource_id("_")
596+
597+
@classmethod
598+
def setUpClass(cls):
599+
pool = BurstyPool(labels={"testcase": "database_api"})
600+
ddl_statements = EMULATOR_DDL_STATEMENTS if USE_EMULATOR else DDL_STATEMENTS
601+
cls._db = Config.INSTANCE.database(
602+
cls.DATABASE_NAME, ddl_statements=ddl_statements, pool=pool
603+
)
604+
operation = cls._db.create()
605+
operation.result(30) # raises on failure / timeout.
606+
607+
@classmethod
608+
def tearDownClass(cls):
609+
cls._db.drop()
610+
611+
def test_exists(self):
612+
table = Table("all_types", self._db)
613+
self.assertTrue(table.exists())
614+
615+
def test_exists_not_found(self):
616+
table = Table("table_does_not_exist", self._db)
617+
self.assertFalse(table.exists())
618+
619+
def test_list_tables(self):
620+
tables = self._db.list_tables()
621+
table_ids = set(table.table_id for table in tables)
622+
self.assertIn("contacts", table_ids)
623+
self.assertIn("contact_phones", table_ids)
624+
self.assertIn("all_types", table_ids)
625+
626+
def test_list_tables_reload(self):
627+
tables = self._db.list_tables()
628+
for table in tables:
629+
self.assertTrue(table.exists())
630+
schema = table.schema
631+
self.assertIsInstance(schema, list)
632+
633+
def test_reload_not_found(self):
634+
table = Table("table_does_not_exist", self._db)
635+
with self.assertRaises(exceptions.NotFound):
636+
table.reload()
637+
638+
def test_schema(self):
639+
table = Table("all_types", self._db)
640+
schema = table.schema
641+
names_and_types = set((field.name, field.type_.code) for field in schema)
642+
self.assertIn(("pkey", TypeCode.INT64), names_and_types)
643+
self.assertIn(("int_value", TypeCode.INT64), names_and_types)
644+
self.assertIn(("int_array", TypeCode.ARRAY), names_and_types)
645+
self.assertIn(("bool_value", TypeCode.BOOL), names_and_types)
646+
self.assertIn(("bytes_value", TypeCode.BYTES), names_and_types)
647+
self.assertIn(("date_value", TypeCode.DATE), names_and_types)
648+
self.assertIn(("float_value", TypeCode.FLOAT64), names_and_types)
649+
self.assertIn(("string_value", TypeCode.STRING), names_and_types)
650+
self.assertIn(("timestamp_value", TypeCode.TIMESTAMP), names_and_types)
651+
652+
593653
@unittest.skipIf(USE_EMULATOR, "Skipping backup tests")
594654
@unittest.skipIf(SKIP_BACKUP_TESTS, "Skipping backup tests")
595655
class TestBackupAPI(unittest.TestCase, _TestData):

tests/unit/test_database.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1293,6 +1293,26 @@ def test_list_database_operations_explicit_filter(self):
12931293
filter_=expected_filter_, page_size=page_size
12941294
)
12951295

1296+
def test_table_factory_defaults(self):
1297+
from google.cloud.spanner_v1.table import Table
1298+
1299+
client = _Client()
1300+
instance = _Instance(self.INSTANCE_NAME, client=client)
1301+
pool = _Pool()
1302+
database = self._make_one(self.DATABASE_ID, instance, pool=pool)
1303+
my_table = database.table("my_table")
1304+
self.assertIsInstance(my_table, Table)
1305+
self.assertIs(my_table._database, database)
1306+
self.assertEqual(my_table.table_id, "my_table")
1307+
1308+
def test_list_tables(self):
1309+
client = _Client()
1310+
instance = _Instance(self.INSTANCE_NAME, client=client)
1311+
pool = _Pool()
1312+
database = self._make_one(self.DATABASE_ID, instance, pool=pool)
1313+
tables = database.list_tables()
1314+
self.assertIsNotNone(tables)
1315+
12961316

12971317
class TestBatchCheckout(_BaseTest):
12981318
def _get_target_class(self):

0 commit comments

Comments
 (0)