Skip to content

Commit 22b4f13

Browse files
committed
Add autocomplete to table/column names
1 parent 30459e1 commit 22b4f13

File tree

3 files changed

+697
-9
lines changed

3 files changed

+697
-9
lines changed

src/SqlAutocomplete.js

Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
// @flow
2+
import * as React from 'react';
3+
import type {Node} from 'react';
4+
5+
type AutocompleteItem = {
6+
text: string,
7+
type: 'table' | 'column',
8+
table?: string,
9+
};
10+
11+
type Props = {
12+
items: Array<AutocompleteItem>,
13+
selectedIndex: number,
14+
position: {top: number, left: number},
15+
onSelect: (item: AutocompleteItem) => void,
16+
onClose: () => void,
17+
};
18+
19+
/**
20+
* Dropdown component for SQL autocomplete suggestions
21+
*/
22+
class SqlAutocompleteDropdown extends React.Component<Props> {
23+
render(): Node {
24+
const {items, selectedIndex, position, onSelect} = this.props;
25+
26+
if (items.length === 0) {
27+
return null;
28+
}
29+
30+
return (
31+
<div
32+
className="sql-autocomplete-dropdown"
33+
style={{
34+
position: 'fixed', // Changed from 'absolute' to 'fixed' to position relative to viewport
35+
top: position.top,
36+
left: position.left,
37+
backgroundColor: 'white',
38+
border: '1px solid #ccc',
39+
borderRadius: '4px',
40+
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
41+
maxHeight: '200px',
42+
overflowY: 'auto',
43+
zIndex: 1000,
44+
minWidth: '150px',
45+
}}
46+
>
47+
{items.map((item, index) => (
48+
<div
49+
key={`${item.type}-${item.text}`}
50+
className={`sql-autocomplete-item ${
51+
index === selectedIndex ? 'selected' : ''
52+
}`}
53+
style={{
54+
padding: '8px 12px',
55+
cursor: 'pointer',
56+
backgroundColor: index === selectedIndex ? '#e6f3ff' : 'white',
57+
borderBottom:
58+
index < items.length - 1 ? '1px solid #eee' : 'none',
59+
}}
60+
onClick={() => onSelect(item)}
61+
>
62+
<span
63+
style={{
64+
fontWeight: item.type === 'table' ? 'bold' : 'normal',
65+
color: item.type === 'table' ? '#0066cc' : '#333',
66+
}}
67+
>
68+
{item.text}
69+
</span>
70+
{item.type === 'column' && item.table && (
71+
<span
72+
style={{
73+
marginLeft: '8px',
74+
fontSize: '12px',
75+
color: '#666',
76+
}}
77+
>
78+
({item.table})
79+
</span>
80+
)}
81+
</div>
82+
))}
83+
</div>
84+
);
85+
}
86+
}
87+
88+
/**
89+
* Parse SQL text to detect table/column references being typed
90+
*/
91+
export function parseForAutocomplete(
92+
text: string,
93+
cursorPosition: number
94+
): {
95+
prefix: string,
96+
context: 'table' | 'column' | null,
97+
needsTablePrefix?: boolean,
98+
} {
99+
// Get text up to cursor position
100+
const beforeCursor = text.slice(0, cursorPosition);
101+
102+
// Find the current word being typed (ignoring trailing spaces)
103+
const wordMatch = beforeCursor.trimEnd().match(/[\w.]*$/);
104+
const currentWord = wordMatch ? wordMatch[0] : '';
105+
106+
// If cursor is after spaces and there's no current word, look for the last word
107+
let actualWord = currentWord;
108+
if (!currentWord && beforeCursor.endsWith(' ')) {
109+
// Look for the word before the spaces
110+
const trimmed = beforeCursor.trimEnd();
111+
const lastWordMatch = trimmed.match(/[\w.]*$/);
112+
actualWord = lastWordMatch ? lastWordMatch[0] : '';
113+
}
114+
115+
// Check if we're in a context where table/column suggestions make sense
116+
const upperText = beforeCursor.toUpperCase();
117+
118+
// Common SQL keywords that typically precede table names
119+
const tableKeywords = ['FROM', 'JOIN', 'INTO', 'UPDATE'];
120+
// Common SQL keywords that typically precede column names
121+
const columnKeywords = [
122+
'SELECT',
123+
'WHERE',
124+
'GROUP BY',
125+
'ORDER BY',
126+
'HAVING',
127+
'ON',
128+
];
129+
130+
// Check if we're currently typing a keyword (don't autocomplete keywords)
131+
const allKeywords = [...tableKeywords, ...columnKeywords];
132+
for (const keyword of allKeywords) {
133+
if (keyword.startsWith(actualWord.toUpperCase()) && actualWord.length > 0) {
134+
return {prefix: '', context: null};
135+
}
136+
}
137+
138+
// Check for table context - must be right after a table keyword
139+
for (const keyword of tableKeywords) {
140+
const keywordIndex = upperText.lastIndexOf(keyword);
141+
if (keywordIndex >= 0) {
142+
const afterKeyword = beforeCursor
143+
.slice(keywordIndex + keyword.length)
144+
.trim();
145+
// Check if the cursor is within the first word after the keyword
146+
const wordsAfter = afterKeyword.split(/\s+/).filter((w) => w.length > 0);
147+
if (wordsAfter.length <= 1) {
148+
// We're in table context - return the actual word being typed
149+
return {prefix: actualWord, context: 'table'};
150+
}
151+
}
152+
}
153+
154+
// Check for column context - must be after column keyword but not after table keyword
155+
for (const keyword of columnKeywords) {
156+
const keywordIndex = upperText.lastIndexOf(keyword);
157+
if (keywordIndex >= 0) {
158+
const afterKeyword = beforeCursor.slice(keywordIndex + keyword.length);
159+
160+
// Check if there's a table keyword after this column keyword
161+
let hasTableKeywordAfter = false;
162+
for (const tk of tableKeywords) {
163+
const tableKeywordIndex = afterKeyword.toUpperCase().lastIndexOf(tk);
164+
if (tableKeywordIndex >= 0) {
165+
// Check if we're past the table keyword and its table name
166+
const afterTableKeyword = afterKeyword
167+
.slice(tableKeywordIndex + tk.length)
168+
.trim();
169+
const tableWords = afterTableKeyword
170+
.split(/\s+/)
171+
.filter((w) => w.length > 0);
172+
if (tableWords.length === 0) {
173+
// We're still naming the table
174+
hasTableKeywordAfter = true;
175+
} else if (
176+
tableWords.length === 1 &&
177+
afterTableKeyword.startsWith(actualWord)
178+
) {
179+
// We're still typing the table name
180+
hasTableKeywordAfter = true;
181+
}
182+
break;
183+
}
184+
}
185+
186+
if (!hasTableKeywordAfter) {
187+
// Check if it's a qualified column name (table.column)
188+
if (actualWord.includes('.')) {
189+
const parts = actualWord.split('.');
190+
return {
191+
prefix: parts[1] || '',
192+
context: 'column',
193+
needsTablePrefix: true,
194+
};
195+
}
196+
return {prefix: actualWord, context: 'column'};
197+
}
198+
}
199+
}
200+
201+
return {prefix: '', context: null};
202+
}
203+
204+
/**
205+
* Generate autocomplete suggestions based on available tables and columns
206+
*/
207+
export function generateSuggestions(
208+
prefix: string,
209+
context: 'table' | 'column' | null,
210+
types: {[string]: Array<string>},
211+
needsTablePrefix?: boolean
212+
): Array<AutocompleteItem> {
213+
if (!context || !prefix) {
214+
return [];
215+
}
216+
217+
const lowerPrefix = prefix.toLowerCase();
218+
const suggestions: Array<AutocompleteItem> = [];
219+
220+
if (context === 'table') {
221+
// Suggest table names
222+
Object.keys(types).forEach((tableName) => {
223+
if (tableName.toLowerCase().startsWith(lowerPrefix)) {
224+
suggestions.push({
225+
text: tableName,
226+
type: 'table',
227+
});
228+
}
229+
});
230+
} else if (context === 'column') {
231+
if (needsTablePrefix) {
232+
// For qualified column names (table.column), suggest columns from specific table
233+
// Extract table name from current word before the dot
234+
const currentWord = prefix;
235+
const dotIndex = currentWord.lastIndexOf('.');
236+
if (dotIndex > 0) {
237+
const tableName = currentWord.slice(0, dotIndex);
238+
const columnPrefix = currentWord.slice(dotIndex + 1).toLowerCase();
239+
240+
if (types[tableName]) {
241+
types[tableName].forEach((column) => {
242+
if (column.toLowerCase().startsWith(columnPrefix)) {
243+
suggestions.push({
244+
text: `${tableName}.${column}`,
245+
type: 'column',
246+
table: tableName,
247+
});
248+
}
249+
});
250+
}
251+
}
252+
} else {
253+
// Suggest all columns from all tables
254+
Object.entries(types).forEach(([tableName, columns]) => {
255+
columns.forEach((column) => {
256+
if (column.toLowerCase().startsWith(lowerPrefix)) {
257+
suggestions.push({
258+
text: column,
259+
type: 'column',
260+
table: tableName,
261+
});
262+
}
263+
});
264+
});
265+
266+
// Also suggest qualified column names
267+
Object.entries(types).forEach(([tableName, columns]) => {
268+
if (tableName.toLowerCase().startsWith(lowerPrefix)) {
269+
columns.forEach((column) => {
270+
suggestions.push({
271+
text: `${tableName}.${column}`,
272+
type: 'column',
273+
table: tableName,
274+
});
275+
});
276+
}
277+
});
278+
}
279+
}
280+
281+
return suggestions.slice(0, 10); // Limit to 10 suggestions
282+
}
283+
284+
export default SqlAutocompleteDropdown;

