From 60fce9446cb31e8712dd01b76a556a65e6228404 Mon Sep 17 00:00:00 2001 From: Brian Sam-Bodden Date: Fri, 26 Sep 2025 09:34:23 -0700 Subject: [PATCH] feat: add native support for UNF and NOINDEX field attributes (#374) Implements native support for Redis field attributes UNF (un-normalized form) and NOINDEX to provide more control over field indexing and sorting behavior. BREAKING CHANGE: None - all changes are backward compatible with default values - Add `no_index` attribute to BaseFieldAttributes for all field types - Add `unf` attribute to TextFieldAttributes and NumericFieldAttributes - Both attributes default to False maintaining backward compatibility Field Support: - TextField: Supports both no_index and unf attributes - NumericField: Supports both no_index and unf attributes - TagField: Supports no_index attribute - GeoField: Supports no_index attribute Technical Implementation: - NOINDEX implemented via redis-py's native no_index parameter - UNF added via args_suffix manipulation with proper ordering - Both attributes require sortable=True to take effect - Special parsing logic handles Redis auto-adding UNF to sortable numeric fields Fixes #374 --- redisvl/redis/connection.py | 18 +- redisvl/schema/fields.py | 53 ++- .../test_unf_noindex_integration.py | 409 ++++++++++++++++++ tests/unit/test_unf_noindex_fields.py | 253 +++++++++++ 4 files changed, 724 insertions(+), 9 deletions(-) create mode 100644 tests/integration/test_unf_noindex_integration.py create mode 100644 tests/unit/test_unf_noindex_fields.py diff --git a/redisvl/redis/connection.py b/redisvl/redis/connection.py index 9e21a0c1..da70024f 100644 --- a/redisvl/redis/connection.py +++ b/redisvl/redis/connection.py @@ -136,7 +136,7 @@ def parse_vector_attrs(attrs): return normalized - def parse_attrs(attrs): + def parse_attrs(attrs, field_type=None): # 'SORTABLE', 'NOSTEM' don't have corresponding values. # Their presence indicates boolean True # TODO 'WITHSUFFIXTRIE' is another boolean attr, but is not returned by ft.info @@ -150,17 +150,23 @@ def parse_attrs(attrs): "SORTABLE": "sortable", "INDEXMISSING": "index_missing", "INDEXEMPTY": "index_empty", + "NOINDEX": "no_index", } + # Special handling for UNF: + # - For NUMERIC fields, Redis always adds UNF when SORTABLE is present + # - For TEXT fields, UNF is only present when explicitly set + # We only set unf=True for TEXT fields to avoid false positives + if "UNF" in attrs: + if field_type == "TEXT": + parsed_attrs["unf"] = True + attrs.remove("UNF") + for redis_attr, python_attr in boolean_attrs.items(): if redis_attr in attrs: parsed_attrs[python_attr] = True attrs.remove(redis_attr) - # Handle UNF which is associated with SORTABLE - if "UNF" in attrs: - attrs.remove("UNF") # UNF present on sortable numeric fields only - try: # Parse remaining attributes as key-value pairs starting from index 6 parsed_attrs.update( @@ -182,7 +188,7 @@ def parse_attrs(attrs): if field_attrs[5] == "VECTOR": field["attrs"] = parse_vector_attrs(field_attrs) else: - field["attrs"] = parse_attrs(field_attrs) + field["attrs"] = parse_attrs(field_attrs, field_type=field_attrs[5]) # append field schema_fields.append(field) diff --git a/redisvl/schema/fields.py b/redisvl/schema/fields.py index f36ed465..7533e8c4 100644 --- a/redisvl/schema/fields.py +++ b/redisvl/schema/fields.py @@ -63,6 +63,8 @@ class BaseFieldAttributes(BaseModel): """Enable faster result sorting on the field at runtime""" index_missing: bool = Field(default=False) """Allow indexing and searching for missing values (documents without the field)""" + no_index: bool = Field(default=False) + """Store field without indexing it (requires sortable=True or field is ignored)""" class TextFieldAttributes(BaseFieldAttributes): @@ -78,6 +80,8 @@ class TextFieldAttributes(BaseFieldAttributes): """Used to perform phonetic matching during search""" index_empty: bool = Field(default=False) """Allow indexing and searching for empty strings""" + unf: bool = Field(default=False) + """Un-normalized form - disable normalization on sortable fields (only applies when sortable=True)""" class TagFieldAttributes(BaseFieldAttributes): @@ -96,7 +100,8 @@ class TagFieldAttributes(BaseFieldAttributes): class NumericFieldAttributes(BaseFieldAttributes): """Numeric field attributes""" - pass + unf: bool = Field(default=False) + """Un-normalized form - disable normalization on sortable fields (only applies when sortable=True)""" class GeoFieldAttributes(BaseFieldAttributes): @@ -223,7 +228,24 @@ def as_redis_field(self) -> RedisField: if self.attrs.index_empty: # type: ignore kwargs["index_empty"] = True - return RedisTextField(name, **kwargs) + # Add NOINDEX if enabled + if self.attrs.no_index: # type: ignore + kwargs["no_index"] = True + + 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") + + return field class TagField(BaseField): @@ -253,6 +275,10 @@ def as_redis_field(self) -> RedisField: if self.attrs.index_empty: # type: ignore kwargs["index_empty"] = True + # Add NOINDEX if enabled + if self.attrs.no_index: # type: ignore + kwargs["no_index"] = True + return RedisTagField(name, **kwargs) @@ -277,7 +303,24 @@ def as_redis_field(self) -> RedisField: if self.attrs.index_missing: # type: ignore kwargs["index_missing"] = True - return RedisNumericField(name, **kwargs) + # Add NOINDEX if enabled + if self.attrs.no_index: # type: ignore + kwargs["no_index"] = True + + 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") + + return field class GeoField(BaseField): @@ -301,6 +344,10 @@ def as_redis_field(self) -> RedisField: if self.attrs.index_missing: # type: ignore kwargs["index_missing"] = True + # Add NOINDEX if enabled + if self.attrs.no_index: # type: ignore + kwargs["no_index"] = True + return RedisGeoField(name, **kwargs) diff --git a/tests/integration/test_unf_noindex_integration.py b/tests/integration/test_unf_noindex_integration.py new file mode 100644 index 00000000..09eaff8e --- /dev/null +++ b/tests/integration/test_unf_noindex_integration.py @@ -0,0 +1,409 @@ +"""Integration tests for UNF and NOINDEX field attributes.""" + +import numpy as np +import pytest + +from redisvl.index import SearchIndex +from redisvl.query import FilterQuery, VectorQuery + + +@pytest.fixture +def sample_data(): + """Create sample data for testing.""" + return [ + { + "id": "doc1", + "title": "First Document", + "content": "This is searchable content", + "score": 95.5, + "price": 100, + "tags": "red,blue", + "location": "0,0", + "vector": np.array([0.1, 0.2, 0.3, 0.4], dtype=np.float32).tobytes(), + }, + { + "id": "doc2", + "title": "Second Document", + "content": "More searchable text here", + "score": 87.3, + "price": 200, + "tags": "green,yellow", + "location": "1,1", + "vector": np.array([0.2, 0.3, 0.4, 0.5], dtype=np.float32).tobytes(), + }, + { + "id": "doc3", + "title": "Third Document", + "content": "Additional content for search", + "score": 92.1, + "price": 150, + "tags": "blue,yellow", + "location": "2,2", + "vector": np.array([0.3, 0.4, 0.5, 0.6], dtype=np.float32).tobytes(), + }, + ] + + +class TestNoIndexIntegration: + """Test NOINDEX functionality with real Redis.""" + + def test_text_field_with_noindex_not_searchable(self, client, sample_data): + """Test that TEXT field with NOINDEX cannot be searched.""" + schema = { + "index": {"name": "test_noindex_text", "prefix": "noindex:"}, + "fields": [ + {"name": "id", "type": "tag"}, + { + "name": "title", + "type": "text", + "attrs": {"no_index": True, "sortable": True}, + }, + {"name": "content", "type": "text"}, + ], + } + + index = SearchIndex.from_dict(schema, redis_client=client) + index.create(overwrite=True) + index.load(sample_data) + + # Should NOT find documents when searching by title (NOINDEX field) + query = FilterQuery( + return_fields=["id", "title"], + filter_expression="@title:(First)", + ) + + # NOINDEX fields return empty results, not an error + results = index.query(query) + assert len(results) == 0 # No results because field is not indexed + + # Should find documents when searching by content (indexed field) + query2 = FilterQuery( + return_fields=["id", "content"], + filter_expression="@content:(searchable)", + ) + results2 = index.query(query2) + assert len(results2) > 0 + + # But title should still be sortable + query3 = FilterQuery( + return_fields=["id", "title"], + filter_expression="*", + sort_by="title", + ) + results3 = index.query(query3) + assert len(results3) == 3 + # Verify sorting worked + titles = [doc["title"] for doc in results3] + assert titles == sorted(titles) + + index.delete() + + def test_numeric_field_with_noindex_not_searchable(self, client, sample_data): + """Test that NUMERIC field with NOINDEX cannot be searched.""" + schema = { + "index": {"name": "test_noindex_numeric", "prefix": "noindex_num:"}, + "fields": [ + {"name": "id", "type": "tag"}, + { + "name": "score", + "type": "numeric", + "attrs": {"no_index": True, "sortable": True}, + }, + {"name": "price", "type": "numeric"}, + ], + } + + index = SearchIndex.from_dict(schema, redis_client=client) + index.create(overwrite=True) + index.load(sample_data) + + # Should NOT find documents when filtering by score (NOINDEX field) + query = FilterQuery( + return_fields=["id", "score"], + filter_expression="@score:[90 100]", + ) + + # NOINDEX fields return empty results, not an error + results = index.query(query) + assert len(results) == 0 # No results because field is not indexed + + # Should find documents when filtering by price (indexed field) + query2 = FilterQuery( + return_fields=["id", "price"], + filter_expression="@price:[100 200]", + ) + results2 = index.query(query2) + assert len(results2) >= 2 + + # But score should still be sortable + query3 = FilterQuery( + return_fields=["id", "score"], + filter_expression="*", + sort_by="score", + ) + results3 = index.query(query3) + assert len(results3) == 3 + # Verify sorting worked + scores = [float(doc["score"]) for doc in results3] + assert scores == sorted(scores) + + index.delete() + + def test_tag_field_with_noindex_not_searchable(self, client, sample_data): + """Test that TAG field with NOINDEX cannot be searched.""" + schema = { + "index": {"name": "test_noindex_tag", "prefix": "noindex_tag:"}, + "fields": [ + {"name": "id", "type": "tag"}, + { + "name": "tags", + "type": "tag", + "attrs": {"no_index": True, "sortable": True}, + }, + ], + } + + index = SearchIndex.from_dict(schema, redis_client=client) + index.create(overwrite=True) + index.load(sample_data) + + # Should NOT find documents when filtering by tags (NOINDEX field) + query = FilterQuery( + return_fields=["id", "tags"], + filter_expression="@tags:{blue}", + ) + + # NOINDEX fields return empty results, not an error + results = index.query(query) + assert len(results) == 0 # No results because field is not indexed + + # But tags should still be sortable and retrievable + query2 = FilterQuery( + return_fields=["id", "tags"], + filter_expression="*", + sort_by="tags", + ) + results2 = index.query(query2) + assert len(results2) == 3 + # Verify we can retrieve the field values + assert all("tags" in doc for doc in results2) + + index.delete() + + def test_mixed_index_and_noindex_fields(self, client, sample_data): + """Test index with mix of indexed and non-indexed fields.""" + schema = { + "index": {"name": "test_mixed_index", "prefix": "mixed:"}, + "fields": [ + {"name": "id", "type": "tag"}, + { + "name": "title", + "type": "text", + "attrs": {"no_index": True, "sortable": True}, + }, + {"name": "content", "type": "text"}, + { + "name": "score", + "type": "numeric", + "attrs": {"no_index": True, "sortable": True}, + }, + {"name": "price", "type": "numeric"}, + { + "name": "vector", + "type": "vector", + "attrs": { + "dims": 4, + "distance_metric": "cosine", + "algorithm": "flat", + }, + }, + ], + } + + index = SearchIndex.from_dict(schema, redis_client=client) + index.create(overwrite=True) + index.load(sample_data) + + # Complex query using only indexed fields + query = VectorQuery( + vector=[0.15, 0.25, 0.35, 0.45], + vector_field_name="vector", + return_fields=["id", "title", "content", "score", "price"], + num_results=3, + ) + results = index.query(query) + assert len(results) >= 1 + + # Verify NOINDEX fields are still returned + for doc in results: + assert "title" in doc # NOINDEX field should still be retrievable + assert "score" in doc # NOINDEX field should still be retrievable + assert "content" in doc + assert "price" in doc + + index.delete() + + +class TestUnfIntegration: + """Test UNF functionality with real Redis.""" + + def test_text_field_unf_sortable_unnormalized(self, client): + """Test that TEXT field with UNF and SORTABLE preserves original case.""" + # Create two indices - one with UNF, one without + schema_with_unf = { + "index": {"name": "test_unf_text", "prefix": "unf:"}, + "fields": [ + {"name": "id", "type": "tag"}, + { + "name": "title", + "type": "text", + "attrs": {"unf": True, "sortable": True}, + }, + ], + } + + schema_without_unf = { + "index": {"name": "test_no_unf_text", "prefix": "no_unf:"}, + "fields": [ + {"name": "id", "type": "tag"}, + { + "name": "title", + "type": "text", + "attrs": {"sortable": True}, # No UNF + }, + ], + } + + # Test data with mixed case + test_data = [ + {"id": "1", "title": "ZEBRA"}, + {"id": "2", "title": "apple"}, + {"id": "3", "title": "Banana"}, + ] + + # Test with UNF (preserves case for sorting) + index_unf = SearchIndex.from_dict(schema_with_unf, redis_client=client) + index_unf.create(overwrite=True) + index_unf.load(test_data) + + query = FilterQuery( + return_fields=["id", "title"], + filter_expression="*", + sort_by="title", + ) + results_unf = index_unf.query(query) + titles_unf = [doc["title"] for doc in results_unf] + + # With UNF, uppercase comes before lowercase in ASCII order + # Expected order: Banana, ZEBRA, apple (B=66, Z=90, a=97) + assert titles_unf == ["Banana", "ZEBRA", "apple"] + + # Test without UNF (normalizes to lowercase for sorting) + index_no_unf = SearchIndex.from_dict(schema_without_unf, redis_client=client) + index_no_unf.create(overwrite=True) + index_no_unf.load(test_data) + + query_no_unf = FilterQuery( + return_fields=["id", "title"], + filter_expression="*", + sort_by="title", + ) + results_no_unf = index_no_unf.query(query_no_unf) + titles_no_unf = [doc["title"] for doc in results_no_unf] + + # Without UNF, all normalized to lowercase for sorting + # Expected order: apple, Banana, ZEBRA (alphabetical) + assert titles_no_unf == ["apple", "Banana", "ZEBRA"] + + index_unf.delete() + index_no_unf.delete() + + def test_numeric_field_unf_behavior(self, client): + """Test NUMERIC field UNF behavior - Redis always applies UNF to sortable numeric.""" + schema = { + "index": {"name": "test_numeric_unf", "prefix": "num_unf:"}, + "fields": [ + {"name": "id", "type": "tag"}, + { + "name": "score", + "type": "numeric", + "attrs": {"sortable": True}, # UNF is implicit for numeric + }, + ], + } + + test_data = [ + {"id": "1", "score": 100.5}, + {"id": "2", "score": 50.2}, + {"id": "3", "score": 75.8}, + ] + + index = SearchIndex.from_dict(schema, redis_client=client) + index.create(overwrite=True) + index.load(test_data) + + query = FilterQuery( + return_fields=["id", "score"], + filter_expression="*", + sort_by="score", + ) + results = index.query(query) + scores = [float(doc["score"]) for doc in results] + + # Numeric sorting should work correctly + assert scores == [50.2, 75.8, 100.5] + + index.delete() + + +class TestSchemaRoundtrip: + """Test that schemas with UNF/NOINDEX can be saved and loaded correctly.""" + + def test_schema_persistence_with_new_attributes(self, client, sample_data): + """Test that index with UNF/NOINDEX can be created and retrieved.""" + schema = { + "index": {"name": "test_persistence", "prefix": "persist:"}, + "fields": [ + {"name": "id", "type": "tag"}, + { + "name": "title", + "type": "text", + "attrs": {"no_index": True, "sortable": True, "unf": True}, + }, + { + "name": "score", + "type": "numeric", + "attrs": {"no_index": True, "sortable": True}, + }, + ], + } + + # Create index + index = SearchIndex.from_dict(schema, redis_client=client) + index.create(overwrite=True) + index.load(sample_data) + + # Load index from Redis + index2 = SearchIndex.from_existing("test_persistence", redis_client=client) + + # Verify fields have correct attributes + title_field = index2.schema.fields["title"] + assert title_field.attrs.no_index is True + assert title_field.attrs.sortable is True + assert title_field.attrs.unf is True # Should be preserved for TEXT field + + score_field = index2.schema.fields["score"] + assert score_field.attrs.no_index is True + assert score_field.attrs.sortable is True + # Note: unf for numeric is not preserved as Redis always applies it + + # Verify the index still works + query = FilterQuery( + return_fields=["id", "title", "score"], + filter_expression="*", + sort_by="title", + ) + results = index2.query(query) + assert len(results) == 3 + + index.delete() diff --git a/tests/unit/test_unf_noindex_fields.py b/tests/unit/test_unf_noindex_fields.py new file mode 100644 index 00000000..f58feace --- /dev/null +++ b/tests/unit/test_unf_noindex_fields.py @@ -0,0 +1,253 @@ +"""Unit tests for UNF and NOINDEX field attributes.""" + +import pytest + +from redisvl.schema.fields import ( + FieldFactory, + GeoField, + NumericField, + TagField, + TextField, +) + + +class TestTextFieldAttributes: + """Test TextField support for no_index and unf attributes.""" + + def test_no_index_attribute_with_sortable(self): + """Test TextField with no_index=True and sortable=True.""" + field = TextField(name="title", attrs={"no_index": True, "sortable": True}) + + assert field.attrs.no_index is True + assert field.attrs.sortable is True + + redis_field = field.as_redis_field() + args = redis_field.redis_args() + assert "NOINDEX" in args + assert "SORTABLE" in args + + def test_no_index_default_value(self): + """Test TextField no_index defaults to False.""" + field = TextField(name="content") + assert field.attrs.no_index is False + + redis_field = field.as_redis_field() + args = redis_field.redis_args() + assert "NOINDEX" not in args + + def test_unf_attribute_with_sortable(self): + """Test TextField with unf=True and sortable=True.""" + field = TextField(name="title", attrs={"unf": True, "sortable": True}) + + assert field.attrs.unf is True + assert field.attrs.sortable is True + + redis_field = field.as_redis_field() + args = redis_field.redis_args() + assert "UNF" in args + assert "SORTABLE" in args + + def test_unf_without_sortable(self): + """Test that UNF is not added when field is not sortable.""" + field = TextField(name="description", attrs={"unf": True, "sortable": False}) + + assert field.attrs.unf is True + assert field.attrs.sortable is False + + redis_field = field.as_redis_field() + args = redis_field.redis_args() + assert "UNF" not in args + assert "SORTABLE" not in args + + def test_unf_default_value(self): + """Test TextField unf defaults to False.""" + field = TextField(name="content") + assert field.attrs.unf is False + + +class TestNumericFieldAttributes: + """Test NumericField support for no_index and unf attributes.""" + + def test_no_index_attribute_with_sortable(self): + """Test NumericField with no_index=True and sortable=True.""" + field = NumericField(name="price", attrs={"no_index": True, "sortable": True}) + + assert field.attrs.no_index is True + assert field.attrs.sortable is True + + redis_field = field.as_redis_field() + args = redis_field.redis_args() + assert "NOINDEX" in args + assert "SORTABLE" in args + + def test_no_index_default_value(self): + """Test NumericField no_index defaults to False.""" + field = NumericField(name="quantity") + assert field.attrs.no_index is False + + redis_field = field.as_redis_field() + args = redis_field.redis_args() + assert "NOINDEX" not in args + + def test_unf_attribute_with_sortable(self): + """Test NumericField with unf=True and sortable=True.""" + field = NumericField(name="score", attrs={"unf": True, "sortable": True}) + + assert field.attrs.unf is True + assert field.attrs.sortable is True + + redis_field = field.as_redis_field() + args = redis_field.redis_args() + assert "UNF" in args + assert "SORTABLE" in args + + def test_unf_without_sortable(self): + """Test that UNF is not added when field is not sortable.""" + field = NumericField(name="count", attrs={"unf": True, "sortable": False}) + + assert field.attrs.unf is True + assert field.attrs.sortable is False + + redis_field = field.as_redis_field() + args = redis_field.redis_args() + assert "UNF" not in args + assert "SORTABLE" not in args + + def test_unf_default_value(self): + """Test NumericField unf defaults to False.""" + field = NumericField(name="rating") + assert field.attrs.unf is False + + +class TestTagFieldNoIndex: + """Test TagField support for no_index attribute.""" + + def test_no_index_attribute_with_sortable(self): + """Test TagField with no_index=True and sortable=True.""" + field = TagField(name="tags", attrs={"no_index": True, "sortable": True}) + + assert field.attrs.no_index is True + assert field.attrs.sortable is True + + redis_field = field.as_redis_field() + args = redis_field.redis_args() + assert "NOINDEX" in args + assert "SORTABLE" in args + + def test_no_index_default_value(self): + """Test TagField no_index defaults to False.""" + field = TagField(name="categories") + assert field.attrs.no_index is False + + redis_field = field.as_redis_field() + args = redis_field.redis_args() + assert "NOINDEX" not in args + + +class TestGeoFieldNoIndex: + """Test GeoField support for no_index attribute.""" + + def test_no_index_attribute_with_sortable(self): + """Test GeoField with no_index=True and sortable=True.""" + field = GeoField(name="location", attrs={"no_index": True, "sortable": True}) + + assert field.attrs.no_index is True + assert field.attrs.sortable is True + + redis_field = field.as_redis_field() + args = redis_field.redis_args() + assert "NOINDEX" in args + assert "SORTABLE" in args + + def test_no_index_default_value(self): + """Test GeoField no_index defaults to False.""" + field = GeoField(name="coordinates") + assert field.attrs.no_index is False + + redis_field = field.as_redis_field() + args = redis_field.redis_args() + assert "NOINDEX" not in args + + +class TestFieldFactoryWithNewAttributes: + """Test FieldFactory creating fields with new attributes.""" + + def test_create_text_field_with_unf_and_noindex(self): + """Test creating TextField with unf and no_index via FieldFactory.""" + field = FieldFactory.create_field( + type="text", + name="title", + attrs={"unf": True, "no_index": True, "sortable": True}, + ) + + assert isinstance(field, TextField) + assert field.attrs.unf is True + assert field.attrs.no_index is True + assert field.attrs.sortable is True + + def test_create_numeric_field_with_unf_and_noindex(self): + """Test creating NumericField with unf and no_index via FieldFactory.""" + field = FieldFactory.create_field( + type="numeric", + name="score", + attrs={"unf": True, "no_index": True, "sortable": True}, + ) + + assert isinstance(field, NumericField) + assert field.attrs.unf is True + assert field.attrs.no_index is True + assert field.attrs.sortable is True + + def test_create_tag_field_with_noindex(self): + """Test creating TagField with no_index via FieldFactory.""" + field = FieldFactory.create_field( + type="tag", name="tags", attrs={"no_index": True, "sortable": True} + ) + + assert isinstance(field, TagField) + assert field.attrs.no_index is True + assert field.attrs.sortable is True + + def test_create_geo_field_with_noindex(self): + """Test creating GeoField with no_index via FieldFactory.""" + field = FieldFactory.create_field( + type="geo", name="location", attrs={"no_index": True, "sortable": True} + ) + + assert isinstance(field, GeoField) + assert field.attrs.no_index is True + assert field.attrs.sortable is True + + +class TestBackwardCompatibility: + """Test that new attributes don't break backward compatibility.""" + + def test_text_field_without_new_attributes(self): + """Test TextField works without specifying new attributes.""" + field = TextField(name="content", attrs={"weight": 2.0}) + + assert field.attrs.weight == 2.0 + assert field.attrs.unf is False + assert field.attrs.no_index is False + + def test_numeric_field_without_new_attributes(self): + """Test NumericField works without specifying new attributes.""" + field = NumericField(name="price", attrs={"sortable": True}) + + assert field.attrs.sortable is True + assert field.attrs.unf is False + assert field.attrs.no_index is False + + def test_tag_field_without_new_attributes(self): + """Test TagField works without specifying new attributes.""" + field = TagField(name="tags", attrs={"separator": "|"}) + + assert field.attrs.separator == "|" + assert field.attrs.no_index is False + + def test_geo_field_without_new_attributes(self): + """Test GeoField works without specifying new attributes.""" + field = GeoField(name="location", attrs={"sortable": True}) + + assert field.attrs.sortable is True + assert field.attrs.no_index is False