Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ jobs:
run: |
sudo service mysql start
mysql -h 127.0.0.1 -u root -proot -e 'CREATE DATABASE cakephp;'
- name: Setup Geography for PostgreSQL
if: matrix.db-type == 'pgsql'
run: |
sudo apt-get install postgis

- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
Expand Down
15 changes: 15 additions & 0 deletions docs/Behavior/Geocoder.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,3 +166,18 @@ You can test the geocoding and also remove cache data where needed.

## Providers
Full list of existing providers [here](https://github.com/geocoder-php/Geocoder#providers).

## Spatial
You can also use the "spatial" finder using coordinates as POINT instead of lat/lng.
```php
$query = $this->Addresses->find('spatial', [
'lat' => 13.3,
'lng' => 19.2,
'distance' => 100,
]);
```
Note: This only works with PostGIS and MySQL 5.7+ (and MariaDB 10.4+) databases, as they support spatial data types.

There can be a performance improvement when using spatial indexes, so you might want to consider using this for larger datasets.
In reality I didnt get the index to work, though. Only managed to limit down the looked up entries from full table scan to range, which
sped it up, too.
2 changes: 1 addition & 1 deletion docs/Model/GeocodedAddresses.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ if ($address && $address->lat && $address->lng) {
}
```

Don't forget to add the Type mapping of `Geo\Database\Type\ObjectType` in your bootstrap.php.
Remember to add the Type mapping of `Geo\Database\Type\ObjectType` in your bootstrap.php.
```php
TypeFactory::map('object', 'Geo\Database\Type\ObjectType');
```
Expand Down
2 changes: 1 addition & 1 deletion src/Controller/Admin/GeocodedAddressesController.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
*
* @property \Geo\Model\Table\GeocodedAddressesTable $GeocodedAddresses
*
* @method \Cake\Datasource\ResultSetInterface<\Geo\Model\Entity\GeocodedAddress> paginate($object = null, array $settings = [])
* @method \Cake\Datasource\ResultSetInterface<\Geo\Model\Entity\GeocodedAddress> paginate(\Cake\Datasource\RepositoryInterface|\Cake\Datasource\QueryInterface|string|null $object = null, array $settings = [])
*/
class GeocodedAddressesController extends AppController {

Expand Down
59 changes: 59 additions & 0 deletions src/Model/Behavior/GeocoderBehavior.php
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ class GeocoderBehavior extends Behavior {
'unit' => Calculator::UNIT_KM,
'implementedFinders' => [
'distance' => 'findDistance',
'spatial' => 'findSpatial',
],
'validationError' => null,
'cache' => false, // Enable only if you got a GeocodedAddresses table running
Expand Down Expand Up @@ -401,6 +402,60 @@ public function findDistance(SelectQuery $query, ?float $lat = null, ?float $lng
return $query;
}

/**
* @param \Cake\ORM\Query\SelectQuery $query
* @param float|null $lat
* @param float|null $lng
* @param \Geocoder\Model\Coordinates|null $coordinates
* @param int|null $distance
* @param string|null $tableName
* @param bool $sort
* @return \Cake\ORM\Query\SelectQuery
*/
public function findSpatial(SelectQuery $query, ?float $lat = null, ?float $lng = null, ?Coordinates $coordinates = null, ?int $distance = null, ?string $tableName = null, bool $sort = true): SelectQuery {
$options = [
'tableName' => $tableName,
'sort' => $sort,
'lat' => $lat,
'lng' => $lng,
'distance' => $distance,
'coordinates' => $coordinates,
];
$options = $this->assertCoordinates($options);

if ($query->isAutoFieldsEnabled() === null) {
$query->enableAutoFields(true);
}

$lat = $options[static::OPTION_LAT];
$lng = $options[static::OPTION_LNG];

// Add distance calculation as a virtual field
$query->select([
'distance' => new QueryExpression(
"ST_Distance_Sphere(coordinates, ST_GeomFromText('POINT($lng $lat)')) / 1000",
),
]);

// Filter by max distance if limit is provided
if (isset($options['distance'])) {
$distance = (float)$options['distance'];
$query->where(function (QueryExpression $exp) use ($lat, $lng, $distance) {
return $exp->lte(
new QueryExpression("ST_Distance_Sphere(coordinates, ST_GeomFromText('POINT($lng $lat)')) / 1000"),
$distance,
);
});
}

if ($options['sort']) {
$sort = $options['sort'] === true ? 'ASC' : $options['sort'];
$query->orderBy(['distance' => $sort]);
}

return $query;
}

/**
* Forms a sql snippet for distance calculation on db level using two lat/lng points.
*
Expand Down Expand Up @@ -653,6 +708,10 @@ protected function assertCoordinates(array $options): array {
throw new InvalidArgumentException($error);
}

if ($options[static::OPTION_LAT] < -90 || $options[static::OPTION_LAT] > 90 || $options[static::OPTION_LNG] < -180 || $options[static::OPTION_LNG] > 180) {
throw new InvalidArgumentException('Invalid latitude or longitude in (' . $options[static::OPTION_LAT] . '/' . $options[static::OPTION_LNG] . ').');
}

return $options;
}

Expand Down
7 changes: 5 additions & 2 deletions templates/Admin/GeocodedAddresses/index.php
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
<?php

/**
* @var \App\View\AppView $this
* @var \Geo\Model\Entity\GeocodedAddress[]|\Cake\Collection\CollectionInterface $geocodedAddresses
* @var iterable<\Geo\Model\Entity\GeocodedAddress> $geocodedAddresses
*/
use Cake\Core\Plugin;

?>

use Cake\Core\Plugin; ?>
<nav class="actions large-3 medium-4 columns col-sm-4 col-xs-12" id="actions-sidebar">
<ul class="side-nav nav nav-pills nav-stacked">
<li class="heading"><?= __('Actions') ?></li>
Expand Down
38 changes: 38 additions & 0 deletions tests/Fixture/SpatialAddressesFixture.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

namespace Geo\Test\Fixture;

use Cake\TestSuite\Fixture\TestFixture;

class SpatialAddressesFixture extends TestFixture {

/**
* Fields
*
* @var array
*/
public array $fields = [
'id' => ['type' => 'integer'],
'address' => ['type' => 'string', 'null' => false, 'default' => '', 'length' => 190, 'comment' => 'street address and street numbe'],
'lat' => ['type' => 'float', 'null' => false, 'default' => null, 'comment' => 'maps.google.de latitude'],
'lng' => ['type' => 'float', 'null' => false, 'default' => null, 'comment' => 'maps.google.de longitude'],
'coordinates' => ['type' => 'point', 'null' => false],
'created' => ['type' => 'datetime', 'null' => true, 'default' => null],
'modified' => ['type' => 'datetime', 'null' => true, 'default' => null],
'_constraints' => [
'primary' => ['type' => 'primary', 'columns' => ['id']],
],
'_indexes' => [
//'coordinates_spatial' => ['type' => 'spatial', 'columns' => ['coordinates'], 'length' => []],
],
];

/**
* Records
*
* @var array
*/
public array $records = [
];

}
130 changes: 130 additions & 0 deletions tests/TestApp/src/Model/Table/SpatialAddressesTable.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
<?php

namespace TestApp\Model\Table;

use ArrayObject;
use Cake\Datasource\EntityInterface;
use Cake\Event\EventInterface;
use Cake\ORM\Query\SelectQuery;
use Cake\ORM\Table;

/**
* SpatialAddresses Model
*
* @method \TestApp\Model\Entity\SpatialAddress get(mixed $primaryKey, array|string $finder = 'all', \Psr\SimpleCache\CacheInterface|string|null $cache = null, \Closure|string|null $cacheKey = null, mixed ...$args)
* @method \TestApp\Model\Entity\SpatialAddress newEntity(array $data, array $options = [])
* @method array<\TestApp\Model\Entity\SpatialAddress> newEntities(array $data, array $options = [])
* @method \TestApp\Model\Entity\SpatialAddress|false save(\Cake\Datasource\EntityInterface $entity, array $options = [])
* @method \TestApp\Model\Entity\SpatialAddress patchEntity(\Cake\Datasource\EntityInterface $entity, array $data, array $options = [])
* @method array<\TestApp\Model\Entity\SpatialAddress> patchEntities(iterable $entities, array $data, array $options = [])
* @method \TestApp\Model\Entity\SpatialAddress findOrCreate($search, ?callable $callback = null, array $options = [])
* @method \TestApp\Model\Entity\SpatialAddress saveOrFail(\Cake\Datasource\EntityInterface $entity, array $options = [])
* @method \Cake\Datasource\ResultSetInterface<\TestApp\Model\Entity\SpatialAddress>|false saveMany(iterable $entities, array $options = [])
* @mixin \Cake\ORM\Behavior\TimestampBehavior
* @method \TestApp\Model\Entity\SpatialAddress newEmptyEntity()
* @method \Cake\Datasource\ResultSetInterface<\TestApp\Model\Entity\SpatialAddress> saveManyOrFail(iterable $entities, array $options = [])
* @method \Cake\Datasource\ResultSetInterface<\TestApp\Model\Entity\SpatialAddress>|false deleteMany(iterable $entities, array $options = [])
* @method \Cake\Datasource\ResultSetInterface<\TestApp\Model\Entity\SpatialAddress> deleteManyOrFail(iterable $entities, array $options = [])
*/
class SpatialAddressesTable extends Table {

/**
* @var \Geo\Geocoder\Geocoder
*/
protected $_Geocoder;

/**
* Initialize method
*
* @param array<string, mixed> $config The configuration for the Table.
* @return void
*/
public function initialize(array $config): void {
parent::initialize($config);

$this->setTable('spatial_addresses');
$this->setDisplayField('address');
$this->setPrimaryKey('id');

//$this->getSchema()->setColumnType('coordinates', 'point');

$this->addBehavior('Timestamp');
}

/**
* Add formatter aka afterFind parsing geospatial values
*
* @param \Cake\Event\EventInterface $event
* @param \Cake\ORM\Query\SelectQuery $query
* @param \ArrayObject $options
* @return void
*/
public function beforeFind(EventInterface $event, SelectQuery $query, ArrayObject $options): void {
$columns = ['coordinates' => 'point'];

// Go through each result and unpack() the binary data
$query->formatResults(function($results) use($columns) {
return $results->map(function($entity) use($columns) {
foreach ($columns as $column => $type) {
if (!isset($entity->{$column})) {
continue;
}
// [TypeError] unpack(): Argument #2 ($string) must be of type string
if (!is_string($entity->{$column})) {
continue;
}
switch ($type) {
// TODO support other types, not only POINT
case 'point':
$entity->{$column} = unpack('x/x/x/x/corder/Ltype/dx/dy', $entity->{$column});

break;
}
}

return $entity;
});
});
}

/**
* Once entity is marshalled, prepare geospatial values to be saved into database
*
* @param \Cake\Event\EventInterface $event
* @param \Cake\Datasource\EntityInterface $entity
* @param \ArrayObject $data
* @param \ArrayObject $options
* @return void
*/
public function afterMarshal(EventInterface $event, EntityInterface $entity, ArrayObject $data, ArrayObject $options): void {
$columns = ['coordinates' => 'point'];

foreach ($columns as $column => $type) {
// Skip if the column is not present in $data
if (!isset($data[$column])) {
if (!empty($data['lat']) && !empty($data['lng'])) {
$data[$column] = [$data['lng'], $data['lat']]; // lng, lat order is important!
} else {
continue;
}
}

// We expect an array like [12, 34] otherwise skip
if (!is_array($data[$column])) {
continue;
}
switch ($type) {
// TODO support other types, not only POINT
case 'point':
$value = sprintf('\'%s(%s)\'', strtoupper($type), implode(' ', $data[$column]));

break;
}
// Set $value on $entity using ST_GeomFromText()
$entity->{$column} = $this->query()->func()->ST_GeomFromText([
$value => 'literal',
]);
}
}

}
Loading