diff --git a/apps/desktop/src/lib/__tests__/sql/semantic/completion.spec.ts b/apps/desktop/src/lib/__tests__/sql/semantic/completion.spec.ts index d46807a54f..c267556bdb 100644 --- a/apps/desktop/src/lib/__tests__/sql/semantic/completion.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/semantic/completion.spec.ts @@ -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", () => { diff --git a/apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts index 49edce3c7d..4f46ce1ef1 100644 --- a/apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts @@ -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", () => { diff --git a/apps/desktop/src/lib/sql/sqlCompletion.ts b/apps/desktop/src/lib/sql/sqlCompletion.ts index 5298c8ad82..1d4d04f920 100644 --- a/apps/desktop/src/lib/sql/sqlCompletion.ts +++ b/apps/desktop/src/lib/sql/sqlCompletion.ts @@ -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(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 { @@ -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"; diff --git a/crates/dbx-core/tests/sql_analysis.rs b/crates/dbx-core/tests/sql_analysis.rs index b03d8f05b0..2e397b6a93 100644 --- a/crates/dbx-core/tests/sql_analysis.rs +++ b/crates/dbx-core/tests/sql_analysis.rs @@ -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}"); +} diff --git a/crates/dbx-sql/src/sql.rs b/crates/dbx-sql/src/sql.rs index 7bee198aa0..60c58feea7 100644 --- a/crates/dbx-sql/src/sql.rs +++ b/crates/dbx-sql/src/sql.rs @@ -954,6 +954,13 @@ fn split_sql_statement_ranges(sql: &str) -> Vec { 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 { + 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 { let mut ranges = Vec::new(); let mut start = 0; diff --git a/crates/dbx-sql/src/sql_analysis.rs b/crates/dbx-sql/src/sql_analysis.rs index 112a48f2a3..c81837468c 100644 --- a/crates/dbx-sql/src/sql_analysis.rs +++ b/crates/dbx-sql/src/sql_analysis.rs @@ -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 = 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 = 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 = 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, @@ -123,7 +137,10 @@ pub fn analyze_sql_references(sql: &str, dialect: Option<&str>) -> Result 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> { + if !is_oracle_compatible_analysis_dialect(dialect) { + return None; + } + let mut masked: Option = 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) } diff --git a/docs/screenshots/issue-9104-oracle-admin-ddl-no-syntax-error.png b/docs/screenshots/issue-9104-oracle-admin-ddl-no-syntax-error.png new file mode 100644 index 0000000000..e9625d6b9b Binary files /dev/null and b/docs/screenshots/issue-9104-oracle-admin-ddl-no-syntax-error.png differ diff --git a/docs/screenshots/issue-9525-table-alias-implicit-form.png b/docs/screenshots/issue-9525-table-alias-implicit-form.png new file mode 100644 index 0000000000..fed4c82f4c Binary files /dev/null and b/docs/screenshots/issue-9525-table-alias-implicit-form.png differ diff --git a/packages/app-tests/sqlCompletion.test.ts b/packages/app-tests/sqlCompletion.test.ts index 76f3d52c4b..3490ae594b 100644 --- a/packages/app-tests/sqlCompletion.test.ts +++ b/packages/app-tests/sqlCompletion.test.ts @@ -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([ - ["dbo.orders", [{ name: "orders_customer_id_fkey", column: "customer_id", ref_schema: "dbo", ref_table: "customers", ref_column: "id" }]], - ]); + const foreignKeysByTable = new Map([["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([ - ["dbo.orders", [{ name: "orders_customer_id_fkey", column: "customer_id", ref_schema: "dbo", ref_table: "customers", ref_column: "id" }]], - ]); + const foreignKeysByTable = new Map([["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([ - ["dbo.orders", [{ name: "orders_customer_id_fkey", column: "customer_id", ref_schema: "sales", ref_table: "customers", ref_column: "id" }]], - ]); + const foreignKeysByTable = new Map([["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", () => {