lib/Geo/Coder/List.pm

Structural Coverage (Approximate)

TER1 (Statement): 97.80%
TER2 (Branch): 90.28%
TER3 (LCSAJ): 89.5% (17/19)
Approximate LCSAJ segments: 289

LCSAJ Legend

โ— Covered โ€” this LCSAJ path was executed during testing.

โ— Not covered โ€” this LCSAJ path was never executed. These are the paths to focus on.

Multiple dots on a line indicate that multiple control-flow paths begin at that line. Hovering over any dot shows:

        start โ†’ end โ†’ jump
        

Uncovered paths show [NOT COVERED] in the tooltip.

Mutant Testing Legend

Survived (tests missed this) Killed (tests detected this) No mutation
    1: package Geo::Coder::List;
    2: 
    3: # Geo::Coder::List - Aggregate and chain multiple geocoding backends
    4: 
    5: use 5.10.1;
    6: 
    7: use strict;
    8: use warnings;
    9: use autodie qw(:all);
   10: 
   11: use Carp;
   12: use Data::Dumper;
   13: use HTML::Entities;
   14: use Object::Configure 0.13;
   15: use Params::Get 0.04;
   16: use Readonly;
   17: use Scalar::Util qw(blessed);
   18: use Time::HiRes;
   19: 
   20: =head1 NAME
   21: 
   22: Geo::Coder::List - Call many Geo-Coders
   23: 
   24: =head1 VERSION
   25: 
   26: Version 0.38
   27: 
   28: =cut
   29: 
   30: our $VERSION = '0.38';
   31: 
   32: # ── Module-level constants (not user-configurable) ────────────────────────────
   33: 
   34: # Default verbosity: 0 = silent, 1 = basic trace, 2 = full Data::Dumper dumps
   35: use constant DEBUG_DEFAULT => 0;
   36: 
   37: # Internal sentinel class: a cached not-found result stored in L1.
   38: # Using a blessed ref lets _cache() distinguish "never looked up" from
   39: # "looked up and found nothing" without requiring a special undef convention.
   40: use constant _NOT_FOUND_CLASS => __PACKAGE__ . '::_NotFound';
   41: 
   42: # The singleton value stored in L1 when a location is confirmed missing
   43: my $NOT_FOUND_SENTINEL = bless {}, _NOT_FOUND_CLASS;
   44: 
   45: # String placed in the 'geocoder' field of a result served from cache
   46: Readonly::Scalar my $CACHE_SOURCE => 'cache';
   47: 
   48: # String used as the 'result' value in log entries for a geocoder miss
   49: Readonly::Scalar my $RESULT_NONE => 'not found';
   50: 
   51: # ── Configurable defaults ─────────────────────────────────────────────────────
   52: #
   53: # Any key here can be overridden at run time via an environment variable named
   54: # GEO__CODER__LIST__<key>, which Object::Configure reads automatically in new().
   55: # Example:
   56: #   export GEO__CODER__LIST__cache_hit_duration='7 days'
   57: 
   58: my %config = (
   59: 	debug               => DEBUG_DEFAULT,
   60: 	# How long a confirmed location stays in the L2 cache
   61: 	cache_hit_duration  => '1 month',
   62: 	# How long a transient/partial failure is cached (retry tomorrow)
   63: 	cache_part_duration => '1 day',
   64: 	# How long a definite not-found is cached (place probably does not exist)
   65: 	cache_miss_duration => '1 week',
   66: );
   67: 
   68: # =============================================================================
   69: # PUBLIC API
   70: # =============================================================================
   71: 
   72: =head1 SYNOPSIS
   73: 
   74: L<Geo::Coder::All> and L<Geo::Coder::Many> are great modules but neither
   75: quite does what I want.
   76: 
   77: C<Geo::Coder::List> aggregates multiple geocoding services into a single,
   78: unified interface.  It chains and prioritizes backends based on regex routing
   79: and per-geocoder query limits, caches results at two levels (L1 in-memory
   80: always; optional L2 via CHI or a plain HASH), and normalizes every provider's
   81: idiosyncratic response into the common structure expected by
   82: L<HTML::GoogleMaps::V3> and L<HTML::OSM>:
   83: 
   84:     $result->{geometry}{location}{lat}   # canonical latitude
   85:     $result->{geometry}{location}{lng}   # canonical longitude
   86:     $result->{geocoder}                  # source object (or 'cache')
   87: 
   88:     use Geo::Coder::List;
   89:     use Geo::Coder::OSM;
   90:     use Geo::Coder::CA;
   91: 
   92:     my $list = Geo::Coder::List->new()
   93:         ->push({ regex => qr/(Canada|USA)$/, geocoder => Geo::Coder::CA->new() })
   94:         ->push(Geo::Coder::OSM->new());
   95: 
   96:     my $loc = $list->geocode('10 Downing St, London, UK');
   97:     printf "lat=%.4f lng=%.4f\n",
   98:         $loc->{geometry}{location}{lat},
   99:         $loc->{geometry}{location}{lng};
  100: 
  101: =head1 SUBROUTINES/METHODS
  102: 
  103: =head2 new
  104: 
  105: Creates a new C<Geo::Coder::List> object.  When called on an existing object
  106: it returns a clone of that object merged with the supplied arguments.
  107: 
  108: The constructor reads configuration from environment variables via
  109: L<Object::Configure>; for example, setting
  110: C<GEO__CODER__LIST__carp_on_warn=1> causes warnings to use L<Carp>.
  111: 
  112:     use Geo::Coder::List;
  113:     use CHI;
  114: 
  115:     # With an optional L2 cache (any CHI driver works)
  116:     my $geocoder = Geo::Coder::List->new(
  117:         cache => CHI->new(driver => 'Memory', global => 1),
  118:         debug => 0,
  119:     );
  120: 
  121:     # Clone an existing object with a higher debug level
  122:     my $verbose = $geocoder->new(debug => 2);
  123: 
  124: =head3 API SPECIFICATION
  125: 
  126: =head4 INPUT
  127: 
  128:     # Params::Validate::Strict schema
  129:     {
  130:         cache => {
  131:             type     => [ 'hashref', 'object' ],	# OBJECT must implement get($key) and set($key, $value, $ttl)
  132:             optional => 1,
  133:         },
  134:         debug => {
  135:             type     => 'boolean',
  136:             optional => 1,
  137:             default  => 0,
  138:         },
  139:         # Any additional key is forwarded to Object::Configure
  140:     }
  141: 
  142: =head4 OUTPUT
  143: 
  144:     # Return::Set schema
  145:     OBJECT blessed into Geo::Coder::List
  146: 
  147: =cut
  148: 
  149: sub new
  150: {
โ—151 โ†’ 155 โ†’ 170  151: 	my $class = shift;
  152: 	my $params = Params::Get::get_params(undef, \@_) || {};
  153: 
  154: 	# Handle the rare Geo::Coder::List::new() (function-style) invocation
  155: 	if(!defined($class)) {

Mutants (Total: 1, Killed: 1, Survived: 0)

156: if(scalar keys %{$params} > 0) {

Mutants (Total: 4, Killed: 4, Survived: 0)

157: # Using ::new() with arguments is not supported 158: carp(__PACKAGE__, ' use ->new() not ::new() to instantiate'); 159: return; 160: } 161: # FIXME: cloning does not work when called as ::new() with arguments 162: $class = __PACKAGE__; 163: } elsif(blessed($class)) { 164: # Shallow clone merged with new params; log is always fresh so the 165: # clone starts with an empty event history independent of the original 166: return bless { %{$class}, %{$params}, log => [] }, ref($class);

Mutants (Total: 2, Killed: 2, Survived: 0)

167: } 168: 169: # Let Object::Configure overlay defaults from environment / config files โ—170 โ†’ 173 โ†’ 179 170: $params = Object::Configure::configure($class, $params); 171: 172: # Fill in any %config defaults the caller did not explicitly supply 173: for my $key (keys %config) { 174: $params->{$key} //= $config{$key}; 175: } 176: 177: # Bless and return; params override scalar defaults but locations/log 178: # are always initialised fresh so callers cannot inject stale state 179: return bless {

Mutants (Total: 2, Killed: 2, Survived: 0)

180: debug => DEBUG_DEFAULT, 181: geocoders => [], 182: %{$params}, 183: locations => {}, 184: log => [], 185: }, $class; 186: } 187: 188: # ============================================================================= 189: 190: =head2 push 191: 192: Appends a geocoder to the chain. Geocoders are tried in the order they 193: were pushed. Returns C<$self> so calls can be chained. 194: 195: A plain geocoder object is tried for every location. A hashref with 196: C<regex>, C<geocoder>, and optional C<limit> keys restricts the geocoder to 197: locations matching the regex and caps total queries at C<limit>. 198: 199: my $list = Geo::Coder::List->new() 200: ->push({ regex => qr/USA$/, geocoder => Geo::Coder::CA->new(), limit => 100 }) 201: ->push(Geo::Coder::OSM->new()); 202: 203: =head3 API SPECIFICATION 204: 205: =head4 INPUT 206: 207: # Params::Validate::Strict schema 208: { 209: geocoder => { 210: type => OBJECT | HASHREF, 211: required => 1, 212: # HASHREF must contain: geocoder => OBJECT 213: # HASHREF may contain: regex => Regexp 214: # limit => SCALAR (positive integer) 215: }, 216: } 217: 218: =head4 OUTPUT 219: 220: # Return::Set schema 221: OBJECT blessed into Geo::Coder::List # $self, for chaining 222: 223: =cut 224: 225: sub push 226: { 227: # Deliberately NOT using Params::Get here: passing the hashref through it 228: # would stringify the compiled qr// object, destroying the regex. 229: my ($self, $geocoder) = @_; 230: 231: # A geocoder argument is mandatory 232: croak(__PACKAGE__, '::push: Usage: ($geocoder)') unless defined($geocoder); 233: 234: # Append to the ordered chain and return $self for chaining 235: CORE::push @{$self->{geocoders}}, $geocoder; 236: 237: return $self;

