fix(postgres): prevent CTE DML statements from being paginated

Co-authored-by: staff <staff@qimaos-MacBook-Pro.local>
This commit is contained in:
zipg 2026-06-24 18:08:31 +08:00 committed by GitHub
parent 6689489cfb
commit 7b47edde3d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
1 changed files with 109 additions and 1 deletions

View File

@ -302,7 +302,11 @@ fn single_selectable_statement(original_sql: &str) -> Result<String, ()> {
return Err(());
}
let upper = statement.trim_start_matches(';').trim_start().to_ascii_uppercase();
if !(upper.starts_with("SELECT") || upper.starts_with("WITH")) {
if upper.starts_with("WITH") {
if !cte_main_statement_is_select(&statement) {
return Err(());
}
} else if !upper.starts_with("SELECT") {
return Err(());
}
if has_top_level_select_into(&statement) {
@ -316,6 +320,46 @@ fn starts_with_cte(sql: &str) -> bool {
sql.trim_start().trim_start_matches(';').trim_start().to_ascii_uppercase().starts_with("WITH")
}
fn cte_main_statement_is_select(sql: &str) -> bool {
let tokens = top_level_sql_tokens(sql);
let mut index = match tokens.iter().position(|token| token.text == "WITH") {
Some(index) => index + 1,
None => return false,
};
if tokens.get(index).is_some_and(|token| token.text == "RECURSIVE") {
index += 1;
}
loop {
if tokens.get(index).is_some_and(|token| token.text != "AS") {
index += 1;
}
if tokens.get(index).is_none_or(|token| token.text != "AS") {
return false;
}
index += 1;
if tokens.get(index).is_some_and(|token| token.text == "NOT") {
index += 1;
if tokens.get(index).is_none_or(|token| token.text != "MATERIALIZED") {
return false;
}
index += 1;
} else if tokens.get(index).is_some_and(|token| token.text == "MATERIALIZED") {
index += 1;
}
let Some(token) = tokens.get(index) else {
return false;
};
if tokens.get(index + 1).is_some_and(|next| next.text == "AS") {
continue;
}
return token.text == "SELECT";
}
}
fn single_statement_error_reason(original_sql: &str) -> &'static str {
let base_sql = original_sql.trim();
if base_sql.is_empty() {
@ -898,6 +942,70 @@ mod tests {
assert!(result.sql.is_none());
}
#[test]
fn postgres_cte_update_is_not_paginated() {
let sql = r#"
WITH available AS (
SELECT id
FROM app_users
WHERE deleted_at IS NULL
),
picked AS (
SELECT id
FROM available
ORDER BY random()
LIMIT 10
)
UPDATE app_users AS u
SET subscription_type = 1
FROM picked
WHERE u.id = picked.id;
"#;
let result = build_paginated_query_sql(PaginatedQuerySqlOptions {
original_sql: sql.to_string(),
database_type: Some(DatabaseType::Postgres),
limit: 100,
offset: 0,
});
assert_eq!(result, err("not_select"));
}
#[test]
fn postgres_cte_update_pagination_plan_executes_original_sql() {
let sql = "WITH picked AS (SELECT id FROM app_users LIMIT 10) UPDATE app_users SET subscription_type = 1 FROM picked WHERE app_users.id = picked.id".to_string();
let plan = build_query_pagination_execution_plan(QueryPaginationExecutionPlanOptions {
sql: sql.clone(),
query_base_sql: sql.clone(),
database_type: Some(DatabaseType::Postgres),
pagination: QueryPagination { limit: 100, offset: 0, session_id: None },
use_agent_cursor: false,
});
assert_eq!(plan.sql_to_execute, sql);
assert!(plan.page_sql.is_none());
assert!(plan.count_sql.is_none());
assert_eq!(plan.page_limit, None);
assert_eq!(plan.page_offset, None);
}
#[test]
fn postgres_cte_select_still_paginates() {
let result = build_paginated_query_sql(PaginatedQuerySqlOptions {
original_sql: "WITH picked AS (SELECT id FROM app_users LIMIT 10) SELECT * FROM picked".to_string(),
database_type: Some(DatabaseType::Postgres),
limit: 100,
offset: 0,
});
assert!(result.ok);
assert_eq!(
result.sql.unwrap(),
"WITH picked AS (SELECT id FROM app_users LIMIT 10) SELECT * FROM picked LIMIT 100;"
);
}
#[test]
fn wraps_sqlserver_select_with_unnamed_column() {
let result = build_paginated_query_sql(PaginatedQuerySqlOptions {