001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.tools;
003
004import java.util.ArrayList;
005import java.util.Collections;
006import java.util.EnumSet;
007import java.util.List;
008import java.util.Locale;
009import java.util.Optional;
010import java.util.Set;
011import java.util.regex.Matcher;
012import java.util.regex.Pattern;
013import java.util.stream.Collectors;
014import java.util.stream.Stream;
015
016import org.openstreetmap.josm.data.osm.OsmPrimitiveType;
017import org.openstreetmap.josm.data.osm.search.SearchCompiler;
018import org.openstreetmap.josm.data.osm.search.SearchCompiler.Match;
019import org.openstreetmap.josm.data.osm.search.SearchParseError;
020
021/**
022 * Builds an Overpass QL from a {@link org.openstreetmap.josm.actions.search.SearchAction} query.
023 *
024 * @since 8744 (using tyrasd/overpass-wizard), 16262 (standalone)
025 */
026public final class SearchCompilerQueryWizard {
027
028    private SearchCompilerQueryWizard() {
029        // private constructor for utility class
030    }
031
032    /**
033     * Builds an Overpass QL from a {@link org.openstreetmap.josm.actions.search.SearchAction} like query.
034     * @param search the {@link org.openstreetmap.josm.actions.search.SearchAction} like query
035     * @return an Overpass QL query
036     * @throws UncheckedParseException when the parsing fails
037     */
038    public static String constructQuery(final String search) {
039        try {
040            Matcher matcher = Pattern.compile("\\s+GLOBAL\\s*$", Pattern.CASE_INSENSITIVE).matcher(search);
041            if (matcher.find()) {
042                final Match match = SearchCompiler.compile(matcher.replaceFirst(""));
043                return constructQuery(match, ";", "");
044            }
045
046            matcher = Pattern.compile("\\s+IN BBOX\\s*$", Pattern.CASE_INSENSITIVE).matcher(search);
047            if (matcher.find()) {
048                final Match match = SearchCompiler.compile(matcher.replaceFirst(""));
049                return constructQuery(match, "[bbox:{{bbox}}];", "");
050            }
051
052            matcher = Pattern.compile("\\s+(?<mode>IN|AROUND)\\s+(?<area>[^\" ]+|\"[^\"]+\")\\s*$", Pattern.CASE_INSENSITIVE).matcher(search);
053            if (matcher.find()) {
054                final Match match = SearchCompiler.compile(matcher.replaceFirst(""));
055                final String mode = matcher.group("mode").toUpperCase(Locale.ENGLISH);
056                final String area = Utils.strip(matcher.group("area"), "\"");
057                if ("IN".equals(mode)) {
058                    return constructQuery(match, ";\n{{geocodeArea:" + area + "}}->.searchArea;", "(area.searchArea)");
059                } else if ("AROUND".equals(mode)) {
060                    return constructQuery(match, ";\n{{radius=1000}}", "(around:{{radius}},{{geocodeCoords:" + area + "}})");
061                } else {
062                    throw new IllegalStateException(mode);
063                }
064            }
065
066            final Match match = SearchCompiler.compile(search);
067            return constructQuery(match, "[bbox:{{bbox}}];", "");
068        } catch (SearchParseError | UnsupportedOperationException e) {
069            throw new UncheckedParseException(e);
070        }
071    }
072
073    private static String constructQuery(final Match match, final String bounds, final String queryLineSuffix) {
074        final List<Match> normalized = normalizeToDNF(match);
075        final List<String> queryLines = new ArrayList<>();
076        queryLines.add("[out:xml][timeout:90]" + bounds);
077        queryLines.add("(");
078        for (Match conjunction : normalized) {
079            final EnumSet<OsmPrimitiveType> types = EnumSet.noneOf(OsmPrimitiveType.class);
080            final String query = constructQuery(conjunction, types);
081            (types.isEmpty() || types.size() == 3
082                    ? Stream.of("nwr")
083                    : types.stream().map(OsmPrimitiveType::getAPIName))
084                    .forEach(type -> queryLines.add("  " + type + query + queryLineSuffix + ";"));
085        }
086        queryLines.add(");");
087        queryLines.add("(._;>;);");
088        queryLines.add("out meta;");
089        return String.join("\n", queryLines);
090    }
091
092    private static String constructQuery(Match match, final Set<OsmPrimitiveType> types) {
093        final boolean negated;
094        if (match instanceof SearchCompiler.Not) {
095            negated = true;
096            match = ((SearchCompiler.Not) match).getMatch();
097        } else {
098            negated = false;
099        }
100        if (match instanceof SearchCompiler.And) {
101            return ((SearchCompiler.And) match).map(m -> constructQuery(m, types), (s1, s2) -> s1 + s2);
102        } else if (match instanceof SearchCompiler.KeyValue) {
103            final String key = ((SearchCompiler.KeyValue) match).getKey();
104            final String value = ((SearchCompiler.KeyValue) match).getValue();
105            if ("newer".equals(key)) {
106                return "(newer:" + quote("{{date:" + value + "}}") + ")";
107            }
108            return "[~" + quote(key) + "~" + quote(value) + "]";
109        } else if (match instanceof SearchCompiler.ExactKeyValue) {
110            // https://wiki.openstreetmap.org/wiki/Overpass_API/Language_Guide
111            // ["key"]             -- filter objects tagged with this key and any value
112            // [!"key"]            -- filter objects not tagged with this key and any value
113            // ["key"="value"]     -- filter objects tagged with this key and this value
114            // ["key"!="value"]    -- filter objects tagged with this key but not this value, or not tagged with this key
115            // ["key"~"value"]     -- filter objects tagged with this key and a value matching a regular expression
116            // ["key"!~"value"]    -- filter objects tagged with this key but a value not matching a regular expression
117            // [~"key"~"value"]    -- filter objects tagged with a key and a value matching regular expressions
118            // [~"key"~"value", i] -- filter objects tagged with a key and a case-insensitive value matching regular expressions
119            final String key = ((SearchCompiler.ExactKeyValue) match).getKey();
120            final String value = ((SearchCompiler.ExactKeyValue) match).getValue();
121            final SearchCompiler.ExactKeyValue.Mode mode = ((SearchCompiler.ExactKeyValue) match).getMode();
122            switch (mode) {
123                case ANY_VALUE:
124                    return "[" + (negated ? "!" : "") + quote(key) + "]";
125                case EXACT:
126                    return "[" + quote(key) + (negated ? "!=" : "=") + quote(value) + "]";
127                case ANY_KEY: // *=value
128                    // fall through
129                case EXACT_REGEXP:
130                    final Matcher matcher = Pattern.compile("/(?<regex>.*)/(?<flags>i)?").matcher(value);
131                    final String valueQuery = matcher.matches()
132                            ? quote(matcher.group("regex")) + Optional.ofNullable(matcher.group("flags")).map(f -> "," + f).orElse("")
133                            : quote(value);
134                    if (mode == SearchCompiler.ExactKeyValue.Mode.ANY_KEY)
135                        return "[~\"^.*$\"" + (negated ? "!~" : "~") + valueQuery + "]";
136                    return "[" + quote(key) + (negated ? "!~" : "~") + valueQuery + "]";
137                case MISSING_KEY:
138                    // special case for empty values, see https://github.com/drolbr/Overpass-API/issues/53
139                    return "[" + quote(key) + (negated ? "!~" : "~") + quote("^$") + "]";
140                default:
141                    return "";
142            }
143        } else if (match instanceof SearchCompiler.BooleanMatch) {
144            final String key = ((SearchCompiler.BooleanMatch) match).getKey();
145            return negated
146                    ? "[" + quote(key) + "~\"false|no|0|off\"]"
147                    : "[" + quote(key) + "~\"true|yes|1|on\"]";
148        } else if (match instanceof SearchCompiler.UserMatch) {
149            final String user = ((SearchCompiler.UserMatch) match).getUser();
150            return user.matches("\\d+")
151                    ? "(uid:" + user + ")"
152                    : "(user:" + quote(user) + ")";
153        } else if (match instanceof SearchCompiler.ExactType) {
154            types.add(((SearchCompiler.ExactType) match).getType());
155            return "";
156        }
157        Logging.warn("Unsupported match type {0}: {1}", match.getClass(), match);
158        return "/*" + match + "*/";
159    }
160
161    /**
162     * Quotes the given string for its use in Overpass QL
163     * @param s the string to quote
164     * @return the quoted string
165     */
166    private static String quote(final String s) {
167        return "\"" + s.replace("\\", "\\\\").replace("\"", "\\\"") + "\"";
168    }
169
170    /**
171     * Normalizes the match to disjunctive normal form: A∧(B∨C) ⇔ (A∧B)∨(A∧C)
172     * @param match the match to normalize
173     * @return the match in disjunctive normal form
174     */
175    private static List<Match> normalizeToDNF(final Match match) {
176        if (match instanceof SearchCompiler.And) {
177            return ((SearchCompiler.And) match).map(SearchCompilerQueryWizard::normalizeToDNF, (lhs, rhs) -> lhs.stream()
178                    .flatMap(l -> rhs.stream().map(r -> new SearchCompiler.And(l, r)))
179                    .collect(Collectors.toList()));
180        } else if (match instanceof SearchCompiler.Or) {
181            return ((SearchCompiler.Or) match).map(SearchCompilerQueryWizard::normalizeToDNF, CompositeList::new);
182        } else if (match instanceof SearchCompiler.Xor) {
183            throw new UnsupportedOperationException(match.toString());
184        } else if (match instanceof SearchCompiler.Not) {
185            // only support negated KeyValue or ExactKeyValue matches
186            final Match innerMatch = ((SearchCompiler.Not) match).getMatch();
187            if (innerMatch instanceof SearchCompiler.BooleanMatch
188                    || innerMatch instanceof SearchCompiler.KeyValue
189                    || innerMatch instanceof SearchCompiler.ExactKeyValue) {
190                return Collections.singletonList(match);
191            }
192            throw new UnsupportedOperationException(match.toString());
193        } else {
194            return Collections.singletonList(match);
195        }
196    }
197
198}