diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..dd9a2b5 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.{yml,yaml}] +indent_size = 2 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..b521a81 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,21 @@ +# Path-based git attributes +# https://www.kernel.org/pub/software/scm/git/docs/gitattributes.html + +# Ignore all test and documentation with "export-ignore". +/.github export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/phpunit.xml.dist export-ignore +/art export-ignore +/docs export-ignore +/tests export-ignore +/.editorconfig export-ignore +/.php_cs.dist.php export-ignore +/psalm.xml export-ignore +/psalm.xml.dist export-ignore +/testbench.yaml export-ignore +/UPGRADING.md export-ignore +/phpstan.neon.dist export-ignore +/phpstan-baseline.neon export-ignore +/docker export-ignore +/docker-compose.yml export-ignore diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000..fe4cfe6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,66 @@ +name: Bug Report +description: Report an Issue or Bug with the Package +title: "[Bug]: " +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + We're sorry to hear you have a problem. Can you help us solve it by providing the following details. + - type: textarea + id: what-happened + attributes: + label: What happened? + description: What did you expect to happen? + placeholder: I cannot currently do X thing because when I do, it breaks X thing. + validations: + required: true + - type: textarea + id: how-to-reproduce + attributes: + label: How to reproduce the bug + description: How did this occur, please add any config values used and provide a set of reliable steps if possible. + placeholder: When I do X I see Y. + validations: + required: true + - type: input + id: package-version + attributes: + label: Package Version + description: What version of our Package are you running? Please be as specific as possible + placeholder: 2.0.0 + validations: + required: true + - type: input + id: php-version + attributes: + label: PHP Version + description: What version of PHP are you running? Please be as specific as possible + placeholder: 8.2.0 + validations: + required: true + - type: input + id: laravel-version + attributes: + label: Laravel Version + description: What version of Laravel are you running? Please be as specific as possible + placeholder: 9.0.0 + validations: + required: true + - type: dropdown + id: operating-systems + attributes: + label: Which operating systems does with happen with? + description: You may select more than one. + multiple: true + options: + - macOS + - Windows + - Linux + - type: textarea + id: notes + attributes: + label: Notes + description: Use this field to provide any other notes that you feel might be relevant to the issue. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..14d37f1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,11 @@ +blank_issues_enabled: false +contact_links: + - name: Ask a question + url: https://github.com/teamq-ec/teamq-laravel-spatie-filters/discussions/new?category=q-a + about: Ask the community for help + - name: Request a feature + url: https://github.com/teamq-ec/teamq-laravel-spatie-filters/discussions/new?category=ideas + about: Share ideas for new features + - name: Report a security issue + url: https://github.com/teamq-ec/teamq-laravel-spatie-filters/security/policy + about: Learn how to notify us for sensitive bugs diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..30c8a49 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +# Please see the documentation for all configuration options: +# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + labels: + - "dependencies" \ No newline at end of file diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml new file mode 100644 index 0000000..ca2197d --- /dev/null +++ b/.github/workflows/dependabot-auto-merge.yml @@ -0,0 +1,32 @@ +name: dependabot-auto-merge +on: pull_request_target + +permissions: + pull-requests: write + contents: write + +jobs: + dependabot: + runs-on: ubuntu-latest + if: ${{ github.actor == 'dependabot[bot]' }} + steps: + + - name: Dependabot metadata + id: metadata + uses: dependabot/fetch-metadata@v1.6.0 + with: + github-token: "${{ secrets.GITHUB_TOKEN }}" + + - name: Auto-merge Dependabot PRs for semver-minor updates + if: ${{steps.metadata.outputs.update-type == 'version-update:semver-minor'}} + run: gh pr merge --auto --merge "$PR_URL" + env: + PR_URL: ${{github.event.pull_request.html_url}} + GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} + + - name: Auto-merge Dependabot PRs for semver-patch updates + if: ${{steps.metadata.outputs.update-type == 'version-update:semver-patch'}} + run: gh pr merge --auto --merge "$PR_URL" + env: + PR_URL: ${{github.event.pull_request.html_url}} + GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} diff --git a/.github/workflows/fix-php-code-style-issues.yml b/.github/workflows/fix-php-code-style-issues.yml new file mode 100644 index 0000000..7520a18 --- /dev/null +++ b/.github/workflows/fix-php-code-style-issues.yml @@ -0,0 +1,27 @@ +name: Fix PHP code style issues + +on: + push: + paths: + - '**.php' + +permissions: + contents: write + +jobs: + php-code-styling: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + with: + ref: ${{ github.head_ref }} + + - name: Fix PHP code style issues + uses: aglipanci/laravel-pint-action@2.3.0 + + - name: Commit changes + uses: stefanzweifel/git-auto-commit-action@v4 + with: + commit_message: Fix styling diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml new file mode 100644 index 0000000..924974a --- /dev/null +++ b/.github/workflows/run-tests.yml @@ -0,0 +1,76 @@ +name: run-tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: true + matrix: + os: [ ubuntu-latest ] + php: [ 8.2, 8.1 ] + laravel: [ 10.* ] + stability: [prefer-lowest, prefer-stable] + include: + - laravel: 10.* + testbench: 8.* + carbon: ^2.63 + + name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.stability }} - ${{ matrix.os }} + + services: + mysql: + image: mysql:8.0 + env: + MYSQL_USER: user + MYSQL_PASSWORD: secret + MYSQL_DATABASE: laravel-query-builder-powered + MYSQL_ROOT_PASSWORD: root + ports: + - 3306 + options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 + + redis: + image: redis + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo + coverage: none + + - name: Setup problem matchers + run: | + echo "::add-matcher::${{ runner.tool_cache }}/php.json" + echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" + + - name: Install dependencies + run: | + composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" "nesbot/carbon:${{ matrix.carbon }}" --no-interaction --no-update + composer update --${{ matrix.stability }} --prefer-dist --no-interaction + + - name: Execute tests + run: vendor/bin/pest --ci + env: + DB_HOST: 127.0.0.1 + DB_PORT: ${{ job.services.mysql.ports[3306] }} + DB_USERNAME: user + DB_PASSWORD: secret + REDIS_PORT: 6379 diff --git a/.github/workflows/update-changelog.yml b/.github/workflows/update-changelog.yml new file mode 100644 index 0000000..8c12ba9 --- /dev/null +++ b/.github/workflows/update-changelog.yml @@ -0,0 +1,31 @@ +name: "Update Changelog" + +on: + release: + types: [released] + +permissions: + contents: write + +jobs: + update: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + with: + ref: main + + - name: Update Changelog + uses: stefanzweifel/changelog-updater-action@v1 + with: + latest-version: ${{ github.event.release.name }} + release-notes: ${{ github.event.release.body }} + + - name: Commit updated CHANGELOG + uses: stefanzweifel/git-auto-commit-action@v4 + with: + branch: main + commit_message: Update CHANGELOG + file_pattern: CHANGELOG.md diff --git a/.gitignore b/.gitignore index 297959a..a7f372d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,23 +1,11 @@ -/vendor/ -node_modules/ -npm-debug.log -yarn-error.log - -# Laravel 4 specific -bootstrap/compiled.php -app/storage/ - -# Laravel 5 & Lumen specific -public/storage -public/hot - -# Laravel 5 & Lumen specific with changed public path -public_html/storage -public_html/hot - -storage/*.key -.env -Homestead.yaml -Homestead.json -/.vagrant -.phpunit.result.cache +.idea +.phpunit.cache +build +composer.lock +coverage +docs +phpunit.xml +phpstan.neon +testbench.yaml +vendor +node_modules diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..bce2475 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +# Changelog + +All notable changes to `laravel-query-builder-powered` will be documented in this file. diff --git a/LICENSE b/LICENSE.md similarity index 85% rename from LICENSE rename to LICENSE.md index 0293ad9..15f3387 100644 --- a/LICENSE +++ b/LICENSE.md @@ -1,6 +1,6 @@ -MIT License +The MIT License (MIT) -Copyright (c) 2023 TeamQ +Copyright (c) luilliarcec Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -9,13 +9,13 @@ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md index ef5bda3..743ec52 100644 --- a/README.md +++ b/README.md @@ -1 +1,62 @@ -# teamq-laravel-spatie-filters \ No newline at end of file +# Laravel Spatie Filters + +[![Latest Version on Packagist](https://img.shields.io/packagist/v/teamq-ec/teamq-laravel-spatie-filters.svg?style=flat-square)](https://packagist.org/packages/teamq-ec/teamq-laravel-spatie-filters) +[![GitHub Tests Action Status](https://img.shields.io/github/actions/workflow/status/teamq-ec/teamq-laravel-spatie-filters/run-tests.yml?branch=main&label=tests&style=flat-square)](https://github.com/teamq-ec/teamq-laravel-spatie-filters/actions?query=workflow%3Arun-tests+branch%3Amain) +[![GitHub Code Style Action Status](https://img.shields.io/github/actions/workflow/status/teamq-ec/teamq-laravel-spatie-filters/fix-php-code-style-issues.yml?branch=main&label=code%20style&style=flat-square)](https://github.com/teamq-ec/teamq-laravel-spatie-filters/actions?query=workflow%3A"Fix+PHP+code+style+issues"+branch%3Amain) +[![Total Downloads](https://img.shields.io/packagist/dt/teamq-ec/teamq-laravel-spatie-filters.svg?style=flat-square)](https://packagist.org/packages/teamq-ec/teamq-laravel-spatie-filters) + +This is where your description should go. Limit it to a paragraph or two. Consider adding a small example. + +## Installation + +You can install the package via composer: + +```bash +composer require teamq/laravel-spatie-filters +``` + +You can publish the config file with: + +```bash +php artisan vendor:publish --tag="laravel-spatie-filters-config" +``` + +This is the contents of the published config file: + +```php +return [ + 'per_page' => [ + // Represents the value to be sent by the user, to obtain all records, if using the result method. + 'all' => 'all', + ], +]; +``` + +## Usage + +WIP + +## Testing + +Can use docker-compose to run + +```bash +docker-compose exec app composer test +``` + +## Changelog + +Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. + +## Security Vulnerabilities + +Please review [our security policy](../../security/policy) on how to report security vulnerabilities. + +## Credits + +- [Luis Arce](https://github.com/luilliarcec) +- [All Contributors](../../contributors) + +## License + +The MIT License (MIT). Please see [License File](LICENSE.md) for more information. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..9526e43 --- /dev/null +++ b/composer.json @@ -0,0 +1,73 @@ +{ + "name": "teamq/laravel-spatie-filters", + "description": "Custom filter and sorting set for 'spatie/laravel-query-builder' package", + "keywords": [ + "teamq", + "laravel", + "filters", + "sortings", + "spatie/laravel-query-builder" + ], + "homepage": "https://github.com/teamq-ec/teamq-laravel-spatie-filters", + "license": "MIT", + "authors": [ + { + "name": "Luis Arce", + "email": "laa@teamq.biz", + "role": "Developer" + } + ], + "require": { + "php": "^8.1", + "illuminate/contracts": "^10.0", + "kirschbaum-development/eloquent-power-joins": "^3.2", + "spatie/laravel-package-tools": "^1.14.0", + "spatie/laravel-query-builder": "^5.2" + }, + "require-dev": { + "roave/security-advisories": "dev-latest", + "laravel/pint": "^1.0", + "nunomaduro/collision": "^7.9", + "nunomaduro/larastan": "^2.0.1", + "orchestra/testbench": "^8.0", + "pestphp/pest": "^2.0", + "pestphp/pest-plugin-arch": "^2.0", + "pestphp/pest-plugin-laravel": "^2.0", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan-deprecation-rules": "^1.0", + "phpstan/phpstan-phpunit": "^1.0" + }, + "autoload": { + "psr-4": { + "TeamQ\\QueryBuilder\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Tests\\": "tests/" + } + }, + "scripts": { + "post-autoload-dump": "@php ./vendor/bin/testbench package:discover --ansi", + "analyse": "vendor/bin/phpstan analyse", + "test": "vendor/bin/pest", + "test-coverage": "vendor/bin/pest --coverage", + "format": "vendor/bin/pint" + }, + "config": { + "sort-packages": true, + "allow-plugins": { + "pestphp/pest-plugin": true, + "phpstan/extension-installer": true + } + }, + "extra": { + "laravel": { + "providers": [ + "QueryBuilderServiceProvider" + ] + } + }, + "minimum-stability": "dev", + "prefer-stable": true +} diff --git a/config/teamq-query-builder.php b/config/teamq-query-builder.php new file mode 100644 index 0000000..1cc2459 --- /dev/null +++ b/config/teamq-query-builder.php @@ -0,0 +1,8 @@ + [ + // Represents the value to be sent by the user, to obtain all records, if using the result method. + 'all' => 'all', + ], +]; diff --git a/configure.php b/configure.php new file mode 100644 index 0000000..d7e0fcc --- /dev/null +++ b/configure.php @@ -0,0 +1,266 @@ +#!/usr/bin/env php + $version) { + if (in_array($name, $names, true)) { + unset($data['require-dev'][$name]); + } + } + + file_put_contents(__DIR__.'/composer.json', json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); +} + +function remove_composer_script($scriptName) +{ + $data = json_decode(file_get_contents(__DIR__.'/composer.json'), true); + + foreach ($data['scripts'] as $name => $script) { + if ($scriptName === $name) { + unset($data['scripts'][$name]); + break; + } + } + + file_put_contents(__DIR__.'/composer.json', json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); +} + +function remove_readme_paragraphs(string $file): void +{ + $contents = file_get_contents($file); + + file_put_contents( + $file, + preg_replace('/.*/s', '', $contents) ?: $contents + ); +} + +function safeUnlink(string $filename) +{ + if (file_exists($filename) && is_file($filename)) { + unlink($filename); + } +} + +function determineSeparator(string $path): string +{ + return str_replace('/', DIRECTORY_SEPARATOR, $path); +} + +function replaceForWindows(): array +{ + return preg_split('/\\r\\n|\\r|\\n/', run('dir /S /B * | findstr /v /i .git\ | findstr /v /i vendor | findstr /v /i '.basename(__FILE__).' | findstr /r /i /M /F:/ ":author :vendor :package VendorName skeleton migration_table_name vendor_name vendor_slug author@domain.com"')); +} + +function replaceForAllOtherOSes(): array +{ + return explode(PHP_EOL, run('grep -E -r -l -i ":author|:vendor|:package|VendorName|skeleton|migration_table_name|vendor_name|vendor_slug|author@domain.com" --exclude-dir=vendor ./* ./.github/* | grep -v '.basename(__FILE__))); +} + +$gitName = run('git config user.name'); +$authorName = ask('Author name', $gitName); + +$gitEmail = run('git config user.email'); +$authorEmail = ask('Author email', $gitEmail); + +$usernameGuess = explode(':', run('git config remote.origin.url'))[1]; +$usernameGuess = dirname($usernameGuess); +$usernameGuess = basename($usernameGuess); +$authorUsername = ask('Author username', $usernameGuess); + +$vendorName = ask('Vendor name', $authorUsername); +$vendorSlug = slugify($vendorName); +$vendorNamespace = str_replace('-', '', ucwords($vendorName)); +$vendorNamespace = ask('Vendor namespace', $vendorNamespace); + +$currentDirectory = getcwd(); +$folderName = basename($currentDirectory); + +$packageName = ask('Package name', $folderName); +$packageSlug = slugify($packageName); +$packageSlugWithoutPrefix = remove_prefix('laravel-', $packageSlug); + +$className = title_case($packageName); +$className = ask('Class name', $className); +$variableName = lcfirst($className); +$description = ask('Package description', "This is my package {$packageSlug}"); + +$usePhpStan = confirm('Enable PhpStan?', true); +$useLaravelPint = confirm('Enable Laravel Pint?', true); +$useDependabot = confirm('Enable Dependabot?', true); +$useLaravelRay = confirm('Use Ray for debugging?', true); +$useUpdateChangelogWorkflow = confirm('Use automatic changelog updater workflow?', true); + +writeln('------'); +writeln("Author : {$authorName} ({$authorUsername}, {$authorEmail})"); +writeln("Vendor : {$vendorName} ({$vendorSlug})"); +writeln("Package : {$packageSlug} <{$description}>"); +writeln("Namespace : {$vendorNamespace}\\{$className}"); +writeln("Class name : {$className}"); +writeln('---'); +writeln('Packages & Utilities'); +writeln('Use Laravel/Pint : '.($useLaravelPint ? 'yes' : 'no')); +writeln('Use Larastan/PhpStan : '.($usePhpStan ? 'yes' : 'no')); +writeln('Use Dependabot : '.($useDependabot ? 'yes' : 'no')); +writeln('Use Ray App : '.($useLaravelRay ? 'yes' : 'no')); +writeln('Use Auto-Changelog : '.($useUpdateChangelogWorkflow ? 'yes' : 'no')); +writeln('------'); + +writeln('This script will replace the above values in all relevant files in the project directory.'); + +if (! confirm('Modify files?', true)) { + exit(1); +} + +$files = (str_starts_with(strtoupper(PHP_OS), 'WIN') ? replaceForWindows() : replaceForAllOtherOSes()); + +foreach ($files as $file) { + replace_in_file($file, [ + ':author_name' => $authorName, + ':author_username' => $authorUsername, + 'author@domain.com' => $authorEmail, + ':vendor_name' => $vendorName, + ':vendor_slug' => $vendorSlug, + 'VendorName' => $vendorNamespace, + ':package_name' => $packageName, + ':package_slug' => $packageSlug, + ':package_slug_without_prefix' => $packageSlugWithoutPrefix, + 'Skeleton' => $className, + 'skeleton' => $packageSlug, + 'migration_table_name' => title_snake($packageSlug), + 'variable' => $variableName, + ':package_description' => $description, + ]); + + match (true) { + str_contains($file, determineSeparator('src/Skeleton.php')) => rename($file, determineSeparator('./src/'.$className.'.php')), + str_contains($file, determineSeparator('src/SkeletonServiceProvider.php')) => rename($file, determineSeparator('./src/'.$className.'ServiceProvider.php')), + str_contains($file, determineSeparator('src/Facades/Skeleton.php')) => rename($file, determineSeparator('./src/Facades/'.$className.'.php')), + str_contains($file, determineSeparator('src/Commands/SkeletonCommand.php')) => rename($file, determineSeparator('./src/Commands/'.$className.'Command.php')), + str_contains($file, determineSeparator('database/migrations/create_skeleton_table.php.stub')) => rename($file, determineSeparator('./database/migrations/create_'.title_snake($packageSlugWithoutPrefix).'_table.php.stub')), + str_contains($file, determineSeparator('config/skeleton.php')) => rename($file, determineSeparator('./config/'.$packageSlugWithoutPrefix.'.php')), + str_contains($file, 'README.md') => remove_readme_paragraphs($file), + default => [], + }; +} + +if (! $useLaravelPint) { + safeUnlink(__DIR__.'/.github/workflows/fix-php-code-style-issues.yml'); + safeUnlink(__DIR__.'/pint.json'); +} + +if (! $usePhpStan) { + safeUnlink(__DIR__.'/phpstan.neon.dist'); + safeUnlink(__DIR__.'/phpstan-baseline.neon'); + safeUnlink(__DIR__.'/.github/workflows/phpstan.yml'); + + remove_composer_deps([ + 'phpstan/extension-installer', + 'phpstan/phpstan-deprecation-rules', + 'phpstan/phpstan-phpunit', + 'nunomaduro/larastan', + ]); + + remove_composer_script('phpstan'); +} + +if (! $useDependabot) { + safeUnlink(__DIR__.'/.github/dependabot.yml'); + safeUnlink(__DIR__.'/.github/workflows/dependabot-auto-merge.yml'); +} + +if (! $useLaravelRay) { + remove_composer_deps(['spatie/laravel-ray']); +} + +if (! $useUpdateChangelogWorkflow) { + safeUnlink(__DIR__.'/.github/workflows/update-changelog.yml'); +} + +confirm('Execute `composer install` and run tests?') && run('composer install && composer test'); + +confirm('Let this script delete itself?', true) && unlink(__FILE__); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3eb976b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,30 @@ +version: "3.6" + +services: + app: + build: + context: . + dockerfile: docker/Dockerfile + container_name: laravel-query-builder-powered + volumes: + - ".:/var/www/html" + depends_on: + - mysql + mysql: + image: 'mysql/mysql-server:8.0' + ports: + - '${FORWARD_DB_PORT:-3306}:3306' + environment: + MYSQL_ROOT_PASSWORD: 'root' + MYSQL_ROOT_HOST: "%" + MYSQL_DATABASE: 'laravel-query-builder-powered' + MYSQL_USER: 'laravel' + MYSQL_PASSWORD: 'laravel' + MYSQL_ALLOW_EMPTY_PASSWORD: 1 + healthcheck: + test: + - CMD + - mysqladmin + - ping + retries: 3 + timeout: 5s diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..5d87581 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,28 @@ +# Base image +FROM php:8.2-alpine + +# Create app folder +RUN mkdir -p /var/www/html/ + +# Set working directory +WORKDIR /var/www/html/ + +# Install dependencies +RUN apk --update add \ + mysql-client + +# Install PHP extensions +RUN docker-php-ext-install pdo_mysql + +## Copy Laravel application files +COPY . . + +# Install Composer +ENV COMPOSER_ALLOW_SUPERUSER=1 +COPY --from=composer:latest /usr/bin/composer /usr/local/bin/composer + +## Install application dependencies +RUN composer update --optimize-autoloader + +# Keeping the container active +CMD [ "tail", "-f", "/dev/null" ] diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon new file mode 100644 index 0000000..e69de29 diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..a91953b --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,14 @@ +includes: + - phpstan-baseline.neon + +parameters: + level: 4 + paths: + - src + - config + - database + tmpDir: build/phpstan + checkOctaneCompatibility: true + checkModelProperties: true + checkMissingIterableValueType: false + diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..c8ff637 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,38 @@ + + + + + tests + + + + + + + + ./src + + + + + + + + + + diff --git a/src/Concerns/HasPropertyRelationship.php b/src/Concerns/HasPropertyRelationship.php new file mode 100644 index 0000000..ccd7a7d --- /dev/null +++ b/src/Concerns/HasPropertyRelationship.php @@ -0,0 +1,57 @@ +relationConstraints, true)) { + return false; + } + + $firstRelationship = explode('.', $property)[0]; + + if (! method_exists($query->getModel(), $firstRelationship)) { + return false; + } + + return is_a($query->getModel()->{$firstRelationship}(), Relation::class); + } + + /** + * Get the constraint parts [$relation, $property] of the given property. + */ + protected function getConstraintParts(string $property): array + { + return collect(explode('.', $property)) + ->pipe(function (Collection $parts) { + return [ + $parts->except(count($parts) - 1)->implode('.'), + $parts->last(), + ]; + }); + } +} diff --git a/src/Concerns/PerPageQuery.php b/src/Concerns/PerPageQuery.php new file mode 100644 index 0000000..acc78bb --- /dev/null +++ b/src/Concerns/PerPageQuery.php @@ -0,0 +1,113 @@ +isPerPageAll($paramName)) { + return $this->get($columns); + } + + return $this->paginate($perPage, $columns, $pageName, $page); + } + + /** + * {@inheritdoc} + */ + public function paginate($perPage = null, $columns = ['*'], $pageName = 'page', $page = null): LengthAwarePaginator + { + $paramName = config('query-builder.parameters.per_page', 'per_page'); + + if ($this->validatePerPageQueryParam($paramName)) { + $perPage = $this->request->input($paramName); + } + + return parent::paginate($perPage, $columns, $pageName, $page); + } + + /** + * Set the minimum amount per page. + * + * @return $this + */ + public function setMinPerPage(int $perPage): static + { + $this->minPerPage = $perPage; + + return $this; + } + + /** + * Set the maximum amount per page. + * + * @return $this + */ + public function setMaxPerPage(int $perPage): static + { + $this->maxPerPage = $perPage; + + return $this; + } + + /** + * Check that the PerPage parameter of the query is a valid value. + */ + protected function validatePerPageQueryParam(string $paramName): bool + { + if ( + ! $this->request->has($paramName) || + ! is_numeric($this->request->input($paramName)) + ) { + return false; + } + + if ( + ($this->request->input($paramName) < $this->minPerPage) || + ($this->maxPerPage && $this->request->input($paramName) > $this->maxPerPage) + ) { + return false; + } + + return true; + } + + /** + * Validates that the per page all is allowed and present in the request. + */ + protected function isPerPageAll(string $paramName): bool + { + return + $this->maxPerPage === null && + $this->request->query($paramName) === config('teamq-query-builder.per_page.all'); + } +} diff --git a/src/Enums/AggregationType.php b/src/Enums/AggregationType.php new file mode 100644 index 0000000..b84901b --- /dev/null +++ b/src/Enums/AggregationType.php @@ -0,0 +1,17 @@ + $query + ->whereDate($property, '!=', $value, $boolean), + + Comparators\Number::GreaterThan => $query + ->whereDate($property, '>', $value, $boolean), + + Comparators\Number::GreaterThanOrEqual => $query + ->whereDate($property, '>=', $value, $boolean), + + Comparators\Number::LessThan => $query + ->whereDate($property, '<', $value, $boolean), + + Comparators\Number::LessThanOrEqual => $query + ->whereDate($property, '<=', $value, $boolean), + + Comparators\Number::Between => $query + ->whereBetween($property, $value, $boolean), + + Comparators\Number::NotBetween => $query + ->whereNotBetween($property, $value, $boolean), + + Comparators\Number::In => $query + ->whereIn($property, $value, $boolean), + + Comparators\Number::NotIn => $query + ->whereNotIn($property, $value, $boolean), + + Comparators\Number::Filled => $query + ->whereNotNull($property, $boolean), + + Comparators\Number::NotFilled => $query + ->whereNull($property, $boolean), + + default => $query + ->whereDate($property, '=', $value, $boolean), + }; + } + + /** + * {@inheritdoc} + */ + protected function validate(mixed $value, mixed $operator): mixed + { + if (is_array($value)) { + $value = array_filter($value, 'strtotime'); + + return count($value) > 0 ? $value : false; + } + + if (is_null($value) || strtotime($value) !== false) { + return $value; + } + + return false; + } +} diff --git a/src/Filters/Filter.php b/src/Filters/Filter.php new file mode 100644 index 0000000..414ca3d --- /dev/null +++ b/src/Filters/Filter.php @@ -0,0 +1,135 @@ +addRelationConstraint = $addRelationConstraint; + $this->joinType = $joinType; + } + + /** + * {@inheritDoc} + */ + public function __invoke(Builder $query, $value, string $property): void + { + if ($this->addRelationConstraint && $this->isRelationProperty($query, $property)) { + $this->withRelationConstraint($query, $value, $property); + + return; + } + + if (! $this->addRelationConstraint && $this->joinType && $this->isRelationProperty($query, $property)) { + $this->withJoinConstraint($query, $value, $property); + + return; + } + + [$value, $operator] = $this->getPayloadData($value); + + $property = $query->qualifyColumn($property); + + if (isset($value) || isset($operator)) { + $value = $this->validate($value, $operator); + + if ($value !== false) { + if (is_array($value)) { + if (in_array($operator, static::ARRAY_OPERATORS, true)) { + $this->handle($query, $value, $property, $operator); + } else { + $query->where(function (Builder $query) use ($value, $property, $operator) { + foreach ($value as $item) { + $this->handle($query, $item, $property, $operator, 'or'); + } + }); + } + + return; + } + + $this->handle($query, $value, $property, $operator); + } + } + } + + /** + * Applies the filter within an Eloquent relationship. + */ + protected function withRelationConstraint(Builder $query, $value, string $property): void + { + [$relationName, $property] = $this->getConstraintParts($property); + + $query->whereHas($relationName, function (Builder $query) use ($value, $property) { + $this->relationConstraints[] = $property = $query->qualifyColumn($property); + + $this->__invoke($query, $value, $property); + }); + } + + /** + * Applies the filter within an PowerJoin relationship. + */ + protected function withJoinConstraint(Builder $query, $value, string $property): void + { + [$relationName, $property] = $this->getConstraintParts($property); + + $relation = $query; + + foreach (explode('.', $relationName) as $partial) { + $relation = $relation->getRelation($partial); + } + + $query->joinRelationship($relationName, joinType: $this->joinType->value); + + $this->relationConstraints[] = $property = $relation->qualifyColumn($property); + + $this->__invoke($query, $value, $property); + } + + /** + * Validates the data in the payload and returns the data. + * + * @return mixed false if the data is invalid + */ + protected function validate(mixed $value, mixed $operator): mixed + { + return $value; + } +} diff --git a/src/Filters/GlobalFilter.php b/src/Filters/GlobalFilter.php new file mode 100644 index 0000000..54ef94d --- /dev/null +++ b/src/Filters/GlobalFilter.php @@ -0,0 +1,208 @@ + $fields + */ + public function __construct(array $fields, bool $addRelationConstraint = true, JoinType $joinType = null) + { + $this->fields = $fields; + $this->addRelationConstraint = $addRelationConstraint; + $this->joinType = $joinType; + } + + /** + * Sets a shortcut to register the filter. + */ + public static function allowed( + array $fields, + bool $addRelationConstraint = true, + JoinType $joinType = null, + string $name = 'global', + ): AllowedFilter { + return AllowedFilter::custom($name, new static($fields, $addRelationConstraint, $joinType)); + } + + /** + * {@inheritDoc} + */ + public function __invoke(Builder $query, $value, string $property): void + { + // Validate the payload value + if (! $this->validate($value)) { + return; + } + + // Converts the payload value to lowercase to be filtered + $value = mb_strtolower($value, 'UTF8'); + + // Get valid fields. + $fields = collect($this->fields) + ->filter(fn ($field) => is_string($field) || $field instanceof Expression); + + // Apply join relationships for filters + if (! $this->addRelationConstraint && $this->joinType) { + $fields + ->filter(fn ($field) => $this->isRelationProperty($query, $field)) + ->each(fn ($field) => $this->setJoinsRelationship($query, $field)); + } + + // Everything is wrapped inside the same `where` condition to isolate + // the rest of the `where` conditions that the query may have. + $query->where(function (Builder $query) use ($value, $fields) { + if ($this->addRelationConstraint) { + // Extracts the properties grouped by the relationships, e.g.: + // "authors" => [ "name", "email" ] + $relations = $fields + ->filter(fn ($field) => $this->isRelationProperty($query, $field)) + ->flip() + ->undot(); + + // Apply filter for relation property. + $relations->each( + fn (array $properties, string $relation) => $this + ->setWhereRelations($query, $relation, $properties, $value) + ); + + // Apply filter for model properties. + $properties = $fields->filter(fn ($field) => ! $this->isRelationProperty($query, $field)); + $this->setWhereProperties($query, $properties, $value); + + return; + } + + if ($this->joinType) { + // Apply filter with join for relation property. + $fields + ->filter(fn ($field) => $this->isRelationProperty($query, $field)) + ->each(fn (string $property) => $this->setWhereJoins($query, $value)); + } + + // Apply filter for model properties. + $properties = $fields + ->filter(fn ($field) => ! $this->isRelationProperty($query, $field)); + + $this->setWhereProperties($query, $properties, $value); + }); + } + + /** + * Sets the where conditions for the relation properties by joins. + */ + protected function setJoinsRelationship(Builder $query, string $property): void + { + [$relationName, $property] = $this->getConstraintParts($property); + + $relation = $query; + + foreach (explode('.', $relationName) as $partial) { + $relation = $relation->getRelation($partial); + } + + $query->joinRelationship($relationName, joinType: $this->joinType->value); + + $this->relationConstraints[] = $relation->qualifyColumn($property); + } + + /** + * Sets the where conditions for the relation properties by joins. + */ + protected function setWhereJoins(Builder $query, mixed $value): void + { + foreach ($this->relationConstraints as $property) { + [$sql, $bindings] = $this->getWhereRawParameters($query->getGrammar()->wrap($property), $value); + + $query->orWhereRaw($sql, $bindings); + } + } + + /** + * Sets the where conditions for the relation properties. + */ + protected function setWhereRelations(Builder $query, string $relation, array $properties, mixed $value): void + { + $query->orWhereHas($relation, function (Builder $query) use ($properties, $value) { + $query->where(function (Builder $query) use ($properties, $value) { + collect($properties) + // Put arrays at the end. + ->sortBy(fn ($property) => is_array($property)) + ->each(function (int|array $properties, string $name) use ($query, $value) { + if (is_array($properties)) { + $this->setWhereRelations($query, $name, $properties, $value); + + return; + } + + $property = $query->getGrammar()->wrap($query->qualifyColumn($name)); + + [$sql, $bindings] = $this->getWhereRawParameters($property, $value); + + $query->orWhereRaw($sql, $bindings); + }); + }); + }); + } + + /** + * Sets the where conditions for the model properties. + */ + protected function setWhereProperties(Builder $query, Collection $properties, mixed $value): void + { + $properties->each(function ($property) use ($query, $value) { + $property = $query->getGrammar()->isExpression($property) + ? $property + : $query->qualifyColumn($property); + + $property = $query->getGrammar()->wrap($property); + + [$sql, $bindings] = $this->getWhereRawParameters($property, $value); + + $query->orWhereRaw($sql, $bindings); + }); + } + + /** + * Gets the parameters, property and value for the where condition in raw format. + */ + protected function getWhereRawParameters(string $property, mixed $value): array + { + return [ + "LOWER({$property}) LIKE ?", + ["%{$value}%"], + ]; + } + + /** + * Validates the data in the payload. + * + * @return mixed false if the data is invalid + */ + protected function validate($value): bool + { + return is_string($value); + } +} diff --git a/src/Filters/NumberFilter.php b/src/Filters/NumberFilter.php new file mode 100644 index 0000000..5b3cf78 --- /dev/null +++ b/src/Filters/NumberFilter.php @@ -0,0 +1,109 @@ + $query + ->where($property, '!=', $value, $boolean), + + Comparators\Number::GreaterThan => $query + ->where($property, '>', $value, $boolean), + + Comparators\Number::GreaterThanOrEqual => $query + ->where($property, '>=', $value, $boolean), + + Comparators\Number::LessThan => $query + ->where($property, '<', $value, $boolean), + + Comparators\Number::LessThanOrEqual => $query + ->where($property, '<=', $value, $boolean), + + Comparators\Number::Between => $query + ->whereBetween($property, $value, $boolean), + + Comparators\Number::NotBetween => $query + ->whereNotBetween($property, $value, $boolean), + + Comparators\Number::In => $query + ->whereIn($property, $value, $boolean), + + Comparators\Number::NotIn => $query + ->whereNotIn($property, $value, $boolean), + + Comparators\Number::Filled => $query + ->whereNotNull($property, $boolean), + + Comparators\Number::NotFilled => $query + ->whereNull($property, $boolean), + + default => $query + ->where($property, '=', $value, $boolean), + }; + } + + /** + * {@inheritdoc} + */ + protected function validate(mixed $value, mixed $operator): mixed + { + if (is_array($value)) { + $value = array_filter($value, 'is_numeric'); + + return count($value) > 0 ? $value : false; + } + + if (is_numeric($value) || is_null($value)) { + return $value; + } + + return false; + } +} diff --git a/src/Filters/TextFilter.php b/src/Filters/TextFilter.php new file mode 100644 index 0000000..2f4541a --- /dev/null +++ b/src/Filters/TextFilter.php @@ -0,0 +1,111 @@ +getQuery()->getGrammar()->wrap($property); + + match ($operator) { + Comparators\Text::Equal => $query + ->whereRaw("lower({$field}) like ?", [$value], $boolean), + + Comparators\Text::NotEqual => $query + ->whereRaw("lower({$field}) not like ?", [$value], $boolean), + + Comparators\Text::StartWith => $query + ->whereRaw("lower({$field}) like ?", ["$value%"], $boolean), + + Comparators\Text::NotStartWith => $query + ->whereRaw("lower({$field}) not like ?", ["$value%"], $boolean), + + Comparators\Text::EndWith => $query + ->whereRaw("lower({$field}) like ?", ["%$value"], $boolean), + + Comparators\Text::NotEndWith => $query + ->whereRaw("lower({$field}) not like ?", ["%$value"], $boolean), + + Comparators\Text::NotContains => $query + ->whereRaw("lower({$field}) not like ?", ["%$value%"], $boolean), + + Comparators\Text::In => $query + ->whereIn($property, $value, $boolean), + + Comparators\Text::NotIn => $query + ->whereNotIn($property, $value, $boolean), + + Comparators\Text::Filled => $query + ->whereNotNull($property, $boolean), + + Comparators\Text::NotFilled => $query + ->whereNull($property, $boolean), + + default => $query + ->whereRaw("lower({$field}) like ?", ["%$value%"], $boolean), + }; + } + + /** + * {@inheritdoc} + */ + protected function validate(mixed $value, mixed $operator): mixed + { + if (is_array($value)) { + $value = array_filter($value, 'strlen'); + + return count($value) > 0 ? $value : false; + } + + if (is_string($value) || is_null($value)) { + return is_string($value) + ? mb_strtolower($value, 'UTF8') + : $value; + } + + return false; + } +} diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php new file mode 100644 index 0000000..e6a3207 --- /dev/null +++ b/src/QueryBuilder.php @@ -0,0 +1,18 @@ +name('teamq-query-builder') + ->hasConfigFile(); + } +} diff --git a/src/Sorts/CaseSort.php b/src/Sorts/CaseSort.php new file mode 100644 index 0000000..7e6e807 --- /dev/null +++ b/src/Sorts/CaseSort.php @@ -0,0 +1,41 @@ + value array is provided. + * + * @link https://spatie.be/docs/laravel-query-builder + * + * @author Luis Arce + */ +class CaseSort implements Sort +{ + /** + * @param array $cases + */ + public function __construct(protected array $cases) + { + } + + /** + * {@inheritDoc} + */ + public function __invoke(Builder $query, bool $descending, string $property): void + { + $column = $query->qualifyColumn($property); + + $sql = 'case '; + foreach ($this->cases as $key => $value) { + $sql .= "when {$column} = {$key} then '{$value}' "; + } + $sql .= 'end '; + + $query->orderBy(new Expression($sql), $descending ? 'desc' : 'asc'); + } +} diff --git a/src/Sorts/RelationSort.php b/src/Sorts/RelationSort.php new file mode 100644 index 0000000..a25e48f --- /dev/null +++ b/src/Sorts/RelationSort.php @@ -0,0 +1,45 @@ +isRelationProperty($query, $property)) { + return; + } + + $query + ->orderByPowerJoins( + $property, + $descending ? 'desc' : 'asc', + $this->aggregationType?->value, + $this->joinType->value + ); + } +} diff --git a/tests/Datasets/Datasets.php b/tests/Datasets/Datasets.php new file mode 100644 index 0000000..aa58558 --- /dev/null +++ b/tests/Datasets/Datasets.php @@ -0,0 +1,336 @@ + [ + 'Belg', // Belgium + 'Laravel Beyond Crud', + 1, + ], + 'authors.name' => [ + 'Taylor', // Taylor Otwell + 'Domain Driven Design for Laravel', + 1, + ], + 'title' => [ + 'beyond C', + 'Laravel Beyond Crud', + 1, + ], + 'isbn' => [ + '58954', // 5895421369 + 'Domain Driven Design for Laravel', + 1, + ], +]); + +dataset('filters.text:isbn', [ + // Value | Operator | Expected | Count + Comparators\Text::Equal->name => [ + 'BE758952123', + Comparators\Text::Equal, + 'BE758952123', + 1, + ], + Comparators\Text::NotEqual->name => [ + 'BE758952123', + Comparators\Text::NotEqual, + 'BE758952123', + 1, + ], + Comparators\Text::StartWith->name => [ + 'BE7589', + Comparators\Text::StartWith, + 'BE758952123', + 1, + ], + Comparators\Text::NotStartWith->name => [ + 'BE7589', + Comparators\Text::NotStartWith, + 'BE758952123', + 1, + ], + Comparators\Text::EndWith->name => [ + '2123', + Comparators\Text::EndWith, + 'BE758952123', + 1, + ], + Comparators\Text::NotEndWith->name => [ + '2123', + Comparators\Text::NotEndWith, + 'BE758952123', + 1, + ], + Comparators\Text::Contains->name => [ + '8952', + Comparators\Text::Contains, + 'BE758952123', + 1, + ], + Comparators\Text::NotContains->name => [ + '8952', + Comparators\Text::NotContains, + 'BE758952123', + 1, + ], + Comparators\Text::Filled->name => [ + null, + Comparators\Text::Filled, + 'BE758952123', + 2, + ], + Comparators\Text::NotFilled->name => [ + null, + Comparators\Text::NotFilled, + 'BE758952123', + 0, + ], + Comparators\Text::In->name => [ + ['BE758952123', 'us5895421369'], + Comparators\Text::In, + 'BE758952123', + 2, + ], + Comparators\Text::NotIn->name => [ + ['be758952123', 'us5895421369'], + Comparators\Text::NotIn, + 'BE758952123', + 0, + ], +]); + +dataset('filters.text:author.email', [ + // Value | Operator | Expected | Count + Comparators\Text::Equal->name => [ + 'support@spatie.be', + Comparators\Text::Equal, + 'support@spatie.be', + 1, + ], + Comparators\Text::NotEqual->name => [ + 'support@spatie.be', + Comparators\Text::NotEqual, + 'support@spatie.be', + 1, + ], + Comparators\Text::StartWith->name => [ + 'support@', + Comparators\Text::StartWith, + 'support@spatie.be', + 1, + ], + Comparators\Text::NotStartWith->name => [ + 'support@', + Comparators\Text::NotStartWith, + 'support@spatie.be', + 1, + ], + Comparators\Text::EndWith->name => [ + '@spatie.be', + Comparators\Text::EndWith, + 'support@spatie.be', + 1, + ], + Comparators\Text::NotEndWith->name => [ + '@spatie.be', + Comparators\Text::NotEndWith, + 'support@spatie.be', + 1, + ], + Comparators\Text::Contains->name => [ + 'spatie', + Comparators\Text::Contains, + 'support@spatie.be', + 1, + ], + Comparators\Text::NotContains->name => [ + 'spatie', + Comparators\Text::NotContains, + 'support@spatie.be', + 1, + ], + Comparators\Text::Filled->name => [ + null, + Comparators\Text::Filled, + 'support@spatie.be', + 2, + ], + Comparators\Text::NotFilled->name => [ + null, + Comparators\Text::NotFilled, + 'support@spatie.be', + 0, + ], + Comparators\Text::In->name => [ + ['SUPPORT@SPATIE.BE', 'TAYLOR@LARAVEL.COM'], + Comparators\Text::In, + 'support@spatie.be', + 2, + ], + Comparators\Text::NotIn->name => [ + ['SUPPORT@SPATIE.BE', 'TAYLOR@LARAVEL.COM'], + Comparators\Text::NotIn, + 'support@spatie.be', + 0, + ], +]); + +dataset('filters.number', [ + // Value | Operator | Expected | Count + Comparators\Number::Equal->name => [ + '5', + Comparators\Number::Equal, + '758952123', + 1, + ], + Comparators\Number::NotEqual->name => [ + '5', + Comparators\Number::NotEqual, + '758952123', + 1, + ], + Comparators\Number::GreaterThan->name => [ + '5', + Comparators\Number::GreaterThan, + '5895421369', + 1, + ], + Comparators\Number::GreaterThanOrEqual->name => [ + '5', + Comparators\Number::GreaterThanOrEqual, + '758952123', + 2, + ], + Comparators\Number::LessThan->name => [ + '10', + Comparators\Number::LessThan, + '758952123', + 1, + ], + Comparators\Number::LessThanOrEqual->name => [ + '10', + Comparators\Number::LessThanOrEqual, + '5895421369', + 2, + ], + Comparators\Number::Between->name => [ + [0, 5], + Comparators\Number::Between, + '758952123', + 1, + ], + Comparators\Number::NotBetween->name => [ + [5, 10], + Comparators\Number::NotBetween, + '758952123', + 0, + ], + Comparators\Number::In->name => [ + [5, 10], + Comparators\Number::In, + '758952123', + 2, + ], + Comparators\Number::NotIn->name => [ + [5, 10], + Comparators\Number::NotIn, + '758952123', + 0, + ], + Comparators\Number::Filled->name => [ + null, + Comparators\Number::Filled, + '758952123', + 2, + ], + Comparators\Number::NotFilled->name => [ + null, + Comparators\Number::NotFilled, + '758952123', + 0, + ], +]); + +dataset('filters.date', [ + // Value | Operator | Expected | Count + Comparators\Number::Equal->name => [ + '2019-08-10', + Comparators\Number::Equal, + '758952123', + 1, + ], + Comparators\Number::NotEqual->name => [ + '2019-08-10', + Comparators\Number::NotEqual, + '758952123', + 1, + ], + Comparators\Number::GreaterThan->name => [ + '2019-08-10', + Comparators\Number::GreaterThan, + '5895421369', + 1, + ], + Comparators\Number::GreaterThanOrEqual->name => [ + '2019-08-10', + Comparators\Number::GreaterThanOrEqual, + '758952123', + 2, + ], + Comparators\Number::LessThan->name => [ + '2019-08-20', + Comparators\Number::LessThan, + '758952123', + 1, + ], + Comparators\Number::LessThanOrEqual->name => [ + '2019-08-20', + Comparators\Number::LessThanOrEqual, + '5895421369', + 2, + ], + Comparators\Number::Between->name => [ + ['2019-08-01', '2019-08-10'], + Comparators\Number::Between, + '758952123', + 1, + ], + Comparators\Number::NotBetween->name => [ + ['2019-08-10', '2019-08-20'], + Comparators\Number::NotBetween, + '758952123', + 0, + ], + Comparators\Number::In->name => [ + ['2019-08-10', '2019-08-20'], + Comparators\Number::In, + '758952123', + 2, + ], + Comparators\Number::NotIn->name => [ + ['2019-08-10', '2019-08-20'], + Comparators\Number::NotIn, + '758952123', + 0, + ], + Comparators\Number::Filled->name => [ + null, + Comparators\Number::Filled, + '758952123', + 2, + ], + Comparators\Number::NotFilled->name => [ + null, + Comparators\Number::NotFilled, + '758952123', + 0, + ], +]); diff --git a/tests/Filters/DateFilterTest.php b/tests/Filters/DateFilterTest.php new file mode 100644 index 0000000..6d4f886 --- /dev/null +++ b/tests/Filters/DateFilterTest.php @@ -0,0 +1,353 @@ +request = new Illuminate\Http\Request(); + $this->request->setMethod(Request::METHOD_GET); + + $this->firstBook = Book::factory() + ->for( + Author::factory() + ->for(Country::factory()->create(['name' => 'Belgium', 'code' => 'BE', 'created_at' => '2019-08-10'])) + ->create([ + 'email' => 'support@spatie.be', + 'created_at' => '2019-08-10', + ]) + ) + ->has(Chapter::factory(['title' => 'Models', 'created_at' => '2019-08-10'])) + ->has(Chapter::factory(['title' => 'Event Listeners', 'created_at' => '2019-08-10'])) + ->create([ + 'isbn' => '758952123', + 'created_at' => '2019-08-10', + ]); + + $this->secondBook = Book::factory() + ->for( + Author::factory() + ->for( + Country::factory()->create(['name' => 'United States', 'code' => 'US', 'created_at' => '2019-08-20'] + ) + ) + ->create([ + 'email' => 'taylor@laravel.com', + 'created_at' => '2019-08-20', + ]) + ) + ->has(Chapter::factory(['title' => 'Domain Layer', 'created_at' => '2019-08-20'])) + ->has(Chapter::factory(['title' => 'Event Communication', 'created_at' => '2019-08-20'])) + ->create([ + 'isbn' => '5895421369', + 'created_at' => '2019-08-20', + ]); +}); + +it('the query is formed correctly', function () { + // filter[isbn][value] = 54213 + // filter[isbn][operator] = contains + $this->request->query->add([ + 'filter' => [ + 'created_at' => [ + 'value' => '2019-08-20', + 'operator' => Comparators\Number::Equal->value, + ], + ], + ]); + + $query = str(Arr::query($this->request->query())) + ->replace('%5B', '[') + ->replace('%5D', ']') + ->value(); + + expect($query) + ->toBe('filter[created_at][value]=2019-08-20&filter[created_at][operator]=1'); +}); + +it('does not apply the filter when value is not an array of dates', function () { + $this->request->query->add([ + 'filter' => [ + 'created_at' => [ + 'value' => ['abc', 'fg'], + 'operator' => 5, + ], + ], + ]); + + $queryBuilder = QueryBuilder::for(Book::class, $this->request) + ->allowedFilters([ + AllowedFilter::custom('created_at', new DateFilter()), + ]); + + expect($queryBuilder->toRawSql()) + ->toBe( + 'select * from `books`' + ); +}); + +it('does not apply the filter if the value is not a date or null', function () { + $this->request->query->add([ + 'filter' => [ + 'created_at' => [ + 'value' => 'abc', + 'operator' => 5, + ], + ], + ]); + + $queryBuilder = QueryBuilder::for(Book::class, $this->request) + ->allowedFilters([ + AllowedFilter::custom('created_at', new DateFilter()), + ]); + + expect($queryBuilder->toRawSql()) + ->toBe( + 'select * from `books`' + ); +}); + +it('apply filter without sub levels (value, operator)', function () { + $this->request->query->add([ + 'filter' => [ + 'created_at' => '2023-08-01', + ], + ]); + + $queryBuilder = QueryBuilder::for(Book::class, $this->request) + ->allowedFilters([ + AllowedFilter::custom('created_at', new DateFilter()), + ]); + + expect($queryBuilder->toSql()) + ->toBe( + 'select * from `books` where date(`books`.`created_at`) = ?' + ); +}); + +it('apply filter without sub levels (operator)', function () { + $this->request->query->add([ + 'filter' => [ + 'created_at' => [ + 'value' => '2023-08-01', + ], + ], + ]); + + $queryBuilder = QueryBuilder::for(Book::class, $this->request) + ->allowedFilters([ + AllowedFilter::custom('created_at', new DateFilter()), + ]); + + expect($queryBuilder->toSql()) + ->toBe( + 'select * from `books` where date(`books`.`created_at`) = ?' + ); +}); + +it('apply filter without sub levels (value)', function () { + $this->request->query->add([ + 'filter' => [ + 'created_at' => [ + 'operator' => Comparators\Number::Filled->value, + ], + ], + ]); + + $queryBuilder = QueryBuilder::for(Book::class, $this->request) + ->allowedFilters([ + AllowedFilter::custom('created_at', new DateFilter()), + ]); + + expect($queryBuilder->toSql()) + ->toBe( + 'select * from `books` where `books`.`created_at` is not null' + ); +}); + +it('apply filter with multi levels (value)', function () { + $this->request->query->add([ + 'filter' => [ + 'created_at' => [ + 'value' => [ + '2020-08-01', + '2023-08-01', + ], + 'operator' => Comparators\Number::In->value, + ], + ], + ]); + + $queryBuilder = QueryBuilder::for(Book::class, $this->request) + ->allowedFilters([ + AllowedFilter::custom('created_at', new DateFilter()), + ]); + + expect($queryBuilder->toSql()) + ->toBe( + 'select * from `books` where `books`.`created_at` in (?, ?)' + ); +}); + +it('filters using all date comparison operators', function ($value, $operator, $expected, $count) { + $this->request->query->add([ + 'filter' => [ + 'created_at' => [ + 'value' => $value, + 'operator' => $operator->value, + ], + ], + ]); + + $queryBuilder = QueryBuilder::for(Book::class, $this->request) + ->allowedFilters([ + AllowedFilter::custom('created_at', new DateFilter()), + ]); + + $result = $queryBuilder->get(); + + expect($result)->count()->toBe($count); + + $negative = [ + Comparators\Number::NotEqual, + Comparators\Number::NotFilled, + Comparators\Number::NotIn, + Comparators\Number::NotBetween, + ]; + + if (in_array($operator, $negative, true)) { + expect($result)->contains('isbn', $expected)->toBeFalse(); + } else { + expect($result)->contains('isbn', $expected)->toBeTrue(); + } +})->with('filters.date'); + +it('apply filter on relationships of type "belongsTo"', function () { + $this->request->query->add([ + 'filter' => [ + 'author.created_at' => [ + 'value' => '2019-08-10', + 'operator' => Comparators\Number::Equal->value, + ], + ], + ]); + + $queryBuilder = QueryBuilder::for(Book::class, $this->request) + ->allowedFilters([ + AllowedFilter::custom('author.created_at', new DateFilter()), + ]); + + expect($queryBuilder->toSql()) + ->toBe( + 'select * from `books` where exists (select * from `authors` where `books`.`author_id` = `authors`.`id` and date(`authors`.`created_at`) = ?)' + ); +}); + +it('apply filter on relationships of type "hasMany"', function () { + $this->request->query->add([ + 'filter' => [ + 'chapters.created_at' => [ + 'value' => '2019-08-10', + 'operator' => Comparators\Number::Equal->value, + ], + ], + ]); + + $queryBuilder = QueryBuilder::for(Book::class, $this->request) + ->allowedFilters([ + AllowedFilter::custom('chapters.created_at', new DateFilter()), + ]); + + expect($queryBuilder->toSql()) + ->toBe( + 'select * from `books` where exists (select * from `chapters` where `books`.`id` = `chapters`.`book_id` and date(`chapters`.`created_at`) = ?)' + ); +}); + +it('apply filter on relationships using joins', function () { + $this->request->query->add([ + 'filter' => [ + 'authors.created_at' => [ + 'value' => '2019-08-10', + 'operator' => Comparators\Number::NotEqual->value, + ], + ], + ]); + + $queryBuilder = QueryBuilder::for(Book::query()->joinRelationship('author'), $this->request) + ->allowedFilters([ + AllowedFilter::custom('authors.created_at', new DateFilter(false)), + ]); + + expect($queryBuilder->toSql()) + ->toBe( + 'select `books`.* from `books` inner join `authors` on `books`.`author_id` = `authors`.`id` where date(`authors`.`created_at`) != ?' + ); +}); + +it('apply filter on relationships using dynamic power joins', function () { + $this->request->query->add([ + 'filter' => [ + 'author.created_at' => [ + 'value' => '2019-08-10', + 'operator' => Comparators\Number::NotEqual->value, + ], + 'author.country.created_at' => [ + 'value' => '2019-08-10', + 'operator' => Comparators\Number::NotEqual->value, + ], + ], + ]); + + $queryBuilder = QueryBuilder::for(Book::class, $this->request) + ->allowedFilters([ + AllowedFilter::custom('author.created_at', new DateFilter(false, JoinType::Inner)), + AllowedFilter::custom('author.country.created_at', new DateFilter(false, JoinType::Inner)), + ]); + + expect($queryBuilder->toSql()) + ->toBe( + 'select `books`.* from `books` inner join `authors` on `books`.`author_id` = `authors`.`id` inner join `countries` on `authors`.`country_id` = `countries`.`id` where date(`authors`.`created_at`) != ? and date(`countries`.`created_at`) != ?' + ); +}); + +it('filters using all number comparison operators with power joins', function ($value, $operator, $expected, $count) { + $this->request->query->add([ + 'filter' => [ + 'author.created_at' => [ + 'value' => $value, + 'operator' => $operator->value, + ], + ], + ]); + + $queryBuilder = QueryBuilder::for(Book::class, $this->request) + ->allowedFilters([ + AllowedFilter::custom('author.created_at', new DateFilter(false, JoinType::Inner)), + ]); + + $result = $queryBuilder->get(); + + expect($result)->count()->toBe($count); + + $negative = [ + Comparators\Number::NotEqual, + Comparators\Number::NotFilled, + Comparators\Number::NotIn, + Comparators\Number::NotBetween, + ]; + + if (in_array($operator, $negative, true)) { + expect($result)->contains('isbn', $expected)->toBeFalse(); + } else { + expect($result)->contains('isbn', $expected)->toBeTrue(); + } +})->with('filters.date'); diff --git a/tests/Filters/GlobalFilterTest.php b/tests/Filters/GlobalFilterTest.php new file mode 100644 index 0000000..bca6a99 --- /dev/null +++ b/tests/Filters/GlobalFilterTest.php @@ -0,0 +1,305 @@ +request = new Illuminate\Http\Request(); + $this->request->setMethod(Request::METHOD_GET); + + $this->firstBook = Book::factory() + ->for( + Author::factory() + ->for(Country::factory()->create(['name' => 'Belgium', 'code' => 'BE'])) + ->create([ + 'name' => 'Spatie', + 'email' => 'support@spatie.be', + ]) + ) + ->has(Chapter::factory(['title' => 'Models', 'number' => 1])) + ->has(Chapter::factory(['title' => 'Event Listeners', 'number' => 2])) + ->create([ + 'title' => 'Laravel Beyond Crud', + 'isbn' => '758952123', + 'pages' => 38, + ]); + + $this->secondBook = Book::factory() + ->for( + Author::factory() + ->for(Country::factory()->create(['name' => 'United States', 'code' => 'US'])) + ->create([ + 'name' => 'Taylor Otwell', + 'email' => 'taylor@laravel.com', + ]) + ) + ->has(Chapter::factory(['title' => 'Domain Layer', 'number' => 1])) + ->has(Chapter::factory(['title' => 'Event Communication', 'number' => 2])) + ->create([ + 'title' => 'Domain Driven Design for Laravel', + 'isbn' => '5895421369', + 'pages' => 45, + ]); +}); + +it('the query is formed correctly', function () { + $this->request->query->add([ + 'filter' => [ + 'global' => '12345', + ], + ]); + + $queryBuilder = QueryBuilder::for(Book::class, $this->request) + ->allowedFilters([ + GlobalFilter::allowed([ + 'author.name', + 'title', + 'isbn', + 'author.email', + 'author.country.name', + 'author.country.code', + 'chapters.title', + ]), + ]); + + expect($queryBuilder->toSql()) + ->toBe( + 'select * from `books` where (exists (select * from `authors` where `books`.`author_id` = `authors`.`id` and (LOWER(`authors`.`name`) LIKE ? or LOWER(`authors`.`email`) LIKE ? or exists (select * from `countries` where `authors`.`country_id` = `countries`.`id` and (LOWER(`countries`.`name`) LIKE ? or LOWER(`countries`.`code`) LIKE ?)))) or exists (select * from `chapters` where `books`.`id` = `chapters`.`book_id` and (LOWER(`chapters`.`title`) LIKE ?)) or LOWER(`books`.`title`) LIKE ? or LOWER(`books`.`isbn`) LIKE ?)' + ); +}); + +it('does not apply the filter if the value is not a string', function () { + $this->request->query->add([ + 'filter' => [ + 'global' => [ + 'abc', + 'def', + ], + ], + ]); + + $queryBuilder = QueryBuilder::for(Book::class, $this->request) + ->allowedFilters([ + GlobalFilter::allowed([ + 'author.name', + 'title', + ]), + ]); + + expect($queryBuilder->toRawSql()) + ->toBe( + 'select * from `books`' + ); +}); + +it('apply filter on properties model', function ($value, $expected, $count) { + $this->request->query->add([ + 'filter' => [ + 'global' => $value, + ], + ]); + + $queryBuilder = QueryBuilder::for(Book::class, $this->request) + ->allowedFilters([ + GlobalFilter::allowed([ + 'title', + 'isbn', + ]), + ]); + + expect($queryBuilder->get()) + ->count()->toBe($count) + ->contains('title', $expected)->toBeTrue() + ->and($queryBuilder->toSql()) + ->toBe( + 'select * from `books` where (LOWER(`books`.`title`) LIKE ? or LOWER(`books`.`isbn`) LIKE ?)' + ); +})->with([ + // Value | Expected value | Expected count + 'global by title' => ['bEyoNd', 'Laravel Beyond Crud', 1], + 'global by isbn' => ['589542', 'Domain Driven Design for Laravel', 1], +]); + +it('apply filter on query expression', function ($value, $expected, $count) { + $this->request->query->add([ + 'filter' => [ + 'global' => $value, + ], + ]); + + $queryBuilder = QueryBuilder::for(Book::class, $this->request) + ->allowedFilters([ + GlobalFilter::allowed([ + new Expression("CONCAT(books.title, ' - ', books.isbn)"), + ]), + ]); + + expect($queryBuilder->get()) + ->count()->toBe($count) + ->contains('title', $expected)->toBeTrue() + ->and($queryBuilder->toSql()) + ->toBe( + "select * from `books` where (LOWER(CONCAT(books.title, ' - ', books.isbn)) LIKE ?)" + ); +})->with([ + // Value | Expected value | Expected count + 'first book concat expression' => ['crud - 7589', 'Laravel Beyond Crud', 1], + 'second book concat expression' => ['El - 5895421', 'Domain Driven Design for Laravel', 1], +]); + +it('apply filter on relationships of type "belongsTo"', function ($value, $expected, $count) { + $this->request->query->add([ + 'filter' => [ + 'global' => $value, + ], + ]); + + $queryBuilder = QueryBuilder::for(Book::class, $this->request) + ->allowedFilters([ + GlobalFilter::allowed(['author.name', 'author.email']), + ]); + + expect($queryBuilder->get()) + ->count()->toBe($count) + ->contains('title', $expected)->toBeTrue() + ->and($queryBuilder->toSql()) + ->toBe( + 'select * from `books` where (exists (select * from `authors` where `books`.`author_id` = `authors`.`id` and (LOWER(`authors`.`name`) LIKE ? or LOWER(`authors`.`email`) LIKE ?)))' + ); +})->with([ + // Value | Expected value | Expected count + 'author.name' => ['sPatI', 'Laravel Beyond Crud', 1], + 'author.email' => ['ie.be', 'Laravel Beyond Crud', 1], +]); + +it('apply filter on relationships of type "belongsTo" (deep)', function ($value, $expected, $count) { + $this->request->query->add([ + 'filter' => [ + 'global' => $value, + ], + ]); + + $queryBuilder = QueryBuilder::for(Book::class, $this->request) + ->allowedFilters([ + GlobalFilter::allowed(['author.country.name', 'author.country.code']), + ]); + + expect($queryBuilder->get()) + ->count()->toBe($count) + ->contains('title', $expected)->toBeTrue() + ->and($queryBuilder->toSql()) + ->toBe( + 'select * from `books` where (exists (select * from `authors` where `books`.`author_id` = `authors`.`id` and (exists (select * from `countries` where `authors`.`country_id` = `countries`.`id` and (LOWER(`countries`.`name`) LIKE ? or LOWER(`countries`.`code`) LIKE ?)))))' + ); +})->with([ + // Value | Expected value | Expected count + 'author.country.name' => ['elgium', 'Laravel Beyond Crud', 1], + 'author.country.code' => ['us', 'Domain Driven Design for Laravel', 1], +]); + +it('apply filter on relationships of type "hasMany"', function ($value, $expected, $count) { + $this->request->query->add([ + 'filter' => [ + 'global' => $value, + ], + ]); + + $queryBuilder = QueryBuilder::for(Book::query()->with(['author']), $this->request) + ->allowedFilters([ + GlobalFilter::allowed(['chapters.title', 'chapters.number']), + ]); + + expect($queryBuilder->get()) + ->count()->toBe($count) + ->contains('title', $expected)->toBeTrue() + ->and($queryBuilder->toSql()) + ->toBe( + 'select * from `books` where (exists (select * from `chapters` where `books`.`id` = `chapters`.`book_id` and (LOWER(`chapters`.`title`) LIKE ? or LOWER(`chapters`.`number`) LIKE ?)))' + ); +})->with([ + // Value | Expected value | Expected count + 'chapters.title' => ['model', 'Laravel Beyond Crud', 1], + 'chapters.number' => ['2', 'Laravel Beyond Crud', 2], +]); + +it('apply filter on properties and relationships using joins', function ($value, $expected, $count) { + $this->request->query->add([ + 'filter' => [ + 'global' => $value, + ], + ]); + + $query = Book::query() + ->join('authors', 'authors.id', 'books.author_id') + ->join('countries', 'countries.id', 'authors.country_id'); + + $queryBuilder = QueryBuilder::for($query, $this->request) + ->allowedFilters([ + GlobalFilter::allowed(['title', 'isbn', 'countries.name', 'authors.name'], false), + ]); + + expect($queryBuilder->get()) + ->count()->toBe($count) + ->contains('title', $expected)->toBeTrue() + ->and($queryBuilder->toSql()) + ->toBe( + 'select * from `books` inner join `authors` on `authors`.`id` = `books`.`author_id` inner join `countries` on `countries`.`id` = `authors`.`country_id` where (LOWER(`books`.`title`) LIKE ? or LOWER(`books`.`isbn`) LIKE ? or LOWER(`countries`.`name`) LIKE ? or LOWER(`authors`.`name`) LIKE ?)' + ); +})->with('filters.global'); + +it('apply filter on properties and relationships using dynamic power joins', function ($value, $expected, $count) { + $this->request->query->add([ + 'filter' => [ + 'global' => $value, + ], + ]); + + $queryBuilder = QueryBuilder::for(Book::class, $this->request) + ->allowedFilters([ + GlobalFilter::allowed(['title', 'isbn', 'author.name', 'author.country.name'], false, JoinType::Inner), + ]); + + expect($queryBuilder->get()) + ->count()->toBe($count) + ->contains('title', $expected)->toBeTrue() + ->and($queryBuilder->toSql()) + ->toBe( + 'select `books`.* from `books` inner join `authors` on `books`.`author_id` = `authors`.`id` inner join `countries` on `authors`.`country_id` = `countries`.`id` where (LOWER(`authors`.`name`) LIKE ? or LOWER(`countries`.`name`) LIKE ? or LOWER(`authors`.`name`) LIKE ? or LOWER(`countries`.`name`) LIKE ? or LOWER(`books`.`title`) LIKE ? or LOWER(`books`.`isbn`) LIKE ?)' + ); +})->with('filters.global'); + +it('filter by all properties', function () { + $this->request->query->add([ + 'filter' => [ + 'global' => 'Belgium', + ], + ]); + + $queryBuilder = QueryBuilder::for(Book::class, $this->request) + ->allowedFilters([ + GlobalFilter::allowed([ + 'author.name', + 'title', + 'isbn', + 'author.email', + 'author.country.name', + 'author.country.code', + 'chapters.title', + ]), + ]); + + expect($queryBuilder->get()) + ->count()->toBe(1) + ->contains('title', 'Laravel Beyond Crud')->toBeTrue() + ->and($queryBuilder->toSql()) + ->toBe( + 'select * from `books` where (exists (select * from `authors` where `books`.`author_id` = `authors`.`id` and (LOWER(`authors`.`name`) LIKE ? or LOWER(`authors`.`email`) LIKE ? or exists (select * from `countries` where `authors`.`country_id` = `countries`.`id` and (LOWER(`countries`.`name`) LIKE ? or LOWER(`countries`.`code`) LIKE ?)))) or exists (select * from `chapters` where `books`.`id` = `chapters`.`book_id` and (LOWER(`chapters`.`title`) LIKE ?)) or LOWER(`books`.`title`) LIKE ? or LOWER(`books`.`isbn`) LIKE ?)' + ); +}); diff --git a/tests/Filters/NumberFilterTest.php b/tests/Filters/NumberFilterTest.php new file mode 100644 index 0000000..b2b91ec --- /dev/null +++ b/tests/Filters/NumberFilterTest.php @@ -0,0 +1,353 @@ +request = new Illuminate\Http\Request(); + $this->request->setMethod(Request::METHOD_GET); + + $this->firstBook = Book::factory() + ->for( + Author::factory() + ->for(Country::factory()->create(['name' => 'Belgium', 'code' => 'BE', 'order' => '5'])) + ->create([ + 'email' => 'support@spatie.be', + 'order' => '5', + ]) + ) + ->has(Chapter::factory(['title' => 'Models', 'order' => '5'])) + ->has(Chapter::factory(['title' => 'Event Listeners', 'order' => '5'])) + ->create([ + 'isbn' => '758952123', + 'order' => '5', + ]); + + $this->secondBook = Book::factory() + ->for( + Author::factory() + ->for( + Country::factory()->create(['name' => 'United States', 'code' => 'US', 'order' => '10'] + ) + ) + ->create([ + 'email' => 'taylor@laravel.com', + 'order' => '10', + ]) + ) + ->has(Chapter::factory(['title' => 'Domain Layer', 'order' => '10'])) + ->has(Chapter::factory(['title' => 'Event Communication', 'order' => '10'])) + ->create([ + 'isbn' => '5895421369', + 'order' => '10', + ]); +}); + +it('the query is formed correctly', function () { + // filter[isbn][value] = 54213 + // filter[isbn][operator] = contains + $this->request->query->add([ + 'filter' => [ + 'order' => [ + 'value' => 10, + 'operator' => Comparators\Number::Equal->value, + ], + ], + ]); + + $query = str(Arr::query($this->request->query())) + ->replace('%5B', '[') + ->replace('%5D', ']') + ->value(); + + expect($query) + ->toBe('filter[order][value]=10&filter[order][operator]=1'); +}); + +it('does not apply the filter when value is not an array of numbers', function () { + $this->request->query->add([ + 'filter' => [ + 'order' => [ + 'value' => ['abc', 'fg'], + 'operator' => 5, + ], + ], + ]); + + $queryBuilder = QueryBuilder::for(Book::class, $this->request) + ->allowedFilters([ + AllowedFilter::custom('order', new NumberFilter()), + ]); + + expect($queryBuilder->toRawSql()) + ->toBe( + 'select * from `books`' + ); +}); + +it('does not apply the filter if the value is not a numeric or null', function () { + $this->request->query->add([ + 'filter' => [ + 'order' => [ + 'value' => 'abc', + 'operator' => 5, + ], + ], + ]); + + $queryBuilder = QueryBuilder::for(Book::class, $this->request) + ->allowedFilters([ + AllowedFilter::custom('order', new NumberFilter()), + ]); + + expect($queryBuilder->toRawSql()) + ->toBe( + 'select * from `books`' + ); +}); + +it('apply filter without sub levels (value, operator)', function () { + $this->request->query->add([ + 'filter' => [ + 'order' => 10, + ], + ]); + + $queryBuilder = QueryBuilder::for(Book::class, $this->request) + ->allowedFilters([ + AllowedFilter::custom('order', new NumberFilter()), + ]); + + expect($queryBuilder->toSql()) + ->toBe( + 'select * from `books` where `books`.`order` = ?' + ); +}); + +it('apply filter without sub levels (operator)', function () { + $this->request->query->add([ + 'filter' => [ + 'order' => [ + 'value' => 10, + ], + ], + ]); + + $queryBuilder = QueryBuilder::for(Book::class, $this->request) + ->allowedFilters([ + AllowedFilter::custom('order', new NumberFilter()), + ]); + + expect($queryBuilder->toSql()) + ->toBe( + 'select * from `books` where `books`.`order` = ?' + ); +}); + +it('apply filter without sub levels (value)', function () { + $this->request->query->add([ + 'filter' => [ + 'order' => [ + 'operator' => Comparators\Number::Filled->value, + ], + ], + ]); + + $queryBuilder = QueryBuilder::for(Book::class, $this->request) + ->allowedFilters([ + AllowedFilter::custom('order', new NumberFilter()), + ]); + + expect($queryBuilder->toSql()) + ->toBe( + 'select * from `books` where `books`.`order` is not null' + ); +}); + +it('apply filter with multi levels (value)', function () { + $this->request->query->add([ + 'filter' => [ + 'order' => [ + 'value' => [ + 5, + 10, + ], + 'operator' => Comparators\Number::In->value, + ], + ], + ]); + + $queryBuilder = QueryBuilder::for(Book::class, $this->request) + ->allowedFilters([ + AllowedFilter::custom('order', new NumberFilter()), + ]); + + expect($queryBuilder->toSql()) + ->toBe( + 'select * from `books` where `books`.`order` in (?, ?)' + ); +}); + +it('filters using all number comparison operators', function ($value, $operator, $expected, $count) { + $this->request->query->add([ + 'filter' => [ + 'order' => [ + 'value' => $value, + 'operator' => $operator->value, + ], + ], + ]); + + $queryBuilder = QueryBuilder::for(Book::class, $this->request) + ->allowedFilters([ + AllowedFilter::custom('order', new NumberFilter()), + ]); + + $result = $queryBuilder->get(); + + expect($result)->count()->toBe($count); + + $negative = [ + Comparators\Number::NotEqual, + Comparators\Number::NotFilled, + Comparators\Number::NotIn, + Comparators\Number::NotBetween, + ]; + + if (in_array($operator, $negative, true)) { + expect($result)->contains('isbn', $expected)->toBeFalse(); + } else { + expect($result)->contains('isbn', $expected)->toBeTrue(); + } +})->with('filters.number'); + +it('apply filter on relationships of type "belongsTo"', function () { + $this->request->query->add([ + 'filter' => [ + 'author.order' => [ + 'value' => 5, + 'operator' => Comparators\Number::Equal->value, + ], + ], + ]); + + $queryBuilder = QueryBuilder::for(Book::class, $this->request) + ->allowedFilters([ + AllowedFilter::custom('author.order', new NumberFilter()), + ]); + + expect($queryBuilder->toSql()) + ->toBe( + 'select * from `books` where exists (select * from `authors` where `books`.`author_id` = `authors`.`id` and `authors`.`order` = ?)' + ); +}); + +it('apply filter on relationships of type "hasMany"', function () { + $this->request->query->add([ + 'filter' => [ + 'chapters.order' => [ + 'value' => '10', + 'operator' => Comparators\Number::Equal->value, + ], + ], + ]); + + $queryBuilder = QueryBuilder::for(Book::class, $this->request) + ->allowedFilters([ + AllowedFilter::custom('chapters.order', new NumberFilter()), + ]); + + expect($queryBuilder->toSql()) + ->toBe( + 'select * from `books` where exists (select * from `chapters` where `books`.`id` = `chapters`.`book_id` and `chapters`.`order` = ?)' + ); +}); + +it('apply filter on relationships using joins', function () { + $this->request->query->add([ + 'filter' => [ + 'authors.order' => [ + 'value' => '10', + 'operator' => Comparators\Number::NotEqual->value, + ], + ], + ]); + + $queryBuilder = QueryBuilder::for(Book::query()->joinRelationship('author'), $this->request) + ->allowedFilters([ + AllowedFilter::custom('authors.order', new NumberFilter(false)), + ]); + + expect($queryBuilder->toSql()) + ->toBe( + 'select `books`.* from `books` inner join `authors` on `books`.`author_id` = `authors`.`id` where `authors`.`order` != ?' + ); +}); + +it('apply filter on relationships using dynamic power joins', function () { + $this->request->query->add([ + 'filter' => [ + 'author.order' => [ + 'value' => '10', + 'operator' => Comparators\Number::NotEqual->value, + ], + 'author.country.order' => [ + 'value' => '10', + 'operator' => Comparators\Number::NotEqual->value, + ], + ], + ]); + + $queryBuilder = QueryBuilder::for(Book::class, $this->request) + ->allowedFilters([ + AllowedFilter::custom('author.order', new NumberFilter(false, JoinType::Inner)), + AllowedFilter::custom('author.country.order', new NumberFilter(false, JoinType::Inner)), + ]); + + expect($queryBuilder->toSql()) + ->toBe( + 'select `books`.* from `books` inner join `authors` on `books`.`author_id` = `authors`.`id` inner join `countries` on `authors`.`country_id` = `countries`.`id` where `authors`.`order` != ? and `countries`.`order` != ?' + ); +}); + +it('filters using all number comparison operators with power joins', function ($value, $operator, $expected, $count) { + $this->request->query->add([ + 'filter' => [ + 'author.order' => [ + 'value' => $value, + 'operator' => $operator->value, + ], + ], + ]); + + $queryBuilder = QueryBuilder::for(Book::class, $this->request) + ->allowedFilters([ + AllowedFilter::custom('author.order', new NumberFilter(false, JoinType::Inner)), + ]); + + $result = $queryBuilder->get(); + + expect($result)->count()->toBe($count); + + $negative = [ + Comparators\Number::NotEqual, + Comparators\Number::NotFilled, + Comparators\Number::NotIn, + Comparators\Number::NotBetween, + ]; + + if (in_array($operator, $negative, true)) { + expect($result)->contains('isbn', $expected)->toBeFalse(); + } else { + expect($result)->contains('isbn', $expected)->toBeTrue(); + } +})->with('filters.number'); diff --git a/tests/Filters/TextFilterTest.php b/tests/Filters/TextFilterTest.php new file mode 100644 index 0000000..79c84e8 --- /dev/null +++ b/tests/Filters/TextFilterTest.php @@ -0,0 +1,356 @@ +request = new Illuminate\Http\Request(); + $this->request->setMethod(Request::METHOD_GET); + + $this->firstBook = Book::factory() + ->for( + Author::factory() + ->for(Country::factory()->create(['name' => 'Belgium', 'code' => 'BE', 'order' => '5'])) + ->create([ + 'email' => 'support@spatie.be', + 'order' => '5', + ]) + ) + ->has(Chapter::factory(['title' => 'Models', 'order' => '5'])) + ->has(Chapter::factory(['title' => 'Event Listeners', 'order' => '5'])) + ->create([ + 'isbn' => 'BE758952123', + 'order' => '5', + ]); + + $this->secondBook = Book::factory() + ->for( + Author::factory() + ->for( + Country::factory()->create(['name' => 'United States', 'code' => 'US', 'order' => '10'] + ) + ) + ->create([ + 'email' => 'taylor@laravel.com', + 'order' => '10', + ]) + ) + ->has(Chapter::factory(['title' => 'Domain Layer', 'order' => '10'])) + ->has(Chapter::factory(['title' => 'Event Communication', 'order' => '10'])) + ->create([ + 'isbn' => 'US5895421369', + 'order' => '10', + ]); +}); + +it('the query is formed correctly', function () { + // filter[isbn][value] = 54213 + // filter[isbn][operator] = contains + $this->request->query->add([ + 'filter' => [ + 'isbn' => [ + 'value' => '54213', + 'operator' => 5, + ], + ], + ]); + + $query = str(Arr::query($this->request->query())) + ->replace('%5B', '[') + ->replace('%5D', ']') + ->value(); + + expect($query) + ->toBe('filter[isbn][value]=54213&filter[isbn][operator]=5'); +}); + +it('does not apply the filter if the value is an array of empty strings', function () { + $this->request->query->add([ + 'filter' => [ + 'isbn' => [ + 'value' => ['', ''], + 'operator' => 5, + ], + ], + ]); + + $queryBuilder = QueryBuilder::for(Book::class, $this->request) + ->allowedFilters([ + AllowedFilter::custom('isbn', new TextFilter()), + ]); + + expect($queryBuilder->toRawSql()) + ->toBe( + 'select * from `books`' + ); +}); + +it('does not apply the filter if the value is not a string or null', function () { + $this->request->query->add([ + 'filter' => [ + 'isbn' => [ + 'value' => true, + 'operator' => 5, + ], + ], + ]); + + $queryBuilder = QueryBuilder::for(Book::class, $this->request) + ->allowedFilters([ + AllowedFilter::custom('isbn', new TextFilter()), + ]); + + expect($queryBuilder->toRawSql()) + ->toBe( + 'select * from `books`' + ); +}); + +it('apply filter without sub levels (value, operator)', function () { + $this->request->query->add([ + 'filter' => [ + 'isbn' => '54213', + ], + ]); + + $queryBuilder = QueryBuilder::for(Book::class, $this->request) + ->allowedFilters([ + AllowedFilter::custom('isbn', new TextFilter()), + ]); + + expect($queryBuilder->toSql()) + ->toBe( + 'select * from `books` where lower(`books`.`isbn`) like ?' + ); +}); + +it('apply filter without sub levels (operator)', function () { + $this->request->query->add([ + 'filter' => [ + 'isbn' => [ + 'value' => '54213', + ], + ], + ]); + + $queryBuilder = QueryBuilder::for(Book::class, $this->request) + ->allowedFilters([ + AllowedFilter::custom('isbn', new TextFilter()), + ]); + + expect($queryBuilder->toSql()) + ->toBe( + 'select * from `books` where lower(`books`.`isbn`) like ?' + ); +}); + +it('apply filter without sub levels (value)', function () { + $this->request->query->add([ + 'filter' => [ + 'isbn' => [ + 'operator' => Comparators\Text::Filled->value, + ], + ], + ]); + + $queryBuilder = QueryBuilder::for(Book::class, $this->request) + ->allowedFilters([ + AllowedFilter::custom('isbn', new TextFilter()), + ]); + + expect($queryBuilder->toSql()) + ->toBe( + 'select * from `books` where `books`.`isbn` is not null' + ); +}); + +it('apply filter with multi levels (value)', function () { + $this->request->query->add([ + 'filter' => [ + 'isbn' => [ + 'value' => [ + '54213', + '7589', + ], + ], + ], + ]); + + $queryBuilder = QueryBuilder::for(Book::class, $this->request) + ->allowedFilters([ + AllowedFilter::custom('isbn', new TextFilter()), + ]); + + expect($queryBuilder->toSql()) + ->toBe( + 'select * from `books` where (lower(`books`.`isbn`) like ? or lower(`books`.`isbn`) like ?)' + ); +}); + +it('filters using all text comparison operators', function ($value, $operator, $expected, $count) { + $this->request->query->add([ + 'filter' => [ + 'isbn' => [ + 'value' => $value, + 'operator' => $operator->value, + ], + ], + ]); + + $queryBuilder = QueryBuilder::for(Book::class, $this->request) + ->allowedFilters([ + AllowedFilter::custom('isbn', new TextFilter()), + ]); + + $result = $queryBuilder->get(); + + expect($result)->count()->toBe($count); + + $negative = [ + Comparators\Text::NotEqual, + Comparators\Text::NotStartWith, + Comparators\Text::NotEndWith, + Comparators\Text::NotContains, + Comparators\Text::NotFilled, + Comparators\Text::NotIn, + ]; + + if (in_array($operator, $negative, true)) { + expect($result)->contains('isbn', $expected)->toBeFalse(); + } else { + expect($result)->contains('isbn', $expected)->toBeTrue(); + } +})->with('filters.text:isbn'); + +it('apply filter on relationships of type "belongsTo"', function () { + $this->request->query->add([ + 'filter' => [ + 'author.email' => [ + 'value' => '@spatie.be', + 'operator' => Comparators\Text::EndWith->value, + ], + ], + ]); + + $queryBuilder = QueryBuilder::for(Book::class, $this->request) + ->allowedFilters([ + AllowedFilter::custom('author.email', new TextFilter()), + ]); + + expect($queryBuilder->toSql()) + ->toBe( + 'select * from `books` where exists (select * from `authors` where `books`.`author_id` = `authors`.`id` and lower(`authors`.`email`) like ?)' + ); +}); + +it('apply filter on relationships of type "hasMany"', function () { + $this->request->query->add([ + 'filter' => [ + 'chapters.title' => [ + 'value' => 'Event', + 'operator' => Comparators\Text::Contains->value, + ], + ], + ]); + + $queryBuilder = QueryBuilder::for(Book::class, $this->request) + ->allowedFilters([ + AllowedFilter::custom('chapters.title', new TextFilter()), + ]); + + expect($queryBuilder->toSql()) + ->toBe( + 'select * from `books` where exists (select * from `chapters` where `books`.`id` = `chapters`.`book_id` and lower(`chapters`.`title`) like ?)' + ); +}); + +it('apply filter on relationships using joins', function () { + $this->request->query->add([ + 'filter' => [ + 'authors.email' => [ + 'value' => '@spatie.be', + 'operator' => Comparators\Text::EndWith->value, + ], + ], + ]); + + $queryBuilder = QueryBuilder::for(Book::query()->joinRelationship('author'), $this->request) + ->allowedFilters([ + AllowedFilter::custom('authors.email', new TextFilter(false)), + ]); + + expect($queryBuilder->toSql()) + ->toBe( + 'select `books`.* from `books` inner join `authors` on `books`.`author_id` = `authors`.`id` where lower(`authors`.`email`) like ?' + ); +}); + +it('apply filter on relationships using dynamic power joins', function () { + $this->request->query->add([ + 'filter' => [ + 'author.email' => [ + 'value' => '@spatie.be', + 'operator' => Comparators\Text::EndWith->value, + ], + 'author.country.name' => [ + 'value' => 'Belgium', + 'operator' => Comparators\Text::Contains->value, + ], + ], + ]); + + $queryBuilder = QueryBuilder::for(Book::class, $this->request) + ->allowedFilters([ + AllowedFilter::custom('author.email', new TextFilter(false, JoinType::Inner)), + AllowedFilter::custom('author.country.name', new TextFilter(false, JoinType::Inner)), + ]); + + expect($queryBuilder->toSql()) + ->toBe( + 'select `books`.* from `books` inner join `authors` on `books`.`author_id` = `authors`.`id` inner join `countries` on `authors`.`country_id` = `countries`.`id` where lower(`authors`.`email`) like ? and lower(`countries`.`name`) like ?' + ); +}); + +it('filters using all text comparison operators with power joins', function ($value, $operator, $expected, $count) { + $this->request->query->add([ + 'filter' => [ + 'author.email' => [ + 'value' => $value, + 'operator' => $operator->value, + ], + ], + ]); + + $queryBuilder = QueryBuilder::for(Book::class, $this->request) + ->allowedFilters([ + AllowedFilter::custom('author.email', new TextFilter(false, JoinType::Inner)), + ]); + + $result = $queryBuilder->get(); + + expect($result)->count()->toBe($count); + + $negative = [ + Comparators\Text::NotEqual, + Comparators\Text::NotStartWith, + Comparators\Text::NotEndWith, + Comparators\Text::NotContains, + Comparators\Text::NotFilled, + Comparators\Text::NotIn, + ]; + + if (in_array($operator, $negative, true)) { + expect($result)->contains('author.email', $expected)->toBeFalse(); + } else { + expect($result)->contains('author.email', $expected)->toBeTrue(); + } +})->with('filters.text:author.email'); diff --git a/tests/Mocks/Database/Factories/AuthorFactory.php b/tests/Mocks/Database/Factories/AuthorFactory.php new file mode 100644 index 0000000..6457778 --- /dev/null +++ b/tests/Mocks/Database/Factories/AuthorFactory.php @@ -0,0 +1,26 @@ + + */ + public function definition(): array + { + return [ + 'country_id' => Country::factory(), + 'name' => fake()->name(), + 'email' => fake()->unique()->safeEmail(), + ]; + } +} diff --git a/tests/Mocks/Database/Factories/BookFactory.php b/tests/Mocks/Database/Factories/BookFactory.php new file mode 100644 index 0000000..7be370f --- /dev/null +++ b/tests/Mocks/Database/Factories/BookFactory.php @@ -0,0 +1,29 @@ + + */ + public function definition(): array + { + return [ + 'author_id' => Author::factory(), + 'title' => fake()->title(), + 'isbn' => fake()->unique()->isbn10(), + 'classification' => fake()->randomElement(BookClassificationEnum::cases()), + 'pages' => fake()->randomDigitNotZero(), + ]; + } +} diff --git a/tests/Mocks/Database/Factories/ChapterFactory.php b/tests/Mocks/Database/Factories/ChapterFactory.php new file mode 100644 index 0000000..91d01a1 --- /dev/null +++ b/tests/Mocks/Database/Factories/ChapterFactory.php @@ -0,0 +1,24 @@ + + */ + public function definition(): array + { + return [ + 'title' => fake()->title(), + 'number' => fake()->randomDigitNotZero(), + ]; + } +} diff --git a/tests/Mocks/Database/Factories/CountryFactory.php b/tests/Mocks/Database/Factories/CountryFactory.php new file mode 100644 index 0000000..e0f2342 --- /dev/null +++ b/tests/Mocks/Database/Factories/CountryFactory.php @@ -0,0 +1,24 @@ + + */ + public function definition(): array + { + return [ + 'name' => fake()->country(), + 'code' => fake()->countryCode(), + ]; + } +} diff --git a/tests/Mocks/Database/migrations/2023_06_10_175830_create_countries_table.php b/tests/Mocks/Database/migrations/2023_06_10_175830_create_countries_table.php new file mode 100644 index 0000000..6f66850 --- /dev/null +++ b/tests/Mocks/Database/migrations/2023_06_10_175830_create_countries_table.php @@ -0,0 +1,24 @@ +id(); + $table->string('name'); + $table->string('code'); + $table->unsignedTinyInteger('order')->default(0); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('countries'); + } +}; diff --git a/tests/Mocks/Database/migrations/2023_06_10_175833_create_authors_table.php b/tests/Mocks/Database/migrations/2023_06_10_175833_create_authors_table.php new file mode 100644 index 0000000..3c230e2 --- /dev/null +++ b/tests/Mocks/Database/migrations/2023_06_10_175833_create_authors_table.php @@ -0,0 +1,25 @@ +id(); + $table->foreignId('country_id'); + $table->string('name'); + $table->string('email', 254); + $table->unsignedTinyInteger('order')->default(0); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('authors'); + } +}; diff --git a/tests/Mocks/Database/migrations/2023_06_10_175854_create_books_table.php b/tests/Mocks/Database/migrations/2023_06_10_175854_create_books_table.php new file mode 100644 index 0000000..64c223a --- /dev/null +++ b/tests/Mocks/Database/migrations/2023_06_10_175854_create_books_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignId('author_id'); + $table->string('title'); + $table->string('isbn'); + $table->unsignedSmallInteger('classification'); + $table->unsignedSmallInteger('pages'); + $table->unsignedTinyInteger('order')->default(0); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('books'); + } +}; diff --git a/tests/Mocks/Database/migrations/2023_06_10_175918_create_chapters_table.php b/tests/Mocks/Database/migrations/2023_06_10_175918_create_chapters_table.php new file mode 100644 index 0000000..073047b --- /dev/null +++ b/tests/Mocks/Database/migrations/2023_06_10_175918_create_chapters_table.php @@ -0,0 +1,25 @@ +id(); + $table->foreignId('book_id'); + $table->string('title'); + $table->unsignedSmallInteger('number'); + $table->unsignedTinyInteger('order')->default(0); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('chapters'); + } +}; diff --git a/tests/Mocks/Enums/BookClassificationEnum.php b/tests/Mocks/Enums/BookClassificationEnum.php new file mode 100644 index 0000000..9d3bb97 --- /dev/null +++ b/tests/Mocks/Enums/BookClassificationEnum.php @@ -0,0 +1,11 @@ +belongsTo(Country::class); + } +} diff --git a/tests/Mocks/Models/Book.php b/tests/Mocks/Models/Book.php new file mode 100644 index 0000000..c9b5210 --- /dev/null +++ b/tests/Mocks/Models/Book.php @@ -0,0 +1,36 @@ + BookClassificationEnum::class, + ]; + + protected static function newFactory(): BookFactory + { + return BookFactory::new(); + } + + public function author(): BelongsTo + { + return $this->belongsTo(Author::class); + } + + public function chapters(): HasMany + { + return $this->hasMany(Chapter::class); + } +} diff --git a/tests/Mocks/Models/Chapter.php b/tests/Mocks/Models/Chapter.php new file mode 100644 index 0000000..599bf2f --- /dev/null +++ b/tests/Mocks/Models/Chapter.php @@ -0,0 +1,19 @@ +in(__DIR__); diff --git a/tests/Queries/PerPageQueryBuilderTest.php b/tests/Queries/PerPageQueryBuilderTest.php new file mode 100644 index 0000000..1109154 --- /dev/null +++ b/tests/Queries/PerPageQueryBuilderTest.php @@ -0,0 +1,93 @@ +request = new Illuminate\Http\Request(); + $this->request->setMethod(Request::METHOD_GET); +}); + +it('when the query parameter per_page is not present, laravel pagination is used', function () { + $queryBuilder = QueryBuilder::for(Author::class, $this->request); + + expect($queryBuilder->paginate()) + ->perPage()->toBe(15) + ->and($queryBuilder->result()) + ->perPage()->toBe(15); +}); + +it('when the query parameter per_page is not valid, laravel pagination is used', function () { + $this->request->query->add(['perPage' => 50]); + + $queryBuilder = QueryBuilder::for(Author::class, $this->request); + + expect($queryBuilder->paginate()) + ->perPage()->toBe(15) + ->and($queryBuilder->result()) + ->perPage()->toBe(15); +}); + +it('when the query parameter per_page is less than the min, laravel pagination is used', function () { + $this->request->query->add(['per_page' => 0]); + + $queryBuilder = QueryBuilder::for(Author::class, $this->request); + + expect($queryBuilder->paginate()) + ->perPage()->toBe(15) + ->and($queryBuilder->result()) + ->perPage()->toBe(15); +}); + +it('when the query parameter per_page is greater than the max, laravel pagination is used', function () { + $this->request->query->add(['per_page' => 51]); + + $queryBuilder = QueryBuilder::for(Author::class, $this->request) + ->setMaxPerPage(50); + + expect($queryBuilder->paginate()) + ->perPage()->toBe(15) + ->and($queryBuilder->result()) + ->perPage()->toBe(15); +}); + +it('by default there is no limit for the parameter per_page', function () { + $this->request->query->add(['per_page' => 1000]); + + $queryBuilder = QueryBuilder::for(Author::class, $this->request); + + expect($queryBuilder->paginate()) + ->perPage()->toBe(1000) + ->and($queryBuilder->result()) + ->perPage()->toBe(1000); +}); + +it('the per_page value is used', function () { + $this->request->query->add(['per_page' => 50]); + + $queryBuilder = QueryBuilder::for(Author::class, $this->request); + + expect($queryBuilder->paginate()) + ->perPage()->toBe(50) + ->and($queryBuilder->result()) + ->perPage()->toBe(50); +}); + +it('this return a collection with all records when `all` is sent', function () { + $this->request->query->add(['per_page' => 'all']); + + $queryBuilder = QueryBuilder::for(Author::class, $this->request); + + expect($queryBuilder->result()) + ->toBeInstanceOf(Collection::class); +}); + +it('this return a length aware pagination with paginated records when `all` is not sent', function () { + $queryBuilder = QueryBuilder::for(Author::class, $this->request); + + expect($queryBuilder->result()) + ->toBeInstanceOf(LengthAwarePaginator::class); +}); diff --git a/tests/Sorts/CaseSortTest.php b/tests/Sorts/CaseSortTest.php new file mode 100644 index 0000000..2b75660 --- /dev/null +++ b/tests/Sorts/CaseSortTest.php @@ -0,0 +1,71 @@ +request = new Illuminate\Http\Request(); + $this->request->setMethod(Request::METHOD_GET); + + $this->firstBook = Book::factory() + ->create([ + 'title' => 'Laravel Beyond Crud', + 'isbn' => '758952123', + 'pages' => 38, + 'classification' => BookClassificationEnum::OverTwelveYearsOld, + ]); + + $this->secondBook = Book::factory() + ->create([ + 'title' => 'Domain Driven Design for Laravel', + 'isbn' => '5895421369', + 'pages' => 45, + 'classification' => BookClassificationEnum::Adults, + ]); +}); + +it('sorts the records in ascending order', function () { + $this->request->query->add([ + 'sort' => 'classification', + ]); + + $cases = collect(BookClassificationEnum::cases()) + ->pluck('name', 'value') + ->toArray(); + + $queryBuilder = QueryBuilder::for(Book::class, $this->request) + ->allowedSorts([ + AllowedSort::custom('classification', new CaseSort($cases)), + ]); + + expect($queryBuilder->get()) + ->sequence( + fn ($book) => $book->classification->toBe(BookClassificationEnum::Adults), + fn ($book) => $book->classification->toBe(BookClassificationEnum::OverTwelveYearsOld), + ); +}); + +it('sorts the records in descending order', function () { + $this->request->query->add([ + 'sort' => '-classification', + ]); + + $cases = collect(BookClassificationEnum::cases()) + ->pluck('name', 'value') + ->toArray(); + + $queryBuilder = QueryBuilder::for(Book::class, $this->request) + ->allowedSorts([ + AllowedSort::custom('classification', new CaseSort($cases)), + ]); + + expect($queryBuilder->get()) + ->sequence( + fn ($book) => $book->classification->toBe(BookClassificationEnum::OverTwelveYearsOld), + fn ($book) => $book->classification->toBe(BookClassificationEnum::Adults), + ); +}); diff --git a/tests/Sorts/RelationSortTest.php b/tests/Sorts/RelationSortTest.php new file mode 100644 index 0000000..3f12cd5 --- /dev/null +++ b/tests/Sorts/RelationSortTest.php @@ -0,0 +1,156 @@ +request = new Illuminate\Http\Request(); + $this->request->setMethod(Request::METHOD_GET); + + $this->firstBook = Book::factory() + ->for( + Author::factory() + ->for(Country::factory()->create(['name' => 'Belgium', 'code' => 'BE'])) + ->create([ + 'name' => 'Spatie', + 'email' => 'support@spatie.be', + ]) + ) + ->has(Chapter::factory(['title' => 'Models', 'number' => 3])) + ->has(Chapter::factory(['title' => 'Event Listeners', 'number' => 4])) + ->create([ + 'title' => 'Laravel Beyond Crud', + 'isbn' => '758952123', + 'pages' => 38, + 'classification' => BookClassificationEnum::OverTwelveYearsOld, + ]); + + $this->secondBook = Book::factory() + ->for( + Author::factory() + ->for(Country::factory()->create(['name' => 'United States', 'code' => 'US'])) + ->create([ + 'name' => 'Taylor Otwell', + 'email' => 'taylor@laravel.com', + ]) + ) + ->has(Chapter::factory(['title' => 'Domain Layer', 'number' => 1])) + ->has(Chapter::factory(['title' => 'Event Communication', 'number' => 2])) + ->create([ + 'title' => 'Domain Driven Design for Laravel', + 'isbn' => '5895421369', + 'pages' => 45, + 'classification' => BookClassificationEnum::Adults, + ]); +}); + +it('sort the records in ascending order using the "BelongTo" relationship', function () { + $this->request->query->add([ + 'sort' => 'author.name', + ]); + + $queryBuilder = QueryBuilder::for(Book::class, $this->request) + ->allowedSorts([ + AllowedSort::custom('author.name', new RelationSort(JoinType::Inner)), + ]); + + expect($queryBuilder->get()) + ->sequence( + fn ($book) => $book->author->name->toBe('Spatie'), + fn ($book) => $book->author->name->toBe('Taylor Otwell'), + ); +}); + +it('sort the records in descending order using the "BelongTo" relationship', function () { + $this->request->query->add([ + 'sort' => '-author.name', + ]); + + $queryBuilder = QueryBuilder::for(Book::class, $this->request) + ->allowedSorts([ + AllowedSort::custom('author.name', new RelationSort(JoinType::Inner)), + ]); + + expect($queryBuilder->get()) + ->sequence( + fn ($book) => $book->author->name->toBe('Taylor Otwell'), + fn ($book) => $book->author->name->toBe('Spatie'), + ); +}); + +it('sort the records in ascending order using the "BelongTo" relationship (deep level)', function () { + $this->request->query->add([ + 'sort' => 'author.country.name', + ]); + + $queryBuilder = QueryBuilder::for(Book::class, $this->request) + ->allowedSorts([ + AllowedSort::custom('author.country.name', new RelationSort(JoinType::Inner)), + ]); + + expect($queryBuilder->get()) + ->sequence( + fn ($book) => $book->author->country->name->toBe('Belgium'), + fn ($book) => $book->author->country->name->toBe('United States'), + ); +}); + +it('sort the records in descending order using the "BelongTo" relationship (deep level)', function () { + $this->request->query->add([ + 'sort' => '-author.country.name', + ]); + + $queryBuilder = QueryBuilder::for(Book::class, $this->request) + ->allowedSorts([ + AllowedSort::custom('author.country.name', new RelationSort(JoinType::Inner)), + ]); + + expect($queryBuilder->get()) + ->sequence( + fn ($book) => $book->author->country->name->toBe('United States'), + fn ($book) => $book->author->country->name->toBe('Belgium'), + ); +}); + +it('sort the records in ascending order using the "HasMany" relationship with aggregations', function () { + $this->request->query->add([ + 'sort' => 'chapters.number', + ]); + + $queryBuilder = QueryBuilder::for(Book::class, $this->request) + ->allowedSorts([ + AllowedSort::custom('chapters.number', new RelationSort(JoinType::Left, AggregationType::Sum)), + ]); + + expect($queryBuilder->distinct()->get()) + ->sequence( + fn ($book) => $book->title->toBe('Domain Driven Design for Laravel'), + fn ($book) => $book->title->toBe('Laravel Beyond Crud'), + ); +}); + +it('sort the records in descending order using the "HasMany" relationship with aggregations', function () { + $this->request->query->add([ + 'sort' => '-chapters.number', + ]); + + $queryBuilder = QueryBuilder::for(Book::class, $this->request) + ->allowedSorts([ + AllowedSort::custom('chapters.number', new RelationSort(JoinType::Left, AggregationType::Sum)), + ]); + + expect($queryBuilder->get()) + ->sequence( + fn ($book) => $book->title->toBe('Laravel Beyond Crud'), + fn ($book) => $book->title->toBe('Domain Driven Design for Laravel'), + ); +}); diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..92745e6 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,26 @@ +loadMigrationsFrom(__DIR__.'/Mocks/Database/migrations'); + } + + protected function getPackageProviders($app): array + { + return [ + \Spatie\QueryBuilder\QueryBuilderServiceProvider::class, + QueryBuilderServiceProvider::class, + PowerJoinsServiceProvider::class, + ]; + } +}