[ES|QL] Fix some validation misconfiguration (#177783)

## Summary

Related issue #177699

Fix variables logic for expressions at `stats by ...` level.
Fix validation logic for agg functions within `eval` or `where` scope.
Fix validation and autocomplete logic for nested quoted expressions
  * i.e. 
  ```
from index | eval round(numberField) + 1 | eval `round(numberField) + 1`
+ 1 | eval ```round(numberField) + 1`` + 1` + 1 | eval
```````round(numberField) + 1```` + 1`` + 1` + 1 | eval
```````````````round(numberField) + 1```````` + 1```` + 1`` + 1` + 1 |
keep ```````````````````````````````round(numberField) +
1```````````````` + 1```````` + 1```` + 1`` + 1`
  ```
* updated `count_distinct` agg definition to have the `precision` second
optional param.

### Checklist

- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios

---------

Co-authored-by: Stratoula Kalafateli <efstratia.kalafateli@elastic.co>
This commit is contained in:
Marco Liberati 2024-03-07 13:57:27 +01:00 committed by GitHub
parent aaf3ad76ac
commit cad276fcbd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 558 additions and 103 deletions

View file

@ -562,6 +562,18 @@ describe('autocomplete', () => {
`from a | ${command} stringField, `,
getFieldNamesByType('any').filter((name) => name !== 'stringField')
);
testSuggestions(
`from a_index | eval round(numberField) + 1 | eval \`round(numberField) + 1\` + 1 | eval \`\`\`round(numberField) + 1\`\` + 1\` + 1 | eval \`\`\`\`\`\`\`round(numberField) + 1\`\`\`\` + 1\`\` + 1\` + 1 | eval \`\`\`\`\`\`\`\`\`\`\`\`\`\`\`round(numberField) + 1\`\`\`\`\`\`\`\` + 1\`\`\`\` + 1\`\` + 1\` + 1 | ${command} `,
[
...getFieldNamesByType('any'),
'`round(numberField) + 1`',
'```round(numberField) + 1`` + 1`',
'```````round(numberField) + 1```` + 1`` + 1`',
'```````````````round(numberField) + 1```````` + 1```` + 1`` + 1`',
'```````````````````````````````round(numberField) + 1```````````````` + 1```````` + 1```` + 1`` + 1`',
]
);
});
}
@ -927,10 +939,7 @@ describe('autocomplete', () => {
[
'var0 =',
...getFieldNamesByType('any'),
// @TODO: leverage the location data to get the original text
// For now return back the trimmed version:
// the ANTLR parser trims all text so that's what it's stored in the AST
'`abs(numberField)+1`',
'`abs(numberField) + 1`',
...getFunctionSignaturesByReturnType('eval', 'any', { evalMath: true }),
],
' '

View file

@ -71,7 +71,7 @@ import {
buildOptionDefinition,
buildSettingDefinitions,
} from './factories';
import { EDITOR_MARKER } from '../shared/constants';
import { EDITOR_MARKER, SINGLE_BACKTICK } from '../shared/constants';
import { getAstContext, removeMarkerArgFromArgsList } from '../shared/context';
import {
buildQueryUntilPreviousCommand,
@ -563,7 +563,7 @@ async function getExpressionSuggestionsByType(
// collect all fields + variables to suggest
const fieldsMap: Map<string, ESQLRealField> = await (argDef ? getFieldsMap() : new Map());
const anyVariables = collectVariables(commands, fieldsMap);
const anyVariables = collectVariables(commands, fieldsMap, innerText);
// enrich with assignment has some special rules who are handled somewhere else
const canHaveAssignments = ['eval', 'stats', 'row'].includes(command.name);
@ -1017,13 +1017,20 @@ async function getFieldsOrFunctionsSuggestions(
}
// due to a bug on the ES|QL table side, filter out fields list with underscored variable names (??)
// avg( numberField ) => avg_numberField_
const ALPHANUMERIC_REGEXP = /[^a-zA-Z\d]/g;
if (
filteredVariablesByType.length &&
filteredVariablesByType.some((v) => /[^a-zA-Z\d]/.test(v))
filteredVariablesByType.some((v) => ALPHANUMERIC_REGEXP.test(v))
) {
for (const variable of filteredVariablesByType) {
const underscoredName = variable.replace(/[^a-zA-Z\d]/g, '_');
const index = filteredFieldsByType.findIndex(({ label }) => underscoredName === label);
// remove backticks if present
const sanitizedVariable = variable.startsWith(SINGLE_BACKTICK)
? variable.slice(1, variable.length - 1)
: variable;
const underscoredName = sanitizedVariable.replace(ALPHANUMERIC_REGEXP, '_');
const index = filteredFieldsByType.findIndex(
({ label }) => underscoredName === label || `_${underscoredName}_` === label
);
if (index >= 0) {
filteredFieldsByType.splice(index);
}
@ -1067,7 +1074,8 @@ async function getFunctionArgsSuggestions(
const variablesExcludingCurrentCommandOnes = excludeVariablesFromCurrentCommand(
commands,
command,
fieldsMap
fieldsMap,
innerText
);
// pick the type of the next arg
const shouldGetNextArgument = node.text.includes(EDITOR_MARKER);
@ -1102,7 +1110,10 @@ async function getFunctionArgsSuggestions(
const isUnknownColumn =
arg &&
isColumnItem(arg) &&
!columnExists(arg, { fields: fieldsMap, variables: variablesExcludingCurrentCommandOnes }).hit;
!columnExists(arg, {
fields: fieldsMap,
variables: variablesExcludingCurrentCommandOnes,
}).hit;
if (noArgDefined || isUnknownColumn) {
const commandArgIndex = command.args.findIndex(
(cmdArg) => isSingleItem(cmdArg) && cmdArg.location.max >= node.location.max
@ -1213,7 +1224,7 @@ async function getListArgsSuggestions(
// so extract the type of the first argument and suggest fields of that type
if (node && isFunctionItem(node)) {
const fieldsMap: Map<string, ESQLRealField> = await getFieldsMaps();
const anyVariables = collectVariables(commands, fieldsMap);
const anyVariables = collectVariables(commands, fieldsMap, innerText);
// extract the current node from the variables inferred
anyVariables.forEach((values, key) => {
if (values.some((v) => v.location === node.location)) {
@ -1301,7 +1312,7 @@ async function getOptionArgsSuggestions(
const isNewExpression = isRestartingExpression(innerText) || option.args.length === 0;
const fieldsMap = await getFieldsMaps();
const anyVariables = collectVariables(commands, fieldsMap);
const anyVariables = collectVariables(commands, fieldsMap, innerText);
const references = {
fields: fieldsMap,
@ -1339,7 +1350,8 @@ async function getOptionArgsSuggestions(
const policyMetadata = await getPolicyMetadata(policyName);
const anyEnhancedVariables = collectVariables(
commands,
appendEnrichFields(fieldsMap, policyMetadata)
appendEnrichFields(fieldsMap, policyMetadata),
innerText
);
if (isNewExpression) {

View file

@ -129,7 +129,7 @@ export const buildFieldsDefinitions = (fields: string[]): AutocompleteCommandDef
export const buildVariablesDefinitions = (variables: string[]): AutocompleteCommandDefinition[] =>
variables.map((label) => ({
label,
insertText: getSafeInsertText(label),
insertText: label,
kind: 4,
detail: i18n.translate('monaco.esql.autocomplete.variableDefinition', {
defaultMessage: `Variable specified by the user within the ES|QL query`,

View file

@ -125,7 +125,10 @@ export const statsAggregationFunctionDefinitions: FunctionDefinition[] = [
supportedCommands: ['stats'],
signatures: [
{
params: [{ name: 'column', type: 'any', noNestingFunctions: true }],
params: [
{ name: 'column', type: 'any', noNestingFunctions: true },
{ name: 'precision', type: 'number', noNestingFunctions: true, optional: true },
],
returnType: 'number',
examples: [
`from index | stats result = count_distinct(field)`,

View file

@ -8,7 +8,7 @@
export const EDITOR_MARKER = 'marker_esql_editor';
export const TICKS_REGEX = /^(`)|(`)$/g;
export const TICKS_REGEX = /^`{1}|`{1}$/g;
export const DOUBLE_TICKS_REGEX = /``/g;
export const SINGLE_TICK_REGEX = /`/g;
export const SINGLE_BACKTICK = '`';

View file

@ -352,7 +352,8 @@ export function isEqualType(
item: ESQLSingleAstItem,
argDef: SignatureArgType,
references: ReferenceMaps,
parentCommand?: string
parentCommand?: string,
nameHit?: string
) {
const argType = 'innerType' in argDef && argDef.innerType ? argDef.innerType : argDef.type;
if (argType === 'any') {
@ -375,10 +376,8 @@ export function isEqualType(
// anything goes, so avoid any effort here
return true;
}
// perform a double check, but give priority to the non trimmed version
const hit = getColumnHit(item.name, references);
const hitTrimmed = getColumnHit(item.name.replace(/\s/g, ''), references);
const validHit = hit || hitTrimmed;
const hit = getColumnHit(nameHit ?? item.name, references);
const validHit = hit;
if (!validHit) {
return false;
}
@ -445,9 +444,9 @@ export function columnExists(
return { hit: true, nameHit: column.name };
}
if (column.quoted) {
const trimmedName = column.name.replace(/`/g, '``').replace(/\s/g, '');
if (variables.has(trimmedName)) {
return { hit: true, nameHit: trimmedName };
const originalName = column.text;
if (variables.has(originalName)) {
return { hit: true, nameHit: originalName };
}
}
if (

View file

@ -6,9 +6,9 @@
* Side Public License, v 1.
*/
import type { ESQLColumn, ESQLAstItem, ESQLCommand, ESQLCommandOption } from '../types';
import type { ESQLAstItem, ESQLCommand, ESQLCommandOption, ESQLFunction } from '../types';
import type { ESQLVariable, ESQLRealField } from '../validation/types';
import { DOUBLE_TICKS_REGEX, EDITOR_MARKER, SINGLE_BACKTICK, TICKS_REGEX } from './constants';
import { DOUBLE_BACKTICK, EDITOR_MARKER, SINGLE_BACKTICK } from './constants';
import {
isColumnItem,
isAssignment,
@ -26,21 +26,6 @@ function addToVariableOccurrencies(variables: Map<string, ESQLVariable[]>, insta
variablesOccurrencies.push(instance);
}
function replaceTrimmedVariable(
variables: Map<string, ESQLVariable[]>,
newRef: ESQLColumn,
oldRef: ESQLVariable[]
) {
// now replace the existing trimmed version with this original one
addToVariableOccurrencies(variables, {
name: newRef.name,
type: oldRef[0].type,
location: newRef.location,
});
// remove the trimmed one
variables.delete(oldRef[0].name);
}
function addToVariables(
oldArg: ESQLAstItem,
newArg: ESQLAstItem,
@ -55,20 +40,11 @@ function addToVariables(
};
// Now workout the exact type
// it can be a rename of another variable as well
let oldRef = fields.get(oldArg.name) || variables.get(oldArg.name);
const oldRef =
fields.get(oldArg.name) || variables.get(oldArg.quoted ? oldArg.text : oldArg.name);
if (oldRef) {
addToVariableOccurrencies(variables, newVariable);
newVariable.type = Array.isArray(oldRef) ? oldRef[0].type : oldRef.type;
} else if (oldArg.quoted) {
// a last attempt in case the user tried to rename an expression:
// trim every space and try a new hit
const expressionTrimmedRef = oldArg.name.replace(/\s/g, '');
oldRef = variables.get(expressionTrimmedRef);
if (oldRef) {
addToVariableOccurrencies(variables, newVariable);
newVariable.type = oldRef[0].type;
replaceTrimmedVariable(variables, oldArg, oldRef);
}
}
}
}
@ -99,10 +75,11 @@ function getAssignRightHandSideType(item: ESQLAstItem, fields: Map<string, ESQLR
export function excludeVariablesFromCurrentCommand(
commands: ESQLCommand[],
currentCommand: ESQLCommand,
fieldsMap: Map<string, ESQLRealField>
fieldsMap: Map<string, ESQLRealField>,
queryString: string
) {
const anyVariables = collectVariables(commands, fieldsMap);
const currentCommandVariables = collectVariables([currentCommand], fieldsMap);
const anyVariables = collectVariables(commands, fieldsMap, queryString);
const currentCommandVariables = collectVariables([currentCommand], fieldsMap, queryString);
const resultVariables = new Map<string, ESQLVariable[]>();
anyVariables.forEach((value, key) => {
if (!currentCommandVariables.has(key)) {
@ -112,36 +89,65 @@ export function excludeVariablesFromCurrentCommand(
return resultVariables;
}
function extractExpressionAsQuotedVariable(
originalQuery: string,
location: { min: number; max: number }
) {
const extractExpressionText = originalQuery.substring(location.min, location.max + 1);
// now inject quotes and save it as variable
return `\`${extractExpressionText.replaceAll(SINGLE_BACKTICK, DOUBLE_BACKTICK)}\``;
}
function addVariableFromAssignment(
assignOperation: ESQLFunction,
variables: Map<string, ESQLVariable[]>,
fields: Map<string, ESQLRealField>
) {
if (isColumnItem(assignOperation.args[0])) {
const rightHandSideArgType = getAssignRightHandSideType(assignOperation.args[1], fields);
addToVariableOccurrencies(variables, {
name: assignOperation.args[0].name,
type: rightHandSideArgType || 'number' /* fallback to number */,
location: assignOperation.args[0].location,
});
}
}
function addVariableFromExpression(
expressionOperation: ESQLFunction,
queryString: string,
variables: Map<string, ESQLVariable[]>
) {
if (!expressionOperation.text.includes(EDITOR_MARKER)) {
// save the variable in its quoted usable way
// (a bit of forward thinking here to simplyfy lookups later)
const forwardThinkingVariableName = extractExpressionAsQuotedVariable(
queryString,
expressionOperation.location
);
const expressionType = 'number';
addToVariableOccurrencies(variables, {
name: forwardThinkingVariableName,
type: expressionType,
location: expressionOperation.location,
});
}
}
export function collectVariables(
commands: ESQLCommand[],
fields: Map<string, ESQLRealField>
fields: Map<string, ESQLRealField>,
queryString: string
): Map<string, ESQLVariable[]> {
const variables = new Map<string, ESQLVariable[]>();
for (const command of commands) {
if (['row', 'eval', 'stats'].includes(command.name)) {
const assignOperations = command.args.filter(isAssignment);
for (const assignOperation of assignOperations) {
if (isColumnItem(assignOperation.args[0])) {
const rightHandSideArgType = getAssignRightHandSideType(assignOperation.args[1], fields);
addToVariableOccurrencies(variables, {
name: assignOperation.args[0].name,
type: rightHandSideArgType || 'number' /* fallback to number */,
location: assignOperation.args[0].location,
});
for (const arg of command.args) {
if (isAssignment(arg)) {
addVariableFromAssignment(arg, variables, fields);
}
}
const expressionOperations = command.args.filter(isExpression);
for (const expressionOperation of expressionOperations) {
if (!expressionOperation.text.includes(EDITOR_MARKER)) {
// just save the entire expression as variable string
const expressionType = 'number';
addToVariableOccurrencies(variables, {
name: expressionOperation.text
.replace(TICKS_REGEX, '')
.replace(DOUBLE_TICKS_REGEX, SINGLE_BACKTICK),
type: expressionType,
location: expressionOperation.location,
});
if (isExpression(arg)) {
addVariableFromExpression(arg, queryString, variables);
}
}
if (command.name === 'stats') {
@ -149,18 +155,12 @@ export function collectVariables(
(arg) => isOptionItem(arg) && arg.name === 'by'
) as ESQLCommandOption[];
for (const commandOption of commandOptionsWithAssignment) {
const optionAssignOperations = commandOption.args.filter(isAssignment);
for (const assignOperation of optionAssignOperations) {
if (isColumnItem(assignOperation.args[0])) {
const rightHandSideArgType = getAssignRightHandSideType(
assignOperation.args[1],
fields
);
addToVariableOccurrencies(variables, {
name: assignOperation.args[0].name,
type: rightHandSideArgType || 'number' /* fallback to number */,
location: assignOperation.args[0].location,
});
for (const optArg of commandOption.args) {
if (isAssignment(optArg)) {
addVariableFromAssignment(optArg, variables, fields);
}
if (isExpression(optArg)) {
addVariableFromExpression(optArg, queryString, variables);
}
}
}
@ -171,6 +171,7 @@ export function collectVariables(
(arg) => isOptionItem(arg) && arg.name === 'with'
) as ESQLCommandOption[];
for (const commandOption of commandOptionsWithAssignment) {
// Enrich assignment has some special behaviour, so do not use the version above here...
for (const assignFn of commandOption.args) {
if (isFunctionItem(assignFn)) {
const [newArg, oldArg] = assignFn?.args || [];

View file

@ -3486,6 +3486,78 @@
"query": "from a_index | where geoPointField Is nOt NuLL",
"error": false
},
{
"query": "from a_index | where avg(numberField)",
"error": true
},
{
"query": "from a_index | where avg(numberField) > 0",
"error": true
},
{
"query": "from a_index | where max(numberField)",
"error": true
},
{
"query": "from a_index | where max(numberField) > 0",
"error": true
},
{
"query": "from a_index | where min(numberField)",
"error": true
},
{
"query": "from a_index | where min(numberField) > 0",
"error": true
},
{
"query": "from a_index | where sum(numberField)",
"error": true
},
{
"query": "from a_index | where sum(numberField) > 0",
"error": true
},
{
"query": "from a_index | where median(numberField)",
"error": true
},
{
"query": "from a_index | where median(numberField) > 0",
"error": true
},
{
"query": "from a_index | where median_absolute_deviation(numberField)",
"error": true
},
{
"query": "from a_index | where median_absolute_deviation(numberField) > 0",
"error": true
},
{
"query": "from a_index | where percentile(numberField, 5)",
"error": true
},
{
"query": "from a_index | where percentile(numberField, 5) > 0",
"error": true
},
{
"query": "from a_index | where count(stringField)",
"error": true
},
{
"query": "from a_index | where count(stringField) > 0",
"error": true
},
{
"query": "from a_index | where count_distinct(stringField, numberField)",
"error": true
},
{
"query": "from a_index | where count_distinct(stringField, numberField) > 0",
"error": true
},
{
"query": "from a_index | where abs(numberField) > 0",
"error": false
@ -4426,6 +4498,182 @@
"query": "from a_index | eval %+ numberField",
"error": true
},
{
"query": "from a_index | eval var = avg(numberField)",
"error": true
},
{
"query": "from a_index | eval var = avg(numberField) > 0",
"error": true
},
{
"query": "from a_index | eval avg(numberField)",
"error": true
},
{
"query": "from a_index | eval avg(numberField) > 0",
"error": true
},
{
"query": "from a_index | eval var = max(numberField)",
"error": true
},
{
"query": "from a_index | eval var = max(numberField) > 0",
"error": true
},
{
"query": "from a_index | eval max(numberField)",
"error": true
},
{
"query": "from a_index | eval max(numberField) > 0",
"error": true
},
{
"query": "from a_index | eval var = min(numberField)",
"error": true
},
{
"query": "from a_index | eval var = min(numberField) > 0",
"error": true
},
{
"query": "from a_index | eval min(numberField)",
"error": true
},
{
"query": "from a_index | eval min(numberField) > 0",
"error": true
},
{
"query": "from a_index | eval var = sum(numberField)",
"error": true
},
{
"query": "from a_index | eval var = sum(numberField) > 0",
"error": true
},
{
"query": "from a_index | eval sum(numberField)",
"error": true
},
{
"query": "from a_index | eval sum(numberField) > 0",
"error": true
},
{
"query": "from a_index | eval var = median(numberField)",
"error": true
},
{
"query": "from a_index | eval var = median(numberField) > 0",
"error": true
},
{
"query": "from a_index | eval median(numberField)",
"error": true
},
{
"query": "from a_index | eval median(numberField) > 0",
"error": true
},
{
"query": "from a_index | eval var = median_absolute_deviation(numberField)",
"error": true
},
{
"query": "from a_index | eval var = median_absolute_deviation(numberField) > 0",
"error": true
},
{
"query": "from a_index | eval median_absolute_deviation(numberField)",
"error": true
},
{
"query": "from a_index | eval median_absolute_deviation(numberField) > 0",
"error": true
},
{
"query": "from a_index | eval var = percentile(numberField, 5)",
"error": true
},
{
"query": "from a_index | eval var = percentile(numberField, 5) > 0",
"error": true
},
{
"query": "from a_index | eval percentile(numberField, 5)",
"error": true
},
{
"query": "from a_index | eval percentile(numberField, 5) > 0",
"error": true
},
{
"query": "from a_index | eval var = count(stringField)",
"error": true
},
{
"query": "from a_index | eval var = count(stringField) > 0",
"error": true
},
{
"query": "from a_index | eval count(stringField)",
"error": true
},
{
"query": "from a_index | eval count(stringField) > 0",
"error": true
},
{
"query": "from a_index | eval var = count_distinct(stringField, numberField)",
"error": true
},
{
"query": "from a_index | eval var = count_distinct(stringField, numberField) > 0",
"error": true
},
{
"query": "from a_index | eval count_distinct(stringField, numberField)",
"error": true
},
{
"query": "from a_index | eval count_distinct(stringField, numberField) > 0",
"error": true
},
{
"query": "from a_index | eval var = st_centroid(cartesianPointField)",
"error": true
},
{
"query": "from a_index | eval var = st_centroid(cartesianPointField) > 0",
"error": true
},
{
"query": "from a_index | eval st_centroid(cartesianPointField)",
"error": true
},
{
"query": "from a_index | eval st_centroid(cartesianPointField) > 0",
"error": true
},
{
"query": "from a_index | eval var = st_centroid(geoPointField)",
"error": true
},
{
"query": "from a_index | eval var = st_centroid(geoPointField) > 0",
"error": true
},
{
"query": "from a_index | eval st_centroid(geoPointField)",
"error": true
},
{
"query": "from a_index | eval st_centroid(geoPointField) > 0",
"error": true
},
{
"query": "from a_index | eval var = abs(numberField)",
"error": false
@ -7979,27 +8227,27 @@
"error": false
},
{
"query": "from a_index | stats var = count_distinct(stringField)",
"query": "from a_index | stats var = count_distinct(stringField, numberField)",
"error": false
},
{
"query": "from a_index | stats count_distinct(stringField)",
"query": "from a_index | stats count_distinct(stringField, numberField)",
"error": false
},
{
"query": "from a_index | stats var = round(count_distinct(stringField))",
"query": "from a_index | stats var = round(count_distinct(stringField, numberField))",
"error": false
},
{
"query": "from a_index | stats round(count_distinct(stringField))",
"query": "from a_index | stats round(count_distinct(stringField, numberField))",
"error": false
},
{
"query": "from a_index | stats var = round(count_distinct(stringField)) + count_distinct(stringField)",
"query": "from a_index | stats var = round(count_distinct(stringField, numberField)) + count_distinct(stringField, numberField)",
"error": false
},
{
"query": "from a_index | stats round(count_distinct(stringField)) + count_distinct(stringField)",
"query": "from a_index | stats round(count_distinct(stringField, numberField)) + count_distinct(stringField, numberField)",
"error": false
},
{
@ -8054,6 +8302,10 @@
"query": "FROM index\n | EVAL numberField * 3.281\n | STATS avg_numberField = AVG(`numberField * 3.281`)",
"error": false
},
{
"query": "FROM index | STATS AVG(numberField) by round(numberField) + 1 | EVAL `round(numberField) + 1` / 2",
"error": false
},
{
"query": "from a_index | sort ",
"error": true
@ -8353,6 +8605,22 @@
{
"query": "from a_index | eval numberField = \"5\"",
"error": false
},
{
"query": "from a_index | eval round(numberField) + 1 | eval `round(numberField) + 1` + 1 | keep ```round(numberField) + 1`` + 1`",
"error": false
},
{
"query": "from a_index | eval round(numberField) + 1 | eval `round(numberField) + 1` + 1 | eval ```round(numberField) + 1`` + 1` + 1 | keep ```````round(numberField) + 1```` + 1`` + 1`",
"error": false
},
{
"query": "from a_index | eval round(numberField) + 1 | eval `round(numberField) + 1` + 1 | eval ```round(numberField) + 1`` + 1` + 1 | eval ```````round(numberField) + 1```` + 1`` + 1` + 1 | keep ```````````````round(numberField) + 1```````` + 1```` + 1`` + 1`",
"error": false
},
{
"query": "from a_index | eval round(numberField) + 1 | eval `round(numberField) + 1` + 1 | eval ```round(numberField) + 1`` + 1` + 1 | eval ```````round(numberField) + 1```` + 1`` + 1` + 1 | eval ```````````````round(numberField) + 1```````` + 1```` + 1`` + 1` + 1 | keep ```````````````````````````````round(numberField) + 1```````````````` + 1```````` + 1```` + 1`` + 1`",
"error": false
}
]
}

View file

@ -33,6 +33,7 @@ export interface ReferenceMaps {
fields: Map<string, ESQLRealField>;
policies: Map<string, ESQLPolicy>;
metadataFields: Set<string>;
query: string;
}
export interface ValidationErrors {

View file

@ -60,6 +60,11 @@ const policies = [
},
];
const NESTING_LEVELS = 4;
const NESTED_DEPTHS = Array(NESTING_LEVELS)
.fill(0)
.map((_, i) => i + 1);
function getCallbackMocks() {
return {
getFieldsFor: jest.fn(async ({ query }) => {
@ -962,7 +967,7 @@ describe('validation logic', () => {
]);
}
for (const nesting of [1, 2, 3, 4]) {
for (const nesting of NESTED_DEPTHS) {
for (const evenOp of ['-', '+']) {
for (const oddOp of ['-', '+']) {
// This builds a combination of +/- operators
@ -1055,6 +1060,48 @@ describe('validation logic', () => {
testErrorsAndWarnings(`from a_index | where ${camelCase(field)}Field Is nOt NuLL`, []);
}
for (const {
name,
alias,
signatures,
...defRest
} of statsAggregationFunctionDefinitions.filter(
({ name: fnName, signatures: statsSignatures }) =>
statsSignatures.some(({ returnType, params }) => ['number'].includes(returnType))
)) {
for (const { params, infiniteParams, ...signRest } of signatures) {
const fieldMapping = getFieldMapping(params);
testErrorsAndWarnings(
`from a_index | where ${
getFunctionSignatures(
{
name,
...defRest,
signatures: [{ params: fieldMapping, ...signRest }],
},
{ withTypes: false }
)[0].declaration
}`,
[`WHERE does not support function ${name}`]
);
testErrorsAndWarnings(
`from a_index | where ${
getFunctionSignatures(
{
name,
...defRest,
signatures: [{ params: fieldMapping, ...signRest }],
},
{ withTypes: false }
)[0].declaration
} > 0`,
[`WHERE does not support function ${name}`]
);
}
}
// Test that all functions work in where
const numericOrStringFunctions = evalFunctionsDefinitions.filter(({ name, signatures }) => {
return signatures.some(
@ -1168,7 +1215,7 @@ describe('validation logic', () => {
testErrorsAndWarnings(`from a_index | eval ${camelCase(field)}Field IS not NULL`, []);
}
for (const nesting of [1, 2, 3, 4]) {
for (const nesting of NESTED_DEPTHS) {
for (const evenOp of ['-', '+']) {
for (const oddOp of ['-', '+']) {
// This builds a combination of +/- operators
@ -1198,6 +1245,67 @@ describe('validation logic', () => {
]);
}
for (const { name, alias, signatures, ...defRest } of statsAggregationFunctionDefinitions) {
for (const { params, infiniteParams, ...signRest } of signatures) {
const fieldMapping = getFieldMapping(params);
testErrorsAndWarnings(
`from a_index | eval var = ${
getFunctionSignatures(
{
name,
...defRest,
signatures: [{ params: fieldMapping, ...signRest }],
},
{ withTypes: false }
)[0].declaration
}`,
[`EVAL does not support function ${name}`]
);
testErrorsAndWarnings(
`from a_index | eval var = ${
getFunctionSignatures(
{
name,
...defRest,
signatures: [{ params: fieldMapping, ...signRest }],
},
{ withTypes: false }
)[0].declaration
} > 0`,
[`EVAL does not support function ${name}`]
);
testErrorsAndWarnings(
`from a_index | eval ${
getFunctionSignatures(
{
name,
...defRest,
signatures: [{ params: fieldMapping, ...signRest }],
},
{ withTypes: false }
)[0].declaration
}`,
[`EVAL does not support function ${name}`]
);
testErrorsAndWarnings(
`from a_index | eval ${
getFunctionSignatures(
{
name,
...defRest,
signatures: [{ params: fieldMapping, ...signRest }],
},
{ withTypes: false }
)[0].declaration
} > 0`,
[`EVAL does not support function ${name}`]
);
}
}
for (const { name, alias, signatures, ...defRest } of evalFunctionsDefinitions) {
for (const { params, infiniteParams, ...signRest } of signatures) {
const fieldMapping = getFieldMapping(params);
@ -1629,7 +1737,7 @@ describe('validation logic', () => {
'At least one aggregation function required in [STATS], found [numberField+1]',
]);
for (const nesting of [1, 2, 3, 4]) {
for (const nesting of NESTED_DEPTHS) {
const moreBuiltinWrapping = Array(nesting).fill('+1').join('');
testErrorsAndWarnings(`from a_index | stats 5 + avg(numberField) ${moreBuiltinWrapping}`, []);
testErrorsAndWarnings(`from a_index | stats 5 ${moreBuiltinWrapping} + avg(numberField)`, []);
@ -1957,6 +2065,11 @@ describe('validation logic', () => {
| STATS avg_numberField = AVG(\`numberField * 3.281\`)`,
[]
);
testErrorsAndWarnings(
`FROM index | STATS AVG(numberField) by round(numberField) + 1 | EVAL \`round(numberField) + 1\` / 2`,
[]
);
});
describe('sort', () => {
@ -2132,6 +2245,52 @@ describe('validation logic', () => {
);
});
describe('quoting and escaping expressions', () => {
function getTicks(amount: number) {
return Array(amount).fill('`').join('');
}
/**
* Given an initial quoted expression, build a new quoted expression
* that appends as many +1 to the previous one based on the nesting level
* i.e. given the expression `round(...) + 1` returns
* ```round(...) + 1`` + 1` (for nesting 1)
* ```````round(...) + 1```` + 1`` + 1` (for nesting 2)
* etc...
* Note how backticks double for each level + wrapping quotes
* The general rule follows an exponential curve given a nesting N:
* (`){ (2^N)-1 } ticks expression (`){ 2^N-1 } +1 (`){ 2^N-2 } +1 ... +1
*
* Mind that nesting arg here is equivalent to N-1
*/
function buildNestedExpression(expr: string, nesting: number) {
const openingTicks = getTicks(Math.pow(2, nesting + 1) - 1);
const firstClosingBatch = getTicks(Math.pow(2, nesting));
const additionalPlusOneswithTicks = Array(nesting)
.fill(' + 1')
.reduce((acc, plusOneAppended, i) => {
// workout how many ticks to add: 2^N-i
const ticks = getTicks(Math.pow(2, nesting - 1 - i));
return `${acc}${plusOneAppended}${ticks}`;
}, '');
const ret = `${openingTicks}${expr}${firstClosingBatch}${additionalPlusOneswithTicks}`;
return ret;
}
for (const nesting of NESTED_DEPTHS) {
// start with a quotable expression
const expr = 'round(numberField) + 1';
const startingQuery = `from a_index | eval ${expr}`;
// now pipe for each nesting level a new eval command that appends a +1 to the previous quoted expression
const finalQuery = `${startingQuery} | ${Array(nesting)
.fill('')
.map((_, i) => {
return `eval ${buildNestedExpression(expr, i)} + 1`;
})
.join(' | ')} | keep ${buildNestedExpression(expr, nesting)}`;
testErrorsAndWarnings(finalQuery, []);
}
});
describe('callbacks', () => {
it(`should not fetch source and fields list when a row command is set`, async () => {
const callbackMocks = getCallbackMocks();

View file

@ -227,7 +227,7 @@ function validateFunctionColumnArg(
}
// do not validate any further for now, only count() accepts wildcard as args...
} else {
if (!isEqualType(actualArg, argDef, references, parentCommand)) {
if (!isEqualType(actualArg, argDef, references, parentCommand, nameHit)) {
// guaranteed by the check above
const columnHit = getColumnHit(nameHit!, references);
messages.push(
@ -381,7 +381,9 @@ function validateFunction(
parentCommand,
parentOption,
references,
isNested || !isAssignment(astFunction)
// use the nesting flag for now just for stats
// TODO: revisit this part later on to make it more generic
parentCommand === 'stats' ? isNested || !isAssignment(astFunction) : false
)
);
}
@ -867,7 +869,7 @@ export async function validateAst(
});
}
const variables = collectVariables(ast, availableFields);
const variables = collectVariables(ast, availableFields, queryString);
// notify if the user is rewriting a column as variable with another type
messages.push(...validateFieldsShadowing(availableFields, variables));
messages.push(...validateUnsupportedTypeFields(availableFields));
@ -879,6 +881,7 @@ export async function validateAst(
policies: availablePolicies,
variables,
metadataFields: availableMetadataFields,
query: queryString,
});
messages.push(...commandMessages);
}