From 0cfca5e2765791afefc77c7eeb6ef6db0dfa3fae Mon Sep 17 00:00:00 2001 From: Yevgeny Tomenko Date: Thu, 12 Sep 2024 09:21:50 +0300 Subject: [PATCH] initial release of CakeDC\SearchFilter plugin for CakePHP 4.5+ --- .github/codecov.yml | 7 + .github/workflows/ci.yml | 121 +++ .gitignore | 10 + LICENSE.txt | 26 + README.md | 167 ++++ composer.json | 56 ++ docs/Criteria.md | 262 ++++++ docs/CustomFilter.md | 230 +++++ docs/Filters.md | 223 +++++ phpcs.xml.dist | 8 + phpstan.neon | 4 + phpunit.xml.dist | 32 + psalm.xml | 26 + src/Filter/AbstractFilter.php | 239 ++++++ src/Filter/BooleanFilter.php | 83 ++ src/Filter/DateFilter.php | 70 ++ src/Filter/DateTimeFilter.php | 47 + .../Exception/MissingFilterException.php | 22 + src/Filter/FilterCollection.php | 175 ++++ src/Filter/FilterInterface.php | 119 +++ src/Filter/FilterRegistry.php | 115 +++ src/Filter/LookupFilter.php | 124 +++ src/Filter/MultipleFilter.php | 37 + src/Filter/NumericFilter.php | 42 + src/Filter/SelectFilter.php | 95 ++ src/Filter/StringFilter.php | 63 ++ src/Manager.php | 433 ++++++++++ src/Model/Filter/CriteriaFilter.php | 68 ++ src/Model/Filter/Criterion/AndCriterion.php | 85 ++ src/Model/Filter/Criterion/BaseCriterion.php | 181 ++++ src/Model/Filter/Criterion/BoolCriterion.php | 68 ++ .../Filter/Criterion/CriteriaBuilder.php | 124 +++ .../Filter/Criterion/CriterionInterface.php | 50 ++ src/Model/Filter/Criterion/DateCriterion.php | 189 ++++ .../Filter/Criterion/DateTimeCriterion.php | 38 + src/Model/Filter/Criterion/InCriterion.php | 94 ++ .../Filter/Criterion/LookupCriterion.php | 118 +++ .../Filter/Criterion/NumericCriterion.php | 84 ++ src/Model/Filter/Criterion/OrCriterion.php | 88 ++ .../Filter/Criterion/StringCriterion.php | 67 ++ src/Plugin.php | 18 + templates/element/Search/v_search.php | 18 + templates/element/Search/v_templates.php | 726 ++++++++++++++++ tests/Fixture/ArticlesFixture.php | 28 + tests/Fixture/ArticlesTagsFixture.php | 29 + tests/Fixture/AuthorsFixture.php | 28 + tests/Fixture/TagsFixture.php | 28 + .../Controller/ArticlesControllerTest.php | 71 ++ tests/TestCase/Filter/BooleanFilterTest.php | 58 ++ tests/TestCase/Filter/CriteriaBuilderTest.php | 182 ++++ tests/TestCase/Filter/DateFilterTest.php | 108 +++ tests/TestCase/Filter/DateTimeFilterTest.php | 59 ++ .../TestCase/Filter/FilterCollectionTest.php | 183 ++++ tests/TestCase/Filter/FilterRegistryTest.php | 116 +++ tests/TestCase/Filter/LookupFilterTest.php | 169 ++++ tests/TestCase/Filter/MultipleFilterTest.php | 57 ++ tests/TestCase/Filter/NumericFilterTest.php | 136 +++ tests/TestCase/Filter/SelectFilterTest.php | 133 +++ tests/TestCase/Filter/StringFilterTest.php | 156 ++++ tests/TestCase/ManagerTest.php | 161 ++++ .../Model/Filter/CriteriaFilterTest.php | 181 ++++ .../Filter/Criterion/AndCriterionTest.php | 109 +++ .../Filter/Criterion/BoolCriterionTest.php | 137 +++ .../Filter/Criterion/DateCriterionTest.php | 402 +++++++++ .../Criterion/DateTimeCriterionTest.php | 309 +++++++ .../Filter/Criterion/InCriterionTest.php | 83 ++ .../Filter/Criterion/LookupCriterionTest.php | 206 +++++ .../Filter/Criterion/NumericCriterionTest.php | 341 ++++++++ .../Filter/Criterion/OrCriterionTest.php | 110 +++ .../Filter/Criterion/StringCriterionTest.php | 355 ++++++++ tests/bootstrap.php | 117 +++ tests/schema.php | 117 +++ tests/schema.sql | 1 + tests/test_app/App/Application.php | 63 ++ .../test_app/App/Controller/AppController.php | 22 + .../App/Controller/ArticlesController.php | 109 +++ tests/test_app/App/Model/Entity/Article.php | 15 + .../App/Model/Table/ArticlesTable.php | 53 ++ .../App/Model/Table/ArticlesTagsTable.php | 30 + .../test_app/App/Model/Table/AuthorsTable.php | 20 + tests/test_app/App/Model/Table/TagsTable.php | 27 + tests/test_app/config/bootstrap.php | 2 + tests/test_app/config/routes.php | 16 + tests/test_app/templates/Articles/index.php | 60 ++ tests/test_app/templates/Articles/search.php | 49 ++ tests/test_app/templates/layout/ajax.php | 1 + tests/test_app/templates/layout/default.php | 42 + tests/test_app/templates/layout/error.php | 1 + .../test_app/templates/layout/js/default.php | 2 + .../test_app/templates/layout/rss/default.php | 17 + webroot/.gitkeep | 0 webroot/js/main.js | 810 ++++++++++++++++++ webroot/js/vue3.js | 13 + 93 files changed, 10374 insertions(+) create mode 100644 .github/codecov.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 LICENSE.txt create mode 100644 README.md create mode 100644 composer.json create mode 100644 docs/Criteria.md create mode 100644 docs/CustomFilter.md create mode 100644 docs/Filters.md create mode 100644 phpcs.xml.dist create mode 100644 phpstan.neon create mode 100644 phpunit.xml.dist create mode 100644 psalm.xml create mode 100644 src/Filter/AbstractFilter.php create mode 100644 src/Filter/BooleanFilter.php create mode 100644 src/Filter/DateFilter.php create mode 100644 src/Filter/DateTimeFilter.php create mode 100644 src/Filter/Exception/MissingFilterException.php create mode 100644 src/Filter/FilterCollection.php create mode 100644 src/Filter/FilterInterface.php create mode 100644 src/Filter/FilterRegistry.php create mode 100644 src/Filter/LookupFilter.php create mode 100644 src/Filter/MultipleFilter.php create mode 100644 src/Filter/NumericFilter.php create mode 100644 src/Filter/SelectFilter.php create mode 100644 src/Filter/StringFilter.php create mode 100644 src/Manager.php create mode 100644 src/Model/Filter/CriteriaFilter.php create mode 100644 src/Model/Filter/Criterion/AndCriterion.php create mode 100644 src/Model/Filter/Criterion/BaseCriterion.php create mode 100644 src/Model/Filter/Criterion/BoolCriterion.php create mode 100644 src/Model/Filter/Criterion/CriteriaBuilder.php create mode 100644 src/Model/Filter/Criterion/CriterionInterface.php create mode 100644 src/Model/Filter/Criterion/DateCriterion.php create mode 100644 src/Model/Filter/Criterion/DateTimeCriterion.php create mode 100644 src/Model/Filter/Criterion/InCriterion.php create mode 100644 src/Model/Filter/Criterion/LookupCriterion.php create mode 100644 src/Model/Filter/Criterion/NumericCriterion.php create mode 100644 src/Model/Filter/Criterion/OrCriterion.php create mode 100644 src/Model/Filter/Criterion/StringCriterion.php create mode 100644 src/Plugin.php create mode 100644 templates/element/Search/v_search.php create mode 100644 templates/element/Search/v_templates.php create mode 100644 tests/Fixture/ArticlesFixture.php create mode 100644 tests/Fixture/ArticlesTagsFixture.php create mode 100644 tests/Fixture/AuthorsFixture.php create mode 100644 tests/Fixture/TagsFixture.php create mode 100644 tests/TestCase/Controller/ArticlesControllerTest.php create mode 100644 tests/TestCase/Filter/BooleanFilterTest.php create mode 100644 tests/TestCase/Filter/CriteriaBuilderTest.php create mode 100644 tests/TestCase/Filter/DateFilterTest.php create mode 100644 tests/TestCase/Filter/DateTimeFilterTest.php create mode 100644 tests/TestCase/Filter/FilterCollectionTest.php create mode 100644 tests/TestCase/Filter/FilterRegistryTest.php create mode 100644 tests/TestCase/Filter/LookupFilterTest.php create mode 100644 tests/TestCase/Filter/MultipleFilterTest.php create mode 100644 tests/TestCase/Filter/NumericFilterTest.php create mode 100644 tests/TestCase/Filter/SelectFilterTest.php create mode 100644 tests/TestCase/Filter/StringFilterTest.php create mode 100644 tests/TestCase/ManagerTest.php create mode 100644 tests/TestCase/Model/Filter/CriteriaFilterTest.php create mode 100644 tests/TestCase/Model/Filter/Criterion/AndCriterionTest.php create mode 100644 tests/TestCase/Model/Filter/Criterion/BoolCriterionTest.php create mode 100644 tests/TestCase/Model/Filter/Criterion/DateCriterionTest.php create mode 100644 tests/TestCase/Model/Filter/Criterion/DateTimeCriterionTest.php create mode 100644 tests/TestCase/Model/Filter/Criterion/InCriterionTest.php create mode 100644 tests/TestCase/Model/Filter/Criterion/LookupCriterionTest.php create mode 100644 tests/TestCase/Model/Filter/Criterion/NumericCriterionTest.php create mode 100644 tests/TestCase/Model/Filter/Criterion/OrCriterionTest.php create mode 100644 tests/TestCase/Model/Filter/Criterion/StringCriterionTest.php create mode 100644 tests/bootstrap.php create mode 100644 tests/schema.php create mode 100644 tests/schema.sql create mode 100644 tests/test_app/App/Application.php create mode 100644 tests/test_app/App/Controller/AppController.php create mode 100644 tests/test_app/App/Controller/ArticlesController.php create mode 100644 tests/test_app/App/Model/Entity/Article.php create mode 100644 tests/test_app/App/Model/Table/ArticlesTable.php create mode 100644 tests/test_app/App/Model/Table/ArticlesTagsTable.php create mode 100644 tests/test_app/App/Model/Table/AuthorsTable.php create mode 100644 tests/test_app/App/Model/Table/TagsTable.php create mode 100644 tests/test_app/config/bootstrap.php create mode 100644 tests/test_app/config/routes.php create mode 100644 tests/test_app/templates/Articles/index.php create mode 100644 tests/test_app/templates/Articles/search.php create mode 100644 tests/test_app/templates/layout/ajax.php create mode 100644 tests/test_app/templates/layout/default.php create mode 100644 tests/test_app/templates/layout/error.php create mode 100644 tests/test_app/templates/layout/js/default.php create mode 100644 tests/test_app/templates/layout/rss/default.php create mode 100644 webroot/.gitkeep create mode 100644 webroot/js/main.js create mode 100644 webroot/js/vue3.js diff --git a/.github/codecov.yml b/.github/codecov.yml new file mode 100644 index 0000000..99ac2ec --- /dev/null +++ b/.github/codecov.yml @@ -0,0 +1,7 @@ +codecov: + require_ci_to_pass: yes + +coverage: + range: "50...100" + +comment: false \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4cefc92 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,121 @@ +name: CI + +on: + push: + + pull_request: + branches: + - '*' + +jobs: + testsuite: + runs-on: ubuntu-22.04 + strategy: + fail-fast: false + matrix: + php-version: ['8.1', '8.2', '8.3'] + db-type: [sqlite, mysql, pgsql] + prefer-lowest: [''] + + steps: + - name: Setup MySQL latest + if: matrix.db-type == 'mysql' + run: | + docker run --rm --name=mysqld -e MYSQL_ROOT_PASSWORD=root -e MYSQL_DATABASE=cakephp -p 3306:3306 -d mysql:8.2 --default-authentication-plugin=mysql_native_password --disable-log-bin + until docker exec mysqld mysqladmin ping --host=127.0.0.1 --password=root --silent; do + echo "Waiting for MySQL..." + sleep 2 + done + + - name: Setup PostgreSQL latest + if: matrix.db-type == 'pgsql' + run: docker run --rm --name=postgres -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=cakephp -p 5432:5432 -d postgres + + - uses: actions/checkout@v2 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + extensions: mbstring, intl, apcu, sqlite, pdo_sqlite, pdo_${{ matrix.db-type }}, ${{ matrix.db-type }} + ini-values: apc.enable_cli = 1 + coverage: pcov + + - name: Get composer cache directory + id: composer-cache + run: echo "::set-output name=dir::$(composer config cache-files-dir)" + + - name: Get date part for cache key + id: key-date + run: echo "::set-output name=date::$(date +'%Y-%m')" + + - name: Cache composer dependencies + uses: actions/cache@v1 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ steps.key-date.outputs.date }}-${{ hashFiles('composer.json') }}-${{ matrix.prefer-lowest }} + + - name: composer install + run: | + if ${{ matrix.prefer-lowest == 'prefer-lowest' }}; then + composer update --prefer-lowest --prefer-stable + else + composer update + fi + + - name: Setup problem matchers for PHPUnit + if: matrix.php-version == '8.1' && matrix.db-type == 'mysql' + run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" + + - name: Run PHPUnit + run: | + if [[ ${{ matrix.db-type }} == 'sqlite' ]]; then export DB_URL='sqlite:///:memory:'; fi + if [[ ${{ matrix.db-type }} == 'mysql' ]]; then export DB_URL='mysql://root:root@127.0.0.1/cakephp?encoding=utf8'; fi + if [[ ${{ matrix.db-type }} == 'pgsql' ]]; then export DB_URL='postgres://postgres:postgres@127.0.0.1/postgres'; fi + if [[ ${{ matrix.php-version }} == '8.1' ]]; then + export CODECOVERAGE=1 && vendor/bin/phpunit --verbose --coverage-clover=coverage.xml + else + vendor/bin/phpunit + fi + + - name: Submit code coverage + if: matrix.php-version == '8.1' + uses: codecov/codecov-action@v1 + + cs-stan: + name: Coding Standard & Static Analysis + runs-on: ubuntu-22.04 + + steps: + - uses: actions/checkout@v2 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.1' + extensions: mbstring, intl, apcu + coverage: none + + - name: Get composer cache directory + id: composer-cache + run: echo "::set-output name=dir::$(composer config cache-files-dir)" + + - name: Get date part for cache key + id: key-date + run: echo "::set-output name=date::$(date +'%Y-%m')" + + - name: Cache composer dependencies + uses: actions/cache@v1 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ steps.key-date.outputs.date }}-${{ hashFiles('composer.json') }}-${{ matrix.prefer-lowest }} + + - name: composer install + run: composer stan-setup + + - name: Run PHP CodeSniffer + run: composer cs-check + + - name: Run phpstan + if: success() || failure() + run: composer stan diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..05ccf56 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +/composer.lock +/composer.phar +/phpunit.xml +/.phpunit.result.cache +/phpunit.phar +/config/Migrations/schema-dump-default.lock +/vendor/ +/.idea/ +/.vscode/ +/tmp diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..5448b25 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,26 @@ +The MIT License + +Copyright 2009-2018 +Cake Development Corporation +1785 E. Sahara Avenue, Suite 490-423 +Las Vegas, Nevada 89104 +Phone: +1 702 425 5085 +https://www.cakedc.com + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights 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 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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2b11601 --- /dev/null +++ b/README.md @@ -0,0 +1,167 @@ +CakeDC SearchFilter Plugin for CakePHP +=================== + +[![Build Status](https://img.shields.io/github/actions/workflow/status/CakeDC/search-filter/ci.yml?branch=main&style=flat-square)](https://github.com/CakeDC/search-filter/actions?query=workflow%3ACI+branch%3Amain) +[![Coverage Status](https://img.shields.io/codecov/c/gh/CakeDC/search-filter.svg?style=flat-square)](https://codecov.io/gh/CakeDC/search-filter) +[![Downloads](https://poser.pugx.org/CakeDC/search-filter/d/total.png)](https://packagist.org/packages/CakeDC/search-filter) +[![License](https://poser.pugx.org/CakeDC/search-filter/license.svg)](https://packagist.org/packages/CakeDC/search-filter) + +Versions and branches +--------------------- + +| CakePHP | CakeDC Users Plugin | Tag | Notes | +| :-------------: | :------------------------: | :--: | :---- | +| ^5.0 | [2.0](https://github.com/cakedc/users/tree/2.next-cake5) | 2.0.0 | stable | +| ^4.5 | [1.0](https://github.com/cakedc/search-filter/tree/1.next-cake4) | 1.0.0 | stable | + +## Overview + +The SearchFilter plugin is a powerful and flexible solution for implementing advanced search functionality in CakePHP applications. It provides a robust set of tools for creating dynamic, user-friendly search interfaces with minimal effort. + +## Features + +- Dynamic filter generation based on database schema +- Support for various filter types: string, numeric, date, datetime, boolean, and lookup (autocomplete) +- Customizable filter conditions (equals, not equals, greater than, less than, between, etc.) +- Vue.js based frontend for an interactive user experience +- AJAX-powered autocomplete functionality for lookup filters +- Easy integration with CakePHP's ORM for efficient query building +- Extensible architecture allowing for custom filter types and conditions + +## Installation + +You can install this plugin into your CakePHP application using [composer](https://getcomposer.org): + +``` +composer require cakedc/search-filter +``` + +Then, add the following line to your application's `src/Application.php` file: + +```php +$this->addPlugin('CakeDC.SearchFilter'); +``` + +## Configuration + +* [Criteria List](docs/Criteria.md) +* [Filters List](docs/Filters.md) + +## Basic Usage + +### Controller + +In your controller, you can set up the search functionality like this: + +```php +use CakeDC\SearchFilter\Manager; + +class PostsController extends AppController +{ + public function index() + { + $query = $this->Posts->find(); + + $manager = new Manager($this->request); + $collection = $manager->newCollection(); + + // Add a general search filter + $collection->add('search', $manager->filters() + ->new('string') + ->setConditions(new \stdClass()) + ->setLabel('Search...') + ); + + // Add a complex name filter that searches across multiple fields + $collection->add('name', $manager->filters() + ->new('string') + ->setLabel('Name') + ->setCriterion( + $manager->criterion()->or([ + $manager->buildCriterion('title', 'string', $this->Posts), + $manager->buildCriterion('body', 'string', $this->Posts), + $manager->buildCriterion('author', 'string', $this->Posts), + ]) + ) + ); + + // Add a datetime filter for the 'created' field + $collection->add('created', $manager->filters() + ->new('datetime') + ->setLabel('Created') + ->setCriterion($manager->buildCriterion('created', 'datetime', $this->Posts)) + ); + + // Automatically add filters based on the table schema + $manager->appendFromSchema($collection, $this->Posts); + + // Get the view configuration for the filters + $viewFields = $collection->getViewConfig(); + $this->set('viewFields', $viewFields); + + // Apply filters if search parameters are present in the request + if (!empty($this->getRequest()->getQuery()) && !empty($this->getRequest()->getQuery('f'))) { + $search = $manager->formatSearchData(); + $this->set('values', $search); + + // Add a custom 'multiple' filter using the CriteriaFilter + $this->Posts->addFilter('multiple', [ + 'className' => 'CakeDC/SearchFilter.Criteria', + 'criteria' => $collection->getCriteria(), + ]); + + $filters = $manager->formatFinders($search); + $query = $query->find('filters', $filters); + } + + // Paginate the results + $posts = $this->paginate($this->Filter->prg($query)); + $this->set(compact('posts')); + } +} +``` + +This example demonstrates several key features of the SearchFilter plugin: + +1. Creating a new `Manager` instance and filter collection. +2. Adding a general search filter that can be used for quick searches. +3. Creating a complex filter that searches across multiple fields using `OrCriterion`. +4. Adding a datetime filter for a specific field. +5. Automatically generating filters based on the table schema. +6. Applying filters when search parameters are present in the request. +7. Using the `CriteriaFilter` for handling multiple filter criteria. + +### View + +In your view, you can render the search component inside search form like this: + +```php +element('CakeDC/SearchFilter.Search/v_search'); ?> +``` + +```html + +``` + +## Advanced Usage + +### Custom Filter Types + +[Custom Range Filter implementation and integration](docs/CustomFilter.md) + +## Frontend Customization + +The plugin uses Vue.js for the frontend. You can customize the look and feel by overriding the templates in your application: + +1. Copy the `templates/element/Search/v_templates.php` file from the plugin to your application's `templates/element/Search/` directory. +2. Modify the templates as needed. + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +## License + +This plugin is licensed under the [MIT License](LICENSE). diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..3dc6bc8 --- /dev/null +++ b/composer.json @@ -0,0 +1,56 @@ +{ + "name": "cakedc/search-filter", + "description": "SearchFilter plugin for CakePHP", + "type": "cakephp-plugin", + "license": "MIT", + "require": { + "php": ">=8.1", + "cakephp/cakephp": "^4.4", + "skie/cakephp-search": "^4.0" + }, + "require-dev": { + "cakephp/cakephp-codesniffer": "^4.0", + "slevomat/coding-standard": "^8.0", + "phpunit/phpunit": "^9.5" + }, + "scripts": { + "fixcode": [ + "bin/cake code_completion generate || echo 'FAILED'", + "bin/cake phpstorm generate || echo 'FAILED'", + "bin/cake annotate all || echo 'FAILED'", + "phpcbf --standard=vendor/cakephp/cakephp-codesniffer/CakePHP src/ tests/ || echo 'FAILED'" + ], + "check": [ + "@cs-check", + "@stan", + "@psalm", + "@test" + ], + "test": "phpunit --stderr", + "coverage-test": "phpunit --stderr --coverage-clover=clover.xml", + "stan": "phpstan.phar analyse --memory-limit=-1 src/", + "stan-setup": "cp composer.json composer.backup && composer require --dev phpstan/phpstan:0.12.94 psalm/phar:~4.9.2 && mv composer.backup composer.json", + "psalm": "psalm.phar --show-info=false", + "stan-rebuild-baseline": "phpstan.phar analyse ./src/ --generate-baseline", + "cs-check": "phpcs -n -p ./src ./tests", + "cs-fix": "phpcbf -n -p ./src ./tests " + }, + "autoload": { + "psr-4": { + "CakeDC\\SearchFilter\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "CakeDC\\SearchFilter\\Test\\": "tests/", + "CakeDC\\SearchFilter\\Test\\App\\": "tests/test_app/App/", + "Cake\\Test\\": "vendor/cakephp/cakephp/tests/" + } + }, + "config": { + "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": false, + "cakephp/plugin-installer": true + } + } +} diff --git a/docs/Criteria.md b/docs/Criteria.md new file mode 100644 index 0000000..d05e44c --- /dev/null +++ b/docs/Criteria.md @@ -0,0 +1,262 @@ +# Search Filter Criteria Documentation + +## Overview + +Search Filter Criteria allow you to build complex, flexible search queries in your CakePHP application. Each criterion type serves a specific purpose and can be combined to create powerful search functionality. + +## Index of Criteria + +1. [AndCriterion](#andcriterion) +2. [BoolCriterion](#boolcriterion) +3. [DateCriterion](#datecriterion) +4. [DateTimeCriterion](#datetimecriterion) +5. [InCriterion](#incriterion) +6. [LookupCriterion](#lookupcriterion) +7. [NumericCriterion](#numericcriterion) +8. [OrCriterion](#orcriterion) +9. [StringCriterion](#stringcriterion) + +## Criterion Types + +### AndCriterion + +**Purpose:** Combines multiple criteria with AND logic. + +**Configuration:** +```php +use CakeDC\SearchFilter\Model\Filter\Criterion\AndCriterion; + +$andCriterion = new AndCriterion($criteria); +``` + + +**Parameters:** +- `$criteria` (array): An array of BaseCriterion objects to be combined with AND logic. + +**Example:** +```php +$andCriterion = new AndCriterion([ + new DateCriterion('created'), + new BoolCriterion('is_active') +]); +``` + + +This will create a condition that matches both the creation date AND the active status. + +--- + +### BoolCriterion + +**Purpose:** Filters results based on a boolean field. + +**Configuration:** +```php +use CakeDC\SearchFilter\Model\Filter\Criterion\BoolCriterion; + +$boolCriterion = new BoolCriterion($field); +``` + + +**Parameters:** +- `$field` (string|\Cake\Database\ExpressionInterface): The boolean field to filter on. + +**Example:** +```php +$boolCriterion = new BoolCriterion('is_published'); +``` + + +This will filter results based on whether the 'is_published' field is true or false. + +--- + +### DateCriterion + +**Purpose:** Filters results based on date fields, supporting various comparison types. + +**Configuration:** +```php +use CakeDC\SearchFilter\Model\Filter\Criterion\DateCriterion; + +$dateCriterion = new DateCriterion($field, $format = 'Y-m-d'); +``` + + +**Parameters:** +- `$field` (string|\Cake\Database\ExpressionInterface): The date field to filter on. +- `$format` (string): The date format string (default: 'Y-m-d'). + +**Example:** +```php +$dateCriterion = new DateCriterion('created_date', 'Y-m-d'); +``` + + +This allows filtering on date fields with support for various conditions like 'between', 'greater than', etc. + +--- + +### DateTimeCriterion + +**Purpose:** Filters results based on datetime fields. + +**Configuration:** +```php +use CakeDC\SearchFilter\Model\Filter\Criterion\DateTimeCriterion; + +$dateTimeCriterion = new DateTimeCriterion($field, $format = 'Y-m-d\TH:i'); +``` + + +**Parameters:** +- `$field` (string|\Cake\Database\ExpressionInterface): The datetime field to filter on. +- `$format` (string): The datetime format string (default: 'Y-m-d\TH:i'). + +**Example:** +```php +$dateTimeCriterion = new DateTimeCriterion('created_at', 'Y-m-d H:i:s'); +``` + + +This allows filtering on datetime fields with support for various conditions, similar to DateCriterion but including time. + +--- + +### InCriterion + +**Purpose:** Filters results where a field's value is in a set of values determined by a subquery. + +**Configuration:** +```php +use CakeDC\SearchFilter\Model\Filter\Criterion\InCriterion; + +$inCriterion = new InCriterion($field, $table, $criterion); +``` + + +**Parameters:** +- `$field` (string|\Cake\Database\ExpressionInterface): The field to filter on. +- `$table` (\Cake\ORM\Table): The table used for the subquery. +- `$criterion` (BaseCriterion): The criterion used to build the subquery. + +**Example:** +```php +$authorTable = $this->getTableLocator()->get('Authors'); +$stringCriterion = new StringCriterion('name'); +$inCriterion = new InCriterion('author_id', $authorTable, $stringCriterion); +``` + + +This will create a subquery to find authors and then filter the main query based on the results. + +--- + +### LookupCriterion + +**Purpose:** Performs a lookup in a related table based on a search term. + +**Configuration:** +```php +use CakeDC\SearchFilter\Model\Filter\Criterion\LookupCriterion; + +$lookupCriterion = new LookupCriterion($field, $table, $criterion); +``` + + +**Parameters:** +- `$field` (string|\Cake\Database\ExpressionInterface): The field to filter on. +- `$table` (\Cake\ORM\Table): The related table to perform the lookup on. +- `$criterion` (BaseCriterion): The criterion used for the lookup. + +**Example:** +```php +$authorTable = $this->getTableLocator()->get('Authors'); +$stringCriterion = new StringCriterion('name'); +$lookupCriterion = new LookupCriterion('author_id', $authorTable, $stringCriterion); +``` + + +This allows searching in related tables and filtering the main query based on the results. + +--- + +### NumericCriterion + +**Purpose:** Filters results based on numeric fields. + +**Configuration:** +```php +use CakeDC\SearchFilter\Model\Filter\Criterion\NumericCriterion; + +$numericCriterion = new NumericCriterion($field); +``` + + +**Parameters:** +- `$field` (string|\Cake\Database\ExpressionInterface): The numeric field to filter on. + +**Example:** +```php +$numericCriterion = new NumericCriterion('price'); +``` + + +This allows filtering on numeric fields with support for various conditions like 'greater than', 'between', etc. + +--- + +### OrCriterion + +**Purpose:** Combines multiple criteria with OR logic. + +**Configuration:** +```php +use CakeDC\SearchFilter\Model\Filter\Criterion\OrCriterion; + +$orCriterion = new OrCriterion($criteria); +``` + + +**Parameters:** +- `$criteria` (array): An array of CriterionInterface objects to be combined with OR logic. + +**Example:** +```php +$orCriterion = new OrCriterion([ + new StringCriterion('title'), + new StringCriterion('content') +]); +``` + + +This will create a condition that matches either the title OR the content. + +--- + +### StringCriterion + +**Purpose:** Filters results based on string matching. + +**Configuration:** +```php +use CakeDC\SearchFilter\Model\Filter\Criterion\StringCriterion; + +$stringCriterion = new StringCriterion($field); +``` + + +**Parameters:** +- `$field` (string|\Cake\Database\ExpressionInterface): The string field to filter on. + +**Example:** +```php +$stringCriterion = new StringCriterion('title'); +``` + + +This allows filtering on string fields with support for various conditions like 'contains', 'starts with', etc. + +--- + +These criteria can be combined to create complex search queries. For example, you can use AndCriterion or OrCriterion to group multiple criteria together. \ No newline at end of file diff --git a/docs/CustomFilter.md b/docs/CustomFilter.md new file mode 100644 index 0000000..f4f0cc4 --- /dev/null +++ b/docs/CustomFilter.md @@ -0,0 +1,230 @@ +## Custom Filter Types and Criteria + +You can create custom filter types by extending the `AbstractFilter` class, and custom criteria by extending the `BaseCriterion` class. Here are examples of both: + +### Custom Filter: RangeFilter + +```php +use CakeDC\SearchFilter\Filter\AbstractFilter; + +class RangeFilter extends AbstractFilter +{ + protected array $properties = [ + 'type' => 'range', + ]; + + protected object|array|null $conditions = [ + self::COND_BETWEEN => 'Between', + ]; +} +``` + + +### Custom Criterion: RangeCriterion + +```php +use CakeDC\SearchFilter\Model\Filter\Criterion\BaseCriterion; +use Cake\Database\Expression\QueryExpression; +use Cake\ORM\Query; +use CakeDC\SearchFilter\Filter\AbstractFilter; + +class RangeCriterion extends BaseCriterion +{ + + protected $field; + + public function __construct($field) + { + $this->field = $field; + } + + public function __invoke(Query $query, string $condition, array $values, array $criteria, array $options): Query + { + $filter = $this->buildFilter($condition, $values, $criteria, $options); + if (!empty($filter)) { + return $query->where($filter); + } + + return $query; + } + + public function buildFilter(string $condition, array $values, array $criteria, array $options = []): ?callable + { + return function (QueryExpression $exp) use ($values) { + if (!empty($values['from']) && !empty($values['to'])) { + return $exp->between($this->field, $values['from'], $values['to']); + } + return $exp; + }; + } + + public function isApplicable($value, string $condition): bool + { + return !empty($value['from']) || !empty($value['to']); + } +} +``` + + +## Registering and Using Custom Filters + +To use your custom filter, you need to register it with the FilterRegistry. You can do this in your controller or in a custom setup method: + +```php +public function initialize(): void +{ + parent::initialize(); + + $manager = new Manager($this->request); + $manager->filters()->load('range', ['className' => RangeFilter::class]); +} +``` + + +Now you can use the custom filter and criterion in your controller action: + +```php +public function index() +{ + $manager = new Manager($this->request); + $collection = $manager->newCollection(); + + $collection->add('price', $manager->filters() + ->getNew('range') + ->setLabel('Price Range') + ->setCriterion(new RangeCriterion('price')) + ); + + // ... rest of your filter setup and query handling +} +``` + + +This setup allows you to create a range filter for backend that can be used for numeric ranges, such as price ranges or date ranges, providing a more specific and efficient way to filter your data. The `filters()` method provides access to the FilterRegistry, allowing you to add custom filters. + +## Vue frontend widget implementation. + +Let's add the template for the RangeFilter add load it after embedding `v_templates.php` file: + +```html + +``` + +Now, let's add the Vue component for the RangeFilter. You can add this to your `main.js` file or a separate JavaScript file that's included in your application: + +```javascript +const RangeInput = { + template: "#search-input-range-template", + props: ['index', 'value', 'field'], + data() { + return { + fromValue: '', + toValue: '', + }; + }, + methods: { + updateValue() { + this.$emit('change-value', { + index: this.index, + value: { + from: this.fromValue, + to: this.toValue + } + }); + } + }, + mounted() { + if (this.value) { + this.fromValue = this.value.from || ''; + this.toValue = this.value.to || ''; + } + }, + watch: { + value(newValue) { + if (newValue) { + this.fromValue = newValue.from || ''; + this.toValue = newValue.to || ''; + } else { + this.fromValue = ''; + this.toValue = ''; + } + } + } +}; +``` + +### Registering RangeInput Component + +The `RangeInput` component can now be registered with the search application using the registration system. + +To register the `RangeInput` component, follow these steps: + +1. Use the `createMyApp` function provided by `window._search`. +2. Pass a registration function as the second argument to `createMyApp`. + +#### Example Registration + +```javascript +function register(app, registrator) { + app.component('RangeInput', RangeInput); + + registrator('range', function(condition, type) { + return 'RangeInput'; + }); +} + +// Create the app with the registration function +window._search.createMyApp(window._search.rootElemId, register); +``` + +Finally, add some CSS to style the range inputs: + +```css +.range-wrapper { + display: flex; + align-items: center; +} + +.range-separator { + margin: 0 10px; +} + +.value-from, +.value-to { + width: 100px; +} +``` + +To use this new RangeFilter in your controller, you would do something like this: + +```php +$collection->add('price', $manager->filters() + ->getNew('range') + ->setLabel('Price Range') + ->setCriterion(new RangeCriterion('price')) + ->setProperty('fromPlaceholder', 'Min Price') + ->setProperty('toPlaceholder', 'Max Price') +); +``` + +This setup creates a new Vue component for the RangeFilter, which displays two number inputs for the "from" and "to" values. The component emits changes to its parent, allowing the search functionality to update accordingly. diff --git a/docs/Filters.md b/docs/Filters.md new file mode 100644 index 0000000..fd34990 --- /dev/null +++ b/docs/Filters.md @@ -0,0 +1,223 @@ +Certainly! I'll update the examples to use object chaining calls for a more concise and fluent syntax. Here's the revised documentation with chained method calls: + +# Search Filter Documentation + +## Overview + +Search Filters in this library provide a flexible way to define and configure various types of filters for your search functionality. Each filter type is designed for specific data types or search scenarios and is associated with a specific Vue widget for rendering. + +## Index of Filters + +1. [BooleanFilter](#booleanfilter) +2. [DateFilter](#datefilter) +3. [DateTimeFilter](#datetimefilter) +4. [LookupFilter](#lookupfilter) +5. [MultipleFilter](#multiplefilter) +6. [NumericFilter](#numericfilter) +7. [SelectFilter](#selectfilter) +8. [StringFilter](#stringfilter) + +## Filter Types + +### BooleanFilter + +**Purpose:** Used for boolean-based filtering, typically for Yes/No selections. + +**Vue Widget:** `SearchSelect` + +**Configuration:** +```php +use CakeDC\SearchFilter\Filter\BooleanFilter; +use CakeDC\SearchFilter\Model\Filter\Criterion\BoolCriterion; + +$booleanFilter = (new BooleanFilter()) + ->setCriterion(new BoolCriterion('is_active')) + ->setLabel('Active Status') + ->setOptions([1 => 'Active', 0 => 'Inactive']); +``` + + +**Key Features:** +- Provides Yes/No options by default. +- Can customize options using `setOptions()` method. +- Renders as a select input. + +--- + +### DateFilter + +**Purpose:** Used for date-based filtering, supporting various date formats and conditions. + +**Vue Widget:** `SearchInputDate`, `SearchInputDateRange`, or `SearchInputDateFixed` (depending on the condition) + +**Configuration:** +```php +use CakeDC\SearchFilter\Filter\DateFilter; +use CakeDC\SearchFilter\Model\Filter\Criterion\DateCriterion; + +$dateFilter = (new DateFilter()) + ->setCriterion(new DateCriterion('created_date')) + ->setLabel('Creation Date') + ->setDateFormat('YYYY-MM-DD'); +``` + + +**Key Features:** +- Default date format is 'DD/MM/YYYY'. +- Supports conditions like equals, greater than, less than, between, etc. +- Includes special conditions like 'Today', 'Yesterday', 'This week', 'Last week'. + +--- + +### DateTimeFilter + +**Purpose:** Used for datetime-based filtering, supporting both date and time components. + +**Vue Widget:** `SearchInputDateTime`, `SearchInputDateTimeRange`, or `SearchInputDateTimeFixed` (depending on the condition) + +**Configuration:** +```php +use CakeDC\SearchFilter\Filter\DateTimeFilter; +use CakeDC\SearchFilter\Model\Filter\Criterion\DateTimeCriterion; + +$dateTimeFilter = (new DateTimeFilter()) + ->setCriterion(new DateTimeCriterion('created_at')) + ->setLabel('Creation Date and Time') + ->setProperty('dateFormat', 'YYYY-MM-DD HH:mm:ss'); +``` + + +**Key Features:** +- Default datetime format is 'DD/MM/YYYY hh:mm A'. +- Supports the same conditions as DateFilter, but includes time in comparisons. + +--- + +### LookupFilter + +**Purpose:** Used for autocomplete-based filtering, allowing lookup of values based on a query string. + +**Vue Widget:** `SearchLookupInput` or `SearchMultiple` (for 'in' condition) + +**Configuration:** +```php +use CakeDC\SearchFilter\Filter\LookupFilter; +use CakeDC\SearchFilter\Model\Filter\Criterion\LookupCriterion; +use CakeDC\SearchFilter\Model\Filter\Criterion\StringCriterion; + +$lookupFilter = (new LookupFilter()) + ->setCriterion(new LookupCriterion('user_id', $usersTable, new StringCriterion('name'))) + ->setLabel('User') + ->setLookupFields(['name', 'email']) + ->setAutocompleteRoute(['controller' => 'Users', 'action' => 'autocomplete']); +``` + + +**Key Features:** +- Supports autocomplete functionality. +- Configurable lookup fields and autocomplete route. +- Generates autocomplete URL automatically. + +--- + +### MultipleFilter + +**Purpose:** Used for filtering based on multiple selected values. + +**Vue Widget:** `SearchMultiple` + +**Configuration:** +```php +use CakeDC\SearchFilter\Filter\MultipleFilter; +use CakeDC\SearchFilter\Model\Filter\Criterion\InCriterion; +use CakeDC\SearchFilter\Model\Filter\Criterion\StringCriterion; + +$multipleFilter = (new MultipleFilter()) + ->setCriterion(new InCriterion('category_id', $categoriesTable, new StringCriterion('name'))) + ->setLabel('Categories') + ->setProperty('placeholder', 'Select multiple options'); +``` + + +**Key Features:** +- Supports 'In' and 'Not In' conditions. +- Designed for multiple selections. + +--- + +### NumericFilter + +**Purpose:** Used for numeric-based filtering, supporting various numeric comparisons. + +**Vue Widget:** `SearchInput` or `SearchInputNumericRange` (for 'between' condition) + +**Configuration:** +```php +use CakeDC\SearchFilter\Filter\NumericFilter; +use CakeDC\SearchFilter\Model\Filter\Criterion\NumericCriterion; + +$numericFilter = (new NumericFilter()) + ->setCriterion(new NumericCriterion('price')) + ->setLabel('Price') + ->setProperty('step', '0.01'); // For decimal numbers +``` + + +**Key Features:** +- Supports conditions like equals, not equals, greater than, less than, between, etc. +- Specifically designed for numeric values. + +--- + +### SelectFilter + +**Purpose:** Used for select-based filtering, allowing selection from predefined options. + +**Vue Widget:** `SearchSelect` or `SearchMultiple` (for 'in' condition) + +**Configuration:** +```php +use CakeDC\SearchFilter\Filter\SelectFilter; +use CakeDC\SearchFilter\Model\Filter\Criterion\InCriterion; +use CakeDC\SearchFilter\Model\Filter\Criterion\StringCriterion; + +$selectFilter = (new SelectFilter()) + ->setCriterion(new InCriterion('status', $statusesTable, new StringCriterion('name'))) + ->setLabel('Status') + ->setOptions(['active' => 'Active', 'inactive' => 'Inactive']) + ->setEmpty('All Statuses'); +``` + + +**Key Features:** +- Supports custom options. +- Can set an empty option. +- Supports conditions like equals, not equals, in, like. + +--- + +### StringFilter + +**Purpose:** Used for string-based filtering, supporting various string comparison conditions. + +**Vue Widget:** `SearchInput` or `SearchMultiple` (for 'in' condition) + +**Configuration:** +```php +use CakeDC\SearchFilter\Filter\StringFilter; +use CakeDC\SearchFilter\Model\Filter\Criterion\StringCriterion; + +$stringFilter = (new StringFilter()) + ->setCriterion(new StringCriterion('title')) + ->setLabel('Title') + ->setType('email'); +``` + + +**Key Features:** +- Supports conditions like like, equals, not equals, in. +- Can be configured for different input types (e.g., text, email). + +--- + +These filters can be used to create a comprehensive search functionality in your application. They provide a range of options for different data types and search requirements, allowing for flexible and powerful search implementations. The associated Vue widgets ensure that the appropriate input type is rendered based on the filter type and condition. \ No newline at end of file diff --git a/phpcs.xml.dist b/phpcs.xml.dist new file mode 100644 index 0000000..3641219 --- /dev/null +++ b/phpcs.xml.dist @@ -0,0 +1,8 @@ + + + My custom coding standard. + + + + + diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..95baaca --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,4 @@ +parameters: + level: 6 + bootstrapFiles: + - tests/bootstrap.php diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..57f0d8a --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,32 @@ + + + + + + + + + + + + tests/TestCase/ + + + + + + + + + + + src + + + + diff --git a/psalm.xml b/psalm.xml new file mode 100644 index 0000000..28b9978 --- /dev/null +++ b/psalm.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/Filter/AbstractFilter.php b/src/Filter/AbstractFilter.php new file mode 100644 index 0000000..1e17c47 --- /dev/null +++ b/src/Filter/AbstractFilter.php @@ -0,0 +1,239 @@ +'; + public const COND_GE = '>='; + public const COND_LT = '<'; + public const COND_LE = '<='; + public const COND_IN = 'in'; + public const COND_NOT_IN = 'notIn'; + public const COND_LIKE = 'like'; + public const COND_NOT_LIKE = 'notLike'; + public const COND_BETWEEN = 'between'; + public const COND_TODAY = 'today'; + public const COND_YESTERDAY = 'yesterday'; + public const COND_THIS_WEEK = 'this_week'; + public const COND_LAST_WEEK = 'last_week'; + + /** + * Array of filter properties + * + * @var array + */ + protected array $properties = []; + + /** + * Array or object of filter conditions + * + * @var array|object|null + */ + protected array|object|null $conditions = []; + + /** + * Label of the filter + * + * @var string|null + */ + protected ?string $label = null; + + /** + * Criterion of the filter + * + * @var \CakeDC\SearchFilter\Model\Filter\Criterion\BaseCriterion|null + */ + protected ?BaseCriterion $criterion = null; + + /** + * Alias of the filter + * + * @var string|null + */ + protected ?string $alias = null; + + /** + * Get the properties of the filter. + * + * @return array + */ + public function getProperties(): array + { + return $this->properties; + } + + /** + * Set the properties of the filter. + * + * @param array $properties The properties to set. + * @return self + */ + public function setProperties(array $properties): self + { + $this->properties = $properties; + + return $this; + } + + /** + * Set a specific property of the filter. + * + * @param string $name The name of the property. + * @param mixed $value The value of the property. + * @return self + */ + public function setProperty(string $name, mixed $value): self + { + $this->properties[$name] = $value; + + return $this; + } + + /** + * Get the criterion of the filter. + * + * @return mixed + */ + public function getCriterion(): mixed + { + return $this->criterion; + } + + /** + * Set the criterion of the filter. + * + * @param \CakeDC\SearchFilter\Model\Filter\Criterion\BaseCriterion $criterion The criterion to set. + * @return self + */ + public function setCriterion(BaseCriterion $criterion): self + { + $this->criterion = $criterion; + + return $this; + } + + /** + * Get the label of the filter. + * + * @return string|null + */ + public function getLabel(): ?string + { + return $this->label; + } + + /** + * Set the label of the filter. + * + * @param string $label The label to set. + * @return self + */ + public function setLabel(string $label): self + { + $this->label = $label; + + return $this; + } + + /** + * Get the alias of the filter. + * + * @return string|null + */ + public function getAlias(): ?string + { + return $this->alias; + } + + /** + * Set the alias of the filter. + * + * @param string $alias The alias to set. + * @return self + */ + public function setAlias(string $alias): self + { + $this->alias = $alias; + + return $this; + } + + /** + * Get the conditions of the filter. + * + * @return array|object|null + */ + public function getConditions(): array|object|null + { + return $this->conditions; + } + + /** + * Set the conditions of the filter. + * + * @param array|object $conditions The conditions to set. + * @return self + */ + public function setConditions(array|object $conditions): self + { + $this->conditions = $conditions; + + return $this; + } + + /** + * Exclude the 'in' condition from the filter. + * + * @return self + */ + public function excludeIn(): self + { + unset($this->conditions[AbstractFilter::COND_IN]); + + return $this; + } + + /** + * Convert the filter to an array. + * + * @return array + */ + public function toArray(): array + { + $props = $this->getProperties(); + $props['conditions'] = $this->getConditions(); + $props['name'] = $this->getLabel(); + + return $props; + } + + /** + * Create a new instance of the filter. + * + * @return self + */ + public function new(): self + { + return clone $this; + } +} diff --git a/src/Filter/BooleanFilter.php b/src/Filter/BooleanFilter.php new file mode 100644 index 0000000..62178f7 --- /dev/null +++ b/src/Filter/BooleanFilter.php @@ -0,0 +1,83 @@ + + */ + protected array $options = [ + 1 => 'Yes', + 0 => 'No', + ]; + + /** + * Array of filter properties + * + * @var array + */ + protected array $properties = [ + 'type' => 'select', + ]; + + /** + * Array of filter conditions + * + * @var array|object|null + */ + protected array|object|null $conditions = []; + + /** + * Get the boolean options + * + * @return array + */ + public function getOptions(): array + { + return $this->options; + } + + /** + * Set the boolean options + * + * @param array $options The options to set + * @return self + */ + public function setOptions(array $options): self + { + $this->options = $options; + + return $this; + } + + /** + * Convert the filter to an array + * + * @return array + */ + public function toArray(): array + { + $props = parent::toArray(); + $props['options'] = $this->getOptions(); + $props['condtions'] = new stdClass(); + + return $props; + } +} diff --git a/src/Filter/DateFilter.php b/src/Filter/DateFilter.php new file mode 100644 index 0000000..5ebf46c --- /dev/null +++ b/src/Filter/DateFilter.php @@ -0,0 +1,70 @@ + + */ + protected array $properties = [ + 'dateFormat' => 'DD/MM/YYYY', + 'type' => 'date', + ]; + + /** + * Array of filter conditions + * + * @var array|object|null + */ + protected array|object|null $conditions = [ + AbstractFilter::COND_EQ => '=', + AbstractFilter::COND_NE => '≠', + AbstractFilter::COND_GT => '>', + AbstractFilter::COND_GE => '>=', + AbstractFilter::COND_LT => '<', + AbstractFilter::COND_LE => '<=', + AbstractFilter::COND_BETWEEN => 'Between', + AbstractFilter::COND_TODAY => 'Today', + AbstractFilter::COND_YESTERDAY => 'Yesterday', + AbstractFilter::COND_THIS_WEEK => 'This week', + AbstractFilter::COND_LAST_WEEK => 'Last week', + ]; + + /** + * Get the date format + * + * @return string + */ + public function getDateFormat(): string + { + return $this->properties['dateFormat']; + } + + /** + * Set the date format + * + * @param string $format The date format to set + * @return self + */ + public function setDateFormat(string $format): self + { + $this->properties['dateFormat'] = $format; + + return $this; + } +} diff --git a/src/Filter/DateTimeFilter.php b/src/Filter/DateTimeFilter.php new file mode 100644 index 0000000..343cdc8 --- /dev/null +++ b/src/Filter/DateTimeFilter.php @@ -0,0 +1,47 @@ + + */ + protected array $properties = [ + 'dateFormat' => 'DD/MM/YYYY hh:mm A', + 'type' => 'datetime', + ]; + + /** + * Array of filter conditions + * + * @var array|object|null + */ + protected array|object|null $conditions = [ + AbstractFilter::COND_EQ => '=', + AbstractFilter::COND_NE => '≠', + AbstractFilter::COND_GT => '>', + AbstractFilter::COND_GE => '>=', + AbstractFilter::COND_LT => '<', + AbstractFilter::COND_LE => '<=', + AbstractFilter::COND_BETWEEN => 'Between', + AbstractFilter::COND_TODAY => 'Today', + AbstractFilter::COND_YESTERDAY => 'Yesterday', + AbstractFilter::COND_THIS_WEEK => 'This week', + AbstractFilter::COND_LAST_WEEK => 'Last week', + ]; +} diff --git a/src/Filter/Exception/MissingFilterException.php b/src/Filter/Exception/MissingFilterException.php new file mode 100644 index 0000000..21a27b7 --- /dev/null +++ b/src/Filter/Exception/MissingFilterException.php @@ -0,0 +1,22 @@ + + * @psalm-suppress TooManyTemplateParams + */ +class FilterCollection implements IteratorAggregate, Countable +{ + /** + * Filter list + * + * @var array + */ + protected array $filters = []; + + /** + * Constructor + * + * @param array $filters The map of filters to add to the collection. + */ + public function __construct(array $filters = []) + { + foreach ($filters as $aslias => $filter) { + $this->add($aslias, $filter); + } + } + + /** + * Add an filter to the collection + * + * @param string $alias The filter alias to map. + * @param \CakeDC\SearchFilter\Filter\FilterInterface $filter The filter to map. + * @return $this + * @throws \InvalidArgumentException + */ + public function add(string $alias, FilterInterface $filter) + { + if ($this->has($alias)) { + throw new Exception(__('Filter %s already registered', $alias)); + } + $this->filters[$alias] = $filter; + $filter->setAlias($alias); + + return $this; + } + + /** + * Add multiple filters at once. + * + * @param array $filters The map of filters to add to the collection. + * @return $this + */ + public function addMany(array $filters) + { + foreach ($filters as $alias => $class) { + $this->add($alias, $class); + } + + return $this; + } + + /** + * Remove an filter from the collection if it exists. + * + * @param string $name The named filter. + * @return $this + */ + public function remove(string $name) + { + unset($this->filters[$name]); + + return $this; + } + + /** + * Implementation of IteratorAggregate. + * + * @return \Traversable + * @psalm-suppress TooManyTemplateParams + */ + public function getIterator(): Traversable + { + return new ArrayIterator($this->filters); + } + + /** + * Implementation of Countable. + * + * Get the number of filters in the collection. + * + * @return int + */ + public function count(): int + { + return count($this->filters); + } + + /** + * Get the list of available filter names. + * + * @return array Filter names + * @psalm-return list + */ + public function keys(): array + { + return array_keys($this->filters); + } + + /** + * Check for an filter by name. + * + * @param string $alias The filter alias to get. + * @return bool Whether the filter exists. + */ + public function has(string $alias): bool + { + return isset($this->filters[$alias]); + } + + /** + * Get the view configuration for the filters. + * + * @return array The view configuration. + */ + public function getViewConfig(): array + { + $result = []; + $filters = $this->filters; + usort($filters, function ($a, $b) { + return strcmp($a->getLabel(), $b->getLabel()); + }); + foreach ($filters as $filter) { + $alias = $filter->getAlias(); + $result[$alias] = $filter->toArray(); + } + + return $result; + } + + /** + * Get the criteria for the filters. + * + * @return array The criteria. + */ + public function getCriteria(): array + { + $result = []; + foreach ($this->filters as $alias => $filter) { + $criterion = $filter->getCriterion(); + if ($criterion !== null) { + $result[$alias] = $filter->getCriterion(); + } + } + + return $result; + } +} diff --git a/src/Filter/FilterInterface.php b/src/Filter/FilterInterface.php new file mode 100644 index 0000000..17af994 --- /dev/null +++ b/src/Filter/FilterInterface.php @@ -0,0 +1,119 @@ + + */ + public function getProperties(): array; + + /** + * Set the properties of the filter. + * + * @param array $properties The properties to set. + * @return self + */ + public function setProperties(array $properties): self; + + /** + * Set a specific property of the filter. + * + * @param string $name The name of the property. + * @param mixed $value The value of the property. + * @return self + */ + public function setProperty(string $name, mixed $value): self; + + /** + * Get the criterion of the filter. + * + * @return mixed + */ + public function getCriterion(): mixed; + + /** + * Set the criterion of the filter. + * + * @param \CakeDC\SearchFilter\Model\Filter\Criterion\BaseCriterion $criterion The criterion to set. + * @return self + */ + public function setCriterion(BaseCriterion $criterion): self; + + /** + * Get the label of the filter. + * + * @return string|null + */ + public function getLabel(): ?string; + + /** + * Set the label of the filter. + * + * @param string $label The label to set. + * @return self + */ + public function setLabel(string $label): self; + + /** + * Get the conditions of the filter. + * + * @return array|object|null + */ + public function getConditions(): array|object|null; + + /** + * Set the conditions of the filter. + * + * @param array|object $conditions The conditions to set. + * @return self + */ + public function setConditions(array|object $conditions): self; + + /** + * Exclude the 'in' condition from the filter. + * + * @return self + */ + public function excludeIn(): self; + + /** + * Convert the filter to an array. + * + * @return array + */ + public function toArray(): array; + + /** + * Create a new instance of the filter. + * + * @return self + */ + public function new(): self; + + /** + * Get the alias of the filter. + * + * @return string|null + */ + public function getAlias(): ?string; + + /** + * Set the alias of the filter. + * + * @param string $alias The alias to set. + * @return self + */ + public function setAlias(string $alias): self; +} diff --git a/src/Filter/FilterRegistry.php b/src/Filter/FilterRegistry.php new file mode 100644 index 0000000..73a1f36 --- /dev/null +++ b/src/Filter/FilterRegistry.php @@ -0,0 +1,115 @@ + + */ +class FilterRegistry extends ObjectRegistry +{ + /** + * Tries to lazy load a filter based on its name + * + * @param string $filter The filter name to be loaded + * @return bool whether the filter could be loaded or not + */ + public function __isset(string $filter): bool + { + if (isset($this->_loaded[$filter])) { + return true; + } + + $this->load($filter); + + return true; + } + + /** + * Provide public read access to the loaded objects + * + * @param string $name Name of property to read + * @return \CakeDC\SearchFilter\Filter\FilterInterface|null + */ + public function __get(string $name): ?FilterInterface + { + if (isset($this->{$name})) { + return $this->_loaded[$name]; + } + + return null; + } + + /** + * Resolve a filter classname. + * + * Part of the template method for Cake\Core\ObjectRegistry::load() + * + * @param string $class Partial classname to resolve. + * @return class-string<\CakeDC\SearchFilter\Filter\FilterInterface>|null Either the correct class name or null. + */ + protected function _resolveClassName(string $class): ?string + { + /** @var class-string<\CakeDC\SearchFilter\Filter\FilterInterface>|null */ + return App::className($class, 'Filter', 'Filter'); + } + + /** + * Throws an exception when a filter is missing. + * + * Part of the template method for Cake\Core\ObjectRegistry::load() + * and Cake\Core\ObjectRegistry::unload() + * + * @param string $class The classname that is missing. + * @param string|null $plugin The plugin the filter is missing in. + * @return void + * @throws \CakeDC\SearchFilter\Filter\Exception\MissingFilterException + */ + protected function _throwMissingClassError(string $class, ?string $plugin): void + { + throw new MissingFilterException([ + 'class' => $class . 'Filter', + 'plugin' => $plugin, + ]); + } + + /** + * Get copy of filter instance. + * + * @param string $name Name of object. + * @return \CakeDC\SearchFilter\Filter\FilterInterface Object instance. + */ + public function new(string $name): FilterInterface + { + return $this->get($name)->new(); + } + + /** + * Create the filter instance. + * + * Part of the template method for Cake\Core\ObjectRegistry::load() + * Enabled filters will be registered with the event manager. + * + * @param \CakeDC\SearchFilter\Filter\FilterInterface|class-string<\CakeDC\SearchFilter\Filter\FilterInterface> $class The class to create. + * @param string $alias The alias of the loaded filter. + * @param array $config An array of settings to use for the filter. + * @return \CakeDC\SearchFilter\Filter\FilterInterface The constructed filter class. + */ + protected function _create($class, string $alias, array $config): FilterInterface + { + if (is_object($class)) { + return $class; + } + + return new $class($config); + } +} diff --git a/src/Filter/LookupFilter.php b/src/Filter/LookupFilter.php new file mode 100644 index 0000000..106e7dc --- /dev/null +++ b/src/Filter/LookupFilter.php @@ -0,0 +1,124 @@ + + */ + protected array $properties = [ + 'type' => 'autocomplete', + 'idName' => 'id', + 'valueName' => 'name', + 'query' => 'name=%QUERY', + 'wildcard' => '%QUERY', + ]; + + /** + * Array of filter conditions + * + * @var array|object|null + */ + protected array|object|null $conditions = [ + AbstractFilter::COND_EQ => '=', + AbstractFilter::COND_NE => '≠', + AbstractFilter::COND_IN => 'In', + AbstractFilter::COND_NOT_IN => 'Not In', + AbstractFilter::COND_LIKE => 'Like', + ]; + + /** + * Array of autocomplete route configuration + * + * @var array + */ + protected array $autocompleteRoute = [ + 'action' => 'autocomplete', + '_ext' => 'json', + ]; + + /** + * Array of lookup fields + * + * @var array + */ + protected array $lookupFields = ['name', 'title', 'id']; + + /** + * Get the autocomplete route configuration + * + * @return array + */ + public function getAutocompleteRoute(): array + { + return $this->autocompleteRoute; + } + + /** + * Set the autocomplete route configuration + * + * @param array $autocompleteRoute The autocomplete route configuration to set + * @return self + */ + public function setAutocompleteRoute(array $autocompleteRoute): self + { + $this->autocompleteRoute = $autocompleteRoute; + + return $this; + } + + /** + * Get the lookup fields + * + * @return array + */ + public function getLookupFields(): array + { + return $this->lookupFields; + } + + /** + * Set the lookup fields + * + * @param array $lookupFields The lookup fields to set + * @return self + */ + public function setLookupFields(array $lookupFields): self + { + $this->lookupFields = $lookupFields; + + return $this; + } + + /** + * Convert the filter to an array + * + * @return array + */ + public function toArray(): array + { + if (empty($this->properties['autocompleteUrl'])) { + $this->properties['autocompleteUrl'] = Router::url($this->getAutocompleteRoute(), true); + } + $props = parent::toArray(); + + return $props; + } +} diff --git a/src/Filter/MultipleFilter.php b/src/Filter/MultipleFilter.php new file mode 100644 index 0000000..7dcea0f --- /dev/null +++ b/src/Filter/MultipleFilter.php @@ -0,0 +1,37 @@ + + */ + protected array $properties = [ + 'type' => 'multiple', + ]; + + /** + * Array of filter conditions + * + * @var array|object|null + */ + protected array|object|null $conditions = [ + AbstractFilter::COND_IN => 'In', + AbstractFilter::COND_NOT_IN => 'Not In', + ]; +} diff --git a/src/Filter/NumericFilter.php b/src/Filter/NumericFilter.php new file mode 100644 index 0000000..cf72d78 --- /dev/null +++ b/src/Filter/NumericFilter.php @@ -0,0 +1,42 @@ + + */ + protected array $properties = [ + 'type' => 'numeric', + ]; + + /** + * Array of filter conditions + * + * @var array|object|null + */ + protected array|object|null $conditions = [ + AbstractFilter::COND_EQ => '=', + AbstractFilter::COND_NE => '≠', + AbstractFilter::COND_GT => '>', + AbstractFilter::COND_GE => '>=', + AbstractFilter::COND_LT => '<', + AbstractFilter::COND_LE => '<=', + AbstractFilter::COND_BETWEEN => 'Between', + ]; +} diff --git a/src/Filter/SelectFilter.php b/src/Filter/SelectFilter.php new file mode 100644 index 0000000..2eac42f --- /dev/null +++ b/src/Filter/SelectFilter.php @@ -0,0 +1,95 @@ + + */ + protected array $options = []; + + /** + * Array of filter properties + * + * @var array + */ + protected array $properties = [ + 'type' => 'select', + ]; + + /** + * Array of filter conditions + * + * @var array|object|null + */ + protected array|object|null $conditions = [ + AbstractFilter::COND_EQ => '=', + AbstractFilter::COND_NE => '≠', + AbstractFilter::COND_IN => 'In', + AbstractFilter::COND_NOT_IN => 'Not In', + ]; + + /** + * Get the select options + * + * @return array + */ + public function getOptions(): array + { + return $this->options; + } + + /** + * Set the empty option for the select + * + * @param string $value The value for the empty option + * @return self + */ + public function setEmpty(string $value): self + { + $this->setProperty('empty', $value); + + return $this; + } + + /** + * Set the select options + * + * @param array $options The options to set + * @return self + */ + public function setOptions(array $options): self + { + $this->options = $options; + + return $this; + } + + /** + * Convert the filter to an array + * + * @return array + */ + public function toArray(): array + { + $props = parent::toArray(); + $props['options'] = $this->getOptions(); + + return $props; + } +} diff --git a/src/Filter/StringFilter.php b/src/Filter/StringFilter.php new file mode 100644 index 0000000..0b430c9 --- /dev/null +++ b/src/Filter/StringFilter.php @@ -0,0 +1,63 @@ + + */ + protected array $properties = [ + 'type' => 'string', + ]; + + /** + * Array of filter conditions + * + * @var array|object|null + */ + protected array|object|null $conditions = [ + AbstractFilter::COND_LIKE => 'Like', + AbstractFilter::COND_EQ => '=', + AbstractFilter::COND_NE => '≠', + AbstractFilter::COND_IN => 'In', + AbstractFilter::COND_NOT_IN => 'Not In', + ]; + + /** + * Get the type of the filter + * + * @return string + */ + public function getType(): string + { + return $this->properties['type']; + } + + /** + * Set the type of the filter + * + * @param string $type The type to set + * @return self + */ + public function setType(string $type): self + { + $this->properties['type'] = $type; + + return $this; + } +} diff --git a/src/Manager.php b/src/Manager.php new file mode 100644 index 0000000..0c267b7 --- /dev/null +++ b/src/Manager.php @@ -0,0 +1,433 @@ + + */ + protected array $fieldBlacklist = ['id', 'password', 'created', 'modified']; + + /** + * List of fields to be excluded from filtering + * + * @var array + */ + protected array $defaultLookupFields = ['name', 'title', 'id']; + + /** + * The current server request + * + * @var \Cake\Http\ServerRequest + */ + protected ServerRequest $request; + + /** + * Instance of a criteria builder object that can be used for + * generating complex criteria + * + * @var \CakeDC\SearchFilter\Model\Filter\Criterion\CriteriaBuilder + */ + protected CriteriaBuilder $_criteriaBuilder; + + /** + * A configuration array for filters to be loaded. + * + * @var array> + */ + protected array $filters = [ + 'boolean' => ['className' => BooleanFilter::class], + 'date' => ['className' => DateFilter::class], + 'datetime' => ['className' => DateTimeFilter::class], + 'lookup' => ['className' => LookupFilter::class], + 'multiple' => ['className' => MultipleFilter::class], + 'numeric' => ['className' => NumericFilter::class], + 'select' => ['className' => SelectFilter::class], + 'string' => ['className' => StringFilter::class], + ]; + + /** + * Constructor + * + * @param \Cake\Http\ServerRequest $request The server request + * @param array>|null $filters Optional filter configurations to override defaults + */ + public function __construct(ServerRequest $request, ?array $filters = null) + { + $this->request = $request; + if (!empty($filters)) { + $this->filters = $filters; + } + + $this->loadFilters(); + $this->_criteriaBuilder = new CriteriaBuilder(); + } + + /** + * Get the filter registry in use by this class. + * + * @return \CakeDC\SearchFilter\Filter\FilterRegistry + */ + public function filters(): FilterRegistry + { + return $this->_filters ??= new FilterRegistry(); + } + + /** + * Returns an instance of criteria builder object that can be used for + * generating complex criteria. + * + * ### Example: + * + * ``` + * $manager->criterion()->or([...]); + * $manager->criterion()->in(...); + * ``` + * + * @return \CakeDC\SearchFilter\Model\Filter\Criterion\CriteriaBuilder + */ + public function criterion(): CriteriaBuilder + { + return $this->_criteriaBuilder; + } + + /** + * Interact with the FilterRegistry to load all the helpers. + * + * @return $this + */ + public function loadFilters() + { + foreach ($this->filters as $name => $config) { + $this->loadFilter($name, $config); + } + + return $this; + } + + /** + * Load a specific filter + * + * @param string $name The name of the filter to load + * @param array $config Configuration for the filter + * @return \CakeDC\SearchFilter\Filter\FilterInterface The loaded filter + */ + public function loadFilter(string $name, array $config = []): FilterInterface + { + /** @var \CakeDC\SearchFilter\Filter\FilterInterface */ + return $this->filters()->load($name, $config); + } + + /** + * Get the field blacklist + * + * @return array + */ + public function getFieldBlacklist(): array + { + return $this->fieldBlacklist; + } + + /** + * Set the field blacklist + * + * @param array $fields The fields to blacklist + * @return self + */ + public function setFieldBlacklist(array $fields): self + { + $this->fieldBlacklist = $fields; + + return $this; + } + + /** + * Get the default lookup fields + * + * @return array + */ + public function getDefaultLookupFields(): array + { + return $this->defaultLookupFields; + } + + /** + * Set the default lookup fields + * + * @param array $defaultLookupFields The lookup fields to set + * @return self + */ + public function setDefaultLookupFields(array $defaultLookupFields): self + { + $this->defaultLookupFields = $defaultLookupFields; + + return $this; + } + + /** + * Create a new FilterCollection instance + * + * @return \CakeDC\SearchFilter\Filter\FilterCollection + */ + public function newCollection(): FilterCollection + { + return new FilterCollection(); + } + + /** + * Append filters from table schema to the collection + * + * @param \CakeDC\SearchFilter\Filter\FilterCollection $collection The filter collection + * @param \Cake\ORM\Table $table The table to get schema from + * @param array $labels Custom labels for fields + * @param array|null $skipFields Fields to skip + * @param array $options Additional options + * @return \CakeDC\SearchFilter\Filter\FilterCollection + */ + public function appendFromSchema(FilterCollection $collection, Table $table, array $labels = [], ?array $skipFields = null, ?array $options = []): FilterCollection + { + if ($skipFields === null) { + $skipFields = $this->getFieldBlacklist(); + } + $schema = $table->getSchema(); + foreach ($schema->columns() as $column) { + if (in_array($column, $skipFields) || $collection->has($column)) { + continue; + } + $type = $schema->getColumnType($column); + if ($type === null) { + continue; + } + $filter = $this->buildFilter($column, $type, $table, $labels); + if ($filter === null) { + continue; + } + + $criterion = $this->buildCriterion($column, $type, $table, $options ?? []); + if ($criterion !== null) { + $filter->setCriterion($criterion); + } + $collection->add($column, $filter); + } + + return $collection; + } + + /** + * Build a criterion based on column type + * + * @param string $column The column name + * @param string $type The column type + * @param \Cake\ORM\Table $table The table instance + * @param array $options Additional options + * @return \CakeDC\SearchFilter\Model\Filter\Criterion\BaseCriterion|null + */ + public function buildCriterion(string $column, string $type, Table $table, array $options = []): ?BaseCriterion + { + $alias = $table->getAlias(); + if (in_array($type, ['string', 'text'])) { + return $this->criterion()->string($alias . '.' . $column); + } elseif (in_array($type, ['date'])) { + return $this->criterion()->date($alias . '.' . $column); + } elseif (in_array($type, ['time', 'timestamp', 'datetime'])) { + return $this->criterion()->datetime($alias . '.' . $column); + } elseif (($type === 'integer' || $type == 'uuid') && (substr($column, -3) === '_id')) { + $assocName = Inflector::pluralize(Inflector::camelize(substr($column, 0, -3))); + if ($table->associations()->has($assocName)) { + $assoc = $table->associations()->get($assocName); + if ($assoc !== null) { + $assocSchema = $assoc->getTarget()->getSchema(); + + $fields = $options['defaultLookupFields'] ?? $this->getDefaultLookupFields(); + foreach ($fields as $field) { + if ($assocSchema->getColumn($field) !== null) { + return $this->criterion()->lookup($alias . '.' . $column, $assoc->getTarget(), $this->criterion()->string($field)); + } + } + } + } + } elseif (in_array($type, ['integer', 'float', 'decimal', 'biginteger'])) { + return $this->criterion()->numeric($alias . '.' . $column); + } elseif ($type === 'boolean') { + return $this->criterion()->bool($alias . '.' . $column); + } + + return null; + } + + /** + * Build a filter based on column type + * + * @param string $column The column name + * @param string $type The column type + * @param \Cake\ORM\Table $table The table instance + * @param array $labels Custom labels for fields + * @return \CakeDC\SearchFilter\Filter\FilterInterface|null + */ + protected function buildFilter(string $column, string $type, Table $table, array $labels): ?FilterInterface + { + $filter = null; + if (in_array($type, ['string', 'text'])) { + $filter = $this->filters()->new('string'); + } elseif (in_array($type, ['date'])) { + $filter = $this->filters()->new('date'); + } elseif (in_array($type, ['time', 'timestamp', 'datetime'])) { + $filter = $this->filters()->new('datetime'); + } elseif (($type === 'integer' || $type == 'uuid') && (substr($column, -3) === '_id')) { + $assocName = Inflector::pluralize(Inflector::camelize(substr($column, 0, -3))); + + /** @var \CakeDC\SearchFilter\Filter\LookupFilter $lookupFilter */ + $lookupFilter = $this->filters()->new('lookup'); + if ($table->associations()->has($assocName)) { + $assoc = $table->associations()->get($assocName); + if ($assoc !== null) { + $assocSchema = $assoc->getTarget()->getSchema(); + + $fields = $lookupFilter->getLookupFields(); + foreach ($fields as $field) { + if ($assocSchema->getColumn($field) !== null) { + $filter = $lookupFilter; + $filter->setProperty('valueName', $field); + $filter->setProperty('query', $field . '=%QUERY'); + $url = $lookupFilter->getAutocompleteRoute(); + $url['controller'] = $assocName; + $lookupFilter->setAutocompleteRoute($url); + $fieldName = substr($column, 0, -3) . '_' . $field; + + if (array_key_exists($fieldName, $labels)) { + $filter->setLabel($labels[$fieldName]); + } else { + $filter->setLabel(Inflector::humanize($fieldName)); + } + + return $filter; + } + } + } + } + } elseif (in_array($type, ['integer', 'float', 'decimal', 'biginteger'])) { + $filter = $this->filters()->new('numeric'); + } elseif ($type === 'boolean') { + $filter = $this->filters()->new('boolean'); + } + + if ($filter !== null) { + if (array_key_exists($column, $labels)) { + $filter->setLabel($labels[$column]); + } else { + $filter->setLabel(Inflector::humanize($column)); + } + } + + return $filter; + } + + /** + * Format search data from query parameters + * + * @param array|null $queryParams Query parameters + * @return array + */ + public function formatSearchData(?array $queryParams = null): array + { + if ($queryParams === null) { + $queryParams = $this->request->getQuery(); + } + $search = []; + $fields = Hash::get($queryParams, 'f', []); + $conditions = Hash::get($queryParams, 'c', []); + $values = Hash::get($queryParams, 'v', []); + + foreach ($fields as $fieldId => $field) { + $value = Hash::get($values, $fieldId, []); + $newVal = []; + foreach ($value as $k => $v) { + if (is_array($v) && count($v) == 1) { + $val = array_shift($v); + $newVal[$k] = $val; + } elseif (is_array($v) && count($v) > 1) { + foreach ($v as $i => $val) { + $newVal[$i][$k] = $val; + } + } + } + $condition = Hash::get($conditions, $fieldId); + if ($condition == 'null') { + $condition = null; + } + if ( + in_array($condition, [AbstractFilter::COND_IN, AbstractFilter::COND_NOT_IN]) + && !Hash::numeric(array_keys($newVal)) + ) { + $newVal = [$newVal]; + } + $search[$field] = [ + 'condition' => $condition, + 'value' => $newVal, + ]; + } + + return $search; + } + + /** + * Format finders from search data + * + * @param array $search The search data + * @return array + */ + public function formatFinders(array $search): array + { + $filters = []; + if (isset($search['search']) && Hash::get($search['search'], 'value.value') != null) { + $filters['search'] = Hash::get($search['search'], 'value.value'); + unset($search['search']); + } + if (!empty($search)) { + $filters['multiple'] = $search; + } + + return $filters; + } +} diff --git a/src/Model/Filter/CriteriaFilter.php b/src/Model/Filter/CriteriaFilter.php new file mode 100644 index 0000000..b89db59 --- /dev/null +++ b/src/Model/Filter/CriteriaFilter.php @@ -0,0 +1,68 @@ + + * - value + * + * @package App\Model\Filter + */ +class CriteriaFilter extends AbstractFilter +{ + /** + * CriteriaFilter constructor. + * + * @param \PlumSearch\Model\FilterRegistry $registry + * @param array $config + */ + public function __construct(FilterRegistry $registry, array $config = []) + { + if (empty($config['criteria'])) { + throw new MissingFilterException( + __('Missed "criteria" configuration setting for filter') + ); + } + parent::__construct($registry, $config); + } + + /** + * Returns query with applied filter + * + * @param \Cake\ORM\Query<\Cake\Datasource\EntityInterface> $query Query. + * @param string $field Field name. + * @param array $value Field value. + * @param array $data Filters values. + * @return \Cake\ORM\Query<\Cake\Datasource\EntityInterface> + */ + protected function _buildQuery(Query $query, string $field, $value, array $data = []): Query + { + $criteria = $this->getConfig('criteria'); + foreach ($value as $name => $values) { + $condition = $values['condition']; + if (array_key_exists($name, $criteria)) { + $criterion = $criteria[$name]; + if (is_callable($criterion)) { + $query = $criterion($query, $condition, $values['value'], $value, $this->getConfig('filterOptions', [])); + } + } + } + + return $query; + } +} diff --git a/src/Model/Filter/Criterion/AndCriterion.php b/src/Model/Filter/Criterion/AndCriterion.php new file mode 100644 index 0000000..6445b3c --- /dev/null +++ b/src/Model/Filter/Criterion/AndCriterion.php @@ -0,0 +1,85 @@ + + */ + protected array $criteria; + + /** + * AndCriterion constructor. + * + * @param array<\CakeDC\SearchFilter\Model\Filter\Criterion\BaseCriterion> $criteria + */ + public function __construct(array $criteria) + { + $this->criteria = $criteria; + } + + /** + * Finder method + * + * @param \Cake\ORM\Query<\Cake\Datasource\EntityInterface> $query + * @param string $condition + * @param array $values + * @param array $criteria + * @param array $options + * @return \Cake\ORM\Query<\Cake\Datasource\EntityInterface> + */ + public function __invoke(Query $query, string $condition, array $values, array $criteria, array $options): Query + { + $filters = $this->buildFilter($condition, $values, $criteria, $options); + if (!empty($filters)) { + return $query->where(function (QueryExpression $exp) use ($filters) { + return $exp->and($filters); + }); + } + + return $query; + } + + /** + * @inheritDoc + */ + public function buildFilter(string $condition, array $values, array $criteria, array $options = []): array|callable|null + { + $filters = []; + foreach ($this->criteria as $criterion) { + $filter = $criterion->buildFilter($condition, $values, $criteria, $options); + if ($filter !== null) { + $filters[] = $filter; + } + } + + return $filters; + } + + /** + * Checks if value applicable for criterion filtering. + * + * @param mixed $value + * @param string $condition + * @return bool + */ + public function isApplicable(mixed $value, string $condition): bool + { + $result = false; + foreach ($this->criteria as $c) { + $result = $result || $c->isApplicable($value, $condition); + } + + return $result; + } +} diff --git a/src/Model/Filter/Criterion/BaseCriterion.php b/src/Model/Filter/Criterion/BaseCriterion.php new file mode 100644 index 0000000..3ccf0bb --- /dev/null +++ b/src/Model/Filter/Criterion/BaseCriterion.php @@ -0,0 +1,181 @@ + $values + * @return mixed + */ + public function getValues(string $fieldName, string $condition, array $values): mixed + { + if (in_array($condition, [AbstractFilter::COND_IN, AbstractFilter::COND_NOT_IN]) && Hash::numeric(array_keys($values))) { + return Hash::extract($values, '{n}.' . $fieldName); + } + if (array_key_exists($fieldName, $values)) { + return $values[$fieldName]; + } + + return null; + } + + /** + * Build filter method + * + * @param string $condition + * @param array $values + * @param array $criteria + * @param array $options + * @return array|array|callable|null A callable that can be used to modify a QueryExpression, or null if not applicable. + */ + abstract public function buildFilter(string $condition, array $values, array $criteria, array $options = []): array|callable|null; + + /** + * Checks if value applicable for criterion filtering. + * + * @param mixed $value + * @param string $condition + * @return bool + */ + abstract public function isApplicable(mixed $value, string $condition): bool; + + /** + * Build like style filter. + * + * @param string|\Cake\Database\Expression\QueryExpression $field + * @param string $condition + * @param mixed $value + * @param array $options + * @return callable + */ + public function buildQueryByCondition(string|QueryExpression $field, string $condition, mixed $value, array $options = []): callable + { + $type = null; + if (isset($options['type'])) { + $type = $options['type']; + } + if ($condition == AbstractFilter::COND_EQ && is_array($value)) { + return function (QueryExpression $expr) use ($field, $value, $type): QueryExpression { + return $expr->in($field, $value, $type); + }; + } + if ($condition == AbstractFilter::COND_NE && is_array($value)) { + return function (QueryExpression $expr) use ($field, $value, $type): QueryExpression { + return $expr->notIn($field, $value, $type); + }; + } + if ($condition == AbstractFilter::COND_EQ) { + return function (QueryExpression $expr) use ($field, $value, $type): QueryExpression { + if ($value instanceof FrozenDate) { + $value = $value->format('Y-m-d'); + } elseif ($value instanceof FrozenTime) { + $value = $value->format('Y-m-d H:i'); + } + + return $expr->eq($field, $value, $type); + }; + } + if ( + in_array($condition, [ + AbstractFilter::COND_NE, + AbstractFilter::COND_GT, + AbstractFilter::COND_GE, + AbstractFilter::COND_LT, + AbstractFilter::COND_LE, + ]) + ) { + return function (QueryExpression $expr) use ($field, $value, $type, $condition): QueryExpression { + if ($value instanceof FrozenDate) { + $value = $value->format('Y-m-d'); + } elseif ($value instanceof FrozenTime) { + $value = $value->format('Y-m-d H:i'); + } + + return $expr->add(new ComparisonExpression($field, $value, $type, $condition)); + }; + } + if ($condition == AbstractFilter::COND_IN && is_array($value) && !empty(array_filter($value))) { + return function (QueryExpression $expr) use ($field, $value, $type): QueryExpression { + return $expr->in($field, $value, $type); + }; + } + if ($condition == AbstractFilter::COND_IN && !is_array($value)) { + return function (QueryExpression $expr) use ($field, $value, $type): QueryExpression { + return $expr->in($field, [$value], $type); + }; + } + + if ($condition == AbstractFilter::COND_NOT_IN && is_array($value) && !empty(array_filter($value))) { + return function (QueryExpression $expr) use ($field, $value, $type): QueryExpression { + return $expr->notIn($field, $value, $type); + }; + } + if ($condition == AbstractFilter::COND_LIKE || $condition == AbstractFilter::COND_NOT_LIKE) { + $value = (string)$value; + if (!isset($options['likeBefore'])) { + $before = true; + } else { + $before = (bool)$options['likeBefore']; + } + if (!isset($options['likeAfter'])) { + $after = true; + } else { + $after = (bool)$options['likeAfter']; + } + if ($before) { + $value = '%' . $value; + } + if ($after) { + $value = $value . '%'; + } + + if (isset($options['caseInsensitive']) && $options['caseInsensitive'] === true) { + return function (QueryExpression $expr) use ($field, $value, $type, $condition) { + if ($condition == AbstractFilter::COND_LIKE) { + return $expr->add(new ComparisonExpression($field, $value, $type, 'ILIKE')); + } else { + return $expr->add(new ComparisonExpression($field, $value, $type, 'NOT ILIKE')); + } + }; + } + + return function (QueryExpression $expr) use ($field, $value, $type, $condition): QueryExpression { + if ($condition == AbstractFilter::COND_LIKE) { + return $expr->like($field, $value, $type); + } else { + return $expr->notLike($field, $value, $type); + } + }; + } + + return function (QueryExpression $expr): QueryExpression { + return $expr; + }; + } +} diff --git a/src/Model/Filter/Criterion/BoolCriterion.php b/src/Model/Filter/Criterion/BoolCriterion.php new file mode 100644 index 0000000..d9a8008 --- /dev/null +++ b/src/Model/Filter/Criterion/BoolCriterion.php @@ -0,0 +1,68 @@ +field = $field; + } + + /** + * Checks if value applicable for criterion filtering. + * + * @param mixed $value + * @param string $condition + * @return bool + */ + public function isApplicable(mixed $value, string $condition): bool + { + return is_array($value) && !empty($value) || $value !== '' && $value !== null; + } + + /** + * Finder method + * + * @param \Cake\ORM\Query<\Cake\Datasource\EntityInterface> $query + * @param string|null $condition + * @param array $values + * @param array $criteria + * @param array $options + * @return \Cake\ORM\Query<\Cake\Datasource\EntityInterface> + */ + public function __invoke(Query $query, $condition, array $values, array $criteria, $options): Query + { + $filter = $this->buildFilter($condition, $values, $criteria, $options); + if (!empty($filter)) { + return $query->where($filter); + } + + return $query; + } + + /** + * @inheritDoc + */ + public function buildFilter(string $condition, array $values, array $criteria, array $options = []): array|callable|null + { + $value = $this->getValues('value', AbstractFilter::COND_EQ, $values); + + return $this->buildQueryByCondition($this->field, '=', $value, ['type' => 'integer']); + } +} diff --git a/src/Model/Filter/Criterion/CriteriaBuilder.php b/src/Model/Filter/Criterion/CriteriaBuilder.php new file mode 100644 index 0000000..d0f0160 --- /dev/null +++ b/src/Model/Filter/Criterion/CriteriaBuilder.php @@ -0,0 +1,124 @@ + $values The values to filter by. + * @param array $criteria Additional criteria. + * @param array $options Additional options. + * @return array|array|callable|null A callable that can be used to modify a QueryExpression, or null if not applicable. + */ + public function buildFilter(string $condition, array $values, array $criteria, array $options = []): array|callable|null; + + /** + * Builds a query based on the given condition. + * + * @param string|\Cake\Database\Expression\QueryExpression $field The field to apply the condition to. + * @param string $condition The condition to apply. + * @param mixed $value The value to compare against. + * @param array $options Additional options. + * @return callable A callable that can be used to modify a QueryExpression. + */ + public function buildQueryByCondition(string|QueryExpression $field, string $condition, mixed $value, array $options = []): callable; +} diff --git a/src/Model/Filter/Criterion/DateCriterion.php b/src/Model/Filter/Criterion/DateCriterion.php new file mode 100644 index 0000000..62bd480 --- /dev/null +++ b/src/Model/Filter/Criterion/DateCriterion.php @@ -0,0 +1,189 @@ +field = $field; + $this->format = $format; + $this->func = new FunctionsBuilder(); + } + + /** + * Checks if value applicable for criterion filtering. + * + * @param mixed $value + * @param string $condition + * @return bool + */ + public function isApplicable(mixed $value, string $condition): bool + { + return is_array($value) && !empty($value) || ($value !== '' && $value !== null && !is_array($value)); + } + + /** + * Finder method + * + * @param \Cake\ORM\Query<\Cake\Datasource\EntityInterface> $query + * @param string $condition + * @param array $values + * @param array $criteria + * @param array $options + * @return \Cake\ORM\Query<\Cake\Datasource\EntityInterface> + */ + public function __invoke(Query $query, string $condition, array $values, array $criteria, array $options): Query + { + $filter = $this->buildFilter($condition, $values, $criteria, $options); + if (!empty($filter)) { + return $query->where($filter); + } + + return $query; + } + + /** + * @inheritDoc + */ + public function buildFilter(string $condition, array $values, array $criteria, array $options = []): array|callable|null + { + return $this->buildCondition($this->field, $condition, $values, $options); + } + + /** + * Build finder condition + * + * @param string|\Cake\Database\ExpressionInterface $fieldName + * @param string $condition + * @param array $values + * @param array $options + * @return callable|null + */ + public function buildCondition(string|ExpressionInterface $fieldName, string $condition, array $values, array $options = []): ?callable + { + if ($condition == AbstractFilter::COND_BETWEEN) { + $from = $this->getValues('date_from', $condition, $values); + $to = $this->getValues('date_to', $condition, $values); + + $from = $this->isApplicable($from, $condition) ? $this->prepareTime($from) : null; + $to = $this->isApplicable($to, $condition) ? $this->prepareTime($to) : null; + + if (!empty($from) && !empty($to)) { + return function (QueryExpression $exp) use ($fieldName, $from, $to): QueryExpression { + return $exp->between($fieldName, $from, $to, 'datetime'); + }; + } elseif (!empty($from)) { + return function (QueryExpression $exp) use ($fieldName, $from): QueryExpression { + return $exp->gte($fieldName, $from, 'datetime'); + }; + } elseif (!empty($to)) { + return function (QueryExpression $exp) use ($fieldName, $to): QueryExpression { + return $exp->lte($fieldName, $to, 'datetime'); + }; + } + + return null; + } elseif ($condition == AbstractFilter::COND_TODAY) { + $value = $this->func->now('date'); + $condition = AbstractFilter::COND_EQ; + } elseif ($condition == AbstractFilter::COND_YESTERDAY) { + $value = $this->func->dateAdd('CURRENT_DATE', -1, 'DAY'); + $condition = AbstractFilter::COND_EQ; + } elseif ($condition == AbstractFilter::COND_THIS_WEEK) { + if (is_string($fieldName)) { + $fieldName = new IdentifierExpression($fieldName); + } + $fieldName = $this->buildYearWeekExpression($fieldName); + $value = $this->buildYearWeekExpression('CURRENT_DATE'); + $condition = AbstractFilter::COND_EQ; + } elseif ($condition == AbstractFilter::COND_LAST_WEEK) { + if (is_string($fieldName)) { + $fieldName = new IdentifierExpression($fieldName); + } + $fieldName = $this->buildYearWeekExpression($fieldName); + $value = $this->buildYearWeekExpression( + $this->func->dateAdd('CURRENT_DATE', -7, 'DAY') + ); + $condition = AbstractFilter::COND_EQ; + } else { + $value = $this->getValues('value', $condition, $values); + if (!$this->isApplicable($value, $condition)) { + return null; + } + $value = $this->prepareTime($value); + } + + return $this->buildQueryByCondition($fieldName, $condition, $value); + } + + /** + * Create an expression that simulate the result from the mysql function `yearweek` that works on all drivers + * + * @param string|\Cake\Database\ExpressionInterface $value can be a table field or a date + * @return \Cake\Database\Expression\FunctionExpression + */ + protected function buildYearWeekExpression(string|ExpressionInterface $value): FunctionExpression + { + $extractYear = (new FunctionExpression('CAST')) + ->setConjunction(' AS ') + ->add([ + $this->func->extract('YEAR', $value), + 'varchar' => 'literal', + ]); + $extractWeek = (new FunctionExpression('CAST')) + ->setConjunction(' AS ') + ->add([ + $this->func->extract('WEEK', $value), + 'varchar' => 'literal', + ]); + + return $this->func->concat([$extractYear, $extractWeek]); + } + + /** + * Create a date/time object from a string + * + * @param string $dateStr + * @return \DateTimeInterface + */ + protected function prepareTime(string $dateStr): \DateTimeInterface + { + return FrozenDate::createFromFormat($this->format, $dateStr); + } +} diff --git a/src/Model/Filter/Criterion/DateTimeCriterion.php b/src/Model/Filter/Criterion/DateTimeCriterion.php new file mode 100644 index 0000000..55f8c6f --- /dev/null +++ b/src/Model/Filter/Criterion/DateTimeCriterion.php @@ -0,0 +1,38 @@ +format = $format; + } + + /** + * Create a date/time object from a string + * + * @param string $dateStr + * @return \DateTimeInterface + */ + protected function prepareTime(string $dateStr): \DateTimeInterface + { + return FrozenTime::createFromFormat($this->format, $dateStr); + } +} diff --git a/src/Model/Filter/Criterion/InCriterion.php b/src/Model/Filter/Criterion/InCriterion.php new file mode 100644 index 0000000..24e31af --- /dev/null +++ b/src/Model/Filter/Criterion/InCriterion.php @@ -0,0 +1,94 @@ +field = $field; + $this->table = $table; + $this->criterion = $criterion; + } + + /** + * Finder method + * + * @param \Cake\ORM\Query<\Cake\Datasource\EntityInterface> $query + * @param string $condition + * @param array $values + * @param array $criteria + * @param array $options + * @return \Cake\ORM\Query<\Cake\Datasource\EntityInterface> + */ + public function __invoke(Query $query, string $condition, array $values, array $criteria, array $options): Query + { + $filter = $this->buildFilter($condition, $values, $criteria, $options); + if ($filter !== null) { + return $query->where($filter); + } + + return $query; + } + + /** + * @inheritDoc + */ + public function buildFilter(string $condition, array $values, array $criteria, array $options = []): array|callable|null + { + $filter = $this->criterion->buildFilter($condition, $values, $criteria, $options); + if ($filter !== null) { + $subconditionQuery = $this->table->find() + ->select([$this->table->aliasField($this->table->getPrimaryKey())]) + ->where($filter); + + return function (QueryExpression $expr) use ($subconditionQuery): QueryExpression { + return $expr->in($this->field, $subconditionQuery); + }; + } + + return null; + } + + /** + * Checks if value applicable for criterion filtering. + * + * @param mixed $value + * @param string $condition + * @return bool + */ + public function isApplicable(mixed $value, string $condition): bool + { + return $this->criterion->isApplicable($value, $condition); + } +} diff --git a/src/Model/Filter/Criterion/LookupCriterion.php b/src/Model/Filter/Criterion/LookupCriterion.php new file mode 100644 index 0000000..afddf1f --- /dev/null +++ b/src/Model/Filter/Criterion/LookupCriterion.php @@ -0,0 +1,118 @@ +field = $field; + $this->table = $table; + $this->criterion = $criterion; + } + + /** + * Finder method + * + * @param \Cake\ORM\Query<\Cake\Datasource\EntityInterface> $query + * @param string $condition + * @param array $values + * @param array $criteria + * @param array $options + * @return \Cake\ORM\Query<\Cake\Datasource\EntityInterface> + */ + public function __invoke(Query $query, string $condition, array $values, array $criteria, array $options): Query + { + $filter = $this->buildFilter($condition, $values, $criteria, $options); + if (!empty($filter)) { + $query->where($filter); + } + + return $query; + } + + /** + * @inheritDoc + */ + public function buildFilter(string $condition, array $values, array $criteria, array $options = []): array|callable|null + { + $value = $this->getValues('id', $condition, $values); + $stringValue = $this->getValues('value', $condition, $values); + + if ($condition == AbstractFilter::COND_LIKE || $condition == AbstractFilter::COND_NOT_LIKE || (in_array($value, ['null', null]) && !empty($stringValue))) { + $filter = $this->criterion->buildFilter($condition, ['value' => $stringValue], $criteria, array_merge(['likeBefore' => false], $options)); + if ($filter != null) { + $subconditionQuery = $this->table->find() + ->select([$this->table->aliasField($this->table->getPrimaryKey())]) + ->where($filter); + + return function (QueryExpression $expr) use ($subconditionQuery): QueryExpression { + return $expr->in($this->field, $subconditionQuery); + }; + } + } elseif ($this->isIdApplicable($value, $condition)) { + $firstValue = is_array($value) ? reset($value) : $value; + $type = Validation::uuid($firstValue) ? 'uuid' : 'integer'; + + return $this->buildQueryByCondition($this->field, $condition, $value, array_merge(['type' => $type], $options)); + } + + return null; + } + + /** + * Checks if value applicable for criterion filtering. + * + * @param mixed $value Checked value. + * @param string $condition Condition rule. + * @return bool + */ + public function isIdApplicable(mixed $value, string $condition): bool + { + return is_array($value) && !empty($value) || $value !== ''; + } + + /** + * Checks if value applicable for criterion filtering. + * + * @param mixed $value + * @param string $condition + * @return bool + */ + public function isApplicable(mixed $value, string $condition): bool + { + return is_array($value) && !empty($value) || $value !== '' && !is_array($value); + } +} diff --git a/src/Model/Filter/Criterion/NumericCriterion.php b/src/Model/Filter/Criterion/NumericCriterion.php new file mode 100644 index 0000000..461f8ef --- /dev/null +++ b/src/Model/Filter/Criterion/NumericCriterion.php @@ -0,0 +1,84 @@ +field = $field; + } + + /** + * Checks if value applicable for criterion filtering. + * + * @param mixed $value + * @param string $condition + * @return bool + */ + public function isApplicable(mixed $value, string $condition): bool + { + return is_array($value) && !empty($value) || $value !== '' && $value !== null; + } + + /** + * Finder method + * + * @param \Cake\ORM\Query<\Cake\Datasource\EntityInterface> $query + * @param string $condition + * @param array $values + * @param array $criteria + * @param array $options + * @return \Cake\ORM\Query<\Cake\Datasource\EntityInterface> + */ + public function __invoke(Query $query, string $condition, array $values, array $criteria, array $options): Query + { + $filter = $this->buildFilter($condition, $values, $criteria, $options); + if (!empty($filter)) { + return $query->where($filter); + } + + return $query; + } + + /** + * @inheritDoc + */ + public function buildFilter(string $condition, array $values, array $criteria, array $options = []): array|callable|null + { + $filter = null; + $fieldName = $this->field; + if ($condition == AbstractFilter::COND_BETWEEN) { + $from = $this->getValues('from', $condition, $values); + $to = $this->getValues('to', $condition, $values); + if ($this->isApplicable($from, $condition) & $this->isApplicable($to, $condition)) { + $filter = function (QueryExpression $exp) use ($fieldName, $from, $to): QueryExpression { + return $exp->between($fieldName, $from, $to, 'integer'); + }; + } + } else { + $value = $this->getValues('value', $condition, $values); + if ($this->isApplicable($value, $condition)) { + $filter = $this->buildQueryByCondition($fieldName, $condition, $value, array_merge(['type' => 'integer'], $options)); + } + } + + return $filter; + } +} diff --git a/src/Model/Filter/Criterion/OrCriterion.php b/src/Model/Filter/Criterion/OrCriterion.php new file mode 100644 index 0000000..b77315d --- /dev/null +++ b/src/Model/Filter/Criterion/OrCriterion.php @@ -0,0 +1,88 @@ +criteria = $criteria; + } + + /** + * Finder method + * + * @param \Cake\ORM\Query<\Cake\Datasource\EntityInterface> $query + * @param string $condition + * @param array $values + * @param array $criteria + * @param array $options + * @return \Cake\ORM\Query<\Cake\Datasource\EntityInterface> + */ + public function __invoke(Query $query, string $condition, array $values, array $criteria, array $options): Query + { + $filters = $this->buildFilter($condition, $values, $criteria, $options); + if (!empty($filters)) { + return $query->where(function (QueryExpression $exp) use ($filters) { + return $exp->or($filters); + }); + } + + return $query; + } + + /** + * @inheritDoc + */ + public function buildFilter(string $condition, array $values, array $criteria, array $options = []): array|callable|null + { + $filters = []; + foreach ($this->criteria as $criterion) { + $filter = $criterion->buildFilter($condition, $values, $criteria, $options); + if ($filter != null) { + $filters[] = $filter; + } + } + if (!empty($filters)) { + return $filters; + } + + return null; + } + + /** + * Checks if value applicable for criterion filtering. + * + * @param mixed $value + * @param string $condition + * @return bool + */ + public function isApplicable(mixed $value, string $condition): bool + { + $result = false; + foreach ($this->criteria as $c) { + $result = $result || $c->isApplicable($value, $condition); + } + + return $result; + } +} diff --git a/src/Model/Filter/Criterion/StringCriterion.php b/src/Model/Filter/Criterion/StringCriterion.php new file mode 100644 index 0000000..136a0e7 --- /dev/null +++ b/src/Model/Filter/Criterion/StringCriterion.php @@ -0,0 +1,67 @@ +field = $field; + } + + /** + * Checks if value applicable for criterion filtering. + * + * @param mixed $value + * @param string $condition + * @return bool + */ + public function isApplicable(mixed $value, string $condition): bool + { + return is_array($value) && !empty($value) || $value !== '' && $value !== null && !is_array($value); + } + + /** + * Finder method + * + * @param \Cake\ORM\Query<\Cake\Datasource\EntityInterface> $query + * @param string $condition + * @param array $values + * @param array $criteria + * @param array $options + * @return \Cake\ORM\Query<\Cake\Datasource\EntityInterface> + */ + public function __invoke(Query $query, string $condition, array $values, array $criteria, array $options): Query + { + $filter = $this->buildFilter($condition, $values, $criteria, $options); + if (!empty($filter)) { + return $query->where($filter); + } + + return $query; + } + + /** + * @inheritDoc + */ + public function buildFilter(string $condition, array $values, array $criteria, array $options = []): array|callable|null + { + $value = $this->getValues('value', $condition, $values); + + return $this->buildQueryByCondition($this->field, $condition, $value, array_merge(['likeBefore' => false, 'likeAfter' => true], $options)); + } +} diff --git a/src/Plugin.php b/src/Plugin.php new file mode 100644 index 0000000..f53a479 --- /dev/null +++ b/src/Plugin.php @@ -0,0 +1,18 @@ + + +element('CakeDC/SearchFilter.Search/v_templates'); ?> + + + + diff --git a/templates/element/Search/v_templates.php b/templates/element/Search/v_templates.php new file mode 100644 index 0000000..e064e6e --- /dev/null +++ b/templates/element/Search/v_templates.php @@ -0,0 +1,726 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/Fixture/ArticlesFixture.php b/tests/Fixture/ArticlesFixture.php new file mode 100644 index 0000000..209416d --- /dev/null +++ b/tests/Fixture/ArticlesFixture.php @@ -0,0 +1,28 @@ + 1, 'title' => 'First Article', 'body' => 'First Article Body', 'published' => 'Y'], + ['author_id' => 3, 'title' => 'Second Article', 'body' => 'Second Article Body', 'published' => 'Y'], + ['author_id' => 1, 'title' => 'Third Article', 'body' => 'Third Article Body', 'published' => 'Y'], + ]; +} diff --git a/tests/Fixture/ArticlesTagsFixture.php b/tests/Fixture/ArticlesTagsFixture.php new file mode 100644 index 0000000..57ca343 --- /dev/null +++ b/tests/Fixture/ArticlesTagsFixture.php @@ -0,0 +1,29 @@ + 1, 'tag_id' => 1], + ['article_id' => 1, 'tag_id' => 2], + ['article_id' => 2, 'tag_id' => 1], + ['article_id' => 2, 'tag_id' => 3], + ]; +} diff --git a/tests/Fixture/AuthorsFixture.php b/tests/Fixture/AuthorsFixture.php new file mode 100644 index 0000000..6af1287 --- /dev/null +++ b/tests/Fixture/AuthorsFixture.php @@ -0,0 +1,28 @@ + 'evgeny'], + ['name' => 'mark'], + ['name' => 'larry'], + ]; +} diff --git a/tests/Fixture/TagsFixture.php b/tests/Fixture/TagsFixture.php new file mode 100644 index 0000000..e1ff9de --- /dev/null +++ b/tests/Fixture/TagsFixture.php @@ -0,0 +1,28 @@ + 'tag1'], + ['name' => 'tag2'], + ['name' => 'tag3'], + ]; +} diff --git a/tests/TestCase/Controller/ArticlesControllerTest.php b/tests/TestCase/Controller/ArticlesControllerTest.php new file mode 100644 index 0000000..094f736 --- /dev/null +++ b/tests/TestCase/Controller/ArticlesControllerTest.php @@ -0,0 +1,71 @@ +get('/articles'); + $this->assertResponseOk(); + $this->assertResponseContains('Articles'); + } + + public function testString(): void + { + $this->get('/articles?f[0]=title&c[0]=like&v[0][value][]=First'); + $this->assertResponseOk(); + $this->assertResponseContains('First Article'); + $this->assertResponseNotContains('Second Article'); + } + + public function testAuthorLike(): void + { + $this->get('/articles?f[0]=author_id&c[0]=like&v[0][value][]=larry'); + $this->assertResponseOk(); + $this->assertResponseContains('Second Article'); + $this->assertResponseNotContains('First Article'); + } + + public function testAuthorEqual(): void + { + $this->get('/articles?f[0]=author_id&c[0]=%3D&v[0][id][]=1'); + // debug($this->_getBodyAsString()); + $this->assertResponseOk(); + $this->assertResponseContains('First Article'); + $this->assertResponseNotContains('Second Article'); + } + + public function testCreatedDate(): void + { + $this->get('/articles?f[0]=created&c[0]=between&v[0][from]=2023-01-01T00:00&v[0][to]=2023-12-31T00:00'); + $this->assertResponseOk(); + $this->assertResponseContains('First Article'); + $this->assertResponseNotContains('Old Article'); + } + + public function testCombinedFilters(): void + { + $this->get('/articles?f[0]=title&c[0]=like&v[0][value][]=First&f[1]=created&c[1]=greaterOrEqual&v[1][value][]=2023-01-01T00:00'); + $this->assertResponseOk(); + $this->assertResponseContains('First Article'); + $this->assertResponseNotContains('Second Article'); + $this->assertResponseNotContains('Old Article'); + } +} diff --git a/tests/TestCase/Filter/BooleanFilterTest.php b/tests/TestCase/Filter/BooleanFilterTest.php new file mode 100644 index 0000000..b81ccc4 --- /dev/null +++ b/tests/TestCase/Filter/BooleanFilterTest.php @@ -0,0 +1,58 @@ +booleanFilter = new BooleanFilter(); + } + + public function tearDown(): void + { + unset($this->booleanFilter); + parent::tearDown(); + } + + public function testConstruct(): void + { + $this->assertInstanceOf(AbstractFilter::class, $this->booleanFilter); + $this->assertInstanceOf(BooleanFilter::class, $this->booleanFilter); + } + + public function testToArray(): void + { + $result = $this->booleanFilter->toArray(); + + $this->assertIsArray($result); + $this->assertArrayHasKey('type', $result); + $this->assertEquals('select', $result['type']); + } + + public function testGetConditions(): void + { + $conditions = $this->booleanFilter->getConditions(); + + $this->assertIsArray($conditions); + foreach ($conditions as $value) { + $this->assertIsString($value); + } + } +} diff --git a/tests/TestCase/Filter/CriteriaBuilderTest.php b/tests/TestCase/Filter/CriteriaBuilderTest.php new file mode 100644 index 0000000..21789cc --- /dev/null +++ b/tests/TestCase/Filter/CriteriaBuilderTest.php @@ -0,0 +1,182 @@ +builder = new CriteriaBuilder(); + $this->table = TableRegistry::getTableLocator()->get('Users'); + } + + /** + * tearDown method + * + * @return void + */ + public function tearDown(): void + { + unset($this->builder); + unset($this->table); + TableRegistry::getTableLocator()->clear(); + parent::tearDown(); + } + + /** + * Test and method + * + * @return void + */ + public function testAnd(): void + { + $criteria = [ + new StringCriterion('name'), + new NumericCriterion('age'), + ]; + $result = $this->builder->and($criteria); + $this->assertInstanceOf(AndCriterion::class, $result); + } + + /** + * Test or method + * + * @return void + */ + public function testOr(): void + { + $criteria = [ + new StringCriterion('name'), + new NumericCriterion('age'), + ]; + $result = $this->builder->or($criteria); + $this->assertInstanceOf(OrCriterion::class, $result); + } + + /** + * Test in method + * + * @return void + */ + public function testIn(): void + { + $field = 'category'; + $criterion = new StringCriterion('category'); + $result = $this->builder->in($field, $this->table, $criterion); + $this->assertInstanceOf(InCriterion::class, $result); + } + + /** + * Test string method + * + * @return void + */ + public function testString(): void + { + $result = $this->builder->string('name'); + $this->assertInstanceOf(StringCriterion::class, $result); + } + + /** + * Test numeric method + * + * @return void + */ + public function testNumeric(): void + { + $result = $this->builder->numeric('age'); + $this->assertInstanceOf(NumericCriterion::class, $result); + } + + /** + * Test bool method + * + * @return void + */ + public function testBool(): void + { + $result = $this->builder->bool('is_active'); + $this->assertInstanceOf(BoolCriterion::class, $result); + } + + /** + * Test date method + * + * @return void + */ + public function testDate(): void + { + $result = $this->builder->date('created_date'); + $this->assertInstanceOf(DateCriterion::class, $result); + } + + /** + * Test date method with custom format + * + * @return void + */ + public function testDateWithCustomFormat(): void + { + $result = $this->builder->date('created_date'); + $this->assertInstanceOf(DateCriterion::class, $result); + } + + /** + * Test datetime method + * + * @return void + */ + public function testDatetime(): void + { + $result = $this->builder->datetime('created_at'); + $this->assertInstanceOf(DateTimeCriterion::class, $result); + } + + /** + * Test datetime method with custom format + * + * @return void + */ + public function testDatetimeWithCustomFormat(): void + { + $result = $this->builder->datetime('created_at'); + $this->assertInstanceOf(DateTimeCriterion::class, $result); + } +} diff --git a/tests/TestCase/Filter/DateFilterTest.php b/tests/TestCase/Filter/DateFilterTest.php new file mode 100644 index 0000000..493daec --- /dev/null +++ b/tests/TestCase/Filter/DateFilterTest.php @@ -0,0 +1,108 @@ +dateFilter = new DateFilter(); + } + + /** + * tearDown method + * + * @return void + */ + public function tearDown(): void + { + unset($this->dateFilter); + parent::tearDown(); + } + + /** + * Test constructor and inheritance + * + * @return void + */ + public function testConstruct(): void + { + $this->assertInstanceOf(AbstractFilter::class, $this->dateFilter); + $this->assertInstanceOf(DateFilter::class, $this->dateFilter); + } + + /** + * Test default date format + * + * @return void + */ + public function testDefaultDateFormat(): void + { + $this->assertEquals('DD/MM/YYYY', $this->dateFilter->getDateFormat()); + } + + /** + * Test setting custom date format + * + * @return void + */ + public function testSetDateFormat(): void + { + $this->dateFilter->setDateFormat('YYYY-MM-DD'); + $this->assertEquals('YYYY-MM-DD', $this->dateFilter->getDateFormat()); + } + + /** + * Test getConditions method + * + * @return void + */ + public function testGetConditions(): void + { + $conditions = $this->dateFilter->getConditions(); + $this->assertIsArray($conditions); + $this->assertArrayHasKey(AbstractFilter::COND_EQ, $conditions); + $this->assertArrayHasKey(AbstractFilter::COND_GT, $conditions); + $this->assertArrayHasKey(AbstractFilter::COND_LT, $conditions); + $this->assertArrayHasKey(AbstractFilter::COND_BETWEEN, $conditions); + } + + /** + * Test toArray method + * + * @return void + */ + public function testToArray(): void + { + $result = $this->dateFilter->toArray(); + $this->assertIsArray($result); + $this->assertArrayHasKey('type', $result); + $this->assertEquals('date', $result['type']); + $this->assertArrayHasKey('dateFormat', $result); + $this->assertEquals('DD/MM/YYYY', $result['dateFormat']); + } +} diff --git a/tests/TestCase/Filter/DateTimeFilterTest.php b/tests/TestCase/Filter/DateTimeFilterTest.php new file mode 100644 index 0000000..00c7c55 --- /dev/null +++ b/tests/TestCase/Filter/DateTimeFilterTest.php @@ -0,0 +1,59 @@ +dateTimeFilter = new DateTimeFilter(); + } + + public function tearDown(): void + { + unset($this->dateTimeFilter); + parent::tearDown(); + } + + public function testConstruct(): void + { + $this->assertInstanceOf(AbstractFilter::class, $this->dateTimeFilter); + $this->assertInstanceOf(DateTimeFilter::class, $this->dateTimeFilter); + } + + public function testToArray(): void + { + $result = $this->dateTimeFilter->toArray(); + + $this->assertIsArray($result); + $this->assertArrayHasKey('type', $result); + $this->assertEquals('datetime', $result['type']); + } + + public function testGetConditions(): void + { + $conditions = $this->dateTimeFilter->getConditions(); + + $this->assertIsArray($conditions); + $this->assertArrayHasKey(AbstractFilter::COND_EQ, $conditions); + $this->assertArrayHasKey(AbstractFilter::COND_GT, $conditions); + $this->assertArrayHasKey(AbstractFilter::COND_LT, $conditions); + $this->assertArrayHasKey(AbstractFilter::COND_BETWEEN, $conditions); + } +} diff --git a/tests/TestCase/Filter/FilterCollectionTest.php b/tests/TestCase/Filter/FilterCollectionTest.php new file mode 100644 index 0000000..368f7ea --- /dev/null +++ b/tests/TestCase/Filter/FilterCollectionTest.php @@ -0,0 +1,183 @@ +filterCollection = new FilterCollection(); + } + + /** + * tearDown method + * + * @return void + */ + public function tearDown(): void + { + unset($this->filterCollection); + parent::tearDown(); + } + + /** + * Test constructor + * + * @return void + */ + public function testConstructor(): void + { + $stringFilter = new StringFilter(); + $collection = new FilterCollection(['test' => $stringFilter]); + $this->assertCount(1, $collection); + } + + /** + * Test add method + * + * @return void + */ + public function testAdd(): void + { + $stringFilter = new StringFilter(); + $this->filterCollection->add('test', $stringFilter); + $this->assertCount(1, $this->filterCollection); + } + + /** + * Test getCriteria method + * + * @return void + */ + public function testGetCriteria(): void + { + $stringFilter = new StringFilter(); + $numericFilter = new NumericFilter(); + + $stringCriterion = new StringCriterion('test'); + $numericCriterion = new NumericCriterion('test2'); + + $stringFilter->setCriterion($stringCriterion); + $numericFilter->setCriterion($numericCriterion); + + $this->filterCollection->add('test', $stringFilter); + $this->filterCollection->add('test2', $numericFilter); + + $criteria = $this->filterCollection->getCriteria(); + + $this->assertCount(2, $criteria); + $this->assertSame( + [ + 'test' => $stringCriterion, + 'test2' => $numericCriterion, + ], + $criteria + ); + } + + /** + * Test getViewConfig method + * + * @return void + */ + public function testGetViewConfig(): void + { + $stringFilter = new StringFilter(); + $numericFilter = new NumericFilter(); + + $stringFilter->setLabel('String Filter'); + $numericFilter->setLabel('Numeric Filter'); + + $this->filterCollection->add('test', $stringFilter); + $this->filterCollection->add('test2', $numericFilter); + + $viewConfig = $this->filterCollection->getViewConfig(); + + $this->assertCount(2, $viewConfig); + $this->assertEquals('String Filter', $viewConfig['test']['name']); + $this->assertEquals('Numeric Filter', $viewConfig['test2']['name']); + } + + /** + * Test sorting in getViewConfig method + * + * @return void + */ + public function testGetViewConfigSorting(): void + { + $filter1 = new StringFilter(); + $filter2 = new StringFilter(); + $filter3 = new StringFilter(); + + $filter1->setLabel('C Filter'); + $filter2->setLabel('A Filter'); + $filter3->setLabel('B Filter'); + + $this->filterCollection->add('test1', $filter1); + $this->filterCollection->add('test2', $filter2); + $this->filterCollection->add('test3', $filter3); + + $viewConfig = $this->filterCollection->getViewConfig(); + + $labels = array_column($viewConfig, 'name'); + $this->assertEquals(['A Filter', 'B Filter', 'C Filter'], $labels); + } + + /** + * Test getIterator method + * + * @return void + */ + public function testGetIterator(): void + { + $stringFilter = new StringFilter(); + $numericFilter = new NumericFilter(); + $this->filterCollection->add('test1', $stringFilter); + $this->filterCollection->add('test2', $numericFilter); + + $iterator = $this->filterCollection->getIterator(); + $this->assertInstanceOf(ArrayIterator::class, $iterator); + $this->assertCount(2, $iterator); + } + + /** + * Test count method + * + * @return void + */ + public function testCount(): void + { + $this->assertCount(0, $this->filterCollection); + $stringFilter = new StringFilter(); + $this->filterCollection->add('test', $stringFilter); + $this->assertCount(1, $this->filterCollection); + } +} diff --git a/tests/TestCase/Filter/FilterRegistryTest.php b/tests/TestCase/Filter/FilterRegistryTest.php new file mode 100644 index 0000000..eff7008 --- /dev/null +++ b/tests/TestCase/Filter/FilterRegistryTest.php @@ -0,0 +1,116 @@ +filterRegistry = new FilterRegistry(); + $this->filterRegistry->load('numeric', ['className' => NumericFilter::class]); + $this->filterRegistry->load('string', ['className' => StringFilter::class]); + } + + /** + * tearDown method + * + * @return void + */ + public function tearDown(): void + { + unset($this->filterRegistry); + parent::tearDown(); + } + + /** + * Test __get method + * + * @return void + */ + public function testGet(): void + { + $stringFilter = $this->filterRegistry->get('string'); + $this->assertInstanceOf(FilterInterface::class, $stringFilter); + } + + /** + * Test __get method + * + * @return void + */ + public function testMagicGet(): void + { + $retrievedFilter = $this->filterRegistry->string; + $this->assertInstanceOf(StringFilter::class, $retrievedFilter); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Filter class nonExistentFilterFilter could not be found.'); + $this->filterRegistry->nonExistentFilter; + } + + /** + * Test __isset method + * + * @return void + */ + public function testIsset(): void + { + $this->assertTrue(isset($this->filterRegistry->string)); + } + + /** + * Test _throwMissingClassError method + * + * @return void + */ + public function testThrowMissingClassError(): void + { + $this->expectException(MissingFilterException::class); + $this->expectExceptionMessage('Filter class NonExistentFilter could not be found.'); + + $method = new ReflectionMethod(FilterRegistry::class, '_throwMissingClassError'); + $method->setAccessible(true); + + $method->invoke($this->filterRegistry, 'NonExistent', null); + } + + /** + * Test new method + * + * @return void + */ + public function testNew(): void + { + $stringFilter = $this->filterRegistry->new('string'); + $this->assertInstanceOf(FilterInterface::class, $stringFilter); + } +} diff --git a/tests/TestCase/Filter/LookupFilterTest.php b/tests/TestCase/Filter/LookupFilterTest.php new file mode 100644 index 0000000..dd071ee --- /dev/null +++ b/tests/TestCase/Filter/LookupFilterTest.php @@ -0,0 +1,169 @@ +scope('/', function ($routes) { + $routes->connect('/authors/autocomplete', ['controller' => 'Authors', 'action' => 'autocomplete']); + }); + + $this->lookupFilter = new LookupFilter(); + $this->lookupFilter->setAutocompleteRoute([ + 'controller' => 'Authors', + 'action' => 'autocomplete', + ]); + } + + /** + * tearDown method + * + * @return void + */ + public function tearDown(): void + { + unset($this->lookupFilter); + Router::reload(); + parent::tearDown(); + } + + /** + * Test constructor and inheritance + * + * @return void + */ + public function testConstruct(): void + { + $this->assertInstanceOf(AbstractFilter::class, $this->lookupFilter); + $this->assertInstanceOf(LookupFilter::class, $this->lookupFilter); + } + + /** + * Test default properties + * + * @return void + */ + public function testDefaultProperties(): void + { + $properties = $this->lookupFilter->getProperties(); + $this->assertEquals('autocomplete', $properties['type']); + $this->assertEquals('id', $properties['idName']); + $this->assertEquals('name', $properties['valueName']); + $this->assertEquals('name=%QUERY', $properties['query']); + $this->assertEquals('%QUERY', $properties['wildcard']); + } + + /** + * Test getAutocompleteRoute method + * + * @return void + */ + public function testGetAutocompleteRoute(): void + { + $route = $this->lookupFilter->getAutocompleteRoute(); + $this->assertIsArray($route); + $this->assertEquals('autocomplete', $route['action']); + } + + /** + * Test setAutocompleteRoute method + * + * @return void + */ + public function testSetAutocompleteRoute(): void + { + $newRoute = ['action' => 'customAutocomplete', '_ext' => 'xml']; + $this->lookupFilter->setAutocompleteRoute($newRoute); + $this->assertEquals($newRoute, $this->lookupFilter->getAutocompleteRoute()); + } + + /** + * Test getLookupFields method + * + * @return void + */ + public function testGetLookupFields(): void + { + $fields = $this->lookupFilter->getLookupFields(); + $this->assertIsArray($fields); + $this->assertEquals(['name', 'title', 'id'], $fields); + } + + /** + * Test setLookupFields method + * + * @return void + */ + public function testSetLookupFields(): void + { + $newFields = ['custom_name', 'custom_id']; + $this->lookupFilter->setLookupFields($newFields); + $this->assertEquals($newFields, $this->lookupFilter->getLookupFields()); + } + + /** + * Test getConditions method + * + * @return void + */ + public function testGetConditions(): void + { + $conditions = $this->lookupFilter->getConditions(); + $this->assertIsArray($conditions); + $this->assertArrayHasKey(AbstractFilter::COND_EQ, $conditions); + $this->assertArrayHasKey(AbstractFilter::COND_NE, $conditions); + $this->assertArrayHasKey(AbstractFilter::COND_IN, $conditions); + $this->assertArrayHasKey(AbstractFilter::COND_LIKE, $conditions); + } + + /** + * Test toArray method + * + * @return void + */ + public function testToArray(): void + { + $result = $this->lookupFilter->toArray(); + + $this->assertArrayHasKey('autocompleteUrl', $result); + $this->assertEquals('http://localhost/authors/autocomplete', $result['autocompleteUrl']); + $this->assertIsArray($result); + $this->assertArrayHasKey('type', $result); + $this->assertEquals('autocomplete', $result['type']); + $this->assertArrayHasKey('idName', $result); + $this->assertArrayHasKey('valueName', $result); + $this->assertArrayHasKey('query', $result); + $this->assertArrayHasKey('wildcard', $result); + } +} diff --git a/tests/TestCase/Filter/MultipleFilterTest.php b/tests/TestCase/Filter/MultipleFilterTest.php new file mode 100644 index 0000000..4e66727 --- /dev/null +++ b/tests/TestCase/Filter/MultipleFilterTest.php @@ -0,0 +1,57 @@ +multipleFilter = new MultipleFilter(); + } + + public function tearDown(): void + { + unset($this->multipleFilter); + parent::tearDown(); + } + + public function testConstruct(): void + { + $this->assertInstanceOf(AbstractFilter::class, $this->multipleFilter); + $this->assertInstanceOf(MultipleFilter::class, $this->multipleFilter); + } + + public function testToArray(): void + { + $result = $this->multipleFilter->toArray(); + + $this->assertIsArray($result); + $this->assertArrayHasKey('type', $result); + $this->assertEquals('multiple', $result['type']); + } + + public function testGetConditions(): void + { + $conditions = $this->multipleFilter->getConditions(); + + $this->assertIsArray($conditions); + $this->assertArrayHasKey(AbstractFilter::COND_IN, $conditions); + $this->assertArrayHasKey(AbstractFilter::COND_NOT_IN, $conditions); + } +} diff --git a/tests/TestCase/Filter/NumericFilterTest.php b/tests/TestCase/Filter/NumericFilterTest.php new file mode 100644 index 0000000..f5cd809 --- /dev/null +++ b/tests/TestCase/Filter/NumericFilterTest.php @@ -0,0 +1,136 @@ +numericFilter = new NumericFilter(); + } + + /** + * tearDown method + * + * @return void + */ + public function tearDown(): void + { + unset($this->numericFilter); + parent::tearDown(); + } + + /** + * Test constructor and inheritance + * + * @return void + */ + public function testConstruct(): void + { + $this->assertInstanceOf(AbstractFilter::class, $this->numericFilter); + $this->assertInstanceOf(NumericFilter::class, $this->numericFilter); + } + + /** + * Test toArray method + * + * @return void + */ + public function testToArray(): void + { + $result = $this->numericFilter->toArray(); + + $this->assertIsArray($result); + $this->assertArrayHasKey('type', $result); + $this->assertEquals('numeric', $result['type']); + } + + /** + * Test getConditions method + * + * @return void + */ + public function testGetConditions(): void + { + $conditions = $this->numericFilter->getConditions(); + + $this->assertIsArray($conditions); + $this->assertArrayHasKey(AbstractFilter::COND_EQ, $conditions); + $this->assertArrayHasKey(AbstractFilter::COND_NE, $conditions); + $this->assertArrayHasKey(AbstractFilter::COND_GT, $conditions); + $this->assertArrayHasKey(AbstractFilter::COND_LT, $conditions); + $this->assertArrayHasKey(AbstractFilter::COND_BETWEEN, $conditions); + } + + /** + * Test getLabel method + * + * @return void + */ + public function testGetLabel(): void + { + $this->assertNull($this->numericFilter->getLabel()); + } + + /** + * Test setLabel method + * + * @return void + */ + public function testSetLabel(): void + { + $this->numericFilter->setLabel('Custom Label'); + $this->assertEquals('Custom Label', $this->numericFilter->getLabel()); + } + + /** + * Test getProperties method + * + * @return void + */ + public function testGetProperties(): void + { + $properties = $this->numericFilter->getProperties(); + $this->assertIsArray($properties); + $this->assertArrayHasKey('type', $properties); + $this->assertEquals('numeric', $properties['type']); + } + + /** + * Test setProperties method + * + * @return void + */ + public function testSetProperties(): void + { + $newProperties = ['custom' => 'value']; + $this->numericFilter->setProperties($newProperties); + $properties = $this->numericFilter->getProperties(); + $this->assertArrayHasKey('custom', $properties); + $this->assertEquals('value', $properties['custom']); + } +} diff --git a/tests/TestCase/Filter/SelectFilterTest.php b/tests/TestCase/Filter/SelectFilterTest.php new file mode 100644 index 0000000..54a1688 --- /dev/null +++ b/tests/TestCase/Filter/SelectFilterTest.php @@ -0,0 +1,133 @@ +selectFilter = new SelectFilter(); + } + + /** + * tearDown method + * + * @return void + */ + public function tearDown(): void + { + unset($this->selectFilter); + parent::tearDown(); + } + + /** + * Test constructor and inheritance + * + * @return void + */ + public function testConstruct(): void + { + $this->assertInstanceOf(AbstractFilter::class, $this->selectFilter); + $this->assertInstanceOf(SelectFilter::class, $this->selectFilter); + } + + /** + * Test getOptions method + * + * @return void + */ + public function testGetOptions(): void + { + $options = $this->selectFilter->getOptions(); + $this->assertIsArray($options); + $this->assertEmpty($options); + } + + /** + * Test setOptions method + * + * @return void + */ + public function testSetOptions(): void + { + $options = ['option1' => 'Value 1', 'option2' => 'Value 2']; + $result = $this->selectFilter->setOptions($options); + + $this->assertInstanceOf(SelectFilter::class, $result); + $this->assertEquals($options, $this->selectFilter->getOptions()); + } + + /** + * Test setEmpty method + * + * @return void + */ + public function testSetEmpty(): void + { + $emptyValue = 'Please select'; + $result = $this->selectFilter->setEmpty($emptyValue); + + $this->assertInstanceOf(SelectFilter::class, $result); + + $properties = $this->getObjectProperty($this->selectFilter, 'properties'); + $this->assertArrayHasKey('empty', $properties); + $this->assertEquals($emptyValue, $properties['empty']); + } + + /** + * Test toArray method + * + * @return void + */ + public function testToArray(): void + { + $options = ['option1' => 'Value 1', 'option2' => 'Value 2']; + $this->selectFilter->setOptions($options); + + $result = $this->selectFilter->toArray(); + + $this->assertIsArray($result); + $this->assertArrayHasKey('options', $result); + $this->assertEquals($options, $result['options']); + $this->assertArrayHasKey('type', $result); + $this->assertEquals('select', $result['type']); + } + + /** + * Helper method to get protected/private property of an object + * + * @param object $object + * @param string $propertyName + * @return mixed + */ + private function getObjectProperty($object, $propertyName) + { + $reflection = new ReflectionClass($object); + $property = $reflection->getProperty($propertyName); + $property->setAccessible(true); + + return $property->getValue($object); + } +} diff --git a/tests/TestCase/Filter/StringFilterTest.php b/tests/TestCase/Filter/StringFilterTest.php new file mode 100644 index 0000000..893fcd7 --- /dev/null +++ b/tests/TestCase/Filter/StringFilterTest.php @@ -0,0 +1,156 @@ +stringFilter = new StringFilter(); + } + + /** + * tearDown method + * + * @return void + */ + public function tearDown(): void + { + unset($this->stringFilter); + parent::tearDown(); + } + + /** + * Test constructor and inheritance + * + * @return void + */ + public function testConstruct(): void + { + $this->assertInstanceOf(AbstractFilter::class, $this->stringFilter); + $this->assertInstanceOf(StringFilter::class, $this->stringFilter); + } + + /** + * Test toArray method + * + * @return void + */ + public function testToArray(): void + { + $result = $this->stringFilter->toArray(); + + $this->assertIsArray($result); + $this->assertArrayHasKey('type', $result); + $this->assertEquals('string', $result['type']); + } + + /** + * Test getConditions method + * + * @return void + */ + public function testGetConditions(): void + { + $conditions = $this->stringFilter->getConditions(); + + $this->assertIsArray($conditions); + $this->assertArrayHasKey(AbstractFilter::COND_EQ, $conditions); + $this->assertArrayHasKey(AbstractFilter::COND_NE, $conditions); + $this->assertArrayHasKey(AbstractFilter::COND_LIKE, $conditions); + $this->assertArrayHasKey(AbstractFilter::COND_IN, $conditions); + } + + /** + * Test getType method (inherited from AbstractFilter) + * + * @return void + */ + public function testGetType(): void + { + $this->assertEquals('string', $this->stringFilter->getType()); + } + + /** + * Test setType method (inherited from AbstractFilter) + * + * @return void + */ + public function testSetType(): void + { + $this->stringFilter->setType('custom_type'); + $this->assertEquals('custom_type', $this->stringFilter->getType()); + } + + /** + * Test getProperties method (inherited from AbstractFilter) + * + * @return void + */ + public function testGetProperties(): void + { + $properties = $this->stringFilter->getProperties(); + $this->assertIsArray($properties); + $this->assertArrayHasKey('type', $properties); + $this->assertEquals('string', $properties['type']); + } + + /** + * Test setProperties method (inherited from AbstractFilter) + * + * @return void + */ + public function testSetProperties(): void + { + $newProperties = ['custom' => 'value']; + $this->stringFilter->setProperties($newProperties); + $properties = $this->stringFilter->getProperties(); + $this->assertArrayHasKey('custom', $properties); + $this->assertEquals('value', $properties['custom']); + } + + /** + * Test getLabel method (inherited from AbstractFilter) + * + * @return void + */ + public function testGetLabel(): void + { + $this->assertNull($this->stringFilter->getLabel()); + } + + /** + * Test setLabel method (inherited from AbstractFilter) + * + * @return void + */ + public function testSetLabel(): void + { + $this->stringFilter->setLabel('Custom Label'); + $this->assertEquals('Custom Label', $this->stringFilter->getLabel()); + } +} diff --git a/tests/TestCase/ManagerTest.php b/tests/TestCase/ManagerTest.php new file mode 100644 index 0000000..c75c007 --- /dev/null +++ b/tests/TestCase/ManagerTest.php @@ -0,0 +1,161 @@ +request = $this->createMock(ServerRequest::class); + $this->manager = new Manager($this->request); + } + + public function testConstructor(): void + { + $this->assertInstanceOf(Manager::class, $this->manager); + } + + public function testFilters(): void + { + $filters = $this->manager->filters(); + $this->assertInstanceOf(FilterRegistry::class, $filters); + } + + public function testCriterion(): void + { + $criterion = $this->manager->criterion(); + $this->assertInstanceOf(CriteriaBuilder::class, $criterion); + } + + public function testLoadFilters(): void + { + $filters = $this->manager->filters(); + $this->assertTrue($filters->has('boolean')); + $this->assertTrue($filters->has('date')); + $this->assertTrue($filters->has('datetime')); + $this->assertTrue($filters->has('lookup')); + $this->assertTrue($filters->has('multiple')); + $this->assertTrue($filters->has('numeric')); + $this->assertTrue($filters->has('select')); + $this->assertTrue($filters->has('string')); + } + + public function testLoadFilter(): void + { + $mockFilter = $this->createMock(FilterInterface::class); + + $mockRegistry = $this->createMock(FilterRegistry::class); + $mockRegistry->method('load')->willReturn($mockFilter); + + $reflectionManager = new \ReflectionClass($this->manager); + $filtersProperty = $reflectionManager->getProperty('_filters'); + $filtersProperty->setAccessible(true); + $filtersProperty->setValue($this->manager, $mockRegistry); + + $filter = $this->manager->loadFilter('custom', ['className' => 'CustomFilter']); + $this->assertInstanceOf(FilterInterface::class, $filter); + } + + public function testGetFieldBlacklist(): void + { + $blacklist = $this->manager->getFieldBlacklist(); + $this->assertIsArray($blacklist); + $this->assertContains('id', $blacklist); + $this->assertContains('password', $blacklist); + $this->assertContains('created', $blacklist); + $this->assertContains('modified', $blacklist); + } + + public function testSetFieldBlacklist(): void + { + $newBlacklist = ['secret_field', 'another_secret']; + $result = $this->manager->setFieldBlacklist($newBlacklist); + $this->assertInstanceOf(Manager::class, $result); + $this->assertEquals($newBlacklist, $this->manager->getFieldBlacklist()); + } + + public function testNewCollection(): void + { + $collection = $this->manager->newCollection(); + $this->assertInstanceOf(FilterCollection::class, $collection); + } + + public function testAppendFromSchema(): void + { + $collection = $this->manager->newCollection(); + $table = $this->createMock(Table::class); + $schema = new TableSchema('test_table'); + $schema->addColumn('name', ['type' => 'string']); + $schema->addColumn('age', ['type' => 'integer']); + + $table->method('getSchema')->willReturn($schema); + + $result = $this->manager->appendFromSchema($collection, $table); + $this->assertInstanceOf(FilterCollection::class, $result); + $this->assertTrue($result->has('name')); + $this->assertTrue($result->has('age')); + } + + public function testFormatSearchData(): void + { + $queryParams = [ + 'f' => ['0' => 'name', '1' => 'age'], + 'c' => ['0' => 'contains', '1' => 'gt'], + 'v' => ['0' => ['value' => 'John'], '1' => ['value' => '25']], + ]; + $this->request->expects($this->once()) + ->method('getQuery') + ->willReturn($queryParams); + + $result = $this->manager->formatSearchData(); + $expected = [ + 'name' => ['condition' => 'contains', 'value' => []], + 'age' => ['condition' => 'gt', 'value' => []], + ]; + $this->assertEquals($expected, $result); + } + + public function testFormatFinders(): void + { + $search = [ + 'search' => ['value' => ['value' => 'John']], + 'age' => ['condition' => 'gt', 'value' => ['value' => '25']], + ]; + + $result = $this->manager->formatFinders($search); + $expected = [ + 'search' => 'John', + 'multiple' => [ + 'age' => ['condition' => 'gt', 'value' => ['value' => '25']], + ], + ]; + $this->assertEquals($expected, $result); + } +} diff --git a/tests/TestCase/Model/Filter/CriteriaFilterTest.php b/tests/TestCase/Model/Filter/CriteriaFilterTest.php new file mode 100644 index 0000000..2ec74ee --- /dev/null +++ b/tests/TestCase/Model/Filter/CriteriaFilterTest.php @@ -0,0 +1,181 @@ + + */ + protected $fixtures = [ + 'plugin.CakeDC/SearchFilter.Articles', + 'plugin.CakeDC/SearchFilter.Authors', + ]; + + /** + * @var \PlumSearch\Model\FilterRegistry + */ + protected $registry; + + /** + * @var \Cake\ORM\Table + */ + protected $Articles; + + /** + * setUp method + * + * @return void + */ + public function setUp(): void + { + parent::setUp(); + $this->Articles = TableRegistry::getTableLocator()->get('Articles'); + $this->registry = new FilterRegistry($this->Articles); + } + + /** + * tearDown method + * + * @return void + */ + public function tearDown(): void + { + unset($this->Articles, $this->registry); + TableRegistry::getTableLocator()->clear(); + parent::tearDown(); + } + + /** + * Test apply method + * + * @return void + */ + public function testApply(): void + { + $criteria = [ + 'title' => new StringCriterion('title'), + 'body' => new StringCriterion('body'), + ]; + + $filter = new CriteriaFilter($this->registry, [ + 'name' => 'multiple', + 'criteria' => $criteria, + 'filterOptions' => [], + ]); + + $query = $this->Articles->find(); + $data = [ + 'multiple' => [ + 'title' => ['condition' => '=', 'value' => ['Test Article']], + 'body' => ['condition' => 'like', 'value' => ['test content']], + ], + ]; + + $result = $filter->apply($query, $data); + + $this->assertStringContainsString('WHERE', $result->sql()); + $this->assertStringContainsString('title =', $result->sql()); + $this->assertStringContainsString('body LIKE', $result->sql()); + + $bindings = $result->getValueBinder()->bindings(); + $this->assertNotEmpty($bindings); + } + + /** + * Test apply IN method + * + * @return void + */ + public function testApplyWithInCriterionAndInnerStringCriterion(): void + { + $this->Authors = TableRegistry::getTableLocator()->get('Authors'); + $this->Articles->belongsTo('Authors'); + + $criteria = [ + 'author_id' => new InCriterion('Articles.author_id', $this->Authors, new StringCriterion('name')), + ]; + + $filter = new CriteriaFilter($this->registry, [ + 'name' => 'multiple', + 'criteria' => $criteria, + 'filterOptions' => [], + ]); + + $query = $this->Articles->find(); + $data = [ + 'multiple' => [ + 'author_id' => [ + 'condition' => 'like', + 'value' => ['value' => 'John'], + ], + ], + ]; + + $result = $filter->apply($query, $data); + + $this->assertMatchesRegularExpression( + '/Articles\.author_id IN \(SELECT Authors\.id AS "?Authors__id"? FROM authors Authors WHERE name LIKE/', + $query->sql() + ); + + $bindings = $result->getValueBinder()->bindings(); + $this->assertCount(1, $bindings); + $this->assertEquals('John%', $bindings[':c0']['value']); + } + + /** + * Test apply method with multiple criteria + * + * @return void + */ + public function testApplyWithMultipleCriteria(): void + { + $criteria = [ + 'title' => new StringCriterion('title'), + 'published' => new StringCriterion('published'), + ]; + + $filter = new CriteriaFilter($this->registry, [ + 'name' => 'multiple', + 'criteria' => $criteria, + 'filterOptions' => [], + ]); + + $query = $this->Articles->find(); + $data = [ + 'multiple' => [ + 'title' => ['condition' => 'like', 'value' => ['value' => 'Test']], + 'published' => ['condition' => '=', 'value' => ['value' => 'Y']], + ], + ]; + + $result = $filter->apply($query, $data); + + $this->assertStringContainsString('WHERE', $result->sql()); + $this->assertStringContainsString('title LIKE :c0 AND published = :c1', $result->sql()); + + $bindings = $result->getValueBinder()->bindings(); + $this->assertCount(2, $bindings); + $this->assertEquals('Test%', $bindings[':c0']['value']); + $this->assertEquals('Y', $bindings[':c1']['value']); + } +} diff --git a/tests/TestCase/Model/Filter/Criterion/AndCriterionTest.php b/tests/TestCase/Model/Filter/Criterion/AndCriterionTest.php new file mode 100644 index 0000000..ae5e8e5 --- /dev/null +++ b/tests/TestCase/Model/Filter/Criterion/AndCriterionTest.php @@ -0,0 +1,109 @@ +andCriterion = new AndCriterion([$nameCriterion, $emailCriterion]); + } + + /** + * tearDown method + * + * @return void + */ + public function tearDown(): void + { + unset($this->andCriterion); + parent::tearDown(); + } + + /** + * Test constructor + * + * @return void + */ + public function testConstructor(): void + { + $this->assertInstanceOf(AndCriterion::class, $this->andCriterion); + } + + /** + * Test buildFilter method + * + * @return void + */ + public function testBuildFilter(): void + { + $condition = AbstractFilter::COND_LIKE; + $values = ['value' => 'john']; + $criteria = [ + 'name' => 'john', + ]; + $options = []; + + $result = $this->andCriterion->buildFilter($condition, $values, $criteria, $options); + + $this->assertIsArray($result); + $this->assertCount(2, $result); + $this->assertContainsOnly(Closure::class, $result); + + $queryExpression = new QueryExpression(); + foreach ($result as $closure) { + $queryExpression = $closure($queryExpression); + } + + $this->assertInstanceOf(QueryExpression::class, $queryExpression); + + $valueBinder = new ValueBinder(); + $sql = $queryExpression->sql($valueBinder); + + $this->assertStringContainsString('(name LIKE :c0 AND email LIKE :c1)', $sql); + + $bindings = $valueBinder->bindings(); + $this->assertArrayHasKey(':c0', $bindings); + $this->assertArrayHasKey(':c1', $bindings); + $this->assertEquals('john%', $bindings[':c0']['value']); + $this->assertEquals('john%', $bindings[':c1']['value']); + } + + /** + * Test isApplicable method + * + * @return void + */ + public function testIsApplicable(): void + { + $this->assertTrue($this->andCriterion->isApplicable(['value' => 'John'], AbstractFilter::COND_LIKE)); + $this->assertFalse($this->andCriterion->isApplicable([], AbstractFilter::COND_LIKE)); + } +} diff --git a/tests/TestCase/Model/Filter/Criterion/BoolCriterionTest.php b/tests/TestCase/Model/Filter/Criterion/BoolCriterionTest.php new file mode 100644 index 0000000..7ac9d51 --- /dev/null +++ b/tests/TestCase/Model/Filter/Criterion/BoolCriterionTest.php @@ -0,0 +1,137 @@ +boolCriterion = new BoolCriterion('test_field'); + } + + /** + * tearDown method + * + * @return void + */ + public function tearDown(): void + { + unset($this->boolCriterion); + parent::tearDown(); + } + + /** + * Test constructor + * + * @return void + */ + public function testConstructor(): void + { + $this->assertInstanceOf(BoolCriterion::class, $this->boolCriterion); + } + + /** + * Test isApplicable method + * + * @return void + */ + public function testIsApplicable(): void + { + $this->assertTrue($this->boolCriterion->isApplicable(true, AbstractFilter::COND_EQ)); + $this->assertTrue($this->boolCriterion->isApplicable(false, AbstractFilter::COND_EQ)); + $this->assertTrue($this->boolCriterion->isApplicable(1, AbstractFilter::COND_EQ)); + $this->assertTrue($this->boolCriterion->isApplicable(0, AbstractFilter::COND_EQ)); + $this->assertFalse($this->boolCriterion->isApplicable('', AbstractFilter::COND_EQ)); + $this->assertFalse($this->boolCriterion->isApplicable(null, AbstractFilter::COND_EQ)); + } + + /** + * Test buildFilter method with '=' condition + * + * @return void + */ + public function testBuildEqualFilter(): void + { + $condition = AbstractFilter::COND_EQ; + $values = ['value' => true]; + $criteria = ['test_field' => true]; + $options = []; + + $result = $this->boolCriterion->buildFilter($condition, $values, $criteria, $options); + + $this->assertIsCallable($result); + + $queryExpression = new QueryExpression(); + $modifiedExpression = $result($queryExpression); + + $this->assertInstanceOf(QueryExpression::class, $modifiedExpression); + + $valueBinder = new ValueBinder(); + $sql = $modifiedExpression->sql($valueBinder); + + $this->assertStringContainsString('test_field =', $sql); + $this->assertStringContainsString(':c0', $sql); + + $bindings = $valueBinder->bindings(); + $this->assertArrayHasKey(':c0', $bindings); + $this->assertEquals(true, $bindings[':c0']['value']); + } + + /** + * Test buildFilter method with 'in' condition + * + * @return void + */ + public function testBuildInFilter(): void + { + $condition = AbstractFilter::COND_IN; + $values = ['value' => [true, false]]; + $criteria = ['test_field' => [true, false]]; + $options = []; + + $result = $this->boolCriterion->buildFilter($condition, $values, $criteria, $options); + + $this->assertIsCallable($result); + + $queryExpression = new QueryExpression(); + $modifiedExpression = $result($queryExpression); + + $this->assertInstanceOf(QueryExpression::class, $modifiedExpression); + + $valueBinder = new ValueBinder(); + $sql = $modifiedExpression->sql($valueBinder); + + $this->assertStringContainsString('test_field IN', $sql); + $this->assertStringContainsString(':c0', $sql); + $this->assertStringContainsString(':c1', $sql); + + $bindings = $valueBinder->bindings(); + $this->assertArrayHasKey(':c0', $bindings); + $this->assertArrayHasKey(':c1', $bindings); + $this->assertEquals(true, $bindings[':c0']['value']); + $this->assertEquals(false, $bindings[':c1']['value']); + } +} diff --git a/tests/TestCase/Model/Filter/Criterion/DateCriterionTest.php b/tests/TestCase/Model/Filter/Criterion/DateCriterionTest.php new file mode 100644 index 0000000..876f482 --- /dev/null +++ b/tests/TestCase/Model/Filter/Criterion/DateCriterionTest.php @@ -0,0 +1,402 @@ +dateCriterion = new DateCriterion('test_date'); + } + + /** + * tearDown method + * + * @return void + */ + public function tearDown(): void + { + unset($this->dateCriterion); + parent::tearDown(); + } + + /** + * Test constructor + * + * @return void + */ + public function testConstructor(): void + { + $this->assertInstanceOf(DateCriterion::class, $this->dateCriterion); + } + + /** + * Test isApplicable method + * + * @return void + */ + public function testIsApplicable(): void + { + $this->assertTrue($this->dateCriterion->isApplicable('2023-05-15', AbstractFilter::COND_EQ)); + $this->assertFalse($this->dateCriterion->isApplicable('', AbstractFilter::COND_EQ)); + $this->assertFalse($this->dateCriterion->isApplicable(null, AbstractFilter::COND_EQ)); + } + + /** + * Test buildFilter method with '=' condition + * + * @return void + */ + public function testBuildEqualFilter(): void + { + $condition = AbstractFilter::COND_EQ; + $values = ['value' => '2023-05-15']; + $criteria = ['test_date' => '2023-05-15']; + $options = []; + + $result = $this->dateCriterion->buildFilter($condition, $values, $criteria, $options); + + $this->assertIsCallable($result); + + $queryExpression = new QueryExpression(); + $modifiedExpression = $result($queryExpression); + + $this->assertInstanceOf(QueryExpression::class, $modifiedExpression); + + $valueBinder = new ValueBinder(); + $sql = $modifiedExpression->sql($valueBinder); + + $this->assertStringContainsString('test_date =', $sql); + $this->assertStringContainsString(':c0', $sql); + + $bindings = $valueBinder->bindings(); + $this->assertArrayHasKey(':c0', $bindings); + $this->assertEquals('2023-05-15', $bindings[':c0']['value']); + } + + /** + * Test buildFilter method with '!=' condition + * + * @return void + */ + public function testBuildNotEqualFilter(): void + { + $condition = AbstractFilter::COND_NE; + $values = ['value' => '2023-05-15']; + $criteria = ['test_date' => '2023-05-15']; + $options = []; + + $result = $this->dateCriterion->buildFilter($condition, $values, $criteria, $options); + + $this->assertIsCallable($result); + + $queryExpression = new QueryExpression(); + $modifiedExpression = $result($queryExpression); + + $this->assertInstanceOf(QueryExpression::class, $modifiedExpression); + + $valueBinder = new ValueBinder(); + $sql = $modifiedExpression->sql($valueBinder); + + $this->assertStringContainsString('test_date !=', $sql); + $this->assertStringContainsString(':c0', $sql); + + $bindings = $valueBinder->bindings(); + $this->assertArrayHasKey(':c0', $bindings); + $this->assertEquals('2023-05-15', $bindings[':c0']['value']); + } + + /** + * Test buildFilter method with '>' condition + * + * @return void + */ + public function testBuildGreaterThanFilter(): void + { + $condition = AbstractFilter::COND_GT; + $values = ['value' => '2023-05-15']; + $criteria = ['test_date' => '2023-05-15']; + $options = []; + + $result = $this->dateCriterion->buildFilter($condition, $values, $criteria, $options); + + $this->assertIsCallable($result); + + $queryExpression = new QueryExpression(); + $modifiedExpression = $result($queryExpression); + + $this->assertInstanceOf(QueryExpression::class, $modifiedExpression); + + $valueBinder = new ValueBinder(); + $sql = $modifiedExpression->sql($valueBinder); + + $this->assertStringContainsString('test_date >', $sql); + $this->assertStringContainsString(':c0', $sql); + + $bindings = $valueBinder->bindings(); + $this->assertArrayHasKey(':c0', $bindings); + $this->assertEquals('2023-05-15', $bindings[':c0']['value']); + } + + /** + * Test buildFilter method with '>=' condition + * + * @return void + */ + public function testBuildGreaterThanOrEqualFilter(): void + { + $condition = AbstractFilter::COND_GE; + $values = ['value' => '2023-05-15']; + $criteria = ['test_date' => '2023-05-15']; + $options = []; + + $result = $this->dateCriterion->buildFilter($condition, $values, $criteria, $options); + + $this->assertIsCallable($result); + + $queryExpression = new QueryExpression(); + $modifiedExpression = $result($queryExpression); + + $this->assertInstanceOf(QueryExpression::class, $modifiedExpression); + + $valueBinder = new ValueBinder(); + $sql = $modifiedExpression->sql($valueBinder); + + $this->assertStringContainsString('test_date >=', $sql); + $this->assertStringContainsString(':c0', $sql); + + $bindings = $valueBinder->bindings(); + $this->assertArrayHasKey(':c0', $bindings); + $this->assertEquals('2023-05-15', $bindings[':c0']['value']); + } + + /** + * Test buildFilter method with '<' condition + * + * @return void + */ + public function testBuildLessThanFilter(): void + { + $condition = AbstractFilter::COND_LT; + $values = ['value' => '2023-05-15']; + $criteria = ['test_date' => '2023-05-15']; + $options = []; + + $result = $this->dateCriterion->buildFilter($condition, $values, $criteria, $options); + + $this->assertIsCallable($result); + + $queryExpression = new QueryExpression(); + $modifiedExpression = $result($queryExpression); + + $this->assertInstanceOf(QueryExpression::class, $modifiedExpression); + + $valueBinder = new ValueBinder(); + $sql = $modifiedExpression->sql($valueBinder); + + $this->assertStringContainsString('test_date <', $sql); + $this->assertStringContainsString(':c0', $sql); + + $bindings = $valueBinder->bindings(); + $this->assertArrayHasKey(':c0', $bindings); + $this->assertEquals('2023-05-15', $bindings[':c0']['value']); + } + + /** + * Test buildFilter method with '<=' condition + * + * @return void + */ + public function testBuildLessThanOrEqualFilter(): void + { + $condition = AbstractFilter::COND_LE; + $values = ['value' => '2023-05-15']; + $criteria = ['test_date' => '2023-05-15']; + $options = []; + + $result = $this->dateCriterion->buildFilter($condition, $values, $criteria, $options); + + $this->assertIsCallable($result); + + $queryExpression = new QueryExpression(); + $modifiedExpression = $result($queryExpression); + + $this->assertInstanceOf(QueryExpression::class, $modifiedExpression); + + $valueBinder = new ValueBinder(); + $sql = $modifiedExpression->sql($valueBinder); + + $this->assertStringContainsString('test_date <=', $sql); + $this->assertStringContainsString(':c0', $sql); + + $bindings = $valueBinder->bindings(); + $this->assertArrayHasKey(':c0', $bindings); + $this->assertEquals('2023-05-15', $bindings[':c0']['value']); + } + + /** + * Test buildFilter method with 'between' condition + * + * @return void + */ + public function testBuildBetweenFilter(): void + { + $condition = AbstractFilter::COND_BETWEEN; + $values = ['date_from' => '2023-05-01', 'date_to' => '2023-05-31']; + $criteria = ['test_date' => ['2023-05-01', '2023-05-31']]; + $options = []; + + $result = $this->dateCriterion->buildFilter($condition, $values, $criteria, $options); + + $this->assertIsCallable($result); + + $queryExpression = new QueryExpression(); + $modifiedExpression = $result($queryExpression); + + $this->assertInstanceOf(QueryExpression::class, $modifiedExpression); + + $valueBinder = new ValueBinder(); + $sql = $modifiedExpression->sql($valueBinder); + + $this->assertStringContainsString('test_date BETWEEN', $sql); + $this->assertStringContainsString(':c0 AND :c1', $sql); + + $bindings = $valueBinder->bindings(); + $this->assertArrayHasKey(':c0', $bindings); + $this->assertArrayHasKey(':c1', $bindings); + $this->assertEquals(new FrozenDate('2023-05-01'), $bindings[':c0']['value']); + $this->assertEquals(new FrozenDate('2023-05-31'), $bindings[':c1']['value']); + } + + /** + * Test buildFilter method with 'today' condition + * + * @return void + */ + public function testBuildTodayFilter(): void + { + $condition = AbstractFilter::COND_TODAY; + $values = []; + $criteria = ['test_date' => 'today']; + $options = []; + + $result = $this->dateCriterion->buildFilter($condition, $values, $criteria, $options); + + $this->assertIsCallable($result); + + $queryExpression = new QueryExpression(); + $modifiedExpression = $result($queryExpression); + + $this->assertInstanceOf(QueryExpression::class, $modifiedExpression); + + $valueBinder = new ValueBinder(); + $sql = $modifiedExpression->sql($valueBinder); + + $this->assertStringContainsString('test_date = (CURRENT_DATE())', $sql); + } + + /** + * Test buildFilter method with 'yesterday' condition + * + * @return void + */ + public function testBuildYesterdayFilter(): void + { + $condition = AbstractFilter::COND_YESTERDAY; + $values = []; + $criteria = ['test_date' => 'yesterday']; + $options = []; + + $result = $this->dateCriterion->buildFilter($condition, $values, $criteria, $options); + + $this->assertIsCallable($result); + + $queryExpression = new QueryExpression(); + $modifiedExpression = $result($queryExpression); + + $this->assertInstanceOf(QueryExpression::class, $modifiedExpression); + + $valueBinder = new ValueBinder(); + $sql = $modifiedExpression->sql($valueBinder); + + $this->assertStringContainsString('test_date = (DATE_ADD(CURRENT_DATE, INTERVAL -1 DAY))', $sql); + } + + /** + * Test buildFilter method with 'this week' condition + * + * @return void + */ + public function testBuildThisWeekFilter(): void + { + $condition = AbstractFilter::COND_THIS_WEEK; + $values = []; + $criteria = ['test_date' => 'this week']; + $options = []; + + $result = $this->dateCriterion->buildFilter($condition, $values, $criteria, $options); + + $this->assertIsCallable($result); + + $queryExpression = new QueryExpression(); + $modifiedExpression = $result($queryExpression); + + $this->assertInstanceOf(QueryExpression::class, $modifiedExpression); + + $valueBinder = new ValueBinder(); + $sql = $modifiedExpression->sql($valueBinder); + + $this->assertStringContainsString('CONCAT(CAST(EXTRACT(YEAR FROM test_date) AS varchar), CAST(EXTRACT(WEEK FROM test_date) AS varchar)) = (CONCAT(CAST(EXTRACT(YEAR FROM CURRENT_DATE) AS varchar), CAST(EXTRACT(WEEK FROM CURRENT_DATE) AS varchar)))', $sql); + } + + /** + * Test buildFilter method with 'last week' condition + * + * @return void + */ + public function testBuildLastWeekFilter(): void + { + $condition = AbstractFilter::COND_LAST_WEEK; + $values = []; + $criteria = ['test_date' => 'last week']; + $options = []; + + $result = $this->dateCriterion->buildFilter($condition, $values, $criteria, $options); + + $this->assertIsCallable($result); + + $queryExpression = new QueryExpression(); + $modifiedExpression = $result($queryExpression); + + $this->assertInstanceOf(QueryExpression::class, $modifiedExpression); + + $valueBinder = new ValueBinder(); + $sql = $modifiedExpression->sql($valueBinder); + + $this->assertStringContainsString('CONCAT(CAST(EXTRACT(YEAR FROM test_date) AS varchar), CAST(EXTRACT(WEEK FROM test_date) AS varchar)) = (CONCAT(CAST(EXTRACT(YEAR FROM DATE_ADD(CURRENT_DATE, INTERVAL -7 DAY)) AS varchar), CAST(EXTRACT(WEEK FROM DATE_ADD(CURRENT_DATE, INTERVAL -7 DAY)) AS varchar)))', $sql); + } +} diff --git a/tests/TestCase/Model/Filter/Criterion/DateTimeCriterionTest.php b/tests/TestCase/Model/Filter/Criterion/DateTimeCriterionTest.php new file mode 100644 index 0000000..9acc3fb --- /dev/null +++ b/tests/TestCase/Model/Filter/Criterion/DateTimeCriterionTest.php @@ -0,0 +1,309 @@ +dateTimeCriterion = new DateTimeCriterion('test_datetime'); + } + + /** + * tearDown method + * + * @return void + */ + public function tearDown(): void + { + unset($this->dateTimeCriterion); + parent::tearDown(); + } + + /** + * Test buildFilter method with 'equal' condition + * + * @return void + */ + public function testBuildEqualFilter(): void + { + $condition = AbstractFilter::COND_EQ; + $values = ['value' => '2023-05-15T00:00']; + $criteria = ['test_datetime' => '2023-05-15T00:00']; + $options = []; + + $result = $this->dateTimeCriterion->buildFilter($condition, $values, $criteria, $options); + + $this->assertIsCallable($result); + + $queryExpression = new QueryExpression(); + $modifiedExpression = $result($queryExpression); + + $this->assertInstanceOf(QueryExpression::class, $modifiedExpression); + + $valueBinder = new ValueBinder(); + $sql = $modifiedExpression->sql($valueBinder); + + $this->assertStringContainsString('test_datetime = :c0', $sql); + $this->assertEquals('2023-05-15 00:00', $valueBinder->bindings()[':c0']['value']); + } + + /** + * Test buildFilter method with 'not equal' condition + * + * @return void + */ + public function testBuildNotEqualFilter(): void + { + $condition = AbstractFilter::COND_NE; + $values = ['value' => '2023-05-15T00:00']; + $criteria = ['test_datetime' => '2023-05-15T00:00']; + $options = []; + + $result = $this->dateTimeCriterion->buildFilter($condition, $values, $criteria, $options); + + $this->assertIsCallable($result); + + $queryExpression = new QueryExpression(); + $modifiedExpression = $result($queryExpression); + + $this->assertInstanceOf(QueryExpression::class, $modifiedExpression); + + $valueBinder = new ValueBinder(); + $sql = $modifiedExpression->sql($valueBinder); + + $this->assertStringContainsString('test_datetime != :c0', $sql); + $this->assertEquals('2023-05-15 00:00', $valueBinder->bindings()[':c0']['value']); + } + + /** + * Test buildFilter method with 'greater than' condition + * + * @return void + */ + public function testBuildGreaterThanFilter(): void + { + $condition = AbstractFilter::COND_GT; + $values = ['value' => '2023-05-15T00:00']; + $criteria = ['test_datetime' => '2023-05-15T00:00']; + $options = []; + + $result = $this->dateTimeCriterion->buildFilter($condition, $values, $criteria, $options); + + $this->assertIsCallable($result); + + $queryExpression = new QueryExpression(); + $modifiedExpression = $result($queryExpression); + + $this->assertInstanceOf(QueryExpression::class, $modifiedExpression); + + $valueBinder = new ValueBinder(); + $sql = $modifiedExpression->sql($valueBinder); + + $this->assertStringContainsString('test_datetime > :c0', $sql); + $this->assertEquals('2023-05-15 00:00', $valueBinder->bindings()[':c0']['value']); + } + + /** + * Test buildFilter method with 'less than' condition + * + * @return void + */ + public function testBuildLessThanFilter(): void + { + $condition = AbstractFilter::COND_LT; + $values = ['value' => '2023-05-15T00:00']; + $criteria = ['test_datetime' => '2023-05-15T00:00']; + $options = []; + + $result = $this->dateTimeCriterion->buildFilter($condition, $values, $criteria, $options); + + $this->assertIsCallable($result); + + $queryExpression = new QueryExpression(); + $modifiedExpression = $result($queryExpression); + + $this->assertInstanceOf(QueryExpression::class, $modifiedExpression); + + $valueBinder = new ValueBinder(); + $sql = $modifiedExpression->sql($valueBinder); + + $this->assertStringContainsString('test_datetime < :c0', $sql); + $this->assertEquals('2023-05-15 00:00', $valueBinder->bindings()[':c0']['value']); + } + + /** + * Test buildFilter method with 'between' condition + * + * @return void + */ + public function testBuildBetweenFilter(): void + { + $condition = AbstractFilter::COND_BETWEEN; + $values = ['date_from' => '2023-05-01T00:00', 'date_to' => '2023-05-31T00:00']; + $criteria = ['test_datetime' => ['2023-05-01T00:00', '2023-05-31T00:00']]; + $options = []; + + $result = $this->dateTimeCriterion->buildFilter($condition, $values, $criteria, $options); + + $this->assertIsCallable($result); + + $queryExpression = new QueryExpression(); + $modifiedExpression = $result($queryExpression); + + $this->assertInstanceOf(QueryExpression::class, $modifiedExpression); + + $valueBinder = new ValueBinder(); + $sql = $modifiedExpression->sql($valueBinder); + + $this->assertStringContainsString('test_datetime BETWEEN :c0 AND :c1', $sql); + $this->assertEquals(new FrozenDate('2023-05-01 00:00'), $valueBinder->bindings()[':c0']['value']); + $this->assertEquals(new FrozenDate('2023-05-31 00:00'), $valueBinder->bindings()[':c1']['value']); + } + + /** + * Test buildFilter method with 'today' condition + * + * @return void + */ + public function testBuildTodayFilter(): void + { + $condition = AbstractFilter::COND_TODAY; + $values = []; + $criteria = ['test_datetime' => 'today']; + $options = []; + + $result = $this->dateTimeCriterion->buildFilter($condition, $values, $criteria, $options); + + $this->assertIsCallable($result); + + $queryExpression = new QueryExpression(); + $modifiedExpression = $result($queryExpression); + + $this->assertInstanceOf(QueryExpression::class, $modifiedExpression); + + $valueBinder = new ValueBinder(); + $sql = $modifiedExpression->sql($valueBinder); + + $this->assertStringContainsString('test_datetime = (CURRENT_DATE())', $sql); + } + + /** + * Test buildFilter method with 'yesterday' condition + * + * @return void + */ + public function testBuildYesterdayFilter(): void + { + $condition = AbstractFilter::COND_YESTERDAY; + $values = []; + $criteria = ['test_datetime' => 'yesterday']; + $options = []; + + $result = $this->dateTimeCriterion->buildFilter($condition, $values, $criteria, $options); + + $this->assertIsCallable($result); + + $queryExpression = new QueryExpression(); + $modifiedExpression = $result($queryExpression); + + $this->assertInstanceOf(QueryExpression::class, $modifiedExpression); + + $valueBinder = new ValueBinder(); + $sql = $modifiedExpression->sql($valueBinder); + + $this->assertStringContainsString('test_datetime = (DATE_ADD(CURRENT_DATE, INTERVAL -1 DAY))', $sql); + } + + /** + * Test buildFilter method with 'this week' condition + * + * @return void + */ + public function testBuildThisWeekFilter(): void + { + $condition = AbstractFilter::COND_THIS_WEEK; + $values = []; + $criteria = ['test_datetime' => 'this week']; + $options = []; + + $result = $this->dateTimeCriterion->buildFilter($condition, $values, $criteria, $options); + + $this->assertIsCallable($result); + + $queryExpression = new QueryExpression(); + $modifiedExpression = $result($queryExpression); + + $this->assertInstanceOf(QueryExpression::class, $modifiedExpression); + + $valueBinder = new ValueBinder(); + $sql = $modifiedExpression->sql($valueBinder); + + $this->assertStringContainsString('CONCAT(CAST(EXTRACT(YEAR FROM test_datetime) AS varchar), CAST(EXTRACT(WEEK FROM test_datetime) AS varchar)) = (CONCAT(CAST(EXTRACT(YEAR FROM CURRENT_DATE) AS varchar), CAST(EXTRACT(WEEK FROM CURRENT_DATE) AS varchar)))', $sql); + } + + /** + * Test buildFilter method with 'last week' condition + * + * @return void + */ + public function testBuildLastWeekFilter(): void + { + $condition = AbstractFilter::COND_LAST_WEEK; + $values = []; + $criteria = ['test_datetime' => 'last week']; + $options = []; + + $result = $this->dateTimeCriterion->buildFilter($condition, $values, $criteria, $options); + + $this->assertIsCallable($result); + + $queryExpression = new QueryExpression(); + $modifiedExpression = $result($queryExpression); + + $this->assertInstanceOf(QueryExpression::class, $modifiedExpression); + + $valueBinder = new ValueBinder(); + $sql = $modifiedExpression->sql($valueBinder); + + $this->assertStringContainsString('CONCAT(CAST(EXTRACT(YEAR FROM test_datetime) AS varchar), CAST(EXTRACT(WEEK FROM test_datetime) AS varchar)) = (CONCAT(CAST(EXTRACT(YEAR FROM DATE_ADD(CURRENT_DATE, INTERVAL -7 DAY)) AS varchar), CAST(EXTRACT(WEEK FROM DATE_ADD(CURRENT_DATE, INTERVAL -7 DAY)) AS varchar)))', $sql); + } + + /** + * Test isApplicable method + * + * @return void + */ + public function testIsApplicable(): void + { + $this->assertTrue($this->dateTimeCriterion->isApplicable('2023-05-15', AbstractFilter::COND_EQ)); + $this->assertTrue($this->dateTimeCriterion->isApplicable(['2023-05-01', '2023-05-31'], AbstractFilter::COND_BETWEEN)); + $this->assertFalse($this->dateTimeCriterion->isApplicable(null, AbstractFilter::COND_EQ)); + $this->assertFalse($this->dateTimeCriterion->isApplicable('', AbstractFilter::COND_EQ)); + $this->assertFalse($this->dateTimeCriterion->isApplicable([], AbstractFilter::COND_BETWEEN)); + } +} diff --git a/tests/TestCase/Model/Filter/Criterion/InCriterionTest.php b/tests/TestCase/Model/Filter/Criterion/InCriterionTest.php new file mode 100644 index 0000000..1d2cc8b --- /dev/null +++ b/tests/TestCase/Model/Filter/Criterion/InCriterionTest.php @@ -0,0 +1,83 @@ +table = $this->getMockBuilder(Table::class)->getMock(); + $this->stringCriterion = new StringCriterion('status'); + $this->inCriterion = new InCriterion('Table.status_id', $this->table, $this->stringCriterion); + } + + /** + * tearDown method + * + * @return void + */ + public function tearDown(): void + { + unset($this->inCriterion); + unset($this->table); + unset($this->mockStringCriterion); + parent::tearDown(); + } + + /** + * Test constructor + * + * @return void + */ + public function testConstructor(): void + { + $this->assertInstanceOf(InCriterion::class, $this->inCriterion); + } + + /** + * Test isApplicable method + * + * @return void + */ + public function testIsApplicable(): void + { + $this->assertTrue($this->inCriterion->isApplicable(['active', 'pending'], AbstractFilter::COND_IN)); + $this->assertTrue($this->inCriterion->isApplicable(['closed'], AbstractFilter::COND_IN)); + $this->assertFalse($this->inCriterion->isApplicable([], AbstractFilter::COND_IN)); + $this->assertTrue($this->inCriterion->isApplicable('active', AbstractFilter::COND_IN)); + $this->assertFalse($this->inCriterion->isApplicable(null, AbstractFilter::COND_IN)); + } +} diff --git a/tests/TestCase/Model/Filter/Criterion/LookupCriterionTest.php b/tests/TestCase/Model/Filter/Criterion/LookupCriterionTest.php new file mode 100644 index 0000000..b6e30fa --- /dev/null +++ b/tests/TestCase/Model/Filter/Criterion/LookupCriterionTest.php @@ -0,0 +1,206 @@ + + */ + protected $fixtures = [ + 'plugin.CakeDC/SearchFilter.Articles', + 'plugin.CakeDC/SearchFilter.Authors', + ]; + + /** + * @var \Cake\ORM\Table + */ + protected $Articles; + + /** + * @var \Cake\ORM\Table + */ + protected $Authors; + + /** + * @var \CakeDC\SearchFilter\Model\Filter\Criterion\LookupCriterion + */ + protected $lookupCriterion; + + /** + * setUp method + * + * @return void + */ + public function setUp(): void + { + parent::setUp(); + $this->Articles = TableRegistry::getTableLocator()->get('Articles'); + $this->Authors = TableRegistry::getTableLocator()->get('Authors'); + $this->Articles->belongsTo('Authors'); + + $innerCriterion = new StringCriterion('name'); + $this->lookupCriterion = new LookupCriterion('Articles.author_id', $this->Authors, $innerCriterion); + } + + /** + * tearDown method + * + * @return void + */ + public function tearDown(): void + { + unset($this->Articles, $this->Authors, $this->lookupCriterion); + TableRegistry::getTableLocator()->clear(); + parent::tearDown(); + } + + /** + * Test constructor + * + * @return void + */ + public function testConstructor(): void + { + $this->assertInstanceOf(LookupCriterion::class, $this->lookupCriterion); + } + + /** + * Test isApplicable method + * + * @return void + */ + public function testIsApplicable(): void + { + $this->assertTrue($this->lookupCriterion->isApplicable(['id' => 1], AbstractFilter::COND_EQ)); + $this->assertTrue($this->lookupCriterion->isApplicable(['value' => 'John'], AbstractFilter::COND_LIKE)); + $this->assertFalse($this->lookupCriterion->isApplicable([], AbstractFilter::COND_EQ)); + $this->assertFalse($this->lookupCriterion->isApplicable('', AbstractFilter::COND_LIKE)); + } + + /** + * Test buildFilter method with 'like' condition + * + * @return void + */ + public function testBuildLikeFilter(): void + { + $condition = AbstractFilter::COND_LIKE; + $values = ['value' => 'John']; + $criteria = ['Articles.author_id' => 'John']; + $options = []; + + $result = $this->lookupCriterion->buildFilter($condition, $values, $criteria, $options); + + $this->assertIsCallable($result); + + $query = $this->Articles->find(); + $modifiedQuery = $query->where($result); + + $this->assertMatchesRegularExpression( + '/Articles\.author_id IN \(SELECT Authors\.id AS "?Authors__id"? FROM authors Authors WHERE name LIKE/', + $query->sql() + ); + $bindings = $modifiedQuery->getValueBinder()->bindings(); + $this->assertArrayHasKey(':c0', $bindings); + $this->assertEquals('John%', $bindings[':c0']['value']); + } + + /** + * Test buildFilter method with '=' condition + * + * @return void + */ + public function testBuildEqualFilter(): void + { + $condition = AbstractFilter::COND_EQ; + $values = ['id' => 1]; + $criteria = ['Articles.author_id' => 1]; + $options = []; + + $result = $this->lookupCriterion->buildFilter($condition, $values, $criteria, $options); + + $this->assertIsCallable($result); + + $query = $this->Articles->find(); + $modifiedQuery = $query->where($result); + + $this->assertStringContainsString('Articles.author_id =', $modifiedQuery->sql()); + + $bindings = $modifiedQuery->getValueBinder()->bindings(); + $this->assertArrayHasKey(':c0', $bindings); + $this->assertEquals(1, $bindings[':c0']['value']); + } + + /** + * Test buildQueryByCondition method with case-sensitive LIKE + * + * @return void + */ + public function testBuildQueryByConditionLikeCaseSensitive(): void + { + $condition = AbstractFilter::COND_LIKE; + $values = ['value' => 'John']; + $criteria = ['Articles.author_id' => ['condition' => $condition, 'value' => $values]]; + $options = []; + + $result = $this->lookupCriterion->buildFilter($condition, $values, $criteria, $options); + + $this->assertIsCallable($result); + + $query = $this->Articles->find(); + $modifiedQuery = $query->where($result); + + $this->assertMatchesRegularExpression( + '/Articles\.author_id IN \(SELECT Authors\.id AS "?Authors__id"? FROM authors Authors WHERE name LIKE/', + $query->sql() + ); + + $bindings = $modifiedQuery->getValueBinder()->bindings(); + $this->assertArrayHasKey(':c0', $bindings); + $this->assertEquals('John%', $bindings[':c0']['value']); + } + + /** + * Test buildQueryByCondition method with case-insensitive LIKE + * + * @return void + */ + public function testBuildQueryByConditionLikeCaseInsensitive(): void + { + $condition = AbstractFilter::COND_LIKE; + $values = ['value' => 'John']; + $criteria = ['Articles.author_id' => ['condition' => $condition, 'value' => $values]]; + $options = ['caseInsensitive' => true]; + + $result = $this->lookupCriterion->buildFilter($condition, $values, $criteria, $options); + + $this->assertIsCallable($result); + + $query = $this->Articles->find(); + $modifiedQuery = $query->where($result); + + $this->assertMatchesRegularExpression( + '/Articles\.author_id IN \(SELECT Authors\.id AS "?Authors__id"? FROM authors Authors WHERE name ILIKE/', + $query->sql() + ); + + $bindings = $modifiedQuery->getValueBinder()->bindings(); + $this->assertArrayHasKey(':c0', $bindings); + $this->assertEquals('John%', $bindings[':c0']['value']); + } +} diff --git a/tests/TestCase/Model/Filter/Criterion/NumericCriterionTest.php b/tests/TestCase/Model/Filter/Criterion/NumericCriterionTest.php new file mode 100644 index 0000000..7aa65b3 --- /dev/null +++ b/tests/TestCase/Model/Filter/Criterion/NumericCriterionTest.php @@ -0,0 +1,341 @@ +numericCriterion = new NumericCriterion('test_field'); + } + + /** + * tearDown method + * + * @return void + */ + public function tearDown(): void + { + unset($this->numericCriterion); + parent::tearDown(); + } + + /** + * Test constructor + * + * @return void + */ + public function testConstructor(): void + { + $this->assertInstanceOf(NumericCriterion::class, $this->numericCriterion); + } + + /** + * Test isApplicable method + * + * @return void + */ + public function testIsApplicable(): void + { + $this->assertTrue($this->numericCriterion->isApplicable(10, AbstractFilter::COND_EQ)); + $this->assertTrue($this->numericCriterion->isApplicable([1, 2, 3], AbstractFilter::COND_IN)); + $this->assertFalse($this->numericCriterion->isApplicable('', AbstractFilter::COND_EQ)); + $this->assertFalse($this->numericCriterion->isApplicable(null, AbstractFilter::COND_EQ)); + } + + /** + * Test buildFilter method with '=' condition + * + * @return void + */ + public function testBuildEqualFilter(): void + { + $condition = AbstractFilter::COND_EQ; + $values = ['value' => 10]; + $criteria = ['test_field' => 10]; + $options = []; + + $result = $this->numericCriterion->buildFilter($condition, $values, $criteria, $options); + + $this->assertIsCallable($result); + + $queryExpression = new QueryExpression(); + $modifiedExpression = $result($queryExpression); + + $this->assertInstanceOf(QueryExpression::class, $modifiedExpression); + + $valueBinder = new ValueBinder(); + $sql = $modifiedExpression->sql($valueBinder); + + $this->assertStringContainsString('test_field =', $sql); + $this->assertStringContainsString(':c0', $sql); + + $bindings = $valueBinder->bindings(); + $this->assertArrayHasKey(':c0', $bindings); + $this->assertEquals(10, $bindings[':c0']['value']); + } + + /** + * Test buildFilter method with '>' condition + * + * @return void + */ + public function testBuildGreaterThanFilter(): void + { + $condition = AbstractFilter::COND_GT; + $values = ['value' => 10]; + $criteria = ['test_field' => 10]; + $options = []; + + $result = $this->numericCriterion->buildFilter($condition, $values, $criteria, $options); + + $this->assertIsCallable($result); + + $queryExpression = new QueryExpression(); + $modifiedExpression = $result($queryExpression); + + $this->assertInstanceOf(QueryExpression::class, $modifiedExpression); + + $valueBinder = new ValueBinder(); + $sql = $modifiedExpression->sql($valueBinder); + + $this->assertStringContainsString('test_field >', $sql); + $this->assertStringContainsString(':c0', $sql); + + $bindings = $valueBinder->bindings(); + $this->assertArrayHasKey(':c0', $bindings); + $this->assertEquals(10, $bindings[':c0']['value']); + } + + /** + * Test buildFilter method with '<' condition + * + * @return void + */ + public function testBuildLessThanFilter(): void + { + $condition = AbstractFilter::COND_LT; + $values = ['value' => 10]; + $criteria = ['test_field' => 10]; + $options = []; + + $result = $this->numericCriterion->buildFilter($condition, $values, $criteria, $options); + + $this->assertIsCallable($result); + + $queryExpression = new QueryExpression(); + $modifiedExpression = $result($queryExpression); + + $this->assertInstanceOf(QueryExpression::class, $modifiedExpression); + + $valueBinder = new ValueBinder(); + $sql = $modifiedExpression->sql($valueBinder); + + $this->assertStringContainsString('test_field <', $sql); + $this->assertStringContainsString(':c0', $sql); + + $bindings = $valueBinder->bindings(); + $this->assertArrayHasKey(':c0', $bindings); + $this->assertEquals(10, $bindings[':c0']['value']); + } + + /** + * Test buildFilter method with '<=' condition + * + * @return void + */ + public function testBuildLessThanOrEqualFilter(): void + { + $condition = AbstractFilter::COND_LE; + $values = ['value' => 10]; + $criteria = ['test_field' => 10]; + $options = []; + + $result = $this->numericCriterion->buildFilter($condition, $values, $criteria, $options); + + $this->assertIsCallable($result); + + $queryExpression = new QueryExpression(); + $modifiedExpression = $result($queryExpression); + + $this->assertInstanceOf(QueryExpression::class, $modifiedExpression); + + $valueBinder = new ValueBinder(); + $sql = $modifiedExpression->sql($valueBinder); + + $this->assertStringContainsString('test_field <=', $sql); + $this->assertStringContainsString(':c0', $sql); + + $bindings = $valueBinder->bindings(); + $this->assertArrayHasKey(':c0', $bindings); + $this->assertEquals(10, $bindings[':c0']['value']); + } + + /** + * Test buildFilter method with 'between' condition + * + * @return void + */ + public function testBuildBetweenFilter(): void + { + $condition = AbstractFilter::COND_BETWEEN; + $values = ['from' => 10, 'to' => 20]; + $criteria = ['test_field' => [10, 20]]; + $options = []; + + $result = $this->numericCriterion->buildFilter($condition, $values, $criteria, $options); + + $this->assertIsCallable($result); + + $queryExpression = new QueryExpression(); + $modifiedExpression = $result($queryExpression); + + $this->assertInstanceOf(QueryExpression::class, $modifiedExpression); + + $valueBinder = new ValueBinder(); + $sql = $modifiedExpression->sql($valueBinder); + + $this->assertStringContainsString('test_field BETWEEN', $sql); + $this->assertStringContainsString(':c0 AND :c1', $sql); + + $bindings = $valueBinder->bindings(); + $this->assertArrayHasKey(':c0', $bindings); + $this->assertArrayHasKey(':c1', $bindings); + $this->assertEquals(10, $bindings[':c0']['value']); + $this->assertEquals(20, $bindings[':c1']['value']); + } + + /** + * Test buildFilter method with 'in' condition + * + * @return void + */ + public function testBuildInFilter(): void + { + $condition = AbstractFilter::COND_IN; + $values = ['value' => [10, 20, 30]]; + $criteria = ['test_field' => [10, 20, 30]]; + $options = []; + + $result = $this->numericCriterion->buildFilter($condition, $values, $criteria, $options); + + $this->assertIsCallable($result); + + $queryExpression = new QueryExpression(); + $modifiedExpression = $result($queryExpression); + + $this->assertInstanceOf(QueryExpression::class, $modifiedExpression); + + $valueBinder = new ValueBinder(); + $sql = $modifiedExpression->sql($valueBinder); + + $this->assertStringContainsString('test_field IN', $sql); + $this->assertStringContainsString(':c0', $sql); + $this->assertStringContainsString(':c1', $sql); + $this->assertStringContainsString(':c2', $sql); + + $bindings = $valueBinder->bindings(); + $this->assertArrayHasKey(':c0', $bindings); + $this->assertArrayHasKey(':c1', $bindings); + $this->assertArrayHasKey(':c2', $bindings); + $this->assertEquals(10, $bindings[':c0']['value']); + $this->assertEquals(20, $bindings[':c1']['value']); + $this->assertEquals(30, $bindings[':c2']['value']); + } + + /** + * Test buildFilter method with 'not in' condition + * + * @return void + */ + public function testBuildNotInFilter(): void + { + $condition = AbstractFilter::COND_NOT_IN; + $values = ['value' => [10, 20, 30]]; + $criteria = ['test_field' => [10, 20, 30]]; + $options = []; + + $result = $this->numericCriterion->buildFilter($condition, $values, $criteria, $options); + + $this->assertIsCallable($result); + + $queryExpression = new QueryExpression(); + $modifiedExpression = $result($queryExpression); + + $this->assertInstanceOf(QueryExpression::class, $modifiedExpression); + + $valueBinder = new ValueBinder(); + $sql = $modifiedExpression->sql($valueBinder); + + $this->assertStringContainsString('test_field NOT IN', $sql); + $this->assertStringContainsString(':c0', $sql); + $this->assertStringContainsString(':c1', $sql); + $this->assertStringContainsString(':c2', $sql); + + $bindings = $valueBinder->bindings(); + $this->assertArrayHasKey(':c0', $bindings); + $this->assertArrayHasKey(':c1', $bindings); + $this->assertArrayHasKey(':c2', $bindings); + $this->assertEquals(10, $bindings[':c0']['value']); + $this->assertEquals(20, $bindings[':c1']['value']); + $this->assertEquals(30, $bindings[':c2']['value']); + } + + /** + * Test getValues method + * + * @return void + */ + public function testGetValues(): void + { + $fieldName = 'test_field'; + + $condition = AbstractFilter::COND_IN; + $values = [['test_field' => 42], ['test_field' => 43]]; + $result = $this->numericCriterion->getValues($fieldName, $condition, $values); + $this->assertEquals([42, 43], $result); + + $condition = AbstractFilter::COND_NOT_IN; + $values = [['test_field' => 42], ['test_field' => 43]]; + $result = $this->numericCriterion->getValues($fieldName, $condition, $values); + $this->assertEquals([42, 43], $result); + + $condition = AbstractFilter::COND_EQ; + $values = ['test_field' => 42]; + $result = $this->numericCriterion->getValues($fieldName, $condition, $values); + $this->assertEquals(42, $result); + + $condition = AbstractFilter::COND_EQ; + $values = ['other_field' => 42]; + $result = $this->numericCriterion->getValues($fieldName, $condition, $values); + $this->assertNull($result); + + $condition = AbstractFilter::COND_IN; + $values = ['a' => ['test_field' => 42], 'b' => ['test_field' => 43]]; + $result = $this->numericCriterion->getValues($fieldName, $condition, $values); + $this->assertNull($result); + } +} diff --git a/tests/TestCase/Model/Filter/Criterion/OrCriterionTest.php b/tests/TestCase/Model/Filter/Criterion/OrCriterionTest.php new file mode 100644 index 0000000..d0c9a38 --- /dev/null +++ b/tests/TestCase/Model/Filter/Criterion/OrCriterionTest.php @@ -0,0 +1,110 @@ +orCriterion = new OrCriterion([$nameCriterion, $emailCriterion]); + } + + /** + * tearDown method + * + * @return void + */ + public function tearDown(): void + { + unset($this->orCriterion); + parent::tearDown(); + } + + /** + * Test constructor + * + * @return void + */ + public function testConstructor(): void + { + $this->assertInstanceOf(OrCriterion::class, $this->orCriterion); + } + + /** + * Test buildFilter method + * + * @return void + */ + public function testBuildFilter(): void + { + $condition = AbstractFilter::COND_LIKE; + $values = ['value' => 'john']; + $criteria = [ + 'name_email' => 'john', + ]; + $options = []; + + $result = $this->orCriterion->buildFilter($condition, $values, $criteria, $options); + + $this->assertIsArray($result); + $this->assertCount(2, $result); + $this->assertContainsOnly(Closure::class, $result); + + $queryExpression = new QueryExpression(); + $queryExpression->setConjunction('OR'); + foreach ($result as $closure) { + $queryExpression = $closure($queryExpression); + } + + $this->assertInstanceOf(QueryExpression::class, $queryExpression); + + $valueBinder = new ValueBinder(); + $sql = $queryExpression->sql($valueBinder); + + $this->assertStringContainsString('(name LIKE :c0 OR email LIKE :c1)', $sql); + + $bindings = $valueBinder->bindings(); + $this->assertArrayHasKey(':c0', $bindings); + $this->assertArrayHasKey(':c1', $bindings); + $this->assertEquals('john%', $bindings[':c0']['value']); + $this->assertEquals('john%', $bindings[':c1']['value']); + } + + /** + * Test isApplicable method + * + * @return void + */ + public function testIsApplicable(): void + { + $this->assertTrue($this->orCriterion->isApplicable(['value' => 'John'], AbstractFilter::COND_LIKE)); + $this->assertFalse($this->orCriterion->isApplicable([], AbstractFilter::COND_LIKE)); + } +} diff --git a/tests/TestCase/Model/Filter/Criterion/StringCriterionTest.php b/tests/TestCase/Model/Filter/Criterion/StringCriterionTest.php new file mode 100644 index 0000000..6cc5683 --- /dev/null +++ b/tests/TestCase/Model/Filter/Criterion/StringCriterionTest.php @@ -0,0 +1,355 @@ +stringCriterion = new StringCriterion('test_field'); + } + + /** + * tearDown method + * + * @return void + */ + public function tearDown(): void + { + unset($this->stringCriterion); + parent::tearDown(); + } + + /** + * Test constructor + * + * @return void + */ + public function testConstructor(): void + { + $this->assertInstanceOf(StringCriterion::class, $this->stringCriterion); + } + + /** + * Test valid operators + * + * @return void + */ + public function testValidOperators(): void + { + $validOperators = [ + AbstractFilter::COND_EQ, + AbstractFilter::COND_NE, + AbstractFilter::COND_LIKE, + AbstractFilter::COND_NOT_LIKE, + AbstractFilter::COND_IN, + AbstractFilter::COND_NOT_IN, + ]; + foreach ($validOperators as $operator) { + $criterion = new StringCriterion('test_field'); + $this->assertInstanceOf(StringCriterion::class, $criterion); + } + } + + /** + * Test buildFilter method with 'like' condition + * + * @return void + */ + public function testBuildLikeFilter(): void + { + $condition = AbstractFilter::COND_LIKE; + $values = ['value' => 'test_value']; + $criteria = ['test_field' => 'test_value']; + $options = []; + + $result = $this->stringCriterion->buildFilter($condition, $values, $criteria, $options); + + $this->assertIsCallable($result); + + $queryExpression = new QueryExpression(); + $modifiedExpression = $result($queryExpression); + + $this->assertInstanceOf(QueryExpression::class, $modifiedExpression); + + $valueBinder = new ValueBinder(); + $sql = $modifiedExpression->sql($valueBinder); + + $this->assertStringContainsString('test_field LIKE', $sql); + $this->assertStringContainsString(':c0', $sql); + + $bindings = $valueBinder->bindings(); + $this->assertArrayHasKey(':c0', $bindings); + $this->assertEquals('test_value%', $bindings[':c0']['value']); + } + + /** + * Test buildFilter method with '=' condition + * + * @return void + */ + public function testBuildEqualFilter(): void + { + $condition = AbstractFilter::COND_EQ; + $values = ['value' => 'test_value']; + $criteria = ['test_field' => 'test_value']; + $options = []; + + $result = $this->stringCriterion->buildFilter($condition, $values, $criteria, $options); + + $this->assertIsCallable($result); + + $queryExpression = new QueryExpression(); + $modifiedExpression = $result($queryExpression); + + $this->assertInstanceOf(QueryExpression::class, $modifiedExpression); + + $valueBinder = new ValueBinder(); + $sql = $modifiedExpression->sql($valueBinder); + + $this->assertStringContainsString('test_field =', $sql); + $this->assertStringContainsString(':c0', $sql); + + $bindings = $valueBinder->bindings(); + $this->assertArrayHasKey(':c0', $bindings); + $this->assertEquals('test_value', $bindings[':c0']['value']); + } + + /** + * Test buildFilter method with '!=' condition + * + * @return void + */ + public function testBuildNotEqualFilter(): void + { + $condition = AbstractFilter::COND_NE; + $values = ['value' => 'test_value']; + $criteria = ['test_field' => 'test_value']; + $options = []; + + $result = $this->stringCriterion->buildFilter($condition, $values, $criteria, $options); + + $this->assertIsCallable($result); + + $queryExpression = new QueryExpression(); + $modifiedExpression = $result($queryExpression); + + $this->assertInstanceOf(QueryExpression::class, $modifiedExpression); + + $valueBinder = new ValueBinder(); + $sql = $modifiedExpression->sql($valueBinder); + + $this->assertStringContainsString('test_field !=', $sql); + $this->assertStringContainsString(':c0', $sql); + + $bindings = $valueBinder->bindings(); + $this->assertArrayHasKey(':c0', $bindings); + $this->assertEquals('test_value', $bindings[':c0']['value']); + } + + /** + * Test buildFilter method with 'not like' condition + * + * @return void + */ + public function testBuildNotLikeFilter(): void + { + $condition = AbstractFilter::COND_NOT_LIKE; + $values = ['value' => 'test_value']; + $criteria = ['test_field' => 'test_value']; + $options = []; + + $result = $this->stringCriterion->buildFilter($condition, $values, $criteria, $options); + + $this->assertIsCallable($result); + + $queryExpression = new QueryExpression(); + $modifiedExpression = $result($queryExpression); + + $this->assertInstanceOf(QueryExpression::class, $modifiedExpression); + + $valueBinder = new ValueBinder(); + $sql = $modifiedExpression->sql($valueBinder); + + $this->assertStringContainsString('test_field NOT LIKE', $sql); + $this->assertStringContainsString(':c0', $sql); + + $bindings = $valueBinder->bindings(); + $this->assertArrayHasKey(':c0', $bindings); + $this->assertEquals('test_value%', $bindings[':c0']['value']); + } + + /** + * Test isApplicable method + * + * @return void + */ + public function testIsApplicable(): void + { + $this->assertTrue($this->stringCriterion->isApplicable('test_value', AbstractFilter::COND_LIKE)); + $this->assertFalse($this->stringCriterion->isApplicable('', AbstractFilter::COND_LIKE)); + $this->assertFalse($this->stringCriterion->isApplicable(null, AbstractFilter::COND_LIKE)); + } + + /** + * Test getValues method + * + * @return void + */ + public function testGetValues(): void + { + $fieldName = 'test_field'; + + $condition = AbstractFilter::COND_IN; + $values = [['test_field' => 'value1'], ['test_field' => 'value2']]; + $result = $this->stringCriterion->getValues($fieldName, $condition, $values); + $this->assertEquals(['value1', 'value2'], $result); + + $condition = AbstractFilter::COND_NOT_IN; + $values = [['test_field' => 'value1'], ['test_field' => 'value2']]; + $result = $this->stringCriterion->getValues($fieldName, $condition, $values); + $this->assertEquals(['value1', 'value2'], $result); + + $condition = AbstractFilter::COND_EQ; + $values = ['test_field' => 'value']; + $result = $this->stringCriterion->getValues($fieldName, $condition, $values); + $this->assertEquals('value', $result); + + $condition = AbstractFilter::COND_EQ; + $values = ['other_field' => 'value']; + $result = $this->stringCriterion->getValues($fieldName, $condition, $values); + $this->assertNull($result); + + $condition = AbstractFilter::COND_IN; + $values = ['a' => ['test_field' => 'value1'], 'b' => ['test_field' => 'value2']]; + $result = $this->stringCriterion->getValues($fieldName, $condition, $values); + $this->assertNull($result); + } + + /** + * Test buildQueryByCondition method with case-sensitive LIKE + * + * @return void + */ + public function testBuildQueryByConditionLikeCaseSensitive(): void + { + $field = 'test_field'; + $condition = AbstractFilter::COND_LIKE; + $value = 'test_value'; + $options = []; + + $result = $this->stringCriterion->buildQueryByCondition($field, $condition, $value, $options); + + $this->assertIsCallable($result); + + $expr = new QueryExpression(); + $resultExpr = $result($expr); + + $this->assertInstanceOf(QueryExpression::class, $resultExpr); + + $valueBinder = new ValueBinder(); + $sql = $resultExpr->sql($valueBinder); + $this->assertStringContainsString('test_field LIKE', $sql); + } + + /** + * Test buildQueryByCondition method with case-insensitive LIKE + * + * @return void + */ + public function testBuildQueryByConditionLikeCaseInsensitive(): void + { + $field = 'test_field'; + $condition = AbstractFilter::COND_LIKE; + $value = 'test_value'; + $options = ['caseInsensitive' => true]; + + $result = $this->stringCriterion->buildQueryByCondition($field, $condition, $value, $options); + + $this->assertIsCallable($result); + + $expr = new QueryExpression(); + $resultExpr = $result($expr); + + $this->assertInstanceOf(QueryExpression::class, $resultExpr); + + $valueBinder = new ValueBinder(); + $sql = $resultExpr->sql($valueBinder); + $this->assertStringContainsString('ILIKE', $sql); + } + + /** + * Test buildQueryByCondition method with case-sensitive NOT LIKE + * + * @return void + */ + public function testBuildQueryByConditionNotLikeCaseSensitive(): void + { + $field = 'test_field'; + $condition = AbstractFilter::COND_NOT_LIKE; + $value = 'test_value'; + $options = []; + + $result = $this->stringCriterion->buildQueryByCondition($field, $condition, $value, $options); + + $this->assertIsCallable($result); + + $expr = new QueryExpression(); + $resultExpr = $result($expr); + + $this->assertInstanceOf(QueryExpression::class, $resultExpr); + + $valueBinder = new ValueBinder(); + $sql = $resultExpr->sql($valueBinder); + $this->assertStringContainsString('test_field NOT LIKE', $sql); + } + + /** + * Test buildQueryByCondition method with case-insensitive NOT LIKE + * + * @return void + */ + public function testBuildQueryByConditionNotLikeCaseInsensitive(): void + { + $field = 'test_field'; + $condition = AbstractFilter::COND_NOT_LIKE; + $value = 'test_value'; + $options = ['caseInsensitive' => true]; + + $result = $this->stringCriterion->buildQueryByCondition($field, $condition, $value, $options); + + $this->assertIsCallable($result); + + $expr = new QueryExpression(); + $resultExpr = $result($expr); + + $this->assertInstanceOf(QueryExpression::class, $resultExpr); + + $valueBinder = new ValueBinder(); + $sql = $resultExpr->sql($valueBinder); + $this->assertStringContainsString('NOT ILIKE', $sql); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..1820505 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,117 @@ + 'CakeDC\SearchFilter\Test\App', + 'encoding' => 'UTF-8', + 'paths' => [ + 'templates' => [TEST_APP . 'templates' . DS], + ], +]); +Configure::write('debug', true); + +@mkdir(TMP . 'cache/models', 0777); +@mkdir(TMP . 'cache/persistent', 0777); +@mkdir(TMP . 'cache/views', 0777); + +$cache = [ + 'default' => [ + 'engine' => 'File', + ], + '_cake_core_' => [ + 'className' => 'File', + 'prefix' => 'search_myapp_cake_core_', + 'path' => CACHE . 'persistent/', + 'serialize' => true, + 'duration' => '+10 seconds', + ], + '_cake_model_' => [ + 'className' => 'File', + 'prefix' => 'search_my_app_cake_model_', + 'path' => CACHE . 'models/', + 'serialize' => 'File', + 'duration' => '+10 seconds', + ], +]; + +Cache::setConfig($cache); +Configure::write('Session', [ + 'defaults' => 'php', +]); + +Plugin::getCollection()->add(new \CakeDC\SearchFilter\Plugin([ + 'path' => dirname(dirname(__FILE__)) . DS, + 'routes' => true, +])); + +Configure::write('App.encoding', 'utf8'); + +// Ensure default test connection is defined +if (!getenv('db_dsn')) { + putenv('db_dsn=sqlite:///:memory:'); +} + +ConnectionManager::setConfig('test', [ + 'url' => getenv('db_dsn'), + 'timezone' => 'UTC', +]); + +// Create test database schema +if (env('FIXTURE_SCHEMA_METADATA')) { + $loader = new SchemaLoader(); + $loader->loadInternalFile(env('FIXTURE_SCHEMA_METADATA')); +} + +$error = [ + 'errorLevel' => E_ALL, + 'skipLog' => [], + 'log' => true, + 'trace' => true, + 'ignoredDeprecationPaths' => [], +]; +(new ErrorTrap($error))->register(); diff --git a/tests/schema.php b/tests/schema.php new file mode 100644 index 0000000..2d4b03e --- /dev/null +++ b/tests/schema.php @@ -0,0 +1,117 @@ + 'authors', + 'columns' => [ + 'id' => [ + 'type' => 'integer', + ], + 'name' => [ + 'type' => 'string', + 'default' => null, + ], + ], + 'constraints' => [ + 'primary' => [ + 'type' => 'primary', + 'columns' => [ + 'id', + ], + ], + ], + ], + [ + 'table' => 'articles', + 'columns' => [ + 'id' => [ + 'type' => 'integer', + ], + 'author_id' => [ + 'type' => 'integer', + 'null' => true, + ], + 'title' => [ + 'type' => 'string', + 'null' => true, + ], + 'body' => 'text', + 'published' => [ + 'type' => 'string', + 'length' => 1, + 'default' => 'N', + ], + ], + 'constraints' => [ + 'primary' => [ + 'type' => 'primary', + 'columns' => [ + 'id', + ], + ], + ], + ], + [ + 'table' => 'tags', + 'columns' => [ + 'id' => [ + 'type' => 'integer', + 'null' => false, + ], + 'name' => [ + 'type' => 'string', + 'null' => false, + ], + ], + 'constraints' => [ + 'primary' => [ + 'type' => 'primary', + 'columns' => [ + 'id', + ], + ], + ], + ], + [ + 'table' => 'articles_tags', + 'columns' => [ + 'article_id' => [ + 'type' => 'integer', + 'null' => false, + ], + 'tag_id' => [ + 'type' => 'integer', + 'null' => false, + ], + ], + 'constraints' => [ + 'unique_tag' => [ + 'type' => 'primary', + 'columns' => [ + 'article_id', + 'tag_id', + ], + ], + 'tag_id_fk' => [ + 'type' => 'foreign', + 'columns' => [ + 'tag_id', + ], + 'references' => [ + 'tags', + 'id', + ], + 'update' => 'cascade', + 'delete' => 'cascade', + ], + ], + ], +]; diff --git a/tests/schema.sql b/tests/schema.sql new file mode 100644 index 0000000..a4411d1 --- /dev/null +++ b/tests/schema.sql @@ -0,0 +1 @@ +-- Test database schema for SearchFilter diff --git a/tests/test_app/App/Application.php b/tests/test_app/App/Application.php new file mode 100644 index 0000000..cf1d4f8 --- /dev/null +++ b/tests/test_app/App/Application.php @@ -0,0 +1,63 @@ +addPlugin('CakeDC/SearchFilter', [ + // 'path' => ROOT . DS, + // 'autoload' => true, + ]); + } + + /** + * Setup the middleware your application will use. + * + * @param \Cake\Http\MiddlewareQueue $middleware The middleware queue to setup. + * @return \Cake\Http\MiddlewareQueue The updated middleware. + */ + public function middleware(MiddlewareQueue $middleware): MiddlewareQueue + { + \Cake\Routing\Router::reload(); + $middleware + ->add(new \Cake\Error\Middleware\ErrorHandlerMiddleware()) + ->add(new \Cake\Routing\Middleware\AssetMiddleware()) + ->add(new \Cake\Routing\Middleware\RoutingMiddleware($this)); + + return $middleware; + } + + public function routes(RouteBuilder $routes): void + { + $routes->setRouteClass(DashedRoute::class); + $routes->scope('/', function (RouteBuilder $builder) { + $builder->fallbacks(); + }); + + parent::routes($routes); + } +} diff --git a/tests/test_app/App/Controller/AppController.php b/tests/test_app/App/Controller/AppController.php new file mode 100644 index 0000000..e5dbbbb --- /dev/null +++ b/tests/test_app/App/Controller/AppController.php @@ -0,0 +1,22 @@ +Articles->Authors; + $this->loadComponent('PlumSearch.Filter', [ + 'formName' => 'Article', + 'parameters' => [ + ['name' => 'title', 'className' => 'Input'], + [ + 'name' => 'author_id', + 'className' => 'Select', + 'finder' => $author->find('list'), + ], + ], + ]); + $this->viewBuilder()->addHelpers([ + 'PlumSearch.Search', + ]); + } + + /** + * Index method + * + * @return void + */ + public function index() + { + $query = $this->Articles->find(); + $manager = new Manager($this->request); + $collection = $manager->newCollection(); + + $collection->add('search', $manager->filters() + ->new('string') + ->setConditions(new \stdClass()) + ->setLabel('Search...')); + + $collection->add('name', $manager->filters() + ->new('string') + ->setLabel('Name') + ->setCriterion( + $manager->criterion()->or([ + $manager->buildCriterion('title', 'string', $this->Articles), + $manager->buildCriterion('body', 'string', $this->Articles), + ]) + )); + + $collection->add('created', $manager->filters() + ->new('datetime') + ->setLabel('Created') + ->setCriterion($manager->buildCriterion('created', 'datetime', $this->Articles))); + + $manager->appendFromSchema($collection, $this->Articles); + + $viewFields = $collection->getViewConfig(); + $this->set('viewFields', $viewFields); + if (!empty($this->getRequest()->getQuery()) && !empty($this->getRequest()->getQuery('f'))) { + $search = $manager->formatSearchData(); + $this->set('values', $search); + + $this->Articles->addFilter('multiple', [ + 'className' => 'CakeDC/SearchFilter.Criteria', + 'criteria' => $collection->getCriteria(), + ]); + + $filters = $manager->formatFinders($search); + $query = $query->find('filters', $filters); + } + + $query = $this->Filter->prg($query); + + $this->set('articles', $this->paginate($query)); + } + + /** + * Search method + * + * @return void + */ + public function search() + { + $query = $this->Filter->prg($this->Articles->find('withAuthors')); + $this->set('articles', $this->paginate($query)); + } +} diff --git a/tests/test_app/App/Model/Entity/Article.php b/tests/test_app/App/Model/Entity/Article.php new file mode 100644 index 0000000..d0eff52 --- /dev/null +++ b/tests/test_app/App/Model/Entity/Article.php @@ -0,0 +1,15 @@ +setTable('articles'); + $this->setDisplayField('id'); + $this->setPrimaryKey('id'); + $this->addBehavior('Timestamp'); + $this->addBehavior('PlumSearch.Filterable'); + $this->addFilter('title', ['className' => 'Like']); + $this->addFilter('author_id', ['className' => 'Value']); + + $this->belongsTo('Authors')->setForeignKey('author_id'); + } + + /** + * Authors search finder + * + * @param Query $query query object instance + * @return $this + */ + public function findWithAuthors(Query $query) + { + return $query->matching('Authors'); + } +} diff --git a/tests/test_app/App/Model/Table/ArticlesTagsTable.php b/tests/test_app/App/Model/Table/ArticlesTagsTable.php new file mode 100644 index 0000000..ca9c6b5 --- /dev/null +++ b/tests/test_app/App/Model/Table/ArticlesTagsTable.php @@ -0,0 +1,30 @@ +setTable('articles_tags'); + $this->setDisplayField('id'); + $this->setPrimaryKey('id'); + + $this->belongsTo('Articles')->setForeignKey('article_id'); + $this->belongsTo('Tags')->setForeignKey('tag_id'); + } +} diff --git a/tests/test_app/App/Model/Table/AuthorsTable.php b/tests/test_app/App/Model/Table/AuthorsTable.php new file mode 100644 index 0000000..f50a541 --- /dev/null +++ b/tests/test_app/App/Model/Table/AuthorsTable.php @@ -0,0 +1,20 @@ +setTable('tags'); + $this->setDisplayField('id'); + $this->setPrimaryKey('id'); + } +} diff --git a/tests/test_app/config/bootstrap.php b/tests/test_app/config/bootstrap.php new file mode 100644 index 0000000..5ff14cf --- /dev/null +++ b/tests/test_app/config/bootstrap.php @@ -0,0 +1,2 @@ +setRouteClass(DashedRoute::class); + + $routes->scope('/', function (RouteBuilder $builder) { + + // $builder->connect('/articles', ['controller' => 'Articles', 'action' => 'index']); + // $builder->connect('/articles/search', ['controller' => 'Articles', 'action' => 'search']); + + $builder->fallbacks(); + }); +}; diff --git a/tests/test_app/templates/Articles/index.php b/tests/test_app/templates/Articles/index.php new file mode 100644 index 0000000..bb9faf6 --- /dev/null +++ b/tests/test_app/templates/Articles/index.php @@ -0,0 +1,60 @@ + +

