feat(plugin): create_table 使用 Generated Column + pg_trgm + 覆盖索引
This commit is contained in:
@@ -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 索引"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user