From e8417d58a4b091d97db6203d69042347646b954f Mon Sep 17 00:00:00 2001 From: Evan Jones Date: Tue, 3 Jun 2025 17:02:21 -0500 Subject: [PATCH] feat: add support for dictionary-based canned query parameters with defaults and descriptions. Resolves #1258 --- datasette/templates/query.html | 14 +++- datasette/utils/__init__.py | 19 ++++++ datasette/views/database.py | 45 +++++++++---- docs/sql_queries.rst | 117 +++++++++++++++++++++++++++++++++ tests/test_canned_queries.py | 68 ++++++++++++++++++- 5 files changed, 250 insertions(+), 13 deletions(-) diff --git a/datasette/templates/query.html b/datasette/templates/query.html index a6e9a3aa..97e6726d 100644 --- a/datasette/templates/query.html +++ b/datasette/templates/query.html @@ -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') %} +

Query parameters

+ {% for param in metadata['params'] %} +

+ + +

+ {% endfor %} + {% elif named_parameter_values %}

Query parameters

{% for name, value in named_parameter_values.items() %}

diff --git a/datasette/utils/__init__.py b/datasette/utils/__init__.py index 38a16b79..5fd7dc4f 100644 --- a/datasette/utils/__init__.py +++ b/datasette/utils/__init__.py @@ -1460,3 +1460,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 diff --git a/datasette/views/database.py b/datasette/views/database.py index 33ee07b3..55487d3c 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -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, @@ -493,18 +494,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"): diff --git a/docs/sql_queries.rst b/docs/sql_queries.rst index a95ccc87..c2093b9d 100644 --- a/docs/sql_queries.rst +++ b/docs/sql_queries.rst @@ -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 diff --git a/tests/test_canned_queries.py b/tests/test_canned_queries.py index c84c8cdb..e9b08a14 100644 --- a/tests/test_canned_queries.py +++ b/tests/test_canned_queries.py @@ -215,7 +215,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 '' 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_vary_header(canned_write_client):