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.

7
  • laravel.com/docs/8.x/eloquent-mutators#accessors-and-mutators Commented Aug 28, 2021 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. Commented Aug 28, 2021 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. Commented Aug 28, 2021 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. Commented Aug 29, 2021 at 9:18
  • I am using Yajra Datatables and creating a custom filter for the non-exisiting dynamic status column. Commented Aug 29, 2021 at 9:19

3 Answers 3

4
+100

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();
Sign up to request clarification or add additional context in comments.

Comments

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

Comments

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

2 Comments

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.
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.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.