| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448 |
- import _ from 'lodash';
- import Plain from 'slate-plain-serializer';
- import QueryField from './query_field';
- // import debounce from './utils/debounce';
- // import {getNextCharacter} from './utils/dom';
- import debounce from 'app/features/explore/utils/debounce';
- import { getNextCharacter } from 'app/features/explore/utils/dom';
- import { KEYWORDS, functionTokens, operatorTokens, grafanaMacros } from './kusto/kusto';
- // import '../sass/editor.base.scss';
- const TYPEAHEAD_DELAY = 100;
- interface Suggestion {
- text: string;
- deleteBackwards?: number;
- type?: string;
- }
- interface SuggestionGroup {
- label: string;
- items: Suggestion[];
- prefixMatch?: boolean;
- skipFilter?: boolean;
- }
- interface KustoSchema {
- Databases: {
- Default?: KustoDBSchema;
- };
- Plugins?: any[];
- }
- interface KustoDBSchema {
- Name?: string;
- Functions?: any;
- Tables?: any;
- }
- const defaultSchema = () => ({
- Databases: {
- Default: {},
- },
- });
- const cleanText = s => s.replace(/[{}[\]="(),!~+\-*/^%]/g, '').trim();
- const wrapText = text => ({ text });
- export default class KustoQueryField extends QueryField {
- fields: any;
- events: any;
- schema: KustoSchema;
- constructor(props, context) {
- super(props, context);
- this.schema = defaultSchema();
- this.onTypeahead = debounce(this.onTypeahead, TYPEAHEAD_DELAY);
- }
- componentDidMount() {
- super.componentDidMount();
- this.fetchSchema();
- }
- onTypeahead = (force?: boolean) => {
- const selection = window.getSelection();
- if (selection.anchorNode) {
- const wrapperNode = selection.anchorNode.parentElement;
- if (wrapperNode === null) {
- return;
- }
- const editorNode = wrapperNode.closest('.slate-query-field');
- if (!editorNode || this.state.value.isBlurred) {
- // Not inside this editor
- return;
- }
- // DOM ranges
- const range = selection.getRangeAt(0);
- const text = selection.anchorNode.textContent;
- if (text === null) {
- return;
- }
- const offset = range.startOffset;
- let prefix = cleanText(text.substr(0, offset));
- // Model ranges
- const modelOffset = this.state.value.anchorOffset;
- const modelPrefix = this.state.value.anchorText.text.slice(0, modelOffset);
- // Determine candidates by context
- let suggestionGroups: SuggestionGroup[] = [];
- const wrapperClasses = wrapperNode.classList;
- let typeaheadContext: string | null = null;
- // Built-in functions
- if (wrapperClasses.contains('function-context')) {
- typeaheadContext = 'context-function';
- suggestionGroups = this.getColumnSuggestions();
- // where
- } else if (modelPrefix.match(/(where\s(\w+\b)?$)/i)) {
- typeaheadContext = 'context-where';
- suggestionGroups = this.getColumnSuggestions();
- // summarize by
- } else if (modelPrefix.match(/(summarize\s(\w+\b)?$)/i)) {
- typeaheadContext = 'context-summarize';
- suggestionGroups = this.getFunctionSuggestions();
- } else if (modelPrefix.match(/(summarize\s(.+\s)?by\s+([^,\s]+,\s*)*([^,\s]+\b)?$)/i)) {
- typeaheadContext = 'context-summarize-by';
- suggestionGroups = this.getColumnSuggestions();
- // order by, top X by, ... by ...
- } else if (modelPrefix.match(/(by\s+([^,\s]+,\s*)*([^,\s]+\b)?$)/i)) {
- typeaheadContext = 'context-by';
- suggestionGroups = this.getColumnSuggestions();
- // join
- } else if (modelPrefix.match(/(on\s(.+\b)?$)/i)) {
- typeaheadContext = 'context-join-on';
- suggestionGroups = this.getColumnSuggestions();
- } else if (modelPrefix.match(/(join\s+(\(\s+)?(\w+\b)?$)/i)) {
- typeaheadContext = 'context-join';
- suggestionGroups = this.getTableSuggestions();
- // distinct
- } else if (modelPrefix.match(/(distinct\s(.+\b)?$)/i)) {
- typeaheadContext = 'context-distinct';
- suggestionGroups = this.getColumnSuggestions();
- // database()
- } else if (modelPrefix.match(/(database\(\"(\w+)\"\)\.(.+\b)?$)/i)) {
- typeaheadContext = 'context-database-table';
- const db = this.getDBFromDatabaseFunction(modelPrefix);
- console.log(db);
- suggestionGroups = this.getTableSuggestions(db);
- prefix = prefix.replace('.', '');
- // new
- } else if (normalizeQuery(Plain.serialize(this.state.value)).match(/^\s*\w*$/i)) {
- typeaheadContext = 'context-new';
- if (this.schema) {
- suggestionGroups = this.getInitialSuggestions();
- } else {
- this.fetchSchema();
- setTimeout(this.onTypeahead, 0);
- return;
- }
- // built-in
- } else if (prefix && !wrapperClasses.contains('argument') && !force) {
- // Use only last typed word as a prefix for searching
- if (modelPrefix.match(/\s$/i)) {
- prefix = '';
- return;
- }
- prefix = getLastWord(prefix);
- typeaheadContext = 'context-builtin';
- suggestionGroups = this.getKeywordSuggestions();
- } else if (force === true) {
- typeaheadContext = 'context-builtin-forced';
- if (modelPrefix.match(/\s$/i)) {
- prefix = '';
- }
- suggestionGroups = this.getKeywordSuggestions();
- }
- let results = 0;
- prefix = prefix.toLowerCase();
- const filteredSuggestions = suggestionGroups
- .map(group => {
- if (group.items && prefix && !group.skipFilter) {
- group.items = group.items.filter(c => c.text.length >= prefix.length);
- if (group.prefixMatch) {
- group.items = group.items.filter(c => c.text.toLowerCase().indexOf(prefix) === 0);
- } else {
- group.items = group.items.filter(c => c.text.toLowerCase().indexOf(prefix) > -1);
- }
- }
- results += group.items.length;
- return group;
- })
- .filter(group => group.items.length > 0);
- // console.log('onTypeahead', selection.anchorNode, wrapperClasses, text, offset, prefix, typeaheadContext);
- // console.log('onTypeahead', prefix, typeaheadContext, force);
- this.setState({
- typeaheadPrefix: prefix,
- typeaheadContext,
- typeaheadText: text,
- suggestions: results > 0 ? filteredSuggestions : [],
- });
- }
- };
- applyTypeahead(change, suggestion) {
- const { typeaheadPrefix, typeaheadContext, typeaheadText } = this.state;
- let suggestionText = suggestion.text || suggestion;
- const move = 0;
- // Modify suggestion based on context
- const nextChar = getNextCharacter();
- if (suggestion.type === 'function') {
- if (!nextChar || nextChar !== '(') {
- suggestionText += '(';
- }
- } else if (typeaheadContext === 'context-function') {
- if (!nextChar || nextChar !== ')') {
- suggestionText += ')';
- }
- } else {
- if (!nextChar || nextChar !== ' ') {
- suggestionText += ' ';
- }
- }
- this.resetTypeahead();
- // Remove the current, incomplete text and replace it with the selected suggestion
- const backward = suggestion.deleteBackwards || typeaheadPrefix.length;
- const text = cleanText(typeaheadText);
- const suffixLength = text.length - typeaheadPrefix.length;
- const offset = typeaheadText.indexOf(typeaheadPrefix);
- const midWord = typeaheadPrefix && ((suffixLength > 0 && offset > -1) || suggestionText === typeaheadText);
- const forward = midWord ? suffixLength + offset : 0;
- return change
- .deleteBackward(backward)
- .deleteForward(forward)
- .insertText(suggestionText)
- .move(move)
- .focus();
- }
- // private _getFieldsSuggestions(): SuggestionGroup[] {
- // return [
- // {
- // prefixMatch: true,
- // label: 'Fields',
- // items: this.fields.map(wrapText)
- // },
- // {
- // prefixMatch: true,
- // label: 'Variables',
- // items: this.props.templateVariables.map(wrapText)
- // }
- // ];
- // }
- // private _getAfterFromSuggestions(): SuggestionGroup[] {
- // return [
- // {
- // skipFilter: true,
- // label: 'Events',
- // items: this.events.map(wrapText)
- // },
- // {
- // prefixMatch: true,
- // label: 'Variables',
- // items: this.props.templateVariables
- // .map(wrapText)
- // .map(suggestion => {
- // suggestion.deleteBackwards = 0;
- // return suggestion;
- // })
- // }
- // ];
- // }
- // private _getAfterSelectSuggestions(): SuggestionGroup[] {
- // return [
- // {
- // prefixMatch: true,
- // label: 'Fields',
- // items: this.fields.map(wrapText)
- // },
- // {
- // prefixMatch: true,
- // label: 'Functions',
- // items: FUNCTIONS.map((s: any) => { s.type = 'function'; return s; })
- // },
- // {
- // prefixMatch: true,
- // label: 'Variables',
- // items: this.props.templateVariables.map(wrapText)
- // }
- // ];
- // }
- private getInitialSuggestions(): SuggestionGroup[] {
- return this.getTableSuggestions();
- }
- private getKeywordSuggestions(): SuggestionGroup[] {
- return [
- {
- prefixMatch: true,
- label: 'Keywords',
- items: KEYWORDS.map(wrapText),
- },
- {
- prefixMatch: true,
- label: 'Operators',
- items: operatorTokens,
- },
- {
- prefixMatch: true,
- label: 'Functions',
- items: functionTokens.map((s: any) => {
- s.type = 'function';
- return s;
- }),
- },
- {
- prefixMatch: true,
- label: 'Macros',
- items: grafanaMacros.map((s: any) => {
- s.type = 'function';
- return s;
- }),
- },
- {
- prefixMatch: true,
- label: 'Tables',
- items: _.map(this.schema.Databases.Default.Tables, (t: any) => ({ text: t.Name })),
- },
- ];
- }
- private getFunctionSuggestions(): SuggestionGroup[] {
- return [
- {
- prefixMatch: true,
- label: 'Functions',
- items: functionTokens.map((s: any) => {
- s.type = 'function';
- return s;
- }),
- },
- {
- prefixMatch: true,
- label: 'Macros',
- items: grafanaMacros.map((s: any) => {
- s.type = 'function';
- return s;
- }),
- },
- ];
- }
- getTableSuggestions(db = 'Default'): SuggestionGroup[] {
- if (this.schema.Databases[db]) {
- return [
- {
- prefixMatch: true,
- label: 'Tables',
- items: _.map(this.schema.Databases[db].Tables, (t: any) => ({ text: t.Name })),
- },
- ];
- } else {
- return [];
- }
- }
- private getColumnSuggestions(): SuggestionGroup[] {
- const table = this.getTableFromContext();
- if (table) {
- const tableSchema = this.schema.Databases.Default.Tables[table];
- if (tableSchema) {
- return [
- {
- prefixMatch: true,
- label: 'Fields',
- items: _.map(tableSchema.OrderedColumns, (f: any) => ({
- text: f.Name,
- hint: f.Type,
- })),
- },
- ];
- }
- }
- return [];
- }
- private getTableFromContext() {
- const query = Plain.serialize(this.state.value);
- const tablePattern = /^\s*(\w+)\s*|/g;
- const normalizedQuery = normalizeQuery(query);
- const match = tablePattern.exec(normalizedQuery);
- if (match && match.length > 1 && match[0] && match[1]) {
- return match[1];
- } else {
- return null;
- }
- }
- private getDBFromDatabaseFunction(prefix: string) {
- const databasePattern = /database\(\"(\w+)\"\)/gi;
- const match = databasePattern.exec(prefix);
- if (match && match.length > 1 && match[0] && match[1]) {
- return match[1];
- } else {
- return null;
- }
- }
- private async fetchSchema() {
- let schema = await this.props.getSchema();
- if (schema) {
- if (schema.Type === 'AppInsights') {
- schema = castSchema(schema);
- }
- this.schema = schema;
- } else {
- this.schema = defaultSchema();
- }
- }
- }
- /**
- * Cast schema from App Insights to default Kusto schema
- */
- function castSchema(schema) {
- const defaultSchemaTemplate = defaultSchema();
- defaultSchemaTemplate.Databases.Default = schema;
- return defaultSchemaTemplate;
- }
- function normalizeQuery(query: string): string {
- const commentPattern = /\/\/.*$/gm;
- let normalizedQuery = query.replace(commentPattern, '');
- normalizedQuery = normalizedQuery.replace('\n', ' ');
- return normalizedQuery;
- }
- function getLastWord(str: string): string {
- const lastWordPattern = /(?:.*\s)?([^\s]+\s*)$/gi;
- const match = lastWordPattern.exec(str);
- if (match && match.length > 1) {
- return match[1];
- }
- return '';
- }
|