Articles

+ +
+

PlumSearch Filter

+ +

CakeDC/SearchFilter

+ Form->create(null, ['type' => 'get']) ?> + element('CakeDC/SearchFilter.Search/v_search', ['viewFields' => $viewFields]) ?> + Form->submit('Search') ?> + Form->end() ?> +
+ + + + + + + + + + + + + + + + + + + + + + +
IDTitleAuthorCreatedActions
Number->format($article->id) ?>title) ?>author) ?>created) ?> + Html->link('View', ['action' => 'view', $article->id]) ?> + Html->link('Edit', ['action' => 'edit', $article->id]) ?> + Form->postLink('Delete', ['action' => 'delete', $article->id], ['confirm' => 'Are you sure?']) ?> +
+ +
+
    + Paginator->first('<< First') ?> + Paginator->prev('< Previous') ?> + Paginator->numbers() ?> + Paginator->next('Next >') ?> + Paginator->last('Last >>') ?> +
+

Paginator->counter('Page {{page}} of {{pages}}, showing {{current}} record(s) out of {{count}} total') ?>

+
+ + \ No newline at end of file diff --git a/tests/test_app/templates/Articles/search.php b/tests/test_app/templates/Articles/search.php new file mode 100644 index 0000000..2fcefa2 --- /dev/null +++ b/tests/test_app/templates/Articles/search.php @@ -0,0 +1,49 @@ + +

