mirror of
https://github.com/simonw/datasette.git
synced 2026-10-18 23:36:57 +02:00
652 lines
22 KiB
Python
652 lines
22 KiB
Python
"""Requests, startup and background work only touch the databases they need.
|
|
|
|
With a capped, idle-reaped connection pool, a loop over every attached
|
|
database opens (and evicts) one connection per database. These tests attach
|
|
50 databases and use tests/connection_budget.py to assert which databases
|
|
each endpoint opens, reads or writes. Every test uses a fresh instance, so
|
|
caches that are only warm after a first request do not hide a sweep.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import shutil
|
|
import sqlite3
|
|
import time
|
|
|
|
import pytest
|
|
|
|
from datasette.app import Datasette
|
|
from datasette.utils.catalog import (
|
|
catalog_relationship_counts,
|
|
catalog_summaries,
|
|
catalog_table_details,
|
|
)
|
|
from datasette.utils.sqlite import (
|
|
sqlite_derived_table_dependencies,
|
|
supports_table_list,
|
|
)
|
|
|
|
from .connection_budget import INTERNAL, connection_budget
|
|
|
|
N = 50
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def only_default_plugins():
|
|
"""Plugins other tests leave registered (tests/plugins/my_plugin.py and
|
|
friends query datasette.get_database() from their hooks) are not part
|
|
of what these tests measure: unregister them for the duration."""
|
|
from datasette.plugins import DEFAULT_PLUGINS, pm
|
|
|
|
removed = [
|
|
(name, plugin)
|
|
for name, plugin in pm.list_name_plugin()
|
|
if name not in DEFAULT_PLUGINS
|
|
]
|
|
for name, _ in removed:
|
|
pm.unregister(name=name)
|
|
try:
|
|
yield
|
|
finally:
|
|
for name, plugin in removed:
|
|
if pm.get_plugin(name) is None:
|
|
pm.register(plugin, name=name)
|
|
|
|
|
|
def _make_db(path, i):
|
|
conn = sqlite3.connect(path)
|
|
conn.executescript("""
|
|
create table t2 (id integer primary key, title text);
|
|
create table t1 (
|
|
id integer primary key, name text, other_id integer references t2(id)
|
|
);
|
|
""")
|
|
conn.executemany(
|
|
"insert into t1 (name, other_id) values (?, ?)",
|
|
[(f"n{j}", j % 3) for j in range(20)],
|
|
)
|
|
conn.executemany(
|
|
"insert into t2 (title) values (?)", [(f"t{j}",) for j in range(3)]
|
|
)
|
|
if i % 5 == 0:
|
|
conn.executescript("""
|
|
create virtual table t1_fts using fts5(
|
|
name, content="t1", content_rowid='id'
|
|
);
|
|
insert into t1_fts(t1_fts) values('rebuild');
|
|
""")
|
|
if i % 3 == 0:
|
|
conn.execute("create view v1 as select * from t1")
|
|
if i % 7 == 0:
|
|
conn.execute("create table _hidden (x)")
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def db_files(tmp_path_factory):
|
|
directory = tmp_path_factory.mktemp("budget")
|
|
paths = []
|
|
for i in range(N):
|
|
path = str(directory / f"db{i:03d}.db")
|
|
_make_db(path, i)
|
|
paths.append(path)
|
|
# Older than the SchemaWatcher's racy window, so a sweep is stat-only
|
|
old = time.time() - 60
|
|
for path in paths:
|
|
os.utime(path, (old, old))
|
|
return paths
|
|
|
|
|
|
def _ds(paths, **kwargs):
|
|
settings = {"schema_watch_interval_ms": 0}
|
|
settings.update(kwargs.pop("settings", {}))
|
|
ds = Datasette(paths, settings=settings, **kwargs)
|
|
ds.root_enabled = True
|
|
return ds
|
|
|
|
|
|
def _root(ds):
|
|
return {"ds_actor": ds.client.actor_cookie({"id": "root"})}
|
|
|
|
|
|
async def _get(ds, path, cookies=None):
|
|
async with connection_budget() as budget:
|
|
response = await ds.client.get(path, cookies=cookies or {})
|
|
assert response.status_code == 200, (path, response.status_code, response.text)
|
|
return response, budget
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("path", ["/", "/.json", "/-/", "/-/.json"])
|
|
@pytest.mark.parametrize("as_root", [False, True])
|
|
async def test_index_page_opens_no_database(db_files, path, as_root):
|
|
ds = _ds(db_files)
|
|
await ds.invoke_startup()
|
|
response, budget = await _get(ds, path, _root(ds) if as_root else None)
|
|
budget.assert_only()
|
|
budget.assert_no_connections()
|
|
if path.endswith(".json"):
|
|
data = response.json()
|
|
assert len(data["databases"]) == N
|
|
# More databases than COUNT_MAX_DATABASES: no row counts
|
|
assert not any(d["show_table_row_counts"] for d in data["databases"])
|
|
db000 = next(d for d in data["databases"] if d["name"] == "db000")
|
|
assert db000["tables_count"] == 2 # t1, t2
|
|
# _hidden, and t1_fts (an external content FTS table is hidden).
|
|
# Its fts5 shadow tables are not listed at all: they derive from
|
|
# t1_fts, which is itself derived (the one-hop permission policy)
|
|
assert db000["hidden_tables_count"] == 2
|
|
assert db000["views_count"] == 1
|
|
shown = {t["name"]: t for t in db000["tables_and_views_truncated"]}
|
|
assert shown["t1"]["columns"] == ["id", "name", "other_id"]
|
|
assert shown["t1"]["primary_keys"] == ["id"]
|
|
assert shown["t1"]["fts_table"] == "t1_fts"
|
|
# Sorted by foreign key relationships when there are no counts
|
|
assert shown["t1"]["num_relationships_for_sorting"] == 1
|
|
ds.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_index_page_counts_rows_for_few_databases(db_files):
|
|
# At most COUNT_MAX_DATABASES databases: rows are counted, and only the
|
|
# databases listed are touched
|
|
ds = _ds(db_files[:3])
|
|
await ds.invoke_startup()
|
|
response, budget = await _get(ds, "/.json")
|
|
budget.assert_only("db000", "db001", "db002")
|
|
data = response.json()
|
|
assert all(d["show_table_row_counts"] for d in data["databases"])
|
|
assert data["databases"][0]["table_rows_sum"] == 23
|
|
ds.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_index_page_uses_cached_immutable_counts(db_files):
|
|
# Immutable databases' counts are computed once, when first needed, and
|
|
# reused even when there are too many databases to count
|
|
ds = Datasette(
|
|
db_files[3:],
|
|
immutables=db_files[:3],
|
|
settings={"schema_watch_interval_ms": 0},
|
|
)
|
|
await ds.invoke_startup()
|
|
for name in ("db000", "db001", "db002"):
|
|
await ds.get_database(name).table_counts()
|
|
response, budget = await _get(ds, "/.json")
|
|
budget.assert_no_connections()
|
|
budget.assert_only()
|
|
by_name = {d["name"]: d for d in response.json()["databases"]}
|
|
assert by_name["db000"]["show_table_row_counts"]
|
|
assert not by_name["db010"]["show_table_row_counts"]
|
|
ds.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("path", ["/-/databases", "/-/databases.json"])
|
|
async def test_databases_endpoint(db_files, path):
|
|
ds = _ds(db_files)
|
|
await ds.invoke_startup()
|
|
_, budget = await _get(ds, path)
|
|
budget.assert_only()
|
|
budget.assert_no_connections()
|
|
ds.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"path",
|
|
[
|
|
"/db001",
|
|
"/db001.json",
|
|
"/db000.json?_search=n",
|
|
"/db001/t1",
|
|
"/db001/t1.json",
|
|
"/db001/t1?_facet=name",
|
|
"/db001/t1.json?_facet=name",
|
|
"/db000/t1_fts.json?_search=n1",
|
|
"/db001/-/query?sql=select+1",
|
|
"/db001/-/query.json?sql=select+1",
|
|
"/db001/-/query",
|
|
"/db001/t1/1",
|
|
"/db001/t1/1.json",
|
|
"/db001/-/schema.json",
|
|
"/db001/t1/-/autocomplete?q=n",
|
|
],
|
|
)
|
|
@pytest.mark.parametrize("as_root", [False, True])
|
|
async def test_database_scoped_pages_touch_only_their_database(db_files, path, as_root):
|
|
ds = _ds(db_files)
|
|
await ds.invoke_startup()
|
|
database = path.split("/")[1].split(".")[0]
|
|
_, budget = await _get(ds, path, _root(ds) if as_root else None)
|
|
budget.assert_only(database)
|
|
ds.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"path,as_root",
|
|
[
|
|
("/-/versions", False),
|
|
("/-/versions.json", False),
|
|
("/-/plugins.json", False),
|
|
("/-/settings.json", False),
|
|
("/-/config.json", False),
|
|
("/-/actor.json", False),
|
|
("/-/threads", True),
|
|
("/-/threads.json", True),
|
|
("/-/tasks.json", True),
|
|
("/-/actions.json", True),
|
|
("/-/jump?q=t1", False),
|
|
("/-/jump.json?q=t1", False),
|
|
("/-/jump.json", False),
|
|
("/-/allowed.json?action=view-table", False),
|
|
("/-/allowed.json?action=view-table", True),
|
|
("/-/allowed.json?action=view-database", False),
|
|
("/-/allowed?action=view-table", True),
|
|
("/-/rules.json?action=view-table", True),
|
|
("/-/check.json?action=view-table&parent=db001&child=t1", True),
|
|
("/-/permissions", True),
|
|
("/-/allow-debug", False),
|
|
("/-/api", False),
|
|
("/-/api", True),
|
|
("/-/create-token", True),
|
|
("/-/queries.json", False),
|
|
("/-/patterns", False),
|
|
],
|
|
)
|
|
async def test_instance_pages_open_no_user_database(db_files, path, as_root):
|
|
ds = _ds(db_files)
|
|
await ds.invoke_startup()
|
|
# /-/versions probes the SQLite library once per instance
|
|
await ds.client.get("/-/versions.json")
|
|
_, budget = await _get(ds, path, _root(ds) if as_root else None)
|
|
budget.assert_only()
|
|
budget.assert_no_connections()
|
|
ds.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_versions_probes_sqlite_once(db_files):
|
|
ds = _ds(db_files[:12], crossdb=True)
|
|
await ds.invoke_startup()
|
|
async with connection_budget() as budget:
|
|
for _ in range(3):
|
|
assert (await ds.client.get("/-/versions.json")).status_code == 200
|
|
# One :memory: connection for the first request, none after; it is not
|
|
# given the --crossdb ATTACHes
|
|
assert sum(budget.probes.values()) == 1
|
|
assert not budget.attached
|
|
budget.assert_only(probes=1)
|
|
ds.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_detect_json1_is_cached(db_files):
|
|
ds = _ds(db_files[:2])
|
|
await ds.invoke_startup()
|
|
await ds.client.get("/db001/t1")
|
|
_, budget = await _get(ds, "/db001/t1?_facet=name")
|
|
assert not budget.probes
|
|
ds.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_instance_schema_reads_each_visible_database_once(db_files):
|
|
# /-/schema dumps every visible database's sqlite_master: one read each,
|
|
# nothing for databases the actor cannot see
|
|
ds = _ds(
|
|
db_files[:10],
|
|
config={"databases": {"db003": {"allow": False}}},
|
|
)
|
|
await ds.invoke_startup()
|
|
_, budget = await _get(ds, "/-/schema.json")
|
|
expected = {f"db{i:03d}" for i in range(10)} - {"db003"}
|
|
assert budget.touched() - {INTERNAL} == expected
|
|
assert all(budget.reads[name] == 1 for name in expected)
|
|
ds.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_autocomplete_debug_is_bounded(db_files):
|
|
ds = _ds(db_files)
|
|
await ds.invoke_startup()
|
|
_, budget = await _get(ds, "/-/debug/autocomplete")
|
|
# Only databases holding one of the (at most 5) suggestions
|
|
budget.assert_at_most(5)
|
|
ds.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_write_touches_only_its_database(db_files, tmp_path):
|
|
paths = []
|
|
for path in db_files[:20]:
|
|
target = tmp_path / os.path.basename(path)
|
|
shutil.copy(path, target)
|
|
paths.append(str(target))
|
|
ds = _ds(paths)
|
|
await ds.invoke_startup()
|
|
async with connection_budget() as budget:
|
|
response = await ds.client.post(
|
|
"/db004/-/execute-write",
|
|
actor={"id": "root"},
|
|
data={"sql": "create table new_table (id integer primary key)"},
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
assert "Query executed" in response.text
|
|
budget.assert_only("db004")
|
|
# The catalog already has the new table
|
|
rows = await ds.get_internal_database().execute(
|
|
"select 1 from catalog_tables where database_name = 'db004' and table_name = 'new_table'"
|
|
)
|
|
assert rows.rows
|
|
ds.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_telemetry_metric_callbacks_open_nothing(db_files):
|
|
from datasette import telemetry
|
|
|
|
ds = _ds(db_files)
|
|
await ds.invoke_startup()
|
|
telemetry.register_datasette(ds)
|
|
try:
|
|
with connection_budget() as budget:
|
|
observations = 0
|
|
for callback in (
|
|
telemetry.observe_sql_thread_limit,
|
|
telemetry.observe_sql_thread_queue_depth,
|
|
telemetry.observe_pending_queries,
|
|
telemetry.observe_write_queue_depth,
|
|
telemetry.observe_open_connections,
|
|
):
|
|
observations += len(list(callback()))
|
|
assert observations
|
|
budget.assert_only(internal=False)
|
|
budget.assert_no_connections(internal=False)
|
|
finally:
|
|
telemetry.unregister_datasette(ds)
|
|
ds.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("num_sql_threads", [0, 3])
|
|
async def test_startup_opens_each_database_at_most_once(db_files, num_sql_threads):
|
|
imm = db_files[:5]
|
|
with connection_budget() as budget:
|
|
ds = Datasette(
|
|
db_files[5:],
|
|
immutables=imm,
|
|
settings={
|
|
"schema_watch_interval_ms": 0,
|
|
"num_sql_threads": num_sql_threads,
|
|
},
|
|
)
|
|
await ds._startup_sequence()
|
|
# One catalog scan connection per database, no pooled reads or writes
|
|
# (immutable table counts are no longer precomputed at startup)
|
|
assert set(budget.opened) - {INTERNAL, "_memory"} == {
|
|
f"db{i:03d}" for i in range(N)
|
|
}
|
|
assert all(
|
|
count == 1 for name, count in budget.opened.items() if name != INTERNAL
|
|
), budget.opened
|
|
assert set(budget.reads) <= {INTERNAL}, budget.reads
|
|
assert set(budget.writes) <= {INTERNAL}, budget.writes
|
|
assert not budget.attached
|
|
ds.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_startup_with_crossdb_attaches_nothing(db_files):
|
|
with connection_budget() as budget:
|
|
ds = Datasette(
|
|
db_files[:12],
|
|
settings={"schema_watch_interval_ms": 0},
|
|
crossdb=True,
|
|
)
|
|
await ds._startup_sequence()
|
|
assert not budget.attached
|
|
# A crossdb query still sees the attached databases
|
|
response = await ds.client.get(
|
|
"/_memory/-/query.json?sql=select+count(*)+from+db001.t1&_shape=array"
|
|
)
|
|
assert response.status_code == 200
|
|
ds.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_restart_with_persisted_catalog_opens_nothing(
|
|
db_files, tmp_path, monkeypatch
|
|
):
|
|
from datasette import schema_watcher
|
|
|
|
# The files' ctimes are recent (os.utime() in the fixture): without this
|
|
# their stored fingerprints would be racy, and so rescanned
|
|
monkeypatch.setattr(schema_watcher, "RACY_WINDOW_NS", 0)
|
|
internal = str(tmp_path / "internal.db")
|
|
ds = Datasette(db_files, internal=internal)
|
|
await ds.invoke_startup()
|
|
ds.close()
|
|
with connection_budget() as budget:
|
|
ds = Datasette(
|
|
db_files, internal=internal, settings={"schema_watch_interval_ms": 0}
|
|
)
|
|
await ds._startup_sequence()
|
|
response = await ds.client.get("/.json")
|
|
assert response.status_code == 200
|
|
budget.assert_only()
|
|
ds.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_idle_sweeps_open_nothing(db_files):
|
|
ds = _ds(db_files)
|
|
await ds.invoke_startup()
|
|
with connection_budget() as budget:
|
|
for _ in range(3):
|
|
await ds._refresh_schemas(background=True)
|
|
await ds.refresh_schemas(force=True)
|
|
budget.assert_only(internal=False)
|
|
budget.assert_no_connections(internal=False)
|
|
ds.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_failed_scan_is_not_retried_by_idle_sweeps(
|
|
db_files, tmp_path, monkeypatch
|
|
):
|
|
from datasette import schema_watcher
|
|
|
|
# The file was just written: do not wait out the racy window
|
|
monkeypatch.setattr(schema_watcher, "RACY_WINDOW_NS", 0)
|
|
bad = tmp_path / "bad.db"
|
|
bad.write_bytes(b"this is not a SQLite database" * 10)
|
|
old = time.time() - 60
|
|
os.utime(bad, (old, old))
|
|
ds = _ds(db_files[:3] + [str(bad)])
|
|
await ds.invoke_startup()
|
|
assert ds.get_database("bad")._watch_state.error is not None
|
|
with connection_budget() as budget:
|
|
for _ in range(3):
|
|
await ds._refresh_schemas(background=True)
|
|
assert "bad" not in budget.touched()
|
|
# ...until the file changes
|
|
good = sqlite3.connect(tmp_path / "good.db")
|
|
good.execute("create table fixed (id integer primary key)")
|
|
good.commit()
|
|
good.close()
|
|
os.replace(tmp_path / "good.db", bad)
|
|
await ds._refresh_schemas(background=True)
|
|
rows = await ds.get_internal_database().execute(
|
|
"select table_name from catalog_tables where database_name = 'bad'"
|
|
)
|
|
assert [r[0] for r in rows.rows] == ["fixed"]
|
|
ds.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_check_databases_reuses_catalog_scan(db_files, tmp_path):
|
|
from datasette.cli import check_databases
|
|
from datasette.utils import ConnectionProblem
|
|
|
|
ds = _ds(db_files)
|
|
with connection_budget() as budget:
|
|
await check_databases(ds)
|
|
# The catalog scan opened each database once; no second pass
|
|
assert all(count == 1 for name, count in budget.opened.items() if name != INTERNAL)
|
|
assert set(budget.reads) <= {INTERNAL, "_memory"}, budget.reads
|
|
ds.close()
|
|
|
|
bad = tmp_path / "bad.db"
|
|
bad.write_bytes(b"this is not a SQLite database" * 10)
|
|
ds = _ds(db_files[:3] + [str(bad)])
|
|
with pytest.raises(Exception) as excinfo:
|
|
await check_databases(ds)
|
|
assert "bad.db" in str(excinfo.value)
|
|
assert "file is not a database" in str(excinfo.value)
|
|
assert not isinstance(excinfo.value, ConnectionProblem)
|
|
ds.close()
|
|
|
|
|
|
# Parity: the catalog helpers answer exactly what the live helpers do
|
|
|
|
|
|
MISC_SQL = """
|
|
create table docs (id integer primary key, title text, body text);
|
|
create virtual table docs_fts using fts4(title, body, content="docs");
|
|
create virtual table docs_vocab using fts4aux(docs_fts);
|
|
create virtual table plain using fts5(x);
|
|
create virtual table geo using rtree(id, minx, maxx);
|
|
create table _private (x);
|
|
create table geometry_columns (f_table_name text);
|
|
create table idx_foo_bar (x);
|
|
create table IDXA (x);
|
|
create table parent (id integer primary key);
|
|
create table child (
|
|
id integer primary key,
|
|
p integer references parent(id),
|
|
q integer references missing(id)
|
|
);
|
|
create table comp (a, b, foreign key (a, b) references parent(id, id));
|
|
create table self_ref (id integer primary key, up integer references self_ref(id));
|
|
create table wide (b text, a integer, c, primary key (a, b));
|
|
create view v_docs as select * from docs;
|
|
"""
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("name", ["misc", "fixtures"])
|
|
async def test_catalog_helpers_match_live_introspection(tmp_path, name):
|
|
from datasette.fixtures import write_fixture_database
|
|
from datasette.utils.catalog import (
|
|
all_derived_table_dependencies,
|
|
catalog_all_foreign_keys,
|
|
)
|
|
|
|
path = str(tmp_path / f"{name}.db")
|
|
if name == "fixtures":
|
|
write_fixture_database(path)
|
|
config_hidden = set()
|
|
config = {}
|
|
else:
|
|
conn = sqlite3.connect(path)
|
|
conn.executescript(MISC_SQL)
|
|
conn.close()
|
|
config_hidden = {"docs"}
|
|
config = {"databases": {"misc": {"tables": {"docs": {"hidden": True}}}}}
|
|
ds = Datasette([path], config=config, settings={"schema_watch_interval_ms": 0})
|
|
await ds.invoke_startup()
|
|
db = ds.get_database(name)
|
|
tables = await db.table_names()
|
|
summary = (await catalog_summaries(ds, [name]))[name]
|
|
# hidden_table_names() also lists SpatiaLite names that do not exist
|
|
hidden = set(await db.hidden_table_names()) & set(tables)
|
|
assert {t for t in tables if summary.is_hidden(t, config_hidden)} == hidden
|
|
assert summary.views == set(await db.view_names())
|
|
|
|
details = await catalog_table_details(ds, [(name, t) for t in tables])
|
|
for table in tables:
|
|
assert details[(name, table)]["columns"] == await db.table_columns(table)
|
|
assert details[(name, table)]["primary_keys"] == await db.primary_keys(table)
|
|
assert details[(name, table)]["fts_table"] == await db.fts_table(table)
|
|
|
|
all_foreign_keys = await db.get_all_foreign_keys()
|
|
assert await catalog_all_foreign_keys(ds, name) == all_foreign_keys
|
|
relationships = (await catalog_relationship_counts(ds, [name])).get(name, {})
|
|
for table, fks in all_foreign_keys.items():
|
|
assert relationships.get(table, 0) == len(fks["incoming"] + fks["outgoing"])
|
|
|
|
derived = await all_derived_table_dependencies(ds)
|
|
live = await db.execute_fn(sqlite_derived_table_dependencies)
|
|
assert derived.get(name, {}) == live
|
|
# Restored from the catalog rather than remembered from the scan
|
|
db._cached_derived_table_dependencies = None
|
|
assert await db.derived_table_dependencies() == live
|
|
ds.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.skipif(not supports_table_list(), reason="needs PRAGMA table_list")
|
|
async def test_catalog_records_table_types(tmp_path):
|
|
path = str(tmp_path / "types.db")
|
|
conn = sqlite3.connect(path)
|
|
conn.executescript("""
|
|
create table plain (id integer primary key);
|
|
create virtual table f using fts5(x);
|
|
""")
|
|
conn.close()
|
|
ds = Datasette([path], settings={"schema_watch_interval_ms": 0})
|
|
await ds.invoke_startup()
|
|
rows = await ds.get_internal_database().execute(
|
|
"select table_name, type from catalog_tables where database_name = 'types'"
|
|
)
|
|
types = dict(rows.rows)
|
|
assert types["plain"] == "table"
|
|
assert types["f"] == "virtual"
|
|
assert types["f_data"] == "shadow"
|
|
ds.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_catalog_derived_dependencies_follow_catalog_changes(tmp_path):
|
|
path = str(tmp_path / "live.db")
|
|
sqlite3.connect(path).execute("create table docs (body text)").connection.close()
|
|
ds = Datasette([path], settings={"schema_watch_interval_ms": 0})
|
|
await ds.invoke_startup()
|
|
from datasette.utils.catalog import all_derived_table_dependencies
|
|
|
|
assert "live" not in await all_derived_table_dependencies(ds)
|
|
db = ds.get_database("live")
|
|
await db.execute_write(
|
|
"create virtual table docs_fts using fts5(body, content='docs')"
|
|
)
|
|
derived = await all_derived_table_dependencies(ds)
|
|
assert derived["live"]["docs_fts"] == "docs"
|
|
assert derived["live"]["docs_fts_data"] == "docs_fts"
|
|
ds.close()
|
|
|
|
|
|
def test_connection_budget_helper_counts(db_files):
|
|
# The helper itself: a direct execute_fn is a read, connect() an open
|
|
import asyncio
|
|
|
|
async def run():
|
|
ds = _ds(db_files[:2])
|
|
await ds.invoke_startup()
|
|
db = ds.get_database("db001")
|
|
async with connection_budget() as budget:
|
|
await db.execute("select 1")
|
|
conn = sqlite3.connect(":memory:")
|
|
conn.close()
|
|
assert budget.reads["db001"] == 1
|
|
assert budget.touched() == {"db001"}
|
|
with pytest.raises(AssertionError, match="db001"):
|
|
budget.assert_only("db000")
|
|
ds.close()
|
|
return budget
|
|
|
|
budget = asyncio.run(run())
|
|
assert sum(budget.probes.values()) == 1
|
|
assert json.dumps(budget.summary())
|