diff --git a/packages/server/src/complete/AstUtils.ts b/packages/server/src/complete/AstUtils.ts new file mode 100644 index 00000000..fd9afc05 --- /dev/null +++ b/packages/server/src/complete/AstUtils.ts @@ -0,0 +1,182 @@ +import { + ColumnRefNode, + FromTableNode, + SelectStatement, + NodeRange, +} from '@joe-re/sql-parser' +import log4js from 'log4js' +import { Table } from '../database_libs/AbstractClient' +import { Pos } from './complete' + +const logger = log4js.getLogger() + +function isNotEmpty(value: T | null | undefined): value is T { + return value === null || value === undefined ? false : true +} + +export function getColumnRefByPos( + columns: ColumnRefNode[], + pos: Pos +): ColumnRefNode | null { + return ( + columns.find( + (v) => + // guard against ColumnRefNode that don't have a location, + // for example sql functions that are not known to the parser + v.location && + v.location.start.line === pos.line + 1 && + v.location.start.column <= pos.column && + v.location.end.line === pos.line + 1 && + v.location.end.column >= pos.column + ) ?? null + ) +} + +export function isPosInLocation(location: NodeRange, pos: Pos) { + return ( + location.start.line === pos.line + 1 && + location.start.column <= pos.column && + location.end.line === pos.line + 1 && + location.end.column >= pos.column + ) +} + +export function createTablesFromFromNodes(fromNodes: FromTableNode[]): Table[] { + return fromNodes.reduce((p, c) => { + if (c.type !== 'subquery') { + return p + } + if (!Array.isArray(c.subquery.columns)) { + return p + } + const columns = c.subquery.columns + .map((v) => { + if (typeof v === 'string') { + return null + } + return { + columnName: + v.as || (v.expr.type === 'column_ref' && v.expr.column) || '', + description: 'alias', + } + }) + .filter(isNotEmpty) + return p.concat({ + database: null, + catalog: null, + columns: columns ?? [], + tableName: c.as ?? '', + }) + }, [] as Table[]) +} + +export function findColumnAtPosition( + ast: SelectStatement, + pos: Pos +): ColumnRefNode | null { + const columns = ast.columns + if (Array.isArray(columns)) { + // columns in select clause + const columnRefs = columns + .map((col) => col.expr) + .filter((expr): expr is ColumnRefNode => expr.type === 'column_ref') + if (ast.type === 'select' && ast.where?.expression) { + if (ast.where.expression.type === 'column_ref') { + // columns in where clause + columnRefs.push(ast.where.expression) + } + } + // column at position + const columnRef = getColumnRefByPos(columnRefs, pos) + if (logger.isDebugEnabled()) logger.debug(JSON.stringify(columnRef)) + return columnRef ?? null + } else if (columns.type == 'star') { + if (ast.type === 'select' && ast.where?.expression) { + // columns in where clause + const columnRefs = + ast.where.expression.type === 'column_ref' ? [ast.where.expression] : [] + // column at position + const columnRef = getColumnRefByPos(columnRefs, pos) + if (logger.isDebugEnabled()) logger.debug(JSON.stringify(columnRef)) + return columnRef ?? null + } + } + return null +} + +/** + * Recursively pull out the FROM nodes (including sub-queries) + * @param tableNodes + * @returns + */ +export function getAllNestedFromNodes( + tableNodes: FromTableNode[] +): FromTableNode[] { + return tableNodes.flatMap((tableNode) => { + let result = [tableNode] + if (tableNode.type == 'subquery') { + const subTableNodes = tableNode.subquery.from?.tables || [] + result = result.concat(getAllNestedFromNodes(subTableNodes)) + } + return result + }) +} + +/** + * Finds the most deeply nested FROM node that have a range encompasing the position. + * In cases such as SELECT * FROM T1 JOIN (SELECT * FROM (SELECT * FROM T2 )) + * We will get a list of nodes like this + * SELECT * FROM T1 + * (SELECT * FROM + * (SELECT * FROM T2)) + * The idea is to reverse the list so that the most nested queries come first. Then + * apply a filter to keep only the FROM nodes which encompass the position and take + * the first one from that resulting list. + * @param fromNodes + * @param pos + * @returns + */ +export function getNearestFromTableFromPos( + fromNodes: FromTableNode[], + pos: Pos +): FromTableNode | null { + return ( + fromNodes + .reverse() + .filter((tableNode) => isPosInLocation(tableNode.location, pos)) + .shift() ?? null + ) +} + +/** + * Test if the given table matches the fromNode. + * @param fromNode + * @param table + * @returns + */ +export function isTableMatch(fromNode: FromTableNode, table: Table): boolean { + switch (fromNode.type) { + case 'subquery': { + if (fromNode.as && fromNode.as !== table.tableName) { + return false + } + break + } + case 'table': { + if (fromNode.table && fromNode.table !== table.tableName) { + return false + } + if (fromNode.db && fromNode.db !== table.database) { + return false + } + if (fromNode.catalog && fromNode.catalog !== table.catalog) { + return false + } + break + } + default: { + return false + } + } + return true +} diff --git a/packages/server/src/complete/CompletionItemUtils.ts b/packages/server/src/complete/CompletionItemUtils.ts new file mode 100644 index 00000000..98c4c273 --- /dev/null +++ b/packages/server/src/complete/CompletionItemUtils.ts @@ -0,0 +1,39 @@ +import { CompletionItem, CompletionItemKind } from 'vscode-languageserver-types' +import { DbFunction } from '../database_libs/AbstractClient' + +export const ICONS = { + KEYWORD: CompletionItemKind.Text, + COLUMN: CompletionItemKind.Interface, + TABLE: CompletionItemKind.Field, + FUNCTION: CompletionItemKind.Property, + ALIAS: CompletionItemKind.Variable, + UTILITY: CompletionItemKind.Event, +} + +export function toCompletionItemForFunction(f: DbFunction): CompletionItem { + const item: CompletionItem = { + label: f.name, + detail: 'function', + kind: ICONS.FUNCTION, + documentation: f.description, + } + return item +} + +export function toCompletionItemForAlias(alias: string): CompletionItem { + const item: CompletionItem = { + label: alias, + detail: 'alias', + kind: ICONS.ALIAS, + } + return item +} + +export function toCompletionItemForKeyword(name: string): CompletionItem { + const item: CompletionItem = { + label: name, + kind: ICONS.KEYWORD, + detail: 'keyword', + } + return item +} diff --git a/packages/server/src/complete/Identifier.ts b/packages/server/src/complete/Identifier.ts index 5318071a..8ba356f1 100644 --- a/packages/server/src/complete/Identifier.ts +++ b/packages/server/src/complete/Identifier.ts @@ -1,5 +1,5 @@ import { CompletionItem, CompletionItemKind } from 'vscode-languageserver-types' -import { makeTableAlias } from './utils' +import { makeTableAlias } from './StringUtils' export const ICONS = { KEYWORD: CompletionItemKind.Text, @@ -40,7 +40,7 @@ export class Identifier { toCompletionItem(): CompletionItem { const idx = this.lastToken.lastIndexOf('.') - const label = this.identifier.substr(idx + 1) + const label = this.identifier.substring(idx + 1) let kindName: string let tableAlias = '' if (this.kind === ICONS.TABLE) { diff --git a/packages/server/src/complete/StringUtils.ts b/packages/server/src/complete/StringUtils.ts new file mode 100644 index 00000000..92143eb4 --- /dev/null +++ b/packages/server/src/complete/StringUtils.ts @@ -0,0 +1,56 @@ +import { FromTableNode } from '@joe-re/sql-parser' +import { Table } from '../database_libs/AbstractClient' +import { Pos } from './complete' + +export function makeTableAlias(tableName: string): string { + if (tableName.length > 3) { + return tableName.substring(0, 3) + } + return tableName +} + +export function getRidOfAfterPosString(sql: string, pos: Pos): string { + return sql + .split('\n') + .filter((_v, idx) => pos.line >= idx) + .map((v, idx) => (idx === pos.line ? v.slice(0, pos.column) : v)) + .join('\n') +} + +// Gets the last token from the given string considering that tokens can contain dots. +export function getLastToken(sql: string): string { + const match = sql.match(/^(?:.|\s)*[^A-z0-9\\.:'](.*?)$/) + if (match) { + let prevToken = '' + let currentToken = match[1] + while (currentToken != prevToken) { + prevToken = currentToken + currentToken = prevToken.replace(/\[.*?\]/, '') + } + return currentToken + } + return sql +} + +export function makeTableName(table: Table): string { + if (table.catalog) { + return table.catalog + '.' + table.database + '.' + table.tableName + } else if (table.database) { + return table.database + '.' + table.tableName + } + return table.tableName +} + +export function getAliasFromFromTableNode(node: FromTableNode): string { + if (node.as) { + return node.as + } + if (node.type === 'table') { + return node.table + } + return '' +} + +export function makeColumnName(alias: string, columnName: string) { + return alias ? alias + '.' + columnName : columnName +} diff --git a/packages/server/src/complete/candidates/createAliasCandidates.ts b/packages/server/src/complete/candidates/createAliasCandidates.ts new file mode 100644 index 00000000..9f1d9895 --- /dev/null +++ b/packages/server/src/complete/candidates/createAliasCandidates.ts @@ -0,0 +1,13 @@ +import { CompletionItem } from 'vscode-languageserver-types' +import { FromTableNode } from '@joe-re/sql-parser' +import { toCompletionItemForAlias } from '../CompletionItemUtils' + +export function createAliasCandidates( + fromNodes: FromTableNode[], + token: string +): CompletionItem[] { + return fromNodes + .map((fromNode) => fromNode.as) + .filter((aliasName) => aliasName && aliasName.startsWith(token)) + .map((aliasName) => toCompletionItemForAlias(aliasName || '')) +} diff --git a/packages/server/src/complete/candidates/createBasicKeywordCandidates.ts b/packages/server/src/complete/candidates/createBasicKeywordCandidates.ts new file mode 100644 index 00000000..839226ca --- /dev/null +++ b/packages/server/src/complete/candidates/createBasicKeywordCandidates.ts @@ -0,0 +1,16 @@ +import { toCompletionItemForKeyword } from '../CompletionItemUtils' + +const CLAUSES: string[] = [ + 'SELECT', + 'WHERE', + 'ORDER BY', + 'GROUP BY', + 'LIMIT', + '--', + '/*', + '(', +] + +export function createBasicKeywordCandidates() { + return CLAUSES.map((v) => toCompletionItemForKeyword(v)) +} diff --git a/packages/server/src/complete/candidates/createColumnCandidates.ts b/packages/server/src/complete/candidates/createColumnCandidates.ts new file mode 100644 index 00000000..7b87e4b9 --- /dev/null +++ b/packages/server/src/complete/candidates/createColumnCandidates.ts @@ -0,0 +1,51 @@ +import { CompletionItem } from 'vscode-languageserver-types' +import { FromTableNode } from '@joe-re/sql-parser' +import { Table } from '../../database_libs/AbstractClient' +import { getAliasFromFromTableNode, makeColumnName } from '../StringUtils' +import { isTableMatch } from '../AstUtils' +import { ICONS } from '../CompletionItemUtils' +import { Identifier } from '../Identifier' + +export function createCandidatesForColumnsOfAnyTable( + tables: Table[], + lastToken: string +): CompletionItem[] { + return tables + .flatMap((table) => table.columns) + .map((column) => { + return new Identifier( + lastToken, + column.columnName, + column.description, + ICONS.TABLE + ) + }) + .filter((item) => item.matchesLastToken()) + .map((item) => item.toCompletionItem()) +} + +export function createCandidatesForScopedColumns( + fromNodes: FromTableNode[], + tables: Table[], + lastToken: string +): CompletionItem[] { + return tables + .flatMap((table) => { + return fromNodes + .filter((fromNode) => isTableMatch(fromNode, table)) + .map(getAliasFromFromTableNode) + .filter((alias) => lastToken.startsWith(alias + '.')) + .flatMap((alias) => + table.columns.map((col) => { + return new Identifier( + lastToken, + makeColumnName(alias, col.columnName), + col.description, + ICONS.COLUMN + ) + }) + ) + }) + .filter((item) => item.matchesLastToken()) + .map((item) => item.toCompletionItem()) +} diff --git a/packages/server/src/complete/candidates/createFunctionCandidates.ts b/packages/server/src/complete/candidates/createFunctionCandidates.ts new file mode 100644 index 00000000..ebb277ad --- /dev/null +++ b/packages/server/src/complete/candidates/createFunctionCandidates.ts @@ -0,0 +1,22 @@ +import { DbFunction } from '../../database_libs/AbstractClient' +import { toCompletionItemForFunction } from '../CompletionItemUtils' + +export function createFunctionCandidates( + functions: DbFunction[], + token?: string +) { + const lowerToken = token?.toLocaleLowerCase() ?? '' + const isTypedUpper = token !== lowerToken + const targets = functions.filter((v) => v.name.startsWith(lowerToken)) + return ( + targets + // Search using lowercase prefix + .filter((v) => v.name.startsWith(lowerToken)) + // If typed string is in upper case, then return upper case suggestions + .map((v) => { + if (isTypedUpper) v.name = v.name.toUpperCase() + return v + }) + .map((v) => toCompletionItemForFunction(v)) + ) +} diff --git a/packages/server/src/complete/candidates/createJoinCandidates.ts b/packages/server/src/complete/candidates/createJoinCandidates.ts new file mode 100644 index 00000000..4d0f0eaf --- /dev/null +++ b/packages/server/src/complete/candidates/createJoinCandidates.ts @@ -0,0 +1,27 @@ +import { CompletionItem } from 'vscode-languageserver-types' +import { SelectStatement } from '@joe-re/sql-parser' +import { getNearestFromTableFromPos } from '../AstUtils' +import { Table } from '../../database_libs/AbstractClient' +import { toCompletionItemForKeyword } from '../CompletionItemUtils' +import { Pos } from '../complete' +import { createTableCandidates } from './createTableCandidates' + +export function createJoinCondidates( + ast: SelectStatement, + tables: Table[], + pos: Pos, + token: string +): CompletionItem[] { + if (!Array.isArray(ast.from?.tables)) { + return [] + } + const result: CompletionItem[] = [] + const fromTable = getNearestFromTableFromPos(ast.from?.tables || [], pos) + if (fromTable && fromTable.type === 'table') { + result.push(...createTableCandidates(tables, token)) + result.push(toCompletionItemForKeyword('INNER JOIN')) + result.push(toCompletionItemForKeyword('LEFT JOIN')) + result.push(toCompletionItemForKeyword('ON')) + } + return result +} diff --git a/packages/server/src/complete/candidates/createJoinTableCndidates.ts b/packages/server/src/complete/candidates/createJoinTableCndidates.ts new file mode 100644 index 00000000..a34cf34e --- /dev/null +++ b/packages/server/src/complete/candidates/createJoinTableCndidates.ts @@ -0,0 +1,73 @@ +import { FromTableNode, ExpectedLiteralNode } from '@joe-re/sql-parser' +import { CompletionItem } from 'vscode-languageserver-types' +import { + getAliasFromFromTableNode, + makeTableAlias, + makeTableName, +} from '../StringUtils' +import { isTableMatch } from '../AstUtils' +import { Table } from '../../database_libs/AbstractClient' +import { ICONS } from '../CompletionItemUtils' + +export function createJoinTablesCandidates( + tables: Table[], + expected: ExpectedLiteralNode[], + fromNodes: FromTableNode[], + token?: string +): CompletionItem[] { + if (!(fromNodes && fromNodes.length > 0)) { + return [] + } + + let joinType = '' + if ('INNER'.startsWith(token || '')) { + joinType = 'INNER' + } else if ('LEFT'.startsWith(token || '')) { + joinType = 'LEFT' + } else if ('RIGH'.startsWith(token || '')) { + joinType = 'RIGHT' + } + + if (!joinType) { + return [] + } + + const isExpectedJoinKeyWord = !!expected + .map((v) => v.text) + .find((v) => v === 'JOIN') + + if (!isExpectedJoinKeyWord) { + return [] + } + + const fromNode = fromNodes[0] + const fromAlias = getAliasFromFromTableNode(fromNode) + const fromTable = tables.find((table) => isTableMatch(fromNode, table)) + + return tables + .filter((table) => table !== fromTable) + .reduce((c, p) => { + const newItems = p.columns + .filter((column) => + fromTable?.columns + .map((col) => col.columnName) + .includes(column.columnName) + ) + .map((column) => { + return { + tableName: makeTableName(p), + alias: makeTableAlias(p.tableName), + columnName: column.columnName, + } + }) + .map((match) => { + const label = `${joinType} JOIN ${match.tableName} AS ${match.alias} ON ${match.alias}.${match.columnName} = ${fromAlias}.${match.columnName}` + return { + label: label, + detail: 'utility', + kind: ICONS.UTILITY, + } + }) + return c.concat(newItems) + }, [] as CompletionItem[]) +} diff --git a/packages/server/src/complete/candidates/createKeywordCandidatesFromExpectedLiterals.ts b/packages/server/src/complete/candidates/createKeywordCandidatesFromExpectedLiterals.ts new file mode 100644 index 00000000..6abc4e09 --- /dev/null +++ b/packages/server/src/complete/candidates/createKeywordCandidatesFromExpectedLiterals.ts @@ -0,0 +1,48 @@ +import { ExpectedLiteralNode } from '@joe-re/sql-parser' +import { CompletionItem } from 'vscode-languageserver-types' +import { toCompletionItemForKeyword } from '../CompletionItemUtils' + +// Check if parser expects us to terminate a single quote value or double quoted column name +// SELECT TABLE1.COLUMN1 FROM TABLE1 WHERE TABLE1.COLUMN1 = "hoge. +// We don't offer the ', the ", the ` as suggestions +const UNDESIRED_LITERAL = [ + '+', + '-', + '*', + '$', + ':', + 'COUNT', + 'AVG', + 'SUM', + 'MIN', + 'MAX', + '`', + '"', + "'", +] + +export function createKeywordCandidatesFromExpectedLiterals( + nodes: ExpectedLiteralNode[] +): CompletionItem[] { + const literals = nodes.map((v) => v.text) + const uniqueLiterals = [...new Set(literals)] + return uniqueLiterals + .filter((v) => !UNDESIRED_LITERAL.includes(v)) + .map((v) => { + switch (v) { + case 'ORDER': + return 'ORDER BY' + case 'GROUP': + return 'GROUP BY' + case 'LEFT': + return 'LEFT JOIN' + case 'RIGHT': + return 'RIGHT JOIN' + case 'INNER': + return 'INNER JOIN' + default: + return v + } + }) + .map((v) => toCompletionItemForKeyword(v)) +} diff --git a/packages/server/src/complete/candidates/createSelectAllColumnsCandidates.ts b/packages/server/src/complete/candidates/createSelectAllColumnsCandidates.ts new file mode 100644 index 00000000..f30989b5 --- /dev/null +++ b/packages/server/src/complete/candidates/createSelectAllColumnsCandidates.ts @@ -0,0 +1,41 @@ +import { FromTableNode } from '@joe-re/sql-parser' +import { Table } from '../../database_libs/AbstractClient' +import { makeColumnName } from '../StringUtils' +import { getAliasFromFromTableNode } from '../StringUtils' +import { isTableMatch } from '../AstUtils' +import { ICONS } from '../CompletionItemUtils' + +export function createSelectAllColumnsCandidates( + fromNodes: FromTableNode[], + tables: Table[], + lastToken: string +) { + return tables.flatMap((table) => { + return fromNodes + .filter((fromNode) => isTableMatch(fromNode, table)) + .map(getAliasFromFromTableNode) + .filter( + () => + lastToken.toUpperCase() === 'SELECT' || // complete SELECT keyword + lastToken === '' + ) // complete at space after SELECT + .map((alias) => { + const columnNames = table.columns + .map((col) => makeColumnName(alias, col.columnName)) + .join(',\n') + const label = `Select all columns from ${alias}` + let prefix = '' + if (lastToken) { + prefix = lastToken + '\n' + } + + return { + label: label, + insertText: prefix + columnNames, + filterText: prefix + label, + detail: 'utility', + kind: ICONS.UTILITY, + } + }) + }) +} diff --git a/packages/server/src/complete/candidates/createTableCandidates.ts b/packages/server/src/complete/candidates/createTableCandidates.ts new file mode 100644 index 00000000..12e5535a --- /dev/null +++ b/packages/server/src/complete/candidates/createTableCandidates.ts @@ -0,0 +1,28 @@ +import { Table } from '../../database_libs/AbstractClient' +import { Identifier } from '../Identifier' +import { ICONS } from '../CompletionItemUtils' + +/** + * Given a table returns all possible ways to refer to it. + * That is by table name only, using the database scope, + * using the catalog and database scopes. + * @param table + * @returns + */ +function allTableNameCombinations(table: Table): string[] { + const names = [table.tableName] + if (table.database) names.push(table.database + '.' + table.tableName) + if (table.catalog) + names.push(table.catalog + '.' + table.database + '.' + table.tableName) + return names +} + +export function createTableCandidates(tables: Table[], lastToken: string) { + return tables + .flatMap((table) => allTableNameCombinations(table)) + .map((aTableNameVariant) => { + return new Identifier(lastToken, aTableNameVariant, '', ICONS.TABLE) + }) + .filter((item) => item.matchesLastToken()) + .map((item) => item.toCompletionItem()) +} diff --git a/packages/server/src/complete.ts b/packages/server/src/complete/complete.ts similarity index 57% rename from packages/server/src/complete.ts rename to packages/server/src/complete/complete.ts index 28f13bfa..e819c408 100644 --- a/packages/server/src/complete.ts +++ b/packages/server/src/complete/complete.ts @@ -12,44 +12,33 @@ import { } from '@joe-re/sql-parser' import log4js from 'log4js' import { CompletionItem } from 'vscode-languageserver-types' -import { Schema, Table } from './database_libs/AbstractClient' +import { Schema, Table } from '../database_libs/AbstractClient' +import { getRidOfAfterPosString, getLastToken } from './StringUtils' import { - makeTableAlias, - getRidOfAfterPosString, - getLastToken, - makeTableName, isPosInLocation, - toCompletionItemForKeyword, - toCompletionItemForAlias, - toCompletionItemForFunction, - ICONS, - createCandidatesForColumnsOfAnyTable, - createCandidatesForTables, - createCandidatesForExpectedLiterals, - getAliasFromFromTableNode, - isTableMatch, - makeColumnName, createTablesFromFromNodes, findColumnAtPosition, getAllNestedFromNodes, -} from './complete/utils' -import { Identifier } from './complete/Identifier' + getNearestFromTableFromPos, +} from './AstUtils' +import { createBasicKeywordCandidates } from './candidates/createBasicKeywordCandidates' +import { createTableCandidates } from './candidates/createTableCandidates' +import { createJoinCondidates } from './candidates/createJoinCandidates' +import { + createCandidatesForColumnsOfAnyTable, + createCandidatesForScopedColumns, +} from './candidates/createColumnCandidates' +import { createAliasCandidates } from './candidates/createAliasCandidates' +import { createSelectAllColumnsCandidates } from './candidates/createSelectAllColumnsCandidates' +import { createFunctionCandidates } from './candidates/createFunctionCandidates' +import { createKeywordCandidatesFromExpectedLiterals } from './candidates/createKeywordCandidatesFromExpectedLiterals' +import { createJoinTablesCandidates } from './candidates/createJoinTableCndidates' +import { ICONS, toCompletionItemForKeyword } from './CompletionItemUtils' -type Pos = { line: number; column: number } +export type Pos = { line: number; column: number } const logger = log4js.getLogger() -const CLAUSES: string[] = [ - 'SELECT', - 'WHERE', - 'ORDER BY', - 'GROUP BY', - 'LIMIT', - '--', - '/*', - '(', -] - function getFromNodesFromClause(sql: string): FromClauseParserResult | null { try { return parseFromClause(sql) @@ -110,7 +99,7 @@ class Completer { const fromNodes = getAllNestedFromNodes( parsedFromClause?.from?.tables || [] ) - const fromNodeOnCursor = this.getFromNodeByPos(fromNodes) + const fromNodeOnCursor = getNearestFromTableFromPos(fromNodes, this.pos) if ( fromNodeOnCursor && fromNodeOnCursor.type === 'incomplete_subquery' @@ -141,18 +130,26 @@ class Completer { } addCandidatesForBasicKeyword() { - CLAUSES.map((v) => toCompletionItemForKeyword(v)).forEach((v) => - this.addCandidateIfStartsWithLastToken(v) - ) + createBasicKeywordCandidates().forEach((v) => { + this.addCandidate(v) + }) } addCandidatesForExpectedLiterals(expected: ExpectedLiteralNode[]) { - createCandidatesForExpectedLiterals(expected).forEach((v) => { - this.addCandidateIfStartsWithLastToken(v) + createKeywordCandidatesFromExpectedLiterals(expected).forEach((v) => { + this.addCandidate(v) }) } addCandidate(item: CompletionItem) { + // A keyword completion can be occured anyplace and need to suppress them. + if ( + item.kind && + item.kind === ICONS.KEYWORD && + !item.label.startsWith(this.lastToken) + ) { + return + } // JupyterLab requires the dot or space character preceeding the key pressed // If the dot or space character are not added to the label then searching // in the list of suggestion does not work. @@ -172,29 +169,8 @@ class Completer { this.candidates.push(item) } - /** - * Finds the most deeply nested FROM node that have a range encompasing the position. - * In cases such as SELECT * FROM T1 JOIN (SELECT * FROM (SELECT * FROM T2 )) - * We will get a list of nodes like this - * SELECT * FROM T1 - * (SELECT * FROM - * (SELECT * FROM T2)) - * The idea is to reverse the list so that the most nested queries come first. Then - * apply a filter to keep only the FROM nodes which encompass the position and take - * the first one from that resulting list. - * @param fromNodes - * @param pos - * @returns - */ - getFromNodeByPos(fromNodes: FromTableNode[]) { - return fromNodes - .reverse() - .filter((tableNode) => isPosInLocation(tableNode.location, this.pos)) - .shift() - } - addCandidatesForTables(tables: Table[]) { - createCandidatesForTables(tables, this.lastToken).forEach((item) => { + createTableCandidates(tables, this.lastToken).forEach((item) => { this.addCandidate(item) }) } @@ -207,12 +183,6 @@ class Completer { ) } - addCandidateIfStartsWithLastToken(item: CompletionItem) { - if (item.label.startsWith(this.lastToken)) { - this.addCandidate(item) - } - } - addCandidatesForIncompleteSubquery( incompleteSubquery: IncompleteSubqueryNode ) { @@ -284,47 +254,14 @@ class Completer { expected: ExpectedLiteralNode[], fromNodes: FromTableNode[] ) { - let joinType = '' - if ('INNER'.startsWith(this.lastToken)) joinType = 'INNER' - if ('LEFT'.startsWith(this.lastToken)) joinType = 'LEFT' - if ('RIGH'.startsWith(this.lastToken)) joinType = 'RIGHT' - - if (joinType && expected.map((v) => v.text).find((v) => v === 'JOIN')) { - if (fromNodes && fromNodes.length > 0) { - const fromNode = fromNodes[0] - const fromAlias = getAliasFromFromTableNode(fromNode) - const fromTable = this.schema.tables.find((table) => - isTableMatch(fromNode, table) - ) - - this.schema.tables - .filter((table) => table != fromTable) - .forEach((table) => { - table.columns - .filter((column) => - fromTable?.columns - .map((col) => col.columnName) - .includes(column.columnName) - ) - .map((column) => { - return { - tableName: makeTableName(table), - alias: makeTableAlias(table.tableName), - columnName: column.columnName, - } - }) - .map((match) => { - const label = `${joinType} JOIN ${match.tableName} AS ${match.alias} ON ${match.alias}.${match.columnName} = ${fromAlias}.${match.columnName}` - return { - label: label, - detail: 'utility', - kind: ICONS.UTILITY, - } - }) - .forEach((item) => this.addCandidate(item)) - }) - } - } + createJoinTablesCandidates( + this.schema.tables, + expected, + fromNodes, + this.lastToken + ).forEach((v) => { + this.addCandidate(v) + }) } addCandidatesForParsedDeleteStatement(ast: DeleteStatement) { @@ -341,20 +278,14 @@ class Completer { } } - completeSelectStatement(ast: SelectStatement) { - if (Array.isArray(ast.columns)) { - this.addCandidateIfStartsWithLastToken(toCompletionItemForKeyword('FROM')) - this.addCandidateIfStartsWithLastToken(toCompletionItemForKeyword('AS')) - } - } - addCandidatesForParsedSelectQuery(ast: SelectStatement) { this.addCandidatesForBasicKeyword() - this.completeSelectStatement(ast) + if (Array.isArray(ast.columns)) { + this.addCandidate(toCompletionItemForKeyword('FROM')) + this.addCandidate(toCompletionItemForKeyword('AS')) + } if (!ast.distinct) { - this.addCandidateIfStartsWithLastToken( - toCompletionItemForKeyword('DISTINCT') - ) + this.addCandidate(toCompletionItemForKeyword('DISTINCT')) } const columnRef = findColumnAtPosition(ast, this.pos) if (!columnRef) { @@ -397,117 +328,50 @@ class Completer { } addJoinCondidates(ast: SelectStatement) { - // from clause: complete 'ON' keyword on 'INNER JOIN' - if (ast.type === 'select' && Array.isArray(ast.from?.tables)) { - const fromTable = this.getFromNodeByPos(ast.from?.tables || []) - if (fromTable && fromTable.type === 'table') { - this.addCandidatesForTables(this.schema.tables) - this.addCandidateIfStartsWithLastToken( - toCompletionItemForKeyword('INNER JOIN') - ) - this.addCandidateIfStartsWithLastToken( - toCompletionItemForKeyword('LEFT JOIN') - ) - if (fromTable.join && !fromTable.on) { - this.addCandidateIfStartsWithLastToken( - toCompletionItemForKeyword('ON') - ) - } - } - } + createJoinCondidates( + ast, + this.schema.tables, + this.pos, + this.lastToken + ).forEach((v) => { + this.addCandidate(v) + }) } addCandidatesForFunctions() { console.time('addCandidatesForFunctions') - if (!this.lastToken) { - // Nothing was typed, return all lowercase functions - this.schema.functions - .map((func) => toCompletionItemForFunction(func)) - .forEach((item) => this.addCandidate(item)) - } else { - // If user typed the start of the function - const lower = this.lastToken.toLowerCase() - const isTypedUpper = this.lastToken != lower - this.schema.functions - // Search using lowercase prefix - .filter((v) => v.name.startsWith(lower)) - // If typed string is in upper case, then return upper case suggestions - .map((v) => { - if (isTypedUpper) v.name = v.name.toUpperCase() - return v - }) - .map((v) => toCompletionItemForFunction(v)) - .forEach((item) => this.addCandidate(item)) - } + createFunctionCandidates(this.schema.functions, this.lastToken).forEach( + (v) => { + this.addCandidate(v) + } + ) console.timeEnd('addCandidatesForFunctions') } addCandidatesForSelectStar(fromNodes: FromTableNode[], tables: Table[]) { console.time('addCandidatesForSelectStar') - tables - .flatMap((table) => { - return fromNodes - .filter((fromNode) => isTableMatch(fromNode, table)) - .map(getAliasFromFromTableNode) - .filter( - () => - this.lastToken.toUpperCase() === 'SELECT' || // complete SELECT keyword - this.lastToken === '' - ) // complete at space after SELECT - .map((alias) => { - const columnNames = table.columns - .map((col) => makeColumnName(alias, col.columnName)) - .join(',\n') - const label = `Select all columns from ${alias}` - let prefix = '' - if (this.lastToken) { - prefix = this.lastToken + '\n' - } - - return { - label: label, - insertText: prefix + columnNames, - filterText: prefix + label, - detail: 'utility', - kind: ICONS.UTILITY, - } - }) - }) - .forEach((item) => this.addCandidate(item)) + createSelectAllColumnsCandidates(fromNodes, tables, this.lastToken).forEach( + (v) => { + this.addCandidate(v) + } + ) console.timeEnd('addCandidatesForSelectStar') } addCandidatesForScopedColumns(fromNodes: FromTableNode[], tables: Table[]) { console.time('addCandidatesForScopedColumns') - tables - .flatMap((table) => { - return fromNodes - .filter((fromNode) => isTableMatch(fromNode, table)) - .map(getAliasFromFromTableNode) - .filter((alias) => this.lastToken.startsWith(alias + '.')) - .flatMap((alias) => - table.columns.map((col) => { - return new Identifier( - this.lastToken, - makeColumnName(alias, col.columnName), - col.description, - ICONS.COLUMN - ) - }) - ) - }) - .filter((item) => item.matchesLastToken()) - .map((item) => item.toCompletionItem()) - .forEach((item) => this.addCandidate(item)) + createCandidatesForScopedColumns(fromNodes, tables, this.lastToken).forEach( + (v) => { + this.addCandidate(v) + } + ) console.timeEnd('addCandidatesForScopedColumns') } addCandidatesForAliases(fromNodes: FromTableNode[]) { - fromNodes - .map((fromNode) => fromNode.as) - .filter((aliasName) => aliasName && aliasName.startsWith(this.lastToken)) - .map((aliasName) => toCompletionItemForAlias(aliasName || '')) - .forEach((item) => this.addCandidate(item)) + createAliasCandidates(fromNodes, this.lastToken).forEach((v) => { + this.addCandidate(v) + }) } } diff --git a/packages/server/src/complete/index.ts b/packages/server/src/complete/index.ts index 81bc39ee..73bd392f 100644 --- a/packages/server/src/complete/index.ts +++ b/packages/server/src/complete/index.ts @@ -1,4 +1,4 @@ import { Identifier } from './Identifier' -import * as utils from './utils' +import { complete } from './complete' -export { Identifier, utils } +export { Identifier, complete } diff --git a/packages/server/src/complete/utils.ts b/packages/server/src/complete/utils.ts deleted file mode 100644 index ded7664e..00000000 --- a/packages/server/src/complete/utils.ts +++ /dev/null @@ -1,333 +0,0 @@ -import { - ColumnRefNode, - ExpectedLiteralNode, - FromTableNode, - NodeRange, - SelectStatement, -} from '@joe-re/sql-parser' -import { CompletionItem, CompletionItemKind } from 'vscode-languageserver-types' -import log4js from 'log4js' -import { Table, DbFunction } from '../database_libs/AbstractClient' -import { Identifier } from './Identifier' - -const logger = log4js.getLogger() - -type Pos = { line: number; column: number } - -export const ICONS = { - KEYWORD: CompletionItemKind.Text, - COLUMN: CompletionItemKind.Interface, - TABLE: CompletionItemKind.Field, - FUNCTION: CompletionItemKind.Property, - ALIAS: CompletionItemKind.Variable, - UTILITY: CompletionItemKind.Event, -} - -const UNDESIRED_LITERAL = [ - '+', - '-', - '*', - '$', - ':', - 'COUNT', - 'AVG', - 'SUM', - 'MIN', - 'MAX', - '`', - '"', - "'", -] - -function isNotEmpty(value: T | null | undefined): value is T { - return value === null || value === undefined ? false : true -} - -export function makeTableAlias(tableName: string): string { - if (tableName.length > 3) { - return tableName.substring(0, 3) - } - return tableName -} - -export function getRidOfAfterPosString(sql: string, pos: Pos) { - return sql - .split('\n') - .filter((_v, idx) => pos.line >= idx) - .map((v, idx) => (idx === pos.line ? v.slice(0, pos.column) : v)) - .join('\n') -} - -// Gets the last token from the given string considering that tokens can contain dots. -export function getLastToken(sql: string): string { - const match = sql.match(/^(?:.|\s)*[^A-z0-9\\.:'](.*?)$/) - if (match) { - let prevToken = '' - let currentToken = match[1] - while (currentToken != prevToken) { - prevToken = currentToken - currentToken = prevToken.replace(/\[.*?\]/, '') - } - return currentToken - } - return sql -} - -export function makeTableName(table: Table): string { - if (table.catalog) { - return table.catalog + '.' + table.database + '.' + table.tableName - } else if (table.database) { - return table.database + '.' + table.tableName - } - return table.tableName -} - -export function isPosInLocation(location: NodeRange, pos: Pos) { - return ( - location.start.line === pos.line + 1 && - location.start.column <= pos.column && - location.end.line === pos.line + 1 && - location.end.column >= pos.column - ) -} - -export function toCompletionItemForFunction(f: DbFunction): CompletionItem { - const item: CompletionItem = { - label: f.name, - detail: 'function', - kind: ICONS.FUNCTION, - documentation: f.description, - } - return item -} - -export function toCompletionItemForAlias(alias: string): CompletionItem { - const item: CompletionItem = { - label: alias, - detail: 'alias', - kind: ICONS.ALIAS, - } - return item -} - -export function toCompletionItemForKeyword(name: string): CompletionItem { - const item: CompletionItem = { - label: name, - kind: ICONS.KEYWORD, - detail: 'keyword', - } - return item -} - -export function createCandidatesForColumnsOfAnyTable( - tables: Table[], - lastToken: string -): CompletionItem[] { - return tables - .flatMap((table) => table.columns) - .map((column) => { - return new Identifier( - lastToken, - column.columnName, - column.description, - ICONS.TABLE - ) - }) - .filter((item) => item.matchesLastToken()) - .map((item) => item.toCompletionItem()) -} - -export function createCandidatesForTables(tables: Table[], lastToken: string) { - return tables - .flatMap((table) => allTableNameCombinations(table)) - .map((aTableNameVariant) => { - return new Identifier(lastToken, aTableNameVariant, '', ICONS.TABLE) - }) - .filter((item) => item.matchesLastToken()) - .map((item) => item.toCompletionItem()) -} - -/** - * Given a table returns all possible ways to refer to it. - * That is by table name only, using the database scope, - * using the catalog and database scopes. - * @param table - * @returns - */ -export function allTableNameCombinations(table: Table): string[] { - const names = [table.tableName] - if (table.database) names.push(table.database + '.' + table.tableName) - if (table.catalog) - names.push(table.catalog + '.' + table.database + '.' + table.tableName) - return names -} - -// Check if parser expects us to terminate a single quote value or double quoted column name -// SELECT TABLE1.COLUMN1 FROM TABLE1 WHERE TABLE1.COLUMN1 = "hoge. -// We don't offer the ', the ", the ` as suggestions -export function createCandidatesForExpectedLiterals( - nodes: ExpectedLiteralNode[] -): CompletionItem[] { - const literals = nodes.map((v) => v.text) - const uniqueLiterals = [...new Set(literals)] - return uniqueLiterals - .filter((v) => !UNDESIRED_LITERAL.includes(v)) - .map((v) => { - switch (v) { - case 'ORDER': - return 'ORDER BY' - case 'GROUP': - return 'GROUP BY' - case 'LEFT': - return 'LEFT JOIN' - case 'RIGHT': - return 'RIGHT JOIN' - case 'INNER': - return 'INNER JOIN' - default: - return v - } - }) - .map((v) => toCompletionItemForKeyword(v)) -} - -export function getAliasFromFromTableNode(node: FromTableNode): string { - if (node.as) { - return node.as - } - if (node.type === 'table') { - return node.table - } - return '' -} - -/** - * Test if the given table matches the fromNode. - * @param fromNode - * @param table - * @returns - */ -export function isTableMatch(fromNode: FromTableNode, table: Table): boolean { - switch (fromNode.type) { - case 'subquery': { - if (fromNode.as && fromNode.as !== table.tableName) { - return false - } - break - } - case 'table': { - if (fromNode.table && fromNode.table !== table.tableName) { - return false - } - if (fromNode.db && fromNode.db !== table.database) { - return false - } - if (fromNode.catalog && fromNode.catalog !== table.catalog) { - return false - } - break - } - default: { - return false - } - } - return true -} - -export function getColumnRefByPos(columns: ColumnRefNode[], pos: Pos) { - return columns.find( - (v) => - // guard against ColumnRefNode that don't have a location, - // for example sql functions that are not known to the parser - v.location && - v.location.start.line === pos.line + 1 && - v.location.start.column <= pos.column && - v.location.end.line === pos.line + 1 && - v.location.end.column >= pos.column - ) -} - -export function makeColumnName(alias: string, columnName: string) { - return alias ? alias + '.' + columnName : columnName -} - -export function createTablesFromFromNodes(fromNodes: FromTableNode[]): Table[] { - return fromNodes.reduce((p, c) => { - if (c.type !== 'subquery') { - return p - } - if (!Array.isArray(c.subquery.columns)) { - return p - } - const columns = c.subquery.columns - .map((v) => { - if (typeof v === 'string') { - return null - } - return { - columnName: - v.as || (v.expr.type === 'column_ref' && v.expr.column) || '', - description: 'alias', - } - }) - .filter(isNotEmpty) - return p.concat({ - database: null, - catalog: null, - columns: columns ?? [], - tableName: c.as ?? '', - }) - }, [] as Table[]) -} - -export function findColumnAtPosition( - ast: SelectStatement, - pos: Pos -): ColumnRefNode | null { - const columns = ast.columns - if (Array.isArray(columns)) { - // columns in select clause - const columnRefs = columns - .map((col) => col.expr) - .filter((expr): expr is ColumnRefNode => expr.type === 'column_ref') - if (ast.type === 'select' && ast.where?.expression) { - if (ast.where.expression.type === 'column_ref') { - // columns in where clause - columnRefs.push(ast.where.expression) - } - } - // column at position - const columnRef = getColumnRefByPos(columnRefs, pos) - if (logger.isDebugEnabled()) logger.debug(JSON.stringify(columnRef)) - return columnRef ?? null - } else if (columns.type == 'star') { - if (ast.type === 'select' && ast.where?.expression) { - // columns in where clause - const columnRefs = - ast.where.expression.type === 'column_ref' ? [ast.where.expression] : [] - // column at position - const columnRef = getColumnRefByPos(columnRefs, pos) - if (logger.isDebugEnabled()) logger.debug(JSON.stringify(columnRef)) - return columnRef ?? null - } - } - return null -} - -/** - * Recursively pull out the FROM nodes (including sub-queries) - * @param tableNodes - * @returns - */ -export function getAllNestedFromNodes( - tableNodes: FromTableNode[] -): FromTableNode[] { - return tableNodes.flatMap((tableNode) => { - let result = [tableNode] - if (tableNode.type == 'subquery') { - const subTableNodes = tableNode.subquery.from?.tables || [] - result = result.concat(getAllNestedFromNodes(subTableNodes)) - } - return result - }) -} diff --git a/packages/server/test/complete.test.ts b/packages/server/test/complete.test.ts index 4a5dd065..e51868fb 100644 --- a/packages/server/test/complete.test.ts +++ b/packages/server/test/complete.test.ts @@ -1,5 +1,4 @@ import { complete } from '../src/complete' -import { Identifier, utils } from '../src/complete/index' describe('keyword completion', () => { test("complete 'SELECT' keyword", () => { @@ -663,30 +662,6 @@ describe('Nested ColumnName completion', () => { expect(result.candidates[1].label).toEqual('`with spaces`.`sub space`') }) - test('getLastToken', () => { - expect(utils.getLastToken('SELECT abc')).toEqual('abc') - expect(utils.getLastToken('SELECT abc.def')).toEqual('abc.def') - expect(utils.getLastToken('SELECT abc[0]')).toEqual('abc') - expect(utils.getLastToken('SELECT abc[0].')).toEqual('abc.') - expect(utils.getLastToken('SELECT abc[0].d')).toEqual('abc.d') - expect(utils.getLastToken('SELECT abc[0].def[0]')).toEqual('abc.def') - expect(utils.getLastToken('SELECT abc[0].def[0].')).toEqual('abc.def.') - expect(utils.getLastToken('SELECT abc[0].def[0].g')).toEqual('abc.def.g') - - expect(utils.getLastToken("SELECT abc['key']")).toEqual('abc') - expect(utils.getLastToken("SELECT abc['key.name'].")).toEqual('abc.') - expect(utils.getLastToken("SELECT abc['key'].d")).toEqual('abc.d') - expect(utils.getLastToken("SELECT abc['key'].def['key']")).toEqual( - 'abc.def' - ) - expect(utils.getLastToken("SELECT abc['key'].def['key'].")).toEqual( - 'abc.def.' - ) - expect(utils.getLastToken("SELECT abc['key'].def[0].g")).toEqual( - 'abc.def.g' - ) - }) - test('with array subscripted', () => { const result = complete( 'SELECT t.abc[0]. FROM TABLE1 as t', @@ -1007,41 +982,3 @@ describe('DELETE statement', () => { expect(result.candidates[1].label).toEqual('COLUMN2') }) }) - -describe('toCompletionItemForIdentifier', () => { - test('complete comlumn name', () => { - const item = new Identifier('col', 'column1', '', utils.ICONS.COLUMN) - const completion = item.toCompletionItem() - expect(completion.label).toEqual('column1') - }) - test('complete aliased comlumn name', () => { - const item = new Identifier( - 'ali.col', - 'ali.column1', - '', - utils.ICONS.COLUMN - ) - const completion = item.toCompletionItem() - expect(completion.label).toEqual('column1') - }) - test('complete aliased nested comlumn last part name', () => { - const item = new Identifier( - 'ali.column1.sub', - 'ali.column1.subcolumn2', - '', - utils.ICONS.COLUMN - ) - const completion = item.toCompletionItem() - expect(completion.label).toEqual('subcolumn2') - }) - test('complete aliased nested comlumn first part name', () => { - const item = new Identifier( - 'ali.colu', - 'ali.column1.subcolumn2', - '', - utils.ICONS.COLUMN - ) - const completion = item.toCompletionItem() - expect(completion.label).toEqual('column1.subcolumn2') - }) -}) diff --git a/packages/server/test/complete/Identifier.test.ts b/packages/server/test/complete/Identifier.test.ts new file mode 100644 index 00000000..a9388a74 --- /dev/null +++ b/packages/server/test/complete/Identifier.test.ts @@ -0,0 +1,37 @@ +import { ICONS } from '../../src/complete/CompletionItemUtils' +import { Identifier } from '../../src/complete/index' + +describe('Identifier', () => { + describe('toCompletionItem', () => { + test('complete comlumn name', () => { + const item = new Identifier('col', 'column1', '', ICONS.COLUMN) + const completion = item.toCompletionItem() + expect(completion.label).toEqual('column1') + }) + test('complete aliased comlumn name', () => { + const item = new Identifier('ali.col', 'ali.column1', '', ICONS.COLUMN) + const completion = item.toCompletionItem() + expect(completion.label).toEqual('column1') + }) + test('complete aliased nested comlumn last part name', () => { + const item = new Identifier( + 'ali.column1.sub', + 'ali.column1.subcolumn2', + '', + ICONS.COLUMN + ) + const completion = item.toCompletionItem() + expect(completion.label).toEqual('subcolumn2') + }) + test('complete aliased nested comlumn first part name', () => { + const item = new Identifier( + 'ali.colu', + 'ali.column1.subcolumn2', + '', + ICONS.COLUMN + ) + const completion = item.toCompletionItem() + expect(completion.label).toEqual('column1.subcolumn2') + }) + }) +}) diff --git a/packages/server/test/complete/StringUtils.test.ts b/packages/server/test/complete/StringUtils.test.ts new file mode 100644 index 00000000..30d3b7b0 --- /dev/null +++ b/packages/server/test/complete/StringUtils.test.ts @@ -0,0 +1,30 @@ +import * as StringUtils from '../../src/complete/StringUtils' +describe('StringUtils', () => { + test('getLastToken', () => { + expect(StringUtils.getLastToken('SELECT abc')).toEqual('abc') + expect(StringUtils.getLastToken('SELECT abc.def')).toEqual('abc.def') + expect(StringUtils.getLastToken('SELECT abc[0]')).toEqual('abc') + expect(StringUtils.getLastToken('SELECT abc[0].')).toEqual('abc.') + expect(StringUtils.getLastToken('SELECT abc[0].d')).toEqual('abc.d') + expect(StringUtils.getLastToken('SELECT abc[0].def[0]')).toEqual('abc.def') + expect(StringUtils.getLastToken('SELECT abc[0].def[0].')).toEqual( + 'abc.def.' + ) + expect(StringUtils.getLastToken('SELECT abc[0].def[0].g')).toEqual( + 'abc.def.g' + ) + + expect(StringUtils.getLastToken("SELECT abc['key']")).toEqual('abc') + expect(StringUtils.getLastToken("SELECT abc['key.name'].")).toEqual('abc.') + expect(StringUtils.getLastToken("SELECT abc['key'].d")).toEqual('abc.d') + expect(StringUtils.getLastToken("SELECT abc['key'].def['key']")).toEqual( + 'abc.def' + ) + expect(StringUtils.getLastToken("SELECT abc['key'].def['key'].")).toEqual( + 'abc.def.' + ) + expect(StringUtils.getLastToken("SELECT abc['key'].def[0].g")).toEqual( + 'abc.def.g' + ) + }) +})