fix(datatable): self-teaching error for unresolved datatable:// references (#9941)

`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 <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-07-05 23:08:08 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 574d3ac9ff
commit 55451db009
2 changed files with 59 additions and 10 deletions
@@ -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"
}
+56 -6
View File
@@ -796,25 +796,55 @@ pub enum DataTableCatalogResourceType {
Instance,
}
/// Build a self-teaching error for an unresolved `datatable://<name>` 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<serde_json::Value> {
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>(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>(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}");
}
}