+7

Giải quyết vấn đề sử dụng paginate kết hợp mệnh đề having trong Laravel

Đặt vấn đề

Chào các bạn, ở bài viết này mình sẽ đề cập đến một lỗi mà có lẽ mọi người hay gặp khi làm việc với query trong Laravel. Mình nói sơ qua về ngữ cảnh bài toán đơn giản như thế này: Mình có table shops chứa thông tin cơ bản của một shop như tên, hình ảnh, ngày thành lập. Table locations chứa name, country, city, latitude và longitude. Một shops có một locations, và bài toán đặt ra là với một locations (lat, lng) và radius truyền lên, mình cần liệt kê những shops gần điểm truyền lên nhất và nằm trong bán kính đó, danh sách trả về có thông tin shops kèm khoảng cách và tất nhiên là có phân trang. Khá đơn giản phải không nào, triển thôi.

Giải pháp

Đầu tên mình xây dựng một scope trong model Location như sau:

    public function scopeDistance($query, $lat, $lng, $radius = 100, $unit = "km")
    {
        $unit = ($unit === "km") ? 6378.10 : 3963.17;
        $lat = (float) $lat;
        $lng = (float) $lng;
        $radius = (double) $radius;

        return $query->having('distance', '<=', $radius)
            ->select(DB::raw("*,
                ($unit * ACOS(COS(RADIANS($lat))
                    * COS(RADIANS(latitude))
                    * COS(RADIANS($lng) - RADIANS(longitude))
                    + SIN(RADIANS($lat))
                    * SIN(RADIANS(latitude)))) AS distance")
            )->orderBy('distance');
    }

Ở đây mình có sử dụng công thức tính khoảng cách giữa 2 locations (latitude, longitude)

acos(sin(lat1).sin(lat2)+cos(lat1).cos(lat2).cos(long2−long1)).R
R is earth’s radius (mean radius = 6,371km);

Trong repository mình sử dụng scope này.

return app(Location::class)->distance($latitude, $longitude, $radius)
            ->join('shops', 'shop_id', '=', 'shops.id')
            ->select('shops.*',  'distance')
            ->paginate(10);

Chạy ==> Lỗi

"SQLSTATE[42S22]: Column not found: 1054 Unknown column 'distance' in 'having clause' (SQL: select count(*) as aggregate from `locations` inner join `shops` on `shop_id` = `shops`.`id` where `locations`.`deleted_at` is null having `distance` <= 100)"

Lỗi này xuất hiện khi chúng ta sử dụng query builder với mệnh đề having ở trước sau đó paginate thì nó sẽ sinh ra lỗi sql, hiểu đơn giản lỗi này là do biến được sử dụng trong mệnh đề having không nằm trong list select. Để giải quyết vấn đề này mình chuyển sang sử dụng where thay vì mệnh đề having, mình sửa lại cái scope trong model Location như sau:

public function scopeDistance($query, $lat, $lng, $radius = 100, $unit = "km")
    {
        $unit = ($unit === "km") ? 6378.10 : 3963.17;
        $lat = (float) $lat;
        $lng = (float) $lng;
        $radius = (double) $radius;

        $sql = "*, ($unit * ACOS(COS(RADIANS($lat))
                * COS(RADIANS(latitude))
                * COS(RADIANS($lng) - RADIANS(longitude))
                + SIN(RADIANS($lat))
                * SIN(RADIANS(latitude)))) AS distance";

        $query->getQuery()->selectRaw($sql);
        $rawQuery = $this->getSql($query);

        return DB::table(DB::raw("(" . $rawQuery . ") as item"))
            ->orderBy('distance')
            ->where('distance', '<', $radius);
    }
    
/**
     * @param Builder $builder
     * @return string
    */
    private function getSql($builder)
    {
        $sql = $builder->toSql();

        foreach($builder->getBindings() as $binding) {
            $value = is_numeric($binding) ? $binding : "'" . $binding . "'";
            $sql = preg_replace('/\?/', $value, $sql, 1);
        }

        return $sql;
    }

OK, vấn đề đã được giải quyết. Tất nhiên đó chỉ là một trong số những cách để giải quyết vấn đề nên trên, bạn có thể sử dụng offset và limit hoặc tận dụng Illuminate\Pagination\Paginator để phân trang. Nhưng mình nghĩ giải pháp của mình có vẻ đơn giản mà hiệu quả nhất.

Kết luận

Hi vọng bài viết này sẽ giúp ích gì đó cho những bạn gặp lỗi tương tự mà chưa tìm ra giải pháp, cảm ơn các bạn đã đọc.


All rights reserved

Bình luận

Đăng nhập để bình luận
Avatar
@Tran.Phu.Hoa
thg 10 27, 2017 6:37 SA

hay lắm a 😆

Avatar
@pht
thg 11 3, 2017 10:57 SA

Sao từ đầu bạn lại dùng having nhỉ, having chỉ dùng với group by, chỉ có MySQL support dạng having <alias>.

Theo mình vấn đề ở đây là, khi phân trang, Laravel chạy thêm 1 câu query để lấy số lượng:

select count(*) as aggregate from `locations` inner join `shops` on `shop_id` = `shops`.`id` where `locations`.`deleted_at` is null having `distance` <= 100

=> Lỗi phát sinh ở đây.

Về solution của bạn, theo mình hiểu là bạn dùng 2 câu select lồng nhau:

return app(Location::class)->distance($latitude, $longitude, $radius)
    ->join('shops', 'shop_id', '=', 'shops.id')
    ->select('shops.*',  'distance');

Được chuyển thành:

select shop.*, distance from (select *, ... as distance from locations join shops on shop_id = shops.id...) as items...

Mình thấy ko tốt lắm, thứ nhất performance có thể bị ảnh hưởng vì 2 câu query lồng nhau, thứ hai là hàm getSql() binding bằng tay thực sự không tốt:

  • $value = is_numeric($binding) ? $binding : "'" . $binding . "'";
    

    => nếu string có chứa ' thì sao? Có thể bị sql injection ko?

  • $sql = preg_replace('/\?/', $value, $sql, 1);
    

    => nếu có đã có dấu ? trước đó trong câu sql (vd: select * from abc where a like 'how?%' and b like ?) thì kết quả sẽ sai?

Dấu ? ở đây được sử dụng cho prepared statement chứ không replace thủ công như thế.

Về solution đơn giản nhất ở đây là bạn bê cả đoạn của alias distance vào whereRaw() do sql không support where <alias column>. Lưu ý là nên prefix column trong raw sql với table name để tránh trùng column khi join.

Nếu dùng cách của bạn, bạn có thể dùng các hàm addBinging, mergeBindings... của query builder thay vì replace string thủ công.

Avatar
@TranDinhVi
thg 12 2, 2017 8:56 SA

Cảm ơn góp ý của bạn (bow)

Avatar
@huuhoa0506
thg 11 17, 2017 3:10 SA

having mà không dùng group thì lỗi là đúng rồi bạn ạ. Không phải nguyên nhân như bạn giải thích. Vấn đề ở đây chỉ là dựa vào biến số bán kính thì mỗi bản ghi bạn có thể tính toán đc khoảng cách với lat va lon và đặt một alias bằng cách sử dụng raw sql. Bạn có thể group theo khó chính của bảng shops sao đó sử dụng having theo giá trị khoảng cách tính đc (alias) trước đó.

Avatar
+7
Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí