Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 38 additions & 2 deletions datasette/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,13 @@
from datasette.views.base import DatasetteError
from datasette.utils.asgi import BadRequest
import json
import re
from .utils import detect_json1, escape_sqlite, path_with_removed_args


looks_like_number_re = re.compile(r"^-?(?:\d+(?:\.\d+)?|\.\d+)$")


@hookimpl(specname="filters_from_request")
def where_filters(request, database, datasette):
# This one deals with ?_where=
Expand Down Expand Up @@ -408,14 +412,30 @@ def selections(self):
def has_selections(self):
return bool(self.pairs)

def build_where_clauses(self, table):
def build_where_clauses(self, table, column_details=None):
sql_bits = []
params = {}
i = 0
for column, lookup, value in self.selections():
filter = self._filters_by_key.get(lookup, None)
if filter:
sql_bit, param = filter.where_clause(table, column, value, i)
param_name = f"p{i}"
if self.should_compare_with_numeric_value(
column_details, column, lookup, value
):
numeric_comparison = (
f'"{column}" = :{param_name} or '
f"(typeof(\"{column}\") in ('integer', 'real') "
f'and "{column}" = CAST(:{param_name} AS NUMERIC))'
)
sql_bit = (
f"not ({numeric_comparison})"
if lookup == "not"
else f"({numeric_comparison})"
)
param = value
else:
sql_bit, param = filter.where_clause(table, column, value, i)
sql_bits.append(sql_bit)
if param is not None:
if not isinstance(param, list):
Expand All @@ -425,3 +445,19 @@ def build_where_clauses(self, table):
params[param_id] = individual_param
i += 1
return sql_bits, params

def should_compare_with_numeric_value(
self, column_details, column, lookup, value
):
if lookup not in ("exact", "not"):
return False
if not isinstance(value, str):
return False
if column_details is None:
return False
column_detail = column_details.get(column)
if column_detail is None:
return False
if (column_detail.type or "").strip():
return False
return bool(looks_like_number_re.match(value))
8 changes: 6 additions & 2 deletions datasette/views/table.py
Original file line number Diff line number Diff line change
Expand Up @@ -1159,7 +1159,9 @@ async def table_view_data(

# Introspect columns and primary keys for table
pks = await db.primary_keys(table_name)
table_columns = await db.table_columns(table_name)
table_column_details = await db.table_column_details(table_name)
table_columns = [col.name for col in table_column_details]
column_details = {col.name: col for col in table_column_details}

# Take ?_col= and ?_nocol= into account
specified_columns = await _columns_to_select(table_columns, pks, request)
Expand Down Expand Up @@ -1203,7 +1205,9 @@ async def table_view_data(

# Build where clauses from query string arguments
filters = Filters(sorted(filter_args))
where_clauses, params = filters.build_where_clauses(table_name)
where_clauses, params = filters.build_where_clauses(
table_name, column_details=column_details
)

# Execute filters_from_request plugin hooks - including the default
# ones that live in datasette/filters.py
Expand Down
61 changes: 61 additions & 0 deletions tests/test_table_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -559,6 +559,67 @@ async def test_table_filter_queries_multiple_of_same_type(ds_client):
] == response.json()["rows"]


@pytest.mark.asyncio
async def test_table_filter_view_on_numeric_computed_column(bare_ds):
db = bare_ds.add_memory_database("computed_column_filter")
await db.execute_write_script("""
CREATE TABLE items(
id INTEGER PRIMARY KEY,
category TEXT,
valid INTEGER
);

INSERT INTO items VALUES (1, 'a', 0);
INSERT INTO items VALUES (2, 'a', 1);
INSERT INTO items VALUES (3, 'a', 0);
INSERT INTO items VALUES (4, 'b', 0);
INSERT INTO items VALUES (5, 'b', 0);

CREATE VIEW summary AS
SELECT category,
SUM(CASE WHEN valid THEN 1 ELSE 0 END) AS valid_count,
SUM(CASE WHEN NOT valid THEN 1 ELSE 0 END) AS invalid_count
FROM items
GROUP BY category;
""")
response = await bare_ds.client.get(
"/computed_column_filter/summary.json?_shape=objects&valid_count__exact=0"
)
assert response.json()["rows"] == [
{"category": "b", "valid_count": 0, "invalid_count": 2}
]

response = await bare_ds.client.get(
"/computed_column_filter/summary.json?_shape=objects&valid_count__not=0"
)
assert response.json()["rows"] == [
{"category": "a", "valid_count": 1, "invalid_count": 2}
]


@pytest.mark.asyncio
async def test_table_filter_view_on_text_computed_column_preserves_exact_text(bare_ds):
db = bare_ds.add_memory_database("computed_text_column_filter")
await db.execute_write_script("""
CREATE TABLE items(
id INTEGER PRIMARY KEY,
code TEXT
);

INSERT INTO items VALUES (1, '0');
INSERT INTO items VALUES (2, '00');

CREATE VIEW summary AS
SELECT code,
code || '' AS code_text
FROM items;
""")
response = await bare_ds.client.get(
"/computed_text_column_filter/summary.json?_shape=objects&code_text__exact=00"
)
assert response.json()["rows"] == [{"code": "00", "code_text": "00"}]


@pytest.mark.skipif(not detect_json1(), reason="Requires the SQLite json1 module")
@pytest.mark.asyncio
async def test_table_filter_json_arraycontains(ds_client):
Expand Down