diff --git a/.gitattributes b/.gitattributes index 6ca01898..b800f758 100644 --- a/.gitattributes +++ b/.gitattributes @@ -4,10 +4,12 @@ * text eol=lf # Ignore all test and documentation with "export-ignore". +/.github export-ignore /.gitattributes export-ignore /.gitignore export-ignore /.travis.yml export-ignore /phpunit.xml.dist export-ignore +/.php_cs.dist.php export-ignore /.scrutinizer.yml export-ignore /tests export-ignore /.editorconfig export-ignore diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml index c09678fd..531772b8 100644 --- a/.github/workflows/dependabot-auto-merge.yml +++ b/.github/workflows/dependabot-auto-merge.yml @@ -13,7 +13,7 @@ jobs: - name: Dependabot metadata id: metadata - uses: dependabot/fetch-metadata@v2.1.0 + uses: dependabot/fetch-metadata@v2.4.0 with: github-token: "${{ secrets.GITHUB_TOKEN }}" compat-lookup: true diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 0039050c..2c782727 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -1,82 +1,73 @@ name: run-tests on: - push: - paths: - - '**.php' - - '.github/workflows/run-tests.yml' - - 'phpunit.xml.dist' - - 'composer.json' - - 'composer.lock' + push jobs: - test: - runs-on: ${{ matrix.os }} - timeout-minutes: 5 - strategy: - fail-fast: true - matrix: - os: [ubuntu-latest] - php: [8.3, 8.2] - laravel: [10.*, 11.*] - stability: [prefer-stable] - include: - - laravel: 11.* - testbench: 9.* - carbon: ^2.63 - - laravel: 10.* - testbench: 8.* + test: + runs-on: ${{ matrix.os }} - name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.stability }} - ${{ matrix.os }} + timeout-minutes: 5 - services: - mysql: - image: mysql:8.0 - env: - MYSQL_USER: user - MYSQL_PASSWORD: secret - MYSQL_DATABASE: laravel_query_builder - MYSQL_ROOT_PASSWORD: secretroot - ports: - - 3306 - options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 + strategy: + fail-fast: true + matrix: + os: [ubuntu-latest] + php: [8.4, 8.3, 8.2] + laravel: ['10.*', '11.*', '12.*'] + stability: [prefer-stable] + include: + - laravel: 11.* + testbench: 9.* + - laravel: 10.* + testbench: 8.* + - laravel: 12.* + testbench: 10.* - redis: - image: redis - ports: - - 6379:6379 - options: >- - --health-cmd "redis-cli ping" - --health-interval 10s - --health-timeout 5s - --health-retries 5 + name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.stability }} - ${{ matrix.os }} - steps: - - name: Checkout code - uses: actions/checkout@v4 + services: + mysql: + image: mysql:8.0 + env: + MYSQL_USER: user + MYSQL_PASSWORD: secret + MYSQL_DATABASE: laravel_query_builder + MYSQL_ROOT_PASSWORD: secretroot + ports: + - 3306 + options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 + redis: + image: redis + ports: + - 6379:6379 + options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5 - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php }} - extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo - coverage: none + steps: + - name: Checkout code + uses: actions/checkout@v4 - - name: Setup problem matchers - run: | - echo "::add-matcher::${{ runner.tool_cache }}/php.json" - echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo + coverage: none - - name: Install dependencies - run: | - composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update - composer update --${{ matrix.stability }} --prefer-dist --no-interaction + - name: Setup problem matchers + run: | + echo "::add-matcher::${{ runner.tool_cache }}/php.json" + echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" - - name: Execute tests - run: vendor/bin/pest - env: - DB_USERNAME: user - DB_PASSWORD: secret - DB_PORT: ${{ job.services.mysql.ports[3306] }} - REDIS_PORT: 6379 + - name: Install dependencies + run: | + composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update + composer update --${{ matrix.stability }} --prefer-dist --no-interaction + - name: Execute tests + run: vendor/bin/pest + env: + DB_USERNAME: user + DB_PASSWORD: secret + DB_PORT: ${{ job.services.mysql.ports[3306] }} + REDIS_PORT: 6379 diff --git a/.gitignore b/.gitignore index 7d3ee0ff..78fe5956 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,7 @@ composer.lock vendor .php_cs.cache coverage +.phpunit.cache .phpunit.result.cache /.idea .php-cs-fixer.cache - diff --git a/.phpunit.cache/test-results b/.phpunit.cache/test-results deleted file mode 100644 index 3f964581..00000000 --- a/.phpunit.cache/test-results +++ /dev/null @@ -1 +0,0 @@ -{"version":"pest_2.34.7","defects":{"P\\Tests\\FilterTest::__pest_evaluable_it_can_filter_models_by_an_array_as_filter_value":7,"P\\Tests\\FilterTest::__pest_evaluable_it_can_filter_results_based_on_the_partial_existence_of_a_property_in_an_array":7,"P\\Tests\\FilterTest::__pest_evaluable_it_ignores_empty_values_in_an_array_partial_filter":7,"P\\Tests\\RelationFilterTest::__pest_evaluable_it_can_filter_results_based_on_the_partial_existence_of_a_property_in_an_array":7},"times":{"P\\Tests\\FieldsTest::__pest_evaluable_it_fetches_all_columns_if_no_field_was_requested":0.009,"P\\Tests\\FieldsTest::__pest_evaluable_it_fetches_all_columns_if_no_field_was_requested_but_allowed_fields_were_specified":0.002,"P\\Tests\\FieldsTest::__pest_evaluable_it_replaces_selected_array_columns_on_the_query":0.003,"P\\Tests\\FieldsTest::__pest_evaluable_it_replaces_selected_string_columns_on_the_query":0.002,"P\\Tests\\FieldsTest::__pest_evaluable_it_can_fetch_specific_array_columns":0.002,"P\\Tests\\FieldsTest::__pest_evaluable_it_can_fetch_specific_string_columns":0.002,"P\\Tests\\FieldsTest::__pest_evaluable_it_wont_fetch_a_specific_array_column_if_its_not_allowed":0.002,"P\\Tests\\FieldsTest::__pest_evaluable_it_wont_fetch_a_specific_string_column_if_its_not_allowed":0.002,"P\\Tests\\FieldsTest::__pest_evaluable_it_can_fetch_sketchy_array_columns_if_they_are_allowed_fields":0.002,"P\\Tests\\FieldsTest::__pest_evaluable_it_can_fetch_sketchy_string_columns_if_they_are_allowed_fields":0.002,"P\\Tests\\FieldsTest::__pest_evaluable_it_guards_against_not_allowed_array_fields":0.004,"P\\Tests\\FieldsTest::__pest_evaluable_it_guards_against_not_allowed_string_fields":0.002,"P\\Tests\\FieldsTest::__pest_evaluable_it_guards_against_not_allowed_array_fields_from_an_included_resource":0.002,"P\\Tests\\FieldsTest::__pest_evaluable_it_guards_against_not_allowed_string_fields_from_an_included_resource":0.002,"P\\Tests\\FieldsTest::__pest_evaluable_it_can_fetch_only_requested_array_columns_from_an_included_model":0.017,"P\\Tests\\FieldsTest::__pest_evaluable_it_can_fetch_only_requested_string_columns_from_an_included_model":0.004,"P\\Tests\\FieldsTest::__pest_evaluable_it_can_fetch_requested_array_columns_from_included_models_up_to_two_levels_deep":0.006,"P\\Tests\\FieldsTest::__pest_evaluable_it_can_fetch_requested_string_columns_from_included_models_up_to_two_levels_deep":0.003,"P\\Tests\\FieldsTest::__pest_evaluable_it_throws_an_exception_when_calling_allowed_includes_before_allowed_fields":0.002,"P\\Tests\\FieldsTest::__pest_evaluable_it_throws_an_exception_when_calling_allowed_includes_before_allowed_fields_but_with_requested_fields":0.002,"P\\Tests\\FieldsTest::__pest_evaluable_it_throws_an_exception_when_requesting_fields_for_an_allowed_included_without_any_allowed_fields":0.002,"P\\Tests\\FieldsTest::__pest_evaluable_it_can_allow_specific_fields_on_an_included_model":0.003,"P\\Tests\\FieldsTest::__pest_evaluable_it_wont_use_sketchy_field_requests":0.002,"P\\Tests\\FilterTest::__pest_evaluable_it_can_filter_models_by_partial_property_by_default":0.002,"P\\Tests\\FilterTest::__pest_evaluable_it_can_filter_models_by_an_array_as_filter_value":0.005,"P\\Tests\\FilterTest::__pest_evaluable_it_can_filter_partially_and_case_insensitive":0.002,"P\\Tests\\FilterTest::__pest_evaluable_it_can_filter_results_based_on_the_partial_existence_of_a_property_in_an_array":0.003,"P\\Tests\\FilterTest::__pest_evaluable_it_can_filter_models_and_return_an_empty_collection":0.002,"P\\Tests\\FilterTest::__pest_evaluable_it_can_filter_a_custom_base_query_with_select":0.002,"P\\Tests\\FilterTest::__pest_evaluable_it_specifies_escape_character_in_supported_databases#('sqlite')":0.003,"P\\Tests\\FilterTest::__pest_evaluable_it_specifies_escape_character_in_supported_databases#('mysql')":0.003,"P\\Tests\\FilterTest::__pest_evaluable_it_specifies_escape_character_in_supported_databases#('pgsql')":0.003,"P\\Tests\\FilterTest::__pest_evaluable_it_specifies_escape_character_in_supported_databases#('sqlsrv')":0.004,"P\\Tests\\FilterTest::__pest_evaluable_it_can_filter_results_based_on_the_existence_of_a_property_in_an_array":0.003,"P\\Tests\\FilterTest::__pest_evaluable_it_ignores_empty_values_in_an_array_partial_filter":0.005,"P\\Tests\\FilterTest::__pest_evaluable_it_ignores_an_empty_array_partial_filter":0.002,"P\\Tests\\FilterTest::__pest_evaluable_falsy_values_are_not_ignored_when_applying_a_partial_filter":0.003,"P\\Tests\\FilterTest::__pest_evaluable_falsy_values_are_not_ignored_when_applying_a_begins_with_strict_filter":0.002,"P\\Tests\\FilterTest::__pest_evaluable_falsy_values_are_not_ignored_when_applying_a_ends_with_strict_filter":0.002,"P\\Tests\\FilterTest::__pest_evaluable_it_can_filter_partial_using_begins_with_strict":0.004,"P\\Tests\\FilterTest::__pest_evaluable_it_can_filter_partial_using_ends_with_strict":0.003,"P\\Tests\\FilterTest::__pest_evaluable_it_can_filter_and_match_results_by_exact_property":0.003,"P\\Tests\\FilterTest::__pest_evaluable_it_can_filter_and_reject_results_by_exact_property":0.003,"P\\Tests\\FilterTest::__pest_evaluable_it_can_filter_results_by_scope":0.003,"P\\Tests\\FilterTest::__pest_evaluable_it_can_filter_results_by_nested_relation_scope":0.003,"P\\Tests\\FilterTest::__pest_evaluable_it_can_filter_results_by_type_hinted_scope":0.003,"P\\Tests\\FilterTest::__pest_evaluable_it_can_filter_results_by_regular_and_type_hinted_scope":0.003,"P\\Tests\\FilterTest::__pest_evaluable_it_can_filter_results_by_scope_with_multiple_parameters":0.005,"P\\Tests\\FilterTest::__pest_evaluable_it_can_filter_results_by_scope_with_multiple_parameters_in_an_associative_array":0.003,"P\\Tests\\FilterTest::__pest_evaluable_it_can_filter_results_by_a_custom_filter_class":0.002,"P\\Tests\\FilterTest::__pest_evaluable_it_can_allow_multiple_filters":0.003,"P\\Tests\\FilterTest::__pest_evaluable_it_can_allow_multiple_filters_as_an_array":0.003,"P\\Tests\\FilterTest::__pest_evaluable_it_can_filter_by_multiple_filters":0.003,"P\\Tests\\FilterTest::__pest_evaluable_it_guards_against_invalid_filters":0.002,"P\\Tests\\FilterTest::__pest_evaluable_it_does_not_throw_invalid_filter_exception_when_disable_in_config":0.002,"P\\Tests\\FilterTest::__pest_evaluable_it_can_create_a_custom_filter_with_an_instantiated_filter":0.003,"P\\Tests\\FilterTest::__pest_evaluable_an_invalid_filter_query_exception_contains_the_unknown_and_allowed_filters":0.003,"P\\Tests\\FilterTest::__pest_evaluable_it_allows_for_adding_ignorable_values":0.003,"P\\Tests\\FilterTest::__pest_evaluable_it_should_not_apply_a_filter_if_the_supplied_value_is_ignored":0.003,"P\\Tests\\FilterTest::__pest_evaluable_it_should_apply_the_filter_on_the_subset_of_allowed_values":0.007,"P\\Tests\\FilterTest::__pest_evaluable_it_should_apply_the_filter_on_the_subset_of_allowed_values_regardless_of_the_keys_order":0.005,"P\\Tests\\FilterTest::__pest_evaluable_it_can_take_an_argument_for_custom_column_name_resolution":0.008,"P\\Tests\\FilterTest::__pest_evaluable_it_sets_property_column_name_to_property_name_by_default":0.002,"P\\Tests\\FilterTest::__pest_evaluable_it_resolves_queries_using_property_column_name":0.003,"P\\Tests\\FilterTest::__pest_evaluable_it_can_filter_using_boolean_flags":0.003,"P\\Tests\\FilterTest::__pest_evaluable_it_should_apply_a_default_filter_value_if_nothing_in_request":0.003,"P\\Tests\\FilterTest::__pest_evaluable_it_does_not_apply_default_filter_when_filter_exists_and_default_is_set":0.003,"P\\Tests\\FilterTest::__pest_evaluable_it_should_apply_a_null_default_filter_value_if_nothing_in_request":0.003,"P\\Tests\\FilterTest::__pest_evaluable_it_does_not_apply_default_filter_when_filter_exists_and_default_null_is_set":0.006,"P\\Tests\\FilterTest::__pest_evaluable_it_should_apply_a_nullable_filter_when_filter_exists_and_is_null":0.005,"P\\Tests\\FilterTest::__pest_evaluable_it_should_apply_a_nullable_filter_when_filter_exists_and_is_set":0.013,"P\\Tests\\FilterTest::__pest_evaluable_it_should_filter_by_query_parameters_if_a_default_value_is_set_and_unset_afterwards":0.007,"P\\Tests\\FilterTest::__pest_evaluable_it_should_not_filter_at_all_if_a_default_value_is_set_and_unset_afterwards":0.004,"P\\Tests\\FilterTest::__pest_evaluable_it_should_apply_a_filter_with_a_multi_dimensional_array_value":0.005,"P\\Tests\\FilterTest::__pest_evaluable_it_can_override_the_array_value_delimiter_for_single_filters":0.005,"P\\Tests\\FiltersCallbackTest::__pest_evaluable_it_should_filter_by_closure":0.003,"P\\Tests\\FiltersCallbackTest::__pest_evaluable_it_should_filter_by_array_callback":0.018,"P\\Tests\\FiltersTrashedTest::__pest_evaluable_it_should_filter_not_trashed_by_default":0.002,"P\\Tests\\FiltersTrashedTest::__pest_evaluable_it_can_filter_only_trashed":0.002,"P\\Tests\\FiltersTrashedTest::__pest_evaluable_it_can_filter_only_trashed_by_scope_directly":0.002,"P\\Tests\\FiltersTrashedTest::__pest_evaluable_it_can_filter_with_trashed":0.002,"P\\Tests\\IncludeTest::__pest_evaluable_it_does_not_require_includes":0.003,"P\\Tests\\IncludeTest::__pest_evaluable_it_can_handle_empty_includes":0.002,"P\\Tests\\IncludeTest::__pest_evaluable_it_can_include_model_relations":0.003,"P\\Tests\\IncludeTest::__pest_evaluable_it_can_include_model_relations_by_alias":0.003,"P\\Tests\\IncludeTest::__pest_evaluable_it_can_include_an_includes_callback":0.004,"P\\Tests\\IncludeTest::__pest_evaluable_it_can_include_an_includes_count":0.003,"P\\Tests\\IncludeTest::__pest_evaluable_allowing_an_include_also_allows_the_include_count":0.003,"P\\Tests\\IncludeTest::__pest_evaluable_it_can_include_an_includes_exists":0.002,"P\\Tests\\IncludeTest::__pest_evaluable_allowing_an_include_also_allows_the_include_exists":0.002,"P\\Tests\\IncludeTest::__pest_evaluable_it_can_include_nested_model_relations":0.003,"P\\Tests\\IncludeTest::__pest_evaluable_it_can_include_nested_model_relations_by_alias":0.005,"P\\Tests\\IncludeTest::__pest_evaluable_it_can_include_model_relations_from_nested_model_relations":0.002,"P\\Tests\\IncludeTest::__pest_evaluable_allowing_a_nested_include_only_allows_the_include_count_for_the_first_level":0.003,"P\\Tests\\IncludeTest::__pest_evaluable_allowing_a_nested_include_only_allows_the_include_exists_for_the_first_level":0.003,"P\\Tests\\IncludeTest::__pest_evaluable_it_can_include_morph_model_relations":0.005,"P\\Tests\\IncludeTest::__pest_evaluable_it_can_include_reverse_morph_model_relations":0.003,"P\\Tests\\IncludeTest::__pest_evaluable_it_can_include_camel_case_includes":0.002,"P\\Tests\\IncludeTest::__pest_evaluable_it_can_include_models_on_an_empty_collection":0.002,"P\\Tests\\IncludeTest::__pest_evaluable_it_guards_against_invalid_includes":0.002,"P\\Tests\\IncludeTest::__pest_evaluable_it_does_not_throw_invalid_include_query_exception_when_disable_in_config":0.002,"P\\Tests\\IncludeTest::__pest_evaluable_it_can_allow_multiple_includes":0.002,"P\\Tests\\IncludeTest::__pest_evaluable_it_can_allow_multiple_includes_as_an_array":0.003,"P\\Tests\\IncludeTest::__pest_evaluable_it_can_remove_duplicate_includes_from_nested_includes":0.002,"P\\Tests\\IncludeTest::__pest_evaluable_it_can_include_multiple_model_relations":0.003,"P\\Tests\\IncludeTest::__pest_evaluable_it_can_query_included_many_to_many_relationships":0.003,"P\\Tests\\IncludeTest::__pest_evaluable_it_returns_correct_id_when_including_many_to_many_relationship":0.003,"P\\Tests\\IncludeTest::__pest_evaluable_an_invalid_include_query_exception_contains_the_unknown_and_allowed_includes":0.002,"P\\Tests\\IncludeTest::__pest_evaluable_it_can_alias_multiple_allowed_includes":0.003,"P\\Tests\\IncludeTest::__pest_evaluable_it_can_include_custom_include_class":0.002,"P\\Tests\\IncludeTest::__pest_evaluable_it_can_include_custom_include_class_by_alias":0.002,"P\\Tests\\IncludeTest::__pest_evaluable_it_can_take_an_argument_for_custom_column_name_resolution":0.002,"P\\Tests\\IncludeTest::__pest_evaluable_it_can_include_a_custom_base_query_with_select":0.002,"P\\Tests\\QueryBuilderEndpointTest::__pest_evaluable_it_can_instantiate_the_query_builder_and_filter_the_query_for_an_actual_api_request":0.015,"P\\Tests\\QueryBuilderRequestTest::__pest_evaluable_it_can_filter_nested_arrays":0.002,"P\\Tests\\QueryBuilderRequestTest::__pest_evaluable_it_can_get_empty_filters_recursively":0.002,"P\\Tests\\QueryBuilderRequestTest::__pest_evaluable_it_will_map_true_and_false_as_booleans_recursively":0.002,"P\\Tests\\QueryBuilderRequestTest::__pest_evaluable_it_can_get_the_sort_query_param_from_the_request":0.002,"P\\Tests\\QueryBuilderRequestTest::__pest_evaluable_it_can_get_the_sort_query_param_from_the_request_body":0.002,"P\\Tests\\QueryBuilderRequestTest::__pest_evaluable_it_can_get_different_sort_query_parameter_name":0.002,"P\\Tests\\QueryBuilderRequestTest::__pest_evaluable_it_will_return_an_empty_collection_when_no_sort_query_param_is_specified":0.002,"P\\Tests\\QueryBuilderRequestTest::__pest_evaluable_it_can_get_multiple_sort_parameters_from_the_request":0.002,"P\\Tests\\QueryBuilderRequestTest::__pest_evaluable_it_will_return_an_empty_collection_when_no_sort_query_params_are_specified":0.002,"P\\Tests\\QueryBuilderRequestTest::__pest_evaluable_it_can_get_the_filter_query_params_from_the_request":0.002,"P\\Tests\\QueryBuilderRequestTest::__pest_evaluable_it_can_get_the_filter_query_params_from_the_request_body":0.01,"P\\Tests\\QueryBuilderRequestTest::__pest_evaluable_it_can_use_different_filter_query_parameter_name":0.003,"P\\Tests\\QueryBuilderRequestTest::__pest_evaluable_it_can_use_null_as_the_filter_query_parameter_name":0.004,"P\\Tests\\QueryBuilderRequestTest::__pest_evaluable_it_can_get_empty_filters":0.002,"P\\Tests\\QueryBuilderRequestTest::__pest_evaluable_it_will_return_an_empty_collection_when_no_filter_query_params_are_specified":0.002,"P\\Tests\\QueryBuilderRequestTest::__pest_evaluable_it_will_map_true_and_false_as_booleans_when_given_in_a_filter_query_string":0.005,"P\\Tests\\QueryBuilderRequestTest::__pest_evaluable_it_will_map_comma_separated_values_as_arrays_when_given_in_a_filter_query_string":0.004,"P\\Tests\\QueryBuilderRequestTest::__pest_evaluable_it_will_map_array_in_filter_recursively_when_given_in_a_filter_query_string":0.003,"P\\Tests\\QueryBuilderRequestTest::__pest_evaluable_it_will_map_comma_separated_values_as_arrays_when_given_in_a_filter_query_string_and_get_those_by_key":0.006,"P\\Tests\\QueryBuilderRequestTest::__pest_evaluable_it_can_get_the_include_query_params_from_the_request":0.009,"P\\Tests\\QueryBuilderRequestTest::__pest_evaluable_it_can_get_the_include_from_the_request_body":0.004,"P\\Tests\\QueryBuilderRequestTest::__pest_evaluable_it_can_get_different_include_query_parameter_name":0.005,"P\\Tests\\QueryBuilderRequestTest::__pest_evaluable_it_will_return_an_empty_collection_when_no_include_query_params_are_specified":0.004,"P\\Tests\\QueryBuilderRequestTest::__pest_evaluable_it_can_get_requested_fields":0.005,"P\\Tests\\QueryBuilderRequestTest::__pest_evaluable_it_can_get_requested_fields_without_a_table_name":0.002,"P\\Tests\\QueryBuilderRequestTest::__pest_evaluable_it_can_get_nested_fields":0.002,"P\\Tests\\QueryBuilderRequestTest::__pest_evaluable_it_can_get_nested_fields_from_a_string_fields_request":0.002,"P\\Tests\\QueryBuilderRequestTest::__pest_evaluable_it_can_get_requested_fields_from_the_request_body":0.002,"P\\Tests\\QueryBuilderRequestTest::__pest_evaluable_it_can_get_different_fields_parameter_name":0.002,"P\\Tests\\QueryBuilderRequestTest::__pest_evaluable_it_can_get_the_append_query_params_from_the_request":0.002,"P\\Tests\\QueryBuilderRequestTest::__pest_evaluable_it_can_get_different_append_query_parameter_name":0.002,"P\\Tests\\QueryBuilderRequestTest::__pest_evaluable_it_will_return_an_empty_collection_when_no_append_query_params_are_specified":0.002,"P\\Tests\\QueryBuilderRequestTest::__pest_evaluable_it_can_get_the_append_query_params_from_the_request_body":0.002,"P\\Tests\\QueryBuilderRequestTest::__pest_evaluable_it_takes_custom_delimiters_for_splitting_request_parameters":0.002,"P\\Tests\\QueryBuilderTest::__pest_evaluable_it_can_be_given_an_eloquent_query_using_where":0.002,"P\\Tests\\QueryBuilderTest::__pest_evaluable_it_can_be_given_an_eloquent_query_using_select":0.002,"P\\Tests\\QueryBuilderTest::__pest_evaluable_it_can_be_given_a_belongs_to_many_relation_query":0.005,"P\\Tests\\QueryBuilderTest::__pest_evaluable_it_can_be_given_a_belongs_to_many_relation_query_with_pivot":0.003,"P\\Tests\\QueryBuilderTest::__pest_evaluable_it_can_be_given_a_model_class_name":0.002,"P\\Tests\\QueryBuilderTest::__pest_evaluable_it_can_not_be_given_a_string_that_is_not_a_class_name":0.002,"P\\Tests\\QueryBuilderTest::__pest_evaluable_it_can_not_be_given_an_object_that_is_neither_relation_nor_eloquent_builder":0.002,"P\\Tests\\QueryBuilderTest::__pest_evaluable_it_will_determine_the_request_when_its_not_given":0.004,"P\\Tests\\QueryBuilderTest::__pest_evaluable_it_can_query_soft_deletes":0.006,"P\\Tests\\QueryBuilderTest::__pest_evaluable_it_can_query_global_scopes":0.004,"P\\Tests\\QueryBuilderTest::__pest_evaluable_it_keeps_eager_loaded_relationships_from_the_base_query":0.004,"P\\Tests\\QueryBuilderTest::__pest_evaluable_it_keeps_local_macros_added_to_the_base_query":0.002,"P\\Tests\\QueryBuilderTest::__pest_evaluable_it_keeps_the_on_delete_callback_added_to_the_base_query":0.002,"P\\Tests\\QueryBuilderTest::__pest_evaluable_it_can_query_local_scopes":0.002,"P\\Tests\\QueryBuilderTest::__pest_evaluable_it_executes_the_same_query_regardless_of_the_order_of_applied_filters_or_sorts":0.002,"P\\Tests\\QueryBuilderTest::__pest_evaluable_it_can_filter_when_sorting_by_joining_a_related_model_which_contains_the_same_field_name":0.003,"P\\Tests\\QueryBuilderTest::__pest_evaluable_it_queries_the_correct_data_for_a_relationship_query":0.004,"P\\Tests\\QueryBuilderTest::__pest_evaluable_it_does_not_lose_pivot_values_with_belongs_to_many_relation":0.005,"P\\Tests\\QueryBuilderTest::__pest_evaluable_it_clones_the_subject_upon_cloning":0.002,"P\\Tests\\QueryBuilderTest::__pest_evaluable_it_supports_clone_as_method":0.002,"P\\Tests\\RelationFilterTest::__pest_evaluable_it_can_filter_related_model_property":0.002,"P\\Tests\\RelationFilterTest::__pest_evaluable_it_can_filter_results_based_on_the_partial_existence_of_a_property_in_an_array":0.003,"P\\Tests\\RelationFilterTest::__pest_evaluable_it_can_filter_models_and_return_an_empty_collection":0.003,"P\\Tests\\RelationFilterTest::__pest_evaluable_it_can_filter_related_nested_model_property":0.003,"P\\Tests\\RelationFilterTest::__pest_evaluable_it_can_filter_related_model_and_related_nested_model_property":0.002,"P\\Tests\\RelationFilterTest::__pest_evaluable_it_can_filter_results_based_on_the_existence_of_a_property_in_an_array":0.004,"P\\Tests\\RelationFilterTest::__pest_evaluable_it_can_filter_and_reject_results_by_exact_property":0.003,"P\\Tests\\RelationFilterTest::__pest_evaluable_it_can_disable_exact_filtering_based_on_related_model_properties":0.002,"P\\Tests\\RelationFilterTest::__pest_evaluable_it_can_disable_partial_filtering_based_on_related_model_properties":0.002,"P\\Tests\\SortTest::__pest_evaluable_it_can_sort_a_query_ascending":0.003,"P\\Tests\\SortTest::__pest_evaluable_it_has_the_allowed_sorts_property_set_even_if_no_sorts_are_requested":0.002,"P\\Tests\\SortTest::__pest_evaluable_it_can_sort_a_query_descending":0.002,"P\\Tests\\SortTest::__pest_evaluable_it_can_sort_a_query_by_alias":0.003,"P\\Tests\\SortTest::__pest_evaluable_it_wont_sort_by_columns_that_werent_allowed_first":0.002,"P\\Tests\\SortTest::__pest_evaluable_it_can_allow_a_descending_sort_by_still_sort_ascending":0.002,"P\\Tests\\SortTest::__pest_evaluable_it_can_sort_a_query_by_a_related_property":0.002,"P\\Tests\\SortTest::__pest_evaluable_it_can_sort_by_json_property_if_its_an_allowed_sort":0.003,"P\\Tests\\SortTest::__pest_evaluable_it_can_sort_by_sketchy_alias_if_its_an_allowed_sort":0.003,"P\\Tests\\SortTest::__pest_evaluable_it_can_sort_a_query_with_custom_select":0.009,"P\\Tests\\SortTest::__pest_evaluable_it_can_sort_a_chunk_query":0.007,"P\\Tests\\SortTest::__pest_evaluable_it_can_guard_against_sorts_that_are_not_allowed":0.003,"P\\Tests\\SortTest::__pest_evaluable_it_will_throw_an_exception_if_a_sort_property_is_not_allowed":0.002,"P\\Tests\\SortTest::__pest_evaluable_it_does_not_throw_invalid_sort_query_exception_when_disable_in_config":0.002,"P\\Tests\\SortTest::__pest_evaluable_an_invalid_sort_query_exception_contains_the_unknown_and_allowed_sorts":0.004,"P\\Tests\\SortTest::__pest_evaluable_it_wont_sort_if_no_sort_query_parameter_is_given":0.002,"P\\Tests\\SortTest::__pest_evaluable_it_wont_sort_sketchy_sort_requests":0.005,"P\\Tests\\SortTest::__pest_evaluable_it_uses_default_sort_parameter_when_no_sort_was_requested":0.005,"P\\Tests\\SortTest::__pest_evaluable_it_doesnt_use_the_default_sort_parameter_when_a_sort_was_requested":0.002,"P\\Tests\\SortTest::__pest_evaluable_it_allows_default_custom_sort_class_parameter":0.002,"P\\Tests\\SortTest::__pest_evaluable_it_uses_default_descending_sort_parameter":0.002,"P\\Tests\\SortTest::__pest_evaluable_it_allows_multiple_default_sort_parameters":0.002,"P\\Tests\\SortTest::__pest_evaluable_it_allows_multiple_default_sort_parameters_in_an_array":0.005,"P\\Tests\\SortTest::__pest_evaluable_it_can_allow_multiple_sort_parameters":0.005,"P\\Tests\\SortTest::__pest_evaluable_it_can_allow_multiple_sort_parameters_as_an_array":0.003,"P\\Tests\\SortTest::__pest_evaluable_it_can_sort_by_multiple_columns":0.008,"P\\Tests\\SortTest::__pest_evaluable_it_can_sort_by_a_custom_sort_class":0.002,"P\\Tests\\SortTest::__pest_evaluable_it_can_take_an_argument_for_custom_column_name_resolution":0.002,"P\\Tests\\SortTest::__pest_evaluable_it_sets_property_column_name_to_property_name_by_default":0.002,"P\\Tests\\SortTest::__pest_evaluable_it_resolves_queries_using_property_column_name":0.003,"P\\Tests\\SortTest::__pest_evaluable_it_can_sort_descending_with_an_alias":0.003,"P\\Tests\\SortTest::__pest_evaluable_it_does_not_add_sort_clauses_multiple_times":0.003,"P\\Tests\\SortTest::__pest_evaluable_given_a_default_sort_a_sort_alias_will_still_be_resolved":0.002,"P\\Tests\\SortTest::__pest_evaluable_late_specified_sorts_still_check_for_allowance":0.004,"P\\Tests\\SortTest::__pest_evaluable_it_can_sort_and_use_scoped_filters_at_the_same_time":0.002,"P\\Tests\\SortTest::__pest_evaluable_it_ignores_non_existing_sorts_before_adding_them_as_an_alias":0.002,"P\\Tests\\SortTest::__pest_evaluable_raw_sorts_do_not_get_purged_when_specifying_allowed_sorts":0.002,"P\\Tests\\SortTest::__pest_evaluable_the_default_direction_of_an_allow_sort_can_be_set":0.004,"P\\Tests\\SortsCallbackTest::__pest_evaluable_it_should_sort_by_closure":0.003,"P\\Tests\\SortsCallbackTest::__pest_evaluable_it_should_sort_by_array_callback":0.002}} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 39724625..581968cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,118 @@ All notable changes to `laravel-query-builder` will be documented in this file +## 6.3.2 - 2025-04-16 + +### What's Changed + +* Enhance QueryBuilder with generics support for better type inference by @alexkart in https://github.com/spatie/laravel-query-builder/pull/1002 + +### New Contributors + +* @alexkart made their first contribution in https://github.com/spatie/laravel-query-builder/pull/1002 + +**Full Changelog**: https://github.com/spatie/laravel-query-builder/compare/6.3.1...6.3.2 + +## 6.3.1 - 2025-02-21 + +### What's Changed + +* General code health improvements by @xHeaven in https://github.com/spatie/laravel-query-builder/pull/988 +* Bump dependabot/fetch-metadata from 2.2.0 to 2.3.0 by @dependabot in https://github.com/spatie/laravel-query-builder/pull/992 +* Laravel 12.x Compatibility by @laravel-shift in https://github.com/spatie/laravel-query-builder/pull/994 +* Exclude `.github` folder and `.php_cs` from being included in composer installation by @stevebauman in https://github.com/spatie/laravel-query-builder/pull/993 + +### New Contributors + +* @xHeaven made their first contribution in https://github.com/spatie/laravel-query-builder/pull/988 + +**Full Changelog**: https://github.com/spatie/laravel-query-builder/compare/6.3.0...6.3.1 + +## 6.3.0 - 2024-12-23 + +### What's Changed + +* Feature: Add "belongs to" filter by @gpibarra in https://github.com/spatie/laravel-query-builder/pull/975 +* Feature: Additional config options to better match the API spec by @CoolGoose in https://github.com/spatie/laravel-query-builder/pull/983 + +### New Contributors + +* @CoolGoose made their first contribution in https://github.com/spatie/laravel-query-builder/pull/983 + +**Full Changelog**: https://github.com/spatie/laravel-query-builder/compare/6.2.3...6.3.0 + +## 6.2.3 - 2024-12-23 + +### What's Changed + +* Fix selecting fields on belongs to many relations by @rasmuscnielsen in https://github.com/spatie/laravel-query-builder/pull/986 + +### New Contributors + +* @rasmuscnielsen made their first contribution in https://github.com/spatie/laravel-query-builder/pull/986 + +**Full Changelog**: https://github.com/spatie/laravel-query-builder/compare/6.2.2...6.2.3 + +## 6.2.2 - 2024-12-11 + +### What's Changed + +* Update filtering.md to clarify handling of array scope parameter by @g-gullstrand in https://github.com/spatie/laravel-query-builder/pull/976 +* Remove PHPUnit cache by @tarexme in https://github.com/spatie/laravel-query-builder/pull/982 +* Fix typo in filtering.md by @yngc0der in https://github.com/spatie/laravel-query-builder/pull/984 +* Fixed IncludedCount.php by @dash8x in https://github.com/spatie/laravel-query-builder/pull/978 + +### New Contributors + +* @g-gullstrand made their first contribution in https://github.com/spatie/laravel-query-builder/pull/976 +* @tarexme made their first contribution in https://github.com/spatie/laravel-query-builder/pull/982 +* @yngc0der made their first contribution in https://github.com/spatie/laravel-query-builder/pull/984 +* @dash8x made their first contribution in https://github.com/spatie/laravel-query-builder/pull/978 + +**Full Changelog**: https://github.com/spatie/laravel-query-builder/compare/6.2.1...6.2.2 + +## 6.2.1 - 2024-10-03 + +### What's Changed + +* Removed explicit escaping for `pgsql` driver in `FiltersPartial` - Fixes #941 by @Talpx1 in https://github.com/spatie/laravel-query-builder/pull/968 + +**Full Changelog**: https://github.com/spatie/laravel-query-builder/compare/6.2.0...6.2.1 + +## 6.2.0 - 2024-09-27 + +### What's Changed + +* [FEAT] add filter by operator by @AbdelrahmanBl in https://github.com/spatie/laravel-query-builder/pull/940 +* Add documentation for the operator filter by @AlexVanderbist in https://github.com/spatie/laravel-query-builder/pull/974 + +### New Contributors + +* @AbdelrahmanBl made their first contribution in https://github.com/spatie/laravel-query-builder/pull/940 + +**Full Changelog**: https://github.com/spatie/laravel-query-builder/compare/6.1.0...6.2.0 + +## 6.1.0 - 2024-09-23 + +### What's Changed + +* Bump ramsey/composer-install from 2 to 3 by @dependabot in https://github.com/spatie/laravel-query-builder/pull/939 +* Add issue #175 link in selecting-fields.md by @alipadron in https://github.com/spatie/laravel-query-builder/pull/951 +* Update filtering.md by @justinkekeocha in https://github.com/spatie/laravel-query-builder/pull/954 +* Bump dependabot/fetch-metadata from 2.1.0 to 2.2.0 by @dependabot in https://github.com/spatie/laravel-query-builder/pull/955 +* [DOCS] Update Frontend implementation with a new one by @cgarciagarcia in https://github.com/spatie/laravel-query-builder/pull/961 +* Update Documentation for php markdown by @chengkangzai in https://github.com/spatie/laravel-query-builder/pull/969 +* AllowedFilter should return static rather than self by @kosarinin in https://github.com/spatie/laravel-query-builder/pull/964 + +### New Contributors + +* @alipadron made their first contribution in https://github.com/spatie/laravel-query-builder/pull/951 +* @cgarciagarcia made their first contribution in https://github.com/spatie/laravel-query-builder/pull/961 +* @chengkangzai made their first contribution in https://github.com/spatie/laravel-query-builder/pull/969 +* @kosarinin made their first contribution in https://github.com/spatie/laravel-query-builder/pull/964 + +**Full Changelog**: https://github.com/spatie/laravel-query-builder/compare/6.0.1...6.1.0 + ## 6.0.1 - 2024-05-21 ### What's Changed diff --git a/README.md b/README.md index f065e80a..bd3fb875 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,19 @@ -# Build Eloquent queries from API requests +
+ + + + Logo for laravel-query-builder + + + +

