erwindeclerck's avatar

Filter table on joined table column

Hi,

I'm trying to create a filter within the table builder on a value from a joined table. Main base table is 'hosts' and the joined table is 'saldi', which is a oneonone table linked trough a foreign key saldi.host to hosts.id. Within the saldi table there is a column (assigned) with an integer varying from zero to about 10. I want to filter rows on which this value on the joined table is > 2.

            Filter::make('Has guests')
                ->query(function(Builder $query): Builder{
                    $query->leftJoin('saldi',function(JoinClause $query){
                        $query->on('hosts.id','saldi.host');
                        $query->where('saldi.assigned','>',2,'where');
                    });
					$query->dump();  //only for debugging, same result with or without...
                    return $query;
                }),

The build query is: "select * from hosts left join saldi on hosts.id = saldi.host where saldi.assigned > ?" Binding is: array:1 [ 0 => 2 ]

It seems my query is okay though there is no filtering happening.

If I create a second filter on a field in the base table (hosts) then the filtering works fine. eg: Filter::make('published') ->query( fn (Builder $query): Builder => $query->where('published', true) ) ->toggle(),

What am i doing wrong?

Kind regards Erwin

0 likes
13 replies
LaryAI's avatar
Level 58

It seems like you're trying to apply a filter using Laravel's Eloquent query builder. The issue might be with how the leftJoin is being used within the filter's query closure. When you want to filter based on a condition in a joined table, you should ensure that the join is applied correctly and that the where condition is applied to the main query, not just within the join closure.

Here's a revised version of your filter:

Filter::make('Has guests')
    ->query(function (Builder $query): Builder {
        return $query->leftJoin('saldi', 'hosts.id', '=', 'saldi.host')
                     ->where('saldi.assigned', '>', 2);
    }),

In this revised version, the where condition is applied to the main query builder instance, not within the leftJoin closure. This ensures that the filter condition (saldi.assigned > 2) is applied to the result of the join, not just to the join condition itself.

Also, make sure that the assigned column in the saldi table is not nullable or doesn't contain null values, as this could affect the results when using a leftJoin. If assigned can be null and you want to exclude those records, you might need to add an additional condition to filter out null values:

Filter::make('Has guests')
    ->query(function (Builder $query): Builder {
        return $query->leftJoin('saldi', 'hosts.id', '=', 'saldi.host')
                     ->where('saldi.assigned', '>', 2)
                     ->whereNotNull('saldi.assigned');
    }),

If you want to ensure that only hosts with a corresponding record in the saldi table are included, you might want to use an innerJoin instead of a leftJoin:

Filter::make('Has guests')
    ->query(function (Builder $query): Builder {
        return $query->join('saldi', 'hosts.id', '=', 'saldi.host')
                     ->where('saldi.assigned', '>', 2);
    }),

This will only include rows where there is a matching record in both hosts and saldi tables, and where the assigned value is greater than 2.

erwindeclerck's avatar

The AI answer doesn't work. It produces an invald query where the join is not happening... And so, the saldi.assigned column of the joined table is not known...

SQLSTATE[42S22]: Column not found: 1054 Unknown column 'saldi.assigned' in 'where clause' SELECT count(*) AS aggregate FROM hosts WHERE id > 10 AND ( saldi.assigned > 2 AND saldi.assigned IS NOT NULL )

The query, produced by my eloquent statement works fine if I copy paste it in my sql-editor, but not within Laravel.

jlrdw's avatar

Is saldi.host the foreign key?

erwindeclerck's avatar

And if I run an Eloquent query outside of this filter, it works fine like this: (Tinkerwell)

DB::table('hosts')
->select('*')
->leftJoin('saldi',function( $query){
  $query->on('hosts.id','saldi.host');
})->where('saldi.assigned','>',2,false)
->ddRawSql();

The produced query is like:

"select * from `hosts` left join `saldi` on `host`.`id` = `saldi`.`host` where  `saldi`.`assigned` > 2"

Which is also a perfect query. And the following gives me the correct collection.

DB::table('hosts')
->select('*')
->leftJoin('saldi',function( $query){
  $query->on('hosts.id','saldi.host');
})->where('saldi.assigned','>',2,false)
->get();

Only, when I do this in the filter statement, no filtering is done...

erwindeclerck's avatar

@jlrdw But how should other filters then be able to modify the query in case I need more then one filter to be combined... (which is the case)

