├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── BUG_REPORT.md │ ├── FEATURE_REQUEST.md │ └── config.yml ├── PULL_REQUEST_TEMPLATE.md ├── SECURITY.md ├── SUPPORT.md ├── dependabot.yml └── workflows │ ├── dependabot-auto-merge.yml │ ├── lint.yml │ ├── tests.yml │ └── update-changelog.yml ├── .husky └── pre-commit ├── .phpvmrc ├── .vscode └── settings.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── assets ├── Banner.jpg └── Social.jpg ├── bin ├── fix.sh ├── lint.sh └── test.sh ├── composer.json ├── composer.lock ├── package-lock.json ├── package.json ├── phpstan.neon ├── pint.json └── src └── Filterable ├── Concerns ├── HandlesFilterPermissions.php ├── HandlesFilterables.php ├── HandlesPreFilters.php ├── HandlesRateLimiting.php ├── HandlesUserScope.php ├── InteractsWithCache.php ├── InteractsWithLogging.php ├── ManagesMemory.php ├── MonitorsPerformance.php ├── OptimizesQueries.php ├── SmartCaching.php ├── SupportsFilterChaining.php ├── TransformsFilterValues.php └── ValidatesFilterInput.php ├── Console ├── MakeFilterCommand.php └── stubs │ ├── filter.basic.stub │ ├── filter.model.stub │ └── filter.stub ├── Contracts ├── Filter.php └── Filterable.php ├── Filter.php ├── Providers └── FilterableServiceProvider.php └── Traits └── Filterable.php /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: thavarshan 2 | buy_me_a_coffee: thavarshan 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/BUG_REPORT.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Bug report" 3 | about: "Report something that's broken." 4 | --- 5 | 6 | 7 | 8 | 9 | - App Version: 1.1.0 10 | - PHP Version: 8.2.0 / 8.3.4 11 | - Database Driver & Version: MySQL 8.0 12 | 13 | ### Description 14 | 15 | ### Steps To Reproduce 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/FEATURE_REQUEST.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Feature request" 3 | about: "Request a new and or additional feature." 4 | --- 5 | 6 | 7 | 8 | 9 | ### Description 10 | 11 | 12 | 13 | ### Feature Details 14 | 15 | 16 | 17 | #### Additional Information 18 | 19 | 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Help & Support 4 | url: https://github.com/Thavarshan/filterable/discussions 5 | about: 'This repository is only for reporting bugs. If you have a question or need help using the library, click:' 6 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Purpose 2 | 3 | _Describe the problem or feature in addition to a link to the issues._ 4 | 5 | ## Approach 6 | 7 | _How does this change address the problem?_ 8 | 9 | #### Open Questions and Pre-Merge TODOs 10 | 11 | - [ ] Use github checklists. When solved, check the box and explain the answer. 12 | 13 | ## Learning 14 | 15 | _Describe the research stage_ 16 | 17 | -------------------------------------------------------------------------------- /.github/SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | **PLEASE DON'T DISCLOSE SECURITY-RELATED ISSUES PUBLICLY, [SEE BELOW](#reporting-a-vulnerability).** 4 | 5 | ## Reporting a Vulnerability 6 | 7 | If you discover a security vulnerability within this app, please send an email to (). All security vulnerabilities will and must be promptly addressed. 8 | 9 | ### Public PGP Key 10 | 11 | ``` 12 | -----BEGIN PGP PUBLIC KEY BLOCK----- 13 | Version: Keybase OpenPGP v1.0.0 14 | Comment: https://keybase.io/crypto 15 | 16 | xsBNBGYs38ABCADLaZL4oqilycKBaENfqeBvEnAqOhkh0G1nXm7rusqA+O8lBQBt 17 | TnaXw7Ey9FM9WPqWQ9z4kEtD5qXwjH2y8f6ysAEjziTdmkuw6gnA9hlSnx+X7glg 18 | WQNqYiehgWS4R586XYVYtl0Mkqd8IZpIKXMVw5M7oha2ytrBOliepU6ZykjpvbBS 19 | DGy+9hjtnPNW48kMXR8fTJ1i2h2Zf6nXJCmnZDRs/KxmX4ROFMbRdrTcac2esEPS 20 | JXgqhkSH7eTIFCAaEybX+a/9BreQkI5nn6/+N5fQb4bFh/gmuLs2tMmKtnVzbJw+ 21 | ZFOCpqJaEcAyQaFaanEpGSZB4DotS6N8mQxVABEBAAHNRUplcm9tZSBUaGF5YW5h 22 | bnRoYWpvdGh5IChMYXJhdmVsIEZpbHRlcmFibGUpIDx0anRoYXZhcnNoYW5AZ21h 23 | aWwuY29tPsLAbQQTAQoAFwUCZizfwAIbLwMLCQcDFQoIAh4BAheAAAoJEMsU1RA3 24 | g7Gyw8EH/jpHF418EeeikKeGo1tlEag2aKvNLGYaU3eVhsUl5zxjnM/cLkfxVvEE 25 | /9ZPYkRpoT91aC21UEf7MdgNNM5/qUawtZRkXkSlwSkrFg66YxnzkHNoJLvwcw8R 26 | RQCWOOakc5V8lZwi2fUJBK9jwH2+2X9t4jmFJQ+C80/lG6iOWxbz48lSPXN6uh42 27 | B8TL5h3Vmlw3yFhVAyGIqir5Xlm0MlgFI+yL9IMWgMYA84cbsMpGABcxmmXE4aK5 28 | fukJtQITOOhml+zcyFEaKabrEN7O6GxBOzRI7nX1Tjwk85PrIjjfWvjUi3A4CDPg 29 | oAN8x43uNsFMjzS+SKDslb3/zi/a7l7OwE0EZizfwAEIAMX63ACe1PNJKzBFOqSB 30 | xwRgI4jWhtACWX3kfAlT1vp056GQIwwDqOfUx7cThkBSi85j2/tO3tQUdHHGSmE0 31 | ISdr+C27Ps7zezwDxlnY5AP0vUGITO9tUh6sPmELfgH+zFtiMxfOnTgkED7hab7j 32 | Uuk9xbhZbBM+w1k6uSIrsSSVrFCsSnu0L9kswev1ST9bvae0Cz05h9x7MlpWpnI6 33 | QHCmJF0/P4Dlex4Kae+jkFjkS9DRy1JDNyk+l4rgL5k5zmdTAfHvnrMmQQxwb3Tp 34 | 9RWVSx1or0yyev4+kQxR1B0gfAnlV+5pgxvQV99BY89z+qvg9ympzMroJiSpq18c 35 | uhMAEQEAAcLBhAQYAQoADwUCZizfwAUJDwmcAAIbLgEpCRDLFNUQN4OxssBdIAQZ 36 | AQoABgUCZizfwAAKCRANC2xYqTEKWUcuB/9qEOPmt6o35KDWR7U+Z7JRlFw6RInF 37 | L+pBWxyROehuddhhAaBNQ5XE+D3nQaVW+KFv+nW3PzBsnvMa3FUrRffQVAozEznA 38 | UK+KBdbt+LIGlCXQqPfMLnNVWLj1klWv2Uj7nn2NpO0tBp5638KP+RTaDh12krX2 39 | zUaC15NlCt0+oaNs9eJ9yQdc6UpZUs6wVFJ3jWSbdosov2V7uXc5v7tma9mKVbuZ 40 | hIrfKQc6OMJuyH/15lNxhAmruCB1OZaUQs/dhik5N0UWNa9FboF1x9KfrDhGZm3A 41 | pzx8d+MdQI0Ck44aiar+W4iSiZ2sLPEWdQi6I0tIR8Y5CSsvZ8g3jr57MtMIAIy+ 42 | WK+MHjDi5pJRVLm6xtGYbvuEWRAS3UCuOkHdbtpevpJeaYIavxVzemZQNNVCzcL4 43 | bvt5mmEYXKRdpFyAV2RJIUt1W42mDPSCf1gOI8UhAwpbk7mkVm/MTxw2V6hYpwPm 44 | XZ3d1oHW9Kfyw9Me2ApxeftAVtABZQAwiHRVT4qbimIEUrl55r5zv5SBHvhMBK5O 45 | LR4hUlgfp6lAuCqFISwTxy7fj5GdmDLh/Jd0WzK+bTSibb75h/9Vrnu+h4cS7WXM 46 | qhhR76obVq3D12pcipVHeVnXWTq4pqBpdXt7nGBbjH0Af5gITQ9kJYVRZlfBNkQI 47 | D5A7lvlDuRms5SaY7yPOwE0EZizfwAEIAMh/U2okz9U5pxVvhI0+U0Qmd9BMQtiO 48 | tQWYiiWgIOCcJTpgGghbOsY6iOiQXA8smA9QZwGTKv0b4JWhZXBIckxYv7P/fDoX 49 | q/GibI1s5O+34RATwDeAPneHSyh3rvSutxrM9gj1X0nQnl/NzQN5GDlyPysTwLJu 50 | 69jCXuDst1jwOYHsrbaL4ME6n8CXnlu0kgdvRaSUh9pQA+MqDWqNUVTJq9M66T5H 51 | MyBlaguK1NURXOg9ar+RnEVGoa5gSInxxHzBvn7NENa0EJekJQ++X521MmHyI5Ay 52 | YD+JBLMideRj1Cyunc7KCL7hpnvRgPGQmgTpLNQrlLzHQ7K8ubF9kBkAEQEAAcLB 53 | hAQYAQoADwUCZizfwAUJDwmcAAIbLgEpCRDLFNUQN4OxssBdIAQZAQoABgUCZizf 54 | wAAKCRDO7IpZICfPAVdRB/9JITU20DQSHoXkwXEGA+/+q2Dy7sxd8SX7kOsEc7Ba 55 | h/W5XaodsM03RJYoGceUl9LizXDXKW7w/z/sJnWiZ9JnkeYKLQznZNoWUdTrii+5 56 | dywbPpocTEfnGhT/hug8rgZ34ZGh7WVt02lNdFhfjJ6NPdVpg5w3AieOIhih4cyU 57 | LGPLbuD5GggbYGWProW4Xs8feKxthwXq/PWd3B0uNEsTpsUFMvRvTXbpDCgO/hYP 58 | NZCGyFEXjfn/mN7ZPUgyZxXzMhLAIkVOBq66tAu3mGvqlpnFX5w1s6eT44lXZcUX 59 | XihSgLtkcmmAtHAFxPtba9TZ+K9jhjRBAdzmuIRApzM6secH/3634iES7nDEi0bT 60 | 9gk1oJDgvudWQfHUOO8XxWa06OW/zsqeS7KUyKF1bUc1R9VHovh7c5w/NUvuN9w7 61 | opZnV/aQ0e3BUvdIxxLRbG//8sv+wfP/YsK5+G5AUBNB5PfKfVcyrEZigx2XLyWd 62 | /DV587k8WTR8Zi4cDIeI8aUR4FckqXX3PIkaX2h3KbC2oZtIeDIi6QKnhcNDg92Z 63 | xH1G4bdzcttum/a3j5+pCElcLbTtaqM2SH7BL2ykfpj3F0u+NS/HLzMvvaSbQJwd 64 | j/xDST73sv2oSA1bXG0zZnJAG6gLSA/+wIMTkGpov5g37nFjn8yAwC5f+Dk1IQww 65 | UcKwfSc= 66 | =N/Ig 67 | -----END PGP PUBLIC KEY BLOCK----- 68 | ``` 69 | -------------------------------------------------------------------------------- /.github/SUPPORT.md: -------------------------------------------------------------------------------- 1 | # Support Questions 2 | 3 | GitHub issue trackers are not intended to provide help or support. Instead, use one of the following channels: 4 | 5 | - [Twitter](https://twitter.com/tjthavarshan) 6 | - [Github discussions](https://github.com/Thavarshan/filterable/issues) 7 | - [API reference](https://github.com/Thavarshan/filterable/wiki/API-Reference-for-the-Filter-Class) 8 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: 'github-actions' 5 | directory: '/' 6 | schedule: 7 | interval: 'weekly' 8 | -------------------------------------------------------------------------------- /.github/workflows/dependabot-auto-merge.yml: -------------------------------------------------------------------------------- 1 | name: dependabot-auto-merge 2 | on: pull_request_target 3 | 4 | permissions: 5 | pull-requests: write 6 | contents: write 7 | 8 | jobs: 9 | dependabot: 10 | runs-on: ubuntu-latest 11 | if: ${{ github.actor == 'dependabot[bot]' }} 12 | steps: 13 | - name: Dependabot metadata 14 | id: metadata 15 | uses: dependabot/fetch-metadata@v2.4.0 16 | with: 17 | github-token: '${{ secrets.GITHUB_TOKEN }}' 18 | compat-lookup: true 19 | 20 | - name: Auto-merge Dependabot PRs for semver-minor updates 21 | if: ${{steps.metadata.outputs.update-type == 'version-update:semver-minor'}} 22 | run: gh pr merge --auto --merge "$PR_URL" 23 | env: 24 | PR_URL: ${{github.event.pull_request.html_url}} 25 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 26 | 27 | - name: Auto-merge Dependabot PRs for semver-patch updates 28 | if: ${{steps.metadata.outputs.update-type == 'version-update:semver-patch'}} 29 | run: gh pr merge --auto --merge "$PR_URL" 30 | env: 31 | PR_URL: ${{github.event.pull_request.html_url}} 32 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 33 | 34 | - name: Auto-merge Dependabot PRs for Action major versions when compatibility is higher than 90% 35 | if: ${{steps.metadata.outputs.package-ecosystem == 'github_actions' && steps.metadata.outputs.update-type == 'version-update:semver-major' && steps.metadata.outputs.compatibility-score >= 90}} 36 | run: gh pr merge --auto --merge "$PR_URL" 37 | env: 38 | PR_URL: ${{github.event.pull_request.html_url}} 39 | GH_TOKEN: ${{secrets.GITHUB_TOKEN}} 40 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: [push] 4 | 5 | permissions: 6 | contents: read 7 | 8 | jobs: 9 | lint: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | fail-fast: true 13 | matrix: 14 | php: [8.4] 15 | 16 | steps: 17 | - name: Checkout code 18 | uses: actions/checkout@v4 19 | 20 | - name: Setup PHP 21 | uses: shivammathur/setup-php@v2 22 | with: 23 | php-version: ${{ matrix.php }} 24 | extensions: json, dom, curl, libxml, mbstring 25 | coverage: none 26 | 27 | - name: Run lint 28 | run: composer lint 29 | 30 | # - name: Commit linted files 31 | # uses: stefanzweifel/git-auto-commit-action@v5 32 | # with: 33 | # commit_message: "Fix coding style" 34 | 35 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | paths: 6 | - '**.php' 7 | - '.github/workflows/tests.yml' 8 | - 'phpunit.xml.dist' 9 | - 'composer.json' 10 | - 'composer.lock' 11 | 12 | jobs: 13 | test: 14 | runs-on: ${{ matrix.os }} 15 | timeout-minutes: 5 16 | strategy: 17 | fail-fast: true 18 | matrix: 19 | os: [ubuntu-latest] 20 | php: [8.3, 8.4] 21 | laravel: [11.*, 12.*] 22 | stability: [prefer-stable] 23 | include: 24 | - laravel: 11.* 25 | testbench: 9.* 26 | carbon: ^2.63 27 | - laravel: 12.* 28 | testbench: 10.* 29 | 30 | name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.stability }} - ${{ matrix.os }} 31 | 32 | services: 33 | mysql: 34 | image: mysql:8.0 35 | env: 36 | MYSQL_USER: user 37 | MYSQL_PASSWORD: secret 38 | MYSQL_DATABASE: test_filterable 39 | MYSQL_ROOT_PASSWORD: secretroot 40 | ports: 41 | - 3306 42 | options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 43 | 44 | redis: 45 | image: redis 46 | ports: 47 | - 6379:6379 48 | options: >- 49 | --health-cmd "redis-cli ping" 50 | --health-interval 10s 51 | --health-timeout 5s 52 | --health-retries 5 53 | 54 | steps: 55 | - name: Checkout code 56 | uses: actions/checkout@v4 57 | 58 | - name: Setup PHP 59 | uses: shivammathur/setup-php@v2 60 | with: 61 | php-version: ${{ matrix.php }} 62 | extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo 63 | coverage: none 64 | 65 | - name: Setup problem matchers 66 | run: | 67 | echo "::add-matcher::${{ runner.tool_cache }}/php.json" 68 | echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" 69 | 70 | - name: Install dependencies 71 | run: | 72 | composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update 73 | composer update --${{ matrix.stability }} --prefer-dist --no-interaction --with-all-dependencies 74 | 75 | - name: Execute tests 76 | run: composer test 77 | env: 78 | DB_USERNAME: user 79 | DB_PASSWORD: secret 80 | DB_PORT: ${{ job.services.mysql.ports[3306] }} 81 | REDIS_PORT: 6379 82 | -------------------------------------------------------------------------------- /.github/workflows/update-changelog.yml: -------------------------------------------------------------------------------- 1 | name: 'Update Changelog' 2 | 3 | on: 4 | release: 5 | types: [released] 6 | 7 | jobs: 8 | update: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - name: Checkout code 13 | uses: actions/checkout@v4 14 | with: 15 | ref: main 16 | token: ${{ secrets.GH_TOKEN }} 17 | 18 | - name: Update Changelog 19 | uses: stefanzweifel/changelog-updater-action@v1 20 | with: 21 | latest-version: ${{ github.event.release.name }} 22 | release-notes: ${{ github.event.release.body }} 23 | 24 | - name: Commit updated CHANGELOG 25 | uses: stefanzweifel/git-auto-commit-action@v5 26 | with: 27 | branch: main 28 | commit_message: Update CHANGELOG 29 | file_pattern: CHANGELOG.md 30 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e # this makes the script fail on first error 4 | 5 | chmod +x bin/fix.sh && ./bin/fix.sh 6 | chmod +x bin/lint.sh && ./bin/lint.sh 7 | php artisan test || true 8 | -------------------------------------------------------------------------------- /.phpvmrc: -------------------------------------------------------------------------------- 1 | 8.2 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "php.version": "8.4" 3 | } 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Release Notes 2 | 3 | ## [Unreleased](https://github.com/Thavarshan/filterable/compare/v2.0.1...HEAD) 4 | 5 | ## [v2.0.1](https://github.com/Thavarshan/filterable/compare/v2.0.0...v2.0.1) - 2025-05-14 6 | 7 | ### Added 8 | 9 | - Focuses on the new tests added to verify the fix and prevent regression 10 | - Emphasizes that this improves the test coverage for edge cases 11 | 12 | ### Changed 13 | 14 | - Highlights the improvements to error handling and resilience 15 | - Frames the changes as positive enhancements to the existing code 16 | - Uses professional, descriptive language without being too technical 17 | 18 | ### Fixed 19 | 20 | - Clearly identifies the specific issue that was fixed 21 | - References the GitHub issue number for traceability 22 | - Includes the exact error message for users who might be searching for a solution 23 | 24 | **Full Changelog**: https://github.com/Thavarshan/filterable/compare/2.0.0...2.0.1 25 | 26 | ## [v2.0.0](https://github.com/Thavarshan/filterable/compare/v1.2.0...v2.0.0) - 2025-05-13 27 | 28 | ### Added 29 | 30 | - Added support for Laravel 12.x 31 | - Added feature flag system for more granular control over filter capabilities 32 | - Added comprehensive test suite with 100+ tests for all traits 33 | - Added `HandlesUserScope` trait for easily filtering by authenticated user 34 | - Added `HandlesRateLimiting` trait to prevent abuse via complex queries 35 | - Added `ValidatesFilterInput` trait for input validation 36 | - Added `MonitorsPerformance` trait for tracking and analyzing filter performance 37 | - Added `TransformsFilterValues` trait for transforming input values 38 | - Added `SupportsFilterChaining` trait for fluent API method chaining 39 | - Added `InteractsWithLogging` trait for improved debugging and auditing 40 | - Added `OptimizesQueries` trait for better query performance 41 | - Added `ManagesMemory` trait for handling large datasets efficiently 42 | - Added support for custom pre-filters via the `HandlesPreFilters` trait 43 | - Added new artisan command (`make:filter`) with support for model-specific filters 44 | 45 | ### Changed 46 | 47 | - Major architecture overhaul with modular trait-based design 48 | - Improved SmartCaching with better cache key generation 49 | - Updated all Cache interactions to use Carbon instances for TTL values 50 | - Enhanced Filter base class with more robust constructor injection 51 | - Standardized return types across all methods for better type safety 52 | - Updated documentation with comprehensive examples for all features 53 | - Improved error handling with more descriptive exceptions 54 | - Modernized test cases to use latest PHPUnit assertions 55 | - Switched to feature-based activation rather than global static methods 56 | 57 | ### Fixed 58 | 59 | - Fixed cache key generation for array values 60 | - Fixed memory leaks when dealing with large datasets 61 | - Fixed issues with query builder method chaining 62 | - Fixed inconsistent behavior with filter application 63 | - Fixed validation errors not being properly propagated 64 | - Fixed user scoping not being applied correctly in some scenarios 65 | - Fixed rate limiting bypass techniques 66 | - Fixed performance monitoring accuracy 67 | - Fixed logging inconsistencies when features are toggled 68 | 69 | ## [v1.2.0](https://github.com/Thavarshan/filterable/compare/v1.1.7...v1.2.0) - 2025-02-25 70 | 71 | ### Added 72 | 73 | - Support for Laravel `^12` 74 | 75 | ## [v1.1.7](https://github.com/Thavarshan/filterable/compare/v1.1.6...v1.1.7) - 2025-02-23 76 | 77 | ### Added 78 | 79 | - Introduced the `FilterableServiceProvider` to register the `MakeFilterCommand`. 80 | - Added a new `Filterable` interface to define the contract for the Filterable trait. 81 | - Added a new `Filter` interface to define the contract for the Filter class. 82 | 83 | ### Changed 84 | 85 | - Added type hints for method parameters and return types to improve code clarity and type safety. 86 | - Improved the `Filterable` trait to ensure compatibility with PHP 8.4. 87 | - Enhanced the `Filter` class with better type hinting and method documentation. 88 | - Updated the `FilterableTest` to include the necessary setup for bootstrapping the Laravel application. 89 | 90 | ### Fixed 91 | 92 | - Fixed an issue where the `config` class was not available during tests by bootstrapping the Laravel application in the test setup. 93 | - Corrected the test case to ensure the `apply` method is called correctly in the `filter_throws_exception_when_filter_application_fails` test. 94 | 95 | ## [v1.1.6](https://github.com/Thavarshan/filterable/compare/v1.1.5...v1.1.6) - 2024-09-25 96 | 97 | ### Changed 98 | 99 | - Extend compatibility to PHP 8.3 100 | 101 | ### Fixed 102 | 103 | - Laravel 9 compatibility issues 104 | 105 | ## [v1.1.5](https://github.com/Thavarshan/filterable/compare/v1.1.4...v1.1.5) - 2024-09-24 106 | 107 | - Minor dependency updates for security 108 | 109 | ## [v1.1.4](https://github.com/Thavarshan/filterable/compare/v1.1.3...v1.1.4) - 2024-08-25 110 | 111 | ### Changed 112 | 113 | - Minor dependency updates for security 114 | 115 | ## [v1.1.3](https://github.com/Thavarshan/filterable/compare/v1.1.2...v1.1.3) - 2024-07-14 116 | 117 | ### Changed 118 | 119 | - Updated dependencies 120 | 121 | ## [v1.1.2](https://github.com/Thavarshan/filterable/compare/v1.1.1...v1.1.2) - 2024-05-16 122 | 123 | ### Changed 124 | 125 | - Modified the buildCacheKey method to sort and normalise `filterables` before generating the cache key. This change reduces the number of unique keys and helps mitigate cache pollution issues. (See PR [#18](https://github.com/Thavarshan/filterable/pull/18)) 126 | Caching has now been changed to be disabled by default. This change provides more control over when caching is used, helping to prevent unnecessary cache pollution. 127 | 128 | ### Fixed 129 | 130 | - Fixed cache pollution issues caused by the generation of too many unique keys. This was achieved by limiting the number of unique filter combinations that can be cached. (See issue [#17](https://github.com/Thavarshan/filterable/issues/17) and PR [#18](https://github.com/Thavarshan/filterable/pull/18)) 131 | 132 | ## [v1.1.1](https://github.com/Thavarshan/filterable/compare/v1.1.0...v1.1.1) - 2024-05-01 133 | 134 | ### Added 135 | 136 | - **Compatibility support for newer PHP versions:** Updated `brick/math` requirement from PHP `^8.0` to `^8.1` to embrace the latest PHP features and improvements. 137 | 138 | ### Changed 139 | 140 | - **Updated `brick/math` from `0.11.0` to `0.12.1`:** Includes performance optimizations and bug fixes to enhance mathematical operations. 141 | - **Updated `laravel/framework` from `v10.48.5` to `v10.48.10`:** Rolled in new minor features and improvements to the Laravel framework that benefit the stability and security of applications using `filterable`. 142 | - **Updated `symfony/console` from `v6.4.6` to `v6.4.7`:** Enhanced compatibility with other Symfony components, improving integration and usage within Symfony-based projects. 143 | - **Updated development dependencies:** 144 | - `phpunit/phpunit` from `^9.0` to `^10.1` for advanced unit testing capabilities. 145 | - `vimeo/psalm` from `5.0.0` to `5.16.0` for improved static analysis and code quality checks. 146 | 147 | 148 | ### Fixed 149 | 150 | - **Security patches and minor bugs:** All updated dependencies include patches for known vulnerabilities and fixes for various minor bugs, enhancing the security and reliability of the `filterable` package. 151 | 152 | ## [v1.1.0](https://github.com/Thavarshan/filterable/compare/1.0.6...v1.1.0) - 2024-04-23 153 | 154 | ### Added 155 | 156 | - **Logging Support in Filter Class**: Introduced comprehensive logging capabilities to enhance debugging and operational monitoring within the `Filter` class. This update allows developers to trace the application of filters more effectively and can be critical for both development and production debugging scenarios. [#12](https://github.com/Thavarshan/filterable/pull/12) 157 | - **Dynamic Logging Controls**: Added methods `enableLogging()` and `disableLogging()` to toggle logging functionality at runtime, allowing better control over performance and log verbosity depending on the environment. 158 | - **Integration with `Psr\Log\LoggerInterface`**: Ensured flexibility in logging implementations by integrating with the standard PSR-3 logger interface. Developers can now inject any compatible logging library that adheres to this standard, facilitating customized logging strategies. 159 | - **Conditional Log Statements**: Added conditional logging throughout the filter application process to provide granular insights into key actions and decisions. This feature is designed to help in pinpointing issues and understanding filter behavior under various conditions. 160 | - **Unit Tests for Logging**: Extended the test suite to include tests verifying that logging behaves as expected under different configurations, ensuring that the new functionality is robust and reliable. 161 | 162 | 163 | ### Changed 164 | 165 | - Deprecated instance method `setUseCache()` in favor of static method `enableCaching()` for improved consistency and clarity. This change aligns with the existing static property `useCache` and enhances the discoverability of caching-related functionality. [#12](https://github.com/Thavarshan/filterable/pull/12) 166 | 167 | ### Fixed 168 | 169 | - Minor bug fixes and performance optimizations to enhance stability and efficiency. 170 | 171 | ## [1.0.6](https://github.com/Thavarshan/filterable/compare/v1.0.5...1.0.6) - 2024-04-14 172 | 173 | ### Changed 174 | 175 | - Refactor `useCache` instance property to static 176 | - Refactor `setUseCache` instance method to `enableCaching` static method for use in service provider classes 177 | 178 | ## [v1.0.5](https://github.com/Thavarshan/filterable/compare/v1.0.4...v1.0.5) - 2024-04-10 179 | 180 | ### Changed 181 | 182 | - Implement filter scope for Eloquent Models to use with `Filterable` trait 183 | 184 | ## [v1.0.4](https://github.com/Thavarshan/filterable/compare/v1.0.3...v1.0.4) - 2024-04-10 185 | 186 | ### Fixed 187 | 188 | - Fix "Fatal Error: Type of `App\Filters\EventFilter::$filters` Must Be Array" (#8) 189 | 190 | ## [v1.0.3](https://github.com/Thavarshan/filterable/compare/v1.0.2...v1.0.3) - 2024-04-10 191 | 192 | ### Fixed 193 | 194 | - Fix for Argument Acceptance in `make:filter` Command [#6](https://github.com/Thavarshan/filterable/issues/6) 195 | 196 | ## [v1.0.2](https://github.com/Thavarshan/filterable/compare/v1.0.1...v1.0.2) - 2024-04-10 197 | 198 | ### Fixed 199 | 200 | - Fix Service Provider Namespace in `composer.json` [#5](https://github.com/Thavarshan/filterable/issues/5) 201 | 202 | ## [v1.0.1](https://github.com/Thavarshan/filterable/compare/v1.0.1...v1.0.0) - 2024-04-10 203 | 204 | ### Fixed 205 | 206 | - Fix `nesbot/carbon` dependency version issue [#3](https://github.com/Thavarshan/filterable/issues/3) 207 | 208 | ## v1.0.0 - 2024-04-10 209 | 210 | Initial release. 211 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Jerome Thayananthajothy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Filterable](./assets/Banner.jpg)](https://github.com/Thavarshan/filterable) 2 | 3 | # About Filterable 4 | 5 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/jerome/filterable.svg)](https://packagist.org/packages/jerome/filterable) 6 | [![Tests](https://github.com/Thavarshan/filterable/actions/workflows/tests.yml/badge.svg?label=tests&branch=main)](https://github.com/Thavarshan/filterable/actions/workflows/tests.yml) 7 | [![Lint](https://github.com/Thavarshan/filterable/actions/workflows/lint.yml/badge.svg)](https://github.com/Thavarshan/filterable/actions/workflows/lint.yml) 8 | [![CodeQL](https://github.com/Thavarshan/filterable/actions/workflows/github-code-scanning/codeql/badge.svg)](https://github.com/Thavarshan/filterable/actions/workflows/github-code-scanning/codeql) 9 | [![PHPStan](https://img.shields.io/badge/PHPStan-level%20max-brightgreen.svg)](https://phpstan.org/) 10 | [![PHP Version](https://img.shields.io/packagist/php-v/jerome/filterable.svg)](https://packagist.org/packages/jerome/filterable) 11 | [![License](https://img.shields.io/packagist/l/jerome/filterable.svg)](https://packagist.org/packages/jerome/filterable) 12 | [![Total Downloads](https://img.shields.io/packagist/dt/jerome/filterable.svg)](https://packagist.org/packages/jerome/filterable) 13 | [![GitHub Stars](https://img.shields.io/github/stars/Thavarshan/filterable.svg?style=social&label=Stars)](https://github.com/Thavarshan/filterable/stargazers) 14 | 15 | The `Filterable` package provides a robust, feature-rich solution for applying dynamic filters to Laravel's Eloquent queries. With a modular, trait-based architecture, it supports advanced features like intelligent caching, user-specific filtering, performance monitoring, memory management, and much more. It's suitable for applications of any scale, from simple blogs to complex enterprise-level data platforms. 16 | 17 | ## Requirements 18 | 19 | - PHP 8.2+ 20 | - Laravel 10.x, 11.x, or 12.x 21 | 22 | ## Features 23 | 24 | - **Dynamic Filtering**: Apply filters based on request parameters with ease 25 | - **Modular Architecture**: Customize your filter implementation using traits 26 | - **Smart Caching**: Both simple and intelligent caching strategies with automatic cache key generation 27 | - **User-Specific Filtering**: Easily implement user-scoped filters 28 | - **Rate Limiting**: Control filter complexity and prevent abuse 29 | - **Validation**: Validate filter inputs before processing 30 | - **Permission Control**: Apply permission-based access to specific filters 31 | - **Performance Monitoring**: Track execution time and query performance 32 | - **Memory Management**: Optimize memory usage for large datasets with lazy loading and chunking 33 | - **Query Optimization**: Intelligent query building with column selection and relationship loading 34 | - **Logging**: Comprehensive logging capabilities for debugging and monitoring 35 | - **Filter Chaining**: Chain multiple filter operations with a fluent API 36 | - **Value Transformation**: Transform input values before applying filters 37 | - **Custom Pre-Filters**: Register filters to run before the main filters 38 | - **Comprehensive Debugging**: Detailed debug information about applied filters and query execution 39 | - **Conditional Execution**: Use Laravel's conditionable trait for conditional filter application 40 | - **Smart Error Handling**: Graceful handling of filtering exceptions 41 | - **Flexible State Management**: Monitor and manage the filter execution state 42 | - **Chainable Configuration**: Fluent API for configuration with method chaining 43 | 44 | ## Installation 45 | 46 | To integrate the `Filterable` package into your Laravel project, install it via Composer: 47 | 48 | ```bash 49 | composer require jerome/filterable 50 | ``` 51 | 52 | The package automatically registers its service provider with Laravel's service container through auto-discovery (Laravel 5.5+). 53 | 54 | For older Laravel versions, manually register the `FilterableServiceProvider` in your `config/app.php` file: 55 | 56 | ```php 57 | 'providers' => [ 58 | // Other service providers... 59 | Filterable\Providers\FilterableServiceProvider::class, 60 | ], 61 | ``` 62 | 63 | ## Usage 64 | 65 | ### Creating a Filter Class 66 | 67 | Create a new filter class using the Artisan command: 68 | 69 | ```bash 70 | php artisan make:filter PostFilter 71 | ``` 72 | 73 | This command supports several options: 74 | 75 | | Option | Shortcut | Description | 76 | |--------|----------|-------------| 77 | | `--basic` | `-b` | Creates a basic filter class with minimal functionality | 78 | | `--model=ModelName` | `-m ModelName` | Generates a filter for the specified model | 79 | | `--force` | `-f` | Creates the class even if the filter already exists | 80 | 81 | Examples: 82 | 83 | ```bash 84 | # Create a basic filter 85 | php artisan make:filter PostFilter --basic 86 | 87 | # Create a filter for a specific model 88 | php artisan make:filter PostFilter --model=Post 89 | 90 | # Force creation of a filter 91 | php artisan make:filter PostFilter --force 92 | 93 | # Combine options 94 | php artisan make:filter PostFilter --model=Post --force 95 | ``` 96 | 97 | The command generates a filter class in the `app/Filters` directory. Extend the base `Filter` class to implement your specific filtering logic: 98 | 99 | ```php 100 | namespace App\Filters; 101 | 102 | use Filterable\Filter; 103 | use Illuminate\Database\Eloquent\Builder; 104 | use Illuminate\Http\Request; 105 | use Illuminate\Contracts\Cache\Repository as Cache; 106 | use Psr\Log\LoggerInterface; 107 | 108 | class PostFilter extends Filter 109 | { 110 | protected array $filters = ['status', 'category']; 111 | 112 | /** 113 | * Enable specific features for this filter. 114 | */ 115 | public function __construct(Request $request, ?Cache $cache = null, ?LoggerInterface $logger = null) 116 | { 117 | parent::__construct($request, $cache, $logger); 118 | 119 | // Enable the features you need 120 | $this->enableFeatures([ 121 | 'validation', 122 | 'caching', 123 | 'logging', 124 | 'performance', 125 | ]); 126 | } 127 | 128 | protected function status(string $value): Builder 129 | { 130 | return $this->builder->where('status', $value); 131 | } 132 | 133 | protected function category(int $value): Builder 134 | { 135 | return $this->builder->where('category_id', $value); 136 | } 137 | } 138 | ``` 139 | 140 | #### Adding Custom Filters 141 | 142 | To add a new filter, define a method within your filter class using **camelCase** naming, and register it in the `$filters` array: 143 | 144 | ```php 145 | protected array $filters = ['last_published_at']; 146 | 147 | protected function lastPublishedAt(string $value): Builder 148 | { 149 | return $this->builder->where('last_published_at', $value); 150 | } 151 | ``` 152 | 153 | ### Implementing the `Filterable` Trait and Interface 154 | 155 | Apply the `Filterable` interface and trait to your Eloquent models: 156 | 157 | ```php 158 | namespace App\Models; 159 | 160 | use Filterable\Interfaces\Filterable as FilterableInterface; 161 | use Filterable\Traits\Filterable as FilterableTrait; 162 | use Illuminate\Database\Eloquent\Model; 163 | 164 | class Post extends Model implements FilterableInterface 165 | { 166 | use FilterableTrait; 167 | } 168 | ``` 169 | 170 | ### Applying Filters 171 | 172 | Basic usage: 173 | 174 | ```php 175 | use App\Models\Post; 176 | use App\Filters\PostFilter; 177 | 178 | $filter = new PostFilter(request(), cache(), logger()); 179 | $posts = Post::filter($filter)->get(); 180 | ``` 181 | 182 | In a controller: 183 | 184 | ```php 185 | use App\Models\Post; 186 | use App\Filters\PostFilter; 187 | use Illuminate\Http\Request; 188 | 189 | class PostController extends Controller 190 | { 191 | public function index(Request $request, PostFilter $filter) 192 | { 193 | $query = Post::filter($filter); 194 | 195 | $posts = $request->has('paginate') 196 | ? $query->paginate($request->query('per_page', 20)) 197 | : $query->get(); 198 | 199 | return response()->json($posts); 200 | } 201 | } 202 | ``` 203 | 204 | ### Laravel 12 Support 205 | 206 | For Laravel 12, which has moved to a more minimal initial setup, make sure to follow these additional steps: 207 | 208 | 1. **Service Registration**: If you're using a minimal Laravel 12 application, you may need to manually register the service provider in your `bootstrap/providers.php` file: 209 | 210 | ```php 211 | return [ 212 | // Other service providers... 213 | Filterable\Providers\FilterableServiceProvider::class, 214 | ]; 215 | ``` 216 | 217 | 2. **Invokable Controllers**: If you're using Laravel 12's invokable controllers, here's how to apply filters: 218 | 219 | ```php 220 | has('paginate') 235 | ? $query->paginate($request->query('per_page', 20)) 236 | : $query->get(); 237 | 238 | return response()->json($posts); 239 | } 240 | } 241 | ``` 242 | 243 | 3. **Route Registration**: Using the new routing style in Laravel 12: 244 | 245 | ```php 246 | use App\Http\Controllers\PostIndexController; 247 | 248 | Route::get('/posts', PostIndexController::class); 249 | ``` 250 | 251 | ### Advanced Features 252 | 253 | #### Feature Management 254 | 255 | Selectively enable features for your filter: 256 | 257 | ```php 258 | // Enable individual features 259 | $filter->enableFeature('validation'); 260 | $filter->enableFeature('caching'); 261 | 262 | // Enable multiple features at once 263 | $filter->enableFeatures([ 264 | 'validation', 265 | 'caching', 266 | 'logging', 267 | 'performance', 268 | ]); 269 | 270 | // Disable a feature 271 | $filter->disableFeature('caching'); 272 | 273 | // Check if a feature is enabled 274 | if ($filter->hasFeature('caching')) { 275 | // Do something 276 | } 277 | ``` 278 | 279 | ##### Available Features 280 | 281 | The Filterable package supports the following features that can be enabled or disabled: 282 | 283 | | Feature | Description | 284 | |---------|-------------| 285 | | `validation` | Validates filter inputs before applying them | 286 | | `permissions` | Enables permission-based access to filters | 287 | | `rateLimit` | Controls filter complexity and prevents abuse | 288 | | `caching` | Caches query results for improved performance | 289 | | `logging` | Provides comprehensive logging capabilities | 290 | | `performance` | Monitors execution time and query performance | 291 | | `optimization` | Optimizes queries with selective columns and eager loading | 292 | | `memoryManagement` | Optimizes memory usage for large datasets | 293 | | `filterChaining` | Enables fluent chaining of multiple filter operations | 294 | | `valueTransformation` | Transforms input values before applying filters | 295 | 296 | Each feature can be enabled independently based on your specific needs: 297 | 298 | ```php 299 | // Enable all features 300 | $filter->enableFeatures([ 301 | 'validation', 302 | 'permissions', 303 | 'rateLimit', 304 | 'caching', 305 | 'logging', 306 | 'performance', 307 | 'optimization', 308 | 'memoryManagement', 309 | 'filterChaining', 310 | 'valueTransformation', 311 | ]); 312 | ``` 313 | 314 | #### User-Scoped Filtering 315 | 316 | Apply filters that are specific to the authenticated user: 317 | 318 | ```php 319 | $filter->forUser($request->user()); 320 | ``` 321 | 322 | #### Pre-Filters 323 | 324 | Apply pre-filters that run before the main filters: 325 | 326 | ```php 327 | $filter->registerPreFilters(function (Builder $query) { 328 | return $query->where('published', true); 329 | }); 330 | ``` 331 | 332 | #### Validation 333 | 334 | Set validation rules for your filter inputs: 335 | 336 | ```php 337 | $filter->setValidationRules([ 338 | 'status' => 'required|in:active,inactive', 339 | 'category_id' => 'sometimes|integer|exists:categories,id', 340 | ]); 341 | 342 | // Add custom validation messages 343 | $filter->setValidationMessages([ 344 | 'status.in' => 'Status must be either active or inactive', 345 | ]); 346 | ``` 347 | 348 | #### Permission Control 349 | 350 | Define permission requirements for specific filters: 351 | 352 | ```php 353 | $filter->setFilterPermissions([ 354 | 'admin_only_filter' => 'admin', 355 | 'editor_filter' => ['editor', 'admin'], 356 | ]); 357 | 358 | // Implement the permission check in your filter class 359 | protected function userHasPermission(string|array $permission): bool 360 | { 361 | if (is_array($permission)) { 362 | return collect($permission)->contains(fn ($role) => $this->forUser->hasRole($role)); 363 | } 364 | 365 | return $this->forUser->hasRole($permission); 366 | } 367 | ``` 368 | 369 | #### Rate Limiting 370 | 371 | Control the complexity of filter requests: 372 | 373 | ```php 374 | // Set the maximum number of filters that can be applied at once 375 | $filter->setMaxFilters(10); 376 | 377 | // Set the maximum complexity score for all filters combined 378 | $filter->setMaxComplexity(100); 379 | 380 | // Define complexity scores for specific filters 381 | $filter->setFilterComplexity([ 382 | 'complex_filter' => 10, 383 | 'simple_filter' => 1, 384 | ]); 385 | ``` 386 | 387 | #### Memory Management 388 | 389 | Optimize memory usage for large datasets: 390 | 391 | ```php 392 | // Process a query with lazy loading 393 | $posts = $filter->lazy()->each(function ($post) { 394 | // Process each post with minimal memory usage 395 | }); 396 | 397 | // Use chunking for large datasets 398 | $filter->chunk(1000, function ($posts) { 399 | // Process posts in chunks of 1000 400 | }); 401 | 402 | // Map over query results without loading all records 403 | $result = $filter->map(function ($post) { 404 | return $post->title; 405 | }); 406 | 407 | // Filter results without loading all records 408 | $result = $filter->filter(function ($post) { 409 | return $post->status === 'active'; 410 | }); 411 | 412 | // Reduce results without loading all records 413 | $total = $filter->reduce(function ($carry, $post) { 414 | return $carry + $post->views; 415 | }, 0); 416 | 417 | // Get a lazy collection with custom chunk size 418 | $lazyCollection = $filter->lazy(500); 419 | 420 | // Process each item with minimal memory usage 421 | $filter->lazyEach(function ($item) { 422 | // Process item 423 | }, 500); 424 | 425 | // Create a generator to iterate with minimal memory 426 | foreach ($filter->cursor() as $item) { 427 | // Process item 428 | } 429 | ``` 430 | 431 | #### Query Optimization 432 | 433 | Optimize database queries: 434 | 435 | ```php 436 | // Select only needed columns 437 | $filter->select(['id', 'title', 'status']); 438 | 439 | // Eager load relationships 440 | $filter->with(['author', 'comments']); 441 | 442 | // Set chunk size for large datasets 443 | $filter->chunkSize(1000); 444 | 445 | // Use a database index hint 446 | $filter->useIndex('idx_posts_status'); 447 | ``` 448 | 449 | #### Caching 450 | 451 | Configure caching behavior: 452 | 453 | ```php 454 | // Set cache expiration time (in minutes) 455 | $filter->setCacheExpiration(60); 456 | 457 | // Manually clear the cache 458 | $filter->clearCache(); 459 | 460 | // Use tagged cache for better invalidation 461 | $filter->cacheTags(['posts', 'api']); 462 | 463 | // Enable specific caching modes 464 | $filter->cacheResults(true); 465 | $filter->cacheCount(true); 466 | 467 | // Get the number of items with caching 468 | $count = $filter->count(); 469 | 470 | // Clear related caches when models change 471 | $filter->clearRelatedCaches(Post::class); 472 | 473 | // Get SQL query without executing it 474 | $sql = $filter->toSql(); 475 | ``` 476 | 477 | #### Logging 478 | 479 | Configure and use logging: 480 | 481 | ```php 482 | // Set a custom logger 483 | $filter->setLogger($customLogger); 484 | 485 | // Get the current logger 486 | $logger = $filter->getLogger(); 487 | 488 | // Log at different levels 489 | $filter->logInfo("Applying filter", ['filter' => 'status']); 490 | $filter->logDebug("Filter details", ['value' => $value]); 491 | $filter->logWarning("Potential issue", ['problem' => 'description']); 492 | 493 | // Logging is automatically handled if enabled 494 | // You can also add custom logging in your filter methods: 495 | protected function customFilter($value): Builder 496 | { 497 | $this->logInfo("Applying custom filter with value: {$value}"); 498 | 499 | return $this->builder->where('custom_field', $value); 500 | } 501 | ``` 502 | 503 | #### Performance Monitoring 504 | 505 | Track and analyze filter performance: 506 | 507 | ```php 508 | // Get performance metrics after applying filters 509 | $metrics = $filter->getMetrics(); 510 | 511 | // Add custom metrics 512 | $filter->addMetric('custom_metric', $value); 513 | 514 | // Get execution time 515 | $executionTime = $filter->getExecutionTime(); 516 | ``` 517 | 518 | #### Filter Chaining 519 | 520 | Chain multiple filter operations with a fluent API: 521 | 522 | ```php 523 | $filter->where('status', 'active') 524 | ->whereIn('category_id', [1, 2, 3]) 525 | ->whereNotIn('tag_id', [4, 5]) 526 | ->whereBetween('created_at', [$startDate, $endDate]) 527 | ->orderBy('created_at', 'desc'); 528 | ``` 529 | 530 | #### Value Transformation 531 | 532 | Transform filter values before applying them: 533 | 534 | ```php 535 | // Register a transformer for a filter 536 | $filter->registerTransformer('date', function ($value) { 537 | return Carbon::parse($value)->toDateTimeString(); 538 | }); 539 | 540 | // Register a transformer for an array of values 541 | $arrayTransformer = function($values) { 542 | return array_map(fn($value) => strtolower($value), $values); 543 | }; 544 | $filter->registerTransformer('tags', $arrayTransformer); 545 | ``` 546 | 547 | #### Conditional Execution 548 | 549 | Use Laravel's conditionable trait for conditional filter application: 550 | 551 | ```php 552 | // Only apply a filter if a condition is met 553 | $filter->when($request->has('status'), function ($filter) use ($request) { 554 | $filter->where('status', $request->status); 555 | }); 556 | 557 | // Apply one filter or another based on a condition 558 | $filter->when($request->has('sort'), 559 | function ($filter) use ($request) { 560 | $filter->orderBy($request->sort); 561 | }, 562 | function ($filter) { 563 | $filter->orderBy('created_at', 'desc'); 564 | } 565 | ); 566 | ``` 567 | 568 | #### State Management 569 | 570 | Monitor and manage the filter execution state: 571 | 572 | ```php 573 | // Check the current state 574 | if ($filter->getDebugInfo()['state'] === 'applied') { 575 | // Process results 576 | } 577 | 578 | // Reset the filter to its initial state 579 | $filter->reset(); 580 | ``` 581 | 582 | #### Debug Information 583 | 584 | Get detailed information about the applied filters: 585 | 586 | ```php 587 | $debugInfo = $filter->getDebugInfo(); 588 | 589 | // Debug info includes: 590 | // - Current state 591 | // - Applied filters 592 | // - Enabled features 593 | // - Query options 594 | // - SQL query and bindings 595 | // - Performance metrics (if enabled) 596 | ``` 597 | 598 | #### Error Handling 599 | 600 | Customize exception handling for your filters: 601 | 602 | ```php 603 | class MyFilter extends Filter 604 | { 605 | protected function handleFilteringException(Throwable $exception): void 606 | { 607 | // Log the exception 608 | $this->logWarning('Filter exception', [ 609 | 'message' => $exception->getMessage(), 610 | 'trace' => $exception->getTraceAsString(), 611 | ]); 612 | 613 | // Optionally rethrow specific exceptions 614 | if ($exception instanceof MyCustomException) { 615 | throw $exception; 616 | } 617 | 618 | // Otherwise, let the parent handle it 619 | parent::handleFilteringException($exception); 620 | } 621 | } 622 | ``` 623 | 624 | ### Complete Example 625 | 626 | ```php 627 | use App\Models\Post; 628 | use App\Filters\PostFilter; 629 | use Illuminate\Http\Request; 630 | 631 | class PostController extends Controller 632 | { 633 | public function index(Request $request, PostFilter $filter) 634 | { 635 | // Enable features 636 | $filter->enableFeatures([ 637 | 'validation', 638 | 'caching', 639 | 'logging', 640 | 'performance', 641 | ]); 642 | 643 | // Set validation rules 644 | $filter->setValidationRules([ 645 | 'status' => 'sometimes|in:active,inactive', 646 | 'category_id' => 'sometimes|integer|exists:categories,id', 647 | ]); 648 | 649 | // Apply user scope 650 | $filter->forUser($request->user()); 651 | 652 | // Apply pre-filters 653 | $filter->registerPreFilters(function ($query) { 654 | return $query->where('published', true); 655 | }); 656 | 657 | // Set caching options 658 | $filter->setCacheExpiration(30); 659 | $filter->cacheTags(['posts', 'api']); 660 | 661 | // Apply custom filter chain 662 | $filter->where('is_featured', true) 663 | ->orderBy('created_at', 'desc'); 664 | 665 | // Apply filters to the query 666 | $query = Post::filter($filter); 667 | 668 | // Get paginated results 669 | $posts = $request->has('paginate') 670 | ? $query->paginate($request->query('per_page', 20)) 671 | : $query->get(); 672 | 673 | // Get performance metrics if needed 674 | $metrics = null; 675 | if ($filter->hasFeature('performance')) { 676 | $metrics = $filter->getMetrics(); 677 | } 678 | 679 | return response()->json([ 680 | 'data' => $posts, 681 | 'metrics' => $metrics, 682 | ]); 683 | } 684 | } 685 | ``` 686 | 687 | ## Frontend Usage 688 | 689 | Send filter parameters as query parameters: 690 | 691 | ```typescript 692 | // Filter posts by status 693 | const response = await fetch('/posts?status=active'); 694 | 695 | // Combine multiple filters 696 | const response = await fetch('/posts?status=active&category_id=2&is_featured=1'); 697 | ``` 698 | 699 | ## Testing 700 | 701 | Testing your filters using PHPUnit: 702 | 703 | ```php 704 | namespace Tests\Unit; 705 | 706 | use Tests\TestCase; 707 | use App\Models\Post; 708 | use App\Filters\PostFilter; 709 | use Illuminate\Foundation\Testing\RefreshDatabase; 710 | use Illuminate\Http\Request; 711 | 712 | class PostFilterTest extends TestCase 713 | { 714 | use RefreshDatabase; 715 | 716 | public function testFiltersPostsByStatus(): void 717 | { 718 | $activePost = Post::factory()->create(['status' => 'active']); 719 | $inactivePost = Post::factory()->create(['status' => 'inactive']); 720 | 721 | $filter = new PostFilter(new Request(['status' => 'active'])); 722 | $filteredPosts = Post::filter($filter)->get(); 723 | 724 | $this->assertTrue($filteredPosts->contains($activePost)); 725 | $this->assertFalse($filteredPosts->contains($inactivePost)); 726 | } 727 | 728 | public function testRateLimitingRejectsComplexQueries(): void 729 | { 730 | // Create a filter with too many parameters 731 | $filter = new PostFilter(new Request([ 732 | 'param1' => 'value1', 733 | 'param2' => 'value2', 734 | // ... many more parameters 735 | ])); 736 | 737 | $filter->enableFeature('rateLimit'); 738 | $filter->setMaxFilters(5); 739 | 740 | // Apply the filter and check if rate limiting was triggered 741 | $result = Post::filter($filter)->get(); 742 | 743 | // Assert that no results were returned due to rate limiting 744 | $this->assertEmpty($result); 745 | } 746 | } 747 | ``` 748 | 749 | ## License 750 | 751 | This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details. 752 | 753 | ## Contributing 754 | 755 | Contributions are welcome and greatly appreciated! If you have suggestions to make this package better, please fork the repository and create a pull request, or open an issue with the tag "enhancement". 756 | 757 | 1. Fork the Project 758 | 2. Create your Feature Branch (`git checkout -b feature/amazing-feature`) 759 | 3. Commit your Changes (`git commit -m 'Add some amazing-feature'`) 760 | 4. Push to the Branch (`git push origin feature/amazing-feature`) 761 | 5. Open a Pull Request 762 | 763 | ## Authors 764 | 765 | - **[Jerome Thayananthajothy]** - *Initial work* - [Thavarshan](https://github.com/Thavarshan) 766 | 767 | See also the list of [contributors](https://github.com/Thavarshan/filterable/contributors) who participated in this project. 768 | 769 | ## Acknowledgments 770 | 771 | - Hat tip to Spatie for their [query builder](https://github.com/spatie/laravel-query-builder) package, which inspired this project. 772 | -------------------------------------------------------------------------------- /assets/Banner.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Thavarshan/filterable/35d4a35c8898ea19a17074ab4d92c438952c40b7/assets/Banner.jpg -------------------------------------------------------------------------------- /assets/Social.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Thavarshan/filterable/35d4a35c8898ea19a17074ab4d92c438952c40b7/assets/Social.jpg -------------------------------------------------------------------------------- /bin/fix.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Exit on error and unbound variables 4 | set -eu 5 | 6 | # Function to check if a composer package is installed 7 | is_composer_package_installed() { 8 | composer show "$1" >/dev/null 2>&1 9 | return $? 10 | } 11 | 12 | # Constants 13 | DUSTER_PACKAGE="tightenco/duster" 14 | DUSTER_PATH="vendor/bin/duster" 15 | SRC_DIR="./src" 16 | 17 | # Check if Duster is installed 18 | if ! is_composer_package_installed "$DUSTER_PACKAGE"; then 19 | echo "Installing $DUSTER_PACKAGE..." 20 | composer require --dev "$DUSTER_PACKAGE" 21 | fi 22 | 23 | # Create a timestamp for logs 24 | TIMESTAMP=$(date +"%Y-%m-%d_%H-%M-%S") 25 | LOG_FILE="duster_run_${TIMESTAMP}.log" 26 | 27 | # Check if directories exist before proceeding 28 | if [ ! -d "$SRC_DIR" ]; then 29 | echo "Error: Source directory $SRC_DIR not found!" 30 | exit 1 31 | fi 32 | 33 | # Run the Duster analysis 34 | echo "Running Duster on $SRC_DIR..." 35 | $DUSTER_PATH fix "$SRC_DIR" | tee -a "$LOG_FILE" 36 | 37 | echo "Code formatting completed. Log saved to $LOG_FILE" 38 | 39 | # Create a summary of changes 40 | echo "Summary of changes:" | tee -a "$LOG_FILE" 41 | grep -E "Linting|Fixed|Failed" "$LOG_FILE" | sort | uniq -c | tee -a "$LOG_FILE" 42 | 43 | exit 0 44 | -------------------------------------------------------------------------------- /bin/lint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Exit when undeclared variables are used 4 | set -u 5 | 6 | # Make pipe commands return the exit status of the last command that fails 7 | set -o pipefail 8 | 9 | SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 10 | PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" 11 | cd "$PROJECT_ROOT" 12 | 13 | # Config 14 | DUSTER_PACKAGE="tightenco/duster" 15 | DUSTER_PATH="vendor/bin/duster" 16 | DIRECTORIES_TO_ANALYSE="src" 17 | FIX_MODE=0 18 | AUTO_INSTALL=1 19 | LINT_ONLY=0 20 | STRICT_MODE=0 21 | EXTRA_DIRS="" 22 | VERBOSE=0 23 | 24 | show_usage() { 25 | echo "Usage: $0 [options]" 26 | echo "" 27 | echo "Options:" 28 | echo " -d, --directories DIRS Comma-separated directories (default: app)" 29 | echo " -f, --fix Fix mode" 30 | echo " -l, --lint-only Skip Duster" 31 | echo " -n, --no-install Don't auto-install Duster" 32 | echo " -s, --strict Exit non-zero if any issues found" 33 | echo " -v, --verbose Verbose output" 34 | echo " -h, --help Show this help" 35 | } 36 | 37 | while [[ $# -gt 0 ]]; do 38 | case $1 in 39 | -d | --directories) 40 | DIRECTORIES_TO_ANALYSE="$2" 41 | shift 2 42 | ;; 43 | --directories=*) 44 | DIRECTORIES_TO_ANALYSE="${1#*=}" 45 | shift 46 | ;; 47 | -f | --fix) 48 | FIX_MODE=1 49 | shift 50 | ;; 51 | -l | --lint-only) 52 | LINT_ONLY=1 53 | shift 54 | ;; 55 | -n | --no-install) 56 | AUTO_INSTALL=0 57 | shift 58 | ;; 59 | -s | --strict) 60 | STRICT_MODE=1 61 | shift 62 | ;; 63 | -v | --verbose) 64 | VERBOSE=1 65 | shift 66 | ;; 67 | -h | --help) 68 | show_usage 69 | exit 0 70 | ;; 71 | *) 72 | if [[ -d "$1" ]]; then 73 | EXTRA_DIRS="$EXTRA_DIRS $1" 74 | else 75 | echo "Unknown option or directory: $1" 76 | show_usage 77 | exit 1 78 | fi 79 | shift 80 | ;; 81 | esac 82 | done 83 | 84 | if [[ -n "$EXTRA_DIRS" ]]; then 85 | DIRECTORIES_TO_ANALYSE="$DIRECTORIES_TO_ANALYSE $EXTRA_DIRS" 86 | fi 87 | 88 | DIRECTORIES_TO_ANALYSE="${DIRECTORIES_TO_ANALYSE//,/ }" 89 | 90 | is_composer_package_installed() { 91 | composer show "$1" >/dev/null 2>&1 92 | } 93 | 94 | validate_php_syntax() { 95 | local directory="$1" 96 | local status=0 97 | local file_count=0 98 | local error_count=0 99 | 100 | echo "Checking PHP syntax in $directory..." 101 | 102 | while IFS= read -r -d $'\0' file; do 103 | file_count=$((file_count + 1)) 104 | if [[ $VERBOSE -eq 1 ]]; then 105 | echo "Checking syntax of $file" 106 | fi 107 | if ! php -l "$file" >/dev/null 2>&1; then 108 | error_count=$((error_count + 1)) 109 | status=1 110 | php -l "$file" 111 | fi 112 | done < <(find "$directory" -type f -name "*.php" -print0) 113 | 114 | echo "✓ Checked $file_count PHP files in $directory with $error_count errors" 115 | return $status 116 | } 117 | 118 | EXIT_STATUS=0 119 | 120 | if [[ $LINT_ONLY -eq 0 ]]; then 121 | if ! is_composer_package_installed "$DUSTER_PACKAGE"; then 122 | if [[ $AUTO_INSTALL -eq 1 ]]; then 123 | echo "Installing $DUSTER_PACKAGE..." 124 | composer require --dev "$DUSTER_PACKAGE" 125 | else 126 | echo "Error: $DUSTER_PACKAGE not installed." 127 | exit 1 128 | fi 129 | fi 130 | 131 | if [[ ! -f "$DUSTER_PATH" ]]; then 132 | echo "Error: Duster binary not found at $DUSTER_PATH" 133 | exit 1 134 | fi 135 | 136 | if [[ $FIX_MODE -eq 1 ]]; then 137 | echo "Running Duster FIX on: $DIRECTORIES_TO_ANALYSE" 138 | $DUSTER_PATH fix $DIRECTORIES_TO_ANALYSE || EXIT_STATUS=1 139 | else 140 | echo "Running Duster LINT on: $DIRECTORIES_TO_ANALYSE" 141 | $DUSTER_PATH lint $DIRECTORIES_TO_ANALYSE || EXIT_STATUS=1 142 | fi 143 | fi 144 | 145 | for dir in $DIRECTORIES_TO_ANALYSE; do 146 | if [[ -d "$dir" ]]; then 147 | if ! validate_php_syntax "$dir"; then 148 | EXIT_STATUS=1 149 | fi 150 | else 151 | echo "Warning: '$dir' not found" 152 | fi 153 | done 154 | 155 | if [[ $EXIT_STATUS -eq 0 ]]; then 156 | echo "✅ All linting checks passed!" 157 | else 158 | echo "⚠️ Linting completed with issues." 159 | fi 160 | 161 | # Only block the commit if strict mode is enabled 162 | if [[ $STRICT_MODE -eq 1 ]]; then 163 | exit $EXIT_STATUS 164 | else 165 | exit 0 166 | fi 167 | -------------------------------------------------------------------------------- /bin/test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Make script exit when a command fails 4 | set -e 5 | 6 | # Make script exit when an undeclared variable is used 7 | set -u 8 | 9 | # Make pipe commands return the exit status of the last command that fails or all commands if successful 10 | set -o pipefail 11 | 12 | # Get the directory where the script is located 13 | SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 14 | PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" 15 | 16 | # Change to project root directory 17 | cd "$PROJECT_ROOT" 18 | 19 | # Variables for customizing test behavior 20 | COVERAGE=0 21 | PARALLEL=0 22 | FILTER="" 23 | SPECIFIC_TEST="" 24 | 25 | # Parse command line arguments 26 | while [[ $# -gt 0 ]]; do 27 | case $1 in 28 | --coverage) 29 | COVERAGE=1 30 | shift 31 | ;; 32 | --parallel) 33 | PARALLEL=1 34 | shift 35 | ;; 36 | --filter=*) 37 | FILTER="${1#*=}" 38 | shift 39 | ;; 40 | --test=*) 41 | SPECIFIC_TEST="${1#*=}" 42 | shift 43 | ;; 44 | --help) 45 | echo "Usage: $0 [options]" 46 | echo "Options:" 47 | echo " --coverage Generate code coverage report" 48 | echo " --parallel Run tests in parallel" 49 | echo " --filter=NAME Only run tests matching the filter" 50 | echo " --test=PATH Run a specific test file or directory" 51 | echo " --help Display this help message" 52 | exit 0 53 | ;; 54 | *) 55 | echo "Unknown option: $1" 56 | echo "Use --help for usage information." 57 | exit 1 58 | ;; 59 | esac 60 | done 61 | 62 | # Check PHP version 63 | PHP_VERSION=$(php -r 'echo PHP_VERSION;') 64 | echo "Using PHP version: $PHP_VERSION" 65 | 66 | # Run composer install if vendor directory is missing 67 | if [ ! -d "vendor" ]; then 68 | echo "Vendor directory missing. Running composer install..." 69 | composer install 70 | fi 71 | 72 | # Build test command 73 | TEST_CMD="vendor/bin/phpunit" 74 | 75 | if [ -n "$FILTER" ]; then 76 | TEST_CMD="$TEST_CMD --filter=$FILTER" 77 | fi 78 | 79 | if [ -n "$SPECIFIC_TEST" ]; then 80 | TEST_CMD="$TEST_CMD $SPECIFIC_TEST" 81 | fi 82 | 83 | if [ "$PARALLEL" -eq 1 ]; then 84 | TEST_CMD="vendor/bin/paratest" 85 | if [ -n "$FILTER" ]; then 86 | echo "Warning: --filter is not supported with parallel testing. Ignoring filter." 87 | fi 88 | fi 89 | 90 | # Run the tests 91 | if [ "$COVERAGE" -eq 1 ]; then 92 | # Check if Xdebug is installed 93 | if php -m | grep -q xdebug; then 94 | echo "Generating test coverage report..." 95 | XDEBUG_MODE=coverage $TEST_CMD --coverage-text --coverage-html=coverage 96 | else 97 | echo "Warning: Xdebug is not installed. Cannot generate coverage report." 98 | $TEST_CMD 99 | fi 100 | else 101 | $TEST_CMD 102 | fi 103 | 104 | # Check exit code 105 | TEST_EXIT_CODE=$? 106 | 107 | # Show summary message 108 | if [ $TEST_EXIT_CODE -eq 0 ]; then 109 | echo "✅ Tests completed successfully!" 110 | else 111 | echo "❌ Tests failed with exit code: $TEST_EXIT_CODE" 112 | fi 113 | 114 | exit $TEST_EXIT_CODE 115 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jerome/filterable", 3 | "description": "Streamline dynamic Eloquent query filtering with seamless API request integration and advanced caching strategies.", 4 | "type": "library", 5 | "license": "MIT", 6 | "keywords": [ 7 | "eloquent", 8 | "filter", 9 | "query" 10 | ], 11 | "homepage": "https://github.com/Thavarshan/filterable", 12 | "support": { 13 | "issues": "https://github.com/Thavarshan/filterable/issues", 14 | "source": "https://github.com/Thavarshan/filterable" 15 | }, 16 | "authors": [ 17 | { 18 | "name": "Jerome Thayananthajothy", 19 | "email": "tjthavarshan@gmail.com", 20 | "homepage": "https://thavarshan.com" 21 | } 22 | ], 23 | "autoload": { 24 | "psr-4": { 25 | "Filterable\\": "src/Filterable", 26 | "Filterable\\Database\\Factories\\": "database/factories" 27 | } 28 | }, 29 | "autoload-dev": { 30 | "psr-4": { 31 | "Filterable\\Tests\\": "tests" 32 | } 33 | }, 34 | "require": { 35 | "php": "^8.3 | ^8.4", 36 | "illuminate/cache": "^11.0|^12.0", 37 | "illuminate/contracts": "^11.0|^12.0", 38 | "illuminate/database": "^11.0|^12.0", 39 | "illuminate/http": "^11.0|^12.0", 40 | "illuminate/support": "^11.0|^12.0", 41 | "laravel/pint": "^1.21", 42 | "nesbot/carbon": "^2.72|^3.0", 43 | "spatie/laravel-package-tools": "^1.11", 44 | "tightenco/duster": "^3.1" 45 | }, 46 | "require-dev": { 47 | "ext-json": "*", 48 | "larastan/larastan": "^3.1", 49 | "mockery/mockery": "^1.4", 50 | "nunomaduro/phpinsights": "^2.11", 51 | "orchestra/testbench": "10.*", 52 | "phpunit/phpunit": "^11.5.3", 53 | "squizlabs/php_codesniffer": "^3.7" 54 | }, 55 | "scripts": { 56 | "lint": "chmod +x bin/lint.sh && ./bin/lint.sh", 57 | "fix": "chmod +x bin/fix.sh && ./bin/fix.sh", 58 | "test": "chmod +x bin/test.sh && ./bin/test.sh" 59 | }, 60 | "config": { 61 | "sort-packages": true, 62 | "allow-plugins": { 63 | "phpunit/phpunit-plugin": true, 64 | "dealerdirect/phpcodesniffer-composer-installer": true 65 | } 66 | }, 67 | "extra": { 68 | "laravel": { 69 | "providers": [ 70 | "Filterable\\Providers\\FilterableServiceProvider" 71 | ] 72 | } 73 | }, 74 | "prefer-stable": true, 75 | "minimum-stability": "dev" 76 | } 77 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "filterable", 3 | "lockfileVersion": 3, 4 | "requires": true, 5 | "packages": { 6 | "": { 7 | "devDependencies": { 8 | "husky": "^9.1.7" 9 | } 10 | }, 11 | "node_modules/husky": { 12 | "version": "9.1.7", 13 | "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", 14 | "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", 15 | "dev": true, 16 | "license": "MIT", 17 | "bin": { 18 | "husky": "bin.js" 19 | }, 20 | "engines": { 21 | "node": ">=18" 22 | }, 23 | "funding": { 24 | "url": "https://github.com/sponsors/typicode" 25 | } 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "type": "module", 4 | "scripts": { 5 | "prepare": "husky" 6 | }, 7 | "devDependencies": { 8 | "husky": "^9.1.7" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | includes: 2 | - vendor/nunomaduro/larastan/extension.neon 3 | 4 | parameters: 5 | paths: 6 | - src 7 | - tests 8 | level: max 9 | ignoreErrors: 10 | - '#Unsafe usage of new static#' 11 | checkMissingIterableValueType: false 12 | treatPhpDocTypesAsCertain: false 13 | -------------------------------------------------------------------------------- /pint.json: -------------------------------------------------------------------------------- 1 | { 2 | "array_indentation": true, 3 | "array_syntax": { 4 | "syntax": "short" 5 | }, 6 | "binary_operator_spaces": { 7 | "default": "single_space" 8 | }, 9 | "blank_line_after_namespace": true, 10 | "blank_line_after_opening_tag": true, 11 | "blank_line_before_statement": { 12 | "statements": [ 13 | "continue", 14 | "return" 15 | ] 16 | }, 17 | "blank_line_between_import_groups": true, 18 | "blank_lines_before_namespace": true, 19 | "braces_position": { 20 | "control_structures_opening_brace": "same_line", 21 | "functions_opening_brace": "next_line_unless_newline_at_signature_end", 22 | "anonymous_functions_opening_brace": "same_line", 23 | "classes_opening_brace": "next_line_unless_newline_at_signature_end", 24 | "anonymous_classes_opening_brace": "next_line_unless_newline_at_signature_end", 25 | "allow_single_line_empty_anonymous_classes": false, 26 | "allow_single_line_anonymous_functions": false 27 | }, 28 | "cast_spaces": true, 29 | "class_attributes_separation": { 30 | "elements": { 31 | "const": "one", 32 | "method": "one", 33 | "property": "one", 34 | "trait_import": "none" 35 | } 36 | }, 37 | "class_definition": { 38 | "multi_line_extends_each_single_line": true, 39 | "single_item_single_line": true, 40 | "single_line": true 41 | }, 42 | "clean_namespace": true, 43 | "compact_nullable_type_declaration": true, 44 | "concat_space": { 45 | "spacing": "none" 46 | }, 47 | "constant_case": { 48 | "case": "lower" 49 | }, 50 | "control_structure_braces": true, 51 | "control_structure_continuation_position": { 52 | "position": "same_line" 53 | }, 54 | "declare_equal_normalize": true, 55 | "declare_parentheses": true, 56 | "elseif": true, 57 | "encoding": true, 58 | "full_opening_tag": true, 59 | "fully_qualified_strict_types": false, 60 | "function_declaration": true, 61 | "general_phpdoc_tag_rename": true, 62 | "heredoc_to_nowdoc": true, 63 | "include": true, 64 | "increment_style": { 65 | "style": "post" 66 | }, 67 | "indentation_type": true, 68 | "integer_literal_case": true, 69 | "lambda_not_used_import": true, 70 | "line_ending": true, 71 | "linebreak_after_opening_tag": true, 72 | "list_syntax": true, 73 | "lowercase_cast": true, 74 | "lowercase_keywords": true, 75 | "lowercase_static_reference": true, 76 | "magic_constant_casing": true, 77 | "magic_method_casing": true, 78 | "method_chaining_indentation": true, 79 | "multiline_whitespace_before_semicolons": { 80 | "strategy": "no_multi_line" 81 | }, 82 | "native_function_casing": true, 83 | "native_type_declaration_casing": true, 84 | "new_with_parentheses": { 85 | "named_class": false, 86 | "anonymous_class": false 87 | }, 88 | "no_alias_functions": true, 89 | "no_alias_language_construct_call": true, 90 | "no_alternative_syntax": true, 91 | "no_binary_string": true, 92 | "no_blank_lines_after_class_opening": true, 93 | "no_blank_lines_after_phpdoc": true, 94 | "no_closing_tag": true, 95 | "no_empty_phpdoc": true, 96 | "no_empty_statement": true, 97 | "no_extra_blank_lines": { 98 | "tokens": [ 99 | "extra", 100 | "throw", 101 | "use" 102 | ] 103 | }, 104 | "no_leading_import_slash": true, 105 | "no_leading_namespace_whitespace": true, 106 | "no_mixed_echo_print": { 107 | "use": "echo" 108 | }, 109 | "no_multiline_whitespace_around_double_arrow": true, 110 | "no_multiple_statements_per_line": true, 111 | "no_short_bool_cast": true, 112 | "no_singleline_whitespace_before_semicolons": true, 113 | "no_space_around_double_colon": true, 114 | "no_spaces_after_function_name": true, 115 | "no_spaces_around_offset": { 116 | "positions": [ 117 | "inside", 118 | "outside" 119 | ] 120 | }, 121 | "no_superfluous_phpdoc_tags": { 122 | "allow_mixed": true, 123 | "allow_unused_params": true 124 | }, 125 | "no_trailing_comma_in_singleline": true, 126 | "no_trailing_whitespace": true, 127 | "no_trailing_whitespace_in_comment": true, 128 | "no_unneeded_control_parentheses": { 129 | "statements": [ 130 | "break", 131 | "clone", 132 | "continue", 133 | "echo_print", 134 | "return", 135 | "switch_case", 136 | "yield" 137 | ] 138 | }, 139 | "no_unneeded_braces": true, 140 | "no_unreachable_default_argument_value": true, 141 | "no_unset_cast": true, 142 | "no_unused_imports": true, 143 | "no_useless_return": true, 144 | "no_whitespace_before_comma_in_array": true, 145 | "no_whitespace_in_blank_line": true, 146 | "normalize_index_brace": true, 147 | "not_operator_with_successor_space": true, 148 | "nullable_type_declaration": true, 149 | "nullable_type_declaration_for_default_null_value": true, 150 | "object_operator_without_whitespace": true, 151 | "ordered_imports": { 152 | "sort_algorithm": "alpha", 153 | "imports_order": [ 154 | "const", 155 | "class", 156 | "function" 157 | ] 158 | }, 159 | "ordered_interfaces": true, 160 | "ordered_traits": true, 161 | "php_unit_method_casing": { 162 | "case": "snake_case" 163 | }, 164 | "phpdoc_align": { 165 | "align": "left", 166 | "spacing": { 167 | "param": 2 168 | } 169 | }, 170 | "phpdoc_indent": true, 171 | "phpdoc_inline_tag_normalizer": true, 172 | "phpdoc_no_access": true, 173 | "phpdoc_no_package": true, 174 | "phpdoc_no_useless_inheritdoc": true, 175 | "phpdoc_order": { 176 | "order": [ 177 | "param", 178 | "return", 179 | "throws" 180 | ] 181 | }, 182 | "phpdoc_scalar": true, 183 | "phpdoc_separation": { 184 | "groups": [ 185 | [ 186 | "deprecated", 187 | "link", 188 | "see", 189 | "since" 190 | ], 191 | [ 192 | "author", 193 | "copyright", 194 | "license" 195 | ], 196 | [ 197 | "category", 198 | "package", 199 | "subpackage" 200 | ], 201 | [ 202 | "property", 203 | "property-read", 204 | "property-write" 205 | ], 206 | [ 207 | "param", 208 | "return" 209 | ] 210 | ] 211 | }, 212 | "phpdoc_single_line_var_spacing": true, 213 | "phpdoc_summary": false, 214 | "phpdoc_tag_type": { 215 | "tags": { 216 | "inheritdoc": "inline" 217 | } 218 | }, 219 | "phpdoc_to_comment": false, 220 | "phpdoc_trim": true, 221 | "phpdoc_types": true, 222 | "phpdoc_var_without_name": true, 223 | "psr_autoloading": false, 224 | "return_type_declaration": { 225 | "space_before": "none" 226 | }, 227 | "self_accessor": false, 228 | "self_static_accessor": true, 229 | "short_scalar_cast": true, 230 | "simplified_null_return": false, 231 | "single_blank_line_at_eof": true, 232 | "single_class_element_per_statement": { 233 | "elements": [ 234 | "const", 235 | "property" 236 | ] 237 | }, 238 | "single_import_per_statement": { 239 | "group_to_single_imports": false 240 | }, 241 | "single_line_after_imports": true, 242 | "single_line_comment_style": { 243 | "comment_types": [ 244 | "hash" 245 | ] 246 | }, 247 | "single_line_empty_body": true, 248 | "single_quote": true, 249 | "single_space_around_construct": true, 250 | "space_after_semicolon": true, 251 | "spaces_inside_parentheses": true, 252 | "standardize_not_equals": true, 253 | "statement_indentation": true, 254 | "switch_case_semicolon_to_colon": true, 255 | "switch_case_space": true, 256 | "ternary_operator_spaces": true, 257 | "trailing_comma_in_multiline": { 258 | "elements": [ 259 | "arrays" 260 | ] 261 | }, 262 | "trim_array_spaces": true, 263 | "type_declaration_spaces": true, 264 | "types_spaces": true, 265 | "unary_operator_spaces": true, 266 | "visibility_required": { 267 | "elements": [ 268 | "method", 269 | "property" 270 | ] 271 | }, 272 | "single_trait_insert_per_statement": false, 273 | "whitespace_after_comma_in_array": true, 274 | "yoda_style": { 275 | "always_move_variable": false, 276 | "equal": false, 277 | "identical": false, 278 | "less_and_greater": false 279 | }, 280 | "method_argument_space": { 281 | "on_multiline": "ensure_fully_multiline", 282 | "after_heredoc": true 283 | } 284 | } 285 | -------------------------------------------------------------------------------- /src/Filterable/Concerns/HandlesFilterPermissions.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | protected array $filterPermissions = []; 13 | 14 | /** 15 | * Set permission requirements for filters. 16 | */ 17 | public function setFilterPermissions(array $permissions): self 18 | { 19 | $this->filterPermissions = $permissions; 20 | 21 | return $this; 22 | } 23 | 24 | /** 25 | * Check if the user has permission to use all requested filters. 26 | */ 27 | protected function checkFilterPermissions(): void 28 | { 29 | if (empty($this->filterPermissions) || is_null($this->forUser)) { 30 | return; 31 | } 32 | 33 | $requestedFilters = array_keys($this->getFilterables()); 34 | $restrictedFilters = array_keys($this->filterPermissions); 35 | 36 | $filtersToCheck = array_intersect($requestedFilters, $restrictedFilters); 37 | 38 | foreach ($filtersToCheck as $filter) { 39 | $permission = $this->filterPermissions[$filter]; 40 | 41 | // Skip filters where the user doesn't have permission 42 | if (! $this->userHasPermission($permission)) { 43 | // Remove the filter from filterables 44 | unset($this->filterables[$filter]); 45 | 46 | if (method_exists($this, 'logInfo')) { 47 | $this->logInfo('Filter removed due to insufficient permissions', [ 48 | 'filter' => $filter, 49 | 'required_permission' => $permission, 50 | ]); 51 | } 52 | } 53 | } 54 | } 55 | 56 | /** 57 | * Check if the user has a specific permission. 58 | */ 59 | protected function userHasPermission(string|array $permission): bool 60 | { 61 | // Override this method in your specific filter class 62 | // to implement your authorization logic 63 | return true; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/Filterable/Concerns/HandlesFilterables.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | protected array $filters = []; 23 | 24 | /** 25 | * Map of filter names to methods. 26 | * 27 | * @var array 28 | */ 29 | protected array $filterMethodMap = []; 30 | 31 | /** 32 | * All filters that have been chosen to be applied. 33 | * 34 | * @var array 35 | */ 36 | protected array $filterables = []; 37 | 38 | /** 39 | * The current filters being applied. 40 | * 41 | * @var array 42 | */ 43 | protected array $currentFilters = []; 44 | 45 | /** 46 | * Initialize with the filter instance. 47 | */ 48 | public function initialize(Filter $filter): void 49 | { 50 | $this->filter = $filter; 51 | 52 | // Copy the filters array from the parent filter 53 | if (property_exists($filter, 'filters') && is_array($filter->filters)) { 54 | $this->filters = $filter->filters; 55 | } 56 | 57 | // Copy the filter method map if it exists 58 | if (property_exists($filter, 'filterMethodMap') && is_array($filter->filterMethodMap)) { 59 | $this->filterMethodMap = $filter->filterMethodMap; 60 | } 61 | } 62 | 63 | /** 64 | * Fetch all relevant filters (key, value) from the request. 65 | * 66 | * @return array 67 | */ 68 | public function getFilterables(): array 69 | { 70 | $filterKeys = array_merge( 71 | $this->getFilters(), 72 | array_keys($this->filterMethodMap ?? []) 73 | ); 74 | 75 | // Contains key, value pairs of the filters 76 | $this->filterables = array_merge( 77 | $this->filterables, 78 | array_filter($this->request->only($filterKeys)) 79 | ); 80 | 81 | $this->currentFilters = array_keys($this->filterables); 82 | 83 | return $this->filterables; 84 | } 85 | 86 | /** 87 | * Get the registered filters. 88 | * 89 | * @return array 90 | */ 91 | public function getFilters(): array 92 | { 93 | return $this->filters; 94 | } 95 | 96 | /** 97 | * Append a filterable value to the filter. 98 | */ 99 | public function appendFilterable(string $key, mixed $value): Filter 100 | { 101 | $this->filterables[$key] = $value; 102 | 103 | return $this; 104 | } 105 | 106 | /** 107 | * Get the current filters being applied. 108 | * 109 | * @return array 110 | */ 111 | public function getCurrentFilters(): array 112 | { 113 | return $this->currentFilters; 114 | } 115 | 116 | /** 117 | * Apply all relevant filters to the query and present it 118 | * as a callable for use within a collection instance. 119 | * 120 | * @see https://laravel.com/docs/10.x/collections#method-filter 121 | */ 122 | public function asCollectionFilter(): Closure 123 | { 124 | return fn (mixed $items) => collect($this->getFilterables()); 125 | } 126 | 127 | /** 128 | * Apply the filterables to the query. 129 | */ 130 | protected function applyFilterables(): void 131 | { 132 | // Use hasFeature('caching') instead of shouldCache() 133 | if (method_exists($this, 'hasFeature') && $this->hasFeature('caching')) { 134 | $this->applyFilterablesWithCache(); 135 | 136 | return; 137 | } 138 | 139 | $this->applyFiltersToQuery(); 140 | } 141 | 142 | /** 143 | * Execute the query builder query functionality with the filters applied. 144 | */ 145 | protected function applyFiltersToQuery(): void 146 | { 147 | collect($this->getFilterables()) 148 | ->filter(fn (mixed $value) => $value !== null 149 | && $value !== '' 150 | && $value !== false 151 | && $value !== []) 152 | ->each(function ($value, $filter) { 153 | $this->applyFilterable($filter, $value); 154 | }); 155 | } 156 | 157 | /** 158 | * Apply a filter to the query. 159 | */ 160 | protected function applyFilterable(string $filter, mixed $value): void 161 | { 162 | $method = $this->makeFilterIntoMethodName($filter); 163 | 164 | if (! method_exists($this, $method)) { 165 | throw new BadMethodCallException( 166 | sprintf('Method [%s] does not exist on %s', $method, static::class) 167 | ); 168 | } 169 | 170 | // Use hasFeature('logging') instead of just checking if method exists 171 | $this->logInfo("Applying filter method: {$method}", [ 172 | 'filter' => $filter, 173 | 'value' => $value, 174 | ]); 175 | 176 | call_user_func([$this, $method], $value); 177 | } 178 | 179 | /** 180 | * Make the filter into a method name. 181 | */ 182 | protected function makeFilterIntoMethodName(string $filter): string 183 | { 184 | return $this->filterMethodMap[$filter] ?? Str::camel($filter); 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /src/Filterable/Concerns/HandlesPreFilters.php: -------------------------------------------------------------------------------- 1 | preFilters = $callback; 21 | 22 | return $this; 23 | } 24 | 25 | /** 26 | * Apply pre-filters to the query. 27 | */ 28 | protected function applyPreFilters(): void 29 | { 30 | if ($this->preFilters === null) { 31 | return; 32 | } 33 | 34 | // Only log if the logging feature is enabled 35 | $this->logInfo('Applying pre-filters'); 36 | 37 | // Apply pre-filters to builder 38 | ($this->preFilters)($this->builder); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Filterable/Concerns/HandlesRateLimiting.php: -------------------------------------------------------------------------------- 1 | 24 | */ 25 | protected array $filterComplexity = []; 26 | 27 | /** 28 | * Set the maximum number of filters. 29 | */ 30 | public function setMaxFilters(int $max): self 31 | { 32 | $this->maxFilters = $max; 33 | 34 | return $this; 35 | } 36 | 37 | /** 38 | * Set the maximum complexity score. 39 | */ 40 | public function setMaxComplexity(int $max): self 41 | { 42 | $this->maxComplexity = $max; 43 | 44 | return $this; 45 | } 46 | 47 | /** 48 | * Set complexity scores for specific filters. 49 | */ 50 | public function setFilterComplexity(array $complexityMap): self 51 | { 52 | $this->filterComplexity = $complexityMap; 53 | 54 | return $this; 55 | } 56 | 57 | /** 58 | * Check if the filter request exceeds the rate limit. 59 | */ 60 | protected function checkRateLimits(): bool 61 | { 62 | // Check the number of filters 63 | $filterCount = count($this->getFilterables()); 64 | if ($filterCount > $this->maxFilters) { 65 | if (method_exists($this, 'logWarning')) { 66 | $this->logWarning('Too many filters applied', [ 67 | 'applied' => $filterCount, 68 | 'maximum' => $this->maxFilters, 69 | ]); 70 | } 71 | 72 | return false; 73 | } 74 | 75 | // Check the complexity score 76 | $complexityScore = $this->calculateComplexity(); 77 | if ($complexityScore > $this->maxComplexity) { 78 | if (method_exists($this, 'logWarning')) { 79 | $this->logWarning('Filter request too complex', [ 80 | 'complexity' => $complexityScore, 81 | 'maximum' => $this->maxComplexity, 82 | ]); 83 | } 84 | 85 | return false; 86 | } 87 | 88 | // Use Laravel's rate limiter for throttling complex requests 89 | $limiter = App::make(RateLimiter::class); 90 | $key = 'filter:'.md5($this->request->ip().'|'.get_class($this)); 91 | 92 | if ($limiter->tooManyAttempts($key, 60)) { 93 | if (method_exists($this, 'logWarning')) { 94 | $this->logWarning('Rate limit exceeded for complex filter', [ 95 | 'ip' => $this->request->ip(), 96 | 'complexity' => $complexityScore, 97 | ]); 98 | } 99 | 100 | return false; 101 | } 102 | 103 | // Add to the rate limiter based on complexity 104 | $limiter->hit($key, ceil($complexityScore / 10)); 105 | 106 | return true; 107 | } 108 | 109 | /** 110 | * Calculate the complexity score of the current filter request. 111 | */ 112 | protected function calculateComplexity(): int 113 | { 114 | $filterables = $this->getFilterables(); 115 | $complexity = 0; 116 | 117 | foreach ($filterables as $filter => $value) { 118 | // Base complexity is 1 per filter 119 | $filterComplexity = 1; 120 | 121 | // Add specific filter complexity if defined 122 | if (isset($this->filterComplexity[$filter])) { 123 | $filterComplexity = $this->filterComplexity[$filter]; 124 | } 125 | 126 | // Adjust complexity based on value (arrays are more complex) 127 | if (is_array($value)) { 128 | $filterComplexity *= count($value); 129 | } 130 | 131 | $complexity += $filterComplexity; 132 | } 133 | 134 | return $complexity; 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/Filterable/Concerns/HandlesUserScope.php: -------------------------------------------------------------------------------- 1 | forUser = $user; 21 | 22 | return $this; 23 | } 24 | 25 | /** 26 | * Apply the user filter to the query. 27 | */ 28 | protected function applyUserScope(): void 29 | { 30 | if (is_null($this->forUser)) { 31 | return; 32 | } 33 | 34 | $attribute = $this->forUser->getAuthIdentifierName(); 35 | $value = $this->forUser->getAuthIdentifier(); 36 | 37 | if (method_exists($this, 'logInfo')) { 38 | $this->logInfo('Applying user-specific filter', [ 39 | 'attribute' => $attribute, 40 | 'value' => $value, 41 | ]); 42 | } 43 | 44 | $this->getBuilder()->where($attribute, $value); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Filterable/Concerns/InteractsWithCache.php: -------------------------------------------------------------------------------- 1 | cacheExpiration; 28 | } 29 | 30 | /** 31 | * Set expiration minutes for the cache. 32 | */ 33 | public function setCacheExpiration(int $minutes): Filter 34 | { 35 | $this->cacheExpiration = $minutes; 36 | 37 | return $this; 38 | } 39 | 40 | /** 41 | * Get the cache handler. 42 | */ 43 | public function getCacheHandler(): Cache 44 | { 45 | if (is_null($this->cache)) { 46 | $this->cache = app(Cache::class); 47 | } 48 | 49 | return $this->cache; 50 | } 51 | 52 | /** 53 | * Set the cache handler. 54 | */ 55 | public function setCacheHandler(Cache $cache): Filter 56 | { 57 | $this->cache = $cache; 58 | 59 | return $this; 60 | } 61 | 62 | /** 63 | * Clear the cache. 64 | */ 65 | public function clearCache(): void 66 | { 67 | if ($this->hasFeature('logging')) { 68 | $this->getLogger()->info('Clearing cache for filter', [ 69 | 'cache_key' => $this->buildCacheKey(), 70 | ]); 71 | } 72 | 73 | $this->getCacheHandler()->forget($this->buildCacheKey()); 74 | } 75 | 76 | /** 77 | * Apply the filterables to the query with caching. 78 | */ 79 | protected function applyFilterablesWithCache(): Collection 80 | { 81 | return $this->getCacheHandler()->remember( 82 | $this->buildCacheKey(), 83 | Carbon::now()->addMinutes($this->getCacheExpiration()), 84 | function (): Collection { 85 | $this->applyFiltersToQuery(); 86 | 87 | return $this->getBuilder()->get(); 88 | } 89 | ); 90 | } 91 | 92 | /** 93 | * Build the cache key for the filter. 94 | */ 95 | protected function buildCacheKey(): string 96 | { 97 | // Create a unique cache key with sanitized inputs 98 | $userPart = 'global'; 99 | 100 | // Check if forUser property exists, is not null, and has getAuthIdentifier method 101 | if (property_exists($this, 'forUser') && $this->forUser !== null && method_exists($this->forUser, 'getAuthIdentifier')) { 102 | $userPart = $this->forUser->getAuthIdentifier() ?? 'global'; 103 | } 104 | 105 | // Get the filterables, sort them by key, and normalize them 106 | $filterables = $this->getFilterables(); 107 | ksort($filterables); 108 | 109 | // Sanitize values for use in cache keys 110 | $sanitizedFilterables = []; 111 | foreach ($filterables as $key => $value) { 112 | // Handle different data types appropriately 113 | if (is_array($value)) { 114 | $sanitizedValue = md5(json_encode($value)); 115 | } elseif (is_scalar($value)) { 116 | $sanitizedValue = (string) $value; 117 | } else { 118 | $sanitizedValue = md5(serialize($value)); 119 | } 120 | 121 | $sanitizedFilterables[$key] = $sanitizedValue; 122 | } 123 | 124 | $filtersPart = http_build_query($sanitizedFilterables); 125 | $cacheKey = "filters:{$userPart}:".md5($filtersPart); 126 | 127 | // Make sure the cache key isn't too long 128 | if (strlen($cacheKey) > 250) { 129 | $cacheKey = "filters:{$userPart}:".md5($filtersPart); 130 | } 131 | 132 | return $cacheKey; 133 | } 134 | 135 | /** 136 | * Execute a query with caching if needed. 137 | * This method delegates to SmartCaching if available. 138 | */ 139 | protected function executeQueryWithCaching(): Collection 140 | { 141 | // If SmartCaching is loaded, use it 142 | if (method_exists($this, 'shouldAutomaticallyCacheQuery')) { 143 | return $this->smartExecuteQueryWithCaching(); 144 | } 145 | 146 | // Otherwise, use basic caching 147 | if (! $this->hasFeature('caching')) { 148 | return $this->getBuilder()->get(); 149 | } 150 | 151 | $cache = $this->getCacheHandler(); 152 | $cacheKey = $this->buildCacheKey(); 153 | 154 | return $cache->remember( 155 | $cacheKey, 156 | Carbon::now()->addMinutes($this->getCacheExpiration()), 157 | function (): Collection { 158 | return $this->getBuilder()->get(); 159 | } 160 | ); 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /src/Filterable/Concerns/InteractsWithLogging.php: -------------------------------------------------------------------------------- 1 | logger = $logger; 21 | 22 | return $this; 23 | } 24 | 25 | /** 26 | * Get the Logger instance. 27 | */ 28 | public function getLogger(): LoggerInterface 29 | { 30 | return $this->logger ?? app(LoggerInterface::class); 31 | } 32 | 33 | /** 34 | * Log an informational message. 35 | */ 36 | protected function logInfo(string $message, array $context = []): void 37 | { 38 | if ($this->hasFeature('logging')) { 39 | $this->getLogger()->info($message, $context); 40 | } 41 | } 42 | 43 | /** 44 | * Log a debug message. 45 | */ 46 | protected function logDebug(string $message, array $context = []): void 47 | { 48 | if ($this->hasFeature('logging')) { 49 | $this->getLogger()->debug($message, $context); 50 | } 51 | } 52 | 53 | /** 54 | * Log a warning message. 55 | */ 56 | protected function logWarning(string $message, array $context = []): void 57 | { 58 | if ($this->hasFeature('logging')) { 59 | $this->getLogger()->warning($message, $context); 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/Filterable/Concerns/ManagesMemory.php: -------------------------------------------------------------------------------- 1 | useLazyCollection = true; 23 | $this->options['chunk_size'] = $chunkSize; 24 | 25 | // Apply all filters to the builder if not already applied 26 | if ($this->state !== 'applied') { 27 | $this->apply($this->getBuilder(), $this->options); 28 | } 29 | 30 | // Return a lazy collection that loads records in chunks 31 | return $this->getBuilder()->lazy($chunkSize); 32 | } 33 | 34 | /** 35 | * Process the results with a callback using minimal memory. 36 | */ 37 | public function lazyEach(Closure $callback, int $chunkSize = 1000): void 38 | { 39 | $this->useLazyCollection = true; 40 | 41 | // Apply all filters to the builder if not already applied 42 | if ($this->state !== 'applied') { 43 | $this->apply($this->getBuilder(), $this->options); 44 | } 45 | 46 | // Process each item with minimal memory usage 47 | $this->getBuilder()->lazy($chunkSize)->each($callback); 48 | } 49 | 50 | /** 51 | * Create a generator to iterate through results with minimal memory usage. 52 | */ 53 | public function cursor(): Generator 54 | { 55 | // Apply all filters to the builder if not already applied 56 | if ($this->state !== 'applied') { 57 | $this->apply($this->getBuilder(), $this->options); 58 | } 59 | 60 | // Use cursor for low memory iteration 61 | return $this->getBuilder()->cursor(); 62 | } 63 | 64 | /** 65 | * Enable or disable lazy collection usage. 66 | */ 67 | public function useLazy(bool $useLazy = true): self 68 | { 69 | $this->useLazyCollection = $useLazy; 70 | 71 | return $this; 72 | } 73 | 74 | /** 75 | * Map over the query results without loading all records. 76 | */ 77 | public function map(callable $callback, int $chunkSize = 1000): array 78 | { 79 | $result = []; 80 | 81 | $this->lazyEach(function ($item) use (&$result, $callback) { 82 | $result[] = $callback($item); 83 | }, $chunkSize); 84 | 85 | return $result; 86 | } 87 | 88 | /** 89 | * Filter the results without loading all records. 90 | */ 91 | public function filter(callable $callback, int $chunkSize = 1000): array 92 | { 93 | $result = []; 94 | 95 | $this->lazyEach(function ($item) use (&$result, $callback) { 96 | if ($callback($item)) { 97 | $result[] = $item; 98 | } 99 | }, $chunkSize); 100 | 101 | return $result; 102 | } 103 | 104 | /** 105 | * Reduce the results without loading all records. 106 | */ 107 | public function reduce(callable $callback, $initial = null, int $chunkSize = 1000) 108 | { 109 | $result = $initial; 110 | 111 | $this->lazyEach(function ($item) use (&$result, $callback) { 112 | $result = $callback($result, $item); 113 | }, $chunkSize); 114 | 115 | return $result; 116 | } 117 | 118 | /** 119 | * Process the query in chunks using a callback. 120 | */ 121 | public function chunk(int $chunkSize, Closure $callback): bool 122 | { 123 | // Apply all filters to the builder if not already applied 124 | if ($this->state !== 'applied') { 125 | $this->apply($this->builder, $this->options); 126 | } 127 | 128 | return $this->getBuilder()->chunk($chunkSize, $callback); 129 | } 130 | 131 | /** 132 | * Execute the query with memory management. 133 | * This method is called from the Filter::get() method when memory management is enabled. 134 | */ 135 | protected function executeQueryWithMemoryManagement(): Collection 136 | { 137 | $chunkSize = $this->options['chunk_size'] ?? 1000; 138 | 139 | // If we need the full collection but want to load it in chunks 140 | // to avoid memory issues 141 | $collection = new Collection; 142 | 143 | $this->getBuilder()->chunk($chunkSize, function ($results) use ($collection) { 144 | $collection->push(...$results); 145 | }); 146 | 147 | return $collection; 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /src/Filterable/Concerns/MonitorsPerformance.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | protected array $metrics = []; 23 | 24 | /** 25 | * Add a custom metric. 26 | */ 27 | public function addMetric(string $key, mixed $value): self 28 | { 29 | $this->metrics[$key] = $value; 30 | 31 | return $this; 32 | } 33 | 34 | /** 35 | * Get all performance metrics. 36 | */ 37 | public function getMetrics(): array 38 | { 39 | return $this->metrics; 40 | } 41 | 42 | /** 43 | * Get the execution time of the filter application. 44 | */ 45 | public function getExecutionTime(): ?float 46 | { 47 | return $this->metrics['execution_time'] ?? null; 48 | } 49 | 50 | /** 51 | * Start timing the filter application. 52 | */ 53 | protected function startTiming(): void 54 | { 55 | $this->startTime = microtime(true); 56 | } 57 | 58 | /** 59 | * End timing the filter application. 60 | */ 61 | protected function endTiming(): void 62 | { 63 | $this->endTime = microtime(true); 64 | $this->metrics['execution_time'] = $this->endTime - $this->startTime; 65 | 66 | if (method_exists($this, 'logInfo')) { 67 | $this->logInfo('Filter executed', [ 68 | 'execution_time' => $this->metrics['execution_time'], 69 | 'memory_usage' => memory_get_usage(true), 70 | 'filter_count' => count($this->getFilterables()), 71 | ]); 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Filterable/Concerns/OptimizesQueries.php: -------------------------------------------------------------------------------- 1 | |null 11 | */ 12 | protected ?array $selectColumns = null; 13 | 14 | /** 15 | * Whether to defer loading relationships. 16 | */ 17 | protected bool $deferRelationships = false; 18 | 19 | /** 20 | * Relationships to eager load. 21 | * 22 | * @var array 23 | */ 24 | protected array $eagerLoadRelations = []; 25 | 26 | /** 27 | * Set specific columns to select to reduce data transfer. 28 | */ 29 | public function select(array $columns): self 30 | { 31 | $this->selectColumns = $columns; 32 | 33 | return $this; 34 | } 35 | 36 | /** 37 | * Eager load relationships to prevent N+1 queries. 38 | */ 39 | public function with(array|string $relations): self 40 | { 41 | if (is_string($relations)) { 42 | $relations = [$relations]; 43 | } 44 | 45 | $this->eagerLoadRelations = array_merge($this->eagerLoadRelations, $relations); 46 | 47 | return $this; 48 | } 49 | 50 | /** 51 | * Enable chunked processing for large datasets. 52 | */ 53 | public function chunkSize(int $size): self 54 | { 55 | $this->setOption('chunk_size', $size); 56 | $this->setOption('use_chunking', true); 57 | 58 | return $this; 59 | } 60 | 61 | /** 62 | * Use a database index hint for better performance. 63 | */ 64 | public function useIndex(string $index): self 65 | { 66 | // This will add an index hint in MySQL 67 | // Note: This is database-specific and may need adaptation 68 | $this->getBuilder()->from($this->getBuilder()->getQuery()->from." USE INDEX ({$index})"); 69 | 70 | return $this; 71 | } 72 | 73 | /** 74 | * Configure the query for optimal performance. 75 | */ 76 | protected function optimizeQuery(): void 77 | { 78 | // Select only needed columns 79 | if (! is_null($this->selectColumns)) { 80 | $this->getBuilder()->select($this->selectColumns); 81 | } 82 | 83 | // Add eager loading for relationships 84 | if (! empty($this->eagerLoadRelations)) { 85 | $this->getBuilder()->with($this->eagerLoadRelations); 86 | } 87 | 88 | // Use query chunking for large datasets if configured 89 | if (isset($this->options['chunk_size']) && is_numeric($this->options['chunk_size'])) { 90 | // We'll set a flag to use chunking when executing 91 | $this->options['use_chunking'] = true; 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/Filterable/Concerns/SmartCaching.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | protected array $cacheTags = []; 18 | 19 | /** 20 | * Whether to cache query results. 21 | */ 22 | protected bool $shouldCacheResults = false; 23 | 24 | /** 25 | * Whether to cache query count only. 26 | */ 27 | protected bool $shouldCacheCount = false; 28 | 29 | /** 30 | * Set cache tags to use for better invalidation. 31 | */ 32 | public function cacheTags(array|string $tags): self 33 | { 34 | if (is_string($tags)) { 35 | $tags = [$tags]; 36 | } 37 | 38 | $this->cacheTags = $tags; 39 | 40 | return $this; 41 | } 42 | 43 | /** 44 | * Enable query result caching. 45 | */ 46 | public function cacheResults(bool $shouldCache = true): self 47 | { 48 | $this->shouldCacheResults = $shouldCache; 49 | 50 | return $this; 51 | } 52 | 53 | /** 54 | * Enable query count caching. 55 | */ 56 | public function cacheCount(bool $shouldCache = true): self 57 | { 58 | $this->shouldCacheCount = $shouldCache; 59 | 60 | return $this; 61 | } 62 | 63 | /** 64 | * Get the SQL for the current query for debugging. 65 | */ 66 | public function toSql(): string 67 | { 68 | return $this->getBuilder()->toSql(); 69 | } 70 | 71 | /** 72 | * Get count with caching. 73 | */ 74 | public function count(): int 75 | { 76 | if (! method_exists($this, 'hasFeature') || 77 | ! $this->hasFeature('caching') || 78 | ! $this->shouldCacheCount) { 79 | return $this->getBuilder()->count(); 80 | } 81 | 82 | $cache = $this->getTaggedCache(); 83 | $cacheKey = $this->buildCacheKey().':count'; 84 | 85 | return $cache->remember( 86 | $cacheKey, 87 | Carbon::now()->addMinutes($this->getCacheExpiration()), 88 | function (): int { 89 | return $this->getBuilder()->count(); 90 | } 91 | ); 92 | } 93 | 94 | /** 95 | * Clear related caches when models change. 96 | */ 97 | public function clearRelatedCaches(string $modelClass): void 98 | { 99 | if (empty($this->cacheTags)) { 100 | return; 101 | } 102 | 103 | $taggedCache = $this->getTaggedCache(); 104 | 105 | if (method_exists($taggedCache, 'flush')) { 106 | $taggedCache->flush(); 107 | } 108 | } 109 | 110 | /** 111 | * Get a tagged cache instance if supported. 112 | */ 113 | protected function getTaggedCache(): Cache 114 | { 115 | $cache = $this->getCacheHandler(); 116 | 117 | if (method_exists($cache, 'tags') && ! empty($this->cacheTags)) { 118 | return $cache->tags($this->cacheTags); 119 | } 120 | 121 | return $cache; 122 | } 123 | 124 | /** 125 | * Analyze a query to determine if it would benefit from caching. 126 | */ 127 | protected function shouldAutomaticallyCacheQuery(): bool 128 | { 129 | // Skip caching for very simple queries that are fast anyway 130 | $simpleOperations = ['=', '>', '<', '>=', '<=']; 131 | $queryWheres = $this->getBuilder()->getQuery()->wheres ?? []; 132 | 133 | // If there's only one simple where clause, don't bother caching 134 | if (count($queryWheres) <= 1) { 135 | foreach ($queryWheres as $where) { 136 | if (isset($where['operator']) && in_array($where['operator'], $simpleOperations)) { 137 | return false; 138 | } 139 | } 140 | } 141 | 142 | // Cache if there are joins, subqueries, or multiple where clauses 143 | return count($queryWheres) > 1 || 144 | ! empty($this->getBuilder()->getQuery()->joins) || 145 | strpos($this->toSql(), 'select') !== false; 146 | } 147 | 148 | /** 149 | * Smart implementation of executeQueryWithCaching. 150 | * This method is called from InteractsWithCache::smartExecuteQueryWithCaching. 151 | */ 152 | protected function smartExecuteQueryWithCaching(): Collection 153 | { 154 | // If caching is disabled or shouldn't be used for this query 155 | if (! method_exists($this, 'hasFeature') || 156 | ! $this->hasFeature('caching') || 157 | (! $this->shouldCacheResults && ! $this->shouldAutomaticallyCacheQuery())) { 158 | return $this->getBuilder()->get(); 159 | } 160 | 161 | $cache = $this->getTaggedCache(); 162 | $cacheKey = $this->buildCacheKey(); 163 | 164 | return $cache->remember( 165 | $cacheKey, 166 | Carbon::now()->addMinutes($this->getCacheExpiration()), 167 | function (): Collection { 168 | // Log actual DB query when building cache 169 | if (method_exists($this, 'hasFeature') && 170 | method_exists($this, 'logInfo') && 171 | $this->hasFeature('logging')) { 172 | $this->logInfo('Building cache for query', [ 173 | 'sql' => $this->toSql(), 174 | 'bindings' => $this->getBuilder()->getBindings(), 175 | ]); 176 | } 177 | 178 | return $this->getBuilder()->get(); 179 | } 180 | ); 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /src/Filterable/Concerns/SupportsFilterChaining.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | protected array $customFilters = []; 16 | 17 | /** 18 | * Add a custom where clause to the query. 19 | */ 20 | public function where(string $column, mixed $operator = null, mixed $value = null): self 21 | { 22 | // Handle different parameter formats 23 | if (func_num_args() === 2) { 24 | $value = $operator; 25 | $operator = '='; 26 | } 27 | 28 | $this->customFilters[] = function (Builder $query) use ($column, $operator, $value) { 29 | return $query->where($column, $operator, $value); 30 | }; 31 | 32 | return $this; 33 | } 34 | 35 | /** 36 | * Add a custom where in clause to the query. 37 | */ 38 | public function whereIn(string $column, array $values): self 39 | { 40 | $this->customFilters[] = function (Builder $query) use ($column, $values) { 41 | return $query->whereIn($column, $values); 42 | }; 43 | 44 | return $this; 45 | } 46 | 47 | /** 48 | * Add a custom where not in clause to the query. 49 | */ 50 | public function whereNotIn(string $column, array $values): self 51 | { 52 | $this->customFilters[] = function (Builder $query) use ($column, $values) { 53 | return $query->whereNotIn($column, $values); 54 | }; 55 | 56 | return $this; 57 | } 58 | 59 | /** 60 | * Add a custom where between clause to the query. 61 | */ 62 | public function whereBetween(string $column, array $values): self 63 | { 64 | $this->customFilters[] = function (Builder $query) use ($column, $values) { 65 | return $query->whereBetween($column, $values); 66 | }; 67 | 68 | return $this; 69 | } 70 | 71 | /** 72 | * Add a custom order by clause to the query. 73 | */ 74 | public function orderBy(string $column, string $direction = 'asc'): self 75 | { 76 | $this->customFilters[] = function (Builder $query) use ($column, $direction) { 77 | return $query->orderBy($column, $direction); 78 | }; 79 | 80 | return $this; 81 | } 82 | 83 | /** 84 | * Apply all custom filters to the query. 85 | */ 86 | protected function applyCustomFilters(): void 87 | { 88 | foreach ($this->customFilters as $filter) { 89 | $filter($this->builder); 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/Filterable/Concerns/TransformsFilterValues.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | protected array $transformers = []; 13 | 14 | /** 15 | * Register a transformer for a filter. 16 | */ 17 | public function registerTransformer(string $filter, callable $transformer): self 18 | { 19 | $this->transformers[$filter] = $transformer; 20 | 21 | return $this; 22 | } 23 | 24 | /** 25 | * Transform a filter value based on registered transformers. 26 | */ 27 | protected function transformFilterValue(string $filter, mixed $value): mixed 28 | { 29 | if (isset($this->transformers[$filter])) { 30 | return call_user_func($this->transformers[$filter], $value); 31 | } 32 | 33 | return $value; 34 | } 35 | 36 | /** 37 | * Apply a transformer to each value in an array. 38 | */ 39 | protected function transformArray(array $values, callable $transformer): array 40 | { 41 | return array_map($transformer, $values); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Filterable/Concerns/ValidatesFilterInput.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | protected array $validationRules = []; 16 | 17 | /** 18 | * Validation messages. 19 | * 20 | * @var array 21 | */ 22 | protected array $validationMessages = []; 23 | 24 | /** 25 | * Set validation rules for filter inputs. 26 | */ 27 | public function setValidationRules(array $rules): self 28 | { 29 | $this->validationRules = $rules; 30 | 31 | return $this; 32 | } 33 | 34 | /** 35 | * Add a validation rule for a specific filter. 36 | */ 37 | public function addValidationRule(string $filter, string|array $rule): self 38 | { 39 | $this->validationRules[$filter] = $rule; 40 | 41 | return $this; 42 | } 43 | 44 | /** 45 | * Set validation messages. 46 | */ 47 | public function setValidationMessages(array $messages): self 48 | { 49 | $this->validationMessages = $messages; 50 | 51 | return $this; 52 | } 53 | 54 | /** 55 | * Validate the filter inputs before applying them. 56 | * 57 | * @throws ValidationException 58 | */ 59 | protected function validateFilterInputs(): void 60 | { 61 | if (empty($this->validationRules)) { 62 | return; 63 | } 64 | 65 | $filterables = $this->getFilterables(); 66 | 67 | // Only validate filters that have corresponding rules 68 | $toValidate = array_intersect_key($filterables, $this->validationRules); 69 | 70 | if (empty($toValidate)) { 71 | return; 72 | } 73 | 74 | if (method_exists($this, 'logInfo')) { 75 | $this->logInfo('Validating filter inputs', [ 76 | 'inputs' => $toValidate, 77 | 'rules' => array_intersect_key($this->validationRules, $toValidate), 78 | ]); 79 | } 80 | 81 | Validator::make($toValidate, $this->validationRules, $this->validationMessages)->validate(); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/Filterable/Console/MakeFilterCommand.php: -------------------------------------------------------------------------------- 1 | option('basic')) { 41 | return $this->resolveStubPath('/stubs/filter.basic.stub'); 42 | } 43 | 44 | if ($this->option('model')) { 45 | return $this->resolveStubPath('/stubs/filter.model.stub'); 46 | } 47 | 48 | return $this->resolveStubPath('/stubs/filter.stub'); 49 | } 50 | 51 | /** 52 | * Resolve the fully-qualified path to the stub. 53 | * 54 | * @param string $stub 55 | */ 56 | protected function resolveStubPath($stub): string 57 | { 58 | return file_exists($customPath = $this->laravel->basePath(trim($stub, '/'))) 59 | ? $customPath 60 | : __DIR__.$stub; 61 | } 62 | 63 | /** 64 | * Get the default namespace for the class. 65 | * 66 | * @param string $rootNamespace 67 | */ 68 | protected function getDefaultNamespace($rootNamespace): string 69 | { 70 | return $rootNamespace.'\Filters'; 71 | } 72 | 73 | /** 74 | * Build the class with the given name. 75 | * 76 | * @param string $name 77 | * 78 | * @throws \Illuminate\Contracts\Filesystem\FileNotFoundException 79 | */ 80 | protected function buildClass($name): string 81 | { 82 | $stub = parent::buildClass($name); 83 | 84 | if ($model = $this->option('model')) { 85 | $stub = $this->replaceModel($stub, $model); 86 | } 87 | 88 | return $stub; 89 | } 90 | 91 | /** 92 | * Replace the model for the given stub. 93 | */ 94 | protected function replaceModel(string $stub, string $model): string 95 | { 96 | $modelClass = $this->parseModel($model); 97 | 98 | $replace = [ 99 | '{{ namespacedModel }}' => $modelClass, 100 | '{{namespacedModel}}' => $modelClass, 101 | '{{ model }}' => class_basename($modelClass), 102 | '{{model}}' => class_basename($modelClass), 103 | '{{ modelVariable }}' => Str::camel(class_basename($modelClass)), 104 | '{{modelVariable}}' => Str::camel(class_basename($modelClass)), 105 | ]; 106 | 107 | return str_replace( 108 | array_keys($replace), 109 | array_values($replace), 110 | $stub 111 | ); 112 | } 113 | 114 | /** 115 | * Get the fully-qualified model class name. 116 | */ 117 | protected function parseModel(string $model): string 118 | { 119 | if (preg_match('([^A-Za-z0-9_/\\\\])', $model)) { 120 | throw new InvalidArgumentException('Model name contains invalid characters.'); 121 | } 122 | 123 | return $this->qualifyModel($model); 124 | } 125 | 126 | /** 127 | * Get the console command options. 128 | */ 129 | protected function getOptions(): array 130 | { 131 | return [ 132 | ['basic', 'b', InputOption::VALUE_NONE, 'Create a basic filter class'], 133 | ['model', 'm', InputOption::VALUE_OPTIONAL, 'Generate a filter for the given model'], 134 | ['force', 'f', InputOption::VALUE_NONE, 'Create the class even if the filter already exists'], 135 | ]; 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/Filterable/Console/stubs/filter.basic.stub: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | protected array $filters = ['name']; 16 | 17 | /** 18 | * Filter the query by a given name value. 19 | * 20 | * @param string $value 21 | * 22 | * @return \Illuminate\Database\Eloquent\Builder 23 | */ 24 | protected function name(string $value): Builder 25 | { 26 | return $this->getBuilder()->where('name_column', $value); 27 | } 28 | 29 | /** 30 | * Apply custom filtering logic here. 31 | * 32 | * @return void 33 | */ 34 | protected function applyFilterables(): void 35 | { 36 | parent::applyFilterables(); 37 | 38 | // Add custom filtering logic here if needed 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Filterable/Console/stubs/filter.model.stub: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | protected array $filters = [ 17 | 'search', 18 | 'sort', 19 | // Add model-specific filters here: 20 | // '{{ modelVariable }}_id', 21 | // 'status', 22 | // 'created_at', 23 | ]; 24 | 25 | /** 26 | * Filter by search term across multiple columns. 27 | * 28 | * @param string $value 29 | * @return Builder 30 | */ 31 | protected function search(string $value): Builder 32 | { 33 | return $this->getBuilder()->where(function ($query) use ($value) { 34 | // Add searchable fields for {{ model }}: 35 | $query->where('name', 'like', "%{$value}%"); 36 | // ->orWhere('description', 'like', "%{$value}%") 37 | // ->orWhere('email', 'like', "%{$value}%"); 38 | }); 39 | } 40 | 41 | /** 42 | * Sort the results by a given column and direction. 43 | * 44 | * @param string $value 45 | * @return Builder 46 | */ 47 | protected function sort(string $value): Builder 48 | { 49 | [$column, $direction] = array_pad(explode(':', $value), 2, 'asc'); 50 | $direction = in_array(strtolower($direction), ['asc', 'desc']) ? $direction : 'asc'; 51 | 52 | // Validate allowed columns for sorting 53 | $allowedColumns = [ 54 | 'id', 55 | 'created_at', 56 | 'updated_at', 57 | // Add model-specific sortable columns here 58 | ]; 59 | 60 | if (!in_array($column, $allowedColumns)) { 61 | $column = 'created_at'; 62 | } 63 | 64 | return $this->getBuilder()->orderBy($column, $direction); 65 | } 66 | 67 | /** 68 | * Example: Filter by {{ modelVariable }} ID 69 | * 70 | * @param int|string $value 71 | * @return Builder 72 | */ 73 | // protected function {{ modelVariable }}Id($value): Builder 74 | // { 75 | // return $this->getBuilder()->where('{{ modelVariable }}_id', $value); 76 | // } 77 | 78 | /** 79 | * Example: Filter by status 80 | * 81 | * @param string $value 82 | * @return Builder 83 | */ 84 | // protected function status(string $value): Builder 85 | // { 86 | // return $this->getBuilder()->where('status', $value); 87 | // } 88 | 89 | /** 90 | * Example: Filter by date range 91 | * 92 | * @param string $value 93 | * @return Builder 94 | */ 95 | // protected function createdAt(string $value): Builder 96 | // { 97 | // [$start, $end] = array_pad(explode(',', $value), 2, null); 98 | // 99 | // $query = $this->getBuilder(); 100 | // 101 | // if ($start) { 102 | // $query->whereDate('created_at', '>=', $start); 103 | // } 104 | // 105 | // if ($end) { 106 | // $query->whereDate('created_at', '<=', $end); 107 | // } 108 | // 109 | // return $query; 110 | // } 111 | 112 | /** 113 | * Register pre-filters for the {{ model }}. 114 | * 115 | * @return $this 116 | */ 117 | public function setupFilter(): self 118 | { 119 | // Example: Always filter active {{ modelVariable }}s 120 | // $this->registerPreFilters(function (Builder $query) { 121 | // return $query->where('is_active', true); 122 | // }); 123 | 124 | // Enable features as needed 125 | // $this->enableFeature('caching'); 126 | // $this->enableFeature('logging'); 127 | 128 | return $this; 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/Filterable/Console/stubs/filter.stub: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | protected array $filters = ['name']; 16 | 17 | /** 18 | * Filter the query by a given name value. 19 | * 20 | * @param string $value 21 | * 22 | * @return \Illuminate\Database\Eloquent\Builder 23 | */ 24 | protected function name(string $value): Builder 25 | { 26 | return $this->getBuilder()->where('name_column', $value); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Filterable/Contracts/Filter.php: -------------------------------------------------------------------------------- 1 | $filters 14 | * @property array $filterMethodMap 15 | * @property array $filterables 16 | */ 17 | interface Filter 18 | { 19 | /** 20 | * Apply the filters. 21 | */ 22 | public function apply(Builder $builder, ?array $options = []): Builder; 23 | 24 | /** 25 | * Fetch all relevant filters (key, value) from the request. 26 | * 27 | * @return array 28 | */ 29 | public function getFilterables(): array; 30 | 31 | /** 32 | * Get the registered filters. 33 | * 34 | * @return array 35 | */ 36 | public function getFilters(): array; 37 | 38 | /** 39 | * Append a filterable value to the filter. 40 | */ 41 | public function appendFilterable(string $key, mixed $value): self; 42 | 43 | /** 44 | * Filter the query to only include records for the authenticated user. 45 | */ 46 | public function forUser(?Authenticatable $user): self; 47 | 48 | /** 49 | * Get the current filters being applied. 50 | * 51 | * @return array 52 | */ 53 | public function getCurrentFilters(): array; 54 | 55 | /** 56 | * Register pre-filters to apply to the query. 57 | */ 58 | public function registerPreFilters(Closure $callback): self; 59 | 60 | /** 61 | * Apply all relevant filters to the query and present it 62 | * as a callable for use within a collection instance. 63 | * 64 | * 65 | * @see https://laravel.com/docs/10.x/collections#method-filter 66 | */ 67 | public function asCollectionFilter(): Closure; 68 | 69 | /** 70 | * Get expiration minutes for the cache. 71 | */ 72 | public function getCacheExpiration(): int; 73 | 74 | /** 75 | * Set expiration minutes for the cache. 76 | * 77 | * @param int $value Expiration minutes for the cache. 78 | */ 79 | public function setCacheExpiration(int $value): self; 80 | 81 | /** 82 | * Get the extra options for the filter. 83 | * 84 | * @return array 85 | */ 86 | public function getOptions(): array; 87 | 88 | /** 89 | * Set a specific option value. 90 | */ 91 | public function setOption(string $key, mixed $value): self; 92 | 93 | /** 94 | * Set the extra options for the filter. 95 | * 96 | * @param array $options 97 | */ 98 | public function setOptions(array $options): self; 99 | 100 | /** 101 | * Get the Eloquent builder instance. 102 | */ 103 | public function getBuilder(): ?Builder; 104 | 105 | /** 106 | * Set the Eloquent builder instance. 107 | */ 108 | public function setBuilder(Builder $builder): self; 109 | 110 | /** 111 | * Set the Logger instance. 112 | */ 113 | public function setLogger(LoggerInterface $logger): self; 114 | 115 | /** 116 | * Get the Logger instance. 117 | */ 118 | public function getLogger(): LoggerInterface; 119 | 120 | /** 121 | * Clear the cache. 122 | */ 123 | public function clearCache(): void; 124 | 125 | /** 126 | * Get the value of cache 127 | */ 128 | public function getCacheHandler(): Cache; 129 | 130 | /** 131 | * Set the value of cache 132 | */ 133 | public function setCacheHandler(Cache $cache): self; 134 | } 135 | -------------------------------------------------------------------------------- /src/Filterable/Contracts/Filterable.php: -------------------------------------------------------------------------------- 1 | 57 | */ 58 | protected array $options = []; 59 | 60 | /** 61 | * Features that are enabled for this filter. 62 | * 63 | * @var array 64 | */ 65 | protected array $features = [ 66 | 'validation' => false, 67 | 'permissions' => false, 68 | 'rateLimit' => false, 69 | 'caching' => false, 70 | 'logging' => false, 71 | 'performance' => false, 72 | 'optimization' => false, 73 | 'memoryManagement' => false, 74 | 'filterChaining' => false, 75 | 'valueTransformation' => false, 76 | ]; 77 | 78 | /** 79 | * The current state of the filter. 80 | */ 81 | protected string $state = 'initialized'; 82 | 83 | /** 84 | * Last exception encountered during filtering. 85 | */ 86 | protected ?Throwable $lastException = null; 87 | 88 | /** 89 | * Create a new filter instance. 90 | * 91 | * @return void 92 | */ 93 | public function __construct( 94 | protected Request $request, 95 | ?Cache $cache = null, 96 | ?LoggerInterface $logger = null 97 | ) { 98 | // Set up the dependencies 99 | if ($cache) { 100 | $this->setCacheHandler($cache); 101 | $this->enableFeature('caching'); 102 | } 103 | 104 | if ($logger) { 105 | $this->setLogger($logger); 106 | $this->enableFeature('logging'); 107 | } 108 | } 109 | 110 | /** 111 | * Apply the filters. 112 | */ 113 | public function apply(Builder $builder, ?array $options = []): Builder 114 | { 115 | // Ensure we're in a clean state or throw meaningful error 116 | if ($this->state !== 'initialized' && $this->state !== 'failed') { 117 | throw new RuntimeException("Filter cannot be reapplied. Current state: {$this->state}"); 118 | } 119 | 120 | $this->builder = $builder; 121 | $this->options = array_merge($this->options, $options ?? []); 122 | $this->state = 'applying'; 123 | 124 | // Start performance monitoring if enabled 125 | if ($this->hasFeature('performance')) { 126 | $this->startTiming(); 127 | } 128 | 129 | // Log start of filter application if logging is enabled 130 | if ($this->hasFeature('logging')) { 131 | $this->logInfo('Beginning filter application', [ 132 | 'filters' => $this->getFilterables(), 133 | 'options' => $this->options, 134 | ]); 135 | } 136 | 137 | // Apply query optimizations if enabled 138 | if ($this->hasFeature('optimization')) { 139 | $this->optimizeQuery(); 140 | } 141 | 142 | try { 143 | // Security checks (only if the features are enabled) 144 | if ($this->hasFeature('validation')) { 145 | $this->validateFilterInputs(); 146 | } 147 | 148 | if ($this->hasFeature('permissions')) { 149 | $this->checkFilterPermissions(); 150 | } 151 | 152 | if ($this->hasFeature('rateLimit')) { 153 | if (! $this->checkRateLimits()) { 154 | throw new RuntimeException('Filter request exceeded rate limit or complexity threshold'); 155 | } 156 | } 157 | 158 | // Apply value transformations if enabled 159 | if ($this->hasFeature('valueTransformation')) { 160 | $this->transformFilterValues(); 161 | } 162 | 163 | // Core filtering (always applied) 164 | $this->applyUserScope(); 165 | $this->applyPreFilters(); 166 | $this->applyFilterables(); 167 | 168 | // Apply custom filter chains if enabled 169 | if ($this->hasFeature('filterChaining') && ! empty($this->customFilters)) { 170 | $this->applyCustomFilters(); 171 | } 172 | 173 | $this->state = 'applied'; 174 | 175 | // Log completion of filter application if logging is enabled 176 | $this->logInfo('Filter application completed', [ 177 | 'applied_filters' => $this->getCurrentFilters(), 178 | ]); 179 | } catch (Throwable $e) { 180 | $this->state = 'failed'; 181 | $this->lastException = $e; 182 | 183 | // Log error if logging is enabled 184 | $this->logWarning('Error applying filters', [ 185 | 'error' => $e->getMessage(), 186 | 'trace' => $e->getTraceAsString(), 187 | ]); 188 | 189 | // Always rethrow validation exceptions 190 | if ($e instanceof ValidationException) { 191 | throw $e; 192 | } 193 | 194 | // For other exceptions, let subclasses decide whether to rethrow 195 | $this->handleFilteringException($e); 196 | } 197 | 198 | // End performance monitoring if enabled 199 | if ($this->hasFeature('performance')) { 200 | $this->endTiming(); 201 | } 202 | 203 | return $this->builder; 204 | } 205 | 206 | /** 207 | * Execute the query and get the results. 208 | */ 209 | public function get(): Collection 210 | { 211 | if ($this->state === 'initialized') { 212 | throw new RuntimeException('You must call apply() before get()'); 213 | } 214 | 215 | if ($this->state === 'failed') { 216 | throw new RuntimeException( 217 | 'Filters failed to apply: '.$this->lastException?->getMessage(), 218 | 0, 219 | $this->lastException 220 | ); 221 | } 222 | 223 | // Use cached query execution if caching is enabled 224 | if ($this->hasFeature('caching')) { 225 | return $this->executeQueryWithCaching(); 226 | } 227 | 228 | // Use memory-efficient processing for large datasets if enabled 229 | if ($this->hasFeature('memoryManagement') && ($this->options['chunk_size'] ?? false)) { 230 | return $this->executeQueryWithMemoryManagement(); 231 | } 232 | 233 | // Otherwise, just execute the query 234 | return $this->getBuilder()->get(); 235 | } 236 | 237 | /** 238 | * Run the full filter pipeline and get results in one step. 239 | */ 240 | public function runQuery(Builder $builder, ?array $options = []): Collection 241 | { 242 | $this->apply($builder, $options); 243 | 244 | return $this->get(); 245 | } 246 | 247 | /** 248 | * Enable a specific feature. 249 | */ 250 | public function enableFeature(string $feature): self 251 | { 252 | if (array_key_exists($feature, $this->features)) { 253 | $this->features[$feature] = true; 254 | } 255 | 256 | return $this; 257 | } 258 | 259 | /** 260 | * Enable multiple features at once. 261 | */ 262 | public function enableFeatures(array $features): self 263 | { 264 | foreach ($features as $feature) { 265 | $this->enableFeature($feature); 266 | } 267 | 268 | return $this; 269 | } 270 | 271 | /** 272 | * Disable a specific feature. 273 | */ 274 | public function disableFeature(string $feature): self 275 | { 276 | if (array_key_exists($feature, $this->features)) { 277 | $this->features[$feature] = false; 278 | } 279 | 280 | return $this; 281 | } 282 | 283 | /** 284 | * Check if a feature is enabled. 285 | */ 286 | public function hasFeature(string $feature): bool 287 | { 288 | return $this->features[$feature] ?? false; 289 | } 290 | 291 | /** 292 | * Get execution statistics and query info for debugging. 293 | */ 294 | public function getDebugInfo(): array 295 | { 296 | $debugInfo = [ 297 | 'state' => $this->state, 298 | 'filters_applied' => $this->getCurrentFilters(), 299 | 'features_enabled' => array_filter($this->features), 300 | 'options' => $this->options, 301 | ]; 302 | 303 | // Add SQL info if we have a builder 304 | if ($this->builder) { 305 | $debugInfo['sql'] = $this->getBuilder()->toSql(); 306 | $debugInfo['bindings'] = $this->getBuilder()->getBindings(); 307 | } 308 | 309 | // Add performance metrics if available 310 | if ($this->hasFeature('performance')) { 311 | $debugInfo['metrics'] = $this->getMetrics(); 312 | } 313 | 314 | return $debugInfo; 315 | } 316 | 317 | /** 318 | * Get the Eloquent builder instance. 319 | */ 320 | public function getBuilder(): ?Builder 321 | { 322 | return $this->builder; 323 | } 324 | 325 | /** 326 | * Set the Eloquent builder instance. 327 | */ 328 | public function setBuilder(Builder $builder): self 329 | { 330 | $this->builder = $builder; 331 | 332 | return $this; 333 | } 334 | 335 | /** 336 | * Get the extra options for the filter. 337 | * 338 | * @return array 339 | */ 340 | public function getOptions(): array 341 | { 342 | return $this->options; 343 | } 344 | 345 | /** 346 | * Set a specific option value. 347 | */ 348 | public function setOption(string $key, mixed $value): self 349 | { 350 | $this->options[$key] = $value; 351 | 352 | return $this; 353 | } 354 | 355 | /** 356 | * Set the extra options for the filter. 357 | * 358 | * @param array $options 359 | */ 360 | public function setOptions(array $options): self 361 | { 362 | foreach ($options as $key => $value) { 363 | $this->setOption($key, $value); 364 | } 365 | 366 | return $this; 367 | } 368 | 369 | /** 370 | * Reset the filter to its initial state. 371 | */ 372 | public function reset(): self 373 | { 374 | $this->state = 'initialized'; 375 | $this->builder = null; 376 | $this->lastException = null; 377 | $this->filterables = []; 378 | $this->currentFilters = []; 379 | $this->customFilters = []; 380 | 381 | return $this; 382 | } 383 | 384 | /** 385 | * Transform filter values if transformers are registered. 386 | * This method is called before applying filters if valueTransformation is enabled. 387 | */ 388 | protected function transformFilterValues(): void 389 | { 390 | if (empty($this->transformers)) { 391 | return; 392 | } 393 | 394 | $filterables = $this->getFilterables(); 395 | 396 | foreach ($filterables as $filter => $value) { 397 | if (isset($this->transformers[$filter])) { 398 | $this->filterables[$filter] = $this->transformFilterValue($filter, $value); 399 | } 400 | } 401 | } 402 | 403 | /** 404 | * Handle exceptions that occur during filtering. 405 | * Subclasses can override this method to customize exception handling. 406 | */ 407 | protected function handleFilteringException(Throwable $exception): void 408 | { 409 | // By default, don't rethrow the exception 410 | // Subclasses can override this to change behavior 411 | } 412 | } 413 | -------------------------------------------------------------------------------- /src/Filterable/Providers/FilterableServiceProvider.php: -------------------------------------------------------------------------------- 1 | name('jerome/filterable') 17 | ->hasCommand(MakeFilterCommand::class); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Filterable/Traits/Filterable.php: -------------------------------------------------------------------------------- 1 | apply($query, $options); 22 | } 23 | } 24 | --------------------------------------------------------------------------------