Repository: betodealmeida/shillelagh

Issue:
Virtual table names are incorrectly escaped
**Describe the bug**
When virtual tables are added/created, they are defined as:
```
table_name = escape(uri)
self._cursor.execute(
            f'CREATE VIRTUAL TABLE "{table_name}" USING {adapter.__name__}({formatted_args})',
        )
```
see: https://github.com/betodealmeida/shillelagh/blob/e379e8ac0b3dc2f45217be5084b01934c8a489d3/src/shillelagh/backends/apsw/db.py#L294

In particular, the table name is put in double quotes.
The escape method applied before, replaces _single_ quotes rather than _double_ quotes:
```
def escape(value: str) -> str:
    """Escape single quotes."""
    return value.replace("'", "''")
```
see: https://github.com/betodealmeida/shillelagh/blob/e379e8ac0b3dc2f45217be5084b01934c8a489d3/src/shillelagh/lib.py#L224

**Expected behavior**
As the table name is quotes in double quotes, double quotes should be escaped in the table name.

There are multiple ways to fix this:

1. Switch `escape` to replace double quotes. Likely not desired as the method seems to be used in other places.
2. Quote the table name in single quotes when creating. Is this breaking anything existing?
3. Ad-hoc escape the double quotes in the table name (likely best option if the impact of 2 can't be assessed).

Happy to send a pull request.

Comments:

Patch:
diff --git a/docs/adapters.rst b/docs/adapters.rst
index 7f7b29f..431085b 100644
--- a/docs/adapters.rst
+++ b/docs/adapters.rst
@@ -51,7 +51,7 @@ For domain wide access you need to create a service account. Make sure that the
     connection = connect(
         ":memory:",
         adapter_kwargs={
-            "gsheetaspi": {
+            "gsheetsapi": {
                 # "service_account_file": "/path/to/credentials.json",
                 "service_account_info": {
                     "type": "service_account",
diff --git a/src/shillelagh/backends/apsw/db.py b/src/shillelagh/backends/apsw/db.py
index bf915c1..91cfb53 100644
--- a/src/shillelagh/backends/apsw/db.py
+++ b/src/shillelagh/backends/apsw/db.py
@@ -39,7 +39,12 @@ from shillelagh.exceptions import (  # nopycln: import; pylint: disable=redefine
     Warning,
 )
 from shillelagh.fields import Blob, Field
-from shillelagh.lib import combine_args_kwargs, escape, find_adapter, serialize
+from shillelagh.lib import (
+    combine_args_kwargs,
+    escape_identifier,
+    find_adapter,
+    serialize,
+)
 from shillelagh.types import (
     BINARY,
     DATETIME,
@@ -290,7 +295,7 @@ class Cursor:  # pylint: disable=too-many-instance-attributes
             f"'{serialize(arg)}'"
             for arg in combine_args_kwargs(adapter, *args, **kwargs)
         )
-        table_name = escape(uri)
+        table_name = escape_identifier(uri)
         self._cursor.execute(
             f'CREATE VIRTUAL TABLE "{table_name}" USING {adapter.__name__}({formatted_args})',
         )
diff --git a/src/shillelagh/lib.py b/src/shillelagh/lib.py
index 684af28..e36b254 100644
--- a/src/shillelagh/lib.py
+++ b/src/shillelagh/lib.py
@@ -221,16 +221,26 @@ def update_order(
     return current_order
 
 
-def escape(value: str) -> str:
+def escape_string(value: str) -> str:
     """Escape single quotes."""
     return value.replace("'", "''")
 
 
-def unescape(value: str) -> str:
+def unescape_string(value: str) -> str:
     """Unescape single quotes."""
     return value.replace("''", "'")
 
 
+def escape_identifier(value: str) -> str:
+    """Escape double quotes."""
+    return value.replace('"', '""')
+
+
+def unescape_identifier(value: str) -> str:
+    """Unescape double quotes."""
+    return value.replace('""', '"')
+
+
 def serialize(value: Any) -> str:
     """
     Serialize adapter arguments.
@@ -247,7 +257,7 @@ def serialize(value: Any) -> str:
             "numbers) are passed as arguments to adapters.",
         ) from ex
 
-    return escape(base64.b64encode(serialized).decode())
+    return escape_string(base64.b64encode(serialized).decode())
 
 
 def deserialize(value: str) -> Any:
@@ -257,7 +267,7 @@ def deserialize(value: str) -> Any:
     This function is used by the SQLite backend, in order to deserialize
     the virtual table definition and instantiate an adapter.
     """
-    return marshal.loads(base64.b64decode(unescape(value).encode()))
+    return marshal.loads(base64.b64decode(unescape_string(value).encode()))
 
 
 def build_sql(  # pylint: disable=too-many-locals, too-many-arguments, too-many-branches


Test patch:
diff --git a/tests/lib_test.py b/tests/lib_test.py
index da4a3e6..1433c7a 100644
--- a/tests/lib_test.py
+++ b/tests/lib_test.py
@@ -26,13 +26,15 @@ from shillelagh.lib import (
     build_sql,
     combine_args_kwargs,
     deserialize,
-    escape,
+    escape_identifier,
+    escape_string,
     filter_data,
     find_adapter,
     is_not_null,
     is_null,
     serialize,
-    unescape,
+    unescape_identifier,
+    unescape_string,
     update_order,
 )
 from shillelagh.typing import RequestedOrder
@@ -272,20 +274,44 @@ def test_build_sql_impossible() -> None:
         build_sql(columns, {"a": Impossible()}, [])
 
 
-def test_escape() -> None:
+def test_escape_string() -> None:
     """
-    Test ``escape``.
+    Test ``escape_string``.
     """
-    assert escape("1") == "1"
-    assert escape("O'Malley's") == "O''Malley''s"
+    assert escape_string("1") == "1"
+    assert escape_string("O'Malley's") == "O''Malley''s"
 
 
-def test_unescape() -> None:
+def test_unescape_string() -> None:
     """
-    Test ``unescape``.
+    Test ``unescape_string``.
     """
-    assert unescape("1") == "1"
-    assert unescape("O''Malley''s") == "O'Malley's"
+    assert unescape_string("1") == "1"
+    assert unescape_string("O''Malley''s") == "O'Malley's"
+
+
+def test_escape_identifier() -> None:
+    """
+    Test ``escape_identifier``.
+    """
+    assert escape_identifier("1") == "1"
+    assert escape_identifier("O'Malley's") == "O'Malley's"
+    assert (
+        escape_identifier('a dove called: "Who? who? who?"')
+        == 'a dove called: ""Who? who? who?""'
+    )
+
+
+def test_unescape_identifier() -> None:
+    """
+    Test ``unescape_identifier``.
+    """
+    assert unescape_identifier("1") == "1"
+    assert unescape_identifier("O''Malley''s") == "O''Malley''s"
+    assert (
+        unescape_identifier('a dove called: ""Who? who? who?""')
+        == 'a dove called: "Who? who? who?"'
+    )
 
 
 def test_serialize() -> None: