File Coverage

File:blib/lib/Geo/Coder/Free/MaxMind.pm
Coverage:15.2%

linestmtbrancondsubpodtimecode
1package Geo::Coder::Free::MaxMind;
2
3# sqlite3 cities.sql
4#       select * from cities where City like '%north shields%';
5# - note 'J5'
6# grep 'GB.ENG.J5' admin2.db
7
8# FIXME: If you search for something like "Sheppy, Kent, England" in list
9#       context, it returns them all.  That's a lot! Should limit to, say
10#       10 results (that number should be tuneable, and be a LIMIT in DB.pm)
11#       And as the correct spelling in Sheppey, arguably it should return nothing
12
13
15
15
15
35
14
176
use strict;
14
15
15
15
22
9
371
use warnings;
15
15
15
15
28
10
68
use autodie qw(:all);
16
17
15
15
15
37404
37
133
use Geo::Coder::Free::DB::MaxMind::admin1;
18
15
15
15
121824
28
95
use Geo::Coder::Free::DB::MaxMind::admin2;
19
15
15
15
116588
26
70
use Geo::Coder::Free::DB::MaxMind::cities;
20
15
15
15
100184
15
186
use Geo::Location::Point;
21
15
15
15
4047
30123
208
use Module::Info;
22
15
15
15
37
14
397
use Carp;
23
15
15
15
27
12
110
use File::Spec;
24
15
15
15
25
14
245
use Params::Get;
25
15
15
15
29
15
151
use Locale::CA;
26
15
15
15
28
10
117
use Locale::US;
27
15
15
15
28
15
118
use CHI;
28
15
15
15
3098
285918
1237
use Locale::Country;
29
15
15
15
59
15
33752
use Scalar::Util;
30
31our %admin1cache;
32our %admin2cache;     # name → region code (e.g. 'Kent' → 'P5')
33our %admin2cache_rev; # region code → name (inverse; avoids O(N) scan in _prepare)
34
35# Lazily-initialised Locale singletons — constructing Locale::US / Locale::CA
36# on every geocode call for US/Canadian addresses was the top object-churn
37# hotspot.  These are module-level my-variables (not exported) so they survive
38# across object instances and avoid the construction cost after the first call.
39my $_locale_us;
40my $_locale_ca;
41
42sub _prepare;
43
44# Some locations aren't found because of inconsistencies in the way things are stored - these are some values I know
45# FIXME: Should be in a configuration file
46my %known_locations = (
47        'Newport Pagnell, Buckinghamshire, England' => {
48                'latitude' => 52.08675,
49                'longitude' => -0.72270
50        },
51);
52
53 - 61
=head1 NAME

Geo::Coder::Free::MaxMind - Provides a geocoding functionality using the MaxMind and GeoNames databases

=head1 VERSION

Version 0.42

=cut
62
63our $VERSION = '0.43';
64
65 - 116
=head1 SYNOPSIS

    use Geo::Coder::Free::MaxMind;

    my $geocoder = Geo::Coder::Free::MaxMind->new();
    my $location = $geocoder->geocode(location => 'Ramsgate, Kent, UK');

=head1 DESCRIPTION

Geo::Coder::Free::MaxMind provides an interface to free databases.

Refer to the source URL for licencing information for these files:
cities.csv is from L<https://www.maxmind.com/en/free-world-cities-database>;
admin1.db is from L<http://download.geonames.org/export/dump/admin1CodesASCII.txt>;
admin2.db is from L<http://download.geonames.org/export/dump/admin2Codes.txt>;

See also L<http://download.geonames.org/export/dump/allCountries.zip>

To significantly speed this up,
gunzip cities.csv and run it through the db2sql script to create an SQLite file.

=head1 METHODS

=head2 new

    $geocoder = Geo::Coder::Free::MaxMind->new();

Takes one optional parameter, directory,
which tells the library where to find the files admin1db, admin2.db and cities.[sql|csv.gz].
If that parameter isn't given, the module will attempt to find the databases, but that can't be guaranteed

There are 3 levels to the Maxmind database.
Here's the method to find the location of Sittingbourne, Kent, England:
1) admin1.db contains admin areas such as counties, states and provinces
   A typical line is:
     US.MD      Maryland        Maryland        4361885
   So a look-up of 'Maryland' will get the concatenated code 'US.MD'
   Note that GB has England, Scotland and Wales at this level, not the counties
     GB.ENG     England England 6269131
   So a look-up of England will give the concatenated code of GB.ENG for use in admin2.db
