Skip to content

Commit 6eae81d

Browse files
[Backport 8.14] ES|QL: Object API helper (#2248)
* ESQL toRecord helper * ESQL helper tests * Add ESQL object API helper to client meta header * Add docstring for toRecords * Include column metadata in toRecords helper * Add docs for ESQL toRecords helper * Verify columns in helper return object (cherry picked from commit 8962168) Co-authored-by: Josh Mock <joshua.mock@elastic.co>
1 parent 462016a commit 6eae81d

File tree

3 files changed

+273
-0
lines changed

3 files changed

+273
-0
lines changed

docs/helpers.asciidoc

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -613,3 +613,97 @@ for await (const doc of scrollSearch) {
613613
console.log(doc)
614614
}
615615
----
616+
617+
[discrete]
618+
[[esql-helper]]
619+
=== ES|QL helper
620+
621+
ES|QL queries can return their results in {ref}/esql-rest.html#esql-rest-format[several formats].
622+
The default JSON format returned by ES|QL queries contains arrays of values
623+
for each row, with column names and types returned separately:
624+
625+
[discrete]
626+
==== Usage
627+
628+
[discrete]
629+
===== `toRecords`
630+
631+
~Added~ ~in~ ~`v8.14.0`~
632+
633+
The default JSON format returned by ES|QL queries contains arrays of values
634+
for each row, with column names and types returned separately:
635+
636+
[source,json]
637+
----
638+
{
639+
"columns": [
640+
{ "name": "@timestamp", "type": "date" },
641+
{ "name": "client_ip", "type": "ip" },
642+
{ "name": "event_duration", "type": "long" },
643+
{ "name": "message", "type": "keyword" }
644+
],
645+
"values": [
646+
[
647+
"2023-10-23T12:15:03.360Z",
648+
"172.21.2.162",
649+
3450233,
650+
"Connected to 10.1.0.3"
651+
],
652+
[
653+
"2023-10-23T12:27:28.948Z",
654+
"172.21.2.113",
655+
2764889,
656+
"Connected to 10.1.0.2"
657+
]
658+
]
659+
}
660+
----
661+
662+
In many cases, it's preferable to operate on an array of objects, one object per row,
663+
rather than an array of arrays. The ES|QL `toRecords` helper converts row data into objects.
664+
665+
[source,js]
666+
----
667+
await client.helpers
668+
.esql({ query: 'FROM sample_data | LIMIT 2' })
669+
.toRecords()
670+
// =>
671+
// {
672+
// "columns": [
673+
// { "name": "@timestamp", "type": "date" },
674+
// { "name": "client_ip", "type": "ip" },
675+
// { "name": "event_duration", "type": "long" },
676+
// { "name": "message", "type": "keyword" }
677+
// ],
678+
// "records": [
679+
// {
680+
// "@timestamp": "2023-10-23T12:15:03.360Z",
681+
// "client_ip": "172.21.2.162",
682+
// "event_duration": 3450233,
683+
// "message": "Connected to 10.1.0.3"
684+
// },
685+
// {
686+
// "@timestamp": "2023-10-23T12:27:28.948Z",
687+
// "client_ip": "172.21.2.113",
688+
// "event_duration": 2764889,
689+
// "message": "Connected to 10.1.0.2"
690+
// },
691+
// ]
692+
// }
693+
----
694+
695+
In TypeScript, you can declare the type that `toRecords` returns:
696+
697+
[source,ts]
698+
----
699+
type EventLog = {
700+
'@timestamp': string,
701+
client_ip: string,
702+
event_duration: number,
703+
message: string,
704+
}
705+
706+
const result = await client.helpers
707+
.esql({ query: 'FROM sample_data | LIMIT 2' })
708+
.toRecords<EventLog>()
709+
----

src/helpers.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,29 @@ export interface BulkHelper<T> extends Promise<BulkStats> {
139139
readonly stats: BulkStats
140140
}
141141

142+
export interface EsqlColumn {
143+
name: string
144+
type: string
145+
}
146+
147+
export type EsqlValue = any[]
148+
149+
export type EsqlRow = EsqlValue[]
150+
151+
export interface EsqlResponse {
152+
columns: EsqlColumn[]
153+
values: EsqlRow[]
154+
}
155+
156+
export interface EsqlHelper {
157+
toRecords: <TDocument>() => Promise<EsqlToRecords<TDocument>>
158+
}
159+
160+
export interface EsqlToRecords<TDocument> {
161+
columns: EsqlColumn[]
162+
records: TDocument[]
163+
}
164+
142165
const { ResponseError, ConfigurationError } = errors
143166
const sleep = promisify(setTimeout)
144167
const pImmediate = promisify(setImmediate)
@@ -935,6 +958,49 @@ export default class Helpers {
935958
}
936959
}
937960
}
961+
962+
/**
963+
* Creates an ES|QL helper instance, to help transform the data returned by an ES|QL query into easy-to-use formats.
964+
* @param {object} params - Request parameters sent to esql.query()
965+
* @returns {object} EsqlHelper instance
966+
*/
967+
esql (params: T.EsqlQueryRequest, reqOptions: TransportRequestOptions = {}): EsqlHelper {
968+
if (this[kMetaHeader] !== null) {
969+
reqOptions.headers = reqOptions.headers ?? {}
970+
reqOptions.headers['x-elastic-client-meta'] = `${this[kMetaHeader] as string},h=qo`
971+
}
972+
973+
const client = this[kClient]
974+
975+
function toRecords<TDocument> (response: EsqlResponse): TDocument[] {
976+
const { columns, values } = response
977+
return values.map(row => {
978+
const doc: Partial<TDocument> = {}
979+
row.forEach((cell, index) => {
980+
const { name } = columns[index]
981+
// @ts-expect-error
982+
doc[name] = cell
983+
})
984+
return doc as TDocument
985+
})
986+
}
987+
988+
const helper: EsqlHelper = {
989+
/**
990+
* Pivots ES|QL query results into an array of row objects, rather than the default format where each row is an array of values.
991+
*/
992+
async toRecords<TDocument>(): Promise<EsqlToRecords<TDocument>> {
993+
params.format = 'json'
994+
// @ts-expect-error it's typed as ArrayBuffer but we know it will be JSON
995+
const response: EsqlResponse = await client.esql.query(params, reqOptions)
996+
const records: TDocument[] = toRecords(response)
997+
const { columns } = response
998+
return { records, columns }
999+
}
1000+
}
1001+
1002+
return helper
1003+
}
9381004
}
9391005

