diff --git a/crates/erp-plugin/src/dynamic_table.rs b/crates/erp-plugin/src/dynamic_table.rs index c1ceb30..5026525 100644 --- a/crates/erp-plugin/src/dynamic_table.rs +++ b/crates/erp-plugin/src/dynamic_table.rs @@ -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, + search: Option<(String, String)>, // (searchable_fields_csv, keyword) + sort_by: Option, + sort_order: Option, + ) -> Result<(String, Vec), String> { + let mut conditions = vec![ + format!("\"tenant_id\" = ${}", 1), + "\"deleted_at\" IS NULL".to_string(), + ]; + let mut param_idx = 2; + let mut values: Vec = 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 = 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 + ); + } }