feat(plugin): 新增数据统计 REST API — count 和 aggregate 端点

- dynamic_table: 新增 build_filtered_count_sql(带过滤/搜索的 COUNT)和 build_aggregate_sql(按字段分组计数)
- data_service: 新增 count 和 aggregate 方法,支持实时统计查询
- data_handler: 新增 count_plugin_data 和 aggregate_plugin_data REST handler
- data_dto: 新增 AggregateItem、AggregateQueryParams、CountQueryParams 类型
- module: 注册 /plugins/{plugin_id}/{entity}/count 和 /aggregate 路由
- 包含 8 个新增单元测试,全部通过
This commit is contained in:
iven
2026-04-16 16:22:33 +08:00
parent 169e6d1fe5
commit 9effa9f942
6 changed files with 460 additions and 1 deletions

View File

@@ -287,6 +287,117 @@ impl DynamicTableManager {
)
}
/// 构建带过滤条件的 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,
@@ -492,4 +603,116 @@ mod tests {
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(), "空字段名应被拒绝");
}
}