Repository: googleapis/python-bigquery

Issue:
Percents de-escaping isn't handled when no parameters are used in the dbapi.

```python

>>> import google.cloud.bigquery.dbapi
>>> conn = google.cloud.bigquery.dbapi.connect()
/home/jim/p/g/python-bigquery/google/cloud/bigquery/client.py:444: UserWarning: Cannot create BigQuery Storage client, the dependency google-cloud-bigquery-storage is not installed.
  warnings.warn(
>>> cursor = conn.cursor()
>>> cursor.execute("select 'foo %%', %(x)s", dict(x=1))
>>> cursor.fetchall()
[Row(('foo %', 1), {'f0_': 0, 'f1_': 1})]
>>> cursor.execute("select 'foo %%'", dict(x=1))
>>> cursor.fetchall()
[Row(('foo %',), {'f0_': 0})]
>>> cursor.execute("select 'foo %%'")
>>> cursor.fetchall()
[Row(('foo %%',), {'f0_': 0})]
```

Bonus:

if people don't escape:

```
>>> cursor.execute("select 'foo %'")
>>> cursor.fetchall()
[Row(('foo %',), {'f0_': 0})]
>>> cursor.execute("select 'foo %'", dict(x=1))
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/home/jim/p/g/python-bigquery/google/cloud/bigquery/dbapi/_helpers.py", line 273, in with_closed_check
    return method(self, *args, **kwargs)
  File "/home/jim/p/g/python-bigquery/google/cloud/bigquery/dbapi/cursor.py", line 172, in execute
    formatted_operation = _format_operation(operation, parameters=parameters)
  File "/home/jim/p/g/python-bigquery/google/cloud/bigquery/dbapi/cursor.py", line 460, in _format_operation
    return _format_operation_dict(operation, parameters)
  File "/home/jim/p/g/python-bigquery/google/cloud/bigquery/dbapi/cursor.py", line 434, in _format_operation_dict
    return operation % formatted_params
ValueError: unsupported format character ''' (0x27) at index 13
```




Discussion:
FTR, I have a local fix. It's pretty simple. In `cursor.py`:

```
@@ -441,7 +461,7 @@ def _format_operation(operation, parameters=None):
             ``parameters`` argument.
     """
     if parameters is None or len(parameters) == 0:
-        return operation
+        return operation.replace("%%", "%")
 
     if isinstance(parameters, collections_abc.Mapping):
```

@jimfulton So the expected behavior is to _always_ de-escape in absence of parameters, no matter what?

It might be breaking for some of the existing parameter-less queries that happen to contain `%%`, but it will at least be clear that de-escaping is consistently always applied.
I think de-escaping has to be done consistently.

I tripped over this while testing the SQLAlchemy integration, which assumes consistent de-escaping.

Fair enough. We do need to assure a good changelog message, though (note to self when merging the PR).

Resolving patch:
diff --git a/google/cloud/bigquery/dbapi/cursor.py b/google/cloud/bigquery/dbapi/cursor.py
--- a/google/cloud/bigquery/dbapi/cursor.py
+++ b/google/cloud/bigquery/dbapi/cursor.py
@@ -389,7 +389,7 @@ def _format_operation_list(operation, parameters):
 
     try:
         return operation % tuple(formatted_params)
-    except TypeError as exc:
+    except (TypeError, ValueError) as exc:
         raise exceptions.ProgrammingError(exc)
 
 
@@ -419,7 +419,7 @@ def _format_operation_dict(operation, parameters):
 
     try:
         return operation % formatted_params
-    except KeyError as exc:
+    except (KeyError, ValueError, TypeError) as exc:
         raise exceptions.ProgrammingError(exc)
 
 
@@ -441,7 +441,7 @@ def _format_operation(operation, parameters=None):
             ``parameters`` argument.
     """
     if parameters is None or len(parameters) == 0:
-        return operation
+        return operation.replace("%%", "%")  # Still do percent de-escaping.
 
     if isinstance(parameters, collections_abc.Mapping):
         return _format_operation_dict(operation, parameters)


Test patch:
diff --git a/tests/unit/test_dbapi_cursor.py b/tests/unit/test_dbapi_cursor.py
--- a/tests/unit/test_dbapi_cursor.py
+++ b/tests/unit/test_dbapi_cursor.py
@@ -633,6 +633,14 @@ def test__format_operation_w_wrong_dict(self):
             {"somevalue-not-here": "hi", "othervalue": "world"},
         )
 
+    def test__format_operation_w_redundant_dict_key(self):
+        from google.cloud.bigquery.dbapi import cursor
+
+        formatted_operation = cursor._format_operation(
+            "SELECT %(somevalue)s;", {"somevalue": "foo", "value-not-used": "bar"}
+        )
+        self.assertEqual(formatted_operation, "SELECT @`somevalue`;")
+
     def test__format_operation_w_sequence(self):
         from google.cloud.bigquery.dbapi import cursor
 
@@ -652,8 +660,53 @@ def test__format_operation_w_too_short_sequence(self):
             ("hello",),
         )
 
+    def test__format_operation_w_too_long_sequence(self):
+        from google.cloud.bigquery import dbapi
+        from google.cloud.bigquery.dbapi import cursor
+
+        self.assertRaises(
+            dbapi.ProgrammingError,
+            cursor._format_operation,
+            "SELECT %s, %s;",
+            ("hello", "world", "everyone"),
+        )
+
     def test__format_operation_w_empty_dict(self):
         from google.cloud.bigquery.dbapi import cursor
 
         formatted_operation = cursor._format_operation("SELECT '%f'", {})
         self.assertEqual(formatted_operation, "SELECT '%f'")
+
+    def test__format_operation_wo_params_single_percent(self):
+        from google.cloud.bigquery.dbapi import cursor
+
+        formatted_operation = cursor._format_operation("SELECT '%'", {})
+        self.assertEqual(formatted_operation, "SELECT '%'")
+
+    def test__format_operation_wo_params_double_percents(self):
+        from google.cloud.bigquery.dbapi import cursor
+
+        formatted_operation = cursor._format_operation("SELECT '%%'", {})
+        self.assertEqual(formatted_operation, "SELECT '%'")
+
+    def test__format_operation_unescaped_percent_w_dict_param(self):
+        from google.cloud.bigquery import dbapi
+        from google.cloud.bigquery.dbapi import cursor
+
+        self.assertRaises(
+            dbapi.ProgrammingError,
+            cursor._format_operation,
+            "SELECT %(foo)s, '100 %';",
+            {"foo": "bar"},
+        )
+
+    def test__format_operation_unescaped_percent_w_list_param(self):
+        from google.cloud.bigquery import dbapi
+        from google.cloud.bigquery.dbapi import cursor
+
+        self.assertRaises(
+            dbapi.ProgrammingError,
+            cursor._format_operation,
+            "SELECT %s, %s, '100 %';",
+            ["foo", "bar"],
+        )