From 7eb236510cf40776e05c87db8b30481e587b6c9d Mon Sep 17 00:00:00 2001 From: Sascha Greuel Date: Wed, 17 Dec 2025 14:43:10 +0100 Subject: [PATCH 1/5] [Release] 0.11.0 Signed-off-by: Sascha Greuel --- .editorconfig | 450 +++++++++++++++++++++++ .gitattributes | 1 + .github/FUNDING.yml | 4 +- .github/ISSUE_TEMPLATE/bug.md | 96 ++--- .github/ISSUE_TEMPLATE/documentation.md | 26 +- .github/ISSUE_TEMPLATE/feature.md | 42 +-- .github/PULL_REQUEST_TEMPLATE.md | 44 +-- .github/workflows/Test.yml | 12 +- .github/workflows/codestyle.yml | 4 +- .gitignore | 14 +- .php-cs-fixer.dist.php | 70 +++- CHANGELOG.md | 255 +++++++------ CODE_OF_CONDUCT.md | 146 ++++---- LICENSE.md | 44 +-- README.md | 16 +- composer.json | 16 +- phpcs.xml | 9 +- phpstan.neon.dist | 6 + src/AccessHelper.php | 48 ++- src/Filters/AbstractFilter.php | 18 +- src/Filters/IndexFilter.php | 5 +- src/Filters/IndexesFilter.php | 7 +- src/Filters/QueryMatchFilter.php | 25 +- src/Filters/QueryResultFilter.php | 10 +- src/Filters/RecursiveFilter.php | 9 +- src/Filters/SliceFilter.php | 22 +- src/JSONPath.php | 81 ++-- src/JSONPathException.php | 2 + src/JSONPathLexer.php | 90 +++-- src/JSONPathToken.php | 85 ++--- src/TokenType.php | 21 ++ tests/AccessHelperTest.php | 212 +++++++++++ tests/JSONPathArrayAccessTest.php | 3 +- tests/JSONPathArrayTest.php | 2 +- tests/JSONPathDashedIndexTest.php | 6 +- tests/JSONPathLexerTest.php | 113 +++--- tests/JSONPathSliceAccessTest.php | 7 +- tests/JSONPathTest.php | 225 +++++++----- tests/JSONPathTestClass.php | 1 + tests/JSONPathTokenTest.php | 55 +++ tests/QueryResultFilterTest.php | 135 +++++++ tests/QueryTest.php | 9 +- tests/SliceFilterTest.php | 71 ++++ tests/data/baselineFailedQueries.txt | 60 +-- tests/data/conferences.json | 100 ++--- tests/data/example.json | 74 ++-- tests/data/extra.json | 20 +- tests/data/indexed-object.json | 64 ++-- tests/data/locations.json | 24 +- tests/data/numerical-indexes-array.json | 28 +- tests/data/numerical-indexes-object.json | 28 +- tests/data/simple-integers.json | 32 +- tests/data/with-dots.json | 30 +- tests/data/with-slashes.json | 20 +- 54 files changed, 2039 insertions(+), 958 deletions(-) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 phpstan.neon.dist create mode 100644 src/TokenType.php create mode 100644 tests/AccessHelperTest.php create mode 100644 tests/JSONPathTokenTest.php create mode 100644 tests/QueryResultFilterTest.php create mode 100644 tests/SliceFilterTest.php diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..0f6c1d8 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,450 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 8 +indent_style = tab +insert_final_newline = true +max_line_length = 120 +tab_width = 8 +trim_trailing_whitespace = false +ij_continuation_indent_size = 8 +ij_formatter_off_tag = @formatter:off +ij_formatter_on_tag = @formatter:on +ij_formatter_tags_enabled = false +ij_smart_tabs = true +ij_wrap_on_typing = false + +[*.css] +indent_size = 4 +tab_width = 4 +ij_continuation_indent_size = 4 +ij_css_align_closing_brace_with_properties = false +ij_css_blank_lines_around_nested_selector = 1 +ij_css_blank_lines_between_blocks = 1 +ij_css_brace_placement = end_of_line +ij_css_enforce_quotes_on_format = false +ij_css_hex_color_long_format = false +ij_css_hex_color_lower_case = false +ij_css_hex_color_short_format = false +ij_css_hex_color_upper_case = false +ij_css_keep_blank_lines_in_code = 2 +ij_css_keep_indents_on_empty_lines = true +ij_css_keep_single_line_blocks = false +ij_css_properties_order = font,font-family,font-size,font-weight,font-style,font-variant,font-size-adjust,font-stretch,line-height,position,z-index,top,right,bottom,left,display,visibility,float,clear,overflow,overflow-x,overflow-y,clip,zoom,align-content,align-items,align-self,flex,flex-flow,flex-basis,flex-direction,flex-grow,flex-shrink,flex-wrap,justify-content,order,box-sizing,width,min-width,max-width,height,min-height,max-height,margin,margin-top,margin-right,margin-bottom,margin-left,padding,padding-top,padding-right,padding-bottom,padding-left,table-layout,empty-cells,caption-side,border-spacing,border-collapse,list-style,list-style-position,list-style-type,list-style-image,content,quotes,counter-reset,counter-increment,resize,cursor,user-select,nav-index,nav-up,nav-right,nav-down,nav-left,transition,transition-delay,transition-timing-function,transition-duration,transition-property,transform,transform-origin,animation,animation-name,animation-duration,animation-play-state,animation-timing-function,animation-delay,animation-iteration-count,animation-direction,text-align,text-align-last,vertical-align,white-space,text-decoration,text-emphasis,text-emphasis-color,text-emphasis-style,text-emphasis-position,text-indent,text-justify,letter-spacing,word-spacing,text-outline,text-transform,text-wrap,text-overflow,text-overflow-ellipsis,text-overflow-mode,word-wrap,word-break,tab-size,hyphens,pointer-events,opacity,color,border,border-width,border-style,border-color,border-top,border-top-width,border-top-style,border-top-color,border-right,border-right-width,border-right-style,border-right-color,border-bottom,border-bottom-width,border-bottom-style,border-bottom-color,border-left,border-left-width,border-left-style,border-left-color,border-radius,border-top-left-radius,border-top-right-radius,border-bottom-right-radius,border-bottom-left-radius,border-image,border-image-source,border-image-slice,border-image-width,border-image-outset,border-image-repeat,outline,outline-width,outline-style,outline-color,outline-offset,background,background-color,background-image,background-repeat,background-attachment,background-position,background-position-x,background-position-y,background-clip,background-origin,background-size,box-decoration-break,box-shadow,text-shadow +ij_css_space_after_colon = true +ij_css_space_before_opening_brace = true +ij_css_use_double_quotes = true +ij_css_value_alignment = do_not_align + +[{*.ant,*.fxml,*.jhm,*.jnlp,*.jrxml,*.rng,*.tld,*.wsdl,*.xml,*.xsd,*.xsl,*.xslt,*.xul,phpunit.xml.dist}] +ij_xml_align_attributes = true +ij_xml_align_text = false +ij_xml_attribute_wrap = off +ij_xml_block_comment_at_first_column = true +ij_xml_keep_blank_lines = 2 +ij_xml_keep_indents_on_empty_lines = true +ij_xml_keep_line_breaks = true +ij_xml_keep_line_breaks_in_text = true +ij_xml_keep_whitespaces = false +ij_xml_keep_whitespaces_around_cdata = preserve +ij_xml_keep_whitespaces_inside_cdata = false +ij_xml_line_comment_at_first_column = true +ij_xml_space_after_tag_name = false +ij_xml_space_around_equals_in_attribute = false +ij_xml_space_inside_empty_tag = true +ij_xml_text_wrap = normal + +[{*.cjs,*.js}] +ij_visual_guides = 120 +ij_javascript_align_imports = false +ij_javascript_align_multiline_array_initializer_expression = false +ij_javascript_align_multiline_binary_operation = false +ij_javascript_align_multiline_chained_methods = false +ij_javascript_align_multiline_extends_list = false +ij_javascript_align_multiline_for = true +ij_javascript_align_multiline_parameters = true +ij_javascript_align_multiline_parameters_in_calls = false +ij_javascript_align_multiline_ternary_operation = false +ij_javascript_align_object_properties = 0 +ij_javascript_align_union_types = false +ij_javascript_align_var_statements = 0 +ij_javascript_array_initializer_new_line_after_left_brace = true +ij_javascript_array_initializer_right_brace_on_new_line = true +ij_javascript_array_initializer_wrap = on_every_item +ij_javascript_assignment_wrap = off +ij_javascript_binary_operation_sign_on_next_line = false +ij_javascript_binary_operation_wrap = off +ij_javascript_blacklist_imports = rxjs/Rx,node_modules/**,**/node_modules/**,@angular/material,@angular/material/typings/** +ij_javascript_blank_lines_after_imports = 1 +ij_javascript_blank_lines_around_class = 1 +ij_javascript_blank_lines_around_field = 0 +ij_javascript_blank_lines_around_function = 1 +ij_javascript_blank_lines_around_method = 1 +ij_javascript_block_brace_style = end_of_line +ij_javascript_call_parameters_new_line_after_left_paren = true +ij_javascript_call_parameters_right_paren_on_new_line = true +ij_javascript_call_parameters_wrap = on_every_item +ij_javascript_catch_on_new_line = true +ij_javascript_chained_call_dot_on_new_line = true +ij_javascript_class_brace_style = end_of_line +ij_javascript_comma_on_new_line = false +ij_javascript_do_while_brace_force = if_multiline +ij_javascript_else_on_new_line = true +ij_javascript_enforce_trailing_comma = remove +ij_javascript_extends_keyword_wrap = off +ij_javascript_extends_list_wrap = off +ij_javascript_field_prefix = _ +ij_javascript_file_name_style = relaxed +ij_javascript_finally_on_new_line = true +ij_javascript_for_brace_force = never +ij_javascript_for_statement_new_line_after_left_paren = false +ij_javascript_for_statement_right_paren_on_new_line = false +ij_javascript_for_statement_wrap = off +ij_javascript_force_quote_style = true +ij_javascript_force_semicolon_style = true +ij_javascript_function_expression_brace_style = end_of_line +ij_javascript_if_brace_force = if_multiline +ij_javascript_import_merge_members = global +ij_javascript_import_prefer_absolute_path = global +ij_javascript_import_sort_members = true +ij_javascript_import_sort_module_name = false +ij_javascript_import_use_node_resolution = true +ij_javascript_imports_wrap = on_every_item +ij_javascript_indent_case_from_switch = true +ij_javascript_indent_chained_calls = true +ij_javascript_indent_package_children = 0 +ij_javascript_jsx_attribute_value = braces +ij_javascript_keep_blank_lines_in_code = 1 +ij_javascript_keep_first_column_comment = true +ij_javascript_keep_indents_on_empty_lines = true +ij_javascript_keep_line_breaks = false +ij_javascript_keep_simple_blocks_in_one_line = true +ij_javascript_keep_simple_methods_in_one_line = true +ij_javascript_line_comment_add_space = true +ij_javascript_line_comment_at_first_column = false +ij_javascript_method_brace_style = end_of_line +ij_javascript_method_call_chain_wrap = off +ij_javascript_method_parameters_new_line_after_left_paren = false +ij_javascript_method_parameters_right_paren_on_new_line = false +ij_javascript_method_parameters_wrap = off +ij_javascript_object_literal_wrap = split_into_lines +ij_javascript_parentheses_expression_new_line_after_left_paren = false +ij_javascript_parentheses_expression_right_paren_on_new_line = false +ij_javascript_place_assignment_sign_on_next_line = false +ij_javascript_prefer_as_type_cast = false +ij_javascript_prefer_explicit_types_function_expression_returns = false +ij_javascript_prefer_explicit_types_function_returns = false +ij_javascript_prefer_explicit_types_vars_fields = false +ij_javascript_prefer_parameters_wrap = false +ij_javascript_reformat_c_style_comments = false +ij_javascript_space_after_colon = true +ij_javascript_space_after_comma = true +ij_javascript_space_after_dots_in_rest_parameter = false +ij_javascript_space_after_generator_mult = true +ij_javascript_space_after_property_colon = true +ij_javascript_space_after_quest = true +ij_javascript_space_after_type_colon = true +ij_javascript_space_after_unary_not = false +ij_javascript_space_before_async_arrow_lparen = true +ij_javascript_space_before_catch_keyword = true +ij_javascript_space_before_catch_left_brace = true +ij_javascript_space_before_catch_parentheses = true +ij_javascript_space_before_class_lbrace = true +ij_javascript_space_before_class_left_brace = true +ij_javascript_space_before_colon = true +ij_javascript_space_before_comma = false +ij_javascript_space_before_do_left_brace = true +ij_javascript_space_before_else_keyword = true +ij_javascript_space_before_else_left_brace = true +ij_javascript_space_before_finally_keyword = true +ij_javascript_space_before_finally_left_brace = true +ij_javascript_space_before_for_left_brace = true +ij_javascript_space_before_for_parentheses = true +ij_javascript_space_before_for_semicolon = false +ij_javascript_space_before_function_left_parenth = true +ij_javascript_space_before_generator_mult = false +ij_javascript_space_before_if_left_brace = true +ij_javascript_space_before_if_parentheses = true +ij_javascript_space_before_method_call_parentheses = false +ij_javascript_space_before_method_left_brace = true +ij_javascript_space_before_method_parentheses = false +ij_javascript_space_before_property_colon = false +ij_javascript_space_before_quest = true +ij_javascript_space_before_switch_left_brace = true +ij_javascript_space_before_switch_parentheses = true +ij_javascript_space_before_try_left_brace = true +ij_javascript_space_before_type_colon = false +ij_javascript_space_before_unary_not = false +ij_javascript_space_before_while_keyword = true +ij_javascript_space_before_while_left_brace = true +ij_javascript_space_before_while_parentheses = true +ij_javascript_spaces_around_additive_operators = true +ij_javascript_spaces_around_arrow_function_operator = true +ij_javascript_spaces_around_assignment_operators = true +ij_javascript_spaces_around_bitwise_operators = true +ij_javascript_spaces_around_equality_operators = true +ij_javascript_spaces_around_logical_operators = true +ij_javascript_spaces_around_multiplicative_operators = true +ij_javascript_spaces_around_relational_operators = true +ij_javascript_spaces_around_shift_operators = true +ij_javascript_spaces_around_unary_operator = false +ij_javascript_spaces_within_array_initializer_brackets = false +ij_javascript_spaces_within_brackets = false +ij_javascript_spaces_within_catch_parentheses = false +ij_javascript_spaces_within_for_parentheses = false +ij_javascript_spaces_within_if_parentheses = false +ij_javascript_spaces_within_imports = false +ij_javascript_spaces_within_interpolation_expressions = false +ij_javascript_spaces_within_method_call_parentheses = false +ij_javascript_spaces_within_method_parentheses = false +ij_javascript_spaces_within_object_literal_braces = false +ij_javascript_spaces_within_object_type_braces = true +ij_javascript_spaces_within_parentheses = false +ij_javascript_spaces_within_switch_parentheses = false +ij_javascript_spaces_within_type_assertion = false +ij_javascript_spaces_within_union_types = true +ij_javascript_spaces_within_while_parentheses = false +ij_javascript_special_else_if_treatment = true +ij_javascript_ternary_operation_signs_on_next_line = false +ij_javascript_ternary_operation_wrap = off +ij_javascript_union_types_wrap = on_every_item +ij_javascript_use_chained_calls_group_indents = false +ij_javascript_use_double_quotes = false +ij_javascript_use_explicit_js_extension = global +ij_javascript_use_path_mapping = always +ij_javascript_use_public_modifier = false +ij_javascript_use_semicolon_after_statement = true +ij_javascript_var_declaration_wrap = normal +ij_javascript_while_brace_force = if_multiline +ij_javascript_while_on_new_line = true +ij_javascript_wrap_comments = false + +[{*.ctp,*.hphp,*.inc,*.module,*.php,*.php4,*.php5,*.phtml}] +ij_php_align_assignments = false +ij_php_align_class_constants = false +ij_php_align_group_field_declarations = false +ij_php_align_inline_comments = false +ij_php_align_key_value_pairs = false +ij_php_align_multiline_array_initializer_expression = false +ij_php_align_multiline_binary_operation = false +ij_php_align_multiline_chained_methods = false +ij_php_align_multiline_extends_list = false +ij_php_align_multiline_for = true +ij_php_align_multiline_parameters = false +ij_php_align_multiline_parameters_in_calls = false +ij_php_align_multiline_ternary_operation = false +ij_php_align_phpdoc_comments = true +ij_php_align_phpdoc_param_names = true +ij_php_anonymous_brace_style = end_of_line +ij_php_api_weight = 28 +ij_php_array_initializer_new_line_after_left_brace = false +ij_php_array_initializer_right_brace_on_new_line = false +ij_php_array_initializer_wrap = on_every_item +ij_php_assignment_wrap = off +ij_php_author_weight = 28 +ij_php_binary_operation_sign_on_next_line = false +ij_php_binary_operation_wrap = off +ij_php_blank_lines_after_class_header = 0 +ij_php_blank_lines_after_function = 1 +ij_php_blank_lines_after_imports = 1 +ij_php_blank_lines_after_opening_tag = 0 +ij_php_blank_lines_after_package = 0 +ij_php_blank_lines_around_class = 1 +ij_php_blank_lines_around_constants = 1 +ij_php_blank_lines_around_field = 1 +ij_php_blank_lines_around_method = 1 +ij_php_blank_lines_before_class_end = 0 +ij_php_blank_lines_before_imports = 0 +ij_php_blank_lines_before_method_body = 0 +ij_php_blank_lines_before_package = 0 +ij_php_blank_lines_before_return_statement = 0 +ij_php_blank_lines_between_imports = 0 +ij_php_block_brace_style = end_of_line +ij_php_call_parameters_new_line_after_left_paren = false +ij_php_call_parameters_right_paren_on_new_line = false +ij_php_call_parameters_wrap = off +ij_php_catch_on_new_line = true +ij_php_category_weight = 28 +ij_php_class_brace_style = end_of_line +ij_php_comma_after_last_array_element = true +ij_php_concat_spaces = true +ij_php_copyright_weight = 28 +ij_php_deprecated_weight = 28 +ij_php_do_while_brace_force = always +ij_php_else_if_style = separate +ij_php_else_on_new_line = true +ij_php_example_weight = 28 +ij_php_extends_keyword_wrap = off +ij_php_extends_list_wrap = off +ij_php_fields_default_visibility = private +ij_php_filesource_weight = 28 +ij_php_finally_on_new_line = true +ij_php_for_brace_force = never +ij_php_for_statement_new_line_after_left_paren = false +ij_php_for_statement_right_paren_on_new_line = false +ij_php_for_statement_wrap = off +ij_php_force_short_declaration_array_style = true +ij_php_global_weight = 28 +ij_php_group_use_wrap = on_every_item +ij_php_if_brace_force = if_multiline +ij_php_if_lparen_on_next_line = false +ij_php_if_rparen_on_next_line = false +ij_php_ignore_weight = 28 +ij_php_import_sorting = alphabetic +ij_php_indent_break_from_case = true +ij_php_indent_case_from_switch = true +ij_php_indent_code_in_php_tags = false +ij_php_internal_weight = 28 +ij_php_keep_blank_lines_after_lbrace = 2 +ij_php_keep_blank_lines_before_right_brace = 0 +ij_php_keep_blank_lines_in_code = 1 +ij_php_keep_blank_lines_in_declarations = 1 +ij_php_keep_control_statement_in_one_line = true +ij_php_keep_first_column_comment = true +ij_php_keep_indents_on_empty_lines = true +ij_php_keep_line_breaks = false +ij_php_keep_rparen_and_lbrace_on_one_line = false +ij_php_keep_simple_methods_in_one_line = false +ij_php_lambda_brace_style = end_of_line +ij_php_license_weight = 28 +ij_php_line_comment_add_space = false +ij_php_line_comment_at_first_column = true +ij_php_link_weight = 28 +ij_php_lower_case_boolean_const = true +ij_php_lower_case_keywords = true +ij_php_lower_case_null_const = true +ij_php_method_brace_style = end_of_line +ij_php_method_call_chain_wrap = off +ij_php_method_parameters_new_line_after_left_paren = false +ij_php_method_parameters_right_paren_on_new_line = false +ij_php_method_parameters_wrap = off +ij_php_method_weight = 28 +ij_php_modifier_list_wrap = false +ij_php_multiline_chained_calls_semicolon_on_new_line = false +ij_php_namespace_brace_style = 1 +ij_php_new_line_after_php_opening_tag = false +ij_php_null_type_position = in_the_end +ij_php_package_weight = 28 +ij_php_param_weight = 0 +ij_php_parentheses_expression_new_line_after_left_paren = false +ij_php_parentheses_expression_right_paren_on_new_line = false +ij_php_phpdoc_blank_line_before_tags = true +ij_php_phpdoc_blank_lines_around_parameters = false +ij_php_phpdoc_keep_blank_lines = true +ij_php_phpdoc_param_spaces_between_name_and_description = 1 +ij_php_phpdoc_param_spaces_between_tag_and_type = 1 +ij_php_phpdoc_param_spaces_between_type_and_name = 1 +ij_php_phpdoc_use_fqcn = false +ij_php_phpdoc_wrap_long_lines = false +ij_php_place_assignment_sign_on_next_line = false +ij_php_place_parens_for_constructor = 0 +ij_php_property_read_weight = 28 +ij_php_property_weight = 28 +ij_php_property_write_weight = 28 +ij_php_return_type_on_new_line = false +ij_php_return_weight = 1 +ij_php_see_weight = 28 +ij_php_since_weight = 28 +ij_php_sort_phpdoc_elements = true +ij_php_space_after_colon = true +ij_php_space_after_colon_in_return_type = true +ij_php_space_after_comma = true +ij_php_space_after_for_semicolon = true +ij_php_space_after_quest = true +ij_php_space_after_type_cast = false +ij_php_space_after_unary_not = false +ij_php_space_before_array_initializer_left_brace = false +ij_php_space_before_catch_keyword = true +ij_php_space_before_catch_left_brace = true +ij_php_space_before_catch_parentheses = true +ij_php_space_before_class_left_brace = true +ij_php_space_before_closure_left_parenthesis = true +ij_php_space_before_colon = true +ij_php_space_before_colon_in_return_type = false +ij_php_space_before_comma = false +ij_php_space_before_do_left_brace = true +ij_php_space_before_else_keyword = true +ij_php_space_before_else_left_brace = true +ij_php_space_before_finally_keyword = true +ij_php_space_before_finally_left_brace = true +ij_php_space_before_for_left_brace = true +ij_php_space_before_for_parentheses = true +ij_php_space_before_for_semicolon = false +ij_php_space_before_if_left_brace = true +ij_php_space_before_if_parentheses = true +ij_php_space_before_method_call_parentheses = false +ij_php_space_before_method_left_brace = true +ij_php_space_before_method_parentheses = false +ij_php_space_before_quest = true +ij_php_space_before_short_closure_left_parenthesis = false +ij_php_space_before_switch_left_brace = true +ij_php_space_before_switch_parentheses = true +ij_php_space_before_try_left_brace = true +ij_php_space_before_unary_not = false +ij_php_space_before_while_keyword = true +ij_php_space_before_while_left_brace = true +ij_php_space_before_while_parentheses = true +ij_php_space_between_ternary_quest_and_colon = false +ij_php_spaces_around_additive_operators = true +ij_php_spaces_around_arrow = false +ij_php_spaces_around_assignment_in_declare = false +ij_php_spaces_around_assignment_operators = true +ij_php_spaces_around_bitwise_operators = true +ij_php_spaces_around_equality_operators = true +ij_php_spaces_around_logical_operators = true +ij_php_spaces_around_multiplicative_operators = true +ij_php_spaces_around_null_coalesce_operator = true +ij_php_spaces_around_relational_operators = true +ij_php_spaces_around_shift_operators = true +ij_php_spaces_around_unary_operator = false +ij_php_spaces_around_var_within_brackets = false +ij_php_spaces_within_array_initializer_braces = false +ij_php_spaces_within_brackets = false +ij_php_spaces_within_catch_parentheses = false +ij_php_spaces_within_for_parentheses = false +ij_php_spaces_within_if_parentheses = false +ij_php_spaces_within_method_call_parentheses = false +ij_php_spaces_within_method_parentheses = false +ij_php_spaces_within_parentheses = false +ij_php_spaces_within_short_echo_tags = true +ij_php_spaces_within_switch_parentheses = false +ij_php_spaces_within_while_parentheses = false +ij_php_special_else_if_treatment = false +ij_php_subpackage_weight = 28 +ij_php_ternary_operation_signs_on_next_line = false +ij_php_ternary_operation_wrap = off +ij_php_throws_weight = 2 +ij_php_todo_weight = 28 +ij_php_unknown_tag_weight = 28 +ij_php_upper_case_boolean_const = false +ij_php_upper_case_null_const = false +ij_php_uses_weight = 28 +ij_php_var_weight = 28 +ij_php_variable_naming_style = mixed +ij_php_version_weight = 28 +ij_php_while_brace_force = always +ij_php_while_on_new_line = true + +[{*.har,*.jsb2,*.jsb3,*.json,.babelrc,.eslintrc,.stylelintrc,bowerrc,composer.lock,jest.config}] +indent_size = 2 +indent_style = space +tab_width = 2 +ij_continuation_indent_size = 2 +ij_smart_tabs = false +ij_json_keep_blank_lines_in_code = 0 +ij_json_keep_indents_on_empty_lines = false +ij_json_keep_line_breaks = true +ij_json_space_after_colon = true +ij_json_space_after_comma = true +ij_json_space_before_colon = true +ij_json_space_before_comma = false +ij_json_spaces_within_braces = false +ij_json_spaces_within_brackets = false +ij_json_wrap_long_lines = false + +[{*.yaml,*.yml}] +indent_size = 2 +ij_yaml_keep_indents_on_empty_lines = false +ij_yaml_keep_line_breaks = true diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..461090b --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.php diff=php diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 5183c50..e01cf0b 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,2 +1,2 @@ -github: softcreatr -custom: ['https://ecologi.com/softcreatr?r=61212ab3fc69b8eb8a2014f4'] +github: softcreatr +custom: ['https://ecologi.com/softcreatr?r=61212ab3fc69b8eb8a2014f4'] diff --git a/.github/ISSUE_TEMPLATE/bug.md b/.github/ISSUE_TEMPLATE/bug.md index 020318e..74fcd73 100644 --- a/.github/ISSUE_TEMPLATE/bug.md +++ b/.github/ISSUE_TEMPLATE/bug.md @@ -1,48 +1,48 @@ ---- -name: πŸ› Bug Report -about: Submit a bug report, to help us improve. -labels: "bug" ---- - -## πŸ› Bug Report - -(A clear and concise description of what the bug is) - -## Have you spent some time to check if this issue has been raised before? - -[ ] I have read googled for a similar issue or checked our older issues for a similar bug - -### Have you read the [Code of Conduct](https://github.com/SoftCreatR/JSONPath/blob/main/CODE_OF_CONDUCT.md)? - -[ ] I have read the Code of Conduct - -## To Reproduce - -(Write your steps here:) - -## Expected behavior - - - -(Write what you thought would happen.) - -## Actual Behavior - - - -(Write what happened. Add screenshots, if applicable.) - -## Your Environment - - - -(Write Environment, Operating system and version etc.) +--- +name: πŸ› Bug Report +about: Submit a bug report, to help us improve. +labels: "bug" +--- + +## πŸ› Bug Report + +(A clear and concise description of what the bug is) + +## Have you spent some time to check if this issue has been raised before? + +[ ] I have read googled for a similar issue or checked our older issues for a similar bug + +### Have you read the [Code of Conduct](https://github.com/SoftCreatR/JSONPath/blob/main/CODE_OF_CONDUCT.md)? + +[ ] I have read the Code of Conduct + +## To Reproduce + +(Write your steps here:) + +## Expected behavior + + + +(Write what you thought would happen.) + +## Actual Behavior + + + +(Write what happened. Add screenshots, if applicable.) + +## Your Environment + + + +(Write Environment, Operating system and version etc.) diff --git a/.github/ISSUE_TEMPLATE/documentation.md b/.github/ISSUE_TEMPLATE/documentation.md index 60c3d86..408ac6d 100644 --- a/.github/ISSUE_TEMPLATE/documentation.md +++ b/.github/ISSUE_TEMPLATE/documentation.md @@ -1,13 +1,13 @@ ---- -name: πŸ“š Documentation -about: Report an issue related to documentation. -labels: "documentation" ---- - -## πŸ“š Documentation - -(A clear and concise description of what the issue is.) - -### Have you read the [Code of Conduct](https://github.com/SoftCreatR/JSONPath/blob/main/CODE_OF_CONDUCT.md)? - -[ ] I have read the Code of Conduct +--- +name: πŸ“š Documentation +about: Report an issue related to documentation. +labels: "documentation" +--- + +## πŸ“š Documentation + +(A clear and concise description of what the issue is.) + +### Have you read the [Code of Conduct](https://github.com/SoftCreatR/JSONPath/blob/main/CODE_OF_CONDUCT.md)? + +[ ] I have read the Code of Conduct diff --git a/.github/ISSUE_TEMPLATE/feature.md b/.github/ISSUE_TEMPLATE/feature.md index f5bb67c..fa5c19c 100644 --- a/.github/ISSUE_TEMPLATE/feature.md +++ b/.github/ISSUE_TEMPLATE/feature.md @@ -1,21 +1,21 @@ ---- -name: πŸ’‘ Feature / Idea -about: Submit a proposal for a new feature. -labels: "feature" ---- - -## πŸ’‘ Feature / Idea - -(A clear and concise description of what the feature is.) - -## Have you spent some time to check if this issue has been raised before? - -[ ] I have read googled for a similar issue or checked our older issues for a similar idea - -### Have you read the [Code of Conduct](https://github.com/SoftCreatR/JSONPath/blob/main/CODE_OF_CONDUCT.md)? - -[ ] I have read the Code of Conduct - -## Pitch - -(Please explain why this feature should be implemented and how it would be used. Add examples, if applicable.) +--- +name: πŸ’‘ Feature / Idea +about: Submit a proposal for a new feature. +labels: "feature" +--- + +## πŸ’‘ Feature / Idea + +(A clear and concise description of what the feature is.) + +## Have you spent some time to check if this issue has been raised before? + +[ ] I have read googled for a similar issue or checked our older issues for a similar idea + +### Have you read the [Code of Conduct](https://github.com/SoftCreatR/JSONPath/blob/main/CODE_OF_CONDUCT.md)? + +[ ] I have read the Code of Conduct + +## Pitch + +(Please explain why this feature should be implemented and how it would be used. Add examples, if applicable.) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 8672e5e..b7dd433 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,22 +1,22 @@ - - -# πŸ”€ Pull Request - -## What does this PR do? - -(Provide a description of what this PR does.) - -## Test Plan - -(Write your test plan here. If you changed any code, please provide us with clear instructions on how you verified your changes work.) - -## Related PRs and Issues - -(If this PR is related to any other PR or resolves any issue or related to any issue link all related PR and issues here.) + + +# πŸ”€ Pull Request + +## What does this PR do? + +(Provide a description of what this PR does.) + +## Test Plan + +(Write your test plan here. If you changed any code, please provide us with clear instructions on how you verified your changes work.) + +## Related PRs and Issues + +(If this PR is related to any other PR or resolves any issue or related to any issue link all related PR and issues here.) diff --git a/.github/workflows/Test.yml b/.github/workflows/Test.yml index 0abee78..5c7dcf8 100644 --- a/.github/workflows/Test.yml +++ b/.github/workflows/Test.yml @@ -19,12 +19,7 @@ jobs: strategy: matrix: php: - - '8.1' - - '8.2' - - '8.3' - - '8.4' - '8.5' - continue-on-error: ${{ matrix.php == '8.4' }} name: PHP ${{ matrix.php }} Test steps: @@ -35,7 +30,7 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} - extensions: json + extensions: dom, json, mbstring, xml, xmlwriter tools: phpcs coverage: pcov env: @@ -51,11 +46,14 @@ jobs: run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" - name: Install dependencies - run: composer update --prefer-dist --no-interaction + run: composer install --prefer-dist --no-interaction --no-progress - name: Run phpcs run: composer cs + - name: Run PHPStan + run: composer phpstan + - name: Execute tests run: | set +e diff --git a/.github/workflows/codestyle.yml b/.github/workflows/codestyle.yml index 1752599..0368e00 100644 --- a/.github/workflows/codestyle.yml +++ b/.github/workflows/codestyle.yml @@ -19,8 +19,8 @@ jobs: - name: Setup PHP with tools uses: shivammathur/setup-php@v2 with: - php-version: '8.1' - extensions: json + php-version: '8.5' + extensions: dom, json, mbstring, xml, xmlwriter tools: cs2pr, phpcs, php-cs-fixer - name: phpcs diff --git a/.gitignore b/.gitignore index 64d819c..600b4c8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ -.idea -.phpunit.result.cache -vendor -.php_cs.cache -composer.lock -composer.phar -.phpcs-cache +.idea +.phpunit.result.cache +vendor +.php_cs.cache +composer.lock +composer.phar +.phpcs-cache diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index f53c77a..999195e 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -1,15 +1,17 @@ exclude('*/vendor/*') - ->in(__DIR__); -return (new PhpCsFixer\Config()) +use PhpCsFixer\Config; +use PhpCsFixer\Finder; + +$finder = Finder::create() + ->in(__DIR__) + ->notPath('vendor'); + +return new Config() ->setRiskyAllowed(true) ->setRules([ '@PSR1' => true, '@PSR2' => true, - '@PSR12' => true, - '@PER' => true, 'array_push' => true, 'backtick_to_shell_exec' => true, @@ -27,18 +29,23 @@ 'non_printable_character' => ['use_escape_sequences_in_strings' => true], + 'lowercase_static_reference' => true, 'magic_constant_casing' => true, 'magic_method_casing' => true, 'native_function_casing' => true, 'native_function_type_declaration_casing' => true, 'cast_spaces' => ['space' => 'none'], + 'lowercase_cast' => true, 'no_unset_cast' => true, + 'short_scalar_cast' => true, 'class_attributes_separation' => true, + 'no_blank_lines_after_class_opening' => true, 'no_null_property_initialization' => true, 'self_accessor' => true, 'single_class_element_per_statement' => true, + 'single_trait_insert_per_statement' => true, 'no_empty_comment' => true, 'single_line_comment_style' => ['comment_types' => ['hash']], @@ -47,7 +54,18 @@ 'no_alternative_syntax' => true, 'no_trailing_comma_in_list_call' => true, - 'no_unneeded_control_parentheses' => ['statements' => ['break', 'clone', 'continue', 'echo_print', 'return', 'switch_case', 'yield', 'yield_from']], + 'no_unneeded_control_parentheses' => [ + 'statements' => [ + 'break', + 'clone', + 'continue', + 'echo_print', + 'return', + 'switch_case', + 'yield', + 'yield_from', + ], + ], 'no_unneeded_curly_braces' => ['namespaces' => true], 'switch_continue_to_break' => true, 'trailing_comma_in_multiline' => ['elements' => ['arrays']], @@ -57,11 +75,19 @@ 'native_function_invocation' => ['include' => ['@internal']], 'no_unreachable_default_argument_value' => true, 'nullable_type_declaration_for_default_null_value' => true, + 'return_type_declaration' => true, 'static_lambda' => true, - 'fully_qualified_strict_types' => true, + 'fully_qualified_strict_types' => ['leading_backslash_in_global_namespace' => true], + 'no_leading_import_slash' => true, 'no_unused_imports' => true, + 'ordered_imports' => [ + 'imports_order' => ['class', 'function', 'const'], + 'sort_algorithm' => 'alpha', + ], + 'blank_line_between_import_groups' => true, + 'declare_equal_normalize' => true, 'dir_constant' => true, 'explicit_indirect_variable' => true, 'function_to_constant' => true, @@ -72,6 +98,7 @@ 'clean_namespace' => true, 'no_leading_namespace_whitespace' => true, + 'single_blank_line_before_namespace' => true, 'no_homoglyph_names' => true, @@ -83,6 +110,7 @@ 'operator_linebreak' => true, 'standardize_increment' => true, 'standardize_not_equals' => true, + 'ternary_operator_spaces' => true, 'ternary_to_elvis_operator' => true, 'ternary_to_null_coalescing' => true, 'unary_operator_spaces' => true, @@ -103,18 +131,22 @@ 'array_indentation' => true, 'blank_line_before_statement' => ['statements' => ['return', 'exit']], + 'compact_nullable_typehint' => true, 'method_chaining_indentation' => true, - 'no_extra_blank_lines' => ['tokens' => ['case', 'continue', 'curly_brace_block', 'default', 'extra', 'parenthesis_brace_block', 'square_brace_block', 'switch', 'throw', 'use']], - 'no_spaces_around_offset' => true, - - // SoftCreatR style - 'global_namespace_import' => [ - 'import_classes' => true, - 'import_constants' => true, - 'import_functions' => false, - ], - 'ordered_imports' => [ - 'imports_order' => ['class', 'function', 'const'], + 'no_extra_blank_lines' => [ + 'tokens' => [ + 'case', + 'continue', + 'curly_brace_block', + 'default', + 'extra', + 'parenthesis_brace_block', + 'square_brace_block', + 'switch', + 'throw', + 'use', + ], ], + 'no_spaces_around_offset' => true, ]) ->setFinder($finder); diff --git a/CHANGELOG.md b/CHANGELOG.md index 2218e0b..2ced640 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,123 +1,138 @@ -# Changelog - -### 0.10.1 -- Fixed ignore whitespace after comparison value in filter expression - -### 0.10.0 -- Fixed query/selector Filter Expression With Current Object -- Fixed query/selector Filter Expression With Different Grouped Operators -- Fixed query/selector Filter Expression With equals_on_array_of_numbers -- Fixed query/selector Filter Expression With Negation and Equals -- Fixed query/selector Filter Expression With Negation and Less Than -- Fixed query/selector Filter Expression Without Value -- Fixed query/selector Filter Expression With Boolean AND Operator (#42) -- Fixed query/selector Filter Expression With Boolean OR Operator (#43) -- Fixed query/selector Filter Expression With Equals (#45) -- Fixed query/selector Filter Expression With Equals false (#46) -- Fixed query/selector Filter Expression With Equals null (#47) -- Fixed query/selector Filter Expression With Equals Number With Fraction (#48) -- Fixed query/selector Filter Expression With Equals true (#50) -- Fixed query/selector Filter Expression With Greater Than (#52) -- Fixed query/selector Filter Expression With Greater Than or Equal (#53) -- Fixed query/selector Filter Expression With Less Than (#54) -- Fixed query/selector Filter Expression With Less Than or Equal (#55) -- Fixed query/selector Filter Expression With Not Equals (#56) -- Fixed query/selector Filter Expression With Value (#57) -- Fixed query/selector script_expression (Expected test result corrected) -- Added additional NULL related query tests from JSONPath RFC - -### 0.9.0 -πŸ”» Breaking changes ahead: - -- Dropped support for PHP < 8.1 - -### 0.8.3 -- Change `getData()` so that it can be mixed instead of array - -### 0.8.2 -- AccessHelper & RecursiveFilter now return a plain `object`, rather than an `ArrayAccess` object - -### 0.8.1 -- Removed strict_types -- Applied some PSR-12 related changes -- Small code optimizations - -### 0.8.0 -πŸ”» Breaking changes ahead: - - - Dropped support for PHP < 8.0 - - Removed deprecated method `JSONPath->data()` - -### 0.7.5 - - Added support for $.length - - Added trim to explode to support both 1,2,3 and 1, 2, 3 inputs - - Dropped in_array strict equality check to be in line with the other standard equality checks such as (== and !=) - -### 0.7.4 - - Removed PHPUnit from conflicting packages - -### 0.7.3 - - Fixed PHP 7.4+ compatibility issues - -### 0.7.2 - - Fixed query/selector "Array Slice With Start Large Negative Number And Open End On Short Array" (#7) - - Fixed query/selector "Union With Keys" (#22) - - Fixed query/selector "Dot Notation After Union With Keys" (#15) - - Fixed query/selector "Union With Keys After Array Slice" (#23) - - Fixed query/selector "Union With Keys After Bracket Notation" (#24) - - Fixed query/selector "Union With Keys On Object Without Key" (#25) - -### 0.7.1 - - Fixed issues with empty tokens (`['']` and `[""]`) - - Fixed TypeError in AccessHelper::keyExists - - Improved QueryTest - -### 0.7.0 +# Changelog + +### 0.11.0 πŸ”» Breaking changes ahead: - - Made JSONPath::__construct final - - Added missing type hints - - Partially reduced complexity - - Performed some code optimizations - - Updated composer.json for proper PHPUnit/PHP usage - - Added support for regular expression operator (`=~`) - - Added QueryTest to perform tests against all queries from https://cburgmer.github.io/json-path-comparison/ - - Switched Code Style from PSR-2 to PSR-12 - -### 0.6.4 - - Removed unnecessary type casting, that caused problems under certain circumstances - - Added support for `nin` operator - - Added support for greater than or equal operator (`>=`) - - Added support for less or equal operator (`<=`) - -### 0.6.3 - - Added support for `in` operator - - Fixed evaluation on indexed object +- Dropped support for PHP < 8.5 +- `JSONPathToken` now uses a `TokenType` enum and the constructor signature changed accordingly. +- `JSONPath` options flag is now an `int` bitmask (was `bool`), requiring callers to pass integer flags. +- `SliceFilter` returns an empty result for non-positive step values (previously iterated indefinitely). +- `QueryResultFilter` now throws a `JSONPathException` for unsupported operators instead of silently proceeding. +- Access helper behavior is stricter: `arrayValues` throws on invalid types; ArrayAccess lookups check `offsetExists` before reading; traversables and objects are handled distinctly. +- Adopted PHP 8.5 features: `TokenType` enum, readonly value object for tokens, typed flags/options, and `#[\Override]` usage. +- CI now runs on PHP 8.5 with required extensions; code style workflow updated accordingly. +- Added coverage for AccessHelper edge cases (magic getters, ArrayAccess, traversables, negative indexes), QueryResultFilter arithmetic branches, and SliceFilter negative/null bounds. +- Fixed empty-expression handling in lexer and improved safety in AccessHelper traversable lookups. +- Added PHPStan static analysis to the toolchain and addressed its findings. -### 0.6.x - - Dropped support for PHP < 7.1 - - Switched from (broken) PSR-0 to PSR-4 - - Updated PHPUnit to 8.5 / 9.4 - - Updated tests - - Added missing PHPDoc blocks - - Added return type hints - - Moved from Travis to GitHub actions - - Set `strict_types=1` - -### 0.5.0 - - Fixed the slice notation (e.g. [0:2:5] etc.). **Breaks code relying on the broken implementation** - -### 0.3.0 - - Added JSONPathToken class as value object - - Lexer clean up and refactor - - Updated the lexing and filtering of the recursive token ("..") to allow for a combination of recursion - and filters, e.g. $..[?(@.type == 'suburb')].name - -### 0.2.1 - 0.2.5 - - Various bug fixes and clean up - -### 0.2.0 - - Added a heap of array access features for more creative iterating and chaining possibilities - -### 0.1.x - - Init +### 0.10.1 +- Fixed ignore whitespace after comparison value in filter expression + +### 0.10.0 +- Fixed query/selector Filter Expression With Current Object +- Fixed query/selector Filter Expression With Different Grouped Operators +- Fixed query/selector Filter Expression With equals_on_array_of_numbers +- Fixed query/selector Filter Expression With Negation and Equals +- Fixed query/selector Filter Expression With Negation and Less Than +- Fixed query/selector Filter Expression Without Value +- Fixed query/selector Filter Expression With Boolean AND Operator (#42) +- Fixed query/selector Filter Expression With Boolean OR Operator (#43) +- Fixed query/selector Filter Expression With Equals (#45) +- Fixed query/selector Filter Expression With Equals false (#46) +- Fixed query/selector Filter Expression With Equals null (#47) +- Fixed query/selector Filter Expression With Equals Number With Fraction (#48) +- Fixed query/selector Filter Expression With Equals true (#50) +- Fixed query/selector Filter Expression With Greater Than (#52) +- Fixed query/selector Filter Expression With Greater Than or Equal (#53) +- Fixed query/selector Filter Expression With Less Than (#54) +- Fixed query/selector Filter Expression With Less Than or Equal (#55) +- Fixed query/selector Filter Expression With Not Equals (#56) +- Fixed query/selector Filter Expression With Value (#57) +- Fixed query/selector script_expression (Expected test result corrected) +- Added additional NULL related query tests from JSONPath RFC + +### 0.9.0 +πŸ”» Breaking changes ahead: + +- Dropped support for PHP < 8.1 + +### 0.8.3 +- Change `getData()` so that it can be mixed instead of array + +### 0.8.2 +- AccessHelper & RecursiveFilter now return a plain `object`, rather than an `ArrayAccess` object + +### 0.8.1 +- Removed strict_types +- Applied some PSR-12 related changes +- Small code optimizations + +### 0.8.0 +πŸ”» Breaking changes ahead: + + - Dropped support for PHP < 8.0 + - Removed deprecated method `JSONPath->data()` + +### 0.7.5 + - Added support for $.length + - Added trim to explode to support both 1,2,3 and 1, 2, 3 inputs + - Dropped in_array strict equality check to be in line with the other standard equality checks such as (== and !=) + +### 0.7.4 + - Removed PHPUnit from conflicting packages + +### 0.7.3 + - Fixed PHP 7.4+ compatibility issues + +### 0.7.2 + - Fixed query/selector "Array Slice With Start Large Negative Number And Open End On Short Array" (#7) + - Fixed query/selector "Union With Keys" (#22) + - Fixed query/selector "Dot Notation After Union With Keys" (#15) + - Fixed query/selector "Union With Keys After Array Slice" (#23) + - Fixed query/selector "Union With Keys After Bracket Notation" (#24) + - Fixed query/selector "Union With Keys On Object Without Key" (#25) + +### 0.7.1 + - Fixed issues with empty tokens (`['']` and `[""]`) + - Fixed TypeError in AccessHelper::keyExists + - Improved QueryTest + +### 0.7.0 +πŸ”» Breaking changes ahead: + + - Made JSONPath::__construct final + - Added missing type hints + - Partially reduced complexity + - Performed some code optimizations + - Updated composer.json for proper PHPUnit/PHP usage + - Added support for regular expression operator (`=~`) + - Added QueryTest to perform tests against all queries from https://cburgmer.github.io/json-path-comparison/ + - Switched Code Style from PSR-2 to PSR-12 + +### 0.6.4 + - Removed unnecessary type casting, that caused problems under certain circumstances + - Added support for `nin` operator + - Added support for greater than or equal operator (`>=`) + - Added support for less or equal operator (`<=`) + +### 0.6.3 + - Added support for `in` operator + - Fixed evaluation on indexed object + +### 0.6.x + - Dropped support for PHP < 7.1 + - Switched from (broken) PSR-0 to PSR-4 + - Updated PHPUnit to 8.5 / 9.4 + - Updated tests + - Added missing PHPDoc blocks + - Added return type hints + - Moved from Travis to GitHub actions + - Set `strict_types=1` + +### 0.5.0 + - Fixed the slice notation (e.g. [0:2:5] etc.). **Breaks code relying on the broken implementation** + +### 0.3.0 + - Added JSONPathToken class as value object + - Lexer clean up and refactor + - Updated the lexing and filtering of the recursive token ("..") to allow for a combination of recursion + and filters, e.g. $..[?(@.type == 'suburb')].name + +### 0.2.1 - 0.2.5 + - Various bug fixes and clean up + +### 0.2.0 + - Added a heap of array access features for more creative iterating and chaining possibilities + +### 0.1.x + - Init diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 85f1e6f..adf4fbd 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -1,73 +1,73 @@ -# Contributor Covenant Code of Conduct - -## Our Pledge - -In the interest of fostering an open and welcoming environment, we as -contributors and maintainers pledge to make participation in our project and -our community a harassment-free experience for everyone, regardless of age, body -size, disability, ethnicity, sex characteristics, gender identity, expression, -level of experience, education, socio-economic status, nationality, personal -appearance, race, religion, or sexual identity and orientation. - -## Our Standards - -Examples of behavior that contributes to creating a positive environment -include: - -* Using welcoming and inclusive language -* Being respectful of differing viewpoints and experiences -* Gracefully accepting constructive criticism -* Focusing on what is best for the community -* Showing empathy towards other community members - -Examples of unacceptable behavior by participants include: - -* The use of sexualized language or imagery and unwelcome sexual attention or advances -* Trolling, insulting/derogatory comments, and personal or political attacks -* Public or private harassment -* Publishing others' private information, such as a physical or electronic address, without explicit permission -* Other conduct which could reasonably be considered inappropriate in a professional setting - -## Our Responsibilities - -Project maintainers are responsible for clarifying the standards of acceptable -behavior and are expected to take appropriate and fair corrective action in -response to any instances of unacceptable behavior. - -Project maintainers have the right and responsibility to remove, edit, or -reject comments, commits, code, wiki edits, issues, and other contributions -that are not aligned to this Code of Conduct, or to ban temporarily or -permanently any contributor for other behaviors that they deem inappropriate, -threatening, offensive, or harmful. - -## Scope - -This Code of Conduct applies both within project spaces and in public spaces -when an individual is representing the project or its community. Examples of -representing a project or community include using an official project e-mail -address, posting via an official social media account, or acting as an appointed -representative at an online or offline event. Representation of a project may be -further defined and clarified by project maintainers. - -## Enforcement - -Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported by contacting the project team at hello@1-2.dev. All -complaints will be reviewed and investigated and will result in a response that -is deemed necessary and appropriate to the circumstances. The project team is -obligated to maintain confidentiality with regard to the reporter of an incident. -Further details of specific enforcement policies may be posted separately. - -Project maintainers who do not follow or enforce the Code of Conduct in good -faith may face temporary or permanent repercussions as determined by other -members of the project's leadership. - -## Attribution - -This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, -available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html - -[homepage]: https://www.contributor-covenant.org - -For answers to common questions about this code of conduct, see -https://www.contributor-covenant.org/faq +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to make participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, sex characteristics, gender identity, expression, +level of experience, education, socio-economic status, nationality, personal +appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the project team at hello@1-2.dev. All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. The project team is +obligated to maintain confidentiality with regard to the reporter of an incident. +Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, +available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see +https://www.contributor-covenant.org/faq diff --git a/LICENSE.md b/LICENSE.md index 2cf851b..ec7d224 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,22 +1,22 @@ -MIT License - -Original work - Copyright (c) 2018 Flow Communications -Modified work - Copyright (c) 2020 Sascha Greuel - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +MIT License + +Original work - Copyright (c) 2018 Flow Communications +Modified work - Copyright (c) 2020 Sascha Greuel + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index b6c46fa..26fd159 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,8 @@ -# JSONPath for PHP 8.1+ +# JSONPath for PHP 8.5+ [![Build](https://img.shields.io/github/actions/workflow/status/SoftCreatR/JSONPath/.github/workflows/Test.yml?branch=main)](https://github.com/SoftCreatR/JSONPath/actions/workflows/Test.yml) [![Latest Release](https://img.shields.io/packagist/v/SoftCreatR/JSONPath?color=blue&label=Latest%20Release)](https://packagist.org/packages/softcreatr/jsonpath) [![MIT licensed](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE) [![Plant Tree](https://img.shields.io/badge/dynamic/json?color=brightgreen&label=Plant%20Tree&query=%24.total&url=https%3A%2F%2Fpublic.ecologi.com%2Fusers%2Fsoftcreatr%2Ftrees)](https://ecologi.com/softcreatr?r=61212ab3fc69b8eb8a2014f4) [![Codecov branch](https://img.shields.io/codecov/c/github/SoftCreatR/JSONPath)](https://codecov.io/gh/SoftCreatR/JSONPath) -[![Code Climate maintainability](https://img.shields.io/codeclimate/maintainability-percentage/SoftCreatR/JSONPath)](https://codeclimate.com/github/SoftCreatR/JSONPath) This is a [JSONPath](http://goessner.net/articles/JsonPath/) implementation for PHP based on Stefan Goessner's JSONPath script. @@ -18,8 +17,19 @@ This project aims to be a clean and simple implementation with the following goa ## Installation +Requires PHP 8.5 or newer. + +```bash +composer require softcreatr/jsonpath:"^0.11" +``` + +## Development + +Static analysis is done with PHPStan. + ```bash -composer require softcreatr/jsonpath:"^0.9" +composer require --dev phpstan/phpstan +./vendor/bin/phpstan analyse --no-progress ``` ## JSONPath Examples diff --git a/composer.json b/composer.json index 232a327..109c4b7 100644 --- a/composer.json +++ b/composer.json @@ -2,7 +2,7 @@ "name": "softcreatr/jsonpath", "description": "JSONPath implementation for parsing, searching and flattening arrays", "license": "MIT", - "version": "0.10.0", + "version": "0.11.0", "authors": [ { "name": "Stephen Frank", @@ -24,13 +24,14 @@ "source": "https://github.com/SoftCreatR/JSONPath" }, "require": { - "php": "8.1 - 8.5", + "php": "^8.5", "ext-json": "*" }, "require-dev": { - "phpunit/phpunit": "10 - 12", - "friendsofphp/php-cs-fixer": "^3.58", - "squizlabs/php_codesniffer": "^3.10" + "friendsofphp/php-cs-fixer": "^3.92", + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^12", + "squizlabs/php_codesniffer": "^4.0" }, "replace": { "flow/jsonpath": "*" @@ -51,8 +52,9 @@ "preferred-install": "dist" }, "scripts": { - "test": "phpunit", "cs": "phpcs", - "cs-fix": "php-cs-fixer fix --config=.php-cs-fixer.dist.php" + "cs-fix": "php-cs-fixer fix --config=.php-cs-fixer.dist.php", + "phpstan": "phpstan analyse --no-progress -c phpstan.neon.dist", + "test": "phpunit" } } diff --git a/phpcs.xml b/phpcs.xml index 69fb3bb..7e88403 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -1,7 +1,10 @@ - - src - tests + + src/ + tests/ diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..3da281c --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,6 @@ +parameters: + level: 6 + paths: + - src + - tests + tmpDir: .phpstan-cache diff --git a/src/AccessHelper.php b/src/AccessHelper.php index 1d7e363..bc0d16d 100644 --- a/src/AccessHelper.php +++ b/src/AccessHelper.php @@ -6,12 +6,18 @@ * @license https://github.com/SoftCreatR/JSONPath/blob/main/LICENSE MIT License */ +declare(strict_types=1); + namespace Flow\JSONPath; use ArrayAccess; +use Traversable; class AccessHelper { + /** + * @return array + */ public static function collectionKeys(mixed $collection): array { if (\is_object($collection)) { @@ -26,7 +32,7 @@ public static function isCollectionType(mixed $collection): bool return \is_array($collection) || \is_object($collection); } - public static function keyExists(mixed $collection, $key, bool $magicIsAllowed = false): bool + public static function keyExists(mixed $collection, int|string|null $key, bool $magicIsAllowed = false): bool { if ($magicIsAllowed && \is_object($collection) && \method_exists($collection, '__get')) { return true; @@ -54,7 +60,7 @@ public static function keyExists(mixed $collection, $key, bool $magicIsAllowed = /** * @todo Optimize conditions */ - public static function getValue(mixed $collection, $key, bool $magicIsAllowed = false) + public static function getValue(mixed $collection, int|string|null $key, bool $magicIsAllowed = false): mixed { if ( $magicIsAllowed @@ -62,20 +68,20 @@ public static function getValue(mixed $collection, $key, bool $magicIsAllowed = && !$collection instanceof ArrayAccess && \method_exists($collection, '__get') ) { $return = $collection->__get($key); + } elseif (\is_int($key) && $collection instanceof Traversable && !$collection instanceof ArrayAccess) { + $return = self::getValueByIndex($collection, $key); } elseif (\is_object($collection) && !$collection instanceof ArrayAccess) { $return = $collection->{$key}; } elseif ($collection instanceof ArrayAccess) { - $return = $collection->offsetGet($key); + $return = $collection->offsetExists($key) ? $collection->offsetGet($key) : null; } elseif (\is_array($collection)) { if (\is_int($key) && $key < 0) { - $return = \array_slice($collection, $key, 1)[0]; + $return = \array_slice($collection, $key, 1)[0] ?? null; } else { - $return = $collection[$key]; + $return = $collection[$key] ?? null; } - } elseif (\is_int($key)) { - $return = self::getValueByIndex($collection, $key); } else { - $return = $collection[$key]; + $return = null; } return $return; @@ -85,7 +91,7 @@ public static function getValue(mixed $collection, $key, bool $magicIsAllowed = * Find item in php collection by index * Written this way to handle instances ArrayAccess or Traversable objects */ - private static function getValueByIndex(mixed $collection, $key): mixed + private static function getValueByIndex(mixed $collection, int $key): mixed { $i = 0; @@ -113,21 +119,27 @@ private static function getValueByIndex(mixed $collection, $key): mixed return null; } - public static function setValue(mixed &$collection, $key, $value) + public static function setValue(mixed &$collection, int|string|null $key, mixed $value): mixed { if (\is_object($collection) && !$collection instanceof ArrayAccess) { - return $collection->{$key} = $value; + $collection->{$key} = $value; + + return $value; } if ($collection instanceof ArrayAccess) { /** @noinspection PhpVoidFunctionResultUsedInspection */ - return $collection->offsetSet($key, $value); + $collection->offsetSet($key, $value); + + return $value; } - return $collection[$key] = $value; + $collection[$key] = $value; + + return $value; } - public static function unsetValue(mixed &$collection, $key): void + public static function unsetValue(mixed &$collection, int|string|null $key): void { if (\is_object($collection) && !$collection instanceof ArrayAccess) { unset($collection->{$key}); @@ -145,7 +157,11 @@ public static function unsetValue(mixed &$collection, $key): void /** * @throws JSONPathException */ - public static function arrayValues(array|object $collection): array|ArrayAccess + /** + * @return array + * @throws JSONPathException + */ + public static function arrayValues(mixed $collection): array { if (\is_array($collection)) { return \array_values($collection); @@ -155,6 +171,6 @@ public static function arrayValues(array|object $collection): array|ArrayAccess return \array_values((array)$collection); } - throw new JSONPathException("Invalid variable type for arrayValues"); + throw new JSONPathException('Invalid variable type for arrayValues'); } } diff --git a/src/Filters/AbstractFilter.php b/src/Filters/AbstractFilter.php index ad9142b..baed19f 100644 --- a/src/Filters/AbstractFilter.php +++ b/src/Filters/AbstractFilter.php @@ -6,23 +6,25 @@ * @license https://github.com/SoftCreatR/JSONPath/blob/main/LICENSE MIT License */ +declare(strict_types=1); + namespace Flow\JSONPath\Filters; -use ArrayAccess; use Flow\JSONPath\JSONPath; use Flow\JSONPath\JSONPathToken; abstract class AbstractFilter { - protected JSONPathToken $token; - - protected bool $magicIsAllowed = false; + protected bool $magicIsAllowed; - public function __construct(JSONPathToken $token, int|bool $options = false) + public function __construct(protected JSONPathToken $token, int $options = 0) { - $this->token = $token; - $this->magicIsAllowed = (bool)($options & JSONPath::ALLOW_MAGIC); + $this->magicIsAllowed = ($options & JSONPath::ALLOW_MAGIC) === JSONPath::ALLOW_MAGIC; } - abstract public function filter(array|ArrayAccess $collection): array; + /** + * @param array|object $collection + * @return array + */ + abstract public function filter(array|object $collection): array; } diff --git a/src/Filters/IndexFilter.php b/src/Filters/IndexFilter.php index 49e6984..ba108ed 100644 --- a/src/Filters/IndexFilter.php +++ b/src/Filters/IndexFilter.php @@ -6,6 +6,8 @@ * @license https://github.com/SoftCreatR/JSONPath/blob/main/LICENSE MIT License */ +declare(strict_types=1); + namespace Flow\JSONPath\Filters; use Flow\JSONPath\AccessHelper; @@ -15,8 +17,9 @@ class IndexFilter extends AbstractFilter { /** * @throws JSONPathException + * @inheritDoc */ - public function filter($collection): array + public function filter(array|object $collection): array { if (\is_array($this->token->value)) { $result = []; diff --git a/src/Filters/IndexesFilter.php b/src/Filters/IndexesFilter.php index 449304c..3d4229a 100644 --- a/src/Filters/IndexesFilter.php +++ b/src/Filters/IndexesFilter.php @@ -6,13 +6,18 @@ * @license https://github.com/SoftCreatR/JSONPath/blob/main/LICENSE MIT License */ +declare(strict_types=1); + namespace Flow\JSONPath\Filters; use Flow\JSONPath\AccessHelper; class IndexesFilter extends AbstractFilter { - public function filter($collection): array + /** + * @inheritDoc + */ + public function filter(array|object $collection): array { $return = []; diff --git a/src/Filters/QueryMatchFilter.php b/src/Filters/QueryMatchFilter.php index 6906d81..e970dd2 100644 --- a/src/Filters/QueryMatchFilter.php +++ b/src/Filters/QueryMatchFilter.php @@ -22,22 +22,23 @@ class QueryMatchFilter extends AbstractFilter { - protected const MATCH_QUERY_NEGATION_WRAPPED = '^(?!)\((?.+)\)$'; + protected const string MATCH_QUERY_NEGATION_WRAPPED = '^(?!)\((?.+)\)$'; - protected const MATCH_QUERY_NEGATION_UNWRAPPED = '^(?!)(?.+)$'; + protected const string MATCH_QUERY_NEGATION_UNWRAPPED = '^(?!)(?.+)$'; - protected const MATCH_QUERY_OPERATORS = ' + protected const string MATCH_QUERY_OPERATORS = ' (@\.(?[^\s<>!=]+)|@\[["\']?(?.*?)["\']?\]|(?@)|(%group(?\d+)%)) (\s*(?==|=~|=|<>|!==|!=|>=|<=|>|<|in|!in|nin)\s*(?.+?(?=\s*(?:&&|$|\|\||%))))? (\s*(?&&|\|\|)\s*)? '; - protected const MATCH_GROUPED_EXPRESSION = '#\([^)(]*+(?:(?R)[^)(]*)*+\)#'; + protected const string MATCH_GROUPED_EXPRESSION = '#\([^)(]*+(?:(?R)[^)(]*)*+\)#'; /** * @throws JSONPathException + * @inheritDoc */ - public function filter($collection): array + public function filter(array|object $collection): array { $filterExpression = $this->token->value; $negateFilter = false; @@ -99,7 +100,7 @@ public function filter($collection): array //Processing a group if ($matches['group'][$expressionPart] !== null) { $filter = '$[?(' . $filterGroups[$matches['group'][$expressionPart]] . ')]'; - $resolve = (new JSONPath($filteredCollection))->find($filter)->getData(); + $resolve = new JSONPath($filteredCollection)->find($filter)->getData(); $return = $resolve; continue; @@ -135,7 +136,7 @@ public function filter($collection): array if ($notNothing) { $selectedNode = AccessHelper::getValue($node, $key, $this->magicIsAllowed); } elseif (\str_contains($key, '.')) { - $foundValue = (new JSONPath($node))->find($key)->getData(); + $foundValue = new JSONPath($node)->find($key)->getData(); if ($foundValue) { $selectedNode = $foundValue[0]; @@ -163,7 +164,9 @@ public function filter($collection): array '>=' => $this->compareLessThan($comparisonValue, $selectedNode) //rfc semantics || $this->compareEquals($selectedNode, $comparisonValue), "in" => \is_array($comparisonValue) && \in_array($selectedNode, $comparisonValue, true), - 'nin', "!in" => \is_array($comparisonValue) && !\in_array($selectedNode, $comparisonValue, true) + 'nin', "!in" => \is_array($comparisonValue) + && !\in_array($selectedNode, $comparisonValue, true), + default => false, }; } @@ -183,12 +186,12 @@ public function filter($collection): array return $return; } - protected function isNumber($value): bool + protected function isNumber(mixed $value): bool { return !\is_string($value) && \is_numeric($value); } - protected function compareEquals($a, $b): bool + protected function compareEquals(mixed $a, mixed $b): bool { $type_a = \gettype($a); $type_b = \gettype($b); @@ -206,7 +209,7 @@ protected function compareEquals($a, $b): bool return false; } - protected function compareLessThan($a, $b): bool + protected function compareLessThan(mixed $a, mixed $b): bool { if ((\is_string($a) && \is_string($b)) || ($this->isNumber($a) && $this->isNumber($b))) { //numerical and string comparison supported only diff --git a/src/Filters/QueryResultFilter.php b/src/Filters/QueryResultFilter.php index cebe0df..354cc7c 100644 --- a/src/Filters/QueryResultFilter.php +++ b/src/Filters/QueryResultFilter.php @@ -6,6 +6,8 @@ * @license https://github.com/SoftCreatR/JSONPath/blob/main/LICENSE MIT License */ +declare(strict_types=1); + namespace Flow\JSONPath\Filters; use Flow\JSONPath\AccessHelper; @@ -15,10 +17,13 @@ class QueryResultFilter extends AbstractFilter { /** * @throws JSONPathException + * @inheritDoc */ - public function filter($collection): array + public function filter(array|object $collection): array { - \preg_match('/@\.(?\w+)\s*(?[-+*\/])\s*(?\d+)/', $this->token->value, $matches); + if (!\preg_match('/@\.(?\w+)\s*(?[-+*\/])\s*(?\d+)/', $this->token->value, $matches)) { + throw new JSONPathException('Unsupported operator in expression'); + } $matchKey = $matches['key']; @@ -35,7 +40,6 @@ public function filter($collection): array '*' => $value * $matches['numeric'], '-' => $value - $matches['numeric'], '/' => $value / $matches['numeric'], - default => throw new JSONPathException('Unsupported operator in expression'), }; $result = []; diff --git a/src/Filters/RecursiveFilter.php b/src/Filters/RecursiveFilter.php index b2ce2de..6f101c5 100644 --- a/src/Filters/RecursiveFilter.php +++ b/src/Filters/RecursiveFilter.php @@ -6,6 +6,8 @@ * @license https://github.com/SoftCreatR/JSONPath/blob/main/LICENSE MIT License */ +declare(strict_types=1); + namespace Flow\JSONPath\Filters; use Flow\JSONPath\AccessHelper; @@ -15,8 +17,9 @@ class RecursiveFilter extends AbstractFilter { /** * @throws JSONPathException + * @inheritDoc */ - public function filter($collection): array + public function filter(array|object $collection): array { $result = []; @@ -28,6 +31,10 @@ public function filter($collection): array /** * @throws JSONPathException */ + /** + * @param array> $result + * @param array|object $data + */ private function recurse(array &$result, array|object $data): void { $result[] = (array)$data; diff --git a/src/Filters/SliceFilter.php b/src/Filters/SliceFilter.php index 02ea3ec..15fe6c3 100644 --- a/src/Filters/SliceFilter.php +++ b/src/Filters/SliceFilter.php @@ -6,13 +6,18 @@ * @license https://github.com/SoftCreatR/JSONPath/blob/main/LICENSE MIT License */ +declare(strict_types=1); + namespace Flow\JSONPath\Filters; use Flow\JSONPath\AccessHelper; class SliceFilter extends AbstractFilter { - public function filter($collection): array + /** + * @inheritDoc + */ + public function filter(array|object $collection): array { $length = \count($collection); $start = $this->token->value['start']; @@ -25,6 +30,7 @@ public function filter($collection): array if ($start < 0) { $start = $length + $start; + if ($start < 0) { $start = 0; } @@ -41,15 +47,13 @@ public function filter($collection): array $result = []; - for ($i = $start; $i < $end; $i += $step) { - $index = $i; - - if ($i < 0) { - $index = $length + $i; - } + if ($step <= 0) { + return $result; + } - if (AccessHelper::keyExists($collection, $index, $this->magicIsAllowed)) { - $result[] = $collection[$index]; + for ($i = $start; $i < $end; $i += $step) { + if (AccessHelper::keyExists($collection, $i, $this->magicIsAllowed)) { + $result[] = $collection[$i]; } } diff --git a/src/JSONPath.php b/src/JSONPath.php index f91cc93..d637a7a 100644 --- a/src/JSONPath.php +++ b/src/JSONPath.php @@ -6,24 +6,32 @@ * @license https://github.com/SoftCreatR/JSONPath/blob/main/LICENSE MIT License */ +declare(strict_types=1); + namespace Flow\JSONPath; use ArrayAccess; use Countable; use Iterator; use JsonSerializable; +use Override; +/** + * @implements ArrayAccess + * @implements Iterator + */ class JSONPath implements ArrayAccess, Iterator, JsonSerializable, Countable { - public const ALLOW_MAGIC = true; + public const int ALLOW_MAGIC = 1; + /** @var array> */ protected static array $tokenCache = []; protected mixed $data = []; - protected bool $options = false; + protected int $options = 0; - final public function __construct(mixed $data = [], bool $options = false) + final public function __construct(mixed $data = [], int $options = 0) { $this->data = $data; $this->options = $options; @@ -42,7 +50,6 @@ public function find(string $expression): self $collectionData = [$this->data]; foreach ($tokens as $token) { - /** @var JSONPathToken $token */ $filter = $token->buildFilter($this->options); $filteredDataList = []; @@ -86,7 +93,7 @@ public function last(): mixed return null; } - $value = $this->data[\end($keys)] ?: null; + $value = $this->data[\end($keys)] ?? null; return AccessHelper::isCollectionType($value) ? new static($value, $this->options) : $value; } @@ -94,7 +101,7 @@ public function last(): mixed /** * Evaluate an expression and return the first key */ - public function firstKey(): mixed + public function firstKey(): string|int|null { $keys = AccessHelper::collectionKeys($this->data); @@ -108,11 +115,11 @@ public function firstKey(): mixed /** * Evaluate an expression and return the last key */ - public function lastKey(): mixed + public function lastKey(): string|int|null { $keys = AccessHelper::collectionKeys($this->data); - if (empty($keys) || \end($keys) === false) { + if (empty($keys)) { return null; } @@ -120,6 +127,7 @@ public function lastKey(): mixed } /** + * @return list * @throws JSONPathException */ public function parseTokens(string $expression): array @@ -144,26 +152,21 @@ public function getData(): mixed } /** - * @return mixed|null * @noinspection MagicMethodsValidityInspection */ - public function __get($key) + public function __get(string|int $key): mixed { return $this->offsetExists($key) ? $this->offsetGet($key) : null; } - /** - * @inheritDoc - */ - public function offsetExists($offset): bool + #[Override] + public function offsetExists(mixed $offset): bool { return AccessHelper::keyExists($this->data, $offset); } - /** - * @inheritDoc - */ - public function offsetGet($offset): mixed + #[Override] + public function offsetGet(mixed $offset): mixed { $value = AccessHelper::getValue($this->data, $offset); @@ -172,10 +175,8 @@ public function offsetGet($offset): mixed : $value; } - /** - * @inheritDoc - */ - public function offsetSet($offset, $value): void + #[Override] + public function offsetSet(mixed $offset, mixed $value): void { if ($offset === null) { $this->data[] = $value; @@ -184,25 +185,19 @@ public function offsetSet($offset, $value): void } } - /** - * @inheritDoc - */ - public function offsetUnset($offset): void + #[Override] + public function offsetUnset(mixed $offset): void { AccessHelper::unsetValue($this->data, $offset); } - /** - * @inheritDoc - */ - public function jsonSerialize(): array + #[Override] + public function jsonSerialize(): mixed { return $this->getData(); } - /** - * @inheritDoc - */ + #[Override] public function current(): mixed { $value = \current($this->data); @@ -210,41 +205,31 @@ public function current(): mixed return AccessHelper::isCollectionType($value) ? new static($value, $this->options) : $value; } - /** - * @inheritDoc - */ + #[Override] public function next(): void { \next($this->data); } - /** - * @inheritDoc - */ + #[Override] public function key(): string|int|null { return \key($this->data); } - /** - * @inheritDoc - */ + #[Override] public function valid(): bool { return \key($this->data) !== null; } - /** - * @inheritDoc - */ + #[Override] public function rewind(): void { \reset($this->data); } - /** - * @inheritDoc - */ + #[Override] public function count(): int { return \count($this->data); diff --git a/src/JSONPathException.php b/src/JSONPathException.php index 71fb09c..3f53704 100644 --- a/src/JSONPathException.php +++ b/src/JSONPathException.php @@ -6,6 +6,8 @@ * @license https://github.com/SoftCreatR/JSONPath/blob/main/LICENSE MIT License */ +declare(strict_types=1); + namespace Flow\JSONPath; use Exception; diff --git a/src/JSONPathLexer.php b/src/JSONPathLexer.php index 2c41c3c..56f8b2c 100644 --- a/src/JSONPathLexer.php +++ b/src/JSONPathLexer.php @@ -6,6 +6,8 @@ * @license https://github.com/SoftCreatR/JSONPath/blob/main/LICENSE MIT License */ +declare(strict_types=1); + namespace Flow\JSONPath; class JSONPathLexer @@ -14,57 +16,61 @@ class JSONPathLexer * Match within bracket groups * Matches are whitespace insensitive */ - public const MATCH_INDEX = '(?!-)[\-\w]+ | \*'; // e.g.: foo or 40f35757-2563-4790-b0b1-caa904be455f + public const string MATCH_INDEX = '(?!-)[\-\w]+ | \*'; // e.g.: foo or 40f35757-2563-4790-b0b1-caa904be455f - public const MATCH_INDEXES = '\s* -?\d+ [-?\d,\s]+'; // Eg. 0,1,2 + public const string MATCH_INDEXES = '\s* -?\d+ [-?\d,\s]+'; // Eg. 0,1,2 - public const MATCH_SLICE = '[-\d:]+ | :'; // Eg. [0:2:1] + public const string MATCH_SLICE = '[-\d:]+ | :'; // Eg. [0:2:1] - public const MATCH_QUERY_RESULT = '\s* \( .+? \) \s*'; // Eg. ?(@.length - 1) + public const string MATCH_QUERY_RESULT = '\s* \( .+? \) \s*'; // Eg. ?(@.length - 1) - public const MATCH_QUERY_MATCH = '\s* \?\(.+?\) \s*'; // Eg. ?(@.foo = "bar") + public const string MATCH_QUERY_MATCH = '\s* \?\(.+?\) \s*'; // Eg. ?(@.foo = "bar") - public const MATCH_INDEX_IN_SINGLE_QUOTES = '\s* \' (.+?)? \' \s*'; // Eg. 'bar' + public const string MATCH_INDEX_IN_SINGLE_QUOTES = '\s* \' (.+?)? \' \s*'; // Eg. 'bar' - public const MATCH_INDEX_IN_DOUBLE_QUOTES = '\s* " (.+?)? " \s*'; // Eg. "bar" + public const string MATCH_INDEX_IN_DOUBLE_QUOTES = '\s* " (.+?)? " \s*'; // Eg. "bar" - /** - * The expression being lexed. - */ - protected string $expression = ''; + private readonly string $expression; - /** - * The length of the expression. - */ - protected int $expressionLength = 0; + private readonly int $expressionLength; public function __construct(string $expression) { $expression = \trim($expression); $len = \strlen($expression); - if ($len > 1) { - if ($expression[0] === '$') { - $expression = \substr($expression, 1); - $len--; - } + if ($len === 0) { + $this->expression = ''; + $this->expressionLength = 0; - if ($expression[0] !== '.' && $expression[0] !== '[') { - $expression = '.' . $expression; - $len++; - } + return; + } - $this->expression = $expression; - $this->expressionLength = $len; + if ($expression[0] === '$') { + $expression = \substr($expression, 1); } + + if ($expression === '') { + $this->expression = ''; + $this->expressionLength = 0; + + return; + } + + if ($expression[0] !== '.' && $expression[0] !== '[') { + $expression = '.' . $expression; + } + + $this->expression = $expression; + $this->expressionLength = \strlen($expression); } /** + * @return list * @throws JSONPathException */ public function parseExpressionTokens(): array { - $dotIndexDepth = 0; $squareBracketDepth = 0; $tokenValue = ''; $tokens = []; @@ -74,7 +80,7 @@ public function parseExpressionTokens(): array if (($squareBracketDepth === 0) && $char === '.') { if ($this->lookAhead($i) === '.') { - $tokens[] = new JSONPathToken(JSONPathToken::T_RECURSIVE, null); + $tokens[] = new JSONPathToken(TokenType::Recursive, null); } continue; @@ -114,17 +120,9 @@ public function parseExpressionTokens(): array if ($squareBracketDepth === 0) { $tokenValue .= $char; - // Double dot ".." - if ($char === '.' && $dotIndexDepth > 1) { - $tokens[] = $this->createToken($tokenValue); - $tokenValue = ''; - continue; - } - - if ($this->atEnd($i) || \in_array($this->lookAhead($i), ['.', '['])) { + if ($this->atEnd($i) || \in_array($this->lookAhead($i), ['.', '['], true)) { $tokens[] = $this->createToken($tokenValue); $tokenValue = ''; - $dotIndexDepth--; } } } @@ -147,6 +145,7 @@ protected function atEnd(int $pos): bool } /** + * @return list * @throws JSONPathException */ public function parseExpression(): array @@ -171,7 +170,7 @@ protected function createToken(string $value): JSONPathToken $tokenValue = (int)$tokenValue; } - $ret = new JSONPathToken(JSONPathToken::T_INDEX, $tokenValue); + $ret = new JSONPathToken(TokenType::Index, $tokenValue); } elseif (\preg_match('/^' . static::MATCH_INDEXES . '$/xu', $tokenValue, $matches)) { $tokenValue = \explode(',', \trim($tokenValue, ',')); @@ -179,31 +178,30 @@ protected function createToken(string $value): JSONPathToken $tokenValue[$i] = (int)\trim($v); } - $ret = new JSONPathToken(JSONPathToken::T_INDEXES, $tokenValue); + $ret = new JSONPathToken(TokenType::Indexes, $tokenValue); } elseif (\preg_match('/^' . static::MATCH_SLICE . '$/xu', $tokenValue, $matches)) { $parts = \explode(':', $tokenValue); $tokenValue = [ - 'start' => isset($parts[0]) && $parts[0] !== '' ? (int)$parts[0] : null, + 'start' => $parts[0] !== '' ? (int)$parts[0] : null, 'end' => isset($parts[1]) && $parts[1] !== '' ? (int)$parts[1] : null, 'step' => isset($parts[2]) && $parts[2] !== '' ? (int)$parts[2] : null, ]; - $ret = new JSONPathToken(JSONPathToken::T_SLICE, $tokenValue); + $ret = new JSONPathToken(TokenType::Slice, $tokenValue); } elseif (\preg_match('/^' . static::MATCH_QUERY_RESULT . '$/xu', $tokenValue)) { $tokenValue = \substr($tokenValue, 1, -1); - $ret = new JSONPathToken(JSONPathToken::T_QUERY_RESULT, $tokenValue); + $ret = new JSONPathToken(TokenType::QueryResult, $tokenValue); } elseif (\preg_match('/^' . static::MATCH_QUERY_MATCH . '$/xu', $tokenValue)) { $tokenValue = \substr($tokenValue, 2, -1); - $ret = new JSONPathToken(JSONPathToken::T_QUERY_MATCH, $tokenValue); + $ret = new JSONPathToken(TokenType::QueryMatch, $tokenValue); } elseif ( \preg_match('/^' . static::MATCH_INDEX_IN_SINGLE_QUOTES . '$/xu', $tokenValue, $matches) || \preg_match('/^' . static::MATCH_INDEX_IN_DOUBLE_QUOTES . '$/xu', $tokenValue, $matches) ) { if (isset($matches[1])) { - $tokenValue = $matches[1]; - $tokenValue = \trim($tokenValue); + $tokenValue = \trim($matches[1]); $possibleArray = false; if ($matches[0][0] === '"') { @@ -218,7 +216,7 @@ protected function createToken(string $value): JSONPathToken $tokenValue = ''; } - $ret = new JSONPathToken(JSONPathToken::T_INDEX, $tokenValue); + $ret = new JSONPathToken(TokenType::Index, $tokenValue); } if ($ret !== null) { diff --git a/src/JSONPathToken.php b/src/JSONPathToken.php index 5b81484..65884e3 100644 --- a/src/JSONPathToken.php +++ b/src/JSONPathToken.php @@ -6,72 +6,37 @@ * @license https://github.com/SoftCreatR/JSONPath/blob/main/LICENSE MIT License */ -namespace Flow\JSONPath; - -class JSONPathToken -{ - /* - * Tokens - */ - public const T_INDEX = 'index'; - - public const T_RECURSIVE = 'recursive'; - - public const T_QUERY_RESULT = 'queryResult'; - - public const T_QUERY_MATCH = 'queryMatch'; - - public const T_SLICE = 'slice'; - - public const T_INDEXES = 'indexes'; - - public string $type; - - public mixed $value; +declare(strict_types=1); - /** - * @throws JSONPathException - */ - public function __construct(string $type, $value) - { - $this->validateType($type); +namespace Flow\JSONPath; - $this->type = $type; - $this->value = $value; - } +use Flow\JSONPath\Filters\AbstractFilter; +use Flow\JSONPath\Filters\IndexesFilter; +use Flow\JSONPath\Filters\IndexFilter; +use Flow\JSONPath\Filters\QueryMatchFilter; +use Flow\JSONPath\Filters\QueryResultFilter; +use Flow\JSONPath\Filters\RecursiveFilter; +use Flow\JSONPath\Filters\SliceFilter; - /** - * @throws JSONPathException - */ - public function validateType(string $type): void - { - if (!\in_array($type, static::getTypes(), true)) { - throw new JSONPathException('Invalid token: ' . $type); - } - } - - public static function getTypes(): array - { - return [ - static::T_INDEX, - static::T_RECURSIVE, - static::T_QUERY_RESULT, - static::T_QUERY_MATCH, - static::T_SLICE, - static::T_INDEXES, - ]; +readonly class JSONPathToken +{ + public function __construct( + public TokenType $type, + public mixed $value + ) { + // ... } - /** - * @throws JSONPathException - */ - public function buildFilter(bool $options) + public function buildFilter(int $options): AbstractFilter { - $filterClass = 'Flow\\JSONPath\\Filters\\' . \ucfirst($this->type) . 'Filter'; - - if (!\class_exists($filterClass)) { - throw new JSONPathException("No filter class exists for token [{$this->type}]"); - } + $filterClass = match ($this->type) { + TokenType::Index => IndexFilter::class, + TokenType::Indexes => IndexesFilter::class, + TokenType::QueryMatch => QueryMatchFilter::class, + TokenType::QueryResult => QueryResultFilter::class, + TokenType::Recursive => RecursiveFilter::class, + TokenType::Slice => SliceFilter::class, + }; return new $filterClass($this, $options); } diff --git a/src/TokenType.php b/src/TokenType.php new file mode 100644 index 0000000..985a8a0 --- /dev/null +++ b/src/TokenType.php @@ -0,0 +1,21 @@ + */ + private array $store = ['bar' => 'baz']; + + public function offsetExists($offset): bool + { + return \array_key_exists($offset, $this->store); + } + + public function offsetGet($offset): mixed + { + return $this->store[$offset]; + } + + public function offsetSet($offset, $value): void + { + $this->store[$offset] = $value; + } + + public function offsetUnset($offset): void + { + unset($this->store[$offset]); + } + }; + + self::assertTrue(AccessHelper::keyExists($arrayAccess, 'bar')); + self::assertTrue(AccessHelper::keyExists([1 => 'foo'], -1)); + } + + public function testGetValueCoversMagicArrayAndArrayAccess(): void + { + $magic = new class { + public function __get(string $name): string + { + return "magic-{$name}"; + } + }; + + $arrayAccess = new class implements ArrayAccess { + /** @var array */ + private array $store = ['bar' => 'baz']; + + public function offsetExists($offset): bool + { + return \array_key_exists($offset, $this->store); + } + + public function offsetGet($offset): mixed + { + return $this->store[$offset]; + } + + public function offsetSet($offset, $value): void + { + $this->store[$offset] = $value; + } + + public function offsetUnset($offset): void + { + unset($this->store[$offset]); + } + }; + + self::assertSame('magic-foo', AccessHelper::getValue($magic, 'foo', true)); + self::assertSame('baz', AccessHelper::getValue($arrayAccess, 'bar')); + self::assertSame('b', AccessHelper::getValue(['a', 'b'], -1)); + self::assertNull(AccessHelper::getValue(['a'], 'missing')); + $plainObject = (object)['prop' => 'value']; + self::assertSame('value', AccessHelper::getValue($plainObject, 'prop')); + } + + public function testGetValueByIndexSupportsTraversableAndNegativeOffset(): void + { + $iterable = new class implements IteratorAggregate { + public function getIterator(): Traversable + { + return new ArrayIterator(['first', 'second', 'third']); + } + }; + + self::assertSame('third', AccessHelper::getValue($iterable, -1)); + self::assertSame('second', AccessHelper::getValue($iterable, 1)); + } + + public function testGetValueNullCases(): void + { + self::assertNull(AccessHelper::getValue('scalar', 'foo')); + self::assertNull(AccessHelper::getValue('scalar', 5)); + + $iterable = new ArrayIterator(['only']); + self::assertNull(AccessHelper::getValue($iterable, 5)); + } + + public function testArrayValuesThrowsOnInvalidType(): void + { + $this->expectException(JSONPathException::class); + AccessHelper::arrayValues('not-an-array'); + } + + /** + * @throws JSONPathException + */ + public function testArrayValuesCastsObject(): void + { + $obj = (object)['a' => 1, 'b' => 2]; + self::assertSame([1, 2], AccessHelper::arrayValues($obj)); + } + + public function testGetValueByIndexReturnsNullWhenOutOfRange(): void + { + $iterable = (static function () { + yield 'first'; + })(); + + self::assertNull(AccessHelper::getValue($iterable, 10)); + } + + public function testKeyExistsAndCollectionHelpers(): void + { + $object = (object)['a' => 1]; + self::assertTrue(AccessHelper::keyExists($object, 'a')); + self::assertFalse(AccessHelper::keyExists('scalar', 'a')); + self::assertSame(['a'], AccessHelper::collectionKeys($object)); + self::assertFalse(AccessHelper::isCollectionType('scalar')); + } + + public function testSetAndUnsetValueAcrossTypes(): void + { + $object = (object)['a' => 1]; + AccessHelper::setValue($object, 'b', 2); + self::assertSame(2, $object->b); + + $arrayAccess = new class implements ArrayAccess { + /** @var array */ + public array $store = []; + + public function offsetExists($offset): bool + { + return \array_key_exists($offset, $this->store); + } + + public function offsetGet($offset): mixed + { + return $this->store[$offset]; + } + + public function offsetSet($offset, $value): void + { + $this->store[$offset] = $value; + } + + public function offsetUnset($offset): void + { + unset($this->store[$offset]); + } + }; + + AccessHelper::setValue($arrayAccess, 'k', 'v'); + self::assertSame('v', $arrayAccess->store['k']); + AccessHelper::unsetValue($arrayAccess, 'k'); + self::assertArrayNotHasKey('k', $arrayAccess->store); + + $array = ['x' => 1]; + AccessHelper::unsetValue($array, 'x'); + self::assertArrayNotHasKey('x', $array); + + $obj = (object)['x' => 1]; + AccessHelper::unsetValue($obj, 'x'); + self::assertFalse(\property_exists($obj, 'x')); + + $arrayAccess->offsetUnset('missing'); + } +} diff --git a/tests/JSONPathArrayAccessTest.php b/tests/JSONPathArrayAccessTest.php index 09c619a..098c1d8 100644 --- a/tests/JSONPathArrayAccessTest.php +++ b/tests/JSONPathArrayAccessTest.php @@ -57,7 +57,7 @@ public function testIterating(): void { $container = new ArrayObject($this->getData('conferences')); - $conferences = (new JSONPath($container)) + $conferences = new JSONPath($container) ->find('.conferences.*'); $names = []; @@ -105,6 +105,7 @@ public function testUpdate(): void $data = new JSONPath($container); $data->offsetSet('name', 'Major League Football'); + /** @phpstan-ignore-next-line */ self::assertEquals('Major League Football', $data->name); } } diff --git a/tests/JSONPathArrayTest.php b/tests/JSONPathArrayTest.php index 3cb33f4..d900191 100644 --- a/tests/JSONPathArrayTest.php +++ b/tests/JSONPathArrayTest.php @@ -54,7 +54,7 @@ public function testIterating(): void { $data = $this->getData('conferences'); - $conferences = (new JSONPath($data)) + $conferences = new JSONPath($data) ->find('.conferences.*'); $names = []; diff --git a/tests/JSONPathDashedIndexTest.php b/tests/JSONPathDashedIndexTest.php index c98cc31..4cedbc8 100644 --- a/tests/JSONPathDashedIndexTest.php +++ b/tests/JSONPathDashedIndexTest.php @@ -18,7 +18,7 @@ class JSONPathDashedIndexTest extends TestCase { /** - * @return array[] + * @return list, array}> */ public static function indexDataProvider(): array { @@ -38,11 +38,13 @@ public static function indexDataProvider(): array /** * @throws JSONPathException + * @param array $data + * @param array $expected */ #[DataProvider('indexDataProvider')] public function testSlice(string $path, array $data, array $expected): void { - $results = (new JSONPath($data)) + $results = new JSONPath($data) ->find($path); self::assertEquals($expected, $results->getData()); diff --git a/tests/JSONPathLexerTest.php b/tests/JSONPathLexerTest.php index 4a3223c..bd2d866 100644 --- a/tests/JSONPathLexerTest.php +++ b/tests/JSONPathLexerTest.php @@ -12,7 +12,7 @@ use Flow\JSONPath\JSONPathException; use Flow\JSONPath\JSONPathLexer; -use Flow\JSONPath\JSONPathToken; +use Flow\JSONPath\TokenType; use PHPUnit\Framework\TestCase; class JSONPathLexerTest extends TestCase @@ -22,10 +22,10 @@ class JSONPathLexerTest extends TestCase */ public function testIndexWildcard(): void { - $tokens = (new JSONPathLexer('.*')) + $tokens = new JSONPathLexer('.*') ->parseExpression(); - self::assertEquals(JSONPathToken::T_INDEX, $tokens[0]->type); + self::assertEquals(TokenType::Index, $tokens[0]->type); self::assertEquals("*", $tokens[0]->value); } @@ -34,10 +34,10 @@ public function testIndexWildcard(): void */ public function testIndexSimple(): void { - $tokens = (new JSONPathLexer('.foo')) + $tokens = new JSONPathLexer('.foo') ->parseExpression(); - self::assertEquals(JSONPathToken::T_INDEX, $tokens[0]->type); + self::assertEquals(TokenType::Index, $tokens[0]->type); self::assertEquals("foo", $tokens[0]->value); } @@ -46,15 +46,15 @@ public function testIndexSimple(): void */ public function testIndexRecursive(): void { - $tokens = (new JSONPathLexer('..teams.*')) + $tokens = new JSONPathLexer('..teams.*') ->parseExpression(); self::assertCount(3, $tokens); - self::assertEquals(JSONPathToken::T_RECURSIVE, $tokens[0]->type); + self::assertEquals(TokenType::Recursive, $tokens[0]->type); self::assertEquals(null, $tokens[0]->value); - self::assertEquals(JSONPathToken::T_INDEX, $tokens[1]->type); + self::assertEquals(TokenType::Index, $tokens[1]->type); self::assertEquals('teams', $tokens[1]->value); - self::assertEquals(JSONPathToken::T_INDEX, $tokens[2]->type); + self::assertEquals(TokenType::Index, $tokens[2]->type); self::assertEquals('*', $tokens[2]->value); } @@ -63,10 +63,10 @@ public function testIndexRecursive(): void */ public function testIndexComplex(): void { - $tokens = (new JSONPathLexer('["\'b.^*_"]')) + $tokens = new JSONPathLexer('["\'b.^*_"]') ->parseExpression(); - self::assertEquals(JSONPathToken::T_INDEX, $tokens[0]->type); + self::assertEquals(TokenType::Index, $tokens[0]->type); self::assertEquals("'b.^*_", $tokens[0]->value); } @@ -78,7 +78,7 @@ public function testIndexBadlyFormed(): void $this->expectException(JSONPathException::class); $this->expectExceptionMessage('Unable to parse token hello* in expression: .hello*'); - (new JSONPathLexer('.hello*')) + new JSONPathLexer('.hello*') ->parseExpression(); } @@ -87,11 +87,11 @@ public function testIndexBadlyFormed(): void */ public function testIndexInteger(): void { - $tokens = (new JSONPathLexer('[0]')) + $tokens = new JSONPathLexer('[0]') ->parseExpression(); - self::assertEquals(JSONPathToken::T_INDEX, $tokens[0]->type); - self::assertEquals("0", $tokens[0]->value); + self::assertEquals(TokenType::Index, $tokens[0]->type); + self::assertSame(0, $tokens[0]->value); } /** @@ -99,13 +99,13 @@ public function testIndexInteger(): void */ public function testIndexIntegerAfterDotNotation(): void { - $tokens = (new JSONPathLexer('.books[0]')) + $tokens = new JSONPathLexer('.books[0]') ->parseExpression(); - self::assertEquals(JSONPathToken::T_INDEX, $tokens[0]->type); - self::assertEquals(JSONPathToken::T_INDEX, $tokens[1]->type); + self::assertEquals(TokenType::Index, $tokens[0]->type); + self::assertEquals(TokenType::Index, $tokens[1]->type); self::assertEquals("books", $tokens[0]->value); - self::assertEquals("0", $tokens[1]->value); + self::assertSame(0, $tokens[1]->value); } /** @@ -113,10 +113,10 @@ public function testIndexIntegerAfterDotNotation(): void */ public function testIndexWord(): void { - $tokens = (new JSONPathLexer('["foo$-/\'"]')) + $tokens = new JSONPathLexer('["foo$-/\'"]') ->parseExpression(); - self::assertEquals(JSONPathToken::T_INDEX, $tokens[0]->type); + self::assertEquals(TokenType::Index, $tokens[0]->type); self::assertEquals("foo$-/'", $tokens[0]->value); } @@ -125,10 +125,10 @@ public function testIndexWord(): void */ public function testIndexWordWithWhitespace(): void { - $tokens = (new JSONPathLexer('[ "foo$-/\'" ]')) + $tokens = new JSONPathLexer('[ "foo$-/\'" ]') ->parseExpression(); - self::assertEquals(JSONPathToken::T_INDEX, $tokens[0]->type); + self::assertEquals(TokenType::Index, $tokens[0]->type); self::assertEquals("foo$-/'", $tokens[0]->value); } @@ -137,10 +137,10 @@ public function testIndexWordWithWhitespace(): void */ public function testSliceSimple(): void { - $tokens = (new JSONPathLexer('[0:1:2]')) + $tokens = new JSONPathLexer('[0:1:2]') ->parseExpression(); - self::assertEquals(JSONPathToken::T_SLICE, $tokens[0]->type); + self::assertEquals(TokenType::Slice, $tokens[0]->type); self::assertEquals(['start' => 0, 'end' => 1, 'step' => 2], $tokens[0]->value); } @@ -149,10 +149,10 @@ public function testSliceSimple(): void */ public function testIndexNegativeIndex(): void { - $tokens = (new JSONPathLexer('[-1]')) + $tokens = new JSONPathLexer('[-1]') ->parseExpression(); - self::assertEquals(JSONPathToken::T_SLICE, $tokens[0]->type); + self::assertEquals(TokenType::Slice, $tokens[0]->type); self::assertEquals(['start' => -1, 'end' => null, 'step' => null], $tokens[0]->value); } @@ -161,10 +161,10 @@ public function testIndexNegativeIndex(): void */ public function testSliceAllNull(): void { - $tokens = (new JSONPathLexer('[:]')) + $tokens = new JSONPathLexer('[:]') ->parseExpression(); - self::assertEquals(JSONPathToken::T_SLICE, $tokens[0]->type); + self::assertEquals(TokenType::Slice, $tokens[0]->type); self::assertEquals(['start' => null, 'end' => null, 'step' => null], $tokens[0]->value); } @@ -173,10 +173,10 @@ public function testSliceAllNull(): void */ public function testQueryResultSimple(): void { - $tokens = (new JSONPathLexer('[(@.foo + 2)]')) + $tokens = new JSONPathLexer('[(@.foo + 2)]') ->parseExpression(); - self::assertEquals(JSONPathToken::T_QUERY_RESULT, $tokens[0]->type); + self::assertEquals(TokenType::QueryResult, $tokens[0]->type); self::assertEquals('@.foo + 2', $tokens[0]->value); } @@ -185,10 +185,10 @@ public function testQueryResultSimple(): void */ public function testQueryMatchSimple(): void { - $tokens = (new JSONPathLexer('[?(@.foo < \'bar\')]')) + $tokens = new JSONPathLexer('[?(@.foo < \'bar\')]') ->parseExpression(); - self::assertEquals(JSONPathToken::T_QUERY_MATCH, $tokens[0]->type); + self::assertEquals(TokenType::QueryMatch, $tokens[0]->type); self::assertEquals('@.foo < \'bar\'', $tokens[0]->value); } @@ -197,10 +197,10 @@ public function testQueryMatchSimple(): void */ public function testQueryMatchNotEqualTO(): void { - $tokens = (new JSONPathLexer('[?(@.foo != \'bar\')]')) + $tokens = new JSONPathLexer('[?(@.foo != \'bar\')]') ->parseExpression(); - self::assertEquals(JSONPathToken::T_QUERY_MATCH, $tokens[0]->type); + self::assertEquals(TokenType::QueryMatch, $tokens[0]->type); self::assertEquals('@.foo != \'bar\'', $tokens[0]->value); } @@ -209,10 +209,10 @@ public function testQueryMatchNotEqualTO(): void */ public function testQueryMatchBrackets(): void { - $tokens = (new JSONPathLexer("[?(@['@language']='en')]")) + $tokens = new JSONPathLexer("[?(@['@language']='en')]") ->parseExpression(); - self::assertEquals(JSONPathToken::T_QUERY_MATCH, $tokens[0]->type); + self::assertEquals(TokenType::QueryMatch, $tokens[0]->type); self::assertEquals("@['@language']='en'", $tokens[0]->value); } @@ -221,11 +221,11 @@ public function testQueryMatchBrackets(): void */ public function testRecursiveSimple(): void { - $tokens = (new JSONPathLexer('..foo')) + $tokens = new JSONPathLexer('..foo') ->parseExpression(); - self::assertEquals(JSONPathToken::T_RECURSIVE, $tokens[0]->type); - self::assertEquals(JSONPathToken::T_INDEX, $tokens[1]->type); + self::assertEquals(TokenType::Recursive, $tokens[0]->type); + self::assertEquals(TokenType::Index, $tokens[1]->type); self::assertEquals(null, $tokens[0]->value); self::assertEquals('foo', $tokens[1]->value); } @@ -235,11 +235,11 @@ public function testRecursiveSimple(): void */ public function testRecursiveWildcard(): void { - $tokens = (new JSONPathLexer('..*')) + $tokens = new JSONPathLexer('..*') ->parseExpression(); - self::assertEquals(JSONPathToken::T_RECURSIVE, $tokens[0]->type); - self::assertEquals(JSONPathToken::T_INDEX, $tokens[1]->type); + self::assertEquals(TokenType::Recursive, $tokens[0]->type); + self::assertEquals(TokenType::Index, $tokens[1]->type); self::assertEquals(null, $tokens[0]->value); self::assertEquals('*', $tokens[1]->value); } @@ -252,7 +252,7 @@ public function testRecursiveBadlyFormed(): void $this->expectException(JSONPathException::class); $this->expectExceptionMessage('Unable to parse token ba^r in expression: ..ba^r'); - (new JSONPathLexer('..ba^r')) + new JSONPathLexer('..ba^r') ->parseExpression(); } @@ -261,10 +261,10 @@ public function testRecursiveBadlyFormed(): void */ public function testIndexesSimple(): void { - $tokens = (new JSONPathLexer('[1,2,3]')) + $tokens = new JSONPathLexer('[1,2,3]') ->parseExpression(); - self::assertEquals(JSONPathToken::T_INDEXES, $tokens[0]->type); + self::assertEquals(TokenType::Indexes, $tokens[0]->type); self::assertEquals([1, 2, 3], $tokens[0]->value); } @@ -273,10 +273,27 @@ public function testIndexesSimple(): void */ public function testIndexesWhitespace(): void { - $tokens = (new JSONPathLexer('[ 1,2 , 3]')) + $tokens = new JSONPathLexer('[ 1,2 , 3]') ->parseExpression(); - self::assertEquals(JSONPathToken::T_INDEXES, $tokens[0]->type); + self::assertEquals(TokenType::Indexes, $tokens[0]->type); self::assertEquals([1, 2, 3], $tokens[0]->value); } + + /** + * @throws JSONPathException + */ + public function testEmptyExpressionsReturnNoTokens(): void + { + self::assertSame([], new JSONPathLexer('')->parseExpression()); + self::assertSame([], new JSONPathLexer('$')->parseExpression()); + } + + /** + * @throws JSONPathException + */ + public function testSingleCharacterExpressionNormalized(): void + { + self::assertSame([], new JSONPathLexer('.')->parseExpression()); + } } diff --git a/tests/JSONPathSliceAccessTest.php b/tests/JSONPathSliceAccessTest.php index 9977ca8..4a18ae0 100644 --- a/tests/JSONPathSliceAccessTest.php +++ b/tests/JSONPathSliceAccessTest.php @@ -17,6 +17,9 @@ class JSONPathSliceAccessTest extends TestCase { + /** + * @return list, array}> + */ public static function sliceDataProvider(): array { return [ @@ -80,11 +83,13 @@ public static function sliceDataProvider(): array /** * @throws JSONPathException + * @param array $data + * @param array $expected */ #[DataProvider('sliceDataProvider')] public function testSlice(string $path, array $data, array $expected): void { - $result = (new JSONPath($data)) + $result = new JSONPath($data) ->find($path); self::assertEquals($expected, $result->getData()); diff --git a/tests/JSONPathTest.php b/tests/JSONPathTest.php index 049bf3b..a5bd09a 100644 --- a/tests/JSONPathTest.php +++ b/tests/JSONPathTest.php @@ -25,22 +25,22 @@ class JSONPathTest extends TestCase /** * $.store.books[0].title * - * @throws JSONPathException|JsonException + * @throws JSONPathException */ public function testChildOperators(): void { - $result = (new JSONPath($this->getData('example'))) + $result = new JSONPath($this->getData('example')) ->find('$.store.books[0].title'); self::assertEquals('Sayings of the Century', $result[0]); } /** - * @throws JSONPathException|JsonException + * @throws JSONPathException */ public function testIndexesObject(): void { - $result = (new JSONPath($this->getData('indexed-object'))) + $result = new JSONPath($this->getData('indexed-object')) ->find('$.store.books[3].title'); self::assertEquals('Sword of Honour', $result[0]); @@ -49,11 +49,11 @@ public function testIndexesObject(): void /** * $['store']['books'][0]['title'] * - * @throws JSONPathException|JsonException + * @throws JSONPathException */ public function testChildOperatorsAlt(): void { - $result = (new JSONPath($this->getData('example'))) + $result = new JSONPath($this->getData('example')) ->find("$['store']['books'][0]['title']"); self::assertEquals('Sayings of the Century', $result[0]); @@ -62,12 +62,12 @@ public function testChildOperatorsAlt(): void /** * $.array[start:end:step] * - * @throws JSONPathException|JsonException + * @throws JSONPathException */ public function testFilterSliceA(): void { // Copy all items... similar to a wildcard - $result = (new JSONPath($this->getData('example'))) + $result = new JSONPath($this->getData('example')) ->find("$['store']['books'][:].title"); self::assertEquals( @@ -127,17 +127,17 @@ public function testFilterSlicePositiveEndIndexes(): void */ public function testFilterSliceNegativeStartIndexes(): void { - $result = (new JSONPath(['first', 'second', 'third', 'fourth', 'fifth'])) + $result = new JSONPath(['first', 'second', 'third', 'fourth', 'fifth']) ->find('$[-2:]'); self::assertEquals(['fourth', 'fifth'], $result->getData()); - $result = (new JSONPath(['first', 'second', 'third', 'fourth', 'fifth'])) + $result = new JSONPath(['first', 'second', 'third', 'fourth', 'fifth']) ->find('$[-1:]'); self::assertEquals(['fifth'], $result->getData()); - $result = (new JSONPath(['first', 'second', 'third'])) + $result = new JSONPath(['first', 'second', 'third']) ->find('$[-4:]'); self::assertEquals(['first', 'second', 'third'], $result->getData()); @@ -193,7 +193,7 @@ public function testFilterSliceNegativeStartAndEndIndexes(): void */ public function testFilterSliceNegativeStartAndPositiveEnd(): void { - $result = (new JSONPath(['first', 'second', 'third', 'fourth', 'fifth'])) + $result = new JSONPath(['first', 'second', 'third', 'fourth', 'fifth']) ->find('$[-2:2]'); self::assertEquals([], $result->getData()); @@ -204,7 +204,7 @@ public function testFilterSliceNegativeStartAndPositiveEnd(): void */ public function testFilterSliceStepBy2(): void { - $result = (new JSONPath(['first', 'second', 'third', 'fourth', 'fifth'])) + $result = new JSONPath(['first', 'second', 'third', 'fourth', 'fifth']) ->find('$[0:4:2]'); self::assertEquals(['first', 'third'], $result->getData()); @@ -218,7 +218,7 @@ public function testFilterSliceStepBy2(): void */ public function testFilterLastIndex(): void { - $result = (new JSONPath(['first', 'second', 'third', 'fourth', 'fifth'])) + $result = new JSONPath(['first', 'second', 'third', 'fourth', 'fifth']) ->find('$[-1]'); self::assertEquals(['fifth'], $result->getData()); @@ -233,7 +233,7 @@ public function testFilterLastIndex(): void public function testFilterSliceG(): void { // Fetch up to the second index - $result = (new JSONPath(['first', 'second', 'third', 'fourth', 'fifth'])) + $result = new JSONPath(['first', 'second', 'third', 'fourth', 'fifth']) ->find('$[:2]'); self::assertEquals(['first', 'second'], $result->getData()); @@ -242,13 +242,13 @@ public function testFilterSliceG(): void /** * $.store.books[(@.length-1)].title * - * This notation is only partially implemented eg. hacked in + * This notation is only partially implemented e.g. hacked in * - * @throws JSONPathException|JsonException + * @throws JSONPathException */ public function testChildQuery(): void { - $result = (new JSONPath($this->getData('example'))) + $result = new JSONPath($this->getData('example')) ->find('$.store.books[(@.length-1)].title'); self::assertEquals(['The Lord of the Rings'], $result->getData()); @@ -258,11 +258,11 @@ public function testChildQuery(): void * $.store.books[?(@.price < 10)].title * Filter books that have a price less than 10 * - * @throws JSONPathException|JsonException + * @throws JSONPathException */ public function testQueryMatchLessThan(): void { - $result = (new JSONPath($this->getData('example'))) + $result = new JSONPath($this->getData('example')) ->find('$.store.books[?(@.price < 10)].title'); self::assertEquals(['Sayings of the Century', 'Moby Dick'], $result->getData()); @@ -272,11 +272,11 @@ public function testQueryMatchLessThan(): void * $.store.books[?(@.price > 10)].title * Filter books that have a price more than 10 * - * @throws JSONPathException|JsonException + * @throws JSONPathException */ public function testQueryMatchMoreThan(): void { - $result = (new JSONPath($this->getData('example'))) + $result = new JSONPath($this->getData('example')) ->find('$.store.books[?(@.price > 10)].title'); self::assertEquals(['Sword of Honour', 'The Lord of the Rings'], $result->getData()); @@ -286,11 +286,11 @@ public function testQueryMatchMoreThan(): void * $.store.books[?(@.price <= 12.99)].title * Filter books that have a price less or equal to 12.99 * - * @throws JSONPathException|JsonException + * @throws JSONPathException */ public function testQueryMatchLessOrEqual(): void { - $result = (new JSONPath($this->getData('example'))) + $result = new JSONPath($this->getData('example')) ->find('$.store.books[?(@.price <= 12.99)].title'); self::assertEquals(['Sayings of the Century', 'Sword of Honour', 'Moby Dick'], $result->getData()); @@ -300,11 +300,11 @@ public function testQueryMatchLessOrEqual(): void * $.store.books[?(@.price >= 12.99)].title * Filter books that have a price less or equal to 12.99 * - * @throws JSONPathException|JsonException + * @throws JSONPathException */ public function testQueryMatchEqualOrMore(): void { - $result = (new JSONPath($this->getData('example'))) + $result = new JSONPath($this->getData('example')) ->find('$.store.books[?(@.price >= 12.99)].title'); self::assertEquals(['Sword of Honour', 'The Lord of the Rings'], $result->getData()); @@ -314,11 +314,11 @@ public function testQueryMatchEqualOrMore(): void * $..books[?(@.author == "J. R. R. Tolkien")] * Filter books that have an author equal to "..." * - * @throws JSONPathException|JsonException + * @throws JSONPathException */ public function testQueryMatchEquals(): void { - $result = (new JSONPath($this->getData('example'))) + $result = new JSONPath($this->getData('example')) ->find('$..books[?(@.author == "J. R. R. Tolkien")].title'); self::assertEquals('The Lord of the Rings', $result[0]); @@ -328,12 +328,12 @@ public function testQueryMatchEquals(): void * $..books[?(@.category=="fiction" && @.author == \'Evelyn Waugh\')].title' * Filter books that are in the "..." category and have an author equal to "..." * - * @throws JSONPathException|JsonException + * @throws JSONPathException */ public function testQueryMatchWhitespaceIgnored(): void { // Additional spaces in filter string are intended to be there - $result = (new JSONPath($this->getData('example'))) + $result = new JSONPath($this->getData('example')) ->find('$..books[?( @.category=="fiction" && @.author == \'Evelyn Waugh\' )].title'); self::assertEquals('Sword of Honour', $result[0]); @@ -343,11 +343,11 @@ public function testQueryMatchWhitespaceIgnored(): void * $..books[?(@.author = 1)] * Filter books that have a title equal to "..." * - * @throws JSONPathException|JsonException + * @throws JSONPathException */ public function testQueryMatchEqualsWithUnquotedInteger(): void { - $results = (new JSONPath($this->getData('simple-integers'))) + $results = new JSONPath($this->getData('simple-integers')) ->find('$..features[?(@.value = 1)]'); self::assertEquals('foo', $results[0]->name); @@ -358,7 +358,7 @@ public function testQueryMatchEqualsWithUnquotedInteger(): void * $..books[?(@.author != "J. R. R. Tolkien")] * Filter books that have an author not equal to "..." * - * @throws JSONPathException|JsonException + * @throws JSONPathException */ public function testQueryMatchNotEqualsTo(): void { @@ -387,7 +387,7 @@ public function testQueryMatchNotEqualsTo(): void * $..books[?(@.author =~ /nigel ree?s/i)] * Filter books where author matches regex * - * @throws JSONPathException|JsonException + * @throws JSONPathException */ public function testQueryMatchWithRegexCaseSensitive(): void { @@ -410,11 +410,11 @@ public function testQueryMatchWithRegexCaseSensitive(): void * $..books[?(@.author =~ "J. R. R. Tolkien")] * Filter books where author matches invalid regex * - * @throws JSONPathException|JsonException + * @throws JSONPathException */ public function testQueryMatchWithInvalidRegex(): void { - $result = (new JSONPath($this->getData('example'))) + $result = new JSONPath($this->getData('example')) ->find('$..books[?(@.author =~ "J. R. R. Tolkien")].title'); self::assertEmpty($result->getData()); @@ -424,11 +424,11 @@ public function testQueryMatchWithInvalidRegex(): void * $..books[?(@.author in ["J. R. R. Tolkien", "Nigel Rees"])] * Filter books that have a title in ["...", "..."] * - * @throws JSONPathException|JsonException + * @throws JSONPathException */ public function testQueryMatchIn(): void { - $results = (new JSONPath($this->getData('example'))) + $results = new JSONPath($this->getData('example')) ->find('$..books[?(@.author in ["J. R. R. Tolkien", "Nigel Rees"])].title'); self::assertEquals(['Sayings of the Century', 'The Lord of the Rings'], $results->getData()); @@ -438,11 +438,11 @@ public function testQueryMatchIn(): void * $..books[?(@.author nin ["J. R. R. Tolkien", "Nigel Rees"])] * Filter books that don't have a title in ["...", "..."] * - * @throws JSONPathException|JsonException + * @throws JSONPathException */ public function testQueryMatchNin(): void { - $results = (new JSONPath($this->getData('example'))) + $results = new JSONPath($this->getData('example')) ->find('$..books[?(@.author nin ["J. R. R. Tolkien", "Nigel Rees"])].title'); self::assertEquals(['Sword of Honour', 'Moby Dick'], $results->getData()); @@ -452,11 +452,11 @@ public function testQueryMatchNin(): void * $..books[?(@.author nin ["J. R. R. Tolkien", "Nigel Rees"])] * Filter books that don't have a title in ["...", "..."] * - * @throws JSONPathException|JsonException + * @throws JSONPathException */ public function testQueryMatchNotIn(): void { - $results = (new JSONPath($this->getData('example'))) + $results = new JSONPath($this->getData('example')) ->find('$..books[?(@.author !in ["J. R. R. Tolkien", "Nigel Rees"])].title'); self::assertEquals(['Sword of Honour', 'Moby Dick'], $results->getData()); @@ -465,11 +465,11 @@ public function testQueryMatchNotIn(): void /** * $.store.books[*].author * - * @throws JSONPathException|JsonException + * @throws JSONPathException */ public function testWildcardAltNotation(): void { - $results = (new JSONPath($this->getData('example'))) + $results = new JSONPath($this->getData('example')) ->find('$.store.books[*].author'); self::assertEquals(['Nigel Rees', 'Evelyn Waugh', 'Herman Melville', 'J. R. R. Tolkien'], $results->getData()); @@ -478,11 +478,11 @@ public function testWildcardAltNotation(): void /** * $..author * - * @throws JSONPathException|JsonException + * @throws JSONPathException */ public function testRecursiveChildSearch(): void { - $result = (new JSONPath($this->getData('example'))) + $result = new JSONPath($this->getData('example')) ->find('$..author'); self::assertEquals(['Nigel Rees', 'Evelyn Waugh', 'Herman Melville', 'J. R. R. Tolkien'], $result->getData()); @@ -493,11 +493,11 @@ public function testRecursiveChildSearch(): void * all things in store * the structure of the example data makes this test look weird * - * @throws JSONPathException|JsonException + * @throws JSONPathException */ public function testWildCard(): void { - $result = (new JSONPath($this->getData('example'))) + $result = new JSONPath($this->getData('example')) ->find('$.store.*'); if (\is_object($result[0][0])) { @@ -517,11 +517,11 @@ public function testWildCard(): void * $.store..price * the price of everything in the store. * - * @throws JSONPathException|JsonException + * @throws JSONPathException */ public function testRecursiveChildSearchAlt(): void { - $result = (new JSONPath($this->getData('example'))) + $result = new JSONPath($this->getData('example')) ->find('$.store..price'); self::assertEquals([8.95, 12.99, 8.99, 22.99, 19.95], $result->getData()); @@ -531,11 +531,11 @@ public function testRecursiveChildSearchAlt(): void * $..books[2] * the third book * - * @throws JSONPathException|JsonException + * @throws JSONPathException */ public function testRecursiveChildSearchWithChildIndex(): void { - $result = (new JSONPath($this->getData('example'))) + $result = new JSONPath($this->getData('example')) ->find('$..books[2].title'); self::assertEquals(['Moby Dick'], $result->getData()); @@ -544,11 +544,11 @@ public function testRecursiveChildSearchWithChildIndex(): void /** * $..books[(@.length-1)] * - * @throws JSONPathException|JsonException + * @throws JSONPathException */ public function testRecursiveChildSearchWithChildQuery(): void { - $result = (new JSONPath($this->getData('example'))) + $result = new JSONPath($this->getData('example')) ->find('$..books[(@.length-1)].title'); self::assertEquals(['The Lord of the Rings'], $result->getData()); @@ -558,11 +558,11 @@ public function testRecursiveChildSearchWithChildQuery(): void * $..books[-1:] * Return the last results * - * @throws JSONPathException|JsonException + * @throws JSONPathException */ public function testRecursiveChildSearchWithSliceFilter(): void { - $result = (new JSONPath($this->getData('example'))) + $result = new JSONPath($this->getData('example')) ->find('$..books[-1:].title'); self::assertEquals(['The Lord of the Rings'], $result->getData()); @@ -570,13 +570,13 @@ public function testRecursiveChildSearchWithSliceFilter(): void /** * $..books[?(@.isbn)] - * filter all books with isbn number + * filter all books with isbn * - * @throws JSONPathException|JsonException + * @throws JSONPathException */ public function testRecursiveWithQueryMatch(): void { - $result = (new JSONPath($this->getData('example'))) + $result = new JSONPath($this->getData('example')) ->find('$..books[?(@.isbn)].isbn'); self::assertEquals(['0-553-21311-3', '0-395-19395-8'], $result->getData()); @@ -590,7 +590,7 @@ public function testRecursiveWithQueryMatch(): void */ public function testRecursiveWithQueryMatchWithDots(): void { - $result = (new JSONPath($this->getData('with-dots'))) + $result = new JSONPath($this->getData('with-dots')) ->find(".data.tokens[?(@.Employee.FirstName)]"); $result = \json_decode( \json_encode($result, JSON_THROW_ON_ERROR), @@ -610,7 +610,7 @@ public function testRecursiveWithQueryMatchWithDots(): void */ public function testRecursiveWithWildcard(): void { - $result = (new JSONPath($this->getData('example'))) + $result = new JSONPath($this->getData('example')) ->find('$..*'); $result = \json_decode( \json_encode($result, JSON_THROW_ON_ERROR), @@ -630,7 +630,7 @@ public function testRecursiveWithWildcard(): void */ public function testSimpleArrayAccess(): void { - $result = (new JSONPath(['title' => 'test title'])) + $result = new JSONPath(['title' => 'test title']) ->find('title'); self::assertEquals(['test title'], $result->getData()); @@ -641,7 +641,7 @@ public function testSimpleArrayAccess(): void */ public function testFilteringOnNoneArrays(): void { - $result = (new JSONPath(['foo' => 'asdf'])) + $result = new JSONPath(['foo' => 'asdf']) ->find('$.foo.bar'); self::assertEquals([], $result->getData()); @@ -653,28 +653,28 @@ public function testFilteringOnNoneArrays(): void public function testMagicMethods(): void { $fooClass = new JSONPathTestClass(); - $results = (new JSONPath($fooClass, JSONPath::ALLOW_MAGIC))->find('$.foo'); + $results = new JSONPath($fooClass, JSONPath::ALLOW_MAGIC)->find('$.foo'); self::assertEquals(['bar'], $results->getData()); } /** - * @throws JSONPathException|JsonException + * @throws JSONPathException */ public function testMatchWithComplexSquareBrackets(): void { - $result = (new JSONPath($this->getData('extra'))) + $result = new JSONPath($this->getData('extra')) ->find("$['http://www.w3.org/2000/01/rdf-schema#label'][?(@['@language']='en')]['@language']"); self::assertEquals(["en"], $result->getData()); } /** - * @throws JSONPathException|JsonException + * @throws JSONPathException */ public function testQueryMatchWithRecursive(): void { - $result = (new JSONPath($this->getData('locations'))) + $result = new JSONPath($this->getData('locations')) ->find("..[?(@.type == 'suburb')].name"); self::assertEquals(["Rosebank"], $result->getData()); @@ -685,7 +685,7 @@ public function testQueryMatchWithRecursive(): void */ public function testFirst(): void { - $result = (new JSONPath($this->getData('extra'))) + $result = new JSONPath($this->getData('extra')) ->find("$['http://www.w3.org/2000/01/rdf-schema#label'].*"); self::assertEquals(["@language" => "en"], $result->first()->getData()); @@ -696,18 +696,18 @@ public function testFirst(): void */ public function testLast(): void { - $result = (new JSONPath($this->getData('extra'))) + $result = new JSONPath($this->getData('extra')) ->find("$['http://www.w3.org/2000/01/rdf-schema#label'].*"); self::assertEquals(["@language" => "de"], $result->last()->getData()); } /** - * @throws JSONPathException|JsonException + * @throws JSONPathException */ public function testSlashesInIndex(): void { - $result = (new JSONPath($this->getData('with-slashes'))) + $result = new JSONPath($this->getData('with-slashes')) ->find("$['mediatypes']['image/png']"); self::assertEquals(["/core/img/filetypes/image.png"], $result->getData()); @@ -718,12 +718,12 @@ public function testSlashesInIndex(): void */ public function testUnionWithKeys(): void { - $result = (new JSONPath( + $result = new JSONPath( [ "key" => "value", "another" => "entry", ] - ))->find("$['key','another']"); + )->find("$['key','another']"); self::assertEquals(["value", "entry"], $result->getData()); } @@ -733,15 +733,15 @@ public function testUnionWithKeys(): void */ public function testCyrillicText(): void { - $jsonPath = (new JSONPath(["Ρ‚Ρ€ΠΎΠ»ΠΎΠ»ΠΎ" => 1])); + $jsonPath = (new JSONPath(["Ρ‚Ρ€ΠΎΠ»ΠΈΠ»ΠΎ" => 1])); $result = $jsonPath - ->find("$['Ρ‚Ρ€ΠΎΠ»ΠΎΠ»ΠΎ']"); + ->find("$['Ρ‚Ρ€ΠΎΠ»ΠΈΠ»ΠΎ']"); self::assertEquals([1], $result->getData()); $result = $jsonPath - ->find("$.Ρ‚Ρ€ΠΎΠ»ΠΎΠ»ΠΎ"); + ->find("$.Ρ‚Ρ€ΠΎΠ»ΠΈΠ»ΠΎ"); self::assertEquals([1], $result->getData()); } @@ -768,12 +768,12 @@ public function testOffsetUnset(): void public function testFirstKey(): void { // Array test for array - $firstKey = (new JSONPath(['a' => 'A', 'b', 'B']))->firstKey(); + $firstKey = new JSONPath(['a' => 'A', 'b', 'B'])->firstKey(); self::assertEquals('a', $firstKey); // Array test for object - $firstKey = (new JSONPath((object)['a' => 'A', 'b', 'B']))->firstKey(); + $firstKey = new JSONPath((object)['a' => 'A', 'b', 'B'])->firstKey(); self::assertEquals('a', $firstKey); } @@ -781,12 +781,12 @@ public function testFirstKey(): void public function testLastKey(): void { // Array test for array - $lastKey = (new JSONPath(['a' => 'A', 'b' => 'B', 'c' => 'C']))->lastKey(); + $lastKey = new JSONPath(['a' => 'A', 'b' => 'B', 'c' => 'C'])->lastKey(); self::assertEquals('c', $lastKey); // Array test for object - $lastKey = (new JSONPath((object)['a' => 'A', 'b' => 'B', 'c' => 'C']))->lastKey(); + $lastKey = new JSONPath((object)['a' => 'A', 'b' => 'B', 'c' => 'C'])->lastKey(); self::assertEquals('c', $lastKey); } @@ -794,11 +794,11 @@ public function testLastKey(): void /** * Test: ensure trailing comma is stripped during parsing * - * @throws JSONPathException|JsonException + * @throws JSONPathException */ public function testTrailingComma(): void { - $result = (new JSONPath($this->getData('example'))) + $result = new JSONPath($this->getData('example')) ->find("$..books[0,1,2,]"); self::assertCount(3, $result); @@ -807,27 +807,76 @@ public function testTrailingComma(): void /** * Test: ensure negative indexes return -n from last index * - * @throws JSONPathException|JsonException + * @throws JSONPathException */ public function testNegativeIndex(): void { - $result = (new JSONPath($this->getData('example'))) + $result = new JSONPath($this->getData('example')) ->find('$..books[-2]'); self::assertEquals("Herman Melville", $result[0]['author']); } + public function testIteratorMetadataOnEmptyCollection(): void + { + $path = new JSONPath([]); + + self::assertNull($path->key()); + self::assertFalse($path->valid()); + self::assertSame(0, $path->count()); + } + + public function testArrayAccessMutators(): void + { + $path = new JSONPath([]); + $path[] = 'foo'; + $path['bar'] = 'baz'; + + self::assertSame('foo', $path[0]); + self::assertSame('baz', $path['bar']); + + unset($path['bar']); + self::assertFalse(isset($path['bar'])); + } + + public function testMagicGetReturnsNullWhenMissing(): void + { + $path = new JSONPath(['foo' => 'bar']); + /** @phpstan-ignore-next-line */ + self::assertNull($path->missing); + } + + public function testIteratorRewindAndKey(): void + { + $path = new JSONPath(['alpha' => 1, 'beta' => 2]); + + $path->rewind(); + self::assertSame('alpha', $path->key()); + $path->next(); + self::assertSame('beta', $path->key()); + } + + public function testFirstAndLastOnEmptyReturnNull(): void + { + $path = new JSONPath([]); + + self::assertNull($path->first()); + self::assertNull($path->last()); + self::assertNull($path->firstKey()); + self::assertNull($path->lastKey()); + } + /** - * @throws JSONPathException|JsonException + * @throws JSONPathException */ public function testQueryAccessWithNumericalIndexes(): void { - $result = (new JSONPath($this->getData('numerical-indexes-object'))) + $result = new JSONPath($this->getData('numerical-indexes-object')) ->find("$.result.list[?(@.o == \"11.51000\")]"); self::assertEquals("11.51000", $result[0]->o); - $result = (new JSONPath($this->getData('numerical-indexes-array'))) + $result = new JSONPath($this->getData('numerical-indexes-array')) ->find("$.result.list[?(@[1] == \"11.51000\")]"); self::assertEquals("11.51000", $result[0][1]); diff --git a/tests/JSONPathTestClass.php b/tests/JSONPathTestClass.php index 46610e3..e3b83bc 100644 --- a/tests/JSONPathTestClass.php +++ b/tests/JSONPathTestClass.php @@ -12,6 +12,7 @@ class JSONPathTestClass { + /** @var array */ protected array $attributes = [ 'foo' => 'bar', ]; diff --git a/tests/JSONPathTokenTest.php b/tests/JSONPathTokenTest.php new file mode 100644 index 0000000..3de620e --- /dev/null +++ b/tests/JSONPathTokenTest.php @@ -0,0 +1,55 @@ +buildFilter(0) + ); + self::assertInstanceOf(IndexesFilter::class, new JSONPathToken(TokenType::Indexes, [])->buildFilter(0)); + self::assertInstanceOf(QueryMatchFilter::class, new JSONPathToken(TokenType::QueryMatch, '')->buildFilter(0)); + self::assertInstanceOf( + QueryResultFilter::class, + new JSONPathToken(TokenType::QueryResult, '')->buildFilter(0) + ); + self::assertInstanceOf(RecursiveFilter::class, new JSONPathToken(TokenType::Recursive, null)->buildFilter(0)); + self::assertInstanceOf( + SliceFilter::class, + new JSONPathToken(TokenType::Slice, ['start' => 0, 'end' => 0, 'step' => 1])->buildFilter(0) + ); + } + + public function testConstructorSetsProperties(): void + { + $token = new JSONPathToken(TokenType::Index, 'value'); + + self::assertSame(TokenType::Index, $token->type); + self::assertSame('value', $token->value); + } +} diff --git a/tests/QueryResultFilterTest.php b/tests/QueryResultFilterTest.php new file mode 100644 index 0000000..4d609e6 --- /dev/null +++ b/tests/QueryResultFilterTest.php @@ -0,0 +1,135 @@ + 3, + 5 => 'bar', + ]; + + self::assertSame(['bar'], $filter->filter($collection)); + } + + /** + * @throws JSONPathException + */ + public function testFilterReturnsEmptyWhenLengthExceeded(): void + { + $token = new JSONPathToken(TokenType::QueryResult, '@.length + 1'); + $filter = new QueryResultFilter($token); + + $collection = ['a', 'b']; + + self::assertSame([], $filter->filter($collection)); + } + + /** + * @throws JSONPathException + */ + #[DataProvider('arithmeticProvider')] + public function testFilterSupportsAllArithmeticOperators(string $expression, int|float $expectedIndex): void + { + $token = new JSONPathToken(TokenType::QueryResult, $expression); + $filter = new QueryResultFilter($token); + + $collection = [ + 'value' => 4, + $expectedIndex => 'found', + ]; + + self::assertSame(['found'], $filter->filter($collection)); + } + + /** + * @return list + */ + public static function arithmeticProvider(): array + { + return [ + ['@.value - 1', 3], + ['@.value * 2', 8], + ['@.value / 2', 2], + ]; + } + + /** + * @throws JSONPathException + */ + public function testFilterFallsBackToLengthWhenKeyMissing(): void + { + $token = new JSONPathToken(TokenType::QueryResult, '@.length - 1'); + $filter = new QueryResultFilter($token); + + $collection = ['zero', 'one']; + + self::assertSame(['one'], $filter->filter($collection)); + } + + /** + * @throws JSONPathException + */ + public function testFilterReturnsEmptyWhenKeyNotFound(): void + { + $token = new JSONPathToken(TokenType::QueryResult, '@.missing + 1'); + $filter = new QueryResultFilter($token); + + self::assertSame([], $filter->filter(['foo' => 'bar'])); + } + + /** + * @throws JSONPathException + */ + public function testFilterReturnsEmptyWhenComputedIndexMissing(): void + { + $token = new JSONPathToken(TokenType::QueryResult, '@.foo + 10'); + $filter = new QueryResultFilter($token); + + self::assertSame([], $filter->filter(['foo' => 1])); + } + + /** + * @throws JSONPathException + */ + public function testFilterReturnsEmptyWhenResultKeyMissing(): void + { + $token = new JSONPathToken(TokenType::QueryResult, '@.foo + 100'); + $filter = new QueryResultFilter($token); + + self::assertSame([], $filter->filter(['foo' => 1])); + } + + public function testUnsupportedOperatorThrows(): void + { + $this->expectException(JSONPathException::class); + + $token = new JSONPathToken(TokenType::QueryResult, '@.foo ^ 2'); + new QueryResultFilter($token)->filter(['foo' => 1]); + } +} diff --git a/tests/QueryTest.php b/tests/QueryTest.php index e832c41..f8b7bdc 100644 --- a/tests/QueryTest.php +++ b/tests/QueryTest.php @@ -19,6 +19,7 @@ class QueryTest extends TestCase { + /** @var list */ public static array $baselineFailedQueries; public static function setUpBeforeClass(): void @@ -57,7 +58,7 @@ public function testQueries( // Avoid "This test did not perform any assertions" // but do not use markTestSkipped, to prevent unnecessary // console outputs - self::assertTrue(true); + self::assertNotSame('', $id); if (empty($consensus) || $skip) { /*$skipReason = empty($consensus) ? 'unknown consensus' : 'skip flag set'; @@ -70,7 +71,7 @@ public function testQueries( } try { - $results = \json_encode((new JSONPath(\json_decode($data, true)))->find($selector)); + $results = \json_encode(new JSONPath(\json_decode($data, true))->find($selector)); self::assertEquals($consensus, $results); @@ -102,7 +103,7 @@ public function testQueries( ); } } - } catch (JSONPathException|RuntimeException $e) { + } catch (JSONPathException | RuntimeException $e) { if (!\in_array($id, self::$baselineFailedQueries, true)) { throw new RuntimeException( $e->getMessage() . "\nQuery: {$query}\n\nMore information: {$url}", @@ -129,7 +130,7 @@ public function testQueries( * The list is generated automatically, based on the results * at https://cburgmer.github.io/json-path-comparison. * - * @return string[] + * @return list */ public static function queryDataProvider(): array { diff --git a/tests/SliceFilterTest.php b/tests/SliceFilterTest.php new file mode 100644 index 0000000..c840e2a --- /dev/null +++ b/tests/SliceFilterTest.php @@ -0,0 +1,71 @@ + $slice + * @param array|object $input + * @param array $expected + */ + #[DataProvider('sliceProvider')] + public function testFilterHandlesNegativeAndNullBounds(array $slice, array|object $input, array $expected): void + { + $token = new JSONPathToken(TokenType::Slice, $slice); + $filter = new SliceFilter($token); + + self::assertSame($expected, $filter->filter($input)); + } + + /** + * @return array, array|object, array}> + */ + public static function sliceProvider(): array + { + return [ + 'negative start clamps at zero' => [ + ['start' => -10, 'end' => 2, 'step' => 1], + ['a', 'b', 'c'], + ['a', 'b'], + ], + 'negative end wraps from length' => [ + ['start' => 0, 'end' => -1, 'step' => 1], + ['a', 'b', 'c'], + ['a', 'b'], + ], + 'nulls default to full length' => [ + ['start' => null, 'end' => null, 'step' => 2], + ['a', 'b', 'c', 'd'], + ['a', 'c'], + ], + 'no results when step negative' => [ + ['start' => 2, 'end' => 0, 'step' => -1], + ['a', 'b', 'c'], + [], + ], + 'works with array object' => [ + ['start' => 0, 'end' => 2, 'step' => 1], + new ArrayObject(['a', 'b', 'c']), + ['a', 'b'], + ], + ]; + } +} diff --git a/tests/data/baselineFailedQueries.txt b/tests/data/baselineFailedQueries.txt index 4f0c2c0..1e15b6d 100644 --- a/tests/data/baselineFailedQueries.txt +++ b/tests/data/baselineFailedQueries.txt @@ -1,31 +1,31 @@ -bracket_notation_with_quoted_closing_bracket_literal -dot_notation_with_key_root_literal -filter_expression_with_tautological_comparison -union_with_wildcard_and_number -array_slice_with_large_number_for_end_and_negative_step -array_slice_with_large_number_for_start_end_negative_step -array_slice_with_negative_step -array_slice_with_negative_step_on_partially_overlapping_array -bracket_notation_with_negative_number_on_short_array -bracket_notation_with_number_on_object -bracket_notation_with_quoted_escaped_backslash -bracket_notation_with_quoted_escaped_single_quote -bracket_notation_with_quoted_special_characters_combined -bracket_notation_with_quoted_wildcard_literal_on_object_without_key -bracket_notation_with_wildcard_after_recursive_descent -dot_notation_with_number -dot_notation_with_number_-1 -dot_notation_with_wildcard_after_recursive_descent -filter_expression_with_bracket_notation_with_-1 -filter_expression_with_equals_with_root_reference -# XFAIL -rfc_semantics_of_null_null_used_as_array -# XFAIL -rfc_semantics_of_null_null_used_as_object -rfc_semantics_of_null_existence -rfc_semantics_of_null_comparison -# XFAIL -rfc_semantics_of_null_comparison_with_missing_value -union_with_filter -union_with_repeated_matches_after_dot_notation_with_wildcard +bracket_notation_with_quoted_closing_bracket_literal +dot_notation_with_key_root_literal +filter_expression_with_tautological_comparison +union_with_wildcard_and_number +array_slice_with_large_number_for_end_and_negative_step +array_slice_with_large_number_for_start_end_negative_step +array_slice_with_negative_step +array_slice_with_negative_step_on_partially_overlapping_array +bracket_notation_with_negative_number_on_short_array +bracket_notation_with_number_on_object +bracket_notation_with_quoted_escaped_backslash +bracket_notation_with_quoted_escaped_single_quote +bracket_notation_with_quoted_special_characters_combined +bracket_notation_with_quoted_wildcard_literal_on_object_without_key +bracket_notation_with_wildcard_after_recursive_descent +dot_notation_with_number +dot_notation_with_number_-1 +dot_notation_with_wildcard_after_recursive_descent +filter_expression_with_bracket_notation_with_-1 +filter_expression_with_equals_with_root_reference +# XFAIL +rfc_semantics_of_null_null_used_as_array +# XFAIL +rfc_semantics_of_null_null_used_as_object +rfc_semantics_of_null_existence +rfc_semantics_of_null_comparison +# XFAIL +rfc_semantics_of_null_comparison_with_missing_value +union_with_filter +union_with_repeated_matches_after_dot_notation_with_wildcard union_with_slice_and_number \ No newline at end of file diff --git a/tests/data/conferences.json b/tests/data/conferences.json index f0c8b85..03c2eb1 100644 --- a/tests/data/conferences.json +++ b/tests/data/conferences.json @@ -1,50 +1,50 @@ -{ - "name": "Major League Baseball", - "abbr": "MLB", - "conferences": [ - { - "name": "Western Conference", - "abbr": "West", - "teams": [ - { - "name": "Dodger", - "city": "Los Angeles", - "whatever": "else", - "players": [ - { - "name": "Bob Smith", - "number": 22 - }, - { - "name": "Joe Face", - "number": 23, - "active": "yes" - } - ] - } - ] - }, - { - "name": "Eastern Conference", - "abbr": "East", - "teams": [ - { - "name": "Mets", - "city": "New York", - "whatever": "else", - "players": [ - { - "name": "something", - "number": 14, - "active": "yes" - }, - { - "name": "something", - "number": 15 - } - ] - } - ] - } - ] -} +{ + "name": "Major League Baseball", + "abbr": "MLB", + "conferences": [ + { + "name": "Western Conference", + "abbr": "West", + "teams": [ + { + "name": "Dodger", + "city": "Los Angeles", + "whatever": "else", + "players": [ + { + "name": "Bob Smith", + "number": 22 + }, + { + "name": "Joe Face", + "number": 23, + "active": "yes" + } + ] + } + ] + }, + { + "name": "Eastern Conference", + "abbr": "East", + "teams": [ + { + "name": "Mets", + "city": "New York", + "whatever": "else", + "players": [ + { + "name": "something", + "number": 14, + "active": "yes" + }, + { + "name": "something", + "number": 15 + } + ] + } + ] + } + ] +} diff --git a/tests/data/example.json b/tests/data/example.json index 54baf41..6a1fff1 100644 --- a/tests/data/example.json +++ b/tests/data/example.json @@ -1,37 +1,37 @@ -{ - "store": { - "books": [ - { - "category": "reference", - "author": "Nigel Rees", - "title": "Sayings of the Century", - "price": 8.95 - }, - { - "category": "fiction", - "author": "Evelyn Waugh", - "title": "Sword of Honour", - "price": 12.99 - }, - { - "category": "fiction", - "author": "Herman Melville", - "title": "Moby Dick", - "isbn": "0-553-21311-3", - "price": 8.99 - }, - { - "category": "fiction", - "author": "J. R. R. Tolkien", - "title": "The Lord of the Rings", - "isbn": "0-395-19395-8", - "price": 22.99 - } - ], - "bicycle": { - "color": "red", - "price": 19.95 - } - }, - "expensive": 10 -} +{ + "store": { + "books": [ + { + "category": "reference", + "author": "Nigel Rees", + "title": "Sayings of the Century", + "price": 8.95 + }, + { + "category": "fiction", + "author": "Evelyn Waugh", + "title": "Sword of Honour", + "price": 12.99 + }, + { + "category": "fiction", + "author": "Herman Melville", + "title": "Moby Dick", + "isbn": "0-553-21311-3", + "price": 8.99 + }, + { + "category": "fiction", + "author": "J. R. R. Tolkien", + "title": "The Lord of the Rings", + "isbn": "0-395-19395-8", + "price": 22.99 + } + ], + "bicycle": { + "color": "red", + "price": 19.95 + } + }, + "expensive": 10 +} diff --git a/tests/data/extra.json b/tests/data/extra.json index 8011098..ca13c43 100644 --- a/tests/data/extra.json +++ b/tests/data/extra.json @@ -1,10 +1,10 @@ -{ - "http://www.w3.org/2000/01/rdf-schema#label": [ - { - "@language": "en" - }, - { - "@language": "de" - } - ] -} +{ + "http://www.w3.org/2000/01/rdf-schema#label": [ + { + "@language": "en" + }, + { + "@language": "de" + } + ] +} diff --git a/tests/data/indexed-object.json b/tests/data/indexed-object.json index d880b07..8f45482 100644 --- a/tests/data/indexed-object.json +++ b/tests/data/indexed-object.json @@ -1,32 +1,32 @@ -{ - "store": { - "books": { - "4": { - "category": "reference", - "author": "Nigel Rees", - "title": "Sayings of the Century", - "price": 8.95 - }, - "3": { - "category": "fiction", - "author": "Evelyn Waugh", - "title": "Sword of Honour", - "price": 12.99 - }, - "2": { - "category": "fiction", - "author": "Herman Melville", - "title": "Moby Dick", - "isbn": "0-553-21311-3", - "price": 8.99 - }, - "1": { - "category": "fiction", - "author": "J. R. R. Tolkien", - "title": "The Lord of the Rings", - "isbn": "0-395-19395-8", - "price": 22.99 - } - } - } -} +{ + "store": { + "books": { + "4": { + "category": "reference", + "author": "Nigel Rees", + "title": "Sayings of the Century", + "price": 8.95 + }, + "3": { + "category": "fiction", + "author": "Evelyn Waugh", + "title": "Sword of Honour", + "price": 12.99 + }, + "2": { + "category": "fiction", + "author": "Herman Melville", + "title": "Moby Dick", + "isbn": "0-553-21311-3", + "price": 8.99 + }, + "1": { + "category": "fiction", + "author": "J. R. R. Tolkien", + "title": "The Lord of the Rings", + "isbn": "0-395-19395-8", + "price": 22.99 + } + } + } +} diff --git a/tests/data/locations.json b/tests/data/locations.json index 2a2a824..0943d78 100644 --- a/tests/data/locations.json +++ b/tests/data/locations.json @@ -1,12 +1,12 @@ -{ - "name": "Gauteng", - "type": "province", - "child": { - "name": "Johannesburg", - "type": "city", - "child": { - "name": "Rosebank", - "type": "suburb" - } - } -} +{ + "name": "Gauteng", + "type": "province", + "child": { + "name": "Johannesburg", + "type": "city", + "child": { + "name": "Rosebank", + "type": "suburb" + } + } +} diff --git a/tests/data/numerical-indexes-array.json b/tests/data/numerical-indexes-array.json index 4871b16..3c91ce3 100644 --- a/tests/data/numerical-indexes-array.json +++ b/tests/data/numerical-indexes-array.json @@ -1,14 +1,14 @@ -{ - "result": { - "list": [ - [ - 1477526400, - "11.51000" - ], - [ - 1477612800, - "11.49870" - ] - ] - } -} +{ + "result": { + "list": [ + [ + 1477526400, + "11.51000" + ], + [ + 1477612800, + "11.49870" + ] + ] + } +} diff --git a/tests/data/numerical-indexes-object.json b/tests/data/numerical-indexes-object.json index 74eaa20..39d77f0 100644 --- a/tests/data/numerical-indexes-object.json +++ b/tests/data/numerical-indexes-object.json @@ -1,14 +1,14 @@ -{ - "result": { - "list": [ - { - "time": 1477526400, - "o": "11.51000" - }, - { - "time": 1477612800, - "o": "11.49870" - } - ] - } -} +{ + "result": { + "list": [ + { + "time": 1477526400, + "o": "11.51000" + }, + { + "time": 1477612800, + "o": "11.49870" + } + ] + } +} diff --git a/tests/data/simple-integers.json b/tests/data/simple-integers.json index e959394..fbb0d9f 100644 --- a/tests/data/simple-integers.json +++ b/tests/data/simple-integers.json @@ -1,16 +1,16 @@ -{ - "features": [ - { - "name": "foo", - "value": 1 - }, - { - "name": "bar", - "value": 2 - }, - { - "name": "baz", - "value": 1 - } - ] -} +{ + "features": [ + { + "name": "foo", + "value": 1 + }, + { + "name": "bar", + "value": 2 + }, + { + "name": "baz", + "value": 1 + } + ] +} diff --git a/tests/data/with-dots.json b/tests/data/with-dots.json index 8e58e1f..7ad89c3 100644 --- a/tests/data/with-dots.json +++ b/tests/data/with-dots.json @@ -1,15 +1,15 @@ -{ - "data": { - "tokens": [ - { - "Employee.FirstName": "Jack" - }, - { - "Employee.LastName": "Daniels" - }, - { - "Employee.Email": "jd@example.com" - } - ] - } -} +{ + "data": { + "tokens": [ + { + "Employee.FirstName": "Jack" + }, + { + "Employee.LastName": "Daniels" + }, + { + "Employee.Email": "jd@example.com" + } + ] + } +} diff --git a/tests/data/with-slashes.json b/tests/data/with-slashes.json index 9f85629..b7039f7 100644 --- a/tests/data/with-slashes.json +++ b/tests/data/with-slashes.json @@ -1,10 +1,10 @@ -{ - "features": [ - ], - "mediatypes": { - "image/png": "/core/img/filetypes/image.png", - "image/jpeg": "/core/img/filetypes/image.png", - "image/gif": "/core/img/filetypes/image.png", - "application/postscript": "/core/img/filetypes/image-vector.png" - } -} +{ + "features": [ + ], + "mediatypes": { + "image/png": "/core/img/filetypes/image.png", + "image/jpeg": "/core/img/filetypes/image.png", + "image/gif": "/core/img/filetypes/image.png", + "application/postscript": "/core/img/filetypes/image-vector.png" + } +} From af17176f54dd808d20798b014c53f05f3023229d Mon Sep 17 00:00:00 2001 From: Sascha Greuel Date: Wed, 17 Dec 2025 15:20:45 +0100 Subject: [PATCH 2/5] Codecov Signed-off-by: Sascha Greuel --- .github/workflows/Test.yml | 4 ++- phpstan.neon.dist | 1 + src/Filters/QueryMatchFilter.php | 58 +++++++++++++++++++++++--------- 3 files changed, 46 insertions(+), 17 deletions(-) diff --git a/.github/workflows/Test.yml b/.github/workflows/Test.yml index 5c7dcf8..fdca049 100644 --- a/.github/workflows/Test.yml +++ b/.github/workflows/Test.yml @@ -69,4 +69,6 @@ jobs: fi - name: Run codecov - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 3da281c..e9b0f25 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -1,4 +1,5 @@ parameters: + treatPhpDocTypesAsCertain: false level: 6 paths: - src diff --git a/src/Filters/QueryMatchFilter.php b/src/Filters/QueryMatchFilter.php index e970dd2..1f0ef8b 100644 --- a/src/Filters/QueryMatchFilter.php +++ b/src/Filters/QueryMatchFilter.php @@ -152,22 +152,48 @@ public function filter(array|object $collection): array $comparisonResult = null; if ($notNothing) { - $comparisonResult = match ($operator) { - null => AccessHelper::keyExists($node, $key, $this->magicIsAllowed) || (!$key), - "=", "==" => $this->compareEquals($selectedNode, $comparisonValue), - "!=", "!==", "<>" => !$this->compareEquals($selectedNode, $comparisonValue), - '=~' => @\preg_match($comparisonValue, $selectedNode), - '<' => $this->compareLessThan($selectedNode, $comparisonValue), - '<=' => $this->compareLessThan($selectedNode, $comparisonValue) - || $this->compareEquals($selectedNode, $comparisonValue), - '>' => $this->compareLessThan($comparisonValue, $selectedNode), //rfc semantics - '>=' => $this->compareLessThan($comparisonValue, $selectedNode) //rfc semantics - || $this->compareEquals($selectedNode, $comparisonValue), - "in" => \is_array($comparisonValue) && \in_array($selectedNode, $comparisonValue, true), - 'nin', "!in" => \is_array($comparisonValue) - && !\in_array($selectedNode, $comparisonValue, true), - default => false, - }; + $comparisonResult = false; + + switch ($operator) { + case null: + $comparisonResult = AccessHelper::keyExists($node, $key, $this->magicIsAllowed) || (!$key); + break; + case "=": + case "==": + $comparisonResult = $this->compareEquals($selectedNode, $comparisonValue); + break; + case "!=": + case "!==": + case "<>": + $comparisonResult = !$this->compareEquals($selectedNode, $comparisonValue); + break; + case '=~': + $comparisonResult = @\preg_match($comparisonValue, $selectedNode); + break; + case '<': + $comparisonResult = $this->compareLessThan($selectedNode, $comparisonValue); + break; + case '<=': + $comparisonResult = $this->compareLessThan($selectedNode, $comparisonValue) + || $this->compareEquals($selectedNode, $comparisonValue); + break; + case '>': + $comparisonResult = $this->compareLessThan($comparisonValue, $selectedNode); //rfc semantics + break; + case '>=': + $comparisonResult = $this->compareLessThan($comparisonValue, $selectedNode) //rfc semantics + || $this->compareEquals($selectedNode, $comparisonValue); + break; + case "in": + $comparisonResult = \is_array($comparisonValue) + && \in_array($selectedNode, $comparisonValue, true); + break; + case 'nin': + case "!in": + $comparisonResult = \is_array($comparisonValue) + && !\in_array($selectedNode, $comparisonValue, true); + break; + } } if ($negateFilter) { From d6ccedfb3ea4a1855eec4005a5e09eee25fb5adb Mon Sep 17 00:00:00 2001 From: Sascha Greuel Date: Thu, 18 Dec 2025 17:23:10 +0100 Subject: [PATCH 3/5] [Release] 1.0.0 - Rebuilt the test suite from scratch: removed bulky baseline fixtures and added compact unit/integration coverage for every filter (index, union, query, recursive, slice), lexer edge cases, and JSONPath core helpers. Runs reflection-free and deprecation-free. - Achieved and enforced 100% code coverage across AccessHelper, all filters, lexer, tokens, and JSONPath core while keeping phpstan and coding standards clean. - Added a lightweight manual query runner with curated examples to exercise selectors quickly without external datasets. - Major compatibility push toward the unofficial JSONPath standard: unions support slices/queries/wildcards, trailing commas parse correctly, negative indexes and bracket-escaped keys (quotes, brackets, wildcards, special chars) are honored, filters compare path-to-path and root references, equality/deep-equality/regex/in/nin semantics align with expectations, and null existence/value handling follows RFC behavior. - New feature highlights from this cycle: - Multi-key unions with and without quotes: `$[name,year]` and `$["name","year"]`. - Robust bracket notation for special/escaped keys, including `']'`, `'*'`, `$`, backslashes, and mixed punctuation. - Trailing comma support in unions/slices (e.g. `$..books[0,1,2,]`). - Negative index handling aligned with spec (short arrays return empty; -1 works where valid). - Filter improvements: path-to-path/root comparisons, deep equality across scalars/objects/arrays/null/empties, regex matching, `in`/`nin`/`!in`, tautological expressions, and `?@` existence behavior per RFC. - Unions combining slices/queries/wildcards now return complete results (e.g. `$[1:3,4]`, `$[*,1]`). This fixes #72, fixes #61, fixes #60, fixes #59, fixes #58, fixes #51, fixes #44, fixes #41, fixes #40, fixes #39, fixes #38, fixes #37, fixes #36, fixes #35, fixes #34, fixes #33, fixes #32, fixes #31, fixes #30, fixes #29, fixes #9, closes #3 Signed-off-by: Sascha Greuel --- .editorconfig | 450 ------ CHANGELOG.md | 47 +- README.md | 78 +- composer.json | 2 +- src/AccessHelper.php | 15 +- src/Filters/AbstractFilter.php | 7 + src/Filters/IndexFilter.php | 3 +- src/Filters/IndexesFilter.php | 38 + src/Filters/QueryMatchFilter.php | 184 ++- src/Filters/RecursiveFilter.php | 8 +- src/Filters/SliceFilter.php | 90 +- src/JSONPath.php | 1 + src/JSONPathLexer.php | 181 ++- src/JSONPathToken.php | 4 +- tests/AccessHelperTest.php | 26 +- tests/IndexFilterTest.php | 87 ++ tests/IndexesFilterTest.php | 60 + tests/JSONPathArrayAccessTest.php | 111 -- tests/JSONPathArrayTest.php | 91 -- tests/JSONPathCoreTest.php | 116 ++ tests/JSONPathDashedIndexTest.php | 52 - tests/JSONPathIntegrationTest.php | 59 + tests/JSONPathLexerTest.php | 442 +++--- tests/JSONPathSliceAccessTest.php | 97 -- tests/JSONPathTest.php | 884 ------------ tests/JSONPathTestClass.php | 27 - tests/JSONPathTokenTest.php | 26 +- tests/QueryMatchFilterTest.php | 261 ++++ tests/QueryTest.php | 1617 ---------------------- tests/RecursiveFilterTest.php | 45 + tests/SliceFilterTest.php | 82 +- tests/Traits/TestDataTrait.php | 39 - tests/data/baselineFailedQueries.txt | 31 - tests/data/conferences.json | 50 - tests/data/example.json | 37 - tests/data/extra.json | 10 - tests/data/indexed-object.json | 32 - tests/data/locations.json | 12 - tests/data/numerical-indexes-array.json | 14 - tests/data/numerical-indexes-object.json | 14 - tests/data/simple-integers.json | 16 - tests/data/with-dots.json | 15 - tests/data/with-slashes.json | 10 - 43 files changed, 1499 insertions(+), 3972 deletions(-) delete mode 100644 .editorconfig create mode 100644 tests/IndexFilterTest.php create mode 100644 tests/IndexesFilterTest.php delete mode 100644 tests/JSONPathArrayAccessTest.php delete mode 100644 tests/JSONPathArrayTest.php create mode 100644 tests/JSONPathCoreTest.php delete mode 100644 tests/JSONPathDashedIndexTest.php create mode 100644 tests/JSONPathIntegrationTest.php delete mode 100644 tests/JSONPathSliceAccessTest.php delete mode 100644 tests/JSONPathTest.php delete mode 100644 tests/JSONPathTestClass.php create mode 100644 tests/QueryMatchFilterTest.php delete mode 100644 tests/QueryTest.php create mode 100644 tests/RecursiveFilterTest.php delete mode 100644 tests/Traits/TestDataTrait.php delete mode 100644 tests/data/baselineFailedQueries.txt delete mode 100644 tests/data/conferences.json delete mode 100644 tests/data/example.json delete mode 100644 tests/data/extra.json delete mode 100644 tests/data/indexed-object.json delete mode 100644 tests/data/locations.json delete mode 100644 tests/data/numerical-indexes-array.json delete mode 100644 tests/data/numerical-indexes-object.json delete mode 100644 tests/data/simple-integers.json delete mode 100644 tests/data/with-dots.json delete mode 100644 tests/data/with-slashes.json diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index 0f6c1d8..0000000 --- a/.editorconfig +++ /dev/null @@ -1,450 +0,0 @@ -root = true - -[*] -charset = utf-8 -end_of_line = lf -indent_size = 8 -indent_style = tab -insert_final_newline = true -max_line_length = 120 -tab_width = 8 -trim_trailing_whitespace = false -ij_continuation_indent_size = 8 -ij_formatter_off_tag = @formatter:off -ij_formatter_on_tag = @formatter:on -ij_formatter_tags_enabled = false -ij_smart_tabs = true -ij_wrap_on_typing = false - -[*.css] -indent_size = 4 -tab_width = 4 -ij_continuation_indent_size = 4 -ij_css_align_closing_brace_with_properties = false -ij_css_blank_lines_around_nested_selector = 1 -ij_css_blank_lines_between_blocks = 1 -ij_css_brace_placement = end_of_line -ij_css_enforce_quotes_on_format = false -ij_css_hex_color_long_format = false -ij_css_hex_color_lower_case = false -ij_css_hex_color_short_format = false -ij_css_hex_color_upper_case = false -ij_css_keep_blank_lines_in_code = 2 -ij_css_keep_indents_on_empty_lines = true -ij_css_keep_single_line_blocks = false -ij_css_properties_order = font,font-family,font-size,font-weight,font-style,font-variant,font-size-adjust,font-stretch,line-height,position,z-index,top,right,bottom,left,display,visibility,float,clear,overflow,overflow-x,overflow-y,clip,zoom,align-content,align-items,align-self,flex,flex-flow,flex-basis,flex-direction,flex-grow,flex-shrink,flex-wrap,justify-content,order,box-sizing,width,min-width,max-width,height,min-height,max-height,margin,margin-top,margin-right,margin-bottom,margin-left,padding,padding-top,padding-right,padding-bottom,padding-left,table-layout,empty-cells,caption-side,border-spacing,border-collapse,list-style,list-style-position,list-style-type,list-style-image,content,quotes,counter-reset,counter-increment,resize,cursor,user-select,nav-index,nav-up,nav-right,nav-down,nav-left,transition,transition-delay,transition-timing-function,transition-duration,transition-property,transform,transform-origin,animation,animation-name,animation-duration,animation-play-state,animation-timing-function,animation-delay,animation-iteration-count,animation-direction,text-align,text-align-last,vertical-align,white-space,text-decoration,text-emphasis,text-emphasis-color,text-emphasis-style,text-emphasis-position,text-indent,text-justify,letter-spacing,word-spacing,text-outline,text-transform,text-wrap,text-overflow,text-overflow-ellipsis,text-overflow-mode,word-wrap,word-break,tab-size,hyphens,pointer-events,opacity,color,border,border-width,border-style,border-color,border-top,border-top-width,border-top-style,border-top-color,border-right,border-right-width,border-right-style,border-right-color,border-bottom,border-bottom-width,border-bottom-style,border-bottom-color,border-left,border-left-width,border-left-style,border-left-color,border-radius,border-top-left-radius,border-top-right-radius,border-bottom-right-radius,border-bottom-left-radius,border-image,border-image-source,border-image-slice,border-image-width,border-image-outset,border-image-repeat,outline,outline-width,outline-style,outline-color,outline-offset,background,background-color,background-image,background-repeat,background-attachment,background-position,background-position-x,background-position-y,background-clip,background-origin,background-size,box-decoration-break,box-shadow,text-shadow -ij_css_space_after_colon = true -ij_css_space_before_opening_brace = true -ij_css_use_double_quotes = true -ij_css_value_alignment = do_not_align - -[{*.ant,*.fxml,*.jhm,*.jnlp,*.jrxml,*.rng,*.tld,*.wsdl,*.xml,*.xsd,*.xsl,*.xslt,*.xul,phpunit.xml.dist}] -ij_xml_align_attributes = true -ij_xml_align_text = false -ij_xml_attribute_wrap = off -ij_xml_block_comment_at_first_column = true -ij_xml_keep_blank_lines = 2 -ij_xml_keep_indents_on_empty_lines = true -ij_xml_keep_line_breaks = true -ij_xml_keep_line_breaks_in_text = true -ij_xml_keep_whitespaces = false -ij_xml_keep_whitespaces_around_cdata = preserve -ij_xml_keep_whitespaces_inside_cdata = false -ij_xml_line_comment_at_first_column = true -ij_xml_space_after_tag_name = false -ij_xml_space_around_equals_in_attribute = false -ij_xml_space_inside_empty_tag = true -ij_xml_text_wrap = normal - -[{*.cjs,*.js}] -ij_visual_guides = 120 -ij_javascript_align_imports = false -ij_javascript_align_multiline_array_initializer_expression = false -ij_javascript_align_multiline_binary_operation = false -ij_javascript_align_multiline_chained_methods = false -ij_javascript_align_multiline_extends_list = false -ij_javascript_align_multiline_for = true -ij_javascript_align_multiline_parameters = true -ij_javascript_align_multiline_parameters_in_calls = false -ij_javascript_align_multiline_ternary_operation = false -ij_javascript_align_object_properties = 0 -ij_javascript_align_union_types = false -ij_javascript_align_var_statements = 0 -ij_javascript_array_initializer_new_line_after_left_brace = true -ij_javascript_array_initializer_right_brace_on_new_line = true -ij_javascript_array_initializer_wrap = on_every_item -ij_javascript_assignment_wrap = off -ij_javascript_binary_operation_sign_on_next_line = false -ij_javascript_binary_operation_wrap = off -ij_javascript_blacklist_imports = rxjs/Rx,node_modules/**,**/node_modules/**,@angular/material,@angular/material/typings/** -ij_javascript_blank_lines_after_imports = 1 -ij_javascript_blank_lines_around_class = 1 -ij_javascript_blank_lines_around_field = 0 -ij_javascript_blank_lines_around_function = 1 -ij_javascript_blank_lines_around_method = 1 -ij_javascript_block_brace_style = end_of_line -ij_javascript_call_parameters_new_line_after_left_paren = true -ij_javascript_call_parameters_right_paren_on_new_line = true -ij_javascript_call_parameters_wrap = on_every_item -ij_javascript_catch_on_new_line = true -ij_javascript_chained_call_dot_on_new_line = true -ij_javascript_class_brace_style = end_of_line -ij_javascript_comma_on_new_line = false -ij_javascript_do_while_brace_force = if_multiline -ij_javascript_else_on_new_line = true -ij_javascript_enforce_trailing_comma = remove -ij_javascript_extends_keyword_wrap = off -ij_javascript_extends_list_wrap = off -ij_javascript_field_prefix = _ -ij_javascript_file_name_style = relaxed -ij_javascript_finally_on_new_line = true -ij_javascript_for_brace_force = never -ij_javascript_for_statement_new_line_after_left_paren = false -ij_javascript_for_statement_right_paren_on_new_line = false -ij_javascript_for_statement_wrap = off -ij_javascript_force_quote_style = true -ij_javascript_force_semicolon_style = true -ij_javascript_function_expression_brace_style = end_of_line -ij_javascript_if_brace_force = if_multiline -ij_javascript_import_merge_members = global -ij_javascript_import_prefer_absolute_path = global -ij_javascript_import_sort_members = true -ij_javascript_import_sort_module_name = false -ij_javascript_import_use_node_resolution = true -ij_javascript_imports_wrap = on_every_item -ij_javascript_indent_case_from_switch = true -ij_javascript_indent_chained_calls = true -ij_javascript_indent_package_children = 0 -ij_javascript_jsx_attribute_value = braces -ij_javascript_keep_blank_lines_in_code = 1 -ij_javascript_keep_first_column_comment = true -ij_javascript_keep_indents_on_empty_lines = true -ij_javascript_keep_line_breaks = false -ij_javascript_keep_simple_blocks_in_one_line = true -ij_javascript_keep_simple_methods_in_one_line = true -ij_javascript_line_comment_add_space = true -ij_javascript_line_comment_at_first_column = false -ij_javascript_method_brace_style = end_of_line -ij_javascript_method_call_chain_wrap = off -ij_javascript_method_parameters_new_line_after_left_paren = false -ij_javascript_method_parameters_right_paren_on_new_line = false -ij_javascript_method_parameters_wrap = off -ij_javascript_object_literal_wrap = split_into_lines -ij_javascript_parentheses_expression_new_line_after_left_paren = false -ij_javascript_parentheses_expression_right_paren_on_new_line = false -ij_javascript_place_assignment_sign_on_next_line = false -ij_javascript_prefer_as_type_cast = false -ij_javascript_prefer_explicit_types_function_expression_returns = false -ij_javascript_prefer_explicit_types_function_returns = false -ij_javascript_prefer_explicit_types_vars_fields = false -ij_javascript_prefer_parameters_wrap = false -ij_javascript_reformat_c_style_comments = false -ij_javascript_space_after_colon = true -ij_javascript_space_after_comma = true -ij_javascript_space_after_dots_in_rest_parameter = false -ij_javascript_space_after_generator_mult = true -ij_javascript_space_after_property_colon = true -ij_javascript_space_after_quest = true -ij_javascript_space_after_type_colon = true -ij_javascript_space_after_unary_not = false -ij_javascript_space_before_async_arrow_lparen = true -ij_javascript_space_before_catch_keyword = true -ij_javascript_space_before_catch_left_brace = true -ij_javascript_space_before_catch_parentheses = true -ij_javascript_space_before_class_lbrace = true -ij_javascript_space_before_class_left_brace = true -ij_javascript_space_before_colon = true -ij_javascript_space_before_comma = false -ij_javascript_space_before_do_left_brace = true -ij_javascript_space_before_else_keyword = true -ij_javascript_space_before_else_left_brace = true -ij_javascript_space_before_finally_keyword = true -ij_javascript_space_before_finally_left_brace = true -ij_javascript_space_before_for_left_brace = true -ij_javascript_space_before_for_parentheses = true -ij_javascript_space_before_for_semicolon = false -ij_javascript_space_before_function_left_parenth = true -ij_javascript_space_before_generator_mult = false -ij_javascript_space_before_if_left_brace = true -ij_javascript_space_before_if_parentheses = true -ij_javascript_space_before_method_call_parentheses = false -ij_javascript_space_before_method_left_brace = true -ij_javascript_space_before_method_parentheses = false -ij_javascript_space_before_property_colon = false -ij_javascript_space_before_quest = true -ij_javascript_space_before_switch_left_brace = true -ij_javascript_space_before_switch_parentheses = true -ij_javascript_space_before_try_left_brace = true -ij_javascript_space_before_type_colon = false -ij_javascript_space_before_unary_not = false -ij_javascript_space_before_while_keyword = true -ij_javascript_space_before_while_left_brace = true -ij_javascript_space_before_while_parentheses = true -ij_javascript_spaces_around_additive_operators = true -ij_javascript_spaces_around_arrow_function_operator = true -ij_javascript_spaces_around_assignment_operators = true -ij_javascript_spaces_around_bitwise_operators = true -ij_javascript_spaces_around_equality_operators = true -ij_javascript_spaces_around_logical_operators = true -ij_javascript_spaces_around_multiplicative_operators = true -ij_javascript_spaces_around_relational_operators = true -ij_javascript_spaces_around_shift_operators = true -ij_javascript_spaces_around_unary_operator = false -ij_javascript_spaces_within_array_initializer_brackets = false -ij_javascript_spaces_within_brackets = false -ij_javascript_spaces_within_catch_parentheses = false -ij_javascript_spaces_within_for_parentheses = false -ij_javascript_spaces_within_if_parentheses = false -ij_javascript_spaces_within_imports = false -ij_javascript_spaces_within_interpolation_expressions = false -ij_javascript_spaces_within_method_call_parentheses = false -ij_javascript_spaces_within_method_parentheses = false -ij_javascript_spaces_within_object_literal_braces = false -ij_javascript_spaces_within_object_type_braces = true -ij_javascript_spaces_within_parentheses = false -ij_javascript_spaces_within_switch_parentheses = false -ij_javascript_spaces_within_type_assertion = false -ij_javascript_spaces_within_union_types = true -ij_javascript_spaces_within_while_parentheses = false -ij_javascript_special_else_if_treatment = true -ij_javascript_ternary_operation_signs_on_next_line = false -ij_javascript_ternary_operation_wrap = off -ij_javascript_union_types_wrap = on_every_item -ij_javascript_use_chained_calls_group_indents = false -ij_javascript_use_double_quotes = false -ij_javascript_use_explicit_js_extension = global -ij_javascript_use_path_mapping = always -ij_javascript_use_public_modifier = false -ij_javascript_use_semicolon_after_statement = true -ij_javascript_var_declaration_wrap = normal -ij_javascript_while_brace_force = if_multiline -ij_javascript_while_on_new_line = true -ij_javascript_wrap_comments = false - -[{*.ctp,*.hphp,*.inc,*.module,*.php,*.php4,*.php5,*.phtml}] -ij_php_align_assignments = false -ij_php_align_class_constants = false -ij_php_align_group_field_declarations = false -ij_php_align_inline_comments = false -ij_php_align_key_value_pairs = false -ij_php_align_multiline_array_initializer_expression = false -ij_php_align_multiline_binary_operation = false -ij_php_align_multiline_chained_methods = false -ij_php_align_multiline_extends_list = false -ij_php_align_multiline_for = true -ij_php_align_multiline_parameters = false -ij_php_align_multiline_parameters_in_calls = false -ij_php_align_multiline_ternary_operation = false -ij_php_align_phpdoc_comments = true -ij_php_align_phpdoc_param_names = true -ij_php_anonymous_brace_style = end_of_line -ij_php_api_weight = 28 -ij_php_array_initializer_new_line_after_left_brace = false -ij_php_array_initializer_right_brace_on_new_line = false -ij_php_array_initializer_wrap = on_every_item -ij_php_assignment_wrap = off -ij_php_author_weight = 28 -ij_php_binary_operation_sign_on_next_line = false -ij_php_binary_operation_wrap = off -ij_php_blank_lines_after_class_header = 0 -ij_php_blank_lines_after_function = 1 -ij_php_blank_lines_after_imports = 1 -ij_php_blank_lines_after_opening_tag = 0 -ij_php_blank_lines_after_package = 0 -ij_php_blank_lines_around_class = 1 -ij_php_blank_lines_around_constants = 1 -ij_php_blank_lines_around_field = 1 -ij_php_blank_lines_around_method = 1 -ij_php_blank_lines_before_class_end = 0 -ij_php_blank_lines_before_imports = 0 -ij_php_blank_lines_before_method_body = 0 -ij_php_blank_lines_before_package = 0 -ij_php_blank_lines_before_return_statement = 0 -ij_php_blank_lines_between_imports = 0 -ij_php_block_brace_style = end_of_line -ij_php_call_parameters_new_line_after_left_paren = false -ij_php_call_parameters_right_paren_on_new_line = false -ij_php_call_parameters_wrap = off -ij_php_catch_on_new_line = true -ij_php_category_weight = 28 -ij_php_class_brace_style = end_of_line -ij_php_comma_after_last_array_element = true -ij_php_concat_spaces = true -ij_php_copyright_weight = 28 -ij_php_deprecated_weight = 28 -ij_php_do_while_brace_force = always -ij_php_else_if_style = separate -ij_php_else_on_new_line = true -ij_php_example_weight = 28 -ij_php_extends_keyword_wrap = off -ij_php_extends_list_wrap = off -ij_php_fields_default_visibility = private -ij_php_filesource_weight = 28 -ij_php_finally_on_new_line = true -ij_php_for_brace_force = never -ij_php_for_statement_new_line_after_left_paren = false -ij_php_for_statement_right_paren_on_new_line = false -ij_php_for_statement_wrap = off -ij_php_force_short_declaration_array_style = true -ij_php_global_weight = 28 -ij_php_group_use_wrap = on_every_item -ij_php_if_brace_force = if_multiline -ij_php_if_lparen_on_next_line = false -ij_php_if_rparen_on_next_line = false -ij_php_ignore_weight = 28 -ij_php_import_sorting = alphabetic -ij_php_indent_break_from_case = true -ij_php_indent_case_from_switch = true -ij_php_indent_code_in_php_tags = false -ij_php_internal_weight = 28 -ij_php_keep_blank_lines_after_lbrace = 2 -ij_php_keep_blank_lines_before_right_brace = 0 -ij_php_keep_blank_lines_in_code = 1 -ij_php_keep_blank_lines_in_declarations = 1 -ij_php_keep_control_statement_in_one_line = true -ij_php_keep_first_column_comment = true -ij_php_keep_indents_on_empty_lines = true -ij_php_keep_line_breaks = false -ij_php_keep_rparen_and_lbrace_on_one_line = false -ij_php_keep_simple_methods_in_one_line = false -ij_php_lambda_brace_style = end_of_line -ij_php_license_weight = 28 -ij_php_line_comment_add_space = false -ij_php_line_comment_at_first_column = true -ij_php_link_weight = 28 -ij_php_lower_case_boolean_const = true -ij_php_lower_case_keywords = true -ij_php_lower_case_null_const = true -ij_php_method_brace_style = end_of_line -ij_php_method_call_chain_wrap = off -ij_php_method_parameters_new_line_after_left_paren = false -ij_php_method_parameters_right_paren_on_new_line = false -ij_php_method_parameters_wrap = off -ij_php_method_weight = 28 -ij_php_modifier_list_wrap = false -ij_php_multiline_chained_calls_semicolon_on_new_line = false -ij_php_namespace_brace_style = 1 -ij_php_new_line_after_php_opening_tag = false -ij_php_null_type_position = in_the_end -ij_php_package_weight = 28 -ij_php_param_weight = 0 -ij_php_parentheses_expression_new_line_after_left_paren = false -ij_php_parentheses_expression_right_paren_on_new_line = false -ij_php_phpdoc_blank_line_before_tags = true -ij_php_phpdoc_blank_lines_around_parameters = false -ij_php_phpdoc_keep_blank_lines = true -ij_php_phpdoc_param_spaces_between_name_and_description = 1 -ij_php_phpdoc_param_spaces_between_tag_and_type = 1 -ij_php_phpdoc_param_spaces_between_type_and_name = 1 -ij_php_phpdoc_use_fqcn = false -ij_php_phpdoc_wrap_long_lines = false -ij_php_place_assignment_sign_on_next_line = false -ij_php_place_parens_for_constructor = 0 -ij_php_property_read_weight = 28 -ij_php_property_weight = 28 -ij_php_property_write_weight = 28 -ij_php_return_type_on_new_line = false -ij_php_return_weight = 1 -ij_php_see_weight = 28 -ij_php_since_weight = 28 -ij_php_sort_phpdoc_elements = true -ij_php_space_after_colon = true -ij_php_space_after_colon_in_return_type = true -ij_php_space_after_comma = true -ij_php_space_after_for_semicolon = true -ij_php_space_after_quest = true -ij_php_space_after_type_cast = false -ij_php_space_after_unary_not = false -ij_php_space_before_array_initializer_left_brace = false -ij_php_space_before_catch_keyword = true -ij_php_space_before_catch_left_brace = true -ij_php_space_before_catch_parentheses = true -ij_php_space_before_class_left_brace = true -ij_php_space_before_closure_left_parenthesis = true -ij_php_space_before_colon = true -ij_php_space_before_colon_in_return_type = false -ij_php_space_before_comma = false -ij_php_space_before_do_left_brace = true -ij_php_space_before_else_keyword = true -ij_php_space_before_else_left_brace = true -ij_php_space_before_finally_keyword = true -ij_php_space_before_finally_left_brace = true -ij_php_space_before_for_left_brace = true -ij_php_space_before_for_parentheses = true -ij_php_space_before_for_semicolon = false -ij_php_space_before_if_left_brace = true -ij_php_space_before_if_parentheses = true -ij_php_space_before_method_call_parentheses = false -ij_php_space_before_method_left_brace = true -ij_php_space_before_method_parentheses = false -ij_php_space_before_quest = true -ij_php_space_before_short_closure_left_parenthesis = false -ij_php_space_before_switch_left_brace = true -ij_php_space_before_switch_parentheses = true -ij_php_space_before_try_left_brace = true -ij_php_space_before_unary_not = false -ij_php_space_before_while_keyword = true -ij_php_space_before_while_left_brace = true -ij_php_space_before_while_parentheses = true -ij_php_space_between_ternary_quest_and_colon = false -ij_php_spaces_around_additive_operators = true -ij_php_spaces_around_arrow = false -ij_php_spaces_around_assignment_in_declare = false -ij_php_spaces_around_assignment_operators = true -ij_php_spaces_around_bitwise_operators = true -ij_php_spaces_around_equality_operators = true -ij_php_spaces_around_logical_operators = true -ij_php_spaces_around_multiplicative_operators = true -ij_php_spaces_around_null_coalesce_operator = true -ij_php_spaces_around_relational_operators = true -ij_php_spaces_around_shift_operators = true -ij_php_spaces_around_unary_operator = false -ij_php_spaces_around_var_within_brackets = false -ij_php_spaces_within_array_initializer_braces = false -ij_php_spaces_within_brackets = false -ij_php_spaces_within_catch_parentheses = false -ij_php_spaces_within_for_parentheses = false -ij_php_spaces_within_if_parentheses = false -ij_php_spaces_within_method_call_parentheses = false -ij_php_spaces_within_method_parentheses = false -ij_php_spaces_within_parentheses = false -ij_php_spaces_within_short_echo_tags = true -ij_php_spaces_within_switch_parentheses = false -ij_php_spaces_within_while_parentheses = false -ij_php_special_else_if_treatment = false -ij_php_subpackage_weight = 28 -ij_php_ternary_operation_signs_on_next_line = false -ij_php_ternary_operation_wrap = off -ij_php_throws_weight = 2 -ij_php_todo_weight = 28 -ij_php_unknown_tag_weight = 28 -ij_php_upper_case_boolean_const = false -ij_php_upper_case_null_const = false -ij_php_uses_weight = 28 -ij_php_var_weight = 28 -ij_php_variable_naming_style = mixed -ij_php_version_weight = 28 -ij_php_while_brace_force = always -ij_php_while_on_new_line = true - -[{*.har,*.jsb2,*.jsb3,*.json,.babelrc,.eslintrc,.stylelintrc,bowerrc,composer.lock,jest.config}] -indent_size = 2 -indent_style = space -tab_width = 2 -ij_continuation_indent_size = 2 -ij_smart_tabs = false -ij_json_keep_blank_lines_in_code = 0 -ij_json_keep_indents_on_empty_lines = false -ij_json_keep_line_breaks = true -ij_json_space_after_colon = true -ij_json_space_after_comma = true -ij_json_space_before_colon = true -ij_json_space_before_comma = false -ij_json_spaces_within_braces = false -ij_json_spaces_within_brackets = false -ij_json_wrap_long_lines = false - -[{*.yaml,*.yml}] -indent_size = 2 -ij_yaml_keep_indents_on_empty_lines = false -ij_yaml_keep_line_breaks = true diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ced640..9dcfb83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,22 +1,35 @@ # Changelog -### 0.11.0 -πŸ”» Breaking changes ahead: - -- Dropped support for PHP < 8.5 -- `JSONPathToken` now uses a `TokenType` enum and the constructor signature changed accordingly. -- `JSONPath` options flag is now an `int` bitmask (was `bool`), requiring callers to pass integer flags. -- `SliceFilter` returns an empty result for non-positive step values (previously iterated indefinitely). -- `QueryResultFilter` now throws a `JSONPathException` for unsupported operators instead of silently proceeding. -- Access helper behavior is stricter: `arrayValues` throws on invalid types; ArrayAccess lookups check `offsetExists` before reading; traversables and objects are handled distinctly. -- Adopted PHP 8.5 features: `TokenType` enum, readonly value object for tokens, typed flags/options, and `#[\Override]` usage. -- CI now runs on PHP 8.5 with required extensions; code style workflow updated accordingly. -- Added coverage for AccessHelper edge cases (magic getters, ArrayAccess, traversables, negative indexes), QueryResultFilter arithmetic branches, and SliceFilter negative/null bounds. -- Fixed empty-expression handling in lexer and improved safety in AccessHelper traversable lookups. -- Added PHPStan static analysis to the toolchain and addressed its findings. - -### 0.10.1 -- Fixed ignore whitespace after comparison value in filter expression +### 1.0.0 +- Rebuilt the test suite from scratch: removed bulky baseline fixtures and added compact unit/integration coverage for every filter (index, union, query, recursive, slice), lexer edge cases, and JSONPath core helpers. Runs reflection-free and deprecation-free. +- Achieved and enforced 100% code coverage across AccessHelper, all filters, lexer, tokens, and JSONPath core while keeping phpstan and coding standards clean. +- Added a lightweight manual query runner with curated examples to exercise selectors quickly without external datasets. +- Major compatibility push toward the unofficial JSONPath standard: unions support slices/queries/wildcards, trailing commas parse correctly, negative indexes and bracket-escaped keys (quotes, brackets, wildcards, special chars) are honored, filters compare path-to-path and root references, equality/deep-equality/regex/in/nin semantics align with expectations, and null existence/value handling follows RFC behavior. +- New feature highlights from this cycle: + - Multi-key unions with and without quotes: `$[name,year]` and `$["name","year"]`. + - Robust bracket notation for special/escaped keys, including `']'`, `'*'`, `$`, backslashes, and mixed punctuation. + - Trailing comma support in unions/slices (e.g. `$..books[0,1,2,]`). + - Negative index handling aligned with spec (short arrays return empty; -1 works where valid). + - Filter improvements: path-to-path/root comparisons, deep equality across scalars/objects/arrays/null/empties, regex matching, `in`/`nin`/`!in`, tautological expressions, and `?@` existence behavior per RFC. + - Unions combining slices/queries/wildcards now return complete results (e.g. `$[1:3,4]`, `$[*,1]`). + +### 0.11.0 +πŸ”» Breaking changes ahead: + +- Dropped support for PHP < 8.5 +- `JSONPathToken` now uses a `TokenType` enum and the constructor signature changed accordingly. +- `JSONPath` options flag is now an `int` bitmask (was `bool`), requiring callers to pass integer flags. +- `SliceFilter` returns an empty result for non-positive step values (previously iterated indefinitely). +- `QueryResultFilter` now throws a `JSONPathException` for unsupported operators instead of silently proceeding. +- Access helper behavior is stricter: `arrayValues` throws on invalid types; ArrayAccess lookups check `offsetExists` before reading; traversables and objects are handled distinctly. +- Adopted PHP 8.5 features: `TokenType` enum, readonly value object for tokens, typed flags/options, and `#[\Override]` usage. +- CI now runs on PHP 8.5 with required extensions; code style workflow updated accordingly. +- Added coverage for AccessHelper edge cases (magic getters, ArrayAccess, traversables, negative indexes), QueryResultFilter arithmetic branches, and SliceFilter negative/null bounds. +- Fixed empty-expression handling in lexer and improved safety in AccessHelper traversable lookups. +- Added PHPStan static analysis to the toolchain and addressed its findings. + +### 0.10.1 +- Fixed ignore whitespace after comparison value in filter expression ### 0.10.0 - Fixed query/selector Filter Expression With Current Object diff --git a/README.md b/README.md index 26fd159..b288203 100644 --- a/README.md +++ b/README.md @@ -4,32 +4,32 @@ [![MIT licensed](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE) [![Plant Tree](https://img.shields.io/badge/dynamic/json?color=brightgreen&label=Plant%20Tree&query=%24.total&url=https%3A%2F%2Fpublic.ecologi.com%2Fusers%2Fsoftcreatr%2Ftrees)](https://ecologi.com/softcreatr?r=61212ab3fc69b8eb8a2014f4) [![Codecov branch](https://img.shields.io/codecov/c/github/SoftCreatR/JSONPath)](https://codecov.io/gh/SoftCreatR/JSONPath) -This is a [JSONPath](http://goessner.net/articles/JsonPath/) implementation for PHP based on Stefan Goessner's JSONPath script. +This is a [JSONPath](http://goessner.net/articles/JsonPath/) implementation for PHP that targets the de facto comparison suite/RFC semantics while keeping the API small, cached, and `eval`-free. -JSONPath is an XPath-like expression language for filtering, flattening and extracting data. +## Highlights -This project aims to be a clean and simple implementation with the following goals: - - - Object-oriented code (should be easier to manage or extend in future) - - Expressions are parsed into tokens using code inspired by the Doctrine Lexer. The tokens are cached internally to avoid re-parsing the expressions. - - There is no `eval()` in use - - Any combination of objects/arrays/ArrayAccess-objects can be used as the data input which is great if you're de-serializing JSON in to objects or if you want to process your own data structures. +- PHP 8.5+ only, with enums/readonly tokens and no `eval`. +- Works with arrays, objects, and `ArrayAccess`/traversables in any combination. +- Unions cover slices/queries/wildcards/multi-key strings (quoted or unquoted); negative indexes and escaped bracket notation are supported. +- Filters support path-to-path/root comparisons, regex, `in`/`nin`/`!in`, deep equality, and RFC-style null existence/value handling. +- Tokenized parsing with internal caching; lightweight manual runner to try bundled examples quickly. ## Installation Requires PHP 8.5 or newer. ```bash -composer require softcreatr/jsonpath:"^0.11" +composer require softcreatr/jsonpath:"^1.0" ``` ## Development -Static analysis is done with PHPStan. +Useful commands: ```bash -composer require --dev phpstan/phpstan -./vendor/bin/phpstan analyse --no-progress +composer exec phpunit +composer phpstan +composer cs ``` ## JSONPath Examples @@ -43,6 +43,7 @@ JSONPath | Result `$..books[(@.length-1)]` | the last book in order. `$..books[-1:]` | the last book in order. `$..books[0,1]` | the first two books +`$..books[title,year]` | multiple keys in a union `$..books[:2]` | the first two books `$..books[::2]` | every second book starting from first one `$..books[1:6:3]` | every third book starting from 1 till 6 @@ -64,8 +65,8 @@ Symbol | Description `*` | Wildcard. All child elements regardless their index. `[,]` | Array indices as a set `[start:end:step]` | Array slice operator borrowed from ES4/Python. -`?()` | Filters a result set by a script expression -`()` | Uses the result of a script expression as the index +`?()` | Filters a result set by a comparison expression +`()` | Uses the result of a comparison expression as the index ## PHP Usage @@ -116,8 +117,6 @@ stdClass Object */ ``` -More examples can be found in the [Wiki](https://github.com/SoftCreatR/JSONPath/wiki/Queries) - ### Magic method access The options flag `JSONPath::ALLOW_MAGIC` will instruct JSONPath when retrieving a value to first check if an object @@ -127,40 +126,47 @@ not very predictable as: - wildcard and recursive features will only look at public properties and can't smell which properties are magically accessible - there is no `property_exists` check for magic methods so an object with a magic `__get()` will always return `true` when checking if the property exists -- any errors thrown or unpredictable behaviour caused by fetching via `__get()` is your own problem to deal with +- any errors thrown or unpredictable behavior caused by fetching via `__get()` is your own problem to deal with ```php +get('bar'); $jsonPath = new JSONPath($myObject, JSONPath::ALLOW_MAGIC); ``` -For more examples, check the JSONPathTest.php tests file. - ## Script expressions -Script expressions are not supported as the original author intended because: +Script execution is intentionally **not** supported: -- This would only be achievable through `eval` (boo). -- Using the script engine from different languages defeats the purpose of having a single expression evaluate the same way in different - languages which seems like a bit of a flaw if you're creating an abstract expression syntax. +- It would require `eval`, which we avoid. +- Behavior would diverge across languages and defeat having a portable expression syntax. -So here are the types of query expressions that are supported: +Supported filter/query patterns (200+ cases covered in the comparison suite): - [?(@._KEY_ _OPERATOR_ _VALUE_)] // <, >, <=, >=, !=, ==, =~, in and nin - e.g. - [?(@.title == "A string")] // - [?(@.title = "A string")] - // A single equals is not an assignment but the SQL-style of '==' - [?(@.title =~ /^a(nother)? string$/i)] - [?(@.title in ["A string", "Another string"])] - [?(@.title nin ["A string", "Another string"])] - -## Known issues - -- This project has not implemented multiple string indexes e.g. `$[name,year]` or `$["name","year"]`. I have no ETA on that feature, and it would require some re-writing of the parser that uses a very basic regex implementation. +``` +[?(@._KEY_ _OPERATOR_ _VALUE_)] + Operators: ==, =, !=, <>, !==, <, >, <=, >=, =~, in, nin, !in + +Examples: +[?(@.title == "A string")] // equality +[?(@.title = "A string")] // SQL-style equals +[?(@.price < 10)] // numeric comparisons +[?(@.title =~ /^a(nother)?/i)] // regex +[?(@.title in ["A","B"])] // membership +[?(@.title nin ["A"])] // not in +[?(@.title !in ["A"])] // alternate not in +[?(@.key == @.other)] // path-to-path comparison +[?(@.key == $.rootValue)] // root reference +[?(@)] or [?(@==@)] // truthy/tautology +[?(@.length)] // existence checks +[?(@['weird-key']=="ok")] // bracket-escaped keys and negative indexes +``` +A full list of (un)supported filter/query patterns can be found in the [JSONPath Comparison Cheatsheet](https://cburgmer.github.io/json-path-comparison/). + ## Similar projects [FlowCommunications/JSONPath](https://github.com/FlowCommunications/JSONPath) is the predecessor of this library by Stephen Frank diff --git a/composer.json b/composer.json index 109c4b7..020bea2 100644 --- a/composer.json +++ b/composer.json @@ -2,7 +2,7 @@ "name": "softcreatr/jsonpath", "description": "JSONPath implementation for parsing, searching and flattening arrays", "license": "MIT", - "version": "0.11.0", + "version": "1.0.0", "authors": [ { "name": "Stephen Frank", diff --git a/src/AccessHelper.php b/src/AccessHelper.php index bc0d16d..6bb9d4f 100644 --- a/src/AccessHelper.php +++ b/src/AccessHelper.php @@ -38,11 +38,14 @@ public static function keyExists(mixed $collection, int|string|null $key, bool $ return true; } - if (\is_int($key) && $key < 0) { - $key = \abs($key); - } - if (\is_array($collection)) { + if (\is_int($key) && $key < 0) { + $keys = \array_keys($collection); + $index = \count($keys) + $key; + + return $index >= 0 && \array_key_exists($index, $keys); + } + return \array_key_exists($key ?? '', $collection); } @@ -76,7 +79,8 @@ public static function getValue(mixed $collection, int|string|null $key, bool $m $return = $collection->offsetExists($key) ? $collection->offsetGet($key) : null; } elseif (\is_array($collection)) { if (\is_int($key) && $key < 0) { - $return = \array_slice($collection, $key, 1)[0] ?? null; + $index = \count($collection) + $key; + $return = $index >= 0 && \array_key_exists($index, $collection) ? $collection[$index] : null; } else { $return = $collection[$key] ?? null; } @@ -128,7 +132,6 @@ public static function setValue(mixed &$collection, int|string|null $key, mixed } if ($collection instanceof ArrayAccess) { - /** @noinspection PhpVoidFunctionResultUsedInspection */ $collection->offsetSet($key, $value); return $value; diff --git a/src/Filters/AbstractFilter.php b/src/Filters/AbstractFilter.php index baed19f..c8fcaa8 100644 --- a/src/Filters/AbstractFilter.php +++ b/src/Filters/AbstractFilter.php @@ -17,11 +17,18 @@ abstract class AbstractFilter { protected bool $magicIsAllowed; + protected mixed $rootData = null; + public function __construct(protected JSONPathToken $token, int $options = 0) { $this->magicIsAllowed = ($options & JSONPath::ALLOW_MAGIC) === JSONPath::ALLOW_MAGIC; } + public function setRootData(mixed $root): void + { + $this->rootData = $root; + } + /** * @param array|object $collection * @return array diff --git a/src/Filters/IndexFilter.php b/src/Filters/IndexFilter.php index ba108ed..62ce5c4 100644 --- a/src/Filters/IndexFilter.php +++ b/src/Filters/IndexFilter.php @@ -23,6 +23,7 @@ public function filter(array|object $collection): array { if (\is_array($this->token->value)) { $result = []; + foreach ($this->token->value as $value) { if (AccessHelper::keyExists($collection, $value, $this->magicIsAllowed)) { $result[] = AccessHelper::getValue($collection, $value, $this->magicIsAllowed); @@ -38,7 +39,7 @@ public function filter(array|object $collection): array ]; } - if ($this->token->value === '*') { + if ($this->token->value === '*' && !$this->token->quoted) { return AccessHelper::arrayValues($collection); } diff --git a/src/Filters/IndexesFilter.php b/src/Filters/IndexesFilter.php index 3d4229a..4ea09c6 100644 --- a/src/Filters/IndexesFilter.php +++ b/src/Filters/IndexesFilter.php @@ -11,17 +11,55 @@ namespace Flow\JSONPath\Filters; use Flow\JSONPath\AccessHelper; +use Flow\JSONPath\JSONPath; +use Flow\JSONPath\JSONPathException; +use Flow\JSONPath\JSONPathToken; +use Flow\JSONPath\TokenType; class IndexesFilter extends AbstractFilter { /** * @inheritDoc + * + * @throws JSONPathException */ public function filter(array|object $collection): array { $return = []; foreach ($this->token->value as $index) { + if (\is_array($index) && ($index['type'] ?? null) === 'slice') { + $sliceToken = new JSONPathToken(TokenType::Slice, $index['value']); + $sliceFilter = new SliceFilter( + $sliceToken, + $this->magicIsAllowed ? JSONPath::ALLOW_MAGIC : 0 + ); + $sliceFilter->setRootData($this->rootData ?? $collection); + + $return = \array_merge($return, $sliceFilter->filter($collection)); + + continue; + } + + if (\is_array($index) && ($index['type'] ?? null) === 'query') { + $queryToken = new JSONPathToken(TokenType::QueryMatch, $index['value']); + $queryFilter = new QueryMatchFilter( + $queryToken, + $this->magicIsAllowed ? JSONPath::ALLOW_MAGIC : 0 + ); + $queryFilter->setRootData($this->rootData ?? $collection); + + $return = \array_merge($return, $queryFilter->filter($collection)); + + continue; + } + + if ($index === '*' && !$this->token->quoted) { + $return = \array_merge($return, AccessHelper::arrayValues($collection)); + + continue; + } + if (AccessHelper::keyExists($collection, $index, $this->magicIsAllowed)) { $return[] = AccessHelper::getValue($collection, $index, $this->magicIsAllowed); } diff --git a/src/Filters/QueryMatchFilter.php b/src/Filters/QueryMatchFilter.php index 1f0ef8b..9b4a798 100644 --- a/src/Filters/QueryMatchFilter.php +++ b/src/Filters/QueryMatchFilter.php @@ -41,7 +41,15 @@ class QueryMatchFilter extends AbstractFilter public function filter(array|object $collection): array { $filterExpression = $this->token->value; + $isShorthand = $this->token->shorthand ?? false; + + if (\is_array($filterExpression)) { + $isShorthand = $filterExpression['shorthand'] ?? $isShorthand; + $filterExpression = $filterExpression['expression'] ?? ''; + } + $negateFilter = false; + if ( \preg_match('/' . static::MATCH_QUERY_NEGATION_WRAPPED . '/x', $filterExpression, $negationMatches) || \preg_match('/' . static::MATCH_QUERY_NEGATION_UNWRAPPED . '/x', $filterExpression, $negationMatches) @@ -51,6 +59,7 @@ public function filter(array|object $collection): array } $filterGroups = []; + if ( \preg_match_all( static::MATCH_GROUPED_EXPRESSION, @@ -61,6 +70,7 @@ public function filter(array|object $collection): array ) { foreach ($matches[0] as $i => $matchesGroup) { $test = \substr($matchesGroup[0], 1, -1); + //sanity check that our group is a group and not something within a string or regular expression if (\preg_match('/' . static::MATCH_QUERY_OPERATORS . '/x', $test)) { $filterGroups[$i] = $test; @@ -81,6 +91,12 @@ public function filter(array|object $collection): array || !isset($matches[1][0]) || isset($matches['logicalandor'][\array_key_last($matches['logicalandor'])]) ) { + $constantResult = $this->evaluateConstantExpression($filterExpression); + + if ($constantResult !== null) { + return $constantResult ? AccessHelper::arrayValues($collection) : []; + } + throw new RuntimeException('Malformed filter query'); } @@ -107,10 +123,12 @@ public function filter(array|object $collection): array } //Process a normal expression - $key = $matches['key'][$expressionPart] ?: $matches['keySquare'][$expressionPart]; + $key = $this->normalizeKey($matches['key'][$expressionPart] ?: $matches['keySquare'][$expressionPart]); $operator = $matches['operator'][$expressionPart] ?? null; $comparisonValue = $matches['comparisonValue'][$expressionPart] ?? null; + $comparisonIsPath = $this->isPathComparison($comparisonValue); + $canCompareMissing = \in_array($operator, ['=', '==', '!=', '!==', '<>'], true) && $comparisonIsPath; if (\is_string($comparisonValue)) { $comparisonValue = \preg_replace('/^\'/', '"', $comparisonValue); @@ -142,6 +160,8 @@ public function filter(array|object $collection): array $selectedNode = $foundValue[0]; $notNothing = true; } + } elseif ($canCompareMissing) { + $notNothing = true; } } else { //Node selection was plain @ @@ -152,46 +172,57 @@ public function filter(array|object $collection): array $comparisonResult = null; if ($notNothing) { + $resolvedComparisonValue = $this->resolveComparisonValue($comparisonValue, $node); $comparisonResult = false; switch ($operator) { case null: - $comparisonResult = AccessHelper::keyExists($node, $key, $this->magicIsAllowed) || (!$key); + if ($key === '' || $key === null) { + $comparisonResult = !$isShorthand || $this->isTruthy($selectedNode); + } else { + $comparisonResult = AccessHelper::keyExists($node, $key, $this->magicIsAllowed) + || (!$key); + } break; case "=": case "==": - $comparisonResult = $this->compareEquals($selectedNode, $comparisonValue); + $comparisonResult = $this->compareEquals($selectedNode, $resolvedComparisonValue); break; case "!=": case "!==": case "<>": - $comparisonResult = !$this->compareEquals($selectedNode, $comparisonValue); + $comparisonResult = !$this->compareEquals($selectedNode, $resolvedComparisonValue); break; case '=~': - $comparisonResult = @\preg_match($comparisonValue, $selectedNode); + $comparisonResult = @\preg_match( + (string)$resolvedComparisonValue, + (string)$selectedNode + ); break; case '<': - $comparisonResult = $this->compareLessThan($selectedNode, $comparisonValue); + $comparisonResult = $this->compareLessThan($selectedNode, $resolvedComparisonValue); break; case '<=': - $comparisonResult = $this->compareLessThan($selectedNode, $comparisonValue) - || $this->compareEquals($selectedNode, $comparisonValue); + $comparisonResult = $this->compareLessThan($selectedNode, $resolvedComparisonValue) + || $this->compareEquals($selectedNode, $resolvedComparisonValue); break; case '>': - $comparisonResult = $this->compareLessThan($comparisonValue, $selectedNode); //rfc semantics + //rfc semantics + $comparisonResult = $this->compareLessThan($resolvedComparisonValue, $selectedNode); break; case '>=': - $comparisonResult = $this->compareLessThan($comparisonValue, $selectedNode) //rfc semantics - || $this->compareEquals($selectedNode, $comparisonValue); + //rfc semantics + $comparisonResult = $this->compareLessThan($resolvedComparisonValue, $selectedNode) + || $this->compareEquals($selectedNode, $resolvedComparisonValue); break; case "in": - $comparisonResult = \is_array($comparisonValue) - && \in_array($selectedNode, $comparisonValue, true); + $comparisonResult = \is_array($resolvedComparisonValue) + && \in_array($selectedNode, $resolvedComparisonValue, true); break; case 'nin': case "!in": - $comparisonResult = \is_array($comparisonValue) - && !\in_array($selectedNode, $comparisonValue, true); + $comparisonResult = \is_array($resolvedComparisonValue) + && !\in_array($selectedNode, $resolvedComparisonValue, true); break; } } @@ -217,6 +248,93 @@ protected function isNumber(mixed $value): bool return !\is_string($value) && \is_numeric($value); } + /** + * @throws JSONPathException + */ + private function resolveComparisonValue(mixed $comparisonValue, mixed $node): mixed + { + if (!\is_string($comparisonValue)) { + return $comparisonValue; + } + + if (\str_starts_with($comparisonValue, '@')) { + $path = \substr($comparisonValue, 1); + + if ($path === '' || $path === '.') { + return $node; + } + + $resolved = new JSONPath($node)->find($path)->getData(); + + return \is_array($resolved) && \array_key_exists(0, $resolved) ? $resolved[0] : null; + } + + if (\str_starts_with($comparisonValue, '$')) { + $root = $this->rootData ?? $node; + $resolved = new JSONPath($root)->find($comparisonValue)->getData(); + + return \is_array($resolved) && \array_key_exists(0, $resolved) ? $resolved[0] : null; + } + + return $comparisonValue; + } + + private function normalizeKey(mixed $key): int|string|null + { + if (\is_string($key) && \preg_match('/^-?\d+$/', $key)) { + return (int)$key; + } + + return $key; + } + + private function isPathComparison(mixed $comparisonValue): bool + { + return \is_string($comparisonValue) && \str_starts_with($comparisonValue, '@'); + } + + private function evaluateConstantExpression(string $expression): ?bool + { + $pattern = '/^\s*(?[^&|]+?)\s*(?==|=|!=|!==|<>|<=|>=|<|>)\s*(?[^&|]+?)\s*$/'; + + if (!\preg_match($pattern, $expression, $matches)) { + return null; + } + + $left = $this->decodeLiteral($matches['left']); + $right = $this->decodeLiteral($matches['right']); + $operator = $matches['operator']; + + return match ($operator) { + '==', '=' => $this->compareEquals($left, $right), + '!=', '!==', '<>' => !$this->compareEquals($left, $right), + '<' => $this->compareLessThan($left, $right), + '<=' => $this->compareLessThan($left, $right) || $this->compareEquals($left, $right), + '>' => $this->compareLessThan($right, $left), + '>=' => $this->compareLessThan($right, $left) || $this->compareEquals($left, $right), + }; + } + + private function decodeLiteral(string $literal): mixed + { + $literal = \trim($literal); + + try { + return \json_decode($literal, true, 512, \JSON_THROW_ON_ERROR); + } catch (JsonException) { + if (\is_numeric($literal)) { + return $literal + 0; + } + + return $literal; + } + } + + private function isTruthy(mixed $value): bool + { + return (bool)$value; + } + protected function compareEquals(mixed $a, mixed $b): bool { $type_a = \gettype($a); @@ -228,13 +346,45 @@ protected function compareEquals(mixed $a, mixed $b): bool /** @noinspection TypeUnsafeComparisonInspection */ return $a == $b; } - //Object/Array - //@TODO array and object comparison + + if (\is_array($a) && \is_array($b)) { + return $this->deepEqual($a, $b); + } + + if (\is_object($a) && \is_object($b)) { + return $this->deepEqual((array)$a, (array)$b); + } } return false; } + /** + * @param array $a + * @param array $b + */ + private function deepEqual(array $a, array $b): bool + { + $aIsList = \array_is_list($a); + $bIsList = \array_is_list($b); + + if ($aIsList !== $bIsList) { + return false; + } + + if (\count($a) !== \count($b)) { + return false; + } + + if ($aIsList) { + return \array_all($a, fn ($value, $index) => \array_key_exists($index, $b) + && $this->compareEquals($value, $b[$index])); + } + + return \array_all($a, fn ($value, $key) => \array_key_exists($key, $b) + && $this->compareEquals($value, $b[$key])); + } + protected function compareLessThan(mixed $a, mixed $b): bool { if ((\is_string($a) && \is_string($b)) || ($this->isNumber($a) && $this->isNumber($b))) { diff --git a/src/Filters/RecursiveFilter.php b/src/Filters/RecursiveFilter.php index 6f101c5..e818953 100644 --- a/src/Filters/RecursiveFilter.php +++ b/src/Filters/RecursiveFilter.php @@ -16,8 +16,9 @@ class RecursiveFilter extends AbstractFilter { /** - * @throws JSONPathException * @inheritDoc + * + * @throws JSONPathException */ public function filter(array|object $collection): array { @@ -28,12 +29,11 @@ public function filter(array|object $collection): array return $result; } - /** - * @throws JSONPathException - */ /** * @param array> $result * @param array|object $data + * + * @throws JSONPathException */ private function recurse(array &$result, array|object $data): void { diff --git a/src/Filters/SliceFilter.php b/src/Filters/SliceFilter.php index 15fe6c3..3750bba 100644 --- a/src/Filters/SliceFilter.php +++ b/src/Filters/SliceFilter.php @@ -22,41 +22,101 @@ public function filter(array|object $collection): array $length = \count($collection); $start = $this->token->value['start']; $end = $this->token->value['end']; - $step = $this->token->value['step'] ?: 1; + $step = $this->token->value['step'] ?? 1; + $result = []; + + if ($step === 0) { + return $result; + } + + if ($step > 0) { + [$start, $end] = $this->normalizeForPositiveStep($length, $start, $end); + + for ($i = $start; $i < $end; $i += $step) { + if (AccessHelper::keyExists($collection, $i, $this->magicIsAllowed)) { + $result[] = $collection[$i]; + } + } + + return $result; + } + + [$start, $end] = $this->normalizeForNegativeStep($length, $start, $end); + for ($i = $start; $i > $end; $i += $step) { + if (AccessHelper::keyExists($collection, $i, $this->magicIsAllowed)) { + $result[] = $collection[$i]; + } + } + + return $result; + } + + /** + * @return array{0: int, 1: int} + */ + private function normalizeForPositiveStep(int $length, ?int $start, ?int $end): array + { if ($start === null) { $start = 0; + } elseif ($start < 0) { + $start += $length; } if ($start < 0) { - $start = $length + $start; - - if ($start < 0) { - $start = 0; - } + $start = 0; + } elseif ($start > $length) { + $start = $length; } if ($end === null) { - // negative index start means the end is -1, else the end is the last index $end = $length; + } elseif ($end < 0) { + $end += $length; } if ($end < 0) { - $end = $length + $end; + $end = 0; + } elseif ($end > $length) { + $end = $length; } - $result = []; + return [$start, $end]; + } - if ($step <= 0) { - return $result; + /** + * @return array{0: int, 1: int} + */ + private function normalizeForNegativeStep(int $length, ?int $start, ?int $end): array + { + if ($start === null) { + $start = $length - 1; + } else { + if ($start < 0) { + $start += $length; + } + + if ($start < 0) { + $start = -1; + } elseif ($start >= $length) { + $start = $length - 1; + } } - for ($i = $start; $i < $end; $i += $step) { - if (AccessHelper::keyExists($collection, $i, $this->magicIsAllowed)) { - $result[] = $collection[$i]; + if ($end === null) { + $end = -1; + } else { + if ($end < 0) { + $end += $length; + } + + if ($end < 0) { + $end = -1; + } elseif ($end >= $length) { + $end = $length - 1; } } - return $result; + return [$start, $end]; } } diff --git a/src/JSONPath.php b/src/JSONPath.php index d637a7a..8010e2b 100644 --- a/src/JSONPath.php +++ b/src/JSONPath.php @@ -51,6 +51,7 @@ public function find(string $expression): self foreach ($tokens as $token) { $filter = $token->buildFilter($this->options); + $filter->setRootData($this->data); $filteredDataList = []; foreach ($collectionData as $value) { diff --git a/src/JSONPathLexer.php b/src/JSONPathLexer.php index 56f8b2c..136479c 100644 --- a/src/JSONPathLexer.php +++ b/src/JSONPathLexer.php @@ -16,19 +16,27 @@ class JSONPathLexer * Match within bracket groups * Matches are whitespace insensitive */ - public const string MATCH_INDEX = '(?!-)[\-\w]+ | \*'; // e.g.: foo or 40f35757-2563-4790-b0b1-caa904be455f - public const string MATCH_INDEXES = '\s* -?\d+ [-?\d,\s]+'; // Eg. 0,1,2 + // e.g.: foo or 40f35757-2563-4790-b0b1-caa904be455f or $ + public const string MATCH_INDEX = '(?!-)[\-\w]+ | \\$ | \\*'; - public const string MATCH_SLICE = '[-\d:]+ | :'; // Eg. [0:2:1] + // Eg. 0,1,2 or *,1 or 0,1,2, + public const string MATCH_INDEXES = '\s* (?:-?\d+|\*) (?: \s* , \s* (?:-?\d+|\*) )+ \s* ,? \s*'; - public const string MATCH_QUERY_RESULT = '\s* \( .+? \) \s*'; // Eg. ?(@.length - 1) + // Eg. [0:2:1] or [-1] + public const string MATCH_SLICE = '(?:-?\d*:-?\d*(?::-?\d*)?|-\\d+)'; - public const string MATCH_QUERY_MATCH = '\s* \?\(.+?\) \s*'; // Eg. ?(@.foo = "bar") + // Eg. ?(@.length - 1) + public const string MATCH_QUERY_RESULT = '\s* \( .+? \) \s*'; - public const string MATCH_INDEX_IN_SINGLE_QUOTES = '\s* \' (.+?)? \' \s*'; // Eg. 'bar' + // Eg. ?(@.foo = "bar") + public const string MATCH_QUERY_MATCH = '\s* \?\(.+?\) \s*'; - public const string MATCH_INDEX_IN_DOUBLE_QUOTES = '\s* " (.+?)? " \s*'; // Eg. "bar" + // Eg. 'bar' + public const string MATCH_INDEX_IN_SINGLE_QUOTES = '\s* \' (.+?)? \' \s*'; + + // Eg. "bar" + public const string MATCH_INDEX_IN_DOUBLE_QUOTES = '\s* " (.+?)? " \s*'; private readonly string $expression; @@ -74,6 +82,7 @@ public function parseExpressionTokens(): array $squareBracketDepth = 0; $tokenValue = ''; $tokens = []; + $inBracketQuote = null; for ($i = 0; $i < $this->expressionLength; $i++) { $char = $this->expression[$i]; @@ -90,14 +99,19 @@ public function parseExpressionTokens(): array $squareBracketDepth++; if ($squareBracketDepth === 1) { + $inBracketQuote = null; + continue; } } - if ($char === ']') { + if ($char === ']' && $squareBracketDepth > 0 && $inBracketQuote === null) { $squareBracketDepth--; if ($squareBracketDepth === 0) { + $tokens[] = $this->createToken($tokenValue); + $tokenValue = ''; + continue; } } @@ -106,24 +120,29 @@ public function parseExpressionTokens(): array * Within square brackets */ if ($squareBracketDepth > 0) { + if (($char === "'" || $char === '"')) { + $escaped = $this->isEscaped($tokenValue); + + if ($inBracketQuote === null && !$escaped) { + $inBracketQuote = $char; + } elseif ($inBracketQuote === $char && !$escaped) { + $inBracketQuote = null; + } + } + $tokenValue .= $char; - if ($squareBracketDepth === 1 && $this->lookAhead($i) === ']') { - $tokens[] = $this->createToken($tokenValue); - $tokenValue = ''; - } + continue; } /* * Outside square brackets */ - if ($squareBracketDepth === 0) { - $tokenValue .= $char; + $tokenValue .= $char; - if ($this->atEnd($i) || \in_array($this->lookAhead($i), ['.', '['], true)) { - $tokens[] = $this->createToken($tokenValue); - $tokenValue = ''; - } + if ($this->atEnd($i) || \in_array($this->lookAhead($i), ['.', '['], true)) { + $tokens[] = $this->createToken($tokenValue); + $tokenValue = ''; } } @@ -141,7 +160,7 @@ protected function lookAhead(int $pos, int $forward = 1): ?string protected function atEnd(int $pos): bool { - return $pos === $this->expressionLength; + return $pos === ($this->expressionLength - 1); } /** @@ -165,6 +184,60 @@ protected function createToken(string $value): JSONPathToken /** @var JSONPathToken|null $ret */ $ret = null; + if (\str_contains($tokenValue, ',')) { + $parts = \array_values(\array_filter( + \array_map('trim', \explode(',', $tokenValue)), + static fn (string $part): bool => $part !== '' + )); + + if ($parts !== []) { + $union = []; + + $hasSlice = false; + $hasQuery = false; + + foreach ($parts as $part) { + if (\preg_match('/^-\\d+$/', $part)) { + $union[] = (int)$part; + + continue; + } + + if (\preg_match('/^' . static::MATCH_SLICE . '$/u', $part)) { + $union[] = [ + 'type' => 'slice', + 'value' => $this->parseSlice($part), + ]; + $hasSlice = true; + + continue; + } + + if (\preg_match('/^(' . static::MATCH_INDEX . ')$/xu', $part)) { + $union[] = \preg_match('/^-?\d+$/', $part) ? (int)$part : $part; + + continue; + } + + if (\preg_match('/^' . static::MATCH_QUERY_MATCH . '$/xu', $part)) { + $union[] = [ + 'type' => 'query', + 'value' => \substr($part, 2, -1), + ]; + $hasQuery = true; + } + } + + if (($hasSlice || $hasQuery) && \count($union) === \count($parts)) { + return new JSONPathToken(TokenType::Indexes, $union); + } + } + } + + if (\preg_match('/^-\\d+$/', $tokenValue)) { + return new JSONPathToken(TokenType::Index, (int)$tokenValue); + } + if (\preg_match('/^(' . static::MATCH_INDEX . ')$/xu', $tokenValue, $matches)) { if (\preg_match('/^-?\d+$/', $tokenValue)) { $tokenValue = (int)$tokenValue; @@ -175,23 +248,26 @@ protected function createToken(string $value): JSONPathToken $tokenValue = \explode(',', \trim($tokenValue, ',')); foreach ($tokenValue as $i => $v) { - $tokenValue[$i] = (int)\trim($v); + $v = \trim($v); + $tokenValue[$i] = $v === '*' ? '*' : (int)$v; } $ret = new JSONPathToken(TokenType::Indexes, $tokenValue); } elseif (\preg_match('/^' . static::MATCH_SLICE . '$/xu', $tokenValue, $matches)) { - $parts = \explode(':', $tokenValue); - $tokenValue = [ - 'start' => $parts[0] !== '' ? (int)$parts[0] : null, - 'end' => isset($parts[1]) && $parts[1] !== '' ? (int)$parts[1] : null, - 'step' => isset($parts[2]) && $parts[2] !== '' ? (int)$parts[2] : null, - ]; + $tokenValue = $this->parseSlice($tokenValue); $ret = new JSONPathToken(TokenType::Slice, $tokenValue); } elseif (\preg_match('/^' . static::MATCH_QUERY_RESULT . '$/xu', $tokenValue)) { $tokenValue = \substr($tokenValue, 1, -1); $ret = new JSONPathToken(TokenType::QueryResult, $tokenValue); + } elseif ($tokenValue === '?') { + $ret = new JSONPathToken(TokenType::QueryMatch, '@', shorthand: true); + } elseif (\preg_match('/^\\?@/', $tokenValue)) { + $expr = \substr($tokenValue, 1); + $expr = $expr === '' ? '@' : $expr; + + $ret = new JSONPathToken(TokenType::QueryMatch, $expr, shorthand: true); } elseif (\preg_match('/^' . static::MATCH_QUERY_MATCH . '$/xu', $tokenValue)) { $tokenValue = \substr($tokenValue, 2, -1); @@ -201,7 +277,7 @@ protected function createToken(string $value): JSONPathToken || \preg_match('/^' . static::MATCH_INDEX_IN_DOUBLE_QUOTES . '$/xu', $tokenValue, $matches) ) { if (isset($matches[1])) { - $tokenValue = \trim($matches[1]); + $tokenValue = $this->decodeQuotedIndex($matches[1], $matches[0][0]); $possibleArray = false; if ($matches[0][0] === '"') { @@ -216,7 +292,7 @@ protected function createToken(string $value): JSONPathToken $tokenValue = ''; } - $ret = new JSONPathToken(TokenType::Index, $tokenValue); + $ret = new JSONPathToken(TokenType::Index, $tokenValue, true); } if ($ret !== null) { @@ -225,4 +301,53 @@ protected function createToken(string $value): JSONPathToken throw new JSONPathException("Unable to parse token {$tokenValue} in expression: {$this->expression}"); } + + /** + * @return array{start: int|null, end: int|null, step: int|null} + */ + private function parseSlice(string $tokenValue): array + { + $parts = \explode(':', $tokenValue); + + return [ + 'start' => $parts[0] !== '' ? (int)$parts[0] : null, + 'end' => isset($parts[1]) && $parts[1] !== '' ? (int)$parts[1] : null, + 'step' => isset($parts[2]) && $parts[2] !== '' ? (int)$parts[2] : null, + ]; + } + + private function isEscaped(string $tokenValue): bool + { + $len = \strlen($tokenValue); + if ($len === 0) { + return false; + } + + $backslashCount = 0; + + for ($i = $len - 1; $i >= 0; $i--) { + if ($tokenValue[$i] === '\\') { + $backslashCount++; + continue; + } + + break; + } + + return ($backslashCount % 2) === 1; + } + + private function decodeQuotedIndex(string $tokenValue, string $quote): string + { + // Unescape backslashes first, then the quote type used + $tokenValue = \str_replace('\\\\', '\\', $tokenValue); + + if ($quote === "'") { + $tokenValue = \str_replace("\\'", "'", $tokenValue); + } elseif ($quote === '"') { + $tokenValue = \str_replace('\\"', '"', $tokenValue); + } + + return $tokenValue; + } } diff --git a/src/JSONPathToken.php b/src/JSONPathToken.php index 65884e3..23fa094 100644 --- a/src/JSONPathToken.php +++ b/src/JSONPathToken.php @@ -22,7 +22,9 @@ { public function __construct( public TokenType $type, - public mixed $value + public mixed $value, + public bool $quoted = false, + public bool $shorthand = false, ) { // ... } diff --git a/tests/AccessHelperTest.php b/tests/AccessHelperTest.php index d22daa2..afb788f 100644 --- a/tests/AccessHelperTest.php +++ b/tests/AccessHelperTest.php @@ -29,6 +29,16 @@ public function __get(string $name): string { return "magic-{$name}"; } + + public function __set(string $name, mixed $value): void + { + $this->{$name} = $value; + } + + public function __isset(string $name): bool + { + return isset($this->{$name}); + } }; self::assertTrue(AccessHelper::keyExists($magic, 'foo', true)); @@ -73,6 +83,16 @@ public function __get(string $name): string { return "magic-{$name}"; } + + public function __set(string $name, mixed $value): void + { + $this->{$name} = $value; + } + + public function __isset(string $name): bool + { + return isset($this->{$name}); + } }; $arrayAccess = new class implements ArrayAccess { @@ -143,6 +163,7 @@ public function testArrayValuesCastsObject(): void { $obj = (object)['a' => 1, 'b' => 2]; self::assertSame([1, 2], AccessHelper::arrayValues($obj)); + self::assertSame([1, 2], AccessHelper::arrayValues(['a' => 1, 'b' => 2])); } public function testGetValueByIndexReturnsNullWhenOutOfRange(): void @@ -160,6 +181,7 @@ public function testKeyExistsAndCollectionHelpers(): void self::assertTrue(AccessHelper::keyExists($object, 'a')); self::assertFalse(AccessHelper::keyExists('scalar', 'a')); self::assertSame(['a'], AccessHelper::collectionKeys($object)); + self::assertSame(['b'], AccessHelper::collectionKeys(['b' => 2])); self::assertFalse(AccessHelper::isCollectionType('scalar')); } @@ -168,6 +190,9 @@ public function testSetAndUnsetValueAcrossTypes(): void $object = (object)['a' => 1]; AccessHelper::setValue($object, 'b', 2); self::assertSame(2, $object->b); + $array = ['x' => 1]; + AccessHelper::setValue($array, 'y', 3); + self::assertSame(3, $array['y']); $arrayAccess = new class implements ArrayAccess { /** @var array */ @@ -199,7 +224,6 @@ public function offsetUnset($offset): void AccessHelper::unsetValue($arrayAccess, 'k'); self::assertArrayNotHasKey('k', $arrayAccess->store); - $array = ['x' => 1]; AccessHelper::unsetValue($array, 'x'); self::assertArrayNotHasKey('x', $array); diff --git a/tests/IndexFilterTest.php b/tests/IndexFilterTest.php new file mode 100644 index 0000000..341669e --- /dev/null +++ b/tests/IndexFilterTest.php @@ -0,0 +1,87 @@ +filter(['first', 'second', 'third']) + ); + } + + /** + * @throws JSONPathException + */ + public function testSingleIndexWorksForObjectsAndArrayAccess(): void + { + $token = new JSONPathToken(TokenType::Index, 'prop'); + $filter = new IndexFilter($token); + $object = (object)['prop' => 5]; + + self::assertSame([5], $filter->filter($object)); + + $arrayObject = new ArrayObject(['prop' => 'value']); + self::assertSame(['value'], $filter->filter($arrayObject)); + } + + /** + * @throws JSONPathException + */ + public function testWildcardReturnsValuesAndLengthReturnsCount(): void + { + $wildcard = new IndexFilter(new JSONPathToken(TokenType::Index, '*')); + $length = new IndexFilter(new JSONPathToken(TokenType::Index, 'length')); + + $input = ['a' => 1, 'b' => 2]; + + self::assertSame([1, 2], $wildcard->filter($input)); + self::assertSame([2], $length->filter($input)); + } + + /** + * @throws JSONPathException + */ + public function testReturnsEmptyWhenKeyMissing(): void + { + $filter = new IndexFilter(new JSONPathToken(TokenType::Index, 'missing')); + + self::assertSame([], $filter->filter(['present' => 1])); + } + + /** + * @throws JSONPathException + */ + public function testJSONPathFindOnScalarProducesEmptyCollection(): void + { + $result = new JSONPath(123)->find('$.missing'); + + self::assertSame([], $result->getData()); + } +} diff --git a/tests/IndexesFilterTest.php b/tests/IndexesFilterTest.php new file mode 100644 index 0000000..7de3313 --- /dev/null +++ b/tests/IndexesFilterTest.php @@ -0,0 +1,60 @@ + 'slice', 'value' => ['start' => 1, 'end' => 3, 'step' => null]], + 0, + ]); + + $filter = new IndexesFilter($token); + + self::assertSame([2, 3, 1], $filter->filter([1, 2, 3, 4])); + } + + /** + * @throws JSONPathException + */ + public function testSupportsQueryAndWildcard(): void + { + $token = new JSONPathToken(TokenType::Indexes, [ + ['type' => 'query', 'value' => '@.v>1'], + '*', + ]); + + $filter = new IndexesFilter($token); + $filter->setRootData([]); + + $data = [ + ['v' => 1], + ['v' => 2], + ]; + + $result = $filter->filter($data); + + self::assertSame([['v' => 2], ['v' => 1], ['v' => 2]], $result); + } +} diff --git a/tests/JSONPathArrayAccessTest.php b/tests/JSONPathArrayAccessTest.php deleted file mode 100644 index 098c1d8..0000000 --- a/tests/JSONPathArrayAccessTest.php +++ /dev/null @@ -1,111 +0,0 @@ -getData('conferences')); - $jsonPath = new JSONPath($container); - - $teams = $jsonPath - ->find('.conferences.*') - ->find('..teams.*'); - - self::assertEquals('Dodger', $teams[0]['name']); - self::assertEquals('Mets', $teams[1]['name']); - - $teams = $jsonPath - ->find('.conferences.*') - ->find('..teams.*'); - - self::assertEquals('Dodger', $teams[0]['name']); - self::assertEquals('Mets', $teams[1]['name']); - - $teams = $jsonPath - ->find('.conferences..teams.*'); - - self::assertEquals('Dodger', $teams[0]['name']); - self::assertEquals('Mets', $teams[1]['name']); - } - - /** - * @throws Exception - */ - public function testIterating(): void - { - $container = new ArrayObject($this->getData('conferences')); - - $conferences = new JSONPath($container) - ->find('.conferences.*'); - - $names = []; - - foreach ($conferences as $conference) { - $players = $conference - ->find('.teams.*.players[?(@.active=yes)]'); - - foreach ($players as $player) { - $names[] = $player->name; - } - } - - self::assertEquals(['Joe Face', 'something'], $names); - } - - /** - * @throws JsonException - * - * @testWith [false] - * [true] - */ - public function testDifferentStylesOfAccess(bool $asArray = true): void - { - $container = new ArrayObject($this->getData('conferences', $asArray)); - $data = new JSONPath($container); - - self::assertArrayHasKey('conferences', $data); - - $conferences = $data->__get('conferences')->getData(); - - if (\is_array($conferences[0])) { - self::assertEquals('Western Conference', $conferences[0]['name']); - } else { - self::assertEquals('Western Conference', $conferences[0]->name); - } - } - - /** - * @noinspection PhpUndefinedFieldInspection - */ - public function testUpdate(): void - { - $container = new ArrayObject($this->getData('conferences')); - $data = new JSONPath($container); - - $data->offsetSet('name', 'Major League Football'); - /** @phpstan-ignore-next-line */ - self::assertEquals('Major League Football', $data->name); - } -} diff --git a/tests/JSONPathArrayTest.php b/tests/JSONPathArrayTest.php deleted file mode 100644 index d900191..0000000 --- a/tests/JSONPathArrayTest.php +++ /dev/null @@ -1,91 +0,0 @@ -getData('conferences'))); - - $teams = $jsonPath - ->find('.conferences.*') - ->find('..teams.*'); - - self::assertEquals('Dodger', $teams[0]['name']); - self::assertEquals('Mets', $teams[1]['name']); - - $teams = $jsonPath - ->find('.conferences.*') - ->find('..teams.*'); - - self::assertEquals('Dodger', $teams[0]['name']); - self::assertEquals('Mets', $teams[1]['name']); - - $teams = $jsonPath - ->find('.conferences..teams.*'); - - self::assertEquals('Dodger', $teams[0]['name']); - self::assertEquals('Mets', $teams[1]['name']); - } - - /** - * @throws Exception - */ - public function testIterating(): void - { - $data = $this->getData('conferences'); - - $conferences = new JSONPath($data) - ->find('.conferences.*'); - - $names = []; - - foreach ($conferences as $conference) { - $players = $conference - ->find('.teams.*.players[?(@.active=yes)]'); - - foreach ($players as $player) { - $names[] = $player->name; - } - } - - self::assertEquals(['Joe Face', 'something'], $names); - } - - /** - * @throws Exception - */ - public function testDifferentStylesOfAccess(): void - { - $data = (new JSONPath($this->getData('conferences', \random_int(0, 1)))); - - self::assertArrayHasKey('conferences', $data); - - $conferences = $data->__get('conferences')->getData(); - - if (\is_array($conferences[0])) { - self::assertEquals('Western Conference', $conferences[0]['name']); - } else { - self::assertEquals('Western Conference', $conferences[0]->name); - } - } -} diff --git a/tests/JSONPathCoreTest.php b/tests/JSONPathCoreTest.php new file mode 100644 index 0000000..b69de18 --- /dev/null +++ b/tests/JSONPathCoreTest.php @@ -0,0 +1,116 @@ + [ + ['v' => 1], + ['v' => 2], + ['v' => 3], + ], + 'nested' => ['inner' => ['x' => 9]], + ]; + + $path = new JSONPath($data); + $slice = $path->find('$.list[1:3]'); + + self::assertSame([['v' => 2], ['v' => 3]], $slice->getData()); + + $first = $path->first(); + $last = $path->last(); + + self::assertSame($data['list'], $first instanceof JSONPath ? $first->getData() : $first); + self::assertSame(['inner' => ['x' => 9]], $last instanceof JSONPath ? $last->getData() : $last); + self::assertSame('list', $path->firstKey()); + self::assertSame('nested', $path->lastKey()); + } + + public function testOffsetAccessAndIteration(): void + { + $path = new JSONPath(['child' => ['a' => 1]]); + + self::assertTrue($path->offsetExists('child')); + + /** @var JSONPath $child */ + $child = $path['child']; + + self::assertInstanceOf(JSONPath::class, $child); + self::assertSame(['a' => 1], $child->getData()); + + $path[] = 'appended'; + $path['new'] = 'value'; + unset($path['child']); + + $collected = []; + + foreach ($path as $key => $value) { + $collected[$key] = $value instanceof JSONPath ? $value->getData() : $value; + } + + self::assertArrayHasKey(0, $collected); + self::assertSame('appended', $collected[0]); + self::assertSame('value', $collected['new']); + self::assertEquals(new JSONPathException('oops'), new JSONPathException('oops')); + self::assertSame('index', TokenType::Index->value); + } + + /** + * @throws JSONPathException + */ + public function testParseTokensCachesResults(): void + { + $path = new JSONPath(['a' => ['b' => 1]]); + $first = $path->parseTokens('$.a.b'); + $second = $path->parseTokens('$.a.b'); + + self::assertNotEmpty($first); + self::assertSame($first, $second); + } + + public function testJsonSerializeAndMagicGet(): void + { + $path = new JSONPath(['a' => 1]); + + self::assertSame(['a' => 1], $path->jsonSerialize()); + self::assertSame(1, $path->__get('a')); + self::assertNull($path->__get('missing')); + + $empty = new JSONPath([]); + + self::assertNull($empty->first()); + self::assertNull($empty->last()); + self::assertNull($empty->firstKey()); + self::assertNull($empty->lastKey()); + self::assertSame(0, $empty->count()); + } + + /** + * @throws JSONPathException + */ + public function testFindOnScalarReturnsEmptyResult(): void + { + $result = new JSONPath(123)->find('$.missing')->getData(); + + self::assertSame([], $result); + } +} diff --git a/tests/JSONPathDashedIndexTest.php b/tests/JSONPathDashedIndexTest.php deleted file mode 100644 index 4cedbc8..0000000 --- a/tests/JSONPathDashedIndexTest.php +++ /dev/null @@ -1,52 +0,0 @@ -, array}> - */ - public static function indexDataProvider(): array - { - return [ - [ - '$.data[test-test-test]', - ['data' => ['test-test-test' => 'foo']], - ['foo'], - ], - [ - '$.data[40f35757-2563-4790-b0b1-caa904be455f]', - ['data' => ['40f35757-2563-4790-b0b1-caa904be455f' => 'bar']], - ['bar'], - ], - ]; - } - - /** - * @throws JSONPathException - * @param array $data - * @param array $expected - */ - #[DataProvider('indexDataProvider')] - public function testSlice(string $path, array $data, array $expected): void - { - $results = new JSONPath($data) - ->find($path); - - self::assertEquals($expected, $results->getData()); - } -} diff --git a/tests/JSONPathIntegrationTest.php b/tests/JSONPathIntegrationTest.php new file mode 100644 index 0000000..88eef64 --- /dev/null +++ b/tests/JSONPathIntegrationTest.php @@ -0,0 +1,59 @@ + new ArrayObject([ + ['name' => 'keep', 'active' => true], + ['name' => 'skip', 'active' => false], + ]), + ]); + + $result = new JSONPath($data)->find('$.items[?(@.active==true)]')->getData(); + + self::assertSame([['name' => 'keep', 'active' => true]], $result); + } + + /** + * @throws JSONPathException + */ + public function testDashedIndexIsParsedWithoutQuotes(): void + { + $data = ['data' => ['dash-key' => 42, 'other' => 1]]; + + $result = new JSONPath($data)->find('$.data[dash-key]')->getData(); + + self::assertSame([42], $result); + } + + /** + * @throws JSONPathException + */ + public function testSlicesResolveViaPublicApi(): void + { + $path = new JSONPath(['values' => [0, 1, 2, 3, 4]]); + + self::assertSame([1, 2, 3], $path->find('$.values[1:-1]')->getData()); + self::assertSame([4, 3], $path->find('$.values[-1:-3:-1]')->getData()); + } +} diff --git a/tests/JSONPathLexerTest.php b/tests/JSONPathLexerTest.php index bd2d866..5948923 100644 --- a/tests/JSONPathLexerTest.php +++ b/tests/JSONPathLexerTest.php @@ -13,61 +13,227 @@ use Flow\JSONPath\JSONPathException; use Flow\JSONPath\JSONPathLexer; use Flow\JSONPath\TokenType; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +#[CoversClass(JSONPathLexer::class)] class JSONPathLexerTest extends TestCase { /** + * @param list $expectedTokens * @throws JSONPathException */ - public function testIndexWildcard(): void + #[DataProvider('expressionProvider')] + public function testParsesExpressions(string $expression, array $expectedTokens): void { - $tokens = new JSONPathLexer('.*') - ->parseExpression(); + $tokens = new JSONPathLexer($expression)->parseExpression(); - self::assertEquals(TokenType::Index, $tokens[0]->type); - self::assertEquals("*", $tokens[0]->value); - } - - /** - * @throws JSONPathException - */ - public function testIndexSimple(): void - { - $tokens = new JSONPathLexer('.foo') - ->parseExpression(); + self::assertCount(\count($expectedTokens), $tokens); - self::assertEquals(TokenType::Index, $tokens[0]->type); - self::assertEquals("foo", $tokens[0]->value); - } + foreach ($expectedTokens as $i => $expected) { + self::assertEquals($expected['type'], $tokens[$i]->type); + self::assertEquals($expected['value'], $tokens[$i]->value); - /** - * @throws JSONPathException - */ - public function testIndexRecursive(): void - { - $tokens = new JSONPathLexer('..teams.*') - ->parseExpression(); - - self::assertCount(3, $tokens); - self::assertEquals(TokenType::Recursive, $tokens[0]->type); - self::assertEquals(null, $tokens[0]->value); - self::assertEquals(TokenType::Index, $tokens[1]->type); - self::assertEquals('teams', $tokens[1]->value); - self::assertEquals(TokenType::Index, $tokens[2]->type); - self::assertEquals('*', $tokens[2]->value); + if (\array_key_exists('shorthand', $expected)) { + self::assertSame($expected['shorthand'], $tokens[$i]->shorthand); + } + } } /** - * @throws JSONPathException + * @return iterable}> */ - public function testIndexComplex(): void + public static function expressionProvider(): iterable { - $tokens = new JSONPathLexer('["\'b.^*_"]') - ->parseExpression(); - - self::assertEquals(TokenType::Index, $tokens[0]->type); - self::assertEquals("'b.^*_", $tokens[0]->value); + yield 'wildcard index' => [ + '.*', + [ + ['type' => TokenType::Index, 'value' => '*'], + ], + ]; + + yield 'simple index' => [ + '.foo', + [ + ['type' => TokenType::Index, 'value' => 'foo'], + ], + ]; + + yield 'bare index normalizes dot prefix' => [ + 'foo', + [ + ['type' => TokenType::Index, 'value' => 'foo'], + ], + ]; + + yield 'complex quoted index' => [ + '["\'b.^*_"]', + [ + ['type' => TokenType::Index, 'value' => "'b.^*_"], + ], + ]; + + yield 'integer index' => [ + '[0]', + [ + ['type' => TokenType::Index, 'value' => 0], + ], + ]; + + yield 'index after dot notation' => [ + '.books[0]', + [ + ['type' => TokenType::Index, 'value' => 'books'], + ['type' => TokenType::Index, 'value' => 0], + ], + ]; + + yield 'quoted index with whitespace' => [ + '[ "foo$-/\'" ]', + [ + ['type' => TokenType::Index, 'value' => "foo$-/'"], + ], + ]; + + yield 'slice with explicit bounds' => [ + '[0:1:2]', + [ + ['type' => TokenType::Slice, 'value' => ['start' => 0, 'end' => 1, 'step' => 2]], + ], + ]; + + yield 'negative index' => [ + '[-1]', + [ + ['type' => TokenType::Index, 'value' => -1], + ], + ]; + + yield 'slice all nulls' => [ + '[:]', + [ + ['type' => TokenType::Slice, 'value' => ['start' => null, 'end' => null, 'step' => null]], + ], + ]; + + yield 'shorthand query current' => [ + '[?@]', + [ + ['type' => TokenType::QueryMatch, 'value' => '@', 'shorthand' => true], + ], + ]; + + yield 'shorthand query comparison' => [ + '[?@==null]', + [ + ['type' => TokenType::QueryMatch, 'value' => '@==null', 'shorthand' => true], + ], + ]; + + yield 'shorthand query empty expression' => [ + '[?]', + [ + ['type' => TokenType::QueryMatch, 'value' => '@', 'shorthand' => true], + ], + ]; + + yield 'double quoted index with escape' => [ + '$["a\\"b"]', + [ + ['type' => TokenType::Index, 'value' => 'a"b'], + ], + ]; + + yield 'union with slice and negative index' => [ + '[-2,1:3]', + [ + [ + 'type' => TokenType::Indexes, + 'value' => [ + -2, + [ + 'type' => 'slice', + 'value' => ['start' => 1, 'end' => 3, 'step' => null], + ], + ], + ], + ], + ]; + + yield 'union with query' => [ + '[1,?(@.foo>1)]', + [ + [ + 'type' => TokenType::Indexes, + 'value' => [ + 1, + [ + 'type' => 'query', + 'value' => '@.foo>1', + ], + ], + ], + ], + ]; + + yield 'single quoted index with escapes' => [ + "$['back\\\\slash\\'quote']", + [ + ['type' => TokenType::Index, 'value' => "back\\slash'quote"], + ], + ]; + + yield 'multiple quoted indexes collapse to array' => [ + '["first","second"]', + [ + ['type' => TokenType::Index, 'value' => ['first', 'second'], 'quoted' => true], + ], + ]; + + yield 'empty quoted index resolves to empty string' => [ + '[""]', + [ + ['type' => TokenType::Index, 'value' => '', 'quoted' => true], + ], + ]; + + yield 'query result expression' => [ + '[(@.foo + 2)]', + [ + ['type' => TokenType::QueryResult, 'value' => '@.foo + 2'], + ], + ]; + + yield 'query match' => [ + "[?(@['@language']='en')]", + [ + ['type' => TokenType::QueryMatch, 'value' => "@['@language']='en'"], + ], + ]; + + yield 'recursive simple' => [ + '..foo', + [ + ['type' => TokenType::Recursive, 'value' => null], + ['type' => TokenType::Index, 'value' => 'foo'], + ], + ]; + + yield 'recursive wildcard' => [ + '..*', + [ + ['type' => TokenType::Recursive, 'value' => null], + ['type' => TokenType::Index, 'value' => '*'], + ], + ]; + + yield 'indexes with whitespace' => [ + '[ 1,2 , 3]', + [ + ['type' => TokenType::Indexes, 'value' => [1, 2, 3]], + ], + ]; } /** @@ -78,170 +244,7 @@ public function testIndexBadlyFormed(): void $this->expectException(JSONPathException::class); $this->expectExceptionMessage('Unable to parse token hello* in expression: .hello*'); - new JSONPathLexer('.hello*') - ->parseExpression(); - } - - /** - * @throws JSONPathException - */ - public function testIndexInteger(): void - { - $tokens = new JSONPathLexer('[0]') - ->parseExpression(); - - self::assertEquals(TokenType::Index, $tokens[0]->type); - self::assertSame(0, $tokens[0]->value); - } - - /** - * @throws JSONPathException - */ - public function testIndexIntegerAfterDotNotation(): void - { - $tokens = new JSONPathLexer('.books[0]') - ->parseExpression(); - - self::assertEquals(TokenType::Index, $tokens[0]->type); - self::assertEquals(TokenType::Index, $tokens[1]->type); - self::assertEquals("books", $tokens[0]->value); - self::assertSame(0, $tokens[1]->value); - } - - /** - * @throws JSONPathException - */ - public function testIndexWord(): void - { - $tokens = new JSONPathLexer('["foo$-/\'"]') - ->parseExpression(); - - self::assertEquals(TokenType::Index, $tokens[0]->type); - self::assertEquals("foo$-/'", $tokens[0]->value); - } - - /** - * @throws JSONPathException - */ - public function testIndexWordWithWhitespace(): void - { - $tokens = new JSONPathLexer('[ "foo$-/\'" ]') - ->parseExpression(); - - self::assertEquals(TokenType::Index, $tokens[0]->type); - self::assertEquals("foo$-/'", $tokens[0]->value); - } - - /** - * @throws JSONPathException - */ - public function testSliceSimple(): void - { - $tokens = new JSONPathLexer('[0:1:2]') - ->parseExpression(); - - self::assertEquals(TokenType::Slice, $tokens[0]->type); - self::assertEquals(['start' => 0, 'end' => 1, 'step' => 2], $tokens[0]->value); - } - - /** - * @throws JSONPathException - */ - public function testIndexNegativeIndex(): void - { - $tokens = new JSONPathLexer('[-1]') - ->parseExpression(); - - self::assertEquals(TokenType::Slice, $tokens[0]->type); - self::assertEquals(['start' => -1, 'end' => null, 'step' => null], $tokens[0]->value); - } - - /** - * @throws JSONPathException - */ - public function testSliceAllNull(): void - { - $tokens = new JSONPathLexer('[:]') - ->parseExpression(); - - self::assertEquals(TokenType::Slice, $tokens[0]->type); - self::assertEquals(['start' => null, 'end' => null, 'step' => null], $tokens[0]->value); - } - - /** - * @throws JSONPathException - */ - public function testQueryResultSimple(): void - { - $tokens = new JSONPathLexer('[(@.foo + 2)]') - ->parseExpression(); - - self::assertEquals(TokenType::QueryResult, $tokens[0]->type); - self::assertEquals('@.foo + 2', $tokens[0]->value); - } - - /** - * @throws JSONPathException - */ - public function testQueryMatchSimple(): void - { - $tokens = new JSONPathLexer('[?(@.foo < \'bar\')]') - ->parseExpression(); - - self::assertEquals(TokenType::QueryMatch, $tokens[0]->type); - self::assertEquals('@.foo < \'bar\'', $tokens[0]->value); - } - - /** - * @throws JSONPathException - */ - public function testQueryMatchNotEqualTO(): void - { - $tokens = new JSONPathLexer('[?(@.foo != \'bar\')]') - ->parseExpression(); - - self::assertEquals(TokenType::QueryMatch, $tokens[0]->type); - self::assertEquals('@.foo != \'bar\'', $tokens[0]->value); - } - - /** - * @throws JSONPathException - */ - public function testQueryMatchBrackets(): void - { - $tokens = new JSONPathLexer("[?(@['@language']='en')]") - ->parseExpression(); - - self::assertEquals(TokenType::QueryMatch, $tokens[0]->type); - self::assertEquals("@['@language']='en'", $tokens[0]->value); - } - - /** - * @throws JSONPathException - */ - public function testRecursiveSimple(): void - { - $tokens = new JSONPathLexer('..foo') - ->parseExpression(); - - self::assertEquals(TokenType::Recursive, $tokens[0]->type); - self::assertEquals(TokenType::Index, $tokens[1]->type); - self::assertEquals(null, $tokens[0]->value); - self::assertEquals('foo', $tokens[1]->value); - } - - /** - * @throws JSONPathException - */ - public function testRecursiveWildcard(): void - { - $tokens = new JSONPathLexer('..*') - ->parseExpression(); - - self::assertEquals(TokenType::Recursive, $tokens[0]->type); - self::assertEquals(TokenType::Index, $tokens[1]->type); - self::assertEquals(null, $tokens[0]->value); - self::assertEquals('*', $tokens[1]->value); + new JSONPathLexer('.hello*')->parseExpression(); } /** @@ -252,48 +255,33 @@ public function testRecursiveBadlyFormed(): void $this->expectException(JSONPathException::class); $this->expectExceptionMessage('Unable to parse token ba^r in expression: ..ba^r'); - new JSONPathLexer('..ba^r') - ->parseExpression(); + new JSONPathLexer('..ba^r')->parseExpression(); } /** * @throws JSONPathException */ - public function testIndexesSimple(): void + public function testEmptyExpressionsReturnNoTokens(): void { - $tokens = new JSONPathLexer('[1,2,3]') - ->parseExpression(); - - self::assertEquals(TokenType::Indexes, $tokens[0]->type); - self::assertEquals([1, 2, 3], $tokens[0]->value); + self::assertSame([], new JSONPathLexer('')->parseExpression()); + self::assertSame([], new JSONPathLexer('$')->parseExpression()); } /** * @throws JSONPathException */ - public function testIndexesWhitespace(): void + public function testSingleCharacterExpressionNormalized(): void { - $tokens = new JSONPathLexer('[ 1,2 , 3]') - ->parseExpression(); - - self::assertEquals(TokenType::Indexes, $tokens[0]->type); - self::assertEquals([1, 2, 3], $tokens[0]->value); + self::assertSame([], new JSONPathLexer('.')->parseExpression()); } /** * @throws JSONPathException */ - public function testEmptyExpressionsReturnNoTokens(): void + public function testUnclosedBracketThrowsAfterFinalFlush(): void { - self::assertSame([], new JSONPathLexer('')->parseExpression()); - self::assertSame([], new JSONPathLexer('$')->parseExpression()); - } + $this->expectException(JSONPathException::class); - /** - * @throws JSONPathException - */ - public function testSingleCharacterExpressionNormalized(): void - { - self::assertSame([], new JSONPathLexer('.')->parseExpression()); + new JSONPathLexer("['unterminated")->parseExpression(); } } diff --git a/tests/JSONPathSliceAccessTest.php b/tests/JSONPathSliceAccessTest.php deleted file mode 100644 index 4a18ae0..0000000 --- a/tests/JSONPathSliceAccessTest.php +++ /dev/null @@ -1,97 +0,0 @@ -, array}> - */ - public static function sliceDataProvider(): array - { - return [ - [ - '$.data[1:3]', - ['data' => ['foo0', 'foo1', 'foo2', 'foo3', 'foo4', 'foo5']], - ['foo1', 'foo2'], - ], - [ - '$.data[4:]', - ['data' => ['foo0', 'foo1', 'foo2', 'foo3', 'foo4', 'foo5']], - ['foo4', 'foo5'], - ], - [ - '$.data[:2]', - ['data' => ['foo0', 'foo1', 'foo2', 'foo3', 'foo4', 'foo5']], - ['foo0', 'foo1'], - ], - [ - '$.data[:]', - ['data' => ['foo0', 'foo1', 'foo2', 'foo3', 'foo4', 'foo5']], - ['foo0', 'foo1', 'foo2', 'foo3', 'foo4', 'foo5'], - ], - [ - '$.data[-1]', - ['data' => ['foo0', 'foo1', 'foo2', 'foo3', 'foo4', 'foo5']], - ['foo5'], - ], - [ - '$.data[-2:]', - ['data' => ['foo0', 'foo1', 'foo2', 'foo3', 'foo4', 'foo5']], - ['foo4', 'foo5'], - ], - [ - '$.data[:-2]', - ['data' => ['foo0', 'foo1', 'foo2', 'foo3', 'foo4', 'foo5']], - ['foo0', 'foo1', 'foo2', 'foo3'], - ], - [ - '$.data[::2]', - ['data' => ['foo0', 'foo1', 'foo2', 'foo3', 'foo4', 'foo5']], - ['foo0', 'foo2', 'foo4'], - ], - [ - '$.data[2::2]', - ['data' => ['foo0', 'foo1', 'foo2', 'foo3', 'foo4', 'foo5']], - ['foo2', 'foo4'], - ], - [ - '$.data[:-2:2]', - ['data' => ['foo0', 'foo1', 'foo2', 'foo3', 'foo4', 'foo5']], - ['foo0', 'foo2'], - ], - [ - '$.data[1:5:2]', - ['data' => ['foo0', 'foo1', 'foo2', 'foo3', 'foo4', 'foo5']], - ['foo1', 'foo3'], - ], - ]; - } - - /** - * @throws JSONPathException - * @param array $data - * @param array $expected - */ - #[DataProvider('sliceDataProvider')] - public function testSlice(string $path, array $data, array $expected): void - { - $result = new JSONPath($data) - ->find($path); - - self::assertEquals($expected, $result->getData()); - } -} diff --git a/tests/JSONPathTest.php b/tests/JSONPathTest.php deleted file mode 100644 index a5bd09a..0000000 --- a/tests/JSONPathTest.php +++ /dev/null @@ -1,884 +0,0 @@ -getData('example')) - ->find('$.store.books[0].title'); - - self::assertEquals('Sayings of the Century', $result[0]); - } - - /** - * @throws JSONPathException - */ - public function testIndexesObject(): void - { - $result = new JSONPath($this->getData('indexed-object')) - ->find('$.store.books[3].title'); - - self::assertEquals('Sword of Honour', $result[0]); - } - - /** - * $['store']['books'][0]['title'] - * - * @throws JSONPathException - */ - public function testChildOperatorsAlt(): void - { - $result = new JSONPath($this->getData('example')) - ->find("$['store']['books'][0]['title']"); - - self::assertEquals('Sayings of the Century', $result[0]); - } - - /** - * $.array[start:end:step] - * - * @throws JSONPathException - */ - public function testFilterSliceA(): void - { - // Copy all items... similar to a wildcard - $result = new JSONPath($this->getData('example')) - ->find("$['store']['books'][:].title"); - - self::assertEquals( - ['Sayings of the Century', 'Sword of Honour', 'Moby Dick', 'The Lord of the Rings'], - $result->getData() - ); - } - - /** - * Positive end indexes - * $[0:2] - * - * @throws JSONPathException - */ - public function testFilterSlicePositiveEndIndexes(): void - { - $jsonPath = (new JSONPath(['first', 'second', 'third', 'fourth', 'fifth'])); - - $result = $jsonPath - ->find('$[0:0]'); - - self::assertEquals([], $result->getData()); - - $result = $jsonPath - ->find('$[0:1]'); - - self::assertEquals(['first'], $result->getData()); - - $result = $jsonPath - ->find('$[0:2]'); - - self::assertEquals(['first', 'second'], $result->getData()); - - $result = $jsonPath - ->find('$[:2]'); - - self::assertEquals(['first', 'second'], $result->getData()); - - $result = $jsonPath - ->find('$[1:2]'); - - self::assertEquals(['second'], $result->getData()); - - $result = $jsonPath - ->find('$[0:3:1]'); - - self::assertEquals(['first', 'second', 'third'], $result->getData()); - - $result = $jsonPath - ->find('$[0:3:0]'); - - self::assertEquals(['first', 'second', 'third'], $result->getData()); - } - - /** - * @throws JSONPathException - */ - public function testFilterSliceNegativeStartIndexes(): void - { - $result = new JSONPath(['first', 'second', 'third', 'fourth', 'fifth']) - ->find('$[-2:]'); - - self::assertEquals(['fourth', 'fifth'], $result->getData()); - - $result = new JSONPath(['first', 'second', 'third', 'fourth', 'fifth']) - ->find('$[-1:]'); - - self::assertEquals(['fifth'], $result->getData()); - - $result = new JSONPath(['first', 'second', 'third']) - ->find('$[-4:]'); - - self::assertEquals(['first', 'second', 'third'], $result->getData()); - } - - /** - * Negative end indexes - * $[:-2] - * - * @throws JSONPathException - */ - public function testFilterSliceNegativeEndIndexes(): void - { - $jsonPath = (new JSONPath(['first', 'second', 'third', 'fourth', 'fifth'])); - - $result = $jsonPath - ->find('$[:-2]'); - - self::assertEquals(['first', 'second', 'third'], $result->getData()); - - $result = $jsonPath - ->find('$[0:-2]'); - - self::assertEquals(['first', 'second', 'third'], $result->getData()); - } - - /** - * Negative end indexes - * $[:-2] - * - * @throws JSONPathException - */ - public function testFilterSliceNegativeStartAndEndIndexes(): void - { - $jsonPath = (new JSONPath(['first', 'second', 'third', 'fourth', 'fifth'])); - - $result = $jsonPath - ->find('$[-2:-1]'); - - self::assertEquals(['fourth'], $result->getData()); - - $result = $jsonPath - ->find('$[-4:-2]'); - - self::assertEquals(['second', 'third'], $result->getData()); - } - - /** - * Negative end indexes - * $[:-2] - * - * @throws JSONPathException - */ - public function testFilterSliceNegativeStartAndPositiveEnd(): void - { - $result = new JSONPath(['first', 'second', 'third', 'fourth', 'fifth']) - ->find('$[-2:2]'); - - self::assertEquals([], $result->getData()); - } - - /** - * @throws JSONPathException - */ - public function testFilterSliceStepBy2(): void - { - $result = new JSONPath(['first', 'second', 'third', 'fourth', 'fifth']) - ->find('$[0:4:2]'); - - self::assertEquals(['first', 'third'], $result->getData()); - } - - /** - * The Last item - * $[-1] - * - * @throws JSONPathException - */ - public function testFilterLastIndex(): void - { - $result = new JSONPath(['first', 'second', 'third', 'fourth', 'fifth']) - ->find('$[-1]'); - - self::assertEquals(['fifth'], $result->getData()); - } - - /** - * Array index slice only end - * $[:2] - * - * @throws JSONPathException - */ - public function testFilterSliceG(): void - { - // Fetch up to the second index - $result = new JSONPath(['first', 'second', 'third', 'fourth', 'fifth']) - ->find('$[:2]'); - - self::assertEquals(['first', 'second'], $result->getData()); - } - - /** - * $.store.books[(@.length-1)].title - * - * This notation is only partially implemented e.g. hacked in - * - * @throws JSONPathException - */ - public function testChildQuery(): void - { - $result = new JSONPath($this->getData('example')) - ->find('$.store.books[(@.length-1)].title'); - - self::assertEquals(['The Lord of the Rings'], $result->getData()); - } - - /** - * $.store.books[?(@.price < 10)].title - * Filter books that have a price less than 10 - * - * @throws JSONPathException - */ - public function testQueryMatchLessThan(): void - { - $result = new JSONPath($this->getData('example')) - ->find('$.store.books[?(@.price < 10)].title'); - - self::assertEquals(['Sayings of the Century', 'Moby Dick'], $result->getData()); - } - - /** - * $.store.books[?(@.price > 10)].title - * Filter books that have a price more than 10 - * - * @throws JSONPathException - */ - public function testQueryMatchMoreThan(): void - { - $result = new JSONPath($this->getData('example')) - ->find('$.store.books[?(@.price > 10)].title'); - - self::assertEquals(['Sword of Honour', 'The Lord of the Rings'], $result->getData()); - } - - /** - * $.store.books[?(@.price <= 12.99)].title - * Filter books that have a price less or equal to 12.99 - * - * @throws JSONPathException - */ - public function testQueryMatchLessOrEqual(): void - { - $result = new JSONPath($this->getData('example')) - ->find('$.store.books[?(@.price <= 12.99)].title'); - - self::assertEquals(['Sayings of the Century', 'Sword of Honour', 'Moby Dick'], $result->getData()); - } - - /** - * $.store.books[?(@.price >= 12.99)].title - * Filter books that have a price less or equal to 12.99 - * - * @throws JSONPathException - */ - public function testQueryMatchEqualOrMore(): void - { - $result = new JSONPath($this->getData('example')) - ->find('$.store.books[?(@.price >= 12.99)].title'); - - self::assertEquals(['Sword of Honour', 'The Lord of the Rings'], $result->getData()); - } - - /** - * $..books[?(@.author == "J. R. R. Tolkien")] - * Filter books that have an author equal to "..." - * - * @throws JSONPathException - */ - public function testQueryMatchEquals(): void - { - $result = new JSONPath($this->getData('example')) - ->find('$..books[?(@.author == "J. R. R. Tolkien")].title'); - - self::assertEquals('The Lord of the Rings', $result[0]); - } - - /** - * $..books[?(@.category=="fiction" && @.author == \'Evelyn Waugh\')].title' - * Filter books that are in the "..." category and have an author equal to "..." - * - * @throws JSONPathException - */ - public function testQueryMatchWhitespaceIgnored(): void - { - // Additional spaces in filter string are intended to be there - $result = new JSONPath($this->getData('example')) - ->find('$..books[?( @.category=="fiction" && @.author == \'Evelyn Waugh\' )].title'); - - self::assertEquals('Sword of Honour', $result[0]); - } - - /** - * $..books[?(@.author = 1)] - * Filter books that have a title equal to "..." - * - * @throws JSONPathException - */ - public function testQueryMatchEqualsWithUnquotedInteger(): void - { - $results = new JSONPath($this->getData('simple-integers')) - ->find('$..features[?(@.value = 1)]'); - - self::assertEquals('foo', $results[0]->name); - self::assertEquals('baz', $results[1]->name); - } - - /** - * $..books[?(@.author != "J. R. R. Tolkien")] - * Filter books that have an author not equal to "..." - * - * @throws JSONPathException - */ - public function testQueryMatchNotEqualsTo(): void - { - $jsonPath = (new JSONPath($this->getData('example'))); - - $results = $jsonPath - ->find('$..books[?(@.author != "J. R. R. Tolkien")].title'); - - self::assertcount(3, $results); - self::assertEquals(['Sayings of the Century', 'Sword of Honour', 'Moby Dick'], $results->getData()); - - $results = $jsonPath - ->find('$..books[?(@.author !== "J. R. R. Tolkien")].title'); - - self::assertcount(3, $results); - self::assertEquals(['Sayings of the Century', 'Sword of Honour', 'Moby Dick'], $results->getData()); - - $results = $jsonPath - ->find('$..books[?(@.author <> "J. R. R. Tolkien")].title'); - - self::assertcount(3, $results); - self::assertEquals(['Sayings of the Century', 'Sword of Honour', 'Moby Dick'], $results->getData()); - } - - /** - * $..books[?(@.author =~ /nigel ree?s/i)] - * Filter books where author matches regex - * - * @throws JSONPathException - */ - public function testQueryMatchWithRegexCaseSensitive(): void - { - $jsonPath = (new JSONPath($this->getData('example'))); - - $results = $jsonPath - ->find('$..books[?(@.author =~ /nigel ree?s/i)].title'); - - self::assertcount(1, $results); - self::assertEquals(['Sayings of the Century'], $results->getData()); - - $results = $jsonPath - ->find('$..books[?(@.title =~ /^(Say|The).*/)].title'); - - self::assertcount(2, $results); - self::assertEquals(['Sayings of the Century', 'The Lord of the Rings'], $results->getData()); - } - - /** - * $..books[?(@.author =~ "J. R. R. Tolkien")] - * Filter books where author matches invalid regex - * - * @throws JSONPathException - */ - public function testQueryMatchWithInvalidRegex(): void - { - $result = new JSONPath($this->getData('example')) - ->find('$..books[?(@.author =~ "J. R. R. Tolkien")].title'); - - self::assertEmpty($result->getData()); - } - - /** - * $..books[?(@.author in ["J. R. R. Tolkien", "Nigel Rees"])] - * Filter books that have a title in ["...", "..."] - * - * @throws JSONPathException - */ - public function testQueryMatchIn(): void - { - $results = new JSONPath($this->getData('example')) - ->find('$..books[?(@.author in ["J. R. R. Tolkien", "Nigel Rees"])].title'); - - self::assertEquals(['Sayings of the Century', 'The Lord of the Rings'], $results->getData()); - } - - /** - * $..books[?(@.author nin ["J. R. R. Tolkien", "Nigel Rees"])] - * Filter books that don't have a title in ["...", "..."] - * - * @throws JSONPathException - */ - public function testQueryMatchNin(): void - { - $results = new JSONPath($this->getData('example')) - ->find('$..books[?(@.author nin ["J. R. R. Tolkien", "Nigel Rees"])].title'); - - self::assertEquals(['Sword of Honour', 'Moby Dick'], $results->getData()); - } - - /** - * $..books[?(@.author nin ["J. R. R. Tolkien", "Nigel Rees"])] - * Filter books that don't have a title in ["...", "..."] - * - * @throws JSONPathException - */ - public function testQueryMatchNotIn(): void - { - $results = new JSONPath($this->getData('example')) - ->find('$..books[?(@.author !in ["J. R. R. Tolkien", "Nigel Rees"])].title'); - - self::assertEquals(['Sword of Honour', 'Moby Dick'], $results->getData()); - } - - /** - * $.store.books[*].author - * - * @throws JSONPathException - */ - public function testWildcardAltNotation(): void - { - $results = new JSONPath($this->getData('example')) - ->find('$.store.books[*].author'); - - self::assertEquals(['Nigel Rees', 'Evelyn Waugh', 'Herman Melville', 'J. R. R. Tolkien'], $results->getData()); - } - - /** - * $..author - * - * @throws JSONPathException - */ - public function testRecursiveChildSearch(): void - { - $result = new JSONPath($this->getData('example')) - ->find('$..author'); - - self::assertEquals(['Nigel Rees', 'Evelyn Waugh', 'Herman Melville', 'J. R. R. Tolkien'], $result->getData()); - } - - /** - * $.store.* - * all things in store - * the structure of the example data makes this test look weird - * - * @throws JSONPathException - */ - public function testWildCard(): void - { - $result = new JSONPath($this->getData('example')) - ->find('$.store.*'); - - if (\is_object($result[0][0])) { - self::assertEquals('Sayings of the Century', $result[0][0]->title); - } else { - self::assertEquals('Sayings of the Century', $result[0][0]['title']); - } - - if (\is_object($result[1])) { - self::assertEquals('red', $result[1]->color); - } else { - self::assertEquals('red', $result[1]['color']); - } - } - - /** - * $.store..price - * the price of everything in the store. - * - * @throws JSONPathException - */ - public function testRecursiveChildSearchAlt(): void - { - $result = new JSONPath($this->getData('example')) - ->find('$.store..price'); - - self::assertEquals([8.95, 12.99, 8.99, 22.99, 19.95], $result->getData()); - } - - /** - * $..books[2] - * the third book - * - * @throws JSONPathException - */ - public function testRecursiveChildSearchWithChildIndex(): void - { - $result = new JSONPath($this->getData('example')) - ->find('$..books[2].title'); - - self::assertEquals(['Moby Dick'], $result->getData()); - } - - /** - * $..books[(@.length-1)] - * - * @throws JSONPathException - */ - public function testRecursiveChildSearchWithChildQuery(): void - { - $result = new JSONPath($this->getData('example')) - ->find('$..books[(@.length-1)].title'); - - self::assertEquals(['The Lord of the Rings'], $result->getData()); - } - - /** - * $..books[-1:] - * Return the last results - * - * @throws JSONPathException - */ - public function testRecursiveChildSearchWithSliceFilter(): void - { - $result = new JSONPath($this->getData('example')) - ->find('$..books[-1:].title'); - - self::assertEquals(['The Lord of the Rings'], $result->getData()); - } - - /** - * $..books[?(@.isbn)] - * filter all books with isbn - * - * @throws JSONPathException - */ - public function testRecursiveWithQueryMatch(): void - { - $result = new JSONPath($this->getData('example')) - ->find('$..books[?(@.isbn)].isbn'); - - self::assertEquals(['0-553-21311-3', '0-395-19395-8'], $result->getData()); - } - - /** - * .data.tokens[?(@.Employee.FirstName)] - * Verify that it is possible to filter with a key containing punctuation - * - * @throws JSONPathException|JsonException - */ - public function testRecursiveWithQueryMatchWithDots(): void - { - $result = new JSONPath($this->getData('with-dots')) - ->find(".data.tokens[?(@.Employee.FirstName)]"); - $result = \json_decode( - \json_encode($result, JSON_THROW_ON_ERROR), - true, - 512, - JSON_THROW_ON_ERROR - ); - - self::assertEquals([['Employee.FirstName' => 'Jack']], $result); - } - - /** - * $..* - * All members of JSON structure - * - * @throws JSONPathException|JsonException - */ - public function testRecursiveWithWildcard(): void - { - $result = new JSONPath($this->getData('example')) - ->find('$..*'); - $result = \json_decode( - \json_encode($result, JSON_THROW_ON_ERROR), - true, - 512, - JSON_THROW_ON_ERROR - ); - - self::assertEquals('Sayings of the Century', $result[0]['books'][0]['title']); - self::assertEquals(19.95, $result[27]); - } - - /** - * Tests direct key access. - * - * @throws JSONPathException - */ - public function testSimpleArrayAccess(): void - { - $result = new JSONPath(['title' => 'test title']) - ->find('title'); - - self::assertEquals(['test title'], $result->getData()); - } - - /** - * @throws JSONPathException - */ - public function testFilteringOnNoneArrays(): void - { - $result = new JSONPath(['foo' => 'asdf']) - ->find('$.foo.bar'); - - self::assertEquals([], $result->getData()); - } - - /** - * @throws JSONPathException - */ - public function testMagicMethods(): void - { - $fooClass = new JSONPathTestClass(); - $results = new JSONPath($fooClass, JSONPath::ALLOW_MAGIC)->find('$.foo'); - - self::assertEquals(['bar'], $results->getData()); - } - - /** - * @throws JSONPathException - */ - public function testMatchWithComplexSquareBrackets(): void - { - $result = new JSONPath($this->getData('extra')) - ->find("$['http://www.w3.org/2000/01/rdf-schema#label'][?(@['@language']='en')]['@language']"); - - self::assertEquals(["en"], $result->getData()); - } - - /** - * @throws JSONPathException - */ - public function testQueryMatchWithRecursive(): void - { - $result = new JSONPath($this->getData('locations')) - ->find("..[?(@.type == 'suburb')].name"); - - self::assertEquals(["Rosebank"], $result->getData()); - } - - /** - * @throws JSONPathException|JsonException - */ - public function testFirst(): void - { - $result = new JSONPath($this->getData('extra')) - ->find("$['http://www.w3.org/2000/01/rdf-schema#label'].*"); - - self::assertEquals(["@language" => "en"], $result->first()->getData()); - } - - /** - * @throws JSONPathException|JsonException - */ - public function testLast(): void - { - $result = new JSONPath($this->getData('extra')) - ->find("$['http://www.w3.org/2000/01/rdf-schema#label'].*"); - - self::assertEquals(["@language" => "de"], $result->last()->getData()); - } - - /** - * @throws JSONPathException - */ - public function testSlashesInIndex(): void - { - $result = new JSONPath($this->getData('with-slashes')) - ->find("$['mediatypes']['image/png']"); - - self::assertEquals(["/core/img/filetypes/image.png"], $result->getData()); - } - - /** - * @throws JSONPathException - */ - public function testUnionWithKeys(): void - { - $result = new JSONPath( - [ - "key" => "value", - "another" => "entry", - ] - )->find("$['key','another']"); - - self::assertEquals(["value", "entry"], $result->getData()); - } - - /** - * @throws JSONPathException - */ - public function testCyrillicText(): void - { - $jsonPath = (new JSONPath(["Ρ‚Ρ€ΠΎΠ»ΠΈΠ»ΠΎ" => 1])); - - $result = $jsonPath - ->find("$['Ρ‚Ρ€ΠΎΠ»ΠΈΠ»ΠΎ']"); - - self::assertEquals([1], $result->getData()); - - $result = $jsonPath - ->find("$.Ρ‚Ρ€ΠΎΠ»ΠΈΠ»ΠΎ"); - - self::assertEquals([1], $result->getData()); - } - - public function testOffsetUnset(): void - { - $jsonIterator = new JSONPath( - [ - "route" => [ - ["name" => "A", "type" => "type of A"], - ["name" => "B", "type" => "type of B"], - ], - ] - ); - - /** @var JSONPath $route */ - $route = $jsonIterator->offsetGet('route'); - $route->offsetUnset(0); - $first = $route->first(); - - self::assertEquals("B", $first['name']); - } - - public function testFirstKey(): void - { - // Array test for array - $firstKey = new JSONPath(['a' => 'A', 'b', 'B'])->firstKey(); - - self::assertEquals('a', $firstKey); - - // Array test for object - $firstKey = new JSONPath((object)['a' => 'A', 'b', 'B'])->firstKey(); - - self::assertEquals('a', $firstKey); - } - - public function testLastKey(): void - { - // Array test for array - $lastKey = new JSONPath(['a' => 'A', 'b' => 'B', 'c' => 'C'])->lastKey(); - - self::assertEquals('c', $lastKey); - - // Array test for object - $lastKey = new JSONPath((object)['a' => 'A', 'b' => 'B', 'c' => 'C'])->lastKey(); - - self::assertEquals('c', $lastKey); - } - - /** - * Test: ensure trailing comma is stripped during parsing - * - * @throws JSONPathException - */ - public function testTrailingComma(): void - { - $result = new JSONPath($this->getData('example')) - ->find("$..books[0,1,2,]"); - - self::assertCount(3, $result); - } - - /** - * Test: ensure negative indexes return -n from last index - * - * @throws JSONPathException - */ - public function testNegativeIndex(): void - { - $result = new JSONPath($this->getData('example')) - ->find('$..books[-2]'); - - self::assertEquals("Herman Melville", $result[0]['author']); - } - - public function testIteratorMetadataOnEmptyCollection(): void - { - $path = new JSONPath([]); - - self::assertNull($path->key()); - self::assertFalse($path->valid()); - self::assertSame(0, $path->count()); - } - - public function testArrayAccessMutators(): void - { - $path = new JSONPath([]); - $path[] = 'foo'; - $path['bar'] = 'baz'; - - self::assertSame('foo', $path[0]); - self::assertSame('baz', $path['bar']); - - unset($path['bar']); - self::assertFalse(isset($path['bar'])); - } - - public function testMagicGetReturnsNullWhenMissing(): void - { - $path = new JSONPath(['foo' => 'bar']); - /** @phpstan-ignore-next-line */ - self::assertNull($path->missing); - } - - public function testIteratorRewindAndKey(): void - { - $path = new JSONPath(['alpha' => 1, 'beta' => 2]); - - $path->rewind(); - self::assertSame('alpha', $path->key()); - $path->next(); - self::assertSame('beta', $path->key()); - } - - public function testFirstAndLastOnEmptyReturnNull(): void - { - $path = new JSONPath([]); - - self::assertNull($path->first()); - self::assertNull($path->last()); - self::assertNull($path->firstKey()); - self::assertNull($path->lastKey()); - } - - /** - * @throws JSONPathException - */ - public function testQueryAccessWithNumericalIndexes(): void - { - $result = new JSONPath($this->getData('numerical-indexes-object')) - ->find("$.result.list[?(@.o == \"11.51000\")]"); - - self::assertEquals("11.51000", $result[0]->o); - - $result = new JSONPath($this->getData('numerical-indexes-array')) - ->find("$.result.list[?(@[1] == \"11.51000\")]"); - - self::assertEquals("11.51000", $result[0][1]); - } -} diff --git a/tests/JSONPathTestClass.php b/tests/JSONPathTestClass.php deleted file mode 100644 index e3b83bc..0000000 --- a/tests/JSONPathTestClass.php +++ /dev/null @@ -1,27 +0,0 @@ - */ - protected array $attributes = [ - 'foo' => 'bar', - ]; - - /** - * @noinspection MagicMethodsValidityInspection - */ - public function __get(mixed $key): ?string - { - return $this->attributes[$key] ?? null; - } -} diff --git a/tests/JSONPathTokenTest.php b/tests/JSONPathTokenTest.php index 3de620e..e116c7a 100644 --- a/tests/JSONPathTokenTest.php +++ b/tests/JSONPathTokenTest.php @@ -16,29 +16,41 @@ use Flow\JSONPath\Filters\QueryResultFilter; use Flow\JSONPath\Filters\RecursiveFilter; use Flow\JSONPath\Filters\SliceFilter; -use Flow\JSONPath\JSONPathException; use Flow\JSONPath\JSONPathToken; use Flow\JSONPath\TokenType; +use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; +#[CoversClass(JSONPathToken::class)] class JSONPathTokenTest extends TestCase { - /** - * @throws JSONPathException - */ public function testBuildFilterReturnsExpectedTypes(): void { self::assertInstanceOf( IndexFilter::class, new JSONPathToken(TokenType::Index, null)->buildFilter(0) ); - self::assertInstanceOf(IndexesFilter::class, new JSONPathToken(TokenType::Indexes, [])->buildFilter(0)); - self::assertInstanceOf(QueryMatchFilter::class, new JSONPathToken(TokenType::QueryMatch, '')->buildFilter(0)); + + self::assertInstanceOf( + IndexesFilter::class, + new JSONPathToken(TokenType::Indexes, [])->buildFilter(0) + ); + + self::assertInstanceOf( + QueryMatchFilter::class, + new JSONPathToken(TokenType::QueryMatch, '')->buildFilter(0) + ); + self::assertInstanceOf( QueryResultFilter::class, new JSONPathToken(TokenType::QueryResult, '')->buildFilter(0) ); - self::assertInstanceOf(RecursiveFilter::class, new JSONPathToken(TokenType::Recursive, null)->buildFilter(0)); + + self::assertInstanceOf( + RecursiveFilter::class, + new JSONPathToken(TokenType::Recursive, null)->buildFilter(0) + ); + self::assertInstanceOf( SliceFilter::class, new JSONPathToken(TokenType::Slice, ['start' => 0, 'end' => 0, 'step' => 1])->buildFilter(0) diff --git a/tests/QueryMatchFilterTest.php b/tests/QueryMatchFilterTest.php new file mode 100644 index 0000000..b3447aa --- /dev/null +++ b/tests/QueryMatchFilterTest.php @@ -0,0 +1,261 @@ +}> + */ + public static function filterProvider(): iterable + { + yield 'shorthand truthy filters values' => [ + 'data' => [0, 1, '', 'value', false], + 'expression' => '$[?@]', + 'expected' => [1 => 1, 3 => 'value'], + ]; + + yield 'negation wrapped' => [ + 'data' => [['flag' => true], ['flag' => false]], + 'expression' => '$[?(!(@.flag==true))]', + 'expected' => [['flag' => false]], + ]; + + yield 'negation unwrapped' => [ + 'data' => [['flag' => true], ['flag' => false]], + 'expression' => '$[?(!@.flag==true)]', + 'expected' => [['flag' => false]], + ]; + + yield 'grouped logical expressions' => [ + 'data' => [ + ['active' => true, 'score' => 1], + ['active' => true, 'score' => 2], + ['active' => false, 'score' => 3], + ], + 'expression' => '$[?(@.active==true && (@.score>1))]', + 'expected' => [['active' => true, 'score' => 2]], + ]; + + yield 'path comparison current and root' => [ + 'data' => [ + 'threshold' => 5, + 'items' => [ + ['v' => 5, 'w' => 5], + ['v' => 4, 'w' => 5], + ], + ], + 'expression' => '$.items[?(@.v==@.w && @.v==$.threshold)]', + 'expected' => [['v' => 5, 'w' => 5]], + ]; + + yield 'missing key compared to path still evaluates' => [ + 'data' => [['foo' => 1], ['foo' => 1, 'bar' => 1]], + 'expression' => '$[?(@.bar==@.foo)]', + 'expected' => [['foo' => 1, 'bar' => 1]], + ]; + + yield 'dot separated key resolves through jsonpath' => [ + 'data' => [ + ['nested' => ['value' => 3]], + ['nested' => ['value' => 4]], + ], + 'expression' => '$[?(@.nested.value==3)]', + 'expected' => [['nested' => ['value' => 3]]], + ]; + + yield 'deep equal lists and objects' => [ + 'data' => [ + ['left' => [1, 2], 'right' => [1, 2]], + ['left' => [1, 2], 'right' => [2, 1]], + ['left' => (object)['a' => 1, 'b' => 2], 'right' => (object)['b' => 2, 'a' => 1]], + ['left' => (object)['a' => 1], 'right' => (object)['a' => 2]], + ], + 'expression' => '$[?(@.left==@.right)]', + 'expected' => [ + ['left' => [1, 2], 'right' => [1, 2]], + ['left' => (object)['a' => 1, 'b' => 2], 'right' => (object)['b' => 2, 'a' => 1]], + ], + ]; + + yield 'plain node selection compares current node' => [ + 'data' => [0, 1, 2], + 'expression' => '$[?(@==@)]', + 'expected' => [0, 1, 2], + ]; + + yield 'deep equal failure branches' => [ + 'data' => [ + ['left' => [1, 2], 'right' => ['a' => 1, 'b' => 2]], + ['left' => [1], 'right' => [1, 2]], + ], + 'expression' => '$[?(@.left==@.right)]', + 'expected' => [], + ]; + + yield 'regex comparison' => [ + 'data' => ['foo', 'bar'], + 'expression' => '$[?(@ =~ /fo.*/)]', + 'expected' => ['foo'], + ]; + + yield 'in operator' => [ + 'data' => [1, 2, 3], + 'expression' => '$[?(@ in [1,3])]', + 'expected' => [1, 3], + ]; + + yield 'nin operator with short circuit or' => [ + 'data' => [ + ['a' => 1], + ['b' => 1], + ['a' => 3], + ], + 'expression' => '$[?(@.a nin [2,3] || @.b==1)]', + 'expected' => [ + ['a' => 1], + ['b' => 1], + ], + ]; + + yield 'existence check without operator' => [ + 'data' => [ + ['value' => 1], + ['other' => 2], + ], + 'expression' => '$[?(@.value)]', + 'expected' => [ + ['value' => 1], + ], + ]; + + yield '!in operator' => [ + 'data' => [1, 2, 3], + 'expression' => '$[?(@ !in [2])]', + 'expected' => [1, 3], + ]; + + yield 'less than comparison' => [ + 'data' => [['n' => 1], ['n' => 3]], + 'expression' => '$[?(@.n<2)]', + 'expected' => [['n' => 1]], + ]; + + yield 'less or equal comparison' => [ + 'data' => [['n' => 1], ['n' => 2], ['n' => 3]], + 'expression' => '$[?(@.n<=2)]', + 'expected' => [['n' => 1], ['n' => 2]], + ]; + + yield 'greater or equal comparison' => [ + 'data' => [['n' => 1], ['n' => 2], ['n' => 3]], + 'expression' => '$[?(@.n>=2)]', + 'expected' => [['n' => 2], ['n' => 3]], + ]; + + yield 'not equals comparison' => [ + 'data' => [['value' => 1], ['value' => 2]], + 'expression' => '$[?(@.value!=2)]', + 'expected' => [['value' => 1]], + ]; + } + + /** + * @param array $expected + * @throws JSONPathException + */ + #[DataProvider('filterProvider')] + public function testFilterScenarios(mixed $data, string $expression, array $expected): void + { + $result = new JSONPath($data)->find($expression)->getData(); + + self::assertEquals(\array_values($expected), \array_values($result)); + } + + /** + * @return iterable + */ + public static function constantExpressionProvider(): iterable + { + yield 'num comparison true' => ['expression' => '[?(1<2)]', 'expectMatch' => true]; + yield 'num comparison false' => ['expression' => '[?(2>3)]', 'expectMatch' => false]; + yield 'num with leading zeros decoded as number' => ['expression' => '[?(0123==123)]', 'expectMatch' => true]; + yield 'string literal decoding' => ['expression' => '[?(foo==foo)]', 'expectMatch' => true]; + yield 'invalid less than comparison for non-scalars' => ['expression' => '[?([]<1)]', 'expectMatch' => false]; + yield 'not equals' => ['expression' => '[?(2!=3)]', 'expectMatch' => true]; + yield 'less or equal' => ['expression' => '[?(2<=2)]', 'expectMatch' => true]; + yield 'greater or equal' => ['expression' => '[?(1>=2)]', 'expectMatch' => false]; + yield 'json literal deep equal' => ['expression' => '[?({"a":1}=={"a":1})]', 'expectMatch' => true]; + } + + /** + * @throws JSONPathException + */ + #[DataProvider('constantExpressionProvider')] + public function testConstantExpressions(string $expression, bool $expectMatch): void + { + $data = ['keep']; + $result = new JSONPath($data)->find('$' . $expression)->getData(); + + self::assertSame($expectMatch ? ['keep'] : [], $result); + } + + /** + * @throws JSONPathException + */ + public function testShorthandTokenValueArrayFiltersTruthyNodes(): void + { + $token = new JSONPathToken(TokenType::QueryMatch, ['expression' => '@', 'shorthand' => true]); + $filter = new QueryMatchFilter($token); + + $collection = [0, 1, '', 'value', false]; + + self::assertSame([1 => 1, 3 => 'value'], $filter->filter($collection)); + } + + /** + * @throws JSONPathException + */ + public function testMalformedFilterThrowsRuntimeException(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Malformed filter query'); + + new JSONPath([1])->find('$[?(foo)]'); + } + + /** + * @throws JSONPathException + */ + public function testNormalizeKeyCastsNumericStrings(): void + { + $token = new JSONPathToken(TokenType::QueryMatch, '@["2"]=="two"'); + $filter = new QueryMatchFilter($token); + + $result = $filter->filter([ + ['2' => 'two', '1' => 'one'], + ['2' => 'nope', '1' => 'one'], + ]); + + self::assertSame([['2' => 'two', '1' => 'one']], \array_values($result)); + } +} diff --git a/tests/QueryTest.php b/tests/QueryTest.php deleted file mode 100644 index f8b7bdc..0000000 --- a/tests/QueryTest.php +++ /dev/null @@ -1,1617 +0,0 @@ - */ - public static array $baselineFailedQueries; - - public static function setUpBeforeClass(): void - { - parent::setUpBeforeClass(); - self::$baselineFailedQueries = \array_map('trim', \file(__DIR__ . '/data/baselineFailedQueries.txt')); - } - - /** - * This method aims to test the current implementation against - * all queries listed on https://cburgmer.github.io/json-path-comparison/ - * - * Every test performed is allowed to fail and whenever an assertion fails, - * a message will be printed to STDERR, so we know, what's going on. - * - * @see https://cburgmer.github.io/json-path-comparison - * - * @noinspection JsonEncodingApiUsageInspection - */ - #[DataProvider('queryDataProvider')] - public function testQueries( - string $id, - string $selector, - string $data, - string $consensus, - bool $skip = false - ): void { - $results = null; - $query = \ucwords(\str_replace('_', ' ', $id)); - if (\str_starts_with($id, 'rfc_')) { - $url = 'https://www.rfc-editor.org/rfc/rfc9535'; - } else { - $url = \sprintf('https://cburgmer.github.io/json-path-comparison/results/%s', $id); - } - - // Avoid "This test did not perform any assertions" - // but do not use markTestSkipped, to prevent unnecessary - // console outputs - self::assertNotSame('', $id); - - if (empty($consensus) || $skip) { - /*$skipReason = empty($consensus) ? 'unknown consensus' : 'skip flag set'; - - \fwrite(STDERR, "==========================\n"); - \fwrite(STDERR, "Query: {$query}\nSKIPPED ({$skipReason})\nMore information: {$url}\n"); - \fwrite(STDERR, "==========================\n\n");*/ - - return; - } - - try { - $results = \json_encode(new JSONPath(\json_decode($data, true))->find($selector)); - - self::assertEquals($consensus, $results); - - if (\in_array($id, self::$baselineFailedQueries, true)) { - throw new ExpectationFailedException( - "XFAIL test {$id} unexpectedly passed, update baselineFailedQueries.txt" - ); - } - } catch (ExpectationFailedException $e) { - try { - // In some cases, the consensus is just disordered, while - // the actual result is correct. Let's perform a canonical - // assert in these cases. There might be still some false positives - // (e.g. multidimensional comparisons), but that's okay, I guess. Maybe, - // we can also find a way around that in the future. - $message = "==========================\n"; - $message .= "Query: {$query}\n\nMore information: {$url}\n"; - $message .= "==========================\n\n"; - self::assertEqualsCanonicalizing( - \json_decode($consensus, true), - \json_decode($results, true), - $message - ); - } catch (ExpectationFailedException) { - if (!\in_array($id, self::$baselineFailedQueries, true)) { - throw new ExpectationFailedException( - $e->getMessage() . "\nQuery: {$query}\n\nMore information: {$url}", - $e->getComparisonFailure() - ); - } - } - } catch (JSONPathException | RuntimeException $e) { - if (!\in_array($id, self::$baselineFailedQueries, true)) { - throw new RuntimeException( - $e->getMessage() . "\nQuery: {$query}\n\nMore information: {$url}", - ); - } - } - } - - /** - * Returns a list of queries, test data and expected results. - * - * A handful of queries may run forever, thus they should - * be skipped for now. - * - * Queries that are currently known as "problematic" are: - * - * - array_slice_with_negative_step_and_start_greater_than_end - * - array_slice_with_open_end_and_negative_step - * - array_slice_with_large_number_for_start - * - array_slice_with_large_number_for_end - * - array_slice_with_open_start_and_negative_step - * - array_slice_with_negative_step_only - * - * The list is generated automatically, based on the results - * at https://cburgmer.github.io/json-path-comparison. - * - * @return list - */ - public static function queryDataProvider(): array - { - return [ - [ // data set #0 - 'array_slice', - '$[1:3]', - '["first","second","third","forth","fifth"]', - '["second","third"]', - ], - [ // data set #1 - 'array_slice_on_exact_match', - '$[0:5]', - '["first","second","third","forth","fifth"]', - '["first","second","third","forth","fifth"]', - ], - [ // data set #2 - 'array_slice_on_non_overlapping_array', - '$[7:10]', - '["first","second","third"]', - '[]', - ], - [ // data set #3 - 'array_slice_on_object', - '$[1:3]', - '{":":42,"more":"string","a":1,"b":2,"c":3,"1:3":"nice"}', - '[]', - ], - [ // data set #4 - 'array_slice_on_partially_overlapping_array', - '$[1:10]', - '["first","second","third"]', - '["second","third"]', - ], - [ // data set #5 - 'array_slice_with_large_number_for_end', - '$[2:113667776004]', - '["first","second","third","forth","fifth"]', - '["third","forth","fifth"]', - true, // skip - ], - [ // data set #6 - unknown consensus, fallback to Proposal A - 'array_slice_with_large_number_for_end_and_negative_step', - '$[2:-113667776004:-1]', - '["first","second","third","forth","fifth"]', - '["third","second","first"]', - ], - [ // data set #7 - 'array_slice_with_large_number_for_start', - '$[-113667776004:2]', - '["first","second","third","forth","fifth"]', - '["first","second"]', - true, // skip - ], - [ // data set #8 - unknown consensus, fallback to Proposal A - 'array_slice_with_large_number_for_start_end_negative_step', - '$[113667776004:2:-1]', - '["first","second","third","forth","fifth"]', - '["fifth","forth"]', - ], - [ // data set #9 - 'array_slice_with_negative_start_and_end_and_range_of_-1', - '$[-4:-5]', - '[2,"a",4,5,100,"nice"]', - '[]', - ], - [ // data set #10 - 'array_slice_with_negative_start_and_end_and_range_of_0', - '$[-4:-4]', - '[2,"a",4,5,100,"nice"]', - '[]', - ], - [ // data set #11 - 'array_slice_with_negative_start_and_end_and_range_of_1', - '$[-4:-3]', - '[2,"a",4,5,100,"nice"]', - '[4]', - ], - [ // data set #12 - 'array_slice_with_negative_start_and_positive_end_and_range_of_-1', - '$[-4:1]', - '[2,"a",4,5,100,"nice"]', - '[]', - ], - [ // data set #13 - 'array_slice_with_negative_start_and_positive_end_and_range_of_0', - '$[-4:2]', - '[2,"a",4,5,100,"nice"]', - '[]', - ], - [ // data set #14 - 'array_slice_with_negative_start_and_positive_end_and_range_of_1', - '$[-4:3]', - '[2,"a",4,5,100,"nice"]', - '[4]', - ], - [ // data set #15 - unknown consensus, fallback to Proposal A - 'array_slice_with_negative_step', - '$[3:0:-2]', - '["first","second","third","forth","fifth"]', - '["forth","second"]', - ], - [ // data set #16 - unknown consensus, fallback to Proposal A - 'array_slice_with_negative_step_and_start_greater_than_end', - '$[0:3:-2]', - '["first","second","third","forth","fifth"]', - '[]', - true, // skip - ], - [ // data set #17 - unknown consensus, fallback to Proposal A - 'array_slice_with_negative_step_on_partially_overlapping_array', - '$[7:3:-1]', - '["first","second","third","forth","fifth"]', - '["fifth"]', - ], - [ // data set #18 - unknown consensus, fallback to Proposal A - 'array_slice_with_negative_step_only', - '$[::-2]', - '["first","second","third","forth","fifth"]', - '["fifth","third","first"]', - true, // skip - ], - [ // data set #19 - 'array_slice_with_open_end', - '$[1:]', - '["first","second","third","forth","fifth"]', - '["second","third","forth","fifth"]', - ], - [ // data set #20 - unknown consensus, fallback to Proposal A - 'array_slice_with_open_end_and_negative_step', - '$[3::-1]', - '["first","second","third","forth","fifth"]', - '["forth","third","second","first"]', - true, // skip - ], - [ // data set #21 - 'array_slice_with_open_start', - '$[:2]', - '["first","second","third","forth","fifth"]', - '["first","second"]', - ], - [ // data set #22 - 'array_slice_with_open_start_and_end', - '$[:]', - '["first","second"]', - '["first","second"]', - ], - [ // data set #23 - 'array_slice_with_open_start_and_end_and_step_empty', - '$[::]', - '["first","second"]', - '["first","second"]', - ], - [ // data set #24 - unknown consensus, fallback to Proposal A - 'array_slice_with_open_start_and_end_on_object', - '$[:]', - '{":":42,"more":"string"}', - '[]', - ], - [ // data set #25 - unknown consensus, fallback to Proposal A - 'array_slice_with_open_start_and_negative_step', - '$[:2:-1]', - '["first","second","third","forth","fifth"]', - '["fifth","forth"]', - true, // skip - ], - [ // data set #26 - 'array_slice_with_positive_start_and_negative_end_and_range_of_-1', - '$[3:-4]', - '[2,"a",4,5,100,"nice"]', - '[]', - ], - [ // data set #27 - 'array_slice_with_positive_start_and_negative_end_and_range_of_0', - '$[3:-3]', - '[2,"a",4,5,100,"nice"]', - '[]', - ], - [ // data set #28 - 'array_slice_with_positive_start_and_negative_end_and_range_of_1', - '$[3:-2]', - '[2,"a",4,5,100,"nice"]', - '[5]', - ], - [ // data set #29 - 'array_slice_with_range_of_-1', - '$[2:1]', - '["first","second","third","forth"]', - '[]', - ], - [ // data set #30 - 'array_slice_with_range_of_0', - '$[0:0]', - '["first","second"]', - '[]', - ], - [ // data set #31 - 'array_slice_with_range_of_1', - '$[0:1]', - '["first","second"]', - '["first"]', - ], - [ // data set #32 - 'array_slice_with_start_-1_and_open_end', - '$[-1:]', - '["first","second","third"]', - '["third"]', - ], - [ // data set #33 - 'array_slice_with_start_-2_and_open_end', - '$[-2:]', - '["first","second","third"]', - '["second","third"]', - ], - [ // data set #34 - 'array_slice_with_start_large_negative_number_and_open_end_on_short_array', - '$[-4:]', - '["first","second","third"]', - '["first","second","third"]', - ], - [ // data set #35 - 'array_slice_with_step', - '$[0:3:2]', - '["first","second","third","forth","fifth"]', - '["first","third"]', - ], - [ // data set #36 - unknown consensus - 'array_slice_with_step_0', - '$[0:3:0]', - '["first","second","third","forth","fifth"]', - '', - ], - [ // data set #37 - 'array_slice_with_step_1', - '$[0:3:1]', - '["first","second","third","forth","fifth"]', - '["first","second","third"]', - ], - [ // data set #38 - 'array_slice_with_step_and_leading_zeros', - '$[010:024:010]', - '[0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25]', - '[10,20]', - ], - [ // data set #39 - 'array_slice_with_step_but_end_not_aligned', - '$[0:4:2]', - '["first","second","third","forth","fifth"]', - '["first","third"]', - ], - [ // data set #40 - 'array_slice_with_step_empty', - '$[1:3:]', - '["first","second","third","forth","fifth"]', - '["second","third"]', - ], - [ // data set #41 - 'array_slice_with_step_only', - '$[::2]', - '["first","second","third","forth","fifth"]', - '["first","third","fifth"]', - ], - [ // data set #42 - 'bracket_notation', - '$[\'key\']', - '{"key":"value"}', - '["value"]', - ], - [ // data set #43 - 'bracket_notation_after_recursive_descent', - '$..[0]', - '["first",{"key":["first nested",{"more":[{"nested":["deepest","second"]},["more","values"]]}]}]', - '["deepest","first nested","first","more",{"nested":["deepest","second"]}]', - ], - [ // data set #44 - 'bracket_notation_on_object_without_key', - '$[\'missing\']', - '{"key":"value"}', - '[]', - ], - [ // data set #45 - 'bracket_notation_with_NFC_path_on_NFD_key', - '$[\'ΓΌ\']', - '{"u\\u0308":42}', - '[]', - ], - [ // data set #46 - 'bracket_notation_with_dot', - '$[\'two.some\']', - '{"one":{"key":"value"},"two":{"some":"more","key":"other value"},"two.some":"42"}', - '["42"]', - ], - [ // data set #47 - 'bracket_notation_with_double_quotes', - '$["key"]', - '{"key":"value"}', - '["value"]', - ], - [ // data set #48 - unknown consensus - 'bracket_notation_with_empty_path', - '$[]', - '{"":42,"\'\'":123,"\\"\\"":222}', - '', - ], - [ // data set #49 - 'bracket_notation_with_empty_string', - '$[\'\']', - '{"":42,"\'\'":123,"\\"\\"":222}', - '[42]', - ], - [ // data set #50 - 'bracket_notation_with_empty_string_doubled_quoted', - '$[""]', - '{"":42,"\'\'":123,"\\"\\"":222}', - '[42]', - ], - [ // data set #51 - 'bracket_notation_with_negative_number_on_short_array', - '$[-2]', - '["one element"]', - '[]', - ], - [ // data set #52 - 'bracket_notation_with_number', - '$[2]', - '["first","second","third","forth","fifth"]', - '["third"]', - ], - [ // data set #53 - 'bracket_notation_with_number_-1', - '$[-1]', - '["first","second","third"]', - '["third"]', - ], - [ // data set #54 - 'bracket_notation_with_number_-1_on_empty_array', - '$[-1]', - '[]', - '[]', - ], - [ // data set #55 - 'bracket_notation_with_number_0', - '$[0]', - '["first","second","third","forth","fifth"]', - '["first"]', - ], - [ // data set #56 - 'bracket_notation_with_number_after_dot_notation_with_wildcard_on_nested_arrays_with_different_length', - '$.*[1]', - '[[1],[2,3]]', - '[3]', - ], - [ // data set #57 - unknown consensus, fallback to Proposal A - 'bracket_notation_with_number_on_object', - '$[0]', - '{"0":"value"}', - '[]', - ], - [ // data set #58 - 'bracket_notation_with_number_on_short_array', - '$[1]', - '["one element"]', - '[]', - ], - [ // data set #59 - unknown consensus, fallback to Proposal A - 'bracket_notation_with_number_on_string', - '$[0]', - '"Hello World"', - '[]', - ], - [ // data set #60 - 'bracket_notation_with_quoted_array_slice_literal', - '$[\':\']', - '{":":"value","another":"entry"}', - '["value"]', - ], - [ // data set #61 - 'bracket_notation_with_quoted_closing_bracket_literal', - '$[\']\']', - '{"]":42}', - '[42]', - ], - [ // data set #62 - 'bracket_notation_with_quoted_current_object_literal', - '$[\'@\']', - '{"@":"value","another":"entry"}', - '["value"]', - ], - [ // data set #63 - 'bracket_notation_with_quoted_dot_literal', - '$[\'.\']', - '{".":"value","another":"entry"}', - '["value"]', - ], - [ // data set #64 - 'bracket_notation_with_quoted_dot_wildcard', - '$[\'.*\']', - '{"key":42,".*":1,"":10}', - '[1]', - ], - [ // data set #65 - 'bracket_notation_with_quoted_double_quote_literal', - '$[\'"\']', - '{"\\"":"value","another":"entry"}', - '["value"]', - ], - [ // data set #66 - unknown consensus, fallback to Proposal A - 'bracket_notation_with_quoted_escaped_backslash', - '$[\'\\\\\']', - '{"\\\\":"value"}', - '["value"]', - ], - [ // data set #67 - unknown consensus, fallback to Proposal A - 'bracket_notation_with_quoted_escaped_single_quote', - '$[\'\\\'\']', - '{"\'":"value"}', - '["value"]', - ], - [ // data set #68 - 'bracket_notation_with_quoted_number_on_object', - '$[\'0\']', - '{"0":"value"}', - '["value"]', - ], - [ // data set #69 - 'bracket_notation_with_quoted_root_literal', - '$[\'$\']', - '{"$":"value","another":"entry"}', - '["value"]', - ], - [ // data set #70 - unknown consensus, fallback to Proposal A - 'bracket_notation_with_quoted_special_characters_combined', - '$[\':@."$,*\\\'\\\\\']', - '{":@.\\"$,*\'\\\\":42}', - '[42]', - ], - [ // data set #71 - unknown consensus - 'bracket_notation_with_quoted_string_and_unescaped_single_quote', - '$[\'single\'quote\']', - '{"single\'quote":"value"}', - '', - ], - [ // data set #72 - 'bracket_notation_with_quoted_union_literal', - '$[\',\']', - '{",":"value","another":"entry"}', - '["value"]', - ], - [ // data set #73 - 'bracket_notation_with_quoted_wildcard_literal', - '$[\'*\']', - '{"*":"value","another":"entry"}', - '["value"]', - ], - [ // data set #74 - unknown consensus, fallback to Proposal A - 'bracket_notation_with_quoted_wildcard_literal_on_object_without_key', - '$[\'*\']', - '{"another":"entry"}', - '[]', - ], - [ // data set #75 - 'bracket_notation_with_string_including_dot_wildcard', - '$[\'ni.*\']', - '{"nice":42,"ni.*":1,"mice":100}', - '[1]', - ], - [ // data set #76 - unknown consensus - 'bracket_notation_with_two_literals_separated_by_dot', - '$[\'two\'.\'some\']', - '{"one":{"key":"value"},"two":{"some":"more","key":"other value"},"two.some":"42","two\'.\'some":"43' - . '"}', - '', - ], - [ // data set #77 - unknown consensus - 'bracket_notation_with_two_literals_separated_by_dot_without_quotes', - '$[two.some]', - '{"one":{"key":"value"},"two":{"some":"more","key":"other value"},"two.some":"42"}', - '', - ], - [ // data set #78 - 'bracket_notation_with_wildcard_after_array_slice', - '$[0:2][*]', - '[[1,2],["a","b"],[0,0]]', - '[1,2,"a","b"]', - ], - [ // data set #79 - 'bracket_notation_with_wildcard_after_dot_notation_after_bracket_notation_with_wildcard', - '$[*].bar[*]', - '[{"bar":[42]}]', - '[42]', - ], - [ // data set #80 - 'bracket_notation_with_wildcard_after_recursive_descent', - '$..[*]', - '{"key":"value","another key":{"complex":"string","primitives":[0,1]}}', - '["string","value",0,1,[0,1],{"complex":"string","primitives":[0,1]}]', - ], - [ // data set #81 - 'bracket_notation_with_wildcard_on_array', - '$[*]', - '["string",42,{"key":"value"},[0,1]]', - '["string",42,{"key":"value"},[0,1]]', - ], - [ // data set #82 - 'bracket_notation_with_wildcard_on_empty_array', - '$[*]', - '[]', - '[]', - ], - [ // data set #83 - 'bracket_notation_with_wildcard_on_empty_object', - '$[*]', - '{}', - '[]', - ], - [ // data set #84 - 'bracket_notation_with_wildcard_on_null_value_array', - '$[*]', - '[40,null,42]', - '[40,null,42]', - ], - [ // data set #85 - 'bracket_notation_with_wildcard_on_object', - '$[*]', - '{"some":"string","int":42,"object":{"key":"value"},"array":[0,1]}', - '["string",42,[0,1],{"key":"value"}]', - ], - [ // data set #86 - unknown consensus - 'bracket_notation_without_quotes', - '$[key]', - '{"key":"value"}', - '', - ], - [ // data set #87 - unknown consensus - 'dot_bracket_notation', - '$.[\'key\']', - '{"key":"value","other":{"key":[{"key":42}]}}', - '', - ], - [ // data set #88 - unknown consensus - 'dot_bracket_notation_with_double_quotes', - '$.["key"]', - '{"key":"value","other":{"key":[{"key":42}]}}', - '', - ], - [ // data set #89 - unknown consensus - 'dot_bracket_notation_without_quotes', - '$.[key]', - '{"key":"value","other":{"key":[{"key":42}]}}', - '', - ], - [ // data set #90 - 'dot_notation', - '$.key', - '{"key":"value"}', - '["value"]', - ], - [ // data set #91 - 'dot_notation_after_array_slice', - '$[0:2].key', - '[{"key":"ey"},{"key":"bee"},{"key":"see"}]', - '["ey","bee"]', - ], - [ // data set #92 - 'dot_notation_after_bracket_notation_after_recursive_descent', - '$..[1].key', - '{"k":[{"key":"some value"},{"key":42}],"kk":[[{"key":100},{"key":200},{"key":300}],[{"key":400},{"k' - . 'ey":500},{"key":600}]],"key":[0,1]}', - '[200,42,500]', - ], - [ // data set #93 - 'dot_notation_after_bracket_notation_with_wildcard', - '$[*].a', - '[{"a":1},{"a":1}]', - '[1,1]', - ], - [ // data set #94 - 'dot_notation_after_bracket_notation_with_wildcard_on_one_matching', - '$[*].a', - '[{"a":1}]', - '[1]', - ], - [ // data set #95 - 'dot_notation_after_bracket_notation_with_wildcard_on_some_matching', - '$[*].a', - '[{"a":1},{"b":1}]', - '[1]', - ], - [ // data set #96 - 'dot_notation_after_filter_expression', - '$[?(@.id==42)].name', - '[{"id":42,"name":"forty-two"},{"id":1,"name":"one"}]', - '["forty-two"]', - ], - [ // data set #97 - 'dot_notation_after_recursive_descent', - '$..key', - '{"object":{"key":"value","array":[{"key":"something"},{"key":{"key":"russian dolls"}}]},"key":"top"' - . '}', - '["russian dolls","something","top","value",{"key":"russian dolls"}]', - ], - [ // data set #98 - 'dot_notation_after_recursive_descent_after_dot_notation', - '$.store..price', - '{"store":{"book":[{"category":"reference","author":"Nigel Rees","title":"Sayings of the Century","p' - . 'rice":8.95},{"category":"fiction","author":"Evelyn Waugh","title":"Sword of Honour","price":12.99},' - . '{"category":"fiction","author":"Herman Melville","title":"Moby Dick","isbn":"0-553-21311-3","price"' - . ':8.99},{"category":"fiction","author":"J. R. R. Tolkien","title":"The Lord of the Rings","isbn":"0-' - . '395-19395-8","price":22.99}],"bicycle":{"color":"red","price":19.95}}}', - '[12.99,19.95,22.99,8.95,8.99]', - ], - [ // data set #99 - 'dot_notation_after_union', - '$[0,2].key', - '[{"key":"ey"},{"key":"bee"},{"key":"see"}]', - '["ey","see"]', - ], - [ // data set #100 - 'dot_notation_after_union_with_keys', - '$[\'one\',\'three\'].key', - '{"one":{"key":"value"},"two":{"k":"v"},"three":{"some":"more","key":"other value"}}', - '["value","other value"]', - ], - [ // data set #101 - 'dot_notation_on_array', - '$.key', - '[0,1]', - '[]', - ], - [ // data set #102 - 'dot_notation_on_array_value', - '$.key', - '{"key":["first","second"]}', - '[["first","second"]]', - ], - [ // data set #103 - 'dot_notation_on_array_with_containing_object_matching_key', - '$.id', - '[{"id":2}]', - '[]', - ], - [ // data set #104 - 'dot_notation_on_empty_object_value', - '$.key', - '{"key":{}}', - '[{}]', - ], - [ // data set #105 - 'dot_notation_on_null_value', - '$.key', - '{"key":null}', - '[null]', - ], - [ // data set #106 - 'dot_notation_on_object_without_key', - '$.missing', - '{"key":"value"}', - '[]', - ], - [ // data set #107 - 'dot_notation_with_dash', - '$.key-dash', - '{"key-dash":"value"}', - '["value"]', - ], - [ // data set #108 - unknown consensus - 'dot_notation_with_double_quotes', - '$."key"', - '{"key":"value","\\"key\\"":42}', - '', - ], - [ // data set #109 - unknown consensus - 'dot_notation_with_double_quotes_after_recursive_descent', - '$.."key"', - '{"object":{"key":"value","\\"key\\"":100,"array":[{"key":"something","\\"key\\"":0},{"key":{"key":"' - . 'russian dolls"},"\\"key\\"":{"\\"key\\"":99}}]},"key":"top","\\"key\\"":42}', - '', - ], - [ // data set #110 - unknown consensus - 'dot_notation_with_empty_path', - '$.', - '{"key":42,"":9001,"\'\'":"nice"}', - '', - ], - [ // data set #111 - 'dot_notation_with_key_named_in', - '$.in', - '{"in":"value"}', - '["value"]', - ], - [ // data set #112 - 'dot_notation_with_key_named_length', - '$.length', - '{"length":"value"}', - '["value"]', - ], - [ // data set #113 - 'dot_notation_with_key_named_length_on_array', - '$.length', - '[4,5,6]', - '[3]', - ], - [ // data set #114 - 'dot_notation_with_key_named_null', - '$.null', - '{"null":"value"}', - '["value"]', - ], - [ // data set #115 - 'dot_notation_with_key_named_true', - '$.true', - '{"true":"value"}', - '["value"]', - ], - [ // data set #116 - unknown consensus, fallback to Proposal A - 'dot_notation_with_key_root_literal', - '$.$', - '{"$":"value"}', - '["value"]', - ], - [ // data set #117 - 'dot_notation_with_non_ASCII_key', - '$.屬性', - '{"\\u5c6c\\u6027":"value"}', - '["value"]', - ], - [ // data set #118 - unknown consensus, fallback to Proposal A - 'dot_notation_with_number', - '$.2', - '["first","second","third","forth","fifth"]', - '[]', - ], - [ // data set #119 - unknown consensus, fallback to Proposal A - 'dot_notation_with_number_-1', - '$.-1', - '["first","second","third","forth","fifth"]', - '[]', - ], - [ // data set #120 - 'dot_notation_with_number_on_object', - '$.2', - '{"a":"first","2":"second","b":"third"}', - '["second"]', - ], - [ // data set #121 - unknown consensus - 'dot_notation_with_single_quotes', - '$.\'key\'', - '{"key":"value","\'key\'":42}', - '', - ], - [ // data set #122 - unknown consensus - 'dot_notation_with_single_quotes_after_recursive_descent', - '$..\'key\'', - '{"object":{"key":"value","\'key\'":100,"array":[{"key":"something","\'key\'":0},{"key":{"key":"russ' - . 'ian dolls"},"\'key\'":{"\'key\'":99}}]},"key":"top","\'key\'":42}', - '', - ], - [ // data set #123 - unknown consensus - 'dot_notation_with_single_quotes_and_dot', - '$.\'some.key\'', - '{"some.key":42,"some":{"key":"value"},"\'some.key\'":43}', - '', - ], - [ // data set #124 - 'dot_notation_with_wildcard_after_dot_notation_after_dot_notation_with_wildcard', - '$.*.bar.*', - '[{"bar":[42]}]', - '[42]', - ], - [ // data set #125 - 'dot_notation_with_wildcard_after_dot_notation_with_wildcard_on_nested_arrays', - '$.*.*', - '[[1,2,3],[4,5,6]]', - '[1,2,3,4,5,6]', - ], - [ // data set #126 - 'dot_notation_with_wildcard_after_recursive_descent', - '$..*', - '{"key":"value","another key":{"complex":"string","primitives":[0,1]}}', - '["string","value",0,1,[0,1],{"complex":"string","primitives":[0,1]}]', - ], - [ // data set #127 - 'dot_notation_with_wildcard_after_recursive_descent_on_null_value_array', - '$..*', - '[40,null,42]', - '[40,42,null]', - ], - [ // data set #128 - 'dot_notation_with_wildcard_after_recursive_descent_on_scalar', - '$..*', - '42', - '[]', - ], - [ // data set #129 - 'dot_notation_with_wildcard_on_array', - '$.*', - '["string",42,{"key":"value"},[0,1]]', - '["string",42,{"key":"value"},[0,1]]', - ], - [ // data set #130 - 'dot_notation_with_wildcard_on_empty_array', - '$.*', - '[]', - '[]', - ], - [ // data set #131 - 'dot_notation_with_wildcard_on_empty_object', - '$.*', - '{}', - '[]', - ], - [ // data set #132 - 'dot_notation_with_wildcard_on_object', - '$.*', - '{"some":"string","int":42,"object":{"key":"value"},"array":[0,1]}', - '["string",42,[0,1],{"key":"value"}]', - ], - [ // data set #133 - unknown consensus - 'dot_notation_without_root', - 'key', - '{"key":"value"}', - '', - ], - [ // data set #134 - unknown consensus, fallback to Proposal A - 'filter_expression_after_dot_notation_with_wildcard_after_recursive_descent', - '$..*[?(@.id>2)]', - '[{"complext":{"one":[{"name":"first","id":1},{"name":"next","id":2},{"name":"another","id":3},{"nam' - . 'e":"more","id":4}],"more":{"name":"next to last","id":5}}},{"name":"last","id":6}]', - '[{"id":3,"name":"another"},{"id":4,"name":"more"},{"id":5,"name":"next to last"}]', - ], - [ // data set #135 - unknown consensus, fallback to Proposal A - 'filter_expression_after_recursive_descent', - '$..[?(@.id==2)]', - '{"id":2,"more":[{"id":2},{"more":{"id":2}},{"id":{"id":2}},[{"id":2}]]}', - '[{"id":2},{"id":2},{"id":2},{"id":2}]', - ], - [ // data set #136 - unknown consensus, fallback to Proposal A - 'filter_expression_on_object', - '$[?(@.key)]', - '{"key":42,"another":{"key":1}}', - '[{"key":1}]', - ], - [ // data set #137 - unknown consensus - 'filter_expression_with_addition', - '$[?(@.key+50==100)]', - '[{"key":60},{"key":50},{"key":10},{"key":-50},{"key+50":100}]', - '', - ], - [ // data set #138 - unknown consensus, fallback to Proposal A - 'filter_expression_with_boolean_and_operator', - '$[?(@.key>42 && @.key<44)]', - '[{"key":42},{"key":43},{"key":44}]', - '[{"key":43}]', - ], - [ // data set #139 - unknown consensus - 'filter_expression_with_boolean_and_operator_and_value_false', - '$[?(@.key>0 && false)]', - '[{"key":1},{"key":3},{"key":"nice"},{"key":true},{"key":null},{"key":false},{"key":{}},{"key":[]},{' - . '"key":-1},{"key":0},{"key":""}]', - '', - ], - [ // data set #140 - unknown consensus - 'filter_expression_with_boolean_and_operator_and_value_true', - '$[?(@.key>0 && true)]', - '[{"key":1},{"key":3},{"key":"nice"},{"key":true},{"key":null},{"key":false},{"key":{}},{"key":[]},{' - . '"key":-1},{"key":0},{"key":""}]', - '', - ], - [ // data set #141 - unknown consensus, fallback to Proposal A - 'filter_expression_with_boolean_or_operator', - '$[?(@.key>43 || @.key<43)]', - '[{"key":42},{"key":43},{"key":44}]', - '[{"key":42},{"key":44}]', - ], - [ // data set #142 - unknown consensus - 'filter_expression_with_boolean_or_operator_and_value_false', - '$[?(@.key>0 || false)]', - '[{"key":1},{"key":3},{"key":"nice"},{"key":true},{"key":null},{"key":false},{"key":{}},{"key":[]},{' - . '"key":-1},{"key":0},{"key":""}]', - '', - ], - [ // data set #143 - unknown consensus - 'filter_expression_with_boolean_or_operator_and_value_true', - '$[?(@.key>0 || true)]', - '[{"key":1},{"key":3},{"key":"nice"},{"key":true},{"key":null},{"key":false},{"key":{}},{"key":[]},{' - . '"key":-1},{"key":0},{"key":""}]', - '', - ], - [ // data set #144 - 'filter_expression_with_bracket_notation', - '$[?(@[\'key\']==42)]', - '[{"key":0},{"key":42},{"key":-1},{"key":41},{"key":43},{"key":42.0001},{"key":41.9999},{"key":100},' - . '{"some":"value"}]', - '[{"key":42}]', - ], - [ // data set #145 - 'filter_expression_with_bracket_notation_and_current_object_literal', - '$[?(@[\'@key\']==42)]', - '[{"@key":0},{"@key":42},{"key":42},{"@key":43},{"some":"value"}]', - '[{"@key":42}]', - ], - [ // data set #146 - unknown consensus, fallback to Proposal A - 'filter_expression_with_bracket_notation_with_-1', - '$[?(@[-1]==2)]', - '[[2,3],["a"],[0,2],[2]]', - '[[0,2],[2]]', - ], - [ // data set #147 - 'filter_expression_with_bracket_notation_with_number', - '$[?(@[1]==\'b\')]', - '[["a","b"],["x","y"]]', - '[["a","b"]]', - ], - [ // data set #148 - unknown consensus, fallback to Proposal A - 'filter_expression_with_bracket_notation_with_number_on_object', - '$[?(@[1]==\'b\')]', - '{"1":["a","b"],"2":["x","y"]}', - '[["a","b"]]', - ], - [ // data set #149 - unknown consensus, fallback to Proposal A - 'filter_expression_with_current_object', - '$[?(@)]', - '["some value",null,"value",0,1,-1,"",[],{},false,true]', - '["some value",null,"value",0,1,-1,"",[],{},false,true]', - ], - [ // data set #150 - unknown consensus, fallback to Proposal A - 'filter_expression_with_different_grouped_operators', - '$[?(@.a && (@.b || @.c))]', - '[{"a":true},{"a":true,"b":true},{"a":true,"b":true,"c":true},{"b":true,"c":true},{"a":true,"c":true' - . '},{"c":true},{"b":true}]', - '[{"a":true,"b":true},{"a":true,"b":true,"c":true},{"a":true,"c":true}]', - ], - [ // data set #151 - unknown consensus - 'filter_expression_with_different_ungrouped_operators', - '$[?(@.a && @.b || @.c)]', - '[{"a":true,"b":true},{"a":true,"b":true,"c":true},{"b":true,"c":true},{"a":true,"c":true},{"a":true' - . '},{"b":true},{"c":true},{"d":true},{}]', - '', - ], - [ // data set #152 - unknown consensus - 'filter_expression_with_division', - '$[?(@.key/10==5)]', - '[{"key":60},{"key":50},{"key":10},{"key":-50},{"key\\/10":5}]', - '', - ], - [ // data set #153 - unknown consensus - 'filter_expression_with_empty_expression', - '$[?()]', - '[1,{"key":42},"value",null]', - '', - ], - [ // data set #154 - unknown consensus, fallback to Proposal A - 'filter_expression_with_equals', - '$[?(@.key==42)]', - '[{"key":0},{"key":42},{"key":-1},{"key":1},{"key":41},{"key":43},{"key":42.0001},{"key":41.9999},{"' - . 'key":100},{"key":"some"},{"key":"42"},{"key":null},{"key":420},{"key":""},{"key":{}},{"key":[]},{"k' - . 'ey":[42]},{"key":{"key":42}},{"key":{"some":42}},{"some":"value"}]', - '[{"key":42}]', - ], - [ // data set #155 - unknown consensus - 'filter_expression_with_equals_array', - '$[?(@.d==["v1","v2"])]', - '[{"d":["v1","v2"]},{"d":["a","b"]},{"d":"v1"},{"d":"v2"},{"d":{}},{"d":[]},{"d":null},{"d":-1},{"d"' - . ':0},{"d":1},{"d":"[\'v1\',\'v2\']"},{"d":"[\'v1\', \'v2\']"},{"d":"v1,v2"},{"d":"[\\"v1\\", \\"v2\\' - . '"]"},{"d":"[\\"v1\\",\\"v2\\"]"}]', - '', - ], - [ // data set #156 - unknown consensus - 'filter_expression_with_equals_array_for_array_slice_with_range_1', - '$[?(@[0:1]==[1])]', - '[[1,2,3],[1],[2,3],1,2]', - '', - ], - [ // data set #157 - unknown consensus - 'filter_expression_with_equals_array_for_dot_notation_with_star', - '$[?(@.*==[1,2])]', - '[[1,2],[2,3],[1],[2],[1,2,3],1,2,3]', - '', - ], - [ // data set #158 - unknown consensus - 'filter_expression_with_equals_array_with_single_quotes', - '$[?(@.d==[\'v1\',\'v2\'])]', - '[{"d":["v1","v2"]},{"d":["a","b"]},{"d":"v1"},{"d":"v2"},{"d":{}},{"d":[]},{"d":null},{"d":-1},{"d"' - . ':0},{"d":1},{"d":"[\'v1\',\'v2\']"},{"d":"[\'v1\', \'v2\']"},{"d":"v1,v2"},{"d":"[\\"v1\\", \\"v2\\' - . '"]"},{"d":"[\\"v1\\",\\"v2\\"]"}]', - '', - ], - [ // data set #159 - unknown consensus - 'filter_expression_with_equals_boolean_expression_value', - '$[?((@.key<44)==false)]', - '[{"key":42},{"key":43},{"key":44}]', - '', - ], - [ // data set #160 - unknown consensus, fallback to Proposal A - 'filter_expression_with_equals_false', - '$[?(@.key==false)]', - '[{"some":"some value"},{"key":true},{"key":false},{"key":null},{"key":"value"},{"key":""},{"key":0}' - . ',{"key":1},{"key":-1},{"key":42},{"key":{}},{"key":[]}]', - '[{"key":false}]', - ], - [ // data set #161 - unknown consensus, fallback to Proposal A - 'filter_expression_with_equals_null', - '$[?(@.key==null)]', - '[{"some":"some value"},{"key":true},{"key":false},{"key":null},{"key":"value"},{"key":""},{"key":0}' - . ',{"key":1},{"key":-1},{"key":42},{"key":{}},{"key":[]}]', - '[{"key":null}]', - ], - [ // data set #162 - unknown consensus - 'filter_expression_with_equals_number_for_array_slice_with_range_1', - '$[?(@[0:1]==1)]', - '[[1,2,3],[1],[2,3],1,2]', - '', - ], - [ // data set #163 - unknown consensus - 'filter_expression_with_equals_number_for_bracket_notation_with_star', - '$[?(@[*]==2)]', - '[[1,2],[2,3],[1],[2],[1,2,3],1,2,3]', - '', - ], - [ // data set #164 - unknown consensus - 'filter_expression_with_equals_number_for_dot_notation_with_star', - '$[?(@.*==2)]', - '[[1,2],[2,3],[1],[2],[1,2,3],1,2,3]', - '', - ], - [ // data set #165 - unknown consensus, fallback to Proposal A - 'filter_expression_with_equals_number_with_fraction', - '$[?(@.key==-0.123e2)]', - '[{"key":-12.3},{"key":-0.123},{"key":-12},{"key":12.3},{"key":2},{"key":"-0.123e2"}]', - '[{"key":-12.3}]', - ], - [ // data set #166 - unknown consensus - 'filter_expression_with_equals_number_with_leading_zeros', - '$[?(@.key==010)]', - '[{"key":"010"},{"key":"10"},{"key":10},{"key":0},{"key":8}]', - '', - ], - [ // data set #167 - unknown consensus - 'filter_expression_with_equals_object', - '$[?(@.d=={"k":"v"})]', - '[{"d":{"k":"v"}},{"d":{"a":"b"}},{"d":"k"},{"d":"v"},{"d":{}},{"d":[]},{"d":null},{"d":-1},{"d":0},' - . '{"d":1},{"d":"[object Object]"},{"d":"{\\"k\\": \\"v\\"}"},{"d":"{\\"k\\":\\"v\\"}"},"v"]', - '', - ], - [ // data set #168 - unknown consensus, fallback to Proposal A - 'filter_expression_with_equals_on_array_of_numbers', - '$[?(@==42)]', - '[0,42,-1,41,43,42.0001,41.9999,null,100]', - '[42]', - ], - [ // data set #169 - 'filter_expression_with_equals_on_array_without_match', - '$[?(@.key==43)]', - '[{"key":42}]', - '[]', - ], - [ // data set #170 - unknown consensus, fallback to Proposal A - 'filter_expression_with_equals_on_object', - '$[?(@.key==42)]', - '{"a":{"key":0},"b":{"key":42},"c":{"key":-1},"d":{"key":41},"e":{"key":43},"f":{"key":42.0001},"g":' - . '{"key":41.9999},"h":{"key":100},"i":{"some":"value"}}', - '[{"key":42}]', - ], - [ // data set #171 - unknown consensus, fallback to Proposal A - 'filter_expression_with_equals_on_object_with_key_matching_query', - '$[?(@.id==2)]', - '{"id":2}', - '[]', - ], - [ // data set #172 - unknown consensus, fallback to Proposal A - 'filter_expression_with_equals_string', - '$[?(@.key=="value")]', - '[{"key":"some"},{"key":"value"},{"key":null},{"key":0},{"key":1},{"key":-1},{"key":""},{"key":{}},{' - . '"key":[]},{"key":"valuemore"},{"key":"morevalue"},{"key":["value"]},{"key":{"some":"value"}},{"key"' - . ':{"key":"value"}},{"some":"value"}]', - '[{"key":"value"}]', - ], - [ // data set #173 - 'filter_expression_with_equals_string_with_current_object_literal', - '$[?(@.key=="hi@example.com")]', - '[{"key":"some"},{"key":"value"},{"key":"hi@example.com"}]', - '[{"key":"hi@example.com"}]', - ], - [ // data set #174 - 'filter_expression_with_equals_string_with_dot_literal', - '$[?(@.key=="some.value")]', - '[{"key":"some"},{"key":"value"},{"key":"some.value"}]', - '[{"key":"some.value"}]', - ], - [ // data set #175 - 'filter_expression_with_equals_string_with_single_quotes', - '$[?(@.key==\'value\')]', - '[{"key":"some"},{"key":"value"}]', - '[{"key":"value"}]', - ], - [ // data set #176 - unknown consensus, fallback to Proposal A - 'filter_expression_with_equals_true', - '$[?(@.key==true)]', - '[{"some":"some value"},{"key":true},{"key":false},{"key":null},{"key":"value"},{"key":""},{"key":0}' - . ',{"key":1},{"key":-1},{"key":42},{"key":{}},{"key":[]}]', - '[{"key":true}]', - ], - [ // data set #177 - unknown consensus, fallback to Proposal A - 'filter_expression_with_equals_with_root_reference', - '$.items[?(@.key==$.value)]', - '{"value":42,"items":[{"key":10},{"key":42},{"key":50}]}', - '[{"key":42}]', - ], - [ // data set #178 - unknown consensus, fallback to Proposal A - 'filter_expression_with_greater_than', - '$[?(@.key>42)]', - '[{"key":0},{"key":42},{"key":-1},{"key":41},{"key":43},{"key":42.0001},{"key":41.9999},{"key":100},' - . '{"key":"43"},{"key":"42"},{"key":"41"},{"key":"value"},{"some":"value"}]', - '[{"key":43},{"key":42.0001},{"key":100}]', - ], - [ // data set #179 - unknown consensus, fallback to Proposal A - 'filter_expression_with_greater_than_or_equal', - '$[?(@.key>=42)]', - '[{"key":0},{"key":42},{"key":-1},{"key":41},{"key":43},{"key":42.0001},{"key":41.9999},{"key":100},' - . '{"key":"43"},{"key":"42"},{"key":"41"},{"key":"value"},{"some":"value"}]', - '[{"key":42},{"key":43},{"key":42.0001},{"key":100}]', - ], - [ // data set #180 - unknown consensus - 'filter_expression_with_in_array_of_values', - '$[?(@.d in [2, 3])]', - '[{"d":1},{"d":2},{"d":1},{"d":3},{"d":4}]', - '[{"d":2},{"d":3}]', - ], - [ // data set #181 - unknown consensus - 'filter_expression_with_in_current_object', - '$[?(2 in @.d)]', - '[{"d":[1,2,3]},{"d":[2]},{"d":[1]},{"d":[3,4]},{"d":[4,2]}]', - '', - ], - [ // data set #182 - unknown consensus, fallback to Proposal A - 'filter_expression_with_less_than', - '$[?(@.key<42)]', - '[{"key":0},{"key":42},{"key":-1},{"key":41},{"key":43},{"key":42.0001},{"key":41.9999},{"key":100},' - . '{"key":"43"},{"key":"42"},{"key":"41"},{"key":"value"},{"some":"value"}]', - '[{"key":0},{"key":-1},{"key":41},{"key":41.9999}]', - ], - [ // data set #183 - unknown consensus, fallback to Proposal A - 'filter_expression_with_less_than_or_equal', - '$[?(@.key<=42)]', - '[{"key":0},{"key":42},{"key":-1},{"key":41},{"key":43},{"key":42.0001},{"key":41.9999},{"key":100},' - . '{"key":"43"},{"key":"42"},{"key":"41"},{"key":"value"},{"some":"value"}]', - '[{"key":0},{"key":42},{"key":-1},{"key":41},{"key":41.9999}]', - ], - [ // data set #184 - unknown consensus - 'filter_expression_with_multiplication', - '$[?(@.key*2==100)]', - '[{"key":60},{"key":50},{"key":10},{"key":-50},{"key*2":100}]', - '', - ], - [ // data set #185 - unknown consensus, fallback to Proposal A - 'filter_expression_with_negation_and_equals', - '$[?(!(@.key==42))]', - '[{"key":0},{"key":42},{"key":-1},{"key":41},{"key":43},{"key":42.0001},{"key":41.9999},{"key":100},' - . '{"key":"43"},{"key":"42"},{"key":"41"},{"key":"value"},{"some":"value"}]', - '[{"key":0},{"key":-1},{"key":41},{"key":43},{"key":42.0001},{"key":41.9999},{"key":100},{"key":"43"' - . '},{"key":"42"},{"key":"41"},{"key":"value"},{"some":"value"}]', - ], - [ // data set #186 - unknown consensus, fallback to Proposal A - 'filter_expression_with_negation_and_less_than', - '$[?(!(@.key<42))]', - '[{"key":0},{"key":42},{"key":-1},{"key":41},{"key":43},{"key":42.0001},{"key":41.9999},{"key":100},' - . '{"key":"43"},{"key":"42"},{"key":"41"},{"key":"value"},{"some":"value"}]', - '[{"key":42},{"key":43},{"key":42.0001},{"key":100},{"key":"43"},{"key":"42"},{"key":"41"},{"key":"v' - . 'alue"},{"some":"value"}]', - ], - [ // data set #187 - unknown consensus, fallback to Proposal A - 'filter_expression_with_not_equals', - '$[?(@.key!=42)]', - '[{"key":0},{"key":42},{"key":-1},{"key":1},{"key":41},{"key":43},{"key":42.0001},{"key":41.9999},{"' - . 'key":100},{"key":"some"},{"key":"42"},{"key":null},{"key":420},{"key":""},{"key":{}},{"key":[]},{"k' - . 'ey":[42]},{"key":{"key":42}},{"key":{"some":42}},{"some":"value"}]', - '[{"key":0},{"key":-1},{"key":1},{"key":41},{"key":43},{"key":42.0001},{"key":41.9999},{"key":100},{' - . '"key":"some"},{"key":"42"},{"key":null},{"key":420},{"key":""},{"key":{}},{"key":[]},{"key":[42]},{' - . '"key":{"key":42}},{"key":{"some":42}}]', - ], - [ // data set #188 - unknown consensus - 'filter_expression_with_regular_expression', - '$[?(@.name=~/hello.*/)]', - '[{"name":"hullo world"},{"name":"hello world"},{"name":"yes hello world"},{"name":"HELLO WORLD"},{"' - . 'name":"good bye"}]', - '', - ], - [ // data set #189 - unknown consensus - 'filter_expression_with_set_wise_comparison_to_scalar', - '$[?(@[*]>=4)]', - '[[1,2],[3,4],[5,6]]', - '', - ], - [ // data set #190 - unknown consensus - 'filter_expression_with_set_wise_comparison_to_set', - '$.x[?(@[*]>=$.y[*])]', - '{"x":[[1,2],[3,4],[5,6]],"y":[3,4,5]}', - '', - ], - [ // data set #191 - unknown consensus - 'filter_expression_with_single_equal', - '$[?(@.key=42)]', - '[{"key":0},{"key":42},{"key":-1},{"key":1},{"key":41},{"key":43},{"key":42.0001},{"key":41.9999},{"' - . 'key":100},{"key":"some"},{"key":"42"},{"key":null},{"key":420},{"key":""},{"key":{}},{"key":[]},{"k' - . 'ey":[42]},{"key":{"key":42}},{"key":{"some":42}},{"some":"value"}]', - '', - ], - [ // data set #192 - unknown consensus - 'filter_expression_with_subfilter', - '$[?(@.a[?(@.price>10)])]', - '[{"a":[{"price":1},{"price":3}]},{"a":[{"price":11}]},{"a":[{"price":8},{"price":12},{"price":3}]},' - . '{"a":[]}]', - '', - ], - [ // data set #193 - 'filter_expression_with_subpaths', - '$[?(@.address.city==\'Berlin\')]', - '[{"address":{"city":"Berlin"}},{"address":{"city":"London"}}]', - '[{"address":{"city":"Berlin"}}]', - ], - [ // data set #194 - unknown consensus, fallback to Proposal A - 'filter_expression_with_subtraction', - '$[?(@.key-50==-100)]', - '[{"key":60},{"key":50},{"key":10},{"key":-50},{"key-50":-100}]', - '[{"key-50":-100}]', - ], - [ // data set #195 - unknown consensus, fallback to Proposal A - 'filter_expression_with_tautological_comparison', - '$[?(1==1)]', - '[1,3,"nice",true,null,false,{},[],-1,0,""]', - '[1,3,"nice",true,null,false,{},[],-1,0,""]', - ], - [ // data set #196 - unknown consensus - 'filter_expression_with_triple_equal', - '$[?(@.key===42)]', - '[{"key":0},{"key":42},{"key":-1},{"key":1},{"key":41},{"key":43},{"key":42.0001},{"key":41.9999},{"' - . 'key":100},{"key":"some"},{"key":"42"},{"key":null},{"key":420},{"key":""},{"key":{}},{"key":[]},{"k' - . 'ey":[42]},{"key":{"key":42}},{"key":{"some":42}},{"some":"value"}]', - '', - ], - [ // data set #197 - unknown consensus, fallback to Proposal A - 'filter_expression_with_value', - '$[?(@.key)]', - '[{"some":"some value"},{"key":true},{"key":false},{"key":null},{"key":"value"},{"key":""},{"key":0}' - . ',{"key":1},{"key":-1},{"key":42},{"key":{}},{"key":[]}]', - '[{"key":true},{"key":false},{"key":null},{"key":"value"},{"key":""},{"key":0},{"key":1},{"key":-1},' - . '{"key":42},{"key":{}},{"key":[]}]', - ], - [ // data set #198 - unknown consensus, fallback to Proposal A - 'filter_expression_with_value_after_dot_notation_with_wildcard_on_array_of_objects', - '$.*[?(@.key)]', - '[{"some":"some value"},{"key":"value"}]', - '[]', - ], - [ // data set #199 - unknown consensus, fallback to Proposal A - 'filter_expression_with_value_after_recursive_descent', - '$..[?(@.id)]', - '{"id":2,"more":[{"id":2},{"more":{"id":2}},{"id":{"id":2}},[{"id":2}]]}', - '[{"id":2},{"id":2},{"id":2},{"id":2},{"id":{"id":2}}]', - ], - [ // data set #200 - unknown consensus - 'filter_expression_with_value_false', - '$[?(false)]', - '[1,3,"nice",true,null,false,{},[],-1,0,""]', - '', - ], - [ // data set #201 - unknown consensus - 'filter_expression_with_value_from_recursive_descent', - '$[?(@..child)]', - '[{"key":[{"child":1},{"child":2}]},{"key":[{"child":2}]},{"key":[{}]},{"key":[{"something":42}]},{}' - . ']', - '', - ], - [ // data set #202 - unknown consensus - 'filter_expression_with_value_null', - '$[?(null)]', - '[1,3,"nice",true,null,false,{},[],-1,0,""]', - '', - ], - [ // data set #203 - unknown consensus - 'filter_expression_with_value_true', - '$[?(true)]', - '[1,3,"nice",true,null,false,{},[],-1,0,""]', - '', - ], - [ // data set #204 - unknown consensus - 'filter_expression_without_parens', - '$[?@.key==42]', - '[{"key":0},{"key":42},{"key":-1},{"key":1},{"key":41},{"key":43},{"key":42.0001},{"key":41.9999},{"' - . 'key":100},{"key":"some"},{"key":"42"},{"key":null},{"key":420},{"key":""},{"key":{}},{"key":[]},{"k' - . 'ey":[42]},{"key":{"key":42}},{"key":{"some":42}},{"some":"value"}]', - '', - ], - [ // data set #205 - unknown consensus, fallback to Proposal A - 'filter_expression_without_value', - '$[?(!@.key)]', - '[{"some":"some value"},{"key":true},{"key":false},{"key":null},{"key":"value"},{"key":""},{"key":0}' - . ',{"key":1},{"key":-1},{"key":42},{"key":{}},{"key":[]}]', - '[{"some":"some value"}]', - ], - [ // data set #206 - unknown consensus - 'parens_notation', - '$(key,more)', - '{"key":1,"some":2,"more":3}', - '', - ], - [ // data set #207 - unknown consensus - 'recursive_descent', - '$..', - '[{"a":{"b":"c"}},[0,1]]', - '', - ], - [ // data set #208 - unknown consensus - 'recursive_descent_after_dot_notation', - '$.key..', - '{"some key":"value","key":{"complex":"string","primitives":[0,1]}}', - '', - ], - [ // data set #209 - 'root', - '$', - '{"key":"value","another key":{"complex":["a",1]}}', - '[{"another key":{"complex":["a",1]},"key":"value"}]', - ], - [ // data set #210 - 'root_on_scalar', - '$', - '42', - '[42]', - ], - [ // data set #211 - 'root_on_scalar_false', - '$', - 'false', - '[false]', - ], - [ // data set #212 - 'root_on_scalar_true', - '$', - 'true', - '[true]', - ], - [ // data set #213 - unknown consensus - 'script_expression', - '$[(@.length-1)]', - '["first","second","third","forth","fifth"]', - '["fifth"]', - ], - [ // data set #214 - 'union', - '$[0,1]', - '["first","second","third"]', - '["first","second"]', - ], - [ // data set #215 - unknown consensus, fallback to Proposal A - 'union_with_filter', - '$[?(@.key<3),?(@.key>6)]', - '[{"key":1},{"key":8},{"key":3},{"key":10},{"key":7},{"key":2},{"key":6},{"key":4}]', - '[{"key":1},{"key":2},{"key":8},{"key":10},{"key":7}]', - ], - [ // data set #216 - 'union_with_keys', - '$[\'key\',\'another\']', - '{"key":"value","another":"entry"}', - '["value","entry"]', - ], - [ // data set #217 - 'union_with_keys_after_array_slice', - '$[:][\'c\',\'d\']', - '[{"c":"cc1","d":"dd1","e":"ee1"},{"c":"cc2","d":"dd2","e":"ee2"}]', - '["cc1","dd1","cc2","dd2"]', - ], - [ // data set #218 - 'union_with_keys_after_bracket_notation', - '$[0][\'c\',\'d\']', - '[{"c":"cc1","d":"dd1","e":"ee1"},{"c":"cc2","d":"dd2","e":"ee2"}]', - '["cc1","dd1"]', - ], - [ // data set #219 - unknown consensus, fallback to Proposal A - 'union_with_keys_after_dot_notation_with_wildcard', - '$.*[\'c\',\'d\']', - '[{"c":"cc1","d":"dd1","e":"ee1"},{"c":"cc2","d":"dd2","e":"ee2"}]', - '["cc1","dd1","cc2","dd2"]', - ], - [ // data set #220 - unknown consensus, fallback to Proposal A - 'union_with_keys_after_recursive_descent', - '$..[\'c\',\'d\']', - '[{"c":"cc1","d":"dd1","e":"ee1"},{"c":"cc2","child":{"d":"dd2"}},{"c":"cc3"},{"d":"dd4"},{"child":{' - . '"c":"cc5"}}]', - '["cc1","cc2","cc3","cc5","dd1","dd2","dd4"]', - ], - [ // data set #221 - 'union_with_keys_on_object_without_key', - '$[\'missing\',\'key\']', - '{"key":"value","another":"entry"}', - '["value"]', - ], - [ // data set #222 - 'union_with_numbers_in_decreasing_order', - '$[4,1]', - '[1,2,3,4,5]', - '[5,2]', - ], - [ // data set #223 - unknown consensus, fallback to Proposal A - 'union_with_repeated_matches_after_dot_notation_with_wildcard', - '$.*[0,:5]', - '{"a":["string",null,true],"b":[false,"string",5.4]}', - '["string","string",null,true,false,false,"string",5.4]', - ], - [ // data set #224 - unknown consensus, fallback to Proposal A - 'union_with_slice_and_number', - '$[1:3,4]', - '[1,2,3,4,5]', - '[2,3,5]', - ], - [ // data set #225 - 'union_with_spaces', - '$[ 0 , 1 ]', - '["first","second","third"]', - '["first","second"]', - ], - [ // data set #226 - unknown consensus, fallback to Proposal A - 'union_with_wildcard_and_number', - '$[*,1]', - '["first","second","third","forth","fifth"]', - '["first","second","third","forth","fifth","second"]', - ], - [ - 'rfc_semantics_of_null_object_value', - '$.a', - '{"a": null, "b": [null], "c": [{}], "null": 1}', - '[null]', - ], - [ - 'rfc_semantics_of_null_null_used_as_array', - '$.a[0]', - '{"a": null, "b": [null], "c": [{}], "null": 1}', - 'XFAIL', - ], - [ - 'rfc_semantics_of_null_null_used_as_object', - '$.a.d', - '{"a": null, "b": [null], "c": [{}], "null": 1}', - 'XFAIL', - ], - [ - 'rfc_semantics_of_null_array_value', - '$.b[0]', - '{"a": null, "b": [null], "c": [{}], "null": 1}', - '[null]', - ], - [ - 'rfc_semantics_of_null_array_value_2', - '$.b[*]', - '{"a": null, "b": [null], "c": [{}], "null": 1}', - '[null]', - ], - [ - 'rfc_semantics_of_null_existence', - '$.b[?@]', - '{"a": null, "b": [null], "c": [{}], "null": 1}', - '[null]', - ], - [ - 'rfc_semantics_of_null_comparison', - '$.b[?@==null]', - '{"a": null, "b": [null], "c": [{}], "null": 1}', - '[null]', - ], - [ - 'rfc_semantics_of_null_comparison_with_missing_value', - '$.c[?@.d==null]', - '{"a": null, "b": [null], "c": [{}], "null": 1}', - 'XFAIL', - ], - [ - 'rfc_semantics_of_null_null_string', - '$.null', - '{"a": null, "b": [null], "c": [{}], "null": 1}', - '[1]', - ], - ]; - } -} diff --git a/tests/RecursiveFilterTest.php b/tests/RecursiveFilterTest.php new file mode 100644 index 0000000..15c3d0f --- /dev/null +++ b/tests/RecursiveFilterTest.php @@ -0,0 +1,45 @@ + ['value' => 3]]; + $data = ['obj' => $nestedObject, 'scalar' => 1]; + + $result = $filter->filter($data); + + self::assertSame($nestedObject, $result[0]['obj']); + self::assertSame( + [ + ['inner' => ['value' => 3]], + ['value' => 3], + ], + \array_slice($result, 1) + ); + } +} diff --git a/tests/SliceFilterTest.php b/tests/SliceFilterTest.php index c840e2a..11a3058 100644 --- a/tests/SliceFilterTest.php +++ b/tests/SliceFilterTest.php @@ -35,6 +35,84 @@ public function testFilterHandlesNegativeAndNullBounds(array $slice, array|objec self::assertSame($expected, $filter->filter($input)); } + /** + * @return iterable, array, array}> + */ + public static function edgeCaseProvider(): iterable + { + yield 'step zero returns empty' => [ + ['start' => 0, 'end' => null, 'step' => 0], + ['a', 'b'], + [], + ]; + + yield 'start beyond length yields empty' => [ + ['start' => 5, 'end' => null, 'step' => 1], + ['a', 'b'], + [], + ]; + + yield 'end beyond length clamps to length' => [ + ['start' => 0, 'end' => 10, 'step' => 1], + ['a', 'b'], + ['a', 'b'], + ]; + + yield 'negative step with null bounds reverses' => [ + ['start' => null, 'end' => null, 'step' => -1], + ['a', 'b', 'c'], + ['c', 'b', 'a'], + ]; + + yield 'positive step with end below zero yields empty' => [ + ['start' => 0, 'end' => -10, 'step' => 1], + ['a', 'b', 'c'], + [], + ]; + + yield 'negative step with start far below length clamps to -1' => [ + ['start' => -5, 'end' => null, 'step' => -1], + ['a', 'b', 'c'], + [], + ]; + + yield 'negative step with start beyond length clamps to last index' => [ + ['start' => 10, 'end' => null, 'step' => -1], + ['a', 'b', 'c'], + ['c', 'b', 'a'], + ]; + + yield 'negative step with end beyond length clamps end' => [ + ['start' => 1, 'end' => 10, 'step' => -1], + ['a', 'b', 'c'], + [], + ]; + yield 'negative step with very negative start clamps to -1 and high end clamps to length' => [ + ['start' => -5, 'end' => 10, 'step' => -1], + ['a', 'b', 'c'], + [], + ]; + yield 'negative step with end far below zero still collects prefix' => [ + ['start' => 1, 'end' => -10, 'step' => -1], + ['a', 'b', 'c'], + ['b', 'a'], + ]; + } + + /** + * @param array $slice + * @param array $input + * @param array $expected + */ + #[DataProvider('edgeCaseProvider')] + public function testEdgeCases(array $slice, array $input, array $expected): void + { + $token = new JSONPathToken(TokenType::Slice, $slice); + $filter = new SliceFilter($token); + + self::assertSame($expected, $filter->filter($input)); + } + /** * @return array, array|object, array}> */ @@ -56,10 +134,10 @@ public static function sliceProvider(): array ['a', 'b', 'c', 'd'], ['a', 'c'], ], - 'no results when step negative' => [ + 'negative step slices in reverse order' => [ ['start' => 2, 'end' => 0, 'step' => -1], ['a', 'b', 'c'], - [], + ['c', 'b'], ], 'works with array object' => [ ['start' => 0, 'end' => 2, 'step' => 1], diff --git a/tests/Traits/TestDataTrait.php b/tests/Traits/TestDataTrait.php deleted file mode 100644 index e632b38..0000000 --- a/tests/Traits/TestDataTrait.php +++ /dev/null @@ -1,39 +0,0 @@ -getMessage()}"); - } - - return $json; - } -} diff --git a/tests/data/baselineFailedQueries.txt b/tests/data/baselineFailedQueries.txt deleted file mode 100644 index 1e15b6d..0000000 --- a/tests/data/baselineFailedQueries.txt +++ /dev/null @@ -1,31 +0,0 @@ -bracket_notation_with_quoted_closing_bracket_literal -dot_notation_with_key_root_literal -filter_expression_with_tautological_comparison -union_with_wildcard_and_number -array_slice_with_large_number_for_end_and_negative_step -array_slice_with_large_number_for_start_end_negative_step -array_slice_with_negative_step -array_slice_with_negative_step_on_partially_overlapping_array -bracket_notation_with_negative_number_on_short_array -bracket_notation_with_number_on_object -bracket_notation_with_quoted_escaped_backslash -bracket_notation_with_quoted_escaped_single_quote -bracket_notation_with_quoted_special_characters_combined -bracket_notation_with_quoted_wildcard_literal_on_object_without_key -bracket_notation_with_wildcard_after_recursive_descent -dot_notation_with_number -dot_notation_with_number_-1 -dot_notation_with_wildcard_after_recursive_descent -filter_expression_with_bracket_notation_with_-1 -filter_expression_with_equals_with_root_reference -# XFAIL -rfc_semantics_of_null_null_used_as_array -# XFAIL -rfc_semantics_of_null_null_used_as_object -rfc_semantics_of_null_existence -rfc_semantics_of_null_comparison -# XFAIL -rfc_semantics_of_null_comparison_with_missing_value -union_with_filter -union_with_repeated_matches_after_dot_notation_with_wildcard -union_with_slice_and_number \ No newline at end of file diff --git a/tests/data/conferences.json b/tests/data/conferences.json deleted file mode 100644 index 03c2eb1..0000000 --- a/tests/data/conferences.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "name": "Major League Baseball", - "abbr": "MLB", - "conferences": [ - { - "name": "Western Conference", - "abbr": "West", - "teams": [ - { - "name": "Dodger", - "city": "Los Angeles", - "whatever": "else", - "players": [ - { - "name": "Bob Smith", - "number": 22 - }, - { - "name": "Joe Face", - "number": 23, - "active": "yes" - } - ] - } - ] - }, - { - "name": "Eastern Conference", - "abbr": "East", - "teams": [ - { - "name": "Mets", - "city": "New York", - "whatever": "else", - "players": [ - { - "name": "something", - "number": 14, - "active": "yes" - }, - { - "name": "something", - "number": 15 - } - ] - } - ] - } - ] -} diff --git a/tests/data/example.json b/tests/data/example.json deleted file mode 100644 index 6a1fff1..0000000 --- a/tests/data/example.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "store": { - "books": [ - { - "category": "reference", - "author": "Nigel Rees", - "title": "Sayings of the Century", - "price": 8.95 - }, - { - "category": "fiction", - "author": "Evelyn Waugh", - "title": "Sword of Honour", - "price": 12.99 - }, - { - "category": "fiction", - "author": "Herman Melville", - "title": "Moby Dick", - "isbn": "0-553-21311-3", - "price": 8.99 - }, - { - "category": "fiction", - "author": "J. R. R. Tolkien", - "title": "The Lord of the Rings", - "isbn": "0-395-19395-8", - "price": 22.99 - } - ], - "bicycle": { - "color": "red", - "price": 19.95 - } - }, - "expensive": 10 -} diff --git a/tests/data/extra.json b/tests/data/extra.json deleted file mode 100644 index ca13c43..0000000 --- a/tests/data/extra.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "http://www.w3.org/2000/01/rdf-schema#label": [ - { - "@language": "en" - }, - { - "@language": "de" - } - ] -} diff --git a/tests/data/indexed-object.json b/tests/data/indexed-object.json deleted file mode 100644 index 8f45482..0000000 --- a/tests/data/indexed-object.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "store": { - "books": { - "4": { - "category": "reference", - "author": "Nigel Rees", - "title": "Sayings of the Century", - "price": 8.95 - }, - "3": { - "category": "fiction", - "author": "Evelyn Waugh", - "title": "Sword of Honour", - "price": 12.99 - }, - "2": { - "category": "fiction", - "author": "Herman Melville", - "title": "Moby Dick", - "isbn": "0-553-21311-3", - "price": 8.99 - }, - "1": { - "category": "fiction", - "author": "J. R. R. Tolkien", - "title": "The Lord of the Rings", - "isbn": "0-395-19395-8", - "price": 22.99 - } - } - } -} diff --git a/tests/data/locations.json b/tests/data/locations.json deleted file mode 100644 index 0943d78..0000000 --- a/tests/data/locations.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "Gauteng", - "type": "province", - "child": { - "name": "Johannesburg", - "type": "city", - "child": { - "name": "Rosebank", - "type": "suburb" - } - } -} diff --git a/tests/data/numerical-indexes-array.json b/tests/data/numerical-indexes-array.json deleted file mode 100644 index 3c91ce3..0000000 --- a/tests/data/numerical-indexes-array.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "result": { - "list": [ - [ - 1477526400, - "11.51000" - ], - [ - 1477612800, - "11.49870" - ] - ] - } -} diff --git a/tests/data/numerical-indexes-object.json b/tests/data/numerical-indexes-object.json deleted file mode 100644 index 39d77f0..0000000 --- a/tests/data/numerical-indexes-object.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "result": { - "list": [ - { - "time": 1477526400, - "o": "11.51000" - }, - { - "time": 1477612800, - "o": "11.49870" - } - ] - } -} diff --git a/tests/data/simple-integers.json b/tests/data/simple-integers.json deleted file mode 100644 index fbb0d9f..0000000 --- a/tests/data/simple-integers.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "features": [ - { - "name": "foo", - "value": 1 - }, - { - "name": "bar", - "value": 2 - }, - { - "name": "baz", - "value": 1 - } - ] -} diff --git a/tests/data/with-dots.json b/tests/data/with-dots.json deleted file mode 100644 index 7ad89c3..0000000 --- a/tests/data/with-dots.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "data": { - "tokens": [ - { - "Employee.FirstName": "Jack" - }, - { - "Employee.LastName": "Daniels" - }, - { - "Employee.Email": "jd@example.com" - } - ] - } -} diff --git a/tests/data/with-slashes.json b/tests/data/with-slashes.json deleted file mode 100644 index b7039f7..0000000 --- a/tests/data/with-slashes.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "features": [ - ], - "mediatypes": { - "image/png": "/core/img/filetypes/image.png", - "image/jpeg": "/core/img/filetypes/image.png", - "image/gif": "/core/img/filetypes/image.png", - "application/postscript": "/core/img/filetypes/image-vector.png" - } -} From 23d04a306a462f467927853807c314b990e3c5ab Mon Sep 17 00:00:00 2001 From: Sascha Greuel Date: Thu, 15 Jan 2026 01:02:45 +0100 Subject: [PATCH 4/5] - Aligned the query runner and lexer with the JSONPath comparison suite: JSON documents are now decoded as objects to preserve `{}` vs `[]`, unsupported selectors no longer abort the runner, and dot-notation now accepts quoted keys with dots/spaces/leading `@`. - Hardened filter parsing: boolean-only filters (`?(true|false|null)`), literal short-circuiting (`&& false`, `|| true`), and empty filters now return the expected collections instead of throwing. - Slice filters gracefully skip non-countable objects. Signed-off-by: Sascha Greuel --- CHANGELOG.md | 5 ++ README.md | 4 +- composer.json | 2 +- src/Filters/QueryMatchFilter.php | 66 ++++++++++++++++++++++++ src/Filters/SliceFilter.php | 8 +++ src/JSONPathLexer.php | 88 +++++++++++++++++++------------- tests/JSONPathLexerTest.php | 21 ++++++++ tests/QueryMatchFilterTest.php | 39 ++++++++++++++ tests/SliceFilterTest.php | 10 +++- 9 files changed, 202 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9dcfb83..c28790b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +### 1.0.1 +- Aligned the query runner and lexer with the JSONPath comparison suite: JSON documents are now decoded as objects to preserve `{}` vs `[]`, unsupported selectors no longer abort the runner, and dot-notation now accepts quoted keys with dots/spaces/leading `@`. +- Hardened filter parsing: boolean-only filters (`?(true|false|null)`), literal short-circuiting (`&& false`, `|| true`), and empty filters now return the expected collections instead of throwing. +- Slice filters gracefully skip non-countable objects. + ### 1.0.0 - Rebuilt the test suite from scratch: removed bulky baseline fixtures and added compact unit/integration coverage for every filter (index, union, query, recursive, slice), lexer edge cases, and JSONPath core helpers. Runs reflection-free and deprecation-free. - Achieved and enforced 100% code coverage across AccessHelper, all filters, lexer, tokens, and JSONPath core while keeping phpstan and coding standards clean. diff --git a/README.md b/README.md index b288203..98e8ebd 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ This is a [JSONPath](http://goessner.net/articles/JsonPath/) implementation for - PHP 8.5+ only, with enums/readonly tokens and no `eval`. - Works with arrays, objects, and `ArrayAccess`/traversables in any combination. - Unions cover slices/queries/wildcards/multi-key strings (quoted or unquoted); negative indexes and escaped bracket notation are supported. -- Filters support path-to-path/root comparisons, regex, `in`/`nin`/`!in`, deep equality, and RFC-style null existence/value handling. +- Filters support path-to-path/root comparisons, regex, `in`/`nin`/`!in`, deep equality, RFC-style null existence/value handling, and literal-only short-circuiting (e.g., `?(true)`, `?(false)`, `&& false`, `|| true`). - Tokenized parsing with internal caching; lightweight manual runner to try bundled examples quickly. ## Installation @@ -65,7 +65,7 @@ Symbol | Description `*` | Wildcard. All child elements regardless their index. `[,]` | Array indices as a set `[start:end:step]` | Array slice operator borrowed from ES4/Python. -`?()` | Filters a result set by a comparison expression +`?()` | Filters a result set by a comparison expression (constant expressions like `?(true)`/`?(false)` are allowed; unsupported/empty filters evaluate to an empty result) `()` | Uses the result of a comparison expression as the index ## PHP Usage diff --git a/composer.json b/composer.json index 020bea2..9c1d5c8 100644 --- a/composer.json +++ b/composer.json @@ -2,7 +2,7 @@ "name": "softcreatr/jsonpath", "description": "JSONPath implementation for parsing, searching and flattening arrays", "license": "MIT", - "version": "1.0.0", + "version": "1.0.1", "authors": [ { "name": "Stephen Frank", diff --git a/src/Filters/QueryMatchFilter.php b/src/Filters/QueryMatchFilter.php index 9b4a798..9c5cf88 100644 --- a/src/Filters/QueryMatchFilter.php +++ b/src/Filters/QueryMatchFilter.php @@ -58,6 +58,18 @@ public function filter(array|object $collection): array $filterExpression = $negationMatches['logicalexpr']; } + $literalResult = $this->evaluateLiteralExpression($filterExpression, $collection); + + if ($literalResult !== null) { + return $literalResult; + } + + $shortCircuitResult = $this->evaluateExpressionWithTrailingLiteral($filterExpression, $collection); + + if ($shortCircuitResult !== null) { + return $shortCircuitResult; + } + $filterGroups = []; if ( @@ -315,6 +327,60 @@ private function evaluateConstantExpression(string $expression): ?bool }; } + /** + * @param array|object $collection + * @return array|null + * @throws JSONPathException + */ + private function evaluateLiteralExpression(string $expression, array|object $collection): ?array + { + $trimmed = \trim($expression); + + if ($trimmed === '') { + return []; + } + + $literalValue = $this->decodeLiteral($trimmed); + $literalIsBool = \is_bool($literalValue); + + if (!$literalIsBool && $literalValue !== null) { + return null; + } + + return $this->isTruthy($literalValue) ? AccessHelper::arrayValues($collection) : []; + } + + /** + * @param array|object $collection + * @return array|null + * @throws JSONPathException + */ + private function evaluateExpressionWithTrailingLiteral( + string $expression, + array|object $collection + ): ?array { + if ( + !\preg_match( + '/^(?.+?)\s*(?&&|\|\|)\s*(?true|false|null)\s*$/i', + $expression, + $matches + ) + ) { + return null; + } + + $leftFilter = '$[?(' . $matches['left'] . ')]'; + $leftResult = new JSONPath($collection)->find($leftFilter)->getData(); + $literalValue = $this->decodeLiteral($matches['literal']); + $literalIsTrue = $this->isTruthy($literalValue); + + return match ($matches['op']) { + '&&' => $literalIsTrue ? $leftResult : [], + '||' => $literalIsTrue ? AccessHelper::arrayValues($collection) : $leftResult, + default => [], + }; + } + private function decodeLiteral(string $literal): mixed { $literal = \trim($literal); diff --git a/src/Filters/SliceFilter.php b/src/Filters/SliceFilter.php index 3750bba..0bfeea4 100644 --- a/src/Filters/SliceFilter.php +++ b/src/Filters/SliceFilter.php @@ -19,6 +19,14 @@ class SliceFilter extends AbstractFilter */ public function filter(array|object $collection): array { + if ( + !\is_array($collection) + && !$collection instanceof \Countable + && !$collection instanceof \ArrayAccess + ) { + return []; + } + $length = \count($collection); $start = $this->token->value['start']; $end = $this->token->value['end']; diff --git a/src/JSONPathLexer.php b/src/JSONPathLexer.php index 136479c..05755fe 100644 --- a/src/JSONPathLexer.php +++ b/src/JSONPathLexer.php @@ -54,7 +54,7 @@ public function __construct(string $expression) return; } - if ($expression[0] === '$') { + if ($expression[0] === '$' || $expression[0] === '@') { $expression = \substr($expression, 1); } @@ -83,11 +83,17 @@ public function parseExpressionTokens(): array $tokenValue = ''; $tokens = []; $inBracketQuote = null; + $inQuote = null; for ($i = 0; $i < $this->expressionLength; $i++) { $char = $this->expression[$i]; - if (($squareBracketDepth === 0) && $char === '.') { + if ($squareBracketDepth === 0 && ($char === "'" || $char === '"')) { + $escaped = $this->isEscaped($tokenValue); + $inQuote = $inQuote === $char && !$escaped ? null : ($inQuote ?? $char); + } + + if (($squareBracketDepth === 0) && $inQuote === null && $char === '.') { if ($this->lookAhead($i) === '.') { $tokens[] = new JSONPathToken(TokenType::Recursive, null); } @@ -140,7 +146,10 @@ public function parseExpressionTokens(): array */ $tokenValue .= $char; - if ($this->atEnd($i) || \in_array($this->lookAhead($i), ['.', '['], true)) { + if ( + $inQuote === null + && ($this->atEnd($i) || \in_array($this->lookAhead($i), ['.', '['], true)) + ) { $tokens[] = $this->createToken($tokenValue); $tokenValue = ''; } @@ -179,7 +188,7 @@ protected function createToken(string $value): JSONPathToken { // The IDE doesn't like, what we do with $value, so let's // move it to a separate variable, to get rid of any IDE warnings - $tokenValue = $value; + $tokenValue = \trim($value); /** @var JSONPathToken|null $ret */ $ret = null; @@ -197,6 +206,15 @@ protected function createToken(string $value): JSONPathToken $hasQuery = false; foreach ($parts as $part) { + if ( + \preg_match('/^' . static::MATCH_INDEX_IN_SINGLE_QUOTES . '$/xu', $part, $matches) + || \preg_match('/^' . static::MATCH_INDEX_IN_DOUBLE_QUOTES . '$/xu', $part, $matches) + ) { + $union[] = $this->decodeQuotedIndex($matches[1] ?? '', $matches[0][0]); + + continue; + } + if (\preg_match('/^-\\d+$/', $part)) { $union[] = (int)$part; @@ -228,8 +246,21 @@ protected function createToken(string $value): JSONPathToken } } - if (($hasSlice || $hasQuery) && \count($union) === \count($parts)) { - return new JSONPathToken(TokenType::Indexes, $union); + if (\count($union) === \count($parts)) { + $quotedPattern = '/^(' . static::MATCH_INDEX_IN_SINGLE_QUOTES . '|' + . static::MATCH_INDEX_IN_DOUBLE_QUOTES . ')$/xu'; + + $quotedCallback = static function (string $part) use ($quotedPattern): bool { + return \preg_match($quotedPattern, $part) === 1; + }; + + $quotedParts = \array_filter($parts, $quotedCallback); + + $allQuoted = \count($quotedParts) === \count($parts); + + $tokenType = ($hasSlice || $hasQuery || !$allQuoted) ? TokenType::Indexes : TokenType::Index; + + return new JSONPathToken($tokenType, $union, $allQuoted); } } } @@ -238,21 +269,25 @@ protected function createToken(string $value): JSONPathToken return new JSONPathToken(TokenType::Index, (int)$tokenValue); } + if ($tokenValue === '') { + return new JSONPathToken(TokenType::Indexes, []); + } + + if ( + ($tokenValue[0] === "'" || $tokenValue[0] === '"') + && $tokenValue[\strlen($tokenValue) - 1] === $tokenValue[0] + ) { + $tokenValue = $this->decodeQuotedIndex(\substr($tokenValue, 1, -1), $tokenValue[0]); + + return new JSONPathToken(TokenType::Index, $tokenValue, true); + } + if (\preg_match('/^(' . static::MATCH_INDEX . ')$/xu', $tokenValue, $matches)) { if (\preg_match('/^-?\d+$/', $tokenValue)) { $tokenValue = (int)$tokenValue; } $ret = new JSONPathToken(TokenType::Index, $tokenValue); - } elseif (\preg_match('/^' . static::MATCH_INDEXES . '$/xu', $tokenValue, $matches)) { - $tokenValue = \explode(',', \trim($tokenValue, ',')); - - foreach ($tokenValue as $i => $v) { - $v = \trim($v); - $tokenValue[$i] = $v === '*' ? '*' : (int)$v; - } - - $ret = new JSONPathToken(TokenType::Indexes, $tokenValue); } elseif (\preg_match('/^' . static::MATCH_SLICE . '$/xu', $tokenValue, $matches)) { $tokenValue = $this->parseSlice($tokenValue); @@ -261,6 +296,8 @@ protected function createToken(string $value): JSONPathToken $tokenValue = \substr($tokenValue, 1, -1); $ret = new JSONPathToken(TokenType::QueryResult, $tokenValue); + } elseif ($tokenValue === '?()') { + $ret = new JSONPathToken(TokenType::QueryMatch, '', shorthand: false); } elseif ($tokenValue === '?') { $ret = new JSONPathToken(TokenType::QueryMatch, '@', shorthand: true); } elseif (\preg_match('/^\\?@/', $tokenValue)) { @@ -272,27 +309,6 @@ protected function createToken(string $value): JSONPathToken $tokenValue = \substr($tokenValue, 2, -1); $ret = new JSONPathToken(TokenType::QueryMatch, $tokenValue); - } elseif ( - \preg_match('/^' . static::MATCH_INDEX_IN_SINGLE_QUOTES . '$/xu', $tokenValue, $matches) - || \preg_match('/^' . static::MATCH_INDEX_IN_DOUBLE_QUOTES . '$/xu', $tokenValue, $matches) - ) { - if (isset($matches[1])) { - $tokenValue = $this->decodeQuotedIndex($matches[1], $matches[0][0]); - - $possibleArray = false; - if ($matches[0][0] === '"') { - $possibleArray = \explode('","', $tokenValue); - } elseif ($matches[0][0] === "'") { - $possibleArray = \explode("','", $tokenValue); - } - if ($possibleArray !== false && \count($possibleArray) > 1) { - $tokenValue = $possibleArray; - } - } else { - $tokenValue = ''; - } - - $ret = new JSONPathToken(TokenType::Index, $tokenValue, true); } if ($ret !== null) { diff --git a/tests/JSONPathLexerTest.php b/tests/JSONPathLexerTest.php index 5948923..aaf8138 100644 --- a/tests/JSONPathLexerTest.php +++ b/tests/JSONPathLexerTest.php @@ -198,6 +198,27 @@ public static function expressionProvider(): iterable ], ]; + yield 'quoted index in dot notation preserves dots' => [ + "$.'some.key'", + [ + ['type' => TokenType::Index, 'value' => 'some.key', 'quoted' => true], + ], + ]; + + yield 'empty bracket notation yields empty index list' => [ + '$[]', + [ + ['type' => TokenType::Indexes, 'value' => []], + ], + ]; + + yield 'empty filter expression tokenizes to empty query match' => [ + '$[?()]', + [ + ['type' => TokenType::QueryMatch, 'value' => '', 'shorthand' => false], + ], + ]; + yield 'query result expression' => [ '[(@.foo + 2)]', [ diff --git a/tests/QueryMatchFilterTest.php b/tests/QueryMatchFilterTest.php index b3447aa..56cbaa8 100644 --- a/tests/QueryMatchFilterTest.php +++ b/tests/QueryMatchFilterTest.php @@ -243,6 +243,45 @@ public function testMalformedFilterThrowsRuntimeException(): void new JSONPath([1])->find('$[?(foo)]'); } + /** + * @throws JSONPathException + */ + public function testLiteralOnlyFilterExpressionsReturnWholeCollectionOrEmpty(): void + { + $data = [1, 2, 3]; + + self::assertSame($data, new JSONPath($data)->find('$[?(true)]')->getData()); + self::assertSame([], new JSONPath($data)->find('$[?(false)]')->getData()); + } + + /** + * @throws JSONPathException + */ + public function testLogicalExpressionsWithLiteralRightOperand(): void + { + $data = [ + ['key' => 1], + ['key' => -1], + ]; + + self::assertSame( + [], + new JSONPath($data)->find('$[?(@.key>0 && false)]')->getData() + ); + self::assertSame( + $data, + new JSONPath($data)->find('$[?(@.key>0 || true)]')->getData() + ); + } + + /** + * @throws JSONPathException + */ + public function testEmptyFilterExpressionReturnsEmpty(): void + { + self::assertSame([], new JSONPath([1, 2])->find('$[?()]')->getData()); + } + /** * @throws JSONPathException */ diff --git a/tests/SliceFilterTest.php b/tests/SliceFilterTest.php index 11a3058..4ceec5a 100644 --- a/tests/SliceFilterTest.php +++ b/tests/SliceFilterTest.php @@ -36,7 +36,7 @@ public function testFilterHandlesNegativeAndNullBounds(array $slice, array|objec } /** - * @return iterable, array, array}> + * @return iterable, array|object, array}> */ public static function edgeCaseProvider(): iterable { @@ -97,6 +97,12 @@ public static function edgeCaseProvider(): iterable ['a', 'b', 'c'], ['b', 'a'], ]; + + yield 'non countable object yields empty' => [ + ['start' => 0, 'end' => null, 'step' => 1], + (object)['a' => 1], + [], + ]; } /** @@ -105,7 +111,7 @@ public static function edgeCaseProvider(): iterable * @param array $expected */ #[DataProvider('edgeCaseProvider')] - public function testEdgeCases(array $slice, array $input, array $expected): void + public function testEdgeCases(array $slice, array|object $input, array $expected): void { $token = new JSONPathToken(TokenType::Slice, $slice); $filter = new SliceFilter($token); From a07f7e4290cb32d21e8faa31bbfae045d97e8c57 Mon Sep 17 00:00:00 2001 From: Sascha Greuel Date: Fri, 23 Jan 2026 15:53:57 +0100 Subject: [PATCH 5/5] Fixed tokenizer handling for quoted bracket keys containing `$` so literals like `['[$the.size$]']` remain atomic and do not split into root tokens. Signed-off-by: Sascha Greuel --- CHANGELOG.md | 11 +++++++---- composer.json | 2 +- src/JSONPathLexer.php | 2 +- tests/JSONPathLexerTest.php | 7 +++++++ 4 files changed, 16 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c28790b..6633d95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,12 @@ # Changelog -### 1.0.1 -- Aligned the query runner and lexer with the JSONPath comparison suite: JSON documents are now decoded as objects to preserve `{}` vs `[]`, unsupported selectors no longer abort the runner, and dot-notation now accepts quoted keys with dots/spaces/leading `@`. -- Hardened filter parsing: boolean-only filters (`?(true|false|null)`), literal short-circuiting (`&& false`, `|| true`), and empty filters now return the expected collections instead of throwing. -- Slice filters gracefully skip non-countable objects. +### 1.0.2 +- Fixed tokenizer handling for quoted bracket keys containing `$` so literals like `['[$the.size$]']` remain atomic and do not split into root tokens. + +### 1.0.1 +- Aligned the query runner and lexer with the JSONPath comparison suite: JSON documents are now decoded as objects to preserve `{}` vs `[]`, unsupported selectors no longer abort the runner, and dot-notation now accepts quoted keys with dots/spaces/leading `@`. +- Hardened filter parsing: boolean-only filters (`?(true|false|null)`), literal short-circuiting (`&& false`, `|| true`), and empty filters now return the expected collections instead of throwing. +- Slice filters gracefully skip non-countable objects. ### 1.0.0 - Rebuilt the test suite from scratch: removed bulky baseline fixtures and added compact unit/integration coverage for every filter (index, union, query, recursive, slice), lexer edge cases, and JSONPath core helpers. Runs reflection-free and deprecation-free. diff --git a/composer.json b/composer.json index 9c1d5c8..dc76792 100644 --- a/composer.json +++ b/composer.json @@ -2,7 +2,7 @@ "name": "softcreatr/jsonpath", "description": "JSONPath implementation for parsing, searching and flattening arrays", "license": "MIT", - "version": "1.0.1", + "version": "1.0.2", "authors": [ { "name": "Stephen Frank", diff --git a/src/JSONPathLexer.php b/src/JSONPathLexer.php index 05755fe..0068dce 100644 --- a/src/JSONPathLexer.php +++ b/src/JSONPathLexer.php @@ -101,7 +101,7 @@ public function parseExpressionTokens(): array continue; } - if ($char === '[') { + if ($char === '[' && $inBracketQuote === null) { $squareBracketDepth++; if ($squareBracketDepth === 1) { diff --git a/tests/JSONPathLexerTest.php b/tests/JSONPathLexerTest.php index aaf8138..c03e265 100644 --- a/tests/JSONPathLexerTest.php +++ b/tests/JSONPathLexerTest.php @@ -219,6 +219,13 @@ public static function expressionProvider(): iterable ], ]; + yield 'quoted index preserves brackets and dollar signs' => [ + '$[\'[$the.size$]\']', + [ + ['type' => TokenType::Index, 'value' => '[$the.size$]', 'quoted' => true], + ], + ]; + yield 'query result expression' => [ '[(@.foo + 2)]', [