Mutants (Total: 2, Killed: 2, Survived: 0)

238: } 239: 240: # ============================================================================= 241: 242: =head2 geocode 243: 244: Resolves a location string to geographic coordinates by trying each geocoder 245: in turn. The first successful result is returned and cached. 246: 247: In scalar context returns a single hashref (or C<undef> on failure). 248: In list context returns all results from the winning geocoder. 249: 250: The C<geocoder> field of the returned hashref holds the geocoder object that 251: supplied the result; it is set to the string C<'cache'> when the result was 252: served from cache. 253: 254: See L<Geo::Coder::GooglePlaces::V3> for the canonical result structure. 255: 256: my $result = $list->geocode(location => 'Paris, France'); 257: if($result) { 258: printf "lat=%.4f lng=%.4f via %s\n", 259: $result->{geometry}{location}{lat}, 260: $result->{geometry}{location}{lng}, 261: ref($result->{geocoder}) || $result->{geocoder}; 262: } 263: 264: # List context returns all candidates from the winning geocoder 265: my @results = $list->geocode('London, UK'); 266: 267: =head3 API SPECIFICATION 268: 269: =head4 INPUT 270: 271: # Params::Validate::Strict schema 272: { 273: location => { 274: type => SCALAR, 275: required => 1, 276: # Must contain at least one non-digit character 277: }, 278: } 279: 280: =head4 OUTPUT 281: 282: # Return::Set schema (scalar context) 283: HASHREF | undef 284: { 285: geometry => { location => { lat => Num, lng => Num } }, 286: geocoder => OBJECT | 'cache', 287: lat => Num, # convenience alias 288: lng => Num, # convenience alias 289: lon => Num, # compatibility alias for lng 290: debug => Int, # source line of the normalisation branch taken 291: # ... provider-specific keys are preserved 292: } 293: 294: # Return::Set schema (list context) 295: ARRAY of the above HASHREFs 296: 297: =cut 298: 299: sub geocode { โ—300 โ†’ 305 โ†’ 310 300: my $self = shift; 301: 302: # Params::Get enforces 'location'; calling geocode() with no args causes 303: # get_params itself to croak with "Usage:" matching t/carp.t expectations 304: my $params = Params::Get::get_params('location', @_); 305: if(!defined($params)) {

Mutants (Total: 1, Killed: 1, Survived: 0)

306: $self->_error(__PACKAGE__, ' usage: geocode(location => $location)'); 307: return; 308: } 309: โ—310 โ†’ 313 โ†’ 319 310: my $location = $params->{'location'}; 311: 312: # Reject empty or whitespace-only location strings 313: if((!defined($location)) || (length($location) == 0)) {

Mutants (Total: 2, Killed: 2, Survived: 0)

314: $self->_warn(__PACKAGE__, ' usage: geocode(location => $location)'); 315: return; 316: } 317: 318: # A purely numeric string is almost certainly an error (e.g. a bare postcode) โ—319 โ†’ 319 โ†’ 324 319: if($params->{'location'} !~ /\D/) {

Mutants (Total: 1, Killed: 1, Survived: 0)

320: $self->_error('Usage: ', __PACKAGE__, ': invalid input to geocode(), ', $params->{location}); 321: } 322: 323: # Collapse runs of whitespace and expand any HTML entities โ—324 โ†’ 340 โ†’ 394 324: $location =~ s/\s\s+/ /g; 325: $location = decode_entities($location); 326: # Propagate the cleaned-up string so geocoders also receive the decoded form 327: $params->{'location'} = $location; 328: 329: print "location: $location\n" if($self->{'debug'}); 330: 331: # Capture the caller's line number once for all log entries in this call 332: my @call_details = caller(0); 333: 334: # ── L1 / L2 cache lookup ────────────────────────────────────────────── 335: 336: # _cache() returns undef for both "not in cache" and "cached not-found"; 337: # the not-found sentinel is handled internally so callers see undef either way 338: my $cached = $self->_cache($location); 339: 340: if(defined $cached) {

Mutants (Total: 1, Killed: 1, Survived: 0)

341: # A defined value means we have a genuine cached positive result 342: my @rc = ref($cached) eq 'ARRAY' ? @{$cached} : ($cached); 343: 344: # Mark every element as coming from cache. Shallow-copy HASH results 345: # first so that neither the L1 cache entry nor any caller-held reference 346: # to the same hashref is mutated in place. 347: for my $r (@rc) { 348: next unless ref($r); 349: $r = { %{$r} } if ref($r) eq 'HASH'; 350: $r->{'geocoder'} = $CACHE_SOURCE; 351: } 352: 353: # Scalar context: return the first (and usually only) element 354: if(!wantarray) {

Mutants (Total: 1, Killed: 1, Survived: 0)

355: my $rc = $rc[0]; 356: CORE::push @{$self->{'log'}}, { 357: line => $call_details[2], 358: location => $location, 359: timetaken => 0, 360: geocoder => $CACHE_SOURCE, 361: wantarray => 0, 362: result => $rc, 363: }; 364: print __PACKAGE__, ': ', __LINE__, ": cached\n" if($self->{'debug'}); 365: return $rc;

Mutants (Total: 2, Killed: 2, Survived: 0)

366: } 367: 368: # List context: return all cached candidates 369: CORE::push @{$self->{'log'}}, { 370: line => $call_details[2], 371: location => $location, 372: timetaken => 0, 373: geocoder => $CACHE_SOURCE, 374: wantarray => 1, 375: result => \@rc, 376: }; 377: print __PACKAGE__, ': ', __LINE__, ": cached\n" if($self->{'debug'}); 378: 379: # Determine if every element is empty; if so return nothing 380: my $allempty = 1; 381: for my $r (@rc) { 382: if(ref($r) eq 'HASH') {

Mutants (Total: 1, Killed: 1, Survived: 0)

383: $allempty = 0 if defined $r->{geometry}{location}{lat}; 384: } elsif(ref($r) eq 'Geo::Location::Point') { 385: $allempty = 0; 386: } 387: } 388: return if $allempty; 389: return @rc;

Mutants (Total: 2, Killed: 2, Survived: 0)

390: } 391: 392: # Also check if this location is cached as a definite not-found in L1, 393: # without going through _cache() (which masks the sentinel as undef) โ—394 โ†’ 394 โ†’ 404 394: if(exists $self->{'locations'}{$location}) {

Mutants (Total: 1, Killed: 1, Survived: 0)

