/-/tables.json with no ?q= returns tables

Closes #2541
This commit is contained in:
Simon Willison 2025-10-25 16:10:00 -07:00
commit bda69ff1c9
2 changed files with 83 additions and 25 deletions

View file

@ -930,44 +930,58 @@ class TablesView(BaseView):
# Get search query parameter
q = request.args.get("q", "").strip()
# Only return matches if there's a non-empty search query
if not q:
return Response.json({"matches": []})
# Build SQL LIKE pattern from search terms
# Split search terms by whitespace and build pattern: %term1%term2%term3%
terms = q.split()
pattern = "%" + "%".join(terms) + "%"
# Get SQL for allowed resources using the permission system
permission_sql, params = await self.ds.allowed_resources_sql(
action="view-table", actor=request.actor
)
# Build query with CTE to filter by search pattern
sql = f"""
WITH allowed_tables AS (
{permission_sql}
)
SELECT parent, child
FROM allowed_tables
WHERE child LIKE :pattern COLLATE NOCASE
ORDER BY length(child), child
"""
# Build query based on whether we have a search query
if q:
# Build SQL LIKE pattern from search terms
# Split search terms by whitespace and build pattern: %term1%term2%term3%
terms = q.split()
pattern = "%" + "%".join(terms) + "%"
# Merge params from permission SQL with our pattern param
all_params = {**params, "pattern": pattern}
# Build query with CTE to filter by search pattern
sql = f"""
WITH allowed_tables AS (
{permission_sql}
)
SELECT parent, child
FROM allowed_tables
WHERE child LIKE :pattern COLLATE NOCASE
ORDER BY length(child), child
"""
all_params = {**params, "pattern": pattern}
else:
# No search query - return all tables, ordered by name
# Fetch 101 to detect if we need to truncate
sql = f"""
WITH allowed_tables AS (
{permission_sql}
)
SELECT parent, child
FROM allowed_tables
ORDER BY parent, child
LIMIT 101
"""
all_params = params
# Execute against internal database
result = await self.ds.get_internal_database().execute(sql, all_params)
# Build response
# Build response with truncation
rows = list(result.rows)
truncated = len(rows) > 100
if truncated:
rows = rows[:100]
matches = [
{
"name": f"{row['parent']}: {row['child']}",
"url": self.ds.urls.table(row["parent"], row["child"]),
}
for row in result.rows
for row in rows
]
return Response.json({"matches": matches})
return Response.json({"matches": matches, "truncated": truncated})

View file

@ -313,6 +313,50 @@ async def test_tables_endpoint_empty_result(test_ds):
pm.unregister(plugin, name="test_plugin")
@pytest.mark.asyncio
async def test_tables_endpoint_no_query_returns_all():
"""Test /-/tables without query parameter returns all tables"""
ds = Datasette()
await ds.invoke_startup()
# Add database with a few tables
db = ds.add_memory_database("test_db")
await db.execute_write("CREATE TABLE users (id INTEGER)")
await db.execute_write("CREATE TABLE posts (id INTEGER)")
await db.execute_write("CREATE TABLE comments (id INTEGER)")
await ds._refresh_schemas()
# Get all tables without query
all_tables = await ds.allowed_resources("view-table", None)
# Should return all tables with truncated: false
assert len(all_tables) >= 3
table_names = {f"{t.parent}/{t.child}" for t in all_tables}
assert "test_db/users" in table_names
assert "test_db/posts" in table_names
assert "test_db/comments" in table_names
@pytest.mark.asyncio
async def test_tables_endpoint_truncation():
"""Test /-/tables truncates at 100 tables and sets truncated flag"""
ds = Datasette()
await ds.invoke_startup()
# Create a database with 105 tables
db = ds.add_memory_database("big_db")
for i in range(105):
await db.execute_write(f"CREATE TABLE table_{i:03d} (id INTEGER)")
await ds._refresh_schemas()
# Get all tables - should be truncated
all_tables = await ds.allowed_resources("view-table", None)
big_db_tables = [t for t in all_tables if t.parent == "big_db"]
# Should have exactly 105 tables in the database
assert len(big_db_tables) == 105
@pytest.mark.asyncio
async def test_tables_endpoint_search_single_term():
"""Test /-/tables?q=user to filter tables matching 'user'"""