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 fa8434697e..3cdc1dc729 100644 --- a/apps/desktop/src/lib/__tests__/sql/semantic/references.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/semantic/references.spec.ts @@ -86,6 +86,34 @@ describe("sqlSemanticReferences shared consumers", () => { expect(diagnostics).toEqual([]); }); + it("keeps flagging a HAVING alias for PostgreSQL, which rejects it", () => { + 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: "postgres", + }); + + expect(diagnostics.map((diagnostic) => diagnostic.message)).toEqual(["Unknown column cnt"]); + }); + 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 2055af3c27..bfa82d5802 100644 --- a/apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts @@ -1381,4 +1381,55 @@ describe("select alias visibility", () => { expect(items.some((item) => item.label === "cnt")).toBe(true); }); + + it("does not prioritize HAVING aliases for dialects that reject them", () => { + const sql = "SELECT COUNT(*) AS cnt, g FROM orders GROUP BY g HAVING cnt > "; + for (const databaseType of ["postgres", "sqlserver", "db2", "oracle"] as const) { + const context = getSqlCompletionContext(sql, sql.length, { databaseType }); + + expect(context.prioritizeSelectAliases, databaseType).toBe(false); + expect(context.selectAliases, databaseType).toEqual([]); + } + }); + + it("keeps HAVING alias priority for SQLite-family and DuckDB dialects", () => { + const sql = "SELECT COUNT(*) AS cnt, g FROM orders GROUP BY g HAVING cnt > "; + for (const databaseType of ["duckdb", "sqlite", "doris", "bigquery"] as const) { + const context = getSqlCompletionContext(sql, sql.length, { databaseType }); + + expect(context.prioritizeSelectAliases, databaseType).toBe(true); + expect(context.selectAliases, databaseType).toContain("cnt"); + } + }); + + it("does not anchor alias visibility on identifiers containing HAVING", () => { + const sql = "SELECT id AS uid FROM orders WHERE having_count > 1"; + const context = getSqlCompletionContext(sql, sql.length, { databaseType: "mysql" }); + + expect(context.prioritizeSelectAliases).toBe(false); + expect(context.selectAliases).toEqual([]); + }); + + it("keeps alias visibility in ORDER BY after an identifier containing HAVING", () => { + const sql = "SELECT id AS uid FROM orders WHERE having_count > 1 ORDER BY uid"; + const cursor = sql.length; + const context = getSqlCompletionContext(sql, cursor, { databaseType: "mysql" }); + + expect(context.prioritizeSelectAliases).toBe(true); + expect(context.selectAliases).toContain("uid"); + }); + + it("keeps HAVING alias priority for unlisted dialects (deny-list polarity)", () => { + const sql = "SELECT COUNT(*) AS cnt, g FROM orders GROUP BY g HAVING cnt > "; + for (const databaseType of ["spark", "databricks", "snowflake", "hive"] as const) { + const context = getSqlCompletionContext(sql, sql.length, { databaseType }); + + expect(context.prioritizeSelectAliases, databaseType).toBe(true); + expect(context.selectAliases, databaseType).toContain("cnt"); + } + // No database type at all keeps the permissive legacy behavior. + const untyped = getSqlCompletionContext(sql, sql.length, {}); + expect(untyped.prioritizeSelectAliases).toBe(true); + expect(untyped.selectAliases).toContain("cnt"); + }); }); diff --git a/apps/desktop/src/lib/database/databaseFeatureSupport.ts b/apps/desktop/src/lib/database/databaseFeatureSupport.ts index ac8b3e026f..f20361e56b 100644 --- a/apps/desktop/src/lib/database/databaseFeatureSupport.ts +++ b/apps/desktop/src/lib/database/databaseFeatureSupport.ts @@ -268,6 +268,50 @@ export function supportsTransaction(dbType?: string): boolean { return !!dbType && TRANSACTION_SUPPORTED_TYPES.includes(dbType); } +// Engines confirmed to reject SELECT projection aliases inside HAVING (they +// resolve only source columns there): the PostgreSQL family (PostgreSQL, +// Redshift, Kingbase, HighGo, UXDB, Vastbase, GaussDB, openGauss, KwDB), SQL +// Server, DB2, the Oracle family (Oracle, OceanBase Oracle mode, Yashandb, +// Dameng, Oscar, Xugu), Informix, Firebird, Exasol, Trino, and PrestoSQL. +// Everything else — including Spark, Databricks, Hive-family engines, and +// Snowflake, which all resolve SELECT aliases in HAVING — keeps the +// permissive behavior, mirroring DBeaver's permissive-default +// ProjectionAliasVisibilityScope with a deny list of known rejecters. +const HAVING_ALIAS_REJECTED_DATABASE_TYPES: ReadonlySet = new Set([ + "postgres", + "redshift", + "kingbase", + "highgo", + "uxdb", + "vastbase", + "gaussdb", + "opengauss", + "kwdb", + "sqlserver", + "db2", + "oracle", + "oceanbase-oracle", + "yashandb", + "dameng", + "oscar", + "xugu", + "informix", + "firebird", + "exasol", + "trino", + "prestosql", +]); + +/** + * Returns true when the engine rejects SELECT alias references from the + * HAVING clause, so alias completion hides there and the "Unknown column" + * diagnostic keeps flagging a projected alias used in HAVING. Unknown or + * unlisted database types stay permissive. + */ +export function rejectsAliasReferenceInHaving(dbType?: string): boolean { + return !!dbType && HAVING_ALIAS_REJECTED_DATABASE_TYPES.has(dbType); +} + /** * Default auto-commit mode when opening a query tab for the given database type. * Query tabs default to auto-commit; users can explicitly switch to manual transactions. diff --git a/apps/desktop/src/lib/sql/semantic/diagnostics.ts b/apps/desktop/src/lib/sql/semantic/diagnostics.ts index d73c7b4dd6..ed6c735589 100644 --- a/apps/desktop/src/lib/sql/semantic/diagnostics.ts +++ b/apps/desktop/src/lib/sql/semantic/diagnostics.ts @@ -286,7 +286,7 @@ export function buildSqlSemanticDiagnostics(analysis: SqlReferenceAnalysis, sche // would otherwise show a false "Unknown column" diagnostic. Reuse the // completion context, which already extracts aliases and knows when they // are visible, to keep diagnostics aligned with executable SQL semantics. - if (schema.sql && isVisibleProjectionAlias(schema.sql, column.span, column.name)) continue; + if (schema.sql && isVisibleProjectionAlias(schema.sql, column.span, column.name, schema.databaseType)) continue; const displayName = column.qualifier ? `${column.qualifier}.${column.name}` : column.name; diagnostics.push({ @@ -327,10 +327,10 @@ export function isSqlVirtualTableReference(table: { name: string; schema?: strin return databaseType === "mysql" && !table.schema && normalizeName(table.name) === "dual"; } -function isVisibleProjectionAlias(sql: string, span: SqlTextSpan, name: string): boolean { +function isVisibleProjectionAlias(sql: string, span: SqlTextSpan, name: string, databaseType?: DatabaseType): boolean { const range = sqlTextSpanToOffsetRange(sql, span); if (!range) return false; - const context = getSqlCompletionContext(sql, range.to); + const context = getSqlCompletionContext(sql, range.to, { databaseType }); if (!context.prioritizeSelectAliases) return false; return context.selectAliases.some((alias) => normalizeName(alias) === normalizeName(name)); } diff --git a/apps/desktop/src/lib/sql/sqlCompletion.ts b/apps/desktop/src/lib/sql/sqlCompletion.ts index 2d514234f3..100f254d19 100644 --- a/apps/desktop/src/lib/sql/sqlCompletion.ts +++ b/apps/desktop/src/lib/sql/sqlCompletion.ts @@ -19,6 +19,7 @@ import { containsHan, orderedSubsequenceSpan, pinyinFirstLetters } from "@/lib/c import { quoteTableIdentifier } from "@/lib/table/tableSelectSql"; import { driverProfileCompletionObjects, driverProfileCompletionTableMetadata, driverProfileCompletionTables, driverProfileRoutineSignatures } from "@/lib/database/driverProfileExtensions"; import { DORIS_FUNCTION_DOCS, DORIS_FUNCTION_SIGNATURES } from "@/lib/sql/doris/functions"; +import { rejectsAliasReferenceInHaving } from "@/lib/database/databaseFeatureSupport"; export { DEFAULT_SQL_SNIPPETS, resolveSqlSnippetBodyForDatabase } from "@/lib/sql/sqlSnippetTemplates"; @@ -2303,9 +2304,9 @@ export function getSqlCompletionContext(sql: string, cursor: number, options: Sq // Check if we're in a context where columns are expected const selectListColumnContext = isInSelectListContext(beforeCursor); - const inColumnContext = selectListColumnContext || isInColumnContext(beforeCursor) || !!insertInfo; + const inColumnContext = selectListColumnContext || isInColumnContext(beforeCursor, options.databaseType) || !!insertInfo; const inJoinConditionContext = isInJoinConditionContext(beforeCursor); - const prioritizeSelectAliases = isInOrderOrGroupByContext(beforeCursor); + const prioritizeSelectAliases = isInOrderOrGroupByContext(beforeCursor, options.databaseType); const inCallRoutineContext = isCallRoutineContext(beforeCursor); const inPotentialPackageMemberContext = !!qualifier && !exclusiveTableSuggestions && !insertInfo && !updateInfo?.inSetClause && !oracleTableFunctionContext; const suggestColumns = !!qualifier || !!updateInfo?.inSetClause || !!insertInfo || (inColumnContext && referencedTables.length > 0); @@ -2531,11 +2532,11 @@ function parseTrailingIdentifierPart(input: string, endExclusive: number): { sta /** * Check if the content before cursor is in a column-expected context. */ -function isInColumnContext(beforeCursor: string): boolean { +function isInColumnContext(beforeCursor: string, databaseType?: DatabaseType): boolean { if (!beforeCursor) return false; if (isInSelectListContext(beforeCursor)) return true; - if (isInOrderOrGroupByContext(beforeCursor)) return true; + if (isInOrderOrGroupByContext(beforeCursor, databaseType)) return true; // Strip string literals const cleaned = beforeCursor.replace(/'[^']*'/g, "''").replace(/"[^"]*"/g, "''"); @@ -2645,21 +2646,42 @@ function isInJoinConditionContext(beforeCursor: string): boolean { return /\b(?:on|and)\s+[a-z0-9_$]*$/i.test(currentJoinSegment); } -function isInOrderOrGroupByContext(beforeCursor: string): boolean { +function lastStandaloneKeywordIndex(cleaned: string, keyword: string): number { + // `\bhaving\b` — a bare `lastIndexOf("having")` also anchors on identifiers + // like `having_count` (t8y2/dbx#8953 review). + const pattern = new RegExp(`\\b${keyword}\\b`, "g"); + let last = -1; + for (let match = pattern.exec(cleaned); match; match = pattern.exec(cleaned)) { + last = match.index; + } + return last; +} + +function isInOrderOrGroupByContext(beforeCursor: string, databaseType?: DatabaseType): boolean { const cleaned = beforeCursor .replace(/'[^']*'/g, "''") .replace(/"[^"]*"/g, '""') .toLowerCase(); const lastOrderBy = cleaned.lastIndexOf("order by"); const lastGroupBy = cleaned.lastIndexOf("group by"); - // 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"); + // SELECT aliases may be referenced inside HAVING for permissive engines + // (MySQL family, SQLite family, DuckDB, BigQuery, Spark, Snowflake, ...), + // so aliases stay visible there just like in ORDER BY/GROUP BY — but + // confirmed rejecters (PostgreSQL family, SQL Server, DB2, Oracle family, + // Trino, ...) hide them, and their "Unknown column" diagnostic keeps + // flagging those (t8y2/dbx#8953 review). + const lastHaving = lastStandaloneKeywordIndex(cleaned, "having"); const lastContext = Math.max(lastOrderBy, lastGroupBy, lastHaving); if (lastContext < 0) return false; const segment = cleaned.slice(lastContext); - return !/\b(?:where|limit|offset|union|intersect|except|join|from)\b/.test(segment); + if (/\b(?:where|limit|offset|union|intersect|except|join|from)\b/.test(segment)) return false; + // An unknown database type keeps the permissive legacy behavior; only + // dialects known to reject HAVING aliases lose the alias visibility + // (everything else — Spark, Snowflake, Hive family, unlisted types — + // stays permissive). + if (lastContext === lastHaving && rejectsAliasReferenceInHaving(databaseType)) return false; + return true; } function isInGroupByContext(beforeCursor: string): boolean { @@ -4365,7 +4387,7 @@ function isFollowedByJoin(beforeToken: string): boolean { } function isInTableListContext(beforeToken: string, databaseType: DatabaseType | undefined): boolean { - if (isInOrderOrGroupByContext(beforeToken)) return false; + if (isInOrderOrGroupByContext(beforeToken, databaseType)) return false; const cleaned = activeQueryBlockSql(maskSqlLiteralsAndComments(beforeToken, databaseType).trimEnd()); if (!/,\s*$/.test(cleaned)) return false;