File Coverage

File:blib/lib/Geo/Coder/List.pm
Coverage:92.0%

linestmtbrancondsubtimecode
1package Geo::Coder::List;
2
3# Geo::Coder::List - Aggregate and chain multiple geocoding backends
4
5
24
24
1760242
56
use 5.10.1;
6
7
24
24
24
36
17
199
use strict;
8
24
24
24
36
16
459
use warnings;
9
24
24
24
3608
113886
62
use autodie qw(:all);
10
11
24
24
24
150145
18
573
use Carp;
12
24
24
24
42
19
351
use Data::Dumper;
13
24
24
24
5237
59502
1053
use HTML::Entities;
14
24
24
24
7133
1659559
482
use Object::Configure 0.13;
15
24
24
24
91
122
380
use Params::Get 0.04;
16
24
24
24
41
18
425
use Readonly;
17
24
24
24
39
15
340
use Scalar::Util qw(blessed);
18
24
24
24
38
38
85
use Time::HiRes;
19
20 - 28
=head1 NAME

Geo::Coder::List - Call many Geo-Coders

=head1 VERSION

Version 0.38

=cut
29
30our $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
24
24
24
1022
17
648
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
24
24
24
64
63
57311
use constant _NOT_FOUND_CLASS => __PACKAGE__ . '::_NotFound';
41
42# The singleton value stored in L1 when a location is confirmed missing
43my $NOT_FOUND_SENTINEL = bless {}, _NOT_FOUND_CLASS;
44
45# String placed in the 'geocoder' field of a result served from cache
46Readonly::Scalar my $CACHE_SOURCE => 'cache';
47
48# String used as the 'result' value in log entries for a geocoder miss
49Readonly::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
58my %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 - 147
=head1 SYNOPSIS

L<Geo::Coder::All> and L<Geo::Coder::Many> are great modules but neither
quite does what I want.

C<Geo::Coder::List> aggregates multiple geocoding services into a single,
unified interface.  It chains and prioritizes backends based on regex routing
and per-geocoder query limits, caches results at two levels (L1 in-memory
always; optional L2 via CHI or a plain HASH), and normalizes every provider's
idiosyncratic response into the common structure expected by
L<HTML::GoogleMaps::V3> and L<HTML::OSM>:

    $result->{geometry}{location}{lat}   # canonical latitude
    $result->{geometry}{location}{lng}   # canonical longitude
    $result->{geocoder}                  # source object (or 'cache')

    use Geo::Coder::List;
    use Geo::Coder::OSM;
    use Geo::Coder::CA;

    my $list = Geo::Coder::List->new()
        ->push({ regex => qr/(Canada|USA)$/, geocoder => Geo::Coder::CA->new() })
        ->push(Geo::Coder::OSM->new());

    my $loc = $list->geocode('10 Downing St, London, UK');
    printf "lat=%.4f lng=%.4f\n",
        $loc->{geometry}{location}{lat},
        $loc->{geometry}{location}{lng};

=head1 SUBROUTINES/METHODS

=head2 new

Creates a new C<Geo::Coder::List> object.  When called on an existing object
it returns a clone of that object merged with the supplied arguments.

The constructor reads configuration from environment variables via
L<Object::Configure>; for example, setting
C<GEO__CODER__LIST__carp_on_warn=1> causes warnings to use L<Carp>.

    use Geo::Coder::List;
    use CHI;

    # With an optional L2 cache (any CHI driver works)
    my $geocoder = Geo::Coder::List->new(
        cache => CHI->new(driver => 'Memory', global => 1),
        debug => 0,
    );

    # Clone an existing object with a higher debug level
    my $verbose = $geocoder->new(debug => 2);

=head3 API SPECIFICATION

=head4 INPUT

    # Params::Validate::Strict schema
    {
        cache => {
            type     => [ 'hashref', 'object' ],     # OBJECT must implement get($key) and set($key, $value, $ttl)
            optional => 1,
        },
        debug => {
            type     => 'boolean',
            optional => 1,
            default  => 0,
        },
        # Any additional key is forwarded to Object::Configure
    }

=head4 OUTPUT

    # Return::Set schema
    OBJECT blessed into Geo::Coder::List

=cut
148
149sub new
150{
151
333
1036397
        my $class = shift;
152
333
481
        my $params = Params::Get::get_params(undef, \@_) || {};
153
154        # Handle the rare Geo::Coder::List::new() (function-style) invocation
155
333
4236
        if(!defined($class)) {
156
8
8
9
15
                if(scalar keys %{$params} > 0) {
157                        # Using ::new() with arguments is not supported
158
4
70
                        carp(__PACKAGE__, ' use ->new() not ::new() to instantiate');
159
4
787
                        return;
160                }
161                # FIXME: cloning does not work when called as ::new() with arguments
162
4
6
                $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
6
6
6
7
11
532
                return bless { %{$class}, %{$params}, log => [] }, ref($class);
167        }
168
169        # Let Object::Configure overlay defaults from environment / config files
170
323
378
        $params = Object::Configure::configure($class, $params);
171
172        # Fill in any %config defaults the caller did not explicitly supply
173
323
688276
        for my $key (keys %config) {
174
1292
1631
                $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 {
180                debug     => DEBUG_DEFAULT,
181                geocoders => [],
182
323
323
248
1340
                %{$params},
183                locations => {},
184                log       => [],
185        }, $class;
186}
187
188# =============================================================================
189
190 - 223
=head2 push

Appends a geocoder to the chain.  Geocoders are tried in the order they
were pushed.  Returns C<$self> so calls can be chained.

A plain geocoder object is tried for every location.  A hashref with
C<regex>, C<geocoder>, and optional C<limit> keys restricts the geocoder to
locations matching the regex and caps total queries at C<limit>.

    my $list = Geo::Coder::List->new()
        ->push({ regex => qr/USA$/, geocoder => Geo::Coder::CA->new(), limit => 100 })
        ->push(Geo::Coder::OSM->new());

=head3 API SPECIFICATION

=head4 INPUT

    # Params::Validate::Strict schema
    {
        geocoder => {
            type     => OBJECT | HASHREF,
            required => 1,
            # HASHREF must contain:  geocoder => OBJECT
            # HASHREF may contain:   regex    => Regexp
            #                        limit    => SCALAR (positive integer)
        },
    }

=head4 OUTPUT

    # Return::Set schema
    OBJECT blessed into Geo::Coder::List   # $self, for chaining

=cut
224
225sub 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
260
2132
        my ($self, $geocoder) = @_;
230
231        # A geocoder argument is mandatory
232
260
276
        croak(__PACKAGE__, '::push: Usage: ($geocoder)') unless defined($geocoder);
233
234        # Append to the ordered chain and return $self for chaining
235
256
256
142
190
        CORE::push @{$self->{geocoders}}, $geocoder;
236
237
256
260
        return $self;
238}
239
240# =============================================================================
241
242 - 297
=head2 geocode

