mirror of
https://github.com/simonw/datasette.git
synced 2025-12-10 16:51:24 +01:00
Handle table names with slashes in them
e.g. https://datasette-wdlexdiaoz.now.sh/fivethirtyeight-75d605c/bob-ross%2Felements-by-episode.csv?CABIN=1&BUSHES=1&CLOUDS=1
This commit is contained in:
parent
4c66097d58
commit
26370b14d8
8 changed files with 98 additions and 33 deletions
|
|
@ -17,6 +17,7 @@ from .utils import (
|
||||||
build_where_clauses,
|
build_where_clauses,
|
||||||
CustomJSONEncoder,
|
CustomJSONEncoder,
|
||||||
escape_css_string,
|
escape_css_string,
|
||||||
|
escape_sqlite_table_name,
|
||||||
InvalidSql,
|
InvalidSql,
|
||||||
path_from_row_pks,
|
path_from_row_pks,
|
||||||
path_with_added_args,
|
path_with_added_args,
|
||||||
|
|
@ -117,8 +118,8 @@ class BaseView(HTTPMethodView):
|
||||||
try:
|
try:
|
||||||
rows = conn.execute(sql, params or {})
|
rows = conn.execute(sql, params or {})
|
||||||
except Exception:
|
except Exception:
|
||||||
print('sql = {}, params = {}'.format(
|
print('ERROR: conn={}, sql = {}, params = {}'.format(
|
||||||
sql, params
|
conn, repr(sql), params
|
||||||
))
|
))
|
||||||
raise
|
raise
|
||||||
return rows
|
return rows
|
||||||
|
|
@ -381,8 +382,8 @@ class TableView(BaseView):
|
||||||
if order_by:
|
if order_by:
|
||||||
order_by = 'order by {} '.format(order_by)
|
order_by = 'order by {} '.format(order_by)
|
||||||
|
|
||||||
sql = 'select {} from "{}" {}{}limit {}'.format(
|
sql = 'select {} from {} {}{}limit {}'.format(
|
||||||
select, table, where_clause, order_by, self.page_size + 1,
|
select, escape_sqlite_table_name(table), where_clause, order_by, self.page_size + 1,
|
||||||
)
|
)
|
||||||
|
|
||||||
rows = await self.execute(name, sql, params)
|
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('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), '/<as_json:(.jsono?)?$>')
|
app.add_route(IndexView.as_view(self), '/<as_json:(.jsono?)?$>')
|
||||||
# TODO: /favicon.ico and /-/static/ deserve far-future cache expires
|
# TODO: /favicon.ico and /-/static/ deserve far-future cache expires
|
||||||
app.add_route(favicon, '/favicon.ico')
|
app.add_route(favicon, '/favicon.ico')
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,18 @@
|
||||||
|
|
||||||
{% block title %}{{ database }}{% endblock %}
|
{% block title %}{{ database }}{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_head %}
|
||||||
|
{% if columns %}
|
||||||
|
<style>
|
||||||
|
@media only screen and (max-width: 576px) {
|
||||||
|
{% for column in columns %}
|
||||||
|
td:nth-of-type({{ loop.index }}):before { content: "{{ column|escape_css_string }}"; }
|
||||||
|
{% endfor %}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="hd"><a href="/">home</a>{% if query %} / <a href="/{{ database }}-{{ database_hash }}">{{ database }}</a>{% endif %}</div>
|
<div class="hd"><a href="/">home</a>{% if query %} / <a href="/{{ database }}-{{ database_hash }}">{{ database }}</a>{% endif %}</div>
|
||||||
|
|
||||||
|
|
@ -14,13 +26,32 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<form class="sql" action="/{{ database }}-{{ database_hash }}" method="get">
|
<form class="sql" action="/{{ database }}-{{ database_hash }}" method="get">
|
||||||
<p><textarea name="sql">{% if query and query.sql %}{{ query.sql }}{% else %}select * from {{ tables[0].name }}{% endif %}</textarea></p>
|
<p><textarea name="sql">{% if query and query.sql %}{{ query.sql }}{% else %}select * from {{ tables[0].name|escape_table_name }}{% endif %}</textarea></p>
|
||||||
<p><input type="submit" value="Run SQL"></p>
|
<p><input type="submit" value="Run SQL"></p>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{% if rows %}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{% for column in columns %}<th scope="col">{{ column }}</th>{% endfor %}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in rows %}
|
||||||
|
<tr>
|
||||||
|
{% for td in row %}
|
||||||
|
<td>{{ td or " " }}</td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% for table in tables %}
|
{% for table in tables %}
|
||||||
<div class="db-table">
|
<div class="db-table">
|
||||||
<h2><a href="/{{ database }}-{{ database_hash }}/{{ table.name|urlencode }}">{{ table.name }}</a></h2>
|
<h2><a href="/{{ database }}-{{ database_hash }}/{{ table.name|quote_plus }}">{{ table.name }}</a></h2>
|
||||||
<p><em>{% for column in table.columns[:9] %}{{ column }}{% if not loop.last %}, {% endif %}{% endfor %}{% if table.columns|length > 9 %}...{% endif %}</em></p>
|
<p><em>{% for column in table.columns[:9] %}{{ column }}{% if not loop.last %}, {% endif %}{% endfor %}{% if table.columns|length > 9 %}...{% endif %}</em></p>
|
||||||
<p>{{ "{:,}".format(table.table_rows) }} row{% if table.table_rows == 1 %}{% else %}s{% endif %}</p>
|
<p>{{ "{:,}".format(table.table_rows) }} row{% if table.table_rows == 1 %}{% else %}s{% endif %}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
{% for database in databases %}
|
{% for database in databases %}
|
||||||
<h2 style="padding-left: 10px; border-left: 10px solid #{{ database.hash[:6] }}"><a href="{{ database.path }}">{{ database.name }}</a></h2>
|
<h2 style="padding-left: 10px; border-left: 10px solid #{{ database.hash[:6] }}"><a href="{{ database.path }}">{{ database.name }}</a></h2>
|
||||||
<p>{{ "{:,}".format(database.table_rows) }} rows in {{ database.tables_count }} table{% if database.tables_count != 1 %}s{% endif %}</p>
|
<p>{{ "{:,}".format(database.table_rows) }} rows in {{ database.tables_count }} table{% if database.tables_count != 1 %}s{% endif %}</p>
|
||||||
<p>{% for table, count in database.tables_truncated %}<a href="{{ database.path }}/{{ table }}" title="{{ count }} rows">{{ table }}</a>{% if not loop.last %}, {% endif %}{% endfor %}{% if database.tables_more %}, <a href="{{ database.path }}">...</a>{% endif %}</p>
|
<p>{% for table, count in database.tables_truncated %}<a href="{{ database.path }}/{{ table|quote_plus }}" title="{{ count }} rows">{{ table }}</a>{% if not loop.last %}, {% endif %}{% endfor %}{% if database.tables_more %}, <a href="{{ database.path }}">...</a>{% endif %}</p>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="hd"><a href="/">home</a> / <a href="/{{ database }}-{{ database_hash }}">{{ database }}</a> / <a href="/{{ database }}-{{ database_hash }}/{{ table }}">{{ table }}</a></div>
|
<div class="hd"><a href="/">home</a> / <a href="/{{ database }}-{{ database_hash }}">{{ database }}</a> / <a href="/{{ database }}-{{ database_hash }}/{{ table|quote_plus }}">{{ table }}</a></div>
|
||||||
|
|
||||||
<h1 style="padding-left: 10px; border-left: 10px solid #{{ database_hash[:6] }}">{{ table }}: {{ ', '.join(primary_key_values) }}</a></h1>
|
<h1 style="padding-left: 10px; border-left: 10px solid #{{ database_hash[:6] }}">{{ table }}: {{ ', '.join(primary_key_values) }}</a></h1>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for row in rows %}
|
{% for row in rows %}
|
||||||
<tr>
|
<tr>
|
||||||
{% if not is_view %}<td><a href="/{{ database }}-{{ database_hash }}/{{ table }}/{{ row_link(row) }}">{{ row_link(row) }}</a></td>{% endif %}
|
{% if not is_view %}<td><a href="/{{ database }}-{{ database_hash }}/{{ table|quote_plus }}/{{ row_link(row) }}">{{ row_link(row) }}</a></td>{% endif %}
|
||||||
{% for td in row %}
|
{% for td in row %}
|
||||||
{% if not use_rowid or (use_rowid and not loop.first) %}
|
{% if not use_rowid or (use_rowid and not loop.first) %}
|
||||||
<td>{{ td or " " }}</td>
|
<td>{{ td or " " }}</td>
|
||||||
|
|
|
||||||
|
|
@ -119,18 +119,26 @@ def path_with_ext(request, ext):
|
||||||
|
|
||||||
|
|
||||||
_css_re = re.compile(r'''['"\n\\]''')
|
_css_re = re.compile(r'''['"\n\\]''')
|
||||||
|
_boring_table_name_re = re.compile(r'^[a-zA-Z0-9_]+$')
|
||||||
|
|
||||||
|
|
||||||
def escape_css_string(s):
|
def escape_css_string(s):
|
||||||
return _css_re.sub(lambda m: '\\{:X}'.format(ord(m.group())), 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):
|
def make_dockerfile(files):
|
||||||
return '''
|
return '''
|
||||||
FROM python:3
|
FROM python:3
|
||||||
COPY . /app
|
COPY . /app
|
||||||
WORKDIR /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
|
RUN datasette build_metadata {} --metadata metadata.json
|
||||||
EXPOSE 8006
|
EXPOSE 8006
|
||||||
CMD ["datasette", "serve", {}, "--port", "8006", "--metadata", "metadata.json"]'''.format(
|
CMD ["datasette", "serve", {}, "--port", "8006", "--metadata", "metadata.json"]'''.format(
|
||||||
|
|
|
||||||
2
setup.py
2
setup.py
|
|
@ -2,7 +2,7 @@ from setuptools import setup, find_packages
|
||||||
|
|
||||||
setup(
|
setup(
|
||||||
name='datasette',
|
name='datasette',
|
||||||
version='0.2',
|
version='0.4',
|
||||||
packages=find_packages(),
|
packages=find_packages(),
|
||||||
package_data={'datasette': ['templates/*.html']},
|
package_data={'datasette': ['templates/*.html']},
|
||||||
include_package_data=True,
|
include_package_data=True,
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,9 @@ import tempfile
|
||||||
@pytest.fixture(scope='module')
|
@pytest.fixture(scope='module')
|
||||||
def app_client():
|
def app_client():
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
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 = sqlite3.connect(filepath)
|
||||||
conn.executescript(FOUR_TABLES)
|
conn.executescript(TABLES)
|
||||||
os.chdir(os.path.dirname(filepath))
|
os.chdir(os.path.dirname(filepath))
|
||||||
yield Datasette([filepath]).app().test_client
|
yield Datasette([filepath]).app().test_client
|
||||||
|
|
||||||
|
|
@ -18,26 +18,26 @@ def app_client():
|
||||||
def test_homepage(app_client):
|
def test_homepage(app_client):
|
||||||
_, response = app_client.get('/')
|
_, response = app_client.get('/')
|
||||||
assert response.status == 200
|
assert response.status == 200
|
||||||
assert 'four_tables' in response.text
|
assert 'test_tables' in response.text
|
||||||
|
|
||||||
# Now try the JSON
|
# Now try the JSON
|
||||||
_, response = app_client.get('/.json')
|
_, response = app_client.get('/.json')
|
||||||
assert response.status == 200
|
assert response.status == 200
|
||||||
assert response.json.keys() == {'four_tables': 0}.keys()
|
assert response.json.keys() == {'test_tables': 0}.keys()
|
||||||
d = response.json['four_tables']
|
d = response.json['test_tables']
|
||||||
assert d['name'] == 'four_tables'
|
assert d['name'] == 'test_tables'
|
||||||
assert d['tables_count'] == 4
|
assert d['tables_count'] == 5
|
||||||
|
|
||||||
|
|
||||||
def test_database_page(app_client):
|
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
|
assert response.status == 302
|
||||||
_, response = app_client.get('/four_tables')
|
_, response = app_client.get('/test_tables')
|
||||||
assert 'four_tables' in response.text
|
assert 'test_tables' in response.text
|
||||||
# Test JSON list of tables
|
# Test JSON list of tables
|
||||||
_, response = app_client.get('/four_tables.json')
|
_, response = app_client.get('/test_tables.json')
|
||||||
data = response.json
|
data = response.json
|
||||||
assert 'four_tables' == data['database']
|
assert 'test_tables' == data['database']
|
||||||
assert [{
|
assert [{
|
||||||
'columns': ['pk', 'content'],
|
'columns': ['pk', 'content'],
|
||||||
'name': 'Table With Space In Name',
|
'name': 'Table With Space In Name',
|
||||||
|
|
@ -54,12 +54,16 @@ def test_database_page(app_client):
|
||||||
'columns': ['pk', 'content'],
|
'columns': ['pk', 'content'],
|
||||||
'name': 'simple_primary_key',
|
'name': 'simple_primary_key',
|
||||||
'table_rows': 2,
|
'table_rows': 2,
|
||||||
|
}, {
|
||||||
|
'columns': ['pk', 'content'],
|
||||||
|
'name': 'table/with/slashes.csv',
|
||||||
|
'table_rows': 1,
|
||||||
}] == data['tables']
|
}] == data['tables']
|
||||||
|
|
||||||
|
|
||||||
def test_custom_sql(app_client):
|
def test_custom_sql(app_client):
|
||||||
_, response = app_client.get(
|
_, 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
|
data = response.json
|
||||||
assert {
|
assert {
|
||||||
|
|
@ -71,17 +75,17 @@ def test_custom_sql(app_client):
|
||||||
{'content': 'world'}
|
{'content': 'world'}
|
||||||
] == data['rows']
|
] == data['rows']
|
||||||
assert ['content'] == data['columns']
|
assert ['content'] == data['columns']
|
||||||
assert 'four_tables' == data['database']
|
assert 'test_tables' == data['database']
|
||||||
|
|
||||||
|
|
||||||
def test_invalid_custom_sql(app_client):
|
def test_invalid_custom_sql(app_client):
|
||||||
_, response = app_client.get(
|
_, response = app_client.get(
|
||||||
'/four_tables?sql=.schema'
|
'/test_tables?sql=.schema'
|
||||||
)
|
)
|
||||||
assert response.status == 400
|
assert response.status == 400
|
||||||
assert 'Statement must begin with SELECT' in response.text
|
assert 'Statement must begin with SELECT' in response.text
|
||||||
_, response = app_client.get(
|
_, response = app_client.get(
|
||||||
'/four_tables.json?sql=.schema'
|
'/test_tables.json?sql=.schema'
|
||||||
)
|
)
|
||||||
assert response.status == 400
|
assert response.status == 400
|
||||||
assert response.json['ok'] is False
|
assert response.json['ok'] is False
|
||||||
|
|
@ -89,12 +93,12 @@ def test_invalid_custom_sql(app_client):
|
||||||
|
|
||||||
|
|
||||||
def test_table_page(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
|
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
|
assert response.status == 200
|
||||||
data = response.json
|
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['query']['params'] == {}
|
||||||
assert data['rows'] == [{
|
assert data['rows'] == [{
|
||||||
'pk': '1',
|
'pk': '1',
|
||||||
|
|
@ -105,10 +109,22 @@ def test_table_page(app_client):
|
||||||
}]
|
}]
|
||||||
|
|
||||||
|
|
||||||
def test_view(app_client):
|
def test_table_with_slashes_in_name(app_client):
|
||||||
_, response = app_client.get('/four_tables/simple_view')
|
_, response = app_client.get('/test_tables/table%2Fwith%2Fslashes.csv')
|
||||||
assert response.status == 200
|
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
|
assert response.status == 200
|
||||||
data = response.json
|
data = response.json
|
||||||
assert data['rows'] == [{
|
assert data['rows'] == [{
|
||||||
|
|
@ -120,7 +136,7 @@ def test_view(app_client):
|
||||||
}]
|
}]
|
||||||
|
|
||||||
|
|
||||||
FOUR_TABLES = '''
|
TABLES = '''
|
||||||
CREATE TABLE simple_primary_key (
|
CREATE TABLE simple_primary_key (
|
||||||
pk varchar(30) primary key,
|
pk varchar(30) primary key,
|
||||||
content text
|
content text
|
||||||
|
|
@ -142,9 +158,16 @@ CREATE TABLE "Table With Space In Name" (
|
||||||
content text
|
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 (1, 'hello');
|
||||||
INSERT INTO simple_primary_key VALUES (2, 'world');
|
INSERT INTO simple_primary_key VALUES (2, 'world');
|
||||||
|
|
||||||
|
INSERT INTO [table/with/slashes.csv] VALUES (3, 'hey');
|
||||||
|
|
||||||
CREATE VIEW simple_view AS
|
CREATE VIEW simple_view AS
|
||||||
SELECT content, upper(content) AS upper_content FROM simple_primary_key;
|
SELECT content, upper(content) AS upper_content FROM simple_primary_key;
|
||||||
'''
|
'''
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue