fix(plugin): 修复唯一索引使用 CREATE UNIQUE INDEX 并添加过滤查询 SQL 构建器
- unique 字段索引从 CREATE INDEX 改为 CREATE UNIQUE INDEX - 新增 build_unique_index_sql 辅助方法 - 新增 build_filtered_query_sql 支持 filter/search/sort 组合查询 - 新增 sanitize_identifier 防止 SQL 注入 - 添加 6 个单元测试覆盖过滤查询场景
This commit is contained in:
@@ -74,9 +74,16 @@ impl DynamicTableManager {
|
||||
sanitized_field,
|
||||
if field.unique { "uniq" } else { "idx" }
|
||||
);
|
||||
let idx_sql = format!(
|
||||
"CREATE INDEX IF NOT EXISTS \"{idx_name}\" ON \"{table_name}\" (\"data\"->>'{sanitized_field}') WHERE \"deleted_at\" IS NULL"
|
||||
);
|
||||
// unique 字段使用 CREATE UNIQUE INDEX,由数据库保证数据完整性
|
||||
let idx_sql = if field.unique {
|
||||
format!(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS \"{idx_name}\" ON \"{table_name}\" (\"data\"->>'{sanitized_field}') WHERE \"deleted_at\" IS NULL"
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"CREATE INDEX IF NOT EXISTS \"{idx_name}\" ON \"{table_name}\" (\"data\"->>'{sanitized_field}') WHERE \"deleted_at\" IS NULL"
|
||||
)
|
||||
};
|
||||
db.execute(Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
idx_sql,
|
||||
@@ -247,4 +254,224 @@ impl DynamicTableManager {
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
/// 构建带过滤条件的查询 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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user