Skip to content

Commit 6028f88

Browse files
author
Gurov Ilya
authored
feat: cursor must detect if the parent connection is closed (#463)
1 parent acd9209 commit 6028f88

File tree

4 files changed

+158
-35
lines changed

4 files changed

+158
-35
lines changed

spanner_dbapi/connection.py

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -17,20 +17,25 @@
1717
class Connection:
1818
def __init__(self, db_handle):
1919
self._dbhandle = db_handle
20-
self._closed = False
2120
self._ddl_statements = []
2221

22+
self.is_closed = False
23+
2324
def cursor(self):
24-
self.__raise_if_already_closed()
25+
self._raise_if_closed()
2526

2627
return Cursor(self)
2728

28-
def __raise_if_already_closed(self):
29-
"""
30-
Raise an exception if attempting to use an already closed connection.
29+
def _raise_if_closed(self):
30+
"""Raise an exception if this connection is closed.
31+
32+
Helper to check the connection state before
33+
running a SQL/DDL/DML query.
34+
35+
:raises: :class:`InterfaceError` if this connection is closed.
3136
"""
32-
if self._closed:
33-
raise InterfaceError("connection already closed")
37+
if self.is_closed:
38+
raise InterfaceError("connection is already closed")
3439

3540
def __handle_update_ddl(self, ddl_statements):
3641
"""
@@ -41,24 +46,24 @@ def __handle_update_ddl(self, ddl_statements):
4146
Returns:
4247
google.api_core.operation.Operation.result()
4348
"""
44-
self.__raise_if_already_closed()
49+
self._raise_if_closed()
4550
# Synchronously wait on the operation's completion.
4651
return self._dbhandle.update_ddl(ddl_statements).result()
4752

4853
def read_snapshot(self):
49-
self.__raise_if_already_closed()
54+
self._raise_if_closed()
5055
return self._dbhandle.snapshot()
5156

5257
def in_transaction(self, fn, *args, **kwargs):
53-
self.__raise_if_already_closed()
58+
self._raise_if_closed()
5459
return self._dbhandle.run_in_transaction(fn, *args, **kwargs)
5560

5661
def append_ddl_statement(self, ddl_statement):
57-
self.__raise_if_already_closed()
62+
self._raise_if_closed()
5863
self._ddl_statements.append(ddl_statement)
5964

6065
def run_prior_DDL_statements(self):
61-
self.__raise_if_already_closed()
66+
self._raise_if_closed()
6267

6368
if not self._ddl_statements:
6469
return
@@ -113,17 +118,21 @@ def get_table_column_schema(self, table_name):
113118
return column_details
114119

115120
def close(self):
121+
"""Close this connection.
122+
123+
The connection will be unusable from this point forward.
124+
"""
116125
self.rollback()
117126
self.__dbhandle = None
118-
self._closed = True
127+
self.is_closed = True
119128

120129
def commit(self):
121-
self.__raise_if_already_closed()
130+
self._raise_if_closed()
122131

123132
self.run_prior_DDL_statements()
124133

125134
def rollback(self):
126-
self.__raise_if_already_closed()
135+
self._raise_if_closed()
127136

128137
# TODO: to be added.
129138

spanner_dbapi/cursor.py

Lines changed: 48 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,14 @@
44
# license that can be found in the LICENSE file or at
55
# https://developers.google.com/open-source/licenses/bsd
66

7-
import google.api_core.exceptions as grpc_exceptions
7+
"""Database cursor API."""
8+
9+
from google.api_core.exceptions import (
10+
AlreadyExists,
11+
FailedPrecondition,
12+
InternalServerError,
13+
InvalidArgument,
14+
)
815
from google.cloud.spanner_v1 import param_types
916

1017
from .exceptions import (
@@ -47,16 +54,21 @@
4754

4855

4956
class Cursor:
57+
"""
58+
Database cursor to manage the context of a fetch operation.
59+
60+
:type connection: :class:`spanner_dbapi.connection.Connection`
61+
:param connection: Parent connection object for this Cursor.
62+
"""
63+
5064
def __init__(self, connection):
5165
self._itr = None
5266
self._res = None
5367
self._row_count = _UNSET_COUNT
5468
self._connection = connection
55-
self._closed = False
69+
self._is_closed = False
5670

57-
# arraysize is a readable and writable property mandated
58-
# by PEP-0249 https://www.python.org/dev/peps/pep-0249/#arraysize
59-
# It determines the results of .fetchmany
71+
# the number of rows to fetch at a time with fetchmany()
6072
self.arraysize = 1
6173

6274
def execute(self, sql, args=None):
@@ -69,7 +81,7 @@ def execute(self, sql, args=None):
6981
Returns:
7082
None
7183
"""
72-
self._raise_if_already_closed()
84+
self._raise_if_closed()
7385

7486
if not self._connection:
7587
raise ProgrammingError("Cursor is not connected to the database")
@@ -93,14 +105,11 @@ def execute(self, sql, args=None):
93105
self.__handle_insert(sql, args or None)
94106
else:
95107
self.__handle_update(sql, args or None)
96-
except (
97-
grpc_exceptions.AlreadyExists,
98-
grpc_exceptions.FailedPrecondition,
99-
) as e:
108+
except (AlreadyExists, FailedPrecondition) as e:
100109
raise IntegrityError(e.details if hasattr(e, "details") else e)
101-
except grpc_exceptions.InvalidArgument as e:
110+
except InvalidArgument as e:
102111
raise ProgrammingError(e.details if hasattr(e, "details") else e)
103-
except grpc_exceptions.InternalServerError as e:
112+
except InternalServerError as e:
104113
raise OperationalError(e.details if hasattr(e, "details") else e)
105114

106115
def __handle_update(self, sql, params):
@@ -228,16 +237,35 @@ def description(self):
228237
def rowcount(self):
229238
return self._row_count
230239

231-
def _raise_if_already_closed(self):
240+
@property
241+
def is_closed(self):
242+
"""The cursor close indicator.
243+
244+
:rtype: :class:`bool`
245+
:returns: True if this cursor or it's parent connection is closed, False
246+
otherwise.
232247
"""
233-
Raise an exception if attempting to use an already closed connection.
248+
return self._is_closed or self._connection.is_closed
249+
250+
def _raise_if_closed(self):
251+
"""Raise an exception if this cursor is closed.
252+
253+
Helper to check this cursor's state before running a
254+
SQL/DDL/DML query. If the parent connection is
255+
already closed it also raises an error.
256+
257+
:raises: :class:`InterfaceError` if this cursor is closed.
234258
"""
235-
if self._closed:
236-
raise InterfaceError("cursor already closed")
259+
if self.is_closed:
260+
raise InterfaceError("cursor is already closed")
237261

238262
def close(self):
263+
"""Close this cursor.
264+
265+
The cursor will be unusable from this point forward.
266+
"""
239267
self.__clear()
240-
self._closed = True
268+
self._is_closed = True
241269

242270
def executemany(self, operation, seq_of_params):
243271
if not self._connection:
@@ -257,15 +285,15 @@ def __iter__(self):
257285
return self._itr
258286

259287
def fetchone(self):
260-
self._raise_if_already_closed()
288+
self._raise_if_closed()
261289

262290
try:
263291
return next(self)
264292
except StopIteration:
265293
return None
266294

267295
def fetchall(self):
268-
self._raise_if_already_closed()
296+
self._raise_if_closed()
269297

270298
return list(self.__iter__())
271299

@@ -282,7 +310,7 @@ def fetchmany(self, size=None):
282310
Error if the previous call to .execute*() did not produce any result set
283311
or if no call was issued yet.
284312
"""
285-
self._raise_if_already_closed()
313+
self._raise_if_closed()
286314

287315
if size is None:
288316
size = self.arraysize
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Copyright 2020 Google LLC
2+
#
3+
# Use of this source code is governed by a BSD-style
4+
# license that can be found in the LICENSE file or at
5+
# https://developers.google.com/open-source/licenses/bsd
6+
7+
"""Connection() class unit tests."""
8+
9+
import unittest
10+
from unittest import mock
11+
12+
from spanner_dbapi import connect, InterfaceError
13+
14+
15+
class TestConnection(unittest.TestCase):
16+
def test_close(self):
17+
with mock.patch(
18+
"google.cloud.spanner_v1.instance.Instance.exists",
19+
return_value=True,
20+
):
21+
with mock.patch(
22+
"google.cloud.spanner_v1.database.Database.exists",
23+
return_value=True,
24+
):
25+
connection = connect("test-instance", "test-database")
26+
27+
self.assertFalse(connection.is_closed)
28+
connection.close()
29+
self.assertTrue(connection.is_closed)
30+
31+
with self.assertRaises(InterfaceError):
32+
connection.cursor()

tests/spanner_dbapi/test_cursor.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# Copyright 2020 Google LLC
2+
#
3+
# Use of this source code is governed by a BSD-style
4+
# license that can be found in the LICENSE file or at
5+
# https://developers.google.com/open-source/licenses/bsd
6+
7+
"""Cursor() class unit tests."""
8+
9+
import unittest
10+
from unittest import mock
11+
12+
from spanner_dbapi import connect, InterfaceError
13+
14+
15+
class TestCursor(unittest.TestCase):
16+
def test_close(self):
17+
with mock.patch(
18+
"google.cloud.spanner_v1.instance.Instance.exists",
19+
return_value=True,
20+
):
21+
with mock.patch(
22+
"google.cloud.spanner_v1.database.Database.exists",
23+
return_value=True,
24+
):
25+
connection = connect("test-instance", "test-database")
26+
27+
cursor = connection.cursor()
28+
self.assertFalse(cursor.is_closed)
29+
30+
cursor.close()
31+
32+
self.assertTrue(cursor.is_closed)
33+
with self.assertRaises(InterfaceError):
34+
cursor.execute("SELECT * FROM database")
35+
36+
def test_connection_closed(self):
37+
with mock.patch(
38+
"google.cloud.spanner_v1.instance.Instance.exists",
39+
return_value=True,
40+
):
41+
with mock.patch(
42+
"google.cloud.spanner_v1.database.Database.exists",
43+
return_value=True,
44+
):
45+
connection = connect("test-instance", "test-database")
46+
47+
cursor = connection.cursor()
48+
self.assertFalse(cursor.is_closed)
49+
50+
connection.close()
51+
52+
self.assertTrue(cursor.is_closed)
53+
with self.assertRaises(InterfaceError):
54+
cursor.execute("SELECT * FROM database")

0 commit comments

Comments
 (0)