diff --git a/libs/client-api/src/http_collab.rs b/libs/client-api/src/http_collab.rs index 6d4c8ec7..aec30ec9 100644 --- a/libs/client-api/src/http_collab.rs +++ b/libs/client-api/src/http_collab.rs @@ -2,7 +2,9 @@ use crate::http::log_request_id; use crate::{blocking_brotli_compress, brotli_compress, Client}; use app_error::AppError; use bytes::Bytes; -use client_api_entity::workspace_dto::{AFDatabase, AFDatabaseRow, ListDatabaseParam}; +use client_api_entity::workspace_dto::{ + AFDatabase, AFDatabaseRow, AFDatabaseRowDetail, ListDatabaseParam, ListDatabaseRowDetailParam, +}; use client_api_entity::{ BatchQueryCollabParams, BatchQueryCollabResult, CollabParams, CreateCollabParams, DeleteCollabParams, PublishCollabItem, QueryCollab, QueryCollabParams, UpdateCollabWebParams, @@ -190,6 +192,26 @@ impl Client { AppResponse::from_response(resp).await?.into_data() } + pub async fn list_database_row_details( + &self, + workspace_id: &str, + database_id: &str, + row_ids: &[&str], + ) -> Result, AppResponseError> { + let url = format!( + "{}/api/workspace/{}/database/{}/row/detail", + self.base_url, workspace_id, database_id + ); + let resp = self + .http_client_with_auth(Method::GET, &url) + .await? + .query(&ListDatabaseRowDetailParam::from(row_ids)) + .send() + .await?; + log_request_id(&resp); + AppResponse::from_response(resp).await?.into_data() + } + #[instrument(level = "debug", skip_all, err)] pub async fn post_realtime_msg( &self, diff --git a/libs/shared-entity/src/dto/workspace_dto.rs b/libs/shared-entity/src/dto/workspace_dto.rs index 4382e91e..c82b0111 100644 --- a/libs/shared-entity/src/dto/workspace_dto.rs +++ b/libs/shared-entity/src/dto/workspace_dto.rs @@ -298,6 +298,22 @@ pub struct ListDatabaseParam { pub name_filter: Option, // logic: if database name contains } +#[derive(Default, Debug, Deserialize, Serialize)] +pub struct ListDatabaseRowDetailParam { + // Comma separated database row ids + // e.g. ",," + pub ids: String, +} + +impl ListDatabaseRowDetailParam { + pub fn from(ids: &[&str]) -> Self { + Self { ids: ids.join(",") } + } + pub fn into_ids(&self) -> Vec<&str> { + self.ids.split(',').collect() + } +} + #[derive(Default, Debug, Deserialize, Serialize)] pub struct QueryWorkspaceFolder { pub depth: Option, @@ -339,3 +355,9 @@ pub struct AFDatabaseMeta { pub struct AFDatabaseRow { pub id: String, } + +#[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct AFDatabaseRowDetail { + pub id: String, + pub cells: HashMap>, +} diff --git a/src/api/workspace.rs b/src/api/workspace.rs index 222f11af..667bf79e 100644 --- a/src/api/workspace.rs +++ b/src/api/workspace.rs @@ -260,6 +260,10 @@ pub fn workspace_scope() -> Scope { web::resource("/{workspace_id}/database/{database_id}/row") .route(web::get().to(list_database_row_id_handler)), ) + .service( + web::resource("/{workspace_id}/database/{database_id}/row/detail") + .route(web::get().to(list_database_row_details_handler)), + ) } pub fn collab_scope() -> Scope { @@ -1897,6 +1901,33 @@ async fn list_database_row_id_handler( Ok(Json(AppResponse::Ok().with_data(db_rows))) } +async fn list_database_row_details_handler( + user_uuid: UserUuid, + path_param: web::Path<(String, String)>, + state: Data, + param: web::Query, +) -> Result>>> { + let (workspace_id, db_id) = path_param.into_inner(); + let uid = state.user_cache.get_user_uid(&user_uuid).await?; + let list_db_row_query = param.into_inner(); + let row_ids = list_db_row_query.into_ids(); + + state + .workspace_access_control + .enforce_action(&uid, &workspace_id, Action::Read) + .await?; + + let db_rows = biz::collab::ops::list_database_row_details( + &state.collab_access_control_storage, + uid, + workspace_id, + db_id, + &row_ids, + ) + .await?; + Ok(Json(AppResponse::Ok().with_data(db_rows))) +} + #[inline] async fn parser_realtime_msg( payload: Bytes, diff --git a/src/biz/collab/ops.rs b/src/biz/collab/ops.rs index d085ef6b..c771939f 100644 --- a/src/biz/collab/ops.rs +++ b/src/biz/collab/ops.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::sync::Arc; use app_error::AppError; @@ -5,6 +6,8 @@ use appflowy_collaborate::collab::storage::CollabAccessControlStorage; use collab::preclude::Collab; use collab_database::database::DatabaseBody; use collab_database::entity::FieldType; +use collab_database::fields::Field; +use collab_database::rows::RowDetail; use collab_database::workspace_database::NoPersistenceDatabaseCollabService; use collab_database::workspace_database::WorkspaceDatabaseBody; use collab_entity::CollabType; @@ -20,6 +23,7 @@ use database_entity::dto::{QueryCollab, QueryCollabParams}; use shared_entity::dto::workspace_dto::AFDatabase; use shared_entity::dto::workspace_dto::AFDatabaseField; use shared_entity::dto::workspace_dto::AFDatabaseRow; +use shared_entity::dto::workspace_dto::AFDatabaseRowDetail; use shared_entity::dto::workspace_dto::FavoriteFolderView; use shared_entity::dto::workspace_dto::RecentFolderView; use shared_entity::dto::workspace_dto::TrashFolderView; @@ -517,3 +521,130 @@ pub async fn list_database_row( Ok(db_rows) } + +pub async fn list_database_row_details( + collab_storage: &CollabAccessControlStorage, + uid: i64, + workspace_uuid_str: String, + database_uuid_str: String, + row_ids: &[&str], +) -> Result, AppError> { + let query_collabs: Vec = row_ids + .iter() + .map(|id| QueryCollab { + object_id: id.to_string(), + collab_type: CollabType::DatabaseRow, + }) + .collect(); + + let database_collab = get_latest_collab( + collab_storage, + GetCollabOrigin::User { uid }, + &workspace_uuid_str, + &database_uuid_str, + CollabType::Database, + ) + .await?; + let db_body = DatabaseBody::from_collab( + &database_collab, + Arc::new(NoPersistenceDatabaseCollabService), + None, + ) + .ok_or_else(|| { + AppError::Internal(anyhow::anyhow!( + "Failed to create database body from collab, db_collab_id: {}", + database_uuid_str, + )) + })?; + + // create a map of field id to field. + // ensure that the field name is unique. + // if the field name is repeated, it will be appended with the field id, + // under practical usage circumstances, no other collision should occur + let field_by_id: HashMap = { + let all_fields = db_body.fields.get_all_fields(&database_collab.transact()); + + let mut uniq_name_set: HashSet = HashSet::with_capacity(all_fields.len()); + let mut field_by_id: HashMap = HashMap::with_capacity(all_fields.len()); + + for mut field in all_fields { + // if the name already exists, append the field id to the name + if uniq_name_set.contains(&field.name) { + let new_name = format!("{}-{}", field.name, field.id); + field.name = new_name.clone(); + } + uniq_name_set.insert(field.name.clone()); + field_by_id.insert(field.id.clone(), field); + } + field_by_id + }; + + let database_row_details = collab_storage + .batch_get_collab(&uid, query_collabs, true) + .await + .into_iter() + .flat_map(|(id, result)| match result { + QueryCollabResult::Success { encode_collab_v1 } => { + let ec = EncodedCollab::decode_from_bytes(&encode_collab_v1).unwrap(); + let collab = + Collab::new_with_source(CollabOrigin::Server, &id, ec.into(), vec![], false).unwrap(); + let row_detail = RowDetail::from_collab(&collab).unwrap(); + let cells = convert_database_cells_human_readable(row_detail.row.cells, &field_by_id); + Some(AFDatabaseRowDetail { id, cells }) + }, + QueryCollabResult::Failed { error } => { + tracing::warn!("Failed to get collab: {:?}", error); + None + }, + }) + .collect::>(); + + Ok(database_row_details) +} + +fn convert_database_cells_human_readable( + db_cells: HashMap>, + field_by_id: &HashMap, +) -> HashMap> { + let mut human_readable_records: HashMap> = + HashMap::with_capacity(db_cells.len()); + + for (field_id, cell) in db_cells { + let field = match field_by_id.get(&field_id) { + Some(field) => field, + None => { + tracing::error!("Failed to get field by id: {}", field_id); + continue; + }, + }; + + let mut human_readable_cell: HashMap = HashMap::with_capacity(cell.len()); + for (key, value) in cell { + let serde_value: String = match key.as_str() { + "created_at" | "last_modified" => match value.cast::() { + Ok(timestamp) => chrono::DateTime::from_timestamp(timestamp, 0) + .unwrap_or_default() + .to_rfc3339(), + Err(err) => { + tracing::error!("Failed to cast timestamp: {:?}", err); + "".to_string() + }, + }, + "field_type" => match value.cast::() { + Ok(field_type_int) => { + let field_type = FieldType::from(field_type_int); + format!("{:?}", field_type) + }, + Err(err) => { + tracing::error!("Failed to cast field type: {:?}", err); + String::from("Unknown") + }, + }, + _ => value.to_string(), + }; + human_readable_cell.insert(key, serde_value); + } + human_readable_records.insert(field.name.clone(), human_readable_cell); + } + human_readable_records +} diff --git a/tests/workspace/workspace_crud.rs b/tests/workspace/workspace_crud.rs index 7633311a..6eaef515 100644 --- a/tests/workspace/workspace_crud.rs +++ b/tests/workspace/workspace_crud.rs @@ -64,6 +64,51 @@ async fn workspace_list_database() { .await .unwrap(); assert_eq!(db_row_ids.len(), 5, "{:?}", db_row_ids); + { + let db_row_ids: Vec<&str> = db_row_ids.iter().map(|s| s.id.as_str()).collect(); + let db_row_ids: &[&str] = &db_row_ids; + let db_row_details = c + .list_database_row_details(&workspace_id, &untitled_db.id, db_row_ids) + .await + .unwrap(); + assert_eq!(db_row_details.len(), 5, "{:#?}", db_row_details); + + // cells: { + // "Multiselect": { + // "field_type": "MultiSelect", + // "last_modified": "2024-08-16T07:23:57+00:00", + // "created_at": "2024-08-16T07:23:35+00:00", + // "data": "BD-T,6UxM", + // }, + // "Description": { + // "field_type": "RichText", + // "last_modified": "2024-08-16T07:17:03+00:00", + // "created_at": "2024-08-16T07:16:51+00:00", + // "data": "Install AppFlowy Mobile", + // }, + // "Status": { + // "data": "CEZD", + // "field_type": "SingleSelect", + // }, + // }, + let _ = db_row_details + .into_iter() + .find(|row| { + row.cells["Multiselect"]["field_type"] == "MultiSelect" + && row.cells["Multiselect"]["last_modified"] == "2024-08-16T07:23:57+00:00" + && row.cells["Multiselect"]["created_at"] == "2024-08-16T07:23:35+00:00" + && row.cells["Multiselect"]["data"] == "BD-T,6UxM" + // Description + && row.cells["Description"]["field_type"] == "RichText" + && row.cells["Description"]["last_modified"] == "2024-08-16T07:17:03+00:00" + && row.cells["Description"]["created_at"] == "2024-08-16T07:16:51+00:00" + && row.cells["Description"]["data"] == "Install AppFlowy Mobile" + // Status + && row.cells["Status"]["data"] == "CEZD" + && row.cells["Status"]["field_type"] == "SingleSelect" + }) + .unwrap(); + } } } {