diff --git a/datasette/utils/__init__.py b/datasette/utils/__init__.py index 2dff9667..af46aa81 100644 --- a/datasette/utils/__init__.py +++ b/datasette/utils/__init__.py @@ -1039,6 +1039,11 @@ class MultiParams: """Return full list""" return self._data.get(name) or [] + def items(self): + """Yield (key, first_value) pairs, matching ``__getitem__`` semantics.""" + for key, values in self._data.items(): + yield key, values[0] + class ConnectionProblem(Exception): pass diff --git a/datasette/utils/asgi.py b/datasette/utils/asgi.py index 35f243b6..b531cf55 100644 --- a/datasette/utils/asgi.py +++ b/datasette/utils/asgi.py @@ -17,7 +17,7 @@ from datasette.utils.multipart import ( DEFAULT_MIN_FREE_DISK_BYTES, ) from mimetypes import guess_type -from urllib.parse import parse_qs, urlunparse, parse_qsl +from urllib.parse import parse_qs, urlunparse from pathlib import Path from http.cookies import SimpleCookie, Morsel import aiofiles @@ -153,7 +153,7 @@ class Request: async def post_vars(self): body = await self.post_body() - return dict(parse_qsl(body.decode("utf-8"), keep_blank_values=True)) + return MultiParams(parse_qs(qs=body.decode("utf-8"), keep_blank_values=True)) async def form( self, diff --git a/datasette/views/query_helpers.py b/datasette/views/query_helpers.py index f30a30bc..f114ba66 100644 --- a/datasette/views/query_helpers.py +++ b/datasette/views/query_helpers.py @@ -279,9 +279,9 @@ def _execute_write_disabled_reason(sql, analysis_error, analysis_rows): def _coerce_execute_write_payload(data, is_json): - if not isinstance(data, dict): - raise QueryValidationError("JSON must be a dictionary") if is_json: + if not isinstance(data, dict): + raise QueryValidationError("JSON must be a dictionary") invalid_keys = set(data) - {"sql", "params"} if invalid_keys: raise QueryValidationError( diff --git a/datasette/views/stored_queries.py b/datasette/views/stored_queries.py index 8c4e849e..73133627 100644 --- a/datasette/views/stored_queries.py +++ b/datasette/views/stored_queries.py @@ -357,14 +357,17 @@ class QueryStoreView(QueryCreateView): query_data = {} try: data, is_json = await _json_or_form_payload(request) - if not isinstance(data, dict): - raise QueryValidationError("JSON must be a dictionary") - query_data = data.get("query") if is_json else data - if not isinstance(query_data, dict): - raise QueryValidationError("JSON must contain a query dictionary") + if is_json: + if not isinstance(data, dict): + raise QueryValidationError("JSON must be a dictionary") + query_data = data.get("query") + if not isinstance(query_data, dict): + raise QueryValidationError("JSON must contain a query dictionary") + else: + query_data = data prepared = await _prepare_query_create(self.ds, request, db, query_data) except QueryValidationError as ex: - if not is_json and isinstance(query_data, dict): + if not is_json: return await self._error_response( request, db, query_data, ex.message, ex.status ) @@ -375,7 +378,7 @@ class QueryStoreView(QueryCreateView): try: await self.ds.add_query(db.name, name, replace=False, **prepared) except sqlite3.IntegrityError as ex: - if not is_json and isinstance(query_data, dict): + if not is_json: return await self._error_response(request, db, query_data, str(ex), 400) return _error([str(ex)], 400) @@ -426,8 +429,8 @@ class QueryUpdateView(BaseView): return _error(["Trusted queries cannot be updated using the API"], 403) try: - data, _ = await _json_or_form_payload(request) - if not isinstance(data, dict): + data, is_json = await _json_or_form_payload(request) + if is_json and not isinstance(data, dict): raise QueryValidationError("JSON must be a dictionary") invalid_keys = set(data) - {"update", "return"} if invalid_keys: diff --git a/docs/changelog.rst b/docs/changelog.rst index d5f8fa14..54802e09 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -56,6 +56,7 @@ Bug fixes - Fixed a bug where visiting ``//-/query`` without a ``?sql=`` parameter returned a 500 error. (:issue:`2743`) - The ``datasette inspect`` command now correctly records row counts for tables with more than 10,000 rows. (:issue:`2712`) +- ``await request.post_vars()`` now returns a :ref:`MultiParams ` object instead of a ``dict``, so multiple values for the same form field are preserved. Use ``.getlist(key)`` to retrieve every value. Existing ``post_vars[key]`` access continues to work, but now returns the *first* submitted value rather than the *last* (matching ``request.args`` semantics). (:issue:`2425`) .. _v1_0_a30: diff --git a/docs/internals.rst b/docs/internals.rst index f269155a..ffd26c00 100644 --- a/docs/internals.rst +++ b/docs/internals.rst @@ -103,8 +103,8 @@ The object also has the following awaitable methods: Don't forget to read about :ref:`internals_csrf`! -``await request.post_vars()`` - dictionary - Returns a dictionary of form variables that were submitted in the request body via ``POST`` using ``application/x-www-form-urlencoded`` encoding. For multipart forms or file uploads, use ``request.form()`` instead. +``await request.post_vars()`` - MultiParams + Returns a :ref:`MultiParams ` object of form variables that were submitted in the request body via ``POST`` using ``application/x-www-form-urlencoded`` encoding. This has the same shape as ``request.args``, so use ``.getlist(key)`` to retrieve every value submitted for keys with multiple values (such as ``