{
+
+ /**
+ * Generates output features for any input feature that should appear in the map.
+ *
+ * Multiple threads may invoke this method concurrently for a single data source so implementations should ensure
+ * thread-safe access to any shared data structures. Separate data sources are processed sequentially.
+ *
+ * All OSM nodes are processed first, then ways, then relations.
+ *
+ * @param sourceFeature the input feature from a source dataset (OSM element, shapefile element, etc.)
+ * @param features a collector for generating output map features to emit
+ */
+ void processFeature(T sourceFeature, FeatureCollector features);
+}
diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/ForwardingProfile.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/ForwardingProfile.java
index e919b41d13..0cdc5f88e9 100644
--- a/planetiler-core/src/main/java/com/onthegomap/planetiler/ForwardingProfile.java
+++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/ForwardingProfile.java
@@ -1,5 +1,8 @@
package com.onthegomap.planetiler;
+import com.onthegomap.planetiler.config.PlanetilerConfig;
+import com.onthegomap.planetiler.expression.Expression;
+import com.onthegomap.planetiler.expression.MultiExpression;
import com.onthegomap.planetiler.geo.GeometryException;
import com.onthegomap.planetiler.geo.TileCoord;
import com.onthegomap.planetiler.reader.SourceFeature;
@@ -9,6 +12,7 @@
import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import java.util.concurrent.CopyOnWriteArrayList;
import java.util.function.Consumer;
/**
@@ -41,8 +45,41 @@ public abstract class ForwardingProfile implements Profile {
private final Map> layerPostProcessors = new HashMap<>();
/** List of handlers that implement {@link TilePostProcessor}. */
private final List tilePostProcessors = new ArrayList<>();
- /** Map from source ID to its handler if it implements {@link FeatureProcessor}. */
- private final Map> sourceElementProcessors = new HashMap<>();
+ /** List of handlers that implements {@link FeatureProcessor} along with a filter expression. */
+ private final List> sourceElementProcessors = new CopyOnWriteArrayList<>();
+ private final List onlyLayers;
+ private final List excludeLayers;
+ @SuppressWarnings("java:S3077")
+ private volatile MultiExpression.Index indexedSourceElementProcessors = null;
+
+ protected ForwardingProfile(PlanetilerConfig config, Handler... handlers) {
+ onlyLayers = config.arguments().getList("only_layers", "Include only certain layers", List.of());
+ excludeLayers = config.arguments().getList("exclude_layers", "Exclude certain layers", List.of());
+ for (var handler : handlers) {
+ registerHandler(handler);
+ }
+ }
+
+ protected ForwardingProfile() {
+ onlyLayers = List.of();
+ excludeLayers = List.of();
+ }
+
+ protected ForwardingProfile(Handler... handlers) {
+ onlyLayers = List.of();
+ excludeLayers = List.of();
+ for (var handler : handlers) {
+ registerHandler(handler);
+ }
+ }
+
+ private boolean caresAboutLayer(String layer) {
+ return (onlyLayers.isEmpty() || onlyLayers.contains(layer)) && !excludeLayers.contains(layer);
+ }
+
+ private boolean caresAboutLayer(Object obj) {
+ return !(obj instanceof HandlerForLayer l) || caresAboutLayer(l.name());
+ }
/**
* Call {@code processor} for every element in {@code source}.
@@ -51,8 +88,29 @@ public abstract class ForwardingProfile implements Profile {
* @param processor handler that will process elements in that source
*/
public void registerSourceHandler(String source, FeatureProcessor processor) {
- sourceElementProcessors.computeIfAbsent(source, name -> new ArrayList<>())
- .add(processor);
+ if (!caresAboutLayer(processor)) {
+ return;
+ }
+ sourceElementProcessors
+ .add(MultiExpression.entry(processor, Expression.and(Expression.matchSource(source), processor.filter())));
+ synchronized (sourceElementProcessors) {
+ indexedSourceElementProcessors = null;
+ }
+ }
+
+ /**
+ * Call {@code processor} for every element.
+ *
+ * @param processor handler that will process elements in that source
+ */
+ public void registerFeatureHandler(FeatureProcessor processor) {
+ if (!caresAboutLayer(processor)) {
+ return;
+ }
+ sourceElementProcessors.add(MultiExpression.entry(processor, processor.filter()));
+ synchronized (sourceElementProcessors) {
+ indexedSourceElementProcessors = null;
+ }
}
/**
@@ -60,6 +118,9 @@ public void registerSourceHandler(String source, FeatureProcessor processor) {
* {@link OsmRelationPreprocessor}, {@link FinishHandler}, {@link TilePostProcessor} or {@link LayerPostProcesser}.
*/
public void registerHandler(Handler handler) {
+ if (!caresAboutLayer(handler)) {
+ return;
+ }
this.handlers.add(handler);
if (handler instanceof OsmNodePreprocessor osmNodePreprocessor) {
osmNodePreprocessors.add(osmNodePreprocessor);
@@ -80,6 +141,9 @@ public void registerHandler(Handler handler) {
if (handler instanceof TilePostProcessor postProcessor) {
tilePostProcessors.add(postProcessor);
}
+ if (handler instanceof FeatureProcessor processor) {
+ registerFeatureHandler(processor);
+ }
}
@Override
@@ -117,19 +181,34 @@ public List preprocessOsmRelation(OsmElement.Relation relation)
@Override
public void processFeature(SourceFeature sourceFeature, FeatureCollector features) {
// delegate source feature processing to each handler for that source
- var handlers = sourceElementProcessors.get(sourceFeature.getSource());
- if (handlers != null) {
- for (var handler : handlers) {
- handler.processFeature(sourceFeature, features);
- // TODO extract common handling for expression-based filtering from basemap to this
- // common profile when we have another use-case for it.
+ for (var handler : getHandlerIndex().getMatches(sourceFeature)) {
+ handler.processFeature(sourceFeature, features);
+ }
+ }
+
+ private MultiExpression.Index getHandlerIndex() {
+ MultiExpression.Index result = indexedSourceElementProcessors;
+ if (result == null) {
+ synchronized (sourceElementProcessors) {
+ result = indexedSourceElementProcessors;
+ if (result == null) {
+ indexedSourceElementProcessors = result = MultiExpression.of(sourceElementProcessors).index();
+ }
}
}
+ return result;
}
@Override
public boolean caresAboutSource(String name) {
- return sourceElementProcessors.containsKey(name);
+ return caresAbout(Expression.PartialInput.ofSource(name));
+ }
+
+ @Override
+ public boolean caresAbout(Expression.PartialInput input) {
+ return sourceElementProcessors.stream().anyMatch(e -> e.expression()
+ .partialEvaluate(input)
+ .simplify() != Expression.FALSE);
}
@Override
@@ -271,13 +350,12 @@ Map> postProcessTile(TileCoord tileCoord,
}
/** Handlers should implement this interface to process input features from a given source ID. */
- public interface FeatureProcessor {
+ @FunctionalInterface
+ public interface FeatureProcessor extends com.onthegomap.planetiler.FeatureProcessor, Handler {
- /**
- * Process an input element from a source feature.
- *
- * @see Profile#processFeature(SourceFeature, FeatureCollector)
- */
- void processFeature(SourceFeature feature, FeatureCollector features);
+ /** Returns an {@link Expression} that limits the features that this processor gets called for. */
+ default Expression filter() {
+ return Expression.TRUE;
+ }
}
}
diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/Planetiler.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/Planetiler.java
index 59371024a5..e0fb09463d 100644
--- a/planetiler-core/src/main/java/com/onthegomap/planetiler/Planetiler.java
+++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/Planetiler.java
@@ -34,6 +34,7 @@
import com.onthegomap.planetiler.util.TopOsmTiles;
import com.onthegomap.planetiler.util.Translations;
import com.onthegomap.planetiler.util.Wikidata;
+import com.onthegomap.planetiler.validator.JavaProfileValidator;
import com.onthegomap.planetiler.worker.RunnableThatThrows;
import java.io.IOException;
import java.nio.file.FileSystem;
@@ -87,7 +88,8 @@ public class Planetiler {
private final Path nodeDbPath;
private final Path multipolygonPath;
private final Path featureDbPath;
- private final boolean downloadSources;
+ private final Path onlyRunTests;
+ private boolean downloadSources;
private final boolean refreshSources;
private final boolean onlyDownloadSources;
private final boolean parseNodeBounds;
@@ -122,6 +124,7 @@ private Planetiler(Arguments arguments) {
}
tmpDir = config.tmpDir();
onlyDownloadSources = arguments.getBoolean("only_download", "download source data then exit", false);
+ onlyRunTests = arguments.file("tests", "run test cases in a yaml then quit", null);
downloadSources = onlyDownloadSources || arguments.getBoolean("download", "download sources", false);
refreshSources =
arguments.getBoolean("refresh_sources", "download new version of source files if they have changed", false);
@@ -670,7 +673,11 @@ public Planetiler overwriteOutput(String defaultOutputUri) {
return setOutput(defaultOutputUri);
}
- /** Alias for {@link #overwriteOutput(String)} which infers the output type based on extension. */
+ /**
+ * Alias for {@link #overwriteOutput(String)} which infers the output type based on extension.
+ *
+ * This will override the value returned by
+ */
public Planetiler overwriteOutput(Path defaultOutput) {
return overwriteOutput(defaultOutput.toString());
}
@@ -680,9 +687,8 @@ public Planetiler overwriteOutput(Path defaultOutput) {
* writes the rendered tiles to the output archive.
*
* @throws IllegalArgumentException if expected inputs have not been provided
- * @throws Exception if an error occurs while processing
*/
- public void run() throws Exception {
+ public void run() {
var showVersion = arguments.getBoolean("version", "show version then exit", false);
var buildInfo = BuildInfo.get();
if (buildInfo != null && LOGGER.isInfoEnabled()) {
@@ -709,6 +715,9 @@ public void run() throws Exception {
if (arguments.getBoolean("help", "show arguments then exit", false)) {
System.exit(0);
+ } else if (onlyRunTests != null) {
+ boolean success = JavaProfileValidator.validate(profile(), onlyRunTests, config());
+ System.exit(success ? 0 : 1);
} else if (onlyDownloadSources) {
// don't check files if not generating map
} else if (config.append()) {
@@ -772,7 +781,7 @@ public void run() throws Exception {
// in case any temp files are left from a previous run...
FileUtils.delete(tmpDir, nodeDbPath, featureDbPath, multipolygonPath);
- Files.createDirectories(tmpDir);
+ FileUtils.createDirectory(tmpDir);
FileUtils.createParentDirectories(nodeDbPath, featureDbPath, multipolygonPath, output.getLocalBasePath());
if (!toDownload.isEmpty()) {
@@ -814,7 +823,11 @@ public void run() throws Exception {
stats.monitorFile("archive", output.getLocalPath(), archive::bytesWritten);
for (Stage stage : stages) {
- stage.task.run();
+ try {
+ stage.task.run();
+ } catch (Exception e) {
+ throw new PlanetilerException("Error occurred during stage " + stage.id, e);
+ }
}
LOGGER.info("Deleting node.db to make room for output file");
@@ -831,13 +844,17 @@ public void run() throws Exception {
TileArchiveWriter.writeOutput(featureGroup, archive, archive::bytesWritten, tileArchiveMetadata, layerStatsPath,
config, stats);
} catch (IOException e) {
- throw new IllegalStateException("Unable to write to " + output, e);
+ throw new PlanetilerException("Unable to write to " + output, e);
}
overallTimer.stop();
LOGGER.info("FINISHED!");
stats.printSummary();
- stats.close();
+ try {
+ stats.close();
+ } catch (Exception e) {
+ throw new PlanetilerException(e);
+ }
}
private void checkDiskSpace() {
@@ -990,4 +1007,15 @@ private record Stage(String id, List details, RunnableThatThrows task) {
private record ToDownload(String id, String url, Path path) {}
private record InputPath(String id, Path path, boolean freeAfterReading) {}
+
+ /** An exception that occurs while running planetiler. */
+ public static class PlanetilerException extends RuntimeException {
+ public PlanetilerException(String message, Exception e) {
+ super(message, e);
+ }
+
+ public PlanetilerException(Exception e) {
+ super(e);
+ }
+ }
}
diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/Profile.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/Profile.java
index 4d8ae8d4ac..8a7dc8f53b 100644
--- a/planetiler-core/src/main/java/com/onthegomap/planetiler/Profile.java
+++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/Profile.java
@@ -1,5 +1,6 @@
package com.onthegomap.planetiler;
+import com.onthegomap.planetiler.expression.Expression;
import com.onthegomap.planetiler.geo.GeometryException;
import com.onthegomap.planetiler.geo.TileCoord;
import com.onthegomap.planetiler.mbtiles.Mbtiles;
@@ -32,9 +33,18 @@
* For complex profiles, {@link ForwardingProfile} provides a framework for splitting the logic up into several handlers
* (i.e. one per layer) and forwarding each element/event to the handlers that care about it.
*/
-public interface Profile {
+public interface Profile extends FeatureProcessor {
// TODO might want to break this apart into sub-interfaces that ForwardingProfile (and TileArchiveMetadata) can use too
+ /**
+ * Default attribution recommended for profiles using OpenStreetMap data
+ *
+ * @see www.openstreetmap.org/copyright
+ */
+ String OSM_ATTRIBUTION = """
+ © OpenStreetMap contributors
+ """.trim();
+
/**
* Allows profile to extract any information it needs from a {@link OsmElement.Node} during the first pass through OSM
* elements.
@@ -71,19 +81,6 @@ default List preprocessOsmRelation(OsmElement.Relation relation
return null;
}
- /**
- * Generates output features for any input feature that should appear in the map.
- *
- * Multiple threads may invoke this method concurrently for a single data source so implementations should ensure
- * thread-safe access to any shared data structures. Separate data sources are processed sequentially.
- *
- * All OSM nodes are processed first, then ways, then relations.
- *
- * @param sourceFeature the input feature from a source dataset (OSM element, shapefile element, etc.)
- * @param features a collector for generating output map features to emit
- */
- void processFeature(SourceFeature sourceFeature, FeatureCollector features);
-
/** Free any resources associated with this profile (i.e. shared data structures) */
default void release() {}
@@ -143,7 +140,9 @@ default Map> postProcessTileFeatures(TileCoord
*
* @see MBTiles specification
*/
- String name();
+ default String name() {
+ return getClass().getSimpleName();
+ }
/**
* Returns the description of the generated tileset to put into {@link Mbtiles} metadata
@@ -248,6 +247,14 @@ default long estimateRamRequired(long osmFileSize) {
return 0L;
}
+ /**
+ * Returns false if this profile will ignore every feature in a set where {@linkplain Expression.PartialInput partial
+ * attributes} are known ahead of time.
+ */
+ default boolean caresAbout(Expression.PartialInput input) {
+ return true;
+ }
+
/**
* A default implementation of {@link Profile} that emits no output elements.
*/
diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/config/Arguments.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/config/Arguments.java
index 879e20e3bf..9124552c29 100644
--- a/planetiler-core/src/main/java/com/onthegomap/planetiler/config/Arguments.java
+++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/config/Arguments.java
@@ -546,4 +546,9 @@ public Arguments subset(String... allowedKeys) {
() -> keys.get().stream().filter(key -> allowed.contains(normalize(key))).toList()
);
}
+
+ /** Returns a new arguments instance where the value for {@code key} defaults to {@code value}. */
+ public Arguments withDefault(Object key, Object value) {
+ return orElse(Arguments.of(key.toString().replaceFirst("^-*", ""), value));
+ }
}
diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/expression/Expression.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/expression/Expression.java
index 3d24c1875a..79a51f140f 100644
--- a/planetiler-core/src/main/java/com/onthegomap/planetiler/expression/Expression.java
+++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/expression/Expression.java
@@ -2,15 +2,19 @@
import static com.onthegomap.planetiler.expression.DataType.GET_TAG;
-import com.google.common.base.Joiner;
+import com.onthegomap.planetiler.geo.GeometryType;
+import com.onthegomap.planetiler.reader.SourceFeature;
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.Collection;
import java.util.List;
+import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.BiFunction;
+import java.util.function.Consumer;
import java.util.function.Predicate;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
@@ -28,6 +32,7 @@
* }
*/
// TODO rename to BooleanExpression
+@FunctionalInterface
public interface Expression extends Simplifiable {
Logger LOGGER = LoggerFactory.getLogger(Expression.class);
@@ -123,6 +128,32 @@ static MatchType matchType(String type) {
return new MatchType(type);
}
+ /**
+ * Returns an expression that evaluates to true if the geometry of an element matches {@code type}.
+ */
+ static MatchType matchGeometryType(GeometryType type) {
+ return new MatchType(switch (type) {
+ case POINT -> POINT_TYPE;
+ case LINE -> LINESTRING_TYPE;
+ case POLYGON -> POLYGON_TYPE;
+ case null, default -> throw new IllegalArgumentException("Unsupported type: " + type);
+ });
+ }
+
+ /**
+ * Returns an expression that evaluates to true if the source of an element matches {@code source}.
+ */
+ static MatchSource matchSource(String source) {
+ return new MatchSource(source);
+ }
+
+ /**
+ * Returns an expression that evaluates to true if the source layer of an element matches {@code layer}.
+ */
+ static MatchSourceLayer matchSourceLayer(String layer) {
+ return new MatchSourceLayer(layer);
+ }
+
private static String generateJavaCodeList(List items) {
return items.stream().map(Expression::generateJavaCode).collect(Collectors.joining(", "));
}
@@ -148,6 +179,19 @@ default Expression replace(Predicate replace, Expression b) {
}
}
+ /** Calls {@code fn} for every expression within the current one. */
+ default void visit(Consumer fn) {
+ fn.accept(this);
+ switch (this) {
+ case Not(var child) -> child.visit(fn);
+ case Or(var children) -> children.forEach(child -> child.visit(fn));
+ case And(var children) -> children.forEach(child -> child.visit(fn));
+ default -> {
+ // already called fn, and no nested children
+ }
+ }
+ }
+
/** Returns true if this expression or any subexpression matches {@code filter}. */
default boolean contains(Predicate filter) {
if (filter.test(this)) {
@@ -162,6 +206,10 @@ default boolean contains(Predicate filter) {
}
}
+ private static Expression constBool(boolean value) {
+ return value ? TRUE : FALSE;
+ }
+
/**
* Returns true if this expression matches an input element.
*
@@ -171,6 +219,20 @@ default boolean contains(Predicate filter) {
*/
boolean evaluate(WithTags input, List matchKeys);
+ /**
+ * Returns a copy of this expression where any parts that the value is known replaced with {@link #TRUE} or
+ * {@link #FALSE} for a set of features where {@link PartialInput} are known ahead of time (ie. a hive-partitioned
+ * parquet input file).
+ */
+ default Expression partialEvaluate(PartialInput input) {
+ return switch (this) {
+ case Not(var expr) -> not(expr.partialEvaluate(input));
+ case And(var exprs) -> and(exprs.stream().map(e -> e.partialEvaluate(input)).toList());
+ case Or(var exprs) -> or(exprs.stream().map(e -> e.partialEvaluate(input)).toList());
+ default -> this;
+ };
+ }
+
//A list that silently drops all additions
class NoopList extends ArrayList {
@Override
@@ -190,7 +252,9 @@ default boolean evaluate(WithTags input) {
}
/** Returns Java code that can be used to reconstruct this expression. */
- String generateJavaCode();
+ default String generateJavaCode() {
+ throw new UnsupportedOperationException();
+ }
/** A constant boolean value. */
record Constant(boolean value, @Override String generateJavaCode) implements Expression {
@@ -329,8 +393,8 @@ public Expression simplifyOnce() {
}
/**
- * Evaluates to true if the value for {@code field} tag is any of {@code exactMatches} or contains any of {@code
- * wildcards}.
+ * 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
@@ -405,8 +469,31 @@ private static String unescape(String input) {
@Override
public boolean evaluate(WithTags input, List matchKeys) {
Object value = valueGetter.apply(input, field);
+ return evaluate(matchKeys, value);
+ }
+
+ @Override
+ public Expression partialEvaluate(PartialInput input) {
+ Object value = input.getTag(field);
+ return value == null ? this : constBool(evaluate(new ArrayList<>(), value));
+ }
+
+ private boolean evaluate(List matchKeys, Object value) {
if (value == null || "".equals(value)) {
return matchWhenMissing;
+ } else if (value instanceof Collection> c) {
+ if (c.isEmpty()) {
+ return matchWhenMissing;
+ } else {
+ for (var item : c) {
+ if (evaluate(matchKeys, item)) {
+ return true;
+ }
+ }
+ return false;
+ }
+ } else if (value instanceof Map, ?>) {
+ return false;
} else {
String str = value.toString();
if (exactMatches.contains(str)) {
@@ -483,12 +570,17 @@ public String generateJavaCode() {
@Override
public boolean evaluate(WithTags input, List matchKeys) {
Object value = input.getTag(field);
- if (value != null && !"".equals(value)) {
+ if (value != null && !"".equals(value) && !(value instanceof Collection> c && c.isEmpty())) {
matchKeys.add(field);
return true;
}
return false;
}
+
+ @Override
+ public Expression partialEvaluate(PartialInput input) {
+ return input.hasTag(field) ? TRUE : this;
+ }
}
/**
@@ -514,5 +606,90 @@ public boolean evaluate(WithTags input, List matchKeys) {
return false;
}
}
+
+ @Override
+ public Expression partialEvaluate(PartialInput input) {
+ return input.types.isEmpty() || input.types.contains(GeometryType.UNKNOWN) ? this :
+ partialEvaluateContains(input.types, switch (type) {
+ case LINESTRING_TYPE -> GeometryType.LINE;
+ case POLYGON_TYPE -> GeometryType.POLYGON;
+ case POINT_TYPE -> GeometryType.POINT;
+ default -> GeometryType.UNKNOWN;
+ }, this);
+ }
+ }
+
+ /**
+ * Evaluates to true if an input element has source matching {@code source}.
+ */
+ record MatchSource(String source) implements Expression {
+
+ @Override
+ public String generateJavaCode() {
+ return "matchSource(" + Format.quote(source) + ")";
+ }
+
+ @Override
+ public boolean evaluate(WithTags input, List matchKeys) {
+ return input instanceof SourceFeature feature && source.equals(feature.getSource());
+ }
+
+ @Override
+ public Expression partialEvaluate(PartialInput input) {
+ return partialEvaluateContains(input.source, source, this);
+ }
+ }
+
+ /**
+ * Evaluates to true if an input element has source layer matching {@code layer}.
+ */
+ record MatchSourceLayer(String layer) implements Expression {
+
+ @Override
+ public String generateJavaCode() {
+ return "matchSourceLayer(" + Format.quote(layer) + ")";
+ }
+
+ @Override
+ public boolean evaluate(WithTags input, List matchKeys) {
+ return input instanceof SourceFeature feature && layer.equals(feature.getSourceLayer());
+ }
+
+ @Override
+ public Expression partialEvaluate(PartialInput input) {
+ return partialEvaluateContains(input.layer, layer, this);
+ }
+ }
+
+ private static Expression partialEvaluateContains(Set set, T item, Expression self) {
+ if (set.isEmpty()) {
+ return self;
+ } else if (set.size() == 1) {
+ return constBool(set.contains(item));
+ } else if (set.contains(item)) {
+ return self;
+ } else {
+ return FALSE;
+ }
+ }
+
+ /**
+ * Partial attributes of a set of features that are known ahead of time, for example a hive-partitioned parquet input
+ * file.
+ *
+ * Features within this set will only add tags but not change them, and the source/layer/geometry type will be one of
+ * the values specified in those sets. If the set is empty, the values are not known ahead of time.
+ */
+ record PartialInput(Set source, Set layer, Map tags, Set types)
+ implements WithTags {
+
+ public static PartialInput ofSource(String source) {
+ return new PartialInput(Set.of(source), Set.of(), Map.of(), Set.of());
+ }
+
+ @Override
+ public Map tags() {
+ return tags == null ? Map.of() : tags;
+ }
}
}
diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/expression/MultiExpression.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/expression/MultiExpression.java
index d1e86d944c..0f6d9691c3 100644
--- a/planetiler-core/src/main/java/com/onthegomap/planetiler/expression/MultiExpression.java
+++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/expression/MultiExpression.java
@@ -4,6 +4,7 @@
import static com.onthegomap.planetiler.expression.Expression.TRUE;
import static com.onthegomap.planetiler.expression.Expression.matchType;
+import com.onthegomap.planetiler.reader.SourceFeature;
import com.onthegomap.planetiler.reader.WithGeometryType;
import com.onthegomap.planetiler.reader.WithTags;
import java.util.ArrayList;
@@ -32,7 +33,7 @@
*
* @param type of data value associated with each expression
*/
-public record MultiExpression (List> expressions) implements Simplifiable> {
+public record MultiExpression(List> expressions) implements Simplifiable> {
private static final Logger LOGGER = LoggerFactory.getLogger(MultiExpression.class);
private static final Comparator BY_ID = Comparator.comparingInt(WithId::id);
@@ -98,9 +99,18 @@ private Index index(boolean warn) {
if (expressions.isEmpty()) {
return new EmptyIndex<>();
}
- boolean caresAboutGeometryType =
- expressions.stream().anyMatch(entry -> entry.expression.contains(exp -> exp instanceof Expression.MatchType));
- return caresAboutGeometryType ? new GeometryTypeIndex<>(this, warn) : new KeyIndex<>(simplify(), warn);
+ if (contains(Expression.MatchSource.class::isInstance)) {
+ return new SourceIndex<>(this, warn);
+ } else if (contains(Expression.MatchSourceLayer.class::isInstance)) {
+ return new SourceLayerIndex<>(this, warn);
+ } else if (contains(Expression.MatchType.class::isInstance)) {
+ return new GeometryTypeIndex<>(this, warn);
+ }
+ return new KeyIndex<>(simplify(), warn);
+ }
+
+ private boolean contains(Predicate test) {
+ return expressions.stream().anyMatch(entry -> entry.expression.contains(test));
}
/** Returns a copy of this multi-expression that replaces every expression using {@code mapper}. */
@@ -203,7 +213,7 @@ private static class EmptyIndex implements Index {
@Override
public List> getMatchesWithTriggers(WithTags input) {
- return List.of();
+ return new ArrayList<>();
}
@Override
@@ -238,7 +248,12 @@ private KeyIndex(MultiExpression expressions, boolean warn) {
always.add(expressionValue);
} else {
getRelevantKeys(expression,
- key -> keyToExpressions.computeIfAbsent(key, k -> new HashSet<>()).add(expressionValue));
+ key -> {
+ while (!key.isBlank()) {
+ keyToExpressions.computeIfAbsent(key, k -> new HashSet<>()).add(expressionValue);
+ key = key.replaceAll("(^|(\\[])?\\.)[^.]*$", "");
+ }
+ });
}
}
// create immutable copies for fast iteration at matching time
@@ -302,10 +317,10 @@ public List> getMatchesWithTriggers(WithTags input) {
/** Index that limits the search space of expressions based on geometry type of an input element. */
private static class GeometryTypeIndex implements Index {
- private final KeyIndex pointIndex;
- private final KeyIndex lineIndex;
- private final KeyIndex polygonIndex;
- private final KeyIndex otherIndex;
+ private final Index pointIndex;
+ private final Index lineIndex;
+ private final Index polygonIndex;
+ private final Index otherIndex;
private GeometryTypeIndex(MultiExpression expressions, boolean warn) {
// build an index per type then search in each of those indexes based on the geometry type of each input element
@@ -316,14 +331,12 @@ private GeometryTypeIndex(MultiExpression expressions, boolean warn) {
otherIndex = indexForType(expressions, Expression.UNKNOWN_GEOMETRY_TYPE, warn);
}
- private KeyIndex indexForType(MultiExpression expressions, String type, boolean warn) {
- return new KeyIndex<>(
- expressions
- .replace(matchType(type), TRUE)
- .replace(e -> e instanceof Expression.MatchType, FALSE)
- .simplify(),
- warn
- );
+ private Index indexForType(MultiExpression expressions, String type, boolean warn) {
+ return expressions
+ .replace(matchType(type), TRUE)
+ .replace(e -> e instanceof Expression.MatchType, FALSE)
+ .simplify()
+ .index(warn);
}
/**
@@ -354,14 +367,97 @@ public List> getMatchesWithTriggers(WithTags input) {
}
}
+ private abstract static class StringFieldIndex implements Index {
+
+ private final Map> sourceIndex;
+ private final Index allSourcesIndex;
+
+ private StringFieldIndex(MultiExpression expressions, boolean warn, Function extract,
+ Function make) {
+ Set sources = new HashSet<>();
+ for (var expression : expressions.expressions) {
+ expression.expression.visit(e -> {
+ String key = extract.apply(e);
+ if (key != null) {
+ sources.add(key);
+ }
+ });
+ }
+ sourceIndex = HashMap.newHashMap(sources.size());
+ for (var source : sources) {
+ var forThisSource = expressions
+ .replace(make.apply(source), TRUE)
+ .replace(e -> extract.apply(e) != null, FALSE)
+ .simplify()
+ .index(warn);
+ if (!forThisSource.isEmpty()) {
+ sourceIndex.put(source, forThisSource);
+ }
+ }
+ allSourcesIndex = expressions.replace(e -> extract.apply(e) != null, FALSE).simplify().index(warn);
+ }
+
+ abstract String extract(WithTags input);
+
+ /**
+ * Returns all data values associated with expressions that match an input element, along with the tag keys that
+ * caused the match.
+ */
+ public List> getMatchesWithTriggers(WithTags input) {
+ List> result = null;
+ String key = extract(input);
+ if (key != null) {
+ var index = sourceIndex.get(key);
+ if (index != null) {
+ result = index.getMatchesWithTriggers(input);
+ }
+ }
+ if (result == null) {
+ result = allSourcesIndex.getMatchesWithTriggers(input);
+ }
+ result.sort(BY_ID);
+ return result;
+ }
+ }
+
+ /** Index that limits the search space of expressions based on geometry type of an input element. */
+ private static class SourceLayerIndex extends StringFieldIndex {
+
+ private SourceLayerIndex(MultiExpression expressions, boolean warn) {
+ super(expressions, warn,
+ e -> e instanceof Expression.MatchSourceLayer(var layer) ? layer : null,
+ Expression::matchSourceLayer);
+ }
+
+ @Override
+ String extract(WithTags input) {
+ return input instanceof SourceFeature feature ? feature.getSourceLayer() : null;
+ }
+ }
+
+ /** Index that limits the search space of expressions based on geometry type of an input element. */
+ private static class SourceIndex extends StringFieldIndex {
+
+ private SourceIndex(MultiExpression expressions, boolean warn) {
+ super(expressions, warn,
+ e -> e instanceof Expression.MatchSource(var source) ? source : null,
+ Expression::matchSource);
+ }
+
+ @Override
+ String extract(WithTags input) {
+ return input instanceof SourceFeature feature ? feature.getSource() : null;
+ }
+ }
+
/** An expression/value pair with unique ID to store whether we evaluated it yet. */
- private record EntryWithId (T result, Expression expression, @Override int id) implements WithId {}
+ private record EntryWithId(T result, Expression expression, @Override int id) implements WithId {}
/**
* An {@code expression} to evaluate on input elements and {@code result} value to return when the element matches.
*/
- public record Entry (T result, Expression expression) {}
+ public record Entry(T result, Expression expression) {}
/** The result when an expression matches, along with the input element tag {@code keys} that triggered the match. */
- public record Match (T match, List keys, @Override int id) implements WithId {}
+ public record Match(T match, List keys, @Override int id) implements WithId {}
}
diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/reader/SourceFeature.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/reader/SourceFeature.java
index 4f32a398db..b5c71f0f6a 100644
--- a/planetiler-core/src/main/java/com/onthegomap/planetiler/reader/SourceFeature.java
+++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/reader/SourceFeature.java
@@ -65,19 +65,6 @@ protected SourceFeature(Map tags, String source, String sourceLa
this.id = id;
}
- // slight optimization: replace default implementation with direct access to the tags
- // map to get slightly improved performance when matching elements against expressions
-
- @Override
- public Object getTag(String key) {
- return tags.get(key);
- }
-
- @Override
- public boolean hasTag(String key) {
- return tags.containsKey(key);
- }
-
@Override
public Map tags() {
diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/reader/Struct.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/reader/Struct.java
index cb86c7fe15..c5e599e1d5 100644
--- a/planetiler-core/src/main/java/com/onthegomap/planetiler/reader/Struct.java
+++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/reader/Struct.java
@@ -56,14 +56,14 @@ static Struct of(Object o) {
result.put(e.getKey(), v);
}
}
- yield new MapStruct(result);
+ yield new MapStruct(result, map);
}
case Collection> collection -> {
List result = new ArrayList<>(collection.size());
for (var d : collection) {
result.add(of(d));
}
- yield new ListStruct(result);
+ yield new ListStruct(result, collection);
}
default -> throw new IllegalArgumentException("Unable to convert " + o + " (" + o.getClass() + ")");
};
@@ -253,22 +253,29 @@ default Struct flatMap(UnaryOperator mapper) {
.flatMap(item -> mapper.apply(item).asList().stream())
.map(Struct::of)
.toList();
- return list.isEmpty() ? NULL : new ListStruct(list);
+ var raw = list.stream().map(Struct::rawValue).toList();
+ return list.isEmpty() ? NULL : new ListStruct(list, raw);
}
class PrimitiveStruct implements Struct {
final T value;
+ private final Object raw;
private String asJson;
- PrimitiveStruct(T value) {
+ PrimitiveStruct(T value, Object raw) {
this.value = value;
+ this.raw = raw;
+ }
+
+ PrimitiveStruct(T value) {
+ this(value, value);
}
@Override
public final Object rawValue() {
- return value;
+ return raw;
}
@Override
@@ -521,8 +528,8 @@ public String asString() {
class MapStruct extends PrimitiveStruct