feat(plugin): create_table 使用 Generated Column + pg_trgm + 覆盖索引

This commit is contained in:
iven
2026-04-17 10:15:05 +08:00
parent 32dd0f72c1
commit a897cd7a87

View File

@@ -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 索引"
);
}
}