Skip to content

Commit 5bfba29

Browse files
authored
Refactor (joe-re#122)
* move getFromNodeByPos function to utils * extract create candidates logics * refactor supressing keyword completion logic * delete unusef function * extract converting completion item logics * move complete function directory * extract AST manipulation functions from utils * refactor test directory * unify Pos definition
1 parent 9ef1cb1 commit 5bfba29

19 files changed

+739
-608
lines changed
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
import {
2+
ColumnRefNode,
3+
FromTableNode,
4+
SelectStatement,
5+
NodeRange,
6+
} from '@joe-re/sql-parser'
7+
import log4js from 'log4js'
8+
import { Table } from '../database_libs/AbstractClient'
9+
import { Pos } from './complete'
10+
11+
const logger = log4js.getLogger()
12+
13+
function isNotEmpty<T>(value: T | null | undefined): value is T {
14+
return value === null || value === undefined ? false : true
15+
}
16+
17+
export function getColumnRefByPos(
18+
columns: ColumnRefNode[],
19+
pos: Pos
20+
): ColumnRefNode | null {
21+
return (
22+
columns.find(
23+
(v) =>
24+
// guard against ColumnRefNode that don't have a location,
25+
// for example sql functions that are not known to the parser
26+
v.location &&
27+
v.location.start.line === pos.line + 1 &&
28+
v.location.start.column <= pos.column &&
29+
v.location.end.line === pos.line + 1 &&
30+
v.location.end.column >= pos.column
31+
) ?? null
32+
)
33+
}
34+
35+
export function isPosInLocation(location: NodeRange, pos: Pos) {
36+
return (
37+
location.start.line === pos.line + 1 &&
38+
location.start.column <= pos.column &&
39+
location.end.line === pos.line + 1 &&
40+
location.end.column >= pos.column
41+
)
42+
}
43+
44+
export function createTablesFromFromNodes(fromNodes: FromTableNode[]): Table[] {
45+
return fromNodes.reduce((p, c) => {
46+
if (c.type !== 'subquery') {
47+
return p
48+
}
49+
if (!Array.isArray(c.subquery.columns)) {
50+
return p
51+
}
52+
const columns = c.subquery.columns
53+
.map((v) => {
54+
if (typeof v === 'string') {
55+
return null
56+
}
57+
return {
58+
columnName:
59+
v.as || (v.expr.type === 'column_ref' && v.expr.column) || '',
60+
description: 'alias',
61+
}
62+
})
63+
.filter(isNotEmpty)
64+
return p.concat({
65+
database: null,
66+
catalog: null,
67+
columns: columns ?? [],
68+
tableName: c.as ?? '',
69+
})
70+
}, [] as Table[])
71+
}
72+
73+
export function findColumnAtPosition(
74+
ast: SelectStatement,
75+
pos: Pos
76+
): ColumnRefNode | null {
77+
const columns = ast.columns
78+
if (Array.isArray(columns)) {
79+
// columns in select clause
80+
const columnRefs = columns
81+
.map((col) => col.expr)
82+
.filter((expr): expr is ColumnRefNode => expr.type === 'column_ref')
83+
if (ast.type === 'select' && ast.where?.expression) {
84+
if (ast.where.expression.type === 'column_ref') {
85+
// columns in where clause
86+
columnRefs.push(ast.where.expression)
87+
}
88+
}
89+
// column at position
90+
const columnRef = getColumnRefByPos(columnRefs, pos)
91+
if (logger.isDebugEnabled()) logger.debug(JSON.stringify(columnRef))
92+
return columnRef ?? null
93+
} else if (columns.type == 'star') {
94+
if (ast.type === 'select' && ast.where?.expression) {
95+
// columns in where clause
96+
const columnRefs =
97+
ast.where.expression.type === 'column_ref' ? [ast.where.expression] : []
98+
// column at position
99+
const columnRef = getColumnRefByPos(columnRefs, pos)
100+
if (logger.isDebugEnabled()) logger.debug(JSON.stringify(columnRef))
101+
return columnRef ?? null
102+
}
103+
}
104+
return null
105+
}
106+
107+
/**
108+
* Recursively pull out the FROM nodes (including sub-queries)
109+
* @param tableNodes
110+
* @returns
111+
*/
112+
export function getAllNestedFromNodes(
113+
tableNodes: FromTableNode[]
114+
): FromTableNode[] {
115+
return tableNodes.flatMap((tableNode) => {
116+
let result = [tableNode]
117+
if (tableNode.type == 'subquery') {
118+
const subTableNodes = tableNode.subquery.from?.tables || []
119+
result = result.concat(getAllNestedFromNodes(subTableNodes))
120+
}
121+
return result
122+
})
123+
}
124+
125+
/**
126+
* Finds the most deeply nested FROM node that have a range encompasing the position.
127+
* In cases such as SELECT * FROM T1 JOIN (SELECT * FROM (SELECT * FROM T2 <pos>))
128+
* We will get a list of nodes like this
129+
* SELECT * FROM T1
130+
* (SELECT * FROM
131+
* (SELECT * FROM T2))
132+
* The idea is to reverse the list so that the most nested queries come first. Then
133+
* apply a filter to keep only the FROM nodes which encompass the position and take
134+
* the first one from that resulting list.
135+
* @param fromNodes
136+
* @param pos
137+
* @returns
138+
*/
139+
export function getNearestFromTableFromPos(
140+
fromNodes: FromTableNode[],
141+
pos: Pos
142+
): FromTableNode | null {
143+
return (
144+
fromNodes
145+
.reverse()
146+
.filter((tableNode) => isPosInLocation(tableNode.location, pos))
147+
.shift() ?? null
148+
)
149+
}
150+
151+
/**
152+
* Test if the given table matches the fromNode.
153+
* @param fromNode
154+
* @param table
155+
* @returns
156+
*/
157+
export function isTableMatch(fromNode: FromTableNode, table: Table): boolean {
158+
switch (fromNode.type) {
159+
case 'subquery': {
160+
if (fromNode.as && fromNode.as !== table.tableName) {
161+
return false
162+
}
163+
break
164+
}
165+
case 'table': {
166+
if (fromNode.table && fromNode.table !== table.tableName) {
167+
return false
168+
}
169+
if (fromNode.db && fromNode.db !== table.database) {
170+
return false
171+
}
172+
if (fromNode.catalog && fromNode.catalog !== table.catalog) {
173+
return false
174+
}
175+
break
176+
}
177+
default: {
178+
return false
179+
}
180+
}
181+
return true
182+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { CompletionItem, CompletionItemKind } from 'vscode-languageserver-types'
2+
import { DbFunction } from '../database_libs/AbstractClient'
3+
4+
export const ICONS = {
5+
KEYWORD: CompletionItemKind.Text,
6+
COLUMN: CompletionItemKind.Interface,
7+
TABLE: CompletionItemKind.Field,
8+
FUNCTION: CompletionItemKind.Property,
9+
ALIAS: CompletionItemKind.Variable,
10+
UTILITY: CompletionItemKind.Event,
11+
}
12+
13+
export function toCompletionItemForFunction(f: DbFunction): CompletionItem {
14+
const item: CompletionItem = {
15+
label: f.name,
16+
detail: 'function',
17+
kind: ICONS.FUNCTION,
18+
documentation: f.description,
19+
}
20+
return item
21+
}
22+
23+
export function toCompletionItemForAlias(alias: string): CompletionItem {
24+
const item: CompletionItem = {
25+
label: alias,
26+
detail: 'alias',
27+
kind: ICONS.ALIAS,
28+
}
29+
return item
30+
}
31+
32+
export function toCompletionItemForKeyword(name: string): CompletionItem {
33+
const item: CompletionItem = {
34+
label: name,
35+
kind: ICONS.KEYWORD,
36+
detail: 'keyword',
37+
}
38+
return item
39+
}

packages/server/src/complete/Identifier.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { CompletionItem, CompletionItemKind } from 'vscode-languageserver-types'
2-
import { makeTableAlias } from './utils'
2+
import { makeTableAlias } from './StringUtils'
33

44
export const ICONS = {
55
KEYWORD: CompletionItemKind.Text,
@@ -40,7 +40,7 @@ export class Identifier {
4040

4141
toCompletionItem(): CompletionItem {
4242
const idx = this.lastToken.lastIndexOf('.')
43-
const label = this.identifier.substr(idx + 1)
43+
const label = this.identifier.substring(idx + 1)
4444
let kindName: string
4545
let tableAlias = ''
4646
if (this.kind === ICONS.TABLE) {
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { FromTableNode } from '@joe-re/sql-parser'
2+
import { Table } from '../database_libs/AbstractClient'
3+
import { Pos } from './complete'
4+
5+
export function makeTableAlias(tableName: string): string {
6+
if (tableName.length > 3) {
7+
return tableName.substring(0, 3)
8+
}
9+
return tableName
10+
}
11+
12+
export function getRidOfAfterPosString(sql: string, pos: Pos): string {
13+
return sql
14+
.split('\n')
15+
.filter((_v, idx) => pos.line >= idx)
16+
.map((v, idx) => (idx === pos.line ? v.slice(0, pos.column) : v))
17+
.join('\n')
18+
}
19+
20+
// Gets the last token from the given string considering that tokens can contain dots.
21+
export function getLastToken(sql: string): string {
22+
const match = sql.match(/^(?:.|\s)*[^A-z0-9\\.:'](.*?)$/)
23+
if (match) {
24+
let prevToken = ''
25+
let currentToken = match[1]
26+
while (currentToken != prevToken) {
27+
prevToken = currentToken
28+
currentToken = prevToken.replace(/\[.*?\]/, '')
29+
}
30+
return currentToken
31+
}
32+
return sql
33+
}
34+
35+
export function makeTableName(table: Table): string {
36+
if (table.catalog) {
37+
return table.catalog + '.' + table.database + '.' + table.tableName
38+
} else if (table.database) {
39+
return table.database + '.' + table.tableName
40+
}
41+
return table.tableName
42+
}
43+
44+
export function getAliasFromFromTableNode(node: FromTableNode): string {
45+
if (node.as) {
46+
return node.as
47+
}
48+
if (node.type === 'table') {
49+
return node.table
50+
}
51+
return ''
52+
}
53+
54+
export function makeColumnName(alias: string, columnName: string) {
55+
return alias ? alias + '.' + columnName : columnName
56+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { CompletionItem } from 'vscode-languageserver-types'
2+
import { FromTableNode } from '@joe-re/sql-parser'
3+
import { toCompletionItemForAlias } from '../CompletionItemUtils'
4+
5+
export function createAliasCandidates(
6+
fromNodes: FromTableNode[],
7+
token: string
8+
): CompletionItem[] {
9+
return fromNodes
10+
.map((fromNode) => fromNode.as)
11+
.filter((aliasName) => aliasName && aliasName.startsWith(token))
12+
.map((aliasName) => toCompletionItemForAlias(aliasName || ''))
13+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { toCompletionItemForKeyword } from '../CompletionItemUtils'
2+
3+
const CLAUSES: string[] = [
4+
'SELECT',
5+
'WHERE',
6+
'ORDER BY',
7+
'GROUP BY',
8+
'LIMIT',
9+
'--',
10+
'/*',
11+
'(',
12+
]
13+
14+
export function createBasicKeywordCandidates() {
15+
return CLAUSES.map((v) => toCompletionItemForKeyword(v))
16+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { CompletionItem } from 'vscode-languageserver-types'
2+
import { FromTableNode } from '@joe-re/sql-parser'
3+
import { Table } from '../../database_libs/AbstractClient'
4+
import { getAliasFromFromTableNode, makeColumnName } from '../StringUtils'
5+
import { isTableMatch } from '../AstUtils'
6+
import { ICONS } from '../CompletionItemUtils'
7+
import { Identifier } from '../Identifier'
8+
9+
export function createCandidatesForColumnsOfAnyTable(
10+
tables: Table[],
11+
lastToken: string
12+
): CompletionItem[] {
13+
return tables
14+
.flatMap((table) => table.columns)
15+
.map((column) => {
16+
return new Identifier(
17+
lastToken,
18+
column.columnName,
19+
column.description,
20+
ICONS.TABLE
21+
)
22+
})
23+
.filter((item) => item.matchesLastToken())
24+
.map((item) => item.toCompletionItem())
25+
}
26+
27+
export function createCandidatesForScopedColumns(
28+
fromNodes: FromTableNode[],
29+
tables: Table[],
30+
lastToken: string
31+
): CompletionItem[] {
32+
return tables
33+
.flatMap((table) => {
34+
return fromNodes
35+
.filter((fromNode) => isTableMatch(fromNode, table))
36+
.map(getAliasFromFromTableNode)
37+
.filter((alias) => lastToken.startsWith(alias + '.'))
38+
.flatMap((alias) =>
39+
table.columns.map((col) => {
40+
return new Identifier(
41+
lastToken,
42+
makeColumnName(alias, col.columnName),
43+
col.description,
44+
ICONS.COLUMN
45+
)
46+
})
47+
)
48+
})
49+
.filter((item) => item.matchesLastToken())
50+
.map((item) => item.toCompletionItem())
51+
}

0 commit comments

Comments
 (0)