Repository: googleapis/python-spanner-django

Issue:
dbapi: cursor.fetchall() returns lists rather than tuples
I haven't dug into the details but it seems that other dbapis behave this way:
```
======================================================================
FAIL: test_database_queried (backends.base.test_base.ExecuteWrapperTests)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/tim/code/django/tests/backends/base/test_base.py", line 90, in test_database_queried
    self.assertEqual(list(seventeen), [(17,)])
AssertionError: Lists differ: [[17]] != [(17,)]

First differing element 0:
[17]
(17,)

- [[17]]
+ [(17,)]

======================================================================
FAIL: test_parameter_escaping (backends.tests.EscapingChecks)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/tim/code/django/tests/backends/tests.py", line 203, in test_parameter_escaping
    self.assertEqual(cursor.fetchall()[0], ('%', '%d'))
AssertionError: ['%%', '%d'] != ('%', '%d')

======================================================================
FAIL: test_parameter_escaping (backends.tests.EscapingChecksDebug)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/tim/code/django/tests/backends/tests.py", line 203, in test_parameter_escaping
    self.assertEqual(cursor.fetchall()[0], ('%', '%d'))
AssertionError: ['%%', '%d'] != ('%', '%d')

======================================================================
FAIL: test_unicode_fetches (backends.tests.BackendTestCase)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/tim/code/django/tests/backends/tests.py", line 310, in test_unicode_fetches
    self.assertEqual(cursor.fetchone(), ('Clark', 'Kent'))
AssertionError: ['Clark', 'Kent'] != ('Clark', 'Kent')
```


Discussion:
Cloud Spanner's results are returned as lists, let me work on a PR to manually convert them to tuples. Thank you for the report.

Resolving patch:
diff --git a/spanner/dbapi/utils.py b/spanner/dbapi/utils.py
--- a/spanner/dbapi/utils.py
+++ b/spanner/dbapi/utils.py
@@ -16,6 +16,8 @@ class PeekIterator(object):
     PeekIterator peeks at the first element out of an iterator
     for the sake of operations like auto-population of fields on reading
     the first element.
+    If next's result is an instance of list, it'll be converted into a tuple
+    to conform with DBAPI v2's sequence expectations.
     """
     def __init__(self, source):
         itr_src = iter(source)
@@ -42,7 +44,7 @@ def __next__(self):
             self.__index += 1
             return self.__next__()
         else:
-            return head
+            return tuple(head) if isinstance(head, list) else head
 
     def __iter__(self):
         return self


Test patch:
diff --git a/tests/spanner/dbapi/test_utils.py b/tests/spanner/dbapi/test_utils.py
new file mode 100644
--- /dev/null
+++ b/tests/spanner/dbapi/test_utils.py
@@ -0,0 +1,36 @@
+# Copyright 2020 Google LLC
+#
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+from unittest import TestCase
+
+from spanner.dbapi.utils import PeekIterator
+
+
+class UtilsTests(TestCase):
+    def test_peekIterator_list_rows_converted_to_tuples(self):
+        # Cloud Spanner returns results in lists e.g. [result].
+        # PeekIterator is used by BaseCursor in its fetch* methods.
+        # This test ensures that anything passed into PeekIterator
+        # will be returned as a tuple.
+        pit = PeekIterator([['a'], ['b'], ['c'], ['d'], ['e']])
+        got = list(pit)
+        want = [('a',), ('b',), ('c',), ('d',), ('e',)]
+        self.assertEqual(got, want, 'Rows of type list must be returned as tuples')
+
+        seventeen = PeekIterator([[17]])
+        self.assertEqual(list(seventeen), [(17,)])
+
+        pit = PeekIterator([['%', '%d']])
+        self.assertEqual(next(pit), ('%', '%d',))
+
+        pit = PeekIterator([('Clark', 'Kent')])
+        self.assertEqual(next(pit), ('Clark', 'Kent',))
+
+    def test_peekIterator_nonlist_rows_unconverted(self):
+        pi = PeekIterator(['a', 'b', 'c', 'd', 'e'])
+        got = list(pi)
+        want = ['a', 'b', 'c', 'd', 'e']
+        self.assertEqual(got, want, 'Values should be returned unchanged')