mirror of
https://github.com/simonw/datasette.git
synced 2026-09-18 22:44:07 +02:00
438 lines
14 KiB
Python
438 lines
14 KiB
Python
import re
|
|
from typing import Literal
|
|
|
|
using_pysqlite3 = False
|
|
try:
|
|
import pysqlite3 as sqlite3
|
|
|
|
using_pysqlite3 = True
|
|
except ImportError:
|
|
import sqlite3
|
|
|
|
if hasattr(sqlite3, "enable_callback_tracebacks"):
|
|
sqlite3.enable_callback_tracebacks(True)
|
|
|
|
_cached_sqlite_version = None
|
|
_cached_supports_returning = None
|
|
SQLiteTableType = Literal["table", "view", "virtual", "shadow"]
|
|
_SQLITE_IDENTIFIER_RE = (
|
|
r"""(?:"(?:[^"]|"")*"|'(?:[^']|'')*'|`(?:[^`]|``)*`|\[[^\]]*\]|[^\s.()'"`\[\]]+)"""
|
|
)
|
|
_VIRTUAL_TABLE_MODULE_RE = re.compile(
|
|
r"^\s*CREATE\s+VIRTUAL\s+TABLE\b\s*(?:IF\s+NOT\s+EXISTS\s+)?"
|
|
+ _SQLITE_IDENTIFIER_RE
|
|
+ r"(?:\s*\.\s*"
|
|
+ _SQLITE_IDENTIFIER_RE
|
|
+ r")?\s*\bUSING\b\s*("
|
|
+ _SQLITE_IDENTIFIER_RE
|
|
+ r")",
|
|
re.IGNORECASE | re.DOTALL,
|
|
)
|
|
_VIRTUAL_TABLE_SHADOW_SUFFIXES = {
|
|
"fts3": ("_content", "_segdir", "_segments", "_stat", "_docsize"),
|
|
"fts4": ("_content", "_segdir", "_segments", "_stat", "_docsize"),
|
|
"fts5": ("_data", "_idx", "_docsize", "_content", "_config"),
|
|
"rtree": ("_node", "_parent", "_rowid"),
|
|
"rtree_i32": ("_node", "_parent", "_rowid"),
|
|
}
|
|
|
|
|
|
def sqlite_version():
|
|
global _cached_sqlite_version
|
|
if _cached_sqlite_version is None:
|
|
_cached_sqlite_version = _sqlite_version()
|
|
return _cached_sqlite_version
|
|
|
|
|
|
def _sqlite_version():
|
|
conn = sqlite3.connect(":memory:")
|
|
try:
|
|
return tuple(
|
|
map(
|
|
int,
|
|
conn.execute("select sqlite_version()").fetchone()[0].split("."),
|
|
)
|
|
)
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def supports_table_xinfo():
|
|
return sqlite_version() >= (3, 26, 0)
|
|
|
|
|
|
def supports_table_list():
|
|
return sqlite_version() >= (3, 37, 0)
|
|
|
|
|
|
def supports_generated_columns():
|
|
return sqlite_version() >= (3, 31, 0)
|
|
|
|
|
|
def supports_returning():
|
|
global _cached_supports_returning
|
|
if _cached_supports_returning is None:
|
|
conn = sqlite3.connect(":memory:")
|
|
try:
|
|
conn.execute("create table t (id integer primary key)")
|
|
conn.execute("insert into t default values returning id").fetchone()
|
|
_cached_supports_returning = True
|
|
except sqlite3.DatabaseError:
|
|
_cached_supports_returning = False
|
|
finally:
|
|
conn.close()
|
|
return _cached_supports_returning
|
|
|
|
|
|
def sqlite_table_type(
|
|
conn,
|
|
table: str,
|
|
*,
|
|
schema: str | None = "main",
|
|
) -> SQLiteTableType | None:
|
|
if supports_table_list():
|
|
try:
|
|
# Use the "PRAGMA table_list" statement form rather than the
|
|
# pragma_table_list(...) table-valued function. The
|
|
# table-valued function is resolved like an ordinary relation
|
|
# name, so an attacker-created table or view literally named
|
|
# "pragma_table_list" can shadow it and spoof the reported
|
|
# type (e.g. claiming a virtual table is an ordinary table).
|
|
# The PRAGMA statement form is a distinct piece of SQL syntax
|
|
# that always invokes SQLite's built-in pragma, so it cannot
|
|
# be shadowed by a user-created relation.
|
|
if schema is not None:
|
|
query = f"PRAGMA {_quote_identifier(schema)}.table_list"
|
|
else:
|
|
query = "PRAGMA table_list"
|
|
cursor = conn.execute(query)
|
|
columns = [description[0] for description in cursor.description]
|
|
for row in cursor.fetchall():
|
|
record = dict(zip(columns, row))
|
|
if record.get("name") != table:
|
|
continue
|
|
if schema is not None and record.get("schema") != schema:
|
|
continue
|
|
row_type = record.get("type")
|
|
if row_type in {"table", "view", "virtual", "shadow"}:
|
|
return row_type
|
|
except sqlite3.DatabaseError:
|
|
pass
|
|
return _sqlite_table_type_from_schema(conn, table, schema=schema)
|
|
|
|
|
|
def check_structured_write_table(conn, table: str, *, allow_missing=False):
|
|
"""Validate a row-write target on the connection that will perform the write."""
|
|
# SQLite resolves identifiers case-insensitively. The create API must not
|
|
# treat a differently cased existing name as a missing table.
|
|
row = conn.execute(
|
|
"select name from main.sqlite_master where name = ? collate nocase "
|
|
"and type in ('table', 'view')",
|
|
(table,),
|
|
).fetchone()
|
|
if row is None and allow_missing:
|
|
return
|
|
if row is not None and sqlite_table_type(conn, row[0]) == "table":
|
|
return
|
|
# Virtual table modules can interpret row writes as administrative operations.
|
|
# Their shadow tables are internal storage, not independently writable data.
|
|
raise ValueError("Structured writes require an ordinary table")
|
|
|
|
|
|
def sqlite_hidden_table_names(conn, *, schema: str | None = "main") -> list[str]:
|
|
schema_table = _sqlite_schema_table(schema)
|
|
try:
|
|
rows = conn.execute(
|
|
f"select name, sql from {schema_table} where type = 'table'"
|
|
).fetchall()
|
|
except sqlite3.DatabaseError:
|
|
return []
|
|
hidden_tables = []
|
|
content_fts_tables = []
|
|
for name, sql in rows:
|
|
if (
|
|
name in {"sqlite_stat1", "sqlite_stat2", "sqlite_stat3", "sqlite_stat4"}
|
|
or name.startswith("_")
|
|
or sqlite_table_type(conn, name, schema=schema) == "shadow"
|
|
):
|
|
hidden_tables.append(name)
|
|
elif _is_fts_content_virtual_table(sql):
|
|
content_fts_tables.append(name)
|
|
return sorted(hidden_tables) + content_fts_tables
|
|
|
|
|
|
def sqlite_derived_table_dependencies(
|
|
conn, *, schema: str | None = "main"
|
|
) -> dict[str, str]:
|
|
"""Return implementation table -> logical/content table dependencies.
|
|
|
|
``PRAGMA table_list`` safely identifies virtual and shadow tables, but
|
|
does not report which virtual table owns a shadow table or which table is
|
|
named by an FTS ``content=`` option. Derive those relationships from
|
|
``sqlite_master`` DDL and the documented shadow-table suffixes.
|
|
|
|
Database errors propagate: failed discovery must not be mistaken for an
|
|
empty dependency map and cached as permission to skip inheritance.
|
|
"""
|
|
schema_table = _sqlite_schema_table(schema)
|
|
rows = conn.execute(
|
|
f"select name, sql from {schema_table} where type = 'table'"
|
|
).fetchall()
|
|
|
|
table_names = {row[0] for row in rows}
|
|
# SQLite identifiers fold ASCII letters only.
|
|
identifier_case = str.maketrans(
|
|
"ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz"
|
|
)
|
|
canonical_names = {name.translate(identifier_case): name for name in table_names}
|
|
dependencies = {}
|
|
for virtual_table, sql in rows:
|
|
module = _virtual_table_module(sql)
|
|
if module is None:
|
|
continue
|
|
|
|
# SQLite's documented shadow tables are implementation details of
|
|
# their logical virtual table.
|
|
for suffix in _VIRTUAL_TABLE_SHADOW_SUFFIXES.get(module, ()):
|
|
shadow_table = virtual_table + suffix
|
|
if shadow_table in table_names:
|
|
dependencies[shadow_table] = virtual_table
|
|
|
|
# An external-content FTS table can expose values fetched from its
|
|
# content table, so it must also depend on that table's permission.
|
|
if module in {"fts3", "fts4", "fts5"}:
|
|
content_table = _fts_external_content_table(sql)
|
|
if content_table:
|
|
dependencies[virtual_table] = content_table
|
|
|
|
if module in {"fts5vocab", "fts4aux"}:
|
|
source = _fts_vocabulary_source(sql, module, schema or "main")
|
|
source = (
|
|
canonical_names.get(source.translate(identifier_case))
|
|
if source
|
|
else None
|
|
)
|
|
# An unresolved source is itself derived, so the one-hop policy denies it.
|
|
dependencies[virtual_table] = source or virtual_table
|
|
|
|
return dependencies
|
|
|
|
|
|
def _sqlite_table_type_from_schema(
|
|
conn,
|
|
table: str,
|
|
*,
|
|
schema: str | None = "main",
|
|
) -> SQLiteTableType | None:
|
|
schema_table = _sqlite_schema_table(schema)
|
|
try:
|
|
row = conn.execute(
|
|
f"select type, sql from {schema_table} where name = ?",
|
|
(table,),
|
|
).fetchone()
|
|
except sqlite3.DatabaseError:
|
|
return None
|
|
if row is None:
|
|
return None
|
|
object_type, sql = row
|
|
if object_type == "view":
|
|
return "view"
|
|
if object_type != "table":
|
|
return None
|
|
if _virtual_table_module(sql) is not None:
|
|
return "virtual"
|
|
if _is_known_shadow_table(conn, table, schema=schema):
|
|
return "shadow"
|
|
return "table"
|
|
|
|
|
|
def _is_known_shadow_table(
|
|
conn,
|
|
table: str,
|
|
*,
|
|
schema: str | None = "main",
|
|
) -> bool:
|
|
schema_table = _sqlite_schema_table(schema)
|
|
try:
|
|
rows = conn.execute(
|
|
f"select name, sql from {schema_table} where type = 'table'"
|
|
).fetchall()
|
|
except sqlite3.DatabaseError:
|
|
return False
|
|
for virtual_table, sql in rows:
|
|
module = _virtual_table_module(sql)
|
|
if module is None:
|
|
continue
|
|
for suffix in _VIRTUAL_TABLE_SHADOW_SUFFIXES.get(module, ()):
|
|
if table == virtual_table + suffix:
|
|
return True
|
|
return False
|
|
|
|
|
|
def _sqlite_schema_table(schema: str | None) -> str:
|
|
if schema is None or schema == "main":
|
|
return "sqlite_master"
|
|
if schema == "temp":
|
|
return "sqlite_temp_master"
|
|
return f"{_quote_identifier(schema)}.sqlite_master"
|
|
|
|
|
|
def _quote_identifier(value: str) -> str:
|
|
return '"{}"'.format(value.replace('"', '""'))
|
|
|
|
|
|
def _virtual_table_module(sql: str | None) -> str | None:
|
|
if not sql:
|
|
return None
|
|
match = _VIRTUAL_TABLE_MODULE_RE.search(_strip_sql_comments(sql))
|
|
if match is None:
|
|
return None
|
|
return _unquote_sql_value(match.group(1)).lower()
|
|
|
|
|
|
def _fts_external_content_table(sql: str | None) -> str | None:
|
|
"""Extract the external ``content=`` table from an FTS declaration."""
|
|
if not sql:
|
|
return None
|
|
sql = _strip_sql_comments(sql)
|
|
match = _VIRTUAL_TABLE_MODULE_RE.search(sql)
|
|
if match is None:
|
|
return None
|
|
open_paren = sql.find("(", match.end())
|
|
if open_paren == -1:
|
|
return None
|
|
close_paren = sql.rfind(")")
|
|
if close_paren <= open_paren:
|
|
return None
|
|
|
|
for argument in _split_sql_arguments(sql[open_paren + 1 : close_paren]):
|
|
key, separator, value = argument.partition("=")
|
|
if not separator or key.strip().lower() != "content":
|
|
continue
|
|
return _unquote_sql_value(value.strip())
|
|
return None
|
|
|
|
|
|
def _fts_vocabulary_source(sql: str, module: str, schema: str) -> str | None:
|
|
"""Resolve a vocabulary source within the current SQLite schema.
|
|
|
|
Cross-schema sources cannot be represented by the dependency map and
|
|
are conservatively left unresolved.
|
|
"""
|
|
sql = _strip_sql_comments(sql)
|
|
match = _VIRTUAL_TABLE_MODULE_RE.search(sql)
|
|
if match is None:
|
|
return None
|
|
start = sql.find("(", match.end())
|
|
end = sql.rfind(")")
|
|
if start < 0 or end <= start:
|
|
return None
|
|
arguments = [
|
|
_unquote_sql_value(arg.strip())
|
|
for arg in _split_sql_arguments(sql[start + 1 : end])
|
|
]
|
|
expected = 2 if module == "fts5vocab" else 1
|
|
if len(arguments) == expected:
|
|
return arguments[0]
|
|
if len(arguments) == expected + 1 and arguments[0].lower() == schema.lower():
|
|
return arguments[1]
|
|
return None
|
|
|
|
|
|
def _split_sql_arguments(arguments: str) -> list[str]:
|
|
"""Split comma-separated SQLite arguments without splitting quoted text."""
|
|
parts = []
|
|
start = 0
|
|
quote = None
|
|
closing_quote = None
|
|
index = 0
|
|
while index < len(arguments):
|
|
char = arguments[index]
|
|
if quote is None:
|
|
if char in {"'", '"', "`", "["}:
|
|
quote = char
|
|
closing_quote = "]" if char == "[" else char
|
|
elif char == ",":
|
|
parts.append(arguments[start:index])
|
|
start = index + 1
|
|
elif char == closing_quote:
|
|
# Single/double/backtick quoting escapes the delimiter by
|
|
# doubling it. Square-bracket identifiers do not.
|
|
if (
|
|
quote != "["
|
|
and index + 1 < len(arguments)
|
|
and arguments[index + 1] == closing_quote
|
|
):
|
|
index += 1
|
|
else:
|
|
quote = None
|
|
closing_quote = None
|
|
index += 1
|
|
parts.append(arguments[start:])
|
|
return parts
|
|
|
|
|
|
def _strip_sql_comments(sql: str) -> str:
|
|
"""Remove SQLite comments while preserving quoted strings/identifiers."""
|
|
output = []
|
|
quote = None
|
|
closing_quote = None
|
|
index = 0
|
|
while index < len(sql):
|
|
char = sql[index]
|
|
next_char = sql[index + 1] if index + 1 < len(sql) else ""
|
|
if quote is None:
|
|
if char in {"'", '"', "`", "["}:
|
|
quote = char
|
|
closing_quote = "]" if char == "[" else char
|
|
output.append(char)
|
|
elif char == "-" and next_char == "-":
|
|
index += 2
|
|
while index < len(sql) and sql[index] not in "\r\n":
|
|
index += 1
|
|
output.append(" ")
|
|
continue
|
|
elif char == "/" and next_char == "*":
|
|
index += 2
|
|
while index + 1 < len(sql) and sql[index : index + 2] != "*/":
|
|
index += 1
|
|
index = min(index + 2, len(sql))
|
|
output.append(" ")
|
|
continue
|
|
else:
|
|
output.append(char)
|
|
else:
|
|
output.append(char)
|
|
if char == closing_quote:
|
|
if (
|
|
quote != "["
|
|
and index + 1 < len(sql)
|
|
and sql[index + 1] == closing_quote
|
|
):
|
|
output.append(sql[index + 1])
|
|
index += 1
|
|
else:
|
|
quote = None
|
|
closing_quote = None
|
|
index += 1
|
|
return "".join(output)
|
|
|
|
|
|
def _unquote_sql_value(value: str) -> str:
|
|
if len(value) < 2:
|
|
return value
|
|
pairs = {"'": "'", '"': '"', "`": "`", "[": "]"}
|
|
closing = pairs.get(value[0])
|
|
if closing is None or value[-1] != closing:
|
|
return value
|
|
unquoted = value[1:-1]
|
|
if value[0] != "[":
|
|
unquoted = unquoted.replace(closing * 2, closing)
|
|
return unquoted
|
|
|
|
|
|
def _is_fts_content_virtual_table(sql: str | None) -> bool:
|
|
return (
|
|
_virtual_table_module(sql) in {"fts3", "fts4", "fts5"}
|
|
and "content=" in sql.lower()
|
|
)
|