diff --git a/apps/desktop/src/lib/__tests__/sql/semantic/references.spec.ts b/apps/desktop/src/lib/__tests__/sql/semantic/references.spec.ts index f0762f6930..fa8434697e 100644 --- a/apps/desktop/src/lib/__tests__/sql/semantic/references.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/semantic/references.spec.ts @@ -58,6 +58,34 @@ describe("sqlSemanticReferences shared consumers", () => { expect(diagnostics).toEqual([]); }); + it("does not flag a SELECT alias used by MySQL HAVING", () => { + const sql = `SELECT COUNT(*) AS cnt, g FROM amounts GROUP BY g HAVING cnt > 1`; + const aliasStart = sql.indexOf("cnt", sql.indexOf("HAVING")); + const analysis: SqlReferenceAnalysis = { + tables: [ + { + name: "amounts", + span: span(sql.indexOf("amounts") + 1, sql.indexOf("amounts") + "amounts".length), + }, + ], + columns: [ + { + name: "cnt", + span: span(aliasStart + 1, aliasStart + "cnt".length), + }, + ], + }; + + const diagnostics = buildSqlSemanticDiagnostics(analysis, { + tables: [], + columnsByTable: new Map([["amounts", [{ name: "price" }, { name: "g" }]]]), + sql, + databaseType: "mysql", + }); + + expect(diagnostics).toEqual([]); + }); + it("resolves navigation targets from subquery aliases and projected columns", () => { const { sql, cursor } = sqlFixtureCursor("SELECT sq.user_| FROM (SELECT id, name AS user_name FROM users) sq"); const model = buildSqlSemanticModel(sql, cursor); diff --git a/apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts index b53252f85b..2055af3c27 100644 --- a/apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts @@ -1344,3 +1344,41 @@ describe("originForSqlCompletionProvider", () => { expect(originForSqlCompletionProvider("explicit", false)).toBe("explicit"); }); }); + +describe("select alias visibility", () => { + it("prioritizes SELECT aliases inside HAVING for MySQL", () => { + const sql = "SELECT COUNT(*) AS cnt, g FROM orders GROUP BY g HAVING cnt > "; + const context = getSqlCompletionContext(sql, sql.length, { databaseType: "mysql", dialect: "mysql" }); + + expect(context.prioritizeSelectAliases).toBe(true); + expect(context.selectAliases).toContain("cnt"); + }); + + it("prioritizes SELECT aliases inside ORDER BY even with later clauses", () => { + const sql = "SELECT SUM(price) AS total FROM orders ORDER BY total LIMIT 10"; + const cursor = sql.indexOf("total", sql.indexOf("ORDER BY")) + "total".length; + const context = getSqlCompletionContext(sql, cursor, { databaseType: "mysql", dialect: "mysql" }); + + expect(context.prioritizeSelectAliases).toBe(true); + expect(context.selectAliases).toContain("total"); + }); + + it("does not prioritize SELECT aliases inside WHERE", () => { + const sql = "SELECT id AS uid FROM orders WHERE uid > "; + const context = getSqlCompletionContext(sql, sql.length, { databaseType: "mysql", dialect: "mysql" }); + + expect(context.prioritizeSelectAliases).toBe(false); + expect(context.selectAliases).toEqual([]); + }); + + it("offers HAVING alias completion items", () => { + const sql = "SELECT COUNT(*) AS cnt, g FROM orders GROUP BY g HAVING cn"; + const items = buildSqlCompletionItems(sql, sql.length, { + dialect: "mysql", + tables: [{ name: "orders", type: "table" }], + columnsByTable: new Map([["orders", [{ name: "g" } as never]]]), + }); + + expect(items.some((item) => item.label === "cnt")).toBe(true); + }); +}); diff --git a/apps/desktop/src/lib/sql/sqlCompletion.ts b/apps/desktop/src/lib/sql/sqlCompletion.ts index 158a26e3ae..2d514234f3 100644 --- a/apps/desktop/src/lib/sql/sqlCompletion.ts +++ b/apps/desktop/src/lib/sql/sqlCompletion.ts @@ -2652,11 +2652,14 @@ function isInOrderOrGroupByContext(beforeCursor: string): boolean { .toLowerCase(); const lastOrderBy = cleaned.lastIndexOf("order by"); const lastGroupBy = cleaned.lastIndexOf("group by"); - const lastContext = Math.max(lastOrderBy, lastGroupBy); + // MySQL (and DuckDB-like engines) also resolve SELECT aliases inside HAVING, + // so aliases stay visible there just like in ORDER BY/GROUP BY. + const lastHaving = cleaned.lastIndexOf("having"); + const lastContext = Math.max(lastOrderBy, lastGroupBy, lastHaving); if (lastContext < 0) return false; const segment = cleaned.slice(lastContext); - return !/\b(?:where|having|limit|offset|union|intersect|except|join|from)\b/.test(segment); + return !/\b(?:where|limit|offset|union|intersect|except|join|from)\b/.test(segment); } function isInGroupByContext(beforeCursor: string): boolean {