Add field type for version strings (#59773) (#62692)

This PR adds a new 'version' field type that allows indexing string values
representing software versions similar to the ones defined in the Semantic
Versioning definition (semver.org). The field behaves very similar to a
'keyword' field but allows efficient sorting and range queries that take into
accound the special ordering needed for version strings. For example, the main
version parts are sorted numerically (ie 2.0.0 < 11.0.0) whereas this wouldn't
be possible with 'keyword' fields today.

Valid version values are similar to the Semantic Versioning definition, with the
notable exception that in addition to the "main" version consiting of
major.minor.patch, we allow less or more than three numeric identifiers, i.e.
"1.2" or "1.4.6.123.12" are treated as valid too.

Relates to #48878
This commit is contained in:
Christoph Büscher 2020-09-21 14:25:42 +02:00 committed by GitHub
parent 5e0f9a414c
commit 803f78ef05
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 2155 additions and 3 deletions

View File

@ -51,6 +51,8 @@ Dates:: Date types, including <<date,`date`>> and
<<range,Range>>:: Range types, such as `long_range`, `double_range`,
`date_range`, and `ip_range`.
<<ip,`ip`>>:: IPv4 and IPv6 addresses.
<<version,Version>>:: Software versions. Supports https://semver.org/[Semantic Versioning]
precedence rules.
{plugins}/mapper-murmur3.html[`murmur3`]:: Compute and stores hashes of
values.
@ -148,6 +150,8 @@ include::types/geo-shape.asciidoc[]
include::types/ip.asciidoc[]
include::types/version.asciidoc[]
include::types/parent-join.asciidoc[]
include::types/keyword.asciidoc[]

View File

@ -134,3 +134,4 @@ The following parameters are accepted by `keyword` fields:
include::constant-keyword.asciidoc[]
include::wildcard.asciidoc[]

View File

@ -0,0 +1,70 @@
[role="xpack"]
[testenv="basic"]
[[version]]
=== Version field type
++++
<titleabbrev>Version</titleabbrev>
++++
The `version` field type is a specialization of the `keyword` field for
handling software version values and to support specialized precedence
rules for them. Precedence is defined following the rules outlined by
https://semver.org/[Semantic Versioning], which for example means that
major, minor and patch version parts are sorted numerically (i.e.
"2.1.0" < "2.4.1" < "2.11.2") and pre-release versions are sorted before
release versions (i.e. "1.0.0-alpha" < "1.0.0").
You index a `version` field as follows
[source,console]
--------------------------------------------------
PUT my-index-000001
{
"mappings": {
"properties": {
"my_version": {
"type": "version"
}
}
}
}
--------------------------------------------------
The field offers the same search capabilities as a regular keyword field. It
can e.g. be searched for exact matches using `match` or `term` queries and
supports prefix and wildcard searches. The main benefit is that `range` queries
will honor Semver ordering, so a `range` query between "1.0.0" and "1.5.0"
will include versions of "1.2.3" but not "1.11.2" for example. Note that this
would be different when using a regular `keyword` field for indexing where ordering
is alphabetical.
Software versions are expected to follow the
https://semver.org/[Semantic Versioning rules] schema and precedence rules with
the notable exception that more or less than three main version identifiers are
allowed (i.e. "1.2" or "1.2.3.4" qualify as valid versions while they wouldn't under
strict Semver rules). Version strings that are not valid under the Semver definition
(e.g. "1.2.alpha.4") can still be indexed and retrieved as exact matches, however they
will all appear _after_ any valid version with regular alphabetical ordering. The empty
String "" is considered invalid and sorted after all valid versions, but before other
invalid ones.
[discrete]
[[version-params]]
==== Parameters for version fields
The following parameters are accepted by `version` fields:
[horizontal]
<<mapping-field-meta,`meta`>>::
Metadata about the field.
[discrete]
==== Limitations
This field type isn't optimized for heavy wildcard, regex or fuzzy searches. While those
type of queries work in this field, you should consider using a regular `keyword` field if
you strongly rely on these kind of queries.

View File

@ -33,9 +33,10 @@ import java.util.Map;
/** Base {@link MappedFieldType} implementation for a field that is indexed
* with the inverted index. */
abstract class TermBasedFieldType extends SimpleMappedFieldType {
public abstract class TermBasedFieldType extends SimpleMappedFieldType {
TermBasedFieldType(String name, boolean isSearchable, boolean hasDocValues, TextSearchInfo textSearchInfo, Map<String, String> meta) {
public TermBasedFieldType(String name, boolean isSearchable, boolean hasDocValues, TextSearchInfo textSearchInfo,
Map<String, String> meta) {
super(name, isSearchable, hasDocValues, textSearchInfo, meta);
}

View File

@ -101,7 +101,7 @@ public class StringStatsAggregator extends MetricsAggregator {
for (int i = 0; i < valuesCount; i++) {
BytesRef value = values.nextValue();
if (value.length > 0) {
String valueStr = value.utf8ToString();
String valueStr = (String) format.format(value);
int length = valueStr.length();
totalLength.increment(bucket, length);

View File

@ -0,0 +1,107 @@
# Integration tests for the version field
#
---
setup:
- skip:
features: headers
version: " - 7.99.99"
reason: "version field is added to 8.0 first"
- do:
indices.create:
index: test_index
body:
mappings:
properties:
version:
type: version
- do:
bulk:
refresh: true
body:
- '{ "index" : { "_index" : "test_index", "_id" : "1" } }'
- '{"version": "1.1.0" }'
- '{ "index" : { "_index" : "test_index", "_id" : "2" } }'
- '{"version": "2.0.0-beta" }'
- '{ "index" : { "_index" : "test_index", "_id" : "3" } }'
- '{"version": "3.1.0" }'
---
"Store malformed":
- do:
indices.create:
index: test_malformed
body:
mappings:
properties:
version:
type: version
- do:
bulk:
refresh: true
body:
- '{ "index" : { "_index" : "test_malformed", "_id" : "1" } }'
- '{"version": "1.1.0" }'
- '{ "index" : { "_index" : "test_malformed", "_id" : "2" } }'
- '{"version": "2.0.0-beta" }'
- '{ "index" : { "_index" : "test_malformed", "_id" : "3" } }'
- '{"version": "v3.1.0" }'
- '{ "index" : { "_index" : "test_malformed", "_id" : "4" } }'
- '{"version": "1.el6" }'
- do:
search:
index: test_malformed
body:
query: { "match" : { "version" : "1.el6" } }
- do:
search:
index: test_malformed
body:
query: { "match_all" : { } }
sort:
version: asc
- match: { hits.total.value: 4 }
- match: { hits.hits.0._source.version: "1.1.0" }
- match: { hits.hits.1._source.version: "2.0.0-beta" }
- match: { hits.hits.2._source.version: "1.el6" }
- match: { hits.hits.3._source.version: "v3.1.0" }
---
"Basic ranges":
- do:
search:
index: test_index
body:
query: { "range" : { "version" : { "gt" : "1.1.0", "lt" : "9999" } } }
- match: { hits.total.value: 2 }
- do:
search:
index: test_index
body:
query: { "range" : { "version" : { "gte" : "1.1.0", "lt" : "9999" } } }
- match: { hits.total.value: 3 }
- do:
search:
index: test_index
body:
query: { "range" : { "version" : { "gte" : "2.0.0", "lt" : "9999" } } }
- match: { hits.total.value: 1 }
- do:
search:
index: test_index
body:
query: { "range" : { "version" : { "gte" : "2.0.0-alpha", "lt" : "9999" } } }
- match: { hits.total.value: 2 }

View File

@ -0,0 +1,54 @@
# Integration tests for the version field
#
---
setup:
- skip:
features: headers
version: " - 7.99.99"
reason: "version field is added to 8.0 first"
- do:
indices.create:
index: test_index
body:
mappings:
properties:
version:
type: version
- do:
bulk:
refresh: true
body:
- '{ "index" : { "_index" : "test_index", "_id" : "1" } }'
- '{"version": "1.1.12" }'
- '{ "index" : { "_index" : "test_index", "_id" : "2" } }'
- '{"version": "2.0.0-beta" }'
- '{ "index" : { "_index" : "test_index", "_id" : "3" } }'
- '{"version": "3.1.0" }'
---
"Filter script":
- do:
search:
index: test_index
body:
query: { "script" : { "script" : { "source": "doc['version'].value.length() > 5"} } }
- match: { hits.total.value: 2 }
- match: { hits.hits.0._source.version: "1.1.12" }
- match: { hits.hits.1._source.version: "2.0.0-beta" }
---
"Sort script":
- do:
search:
index: test_index
body:
sort: { "_script" : { "type" : "number", "script" : { "source": "doc['version'].value.length()" } } }
- match: { hits.total.value: 3 }
- match: { hits.hits.0._source.version: "3.1.0" }
- match: { hits.hits.1._source.version: "1.1.12" }
- match: { hits.hits.2._source.version: "2.0.0-beta" }

View File

@ -0,0 +1,23 @@
evaluationDependsOn(xpackModule('core'))
apply plugin: 'elasticsearch.esplugin'
apply plugin: 'elasticsearch.internal-cluster-test'
esplugin {
name 'versionfield'
description 'A plugin for a field type to store sofware versions'
classname 'org.elasticsearch.xpack.versionfield.VersionFieldPlugin'
extendedPlugins = ['x-pack-core', 'lang-painless']
}
archivesBaseName = 'x-pack-versionfield'
dependencies {
compileOnly project(path: xpackModule('core'), configuration: 'default')
compileOnly project(':modules:lang-painless:spi')
compileOnly(project(':modules:lang-painless')) {
// exclude ASM to not affect featureAware task on Java 10+
exclude group: "org.ow2.asm"
}
testImplementation project(path: xpackModule('core'), configuration: 'testArtifacts')
testImplementation project(path: xpackModule('analytics'), configuration: 'default')
}

View File

@ -0,0 +1,89 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
package org.elasticsearch.xpack.versionfield;
import org.elasticsearch.action.search.SearchResponse;
import org.elasticsearch.common.xcontent.XContentFactory;
import org.elasticsearch.plugins.Plugin;
import org.elasticsearch.search.aggregations.AggregationBuilders;
import org.elasticsearch.search.aggregations.bucket.terms.Terms;
import org.elasticsearch.search.aggregations.bucket.terms.Terms.Bucket;
import org.elasticsearch.test.ESIntegTestCase;
import org.elasticsearch.xpack.core.LocalStateCompositeXPackPlugin;
import java.util.Collection;
import java.util.List;
import static org.elasticsearch.common.xcontent.XContentFactory.jsonBuilder;
public class VersionFieldIT extends ESIntegTestCase {
@Override
protected Collection<Class<? extends Plugin>> nodePlugins() {
return org.elasticsearch.common.collect.List.of(VersionFieldPlugin.class, LocalStateCompositeXPackPlugin.class);
}
public void testTermsAggregation() throws Exception {
String indexName = "test";
createIndex(indexName);
client().admin()
.indices()
.preparePutMapping(indexName)
.setType("_doc")
.setSource(
XContentFactory.jsonBuilder()
.startObject()
.startObject("_doc")
.startObject("properties")
.startObject("version")
.field("type", "version")
.endObject()
.endObject()
.endObject()
.endObject()
)
.get();
ensureGreen();
client().prepareIndex(indexName, "_doc")
.setId("1")
.setSource(jsonBuilder().startObject().field("version", "1.0").endObject())
.get();
client().prepareIndex(indexName, "_doc")
.setId("2")
.setSource(jsonBuilder().startObject().field("version", "1.3.0").endObject())
.get();
client().prepareIndex(indexName, "_doc")
.setId("3")
.setSource(jsonBuilder().startObject().field("version", "2.1.0-alpha").endObject())
.get();
client().prepareIndex(indexName, "_doc")
.setId("4")
.setSource(jsonBuilder().startObject().field("version", "2.1.0").endObject())
.get();
client().prepareIndex(indexName, "_doc")
.setId("5")
.setSource(jsonBuilder().startObject().field("version", "3.11.5").endObject())
.get();
refresh();
// terms aggs
SearchResponse response = client().prepareSearch(indexName)
.addAggregation(AggregationBuilders.terms("myterms").field("version"))
.get();
Terms terms = response.getAggregations().get("myterms");
List<? extends Bucket> buckets = terms.getBuckets();
assertEquals(5, buckets.size());
assertEquals("1.0", buckets.get(0).getKey());
assertEquals("1.3.0", buckets.get(1).getKey());
assertEquals("2.1.0-alpha", buckets.get(2).getKey());
assertEquals("2.1.0", buckets.get(3).getKey());
assertEquals("3.11.5", buckets.get(4).getKey());
}
}

View File

@ -0,0 +1,226 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
package org.elasticsearch.xpack.versionfield;
import org.apache.commons.codec.Charsets;
import org.apache.lucene.util.BytesRef;
import org.apache.lucene.util.BytesRefBuilder;
import java.nio.charset.StandardCharsets;
import java.util.regex.Pattern;
/**
* Encodes a version string to a {@link BytesRef} that correctly sorts according to software version precedence rules like
* the ones described in Semantic Versioning (https://semver.org/)
*
* Version strings are considered to consist of three parts:
* <ul>
* <li> a numeric major.minor.patch part starting the version string (e.g. 1.2.3)
* <li> an optional "pre-release" part that starts with a `-` character and can consist of several alphanumerical sections
* separated by dots (e.g. "-alpha.2.3")
* <li> an optional "build" part that starts with a `+` character. This will simply be treated as a suffix with ASCII sort order.
* </ul>
*
* The version string is encoded such that the ordering works like the following:
* <ul>
* <li> Major, minor, and patch versions are always compared numerically
* <li> pre-release version have lower precedence than a normal version. (e.g 1.0.0-alpha &lt; 1.0.0)
* <li> the precedence for pre-release versions with same main version is calculated comparing each dot separated identifier from
* left to right. Identifiers consisting of only digits are compared numerically and identifiers with letters or hyphens are compared
* lexically in ASCII sort order. Numeric identifiers always have lower precedence than non-numeric identifiers.
* </ul>
*/
class VersionEncoder {
public static final byte NUMERIC_MARKER_BYTE = (byte) 0x01;
public static final byte PRERELEASE_SEPARATOR_BYTE = (byte) 0x02;
public static final byte NO_PRERELEASE_SEPARATOR_BYTE = (byte) 0x03;
private static final char PRERELEASE_SEPARATOR = '-';
private static final char DOT_SEPARATOR = '.';
private static final char BUILD_SEPARATOR = '+';
private static final String ENCODED_EMPTY_STRING = new String(new byte[] { NO_PRERELEASE_SEPARATOR_BYTE }, Charsets.UTF_8);
// Regex to test relaxed Semver Main Version validity. Allows for more or less than three main version parts
private static Pattern LEGAL_MAIN_VERSION_SEMVER = Pattern.compile("(0|[1-9]\\d*)(\\.(0|[1-9]\\d*))*");
private static Pattern LEGAL_PRERELEASE_VERSION_SEMVER = Pattern.compile(
"(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))"
);
private static Pattern LEGAL_BUILDSUFFIX_SEMVER = Pattern.compile("(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?");
/**
* Encodes a version string.
*/
public static EncodedVersion encodeVersion(String versionString) {
VersionParts versionParts = VersionParts.ofVersion(versionString);
// don't treat non-legal versions further, just mark them as illegal and return
if (legalVersionString(versionParts) == false) {
if (versionString.length() == 0) {
// special case, we want empty string to sort after valid strings, which all start with 0x01, add a higher char that
// we are sure to remove when decoding
versionString = ENCODED_EMPTY_STRING;
}
return new EncodedVersion(new BytesRef(versionString), false, true, null, null, null);
}
BytesRefBuilder encodedBytes = new BytesRefBuilder();
Integer[] mainVersionParts = prefixDigitGroupsWithLength(versionParts.mainVersion, encodedBytes);
if (versionParts.preRelease != null) {
encodedBytes.append(PRERELEASE_SEPARATOR_BYTE); // versions with pre-release part sort before ones without
encodedBytes.append((byte) PRERELEASE_SEPARATOR);
String[] preReleaseParts = versionParts.preRelease.substring(1).split("\\.");
boolean first = true;
for (String preReleasePart : preReleaseParts) {
if (first == false) {
encodedBytes.append((byte) DOT_SEPARATOR);
}
boolean isNumeric = preReleasePart.chars().allMatch(x -> Character.isDigit(x));
if (isNumeric) {
prefixDigitGroupsWithLength(preReleasePart, encodedBytes);
} else {
encodedBytes.append(new BytesRef(preReleasePart));
}
first = false;
}
} else {
encodedBytes.append(NO_PRERELEASE_SEPARATOR_BYTE);
}
if (versionParts.buildSuffix != null) {
encodedBytes.append(new BytesRef(versionParts.buildSuffix));
}
return new EncodedVersion(
encodedBytes.toBytesRef(),
true,
versionParts.preRelease != null,
mainVersionParts[0],
mainVersionParts[1],
mainVersionParts[2]
);
}
private static Integer[] prefixDigitGroupsWithLength(String input, BytesRefBuilder result) {
int pos = 0;
int mainVersionCounter = 0;
Integer[] mainVersionComponents = new Integer[3];
while (pos < input.length()) {
if (Character.isDigit(input.charAt(pos))) {
// found beginning of number block, so get its length
int start = pos;
BytesRefBuilder number = new BytesRefBuilder();
while (pos < input.length() && Character.isDigit(input.charAt(pos))) {
number.append((byte) input.charAt(pos));
pos++;
}
int length = pos - start;
if (length >= 128) {
throw new IllegalArgumentException("Groups of digits cannot be longer than 127, but found: " + length);
}
result.append(NUMERIC_MARKER_BYTE); // ensure length byte does cause higher sort order comparing to other byte[]
result.append((byte) (length | 0x80)); // add upper bit to mark as length
result.append(number);
// if present, parse out three leftmost version parts
if (mainVersionCounter < 3) {
mainVersionComponents[mainVersionCounter] = Integer.valueOf(number.toBytesRef().utf8ToString());
mainVersionCounter++;
}
} else {
result.append((byte) input.charAt(pos));
pos++;
}
}
return mainVersionComponents;
}
public static String decodeVersion(BytesRef version) {
int inputPos = version.offset;
int resultPos = 0;
byte[] result = new byte[version.length];
while (inputPos < version.offset + version.length) {
byte inputByte = version.bytes[inputPos];
if (inputByte == NUMERIC_MARKER_BYTE) {
// need to skip this byte
inputPos++;
// this should always be a length encoding, which is skipped by increasing inputPos at the end of the loop
assert version.bytes[inputPos] < 0;
} else if (inputByte != PRERELEASE_SEPARATOR_BYTE && inputByte != NO_PRERELEASE_SEPARATOR_BYTE) {
result[resultPos] = inputByte;
resultPos++;
}
inputPos++;
}
return new String(result, 0, resultPos, StandardCharsets.UTF_8);
}
static boolean legalVersionString(VersionParts versionParts) {
boolean legalMainVersion = LEGAL_MAIN_VERSION_SEMVER.matcher(versionParts.mainVersion).matches();
boolean legalPreRelease = true;
if (versionParts.preRelease != null) {
legalPreRelease = LEGAL_PRERELEASE_VERSION_SEMVER.matcher(versionParts.preRelease).matches();
}
boolean legalBuildSuffix = true;
if (versionParts.buildSuffix != null) {
legalBuildSuffix = LEGAL_BUILDSUFFIX_SEMVER.matcher(versionParts.buildSuffix).matches();
}
return legalMainVersion && legalPreRelease && legalBuildSuffix;
}
static class EncodedVersion {
public final boolean isLegal;
public final boolean isPreRelease;
public final BytesRef bytesRef;
public final Integer major;
public final Integer minor;
public final Integer patch;
private EncodedVersion(BytesRef bytesRef, boolean isLegal, boolean isPreRelease, Integer major, Integer minor, Integer patch) {
super();
this.bytesRef = bytesRef;
this.isLegal = isLegal;
this.isPreRelease = isPreRelease;
this.major = major;
this.minor = minor;
this.patch = patch;
}
}
static class VersionParts {
final String mainVersion;
final String preRelease;
final String buildSuffix;
private VersionParts(String mainVersion, String preRelease, String buildSuffix) {
this.mainVersion = mainVersion;
this.preRelease = preRelease;
this.buildSuffix = buildSuffix;
}
static VersionParts ofVersion(String versionString) {
String buildSuffix = extractSuffix(versionString, BUILD_SEPARATOR);
if (buildSuffix != null) {
versionString = versionString.substring(0, versionString.length() - buildSuffix.length());
}
String preRelease = extractSuffix(versionString, PRERELEASE_SEPARATOR);
if (preRelease != null) {
versionString = versionString.substring(0, versionString.length() - preRelease.length());
}
return new VersionParts(versionString, preRelease, buildSuffix);
}
private static String extractSuffix(String input, char separator) {
int start = input.indexOf(separator);
return start > 0 ? input.substring(start) : null;
}
}
}

View File

@ -0,0 +1,42 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
package org.elasticsearch.xpack.versionfield;
import org.elasticsearch.painless.spi.PainlessExtension;
import org.elasticsearch.painless.spi.Whitelist;
import org.elasticsearch.painless.spi.WhitelistLoader;
import org.elasticsearch.script.AggregationScript;
import org.elasticsearch.script.FieldScript;
import org.elasticsearch.script.FilterScript;
import org.elasticsearch.script.NumberSortScript;
import org.elasticsearch.script.ScoreScript;
import org.elasticsearch.script.ScriptContext;
import org.elasticsearch.script.StringSortScript;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static java.util.Collections.singletonList;
public class VersionFieldDocValuesExtension implements PainlessExtension {
private static final Whitelist WHITELIST = WhitelistLoader.loadFromResourceFiles(VersionFieldDocValuesExtension.class, "whitelist.txt");
@Override
public Map<ScriptContext<?>, List<Whitelist>> getContextWhitelists() {
Map<ScriptContext<?>, List<Whitelist>> whitelist = new HashMap<>();
List<Whitelist> list = singletonList(WHITELIST);
whitelist.put(AggregationScript.CONTEXT, list);
whitelist.put(ScoreScript.CONTEXT, list);
whitelist.put(FilterScript.CONTEXT, list);
whitelist.put(FieldScript.CONTEXT, list);
whitelist.put(NumberSortScript.CONTEXT, list);
whitelist.put(StringSortScript.CONTEXT, list);
return whitelist;
}
}

View File

@ -0,0 +1,38 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
package org.elasticsearch.xpack.versionfield;
import org.elasticsearch.common.io.stream.NamedWriteableRegistry;
import org.elasticsearch.common.settings.Settings;
import org.elasticsearch.index.mapper.Mapper;
import org.elasticsearch.plugins.MapperPlugin;
import org.elasticsearch.plugins.Plugin;
import org.elasticsearch.search.DocValueFormat;
import java.util.List;
import java.util.Map;
public class VersionFieldPlugin extends Plugin implements MapperPlugin {
public VersionFieldPlugin(Settings settings) {}
@Override
public Map<String, Mapper.TypeParser> getMappers() {
return org.elasticsearch.common.collect.Map.of(VersionStringFieldMapper.CONTENT_TYPE, VersionStringFieldMapper.PARSER);
}
@Override
public List<NamedWriteableRegistry.Entry> getNamedWriteables() {
return org.elasticsearch.common.collect.List.of(
new NamedWriteableRegistry.Entry(
DocValueFormat.class,
VersionStringFieldMapper.VERSION_DOCVALUE.getWriteableName(),
in -> VersionStringFieldMapper.VERSION_DOCVALUE
)
);
}
}

View File

@ -0,0 +1,120 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
package org.elasticsearch.xpack.versionfield;
import org.apache.lucene.index.Term;
import org.apache.lucene.search.AutomatonQuery;
import org.apache.lucene.search.WildcardQuery;
import org.apache.lucene.util.BytesRef;
import org.apache.lucene.util.automaton.Automata;
import org.apache.lucene.util.automaton.Automaton;
import org.apache.lucene.util.automaton.Operations;
import java.util.ArrayList;
import java.util.List;
/**
* A variation of the {@link WildcardQuery} than skips over meta characters introduced using {@link VersionEncoder}.
*/
class VersionFieldWildcardQuery extends AutomatonQuery {
private static final Automaton OPTIONAL_NUMERIC_CHARPREFIX = Operations.optional(
Operations.concatenate(Automata.makeChar(VersionEncoder.NUMERIC_MARKER_BYTE), Automata.makeCharRange(0x80, 0xFF))
);
private static final Automaton OPTIONAL_RELEASE_SEPARATOR = Operations.optional(
Operations.union(
Automata.makeChar(VersionEncoder.PRERELEASE_SEPARATOR_BYTE),
Automata.makeChar(VersionEncoder.NO_PRERELEASE_SEPARATOR_BYTE)
)
);
private static final byte WILDCARD_STRING = '*';
private static final byte WILDCARD_CHAR = '?';
VersionFieldWildcardQuery(Term term) {
super(term, toAutomaton(term), Integer.MAX_VALUE, true);
}
private static Automaton toAutomaton(Term wildcardquery) {
List<Automaton> automata = new ArrayList<>();
BytesRef wildcardText = wildcardquery.bytes();
boolean containsPreReleaseSeparator = false;
for (int i = 0; i < wildcardText.length;) {
final byte c = wildcardText.bytes[wildcardText.offset + i];
int length = Character.charCount(c);
switch (c) {
case WILDCARD_STRING:
automata.add(Automata.makeAnyString());
break;
case WILDCARD_CHAR:
// this should also match leading digits, which have optional leading numeric marker and length bytes
automata.add(OPTIONAL_NUMERIC_CHARPREFIX);
automata.add(OPTIONAL_RELEASE_SEPARATOR);
automata.add(Automata.makeAnyChar());
break;
case '-':
// this should potentially match the first prerelease-dash, so we need an optional marker byte here
automata.add(Operations.optional(Automata.makeChar(VersionEncoder.PRERELEASE_SEPARATOR_BYTE)));
containsPreReleaseSeparator = true;
automata.add(Automata.makeChar(c));
break;
case '+':
// this can potentially appear after major version, optionally match the no-prerelease marker
automata.add(Operations.optional(Automata.makeChar(VersionEncoder.NO_PRERELEASE_SEPARATOR_BYTE)));
containsPreReleaseSeparator = true;
automata.add(Automata.makeChar(c));
break;
case '0':
case '1':
case '2':
case '3':
case '4':
case '5':
case '6':
case '7':
case '8':
case '9':
boolean firstDigitInGroup = true;
if (i > 0
&& wildcardText.bytes[wildcardText.offset + i - 1] >= (byte) '0'
&& wildcardText.bytes[wildcardText.offset + i - 1] <= (byte) '9') {
firstDigitInGroup = false;
}
if (firstDigitInGroup) {
automata.add(OPTIONAL_NUMERIC_CHARPREFIX);
}
automata.add(Automata.makeChar(c));
break;
default:
automata.add(Automata.makeChar(c));
}
i += length;
}
// when we only have main version part, we need to add an optional NO_PRERELESE_SEPARATOR_BYTE
if (containsPreReleaseSeparator == false) {
automata.add(Operations.optional(Automata.makeChar(VersionEncoder.NO_PRERELEASE_SEPARATOR_BYTE)));
}
return Operations.concatenate(automata);
}
@Override
public String toString(String field) {
StringBuilder buffer = new StringBuilder();
if (!getField().equals(field)) {
buffer.append(getField());
buffer.append(":");
}
buffer.append(term.text());
return buffer.toString();
}
}

View File

@ -0,0 +1,58 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
package org.elasticsearch.xpack.versionfield;
import org.apache.lucene.index.SortedSetDocValues;
import org.apache.lucene.util.ArrayUtil;
import org.elasticsearch.index.fielddata.ScriptDocValues;
import java.io.IOException;
public final class VersionScriptDocValues extends ScriptDocValues<String> {
private final SortedSetDocValues in;
private long[] ords = new long[0];
private int count;
public VersionScriptDocValues(SortedSetDocValues in) {
this.in = in;
}
@Override
public void setNextDocId(int docId) throws IOException {
count = 0;
if (in.advanceExact(docId)) {
for (long ord = in.nextOrd(); ord != SortedSetDocValues.NO_MORE_ORDS; ord = in.nextOrd()) {
ords = ArrayUtil.grow(ords, count + 1);
ords[count++] = ord;
}
}
}
public String getValue() {
return get(0);
}
@Override
public String get(int index) {
if (count == 0) {
throw new IllegalStateException(
"A document doesn't have a value for a field! " + "Use doc[<field>].size()==0 to check if a document is missing a field!"
);
}
try {
return VersionEncoder.decodeVersion(in.lookupOrd(ords[index]));
} catch (IOException e) {
throw new RuntimeException(e);
}
}
@Override
public int size() {
return count;
}
}

View File

@ -0,0 +1,404 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
package org.elasticsearch.xpack.versionfield;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.FieldType;
import org.apache.lucene.document.SortedSetDocValuesField;
import org.apache.lucene.index.FilteredTermsEnum;
import org.apache.lucene.index.IndexOptions;
import org.apache.lucene.index.Term;
import org.apache.lucene.index.Terms;
import org.apache.lucene.index.TermsEnum;
import org.apache.lucene.search.DocValuesFieldExistsQuery;
import org.apache.lucene.search.FuzzyQuery;
import org.apache.lucene.search.MultiTermQuery;
import org.apache.lucene.search.Query;
import org.apache.lucene.search.RegexpQuery;
import org.apache.lucene.search.TermRangeQuery;
import org.apache.lucene.util.AttributeSource;
import org.apache.lucene.util.BytesRef;
import org.apache.lucene.util.automaton.ByteRunAutomaton;
import org.elasticsearch.ElasticsearchException;
import org.elasticsearch.common.Nullable;
import org.elasticsearch.common.collect.Iterators;
import org.elasticsearch.common.io.stream.StreamOutput;
import org.elasticsearch.common.lucene.BytesRefs;
import org.elasticsearch.common.lucene.Lucene;
import org.elasticsearch.common.unit.Fuzziness;
import org.elasticsearch.common.xcontent.XContentParser;
import org.elasticsearch.index.fielddata.IndexFieldData;
import org.elasticsearch.index.fielddata.plain.SortedSetOrdinalsIndexFieldData;
import org.elasticsearch.index.mapper.FieldMapper;
import org.elasticsearch.index.mapper.MappedFieldType;
import org.elasticsearch.index.mapper.Mapper;
import org.elasticsearch.index.mapper.MapperService;
import org.elasticsearch.index.mapper.ParametrizedFieldMapper;
import org.elasticsearch.index.mapper.ParseContext;
import org.elasticsearch.index.mapper.SourceValueFetcher;
import org.elasticsearch.index.mapper.TermBasedFieldType;
import org.elasticsearch.index.mapper.TextSearchInfo;
import org.elasticsearch.index.mapper.ValueFetcher;
import org.elasticsearch.index.query.QueryShardContext;
import org.elasticsearch.index.query.support.QueryParsers;
import org.elasticsearch.search.DocValueFormat;
import org.elasticsearch.search.aggregations.support.CoreValuesSourceType;
import org.elasticsearch.search.lookup.SearchLookup;
import org.elasticsearch.xpack.versionfield.VersionEncoder.EncodedVersion;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.function.Supplier;
import static org.elasticsearch.search.SearchService.ALLOW_EXPENSIVE_QUERIES;
import static org.elasticsearch.xpack.versionfield.VersionEncoder.encodeVersion;
/**
* A {@link FieldMapper} for indexing fields with version strings.
*/
public class VersionStringFieldMapper extends ParametrizedFieldMapper {
private static byte[] MIN_VALUE = new byte[16];
private static byte[] MAX_VALUE = new byte[16];
static {
Arrays.fill(MIN_VALUE, (byte) 0);
Arrays.fill(MAX_VALUE, (byte) -1);
}
public static final String CONTENT_TYPE = "version";
public static class Defaults {
public static final FieldType FIELD_TYPE = new FieldType();
static {
FIELD_TYPE.setTokenized(false);
FIELD_TYPE.setOmitNorms(true);
FIELD_TYPE.setIndexOptions(IndexOptions.DOCS);
FIELD_TYPE.freeze();
}
}
static class Builder extends ParametrizedFieldMapper.Builder {
private final Parameter<Map<String, String>> meta = Parameter.metaParam();
Builder(String name) {
super(name);
}
private VersionStringFieldType buildFieldType(BuilderContext context, FieldType fieldtype) {
return new VersionStringFieldType(buildFullName(context), fieldtype, meta.getValue());
}
@Override
public VersionStringFieldMapper build(BuilderContext context) {
FieldType fieldtype = new FieldType(Defaults.FIELD_TYPE);
return new VersionStringFieldMapper(
name,
fieldtype,
buildFieldType(context, fieldtype),
multiFieldsBuilder.build(this, context),
copyTo.build()
);
}
@Override
protected List<Parameter<?>> getParameters() {
return org.elasticsearch.common.collect.List.of(meta);
}
}
public static final TypeParser PARSER = new TypeParser((n, c) -> new Builder(n));
public static final class VersionStringFieldType extends TermBasedFieldType {
public VersionStringFieldType(String name, FieldType fieldType, Map<String, String> meta) {
super(name, true, true, new TextSearchInfo(fieldType, null, Lucene.KEYWORD_ANALYZER, Lucene.KEYWORD_ANALYZER), meta);
setIndexAnalyzer(Lucene.KEYWORD_ANALYZER);
}
@Override
public String typeName() {
return CONTENT_TYPE;
}
@Override
public Query existsQuery(QueryShardContext context) {
return new DocValuesFieldExistsQuery(name());
}
@Override
public Query prefixQuery(String value, MultiTermQuery.RewriteMethod method, QueryShardContext context) {
return wildcardQuery(value + "*", method, context);
}
/**
* We cannot simply use RegexpQuery directly since we use the encoded terms from the dictionary, but the
* automaton in the query will assume unencoded terms. We are running through all terms, decode them and
* then run them through the automaton manually instead. This is not as efficient as intersecting the original
* Terms with the compiled automaton, but we expect the number of distinct version terms indexed into this field
* to be low enough and the use of "regexp" queries on this field rare enough to brute-force this
*/
@Override
public Query regexpQuery(
String value,
int syntaxFlags,
int matchFlags,
int maxDeterminizedStates,
@Nullable MultiTermQuery.RewriteMethod method,
QueryShardContext context
) {
if (context.allowExpensiveQueries() == false) {
throw new ElasticsearchException(
"[regexp] queries cannot be executed when '" + ALLOW_EXPENSIVE_QUERIES.getKey() + "' is set to false."
);
}
RegexpQuery query = new RegexpQuery(new Term(name(), new BytesRef(value)), syntaxFlags, matchFlags, maxDeterminizedStates) {
@Override
protected TermsEnum getTermsEnum(Terms terms, AttributeSource atts) throws IOException {
return new FilteredTermsEnum(terms.iterator(), false) {
@Override
protected AcceptStatus accept(BytesRef term) throws IOException {
byte[] decoded = VersionEncoder.decodeVersion(term).getBytes(StandardCharsets.UTF_8);
boolean accepted = compiled.runAutomaton.run(decoded, 0, decoded.length);
if (accepted) {
return AcceptStatus.YES;
}
return AcceptStatus.NO;
}
};
}
};
if (method != null) {
query.setRewriteMethod(method);
}
return query;
}
/**
* We cannot simply use FuzzyQuery directly since we use the encoded terms from the dictionary, but the
* automaton in the query will assume unencoded terms. We are running through all terms, decode them and
* then run them through the automaton manually instead. This is not as efficient as intersecting the original
* Terms with the compiled automaton, but we expect the number of distinct version terms indexed into this field
* to be low enough and the use of "fuzzy" queries on this field rare enough to brute-force this
*/
@Override
public Query fuzzyQuery(
Object value,
Fuzziness fuzziness,
int prefixLength,
int maxExpansions,
boolean transpositions,
QueryShardContext context
) {
if (context.allowExpensiveQueries() == false) {
throw new ElasticsearchException(
"[fuzzy] queries cannot be executed when '" + ALLOW_EXPENSIVE_QUERIES.getKey() + "' is set to false."
);
}
return new FuzzyQuery(
new Term(name(), (BytesRef) value),
fuzziness.asDistance(BytesRefs.toString(value)),
prefixLength,
maxExpansions,
transpositions
) {
@Override
protected TermsEnum getTermsEnum(Terms terms, AttributeSource atts) throws IOException {
ByteRunAutomaton runAutomaton = getAutomata().runAutomaton;
return new FilteredTermsEnum(terms.iterator(), false) {
@Override
protected AcceptStatus accept(BytesRef term) throws IOException {
byte[] decoded = VersionEncoder.decodeVersion(term).getBytes(StandardCharsets.UTF_8);
boolean accepted = runAutomaton.run(decoded, 0, decoded.length);
if (accepted) {
return AcceptStatus.YES;
}
return AcceptStatus.NO;
}
};
}
};
}
@Override
public Query wildcardQuery(String value, MultiTermQuery.RewriteMethod method, QueryShardContext context) {
if (context.allowExpensiveQueries() == false) {
throw new ElasticsearchException(
"[wildcard] queries cannot be executed when '" + ALLOW_EXPENSIVE_QUERIES.getKey() + "' is set to false."
);
}
VersionFieldWildcardQuery query = new VersionFieldWildcardQuery(new Term(name(), value));
QueryParsers.setRewriteMethod(query, method);
return query;
}
@Override
protected BytesRef indexedValueForSearch(Object value) {
String valueAsString;
if (value instanceof String) {
valueAsString = (String) value;
} else if (value instanceof BytesRef) {
// encoded string, need to re-encode
valueAsString = ((BytesRef) value).utf8ToString();
} else {
throw new IllegalArgumentException("Illegal value type: " + value.getClass() + ", value: " + value);
}
return encodeVersion(valueAsString).bytesRef;
}
@Override
public IndexFieldData.Builder fielddataBuilder(String fullyQualifiedIndexName, Supplier<SearchLookup> searchLookup) {
return new SortedSetOrdinalsIndexFieldData.Builder(name(), VersionScriptDocValues::new, CoreValuesSourceType.BYTES);
}
@Override
public Object valueForDisplay(Object value) {
if (value == null) {
return null;
}
return VERSION_DOCVALUE.format((BytesRef) value);
}
@Override
public DocValueFormat docValueFormat(@Nullable String format, ZoneId timeZone) {
if (format != null) {
throw new IllegalArgumentException("Field [" + name() + "] of type [" + typeName() + "] does not support custom formats");
}
if (timeZone != null) {
throw new IllegalArgumentException(
"Field [" + name() + "] of type [" + typeName() + "] does not support custom time zones"
);
}
return VERSION_DOCVALUE;
}
@Override
public Query rangeQuery(Object lowerTerm, Object upperTerm, boolean includeLower, boolean includeUpper, QueryShardContext context) {
BytesRef lower = lowerTerm == null ? null : indexedValueForSearch(lowerTerm);
BytesRef upper = upperTerm == null ? null : indexedValueForSearch(upperTerm);
return new TermRangeQuery(name(), lower, upper, includeLower, includeUpper);
}
}
private final FieldType fieldType;
private VersionStringFieldMapper(
String simpleName,
FieldType fieldType,
MappedFieldType mappedFieldType,
MultiFields multiFields,
CopyTo copyTo
) {
super(simpleName, mappedFieldType, multiFields, copyTo);
this.fieldType = fieldType;
}
@Override
public VersionStringFieldType fieldType() {
return (VersionStringFieldType) super.fieldType();
}
@Override
public ValueFetcher valueFetcher(MapperService mapperService, SearchLookup searchLookup, String format) {
if (format != null) {
throw new IllegalArgumentException("Field [" + name() + "] of type [" + typeName() + "] doesn't support formats.");
}
return new SourceValueFetcher(name(), mapperService, parsesArrayValue(), null) {
@Override
protected String parseSourceValue(Object value) {
return value.toString();
}
};
}
@Override
protected String contentType() {
return CONTENT_TYPE;
}
@Override
protected VersionStringFieldMapper clone() {
return (VersionStringFieldMapper) super.clone();
}
@Override
protected void parseCreateField(ParseContext context) throws IOException {
String versionString;
if (context.externalValueSet()) {
versionString = context.externalValue().toString();
} else {
XContentParser parser = context.parser();
if (parser.currentToken() == XContentParser.Token.VALUE_NULL) {
return;
} else {
versionString = parser.textOrNull();
}
}
if (versionString == null) {
return;
}
EncodedVersion encoding = encodeVersion(versionString);
BytesRef encodedVersion = encoding.bytesRef;
context.doc().add(new Field(fieldType().name(), encodedVersion, fieldType));
context.doc().add(new SortedSetDocValuesField(fieldType().name(), encodedVersion));
}
@Override
public Iterator<Mapper> iterator() {
List<Mapper> subIterators = new ArrayList<>();
@SuppressWarnings("unchecked")
Iterator<Mapper> concat = Iterators.concat(super.iterator(), subIterators.iterator());
return concat;
}
public static DocValueFormat VERSION_DOCVALUE = new DocValueFormat() {
@Override
public String getWriteableName() {
return "version";
}
@Override
public void writeTo(StreamOutput out) {}
@Override
public String format(BytesRef value) {
return VersionEncoder.decodeVersion(value);
}
@Override
public BytesRef parseBytesRef(String value) {
return VersionEncoder.encodeVersion(value).bytesRef;
}
@Override
public String toString() {
return getWriteableName();
}
};
@Override
public ParametrizedFieldMapper.Builder getMergeBuilder() {
return new Builder(simpleName()).init(this);
}
}

View File

@ -0,0 +1 @@
org.elasticsearch.xpack.versionfield.VersionFieldDocValuesExtension

View File

@ -0,0 +1,5 @@
class org.elasticsearch.xpack.versionfield.VersionScriptDocValues {
String get(int)
String getValue()
}

View File

@ -0,0 +1,237 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
package org.elasticsearch.xpack.versionfield;
import joptsimple.internal.Strings;
import org.apache.lucene.util.BytesRef;
import org.elasticsearch.test.ESTestCase;
import org.elasticsearch.xpack.versionfield.VersionEncoder.EncodedVersion;
import java.util.Arrays;
import static org.elasticsearch.xpack.versionfield.VersionEncoder.decodeVersion;
public class VersionEncoderTests extends ESTestCase {
public void testEncodingOrderingSemver() {
assertTrue(encodeVersion("1").compareTo(encodeVersion("1.0")) < 0);
assertTrue(encodeVersion("1.0").compareTo(encodeVersion("1.0.0.0.0.0.0.0.0.1")) < 0);
assertTrue(encodeVersion("1.0.0").compareTo(encodeVersion("1.0.0.0.0.0.0.0.0.1")) < 0);
assertTrue(encodeVersion("1.0.0").compareTo(encodeVersion("2.0.0")) < 0);
assertTrue(encodeVersion("2.0.0").compareTo(encodeVersion("11.0.0")) < 0);
assertTrue(encodeVersion("2.0.0").compareTo(encodeVersion("2.1.0")) < 0);
assertTrue(encodeVersion("2.1.0").compareTo(encodeVersion("2.1.1")) < 0);
assertTrue(encodeVersion("2.1.1").compareTo(encodeVersion("2.1.1.0")) < 0);
assertTrue(encodeVersion("2.0.0").compareTo(encodeVersion("11.0.0")) < 0);
assertTrue(encodeVersion("1.0.0").compareTo(encodeVersion("2.0")) < 0);
assertTrue(encodeVersion("1.0.0-a").compareTo(encodeVersion("1.0.0-b")) < 0);
assertTrue(encodeVersion("1.0.0-1.0.0").compareTo(encodeVersion("1.0.0-2.0")) < 0);
assertTrue(encodeVersion("1.0.0-alpha").compareTo(encodeVersion("1.0.0-alpha.1")) < 0);
assertTrue(encodeVersion("1.0.0-alpha.1").compareTo(encodeVersion("1.0.0-alpha.beta")) < 0);
assertTrue(encodeVersion("1.0.0-alpha.beta").compareTo(encodeVersion("1.0.0-beta")) < 0);
assertTrue(encodeVersion("1.0.0-beta").compareTo(encodeVersion("1.0.0-beta.2")) < 0);
assertTrue(encodeVersion("1.0.0-beta.2").compareTo(encodeVersion("1.0.0-beta.11")) < 0);
assertTrue(encodeVersion("1.0.0-beta11").compareTo(encodeVersion("1.0.0-beta2")) < 0); // correct according to Semver specs
assertTrue(encodeVersion("1.0.0-beta.11").compareTo(encodeVersion("1.0.0-rc.1")) < 0);
assertTrue(encodeVersion("1.0.0-rc.1").compareTo(encodeVersion("1.0.0")) < 0);
assertTrue(encodeVersion("1.0.0").compareTo(encodeVersion("2.0.0-pre127")) < 0);
assertTrue(encodeVersion("2.0.0-pre127").compareTo(encodeVersion("2.0.0-pre128")) < 0);
assertTrue(encodeVersion("2.0.0-pre128").compareTo(encodeVersion("2.0.0-pre128-somethingelse")) < 0);
assertTrue(encodeVersion("2.0.0-pre20201231z110026").compareTo(encodeVersion("2.0.0-pre227")) < 0);
// invalid versions sort after valid ones
assertTrue(encodeVersion("99999.99999.99999").compareTo(encodeVersion("1.invalid")) < 0);
assertTrue(encodeVersion("").compareTo(encodeVersion("a")) < 0);
}
private static BytesRef encodeVersion(String version) {
return VersionEncoder.encodeVersion(version).bytesRef;
}
public void testPreReleaseFlag() {
assertTrue(VersionEncoder.encodeVersion("1.2-alpha.beta").isPreRelease);
assertTrue(VersionEncoder.encodeVersion("1.2.3-someOtherPreRelease").isPreRelease);
assertTrue(VersionEncoder.encodeVersion("1.2.3-some-Other-Pre.123").isPreRelease);
assertTrue(VersionEncoder.encodeVersion("1.2.3-some-Other-Pre.123+withBuild").isPreRelease);
assertFalse(VersionEncoder.encodeVersion("1").isPreRelease);
assertFalse(VersionEncoder.encodeVersion("1.2").isPreRelease);
assertFalse(VersionEncoder.encodeVersion("1.2.3").isPreRelease);
assertFalse(VersionEncoder.encodeVersion("1.2.3+buildSufix").isPreRelease);
assertFalse(VersionEncoder.encodeVersion("1.2.3+buildSufix-withDash").isPreRelease);
}
public void testVersionPartExtraction() {
int numParts = randomIntBetween(1, 6);
String[] parts = new String[numParts];
for (int i = 0; i < numParts; i++) {
parts[i] = String.valueOf(randomIntBetween(1, 1000));
}
EncodedVersion encodedVersion = VersionEncoder.encodeVersion(String.join(".", parts));
assertEquals(parts[0], encodedVersion.major.toString());
if (numParts > 1) {
assertEquals(parts[1], encodedVersion.minor.toString());
} else {
assertNull(encodedVersion.minor);
}
if (numParts > 2) {
assertEquals(parts[2], encodedVersion.patch.toString());
} else {
assertNull(encodedVersion.patch);
}
}
public void testMaxDigitGroupLength() {
String versionString = "1.0." + Strings.repeat('1', 128);
IllegalArgumentException ex = expectThrows(IllegalArgumentException.class, () -> decodeVersion(encodeVersion(versionString)));
assertEquals("Groups of digits cannot be longer than 127, but found: 128", ex.getMessage());
}
/**
* test that encoding and decoding leads back to the same version string
*/
public void testRandomRoundtrip() {
String versionString = randomVersionString();
assertEquals(versionString, decodeVersion(encodeVersion(versionString)));
}
private String randomVersionString() {
StringBuilder sb = new StringBuilder();
sb.append(randomIntBetween(0, 1000));
int releaseNumerals = randomIntBetween(0, 4);
for (int i = 0; i < releaseNumerals; i++) {
sb.append(".");
sb.append(randomIntBetween(0, 10000));
}
// optional pre-release part
if (randomBoolean()) {
sb.append("-");
int preReleaseParts = randomIntBetween(1, 5);
for (int i = 0; i < preReleaseParts; i++) {
if (randomBoolean()) {
sb.append(randomIntBetween(0, 1000));
} else {
int alphanumParts = 3;
for (int j = 0; j < alphanumParts; j++) {
if (randomBoolean()) {
sb.append(randomAlphaOfLengthBetween(1, 2));
} else {
sb.append(randomIntBetween(1, 99));
}
if (rarely()) {
sb.append(randomFrom(Arrays.asList("-")));
}
}
}
sb.append(".");
}
sb.deleteCharAt(sb.length() - 1); // remove trailing dot
}
// optional build part
if (randomBoolean()) {
sb.append("+").append(randomAlphaOfLengthBetween(1, 15));
}
return sb.toString();
}
/**
* taken from https://regex101.com/r/vkijKf/1/ via https://semver.org/
*/
public void testSemVerValidation() {
String[] validSemverVersions = new String[] {
"0.0.4",
"1.2.3",
"10.20.30",
"1.1.2-prerelease+meta",
"1.1.2+meta",
"1.1.2+meta-valid",
"1.0.0-alpha",
"1.0.0-beta",
"1.0.0-alpha.beta",
"1.0.0-alpha.beta.1",
"1.0.0-alpha.1",
"1.0.0-alpha0.valid",
"1.0.0-alpha.0valid",
"1.0.0-alpha-a.b-c-somethinglong+build.1-aef.1-its-okay",
"1.0.0-rc.1+build.1",
"2.0.0-rc.1+build.123",
"1.2.3-beta",
"10.2.3-DEV-SNAPSHOT",
"1.2.3-SNAPSHOT-123",
"1.0.0",
"2.0.0",
"1.1.7",
"2.0.0+build.1848",
"2.0.1-alpha.1227",
"1.0.0-alpha+beta",
"1.2.3----RC-SNAPSHOT.12.9.1--.12+788",
"1.2.3----R-S.12.9.1--.12+meta",
"1.2.3----RC-SNAPSHOT.12.9.1--.12",
"1.0.0+0.build.1-rc.10000aaa-kk-0.1",
"999999999.999999999.999999999",
"1.0.0-0A.is.legal",
// the following are not strict semver but we allow them
"1.2-SNAPSHOT",
"1.2-RC-SNAPSHOT",
"1",
"1.2.3.4" };
for (String version : validSemverVersions) {
assertTrue("should be valid: " + version, VersionEncoder.encodeVersion(version).isLegal);
// since we're here, also check encoding / decoding rountrip
assertEquals(version, decodeVersion(encodeVersion(version)));
}
String[] invalidSemverVersions = new String[] {
"",
"1.2.3-0123",
"1.2.3-0123.0123",
"1.1.2+.123",
"+invalid",
"-invalid",
"-invalid+invalid",
"-invalid.01",
"alpha",
"alpha.beta",
"alpha.beta.1",
"alpha.1",
"alpha+beta",
"alpha_beta",
"alpha.",
"alpha..",
"beta",
"1.0.0-alpha_beta",
"-alpha.",
"1.0.0-alpha..",
"1.0.0-alpha..1",
"1.0.0-alpha...1",
"1.0.0-alpha....1",
"1.0.0-alpha.....1",
"1.0.0-alpha......1",
"1.0.0-alpha.......1",
"01.1.1",
"1.01.1",
"1.1.01",
"1.2.3.DEV",
"1.2.31.2.3----RC-SNAPSHOT.12.09.1--..12+788",
"-1.0.3-gamma+b7718",
"+justmeta",
"9.8.7+meta+meta",
"9.8.7-whatever+meta+meta",
"999999999.999999999.999999999.----RC-SNAPSHOT.12.09.1--------------------------------..12",
"12.el2",
"12.el2-1.0-rc5",
"6.nüll.7" // make sure extended ascii-range (128-255) in invalid versions is decoded correctly
};
for (String version : invalidSemverVersions) {
assertFalse("should be invalid: " + version, VersionEncoder.encodeVersion(version).isLegal);
// since we're here, also check encoding / decoding rountrip
assertEquals(version, decodeVersion(encodeVersion(version)));
}
}
}

View File

@ -0,0 +1,139 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
package org.elasticsearch.xpack.versionfield;
import org.apache.lucene.index.DocValuesType;
import org.apache.lucene.index.IndexOptions;
import org.apache.lucene.index.IndexableField;
import org.apache.lucene.index.IndexableFieldType;
import org.elasticsearch.Version;
import org.elasticsearch.cluster.metadata.IndexMetadata;
import org.elasticsearch.common.Strings;
import org.elasticsearch.common.bytes.BytesReference;
import org.elasticsearch.common.settings.Settings;
import org.elasticsearch.common.xcontent.XContentBuilder;
import org.elasticsearch.common.xcontent.XContentFactory;
import org.elasticsearch.common.xcontent.XContentType;
import org.elasticsearch.index.mapper.ContentPath;
import org.elasticsearch.index.mapper.DocumentMapper;
import org.elasticsearch.index.mapper.Mapper;
import org.elasticsearch.index.mapper.MapperParsingException;
import org.elasticsearch.index.mapper.MapperTestCase;
import org.elasticsearch.index.mapper.ParsedDocument;
import org.elasticsearch.index.mapper.SourceToParse;
import org.elasticsearch.plugins.Plugin;
import java.io.IOException;
import java.util.Collection;
import java.util.Collections;
import static org.hamcrest.Matchers.equalTo;
public class VersionStringFieldMapperTests extends MapperTestCase {
@Override
protected Collection<? extends Plugin> getPlugins() {
return Collections.singletonList(new VersionFieldPlugin(getIndexSettings()));
}
@Override
protected void minimalMapping(XContentBuilder b) throws IOException {
b.field("type", "version");
}
public void testDefaults() throws Exception {
XContentBuilder mapping = fieldMapping(this::minimalMapping);
DocumentMapper mapper = createDocumentMapper(mapping);
assertEquals(Strings.toString(mapping), mapper.mappingSource().toString());
ParsedDocument doc = mapper.parse(
new SourceToParse(
"test",
"_doc",
"1",
BytesReference.bytes(XContentFactory.jsonBuilder().startObject().field("field", "1.2.3").endObject()),
XContentType.JSON
)
);
IndexableField[] fields = doc.rootDoc().getFields("field");
assertEquals(2, fields.length);
assertEquals("1.2.3", VersionEncoder.decodeVersion(fields[0].binaryValue()));
IndexableFieldType fieldType = fields[0].fieldType();
assertThat(fieldType.omitNorms(), equalTo(true));
assertFalse(fieldType.tokenized());
assertFalse(fieldType.stored());
assertThat(fieldType.indexOptions(), equalTo(IndexOptions.DOCS));
assertThat(fieldType.storeTermVectors(), equalTo(false));
assertThat(fieldType.storeTermVectorOffsets(), equalTo(false));
assertThat(fieldType.storeTermVectorPositions(), equalTo(false));
assertThat(fieldType.storeTermVectorPayloads(), equalTo(false));
assertEquals(DocValuesType.NONE, fieldType.docValuesType());
assertEquals("1.2.3", VersionEncoder.decodeVersion(fields[1].binaryValue()));
fieldType = fields[1].fieldType();
assertThat(fieldType.indexOptions(), equalTo(IndexOptions.NONE));
assertEquals(DocValuesType.SORTED_SET, fieldType.docValuesType());
}
public void testParsesNestedEmptyObjectStrict() throws IOException {
DocumentMapper defaultMapper = createDocumentMapper(fieldMapping(this::minimalMapping));
BytesReference source = BytesReference.bytes(
XContentFactory.jsonBuilder().startObject().startObject("field").endObject().endObject()
);
MapperParsingException ex = expectThrows(
MapperParsingException.class,
() -> defaultMapper.parse(new SourceToParse("test", "_doc", "1", source, XContentType.JSON))
);
assertEquals(
"failed to parse field [field] of type [version] in document with id '1'. " + "Preview of field's value: '{}'",
ex.getMessage()
);
}
public void testFailsParsingNestedList() throws IOException {
DocumentMapper defaultMapper = createDocumentMapper(fieldMapping(this::minimalMapping));
BytesReference source = BytesReference.bytes(
XContentFactory.jsonBuilder()
.startObject()
.startArray("field")
.startObject()
.startArray("array_name")
.value("inner_field_first")
.value("inner_field_second")
.endArray()
.endObject()
.endArray()
.endObject()
);
MapperParsingException ex = expectThrows(
MapperParsingException.class,
() -> defaultMapper.parse(new SourceToParse("test", "_doc", "1", source, XContentType.JSON))
);
assertEquals(
"failed to parse field [field] of type [version] in document with id '1'. "
+ "Preview of field's value: '{array_name=[inner_field_first, inner_field_second]}'",
ex.getMessage()
);
}
public void testFetchSourceValue() throws IOException {
Settings settings = Settings.builder().put(IndexMetadata.SETTING_VERSION_CREATED, Version.CURRENT.id).build();
Mapper.BuilderContext context = new Mapper.BuilderContext(settings, new ContentPath());
VersionStringFieldMapper mapper = new VersionStringFieldMapper.Builder("field").build(context);
assertEquals(org.elasticsearch.common.collect.List.of("value"), fetchSourceValue(mapper, "value"));
assertEquals(org.elasticsearch.common.collect.List.of("42"), fetchSourceValue(mapper, 42L));
assertEquals(org.elasticsearch.common.collect.List.of("true"), fetchSourceValue(mapper, true));
IllegalArgumentException e = expectThrows(IllegalArgumentException.class, () -> fetchSourceValue(mapper, "value", "format"));
assertEquals("Field [field] of type [version] doesn't support formats.", e.getMessage());
}
}

View File

@ -0,0 +1,533 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
package org.elasticsearch.xpack.versionfield;
import org.elasticsearch.action.search.SearchResponse;
import org.elasticsearch.common.settings.Settings;
import org.elasticsearch.index.query.QueryBuilders;
import org.elasticsearch.plugins.Plugin;
import org.elasticsearch.search.SearchHit;
import org.elasticsearch.search.aggregations.AggregationBuilders;
import org.elasticsearch.search.aggregations.bucket.terms.Terms;
import org.elasticsearch.search.aggregations.bucket.terms.Terms.Bucket;
import org.elasticsearch.search.aggregations.metrics.Cardinality;
import org.elasticsearch.search.sort.SortOrder;
import org.elasticsearch.test.ESSingleNodeTestCase;
import org.elasticsearch.xpack.analytics.AnalyticsAggregationBuilders;
import org.elasticsearch.xpack.analytics.AnalyticsPlugin;
import org.elasticsearch.xpack.analytics.stringstats.InternalStringStats;
import org.elasticsearch.xpack.core.LocalStateCompositeXPackPlugin;
import java.io.IOException;
import java.util.Collection;
import java.util.List;
import static org.elasticsearch.common.xcontent.XContentFactory.jsonBuilder;
public class VersionStringFieldTests extends ESSingleNodeTestCase {
@Override
protected Collection<Class<? extends Plugin>> getPlugins() {
return pluginList(VersionFieldPlugin.class, LocalStateCompositeXPackPlugin.class, AnalyticsPlugin.class);
}
public String setUpIndex(String indexName) throws IOException {
createIndex(indexName, Settings.builder().put("index.number_of_shards", 1).build(), "_doc", "version", "type=version");
ensureGreen(indexName);
client().prepareIndex(indexName, "_doc")
.setId("1")
.setSource(jsonBuilder().startObject().field("version", "11.1.0").endObject())
.get();
client().prepareIndex(indexName, "_doc")
.setId("2")
.setSource(jsonBuilder().startObject().field("version", "1.0.0").endObject())
.get();
client().prepareIndex(indexName, "_doc")
.setId("3")
.setSource(jsonBuilder().startObject().field("version", "1.3.0+build.1234567").endObject())
.get();
client().prepareIndex(indexName, "_doc")
.setId("4")
.setSource(jsonBuilder().startObject().field("version", "2.1.0-alpha.beta").endObject())
.get();
client().prepareIndex(indexName, "_doc")
.setId("5")
.setSource(jsonBuilder().startObject().field("version", "2.1.0").endObject())
.get();
client().prepareIndex(indexName, "_doc")
.setId("6")
.setSource(jsonBuilder().startObject().field("version", "21.11.0").endObject())
.get();
client().admin().indices().prepareRefresh(indexName).get();
return indexName;
}
public void testExactQueries() throws Exception {
String indexName = "test";
setUpIndex(indexName);
// match
SearchResponse response = client().prepareSearch(indexName).setQuery(QueryBuilders.matchQuery("version", ("1.0.0"))).get();
assertEquals(1, response.getHits().getTotalHits().value);
response = client().prepareSearch(indexName).setQuery(QueryBuilders.matchQuery("version", "1.4.0")).get();
assertEquals(0, response.getHits().getTotalHits().value);
response = client().prepareSearch(indexName).setQuery(QueryBuilders.matchQuery("version", "1.3.0")).get();
assertEquals(0, response.getHits().getTotalHits().value);
response = client().prepareSearch(indexName).setQuery(QueryBuilders.matchQuery("version", "1.3.0+build.1234567")).get();
assertEquals(1, response.getHits().getTotalHits().value);
// term
response = client().prepareSearch(indexName).setQuery(QueryBuilders.termQuery("version", "1.0.0")).get();
assertEquals(1, response.getHits().getTotalHits().value);
response = client().prepareSearch(indexName).setQuery(QueryBuilders.termQuery("version", "1.4.0")).get();
assertEquals(0, response.getHits().getTotalHits().value);
response = client().prepareSearch(indexName).setQuery(QueryBuilders.termQuery("version", "1.3.0")).get();
assertEquals(0, response.getHits().getTotalHits().value);
response = client().prepareSearch(indexName).setQuery(QueryBuilders.termQuery("version", "1.3.0+build.1234567")).get();
assertEquals(1, response.getHits().getTotalHits().value);
// terms
response = client().prepareSearch(indexName).setQuery(QueryBuilders.termsQuery("version", "1.0.0", "1.3.0")).get();
assertEquals(1, response.getHits().getTotalHits().value);
response = client().prepareSearch(indexName).setQuery(QueryBuilders.termsQuery("version", "1.4.0", "1.3.0+build.1234567")).get();
assertEquals(1, response.getHits().getTotalHits().value);
// phrase query (just for keyword compatibility)
response = client().prepareSearch(indexName).setQuery(QueryBuilders.matchPhraseQuery("version", "2.1.0-alpha.beta")).get();
assertEquals(1, response.getHits().getTotalHits().value);
}
public void testRangeQueries() throws Exception {
String indexName = setUpIndex("test");
SearchResponse response = client().prepareSearch(indexName)
.setQuery(QueryBuilders.rangeQuery("version").from("1.0.0").to("3.0.0"))
.get();
assertEquals(4, response.getHits().getTotalHits().value);
response = client().prepareSearch(indexName).setQuery(QueryBuilders.rangeQuery("version").from("1.1.0").to("3.0.0")).get();
assertEquals(3, response.getHits().getTotalHits().value);
response = client().prepareSearch(indexName)
.setQuery(QueryBuilders.rangeQuery("version").from("0.1.0").to("2.1.0-alpha.beta"))
.get();
assertEquals(3, response.getHits().getTotalHits().value);
response = client().prepareSearch(indexName).setQuery(QueryBuilders.rangeQuery("version").from("2.1.0").to("3.0.0")).get();
assertEquals(1, response.getHits().getTotalHits().value);
response = client().prepareSearch(indexName).setQuery(QueryBuilders.rangeQuery("version").from("3.0.0").to("4.0.0")).get();
assertEquals(0, response.getHits().getTotalHits().value);
response = client().prepareSearch(indexName)
.setQuery(QueryBuilders.rangeQuery("version").from("1.3.0+build.1234569").to("3.0.0"))
.get();
assertEquals(2, response.getHits().getTotalHits().value);
// ranges excluding edges
response = client().prepareSearch(indexName).setQuery(QueryBuilders.rangeQuery("version").from("1.0.0", false).to("3.0.0")).get();
assertEquals(3, response.getHits().getTotalHits().value);
response = client().prepareSearch(indexName).setQuery(QueryBuilders.rangeQuery("version").from("1.0.0").to("2.1.0", false)).get();
assertEquals(3, response.getHits().getTotalHits().value);
// open ranges
response = client().prepareSearch(indexName).setQuery(QueryBuilders.rangeQuery("version").from("1.4.0")).get();
assertEquals(4, response.getHits().getTotalHits().value);
response = client().prepareSearch(indexName).setQuery(QueryBuilders.rangeQuery("version").to("1.4.0")).get();
assertEquals(2, response.getHits().getTotalHits().value);
}
public void testPrefixQuery() throws IOException {
String indexName = setUpIndex("test");
// prefix
SearchResponse response = client().prepareSearch(indexName).setQuery(QueryBuilders.prefixQuery("version", "1")).get();
assertEquals(3, response.getHits().getTotalHits().value);
response = client().prepareSearch(indexName).setQuery(QueryBuilders.prefixQuery("version", "2.1")).get();
assertEquals(2, response.getHits().getTotalHits().value);
response = client().prepareSearch(indexName).setQuery(QueryBuilders.prefixQuery("version", "2.1.0-")).get();
assertEquals(1, response.getHits().getTotalHits().value);
response = client().prepareSearch(indexName).setQuery(QueryBuilders.prefixQuery("version", "1.3.0+b")).get();
assertEquals(1, response.getHits().getTotalHits().value);
response = client().prepareSearch(indexName).setQuery(QueryBuilders.prefixQuery("version", "2")).get();
assertEquals(3, response.getHits().getTotalHits().value);
response = client().prepareSearch(indexName).setQuery(QueryBuilders.prefixQuery("version", "21")).get();
assertEquals(1, response.getHits().getTotalHits().value);
response = client().prepareSearch(indexName).setQuery(QueryBuilders.prefixQuery("version", "21.")).get();
assertEquals(1, response.getHits().getTotalHits().value);
response = client().prepareSearch(indexName).setQuery(QueryBuilders.prefixQuery("version", "21.1")).get();
assertEquals(1, response.getHits().getTotalHits().value);
response = client().prepareSearch(indexName).setQuery(QueryBuilders.prefixQuery("version", "21.11")).get();
assertEquals(1, response.getHits().getTotalHits().value);
}
public void testSort() throws IOException {
String indexName = setUpIndex("test");
// also adding some invalid versions that should be sorted after legal ones
client().prepareIndex(indexName, "_doc").setSource(jsonBuilder().startObject().field("version", "1.2.3alpha").endObject()).get();
client().prepareIndex(indexName, "_doc").setSource(jsonBuilder().startObject().field("version", "1.3.567#12").endObject()).get();
client().admin().indices().prepareRefresh(indexName).get();
// sort based on version field
SearchResponse response = client().prepareSearch(indexName)
.setQuery(QueryBuilders.matchAllQuery())
.addSort("version", SortOrder.DESC)
.get();
assertEquals(8, response.getHits().getTotalHits().value);
SearchHit[] hits = response.getHits().getHits();
assertEquals("1.3.567#12", hits[0].getSortValues()[0]);
assertEquals("1.2.3alpha", hits[1].getSortValues()[0]);
assertEquals("21.11.0", hits[2].getSortValues()[0]);
assertEquals("11.1.0", hits[3].getSortValues()[0]);
assertEquals("2.1.0", hits[4].getSortValues()[0]);
assertEquals("2.1.0-alpha.beta", hits[5].getSortValues()[0]);
assertEquals("1.3.0+build.1234567", hits[6].getSortValues()[0]);
assertEquals("1.0.0", hits[7].getSortValues()[0]);
response = client().prepareSearch(indexName).setQuery(QueryBuilders.matchAllQuery()).addSort("version", SortOrder.ASC).get();
assertEquals(8, response.getHits().getTotalHits().value);
hits = response.getHits().getHits();
assertEquals("1.0.0", hits[0].getSortValues()[0]);
assertEquals("1.3.0+build.1234567", hits[1].getSortValues()[0]);
assertEquals("2.1.0-alpha.beta", hits[2].getSortValues()[0]);
assertEquals("2.1.0", hits[3].getSortValues()[0]);
assertEquals("11.1.0", hits[4].getSortValues()[0]);
assertEquals("21.11.0", hits[5].getSortValues()[0]);
assertEquals("1.2.3alpha", hits[6].getSortValues()[0]);
assertEquals("1.3.567#12", hits[7].getSortValues()[0]);
}
public void testRegexQuery() throws Exception {
String indexName = "test_regex";
createIndex(indexName, Settings.builder().put("index.number_of_shards", 1).build(), "_doc", "version", "type=version");
ensureGreen(indexName);
client().prepareIndex(indexName, "_doc")
.setId("1")
.setSource(jsonBuilder().startObject().field("version", "1.0.0alpha2.1.0-rc.1").endObject())
.get();
client().prepareIndex(indexName, "_doc")
.setId("2")
.setSource(jsonBuilder().startObject().field("version", "1.3.0+build.1234567").endObject())
.get();
client().prepareIndex(indexName, "_doc")
.setId("3")
.setSource(jsonBuilder().startObject().field("version", "2.1.0-alpha.beta").endObject())
.get();
client().prepareIndex(indexName, "_doc")
.setId("4")
.setSource(jsonBuilder().startObject().field("version", "2.1.0").endObject())
.get();
client().prepareIndex(indexName, "_doc")
.setId("5")
.setSource(jsonBuilder().startObject().field("version", "2.33.0").endObject())
.get();
client().admin().indices().prepareRefresh(indexName).get();
SearchResponse response = client().prepareSearch(indexName).setQuery(QueryBuilders.regexpQuery("version", "2.*0")).get();
assertEquals(2, response.getHits().getTotalHits().value);
assertEquals("2.1.0", response.getHits().getHits()[0].getSourceAsMap().get("version"));
assertEquals("2.33.0", response.getHits().getHits()[1].getSourceAsMap().get("version"));
response = client().prepareSearch(indexName).setQuery(QueryBuilders.regexpQuery("version", "<0-10>.<0-10>.*al.*")).get();
assertEquals(2, response.getHits().getTotalHits().value);
assertEquals("1.0.0alpha2.1.0-rc.1", response.getHits().getHits()[0].getSourceAsMap().get("version"));
assertEquals("2.1.0-alpha.beta", response.getHits().getHits()[1].getSourceAsMap().get("version"));
response = client().prepareSearch(indexName).setQuery(QueryBuilders.regexpQuery("version", "1.[0-9].[0-9].*")).get();
assertEquals(2, response.getHits().getTotalHits().value);
assertEquals("1.0.0alpha2.1.0-rc.1", response.getHits().getHits()[0].getSourceAsMap().get("version"));
assertEquals("1.3.0+build.1234567", response.getHits().getHits()[1].getSourceAsMap().get("version"));
// test case sensitivity / insensitivity
response = client().prepareSearch(indexName).setQuery(QueryBuilders.regexpQuery("version", ".*alpha.*")).get();
assertEquals(2, response.getHits().getTotalHits().value);
assertEquals("1.0.0alpha2.1.0-rc.1", response.getHits().getHits()[0].getSourceAsMap().get("version"));
assertEquals("2.1.0-alpha.beta", response.getHits().getHits()[1].getSourceAsMap().get("version"));
response = client().prepareSearch(indexName).setQuery(QueryBuilders.regexpQuery("version", ".*Alpha.*")).get();
assertEquals(0, response.getHits().getTotalHits().value);
response = client().prepareSearch(indexName)
.setQuery(QueryBuilders.regexpQuery("version", ".*Alpha.*").caseInsensitive(true))
.get();
assertEquals(2, response.getHits().getTotalHits().value);
assertEquals("1.0.0alpha2.1.0-rc.1", response.getHits().getHits()[0].getSourceAsMap().get("version"));
assertEquals("2.1.0-alpha.beta", response.getHits().getHits()[1].getSourceAsMap().get("version"));
}
public void testFuzzyQuery() throws Exception {
String indexName = "test_fuzzy";
createIndex(indexName, Settings.builder().put("index.number_of_shards", 1).build(), "_doc", "version", "type=version");
ensureGreen(indexName);
client().prepareIndex(indexName, "_doc")
.setId("1")
.setSource(jsonBuilder().startObject().field("version", "1.0.0-alpha.2.1.0-rc.1").endObject())
.get();
client().prepareIndex(indexName, "_doc")
.setId("2")
.setSource(jsonBuilder().startObject().field("version", "1.3.0+build.1234567").endObject())
.get();
client().prepareIndex(indexName, "_doc")
.setId("3")
.setSource(jsonBuilder().startObject().field("version", "2.1.0-alpha.beta").endObject())
.get();
client().prepareIndex(indexName, "_doc")
.setId("4")
.setSource(jsonBuilder().startObject().field("version", "2.1.0").endObject())
.get();
client().prepareIndex(indexName, "_doc")
.setId("5")
.setSource(jsonBuilder().startObject().field("version", "2.33.0").endObject())
.get();
client().prepareIndex(indexName, "_doc")
.setId("6")
.setSource(jsonBuilder().startObject().field("version", "2.a3.0").endObject())
.get();
client().admin().indices().prepareRefresh(indexName).get();
SearchResponse response = client().prepareSearch(indexName).setQuery(QueryBuilders.fuzzyQuery("version", "2.3.0")).get();
assertEquals(3, response.getHits().getTotalHits().value);
assertEquals("2.1.0", response.getHits().getHits()[0].getSourceAsMap().get("version"));
assertEquals("2.33.0", response.getHits().getHits()[1].getSourceAsMap().get("version"));
assertEquals("2.a3.0", response.getHits().getHits()[2].getSourceAsMap().get("version"));
}
public void testWildcardQuery() throws Exception {
String indexName = "test_wildcard";
createIndex(
indexName,
Settings.builder().put("index.number_of_shards", 1).build(),
"_doc",
"version",
"type=version",
"foo",
"type=keyword"
);
ensureGreen(indexName);
for (String version : org.elasticsearch.common.collect.List.of(
"1.0.0-alpha.2.1.0-rc.1",
"1.3.0+build.1234567",
"2.1.0-alpha.beta",
"2.1.0",
"2.33.0",
"3.1.1-a",
"3.1.1+b",
"3.1.123"
)) {
client().prepareIndex(indexName, "_doc").setSource(jsonBuilder().startObject().field("version", version).endObject()).get();
}
client().admin().indices().prepareRefresh(indexName).get();
checkWildcardQuery(indexName, "*alpha*", new String[] { "1.0.0-alpha.2.1.0-rc.1", "2.1.0-alpha.beta" });
checkWildcardQuery(indexName, "*b*", new String[] { "1.3.0+build.1234567", "2.1.0-alpha.beta", "3.1.1+b" });
checkWildcardQuery(indexName, "*bet*", new String[] { "2.1.0-alpha.beta" });
checkWildcardQuery(indexName, "2.1*", new String[] { "2.1.0-alpha.beta", "2.1.0" });
checkWildcardQuery(indexName, "2.1.0-*", new String[] { "2.1.0-alpha.beta" });
checkWildcardQuery(indexName, "*2.1.0-*", new String[] { "1.0.0-alpha.2.1.0-rc.1", "2.1.0-alpha.beta" });
checkWildcardQuery(indexName, "*2.1.0*", new String[] { "1.0.0-alpha.2.1.0-rc.1", "2.1.0-alpha.beta", "2.1.0" });
checkWildcardQuery(indexName, "*2.?.0*", new String[] { "1.0.0-alpha.2.1.0-rc.1", "2.1.0-alpha.beta", "2.1.0" });
checkWildcardQuery(indexName, "*2.??.0*", new String[] { "2.33.0" });
checkWildcardQuery(indexName, "?.1.0", new String[] { "2.1.0" });
checkWildcardQuery(indexName, "*-*", new String[] { "1.0.0-alpha.2.1.0-rc.1", "2.1.0-alpha.beta", "3.1.1-a" });
checkWildcardQuery(indexName, "1.3.0+b*", new String[] { "1.3.0+build.1234567" });
checkWildcardQuery(indexName, "3.1.1??", new String[] { "3.1.1-a", "3.1.1+b", "3.1.123" });
}
private void checkWildcardQuery(String indexName, String query, String... expectedResults) {
SearchResponse response = client().prepareSearch(indexName).setQuery(QueryBuilders.wildcardQuery("version", query)).get();
assertEquals(expectedResults.length, response.getHits().getTotalHits().value);
for (int i = 0; i < expectedResults.length; i++) {
String expected = expectedResults[i];
Object actual = response.getHits().getHits()[i].getSourceAsMap().get("version");
assertEquals("expected " + expected + " in position " + i + " but found " + actual, expected, actual);
}
}
/**
* test that versions that are invalid under semver are still indexed and retrieveable, though they sort differently
*/
public void testStoreMalformed() throws Exception {
String indexName = "test_malformed";
createIndex(indexName, Settings.builder().put("index.number_of_shards", 1).build(), "_doc", "version", "type=version");
ensureGreen(indexName);
client().prepareIndex(indexName, "_doc")
.setId("1")
.setSource(jsonBuilder().startObject().field("version", "1.invalid.0").endObject())
.get();
client().prepareIndex(indexName, "_doc")
.setId("2")
.setSource(jsonBuilder().startObject().field("version", "2.2.0").endObject())
.get();
client().prepareIndex(indexName, "_doc")
.setId("3")
.setSource(jsonBuilder().startObject().field("version", "2.2.0-badchar!").endObject())
.get();
client().prepareIndex(indexName, "_doc").setId("4").setSource(jsonBuilder().startObject().field("version", "").endObject()).get();
client().admin().indices().prepareRefresh(indexName).get();
SearchResponse response = client().prepareSearch(indexName).addDocValueField("version").get();
assertEquals(4, response.getHits().getTotalHits().value);
assertEquals("1", response.getHits().getAt(0).getId());
assertEquals("1.invalid.0", response.getHits().getAt(0).field("version").getValue());
assertEquals("2", response.getHits().getAt(1).getId());
assertEquals("2.2.0", response.getHits().getAt(1).field("version").getValue());
assertEquals("3", response.getHits().getAt(2).getId());
assertEquals("2.2.0-badchar!", response.getHits().getAt(2).field("version").getValue());
assertEquals("4", response.getHits().getAt(3).getId());
assertEquals("", response.getHits().getAt(3).field("version").getValue());
// exact match for malformed term
response = client().prepareSearch(indexName).setQuery(QueryBuilders.matchQuery("version", "1.invalid.0")).get();
assertEquals(1, response.getHits().getTotalHits().value);
response = client().prepareSearch(indexName).setQuery(QueryBuilders.matchQuery("version", "2.2.0-badchar!")).get();
assertEquals(1, response.getHits().getTotalHits().value);
response = client().prepareSearch(indexName).setQuery(QueryBuilders.matchQuery("version", "")).get();
assertEquals(1, response.getHits().getTotalHits().value);
// also should appear in terms aggs
response = client().prepareSearch(indexName).addAggregation(AggregationBuilders.terms("myterms").field("version")).get();
Terms terms = response.getAggregations().get("myterms");
List<? extends Bucket> buckets = terms.getBuckets();
assertEquals(4, buckets.size());
assertEquals("2.2.0", buckets.get(0).getKey());
assertEquals("", buckets.get(1).getKey());
assertEquals("1.invalid.0", buckets.get(2).getKey());
assertEquals("2.2.0-badchar!", buckets.get(3).getKey());
// invalid values should sort after all valid ones
response = client().prepareSearch(indexName).setQuery(QueryBuilders.matchAllQuery()).addSort("version", SortOrder.ASC).get();
assertEquals(4, response.getHits().getTotalHits().value);
SearchHit[] hits = response.getHits().getHits();
assertEquals("2.2.0", hits[0].getSortValues()[0]);
assertEquals("", hits[1].getSortValues()[0]);
assertEquals("1.invalid.0", hits[2].getSortValues()[0]);
assertEquals("2.2.0-badchar!", hits[3].getSortValues()[0]);
// ranges can include them, but they are sorted last
response = client().prepareSearch(indexName).setQuery(QueryBuilders.rangeQuery("version").to("3.0.0")).get();
assertEquals(1, response.getHits().getTotalHits().value);
response = client().prepareSearch(indexName).setQuery(QueryBuilders.rangeQuery("version").from("3.0.0")).get();
assertEquals(3, response.getHits().getTotalHits().value);
// using the empty string as lower bound should return all "invalid" versions
response = client().prepareSearch(indexName).setQuery(QueryBuilders.rangeQuery("version").from("")).get();
assertEquals(3, response.getHits().getTotalHits().value);
}
public void testAggs() throws Exception {
String indexName = "test_aggs";
createIndex(indexName, Settings.builder().put("index.number_of_shards", 1).build(), "_doc", "version", "type=version");
ensureGreen(indexName);
client().prepareIndex(indexName, "_doc")
.setId("1")
.setSource(jsonBuilder().startObject().field("version", "1.0").endObject())
.get();
client().prepareIndex(indexName, "_doc")
.setId("2")
.setSource(jsonBuilder().startObject().field("version", "1.3.0").endObject())
.get();
client().prepareIndex(indexName, "_doc")
.setId("3")
.setSource(jsonBuilder().startObject().field("version", "2.1.0-alpha").endObject())
.get();
client().prepareIndex(indexName, "_doc")
.setId("4")
.setSource(jsonBuilder().startObject().field("version", "2.1.0").endObject())
.get();
client().prepareIndex(indexName, "_doc")
.setId("5")
.setSource(jsonBuilder().startObject().field("version", "3.11.5").endObject())
.get();
client().admin().indices().prepareRefresh(indexName).get();
// terms aggs
SearchResponse response = client().prepareSearch(indexName)
.addAggregation(AggregationBuilders.terms("myterms").field("version"))
.get();
Terms terms = response.getAggregations().get("myterms");
List<? extends Bucket> buckets = terms.getBuckets();
assertEquals(5, buckets.size());
assertEquals("1.0", buckets.get(0).getKey());
assertEquals("1.3.0", buckets.get(1).getKey());
assertEquals("2.1.0-alpha", buckets.get(2).getKey());
assertEquals("2.1.0", buckets.get(3).getKey());
assertEquals("3.11.5", buckets.get(4).getKey());
// cardinality
response = client().prepareSearch(indexName).addAggregation(AggregationBuilders.cardinality("myterms").field("version")).get();
Cardinality card = response.getAggregations().get("myterms");
assertEquals(5, card.getValue());
// string stats
response = client().prepareSearch(indexName)
.addAggregation(AnalyticsAggregationBuilders.stringStats("stats").field("version"))
.get();
InternalStringStats stats = response.getAggregations().get("stats");
assertEquals(3, stats.getMinLength());
assertEquals(11, stats.getMaxLength());
}
public void testMultiValues() throws Exception {
String indexName = "test_multi";
createIndex(indexName, Settings.builder().put("index.number_of_shards", 1).build(), "_doc", "version", "type=version");
ensureGreen(indexName);
client().prepareIndex(indexName, "_doc")
.setId("1")
.setSource(jsonBuilder().startObject().array("version", "1.0.0", "3.0.0").endObject())
.get();
client().prepareIndex(indexName, "_doc")
.setId("2")
.setSource(jsonBuilder().startObject().array("version", "2.0.0", "4.alpha.0").endObject())
.get();
client().prepareIndex(indexName, "_doc")
.setId("3")
.setSource(jsonBuilder().startObject().array("version", "2.1.0", "2.2.0", "5.99.0").endObject())
.get();
client().admin().indices().prepareRefresh(indexName).get();
SearchResponse response = client().prepareSearch(indexName).addSort("version", SortOrder.ASC).get();
assertEquals(3, response.getHits().getTotalHits().value);
assertEquals("1", response.getHits().getAt(0).getId());
assertEquals("2", response.getHits().getAt(1).getId());
assertEquals("3", response.getHits().getAt(2).getId());
response = client().prepareSearch(indexName).setQuery(QueryBuilders.matchQuery("version", "3.0.0")).get();
assertEquals(1, response.getHits().getTotalHits().value);
assertEquals("1", response.getHits().getAt(0).getId());
response = client().prepareSearch(indexName).setQuery(QueryBuilders.matchQuery("version", "4.alpha.0")).get();
assertEquals(1, response.getHits().getTotalHits().value);
assertEquals("2", response.getHits().getAt(0).getId());
// range
response = client().prepareSearch(indexName).setQuery(QueryBuilders.rangeQuery("version").to("1.5.0")).get();
assertEquals(1, response.getHits().getTotalHits().value);
response = client().prepareSearch(indexName).setQuery(QueryBuilders.rangeQuery("version").from("1.5.0")).get();
assertEquals(3, response.getHits().getTotalHits().value);
response = client().prepareSearch(indexName).setQuery(QueryBuilders.rangeQuery("version").from("5.0.0").to("6.0.0")).get();
assertEquals(1, response.getHits().getTotalHits().value);
}
}