// Licensed to Cloudera, Inc. under one // or more contributor license agreements. See the NOTICE file // distributed with this work for additional information // regarding copyright ownership. Cloudera, Inc. licenses this file // to you under the Apache License, Version 2.0 (the // "License"); you may not use this file except in compliance // with the License. You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. import { Ace } from '../../../../ext/ace'; import ace from '../../../../ext/aceHelper'; import KnockoutObservable from '@types/knockout'; import { SQL_SYNTAX_DROPDOWN_HIDE_TOPIC, SQL_SYNTAX_DROPDOWN_SHOW_TOPIC, SqlSyntaxDropdownShowEvent } from './events'; import { ActiveStatementChangedEventDetails } from './types'; import Executor from '../../../../apps/editor/execution/executor'; import DataCatalogEntry, { TableSourceMeta } from '../../../../catalog/DataCatalogEntry'; import SubscriptionTracker, { Disposable } from '../../../../components/utils/SubscriptionTracker'; import AssistStorageEntry from '../../../../ko/components/assist/assistStorageEntry'; import dataCatalog from '../../../../catalog/dataCatalog'; import { IdentifierChainEntry, IdentifierLocation, ParsedLocation, ParsedTable, SyntaxError } from '../../../../parse/types'; import { EditorInterpreter } from '../../../../config/types'; import { hueWindow } from '../../../../types/types'; import huePubSub, { HueSubscription } from '../../../../utils/huePubSub'; import I18n from '../../../../utils/i18n'; import { ParsedSqlStatement } from '../../../../parse/sqlStatementsParser'; import { getStatementsParser } from '../../../../parse/utils'; import sqlUtils from '../../../../sql/sqlUtils'; import stringDistance from '../../../../sql/stringDistance'; import { POST_FROM_LOCATION_WORKER_EVENT, POST_FROM_SYNTAX_WORKER_EVENT, POST_TO_LOCATION_WORKER_EVENT, POST_TO_SYNTAX_WORKER_EVENT } from '../../../../sql/workers/events'; import { getFromLocalStorage } from '../../../../utils/storageUtils'; import { SqlReferenceProvider } from '../../../../sql/reference/types'; export const REFRESH_STATEMENT_LOCATIONS_EVENT = 'editor.refresh.statement.locations'; export const ACTIVE_STATEMENT_CHANGED_EVENT = 'editor.active.statement.changed'; export const CURSOR_POSITION_CHANGED_EVENT = 'editor.cursor.position.changed'; export const GET_ACTIVE_LOCATIONS_EVENT = 'get.active.editor.locations'; const HIVE_DIALECT = 'hive'; const IMPALA_DIALECT = 'impala'; const SPARKSQL_DIALECT = 'sparksql'; const STATEMENT_COUNT_AROUND_ACTIVE = 10; const VERIFY_LIMIT = 50; const VERIFY_DELAY = 50; const EXPAND_STAR_LABEL = I18n('Right-click to expand with columns'); const CONTEXT_TOOLTIP_LABEL = I18n('Right-click for details'); const isPointInside = (parseLocation: ParsedLocation, editorPosition: Ace.Position) => { const row = editorPosition.row + 1; // ace positioning has 0 based rows while the parser has 1 const column = editorPosition.column; return ( (parseLocation.first_line < row && row < parseLocation.last_line) || (parseLocation.first_line === row && row === parseLocation.last_line && parseLocation.first_column <= column && column < parseLocation.last_column) || (parseLocation.first_line === row && row < parseLocation.last_line && column >= parseLocation.first_column) || (parseLocation.first_line < row && row === parseLocation.last_line && column < parseLocation.last_column) ); }; const getFirstPosition = ( editorPositionOne: Ace.Position, editorPositionTwo: Ace.Position ): Ace.Position => { if (editorPositionOne.row === editorPositionTwo.row) { return editorPositionOne.column <= editorPositionTwo.column ? editorPositionOne : editorPositionTwo; } return editorPositionOne.row < editorPositionTwo.row ? editorPositionOne : editorPositionTwo; }; const equalPositions = (editorPositionOne: Ace.Position, editorPositionTwo: Ace.Position) => editorPositionOne.row === editorPositionTwo.row && editorPositionOne.column === editorPositionTwo.column; export type ActiveLocationHighlighting = 'error' | 'all'; export default class AceLocationHandler implements Disposable { editor: Ace.Editor; editorId: string; executor: Executor; activeLocationHighlighting: ActiveLocationHighlighting; temporaryOnly: boolean; subTracker: SubscriptionTracker = new SubscriptionTracker(); availableDatabases = new Set(); changeThrottle = -1; verifyThrottle = -1; updateTimeout = -1; cursorChangePaused = false; sqlSyntaxWorkerSub?: HueSubscription; sqlReferenceProvider?: SqlReferenceProvider; activeStatement: ParsedSqlStatement | undefined; lastKnownStatements = { editorChangeTime: 0, statements: [] }; constructor(options: { editor: Ace.Editor; editorId: string; executor: Executor; activeLocationHighlighting?: ActiveLocationHighlighting; temporaryOnly?: boolean; sqlReferenceProvider?: SqlReferenceProvider; }) { this.editor = options.editor; this.editorId = options.editorId; this.executor = options.executor; this.activeLocationHighlighting = options.activeLocationHighlighting || 'all'; this.temporaryOnly = !!options.temporaryOnly; this.sqlReferenceProvider = options.sqlReferenceProvider; this.attachStatementLocator(); this.attachSqlWorker(); this.attachMouseListeners(); this.subTracker.subscribe(this.executor.connector, this.updateAvailableDatabases.bind(this)); this.subTracker.trackTimeout(this.changeThrottle); this.subTracker.trackTimeout(this.verifyThrottle); this.subTracker.trackTimeout(this.updateTimeout); this.updateAvailableDatabases(); } private async updateAvailableDatabases() { window.clearTimeout(this.updateTimeout); if (!this.executor.namespace() || !this.executor.compute()) { this.updateTimeout = window.setTimeout(this.updateAvailableDatabases.bind(this), 300); return; } try { const children = await dataCatalog.getChildren({ connector: this.executor.connector(), namespace: this.executor.namespace(), compute: this.executor.compute(), path: [] }); this.availableDatabases.clear(); children.forEach((dbEntry: DataCatalogEntry) => { this.availableDatabases.add(dbEntry.name.toLowerCase()); }); } catch (err) {} } private isSqlDialect(): boolean { return (this.executor.connector()).is_sql; } private getDialect(): string | undefined { return this.executor.connector().dialect; } attachMouseListeners(): void { const Tooltip = ace.require('ace/tooltip').Tooltip; const AceRange = ace.require('ace/range').Range; const contextTooltip = new Tooltip(this.editor.container); let tooltipTimeout = -1; let disableTooltip = false; let lastHoveredToken: Ace.HueToken | null = null; const activeMarkers: number[] = []; let keepLastMarker = false; const hideContextTooltip = () => { clearTimeout(tooltipTimeout); contextTooltip.hide(); }; const clearActiveMarkers = () => { hideContextTooltip(); while (activeMarkers.length > (keepLastMarker ? 1 : 0)) { const marker = activeMarkers.shift(); if (typeof marker !== 'undefined') { this.editor.session.removeMarker(marker); } } }; const markLocation = (parseLocation: IdentifierLocation) => { let range; if (parseLocation.type === 'function') { // Todo: Figure out why functions need an extra char at the end range = new AceRange( parseLocation.location.first_line - 1, parseLocation.location.first_column - 1, parseLocation.location.last_line - 1, parseLocation.location.last_column ); } else { range = new AceRange( parseLocation.location.first_line - 1, parseLocation.location.first_column - 1, parseLocation.location.last_line - 1, parseLocation.location.last_column - 1 ); } activeMarkers.push(this.editor.session.addMarker(range, 'hue-ace-location')); return range; }; this.subTracker.subscribe('context.popover.shown', () => { hideContextTooltip(); keepLastMarker = true; disableTooltip = true; }); this.subTracker.subscribe('context.popover.hidden', () => { disableTooltip = false; clearActiveMarkers(); keepLastMarker = false; }); const mousemoveListener = this.editor.on('mousemove', e => { clearTimeout(tooltipTimeout); const selectionRange = this.editor.selection.getRange(); if (selectionRange.isEmpty()) { const pointerPosition = this.editor.renderer.screenToTextCoordinates( e.clientX + 5, e.clientY ); const endTestPosition = this.editor.renderer.screenToTextCoordinates( e.clientX + 15, e.clientY ); if (endTestPosition.column !== pointerPosition.column) { const token = this.editor.session.getTokenAt(pointerPosition.row, pointerPosition.column); if ( this.activeLocationHighlighting === 'all' && token !== null && !token.notFound && token.parseLocation && !disableTooltip && token.parseLocation.type !== 'alias' ) { tooltipTimeout = window.setTimeout(() => { if (token.parseLocation) { const endCoordinates = this.editor.renderer.textToScreenCoordinates( pointerPosition.row, token.start || 0 ); let tooltipText = token.parseLocation.type === 'asterisk' ? EXPAND_STAR_LABEL : CONTEXT_TOOLTIP_LABEL; let colType; if (token.parseLocation.type === 'column') { const tableChain = [...(token.parseLocation.identifierChain || [])]; const lastIdentifier = tableChain.pop(); if (tableChain.length > 0 && lastIdentifier && lastIdentifier.name) { const colName = lastIdentifier.name.toLowerCase(); // Note, as cachedOnly is set to true it will call the successCallback right away (or not at all) dataCatalog .getEntry({ namespace: this.executor.namespace(), compute: this.executor.compute(), connector: this.executor.connector(), temporaryOnly: this.temporaryOnly, path: tableChain.map(identifier => identifier.name) }) .then(entry => { entry .getSourceMeta({ cachedOnly: true, silenceErrors: true }) .then(sourceMeta => { if (sourceMeta && (sourceMeta).extended_columns) { (sourceMeta).extended_columns.every( (col: { name: string; type: string }) => { if (col.name.toLowerCase() === colName) { colType = (col.type.match(/^[^<]*/g) || ['T'])[0]; return false; } return true; } ); } }); }); } } if (token.parseLocation.identifierChain) { let sqlIdentifier = token.parseLocation.identifierChain .map(identifier => identifier.name) .join('.'); if (colType) { sqlIdentifier += ' (' + colType + ')'; } tooltipText = sqlIdentifier + ' - ' + tooltipText; } else if (token.parseLocation.function) { tooltipText = token.parseLocation.function + ' - ' + tooltipText; } contextTooltip.show( tooltipText, endCoordinates.pageX, endCoordinates.pageY + this.editor.renderer.lineHeight + 3 ); } }, 500); } else if (token !== null && token.notFound) { tooltipTimeout = window.setTimeout(() => { // TODO: i18n if (token.notFound && token.syntaxError) { let tooltipText; if (token.syntaxError.expected.length > 0) { tooltipText = I18n('Did you mean') + ' "' + token.syntaxError.expected[0].text + '"?'; } else { tooltipText = I18n('Could not find') + ' "' + (token.qualifiedIdentifier || token.value) + '"'; } const endCoordinates = this.editor.renderer.textToScreenCoordinates( pointerPosition.row, token.start || 0 ); contextTooltip.show( tooltipText, endCoordinates.pageX, endCoordinates.pageY + this.editor.renderer.lineHeight + 3 ); } }, 500); } else if (token !== null && token.syntaxError) { tooltipTimeout = window.setTimeout(() => { if (token.syntaxError) { let tooltipText; if (token.syntaxError.expected.length > 0) { tooltipText = I18n('Did you mean') + ' "' + token.syntaxError.expected[0].text + '"?'; } else if (token.syntaxError.expectedStatementEnd) { tooltipText = I18n('Expected end of statement'); } if (tooltipText) { const endCoordinates = this.editor.renderer.textToScreenCoordinates( pointerPosition.row, token.start || 0 ); contextTooltip.show( tooltipText, endCoordinates.pageX, endCoordinates.pageY + this.editor.renderer.lineHeight + 3 ); } } }, 500); } else { hideContextTooltip(); } if (lastHoveredToken !== token) { clearActiveMarkers(); if ( this.activeLocationHighlighting === 'all' && token !== null && !token.notFound && token.parseLocation && ['alias', 'whereClause', 'limitClause', 'selectList'].indexOf( token.parseLocation.type ) === -1 ) { markLocation(token.parseLocation); } lastHoveredToken = token; } } else { clearActiveMarkers(); lastHoveredToken = null; } } }); this.subTracker.addDisposable({ dispose: () => this.editor.off('mousemove', mousemoveListener) }); const inputListener = this.editor.on('input', () => { clearActiveMarkers(); lastHoveredToken = null; }); this.subTracker.addDisposable({ dispose: () => this.editor.off('input', inputListener) }); const mouseoutListener = function () { clearActiveMarkers(); clearTimeout(tooltipTimeout); contextTooltip.hide(); lastHoveredToken = null; }; this.editor.container.addEventListener('mouseout', mouseoutListener); this.subTracker.addDisposable({ dispose: () => this.editor.container.removeEventListener('mouseout', mouseoutListener) }); const onContextMenu = (e: { clientX: number; clientY: number; preventDefault: () => void }) => { const selectionRange = this.editor.selection.getRange(); huePubSub.publish('context.popover.hide'); huePubSub.publish(SQL_SYNTAX_DROPDOWN_HIDE_TOPIC); if (selectionRange.isEmpty()) { const pointerPosition = this.editor.renderer.screenToTextCoordinates( e.clientX + 5, e.clientY ); const token = this.editor.session.getTokenAt(pointerPosition.row, pointerPosition.column); if ( token && ((token.parseLocation && ['alias', 'whereClause', 'limitClause', 'selectList'].indexOf( token.parseLocation.type ) === -1) || token.syntaxError) ) { let range: Ace.Range | undefined = undefined; if ( token.parseLocation && (this.activeLocationHighlighting === 'all' || token.notFound) ) { range = markLocation(token.parseLocation); } else if (token.syntaxError) { range = new AceRange( token.syntaxError.loc.first_line - 1, token.syntaxError.loc.first_column, token.syntaxError.loc.last_line - 1, token.syntaxError.loc.first_column + token.syntaxError.text.length ); } if (!range) { return; } const startCoordinates = this.editor.renderer.textToScreenCoordinates( range.start.row, range.start.column ); const endCoordinates = this.editor.renderer.textToScreenCoordinates( range.end.row, range.end.column ); const source = { // TODO: add element likely in the event left: startCoordinates.pageX - 3, top: startCoordinates.pageY, right: endCoordinates.pageX - 3, bottom: endCoordinates.pageY + this.editor.renderer.lineHeight }; if (token.parseLocation && token.parseLocation.identifierChain && !token.notFound) { token.parseLocation .resolveCatalogEntry({ temporaryOnly: this.temporaryOnly }) .then(entry => { huePubSub.publish('context.popover.show', { data: { type: 'catalogEntry', catalogEntry: entry }, pinEnabled: true, connector: this.executor.connector(), source: source }); }) .catch(() => { token.notFound = true; }); } else if (token.parseLocation && !token.notFound) { const parseLocation = token.parseLocation; // Asterisk, function etc. if (parseLocation.type === 'file' && parseLocation.path) { AssistStorageEntry.getEntry(parseLocation.path).then(entry => { (<{ open: KnockoutObservable }>(entry)).open(true); huePubSub.publish('context.popover.show', { data: { type: 'storageEntry', storageEntry: entry, editorLocation: parseLocation.location }, connector: this.executor.connector(), pinEnabled: true, source: source }); }); } else { huePubSub.publish('context.popover.show', { data: parseLocation, connector: this.executor.connector(), sourceType: this.executor.connector().dialect, namespace: this.executor.namespace(), compute: this.executor.compute(), defaultDatabase: this.executor.database(), pinEnabled: true, source: source }); } } else if (token.syntaxError) { huePubSub.publish(SQL_SYNTAX_DROPDOWN_SHOW_TOPIC, { editorId: this.editorId, data: token.syntaxError, editor: this.editor, range, sourceType: this.executor.connector().dialect, defaultDatabase: this.executor.database(), source }); } e.preventDefault(); return false; } } }; this.editor.container.addEventListener('contextmenu', onContextMenu); this.subTracker.addDisposable({ dispose: () => this.editor.container.removeEventListener('contextmenu', onContextMenu) }); } attachStatementLocator(): void { this.changeThrottle = window.setTimeout(this.parseForStatements.bind(this), 0); window.setTimeout(this.updateActiveStatement.bind(this), 0); this.cursorChangePaused = false; // On change the cursor is also moved, this limits the calls while typing let lastStart: Ace.Position; let lastEnd: Ace.Position; let lastCursorPosition: Ace.Position; const changeSelectionListener = this.editor.on('changeSelection', () => { if (this.cursorChangePaused) { return; } window.clearTimeout(this.changeThrottle); this.changeThrottle = window.setTimeout(() => { const newCursorPosition = this.editor.getCursorPosition(); if ( !lastCursorPosition || lastCursorPosition.row !== newCursorPosition.row || lastCursorPosition.column !== newCursorPosition.column ) { huePubSub.publish(CURSOR_POSITION_CHANGED_EVENT, { editorId: this.editorId, position: newCursorPosition }); lastCursorPosition = newCursorPosition; } // The active statement is initially the top one in the selection, batch execution updates this. const newStart = this.editor.getSelectionRange().start; const newEnd = this.editor.getSelectionRange().end; if ( this.isSqlDialect() && (!lastStart || !equalPositions(lastStart, newStart) || !lastEnd || !equalPositions(lastEnd, newEnd)) ) { this.updateActiveStatement(true); lastStart = newStart; lastEnd = newEnd; } }, 100); }); this.subTracker.addDisposable({ dispose: () => this.editor.off('changeSelection', changeSelectionListener) }); const changeListener = this.editor.on('change', () => { if (this.isSqlDialect()) { window.clearTimeout(this.changeThrottle); this.cursorChangePaused = true; this.changeThrottle = window.setTimeout(() => { this.parseForStatements(); this.updateActiveStatement(); this.cursorChangePaused = false; }, 500); this.editor.lastChangeTime = Date.now(); } }); this.subTracker.addDisposable({ dispose: () => this.editor.off('change', changeListener) }); this.subTracker.subscribe(REFRESH_STATEMENT_LOCATIONS_EVENT, editorId => { if (editorId === this.editorId) { this.refreshStatementLocations(); } }); } // eslint-disable-next-line @typescript-eslint/no-unused-vars updateActiveStatement(cursorChange?: boolean): void { if (!this.isSqlDialect()) { return; } const selectionRange = this.editor.getSelectionRange(); const cursorLocation = selectionRange.start; if (!equalPositions(selectionRange.start, selectionRange.end)) { // TODO: Figure out what this does and why it needs the result.statement_range // if (!cursorChange && this.snippet.result && this.snippet.result.statement_range()) { // let executingStatement = this.snippet.result.statement_range(); // // Row and col are 0 for both start and end on execute, so if the selection hasn't changed we'll use last known executed statement // if ( // executingStatement.start.row === 0 && // executingStatement.start.column === 0 && // executingStatement.end.row === 0 && // executingStatement.end.column === 0 && // lastExecutingStatement // ) { // executingStatement = lastExecutingStatement; // } // if (executingStatement.start.row === 0) { // cursorLocation.column += executingStatement.start.column; // } else if (executingStatement.start.row !== 0 || executingStatement.start.column !== 0) { // cursorLocation.row += executingStatement.start.row; // cursorLocation.column = executingStatement.start.column; // } // lastExecutingStatement = executingStatement; // } else { // lastExecutingStatement = null; // } } const selectedStatements: ParsedSqlStatement[] = []; const precedingStatements: ParsedSqlStatement[] = []; const followingStatements: ParsedSqlStatement[] = []; this.activeStatement = undefined; const firstSelectionPoint = getFirstPosition(selectionRange.start, selectionRange.end); const lastSelectionPoint = selectionRange.start === firstSelectionPoint ? selectionRange.end : selectionRange.start; let found = false; let statementIndex = 0; let insideSelection = false; if (this.lastKnownStatements.statements.length === 1) { this.activeStatement = this.lastKnownStatements.statements[0]; } else { this.lastKnownStatements.statements.forEach(statement => { if (!equalPositions(firstSelectionPoint, lastSelectionPoint)) { if (!insideSelection && isPointInside(statement.location, firstSelectionPoint)) { insideSelection = true; } if (insideSelection) { selectedStatements.push(statement); if ( isPointInside(statement.location, lastSelectionPoint) || (statement.location.last_line === lastSelectionPoint.row + 1 && statement.location.last_column === lastSelectionPoint.column) ) { insideSelection = false; } } } if (isPointInside(statement.location, cursorLocation)) { statementIndex++; found = true; this.activeStatement = statement; } else if (!found) { statementIndex++; if (precedingStatements.length === STATEMENT_COUNT_AROUND_ACTIVE) { precedingStatements.shift(); } precedingStatements.push(statement); } else if (found && followingStatements.length < STATEMENT_COUNT_AROUND_ACTIVE) { followingStatements.push(statement); } }); // Can happen if multiple statements and the cursor is after the last one if (!found) { precedingStatements.pop(); this.activeStatement = this.lastKnownStatements.statements[this.lastKnownStatements.statements.length - 1]; } } if (!selectedStatements.length && this.activeStatement) { selectedStatements.push(this.activeStatement); } huePubSub.publish(ACTIVE_STATEMENT_CHANGED_EVENT, { id: this.editorId, editorChangeTime: this.lastKnownStatements.editorChangeTime, activeStatementIndex: statementIndex, totalStatementCount: this.lastKnownStatements.statements.length, precedingStatements: precedingStatements, activeStatement: this.activeStatement, selectedStatements: selectedStatements, followingStatements: followingStatements }); if (this.activeStatement) { this.checkForSyntaxErrors(this.activeStatement.location, firstSelectionPoint); } } parseForStatements(): void { if (this.isSqlDialect()) { try { const lastChangeTime = this.editor.lastChangeTime; this.lastKnownStatements.statements = getStatementsParser(this.executor.connector()).parse( this.editor.getValue() ); this.lastKnownStatements.editorChangeTime = lastChangeTime; const hueDebug = (window).hueDebug; if (hueDebug && hueDebug.logStatementLocations) { // eslint-disable-next-line no-restricted-syntax console.log(this.lastKnownStatements); } } catch (error) { console.warn('Could not parse statements!'); console.warn(error); } } } refreshStatementLocations(): void { this.cursorChangePaused = true; window.clearTimeout(this.changeThrottle); this.parseForStatements(); this.updateActiveStatement(); this.cursorChangePaused = false; } clearMarkedErrors(type?: string): void { const markers = this.editor.getSession().$backMarkers; for (const markerId in markers) { if (markers[markerId].clazz.indexOf('hue-ace-syntax-' + (type || '')) === 0) { markers[markerId].dispose(); } } } checkForSyntaxErrors(statementLocation: ParsedLocation, cursorPosition: Ace.Position): void { if ( this.sqlSyntaxWorkerSub && (this.getDialect() === IMPALA_DIALECT || this.getDialect() === HIVE_DIALECT || this.getDialect() === SPARKSQL_DIALECT) ) { const AceRange = ace.require('ace/range').Range; const editorChangeTime = this.editor.lastChangeTime; const beforeCursor = this.editor .getSession() .getTextRange( new AceRange( statementLocation.first_line - 1, statementLocation.first_column, cursorPosition.row, cursorPosition.column ) ); const afterCursor = this.editor .getSession() .getTextRange( new AceRange( cursorPosition.row, cursorPosition.column, statementLocation.last_line - 1, statementLocation.last_column ) ); huePubSub.publish(POST_TO_SYNTAX_WORKER_EVENT, { id: this.editorId, editorChangeTime: editorChangeTime, beforeCursor: beforeCursor, afterCursor: afterCursor, statementLocation: statementLocation, connector: this.executor.connector() }); } } addAnchoredMarker(range: Ace.Range, token: Ace.HueToken, clazz: string): void { range.start = this.editor.getSession().doc.createAnchor(range.start); range.end = this.editor.getSession().doc.createAnchor(range.end); const markerId = this.editor.getSession().addMarker(range, clazz); const marker = this.editor.getSession().$backMarkers[markerId]; marker.token = token; marker.dispose = () => { (range.start).detach(); (range.end).detach(); delete marker.token.syntaxError; delete marker.token.notFound; this.editor.getSession().removeMarker(markerId); }; } attachSqlSyntaxWorker(): void { if (this.sqlSyntaxWorkerSub) { return; } this.sqlSyntaxWorkerSub = huePubSub.subscribe(POST_FROM_SYNTAX_WORKER_EVENT, e => { if (e.data.id !== this.editorId || e.data.editorChangeTime !== this.editor.lastChangeTime) { return; } this.clearMarkedErrors('error'); if ( !e.data.syntaxError || !e.data.syntaxError.expected || e.data.syntaxError.expected.length === 0 ) { // Only show errors that we have suggestions for return; } const suppressedRules = getFromLocalStorage<{ [key: string]: boolean }>( 'hue.syntax.checker.suppressedRules', {} ); if ( e.data.syntaxError && e.data.syntaxError.ruleId && !suppressedRules[ e.data.syntaxError.ruleId.toString() + e.data.syntaxError.text.toLowerCase() ] ) { // TODO: Figure out why this is needed. // if ( // this.snippet.positionStatement() && // sqlUtils.locationEquals( // e.data.statementLocation, // this.snippet.positionStatement().location // ) // ) { // this.snippet.positionStatement().syntaxError = true; // } const hueDebug = (window).hueDebug; if (hueDebug && hueDebug.showSyntaxParseResult) { // eslint-disable-next-line no-restricted-syntax console.log(e.data.syntaxError); } const token = this.editor .getSession() .getTokenAt( e.data.syntaxError.loc.first_line - 1, e.data.syntaxError.loc.first_column + 1 ); // Don't mark the current edited word as an error if the cursor is at the end of the word // For now [a-z] is fine as we only check syntax for keywords if ( /[a-z]$/i.test(this.editor.getTextBeforeCursor()) && !/^[a-z]/i.test(this.editor.getTextAfterCursor()) ) { const cursorPos = this.editor.getCursorPosition(); const cursorToken = this.editor.getSession().getTokenAt(cursorPos.row, cursorPos.column); if (cursorToken === token) { return; } } // If no token is found it likely means that the parser response came back after the text was changed, // at which point it will trigger another parse so we can ignore this. if (token) { token.syntaxError = e.data.syntaxError; const AceRange = ace.require('ace/range').Range; const range = new AceRange( e.data.syntaxError.loc.first_line - 1, e.data.syntaxError.loc.first_column, e.data.syntaxError.loc.last_line - 1, e.data.syntaxError.loc.first_column + e.data.syntaxError.text.length ); this.addAnchoredMarker(range, token, 'hue-ace-syntax-error'); } } }); huePubSub.publish(REFRESH_STATEMENT_LOCATIONS_EVENT, this.editorId); } detachSqlSyntaxWorker(): void { if (this.sqlSyntaxWorkerSub) { this.sqlSyntaxWorkerSub.remove(); this.sqlSyntaxWorkerSub = undefined; } this.clearMarkedErrors(); } async fetchChildren(identifierChain: IdentifierChainEntry[]): Promise { return dataCatalog.getChildren({ connector: this.executor.connector(), namespace: this.executor.namespace(), compute: this.executor.compute(), temporaryOnly: this.temporaryOnly, path: identifierChain.map(identifier => identifier.name), silenceErrors: true, cachedOnly: true }); } async fetchPossibleValues( token: Ace.HueToken ): Promise<(DataCatalogEntry | IdentifierChainEntry)[]> { if ( token.parseLocation && token.parseLocation.tables && token.parseLocation.tables.length > 0 ) { const tablePromises: Promise[] = []; token.parseLocation.tables.forEach(table => { if (table.identifierChain) { tablePromises.push(this.fetchChildren(table.identifierChain)); } }); const children = await Promise.all(tablePromises); const joined: (DataCatalogEntry | IdentifierChainEntry)[] = []; children.forEach(childEntries => { joined.push(...childEntries); }); if ( token.parseLocation && token.parseLocation.type === 'column' && token.parseLocation.tables ) { // Could be a table reference token.parseLocation.tables.forEach(table => { if (!table.alias) { // Aliases are added later joined.push(table.identifierChain[table.identifierChain.length - 1]); } }); } return joined; } if ( token.parseLocation && token.parseLocation.identifierChain && token.parseLocation.identifierChain.length ) { // fetch the parent try { return await this.fetchChildren( token.parseLocation.identifierChain.slice( 0, token.parseLocation.identifierChain.length - 1 ) ); } catch (err) {} } return []; } verifyExists(tokens: Ace.HueToken[], allLocations: IdentifierLocation[]): void { window.clearTimeout(this.verifyThrottle); this.clearMarkedErrors('warning'); if (!this.sqlSyntaxWorkerSub) { return; } const cursorPos = this.editor.getCursorPosition(); const tokensToVerify = tokens .filter(token => { return ( token && token.parseLocation && (token.parseLocation.type === 'table' || token.parseLocation.type === 'column') && (token.parseLocation.identifierChain || token.parseLocation.tables) && !( cursorPos.row + 1 === token.parseLocation.location.last_line && cursorPos.column + 1 === token.parseLocation.location.first_column + token.value.length ) ); }) .slice(0, VERIFY_LIMIT); if (tokensToVerify.length === 0) { return; } const aliasIndex: { [alias: string]: IdentifierLocation } = {}; const aliases: { name: string }[] = []; allLocations.forEach(location => { if ( location.type === 'alias' && location.alias && (location.source === 'column' || location.source === 'table' || location.source === 'subquery' || location.source === 'cte') ) { aliasIndex[location.alias.toLowerCase()] = location; aliases.push({ name: location.alias.toLowerCase() }); } }); const resolvePathFromTables = (location: IdentifierLocation): Promise => { return new Promise(resolve => { if ( location.type === 'column' && location.tables && location.identifierChain && location.identifierChain.length === 1 ) { const findIdentifierChainInTable = (tablesToGo: ParsedTable[]) => { const nextTable = tablesToGo.shift(); if (nextTable && !nextTable.subQuery) { dataCatalog .getChildren({ connector: this.executor.connector(), namespace: this.executor.namespace(), compute: this.executor.compute(), temporaryOnly: this.temporaryOnly, path: nextTable.identifierChain.map(identifier => identifier.name), cachedOnly: true, silenceErrors: true }) .then((entries: DataCatalogEntry[]) => { const containsColumn = entries.some( entry => location.identifierChain && sqlUtils.identifierEquals(entry.name, location.identifierChain[0].name) ); if (containsColumn) { location.identifierChain = [ ...nextTable.identifierChain, ...(location.identifierChain || []) ]; delete location.tables; resolve(); } else if (tablesToGo.length) { findIdentifierChainInTable(tablesToGo); } else { resolve(); } }) .catch(() => resolve()); } else if (tablesToGo.length > 0) { findIdentifierChainInTable(tablesToGo); } else { resolve(); } }; if (location.tables.length > 1) { findIdentifierChainInTable([...location.tables]); } else if (location.tables.length === 1 && location.tables[0].identifierChain) { location.identifierChain = [ ...location.tables[0].identifierChain, ...location.identifierChain ]; delete location.tables; resolve(); } } else { resolve(); } }); }; const verify = () => { const token = tokensToVerify.shift(); if (!token) { return; } const location = token.parseLocation; if (!location) { return; } // TODO: Verify columns in sub queries, i.e. 'code' in 'select code from (select * from web_logs) wl, customers c;' if ((location.type === 'column' || location.type === 'complex') && location.tables) { const hasSubQueries = location.tables.some(table => !!table.subQuery); if (hasSubQueries) { this.verifyThrottle = window.setTimeout(verify, VERIFY_DELAY); return; } } resolvePathFromTables(location) .then(() => { if (location.type === 'column') { let possibleAlias; if ( location.tables && location.identifierChain && location.identifierChain.length > 1 && token.parseLocation && token.parseLocation.identifierChain ) { possibleAlias = aliasIndex[token.parseLocation.identifierChain[0].name.toLowerCase()]; } else if (location.tables) { location.tables.some(table => { if ( table.identifierChain && table.identifierChain.length === 1 && table.identifierChain[0].name ) { possibleAlias = aliasIndex[table.identifierChain[0].name.toLowerCase()]; return possibleAlias; } return false; }); } if (possibleAlias && possibleAlias.source === 'cte') { // We currently don't discover the columns from a CTE so we can't say if a column exists or not this.verifyThrottle = window.setTimeout(verify, VERIFY_DELAY); return; } } this.fetchPossibleValues(token) .then(async possibleValues => { // Tokens might change while making api calls if (!token.parseLocation) { this.verifyThrottle = window.setTimeout(verify, VERIFY_DELAY); return; } // Append aliases unless qualified i.e.for 'b' in SELECT a.b we shouldn't suggest aliases if ( (token.parseLocation.type !== 'column' && token.parseLocation.type !== 'complex') || !token.parseLocation.qualified ) { possibleValues = possibleValues.concat(aliases); } const tokenValLower = token.actualValue.toLowerCase(); const uniqueSet = new Set(); const uniqueValues: IdentifierChainEntry[] = []; for (let i = 0; i < possibleValues.length; i++) { const entry = possibleValues[i]; entry.name = await sqlUtils.backTickIfNeeded( this.executor.connector(), entry.name, this.sqlReferenceProvider ); const nameLower = entry.name.toLowerCase(); if ( nameLower === tokenValLower || (tokenValLower.indexOf('`') === 0 && tokenValLower.replace(/`/g, '') === nameLower) ) { // Break if found this.verifyThrottle = window.setTimeout(verify, VERIFY_DELAY); return; } if (!uniqueSet.has(nameLower)) { uniqueValues.push(entry); uniqueSet.add(nameLower); } } possibleValues = uniqueValues; const isLowerCase = tokenValLower === token.value; const weightedExpected = possibleValues.map(entry => { const name = (entry).name; return { text: isLowerCase ? name.toLowerCase() : name, distance: stringDistance(token.value, name) }; }); weightedExpected.sort((a, b) => a.distance === b.distance ? a.text.localeCompare(b.text) : a.distance - b.distance ); token.syntaxError = { loc: token.parseLocation.location, text: token.value, expected: weightedExpected.slice(0, 50) }; token.notFound = true; if ( token.parseLocation && token.parseLocation.type === 'table' && token.parseLocation.identifierChain ) { token.qualifiedIdentifier = token.parseLocation.identifierChain .map(identifier => identifier.name) .join('.'); } if (token.parseLocation && weightedExpected.length > 0) { const AceRange = ace.require('ace/range').Range; const range = new AceRange( token.parseLocation.location.first_line - 1, token.parseLocation.location.first_column - 1, token.parseLocation.location.last_line - 1, token.parseLocation.location.last_column - 1 ); this.addAnchoredMarker(range, token, 'hue-ace-syntax-warning'); } this.verifyThrottle = window.setTimeout(verify, VERIFY_DELAY); }) .catch(() => { // Can happen when tables aren't cached etc. this.verifyThrottle = window.setTimeout(verify, VERIFY_DELAY); }); }) .catch(() => { // Can happen when tables aren't cached etc. this.verifyThrottle = window.setTimeout(verify, VERIFY_DELAY); }); }; this.verifyThrottle = window.setTimeout(verify, VERIFY_DELAY); } isDatabase(databaseIdentifier: string): boolean { if (!databaseIdentifier) { return false; } const cleanIdentifier = databaseIdentifier .replace(/^\s*`/, '') .replace(/`\s*$/, '') .toLowerCase(); return this.availableDatabases.has(cleanIdentifier); } attachSqlWorker(): void { const activeTokens: Ace.HueToken[] = []; let lastKnownLocations = {}; this.subTracker.subscribe(GET_ACTIVE_LOCATIONS_EVENT, callback => { callback(lastKnownLocations); }); this.subTracker.subscribe( POST_FROM_LOCATION_WORKER_EVENT, (e: { data: { id: string; editorChangeTime: number; locations: IdentifierLocation[]; activeStatementLocations: IdentifierLocation[]; totalStatementCount: number; activeStatementIndex: number; precedingStatements: IdentifierLocation[]; activeStatement: IdentifierLocation; selectedStatements: IdentifierLocation[]; followingStatements: IdentifierLocation[]; }; }) => { if ( e.data.id !== this.editorId || e.data.editorChangeTime !== this.editor.lastChangeTime || !this.isSqlDialect() ) { return; } lastKnownLocations = { id: this.editorId, connector: this.executor.connector(), namespace: this.executor.namespace(), compute: this.executor.compute(), defaultDatabase: this.executor.database(), locations: e.data.locations, editorChangeTime: e.data.editorChangeTime, activeStatementLocations: e.data.activeStatementLocations, totalStatementCount: e.data.totalStatementCount, activeStatementIndex: e.data.activeStatementIndex }; // Clear out old parse locations to prevent them from being shown when there's a syntax error in the statement while (activeTokens.length > 0) { const activeToken = activeTokens.pop(); if (activeToken) { delete activeToken.parseLocation; } } const tokensToVerify: Ace.HueToken[] = []; e.data.locations.forEach(location => { if (location.type === 'statementType' && this.getDialect() !== IMPALA_DIALECT) { // We currently only have a good mapping from statement types to impala topics. // TODO: Extract links between Hive topic IDs and statement types return; } if ( ['statement', 'selectList', 'whereClause', 'limitClause'].indexOf(location.type) !== -1 || ((location.type === 'table' || location.type === 'column') && typeof location.identifierChain === 'undefined') ) { return; } if ( location.identifierChain && location.identifierChain.length && location.identifierChain[0].name ) { // The parser isn't aware of the DDL so sometimes it marks complex columns as tables // I.e. "Impala SELECT a FROM b.c" Is 'b' a database or a table? If table then 'c' is complex if ( this.getDialect() === IMPALA_DIALECT && location.identifierChain.length > 2 && (location.type === 'table' || location.type === 'column') && this.isDatabase(location.identifierChain[0].name) ) { location.type = 'complex'; } } let token = this.editor .getSession() .getTokenAt(location.location.first_line - 1, location.location.first_column); // Find open UDFs and prevent them from being marked as missing columns, i.e. cos in "SELECT * FROM foo where cos(a|" const rowTokens = this.editor.getSession().getTokens(location.location.first_line - 1); if (location.type === 'column' && token && rowTokens) { let tokenFound = false; let isFunction = false; rowTokens.some(rowToken => { if (tokenFound && /\s+/.test(rowToken.value)) { return false; } if (tokenFound) { isFunction = rowToken.value === '('; return true; } if (rowToken === token) { tokenFound = true; } }); if (isFunction) { location.type = 'function'; delete location.identifierChain; location.function = token.value; token = null; } } if (token && token.value && /`$/.test(token.value)) { // Ace getTokenAt() thinks the first ` is a token, column +1 will include the first and last. token = this.editor .getSession() .getTokenAt(location.location.first_line - 1, location.location.first_column + 1); } if (token && token.value && /^\s*\${\s*$/.test(token.value)) { token = null; } if (token && token.value) { const AceRange = ace.require('ace/range').Range; // The Ace tokenizer also splits on '{', '(' etc. hence the actual value; token.actualValue = this.editor .getSession() .getTextRange( new AceRange( location.location.first_line - 1, location.location.first_column - 1, location.location.last_line - 1, location.location.last_column - 1 ) ); } if (token !== null) { token.parseLocation = location; activeTokens.push(token); delete token.notFound; delete token.syntaxError; if (location.active) { tokensToVerify.push(token); } } }); if ( this.getDialect() === IMPALA_DIALECT || this.getDialect() === HIVE_DIALECT || this.getDialect() === SPARKSQL_DIALECT ) { this.verifyExists(tokensToVerify, e.data.activeStatementLocations); } huePubSub.publish('editor.active.locations', lastKnownLocations); } ); this.subTracker.subscribe('editor.active.statement.changed', statementDetails => { if (statementDetails.id !== this.editorId) { return; } if (this.isSqlDialect()) { huePubSub.publish(POST_TO_LOCATION_WORKER_EVENT, { id: this.editorId, statementDetails: statementDetails, connector: this.executor.connector(), namespace: this.executor.namespace(), compute: this.executor.compute(), defaultDatabase: this.executor.database() }); } }); } dispose(): void { this.subTracker.dispose(); this.detachSqlSyntaxWorker(); } }