fix(sql-analysis): tolerate Oracle admin DDL in diagnostics

This commit is contained in:
zipg authored and GitHub committed 2026-09-20 11:06:40 +08:00
1 parent a67bbb4ed5
commit 88ba58a79c
9 files changed
+241 -99

No files matched your search

@@ -507,7 +507,7 @@ FROM (
autoAliasTables: true,
});
expect(items.find((item) => item.label === "order_items")?.apply).toBe("order_items AS oi2");
expect(items.find((item) => item.label === "order_items")?.apply).toBe("order_items oi2");
});
it("omits generated aliases on a DELETE target table (issue #9186)", () => {
@@ -576,7 +576,7 @@ FROM (
autoAliasTables: true,
});
expect(items.find((item) => item.label === "order_items")?.apply).toBe("order_items AS oi");
expect(items.find((item) => item.label === "order_items")?.apply).toBe("order_items oi");
});
it("keeps generated aliases after a multi-table DELETE target list", () => {
@@ -585,7 +585,7 @@ FROM (
autoAliasTables: true,
});
expect(items.find((item) => item.label === "order_items")?.apply).toBe("order_items AS oi");
expect(items.find((item) => item.label === "order_items")?.apply).toBe("order_items oi");
});
it("keeps generated aliases on FROM and JOIN sources", () => {
@@ -598,8 +598,8 @@ FROM (
autoAliasTables: true,
});
expect(joined.items.find((item) => item.label === "order_items")?.apply).toBe("order_items AS oi");
expect(queried.items.find((item) => item.label === "DH_MODEL_CAP")?.apply).toBe("DH_MODEL_CAP AS dmc");
expect(joined.items.find((item) => item.label === "order_items")?.apply).toBe("order_items oi");
expect(queried.items.find((item) => item.label === "DH_MODEL_CAP")?.apply).toBe("DH_MODEL_CAP dmc");
});
it("preserves dialect-aware identifier quoting in apply text", () => {
@@ -674,7 +674,7 @@ describe("sqlCompletion table aliases", () => {
});
const table = items.find((item) => item.label === "materials_order_item" && item.type === "table");
expect(table?.apply).toBe("materials_order_item AS moi");
expect(table?.apply).toBe("materials_order_item moi");
});
it("uses every word initial for longer multi-word names", () => {
@@ -686,7 +686,7 @@ describe("sqlCompletion table aliases", () => {
});
const table = items.find((item) => item.label === "super_long_customer_order_history_archive_snapshot_daily_replica" && item.type === "table");
expect(table?.apply).toBe("super_long_customer_order_history_archive_snapshot_daily_replica AS slcohasdr");
expect(table?.apply).toBe("super_long_customer_order_history_archive_snapshot_daily_replica slcohasdr");
});
it("applies generated aliases to table completions when enabled", () => {
@@ -698,7 +698,7 @@ describe("sqlCompletion table aliases", () => {
});
const table = items.find((item) => item.label === "order_items" && item.type === "table");
expect(table?.apply).toBe("order_items AS oi");
expect(table?.apply).toBe("order_items oi");
});
it("omits AS from Oracle table alias completions", () => {
@@ -772,7 +772,7 @@ describe("sqlCompletion table aliases", () => {
});
const table = items.find((item) => item.label === "order_items" && item.type === "table");
expect(table?.apply).toBe("order_items AS oi2");
expect(table?.apply).toBe("order_items oi2");
});
it("applies generated aliases in comma-separated FROM table lists", () => {
@@ -784,7 +784,7 @@ describe("sqlCompletion table aliases", () => {
});
const table = items.find((item) => item.label === "order_items" && item.type === "table");
expect(table?.apply).toBe("order_items AS oi");
expect(table?.apply).toBe("order_items oi");
});
it("does not apply generated aliases to non-query table completions", () => {
@@ -798,6 +798,25 @@ describe("sqlCompletion table aliases", () => {
const table = items.find((item) => item.label === "order_items" && item.type === "table");
expect(table?.apply).toBe("order_items");
});
it("emits table aliases without AS on every dialect (issue #9525)", () => {
// `FROM orders AS o` is rejected by Oracle and the Oracle-compatible profiles, while the
// implicit form is accepted everywhere, so generated SQL must use it for all dialects.
for (const databaseType of ["postgres", "mysql", "sqlserver", "oracle", "sqlite", "clickhouse"] as const) {
const sql = "SELECT * FROM ord";
const items = buildSqlCompletionItems(sql, sql.length, {
databaseType,
tables: [{ name: "order_items", type: "table" }],
columnsByTable: new Map(),
autoAliasTables: true,
});
const table = items.find((item) => item.label === "order_items" && item.type === "table");
// Oracle additionally double-quotes the identifier to preserve its stored case.
expect(table?.apply, databaseType).not.toContain(" AS ");
expect(table?.apply, databaseType).toMatch(/order_items"? oi$/);
}
});
});
describe("sqlCompletion scoped context classification", () => {
+21 -15
View File
@@ -1690,13 +1690,13 @@ class SqlCompletionProvider {
const emptyTableNameCompletion = !context.prefix && (context.suggestTables || context.exclusiveTableSuggestions);
if (!pendingJoinKeyword && !emptyTableNameCompletion && !context.tableAliasAfterCursor && context.referencedTables.length > 0 && !context.suggestColumns && !context.insertTable && supportsTableAliases(this.databaseType)) {
this.items.push(...buildAliasItems(context, this.databaseType, this.input.keywordCase));
this.items.push(...buildAliasItems(context));
}
if (!context.exclusiveColumnSuggestions && context.suggestTables) {
const autoAliasTables = !!this.input.autoAliasTables && context.autoAliasTableCompletions && !context.tableCompletionTargetAliasUnsafe && supportsTableAliases(this.databaseType);
this.items.push(...buildForeignKeyRelatedTableItems(context, completionTables, this.input.foreignKeysByTable, this.dialect, autoAliasTables, this.databaseType, this.input.keywordCase, this.input.currentSchema));
this.items.push(...buildTableItems(context, completionTables, this.dialect, autoAliasTables, context.referencedTables, this.databaseType, this.input.currentSchema, this.input.keywordCase));
this.items.push(...buildForeignKeyRelatedTableItems(context, completionTables, this.input.foreignKeysByTable, this.dialect, autoAliasTables, this.databaseType, this.input.currentSchema));
this.items.push(...buildTableItems(context, completionTables, this.dialect, autoAliasTables, context.referencedTables, this.databaseType, this.input.currentSchema));
if (this.databaseType === "clickhouse") {
this.items.push(...buildClickHouseFunctionItems(context.prefix, context.openingParenAfterCursor, "table"));
}
@@ -1725,7 +1725,7 @@ class SqlCompletionProvider {
if (context.prefix) {
for (const item of this.items) {
// Alias snippets reuse the prefix as a label while applying alias SQL, so they are not exact name matches.
const isAliasSnippet = item.type === "snippet" && item.apply === formatAliasCompletionApply(item.label, this.databaseType, this.input.keywordCase);
const isAliasSnippet = item.type === "snippet" && item.apply === formatAliasCompletionApply(item.label);
const isExactLabelMatch = !isAliasSnippet && item.label.toLowerCase() === context.prefix.toLowerCase();
const isExactFilterTextMatch = item.filterText?.toLowerCase() === context.prefix.toLowerCase();
if (isExactLabelMatch || isExactFilterTextMatch) {
@@ -3850,7 +3850,6 @@ function buildTableItems(
referencedTables: SqlCompletionReferencedTable[] = [],
databaseType?: DatabaseType,
currentSchema?: string,
keywordCase?: SqlKeywordCase,
): SqlCompletionItem[] {
const { prefix } = context;
const qualifierSchema = context.qualifier?.split(".").filter(Boolean).pop();
@@ -3873,7 +3872,7 @@ function buildTableItems(
label: table.name,
type: "table" as const,
detail,
apply: formatTableAliasApply(applyName, alias, databaseType, keywordCase),
apply: formatTableAliasApply(applyName, alias),
boost: computeBoost(table.name, prefix) + 1000 + (table.boost ?? 0),
dedupeKey: table.applyName || ambiguousTableName || (databaseType === "oracle" && table.schema) ? applyName : undefined,
};
@@ -3889,7 +3888,6 @@ function buildForeignKeyRelatedTableItems(
dialect?: SqlCompletionApplyDialect,
autoAliasTables = false,
databaseType?: DatabaseType,
keywordCase?: SqlKeywordCase,
currentSchema?: string,
): SqlCompletionItem[] {
if (!foreignKeysByTable || context.referencedTables.length === 0) return [];
@@ -3928,7 +3926,7 @@ function buildForeignKeyRelatedTableItems(
label: table.name,
type: "table" as const,
detail,
apply: formatTableAliasApply(applyName, alias, databaseType, keywordCase),
apply: formatTableAliasApply(applyName, alias),
boost: computeBoost(table.name, context.prefix) + 3600,
// Mirror buildTableItems' dedupeKey so an FK candidate and the regular
// candidate for the same schema-qualified table collapse to one entry.
@@ -4443,7 +4441,7 @@ function buildReferencedAliasItems(context: SqlCompletionContext, t?: SqlComplet
return items;
}
function buildAliasItems(context: SqlCompletionContext, databaseType?: DatabaseType, keywordCase?: SqlKeywordCase): SqlCompletionItem[] {
function buildAliasItems(context: SqlCompletionContext): SqlCompletionItem[] {
const items: SqlCompletionItem[] = [];
const existingAliases = new Set(context.referencedTables.map((ref) => ref.alias?.toLowerCase()).filter((alias): alias is string => !!alias));
const seen = new Set<string>(existingAliases);
@@ -4457,20 +4455,28 @@ function buildAliasItems(context: SqlCompletionContext, databaseType?: DatabaseT
label: candidate,
type: "snippet" as const,
detail: `alias for ${ref.name}`,
apply: formatAliasCompletionApply(candidate, databaseType, keywordCase),
apply: formatAliasCompletionApply(candidate),
boost: 1600 - items.length,
});
}
return items;
}
function formatTableAliasApply(tableName: string, alias: string, databaseType?: DatabaseType, keywordCase?: SqlKeywordCase): string {
/**
* Table aliases are always emitted in the implicit form (`orders o`).
*
* `AS` is not valid for table aliases on every engine: Oracle, and the Oracle-compatible profiles
* (OceanBase Oracle, Kingbase in Oracle mode, ...), reject `FROM orders AS o`. The implicit form is
* accepted by every dialect DBX supports, so generated SQL stays portable across
* PostgreSQL / Oracle / Kingbase (issue #9525).
*/
function formatTableAliasApply(tableName: string, alias: string): string {
if (!alias) return tableName;
return isOracleLikeDatabase(databaseType) ? `${tableName} ${alias}` : `${tableName} ${applySqlKeywordCase("AS", keywordCase)} ${alias}`;
return `${tableName} ${alias}`;
}
function formatAliasCompletionApply(alias: string, databaseType?: DatabaseType, keywordCase?: SqlKeywordCase): string {
return isOracleLikeDatabase(databaseType) ? `${alias} ` : `${applySqlKeywordCase("AS", keywordCase)} ${alias} `;
function formatAliasCompletionApply(alias: string): string {
return `${alias} `;
}
function generateAlias(tableName: string, existing = new Set<string>()): string {
@@ -5393,7 +5399,7 @@ function isOracleLikeDatabase(databaseType?: DatabaseType): boolean {
return databaseType === "oracle" || databaseType === "oceanbase-oracle";
}
// CQL has no table alias syntax, so Cassandra must never receive `table AS alias`
// CQL has no table alias syntax, so Cassandra must never receive `table alias`
// or standalone alias suggestions.
function supportsTableAliases(databaseType?: DatabaseType): boolean {
return databaseType !== "cassandra";
+59
View File
@@ -799,3 +799,62 @@ fn generic_dialect_still_rejects_spark_datasource_clauses() {
assert!(error.contains("USING"));
}
#[test]
fn oracle_admin_ddl_statements_do_not_raise_syntax_errors() {
for sql in [
"create user dbx_tmp identified by \"pw\" account unlock;",
"alter user dbx_tmp identified by \"pw\";",
"alter user dbx_tmp default tablespace dbx_ts;",
"create tablespace dbx_ts datafile '/tmp/dbx_ts.dbf' size 10m autoextend on next 1m maxsize unlimited;",
"alter tablespace dbx_ts add datafile '/tmp/dbx_ts2.dbf' size 5m;",
"drop tablespace dbx_ts including contents and datafiles;",
"create profile dbx_prof limit failed_login_attempts 5;",
"create directory dbx_dir as '/tmp/dbx_dir';",
"create or replace public synonym dbx_syn for dual;",
"alter session set nls_date_format='YYYY-MM-DD';",
"grant create session to dbx_tmp;",
"revoke create session from dbx_tmp;",
] {
let analysis = analyze_sql_references(sql, Some("oracle"))
.unwrap_or_else(|error| panic!("Oracle admin DDL should analyze: {error} ({sql})"));
assert!(analysis.tables.is_empty(), "unexpected table references for {sql}");
assert!(analysis.columns.is_empty(), "unexpected column references for {sql}");
}
}
#[test]
fn oracle_admin_ddl_masking_keeps_following_statement_spans() {
let sql =
"select 1 from users;\ncreate user dbx_tmp identified by \"pw\" account unlock;\nselect u.id from users u;";
let analysis = analyze_sql_references(sql, Some("oracle"))
.unwrap_or_else(|error| panic!("masked Oracle admin DDL should analyze: {error}"));
let tables: Vec<_> = analysis.tables.iter().map(|table| (table.name.as_str(), table.scope_id)).collect();
assert_eq!(tables, vec![("users", 0), ("users", 1)]);
// The trailing statement keeps the exact line/column it had in the unmasked script
// ("users" starts after `select u.id from ` on line 3).
assert_eq!(analysis.tables[1].span.start_line, 3);
assert_eq!(analysis.tables[1].span.start_column, 18);
let columns: Vec<_> = analysis.columns.iter().map(|column| column.name.as_str()).collect();
assert_eq!(columns, vec!["id"]);
assert_eq!(analysis.columns[0].span.start_line, 3);
}
#[test]
fn oracle_admin_ddl_tolerance_is_scoped_to_oracle_compatible_dialects() {
for dialect in [Some("generic"), Some("postgres"), Some("mysql"), None] {
let error = analyze_sql_references("create user dbx_tmp identified by \"pw\";", dialect)
.expect_err("non Oracle dialects must keep the parser error");
assert!(!error.is_empty());
}
}
#[test]
fn oracle_syntax_errors_are_still_reported() {
let error = analyze_sql_references("select from where order;", Some("oracle"))
.expect_err("a real Oracle syntax error must still be reported");
assert!(error.contains("sql parser error"), "unexpected error: {error}");
}
+7
View File
@@ -954,6 +954,13 @@ fn split_sql_statement_ranges(sql: &str) -> Vec<SqlStatementRange> {
split_sql_statement_ranges_with_options(sql, SqlParsingOptions::default())
}
/// Statement ranges that keep their byte offsets into `sql`, so callers can rewrite
/// individual statements in place (see the Oracle administrative DDL tolerance in
/// `sql_analysis`).
pub(crate) fn statement_ranges_for_database(sql: &str, db_type: DatabaseType) -> Vec<SqlStatementRange> {
split_sql_statement_ranges_with_options(sql, SqlParsingOptions::for_database_type(db_type))
}
fn split_sql_statement_ranges_with_options(sql: &str, options: SqlParsingOptions) -> Vec<SqlStatementRange> {
let mut ranges = Vec::new();
let mut start = 0;
+66 -2
View File
@@ -16,7 +16,10 @@ use sqlparser::keywords::Keyword;
use sqlparser::parser::{Parser, ParserError};
use sqlparser::tokenizer::{Span, Token, TokenWithSpan, Tokenizer};
use crate::sql::{starts_with_duckdb_result_sql_keyword, starts_with_executable_sql_keyword};
use crate::models::connection::DatabaseType;
use crate::sql::{
starts_with_duckdb_result_sql_keyword, starts_with_executable_sql_keyword, statement_ranges_for_database,
};
static CLICKHOUSE_STRICTNESS_FIRST_JOIN_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)\b(ANY|ALL|SEMI|ANTI|ASOF)\s+(LEFT|RIGHT|FULL|INNER|CROSS)(\s+OUTER)?\s+JOIN\b")
@@ -26,6 +29,17 @@ static POSTGRES_DEFAULT_PRIVILEGES_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)^\s*ALTER\s+DEFAULT\s+PRIVILEGES\b").expect("valid PostgreSQL default privileges regex")
});
/// Oracle administrative DDL that sqlparser has no grammar for: Oracle and the
/// Oracle-compatible engines (OceanBase Oracle mode, Dameng) accept these statements, but
/// the analyzer routes them through sqlparser's generic dialect, which reports a bogus
/// `sql parser error` on statements the server happily runs (issue #9104).
static ORACLE_ADMIN_DDL_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"(?is)^\s*(?:(?:create|alter|drop)\s+(?:or\s+replace\s+)?(?:public\s+)?(?:user|tablespace|profile|directory|synonym|role|session|system)\b|(?:grant|revoke)\b)",
)
.expect("valid Oracle administrative DDL regex")
});
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SqlReferenceAnalysis {
pub tables: Vec<SqlTableReference>,
@@ -123,7 +137,10 @@ pub fn analyze_sql_references(sql: &str, dialect: Option<&str>) -> Result<SqlRef
{
return Ok(SqlReferenceAnalysis { tables: vec![], columns: vec![], scopes: vec![] });
}
Err(error) => return Err(error.to_string()),
Err(error) => match oracle_admin_ddl_gap_statements(dialect, &parser_sql) {
Some(statements) => statements,
None => return Err(error.to_string()),
},
};
let mut analyzer = Analyzer {
@@ -793,6 +810,53 @@ fn starts_with_duckdb_parser_gap_sql(sql: &str) -> bool {
&& starts_with_executable_sql_keyword(sql, &["FROM", "SUMMARIZE", "SUMMARISE", "PIVOT", "UNPIVOT"])
}
/// Oracle-compatible dialects whose statements the analyzer parses with sqlparser's
/// generic dialect. Oracle connections report the PL/SQL formatter id (`oracle`) as
/// their analysis dialect, and Dameng follows the same Oracle-compatible grammar.
fn is_oracle_compatible_analysis_dialect(dialect: Option<&str>) -> bool {
let Some(name) = dialect else {
return false;
};
let name = name.trim().to_ascii_lowercase();
matches!(name.as_str(), "oracle" | "dameng")
}
/// Re-parses `sql` with every Oracle administrative DDL statement blanked out, so the
/// statements around it keep their diagnostics instead of the whole script failing on a
/// false syntax error. Returns `None` when nothing matched or when the rest of the script
/// still does not parse, in which case the caller keeps the original parser error.
fn oracle_admin_ddl_gap_statements(dialect: Option<&str>, sql: &str) -> Option<Vec<Statement>> {
if !is_oracle_compatible_analysis_dialect(dialect) {
return None;
}
let mut masked: Option<String> = None;
for range in statement_ranges_for_database(sql, DatabaseType::Oracle) {
if !ORACLE_ADMIN_DDL_RE.is_match(&range.text) {
continue;
}
let target = masked.get_or_insert_with(|| sql.to_string());
let blanked = blank_out_statement_text(target.get(range.start..range.end)?);
target.replace_range(range.start..range.end, &blanked);
}
Parser::parse_sql(&GenericDialect {}, masked.as_deref()?).ok()
}
/// Replaces every character of a statement with a space while keeping line breaks and the
/// original byte length, so token positions of the statements that follow stay exact.
fn blank_out_statement_text(statement: &str) -> String {
let mut blanked = String::with_capacity(statement.len());
for character in statement.chars() {
if character == '\n' || character == '\r' {
blanked.push(character);
} else {
for _ in 0..character.len_utf8() {
blanked.push(' ');
}
}
}
blanked
}
fn starts_with_postgres_parser_gap_sql(sql: &str) -> bool {
POSTGRES_DEFAULT_PRIVILEGES_RE.is_match(sql)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

+59 -72
View File
@@ -206,14 +206,8 @@ test("suggests database-specific data types and functions", () => {
assert.ok(mysqlCurrentTimestampItems.some((item) => item.type === "function" && item.label === "CURRENT_TIME"));
assert.ok(mysqlCurdateItems.some((item) => item.type === "function" && item.label === "CURDATE"));
assert.ok(mysqlIfnullItems.some((item) => item.type === "function" && item.label === "IFNULL"));
assert.equal(
mysqlDateAddItems.find((item) => item.type === "function" && item.label === "DATE_ADD")?.apply,
"DATE_ADD(${date}, INTERVAL ${expr} ${unit})",
);
assert.equal(
mysqlDateSubItems.find((item) => item.type === "function" && item.label === "DATE_SUB")?.apply,
"DATE_SUB(${date}, INTERVAL ${expr} ${unit})",
);
assert.equal(mysqlDateAddItems.find((item) => item.type === "function" && item.label === "DATE_ADD")?.apply, "DATE_ADD(${date}, INTERVAL ${expr} ${unit})");
assert.equal(mysqlDateSubItems.find((item) => item.type === "function" && item.label === "DATE_SUB")?.apply, "DATE_SUB(${date}, INTERVAL ${expr} ${unit})");
assert.ok(mysqlSubstringIndexItems.some((item) => item.type === "function" && item.label === "SUBSTRING_INDEX"));
assert.ok(mysqlLeftItems.some((item) => item.type === "function" && item.label === "LEFT"));
assert.ok(mysqlLeftItems.some((item) => item.type === "keyword" && item.label === "LEFT"));
@@ -233,9 +227,15 @@ test("suggests database-specific data types and functions", () => {
databaseType: "mysql",
});
assert.equal(mysqlDateTypeItems[0]?.label, "DATE");
assert.equal(mysqlDateTypeItems.some((item) => item.type === "function" && item.label === "DATE"), false);
assert.equal(
mysqlDateTypeItems.some((item) => item.type === "function" && item.label === "DATE"),
false,
);
assert.equal(mysqlTimeTypeItems[0]?.label, "TIME");
assert.equal(mysqlTimeTypeItems.some((item) => item.type === "function" && item.label === "TIME"), false);
assert.equal(
mysqlTimeTypeItems.some((item) => item.type === "function" && item.label === "TIME"),
false,
);
const mysqlCreateViewItems = buildSqlCompletionItems("CREATE VIEW v AS SELECT dat", "CREATE VIEW v AS SELECT dat".length, {
tables: [],
@@ -1397,7 +1397,10 @@ test("does not mix routines into an explicit table alias column completion", ()
});
assert.ok(items.some((item) => item.label === "name" && item.type === "column"));
assert.equal(items.some((item) => item.label === "name_formatter"), false);
assert.equal(
items.some((item) => item.label === "name_formatter"),
false,
);
});
test("suggests matching database functions alongside referenced columns", () => {
@@ -2194,15 +2197,7 @@ test("extracts JOIN tables without explicit aliases", () => {
});
test("extracts MySQL backtick-qualified tables across a JOIN", () => {
const sql = [
"select",
" `jobdb`.`job_application_ats_process`.`process_id`,",
" count(*)",
"from",
" `jobdb`.`job_application`",
"join `jobdb`.`job_application_ats_process` on",
" `jobdb`.`job_application`.`id` = `jobdb`.`job_application_ats_process`.`app_id`",
].join("\n");
const sql = ["select", " `jobdb`.`job_application_ats_process`.`process_id`,", " count(*)", "from", " `jobdb`.`job_application`", "join `jobdb`.`job_application_ats_process` on", " `jobdb`.`job_application`.`id` = `jobdb`.`job_application_ats_process`.`app_id`"].join("\n");
const context = getSqlCompletionContext(sql, sql.length);
assert.deepEqual(
@@ -2302,10 +2297,7 @@ test("preserves an exact routine match before truncating candidates", () => {
const sql = "select aaa";
const items = buildSqlCompletionItems(sql, sql.length, {
tables: [],
objects: [
...Array.from({ length: 200 }, (_, index) => ({ name: `a_a_a_${index}`, type: "procedure" as const })),
{ name: "aaa", type: "function" },
],
objects: [...Array.from({ length: 200 }, (_, index) => ({ name: `a_a_a_${index}`, type: "procedure" as const })), { name: "aaa", type: "function" }],
columnsByTable: new Map(),
});
@@ -3517,7 +3509,10 @@ test("uses row-source aliases for all columns across multiple tables", () => {
columns.some((item) => item.label === "o.id" && item.apply === "o.id"),
"should show o.id",
);
assert.ok(columns.some((item) => item.label === "u.name" && item.apply === "u.name"), "unique name should use its row-source alias");
assert.ok(
columns.some((item) => item.label === "u.name" && item.apply === "u.name"),
"unique name should use its row-source alias",
);
assert.ok(
columns.some((item) => item.label === "o.user_id" && item.apply === "o.user_id"),
"unique user_id should use its row-source alias",
@@ -3574,7 +3569,8 @@ test("suggests table alias after FROM table", () => {
});
const aliasItem = items.find((item) => item.type === "snippet" && item.detail?.includes("alias for"));
assert.ok(aliasItem, "should suggest alias for table");
assert.ok(aliasItem!.apply!.includes("AS"), "alias apply should include AS");
assert.equal(aliasItem!.apply, "us ");
assert.ok(!aliasItem!.apply!.includes("AS"), "alias apply should use the implicit form");
});
test("applies the configured keyword case to generated table aliases", () => {
@@ -3585,7 +3581,7 @@ test("applies the configured keyword case to generated table aliases", () => {
keywordCase: "lower",
});
const tableItem = tableItems.find((item) => item.type === "table" && item.label === "orders");
assert.equal(tableItem?.apply, "orders as ord");
assert.equal(tableItem?.apply, "orders ord");
const aliasItems = buildSqlCompletionItems("select * from orders ", "select * from orders ".length, {
tables,
@@ -3593,7 +3589,7 @@ test("applies the configured keyword case to generated table aliases", () => {
keywordCase: "lower",
});
const aliasItem = aliasItems.find((item) => item.type === "snippet" && item.detail === "alias for orders");
assert.equal(aliasItem?.apply, "as ord ");
assert.equal(aliasItem?.apply, "ord ");
});
test("keeps generated table aliases uppercase when keyword case is preserved", () => {
@@ -3604,7 +3600,7 @@ test("keeps generated table aliases uppercase when keyword case is preserved", (
keywordCase: "preserve",
});
const tableItem = items.find((item) => item.type === "table" && item.label === "orders");
assert.equal(tableItem?.apply, "orders AS ord");
assert.equal(tableItem?.apply, "orders ord");
});
test("prioritizes table acronym matches above alias snippets", () => {
@@ -3617,7 +3613,7 @@ test("prioritizes table acronym matches above alias snippets", () => {
assert.equal(items[0]?.label, "user_basic_info");
assert.equal(items[0]?.type, "table");
assert.ok(
items.some((item) => item.type === "snippet" && item.apply === "AS ubi "),
items.some((item) => item.type === "snippet" && item.apply === "ubi "),
"alias snippet should remain available",
);
});
@@ -3641,8 +3637,8 @@ test("table alias suggestions avoid reserved words", () => {
const aliasItem = items.find((item) => item.type === "snippet" && item.detail === "alias for orders");
assert.ok(aliasItem);
assert.notEqual(aliasItem!.apply, "AS or ");
assert.equal(aliasItem!.apply, "AS ord ");
assert.notEqual(aliasItem!.apply, "or ");
assert.equal(aliasItem!.apply, "ord ");
});
test("automatic table aliases avoid reserved words", () => {
@@ -3654,17 +3650,17 @@ test("automatic table aliases avoid reserved words", () => {
const tableItem = items.find((item) => item.type === "table" && item.label === "orders");
assert.ok(tableItem);
assert.notEqual(tableItem!.apply, "orders AS or");
assert.equal(tableItem!.apply, "orders AS ord");
assert.notEqual(tableItem!.apply, "orders or");
assert.equal(tableItem!.apply, "orders ord");
});
test("automatic table aliases respect text after the cursor", () => {
const cases: Array<[string, number, string]> = [
["select * from ord AS o", "select * from ord".length, "orders"],
["select * from ord o", "select * from ord".length, "orders"],
["select * from ord where id = 1", "select * from ord".length, "orders AS ord"],
["select * from ord", "select * from ord".length, "orders AS ord"],
["select * from ord, users", "select * from ord".length, "orders AS ord"],
["select * from ord where id = 1", "select * from ord".length, "orders ord"],
["select * from ord", "select * from ord".length, "orders ord"],
["select * from ord, users", "select * from ord".length, "orders ord"],
["select * from orders AS o", "select * from or".length, "orders"],
["select * from ord单 AS o", "select * from ord".length, "orders"],
["select * from orde\u0301 AS o", "select * from ord".length, "orders"],
@@ -3676,7 +3672,7 @@ test("automatic table aliases respect text after the cursor", () => {
["select * from ord -- comment\n o", "select * from ord".length, "orders"],
["select * from ord\n o", "select * from ord".length, "orders"],
["select * from ord /* ; */ AS o", "select * from ord".length, "orders"],
["select * from ord /* comment */ where id = 1", "select * from ord".length, "orders AS ord"],
["select * from ord /* comment */ where id = 1", "select * from ord".length, "orders ord"],
];
for (const [sql, cursor, expectedApply] of cases) {
@@ -3719,26 +3715,26 @@ test("table alias suggestions avoid SQL keywords", () => {
const aliasItem = items.find((item) => item.type === "snippet" && item.detail === "alias for item_file");
assert.ok(aliasItem);
assert.notEqual(aliasItem!.apply, "AS if ");
assert.equal(aliasItem!.apply, "AS it ");
assert.notEqual(aliasItem!.apply, "if ");
assert.equal(aliasItem!.apply, "it ");
});
test("automatic table aliases avoid SQL keywords", () => {
const cases: Array<[string, string]> = [
["account_store", "account_store AS ac"],
["account_type", "account_type AS ac"],
["data_order", "data_order AS da"],
["invoice_note", "invoice_note AS inv"],
["item_file", "item_file AS it"],
["item_status", "item_status AS it"],
["new_order", "new_order AS ne"],
["no_config", "no_config AS nc"],
["order_node", "order_node AS ord"],
["order_flow", "order_flow AS ord"],
["order_region", "order_region AS ord"],
["row_value", "row_value AS rv"],
["use_case", "use_case AS uc"],
["user_role", "user_role AS ur"],
["account_store", "account_store ac"],
["account_type", "account_type ac"],
["data_order", "data_order da"],
["invoice_note", "invoice_note inv"],
["item_file", "item_file it"],
["item_status", "item_status it"],
["new_order", "new_order ne"],
["no_config", "no_config nc"],
["order_node", "order_node ord"],
["order_flow", "order_flow ord"],
["order_region", "order_region ord"],
["row_value", "row_value rv"],
["use_case", "use_case uc"],
["user_role", "user_role ur"],
];
for (const [tableName, expectedApply] of cases) {
@@ -3776,8 +3772,8 @@ test("table alias suggestions avoid existing aliases", () => {
const aliasItem = items.find((item) => item.type === "snippet" && item.detail === "alias for customer_orders");
assert.ok(aliasItem);
assert.notEqual(aliasItem!.apply, "AS co ");
assert.equal(aliasItem!.apply, "AS cu ");
assert.notEqual(aliasItem!.apply, "co ");
assert.equal(aliasItem!.apply, "cu ");
});
// --- CASE snippet ---
@@ -3988,9 +3984,7 @@ test("boosts foreign-key related table candidates in JOIN table context", () =>
});
test("keeps automatic SQL Server aliases on foreign-key related JOIN candidates", () => {
const foreignKeysByTable = new Map<string, SqlCompletionForeignKey[]>([
["dbo.orders", [{ name: "orders_customer_id_fkey", column: "customer_id", ref_schema: "dbo", ref_table: "customers", ref_column: "id" }]],
]);
const foreignKeysByTable = new Map<string, SqlCompletionForeignKey[]>([["dbo.orders", [{ name: "orders_customer_id_fkey", column: "customer_id", ref_schema: "dbo", ref_table: "customers", ref_column: "id" }]]]);
const sql = "select * from dbo.orders o join cus";
const items = buildSqlCompletionItems(sql, sql.length, {
tables: [
@@ -4006,13 +4000,11 @@ test("keeps automatic SQL Server aliases on foreign-key related JOIN candidates"
assert.equal(items[0]?.label, "customers");
assert.ok(items[0]?.detail?.includes("related by"));
assert.equal(items[0]?.apply, "customers AS cs");
assert.equal(items[0]?.apply, "customers cs");
});
test("does not add aliases to foreign-key related JOIN candidates when disabled", () => {
const foreignKeysByTable = new Map<string, SqlCompletionForeignKey[]>([
["dbo.orders", [{ name: "orders_customer_id_fkey", column: "customer_id", ref_schema: "dbo", ref_table: "customers", ref_column: "id" }]],
]);
const foreignKeysByTable = new Map<string, SqlCompletionForeignKey[]>([["dbo.orders", [{ name: "orders_customer_id_fkey", column: "customer_id", ref_schema: "dbo", ref_table: "customers", ref_column: "id" }]]]);
const sql = "select * from dbo.orders o join cus";
const items = buildSqlCompletionItems(sql, sql.length, {
tables: [
@@ -4032,9 +4024,7 @@ test("does not add aliases to foreign-key related JOIN candidates when disabled"
});
test("schema-qualifies foreign-key related JOIN candidates when the target table name spans schemas", () => {
const foreignKeysByTable = new Map<string, SqlCompletionForeignKey[]>([
["dbo.orders", [{ name: "orders_customer_id_fkey", column: "customer_id", ref_schema: "sales", ref_table: "customers", ref_column: "id" }]],
]);
const foreignKeysByTable = new Map<string, SqlCompletionForeignKey[]>([["dbo.orders", [{ name: "orders_customer_id_fkey", column: "customer_id", ref_schema: "sales", ref_table: "customers", ref_column: "id" }]]]);
const sql = "select * from dbo.orders o join cus";
const items = buildSqlCompletionItems(sql, sql.length, {
tables: [
@@ -4053,14 +4043,11 @@ test("schema-qualifies foreign-key related JOIN candidates when the target table
assert.ok(fkCandidate, "should surface the foreign-key related candidate");
// customers exists in both dbo and sales, so the FK candidate must qualify with
// the referenced schema (sales.customers) instead of a bare, ambiguous customers.
assert.equal(fkCandidate?.apply, "sales.customers AS cs");
assert.equal(fkCandidate?.apply, "sales.customers cs");
assert.equal(fkCandidate?.dedupeKey, "sales.customers");
// The FK candidate (higher boost) should win dedupe against the regular
// sales.customers candidate, leaving no bare `customers AS cs` entry.
assert.ok(
!items.some((item) => item.type === "table" && item.apply === "customers AS cs"),
"should not emit a bare unqualified customers candidate alongside the qualified FK candidate",
);
// sales.customers candidate, leaving no bare `customers cs` entry.
assert.ok(!items.some((item) => item.type === "table" && item.apply === "customers cs"), "should not emit a bare unqualified customers candidate alongside the qualified FK candidate");
});
test("boosts inbound foreign-key table candidates in JOIN table context", () => {