@@ -52,30 +52,18 @@ class QueryBuilder:
5252 TAG_SPECIAL_CHARS = r".,<>{}[]\"':;!@#$%^&*()-+=~"
5353
5454 # Characters that have special meaning in RediSearch free-text queries
55- # (outside of double-quoted phrases) and must be escaped with a backslash.
56- # Only characters likely to appear accidentally in user data are included;
57- # intentional RediSearch features (~, *, %, ^) are intentionally excluded.
58- TEXT_QUERY_SPECIAL_CHARS = frozenset ({"\\ " , "-" , "@" , "|" , "(" , ")" })
59-
60- @staticmethod
61- def _escape_text_value (value : str ) -> str :
62- """Escape characters that are special inside RediSearch double-quoted phrases.
63-
64- Backslashes and double quotes must be escaped so they don't break
65- the query syntax or alter its meaning.
66- """
67- # Escape backslashes first (so we don't double-escape the quote escapes),
68- # then escape double quotes.
69- return value .replace ("\\ " , "\\ \\ " ).replace ('"' , '\\ "' )
55+ # (outside double-quoted phrases). Must be escaped with backslash.
56+ # Includes double-quote to prevent starting/ending quoted phrases.
57+ TEXT_QUERY_SPECIAL_CHARS = set ('\\ |-()"@~!{}[]^$><=;:' )
7058
7159 @classmethod
7260 def _escape_fulltext_term (cls , term : str ) -> str :
7361 """Escape characters that have special meaning in RediSearch free-text queries.
7462
7563 Applied to individual terms used outside of double-quoted phrases (e.g.,
76- in parenthesized FULLTEXT expressions) so that user input containing
77- RediSearch operator characters like |, -, (, ), @ does not alter the
78- query semantics or produce syntax errors.
64+ in parenthesized FULLTEXT expressions, LIKE, FUZZY ) so that user input
65+ containing RediSearch operator characters does not alter query semantics
66+ or produce syntax errors.
7967 """
8068 result = []
8169 for char in term :
@@ -85,6 +73,17 @@ def _escape_fulltext_term(cls, term: str) -> str:
8573 result .append (char )
8674 return "" .join (result )
8775
76+ @staticmethod
77+ def _escape_text_value (value : str ) -> str :
78+ """Escape characters that are special inside RediSearch double-quoted phrases.
79+
80+ Backslashes and double quotes must be escaped so they don't break
81+ the query syntax or alter its meaning.
82+ """
83+ # Escape backslashes first (so we don't double-escape the quote escapes),
84+ # then escape double quotes.
85+ return value .replace ("\\ " , "\\ \\ " ).replace ('"' , '\\ "' )
86+
8887 def build_text_condition (
8988 self ,
9089 field : str | list [str ],
@@ -124,30 +123,35 @@ def build_text_condition(
124123 if operator in ("=" , "!=" ):
125124 escaped = self ._escape_text_value (value )
126125 return f'{ prefix } (@{ field_str } :"{ escaped } ")'
127- return f"{ prefix } (@{ field_str } :{ value } )"
126+ escaped = self ._escape_fulltext_term (value )
127+ return f"{ prefix } (@{ field_str } :{ escaped } )"
128128
129129 # Handle different operators
130130 if operator == "LIKE" :
131- # Convert SQL LIKE pattern (%) to RediSearch prefix/suffix/infix (*)
132- search_value = value .replace ("%" , "*" )
131+ # Escape special chars in the non-wildcard portion, then convert % → *
132+ # Split on %, escape each segment, rejoin with *
133+ parts = value .split ("%" )
134+ escaped_parts = [self ._escape_fulltext_term (p ) for p in parts ]
135+ search_value = "*" .join (escaped_parts )
133136 elif operator == "FUZZY" :
134- # Wrap with % signs — count determined by fuzzy_level
137+ # Escape special chars before wrapping with % markers
138+ escaped = self ._escape_fulltext_term (value )
135139 level = fuzzy_level if fuzzy_level is not None else 1
136140 if level not in (1 , 2 , 3 ):
137141 raise ValueError (
138142 f"Fuzzy level must be 1, 2, or 3 (got { level } ). "
139143 "RediSearch supports a maximum Levenshtein distance of 3."
140144 )
141145 pct = "%" * level
142- search_value = f"{ pct } { value } { pct } "
146+ search_value = f"{ pct } { escaped } { pct } "
143147 elif operator in ("=" , "!=" ):
144148 # Exact phrase match — always wrap in quotes, preserve stopwords.
145149 escaped = self ._escape_text_value (value )
146150 search_value = f'"{ escaped } "'
147151 elif " " in value and " OR " not in value :
148- # FULLTEXT with multi-word: tokenized search with stopword filtering.
149- # Each term is escaped to prevent RediSearch operator characters in
150- # user input from changing query semantics .
152+ # FULLTEXT/MATCH with multi-word: tokenized search with stopword filtering.
153+ # FULLTEXT is intentionally pass-through — users craft RediSearch queries
154+ # via fulltext(), so operator chars like ~, |, - are preserved .
151155 words = value .split ()
152156 removed_stopwords = [
153157 w for w in words if w .lower () in REDIS_DEFAULT_STOPWORDS
@@ -166,23 +170,14 @@ def build_text_condition(
166170 stacklevel = 2 ,
167171 )
168172
169- if filtered_words :
170- escaped_terms = " " .join (
171- self ._escape_fulltext_term (w ) for w in filtered_words
172- )
173- else :
174- # All words were stopwords; pass them through (escaped) so the
175- # query doesn't become empty. RediSearch will still skip them at
176- # query time, but this avoids a syntax error from an empty clause.
177- escaped_terms = " " .join (self ._escape_fulltext_term (w ) for w in words )
178- search_value = f"({ escaped_terms } )"
173+ terms = " " .join (filtered_words ) if filtered_words else value
174+ search_value = f"({ terms } )"
179175 elif " OR " in value :
180176 # OR union within text field: split on ' OR ' and join with |
181- or_terms = [
182- self ._escape_fulltext_term (t .strip ()) for t in value .split (" OR " )
183- ]
177+ or_terms = [t .strip () for t in value .split (" OR " )]
184178 search_value = f"({ '|' .join (or_terms )} )"
185179 else :
180+ # Single-word FULLTEXT — escape to prevent accidental operator injection
186181 search_value = self ._escape_fulltext_term (value )
187182
188183 base = f"{ prefix } @{ field } :{ search_value } "
0 commit comments