diff --git a/common/metadata_utils.py b/common/metadata_utils.py index 130413402d..3aa4c7c0e6 100644 --- a/common/metadata_utils.py +++ b/common/metadata_utils.py @@ -39,7 +39,13 @@ def meta_filter(metas: dict, filters: list[dict], logic: str = "and"): def filter_out(v2docs, operator, value): ids = [] + original_value = value for input, docids in v2docs.items(): + # Reset to the pristine filter value each iteration -- the comparison branch + # below reassigns `value` in place (date normalization, literal_eval coercion), + # and reusing that mutated value on the next dict entry compares it against + # the wrong (already-coerced) type/content instead of the original filter value. + value = original_value if operator in ["=", "≠", ">", "<", "≥", "≤"]: # Check if input is in YYYY-MM-DD date format input_str = str(input).strip() @@ -64,8 +70,14 @@ def meta_filter(metas: dict, filters: list[dict], logic: str = "and"): try: if isinstance(input, list): input = input[0] - input = ast.literal_eval(input) - value = ast.literal_eval(value) + except Exception: + pass + # Commit both literal_eval results together, or neither -- assigning + # just one side (e.g. "None" parses but "none" doesn't) would compare + # mismatched types after lowercasing and silently break the + # case-insensitive match below. + try: + input, value = ast.literal_eval(input), ast.literal_eval(value) except Exception: pass diff --git a/test/unit_test/common/test_metadata_filter_operators.py b/test/unit_test/common/test_metadata_filter_operators.py index fc0cca7be8..dea5e3ef90 100644 --- a/test/unit_test/common/test_metadata_filter_operators.py +++ b/test/unit_test/common/test_metadata_filter_operators.py @@ -164,3 +164,31 @@ def test_or_logic_still_unions_after_empty_first_condition(): ] assert set(meta_filter(metas, filters, logic="or")) == {"doc2"} + + +def test_equal_is_case_insensitive_for_python_keyword_literals(): + # "None" is a metadata cell that happens to be a Python literal keyword; the + # query value "none" (lowercase) is not a valid literal on its own. Coercing + # one side (input -> None) while leaving the other as the string "none" would + # compare mismatched types and silently fail the case-insensitive match. + metas = {"status": {"None": ["doc1"], "Active": ["doc2"]}} + filters = [{"key": "status", "op": "=", "value": "none"}] + + assert meta_filter(metas, filters) == ["doc1"] + + +def test_not_equal_is_case_insensitive_for_python_keyword_literals(): + metas = {"status": {"None": ["doc1"], "Active": ["doc2"]}} + filters = [{"key": "status", "op": "≠", "value": "none"}] + + assert meta_filter(metas, filters) == ["doc2"] + + +def test_greater_than_unaffected_by_prior_dict_entry_coercing_the_query_value(): + # The query value must be re-read fresh for every metadata entry -- if an + # earlier entry coerces it in place (e.g. "5" -> 5), a later entry must not + # compare against that already-coerced leftover instead of the original "5". + metas = {"score": {"5": ["doc1"], "10": ["doc2"]}} + filters = [{"key": "score", "op": ">", "value": "5"}] + + assert meta_filter(metas, filters) == ["doc2"]