Skip to content
1 change: 1 addition & 0 deletions test/framework/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ dependencies {
api "org.elasticsearch:mocksocket:${versions.mocksocket}"

testImplementation project(':x-pack:plugin:mapper-unsigned-long')
testImplementation project(':x-pack:plugin:mapper-counted-keyword')
testImplementation project(":modules:mapper-extras")
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

import org.elasticsearch.logsdb.datageneration.datasource.DataSource;
import org.elasticsearch.logsdb.datageneration.fields.leaf.ByteFieldDataGenerator;
import org.elasticsearch.logsdb.datageneration.fields.leaf.CountedKeywordFieldDataGenerator;
import org.elasticsearch.logsdb.datageneration.fields.leaf.DoubleFieldDataGenerator;
import org.elasticsearch.logsdb.datageneration.fields.leaf.FloatFieldDataGenerator;
import org.elasticsearch.logsdb.datageneration.fields.leaf.HalfFloatFieldDataGenerator;
Expand All @@ -34,7 +35,8 @@ public enum FieldType {
DOUBLE("double"),
FLOAT("float"),
HALF_FLOAT("half_float"),
SCALED_FLOAT("scaled_float");
SCALED_FLOAT("scaled_float"),
COUNTED_KEYWORD("counted_keyword");

private final String name;

Expand All @@ -54,6 +56,7 @@ public FieldDataGenerator generator(String fieldName, DataSource dataSource) {
case FLOAT -> new FloatFieldDataGenerator(fieldName, dataSource);
case HALF_FLOAT -> new HalfFloatFieldDataGenerator(fieldName, dataSource);
case SCALED_FLOAT -> new ScaledFloatFieldDataGenerator(fieldName, dataSource);
case COUNTED_KEYWORD -> new CountedKeywordFieldDataGenerator(fieldName, dataSource);
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ public DataSourceResponse.LeafMappingParametersGenerator handle(DataSourceReques
case KEYWORD -> keywordMapping(request, map);
case LONG, INTEGER, SHORT, BYTE, DOUBLE, FLOAT, HALF_FLOAT, UNSIGNED_LONG -> plain(map);
case SCALED_FLOAT -> scaledFloatMapping(map);
case COUNTED_KEYWORD -> plain(Map.of("index", ESTestCase.randomBoolean()));
});
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/

package org.elasticsearch.logsdb.datageneration.fields.leaf;

import org.elasticsearch.logsdb.datageneration.FieldDataGenerator;
import org.elasticsearch.logsdb.datageneration.datasource.DataSource;
import org.elasticsearch.logsdb.datageneration.datasource.DataSourceRequest;
import org.elasticsearch.test.ESTestCase;

import java.util.HashSet;
import java.util.Set;
import java.util.function.Supplier;

public class CountedKeywordFieldDataGenerator implements FieldDataGenerator {
private final Supplier<Object> valueGenerator;
private final Set<String> previousStrings = new HashSet<>();

public CountedKeywordFieldDataGenerator(String fieldName, DataSource dataSource) {
var strings = dataSource.get(new DataSourceRequest.StringGenerator());
var nulls = dataSource.get(new DataSourceRequest.NullWrapper());
var arrays = dataSource.get(new DataSourceRequest.ArrayWrapper());

this.valueGenerator = arrays.wrapper().compose(nulls.wrapper()).apply(() -> {
if (previousStrings.size() > 0 && ESTestCase.randomBoolean()) {
return ESTestCase.randomFrom(previousStrings);
Copy link
Contributor

@lkts lkts Jan 31, 2025

Choose a reason for hiding this comment

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

We should not directly use ESTestCase here, it should be hidden inside DataSource. You can probably define a generic "repeating" wrapper but i haven't looked too closely.

} else {
String value = strings.generator().get();
previousStrings.add(value);
return value;
}
});
}

@Override
public Object generateValue() {
return valueGenerator.get();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import org.elasticsearch.xcontent.XContentBuilder;

import java.math.BigInteger;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
Expand Down Expand Up @@ -223,4 +224,68 @@ private static BigInteger toBigInteger(Object value) {
return (BigInteger) value;
}
}

class CountedKeywordMatcher implements FieldSpecificMatcher {
private final XContentBuilder actualMappings;
private final Settings.Builder actualSettings;
private final XContentBuilder expectedMappings;
private final Settings.Builder expectedSettings;

CountedKeywordMatcher(
XContentBuilder actualMappings,
Settings.Builder actualSettings,
XContentBuilder expectedMappings,
Settings.Builder expectedSettings
) {
this.actualMappings = actualMappings;
this.actualSettings = actualSettings;
this.expectedMappings = expectedMappings;
this.expectedSettings = expectedSettings;
}

private static List<String> normalize(List<Object> values) {
return values.stream().filter(Objects::nonNull).map(it -> (String) it).toList();
}

private static boolean matchCountsEqualExact(List<String> actualNormalized, List<String> expectedNormalized) {
HashMap<String, Integer> counts = new HashMap<>();
for (String value : actualNormalized) {
counts.put(value, counts.getOrDefault(value, 0) + 1);
}
for (String value : expectedNormalized) {
int newCount = counts.getOrDefault(value, 0) - 1;
if (newCount == 0) {
counts.remove(value);
} else {
counts.put(value, newCount);
}
}

return counts.isEmpty();
Copy link
Contributor

Choose a reason for hiding this comment

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

Nice

}

@Override
public MatchResult match(
List<Object> actual,
List<Object> expected,
Map<String, Object> actualMapping,
Map<String, Object> expectedMapping
) {
var actualNormalized = normalize(actual);
var expectedNormalized = normalize(expected);

return matchCountsEqualExact(actualNormalized, expectedNormalized)
? MatchResult.match()
: MatchResult.noMatch(
formatErrorMessage(
actualMappings,
actualSettings,
expectedMappings,
expectedSettings,
"Values of type [counted_keyword] don't match after normalization, normalized"
+ prettyPrintCollections(actualNormalized, expectedNormalized)
)
);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,9 @@ public SourceMatcher(
"scaled_float",
new FieldSpecificMatcher.ScaledFloatMatcher(actualMappings, actualSettings, expectedMappings, expectedSettings),
"unsigned_long",
new FieldSpecificMatcher.UnsignedLongMatcher(actualMappings, actualSettings, expectedMappings, expectedSettings)
new FieldSpecificMatcher.UnsignedLongMatcher(actualMappings, actualSettings, expectedMappings, expectedSettings),
"counted_keyword",
new FieldSpecificMatcher.CountedKeywordMatcher(actualMappings, actualSettings, expectedMappings, expectedSettings)
);
this.dynamicFieldMatcher = new DynamicFieldMatcher(actualMappings, actualSettings, expectedMappings, expectedSettings);
}
Expand Down Expand Up @@ -100,17 +102,8 @@ private MatchResult compareSource(Map<String, List<Object>> actual, Map<String,
var actualValues = actual.get(name);
var expectedValues = expectedFieldEntry.getValue();

// There are cases when field values are stored in ignored source
// so we try to match them as is first and then apply field specific matcher.
// This is temporary, we should be able to tell when source is exact using mappings.
// See #111916.
var genericMatchResult = matchWithGenericMatcher(actualValues, expectedValues);
if (genericMatchResult.isMatch()) {
continue;
}

var matchIncludingFieldSpecificMatchers = matchWithFieldSpecificMatcher(name, actualValues, expectedValues).orElse(
genericMatchResult
var matchIncludingFieldSpecificMatchers = matchWithFieldSpecificMatcher(name, actualValues, expectedValues).orElseGet(
() -> matchWithGenericMatcher(actualValues, expectedValues)
);
if (matchIncludingFieldSpecificMatchers.isMatch() == false) {
var message = "Source documents don't match for field [" + name + "]: " + matchIncludingFieldSpecificMatchers.getMessage();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import org.elasticsearch.test.ESTestCase;
import org.elasticsearch.xcontent.XContentBuilder;
import org.elasticsearch.xcontent.XContentType;
import org.elasticsearch.xpack.countedkeyword.CountedKeywordMapperPlugin;
import org.elasticsearch.xpack.unsignedlong.UnsignedLongMapperPlugin;

import java.io.IOException;
Expand Down Expand Up @@ -110,7 +111,7 @@ public DataSourceResponse.FieldTypeGenerator handle(DataSourceRequest.FieldTypeG
var mappingService = new MapperServiceTestCase() {
@Override
protected Collection<? extends Plugin> getPlugins() {
return List.of(new UnsignedLongMapperPlugin(), new MapperExtrasPlugin());
return List.of(new UnsignedLongMapperPlugin(), new MapperExtrasPlugin(), new CountedKeywordMapperPlugin());
}
}.createMapperService(mappingXContent);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,4 +105,38 @@ public void testMappedMismatch() throws IOException {
var sut = new SourceMatcher(mapping, Settings.builder(), mapping, Settings.builder(), actual, expected, false);
assertFalse(sut.match().isMatch());
}

public void testCountedKeywordMatch() throws IOException {
List<Map<String, Object>> actual = List.of(Map.of("field", List.of("a", "b", "a", "c", "b", "a")));
List<Map<String, Object>> expected = List.of(Map.of("field", List.of("a", "b", "a", "c", "b", "a")));

var mapping = XContentBuilder.builder(XContentType.JSON.xContent());
mapping.startObject();
mapping.startObject("_doc");
{
mapping.startObject("field").field("type", "counted_keyword").endObject();
}
mapping.endObject();
mapping.endObject();

var sut = new SourceMatcher(mapping, Settings.builder(), mapping, Settings.builder(), actual, expected, false);
assertTrue(sut.match().isMatch());
}

public void testCountedKeywordMismatch() throws IOException {
List<Map<String, Object>> actual = List.of(Map.of("field", List.of("a", "b", "a", "c", "b", "a")));
List<Map<String, Object>> expected = List.of(Map.of("field", List.of("a", "b", "c", "a")));

var mapping = XContentBuilder.builder(XContentType.JSON.xContent());
mapping.startObject();
mapping.startObject("_doc");
{
mapping.startObject("field").field("type", "counted_keyword").endObject();
}
mapping.endObject();
mapping.endObject();

var sut = new SourceMatcher(mapping, Settings.builder(), mapping, Settings.builder(), actual, expected, false);
assertFalse(sut.match().isMatch());
}
}