NAME
Database::BI::Model::Geocode - Resolve a location column to coordinates, once per distinct value
VERSION
0.013.0
SYNOPSIS
Geocode a dataset's place column, with the counts a page needs:
use Database::BI::Model::Geocode;
my $geocode = Database::BI::Model::Geocode->new();
my $result = $geocode->resolve_column($rows, 'place');
for my $marker (@{ $result->{'markers'} }) {
$map->add_marker([$marker->{'lat'}, $marker->{'lon'}], html => $popup);
}
my $counts = $result->{'counts'};
# { rows => 401, plotted => 3, unusable => 398, distinct => 7, skipped => 4,
# cached => 1, attempted => 2, lookups => 2, resolved => 3, unresolved => 4,
# not_attempted => 0 }
print "$_->{'text'}\n" for @{ $result->{'messages'} };
Remember the answers in the dataset's model store, so the next request is free:
my $spec = 'table:sales|loc=place';
my $cache = Database::BI::Model::Geocode->cache_from_models(\%models, $spec);
my $result = $geocode->resolve_values(\@places, cache => $cache);
my $models = Database::BI::Model::Geocode->models_with_cache(
\%models, $spec, $result->{'cache'},
backend => $result->{'backend'},
built => $now,
);
Ask whether the panel should offer the option at all:
my ($available, $reason) = $geocode->geocoder_available();
# (0, 'Geocoding a location column needs Geo::Coder::Free 0.47 or later, ...')
DESCRIPTION
Geo::Coder::Free resolves a place to a Geo::Location::Point. A table column holds something less tidy than a place: names repeated thousands of times, blank cells, values that are not places at all. This module does the three things the column then needs:
- Ask once per distinct value.
-
The column is reduced to a set first. A repeated value costs one lookup, so a 100,000-row file holding a dozen places costs a dozen lookups, not 100,000.
- Refuse a value that is not a location before any backend sees it.
-
$geocoder->valid_location($value)is the rule the guard insidegeocode()and the backends themselves apply: false forundef, the empty string, a reference, and a string of digits only. Answering it here means a bare number in the wrong column is counted and reported rather than looked up, and the count of lookups stays the count of work the request did. - Look the rest up without letting one value end the batch.
-
The batch is made with
on_error => 'undef'. A value the geocoder refuses after the pre-filter is a miss; a backend that throws is still a throw, because nothing here is wrapped in aneval.
The counts add up, in every path through the module: skipped + cached + attempted + not_attempted == distinct, and resolved + unresolved == cached + attempted. lookups is the count the geocoder itself reports for the batch, which is attempted unless the geocoder knows something finer.
The cache
A cache entry is a [lat, lon] arrayref for a place that resolved, or 0 for a place that was looked up and not found - the shape section 6.3 gives the geo section of .bi/models.json. That 0 is what stops a column of unresolvable values from being retried on every request, and it is why unresolved can be reported when no lookup happened.
The cache is passed per call and returned per call; the caller's hashref is never modified, so a request that dies half way through cannot leave a half-written cache behind. Persisting it is the controller's business, which keeps the model_dir-inside-data_dir guard in the one place that already owns it (section 7: no write to data_dir, ever). A cache that has been remembered is also used when no geocoder can be loaded at all: the places already resolved are still plotted, and only new lookups are unavailable.
Without the geocoder
Geo::Coder::Free is a recommended dependency, not a required one, and it is loaded when it is first needed rather than at compile time. If it is missing, or older than the floor, nothing is looked up and nothing croaks: available is false, error_map_geocoder_missing says which version is needed and which is installed, and the caller maps explicit coordinate columns instead.
CONSTRUCTOR
new
my $geocode = Database::BI::Model::Geocode->new(%options);
API SPECIFICATION
INPUT
geocoder => object, # optional; must provide valid_location() and geocode_many()
openaddr => string, # optional; OpenAddresses data path (default: $ENV{OPENADDR_HOME})
min_geocoder_version => string, # optional; default 0.47
i18n => object, # optional; must implement maketext($key, @args)
geocoder is the seam the tests use, and the only way to resolve with something other than Geo::Coder::Free. An injected geocoder is used exactly as given - it is not version-checked, which is what lets a stub stand in for the module.
min_geocoder_version is injectable so a caller can pin the floor, and so the too-old path can be tested on a machine that has the module installed.
Accepts a flat key/value list or a hashref, via Params::Get.
OUTPUT
Returns $self. Croaks on an injected geocoder that cannot answer valid_location() or geocode_many() (error_geocoder_interface): a geocoder that cannot answer the rule is a programming error, unlike a geocoder that is not installed.
SUBROUTINES/METHODS
resolve_column
my $result = $geocode->resolve_column($rows, $column, %options);
Resolve one column of a table: $rows is an arrayref of hashrefs as the pipeline produces them, and $column names the one holding the places.
API SPECIFICATION
INPUT
$rows => arrayref of hashrefs, required
$column => string, required
cache => hashref, # optional; value => [lat, lon] | 0
region => string, # optional; two-letter country hint for the geocoder
max => integer, # optional; new lookups allowed, default 500
max is map_geocode_max: new lookups, not rows and not cached answers. A value already in cache costs nothing and does not count against it.
region is passed straight through; it is not derived from the request's language, because a language is not a country.
OUTPUT
Returns a hashref:
{
available => 1, # 0 when no geocoder could be loaded
points => { $value => { lat, lon, rows, first_row, backend } },
markers => [ { value, lat, lon, rows, first_row } ], # first-seen order
counts => { rows, plotted, unusable, distinct, skipped, cached,
attempted, lookups, resolved, unresolved, not_attempted },
skipped => [ $value ], # not a location: never looked up
unresolved => [ $value ], # looked up (now or before) and not found
not_attempted => [ $value ], # held back by the cap
messages => [ { key, text } ],
cache => { $value => [lat, lon] | 0 },
backends => { 'OpenAddresses' => 3 },
backend => 'OpenAddresses', # undef when nothing resolved
capped => 0,
}
points holds one entry per value that produced a position - whether it was looked up now or remembered from an earlier request - and markers is the same in first-seen order, which is the order a map reads better in. A value that was plotted from the cache carries no backend, because the cache remembers a position and not which dataset gave it; only the places looked up in this request name one. rows on an entry is how many rows of the column hold that value, so an aggregated marker's popup can say how many events are at it without recounting; first_row is the index of the first of them.
A cell that is undef, empty, or a reference is in skipped: no module is needed to know it is not a location. A value the geocoder's valid_location() refused is in skipped too, since that method is the rule. unresolved holds the places that were looked up - now or in an earlier request, from the cache - and found nothing; not_attempted holds the ones the cap held back, which are deliberately *not* recorded in the cache, so the next request or the warm script picks them up. When the geocoder is unavailable, every non-empty value is in not_attempted: nothing is refused on its behalf, and nothing is guessed at.
MESSAGES
error_map_loc_column -- the named column is in none of the rows
error_map_geocoder_missing -- no geocoder, or one older than the floor
warn_map_rows_skipped -- some rows have no plottable location
warn_map_geocode_capped -- the cap held some places back
warn_map_geocoder_unavailable_data -- no OPENADDR_HOME, so coarse answers only
EXAMPLE
my $result = $geocode->resolve_column($rows, 'place', region => 'GB', max => 500);
for my $message (grep { $_->{'key'} eq 'warn_map_rows_skipped' } @{ $result->{'messages'} }) {
warn $message->{'text'};
}
resolve_values
my $result = $geocode->resolve_values(\@values, %options);
The same resolution without rows: \@values is a list of cell values, in any order and with any repeats, and the returned hashref is the one resolve_column() is built on, without rows/plotted/unusable, without markers, and without the row-level warn_map_rows_skipped message. This is what a warming script wants: every distinct value of a column, resolved and reported as counts.
geocoder_available
my ($available, $reason) = $geocode->geocoder_available();
Whether this object can resolve anything new, and why not when it cannot. It loads the geocoder if that has not happened yet, and never croaks: a panel asks this to decide whether to offer the location option at all, so the answer is a sentence about the installed module rather than an exception.
cache_from_models
my $cache = Database::BI::Model::Geocode->cache_from_models(\%models, $spec_key);
The remembered places of one dataset spec, as a copy, from a model store in the shape _read_models() returns. An unknown spec, or one that has never been geocoded, gives an empty hashref.
models_with_cache
$models = Database::BI::Model::Geocode->models_with_cache(
\%models, $spec_key, $cache, backend => 'OpenAddresses', built => $iso8601,
);
A new model store in which those places are recorded under geo => { $spec_key => { places, backend, built } }, leaving every other key, every other spec, and the caller's hashref untouched. This module never writes the file: it returns the structure for the controller to hand to _write_models(), which is where the writability and data_dir guards live. built is passed in rather than taken from the clock, so a caller decides what time it was.
resolve_values
resolve_column
geocoder_available
cache_from_models
models_with_cache
LICENCE AND COPYRIGHT
Copyright 2026 Nigel Horne.
Usage is subject to the GPL2 licence terms. If you use it, please let me know.