fix(sql): gate having alias visibility by dialect reject list
This commit is contained in:
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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in new issue
Block a user