395: my $stored = $self->{'locations'}{$location}; 396: if(ref($stored) && ref($stored) eq _NOT_FOUND_CLASS) {

Mutants (Total: 1, Killed: 1, Survived: 0)

397: print "No matches (cached)\n" if($self->{'debug'}); 398: return wantarray ? () : undef;

Mutants (Total: 2, Killed: 2, Survived: 0)

399: } 400: } 401: 402: # ── Try each geocoder in turn ───────────────────────────────────────── 403: โ—404 โ†’ 404 โ†’ 760 404: ENCODER: foreach my $g (@{$self->{geocoders}}) { 405: my $geocoder = $g; 406: 407: # Unpack a hashref entry and apply regex / limit guards 408: if(ref($geocoder) eq 'HASH') {

Mutants (Total: 1, Killed: 1, Survived: 0)

409: # Decrement and check the per-geocoder query limit 410: if(exists($geocoder->{'limit'}) && defined(my $limit = $geocoder->{'limit'})) {

Mutants (Total: 1, Killed: 1, Survived: 0)

411: print "limit: $limit\n" if($self->{'debug'}); 412: if($limit <= 0) {

Mutants (Total: 4, Killed: 4, Survived: 0)

413: next; 414: } 415: $geocoder->{'limit'}--; 416: } 417: 418: # Skip this entry if the location does not match its regex 419: if(my $regex = $geocoder->{'regex'}) {

Mutants (Total: 1, Killed: 1, Survived: 0)

420: print 'consider ', ref($geocoder->{geocoder}), ": $regex\n" 421: if($self->{'debug'}); 422: if($location !~ $regex) {

Mutants (Total: 1, Killed: 1, Survived: 0)

423: next; 424: } 425: } 426: 427: # Unwrap the actual geocoder object from the hashref 428: $geocoder = $g->{'geocoder'}; 429: } 430: 431: # Start timing before the network call 432: my @rc; 433: my $timetaken = Time::HiRes::time(); 434: 435: eval { 436: # Geo::GeoNames uses a positional argument, not a hash 437: print 'trying ', ref($geocoder), "\n" if($self->{'debug'}); 438: if(ref($geocoder) eq 'Geo::GeoNames') {

Mutants (Total: 1, Killed: 1, Survived: 0)

439: print 'username => ', $geocoder->username(), "\n" 440: if($self->{'debug'}); 441: die 'lost username' if(!defined($geocoder->username())); 442: @rc = $geocoder->geocode($location); 443: } else { 444: @rc = $geocoder->geocode(%{$params}); 445: } 446: }; 447: 448: if($@) {

Mutants (Total: 1, Killed: 1, Survived: 0)

449: # Log the failure and move on; do not abort the whole chain 450: my $log = { 451: line => $call_details[2], 452: location => $location, 453: geocoder => ref($geocoder), 454: timetaken => Time::HiRes::time() - $timetaken, 455: wantarray => wantarray, 456: error => $@, 457: }; 458: CORE::push @{$self->{'log'}}, $log; 459: $self->_warn(ref($geocoder), " '$location': $@"); 460: next ENCODER; 461: } 462: 463: $timetaken = Time::HiRes::time() - $timetaken; 464: 465: # Geo::Coder::US::Census sometimes returns a truthy but empty result 466: if((ref($geocoder) eq 'Geo::Coder::US::Census') &&

Mutants (Total: 1, Killed: 1, Survived: 0)

467: !(defined($rc[0]->{result}{addressMatches}[0]->{coordinates}{y}))) { 468: my $log = { 469: line => $call_details[2], 470: location => $location, 471: timetaken => $timetaken, 472: geocoder => ref($geocoder), 473: wantarray => wantarray, 474: result => $RESULT_NONE, 475: }; 476: CORE::push @{$self->{'log'}}, $log; 477: next ENCODER; 478: } 479: 480: # Reject empty result sets and trivially empty hashes / arrays 481: if((scalar(@rc) == 0) ||

Mutants (Total: 2, Killed: 2, Survived: 0)

482: ((ref($rc[0]) eq 'HASH') && (scalar(keys %{$rc[0]}) == 0)) ||

Mutants (Total: 1, Killed: 1, Survived: 0)

483: # UNREACHABLE: if $rc[0] is an ARRAY ref, $rc[0][0] may be undef, 484: # which causes keys(%{undef}) to die under strict refs. No known 485: # geocoder returns an ARRAY-of-ARRAYs with an empty-hash sub-element. 486: # A safe rewrite would guard with: ref($rc[0][0]) eq 'HASH' first. 487: # ((ref($rc[0]) eq 'ARRAY') && (scalar(keys %{$rc[0][0]}) == 0)) || 488: 0) { 489: my $log = { 490: line => $call_details[2], 491: location => $location, 492: timetaken => $timetaken, 493: geocoder => ref($geocoder), 494: wantarray => wantarray, 495: result => $RESULT_NONE, 496: }; 497: CORE::push @{$self->{'log'}}, $log; 498: next ENCODER; 499: } 500: 501: # ── Normalise each candidate result ────────────────────────────── 502: 503: # Track which element was successfully normalised so we return it, 504: # not blindly return $rc[0] when a later element was the good one 505: my $good_result; 506: 507: POSSIBLE_LOCATION: foreach my $l (@rc) { 508: # Geo::GeoNames wraps each result in a one-element array 509: if(ref($l) eq 'ARRAY') {

Mutants (Total: 1, Killed: 1, Survived: 0)

510: # FIXME: only the first element of the sub-array is considered 511: $l = $l->[0]; 512: } 513: 514: # Skip undefined or empty-string candidates 515: if((!defined($l)) || ($l eq '')) {

Mutants (Total: 1, Killed: 1, Survived: 0)

516: my $log = { 517: line => $call_details[2], 518: location => $location, 519: timetaken => $timetaken, 520: geocoder => ref($geocoder), 521: wantarray => wantarray, 522: result => $RESULT_NONE, 523: }; 524: CORE::push @{$self->{'log'}}, $log; 525: next ENCODER; 526: } 527: 528: # Skip bare scalars (e.g. integer 0, plain strings) that are 529: # not references; they cannot be hash-dereferenced below 530: next unless ref($l); 531: 532: # Stamp the source geocoder on the result before normalisation 533: $l->{'geocoder'} = ref($geocoder); 534: 535: print ref($geocoder), ': ', 536: Data::Dumper->new([\$l])->Dump() if($self->{'debug'} >= 2);

Mutants (Total: 3, Killed: 0, Survived: 3)
537: 538: # Geo::Location::Point objects carry their own accessors; 539: # upgrade the geocoder field and populate the canonical geometry 540: # structure so callers can rely on geometry.location.{lat,lng} 541: if(ref($l) eq 'Geo::Location::Point') {

Mutants (Total: 1, Killed: 1, Survived: 0)

542: $l->{'geocoder'} = $geocoder; 543: 544: # Populate canonical geometry structure from the GLP's own fields 545: if(!defined($l->{geometry}{location}{lat}) && defined($l->{lat})) {

Mutants (Total: 1, Killed: 1, Survived: 0)

546: $l->{geometry}{location}{lat} = $l->{lat}; 547: $l->{geometry}{location}{lng} = $l->{lng} // $l->{lon}; 548: } 549: 550: # Convenience aliases (idempotent if already set by GLP) 551: $l->{'lat'} //= $l->{geometry}{location}{lat}; 552: $l->{'lng'} //= $l->{geometry}{location}{lng}; 553: $l->{'lon'} //= $l->{geometry}{location}{lng}; 554: 555: CORE::push @{$self->{'log'}}, { 556: line => $call_details[2], 557: location => $location, 558: timetaken => $timetaken, 559: geocoder => ref($geocoder), 560: wantarray => wantarray, 561: result => $l, 562: }; 563: $good_result = $l; 564: last POSSIBLE_LOCATION; 565: } 566: 567: # Only HASH results need normalisation 568: next if(ref($l) ne 'HASH'); 569: 570: if($l->{'error'}) {

Mutants (Total: 1, Killed: 1, Survived: 0)

571: # A top-level 'error' key signals a provider-level failure 572: my $log = { 573: line => $call_details[2], 574: location => $location, 575: timetaken => $timetaken, 576: geocoder => ref($geocoder), 577: wantarray => wantarray, 578: error => $l->{'error'}, 579: }; 580: CORE::push @{$self->{'log'}}, $log; 581: next ENCODER; 582: } else { 583: # Map provider-specific fields to the canonical geometry structure 584: if(!defined($l->{geometry}{location}{lat})) {

Mutants (Total: 1, Killed: 1, Survived: 0)

