Skip to content

Commit 30cdbef

Browse files
authored
feat: USER_AGENT command (AST, APIs) (#77)
part of elastic/kibana#260454 ## Summary This PR adds support for the new USER_AGENT command: - AST support - visitor context - tests ### Checklist <!-- Delete any items that are not applicable to this PR. --> - [x] Unit tests have been added or updated. - [ ] The proper documentation has been added or updated. - [ ] If this PR contains breaking changes, you have explained them using the `BREAKING CHANGE: change` syntax. - [x] The PR title has the correct [conventional commit](https://www.conventionalcommits.org/en/v1.0.0/) label in the title, this is crucial for the correct versioning of this package. Check the following cheat shit. | Title Label | Release | |---|---| | `breaking: true (!)` | `major` | | `feat` | `minor` | | `fix` | `patch` | | `refactor` | `patch` | | `perf` | `patch` | | `build` | `patch` | | `docs` | `patch` | | `chore` | `patch` | | `revert` | `patch` |
1 parent cb64377 commit 30cdbef

6 files changed

Lines changed: 408 additions & 21 deletions

File tree

src/ast/visitor/contexts.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import type {
2020
ESQLAstJoinCommand,
2121
ESQLAstMetricsInfoCommand,
2222
ESQLAstQueryExpression,
23+
ESQLAstUserAgentCommand,
2324
ESQLAstRegisteredDomainCommand,
2425
ESQLAstRerankCommand,
2526
ESQLAstTsInfoCommand,
@@ -609,6 +610,12 @@ export class MetricsInfoCommandVisitorContext<
609610
Data extends SharedData = SharedData,
610611
> extends CommandVisitorContext<Methods, Data, ESQLAstMetricsInfoCommand> {}
611612

613+
// USER_AGENT <qualifiedName> = <primaryExpression> [WITH <map>]
614+
export class UserAgentCommandVisitorContext<
615+
Methods extends VisitorMethods = VisitorMethods,
616+
Data extends SharedData = SharedData,
617+
> extends CommandVisitorContext<Methods, Data, ESQLAstUserAgentCommand> {}
618+
612619
// Expressions -----------------------------------------------------------------
613620

614621
export class ExpressionVisitorContext<

src/ast/visitor/global_visitor_context.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type {
1414
ESQLAstJoinCommand,
1515
ESQLAstMetricsInfoCommand,
1616
ESQLAstQueryExpression,
17+
ESQLAstUserAgentCommand,
1718
ESQLAstRegisteredDomainCommand,
1819
ESQLAstRerankCommand,
1920
ESQLAstTsInfoCommand,
@@ -254,6 +255,14 @@ export class GlobalVisitorContext<
254255
input as any
255256
);
256257
}
258+
case 'user_agent': {
259+
if (!this.methods.visitUserAgentCommand) break;
260+
return this.visitUserAgentCommand(
261+
parent,
262+
commandNode as ESQLAstUserAgentCommand,
263+
input as any
264+
);
265+
}
257266
}
258267
return this.visitCommandGeneric(parent, commandNode, input as any);
259268
}
@@ -560,6 +569,15 @@ export class GlobalVisitorContext<
560569
return this.visitWithSpecificContext('visitMetricsInfoCommand', context, input);
561570
}
562571

572+
public visitUserAgentCommand(
573+
parent: contexts.VisitorContext | null,
574+
node: ESQLAstUserAgentCommand,
575+
input: types.VisitorInput<Methods, 'visitUserAgentCommand'>
576+
): types.VisitorOutput<Methods, 'visitUserAgentCommand'> {
577+
const context = new contexts.UserAgentCommandVisitorContext(this, node, parent);
578+
return this.visitWithSpecificContext('visitUserAgentCommand', context, input);
579+
}
580+
563581
// #endregion
564582

565583
// #region Expression visiting -------------------------------------------------------