Resolves a location string to geographic coordinates by trying each geocoder
in turn.  The first successful result is returned and cached.

In scalar context returns a single hashref (or C<undef> on failure).
In list context returns all results from the winning geocoder.

The C<geocoder> field of the returned hashref holds the geocoder object that
supplied the result; it is set to the string C<'cache'> when the result was
served from cache.

See L<Geo::Coder::GooglePlaces::V3> for the canonical result structure.

    my $result = $list->geocode(location => 'Paris, France');
    if($result) {
        printf "lat=%.4f lng=%.4f via %s\n",
            $result->{geometry}{location}{lat},
            $result->{geometry}{location}{lng},
            ref($result->{geocoder}) || $result->{geocoder};
    }

    # List context returns all candidates from the winning geocoder
    my @results = $list->geocode('London, UK');

=head3 API SPECIFICATION

=head4 INPUT

    # Params::Validate::Strict schema
    {
        location => {
            type     => SCALAR,
            required => 1,
            # Must contain at least one non-digit character
        },
    }

=head4 OUTPUT

    # Return::Set schema (scalar context)
    HASHREF | undef
    {
        geometry => { location => { lat => Num, lng => Num } },
        geocoder => OBJECT | 'cache',
        lat      => Num,   # convenience alias
        lng      => Num,   # convenience alias
        lon      => Num,   # compatibility alias for lng
        debug    => Int,   # source line of the normalisation branch taken
        # ... provider-specific keys are preserved
    }

    # Return::Set schema (list context)
    ARRAY of the above HASHREFs

