Files
erp/crates/erp-plugin/src/manifest.rs
iven 527a57df9e feat(plugin): PluginRelation 级联删除声明 + OnDeleteStrategy
新增 OnDeleteStrategy 枚举(Nullify/Cascade/Restrict)和
PluginRelation 结构体声明实体关联关系。PluginEntity 增加
relations 字段(serde(default) 向后兼容)。
2026-04-17 10:33:58 +08:00

750 lines
20 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use serde::{Deserialize, Serialize};
use crate::error::{PluginError, PluginResult};
/// 插件清单 — 从 TOML 文件解析
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginManifest {
pub metadata: PluginMetadata,
pub schema: Option<PluginSchema>,
pub events: Option<PluginEvents>,
pub ui: Option<PluginUi>,
pub permissions: Option<Vec<PluginPermission>>,
}
/// 插件元数据
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginMetadata {
pub id: String,
pub name: String,
pub version: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub author: String,
#[serde(default)]
pub min_platform_version: Option<String>,
#[serde(default)]
pub dependencies: Vec<String>,
}
/// 插件 Schema — 定义动态实体
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginSchema {
pub entities: Vec<PluginEntity>,
}
/// 插件实体定义
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginEntity {
pub name: String,
pub display_name: String,
#[serde(default)]
pub fields: Vec<PluginField>,
#[serde(default)]
pub indexes: Vec<PluginIndex>,
#[serde(default)]
pub relations: Vec<PluginRelation>,
}
/// 字段校验规则
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FieldValidation {
pub pattern: Option<String>, // 正则表达式
pub message: Option<String>, // 校验失败提示
}
/// 插件字段定义
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginField {
pub name: String,
pub field_type: PluginFieldType,
#[serde(default)]
pub required: bool,
#[serde(default)]
pub unique: bool,
pub default: Option<serde_json::Value>,
pub display_name: Option<String>,
pub ui_widget: Option<String>,
pub options: Option<Vec<serde_json::Value>>,
#[serde(default)]
pub searchable: Option<bool>,
#[serde(default)]
pub filterable: Option<bool>,
#[serde(default)]
pub sortable: Option<bool>,
#[serde(default)]
pub visible_when: Option<String>,
pub ref_entity: Option<String>, // 外键引用的实体名
pub validation: Option<FieldValidation>, // 字段校验规则
#[serde(default)]
pub no_cycle: Option<bool>, // 禁止循环引用
}
/// 字段类型
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PluginFieldType {
String,
Integer,
Float,
Boolean,
Date,
DateTime,
Json,
Uuid,
Decimal,
}
impl PluginFieldType {
/// Generated Column 的 SQL 类型
pub fn generated_sql_type(&self) -> &'static str {
match self {
Self::String | Self::Json => "TEXT",
Self::Integer => "INTEGER",
Self::Float => "DOUBLE PRECISION",
Self::Decimal => "NUMERIC",
Self::Boolean => "BOOLEAN",
Self::Date => "DATE",
Self::DateTime => "TIMESTAMPTZ",
Self::Uuid => "UUID",
}
}
/// Generated Column 的表达式
pub fn generated_expr(&self, field_name: &str) -> String {
match self {
Self::String | Self::Json => format!("data->>'{}'", field_name),
_ => format!("(data->>'{}')::{}", field_name, self.generated_sql_type()),
}
}
/// 该类型是否适合生成 Generated Column
pub fn supports_generated_column(&self) -> bool {
!matches!(self, Self::Json)
}
}
impl PluginField {
/// 测试辅助:构造一个全默认值的 PluginField
#[cfg(test)]
pub fn default_for_field() -> Self {
Self {
name: String::new(),
field_type: PluginFieldType::String,
required: false,
unique: false,
default: None,
display_name: None,
ui_widget: None,
options: None,
searchable: None,
filterable: None,
sortable: None,
visible_when: None,
ref_entity: None,
validation: None,
no_cycle: None,
}
}
}
/// 索引定义
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginIndex {
pub name: String,
pub fields: Vec<String>,
#[serde(default)]
pub unique: bool,
}
/// 级联删除策略
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum OnDeleteStrategy {
Nullify, // 置空外键字段
Cascade, // 级联软删除
Restrict, // 存在关联时拒绝删除
}
/// 实体关联关系声明
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginRelation {
pub entity: String,
pub foreign_key: String,
pub on_delete: OnDeleteStrategy,
}
/// 事件订阅配置
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginEvents {
pub subscribe: Vec<String>,
}
/// UI 页面配置
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginUi {
pub pages: Vec<PluginPageType>,
}
/// 插件页面类型tagged enumTOML 中通过 type 字段区分)
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type")]
pub enum PluginPageType {
#[serde(rename = "crud")]
Crud {
entity: String,
label: String,
#[serde(default)]
icon: Option<String>,
#[serde(default)]
enable_search: Option<bool>,
#[serde(default)]
enable_views: Option<Vec<String>>,
},
#[serde(rename = "tree")]
Tree {
entity: String,
label: String,
#[serde(default)]
icon: Option<String>,
id_field: String,
parent_field: String,
label_field: String,
},
#[serde(rename = "detail")]
Detail {
entity: String,
label: String,
sections: Vec<PluginSection>,
},
#[serde(rename = "tabs")]
Tabs {
label: String,
#[serde(default)]
icon: Option<String>,
tabs: Vec<PluginPageType>,
},
#[serde(rename = "graph")]
Graph {
entity: String,
label: String,
#[serde(default)]
icon: Option<String>,
relationship_entity: String,
source_field: String,
target_field: String,
edge_label_field: String,
node_label_field: String,
},
#[serde(rename = "dashboard")]
Dashboard {
label: String,
#[serde(default)]
icon: Option<String>,
},
}
/// 插件页面区段(用于 detail 页面类型)
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type")]
pub enum PluginSection {
#[serde(rename = "fields")]
Fields {
label: String,
fields: Vec<String>,
},
#[serde(rename = "crud")]
Crud {
label: String,
entity: String,
#[serde(default)]
filter_field: Option<String>,
#[serde(default)]
enable_views: Option<Vec<String>>,
},
}
/// 权限定义
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginPermission {
pub code: String,
pub name: String,
#[serde(default)]
pub description: String,
}
/// 从 TOML 字符串解析插件清单
pub fn parse_manifest(toml_str: &str) -> PluginResult<PluginManifest> {
let manifest: PluginManifest =
toml::from_str(toml_str).map_err(|e| PluginError::InvalidManifest(e.to_string()))?;
// 验证必填字段
if manifest.metadata.id.is_empty() {
return Err(PluginError::InvalidManifest("metadata.id 不能为空".to_string()));
}
if manifest.metadata.name.is_empty() {
return Err(PluginError::InvalidManifest(
"metadata.name 不能为空".to_string(),
));
}
// 验证实体名称
if let Some(schema) = &manifest.schema {
for entity in &schema.entities {
if entity.name.is_empty() {
return Err(PluginError::InvalidManifest(
"entity.name 不能为空".to_string(),
));
}
// 验证实体名称只包含合法字符
if !entity
.name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_')
{
return Err(PluginError::InvalidManifest(format!(
"entity.name '{}' 只能包含字母、数字和下划线",
entity.name
)));
}
}
}
// 验证页面类型配置
if let Some(ui) = &manifest.ui {
validate_pages(&ui.pages)?;
}
Ok(manifest)
}
/// 递归验证页面配置
fn validate_pages(pages: &[PluginPageType]) -> PluginResult<()> {
for page in pages {
match page {
PluginPageType::Crud { entity, .. } => {
if entity.is_empty() {
return Err(PluginError::InvalidManifest(
"crud page 的 entity 不能为空".into(),
));
}
}
PluginPageType::Tree {
entity: _,
label: _,
icon: _,
id_field,
parent_field,
label_field,
} => {
if id_field.is_empty() || parent_field.is_empty() || label_field.is_empty() {
return Err(PluginError::InvalidManifest(
"tree page 的 id_field/parent_field/label_field 不能为空".into(),
));
}
}
PluginPageType::Detail { entity, sections, .. } => {
if entity.is_empty() {
return Err(PluginError::InvalidManifest(
"detail page 的 entity 不能为空".into(),
));
}
if sections.is_empty() {
return Err(PluginError::InvalidManifest(
"detail page 的 sections 不能为空".into(),
));
}
}
PluginPageType::Tabs { tabs, .. } => {
if tabs.is_empty() {
return Err(PluginError::InvalidManifest(
"tabs page 的 tabs 不能为空".into(),
));
}
validate_pages(tabs)?;
}
PluginPageType::Graph {
entity,
relationship_entity,
source_field,
target_field,
..
} => {
if entity.is_empty() || relationship_entity.is_empty() {
return Err(PluginError::InvalidManifest(
"graph page 的 entity/relationship_entity 不能为空".into(),
));
}
if source_field.is_empty() || target_field.is_empty() {
return Err(PluginError::InvalidManifest(
"graph page 的 source_field/target_field 不能为空".into(),
));
}
}
PluginPageType::Dashboard { .. } => {
// dashboard 无需额外验证
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_minimal_manifest() {
let toml = r#"
[metadata]
id = "test-plugin"
name = "测试插件"
version = "0.1.0"
"#;
let manifest = parse_manifest(toml).unwrap();
assert_eq!(manifest.metadata.id, "test-plugin");
assert_eq!(manifest.metadata.name, "测试插件");
assert!(manifest.schema.is_none());
}
#[test]
fn parse_full_manifest() {
let toml = r#"
[metadata]
id = "inventory"
name = "进销存"
version = "1.0.0"
description = "简单进销存管理"
author = "ERP Team"
[schema]
[[schema.entities]]
name = "product"
display_name = "商品"
[[schema.entities.fields]]
name = "sku"
field_type = "string"
required = true
unique = true
display_name = "SKU 编码"
[[schema.entities.fields]]
name = "price"
field_type = "decimal"
required = true
display_name = "价格"
[events]
subscribe = ["workflow.task.completed", "order.*"]
[ui]
[[ui.pages]]
type = "crud"
entity = "product"
label = "商品管理"
icon = "ShoppingOutlined"
[[permissions]]
code = "product.list"
name = "查看商品"
description = "查看商品列表"
"#;
let manifest = parse_manifest(toml).unwrap();
assert_eq!(manifest.metadata.id, "inventory");
let schema = manifest.schema.unwrap();
assert_eq!(schema.entities.len(), 1);
assert_eq!(schema.entities[0].name, "product");
assert_eq!(schema.entities[0].fields.len(), 2);
let events = manifest.events.unwrap();
assert_eq!(events.subscribe.len(), 2);
let ui = manifest.ui.unwrap();
assert_eq!(ui.pages.len(), 1);
// 验证新格式解析正确
match &ui.pages[0] {
PluginPageType::Crud { entity, label, .. } => {
assert_eq!(entity, "product");
assert_eq!(label, "商品管理");
}
_ => panic!("Expected Crud page type"),
}
}
#[test]
fn reject_empty_id() {
let toml = r#"
[metadata]
id = ""
name = "测试"
version = "0.1.0"
"#;
let result = parse_manifest(toml);
assert!(result.is_err());
}
#[test]
fn reject_invalid_entity_name() {
let toml = r#"
[metadata]
id = "test"
name = "测试"
version = "0.1.0"
[schema]
[[schema.entities]]
name = "my-table"
display_name = "表格"
"#;
let result = parse_manifest(toml);
assert!(result.is_err());
}
#[test]
fn parse_manifest_with_new_fields_and_page_types() {
let toml = r#"
[metadata]
id = "test-plugin"
name = "Test"
version = "0.1.0"
description = "Test"
author = "Test"
min_platform_version = "0.1.0"
[[schema.entities]]
name = "customer"
display_name = "客户"
[[schema.entities.fields]]
name = "code"
field_type = "string"
required = true
display_name = "编码"
unique = true
searchable = true
filterable = true
visible_when = "customer_type == 'enterprise'"
[[ui.pages]]
type = "tabs"
label = "客户管理"
icon = "team"
[[ui.pages.tabs]]
label = "客户列表"
type = "crud"
entity = "customer"
enable_search = true
enable_views = ["table", "timeline"]
[[ui.pages]]
type = "detail"
entity = "customer"
label = "客户详情"
[[ui.pages.sections]]
type = "fields"
label = "基本信息"
fields = ["code", "name"]
"#;
let manifest = parse_manifest(toml).expect("should parse");
let field = &manifest.schema.as_ref().unwrap().entities[0].fields[0];
assert_eq!(field.searchable, Some(true));
assert_eq!(field.filterable, Some(true));
assert_eq!(
field.visible_when.as_deref(),
Some("customer_type == 'enterprise'")
);
// 验证页面类型解析
let ui = manifest.ui.as_ref().unwrap();
assert_eq!(ui.pages.len(), 2);
// tabs 页面
match &ui.pages[0] {
PluginPageType::Tabs { label, tabs, .. } => {
assert_eq!(label, "客户管理");
assert_eq!(tabs.len(), 1);
}
_ => panic!("Expected Tabs page type"),
}
// detail 页面
match &ui.pages[1] {
PluginPageType::Detail {
entity, sections, ..
} => {
assert_eq!(entity, "customer");
assert_eq!(sections.len(), 1);
}
_ => panic!("Expected Detail page type"),
}
}
#[test]
fn reject_empty_entity_in_crud_page() {
let toml = r#"
[metadata]
id = "test"
name = "Test"
version = "0.1.0"
[ui]
[[ui.pages]]
type = "crud"
entity = ""
label = "测试"
"#;
let result = parse_manifest(toml);
assert!(result.is_err());
}
#[test]
fn reject_empty_tabs_in_tabs_page() {
let toml = r#"
[metadata]
id = "test"
name = "Test"
version = "0.1.0"
[ui]
[[ui.pages]]
type = "tabs"
label = "空标签页"
"#;
let result = parse_manifest(toml);
assert!(result.is_err());
}
#[test]
fn field_type_to_sql_mapping() {
assert_eq!(PluginFieldType::String.generated_sql_type(), "TEXT");
assert_eq!(PluginFieldType::Integer.generated_sql_type(), "INTEGER");
assert_eq!(PluginFieldType::Float.generated_sql_type(), "DOUBLE PRECISION");
assert_eq!(PluginFieldType::Decimal.generated_sql_type(), "NUMERIC");
assert_eq!(PluginFieldType::Boolean.generated_sql_type(), "BOOLEAN");
assert_eq!(PluginFieldType::Date.generated_sql_type(), "DATE");
assert_eq!(PluginFieldType::DateTime.generated_sql_type(), "TIMESTAMPTZ");
assert_eq!(PluginFieldType::Uuid.generated_sql_type(), "UUID");
assert_eq!(PluginFieldType::Json.generated_sql_type(), "TEXT");
}
#[test]
fn field_type_generated_expression() {
assert_eq!(PluginFieldType::String.generated_expr("name"), "data->>'name'");
assert_eq!(PluginFieldType::Integer.generated_expr("age"), "(data->>'age')::INTEGER");
assert_eq!(PluginFieldType::Uuid.generated_expr("ref_id"), "(data->>'ref_id')::UUID");
}
#[test]
fn parse_field_with_ref_entity() {
let toml = r#"
[metadata]
id = "test"
name = "Test"
version = "0.1.0"
[schema]
[[schema.entities]]
name = "contact"
display_name = "联系人"
[[schema.entities.fields]]
name = "customer_id"
field_type = "uuid"
required = true
display_name = "所属客户"
ref_entity = "customer"
"#;
let manifest = parse_manifest(toml).unwrap();
let field = &manifest.schema.unwrap().entities[0].fields[0];
assert_eq!(field.ref_entity.as_deref(), Some("customer"));
}
#[test]
fn parse_field_with_validation() {
let toml = r#"
[metadata]
id = "test"
name = "Test"
version = "0.1.0"
[schema]
[[schema.entities]]
name = "contact"
display_name = "联系人"
[[schema.entities.fields]]
name = "phone"
field_type = "string"
display_name = "手机号"
validation = { pattern = "^1[3-9]\\d{9}$", message = "手机号格式不正确" }
"#;
let manifest = parse_manifest(toml).unwrap();
let field = &manifest.schema.unwrap().entities[0].fields[0];
let v = field.validation.as_ref().unwrap();
assert_eq!(v.pattern.as_deref(), Some("^1[3-9]\\d{9}$"));
assert_eq!(v.message.as_deref(), Some("手机号格式不正确"));
}
#[test]
fn parse_field_with_no_cycle() {
let toml = r#"
[metadata]
id = "test"
name = "Test"
version = "0.1.0"
[schema]
[[schema.entities]]
name = "customer"
display_name = "客户"
[[schema.entities.fields]]
name = "parent_id"
field_type = "uuid"
display_name = "上级客户"
ref_entity = "customer"
no_cycle = true
"#;
let manifest = parse_manifest(toml).unwrap();
let field = &manifest.schema.unwrap().entities[0].fields[0];
assert_eq!(field.no_cycle, Some(true));
assert_eq!(field.ref_entity.as_deref(), Some("customer"));
}
#[test]
fn parse_entity_with_relations() {
let toml = r#"
[metadata]
id = "test"
name = "Test"
version = "0.1.0"
[schema]
[[schema.entities]]
name = "customer"
display_name = "客户"
[[schema.entities.fields]]
name = "code"
field_type = "string"
required = true
display_name = "编码"
[[schema.entities.relations]]
entity = "contact"
foreign_key = "customer_id"
on_delete = "cascade"
[[schema.entities.relations]]
entity = "customer_tag"
foreign_key = "customer_id"
on_delete = "cascade"
"#;
let manifest = parse_manifest(toml).unwrap();
let entity = &manifest.schema.unwrap().entities[0];
assert_eq!(entity.relations.len(), 2);
assert_eq!(entity.relations[0].entity, "contact");
assert_eq!(entity.relations[0].foreign_key, "customer_id");
assert!(matches!(entity.relations[0].on_delete, OnDeleteStrategy::Cascade));
}
}