src/ast/visitor/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ export type CommandVisitorInput<Methods extends VisitorMethods> = AnyToVoid<
116116
VisitorInput<Methods, 'visitUriPartsCommand'> &
117117
VisitorInput<Methods, 'visitTsInfoCommand'> &
118118
VisitorInput<Methods, 'visitMetricsInfoCommand'> &
119+
VisitorInput<Methods, 'visitUserAgentCommand'> &
119120
VisitorInput<Methods, 'visitRegisteredDomainCommand'>
120121
>;
121122

@@ -151,6 +152,7 @@ export type CommandVisitorOutput<Methods extends VisitorMethods> =
151152
| VisitorOutput<Methods, 'visitUriPartsCommand'>
152153
| VisitorOutput<Methods, 'visitTsInfoCommand'>
153154
| VisitorOutput<Methods, 'visitMetricsInfoCommand'>
155+
| VisitorOutput<Methods, 'visitUserAgentCommand'>
154156
| VisitorOutput<Methods, 'visitRegisteredDomainCommand'>;
155157

156158
/* eslint-disable @typescript-eslint/no-explicit-any */
@@ -218,6 +220,11 @@ export interface VisitorMethods<
218220
any,
219221
any
220222
>;
223+
visitUserAgentCommand?: Visitor<
224+
contexts.UserAgentCommandVisitorContext<Visitors, Data>,
225+
any,
226+
any
227+
>;
221228
visitExpression?: Visitor<contexts.ExpressionVisitorContext<Visitors, Data>, any, any>;
222229
visitSourceExpression?: Visitor<
223230
contexts.SourceExpressionVisitorContext<Visitors, Data>,
Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import { EsqlQuery } from '../../composer/query';
9+
import { Walker } from '../../ast/walker';
10+
import type {
11+
ESQLAstQueryExpression,
12+
ESQLAstUserAgentCommand,
13+
ESQLCommandOption,
14+
ESQLFunction,
15+
ESQLMap,
16+
} from '../../types';
17+
18+
/**
19+
* Examples follow the ES|QL USER_AGENT command syntax described in Elastic’s docs:
20+
* https://www.elastic.co/docs/reference/query-languages/esql/commands/user-agent
21+
*/
22+
describe('USER_AGENT', () => {
23+
const getUserAgent = (ast: ESQLAstQueryExpression): ESQLAstUserAgentCommand =>
24+
Walker.match(ast, {
25+
type: 'command',
26+
name: 'user_agent',
27+
}) as ESQLAstUserAgentCommand;
28+
29+
describe('correctly formatted', () => {
30+
it('parses prefix = expression without WITH (FROM … | USER_AGENT ua = user_agent)', () => {
31+
const src = `FROM web_logs | USER_AGENT ua = user_agent`;
32+
const { ast, errors } = EsqlQuery.fromSrc(src);
33+
const cmd = getUserAgent(ast);
34+
35+
expect(errors.length).toBe(0);
36+
expect(cmd).toMatchObject({
37+
type: 'command',
38+
name: 'user_agent',
39+
incomplete: false,
40+
});
41+
expect(cmd.targetField).toMatchObject({
42+
type: 'column',
43+
name: 'ua',
44+
});
45+
expect(cmd.expression).toMatchObject({
46+
type: 'column',
47+
name: 'user_agent',
48+
});
49+
expect(cmd.namedParameters).toBeUndefined();
50+
expect(cmd.args).toHaveLength(1);
51+
expect(cmd.args[0]).toMatchObject({
52+
type: 'function',
53+
subtype: 'binary-expression',
54+
name: '=',
55+
});
56+
});
57+
58+
it('parses WITH { extract_device_type: true } (ROW … | USER_AGENT ua = input WITH { … })', () => {
59+
const src = `ROW input = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_2) AppleWebKit/537.36"
60+
| USER_AGENT ua = input WITH { "extract_device_type": true }`;
61+
const { ast, errors } = EsqlQuery.fromSrc(src);
62+
const cmd = getUserAgent(ast);
63+
64+
expect(errors.length).toBe(0);
65+
expect(cmd.targetField).toMatchObject({ type: 'column', name: 'ua' });
66+
expect(cmd.expression).toMatchObject({ type: 'column', name: 'input' });
67+
68+
expect(cmd.namedParameters).toMatchObject({
69+
type: 'map',
70+
entries: [
71+
{
72+
type: 'map-entry',
73+
key: {
74+
type: 'literal',
75+
literalType: 'keyword',
76+
valueUnquoted: 'extract_device_type',
77+
},
78+
value: {
79+
type: 'literal',
80+
literalType: 'boolean',
81+
value: 'true',
82+
},
83+
},
84+
],
85+
});
86+
87+
const withOption = cmd.args.find(
88+
(arg): arg is ESQLCommandOption =>
89+
'type' in arg && arg.type === 'option' && arg.name === 'with'
90+
);
91+
expect(withOption).toBeDefined();
92+
expect((withOption!.args[0] as ESQLMap).entries).toHaveLength(1);
93+
});
94+
95+
it('parses WITH { regex_file: "my-regexes.yml" }', () => {
96+
const src = `FROM web_logs | USER_AGENT ua = user_agent WITH { "regex_file": "my-regexes.yml" }`;
97+
const { ast, errors } = EsqlQuery.fromSrc(src);
98+
const cmd = getUserAgent(ast);
99+
100+
expect(errors.length).toBe(0);
101+
expect(cmd.namedParameters).toMatchObject({
102+
type: 'map',
103+
entries: [
104+
{
105+
type: 'map-entry',
106+
key: {
107+
type: 'literal',
108+
valueUnquoted: 'regex_file',
109+
},
110+
value: {
111+
type: 'literal',
112+
literalType: 'keyword',
113+
valueUnquoted: 'my-regexes.yml',
114+
},
115+
},
116+
],
117+
});
118+
});
119+
120+
it('parses WITH properties list and extract_device_type', () => {
121+
const src = `ROW ua_str = "Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X) AppleWebKit/605.1.15"
122+
| USER_AGENT ua = ua_str WITH { "properties": ["name", "version", "device"], "extract_device_type": true }`;
123+
const { ast, errors } = EsqlQuery.fromSrc(src);
124+
const cmd = getUserAgent(ast);
125+
126+
expect(errors.length).toBe(0);
127+
expect(cmd.expression).toMatchObject({ type: 'column', name: 'ua_str' });
128+
129+
const map = cmd.namedParameters as ESQLMap;
130+
expect(map.type).toBe('map');
131+
expect(map.entries).toHaveLength(2);
132+
133+
const propertiesEntry = map.entries.find(
134+
(e) =>
135+
e.key.type === 'literal' &&
136+
'valueUnquoted' in e.key &&
137+
e.key.valueUnquoted === 'properties'
138+
);
139+
expect(propertiesEntry?.value).toMatchObject({
140+
type: 'list',
141+
});
142+
143+
const deviceTypeEntry = map.entries.find(
144+
(e) =>
145+
e.key.type === 'literal' &&
146+
'valueUnquoted' in e.key &&
147+
e.key.valueUnquoted === 'extract_device_type'
148+
);
149+
expect(deviceTypeEntry?.value).toMatchObject({
150+
type: 'literal',
151+
literalType: 'boolean',
152+
value: 'true',
153+
});
154+
});
155+
156+
it('parses the assignment structure in args', () => {
157+
const src = `FROM index | USER_AGENT ua = user_agent`;
158+
const { ast } = EsqlQuery.fromSrc(src);
159+
const cmd = getUserAgent(ast);
160+
161+
const assignment = cmd.args[0] as ESQLFunction;
162+
expect(assignment.args[0]).toMatchObject({
163+
type: 'column',
164+
name: 'ua',
165+
});
166+
expect(assignment.args[1]).toMatchObject({
167+
type: 'column',
168+
name: 'user_agent',
169+
});
170+
});
171+
172+
it('parses dotted prefix column', () => {
173+
const src = `FROM index | USER_AGENT client.ua = raw_ua`;
174+
const { ast, errors } = EsqlQuery.fromSrc(src);
175+
const cmd = getUserAgent(ast);
176+
177+
expect(errors.length).toBe(0);
178+
expect(cmd.targetField).toMatchObject({
179+
type: 'column',
180+
});
181+
expect(cmd.targetField.parts).toEqual(['client', 'ua']);
182+
});
183+
});
184+
185+
describe('incomplete flag', () => {
186+
it('is false when the full "ua = ua_str" expression is valid', () => {
187+
const { ast } = EsqlQuery.fromSrc(`FROM index | USER_AGENT ua = user_agent`);
188+
const cmd = getUserAgent(ast);
189+
190+
expect(cmd.incomplete).toBe(false);
191+
expect(cmd.expression).toMatchObject({
192+
type: 'column',
193+
name: 'user_agent',
194+
incomplete: false,
195+
});
196+
});
197+
198+
it('is true when only the target field is provided (no assignment)', () => {
199+
const { ast } = EsqlQuery.fromSrc(`FROM index | USER_AGENT ua`);
200+
const cmd = getUserAgent(ast);
201+
202+
expect(cmd.incomplete).toBe(true);
203+
expect(cmd.expression).toBeUndefined();
204+
});
205+
206+
it('is true when the assignment is present but the expression is missing', () => {
207+
const { ast } = EsqlQuery.fromSrc(`FROM index | USER_AGENT ua =`);
208+
const cmd = getUserAgent(ast);
209+
210+
expect(cmd.incomplete).toBe(true);
211+
expect(cmd.expression).toMatchObject({
212+
type: 'unknown',
213+
incomplete: true,
214+
});
215+
});
216+
});
217+
218+
describe('incorrectly formatted', () => {
219+
it('errors on just the command keyword', () => {
220+
const { ast, errors } = EsqlQuery.fromSrc(`FROM index | USER_AGENT`);
221+
const cmd = getUserAgent(ast);
222+
223+
expect(errors.length).toBeGreaterThan(0);
224+
expect(cmd).toMatchObject({
225+
name: 'user_agent',
226+
incomplete: true,
227+
});
228+
expect(cmd.targetField).toMatchObject({
229+
type: 'column',
230+
name: '',
231+
incomplete: true,
232+
});
233+
expect(cmd.expression).toBeUndefined();
234+
});
235+
236+
it('errors on missing assignment', () => {
237+
const { ast, errors } = EsqlQuery.fromSrc(`FROM index | USER_AGENT ua`);
238+
const cmd = getUserAgent(ast);
239+
240+
expect(errors.length).toBeGreaterThan(0);
241+
expect(cmd).toMatchObject({
242+
name: 'user_agent',
243+
incomplete: true,
244+
});
245+
expect(cmd.targetField).toMatchObject({
246+
type: 'column',
247+
name: 'ua',
248+
});
249+
expect(cmd.expression).toBeUndefined();
250+
});
251+
252+
it('errors on missing expression after assignment', () => {
253+
const { ast, errors } = EsqlQuery.fromSrc(`FROM index | USER_AGENT ua =`);
254+
const cmd = getUserAgent(ast);
255+
256+
expect(errors.length).toBeGreaterThan(0);
257+
expect(cmd).toMatchObject({
258+
name: 'user_agent',
259+
incomplete: true,
260+
});
261+
expect(cmd.targetField).toMatchObject({
262+
type: 'column',
263+
name: 'ua',
264+
});
265+
expect(cmd.expression).toMatchObject({
266+
type: 'unknown',
267+
incomplete: true,
268+
});
269+
});
270+
});
271+
});

0 commit comments

Comments
 (0)