feat(plugin): SQL 查询路由 — Generated Column 字段优先使用 _f_ 前缀列
This commit is contained in:
@@ -501,6 +501,102 @@ impl DynamicTableManager {
|
|||||||
|
|
||||||
Ok((sql, values))
|
Ok((sql, values))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 返回字段引用函数 — Generated Column 存在时用 _f_{name}
|
||||||
|
pub fn field_reference_fn(generated_fields: &[String]) -> impl Fn(&str) -> String + '_ {
|
||||||
|
move |field_name: &str| {
|
||||||
|
let clean = sanitize_identifier(field_name);
|
||||||
|
if generated_fields.contains(&clean) {
|
||||||
|
format!("\"_f_{}\"", clean)
|
||||||
|
} else {
|
||||||
|
format!("\"data\"->>'{}'", clean)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 扩展版查询构建 — 支持 Generated Column 路由
|
||||||
|
pub fn build_filtered_query_sql_ex(
|
||||||
|
table_name: &str,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
limit: u64,
|
||||||
|
offset: u64,
|
||||||
|
filter: Option<serde_json::Value>,
|
||||||
|
search: Option<(String, String)>,
|
||||||
|
sort_by: Option<String>,
|
||||||
|
sort_order: Option<String>,
|
||||||
|
generated_fields: &[String],
|
||||||
|
) -> Result<(String, Vec<Value>), String> {
|
||||||
|
let ref_fn = Self::field_reference_fn(generated_fields);
|
||||||
|
|
||||||
|
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!("{} = ${}", ref_fn(&clean_key), param_idx));
|
||||||
|
values.push(Value::String(Some(Box::new(
|
||||||
|
val.as_str().unwrap_or("").to_string(),
|
||||||
|
))));
|
||||||
|
param_idx += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// search
|
||||||
|
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 {} {}", ref_fn(&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)]
|
#[cfg(test)]
|
||||||
@@ -822,4 +918,64 @@ mod tests {
|
|||||||
"searchable 字段应使用 pg_trgm GIN 索引"
|
"searchable 字段应使用 pg_trgm GIN 索引"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== field_reference_fn + build_filtered_query_sql_ex 测试 =====
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_field_reference_uses_generated_column() {
|
||||||
|
let generated_fields = vec![
|
||||||
|
"code".to_string(),
|
||||||
|
"status".to_string(),
|
||||||
|
"level".to_string(),
|
||||||
|
];
|
||||||
|
let ref_fn = DynamicTableManager::field_reference_fn(&generated_fields);
|
||||||
|
assert_eq!(ref_fn("code"), "\"_f_code\"");
|
||||||
|
assert_eq!(ref_fn("status"), "\"_f_status\"");
|
||||||
|
assert_eq!(ref_fn("name"), "\"data\"->>'name'");
|
||||||
|
assert_eq!(ref_fn("remark"), "\"data\"->>'remark'");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_filtered_query_uses_generated_column_for_sort() {
|
||||||
|
let generated_fields = vec!["code".to_string(), "level".to_string()];
|
||||||
|
let (sql, _) = DynamicTableManager::build_filtered_query_sql_ex(
|
||||||
|
"plugin_test",
|
||||||
|
Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
|
||||||
|
20,
|
||||||
|
0,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some("level".to_string()),
|
||||||
|
Some("asc".to_string()),
|
||||||
|
&generated_fields,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
sql.contains("ORDER BY \"_f_level\" ASC"),
|
||||||
|
"排序应使用 Generated Column,got: {}",
|
||||||
|
sql
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_filtered_query_uses_generated_column_for_filter() {
|
||||||
|
let generated_fields = vec!["status".to_string()];
|
||||||
|
let (sql, _) = DynamicTableManager::build_filtered_query_sql_ex(
|
||||||
|
"plugin_test",
|
||||||
|
Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
|
||||||
|
20,
|
||||||
|
0,
|
||||||
|
Some(serde_json::json!({"status": "active"})),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
&generated_fields,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
sql.contains("\"_f_status\" = $"),
|
||||||
|
"过滤应使用 Generated Column,got: {}",
|
||||||
|
sql
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user