Skip to content

Commit 1657d2f

Browse files
committed
test(migrate): add migration planner, wizard, and integration tests
- Unit tests for MigrationPlanner diff classification - Unit tests for MigrationWizard (41 tests incl. adversarial inputs) - Integration test for drop_recreate flow - Field modifier ordering integration tests (INDEXEMPTY, INDEXMISSING, etc.)
1 parent 725af07 commit 1657d2f

File tree

4 files changed

+2385
-0
lines changed

4 files changed

+2385
-0
lines changed

tests/integration/test_field_modifier_ordering_integration.py

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,233 @@ def test_indexmissing_enables_ismissing_query(self, client, redis_url, worker_id
399399
index.delete(drop=True)
400400

401401

402+
class TestIndexEmptyIntegration:
403+
"""Integration tests for INDEXEMPTY functionality."""
404+
405+
def test_text_field_index_empty_creates_successfully(
406+
self, client, redis_url, worker_id
407+
):
408+
"""Test that INDEXEMPTY on text field allows index creation."""
409+
skip_if_search_version_below_for_indexmissing(client)
410+
schema_dict = {
411+
"index": {
412+
"name": f"test_text_empty_{worker_id}",
413+
"prefix": f"textempty_{worker_id}:",
414+
"storage_type": "hash",
415+
},
416+
"fields": [
417+
{
418+
"name": "description",
419+
"type": "text",
420+
"attrs": {"index_empty": True},
421+
}
422+
],
423+
}
424+
425+
schema = IndexSchema.from_dict(schema_dict)
426+
index = SearchIndex(schema=schema, redis_url=redis_url)
427+
index.create(overwrite=True)
428+
429+
# Verify index was created
430+
info = client.execute_command("FT.INFO", f"test_text_empty_{worker_id}")
431+
assert info is not None
432+
433+
# Create documents with empty and non-empty values
434+
client.hset(f"textempty_{worker_id}:1", "description", "has content")
435+
client.hset(f"textempty_{worker_id}:2", "description", "")
436+
client.hset(f"textempty_{worker_id}:3", "description", "more content")
437+
438+
# Search should work, empty string doc should be indexed
439+
result = client.execute_command(
440+
"FT.SEARCH",
441+
f"test_text_empty_{worker_id}",
442+
"*",
443+
)
444+
# All 3 docs should be found
445+
assert result[0] == 3
446+
447+
# Cleanup
448+
client.delete(
449+
f"textempty_{worker_id}:1",
450+
f"textempty_{worker_id}:2",
451+
f"textempty_{worker_id}:3",
452+
)
453+
index.delete(drop=True)
454+
455+
def test_tag_field_index_empty_creates_successfully(
456+
self, client, redis_url, worker_id
457+
):
458+
"""Test that INDEXEMPTY on tag field allows index creation."""
459+
skip_if_search_version_below_for_indexmissing(client)
460+
schema_dict = {
461+
"index": {
462+
"name": f"test_tag_empty_{worker_id}",
463+
"prefix": f"tagempty_{worker_id}:",
464+
"storage_type": "hash",
465+
},
466+
"fields": [
467+
{
468+
"name": "category",
469+
"type": "tag",
470+
"attrs": {"index_empty": True},
471+
}
472+
],
473+
}
474+
475+
schema = IndexSchema.from_dict(schema_dict)
476+
index = SearchIndex(schema=schema, redis_url=redis_url)
477+
index.create(overwrite=True)
478+
479+
# Verify index was created
480+
info = client.execute_command("FT.INFO", f"test_tag_empty_{worker_id}")
481+
assert info is not None
482+
483+
# Create documents with empty and non-empty values
484+
client.hset(f"tagempty_{worker_id}:1", "category", "electronics")
485+
client.hset(f"tagempty_{worker_id}:2", "category", "")
486+
client.hset(f"tagempty_{worker_id}:3", "category", "books")
487+
488+
# Search should work
489+
result = client.execute_command(
490+
"FT.SEARCH",
491+
f"test_tag_empty_{worker_id}",
492+
"*",
493+
)
494+
# All 3 docs should be found
495+
assert result[0] == 3
496+
497+
# Cleanup
498+
client.delete(
499+
f"tagempty_{worker_id}:1",
500+
f"tagempty_{worker_id}:2",
501+
f"tagempty_{worker_id}:3",
502+
)
503+
index.delete(drop=True)
504+
505+
506+
class TestUnfModifierIntegration:
507+
"""Integration tests for UNF (un-normalized form) modifier."""
508+
509+
def test_text_field_unf_requires_sortable(self, client, redis_url, worker_id):
510+
"""Test that UNF on text field works only when sortable is also True."""
511+
skip_if_search_version_below_for_indexmissing(client)
512+
schema_dict = {
513+
"index": {
514+
"name": f"test_text_unf_{worker_id}",
515+
"prefix": f"textunf_{worker_id}:",
516+
"storage_type": "hash",
517+
},
518+
"fields": [
519+
{
520+
"name": "title",
521+
"type": "text",
522+
"attrs": {"sortable": True, "unf": True},
523+
}
524+
],
525+
}
526+
527+
schema = IndexSchema.from_dict(schema_dict)
528+
index = SearchIndex(schema=schema, redis_url=redis_url)
529+
530+
# Should create successfully
531+
index.create(overwrite=True)
532+
533+
info = client.execute_command("FT.INFO", f"test_text_unf_{worker_id}")
534+
assert info is not None
535+
536+
index.delete(drop=True)
537+
538+
def test_numeric_field_unf_with_sortable(self, client, redis_url, worker_id):
539+
"""Test that UNF on numeric field works when sortable is True."""
540+
skip_if_search_version_below_for_indexmissing(client)
541+
schema_dict = {
542+
"index": {
543+
"name": f"test_num_unf_{worker_id}",
544+
"prefix": f"numunf_{worker_id}:",
545+
"storage_type": "hash",
546+
},
547+
"fields": [
548+
{
549+
"name": "price",
550+
"type": "numeric",
551+
"attrs": {"sortable": True, "unf": True},
552+
}
553+
],
554+
}
555+
556+
schema = IndexSchema.from_dict(schema_dict)
557+
index = SearchIndex(schema=schema, redis_url=redis_url)
558+
559+
# Should create successfully
560+
index.create(overwrite=True)
561+
562+
info = client.execute_command("FT.INFO", f"test_num_unf_{worker_id}")
563+
assert info is not None
564+
565+
index.delete(drop=True)
566+
567+
568+
class TestNoIndexModifierIntegration:
569+
"""Integration tests for NOINDEX modifier."""
570+
571+
def test_noindex_with_sortable_allows_sorting_not_searching(
572+
self, client, redis_url, worker_id
573+
):
574+
"""Test that NOINDEX field can be sorted but not searched."""
575+
schema_dict = {
576+
"index": {
577+
"name": f"test_noindex_{worker_id}",
578+
"prefix": f"noindex_{worker_id}:",
579+
"storage_type": "hash",
580+
},
581+
"fields": [
582+
{
583+
"name": "searchable",
584+
"type": "text",
585+
},
586+
{
587+
"name": "sort_only",
588+
"type": "numeric",
589+
"attrs": {"sortable": True, "no_index": True},
590+
},
591+
],
592+
}
593+
594+
schema = IndexSchema.from_dict(schema_dict)
595+
index = SearchIndex(schema=schema, redis_url=redis_url)
596+
index.create(overwrite=True)
597+
598+
# Add test documents
599+
client.hset(
600+
f"noindex_{worker_id}:1", mapping={"searchable": "hello", "sort_only": 10}
601+
)
602+
client.hset(
603+
f"noindex_{worker_id}:2", mapping={"searchable": "world", "sort_only": 5}
604+
)
605+
client.hset(
606+
f"noindex_{worker_id}:3", mapping={"searchable": "test", "sort_only": 15}
607+
)
608+
609+
# Sorting by no_index field should work
610+
result = client.execute_command(
611+
"FT.SEARCH",
612+
f"test_noindex_{worker_id}",
613+
"*",
614+
"SORTBY",
615+
"sort_only",
616+
"ASC",
617+
)
618+
assert result[0] == 3
619+
620+
# Cleanup
621+
client.delete(
622+
f"noindex_{worker_id}:1",
623+
f"noindex_{worker_id}:2",
624+
f"noindex_{worker_id}:3",
625+
)
626+
index.delete(drop=True)
627+
628+
402629
class TestFieldTypeModifierSupport:
403630
"""Test that field types only support their documented modifiers."""
404631

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import uuid
2+
3+
import yaml
4+
5+
from redisvl.index import SearchIndex
6+
from redisvl.migration import MigrationExecutor, MigrationPlanner, MigrationValidator
7+
from redisvl.migration.utils import load_migration_plan, schemas_equal
8+
from redisvl.redis.utils import array_to_buffer
9+
10+
11+
def test_drop_recreate_plan_apply_validate_flow(redis_url, worker_id, tmp_path):
12+
unique_id = str(uuid.uuid4())[:8]
13+
index_name = f"migration_v1_{worker_id}_{unique_id}"
14+
prefix = f"migration_v1:{worker_id}:{unique_id}"
15+
16+
source_index = SearchIndex.from_dict(
17+
{
18+
"index": {
19+
"name": index_name,
20+
"prefix": prefix,
21+
"storage_type": "hash",
22+
},
23+
"fields": [
24+
{"name": "doc_id", "type": "tag"},
25+
{"name": "title", "type": "text"},
26+
{"name": "price", "type": "numeric"},
27+
{
28+
"name": "embedding",
29+
"type": "vector",
30+
"attrs": {
31+
"algorithm": "hnsw",
32+
"dims": 3,
33+
"distance_metric": "cosine",
34+
"datatype": "float32",
35+
},
36+
},
37+
],
38+
},
39+
redis_url=redis_url,
40+
)
41+
42+
docs = [
43+
{
44+
"doc_id": "1",
45+
"title": "alpha",
46+
"price": 1,
47+
"category": "news",
48+
"embedding": array_to_buffer([0.1, 0.2, 0.3], "float32"),
49+
},
50+
{
51+
"doc_id": "2",
52+
"title": "beta",
53+
"price": 2,
54+
"category": "sports",
55+
"embedding": array_to_buffer([0.2, 0.1, 0.4], "float32"),
56+
},
57+
]
58+
59+
source_index.create(overwrite=True)
60+
source_index.load(docs, id_field="doc_id")
61+
62+
patch_path = tmp_path / "schema_patch.yaml"
63+
patch_path.write_text(
64+
yaml.safe_dump(
65+
{
66+
"version": 1,
67+
"changes": {
68+
"add_fields": [
69+
{
70+
"name": "category",
71+
"type": "tag",
72+
"attrs": {"separator": ","},
73+
}
74+
],
75+
"remove_fields": ["price"],
76+
"update_fields": [{"name": "title", "attrs": {"sortable": True}}],
77+
},
78+
},
79+
sort_keys=False,
80+
)
81+
)
82+
83+
plan_path = tmp_path / "migration_plan.yaml"
84+
planner = MigrationPlanner()
85+
plan = planner.create_plan(
86+
index_name,
87+
redis_url=redis_url,
88+
schema_patch_path=str(patch_path),
89+
)
90+
assert plan.diff_classification.supported is True
91+
planner.write_plan(plan, str(plan_path))
92+
93+
query_check_path = tmp_path / "query_checks.yaml"
94+
query_check_path.write_text(
95+
yaml.safe_dump({"fetch_ids": ["1", "2"]}, sort_keys=False)
96+
)
97+
98+
executor = MigrationExecutor()
99+
report = executor.apply(
100+
load_migration_plan(str(plan_path)),
101+
redis_url=redis_url,
102+
query_check_file=str(query_check_path),
103+
)
104+
105+
assert report.result == "succeeded"
106+
assert report.validation.schema_match is True
107+
assert report.validation.doc_count_match is True
108+
assert report.validation.key_sample_exists is True
109+
assert report.validation.indexing_failures_delta == 0
110+
assert not report.validation.errors
111+
assert report.benchmark_summary.documents_indexed_per_second is not None
112+
113+
live_index = SearchIndex.from_existing(index_name, redis_url=redis_url)
114+
assert schemas_equal(live_index.schema.to_dict(), plan.merged_target_schema)
115+
116+
validator = MigrationValidator()
117+
validation, _target_info, _duration = validator.validate(
118+
load_migration_plan(str(plan_path)),
119+
redis_url=redis_url,
120+
query_check_file=str(query_check_path),
121+
)
122+
assert validation.schema_match is True
123+
assert validation.doc_count_match is True
124+
assert validation.key_sample_exists is True
125+
assert not validation.errors
126+
127+
live_index.delete(drop=True)

0 commit comments

Comments
 (0)