diff --git a/datasette/app.py b/datasette/app.py index f1278043..787cc991 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -50,6 +50,8 @@ class RenderMixin(HTTPMethodView): class BaseView(RenderMixin): + re_named_parameter = re.compile(':([a-zA-Z0-9_]+)') + def __init__(self, datasette): self.ds = datasette self.files = datasette.files @@ -258,6 +260,46 @@ class BaseView(RenderMixin): ) return r + async def custom_sql(self, request, name, hash, sql, editable=True, canned_query=None): + params = request.raw_args + if 'sql' in params: + params.pop('sql') + # Extract any :named parameters + named_parameters = self.re_named_parameter.findall(sql) + named_parameter_values = { + named_parameter: params.get(named_parameter) or '' + for named_parameter in named_parameters + } + + # Set to blank string if missing from params + for named_parameter in named_parameters: + if named_parameter not in params: + params[named_parameter] = '' + + extra_args = {} + if params.get('_sql_time_limit_ms'): + extra_args['custom_time_limit'] = int(params['_sql_time_limit_ms']) + rows, truncated, description = await self.execute( + name, sql, params, truncate=True, **extra_args + ) + columns = [r[0] for r in description] + return { + 'database': name, + 'rows': rows, + 'truncated': truncated, + 'columns': columns, + 'query': { + 'sql': sql, + 'params': params, + } + }, { + 'database_hash': hash, + 'custom_sql': True, + 'named_parameter_values': named_parameter_values, + 'editable': editable, + 'canned_query': canned_query, + }, ('query-{}.html'.format(to_css_class(name)), 'query.html') + class IndexView(RenderMixin): def __init__(self, datasette): @@ -315,12 +357,13 @@ async def favicon(request): class DatabaseView(BaseView): - re_named_parameter = re.compile(':([a-zA-Z0-9_]+)') - async def data(self, request, name, hash): if request.args.get('sql'): - return await self.custom_sql(request, name, hash) + sql = request.raw_args.pop('sql') + validate_sql_select(sql) + return await self.custom_sql(request, name, hash, sql) info = self.ds.inspect()[name] + metadata = self.ds.metadata.get('databases', {}).get(name, {}) tables = list(info['tables'].values()) tables.sort(key=lambda t: (t['hidden'], t['name'])) return { @@ -328,48 +371,14 @@ class DatabaseView(BaseView): 'tables': tables, 'hidden_count': len([t for t in tables if t['hidden']]), 'views': info['views'], + 'queries': [{ + 'name': query_name, + 'sql': query_sql, + } for query_name, query_sql in (metadata.get('queries') or {}).items()], }, { 'database_hash': hash, 'show_hidden': request.args.get('_show_hidden'), - }, ('database-{}.html'.format(to_css_class(name)), 'database.html') - - async def custom_sql(self, request, name, hash): - params = request.raw_args - sql = params.pop('sql') - validate_sql_select(sql) - - # Extract any :named parameters - named_parameters = self.re_named_parameter.findall(sql) - named_parameter_values = { - named_parameter: params.get(named_parameter) or '' - for named_parameter in named_parameters - } - - # Set to blank string if missing from params - for named_parameter in named_parameters: - if named_parameter not in params: - params[named_parameter] = '' - - extra_args = {} - if params.get('_sql_time_limit_ms'): - extra_args['custom_time_limit'] = int(params['_sql_time_limit_ms']) - rows, truncated, description = await self.execute( - name, sql, params, truncate=True, **extra_args - ) - columns = [r[0] for r in description] - return { - 'database': name, - 'rows': rows, - 'truncated': truncated, - 'columns': columns, - 'query': { - 'sql': sql, - 'params': params, - } - }, { - 'database_hash': hash, - 'custom_sql': True, - 'named_parameter_values': named_parameter_values, + 'editable': True, }, ('database-{}.html'.format(to_css_class(name)), 'database.html') @@ -466,6 +475,9 @@ class RowTableShared(BaseView): class TableView(RowTableShared): async def data(self, request, name, hash, table): table = urllib.parse.unquote_plus(table) + canned_query = self.ds.get_canned_query(name, table) + if canned_query is not None: + return await self.custom_sql(request, name, hash, canned_query['sql'], editable=False, canned_query=table) pks = await self.pks_for_table(name, table) is_view = bool(list(await self.execute(name, "SELECT count(*) from sqlite_master WHERE type = 'view' and name=:n", { 'n': table, @@ -792,6 +804,20 @@ class Datasette: self.template_dir = template_dir self.static_mounts = static_mounts or [] + def get_canned_query(self, database_name, query_name): + query = self.metadata.get( + 'databases', {} + ).get( + database_name, {} + ).get( + 'queries', {} + ).get(query_name) + if query: + return { + 'name': query_name, + 'sql': query, + } + def asset_urls(self, key): for url_or_dict in (self.metadata.get(key) or []): if isinstance(url_or_dict, dict): diff --git a/datasette/templates/_codemirror.html b/datasette/templates/_codemirror.html new file mode 100644 index 00000000..237d6907 --- /dev/null +++ b/datasette/templates/_codemirror.html @@ -0,0 +1,7 @@ + + + + diff --git a/datasette/templates/_codemirror_foot.html b/datasette/templates/_codemirror_foot.html new file mode 100644 index 00000000..1e07fc72 --- /dev/null +++ b/datasette/templates/_codemirror_foot.html @@ -0,0 +1,13 @@ + diff --git a/datasette/templates/database.html b/datasette/templates/database.html index c1876ea9..bbab1668 100644 --- a/datasette/templates/database.html +++ b/datasette/templates/database.html @@ -3,63 +3,22 @@ {% block title %}{{ database }}{% endblock %} {% block extra_head %} -{% if columns %} - -{% endif %} - - - - +{% include "_codemirror.html" %} {% endblock %} {% block body_class %}db db-{{ database|to_css_class }}{% endblock %} {% block content %} -
home{% if query %} / {{ database }}{% endif %}
+
home

