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

[Backport 8.14] ES|QL: Object API helper #2248

Merged
merged 1 commit into from
May 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions docs/helpers.asciidoc
Original file line number Diff line number Diff line change
Expand Up @@ -613,3 +613,97 @@ for await (const doc of scrollSearch) {
console.log(doc)
}
----

[discrete]
[[esql-helper]]
=== ES|QL helper

ES|QL queries can return their results in {ref}/esql-rest.html#esql-rest-format[several formats].
The default JSON format returned by ES|QL queries contains arrays of values
for each row, with column names and types returned separately:

[discrete]
==== Usage

[discrete]
===== `toRecords`

~Added~ ~in~ ~`v8.14.0`~

The default JSON format returned by ES|QL queries contains arrays of values
for each row, with column names and types returned separately:

[source,json]
----
{
"columns": [
{ "name": "@timestamp", "type": "date" },
{ "name": "client_ip", "type": "ip" },
{ "name": "event_duration", "type": "long" },
{ "name": "message", "type": "keyword" }
],
"values": [
[
"2023-10-23T12:15:03.360Z",
"172.21.2.162",
3450233,
"Connected to 10.1.0.3"
],
[
"2023-10-23T12:27:28.948Z",
"172.21.2.113",
2764889,
"Connected to 10.1.0.2"
]
]
}
----

In many cases, it's preferable to operate on an array of objects, one object per row,
rather than an array of arrays. The ES|QL `toRecords` helper converts row data into objects.

[source,js]
----
await client.helpers
.esql({ query: 'FROM sample_data | LIMIT 2' })
.toRecords()
// =>
// {
// "columns": [
// { "name": "@timestamp", "type": "date" },
// { "name": "client_ip", "type": "ip" },
// { "name": "event_duration", "type": "long" },
// { "name": "message", "type": "keyword" }
// ],
// "records": [
// {
// "@timestamp": "2023-10-23T12:15:03.360Z",
// "client_ip": "172.21.2.162",
// "event_duration": 3450233,
// "message": "Connected to 10.1.0.3"
// },
// {
// "@timestamp": "2023-10-23T12:27:28.948Z",
// "client_ip": "172.21.2.113",
// "event_duration": 2764889,
// "message": "Connected to 10.1.0.2"
// },
// ]
// }
----

In TypeScript, you can declare the type that `toRecords` returns:

[source,ts]
----
type EventLog = {
'@timestamp': string,
client_ip: string,
event_duration: number,
message: string,
}

const result = await client.helpers
.esql({ query: 'FROM sample_data | LIMIT 2' })
.toRecords<EventLog>()
----
66 changes: 66 additions & 0 deletions src/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,29 @@ export interface BulkHelper<T> extends Promise<BulkStats> {
readonly stats: BulkStats
}

export interface EsqlColumn {
name: string
type: string
}

export type EsqlValue = any[]

export type EsqlRow = EsqlValue[]

export interface EsqlResponse {
columns: EsqlColumn[]
values: EsqlRow[]
}

export interface EsqlHelper {
toRecords: <TDocument>() => Promise<EsqlToRecords<TDocument>>
}

export interface EsqlToRecords<TDocument> {
columns: EsqlColumn[]
records: TDocument[]
}

const { ResponseError, ConfigurationError } = errors
const sleep = promisify(setTimeout)
const pImmediate = promisify(setImmediate)
Expand Down Expand Up @@ -935,6 +958,49 @@ export default class Helpers {
}
}
}

/**
* Creates an ES|QL helper instance, to help transform the data returned by an ES|QL query into easy-to-use formats.
* @param {object} params - Request parameters sent to esql.query()
* @returns {object} EsqlHelper instance
*/
esql (params: T.EsqlQueryRequest, reqOptions: TransportRequestOptions = {}): EsqlHelper {
if (this[kMetaHeader] !== null) {
reqOptions.headers = reqOptions.headers ?? {}
reqOptions.headers['x-elastic-client-meta'] = `${this[kMetaHeader] as string},h=qo`
}

const client = this[kClient]

function toRecords<TDocument> (response: EsqlResponse): TDocument[] {
const { columns, values } = response
return values.map(row => {
const doc: Partial<TDocument> = {}
row.forEach((cell, index) => {
const { name } = columns[index]
// @ts-expect-error
doc[name] = cell
})
return doc as TDocument
})
}

const helper: EsqlHelper = {
/**
* Pivots ES|QL query results into an array of row objects, rather than the default format where each row is an array of values.
*/
async toRecords<TDocument>(): Promise<EsqlToRecords<TDocument>> {
params.format = 'json'
// @ts-expect-error it's typed as ArrayBuffer but we know it will be JSON
const response: EsqlResponse = await client.esql.query(params, reqOptions)
const records: TDocument[] = toRecords(response)
const { columns } = response
return { records, columns }
}
}

return helper
}
}

// Using a getter will improve the overall performances of the code,
Expand Down
113 changes: 113 additions & 0 deletions test/unit/helpers/esql.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import { test } from 'tap'
import { connection } from '../../utils'
import { Client } from '../../../'

test('ES|QL helper', t => {
test('toRecords', t => {
t.test('Takes an ESQL response and pivots it to an array of records', async t => {
type MyDoc = {
'@timestamp': string,
client_ip: string,
event_duration: number,
message: string,
}

const MockConnection = connection.buildMockConnection({
onRequest (_params) {
return {
body: {
columns: [
{ name: '@timestamp', type: 'date' },
{ name: 'client_ip', type: 'ip' },
{ name: 'event_duration', type: 'long' },
{ name: 'message', type: 'keyword' }
],
values: [
[
'2023-10-23T12:15:03.360Z',
'172.21.2.162',
3450233,
'Connected to 10.1.0.3'
],
[
'2023-10-23T12:27:28.948Z',
'172.21.2.113',
2764889,
'Connected to 10.1.0.2'
]
]
}
}
}
})

const client = new Client({
node: 'http://localhost:9200',
Connection: MockConnection
})

const result = await client.helpers.esql({ query: 'FROM sample_data' }).toRecords<MyDoc>()
const { records, columns } = result
t.equal(records.length, 2)
t.ok(records[0])
t.same(records[0], {
'@timestamp': '2023-10-23T12:15:03.360Z',
client_ip: '172.21.2.162',
event_duration: 3450233,
message: 'Connected to 10.1.0.3'
})
t.same(columns, [
{ name: '@timestamp', type: 'date' },
{ name: 'client_ip', type: 'ip' },
{ name: 'event_duration', type: 'long' },
{ name: 'message', type: 'keyword' }
])
t.end()
})

t.test('ESQL helper uses correct x-elastic-client-meta helper value', async t => {
const MockConnection = connection.buildMockConnection({
onRequest (params) {
const header = params.headers?.['x-elastic-client-meta'] ?? ''
t.ok(header.includes('h=qo'), `Client meta header does not include ESQL helper value: ${header}`)
return {
body: {
columns: [{ name: '@timestamp', type: 'date' }],
values: [['2023-10-23T12:15:03.360Z']],
}
}
}
})

const client = new Client({
node: 'http://localhost:9200',
Connection: MockConnection
})

await client.helpers.esql({ query: 'FROM sample_data' }).toRecords()
t.end()
})

t.end()
})
t.end()
})
Loading