mirror of
https://github.com/simonw/datasette.git
synced 2026-05-31 22:27:00 +02:00
Merge e8417d58a4 into 6cafdcb6fa
This commit is contained in:
commit
0093a5f643
5 changed files with 250 additions and 13 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue