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

ESQL: Introduce language versioning to REST API #106824

Merged
merged 30 commits into from
Apr 3, 2024
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
1f45d65
Add version enum
alex-spies Mar 27, 2024
c4d5a8e
Parse + validate esql.version
alex-spies Mar 27, 2024
26b9fe6
Add tests
alex-spies Mar 27, 2024
887759a
Refactor EsqlVersion.toString
alex-spies Mar 27, 2024
da6c0e1
Make checkstyle happy
alex-spies Mar 27, 2024
8e6de63
Avoid forbidden APIs
alex-spies Mar 27, 2024
98b816f
Use proper emojis
alex-spies Mar 28, 2024
d5bef79
Add disambiguation counter to version
alex-spies Mar 28, 2024
f0a2aae
Make parsing stricter
alex-spies Mar 28, 2024
9e97e43
Rename esql.version -> version
alex-spies Mar 28, 2024
11ba353
Update docs/changelog/106824.yaml
alex-spies Mar 28, 2024
a29fc71
Remove obsolete TODO
alex-spies Mar 28, 2024
85576a7
Spotless
alex-spies Mar 28, 2024
5920994
Refactor month and numberThisMonth
alex-spies Apr 2, 2024
a633632
Add validation tests for non-snapshot builds
alex-spies Apr 2, 2024
c8c9684
Merge remote-tracking branch 'upstream/main' into esql-version
alex-spies Apr 2, 2024
de6304e
Merge remote-tracking branch 'upstream/main' into esql-version
alex-spies Apr 2, 2024
be3f255
Use randomization to test both sync/async
alex-spies Apr 2, 2024
5b15c30
Add empty version string to test
alex-spies Apr 2, 2024
c6f06db
Address feedback
alex-spies Apr 3, 2024
eb795fb
Improve assertions
alex-spies Apr 3, 2024
8a9c0f8
Rename nightly -> snapshot
alex-spies Apr 3, 2024
0b5fb1f
Add latest version to error message when missing
alex-spies Apr 3, 2024
962f16b
Add latest version to error message if invalid
alex-spies Apr 3, 2024
3b7c031
Add latest version in error if using snapshot
alex-spies Apr 3, 2024
20118c6
Remove emoji from error message
alex-spies Apr 3, 2024
037f1f7
Throw IAE instead of AssertionError
alex-spies Apr 3, 2024
ab3be62
Use rocket emoji for first released version
alex-spies Apr 3, 2024
b181c7c
Merge remote-tracking branch 'upstream/main' into esql-version
alex-spies Apr 3, 2024
acb2703
Fix test
alex-spies Apr 3, 2024
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
5 changes: 5 additions & 0 deletions docs/changelog/106824.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
pr: 106824
summary: "ESQL: Introduce language versioning to REST API"
area: ES|QL
type: enhancement
issues: []
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ protected EsqlQueryRequest(StreamInput in) throws IOException {
super(in);
}

public abstract String esqlVersion();
alex-spies marked this conversation as resolved.
Show resolved Hide resolved

public abstract String query();

public abstract QueryBuilder filter();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ public final ActionType<Response> action() {
return action;
}

public abstract EsqlQueryRequestBuilder<Request, Response> esqlVersion(String esqlVersion);

public abstract EsqlQueryRequestBuilder<Request, Response> query(String query);

public abstract EsqlQueryRequestBuilder<Request, Response> filter(QueryBuilder filter);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import org.elasticsearch.tasks.TaskId;
import org.elasticsearch.xpack.esql.parser.TypedParamValue;
import org.elasticsearch.xpack.esql.plugin.QueryPragmas;
import org.elasticsearch.xpack.esql.version.EsqlVersion;

import java.io.IOException;
import java.util.List;
Expand All @@ -35,6 +36,7 @@ public class EsqlQueryRequest extends org.elasticsearch.xpack.core.esql.action.E

private boolean async;

