KustoQueryField.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448
  1. import _ from 'lodash';
  2. import Plain from 'slate-plain-serializer';
  3. import QueryField from './query_field';
  4. // import debounce from './utils/debounce';
  5. // import {getNextCharacter} from './utils/dom';
  6. import debounce from 'app/features/explore/utils/debounce';
  7. import { getNextCharacter } from 'app/features/explore/utils/dom';
  8. import { KEYWORDS, functionTokens, operatorTokens, grafanaMacros } from './kusto/kusto';
  9. // import '../sass/editor.base.scss';
  10. const TYPEAHEAD_DELAY = 100;
  11. interface Suggestion {
  12. text: string;
  13. deleteBackwards?: number;
  14. type?: string;
  15. }
  16. interface SuggestionGroup {
  17. label: string;
  18. items: Suggestion[];
  19. prefixMatch?: boolean;
  20. skipFilter?: boolean;
  21. }
  22. interface KustoSchema {
  23. Databases: {
  24. Default?: KustoDBSchema;
  25. };
  26. Plugins?: any[];
  27. }
  28. interface KustoDBSchema {
  29. Name?: string;
  30. Functions?: any;
  31. Tables?: any;
  32. }
  33. const defaultSchema = () => ({
  34. Databases: {
  35. Default: {},
  36. },
  37. });
  38. const cleanText = s => s.replace(/[{}[\]="(),!~+\-*/^%]/g, '').trim();
  39. const wrapText = text => ({ text });
  40. export default class KustoQueryField extends QueryField {
  41. fields: any;
  42. events: any;
  43. schema: KustoSchema;
  44. constructor(props, context) {
  45. super(props, context);
  46. this.schema = defaultSchema();
  47. this.onTypeahead = debounce(this.onTypeahead, TYPEAHEAD_DELAY);
  48. }
  49. componentDidMount() {
  50. super.componentDidMount();
  51. this.fetchSchema();
  52. }
  53. onTypeahead = (force?: boolean) => {
  54. const selection = window.getSelection();
  55. if (selection.anchorNode) {
  56. const wrapperNode = selection.anchorNode.parentElement;
  57. if (wrapperNode === null) {
  58. return;
  59. }
  60. const editorNode = wrapperNode.closest('.slate-query-field');
  61. if (!editorNode || this.state.value.isBlurred) {
  62. // Not inside this editor
  63. return;
  64. }
  65. // DOM ranges
  66. const range = selection.getRangeAt(0);
  67. const text = selection.anchorNode.textContent;
  68. if (text === null) {
  69. return;
  70. }
  71. const offset = range.startOffset;
  72. let prefix = cleanText(text.substr(0, offset));
  73. // Model ranges
  74. const modelOffset = this.state.value.anchorOffset;
  75. const modelPrefix = this.state.value.anchorText.text.slice(0, modelOffset);
  76. // Determine candidates by context
  77. let suggestionGroups: SuggestionGroup[] = [];
  78. const wrapperClasses = wrapperNode.classList;
  79. let typeaheadContext: string | null = null;
  80. // Built-in functions
  81. if (wrapperClasses.contains('function-context')) {
  82. typeaheadContext = 'context-function';
  83. suggestionGroups = this.getColumnSuggestions();
  84. // where
  85. } else if (modelPrefix.match(/(where\s(\w+\b)?$)/i)) {
  86. typeaheadContext = 'context-where';
  87. suggestionGroups = this.getColumnSuggestions();
  88. // summarize by
  89. } else if (modelPrefix.match(/(summarize\s(\w+\b)?$)/i)) {
  90. typeaheadContext = 'context-summarize';
  91. suggestionGroups = this.getFunctionSuggestions();
  92. } else if (modelPrefix.match(/(summarize\s(.+\s)?by\s+([^,\s]+,\s*)*([^,\s]+\b)?$)/i)) {
  93. typeaheadContext = 'context-summarize-by';
  94. suggestionGroups = this.getColumnSuggestions();
  95. // order by, top X by, ... by ...
  96. } else if (modelPrefix.match(/(by\s+([^,\s]+,\s*)*([^,\s]+\b)?$)/i)) {
  97. typeaheadContext = 'context-by';
  98. suggestionGroups = this.getColumnSuggestions();
  99. // join
  100. } else if (modelPrefix.match(/(on\s(.+\b)?$)/i)) {
  101. typeaheadContext = 'context-join-on';
  102. suggestionGroups = this.getColumnSuggestions();
  103. } else if (modelPrefix.match(/(join\s+(\(\s+)?(\w+\b)?$)/i)) {
  104. typeaheadContext = 'context-join';
  105. suggestionGroups = this.getTableSuggestions();
  106. // distinct
  107. } else if (modelPrefix.match(/(distinct\s(.+\b)?$)/i)) {
  108. typeaheadContext = 'context-distinct';
  109. suggestionGroups = this.getColumnSuggestions();
  110. // database()
  111. } else if (modelPrefix.match(/(database\(\"(\w+)\"\)\.(.+\b)?$)/i)) {
  112. typeaheadContext = 'context-database-table';
  113. const db = this.getDBFromDatabaseFunction(modelPrefix);
  114. console.log(db);
  115. suggestionGroups = this.getTableSuggestions(db);
  116. prefix = prefix.replace('.', '');
  117. // new
  118. } else if (normalizeQuery(Plain.serialize(this.state.value)).match(/^\s*\w*$/i)) {
  119. typeaheadContext = 'context-new';
  120. if (this.schema) {
  121. suggestionGroups = this.getInitialSuggestions();
  122. } else {
  123. this.fetchSchema();
  124. setTimeout(this.onTypeahead, 0);
  125. return;
  126. }
  127. // built-in
  128. } else if (prefix && !wrapperClasses.contains('argument') && !force) {
  129. // Use only last typed word as a prefix for searching
  130. if (modelPrefix.match(/\s$/i)) {
  131. prefix = '';
  132. return;
  133. }
  134. prefix = getLastWord(prefix);
  135. typeaheadContext = 'context-builtin';
  136. suggestionGroups = this.getKeywordSuggestions();
  137. } else if (force === true) {
  138. typeaheadContext = 'context-builtin-forced';
  139. if (modelPrefix.match(/\s$/i)) {
  140. prefix = '';
  141. }
  142. suggestionGroups = this.getKeywordSuggestions();
  143. }
  144. let results = 0;
  145. prefix = prefix.toLowerCase();
  146. const filteredSuggestions = suggestionGroups
  147. .map(group => {
  148. if (group.items && prefix && !group.skipFilter) {
  149. group.items = group.items.filter(c => c.text.length >= prefix.length);
  150. if (group.prefixMatch) {
  151. group.items = group.items.filter(c => c.text.toLowerCase().indexOf(prefix) === 0);
  152. } else {
  153. group.items = group.items.filter(c => c.text.toLowerCase().indexOf(prefix) > -1);
  154. }
  155. }
  156. results += group.items.length;
  157. return group;
  158. })
  159. .filter(group => group.items.length > 0);
  160. // console.log('onTypeahead', selection.anchorNode, wrapperClasses, text, offset, prefix, typeaheadContext);
  161. // console.log('onTypeahead', prefix, typeaheadContext, force);
  162. this.setState({
  163. typeaheadPrefix: prefix,
  164. typeaheadContext,
  165. typeaheadText: text,
  166. suggestions: results > 0 ? filteredSuggestions : [],
  167. });
  168. }
  169. };
  170. applyTypeahead(change, suggestion) {
  171. const { typeaheadPrefix, typeaheadContext, typeaheadText } = this.state;
  172. let suggestionText = suggestion.text || suggestion;
  173. const move = 0;
  174. // Modify suggestion based on context
  175. const nextChar = getNextCharacter();
  176. if (suggestion.type === 'function') {
  177. if (!nextChar || nextChar !== '(') {
  178. suggestionText += '(';
  179. }
  180. } else if (typeaheadContext === 'context-function') {
  181. if (!nextChar || nextChar !== ')') {
  182. suggestionText += ')';
  183. }
  184. } else {
  185. if (!nextChar || nextChar !== ' ') {
  186. suggestionText += ' ';
  187. }
  188. }
  189. this.resetTypeahead();
  190. // Remove the current, incomplete text and replace it with the selected suggestion
  191. const backward = suggestion.deleteBackwards || typeaheadPrefix.length;
  192. const text = cleanText(typeaheadText);
  193. const suffixLength = text.length - typeaheadPrefix.length;
  194. const offset = typeaheadText.indexOf(typeaheadPrefix);
  195. const midWord = typeaheadPrefix && ((suffixLength > 0 && offset > -1) || suggestionText === typeaheadText);
  196. const forward = midWord ? suffixLength + offset : 0;
  197. return change
  198. .deleteBackward(backward)
  199. .deleteForward(forward)
  200. .insertText(suggestionText)
  201. .move(move)
  202. .focus();
  203. }
  204. // private _getFieldsSuggestions(): SuggestionGroup[] {
  205. // return [
  206. // {
  207. // prefixMatch: true,
  208. // label: 'Fields',
  209. // items: this.fields.map(wrapText)
  210. // },
  211. // {
  212. // prefixMatch: true,
  213. // label: 'Variables',
  214. // items: this.props.templateVariables.map(wrapText)
  215. // }
  216. // ];
  217. // }
  218. // private _getAfterFromSuggestions(): SuggestionGroup[] {
  219. // return [
  220. // {
  221. // skipFilter: true,
  222. // label: 'Events',
  223. // items: this.events.map(wrapText)
  224. // },
  225. // {
  226. // prefixMatch: true,
  227. // label: 'Variables',
  228. // items: this.props.templateVariables
  229. // .map(wrapText)
  230. // .map(suggestion => {
  231. // suggestion.deleteBackwards = 0;
  232. // return suggestion;
  233. // })
  234. // }
  235. // ];
  236. // }
  237. // private _getAfterSelectSuggestions(): SuggestionGroup[] {
  238. // return [
  239. // {
  240. // prefixMatch: true,
  241. // label: 'Fields',
  242. // items: this.fields.map(wrapText)
  243. // },
  244. // {
  245. // prefixMatch: true,
  246. // label: 'Functions',
  247. // items: FUNCTIONS.map((s: any) => { s.type = 'function'; return s; })
  248. // },
  249. // {
  250. // prefixMatch: true,
  251. // label: 'Variables',
  252. // items: this.props.templateVariables.map(wrapText)
  253. // }
  254. // ];
  255. // }
  256. private getInitialSuggestions(): SuggestionGroup[] {
  257. return this.getTableSuggestions();
  258. }
  259. private getKeywordSuggestions(): SuggestionGroup[] {
  260. return [
  261. {
  262. prefixMatch: true,
  263. label: 'Keywords',
  264. items: KEYWORDS.map(wrapText),
  265. },
  266. {
  267. prefixMatch: true,
  268. label: 'Operators',
  269. items: operatorTokens,
  270. },
  271. {
  272. prefixMatch: true,
  273. label: 'Functions',
  274. items: functionTokens.map((s: any) => {
  275. s.type = 'function';
  276. return s;
  277. }),
  278. },
  279. {
  280. prefixMatch: true,
  281. label: 'Macros',
  282. items: grafanaMacros.map((s: any) => {
  283. s.type = 'function';
  284. return s;
  285. }),
  286. },
  287. {
  288. prefixMatch: true,
  289. label: 'Tables',
  290. items: _.map(this.schema.Databases.Default.Tables, (t: any) => ({ text: t.Name })),
  291. },
  292. ];
  293. }
  294. private getFunctionSuggestions(): SuggestionGroup[] {
  295. return [
  296. {
  297. prefixMatch: true,
  298. label: 'Functions',
  299. items: functionTokens.map((s: any) => {
  300. s.type = 'function';
  301. return s;
  302. }),
  303. },
  304. {
  305. prefixMatch: true,
  306. label: 'Macros',
  307. items: grafanaMacros.map((s: any) => {
  308. s.type = 'function';
  309. return s;
  310. }),
  311. },
  312. ];
  313. }
  314. getTableSuggestions(db = 'Default'): SuggestionGroup[] {
  315. if (this.schema.Databases[db]) {
  316. return [
  317. {
  318. prefixMatch: true,
  319. label: 'Tables',
  320. items: _.map(this.schema.Databases[db].Tables, (t: any) => ({ text: t.Name })),
  321. },
  322. ];
  323. } else {
  324. return [];
  325. }
  326. }
  327. private getColumnSuggestions(): SuggestionGroup[] {
  328. const table = this.getTableFromContext();
  329. if (table) {
  330. const tableSchema = this.schema.Databases.Default.Tables[table];
  331. if (tableSchema) {
  332. return [
  333. {
  334. prefixMatch: true,
  335. label: 'Fields',
  336. items: _.map(tableSchema.OrderedColumns, (f: any) => ({
  337. text: f.Name,
  338. hint: f.Type,
  339. })),
  340. },
  341. ];
  342. }
  343. }
  344. return [];
  345. }
  346. private getTableFromContext() {
  347. const query = Plain.serialize(this.state.value);
  348. const tablePattern = /^\s*(\w+)\s*|/g;
  349. const normalizedQuery = normalizeQuery(query);
  350. const match = tablePattern.exec(normalizedQuery);
  351. if (match && match.length > 1 && match[0] && match[1]) {
  352. return match[1];
  353. } else {
  354. return null;
  355. }
  356. }
  357. private getDBFromDatabaseFunction(prefix: string) {
  358. const databasePattern = /database\(\"(\w+)\"\)/gi;
  359. const match = databasePattern.exec(prefix);
  360. if (match && match.length > 1 && match[0] && match[1]) {
  361. return match[1];
  362. } else {
  363. return null;
  364. }
  365. }
  366. private async fetchSchema() {
  367. let schema = await this.props.getSchema();
  368. if (schema) {
  369. if (schema.Type === 'AppInsights') {
  370. schema = castSchema(schema);
  371. }
  372. this.schema = schema;
  373. } else {
  374. this.schema = defaultSchema();
  375. }
  376. }
  377. }
  378. /**
  379. * Cast schema from App Insights to default Kusto schema
  380. */
  381. function castSchema(schema) {
  382. const defaultSchemaTemplate = defaultSchema();
  383. defaultSchemaTemplate.Databases.Default = schema;
  384. return defaultSchemaTemplate;
  385. }
  386. function normalizeQuery(query: string): string {
  387. const commentPattern = /\/\/.*$/gm;
  388. let normalizedQuery = query.replace(commentPattern, '');
  389. normalizedQuery = normalizedQuery.replace('\n', ' ');
  390. return normalizedQuery;
  391. }
  392. function getLastWord(str: string): string {
  393. const lastWordPattern = /(?:.*\s)?([^\s]+\s*)$/gi;
  394. const match = lastWordPattern.exec(str);
  395. if (match && match.length > 1) {
  396. return match[1];
  397. }
  398. return '';
  399. }