• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

dataunitylab / relational-playground / #107

09 Sep 2025 03:15PM UTC coverage: 78.436% (-7.9%) from 86.296%
#107

push

michaelmior
Update Node to 24

Signed-off-by: Michael Mior <mmior@mail.rit.edu>

517 of 731 branches covered (70.73%)

Branch coverage included in aggregate %.

1018 of 1226 relevant lines covered (83.03%)

14584.19 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

30.13
/src/SqlEditor.js
1
// @flow
2
import * as React from 'react';
3
import Editor from 'react-simple-code-editor';
4
import {highlight, languages} from 'prismjs/components/prism-core';
5
import 'prismjs/components/prism-sql';
6
import {exprFromSql} from './modules/relexp';
7
import {resetAction} from './modules/data';
8
import SqlAutocompleteDropdown, {
9
  parseForAutocomplete,
10
  generateSuggestions,
11
} from './SqlAutocomplete';
12
import './SqlEditor.css';
13

14
import 'prismjs/themes/prism.css';
15

16
import type {Node} from 'react';
17

18
const parser = require('@michaelmior/js-sql-parser');
2✔
19

20
type Props = {
21
  navigate: any,
22
  defaultText: string,
23
  exprFromSql: typeof exprFromSql,
24
  resetAction: typeof resetAction,
25
  ReactGA: any,
26
  types: {[string]: Array<string>},
27
};
28

29
type State = {
30
  error: string | null,
31
  timeout: any,
32
  query: string,
33
  showAutocomplete: boolean,
34
  autocompleteItems: Array<any>,
35
  selectedIndex: number,
36
  autocompletePosition: {top: number, left: number},
37
  cursorPosition: number,
38
};
39