585: my ($lat, $long); 586: 587: if(defined($l->{lat}) && defined($l->{lon})) {

Mutants (Total: 1, Killed: 1, Survived: 0)

588: # OSM / RandMcNally: top-level lat/lon fields 589: $lat = $l->{lat}; 590: $long = $l->{lon}; 591: $l->{'debug'} = __LINE__; 592: } elsif($l->{BestLocation}) { 593: # Bing Maps: BestLocation.Coordinates.{Latitude,Longitude} 594: $lat = $l->{BestLocation}->{Coordinates}->{Latitude}; 595: $long = $l->{BestLocation}->{Coordinates}->{Longitude}; 596: $l->{'debug'} = __LINE__; 597: } elsif($l->{point}) { 598: # Bing Maps alternative: point.coordinates[lat, lng] 599: $lat = $l->{point}->{coordinates}[0]; 600: $long = $l->{point}->{coordinates}[1]; 601: $l->{'debug'} = __LINE__; 602: } elsif(defined($l->{latt})) { 603: # geocoder.ca: latt / longt fields 604: $lat = $l->{latt}; 605: $long = $l->{longt}; 606: $l->{'debug'} = __LINE__; 607: } elsif(defined($l->{latitude})) { 608: # postcodes.io, Geo::Coder::Free: latitude / longitude 609: $lat = $l->{latitude}; 610: $long = $l->{longitude}; 611: if(my $type = $l->{'local_type'}) {

Mutants (Total: 1, Killed: 1, Survived: 0)

612: # Carry the local_type hint forward as a normalised 'type' 613: $l->{'type'} = lcfirst($type); 614: } 615: $l->{'debug'} = __LINE__; 616: } elsif(defined($l->{'properties'}{'geoLatitude'})) { 617: # HERE / Ovi: properties.geoLatitude / geoLongitude 618: $lat = $l->{properties}{geoLatitude}; 619: $long = $l->{properties}{geoLongitude}; 620: $l->{'debug'} = __LINE__; 621: } elsif($l->{'results'}[0]->{'geometry'}) { 622: if($l->{'results'}[0]->{'geometry'}->{'location'}) {

Mutants (Total: 1, Killed: 1, Survived: 0)

623: # DataScienceToolkit mirrors the Google Maps shape 624: $lat = $l->{'results'}[0]->{'geometry'}->{'location'}->{'lat'}; 625: $long = $l->{'results'}[0]->{'geometry'}->{'location'}->{'lng'}; 626: $l->{'debug'} = __LINE__; 627: } else { 628: # OpenCage places lat/lng directly under geometry 629: $lat = $l->{'results'}[0]->{'geometry'}->{'lat'}; 630: $long = $l->{'results'}[0]->{'geometry'}->{'lng'}; 631: $l->{'debug'} = __LINE__; 632: } 633: } elsif($l->{'RESULTS'}) { 634: # GeoCodeFarm: RESULTS[0].COORDINATES.{latitude,longitude} 635: $lat = $l->{'RESULTS'}[0]{'COORDINATES'}{'latitude'}; 636: $long = $l->{'RESULTS'}[0]{'COORDINATES'}{'longitude'}; 637: $l->{'debug'} = __LINE__; 638: } elsif(defined($l->{result}{addressMatches}[0]->{coordinates}{y})) { 639: # US Census Bureau: result.addressMatches[0].coordinates.{y,x} 640: $lat = $l->{result}{addressMatches}[0]->{coordinates}{y}; 641: $long = $l->{result}{addressMatches}[0]->{coordinates}{x}; 642: $l->{'debug'} = __LINE__; 643: } elsif(defined($l->{lat})) { 644: # Geo::GeoNames: lat / lng (reached only after lat+lon check fails) 645: $lat = $l->{lat}; 646: $long = $l->{lng}; 647: $l->{'debug'} = __LINE__; 648: } elsif($l->{features}) { 649: if($l->{features}[0]->{center}) {

Mutants (Total: 1, Killed: 1, Survived: 0)

650: # Geo::Coder::Mapbox: center is [lng, lat] 651: $lat = $l->{features}[0]->{center}[1]; 652: $long = $l->{features}[0]->{center}[0]; 653: $l->{'debug'} = __LINE__; 654: } elsif($l->{'features'}[0]{'geometry'}{'coordinates'}) { 655: # Geo::Coder::GeoApify: coordinates is [lng, lat] 656: $lat = $l->{'features'}[0]{'geometry'}{'coordinates'}[1]; 657: $long = $l->{'features'}[0]{'geometry'}{'coordinates'}[0]; 658: $l->{'debug'} = __LINE__; 659: } else { 660: # GeoApify signals not-found via empty features, not an error 661: next ENCODER; 662: } 663: } else { 664: $l->{'debug'} = __LINE__; 665: } 666: 667: if(defined($lat) && defined($long)) {

Mutants (Total: 1, Killed: 1, Survived: 0)

668: # Populate the canonical geometry structure 669: $l->{geometry}{location}{lat} = $lat; 670: $l->{geometry}{location}{lng} = $long; 671: # Compatibility aliases expected by callers 672: $l->{'lat'} = $lat; 673: $l->{'lon'} = $long; 674: } else { 675: # No coordinates extracted; clean up any partial data 676: delete $l->{'geometry'}; 677: delete $l->{'lat'}; 678: delete $l->{'lon'}; 679: } 680: 681: # geocoder.xyz provides a country name under 'standard' 682: if($l->{'standard'}{'countryname'}) {

Mutants (Total: 1, Killed: 1, Survived: 0)

683: $l->{'address'}{'country'} = $l->{'standard'}{'countryname'}; 684: } 685: } 686: 687: if(defined($l->{geometry}{location}{lat})) {

Mutants (Total: 1, Killed: 1, Survived: 0)

688: print $l->{geometry}{location}{lat}, '/', 689: $l->{geometry}{location}{lng}, "\n" 690: if($self->{'debug'}); 691: 692: # Store the geocoder object (not just its name) on the result 693: $l->{geocoder} = $geocoder; 694: $l->{'lat'} //= $l->{geometry}{location}{lat}; 695: $l->{'lng'} //= $l->{geometry}{location}{lng}; 696: $l->{'lon'} //= $l->{geometry}{location}{lng}; 697: 698: my $log = { 699: line => $call_details[2], 700: location => $location, 701: timetaken => $timetaken, 702: geocoder => ref($geocoder), 703: wantarray => wantarray, 704: result => $l, 705: }; 706: CORE::push @{$self->{'log'}}, $log; 707: 708: # Record which element succeeded, then exit the inner loop 709: $good_result = $l; 710: last POSSIBLE_LOCATION; 711: } 712: } 713: } 714: 715: # Only attempt to return / cache if normalisation actually succeeded 716: next ENCODER unless defined $good_result; 717: 718: print 'Number of matches from ', ref($geocoder), ': ', 719: scalar(@rc), "\n" if($self->{'debug'}); 720: 721: if($self->{'debug'} >= 2) {

Mutants (Total: 4, Killed: 1, Survived: 3)
722: # Use 'local' to avoid permanently altering the global Maxdepth 723: local $Data::Dumper::Maxdepth = 10; 724: print Data::Dumper->new([\@rc])->Dump(); 725: } 726: 727: # NOTE (latent unreachable path): if a geocoder returned a list whose 728: # first element is undef (e.g. (undef, {lat=>1,lon=>2})), $good_result 729: # would be set from a later element but defined($rc[0]) is false, so 730: # the block below is skipped and the valid result is silently discarded. 731: # No known geocoder produces a leading undef, making this scenario 732: # unreachable in practice. A safer guard would be defined($good_result). 733: if(defined($rc[0])) {

Mutants (Total: 1, Killed: 1, Survived: 0)

734: # Normalise the legacy 'long' key some geocoders emit 735: if(defined($rc[0]->{'long'}) && !defined($rc[0]->{'lng'})) {

Mutants (Total: 1, Killed: 1, Survived: 0)

736: $rc[0]->{'lng'} = $rc[0]->{'long'}; 737: } 738: if(defined($rc[0]->{'long'}) && !defined($rc[0]->{'lon'})) {

Mutants (Total: 1, Killed: 1, Survived: 0)

739: $rc[0]->{'lon'} = $rc[0]->{'long'}; 740: } 741: 742: # Sanity check: the good result must have lat and lng 743: if((!defined($good_result->{lat})) || (!defined($good_result->{lng}))) {

