fix(sql): gate having alias visibility by dialect reject list

This commit is contained in:
dienaso authored and GitHub committed 2026-09-14 01:44:58 +08:00
1 parent ac7fbaeb43
commit 1b6ff2ba54
5 files changed
+158 -13

No files matched your search

@@ -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);
@@ -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");
});
});
@@ -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<string> = 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.
@@ -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));
}
+32 -10
View File
@@ -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;