fix(geocoding): index the local place-name prefix search

UPPER("name") LIKE UPPER(?) could never use the plain index on "name",
so every local search did a full table scan over ~170k reference
rows on TaskService's single-threaded DATABASE pool. Add a generated
name_upper column with its own index and query that instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016okXfGz39FtQawFWDYpQ5C
This commit is contained in:
2026-09-20 23:43:00 -04:00
co-authored by Claude Sonnet 5
parent 77522de02d
commit 07f8e962d0
2 changed files with 22 additions and 4 deletions
@@ -69,9 +69,19 @@ public class GeoReferenceDatabase {
)
""");
jdbc.execute("CREATE INDEX IF NOT EXISTS \"ix_place_lat_lon\" ON \"place\" (\"latitude\", \"longitude\")");
// Backs PlaceReferenceQueryService.search's prefix lookup — a plain B-tree still helps a LIKE
// 'x%' scan, unlike the trailing-wildcard search this table has no use case for.
// No longer what backs PlaceReferenceQueryService's prefix search (see ix_place_name_upper below) —
// kept only in case something else ever needs a plain, case-sensitive lookup on "name" itself.
jdbc.execute("CREATE INDEX IF NOT EXISTS \"ix_place_name\" ON \"place\" (\"name\")");
// H2 cannot use a plain index on "name" to accelerate a predicate wrapped in UPPER("name") — it had
// been doing a full table scan over all ~170k rows on every search (confirmed via EXPLAIN), which
// is exactly what made typing/deleting in "Add a location" feel like it froze the more you typed:
// every settled keystroke re-scanned the whole table on TaskService's single-threaded DATABASE pool.
// A generated column actually stores UPPER("name") per row, so a real index can be built on it —
// ix_place_name_upper turns that scan into an index range lookup. IF NOT EXISTS on both statements
// makes this a one-time cost (computed for every existing row) the first time an already-populated
// database is opened after this change, never again after.
jdbc.execute("ALTER TABLE \"place\" ADD COLUMN IF NOT EXISTS \"name_upper\" VARCHAR(200) GENERATED ALWAYS AS (UPPER(\"name\"))");
jdbc.execute("CREATE INDEX IF NOT EXISTS \"ix_place_name_upper\" ON \"place\" (\"name_upper\")");
}
/** The reference database's own {@link JdbcTemplate} — never a Spring bean, see the class javadoc. */
@@ -5,6 +5,7 @@ import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Locale;
/**
* Read-only access to the reference {@code "place"} table for the Maintenance geocoding panel — the
@@ -44,6 +45,13 @@ public class PlaceReferenceQueryService {
* Places whose name starts with {@code query} (case-insensitive), most populous first — backs the
* "add a place" search in {@code GeoLocationEditView}. Blank input finds nothing rather than every
* row: a fresh search field with no text yet must not dump the whole table into a dropdown.
*
* <p>Filters on {@code "name_upper"} (a generated {@code UPPER("name")} column, see
* {@link GeoReferenceDatabase}) rather than wrapping {@code "name"} in {@code UPPER(...)} at query
* time — H2 cannot use a plain index on {@code "name"} to accelerate a predicate on a function of it,
* so that shape was a full table scan over every one of the ~170k reference rows on every call. The
* query string is upper-cased here instead, in Java, so the bind parameter matches the stored column
* directly and the index on it actually gets used.
*/
public List<PlaceRow> searchByNamePrefix(String query, int limit) {
if (query.isBlank()) {
@@ -53,11 +61,11 @@ public class PlaceReferenceQueryService {
"""
SELECT "id", "name", "country_code", "admin1_code", "latitude", "longitude", "population"
FROM "place"
WHERE UPPER("name") LIKE UPPER(?) ESCAPE '\\'
WHERE "name_upper" LIKE ? ESCAPE '\\'
ORDER BY "population" DESC NULLS LAST
LIMIT ?
""",
ROW_MAPPER, escapeLikeWildcards(query.strip()) + "%", limit);
ROW_MAPPER, escapeLikeWildcards(query.strip()).toUpperCase(Locale.ROOT) + "%", limit);
}
/** {@code _} and {@code %} are LIKE wildcards; a place name containing either must match literally. */