This commit is contained in:
Evan Jones 2026-05-25 09:25:09 +00:00 committed by GitHub
commit 0093a5f643
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 250 additions and 13 deletions

View file

@ -57,7 +57,19 @@
>
{% endif %}
{% endif %}
{% if named_parameter_values %}
{# Debug lines removed to avoid Jinja2 Undefined errors #}
{% if canned_query and metadata and metadata.get('params') %}
<h3>Query parameters</h3>
{% for param in metadata['params'] %}
<p>
<label for="qp{{ loop.index }}">{{ param.name }}</label>
<input type="text" id="qp{{ loop.index }}" name="{{ param.name }}"
value="{{ named_parameter_values[param.name]|default(param.default, true) }}" {% if param['description'] %} title="{{ param['description'] }}"{% endif %}>
</p>
{% endfor %}
{% elif named_parameter_values %}
<h3>Query parameters</h3>
{% for name, value in named_parameter_values.items() %}
<p><label for="qp{{ loop.index }}">{{ name }}</label> <input type="text" id="qp{{ loop.index }}" name="{{ name }}" value="{{ value }}"></p>

View file

@ -1568,3 +1568,22 @@ def deep_dict_update(dict1, dict2):
else:
dict1[key] = value
return dict1
def normalize_canned_query_params(params: list) -> list[dict]:
"""
Normalize canned query parameters into a list of dicts with at least 'name'.
Accepts either a list of strings or dicts, returns list of dicts.
"""
normalized = []
for param in params:
if isinstance(param, str):
normalized.append({"name": param})
elif isinstance(param, dict):
# Copy all keys to preserve fields like 'description', 'default', etc.
normalized.append(dict(param))
else:
raise ValueError(
f"Canned query param must be str or dict, got {type(param)}"
)
return normalized

View file

@ -18,6 +18,7 @@ from datasette.utils import (
await_me_maybe,
call_with_supported_arguments,
named_parameters as derive_named_parameters,
normalize_canned_query_params,
format_bytes,
make_slot_function,
tilde_decode,
@ -576,18 +577,40 @@ class QueryView(View):
# Extract any :named parameters
named_parameters = []
if canned_query and canned_query.get("params"):
named_parameters = canned_query["params"]
if not named_parameters:
canned_query["params"] = normalize_canned_query_params(
canned_query["params"]
)
named_parameters = [p["name"] for p in canned_query["params"]]
else:
named_parameters = derive_named_parameters(sql)
named_parameter_values = {
named_parameter: params.get(named_parameter) or ""
for named_parameter in named_parameters
if not named_parameter.startswith("_")
}
# Set to blank string if missing from params
for named_parameter in named_parameters:
if named_parameter not in params and not named_parameter.startswith("_"):
params[named_parameter] = ""
if canned_query and canned_query.get("params"):
# Build a dict of param name -> param dict for lookup
param_dict_by_name = {p["name"]: p for p in canned_query["params"]}
named_parameter_values = {}
for named_parameter in named_parameters:
if named_parameter.startswith("_"):
continue
value = params.get(named_parameter)
if value is None or value == "":
# Use default if available
param_dict = param_dict_by_name.get(named_parameter, {})
value = param_dict.get("default", "")
named_parameter_values[named_parameter] = value
# Also set in params for downstream use
if named_parameter not in params:
params[named_parameter] = value
else:
named_parameter_values = {
named_parameter: params.get(named_parameter) or ""
for named_parameter in named_parameters
if not named_parameter.startswith("_")
}
# Set to blank string if missing from params
for named_parameter in named_parameters:
if named_parameter not in params and not named_parameter.startswith(
"_"
):
params[named_parameter] = ""
extra_args = {}
if params.get("_timelimit"):

View file

@ -270,6 +270,123 @@ You can alternatively provide an explicit list of named parameters using the ``"
}
.. [[[end]]]
.. _dictionary_based_canned_query_parameters:
Dictionary-based canned query parameters
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
The items in the ``params`` list can also be dictionaries. This allows for more detailed configuration of each parameter. When using a dictionary, it **must** have a ``name`` key. The following optional keys can also be used:
* ``description``: A string providing a human-readable description for the parameter. In the web interface, this description will be used as the ``title`` attribute for the input field, typically appearing as a tooltip when the user hovers over the field.
* ``default``: A string specifying the default value for the parameter. This value will pre-populate the input field in the form.
Here's an example of a canned query that uses dictionary-based parameter definitions to provide descriptions and default values:
.. [[[cog
config_example(cog, """
databases:
my_store:
queries:
product_filter:
title: Filter Products
sql: |-
SELECT name, price, category, stock_quantity
FROM products
WHERE category = :category
AND price < :max_price
AND stock_quantity >= :min_stock
LIMIT :results_limit;
params:
- name: "category"
description: "The product category to filter by (e.g., electronics, books)"
default: "electronics"
- name: "max_price"
description: "The maximum price for the product (e.g., 100.00)"
default: "100.00"
- name: "min_stock"
description: "Minimum stock quantity"
default: "1"
- name: "results_limit"
description: "Maximum number of results to return"
default: "10"
""")
.. ]]]
.. tab:: datasette.yaml
.. code-block:: yaml
databases:
my_store:
queries:
product_filter:
title: Filter Products
sql: |-
SELECT name, price, category, stock_quantity
FROM products
WHERE category = :category
AND price < :max_price
AND stock_quantity >= :min_stock
LIMIT :results_limit;
params:
- name: "category"
description: "The product category to filter by (e.g., electronics, books)"
default: "electronics"
- name: "max_price"
description: "The maximum price for the product (e.g., 100.00)"
default: "100.00"
- name: "min_stock"
description: "Minimum stock quantity"
default: "1"
- name: "results_limit"
description: "Maximum number of results to return"
default: "10"
.. tab:: datasette.json
.. code-block:: json
{
"databases": {
"my_store": {
"queries": {
"product_filter": {
"title": "Filter Products",
"sql": "SELECT name, price, category, stock_quantity\nFROM products\nWHERE category = :category\n AND price < :max_price\n AND stock_quantity >= :min_stock\nLIMIT :results_limit;",
"params": [
{
"name": "category",
"description": "The product category to filter by (e.g., electronics, books)",
"default": "electronics"
},
{
"name": "max_price",
"description": "The maximum price for the product (e.g., 100.00)",
"default": "100.00"
},
{
"name": "min_stock",
"description": "Minimum stock quantity",
"default": "1"
},
{
"name": "results_limit",
"description": "Maximum number of results to return",
"default": "10"
}
]
}
}
}
}
}
.. [[[end]]]
This configuration would generate a form with four input fields. The "category" field would default to "electronics" and have a tooltip explaining its purpose. Similar defaults and tooltips would apply to "max_price", "min_stock", and "results_limit".
.. _canned_queries_options:
Additional canned query options

View file

@ -196,7 +196,73 @@ def test_error_in_on_success_message_sql(canned_write_client):
def test_custom_params(canned_write_client):
response = canned_write_client.get("/data/update_name?extra=foo")
assert '<input type="text" id="qp3" name="extra" value="foo">' in response.text
soup = Soup(response.text, "html.parser")
extra_input = soup.find("input", {"name": "extra"})
assert extra_input is not None
assert extra_input.get("type") == "text"
assert extra_input.get("value") == "foo"
def test_canned_query_param_dicts(tmpdir):
"""
Test that canned query params as dicts (with default and description) render correctly.
"""
with make_app_client(
extra_databases={
"data.db": "create table demo (id integer primary key, val text, flag integer)"
},
config={
"databases": {
"data": {
"queries": {
"demo_query": {
"sql": "select * from demo where val = :val and flag = :flag",
"params": [
"id",
{
"name": "val",
"default": "foo",
"description": "The value to filter on",
},
{
"name": "flag",
"default": "1",
"description": "A flag (1 or 0)",
},
],
}
}
}
}
},
) as client:
# Test default values and tooltips
response = client.get("/data/demo_query")
soup = Soup(response.text, "html.parser")
id_input = soup.find("input", {"name": "id"})
val_input = soup.find("input", {"name": "val"})
flag_input = soup.find("input", {"name": "flag"})
assert id_input # Verify we still treat name-only params correctly
assert val_input # Verify param dicts are treated correctly
assert flag_input
assert val_input["value"] == "foo"
assert flag_input["value"] == "1"
assert val_input["title"] == "The value to filter on"
assert flag_input["title"] == "A flag (1 or 0)"
# Test user override
response2 = client.get("/data/demo_query?val=bar&flag=0")
soup2 = Soup(response2.text, "html.parser")
val_input2 = soup2.find("input", {"name": "val"})
flag_input2 = soup2.find("input", {"name": "flag"})
assert val_input2["value"] == "bar"
assert flag_input2["value"] == "0"
# Test fallback to default if param is blank
response3 = client.get("/data/demo_query?val=&flag=")
soup3 = Soup(response3.text, "html.parser")
val_input3 = soup3.find("input", {"name": "val"})
flag_input3 = soup3.find("input", {"name": "flag"})
assert val_input3["value"] == "foo"
assert flag_input3["value"] == "1"
def test_canned_query_pages_no_vary_header(canned_write_client):