src/SqlAutocomplete.test.js

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
// @flow
2+
import {parseForAutocomplete, generateSuggestions} from './SqlAutocomplete';
3+
4+
describe('SqlAutocomplete', () => {
5+
const types = {
6+
Doctor: ['id', 'firstName', 'lastName', 'salary', 'departmentId'],
7+
Department: ['id', 'name', 'budget'],
8+
Patient: ['id', 'firstName', 'lastName', 'dateOfBirth'],
9+
};
10+
11+
describe('parseForAutocomplete', () => {
12+
test('detects table context after FROM', () => {
13+
const result = parseForAutocomplete('SELECT * FROM Doc', 17);
14+
expect(result.context).toBe('table');
15+
expect(result.prefix).toBe('Doc');
16+
});
17+
18+
test('detects table context after JOIN', () => {
19+
const result = parseForAutocomplete('SELECT * FROM Doctor JOIN Dep', 29);
20+
expect(result.context).toBe('table');
21+
expect(result.prefix).toBe('Dep');
22+
});
23+
24+
test('detects column context after SELECT', () => {
25+
const result = parseForAutocomplete('SELECT first', 12);
26+
expect(result.context).toBe('column');
27+
expect(result.prefix).toBe('first');
28+
});
29+
30+
test('detects column context after WHERE', () => {
31+
const result = parseForAutocomplete('SELECT * FROM Doctor WHERE sal', 30);
32+
expect(result.context).toBe('column');
33+
expect(result.prefix).toBe('sal');
34+
});
35+
36+
test('detects qualified column context', () => {
37+
const result = parseForAutocomplete('SELECT Doctor.first', 19);
38+
expect(result.context).toBe('column');
39+
expect(result.prefix).toBe('first');
40+
expect(result.needsTablePrefix).toBe(true);
41+
});
42+
43+
test('returns null context for non-relevant positions', () => {
44+
const result = parseForAutocomplete('SELECT * FROM Doctor', 10);
45+
expect(result.context).toBeNull();
46+
});
47+
});
48+
49+
describe('generateSuggestions', () => {
50+
test('suggests table names', () => {
51+
const suggestions = generateSuggestions('Doc', 'table', types);
52+
expect(suggestions).toHaveLength(1);
53+
expect(suggestions[0].text).toBe('Doctor');
54+
expect(suggestions[0].type).toBe('table');
55+
});
56+
57+
test('suggests column names', () => {
58+
const suggestions = generateSuggestions('first', 'column', types);
59+
expect(suggestions.length).toBeGreaterThan(0);
60+
expect(suggestions.some((s) => s.text === 'firstName')).toBe(true);
61+
expect(suggestions.every((s) => s.type === 'column')).toBe(true);
62+
});
63+
64+
test('suggests qualified column names for specific table', () => {
65+
const suggestions = generateSuggestions(
66+
'Doctor.first',
67+
'column',
68+
types,
69+
true
70+
);
71+
expect(suggestions.length).toBeGreaterThan(0);
72+
expect(suggestions.some((s) => s.text === 'Doctor.firstName')).toBe(true);
73+
});
74+
75+
test('limits suggestions to 10 items', () => {
76+
const suggestions = generateSuggestions('', 'column', types);
77+
expect(suggestions.length).toBeLessThanOrEqual(10);
78+
});
79+
80+
test('returns empty array for no matches', () => {
81+
const suggestions = generateSuggestions('xyz', 'table', types);
82+
expect(suggestions).toHaveLength(0);
83+
});
84+
});
85+
});

0 commit comments

Comments
 (0)