{"html_url": "https://github.com/simonw/datasette/issues/1878#issuecomment-1336100218", "issue_url": "https://api.github.com/repos/simonw/datasette/issues/1878", "id": 1336100218, "node_id": "IC_kwDOBm6k_c5Po0V6", "user": {"value": 9599, "label": "simonw"}, "created_at": "2022-12-03T07:02:15Z", "updated_at": "2022-12-03T07:02:15Z", "author_association": "OWNER", "body": "Moved this work to a PR:\r\n- #1931", "reactions": "{\"total_count\": 0, \"+1\": 0, \"-1\": 0, \"laugh\": 0, \"hooray\": 0, \"confused\": 0, \"heart\": 0, \"rocket\": 0, \"eyes\": 0}", "issue": {"value": 1432013704, "label": "/db/table/-/upsert API"}, "performed_via_github_app": null} {"html_url": "https://github.com/simonw/datasette/issues/1878#issuecomment-1336094562", "issue_url": "https://api.github.com/repos/simonw/datasette/issues/1878", "id": 1336094562, "node_id": "IC_kwDOBm6k_c5Poy9i", "user": {"value": 9599, "label": "simonw"}, "created_at": "2022-12-03T06:27:50Z", "updated_at": "2022-12-03T06:29:06Z", "author_association": "OWNER", "body": "This adds it to the API explorer:\r\n\r\n```diff\r\ndiff --git a/datasette/views/special.py b/datasette/views/special.py\r\nindex 1f84b094..1b4a9d3c 100644\r\n--- a/datasette/views/special.py\r\n+++ b/datasette/views/special.py\r\n@@ -316,21 +316,37 @@ class ApiExplorerView(BaseView):\r\n request.actor, \"insert-row\", (name, table)\r\n ):\r\n pks = await db.primary_keys(table)\r\n- table_links.append(\r\n- {\r\n- \"path\": self.ds.urls.table(name, table) + \"/-/insert\",\r\n- \"method\": \"POST\",\r\n- \"label\": \"Insert rows into {}\".format(table),\r\n- \"json\": {\r\n- \"rows\": [\r\n- {\r\n- column: None\r\n- for column in await db.table_columns(table)\r\n- if column not in pks\r\n- }\r\n- ]\r\n+ table_links.extend(\r\n+ [\r\n+ {\r\n+ \"path\": self.ds.urls.table(name, table) + \"/-/insert\",\r\n+ \"method\": \"POST\",\r\n+ \"label\": \"Insert rows into {}\".format(table),\r\n+ \"json\": {\r\n+ \"rows\": [\r\n+ {\r\n+ column: None\r\n+ for column in await db.table_columns(table)\r\n+ if column not in pks\r\n+ }\r\n+ ]\r\n+ },\r\n },\r\n- }\r\n+ {\r\n+ \"path\": self.ds.urls.table(name, table) + \"/-/upsert\",\r\n+ \"method\": \"POST\",\r\n+ \"label\": \"Upsert rows into {}\".format(table),\r\n+ \"json\": {\r\n+ \"rows\": [\r\n+ {\r\n+ column: None\r\n+ for column in await db.table_columns(table)\r\n+ if column not in pks\r\n+ }\r\n+ ]\r\n+ },\r\n+ },\r\n+ ]\r\n )\r\n if await self.ds.permission_allowed(\r\n request.actor, \"drop-table\", (name, table)\r\n```\r\nExcept it doesn't quite, because the example JSON this generates is invalid as it does not include the primary key column.\r\n\r\n(Made me notice that the way example columns are created for `/-/insert` will fail for tables that don't have an auto-incrementing primary key)", "reactions": "{\"total_count\": 0, \"+1\": 0, \"-1\": 0, \"laugh\": 0, \"hooray\": 0, \"confused\": 0, \"heart\": 0, \"rocket\": 0, \"eyes\": 0}", "issue": {"value": 1432013704, "label": "/db/table/-/upsert API"}, "performed_via_github_app": null} {"html_url": "https://github.com/simonw/datasette/issues/1878#issuecomment-1336094470", "issue_url": "https://api.github.com/repos/simonw/datasette/issues/1878", "id": 1336094470, "node_id": "IC_kwDOBm6k_c5Poy8G", "user": {"value": 9599, "label": "simonw"}, "created_at": "2022-12-03T06:27:13Z", "updated_at": "2022-12-03T06:27:13Z", "author_association": "OWNER", "body": "Tests are going to need to cover both rowid-only and compound primary key tables, including all of the error states.", "reactions": "{\"total_count\": 0, \"+1\": 0, \"-1\": 0, \"laugh\": 0, \"hooray\": 0, \"confused\": 0, \"heart\": 0, \"rocket\": 0, \"eyes\": 0}", "issue": {"value": 1432013704, "label": "/db/table/-/upsert API"}, "performed_via_github_app": null} {"html_url": "https://github.com/simonw/datasette/issues/1878#issuecomment-1336094381", "issue_url": "https://api.github.com/repos/simonw/datasette/issues/1878", "id": 1336094381, "node_id": "IC_kwDOBm6k_c5Poy6t", "user": {"value": 9599, "label": "simonw"}, "created_at": "2022-12-03T06:26:25Z", "updated_at": "2022-12-03T06:26:25Z", "author_association": "OWNER", "body": "Initial prototype:\r\n```diff\r\ndiff --git a/datasette/app.py b/datasette/app.py\r\nindex 125b4969..282c0984 100644\r\n--- a/datasette/app.py\r\n+++ b/datasette/app.py\r\n@@ -40,7 +40,7 @@ from .views.special import (\r\n PermissionsDebugView,\r\n MessagesDebugView,\r\n )\r\n-from .views.table import TableView, TableInsertView, TableDropView\r\n+from .views.table import TableView, TableInsertView, TableUpsertView, TableDropView\r\n from .views.row import RowView, RowDeleteView, RowUpdateView\r\n from .renderer import json_renderer\r\n from .url_builder import Urls\r\n@@ -1292,6 +1292,10 @@ class Datasette:\r\n TableInsertView.as_view(self),\r\n r\"/(?P[^\\/\\.]+)/(?P[^\\/\\.]+)/-/insert$\",\r\n )\r\n+ add_route(\r\n+ TableUpsertView.as_view(self),\r\n+ r\"/(?P[^\\/\\.]+)/(?P
[^\\/\\.]+)/-/upsert$\",\r\n+ )\r\n add_route(\r\n TableDropView.as_view(self),\r\n r\"/(?P[^\\/\\.]+)/(?P
[^\\/\\.]+)/-/drop$\",\r\ndiff --git a/datasette/views/table.py b/datasette/views/table.py\r\nindex 7ba78c11..ae0d6366 100644\r\n--- a/datasette/views/table.py\r\n+++ b/datasette/views/table.py\r\n@@ -1074,9 +1074,15 @@ class TableInsertView(BaseView):\r\n def __init__(self, datasette):\r\n self.ds = datasette\r\n \r\n- async def _validate_data(self, request, db, table_name):\r\n+ async def _validate_data(self, request, db, table_name, pks, upsert):\r\n errors = []\r\n \r\n+ pks_list = []\r\n+ if isinstance(pks, str):\r\n+ pks_list = [pks]\r\n+ else:\r\n+ pks_list = list(pks)\r\n+\r\n def _errors(errors):\r\n return None, errors, {}\r\n \r\n@@ -1135,6 +1141,15 @@ class TableInsertView(BaseView):\r\n # Validate columns of each row\r\n columns = set(await db.table_columns(table_name))\r\n for i, row in enumerate(rows):\r\n+ if upsert:\r\n+ # It MUST have the primary key\r\n+ missing_pks = [pk for pk in pks_list if pk not in row]\r\n+ if missing_pks:\r\n+ errors.append(\r\n+ 'Row {} is missing primary key column(s): \"{}\"'.format(\r\n+ i, '\", \"'.join(missing_pks)\r\n+ )\r\n+ )\r\n invalid_columns = set(row.keys()) - columns\r\n if invalid_columns:\r\n errors.append(\r\n@@ -1146,7 +1161,7 @@ class TableInsertView(BaseView):\r\n return _errors(errors)\r\n return rows, errors, extras\r\n \r\n- async def post(self, request):\r\n+ async def post(self, request, upsert=False):\r\n try:\r\n resolved = await self.ds.resolve_table(request)\r\n except NotFound as e:\r\n@@ -1164,7 +1179,12 @@ class TableInsertView(BaseView):\r\n request.actor, \"insert-row\", resource=(database_name, table_name)\r\n ):\r\n return _error([\"Permission denied\"], 403)\r\n- rows, errors, extras = await self._validate_data(request, db, table_name)\r\n+\r\n+ pks = await db.primary_keys(table_name)\r\n+\r\n+ rows, errors, extras = await self._validate_data(\r\n+ request, db, table_name, pks, upsert\r\n+ )\r\n if errors:\r\n return _error(errors, 400)\r\n \r\n@@ -1172,15 +1192,19 @@ class TableInsertView(BaseView):\r\n replace = extras.get(\"replace\")\r\n \r\n should_return = bool(extras.get(\"return\", False))\r\n- # Insert rows\r\n- def insert_rows(conn):\r\n+\r\n+ def insert_or_upsert_rows(conn):\r\n table = sqlite_utils.Database(conn)[table_name]\r\n+ kwargs = {}\r\n+ if upsert:\r\n+ kwargs[\"pk\"] = pks[0] if len(pks) == 1 else pks\r\n+ else:\r\n+ kwargs = {\"ignore\": ignore, \"replace\": replace}\r\n if should_return:\r\n rowids = []\r\n+ method = table.upsert if upsert else table.insert\r\n for row in rows:\r\n- rowids.append(\r\n- table.insert(row, ignore=ignore, replace=replace).last_rowid\r\n- )\r\n+ rowids.append(method(row, **kwargs).last_rowid)\r\n return list(\r\n table.rows_where(\r\n \"rowid in ({})\".format(\",\".join(\"?\" for _ in rowids)),\r\n@@ -1188,10 +1212,11 @@ class TableInsertView(BaseView):\r\n )\r\n )\r\n else:\r\n- table.insert_all(rows, ignore=ignore, replace=replace)\r\n+ method_all = table.upsert_all if upsert else table.insert_all\r\n+ method_all(rows, **kwargs)\r\n \r\n try:\r\n- rows = await db.execute_write_fn(insert_rows)\r\n+ rows = await db.execute_write_fn(insert_or_upsert_rows)\r\n except Exception as e:\r\n return _error([str(e)])\r\n result = {\"ok\": True}\r\n@@ -1200,6 +1225,13 @@ class TableInsertView(BaseView):\r\n return Response.json(result, status=201)\r\n \r\n \r\n+class TableUpsertView(TableInsertView):\r\n+ name = \"table-upsert\"\r\n+\r\n+ async def post(self, request):\r\n+ return await super().post(request, upsert=True)\r\n+\r\n+\r\n class TableDropView(BaseView):\r\n name = \"table-drop\"\r\n ```\r\nManual testing reveals that this mostly works... but it's not doing the right thing for `\"return\": true` - it always returns an empty list.", "reactions": "{\"total_count\": 0, \"+1\": 0, \"-1\": 0, \"laugh\": 0, \"hooray\": 0, \"confused\": 0, \"heart\": 0, \"rocket\": 0, \"eyes\": 0}", "issue": {"value": 1432013704, "label": "/db/table/-/upsert API"}, "performed_via_github_app": null} {"html_url": "https://github.com/simonw/datasette/issues/1878#issuecomment-1336073212", "issue_url": "https://api.github.com/repos/simonw/datasette/issues/1878", "id": 1336073212, "node_id": "IC_kwDOBm6k_c5Potv8", "user": {"value": 9599, "label": "simonw"}, "created_at": "2022-12-03T05:38:49Z", "updated_at": "2022-12-03T05:38:49Z", "author_association": "OWNER", "body": "And on Discord today: https://discord.com/channels/823971286308356157/823971286941302908/1048426072066236536", "reactions": "{\"total_count\": 0, \"+1\": 0, \"-1\": 0, \"laugh\": 0, \"hooray\": 0, \"confused\": 0, \"heart\": 0, \"rocket\": 0, \"eyes\": 0}", "issue": {"value": 1432013704, "label": "/db/table/-/upsert API"}, "performed_via_github_app": null} {"html_url": "https://github.com/simonw/datasette/issues/1878#issuecomment-1336070843", "issue_url": "https://api.github.com/repos/simonw/datasette/issues/1878", "id": 1336070843, "node_id": "IC_kwDOBm6k_c5PotK7", "user": {"value": 9599, "label": "simonw"}, "created_at": "2022-12-03T05:37:53Z", "updated_at": "2022-12-03T05:37:53Z", "author_association": "OWNER", "body": "Also requested here: https://news.ycombinator.com/item?id=33839894", "reactions": "{\"total_count\": 0, \"+1\": 0, \"-1\": 0, \"laugh\": 0, \"hooray\": 0, \"confused\": 0, \"heart\": 0, \"rocket\": 0, \"eyes\": 0}", "issue": {"value": 1432013704, "label": "/db/table/-/upsert API"}, "performed_via_github_app": null} {"html_url": "https://github.com/simonw/datasette/issues/1878#issuecomment-1312534826", "issue_url": "https://api.github.com/repos/simonw/datasette/issues/1878", "id": 1312534826, "node_id": "IC_kwDOBm6k_c5OO7Eq", "user": {"value": 18738650, "label": "stevecrawshaw"}, "created_at": "2022-11-12T17:34:58Z", "updated_at": "2022-11-12T17:34:58Z", "author_association": "NONE", "body": "Hi Simon. I have just started experimenting with datasette in earnest, looking at it's suitability for air quality open data. A bulk upsert \\ upsert_all would be very useful for me in enabling real time data to be pushed from a sql server database with FME server to a datasette db. \r\n\r\nAn hourly process queries the last 2 hours of data and pushes that to my database, inserting new data and updating existing combinations of pk siteid and date_time. This is already implemented on our current [open data portal](https://opendata.bristol.gov.uk/explore/dataset/air-quality-data-continuous/table/?disjunctive.location&sort=date_time). Excited to see your progress with this! Thank you for this amazing software.", "reactions": "{\"total_count\": 0, \"+1\": 0, \"-1\": 0, \"laugh\": 0, \"hooray\": 0, \"confused\": 0, \"heart\": 0, \"rocket\": 0, \"eyes\": 0}", "issue": {"value": 1432013704, "label": "/db/table/-/upsert API"}, "performed_via_github_app": null} {"html_url": "https://github.com/simonw/datasette/issues/1878#issuecomment-1299071456", "issue_url": "https://api.github.com/repos/simonw/datasette/issues/1878", "id": 1299071456, "node_id": "IC_kwDOBm6k_c5NbkHg", "user": {"value": 9599, "label": "simonw"}, "created_at": "2022-11-01T20:02:43Z", "updated_at": "2022-11-01T20:02:43Z", "author_association": "OWNER", "body": "Note that \"update\" is partially covered by the `replace` option to `/-/insert`, added here:\r\n- https://github.com/simonw/datasette/issues/1873#issuecomment-1298885451\r\n\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": 1432013704, "label": "/db/table/-/upsert API"}, "performed_via_github_app": null}