Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: Tests

on:
push:
branches: [ main, master ]
pull_request:

jobs:
phpunit:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
php: ['8.2', '8.3']

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: sqlite3, pdo_sqlite, mbstring, dom, fileinfo
coverage: none

- name: Install dependencies (with cache)
uses: ramsey/composer-install@v3
with:
composer-options: --no-interaction --prefer-dist --no-progress

- name: Run PHPUnit
run: vendor/bin/phpunit -c phpunit.xml

1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ build
composer.lock
coverage
docs
phpunit.xml
psalm.xml
testbench.yaml
vendor
Expand Down
12 changes: 11 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,15 @@
"Firevel\\Filterable\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Firevel\\Filterable\\Tests\\": "tests/"
}
},
"require-dev": {
"phpunit/phpunit": "^10.5",
"orchestra/testbench": "^8.16",
"mockery/mockery": "^1.6"
},
"minimum-stability": "dev"
}
}
28 changes: 28 additions & 0 deletions phpunit.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd"
bootstrap="tests/bootstrap.php"
colors="true"
processIsolation="false"
stopOnFailure="false"
executionOrder="random"
failOnWarning="true"
failOnRisky="true"
failOnEmptyTestSuite="true"
beStrictAboutOutputDuringTests="true"
verbose="true">
<testsuites>
<testsuite name="Filterable Test Suite">
<directory>tests</directory>
</testsuite>
</testsuites>
<coverage>
<include>
<directory suffix=".php">./src</directory>
</include>
</coverage>
<php>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
</php>
</phpunit>
50 changes: 35 additions & 15 deletions src/Filterable.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,18 @@

trait Filterable
{
/**
* Determine the validation key for a given filter name.
* - For JSON paths (col->path), the validation key is the left side including any relationship dot prefix (e.g., user.document).
* - Otherwise, use the raw filter name.
*/
protected function getValidationKey(string $filterName): string
{
if (strpos($filterName, '->') !== false) {
return explode('->', $filterName, 2)[0];
}
return $filterName;
}
/**
* Default operator if no other operator detected.
*
Expand Down Expand Up @@ -80,22 +92,26 @@ public function applyFiltersToQuery($filters, $query)

if ($this->validateColumns) {
foreach ($filters as $filterName => $filterValue) {
$baseColumn = explode('->', $filterName)[0];
if (! array_key_exists($baseColumn, $this->filterable)) {
throw new \Exception("Filter column '$baseColumn' is not allowed.");
$validationKey = $this->getValidationKey($filterName);
if (! array_key_exists($validationKey, $this->filterable)) {
throw new \Exception("Filter column '$validationKey' is not allowed.");
}
}
}

foreach ($filters as $filterName => $filterValue) {
$baseColumn = explode('->', $filterName)[0];
// Eagerly reject nested relationship paths beyond one level
if (strpos($filterName, '.') !== false && substr_count($filterName, '.') > 1) {
throw new \Exception('Maximum one‐level sub‐query filtering supported.');
}
$validationKey = $this->getValidationKey($filterName);

// Skip any filters not explicitly declared in $filterable
if (! array_key_exists($baseColumn, $this->filterable)) {
if (! array_key_exists($validationKey, $this->filterable)) {
continue;
}

$filterType = $this->filterable[$baseColumn];
$filterType = $this->filterable[$validationKey];

// If the filterType is "scope", call the local scope method
if ($filterType === 'scope') {
Expand Down Expand Up @@ -153,11 +169,13 @@ public function applyFilterToQuery($filterType, $filterName, $filterValue, $oper
}

// Support “relationship.column” notation (only one level deep)
$isRelationshipDot = false;
if (strpos($filterName, '.') !== false) {
if (substr_count($filterName, '.') > 1) {
throw new \Exception('Maximum one‐level sub‐query filtering supported.');
}
list($relationship, $filterName) = explode('.', $filterName);
$isRelationshipDot = true;
}

// Handle “in” operator
Expand Down Expand Up @@ -201,8 +219,14 @@ public function applyFilterToQuery($filterType, $filterName, $filterValue, $oper
break;

case 'relationship':
$filterName = Str::camel($filterName);
$method = 'has';
if ($isRelationshipDot) {
// When targeting a column on the related model, we apply a where inside whereHas
$method = 'where';
} else {
// When filtering by relationship existence/count
$filterName = Str::camel($filterName);
$method = 'has';
}
break;

case 'boolean':
Expand Down Expand Up @@ -231,16 +255,12 @@ public function applyFilterToQuery($filterType, $filterName, $filterValue, $oper
if (! empty($relationship)) {
return $query->whereHas(
Str::camel($relationship),
function ($query) use ($method, $filterName, $operator, $filterValue, $filterRelationshipQuery, $filterType) {
function ($query) use ($filterName, $operator, $filterValue, $filterRelationshipQuery) {
if (! empty($filterRelationshipQuery)) {
$query->where($filterRelationshipQuery);
}

if ($filterType === 'array') {
return $query->$method($filterName, $filterValue);
}

return $query->$method($filterName, $operator, $filterValue);
return $query->where($filterName, $operator, $filterValue);
}
);
}
Expand Down Expand Up @@ -299,4 +319,4 @@ public function scopeFilter($query, $filters)

return $query;
}
}
}
Loading