erwindeclerck's avatar

Okay... found a solution to circumvent the bizarre way filament is skipping joined tables. Probably I get it wrong (or there is a bug) but I didn't find a normal way out. I think it's a strange approach but it works...

                Filter::make('Has guests')
                    ->query(
                        function($query){
                            $arr=[];
                            $out = DB::table('saldi')->where('assigned','>',0)->get();
                            foreach($out as $obj){
                                $arr[]=$obj->host;
                            }
                            return $query->whereIn('id',$arr);
                        }
                    ),
```
1 like
jimbocity's avatar

@erwindeclerck Yep, this is the only way to do it. You have to perform a subquery to return an array of ID's from the the table you wanted to join, and THEN filter your original query with one or more of those ID's (WhereIn (ids)).

You can also technically perform the subquery as a separate query prior to your main SQL, but performing as a subquery is nicer.

You can also simplify a little. Instead of using ->get() + foreach, simply use " $arr = DB::table('saldi')->where('assigned','>',0)->pluck('host')->toArray();

...then return your $query->whereId('id',$arr);

amitsolanki24_'s avatar

Try to change this line of code $query->where('saldi.assigned','>',2,'where') With below code $query->where('saldi.assigned','>',2)

ChristinePHPCoder's avatar

I faced the same situation recently and implementing the above solution was not practical for my purpose but there's another way to work around this limitation which I think is cleaner. I used Filament's baseQuery method to alter the main query and join on the tables I needed to join on.

As a developer, I'm unable to expose my employer's original code publicly (my code uses a custom filter form because I needed to have dependent selects in the form) but I've created a dummy/demo example using a common scenario of products which are assigned to categories.

public static function table(Table $table): Table
    {   
        return $table
            ->filters([
                Filter::make('filters')
                    ->indicateUsing(function (array $data): string {
                        $indicators = '';

                        if($data['category_id']){
                            $category = Category::find($data['category_id']);
                            return 'Category: ' . $category->name;
                        }
                        
                        return $indicators;
                    })
                    ->form([
                        Select::make('category_id')
                            ->label('Category')
                            ->options(function () {
                                $categories = Category::select('id', 'name')->orderBy('name', 'asc')->get();
                                return Arr::pluck($categories, 'name', 'id');
                            })
                            ->searchable(),
                    ])
                    ->columnSpanFull()
                    ->baseQuery(function (Builder $query, array $data): Builder {
                        //Join on the products to categories table 
                        if($data['category_id']){
                            $query->join('products_to_categories', 'products.id', '=', 'products_to_categories.product_id');
                        } 
                        return $query; 
                    })
                    ->query(function (Builder $query, array $data): Builder {
                        if($data['category_id']){
                            $query->where('products_to_categories.category_id', $data['category_id']);
                        } 
                        return $query;
                    })

            ])
            // ... put the rest of your table methods here, eg. columns etc
            ;
    }

I haven't tested it but using baseQuery() should also work without a custom form. Please see Filament's documentation (Filters > Getting Started > Modifying the base query).

NOTE: IF THE TABLES YOU JOIN ON CONTAIN AN ID COLUMN FOR THE PRIMARY KEY THE QUERY MAY RETURN THE ID COLUMN OF YOUR MAIN TABLE (PRODUCTS) PLUS THE TABLES YOU HAVE JOINED ON (PRODUCTS_TO_CATEGORIES) AND THE ID FOR EACH RECORD MAY THEN BE INCORRECT. THE FIX IS SIMPLE - SPECIFY THE COLUMNS YOU WANT IN THE BASEQUERY METHOD, EG.

$query->select('products.id', 'producst.name', 'products_to_categories.count') ..... etc

1 like
atorscho's avatar

@ChristinePHPCoder thank you for this snippet! It did help, but with one caveat:

  • the query method does not take into account the joined table from baseQuery, for some reason.

So in this case:

$table->query(function (Builder $query, array $data) {
    $query
         ->having('percent_total', '>=', 75)
         ->having('percent_total', '<=', 100);
}),

the percent_total is not used, since the column does not exist (according to Filament).

However, if I put all of my join and having clauses inside the baseQuery and delete query method. The SelectFilter tries to use the column name from SelectFilter::make('column_name') to compare it with options value, completely ignoring the baseQuery. So I had to keep and empty query() method.

Please or to participate in this conversation.