From 55451db009e3060c21948ece2c97e102a3c9b171 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sun, 5 Jul 2026 23:08:08 +0200 Subject: [PATCH] fix(datatable): self-teaching error for unresolved datatable:// references (#9941) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ATTACH 'datatable://main'` (or any datatable schema/executor path) failed with a bare "datatable main not found", giving the user no way forward — the datatable substrate has no auto-provisioning like a DuckLake catalog, so the fix is always to create one in workspace settings, but nothing said so. `get_datatable_resource_from_db_unchecked` now returns a NotFound error that lists the workspace's configured data tables (to catch typos) and points at the "Data tables" settings tab, noting `main` is the default name used by `datatable://main`. The message bubbles up wherever the resolver is called (pipeline ATTACH, schema fetch, postgres executor, agent HTTP endpoint). Co-authored-by: Claude Opus 4.8 --- ...3656e27f1c962c2f0438e0cc06530661fbf8.json} | 7 +-- backend/windmill-common/src/workspaces.rs | 62 +++++++++++++++++-- 2 files changed, 59 insertions(+), 10 deletions(-) rename backend/.sqlx/{query-caf4dc1046769f410d1277cdd3747763e0d674e8a1d9f3512629cb5a9e5a3439.json => query-dc60d43814e2b5220db648677dbb3656e27f1c962c2f0438e0cc06530661fbf8.json} (53%) diff --git a/backend/.sqlx/query-caf4dc1046769f410d1277cdd3747763e0d674e8a1d9f3512629cb5a9e5a3439.json b/backend/.sqlx/query-dc60d43814e2b5220db648677dbb3656e27f1c962c2f0438e0cc06530661fbf8.json similarity index 53% rename from backend/.sqlx/query-caf4dc1046769f410d1277cdd3747763e0d674e8a1d9f3512629cb5a9e5a3439.json rename to backend/.sqlx/query-dc60d43814e2b5220db648677dbb3656e27f1c962c2f0438e0cc06530661fbf8.json index f2398bf143..0d36677337 100644 --- a/backend/.sqlx/query-caf4dc1046769f410d1277cdd3747763e0d674e8a1d9f3512629cb5a9e5a3439.json +++ b/backend/.sqlx/query-dc60d43814e2b5220db648677dbb3656e27f1c962c2f0438e0cc06530661fbf8.json @@ -1,17 +1,16 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT ws.datatable->'datatables'->$2 AS config\n FROM workspace_settings ws\n WHERE ws.workspace_id = $1\n ", + "query": "\n SELECT ws.datatable->'datatables' AS datatables\n FROM workspace_settings ws\n WHERE ws.workspace_id = $1\n ", "describe": { "columns": [ { "ordinal": 0, - "name": "config", + "name": "datatables", "type_info": "Jsonb" } ], "parameters": { "Left": [ - "Text", "Text" ] }, @@ -19,5 +18,5 @@ null ] }, - "hash": "caf4dc1046769f410d1277cdd3747763e0d674e8a1d9f3512629cb5a9e5a3439" + "hash": "dc60d43814e2b5220db648677dbb3656e27f1c962c2f0438e0cc06530661fbf8" } diff --git a/backend/windmill-common/src/workspaces.rs b/backend/windmill-common/src/workspaces.rs index 14bc5fc344..3b5220d46f 100644 --- a/backend/windmill-common/src/workspaces.rs +++ b/backend/windmill-common/src/workspaces.rs @@ -796,25 +796,55 @@ pub enum DataTableCatalogResourceType { Instance, } +/// Build a self-teaching error for an unresolved `datatable://` reference. +/// The raw "not found" gives the user no way forward — the datatable substrate has +/// no auto-provisioning (unlike a DuckLake catalog), so the fix is always to create +/// one in workspace settings. Surface the available names (to catch typos) and point +/// at the settings page so the message is actionable wherever it bubbles up +/// (pipeline `ATTACH`, schema fetch, postgres executor, ...). +fn datatable_not_found_error(name: &str, datatables: Option<&serde_json::Value>) -> Error { + let available: Vec<&str> = datatables + .and_then(|d| d.as_object()) + .map(|o| o.keys().map(String::as_str).collect()) + .unwrap_or_default(); + + let hint = if available.is_empty() { + "No data table is configured in this workspace yet.".to_string() + } else { + format!("Configured data tables: {}.", available.join(", ")) + }; + + Error::NotFound(format!( + "Data table '{name}' not found. {hint} \ + Create one in workspace settings under the \"Data tables\" tab \ + (/workspace_settings?tab=windmill_data_tables) — the name \"main\" is the default \ + used by `datatable://main`." + )) +} + pub async fn get_datatable_resource_from_db_unchecked( db: &DB, w_id: &str, name: &str, ) -> Result { - let datatable = sqlx::query_scalar!( + let datatables = sqlx::query_scalar!( r#" - SELECT ws.datatable->'datatables'->$2 AS config + SELECT ws.datatable->'datatables' AS datatables FROM workspace_settings ws WHERE ws.workspace_id = $1 "#, &w_id, - name ) .fetch_one(db) .await - .map_err(|err| Error::internal_err(format!("getting datatable {name}: {err}")))? - .ok_or_else(|| Error::internal_err(format!("datatable {name} not found")))?; - let datatable = serde_json::from_value::(datatable)?; + .map_err(|err| Error::internal_err(format!("getting datatable {name}: {err}")))?; + + let datatable = datatables + .as_ref() + .and_then(|d| d.get(name)) + .filter(|v| !v.is_null()) + .ok_or_else(|| datatable_not_found_error(name, datatables.as_ref()))?; + let datatable = serde_json::from_value::(datatable.clone())?; let db_resource = if datatable.database.resource_type == DataTableCatalogResourceType::Instance { @@ -1979,4 +2009,24 @@ mod tests { "" ); } + + #[test] + fn test_datatable_not_found_error_no_datatables() { + let msg = datatable_not_found_error("main", None).to_string(); + assert!(msg.contains("'main' not found"), "{msg}"); + assert!(msg.contains("No data table is configured"), "{msg}"); + // Always signposts the settings tab so the message is actionable. + assert!(msg.contains("tab=windmill_data_tables"), "{msg}"); + } + + #[test] + fn test_datatable_not_found_error_lists_available() { + let configured = serde_json::json!({ "analytics": {}, "staging": {} }); + let msg = datatable_not_found_error("main", Some(&configured)).to_string(); + assert!(msg.contains("'main' not found"), "{msg}"); + // Surface configured names to catch typos. + assert!(msg.contains("analytics"), "{msg}"); + assert!(msg.contains("staging"), "{msg}"); + assert!(msg.contains("tab=windmill_data_tables"), "{msg}"); + } }