Repository: googleapis/python-spanner-django

Issue:
dbapi: duplicate table raises GoogleAPICallError rather than DatabaseError
(Pull the latest changes from the spanner-2.2.x branch of the Django fork as I've edited the SQL in this test to work with Spanner.)

`GoogleAPICallError` currently happens but `DatabaseError` is expected:

```
======================================================================
ERROR: test_duplicate_table_error (backends.tests.BackendTestCase)
Creating an existing table returns a DatabaseError
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/tim/code/django/django/test/testcases.py", line 274, in __call__
    self._post_teardown()
  File "/home/tim/code/django/django/test/testcases.py", line 1009, in _post_teardown
    self._fixture_teardown()
  File "/home/tim/code/django/django/test/testcases.py", line 1044, in _fixture_teardown
    inhibit_post_migrate=inhibit_post_migrate)
  File "/home/tim/code/django/django/core/management/__init__.py", line 148, in call_command
    return command.execute(*args, **defaults)
  File "/home/tim/code/django/django/core/management/base.py", line 364, in execute
    output = self.handle(*args, **options)
  File "/home/tim/code/django/django/core/management/commands/flush.py", line 49, in handle
    allow_cascade=allow_cascade)
  File "/home/tim/code/django/django/core/management/sql.py", line 13, in sql_flush
    tables = connection.introspection.django_table_names(only_existing=True, include_views=False)
  File "/home/tim/code/django/django/db/backends/base/introspection.py", line 78, in django_table_names
    existing_tables = set(self.table_names(include_views=include_views))
  File "/home/tim/code/django/django/db/backends/base/introspection.py", line 47, in table_names
    return get_names(cursor)
  File "/home/tim/code/django/django/db/backends/base/introspection.py", line 43, in get_names
    return sorted(ti.name for ti in self.get_table_list(cursor)
  File "/home/tim/code/spanner-django/spanner/django/introspection.py", line 27, in get_table_list
    return [TableInfo(row[0], 't') for row in cursor.list_tables()]
  File "/home/tim/code/spanner-django/spanner/dbapi/autocommit_on_cursor.py", line 272, in list_tables
    return self.__db_handle.list_tables()
  File "/home/tim/code/spanner-django/spanner/dbapi/autocommit_on_connection.py", line 99, in list_tables
    """)
  File "/home/tim/code/spanner-django/spanner/dbapi/autocommit_on_connection.py", line 104, in run_sql_in_snapshot
    self.run_prior_DDL_statements()
  File "/home/tim/code/spanner-django/spanner/dbapi/autocommit_on_connection.py", line 89, in run_prior_DDL_statements
    return self.__handle_update_ddl(ddl_statements)
  File "/home/tim/code/spanner-django/spanner/dbapi/autocommit_on_connection.py", line 64, in __handle_update_ddl
    return self.__dbhandle.update_ddl(ddl_statements).result()
  File "/home/tim/.virtualenvs/django37/lib/python3.7/site-packages/google/api_core/future/polling.py", line 127, in result
    raise self._exception
google.api_core.exceptions.GoogleAPICallError: None Duplicate name in schema: backends_article.

======================================================================
FAIL: test_duplicate_table_error (backends.tests.BackendTestCase)
Creating an existing table returns a DatabaseError
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/tim/code/django/tests/backends/tests.py", line 350, in test_duplicate_table_error
    cursor.execute(query)
AssertionError: DatabaseError not raised
```


Discussion:
Thank you for the report @timgraham! Oddly, Cloud Spanner sends back a gRPC status code of `None`  aka `0` which is interpreted as success :(
<img width="583" alt="Screen Shot 2020-03-15 at 6 46 57 PM" src="https://user-images.githubusercontent.com/4898263/76716497-a3d2b980-66ed-11ea-987b-bdd2c9781076.png">

I'll file that bug and send it to the Cloud Spanner engineering team. However, here I'll add error transformation in connection.run_prior_ddl and send a PR shortly.
I have filed https://github.com/googleapis/python-spanner/issues/38 for a follow-up and in the meantime I shall send a PR patching up.
@timgraham I just applied this patch below
```diff
diff --git a/spanner/dbapi/base_connection.py b/spanner/dbapi/base_connection.py
index a5fde9f..a51cd20 100644
--- a/spanner/dbapi/base_connection.py
+++ b/spanner/dbapi/base_connection.py
@@ -4,7 +4,9 @@
 # license that can be found in the LICENSE file or at
 # https://developers.google.com/open-source/licenses/bsd
 
-from .exceptions import InterfaceError
+import google.api_core.exceptions as grpc_exceptions
+
+from .exceptions import DatabaseError, InterfaceError
 from .utils import get_table_column_schema as get_table_column_schema_impl
 
 
@@ -32,7 +34,14 @@ class BaseConnection:
         """
         self._raise_if_already_closed()
         # Synchronously wait on the operation's completion.
-        return self._dbhandle.update_ddl(ddl_statements).result()
+        try:
+            return self._dbhandle.update_ddl(ddl_statements).result()
+        except grpc_exceptions.InvalidArgument as e:
+            raise ProgrammingError(e.details if hasattr(e, 'details') else e)
+        except Exception as e:
+            # TODO: Once https://github.com/googleapis/python-spanner/issues/38
+            # is fixed, add a special exception handling clause here.
+            raise DatabaseError(e.details if hasattr(e, 'details') else e)
 
     def read_snapshot(self):
         self._raise_if_already_closed()
```

which produces

```shell
  File "/Library/Frameworks/Python.framework/Versions/3.7/lib/python3.7/site-packages/spanner/dbapi/base_connection.py", line 44, in __handle_update_ddl
    raise DatabaseError(e.details if hasattr(e, 'details') else e)
spanner.dbapi.exceptions.DatabaseError: None Duplicate name in schema: backends_article.

======================================================================
FAIL: test_duplicate_table_error (backends.tests.BackendTestCase)
Creating an existing table returns a DatabaseError
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/emmanuelodeke/Desktop/django-spanner/django_tests/django/tests/backends/tests.py", line 351, in test_duplicate_table_error
    cursor.execute(query)
AssertionError: DatabaseError not raised

----------------------------------------------------------------------
Ran 1 test in 2.846s

FAILED (failures=1, errors=1)
```


 but it seems like we might have to do the same trick as we did in https://github.com/orijtech/django-spanner/pull/352 with features.DatabaseFeatures.<ATTRIBUTE>?
No, the issue is something else. It looks like `spanner.dbapi.exceptions.DatabaseError` isn't reraised as Django's `DatabaseError` as expected (typically done by [DatabaseErrorWrapper](https://github.com/django/django/blob/fc0fa72ff4cdbf5861a366e31cb8bbacd44da22d/django/db/utils.py#L52)). I'll take a look if you're stuck.
Gotcha, please do, all yours :) and thank you!
I think the problem has to do with the SQL batching / deferring that you implemented. The `CREATE TABLE` from the test isn't run when the test executes `cursor.execute(query)` (as expected). Instead, it's not sent to the database until the test's "tear down" stage which is why we see the exception at that point:
```
======================================================================
ERROR: test_duplicate_table_error (backends.tests.BackendTestCase)
Creating an existing table returns a DatabaseError
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/tim/code/spanner-django/spanner/dbapi/base_connection.py", line 39, in __handle_update_ddl
    res = self._dbhandle.update_ddl(ddl_statements).result()
  File "/home/tim/.virtualenvs/django37/lib/python3.7/site-packages/google/api_core/future/polling.py", line 127, in result
    raise self._exception
google.api_core.exceptions.GoogleAPICallError: None Duplicate name in schema: backends_article.

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/home/tim/code/django/django/test/testcases.py", line 274, in __call__
    self._post_teardown()
  File "/home/tim/code/django/django/test/testcases.py", line 1009, in _post_teardown
    self._fixture_teardown()
  File "/home/tim/code/django/django/test/testcases.py", line 1044, in _fixture_teardown
    inhibit_post_migrate=inhibit_post_migrate)
  File "/home/tim/code/django/django/core/management/__init__.py", line 148, in call_command
    return command.execute(*args, **defaults)
  File "/home/tim/code/django/django/core/management/base.py", line 364, in execute
    output = self.handle(*args, **options)
  File "/home/tim/code/django/django/core/management/commands/flush.py", line 49, in handle
    allow_cascade=allow_cascade)
  File "/home/tim/code/django/django/core/management/sql.py", line 13, in sql_flush
    tables = connection.introspection.django_table_names(only_existing=True, include_views=False)
  File "/home/tim/code/django/django/db/backends/base/introspection.py", line 78, in django_table_names
    existing_tables = set(self.table_names(include_views=include_views))
  File "/home/tim/code/django/django/db/backends/base/introspection.py", line 47, in table_names
    return get_names(cursor)
  File "/home/tim/code/django/django/db/backends/base/introspection.py", line 43, in get_names
    return sorted(ti.name for ti in self.get_table_list(cursor)
  File "/home/tim/code/spanner-django/django_spanner/introspection.py", line 27, in get_table_list
    return [TableInfo(row[0], 't') for row in cursor.list_tables()]
  File "/home/tim/code/spanner-django/spanner/dbapi/base_cursor.py", line 157, in list_tables
    return self._connection.list_tables()
  File "/home/tim/code/spanner-django/spanner/dbapi/base_connection.py", line 81, in list_tables
    """)
  File "/home/tim/code/spanner-django/spanner/dbapi/base_connection.py", line 86, in run_sql_in_snapshot
    self.run_prior_DDL_statements()
  File "/home/tim/code/spanner-django/spanner/dbapi/base_connection.py", line 71, in run_prior_DDL_statements
    return self.__handle_update_ddl(ddl_statements)
  File "/home/tim/code/spanner-django/spanner/dbapi/base_connection.py", line 48, in __handle_update_ddl
    raise DatabaseError(e.details if hasattr(e, 'details') else e)
spanner.dbapi.exceptions.DatabaseError: None Duplicate name in schema: backends_article.
```
Got it, I had also suspected that. So the trade off here is that if we don’t batch, creating tables and destroying tables aka any DDL combinations, can take tens of minutes-to-hours, instead of seconds or a single digit minute.

Perhaps we can have a flag that’ll not do batching when requested and would be passed in for this specific case. However, it is important that we maintain DDL batching for the common case.
Have you written up how the batching works? I scanned the design documents for "batch" and didn't find something. Perhaps if I better understand how it works I can suggest something.
>Have you written up how the batching works? I scanned the design documents for "batch" and didn't find something. Perhaps if I better understand how it works I can suggest something.

I've added batching to the [spanner.dbapi doc](https://docs.google.com/document/d/19HaiTn1FdfkP_0aNFKbygkvtbSrWqkXArTxlctx_O70/edit#heading=h.v0jiuru5q6nl)
Thanks. I was thinking we could add some public method to spanner_dbapi's `Cursor` and call that in the test (basically make `cursor._run_prior_DDL_statements()` public), however, that doesn't work because that won't have [`with self.db.wrap_database_errors:`](https://github.com/django/django/blob/00ff7a44dee91be59a64559cadeeba0f7386fd6f/django/db/backends/utils.py#L14-L89) (which is what transform's a dbapi library's `DatabaseError`, etc. into Django's `DatabaseError`) that `Cursor.execute()` has.

Perhaps adding a `Cursor.__init__()` option called something like `batch_ddl` (default to True) could be used to disable the batching in certain tests. I'm not sure if the batching behavior will be probablematic in places outside of tests.

However, I've also encountered this issue in some other tests where raw cursors aren't used, so that approach won't work. I'll keep thinking about it.
```
======================================================================
ERROR: test_migrate_fake_initial (migrations.test_commands.MigrateTests)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/tim/code/spanner-django/spanner_dbapi/base_connection.py", line 38, in __handle_update_ddl
    return self._dbhandle.update_ddl(ddl_statements).result()
  File "/home/tim/.virtualenvs/django37/lib/python3.7/site-packages/google/api_core/future/polling.py", line 127, in result
    raise self._exception
google.api_core.exceptions.GoogleAPICallError: None Duplicate name in schema: migrations_author.

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/home/tim/code/django/django/test/utils.py", line 373, in inner
    return func(*args, **kwargs)
  File "/home/tim/code/django/tests/migrations/test_commands.py", line 153, in test_migrate_fake_initial
    call_command("migrate", "migrations", "0001", verbosity=0)
  File "/home/tim/code/django/django/core/management/__init__.py", line 148, in call_command
    return command.execute(*args, **defaults)
  File "/home/tim/code/django/django/core/management/base.py", line 364, in execute
    output = self.handle(*args, **options)
  File "/home/tim/code/django/django/core/management/base.py", line 83, in wrapped
    res = handle_func(*args, **kwargs)
  File "/home/tim/code/django/django/core/management/commands/migrate.py", line 234, in handle
    fake_initial=fake_initial,
  File "/home/tim/code/django/django/db/migrations/executor.py", line 117, in migrate
    state = self._migrate_all_forwards(state, plan, full_plan, fake=fake, fake_initial=fake_initial)
  File "/home/tim/code/django/django/db/migrations/executor.py", line 147, in _migrate_all_forwards
    state = self.apply_migration(state, migration, fake=fake, fake_initial=fake_initial)
  File "/home/tim/code/django/django/db/migrations/executor.py", line 246, in apply_migration
    self.record_migration(migration)
  File "/home/tim/code/django/django/db/migrations/executor.py", line 261, in record_migration
    self.recorder.record_applied(migration.app_label, migration.name)
  File "/home/tim/code/django/django/db/migrations/recorder.py", line 82, in record_applied
    self.ensure_schema()
  File "/home/tim/code/django/django/db/migrations/recorder.py", line 62, in ensure_schema
    if self.has_table():
  File "/home/tim/code/django/django/db/migrations/recorder.py", line 56, in has_table
    return self.Migration._meta.db_table in self.connection.introspection.table_names(self.connection.cursor())
  File "/home/tim/code/django/django/db/backends/base/introspection.py", line 48, in table_names
    return get_names(cursor)
  File "/home/tim/code/django/django/db/backends/base/introspection.py", line 43, in get_names
    return sorted(ti.name for ti in self.get_table_list(cursor)
  File "/home/tim/code/spanner-django/django_spanner/introspection.py", line 33, in get_table_list
    return [TableInfo(row[0], 't') for row in cursor.list_tables()]
  File "/home/tim/code/spanner-django/spanner_dbapi/base_cursor.py", line 157, in list_tables
    return self._connection.list_tables()
  File "/home/tim/code/spanner-django/spanner_dbapi/base_connection.py", line 78, in list_tables
    """)
  File "/home/tim/code/spanner-django/spanner_dbapi/base_connection.py", line 83, in run_sql_in_snapshot
    self.run_prior_DDL_statements()
  File "/home/tim/code/spanner-django/spanner_dbapi/base_connection.py", line 68, in run_prior_DDL_statements
    return self.__handle_update_ddl(ddl_statements)
  File "/home/tim/code/spanner-django/spanner_dbapi/base_connection.py", line 44, in __handle_update_ddl
    raise DatabaseError(e.details if hasattr(e, 'details') else e)
spanner_dbapi.exceptions.DatabaseError: None Duplicate name in schema: migrations_author.
```


Resolving patch:
diff --git a/django_spanner/features.py b/django_spanner/features.py
--- a/django_spanner/features.py
+++ b/django_spanner/features.py
@@ -283,9 +283,6 @@ class DatabaseFeatures(BaseDatabaseFeatures):
         "backends.tests.BackendTestCase.test_cursor_execute_with_pyformat",
         "backends.tests.BackendTestCase.test_cursor_executemany_with_pyformat",
         "backends.tests.BackendTestCase.test_cursor_executemany_with_pyformat_iterator",
-        # duplicate table raises GoogleAPICallError rather than DatabaseError:
-        # https://github.com/googleapis/python-spanner-django/issues/344
-        "backends.tests.BackendTestCase.test_duplicate_table_error",
         "migrations.test_commands.MigrateTests.test_migrate_fake_initial",
         "migrations.test_commands.MigrateTests.test_migrate_initial_false",
         "migrations.test_executor.ExecutorTests.test_soft_apply",


Test patch: