diff --git a/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts index e3c04b445a..a54abbb9af 100644 --- a/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts @@ -353,6 +353,38 @@ describe("splitSqlStatementRanges", () => { expect(rangeSqlTexts(splitSqlStatementRanges(sql))).toEqual(["SELECT 1", "SELECT 2", "SELECT 3"]); }); + // Issue #7832 shape: MySQL GROUP BY over a LEFT JOIN with an aggregated + // derived table, COUNT(DISTINCT IF(...)) in the projection, and inline + // `-- 中文` line comments. The splitter must keep it a single statement and + // cursor-statement extraction must always include the trailing GROUP BY, so + // the executed SQL keeps the query's row cardinality. + it("keeps a commented MySQL GROUP BY query as one statement including the GROUP BY (issue #7832)", () => { + const sql = [ + "SELECT", + " base.brand_name", + " ,base.stall_id", + " ,base.floor", + " ,COUNT(1) total_invite -- 邀约数量", + " ,SUM(IFNULL(base.ver_status, 0)) sign_num -- 签到数量", + " ,SUM(IFNULL(dr.draw_count, 0)) draw_num -- 抽奖次数", + " ,SUM(IFNULL(dr.draw_user_num, 0)) draw_user_num -- 抽奖人数", + " ,COUNT(distinct IF(base.ver_status = 1, base.mobile, null)) sign_and_draw_user_num", + "FROM v_form_data_1786326962 base", + "LEFT JOIN (", + " SELECT id, SUM(IFNULL(hx_status, 0)) draw_count, COUNT(distinct IF(hx_status = 1, mobile, null)) draw_user_num", + " FROM v_form_data_1786326962_coupon", + " GROUP BY id", + ") dr ON base.id = dr.id", + "GROUP BY base.brand_name, base.stall_id, base.floor", + ].join("\n"); + const ranges = splitSqlStatementRanges(sql, "mysql"); + expect(rangeSqlTexts(ranges)).toEqual([sql.trim()]); + for (const position of [0, indexOf(sql, "total_invite"), indexOf(sql, "sign_and_draw_user_num"), sql.indexOf("GROUP BY") + 5, sql.length - 1]) { + const range = statementRangeAtCursor(sql, position, "mysql"); + expect(range?.sql).toContain("GROUP BY base.brand_name, base.stall_id, base.floor"); + } + }); + it("keeps a trailing statement without a semicolon", () => { const sql = "SELECT 1;\nSELECT 2"; const ranges = splitSqlStatementRanges(sql); diff --git a/crates/dbx-core/src/query_result_sql.rs b/crates/dbx-core/src/query_result_sql.rs index 0a9b8fe0b2..20f07d6c45 100644 --- a/crates/dbx-core/src/query_result_sql.rs +++ b/crates/dbx-core/src/query_result_sql.rs @@ -2335,6 +2335,61 @@ fn fallback_alias(index: usize) -> String { mod tests { use super::*; + /// Query shape from issue #7832: a MySQL GROUP BY over a LEFT JOIN with an + /// aggregated derived table, COUNT(DISTINCT IF(...)) in the projection, and + /// inline `-- 中文` line comments. Locks in the invariants that keep DBX's + /// derived page/count SQL row-count-identical to the user's statement: + /// the statement splitter must keep it a single statement, the page SQL + /// must preserve the GROUP BY while injecting deterministic pagination, + /// and the count wrap must count the grouped result, not the raw join. + #[test] + fn mysql_group_by_with_distinct_if_and_comments_keeps_grouping_in_page_and_count_sql() { + let sql = "SELECT\n base.brand_name\n ,base.stall_id\n ,base.floor\n ,COUNT(1) total_invite -- 邀约数量\n ,SUM(IFNULL(base.ver_status, 0)) sign_num -- 签到数量\n ,SUM(IFNULL(dr.draw_count, 0)) draw_num -- 抽奖次数\n ,SUM(IFNULL(dr.draw_user_num, 0)) draw_user_num -- 抽奖人数\n ,COUNT(distinct IF(base.ver_status = 1, base.mobile, null)) sign_and_draw_user_num\nFROM v_form_data_1786326962 base\nLEFT JOIN (\n SELECT id, SUM(IFNULL(hx_status, 0)) draw_count, COUNT(distinct IF(hx_status = 1, mobile, null)) draw_user_num\n FROM v_form_data_1786326962_coupon\n GROUP BY id\n) dr ON base.id = dr.id\nGROUP BY base.brand_name, base.stall_id, base.floor"; + + let statements = crate::sql::split_sql_statements_for_database(sql, DatabaseType::Mysql); + assert_eq!(statements.len(), 1, "inline comments must not split the statement"); + + let plan = build_query_pagination_execution_plan(QueryPaginationExecutionPlanOptions { + sql: sql.to_string(), + query_base_sql: sql.to_string(), + database_type: Some(DatabaseType::Mysql), + pagination: QueryPagination { limit: 500, offset: 0, session_id: None }, + use_agent_cursor: false, + first_page_uses_actual_sql: false, + }); + + let page_sql = plan.page_sql.expect("pagination plan provides page SQL"); + assert!(page_sql.to_uppercase().contains("GROUP BY")); + assert!(page_sql.contains("ORDER BY 1, 2, 3 LIMIT 500;"), "dedup pagination must be appended after the GROUP BY, got: {page_sql}"); + + let count_sql = plan.count_sql.expect("pagination plan provides count SQL"); + assert!(count_sql.starts_with("SELECT COUNT(*) AS dbx_total_rows FROM (")); + assert_eq!( + count_sql.matches("GROUP BY").count(), + 2, + "the count wrap must keep both the outer and derived-table GROUP BY, got: {count_sql}" + ); + + let mut variants = vec![ + ("crlf", sql.replace('\n', "\r\n")), + ("trailing semicolon", format!("{sql};")), + ("trailing GROUP BY comment", format!("{sql} -- 分组")), + ]; + if let Some(stripped) = sql.strip_prefix("SELECT") { + variants.push(("leading comment", format!("-- header\nSELECT{stripped}"))); + } + for (name, variant) in variants { + let counted = build_count_query_sql(CountQuerySqlOptions { + original_sql: variant.clone(), + database_type: Some(DatabaseType::Mysql), + }); + let counted = counted.sql.unwrap_or_default(); + assert_eq!(counted.matches("GROUP BY").count(), 2, "{name}: count wrap kept both GROUP BYs"); + let statements = crate::sql::split_sql_statements_for_database(&variant, DatabaseType::Mysql); + assert_eq!(statements.len(), 1, "{name}: still a single statement"); + } + } + #[test] fn easysearch_uses_elasticsearch_sql_pagination_rules() { let paginated = build_paginated_query_sql(PaginatedQuerySqlOptions {