Skip to content

Commit 239a46f

Browse files
committed
feat(migrate): complete wizard with all supported operations
- Add rename operations (rename index, change prefix, rename field) - Add vector field removal with [WARNING] indicator - Add index_empty, ef_runtime, epsilon prompts - Add phonetic_matcher and withsuffixtrie for text/tag fields - Update menu to 8 options - All 40 supported operations now in wizard
1 parent c08c727 commit 239a46f

File tree

2 files changed

+350
-132
lines changed

2 files changed

+350
-132
lines changed

redisvl/migration/wizard.py

Lines changed: 195 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,12 @@
44

55
import yaml
66

7-
from redisvl.migration.models import FieldUpdate, SchemaPatch, SchemaPatchChanges
7+
from redisvl.migration.models import (
8+
FieldRename,
9+
FieldUpdate,
10+
SchemaPatch,
11+
SchemaPatchChanges,
12+
)
813
from redisvl.migration.planner import MigrationPlanner
914
from redisvl.migration.utils import list_indexes, write_yaml
1015
from redisvl.schema.schema import IndexSchema
@@ -75,6 +80,9 @@ def _load_existing_patch(self, patch_path: str) -> SchemaPatchChanges:
7580
print(f" Add fields: {len(patch.changes.add_fields)}")
7681
print(f" Update fields: {len(patch.changes.update_fields)}")
7782
print(f" Remove fields: {len(patch.changes.remove_fields)}")
83+
print(f" Rename fields: {len(patch.changes.rename_fields)}")
84+
if patch.changes.index:
85+
print(f" Index changes: {list(patch.changes.index.keys())}")
7886
return patch.changes
7987