40
/** Editor for SQL queries */
41
class SqlEditor extends React.Component<Props, State> {
42
  editorRef: {current: null | HTMLElement};
43
  isAutocompleting: boolean;
44

45
  constructor() {
46
    super();
3✔
47
    // $FlowFixMe[method-unbinding]
48
    (this: any).handleChange = this.handleChange.bind(this);
3✔
49
    // $FlowFixMe[method-unbinding]
50
    (this: any).parseQuery = this.parseQuery.bind(this);
3✔
51
    // $FlowFixMe[method-unbinding]
52
    (this: any).handleKeyDown = this.handleKeyDown.bind(this);
3✔
53
    // $FlowFixMe[method-unbinding]
54
    (this: any).handleAutocompleteSelect =
3✔
55
      // $FlowFixMe[method-unbinding]
56
      this.handleAutocompleteSelect.bind(this);
57
    // $FlowFixMe[method-unbinding]
58
    (this: any).hideAutocomplete = this.hideAutocomplete.bind(this);
3✔
59

60
    this.editorRef = React.createRef();
3✔
61
    this.isAutocompleting = false;
3✔
62
    this.state = {
3✔
63
      error: null,
64
      timeout: null,
65
      query: '',
66
      showAutocomplete: false,
67
      autocompleteItems: [],
68
      selectedIndex: 0,
69
      autocompletePosition: {top: 0, left: 0},
70
      cursorPosition: 0,
71
    };
72
  }
73

74
  componentDidMount() {
75
    // Parse the initial query when we start
76
    const values = new URL(window.location.toString()).searchParams;
3✔
77
    const query = values.get('query');
3✔
78

79
    if (query) {
3!
80
      this.parseQuery(query, false);
×
81
      this.setState({query: query});
×
82
    } else {
83
      this.parseQuery(this.props.defaultText, true);
3✔
84
      this.setState({query: this.props.defaultText});
3✔
85
    }
86
  }
87

88
  /**
89
   * @param text - the query to parse (optional - if not provided, uses current state)
90
   * @param firstLoad - whether this is the first call when mounted
91
   */
92
  parseQuery(text?: string, firstLoad?: boolean): void {
93
    // Always use the current state query unless explicitly overridden
94
    const queryToParse = text !== undefined ? text : this.state.query;
4✔
95

96
    if (!firstLoad) {
4✔
97
      if (this.props.resetAction) {
1!
98
        this.props.resetAction();
1✔
99
      }
100
      this.setState({timeout: null});
1✔
101
    }
102
    try {
4✔
103
      const sql = parser.parse(queryToParse);
4✔
104
      if (
4!
105
        sql.nodeType === 'Main' &&
8✔
106
        ['Except', 'Intersect', 'Select', 'Union'].includes(sql.value.type)
107
      ) {
108
        // Record the typed SQL statement
109
        if (!firstLoad && this.props.ReactGA) {
4✔
110
          this.props.ReactGA.event({
1✔
111
            category: 'User Typing SQL Statement',
112
            action: queryToParse,
113
          });
114
        }
115

116
        // Parse the query
117
        this.props.exprFromSql(sql.value, this.props.types);
4✔
118
        this.props.navigate('/?query=' + queryToParse);
4✔
119

120
        if (!firstLoad) {
1!
121
          this.setState({error: null});
×
122
        }
123
      } else {
124
        // Show an error if we try to parse any unsupported query type
125
        const errMsg = 'Unsupported expression';
×
126
        if (!firstLoad) {
×
127
          this.setState({error: errMsg});
×
128
        }
129
      }
130
    } catch (err) {
131
      // Display any error message generated during parsing
132
      if (!firstLoad) {
3✔
133
        this.setState({error: err.message});
1✔
134
      }
135
    }
136
  }
137

138
  handleChange(text: string): void {
139
    // If we're in the middle of autocomplete, don't trigger parsing or autocomplete - just update the state
140
    if (this.isAutocompleting) {
1!
141
      this.setState({query: text});
×
142
      return;
×
143
    }
144

145
    // Cancel any pending query parsing
146
    if (this.state.timeout) {
1!
147
      clearTimeout(this.state.timeout);
×
148
    }
149

150
    // Only parse the query once per second
151
    let handle = setTimeout(() => {
1✔
152
      // Use current state instead of captured closure variable
153
      this.parseQuery();
1✔
154
    }, 1000);
155

156
    this.setState({timeout: handle, query: text}, () => {
1✔
157
      // Update autocomplete after state is set with a small delay to ensure cursor position is updated
158
      setTimeout(() => {
1✔
159
        this.updateAutocomplete(text);
1✔
160
      }, 50);
161
    });
162
  }
163

164
  updateAutocomplete(text: string): void {
165
    // Check if we're still in autocompleting mode
166
    if (this.isAutocompleting) {
1!
167
      return;
×
168
    }
169

170
    // Get cursor position from the textarea
171
    const textarea = document.getElementById('sqlInput');
1✔
172
    if (
1!
173
      !textarea ||
1!
174
      !(
175
        textarea instanceof HTMLInputElement ||
×
176
        textarea instanceof HTMLTextAreaElement
177
      ) ||
178
      typeof textarea.selectionStart !== 'number'
179
    ) {
180
      this.hideAutocomplete();
1✔
181
      return;
1✔
182
    }
183

184
    const cursorPosition = textarea.selectionStart;
×
185
    const parseResult = parseForAutocomplete(text, cursorPosition);
×
186

187
    if (!parseResult.context || !parseResult.prefix) {
×
188
      this.hideAutocomplete();
×
189
      return;
×
190
    }
191

192
    const suggestions = generateSuggestions(
×
193
      parseResult.prefix,
194
      parseResult.context,
195
      this.props.types,
196
      parseResult.needsTablePrefix
197
    );
198

199
    if (suggestions.length === 0) {
×
200
      this.hideAutocomplete();
×
201
      return;
×
202
    }
203

204
    // Scenario 1: Check if the user has typed exactly one of the suggestions
205
    if (
×
206
      suggestions.length === 1 &&
×
207
      suggestions[0].text === parseResult.prefix
208
    ) {
209
      this.hideAutocomplete();
×
210
      return;
×
211
    }
212

213
    // Also hide if the prefix exactly matches any suggestion (case-insensitive)
214
    const exactMatch = suggestions.find(
×
215
      (suggestion) =>
216
        suggestion.text.toLowerCase() === parseResult.prefix.toLowerCase()
×
217
    );
218
    if (exactMatch) {
×
219
      this.hideAutocomplete();
×
220
      return;
×
221
    }
222

223
    // Calculate position for dropdown using the actual textarea element
224
    const rect = textarea.getBoundingClientRect();
×
225
    const position = this.calculateDropdownPosition(
×
226
      textarea,
227
      cursorPosition,
228
      rect
229
    );
230

231
    this.setState({
×
232
      showAutocomplete: true,
233
      autocompleteItems: suggestions,
234
      selectedIndex: 0,
235
      autocompletePosition: position,
236
      cursorPosition: cursorPosition, // Update the stored cursor position
237
    });
238
  }
239

240
  calculateDropdownPosition(
241
    textarea: any,
242
    cursorPosition: number,
243
    rect: ClientRect
244
  ): {top: number, left: number} {
245
    // Get text up to cursor position
246
    const textBeforeCursor = textarea.value.slice(0, cursorPosition);
×
247
    const lines = textBeforeCursor.split('\n');
×
248
    const currentLine = lines.length - 1;
×
249
    const currentLineText = lines[currentLine] || '';
×
250

251
    // Get the computed style of the textarea
252
    const computedStyle = getComputedStyle(textarea);
×
253
    const fontSize = parseFloat(computedStyle.fontSize) || 14;
×
254
    const lineHeight = parseFloat(computedStyle.lineHeight) || fontSize * 1.4;
×
255

256
    // Measure actual character width using a temporary element
257
    let actualCharWidth = fontSize * 0.6; // fallback
×
258
    const body = document.body;
×
259
    if (body) {
×
260
      const measurer = document.createElement('span');
×
261
      measurer.style.fontFamily = computedStyle.fontFamily;
×
262
      measurer.style.fontSize = computedStyle.fontSize;
×
263
      measurer.style.fontWeight = computedStyle.fontWeight;
×
264
      measurer.style.letterSpacing = computedStyle.letterSpacing;
×
265
      measurer.style.visibility = 'hidden';
×
266
      measurer.style.position = 'absolute';
×
267
      measurer.style.whiteSpace = 'pre';
×
268
      measurer.textContent = currentLineText;
×
269

270
      body.appendChild(measurer);
×
271
      const actualTextWidth = measurer.offsetWidth;
×
272
      body.removeChild(measurer);
×
273

274
      if (currentLineText.length > 0) {
×
275
        actualCharWidth = actualTextWidth / currentLineText.length;
×
276
      }
277
    }
278

279
    // Get padding and border values
280
    const paddingTop = parseFloat(computedStyle.paddingTop) || 0;
×
281
    const paddingLeft = parseFloat(computedStyle.paddingLeft) || 0;
×
282
    const borderTop = parseFloat(computedStyle.borderTopWidth) || 0;
×
283
    const borderLeft = parseFloat(computedStyle.borderLeftWidth) || 0;
×
284

285
    // Calculate the cursor position, then move dropdown below the current line
286
    const cursorTop =
287
      rect.top +
×
288
      paddingTop +
289
      borderTop +
290
      currentLine * lineHeight +
291
      lineHeight +
292
      window.scrollY;
293
    const cursorLeft =
294
      rect.left +
×
295
      paddingLeft +
296
      borderLeft +
297
      currentLineText.length * actualCharWidth +
298
      window.scrollX;
299

300
    return {
×
301
      top: cursorTop,
302
      left: cursorLeft,
303
    };
304
  }
305

306
  handleKeyDown(event: KeyboardEvent): boolean {
307
    if (!this.state.showAutocomplete) {
×
308
      return false;
×
309
    }
310

311
    switch (event.key) {
×
312
      case 'ArrowDown':
313
        event.preventDefault();
×
314
        this.setState((prevState) => ({
×
315
          selectedIndex:
316
            (prevState.selectedIndex + 1) % prevState.autocompleteItems.length,
317
        }));
318
        return true;
×
319

320
      case 'ArrowUp':
321
        event.preventDefault();
×
322
        this.setState((prevState) => ({
×
323
          selectedIndex:
324
            prevState.selectedIndex === 0
×
325
              ? prevState.autocompleteItems.length - 1
326
              : prevState.selectedIndex - 1,
327
        }));
328
        return true;
×
329

330
      case 'Tab':
331
      case 'Enter':
332
        event.preventDefault();
×
333
        this.handleAutocompleteSelect(
×
334
          this.state.autocompleteItems[this.state.selectedIndex]
335
        );
336
        return true;
×
337

338
      case 'Escape':
339
        this.hideAutocomplete();
×
340
        return true;
×
341

342
      default:
343
        return false;
×
344
    }
345
  }
346

347
  handleAutocompleteSelect(item: any): void {
348
    const {query, cursorPosition: storedCursorPosition} = this.state;
×
349

350
    // Set the class property to prevent handleChange from interfering
351
    this.isAutocompleting = true;
×
352

353
    // Cancel any pending query parsing from handleChange
354
    if (this.state.timeout) {
×
355
      clearTimeout(this.state.timeout);
×
356
    }
357

358
    // Get the current cursor position from the actual textarea element
359
    const textarea = document.getElementById('sqlInput');
×
360
    if (
×
361
      !textarea ||
×
362
      !(
363
        textarea instanceof HTMLInputElement ||
×
364
        textarea instanceof HTMLTextAreaElement
365
      ) ||
366
      typeof textarea.selectionStart !== 'number'
367
    ) {
368
      this.hideAutocomplete();
×
369
      return;
×
370
    }
371

372
    // Use the stored cursor position from when autocomplete was triggered, not the current position
373
    // This is important because the text might have changed since autocomplete was shown
374
    const originalCursorPosition = storedCursorPosition;
×
375

376
    // Parse using the original cursor position to get the original prefix that was typed
377
    const parseResult = parseForAutocomplete(query, originalCursorPosition);
×
378
    const prefixLength = parseResult.prefix.length;
×
379

380
    // Replace the original prefix with the selected item
381
    const beforePrefix = query.slice(0, originalCursorPosition - prefixLength);
×
382
    const afterPrefix = query.slice(originalCursorPosition);
×
383
    const newQuery = beforePrefix + item.text + afterPrefix;
×
384

385
    // Calculate new cursor position (after the inserted text)
386
    const newCursorPosition = beforePrefix.length + item.text.length;
×
387

388
    this.setState(
×
389
      {
390
        query: newQuery,
391
        showAutocomplete: false, // Always hide autocomplete after selection
392
        cursorPosition: newCursorPosition, // Update stored cursor position for next autocomplete
393
        timeout: null, // Clear the timeout from state as well
394
      },
395
      () => {
396
        // Set the cursor position after the state update
397
        setTimeout(() => {
×
398
          // $FlowIssue[method-unbinding]
399
          if (textarea.setSelectionRange) {
×
400
            textarea.setSelectionRange(newCursorPosition, newCursorPosition);
×
401
          }
402

403
          // Trigger parsing immediately with the current state query
404
          this.parseQuery();
×
405

406
          // Clear the autocompleting flag after a delay to prevent immediate re-trigger
407
          setTimeout(() => {
×
408
            this.isAutocompleting = false;
×
409
          }, 300); // Longer delay to ensure all handleChange events are processed
410
        }, 10);
411
      }
412
    );
413
  }
414

415
  hideAutocomplete(): void {
416
    this.setState({
1✔
417
      showAutocomplete: false,
418
      autocompleteItems: [],
419
      selectedIndex: 0,
420
    });
421
  }
422

423
  render(): Node {
424
    // Include any error message if needed
425
    let error: React.Node = <React.Fragment />;
10✔
426
    if (this.state.error) {
10✔
427
      error = <div style={{color: 'red'}}>{this.state.error}</div>;
1✔
428
    }
429

430
    return (
10✔
431
      <div className={'SqlEditor'}>
432
        <label htmlFor="sqlInput">
433
          <h4>SQL Query</h4>
434
        </label>
435
        <div
436
          className="editor"
437
          style={{position: 'relative'}}
438
          onKeyDown={(event) => this.handleKeyDown(event)}
×
439
        >
440
          <Editor
441
            ref={this.editorRef}
442
            value={this.state.query}
443
            // $FlowFixMe[method-unbinding]
444
            onValueChange={this.handleChange}
445
            highlight={(code) => highlight(code, languages.sql)}
2✔
446
            padding={10}
447
            style={{
448
              fontDisplay: 'swap',
449
              fontFamily: '"Fira Code", monospace',
450
            }}
451
            textareaId="sqlInput"
452
          />
453
          {this.state.showAutocomplete && (
10!
454
            <SqlAutocompleteDropdown
455
              items={this.state.autocompleteItems}
456
              selectedIndex={this.state.selectedIndex}
457
              position={this.state.autocompletePosition}
458
              onSelect={(item) => this.handleAutocompleteSelect(item)}
×
459
              onClose={() => this.hideAutocomplete()}
×
460
            />
461
          )}
462
        </div>
463
        <div className="error">{error}</div>
464
      </div>
465
    );
466
  }
467
}
468

469
export default SqlEditor;
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc