Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -215,4 +215,19 @@ describe('correctCommonEsqlMistakes', () => {
| STATS success_rate = AVG(successful)`,
});
});

it('escapes special characters in column names', () => {
expectQuery({
input: `FROM "custom-test"
| STATS
count = COUNT(*),
min = MIN("Total Bytes"),
max = MAX("Total Bytes"),
avg = AVG("Total Bytes"),
sum = SUM("Total Bytes")
`,
expectedOutput: `FROM "custom-test"
| STATS count = COUNT(*), min = MIN(\`Total Bytes\`), max = MAX(\`Total Bytes\`), avg = AVG(\`Total Bytes\`), sum = SUM(\`Total Bytes\`)`,
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@
* 2.0.
*/

import { scalarFunctionDefinitions } from '@kbn/esql-validation-autocomplete/src/definitions/generated/scalar_functions';
import { aggregationFunctionDefinitions } from '@kbn/esql-validation-autocomplete/src/definitions/generated/aggregation_functions';
import type { FunctionDefinition } from '@kbn/esql-validation-autocomplete';
import { memoize } from 'lodash';

const STRING_DELIMITER_TOKENS = ['`', "'", '"'];
const ESCAPE_TOKEN = '\\\\';

Expand Down Expand Up @@ -94,6 +99,62 @@ function removeColumnQuotesAndEscape(column: string) {
return '`' + plainColumnIdentifier + '`';
}

const getFunctionDefinitionMap = memoize(() => {
const functionDefinitionMap = new Map<string, FunctionDefinition>();
const allFunctionDefinitions = [...scalarFunctionDefinitions, ...aggregationFunctionDefinitions];
allFunctionDefinitions.forEach((definition) => {
const functionName = definition.name.toLowerCase();
if (!functionDefinitionMap.has(functionName)) {
functionDefinitionMap.set(functionName, definition);
}
});
return functionDefinitionMap;
});

/**
* Replaces quotes for fields in function argument if present.
* @example
* Example 1: Without quotes
* escapeColumnsInFunctions('MIN(total_bytes)'); // 'MIN(total_bytes)'
*
* @example
* Example 2: With quotes
* escapeColumnsInFunctions('MIN("Total Bytes")'); // 'MIN(`Total Bytes`)'
*/
function escapeColumnsInFunctions(string: string): string {
const regex = /([A-Za-z_]+)\s*\(([^()]*?)\)/g;

return string.replace(regex, (match: string, functionName: string, args: string) => {
const functionDefinition = getFunctionDefinitionMap().get(functionName.toLowerCase());
if (!functionDefinition) {
// function definition not found, return the original match
return match;
}

const escapedArgs = args.length
? args
.split(',')
.map((arg, index) => {
const trimmedArg = arg.trim();
// Only escape field names
const paramName = functionDefinition.signatures[0].params[index]?.name;
if (paramName !== 'field' && paramName !== 'number') {
// It should be just a field, but some functions like SUM and AVG have a "number" name 🤷‍♂️
return trimmedArg;
}
// If the string is not wrapped in quotes, return it as is
if (!trimmedArg.match(/^["'].*["']$/)) {
return trimmedArg;
}
return removeColumnQuotesAndEscape(trimmedArg);
})
.join(', ')
: args;

return `${functionName}(${escapedArgs})`;
});
}

function replaceAsKeywordWithAssignments(command: string) {
return command.replaceAll(/^STATS\s*(.*)/g, (__, statsOperations: string) => {
return `STATS ${statsOperations.replaceAll(
Expand All @@ -113,10 +174,12 @@ function escapeColumns(line: string) {
const escapedBody = split(body.trim(), ',')
.map((statement) => {
const [lhs, rhs] = split(statement, '=');

if (!rhs) {
return lhs;
}
return `${removeColumnQuotesAndEscape(lhs)} = ${rhs}`;
const escapedRhs = escapeColumnsInFunctions(rhs);
return `${removeColumnQuotesAndEscape(lhs)} = ${escapedRhs}`;
})
.join(', ');

Expand Down