Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Search Bar/Query/Default Syntax] Parser support for "recognizedFields" options #7960

Merged
merged 9 commits into from
Aug 22, 2024
5 changes: 5 additions & 0 deletions packages/eui/changelogs/upcoming/7960.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
**recognizedFields**

- `recognizedFields` is a new field of `ParseOptions` which specifies the phrases that will be parsed as field
clauses. This field can be used wherever ParseOptions are exposed: in `Query.parse(terms, parseOptions)` and
in the `box` prop of `EuiSearchBar`.
cee-chen marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -1240,4 +1240,76 @@ describe('defaultSyntax', () => {
const printedQuery = defaultSyntax.print(ast);
expect(printedQuery).toBe(query);
});

describe('recognizedFields', () => {
test('parse field clauses from a query only for recognized fields', () => {
const query = 'remote:test type:dataview happiness';
const ast = defaultSyntax.parse(query, {
schema: {
recognizedFields: ['tag', 'type'],
},
});
expect(ast).toBeDefined();
expect(ast.clauses).toHaveLength(3);
expect(ast.getFieldClauses()).toMatchObject([
{ field: 'type', match: 'must', value: 'dataview' },
]);
expect(ast.getTermClauses()).toEqual([
{ match: 'must', type: 'term', value: 'remote:test' },
{ match: 'must', type: 'term', value: 'happiness' },
]);

const printedQuery = defaultSyntax.print(ast);
expect(printedQuery).toBe('remote\\:test type:dataview happiness');
});

test('combined with "is" clause', () => {
const query = 'remote:test is:pending';
const ast = defaultSyntax.parse(query, {
schema: {
recognizedFields: ['tag', 'type'],
},
});
expect(ast).toBeDefined();
expect(ast.clauses).toHaveLength(2);
expect(ast.getFieldClauses()).toHaveLength(0);
expect(ast.getIsClauses()).toEqual([
{ flag: 'pending', match: 'must', type: 'is' },
]);
expect(ast.getTermClauses()).toEqual([
{ match: 'must', type: 'term', value: 'remote:test' },
]);

const printedQuery = defaultSyntax.print(ast);
expect(printedQuery).toBe('remote\\:test is:pending');
});

test('parse complex terms from a query', () => {
const query =
'my-remote-cluster:my-remote-index type:data-view tag:really-nice-content';
const ast = defaultSyntax.parse(query, {
schema: {
recognizedFields: ['tag', 'type'],
},
});
expect(ast).toBeDefined();
expect(ast.clauses).toHaveLength(3);
expect(ast.getFieldClauses()).toMatchObject([
{ field: 'type', match: 'must', value: 'data-view' },
{ field: 'tag', match: 'must', value: 'really-nice-content' },
]);
expect(ast.getTermClauses()).toEqual([
{
match: 'must',
type: 'term',
value: 'my-remote-cluster:my-remote-index',
},
]);

const printedQuery = defaultSyntax.print(ast);
expect(printedQuery).toBe(
'my\\-remote\\-cluster\\:my\\-remote\\-index type:data-view tag:really-nice-content'
);
});
});
});
14 changes: 11 additions & 3 deletions packages/eui/src/components/search_bar/query/default_syntax.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ import peg from 'pegjs-inline-precompile'; // eslint-disable-line import/no-unre

const parser = peg`
{
const { AST, Exp, unescapeValue, unescapePhraseValue, resolveFieldValue } = options;
const { AST, Exp, unescapeValue, unescapePhraseValue, resolveFieldValue, recognizedFields } = options;
const hasRecognizedFields = recognizedFields && recognizedFields.length > 0;
const ctx = Object.assign({ error }, options );
}

Expand Down Expand Up @@ -108,8 +109,9 @@ FieldLTEValue
flagName "flag name"
= identifier

// If recognizedFields was given in options, the identifier must match an allowed field
fieldName "field name"
= identifier
= id:identifier &{ return !hasRecognizedFields || recognizedFields.includes(id); } { return id; }
Copy link
Contributor

Choose a reason for hiding this comment

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

What is the ampersand doing here?

Copy link
Member Author

Choose a reason for hiding this comment

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

@pugnascotia

Unfortunately, PegJS documentation is no longer available directly, but it can be found here: http://web.archive.org/web/20240102234657/https://pegjs.org/documentation. Take a look under "Parsing Expression Types"

& expression
Try to match the expression. If the match succeeds, just return undefined and do not consume any input, otherwise consider the match failed.

In other words, the &{ return !hasRecognizedFields || recognizedFields.includes(id); } is a predicate check that determines whether the rule succeeds or fails. This check ensures that if hasRecognizedFields is truthy, then there is a restriction that the identifier must be included in the recognizedFields array to be treated as a field name. If the predicate passes, then the identifier is parsed as a field name. If the predicate does not pass, then the fieldName match is failed and the parser falls back to using the identifier rule.

By the way, I used Microsoft Copilot to explain this to me and help me with updating the parsing rules. If it wasn't for that, I am sure I would not have been able to figure it out.

Copy link
Contributor

Choose a reason for hiding this comment

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

I checked the docs as they appear in source control, to double-check:

https://github.com/pegjs/pegjs/blob/master/docs/grammar/parsing-expression-types.md#--predicate-

So this looks good.


identifier
= identifierChar+ { return unescapeValue(text()); }
Expand Down Expand Up @@ -256,7 +258,11 @@ interface ValueExpression {

export interface ParseOptions {
dateFormat?: any;
schema?: any;
schema?: {
strict?: boolean;
fields?: string[] | Record<string, any>;
recognizedFields?: string[];
};
escapeValue?: (value: any) => string;
}

Expand Down Expand Up @@ -493,6 +499,7 @@ export const defaultSyntax: Syntax = Object.freeze({
const dateFormat = options.dateFormat || defaultDateFormat;
const parseDate = dateValueParser(dateFormat);
const schema = options.schema || {};
const recognizedFields = schema.recognizedFields;
cee-chen marked this conversation as resolved.
Show resolved Hide resolved
const clauses = parser.parse(query, {
AST,
Exp,
Expand All @@ -502,6 +509,7 @@ export const defaultSyntax: Syntax = Object.freeze({
resolveFieldValue,
validateFlag,
schema: { strict: false, flags: [], fields: {}, ...schema },
recognizedFields,
});
return AST.create(clauses);
},
Expand Down
4 changes: 3 additions & 1 deletion packages/eui/src/components/search_bar/search_bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ export interface SchemaType {
strict?: boolean;
fields?: any;
flags?: string[];
// Controls which phrases will be parsed as field clauses
recognizedFields?: string[];
}

export type EuiSearchBarOnChangeArgs = ArgsWithQuery | ArgsWithError;
Expand Down Expand Up @@ -123,7 +125,7 @@ const parseQuery = (
props: EuiSearchBarProps
): Query => {
let schema: SchemaType | undefined = undefined;
if (props.box && props.box.schema && typeof props.box.schema === 'object') {
if (props.box?.schema && typeof props.box?.schema === 'object') {
schema = props.box.schema;
}
const dateFormat = props.dateFormat;
Expand Down
Loading