mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-30 16:21:39 +00:00
1130 lines
30 KiB
TypeScript
1130 lines
30 KiB
TypeScript
const SQL_KEYWORDS = [
|
|
"SELECT",
|
|
"FROM",
|
|
"WHERE",
|
|
"JOIN",
|
|
"LEFT",
|
|
"RIGHT",
|
|
"INNER",
|
|
"OUTER",
|
|
"ON",
|
|
"GROUP BY",
|
|
"ORDER BY",
|
|
"ASC",
|
|
"DESC",
|
|
"HAVING",
|
|
"LIMIT",
|
|
"OFFSET",
|
|
"INSERT",
|
|
"INTO",
|
|
"VALUES",
|
|
"UPDATE",
|
|
"SET",
|
|
"DELETE",
|
|
"CREATE",
|
|
"TABLE",
|
|
"VIEW",
|
|
"AS",
|
|
"AND",
|
|
"OR",
|
|
"NOT",
|
|
"IN",
|
|
"IS",
|
|
"NULL",
|
|
"LIKE",
|
|
"DISTINCT",
|
|
"UNION",
|
|
"ALL",
|
|
"EXISTS",
|
|
"BETWEEN",
|
|
"CASE",
|
|
"WHEN",
|
|
"THEN",
|
|
"ELSE",
|
|
"END",
|
|
"IF",
|
|
"COUNT",
|
|
"SUM",
|
|
"AVG",
|
|
"MIN",
|
|
"MAX",
|
|
"IIF",
|
|
"CHOOSE",
|
|
"COALESCE",
|
|
"CAST",
|
|
"ALTER",
|
|
"DROP",
|
|
"ADD",
|
|
"COLUMN",
|
|
"INDEX",
|
|
"PRIMARY",
|
|
"KEY",
|
|
"FOREIGN",
|
|
"REFERENCES",
|
|
"CONSTRAINT",
|
|
"DEFAULT",
|
|
"CHECK",
|
|
"UNIQUE",
|
|
"BEGIN",
|
|
"COMMIT",
|
|
"ROLLBACK",
|
|
"TRUNCATE",
|
|
"EXPLAIN",
|
|
"ANALYZE",
|
|
"WITH",
|
|
"RECURSIVE",
|
|
"OVER",
|
|
"PARTITION BY",
|
|
"ROW_NUMBER",
|
|
"RANK",
|
|
"DENSE_RANK",
|
|
"LAG",
|
|
"LEAD",
|
|
"FIRST_VALUE",
|
|
"LAST_VALUE",
|
|
"NTILE",
|
|
"CROSS",
|
|
"APPLY",
|
|
"CROSS APPLY",
|
|
"OUTER APPLY",
|
|
"ISJSON",
|
|
"JSON_ARRAY",
|
|
"JSON_ARRAYAGG",
|
|
"JSON_ARRAY_APPEND",
|
|
"JSON_ARRAY_INSERT",
|
|
"JSON_CONTAINS",
|
|
"JSON_CONTAINS_PATH",
|
|
"JSON_DEPTH",
|
|
"JSON_EXTRACT",
|
|
"JSON_INSERT",
|
|
"JSON_KEYS",
|
|
"JSON_LENGTH",
|
|
"JSON_MERGE_PATCH",
|
|
"JSON_MERGE_PRESERVE",
|
|
"JSON_MODIFY",
|
|
"JSON_OBJECT",
|
|
"JSON_OBJECTAGG",
|
|
"JSON_OVERLAPS",
|
|
"JSON_PATH_EXISTS",
|
|
"JSON_PRETTY",
|
|
"JSON_QUERY",
|
|
"JSON_QUOTE",
|
|
"JSON_REMOVE",
|
|
"JSON_REPLACE",
|
|
"JSON_SCHEMA_VALID",
|
|
"JSON_SEARCH",
|
|
"JSON_SET",
|
|
"JSON_STORAGE_FREE",
|
|
"JSON_STORAGE_SIZE",
|
|
"JSON_TABLE",
|
|
"JSON_TYPE",
|
|
"JSON_UNQUOTE",
|
|
"JSON_VALID",
|
|
"JSON_VALUE",
|
|
"OPENJSON",
|
|
"OPENXML",
|
|
"OPENROWSET",
|
|
"FULL",
|
|
"NATURAL",
|
|
"USING",
|
|
"LATERAL",
|
|
"UNNEST",
|
|
"FILTER",
|
|
"EXCLUDE",
|
|
"REPLACE",
|
|
"QUALIFY",
|
|
"PIVOT",
|
|
"UNPIVOT",
|
|
"ASOF",
|
|
"POSITIONAL",
|
|
"ANTI",
|
|
"SEMI",
|
|
"SAMPLE",
|
|
"TABLESAMPLE",
|
|
"STRUCT",
|
|
"MAP",
|
|
"LIST",
|
|
"ARRAY",
|
|
"LAMBDA",
|
|
"LIST_TRANSFORM",
|
|
"READ_CSV",
|
|
"READ_PARQUET",
|
|
"READ_JSON",
|
|
"COPY",
|
|
"EXPORT",
|
|
"IMPORT",
|
|
"DESCRIBE",
|
|
"SHOW",
|
|
"SUMMARIZE",
|
|
"PRAGMA",
|
|
"BIGINT",
|
|
"BINARY",
|
|
"BIT",
|
|
"CHAR",
|
|
"DATE",
|
|
"DATETIME",
|
|
"DATETIME2",
|
|
"DATETIMEOFFSET",
|
|
"DECIMAL",
|
|
"FLOAT",
|
|
"IMAGE",
|
|
"INT",
|
|
"MONEY",
|
|
"NCHAR",
|
|
"NTEXT",
|
|
"NUMERIC",
|
|
"NVARCHAR",
|
|
"REAL",
|
|
"SMALLDATETIME",
|
|
"SMALLINT",
|
|
"SMALLMONEY",
|
|
"TEXT",
|
|
"TIME",
|
|
"TIMESTAMP",
|
|
"TINYINT",
|
|
"UNIQUEIDENTIFIER",
|
|
"VARBINARY",
|
|
"VARCHAR",
|
|
"XML",
|
|
// Common built-in functions
|
|
"ABS",
|
|
"CEIL",
|
|
"CEILING",
|
|
"FLOOR",
|
|
"ROUND",
|
|
"MOD",
|
|
"POWER",
|
|
"SQRT",
|
|
"SIGN",
|
|
"TRUNCATE",
|
|
"CONCAT",
|
|
"CONCAT_WS",
|
|
"LENGTH",
|
|
"CHAR_LENGTH",
|
|
"UPPER",
|
|
"LOWER",
|
|
"TRIM",
|
|
"LTRIM",
|
|
"RTRIM",
|
|
"SUBSTRING",
|
|
"SUBSTR",
|
|
"INSTR",
|
|
"LOCATE",
|
|
"LPAD",
|
|
"RPAD",
|
|
"REVERSE",
|
|
"REPEAT",
|
|
"SPACE",
|
|
"FORMAT",
|
|
"HEX",
|
|
"UNHEX",
|
|
"NOW",
|
|
"CURDATE",
|
|
"CURTIME",
|
|
"DATE_ADD",
|
|
"DATE_SUB",
|
|
"DATE_FORMAT",
|
|
"DATEDIFF",
|
|
"TIMESTAMPDIFF",
|
|
"EXTRACT",
|
|
"YEAR",
|
|
"MONTH",
|
|
"DAY",
|
|
"HOUR",
|
|
"MINUTE",
|
|
"SECOND",
|
|
"DAYOFWEEK",
|
|
"DAYOFYEAR",
|
|
"LAST_DAY",
|
|
"STR_TO_DATE",
|
|
"CONVERT",
|
|
"IFNULL",
|
|
"NULLIF",
|
|
"GREATEST",
|
|
"LEAST",
|
|
"GROUP_CONCAT",
|
|
"FIND_IN_SET",
|
|
"FIELD",
|
|
"ELT",
|
|
"REGEXP",
|
|
"REGEXP_LIKE",
|
|
"REGEXP_REPLACE",
|
|
"REGEXP_SUBSTR",
|
|
"UUID",
|
|
"MD5",
|
|
"SHA1",
|
|
"SHA2",
|
|
"CRC32",
|
|
];
|
|
|
|
const TABLE_TRIGGER_KEYWORDS = new Set(["from", "join", "update", "into", "table", "describe", "explain", "apply"]);
|
|
const EXCLUSIVE_TABLE_TRIGGER_KEYWORDS = new Set(["from", "join", "update", "into", "apply"]);
|
|
const JOIN_MODIFIERS = new Set(["left", "right", "inner", "outer", "cross", "full", "natural"]);
|
|
const MAX_TABLE_COMPLETION_ITEMS = 200;
|
|
|
|
const SQL_SNIPPETS: Array<{ label: string; prefix: string; apply: string; detail: string }> = [
|
|
{
|
|
label: "select *",
|
|
prefix: "sel",
|
|
apply: "SELECT *\nFROM ${table}\nLIMIT 100;",
|
|
detail: "SELECT template",
|
|
},
|
|
{
|
|
label: "insert into",
|
|
prefix: "ins",
|
|
apply: "INSERT INTO ${table} (${columns})\nVALUES (${values});",
|
|
detail: "INSERT template",
|
|
},
|
|
{
|
|
label: "update set",
|
|
prefix: "upd",
|
|
apply: "UPDATE ${table}\nSET ${column} = ${value}\nWHERE ${condition};",
|
|
detail: "UPDATE template",
|
|
},
|
|
{
|
|
label: "common table expression",
|
|
prefix: "cte",
|
|
apply: "WITH ${name} AS (\n SELECT ${columns}\n FROM ${table}\n)\nSELECT *\nFROM ${name};",
|
|
detail: "CTE template",
|
|
},
|
|
{
|
|
label: "join",
|
|
prefix: "join",
|
|
apply: "JOIN ${table} ON ${left_column} = ${right_column}",
|
|
detail: "JOIN template",
|
|
},
|
|
];
|
|
|
|
const SQL_FUNCTION_SIGNATURES = new Map<string, string[]>([
|
|
["COUNT", ["expression"]],
|
|
["SUM", ["expression"]],
|
|
["AVG", ["expression"]],
|
|
["MIN", ["expression"]],
|
|
["MAX", ["expression"]],
|
|
["DATE_FORMAT", ["date", "format"]],
|
|
["DATEDIFF", ["date1", "date2"]],
|
|
["TIMESTAMPDIFF", ["unit", "datetime_expr1", "datetime_expr2"]],
|
|
["DATE_ADD", ["date", "interval"]],
|
|
["DATE_SUB", ["date", "interval"]],
|
|
["SUBSTRING", ["string", "start", "length"]],
|
|
["SUBSTR", ["string", "start", "length"]],
|
|
["CONCAT", ["value", "...values"]],
|
|
["COALESCE", ["value", "...values"]],
|
|
["CAST", ["expression", "type"]],
|
|
["ROUND", ["number", "decimals"]],
|
|
["IFNULL", ["expression", "fallback"]],
|
|
["NULLIF", ["expression1", "expression2"]],
|
|
]);
|
|
|
|
export interface SqlCompletionTable {
|
|
name: string;
|
|
schema?: string;
|
|
type?: "table" | "view";
|
|
}
|
|
|
|
export interface SqlCompletionColumn {
|
|
name: string;
|
|
table: string;
|
|
schema?: string;
|
|
dataType?: string;
|
|
}
|
|
|
|
export interface SqlCompletionItem {
|
|
label: string;
|
|
type: "keyword" | "table" | "column" | "snippet";
|
|
detail?: string;
|
|
apply?: string;
|
|
boost: number;
|
|
}
|
|
|
|
export interface SqlCompletionReferencedTable {
|
|
name: string;
|
|
schema?: string;
|
|
alias?: string;
|
|
}
|
|
|
|
export interface SqlCompletionContext {
|
|
prefix: string;
|
|
qualifier?: string;
|
|
suggestTables: boolean;
|
|
suggestColumns: boolean;
|
|
suggestKeywords: boolean;
|
|
suggestJoinConditions: boolean;
|
|
exclusiveTableSuggestions: boolean;
|
|
exclusiveColumnSuggestions: boolean;
|
|
prioritizeSelectAliases: boolean;
|
|
selectAliases: string[];
|
|
referencedTables: SqlCompletionReferencedTable[];
|
|
}
|
|
|
|
export interface SqlFunctionSignatureHelp {
|
|
name: string;
|
|
signature: string;
|
|
activeParameter: number;
|
|
parameters: string[];
|
|
}
|
|
|
|
export function buildSqlCompletionItems(
|
|
sql: string,
|
|
cursor: number,
|
|
input: {
|
|
tables: SqlCompletionTable[];
|
|
columnsByTable: Map<string, SqlCompletionColumn[]>;
|
|
},
|
|
): SqlCompletionItem[] {
|
|
const context = getSqlCompletionContext(sql, cursor);
|
|
return buildSqlCompletionItemsFromContext(context, input);
|
|
}
|
|
|
|
export function buildSqlCompletionItemsFromContext(
|
|
context: SqlCompletionContext,
|
|
input: {
|
|
tables: SqlCompletionTable[];
|
|
columnsByTable: Map<string, SqlCompletionColumn[]>;
|
|
},
|
|
): SqlCompletionItem[] {
|
|
const items: SqlCompletionItem[] = [];
|
|
|
|
if (!context.exclusiveTableSuggestions && !context.exclusiveColumnSuggestions) {
|
|
items.push(...buildSnippetItems(context.prefix));
|
|
items.push(...buildFunctionSnippetItems(context.prefix));
|
|
}
|
|
|
|
if (!context.exclusiveTableSuggestions && !context.exclusiveColumnSuggestions && context.prioritizeSelectAliases) {
|
|
items.push(...buildSelectAliasItems(context));
|
|
}
|
|
|
|
if (!context.exclusiveTableSuggestions && !context.exclusiveColumnSuggestions && context.suggestJoinConditions) {
|
|
items.push(...buildJoinConditionItems(context, input.columnsByTable));
|
|
}
|
|
|
|
// Always suggest keywords (regardless of qualifier)
|
|
if (context.suggestKeywords) {
|
|
items.push(...buildKeywordItems(context.prefix));
|
|
}
|
|
|
|
if (!context.exclusiveTableSuggestions && context.suggestColumns) {
|
|
items.push(...buildColumnItems(context, input.columnsByTable));
|
|
}
|
|
|
|
if (!context.exclusiveColumnSuggestions && context.suggestTables) {
|
|
items.push(...buildTableItems(context.prefix, input.tables));
|
|
}
|
|
|
|
return dedupeAndSort(items);
|
|
}
|
|
|
|
export function shouldAutoOpenSqlCompletion(sql: string, cursor: number): boolean {
|
|
const previousChar = sql[cursor - 1];
|
|
if (!previousChar) return false;
|
|
if (/\bon\s+$/i.test(sql.slice(0, cursor))) return true;
|
|
if (/[,;()[\]]/.test(previousChar)) return false;
|
|
const context = getSqlCompletionContext(sql, cursor);
|
|
if (context.exclusiveTableSuggestions || context.exclusiveColumnSuggestions || context.suggestTables) return true;
|
|
return /[\w$.]/.test(previousChar);
|
|
}
|
|
|
|
export function getSqlCompletionResultValidFor(sql: string, cursor: number): RegExp | undefined {
|
|
const context = getSqlCompletionContext(sql, cursor);
|
|
return context.suggestTables ? undefined : /^[\w$]*$/;
|
|
}
|
|
|
|
export function getSqlFunctionSignatureHelp(sql: string, cursor: number): SqlFunctionSignatureHelp | null {
|
|
const beforeCursor = sql.slice(0, cursor);
|
|
const openParenIndex = findActiveFunctionOpenParen(beforeCursor);
|
|
if (openParenIndex == null) return null;
|
|
|
|
const beforeParen = beforeCursor.slice(0, openParenIndex).trimEnd();
|
|
const name = /([A-Za-z_][\w$]*)$/.exec(beforeParen)?.[1]?.toUpperCase();
|
|
if (!name) return null;
|
|
|
|
const parameters = SQL_FUNCTION_SIGNATURES.get(name);
|
|
if (!parameters) return null;
|
|
|
|
const activeParameter = countTopLevelCommas(beforeCursor.slice(openParenIndex + 1));
|
|
return {
|
|
name,
|
|
signature: `${name}(${parameters.join(", ")})`,
|
|
activeParameter: Math.min(activeParameter, Math.max(0, parameters.length - 1)),
|
|
parameters,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Find the start position of the SQL statement containing the cursor.
|
|
* Respects semicolons and string literals.
|
|
*/
|
|
function extractStatementStart(sql: string, cursor: number): number {
|
|
let start = 0;
|
|
let inSingleQuote = false;
|
|
let inDoubleQuote = false;
|
|
for (let i = 0; i < sql.length; i++) {
|
|
const ch = sql[i];
|
|
if (ch === "'" && !inDoubleQuote) inSingleQuote = !inSingleQuote;
|
|
else if (ch === '"' && !inSingleQuote) inDoubleQuote = !inDoubleQuote;
|
|
else if (ch === ";" && !inSingleQuote && !inDoubleQuote) {
|
|
if (i < cursor) {
|
|
start = i + 1;
|
|
while (start < sql.length && /\s/.test(sql[start])) start++;
|
|
}
|
|
}
|
|
}
|
|
return start;
|
|
}
|
|
|
|
/**
|
|
* Extract the full SQL statement that contains the cursor position.
|
|
* Respects semicolons and string literals.
|
|
*/
|
|
function extractStatementAt(sql: string, cursor: number): string {
|
|
const start = extractStatementStart(sql, cursor);
|
|
let end = sql.length;
|
|
let inSingleQuote = false;
|
|
let inDoubleQuote = false;
|
|
for (let i = start; i < sql.length; i++) {
|
|
const ch = sql[i];
|
|
if (ch === "'" && !inDoubleQuote) inSingleQuote = !inSingleQuote;
|
|
else if (ch === '"' && !inSingleQuote) inDoubleQuote = !inDoubleQuote;
|
|
else if (ch === ";" && !inSingleQuote && !inDoubleQuote && i >= cursor) {
|
|
end = i;
|
|
break;
|
|
}
|
|
}
|
|
return sql.slice(start, end).trim();
|
|
}
|
|
|
|
export function getSqlCompletionContext(sql: string, cursor: number): SqlCompletionContext {
|
|
// Extract the full statement at cursor position for referenced tables
|
|
const fullStatement = extractStatementAt(sql, cursor);
|
|
|
|
// Content before cursor within the current statement
|
|
const stmtStart = extractStatementStart(sql, cursor);
|
|
const beforeCursor = sql.slice(stmtStart, cursor);
|
|
|
|
const dottedMatch = /([A-Za-z_][\w$]*)\.([A-Za-z_][\w$]*)?$/.exec(beforeCursor);
|
|
const plainMatch = /([A-Za-z_][\w$]*)$/.exec(beforeCursor);
|
|
const prefix = dottedMatch?.[2] ?? plainMatch?.[1] ?? "";
|
|
const qualifier = dottedMatch?.[1];
|
|
const bareStart = dottedMatch
|
|
? beforeCursor.length - dottedMatch[0].length
|
|
: beforeCursor.length - (plainMatch?.[1]?.length ?? 0);
|
|
const beforeToken = beforeCursor.slice(0, Math.max(0, bareStart)).trimEnd();
|
|
const lastWord = /([A-Za-z_][\w$]*)$/.exec(beforeToken)?.[1]?.toLowerCase() ?? "";
|
|
|
|
const referencedTables = extractReferencedTables(fullStatement);
|
|
|
|
const afterTableTrigger =
|
|
TABLE_TRIGGER_KEYWORDS.has(lastWord) ||
|
|
(JOIN_MODIFIERS.has(lastWord) && isFollowedByJoin(beforeToken)) ||
|
|
isInTableListContext(beforeToken);
|
|
const exclusiveTableSuggestions =
|
|
EXCLUSIVE_TABLE_TRIGGER_KEYWORDS.has(lastWord) ||
|
|
(JOIN_MODIFIERS.has(lastWord) && isFollowedByJoin(beforeToken)) ||
|
|
isInTableListContext(beforeToken);
|
|
const exclusiveColumnSuggestions = !!qualifier && !exclusiveTableSuggestions;
|
|
|
|
// Check if we're in a context where columns are expected
|
|
const inColumnContext = isInColumnContext(beforeCursor);
|
|
const inJoinConditionContext = isInJoinConditionContext(beforeCursor);
|
|
const prioritizeSelectAliases = isInOrderOrGroupByContext(beforeCursor);
|
|
|
|
return {
|
|
prefix,
|
|
qualifier,
|
|
// Suggest tables ONLY after FROM/JOIN/UPDATE/INTO/etc keywords
|
|
suggestTables: afterTableTrigger,
|
|
// Suggest columns when:
|
|
// 1. There's a table qualifier (table.column)
|
|
// 2. We're in a column context (WHERE, ON, SELECT, etc.) AND there are referenced tables
|
|
suggestColumns: !!qualifier || (inColumnContext && referencedTables.length > 0),
|
|
// Always suggest keywords
|
|
suggestKeywords: !exclusiveTableSuggestions && !exclusiveColumnSuggestions,
|
|
suggestJoinConditions: inJoinConditionContext && referencedTables.length >= 2,
|
|
exclusiveTableSuggestions,
|
|
exclusiveColumnSuggestions,
|
|
prioritizeSelectAliases,
|
|
selectAliases: prioritizeSelectAliases ? extractSelectAliases(fullStatement) : [],
|
|
referencedTables,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Check if the content before cursor is in a column-expected context.
|
|
*/
|
|
function isInColumnContext(beforeCursor: string): boolean {
|
|
if (!beforeCursor) return false;
|
|
|
|
// Strip string literals
|
|
const cleaned = beforeCursor.replace(/'[^']*'/g, "''").replace(/"[^"]*"/g, "''");
|
|
|
|
// Get all words/tokens
|
|
const lastWords = cleaned.trimEnd().split(/\s+/);
|
|
|
|
// Check the last 3 words for column-context keywords
|
|
for (let i = lastWords.length - 1; i >= Math.max(0, lastWords.length - 3); i--) {
|
|
const word = lastWords[i]?.toLowerCase().replace(/[^a-z0-9.]/g, "") ?? "";
|
|
// Operators that indicate column context
|
|
if (/^[=<>!+\-*/(,]$/.test(word)) return true;
|
|
// Keywords that directly precede column expressions
|
|
if (["where", "on", "having", "set", "and", "or", "not", "is", "like", "in", "between", "select"].includes(word)) {
|
|
return true;
|
|
}
|
|
// "ORDER BY" / "GROUP BY" — when we see "by", check the word before it
|
|
if (word === "by" && i > 0) {
|
|
const prevWord = lastWords[i - 1]?.toLowerCase() ?? "";
|
|
if (["order", "group"].includes(prevWord)) return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function isInJoinConditionContext(beforeCursor: string): boolean {
|
|
const cleaned = beforeCursor
|
|
.replace(/'[^']*'/g, "''")
|
|
.replace(/"[^"]*"/g, "''")
|
|
.toLowerCase();
|
|
const lastJoinIndex = cleaned.lastIndexOf(" join ");
|
|
const currentJoinSegment = lastJoinIndex >= 0 ? cleaned.slice(lastJoinIndex) : cleaned;
|
|
if (!/\bon\b/.test(currentJoinSegment)) return false;
|
|
return /\b(?:on|and)\s+[a-z0-9_$]*$/i.test(currentJoinSegment);
|
|
}
|
|
|
|
function isInOrderOrGroupByContext(beforeCursor: string): boolean {
|
|
const cleaned = beforeCursor
|
|
.replace(/'[^']*'/g, "''")
|
|
.replace(/"[^"]*"/g, '""')
|
|
.toLowerCase();
|
|
const lastOrderBy = cleaned.lastIndexOf("order by");
|
|
const lastGroupBy = cleaned.lastIndexOf("group by");
|
|
const lastContext = Math.max(lastOrderBy, lastGroupBy);
|
|
if (lastContext < 0) return false;
|
|
|
|
const segment = cleaned.slice(lastContext);
|
|
return !/\b(?:where|having|limit|offset|union|intersect|except|join|from)\b/.test(segment);
|
|
}
|
|
|
|
function extractReferencedTables(sql: string): SqlCompletionReferencedTable[] {
|
|
// Keywords that should NOT be treated as table aliases
|
|
const ALIAS_BLACKLIST = new Set([
|
|
"where",
|
|
"group",
|
|
"order",
|
|
"having",
|
|
"limit",
|
|
"offset",
|
|
"union",
|
|
"intersect",
|
|
"except",
|
|
"and",
|
|
"or",
|
|
"not",
|
|
"is",
|
|
"like",
|
|
"in",
|
|
"between",
|
|
"exists",
|
|
"select",
|
|
"from",
|
|
"join",
|
|
"left",
|
|
"right",
|
|
"inner",
|
|
"outer",
|
|
"cross",
|
|
"apply",
|
|
"full",
|
|
"natural",
|
|
"on",
|
|
"as",
|
|
"set",
|
|
"insert",
|
|
"update",
|
|
"delete",
|
|
"create",
|
|
"drop",
|
|
"alter",
|
|
"into",
|
|
"values",
|
|
"returning",
|
|
"for",
|
|
"window",
|
|
"partition",
|
|
"over",
|
|
"with",
|
|
"recursive",
|
|
"lateral",
|
|
"when",
|
|
"then",
|
|
"else",
|
|
"end",
|
|
"case",
|
|
"cast",
|
|
"coalesce",
|
|
"null",
|
|
"true",
|
|
"false",
|
|
"distinct",
|
|
"all",
|
|
"primary",
|
|
"key",
|
|
"foreign",
|
|
"references",
|
|
"constraint",
|
|
"default",
|
|
"check",
|
|
"unique",
|
|
"index",
|
|
"table",
|
|
"view",
|
|
"database",
|
|
"schema",
|
|
"describe",
|
|
"explain",
|
|
"analyze",
|
|
"pivot",
|
|
"unpivot",
|
|
"asof",
|
|
"positional",
|
|
"anti",
|
|
"semi",
|
|
"sample",
|
|
"filter",
|
|
"qualify",
|
|
"offset",
|
|
"fetch",
|
|
"next",
|
|
"rows",
|
|
"only",
|
|
"preceding",
|
|
"following",
|
|
"current",
|
|
"unbounded",
|
|
"asc",
|
|
"desc",
|
|
"nulls",
|
|
"first",
|
|
"last",
|
|
"ignore",
|
|
"respect",
|
|
]);
|
|
|
|
const pattern =
|
|
/\b(?:from|join|update|into|apply)\s+((?:"[^"]+"|`[^`]+`|[A-Za-z_][\w$]*)(?:\.(?:"[^"]+"|`[^`]+`|[A-Za-z_][\w$]*))?)(?:\s+(?:as\s+)?([A-Za-z_][\w$]*))?/gi;
|
|
const referenced: SqlCompletionReferencedTable[] = [];
|
|
for (const match of sql.matchAll(pattern)) {
|
|
const rawName = match[1];
|
|
const alias = match[2];
|
|
const [first, second] = splitQualifiedName(rawName);
|
|
if (!first) continue;
|
|
// Filter out SQL keywords that accidentally matched as aliases
|
|
const cleanAlias = alias && !ALIAS_BLACKLIST.has(alias.toLowerCase()) ? alias : undefined;
|
|
const table = second ? { schema: first, name: second, alias: cleanAlias } : { name: first, alias: cleanAlias };
|
|
referenced.push(table);
|
|
}
|
|
return referenced;
|
|
}
|
|
|
|
function extractSelectAliases(sql: string): string[] {
|
|
const selectList = extractSelectList(sql);
|
|
if (!selectList) return [];
|
|
|
|
const aliases: string[] = [];
|
|
const seen = new Set<string>();
|
|
for (const expression of splitTopLevel(selectList, ",")) {
|
|
const alias = extractSelectAlias(expression);
|
|
if (!alias) continue;
|
|
const key = alias.toLowerCase();
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
aliases.push(alias);
|
|
}
|
|
|
|
return aliases;
|
|
}
|
|
|
|
function extractSelectList(sql: string): string | null {
|
|
const lower = sql.toLowerCase();
|
|
const selectIndex = lower.search(/\bselect\b/);
|
|
if (selectIndex < 0) return null;
|
|
|
|
let depth = 0;
|
|
let inSingleQuote = false;
|
|
let inDoubleQuote = false;
|
|
for (let i = selectIndex + "select".length; i < sql.length; i++) {
|
|
const ch = sql[i];
|
|
if (ch === "'" && !inDoubleQuote) {
|
|
inSingleQuote = !inSingleQuote;
|
|
continue;
|
|
}
|
|
if (ch === '"' && !inSingleQuote) {
|
|
inDoubleQuote = !inDoubleQuote;
|
|
continue;
|
|
}
|
|
if (inSingleQuote || inDoubleQuote) continue;
|
|
if (ch === "(") depth++;
|
|
else if (ch === ")") depth = Math.max(0, depth - 1);
|
|
else if (
|
|
depth === 0 &&
|
|
lower.slice(i, i + "from".length) === "from" &&
|
|
!isIdentifierPart(sql[i - 1]) &&
|
|
!isIdentifierPart(sql[i + "from".length])
|
|
) {
|
|
return sql.slice(selectIndex + "select".length, i).trim();
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function extractSelectAlias(expression: string): string | null {
|
|
const trimmed = expression.trim();
|
|
const explicitAlias = /\bas\s+([A-Za-z_][\w$]*)$/i.exec(trimmed)?.[1];
|
|
if (explicitAlias) return explicitAlias;
|
|
|
|
const implicitAlias = /(?:^|[\s)])([A-Za-z_][\w$]*)$/.exec(trimmed)?.[1];
|
|
if (!implicitAlias) return null;
|
|
const expressionWithoutAlias = trimmed.slice(0, trimmed.length - implicitAlias.length).trimEnd();
|
|
if (!expressionWithoutAlias || /^[A-Za-z_][\w$]*(?:\.[A-Za-z_][\w$]*)?$/.test(trimmed)) return null;
|
|
return implicitAlias;
|
|
}
|
|
|
|
function isIdentifierPart(ch: string | undefined): boolean {
|
|
return !!ch && /[A-Za-z0-9_$]/.test(ch);
|
|
}
|
|
|
|
function splitTopLevel(text: string, separator: string): string[] {
|
|
const parts: string[] = [];
|
|
let start = 0;
|
|
let depth = 0;
|
|
let inSingleQuote = false;
|
|
let inDoubleQuote = false;
|
|
|
|
for (let i = 0; i < text.length; i++) {
|
|
const ch = text[i];
|
|
if (ch === "'" && !inDoubleQuote) {
|
|
inSingleQuote = !inSingleQuote;
|
|
continue;
|
|
}
|
|
if (ch === '"' && !inSingleQuote) {
|
|
inDoubleQuote = !inDoubleQuote;
|
|
continue;
|
|
}
|
|
if (inSingleQuote || inDoubleQuote) continue;
|
|
if (ch === "(") depth++;
|
|
else if (ch === ")") depth = Math.max(0, depth - 1);
|
|
else if (ch === separator && depth === 0) {
|
|
parts.push(text.slice(start, i));
|
|
start = i + 1;
|
|
}
|
|
}
|
|
|
|
parts.push(text.slice(start));
|
|
return parts;
|
|
}
|
|
|
|
function splitQualifiedName(input: string): [string | undefined, string | undefined] {
|
|
const parts = input
|
|
.split(".")
|
|
.map((part) => unquoteIdentifier(part.trim()))
|
|
.filter(Boolean);
|
|
if (parts.length >= 2) return [parts[0], parts[1]];
|
|
return [parts[0], undefined];
|
|
}
|
|
|
|
function unquoteIdentifier(value: string): string {
|
|
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("`") && value.endsWith("`"))) {
|
|
return value.slice(1, -1);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function buildTableItems(prefix: string, tables: SqlCompletionTable[]): SqlCompletionItem[] {
|
|
return tables
|
|
.filter((table) => matchesPrefix(table.name, prefix))
|
|
.slice(0, MAX_TABLE_COMPLETION_ITEMS)
|
|
.map((table) => ({
|
|
label: table.name,
|
|
type: "table" as const,
|
|
detail: table.schema ? `${table.schema}.${table.name}` : table.type,
|
|
boost: computeBoost(table.name, prefix) + 1000,
|
|
}));
|
|
}
|
|
|
|
function isFollowedByJoin(beforeToken: string): boolean {
|
|
const words = beforeToken.trimEnd().split(/\s+/);
|
|
const second = words[words.length - 2]?.toLowerCase();
|
|
return second === "join" || JOIN_MODIFIERS.has(second ?? "");
|
|
}
|
|
|
|
function isInTableListContext(beforeToken: string): boolean {
|
|
return /,\s*$/.test(beforeToken) && /\b(?:from|join|update|into)\b/i.test(beforeToken);
|
|
}
|
|
|
|
function buildColumnItems(
|
|
context: SqlCompletionContext,
|
|
columnsByTable: Map<string, SqlCompletionColumn[]>,
|
|
): SqlCompletionItem[] {
|
|
// Collect all columns from the map (all tables have been fetched)
|
|
const allColumns: Array<SqlCompletionColumn & { key: string }> = [];
|
|
for (const [key, cols] of columnsByTable.entries()) {
|
|
for (const col of cols) {
|
|
allColumns.push({ ...col, key });
|
|
}
|
|
}
|
|
|
|
// If there's a qualifier (e.g., c.card_name), filter to tables matching the qualifier
|
|
let relevantCols = allColumns;
|
|
if (context.qualifier) {
|
|
const q = context.qualifier;
|
|
const qLower = q.toLowerCase();
|
|
// Find tables whose name OR alias matches the qualifier
|
|
const relatedTables = context.referencedTables.filter(
|
|
(table) =>
|
|
table.alias === q ||
|
|
table.alias?.toLowerCase() === qLower ||
|
|
table.name === q ||
|
|
table.name.toLowerCase() === qLower,
|
|
);
|
|
// Build a set of actual table names to filter by
|
|
const tableNameSet = new Set(relatedTables.map((t) => t.name.toLowerCase()));
|
|
// Also build all possible key formats for columnsByTable matching
|
|
const tableKeys = new Set<string>();
|
|
for (const table of relatedTables) {
|
|
tableKeys.add(table.name);
|
|
if (table.schema) {
|
|
tableKeys.add(`${table.schema}.${table.name}`);
|
|
}
|
|
}
|
|
// Filter columns by matching the column's table name or the map key
|
|
relevantCols = allColumns.filter((c) => tableNameSet.has(c.table.toLowerCase()) || tableKeys.has(c.key));
|
|
}
|
|
|
|
// Deduplicate columns by name
|
|
const seen = new Set<string>();
|
|
const uniqueColumns = relevantCols.filter((c) => {
|
|
if (seen.has(c.name)) return false;
|
|
seen.add(c.name);
|
|
return true;
|
|
});
|
|
|
|
return uniqueColumns
|
|
.filter((column) => matchesPrefix(column.name, context.prefix))
|
|
.map((column) => ({
|
|
label: column.name,
|
|
type: "column" as const,
|
|
detail: column.schema ? `${column.schema}.${column.table}` : column.table,
|
|
boost: computeBoost(column.name, context.prefix),
|
|
}));
|
|
}
|
|
|
|
function buildJoinConditionItems(
|
|
context: SqlCompletionContext,
|
|
columnsByTable: Map<string, SqlCompletionColumn[]>,
|
|
): SqlCompletionItem[] {
|
|
const refs = context.referencedTables;
|
|
if (refs.length < 2) return [];
|
|
|
|
const latest = refs[refs.length - 1];
|
|
const previousRefs = refs.slice(0, -1);
|
|
const items: SqlCompletionItem[] = [];
|
|
|
|
for (const previous of previousRefs) {
|
|
const previousColumns = columnsForReferencedTable(previous, columnsByTable);
|
|
const latestColumns = columnsForReferencedTable(latest, columnsByTable);
|
|
items.push(...buildJoinConditionItemsForPair(previous, previousColumns, latest, latestColumns, context.prefix));
|
|
}
|
|
|
|
return items;
|
|
}
|
|
|
|
function columnsForReferencedTable(
|
|
table: SqlCompletionReferencedTable,
|
|
columnsByTable: Map<string, SqlCompletionColumn[]>,
|
|
): SqlCompletionColumn[] {
|
|
const keys = table.schema ? [`${table.schema}.${table.name}`, table.name] : [table.name];
|
|
for (const key of keys) {
|
|
const columns = columnsByTable.get(key);
|
|
if (columns) return columns;
|
|
}
|
|
return [];
|
|
}
|
|
|
|
function buildJoinConditionItemsForPair(
|
|
left: SqlCompletionReferencedTable,
|
|
leftColumns: SqlCompletionColumn[],
|
|
right: SqlCompletionReferencedTable,
|
|
rightColumns: SqlCompletionColumn[],
|
|
prefix: string,
|
|
): SqlCompletionItem[] {
|
|
const items: SqlCompletionItem[] = [];
|
|
const leftRef = left.alias || left.name;
|
|
const rightRef = right.alias || right.name;
|
|
const leftTableKey = singularTableName(left.name);
|
|
const rightTableKey = singularTableName(right.name);
|
|
|
|
for (const leftColumn of leftColumns) {
|
|
for (const rightColumn of rightColumns) {
|
|
const leftName = leftColumn.name.toLowerCase();
|
|
const rightName = rightColumn.name.toLowerCase();
|
|
const leftLabel = `${leftRef}.${leftColumn.name}`;
|
|
const rightLabel = `${rightRef}.${rightColumn.name}`;
|
|
let boost = 0;
|
|
|
|
if (leftName === "id" && rightName === `${leftTableKey}_id`) {
|
|
boost = 2300;
|
|
} else if (rightName === "id" && leftName === `${rightTableKey}_id`) {
|
|
boost = 2300;
|
|
} else if (leftName !== "id" && leftName === rightName) {
|
|
boost = 1700;
|
|
}
|
|
|
|
if (!boost) continue;
|
|
const label = `${leftLabel} = ${rightLabel}`;
|
|
if (prefix && !matchesPrefix(label, prefix)) continue;
|
|
items.push({
|
|
label,
|
|
type: "snippet",
|
|
detail: "JOIN condition",
|
|
apply: label,
|
|
boost,
|
|
});
|
|
}
|
|
}
|
|
|
|
return items;
|
|
}
|
|
|
|
function singularTableName(name: string): string {
|
|
const lower = name.toLowerCase();
|
|
if (lower.endsWith("ies") && lower.length > 3) return `${lower.slice(0, -3)}y`;
|
|
if (lower.endsWith("s") && lower.length > 1) return lower.slice(0, -1);
|
|
return lower;
|
|
}
|
|
|
|
function buildSnippetItems(prefix: string): SqlCompletionItem[] {
|
|
if (!prefix) return [];
|
|
return SQL_SNIPPETS.filter(
|
|
(snippet) => matchesPrefix(snippet.prefix, prefix) || matchesPrefix(snippet.label, prefix),
|
|
).map((snippet) => ({
|
|
label: snippet.label,
|
|
type: "snippet" as const,
|
|
detail: snippet.detail,
|
|
apply: snippet.apply,
|
|
boost: computeBoost(snippet.prefix, prefix) - 1100,
|
|
}));
|
|
}
|
|
|
|
function buildFunctionSnippetItems(prefix: string): SqlCompletionItem[] {
|
|
return [...SQL_FUNCTION_SIGNATURES.entries()]
|
|
.filter(([name]) => matchesPrefix(name, prefix))
|
|
.map(([name, parameters]) => ({
|
|
label: name,
|
|
type: "snippet" as const,
|
|
detail: "function",
|
|
apply: `${name}(${parameters.map((parameter) => `\${${parameter}}`).join(", ")})`,
|
|
boost: computeBoost(name, prefix) + 300,
|
|
}));
|
|
}
|
|
|
|
function buildSelectAliasItems(context: SqlCompletionContext): SqlCompletionItem[] {
|
|
return context.selectAliases
|
|
.filter((alias) => matchesPrefix(alias, context.prefix))
|
|
.map((alias, index) => ({
|
|
label: alias,
|
|
type: "column" as const,
|
|
detail: "SELECT alias",
|
|
boost: computeBoost(alias, context.prefix) + 3500 - index,
|
|
}));
|
|
}
|
|
|
|
function buildKeywordItems(prefix: string): SqlCompletionItem[] {
|
|
return SQL_KEYWORDS.filter((keyword) => !SQL_FUNCTION_SIGNATURES.has(keyword) && matchesPrefix(keyword, prefix)).map(
|
|
(keyword) => ({
|
|
label: keyword,
|
|
type: "keyword" as const,
|
|
boost: computeBoost(keyword, prefix),
|
|
}),
|
|
);
|
|
}
|
|
|
|
function matchesPrefix(candidate: string, prefix: string): boolean {
|
|
if (!prefix) return true;
|
|
return candidate.toLowerCase().includes(prefix.toLowerCase());
|
|
}
|
|
|
|
function computeBoost(candidate: string, prefix: string): number {
|
|
if (!prefix) return 1;
|
|
const candidateLower = candidate.toLowerCase();
|
|
const prefixLower = prefix.toLowerCase();
|
|
if (candidateLower === prefixLower) return 3000 - candidate.length;
|
|
if (candidateLower.startsWith(prefixLower)) return 2000 - candidate.length;
|
|
return 100 - candidate.length;
|
|
}
|
|
|
|
function dedupeAndSort(items: SqlCompletionItem[]): SqlCompletionItem[] {
|
|
const seen = new Set<string>();
|
|
return items
|
|
.sort((left, right) => right.boost - left.boost)
|
|
.filter((item) => {
|
|
const key = `${item.type}:${item.label}`;
|
|
if (seen.has(key)) return false;
|
|
seen.add(key);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
function findActiveFunctionOpenParen(sqlBeforeCursor: string): number | null {
|
|
let depth = 0;
|
|
let inSingleQuote = false;
|
|
let inDoubleQuote = false;
|
|
|
|
for (let i = sqlBeforeCursor.length - 1; i >= 0; i--) {
|
|
const ch = sqlBeforeCursor[i];
|
|
if (ch === "'" && !inDoubleQuote) {
|
|
inSingleQuote = !inSingleQuote;
|
|
continue;
|
|
}
|
|
if (ch === '"' && !inSingleQuote) {
|
|
inDoubleQuote = !inDoubleQuote;
|
|
continue;
|
|
}
|
|
if (inSingleQuote || inDoubleQuote) continue;
|
|
|
|
if (ch === ")") {
|
|
depth++;
|
|
} else if (ch === "(") {
|
|
if (depth === 0) return i;
|
|
depth--;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function countTopLevelCommas(text: string): number {
|
|
let count = 0;
|
|
let depth = 0;
|
|
let inSingleQuote = false;
|
|
let inDoubleQuote = false;
|
|
|
|
for (let i = 0; i < text.length; i++) {
|
|
const ch = text[i];
|
|
if (ch === "'" && !inDoubleQuote) {
|
|
inSingleQuote = !inSingleQuote;
|
|
continue;
|
|
}
|
|
if (ch === '"' && !inSingleQuote) {
|
|
inDoubleQuote = !inDoubleQuote;
|
|
continue;
|
|
}
|
|
if (inSingleQuote || inDoubleQuote) continue;
|
|
|
|
if (ch === "(") depth++;
|
|
else if (ch === ")") depth = Math.max(0, depth - 1);
|
|
else if (ch === "," && depth === 0) count++;
|
|
}
|
|
|
|
return count;
|
|
}
|