diff --git a/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts index 3f8b66d2c8..5fd711bc59 100644 --- a/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts @@ -1035,6 +1035,22 @@ WHERE request_json LIKE '%"paperFlag":null%';`; expect(rangeSqlTexts(executableStatementRanges(sql, "mysql"))).toEqual([sql.slice(0, -1)]); }); + it("keeps a line-start MySQL TRUNCATE function inside a SELECT projection", () => { + const sql = `SELECT + order_id, + TRUNCATE(amount, 2) AS rounded_amount +FROM orders;`; + + expect(rangeSqlTexts(executableStatementRanges(sql, "mysql"))).toEqual([sql.slice(0, -1)]); + expect(currentExecutableStatementRange(sql, indexOf(sql, "TRUNCATE"), "mysql")?.sql.trim()).toBe(sql.slice(0, -1)); + }); + + it("keeps standalone TRUNCATE TABLE statements separate", () => { + const sql = "SELECT 1;\nTRUNCATE TABLE t;"; + + expect(rangeSqlTexts(executableStatementRanges(sql, "mysql"))).toEqual(["SELECT 1", "TRUNCATE TABLE t"]); + }); + it("does not merge a plain MySQL DESC table statement with the next query", () => { const sql = "DESC users\nSELECT * FROM users;"; expect(statementRangeAtCursor(sql, indexOf(sql, "DESC"), "mysql")?.sql.trim()).toBe("DESC users"); diff --git a/apps/desktop/src/lib/sql/sqlCompletion.ts b/apps/desktop/src/lib/sql/sqlCompletion.ts index 3237a6cb18..003e222082 100644 --- a/apps/desktop/src/lib/sql/sqlCompletion.ts +++ b/apps/desktop/src/lib/sql/sqlCompletion.ts @@ -431,6 +431,11 @@ const MYSQL_SQL_KEYWORDS = [ "SHOW", "DESCRIBE", "REPLACE", + "REPEAT", + "DATABASE", + "SCHEMA", + "USER", + "CURRENT_USER", "DUPLICATE KEY", "JSON_EXTRACT", "JSON_UNQUOTE", @@ -940,6 +945,20 @@ const MYSQL_FUNCTION_SIGNATURES = new Map([ ["DAYOFYEAR", ["date"]], ["LAST_DAY", ["date"]], ["STR_TO_DATE", ["string", "format"]], + ["MONTHNAME", ["date"]], + ["DAYOFMONTH", ["date"]], + ["WEEKDAY", ["date"]], + ["WEEK", ["date", "mode"]], + ["QUARTER", ["date"]], + ["ADDDATE", ["date", "days"]], + ["SUBDATE", ["date", "days"]], + ["ADDTIME", ["datetime", "time"]], + ["SUBTIME", ["datetime", "time"]], + ["TIMEDIFF", ["datetime1", "datetime2"]], + ["FROM_DAYS", ["day_number"]], + ["TO_DAYS", ["date"]], + ["MAKEDATE", ["year", "day_of_year"]], + ["MAKETIME", ["hour", "minute", "second"]], ["IFNULL", ["expression", "fallback"]], ["IF", ["condition", "true_value", "false_value"]], ["CONCAT_WS", ["separator", "...values"]], @@ -952,20 +971,67 @@ const MYSQL_FUNCTION_SIGNATURES = new Map([ ["LPAD", ["string", "length", "pad"]], ["RPAD", ["string", "length", "pad"]], ["REVERSE", ["string"]], + ["POSITION", ["substring", "string"]], + ["REPEAT", ["string", "count"]], + ["STRCMP", ["string1", "string2"]], ["FIND_IN_SET", ["string", "string_list"]], + ["ELT", ["index", "string1", "...strings"]], + ["FIELD", ["value", "value1", "...values"]], + ["MAKE_SET", ["bits", "string1", "...strings"]], ["RAND", []], + ["POW", ["base", "exponent"]], + ["EXP", ["number"]], + ["LN", ["number"]], + ["LOG", ["base", "number"]], + ["LOG10", ["number"]], + ["LOG2", ["number"]], + ["SIN", ["number"]], + ["PI", []], + ["COS", ["number"]], + ["TAN", ["number"]], + ["ASIN", ["number"]], + ["ACOS", ["number"]], + ["ATAN", ["number"]], + ["ATAN2", ["y", "x"]], + ["DEGREES", ["radians"]], + ["RADIANS", ["degrees"]], + ["BIN", ["number"]], + ["HEX", ["value"]], + ["UNHEX", ["string"]], + ["OCT", ["number"]], + ["CONV", ["number", "from_base", "to_base"]], + ["TRUNCATE", ["number", "decimals"]], ["MD5", ["string"]], ["SHA1", ["string"]], ["SHA2", ["string", "bit_length"]], ["JSON_EXTRACT", ["json", "path"]], ["JSON_UNQUOTE", ["json"]], + ["JSON_OBJECT", ["key", "value", "...pairs"]], + ["JSON_ARRAY", ["...values"]], + ["JSON_SET", ["json", "path", "value", "...path_value_pairs"]], + ["JSON_INSERT", ["json", "path", "value", "...path_value_pairs"]], + ["JSON_REPLACE", ["json", "path", "value", "...path_value_pairs"]], + ["JSON_REMOVE", ["json", "path", "...paths"]], + ["JSON_CONTAINS", ["target", "candidate"]], + ["JSON_LENGTH", ["json"]], ["GROUP_CONCAT", ["expression"]], + ["PASSWORD", ["string"]], + ["DATABASE", []], + ["SCHEMA", []], + ["USER", []], + ["CURRENT_USER", []], + ["COLLATION", ["string"]], + ["FOUND_ROWS", []], + ["LAST_INSERT_ID", []], + ["BENCHMARK", ["count", "expression"]], + ["SLEEP", ["seconds"]], ["UUID", []], + ["UUID_SHORT", []], ["NOW", []], ]); /** Keywords that may also exist as built-in functions; keep both completion entries. */ -const DUAL_ROLE_SQL_KEYWORDS = new Set(["LEFT", "RIGHT", "IF"]); +const DUAL_ROLE_SQL_KEYWORDS = new Set(["LEFT", "RIGHT", "IF", "TRUNCATE", "REPEAT", "DATABASE", "SCHEMA", "USER", "CURRENT_USER"]); const SQLITE_FUNCTION_SIGNATURES = new Map([ ["JSON_EXTRACT", ["json", "path"]], @@ -1048,6 +1114,7 @@ const DATABASE_FUNCTION_SIGNATURES: Partial([ ["DATE_ADD", "DATE_ADD(${date}, INTERVAL ${expr} ${unit})"], ["DATE_SUB", "DATE_SUB(${date}, INTERVAL ${expr} ${unit})"], + ["POSITION", "POSITION(${substring} IN ${string})"], ]); const COMMON_SQL_FUNCTION_NAMES = new Set([ diff --git a/apps/desktop/src/lib/sql/sqlStatementRanges.ts b/apps/desktop/src/lib/sql/sqlStatementRanges.ts index e9f3748ba2..de92485b46 100644 --- a/apps/desktop/src/lib/sql/sqlStatementRanges.ts +++ b/apps/desktop/src/lib/sql/sqlStatementRanges.ts @@ -231,6 +231,8 @@ const COMMON_SOFT_STATEMENT_START_KEYWORDS = [ "COPY", ] as const; +const SOFT_STATEMENT_FUNCTION_KEYWORDS = new Set(["REPLACE", "TRUNCATE"]); + const DATABASE_SOFT_STATEMENT_KEYWORDS: Partial> = { mysql: ["HANDLER", "LOAD", "OPTIMIZE", "REPAIR"], postgres: ["DO", "LISTEN", "NOTIFY", "UNLISTEN"], @@ -957,7 +959,7 @@ function softStatementKeywordAt(sql: string, pos: number, databaseType?: Databas const match = /^[A-Za-z_][\w$]*/.exec(sql.slice(pos)); if (!match) return null; const keyword = match[0].toUpperCase(); - if (keyword === "REPLACE" && nextNonWhitespaceChar(sql, pos + match[0].length) === "(") return null; + if (SOFT_STATEMENT_FUNCTION_KEYWORDS.has(keyword) && nextNonWhitespaceChar(sql, pos + match[0].length) === "(") return null; // COMMENT is also a common column name. Only COMMENT ON starts a standalone // SQL command; otherwise a line-start projection column must stay in SELECT. if (keyword === "COMMENT" && nextSqlWord(sql, pos + match[0].length, databaseType, parameterOptions) !== "ON") return null; diff --git a/packages/app-tests/sqlCompletion.test.ts b/packages/app-tests/sqlCompletion.test.ts index 535268d977..4fccff0cf1 100644 --- a/packages/app-tests/sqlCompletion.test.ts +++ b/packages/app-tests/sqlCompletion.test.ts @@ -268,6 +268,123 @@ test("suggests MySQL VERSION and REVERSE without broadening other dialects", () assert.equal(buildFunctionItems("reve").find((item) => item.label === "REVERSE")?.apply, "REVERSE(${string})"); }); +test("suggests the reported MySQL 5.7 built-in functions with dialect-specific signatures", () => { + const reportedFunctions = [ + "VERSION", + "POSITION", + "REPEAT", + "STRCMP", + "POW", + "EXP", + "LN", + "LOG", + "LOG10", + "LOG2", + "SIN", + "PI", + "COS", + "TAN", + "ASIN", + "ACOS", + "ATAN", + "ATAN2", + "DEGREES", + "RADIANS", + "MONTHNAME", + "DAYOFMONTH", + "WEEKDAY", + "WEEK", + "QUARTER", + "ADDDATE", + "SUBDATE", + "ADDTIME", + "SUBTIME", + "TIMEDIFF", + "FROM_DAYS", + "TO_DAYS", + "MAKEDATE", + "MAKETIME", + "BIN", + "HEX", + "UNHEX", + "OCT", + "CONV", + "JSON_OBJECT", + "JSON_ARRAY", + "JSON_SET", + "JSON_INSERT", + "JSON_REPLACE", + "JSON_REMOVE", + "JSON_CONTAINS", + "JSON_LENGTH", + "PASSWORD", + "DATABASE", + "SCHEMA", + "USER", + "CURRENT_USER", + "COLLATION", + "FOUND_ROWS", + "LAST_INSERT_ID", + "BENCHMARK", + "SLEEP", + "UUID_SHORT", + "ELT", + "FIELD", + "MAKE_SET", + "TRUNCATE", + "MD5", + "SHA1", + "SHA2", + ] as const; + const mysqlItems = buildSqlCompletionItems("select ", "select ".length, { + tables: [], + columnsByTable: new Map(), + databaseType: "mysql", + }); + const mysqlFunctions = mysqlItems.filter((item) => item.type === "function"); + const mysqlFunctionLabels = new Set(mysqlFunctions.map((item) => item.label)); + + assert.deepEqual( + reportedFunctions.filter((name) => !mysqlFunctionLabels.has(name)), + [], + ); + assert.equal(mysqlFunctions.find((item) => item.label === "POSITION")?.apply, "POSITION(${substring} IN ${string})"); + assert.deepEqual(getSqlFunctionSignatureHelp("select position(", "select position(".length, "mysql")?.parameters, ["substring", "string"]); + assert.deepEqual(getSqlFunctionSignatureHelp("select log(10,", "select log(10,".length, "mysql")?.parameters, ["base", "number"]); + assert.deepEqual(getSqlFunctionSignatureHelp("select json_contains(", "select json_contains(".length, "mysql")?.parameters, ["target", "candidate"]); + assert.deepEqual(getSqlFunctionSignatureHelp("select json_length(", "select json_length(".length, "mysql")?.parameters, ["json"]); + assert.deepEqual(getSqlFunctionSignatureHelp("select benchmark(", "select benchmark(".length, "mysql")?.parameters, ["count", "expression"]); + + for (const databaseType of ["postgres", "sqlserver"] as const) { + const items = buildSqlCompletionItems("select strc", "select strc".length, { + tables: [], + columnsByTable: new Map(), + databaseType, + }); + assert.equal( + items.some((item) => item.type === "function" && item.label === "STRCMP"), + false, + ); + assert.equal(getSqlFunctionSignatureHelp("select strcmp(", "select strcmp(".length, databaseType), null); + } + + for (const name of ["TRUNCATE", "REPEAT", "DATABASE", "SCHEMA", "USER", "CURRENT_USER"]) { + const items = buildSqlCompletionItems(name.toLowerCase(), name.length, { + tables: [], + columnsByTable: new Map(), + databaseType: "mysql", + }); + assert.ok( + items.some((item) => item.type === "function" && item.label === name), + `${name} function suggestion missing`, + ); + assert.ok( + items.some((item) => item.type === "keyword" && item.label === name), + `${name} keyword suggestion missing`, + ); + } +}); + test("suggests Oracle SQL, PL/SQL, and data type keywords", () => { const keywordCases = [ ["tru", "TRUNCATE"],