新增 OnDeleteStrategy 枚举(Nullify/Cascade/Restrict)和 PluginRelation 结构体声明实体关联关系。PluginEntity 增加 relations 字段(serde(default) 向后兼容)。
750 lines
20 KiB
Rust
750 lines
20 KiB
Rust
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 enum,TOML 中通过 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));
|
||
}
|
||
}
|