diff --git a/crates/erp-plugin/src/dynamic_table.rs b/crates/erp-plugin/src/dynamic_table.rs index 9f4b49a..2af0eac 100644 --- a/crates/erp-plugin/src/dynamic_table.rs +++ b/crates/erp-plugin/src/dynamic_table.rs @@ -2,10 +2,10 @@ use sea_orm::{ConnectionTrait, DatabaseConnection, FromQueryResult, Statement, V use uuid::Uuid; use crate::error::{PluginError, PluginResult}; -use crate::manifest::PluginEntity; +use crate::manifest::{PluginEntity, PluginFieldType}; /// 消毒标识符:只保留 ASCII 字母、数字、下划线,防止 SQL 注入 -fn sanitize_identifier(input: &str) -> String { +pub(crate) fn sanitize_identifier(input: &str) -> String { input .chars() .map(|c| if c.is_ascii_alphanumeric() || c == '_' { c } else { '_' }) @@ -23,97 +23,118 @@ impl DynamicTableManager { format!("plugin_{}_{}", sanitized_id, sanitized_entity) } - /// 创建动态表 - pub async fn create_table( - db: &DatabaseConnection, - plugin_id: &str, - entity: &PluginEntity, - ) -> PluginResult<()> { + /// 生成包含 Generated Column 的建表 DDL + pub fn build_create_table_sql(plugin_id: &str, entity: &PluginEntity) -> String { let table_name = Self::table_name(plugin_id, &entity.name); - // 创建表 - let create_sql = format!( - "CREATE TABLE IF NOT EXISTS \"{table_name}\" (\ - \"id\" UUID PRIMARY KEY, \ + let mut gen_cols = Vec::new(); + let mut indexes = Vec::new(); + + for field in &entity.fields { + if !field.field_type.supports_generated_column() { + continue; + } + // 提取规则:unique / sortable / filterable + let should_extract = field.unique + || field.sortable == Some(true) + || field.filterable == Some(true) + || (field.required + && (field.sortable == Some(true) || field.filterable == Some(true))); + + if !should_extract { + continue; + } + + let col_name = format!("_f_{}", sanitize_identifier(&field.name)); + let sql_type = field.field_type.generated_sql_type(); + let expr = field.field_type.generated_expr(&sanitize_identifier(&field.name)); + + gen_cols.push(format!( + " \"{}\" {} GENERATED ALWAYS AS ({}) STORED", + col_name, sql_type, expr + )); + + // 索引策略 + let col_idx = format!("{}_{}", sanitize_identifier(&table_name), col_name); + if field.unique { + indexes.push(format!( + "CREATE UNIQUE INDEX IF NOT EXISTS \"idx_{}_uniq\" ON \"{}\" (tenant_id, \"{}\") WHERE deleted_at IS NULL", + col_idx, table_name, col_name + )); + } else { + indexes.push(format!( + "CREATE INDEX IF NOT EXISTS \"idx_{}\" ON \"{}\" (tenant_id, \"{}\") WHERE deleted_at IS NULL", + col_idx, table_name, col_name + )); + } + } + + // pg_trgm 索引 + for field in &entity.fields { + if field.searchable == Some(true) + && matches!(field.field_type, PluginFieldType::String) + { + let sf = sanitize_identifier(&field.name); + indexes.push(format!( + "CREATE INDEX IF NOT EXISTS \"idx_{}_{}_trgm\" ON \"{}\" USING GIN ((data->>'{}') gin_trgm_ops) WHERE deleted_at IS NULL", + sanitize_identifier(&table_name), sf, table_name, sf + )); + } + } + + // 覆盖索引 + indexes.push(format!( + "CREATE INDEX IF NOT EXISTS \"idx_{}_tenant_cover\" ON \"{}\" (tenant_id, created_at DESC) INCLUDE (id, data, updated_at, version) WHERE deleted_at IS NULL", + sanitize_identifier(&table_name), table_name + )); + + let gen_cols_sql = if gen_cols.is_empty() { + String::new() + } else { + format!(",\n{}", gen_cols.join(",\n")) + }; + + format!( + "CREATE TABLE IF NOT EXISTS \"{}\" (\ + \"id\" UUID PRIMARY KEY DEFAULT gen_random_uuid(), \ \"tenant_id\" UUID NOT NULL, \ - \"data\" JSONB NOT NULL DEFAULT '{{}}', \ + \"data\" JSONB NOT NULL DEFAULT '{{}}'{gen_cols}, \ \"created_at\" TIMESTAMPTZ NOT NULL DEFAULT NOW(), \ \"updated_at\" TIMESTAMPTZ NOT NULL DEFAULT NOW(), \ \"created_by\" UUID, \ \"updated_by\" UUID, \ \"deleted_at\" TIMESTAMPTZ, \ - \"version\" INT NOT NULL DEFAULT 1)" - ); + \"version\" INT NOT NULL DEFAULT 1);\n\ + {}", + table_name, + indexes.join(";\n"), + gen_cols = gen_cols_sql, + ) + } - db.execute_unprepared(&create_sql).await - .map_err(|e| { - tracing::error!(sql = %create_sql, error = %e, "CREATE TABLE failed"); - PluginError::DatabaseError(e.to_string()) - })?; - - // 创建租户索引 - let tenant_idx_sql = format!( - "CREATE INDEX IF NOT EXISTS \"idx_{t}_tenant\" ON \"{table_name}\" (\"tenant_id\") WHERE \"deleted_at\" IS NULL", - t = sanitize_identifier(&table_name) - ); - db.execute_unprepared(&tenant_idx_sql).await - .map_err(|e| PluginError::DatabaseError(e.to_string()))?; - - // 为字段创建索引(使用参数化方式避免 SQL 注入) - let mut idx_counter = 0usize; - for field in &entity.fields { - if field.unique || field.required { - idx_counter += 1; - let sanitized_field = sanitize_identifier(&field.name); - let idx_name = format!( - "idx_{}_{}_{}", - sanitize_identifier(&table_name), - sanitized_field, - if field.unique { "uniq" } else { "idx" } - ); - // unique 字段使用 CREATE UNIQUE INDEX,由数据库保证数据完整性 - let idx_sql = if field.unique { - format!( - "CREATE UNIQUE INDEX IF NOT EXISTS \"{}\" ON \"{}\" (\"data\"->>'{}') WHERE \"deleted_at\" IS NULL", - idx_name, table_name, sanitized_field - ) - } else { - format!( - "CREATE INDEX IF NOT EXISTS \"{}\" ON \"{}\" (\"data\"->>'{}') WHERE \"deleted_at\" IS NULL", - idx_name, table_name, sanitized_field - ) - }; - tracing::info!(step = idx_counter, field = %field.name, unique = field.unique, sql = %idx_sql, "Creating field index"); - match db.execute_unprepared(&idx_sql).await { - Ok(_) => {}, - Err(e) => { - tracing::warn!(step = idx_counter, field = %field.name, sql = %idx_sql, error = %e, "Index creation skipped (non-fatal)"); - } - } - } + /// 创建动态表(使用 Generated Column + pg_trgm + 覆盖索引) + pub async fn create_table( + db: &DatabaseConnection, + plugin_id: &str, + entity: &PluginEntity, + ) -> PluginResult<()> { + let ddl = Self::build_create_table_sql(plugin_id, entity); + for sql in ddl + .split(';') + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + { + tracing::info!(sql = %sql, "Executing DDL"); + db.execute_unprepared(sql).await.map_err(|e| { + tracing::error!(sql = %sql, error = %e, "DDL execution failed"); + PluginError::DatabaseError(e.to_string()) + })?; } - - // 为 searchable 字段创建 B-tree 索引以加速 ILIKE 前缀查询 - for field in &entity.fields { - if field.searchable == Some(true) { - idx_counter += 1; - let sanitized_field = sanitize_identifier(&field.name); - let idx_name = format!("{}_{}_sidx", sanitize_identifier(&table_name), sanitized_field); - let idx_sql = format!( - "CREATE INDEX IF NOT EXISTS \"{}\" ON \"{}\" (\"data\"->>'{}') WHERE \"deleted_at\" IS NULL", - idx_name, table_name, sanitized_field - ); - tracing::info!(step = idx_counter, field = %field.name, sql = %idx_sql, "Creating search index"); - match db.execute_unprepared(&idx_sql).await { - Ok(_) => {}, - Err(e) => { - tracing::warn!(step = idx_counter, field = %field.name, sql = %idx_sql, error = %e, "Search index creation skipped (non-fatal)"); - } - } - } - } - - tracing::info!(table = %table_name, "Dynamic table created"); + tracing::info!( + plugin_id = %plugin_id, + entity = %entity.name, + "Dynamic table created with Generated Columns" + ); Ok(()) } @@ -717,4 +738,88 @@ mod tests { ); assert!(result.is_err(), "空字段名应被拒绝"); } + + // ===== build_create_table_sql (Generated Column) 测试 ===== + + #[test] + fn test_build_create_table_sql_with_generated_columns() { + use crate::manifest::{PluginEntity, PluginField, PluginFieldType}; + + let entity = PluginEntity { + name: "customer".to_string(), + display_name: "客户".to_string(), + fields: vec![ + PluginField { + name: "code".to_string(), + field_type: PluginFieldType::String, + required: true, + unique: true, + display_name: Some("编码".to_string()), + searchable: Some(true), + ..PluginField::default_for_field() + }, + PluginField { + name: "level".to_string(), + field_type: PluginFieldType::String, + filterable: Some(true), + display_name: Some("等级".to_string()), + ..PluginField::default_for_field() + }, + PluginField { + name: "sort_order".to_string(), + field_type: PluginFieldType::Integer, + sortable: Some(true), + display_name: Some("排序".to_string()), + ..PluginField::default_for_field() + }, + ], + indexes: vec![], + }; + + let sql = DynamicTableManager::build_create_table_sql("erp_crm", &entity); + assert!( + sql.contains("_f_code"), + "应包含 _f_code Generated Column" + ); + assert!( + sql.contains("_f_level"), + "应包含 _f_level Generated Column" + ); + assert!( + sql.contains("_f_sort_order"), + "应包含 _f_sort_order Generated Column" + ); + assert!( + sql.contains("GENERATED ALWAYS AS"), + "应包含 GENERATED ALWAYS AS" + ); + assert!( + sql.contains("::INTEGER"), + "Integer 字段应有类型转换" + ); + } + + #[test] + fn test_build_create_table_sql_pg_trgm_search_index() { + use crate::manifest::{PluginEntity, PluginField, PluginFieldType}; + + let entity = PluginEntity { + name: "customer".to_string(), + display_name: "客户".to_string(), + fields: vec![PluginField { + name: "name".to_string(), + field_type: PluginFieldType::String, + searchable: Some(true), + display_name: Some("名称".to_string()), + ..PluginField::default_for_field() + }], + indexes: vec![], + }; + + let sql = DynamicTableManager::build_create_table_sql("erp_crm", &entity); + assert!( + sql.contains("gin_trgm_ops"), + "searchable 字段应使用 pg_trgm GIN 索引" + ); + } }