use crate::api::rest_operation::RestOperation;
use crate::auth::Authenticator;
use crate::client::ForceClient;
use crate::error::Result;
use crate::types::describe::{FieldType, SObjectDescribe};
use std::collections::{HashMap, HashSet};
#[derive(Debug, Clone)]
struct SchemaNode {
name: String,
label: String,
fields: Vec<SchemaField>,
}
#[derive(Debug, Clone)]
struct SchemaField {
name: String,
type_: FieldType,
reference_to: Vec<String>,
}
#[derive(Debug)]
pub struct SchemaGraph<'a, A: Authenticator> {
client: &'a ForceClient<A>,
nodes: HashMap<String, SchemaNode>,
scanned: HashSet<String>,
}
impl<'a, A: Authenticator> SchemaGraph<'a, A> {
#[must_use]
pub fn new(client: &'a ForceClient<A>) -> Self {
Self {
client,
nodes: HashMap::new(),
scanned: HashSet::new(),
}
}
pub async fn scan(&mut self, sobject: &str) -> Result<()> {
if !self.scanned.insert(sobject.to_string()) {
return Ok(()); }
let describe = self.client.rest().describe(sobject).await?;
self.add_node(describe);
Ok(())
}
pub fn add_describe(&mut self, describe: SObjectDescribe) {
self.scanned.insert(describe.name.clone());
self.add_node(describe);
}
fn add_node(&mut self, describe: SObjectDescribe) {
let node = SchemaNode {
name: describe.name.clone(),
label: describe.label,
fields: describe
.fields
.into_iter()
.map(|f| SchemaField {
name: f.name,
type_: f.type_,
reference_to: f.reference_to,
})
.collect(),
};
self.nodes.insert(describe.name, node);
}
#[must_use]
pub fn to_mermaid(&self) -> String {
let mut mermaid = String::with_capacity(2048);
self.write_mermaid(&mut mermaid);
mermaid
}
pub fn write_mermaid(&self, mermaid: &mut String) {
use std::fmt::Write;
mermaid.push_str("erDiagram\n");
let mut node_names: Vec<_> = self.nodes.keys().collect();
node_names.sort();
for name in &node_names {
let node = &self.nodes[*name];
let _ = writeln!(mermaid, " {} {{", node.name);
for field in &node.fields {
let type_str = match field.type_ {
FieldType::Int => "int",
FieldType::Double => "double",
FieldType::Boolean => "boolean",
FieldType::Id => "id",
FieldType::Reference => "reference",
FieldType::Date => "date",
FieldType::Datetime => "datetime",
FieldType::Picklist => "picklist",
_ => "string", };
let _ = writeln!(mermaid, " {} {}", type_str, field.name);
}
mermaid.push_str(" }\n\n");
}
for name in &node_names {
let node = &self.nodes[*name];
for field in &node.fields {
if field.type_ == FieldType::Reference {
for target in &field.reference_to {
if self.nodes.contains_key(target) {
let _ = writeln!(
mermaid,
" {} ||--o{{ {} : \"{}\"",
target, node.name, field.name
);
}
}
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::client::builder;
use crate::test_support::{MockAuthenticator, Must};
use serde_json::json;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
async fn setup_mock_describe_account(mock_server: &MockServer) {
let id_field = json!({
"name": "Id", "type": "id", "label": "Account ID",
"referenceTo": [],
"aggregatable": true, "autoNumber": false, "byteLength": 18, "calculated": false,
"cascadeDelete": false, "caseSensitive": false, "createable": false, "custom": false,
"defaultedOnCreate": true, "dependentPicklist": false, "deprecatedAndHidden": false,
"digits": 0, "displayLocationInDecimal": false, "encrypted": false, "externalId": false,
"filterable": true, "groupable": true, "highScaleNumber": false, "htmlFormatted": false,
"idLookup": true, "length": 18, "nameField": false, "namePointing": false, "nillable": false,
"permissionable": false, "polymorphicForeignKey": false, "precision": 0, "queryByDistance": false,
"restrictedDelete": false, "restrictedPicklist": false, "scale": 0, "soapType": "tns:ID",
"sortable": true, "unique": false, "updateable": false, "writeRequiresMasterRead": false
});
let name_field = json!({
"name": "Name", "type": "string", "label": "Account Name",
"referenceTo": [],
"aggregatable": true, "autoNumber": false, "byteLength": 18, "calculated": false,
"cascadeDelete": false, "caseSensitive": false, "createable": false, "custom": false,
"defaultedOnCreate": true, "dependentPicklist": false, "deprecatedAndHidden": false,
"digits": 0, "displayLocationInDecimal": false, "encrypted": false, "externalId": false,
"filterable": true, "groupable": true, "highScaleNumber": false, "htmlFormatted": false,
"idLookup": true, "length": 18, "nameField": false, "namePointing": false, "nillable": false,
"permissionable": false, "polymorphicForeignKey": false, "precision": 0, "queryByDistance": false,
"restrictedDelete": false, "restrictedPicklist": false, "scale": 0, "soapType": "xsd:string",
"sortable": true, "unique": false, "updateable": false, "writeRequiresMasterRead": false
});
let describe_json = json!({
"name": "Account",
"label": "Account",
"custom": false,
"queryable": true,
"activateable": false, "createable": true, "customSetting": false, "deletable": true,
"deprecatedAndHidden": false, "feedEnabled": true, "hasSubtypes": false,
"isSubtype": false, "keyPrefix": "001", "labelPlural": "Accounts", "layoutable": true,
"mergeable": true, "mruEnabled": true, "replicateable": true, "retrieveable": true,
"searchable": true, "triggerable": true, "undeletable": true, "updateable": true,
"urls": {}, "childRelationships": [], "recordTypeInfos": [],
"fields": [id_field, name_field]
});
Mock::given(method("GET"))
.and(path("/services/data/v60.0/sobjects/Account/describe"))
.respond_with(ResponseTemplate::new(200).set_body_json(describe_json))
.mount(mock_server)
.await;
}
async fn setup_mock_describe_contact(mock_server: &MockServer) {
let id_field = json!({
"name": "Id", "type": "id", "label": "Contact ID",
"referenceTo": [],
"aggregatable": true, "autoNumber": false, "byteLength": 18, "calculated": false,
"cascadeDelete": false, "caseSensitive": false, "createable": false, "custom": false,
"defaultedOnCreate": true, "dependentPicklist": false, "deprecatedAndHidden": false,
"digits": 0, "displayLocationInDecimal": false, "encrypted": false, "externalId": false,
"filterable": true, "groupable": true, "highScaleNumber": false, "htmlFormatted": false,
"idLookup": true, "length": 18, "nameField": false, "namePointing": false, "nillable": false,
"permissionable": false, "polymorphicForeignKey": false, "precision": 0, "queryByDistance": false,
"restrictedDelete": false, "restrictedPicklist": false, "scale": 0, "soapType": "tns:ID",
"sortable": true, "unique": false, "updateable": false, "writeRequiresMasterRead": false
});
let account_id_field = json!({
"name": "AccountId", "type": "reference", "label": "Account ID",
"referenceTo": ["Account"],
"aggregatable": true, "autoNumber": false, "byteLength": 18, "calculated": false,
"cascadeDelete": false, "caseSensitive": false, "createable": false, "custom": false,
"defaultedOnCreate": true, "dependentPicklist": false, "deprecatedAndHidden": false,
"digits": 0, "displayLocationInDecimal": false, "encrypted": false, "externalId": false,
"filterable": true, "groupable": true, "highScaleNumber": false, "htmlFormatted": false,
"idLookup": true, "length": 18, "nameField": false, "namePointing": false, "nillable": false,
"permissionable": false, "polymorphicForeignKey": false, "precision": 0, "queryByDistance": false,
"restrictedDelete": false, "restrictedPicklist": false, "scale": 0, "soapType": "tns:ID",
"sortable": true, "unique": false, "updateable": false, "writeRequiresMasterRead": false
});
let describe_json = json!({
"name": "Contact",
"label": "Contact",
"custom": false,
"queryable": true,
"activateable": false, "createable": true, "customSetting": false, "deletable": true,
"deprecatedAndHidden": false, "feedEnabled": true, "hasSubtypes": false,
"isSubtype": false, "keyPrefix": "003", "labelPlural": "Contacts", "layoutable": true,
"mergeable": true, "mruEnabled": true, "replicateable": true, "retrieveable": true,
"searchable": true, "triggerable": true, "undeletable": true, "updateable": true,
"urls": {}, "childRelationships": [], "recordTypeInfos": [],
"fields": [id_field, account_id_field]
});
Mock::given(method("GET"))
.and(path("/services/data/v60.0/sobjects/Contact/describe"))
.respond_with(ResponseTemplate::new(200).set_body_json(describe_json))
.mount(mock_server)
.await;
}
#[tokio::test]
async fn test_schema_graph_generation() {
let mock_server = MockServer::start().await;
let auth = MockAuthenticator::new("token", &mock_server.uri());
let client = builder().authenticate(auth).build().await.must();
setup_mock_describe_account(&mock_server).await;
setup_mock_describe_contact(&mock_server).await;
let mut graph = SchemaGraph::new(&client);
graph.scan("Account").await.must();
graph.scan("Contact").await.must();
let mermaid = graph.to_mermaid();
println!("Generated Mermaid:\n{}", mermaid);
assert!(mermaid.contains("erDiagram"));
assert!(mermaid.contains("Account {"));
assert!(mermaid.contains("Contact {"));
assert!(mermaid.contains("string Name"));
assert!(mermaid.contains("id Id"));
assert!(mermaid.contains("Account ||--o{ Contact : \"AccountId\""));
}
}