diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 65fdc88212..6bd7d7ea7e 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -2204,17 +2204,15 @@ async fn list_datatables( Extension(db): Extension, Path(w_id): Path, ) -> JsonResult> { - let names = list_datatable_names(&db, &w_id).await?; + // A pointer entry owns no database, so what it resolves to is the only truthful answer here. + // One that resolves to nothing — a pointer whose workspace was deleted — is dropped rather than + // listed with a database it does not have; what happened is named where it is actionable + // instead: by the delete that stranded it, and by any attempt to use it. + let resolved = + windmill_common::workspaces::resolve_workspace_governing_datatables(&db, &w_id).await?; - let mut items = Vec::with_capacity(names.len()); - for name in names { - // A pointer entry owns no database, so what it resolves to is the only truthful answer - // here. One that resolves to nothing — a pointer whose workspace was deleted — is dropped - // rather than listed with a database it does not have; what happened is named where it is - // actionable instead: by the delete that stranded it, and by any attempt to use it. - let Ok(governing) = resolve_governing_datatable(&db, &w_id, &name).await else { - continue; - }; + let mut items = Vec::with_capacity(resolved.len()); + for (name, governing) in resolved { let database = governing .datatable .database diff --git a/backend/windmill-common/src/workspaces.rs b/backend/windmill-common/src/workspaces.rs index 3e8dd0aae5..1bf6314e5e 100644 --- a/backend/windmill-common/src/workspaces.rs +++ b/backend/windmill-common/src/workspaces.rs @@ -1522,6 +1522,96 @@ pub async fn resolve_governing_datatable( ))) } +/// Every entry of a workspace resolved as [`resolve_governing_datatable`] resolves one, in stored +/// order, reading the settings rows one pointer hop at a time rather than once per entry. An entry +/// that does not resolve — malformed, a dangling pointer, a loop — is left out. Same authorization +/// contract as the single resolution: it checks nothing. +pub async fn resolve_workspace_governing_datatables( + db: &DB, + w_id: &str, +) -> Result> { + type Entries = + std::collections::HashMap>; + async fn load(db: &DB, workspaces: &[String], entries: &mut Entries) -> Result> { + let rows: Vec<(String, String, serde_json::Value)> = sqlx::query_as( + "SELECT ws.workspace_id, dt.key, dt.value FROM workspace_settings ws + CROSS JOIN LATERAL jsonb_each(COALESCE(ws.datatable->'datatables', '{}'::jsonb)) dt + WHERE ws.workspace_id = ANY($1)", + ) + .bind(workspaces) + .fetch_all(db) + .await?; + for ws in workspaces { + entries.entry(ws.clone()).or_default(); + } + let mut keys = Vec::with_capacity(rows.len()); + for (ws, key, value) in rows { + keys.push(key.clone()); + entries.entry(ws).or_default().insert(key, value); + } + Ok(keys) + } + + let mut entries = Entries::new(); + let listed = load(db, &[w_id.to_string()], &mut entries).await?; + // (index into `listed`, workspace, entry name) still to be followed. + let mut cursors: Vec<(usize, String, String)> = listed + .iter() + .enumerate() + .map(|(i, name)| (i, w_id.to_string(), name.clone())) + .collect(); + let mut resolved: Vec<(usize, GoverningDatatable)> = vec![]; + + for _ in 0..DATATABLE_REFERENCE_MAX_DEPTH { + let mut next = vec![]; + for (i, ws, name) in cursors.drain(..) { + let Some(value) = entries + .get(&ws) + .and_then(|m| m.get(&name)) + .filter(|v| !v.is_null()) + else { + continue; + }; + let Ok(datatable) = serde_json::from_value::(value.clone()) else { + continue; + }; + if validate_datatable_shape(&name, &datatable).is_err() { + continue; + } + match &datatable.reference { + None => { + resolved.push((i, GoverningDatatable { workspace_id: ws, name, datatable })) + } + Some(reference) => next.push(( + i, + reference.workspace_id.clone(), + reference.datatable.clone(), + )), + } + } + if next.is_empty() { + break; + } + let to_load: Vec = next + .iter() + .map(|(_, ws, _)| ws.clone()) + .filter(|ws| !entries.contains_key(ws)) + .collect::>() + .into_iter() + .collect(); + if !to_load.is_empty() { + load(db, &to_load, &mut entries).await?; + } + cursors = next; + } + + resolved.sort_by_key(|(i, _)| *i); + Ok(resolved + .into_iter() + .map(|(i, governing)| (listed[i].clone(), governing)) + .collect()) +} + /// Build the `admin` connection for a governing entry: `custom_instance_user` for an instance /// database, the user's own resource for a BYO-postgres one. async fn resolve_datatable_connection_unchecked(