8088
def _resolve_index_name(
@@ -120,8 +128,11 @@ def _build_patch(
120128
print("1. Add field (text, tag, numeric, geo)")
121129
print("2. Update field (sortable, weight, separator, vector config)")
122130
print("3. Remove field")
123-
print("4. Preview patch (show pending changes as YAML)")
124-
print("5. Finish")
131+
print("4. Rename field (rename field in all documents)")
132+
print("5. Rename index (change index name)")
133+
print("6. Change prefix (rename all keys)")
134+
print("7. Preview patch (show pending changes as YAML)")
135+
print("8. Finish")
125136
action = input("Enter a number: ").strip()
126137

127138
if action == "1":
@@ -137,15 +148,27 @@ def _build_patch(
137148
if field_name:
138149
changes.remove_fields.append(field_name)
139150
elif action == "4":
151+
field_rename = self._prompt_rename_field(source_schema)
152+
if field_rename:
153+
changes.rename_fields.append(field_rename)
154+
elif action == "5":
155+
new_name = self._prompt_rename_index(source_schema)
156+
if new_name:
157+
changes.index["name"] = new_name
158+
elif action == "6":
159+
new_prefix = self._prompt_change_prefix(source_schema)
160+
if new_prefix:
161+
changes.index["prefix"] = new_prefix
162+
elif action == "7":
140163
print(
141164
yaml.safe_dump(
142165
{"version": 1, "changes": changes.model_dump()}, sort_keys=False
143166
)
144167
)
145-
elif action == "5":
168+
elif action == "8":
146169
done = True
147170
else:
148-
print("Invalid action. Please choose 1-5.")
171+
print("Invalid action. Please choose 1-8.")
149172

150173
return SchemaPatch(version=1, changes=changes)
151174

@@ -220,28 +243,125 @@ def _prompt_update_field(
220243
return FieldUpdate(name=selected["name"], attrs=attrs)
221244

222245
def _prompt_remove_field(self, source_schema: Dict[str, Any]) -> Optional[str]:
223-
removable_fields = [
224-
field["name"]
225-
for field in source_schema["fields"]
226-
if field["type"] != "vector"
227-
]
246+
removable_fields = [field["name"] for field in source_schema["fields"]]
228247
if not removable_fields:
229-
print("No removable Phase 1 fields are available.")
248+
print("No fields available to remove.")
230249
return None
231250

232251
print("Removable fields:")
233-
for position, field_name in enumerate(removable_fields, start=1):
234-
print(f"{position}. {field_name}")
252+
for position, field in enumerate(source_schema["fields"], start=1):
253+
field_type = field["type"]
254+
warning = " [WARNING: vector field]" if field_type == "vector" else ""
255+
print(f"{position}. {field['name']} ({field_type}){warning}")
235256

236257
choice = input("Select a field to remove by number or name: ").strip()
258+
selected_name: Optional[str] = None
237259
if choice in removable_fields:
238-
return choice
239-
if choice.isdigit():
260+
selected_name = choice
261+
elif choice.isdigit():
240262
offset = int(choice) - 1
241263
if 0 <= offset < len(removable_fields):
242-
return removable_fields[offset]
243-
print("Invalid field selection.")
244-
return None
264+
selected_name = removable_fields[offset]
265+
266+
if not selected_name:
267+
print("Invalid field selection.")
268+
return None
269+
270+
# Check if it's a vector field and require confirmation
271+
selected_field = next(
272+
(f for f in source_schema["fields"] if f["name"] == selected_name), None
273+
)
274+
if selected_field and selected_field["type"] == "vector":
275+
print(
276+
f"\n WARNING: Removing vector field '{selected_name}' will:\n"
277+
" - Remove it from the search index\n"
278+
" - Leave vector data in documents (wasted storage)\n"
279+
" - Require re-embedding if you want to restore it later"
280+
)
281+
confirm = input("Type 'yes' to confirm removal: ").strip().lower()
282+
if confirm != "yes":
283+
print("Cancelled.")
284+
return None
285+
286+
return selected_name
287+
288+
def _prompt_rename_field(
289+
self, source_schema: Dict[str, Any]
290+
) -> Optional[FieldRename]:
291+
"""Prompt user to rename a field in all documents."""
292+
fields = source_schema["fields"]
293+
if not fields:
294+
print("No fields available to rename.")
295+
return None
296+
297+
print("Fields available for renaming:")
298+
for position, field in enumerate(fields, start=1):
299+
print(f"{position}. {field['name']} ({field['type']})")
300+
301+
choice = input("Select a field to rename by number or name: ").strip()
302+
selected: Optional[Dict[str, Any]] = None
303+
for position, field in enumerate(fields, start=1):
304+
if choice == str(position) or choice == field["name"]:
305+
selected = field
306+
break
307+
if not selected:
308+
print("Invalid field selection.")
309+
return None
310+
311+
old_name = selected["name"]
312+
print(f"Renaming field '{old_name}'")
313+
print(
314+
" Warning: This will modify all documents to rename the field. "
315+
"This is an expensive operation for large datasets."
316+
)
317+
new_name = input("New field name: ").strip()
318+
if not new_name:
319+
print("New field name is required.")
320+
return None
321+
if new_name == old_name:
322+
print("New name is the same as the old name.")
323+
return None
324+
325+
existing_names = {f["name"] for f in fields}
326+
if new_name in existing_names:
327+
print(f"Field '{new_name}' already exists.")
328+
return None
329+
330+
return FieldRename(old_name=old_name, new_name=new_name)
331+
332+
def _prompt_rename_index(self, source_schema: Dict[str, Any]) -> Optional[str]:
333+
"""Prompt user to rename the index."""
334+
current_name = source_schema["index"]["name"]
335+
print(f"Current index name: {current_name}")
336+
print(
337+
" Note: This only changes the index name. "
338+
"Documents and keys are unchanged."
339+
)
340+
new_name = input("New index name: ").strip()
341+
if not new_name:
342+
print("New index name is required.")
343+
return None
344+
if new_name == current_name:
345+
print("New name is the same as the current name.")
346+
return None
347+
return new_name
348+
349+
def _prompt_change_prefix(self, source_schema: Dict[str, Any]) -> Optional[str]:
350+
"""Prompt user to change the key prefix."""
351+
current_prefix = source_schema["index"]["prefix"]
352+
print(f"Current prefix: {current_prefix}")
353+
print(
354+
" Warning: This will RENAME all keys from the old prefix to the new prefix. "
355+
"This is an expensive operation for large datasets."
356+
)
357+
new_prefix = input("New prefix: ").strip()
358+
if not new_prefix:
359+
print("New prefix is required.")
360+
return None
361+
if new_prefix == current_prefix:
362+
print("New prefix is the same as the current prefix.")
363+
return None
364+
return new_prefix
245365

246366
def _prompt_common_attrs(
247367
self, field_type: str, allow_blank: bool = False
@@ -262,6 +382,14 @@ def _prompt_common_attrs(
262382
if index_missing is not None:
263383
attrs["index_missing"] = index_missing
264384

385+
# Index empty - index documents where field value is empty string
386+
print(
387+
" Index empty: enables isempty() queries for documents with empty string values"
388+
)
389+
index_empty = self._prompt_bool("Index empty", allow_blank=allow_blank)
390+
if index_empty is not None:
391+
attrs["index_empty"] = index_empty
392+
265393
# Type-specific attributes
266394
if field_type == "text":
267395
self._prompt_text_attrs(attrs, allow_blank)
@@ -302,11 +430,21 @@ def _prompt_text_attrs(self, attrs: Dict[str, Any], allow_blank: bool) -> None:
302430
except ValueError:
303431
print("Invalid weight value.")
304432

305-
# Index empty (requires Redis Search 2.10+)
306-
print(" Index empty: enables searching for empty string values")
307-
index_empty = self._prompt_bool("Index empty", allow_blank=allow_blank)
308-
if index_empty is not None:
309-
attrs["index_empty"] = index_empty
433+
# Phonetic matcher
434+
print(
435+
" Phonetic matcher: enables phonetic matching (e.g., 'dm:en' for Metaphone)"
436+
)
437+
phonetic = input("Phonetic matcher [leave blank for none]: ").strip()
438+
if phonetic:
439+
attrs["phonetic_matcher"] = phonetic
440+
441+
# Withsuffixtrie
442+
print(" Suffix trie: enables suffix/contains queries (*suffix, *contains*)")
443+
withsuffixtrie = self._prompt_bool(
444+
"Enable suffix trie", allow_blank=allow_blank
445+
)
446+
if withsuffixtrie is not None:
447+
attrs["withsuffixtrie"] = withsuffixtrie
310448

311449
# UNF (only if sortable)
312450
if attrs.get("sortable"):
@@ -329,11 +467,13 @@ def _prompt_tag_attrs(self, attrs: Dict[str, Any], allow_blank: bool) -> None:
329467
if case_sensitive is not None:
330468
attrs["case_sensitive"] = case_sensitive
331469

332-
# Index empty (requires Redis Search 2.10+)
333-
print(" Index empty: enables searching for empty tag values")
334-
index_empty = self._prompt_bool("Index empty", allow_blank=allow_blank)
335-
if index_empty is not None:
336-
attrs["index_empty"] = index_empty
470+
# Withsuffixtrie
471+
print(" Suffix trie: enables suffix/contains queries (*suffix, *contains*)")
472+
withsuffixtrie = self._prompt_bool(
473+
"Enable suffix trie", allow_blank=allow_blank
474+
)
475+
if withsuffixtrie is not None:
476+
attrs["withsuffixtrie"] = withsuffixtrie
337477

338478
def _prompt_numeric_attrs(
339479
self, attrs: Dict[str, Any], allow_blank: bool, sortable: Optional[bool]
@@ -439,6 +579,33 @@ def _prompt_vector_attrs(self, field: Dict[str, Any]) -> Dict[str, Any]:
439579
if ef_input and ef_input.isdigit():
440580
attrs["ef_construction"] = int(ef_input)
441581

582+
print(
583+
" EF_RUNTIME: query-time search depth (higher=better recall, slower queries)"
584+
)
585+
ef_runtime_input = input(
586+
f"EF_RUNTIME [current: {current.get('ef_runtime', 10)}]: "
587+
).strip()
588+
if ef_runtime_input and ef_runtime_input.isdigit():
589+
ef_runtime_val = int(ef_runtime_input)
590+
if ef_runtime_val > 0:
591+
attrs["ef_runtime"] = ef_runtime_val
592+
593+
print(
594+
" EPSILON: relative factor for range queries (0.0-1.0, lower=more accurate)"
595+
)
596+
epsilon_input = input(
597+
f"EPSILON [current: {current.get('epsilon', 0.01)}]: "
598+
).strip()
599+
if epsilon_input:
600+
try:
601+
epsilon_val = float(epsilon_input)
602+
if 0.0 <= epsilon_val <= 1.0:
603+
attrs["epsilon"] = epsilon_val
604+
else:
605+
print(" Epsilon must be between 0.0 and 1.0, ignoring.")
606+
except ValueError:
607+
print(" Invalid epsilon value, ignoring.")
608+
442609
elif effective_algo == "SVS-VAMANA":
443610
print(
444611
" GRAPH_MAX_DEGREE: max edges per node (higher=better recall, more memory)"

0 commit comments

Comments
 (0)