Mutants (Total: 1, Killed: 1, Survived: 0)

744: $self->_warn(Data::Dumper->new([\@rc])->Dump()); 745: $self->_error("BUG: '$location': HASH exists but is not sensible"); 746: } 747: 748: if(wantarray) {

Mutants (Total: 1, Killed: 1, Survived: 0)

749: $self->_cache($location, \@rc); 750: return @rc;

Mutants (Total: 2, Killed: 2, Survived: 0)

751: } 752: 753: $self->_cache($location, $good_result); 754: return $good_result;

Mutants (Total: 2, Killed: 2, Survived: 0)

755: } 756: } 757: 758: # ── No geocoder produced a usable result ────────────────────────────── 759: 760: print "No matches\n" if($self->{'debug'}); 761: 762: # Cache the not-found result so repeated calls do not hammer all backends 763: $self->_cache($location, undef); 764: 765: return wantarray ? () : undef;

Mutants (Total: 2, Killed: 2, Survived: 0)

766: } 767: 768: # ============================================================================= 769: 770: =head2 ua 771: 772: Sets the L<LWP::UserAgent> (or compatible) object on every geocoder in the 773: chain. Useful when you need proxy support or custom timeouts across all 774: backends at once. 775: 776: There is intentionally no read accessor since that would be meaningless 777: (each geocoder could have a different UA). 778: 779: use LWP::UserAgent; 780: my $ua = LWP::UserAgent->new(); 781: $ua->env_proxy(1); 782: $list->ua($ua); 783: 784: =head3 API SPECIFICATION 785: 786: =head4 INPUT 787: 788: # Params::Validate::Strict schema 789: { 790: ua => { 791: type => OBJECT, 792: optional => 1, 793: }, 794: } 795: 796: =head4 OUTPUT 797: 798: # Return::Set schema 799: OBJECT # the same $ua that was passed in 800: 801: =cut 802: 803: sub ua 804: { โ—805 โ†’ 811 โ†’ 835 805: my ($self, $ua) = @_; 806: 807: # Nothing to propagate if no UA was supplied 808: return unless $ua; 809: 810: # Push the UA into every geocoder in the chain 811: foreach my $g (@{$self->{geocoders}}) { 812: my $geocoder = (ref($g) eq 'HASH') ? $g->{geocoder} : $g; 813: # Guard against a misconfigured entry that has no geocoder object 814: Carp::croak('No geocoder found') unless defined $geocoder; 815: 816: # When the incoming UA supports clone(), create a per-geocoder copy 817: # and set the geocoder's own agent string on that copy. 818: # Some APIs (e.g. OSM Nominatim) require a specific User-Agent and 819: # refuse requests that carry the generic libwww-perl default. 820: # The agent string is derived from the geocoder class name and version 821: # (e.g. 'Geo::Coder::OSM/0.03') without reading the geocoder's current 822: # UA, which would trigger any spy or hook installed on its ua() method. 823: if($ua->can('clone') && $ua->can('agent')) {

Mutants (Total: 1, Killed: 1, Survived: 0)

824: my $per_ua = $ua->clone(); 825: my $class = ref($geocoder); 826: my $version = eval { $geocoder->VERSION() } // ''; 827: $per_ua->agent($version ? "$class/$version" : $class); 828: $geocoder->ua($per_ua); 829: } else { 830: $geocoder->ua($ua); 831: } 832: } 833: 834: # Return the UA so callers can verify what was set (API contract) 835: return $ua;

Mutants (Total: 2, Killed: 2, Survived: 0)

836: } 837: 838: # ============================================================================= 839: 840: =head2 reverse_geocode 841: 842: Converts a latitude/longitude pair into a human-readable address string. 843: 844: In scalar context returns a single address string (or C<undef>). 845: In list context returns all address strings from the winning geocoder. 846: 847: my $address = $list->reverse_geocode(latlng => '51.5074,-0.1278'); 848: print "Address: $address\n" if $address; 849: 850: my @addresses = $list->reverse_geocode(latlng => '51.5074,-0.1278'); 851: 852: =head3 API SPECIFICATION 853: 854: =head4 INPUT 855: 856: # Params::Validate::Strict schema 857: { 858: latlng => { 859: type => SCALAR, 860: required => 1, 861: regex => qr/^\s*[-+]?(?:\d*\.?\d+|\d+\.?\d*) 862: \s*,\s* 863: [-+]?(?:\d*\.?\d+|\d+\.?\d*)\s*$/x, 864: }, 865: } 866: 867: =head4 OUTPUT 868: 869: # Return::Set schema (scalar context) 870: SCALAR (address string) | undef 871: 872: # Return::Set schema (list context) 873: ARRAY of SCALAR 874: 875: =cut 876: 877: sub reverse_geocode { โ—878 โ†’ 889 โ†’ 893 878: my $self = shift; 879: my $params = Params::Get::get_params('latlng', \@_); 880: 881: my $latlng = $params->{'latlng'} or Carp::croak('Usage: reverse_geocode(latlng => $location)'); 882: 883: # Split into components; populate convenience keys for geocoders that want them 884: my ($latitude, $longitude) = split(/,/, $latlng); 885: $params->{'lat'} //= $latitude; 886: $params->{'lon'} //= $longitude; 887: 888: # Check L1 / L2 cache before hitting any backend 889: if(my $rc = $self->_cache($latlng)) {

Mutants (Total: 1, Killed: 1, Survived: 0)

890: return $rc;

Mutants (Total: 2, Killed: 2, Survived: 0)

891: } 892: โ—893 โ†’ 895 โ†’ 1057 893: my @call_details = caller(0); 894: 895: foreach my $g (@{$self->{geocoders}}) { 896: my $geocoder = $g; 897: 898: # Apply the per-geocoder limit guard for hashref entries 899: if(ref($geocoder) eq 'HASH') {

Mutants (Total: 1, Killed: 1, Survived: 0)

900: if(exists($geocoder->{'limit'}) && defined(my $limit = $geocoder->{'limit'})) {

Mutants (Total: 1, Killed: 1, Survived: 0)

901: print "limit: $limit\n" if($self->{'debug'}); 902: if($limit <= 0) {

Mutants (Total: 4, Killed: 4, Survived: 0)

903: next; 904: } 905: $geocoder->{'limit'}--; 906: } 907: $geocoder = $g->{'geocoder'}; 908: } 909: 910: print 'trying ', ref($geocoder), "\n" if($self->{'debug'}); 911: 912: if(wantarray) {

Mutants (Total: 1, Killed: 1, Survived: 0)

913: # ── List context: collect all address strings from this geocoder ─── 914: my @rc; 915: my @locs; 916: eval { @locs = $geocoder->reverse_geocode(%{$params}) }; 917: 918: # Some geocoders (e.g. Geo::Coder::GeoApify) use strict parameter 919: # validation and reject the 'latlng' key as unknown. Retry without 920: # it -- lat and lon are already in %params from the split above. 921: # Other geocoders (e.g. Geo::Coder::CA) require 'latlng', so the 922: # first attempt must include it. 923: if($@ =~ /Unknown parameter.*latlng|latlng.*[Uu]nknown/s) {

Mutants (Total: 1, Killed: 1, Survived: 0)

924: my %no_latlng = %{$params}; 925: delete $no_latlng{'latlng'}; 926: $@ = ''; 927: eval { @locs = $geocoder->reverse_geocode(%no_latlng) }; 928: } 929: 930: if($@) {

Mutants (Total: 1, Killed: 1, Survived: 0)

931: CORE::push @{$self->{'log'}}, { 932: line => $call_details[2], 933: location => $latlng, 934: geocoder => ref($geocoder), 935: timetaken => 0, 936: wantarray => 1, 937: error => $@, 938: }; 939: $self->_warn(ref($geocoder), " '$latlng': $@"); 940: next; 941: } 942: 943: print Data::Dumper->new([\@locs])->Dump() if($self->{'debug'} >= 2);