=cut
298
299sub geocode {
300
202
12376
        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
202
200
        my $params = Params::Get::get_params('location', @_);
305
200
2282
        if(!defined($params)) {
306
0
0
                $self->_error(__PACKAGE__, ' usage: geocode(location => $location)');
307
0
0
                return;
308        }
309
310
200
134
        my $location = $params->{'location'};
311
312        # Reject empty or whitespace-only location strings
313
200
333
        if((!defined($location)) || (length($location) == 0)) {
314
6
14
                $self->_warn(__PACKAGE__, ' usage: geocode(location => $location)');
315
6
2049
                return;
316        }
317
318        # A purely numeric string is almost certainly an error (e.g. a bare postcode)
319
194
300
        if($params->{'location'} !~ /\D/) {
320
4
11
                $self->_error('Usage: ', __PACKAGE__, ': invalid input to geocode(), ', $params->{location});
321        }
322
323        # Collapse runs of whitespace and expand any HTML entities
324
190
372
        $location =~ s/\s\s+/ /g;
325
190
564
        $location = decode_entities($location);
326        # Propagate the cleaned-up string so geocoders also receive the decoded form
327
190
151
        $params->{'location'} = $location;
328
329
190
188
        print "location: $location\n" if($self->{'debug'});
330
331        # Capture the caller's line number once for all log entries in this call
332
190
165
        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
190
1248
        my $cached = $self->_cache($location);
339
340
190
149
        if(defined $cached) {
341                # A defined value means we have a genuine cached positive result
342
28
5
39
5
                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
28
24
                for my $r (@rc) {
348
28
23
                        next unless ref($r);
349
28
26
31
42
                        $r = { %{$r} } if ref($r) eq 'HASH';
350
28
42
                        $r->{'geocoder'} = $CACHE_SOURCE;
351                }
352
353                # Scalar context: return the first (and usually only) element
354
28
24
                if(!wantarray) {
355
22
18
                        my $rc = $rc[0];
356
22
22
13
42
                        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
22
23
                        print __PACKAGE__, ': ', __LINE__, ": cached\n" if($self->{'debug'});
365
22
36
                        return $rc;
366                }
367
368                # List context: return all cached candidates
369
6
6
3
18
                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
6
8
                print __PACKAGE__, ': ', __LINE__, ": cached\n" if($self->{'debug'});
378
379                # Determine if every element is empty; if so return nothing
380
6
3
                my $allempty = 1;
381
6
5
                for my $r (@rc) {
382
6
8
                        if(ref($r) eq 'HASH') {
383
5
10
                                $allempty = 0 if defined $r->{geometry}{location}{lat};
384                        } elsif(ref($r) eq 'Geo::Location::Point') {
385
1
1
                                $allempty = 0;
386                        }
387                }
388
6
8
                return if $allempty;
389
4
20
                return @rc;
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
162
152
        if(exists $self->{'locations'}{$location}) {
395
5
4
                my $stored = $self->{'locations'}{$location};
396
5
15
                if(ref($stored) && ref($stored) eq _NOT_FOUND_CLASS) {
397
5
5
                        print "No matches (cached)\n" if($self->{'debug'});
398
5
11
                        return wantarray ? () : undef;
399                }
400        }
401
402        # ── Try each geocoder in turn ─────────────────────────────────────────
403
404
157
157
104
121
        ENCODER: foreach my $g (@{$self->{geocoders}}) {
405
164
102
                my $geocoder = $g;
406
407                # Unpack a hashref entry and apply regex / limit guards
408
164
149
                if(ref($geocoder) eq 'HASH') {
409                        # Decrement and check the per-geocoder query limit
410
14
26
                        if(exists($geocoder->{'limit'}) && defined(my $limit = $geocoder->{'limit'})) {
411
10
14
                                print "limit: $limit\n" if($self->{'debug'});
412
10
9
                                if($limit <= 0) {
413
6
8
                                        next;
414                                }
415
4
4
                                $geocoder->{'limit'}--;
416                        }
417
418                        # Skip this entry if the location does not match its regex
419
8
13
                        if(my $regex = $geocoder->{'regex'}) {
420                                print 'consider ', ref($geocoder->{geocoder}), ": $regex\n"
421
4
5
                                        if($self->{'debug'});
422
4
19
                                if($location !~ $regex) {
423
3
6
                                        next;
424                                }
425                        }
426
427                        # Unwrap the actual geocoder object from the hashref
428
5
6
                        $geocoder = $g->{'geocoder'};
429                }
430
431                # Start timing before the network call
432
155
67
                my @rc;
433
155
164
                my $timetaken = Time::HiRes::time();
434
435
155
84
                eval {
436                        # Geo::GeoNames uses a positional argument, not a hash
437
155
127
                        print 'trying ', ref($geocoder), "\n" if($self->{'debug'});
438
155
150
                        if(ref($geocoder) eq 'Geo::GeoNames') {
439                                print 'username => ', $geocoder->username(), "\n"
440
2
3
                                        if($self->{'debug'});
441
2
2
                                die 'lost username' if(!defined($geocoder->username()));
442
1
2
                                @rc = $geocoder->geocode($location);
443                        } else {
444
153
153
81
225
                                @rc = $geocoder->geocode(%{$params});
445                        }
446                };
447
448
155
1170
                if($@) {
449                        # Log the failure and move on; do not abort the whole chain
450
9
31
                        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
9
9
11
9
                        CORE::push @{$self->{'log'}}, $log;
459
9
24
                        $self->_warn(ref($geocoder), " '$location': $@");
460
9
2649
                        next ENCODER;
461                }
462
463
146
135
                $timetaken = Time::HiRes::time() - $timetaken;
464
465                # Geo::Coder::US::Census sometimes returns a truthy but empty result
466
146
149
                if((ref($geocoder) eq 'Geo::Coder::US::Census') &&
467                   !(defined($rc[0]->{result}{addressMatches}[0]->{coordinates}{y}))) {
468
2
5
                        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
2
2
2
1
                        CORE::push @{$self->{'log'}}, $log;
477
2
3
                        next ENCODER;
478                }
479
480                # Reject empty result sets and trivially empty hashes / arrays
481
144
250
                if((scalar(@rc) == 0) ||
482
110
232
                   ((ref($rc[0]) eq 'HASH')  && (scalar(keys %{$rc[0]}) == 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
22
57
                        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
22
22
16
17
                        CORE::push @{$self->{'log'}}, $log;
498
22
19
                        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
122
61
                my $good_result;
506
507
122
80
                POSSIBLE_LOCATION: foreach my $l (@rc) {
508                        # Geo::GeoNames wraps each result in a one-element array
509
122
126
                        if(ref($l) eq 'ARRAY') {
510                                # FIXME: only the first element of the sub-array is considered
511
2
2
                                $l = $l->[0];
512                        }
513
514                        # Skip undefined or empty-string candidates
515
122
195
                        if((!defined($l)) || ($l eq '')) {
516
5
11
                                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
5
5
4
3
                                CORE::push @{$self->{'log'}}, $log;
525
5
7
                                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
117
88
                        next unless ref($l);
531
532                        # Stamp the source geocoder on the result before normalisation
533
114
108
                        $l->{'geocoder'} = ref($geocoder);
534
535                        print ref($geocoder), ': ',
536
114
99
                                Data::Dumper->new([\$l])->Dump() if($self->{'debug'} >= 2);
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
114
198
                        if(ref($l) eq 'Geo::Location::Point') {
542
4
4
                                $l->{'geocoder'} = $geocoder;
543
544                                # Populate canonical geometry structure from the GLP's own fields
545
4
10
                                if(!defined($l->{geometry}{location}{lat}) && defined($l->{lat})) {
546
3
5
                                        $l->{geometry}{location}{lat} = $l->{lat};
547
3
4
                                        $l->{geometry}{location}{lng} = $l->{lng} // $l->{lon};
548                                }
549
550                                # Convenience aliases (idempotent if already set by GLP)
551
4
7
                                $l->{'lat'} //= $l->{geometry}{location}{lat};
552
4
5
                                $l->{'lng'} //= $l->{geometry}{location}{lng};
553
4
11
                                $l->{'lon'} //= $l->{geometry}{location}{lng};
554
555
4
4
2
10
                                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
4
3
                                $good_result = $l;
564
4
3
                                last POSSIBLE_LOCATION;
565                        }
566
567                        # Only HASH results need normalisation
568
110
92
                        next if(ref($l) ne 'HASH');
569
570
110
99
                        if($l->{'error'}) {
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
1
3
                                        error     => $l->{'error'},
579                                };
580
1
1
1
1
                                CORE::push @{$self->{'log'}}, $log;
581
1
2
                                next ENCODER;
582                        } else {
583                                # Map provider-specific fields to the canonical geometry structure
584
109
138
                                if(!defined($l->{geometry}{location}{lat})) {
585
105
62
                                        my ($lat, $long);
586
587
105
231
                                        if(defined($l->{lat}) && defined($l->{lon})) {
588                                                # OSM / RandMcNally: top-level lat/lon fields
589
79
54
                                                $lat   = $l->{lat};
590
79
39
                                                $long  = $l->{lon};
591
79
64
                                                $l->{'debug'} = __LINE__;
592                                        } elsif($l->{BestLocation}) {
593                                                # Bing Maps: BestLocation.Coordinates.{Latitude,Longitude}
594
2
2
                                                $lat   = $l->{BestLocation}->{Coordinates}->{Latitude};
595
2
2
                                                $long  = $l->{BestLocation}->{Coordinates}->{Longitude};
596
2
2
                                                $l->{'debug'} = __LINE__;
597                                        } elsif($l->{point}) {
598                                                # Bing Maps alternative: point.coordinates[lat, lng]
599
3
4
                                                $lat   = $l->{point}->{coordinates}[0];
600
3
3
                                                $long  = $l->{point}->{coordinates}[1];
601
3
3
                                                $l->{'debug'} = __LINE__;
602                                        } elsif(defined($l->{latt})) {
603                                                # geocoder.ca: latt / longt fields
604
2
3
                                                $lat   = $l->{latt};
605
2
1
                                                $long  = $l->{longt};
606
2
3
                                                $l->{'debug'} = __LINE__;
607                                        } elsif(defined($l->{latitude})) {
608                                                # postcodes.io, Geo::Coder::Free: latitude / longitude
609
3
3
                                                $lat   = $l->{latitude};
610
3
4
                                                $long  = $l->{longitude};
611
3
6
                                                if(my $type = $l->{'local_type'}) {
612                                                        # Carry the local_type hint forward as a normalised 'type'
613
1
1
                                                        $l->{'type'} = lcfirst($type);
614                                                }
615
3
4
                                                $l->{'debug'} = __LINE__;
616                                        } elsif(defined($l->{'properties'}{'geoLatitude'})) {
617                                                # HERE / Ovi: properties.geoLatitude / geoLongitude
618
1
1
                                                $lat   = $l->{properties}{geoLatitude};
619
1
1
                                                $long  = $l->{properties}{geoLongitude};
620
1
1
                                                $l->{'debug'} = __LINE__;
621                                        } elsif($l->{'results'}[0]->{'geometry'}) {
622
3
5
                                                if($l->{'results'}[0]->{'geometry'}->{'location'}) {
623                                                        # DataScienceToolkit mirrors the Google Maps shape
624
1
1
                                                        $lat   = $l->{'results'}[0]->{'geometry'}->{'location'}->{'lat'};
625
1
2
                                                        $long  = $l->{'results'}[0]->{'geometry'}->{'location'}->{'lng'};
626
1
0
                                                        $l->{'debug'} = __LINE__;
627                                                } else {
628                                                        # OpenCage places lat/lng directly under geometry
629
2
2
                                                        $lat   = $l->{'results'}[0]->{'geometry'}->{'lat'};
630
2
3
                                                        $long  = $l->{'results'}[0]->{'geometry'}->{'lng'};
631
2
3
                                                        $l->{'debug'} = __LINE__;
632                                                }
633                                        } elsif($l->{'RESULTS'}) {
634                                                # GeoCodeFarm: RESULTS[0].COORDINATES.{latitude,longitude}
635
2
21
                                                $lat   = $l->{'RESULTS'}[0]{'COORDINATES'}{'latitude'};
636
2
2
                                                $long  = $l->{'RESULTS'}[0]{'COORDINATES'}{'longitude'};
637
2
2
                                                $l->{'debug'} = __LINE__;
638                                        } elsif(defined($l->{result}{addressMatches}[0]->{coordinates}{y})) {
639                                                # US Census Bureau: result.addressMatches[0].coordinates.{y,x}
640
1
2
                                                $lat   = $l->{result}{addressMatches}[0]->{coordinates}{y};
641
1
1
                                                $long  = $l->{result}{addressMatches}[0]->{coordinates}{x};
642
1
0
                                                $l->{'debug'} = __LINE__;
643                                        } elsif(defined($l->{lat})) {
644                                                # Geo::GeoNames: lat / lng (reached only after lat+lon check fails)
645
3
3
                                                $lat   = $l->{lat};
646
3
4
                                                $long  = $l->{lng};
647
3
3
                                                $l->{'debug'} = __LINE__;
648                                        } elsif($l->{features}) {
649
4
7
                                                if($l->{features}[0]->{center}) {
650                                                        # Geo::Coder::Mapbox: center is [lng, lat]
651
1
1
                                                        $lat   = $l->{features}[0]->{center}[1];
652
1
1
                                                        $long  = $l->{features}[0]->{center}[0];
653
1
2
                                                        $l->{'debug'} = __LINE__;
654                                                } elsif($l->{'features'}[0]{'geometry'}{'coordinates'}) {
655                                                        # Geo::Coder::GeoApify: coordinates is [lng, lat]
656
1
1
                                                        $lat   = $l->{'features'}[0]{'geometry'}{'coordinates'}[1];
657
1
1
                                                        $long  = $l->{'features'}[0]{'geometry'}{'coordinates'}[0];
658
1
1
                                                        $l->{'debug'} = __LINE__;
659                                                } else {
660                                                        # GeoApify signals not-found via empty features, not an error
661
2
6
                                                        next ENCODER;
662                                                }
663                                        } else {
664
2
2
                                                $l->{'debug'} = __LINE__;
665                                        }
666
667
103
125
                                        if(defined($lat) && defined($long)) {
668                                                # Populate the canonical geometry structure
669
101
101
                                                $l->{geometry}{location}{lat} = $lat;
670
101
82
                                                $l->{geometry}{location}{lng} = $long;
671                                                # Compatibility aliases expected by callers
672
101
74
                                                $l->{'lat'} = $lat;
673
101
66
                                                $l->{'lon'} = $long;
674                                        } else {
675                                                # No coordinates extracted; clean up any partial data
676
2
2
                                                delete $l->{'geometry'};
677
2
2
                                                delete $l->{'lat'};
678
2
2
                                                delete $l->{'lon'};
679                                        }
680
681                                        # geocoder.xyz provides a country name under 'standard'
682
103
123
                                        if($l->{'standard'}{'countryname'}) {
683
1
1
                                                $l->{'address'}{'country'} = $l->{'standard'}{'countryname'};
684                                        }
685                                }
686
687
107
120
                                if(defined($l->{geometry}{location}{lat})) {
688                                        print $l->{geometry}{location}{lat}, '/',
689                                                $l->{geometry}{location}{lng}, "\n"
690
105
110
                                                if($self->{'debug'});
691
692                                        # Store the geocoder object (not just its name) on the result
693
105
91
                                        $l->{geocoder} = $geocoder;
694
105
108
                                        $l->{'lat'} //= $l->{geometry}{location}{lat};
695
105
206
                                        $l->{'lng'} //= $l->{geometry}{location}{lng};
696
105
87
                                        $l->{'lon'} //= $l->{geometry}{location}{lng};
697
698
105
251
                                        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
105
105
64
69
                                        CORE::push @{$self->{'log'}}, $log;
707
708                                        # Record which element succeeded, then exit the inner loop
709
105
51
                                        $good_result = $l;
710
105
95
                                        last POSSIBLE_LOCATION;
711                                }
712                        }
713                }
714
715                # Only attempt to return / cache if normalisation actually succeeded
716
114
88
                next ENCODER unless defined $good_result;
717
718                print 'Number of matches from ', ref($geocoder), ': ',
719
109
109
                        scalar(@rc), "\n" if($self->{'debug'});
720
721
109
105
                if($self->{'debug'} >= 2) {
722                        # Use 'local' to avoid permanently altering the global Maxdepth
723
2
2
                        local $Data::Dumper::Maxdepth = 10;
724
2
5
                        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
109
165
                if(defined($rc[0])) {
734                        # Normalise the legacy 'long' key some geocoders emit
735
109
123
                        if(defined($rc[0]->{'long'}) && !defined($rc[0]->{'lng'})) {
736
1
1
                                $rc[0]->{'lng'} = $rc[0]->{'long'};
737                        }
738
109
111
                        if(defined($rc[0]->{'long'}) && !defined($rc[0]->{'lon'})) {
739
1
1
                                $rc[0]->{'lon'} = $rc[0]->{'long'};
740                        }
741
742                        # Sanity check: the good result must have lat and lng
743
109
158
                        if((!defined($good_result->{lat})) || (!defined($good_result->{lng}))) {
744
1
4
                                $self->_warn(Data::Dumper->new([\@rc])->Dump());
745
1
424
                                $self->_error("BUG: '$location': HASH exists but is not sensible");
746                        }
747
748
108
76
                        if(wantarray) {
749
4
7
                                $self->_cache($location, \@rc);
750
4
8
                                return @rc;
751                        }
752
753
104
100
                        $self->_cache($location, $good_result);
754
104
181
                        return $good_result;
755                }
756        }
757
758        # ── No geocoder produced a usable result ──────────────────────────────
759
760
48
57
        print "No matches\n" if($self->{'debug'});
761
762        # Cache the not-found result so repeated calls do not hammer all backends
763
48
50
        $self->_cache($location, undef);
764
765
48
102
        return wantarray ? () : undef;
766}
767
768# =============================================================================
769
770 - 801
=head2 ua

Sets the L<LWP::UserAgent> (or compatible) object on every geocoder in the
chain.  Useful when you need proxy support or custom timeouts across all
backends at once.

There is intentionally no read accessor since that would be meaningless
(each geocoder could have a different UA).

    use LWP::UserAgent;
    my $ua = LWP::UserAgent->new();
    $ua->env_proxy(1);
    $list->ua($ua);

=head3 API SPECIFICATION

=head4 INPUT

    # Params::Validate::Strict schema
    {
        ua => {
            type     => OBJECT,
            optional => 1,
        },
    }

=head4 OUTPUT

    # Return::Set schema
    OBJECT   # the same $ua that was passed in

=cut
802
803sub ua
804{
805
31
1821
        my ($self, $ua) = @_;
806
807        # Nothing to propagate if no UA was supplied
808
31
62
        return unless $ua;
809
810        # Push the UA into every geocoder in the chain
811
27
27
16
30
        foreach my $g (@{$self->{geocoders}}) {
812
30
45
                my $geocoder = (ref($g) eq 'HASH') ? $g->{geocoder} : $g;
813                # Guard against a misconfigured entry that has no geocoder object
814
30
71
                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
27
100
                if($ua->can('clone') && $ua->can('agent')) {
824
13
16
                        my $per_ua  = $ua->clone();
825
13
38
                        my $class   = ref($geocoder);
826
13
13
7
87
                        my $version = eval { $geocoder->VERSION() } // '';
827
13
39
                        $per_ua->agent($version ? "$class/$version" : $class);
828
13
40
                        $geocoder->ua($per_ua);
829                } else {
830
14
18
                        $geocoder->ua($ua);
831                }
832        }
833
834        # Return the UA so callers can verify what was set (API contract)
835
24
51
        return $ua;
836}
837
838# =============================================================================
839
840 - 875
=head2 reverse_geocode

Converts a latitude/longitude pair into a human-readable address string.

In scalar context returns a single address string (or C<undef>).
In list context returns all address strings from the winning geocoder.

    my $address = $list->reverse_geocode(latlng => '51.5074,-0.1278');
    print "Address: $address\n" if $address;

    my @addresses = $list->reverse_geocode(latlng => '51.5074,-0.1278');

=head3 API SPECIFICATION

=head4 INPUT

    # Params::Validate::Strict schema
    {
        latlng => {
            type    => SCALAR,
            required => 1,
            regex   => qr/^\s*[-+]?(?:\d*\.?\d+|\d+\.?\d*)
                              \s*,\s*
                          [-+]?(?:\d*\.?\d+|\d+\.?\d*)\s*$/x,
        },
    }

=head4 OUTPUT

    # Return::Set schema (scalar context)
    SCALAR (address string) | undef

    # Return::Set schema (list context)
    ARRAY of SCALAR

=cut
876
877sub reverse_geocode {
878
62
3868
        my $self = shift;
879
62
67
        my $params = Params::Get::get_params('latlng', \@_);
880
881
60
544
        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
58
84
        my ($latitude, $longitude) = split(/,/, $latlng);
885
58
125
        $params->{'lat'} //= $latitude;
886
58
95
        $params->{'lon'} //= $longitude;
887
888        # Check L1 / L2 cache before hitting any backend
889
58
73
        if(my $rc = $self->_cache($latlng)) {
890
5
9
                return $rc;
891        }
892
893
53
52
        my @call_details = caller(0);
894
895
53
53
338
55
        foreach my $g (@{$self->{geocoders}}) {
896
54
34
                my $geocoder = $g;
897
898                # Apply the per-geocoder limit guard for hashref entries
899
54
54
                if(ref($geocoder) eq 'HASH') {
900
5
9
                        if(exists($geocoder->{'limit'}) && defined(my $limit = $geocoder->{'limit'})) {
901
4
6
                                print "limit: $limit\n" if($self->{'debug'});
902
4
5
                                if($limit <= 0) {
903
3
5
                                        next;
904                                }
905
1
0
                                $geocoder->{'limit'}--;
906                        }
907
2
3
                        $geocoder = $g->{'geocoder'};
908                }
909
910
51
56
                print 'trying ', ref($geocoder), "\n" if($self->{'debug'});
911
912
51
51
                if(wantarray) {
913                        # ── List context: collect all address strings from this geocoder ───
914
17
11
                        my @rc;
915                        my @locs;
916
17
17
17
13
34
26
                        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
17
76
                        if($@ =~ /Unknown parameter.*latlng|latlng.*[Uu]nknown/s) {
924
2
2
2
3
                                my %no_latlng = %{$params};
925
2
3
                                delete $no_latlng{'latlng'};
926
2
2
                                $@ = '';
927
2
2
1
3
                                eval { @locs = $geocoder->reverse_geocode(%no_latlng) };
928                        }
929
930
17
30
                        if($@) {
931
3
3
3
9
                                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
3
6
                                $self->_warn(ref($geocoder), " '$latlng': $@");
940
3
768
                                next;
941                        }
942
943
14
19
                        print Data::Dumper->new([\@locs])->Dump() if($self->{'debug'} >= 2);
944
945
14
17
                        foreach my $loc (@locs) {
946
19
21
                                if(my $name = $loc->{'display_name'}) {
947                                        # OSM returns the full address in display_name
948
15
20
                                        CORE::push @rc, $name;
949                                } elsif($loc->{'city'}) {
950                                        # Geo::Coder::CA: build the address from individual fields
951
2
4
                                        CORE::push @rc, _build_ca_address($loc);
952                                } elsif($loc->{features}) {
953                                        # GeoApify: formatted string inside a features array
954                                        CORE::push @rc,
955
2
4
                                                $loc->{features}[0]->{properties}{formatted};
956
2
2
                                        last;   # only one result from this provider
957                                }
958                        }
959
960
14
14
12
48
                        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
14
18
                        $self->_cache($latlng, \@rc);
970
14
40
                        return @rc;
971
972                } else {
973                        # ── Scalar context: return the first address string ────────────────
974                        my $rc = $self->_cache($latlng)
975
34
34
34
35
23
43
                                // eval { $geocoder->reverse_geocode(%{$params}) };
976
977                        # Same strict-validation fallback as the list-context path above
978
34
201
                        if($@ =~ /Unknown parameter.*latlng|latlng.*[Uu]nknown/s) {
979
7
7
8
13
                                my %no_latlng = %{$params};
980
7
7
                                delete $no_latlng{'latlng'};
981
7
8
                                $@ = '';
982
7
7
6
9
                                $rc = eval { $geocoder->reverse_geocode(%no_latlng) };
983                        }
984
985
34
76
                        if($@) {
986
7
7
7
26
                                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
7
22
                                $self->_warn(ref($geocoder), " '$latlng': $@");
995
7
1999
                                next;
996                        }
997
998                        # A bare string needs no further processing
999
27
32
                        next unless defined $rc;
1000
26
27
                        if(!ref($rc)) {
1001
3
3
3
11
                                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
3
7
                                return $rc;
1010                        }
1011
1012
23
36
                        print Data::Dumper->new([$rc])->Dump() if($self->{'debug'} >= 2);
1013
1014
23
55
                        if(my $name = $rc->{'display_name'}) {
1015                                # OSM
1016
18
18
14
66
                                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
18
21
                                return $self->_cache($latlng, $name);
1025                        }
1026
1027
5
6
                        if($rc->{'city'}) {
1028                                # Geo::Coder::CA
1029
3
6
                                my $name = _build_ca_address($rc);
1030
3
3
2
10
                                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
3
5
                                return $self->_cache($latlng, $name);
1039                        }
1040
1041
2
5
                        if($rc->{features}) {
1042                                # GeoApify
1043
2
2
                                my $name = $rc->{features}[0]->{properties}{formatted};
1044
2
2
2
7
                                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
2
4
                                return $self->_cache($latlng, $name);
1053                        }
1054                }
1055        }
1056
1057
13
39
        return;
1058}
1059
1060# =============================================================================
1061
1062 - 1098
=head2 log

Returns an arrayref of log entries accumulated since the last C<flush()>.
Each entry is a hashref with the keys: C<line>, C<location>, C<timetaken>,
C<geocoder>, C<wantarray>, and either C<result> or C<error>.

    foreach my $entry (@{ $list->log() }) {
        printf "%s: %.3fs via %s\n",
            $entry->{location},
            $entry->{timetaken},
            $entry->{geocoder};
    }

=head3 API SPECIFICATION

=head4 INPUT

    # No parameters accepted

=head4 OUTPUT

    # Return::Set schema
    ARRAYREF of HASHREF
    [
        {
            line      => Int,
            location  => Str,
            timetaken => Num,
            geocoder  => Str | 'cache',
            wantarray => Bool,
            result    => HASHREF | ARRAYREF | Str,   # on success
            error     => Str,                        # on failure
        },
        ...
    ]

=cut
1099
1100sub log {
1101
37
2598
        my $self = shift;
1102
1103        # Guard against the state left by flush(); always return a valid arrayref
1104
37
85
        return $self->{'log'} // [];
1105}
1106
1107# =============================================================================
1108
1109 - 1128
=head2 flush

Clears all accumulated log entries and returns C<$self> to allow chaining.

    $list->geocode('Paris, France');
    my $entries = $list->log();
    $list->flush()->geocode('London, UK');   # chained

=head3 API SPECIFICATION

=head4 INPUT

    # No parameters accepted

=head4 OUTPUT

    # Return::Set schema
    OBJECT blessed into Geo::Coder::List   # $self, for chaining

=cut
1129
1130sub flush {
1131
15
90
        my $self = shift;
1132
1133        # Reset to an empty arrayref so log() always returns a valid reference
1134
15
17
        $self->{'log'} = [];
1135
1136
15
16
        return $self;
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
1156sub _build_ca_address
1157{
1158
16
15389
        my $loc = $_[0];
1159
16
10
        my $name  = '';
1160
1161
16
19
        if(my $usa = $loc->{'usa'}) {
1162                # US address layout inside a CA result
1163
5
10
                $name  = $usa->{'usstnumber'} // '';
1164                # Street name follows number with a space; if no number, no leading space
1165
5
13
                $name .= ($name ? ' ' : '') . $usa->{'usstaddress'} if $usa->{'usstaddress'};
1166                # City, state, country each separated by ', '; skip separator if name empty
1167
5
10
                $name .= ($name ? ', ' : '') . $usa->{'uscity'}     if $usa->{'uscity'};
1168
5
9
                $name .= ($name ? ', ' : '') . $usa->{'state'}      if $usa->{'state'};
1169                # Country is always appended for the US branch
1170
5
8
                $name .= ($name ? ', ' : '') . 'USA';
1171        } else {
1172                # Canadian address layout
1173
11
19
                $name  = $loc->{'stnumber'} // '';
1174                # Street name follows number with a space; if no number, no leading space
1175
11
28
                $name .= ($name ? ' ' : '') . $loc->{'staddress'}   if $loc->{'staddress'};
1176
11
23
                $name .= ($name ? ', ' : '') . $loc->{'city'}        if $loc->{'city'};
1177
11
17
                $name .= ($name ? ', ' : '') . $loc->{'prov'}        if $loc->{'prov'};
1178        }
1179
1180
16
17
        return $name;
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
1211sub _cache {
1212
510
924
        my $self = shift;
1213
510
281
        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
510
398
        if(scalar(@_)) {
1220
218
121
                my $value = shift;
1221
1222                # Store a sentinel for not-found so we can skip backends on repeat calls
1223
218
161
                my $stored = defined($value) ? $value : $NOT_FOUND_SENTINEL;
1224
218
285
                $self->{locations}->{$key} = $stored;
1225
1226
218
109
                my $rc = $value;
1227
1228
218
174
                if($self->{'cache'}) {
1229
24
16
                        my $duration;
1230
1231
24
59
                        if(ref($value) eq 'ARRAY') {
1232
8
8
3
33
                                foreach my $item (@{$value}) {
1233                                        # Blessed objects (e.g. Geo::Location::Point) may hold
1234                                        # unserializable handles; stringify their geocoder field too
1235
8
17
                                        if(blessed($item) && ref($item->{'geocoder'})) {
1236
2
2
                                                $item->{'geocoder'} = ref($item->{'geocoder'});
1237                                        }
1238
1239
8
13
                                        next unless ref($item) eq 'HASH';
1240
1241                                        # Serialise the geocoder object to its class name for storage
1242
6
7
                                        $item->{'geocoder'} = ref($item->{'geocoder'});
1243
1244                                        # Strip everything except geometry to keep the L2 entry small
1245
6
7
                                        unless($self->{'debug'}) {
1246
5
15
6
19
                                                while(my ($k, $v) = each %{$item}) {
1247
10
16
                                                        delete $item->{$k} unless($k eq 'geometry');
1248                                                }
1249                                        }
1250
1251
6
9
                                        unless(defined($item->{geometry}{location}{lat})) {
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
4
11
                                                $duration //= $self->{'cache_part_duration'};
1261
4
4
                                                $rc = undef;
1262                                        }
1263                                }
1264
1265                                # All items were clean: use the full hit duration
1266
8
11
                                $duration //= $self->{'cache_hit_duration'};
1267
1268                        } elsif(ref($value) eq 'HASH') {
1269
13
13
                                $value->{'geocoder'} = ref($value->{'geocoder'});
1270
1271
13
18
                                unless($self->{'debug'}) {
1272
12
52
25
57
                                        while(my ($k, $v) = each %{$value}) {
1273
40
43
                                                delete $value->{$k} unless($k eq 'geometry');
1274                                        }
1275                                }
1276
1277
13
24
                                if(defined($value->{geometry}{location}{lat})) {
1278                                        # Confirmed location: cache for a full month
1279
8
11
                                        $duration = $self->{'cache_hit_duration'};
1280                                } elsif(defined($value->{geometry})) {
1281                                        # Partial geometry: may be a transient failure, retry soon
1282
5
5
                                        $duration = $self->{'cache_part_duration'};
1283
5
4
                                        $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
3
6
                                if(ref($value) && ref($value->{'geocoder'})) {
1299
0
0
                                        $value->{'geocoder'} = ref($value->{'geocoder'});
1300                                }
1301
3
3
                                $duration = $self->{'cache_hit_duration'};
1302                        }
1303
1304
24
30
                        print Data::Dumper->new([$value])->Dump() if($self->{'debug'});
1305
1306                        # Do not push the not-found sentinel into L2
1307
24
166
                        if(!defined($value)) {
1308                                # value is not-found; L1 sentinel is sufficient
1309                        } elsif(ref($self->{'cache'}) eq 'HASH') {
1310
11
12
                                $self->{'cache'}->{$key} = $value;
1311                        } else {
1312
10
18
                                $self->{'cache'}->set($key, $value, $duration);
1313                        }
1314                }
1315
1316
218
243
                return $rc;
1317        }
1318
1319        # ── Read path ─────────────────────────────────────────────────────────
1320
1321        # Check L1 first (in-process, no serialisation cost)
1322
292
227
        my $rc = $self->{'locations'}->{$key};
1323
1324        # Fall through to L2 only when L1 has no entry for this key
1325
292
441
        if((!defined($rc)) && $self->{'cache'}) {
1326
7
9
                if(ref($self->{'cache'}) eq 'HASH') {
1327
5
5
                        $rc = $self->{'cache'}->{$key};
1328                } else {
1329
2
4
                        $rc = $self->{'cache'}->get($key);
1330                }
1331        }
1332
1333
292
322
        return unless defined $rc;
1334
1335        # Translate the not-found sentinel back to undef for the caller
1336
46
94
        return if ref($rc) && (ref($rc) eq _NOT_FOUND_CLASS);
1337
1338        # Restore the convenience aliases that were stripped before L2 storage
1339
39
43
        if(ref($rc) eq 'HASH') {
1340
28
41
                return unless defined($rc->{geometry}{location}{lat});
1341
27
43
                $rc->{'lat'} //= $rc->{geometry}{location}{lat};
1342
27
29
                $rc->{'lng'} //= $rc->{geometry}{location}{lng};
1343
27
40
                $rc->{'lon'} //= $rc->{geometry}{location}{lng};
1344        }
1345
1346
38
32
        return $rc;
1347}
1348
1349# Emit a debug message somewhere
1350sub _debug {
1351
0
0
        my $self = shift;
1352
1353
0
0
        if(my $logger = $self->{logger}) {
1354
0
0
                $logger->debug(@_);
1355        }
1356
0
0
        if($self->{debug}) {
1357
0
0
                print @_, "\n";
1358        }
1359}
1360
1361# Emit a warning message somewhere
1362sub _warn {
1363
26
117
        my $self = shift;
1364
1365
26
35
        if(my $logger = $self->{logger}) {
1366
26
61
                $logger->warn(@_);
1367        } else {
1368
0
0
                Carp::carp(@_);
1369        }
1370}
1371
1372# Emit an error message somewhere
1373sub _error {
1374
5
5
        my $self = shift;
1375
1376
5
9
        if(my $logger = $self->{logger}) {
1377
5
17
                $logger->error(@_);
1378
5
2260
                die @_;
1379        } else {
1380
0
                Carp::croak(@_);
1381        }
1382}
1383
1384# =============================================================================
1385# DOCUMENTATION
1386# =============================================================================
1387
1388 - 1571
=head1 AUTHOR

Nigel Horne, C<< <njh at nigelhorne.com> >>

=head1 BUGS

Please report any bugs or feature requests to
C<bug-geo-coder-list at rt.cpan.org>, or through the web interface at
L<https://rt.cpan.org/NoAuth/ReportBug.html?Queue=Geo-Coder-List>.

Known limitations:

=over 4

=item * C<reverse_geocode()> does not yet support L<Geo::Location::Point> objects.

=item * When C<Geo::GeoNames> returns multiple candidates, only the first
element of each sub-array is considered.

=back

=head1 SEE ALSO

=over 4

=item * L<Test Dashboard|https://nigelhorne.github.io/Geo-Coder-List/coverage/>

=item * L<Geo::Coder::All>

=item * L<Geo::Coder::GooglePlaces>

=item * L<Geo::Coder::Many>

=item * L<Configure an Object at Runtime|Object::Configure>

=item * L<Readonly>

=back

=head1 SUPPORT

You can find documentation for this module with the perldoc command:

    perldoc Geo::Coder::List

=over 4

=item * RT: CPAN's request tracker

L<https://rt.cpan.org/NoAuth/Bugs.html?Dist=Geo-Coder-List>

=item * MetaCPAN

L<https://metacpan.org/release/Geo-Coder-List>

=back

=encoding utf-8

=head2 FORMAL SPECIFICATION

=head3 new

    List_State
    â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
    geocoders : seq (Geocoder | RegexGeocoder)
    L1        : LocationStr ↛ (GeoResult | NotFound)
    log       : seq LogEntry
    debug     : â„•
    cache?    : L2Cache

    new
    â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
    List_State
    params? : â„™(Key × Value)
    â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
    geocoders = ⟨⟩
    L1        = ∅
    log       = ⟨⟩
    debug     = params?.debug ∣ DEBUG_DEFAULT
    cache     = params?.cache ∣ ⊥

=head3 push

    push
    â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
    Î”List_State
    g? : Geocoder | RegexGeocoder
    â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
    geocoders' = geocoders ⌢ ⟨g?⟩
    L1'        = L1
    log'       = log
    â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
    where RegexGeocoder ::= { regex    : Regex
                             ; geocoder : Geocoder
                             ; limit?  : â„• }

=head3 geocode

    LocationStr ::= { s : seq Char | s ≠ ⟨⟩ ∧ ∃ c : s • c ∉ Digit }
    GeoResult   ::= HASHREF with geometry.location.{lat,lng} : ℝ

    geocode
    â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
    Î”List_State
    loc?    : LocationStr
    result! : GeoResult | ⊥
    â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
    loc? ∈ dom L1
      âŸ¹ result! = L1(loc?)
         âˆ§ log' = log ⌢ ⟨{geocoder ↦ cache; timetaken ↦ 0}⟩

    loc? ∉ dom L1
      âŸ¹ (∃ i : 1..#geocoders •
            applies(geocoders i, loc?)
            âˆ§ result! = Normalize(geocoders i . geocode(loc?))
            âˆ§ L1' = L1 ⊕ {loc? ↦ result!}
            âˆ§ log' = log ⌢ ⟨{geocoder ↦ class(geocoders i)}⟩)
         âˆ¨ (result! = ⊥ ∧ L1' = L1 ⊕ {loc? ↦ ⊥})

    applies(g, loc) ≙
        (g isa Geocoder)
      âˆ¨ (g isa RegexGeocoder ∧ loc ∈ matches(g.regex) ∧ g.limit > 0)

=head3 ua SPECIFICATION

    ua
    â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
    ÎžList_State
    ua?  : UserAgent
    ua!  : UserAgent
    â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
    âˆ€ g : ran geocoders • g.ua = ua?
    ua!  = ua?

=head3 reverse_geocode

    LatLngStr ::= { s : seq Char
                  | s matches /^[-+]?\d+\.?\d*,[-+]?\d+\.?\d*$/ }

    reverse_geocode
    â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
    Î”List_State
    latlng? : LatLngStr
    result! : seq Char | ⊥
    â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
    latlng? ∈ dom L1
      âŸ¹ result! = L1(latlng?)

    latlng? ∉ dom L1
      âŸ¹ (∃ i : 1..#geocoders •
            applies(geocoders i, latlng?)
            âˆ§ result! = geocoders i . reverse_geocode(latlng?)
            âˆ§ L1' = L1 ⊕ {latlng? ↦ result!})
         âˆ¨ result! = ⊥

=head3 log

    log
    â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
    ÎžList_State
    result! : seq LogEntry
    â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
    result! = log

=head3 flush

    flush
    â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
    Î”List_State
    â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
    log'       = ⟨⟩
    geocoders' = geocoders
    L1'        = L1

=head1 LICENSE AND COPYRIGHT

Copyright 2016-2026 Nigel Horne.

Usage is subject to the GPL2 licence terms.
If you use it,
please let me know.

=cut
1572
15731;