fix(sql-analysis): tolerate Oracle admin DDL in diagnostics
This commit is contained in:
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", () => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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", () => {
|
||||
|
||||
Reference in new issue
Block a user