- get_schema 端点同时返回 entities 和 ui 页面配置,修复前端无法生成动态菜单的问题 - 动态表 INSERT/UPDATE 添加 ::jsonb 类型转换,修复 PostgreSQL 类型推断错误 - JSONB 索引创建改为非致命(warn 跳过),避免索引冲突阻断安装流程 - 权限注册/注销改用参数化查询,消除 SQL 注入风险 - DDL 语句改用 execute_unprepared,避免不必要的安全检查开销 - clear_plugin 支持已上传状态的清理 - 添加关键步骤 tracing 日志便于排查安装问题
721 lines
26 KiB
Rust
721 lines
26 KiB
Rust
use sea_orm::{ConnectionTrait, DatabaseConnection, FromQueryResult, Statement, Value};
|
||
use uuid::Uuid;
|
||
|
||
use crate::error::{PluginError, PluginResult};
|
||
use crate::manifest::PluginEntity;
|
||
|
||
/// 消毒标识符:只保留 ASCII 字母、数字、下划线,防止 SQL 注入
|
||
fn sanitize_identifier(input: &str) -> String {
|
||
input
|
||
.chars()
|
||
.map(|c| if c.is_ascii_alphanumeric() || c == '_' { c } else { '_' })
|
||
.collect()
|
||
}
|
||
|
||
/// 动态表管理器 — 处理插件动态创建/删除的数据库表
|
||
pub struct DynamicTableManager;
|
||
|
||
impl DynamicTableManager {
|
||
/// 生成动态表名: `plugin_{sanitized_id}_{sanitized_entity}`
|
||
pub fn table_name(plugin_id: &str, entity_name: &str) -> String {
|
||
let sanitized_id = sanitize_identifier(plugin_id);
|
||
let sanitized_entity = sanitize_identifier(entity_name);
|
||
format!("plugin_{}_{}", sanitized_id, sanitized_entity)
|
||
}
|
||
|
||
/// 创建动态表
|
||
pub async fn create_table(
|
||
db: &DatabaseConnection,
|
||
plugin_id: &str,
|
||
entity: &PluginEntity,
|
||
) -> PluginResult<()> {
|
||
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, \
|
||
\"tenant_id\" UUID NOT NULL, \
|
||
\"data\" JSONB NOT NULL DEFAULT '{{}}', \
|
||
\"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)"
|
||
);
|
||
|
||
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)");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 为 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");
|
||
Ok(())
|
||
}
|
||
|
||
/// 删除动态表
|
||
pub async fn drop_table(
|
||
db: &DatabaseConnection,
|
||
plugin_id: &str,
|
||
entity_name: &str,
|
||
) -> PluginResult<()> {
|
||
let table_name = Self::table_name(plugin_id, entity_name);
|
||
let sql = format!("DROP TABLE IF EXISTS \"{}\"", table_name);
|
||
db.execute(Statement::from_string(
|
||
sea_orm::DatabaseBackend::Postgres,
|
||
sql,
|
||
))
|
||
.await
|
||
.map_err(|e| PluginError::DatabaseError(e.to_string()))?;
|
||
tracing::info!(table = %table_name, "Dynamic table dropped");
|
||
Ok(())
|
||
}
|
||
|
||
/// 检查表是否存在
|
||
pub async fn table_exists(db: &DatabaseConnection, table_name: &str) -> PluginResult<bool> {
|
||
#[derive(FromQueryResult)]
|
||
struct ExistsResult {
|
||
exists: bool,
|
||
}
|
||
let result = ExistsResult::find_by_statement(Statement::from_sql_and_values(
|
||
sea_orm::DatabaseBackend::Postgres,
|
||
"SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = $1)",
|
||
[table_name.into()],
|
||
))
|
||
.one(db)
|
||
.await
|
||
.map_err(|e| PluginError::DatabaseError(e.to_string()))?;
|
||
|
||
Ok(result.map(|r| r.exists).unwrap_or(false))
|
||
}
|
||
|
||
/// 构建 INSERT SQL
|
||
pub fn build_insert_sql(
|
||
table_name: &str,
|
||
tenant_id: Uuid,
|
||
user_id: Uuid,
|
||
data: &serde_json::Value,
|
||
) -> (String, Vec<Value>) {
|
||
let id = Uuid::now_v7();
|
||
Self::build_insert_sql_with_id(table_name, id, tenant_id, user_id, data)
|
||
}
|
||
|
||
/// 构建 INSERT SQL(指定 ID)
|
||
pub fn build_insert_sql_with_id(
|
||
table_name: &str,
|
||
id: Uuid,
|
||
tenant_id: Uuid,
|
||
user_id: Uuid,
|
||
data: &serde_json::Value,
|
||
) -> (String, Vec<Value>) {
|
||
let sql = format!(
|
||
"INSERT INTO \"{}\" (id, tenant_id, data, created_by, updated_by, version) \
|
||
VALUES ($1, $2, $3::jsonb, $4, $5, 1) \
|
||
RETURNING id, tenant_id, data, created_at, updated_at, version",
|
||
table_name
|
||
);
|
||
let values = vec![
|
||
id.into(),
|
||
tenant_id.into(),
|
||
serde_json::to_string(data).unwrap_or_default().into(),
|
||
user_id.into(),
|
||
user_id.into(),
|
||
];
|
||
(sql, values)
|
||
}
|
||
|
||
/// 构建 SELECT SQL
|
||
pub fn build_query_sql(
|
||
table_name: &str,
|
||
tenant_id: Uuid,
|
||
limit: u64,
|
||
offset: u64,
|
||
) -> (String, Vec<Value>) {
|
||
let sql = format!(
|
||
"SELECT id, data, created_at, updated_at, version \
|
||
FROM \"{}\" \
|
||
WHERE tenant_id = $1 AND deleted_at IS NULL \
|
||
ORDER BY created_at DESC \
|
||
LIMIT $2 OFFSET $3",
|
||
table_name
|
||
);
|
||
let values = vec![tenant_id.into(), (limit as i64).into(), (offset as i64).into()];
|
||
(sql, values)
|
||
}
|
||
|
||
/// 构建 COUNT SQL
|
||
pub fn build_count_sql(table_name: &str, tenant_id: Uuid) -> (String, Vec<Value>) {
|
||
let sql = format!(
|
||
"SELECT COUNT(*) as count FROM \"{}\" WHERE tenant_id = $1 AND deleted_at IS NULL",
|
||
table_name
|
||
);
|
||
let values = vec![tenant_id.into()];
|
||
(sql, values)
|
||
}
|
||
|
||
/// 构建 UPDATE SQL(含乐观锁)
|
||
pub fn build_update_sql(
|
||
table_name: &str,
|
||
id: Uuid,
|
||
tenant_id: Uuid,
|
||
user_id: Uuid,
|
||
data: &serde_json::Value,
|
||
version: i32,
|
||
) -> (String, Vec<Value>) {
|
||
let sql = format!(
|
||
"UPDATE \"{}\" \
|
||
SET data = $1::jsonb, updated_at = NOW(), updated_by = $2, version = version + 1 \
|
||
WHERE id = $3 AND tenant_id = $4 AND version = $5 AND deleted_at IS NULL \
|
||
RETURNING id, data, created_at, updated_at, version",
|
||
table_name
|
||
);
|
||
let values = vec![
|
||
serde_json::to_string(data).unwrap_or_default().into(),
|
||
user_id.into(),
|
||
id.into(),
|
||
tenant_id.into(),
|
||
version.into(),
|
||
];
|
||
(sql, values)
|
||
}
|
||
|
||
/// 构建 DELETE SQL(软删除)
|
||
pub fn build_delete_sql(
|
||
table_name: &str,
|
||
id: Uuid,
|
||
tenant_id: Uuid,
|
||
) -> (String, Vec<Value>) {
|
||
let sql = format!(
|
||
"UPDATE \"{}\" \
|
||
SET deleted_at = NOW(), updated_at = NOW() \
|
||
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL",
|
||
table_name
|
||
);
|
||
let values = vec![id.into(), tenant_id.into()];
|
||
(sql, values)
|
||
}
|
||
|
||
/// 构建单条查询 SQL
|
||
pub fn build_get_by_id_sql(
|
||
table_name: &str,
|
||
id: Uuid,
|
||
tenant_id: Uuid,
|
||
) -> (String, Vec<Value>) {
|
||
let sql = format!(
|
||
"SELECT id, data, created_at, updated_at, version \
|
||
FROM \"{}\" \
|
||
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL",
|
||
table_name
|
||
);
|
||
let values = vec![id.into(), tenant_id.into()];
|
||
(sql, values)
|
||
}
|
||
|
||
/// 构建唯一索引 SQL(供测试验证)
|
||
pub fn build_unique_index_sql(table_name: &str, field_name: &str) -> String {
|
||
let sanitized_field = sanitize_identifier(field_name);
|
||
let idx_name = format!(
|
||
"idx_{}_{}_uniq",
|
||
sanitize_identifier(table_name),
|
||
sanitized_field
|
||
);
|
||
format!(
|
||
"CREATE UNIQUE INDEX IF NOT EXISTS \"{}\" ON \"{}\" (\"data\"->>'{}') WHERE \"deleted_at\" IS NULL",
|
||
idx_name, table_name, sanitized_field
|
||
)
|
||
}
|
||
|
||
/// 构建带过滤条件的 COUNT SQL
|
||
/// 复用 build_filtered_query_sql 的条件构建逻辑,但只做 COUNT
|
||
pub fn build_filtered_count_sql(
|
||
table_name: &str,
|
||
tenant_id: Uuid,
|
||
filter: Option<serde_json::Value>,
|
||
search: Option<(String, String)>, // (searchable_fields_csv, keyword)
|
||
) -> Result<(String, Vec<Value>), String> {
|
||
let mut conditions = vec![
|
||
format!("\"tenant_id\" = ${}", 1),
|
||
"\"deleted_at\" IS NULL".to_string(),
|
||
];
|
||
let mut param_idx = 2;
|
||
let mut values: Vec<Value> = vec![tenant_id.into()];
|
||
|
||
// 处理 filter(与 build_filtered_query_sql 保持一致)
|
||
if let Some(f) = filter {
|
||
if let Some(obj) = f.as_object() {
|
||
for (key, val) in obj {
|
||
let clean_key = sanitize_identifier(key);
|
||
if clean_key.is_empty() {
|
||
return Err(format!("无效的过滤字段名: {}", key));
|
||
}
|
||
conditions.push(format!("\"data\"->>'{}' = ${}", clean_key, param_idx));
|
||
values.push(Value::String(Some(Box::new(
|
||
val.as_str().unwrap_or("").to_string(),
|
||
))));
|
||
param_idx += 1;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 处理 search(与 build_filtered_query_sql 保持一致)
|
||
if let Some((fields_csv, keyword)) = search {
|
||
let escaped = keyword.replace('%', "\\%").replace('_', "\\_");
|
||
let fields: Vec<&str> = fields_csv.split(',').collect();
|
||
let search_param_idx = param_idx;
|
||
let search_conditions: Vec<String> = fields
|
||
.iter()
|
||
.map(|f| {
|
||
let clean = sanitize_identifier(f.trim());
|
||
format!("\"data\"->>'{}' ILIKE ${}", clean, search_param_idx)
|
||
})
|
||
.collect();
|
||
conditions.push(format!("({})", search_conditions.join(" OR ")));
|
||
values.push(Value::String(Some(Box::new(format!("%{}%", escaped)))));
|
||
}
|
||
|
||
let sql = format!(
|
||
"SELECT COUNT(*) as count FROM \"{}\" WHERE {}",
|
||
table_name,
|
||
conditions.join(" AND "),
|
||
);
|
||
|
||
Ok((sql, values))
|
||
}
|
||
|
||
/// 构建聚合查询 SQL — 按 JSONB 字段分组计数
|
||
/// SELECT data->>'group_field' as key, COUNT(*) as count
|
||
/// FROM table WHERE tenant_id = $1 AND deleted_at IS NULL [AND filter...]
|
||
/// GROUP BY data->>'group_field' ORDER BY count DESC
|
||
pub fn build_aggregate_sql(
|
||
table_name: &str,
|
||
tenant_id: Uuid,
|
||
group_by_field: &str,
|
||
filter: Option<serde_json::Value>,
|
||
) -> Result<(String, Vec<Value>), String> {
|
||
let clean_group = sanitize_identifier(group_by_field);
|
||
if clean_group.is_empty() {
|
||
return Err(format!("无效的分组字段名: {}", group_by_field));
|
||
}
|
||
|
||
let mut conditions = vec![
|
||
format!("\"tenant_id\" = ${}", 1),
|
||
"\"deleted_at\" IS NULL".to_string(),
|
||
];
|
||
let mut param_idx = 2;
|
||
let mut values: Vec<Value> = vec![tenant_id.into()];
|
||
|
||
// 处理 filter
|
||
if let Some(f) = filter {
|
||
if let Some(obj) = f.as_object() {
|
||
for (key, val) in obj {
|
||
let clean_key = sanitize_identifier(key);
|
||
if clean_key.is_empty() {
|
||
return Err(format!("无效的过滤字段名: {}", key));
|
||
}
|
||
conditions.push(format!("\"data\"->>'{}' = ${}", clean_key, param_idx));
|
||
values.push(Value::String(Some(Box::new(
|
||
val.as_str().unwrap_or("").to_string(),
|
||
))));
|
||
param_idx += 1;
|
||
}
|
||
}
|
||
}
|
||
|
||
let sql = format!(
|
||
"SELECT \"data\"->>'{}' as key, COUNT(*) as count \
|
||
FROM \"{}\" \
|
||
WHERE {} \
|
||
GROUP BY \"data\"->>'{}' \
|
||
ORDER BY count DESC",
|
||
clean_group,
|
||
table_name,
|
||
conditions.join(" AND "),
|
||
clean_group,
|
||
);
|
||
|
||
Ok((sql, values))
|
||
}
|
||
|
||
/// 构建带过滤条件的查询 SQL
|
||
pub fn build_filtered_query_sql(
|
||
table_name: &str,
|
||
tenant_id: Uuid,
|
||
limit: u64,
|
||
offset: u64,
|
||
filter: Option<serde_json::Value>,
|
||
search: Option<(String, String)>, // (searchable_fields_csv, keyword)
|
||
sort_by: Option<String>,
|
||
sort_order: Option<String>,
|
||
) -> Result<(String, Vec<Value>), String> {
|
||
let mut conditions = vec![
|
||
format!("\"tenant_id\" = ${}", 1),
|
||
"\"deleted_at\" IS NULL".to_string(),
|
||
];
|
||
let mut param_idx = 2;
|
||
let mut values: Vec<Value> = vec![tenant_id.into()];
|
||
|
||
// 处理 filter
|
||
if let Some(f) = filter {
|
||
if let Some(obj) = f.as_object() {
|
||
for (key, val) in obj {
|
||
let clean_key = sanitize_identifier(key);
|
||
if clean_key.is_empty() {
|
||
return Err(format!("无效的过滤字段名: {}", key));
|
||
}
|
||
conditions.push(format!("\"data\"->>'{}' = ${}", clean_key, param_idx));
|
||
values.push(Value::String(Some(Box::new(
|
||
val.as_str().unwrap_or("").to_string(),
|
||
))));
|
||
param_idx += 1;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 处理 search — 所有 searchable 字段共享同一个 ILIKE 参数
|
||
if let Some((fields_csv, keyword)) = search {
|
||
let escaped = keyword.replace('%', "\\%").replace('_', "\\_");
|
||
let fields: Vec<&str> = fields_csv.split(',').collect();
|
||
let search_param_idx = param_idx;
|
||
let search_conditions: Vec<String> = fields
|
||
.iter()
|
||
.map(|f| {
|
||
let clean = sanitize_identifier(f.trim());
|
||
format!("\"data\"->>'{}' ILIKE ${}", clean, search_param_idx)
|
||
})
|
||
.collect();
|
||
conditions.push(format!("({})", search_conditions.join(" OR ")));
|
||
values.push(Value::String(Some(Box::new(format!("%{}%", escaped)))));
|
||
param_idx += 1;
|
||
}
|
||
|
||
// 处理 sort
|
||
let order_clause = if let Some(sb) = sort_by {
|
||
let clean = sanitize_identifier(&sb);
|
||
if clean.is_empty() {
|
||
return Err(format!("无效的排序字段名: {}", sb));
|
||
}
|
||
let dir = match sort_order.as_deref() {
|
||
Some("asc") | Some("ASC") => "ASC",
|
||
_ => "DESC",
|
||
};
|
||
format!("ORDER BY \"data\"->>'{}' {}", clean, dir)
|
||
} else {
|
||
"ORDER BY \"created_at\" DESC".to_string()
|
||
};
|
||
|
||
let sql = format!(
|
||
"SELECT id, data, created_at, updated_at, version FROM \"{}\" WHERE {} {} LIMIT ${} OFFSET ${}",
|
||
table_name,
|
||
conditions.join(" AND "),
|
||
order_clause,
|
||
param_idx,
|
||
param_idx + 1,
|
||
);
|
||
values.push((limit as i64).into());
|
||
values.push((offset as i64).into());
|
||
|
||
Ok((sql, values))
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn test_unique_index_sql_uses_create_unique_index() {
|
||
let sql = DynamicTableManager::build_unique_index_sql("plugin_test", "code");
|
||
assert!(
|
||
sql.contains("CREATE UNIQUE INDEX"),
|
||
"Expected UNIQUE index, got: {}",
|
||
sql
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_build_filtered_query_sql_with_filter() {
|
||
let (sql, values) = DynamicTableManager::build_filtered_query_sql(
|
||
"plugin_test_customer",
|
||
Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
|
||
20,
|
||
0,
|
||
Some(serde_json::json!({"customer_id": "abc-123"})),
|
||
None,
|
||
None,
|
||
None,
|
||
)
|
||
.unwrap();
|
||
assert!(
|
||
sql.contains("\"data\"->>'customer_id' ="),
|
||
"Expected filter in SQL, got: {}",
|
||
sql
|
||
);
|
||
assert!(sql.contains("tenant_id"), "Expected tenant_id filter");
|
||
// 验证参数值
|
||
assert_eq!(values.len(), 4); // tenant_id + filter_value + limit + offset
|
||
}
|
||
|
||
#[test]
|
||
fn test_build_filtered_query_sql_sanitizes_keys() {
|
||
let result = DynamicTableManager::build_filtered_query_sql(
|
||
"plugin_test",
|
||
Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
|
||
20,
|
||
0,
|
||
Some(serde_json::json!({"evil'; DROP TABLE--": "value"})),
|
||
None,
|
||
None,
|
||
None,
|
||
);
|
||
// 恶意 key 被清理为合法标识符
|
||
let (sql, _) = result.unwrap();
|
||
assert!(
|
||
!sql.contains("DROP TABLE"),
|
||
"SQL should not contain injection: {}",
|
||
sql
|
||
);
|
||
assert!(
|
||
sql.contains("evil___DROP_TABLE__"),
|
||
"Key should be sanitized: {}",
|
||
sql
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_build_filtered_query_sql_with_search() {
|
||
let (sql, values) = DynamicTableManager::build_filtered_query_sql(
|
||
"plugin_test",
|
||
Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
|
||
20,
|
||
0,
|
||
None,
|
||
Some(("name,code".to_string(), "测试关键词".to_string())),
|
||
None,
|
||
None,
|
||
)
|
||
.unwrap();
|
||
assert!(sql.contains("ILIKE"), "Expected ILIKE in SQL, got: {}", sql);
|
||
// 验证搜索参数值包含 %...%
|
||
if let Value::String(Some(s)) = &values[1] {
|
||
assert!(s.contains("测试关键词"), "Search value should contain keyword");
|
||
assert!(s.starts_with('%'), "Search value should start with %");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_build_filtered_query_sql_with_sort() {
|
||
let (sql, _) = DynamicTableManager::build_filtered_query_sql(
|
||
"plugin_test",
|
||
Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
|
||
20,
|
||
0,
|
||
None,
|
||
None,
|
||
Some("name".to_string()),
|
||
Some("asc".to_string()),
|
||
)
|
||
.unwrap();
|
||
assert!(
|
||
sql.contains("ORDER BY \"data\"->>'name' ASC"),
|
||
"Expected sort clause, got: {}",
|
||
sql
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_build_filtered_query_sql_default_sort() {
|
||
let (sql, _) = DynamicTableManager::build_filtered_query_sql(
|
||
"plugin_test",
|
||
Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
|
||
20,
|
||
0,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
)
|
||
.unwrap();
|
||
assert!(
|
||
sql.contains("ORDER BY \"created_at\" DESC"),
|
||
"Expected default sort, got: {}",
|
||
sql
|
||
);
|
||
}
|
||
|
||
// ===== build_filtered_count_sql 测试 =====
|
||
|
||
#[test]
|
||
fn test_build_filtered_count_sql_basic() {
|
||
let (sql, values) = DynamicTableManager::build_filtered_count_sql(
|
||
"plugin_test",
|
||
Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
|
||
None,
|
||
None,
|
||
)
|
||
.unwrap();
|
||
assert!(sql.contains("COUNT(*)"), "Expected COUNT(*), got: {}", sql);
|
||
assert!(sql.contains("tenant_id"), "Expected tenant_id filter");
|
||
assert!(sql.contains("deleted_at"), "Expected soft delete filter");
|
||
assert_eq!(values.len(), 1); // 仅 tenant_id
|
||
}
|
||
|
||
#[test]
|
||
fn test_build_filtered_count_sql_with_filter() {
|
||
let (sql, values) = DynamicTableManager::build_filtered_count_sql(
|
||
"plugin_test_customer",
|
||
Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
|
||
Some(serde_json::json!({"status": "active"})),
|
||
None,
|
||
)
|
||
.unwrap();
|
||
assert!(sql.contains("\"data\"->>'status' ="), "Expected filter, got: {}", sql);
|
||
assert_eq!(values.len(), 2); // tenant_id + filter_value
|
||
}
|
||
|
||
#[test]
|
||
fn test_build_filtered_count_sql_with_search() {
|
||
let (sql, values) = DynamicTableManager::build_filtered_count_sql(
|
||
"plugin_test",
|
||
Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
|
||
None,
|
||
Some(("name,code".to_string(), "搜索词".to_string())),
|
||
)
|
||
.unwrap();
|
||
assert!(sql.contains("ILIKE"), "Expected ILIKE, got: {}", sql);
|
||
assert_eq!(values.len(), 2); // tenant_id + search_param
|
||
}
|
||
|
||
#[test]
|
||
fn test_build_filtered_count_sql_no_limit_offset() {
|
||
// COUNT SQL 不应包含 LIMIT/OFFSET
|
||
let (sql, _) = DynamicTableManager::build_filtered_count_sql(
|
||
"plugin_test",
|
||
Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
|
||
None,
|
||
None,
|
||
)
|
||
.unwrap();
|
||
assert!(!sql.contains("LIMIT"), "COUNT SQL 不应包含 LIMIT");
|
||
assert!(!sql.contains("OFFSET"), "COUNT SQL 不应包含 OFFSET");
|
||
}
|
||
|
||
// ===== build_aggregate_sql 测试 =====
|
||
|
||
#[test]
|
||
fn test_build_aggregate_sql_basic() {
|
||
let (sql, values) = DynamicTableManager::build_aggregate_sql(
|
||
"plugin_test_customer",
|
||
Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
|
||
"status",
|
||
None,
|
||
)
|
||
.unwrap();
|
||
assert!(sql.contains("GROUP BY"), "Expected GROUP BY, got: {}", sql);
|
||
assert!(sql.contains("\"data\"->>'status'"), "Expected group field, got: {}", sql);
|
||
assert!(sql.contains("ORDER BY count DESC"), "Expected ORDER BY count DESC, got: {}", sql);
|
||
assert_eq!(values.len(), 1); // 仅 tenant_id
|
||
}
|
||
|
||
#[test]
|
||
fn test_build_aggregate_sql_with_filter() {
|
||
let (sql, values) = DynamicTableManager::build_aggregate_sql(
|
||
"plugin_test_customer",
|
||
Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
|
||
"region",
|
||
Some(serde_json::json!({"status": "active"})),
|
||
)
|
||
.unwrap();
|
||
assert!(sql.contains("\"data\"->>'region'"), "Expected group field, got: {}", sql);
|
||
assert!(sql.contains("\"data\"->>'status' ="), "Expected filter, got: {}", sql);
|
||
assert_eq!(values.len(), 2); // tenant_id + filter_value
|
||
}
|
||
|
||
#[test]
|
||
fn test_build_aggregate_sql_sanitizes_group_field() {
|
||
let result = DynamicTableManager::build_aggregate_sql(
|
||
"plugin_test",
|
||
Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
|
||
"evil'; DROP TABLE--",
|
||
None,
|
||
);
|
||
let (sql, _) = result.unwrap();
|
||
assert!(!sql.contains("DROP TABLE"), "SQL 不应包含注入: {}", sql);
|
||
assert!(sql.contains("evil___DROP_TABLE__"), "字段名应被清理: {}", sql);
|
||
}
|
||
|
||
#[test]
|
||
fn test_build_aggregate_sql_empty_field_rejected() {
|
||
let result = DynamicTableManager::build_aggregate_sql(
|
||
"plugin_test",
|
||
Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
|
||
"",
|
||
None,
|
||
);
|
||
assert!(result.is_err(), "空字段名应被拒绝");
|
||
}
|
||
}
|