diff --git a/apps/desktop/src/lib/__tests__/sql/starProjectionColumnMapping.spec.ts b/apps/desktop/src/lib/__tests__/sql/starProjectionColumnMapping.spec.ts new file mode 100644 index 0000000000..19f1721b34 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/sql/starProjectionColumnMapping.spec.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { analyzeEditableQueryEditability, resolveSourceColumnsByOrdinal } from "@/lib/sql/sqlAnalysis"; + +/** + * An unqualified `*` is unambiguous when the query has exactly one source, + * so it must bind to that source the same way a qualified `alias.*` does. + * Regression coverage for #8015: `SELECT *, col FROM t` (bare star mixed + * with an explicit column, single source, no alias) was dropping the star + * expansion entirely because the bare-star projection carried no + * `sourceKey`, misaligning every result column from that point on. + */ +describe("single-source star projection column mapping", () => { + it("expands a bare unqualified star mixed with an explicit column", () => { + const result = analyzeEditableQueryEditability("SELECT *, amount FROM orders"); + expect(result.editable).toBe(true); + if (!result.editable) return; + + const source = { key: "orders:0", tableName: "orders" }; + const resolved = resolveSourceColumnsByOrdinal("mysql", result.analysis, [{ source, columns: [{ name: "id" }, { name: "user_id" }, { name: "amount" }] }], 4); + expect(resolved).toEqual([ + { sourceKey: "orders:0", sourceColumn: "id" }, + { sourceKey: "orders:0", sourceColumn: "user_id" }, + { sourceKey: "orders:0", sourceColumn: "amount" }, + { sourceKey: "orders:0", sourceColumn: "amount" }, + ]); + }); + + it("expands a bare unqualified star mixed with an aliased source and leading explicit columns", () => { + // Matches the issue's own repro shape: qualified explicit columns before + // a trailing bare `*`, single aliased source, no primary key. + const result = analyzeEditableQueryEditability("SELECT fl.sqlicenseno, fl.sqtypetext, * FROM flow_licenseapprove AS fl"); + expect(result.editable).toBe(true); + if (!result.editable) return; + + const source = { key: "fl:0", tableName: "flow_licenseapprove" }; + const resolved = resolveSourceColumnsByOrdinal("kingbase", result.analysis, [{ source, columns: [{ name: "sqlicenseno" }, { name: "sqtypetext" }, { name: "uniformid" }, { name: "sqtypeid" }] }], 5); + expect(resolved).toEqual([ + { sourceKey: "fl:0", sourceColumn: "sqlicenseno" }, + { sourceKey: "fl:0", sourceColumn: "sqtypetext" }, + { sourceKey: "fl:0", sourceColumn: "sqlicenseno" }, + { sourceKey: "fl:0", sourceColumn: "sqtypetext" }, + { sourceKey: "fl:0", sourceColumn: "uniformid" }, + ]); + }); + + it("still expands a plain unqualified star with no other columns (baseline, unchanged)", () => { + const result = analyzeEditableQueryEditability("SELECT * FROM orders"); + expect(result.editable).toBe(true); + if (!result.editable) return; + expect(result.analysis.selectStar).toBe(true); + + const source = { key: "orders:0", tableName: "orders" }; + const resolved = resolveSourceColumnsByOrdinal("mysql", result.analysis, [{ source, columns: [{ name: "id" }, { name: "amount" }] }], 2); + expect(resolved).toEqual([ + { sourceKey: "orders:0", sourceColumn: "id" }, + { sourceKey: "orders:0", sourceColumn: "amount" }, + ]); + }); +}); diff --git a/apps/desktop/src/lib/sql/sqlAnalysis.ts b/apps/desktop/src/lib/sql/sqlAnalysis.ts index 7f29879624..c8a4259d0b 100644 --- a/apps/desktop/src/lib/sql/sqlAnalysis.ts +++ b/apps/desktop/src/lib/sql/sqlAnalysis.ts @@ -452,8 +452,17 @@ function parseSelectColumn(col: string, sources?: EditableQuerySource[]): Editab function parseStarSelectColumn(col: string, sources?: EditableQuerySource[]): EditableQueryColumn | null { if (col === "*") { + // An unqualified `*` is unambiguous when the query has exactly one + // source, so it can bind to that source like a qualified `alias.*` + // would. Without this, expandProjectionColumnsForSources cannot find a + // matching table source (no sourceKey) and drops the star expansion + // entirely, misaligning every result column that follows it — e.g. + // `SELECT *, amount FROM orders` loses all result-column comments even + // though the table has no ambiguity to resolve. + const sourceKey = sources?.length === 1 ? sources[0]!.key : undefined; return { star: true, + ...(sourceKey ? { sourceKey } : {}), resultName: "*", expression: col, }; diff --git a/crates/dbx-core/src/sql_editability.rs b/crates/dbx-core/src/sql_editability.rs index b26713956d..0e487520c8 100644 --- a/crates/dbx-core/src/sql_editability.rs +++ b/crates/dbx-core/src/sql_editability.rs @@ -310,11 +310,23 @@ fn parse_select_column(column: &str, sources: &[FromSource]) -> Option Option { let trimmed = column.trim(); if trimmed == "*" { + // An unqualified `*` is unambiguous when the query has exactly one + // source, so it can bind to that source like a qualified `alias.*` + // would. Without this, the star projection carries no source_key and + // the ordinal column-comment resolver (apps/desktop TS side) cannot + // expand it against the table's columns, misaligning every result + // column from that point on — e.g. `SELECT *, amount FROM orders` + // loses all result-column comments even though the table has no + // ambiguity to resolve. + let source_key = match sources { + [only] => Some(only.key.clone()), + _ => None, + }; return Some(EditableQueryColumn { source_name: None, source_name_quoted: false, source_qualifier: None, - source_key: None, + source_key, star: true, result_name: "*".to_string(), expression: trimmed.to_string(), @@ -1226,6 +1238,49 @@ mod tests { ); } + #[test] + fn maps_single_table_bare_star_mixed_with_explicit_column() { + // Regression for #8015: a bare, unqualified `*` mixed with another + // projected column is unambiguous when the query has exactly one + // source, so it must bind to that source (source_key) the same way + // a qualified `t.*` does. Previously it carried no source_key, + // which made the ordinal column-comment resolver drop the star + // expansion entirely and lose comments for the whole result set. + let result = analyze_editable_query_editability("SELECT *, amount FROM orders"); + + assert!(result.editable); + let analysis = result.analysis.unwrap(); + assert_eq!(analysis.table_name, "orders"); + assert_eq!( + analysis.columns, + vec![ + star_column(None, Some("orders:0"), "*"), + column(Some("amount"), false, None, None, "amount", "amount"), + ] + ); + } + + #[test] + fn maps_single_aliased_table_bare_trailing_star_after_qualified_columns() { + // Matches the issue's own repro shape: qualified explicit columns + // followed by a trailing bare `*`, single aliased source. + let result = analyze_editable_query_editability( + "SELECT fl.sqlicenseno, fl.sqtypetext, * FROM flow_licenseapprove AS fl", + ); + + assert!(result.editable); + let analysis = result.analysis.unwrap(); + assert_eq!(analysis.table_alias.as_deref(), Some("fl")); + assert_eq!( + analysis.columns, + vec![ + column(Some("sqlicenseno"), false, Some("fl"), Some("fl:0"), "sqlicenseno", "fl.sqlicenseno"), + column(Some("sqtypetext"), false, Some("fl"), Some("fl:0"), "sqtypetext", "fl.sqtypetext"), + star_column(None, Some("fl:0"), "*"), + ] + ); + } + #[test] fn recognizes_distinct_single_table_projection_as_update_only() { let result = analyze_editable_query_editability("select distinct id, name from users");