Search Articles

+ +
+

PlumSearch Filter

+
+ + + + + + + + + + + + + + + + + + + + + + +
IDTitleAuthorCreatedActions
Number->format($article->id) ?>title) ?>author) ?>created) ?> + Html->link('View', ['action' => 'view', $article->id]) ?> + Html->link('Edit', ['action' => 'edit', $article->id]) ?> + Form->postLink('Delete', ['action' => 'delete', $article->id], ['confirm' => 'Are you sure?']) ?> +
+ +
+
    + Paginator->first('<< First') ?> + Paginator->prev('< Previous') ?> + Paginator->numbers() ?> + Paginator->next('Next >') ?> + Paginator->last('Last >>') ?> +
+

Paginator->counter('Page {{page}} of {{pages}}, showing {{current}} record(s) out of {{count}} total') ?>

+
\ No newline at end of file diff --git a/tests/test_app/templates/layout/ajax.php b/tests/test_app/templates/layout/ajax.php new file mode 100644 index 0000000..2f16ebc --- /dev/null +++ b/tests/test_app/templates/layout/ajax.php @@ -0,0 +1 @@ + diff --git a/tests/test_app/templates/layout/default.php b/tests/test_app/templates/layout/default.php new file mode 100644 index 0000000..d576f14 --- /dev/null +++ b/tests/test_app/templates/layout/default.php @@ -0,0 +1,42 @@ +loadHelper('Html'); + +$cakeDescription = 'My Test App'; +?> + + + + Html->charset(); ?> + + <?= $cakeDescription ?>: + <?= $this->fetch('title'); ?> + + Html->meta('icon'); + + echo $this->Html->css('cake.generic'); + + echo $this->fetch('script'); + ?> + + +
+ +
+ + fetch('content'); ?> + +
+ +
+ + diff --git a/tests/test_app/templates/layout/error.php b/tests/test_app/templates/layout/error.php new file mode 100644 index 0000000..3c56766 --- /dev/null +++ b/tests/test_app/templates/layout/error.php @@ -0,0 +1 @@ +fetch('content'); ?> diff --git a/tests/test_app/templates/layout/js/default.php b/tests/test_app/templates/layout/js/default.php new file mode 100644 index 0000000..77f86d9 --- /dev/null +++ b/tests/test_app/templates/layout/js/default.php @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/tests/test_app/templates/layout/rss/default.php b/tests/test_app/templates/layout/rss/default.php new file mode 100644 index 0000000..b92cd30 --- /dev/null +++ b/tests/test_app/templates/layout/rss/default.php @@ -0,0 +1,17 @@ +Rss->header(); + +if (!isset($channel)) { + $channel = []; +} +if (!isset($channel['title'])) { + $channel['title'] = $this->fetch('title'); +} + +echo $this->Rss->document( + $this->Rss->channel( + [], $channel, $this->fetch('content') + ) +); + +?> diff --git a/webroot/.gitkeep b/webroot/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/webroot/js/main.js b/webroot/js/main.js new file mode 100644 index 0000000..b4baa72 --- /dev/null +++ b/webroot/js/main.js @@ -0,0 +1,810 @@ +const { + onMounted, + onUpdated, + onUnmounted, + ref, + watch, + reactive, +} = Vue; + +function uuid() { + const b = crypto.getRandomValues(new Uint16Array(8)); + const d = [].map.call(b, a => a.toString(16).padStart(4, '0')).join(''); + const vr = (((b[5] >> 12) & 3) | 8).toString(16); + return `${d.substr(0, 8)}-${d.substr(8, 4)}-4${d.substr(13, 3)}-${vr}${d.substr(17, 3)}-${d.substr(20, 12)}`; +}; + +const isMultiple = function (condition) { + return (condition == 'in' || condition == 'notIn'); +}; + +const inputTypeMap = { + 'none': function (type, condition) { + if (isMultiple(condition)) { + return 'SearchMultiple'; + } + return 'SearchNone'; + }, + 'string': function (type, condition) { + if (isMultiple(condition)) { + return 'SearchMultiple'; + } + return 'SearchInput'; + }, + 'numeric': function (type, condition) { + if (isMultiple(condition)) { + return 'SearchMultiple'; + } else if (condition == 'between') { + return 'SearchInputNumericRange'; + } + + return 'SearchInput'; + }, + 'date': function (type, condition) { + if (isMultiple(condition)) { + return 'SearchMultiple'; + } + switch (condition) { + case 'last_week': + case 'this_week': + case 'yesterday': + case 'today': + return 'SearchInputDateFixed'; + case 'between': + return 'SearchInputDateRange'; + default: + return 'SearchInputDate'; + } + }, + 'datetime': function (type, condition) { + if (isMultiple(condition)) { + return 'SearchMultiple'; + } + switch (condition) { + case 'last_week': + case 'this_week': + case 'yesterday': + case 'today': + return 'SearchInputDateTimeFixed'; + case 'between': + return 'SearchInputDateTimeRange'; + default: + return 'SearchInputDateTime'; + } + }, + 'select': function (type, condition) { + if (isMultiple(condition)) { + return 'SearchMultiple'; + } + return 'SearchSelect'; + }, + 'autocomplete': function (type, condition) { + if (isMultiple(condition)) { + return 'SearchMultiple'; + } else if (condition == 'like') { + return 'SearchInput'; + } else { + return 'SearchLookupInput'; + } + }, + 'multiple': function (type, condition) { + return 'SearchSelectMultiple'; + }, +}; + +function registerConditions(type, checker) { + inputTypeMap[type] = checker; +} + +function getInputType(type, condition) { + let defaultComponent = 'SearchInput'; + if (inputTypeMap.hasOwnProperty(type)) { + let component = inputTypeMap[type](type, condition); + if (component !== null) { + return component; + } + } + return defaultComponent; +} + +function getInputType1(type, condition) { + let componentName = 'SearchInput'; + if (condition == 'in' && type == 'multiple') { + componentName = 'SearchSelectMultiple'; + } else if (condition == 'in') { + componentName = 'SearchMultiple'; + } else if (condition == 'none') { + componentName = 'SearchNone'; + } else if (type == 'autocomplete' && condition == 'like') { + componentName = 'SearchInput'; + } else if (type == 'autocomplete') { + componentName = 'SearchLookupInput'; + } else if (type == 'numeric' && condition == 'between') { + componentName = 'SearchInputNumericRange'; + } else if (type == 'datetime') { + switch (condition) { + case 'last_week': + case 'this_week': + case 'yesterday': + case 'today': + componentName = 'SearchInputDateTimeFixed'; + break; + case 'between': + componentName = 'SearchInputDateTimeRange'; + break; + default: + componentName = 'SearchInputDateTime'; + } + } else if (type == 'date') { + switch (condition) { + case 'last_week': + case 'this_week': + case 'yesterday': + case 'today': + componentName = 'SearchInputDateFixed'; + break; + case 'between': + componentName = 'SearchInputDateRange'; + break; + default: + componentName = 'SearchInputDate'; + } + } else if (type == 'select') { + componentName = 'SearchSelect'; + } else if (type == 'multiple') { + componentName = 'SearchSelectMultiple'; + } else { + componentName = 'SearchInput'; + } + + return componentName; +}; + +const SearchApp = { + template: "#search-list", + rootElemId: 'ext-search', + data() { + return { + filters: [], + components: [], + appKey: uuid(), + }; + }, + methods: { + getProps(type, index) { + let idx = this.components.findIndex(el => el.index == index); + return { + fields: this.fields, + values: this.values, + ...this.components[idx] + } + }, + emptyFilters() { + return this.components.length == 0; + }, + add: function(options) { + options = options || {}; + this.components.push({ + type: 'SearchItem', + index: this.components.length, + key: uuid(), + ...options + }); + }, + addOrHighlight(filter) { + let idx = this.components.findIndex(el => el.filter == filter); + this.components = this.components.map((c) => { + return {...c, highlight: false}; + }) + if (idx < 0) { + this.add({filter:filter, highlight: true}); + } else { + this.components = this.components.map((c) => { + return {...c, highlight: (c.filter == filter), key: uuid()}; + }) + } + }, + + removeAll: function() { + this.components = [] + }, + removeItem(index) { + let idx = this.components.findIndex(el => el.index == index); + this.components = this.components.filter(el => el.index != index) + this.updateComponentsIndex(); + }, + updateComponentsIndex() { + const keys = this.components.keys(); + for (let x of keys) { + this.components[x].index = x; + } + }, + selectFilter(evt) { + let idx = this.components.findIndex(el => el.index == evt.index); + this.components[idx] = {...this.components[idx], filter: evt.filter}; + }, + addFilter(evt) { + this.add({ filter: evt.filter }); + }, + selectCondition(evt) { + let idx = this.components.findIndex(el => el.index == evt.index); + this.components[idx] = {...this.components[idx], condition: evt.condition}; + }, + setValue(evt) { + let idx = this.components.findIndex(el => el.index == evt.index); + this.components[idx] = {...this.components[idx], value: evt.value}; + }, + + }, + mounted() { + console.info("Search mounted!"); + const keys = Object.keys(this.values); + for (let filter of keys) { + this.add({filter: filter, ...this.values[filter]}); + } + if (keys.length == 0) { + this.add(); + } + const appRoot = document.getElementById(window._search.rootElemId); + appRoot.addEventListener( + "activate-filter", + (e) => { + this.addOrHighlight(e.detail.filter); + // e.target matches elem + // trigger + // const event = new CustomEvent("activate-filter", {detail: { filter: 'title' }}); + // const el = document.getElementById(window._search.rootElemId) + // el.dispatchEvent(event) + }, + false, + ); + }, + setup(props, context) { + let fields = reactive(window._search.fields); + let values = reactive(window._search.values); + + return {fields, values}; + } +}; + +const AddNewFilter = { + template: "#search-add-filter-template", + props: ['fields', 'components'], + data() { + return { + search_filter: '', + }; + }, + methods: { + disabled(filter) { + return this.components.findIndex(el => el.filter == filter) >= 0; + }, + selectFilter(event) { + this.$emit('add-filter', {filter: this.search_filter}); + }, + }, + +}; + +const SearchItem = { + template: "#search-item-template", + props: ['fields', 'index', 'filter', 'condition', 'value', 'highlight'], + data() { + return { + search_filter: this.filter, + itemClasses: ['search-filter-item'], + }; + }, + methods: { + selectCondition(event) { + this.$emit('select-condition', event); + }, + setValue(event) { + this.$emit('change-value', event); + }, + selectFilter(event) { + this.$emit('select-filter', {filter: this.search_filter, index: this.index}); + }, + displayCondition() { + return this.search_filter != ''; + }, + currentField() { + let field = this.fields[this.search_filter]; + let localField = {index: this.index}; + return {...field, ...localField, condition: this.condition, value: this.value, field: field}; + }, + closeItem(index) { + this.$emit('close-item', this.index) + }, + }, + mounted() { + if (this.highlight) { + this.itemClasses = ['item-alert', 'search-filter-item']; + window.scrollTo({ + top: document.getElementById("filter-" + this.filter).offsetTop, + left: 0, + behavior: "smooth", + }); + setTimeout(() => { this.itemClasses = ['search-filter-item']; }, 5000); + } + }, + setup(props, context) { + let fields = props.fields; + + return { fields }; + } +}; + +const SearchCondition = { + template: "#search-conditions-template", + props: ['conditions', 'name', 'type', 'index', 'condition', 'value', 'field'], + data() { + let condition = this.condition; + if (condition == '' || condition == null) { + let keys = Object.keys(this.conditions); + if (keys.length > 0) { + condition = keys[0]; + } + } + return { + conditionValue: condition, + inputType: getInputType(this.type, condition), + }; + }, + methods: { + showConditionClasses() { + let keys = Object.keys(this.conditions) + if (keys.length > 0) { + return []; + } else { + return ['hidden']; + } + }, + changeCondition(event) { + this.$emit('select-condition', {condition: this.conditionValue, index: this.index}); + this.inputType = getInputType(this.type, this.conditionValue); + }, + setValue(event) { + this.$emit('change-value', event); + }, + visibleInput() { + return this.inputType != null; + }, + getInputType() { + return getInputType(this.type, this.conditionValue); + }, + getInputProps() { + return { index: this.index, value: this.value, field: this.field, type: this.type }; + }, + }, +}; + +const SearchInput = { + template: "#search-input-template", + props: ['index', 'value', 'field'], + data() { + let value = ''; + if (this.value != null && this.value != undefined) { + value = this.value.value; + } + return { + currentValue: value, + }; + }, + methods: { + setValue(event) { + this.$emit('change-value', {index: this.index, value: {value: this.currentValue}}); + }, + }, +}; + +const SearchNone = { + template: "#search-input-none-template", + props: ['index', 'value', 'field'], + data() { + return { + currentValue: '', + }; + }, + methods: { + setValue(event) { + this.$emit('change-value', {index: this.index, value: {value: this.currentValue}}); + }, + }, +}; + +const SearchInputDate = { + template: "#search-input-date-template", + props: ['index', 'value', 'field'], + data() { + let value = ''; + if (this.value != null && this.value != undefined) { + value = this.value.value; + } + return { + currentValue: value, + }; + }, + methods: { + setValue(field) { + this.$emit('change-value', {index: this.index, value: {value: this.currentValue}}); + }, + }, +}; + +const SearchInputDateRange = { + template: "#search-input-date-range-template", + props: ['index', 'value', 'field'], + data() { + let dateFrom = ''; + let dateTo = ''; + if (this.value != null && this.value != undefined) { + if (this.value.date_from) { + dateFrom = this.value.date_from; + } else if (this.value.value) { + dateFrom = this.value.value; + } + if (this.value.date_to) { + dateTo = this.value.date_to; + } + } + return { + currentDateFrom: dateFrom, + currentDateTo: dateTo, + dateFormat: 'MM/DD/YYYY hh:mm A', + }; + }, + methods: { + setValue(event) { + this.$emit('change-value', {index: this.index, value: {dateFrom: this.currentDateFrom, dateTo: this.currentDateTo}}); + }, + }, +}; + +const SearchInputDateFixed = { + template: "#search-input-date-fixed-template", + props: ['index', 'value', 'field'], + data() { + return { + currentValue: '', + }; + }, + methods: { + setValue(event) { + this.$emit('change-value', {index: this.index, value: {value: this.currentValue}}); + }, + }, +}; + +const SearchInputDateTime = { + template: "#search-input-date-time-template", + props: ['index', 'value', 'field'], + data() { + let value = ''; + if (this.value != null && this.value != undefined) { + value = this.value.value; + } + return { + currentValue: value, + }; + }, + methods: { + setValue(field) { + this.$emit('change-value', {index: this.index, value: {value: this.currentValue}}); + }, + }, +}; + +const SearchInputDateTimeRange = { + template: "#search-input-date-time-range-template", + props: ['index', 'value', 'field'], + data() { + let dateFrom = ''; + let dateTo = ''; + if (this.value != null && this.value != undefined) { + if (this.value.date_from) { + dateFrom = this.value.date_from; + } else if (this.value.value) { + dateFrom = this.value.value; + } + if (this.value.date_to) { + dateTo = this.value.date_to; + } + } + return { + currentDateFrom: dateFrom, + currentDateTo: dateTo, + dateFormat: 'MM/DD/YYYY hh:mm A', + }; + }, + methods: { + setValue(event) { + this.$emit('change-value', {index: this.index, value: {dateFrom: this.currentDateFrom, dateTo: this.currentDateTo}}); + }, + }, +}; + +const SearchInputDateTimeFixed = { + template: "#search-input-date-time-fixed-template", + props: ['index', 'value', 'field'], + data() { + return { + currentValue: '', + }; + }, + methods: { + setValue(event) { + this.$emit('change-value', {index: this.index, value: {value: this.currentValue}}); + }, + }, +}; + +const SearchInputNumericRange = { + template: "#search-input-numeric-range-template", + props: ['index', 'value', 'field'], + data() { + let from = ''; + let to = ''; + if (this.value != null && this.value != undefined) { + if (this.value.from) { + from = this.value.from; + } else if (this.value.value) { + from = this.value.value; + } + if (this.value.to) { + to = this.value.to; + } + } + return { + currentFrom: from, + currentTo: to, + }; + }, + methods: { + setValue(event) { + this.$emit('change-value', {index: this.index, value: {from: this.from, to: this.to}}); + }, + }, +}; + +const SearchSelect = { + template: "#search-input-select-template", + props: ['index', 'value', 'field'], + data() { + let value = ''; + if (this.value != null && this.value != undefined) { + value = this.value.value; + } + return { + currentValue: value, + options: this.field.options || {}, + empty: this.field.empty || '[Select]', + }; + }, + methods: { + setValue(event) { + this.$emit('change-value', {index: this.index, value: {value: this.currentValue}}); + }, + }, +}; + +const SearchSelectMultiple = { + template: "#search-input-multiple-template", + props: ['index', 'value', 'field'], + data() { + let value = ''; + if (this.value != null && this.value != undefined) { + value = this.value.value; + } + return { + currentValue: value, + options: this.field.options || {}, + empty: this.field.empty || '[Select]', + }; + }, + methods: { + setValue(event) { + this.$emit('change-value', {index: this.index, value: {value: this.currentValue}}); + }, + }, +}; + +const SearchMultiple = { + template: "#search-multiple-list-template", + props: ['index', 'value', 'field', 'type'], + data() { + return { + components: [], + values: this.value || {}, + }; + }, + methods: { + getProps(index) { + let idx = this.components.findIndex(el => el.itemIndex == index); + return {field: this.field, value: this.value, index: this.index, ...this.components[idx]} + }, + add: function(options) { + options = options || { + value: this.value, + }; + this.components.push({ + field: this.field, + index: this.index, + itemType: this.type, + type: 'SearchMultipleItem', + itemIndex: this.components.length, + key: uuid(), + ...options + }); + }, + + removeItem(index) { + let idx = this.components.findIndex(el => el.itemIndex == index); + this.components = this.components.filter(el => el.itemIndex != index) + this.updateComponentsIndex(); + }, + updateComponentsIndex() { + const keys = this.components.keys(); + for (let x of keys) { + this.components[x].itemIndex = x; + } + }, + setValue(evt) { + this.values[evt.itemIndex] = evt.value; + this.$emit('change-value', {index: this.index, value: this.values}); + }, + }, + mounted() { + if (this.value != undefined && this.value != null) { + const keys = Object.keys(this.value); + + for (let v of keys) { + this.add({value: this.value[v]}); + } + if (keys.length == 0) { + this.add(); + } + } else { + this.add(); + } + }, +}; + +const SearchMultipleItem = { + template: "#search-multiple-item-template", + props: ['name', 'type', 'index', 'itemIndex', 'value', 'field', 'itemType'], + data() { + return { + conditionValue: this.condition, + }; + }, + methods: { + closeItem(index) { + this.$emit('close-item', this.itemIndex) + }, + setValue(event) { + this.$emit('change-value', {index: this.index, itemIndex: this.itemIndex, ...event}); + }, + visibleInput() { + return true; + }, + getInputType() { + return getInputType(this.itemType, '='); + }, + getInputProps() { + return { index: this.index, value: this.value, field: this.field }; + }, + }, + +}; + +const SearchLookupInput = { + template: "#search-input-lookup-template", + props: ['index', 'value', 'field'], + data() { + return { + inputValue: '', + selectedId: '', + suggestions: [], + showSuggestions: false, + debounceTimeout: null, + }; + }, + computed: { + autocompleteUrl() { + return this.field.autocompleteUrl; + }, + idName() { + return this.field.idName || 'id'; + }, + valueName() { + return this.field.valueName || 'name'; + }, + query() { + return this.field.query || `${this.valueName}=%QUERY`; + }, + wildcard() { + return this.field.wildcard || '%QUERY'; + }, + }, + methods: { + onInput() { + clearTimeout(this.debounceTimeout); + this.debounceTimeout = setTimeout(() => { + this.fetchSuggestions(); + }, 300); + }, + async fetchSuggestions() { + if (this.inputValue.length < 2) { + this.suggestions = []; + this.showSuggestions = false; + return; + } + + let query = this.query.replace(this.wildcard, this.inputValue) + let autocompleteUrl = this.autocompleteUrl + '?' + query; + const url = new URL(autocompleteUrl); + + try { + const response = await fetch(url); + if (!response.ok) { + throw new Error('Network response was not ok'); + } + const data = await response.json(); + this.suggestions = data; + this.showSuggestions = true; + } catch (error) { + console.error('Error fetching suggestions:', error); + } + }, + selectSuggestion(suggestion) { + this.inputValue = suggestion[this.valueName]; + this.selectedId = suggestion[this.idName]; + this.showSuggestions = false; + this.$emit('change-value', { + index: this.index, + value: { id: this.selectedId, value: this.inputValue } + }); + }, + onBlur() { + setTimeout(() => { + this.showSuggestions = false; + }, 200); + }, + }, + mounted() { + if (this.value) { + this.inputValue = this.value.value; + this.selectedId = this.value.id; + } + }, +}; + +const createMyApp = (root, callback) => { + const app = Vue.createApp(SearchApp); + app.component('AddNewFilter', AddNewFilter); + app.component('SearchItem', SearchItem); + app.component('SearchCondition', SearchCondition); + app.component('SearchInput', SearchInput); + app.component('SearchInputDate', SearchInputDate); + app.component('SearchInputDateFixed', SearchInputDateFixed); + app.component('SearchInputDateRange', SearchInputDateRange); + app.component('SearchInputDateTime', SearchInputDateTime); + app.component('SearchInputDateTimeFixed', SearchInputDateTimeFixed); + app.component('SearchInputDateTimeRange', SearchInputDateTimeRange); + app.component('SearchInputNumericRange', SearchInputNumericRange); + app.component('SearchNone', SearchNone); + app.component('SearchSelect', SearchSelect); + app.component('SearchSelectMultiple', SearchSelectMultiple); + app.component('SearchMultiple', SearchMultiple); + app.component('SearchMultipleItem', SearchMultipleItem); + app.component('SearchLookupInput', SearchLookupInput); + if (callback != undefined) { + callback(app, registerConditions); + } + window._search.rootElemId = root; + app.mount('#' + root); + window._search.app = app; +}; +window._search.rootElemId = SearchApp.rootElemId; +window._search.createMyApp = createMyApp; diff --git a/webroot/js/vue3.js b/webroot/js/vue3.js new file mode 100644 index 0000000..40974a4 --- /dev/null +++ b/webroot/js/vue3.js @@ -0,0 +1,13 @@ +/** +* vue v3.5.3 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**/ +var Vue=function(e){"use strict"; +/*! #__NO_SIDE_EFFECTS__ */function t(e,t){const n=new Set(e.split(","));return t?e=>n.has(e.toLowerCase()):e=>n.has(e)}const n=Object.freeze({}),o=Object.freeze([]),s=()=>{},r=()=>!1,i=e=>111===e.charCodeAt(0)&&110===e.charCodeAt(1)&&(e.charCodeAt(2)>122||e.charCodeAt(2)<97),a=e=>e.startsWith("onUpdate:"),c=Object.assign,l=(e,t)=>{const n=e.indexOf(t);n>-1&&e.splice(n,1)},u=Object.prototype.hasOwnProperty,d=(e,t)=>u.call(e,t),p=Array.isArray,h=e=>"[object Map]"===x(e),f=e=>"[object Set]"===x(e),m=e=>"[object Date]"===x(e),g=e=>"function"==typeof e,y=e=>"string"==typeof e,v=e=>"symbol"==typeof e,b=e=>null!==e&&"object"==typeof e,S=e=>(b(e)||g(e))&&g(e.then)&&g(e.catch),_=Object.prototype.toString,x=e=>_.call(e),w=e=>x(e).slice(8,-1),k=e=>"[object Object]"===x(e),C=e=>y(e)&&"NaN"!==e&&"-"!==e[0]&&""+parseInt(e,10)===e,T=t(",key,ref,ref_for,ref_key,onVnodeBeforeMount,onVnodeMounted,onVnodeBeforeUpdate,onVnodeUpdated,onVnodeBeforeUnmount,onVnodeUnmounted"),E=t("bind,cloak,else-if,else,for,html,if,model,on,once,pre,show,slot,text,memo"),A=e=>{const t=Object.create(null);return n=>t[n]||(t[n]=e(n))},N=/-(\w)/g,I=A((e=>e.replace(N,((e,t)=>t?t.toUpperCase():"")))),$=/\B([A-Z])/g,O=A((e=>e.replace($,"-$1").toLowerCase())),R=A((e=>e.charAt(0).toUpperCase()+e.slice(1))),M=A((e=>e?`on${R(e)}`:"")),P=(e,t)=>!Object.is(e,t),F=(e,...t)=>{for(let n=0;n{Object.defineProperty(e,t,{configurable:!0,enumerable:!1,writable:o,value:n})},L=e=>{const t=parseFloat(e);return isNaN(t)?e:t},V=e=>{const t=y(e)?Number(e):NaN;return isNaN(t)?e:t};let j;const U=()=>j||(j="undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:"undefined"!=typeof window?window:"undefined"!=typeof global?global:{}),B={1:"TEXT",2:"CLASS",4:"STYLE",8:"PROPS",16:"FULL_PROPS",32:"NEED_HYDRATION",64:"STABLE_FRAGMENT",128:"KEYED_FRAGMENT",256:"UNKEYED_FRAGMENT",512:"NEED_PATCH",1024:"DYNAMIC_SLOTS",2048:"DEV_ROOT_FRAGMENT",[-1]:"HOISTED",[-2]:"BAIL"},H={1:"STABLE",2:"DYNAMIC",3:"FORWARDED"},q=t("Infinity,undefined,NaN,isFinite,isNaN,parseFloat,parseInt,decodeURI,decodeURIComponent,encodeURI,encodeURIComponent,Math,Number,Date,Array,Object,Boolean,String,RegExp,Map,Set,JSON,Intl,BigInt,console,Error,Symbol"),W=2;function z(e){if(p(e)){const t={};for(let n=0;n{if(e){const n=e.split(J);n.length>1&&(t[n[0].trim()]=n[1].trim())}})),t}function X(e){let t="";if(y(e))t=e;else if(p(e))for(let n=0;n?@[\\\]^`{|}~]/g;function le(e,t){return e.replace(ce,(e=>`\\${e}`))}function ue(e,t){if(e===t)return!0;let n=m(e),o=m(t);if(n||o)return!(!n||!o)&&e.getTime()===t.getTime();if(n=v(e),o=v(t),n||o)return e===t;if(n=p(e),o=p(t),n||o)return!(!n||!o)&&function(e,t){if(e.length!==t.length)return!1;let n=!0;for(let o=0;n&&oue(e,t)))}const pe=e=>!(!e||!0!==e.__v_isRef),he=e=>y(e)?e:null==e?"":p(e)||b(e)&&(e.toString===_||!g(e.toString))?pe(e)?he(e.value):JSON.stringify(e,fe,2):String(e),fe=(e,t)=>pe(t)?fe(e,t.value):h(t)?{[`Map(${t.size})`]:[...t.entries()].reduce(((e,[t,n],o)=>(e[me(t,o)+" =>"]=n,e)),{})}:f(t)?{[`Set(${t.size})`]:[...t.values()].map((e=>me(e)))}:v(t)?me(t):!b(t)||p(t)||k(t)?t:String(t),me=(e,t="")=>{var n;return v(e)?`Symbol(${null!=(n=e.description)?n:t})`:e};function ge(e,...t){console.warn(`[Vue warn] ${e}`,...t)}let ye,ve;class be{constructor(e=!1){this.detached=e,this._active=!0,this.effects=[],this.cleanups=[],this._isPaused=!1,this.parent=ye,!e&&ye&&(this.index=(ye.scopes||(ye.scopes=[])).push(this)-1)}get active(){return this._active}pause(){if(this._active){let e,t;if(this._isPaused=!0,this.scopes)for(e=0,t=this.scopes.length;e0)return;let e;for(;we;){let t=we;for(we=void 0;t;){const n=t.nextEffect;if(t.nextEffect=void 0,t.flags&=-9,1&t.flags)try{t.trigger()}catch(t){e||(e=t)}t=n}}if(e)throw e}function Ee(e){for(let t=e.deps;t;t=t.nextDep)t.version=-1,t.prevActiveLink=t.dep.activeLink,t.dep.activeLink=t}function Ae(e){let t,n=e.depsTail;for(let e=n;e;e=e.prevDep)-1===e.version?(e===n&&(n=e.prevDep),$e(e),Oe(e)):t=e,e.dep.activeLink=e.prevActiveLink,e.prevActiveLink=void 0;e.deps=t,e.depsTail=n}function Ne(e){for(let t=e.deps;t;t=t.nextDep)if(t.dep.version!==t.version||t.dep.computed&&!1===Ie(t.dep.computed)||t.dep.version!==t.version)return!0;return!!e._dirty}function Ie(e){if(2&e.flags)return!1;if(4&e.flags&&!(16&e.flags))return;if(e.flags&=-17,e.globalVersion===Le)return;e.globalVersion=Le;const t=e.dep;if(e.flags|=2,t.version>0&&!e.isSSR&&!Ne(e))return void(e.flags&=-3);const n=ve,o=Re;ve=e,Re=!0;try{Ee(e);const n=e.fn(e._value);(0===t.version||P(n,e._value))&&(e._value=n,t.version++)}catch(e){throw t.version++,e}finally{ve=n,Re=o,Ae(e),e.flags&=-3}}function $e(e){const{dep:t,prevSub:n,nextSub:o}=e;if(n&&(n.nextSub=o,e.prevSub=void 0),o&&(o.prevSub=n,e.nextSub=void 0),t.subs===e&&(t.subs=n),!t.subs&&t.computed){t.computed.flags&=-5;for(let e=t.computed.deps;e;e=e.nextDep)$e(e)}}function Oe(e){const{prevDep:t,nextDep:n}=e;t&&(t.nextDep=n,e.prevDep=void 0),n&&(n.prevDep=t,e.nextDep=void 0)}let Re=!0;const Me=[];function Pe(){Me.push(Re),Re=!1}function Fe(){const e=Me.pop();Re=void 0===e||e}function De(e){const{cleanup:t}=e;if(e.cleanup=void 0,t){const e=ve;ve=void 0;try{t()}finally{ve=e}}}let Le=0;class Ve{constructor(e){this.computed=e,this.version=0,this.activeLink=void 0,this.subs=void 0,this.subsHead=void 0}track(e){if(!ve||!Re||ve===this.computed)return;let t=this.activeLink;if(void 0===t||t.sub!==ve)t=this.activeLink={dep:this,sub:ve,version:this.version,nextDep:void 0,prevDep:void 0,nextSub:void 0,prevSub:void 0,prevActiveLink:void 0},ve.deps?(t.prevDep=ve.depsTail,ve.depsTail.nextDep=t,ve.depsTail=t):ve.deps=ve.depsTail=t,4&ve.flags&&je(t);else if(-1===t.version&&(t.version=this.version,t.nextDep)){const e=t.nextDep;e.prevDep=t.prevDep,t.prevDep&&(t.prevDep.nextDep=e),t.prevDep=ve.depsTail,t.nextDep=void 0,ve.depsTail.nextDep=t,ve.depsTail=t,ve.deps===t&&(ve.deps=e)}return ve.onTrack&&ve.onTrack(c({effect:ve},e)),t}trigger(e){this.version++,Le++,this.notify(e)}notify(e){Ce();try{for(let t=this.subsHead;t;t=t.nextSub)!t.sub.onTrigger||8&t.sub.flags||t.sub.onTrigger(c({effect:t.sub},e));for(let e=this.subs;e;e=e.prevSub)e.sub.notify()}finally{Te()}}}function je(e){const t=e.dep.computed;if(t&&!e.dep.subs){t.flags|=20;for(let e=t.deps;e;e=e.nextDep)je(e)}const n=e.dep.subs;n!==e&&(e.prevSub=n,n&&(n.nextSub=e)),void 0===e.dep.subsHead&&(e.dep.subsHead=e),e.dep.subs=e}const Ue=new WeakMap,Be=Symbol("Object iterate"),He=Symbol("Map keys iterate"),qe=Symbol("Array iterate");function We(e,t,n){if(Re&&ve){let o=Ue.get(e);o||Ue.set(e,o=new Map);let s=o.get(n);s||o.set(n,s=new Ve),s.track({target:e,type:t,key:n})}}function ze(e,t,n,o,s,r){const i=Ue.get(e);if(!i)return void Le++;let a=[];if("clear"===t)a=[...i.values()];else{const s=p(e),r=s&&C(n);if(s&&"length"===n){const e=Number(o);i.forEach(((t,n)=>{("length"===n||n===qe||!v(n)&&n>=e)&&a.push(t)}))}else{const o=e=>e&&a.push(e);switch(void 0!==n&&o(i.get(n)),r&&o(i.get(qe)),t){case"add":s?r&&o(i.get("length")):(o(i.get(Be)),h(e)&&o(i.get(He)));break;case"delete":s||(o(i.get(Be)),h(e)&&o(i.get(He)));break;case"set":h(e)&&o(i.get(Be))}}}Ce();for(const i of a)i.trigger({target:e,type:t,key:n,newValue:o,oldValue:s,oldTarget:r});Te()}function Ke(e){const t=Jt(e);return t===e?t:(We(t,"iterate",qe),zt(e)?t:t.map(Yt))}function Je(e){return We(e=Jt(e),"iterate",qe),e}const Ge={__proto__:null,[Symbol.iterator](){return Ye(this,Symbol.iterator,Yt)},concat(...e){return Ke(this).concat(...e.map((e=>p(e)?Ke(e):e)))},entries(){return Ye(this,"entries",(e=>(e[1]=Yt(e[1]),e)))},every(e,t){return Qe(this,"every",e,t,void 0,arguments)},filter(e,t){return Qe(this,"filter",e,t,(e=>e.map(Yt)),arguments)},find(e,t){return Qe(this,"find",e,t,Yt,arguments)},findIndex(e,t){return Qe(this,"findIndex",e,t,void 0,arguments)},findLast(e,t){return Qe(this,"findLast",e,t,Yt,arguments)},findLastIndex(e,t){return Qe(this,"findLastIndex",e,t,void 0,arguments)},forEach(e,t){return Qe(this,"forEach",e,t,void 0,arguments)},includes(...e){return et(this,"includes",e)},indexOf(...e){return et(this,"indexOf",e)},join(e){return Ke(this).join(e)},lastIndexOf(...e){return et(this,"lastIndexOf",e)},map(e,t){return Qe(this,"map",e,t,void 0,arguments)},pop(){return tt(this,"pop")},push(...e){return tt(this,"push",e)},reduce(e,...t){return Ze(this,"reduce",e,t)},reduceRight(e,...t){return Ze(this,"reduceRight",e,t)},shift(){return tt(this,"shift")},some(e,t){return Qe(this,"some",e,t,void 0,arguments)},splice(...e){return tt(this,"splice",e)},toReversed(){return Ke(this).toReversed()},toSorted(e){return Ke(this).toSorted(e)},toSpliced(...e){return Ke(this).toSpliced(...e)},unshift(...e){return tt(this,"unshift",e)},values(){return Ye(this,"values",Yt)}};function Ye(e,t,n){const o=Je(e),s=o[t]();return o===e||zt(e)||(s._next=s.next,s.next=()=>{const e=s._next();return e.value&&(e.value=n(e.value)),e}),s}const Xe=Array.prototype;function Qe(e,t,n,o,s,r){const i=Je(e),a=i!==e&&!zt(e),c=i[t];if(c!==Xe[t]){const t=c.apply(e,r);return a?Yt(t):t}let l=n;i!==e&&(a?l=function(t,o){return n.call(this,Yt(t),o,e)}:n.length>2&&(l=function(t,o){return n.call(this,t,o,e)}));const u=c.call(i,l,o);return a&&s?s(u):u}function Ze(e,t,n,o){const s=Je(e);let r=n;return s!==e&&(zt(e)?n.length>3&&(r=function(t,o,s){return n.call(this,t,o,s,e)}):r=function(t,o,s){return n.call(this,t,Yt(o),s,e)}),s[t](r,...o)}function et(e,t,n){const o=Jt(e);We(o,"iterate",qe);const s=o[t](...n);return-1!==s&&!1!==s||!Kt(n[0])?s:(n[0]=Jt(n[0]),o[t](...n))}function tt(e,t,n=[]){Pe(),Ce();const o=Jt(e)[t].apply(e,n);return Te(),Fe(),o}const nt=t("__proto__,__v_isRef,__isVue"),ot=new Set(Object.getOwnPropertyNames(Symbol).filter((e=>"arguments"!==e&&"caller"!==e)).map((e=>Symbol[e])).filter(v));function st(e){v(e)||(e=String(e));const t=Jt(this);return We(t,"has",e),t.hasOwnProperty(e)}class rt{constructor(e=!1,t=!1){this._isReadonly=e,this._isShallow=t}get(e,t,n){const o=this._isReadonly,s=this._isShallow;if("__v_isReactive"===t)return!o;if("__v_isReadonly"===t)return o;if("__v_isShallow"===t)return s;if("__v_raw"===t)return n===(o?s?Lt:Dt:s?Ft:Pt).get(e)||Object.getPrototypeOf(e)===Object.getPrototypeOf(n)?e:void 0;const r=p(e);if(!o){let e;if(r&&(e=Ge[t]))return e;if("hasOwnProperty"===t)return st}const i=Reflect.get(e,t,Qt(e)?e:n);return(v(t)?ot.has(t):nt(t))?i:(o||We(e,"get",t),s?i:Qt(i)?r&&C(t)?i:i.value:b(i)?o?Ut(i):Vt(i):i)}}class it extends rt{constructor(e=!1){super(!1,e)}set(e,t,n,o){let s=e[t];if(!this._isShallow){const t=Wt(s);if(zt(n)||Wt(n)||(s=Jt(s),n=Jt(n)),!p(e)&&Qt(s)&&!Qt(n))return!t&&(s.value=n,!0)}const r=p(e)&&C(t)?Number(t)e,ht=e=>Reflect.getPrototypeOf(e);function ft(e,t,n=!1,o=!1){const s=Jt(e=e.__v_raw),r=Jt(t);n||(P(t,r)&&We(s,"get",t),We(s,"get",r));const{has:i}=ht(s),a=o?pt:n?Xt:Yt;return i.call(s,t)?a(e.get(t)):i.call(s,r)?a(e.get(r)):void(e!==s&&e.get(t))}function mt(e,t=!1){const n=this.__v_raw,o=Jt(n),s=Jt(e);return t||(P(e,s)&&We(o,"has",e),We(o,"has",s)),e===s?n.has(e):n.has(e)||n.has(s)}function gt(e,t=!1){return e=e.__v_raw,!t&&We(Jt(e),"iterate",Be),Reflect.get(e,"size",e)}function yt(e,t=!1){t||zt(e)||Wt(e)||(e=Jt(e));const n=Jt(this);return ht(n).has.call(n,e)||(n.add(e),ze(n,"add",e,e)),this}function vt(e,t,n=!1){n||zt(t)||Wt(t)||(t=Jt(t));const o=Jt(this),{has:s,get:r}=ht(o);let i=s.call(o,e);i?Mt(o,s,e):(e=Jt(e),i=s.call(o,e));const a=r.call(o,e);return o.set(e,t),i?P(t,a)&&ze(o,"set",e,t,a):ze(o,"add",e,t),this}function bt(e){const t=Jt(this),{has:n,get:o}=ht(t);let s=n.call(t,e);s?Mt(t,n,e):(e=Jt(e),s=n.call(t,e));const r=o?o.call(t,e):void 0,i=t.delete(e);return s&&ze(t,"delete",e,void 0,r),i}function St(){const e=Jt(this),t=0!==e.size,n=h(e)?new Map(e):new Set(e),o=e.clear();return t&&ze(e,"clear",void 0,void 0,n),o}function _t(e,t){return function(n,o){const s=this,r=s.__v_raw,i=Jt(r),a=t?pt:e?Xt:Yt;return!e&&We(i,"iterate",Be),r.forEach(((e,t)=>n.call(o,a(e),a(t),s)))}}function xt(e,t,n){return function(...o){const s=this.__v_raw,r=Jt(s),i=h(r),a="entries"===e||e===Symbol.iterator&&i,c="keys"===e&&i,l=s[e](...o),u=n?pt:t?Xt:Yt;return!t&&We(r,"iterate",c?He:Be),{next(){const{value:e,done:t}=l.next();return t?{value:e,done:t}:{value:a?[u(e[0]),u(e[1])]:u(e),done:t}},[Symbol.iterator](){return this}}}}function wt(e){return function(...t){{const n=t[0]?`on key "${t[0]}" `:"";ge(`${R(e)} operation ${n}failed: target is readonly.`,Jt(this))}return"delete"!==e&&("clear"===e?void 0:this)}}function kt(){const e={get(e){return ft(this,e)},get size(){return gt(this)},has:mt,add:yt,set:vt,delete:bt,clear:St,forEach:_t(!1,!1)},t={get(e){return ft(this,e,!1,!0)},get size(){return gt(this)},has:mt,add(e){return yt.call(this,e,!0)},set(e,t){return vt.call(this,e,t,!0)},delete:bt,clear:St,forEach:_t(!1,!0)},n={get(e){return ft(this,e,!0)},get size(){return gt(this,!0)},has(e){return mt.call(this,e,!0)},add:wt("add"),set:wt("set"),delete:wt("delete"),clear:wt("clear"),forEach:_t(!0,!1)},o={get(e){return ft(this,e,!0,!0)},get size(){return gt(this,!0)},has(e){return mt.call(this,e,!0)},add:wt("add"),set:wt("set"),delete:wt("delete"),clear:wt("clear"),forEach:_t(!0,!0)};return["keys","values","entries",Symbol.iterator].forEach((s=>{e[s]=xt(s,!1,!1),n[s]=xt(s,!0,!1),t[s]=xt(s,!1,!0),o[s]=xt(s,!0,!0)})),[e,n,t,o]}const[Ct,Tt,Et,At]=kt();function Nt(e,t){const n=t?e?At:Et:e?Tt:Ct;return(t,o,s)=>"__v_isReactive"===o?!e:"__v_isReadonly"===o?e:"__v_raw"===o?t:Reflect.get(d(n,o)&&o in t?n:t,o,s)}const It={get:Nt(!1,!1)},$t={get:Nt(!1,!0)},Ot={get:Nt(!0,!1)},Rt={get:Nt(!0,!0)};function Mt(e,t,n){const o=Jt(n);if(o!==n&&t.call(e,o)){const t=w(e);ge(`Reactive ${t} contains both the raw and reactive versions of the same object${"Map"===t?" as keys":""}, which can lead to inconsistencies. Avoid differentiating between the raw and reactive versions of an object and only use the reactive version if possible.`)}}const Pt=new WeakMap,Ft=new WeakMap,Dt=new WeakMap,Lt=new WeakMap;function Vt(e){return Wt(e)?e:Ht(e,!1,ct,It,Pt)}function jt(e){return Ht(e,!1,ut,$t,Ft)}function Ut(e){return Ht(e,!0,lt,Ot,Dt)}function Bt(e){return Ht(e,!0,dt,Rt,Lt)}function Ht(e,t,n,o,s){if(!b(e))return ge(`value cannot be made ${t?"readonly":"reactive"}: ${String(e)}`),e;if(e.__v_raw&&(!t||!e.__v_isReactive))return e;const r=s.get(e);if(r)return r;const i=(a=e).__v_skip||!Object.isExtensible(a)?0:function(e){switch(e){case"Object":case"Array":return 1;case"Map":case"Set":case"WeakMap":case"WeakSet":return 2;default:return 0}}(w(a));var a;if(0===i)return e;const c=new Proxy(e,2===i?o:n);return s.set(e,c),c}function qt(e){return Wt(e)?qt(e.__v_raw):!(!e||!e.__v_isReactive)}function Wt(e){return!(!e||!e.__v_isReadonly)}function zt(e){return!(!e||!e.__v_isShallow)}function Kt(e){return!!e&&!!e.__v_raw}function Jt(e){const t=e&&e.__v_raw;return t?Jt(t):e}function Gt(e){return Object.isExtensible(e)&&D(e,"__v_skip",!0),e}const Yt=e=>b(e)?Vt(e):e,Xt=e=>b(e)?Ut(e):e;function Qt(e){return!!e&&!0===e.__v_isRef}function Zt(e){return tn(e,!1)}function en(e){return tn(e,!0)}function tn(e,t){return Qt(e)?e:new nn(e,t)}class nn{constructor(e,t){this.dep=new Ve,this.__v_isRef=!0,this.__v_isShallow=!1,this._rawValue=t?e:Jt(e),this._value=t?e:Yt(e),this.__v_isShallow=t}get value(){return this.dep.track({target:this,type:"get",key:"value"}),this._value}set value(e){const t=this._rawValue,n=this.__v_isShallow||zt(e)||Wt(e);e=n?e:Jt(e),P(e,t)&&(this._rawValue=e,this._value=n?e:Yt(e),this.dep.trigger({target:this,type:"set",key:"value",newValue:e,oldValue:t}))}}function on(e){return Qt(e)?e.value:e}const sn={get:(e,t,n)=>"__v_raw"===t?e:on(Reflect.get(e,t,n)),set:(e,t,n,o)=>{const s=e[t];return Qt(s)&&!Qt(n)?(s.value=n,!0):Reflect.set(e,t,n,o)}};function rn(e){return qt(e)?e:new Proxy(e,sn)}class an{constructor(e){this.__v_isRef=!0,this._value=void 0;const t=this.dep=new Ve,{get:n,set:o}=e(t.track.bind(t),t.trigger.bind(t));this._get=n,this._set=o}get value(){return this._value=this._get()}set value(e){this._set(e)}}function cn(e){return new an(e)}class ln{constructor(e,t,n){this._object=e,this._key=t,this._defaultValue=n,this.__v_isRef=!0,this._value=void 0}get value(){const e=this._object[this._key];return this._value=void 0===e?this._defaultValue:e}set value(e){this._object[this._key]=e}get dep(){return e=Jt(this._object),t=this._key,null==(n=Ue.get(e))?void 0:n.get(t);var e,t,n}}class un{constructor(e){this._getter=e,this.__v_isRef=!0,this.__v_isReadonly=!0,this._value=void 0}get value(){return this._value=this._getter()}}function dn(e,t,n){const o=e[t];return Qt(o)?o:new ln(e,t,n)}class pn{constructor(e,t,n){this.fn=e,this.setter=t,this._value=void 0,this.dep=new Ve(this),this.__v_isRef=!0,this.deps=void 0,this.depsTail=void 0,this.flags=16,this.globalVersion=Le-1,this.effect=this,this.__v_isReadonly=!t,this.isSSR=n}notify(){ve!==this&&(this.flags|=16,this.dep.notify())}get value(){const e=this.dep.track({target:this,type:"get",key:"value"});return Ie(this),e&&(e.version=this.dep.version),this._value}set value(e){this.setter?this.setter(e):ge("Write operation failed: computed value is readonly")}}const hn={},fn=new WeakMap;let mn;function gn(e,t=!1,n=mn){if(n){let t=fn.get(n);t||fn.set(n,t=[]),t.push(e)}else t||ge("onWatcherCleanup() was called when there was no active watcher to associate with.")}function yn(e,t=1/0,n){if(t<=0||!b(e)||e.__v_skip)return e;if((n=n||new Set).has(e))return e;if(n.add(e),t--,Qt(e))yn(e.value,t,n);else if(p(e))for(let o=0;o{yn(e,t,n)}));else if(k(e)){for(const o in e)yn(e[o],t,n);for(const o of Object.getOwnPropertySymbols(e))Object.prototype.propertyIsEnumerable.call(e,o)&&yn(e[o],t,n)}return e}const vn=[];function bn(e){vn.push(e)}function Sn(){vn.pop()}let _n=!1;function xn(e,...t){if(_n)return;_n=!0,Pe();const n=vn.length?vn[vn.length-1].component:null,o=n&&n.appContext.config.warnHandler,s=function(){let e=vn[vn.length-1];if(!e)return[];const t=[];for(;e;){const n=t[0];n&&n.vnode===e?n.recurseCount++:t.push({vnode:e,recurseCount:0});const o=e.component&&e.component.parent;e=o&&o.vnode}return t}();if(o)En(o,n,11,[e+t.map((e=>{var t,n;return null!=(n=null==(t=e.toString)?void 0:t.call(e))?n:JSON.stringify(e)})).join(""),n&&n.proxy,s.map((({vnode:e})=>`at <${Sa(n,e.type)}>`)).join("\n"),s]);else{const n=[`[Vue warn]: ${e}`,...t];s.length&&n.push("\n",...function(e){const t=[];return e.forEach(((e,n)=>{t.push(...0===n?[]:["\n"],...function({vnode:e,recurseCount:t}){const n=t>0?`... (${t} recursive calls)`:"",o=!!e.component&&null==e.component.parent,s=` at <${Sa(e.component,e.type,o)}`,r=">"+n;return e.props?[s,...wn(e.props),r]:[s+r]}(e))})),t}(s)),console.warn(...n)}Fe(),_n=!1}function wn(e){const t=[],n=Object.keys(e);return n.slice(0,3).forEach((n=>{t.push(...kn(n,e[n]))})),n.length>3&&t.push(" ..."),t}function kn(e,t,n){return y(t)?(t=JSON.stringify(t),n?t:[`${e}=${t}`]):"number"==typeof t||"boolean"==typeof t||null==t?n?t:[`${e}=${t}`]:Qt(t)?(t=kn(e,Jt(t.value),!0),n?t:[`${e}=Ref<`,t,">"]):g(t)?[`${e}=fn${t.name?`<${t.name}>`:""}`]:(t=Jt(t),n?t:[`${e}=`,t])}function Cn(e,t){void 0!==e&&("number"!=typeof e?xn(`${t} is not a valid number - got ${JSON.stringify(e)}.`):isNaN(e)&&xn(`${t} is NaN - the duration expression might be incorrect.`))}const Tn={sp:"serverPrefetch hook",bc:"beforeCreate hook",c:"created hook",bm:"beforeMount hook",m:"mounted hook",bu:"beforeUpdate hook",u:"updated",bum:"beforeUnmount hook",um:"unmounted hook",a:"activated hook",da:"deactivated hook",ec:"errorCaptured hook",rtc:"renderTracked hook",rtg:"renderTriggered hook",0:"setup function",1:"render function",2:"watcher getter",3:"watcher callback",4:"watcher cleanup function",5:"native event handler",6:"component event handler",7:"vnode hook",8:"directive hook",9:"transition hook",10:"app errorHandler",11:"app warnHandler",12:"ref function",13:"async component loader",14:"scheduler flush",15:"component update",16:"app unmount cleanup function"};function En(e,t,n,o){try{return o?e(...o):e()}catch(e){Nn(e,t,n)}}function An(e,t,n,o){if(g(e)){const s=En(e,t,n,o);return s&&S(s)&&s.catch((e=>{Nn(e,t,n)})),s}if(p(e)){const s=[];for(let r=0;r=zn(n)?On.push(e):On.splice(function(e){let t=In?Rn+1:0,n=On.length;for(;t>>1,s=On[o],r=zn(s);rzn(e)-zn(t)));if(Mn.length=0,Pn)return void Pn.push(...t);for(Pn=t,e=e||new Map,Fn=0;Fnnull==e.id?2&e.flags?-1:1/0:e.id;function Kn(e){$n=!1,In=!0,e=e||new Map;const t=t=>Jn(e,t);try{for(Rn=0;RnVn){const e=t.i,n=e&&ba(e.type);return Nn(`Maximum recursive updates exceeded${n?` in component <${n}>`:""}. This means you have a reactive effect that is mutating its own dependencies and thus recursively triggering itself. Possible sources include component template, render function, updated hook or watcher source function.`,null,10),!0}e.set(t,n+1)}else e.set(t,1)}let Gn=!1;const Yn=new Map;U().__VUE_HMR_RUNTIME__={createRecord:to(Qn),rerender:to((function(e,t){const n=Xn.get(e);if(!n)return;n.initialDef.render=t,[...n.instances].forEach((e=>{t&&(e.render=t,Zn(e.type).render=t),e.renderCache=[],Gn=!0,e.update(),Gn=!1}))})),reload:to((function(e,t){const n=Xn.get(e);if(!n)return;t=Zn(t),eo(n.initialDef,t);const o=[...n.instances];for(let e=0;e{s.parent.update(),i.delete(s)})):s.appContext.reload?s.appContext.reload():"undefined"!=typeof window?window.location.reload():console.warn("[HMR] Root or manually mounted instance modified. Full reload required."),s.root.ce&&s!==s.root&&s.root.ce._removeChildStyle(r)}Hn((()=>{Yn.clear()}))}))};const Xn=new Map;function Qn(e,t){return!Xn.has(e)&&(Xn.set(e,{initialDef:Zn(t),instances:new Set}),!0)}function Zn(e){return _a(e)?e.__vccOpts:e}function eo(e,t){c(e,t);for(const n in e)"__file"===n||n in t||delete e[n]}function to(e){return(t,n)=>{try{return e(t,n)}catch(e){console.error(e),console.warn("[HMR] Something went wrong during Vue component hot-reload. Full reload required.")}}}let no,oo=[],so=!1;function ro(e,...t){no?no.emit(e,...t):so||oo.push({event:e,args:t})}function io(e,t){var n,o;if(no=e,no)no.enabled=!0,oo.forEach((({event:e,args:t})=>no.emit(e,...t))),oo=[];else if("undefined"!=typeof window&&window.HTMLElement&&!(null==(o=null==(n=window.navigator)?void 0:n.userAgent)?void 0:o.includes("jsdom"))){(t.__VUE_DEVTOOLS_HOOK_REPLAY__=t.__VUE_DEVTOOLS_HOOK_REPLAY__||[]).push((e=>{io(e,t)})),setTimeout((()=>{no||(t.__VUE_DEVTOOLS_HOOK_REPLAY__=null,so=!0,oo=[])}),3e3)}else so=!0,oo=[]}const ao=po("component:added"),co=po("component:updated"),lo=po("component:removed"),uo=e=>{no&&"function"==typeof no.cleanupBuffer&&!no.cleanupBuffer(e)&&lo(e)}; +/*! #__NO_SIDE_EFFECTS__ */ +function po(e){return t=>{ro(e,t.appContext.app,t.uid,t.parent?t.parent.uid:void 0,t)}}const ho=mo("perf:start"),fo=mo("perf:end");function mo(e){return(t,n,o)=>{ro(e,t.appContext.app,t.uid,t,n,o)}}let go=null,yo=null;function vo(e){const t=go;return go=e,yo=e&&e.type.__scopeId||null,t}function bo(e,t=go,n){if(!t)return e;if(e._n)return e;const o=(...n)=>{o._d&&Oi(-1);const s=vo(t);let r;try{r=e(...n)}finally{vo(s),o._d&&Oi(1)}return co(t),r};return o._n=!0,o._c=!0,o._d=!0,o}function So(e){E(e)&&xn("Do not use built-in directive ids as custom directive id: "+e)}function _o(e,t,n,o){const s=e.dirs,r=t&&t.dirs;for(let i=0;ie.__isTeleport,ko=e=>e&&(e.disabled||""===e.disabled),Co=e=>"undefined"!=typeof SVGElement&&e instanceof SVGElement,To=e=>"function"==typeof MathMLElement&&e instanceof MathMLElement,Eo=(e,t)=>{const n=e&&e.to;if(y(n)){if(t){const o=t(n);return o||ko(e)||xn(`Failed to locate Teleport target with selector "${n}". Note the target element must exist before the component is mounted - i.e. the target cannot be rendered by the component itself, and ideally should be outside of the entire Vue component tree.`),o}return xn("Current renderer does not support string target for Teleports. (missing querySelector renderer option)"),null}return n||ko(e)||xn(`Invalid Teleport target: ${n}`),n};function Ao(e,t,n,{o:{insert:o},m:s},r=2){0===r&&o(e.targetAnchor,t,n);const{el:i,anchor:a,shapeFlag:c,children:l,props:u}=e,d=2===r;if(d&&o(i,t,n),(!d||ko(u))&&16&c)for(let e=0;e{16&v&&u(b,e,t,s,r,i,a,c)},p=()=>{const e=t.target=Eo(t.props,f),n=$o(e,t,m,h);e?("svg"!==i&&Co(e)?i="svg":"mathml"!==i&&To(e)&&(i="mathml"),y||(d(e,n),Io(t))):y||xn("Invalid Teleport target on mount:",e,`(${typeof e})`)};y&&(d(n,l),Io(t)),(_=t.props)&&(_.defer||""===_.defer)?Fr(p,r):p()}else{t.el=e.el,t.targetStart=e.targetStart;const o=t.anchor=e.anchor,u=t.target=e.target,h=t.targetAnchor=e.targetAnchor,m=ko(e.props),g=m?n:u,v=m?o:h;if("svg"===i||Co(u)?i="svg":("mathml"===i||To(u))&&(i="mathml"),S?(p(e.dynamicChildren,S,g,s,r,i,a),Hr(e,t,!0)):c||d(e,t,g,v,s,r,i,a,!1),y)m?t.props&&e.props&&t.props.to!==e.props.to&&(t.props.to=e.props.to):Ao(t,n,o,l,1);else if((t.props&&t.props.to)!==(e.props&&e.props.to)){const e=t.target=Eo(t.props,f);e?Ao(t,e,null,l,0):xn("Invalid Teleport target on update:",u,`(${typeof u})`)}else m&&Ao(t,u,h,l,1);Io(t)}var _},remove(e,t,n,{um:o,o:{remove:s}},r){const{shapeFlag:i,children:a,anchor:c,targetStart:l,targetAnchor:u,target:d,props:p}=e;if(d&&(s(l),s(u)),r&&s(c),16&i){const e=r||!ko(p);for(let s=0;s{e.isMounted=!0})),Cs((()=>{e.isUnmounting=!0})),e}const Po=[Function,Array],Fo={mode:String,appear:Boolean,persisted:Boolean,onBeforeEnter:Po,onEnter:Po,onAfterEnter:Po,onEnterCancelled:Po,onBeforeLeave:Po,onLeave:Po,onAfterLeave:Po,onLeaveCancelled:Po,onBeforeAppear:Po,onAppear:Po,onAfterAppear:Po,onAppearCancelled:Po},Do=e=>{const t=e.subTree;return t.component?Do(t.component):t};function Lo(e){let t=e[0];if(e.length>1){let n=!1;for(const o of e)if(o.type!==ki){if(n){xn(" can only be used on a single element or component. Use for lists.");break}t=o,n=!0}}return t}const Vo={name:"BaseTransition",props:Fo,setup(e,{slots:t}){const n=Zi(),o=Mo();return()=>{const s=t.default&&Wo(t.default(),!0);if(!s||!s.length)return;const r=Lo(s),i=Jt(e),{mode:a}=i;if(a&&"in-out"!==a&&"out-in"!==a&&"default"!==a&&xn(`invalid mode: ${a}`),o.isLeaving)return Bo(r);const c=Ho(r);if(!c)return Bo(r);let l=Uo(c,i,o,n,(e=>l=e));c.type!==ki&&qo(c,l);const u=n.subTree,d=u&&Ho(u);if(d&&d.type!==ki&&!Fi(c,d)&&Do(n).type!==ki){const e=Uo(d,i,o,n);if(qo(d,e),"out-in"===a&&c.type!==ki)return o.isLeaving=!0,e.afterLeave=()=>{o.isLeaving=!1,8&n.job.flags||n.update(),delete e.afterLeave},Bo(r);"in-out"===a&&c.type!==ki&&(e.delayLeave=(e,t,n)=>{jo(o,d)[String(d.key)]=d,e[Oo]=()=>{t(),e[Oo]=void 0,delete l.delayedLeave},l.delayedLeave=n})}return r}}};function jo(e,t){const{leavingVNodes:n}=e;let o=n.get(t.type);return o||(o=Object.create(null),n.set(t.type,o)),o}function Uo(e,t,n,o,s){const{appear:r,mode:i,persisted:a=!1,onBeforeEnter:c,onEnter:l,onAfterEnter:u,onEnterCancelled:d,onBeforeLeave:h,onLeave:f,onAfterLeave:m,onLeaveCancelled:g,onBeforeAppear:y,onAppear:v,onAfterAppear:b,onAppearCancelled:S}=t,_=String(e.key),x=jo(n,e),w=(e,t)=>{e&&An(e,o,9,t)},k=(e,t)=>{const n=t[1];w(e,t),p(e)?e.every((e=>e.length<=1))&&n():e.length<=1&&n()},C={mode:i,persisted:a,beforeEnter(t){let o=c;if(!n.isMounted){if(!r)return;o=y||c}t[Oo]&&t[Oo](!0);const s=x[_];s&&Fi(e,s)&&s.el[Oo]&&s.el[Oo](),w(o,[t])},enter(e){let t=l,o=u,s=d;if(!n.isMounted){if(!r)return;t=v||l,o=b||u,s=S||d}let i=!1;const a=e[Ro]=t=>{i||(i=!0,w(t?s:o,[e]),C.delayedLeave&&C.delayedLeave(),e[Ro]=void 0)};t?k(t,[e,a]):a()},leave(t,o){const s=String(e.key);if(t[Ro]&&t[Ro](!0),n.isUnmounting)return o();w(h,[t]);let r=!1;const i=t[Oo]=n=>{r||(r=!0,o(),w(n?g:m,[t]),t[Oo]=void 0,x[s]===e&&delete x[s])};x[s]=e,f?k(f,[t,i]):i()},clone(e){const r=Uo(e,t,n,o,s);return s&&s(r),r}};return C}function Bo(e){if(us(e))return(e=Bi(e)).children=null,e}function Ho(e){if(!us(e))return wo(e.type)&&e.children?Lo(e.children):e;if(e.component)return e.component.subTree;const{shapeFlag:t,children:n}=e;if(n){if(16&t)return n[0];if(32&t&&g(n.default))return n.default()}}function qo(e,t){6&e.shapeFlag&&e.component?(e.transition=t,qo(e.component.subTree,t)):128&e.shapeFlag?(e.ssContent.transition=t.clone(e.ssContent),e.ssFallback.transition=t.clone(e.ssFallback)):e.transition=t}function Wo(e,t=!1,n){let o=[],s=0;for(let r=0;r1)for(let e=0;ec({name:e.name},t,{setup:e}))():e}function Ko(e){e.ids=[e.ids[0]+e.ids[2]+++"-",0,0]}const Jo=new WeakSet;function Go(e,t,o,s,r=!1){if(p(e))return void e.forEach(((e,n)=>Go(e,t&&(p(t)?t[n]:t),o,s,r)));if(cs(s)&&!r)return;const i=4&s.shapeFlag?ga(s.component):s.el,a=r?null:i,{i:c,r:u}=e;if(!c)return void xn("Missing ref owner context. ref cannot be used on hoisted vnodes. A vnode with ref must be created inside the render function.");const h=t&&t.r,f=c.refs===n?c.refs={}:c.refs,m=c.setupState,v=Jt(m),b=m===n?()=>!1:e=>!Jo.has(v[e])&&d(v,e);if(null!=h&&h!==u&&(y(h)?(f[h]=null,b(h)&&(m[h]=null)):Qt(h)&&(h.value=null)),g(u))En(u,c,12,[a,f]);else{const t=y(u),n=Qt(u);if(t||n){const s=()=>{if(e.f){const n=t?b(u)?m[u]:f[u]:u.value;r?p(n)&&l(n,i):p(n)?n.includes(i)||n.push(i):t?(f[u]=[i],b(u)&&(m[u]=f[u])):(u.value=[i],e.k&&(f[e.k]=u.value))}else t?(f[u]=a,b(u)&&(m[u]=a)):n?(u.value=a,e.k&&(f[e.k]=a)):xn("Invalid template ref type:",u,`(${typeof u})`)};a?(s.id=-1,Fr(s,o)):s()}else xn("Invalid template ref type:",u,`(${typeof u})`)}}let Yo=!1;const Xo=()=>{Yo||(console.error("Hydration completed but contains mismatches."),Yo=!0)},Qo=e=>{if(1===e.nodeType)return(e=>e.namespaceURI.includes("svg")&&"foreignObject"!==e.tagName)(e)?"svg":(e=>e.namespaceURI.includes("MathML"))(e)?"mathml":void 0},Zo=e=>8===e.nodeType;function es(e){const{mt:t,p:n,o:{patchProp:o,createText:s,nextSibling:r,parentNode:a,remove:c,insert:l,createComment:u}}=e,d=(n,o,i,c,u,b=!1)=>{b=b||!!o.dynamicChildren;const S=Zo(n)&&"["===n.data,_=()=>m(n,o,i,c,u,S),{type:x,ref:w,shapeFlag:k,patchFlag:C}=o;let T=n.nodeType;o.el=n,D(n,"__vnode",o,!0),D(n,"__vueParentComponent",i,!0),-2===C&&(b=!1,o.dynamicChildren=null);let E=null;switch(x){case wi:3!==T?""===o.children?(l(o.el=s(""),a(n),n),E=n):E=_():(n.data!==o.children&&(xn("Hydration text mismatch in",n.parentNode,`\n - rendered on server: ${JSON.stringify(n.data)}\n - expected on client: ${JSON.stringify(o.children)}`),Xo(),n.data=o.children),E=r(n));break;case ki:v(n)?(E=r(n),y(o.el=n.content.firstChild,n,i)):E=8!==T||S?_():r(n);break;case Ci:if(S&&(T=(n=r(n)).nodeType),1===T||3===T){E=n;const e=!o.children.length;for(let t=0;t{a=a||!!t.dynamicChildren;const{type:l,props:u,patchFlag:d,shapeFlag:p,dirs:f,transition:m}=t,g="input"===l||"option"===l;{f&&_o(t,null,n,"created");let l,d=!1;if(v(e)){d=Br(s,m)&&n&&n.vnode.props&&n.vnode.props.appear;const o=e.content.firstChild;d&&m.beforeEnter(o),y(o,e,n),t.el=e=o}if(16&p&&(!u||!u.innerHTML&&!u.textContent)){let o=h(e.firstChild,t,e,n,s,r,a),i=!1;for(;o;){as(e,1)||(i||(xn("Hydration children mismatch on",e,"\nServer rendered element contains more child nodes than client vdom."),i=!0),Xo());const t=o;o=o.nextSibling,c(t)}}else 8&p&&e.textContent!==t.children&&(as(e,0)||(xn("Hydration text content mismatch on",e,`\n - rendered on server: ${e.textContent}\n - expected on client: ${t.children}`),Xo()),e.textContent=t.children);if(u){const s=e.tagName.includes("-");for(const r in u)f&&f.some((e=>e.dir.created))||!ts(e,r,u[r],t,n)||Xo(),(g&&(r.endsWith("value")||"indeterminate"===r)||i(r)&&!T(r)||"."===r[0]||s)&&o(e,r,null,u[r],void 0,n)}(l=u&&u.onVnodeBeforeMount)&&Gi(l,n,t),f&&_o(t,null,n,"beforeMount"),((l=u&&u.onVnodeMounted)||f||d)&&Si((()=>{l&&Gi(l,n,t),d&&m.enter(e),f&&_o(t,null,n,"mounted")}),s)}return e.nextSibling},h=(e,t,o,i,a,c,u)=>{u=u||!!t.dynamicChildren;const p=t.children,h=p.length;let f=!1;for(let t=0;t{const{slotScopeIds:c}=t;c&&(s=s?s.concat(c):c);const d=a(e),p=h(r(e),t,d,n,o,s,i);return p&&Zo(p)&&"]"===p.data?r(t.anchor=p):(Xo(),l(t.anchor=u("]"),d,p),p)},m=(e,t,o,s,i,l)=>{if(as(e.parentElement,1)||(xn("Hydration node mismatch:\n- rendered on server:",e,3===e.nodeType?"(text)":Zo(e)&&"["===e.data?"(start of fragment)":"","\n- expected on client:",t.type),Xo()),t.el=null,l){const t=g(e);for(;;){const n=r(e);if(!n||n===t)break;c(n)}}const u=r(e),d=a(e);return c(e),n(null,t,d,u,o,s,Qo(d),i),u},g=(e,t="[",n="]")=>{let o=0;for(;e;)if((e=r(e))&&Zo(e)&&(e.data===t&&o++,e.data===n)){if(0===o)return r(e);o--}return e},y=(e,t,n)=>{const o=t.parentNode;o&&o.replaceChild(e,t);let s=n;for(;s;)s.vnode.el===t&&(s.vnode.el=s.subTree.el=e),s=s.parent},v=e=>1===e.nodeType&&"template"===e.tagName.toLowerCase();return[(e,t)=>{if(!t.hasChildNodes())return xn("Attempting to hydrate existing markup but container is empty. Performing full mount instead."),n(null,e,t),Wn(),void(t._vnode=e);d(t.firstChild,e,null,null,null),Wn(),t._vnode=e},d]}function ts(e,t,n,o,s){let r,i,a,c;if("class"===t)a=e.getAttribute("class"),c=X(n),function(e,t){if(e.size!==t.size)return!1;for(const n of e)if(!t.has(n))return!1;return!0}(ns(a||""),ns(c))||(r=2,i="class");else if("style"===t){a=e.getAttribute("style")||"",c=y(n)?n:function(e){let t="";if(!e||y(e))return t;for(const n in e){const o=e[n];(y(o)||"number"==typeof o)&&(t+=`${n.startsWith("--")?n:O(n)}:${o};`)}return t}(z(n));const t=os(a),l=os(c);if(o.dirs)for(const{dir:e,value:t}of o.dirs)"show"!==e.name||t||l.set("display","none");s&&ss(s,o,l),function(e,t){if(e.size!==t.size)return!1;for(const[n,o]of e)if(o!==t.get(n))return!1;return!0}(t,l)||(r=3,i="style")}else(e instanceof SVGElement&&ae(t)||e instanceof HTMLElement&&(se(t)||ie(t)))&&(se(t)?(a=e.hasAttribute(t),c=re(n)):null==n?(a=e.hasAttribute(t),c=!1):(a=e.hasAttribute(t)?e.getAttribute(t):"value"===t&&"TEXTAREA"===e.tagName&&e.value,c=!!function(e){if(null==e)return!1;const t=typeof e;return"string"===t||"number"===t||"boolean"===t}(n)&&String(n)),a!==c&&(r=4,i=t));if(null!=r&&!as(e,r)){const t=e=>!1===e?"(not rendered)":`${i}="${e}"`;return xn(`Hydration ${is[r]} mismatch on`,e,`\n - rendered on server: ${t(a)}\n - expected on client: ${t(c)}\n Note: this mismatch is check-only. The DOM will not be rectified in production due to performance overhead.\n You should fix the source of the mismatch.`),!0}return!1}function ns(e){return new Set(e.trim().split(/\s+/))}function os(e){const t=new Map;for(const n of e.split(";")){let[e,o]=n.split(":");e=e.trim(),o=o&&o.trim(),e&&o&&t.set(e,o)}return t}function ss(e,t,n){const o=e.subTree;if(e.getCssVars&&(t===o||o&&o.type===xi&&o.children.includes(t))){const t=e.getCssVars();for(const e in t)n.set(`--${le(e)}`,String(t[e]))}t===o&&e.parent&&ss(e.parent,e.vnode,n)}const rs="data-allow-mismatch",is={0:"text",1:"children",2:"class",3:"style",4:"attribute"};function as(e,t){if(0===t||1===t)for(;e&&!e.hasAttribute(rs);)e=e.parentElement;const n=e&&e.getAttribute(rs);if(null==n)return!1;if(""===n)return!0;{const e=n.split(",");return!(0!==t||!e.includes("children"))||n.split(",").includes(is[t])}}const cs=e=>!!e.type.__asyncLoader +/*! #__NO_SIDE_EFFECTS__ */;function ls(e,t){const{ref:n,props:o,children:s,ce:r}=t.vnode,i=ji(e,o,s);return i.ref=n,i.ce=r,delete t.vnode.ce,i}const us=e=>e.type.__isKeepAlive,ds={name:"KeepAlive",__isKeepAlive:!0,props:{include:[String,RegExp,Array],exclude:[String,RegExp,Array],max:[String,Number]},setup(e,{slots:t}){const n=Zi(),o=n.ctx,s=new Map,r=new Set;let i=null;n.__v_cache=s;const a=n.suspense,{renderer:{p:c,m:l,um:u,o:{createElement:d}}}=o,p=d("div");function h(e){ys(e),u(e,n,a,!0)}function f(e){s.forEach(((t,n)=>{const o=ba(t.type);o&&!e(o)&&m(n)}))}function m(e){const t=s.get(e);!t||i&&Fi(t,i)?i&&ys(i):h(t),s.delete(e),r.delete(e)}o.activate=(e,t,n,o,s)=>{const r=e.component;l(e,t,n,0,a),c(r.vnode,e,t,n,r,a,o,e.slotScopeIds,s),Fr((()=>{r.isDeactivated=!1,r.a&&F(r.a);const t=e.props&&e.props.onVnodeMounted;t&&Gi(t,r.parent,e)}),a),ao(r)},o.deactivate=e=>{const t=e.component;Wr(t.m),Wr(t.a),l(e,p,null,1,a),Fr((()=>{t.da&&F(t.da);const n=e.props&&e.props.onVnodeUnmounted;n&&Gi(n,t.parent,e),t.isDeactivated=!0}),a),ao(t)},Gr((()=>[e.include,e.exclude]),(([e,t])=>{e&&f((t=>ps(e,t))),t&&f((e=>!ps(t,e)))}),{flush:"post",deep:!0});let g=null;const y=()=>{null!=g&&(hi(n.subTree.type)?Fr((()=>{s.set(g,vs(n.subTree))}),n.subTree.suspense):s.set(g,vs(n.subTree)))};return xs(y),ks(y),Cs((()=>{s.forEach((e=>{const{subTree:t,suspense:o}=n,s=vs(t);if(e.type!==s.type||e.key!==s.key)h(e);else{ys(s);const e=s.component.da;e&&Fr(e,o)}}))})),()=>{if(g=null,!t.default)return i=null;const n=t.default(),o=n[0];if(n.length>1)return xn("KeepAlive should contain exactly one component child."),i=null,n;if(!(Pi(o)&&(4&o.shapeFlag||128&o.shapeFlag)))return i=null,o;let a=vs(o);if(a.type===ki)return i=null,a;const c=a.type,l=ba(cs(a)?a.type.__asyncResolved||{}:c),{include:u,exclude:d,max:p}=e;if(u&&(!l||!ps(u,l))||d&&l&&ps(d,l))return a.shapeFlag&=-257,i=a,o;const h=null==a.key?c:a.key,f=s.get(h);return a.el&&(a=Bi(a),128&o.shapeFlag&&(o.ssContent=a)),g=h,f?(a.el=f.el,a.component=f.component,a.transition&&qo(a,a.transition),a.shapeFlag|=512,r.delete(h),r.add(h)):(r.add(h),p&&r.size>parseInt(p,10)&&m(r.values().next().value)),a.shapeFlag|=256,i=a,hi(o.type)?o:a}}};function ps(e,t){return p(e)?e.some((e=>ps(e,t))):y(e)?e.split(",").includes(t):"[object RegExp]"===x(e)&&(e.lastIndex=0,e.test(t))}function hs(e,t){ms(e,"a",t)}function fs(e,t){ms(e,"da",t)}function ms(e,t,n=Qi){const o=e.__wdc||(e.__wdc=()=>{let t=n;for(;t;){if(t.isDeactivated)return;t=t.parent}return e()});if(bs(t,o,n),n){let e=n.parent;for(;e&&e.parent;)us(e.parent.vnode)&&gs(o,t,n,e),e=e.parent}}function gs(e,t,n,o){const s=bs(t,e,o,!0);Ts((()=>{l(o[t],s)}),n)}function ys(e){e.shapeFlag&=-257,e.shapeFlag&=-513}function vs(e){return 128&e.shapeFlag?e.ssContent:e}function bs(e,t,n=Qi,o=!1){if(n){const s=n[e]||(n[e]=[]),r=t.__weh||(t.__weh=(...o)=>{Pe();const s=na(n),r=An(t,n,e,o);return s(),Fe(),r});return o?s.unshift(r):s.push(r),r}xn(`${M(Tn[e].replace(/ hook$/,""))} is called when there is no active component instance to be associated with. Lifecycle injection APIs can only be used during execution of setup(). If you are using async setup(), make sure to register lifecycle hooks before the first await statement.`)}const Ss=e=>(t,n=Qi)=>{la&&"sp"!==e||bs(e,((...e)=>t(...e)),n)},_s=Ss("bm"),xs=Ss("m"),ws=Ss("bu"),ks=Ss("u"),Cs=Ss("bum"),Ts=Ss("um"),Es=Ss("sp"),As=Ss("rtg"),Ns=Ss("rtc");function Is(e,t=Qi){bs("ec",e,t)}const $s="components";const Os=Symbol.for("v-ndc");function Rs(e,t,n=!0,o=!1){const s=go||Qi;if(s){const r=s.type;if(e===$s){const e=ba(r,!1);if(e&&(e===t||e===I(t)||e===R(I(t))))return r}const i=Ms(s[e]||r[e],t)||Ms(s.appContext[e],t);if(!i&&o)return r;if(n&&!i){const n=e===$s?"\nIf this is a native custom element, make sure to exclude it from component resolution via compilerOptions.isCustomElement.":"";xn(`Failed to resolve ${e.slice(0,-1)}: ${t}${n}`)}return i}xn(`resolve${R(e.slice(0,-1))} can only be used in render() or setup().`)}function Ms(e,t){return e&&(e[t]||e[I(t)]||e[R(I(t))])}function Ps(e){return e.some((e=>!Pi(e)||e.type!==ki&&!(e.type===xi&&!Ps(e.children))))?e:null}const Fs=e=>e?ia(e)?ga(e):Fs(e.parent):null,Ds=c(Object.create(null),{$:e=>e,$el:e=>e.vnode.el,$data:e=>e.data,$props:e=>Bt(e.props),$attrs:e=>Bt(e.attrs),$slots:e=>Bt(e.slots),$refs:e=>Bt(e.refs),$parent:e=>Fs(e.parent),$root:e=>Fs(e.root),$host:e=>e.ce,$emit:e=>e.emit,$options:e=>Gs(e),$forceUpdate:e=>e.f||(e.f=()=>{Un(e.update)}),$nextTick:e=>e.n||(e.n=jn.bind(e.proxy)),$watch:e=>Xr.bind(e)}),Ls=e=>"_"===e||"$"===e,Vs=(e,t)=>e!==n&&!e.__isScriptSetup&&d(e,t),js={get({_:e},t){if("__v_skip"===t)return!0;const{ctx:o,setupState:s,data:r,props:i,accessCache:a,type:c,appContext:l}=e;if("__isVue"===t)return!0;let u;if("$"!==t[0]){const c=a[t];if(void 0!==c)switch(c){case 1:return s[t];case 2:return r[t];case 4:return o[t];case 3:return i[t]}else{if(Vs(s,t))return a[t]=1,s[t];if(r!==n&&d(r,t))return a[t]=2,r[t];if((u=e.propsOptions[0])&&d(u,t))return a[t]=3,i[t];if(o!==n&&d(o,t))return a[t]=4,o[t];Ws&&(a[t]=0)}}const p=Ds[t];let h,f;return p?("$attrs"===t?(We(e.attrs,"get",""),si()):"$slots"===t&&We(e,"get",t),p(e)):(h=c.__cssModules)&&(h=h[t])?h:o!==n&&d(o,t)?(a[t]=4,o[t]):(f=l.config.globalProperties,d(f,t)?f[t]:void(!go||y(t)&&0===t.indexOf("__v")||(r!==n&&Ls(t[0])&&d(r,t)?xn(`Property ${JSON.stringify(t)} must be accessed via $data because it starts with a reserved character ("$" or "_") and is not proxied on the render context.`):e===go&&xn(`Property ${JSON.stringify(t)} was accessed during render but is not defined on instance.`))))},set({_:e},t,o){const{data:s,setupState:r,ctx:i}=e;return Vs(r,t)?(r[t]=o,!0):r.__isScriptSetup&&d(r,t)?(xn(`Cannot mutate