Skip to content
2 changes: 2 additions & 0 deletions src/platform/packages/shared/kbn-esql-ast/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,5 @@ export {
export { EsqlQuery } from './src/query';

export * as mutate from './src/mutate';

export { singleItems, resolveItem, lastItem, firstItem } from './src/visitor/utils';
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import {
isLiteralItem,
isTimeIntervalItem,
sourceExists,
isParamExpressionType,
} from '../shared/helpers';
import type { ESQLSourceResult } from '../shared/types';
import { listCompleteItem, commaCompleteItem, pipeCompleteItem } from './complete_items';
Expand Down Expand Up @@ -605,12 +606,17 @@ export function checkFunctionInvocationComplete(
if (fnDefinition.name === 'in' && Array.isArray(func.args[1]) && !func.args[1].length) {
return { complete: false, reason: 'tooFewArgs' };
}

// If the function is complete, check that the types of the arguments match the function definition
const hasCorrectTypes = fnDefinition.signatures.some((def) => {
return func.args.every((a, index) => {
return (
fnDefinition.name.endsWith('null') ||
def.params[index].type === 'any' ||
def.params[index].type === getExpressionType(a)
def.params[index].type === getExpressionType(a) ||
// this is a special case for expressions with named parameters
// e.g. "WHERE field == ?value"
isParamExpressionType(getExpressionType(a))
);
});
});
Expand Down Expand Up @@ -757,6 +763,17 @@ type ExpressionPosition =
| 'after_literal'
| 'empty_expression';

/**
* Escapes special characters in a string to be used as a literal match in a regular expression.
* @param {string} text The input string to escape.
* @returns {string} The escaped string.
*/
function escapeRegExp(text: string): string {
// Characters with special meaning in regex: . * + ? ^ $ { } ( ) | [ ] \
// We need to escape all of them. The `$&` in the replacement string means "the matched substring".
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

/**
* Determines the position of the cursor within an expression.
* @param innerText
Expand Down Expand Up @@ -785,7 +802,8 @@ export const getExpressionPosition = (
if (
isColumnItem(expressionRoot) &&
// and not directly after the column name or prefix e.g. "colu/"
!new RegExp(`${expressionRoot.parts.join('\\.')}$`).test(innerText)
// we are escaping the column name here as it may contain special characters such as ??
!new RegExp(`${escapeRegExp(expressionRoot.parts.join('\\.'))}$`).test(innerText)
Copy link
Contributor Author

@stratoula stratoula Jun 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change is irrelevant with the bug we are trying to solve here but it solves another bug. An error appears in the console when you use the named params with the ?? (such as ... | WHERE ??field)

image (99)

) {
return 'after_column';
}
Expand Down Expand Up @@ -850,7 +868,9 @@ export async function suggestForExpression({
suggestions.push(
...getOperatorSuggestions({
location,
leftParamType: expressionType,
// In case of a param literal, we don't know the type of the left operand
// so we can only suggest operators that accept any type as a left operand
leftParamType: isParamExpressionType(expressionType) ? undefined : expressionType,
ignored: ['='],
})
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export const dataTypes = [
'time_literal', // @TODO consider merging time_literal with time_duration
'time_duration',
'date_period',
'param', // Defines a named param such as ?value or ??field
] as const;

export type SupportedDataType = (typeof dataTypes)[number];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,10 @@ describe('getExpressionType', () => {
expression: '1 day',
expectedType: 'time_literal',
},
{
expression: '?value',
expectedType: 'param',
},
];
test.each(cases)('detects a literal of type $expectedType', ({ expression, expectedType }) => {
const ast = getASTForExpression(expression);
Expand Down Expand Up @@ -178,6 +182,10 @@ describe('getExpressionType', () => {
'unknown'
);
});

it('handles fields defined by a named param', () => {
expect(getExpressionType(getASTForExpression('??field'), new Map(), new Map())).toBe('param');
});
});

describe('functions', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
type ESQLSource,
type ESQLTimeInterval,
type ESQLAstCommand,
lastItem,
} from '@kbn/esql-ast';
import {
ESQLIdentifier,
Expand Down Expand Up @@ -820,6 +821,13 @@ export function getParamAtPosition(
return params.length > position ? params[position] : minParams ? params[params.length - 1] : null;
}

// --- Expression types helpers ---

/**
* Type guard to check if the type is 'param'
*/
export const isParamExpressionType = (type: string): type is 'param' => type === 'param';
Copy link
Contributor

@drewdaemon drewdaemon Jun 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: what does having a function for this accomplish?

As a comparable: I think most of our isSomething AST helpers only exist because we currently have to make sure that the nodes aren't randomly arrays. I would love to see them disappear and just be able to say if (node.type === 'option') instead of if (isOptionItem(node))for example.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh interesting thought. I like these small functions for readability. So I prefer them from something === "param". I had them as that actually and I didnt like it.

I think if your utilities are well organized they can be really helpful for the code readability. Mostly because we have so many different types and terms. I always struggle reading this code and I feel that these small utilities help me.

I hear your point ofc. At the end of the day it is a personal preference. We should have a convention for sure. 👍


/**
* Determines the type of the expression
*/
Expand All @@ -839,7 +847,7 @@ export function getExpressionType(
return getExpressionType(root[0], fields, userDefinedColumns);
}

if (isLiteralItem(root) && root.literalType !== 'param') {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This check was returning all the named params as of type unknown. We are filtering out suggestions for type unknown so with this change now we are returning the type param which is not filtered out.

if (isLiteralItem(root)) {
return root.literalType;
}

Expand Down Expand Up @@ -867,6 +875,12 @@ export function getExpressionType(

if (isColumnItem(root) && fields && userDefinedColumns) {
const column = getColumnForASTNode(root, { fields, userDefinedColumns });
const lastArg = lastItem(root.args);
// If the last argument is a param, we return its type (param literal type)
// This is useful for cases like `where ??field`
if (isParam(lastArg)) {
return lastArg.literalType;
}
if (!column) {
return 'unknown';
}
Expand Down Expand Up @@ -916,7 +930,6 @@ export function getExpressionType(
if (!signaturesWithCorrectArity.length) {
return 'unknown';
}

const argTypes = root.args.map((arg) => getExpressionType(arg, fields, userDefinedColumns));

// When functions are passed null for any argument, they generally return null
Expand All @@ -930,7 +943,8 @@ export function getExpressionType(
param &&
(param.type === 'any' ||
param.type === argType ||
(argType === 'keyword' && ['date', 'date_period'].includes(param.type)))
(argType === 'keyword' && ['date', 'date_period'].includes(param.type)) ||
isParamExpressionType(argType))
);
});
});
Expand All @@ -945,6 +959,8 @@ export function getExpressionType(
return 'unknown';
}

// --- Fields helpers ---

export function transformMapToESQLFields(
inputMap: Map<string, ESQLUserDefinedColumn[]>
): ESQLFieldWithMetadata[] {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,17 +47,14 @@ describe('allows named params', () => {
test('WHERE boolean expression can contain a param', async () => {
const { validate } = await setup();

const res0 = await validate('FROM index | STATS var = ?func(?field) | WHERE var >= ?value');
const res0 = await validate('FROM index | STATS var = ??func(??field) | WHERE var >= ?value');
expect(res0).toMatchObject({ errors: [], warnings: [] });

const res1 = await validate('FROM index | STATS var = ?param | WHERE var >= ?value');
const res1 = await validate('FROM index | WHERE textField >= ?value');
expect(res1).toMatchObject({ errors: [], warnings: [] });

const res2 = await validate('FROM index | STATS var = ?param | WHERE var >= ?value');
const res2 = await validate('FROM index | WHERE ??field >= ?value');
expect(res2).toMatchObject({ errors: [], warnings: [] });

const res3 = await validate('FROM index | STATS var = ?param | WHERE ?value >= var');
expect(res3).toMatchObject({ errors: [], warnings: [] });
});

test('in column names', async () => {
Expand Down