Mutants (Total: 3, Killed: 0, Survived: 3)
944: 945: foreach my $loc (@locs) { 946: if(my $name = $loc->{'display_name'}) {

Mutants (Total: 1, Killed: 1, Survived: 0)

947: # OSM returns the full address in display_name 948: CORE::push @rc, $name; 949: } elsif($loc->{'city'}) { 950: # Geo::Coder::CA: build the address from individual fields 951: CORE::push @rc, _build_ca_address($loc); 952: } elsif($loc->{features}) { 953: # GeoApify: formatted string inside a features array 954: CORE::push @rc, 955: $loc->{features}[0]->{properties}{formatted}; 956: last; # only one result from this provider 957: } 958: } 959: 960: CORE::push @{$self->{'log'}}, { 961: line => $call_details[2], 962: location => $latlng, 963: geocoder => ref($geocoder), 964: timetaken => 0, 965: wantarray => 1, 966: result => \@rc, 967: }; 968: 969: $self->_cache($latlng, \@rc); 970: return @rc;

Mutants (Total: 2, Killed: 2, Survived: 0)

971: 972: } else { 973: # ── Scalar context: return the first address string ──────────────── 974: my $rc = $self->_cache($latlng) 975: // eval { $geocoder->reverse_geocode(%{$params}) }; 976: 977: # Same strict-validation fallback as the list-context path above 978: if($@ =~ /Unknown parameter.*latlng|latlng.*[Uu]nknown/s) {

Mutants (Total: 1, Killed: 1, Survived: 0)

979: my %no_latlng = %{$params}; 980: delete $no_latlng{'latlng'}; 981: $@ = ''; 982: $rc = eval { $geocoder->reverse_geocode(%no_latlng) }; 983: } 984: 985: if($@) {

Mutants (Total: 1, Killed: 1, Survived: 0)

986: CORE::push @{$self->{'log'}}, { 987: line => $call_details[2], 988: location => $latlng, 989: geocoder => ref($geocoder), 990: timetaken => 0, 991: wantarray => 0, 992: error => $@, 993: }; 994: $self->_warn(ref($geocoder), " '$latlng': $@"); 995: next; 996: } 997: 998: # A bare string needs no further processing 999: next unless defined $rc; 1000: if(!ref($rc)) {

Mutants (Total: 1, Killed: 1, Survived: 0)

1001: CORE::push @{$self->{'log'}}, { 1002: line => $call_details[2], 1003: location => $latlng, 1004: geocoder => ref($geocoder), 1005: timetaken => 0, 1006: wantarray => 0, 1007: result => $rc, 1008: }; 1009: return $rc;

Mutants (Total: 2, Killed: 2, Survived: 0)

1010: } 1011: 1012: print Data::Dumper->new([$rc])->Dump() if($self->{'debug'} >= 2);

Mutants (Total: 3, Killed: 0, Survived: 3)
1013: 1014: if(my $name = $rc->{'display_name'}) {

Mutants (Total: 1, Killed: 1, Survived: 0)

1015: # OSM 1016: CORE::push @{$self->{'log'}}, { 1017: line => $call_details[2], 1018: location => $latlng, 1019: geocoder => ref($geocoder), 1020: timetaken => 0, 1021: wantarray => 0, 1022: result => $name, 1023: }; 1024: return $self->_cache($latlng, $name);

Mutants (Total: 2, Killed: 2, Survived: 0)

1025: } 1026: 1027: if($rc->{'city'}) {

Mutants (Total: 1, Killed: 1, Survived: 0)

1028: # Geo::Coder::CA 1029: my $name = _build_ca_address($rc); 1030: CORE::push @{$self->{'log'}}, { 1031: line => $call_details[2], 1032: location => $latlng, 1033: geocoder => ref($geocoder), 1034: timetaken => 0, 1035: wantarray => 0, 1036: result => $name, 1037: }; 1038: return $self->_cache($latlng, $name);

Mutants (Total: 2, Killed: 2, Survived: 0)

1039: } 1040: 1041: if($rc->{features}) {

Mutants (Total: 1, Killed: 1, Survived: 0)

1042: # GeoApify 1043: my $name = $rc->{features}[0]->{properties}{formatted}; 1044: CORE::push @{$self->{'log'}}, { 1045: line => $call_details[2], 1046: location => $latlng, 1047: geocoder => ref($geocoder), 1048: timetaken => 0, 1049: wantarray => 0, 1050: result => $name, 1051: }; 1052: return $self->_cache($latlng, $name);

Mutants (Total: 2, Killed: 2, Survived: 0)

1053: } 1054: } 1055: } 1056: 1057: return; 1058: } 1059: 1060: # ============================================================================= 1061: 1062: =head2 log 1063: 1064: Returns an arrayref of log entries accumulated since the last C<flush()>. 1065: Each entry is a hashref with the keys: C<line>, C<location>, C<timetaken>, 1066: C<geocoder>, C<wantarray>, and either C<result> or C<error>. 1067: 1068: foreach my $entry (@{ $list->log() }) { 1069: printf "%s: %.3fs via %s\n", 1070: $entry->{location}, 1071: $entry->{timetaken}, 1072: $entry->{geocoder}; 1073: } 1074: 1075: =head3 API SPECIFICATION 1076: 1077: =head4 INPUT 1078: 1079: # No parameters accepted 1080: 1081: =head4 OUTPUT 1082: 1083: # Return::Set schema 1084: ARRAYREF of HASHREF 1085: [ 1086: { 1087: line => Int, 1088: location => Str, 1089: timetaken => Num, 1090: geocoder => Str | 'cache', 1091: wantarray => Bool, 1092: result => HASHREF | ARRAYREF | Str, # on success 1093: error => Str, # on failure 1094: }, 1095: ... 1096: ] 1097: 1098: =cut 1099: 1100: sub log { 1101: my $self = shift; 1102: 1103: # Guard against the state left by flush(); always return a valid arrayref 1104: return $self->{'log'} // [];

Mutants (Total: 2, Killed: 2, Survived: 0)

1105: } 1106: 1107: # ============================================================================= 1108: 1109: =head2 flush 1110: 1111: Clears all accumulated log entries and returns C<$self> to allow chaining. 1112: 1113: $list->geocode('Paris, France'); 1114: my $entries = $list->log(); 1115: $list->flush()->geocode('London, UK'); # chained 1116: 1117: =head3 API SPECIFICATION 1118: 1119: =head4 INPUT 1120: 1121: # No parameters accepted 1122: 1123: =head4 OUTPUT 1124: 1125: # Return::Set schema 1126: OBJECT blessed into Geo::Coder::List # $self, for chaining 1127: 1128: =cut 1129: 1130: sub flush { 1131: my $self = shift; 1132: 1133: # Reset to an empty arrayref so log() always returns a valid reference 1134: $self->{'log'} = []; 1135: 1136: return $self;

Mutants (Total: 2, Killed: 2, Survived: 0)

1137: } 1138: 1139: # ============================================================================= 1140: # PRIVATE HELPERS 1141: # ============================================================================= 1142: 1143: # _build_ca_address 1144: # 1145: # Purpose: Assemble a printable address string from a Geo::Coder::CA 1146: # reverse-geocode response. The CA response uses different keys 1147: # for US addresses (nested under 'usa') vs Canadian ones. 1148: # 1149: # Entry: $loc - HASHREF from Geo::Coder::CA reverse_geocode() 1150: # 1151: # Exit: Returns a plain string, or empty string if nothing was found. 1152: # 1153: # Notes: Street number and name are joined with a space; other parts 1154: # (city, province/state, country) are joined with ', '. 1155: 1156: sub _build_ca_address 1157: { โ—1158 โ†’ 1161 โ†’ 1180 1158: my $loc = $_[0]; 1159: my $name = ''; 1160: 1161: if(my $usa = $loc->{'usa'}) {

Mutants (Total: 1, Killed: 1, Survived: 0)

1162: # US address layout inside a CA result 1163: $name = $usa->{'usstnumber'} // ''; 1164: # Street name follows number with a space; if no number, no leading space 1165: $name .= ($name ? ' ' : '') . $usa->{'usstaddress'} if $usa->{'usstaddress'}; 1166: # City, state, country each separated by ', '; skip separator if name empty 1167: $name .= ($name ? ', ' : '') . $usa->{'uscity'} if $usa->{'uscity'}; 1168: $name .= ($name ? ', ' : '') . $usa->{'state'} if $usa->{'state'}; 1169: # Country is always appended for the US branch 1170: $name .= ($name ? ', ' : '') . 'USA'; 1171: } else { 1172: # Canadian address layout 1173: $name = $loc->{'stnumber'} // ''; 1174: # Street name follows number with a space; if no number, no leading space 1175: $name .= ($name ? ' ' : '') . $loc->{'staddress'} if $loc->{'staddress'}; 1176: $name .= ($name ? ', ' : '') . $loc->{'city'} if $loc->{'city'}; 1177: $name .= ($name ? ', ' : '') . $loc->{'prov'} if $loc->{'prov'}; 1178: } 1179: 1180: return $name;

