| 
 | 1 | +/*  | 
 | 2 | + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one  | 
 | 3 | + * or more contributor license agreements. Licensed under the "Elastic License  | 
 | 4 | + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side  | 
 | 5 | + * Public License v 1"; you may not use this file except in compliance with, at  | 
 | 6 | + * your election, the "Elastic License 2.0", the "GNU Affero General Public  | 
 | 7 | + * License v3.0 only", or the "Server Side Public License, v 1".  | 
 | 8 | + */  | 
 | 9 | + | 
 | 10 | +package org.elasticsearch.index.mapper.blockloader;  | 
 | 11 | + | 
 | 12 | +import org.apache.lucene.util.BytesRef;  | 
 | 13 | +import org.elasticsearch.common.geo.GeoPoint;  | 
 | 14 | +import org.elasticsearch.geometry.Point;  | 
 | 15 | +import org.elasticsearch.geometry.utils.WellKnownBinary;  | 
 | 16 | +import org.elasticsearch.index.mapper.BlockLoaderTestCase;  | 
 | 17 | +import org.elasticsearch.index.mapper.MappedFieldType;  | 
 | 18 | + | 
 | 19 | +import java.nio.ByteOrder;  | 
 | 20 | +import java.util.ArrayList;  | 
 | 21 | +import java.util.Comparator;  | 
 | 22 | +import java.util.List;  | 
 | 23 | +import java.util.Map;  | 
 | 24 | +import java.util.Objects;  | 
 | 25 | + | 
 | 26 | +public class GeoPointFieldBlockLoaderTests extends BlockLoaderTestCase {  | 
 | 27 | +    public GeoPointFieldBlockLoaderTests(BlockLoaderTestCase.Params params) {  | 
 | 28 | +        super("geo_point", params);  | 
 | 29 | +    }  | 
 | 30 | + | 
 | 31 | +    @Override  | 
 | 32 | +    @SuppressWarnings("unchecked")  | 
 | 33 | +    protected Object expected(Map<String, Object> fieldMapping, Object value, TestContext testContext) {  | 
 | 34 | +        var extractedFieldValues = (ExtractedFieldValues) value;  | 
 | 35 | +        var values = extractedFieldValues.values();  | 
 | 36 | + | 
 | 37 | +        var rawNullValue = fieldMapping.get("null_value");  | 
 | 38 | + | 
 | 39 | +        GeoPoint nullValue;  | 
 | 40 | +        if (rawNullValue == null) {  | 
 | 41 | +            nullValue = null;  | 
 | 42 | +        } else if (rawNullValue instanceof String s) {  | 
 | 43 | +            nullValue = convert(s, null);  | 
 | 44 | +        } else {  | 
 | 45 | +            throw new IllegalStateException("Unexpected null_value format");  | 
 | 46 | +        }  | 
 | 47 | + | 
 | 48 | +        if (params.preference() == MappedFieldType.FieldExtractPreference.DOC_VALUES && hasDocValues(fieldMapping, true)) {  | 
 | 49 | +            if (values instanceof List<?> == false) {  | 
 | 50 | +                var point = convert(values, nullValue);  | 
 | 51 | +                return point != null ? point.getEncoded() : null;  | 
 | 52 | +            }  | 
 | 53 | + | 
 | 54 | +            var resultList = ((List<Object>) values).stream()  | 
 | 55 | +                .map(v -> convert(v, nullValue))  | 
 | 56 | +                .filter(Objects::nonNull)  | 
 | 57 | +                .map(GeoPoint::getEncoded)  | 
 | 58 | +                .sorted()  | 
 | 59 | +                .toList();  | 
 | 60 | +            return maybeFoldList(resultList);  | 
 | 61 | +        }  | 
 | 62 | + | 
 | 63 | +        if (params.syntheticSource() == false) {  | 
 | 64 | +            return exactValuesFromSource(values, nullValue);  | 
 | 65 | +        }  | 
 | 66 | + | 
 | 67 | +        // Usually implementation of block loader from source adjusts values read from source  | 
 | 68 | +        // so that they look the same as doc_values would (like reducing precision).  | 
 | 69 | +        // geo_point does not do that and because of that we need to handle all these cases below.  | 
 | 70 | +        // If we are reading from stored source or fallback synthetic source we get the same exact data as source.  | 
 | 71 | +        // But if we are using "normal" synthetic source we get lesser precision data from doc_values.  | 
 | 72 | +        // That is unless "synthetic_source_keep" forces fallback synthetic source again.  | 
 | 73 | + | 
 | 74 | +        if (testContext.forceFallbackSyntheticSource()) {  | 
 | 75 | +            return exactValuesFromSource(values, nullValue);  | 
 | 76 | +        }  | 
 | 77 | + | 
 | 78 | +        String syntheticSourceKeep = (String) fieldMapping.getOrDefault("synthetic_source_keep", "none");  | 
 | 79 | +        if (syntheticSourceKeep.equals("all")) {  | 
 | 80 | +            return exactValuesFromSource(values, nullValue);  | 
 | 81 | +        }  | 
 | 82 | +        if (syntheticSourceKeep.equals("arrays") && extractedFieldValues.documentHasObjectArrays()) {  | 
 | 83 | +            return exactValuesFromSource(values, nullValue);  | 
 | 84 | +        }  | 
 | 85 | + | 
 | 86 | +        // synthetic source and doc_values are present  | 
 | 87 | +        if (hasDocValues(fieldMapping, true)) {  | 
 | 88 | +            if (values instanceof List<?> == false) {  | 
 | 89 | +                return toWKB(normalize(convert(values, nullValue)));  | 
 | 90 | +            }  | 
 | 91 | + | 
 | 92 | +            var resultList = ((List<Object>) values).stream()  | 
 | 93 | +                .map(v -> convert(v, nullValue))  | 
 | 94 | +                .filter(Objects::nonNull)  | 
 | 95 | +                .sorted(Comparator.comparingLong(GeoPoint::getEncoded))  | 
 | 96 | +                .map(p -> toWKB(normalize(p)))  | 
 | 97 | +                .toList();  | 
 | 98 | +            return maybeFoldList(resultList);  | 
 | 99 | +        }  | 
 | 100 | + | 
 | 101 | +        // synthetic source but no doc_values so using fallback synthetic source  | 
 | 102 | +        return exactValuesFromSource(values, nullValue);  | 
 | 103 | +    }  | 
 | 104 | + | 
 | 105 | +    @SuppressWarnings("unchecked")  | 
 | 106 | +    private Object exactValuesFromSource(Object value, GeoPoint nullValue) {  | 
 | 107 | +        if (value instanceof List<?> == false) {  | 
 | 108 | +            return toWKB(convert(value, nullValue));  | 
 | 109 | +        }  | 
 | 110 | + | 
 | 111 | +        var resultList = ((List<Object>) value).stream().map(v -> convert(v, nullValue)).filter(Objects::nonNull).map(this::toWKB).toList();  | 
 | 112 | +        return maybeFoldList(resultList);  | 
 | 113 | +    }  | 
 | 114 | + | 
 | 115 | +    private record ExtractedFieldValues(Object values, boolean documentHasObjectArrays) {}  | 
 | 116 | + | 
 | 117 | +    @Override  | 
 | 118 | +    protected Object getFieldValue(Map<String, Object> document, String fieldName) {  | 
 | 119 | +        var extracted = new ArrayList<>();  | 
 | 120 | +        var documentHasObjectArrays = processLevel(document, fieldName, extracted, false);  | 
 | 121 | + | 
 | 122 | +        if (extracted.size() == 1) {  | 
 | 123 | +            return new ExtractedFieldValues(extracted.get(0), documentHasObjectArrays);  | 
 | 124 | +        }  | 
 | 125 | + | 
 | 126 | +        return new ExtractedFieldValues(extracted, documentHasObjectArrays);  | 
 | 127 | +    }  | 
 | 128 | + | 
 | 129 | +    @SuppressWarnings("unchecked")  | 
 | 130 | +    private boolean processLevel(Map<String, Object> level, String field, ArrayList<Object> extracted, boolean documentHasObjectArrays) {  | 
 | 131 | +        if (field.contains(".") == false) {  | 
 | 132 | +            var value = level.get(field);  | 
 | 133 | +            processLeafLevel(value, extracted);  | 
 | 134 | +            return documentHasObjectArrays;  | 
 | 135 | +        }  | 
 | 136 | + | 
 | 137 | +        var nameInLevel = field.split("\\.")[0];  | 
 | 138 | +        var entry = level.get(nameInLevel);  | 
 | 139 | +        if (entry instanceof Map<?, ?> m) {  | 
 | 140 | +            return processLevel((Map<String, Object>) m, field.substring(field.indexOf('.') + 1), extracted, documentHasObjectArrays);  | 
 | 141 | +        }  | 
 | 142 | +        if (entry instanceof List<?> l) {  | 
 | 143 | +            for (var object : l) {  | 
 | 144 | +                processLevel((Map<String, Object>) object, field.substring(field.indexOf('.') + 1), extracted, true);  | 
 | 145 | +            }  | 
 | 146 | +            return true;  | 
 | 147 | +        }  | 
 | 148 | + | 
 | 149 | +        assert false : "unexpected document structure";  | 
 | 150 | +        return false;  | 
 | 151 | +    }  | 
 | 152 | + | 
 | 153 | +    private void processLeafLevel(Object value, ArrayList<Object> extracted) {  | 
 | 154 | +        if (value instanceof List<?> l) {  | 
 | 155 | +            if (l.size() > 0 && l.get(0) instanceof Double) {  | 
 | 156 | +                // this must be a single point in array form  | 
 | 157 | +                // we'll put it into a different form here to make our lives a bit easier while implementing `expected`  | 
 | 158 | +                extracted.add(Map.of("type", "point", "coordinates", l));  | 
 | 159 | +            } else {  | 
 | 160 | +                // this is actually an array of points but there could still be points in array form inside  | 
 | 161 | +                for (var arrayValue : l) {  | 
 | 162 | +                    processLeafLevel(arrayValue, extracted);  | 
 | 163 | +                }  | 
 | 164 | +            }  | 
 | 165 | +        } else {  | 
 | 166 | +            extracted.add(value);  | 
 | 167 | +        }  | 
 | 168 | +    }  | 
 | 169 | + | 
 | 170 | +    @SuppressWarnings("unchecked")  | 
 | 171 | +    private GeoPoint convert(Object value, GeoPoint nullValue) {  | 
 | 172 | +        if (value == null) {  | 
 | 173 | +            return nullValue;  | 
 | 174 | +        }  | 
 | 175 | + | 
 | 176 | +        if (value instanceof String s) {  | 
 | 177 | +            try {  | 
 | 178 | +                return new GeoPoint(s);  | 
 | 179 | +            } catch (Exception e) {  | 
 | 180 | +                return null;  | 
 | 181 | +            }  | 
 | 182 | +        }  | 
 | 183 | + | 
 | 184 | +        if (value instanceof Map<?, ?> m) {  | 
 | 185 | +            if (m.get("type") != null) {  | 
 | 186 | +                var coordinates = (List<Double>) m.get("coordinates");  | 
 | 187 | +                // Order is GeoJSON is lon,lat  | 
 | 188 | +                return new GeoPoint(coordinates.get(1), coordinates.get(0));  | 
 | 189 | +            } else {  | 
 | 190 | +                return new GeoPoint((Double) m.get("lat"), (Double) m.get("lon"));  | 
 | 191 | +            }  | 
 | 192 | +        }  | 
 | 193 | + | 
 | 194 | +        // Malformed values are excluded  | 
 | 195 | +        return null;  | 
 | 196 | +    }  | 
 | 197 | + | 
 | 198 | +    private GeoPoint normalize(GeoPoint point) {  | 
 | 199 | +        if (point == null) {  | 
 | 200 | +            return null;  | 
 | 201 | +        }  | 
 | 202 | +        return point.resetFromEncoded(point.getEncoded());  | 
 | 203 | +    }  | 
 | 204 | + | 
 | 205 | +    private BytesRef toWKB(GeoPoint point) {  | 
 | 206 | +        if (point == null) {  | 
 | 207 | +            return null;  | 
 | 208 | +        }  | 
 | 209 | + | 
 | 210 | +        return new BytesRef(WellKnownBinary.toWKB(new Point(point.getX(), point.getY()), ByteOrder.LITTLE_ENDIAN));  | 
 | 211 | +    }  | 
 | 212 | +}  | 
0 commit comments