package com.onthegomap.planetiler.expression; import static com.onthegomap.planetiler.expression.DataType.GET_TAG; import com.onthegomap.planetiler.reader.WithGeometryType; import com.onthegomap.planetiler.reader.WithTags; import com.onthegomap.planetiler.util.Format; import java.util.ArrayList; import java.util.List; import java.util.Objects; import java.util.Set; import java.util.function.BiFunction; import java.util.function.Predicate; import java.util.regex.Pattern; import java.util.stream.Collectors; import java.util.stream.Stream; import org.apache.logging.log4j.util.Strings; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** * A framework for defining and manipulating boolean expressions that match on input element. *

* Calling {@code toString()} on any expression will generate code that can be used to recreate an identical copy of the * original expression, assuming that the generated code includes: * *

 * {@code
 * import static com.onthegomap.planetiler.expression.Expression.*;
 * }
 * 
*/ // TODO rename to BooleanExpression public interface Expression extends Simplifiable { Logger LOGGER = LoggerFactory.getLogger(Expression.class); String LINESTRING_TYPE = "linestring"; String POINT_TYPE = "point"; String POLYGON_TYPE = "polygon"; String UNKNOWN_GEOMETRY_TYPE = "unknown_type"; Set supportedTypes = Set.of(LINESTRING_TYPE, POINT_TYPE, POLYGON_TYPE, UNKNOWN_GEOMETRY_TYPE); Expression TRUE = new Constant(true, "TRUE"); Expression FALSE = new Constant(false, "FALSE"); List dummyList = new NoopList<>(); static And and(Expression... children) { return and(List.of(children)); } static And and(List children) { return new And(children); } static Or or(Expression... children) { return or(List.of(children)); } static Or or(List children) { return new Or(children); } static Not not(Expression child) { return new Not(child); } /** * Returns an expression that evaluates to true if the value for {@code field} tag is any of {@code values}. *