Mutants (Total: 2, Killed: 2, Survived: 0)

1181: } 1182: 1183: # ----------------------------------------------------------------------------- 1184: 1185: # _cache 1186: # 1187: # Read from or write to the two-level cache. 1188: # L1 is an in-process HASH (always active). 1189: # L2 is an optional CHI-compatible object or a plain HASH ref. 1190: # 1191: # Entry (write): _cache($key, $value) 1192: # $value may be undef, which is stored as $NOT_FOUND_SENTINEL so 1193: # subsequent reads can distinguish "cached not-found" from "never 1194: # looked up". Detect the write path by testing scalar(@_) before 1195: # shifting, not by truthiness of the value. 1196: # 1197: # Entry (read): _cache($key) 1198: # 1199: # Exit (write): Returns $value (undef if the location was not found). 1200: # Exit (read): Returns the cached value, or undef if not in cache. 1201: # $NOT_FOUND_SENTINEL is never surfaced to callers; undef is 1202: # returned in its place so callers handle both cases identically. 1203: # 1204: # Side effects: May update $self->{locations} (L1) and $self->{'cache'} (L2). 1205: # 1206: # Notes: Cache TTLs are taken from $self->{cache_*_duration}, which are 1207: # initialised from %config and overridable via Object::Configure. 1208: # Not-found sentinels are stored only in L1 to avoid leaking 1209: # internal implementation details into an external L2 store. 1210: 1211: sub _cache { โ—1212 โ†’ 1219 โ†’ 1322 1212: my $self = shift; 1213: my $key = shift; 1214: 1215: # ── Write path ──────────────────────────────────────────────────────── 1216: # Detect a write call by the presence of a third argument (even if undef). 1217: # Testing truthiness of the value would silently swallow not-found results. 1218: 1219: if(scalar(@_)) {

Mutants (Total: 1, Killed: 1, Survived: 0)

1220: my $value = shift; 1221: 1222: # Store a sentinel for not-found so we can skip backends on repeat calls 1223: my $stored = defined($value) ? $value : $NOT_FOUND_SENTINEL; 1224: $self->{locations}->{$key} = $stored; 1225: 1226: my $rc = $value; 1227: 1228: if($self->{'cache'}) {

Mutants (Total: 1, Killed: 1, Survived: 0)

1229: my $duration; 1230: 1231: if(ref($value) eq 'ARRAY') {

Mutants (Total: 1, Killed: 1, Survived: 0)

1232: foreach my $item (@{$value}) { 1233: # Blessed objects (e.g. Geo::Location::Point) may hold 1234: # unserializable handles; stringify their geocoder field too 1235: if(blessed($item) && ref($item->{'geocoder'})) {

Mutants (Total: 1, Killed: 1, Survived: 0)

1236: $item->{'geocoder'} = ref($item->{'geocoder'}); 1237: } 1238: 1239: next unless ref($item) eq 'HASH'; 1240: 1241: # Serialise the geocoder object to its class name for storage 1242: $item->{'geocoder'} = ref($item->{'geocoder'}); 1243: 1244: # Strip everything except geometry to keep the L2 entry small 1245: unless($self->{'debug'}) {

Mutants (Total: 1, Killed: 1, Survived: 0)

1246: while(my ($k, $v) = each %{$item}) { 1247: delete $item->{$k} unless($k eq 'geometry'); 1248: } 1249: } 1250: 1251: unless(defined($item->{geometry}{location}{lat})) {

Mutants (Total: 1, Killed: 1, Survived: 0)

1252: # Partial or temporary failure: use the shorter TTL. 1253: # UNREACHABLE ARM: the access above auto-vivifies 1254: # $item->{geometry} as {}, so defined($item->{geometry}) 1255: # is always true here; the false arm never executes. 1256: # Original ternary preserved for documentation: 1257: # $duration //= defined($item->{geometry}) 1258: # ? $self->{'cache_part_duration'} 1259: # : $self->{'cache_miss_duration'}; 1260: $duration //= $self->{'cache_part_duration'}; 1261: $rc = undef; 1262: } 1263: } 1264: 1265: # All items were clean: use the full hit duration 1266: $duration //= $self->{'cache_hit_duration'}; 1267: 1268: } elsif(ref($value) eq 'HASH') { 1269: $value->{'geocoder'} = ref($value->{'geocoder'}); 1270: 1271: unless($self->{'debug'}) {

Mutants (Total: 1, Killed: 1, Survived: 0)

1272: while(my ($k, $v) = each %{$value}) { 1273: delete $value->{$k} unless($k eq 'geometry'); 1274: } 1275: } 1276: 1277: if(defined($value->{geometry}{location}{lat})) {

Mutants (Total: 1, Killed: 1, Survived: 0)

1278: # Confirmed location: cache for a full month 1279: $duration = $self->{'cache_hit_duration'}; 1280: } elsif(defined($value->{geometry})) { 1281: # Partial geometry: may be a transient failure, retry soon 1282: $duration = $self->{'cache_part_duration'}; 1283: $rc = undef; 1284: } 1285: # UNREACHABLE: the else branch below is dead. The access 1286: # defined($value->{geometry}{location}{lat}) above auto-vivifies 1287: # $value->{geometry} as {}, so the elsif is always taken when 1288: # the if fails. Original else preserved for documentation: 1289: # } else { 1290: # # No geometry at all: place probably does not exist 1291: # $duration = $self->{'cache_miss_duration'}; 1292: # $rc = undef; 1293: # } 1294: } else { 1295: # Scalar string or a blessed object (e.g. Geo::Location::Point). 1296: # Blessed objects may hold unserializable handles; stringify the 1297: # geocoder field so CHI (Storable) can freeze the value safely. 1298: if(ref($value) && ref($value->{'geocoder'})) {

Mutants (Total: 1, Killed: 1, Survived: 0)

1299: $value->{'geocoder'} = ref($value->{'geocoder'}); 1300: } 1301: $duration = $self->{'cache_hit_duration'}; 1302: } 1303: 1304: print Data::Dumper->new([$value])->Dump() if($self->{'debug'}); 1305: 1306: # Do not push the not-found sentinel into L2 1307: if(!defined($value)) {

Mutants (Total: 1, Killed: 1, Survived: 0)

1308: # value is not-found; L1 sentinel is sufficient 1309: } elsif(ref($self->{'cache'}) eq 'HASH') { 1310: $self->{'cache'}->{$key} = $value; 1311: } else { 1312: $self->{'cache'}->set($key, $value, $duration); 1313: } 1314: } 1315: 1316: return $rc;

Mutants (Total: 2, Killed: 2, Survived: 0)

1317: } 1318: 1319: # ── Read path ───────────────────────────────────────────────────────── 1320: 1321: # Check L1 first (in-process, no serialisation cost) โ—1322 โ†’ 1325 โ†’ 1333 1322: my $rc = $self->{'locations'}->{$key}; 1323: 1324: # Fall through to L2 only when L1 has no entry for this key 1325: if((!defined($rc)) && $self->{'cache'}) {

Mutants (Total: 1, Killed: 1, Survived: 0)

1326: if(ref($self->{'cache'}) eq 'HASH') {

Mutants (Total: 1, Killed: 1, Survived: 0)

1327: $rc = $self->{'cache'}->{$key}; 1328: } else { 1329: $rc = $self->{'cache'}->get($key); 1330: } 1331: } 1332: โ—1333 โ†’ 1339 โ†’ 1346 1333: return unless defined $rc; 1334: 1335: # Translate the not-found sentinel back to undef for the caller 1336: return if ref($rc) && (ref($rc) eq _NOT_FOUND_CLASS); 1337: 1338: # Restore the convenience aliases that were stripped before L2 storage 1339: if(ref($rc) eq 'HASH') {

Mutants (Total: 1, Killed: 1, Survived: 0)

1340: return unless defined($rc->{geometry}{location}{lat}); 1341: $rc->{'lat'} //= $rc->{geometry}{location}{lat}; 1342: $rc->{'lng'} //= $rc->{geometry}{location}{lng}; 1343: $rc->{'lon'} //= $rc->{geometry}{location}{lng}; 1344: } 1345: 1346: return $rc;