9401006
// Using a getter will improve the overall performances of the code,

test/unit/helpers/esql.test.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/*
2+
* Licensed to Elasticsearch B.V. under one or more contributor
3+
* license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright
5+
* ownership. Elasticsearch B.V. licenses this file to you under
6+
* the Apache License, Version 2.0 (the "License"); you may
7+
* not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
import { test } from 'tap'
21+
import { connection } from '../../utils'
22+
import { Client } from '../../../'
23+
24+
test('ES|QL helper', t => {
25+
test('toRecords', t => {
26+
t.test('Takes an ESQL response and pivots it to an array of records', async t => {
27+
type MyDoc = {
28+
'@timestamp': string,
29+
client_ip: string,
30+
event_duration: number,
31+
message: string,
32+
}
33+
34+
const MockConnection = connection.buildMockConnection({
35+
onRequest (_params) {
36+
return {
37+
body: {
38+
columns: [
39+
{ name: '@timestamp', type: 'date' },
40+
{ name: 'client_ip', type: 'ip' },
41+
{ name: 'event_duration', type: 'long' },
42+
{ name: 'message', type: 'keyword' }
43+
],
44+
values: [
45+
[
46+
'2023-10-23T12:15:03.360Z',
47+
'172.21.2.162',
48+
3450233,
49+
'Connected to 10.1.0.3'
50+
],
51+
[
52+
'2023-10-23T12:27:28.948Z',
53+
'172.21.2.113',
54+
2764889,
55+
'Connected to 10.1.0.2'
56+
]
57+
]
58+
}
59+
}
60+
}
61+
})
62+
63+
const client = new Client({
64+
node: 'http://localhost:9200',
65+
Connection: MockConnection
66+
})
67+
68+
const result = await client.helpers.esql({ query: 'FROM sample_data' }).toRecords<MyDoc>()
69+
const { records, columns } = result
70+
t.equal(records.length, 2)
71+
t.ok(records[0])
72+
t.same(records[0], {
73+
'@timestamp': '2023-10-23T12:15:03.360Z',
74+
client_ip: '172.21.2.162',
75+
event_duration: 3450233,
76+
message: 'Connected to 10.1.0.3'
77+
})
78+
t.same(columns, [
79+
{ name: '@timestamp', type: 'date' },
80+
{ name: 'client_ip', type: 'ip' },
81+
{ name: 'event_duration', type: 'long' },
82+
{ name: 'message', type: 'keyword' }
83+
])
84+
t.end()
85+
})
86+
87+
t.test('ESQL helper uses correct x-elastic-client-meta helper value', async t => {
88+
const MockConnection = connection.buildMockConnection({
89+
onRequest (params) {
90+
const header = params.headers?.['x-elastic-client-meta'] ?? ''
91+
t.ok(header.includes('h=qo'), `Client meta header does not include ESQL helper value: ${header}`)
92+
return {
93+
body: {
94+
columns: [{ name: '@timestamp', type: 'date' }],
95+
values: [['2023-10-23T12:15:03.360Z']],
96+
}
97+
}
98+
}
99+
})
100+
101+
const client = new Client({
102+
node: 'http://localhost:9200',
103+
Connection: MockConnection
104+
})
105+
106+
await client.helpers.esql({ query: 'FROM sample_data' }).toRecords()
107+
t.end()
108+
})
109+
110+
t.end()
111+
})
112+
t.end()
113+
})

0 commit comments

Comments
 (0)