Skip to content

Commit 6ce1ff1

Browse files
feat: ES|QL object API helper (#57)
See elastic/elasticsearch-js#2238 Co-authored-by: Josh Mock <joshua.mock@elastic.co>
1 parent 6a86e25 commit 6ce1ff1

File tree

2 files changed

+179
-0
lines changed

2 files changed

+179
-0
lines changed

src/helpers.ts

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

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

930996
// 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)