UNPKG

gethue

Version:

Hue is an Open source SQL Query Editor for Databases/Warehouses

1,391 lines (1,274 loc) 50 kB
// 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 $ from 'jquery'; import ace from '../../../ext/aceHelper'; import { DIALECT } from '../../../apps/editor/snippet'; import dataCatalog from '../../../catalog/dataCatalog'; import AssistStorageEntry from '../../../ko/components/assist/assistStorageEntry'; import { getStatementsParser } from '../../../parse/utils'; import sqlUtils from '../../../sql/sqlUtils'; 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 stringDistance from '../../../sql/stringDistance'; import hueDebug from '../../../utils/hueDebug'; import huePubSub from '../../../utils/huePubSub'; import I18n from '../../../utils/i18n'; import { getFromLocalStorage } from '../../../utils/storageUtils'; // TODO: depends on Ace, sqlStatementsParser 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'; const STATEMENT_COUNT_AROUND_ACTIVE = 10; const VERIFY_LIMIT = 50; const VERIFY_DELAY = 50; class AceLocationHandler { constructor(options) { const self = this; self.editor = options.editor; self.editorId = options.editorId; self.snippet = options.snippet; self.executor = options.executor; self.expandStar = (options.i18n && options.i18n.expandStar) || 'Right-click to expand with columns'; self.contextTooltip = (options.i18n && options.i18n.contextTooltip) || 'Right-click for details'; self.sqlSyntaxWorkerSub = null; self.disposeFunctions = []; self.databaseIndex = {}; self.attachStatementLocator(); self.attachSqlWorker(); self.attachMouseListeners(); self.dialect = () => self.snippet.type(); self.verifyThrottle = -1; const updateDatabaseIndex = function (databaseList) { self.databaseIndex = {}; databaseList.forEach(database => { self.databaseIndex[database.toLowerCase()] = true; }); }; const databaseSub = self.snippet.availableDatabases.subscribe(updateDatabaseIndex); self.disposeFunctions.push(() => { databaseSub.dispose(); }); updateDatabaseIndex(self.snippet.availableDatabases()); } attachMouseListeners() { const self = this; const Tooltip = ace.require('ace/tooltip').Tooltip; const AceRange = ace.require('ace/range').Range; const contextTooltip = new Tooltip(self.editor.container); let tooltipTimeout = -1; let disableTooltip = false; let lastHoveredToken = null; const activeMarkers = []; let keepLastMarker = false; const hideContextTooltip = function () { clearTimeout(tooltipTimeout); contextTooltip.hide(); }; const clearActiveMarkers = function () { hideContextTooltip(); while (activeMarkers.length > keepLastMarker ? 1 : 0) { self.editor.session.removeMarker(activeMarkers.shift()); } }; const markLocation = function (parseLocation) { 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(self.editor.session.addMarker(range, 'hue-ace-location')); return range; }; const popoverShownSub = huePubSub.subscribe('context.popover.shown', () => { hideContextTooltip(); keepLastMarker = true; disableTooltip = true; }); self.disposeFunctions.push(() => { popoverShownSub.remove(); }); const popoverHiddenSub = huePubSub.subscribe('context.popover.hidden', () => { disableTooltip = false; clearActiveMarkers(); keepLastMarker = false; }); self.disposeFunctions.push(() => { popoverHiddenSub.remove(); }); const mousemoveListener = self.editor.on('mousemove', e => { clearTimeout(tooltipTimeout); const selectionRange = self.editor.selection.getRange(); if (selectionRange.isEmpty()) { const pointerPosition = self.editor.renderer.screenToTextCoordinates( e.clientX + 5, e.clientY ); const endTestPosition = self.editor.renderer.screenToTextCoordinates( e.clientX + 15, e.clientY ); if (endTestPosition.column !== pointerPosition.column) { const token = self.editor.session.getTokenAt(pointerPosition.row, pointerPosition.column); if ( token !== null && !token.notFound && token.parseLocation && !disableTooltip && token.parseLocation.type !== 'alias' ) { tooltipTimeout = window.setTimeout(() => { if (token.parseLocation) { const endCoordinates = self.editor.renderer.textToScreenCoordinates( pointerPosition.row, token.start ); let tooltipText = token.parseLocation.type === 'asterisk' ? self.expandStar : self.contextTooltip; let colType; if (token.parseLocation.type === 'column') { const tableChain = token.parseLocation.identifierChain.concat(); 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: self.snippet.namespace(), compute: self.snippet.compute(), connector: self.snippet.connector(), temporaryOnly: self.snippet.autocompleteSettings.temporaryOnly, path: $.map(tableChain, identifier => { return identifier.name; }) }) .then(entry => { entry .getSourceMeta({ cachedOnly: true, silenceErrors: true }) .then(sourceMeta => { if (sourceMeta && sourceMeta.extended_columns) { sourceMeta.extended_columns.every(col => { if (col.name.toLowerCase() === colName) { colType = col.type.match(/^[^<]*/g)[0]; return false; } return true; }); } }) .catch(() => { // Ignore }); }) .catch(err => { // Ignore }); } } if (token.parseLocation.identifierChain) { let sqlIdentifier = $.map(token.parseLocation.identifierChain, identifier => { return 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 + self.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 = self.editor.renderer.textToScreenCoordinates( pointerPosition.row, token.start ); contextTooltip.show( tooltipText, endCoordinates.pageX, endCoordinates.pageY + self.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 = self.editor.renderer.textToScreenCoordinates( pointerPosition.row, token.start ); contextTooltip.show( tooltipText, endCoordinates.pageX, endCoordinates.pageY + self.editor.renderer.lineHeight + 3 ); } } }, 500); } else { hideContextTooltip(); } if (lastHoveredToken !== token) { clearActiveMarkers(); if ( token !== null && !token.notFound && token.parseLocation && ['alias', 'whereClause', 'limitClause', 'selectList'].indexOf( token.parseLocation.type ) === -1 ) { markLocation(token.parseLocation); } lastHoveredToken = token; } } else { clearActiveMarkers(); lastHoveredToken = null; } } }); self.disposeFunctions.push(() => { self.editor.off('mousemove', mousemoveListener); }); const inputListener = self.editor.on('input', () => { clearActiveMarkers(); lastHoveredToken = null; }); self.disposeFunctions.push(() => { self.editor.off('input', inputListener); }); const mouseoutListener = function () { clearActiveMarkers(); clearTimeout(tooltipTimeout); contextTooltip.hide(); lastHoveredToken = null; }; self.editor.container.addEventListener('mouseout', mouseoutListener); self.disposeFunctions.push(() => { self.editor.container.removeEventListener('mouseout', mouseoutListener); }); const onContextMenu = function (e) { const selectionRange = self.editor.selection.getRange(); huePubSub.publish('context.popover.hide'); huePubSub.publish('sql.syntax.dropdown.hide'); if (selectionRange.isEmpty()) { const pointerPosition = self.editor.renderer.screenToTextCoordinates( e.clientX + 5, e.clientY ); const token = self.editor.session.getTokenAt(pointerPosition.row, pointerPosition.column); if ( token && ((token.parseLocation && ['alias', 'whereClause', 'limitClause', 'selectList'].indexOf( token.parseLocation.type ) === -1) || token.syntaxError) ) { const range = token.parseLocation ? markLocation(token.parseLocation) : 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 ); const startCoordinates = self.editor.renderer.textToScreenCoordinates( range.start.row, range.start.column ); const endCoordinates = self.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 + self.editor.renderer.lineHeight }; if (token.parseLocation && token.parseLocation.identifierChain && !token.notFound) { token.parseLocation .resolveCatalogEntry({ temporaryOnly: self.snippet.autocompleteSettings.temporaryOnly }) .then(entry => { huePubSub.publish('context.popover.show', { data: { type: 'catalogEntry', catalogEntry: entry }, pinEnabled: true, connector: self.snippet.connector(), source: source }); }) .catch(() => { token.notFound = true; }); } else if (token.parseLocation && !token.notFound) { // Asterisk, function etc. if (token.parseLocation.type === 'file') { AssistStorageEntry.getEntry(token.parseLocation.path).then(entry => { entry.open(true); huePubSub.publish('context.popover.show', { data: { type: 'storageEntry', storageEntry: entry, editorLocation: token.parseLocation.location }, connector: self.snippet.connector(), pinEnabled: true, source: source }); }); } else { huePubSub.publish('context.popover.show', { data: token.parseLocation, connector: self.snippet.connector(), sourceType: self.dialect(), namespace: self.snippet.namespace(), compute: self.snippet.compute(), defaultDatabase: self.snippet.database(), pinEnabled: true, source: source }); } } else if (token.syntaxError) { huePubSub.publish('sql.syntax.dropdown.show', { editorId: self.snippet.id(), data: token.syntaxError, editor: self.editor, range: range, sourceType: self.dialect(), defaultDatabase: self.snippet.database(), source: source }); } e.preventDefault(); return false; } } }; const contextmenuListener = self.editor.container.addEventListener( 'contextmenu', onContextMenu ); self.disposeFunctions.push(() => { self.editor.container.removeEventListener('contextmenu', contextmenuListener); }); } attachStatementLocator() { const self = this; let lastKnownStatements = []; let activeStatement; const isPointInside = function (parseLocation, editorPosition) { 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, editorPositionTwo) => { if (editorPositionOne.row === editorPositionTwo.row) { return editorPositionOne.column <= editorPositionTwo.column ? editorPositionOne : editorPositionTwo; } return editorPositionOne.row < editorPositionTwo.row ? editorPositionOne : editorPositionTwo; }; const equalPositions = (editorPositionOne, editorPositionTwo) => { return ( editorPositionOne.row === editorPositionTwo.row && editorPositionOne.column === editorPositionTwo.column ); }; let lastExecutingStatement = null; const updateActiveStatement = function (cursorChange) { if (!self.snippet.isSqlDialect()) { return; } const selectionRange = self.editor.getSelectionRange(); const cursorLocation = selectionRange.start; if (!equalPositions(selectionRange.start, selectionRange.end)) { if (!cursorChange && self.snippet.result && self.snippet.result.statement_range()) { let executingStatement = self.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; } } if (cursorChange && activeStatement) { // Don't update when cursor stays in the same statement if (isPointInside(activeStatement.location, cursorLocation)) { return; } } const selectedStatements = []; const precedingStatements = []; const followingStatements = []; activeStatement = null; 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 (lastKnownStatements.length === 1) { activeStatement = lastKnownStatements[0]; } else { lastKnownStatements.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; 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(); activeStatement = lastKnownStatements[lastKnownStatements.length - 1]; } } if (!selectedStatements.length) { selectedStatements.push(activeStatement); } huePubSub.publish(ACTIVE_STATEMENT_CHANGED_EVENT, { id: self.editorId, editorChangeTime: lastKnownStatements.editorChangeTime, activeStatementIndex: statementIndex, totalStatementCount: lastKnownStatements.length, precedingStatements: precedingStatements, activeStatement: activeStatement, selectedStatements: selectedStatements, followingStatements: followingStatements }); if (activeStatement) { self.checkForSyntaxErrors(activeStatement.location, firstSelectionPoint); } }; const parseForStatements = function () { if (self.snippet.isSqlDialect()) { try { const lastChangeTime = self.editor.lastChangeTime; lastKnownStatements = getStatementsParser(self.snippet.connector()).parse( self.editor.getValue() ); lastKnownStatements.editorChangeTime = lastChangeTime; if (typeof hueDebug !== 'undefined' && hueDebug.logStatementLocations) { // eslint-disable-next-line no-restricted-syntax console.log(lastKnownStatements); } } catch (error) { console.warn('Could not parse statements!'); console.warn(error); } } }; let changeThrottle = window.setTimeout(parseForStatements, 0); const updateThrottle = window.setTimeout(updateActiveStatement, 0); let cursorChangePaused = false; // On change the cursor is also moved, this limits the calls while typing let lastStart; let lastCursorPosition; const changeSelectionListener = self.editor.on('changeSelection', () => { if (cursorChangePaused) { return; } window.clearTimeout(changeThrottle); changeThrottle = window.setTimeout(() => { const newCursorPosition = self.editor.getCursorPosition(); if ( !lastCursorPosition || lastCursorPosition.row !== newCursorPosition.row || lastCursorPosition.column !== newCursorPosition.column ) { huePubSub.publish(CURSOR_POSITION_CHANGED_EVENT, { editorId: self.snippet.id(), position: newCursorPosition }); lastCursorPosition = newCursorPosition; } // The active statement is initially the top one in the selection, batch execution updates this. const newStart = self.editor.getSelectionRange().start; if (self.snippet.isSqlDialect() && (!lastStart || !equalPositions(lastStart, newStart))) { window.clearTimeout(updateThrottle); updateActiveStatement(true); lastStart = newStart; } }, 100); }); const changeListener = self.editor.on('change', () => { if (self.snippet.isSqlDialect()) { window.clearTimeout(changeThrottle); cursorChangePaused = true; changeThrottle = window.setTimeout(() => { window.clearTimeout(updateThrottle); parseForStatements(); updateActiveStatement(); cursorChangePaused = false; }, 500); self.editor.lastChangeTime = Date.now(); } }); const locateSubscription = huePubSub.subscribe(REFRESH_STATEMENT_LOCATIONS_EVENT, editorId => { if (editorId === self.snippet.id()) { cursorChangePaused = true; window.clearTimeout(changeThrottle); window.clearTimeout(updateThrottle); parseForStatements(); updateActiveStatement(); cursorChangePaused = false; } }); self.disposeFunctions.push(() => { window.clearTimeout(changeThrottle); window.clearTimeout(updateThrottle); self.editor.off('changeSelection', changeSelectionListener); self.editor.off('change', changeListener); locateSubscription.remove(); }); } clearMarkedErrors(type) { const self = this; for (const marker in self.editor.getSession().$backMarkers) { if ( self.editor .getSession() .$backMarkers[marker].clazz.indexOf('hue-ace-syntax-' + (type || '')) === 0 ) { self.editor.getSession().$backMarkers[marker].dispose(); } } } checkForSyntaxErrors(statementLocation, cursorPosition) { const self = this; if ( self.sqlSyntaxWorkerSub !== null && (self.dialect() === DIALECT.impala || self.dialect() === DIALECT.hive || self.dialect() === DIALECT.sparksql) ) { const AceRange = ace.require('ace/range').Range; const editorChangeTime = self.editor.lastChangeTime; const beforeCursor = self.editor .getSession() .getTextRange( new AceRange( statementLocation.first_line - 1, statementLocation.first_column, cursorPosition.row, cursorPosition.column ) ); const afterCursor = self.editor .getSession() .getTextRange( new AceRange( cursorPosition.row, cursorPosition.column, statementLocation.last_line - 1, statementLocation.last_column ) ); huePubSub.publish(POST_TO_SYNTAX_WORKER_EVENT, { id: self.snippet.id(), editorChangeTime: editorChangeTime, beforeCursor: beforeCursor, afterCursor: afterCursor, statementLocation: statementLocation, connector: self.snippet.connector() }); } } addAnchoredMarker(range, token, clazz) { const self = this; range.start = self.editor.getSession().doc.createAnchor(range.start); range.end = self.editor.getSession().doc.createAnchor(range.end); const markerId = self.editor.getSession().addMarker(range, clazz); const marker = self.editor.getSession().$backMarkers[markerId]; marker.token = token; marker.dispose = function () { range.start.detach(); range.end.detach(); delete marker.token.syntaxError; delete marker.token.notFound; self.editor.getSession().removeMarker(markerId); }; } attachSqlSyntaxWorker() { const self = this; if (self.sqlSyntaxWorkerSub !== null) { return; } self.sqlSyntaxWorkerSub = huePubSub.subscribe(POST_FROM_SYNTAX_WORKER_EVENT, e => { if ( e.data.id !== self.snippet.id() || e.data.editorChangeTime !== self.editor.lastChangeTime ) { return; } self.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('hue.syntax.checker.suppressedRules', {}); if ( e.data.syntaxError && e.data.syntaxError.ruleId && !suppressedRules[ e.data.syntaxError.ruleId.toString() + e.data.syntaxError.text.toLowerCase() ] ) { if ( self.snippet.positionStatement() && sqlUtils.locationEquals( e.data.statementLocation, self.snippet.positionStatement().location ) ) { self.snippet.positionStatement().syntaxError = true; } if (hueDebug && hueDebug.showSyntaxParseResult) { // eslint-disable-next-line no-restricted-syntax console.log(e.data.syntaxError); } const token = self.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(self.editor.getTextBeforeCursor()) && !/^[a-z]/i.test(self.editor.getTextAfterCursor()) ) { const cursorPos = self.editor.getCursorPosition(); const cursorToken = self.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 ); self.addAnchoredMarker(range, token, 'hue-ace-syntax-error'); } } }); huePubSub.publish(REFRESH_STATEMENT_LOCATIONS_EVENT, self.snippet.id()); } detachSqlSyntaxWorker() { const self = this; if (self.sqlSyntaxWorkerSub !== null) { self.sqlSyntaxWorkerSub.remove(); self.sqlSyntaxWorkerSub = null; } self.clearMarkedErrors(); } fetchChildren(identifierChain) { const self = this; const deferred = $.Deferred(); dataCatalog .getChildren({ connector: self.snippet.connector(), namespace: self.snippet.namespace(), compute: self.snippet.compute(), temporaryOnly: self.snippet.autocompleteSettings.temporaryOnly, path: $.map(identifierChain, identifier => { return identifier.name; }), silenceErrors: true, cachedOnly: true }) .then(deferred.resolve) .catch(() => { deferred.reject([]); }); return deferred; } fetchPossibleValues(token) { const self = this; const promise = $.Deferred(); if (token.parseLocation.tables && token.parseLocation.tables.length > 0) { const tablePromises = []; token.parseLocation.tables.forEach(table => { if (table.identifierChain) { tablePromises.push(self.fetchChildren(table.identifierChain)); } }); $.when .apply($, tablePromises) .done(function () { let joined = []; for (let i = 0; i < arguments.length; i++) { joined = joined.concat(arguments[i]); } if (token.parseLocation.type === 'column') { // 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]); } }); } promise.resolve(joined); }) .fail(promise.reject); } else if ( token.parseLocation.identifierChain && token.parseLocation.identifierChain.length > 0 ) { // fetch the parent return self.fetchChildren( token.parseLocation.identifierChain.slice(0, token.parseLocation.identifierChain.length - 1) ); } else { promise.reject([]); } return promise; } verifyExists(tokens, allLocations) { const self = this; window.clearInterval(self.verifyThrottle); self.clearMarkedErrors('warning'); if (self.sqlSyntaxWorkerSub === null) { return; } const cursorPos = self.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 = {}; const aliases = []; allLocations.forEach(location => { if ( location.type === '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 = function (location) { const promise = $.Deferred(); if ( location.type === 'column' && typeof location.tables !== 'undefined' && location.identifierChain.length === 1 ) { const findIdentifierChainInTable = function (tablesToGo) { const nextTable = tablesToGo.shift(); if (typeof nextTable.subQuery === 'undefined') { dataCatalog .getChildren({ connector: self.snippet.connector(), namespace: self.snippet.namespace(), compute: self.snippet.compute(), temporaryOnly: self.snippet.autocompleteSettings.temporaryOnly, path: $.map(nextTable.identifierChain, identifier => { return identifier.name; }), cachedOnly: true, silenceErrors: true }) .then(entries => { const containsColumn = entries.some(entry => { return sqlUtils.identifierEquals(entry.name, location.identifierChain[0].name); }); if (containsColumn) { location.identifierChain = nextTable.identifierChain.concat( location.identifierChain ); delete location.tables; promise.resolve(); } else if (tablesToGo.length > 0) { findIdentifierChainInTable(tablesToGo); } else { promise.resolve(); } }) .catch(promise.resolve); } else if (tablesToGo.length > 0) { findIdentifierChainInTable(tablesToGo); } else { promise.resolve(); } }; if (location.tables.length > 1) { findIdentifierChainInTable(location.tables.concat()); } else if (location.tables.length === 1 && location.tables[0].identifierChain) { location.identifierChain = location.tables[0].identifierChain.concat( location.identifierChain ); delete location.tables; promise.resolve(); } } else { promise.resolve(); } return promise; }; const verify = function () { if (tokensToVerify.length === 0) { return; } const token = tokensToVerify.shift(); const location = token.parseLocation; // TODO: Verify columns in subqueries, 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 => { return typeof table.subQuery !== 'undefined'; }); if (hasSubQueries) { self.verifyThrottle = window.setTimeout(verify, VERIFY_DELAY); return; } } resolvePathFromTables(location) .done(() => { if (location.type === 'column') { let possibleAlias; if ( !location.tables && location.identifierChain && location.identifierChain.length > 1 ) { 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 self.verifyThrottle = window.setTimeout(verify, VERIFY_DELAY); return; } } self .fetchPossibleValues(token) .done(async possibleValues => { // Tokens might change while making api calls if (!token.parseLocation) { self.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 uniqueIndex = {}; const uniqueValues = []; for (let i = 0; i < possibleValues.length; i++) { possibleValues[i].name = await sqlUtils.backTickIfNeeded( self.snippet.connector(), possibleValues[i].name ); const nameLower = possibleValues[i].name.toLowerCase(); if ( nameLower === tokenValLower || (tokenValLower.indexOf('`') === 0 && tokenValLower.replace(/`/g, '') === nameLower) ) { // Break if found self.verifyThrottle = window.setTimeout(verify, VERIFY_DELAY); return; } if (!uniqueIndex[nameLower]) { uniqueValues.push(possibleValues[i]); uniqueIndex[nameLower] = true; } } possibleValues = uniqueValues; const isLowerCase = tokenValLower === token.value; const weightedExpected = $.map(possibleValues, val => { return { text: isLowerCase ? val.name.toLowerCase() : val.name, distance: stringDistance(token.value, val.name) }; }); weightedExpected.sort((a, b) => { if (a.distance === b.distance) { return a.text.localeCompare(b.text); } return a.distance - b.distance; }); token.syntaxError = { expected: weightedExpected.slice(0, 50) }; token.notFound = true; if (token.parseLocation && token.parseLocation.type === 'table') { const path = $.map(token.parseLocation.identifierChain, identifier => { return identifier.name; }); token.qualifiedIdentifier = path.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 ); self.addAnchoredMarker(range, token, 'hue-ace-syntax-warning'); } self.verifyThrottle = window.setTimeout(verify, VERIFY_DELAY); }) .fail(() => { // Can happen when tables aren't cached etc. self.verifyThrottle = window.setTimeout(verify, VERIFY_DELAY); }); }) .fail(() => { // Can happen when tables aren't cached etc. self.verifyThrottle = window.setTimeout(verify, VERIFY_DELAY); }); }; self.verifyThrottle = window.setTimeout(verify, VERIFY_DELAY); } isDatabase(databaseIdentifier) { const self = this; if (!databaseIdentifier) { return false; } const cleanIdentifier = databaseIdentifier .replace(/^\s*`/, '') .replace(/`\s*$/, '') .toLowerCase(); return self.databaseIndex[cleanIdentifier]; } attachSqlWorker() { const self = this; const activeTokens = []; let lastKnownLocations = {}; const getLocationsSub = huePubSub.subscribe( 'get.active.editor.locations', (callback, snippet) => { if (self.snippet === snippet && (self.snippet.inFocus() || self.snippet.editorMode())) { callback(lastKnownLocations); } } ); self.disposeFunctions.push(() => { getLocationsSub.remove(); }); const locationWorkerSub = huePubSub.subscribe(POST_FROM_LOCATION_WORKER_EVENT, e => { if ( e.data.id !== self.snippet.id() || e.data.editorChangeTime !== self.editor.lastChangeTime || !self.snippet.isSqlDialect() ) { return; } lastKnownLocations = { id: self.editorId, connector: self.snippet.connector(), namespace: self.snippet.namespace(), compute: self.snippet.compute(), defaultDatabase: self.snippet.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) { delete activeTokens.pop().parseLocation; } const tokensToVerify = []; e.data.locations.forEach(location => { if (location.type === 'statementType' && self.dialect() !== DIALECT.impala) { // 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 ( self.dialect() === DIALECT.impala && location.identifierChain.length > 2 && (location.type === 'table' || location.type === 'column') && self.isDatabase(location.identifierChain[0].name) ) { location.type = 'complex'; } } let token = self.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 = self.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 = self.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 = self.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 ( self.dialect() === DIALECT.impala || self.dialect() === DIALECT.hive || self.dialect() === DIALECT.sparksql ) { self.verifyExists(tokensToVerify, e.data.activeStatementLocations); } huePubSub.publish('editor.active.locations', lastKnownLocations); }); self.disposeFunctions.push(() => { locationWorkerSub.remove(); }); let lastContextRequest; const statementSubscription = huePubSub.subscribe( 'editor.active.statement.changed', statementDetails => { if (statementDetails.id !== self.editorId) { return; } if (self.snippet.isSqlDialect()) { if (lastContextRequest) { lastContextRequest.dispose(); } lastContextRequest = self.snippet.whenContextSet().done(() => { huePubSub.publish(POST_TO_LOCATION_WORKER_EVENT, { id: self.snippet.id(), statementDetails: statementDetails, connector: self.snippet.connector(), namespace: self.snippet.namespace(), compute: self.snippet.compute(), defaultDatabase: self.snippet.database() }); }); } } ); self.disposeFunctions.push(() => { statementSubscription.remove(); }); } dispose() { const self = this; if (self.sqlSyntaxWorkerSub !== null) { self.sqlSyntaxWorkerSub.remove(); self.sqlSyntaxWorkerSub = null; } self.disposeFunctions.forEach(dispose => { dispose(); }); } } export default AceLocationHandler;