diff --git a/redisvl/schema/fields.py b/redisvl/schema/fields.py index 3a3f2206..b7492767 100644 --- a/redisvl/schema/fields.py +++ b/redisvl/schema/fields.py @@ -35,7 +35,7 @@ """ from enum import Enum -from typing import Any, Dict, Literal, Optional, Tuple, Type, Union +from typing import Any, Dict, List, Literal, Optional, Tuple, Type, Union from pydantic import BaseModel, Field, field_validator, model_validator from redis.commands.search.field import Field as RedisField @@ -97,6 +97,49 @@ class CompressionType(str, Enum): LeanVec8x8 = "LeanVec8x8" +### Helper Functions ### + + +def _normalize_field_modifiers( + field: RedisField, canonical_order: List[str], want_unf: bool = False +) -> None: + """Normalize field modifier ordering for RediSearch parser. + + RediSearch has a parser limitation where INDEXEMPTY and + INDEXMISSING must appear BEFORE SORTABLE in field definitions. This function + reorders field.args_suffix to match the canonical order. + + Args: + field: Redis field object whose args_suffix will be normalized + canonical_order: List of modifiers in desired canonical order + want_unf: Whether UNF should be added after SORTABLE (default: False) + + Time Complexity: O(n + m), where n = len(field.args_suffix), m = len(canonical_order). + - O(n) to create the set from field.args_suffix + - O(m) to iterate over canonical_order and perform set lookups (O(1) average case per lookup) + Space Complexity: O(n) + + Example: + >>> field = RedisTextField("title") + >>> field.args_suffix = ["SORTABLE", "INDEXMISSING"] + >>> _normalize_field_modifiers(field, ["INDEXEMPTY", "INDEXMISSING", "SORTABLE"]) + >>> field.args_suffix + ['INDEXMISSING', 'SORTABLE'] + """ + suffix_set = set(field.args_suffix) + + # Build new suffix with only known modifiers in canonical order + new_suffix = [] + for modifier in canonical_order: + if modifier in suffix_set: + new_suffix.append(modifier) + # Special case: UNF only appears with SORTABLE + if modifier == "SORTABLE" and want_unf and "UNF" not in suffix_set: + new_suffix.append("UNF") + + field.args_suffix = new_suffix + + ### Field Attributes ### @@ -290,7 +333,7 @@ def validate_svs_params(self): ): logger.warning( f"LeanVec compression selected without 'reduce'. " - f"Consider setting reduce={self.dims//2} for better performance" + f"Consider setting reduce={self.dims // 2} for better performance" ) if self.graph_max_degree and self.graph_max_degree < 32: @@ -371,16 +414,11 @@ def as_redis_field(self) -> RedisField: field = RedisTextField(name, **kwargs) - # Add UNF support (only when sortable) - # UNF must come before NOINDEX in the args_suffix - if self.attrs.unf and self.attrs.sortable: # type: ignore - if "NOINDEX" in field.args_suffix: - # Insert UNF before NOINDEX - noindex_idx = field.args_suffix.index("NOINDEX") - field.args_suffix.insert(noindex_idx, "UNF") - else: - # No NOINDEX, append normally - field.args_suffix.append("UNF") + # Normalize suffix ordering to satisfy RediSearch parser expectations. + # Canonical order: [INDEXEMPTY] [INDEXMISSING] [SORTABLE [UNF]] [NOINDEX] + canonical_order = ["INDEXEMPTY", "INDEXMISSING", "SORTABLE", "UNF", "NOINDEX"] + want_unf = self.attrs.unf and self.attrs.sortable # type: ignore + _normalize_field_modifiers(field, canonical_order, want_unf) return field @@ -416,7 +454,14 @@ def as_redis_field(self) -> RedisField: if self.attrs.no_index: # type: ignore kwargs["no_index"] = True - return RedisTagField(name, **kwargs) + field = RedisTagField(name, **kwargs) + + # Normalize suffix ordering to satisfy RediSearch parser expectations. + # Canonical order: [INDEXEMPTY] [INDEXMISSING] [SORTABLE] [NOINDEX] + canonical_order = ["INDEXEMPTY", "INDEXMISSING", "SORTABLE", "NOINDEX"] + _normalize_field_modifiers(field, canonical_order) + + return field class NumericField(BaseField): @@ -446,16 +491,12 @@ def as_redis_field(self) -> RedisField: field = RedisNumericField(name, **kwargs) - # Add UNF support (only when sortable) - # UNF must come before NOINDEX in the args_suffix - if self.attrs.unf and self.attrs.sortable: # type: ignore - if "NOINDEX" in field.args_suffix: - # Insert UNF before NOINDEX - noindex_idx = field.args_suffix.index("NOINDEX") - field.args_suffix.insert(noindex_idx, "UNF") - else: - # No NOINDEX, append normally - field.args_suffix.append("UNF") + # Normalize suffix ordering to satisfy RediSearch parser expectations. + # Canonical order: [INDEXMISSING] [SORTABLE [UNF]] [NOINDEX] + # Note: INDEXEMPTY is not supported for NUMERIC fields + canonical_order = ["INDEXMISSING", "SORTABLE", "UNF", "NOINDEX"] + want_unf = self.attrs.unf and self.attrs.sortable # type: ignore + _normalize_field_modifiers(field, canonical_order, want_unf) return field @@ -485,7 +526,15 @@ def as_redis_field(self) -> RedisField: if self.attrs.no_index: # type: ignore kwargs["no_index"] = True - return RedisGeoField(name, **kwargs) + field = RedisGeoField(name, **kwargs) + + # Normalize suffix ordering to satisfy RediSearch parser expectations. + # Canonical order: [INDEXMISSING] [SORTABLE] [NOINDEX] + # Note: INDEXEMPTY is not supported for GEO fields + canonical_order = ["INDEXMISSING", "SORTABLE", "NOINDEX"] + _normalize_field_modifiers(field, canonical_order) + + return field class FlatVectorField(BaseField): diff --git a/tests/integration/test_field_modifier_ordering_integration.py b/tests/integration/test_field_modifier_ordering_integration.py new file mode 100644 index 00000000..4fc1dabb --- /dev/null +++ b/tests/integration/test_field_modifier_ordering_integration.py @@ -0,0 +1,479 @@ +""" +Integration tests for field modifier ordering against live Redis. + +These tests verify that indices can be created successfully with various +field modifier combinations. The key test is that index.create() succeeds +without error - if the modifiers are in the wrong order, Redis will reject +the FT.CREATE command. +""" + +import pytest + +from redisvl.index import SearchIndex +from redisvl.redis.connection import RedisConnectionFactory +from redisvl.schema import IndexSchema + +MIN_SEARCH_VERSION_FOR_INDEXMISSING = 21000 # RediSearch 2.10.0+ + + +def skip_if_search_version_below_for_indexmissing(client) -> None: + """Skip tests that require INDEXMISSING/INDEXEMPTY if RediSearch is too old.""" + modules = RedisConnectionFactory.get_modules(client) + search_ver = modules.get("search", 0) + searchlight_ver = modules.get("searchlight", 0) + current_ver = max(search_ver, searchlight_ver) + if current_ver < MIN_SEARCH_VERSION_FOR_INDEXMISSING: + pytest.skip( + "INDEXMISSING/INDEXEMPTY require RediSearch 2.10+ " + f"(found module version {current_ver})" + ) + + +class TestTextFieldModifierOrderingIntegration: + """Integration tests for TextField modifier ordering.""" + + def test_textfield_sortable_and_index_missing(self, client, redis_url, worker_id): + """Test TextField with sortable and index_missing creates successfully.""" + skip_if_search_version_below_for_indexmissing(client) + schema_dict = { + "index": { + "name": f"test_text_sortable_missing_{worker_id}", + "prefix": f"text_sm_{worker_id}", + "storage_type": "hash", + }, + "fields": [ + { + "name": "title", + "type": "text", + "attrs": {"sortable": True, "index_missing": True}, + } + ], + } + + schema = IndexSchema.from_dict(schema_dict) + index = SearchIndex(schema=schema, redis_url=redis_url) + + # This should succeed - if modifiers are in wrong order, it will fail + index.create(overwrite=True) + + # Verify index was created + info = client.execute_command( + "FT.INFO", f"test_text_sortable_missing_{worker_id}" + ) + assert info is not None + + # Cleanup + index.delete(drop=True) + + def test_textfield_all_modifiers(self, client, redis_url, worker_id): + """Test TextField with all modifiers.""" + skip_if_search_version_below_for_indexmissing(client) + schema_dict = { + "index": { + "name": f"test_text_all_mods_{worker_id}", + "prefix": f"text_all_{worker_id}", + "storage_type": "hash", + }, + "fields": [ + { + "name": "content", + "type": "text", + "attrs": { + "index_empty": True, + "index_missing": True, + "sortable": True, + "unf": True, + }, + } + ], + } + + schema = IndexSchema.from_dict(schema_dict) + index = SearchIndex(schema=schema, redis_url=redis_url) + + # This should succeed - if modifiers are in wrong order, it will fail + index.create(overwrite=True) + + # Verify index was created + info = client.execute_command("FT.INFO", f"test_text_all_mods_{worker_id}") + assert info is not None + + # Cleanup + index.delete(drop=True) + + +class TestTagFieldModifierOrderingIntegration: + """Integration tests for TagField modifier ordering.""" + + def test_tagfield_sortable_and_index_missing(self, client, redis_url, worker_id): + """Test TagField with sortable and index_missing creates successfully.""" + skip_if_search_version_below_for_indexmissing(client) + schema_dict = { + "index": { + "name": f"test_tag_sortable_missing_{worker_id}", + "prefix": f"tag_sm_{worker_id}", + "storage_type": "hash", + }, + "fields": [ + { + "name": "tags", + "type": "tag", + "attrs": {"sortable": True, "index_missing": True}, + } + ], + } + + schema = IndexSchema.from_dict(schema_dict) + index = SearchIndex(schema=schema, redis_url=redis_url) + + # This should succeed - if modifiers are in wrong order, it will fail + index.create(overwrite=True) + + # Verify index was created + info = client.execute_command( + "FT.INFO", f"test_tag_sortable_missing_{worker_id}" + ) + assert info is not None + + # Cleanup + index.delete(drop=True) + + def test_tagfield_all_modifiers(self, client, redis_url, worker_id): + """Test TagField with all modifiers.""" + skip_if_search_version_below_for_indexmissing(client) + schema_dict = { + "index": { + "name": f"test_tag_all_mods_{worker_id}", + "prefix": f"tag_all_{worker_id}", + "storage_type": "hash", + }, + "fields": [ + { + "name": "categories", + "type": "tag", + "attrs": { + "index_empty": True, + "index_missing": True, + "sortable": True, + }, + } + ], + } + + schema = IndexSchema.from_dict(schema_dict) + index = SearchIndex(schema=schema, redis_url=redis_url) + + # This should succeed - if modifiers are in wrong order, it will fail + index.create(overwrite=True) + + # Verify index was created + info = client.execute_command("FT.INFO", f"test_tag_all_mods_{worker_id}") + assert info is not None + + # Cleanup + index.delete(drop=True) + + +class TestGeoFieldModifierOrderingIntegration: + """Integration tests for GeoField modifier ordering.""" + + def test_geofield_sortable_and_index_missing(self, client, redis_url, worker_id): + """Test GeoField with sortable and index_missing creates successfully.""" + skip_if_search_version_below_for_indexmissing(client) + schema_dict = { + "index": { + "name": f"test_geo_sortable_missing_{worker_id}", + "prefix": f"geo_sm_{worker_id}", + "storage_type": "hash", + }, + "fields": [ + { + "name": "location", + "type": "geo", + "attrs": {"sortable": True, "index_missing": True}, + } + ], + } + + schema = IndexSchema.from_dict(schema_dict) + index = SearchIndex(schema=schema, redis_url=redis_url) + + # This should succeed - if modifiers are in wrong order, it will fail + index.create(overwrite=True) + + # Verify index was created + info = client.execute_command( + "FT.INFO", f"test_geo_sortable_missing_{worker_id}" + ) + assert info is not None + + # Cleanup + index.delete(drop=True) + + +class TestNumericFieldModifierOrderingIntegration: + """Integration tests for NumericField modifier ordering.""" + + def test_numericfield_sortable_and_index_missing( + self, client, redis_url, worker_id + ): + """Test NumericField with sortable and index_missing creates successfully.""" + skip_if_search_version_below_for_indexmissing(client) + schema_dict = { + "index": { + "name": f"test_numeric_sortable_missing_{worker_id}", + "prefix": f"num_sm_{worker_id}", + "storage_type": "hash", + }, + "fields": [ + { + "name": "price", + "type": "numeric", + "attrs": {"sortable": True, "index_missing": True}, + } + ], + } + + schema = IndexSchema.from_dict(schema_dict) + index = SearchIndex(schema=schema, redis_url=redis_url) + + # This should succeed - if modifiers are in wrong order, it will fail + index.create(overwrite=True) + + # Verify index was created + info = client.execute_command( + "FT.INFO", f"test_numeric_sortable_missing_{worker_id}" + ) + assert info is not None + + # Cleanup + index.delete(drop=True) + + +class TestMultiFieldModifierOrderingIntegration: + """Integration tests for multiple field types with modifiers.""" + + def test_mixed_field_types_with_modifiers(self, client, redis_url, worker_id): + """Test index with multiple field types all using modifiers.""" + skip_if_search_version_below_for_indexmissing(client) + schema_dict = { + "index": { + "name": f"test_mixed_fields_{worker_id}", + "prefix": f"mixed_{worker_id}", + "storage_type": "hash", + }, + "fields": [ + { + "name": "title", + "type": "text", + "attrs": {"sortable": True, "index_missing": True}, + }, + { + "name": "tags", + "type": "tag", + "attrs": {"sortable": True, "index_missing": True}, + }, + { + "name": "price", + "type": "numeric", + "attrs": {"sortable": True, "index_missing": True}, + }, + { + "name": "location", + "type": "geo", + "attrs": {"sortable": True, "index_missing": True}, + }, + ], + } + + schema = IndexSchema.from_dict(schema_dict) + index = SearchIndex(schema=schema, redis_url=redis_url) + + # This should succeed - if modifiers are in wrong order, it will fail + index.create(overwrite=True) + + # Verify index was created + info = client.execute_command("FT.INFO", f"test_mixed_fields_{worker_id}") + assert info is not None + + # Verify all fields were created + # Find the 'attributes' key in the FT.INFO response (flat list format) + attrs_list = None + for i in range(0, len(info) - 1, 2): + if info[i] == b"attributes" or info[i] == "attributes": + attrs_list = info[i + 1] + break + assert attrs_list is not None, "'attributes' key not found in FT.INFO response" + assert len(attrs_list) == 4 + + # Cleanup + index.delete(drop=True) + + +class TestFieldModifierIntegration: + """Integration tests for complex field modifier combinations.""" + + def test_index_creation_with_multiple_modifiers(self, client, redis_url, worker_id): + """Test creating index with INDEXMISSING SORTABLE UNF modifiers. + + This test verifies that an index with all three modifiers + (INDEXMISSING, SORTABLE, UNF) can be created successfully with + correct field ordering. + """ + skip_if_search_version_below_for_indexmissing(client) + schema_dict = { + "index": { + "name": f"testidx_{worker_id}", + "prefix": f"test_{worker_id}:", + "storage_type": "hash", + }, + "fields": [ + { + "name": "description", + "type": "text", + "attrs": {"index_missing": True, "sortable": True, "unf": True}, + } + ], + } + + schema = IndexSchema.from_dict(schema_dict) + index = SearchIndex(schema=schema, redis_url=redis_url) + + # This should succeed with correct modifier ordering + index.create(overwrite=True) + + # Verify index was created + info = client.execute_command("FT.INFO", f"testidx_{worker_id}") + assert info is not None + + # Cleanup + index.delete(drop=True) + + def test_indexmissing_enables_ismissing_query(self, client, redis_url, worker_id): + """Test that INDEXMISSING enables ismissing() query function.""" + skip_if_search_version_below_for_indexmissing(client) + schema_dict = { + "index": { + "name": f"test_ismissing_{worker_id}", + "prefix": f"ismiss_{worker_id}:", + "storage_type": "hash", + }, + "fields": [ + { + "name": "optional_field", + "type": "text", + "attrs": {"index_missing": True}, + } + ], + } + + schema = IndexSchema.from_dict(schema_dict) + index = SearchIndex(schema=schema, redis_url=redis_url) + + index.create(overwrite=True) + + # Create documents with and without the field + client.hset(f"ismiss_{worker_id}:1", "optional_field", "has value") + client.hset(f"ismiss_{worker_id}:2", "other_field", "no optional_field") + client.hset(f"ismiss_{worker_id}:3", "optional_field", "also has value") + + # Query for missing fields + result = client.execute_command( + "FT.SEARCH", + f"test_ismissing_{worker_id}", + "ismissing(@optional_field)", + "DIALECT", + "2", + ) + + # Should return 1 result (ismiss_{worker_id}:2) + assert result[0] == 1 + assert f"ismiss_{worker_id}:2" in str(result) + + # Cleanup + client.delete( + f"ismiss_{worker_id}:1", + f"ismiss_{worker_id}:2", + f"ismiss_{worker_id}:3", + ) + index.delete(drop=True) + + +class TestFieldTypeModifierSupport: + """Test that field types only support their documented modifiers.""" + + def test_numeric_field_does_not_support_index_empty( + self, client, redis_url, worker_id + ): + """Verify that NumericField does not have index_empty attribute. + + INDEXEMPTY is only supported for TEXT and TAG fields according to + RediSearch documentation. NumericFieldAttributes should not have + an index_empty attribute. + """ + import inspect + + from redisvl.schema.fields import NumericFieldAttributes + + # Verify NumericFieldAttributes doesn't have index_empty + attrs = inspect.signature(NumericFieldAttributes).parameters + assert ( + "index_empty" not in attrs + ), "NumericFieldAttributes should not have index_empty parameter" + + # Verify the attribute doesn't exist on the class + field_attrs = NumericFieldAttributes() + assert not hasattr( + field_attrs, "index_empty" + ), "NumericFieldAttributes should not have index_empty attribute" + + def test_geo_field_does_not_support_index_empty(self, client, redis_url, worker_id): + """Verify that GeoField does not have index_empty attribute. + + INDEXEMPTY is only supported for TEXT and TAG fields according to + RediSearch documentation. GeoFieldAttributes should not have + an index_empty attribute. + """ + import inspect + + from redisvl.schema.fields import GeoFieldAttributes + + # Verify GeoFieldAttributes doesn't have index_empty + attrs = inspect.signature(GeoFieldAttributes).parameters + assert ( + "index_empty" not in attrs + ), "GeoFieldAttributes should not have index_empty parameter" + + # Verify the attribute doesn't exist on the class + field_attrs = GeoFieldAttributes() + assert not hasattr( + field_attrs, "index_empty" + ), "GeoFieldAttributes should not have index_empty attribute" + + def test_text_field_supports_index_empty(self, client, redis_url, worker_id): + """Verify that TextField supports index_empty attribute. + + INDEXEMPTY is supported for TEXT fields according to RediSearch documentation. + """ + from redisvl.schema.fields import TextFieldAttributes + + # Verify TextFieldAttributes has index_empty + field_attrs = TextFieldAttributes(index_empty=True) + assert hasattr( + field_attrs, "index_empty" + ), "TextFieldAttributes should have index_empty attribute" + assert field_attrs.index_empty is True + + def test_tag_field_supports_index_empty(self, client, redis_url, worker_id): + """Verify that TagField supports index_empty attribute. + + INDEXEMPTY is supported for TAG fields according to RediSearch documentation. + """ + from redisvl.schema.fields import TagFieldAttributes + + # Verify TagFieldAttributes has index_empty + field_attrs = TagFieldAttributes(index_empty=True) + assert hasattr( + field_attrs, "index_empty" + ), "TagFieldAttributes should have index_empty attribute" + assert field_attrs.index_empty is True diff --git a/tests/unit/test_field_modifier_ordering.py b/tests/unit/test_field_modifier_ordering.py new file mode 100644 index 00000000..fad097fc --- /dev/null +++ b/tests/unit/test_field_modifier_ordering.py @@ -0,0 +1,352 @@ +""" +Unit tests for field modifier ordering fix. + +Tests verify that field modifiers are generated in the correct order +to satisfy RediSearch parser requirements. The canonical order is: + [INDEXEMPTY] [INDEXMISSING] [SORTABLE [UNF]] [NOINDEX] + +This is required because RediSearch has a parser limitation +where INDEXEMPTY/INDEXMISSING must appear BEFORE SORTABLE in field definitions. +""" + +import pytest +from redis.commands.search.field import TextField as RedisTextField + +from redisvl.schema.fields import ( + GeoField, + NumericField, + TagField, + TextField, + _normalize_field_modifiers, +) + + +class TestTextFieldModifierOrdering: + """Test TextField generates modifiers in correct order.""" + + def test_sortable_and_index_missing_order(self): + """Test that INDEXMISSING comes before SORTABLE.""" + field = TextField(name="title", attrs={"sortable": True, "index_missing": True}) + redis_field = field.as_redis_field() + suffix = redis_field.args_suffix + + # INDEXMISSING must come before SORTABLE + assert "INDEXMISSING" in suffix + assert "SORTABLE" in suffix + assert suffix.index("INDEXMISSING") < suffix.index("SORTABLE") + + def test_all_modifiers_order(self): + """Test canonical order with all modifiers.""" + field = TextField( + name="content", + attrs={ + "index_empty": True, + "index_missing": True, + "sortable": True, + "unf": True, + "no_index": False, + }, + ) + redis_field = field.as_redis_field() + suffix = redis_field.args_suffix + + # Verify canonical order: INDEXEMPTY, INDEXMISSING, SORTABLE, UNF + assert suffix == ["INDEXEMPTY", "INDEXMISSING", "SORTABLE", "UNF"] + + def test_unf_only_with_sortable(self): + """Test that UNF only appears when sortable=True.""" + field = TextField(name="title", attrs={"sortable": True, "unf": True}) + redis_field = field.as_redis_field() + suffix = redis_field.args_suffix + + assert "UNF" in suffix + assert "SORTABLE" in suffix + assert suffix.index("SORTABLE") < suffix.index("UNF") + + def test_unf_ignored_without_sortable(self): + """Test that UNF is ignored when sortable=False.""" + field = TextField(name="description", attrs={"sortable": False, "unf": True}) + redis_field = field.as_redis_field() + suffix = redis_field.args_suffix + + assert "UNF" not in suffix + assert "SORTABLE" not in suffix + + +class TestNumericFieldModifierOrdering: + """Test NumericField generates modifiers in correct order.""" + + def test_sortable_and_index_missing_order(self): + """Test that INDEXMISSING comes before SORTABLE.""" + field = NumericField( + name="price", attrs={"sortable": True, "index_missing": True} + ) + redis_field = field.as_redis_field() + suffix = redis_field.args_suffix + + # INDEXMISSING must come before SORTABLE + assert "INDEXMISSING" in suffix + assert "SORTABLE" in suffix + assert suffix.index("INDEXMISSING") < suffix.index("SORTABLE") + + def test_all_modifiers_order(self): + """Test canonical order with all modifiers.""" + field = NumericField( + name="score", + attrs={ + "index_missing": True, + "sortable": True, + "unf": True, + "no_index": False, + }, + ) + redis_field = field.as_redis_field() + suffix = redis_field.args_suffix + + # Verify canonical order: INDEXMISSING, SORTABLE, UNF + assert suffix == ["INDEXMISSING", "SORTABLE", "UNF"] + + def test_unf_only_with_sortable(self): + """Test that UNF only appears when sortable=True.""" + field = NumericField(name="rating", attrs={"sortable": True, "unf": True}) + redis_field = field.as_redis_field() + suffix = redis_field.args_suffix + + assert "UNF" in suffix + assert "SORTABLE" in suffix + assert suffix.index("SORTABLE") < suffix.index("UNF") + + +class TestTagFieldModifierOrdering: + """Test TagField generates modifiers in correct order.""" + + def test_sortable_and_index_missing_order(self): + """Test that INDEXMISSING comes before SORTABLE.""" + field = TagField(name="tags", attrs={"sortable": True, "index_missing": True}) + redis_field = field.as_redis_field() + suffix = redis_field.args_suffix + + # INDEXMISSING must come before SORTABLE + assert "INDEXMISSING" in suffix + assert "SORTABLE" in suffix + assert suffix.index("INDEXMISSING") < suffix.index("SORTABLE") + + def test_all_modifiers_order(self): + """Test canonical order with all modifiers.""" + field = TagField( + name="categories", + attrs={ + "index_empty": True, + "index_missing": True, + "sortable": True, + "no_index": False, + }, + ) + redis_field = field.as_redis_field() + suffix = redis_field.args_suffix + + # Verify canonical order: INDEXEMPTY, INDEXMISSING, SORTABLE + assert suffix == ["INDEXEMPTY", "INDEXMISSING", "SORTABLE"] + + def test_index_empty_before_index_missing(self): + """Test that INDEXEMPTY comes before INDEXMISSING.""" + field = TagField( + name="status", attrs={"index_empty": True, "index_missing": True} + ) + redis_field = field.as_redis_field() + suffix = redis_field.args_suffix + + assert "INDEXEMPTY" in suffix + assert "INDEXMISSING" in suffix + assert suffix.index("INDEXEMPTY") < suffix.index("INDEXMISSING") + + +class TestGeoFieldModifierOrdering: + """Test GeoField generates modifiers in correct order.""" + + def test_sortable_and_index_missing_order(self): + """Test that INDEXMISSING comes before SORTABLE.""" + field = GeoField( + name="location", attrs={"sortable": True, "index_missing": True} + ) + redis_field = field.as_redis_field() + suffix = redis_field.args_suffix + + # INDEXMISSING must come before SORTABLE + assert "INDEXMISSING" in suffix + assert "SORTABLE" in suffix + assert suffix.index("INDEXMISSING") < suffix.index("SORTABLE") + + def test_all_modifiers_order(self): + """Test canonical order with all modifiers.""" + field = GeoField( + name="coordinates", + attrs={ + "index_missing": True, + "sortable": True, + "no_index": False, + }, + ) + redis_field = field.as_redis_field() + suffix = redis_field.args_suffix + + # Verify canonical order: INDEXMISSING, SORTABLE + assert suffix == ["INDEXMISSING", "SORTABLE"] + + +class TestModifierOrderingConsistency: + """Test that all field types follow the same ordering rules.""" + + def test_all_fields_index_missing_before_sortable(self): + """Test that all field types put INDEXMISSING before SORTABLE.""" + fields = [ + TextField(name="text", attrs={"sortable": True, "index_missing": True}), + NumericField( + name="numeric", attrs={"sortable": True, "index_missing": True} + ), + TagField(name="tag", attrs={"sortable": True, "index_missing": True}), + GeoField(name="geo", attrs={"sortable": True, "index_missing": True}), + ] + + for field in fields: + redis_field = field.as_redis_field() + suffix = redis_field.args_suffix + + assert ( + "INDEXMISSING" in suffix + ), f"{field.__class__.__name__} missing INDEXMISSING" + assert "SORTABLE" in suffix, f"{field.__class__.__name__} missing SORTABLE" + assert suffix.index("INDEXMISSING") < suffix.index( + "SORTABLE" + ), f"{field.__class__.__name__} has wrong order" + + def test_noindex_comes_last(self): + """Test that NOINDEX always comes last.""" + fields = [ + TextField(name="text", attrs={"sortable": True, "no_index": True}), + NumericField(name="numeric", attrs={"sortable": True, "no_index": True}), + TagField(name="tag", attrs={"sortable": True, "no_index": True}), + GeoField(name="geo", attrs={"sortable": True, "no_index": True}), + ] + + for field in fields: + redis_field = field.as_redis_field() + suffix = redis_field.args_suffix + + if "NOINDEX" in suffix: + assert ( + suffix[-1] == "NOINDEX" + ), f"{field.__class__.__name__} NOINDEX not at end" + + +class TestNormalizeFieldModifiersHelper: + """Test the _normalize_field_modifiers helper function.""" + + def test_basic_reordering(self): + """Test basic reordering of INDEXMISSING and SORTABLE.""" + field = RedisTextField("test") + field.args_suffix = ["SORTABLE", "INDEXMISSING"] + canonical_order = ["INDEXMISSING", "SORTABLE"] + + _normalize_field_modifiers(field, canonical_order) + + assert field.args_suffix == ["INDEXMISSING", "SORTABLE"] + + def test_unf_added_with_sortable(self): + """Test that UNF is added when want_unf=True and SORTABLE is present.""" + field = RedisTextField("test") + field.args_suffix = ["SORTABLE"] + canonical_order = ["SORTABLE", "UNF"] + + _normalize_field_modifiers(field, canonical_order, want_unf=True) + + assert field.args_suffix == ["SORTABLE", "UNF"] + + def test_unf_not_duplicated(self): + """Test that UNF is not duplicated if already present.""" + field = RedisTextField("test") + field.args_suffix = ["SORTABLE", "UNF"] + canonical_order = ["SORTABLE", "UNF"] + + _normalize_field_modifiers(field, canonical_order, want_unf=True) + + assert field.args_suffix == ["SORTABLE", "UNF"] + + def test_unf_not_added_without_sortable(self): + """Test that UNF is not added if SORTABLE is not present.""" + field = RedisTextField("test") + field.args_suffix = ["INDEXMISSING"] + canonical_order = ["INDEXMISSING", "SORTABLE", "UNF"] + + _normalize_field_modifiers(field, canonical_order, want_unf=True) + + assert field.args_suffix == ["INDEXMISSING"] + + def test_all_modifiers_canonical_order(self): + """Test canonical order with all modifiers.""" + field = RedisTextField("test") + field.args_suffix = ["NOINDEX", "UNF", "SORTABLE", "INDEXMISSING", "INDEXEMPTY"] + canonical_order = ["INDEXEMPTY", "INDEXMISSING", "SORTABLE", "UNF", "NOINDEX"] + + _normalize_field_modifiers(field, canonical_order, want_unf=True) + + assert field.args_suffix == [ + "INDEXEMPTY", + "INDEXMISSING", + "SORTABLE", + "UNF", + "NOINDEX", + ] + + def test_empty_suffix(self): + """Test with empty args_suffix.""" + field = RedisTextField("test") + field.args_suffix = [] + canonical_order = ["INDEXMISSING", "SORTABLE"] + + _normalize_field_modifiers(field, canonical_order) + + assert field.args_suffix == [] + + +class TestMLPCommandsScenario: + """Test the exact scenario from mlp_commands.txt.""" + + def test_work_experience_summary_field(self): + """Test TextField with INDEXMISSING SORTABLE UNF (mlp_commands.txt scenario).""" + field = TextField( + name="work_experience_summary", + attrs={"index_missing": True, "sortable": True, "unf": True}, + ) + redis_field = field.as_redis_field() + suffix = redis_field.args_suffix + + # Verify exact order from mlp_commands.txt + assert suffix == ["INDEXMISSING", "SORTABLE", "UNF"] + + def test_mlp_scenario_redis_args(self): + """Test that redis_args() produces correct command for mlp_commands.txt scenario.""" + field = TextField( + name="work_experience_summary", + attrs={"index_missing": True, "sortable": True, "unf": True}, + ) + redis_field = field.as_redis_field() + args = redis_field.redis_args() + + # Verify the args contain the field name and modifiers in correct order + assert "work_experience_summary" in args + assert "TEXT" in args + + # Find the position of TEXT and verify modifiers come after it + text_idx = args.index("TEXT") + remaining_args = args[text_idx + 1 :] + + # Verify INDEXMISSING comes before SORTABLE + if "INDEXMISSING" in remaining_args and "SORTABLE" in remaining_args: + assert remaining_args.index("INDEXMISSING") < remaining_args.index( + "SORTABLE" + ) + + # Verify SORTABLE comes before UNF + if "SORTABLE" in remaining_args and "UNF" in remaining_args: + assert remaining_args.index("SORTABLE") < remaining_args.index("UNF")