diff --git a/datasette/app.py b/datasette/app.py index 3a3efbe4..f3f0c387 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -17,6 +17,7 @@ from .utils import ( build_where_clauses, CustomJSONEncoder, escape_css_string, + escape_sqlite_table_name, InvalidSql, path_from_row_pks, path_with_added_args, @@ -117,8 +118,8 @@ class BaseView(HTTPMethodView): try: rows = conn.execute(sql, params or {}) except Exception: - print('sql = {}, params = {}'.format( - sql, params + print('ERROR: conn={}, sql = {}, params = {}'.format( + conn, repr(sql), params )) raise return rows @@ -381,8 +382,8 @@ class TableView(BaseView): if order_by: order_by = 'order by {} '.format(order_by) - sql = 'select {} from "{}" {}{}limit {}'.format( - select, table, where_clause, order_by, self.page_size + 1, + sql = 'select {} from {} {}{}limit {}'.format( + select, escape_sqlite_table_name(table), where_clause, order_by, self.page_size + 1, ) rows = await self.execute(name, sql, params) @@ -519,6 +520,8 @@ class Datasette: ]) ) self.jinja.add_env('escape_css_string', escape_css_string, 'filters') + self.jinja.add_env('quote_plus', lambda u: urllib.parse.quote_plus(u), 'filters') + self.jinja.add_env('escape_table_name', escape_sqlite_table_name, 'filters') app.add_route(IndexView.as_view(self), '/') # TODO: /favicon.ico and /-/static/ deserve far-future cache expires app.add_route(favicon, '/favicon.ico') diff --git a/datasette/templates/database.html b/datasette/templates/database.html index 11284340..aa53d602 100644 --- a/datasette/templates/database.html +++ b/datasette/templates/database.html @@ -2,6 +2,18 @@ {% block title %}{{ database }}{% endblock %} +{% block extra_head %} +{% if columns %} + +{% endif %} +{% endblock %} + {% block content %}
home{% if query %} / {{ database }}{% endif %}
@@ -14,13 +26,32 @@ {% endif %}
-

+

+{% if rows %} + + + + {% for column in columns %}{% endfor %} + + + + {% for row in rows %} + + {% for td in row %} + + {% endfor %} + + {% endfor %} + +
{{ column }}
{{ td or " " }}
+{% endif %} + {% for table in tables %}
-

{{ table.name }}

+

{{ table.name }}

{% for column in table.columns[:9] %}{{ column }}{% if not loop.last %}, {% endif %}{% endfor %}{% if table.columns|length > 9 %}...{% endif %}

{{ "{:,}".format(table.table_rows) }} row{% if table.table_rows == 1 %}{% else %}s{% endif %}

diff --git a/datasette/templates/index.html b/datasette/templates/index.html index e3acfdb4..beda106b 100644 --- a/datasette/templates/index.html +++ b/datasette/templates/index.html @@ -7,7 +7,7 @@ {% for database in databases %}

{{ database.name }}

{{ "{:,}".format(database.table_rows) }} rows in {{ database.tables_count }} table{% if database.tables_count != 1 %}s{% endif %}

-

{% for table, count in database.tables_truncated %}{{ table }}{% if not loop.last %}, {% endif %}{% endfor %}{% if database.tables_more %}, ...{% endif %}

+

{% for table, count in database.tables_truncated %}{{ table }}{% if not loop.last %}, {% endif %}{% endfor %}{% if database.tables_more %}, ...{% endif %}

{% endfor %} {% endblock %} diff --git a/datasette/templates/row.html b/datasette/templates/row.html index a82ccfc1..2d4ca39b 100644 --- a/datasette/templates/row.html +++ b/datasette/templates/row.html @@ -13,7 +13,7 @@ {% endblock %} {% block content %} -
home / {{ database }} / {{ table }}
+
home / {{ database }} / {{ table }}

{{ table }}: {{ ', '.join(primary_key_values) }}

diff --git a/datasette/templates/table.html b/datasette/templates/table.html index a57148d2..1d5f40a5 100644 --- a/datasette/templates/table.html +++ b/datasette/templates/table.html @@ -36,7 +36,7 @@ {% for row in rows %} - {% if not is_view %}{{ row_link(row) }}{% endif %} + {% if not is_view %}{{ row_link(row) }}{% endif %} {% for td in row %} {% if not use_rowid or (use_rowid and not loop.first) %} {{ td or " " }} diff --git a/datasette/utils.py b/datasette/utils.py index 87e5da8d..c518bc53 100644 --- a/datasette/utils.py +++ b/datasette/utils.py @@ -119,18 +119,26 @@ def path_with_ext(request, ext): _css_re = re.compile(r'''['"\n\\]''') +_boring_table_name_re = re.compile(r'^[a-zA-Z0-9_]+$') def escape_css_string(s): return _css_re.sub(lambda m: '\\{:X}'.format(ord(m.group())), s) +def escape_sqlite_table_name(s): + if _boring_table_name_re.match(s): + return s + else: + return '[{}]'.format(s) + + def make_dockerfile(files): return ''' FROM python:3 COPY . /app WORKDIR /app -RUN pip install https://static.simonwillison.net/static/2017/datasette-0.2-py3-none-any.whl +RUN pip install https://static.simonwillison.net/static/2017/datasette-0.4-py3-none-any.whl RUN datasette build_metadata {} --metadata metadata.json EXPOSE 8006 CMD ["datasette", "serve", {}, "--port", "8006", "--metadata", "metadata.json"]'''.format( diff --git a/setup.py b/setup.py index a20425cd..2f89f6d9 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup, find_packages setup( name='datasette', - version='0.2', + version='0.4', packages=find_packages(), package_data={'datasette': ['templates/*.html']}, include_package_data=True, diff --git a/tests/test_app.py b/tests/test_app.py index 4184e053..6bf880a2 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -8,9 +8,9 @@ import tempfile @pytest.fixture(scope='module') def app_client(): with tempfile.TemporaryDirectory() as tmpdir: - filepath = os.path.join(tmpdir, 'four_tables.db') + filepath = os.path.join(tmpdir, 'test_tables.db') conn = sqlite3.connect(filepath) - conn.executescript(FOUR_TABLES) + conn.executescript(TABLES) os.chdir(os.path.dirname(filepath)) yield Datasette([filepath]).app().test_client @@ -18,26 +18,26 @@ def app_client(): def test_homepage(app_client): _, response = app_client.get('/') assert response.status == 200 - assert 'four_tables' in response.text + assert 'test_tables' in response.text # Now try the JSON _, response = app_client.get('/.json') assert response.status == 200 - assert response.json.keys() == {'four_tables': 0}.keys() - d = response.json['four_tables'] - assert d['name'] == 'four_tables' - assert d['tables_count'] == 4 + assert response.json.keys() == {'test_tables': 0}.keys() + d = response.json['test_tables'] + assert d['name'] == 'test_tables' + assert d['tables_count'] == 5 def test_database_page(app_client): - _, response = app_client.get('/four_tables', allow_redirects=False) + _, response = app_client.get('/test_tables', allow_redirects=False) assert response.status == 302 - _, response = app_client.get('/four_tables') - assert 'four_tables' in response.text + _, response = app_client.get('/test_tables') + assert 'test_tables' in response.text # Test JSON list of tables - _, response = app_client.get('/four_tables.json') + _, response = app_client.get('/test_tables.json') data = response.json - assert 'four_tables' == data['database'] + assert 'test_tables' == data['database'] assert [{ 'columns': ['pk', 'content'], 'name': 'Table With Space In Name', @@ -54,12 +54,16 @@ def test_database_page(app_client): 'columns': ['pk', 'content'], 'name': 'simple_primary_key', 'table_rows': 2, + }, { + 'columns': ['pk', 'content'], + 'name': 'table/with/slashes.csv', + 'table_rows': 1, }] == data['tables'] def test_custom_sql(app_client): _, response = app_client.get( - '/four_tables.jsono?sql=select+content+from+simple_primary_key' + '/test_tables.jsono?sql=select+content+from+simple_primary_key' ) data = response.json assert { @@ -71,17 +75,17 @@ def test_custom_sql(app_client): {'content': 'world'} ] == data['rows'] assert ['content'] == data['columns'] - assert 'four_tables' == data['database'] + assert 'test_tables' == data['database'] def test_invalid_custom_sql(app_client): _, response = app_client.get( - '/four_tables?sql=.schema' + '/test_tables?sql=.schema' ) assert response.status == 400 assert 'Statement must begin with SELECT' in response.text _, response = app_client.get( - '/four_tables.json?sql=.schema' + '/test_tables.json?sql=.schema' ) assert response.status == 400 assert response.json['ok'] is False @@ -89,12 +93,12 @@ def test_invalid_custom_sql(app_client): def test_table_page(app_client): - _, response = app_client.get('/four_tables/simple_primary_key') + _, response = app_client.get('/test_tables/simple_primary_key') assert response.status == 200 - _, response = app_client.get('/four_tables/simple_primary_key.jsono') + _, response = app_client.get('/test_tables/simple_primary_key.jsono') assert response.status == 200 data = response.json - assert data['query']['sql'] == 'select * from "simple_primary_key" order by pk limit 51' + assert data['query']['sql'] == 'select * from simple_primary_key order by pk limit 51' assert data['query']['params'] == {} assert data['rows'] == [{ 'pk': '1', @@ -105,10 +109,22 @@ def test_table_page(app_client): }] -def test_view(app_client): - _, response = app_client.get('/four_tables/simple_view') +def test_table_with_slashes_in_name(app_client): + _, response = app_client.get('/test_tables/table%2Fwith%2Fslashes.csv') assert response.status == 200 - _, response = app_client.get('/four_tables/simple_view.jsono') + _, response = app_client.get('/test_tables/table%2Fwith%2Fslashes.csv.jsono') + assert response.status == 200 + data = response.json + assert data['rows'] == [{ + 'pk': '3', + 'content': 'hey', + }] + + +def test_view(app_client): + _, response = app_client.get('/test_tables/simple_view') + assert response.status == 200 + _, response = app_client.get('/test_tables/simple_view.jsono') assert response.status == 200 data = response.json assert data['rows'] == [{ @@ -120,7 +136,7 @@ def test_view(app_client): }] -FOUR_TABLES = ''' +TABLES = ''' CREATE TABLE simple_primary_key ( pk varchar(30) primary key, content text @@ -142,9 +158,16 @@ CREATE TABLE "Table With Space In Name" ( content text ); +CREATE TABLE "table/with/slashes.csv" ( + pk varchar(30) primary key, + content text +); + INSERT INTO simple_primary_key VALUES (1, 'hello'); INSERT INTO simple_primary_key VALUES (2, 'world'); +INSERT INTO [table/with/slashes.csv] VALUES (3, 'hey'); + CREATE VIEW simple_view AS SELECT content, upper(content) AS upper_content FROM simple_primary_key; '''