From 751abbcc57c2b80275a9d507149dfe829a00493b Mon Sep 17 00:00:00 2001
From: Alex Garcia <alexsebastian.garcia@gmail.com>
Date: Wed, 14 Aug 2024 15:39:49 -0700
Subject: [PATCH] don't hide virtual table, hide shadow tables.

---
 datasette/database.py            | 76 ++++++++++++++++++++------------
 tests/test_api.py                | 46 +++++++++----------
 tests/test_html.py               |  3 +-
 tests/test_internals_database.py | 42 ++++++++++++++++++
 4 files changed, 116 insertions(+), 51 deletions(-)

diff --git a/datasette/database.py b/datasette/database.py
index 71c134d1d9..8b55f8f34e 100644
--- a/datasette/database.py
+++ b/datasette/database.py
@@ -20,6 +20,7 @@
     table_columns,
     table_column_details,
 )
+from .utils.sqlite import sqlite_version
 from .inspect import inspect_hash
 
 connections = threading.local()
@@ -459,22 +460,56 @@ async def foreign_keys_for_table(self, table):
         )
 
     async def hidden_table_names(self):
-        # Mark tables 'hidden' if they relate to FTS virtual tables
-        hidden_tables = [
-            r[0]
-            for r in (
-                await self.execute(
+        hidden_tables = []
+        # Add any tables marked as hidden in config
+        db_config = self.ds.config.get("databases", {}).get(self.name, {})
+        if "tables" in db_config:
+            hidden_tables += [
+                t for t in db_config["tables"] if db_config["tables"][t].get("hidden")
+            ]
+
+        if sqlite_version()[1] >= 37:
+            hidden_tables += [
+                x[0]
+                for x in await self.execute(
+                    """
+                      with shadow_tables as (
+                        select name
+                        from pragma_table_list
+                        where [type] = 'shadow'
+                        order by name
+                      ),
+                      core_tables as (
+                        select name
+                        from sqlite_master
+                        WHERE  name in ('sqlite_stat1', 'sqlite_stat2', 'sqlite_stat3', 'sqlite_stat4')
+                          OR substr(name, 1, 1) == '_'
+                      ),
+                      combined as (
+                        select name from shadow_tables
+                        union all
+                        select name from core_tables
+                      )
+                      select name from combined order by 1
                     """
-                select name from sqlite_master
-                where rootpage = 0
-                and (
-                    sql like '%VIRTUAL TABLE%USING FTS%'
-                ) or name in ('sqlite_stat1', 'sqlite_stat2', 'sqlite_stat3', 'sqlite_stat4')
-                or name like '\\_%' escape '\\'
-            """
                 )
-            ).rows
-        ]
+            ]
+        else:
+            hidden_tables += [
+                x[0]
+                for x in await self.execute(
+                    """
+                      with final as (
+                        select name
+                        from sqlite_master
+                        WHERE  name in ('sqlite_stat1', 'sqlite_stat2', 'sqlite_stat3', 'sqlite_stat4')
+                          OR substr(name, 1, 1) == '_'
+                      ),
+                      select name from final order by 1
+                    """
+                )
+            ]
+
         has_spatialite = await self.execute_fn(detect_spatialite)
         if has_spatialite:
             # Also hide Spatialite internal tables
@@ -503,19 +538,6 @@ async def hidden_table_names(self):
                     )
                 ).rows
             ]
-        # Add any tables marked as hidden in config
-        db_config = self.ds.config.get("databases", {}).get(self.name, {})
-        if "tables" in db_config:
-            hidden_tables += [
-                t for t in db_config["tables"] if db_config["tables"][t].get("hidden")
-            ]
-        # Also mark as hidden any tables which start with the name of a hidden table
-        # e.g. "searchable_fts" implies "searchable_fts_content" should be hidden
-        for table_name in await self.table_names():
-            for hidden_table in hidden_tables[:]:
-                if table_name.startswith(hidden_table):
-                    hidden_tables.append(table_name)
-                    continue
 
         return hidden_tables
 
diff --git a/tests/test_api.py b/tests/test_api.py
index 431ab5cee4..01c9bb79ad 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -389,6 +389,29 @@ async def test_database_page(ds_client):
             },
             "private": False,
         },
+        {
+            "name": "searchable_fts",
+            "columns": [
+                "text1",
+                "text2",
+                "name with . and spaces",
+            ]
+            + (
+                [
+                    "searchable_fts",
+                    "docid",
+                    "__langid",
+                ]
+                if supports_table_xinfo()
+                else []
+            ),
+            "primary_keys": [],
+            "count": 2,
+            "hidden": False,
+            "fts_table": "searchable_fts",
+            "foreign_keys": {"incoming": [], "outgoing": []},
+            "private": False,
+        },
         {
             "name": "searchable_tags",
             "columns": ["searchable_id", "tag"],
@@ -525,29 +548,6 @@ async def test_database_page(ds_client):
             "foreign_keys": {"incoming": [], "outgoing": []},
             "private": False,
         },
-        {
-            "name": "searchable_fts",
-            "columns": [
-                "text1",
-                "text2",
-                "name with . and spaces",
-            ]
-            + (
-                [
-                    "searchable_fts",
-                    "docid",
-                    "__langid",
-                ]
-                if supports_table_xinfo()
-                else []
-            ),
-            "primary_keys": [],
-            "count": 2,
-            "hidden": True,
-            "fts_table": "searchable_fts",
-            "foreign_keys": {"incoming": [], "outgoing": []},
-            "private": False,
-        },
         {
             "name": "searchable_fts_docsize",
             "columns": ["docid", "size"],
diff --git a/tests/test_html.py b/tests/test_html.py
index 5b60d2f544..735a7ef7e0 100644
--- a/tests/test_html.py
+++ b/tests/test_html.py
@@ -39,13 +39,14 @@ def test_homepage(app_client_two_attached_databases):
     assert "extra database" == h2.text.strip()
     counts_p, links_p = h2.find_all_next("p")[:2]
     assert (
-        "2 rows in 1 table, 5 rows in 4 hidden tables, 1 view" == counts_p.text.strip()
+        "4 rows in 2 tables, 3 rows in 3 hidden tables, 1 view" == counts_p.text.strip()
     )
     # We should only show visible, not hidden tables here:
     table_links = [
         {"href": a["href"], "text": a.text.strip()} for a in links_p.findAll("a")
     ]
     assert [
+        {"href": r"/extra+database/searchable_fts", "text": "searchable_fts"},
         {"href": r"/extra+database/searchable", "text": "searchable"},
         {"href": r"/extra+database/searchable_view", "text": "searchable_view"},
     ] == table_links
diff --git a/tests/test_internals_database.py b/tests/test_internals_database.py
index 1c155cf3b3..70be0f4ed4 100644
--- a/tests/test_internals_database.py
+++ b/tests/test_internals_database.py
@@ -664,3 +664,45 @@ async def test_in_memory_databases_forbid_writes(app_client):
     # Using db.execute_write() should work:
     await db.execute_write("create table foo (t text)")
     assert await db.table_names() == ["foo"]
+
+
+@pytest.mark.asyncio
+async def test_hidden_tables(app_client):
+    ds = app_client.ds
+    db = ds.add_database(Database(ds, is_memory=True, is_mutable=True))
+    assert await db.hidden_table_names() == []
+    await db.execute("create virtual table f using fts5(a)")
+    assert await db.hidden_table_names() == [
+       'f_config',
+       'f_content',
+       'f_data',
+       'f_docsize',
+       'f_idx',
+   ]
+
+    await db.execute("create virtual table r using rtree(id, amin, amax)")
+    assert await db.hidden_table_names() == [
+       'f_config',
+       'f_content',
+       'f_data',
+       'f_docsize',
+       'f_idx',
+       'r_node',
+       'r_parent',
+       'r_rowid'
+   ]
+
+    await db.execute("create table _hideme(_)")
+    assert await db.hidden_table_names() == [
+        '_hideme',
+       'f_config',
+       'f_content',
+       'f_data',
+       'f_docsize',
+       'f_idx',
+       'r_node',
+       'r_parent',
+       'r_rowid'
+   ]
+
+