* {@code values} can contain exact matches, "%text%" to match any value containing "text", or "" to match any value. */ static MatchAny matchAny(String field, Object... values) { return matchAny(field, List.of(values)); } /** * Returns an expression that evaluates to true if the value for {@code field} tag is any of {@code values}. *

* {@code values} can contain exact matches, "%text%" to match any value containing "text", or "" to match any value. */ static MatchAny matchAny(String field, List values) { return MatchAny.from(field, GET_TAG, values); } /** * Returns an expression that evaluates to true if the value for {@code field} tag is any of {@code values}, when * considering the tag as a specified data type and then converted to a string. *

* {@code values} can contain exact matches, "%text%" to match any value containing "text", or "" to match any value. */ static MatchAny matchAnyTyped(String field, BiFunction typeGetter, Object... values) { return matchAnyTyped(field, typeGetter, List.of(values)); } /** * Returns an expression that evaluates to true if the value for {@code field} tag is any of {@code values}, when * considering the tag as a specified data type and then converted to a string. *

* {@code values} can contain exact matches, "%text%" to match any value containing "text", or "" to match any value. */ static MatchAny matchAnyTyped(String field, BiFunction typeGetter, List values) { return MatchAny.from(field, typeGetter, values); } /** Returns an expression that evaluates to true if the element has any value for tag {@code field}. */ static MatchField matchField(String field) { return new MatchField(field); } /** * Returns an expression that evaluates to true if the geometry of an element matches {@code type}. *

* Allowed values: *

    *
  • "linestring"
  • *
  • "point"
  • *
  • "polygon"
  • *
  • "relation_member"
  • *
*/ static MatchType matchType(String type) { if (!supportedTypes.contains(type)) { throw new IllegalArgumentException("Unsupported type: " + type); } return new MatchType(type); } private static String generateJavaCodeList(List items) { return items.stream().map(Expression::generateJavaCode).collect(Collectors.joining(", ")); } /** Returns a copy of this expression where every nested instance of {@code a} is replaced with {@code b}. */ default Expression replace(Expression a, Expression b) { return replace(a::equals, b); } /** * Returns a copy of this expression where every nested instance matching {@code replace} is replaced with {@code b}. */ default Expression replace(Predicate replace, Expression b) { if (replace.test(this)) { return b; } else if (this instanceof Not not) { return new Not(not.child.replace(replace, b)); } else if (this instanceof Or or) { return new Or(or.children.stream().map(child -> child.replace(replace, b)).toList()); } else if (this instanceof And and) { return new And(and.children.stream().map(child -> child.replace(replace, b)).toList()); } else { return this; } } /** Returns true if this expression or any subexpression matches {@code filter}. */ default boolean contains(Predicate filter) { if (filter.test(this)) { return true; } else if (this instanceof Not not) { return not.child.contains(filter); } else if (this instanceof Or or) { return or.children.stream().anyMatch(child -> child.contains(filter)); } else if (this instanceof And and) { return and.children.stream().anyMatch(child -> child.contains(filter)); } else { return false; } } /** * Returns true if this expression matches an input element. * * @param input the input element * @param matchKeys list that this method call will add any key to that was responsible for triggering the match * @return true if this expression matches the input element */ boolean evaluate(WithTags input, List matchKeys); //A list that silently drops all additions class NoopList extends ArrayList { @Override public boolean add(T t) { return true; } } /** * Returns true if this expression matches an input element. * * @param input the input element * @return true if this expression matches the input element */ default boolean evaluate(WithTags input) { return evaluate(input, dummyList); } /** Returns Java code that can be used to reconstruct this expression. */ String generateJavaCode(); /** A constant boolean value. */ record Constant(boolean value, @Override String generateJavaCode) implements Expression { @Override public String toString() { return generateJavaCode; } @Override public boolean evaluate(WithTags input, List matchKeys) { return value; } } record And(List children) implements Expression { @Override public String generateJavaCode() { return "and(" + generateJavaCodeList(children) + ")"; } @Override public boolean evaluate(WithTags input, List matchKeys) { for (Expression child : children) { if (!child.evaluate(input, matchKeys)) { matchKeys.clear(); return false; } } return true; } @Override public Expression simplifyOnce() { if (children.isEmpty()) { return TRUE; } if (children.size() == 1) { return children.get(0).simplifyOnce(); } if (children.contains(FALSE)) { return FALSE; } return and(children.stream() // hoist children .flatMap(child -> child instanceof And childAnd ? childAnd.children.stream() : Stream.of(child)) .filter(child -> child != TRUE) // and() == and(TRUE) == and(TRUE, TRUE) == TRUE, so safe to remove all here .distinct() .map(Simplifiable::simplifyOnce).toList()); } } record Or(List children) implements Expression { @Override public String generateJavaCode() { return "or(" + generateJavaCodeList(children) + ")"; } @Override public boolean evaluate(WithTags input, List matchKeys) { for (Expression child : children) { if (child.evaluate(input, matchKeys)) { return true; } } return false; } @Override public boolean equals(Object obj) { if (obj == this) { return true; } if (obj == null || obj.getClass() != this.getClass()) { return false; } var that = (Or) obj; return Objects.equals(this.children, that.children); } @Override public int hashCode() { return Objects.hash(children); } @Override public Expression simplifyOnce() { if (children.isEmpty()) { return FALSE; } if (children.size() == 1) { return children.get(0).simplifyOnce(); } if (children.contains(TRUE)) { return TRUE; } return or(children.stream() // hoist children .flatMap(child -> child instanceof Or childOr ? childOr.children.stream() : Stream.of(child)) .filter(child -> child != FALSE) // or() == or(FALSE) == or(FALSE, FALSE) == FALSE, so safe to remove all here .distinct() .map(Simplifiable::simplifyOnce).toList()); } } record Not(Expression child) implements Expression { @Override public String generateJavaCode() { return "not(" + child.generateJavaCode() + ")"; } @Override public boolean evaluate(WithTags input, List matchKeys) { return !child.evaluate(input, new ArrayList<>()); } @Override public Expression simplifyOnce() { if (child instanceof Or or) { return and(or.children.stream().map(Expression::not).toList()); } else if (child instanceof And and) { return or(and.children.stream().map(Expression::not).toList()); } else if (child instanceof Not not2) { return not2.child; } else if (child == TRUE) { return FALSE; } else if (child == FALSE) { return TRUE; } else if (child instanceof MatchAny any && any.values.equals(List.of(""))) { return matchField(any.field); } return this; } } /** * Evaluates to true if the value for {@code field} tag is any of {@code exactMatches} or contains any of {@code * wildcards}. * * @param values all raw string values that were initially provided * @param exactMatches the input {@code values} that should be treated as exact matches * @param pattern regular expression that the value must match, or null * @param matchWhenMissing if {@code values} contained "" */ record MatchAny( String field, List values, Set exactMatches, Pattern pattern, boolean matchWhenMissing, BiFunction valueGetter ) implements Expression { static MatchAny from(String field, BiFunction valueGetter, List values) { List exactMatches = new ArrayList<>(); List patterns = new ArrayList<>(); for (var value : values) { if (value != null) { String string = value.toString(); if (string.matches("^.*(? v == null || "".equals(v)); return new MatchAny(field, values, Set.copyOf(exactMatches), patterns.isEmpty() ? null : Pattern.compile("(" + Strings.join(patterns, '|') + ")"), matchWhenMissing, valueGetter ); } private static String wildcardToRegex(String string) { StringBuilder regex = new StringBuilder("^"); StringBuilder token = new StringBuilder(); while (!string.isEmpty()) { if (string.startsWith("\\%")) { if (!token.isEmpty()) { regex.append(Pattern.quote(token.toString())); } token.setLength(0); regex.append("%"); string = string.replaceFirst("^\\\\%", ""); } else if (string.startsWith("%")) { if (!token.isEmpty()) { regex.append(Pattern.quote(token.toString())); } token.setLength(0); regex.append(".*"); string = string.replaceFirst("^%+", ""); } else { token.append(string.charAt(0)); string = string.substring(1); } } if (!token.isEmpty()) { regex.append(Pattern.quote(token.toString())); } regex.append('$'); return regex.toString(); } private static String unescape(String input) { return input.replace("\\%", "%"); } @Override public boolean evaluate(WithTags input, List matchKeys) { Object value = valueGetter.apply(input, field); if (value == null || "".equals(value)) { return matchWhenMissing; } else { String str = value.toString(); if (exactMatches.contains(str)) { matchKeys.add(field); return true; } if (pattern != null && pattern.matcher(str).matches()) { matchKeys.add(field); return true; } return false; } } @Override public Expression simplifyOnce() { return isMatchAnything() ? matchField(field) : this; } @Override public String generateJavaCode() { // java code generation only needed for the simple cases used by openmaptiles schema generation List valueStrings = new ArrayList<>(); if (GET_TAG != valueGetter) { throw new UnsupportedOperationException("Code generation only supported for default getTag"); } for (var value : values) { if (value instanceof String string) { valueStrings.add(Format.quote(string)); } else { throw new UnsupportedOperationException("Code generation only supported for string values, found: " + value.getClass().getCanonicalName() + " " + value); } } return "matchAny(" + Format.quote(field) + ", " + String.join(", ", valueStrings) + ")"; } public boolean isMatchAnything() { return !matchWhenMissing && exactMatches.isEmpty() && (pattern != null && pattern.toString().equals("(^.*$)")); } @Override public boolean equals(Object o) { return this == o || (o instanceof MatchAny matchAny && matchWhenMissing == matchAny.matchWhenMissing && Objects.equals(field, matchAny.field) && Objects.equals(values, matchAny.values) && Objects.equals(exactMatches, matchAny.exactMatches) && // Patterns for the same input string are not equal Objects.equals(patternString(), matchAny.patternString()) && Objects.equals(valueGetter, matchAny.valueGetter)); } private String patternString() { return pattern == null ? null : pattern.pattern(); } @Override public int hashCode() { return Objects.hash(field, values, exactMatches, patternString(), matchWhenMissing, valueGetter); } } /** Evaluates to true if an input element contains any value for {@code field} tag. */ record MatchField(String field) implements Expression { @Override public String generateJavaCode() { return "matchField(" + Format.quote(field) + ")"; } @Override public boolean evaluate(WithTags input, List matchKeys) { Object value = input.getTag(field); if (value != null && !"".equals(value)) { matchKeys.add(field); return true; } return false; } } /** * Evaluates to true if an input element has geometry type matching {@code type}. */ record MatchType(String type) implements Expression { @Override public String generateJavaCode() { return "matchType(" + Format.quote(type) + ")"; } @Override public boolean evaluate(WithTags input, List matchKeys) { if (input instanceof WithGeometryType withGeom) { return switch (type) { case LINESTRING_TYPE -> withGeom.canBeLine(); case POLYGON_TYPE -> withGeom.canBePolygon(); case POINT_TYPE -> withGeom.isPoint(); default -> false; }; } else { return false; } } } }