private String esqlVersion;
private String query;
private boolean columnar;
private boolean profile;
Expand Down Expand Up @@ -65,17 +67,47 @@ public EsqlQueryRequest(StreamInput in) throws IOException {
@Override
public ActionRequestValidationException validate() {
ActionRequestValidationException validationException = null;
if (Strings.hasText(esqlVersion) == false) {
// TODO: make this required
// "https://github.com/elastic/elasticsearch/issues/104890"
// validationException = addValidationError("[" + RequestXContent.ESQL_VERSION_FIELD + "] is required", validationException);
} else {
EsqlVersion version = EsqlVersion.parse(esqlVersion);
not-napoleon marked this conversation as resolved.
Show resolved Hide resolved
if (version == null) {
validationException = addValidationError(
"[" + RequestXContent.ESQL_VERSION_FIELD + "] has invalid value [" + esqlVersion + "]",
validationException
);
} else if (version == EsqlVersion.NIGHTLY && Build.current().isSnapshot() == false) {
Copy link
Member

Choose a reason for hiding this comment

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

Thinking out loud - does the snapshot check belong here, or in the EsqlVersion.parse method? I can see arguments for both.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hm, personally I do think it belongs here - EsqlVersion shouldn't need to worry about where we permit certain versions to be used IMHO, and why; also, this allows to keep EsqlVersion.parse simple: either the string is a valid version or it's not - otherwise we'd have to throw an exception containing the reason why parsing failed and catch it in EsqlQueryRequest.validate(). We might also parse versions coming from elsewhere in the future, and wouldn't want to prevent parsing of "nightly" just because we're not on a snapshot version.

validationException = addValidationError(
"[" + RequestXContent.ESQL_VERSION_FIELD + "] with value [" + esqlVersion + "] only allowed in snapshot builds",
validationException
);
}
}
if (Strings.hasText(query) == false) {
validationException = addValidationError("[query] is required", validationException);
validationException = addValidationError("[" + RequestXContent.QUERY_FIELD + "] is required", validationException);
alex-spies marked this conversation as resolved.
Show resolved Hide resolved
}
if (Build.current().isSnapshot() == false && pragmas.isEmpty() == false) {
validationException = addValidationError("[pragma] only allowed in snapshot builds", validationException);
validationException = addValidationError(
"[" + RequestXContent.PRAGMA_FIELD + "] only allowed in snapshot builds",
validationException
);
}
return validationException;
}

public EsqlQueryRequest() {}

public void esqlVersion(String esqlVersion) {
this.esqlVersion = esqlVersion;
}

@Override
public String esqlVersion() {
return esqlVersion;
}

public void query(String query) {
this.query = query;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ private EsqlQueryRequestBuilder(ElasticsearchClient client, EsqlQueryRequest req
super(client, EsqlQueryAction.INSTANCE, request);
}

@Override
public EsqlQueryRequestBuilder esqlVersion(String esqlVersion) {
request.esqlVersion(esqlVersion);
return this;
}

@Override
public EsqlQueryRequestBuilder query(String query) {
request.query(query);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,11 @@ final class RequestXContent {
PARAM_PARSER.declareString(constructorArg(), TYPE);
}

private static final ParseField QUERY_FIELD = new ParseField("query");
static final ParseField ESQL_VERSION_FIELD = new ParseField("version");
static final ParseField QUERY_FIELD = new ParseField("query");
private static final ParseField COLUMNAR_FIELD = new ParseField("columnar");
private static final ParseField FILTER_FIELD = new ParseField("filter");
private static final ParseField PRAGMA_FIELD = new ParseField("pragma");
static final ParseField PRAGMA_FIELD = new ParseField("pragma");
private static final ParseField PARAMS_FIELD = new ParseField("params");
private static final ParseField LOCALE_FIELD = new ParseField("locale");
private static final ParseField PROFILE_FIELD = new ParseField("profile");
Expand All @@ -72,6 +73,7 @@ static EsqlQueryRequest parseAsync(XContentParser parser) {
}

private static void objectParserCommon(ObjectParser<EsqlQueryRequest, ?> parser) {
parser.declareString(EsqlQueryRequest::esqlVersion, ESQL_VERSION_FIELD);
parser.declareString(EsqlQueryRequest::query, QUERY_FIELD);
parser.declareBoolean(EsqlQueryRequest::columnar, COLUMNAR_FIELD);
parser.declareObject(EsqlQueryRequest::filter, (p, c) -> AbstractQueryBuilder.parseTopLevelQuery(p), FILTER_FIELD);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

package org.elasticsearch.xpack.esql.version;

import org.elasticsearch.common.Strings;
import org.elasticsearch.common.VersionId;

import java.util.LinkedHashMap;
import java.util.Map;

public enum EsqlVersion implements VersionId<EsqlVersion> {
Copy link
Member

Choose a reason for hiding this comment

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

Better make this a class similar to Version - enum are quite restrictive and impossible to extend.
Having a class gives us future extension points - in the worse case we won't use them and the class will be like an enum.

Copy link
Member

Choose a reason for hiding this comment

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

I think we can flip it to a class with constants when we need that. If we can get away with an enum for a while I'd prefer that.

/**
* Breaking changes go here until the next version is released.
*/
NIGHTLY(Integer.MAX_VALUE, Integer.MAX_VALUE, "😴"),
PARTY_POPPER(2024, 4, "🎉");

static final Map<String, EsqlVersion> VERSION_MAP_WITH_AND_WITHOUT_EMOJI = versionMapWithAndWithoutEmoji();

private static Map<String, EsqlVersion> versionMapWithAndWithoutEmoji() {
Map<String, EsqlVersion> stringToVersion = new LinkedHashMap<>(EsqlVersion.values().length * 2);

for (EsqlVersion version : EsqlVersion.values()) {
EsqlVersion existingVersionForKey = null;
existingVersionForKey = stringToVersion.put(version.versionStringWithoutEmoji(), version);
assert existingVersionForKey == null;
existingVersionForKey = stringToVersion.put(version.toString(), version);
assert existingVersionForKey == null;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
for (EsqlVersion version : EsqlVersion.values()) {
EsqlVersion existingVersionForKey = null;
existingVersionForKey = stringToVersion.put(version.versionStringWithoutEmoji(), version);
assert existingVersionForKey == null;
existingVersionForKey = stringToVersion.put(version.toString(), version);
assert existingVersionForKey == null;
}
for (EsqlVersion version : EsqlVersion.values()) {
assert null == stringToVersion.put(version.versionStringWithoutEmoji(), version);
assert null == stringToVersion.put(version.toString(), version);
}

Copy link
Member

Choose a reason for hiding this comment

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

I think I actually prefer an explicit throw here rather than just an assert. This should run once per instance of Elasticsearch, I don't think we need the performance gain of being able to disable asserts in production.

Copy link
Member

Choose a reason for hiding this comment

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

I'm not entirely sure how assert works, but I'd be worried that putting these onto one line will make it not perform the put if assertions are disabled. FWIW, it doesn't really matter if it runs it or not - it'll slow down a reader either way. But the assert on it's own line is fin.

You could make it a hard test. The extra test on startup is probably not going to hurt us.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This suggestion looks cleaner, but is not equivalent; disabling assertions breaks the version parsing altogether as no version strings are added to the stringToVersion map anymore.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'll add an explicit throw new AssertionError(...).


return stringToVersion;
}

EsqlVersion(int year, int month, String emoji) {
Copy link
Member

Choose a reason for hiding this comment

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

If we think we might need to serialize this, we should explicitly pass in an ordinal value for serialization (that way it's proof against re-ordering the constants or deleting one). I don't know that we'll have to serialize these, but in case you aren't familiar with that pattern, I wanted to call it out.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Discussed with Mark: will add a disambiguation counter for good measure, so versions will be 2024.04.01.🎉 and if we end up really having to release two language versions in a month, there may be a 2024.04.02.💥.

this(year, month, 1, emoji);
}

EsqlVersion(int year, int month, int numberThisMonth, String emoji) {
assert 0 < numberThisMonth && numberThisMonth < 100;
this.year = year;
this.month = month;
this.numberThisMonth = numberThisMonth;
this.emoji = emoji;
}

private int year;
private int month;
Copy link
Contributor

Choose a reason for hiding this comment

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

month can be made byte.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Makes sense; I'd like to still use an int in the constructor. though, for easier readability if that's good with you?

// In case we really have to release more than one version in a given month, disambiguates between versions in the month.
private int numberThisMonth;
alex-spies marked this conversation as resolved.
Show resolved Hide resolved
private String emoji;

/**
* Version prefix that we accept when parsing. If a version string starts with the given prefix, we consider the version string valid.
* E.g. "2024.04.01.🎉" will be interpreted as {@link EsqlVersion#PARTY_POPPER}, but so will "2024.04.01".
*/
public String versionStringWithoutEmoji() {
return this == NIGHTLY ? "nightly" : Strings.format("%d.%02d.%02d", year, month, numberThisMonth);
}

public String emoji() {
return emoji;
}

public static EsqlVersion parse(String versionString) {
return VERSION_MAP_WITH_AND_WITHOUT_EMOJI.get(versionString);
}

@Override
public String toString() {
return versionStringWithoutEmoji() + "." + emoji;
}

@Override
public int id() {
return this == NIGHTLY ? Integer.MAX_VALUE : (10000 * year + 100 * month + numberThisMonth);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
import org.elasticsearch.xcontent.XContentParserConfiguration;
import org.elasticsearch.xcontent.XContentType;
import org.elasticsearch.xpack.esql.parser.TypedParamValue;
import org.elasticsearch.xpack.esql.version.EsqlVersion;
import org.elasticsearch.xpack.esql.version.EsqlVersionTests;

import java.io.IOException;
import java.util.ArrayList;
Expand All @@ -44,20 +46,23 @@ public void testParseFields() throws IOException {
boolean columnar = randomBoolean();
Locale locale = randomLocale(random());
QueryBuilder filter = randomQueryBuilder();
EsqlVersion esqlVersion = randomFrom(EsqlVersion.values());

List<TypedParamValue> params = randomParameters();
boolean hasParams = params.isEmpty() == false;
StringBuilder paramsString = paramsString(params, hasParams);
String json = String.format(Locale.ROOT, """
{
"version": "%s",
"query": "%s",
"columnar": %s,
"locale": "%s",
"filter": %s
%s""", query, columnar, locale.toLanguageTag(), filter, paramsString);
%s""", esqlVersion, query, columnar, locale.toLanguageTag(), filter, paramsString);

EsqlQueryRequest request = parseEsqlQueryRequestSync(json);

assertEquals(esqlVersion.toString(), request.esqlVersion());
assertEquals(query, request.query());
assertEquals(columnar, request.columnar());
assertEquals(locale.toLanguageTag(), request.locale().toLanguageTag());
Expand All @@ -75,6 +80,7 @@ public void testParseFieldsForAsync() throws IOException {
boolean columnar = randomBoolean();
Locale locale = randomLocale(random());
QueryBuilder filter = randomQueryBuilder();
EsqlVersion esqlVersion = randomFrom(EsqlVersion.values());

List<TypedParamValue> params = randomParameters();
boolean hasParams = params.isEmpty() == false;
Expand All @@ -86,6 +92,7 @@ public void testParseFieldsForAsync() throws IOException {
Locale.ROOT,
"""
{
"version": "%s",
"query": "%s",
"columnar": %s,
"locale": "%s",
Expand All @@ -94,6 +101,7 @@ public void testParseFieldsForAsync() throws IOException {
"wait_for_completion_timeout": "%s",
"keep_alive": "%s"
%s""",
esqlVersion,
query,
columnar,
locale.toLanguageTag(),
Expand All @@ -106,6 +114,7 @@ public void testParseFieldsForAsync() throws IOException {

EsqlQueryRequest request = parseEsqlQueryRequestAsync(json);

assertEquals(esqlVersion.toString(), request.esqlVersion());
assertEquals(query, request.query());
assertEquals(columnar, request.columnar());
assertEquals(locale.toLanguageTag(), request.locale().toLanguageTag());
Expand Down Expand Up @@ -149,10 +158,65 @@ public void testRejectUnknownFields() {
}""", "unknown field [asdf]");
}

public void testMissingQueryIsNotValidation() throws IOException {
public void testKnownVersionIsValid() throws IOException {
for (EsqlVersion version : EsqlVersion.values()) {
String validVersionString = randomBoolean() ? version.versionStringWithoutEmoji() : version.toString();

String json = String.format(Locale.ROOT, """
{
"version": "%s",
"query": "ROW x = 1"
}
""", validVersionString);

EsqlQueryRequest request = parseEsqlQueryRequestSync(json);
assertNull(request.validate());

request = parseEsqlQueryRequestAsync(json);
assertNull(request.validate());
}
}

public void testUnknownVersionIsNotValid() throws IOException {
String invalidVersionString = EsqlVersionTests.invalidVersionString();

String json = String.format(Locale.ROOT, """
{
"version": "%s",
"query": "ROW x = 1"
}
""", invalidVersionString);

EsqlQueryRequest request = parseEsqlQueryRequestSync(json);
assertNotNull(request.validate());
assertThat(request.validate().getMessage(), containsString("[version] has invalid value [" + invalidVersionString + "]"));

request = parseEsqlQueryRequestAsync(json);
assertNotNull(request.validate());
assertThat(request.validate().getMessage(), containsString("[version] has invalid value [" + invalidVersionString + "]"));
}

@AwaitsFix(bugUrl = "https://github.com/elastic/elasticsearch/issues/104890")
public void testMissingVersionIsNotValid() throws IOException {
String json = """
{
"columnar": true,
"query": "row x = 1"
alex-spies marked this conversation as resolved.
Show resolved Hide resolved
}""";
EsqlQueryRequest request = parseEsqlQueryRequestSync(json);
assertNotNull(request.validate());
assertThat(request.validate().getMessage(), containsString("[version] is required"));

request = parseEsqlQueryRequestAsync(json);
assertNotNull(request.validate());
assertThat(request.validate().getMessage(), containsString("[version] is required"));
}

public void testMissingQueryIsNotValid() throws IOException {
String json = """
{
"columnar": true
"columnar": true,
"version": "nightly"
}""";
EsqlQueryRequest request = parseEsqlQueryRequestSync(json);
assertNotNull(request.validate());
Expand Down
Loading