2

I am working on a php project where I need a dynamic column to be part of the sql query result, I have a trips table where each one might have many travels, the trip status is always calculated dynamically according to the several conditions below:

public static function getTripStatus($item)
    {
        $status = 'Unknown';
        if ($item->is_canceled == true) {
            $status = 'canceled';
        } elseif ($item->travels->count() == $item->travels->where('status', 'open')->count()) {
            $status = 'open';
        } else if ($item->travels->count() > 0 && $item->travels->count() == $item->travels->where('status', 'finished')->count()) {
            $status = 'finished';
        } elseif ($item->travels->where('status', 'started')->count() > 0) {
            $status = 'started';
        }

        return $status;
    }

I need to convert the function below to an SQL function so that the status column is dynamically appended to the query result.

tinyCoder
  • 350
  • 13
  • 37
  • https://laravel.com/docs/8.x/eloquent-mutators#accessors-and-mutators – user3532758 Aug 28 '21 at 14:01
  • Thanks but appended attributes are not part of the SQL query, I can't filter results according to an appended value. I want the `status` column to be accessable and filterable anywhere in my code, and the only way is to convert the php code to a native sql one. – tinyCoder Aug 28 '21 at 21:26
  • 1
    @tinyCoder Since `status` isn't actually a column, I don't think there is a solution that allows you to naively consider it a regular column elsewhere in your code (assuming you want to do something like `Trip::where('status', 'canceled')->get()`). If you give some specific instances of how you want to use this `status` value elsewhere, we might be able to come up with a different/least painful solution. Off hand, using a local scope seems like a good place to look. – Shane Aug 28 '21 at 22:42
  • I was able to do this like `Student::select()->addSelect(DB::raw('CONCAT(first_name," ", last_name) as full_name'))` where full_name became a regular column in the query that I can filter the results with, directly from my frontend table, where the situation is more complicated. – tinyCoder Aug 29 '21 at 09:18
  • I am using Yajra Datatables and creating a custom filter for the non-exisiting dynamic `status` column. – tinyCoder Aug 29 '21 at 09:19
  • https://stackoverflow.com/questions/26538485/how-to-add-a-custom-column-with-a-default-value-in-an-sql-query It sounds like this is relevant, and would probably cover your use case of injecting a conditionally assigned value into the SQL response. – msenne Aug 30 '21 at 18:38
  • @tinyCoder you can do the same in sql check [here](https://www.db-fiddle.com/f/seECsJkqjnNzeovpK9KBU7/2). – Chandan Aug 31 '21 at 11:35

3 Answers3

4

You can add a raw select, this way you keep the performance and PHP side of the code clean.

Only thing you need to be careful is indexing the fields you're using for conditionals.

SELECT
    trips.id,
    trips.name,
    CASE
        WHEN `trips`.`is_canceled` THEN "canceled"
        WHEN NOT EXISTS (SELECT * FROM `travels` WHERE trips.id = travels.trip_id) THEN "no_travels"
        WHEN (SELECT count(*) FROM `travels` WHERE trips.id = travels.trip_id) = (SELECT count(*) FROM `travels` WHERE `status` = "open" and trips.id = travels.trip_id) THEN "open"
        WHEN (SELECT count(*) FROM `travels` WHERE trips.id = travels.trip_id) = (SELECT count(*) FROM `travels` WHERE `status` = "finished" and trips.id = travels.trip_id) THEN "finished"
        WHEN (SELECT count(*) FROM `travels` WHERE trips.id = travels.trip_id) = (SELECT count(*) FROM `travels` WHERE `status` = "started" and trips.id = travels.trip_id) THEN "started"
        ELSE "Unknown"
    END as `status`
FROM
    `trips`;

A simple equivalent of the above query could be written like this in Laravel:

$countSubQuery = "SELECT count(*) FROM `travels` WHERE trips.id = travels.trip_id";

    $trips = Trip::select([
        'id',
        \DB::raw("
            CASE
                WHEN `trips`.`is_canceled` THEN 'canceled'
                WHEN NOT EXISTS (SELECT * FROM `travels` WHERE trips.id = travels.trip_id) THEN 'no_travels'
                WHEN ({$countSubQuery}) = ({$countSubQuery} and `status` = 'open') THEN 'open'
                WHEN ({$countSubQuery}) = ({$countSubQuery} and `status` = 'finished') THEN 'finished'
                WHEN ({$countSubQuery}) = ({$countSubQuery} and `status` = 'started') THEN 'started'
                ELSE 'Unknown'
            END as `status`
        "),
    ])->get();

    dd($trips);

And then if you're planning to use this often, you could extract it to a scope inside your model.

/**
 * Query scope withStatus.
 *
 * @param  \Illuminate\Database\Eloquent\Builder
 * @return \Illuminate\Database\Eloquent\Builder
 */
public function scopeWithStatus($query)
{
    $countSubQuery = "SELECT count(*) FROM `travels` WHERE trips.id = travels.trip_id";

    return $query->addSelect(
        \DB::raw("
            CASE
                WHEN `trips`.`is_canceled` THEN 'canceled'
                WHEN NOT EXISTS (SELECT * FROM `travels` WHERE trips.id = travels.trip_id) THEN 'no_travels'
                WHEN ({$countSubQuery}) = ({$countSubQuery} and `status` = 'open') THEN 'open'
                WHEN ({$countSubQuery}) = ({$countSubQuery} and `status` = 'finished') THEN 'finished'
                WHEN ({$countSubQuery}) = ({$countSubQuery} and `status` = 'started') THEN 'started'
                ELSE 'Unknown'
            END as `status`
        "),
    );
}

Above code will let you run the select wherever you want easily BUT it has a catch.

You would need to specify the fields you want from database since we use the addSelect method inside the scope it assumes that we do not want to get * and only gets status. To prevent this you can simply say:

Trip::select('*')->withStatus()->get();
Ozan Kurt
  • 3,731
  • 4
  • 18
  • 32
1

Any of the query can be used with scope as suggested by Ozan

Query #1

SELECT
    A.id as id,
    CASE
        WHEN A.is_canceled THEN 'canceled'
        WHEN NOT EXISTS (SELECT * FROM B) THEN 'no_B'
        WHEN (SELECT count(*) FROM B WHERE A.id = B.trip_id) = (SELECT count(*) FROM B WHERE status = 'open' and A.id = B.trip_id) THEN 'open'
        WHEN (SELECT count(*) FROM B WHERE A.id = B.trip_id) = (SELECT count(*) FROM B WHERE status = 'finished' and A.id = B.trip_id) THEN 'finished'
        WHEN (SELECT count(*) FROM B WHERE A.id = B.trip_id) = (SELECT count(*) FROM B WHERE status = 'started' and A.id = B.trip_id) THEN 'started'
        ELSE 'Unknown'
    END as status
FROM
    A;
id status
1 canceled
2 Unknown
3 Unknown

Query #2

with m As (
  SELECT
    A.id as id,
    A.is_canceled AS is_canceled,
    COUNT(*) AS total,
    COUNT(CASE WHEN status = 'open' then 1 ELSE NULL END) AS open,
    COUNT(CASE WHEN status = 'finished' then 1 ELSE NULL END) AS finished,
    COUNT(CASE WHEN status = 'started' then 1 ELSE NULL END) AS started
  FROM B JOIN A ON B.trip_id = A.id
  GROUP BY A.id, A.is_canceled
)

SELECT
  A.id,
  CASE
    WHEN A.is_canceled THEN 'cancelled'
    WHEN (select total from m where A.id = m.id) = (select open from m where A.id = m.id) THEN 'open'
    WHEN (select total from m where A.id = m.id) = (select finished from m where A.id = m.id) THEN 'finished'
    WHEN (select total from m where A.id = m.id) = (select started from m where A.id = m.id) THEN 'started'
    ELSE 'Unknown'
    END AS status
from A;
id status
1 cancelled
2 Unknown
3 Unknown

Query #3

SELECT
  id,
  CASE
    WHEN is_canceled THEN 'cancelled'
    WHEN total = open THEN 'open'
    WHEN total = finished THEN 'finished'
    WHEN total = started THEN 'started'
    ELSE 'Unknown'
  END AS status
FROM (
  SELECT
    A.id as id,
    A.is_canceled AS is_canceled,
    COUNT(*) AS total,
    COUNT(CASE WHEN status = 'open' then 1 ELSE NULL END) AS open,
    COUNT(CASE WHEN status = 'finished' then 1 ELSE NULL END) AS finished,
    COUNT(CASE WHEN status = 'started' then 1 ELSE NULL END) AS started
  FROM B JOIN A ON B.trip_id = A.id
  GROUP BY A.id, A.is_canceled
) t ;
id status
1 cancelled
3 Unknown
2 Unknown

Execution time for 10k records.

enter image description here

View on DB Fiddle

Chandan
  • 11,465
  • 1
  • 6
  • 25
0

If you want a custom attribute to be appended to the "trips" objects when queried: I recommend using a custom attribute that you can "always" load with your models, or on-demand.

You can define a trip_status attribute, and rename your method to getTripStatusAttribute and it'll be added to the resulting trips queried.

Note that this will NOT affect the main trips SQL query. But will add a travel query for each resulting trips, if travel are not eager loaded. And can be resource greedy as you use a SQL count on a relation every time a trip is loaded (always or on-demand as said).

You can also query your trips using withCount to eager load the travel count with the trip query. And base the trip_status accessor method on the queried count attribute. Still can belong with a heavy database, so prefer to load the attribute only when needed.

You can use withCount with conditions (also see), and use it in accessor:

$posts = Post::withCount('upvotes')
         ->having('upvotes_count', '>', 5)
         ->get();

Second note: you can add the travel count to the trip query result, but I don't think that you can add a trip_status only using SQL. A Laravel attribute would be the way to go, by taking care of performance (load travel if needed relationLoaded, use withCount...)

Edit 2: after seeing your comment I am using Yajra Datatables and creating a custom filter for the non-exisiting dynamic status column

you cannot make SQL query against the trip_status as it's not a database. You could filter a collection with ALL the trips on the trip_status, but it's a no go for performance: you need a eloquent/SQL query. But as I explained, you can query with custom withCount, and so reproduce your needs.

So for the GET data part, I would use the custom attribute.

But for the query part, in a datatable, I would filter my query based on the frontend filters. So you'll need a query based on multiple eager loaded counts (check all the link snippets logic to "mix" them).

Here what a query could look like (not tested fully, but tested the eloquent/SQL part to check for results):

 //here you can add other query filters
 $trips = Trip::query();

        if($frontend_filter === 'canceled') {
            $trips = $trips->where('is_canceled', 1);
        }
        elseif($frontend_filter === 'open') {
            $trips = $trips::withCount([
                    'travels',
                    'travels AS opened_travel_count' => function ($query) {
                        $query->where('travels.status', '=', 'open');
                    }
                ])
                ->having('travels_count', '=', 'opened_travel_count'); //careful: will match 0   
        }
        elseif($frontend_filter === 'finished') {
            $trips = $trips::withCount([
                    'travels',
                    'travels AS finished_travel_count' => function ($query) {
                        $query->where('travels.status', '=', 'finished');
                    }
                ])
                ->having('travels_count', '>', 0) //could use whereHas travels
                ->having('travels_count', '=', 'finished_travel_count');
        }
        elseif($frontend_filter === 'started') {
            $trips = $trips::withCount([
                    'travels', //useless
                    'travels AS started_travel_count' => function ($query) {
                        $query->where('travels.status', '=', 'started');
                    }
                ])
                ->having('started_travel_count', '>', 0); //could use whereHas travels where travel
        }


        //pagination, global eager loads, selects etc...
        //you have the pre-filtered query to pass for pagination and stuff
        //$trips = $trips->get(); //this gets all and should NOT be used for a datatable
Mtxz
  • 3,749
  • 15
  • 29
  • Thanks for your efforts, appended values are not part of the SQL query, thus, not filterable or searchable, and filtering it in the front-end is very costy with thousands of records in the database as it will always query ALL the rows to be able to filter them. – tinyCoder Aug 31 '21 at 18:18
  • 1
    Yes, that is what I explained. Look at my code example as to how to query your model with the conditions you need. You have both in my post: attribute to see your status, and a way to query based on your status conditions. – Mtxz Aug 31 '21 at 21:38