From 6a5eff1bd96997925f6f6c20a8ac5aca9a8d1045 Mon Sep 17 00:00:00 2001 From: Michael Slowik Date: Mon, 11 Aug 2025 18:30:31 +0200 Subject: [PATCH 1/4] initial tests --- src/Filterable.php | 50 ++++-- tests/BasicFilteringTest.php | 244 ++++++++++++++++++++++++++ tests/DateTimeFilteringTest.php | 84 +++++++++ tests/EdgeCasesAndValidationTest.php | 253 +++++++++++++++++++++++++++ tests/FilterableTest.php | 103 +++++++++++ tests/Models/Comment.php | 20 +++ tests/Models/Post.php | 41 +++++ tests/Models/TestModel.php | 65 +++++++ tests/Models/User.php | 31 ++++ tests/Models/ValidatedModel.php | 26 +++ tests/RelationshipFilteringTest.php | 193 ++++++++++++++++++++ tests/ScopeFilteringTest.php | 245 ++++++++++++++++++++++++++ tests/TestCase.php | 70 ++++++++ tests/bootstrap.php | 14 ++ 14 files changed, 1424 insertions(+), 15 deletions(-) create mode 100644 tests/BasicFilteringTest.php create mode 100644 tests/DateTimeFilteringTest.php create mode 100644 tests/EdgeCasesAndValidationTest.php create mode 100644 tests/FilterableTest.php create mode 100644 tests/Models/Comment.php create mode 100644 tests/Models/Post.php create mode 100644 tests/Models/TestModel.php create mode 100644 tests/Models/User.php create mode 100644 tests/Models/ValidatedModel.php create mode 100644 tests/RelationshipFilteringTest.php create mode 100644 tests/ScopeFilteringTest.php create mode 100644 tests/TestCase.php create mode 100644 tests/bootstrap.php diff --git a/src/Filterable.php b/src/Filterable.php index 49d7c76..d00929f 100644 --- a/src/Filterable.php +++ b/src/Filterable.php @@ -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. * @@ -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') { @@ -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 @@ -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': @@ -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); } ); } @@ -299,4 +319,4 @@ public function scopeFilter($query, $filters) return $query; } -} \ No newline at end of file +} diff --git a/tests/BasicFilteringTest.php b/tests/BasicFilteringTest.php new file mode 100644 index 0000000..008eb28 --- /dev/null +++ b/tests/BasicFilteringTest.php @@ -0,0 +1,244 @@ + 'John Doe', + 'email' => 'john@example.com', + 'age' => 25, + 'price' => 99.99, + 'active' => true, + ]); + + TestModel::create([ + 'name' => 'Jane Smith', + 'email' => 'jane@example.com', + 'age' => 30, + 'price' => 149.99, + 'active' => false, + ]); + + TestModel::create([ + 'name' => 'Bob Johnson', + 'email' => 'bob@example.com', + 'age' => 35, + 'price' => 199.99, + 'active' => true, + ]); + } + + public function test_integer_filtering_with_equals_operator() + { + $results = TestModel::filter(['age' => 25])->get(); + + $this->assertCount(1, $results); + $this->assertEquals('John Doe', $results->first()->name); + } + + public function test_integer_filtering_with_greater_than_operator() + { + $results = TestModel::filter(['age' => ['>' => 30]])->get(); + + $this->assertCount(1, $results); + $this->assertEquals('Bob Johnson', $results->first()->name); + } + + public function test_integer_filtering_with_less_than_operator() + { + $results = TestModel::filter(['age' => ['<' => 30]])->get(); + + $this->assertCount(1, $results); + $this->assertEquals('John Doe', $results->first()->name); + } + + public function test_integer_filtering_with_greater_than_or_equal_operator() + { + $results = TestModel::filter(['age' => ['>=' => 30]])->get(); + + $this->assertCount(2, $results); + $this->assertContains('Jane Smith', $results->pluck('name')); + $this->assertContains('Bob Johnson', $results->pluck('name')); + } + + public function test_integer_filtering_with_less_than_or_equal_operator() + { + $results = TestModel::filter(['age' => ['<=' => 30]])->get(); + + $this->assertCount(2, $results); + $this->assertContains('John Doe', $results->pluck('name')); + $this->assertContains('Jane Smith', $results->pluck('name')); + } + + public function test_integer_filtering_with_not_equal_operator() + { + $results = TestModel::filter(['age' => ['<>' => 30]])->get(); + + $this->assertCount(2, $results); + $this->assertContains('John Doe', $results->pluck('name')); + $this->assertContains('Bob Johnson', $results->pluck('name')); + } + + public function test_integer_filtering_with_in_operator() + { + $results = TestModel::filter(['age' => ['in' => '25,35']])->get(); + + $this->assertCount(2, $results); + $this->assertContains('John Doe', $results->pluck('name')); + $this->assertContains('Bob Johnson', $results->pluck('name')); + } + + public function test_integer_filtering_with_in_operator_array() + { + $results = TestModel::filter(['age' => ['in' => [25, 35]]])->get(); + + $this->assertCount(2, $results); + $this->assertContains('John Doe', $results->pluck('name')); + $this->assertContains('Bob Johnson', $results->pluck('name')); + } + + public function test_string_filtering_with_equals_operator() + { + $results = TestModel::filter(['name' => 'John Doe'])->get(); + + $this->assertCount(1, $results); + $this->assertEquals('john@example.com', $results->first()->email); + } + + public function test_string_filtering_with_like_operator() + { + $results = TestModel::filter(['name' => ['like' => '%John%']])->get(); + + $this->assertCount(2, $results); + $this->assertContains('John Doe', $results->pluck('name')); + $this->assertContains('Bob Johnson', $results->pluck('name')); + } + + public function test_string_filtering_with_not_equal_operator() + { + $results = TestModel::filter(['name' => ['<>' => 'John Doe']])->get(); + + $this->assertCount(2, $results); + $this->assertNotContains('John Doe', $results->pluck('name')); + } + + public function test_boolean_filtering_true() + { + $results = TestModel::filter(['active' => true])->get(); + + $this->assertCount(2, $results); + $this->assertContains('John Doe', $results->pluck('name')); + $this->assertContains('Bob Johnson', $results->pluck('name')); + } + + public function test_boolean_filtering_false() + { + $results = TestModel::filter(['active' => false])->get(); + + $this->assertCount(1, $results); + $this->assertEquals('Jane Smith', $results->first()->name); + } + + public function test_boolean_filtering_string_true() + { + $results = TestModel::filter(['active' => 'true'])->get(); + + $this->assertCount(2, $results); + } + + public function test_boolean_filtering_string_false() + { + $results = TestModel::filter(['active' => 'false'])->get(); + + $this->assertCount(1, $results); + $this->assertEquals('Jane Smith', $results->first()->name); + } + + public function test_boolean_filtering_numeric_one() + { + $results = TestModel::filter(['active' => '1'])->get(); + + $this->assertCount(2, $results); + } + + public function test_boolean_filtering_numeric_zero() + { + $results = TestModel::filter(['active' => '0'])->get(); + + $this->assertCount(1, $results); + $this->assertEquals('Jane Smith', $results->first()->name); + } + + public function test_multiple_filters() + { + $results = TestModel::filter([ + 'age' => ['>=' => 30], + 'active' => true, + ])->get(); + + $this->assertCount(1, $results); + $this->assertEquals('Bob Johnson', $results->first()->name); + } + + public function test_null_filtering_with_is_operator() + { + TestModel::create(['name' => 'Null Test', 'age' => null]); + + $results = TestModel::filter(['age' => ['is' => 'null']])->get(); + + $this->assertCount(1, $results); + $this->assertEquals('Null Test', $results->first()->name); + } + + public function test_null_filtering_with_not_operator() + { + TestModel::create(['name' => 'Null Test', 'age' => null]); + + $results = TestModel::filter(['age' => ['not' => 'null']])->get(); + + $this->assertCount(3, $results); + $this->assertNotContains('Null Test', $results->pluck('name')); + } + + public function test_id_filtering() + { + $model = TestModel::first(); + + $results = TestModel::filter(['id' => $model->id])->get(); + + $this->assertCount(1, $results); + $this->assertEquals($model->name, $results->first()->name); + } + + public function test_price_filtering_as_integer_type() + { + $results = TestModel::filter(['price' => ['>' => 100]])->get(); + + $this->assertCount(2, $results); + $this->assertContains('Jane Smith', $results->pluck('name')); + $this->assertContains('Bob Johnson', $results->pluck('name')); + } + + public function test_empty_filters_returns_all() + { + $results = TestModel::filter([])->get(); + + $this->assertCount(3, $results); + } + + public function test_filter_with_undeclared_column_is_skipped() + { + $results = TestModel::filter(['undeclared_column' => 'value'])->get(); + + $this->assertCount(3, $results); + } +} \ No newline at end of file diff --git a/tests/DateTimeFilteringTest.php b/tests/DateTimeFilteringTest.php new file mode 100644 index 0000000..9f8618a --- /dev/null +++ b/tests/DateTimeFilteringTest.php @@ -0,0 +1,84 @@ + 'Morning Record', + 'birth_date' => '1990-01-15', + 'created_at' => '2023-01-15 08:00:00', + 'updated_at' => '2023-01-15 08:00:00', + ]); + + TestModel::create([ + 'name' => 'Evening Record', + 'birth_date' => '1990-01-15', + 'created_at' => '2023-01-15 20:30:00', + 'updated_at' => '2023-01-15 20:30:00', + ]); + + // Different date + TestModel::create([ + 'name' => 'Next Day Record', + 'birth_date' => '1995-06-20', + 'created_at' => '2023-06-20 14:45:00', + 'updated_at' => '2023-06-20 14:45:00', + ]); + } + + public function test_date_filtering_uses_whereDate_and_matches_whole_day() + { + // Filter by YYYY-MM-DD on a datetime field should behave as whereDate + $results = TestModel::filter(['created_at' => '2023-01-15'])->get(); + + $this->assertCount(2, $results); + $this->assertContains('Morning Record', $results->pluck('name')); + $this->assertContains('Evening Record', $results->pluck('name')); + } + + public function test_datetime_filtering_with_full_timestamp_matches_exact() + { + $results = TestModel::filter(['created_at' => '2023-01-15 08:00:00'])->get(); + + $this->assertCount(1, $results); + $this->assertEquals('Morning Record', $results->first()->name); + } + + public function test_date_comparison_operators() + { + // birth_date is a date type, so comparisons should work on dates + $after1992 = TestModel::filter(['birth_date' => ['>' => '1992-01-01']])->get(); + $this->assertCount(1, $after1992); + $this->assertEquals('Next Day Record', $after1992->first()->name); + + $onOrBefore1990 = TestModel::filter(['birth_date' => ['<=' => '1990-01-15']])->get(); + $this->assertCount(2, $onOrBefore1990); + $this->assertContains('Morning Record', $onOrBefore1990->pluck('name')); + $this->assertContains('Evening Record', $onOrBefore1990->pluck('name')); + } + + public function test_datetime_comparison_operators_with_aliases() + { + $afterMorning = TestModel::filter(['created_at' => ['gt' => '2023-01-15 08:00:00']])->get(); + $this->assertCount(2, $afterMorning); + $this->assertContains('Evening Record', $afterMorning->pluck('name')); + $this->assertContains('Next Day Record', $afterMorning->pluck('name')); + + $beforeOrAtEvening = TestModel::filter(['created_at' => ['lte' => '2023-01-15 20:30:00']])->get(); + $this->assertCount(2, $beforeOrAtEvening); + $this->assertContains('Morning Record', $beforeOrAtEvening->pluck('name')); + $this->assertContains('Evening Record', $beforeOrAtEvening->pluck('name')); + } +} + diff --git a/tests/EdgeCasesAndValidationTest.php b/tests/EdgeCasesAndValidationTest.php new file mode 100644 index 0000000..397c768 --- /dev/null +++ b/tests/EdgeCasesAndValidationTest.php @@ -0,0 +1,253 @@ + 'Test Model 1', 'email' => 'test1@example.com']); + TestModel::create(['name' => 'Test Model 2', 'email' => 'test2@example.com']); + } + + public function test_empty_filterable_array_returns_all_records() + { + $model = new class extends TestModel { + protected $table = 'test_models'; + protected $filterable = []; + }; + + $results = $model::filter(['name' => 'Test Model 1'])->get(); + + // Should return all records since no filters are defined + $this->assertCount(2, $results); + } + + public function test_null_filterable_property_returns_all_records() + { + $model = new class extends TestModel { + protected $table = 'test_models'; + protected $filterable = null; + }; + + $results = $model::filter(['name' => 'Test Model 1'])->get(); + + // Should return all records since filterable is null + $this->assertCount(2, $results); + } + + public function test_column_validation_enabled_with_valid_column() + { + ValidatedModel::create(['name' => 'Validated 1', 'email' => 'validated1@example.com']); + ValidatedModel::create(['name' => 'Validated 2', 'email' => 'validated2@example.com']); + + $results = ValidatedModel::filter(['name' => 'Validated 1'])->get(); + + $this->assertCount(1, $results); + $this->assertEquals('Validated 1', $results->first()->name); + } + + public function test_column_validation_enabled_with_invalid_column() + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage("Filter column 'age' is not allowed."); + + ValidatedModel::filter(['age' => 25])->get(); + } + + public function test_column_validation_with_json_path() + { + $this->markTestSkipped('JSON path validation tests deferred.'); + } + + public function test_column_validation_with_invalid_json_path() + { + $this->markTestSkipped('JSON path validation tests deferred.'); + } + + public function test_illegal_operator_throws_exception() + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Illegal operator @'); + + TestModel::filter(['name' => ['@' => 'test']])->get(); + } + + public function test_operator_not_allowed_for_type_throws_exception() + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage("Operator 'like' is not allowed for type 'integer'"); + + TestModel::filter(['age' => ['like' => '%25%']])->get(); + } + + public function test_unsupported_filter_type_throws_exception() + { + $model = new class extends TestModel { + protected $table = 'test_models'; + protected $filterable = [ + 'custom' => 'unsupported_type', + ]; + }; + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Unsupported filter type unsupported_type'); + + $model::filter(['custom' => 'value'])->get(); + } + + public function test_url_encoded_operators() + { + // Test that URL-encoded operators are properly decoded + TestModel::create(['name' => 'Encoded Test', 'age' => 40]); + + $results = TestModel::filter(['age' => ['%3E' => 35]])->get(); // %3E is > + + $this->assertCount(1, $results); + $this->assertEquals('Encoded Test', $results->first()->name); + } + + public function test_filter_with_closure_in_relationship_query() + { + $user = User::create(['name' => 'Special User', 'email' => 'special@example.com', 'level' => 8]); + TestModel::create(['name' => 'Special Model', 'user_id' => $user->id]); + + $model = new TestModel(); + $query = TestModel::query(); + + // Test with relationship query + $model->useRelationshipQuery(function($q) { + $q->where('email', 'like', '%special%'); + }); + + $model->applyFiltersToQuery(['user' => ['>=' => 1]], $query); + $results = $query->get(); + + $this->assertCount(1, $results); + $this->assertEquals('Special Model', $results->first()->name); + } + + public function test_multiple_operators_on_same_field() + { + TestModel::create(['name' => 'Range Test 1', 'age' => 20]); + TestModel::create(['name' => 'Range Test 2', 'age' => 30]); + TestModel::create(['name' => 'Range Test 3', 'age' => 40]); + + // Test multiple operators on the same field + $results = TestModel::filter([ + 'age' => [ + '>=' => 25, + '<=' => 35, + ], + ])->get(); + + $this->assertCount(1, $results); + $this->assertEquals('Range Test 2', $results->first()->name); + } + + public function test_special_characters_in_filter_values() + { + TestModel::create(['name' => "O'Brien", 'email' => "o'brien@example.com"]); + TestModel::create(['name' => 'Test & Co.', 'email' => 'test&co@example.com']); + TestModel::create(['name' => '50% off', 'email' => '50percent@example.com']); + + // Test with special characters + $results = TestModel::filter(['name' => "O'Brien"])->get(); + $this->assertCount(1, $results); + + $results = TestModel::filter(['name' => ['like' => '%&%']])->get(); + $this->assertCount(1, $results); + $this->assertEquals('Test & Co.', $results->first()->name); + + $results = TestModel::filter(['name' => ['like' => '50%']])->get(); + $this->assertCount(1, $results); + $this->assertEquals('50% off', $results->first()->name); + } + + public function test_very_long_json_paths() + { + $this->markTestSkipped('Deep JSON path tests deferred.'); + } + + public function test_filter_preserves_query_builder_state() + { + TestModel::create(['name' => 'Active Test', 'active' => true, 'age' => 25]); + TestModel::create(['name' => 'Inactive Test', 'active' => false, 'age' => 30]); + + // Test that filter() preserves existing query conditions + $results = TestModel::where('active', true) + ->filter(['age' => ['>=' => 20]]) + ->get(); + + $this->assertCount(1, $results); + $this->assertEquals('Active Test', $results->first()->name); + } + + public function test_filter_with_empty_string_value() + { + TestModel::create(['name' => '', 'email' => 'empty@example.com']); + TestModel::create(['name' => 'Not Empty', 'email' => 'notempty@example.com']); + + $results = TestModel::filter(['name' => ''])->get(); + + $this->assertCount(1, $results); + $this->assertEquals('empty@example.com', $results->first()->email); + } + + public function test_filter_with_zero_values() + { + TestModel::create(['name' => 'Zero Age', 'age' => 0]); + TestModel::create(['name' => 'Non Zero', 'age' => 25]); + + $results = TestModel::filter(['age' => 0])->get(); + + $this->assertCount(1, $results); + $this->assertEquals('Zero Age', $results->first()->name); + } + + public function test_case_sensitivity_in_operators() + { + // Test that operators are case-insensitive if needed + TestModel::create(['name' => 'Null Test Upper', 'age' => null]); + TestModel::create(['name' => 'Not Null', 'age' => 25]); + + // Test with uppercase NULL - should find all null age records + $results = TestModel::filter(['age' => ['is' => 'NULL']])->get(); + // Could be more than 1 if other tests created records with null age + $this->assertGreaterThanOrEqual(1, $results->count()); + $this->assertContains('Null Test Upper', $results->pluck('name')); + + // Test with mixed case + $results = TestModel::filter(['age' => ['not' => 'NuLl']])->get(); + // Robust assertion: should include non-null age we just created + $this->assertContains('Not Null', $results->pluck('name')); + // and should not include the null-age record we created + $this->assertNotContains('Null Test Upper', $results->pluck('name')); + $this->assertGreaterThanOrEqual(1, $results->count()); + } + + public function test_default_operator_override() + { + $model = new class extends TestModel { + protected $table = 'test_models'; + protected $defaultFilterOperator = 'like'; + protected $filterable = [ + 'name' => 'string', + ]; + }; + + $model::create(['name' => 'Default Like Test']); + $model::create(['name' => 'Another Test']); + + // Should use LIKE as default operator + $results = $model::filter(['name' => '%Test'])->get(); + + $this->assertCount(2, $results); + } +} diff --git a/tests/FilterableTest.php b/tests/FilterableTest.php new file mode 100644 index 0000000..d5071e1 --- /dev/null +++ b/tests/FilterableTest.php @@ -0,0 +1,103 @@ + '>', + 'gte' => '>=', + 'lt' => '<', + 'lte' => '<=', + 'ne' => '<>', + 'eq' => '=', + ]; + + foreach ($supportedAliases as $alias => $expectedOperator) { + $query = Mockery::mock(Builder::class); + $query->shouldReceive('where') + ->once() + ->with('age', $expectedOperator, 25) + ->andReturnSelf(); + + $model->applyFilterToQuery('integer', 'age', 25, $alias, $query); + } + // Mark that expectations were verified + $this->addToAssertionCount(1); + } + + public function testOperatorAliasesInFilterScope() + { + $model = new TestModel(); + $query = Mockery::mock(Builder::class); + + $query->shouldReceive('where') + ->once() + ->with('age', '>', 25) + ->andReturnSelf(); + + $query->shouldReceive('where') + ->once() + ->with('score', '<', 100) + ->andReturnSelf(); + + $filters = [ + 'age' => ['gt' => 25], + 'score' => ['lt' => 100], + ]; + + $model->applyFiltersToQuery($filters, $query); + $this->addToAssertionCount(1); + } + + public function testMixedOperatorsAndAliases() + { + $model = new TestModel(); + $query = Mockery::mock(Builder::class); + + $query->shouldReceive('where') + ->once() + ->with('age', '>', 25) + ->andReturnSelf(); + + $query->shouldReceive('where') + ->once() + ->with('score', '>=', 50) + ->andReturnSelf(); + + $filters = [ + 'age' => ['>' => 25], + 'score' => ['gte' => 50], + ]; + + $model->applyFiltersToQuery($filters, $query); + $this->addToAssertionCount(1); + } +} + +class TestModel extends Model +{ + use Filterable; + + protected $filterable = [ + 'age' => 'integer', + 'score' => 'integer', + 'name' => 'string', + 'created_at' => 'date', + ]; +} diff --git a/tests/Models/Comment.php b/tests/Models/Comment.php new file mode 100644 index 0000000..dcdb0ec --- /dev/null +++ b/tests/Models/Comment.php @@ -0,0 +1,20 @@ +belongsTo(Post::class); + } + + public function user() + { + return $this->belongsTo(User::class); + } +} \ No newline at end of file diff --git a/tests/Models/Post.php b/tests/Models/Post.php new file mode 100644 index 0000000..d77897f --- /dev/null +++ b/tests/Models/Post.php @@ -0,0 +1,41 @@ + 'json', + ]; + + protected $filterable = [ + 'id' => 'id', + 'title' => 'string', + 'user_id' => 'integer', + 'meta' => 'json', + 'user' => 'relationship', + 'comments' => 'relationship', + // Explicit sub-resources for relationship filtering + 'user.name' => 'string', + 'user.email' => 'string', + 'user.level' => 'integer', + 'comments.user_id' => 'integer', + ]; + + public function user() + { + return $this->belongsTo(User::class); + } + + public function comments() + { + return $this->hasMany(Comment::class); + } +} diff --git a/tests/Models/TestModel.php b/tests/Models/TestModel.php new file mode 100644 index 0000000..aec50e3 --- /dev/null +++ b/tests/Models/TestModel.php @@ -0,0 +1,65 @@ + 'boolean', + 'settings' => 'json', + 'tags' => 'json', + 'birth_date' => 'date', + ]; + + protected $filterable = [ + 'id' => 'id', + 'name' => 'string', + 'email' => 'string', + 'age' => 'integer', + 'price' => 'integer', + 'active' => 'boolean', + 'birth_date' => 'date', + 'created_at' => 'datetime', + 'settings' => 'json', + 'tags' => 'array', + 'user' => 'relationship', + // Explicit sub-resources for relationship filtering + 'user.email' => 'string', + 'user.name' => 'string', + 'user.level' => 'integer', + 'activeUsers' => 'scope', + ]; + + public function user() + { + return $this->belongsTo(User::class); + } + + public function scopeActiveUsers($query, $value) + { + if ($value) { + return $query->where('active', true) + ->whereHas('user', function ($q) { + $q->where('level', '>', 5); + }); + } + return $query; + } +} diff --git a/tests/Models/User.php b/tests/Models/User.php new file mode 100644 index 0000000..dea15bc --- /dev/null +++ b/tests/Models/User.php @@ -0,0 +1,31 @@ + 'id', + 'name' => 'string', + 'email' => 'string', + 'level' => 'integer', + 'posts' => 'relationship', + ]; + + public function posts() + { + return $this->hasMany(Post::class); + } + + public function testModels() + { + return $this->hasMany(TestModel::class); + } +} \ No newline at end of file diff --git a/tests/Models/ValidatedModel.php b/tests/Models/ValidatedModel.php new file mode 100644 index 0000000..6c7b3c3 --- /dev/null +++ b/tests/Models/ValidatedModel.php @@ -0,0 +1,26 @@ + 'string', + 'email' => 'string', + ]; + + public function __construct(array $attributes = []) + { + parent::__construct($attributes); + $this->validateColumns = true; + } +} \ No newline at end of file diff --git a/tests/RelationshipFilteringTest.php b/tests/RelationshipFilteringTest.php new file mode 100644 index 0000000..42b3689 --- /dev/null +++ b/tests/RelationshipFilteringTest.php @@ -0,0 +1,193 @@ + 'John Doe', 'email' => 'john@example.com', 'level' => 5]); + $user2 = User::create(['name' => 'Jane Smith', 'email' => 'jane@example.com', 'level' => 10]); + $user3 = User::create(['name' => 'Bob Johnson', 'email' => 'bob@example.com', 'level' => 3]); + + // Create test models with user relationships + TestModel::create(['name' => 'Model 1', 'user_id' => $user1->id, 'active' => true]); + TestModel::create(['name' => 'Model 2', 'user_id' => $user2->id, 'active' => true]); + TestModel::create(['name' => 'Model 3', 'user_id' => $user3->id, 'active' => false]); + TestModel::create(['name' => 'Model 4', 'user_id' => null, 'active' => true]); + + // Create posts + $post1 = Post::create(['title' => 'Post 1', 'content' => 'Content 1', 'user_id' => $user1->id]); + $post2 = Post::create(['title' => 'Post 2', 'content' => 'Content 2', 'user_id' => $user2->id]); + $post3 = Post::create(['title' => 'Post 3', 'content' => 'Content 3', 'user_id' => $user2->id]); + + // Create comments + Comment::create(['content' => 'Comment 1', 'post_id' => $post1->id, 'user_id' => $user2->id]); + Comment::create(['content' => 'Comment 2', 'post_id' => $post1->id, 'user_id' => $user3->id]); + Comment::create(['content' => 'Comment 3', 'post_id' => $post2->id, 'user_id' => $user1->id]); + } + + public function test_basic_relationship_filtering() + { + // Filter test models that have a user + $results = TestModel::filter(['user' => ['>=' => 1]])->get(); + + $this->assertCount(3, $results); + $this->assertNotContains('Model 4', $results->pluck('name')); + } + + public function test_relationship_filtering_with_dot_notation() + { + // Filter test models by user email + $results = TestModel::filter(['user.email' => 'jane@example.com'])->get(); + + $this->assertCount(1, $results); + $this->assertEquals('Model 2', $results->first()->name); + } + + public function test_relationship_filtering_with_like_operator() + { + // Filter test models by user name pattern + $results = TestModel::filter(['user.name' => ['like' => '%John%']])->get(); + + $this->assertCount(2, $results); + $this->assertContains('Model 1', $results->pluck('name')); + $this->assertContains('Model 3', $results->pluck('name')); + } + + public function test_relationship_filtering_with_integer_comparison() + { + // Filter test models by user level + $results = TestModel::filter(['user.level' => ['>' => 5]])->get(); + + $this->assertCount(1, $results); + $this->assertEquals('Model 2', $results->first()->name); + } + + public function test_has_many_relationship_filtering() + { + // Filter users that have posts + $results = User::filter(['posts' => ['>=' => 1]])->get(); + + $this->assertCount(2, $results); + $this->assertContains('John Doe', $results->pluck('name')); + $this->assertContains('Jane Smith', $results->pluck('name')); + $this->assertNotContains('Bob Johnson', $results->pluck('name')); + } + + public function test_has_many_relationship_filtering_with_count() + { + // Filter users that have more than 1 post + $results = User::filter(['posts' => ['>' => 1]])->get(); + + $this->assertCount(1, $results); + $this->assertEquals('Jane Smith', $results->first()->name); + } + + public function test_nested_relationship_filtering() + { + // Filter posts that have comments from a specific user + $results = Post::filter(['comments.user_id' => User::where('email', 'jane@example.com')->first()->id])->get(); + + $this->assertCount(1, $results); + $this->assertEquals('Post 1', $results->first()->title); + } + + public function test_multiple_relationship_filters() + { + // Filter test models with active status and user level > 3 + $results = TestModel::filter([ + 'active' => true, + 'user.level' => ['>' => 3], + ])->get(); + + $this->assertCount(2, $results); + $this->assertContains('Model 1', $results->pluck('name')); + $this->assertContains('Model 2', $results->pluck('name')); + } + + public function test_relationship_filtering_with_null_check() + { + // Create a post without comments + Post::create(['title' => 'Post 4', 'content' => 'Content 4', 'user_id' => User::first()->id]); + + // Filter posts that have no comments + $results = Post::filter(['comments' => ['=' => 0]])->get(); + + $this->assertCount(2, $results); + $this->assertContains('Post 3', $results->pluck('title')); + $this->assertContains('Post 4', $results->pluck('title')); + } + + public function test_relationship_filtering_with_whereHas() + { + // This tests the internal whereHas functionality + // Filter posts by user name through relationship + $results = Post::filter(['user.name' => 'Jane Smith'])->get(); + + $this->assertCount(2, $results); + $this->assertContains('Post 2', $results->pluck('title')); + $this->assertContains('Post 3', $results->pluck('title')); + } + + public function test_relationship_filtering_with_multiple_conditions() + { + // Filter posts by user with multiple conditions + $results = Post::filter([ + 'user.email' => ['like' => '%@example.com'], + 'user.level' => ['>=' => 5], + ])->get(); + + $this->assertCount(3, $results); + } + + public function test_relationship_query_with_extra_conditions() + { + // Create a TestModel instance to test useRelationshipQuery + $model = new TestModel(); + + // Apply relationship query filter + $query = TestModel::query(); + $model->useRelationshipQuery(function($q) { + $q->where('level', '>', 5); + }); + + // Apply filters + $model->applyFiltersToQuery(['user' => ['>=' => 1]], $query); + $results = $query->get(); + + $this->assertCount(1, $results); + $this->assertEquals('Model 2', $results->first()->name); + } + + public function test_relationship_filtering_throws_exception_for_nested_relationships() + { + try { + TestModel::filter(['user.posts.title' => 'test'])->get(); + $this->fail('Expected exception was not thrown'); + } catch (\Exception $e) { + $this->assertStringContainsString('Maximum one', $e->getMessage()); + $this->assertStringContainsString('level sub', $e->getMessage()); + $this->assertStringContainsString('query filtering supported', $e->getMessage()); + } + } + + public function test_relationship_filtering_converts_to_camel_case() + { + // Create a model with snake_case relationship + // The relationship 'user' should work even if column is 'user_id' + $results = TestModel::filter(['user' => ['>=' => 1]])->get(); + + $this->assertCount(3, $results); + } + + // JSON-based relationship filtering deferred for now +} diff --git a/tests/ScopeFilteringTest.php b/tests/ScopeFilteringTest.php new file mode 100644 index 0000000..73b9c0f --- /dev/null +++ b/tests/ScopeFilteringTest.php @@ -0,0 +1,245 @@ + 'Admin User', 'email' => 'admin@example.com', 'level' => 10]); + $user2 = User::create(['name' => 'Power User', 'email' => 'power@example.com', 'level' => 7]); + $user3 = User::create(['name' => 'Regular User', 'email' => 'regular@example.com', 'level' => 3]); + + // Create test models + TestModel::create([ + 'name' => 'Active Admin Model', + 'user_id' => $user1->id, + 'active' => true, + ]); + + TestModel::create([ + 'name' => 'Active Power Model', + 'user_id' => $user2->id, + 'active' => true, + ]); + + TestModel::create([ + 'name' => 'Inactive Admin Model', + 'user_id' => $user1->id, + 'active' => false, + ]); + + TestModel::create([ + 'name' => 'Active Regular Model', + 'user_id' => $user3->id, + 'active' => true, + ]); + + TestModel::create([ + 'name' => 'Orphan Model', + 'user_id' => null, + 'active' => true, + ]); + } + + public function test_scope_filter_with_true_value() + { + // The activeUsers scope filters for active = true AND user.level > 5 + $results = TestModel::filter(['activeUsers' => true])->get(); + + $this->assertCount(2, $results); + $this->assertContains('Active Admin Model', $results->pluck('name')); + $this->assertContains('Active Power Model', $results->pluck('name')); + } + + public function test_scope_filter_with_false_value() + { + // When passing false, the scope should not apply any filtering + $results = TestModel::filter(['activeUsers' => false])->get(); + + $this->assertCount(5, $results); // All models + } + + public function test_scope_filter_with_string_value() + { + // Scope filters pass the value to the scope method + $results = TestModel::filter(['activeUsers' => '1'])->get(); + + $this->assertCount(2, $results); + $this->assertContains('Active Admin Model', $results->pluck('name')); + $this->assertContains('Active Power Model', $results->pluck('name')); + } + + public function test_combining_scope_filter_with_other_filters() + { + $results = TestModel::filter([ + 'activeUsers' => true, + 'name' => ['like' => '%Admin%'], + ])->get(); + + $this->assertCount(1, $results); + $this->assertEquals('Active Admin Model', $results->first()->name); + } + + public function test_multiple_scope_filters() + { + // Add another scope to TestModel for testing + TestModel::macro('scopeHighLevelUsers', function ($query, $value) { + if ($value) { + return $query->whereHas('user', function ($q) { + $q->where('level', '>=', 8); + }); + } + return $query; + }); + + // Create a model with both scopes in filterable + $model = new class extends TestModel { + protected $table = 'test_models'; + protected $filterable = [ + 'activeUsers' => 'scope', + 'highLevelUsers' => 'scope', + 'name' => 'string', + 'email' => 'string', + 'age' => 'integer', + 'price' => 'integer', + 'active' => 'boolean', + 'birth_date' => 'date', + 'created_at' => 'datetime', + 'settings' => 'json', + 'tags' => 'array', + 'user' => 'relationship', + ]; + }; + + $results = TestModel::filter([ + 'activeUsers' => true, + 'active' => true, // This would normally include more results + ])->get(); + + // activeUsers scope already filters for active AND level > 5 + $this->assertCount(2, $results); + } + + public function test_scope_filter_maintains_camel_case() + { + // The filter name 'activeUsers' should be converted to camelCase for the scope method + // This is testing that Str::camel() is working correctly + $results = TestModel::filter(['activeUsers' => true])->get(); + + $this->assertGreaterThan(0, $results->count()); + } + + public function test_scope_filter_with_complex_logic() + { + // Create a more complex scenario + TestModel::create([ + 'name' => 'Edge Case Model', + 'user_id' => User::create(['name' => 'Edge User', 'email' => 'edge@example.com', 'level' => 6])->id, + 'active' => true, + ]); + + // Should include the edge case (level 6 > 5) + $results = TestModel::filter(['activeUsers' => true])->get(); + + $this->assertCount(3, $results); + $this->assertContains('Edge Case Model', $results->pluck('name')); + } + + public function test_scope_filter_is_applied_before_other_filters() + { + // Test that scope filters are processed in the correct order + $query = TestModel::query(); + + // Track query execution order + $executionOrder = []; + + TestModel::macro('scopeTrackingScope', function ($query, $value) use (&$executionOrder) { + $executionOrder[] = 'scope'; + return $query; + }); + + // Override applyFilterToQuery to track regular filters + $model = new class extends TestModel { + public $filterable = [ + 'trackingScope' => 'scope', + 'name' => 'string', + ]; + + public function applyFilterToQuery($filterType, $filterName, $filterValue, $operator, $query) + { + if ($filterType !== 'scope') { + $GLOBALS['test_execution_order'][] = 'regular'; + } + return parent::applyFilterToQuery($filterType, $filterName, $filterValue, $operator, $query); + } + }; + + // The scope should be called during the filter processing + $results = TestModel::filter(['activeUsers' => true])->get(); + + $this->assertGreaterThan(0, $results->count()); + } + + public function test_undefined_scope_throws_exception() + { + // Create a test model with a scope filter that doesn't have a corresponding method + $model = new class extends TestModel { + protected $filterable = [ + 'nonExistentScope' => 'scope', + ]; + }; + + $this->expectException(\BadMethodCallException::class); + + $model::filter(['nonExistentScope' => true])->get(); + } + + public function test_scope_receives_filter_value() + { + // Create a scope that uses the passed value + TestModel::macro('scopeMinLevel', function ($query, $minLevel) { + return $query->whereHas('user', function ($q) use ($minLevel) { + $q->where('level', '>=', $minLevel); + }); + }); + + $model = new class extends TestModel { + protected $table = 'test_models'; + protected $filterable = [ + 'minLevel' => 'scope', + 'name' => 'string', + 'email' => 'string', + 'age' => 'integer', + 'price' => 'integer', + 'active' => 'boolean', + 'birth_date' => 'date', + 'created_at' => 'datetime', + 'settings' => 'json', + 'tags' => 'array', + 'user' => 'relationship', + 'activeUsers' => 'scope', + ]; + + public function scopeMinLevel($query, $minLevel) + { + return $query->whereHas('user', function ($q) use ($minLevel) { + $q->where('level', '>=', $minLevel); + }); + } + }; + + $results = $model::filter(['minLevel' => 8])->get(); + + // Two models reference a user with level >= 8 + $this->assertCount(2, $results); + $this->assertContains('Active Admin Model', $results->pluck('name')); + $this->assertContains('Inactive Admin Model', $results->pluck('name')); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..27c5a87 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,70 @@ +setUpDatabase(); + } + + protected function getEnvironmentSetUp($app) + { + $app['config']->set('database.default', 'testing'); + $app['config']->set('database.connections.testing', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]); + } + + protected function setUpDatabase() + { + Schema::create('test_models', function (Blueprint $table) { + $table->id(); + $table->string('name')->nullable(); + $table->string('email')->nullable(); + $table->integer('age')->nullable(); + $table->decimal('price', 10, 2)->nullable(); + $table->boolean('active')->default(false); + $table->date('birth_date')->nullable(); + $table->dateTime('created_at')->nullable(); + $table->dateTime('updated_at')->nullable(); + $table->json('settings')->nullable(); + $table->json('tags')->nullable(); + $table->unsignedBigInteger('user_id')->nullable(); + }); + + Schema::create('users', function (Blueprint $table) { + $table->id(); + $table->string('name'); + $table->string('email'); + $table->integer('level')->default(1); + $table->timestamps(); + }); + + Schema::create('posts', function (Blueprint $table) { + $table->id(); + $table->string('title'); + $table->text('content'); + $table->unsignedBigInteger('user_id'); + $table->json('meta')->nullable(); + $table->timestamps(); + }); + + Schema::create('comments', function (Blueprint $table) { + $table->id(); + $table->text('content'); + $table->unsignedBigInteger('post_id'); + $table->unsignedBigInteger('user_id'); + $table->timestamps(); + }); + } +} \ No newline at end of file diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..cf7c212 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,14 @@ + Date: Mon, 11 Aug 2025 18:31:03 +0200 Subject: [PATCH 2/4] test ci --- .github/workflows/tests.yml | 34 ++++++++++++++++++++++++++++++++++ composer.json | 7 ++++++- 2 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/tests.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..5c5f33f --- /dev/null +++ b/.github/workflows/tests.yml @@ -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 + diff --git a/composer.json b/composer.json index 072183a..1f64720 100644 --- a/composer.json +++ b/composer.json @@ -15,5 +15,10 @@ "Firevel\\Filterable\\": "src/" } }, + "require-dev": { + "phpunit/phpunit": "^10.5", + "orchestra/testbench": "^8.16", + "mockery/mockery": "^1.6" + }, "minimum-stability": "dev" -} \ No newline at end of file +} From 86304bebc098cf6630799967375a47d4f18db8e6 Mon Sep 17 00:00:00 2001 From: Michael Slowik Date: Mon, 11 Aug 2025 18:38:27 +0200 Subject: [PATCH 3/4] phpunit --- .gitignore | 1 - phpunit.xml | 28 ++++++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 phpunit.xml diff --git a/.gitignore b/.gitignore index eb5d316..9825cb3 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,6 @@ build composer.lock coverage docs -phpunit.xml psalm.xml testbench.yaml vendor diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..9fcf761 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,28 @@ + + + + + tests + + + + + ./src + + + + + + + From ef3b193f8136493ba5283bd2ee8af08b41ec2f42 Mon Sep 17 00:00:00 2001 From: Michael Slowik Date: Mon, 11 Aug 2025 18:40:27 +0200 Subject: [PATCH 4/4] test auto load --- composer.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/composer.json b/composer.json index 1f64720..b243dae 100644 --- a/composer.json +++ b/composer.json @@ -15,6 +15,11 @@ "Firevel\\Filterable\\": "src/" } }, + "autoload-dev": { + "psr-4": { + "Firevel\\Filterable\\Tests\\": "tests/" + } + }, "require-dev": { "phpunit/phpunit": "^10.5", "orchestra/testbench": "^8.16",