{{ database }}

-

Custom SQL query{% if rows %} returning {% if truncated %}more than {% endif %}{{ "{:,}".format(rows|length) }} row{% if rows|length == 1 %}{% else %}s{% endif %}{% endif %}

-

- {% if named_parameter_values %} -

Query parameters

- {% for name, value in named_parameter_values.items() %} -

- {% endfor %} - {% endif %} +

Custom SQL query

+

-{% if rows %} -

This data as .json, .jsono

- - - - {% for column in columns %}{% endfor %} - - - - {% for row in rows %} - - {% for td in row %} - - {% endfor %} - - {% endfor %} - -
{{ column }}
{% if td == None %}{{ " "|safe }}{% else %}{{ td }}{% endif %}
-{% endif %} - {% for table in tables %} {% if show_hidden or not table.hidden %}
@@ -83,20 +42,17 @@ {% endif %} +{% if queries %} +

Queries

+ +{% endif %} +

Download SQLite DB: {{ database }}.db

- +{% include "_codemirror_foot.html" %} {% endblock %} diff --git a/datasette/templates/query.html b/datasette/templates/query.html new file mode 100644 index 00000000..d8bbba7c --- /dev/null +++ b/datasette/templates/query.html @@ -0,0 +1,63 @@ +{% extends "base.html" %} + +{% block title %}{{ database }}{% if query and query.sql %}{{ query.sql }}{% endif %}{% endblock %} + +{% block extra_head %} +{% if columns %} + +{% endif %} +{% include "_codemirror.html" %} +{% endblock %} + +{% block body_class %}query db-{{ database|to_css_class }}{% endblock %} + +{% block content %} +
home / {{ database }}
+ +

{{ database }}

+ +
+

Custom SQL query{% if rows %} returning {% if truncated %}more than {% endif %}{{ "{:,}".format(rows|length) }} row{% if rows|length == 1 %}{% else %}s{% endif %}{% endif %}

+ {% if editable %} +

+ {% else %} +
{% if query %}{{ query.sql }}{% endif %}
+ {% endif %} + {% if named_parameter_values %} +

Query parameters

+ {% for name, value in named_parameter_values.items() %} +

+ {% endfor %} + {% endif %} +

+
+ +{% if rows %} +

This data as .json, .jsono

+ + + + {% for column in columns %}{% endfor %} + + + + {% for row in rows %} + + {% for td in row %} + + {% endfor %} + + {% endfor %} + +
{{ column }}
{% if td == None %}{{ " "|safe }}{% else %}{{ td }}{% endif %}
+{% endif %} + +{% include "_codemirror_foot.html" %} + +{% endblock %}