2) admin2.db contains admin areas drilled down from the admin1 database such as US counties
   Note that GB has counties
   A typical line is:
    GB.ENG.G5   Kent    Kent    3333158
   So a look-up of 'Kent' with a concatenated code to start with 'GB.ENG' will code the region G5 for use in cities.sql
3) cities.sql contains the latitude and longitude of the place we want, so a search for 'sittingbourne' in the
   region 'g5' will give
     gb,sittingbourne,Sittingbourne,G5,41148,51.333333,.75

The admin2.db is far from comprehensive, see Makefile.PL for some entries that are added manually.

=cut
117
118sub new
119{
120
98
1
117
        my $class = shift;
121
122        # Handle hash or hashref arguments
123
98
182
        my $args = Params::Get::get_params(undef, @_);
124
125
98
1442
        if(!defined($class)) {
126                # Geo::Coder::Free::Local->new not Geo::Coder::Free::Local::new
127                # carp(__PACKAGE__, ' use ->new() not ::new() to instantiate');
128                # return;
129
130                # FIXME: this only works when no arguments are given
131
0
0
                $class = __PACKAGE__;
132        } elsif(Scalar::Util::blessed($class)) {
133                # If $class is an object, clone it with new arguments
134
0
0
0
0
0
0
                return bless { %{$class}, %{$args} }, ref($class);
135        }
136
137
98
531
        my $directory = $args->{'directory'} || Module::Info->new_from_loaded(__PACKAGE__)->file();
138
98
5074
        $directory =~ s/\.pm$//;
139
140
98
1594
        if(!-d $directory) {
141
0
0
                Carp::croak(ref($class), ": directory $directory doesn't exist");
142        }
143
144        Database::Abstraction::init({
145                cache_duration => '1 day',
146
98
804
                %{$args},
147                directory => File::Spec->catfile($directory, 'databases'),
148
98
126
                cache => $args->{'cache'} || CHI->new(driver => 'Memory', global => 1)
149        });
150
151        # Return the blessed object
152        return bless {
153
98
396617
                cache => $args->{'cache'} || CHI->new(driver => 'Memory', global => 1),
154        }, $class;
155}
156
157 - 173
=head2 geocode

    $location = $geocoder->geocode(location => $location);

    print 'Latitude: ', $location->lat(), "\n";
    print 'Longitude: ', $location->long(), "\n";

    # TODO:
    # @locations = $geocoder->geocode('Portland, USA');
    # diag 'There are Portlands in ', join (', ', map { $_->{'state'} } @locations);

    # This will return one place in New Brunwsick, not them all
    # TODO: Arguably it should get them all from the database (or at least say the first 100) and return the central location
    my @locations = $geocoder->geocode({ location => 'New Brunswick, Canada' });
    die if(scalar(@locations) != 1);

