mirror of
https://github.com/simonw/datasette.git
synced 2025-12-10 16:51:24 +01:00
parent
81df47e8d9
commit
50920cfe3d
9 changed files with 177 additions and 55 deletions
|
|
@ -71,6 +71,15 @@ CONFIG_OPTIONS = (
|
||||||
ConfigOption("facet_suggest_time_limit_ms", 50, """
|
ConfigOption("facet_suggest_time_limit_ms", 50, """
|
||||||
Time limit for calculating a suggested facet
|
Time limit for calculating a suggested facet
|
||||||
""".strip()),
|
""".strip()),
|
||||||
|
ConfigOption("allow_facet", True, """
|
||||||
|
Allow users to specify columns to facet using ?_facet= parameter
|
||||||
|
""".strip()),
|
||||||
|
ConfigOption("allow_download", True, """
|
||||||
|
Allow users to download the original SQLite database files
|
||||||
|
""".strip()),
|
||||||
|
ConfigOption("suggest_facets", True, """
|
||||||
|
Calculate and display suggested facets
|
||||||
|
""".strip()),
|
||||||
)
|
)
|
||||||
DEFAULT_CONFIG = {
|
DEFAULT_CONFIG = {
|
||||||
option.name: option.default
|
option.name: option.default
|
||||||
|
|
|
||||||
|
|
@ -28,21 +28,35 @@ class StaticMount(click.ParamType):
|
||||||
class Config(click.ParamType):
|
class Config(click.ParamType):
|
||||||
name = "config"
|
name = "config"
|
||||||
|
|
||||||
def convert(self, value, param, ctx):
|
def convert(self, config, param, ctx):
|
||||||
ok = True
|
if ":" not in config:
|
||||||
if ":" not in value:
|
|
||||||
ok = False
|
|
||||||
else:
|
|
||||||
name, intvalue = value.split(":")
|
|
||||||
ok = intvalue.isdigit()
|
|
||||||
if not ok:
|
|
||||||
self.fail(
|
self.fail(
|
||||||
'"{}" should be of format name:integer'.format(value),
|
'"{}" should be name:value'.format(config), param, ctx
|
||||||
param, ctx
|
|
||||||
)
|
)
|
||||||
|
return
|
||||||
|
name, value = config.split(":")
|
||||||
if name not in DEFAULT_CONFIG:
|
if name not in DEFAULT_CONFIG:
|
||||||
self.fail("{} is not a valid limit".format(name), param, ctx)
|
self.fail("{} is not a valid option".format(name), param, ctx)
|
||||||
return name, int(intvalue)
|
return
|
||||||
|
# Type checking
|
||||||
|
default = DEFAULT_CONFIG[name]
|
||||||
|
if isinstance(default, bool):
|
||||||
|
if value not in ('on', 'off', 'true', 'false', '1', '0'):
|
||||||
|
self.fail(
|
||||||
|
'"{}" should be on/off/true/false'.format(name), param, ctx
|
||||||
|
)
|
||||||
|
return
|
||||||
|
return name, value in ('on', 'true', '1')
|
||||||
|
elif isinstance(default, int):
|
||||||
|
if not value.isdigit():
|
||||||
|
self.fail(
|
||||||
|
'"{}" should be an integer'.format(name), param, ctx
|
||||||
|
)
|
||||||
|
return
|
||||||
|
return name, int(value)
|
||||||
|
else:
|
||||||
|
# Should never happen:
|
||||||
|
self.fail('Invalid option')
|
||||||
|
|
||||||
|
|
||||||
@click.group(cls=DefaultGroup, default="serve", default_if_no_args=True)
|
@click.group(cls=DefaultGroup, default="serve", default_if_no_args=True)
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,9 @@
|
||||||
</ul>
|
</ul>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<p>Download SQLite DB: <a href="/{{ database }}-{{ database_hash }}.db">{{ database }}.db</a></p>
|
{% if config.allow_download %}
|
||||||
|
<p>Download SQLite DB: <a href="/{{ database }}-{{ database_hash }}.db">{{ database }}.db</a></p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% include "_codemirror_foot.html" %}
|
{% include "_codemirror_foot.html" %}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ from sanic import response
|
||||||
|
|
||||||
from datasette.utils import to_css_class, validate_sql_select
|
from datasette.utils import to_css_class, validate_sql_select
|
||||||
|
|
||||||
from .base import BaseView
|
from .base import BaseView, DatasetteError
|
||||||
|
|
||||||
|
|
||||||
class DatabaseView(BaseView):
|
class DatabaseView(BaseView):
|
||||||
|
|
@ -29,6 +29,7 @@ class DatabaseView(BaseView):
|
||||||
{"name": query_name, "sql": query_sql}
|
{"name": query_name, "sql": query_sql}
|
||||||
for query_name, query_sql in (metadata.get("queries") or {}).items()
|
for query_name, query_sql in (metadata.get("queries") or {}).items()
|
||||||
],
|
],
|
||||||
|
"config": self.ds.config,
|
||||||
}, {
|
}, {
|
||||||
"database_hash": hash,
|
"database_hash": hash,
|
||||||
"show_hidden": request.args.get("_show_hidden"),
|
"show_hidden": request.args.get("_show_hidden"),
|
||||||
|
|
@ -42,6 +43,8 @@ class DatabaseView(BaseView):
|
||||||
class DatabaseDownload(BaseView):
|
class DatabaseDownload(BaseView):
|
||||||
|
|
||||||
async def view_get(self, request, name, hash, **kwargs):
|
async def view_get(self, request, name, hash, **kwargs):
|
||||||
|
if not self.ds.config["allow_download"]:
|
||||||
|
raise DatasetteError("Database download is forbidden", status=403)
|
||||||
filepath = self.ds.inspect()[name]["file"]
|
filepath = self.ds.inspect()[name]["file"]
|
||||||
return await response.file_stream(
|
return await response.file_stream(
|
||||||
filepath,
|
filepath,
|
||||||
|
|
|
||||||
|
|
@ -542,6 +542,8 @@ class TableView(RowTableShared):
|
||||||
facet_size = self.ds.config["default_facet_size"]
|
facet_size = self.ds.config["default_facet_size"]
|
||||||
metadata_facets = table_metadata.get("facets", [])
|
metadata_facets = table_metadata.get("facets", [])
|
||||||
facets = metadata_facets[:]
|
facets = metadata_facets[:]
|
||||||
|
if request.args.get("_facet") and not self.ds.config["allow_facet"]:
|
||||||
|
raise DatasetteError("_facet= is not allowed", status=400)
|
||||||
try:
|
try:
|
||||||
facets.extend(request.args["_facet"])
|
facets.extend(request.args["_facet"])
|
||||||
except KeyError:
|
except KeyError:
|
||||||
|
|
@ -650,41 +652,44 @@ class TableView(RowTableShared):
|
||||||
|
|
||||||
# Detect suggested facets
|
# Detect suggested facets
|
||||||
suggested_facets = []
|
suggested_facets = []
|
||||||
for facet_column in columns:
|
if self.ds.config["suggest_facets"] and self.ds.config["allow_facet"]:
|
||||||
if facet_column in facets:
|
for facet_column in columns:
|
||||||
continue
|
if facet_column in facets:
|
||||||
suggested_facet_sql = '''
|
continue
|
||||||
select distinct {column} {from_sql}
|
if not self.ds.config["suggest_facets"]:
|
||||||
{and_or_where} {column} is not null
|
continue
|
||||||
limit {limit}
|
suggested_facet_sql = '''
|
||||||
'''.format(
|
select distinct {column} {from_sql}
|
||||||
column=escape_sqlite(facet_column),
|
{and_or_where} {column} is not null
|
||||||
from_sql=from_sql,
|
limit {limit}
|
||||||
and_or_where='and' if from_sql_where_clauses else 'where',
|
'''.format(
|
||||||
limit=facet_size+1
|
column=escape_sqlite(facet_column),
|
||||||
)
|
from_sql=from_sql,
|
||||||
distinct_values = None
|
and_or_where='and' if from_sql_where_clauses else 'where',
|
||||||
try:
|
limit=facet_size+1
|
||||||
distinct_values = await self.ds.execute(
|
|
||||||
name, suggested_facet_sql, from_sql_params,
|
|
||||||
truncate=False,
|
|
||||||
custom_time_limit=self.ds.config["facet_suggest_time_limit_ms"],
|
|
||||||
)
|
)
|
||||||
num_distinct_values = len(distinct_values)
|
distinct_values = None
|
||||||
if (
|
try:
|
||||||
num_distinct_values and
|
distinct_values = await self.ds.execute(
|
||||||
num_distinct_values > 1 and
|
name, suggested_facet_sql, from_sql_params,
|
||||||
num_distinct_values <= facet_size and
|
truncate=False,
|
||||||
num_distinct_values < filtered_table_rows_count
|
custom_time_limit=self.ds.config["facet_suggest_time_limit_ms"],
|
||||||
):
|
)
|
||||||
suggested_facets.append({
|
num_distinct_values = len(distinct_values)
|
||||||
'name': facet_column,
|
if (
|
||||||
'toggle_url': path_with_added_args(
|
num_distinct_values and
|
||||||
request, {'_facet': facet_column}
|
num_distinct_values > 1 and
|
||||||
),
|
num_distinct_values <= facet_size and
|
||||||
})
|
num_distinct_values < filtered_table_rows_count
|
||||||
except InterruptedError:
|
):
|
||||||
pass
|
suggested_facets.append({
|
||||||
|
'name': facet_column,
|
||||||
|
'toggle_url': path_with_added_args(
|
||||||
|
request, {'_facet': facet_column}
|
||||||
|
),
|
||||||
|
})
|
||||||
|
except InterruptedError:
|
||||||
|
pass
|
||||||
|
|
||||||
# human_description_en combines filters AND search, if provided
|
# human_description_en combines filters AND search, if provided
|
||||||
human_description_en = filters.human_description_en(extra=search_descriptions)
|
human_description_en = filters.human_description_en(extra=search_descriptions)
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@ The default number of rows returned by the table page. You can over-ride this on
|
||||||
|
|
||||||
datasette mydatabase.db --config default_page_size:50
|
datasette mydatabase.db --config default_page_size:50
|
||||||
|
|
||||||
|
|
||||||
sql_time_limit_ms
|
sql_time_limit_ms
|
||||||
-----------------
|
-----------------
|
||||||
|
|
||||||
|
|
@ -39,6 +38,17 @@ You can increase or decrease this limit like so::
|
||||||
|
|
||||||
datasette mydatabase.db --config max_returned_rows:2000
|
datasette mydatabase.db --config max_returned_rows:2000
|
||||||
|
|
||||||
|
allow_facet
|
||||||
|
-----------
|
||||||
|
|
||||||
|
Allow users to specify columns they would like to facet on using the ``?_facet=COLNAME`` URL parameter to the table view.
|
||||||
|
|
||||||
|
This is enabled by default. If disabled, facets will still be displayed if they have been specifically enabled in ``metadata.json`` configuration for the table.
|
||||||
|
|
||||||
|
Here's how to disable this feature::
|
||||||
|
|
||||||
|
datasette mydatabase.db --config allow_facet:off
|
||||||
|
|
||||||
default_facet_size
|
default_facet_size
|
||||||
------------------
|
------------------
|
||||||
|
|
||||||
|
|
@ -61,3 +71,17 @@ When Datasette calculates suggested facets it needs to run a SQL query for every
|
||||||
You can increase this time limit like so::
|
You can increase this time limit like so::
|
||||||
|
|
||||||
datasette mydatabase.db --config facet_suggest_time_limit_ms:500
|
datasette mydatabase.db --config facet_suggest_time_limit_ms:500
|
||||||
|
|
||||||
|
suggest_facets
|
||||||
|
--------------
|
||||||
|
|
||||||
|
Should Datasette calculate suggested facets? On by default, turn this off like so::
|
||||||
|
|
||||||
|
datasette mydatabase.db --config suggest_facets:off
|
||||||
|
|
||||||
|
allow_download
|
||||||
|
--------------
|
||||||
|
|
||||||
|
Should users be able to download the original SQLite database using a link on the database index page? This is turned on by default - to disable database downloads, use the following::
|
||||||
|
|
||||||
|
datasette mydatabase.db --config allow_download:off
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import tempfile
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
|
||||||
def app_client(sql_time_limit_ms=None, max_returned_rows=None):
|
def app_client(sql_time_limit_ms=None, max_returned_rows=None, config=None):
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
filepath = os.path.join(tmpdir, 'test_tables.db')
|
filepath = os.path.join(tmpdir, 'test_tables.db')
|
||||||
conn = sqlite3.connect(filepath)
|
conn = sqlite3.connect(filepath)
|
||||||
|
|
@ -18,15 +18,17 @@ def app_client(sql_time_limit_ms=None, max_returned_rows=None):
|
||||||
plugins_dir = os.path.join(tmpdir, 'plugins')
|
plugins_dir = os.path.join(tmpdir, 'plugins')
|
||||||
os.mkdir(plugins_dir)
|
os.mkdir(plugins_dir)
|
||||||
open(os.path.join(plugins_dir, 'my_plugin.py'), 'w').write(PLUGIN)
|
open(os.path.join(plugins_dir, 'my_plugin.py'), 'w').write(PLUGIN)
|
||||||
|
config = config or {}
|
||||||
|
config.update({
|
||||||
|
'default_page_size': 50,
|
||||||
|
'max_returned_rows': max_returned_rows or 100,
|
||||||
|
'sql_time_limit_ms': sql_time_limit_ms or 200,
|
||||||
|
})
|
||||||
ds = Datasette(
|
ds = Datasette(
|
||||||
[filepath],
|
[filepath],
|
||||||
metadata=METADATA,
|
metadata=METADATA,
|
||||||
plugins_dir=plugins_dir,
|
plugins_dir=plugins_dir,
|
||||||
config={
|
config=config,
|
||||||
'default_page_size': 50,
|
|
||||||
'max_returned_rows': max_returned_rows or 100,
|
|
||||||
'sql_time_limit_ms': sql_time_limit_ms or 200,
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
ds.sqlite_functions.append(
|
ds.sqlite_functions.append(
|
||||||
('sleep', 1, lambda n: time.sleep(float(n))),
|
('sleep', 1, lambda n: time.sleep(float(n))),
|
||||||
|
|
|
||||||
|
|
@ -914,6 +914,9 @@ def test_config_json(app_client):
|
||||||
"facet_time_limit_ms": 200,
|
"facet_time_limit_ms": 200,
|
||||||
"max_returned_rows": 100,
|
"max_returned_rows": 100,
|
||||||
"sql_time_limit_ms": 200,
|
"sql_time_limit_ms": 200,
|
||||||
|
"allow_download": True,
|
||||||
|
"allow_facet": True,
|
||||||
|
"suggest_facets": True
|
||||||
} == response.json
|
} == response.json
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1080,3 +1083,36 @@ def test_facets(app_client, path, expected_facet_results):
|
||||||
for facet_value in facet_info["results"]:
|
for facet_value in facet_info["results"]:
|
||||||
facet_value['toggle_url'] = facet_value['toggle_url'].split('?')[1]
|
facet_value['toggle_url'] = facet_value['toggle_url'].split('?')[1]
|
||||||
assert expected_facet_results == facet_results
|
assert expected_facet_results == facet_results
|
||||||
|
|
||||||
|
|
||||||
|
def test_suggested_facets(app_client):
|
||||||
|
assert len(app_client.get(
|
||||||
|
"/test_tables/facetable.json",
|
||||||
|
gather_request=False
|
||||||
|
).json["suggested_facets"]) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_allow_facet_off():
|
||||||
|
for client in app_client(config={
|
||||||
|
'allow_facet': False,
|
||||||
|
}):
|
||||||
|
assert 400 == client.get(
|
||||||
|
"/test_tables/facetable.json?_facet=planet_int",
|
||||||
|
gather_request=False
|
||||||
|
).status
|
||||||
|
# Should not suggest any facets either:
|
||||||
|
assert [] == client.get(
|
||||||
|
"/test_tables/facetable.json",
|
||||||
|
gather_request=False
|
||||||
|
).json["suggested_facets"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_suggest_facets_off():
|
||||||
|
for client in app_client(config={
|
||||||
|
'suggest_facets': False,
|
||||||
|
}):
|
||||||
|
# Now suggested_facets should be []
|
||||||
|
assert [] == client.get(
|
||||||
|
"/test_tables/facetable.json",
|
||||||
|
gather_request=False
|
||||||
|
).json["suggested_facets"]
|
||||||
|
|
|
||||||
|
|
@ -468,6 +468,33 @@ def test_table_metadata(app_client):
|
||||||
assert_footer_links(soup)
|
assert_footer_links(soup)
|
||||||
|
|
||||||
|
|
||||||
|
def test_allow_download_on(app_client):
|
||||||
|
response = app_client.get(
|
||||||
|
"/test_tables",
|
||||||
|
gather_request=False
|
||||||
|
)
|
||||||
|
soup = Soup(response.body, 'html.parser')
|
||||||
|
assert len(soup.findAll('a', {'href': re.compile('\.db$')}))
|
||||||
|
|
||||||
|
|
||||||
|
def test_allow_download_off():
|
||||||
|
for client in app_client(config={
|
||||||
|
'allow_download': False,
|
||||||
|
}):
|
||||||
|
response = client.get(
|
||||||
|
"/test_tables",
|
||||||
|
gather_request=False
|
||||||
|
)
|
||||||
|
soup = Soup(response.body, 'html.parser')
|
||||||
|
assert not len(soup.findAll('a', {'href': re.compile('\.db$')}))
|
||||||
|
# Accessing URL directly should 403
|
||||||
|
response = client.get(
|
||||||
|
"/test_tables.db",
|
||||||
|
gather_request=False
|
||||||
|
)
|
||||||
|
assert 403 == response.status
|
||||||
|
|
||||||
|
|
||||||
def assert_querystring_equal(expected, actual):
|
def assert_querystring_equal(expected, actual):
|
||||||
assert sorted(expected.split('&')) == sorted(actual.split('&'))
|
assert sorted(expected.split('&')) == sorted(actual.split('&'))
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue