{"html_url": "https://github.com/simonw/datasette/pull/2053#issuecomment-1498279469", "issue_url": "https://api.github.com/repos/simonw/datasette/issues/2053", "id": 1498279469, "node_id": "IC_kwDOBm6k_c5ZTe4t", "user": {"value": 9599, "label": "simonw"}, "created_at": "2023-04-05T23:28:53Z", "updated_at": "2023-04-05T23:28:53Z", "author_association": "OWNER", "body": "Table errors page currently does this:\r\n```json\r\n{\r\n \"ok\": false,\r\n \"error\": \"no such column: blah\",\r\n \"status\": 400,\r\n \"title\": \"Invalid SQL\"\r\n}\r\n```", "reactions": "{\"total_count\": 0, \"+1\": 0, \"-1\": 0, \"laugh\": 0, \"hooray\": 0, \"confused\": 0, \"heart\": 0, \"rocket\": 0, \"eyes\": 0}", "issue": {"value": 1656432059, "label": "WIP new JSON for queries"}, "performed_via_github_app": null} {"html_url": "https://github.com/simonw/datasette/pull/2053#issuecomment-1563285150", "issue_url": "https://api.github.com/repos/simonw/datasette/issues/2053", "id": 1563285150, "node_id": "IC_kwDOBm6k_c5dLdae", "user": {"value": 9599, "label": "simonw"}, "created_at": "2023-05-25T17:48:50Z", "updated_at": "2023-05-25T17:49:52Z", "author_association": "OWNER", "body": "Uncommitted experimental code:\r\n```diff\r\ndiff --git a/datasette/views/database.py b/datasette/views/database.py\r\nindex 455ebd1f..85775433 100644\r\n--- a/datasette/views/database.py\r\n+++ b/datasette/views/database.py\r\n@@ -909,12 +909,13 @@ async def query_view(\r\n elif format_ in datasette.renderers.keys():\r\n # Dispatch request to the correct output format renderer\r\n # (CSV is not handled here due to streaming)\r\n+ print(data)\r\n result = call_with_supported_arguments(\r\n datasette.renderers[format_][0],\r\n datasette=datasette,\r\n- columns=columns,\r\n- rows=rows,\r\n- sql=sql,\r\n+ columns=data[\"rows\"][0].keys(),\r\n+ rows=data[\"rows\"],\r\n+ sql='',\r\n query_name=None,\r\n database=db.name,\r\n table=None,\r\n@@ -923,7 +924,7 @@ async def query_view(\r\n # These will be deprecated in Datasette 1.0:\r\n args=request.args,\r\n data={\r\n- \"rows\": rows,\r\n+ \"rows\": data[\"rows\"],\r\n }, # TODO what should this be?\r\n )\r\n result = await await_me_maybe(result)\r\ndiff --git a/docs/index.rst b/docs/index.rst\r\nindex 5a9cc7ed..254ed3da 100644\r\n--- a/docs/index.rst\r\n+++ b/docs/index.rst\r\n@@ -57,6 +57,7 @@ Contents\r\n settings\r\n introspection\r\n custom_templates\r\n+ template_context\r\n plugins\r\n writing_plugins\r\n plugin_hooks\r\n```\r\nWhere `docs/template_context.rst` looked like this:\r\n```rst\r\n.. _template_context:\r\n\r\nTemplate context\r\n================\r\n\r\n.. currentmodule:: datasette.context\r\n\r\nThis page describes the variables made available to templates used by Datasette to render different pages of the application.\r\n\r\n.. autoclass:: QueryContext\r\n :members:\r\n```\r\nAnd `datasette/context.py` had this:\r\n```python\r\nfrom dataclasses import dataclass\r\n\r\n@dataclass\r\nclass QueryContext:\r\n \"\"\"\r\n Used by the ``/database`` page when showing the results of a SQL query\r\n \"\"\"\r\n id: int\r\n \"Id is a thing\"\r\n rows: list[dict]\r\n \"Name is another thing\"\r\n```", "reactions": "{\"total_count\": 0, \"+1\": 0, \"-1\": 0, \"laugh\": 0, \"hooray\": 0, \"confused\": 0, \"heart\": 0, \"rocket\": 0, \"eyes\": 0}", "issue": {"value": 1656432059, "label": "WIP new JSON for queries"}, "performed_via_github_app": null} {"html_url": "https://github.com/simonw/datasette/pull/2053#issuecomment-1563663616", "issue_url": "https://api.github.com/repos/simonw/datasette/issues/2053", "id": 1563663616, "node_id": "IC_kwDOBm6k_c5dM50A", "user": {"value": 9599, "label": "simonw"}, "created_at": "2023-05-26T00:32:08Z", "updated_at": "2023-05-26T00:32:08Z", "author_association": "OWNER", "body": "Now that I have the new `View` subclass from #2078 I want to use it to simplify this code.\r\n\r\nChallenge: there are several things to consider here:\r\n\r\n- The `/db` page without `?sql=` displays a list of tables in that database\r\n- With `?sql=` it shows the query results for that query (or an error)\r\n- If it's a `/db/name-of-canned-query` it works a bit like the query page, but executes a canned query instead of the `?sql=` query\r\n- POST `/db/name-of-canned-query` is support for writable canned queries", "reactions": "{\"total_count\": 0, \"+1\": 0, \"-1\": 0, \"laugh\": 0, \"hooray\": 0, \"confused\": 0, \"heart\": 0, \"rocket\": 0, \"eyes\": 0}", "issue": {"value": 1656432059, "label": "WIP new JSON for queries"}, "performed_via_github_app": null} {"html_url": "https://github.com/simonw/datasette/pull/2053#issuecomment-1563663925", "issue_url": "https://api.github.com/repos/simonw/datasette/issues/2053", "id": 1563663925, "node_id": "IC_kwDOBm6k_c5dM541", "user": {"value": 9599, "label": "simonw"}, "created_at": "2023-05-26T00:32:47Z", "updated_at": "2023-05-26T00:35:47Z", "author_association": "OWNER", "body": "I'm going to entirely split canned queries off from `?sql=` queries - they share a bunch of code right now which is just making everything much harder to follow.\r\n\r\nI'll refactor their shared bits into functions that they both call.\r\n\r\nOr _maybe_ I'll try having `CannedQueryView` as a subclass of `QueryView`.", "reactions": "{\"total_count\": 0, \"+1\": 0, \"-1\": 0, \"laugh\": 0, \"hooray\": 0, \"confused\": 0, \"heart\": 0, \"rocket\": 0, \"eyes\": 0}", "issue": {"value": 1656432059, "label": "WIP new JSON for queries"}, "performed_via_github_app": null} {"html_url": "https://github.com/simonw/datasette/pull/2053#issuecomment-1563667574", "issue_url": "https://api.github.com/repos/simonw/datasette/issues/2053", "id": 1563667574, "node_id": "IC_kwDOBm6k_c5dM6x2", "user": {"value": 9599, "label": "simonw"}, "created_at": "2023-05-26T00:40:22Z", "updated_at": "2023-05-26T00:40:22Z", "author_association": "OWNER", "body": "Or maybe...\r\n\r\n- `BaseQueryView(View)` - knows how to render the results of a SQL query\r\n- `QueryView(BaseQueryView)` - renders from `?sql=`\r\n- `CannedQueryView(BaseQueryView)` - renders for a named canned query\r\n\r\nAnd then later perhaps:\r\n\r\n- `RowQueryView(BaseQueryView)` - renders the `select * from t where pk = ?`\r\n- `TableQueryView(BaseQueryView)` - replaces the super complex existing `TableView`", "reactions": "{\"total_count\": 0, \"+1\": 0, \"-1\": 0, \"laugh\": 0, \"hooray\": 0, \"confused\": 0, \"heart\": 0, \"rocket\": 0, \"eyes\": 0}", "issue": {"value": 1656432059, "label": "WIP new JSON for queries"}, "performed_via_github_app": null} {"html_url": "https://github.com/simonw/datasette/pull/2053#issuecomment-1563793781", "issue_url": "https://api.github.com/repos/simonw/datasette/issues/2053", "id": 1563793781, "node_id": "IC_kwDOBm6k_c5dNZl1", "user": {"value": 9599, "label": "simonw"}, "created_at": "2023-05-26T04:27:55Z", "updated_at": "2023-05-26T04:27:55Z", "author_association": "OWNER", "body": "I should split out a `canned_query.html` template too, as something that extends the `query.html` template.", "reactions": "{\"total_count\": 0, \"+1\": 0, \"-1\": 0, \"laugh\": 0, \"hooray\": 0, \"confused\": 0, \"heart\": 0, \"rocket\": 0, \"eyes\": 0}", "issue": {"value": 1656432059, "label": "WIP new JSON for queries"}, "performed_via_github_app": null} {"html_url": "https://github.com/simonw/datasette/pull/2053#issuecomment-1565058994", "issue_url": "https://api.github.com/repos/simonw/datasette/issues/2053", "id": 1565058994, "node_id": "IC_kwDOBm6k_c5dSOey", "user": {"value": 9599, "label": "simonw"}, "created_at": "2023-05-26T23:13:02Z", "updated_at": "2023-05-26T23:13:02Z", "author_association": "OWNER", "body": "I should have an extra called `extra_html_context` which bundles together all of the weird extra stuff needed by the HTML template, and is then passed as the root context when the template is rendered (with the other stuff from extras patched into it).", "reactions": "{\"total_count\": 0, \"+1\": 0, \"-1\": 0, \"laugh\": 0, \"hooray\": 0, \"confused\": 0, \"heart\": 0, \"rocket\": 0, \"eyes\": 0}", "issue": {"value": 1656432059, "label": "WIP new JSON for queries"}, "performed_via_github_app": null} {"html_url": "https://github.com/simonw/datasette/pull/2053#issuecomment-1651874649", "issue_url": "https://api.github.com/repos/simonw/datasette/issues/2053", "id": 1651874649, "node_id": "IC_kwDOBm6k_c5idZtZ", "user": {"value": 9599, "label": "simonw"}, "created_at": "2023-07-26T14:03:37Z", "updated_at": "2023-07-26T14:03:37Z", "author_association": "OWNER", "body": "Big chunk of commented-out code I just removed:\r\n```python\r\n\r\n import pdb\r\n\r\n pdb.set_trace()\r\n\r\n if isinstance(output, dict) and output.get(\"ok\") is False:\r\n # TODO: Other error codes?\r\n\r\n response.status_code = 400\r\n\r\n if datasette.cors:\r\n add_cors_headers(response.headers)\r\n\r\n return response\r\n\r\n # registry = Registry(\r\n # extra_count,\r\n # extra_facet_results,\r\n # extra_facets_timed_out,\r\n # extra_suggested_facets,\r\n # facet_instances,\r\n # extra_human_description_en,\r\n # extra_next_url,\r\n # extra_columns,\r\n # extra_primary_keys,\r\n # run_display_columns_and_rows,\r\n # extra_display_columns,\r\n # extra_display_rows,\r\n # extra_debug,\r\n # extra_request,\r\n # extra_query,\r\n # extra_metadata,\r\n # extra_extras,\r\n # extra_database,\r\n # extra_table,\r\n # extra_database_color,\r\n # extra_table_actions,\r\n # extra_filters,\r\n # extra_renderers,\r\n # extra_custom_table_templates,\r\n # extra_sorted_facet_results,\r\n # extra_table_definition,\r\n # extra_view_definition,\r\n # extra_is_view,\r\n # extra_private,\r\n # extra_expandable_columns,\r\n # extra_form_hidden_args,\r\n # )\r\n\r\n results = await registry.resolve_multi(\r\n [\"extra_{}\".format(extra) for extra in extras]\r\n )\r\n data = {\r\n \"ok\": True,\r\n \"next\": next_value and str(next_value) or None,\r\n }\r\n data.update(\r\n {\r\n key.replace(\"extra_\", \"\"): value\r\n for key, value in results.items()\r\n if key.startswith(\"extra_\") and key.replace(\"extra_\", \"\") in extras\r\n }\r\n )\r\n raw_sqlite_rows = rows[:page_size]\r\n data[\"rows\"] = [dict(r) for r in raw_sqlite_rows]\r\n\r\n private = False\r\n if canned_query:\r\n # Respect canned query permissions\r\n visible, private = await datasette.check_visibility(\r\n request.actor,\r\n permissions=[\r\n (\"view-query\", (database, canned_query)),\r\n (\"view-database\", database),\r\n \"view-instance\",\r\n ],\r\n )\r\n if not visible:\r\n raise Forbidden(\"You do not have permission to view this query\")\r\n\r\n else:\r\n await datasette.ensure_permissions(request.actor, [(\"execute-sql\", database)])\r\n\r\n # If there's no sql, show the database index page\r\n if not sql:\r\n return await database_index_view(request, datasette, db)\r\n\r\n validate_sql_select(sql)\r\n\r\n # Extract any :named parameters\r\n named_parameters = named_parameters or await derive_named_parameters(db, sql)\r\n named_parameter_values = {\r\n named_parameter: params.get(named_parameter) or \"\"\r\n for named_parameter in named_parameters\r\n if not named_parameter.startswith(\"_\")\r\n }\r\n\r\n # Set to blank string if missing from params\r\n for named_parameter in named_parameters:\r\n if named_parameter not in params and not named_parameter.startswith(\"_\"):\r\n params[named_parameter] = \"\"\r\n\r\n extra_args = {}\r\n if params.get(\"_timelimit\"):\r\n extra_args[\"custom_time_limit\"] = int(params[\"_timelimit\"])\r\n if _size:\r\n extra_args[\"page_size\"] = _size\r\n\r\n templates = [f\"query-{to_css_class(database)}.html\", \"query.html\"]\r\n if canned_query:\r\n templates.insert(\r\n 0,\r\n f\"query-{to_css_class(database)}-{to_css_class(canned_query)}.html\",\r\n )\r\n\r\n query_error = None\r\n\r\n # Execute query - as write or as read\r\n if write:\r\n raise NotImplementedError(\"Write queries not yet implemented\")\r\n # if request.method == \"POST\":\r\n # # If database is immutable, return an error\r\n # if not db.is_mutable:\r\n # raise Forbidden(\"Database is immutable\")\r\n # body = await request.post_body()\r\n # body = body.decode(\"utf-8\").strip()\r\n # if body.startswith(\"{\") and body.endswith(\"}\"):\r\n # params = json.loads(body)\r\n # # But we want key=value strings\r\n # for key, value in params.items():\r\n # params[key] = str(value)\r\n # else:\r\n # params = dict(parse_qsl(body, keep_blank_values=True))\r\n # # Should we return JSON?\r\n # should_return_json = (\r\n # request.headers.get(\"accept\") == \"application/json\"\r\n # or request.args.get(\"_json\")\r\n # or params.get(\"_json\")\r\n # )\r\n # if canned_query:\r\n # params_for_query = MagicParameters(params, request, self.ds)\r\n # else:\r\n # params_for_query = params\r\n # ok = None\r\n # try:\r\n # cursor = await self.ds.databases[database].execute_write(\r\n # sql, params_for_query\r\n # )\r\n # message = metadata.get(\r\n # \"on_success_message\"\r\n # ) or \"Query executed, {} row{} affected\".format(\r\n # cursor.rowcount, \"\" if cursor.rowcount == 1 else \"s\"\r\n # )\r\n # message_type = self.ds.INFO\r\n # redirect_url = metadata.get(\"on_success_redirect\")\r\n # ok = True\r\n # except Exception as e:\r\n # message = metadata.get(\"on_error_message\") or str(e)\r\n # message_type = self.ds.ERROR\r\n # redirect_url = metadata.get(\"on_error_redirect\")\r\n # ok = False\r\n # if should_return_json:\r\n # return Response.json(\r\n # {\r\n # \"ok\": ok,\r\n # \"message\": message,\r\n # \"redirect\": redirect_url,\r\n # }\r\n # )\r\n # else:\r\n # self.ds.add_message(request, message, message_type)\r\n # return self.redirect(request, redirect_url or request.path)\r\n # else:\r\n\r\n # async def extra_template():\r\n # return {\r\n # \"request\": request,\r\n # \"db_is_immutable\": not db.is_mutable,\r\n # \"path_with_added_args\": path_with_added_args,\r\n # \"path_with_removed_args\": path_with_removed_args,\r\n # \"named_parameter_values\": named_parameter_values,\r\n # \"canned_query\": canned_query,\r\n # \"success_message\": request.args.get(\"_success\") or \"\",\r\n # \"canned_write\": True,\r\n # }\r\n\r\n # return (\r\n # {\r\n # \"database\": database,\r\n # \"rows\": [],\r\n # \"truncated\": False,\r\n # \"columns\": [],\r\n # \"query\": {\"sql\": sql, \"params\": params},\r\n # \"private\": private,\r\n # },\r\n # extra_template,\r\n # templates,\r\n # )\r\n\r\n # Not a write\r\n rows = []\r\n if canned_query:\r\n params_for_query = MagicParameters(params, request, datasette)\r\n else:\r\n params_for_query = params\r\n try:\r\n results = await datasette.execute(\r\n database, sql, params_for_query, truncate=True, **extra_args\r\n )\r\n columns = [r[0] for r in results.description]\r\n rows = list(results.rows)\r\n except sqlite3.DatabaseError as e:\r\n query_error = e\r\n results = None\r\n columns = []\r\n\r\n allow_execute_sql = await datasette.permission_allowed(\r\n request.actor, \"execute-sql\", database\r\n )\r\n\r\n format_ = request.url_vars.get(\"format\") or \"html\"\r\n\r\n if format_ == \"csv\":\r\n raise NotImplementedError(\"CSV format not yet implemented\")\r\n elif format_ in datasette.renderers.keys():\r\n # Dispatch request to the correct output format renderer\r\n # (CSV is not handled here due to streaming)\r\n result = call_with_supported_arguments(\r\n datasette.renderers[format_][0],\r\n datasette=datasette,\r\n columns=columns,\r\n rows=rows,\r\n sql=sql,\r\n query_name=None,\r\n database=db.name,\r\n table=None,\r\n request=request,\r\n view_name=\"table\", # TODO: should this be \"query\"?\r\n # These will be deprecated in Datasette 1.0:\r\n args=request.args,\r\n data={\r\n \"rows\": rows,\r\n }, # TODO what should this be?\r\n )\r\n result = await await_me_maybe(result)\r\n if result is None:\r\n raise NotFound(\"No data\")\r\n if isinstance(result, dict):\r\n r = Response(\r\n body=result.get(\"body\"),\r\n status=result.get(\"status_code\") or 200,\r\n content_type=result.get(\"content_type\", \"text/plain\"),\r\n headers=result.get(\"headers\"),\r\n )\r\n elif isinstance(result, Response):\r\n r = result\r\n # if status_code is not None:\r\n # # Over-ride the status code\r\n # r.status = status_code\r\n else:\r\n assert False, f\"{result} should be dict or Response\"\r\n elif format_ == \"html\":\r\n headers = {}\r\n templates = [f\"query-{to_css_class(database)}.html\", \"query.html\"]\r\n template = datasette.jinja_env.select_template(templates)\r\n alternate_url_json = datasette.absolute_url(\r\n request,\r\n datasette.urls.path(path_with_format(request=request, format=\"json\")),\r\n )\r\n headers.update(\r\n {\r\n \"Link\": '{}; rel=\"alternate\"; type=\"application/json+datasette\"'.format(\r\n alternate_url_json\r\n )\r\n }\r\n )\r\n r = Response.html(\r\n await datasette.render_template(\r\n template,\r\n dict(\r\n data,\r\n append_querystring=append_querystring,\r\n path_with_replaced_args=path_with_replaced_args,\r\n fix_path=datasette.urls.path,\r\n settings=datasette.settings_dict(),\r\n # TODO: review up all of these hacks:\r\n alternate_url_json=alternate_url_json,\r\n datasette_allow_facet=(\r\n \"true\" if datasette.setting(\"allow_facet\") else \"false\"\r\n ),\r\n is_sortable=any(c[\"sortable\"] for c in data[\"display_columns\"]),\r\n allow_execute_sql=await datasette.permission_allowed(\r\n request.actor, \"execute-sql\", resolved.db.name\r\n ),\r\n query_ms=1.2,\r\n select_templates=[\r\n f\"{'*' if template_name == template.name else ''}{template_name}\"\r\n for template_name in templates\r\n ],\r\n ),\r\n request=request,\r\n view_name=\"table\",\r\n ),\r\n headers=headers,\r\n )\r\n else:\r\n assert False, \"Invalid format: {}\".format(format_)\r\n # if next_url:\r\n # r.headers[\"link\"] = f'<{next_url}>; rel=\"next\"'\r\n return r\r\n```", "reactions": "{\"total_count\": 0, \"+1\": 0, \"-1\": 0, \"laugh\": 0, \"hooray\": 0, \"confused\": 0, \"heart\": 0, \"rocket\": 0, \"eyes\": 0}", "issue": {"value": 1656432059, "label": "WIP new JSON for queries"}, "performed_via_github_app": null} {"html_url": "https://github.com/simonw/datasette/pull/2053#issuecomment-1651883505", "issue_url": "https://api.github.com/repos/simonw/datasette/issues/2053", "id": 1651883505, "node_id": "IC_kwDOBm6k_c5idb3x", "user": {"value": 9599, "label": "simonw"}, "created_at": "2023-07-26T14:08:20Z", "updated_at": "2023-07-26T14:08:20Z", "author_association": "OWNER", "body": "I think the hardest part of getting this working is dealing with the different formats.\r\n\r\nIdea: refactor `.html` as a format (since it's by far the most complex) and tweak the plugin hook a bit as part of that, then use what I learn from that to get the other formats working.", "reactions": "{\"total_count\": 0, \"+1\": 0, \"-1\": 0, \"laugh\": 0, \"hooray\": 0, \"confused\": 0, \"heart\": 0, \"rocket\": 0, \"eyes\": 0}", "issue": {"value": 1656432059, "label": "WIP new JSON for queries"}, "performed_via_github_app": null} {"html_url": "https://github.com/simonw/datasette/pull/2053#issuecomment-1651894668", "issue_url": "https://api.github.com/repos/simonw/datasette/issues/2053", "id": 1651894668, "node_id": "IC_kwDOBm6k_c5idemM", "user": {"value": 9599, "label": "simonw"}, "created_at": "2023-07-26T14:14:34Z", "updated_at": "2023-07-26T14:14:34Z", "author_association": "OWNER", "body": "Another point of confusion is how `/content` sometimes serves the database index page (with a list of tables) and sometimes solves the results of a query.\r\n\r\nI could resolve this by turning the information on the index page into extras, which can optionally be requested any time a query is run but default to being shown if there is no query.", "reactions": "{\"total_count\": 0, \"+1\": 0, \"-1\": 0, \"laugh\": 0, \"hooray\": 0, \"confused\": 0, \"heart\": 0, \"rocket\": 0, \"eyes\": 0}", "issue": {"value": 1656432059, "label": "WIP new JSON for queries"}, "performed_via_github_app": null} {"html_url": "https://github.com/simonw/datasette/pull/2053#issuecomment-1651904060", "issue_url": "https://api.github.com/repos/simonw/datasette/issues/2053", "id": 1651904060, "node_id": "IC_kwDOBm6k_c5idg48", "user": {"value": 9599, "label": "simonw"}, "created_at": "2023-07-26T14:19:00Z", "updated_at": "2023-07-26T15:25:15Z", "author_association": "OWNER", "body": "Worth noting that the `register_output_renderer()` is actually pretty easy to extend, because it returns a dictionary which could have more keys (like the required set of extras) added to it:\r\n```python\r\n@hookimpl\r\ndef register_output_renderer(datasette):\r\n return {\r\n \"extension\": \"test\",\r\n \"render\": render_demo,\r\n \"can_render\": can_render_demo, # Optional\r\n }\r\n```\r\nhttps://docs.datasette.io/en/0.64.3/plugin_hooks.html#register-output-renderer-datasette\r\n\r\nOne slight hiccup with that plugin hook is this:\r\n\r\n> rows - list of `sqlite3.Row` objects\r\n\r\nI could turn that into a Datasette defined object that behaves like a [sqlite3.Row](https://docs.python.org/3/library/sqlite3.html#sqlite3.Row) though, which would give me extra flexibility in the future.\r\n\r\nA bit tricky though since it's implemented in C for performance: https://github.com/python/cpython/blob/b0202a4e5d6b629ba5acbc703e950f08ebaf07df/Modules/_sqlite/row.c\r\n\r\nPasted that into Claude for the following explanation:\r\n\r\n> - pysqlite_Row is the structure defining the Row object. It contains the tuple of data (self->data) and description of columns (self->description).\r\n> - pysqlite_row_new() is the constructor which creates a new Row object given a cursor and tuple of data.\r\n> - pysqlite_row_dealloc() frees the memory when Row object is deleted.\r\n> - pysqlite_row_keys() returns the column names of the row.\r\n> - pysqlite_row_length() and pysqlite_row_subscript() implement sequence like behavior to access row elements by index.\r\n> - pysqlite_row_subscript() also allows accessing by column name by doing a lookup in description.\r\n> - pysqlite_row_hash() and pysqlite_row_richcompare() implement equality checks and hash function.\r\n\r\nI could use protocols in Python to make my own `DatasetteRow` which can be used interchangeably with `sqlite3.Row` - https://docs.python.org/3/library/typing.html#typing.Protocol\r\n\r\nTurned this into a TIL: https://til.simonwillison.net/python/protocols", "reactions": "{\"total_count\": 0, \"+1\": 0, \"-1\": 0, \"laugh\": 0, \"hooray\": 0, \"confused\": 0, \"heart\": 0, \"rocket\": 0, \"eyes\": 0}", "issue": {"value": 1656432059, "label": "WIP new JSON for queries"}, "performed_via_github_app": null} {"html_url": "https://github.com/simonw/datasette/pull/2053#issuecomment-1652296467", "issue_url": "https://api.github.com/repos/simonw/datasette/issues/2053", "id": 1652296467, "node_id": "IC_kwDOBm6k_c5ifAsT", "user": {"value": 9599, "label": "simonw"}, "created_at": "2023-07-26T18:26:44Z", "updated_at": "2023-07-26T18:26:44Z", "author_association": "OWNER", "body": "I'm abandoning this branch in favour of a fresh attempt, described here:\r\n- https://github.com/simonw/datasette/issues/2109\r\n\r\nI'll copy bits and pieces of this branch across as-needed.", "reactions": "{\"total_count\": 0, \"+1\": 0, \"-1\": 0, \"laugh\": 0, \"hooray\": 0, \"confused\": 0, \"heart\": 0, \"rocket\": 0, \"eyes\": 0}", "issue": {"value": 1656432059, "label": "WIP new JSON for queries"}, "performed_via_github_app": null}