Repository: googleapis/python-spanner-django

Issue:
dbapi: Cursor.execute should support DDL updates
Spanner's Python client only runs DDL updates (like CREATE TABLE, CREATE DATABASE etc https://cloud.google.com/spanner/docs/data-definition-language) on the Spanner.DatabaseClient which is different from running on an instance.

Let's ensure that we can abstract that functionality so that the users of this dbapi implementation don't have to know about and thus can do without even knowing the differences
```shell
        stmts = [
        """DROP TABLE AckworthSingers""",
        """CREATE TABLE AckworthSingers (
                      SingerId   INT64 NOT NULL,
                      FirstName  STRING(1024),
                      LastName   STRING(1024),
                      BirthDate  DATE,
                    ) PRIMARY KEY(SingerId)""",
        """SELECT COUNT(*) FROM Singers""",
        ]

        for stmt in stmts:
              cur.execute(stmt)
```
and no panics
instead of
```shell
The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/Users/emmanuelodeke/Desktop/spanner-orm/spanner/dbapi/cursor.py", line 88, in execute
    self.__session.run_in_transaction(self.__do_execute_update, sql, *args, **kwargs)
  File "/Library/Frameworks/Python.framework/Versions/3.7/lib/python3.7/site-packages/google/cloud/spanner_v1/session.py", line 299, in run_in_transaction
    return_value = func(txn, *args, **kw)
  File "/Users/emmanuelodeke/Desktop/spanner-orm/spanner/dbapi/cursor.py", line 104, in __do_execute_update
    res = transaction.execute_update(sql, *args, **kwargs)
  File "/Library/Frameworks/Python.framework/Versions/3.7/lib/python3.7/site-packages/google/cloud/spanner_v1/transaction.py", line 202, in execute_update
    metadata=metadata,
  File "/Library/Frameworks/Python.framework/Versions/3.7/lib/python3.7/site-packages/google/cloud/spanner_v1/gapic/spanner_client.py", line 812, in execute_sql
    request, retry=retry, timeout=timeout, metadata=metadata
  File "/Library/Frameworks/Python.framework/Versions/3.7/lib/python3.7/site-packages/google/api_core/gapic_v1/method.py", line 143, in __call__
    return wrapped_func(*args, **kwargs)
  File "/Library/Frameworks/Python.framework/Versions/3.7/lib/python3.7/site-packages/google/api_core/retry.py", line 277, in retry_wrapped_func
    on_error=on_error,
  File "/Library/Frameworks/Python.framework/Versions/3.7/lib/python3.7/site-packages/google/api_core/retry.py", line 182, in retry_target
    return target()
  File "/Library/Frameworks/Python.framework/Versions/3.7/lib/python3.7/site-packages/google/api_core/timeout.py", line 214, in func_with_timeout
    return func(*args, **kwargs)
  File "/Library/Frameworks/Python.framework/Versions/3.7/lib/python3.7/site-packages/google/api_core/grpc_helpers.py", line 59, in error_remapped_callable
    six.raise_from(exceptions.from_grpc_error(exc), exc)
  File "<string>", line 3, in raise_from
google.api_core.exceptions.InvalidArgument: 400 Statement not supported: CreateTableStmt [at 1:1]\n\n^
```


Discussion:

Resolving patch:
diff --git a/spanner/dbapi/connection.py b/spanner/dbapi/connection.py
--- a/spanner/dbapi/connection.py
+++ b/spanner/dbapi/connection.py
@@ -56,7 +56,7 @@ def cursor(self):
         if not session.exists():
             session.create()
 
-        return Cursor(session)
+        return Cursor(session, self)
 
 
     def update_ddl(self, ddl_statements):
diff --git a/spanner/dbapi/cursor.py b/spanner/dbapi/cursor.py
--- a/spanner/dbapi/cursor.py
+++ b/spanner/dbapi/cursor.py
@@ -23,6 +23,7 @@
 
 from .parse_utils import (
         classify_stmt,
+        STMT_DDL,
         STMT_NON_UPDATING
 )
 
@@ -30,11 +31,12 @@
 _UNSET_COUNT = -1
 
 class Cursor(object):
-    def __init__(self, session):
+    def __init__(self, session, db_handle=None):
         self.__session = session
         self.__itr = None
         self.__res = None
         self.__row_count = _UNSET_COUNT
+        self.__db_handle = db_handle
 
 
     @property
@@ -82,7 +84,10 @@ def execute(self, sql, *args, **kwargs):
 
         # Classify whether this is a read-only SQL statement.
         try:
-            if classify_stmt(sql) == STMT_NON_UPDATING:
+            if classify_stmt(sql) == STMT_DDL:
+                # Special case: since Spanner won't execute DDL updates
+                self.__do_update_ddl(sql)
+            elif classify_stmt(sql) == STMT_NON_UPDATING:
                 self.__do_execute_non_update(sql, *args, **kwargs)
             else:
                 self.__session.run_in_transaction(self.__do_execute_update, sql, *args, **kwargs)
@@ -185,6 +190,14 @@ def close(self):
         self.__session.delete()
 
 
+    def __do_update_ddl(self, *ddl_statements):
+        if not self.__db_handle:
+            raise ProgrammingError('Trying to run an DDL update but no database handle')
+
+
+        return self.__db_handle.update_ddl(ddl_statements)
+
+
 class Column:
     def __init__(self, name, type_code, display_size=None, internal_size=None, precision=None, scale=None, null_ok=False):
         self.name = name
diff --git a/spanner/dbapi/parse_utils.py b/spanner/dbapi/parse_utils.py
--- a/spanner/dbapi/parse_utils.py
+++ b/spanner/dbapi/parse_utils.py
@@ -150,14 +150,20 @@ def validate_instance_config(instance_config):
     return None
 
 
+STMT_DDL = 'DDL'
 STMT_NON_UPDATING = 'NON_UPDATING'
 STMT_UPDATING = 'UPDATING'
 
 # Heuristic for identifying statements that don't need to be run as updates.
 re_NON_UPDATE = re.compile('^\s*(SELECT|ANALYZE|AUDIT|EXPLAIN|SHOW)', re.UNICODE|re.IGNORECASE)
 
+# DDL statements follow https://cloud.google.com/spanner/docs/data-definition-language
+re_DDL = re.compile('^\s*(CREATE TABLE|CREATE DATABASE|ALTER TABLE|DROP TABLE|DROP INDEX)', re.UNICODE|re.IGNORECASE)
+
 def classify_stmt(sql):
-    if re_NON_UPDATE.match(sql):
+    if re_DDL.match(sql):
+        return STMT_DDL
+    elif re_NON_UPDATE.match(sql):
         return STMT_NON_UPDATING
-
-    return STMT_UPDATING
+    else:
+        return STMT_UPDATING


Test patch: