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,
|
sanitized_field,
|
||||||
if field.unique { "uniq" } else { "idx" }
|
if field.unique { "uniq" } else { "idx" }
|
||||||
);
|
);
|
||||||
let idx_sql = format!(
|
// unique 字段使用 CREATE UNIQUE INDEX,由数据库保证数据完整性
|
||||||
"CREATE INDEX IF NOT EXISTS \"{idx_name}\" ON \"{table_name}\" (\"data\"->>'{sanitized_field}') WHERE \"deleted_at\" IS NULL"
|
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(
|
db.execute(Statement::from_string(
|
||||||
sea_orm::DatabaseBackend::Postgres,
|
sea_orm::DatabaseBackend::Postgres,
|
||||||
idx_sql,
|
idx_sql,
|
||||||
@@ -247,4 +254,224 @@ impl DynamicTableManager {
|
|||||||
let values = vec![id.into(), tenant_id.into()];
|
let values = vec![id.into(), tenant_id.into()];
|
||||||
(sql, values)
|
(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