kopia lustrzana https://github.com/simonw/datasette
Merge branch 'master' into publish-heroku
commit
75450abbe8
|
@ -141,6 +141,7 @@ This will create a docker image containing both the datasette application and th
|
||||||
-m, --metadata FILENAME Path to JSON file containing metadata to publish
|
-m, --metadata FILENAME Path to JSON file containing metadata to publish
|
||||||
--extra-options TEXT Extra options to pass to datasette serve
|
--extra-options TEXT Extra options to pass to datasette serve
|
||||||
--force Pass --force option to now
|
--force Pass --force option to now
|
||||||
|
--branch TEXT Install datasette from a GitHub branch e.g. master
|
||||||
--title TEXT Title for metadata
|
--title TEXT Title for metadata
|
||||||
--license TEXT License label for metadata
|
--license TEXT License label for metadata
|
||||||
--license_url TEXT License URL for metadata
|
--license_url TEXT License URL for metadata
|
||||||
|
@ -162,6 +163,7 @@ If you have docker installed you can use `datasette package` to create a new Doc
|
||||||
optionally use name:tag format
|
optionally use name:tag format
|
||||||
-m, --metadata FILENAME Path to JSON file containing metadata to publish
|
-m, --metadata FILENAME Path to JSON file containing metadata to publish
|
||||||
--extra-options TEXT Extra options to pass to datasette serve
|
--extra-options TEXT Extra options to pass to datasette serve
|
||||||
|
--branch TEXT Install datasette from a GitHub branch e.g. master
|
||||||
--title TEXT Title for metadata
|
--title TEXT Title for metadata
|
||||||
--license TEXT License label for metadata
|
--license TEXT License label for metadata
|
||||||
--license_url TEXT License URL for metadata
|
--license_url TEXT License URL for metadata
|
||||||
|
|
196
datasette/app.py
196
datasette/app.py
|
@ -16,9 +16,10 @@ import jinja2
|
||||||
import hashlib
|
import hashlib
|
||||||
import time
|
import time
|
||||||
from .utils import (
|
from .utils import (
|
||||||
build_where_clauses,
|
Filters,
|
||||||
compound_pks_from_path,
|
compound_pks_from_path,
|
||||||
CustomJSONEncoder,
|
CustomJSONEncoder,
|
||||||
|
detect_fts_sql,
|
||||||
escape_css_string,
|
escape_css_string,
|
||||||
escape_sqlite_table_name,
|
escape_sqlite_table_name,
|
||||||
get_all_foreign_keys,
|
get_all_foreign_keys,
|
||||||
|
@ -56,7 +57,7 @@ class BaseView(HTTPMethodView):
|
||||||
return r
|
return r
|
||||||
|
|
||||||
def redirect(self, request, path):
|
def redirect(self, request, path):
|
||||||
if request.query_string:
|
if request.query_string and '?' not in path:
|
||||||
path = '{}?{}'.format(
|
path = '{}?{}'.format(
|
||||||
path, request.query_string
|
path, request.query_string
|
||||||
)
|
)
|
||||||
|
@ -181,9 +182,13 @@ class BaseView(HTTPMethodView):
|
||||||
template = self.template
|
template = self.template
|
||||||
status_code = 200
|
status_code = 200
|
||||||
try:
|
try:
|
||||||
data, extra_template_data = await self.data(
|
response_or_template_contexts = await self.data(
|
||||||
request, name, hash, **kwargs
|
request, name, hash, **kwargs
|
||||||
)
|
)
|
||||||
|
if isinstance(response_or_template_contexts, response.HTTPResponse):
|
||||||
|
return response_or_template_contexts
|
||||||
|
else:
|
||||||
|
data, extra_template_data = response_or_template_contexts
|
||||||
except (sqlite3.OperationalError, InvalidSql) as e:
|
except (sqlite3.OperationalError, InvalidSql) as e:
|
||||||
data = {
|
data = {
|
||||||
'ok': False,
|
'ok': False,
|
||||||
|
@ -221,16 +226,23 @@ class BaseView(HTTPMethodView):
|
||||||
headers=headers,
|
headers=headers,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
context = {**data, **dict(
|
extras = {}
|
||||||
extra_template_data()
|
if callable(extra_template_data):
|
||||||
if callable(extra_template_data)
|
extras = extra_template_data()
|
||||||
else extra_template_data
|
if asyncio.iscoroutine(extras):
|
||||||
), **{
|
extras = await extras
|
||||||
|
else:
|
||||||
|
extras = extra_template_data
|
||||||
|
context = {
|
||||||
|
**data,
|
||||||
|
**extras,
|
||||||
|
**{
|
||||||
'url_json': path_with_ext(request, '.json'),
|
'url_json': path_with_ext(request, '.json'),
|
||||||
'url_jsono': path_with_ext(request, '.jsono'),
|
'url_jsono': path_with_ext(request, '.jsono'),
|
||||||
'metadata': self.ds.metadata,
|
'metadata': self.ds.metadata,
|
||||||
'datasette_version': __version__,
|
'datasette_version': __version__,
|
||||||
}}
|
}
|
||||||
|
}
|
||||||
r = self.jinja.render(
|
r = self.jinja.render(
|
||||||
template,
|
template,
|
||||||
request,
|
request,
|
||||||
|
@ -407,18 +419,47 @@ class TableView(BaseView):
|
||||||
# That's so if there is a column that starts with _
|
# That's so if there is a column that starts with _
|
||||||
# it can still be queried using ?_col__exact=blah
|
# it can still be queried using ?_col__exact=blah
|
||||||
special_args = {}
|
special_args = {}
|
||||||
|
special_args_lists = {}
|
||||||
other_args = {}
|
other_args = {}
|
||||||
for key, value in request.args.items():
|
for key, value in request.args.items():
|
||||||
if key.startswith('_') and '__' not in key:
|
if key.startswith('_') and '__' not in key:
|
||||||
special_args[key] = value[0]
|
special_args[key] = value[0]
|
||||||
|
special_args_lists[key] = value
|
||||||
else:
|
else:
|
||||||
other_args[key] = value[0]
|
other_args[key] = value[0]
|
||||||
|
|
||||||
if other_args:
|
# Handle ?_filter_column and redirect, if present
|
||||||
where_clauses, params = build_where_clauses(other_args)
|
if '_filter_column' in special_args:
|
||||||
else:
|
filter_column = special_args['_filter_column']
|
||||||
where_clauses = []
|
filter_op = special_args.get('_filter_op') or ''
|
||||||
params = {}
|
filter_value = special_args.get('_filter_value') or ''
|
||||||
|
if '__' in filter_op:
|
||||||
|
filter_op, filter_value = filter_op.split('__', 1)
|
||||||
|
return self.redirect(request, path_with_added_args(request, {
|
||||||
|
'{}__{}'.format(filter_column, filter_op): filter_value,
|
||||||
|
'_filter_column': None,
|
||||||
|
'_filter_op': None,
|
||||||
|
'_filter_value': None,
|
||||||
|
}))
|
||||||
|
|
||||||
|
filters = Filters(sorted(other_args.items()))
|
||||||
|
where_clauses, params = filters.build_where_clauses()
|
||||||
|
|
||||||
|
# _search support:
|
||||||
|
fts_table = None
|
||||||
|
fts_sql = detect_fts_sql(table)
|
||||||
|
fts_rows = list(await self.execute(name, fts_sql))
|
||||||
|
if fts_rows:
|
||||||
|
fts_table = fts_rows[0][0]
|
||||||
|
|
||||||
|
search = special_args.get('_search')
|
||||||
|
if search and fts_table:
|
||||||
|
where_clauses.append(
|
||||||
|
'rowid in (select rowid from [{fts_table}] where [{fts_table}] match :search)'.format(
|
||||||
|
fts_table=fts_table
|
||||||
|
)
|
||||||
|
)
|
||||||
|
params['search'] = search
|
||||||
|
|
||||||
next = special_args.get('_next')
|
next = special_args.get('_next')
|
||||||
offset = ''
|
offset = ''
|
||||||
|
@ -453,6 +494,16 @@ class TableView(BaseView):
|
||||||
if order_by:
|
if order_by:
|
||||||
order_by = 'order by {} '.format(order_by)
|
order_by = 'order by {} '.format(order_by)
|
||||||
|
|
||||||
|
# _group_count=col1&_group_count=col2
|
||||||
|
group_count = special_args_lists.get('_group_count') or []
|
||||||
|
if group_count:
|
||||||
|
sql = 'select {group_cols}, count(*) as "count" from {table_name} {where} group by {group_cols} order by "count" desc limit 100'.format(
|
||||||
|
group_cols=', '.join('"{}"'.format(group_count_col) for group_count_col in group_count),
|
||||||
|
table_name=escape_sqlite_table_name(table),
|
||||||
|
where=where_clause,
|
||||||
|
)
|
||||||
|
is_view = True
|
||||||
|
else:
|
||||||
sql = 'select {select} from {table_name} {where}{order_by}limit {limit}{offset}'.format(
|
sql = 'select {select} from {table_name} {where}{order_by}limit {limit}{offset}'.format(
|
||||||
select=select,
|
select=select,
|
||||||
table_name=escape_sqlite_table_name(table),
|
table_name=escape_sqlite_table_name(table),
|
||||||
|
@ -481,6 +532,8 @@ class TableView(BaseView):
|
||||||
table_rows = None
|
table_rows = None
|
||||||
if not is_view:
|
if not is_view:
|
||||||
table_rows = info[name]['tables'][table]['count']
|
table_rows = info[name]['tables'][table]['count']
|
||||||
|
|
||||||
|
# Pagination next link
|
||||||
next_value = None
|
next_value = None
|
||||||
next_url = None
|
next_url = None
|
||||||
if len(rows) > self.page_size:
|
if len(rows) > self.page_size:
|
||||||
|
@ -492,6 +545,17 @@ class TableView(BaseView):
|
||||||
'_next': next_value,
|
'_next': next_value,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
async def extra_template():
|
||||||
|
return {
|
||||||
|
'database_hash': hash,
|
||||||
|
'human_filter_description': filters.human_description(),
|
||||||
|
'supports_search': bool(fts_table),
|
||||||
|
'search': search or '',
|
||||||
|
'use_rowid': use_rowid,
|
||||||
|
'display_columns': display_columns,
|
||||||
|
'display_rows': await self.make_display_rows(name, hash, table, rows, display_columns, pks, is_view, use_rowid),
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'database': name,
|
'database': name,
|
||||||
'table': table,
|
'table': table,
|
||||||
|
@ -509,15 +573,37 @@ class TableView(BaseView):
|
||||||
},
|
},
|
||||||
'next': next_value and str(next_value) or None,
|
'next': next_value and str(next_value) or None,
|
||||||
'next_url': next_url,
|
'next_url': next_url,
|
||||||
}, lambda: {
|
}, extra_template
|
||||||
'database_hash': hash,
|
|
||||||
'use_rowid': use_rowid,
|
|
||||||
'display_columns': display_columns,
|
|
||||||
'display_rows': make_display_rows(name, hash, table, rows, display_columns, pks, is_view, use_rowid),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
async def make_display_rows(self, database, database_hash, table, rows, display_columns, pks, is_view, use_rowid):
|
||||||
|
# Get fancy with foreign keys
|
||||||
|
expanded = {}
|
||||||
|
tables = self.ds.inspect()[database]['tables']
|
||||||
|
table_info = tables.get(table) or {}
|
||||||
|
if table_info and not is_view:
|
||||||
|
foreign_keys = table_info['foreign_keys']['outgoing']
|
||||||
|
for fk in foreign_keys:
|
||||||
|
label_column = tables.get(fk['other_table'], {}).get('label_column')
|
||||||
|
if not label_column:
|
||||||
|
# We only link cells to other tables with label columns defined
|
||||||
|
continue
|
||||||
|
ids_to_lookup = set([row[fk['column']] for row in rows])
|
||||||
|
sql = 'select "{other_column}", "{label_column}" from {other_table} where "{other_column}" in ({placeholders})'.format(
|
||||||
|
other_column=fk['other_column'],
|
||||||
|
label_column=label_column,
|
||||||
|
other_table=escape_sqlite_table_name(fk['other_table']),
|
||||||
|
placeholders=', '.join(['?'] * len(ids_to_lookup)),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
results = await self.execute(database, sql, list(set(ids_to_lookup)))
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
# Probably hit the timelimit
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
for id, value in results:
|
||||||
|
expanded[(fk['column'], id)] = (fk['other_table'], value)
|
||||||
|
|
||||||
def make_display_rows(database, database_hash, table, rows, display_columns, pks, is_view, use_rowid):
|
to_return = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
cells = []
|
cells = []
|
||||||
# Unless we are a view, the first column is a link - either to the rowid
|
# Unless we are a view, the first column is a link - either to the rowid
|
||||||
|
@ -540,8 +626,18 @@ def make_display_rows(database, database_hash, table, rows, display_columns, pks
|
||||||
if use_rowid and column == 'rowid':
|
if use_rowid and column == 'rowid':
|
||||||
# We already showed this in the linked first column
|
# We already showed this in the linked first column
|
||||||
continue
|
continue
|
||||||
if False: # TODO: This is where we will do foreign key linking
|
elif (column, value) in expanded:
|
||||||
display_value = jinja2.Markup('<a href="#">{}</a>'.format('foreign key'))
|
other_table, label = expanded[(column, value)]
|
||||||
|
display_value = jinja2.Markup(
|
||||||
|
# TODO: Escape id/label/etc so no XSS here
|
||||||
|
'<a href="/{database}-{database_hash}/{table}/{id}">{label}</a>'.format(
|
||||||
|
database=database,
|
||||||
|
database_hash=database_hash,
|
||||||
|
table=escape_sqlite_table_name(other_table),
|
||||||
|
id=value,
|
||||||
|
label=label,
|
||||||
|
)
|
||||||
|
)
|
||||||
elif value is None:
|
elif value is None:
|
||||||
display_value = jinja2.Markup(' ')
|
display_value = jinja2.Markup(' ')
|
||||||
else:
|
else:
|
||||||
|
@ -550,7 +646,8 @@ def make_display_rows(database, database_hash, table, rows, display_columns, pks
|
||||||
'column': column,
|
'column': column,
|
||||||
'value': display_value,
|
'value': display_value,
|
||||||
})
|
})
|
||||||
yield cells
|
to_return.append(cells)
|
||||||
|
return to_return
|
||||||
|
|
||||||
|
|
||||||
class RowView(BaseView):
|
class RowView(BaseView):
|
||||||
|
@ -581,6 +678,13 @@ class RowView(BaseView):
|
||||||
rows = list(rows)
|
rows = list(rows)
|
||||||
if not rows:
|
if not rows:
|
||||||
raise NotFound('Record not found: {}'.format(pk_values))
|
raise NotFound('Record not found: {}'.format(pk_values))
|
||||||
|
|
||||||
|
async def template_data():
|
||||||
|
return {
|
||||||
|
'database_hash': hash,
|
||||||
|
'foreign_key_tables': await self.foreign_key_tables(name, table, pk_values),
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'database': name,
|
'database': name,
|
||||||
'table': table,
|
'table': table,
|
||||||
|
@ -588,9 +692,34 @@ class RowView(BaseView):
|
||||||
'columns': columns,
|
'columns': columns,
|
||||||
'primary_keys': pks,
|
'primary_keys': pks,
|
||||||
'primary_key_values': pk_values,
|
'primary_key_values': pk_values,
|
||||||
}, {
|
}, template_data
|
||||||
'database_hash': hash,
|
|
||||||
}
|
async def foreign_key_tables(self, name, table, pk_values):
|
||||||
|
if len(pk_values) != 1:
|
||||||
|
return []
|
||||||
|
table_info = self.ds.inspect()[name]['tables'].get(table)
|
||||||
|
if not table:
|
||||||
|
return []
|
||||||
|
foreign_keys = table_info['foreign_keys']['incoming']
|
||||||
|
sql = 'select ' + ', '.join([
|
||||||
|
'(select count(*) from {table} where "{column}"= :id)'.format(
|
||||||
|
table=escape_sqlite_table_name(fk['other_table']),
|
||||||
|
column=fk['other_column'],
|
||||||
|
)
|
||||||
|
for fk in foreign_keys
|
||||||
|
])
|
||||||
|
try:
|
||||||
|
rows = list(await self.execute(name, sql, {'id': pk_values[0]}))
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
# Almost certainly hit the timeout
|
||||||
|
return []
|
||||||
|
foreign_table_counts = dict(zip([fk['other_table'] for fk in foreign_keys], rows[0]))
|
||||||
|
foreign_key_tables = []
|
||||||
|
for fk in foreign_keys:
|
||||||
|
count = foreign_table_counts[fk['other_table']]
|
||||||
|
if count:
|
||||||
|
foreign_key_tables.append({**fk, **{'count': count}})
|
||||||
|
return foreign_key_tables
|
||||||
|
|
||||||
|
|
||||||
class Datasette:
|
class Datasette:
|
||||||
|
@ -638,8 +767,19 @@ class Datasette:
|
||||||
for r in conn.execute('select * from sqlite_master where type="table"')
|
for r in conn.execute('select * from sqlite_master where type="table"')
|
||||||
]
|
]
|
||||||
for table in table_names:
|
for table in table_names:
|
||||||
|
count = conn.execute(
|
||||||
|
'select count(*) from {}'.format(escape_sqlite_table_name(table))
|
||||||
|
).fetchone()[0]
|
||||||
|
label_column = None
|
||||||
|
# If table has two columns, one of which is ID, then label_column is the other one
|
||||||
|
column_names = [r[1] for r in conn.execute(
|
||||||
|
'PRAGMA table_info({});'.format(escape_sqlite_table_name(table))
|
||||||
|
).fetchall()]
|
||||||
|
if column_names and len(column_names) == 2 and 'id' in column_names:
|
||||||
|
label_column = [c for c in column_names if c != 'id'][0]
|
||||||
tables[table] = {
|
tables[table] = {
|
||||||
'count': conn.execute('select count(*) from "{}"'.format(table)).fetchone()[0],
|
'count': count,
|
||||||
|
'label_column': label_column,
|
||||||
}
|
}
|
||||||
|
|
||||||
foreign_keys = get_all_foreign_keys(conn)
|
foreign_keys = get_all_foreign_keys(conn)
|
||||||
|
|
|
@ -11,6 +11,7 @@ from .utils import (
|
||||||
|
|
||||||
|
|
||||||
@click.group(cls=DefaultGroup, default='serve', default_if_no_args=True)
|
@click.group(cls=DefaultGroup, default='serve', default_if_no_args=True)
|
||||||
|
@click.version_option()
|
||||||
def cli():
|
def cli():
|
||||||
"""
|
"""
|
||||||
Datasette!
|
Datasette!
|
||||||
|
@ -38,12 +39,13 @@ def build(files, inspect_file):
|
||||||
)
|
)
|
||||||
@click.option('--extra-options', help='Extra options to pass to datasette serve')
|
@click.option('--extra-options', help='Extra options to pass to datasette serve')
|
||||||
@click.option('--force', is_flag=True, help='Pass --force option to now')
|
@click.option('--force', is_flag=True, help='Pass --force option to now')
|
||||||
|
@click.option('--branch', help='Install datasette from a GitHub branch e.g. master')
|
||||||
@click.option('--title', help='Title for metadata')
|
@click.option('--title', help='Title for metadata')
|
||||||
@click.option('--license', help='License label for metadata')
|
@click.option('--license', help='License label for metadata')
|
||||||
@click.option('--license_url', help='License URL for metadata')
|
@click.option('--license_url', help='License URL for metadata')
|
||||||
@click.option('--source', help='Source label for metadata')
|
@click.option('--source', help='Source label for metadata')
|
||||||
@click.option('--source_url', help='Source URL for metadata')
|
@click.option('--source_url', help='Source URL for metadata')
|
||||||
def publish(publisher, files, name, metadata, extra_options, force, **extra_metadata):
|
def publish(publisher, files, name, metadata, extra_options, force, branch, **extra_metadata):
|
||||||
"""
|
"""
|
||||||
Publish specified SQLite database files to the internet along with a datasette API.
|
Publish specified SQLite database files to the internet along with a datasette API.
|
||||||
|
|
||||||
|
@ -68,7 +70,7 @@ def publish(publisher, files, name, metadata, extra_options, force, **extra_meta
|
||||||
|
|
||||||
if publisher == 'now':
|
if publisher == 'now':
|
||||||
_fail_if_publish_binary_not_installed('now', 'Zeit Now', 'https://zeit.co/now')
|
_fail_if_publish_binary_not_installed('now', 'Zeit Now', 'https://zeit.co/now')
|
||||||
with temporary_docker_directory(files, name, metadata, extra_options, extra_metadata):
|
with temporary_docker_directory(files, name, metadata, extra_options, branch, extra_metadata):
|
||||||
if force:
|
if force:
|
||||||
call(['now', '--force'])
|
call(['now', '--force'])
|
||||||
else:
|
else:
|
||||||
|
@ -84,7 +86,7 @@ def publish(publisher, files, name, metadata, extra_options, force, **extra_meta
|
||||||
click.confirm('Install it? (this will run `heroku plugins:install heroku-builds`)', abort=True)
|
click.confirm('Install it? (this will run `heroku plugins:install heroku-builds`)', abort=True)
|
||||||
call(["heroku", "plugins:install", "heroku-builds"])
|
call(["heroku", "plugins:install", "heroku-builds"])
|
||||||
|
|
||||||
with temporary_heroku_directory(files, name, metadata, extra_options, extra_metadata):
|
with temporary_heroku_directory(files, name, metadata, extra_options, branch, extra_metadata):
|
||||||
create_output = check_output(['heroku', 'apps:create', '--json'])
|
create_output = check_output(['heroku', 'apps:create', '--json'])
|
||||||
app_name = json.loads(create_output)["name"]
|
app_name = json.loads(create_output)["name"]
|
||||||
call(["heroku", "builds:create", "-a", app_name])
|
call(["heroku", "builds:create", "-a", app_name])
|
||||||
|
@ -100,12 +102,13 @@ def publish(publisher, files, name, metadata, extra_options, force, **extra_meta
|
||||||
help='Path to JSON file containing metadata to publish'
|
help='Path to JSON file containing metadata to publish'
|
||||||
)
|
)
|
||||||
@click.option('--extra-options', help='Extra options to pass to datasette serve')
|
@click.option('--extra-options', help='Extra options to pass to datasette serve')
|
||||||
|
@click.option('--branch', help='Install datasette from a GitHub branch e.g. master')
|
||||||
@click.option('--title', help='Title for metadata')
|
@click.option('--title', help='Title for metadata')
|
||||||
@click.option('--license', help='License label for metadata')
|
@click.option('--license', help='License label for metadata')
|
||||||
@click.option('--license_url', help='License URL for metadata')
|
@click.option('--license_url', help='License URL for metadata')
|
||||||
@click.option('--source', help='Source label for metadata')
|
@click.option('--source', help='Source label for metadata')
|
||||||
@click.option('--source_url', help='Source URL for metadata')
|
@click.option('--source_url', help='Source URL for metadata')
|
||||||
def package(files, tag, metadata, extra_options, **extra_metadata):
|
def package(files, tag, metadata, extra_options, branch, **extra_metadata):
|
||||||
"Package specified SQLite files into a new datasette Docker container"
|
"Package specified SQLite files into a new datasette Docker container"
|
||||||
if not shutil.which('docker'):
|
if not shutil.which('docker'):
|
||||||
click.secho(
|
click.secho(
|
||||||
|
@ -116,7 +119,7 @@ def package(files, tag, metadata, extra_options, **extra_metadata):
|
||||||
err=True,
|
err=True,
|
||||||
)
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
with temporary_docker_directory(files, 'datasette', metadata, extra_options, extra_metadata):
|
with temporary_docker_directory(files, 'datasette', metadata, extra_options, branch, extra_metadata):
|
||||||
args = ['docker', 'build']
|
args = ['docker', 'build']
|
||||||
if tag:
|
if tag:
|
||||||
args.append('-t')
|
args.append('-t')
|
||||||
|
|
|
@ -21,6 +21,13 @@ td {
|
||||||
th {
|
th {
|
||||||
padding-right: 1em;
|
padding-right: 1em;
|
||||||
}
|
}
|
||||||
|
table a:link {
|
||||||
|
text-decoration: none;
|
||||||
|
color: #445ac8;
|
||||||
|
}
|
||||||
|
table a:visited {
|
||||||
|
color: #8f54c4;
|
||||||
|
}
|
||||||
@media only screen and (max-width: 576px) {
|
@media only screen and (max-width: 576px) {
|
||||||
/* Force table to not be like tables anymore */
|
/* Force table to not be like tables anymore */
|
||||||
table, thead, tbody, th, td, tr {
|
table, thead, tbody, th, td, tr {
|
||||||
|
@ -83,12 +90,13 @@ form.sql textarea {
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
font-size: 1.3em;
|
font-size: 1.3em;
|
||||||
}
|
}
|
||||||
form.sql label {
|
form label {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 15%;
|
width: 15%;
|
||||||
}
|
}
|
||||||
form.sql input[type=text] {
|
form input[type=text],
|
||||||
|
form input[type=search] {
|
||||||
border: 1px solid #ccc;
|
border: 1px solid #ccc;
|
||||||
width: 60%;
|
width: 60%;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
|
@ -96,12 +104,15 @@ form.sql input[type=text] {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
font-size: 1.1em;
|
font-size: 1.1em;
|
||||||
}
|
}
|
||||||
|
form input[type=search] {
|
||||||
|
width: 40%;
|
||||||
|
}
|
||||||
@media only screen and (max-width: 576px) {
|
@media only screen and (max-width: 576px) {
|
||||||
form.sql textarea {
|
form.sql textarea {
|
||||||
width: 95%;
|
width: 95%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
form.sql input[type=submit] {
|
form input[type=submit] {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
background-color: #007bff;
|
background-color: #007bff;
|
||||||
border-color: #007bff;
|
border-color: #007bff;
|
||||||
|
@ -111,7 +122,7 @@ form.sql input[type=submit] {
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
border: 1px solid blue;
|
border: 1px solid blue;
|
||||||
padding: .275rem .75rem;
|
padding: .275rem .75rem;
|
||||||
font-size: 1rem;
|
font-size: 0.9rem;
|
||||||
line-height: 1.5;
|
line-height: 1;
|
||||||
border-radius: .25rem;
|
border-radius: .25rem;
|
||||||
}
|
}
|
||||||
|
|
|
@ -44,11 +44,8 @@
|
||||||
<p><input type="submit" value="Run SQL"></p>
|
<p><input type="submit" value="Run SQL"></p>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{% if truncated %}
|
|
||||||
<div style="padding: 1em; margin: 1em 0; border: 3px solid red;">These results were truncated. You will need to apply OFFSET/LIMIT to see the whole result set.</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if rows %}
|
{% if rows %}
|
||||||
|
<p>Returned {% if truncated %}more than {% endif %}{{ "{:,}".format(rows|length) }} row{% if rows|length == 1 %}{% else %}s{% endif %}</p>
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
@ -59,7 +56,7 @@
|
||||||
{% for row in rows %}
|
{% for row in rows %}
|
||||||
<tr>
|
<tr>
|
||||||
{% for td in row %}
|
{% for td in row %}
|
||||||
<td>{{ td or " "|safe }}</td>
|
<td>{% if td == None %}{{ " "|safe }}{% else %}{{ td }}{% endif %}</td>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
@ -93,7 +90,8 @@
|
||||||
editor.setOption("extraKeys", {
|
editor.setOption("extraKeys", {
|
||||||
"Shift-Enter": function() {
|
"Shift-Enter": function() {
|
||||||
document.getElementsByClassName("sql")[0].submit();
|
document.getElementsByClassName("sql")[0].submit();
|
||||||
}
|
},
|
||||||
|
Tab: false
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
@ -35,4 +35,18 @@
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
{% if foreign_key_tables %}
|
||||||
|
<h2>Links from other tables</h2>
|
||||||
|
<ul>
|
||||||
|
{% for other in foreign_key_tables %}
|
||||||
|
<li>
|
||||||
|
<a href="/{{ database }}-{{ database_hash }}/{{ other.other_table|quote_plus }}?{{ other.other_column }}={{ ', '.join(primary_key_values) }}">
|
||||||
|
{{ "{:,}".format(other.count) }} row{% if other.count == 1 %}{% else %}s{% endif %}</a>
|
||||||
|
from {{ other.other_column }} in {{ other.other_table }}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -21,6 +21,14 @@
|
||||||
<h2>{{ "{:,}".format(table_rows) }} total row{% if table_rows == 1 %}{% else %}s{% endif %} in this table</h2>
|
<h2>{{ "{:,}".format(table_rows) }} total row{% if table_rows == 1 %}{% else %}s{% endif %} in this table</h2>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<p>{{ human_filter_description }}</p>
|
||||||
|
|
||||||
|
{% if supports_search %}
|
||||||
|
<form action="/{{ database }}-{{ database_hash }}/{{ table|quote_plus }}" method="get">
|
||||||
|
<p><input type="search" name="_search" value="{{ search }}"> <input type="submit" value="Search"></p>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if query.params %}
|
{% if query.params %}
|
||||||
<pre>{{ query.sql }}</pre>
|
<pre>{{ query.sql }}</pre>
|
||||||
<pre>params = {{ query.params|tojson(4) }}</pre>
|
<pre>params = {{ query.params|tojson(4) }}</pre>
|
||||||
|
|
|
@ -7,7 +7,6 @@ import sqlite3
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
import urllib
|
import urllib
|
||||||
import shlex
|
|
||||||
|
|
||||||
|
|
||||||
def compound_pks_from_path(path):
|
def compound_pks_from_path(path):
|
||||||
|
@ -27,44 +26,6 @@ def path_from_row_pks(row, pks, use_rowid):
|
||||||
return ','.join(bits)
|
return ','.join(bits)
|
||||||
|
|
||||||
|
|
||||||
def build_where_clauses(args):
|
|
||||||
sql_bits = []
|
|
||||||
params = {}
|
|
||||||
for i, (key, value) in enumerate(sorted(args.items())):
|
|
||||||
if '__' in key:
|
|
||||||
column, lookup = key.rsplit('__', 1)
|
|
||||||
else:
|
|
||||||
column = key
|
|
||||||
lookup = 'exact'
|
|
||||||
template = {
|
|
||||||
'exact': '"{}" = :{}',
|
|
||||||
'contains': '"{}" like :{}',
|
|
||||||
'endswith': '"{}" like :{}',
|
|
||||||
'startswith': '"{}" like :{}',
|
|
||||||
'gt': '"{}" > :{}',
|
|
||||||
'gte': '"{}" >= :{}',
|
|
||||||
'lt': '"{}" < :{}',
|
|
||||||
'lte': '"{}" <= :{}',
|
|
||||||
'glob': '"{}" glob :{}',
|
|
||||||
'like': '"{}" like :{}',
|
|
||||||
}[lookup]
|
|
||||||
numeric_operators = {'gt', 'gte', 'lt', 'lte'}
|
|
||||||
value_convert = {
|
|
||||||
'contains': lambda s: '%{}%'.format(s),
|
|
||||||
'endswith': lambda s: '%{}'.format(s),
|
|
||||||
'startswith': lambda s: '{}%'.format(s),
|
|
||||||
}.get(lookup, lambda s: s)
|
|
||||||
converted = value_convert(value)
|
|
||||||
if lookup in numeric_operators and converted.isdigit():
|
|
||||||
converted = int(converted)
|
|
||||||
param_id = 'p{}'.format(i)
|
|
||||||
sql_bits.append(
|
|
||||||
template.format(column, param_id)
|
|
||||||
)
|
|
||||||
params[param_id] = converted
|
|
||||||
return sql_bits, params
|
|
||||||
|
|
||||||
|
|
||||||
class CustomJSONEncoder(json.JSONEncoder):
|
class CustomJSONEncoder(json.JSONEncoder):
|
||||||
def default(self, obj):
|
def default(self, obj):
|
||||||
if isinstance(obj, sqlite3.Row):
|
if isinstance(obj, sqlite3.Row):
|
||||||
|
@ -116,9 +77,17 @@ def validate_sql_select(sql):
|
||||||
|
|
||||||
|
|
||||||
def path_with_added_args(request, args):
|
def path_with_added_args(request, args):
|
||||||
current = request.raw_args.copy()
|
current = {
|
||||||
current.update(args)
|
key: value
|
||||||
return request.path + '?' + urllib.parse.urlencode(current)
|
for key, value in request.raw_args.items()
|
||||||
|
if key not in args
|
||||||
|
}
|
||||||
|
current.update({
|
||||||
|
key: value
|
||||||
|
for key, value in args.items()
|
||||||
|
if value is not None
|
||||||
|
})
|
||||||
|
return request.path + '?' + urllib.parse.urlencode(sorted(current.items()))
|
||||||
|
|
||||||
|
|
||||||
def path_with_ext(request, ext):
|
def path_with_ext(request, ext):
|
||||||
|
@ -144,7 +113,7 @@ def escape_sqlite_table_name(s):
|
||||||
return '[{}]'.format(s)
|
return '[{}]'.format(s)
|
||||||
|
|
||||||
|
|
||||||
def make_dockerfile(files, metadata_file, extra_options=''):
|
def make_dockerfile(files, metadata_file, extra_options='', branch=None):
|
||||||
cmd = ['"datasette"', '"serve"', '"--host"', '"0.0.0.0"']
|
cmd = ['"datasette"', '"serve"', '"--host"', '"0.0.0.0"']
|
||||||
cmd.append('"' + '", "'.join(files) + '"')
|
cmd.append('"' + '", "'.join(files) + '"')
|
||||||
cmd.extend(['"--cors"', '"--port"', '"8001"', '"--inspect-file"', '"inspect-data.json"'])
|
cmd.extend(['"--cors"', '"--port"', '"8001"', '"--inspect-file"', '"inspect-data.json"'])
|
||||||
|
@ -153,21 +122,27 @@ def make_dockerfile(files, metadata_file, extra_options=''):
|
||||||
if extra_options:
|
if extra_options:
|
||||||
for opt in extra_options.split():
|
for opt in extra_options.split():
|
||||||
cmd.append('"{}"'.format(opt))
|
cmd.append('"{}"'.format(opt))
|
||||||
|
install_from = 'datasette'
|
||||||
|
if branch:
|
||||||
|
install_from = 'https://github.com/simonw/datasette/archive/{}.zip'.format(
|
||||||
|
branch
|
||||||
|
)
|
||||||
return '''
|
return '''
|
||||||
FROM python:3
|
FROM python:3
|
||||||
COPY . /app
|
COPY . /app
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN pip install datasette
|
RUN pip install {install_from}
|
||||||
RUN datasette build {} --inspect-file inspect-data.json
|
RUN datasette build {files} --inspect-file inspect-data.json
|
||||||
EXPOSE 8001
|
EXPOSE 8001
|
||||||
CMD [{}]'''.format(
|
CMD [{cmd}]'''.format(
|
||||||
' '.join(files),
|
files=' '.join(files),
|
||||||
', '.join(cmd)
|
cmd=', '.join(cmd),
|
||||||
|
install_from=install_from,
|
||||||
).strip()
|
).strip()
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def temporary_docker_directory(files, name, metadata, extra_options, extra_metadata=None):
|
def temporary_docker_directory(files, name, metadata, extra_options, branch=None, extra_metadata=None):
|
||||||
extra_metadata = extra_metadata or {}
|
extra_metadata = extra_metadata or {}
|
||||||
tmp = tempfile.TemporaryDirectory()
|
tmp = tempfile.TemporaryDirectory()
|
||||||
# We create a datasette folder in there to get a nicer now deploy name
|
# We create a datasette folder in there to get a nicer now deploy name
|
||||||
|
@ -187,7 +162,7 @@ def temporary_docker_directory(files, name, metadata, extra_options, extra_metad
|
||||||
if value:
|
if value:
|
||||||
metadata_content[key] = value
|
metadata_content[key] = value
|
||||||
try:
|
try:
|
||||||
dockerfile = make_dockerfile(file_names, metadata_content and 'metadata.json', extra_options)
|
dockerfile = make_dockerfile(file_names, metadata_content and 'metadata.json', extra_options, branch)
|
||||||
os.chdir(datasette_dir)
|
os.chdir(datasette_dir)
|
||||||
if metadata_content:
|
if metadata_content:
|
||||||
open('metadata.json', 'w').write(json.dumps(metadata_content, indent=2))
|
open('metadata.json', 'w').write(json.dumps(metadata_content, indent=2))
|
||||||
|
@ -199,8 +174,9 @@ def temporary_docker_directory(files, name, metadata, extra_options, extra_metad
|
||||||
tmp.cleanup()
|
tmp.cleanup()
|
||||||
os.chdir(saved_cwd)
|
os.chdir(saved_cwd)
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def temporary_heroku_directory(files, name, metadata, extra_options, extra_metadata=None):
|
def temporary_heroku_directory(files, name, metadata, extra_options, branch=None, extra_metadata=None):
|
||||||
# FIXME: lots of duplicated code from above
|
# FIXME: lots of duplicated code from above
|
||||||
|
|
||||||
extra_metadata = extra_metadata or {}
|
extra_metadata = extra_metadata or {}
|
||||||
|
@ -228,7 +204,13 @@ def temporary_heroku_directory(files, name, metadata, extra_options, extra_metad
|
||||||
open('metadata.json', 'w').write(json.dumps(metadata_content, indent=2))
|
open('metadata.json', 'w').write(json.dumps(metadata_content, indent=2))
|
||||||
|
|
||||||
open('runtime.txt', 'w').write('python-3.6.2')
|
open('runtime.txt', 'w').write('python-3.6.2')
|
||||||
open('requirements.txt', 'w').write('datasette')
|
|
||||||
|
if branch:
|
||||||
|
install_from = f'https://github.com/simonw/datasette/archive/{branch}.zip'
|
||||||
|
else:
|
||||||
|
install_from = 'datasette'
|
||||||
|
|
||||||
|
open('requirements.txt', 'w').write(install_from)
|
||||||
os.mkdir('bin')
|
os.mkdir('bin')
|
||||||
open('bin/post_compile', 'w').write('datasette build --inspect-file inspect-data.json')
|
open('bin/post_compile', 'w').write('datasette build --inspect-file inspect-data.json')
|
||||||
|
|
||||||
|
@ -245,211 +227,6 @@ def temporary_heroku_directory(files, name, metadata, extra_options, extra_metad
|
||||||
tmp.cleanup()
|
tmp.cleanup()
|
||||||
os.chdir(saved_cwd)
|
os.chdir(saved_cwd)
|
||||||
|
|
||||||
from contextlib import contextmanager
|
|
||||||
import base64
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import sqlite3
|
|
||||||
import tempfile
|
|
||||||
import time
|
|
||||||
import urllib
|
|
||||||
|
|
||||||
|
|
||||||
def compound_pks_from_path(path):
|
|
||||||
return [
|
|
||||||
urllib.parse.unquote_plus(b) for b in path.split(',')
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def path_from_row_pks(row, pks, use_rowid):
|
|
||||||
if use_rowid:
|
|
||||||
return urllib.parse.quote_plus(str(row['rowid']))
|
|
||||||
bits = []
|
|
||||||
for pk in pks:
|
|
||||||
bits.append(
|
|
||||||
urllib.parse.quote_plus(str(row[pk]))
|
|
||||||
)
|
|
||||||
return ','.join(bits)
|
|
||||||
|
|
||||||
|
|
||||||
def build_where_clauses(args):
|
|
||||||
sql_bits = []
|
|
||||||
params = {}
|
|
||||||
for i, (key, value) in enumerate(sorted(args.items())):
|
|
||||||
if '__' in key:
|
|
||||||
column, lookup = key.rsplit('__', 1)
|
|
||||||
else:
|
|
||||||
column = key
|
|
||||||
lookup = 'exact'
|
|
||||||
template = {
|
|
||||||
'exact': '"{}" = :{}',
|
|
||||||
'contains': '"{}" like :{}',
|
|
||||||
'endswith': '"{}" like :{}',
|
|
||||||
'startswith': '"{}" like :{}',
|
|
||||||
'gt': '"{}" > :{}',
|
|
||||||
'gte': '"{}" >= :{}',
|
|
||||||
'lt': '"{}" < :{}',
|
|
||||||
'lte': '"{}" <= :{}',
|
|
||||||
'glob': '"{}" glob :{}',
|
|
||||||
'like': '"{}" like :{}',
|
|
||||||
'isnull': '"{}" is null',
|
|
||||||
}[lookup]
|
|
||||||
numeric_operators = {'gt', 'gte', 'lt', 'lte'}
|
|
||||||
value_convert = {
|
|
||||||
'contains': lambda s: '%{}%'.format(s),
|
|
||||||
'endswith': lambda s: '%{}'.format(s),
|
|
||||||
'startswith': lambda s: '{}%'.format(s),
|
|
||||||
}.get(lookup, lambda s: s)
|
|
||||||
converted = value_convert(value)
|
|
||||||
if lookup in numeric_operators and converted.isdigit():
|
|
||||||
converted = int(converted)
|
|
||||||
if ':{}' in template:
|
|
||||||
param_id = 'p{}'.format(i)
|
|
||||||
params[param_id] = converted
|
|
||||||
tokens = (column, param_id)
|
|
||||||
else:
|
|
||||||
tokens = (column,)
|
|
||||||
sql_bits.append(
|
|
||||||
template.format(*tokens)
|
|
||||||
)
|
|
||||||
return sql_bits, params
|
|
||||||
|
|
||||||
|
|
||||||
class CustomJSONEncoder(json.JSONEncoder):
|
|
||||||
def default(self, obj):
|
|
||||||
if isinstance(obj, sqlite3.Row):
|
|
||||||
return tuple(obj)
|
|
||||||
if isinstance(obj, sqlite3.Cursor):
|
|
||||||
return list(obj)
|
|
||||||
if isinstance(obj, bytes):
|
|
||||||
# Does it encode to utf8?
|
|
||||||
try:
|
|
||||||
return obj.decode('utf8')
|
|
||||||
except UnicodeDecodeError:
|
|
||||||
return {
|
|
||||||
'$base64': True,
|
|
||||||
'encoded': base64.b64encode(obj).decode('latin1'),
|
|
||||||
}
|
|
||||||
return json.JSONEncoder.default(self, obj)
|
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def sqlite_timelimit(conn, ms):
|
|
||||||
deadline = time.time() + (ms / 1000)
|
|
||||||
# n is the number of SQLite virtual machine instructions that will be
|
|
||||||
# executed between each check. It's hard to know what to pick here.
|
|
||||||
# After some experimentation, I've decided to go with 1000 by default and
|
|
||||||
# 1 for time limits that are less than 50ms
|
|
||||||
n = 1000
|
|
||||||
if ms < 50:
|
|
||||||
n = 1
|
|
||||||
|
|
||||||
def handler():
|
|
||||||
if time.time() >= deadline:
|
|
||||||
return 1
|
|
||||||
|
|
||||||
conn.set_progress_handler(handler, n)
|
|
||||||
yield
|
|
||||||
conn.set_progress_handler(None, n)
|
|
||||||
|
|
||||||
|
|
||||||
class InvalidSql(Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def validate_sql_select(sql):
|
|
||||||
sql = sql.strip().lower()
|
|
||||||
if not sql.startswith('select '):
|
|
||||||
raise InvalidSql('Statement must begin with SELECT')
|
|
||||||
if 'pragma' in sql:
|
|
||||||
raise InvalidSql('Statement may not contain PRAGMA')
|
|
||||||
|
|
||||||
|
|
||||||
def path_with_added_args(request, args):
|
|
||||||
current = request.raw_args.copy()
|
|
||||||
current.update(args)
|
|
||||||
return request.path + '?' + urllib.parse.urlencode(current)
|
|
||||||
|
|
||||||
|
|
||||||
def path_with_ext(request, ext):
|
|
||||||
path = request.path
|
|
||||||
path += ext
|
|
||||||
if request.query_string:
|
|
||||||
path += '?' + request.query_string
|
|
||||||
return path
|
|
||||||
|
|
||||||
|
|
||||||
_css_re = re.compile(r'''['"\n\\]''')
|
|
||||||
_boring_table_name_re = re.compile(r'^[a-zA-Z_][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, metadata_file, extra_options=''):
|
|
||||||
cmd = ['"datasette"', '"serve"', '"--host"', '"0.0.0.0"']
|
|
||||||
cmd.append('"' + '", "'.join(files) + '"')
|
|
||||||
cmd.extend(['"--cors"', '"--port"', '"8001"', '"--inspect-file"', '"inspect-data.json"'])
|
|
||||||
if metadata_file:
|
|
||||||
cmd.extend(['"--metadata"', '"{}"'.format(metadata_file)])
|
|
||||||
if extra_options:
|
|
||||||
for opt in extra_options.split():
|
|
||||||
cmd.append('"{}"'.format(opt))
|
|
||||||
return '''
|
|
||||||
FROM python:3
|
|
||||||
COPY . /app
|
|
||||||
WORKDIR /app
|
|
||||||
RUN pip install datasette
|
|
||||||
RUN datasette build {} --inspect-file inspect-data.json
|
|
||||||
EXPOSE 8001
|
|
||||||
CMD [{}]'''.format(
|
|
||||||
' '.join(files),
|
|
||||||
', '.join(cmd)
|
|
||||||
).strip()
|
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def temporary_docker_directory(files, name, metadata, extra_options, extra_metadata=None):
|
|
||||||
extra_metadata = extra_metadata or {}
|
|
||||||
tmp = tempfile.TemporaryDirectory()
|
|
||||||
# We create a datasette folder in there to get a nicer now deploy name
|
|
||||||
datasette_dir = os.path.join(tmp.name, name)
|
|
||||||
os.mkdir(datasette_dir)
|
|
||||||
saved_cwd = os.getcwd()
|
|
||||||
file_paths = [
|
|
||||||
os.path.join(saved_cwd, name)
|
|
||||||
for name in files
|
|
||||||
]
|
|
||||||
file_names = [os.path.split(f)[-1] for f in files]
|
|
||||||
if metadata:
|
|
||||||
metadata_content = json.load(metadata)
|
|
||||||
else:
|
|
||||||
metadata_content = {}
|
|
||||||
for key, value in extra_metadata.items():
|
|
||||||
if value:
|
|
||||||
metadata_content[key] = value
|
|
||||||
try:
|
|
||||||
dockerfile = make_dockerfile(file_names, metadata_content and 'metadata.json', extra_options)
|
|
||||||
os.chdir(datasette_dir)
|
|
||||||
if metadata_content:
|
|
||||||
open('metadata.json', 'w').write(json.dumps(metadata_content, indent=2))
|
|
||||||
open('Dockerfile', 'w').write(dockerfile)
|
|
||||||
for path, filename in zip(file_paths, file_names):
|
|
||||||
os.link(path, os.path.join(datasette_dir, filename))
|
|
||||||
yield
|
|
||||||
finally:
|
|
||||||
tmp.cleanup()
|
|
||||||
os.chdir(saved_cwd)
|
|
||||||
|
|
||||||
|
|
||||||
def get_all_foreign_keys(conn):
|
def get_all_foreign_keys(conn):
|
||||||
tables = [r[0] for r in conn.execute('select name from sqlite_master where type="table"')]
|
tables = [r[0] for r in conn.execute('select name from sqlite_master where type="table"')]
|
||||||
|
@ -482,3 +259,123 @@ def get_all_foreign_keys(conn):
|
||||||
})
|
})
|
||||||
|
|
||||||
return table_to_foreign_keys
|
return table_to_foreign_keys
|
||||||
|
|
||||||
|
|
||||||
|
def detect_fts(conn, table, return_sql=False):
|
||||||
|
"Detect if table has a corresponding FTS virtual table and return it"
|
||||||
|
rows = conn.execute(detect_fts_sql(table)).fetchall()
|
||||||
|
if len(rows) == 0:
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
return rows[0][0]
|
||||||
|
|
||||||
|
|
||||||
|
def detect_fts_sql(table):
|
||||||
|
return r'''
|
||||||
|
select name from sqlite_master
|
||||||
|
where rootpage = 0
|
||||||
|
and (
|
||||||
|
sql like '%VIRTUAL TABLE%USING FTS%content="{table}"%'
|
||||||
|
or tbl_name = "{table}"
|
||||||
|
)
|
||||||
|
'''.format(table=table)
|
||||||
|
|
||||||
|
|
||||||
|
class Filter:
|
||||||
|
def __init__(self, key, sql_template, human_template, format='{}', numeric=False, no_argument=False):
|
||||||
|
self.key = key
|
||||||
|
self.sql_template = sql_template
|
||||||
|
self.human_template = human_template
|
||||||
|
self.format = format
|
||||||
|
self.numeric = numeric
|
||||||
|
self.no_argument = no_argument
|
||||||
|
|
||||||
|
def where_clause(self, column, value, param_counter):
|
||||||
|
converted = self.format.format(value)
|
||||||
|
if self.numeric and converted.isdigit():
|
||||||
|
converted = int(converted)
|
||||||
|
if self.no_argument:
|
||||||
|
kwargs = {
|
||||||
|
'c': column,
|
||||||
|
}
|
||||||
|
converted = None
|
||||||
|
else:
|
||||||
|
kwargs = {
|
||||||
|
'c': column,
|
||||||
|
'p': 'p{}'.format(param_counter),
|
||||||
|
}
|
||||||
|
return self.sql_template.format(**kwargs), converted
|
||||||
|
|
||||||
|
def human_clause(self, column, value):
|
||||||
|
if callable(self.human_template):
|
||||||
|
template = self.human_template(column, value)
|
||||||
|
else:
|
||||||
|
template = self.human_template
|
||||||
|
if self.no_argument:
|
||||||
|
return template.format(c=column)
|
||||||
|
else:
|
||||||
|
return template.format(c=column, v=value)
|
||||||
|
|
||||||
|
|
||||||
|
class Filters:
|
||||||
|
_filters = [
|
||||||
|
Filter('exact', '"{c}" = :{p}', lambda c, v: '{c} = {v}' if v.isdigit() else '{c} = "{v}"'),
|
||||||
|
Filter('contains', '"{c}" like :{p}', '{c} contains "{v}"', format='%{}%'),
|
||||||
|
Filter('endswith', '"{c}" like :{p}', '{c} ends with "{v}"', format='%{}'),
|
||||||
|
Filter('startswith', '"{c}" like :{p}', '{c} starts with "{v}"', format='{}%'),
|
||||||
|
Filter('gt', '"{c}" > :{p}', '{c} > {v}', numeric=True),
|
||||||
|
Filter('gte', '"{c}" >= :{p}', '{c} \u2265 {v}', numeric=True),
|
||||||
|
Filter('lt', '"{c}" < :{p}', '{c} < {v}', numeric=True),
|
||||||
|
Filter('lte', '"{c}" <= :{p}', '{c} \u2264 {v}', numeric=True),
|
||||||
|
Filter('glob', '"{c}" glob :{p}', '{c} glob "{v}"'),
|
||||||
|
Filter('like', '"{c}" like :{p}', '{c} like "{v}"'),
|
||||||
|
Filter('isnull', '"{c}" is null', '{c} is null', no_argument=True),
|
||||||
|
Filter('notnull', '"{c}" is not null', '{c} is not null', no_argument=True),
|
||||||
|
Filter('isblank', '("{c}" is null or "{c}" = "")', '{c} is blank', no_argument=True),
|
||||||
|
Filter('notblank', '("{c}" is not null and "{c}" != "")', '{c} is not blank', no_argument=True),
|
||||||
|
]
|
||||||
|
_filters_by_key = {
|
||||||
|
f.key: f for f in _filters
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, pairs):
|
||||||
|
self.pairs = pairs
|
||||||
|
|
||||||
|
def human_description(self):
|
||||||
|
bits = []
|
||||||
|
for key, value in self.pairs:
|
||||||
|
if '__' in key:
|
||||||
|
column, lookup = key.rsplit('__', 1)
|
||||||
|
else:
|
||||||
|
column = key
|
||||||
|
lookup = 'exact'
|
||||||
|
filter = self._filters_by_key.get(lookup, None)
|
||||||
|
if filter:
|
||||||
|
bits.append(filter.human_clause(column, value))
|
||||||
|
# Comma separated, with an ' and ' at the end
|
||||||
|
and_bits = []
|
||||||
|
commas, tail = bits[:-1], bits[-1:]
|
||||||
|
if commas:
|
||||||
|
and_bits.append(', '.join(commas))
|
||||||
|
if tail:
|
||||||
|
and_bits.append(tail[0])
|
||||||
|
return ' and '.join(and_bits)
|
||||||
|
|
||||||
|
def build_where_clauses(self):
|
||||||
|
sql_bits = []
|
||||||
|
params = {}
|
||||||
|
for i, (key, value) in enumerate(self.pairs):
|
||||||
|
if '__' in key:
|
||||||
|
column, lookup = key.rsplit('__', 1)
|
||||||
|
else:
|
||||||
|
column = key
|
||||||
|
lookup = 'exact'
|
||||||
|
filter = self._filters_by_key.get(lookup, None)
|
||||||
|
if filter:
|
||||||
|
sql_bit, param = filter.where_clause(column, value, i)
|
||||||
|
sql_bits.append(sql_bit)
|
||||||
|
if param is not None:
|
||||||
|
param_id = 'p{}'.format(i)
|
||||||
|
params[param_id] = param
|
||||||
|
return sql_bits, params
|
||||||
|
return ' and '.join(sql_bits), params
|
||||||
|
|
|
@ -4,6 +4,7 @@ import pytest
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope='module')
|
@pytest.fixture(scope='module')
|
||||||
|
@ -227,6 +228,38 @@ def test_row(app_client):
|
||||||
assert [{'pk': '1', 'content': 'hello'}] == response.json['rows']
|
assert [{'pk': '1', 'content': 'hello'}] == response.json['rows']
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_filter_redirects(app_client):
|
||||||
|
filter_args = urllib.parse.urlencode({
|
||||||
|
'_filter_column': 'content',
|
||||||
|
'_filter_op': 'startswith',
|
||||||
|
'_filter_value': 'x'
|
||||||
|
})
|
||||||
|
# First we need to resolve the correct path before testing more redirects
|
||||||
|
path_base = app_client.get(
|
||||||
|
'/test_tables/simple_primary_key', allow_redirects=False, gather_request=False
|
||||||
|
).headers['Location']
|
||||||
|
path = path_base + '?' + filter_args
|
||||||
|
response = app_client.get(path, allow_redirects=False, gather_request=False)
|
||||||
|
assert response.status == 302
|
||||||
|
assert response.headers['Location'].endswith('?content__startswith=x')
|
||||||
|
|
||||||
|
# Adding a redirect to an existing querystring:
|
||||||
|
path = path_base + '?foo=bar&' + filter_args
|
||||||
|
response = app_client.get(path, allow_redirects=False, gather_request=False)
|
||||||
|
assert response.status == 302
|
||||||
|
assert response.headers['Location'].endswith('?content__startswith=x&foo=bar')
|
||||||
|
|
||||||
|
# Test that op with a __x suffix overrides the filter value
|
||||||
|
path = path_base + '?' + urllib.parse.urlencode({
|
||||||
|
'_filter_column': 'content',
|
||||||
|
'_filter_op': 'isnull__5',
|
||||||
|
'_filter_value': 'x'
|
||||||
|
})
|
||||||
|
response = app_client.get(path, allow_redirects=False, gather_request=False)
|
||||||
|
assert response.status == 302
|
||||||
|
assert response.headers['Location'].endswith('?content__isnull=5')
|
||||||
|
|
||||||
|
|
||||||
TABLES = '''
|
TABLES = '''
|
||||||
CREATE TABLE simple_primary_key (
|
CREATE TABLE simple_primary_key (
|
||||||
pk varchar(30) primary key,
|
pk varchar(30) primary key,
|
||||||
|
|
|
@ -4,6 +4,7 @@ Tests for various datasette helper functions.
|
||||||
|
|
||||||
from datasette import utils
|
from datasette import utils
|
||||||
import pytest
|
import pytest
|
||||||
|
import sqlite3
|
||||||
import json
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
@ -99,7 +100,8 @@ def test_custom_json_encoder(obj, expected):
|
||||||
),
|
),
|
||||||
])
|
])
|
||||||
def test_build_where(args, expected_where, expected_params):
|
def test_build_where(args, expected_where, expected_params):
|
||||||
sql_bits, actual_params = utils.build_where_clauses(args)
|
f = utils.Filters(sorted(args.items()))
|
||||||
|
sql_bits, actual_params = f.build_where_clauses()
|
||||||
assert expected_where == sql_bits
|
assert expected_where == sql_bits
|
||||||
assert {
|
assert {
|
||||||
'p{}'.format(i): param
|
'p{}'.format(i): param
|
||||||
|
@ -124,3 +126,26 @@ def test_validate_sql_select_bad(bad_sql):
|
||||||
])
|
])
|
||||||
def test_validate_sql_select_good(good_sql):
|
def test_validate_sql_select_good(good_sql):
|
||||||
utils.validate_sql_select(good_sql)
|
utils.validate_sql_select(good_sql)
|
||||||
|
|
||||||
|
|
||||||
|
def test_detect_fts():
|
||||||
|
sql = '''
|
||||||
|
CREATE TABLE "Dumb_Table" (
|
||||||
|
"TreeID" INTEGER,
|
||||||
|
"qSpecies" TEXT
|
||||||
|
);
|
||||||
|
CREATE TABLE "Street_Tree_List" (
|
||||||
|
"TreeID" INTEGER,
|
||||||
|
"qSpecies" TEXT,
|
||||||
|
"qAddress" TEXT,
|
||||||
|
"SiteOrder" INTEGER,
|
||||||
|
"qSiteInfo" TEXT,
|
||||||
|
"PlantType" TEXT,
|
||||||
|
"qCaretaker" TEXT
|
||||||
|
);
|
||||||
|
CREATE VIRTUAL TABLE "Street_Tree_List_fts" USING FTS4 ("qAddress", "qCaretaker", "qSpecies", content="Street_Tree_List");
|
||||||
|
'''
|
||||||
|
conn = sqlite3.connect(':memory:')
|
||||||
|
conn.executescript(sql)
|
||||||
|
assert None is utils.detect_fts(conn, 'Dumb_Table')
|
||||||
|
assert 'Street_Tree_List_fts' == utils.detect_fts(conn, 'Street_Tree_List')
|
||||||
|
|
Ładowanie…
Reference in New Issue