=cut
174
175sub geocode {
176
1
1
1
        my $self = shift;
177
1
1
        my %params;
178
179        # Try hard to support whatever API that the user wants to use
180
1
5
        if(!ref($self)) {
181
0
0
                if(scalar(@_)) {
182
0
0
                        return(__PACKAGE__->new()->geocode(@_));
183                } elsif(!defined($self)) {
184                        # Geo::Coder::Free->geocode()
185
0
0
                        Carp::croak('Usage: ', __PACKAGE__, '::geocode(location => $location|scantext => $text)');
186                } elsif($self eq __PACKAGE__) {
187
0
0
                        Carp::croak("Usage: $self", '::geocode(location => $location|scantext => $text)');
188                }
189
0
0
                return(__PACKAGE__->new()->geocode($self));
190        } elsif(ref($self) eq 'HASH') {
191
0
0
                return(__PACKAGE__->new()->geocode($self));
192        } elsif(ref($_[0]) eq 'HASH') {
193
1
1
1
1
                %params = %{$_[0]};
194        # } elsif(ref($_[0]) && (ref($_[0] !~ /::/))) {
195        } elsif(ref($_[0])) {
196
0
0
                Carp::croak('Usage: ', __PACKAGE__, '::geocode(location => $location|scantext => $text)');
197        } elsif(scalar(@_) && (scalar(@_) % 2 == 0)) {
198
0
0
                %params = @_;
199        } else {
200
0
0
                $params{'location'} = shift;
201        }
202
203        my $location = $params{location}
204
1
3
                or Carp::croak('Usage: geocode(location => $location)');
205
206        # Fail when the input is just a set of numbers
207
1
3
        if($location !~ /\D/) {
208
0
0
                Carp::croak('Usage: ', __PACKAGE__, ": invalid input to geocode(), $location");
209
0
0
                return;
210        }
211
212
1
3
        if($location =~ /^(.+),\s*Washington\s*DC,(.+)$/) {
213
0
0
                $location = "$1, Washington, DC, $2";
214        }
215
216
1
2
        if(my $rc = $known_locations{$location}) {
217                # return $known_locations{$location};
218                return Geo::Location::Point->new({
219                        'lat' => $rc->{'latitude'},
220                        'long' => $rc->{'longitude'},
221                        'lon' => $rc->{'longitude'},
222
0
0
                        'lng' => $rc->{'longitude'},
223                        'location' => $location,
224                        'database' => 'MaxMind'
225                });
226        }
227
228
1
5
        return unless(($location =~ /,/) || $params{'region'}); # Not well formed, or an attempt to find the location of an entire country
229
230        # Check cache first
231
0
        my $cached_result = $self->{'cache'}->get($location);
232
0
        return $cached_result if($cached_result);
233
234
0
        my $county;
235        my $state;
236
0
        my $country;
237
0
        my $country_code;
238
0
        my $concatenated_codes;
239
0
        my $region_only;
240
241
0
        if($location =~ /^([\w\s\-]+),([\w\s]+),([\w\s]+)?$/) {
242                # Turn 'Ramsgate, Kent, UK' into 'Ramsgate'
243
0
                $location = $1;
244
0
                $county = $2;
245
0
                $country = $3;
246
0
                $location =~ s/\-/ /g;
247
0
                $county =~ s/^\s//g;
248
0
                $county =~ s/\s$//g;
249
0
                $country =~ s/^\s//g;
250
0
                $country =~ s/\s$//g;
251
0
                if($location =~ /^St\.? (.+)/) {
252
0
                        $location = "Saint $1";
253                }
254
0
                if(($country =~ /^(Canada|United States|USA|US)$/)) {
255
0
                        $state = $county;
256
0
                        $county = undef;
257                }
258        } elsif($location =~ /^([\w\s\-]+),([\w\s]+),([\w\s]+),\s*(Canada|United States|USA|US)?$/) {
259
0
                $location = $1;
260
0
                $county = $2;
261
0
                $state = $3;
262
0
                $country = $4;
263
0
                $county =~ s/^\s//g;
264
0
                $county =~ s/\s$//g;
265
0
                $state =~ s/^\s//g;
266
0
                $state =~ s/\s$//g;
267                # $country =~ s/^\s//g;
268
0
                $country =~ s/\s$//g;
269        } elsif($location =~ /^[\w\s-],[\w\s-]/) {
270
0
                Carp::carp(__PACKAGE__, ": can't parse and handle $location");
271
0
                return;
272        } elsif(($location =~ /^[\w\s-]+$/) && (my $region = $params{'region'})) {
273
0
                $location =~ s/^\s//g;
274
0
                $location =~ s/\s$//g;
275
0
                $country = uc($region);
276        } elsif($location =~ /^([\w\s-]+),\s*(\w+)$/) {
277                # e.g. a county in the UK or a state in the US
278
0
                $county = $1;
279
0
                $country = $2;
280
0
                $county =~ s/^\s//g;
281
0
                $county =~ s/\s$//g;
282                # $country =~ s/^\s//g;
283
0
                $country =~ s/\s$//g;
284
0
                $region_only = 1;       # Will only return one match, not every match in the region
285        } else {
286                # Carp::croak(__PACKAGE__, ' only supports towns, not full addresses');
287
0
                return;
288        }
289
0
        my $countrycode;
290
0
        if($country) {
291
0
                if(defined($country) && (($country eq 'UK') || ($country eq 'United Kingdom') || ($country eq 'England'))) {
292
0
                        $country = 'Great Britain';
293
0
                        $concatenated_codes = 'GB';
294                }
295
0
                $countrycode = country2code($country);
296                # if($county && $countrycode) {
297                # }
298
299
0
                if($state && $admin1cache{$state}) {
300
0
                        $concatenated_codes = $admin1cache{$state};
301                } elsif($admin1cache{$country} && !defined($state)) {
302
0
                        $concatenated_codes = $admin1cache{$country};
303                } else {
304
0
                        $self->{'admin1'} //= Geo::Coder::Free::DB::MaxMind::admin1->new(no_entry => 1) or Carp::croak("Can't open the admin1 database");
305
306
0
                        if(my $admin1 = $self->{'admin1'}->fetchrow_hashref(asciiname => $country)) {
307
0
                                $concatenated_codes = $admin1->{'concatenated_codes'};
308
0
                                $admin1cache{$country} = $concatenated_codes;
309                        } elsif($state) {
310
0
                                $concatenated_codes = uc($countrycode);
311
0
                                if($state =~ /^[A-Z]{2}$/) {
312
0
                                        $concatenated_codes .= ".$state";
313                                } else {
314
0
                                        $country_code = $concatenated_codes;
315
0
0
                                        my @admin1s = @{$self->{'admin1'}->selectall_hashref(asciiname => $state)};
316
0
                                        foreach my $admin1(@admin1s) {
317
0
                                                if($admin1->{'concatenated_codes'} =~ /^\Q$concatenated_codes\E\./i) {
318
0
                                                        $concatenated_codes = $admin1->{'concatenated_codes'};
319
0
                                                        last;
320                                                }
321                                        }
322                                }
323
0
                                $admin1cache{$state} = $concatenated_codes;
324                        } elsif($countrycode) {
325
0
                                $concatenated_codes = uc($countrycode);
326
0
                                $admin1cache{$country} = $concatenated_codes;
327                        } elsif(Locale::Country::code2country($country)) {
328
0
                                $concatenated_codes = uc($country);
329
0
                                $admin1cache{$country} = $concatenated_codes;
330                        }
331                }
332        }
333
0
        return unless(defined($concatenated_codes));
334
335
0
        my @admin2s;
336        my $region;
337
0
        my @regions;
338
0
        if($country =~ /^(United States|USA|US)$/) {
339
0
                if($county && (length($county) > 2)) {
340
0
                        if(my $twoletterstate = ($_locale_us //= Locale::US->new())->{state2code}{uc($county)}) {
341
0
                                $county = $twoletterstate;
342                        }
343                }
344
0
                if($state && (length($state) > 2)) {
345
0
                        if(my $twoletterstate = ($_locale_us //= Locale::US->new())->{state2code}{uc($state)}) {
346
0
                                $state = $twoletterstate;
347                        }
348                }
349        } elsif(($country eq 'Canada') && $state && (length($state) > 2)) {
350
0
                if(($_locale_ca //= Locale::CA->new())->{province2code}{uc($state)}) {
351                        # FIXME:  I can't see that province locations are stored in cities.csv
352
0
                        return unless(defined($location));      # OK if searching for a city, that works
353                }
354        }
355
356
0
        $self->{'admin2'} //= Geo::Coder::Free::DB::MaxMind::admin2->new(no_entry => 1) or Carp::croak("Can't open the admin2 database");
357
358
0
        if(defined($county) && ($county =~ /^[A-Z]{2}$/) && ($country =~ /^(United States|USA|US)$/)) {
359                # US state. Not Canadian province.
360
0
                $region = $county;
361        } elsif($county && $admin1cache{$county}) {
362
0
                $region = $admin1cache{$county};
363        } elsif($county && $admin2cache{$county}) {
364
0
                $region = $admin2cache{$county};
365        } elsif(defined($state) && $admin2cache{$state} && !defined($county)) {
366
0
                $region = $admin2cache{$state};
367        } else {
368
0
                if(defined($county) && ($county eq 'London')) {
369
0
                        @admin2s = $self->{'admin2'}->selectall_hash(asciiname => $location);
370                } elsif(defined($county)) {
371
0
                        @admin2s = $self->{'admin2'}->selectall_hash(asciiname => $county);
372                }
373
0
                foreach my $admin2(@admin2s) {
374
0
                        if($admin2->{'concatenated_codes'} =~ $concatenated_codes) {
375
0
                                $region = $admin2->{'concatenated_codes'};
376
0
                                if($region =~ /^[A-Z]{2}\.([A-Z]{2})\./) {
377
0
                                        my $rc = $1;
378
0
                                        if(defined($state) && ($state =~ /^[A-Z]{2}$/)) {
379
0
                                                if($state eq $rc) {
380
0
                                                        $region = $rc;
381
0
                                                        @regions = ();
382
0
                                                        last;
383                                                }
384                                        } else {
385
0
                                                push @regions, $region;
386
0
                                                push @regions, $rc;
387                                        }
388                                } else {
389
0
                                        push @regions, $region;
390                                }
391                        }
392                }
393
0
                if($state && !defined($region)) {
394
0
                        if($state =~ /^[A-Z]{2}$/) {
395
0
                                $region = $state;
396
0
                                @regions = ();
397                        } else {
398
0
                                @admin2s = $self->{'admin2'}->selectall_hash(asciiname => $state);
399
0
                                foreach my $admin2(@admin2s) {
400
0
                                        if($admin2->{'concatenated_codes'} =~ $concatenated_codes) {
401
0
                                                $region = $admin2->{'concatenated_codes'};
402
0
                                                last;
403                                        }
404                                }
405                        }
406                }
407        }
408
409
0
        if((scalar(@regions) == 0) && !defined($region)) {
410                # e.g. Unitary authorities in the UK
411                # admin[12].db columns are labelled ['concatenated_codes', 'name', 'asciiname', 'geonameId']
412
0
                @admin2s = $self->{'admin2'}->selectall_hash(asciiname => $location);
413
0
                if((scalar(@admin2s) == 0) && ($country =~ /^(Canada|United States|USA|US)$/) && ($location !~ /\sCounty/i)) {
414
0
                        $location .= ' County';
415
0
                        @admin2s = $self->{'admin2'}->selectall_hash(asciiname => $location);
416                }
417
0
                if(scalar(@admin2s) && defined($admin2s[0]->{'concatenated_codes'})) {
418
0
                        foreach my $admin2(@admin2s) {
419
0
                                my $concat = $admin2->{'concatenated_codes'};
420
0
                                if($concat =~ /^CA\.(\d\d)\./) {
421                                        # Canadian provinces are not stored in the same way as US states
422
0
                                        $region = $1;
423
0
                                        last;
424                                } elsif($concat =~ $concatenated_codes) {
425
0
                                        $region = $concat;
426
0
                                        last;
427                                }
428                        }
429                } elsif(defined($county)) {
430                        # e.g. states in the US
431
0
                        if(!defined($self->{'admin1'})) {
432
0
                                $self->{'admin1'} = Geo::Coder::Free::DB::MaxMind::admin1->new(no_entry => 1) or Carp::croak("Can't open the admin1 database");
433                        }
434
0
                        my @admin1s = $self->{'admin1'}->selectall_hash(asciiname => $county);
435
0
                        foreach my $admin1(@admin1s) {
436
0
                                if($admin1->{'concatenated_codes'} =~ /^\Q$concatenated_codes\E\./i) {
437
0
                                        $region = $admin1->{'concatenated_codes'};
438
0
                                        if(scalar(@admin1s) == 1) {
439
0
                                                $admin1cache{$county} = $region;
440                                        }
441
0
                                        last;
442                                }
443                        }
444                }
445        }
446
447
0
        if(!defined($self->{'cities'})) {
448                $self->{'cities'} = Geo::Coder::Free::DB::MaxMind::cities->new(
449
0
                        cache => $self->{cache} || CHI->new(driver => 'Memory', datastore => {}),
450                        no_entry => 1,
451                );
452        }
453
454
0
        my $options;
455
0
        if(defined($county) && ($county =~ /^[A-Z]{2}$/) && ($country =~ /^(United States|USA|US)$/)) {
456
0
                $options = { Country => 'us' };
457        } else {
458
0
                if($region_only) {
459
0
                        $options = {};
460                } else {
461
0
                        $options = { City => lc($location) };
462
0
                        $options->{'City'} =~ s/,\s*\w+$//;
463                }
464        }
465
0
        if($region) {
466
0
                $region =~ s/^.*\.//;    # keep only the rightmost dot-delimited component
467
0
                $options->{'Region'} = $region;
468
0
                if($country_code) {
469
0
                        $options->{'Country'} = lc($country_code);
470                }
471                # If there's more than one match, don't cache as we don't
472                # know which one will be matched later
473
0
                if(scalar(@admin2s) == 1) {
474
0
                        if($state) {
475
0
                                $admin2cache{$state}     = $region;
476
0
                                $admin2cache_rev{$region} = $state;
477                        } elsif($county) {
478
0
                                $admin2cache{$county}    = $region;
479
0
                                $admin2cache_rev{$region} = $county;
480                        }
481                }
482        }
483
484
0
        my $confidence = 0.5;
485
0
        if(my $c = $params{'region'}) {
486
0
                $options->{'Country'} = lc($c);
487
0
                $confidence = 0.1;
488        } elsif($countrycode) {
489
0
                $options->{'Country'} = $countrycode;
490
0
                $confidence = 0.1;
491        }
492        # This case nonsense is because DBD::CSV changes the columns to lowercase, whereas DBD::SQLite does not
493        # if(wantarray && (!$options->{'City'}) && !$region_only) {
494        # if(0) {       # We don't need to find all the cities in a state, which is what this would do
495                # my @rc = $self->{'cities'}->selectall_hash($options);
496                # if(scalar(@rc) == 0) {
497                        # if((!defined($region)) && !defined($params{'region'})) {
498                                # # Add code for this area to Makefile.PL and rebuild
499                                # Carp::carp(__PACKAGE__, ": didn't determine region from $location");
500                                # return;
501                        # }
502                        # # This would return all of the cities in the wrong region
503                        # if($countrycode) {
504                                # @rc = $self->{'cities'}->selectall_hash('Region' => ($region || $params{'region'}), 'Country' => $countrycode);
505                                # if(scalar(@rc) == 0) {
506                                        # return;
507                                # }
508                        # }
509                # }
510                # foreach my $city(@rc) {
511                        # if($city->{'Latitude'}) {
512                                # $city->{'latitude'} = delete $city->{'Latitude'};
513                                # $city->{'longitude'} = delete $city->{'Longitude'};
514                        # }
515                        # if($city->{'Country'}) {
516                                # $city->{'country'} = uc(delete $city->{'Country'});
517                        # }
518                        # if($city->{'Region'}) {
519                                # $city->{'state'} = uc(delete $city->{'Region'});
520                        # }
521                        # if($city->{'City'}) {
522                                # $city->{'city'} = uc(delete $city->{'AccentCity'});
523                                # delete $city->{'City'};
524                                # # Less likely to get false positives with long words
525                                # if(length($city->{'city'}) > 10) {
526                                        # if($confidence <= 0.8) {
527                                                # $confidence += 0.2;
528                                        # } else {
529                                                # $confidence = 1.0;
530                                        # }
531                                # }
532                        # }
533                        # $city->{'confidence'} = $confidence;
534                        # my $l = $options->{'City'};
535                        # if($options->{'Region'}) {
536                                # $l .= ', ' . $options->{'Region'};
537                        # }
538                        # if($options->{'Country'}) {
539                                # $l .= ', ' . ucfirst($options->{'Country'});
540                        # }
541                        # $city->{'location'} = $l;
542                # }
543                # # return @rc;
544                # my @locations;
545                #
546                # foreach my $l(@rc) {
547                        # if(exists($l->{'latitude'})) {
548                                # push @locations, Geo::Location::Point->new({
549                                        # 'lat' => $l->{'latitude'},
550                                        # 'long' => $l->{'longitude'},
551                                        # 'lon' => $l->{'longitude'},
552                                        # 'location' => $location,
553                                        # 'database' => 'MaxMind',
554                                        # 'maxmind' => $l,
555                                # });
556                        # # } else {
557                                # # Carp::carp(__PACKAGE__, ": $location has latitude of 0");
558                                # # return;
559                        # }
560                # }
561                #
562                # return @locations;
563        # }
564
0
        my $city = $self->{'cities'}->fetchrow_hashref($options);
565
0
        if(!defined($city)) {
566
0
                foreach $region(@regions) {
567
0
                        $region =~ s/^.*\.//;    # keep only the rightmost dot-delimited component
568
0
                        if($country =~ /^(United States|USA|US)$/) {
569
0
                                next unless($region =~ /^[A-Z]{2}$/);   # In the US, the regions are the states
570                        }
571
0
                        $options->{'Region'} = $region;
572
0
                        $city = $self->{'cities'}->fetchrow_hashref($options);
573
0
                        last if(defined($city));
574                }
575        }
576
577
0
        if(defined($city) && defined($city->{'Latitude'})) {
578                # Cache and return result
579
0
                delete $city->{'Region'} if(defined($city->{'Region'}) && ($city->{'Region'} =~ /^[A-Z]\d$/)); # E.g. Region = G5
580
0
                delete $city->{'Population'} if(defined($city->{'Population'}) && (length($city->{'Population'}) == 0));
581                my $rc = Geo::Location::Point->new({
582
0
0
                        %{$city},
583                        ('database' => 'MaxMind', 'confidence' => $confidence)
584                });
585
0
                $self->{cache}->set($location, $rc);
586
0
                return $rc;
587        }
588        # return $city;
589
0
        return;
590}
591
592 - 598
=head2  reverse_geocode

    $location = $geocoder->reverse_geocode(latlng => '37.778907,-122.39732');

Returns a string, or undef if it can't be found.

=cut
599
600sub reverse_geocode
601{
602
0
1
        my $self = shift;
603
0
        my %params;
604
605        # Try hard to support whatever API that the user wants to use
606
0
        if(!ref($self)) {
607
0
                if(scalar(@_)) {
608
0
                        return(__PACKAGE__->new()->reverse_geocode(@_));
609                } elsif(!defined($self)) {
610                        # Geo::Coder::Free->reverse_geocode()
611
0
                        Carp::croak('Usage: ', __PACKAGE__, '::reverse_geocode(latlng => "$lat,$long")');
612                } elsif($self eq __PACKAGE__) {
613
0
                        Carp::croak("Usage: $self", '::reverse_geocode(latlng => "$lat,$long")');
614                }
615
0
                return(__PACKAGE__->new()->reverse_geocode($self));
616        } elsif(ref($self) eq 'HASH') {
617
0
                return(__PACKAGE__->new()->reverse_geocode($self));
618        } elsif(ref($_[0]) eq 'HASH') {
619
0
0
                %params = %{$_[0]};
620        # } elsif(ref($_[0]) && (ref($_[0] !~ /::/))) {
621        } elsif(ref($_[0])) {
622
0
                Carp::croak('Usage: ', __PACKAGE__, '::reverse_geocode(latlng => "$lat,$long")');
623        } elsif(scalar(@_) && (scalar(@_) % 2 == 0)) {
624
0
                %params = @_;
625        } else {
626
0
                $params{'latlng'} = shift;
627        }
628
629
0
        my $latlng = $params{'latlng'};
630
631
0
        my $latitude;
632        my $longitude;
633
634
0
        if($latlng) {
635
0
                ($latitude, $longitude) = split(/,/, $latlng);
636        } else {
637
0
                $latitude //= $params{'lat'};
638
0
                $longitude //= $params{'lon'};
639
0
                $longitude //= $params{'long'};
640        }
641
642
0
        if((!defined($latitude)) || !defined($longitude)) {
643
0
                Carp::croak('Usage: ', __PACKAGE__, '::reverse_geocode(latlng => "$lat,$long")');
644        }
645
646
0
        if(!defined($self->{'cities'})) {
647                $self->{'cities'} = Geo::Coder::Free::DB::MaxMind::cities->new(
648
0
                        cache => $self->{cache} || CHI->new(driver => 'Memory', datastore => {}),
649                        no_entry => 1,
650                );
651        }
652
653        # Validate coordinates before interpolating into SQL — prevents injection
654        # if a caller passes a non-numeric latlng string.
655
0
        for my $c ($latitude, $longitude) {
656
0
                Carp::croak('Invalid coordinate value for reverse_geocode')
657                        unless defined($c) && $c =~ /^-?\d+\.?\d*$/;
658        }
659
660
0
        if(wantarray) {
661
0
                my @locs = $self->{'cities'}->execute("SELECT * FROM cities WHERE ((ABS(Latitude - $latitude)) < 0.01) AND ((ABS(Longitude - $longitude)) < 0.01)");
662
0
                foreach my $loc(@locs) {
663
0
                        $self->_prepare($loc);
664                }
665
0
0
                return map { Geo::Location::Point->new($_)->as_string() } @locs;
666        }
667
668        # Performance: the former code issued 5 sequential SQL queries with ever-
669        # widening radii (0.000001 → 0.01).  Replace with a single query that
670        # orders by Manhattan distance and caps at the maximum radius (0.01°).
671        # This eliminates 4 round-trips to SQLite on the common miss-until-last case.
672
0
        my $rc = $self->{'cities'}->execute(
673                "SELECT * FROM cities" .
674                " WHERE ABS(Latitude - $latitude) < 0.01 AND ABS(Longitude - $longitude) < 0.01" .
675                " ORDER BY (ABS(Latitude - $latitude) + ABS(Longitude - $longitude)) ASC" .
676                " LIMIT 1"
677        );
678
0
        if($rc) {
679
0
                $self->_prepare($rc);
680
0
                return Geo::Location::Point->new($rc)->as_string();
681        }
682
0
        return;
683}
684
685# Change 'Charing Cross, P5, Gb' to 'Charing Cross, London, Gb'
686sub _prepare {
687
0
        my ($self, $loc) = @_;
688
689
0
        if(my $region = $loc->{'Region'}) {
690                # O(1) reverse lookup — the former O(N) `while each %admin2cache` scan
691                # walked the entire cache comparing values.  %admin2cache_rev is kept in
692                # sync at every write site, giving a constant-time code→name translation.
693
0
                my $county = $admin2cache_rev{$region};
694
0
                if($county) {
695
0
                        $loc->{'Region'} = $county;
696                } else {
697
0
                        $self->{'admin2'} //= Geo::Coder::Free::DB::MaxMind::admin2->new(no_entry => 1) or Carp::croak("Can't open the admin2 database");
698
699                        # Validate both tokens before interpolating into SQL.
700                        # $loc->{'Country'} is DB-sourced (ISO 3166-1 alpha-2 = two
701                        # uppercase letters) and $region is user-supplied.  Unvalidated
702                        # interpolation into a LIKE predicate would allow SQL injection.
703
0
                        my $_country = uc($loc->{'Country'} // '');
704
0
                        my $_reg     = uc($region // '');
705
0
                        my $row;
706
0
                        if($_country =~ /^[A-Z]{2}$/ && $_reg =~ /^[A-Z0-9]{1,10}$/) {
707
0
                                $row = $self->{'admin2'}->execute("SELECT name FROM admin2 WHERE concatenated_codes LIKE '$_country.%.$_reg' LIMIT 1");
708                        }
709
0
                        if(ref($row) && $row->{'name'}) {
710                                # Write to both forward and reverse caches in one go
711
0
                                $admin2cache{$row->{'name'}}  = $region;
712
0
                                $admin2cache_rev{$region}      = $row->{'name'};
713
0
                                $loc->{'Region'}               = $row->{'name'};
714                        }
715                }
716        }
717}
718
719 - 723
=head2  ua

Does nothing, here for compatibility with other geocoders

=cut
724
725
1
sub ua {
726}
727
728 - 775
=head1 AUTHOR

Nigel Horne, C<< <njh@bandsman.co.uk> >>

This library is free software; you can redistribute it and/or modify
it under the same terms as Perl itself.

=head1 BUGS

Lots of lookups fail at the moment.

The MaxMind data only contains cities.

Can't parse and handle "London, England".

The database contains Canadian cities, but not provinces, so a search for "New Brunswick, Canada" won't work

The GeoNames admin databases are in this class, they should be in Geo::Coder::GeoNames.

The data at
L<https://github.com/apache/commons-csv/blob/master/src/test/resources/org/apache/commons/csv/perf/worldcitiespop.txt.gz?raw=true>
are 7 years out of date,
and are inconsistent with the Geonames database.

If you search for something like "Sheppy, Kent, England" in list context,
it returns them all.
That's a lot!
It should be limited to,
say 10 results (that number should be tuneable, and be a LIMIT in DB.pm),
and as the correct spelling in Sheppey, arguably it should return nothing.

=head1 SEE ALSO

VWF, MaxMind and geonames.

=head1 LICENSE AND COPYRIGHT

Copyright 2017-2025 Nigel Horne.

The program code is released under the following licence: GPL for personal use on a single computer.
All other users (including Commercial, Charity, Educational, Government)
must apply in writing for a licence for use from Nigel Horne at `<njh at nigelhorne.com>`.

This product includes GeoLite2 data created by MaxMind, available from
L<https://www.maxmind.com/en/home>.
(Note that this currently gives a 403 error - I need to find the latest URL).

=cut
776
7771;