Mutants (Total: 2, Killed: 2, Survived: 0)

1347: } 1348: 1349: # Emit a debug message somewhere 1350: sub _debug { โ—[NOT COVERED] 1351 โ†’ 1353 โ†’ 1356 1351: my $self = shift; 1352: 1353: if(my $logger = $self->{logger}) {

Mutants (Total: 1, Killed: 0, Survived: 1)
1354: $logger->debug(@_); 1355: } โ—[NOT COVERED] 1356 โ†’ 1356 โ†’ 0 1356: if($self->{debug}) {
Mutants (Total: 1, Killed: 0, Survived: 1)
1357: print @_, "\n"; 1358: } 1359: } 1360: 1361: # Emit a warning message somewhere 1362: sub _warn { โ—1363 โ†’ 1365 โ†’ 0 1363: my $self = shift; 1364: 1365: if(my $logger = $self->{logger}) {
Mutants (Total: 1, Killed: 0, Survived: 1)
1366: $logger->warn(@_); 1367: } else { 1368: Carp::carp(@_); 1369: } 1370: } 1371: 1372: # Emit an error message somewhere 1373: sub _error { โ—1374 โ†’ 1376 โ†’ 0 1374: my $self = shift; 1375: 1376: if(my $logger = $self->{logger}) {
Mutants (Total: 1, Killed: 0, Survived: 1)
1377: $logger->error(@_); 1378: die @_; 1379: } else { 1380: Carp::croak(@_); 1381: } 1382: } 1383: 1384: # ============================================================================= 1385: # DOCUMENTATION 1386: # ============================================================================= 1387: 1388: =head1 AUTHOR 1389: 1390: Nigel Horne, C<< <njh at nigelhorne.com> >> 1391: 1392: =head1 BUGS 1393: 1394: Please report any bugs or feature requests to 1395: C<bug-geo-coder-list at rt.cpan.org>, or through the web interface at 1396: L<https://rt.cpan.org/NoAuth/ReportBug.html?Queue=Geo-Coder-List>. 1397: 1398: Known limitations: 1399: 1400: =over 4 1401: 1402: =item * C<reverse_geocode()> does not yet support L<Geo::Location::Point> objects. 1403: 1404: =item * When C<Geo::GeoNames> returns multiple candidates, only the first 1405: element of each sub-array is considered. 1406: 1407: =back 1408: 1409: =head1 SEE ALSO 1410: 1411: =over 4 1412: 1413: =item * L<Test Dashboard|https://nigelhorne.github.io/Geo-Coder-List/coverage/> 1414: 1415: =item * L<Geo::Coder::All> 1416: 1417: =item * L<Geo::Coder::GooglePlaces> 1418: 1419: =item * L<Geo::Coder::Many> 1420: 1421: =item * L<Configure an Object at Runtime|Object::Configure> 1422: 1423: =item * L<Readonly> 1424: 1425: =back 1426: 1427: =head1 SUPPORT 1428: 1429: You can find documentation for this module with the perldoc command: 1430: 1431: perldoc Geo::Coder::List 1432: 1433: =over 4 1434: 1435: =item * RT: CPAN's request tracker 1436: 1437: L<https://rt.cpan.org/NoAuth/Bugs.html?Dist=Geo-Coder-List> 1438: 1439: =item * MetaCPAN 1440: 1441: L<https://metacpan.org/release/Geo-Coder-List> 1442: 1443: =back 1444: 1445: =encoding utf-8 1446: 1447: =head2 FORMAL SPECIFICATION 1448: 1449: =head3 new 1450: 1451: List_State 1452: ────────────────────────────────────────────────────── 1453: geocoders : seq (Geocoder | RegexGeocoder) 1454: L1 : LocationStr ↛ (GeoResult | NotFound) 1455: log : seq LogEntry 1456: debug : ℕ 1457: cache? : L2Cache 1458: 1459: new 1460: ────────────────────────────────────────────────────── 1461: List_State 1462: params? : ℙ(Key × Value) 1463: ────────────────────────────────────────────────────── 1464: geocoders = ⟨⟩ 1465: L1 = ∅ 1466: log = ⟨⟩ 1467: debug = params?.debug ∣ DEBUG_DEFAULT 1468: cache = params?.cache ∣ ⊥ 1469: 1470: =head3 push 1471: 1472: push 1473: ────────────────────────────────────────────────────── 1474: ΔList_State 1475: g? : Geocoder | RegexGeocoder 1476: ────────────────────────────────────────────────────── 1477: geocoders' = geocoders ⌢ ⟨g?⟩ 1478: L1' = L1 1479: log' = log 1480: ────────────────────────────────────────────────────── 1481: where RegexGeocoder ::= { regex : Regex 1482: ; geocoder : Geocoder 1483: ; limit? : ℕ } 1484: 1485: =head3 geocode 1486: 1487: LocationStr ::= { s : seq Char | s ≠ ⟨⟩ ∧ ∃ c : s • c ∉ Digit } 1488: GeoResult ::= HASHREF with geometry.location.{lat,lng} : ℝ 1489: 1490: geocode 1491: ────────────────────────────────────────────────────────────────────── 1492: ΔList_State 1493: loc? : LocationStr 1494: result! : GeoResult | ⊥ 1495: ────────────────────────────────────────────────────────────────────── 1496: loc? ∈ dom L1 1497: ⟹ result! = L1(loc?) 1498: ∧ log' = log ⌢ ⟨{geocoder ↦ cache; timetaken ↦ 0}⟩ 1499: 1500: loc? ∉ dom L1 1501: ⟹ (∃ i : 1..#geocoders • 1502: applies(geocoders i, loc?) 1503: ∧ result! = Normalize(geocoders i . geocode(loc?)) 1504: ∧ L1' = L1 ⊕ {loc? ↦ result!} 1505: ∧ log' = log ⌢ ⟨{geocoder ↦ class(geocoders i)}⟩) 1506: ∨ (result! = ⊥ ∧ L1' = L1 ⊕ {loc? ↦ ⊥}) 1507: 1508: applies(g, loc) ≙ 1509: (g isa Geocoder) 1510: ∨ (g isa RegexGeocoder ∧ loc ∈ matches(g.regex) ∧ g.limit > 0) 1511: 1512: =head3 ua SPECIFICATION 1513: 1514: ua 1515: ────────────────────────────────────────────────────── 1516: ΞList_State 1517: ua? : UserAgent 1518: ua! : UserAgent 1519: ────────────────────────────────────────────────────── 1520: ∀ g : ran geocoders • g.ua = ua? 1521: ua! = ua? 1522: 1523: =head3 reverse_geocode 1524: 1525: LatLngStr ::= { s : seq Char 1526: | s matches /^[-+]?\d+\.?\d*,[-+]?\d+\.?\d*$/ } 1527: 1528: reverse_geocode 1529: ────────────────────────────────────────────────────────────────────── 1530: ΔList_State 1531: latlng? : LatLngStr 1532: result! : seq Char | ⊥ 1533: ────────────────────────────────────────────────────────────────────── 1534: latlng? ∈ dom L1 1535: ⟹ result! = L1(latlng?) 1536: 1537: latlng? ∉ dom L1 1538: ⟹ (∃ i : 1..#geocoders • 1539: applies(geocoders i, latlng?) 1540: ∧ result! = geocoders i . reverse_geocode(latlng?) 1541: ∧ L1' = L1 ⊕ {latlng? ↦ result!}) 1542: ∨ result! = ⊥ 1543: 1544: =head3 log 1545: 1546: log 1547: ────────────────────────────────────────────────────── 1548: ΞList_State 1549: result! : seq LogEntry 1550: ────────────────────────────────────────────────────── 1551: result! = log 1552: 1553: =head3 flush 1554: 1555: flush 1556: ────────────────────────────────────────────────────── 1557: ΔList_State 1558: ────────────────────────────────────────────────────── 1559: log' = ⟨⟩ 1560: geocoders' = geocoders 1561: L1' = L1 1562: 1563: =head1 LICENSE AND COPYRIGHT 1564: 1565: Copyright 2016-2026 Nigel Horne. 1566: 1567: Usage is subject to the GPL2 licence terms. 1568: If you use it, 1569: please let me know. 1570: 1571: =cut 1572: 1573: 1;