Build Eloquent queries from API requests

[![Latest Version on Packagist](https://img.shields.io/packagist/v/spatie/laravel-query-builder.svg?style=flat-square)](https://packagist.org/packages/spatie/laravel-query-builder) ![Test Status](https://img.shields.io/github/actions/workflow/status/spatie/laravel-query-builder/run-tests.yml?label=tests&branch=main) ![Code Style Status](https://img.shields.io/github/actions/workflow/status/spatie/laravel-query-builder/php-cs-fixer.yml?label=code%20style&branch=main) [![Total Downloads](https://img.shields.io/packagist/dt/spatie/laravel-query-builder.svg?style=flat-square)](https://packagist.org/packages/spatie/laravel-query-builder) - -This package allows you to filter, sort and include eloquent relations based on a request. The `QueryBuilder` used in this package extends Laravel's default Eloquent builder. This means all your favorite methods and macros are still available. Query parameter names follow the [JSON API specification](http://jsonapi.org/) as closely as possible. + +
## Caching foreign keys Add this line to your composer.json file to cache foreign keys. This will allow the query builder to automatically detect foreign keys without having to make a database call. diff --git a/composer.json b/composer.json index e7ce390f..a28ea8b6 100644 --- a/composer.json +++ b/composer.json @@ -21,19 +21,18 @@ ], "require": { "php": "^8.2", - "illuminate/database": "^10.0|^11.0", - "illuminate/http": "^10.0|^11.0", - "illuminate/support": "^10.0|^11.0", + "illuminate/database": "^10.0|^11.0|^12.0", + "illuminate/http": "^10.0|^11.0|^12.0", + "illuminate/support": "^10.0|^11.0|^12.0", "spatie/laravel-package-tools": "^1.11", "doctrine/dbal": "^3.5" }, "require-dev": { "ext-json": "*", "mockery/mockery": "^1.4", - "nunomaduro/larastan": "^2.0", - "orchestra/testbench": "^7.0|^8.0", - "phpunit/phpunit": "^10.0", - "pestphp/pest": "^2.0", + "orchestra/testbench": "^7.0|^8.0|^10.0", + "pestphp/pest": "^2.0|^3.7", + "phpunit/phpunit": "^10.0|^11.5.3", "spatie/invade": "^2.0" }, "autoload": { diff --git a/config/query-builder.php b/config/query-builder.php index 36d3d9f5..94fca196 100644 --- a/config/query-builder.php +++ b/config/query-builder.php @@ -60,4 +60,23 @@ * GET /users?fields[userOwner]=id,name */ 'convert_relation_names_to_snake_case_plural' => true, + + /* + * By default, the package expects relationship names to be snake case plural when using fields[relationship]. + * For example, fetching the id and name for a userOwner relation would look like this: + * GET /users?fields[user_owner]=id,name + * + * Set this to one of `snake_case`, `camelCase` or `none` if you want to enable table name resolution in addition to the relation name resolution + * GET /users?include=topOrders&fields[orders]=id,name + */ + 'convert_relation_table_name_strategy' => false, + + /* + * By default, the package expects the field names to match the database names + * For example, fetching the field named firstName would look like this: + * GET /users?fields=firstName + * + * Set this to `true` if you want to convert the firstName into first_name for the underlying query + */ + 'convert_field_names_to_snake_case' => false, ]; diff --git a/database/factories/AppendModelFactory.php b/database/factories/AppendModelFactory.php index 3591b07e..26b38331 100644 --- a/database/factories/AppendModelFactory.php +++ b/database/factories/AppendModelFactory.php @@ -2,8 +2,8 @@ namespace Spatie\QueryBuilder\Database\Factories; -use Spatie\QueryBuilder\Tests\TestClasses\Models\AppendModel; use Illuminate\Database\Eloquent\Factories\Factory; +use Spatie\QueryBuilder\Tests\TestClasses\Models\AppendModel; class AppendModelFactory extends Factory { diff --git a/database/factories/TestModelFactory.php b/database/factories/TestModelFactory.php index 8b1001ad..8965abfb 100644 --- a/database/factories/TestModelFactory.php +++ b/database/factories/TestModelFactory.php @@ -16,4 +16,3 @@ public function definition() ]; } } - diff --git a/docs/_index.md b/docs/_index.md index f17c1c36..2c3ffc19 100644 --- a/docs/_index.md +++ b/docs/_index.md @@ -1,5 +1,5 @@ --- -title: v5 +title: v6 slogan: Easily build Eloquent queries from API requests. githubUrl: https://github.com/spatie/laravel-query-builder branch: main diff --git a/docs/advanced-usage/front-end-implementation.md b/docs/advanced-usage/front-end-implementation.md index fe90478f..f532888a 100644 --- a/docs/advanced-usage/front-end-implementation.md +++ b/docs/advanced-usage/front-end-implementation.md @@ -1,6 +1,6 @@ --- title: Front-end implementation -weight: 3 +weight: 6 --- If you're interested in building query urls on the front-end to match this package, you could use one of the below: @@ -11,3 +11,4 @@ If you're interested in building query urls on the front-end to match this packa Pascal Baljet](https://github.com/pascalbaljet). - React: [cogent-js package](https://www.npmjs.com/package/cogent-js) by [Joel Male](https://github.com/joelwmale). - Typescript: [query-builder-ts package](https://www.npmjs.com/package/@vortechron/query-builder-ts) by [Amirul Adli](https://www.npmjs.com/~vortechron) +- Typescript + React [react-query-builder](https://www.npmjs.com/package/@cgarciagarcia/react-query-builder) by [Carlos Garcia](https://github.com/cgarciagarcia) diff --git a/docs/features/filtering.md b/docs/features/filtering.md index 178bb8cd..f2a7e0e5 100644 --- a/docs/features/filtering.md +++ b/docs/features/filtering.md @@ -86,6 +86,40 @@ $users = QueryBuilder::for(User::class) // $users will contain all admin users with id 1, 2, 3, 4 or 5 ``` +## Operator filters + +Operator filters allow you to filter results based on different operators such as EQUAL, NOT_EQUAL, GREATER_THAN, LESS_THAN, GREATER_THAN_OR_EQUAL, LESS_THAN_OR_EQUAL, and DYNAMIC. You can use the `AllowedFilter::operator` method to create operator filters. + +```php +use Spatie\QueryBuilder\AllowedFilter; +use Spatie\QueryBuilder\Enums\FilterOperator; + +// GET /users?filter[salary]=3000 +$users = QueryBuilder::for(User::class) + ->allowedFilters([ + AllowedFilter::operator('salary', FilterOperator::GREATER_THAN), + ]) + ->get(); + +// $users will contain all users with a salary greater than 3000 +``` + +You can also use dynamic operator filters, which allow you to specify the operator in the filter value: + +```php +use Spatie\QueryBuilder\AllowedFilter; +use Spatie\QueryBuilder\Enums\FilterOperator; + +// GET /users?filter[salary]=>3000 +$users = QueryBuilder::for(User::class) + ->allowedFilters([ + AllowedFilter::operator('salary', FilterOperator::DYNAMIC), + ]) + ->get(); + +// $users will contain all users with a salary greater than 3000 +``` + ## Exact or partial filters for related properties You can also add filters for a relationship property using the dot-notation: `AllowedFilter::exact('posts.title')`. This works for exact and partial filters. Under the hood we'll add a `whereHas` statement for the `posts` that filters for the given `title` property as well. @@ -100,6 +134,55 @@ QueryBuilder::for(User::class) ->allowedFilters(AllowedFilter::exact('posts.title', null, $addRelationConstraint)); ``` +## BelongsTo filters + +In Model: +```php +class Comment extends Model +{ + public function post(): BelongsTo + { + return $this->belongsTo(Post::class); + } +} +``` + +```php +QueryBuilder::for(Comment::class) + ->allowedFilters([ + AllowedFilter::belongsTo('post'), + ]) + ->get(); +``` + +Alias +```php +QueryBuilder::for(Comment::class) + ->allowedFilters([ + AllowedFilter::belongsTo('post_id', 'post'), + ]) + ->get(); +``` + +Nested +```php +class Post extends Model +{ + public function author(): BelongsTo + { + return $this->belongsTo(User::class); + } +} +``` + +```php +QueryBuilder::for(Comment::class) + ->allowedFilters([ + AllowedFilter::belongsTo('author_post_id', 'post.author'), + ]) + ->get(); +``` + ## Scope filters Sometimes more advanced filtering options are necessary. This is where scope filters, callback filters and custom filters come in handy. @@ -137,6 +220,14 @@ You can even pass multiple parameters to the scope by passing a comma separated GET /events?filter[schedule.starts_between]=2018-01-01,2018-12-31 ``` +When passing an array as a parameter you can access it, as an array, in the scope by using the spread operator. +```php +public function scopeInvitedUsers(Builder $query, ...$users): Builder +{ + return $query->whereIn('id', $users); +} +``` + When using scopes that require model instances in the parameters, we'll automatically try to inject the model instances into your scope. This works the same way as route model binding does for injecting Eloquent models into controllers. For example: ```php @@ -148,6 +239,12 @@ public function scopeEvent(Builder $query, \App\Models\Event $event): Builder // GET /events?filter[event]=1 - the event with ID 1 will automatically be resolved and passed to the scoped filter ``` +If you use any other column aside `id` column for route model binding (ULID,UUID). Remeber to specify the value of the column used in route model binding + +```php +// GET /events?filter[event]=01j0rcpkx5517v0aqyez5vnwn - supposing we use a ULID column for route model binding. +``` + Scopes are usually not named with query filters in mind. Use [filter aliases](#filter-aliases) to alias them to something more appropriate: ```php diff --git a/docs/features/selecting-fields.md b/docs/features/selecting-fields.md index 2ab16d32..b613e719 100644 --- a/docs/features/selecting-fields.md +++ b/docs/features/selecting-fields.md @@ -9,8 +9,8 @@ Sometimes you'll want to fetch only a couple fields to reduce the overall size o The following example fetches only the users' `id` and `name`: -``` -GET /users?fields[users]=id,name +```php +// GET /users?fields[users]=id,name $users = QueryBuilder::for(User::class) ->allowedFields(['id', 'name']) @@ -51,7 +51,7 @@ QueryBuilder::for(Post::class) // All posts will be fetched including _only_ the name of the author. ``` -⚠️ Keep in mind that the fields query will completely override the `SELECT` part of the query. This means that you'll need to manually specify any columns required for Eloquent relationships to work, in the above example `author.id`. See issue #175 as well. +⚠️ Keep in mind that the fields query will completely override the `SELECT` part of the query. This means that you'll need to manually specify any columns required for Eloquent relationships to work, in the above example `author.id`. See issue [#175](https://github.com/spatie/laravel-query-builder/issues/175) as well. ⚠️ `allowedFields` must be called before `allowedIncludes`. Otherwise the query builder won't know what fields to include for the requested includes and an exception will be thrown. diff --git a/docs/introduction.md b/docs/introduction.md index 3baf681f..b7f5b797 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -23,7 +23,7 @@ $users = QueryBuilder::for(User::class) // all `User`s that contain the string "John" in their name ``` -[Read more about filtering features like: partial filters, exact filters, scope filters, custom filters, ignored values, default filter values, ...](https://spatie.be/docs/laravel-query-builder/v5/features/filtering/) +[Read more about filtering features like: partial filters, exact filters, scope filters, custom filters, ignored values, default filter values, ...](https://spatie.be/docs/laravel-query-builder/v6/features/filtering/) ### Including relations based on a request: `/users?include=posts`: @@ -35,7 +35,7 @@ $users = QueryBuilder::for(User::class) // all `User`s with their `posts` loaded ``` -[Read more about include features like: including nested relationships, including relationship count, ...](https://spatie.be/docs/laravel-query-builder/v5/features/including-relationships/) +[Read more about include features like: including nested relationships, including relationship count, ...](https://spatie.be/docs/laravel-query-builder/v6/features/including-relationships/) ### Sorting a query based on a request: `/users?sort=id`: @@ -47,7 +47,7 @@ $users = QueryBuilder::for(User::class) // all `User`s sorted by ascending id ``` -[Read more about sorting features like: custom sorts, sort direction, ...](https://spatie.be/docs/laravel-query-builder/v5/features/sorting/) +[Read more about sorting features like: custom sorts, sort direction, ...](https://spatie.be/docs/laravel-query-builder/v6/features/sorting/) ### Works together nicely with existing queries: @@ -70,7 +70,7 @@ $users = QueryBuilder::for(User::class) // the fetched `User`s will only have their id & email set ``` -[Read more about selecting fields.](https://spatie.be/docs/laravel-query-builder/v5/features/selecting-fields/) +[Read more about selecting fields.](https://spatie.be/docs/laravel-query-builder/v6/features/selecting-fields/) ## We have badges! diff --git a/phpstan.neon.dist b/phpstan.neon.dist index c590d9e3..6dcbbdec 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -1,5 +1,5 @@ includes: - - ./vendor/nunomaduro/larastan/extension.neon + - ./vendor/larastan/larastan/extension.neon - phpstan-baseline.neon parameters: @@ -14,11 +14,9 @@ parameters: checkModelProperties: true checkOctaneCompatibility: true - checkMissingIterableValueType: false reportUnmatchedIgnoredErrors: false noUnnecessaryCollectionCall: true checkNullables: true - checkGenericClassInNonGenericObjectType: false treatPhpDocTypesAsCertain: false ignoreErrors: diff --git a/src/AllowedField.php b/src/AllowedField.php index 2be79d1d..6689a952 100644 --- a/src/AllowedField.php +++ b/src/AllowedField.php @@ -3,6 +3,7 @@ namespace Spatie\QueryBuilder; use Illuminate\Support\Collection; +use Illuminate\Support\Str; class AllowedField { @@ -36,8 +37,12 @@ public function getName(): string return $this->name; } - public function getInternalNames(): Collection + public function getInternalNames(bool $snakeCase = false): Collection { + if ($snakeCase) { + return $this->internalNames->map(fn ($name) => Str::snake($name)); + } + return $this->internalNames; } } diff --git a/src/AllowedFilter.php b/src/AllowedFilter.php index a9ba5bb0..c4ea46ee 100644 --- a/src/AllowedFilter.php +++ b/src/AllowedFilter.php @@ -3,11 +3,14 @@ namespace Spatie\QueryBuilder; use Illuminate\Support\Collection; +use Spatie\QueryBuilder\Enums\FilterOperator; use Spatie\QueryBuilder\Filters\Filter; use Spatie\QueryBuilder\Filters\FiltersBeginsWithStrict; +use Spatie\QueryBuilder\Filters\FiltersBelongsTo; use Spatie\QueryBuilder\Filters\FiltersCallback; use Spatie\QueryBuilder\Filters\FiltersEndsWithStrict; use Spatie\QueryBuilder\Filters\FiltersExact; +use Spatie\QueryBuilder\Filters\FiltersOperator; use Spatie\QueryBuilder\Filters\FiltersPartial; use Spatie\QueryBuilder\Filters\FiltersScope; use Spatie\QueryBuilder\Filters\FiltersTrashed; @@ -45,67 +48,81 @@ public function filter(QueryBuilder $query, $value): void ($this->filterClass)($query->getEloquentBuilder(), $valueToFilter, $this->internalName); } - public static function setFilterArrayValueDelimiter(string $delimiter = null): void + public static function setFilterArrayValueDelimiter(?string $delimiter = null): void { if (isset($delimiter)) { QueryBuilderRequest::setFilterArrayValueDelimiter($delimiter); } } - public static function exact(string $name, ?string $internalName = null, bool $addRelationConstraint = true, string $arrayValueDelimiter = null): self + public static function exact(string $name, ?string $internalName = null, bool $addRelationConstraint = true, ?string $arrayValueDelimiter = null): static { static::setFilterArrayValueDelimiter($arrayValueDelimiter); return new static($name, new FiltersExact($addRelationConstraint), $internalName); } - public static function partial(string $name, $internalName = null, bool $addRelationConstraint = true, string $arrayValueDelimiter = null): self + public static function partial(string $name, $internalName = null, bool $addRelationConstraint = true, ?string $arrayValueDelimiter = null): static { static::setFilterArrayValueDelimiter($arrayValueDelimiter); return new static($name, new FiltersPartial($addRelationConstraint), $internalName); } - public static function beginsWithStrict(string $name, $internalName = null, bool $addRelationConstraint = true, string $arrayValueDelimiter = null): self + public static function beginsWithStrict(string $name, $internalName = null, bool $addRelationConstraint = true, ?string $arrayValueDelimiter = null): static { static::setFilterArrayValueDelimiter($arrayValueDelimiter); return new static($name, new FiltersBeginsWithStrict($addRelationConstraint), $internalName); } - public static function endsWithStrict(string $name, $internalName = null, bool $addRelationConstraint = true, string $arrayValueDelimiter = null): self + public static function endsWithStrict(string $name, $internalName = null, bool $addRelationConstraint = true, ?string $arrayValueDelimiter = null): static { static::setFilterArrayValueDelimiter($arrayValueDelimiter); return new static($name, new FiltersEndsWithStrict($addRelationConstraint), $internalName); } - public static function scope(string $name, $internalName = null, string $arrayValueDelimiter = null): self + public static function belongsTo(string $name, $internalName = null, ?string $arrayValueDelimiter = null): static + { + static::setFilterArrayValueDelimiter($arrayValueDelimiter); + + return new static($name, new FiltersBelongsTo(), $internalName); + } + + public static function scope(string $name, $internalName = null, ?string $arrayValueDelimiter = null): static { static::setFilterArrayValueDelimiter($arrayValueDelimiter); return new static($name, new FiltersScope(), $internalName); } - public static function callback(string $name, $callback, $internalName = null, string $arrayValueDelimiter = null): self + public static function callback(string $name, $callback, $internalName = null, ?string $arrayValueDelimiter = null): static { static::setFilterArrayValueDelimiter($arrayValueDelimiter); return new static($name, new FiltersCallback($callback), $internalName); } - public static function trashed(string $name = 'trashed', $internalName = null): self + public static function trashed(string $name = 'trashed', $internalName = null): static { return new static($name, new FiltersTrashed(), $internalName); } - public static function custom(string $name, Filter $filterClass, $internalName = null, string $arrayValueDelimiter = null): self + public static function custom(string $name, Filter $filterClass, $internalName = null, ?string $arrayValueDelimiter = null): static { static::setFilterArrayValueDelimiter($arrayValueDelimiter); return new static($name, $filterClass, $internalName); } + public static function operator(string $name, FilterOperator $filterOperator, string $boolean = 'and', ?string $internalName = null, bool $addRelationConstraint = true, ?string $arrayValueDelimiter = null): self + { + static::setFilterArrayValueDelimiter($arrayValueDelimiter); + + return new static($name, new FiltersOperator($addRelationConstraint, $filterOperator, $boolean), $internalName); + } + public function getFilterClass(): Filter { return $this->filterClass; @@ -121,7 +138,7 @@ public function isForFilter(string $filterName): bool return $this->name === $filterName; } - public function ignore(...$values): self + public function ignore(...$values): static { $this->ignored = $this->ignored ->merge($values) @@ -140,7 +157,7 @@ public function getInternalName(): string return $this->internalName; } - public function default($value): self + public function default($value): static { $this->hasDefault = true; $this->default = $value; @@ -162,14 +179,14 @@ public function hasDefault(): bool return $this->hasDefault; } - public function nullable(bool $nullable = true): self + public function nullable(bool $nullable = true): static { $this->nullable = $nullable; return $this; } - public function unsetDefault(): self + public function unsetDefault(): static { $this->hasDefault = false; unset($this->default); diff --git a/src/Concerns/AddsFieldsToQuery.php b/src/Concerns/AddsFieldsToQuery.php index 726574d1..f276cd73 100644 --- a/src/Concerns/AddsFieldsToQuery.php +++ b/src/Concerns/AddsFieldsToQuery.php @@ -40,13 +40,21 @@ protected function addRequestedModelFieldsToQuery(): void { $modelTableName = $this->getModel()->getTable(); + if (config('query-builder.convert_relation_table_name_strategy', false) === 'camelCase') { + $modelTableName = Str::camel($modelTableName); + } + + if (config('query-builder.convert_relation_table_name_strategy', false) === 'snake_case') { + $modelTableName = Str::snake($modelTableName); + } + $requestFields = $this->request->fields()->map(function ($field) { return $field->name; }); $modelFields = $this->allowedFields->mapWithKeys(function (AllowedField $field) { return [ - $field->getName() => $field->getInternalNames()->toArray(), + $field->getName() => $field->getInternalNames(config('query-builder.convert_field_names_to_snake_case', false))->toArray(), ]; }); @@ -73,26 +81,64 @@ protected function addRequestedModelFieldsToQuery(): void $this->select($prependedFields); } - public function getRequestedFieldsForRelatedTable(string $relation): array + public function getRequestedFieldsForRelatedTable(string $relation, ?string $tableName = null): array { - $tableOrRelation = config('query-builder.convert_relation_names_to_snake_case_plural', true) + // Build list of possible table names to check + $possibleRelatedNames = []; + + // Original table name conversion logic + $possibleRelatedNames[] = config('query-builder.convert_relation_names_to_snake_case_plural', true) ? Str::plural(Str::snake($relation)) : $relation; - $fields = $this->request->fields() - ->mapWithKeys(fn ($fields, $table) => [$table => $fields]) - ->get($tableOrRelation); + // New strategy-based conversions + $strategy = config('query-builder.convert_relation_table_name_strategy', false); + if ($tableName) { + if ($strategy === 'snake_case') { + $possibleRelatedNames[] = Str::snake($tableName); + } elseif ($strategy === 'camelCase') { + $possibleRelatedNames[] = Str::camel($tableName); + } elseif ($strategy === 'none') { + $possibleRelatedNames[] = $tableName; + } + } - if (! $fields) { + // Get fields with potential snake_case conversion + $fields = $this->request->fields(); + + if (config('query-builder.convert_field_names_to_snake_case', false)) { + $fields = $fields->mapWithKeys(fn ($fields, $table) => [ + $table => collect($fields)->map(fn ($field) => Str::snake($field)), + ]); + } + + // Find fields for any of the possible table names + $matchedFields = null; + foreach ($possibleRelatedNames as $tableName) { + if ($fields->has($tableName)) { + $matchedFields = $fields->get($tableName); + + break; + } + } + + if (! $matchedFields) { return []; } + $matchedFields = $matchedFields->toArray(); + + // Validate against allowed fields as in original implementation if (! $this->allowedFields instanceof Collection) { - // We have requested fields but no allowed fields (yet?) - throw new UnknownIncludedFieldsQuery($fields); + throw new UnknownIncludedFieldsQuery($matchedFields); + } + + // Prepend table name if provided (from new implementation) + if ($tableName !== null) { + $matchedFields = $this->prependFieldsWithTableName($matchedFields, $tableName); } - return $fields; + return $matchedFields; } protected function ensureAllFieldsExist(): void diff --git a/src/Enums/FilterOperator.php b/src/Enums/FilterOperator.php new file mode 100644 index 00000000..451f37e7 --- /dev/null +++ b/src/Enums/FilterOperator.php @@ -0,0 +1,19 @@ +'; + case LESS_THAN_OR_EQUAL = '<='; + case GREATER_THAN_OR_EQUAL = '>='; + case NOT_EQUAL = '<>'; + + public function isDynamic() + { + return self::DYNAMIC === $this; + } +} diff --git a/src/Filters/FiltersBelongsTo.php b/src/Filters/FiltersBelongsTo.php new file mode 100644 index 00000000..059924b9 --- /dev/null +++ b/src/Filters/FiltersBelongsTo.php @@ -0,0 +1,83 @@ + + */ +class FiltersBelongsTo implements Filter +{ + /** {@inheritdoc} */ + public function __invoke(Builder $query, $value, string $property) + { + $values = array_values(Arr::wrap($value)); + + $propertyParts = collect(explode('.', $property)); + $relation = $propertyParts->pop(); + $relationParent = $propertyParts->implode('.'); + $relatedModel = $this->getRelatedModel($query->getModel(), $relation, $relationParent); + + $relatedCollection = $relatedModel->newCollection(); + array_walk($values, fn ($v) => $relatedCollection->add( + tap($relatedModel->newInstance(), fn ($m) => $m->setAttribute($m->getKeyName(), $v)) + )); + + if ($relatedCollection->isEmpty()) { + return $query; + } + + if ($relationParent) { + $query->whereHas($relationParent, fn (Builder $q) => $q->whereBelongsTo($relatedCollection, $relation)); + } else { + $query->whereBelongsTo($relatedCollection, $relation); + } + } + + protected function getRelatedModel(Model $modelQuery, string $relationName, string $relationParent): Model + { + if ($relationParent) { + $modelParent = $this->getModelFromRelation($modelQuery, $relationParent); + } else { + $modelParent = $modelQuery; + } + + $relatedModel = $this->getRelatedModelFromRelation($modelParent, $relationName); + + return $relatedModel; + } + + protected function getRelatedModelFromRelation(Model $model, string $relationName): ?Model + { + $relationObject = $model->$relationName(); + if (! is_subclass_of($relationObject, Relation::class)) { + throw RelationNotFoundException::make($model, $relationName); + } + + $relatedModel = $relationObject->getRelated(); + + return $relatedModel; + } + + protected function getModelFromRelation(Model $model, string $relation, int $level = 0): ?Model + { + $relationParts = explode('.', $relation); + if (count($relationParts) == 1) { + return $this->getRelatedModelFromRelation($model, $relation); + } + + $firstRelation = $relationParts[0]; + $firstRelatedModel = $this->getRelatedModelFromRelation($model, $firstRelation); + if (! $firstRelatedModel) { + return null; + } + + return $this->getModelFromRelation($firstRelatedModel, implode('.', array_slice($relationParts, 1)), $level + 1); + } +} diff --git a/src/Filters/FiltersExact.php b/src/Filters/FiltersExact.php index 58c07818..b84ad991 100644 --- a/src/Filters/FiltersExact.php +++ b/src/Filters/FiltersExact.php @@ -66,7 +66,7 @@ protected function withRelationConstraint(Builder $query, mixed $value, string $ $parts->last(), ]); - $query->whereHas($relation, function (Builder $query) use ($value, $property) { + $query->whereHas($relation, function (Builder $query) use ($property, $value) { $this->relationConstraints[] = $property = $query->qualifyColumn($property); $this->__invoke($query, $value, $property); diff --git a/src/Filters/FiltersOperator.php b/src/Filters/FiltersOperator.php new file mode 100644 index 00000000..157649e7 --- /dev/null +++ b/src/Filters/FiltersOperator.php @@ -0,0 +1,66 @@ + + */ +class FiltersOperator extends FiltersExact implements Filter +{ + public function __construct(protected bool $addRelationConstraint, protected FilterOperator $filterOperator, protected string $boolean) + { + } + + /** {@inheritdoc} */ + public function __invoke(Builder $query, $value, string $property) + { + $filterOperator = $this->filterOperator; + + if ($this->addRelationConstraint) { + if ($this->isRelationProperty($query, $property)) { + $this->withRelationConstraint($query, $value, $property); + + return; + } + } + + if (is_array($value)) { + $query->where(function ($query) use ($value, $property) { + foreach ($value as $item) { + $this->__invoke($query, $item, $property); + } + }); + + return; + } elseif ($this->filterOperator->isDynamic()) { + $filterOperator = $this->getDynamicFilterOperator($value); + $this->removeDynamicFilterOperatorFromValue($value, $filterOperator); + } + + $query->where($query->qualifyColumn($property), $filterOperator->value, $value, $this->boolean); + } + + protected function getDynamicFilterOperator(string $value): FilterOperator + { + $filterOperator = FilterOperator::EQUAL; + + foreach (FilterOperator::cases() as $filterOperatorCase) { + if (str_starts_with($value, $filterOperatorCase->value) && ! $filterOperatorCase->isDynamic()) { + $filterOperator = $filterOperatorCase; + } + } + + return $filterOperator; + } + + protected function removeDynamicFilterOperatorFromValue(string &$value, FilterOperator $filterOperator) + { + if (str_contains($value, $filterOperator->value)) { + $value = substr_replace($value, '', 0, strlen($filterOperator->value)); + } + } +} diff --git a/src/Filters/FiltersPartial.php b/src/Filters/FiltersPartial.php index 77db819c..dbea2f3c 100644 --- a/src/Filters/FiltersPartial.php +++ b/src/Filters/FiltersPartial.php @@ -74,7 +74,7 @@ protected static function escapeLike(string $value): string */ protected static function maybeSpecifyEscapeChar(string $driver): string { - if (! in_array($driver, ['sqlite','pgsql','sqlsrv'])) { + if (! in_array($driver, ['sqlite','sqlsrv'])) { return ''; } diff --git a/src/Filters/FiltersScope.php b/src/Filters/FiltersScope.php index eca6829a..5be5d508 100644 --- a/src/Filters/FiltersScope.php +++ b/src/Filters/FiltersScope.php @@ -54,12 +54,12 @@ protected function resolveParameters(Builder $query, $values, string $scope): ar } foreach ($parameters as $parameter) { - if (! optional($this->getClass($parameter))->isSubclassOf(Model::class)) { + if (! $this->getClass($parameter)?->isSubclassOf(Model::class)) { continue; } /** @var TModelClass $model */ - $model = $this->getClass($parameter)?->newInstance(); + $model = $this->getClass($parameter)->newInstance(); $index = $parameter->getPosition() - 1; $value = $values[$index]; diff --git a/src/Includes/IncludedCount.php b/src/Includes/IncludedCount.php index 4e2c05d6..67f3eb1a 100644 --- a/src/Includes/IncludedCount.php +++ b/src/Includes/IncludedCount.php @@ -9,6 +9,9 @@ class IncludedCount implements IncludeInterface { public function __invoke(Builder $query, string $count) { - $query->withCount(Str::before($count, config('query-builder.count_suffix', 'Count'))); + $suffix = config('query-builder.count_suffix', 'Count'); + $relation = Str::endsWith($count, $suffix) ? Str::beforeLast($count, $suffix) : $count; + + $query->withCount($relation); } } diff --git a/src/Includes/IncludedRelationship.php b/src/Includes/IncludedRelationship.php index 0df9e279..9aca7d8d 100644 --- a/src/Includes/IncludedRelationship.php +++ b/src/Includes/IncludedRelationship.php @@ -3,6 +3,7 @@ namespace Spatie\QueryBuilder\Includes; use Closure; +use Exception; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Collection; @@ -16,11 +17,27 @@ public function __invoke(Builder $query, string $relationship) $relatedTables = collect(explode('.', $relationship)); $withs = $relatedTables - ->mapWithKeys(function ($table, $key) use ($relatedTables) { + ->mapWithKeys(function ($table, $key) use ($relatedTables, $query) { $fullRelationName = $relatedTables->slice(0, $key + 1)->implode('.'); if ($this->getRequestedFieldsForRelatedTable) { - $fields = ($this->getRequestedFieldsForRelatedTable)($fullRelationName); + + $tableName = null; + $strategy = config('query-builder.convert_relation_table_name_strategy', false); + + if ($strategy !== false) { + // Try to resolve the related model's table name + try { + // Use the current query's model to resolve the relationship + $relatedModel = $query->getModel()->{$fullRelationName}()->getRelated(); + $tableName = $relatedModel->getTable(); + } catch (Exception $e) { + // If we can not figure out the table don't do anything + $tableName = null; + } + } + + $fields = ($this->getRequestedFieldsForRelatedTable)($fullRelationName, $tableName); } if (empty($fields)) { @@ -28,7 +45,7 @@ public function __invoke(Builder $query, string $relationship) } return [$fullRelationName => function ($query) use ($fields) { - $query->select($fields); + $query->select($query->qualifyColumns($fields)); }]; }) ->toArray(); diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php index 80366e85..eb181915 100755 --- a/src/QueryBuilder.php +++ b/src/QueryBuilder.php @@ -14,7 +14,8 @@ use Spatie\QueryBuilder\Concerns\SortsQuery; /** - * @mixin EloquentBuilder + * @template TModel of Model + * @mixin EloquentBuilder */ class QueryBuilder implements ArrayAccess { @@ -57,7 +58,10 @@ public static function for( $subject = $subject::query(); } - return new static($subject, $request); + /** @var static $queryBuilder */ + $queryBuilder = new static($subject, $request); + + return $queryBuilder; } public function __call($name, $arguments) diff --git a/tests/FieldsTest.php b/tests/FieldsTest.php index af5c12f1..a8148b0a 100644 --- a/tests/FieldsTest.php +++ b/tests/FieldsTest.php @@ -83,6 +83,21 @@ expect($query)->toEqual($expected); }); +it('can fetch specific string columns jsonApi Format', function () { + config(['query-builder.convert_field_names_to_snake_case' => true]); + config(['query-builder.convert_relation_table_name_strategy' => 'camelCase']); + + $query = createQueryFromFieldRequest('firstName,id') + ->allowedFields(['firstName', 'id']) + ->toSql(); + + $expected = TestModel::query() + ->select("{$this->modelTableName}.first_name", "{$this->modelTableName}.id") + ->toSql(); + + expect($query)->toEqual($expected); +}); + it('wont fetch a specific array column if its not allowed', function () { $query = createQueryFromFieldRequest(['test_models' => 'random-column'])->toSql(); @@ -174,7 +189,7 @@ $queryBuilder->first()->relatedModels; $this->assertQueryLogContains('select `test_models`.`id` from `test_models`'); - $this->assertQueryLogContains('select `name` from `related_models`'); + $this->assertQueryLogContains('select `related_models`.`name` from `related_models`'); }); it('can fetch only requested string columns from an included model', function () { @@ -197,7 +212,104 @@ $queryBuilder->first()->relatedModels; $this->assertQueryLogContains('select `test_models`.`id` from `test_models`'); - $this->assertQueryLogContains('select `name` from `related_models`'); + $this->assertQueryLogContains('select `related_models`.`name` from `related_models`'); +}); + +it('can fetch only requested string columns from an included belongs to many model', function () { + TestModel::first()->relatedThroughPivotModels()->create([ + 'name' => 'related', + ]); + + $request = new Request([ + 'fields' => 'id,related_through_pivot_models.id,related_through_pivot_models.name', + 'include' => ['relatedThroughPivotModels'], + ]); + + $queryBuilder = QueryBuilder::for(TestModel::class, $request) + ->allowedFields('id', 'related_through_pivot_models.id', 'related_through_pivot_models.name') + ->allowedIncludes('relatedThroughPivotModels'); + + DB::enableQueryLog(); + + $queryBuilder->first()->relatedThroughPivotModels; + + $this->assertQueryLogContains('select `test_models`.`id` from `test_models`'); + $this->assertQueryLogContains('select `related_through_pivot_models`.`id`, `related_through_pivot_models`.`name`, `pivot_models`.`test_model_id` as `pivot_test_model_id`, `pivot_models`.`related_through_pivot_model_id` as `pivot_related_through_pivot_model_id` from `related_through_pivot_models` inner join `pivot_models` on `related_through_pivot_models`.`id` = `pivot_models`.`related_through_pivot_model_id` where `pivot_models`.`test_model_id` in ('); +}); + +it('can fetch only requested string columns from an included model jsonApi format', function () { + config(['query-builder.convert_relation_table_name_strategy' => 'camelCase']); + RelatedModel::create([ + 'test_model_id' => $this->model->id, + 'name' => 'related', + ]); + + $request = new Request([ + 'fields' => 'id,relatedModels.name', + 'include' => ['relatedModels'], + ]); + + $queryBuilder = QueryBuilder::for(TestModel::class, $request) + ->allowedFields('relatedModels.name', 'id') + ->allowedIncludes('relatedModels'); + + DB::enableQueryLog(); + + $queryBuilder->first()->relatedModels; + + $this->assertQueryLogContains('select `test_models`.`id` from `test_models`'); + $this->assertQueryLogContains('select `related_models`.`name` from `related_models`'); +}); + +it('can fetch only requested string columns from an included model jsonApi format with field conversion', function () { + config(['query-builder.convert_field_names_to_snake_case' => true]); + config(['query-builder.convert_relation_table_name_strategy' => 'camelCase']); + + RelatedModel::create([ + 'test_model_id' => $this->model->id, + 'name' => 'related', + ]); + + $request = new Request([ + 'fields' => 'id,relatedModels.fullName', + 'include' => ['relatedModels'], + ]); + + $queryBuilder = QueryBuilder::for(TestModel::class, $request) + ->allowedFields('relatedModels.fullName', 'id') + ->allowedIncludes('relatedModels'); + + DB::enableQueryLog(); + + $queryBuilder->first()->relatedModels; + + $this->assertQueryLogContains('select `test_models`.`id` from `test_models`'); + $this->assertQueryLogContains('select `related_models`.`full_name` from `related_models`'); +}); + +it('can fetch only requested string columns from an included model through pivot jsonApi format', function () { + config(['query-builder.convert_relation_table_name_strategy' => 'camelCase']); + + $this->model->relatedThroughPivotModels()->create([ + 'id' => $this->model->id + 1, + 'name' => 'Test', + ]); + + $request = new Request([ + 'fields' => 'id,relatedThroughPivotModels.name', + 'include' => ['relatedThroughPivotModels'], + ]); + + $queryBuilder = QueryBuilder::for(TestModel::class, $request) + ->allowedFields('relatedThroughPivotModels.name', 'id') + ->allowedIncludes('relatedThroughPivotModels'); + + DB::enableQueryLog(); + + $queryBuilder->first()->relatedThroughPivotModels; + + $this->assertQueryLogContains('select `test_models`.`id` from `test_models`'); + $this->assertQueryLogContains('select `related_through_pivot_models`.`name`, `pivot_models`.`test_model_id` as `pivot_test_model_id`, `pivot_models`.`related_through_pivot_model_id` as `pivot_related_through_pivot_model_id` from `related_through_pivot_models`'); }); it('can fetch requested array columns from included models up to two levels deep', function () { @@ -224,6 +336,36 @@ expect($result->relatedModels->first()->testModel->toArray())->toEqual(['id' => $this->model->id]); }); +it('can fetch requested array columns from included models up to two levels deep jsonApi mapper', function () { + config(['query-builder.convert_field_names_to_snake_case' => true]); + config(['query-builder.convert_relation_table_name_strategy' => 'camelCase']); + + $relatedModel = RelatedModel::create([ + 'test_model_id' => $this->model->id, + 'name' => 'related', + ]); + + $relatedModel->nestedRelatedModels()->create([ + 'name' => 'nested related', + ]); + + $request = new Request([ + 'fields' => 'id,name,relatedModels.id,relatedModels.name,nestedRelatedModels.id,nestedRelatedModels.name', + 'include' => ['nestedRelatedModels', 'relatedModels'], + ]); + + + $queryBuilder = QueryBuilder::for(TestModel::class, $request) + ->allowedFields('id', 'name', 'relatedModels.id', 'relatedModels.name', 'nestedRelatedModels.id', 'nestedRelatedModels.name') + ->allowedIncludes('relatedModels', 'nestedRelatedModels'); + + DB::enableQueryLog(); + $queryBuilder->first(); + + $this->assertQueryLogContains('select `test_models`.`id`, `test_models`.`name` from `test_models`'); + $this->assertQueryLogContains('select `nested_related_models`.`id`, `nested_related_models`.`name`, `related_models`.`test_model_id` as `laravel_through_key` from `nested_related_models`'); +}); + it('can fetch requested string columns from included models up to two levels deep', function () { RelatedModel::create([ 'test_model_id' => $this->model->id, @@ -299,7 +441,7 @@ $queryBuilder->first()->relatedModels; $this->assertQueryLogContains('select * from `test_models`'); - $this->assertQueryLogContains('select `id`, `name` from `related_models`'); + $this->assertQueryLogContains('select `related_models`.`id`, `related_models`.`name` from `related_models`'); }); it('wont use sketchy field requests', function () { diff --git a/tests/FilterTest.php b/tests/FilterTest.php index 650abbd9..75d5042a 100644 --- a/tests/FilterTest.php +++ b/tests/FilterTest.php @@ -9,11 +9,14 @@ use function PHPUnit\Framework\assertObjectHasProperty; use Spatie\QueryBuilder\AllowedFilter; +use Spatie\QueryBuilder\Enums\FilterOperator; use Spatie\QueryBuilder\Exceptions\InvalidFilterQuery; use Spatie\QueryBuilder\Filters\Filter as CustomFilter; use Spatie\QueryBuilder\Filters\Filter as FilterInterface; use Spatie\QueryBuilder\Filters\FiltersExact; use Spatie\QueryBuilder\QueryBuilder; +use Spatie\QueryBuilder\Tests\TestClasses\Models\NestedRelatedModel; +use Spatie\QueryBuilder\Tests\TestClasses\Models\RelatedModel; use Spatie\QueryBuilder\Tests\TestClasses\Models\TestModel; beforeEach(function () { @@ -30,10 +33,38 @@ expect($models)->toHaveCount(1); }); +it('can use a custom filter query string parameter', function () { + config(['query-builder.parameters.filter' => 'custom_filter']); + + $request = new Request([ + 'custom_filter' => ['name' => $this->models->first()->name], + ]); + + $models = QueryBuilder::for(TestModel::class, $request) + ->allowedFilters('name') + ->get(); + + expect($models)->toHaveCount(1); +}); + +it('can work without a general filter query string parameter configured', function () { + config(['query-builder.parameters.filter' => null]); + + $request = new Request([ + 'name' => $this->models->first()->name, + ]); + + $models = QueryBuilder::for(TestModel::class, $request) + ->allowedFilters('name') + ->get(); + + expect($models)->toHaveCount(1); +}); + it('can filter models by an array as filter value', function () { $models = createQueryFromFilterRequest([ - 'name' => ['first' => $this->models->first()->name], - ]) + 'name' => ['first' => $this->models->first()->name], + ]) ->allowedFilters('name') ->get(); @@ -42,8 +73,8 @@ it('can filter partially and case insensitive', function () { $models = createQueryFromFilterRequest([ - 'name' => strtoupper($this->models->first()->name), - ]) + 'name' => strtoupper($this->models->first()->name), + ]) ->allowedFilters('name') ->get(); @@ -55,8 +86,8 @@ $model2 = TestModel::create(['name' => 'uvwxyz']); $results = createQueryFromFilterRequest([ - 'name' => 'abc,xyz', - ]) + 'name' => 'abc,xyz', + ]) ->allowedFilters('name') ->get(); @@ -66,8 +97,8 @@ it('can filter models and return an empty collection', function () { $models = createQueryFromFilterRequest([ - 'name' => 'None existing first name', - ]) + 'name' => 'None existing first name', + ]) ->allowedFilters('name') ->get(); @@ -91,6 +122,10 @@ }); it('specifies escape character in supported databases', function (string $dbDriver) { + if ($dbDriver === 'mariadb' && ! in_array('mariadb', DB::supportedDrivers())) { + $this->markTestSkipped('mariadb driver not supported in the installed version of illuminate/database dependency'); + } + $fakeConnection = "test_{$dbDriver}"; DB::connectUsing($fakeConnection, [ @@ -99,6 +134,7 @@ ]); DB::usingConnection($fakeConnection, function () use ($dbDriver) { + $request = new Request([ 'filter' => ['name' => 'to_find'], ]); @@ -107,15 +143,19 @@ ->allowedFilters('name', 'id') ->toSql(); - expect($queryBuilderSql)->when(in_array($dbDriver, ["sqlite","pgsql","sqlsrv"]), fn (Expectation $query) => $query->toContain("ESCAPE '\'")); - expect($queryBuilderSql)->when($dbDriver === 'mysql', fn (Expectation $query) => $query->not->toContain("ESCAPE '\'")); + expect($queryBuilderSql)->when(in_array($dbDriver, ["sqlite", "sqlsrv"]), fn ( + Expectation $query + ) => $query->toContain("ESCAPE '\'")); + expect($queryBuilderSql)->when(in_array($dbDriver, ["mysql", "mariadb", "pgsql"]), fn ( + Expectation $query + ) => $query->not->toContain("ESCAPE '\'")); }); -})->with(['sqlite', 'mysql', 'pgsql', 'sqlsrv']); +})->with(['sqlite', 'mysql', 'pgsql', 'sqlsrv', 'mariadb']); it('can filter results based on the existence of a property in an array', function () { $results = createQueryFromFilterRequest([ - 'id' => '1,2', - ]) + 'id' => '1,2', + ]) ->allowedFilters(AllowedFilter::exact('id')) ->get(); @@ -125,8 +165,8 @@ it('ignores empty values in an array partial filter', function () { $results = createQueryFromFilterRequest([ - 'id' => '2,', - ]) + 'id' => '2,', + ]) ->allowedFilters(AllowedFilter::partial('id')) ->get(); @@ -136,8 +176,8 @@ it('ignores an empty array partial filter', function () { $results = createQueryFromFilterRequest([ - 'id' => ',,', - ]) + 'id' => ',,', + ]) ->allowedFilters(AllowedFilter::partial('id')) ->get(); @@ -148,8 +188,8 @@ DB::enableQueryLog(); createQueryFromFilterRequest([ - 'id' => [0], - ]) + 'id' => [0], + ]) ->allowedFilters(AllowedFilter::partial('id')) ->get(); @@ -160,8 +200,8 @@ DB::enableQueryLog(); createQueryFromFilterRequest([ - 'id' => [0], - ]) + 'id' => [0], + ]) ->allowedFilters(AllowedFilter::beginsWithStrict('id')) ->get(); @@ -172,8 +212,8 @@ DB::enableQueryLog(); createQueryFromFilterRequest([ - 'id' => [0], - ]) + 'id' => [0], + ]) ->allowedFilters(AllowedFilter::endsWithStrict('id')) ->get(); @@ -225,8 +265,8 @@ ->get(); $modelsResult = createQueryFromFilterRequest([ - 'id' => $testModel->id, - ]) + 'id' => $testModel->id, + ]) ->allowedFilters(AllowedFilter::exact('id')) ->get(); @@ -237,14 +277,95 @@ $testModel = TestModel::create(['name' => 'John Testing Doe']); $modelsResult = createQueryFromFilterRequest([ - 'name' => ' Testing ', - ]) + 'name' => ' Testing ', + ]) ->allowedFilters(AllowedFilter::exact('name')) ->get(); expect($modelsResult)->toHaveCount(0); }); +it('can filter results by belongs to', function () { + $relatedModel = RelatedModel::create(['name' => 'John Related Doe', 'test_model_id' => 0]); + $nestedModel = NestedRelatedModel::create(['name' => 'John Nested Doe', 'related_model_id' => $relatedModel->id]); + + $modelsResult = createQueryFromFilterRequest(['relatedModel' => $relatedModel->id], NestedRelatedModel::class) + ->allowedFilters(AllowedFilter::belongsTo('relatedModel')) + ->get(); + + expect($modelsResult)->toHaveCount(1); +}); + +it('can filter results by belongs to no match', function () { + $relatedModel = RelatedModel::create(['name' => 'John Related Doe', 'test_model_id' => 0]); + $nestedModel = NestedRelatedModel::create(['name' => 'John Nested Doe', 'related_model_id' => $relatedModel->id + 1]); + + $modelsResult = createQueryFromFilterRequest(['relatedModel' => $relatedModel->id], NestedRelatedModel::class) + ->allowedFilters(AllowedFilter::belongsTo('relatedModel')) + ->get(); + + expect($modelsResult)->toHaveCount(0); +}); + +it('can filter results by belongs multiple', function () { + $relatedModel1 = RelatedModel::create(['name' => 'John Related Doe 1', 'test_model_id' => 0]); + $nestedModel1 = NestedRelatedModel::create(['name' => 'John Nested Doe 1', 'related_model_id' => $relatedModel1->id]); + $relatedModel2 = RelatedModel::create(['name' => 'John Related Doe 2', 'test_model_id' => 0]); + $nestedModel2 = NestedRelatedModel::create(['name' => 'John Nested Doe 2', 'related_model_id' => $relatedModel2->id]); + + $modelsResult = createQueryFromFilterRequest(['relatedModel' => $relatedModel1->id.','.$relatedModel2->id], NestedRelatedModel::class) + ->allowedFilters(AllowedFilter::belongsTo('relatedModel')) + ->get(); + + expect($modelsResult)->toHaveCount(2); +}); + +it('can filter results by belongs multiple with different internal name', function () { + $relatedModel1 = RelatedModel::create(['name' => 'John Related Doe 1', 'test_model_id' => 0]); + $nestedModel1 = NestedRelatedModel::create(['name' => 'John Nested Doe 1', 'related_model_id' => $relatedModel1->id]); + $relatedModel2 = RelatedModel::create(['name' => 'John Related Doe 2', 'test_model_id' => 0]); + $nestedModel2 = NestedRelatedModel::create(['name' => 'John Nested Doe 2', 'related_model_id' => $relatedModel2->id]); + + $modelsResult = createQueryFromFilterRequest(['testFilter' => $relatedModel1->id.','.$relatedModel2->id], NestedRelatedModel::class) + ->allowedFilters(AllowedFilter::belongsTo('testFilter', 'relatedModel')) + ->get(); + + expect($modelsResult)->toHaveCount(2); +}); + +it('can filter results by belongs multiple with different internal name and nested model', function () { + $testModel1 = TestModel::create(['name' => 'John Test Doe 1']); + $relatedModel1 = RelatedModel::create(['name' => 'John Related Doe 1', 'test_model_id' => $testModel1->id]); + $nestedModel1 = NestedRelatedModel::create(['name' => 'John Nested Doe 1', 'related_model_id' => $relatedModel1->id]); + $testModel2 = TestModel::create(['name' => 'John Test Doe 2']); + $relatedModel2 = RelatedModel::create(['name' => 'John Related Doe 2', 'test_model_id' => $testModel2->id]); + $nestedModel2 = NestedRelatedModel::create(['name' => 'John Nested Doe 2', 'related_model_id' => $relatedModel2->id]); + + $modelsResult = createQueryFromFilterRequest(['test_filter' => $testModel1->id.','.$testModel2->id], NestedRelatedModel::class) + ->allowedFilters(AllowedFilter::belongsTo('test_filter', 'relatedModel.testModel')) + ->get(); + + expect($modelsResult)->toHaveCount(2); +}); + +it('throws an exception when trying to filter by belongs to with an inexistent relation', function ($relationName, $exceptionClass) { + $this->expectException($exceptionClass); + + $modelsResult = createQueryFromFilterRequest(['test_filter' => 1], RelatedModel::class) + ->allowedFilters(AllowedFilter::belongsTo('test_filter', $relationName)) + ->get(); + +})->with([ + ['inexistentRelation', \BadMethodCallException::class], + ['testModel.inexistentRelation', \BadMethodCallException::class], // existing 'testModel' belongsTo relation + ['inexistentRelation.inexistentRelation', \BadMethodCallException::class], + ['getTable', \Illuminate\Database\Eloquent\RelationNotFoundException::class], + ['testModel.getTable', \Illuminate\Database\Eloquent\RelationNotFoundException::class], // existing 'testModel' belongsTo relation + ['getTable.getTable', \Illuminate\Database\Eloquent\RelationNotFoundException::class], + ['nestedRelatedModels', \Illuminate\Database\Eloquent\RelationNotFoundException::class], // existing 'nestedRelatedModels' relation but not a belongsTo relation + ['testModel.relatedModels', \Illuminate\Database\Eloquent\RelationNotFoundException::class], // existing 'testModel' belongsTo relation and existing 'relatedModels' relation but not a belongsTo relation +]); + it('can filter results by scope', function () { $testModel = TestModel::create(['name' => 'John Testing Doe']); @@ -322,8 +443,8 @@ public function __invoke(Builder $query, $value, string $property): Builder }; $modelResult = createQueryFromFilterRequest([ - 'custom_name' => $testModel->name, - ]) + 'custom_name' => $testModel->name, + ]) ->allowedFilters(AllowedFilter::custom('custom_name', $filterClass)) ->first(); @@ -335,8 +456,8 @@ public function __invoke(Builder $query, $value, string $property): Builder $model2 = TestModel::create(['name' => 'abcdef']); $results = createQueryFromFilterRequest([ - 'name' => 'abc', - ]) + 'name' => 'abc', + ]) ->allowedFilters('name', AllowedFilter::exact('id')) ->get(); @@ -349,8 +470,8 @@ public function __invoke(Builder $query, $value, string $property): Builder $model2 = TestModel::create(['name' => 'abcdef']); $results = createQueryFromFilterRequest([ - 'name' => 'abc', - ]) + 'name' => 'abc', + ]) ->allowedFilters(['name', AllowedFilter::exact('id')]) ->get(); @@ -363,9 +484,9 @@ public function __invoke(Builder $query, $value, string $property): Builder $model2 = TestModel::create(['name' => 'abcdef']); $results = createQueryFromFilterRequest([ - 'name' => 'abc', - 'id' => "1,{$model1->id}", - ]) + 'name' => 'abc', + 'id' => "1,{$model1->id}", + ]) ->allowedFilters('name', AllowedFilter::exact('id')) ->get(); @@ -408,8 +529,8 @@ public function __invoke(Builder $query, $value, string $property): Builder TestModel::create(['name' => 'abcdef']); $results = createQueryFromFilterRequest([ - '*' => '*', - ]) + '*' => '*', + ]) ->allowedFilters('name', AllowedFilter::custom('*', $customFilter)) ->get(); @@ -438,8 +559,8 @@ public function __invoke(Builder $query, $value, string $property): Builder it('should not apply a filter if the supplied value is ignored', function () { $models = createQueryFromFilterRequest([ - 'name' => '-1', - ]) + 'name' => '-1', + ]) ->allowedFilters(AllowedFilter::exact('name')->ignore('-1')) ->get(); @@ -451,8 +572,8 @@ public function __invoke(Builder $query, $value, string $property): Builder TestModel::create(['name' => 'John Deer']); $models = createQueryFromFilterRequest([ - 'name' => 'John Deer,John Doe', - ]) + 'name' => 'John Deer,John Doe', + ]) ->allowedFilters(AllowedFilter::exact('name')->ignore('John Doe')) ->get(); @@ -464,8 +585,8 @@ public function __invoke(Builder $query, $value, string $property): Builder TestModel::create(['id' => 7, 'name' => 'John Deer']); $models = createQueryFromFilterRequest([ - 'id' => [ 7, 6 ], - ]) + 'id' => [7, 6], + ]) ->allowedFilters(AllowedFilter::exact('id')->ignore(6)) ->get(); @@ -491,8 +612,8 @@ public function __invoke(Builder $query, $value, string $property): Builder TestModel::create(['name' => 'abcdef']); $models = createQueryFromFilterRequest([ - 'nickname' => 'abcdef', - ]) + 'nickname' => 'abcdef', + ]) ->allowedFilters($filter) ->get(); @@ -527,8 +648,8 @@ public function __invoke(Builder $query, $value, string $property): Builder TestModel::create(['name' => 'UniqueJohn Deer']); $models = createQueryFromFilterRequest([ - 'name' => 'UniqueDoe', - ]) + 'name' => 'UniqueDoe', + ]) ->allowedFilters(AllowedFilter::partial('name')->default('UniqueJohn')) ->get(); @@ -551,8 +672,8 @@ public function __invoke(Builder $query, $value, string $property): Builder TestModel::create(['name' => 'UniqueJohn Deer']); $models = createQueryFromFilterRequest([ - 'name' => 'UniqueJohn Deer', - ]) + 'name' => 'UniqueJohn Deer', + ]) ->allowedFilters(AllowedFilter::exact('name')->default(null)) ->get(); @@ -560,15 +681,18 @@ public function __invoke(Builder $query, $value, string $property): Builder }); it('should apply a nullable filter when filter exists and is null', function () { + DB::enableQueryLog(); + TestModel::create(['name' => null]); TestModel::create(['name' => 'UniqueJohn Deer']); $models = createQueryFromFilterRequest([ - 'name' => null, - ]) + 'name' => null, + ]) ->allowedFilters(AllowedFilter::exact('name')->nullable()) ->get(); + $this->assertQueryLogContains("select * from `test_models` where `test_models`.`name` is null"); expect($models->count())->toEqual(1); }); @@ -577,8 +701,8 @@ public function __invoke(Builder $query, $value, string $property): Builder TestModel::create(['name' => 'UniqueJohn Deer']); $models = createQueryFromFilterRequest([ - 'name' => 'UniqueJohn Deer', - ]) + 'name' => 'UniqueJohn Deer', + ]) ->allowedFilters(AllowedFilter::exact('name')->nullable()) ->get(); @@ -590,8 +714,8 @@ public function __invoke(Builder $query, $value, string $property): Builder $filterWithDefault = AllowedFilter::exact('name')->default('some default value'); $models = createQueryFromFilterRequest([ - 'name' => 'John Doe', - ]) + 'name' => 'John Doe', + ]) ->allowedFilters($filterWithDefault->unsetDefault()) ->get(); @@ -611,10 +735,10 @@ public function __invoke(Builder $query, $value, string $property): Builder TestModel::create(['name' => 'John Doe']); $models = createQueryFromFilterRequest(['conditions' => [[ - 'attribute' => 'name', - 'operator' => '=', - 'value' => 'John Doe', - ]]]) + 'attribute' => 'name', + 'operator' => '=', + 'value' => 'John Doe', + ]]]) ->allowedFilters(AllowedFilter::callback('conditions', function ($query, $conditions) { foreach ($conditions as $condition) { $query->where( @@ -635,25 +759,138 @@ public function __invoke(Builder $query, $value, string $property): Builder // First use default delimiter $models = createQueryFromFilterRequest([ - 'ref_id' => 'h4S4MG3(+>azv4z/I,>XZII/Q1On', - ]) + 'ref_id' => 'h4S4MG3(+>azv4z/I,>XZII/Q1On', + ]) ->allowedFilters(AllowedFilter::exact('ref_id', 'name', true)) ->get(); expect($models->count())->toEqual(2); // Custom delimiter $models = createQueryFromFilterRequest([ - 'ref_id' => 'h4S4MG3(+>azv4z/I|>XZII/Q1On', - ]) + 'ref_id' => 'h4S4MG3(+>azv4z/I|>XZII/Q1On', + ]) ->allowedFilters(AllowedFilter::exact('ref_id', 'name', true, '|')) ->get(); expect($models->count())->toEqual(2); // Custom delimiter, but default in request $models = createQueryFromFilterRequest([ - 'ref_id' => 'h4S4MG3(+>azv4z/I,>XZII/Q1On', - ]) + 'ref_id' => 'h4S4MG3(+>azv4z/I,>XZII/Q1On', + ]) ->allowedFilters(AllowedFilter::exact('ref_id', 'name', true, '|')) ->get(); expect($models->count())->toEqual(0); }); + +it('can filter name with equal operator filter', function () { + TestModel::create(['name' => 'John Doe']); + + $results = createQueryFromFilterRequest([ + 'name' => 'John Doe', + ]) + ->allowedFilters(AllowedFilter::operator('name', FilterOperator::EQUAL)) + ->get(); + + expect($results)->toHaveCount(1); +}); + +it('can filter name with not equal operator filter', function () { + TestModel::create(['name' => 'John Doe']); + + $results = createQueryFromFilterRequest([ + 'name' => 'John Doe', + ]) + ->allowedFilters(AllowedFilter::operator('name', FilterOperator::NOT_EQUAL)) + ->get(); + + expect($results)->toHaveCount(5); +}); + +it('can filter salary with greater than operator filter', function () { + TestModel::create(['salary' => 5000]); + + $results = createQueryFromFilterRequest([ + 'salary' => 3000, + ]) + ->allowedFilters(AllowedFilter::operator('salary', FilterOperator::GREATER_THAN)) + ->get(); + + expect($results)->toHaveCount(1); +}); + +it('can filter salary with less than operator filter', function () { + TestModel::create(['salary' => 5000]); + + $results = createQueryFromFilterRequest([ + 'salary' => 7000, + ]) + ->allowedFilters(AllowedFilter::operator('salary', FilterOperator::LESS_THAN)) + ->get(); + + expect($results)->toHaveCount(1); +}); + +it('can filter salary with greater than or equal operator filter', function () { + TestModel::create(['salary' => 5000]); + + $results = createQueryFromFilterRequest([ + 'salary' => 3000, + ]) + ->allowedFilters(AllowedFilter::operator('salary', FilterOperator::GREATER_THAN_OR_EQUAL)) + ->get(); + + expect($results)->toHaveCount(1); +}); + +it('can filter salary with less than or equal operator filter', function () { + TestModel::create(['salary' => 5000]); + + $results = createQueryFromFilterRequest([ + 'salary' => 7000, + ]) + ->allowedFilters(AllowedFilter::operator('salary', FilterOperator::LESS_THAN_OR_EQUAL)) + ->get(); + + expect($results)->toHaveCount(1); +}); + +it('can filter array of names with equal operator filter', function () { + TestModel::create(['name' => 'John Doe']); + TestModel::create(['name' => 'Max Doe']); + + $results = createQueryFromFilterRequest([ + 'name' => 'John Doe,Max Doe', + ]) + ->allowedFilters(AllowedFilter::operator('name', FilterOperator::EQUAL, 'or')) + ->get(); + + expect($results)->toHaveCount(2); +}); + +it('can filter salary with dynamic operator filter', function () { + TestModel::create(['salary' => 5000]); + TestModel::create(['salary' => 2000]); + + $results = createQueryFromFilterRequest([ + 'salary' => '>2000', + ]) + ->allowedFilters(AllowedFilter::operator('salary', FilterOperator::DYNAMIC)) + ->get(); + + expect($results)->toHaveCount(1); +}); + +it('can filter salary with dynamic array operator filter', function () { + TestModel::create(['salary' => 1000]); + TestModel::create(['salary' => 2000]); + TestModel::create(['salary' => 3000]); + TestModel::create(['salary' => 4000]); + + $results = createQueryFromFilterRequest([ + 'salary' => '>1000,<4000', + ]) + ->allowedFilters(AllowedFilter::operator('salary', FilterOperator::DYNAMIC)) + ->get(); + + expect($results)->toHaveCount(2); +}); diff --git a/tests/RelationFilterTest.php b/tests/RelationFilterTest.php index c6144482..31902883 100644 --- a/tests/RelationFilterTest.php +++ b/tests/RelationFilterTest.php @@ -1,6 +1,7 @@ toContain('LOWER(`relatedModels`.`name`) LIKE ?'); }); + +it('can disable operator filtering based on related model properties', function () { + $addRelationConstraint = false; + + $sql = createQueryFromFilterRequest([ + 'relatedModels.name' => $this->models->first()->name, + ]) + ->allowedFilters(AllowedFilter::operator('relatedModels.name', FilterOperator::EQUAL, 'and', null, $addRelationConstraint)) + ->toSql(); + + expect($sql)->toContain('`relatedModels`.`name` = ?'); +}); diff --git a/tests/TestCase.php b/tests/TestCase.php index 62427dc3..591c22b6 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -37,6 +37,8 @@ protected function setUpDatabase(Application $app) $table->increments('id'); $table->timestamps(); $table->string('name')->nullable(); + $table->string('full_name')->nullable(); + $table->double('salary')->nullable(); $table->boolean('is_visible')->default(true); }); @@ -61,6 +63,7 @@ protected function setUpDatabase(Application $app) $table->increments('id'); $table->integer('test_model_id'); $table->string('name'); + $table->string('full_name')->nullable(); }); $app['db']->connection()->getSchemaBuilder()->create('nested_related_models', function (Blueprint $table) { @@ -91,7 +94,7 @@ protected function setUpDatabase(Application $app) protected function getPackageProviders($app) { return [ - RayServiceProvider::class, + // RayServiceProvider::class, QueryBuilderServiceProvider::class, ]; } diff --git a/tests/TestClasses/Models/TestModel.php b/tests/TestClasses/Models/TestModel.php index 16797919..e182b028 100644 --- a/tests/TestClasses/Models/TestModel.php +++ b/tests/TestClasses/Models/TestModel.php @@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasManyThrough; use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Support\Carbon; @@ -27,6 +28,18 @@ public function relatedModel(): BelongsTo return $this->belongsTo(RelatedModel::class); } + public function nestedRelatedModels(): HasManyThrough + { + return $this->hasManyThrough( + NestedRelatedModel::class, // Target model + RelatedModel::class, // Intermediate model + 'test_model_id', // Foreign key on RelatedModel + 'related_model_id', // Foreign key on NestedRelatedModel + 'id', // Local key on TestModel + 'id' // Local key on RelatedModel + ); + } + public function otherRelatedModels(): HasMany { return $this->hasMany(RelatedModel::class);