Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to sort query results by distance in Laravel QueryBuilder / MySQL Spatial package?

First I want to show you the current database structure. There are three tables:

dishes (id, name)

locations (id, name, coordinates (POINT))

dish_location(location_id, dish_id)

Now I want to implement an API which gets the position (latitude, longitude) of the user and returns a list of dishes sorted by the distance in km. I already have a method which takes two latitudes and two longitudes and gives me the distance. But I am sure you can show me a way, which is a more performant way to do this directly in the MySQL query.

Additional: I want to do a "load more"-function in the API. So I pass the count of already received items or how would I solve this in this case?

I am using this for the MySQL Spatial package

like image 227
rakete Avatar asked Oct 02 '17 17:10

rakete


3 Answers

First, let's take a look at how to do this with the basic query builder. Then, we'll discuss how to execute this query with Eloquent models:

function paginateDishesFromPoint(Point $point, $pageSize) 
{
    $distanceField = "ST_Distance_Sphere(locations.coordinates, "
        . "ST_GeomFromText('{$point->toWKT()}') AS distance"; 

    return DB::table('dishes') 
        ->select('dishes.*', DB::raw($distanceField))
        ->join('dish_locations', 'dish_locations.dish_id', '=', 'dishes.id')
        ->join('locations', 'locations.id', '=', 'dish_locations.location_id')
        ->orderBy('distance') 
        ->paginate($pageSize);
}

The ST_Distance_Sphere() function calculates a distance that we can sort results by. Laravel's paginate() method performs automatic pagination for us using the page parameter passed through the request URL. Read the pagination docs for more information. With the function above, we can fetch a paginated result set as follows:

$point = new Point($latitude, $longitude); 
$sortedDishes = paginateDishesFromPoint($point, 15); 

...where Point is the Grimzy\LaravelMysqlSpatial\Types\Point class from the package we're using, and 15 is the number of results per page.

Now, let's try to do this with Eloquent models. We'll use a local query scope to encapsulate the logic needed to create the portion of the query that performs the ordering:

class Dish extends Model 
{
    ...

    public function locations() 
    {
        return $this->belongsToMany(App\Location::class);
    }

    public function scopeOrderByDistanceFrom($query, Point $point) 
    {
        $relation = $this->locations();
        $locationsTable = $relation->getRelated()->getTable();
        $distanceField = "ST_Distance_Sphere($locationsTable.coordinates, "
        . "ST_GeomFromText('{$point->toWKT()}') AS distance";

        return $query
            ->select($this->getTable() . '.*', DB::raw($distanceField))
            ->join(
                $relation->getTable(), 
                $relation->getQualifiedForeignKeyName(), 
                '=', 
                $relation->getQualifiedParentKeyName()
            )
            ->join(
                $locationsTable,
                $relation->getRelated()->getQualifiedKeyName(),
                '=', 
                $relation->getQualifiedRelatedKeyName()
            )
            ->orderBy('distance');
    }
}

This implementation uses metadata on the models to add the table and field names to the query so we don't need to update this method if they change. Now we can fetch the ordered set using the model:

$point = new Point($latitude, $longitude); 
$sortedDishes = Dish::orderByDistanceFrom($point)->paginate($pageSize);

$sortedDishes is an instance of Laravel's LengthAwarePaginator which wraps a Collection of the models. If we pass the results to a view, here's how to display them in a Blade template:

<ul>
    @foreach($sortedDishes as $dish) 
        <li>{{ $dish->name }} is {{ $dish->distance }} meters away.</li>
    @endforeach
</ul>

<a href="{{ $sortedDishes->nextPageUrl() }}">Load more...</a>

As shown above, the paginator provides convenience methods that we can use to easily move between paged results.

Alternatively, we could use AJAX requests to load the results. Just be sure to pass the current page + 1 in the page parameter of the request data.

like image 92
Cy Rossignol Avatar answered Oct 19 '22 12:10

Cy Rossignol


SQL

SELECT d.name AS `Dish`,
       l.name AS `Location`,
       ST_Distance(l.coordinates, POINT(<<<longitude>>>, <<<latitude>>>)) AS `Distance`
FROM dish_locations dl
JOIN dishes d
ON d.id = dl.dish_id
JOIN locations l
ON l.id = dl.location_id
ORDER BY `Distance`
LIMIT <<<n-1>>>, <<<page size>>>;

...inserting <<<longitude>>> and <<<latitude>>> from the user's position, <<<n-1>>> from the number of rows that have already been retrieved minus one and <<<page size>>> as the desired next number of rows to retrieve.

Demo

http://rextester.com/YAEBF16430

Explanation

  • ST_Distance_Sphere is used to calculate the distance in meters.
  • LIMIT <<offset>>, <<num>> is used to implement the paging (where offset is zero-based).
like image 1
Steve Chambers Avatar answered Oct 19 '22 11:10

Steve Chambers


Postgis has two operators (<-> and <#>) to work distance and calculate KNN(Nearest Neighbors). You can use them instead of st_distance. Operators Query would be something like:

WITH index_query AS (
 SELECT ST_Distance(geom, 'SRID=3005;POINT(1011102 450541)'::geometry) as d,edabbr, vaabbr
FROM va2005
ORDER BY geom <-> 'SRID=3005;POINT(1011102 450541)'::geometry LIMIT 100)
SELECT *
   FROM index_query
ORDER BY d limit 10;

Using distance as d you can order.

like image 1
Marco Avatar answered Oct 19 '22 12:10

Marco