mirror of https://github.com/grafana/grafana
Loki Query Editor: Add support to display query parsing errors to users (#59427)
* feat(loki-query-validation): validation proof of concept * feat(loki-query-validation): refactor and properly display the error portion * feat(loki-query-validation): add support for multi-line queries * feat(loki-query-validation): improve display of linting errors to users * feat(loki-query-validation): add unit tests * Chore: remove unused import * wip * Revert "wip" This reverts commit 44896f7fa2d33251033f8c37776f4d6f2f43787d. * Revert "Revert "wip"" This reverts commit f7889f49a6b0bdc5a4b677e9bbb8c62ea3cccb74. * feat(loki-query-validation): parse original and interpolated query for better validation feedback * feat(loki-query-validation): refactor interpolated query validation support * feat(loki-query-validation): improve validation for interpolated queriespull/60265/head
parent
c2dcf78fac
commit
58a41af3f3
@ -0,0 +1,101 @@ |
||||
import { validateQuery } from './validation'; |
||||
|
||||
describe('Monaco Query Validation', () => { |
||||
test('Identifies empty queries as valid', () => { |
||||
expect(validateQuery('', '', [])).toBeFalsy(); |
||||
}); |
||||
|
||||
test('Identifies valid queries', () => { |
||||
const query = '{place="luna"}'; |
||||
expect(validateQuery(query, query, [])).toBeFalsy(); |
||||
}); |
||||
|
||||
test('Validates logs queries', () => { |
||||
let query = '{place="incomplete"'; |
||||
expect(validateQuery(query, query, [query])).toEqual([ |
||||
{ |
||||
endColumn: 20, |
||||
endLineNumber: 1, |
||||
error: '{place="incomplete"', |
||||
startColumn: 1, |
||||
startLineNumber: 1, |
||||
}, |
||||
]); |
||||
|
||||
query = '{place="luna"} | notaparser'; |
||||
expect(validateQuery(query, query, [query])).toEqual([ |
||||
{ |
||||
endColumn: 28, |
||||
endLineNumber: 1, |
||||
error: 'notaparser', |
||||
startColumn: 18, |
||||
startLineNumber: 1, |
||||
}, |
||||
]); |
||||
|
||||
query = '{place="luna"} | logfmt |'; |
||||
expect(validateQuery(query, query, [query])).toEqual([ |
||||
{ |
||||
endColumn: 26, |
||||
endLineNumber: 1, |
||||
error: '|', |
||||
startColumn: 25, |
||||
startLineNumber: 1, |
||||
}, |
||||
]); |
||||
}); |
||||
|
||||
test('Validates metric queries', () => { |
||||
let query = 'sum(count_over_time({place="luna" | unwrap request_time [5m])) by (level)'; |
||||
expect(validateQuery(query, query, [query])).toEqual([ |
||||
{ |
||||
endColumn: 35, |
||||
endLineNumber: 1, |
||||
error: '{place="luna" ', |
||||
startColumn: 21, |
||||
startLineNumber: 1, |
||||
}, |
||||
]); |
||||
|
||||
query = 'sum(count_over_time({place="luna"} | unwrap [5m])) by (level)'; |
||||
expect(validateQuery(query, query, [query])).toEqual([ |
||||
{ |
||||
endColumn: 45, |
||||
endLineNumber: 1, |
||||
error: '| unwrap ', |
||||
startColumn: 36, |
||||
startLineNumber: 1, |
||||
}, |
||||
]); |
||||
|
||||
query = 'sum()'; |
||||
expect(validateQuery(query, query, [query])).toEqual([ |
||||
{ |
||||
endColumn: 5, |
||||
endLineNumber: 1, |
||||
error: '', |
||||
startColumn: 5, |
||||
startLineNumber: 1, |
||||
}, |
||||
]); |
||||
}); |
||||
|
||||
test('Validates multi-line queries', () => { |
||||
const query = ` |
||||
{place="luna"}
|
||||
# this is a comment
|
||||
| |
||||
logfmt fail |
||||
|= "a"`;
|
||||
const queryLines = query.split('\n'); |
||||
expect(validateQuery(query, query, queryLines)).toEqual([ |
||||
{ |
||||
endColumn: 12, |
||||
endLineNumber: 5, |
||||
error: 'fail', |
||||
startColumn: 8, |
||||
startLineNumber: 5, |
||||
}, |
||||
]); |
||||
}); |
||||
}); |
@ -0,0 +1,115 @@ |
||||
import { SyntaxNode } from '@lezer/common'; |
||||
|
||||
import { parser } from '@grafana/lezer-logql'; |
||||
import { ErrorId } from 'app/plugins/datasource/prometheus/querybuilder/shared/parsingUtils'; |
||||
|
||||
interface ParserErrorBoundary { |
||||
startLineNumber: number; |
||||
startColumn: number; |
||||
endLineNumber: number; |
||||
endColumn: number; |
||||
error: string; |
||||
} |
||||
|
||||
interface ParseError { |
||||
text: string; |
||||
node: SyntaxNode; |
||||
} |
||||
|
||||
export function validateQuery( |
||||
query: string, |
||||
interpolatedQuery: string, |
||||
queryLines: string[] |
||||
): ParserErrorBoundary[] | false { |
||||
if (!query) { |
||||
return false; |
||||
} |
||||
|
||||
/** |
||||
* To provide support to variable interpolation in query validation, we run the parser in the interpolated |
||||
* query. If there are errors there, we trace them back to the original unparsed query, so we can more |
||||
* accurately highlight the error in the query, since it's likely that the variable name and variable value |
||||
* have different lengths. With this, we also exclude irrelevant parser errors that are produced by |
||||
* lezer not understanding $variables and $__variables, which usually generate 2 or 3 error SyntaxNode. |
||||
*/ |
||||
const interpolatedErrors: ParseError[] = parseQuery(interpolatedQuery); |
||||
if (!interpolatedErrors.length) { |
||||
return false; |
||||
} |
||||
|
||||
let parseErrors: ParseError[] = interpolatedErrors; |
||||
if (query !== interpolatedQuery) { |
||||
const queryErrors: ParseError[] = parseQuery(query); |
||||
parseErrors = interpolatedErrors.flatMap( |
||||
(interpolatedError) => |
||||
queryErrors.filter((queryError) => interpolatedError.text === queryError.text) || interpolatedError |
||||
); |
||||
} |
||||
|
||||
return parseErrors.map((parseError) => findErrorBoundary(query, queryLines, parseError)).filter(isErrorBoundary); |
||||
} |
||||
|
||||
function parseQuery(query: string) { |
||||
const parseErrors: ParseError[] = []; |
||||
const tree = parser.parse(query); |
||||
tree.iterate({ |
||||
enter: (nodeRef): false | void => { |
||||
if (nodeRef.type.id === ErrorId) { |
||||
const node = nodeRef.node; |
||||
parseErrors.push({ |
||||
node: node, |
||||
text: query.substring(node.from, node.to), |
||||
}); |
||||
} |
||||
}, |
||||
}); |
||||
return parseErrors; |
||||
} |
||||
|
||||
function findErrorBoundary(query: string, queryLines: string[], parseError: ParseError): ParserErrorBoundary | null { |
||||
if (queryLines.length === 1) { |
||||
const isEmptyString = parseError.node.from === parseError.node.to; |
||||
const errorNode = isEmptyString && parseError.node.parent ? parseError.node.parent : parseError.node; |
||||
const error = isEmptyString ? query.substring(errorNode.from, errorNode.to) : parseError.text; |
||||
return { |
||||
startLineNumber: 1, |
||||
startColumn: errorNode.from + 1, |
||||
endLineNumber: 1, |
||||
endColumn: errorNode.to + 1, |
||||
error, |
||||
}; |
||||
} |
||||
|
||||
let startPos = 0, |
||||
endPos = 0; |
||||
for (let line = 0; line < queryLines.length; line++) { |
||||
endPos = startPos + queryLines[line].length; |
||||
|
||||
if (parseError.node.from > endPos) { |
||||
startPos += queryLines[line].length + 1; |
||||
continue; |
||||
} |
||||
|
||||
return { |
||||
startLineNumber: line + 1, |
||||
startColumn: parseError.node.from - startPos + 1, |
||||
endLineNumber: line + 1, |
||||
endColumn: parseError.node.to - startPos + 1, |
||||
error: parseError.text, |
||||
}; |
||||
} |
||||
|
||||
return null; |
||||
} |
||||
|
||||
function isErrorBoundary(boundary: ParserErrorBoundary | null): boundary is ParserErrorBoundary { |
||||
return boundary !== null; |
||||
} |
||||
|
||||
export const placeHolderScopedVars = { |
||||
__interval: { text: '1s', value: '1s' }, |
||||
__interval_ms: { text: '1000', value: 1000 }, |
||||
__range_ms: { text: '1000', value: 1000 }, |
||||
__range_s: { text: '1', value: 1 }, |
||||
__range: { text: '1s', value: '1s' }, |
||||
}; |
Loading…
Reference in new issue