diff --git a/docs/vars/var-operations.md b/docs/vars/var-operations.md index 11e48623c6e..b6b1f38ff77 100644 --- a/docs/vars/var-operations.md +++ b/docs/vars/var-operations.md @@ -487,6 +487,8 @@ def var_string_example(): ) ``` +An optional string var (`str | None`) holding `None` acts as an empty string in the string methods (`lower`, `upper`, `title`, `capitalize`, `strip`, `split`, `contains`, `startswith`, `endswith`, `replace`, `length`, indexing, slicing and `*`): each gives what it gives for `""`. For example, `lower` gives an empty string, `contains("a")` is false, `length` is 0 and `split(" ")` gives `[""]`. Concatenation with `+` and the comparison operators are not covered. + ## Get Item (Indexing) Indexing is only supported for strings, lists, tuples, dicts, and dataframes. To index into a state var strict type annotations are required. diff --git a/packages/reflex-base/news/+optional-str-none.bugfix.md b/packages/reflex-base/news/+optional-str-none.bugfix.md new file mode 100644 index 00000000000..b9ee8e29826 --- /dev/null +++ b/packages/reflex-base/news/+optional-str-none.bugfix.md @@ -0,0 +1 @@ +Methods of an optional string var (`str | None`) holding `None` no longer crash the page or the production prerender: the string acts as an empty string, so each method gives what it gives for `""`. For example, `lower` gives an empty string, `contains("a")` is false and `split(" ")` gives `[""]`. Before, the methods compiled to a native string method (`split` with a separator, `strip`, `lower`, `upper`, `contains`, ...) threw `Cannot read properties of null`, while `length`, indexing and slicing already read `None` as empty. Concatenation with `+` and the comparison operators are unchanged. diff --git a/packages/reflex-base/src/reflex_base/vars/sequence.py b/packages/reflex-base/src/reflex_base/vars/sequence.py index 6f68881b99b..84f537b2d0c 100644 --- a/packages/reflex-base/src/reflex_base/vars/sequence.py +++ b/packages/reflex-base/src/reflex_base/vars/sequence.py @@ -783,6 +783,18 @@ def create( class StringVar(Var[STRING_TYPE], python_types=str): """Base class for immutable string vars.""" + def _or_empty(self) -> StringVar[Any]: + """View an optional string as an empty string while it holds None. + + Returns: + The string, guarded against None if its type allows None. + """ + var_type = self._var_type + # Most strings are plain ``str``: skip the union check for them. + if var_type is str or not types.is_optional(var_type): + return self + return string_or_empty_operation(self) + def __add__(self, other: StringVar | str) -> ConcatVarOperation: """Concatenate two strings. @@ -825,7 +837,7 @@ def __mul__(self, other: NumberVar | int) -> StringVar: ): raise_unsupported_operand_types("*", (type(self), type(other))) - return string_repeat_operation(self, other) + return string_repeat_operation(self._or_empty(), other) def __rmul__(self, other: NumberVar | int) -> StringVar: """Multiply the sequence by a number or an integer. @@ -841,7 +853,7 @@ def __rmul__(self, other: NumberVar | int) -> StringVar: ): raise_unsupported_operand_types("*", (type(other), type(self))) - return string_repeat_operation(self, other) + return string_repeat_operation(self._or_empty(), other) @overload def __getitem__(self, i: slice) -> StringVar: ... @@ -882,7 +894,7 @@ def lower(self) -> StringVar: Returns: The string lower operation. """ - return string_lower_operation(self) + return string_lower_operation(self._or_empty()) def lstrip(self, chars: StringVar | str | None = None) -> StringVar: """Left strip the string. @@ -896,7 +908,7 @@ def lstrip(self, chars: StringVar | str | None = None) -> StringVar: if chars is not None and not isinstance(chars, (StringVar, str)): raise_unsupported_operand_types("lstrip", (type(self), type(chars))) - return string_lstrip_operation(self, chars) + return string_lstrip_operation(self._or_empty(), chars) def upper(self) -> StringVar: """Convert the string to uppercase. @@ -904,7 +916,7 @@ def upper(self) -> StringVar: Returns: The string upper operation. """ - return string_upper_operation(self) + return string_upper_operation(self._or_empty()) def title(self) -> StringVar: """Convert the string to title case. @@ -912,7 +924,7 @@ def title(self) -> StringVar: Returns: The string title operation. """ - return string_title_operation(self) + return string_title_operation(self._or_empty()) def capitalize(self) -> StringVar: """Capitalize the string. @@ -920,7 +932,7 @@ def capitalize(self) -> StringVar: Returns: The string capitalize operation. """ - return string_capitalize_operation(self) + return string_capitalize_operation(self._or_empty()) def strip(self, chars: StringVar | str | None = None) -> StringVar: """Strip the string. @@ -934,7 +946,7 @@ def strip(self, chars: StringVar | str | None = None) -> StringVar: if chars is not None and not isinstance(chars, (StringVar, str)): raise_unsupported_operand_types("strip", (type(self), type(chars))) - return string_strip_operation(self, chars) + return string_strip_operation(self._or_empty(), chars) def reversed(self) -> StringVar: """Reverse the string. @@ -956,7 +968,7 @@ def rstrip(self, chars: StringVar | str | None = None) -> StringVar: if chars is not None and not isinstance(chars, (StringVar, str)): raise_unsupported_operand_types("rstrip", (type(self), type(chars))) - return string_rstrip_operation(self, chars) + return string_rstrip_operation(self._or_empty(), chars) def contains( self, other: StringVar | str, field: StringVar | str | None = None @@ -976,7 +988,7 @@ def contains( if not isinstance(field, (StringVar, str)): raise_unsupported_operand_types("contains", (type(self), type(field))) return string_contains_field_operation(self, other, field) - return string_contains_operation(self, other) + return string_contains_operation(self._or_empty(), other) def split(self, separator: StringVar | str = "") -> ArrayVar[list[str]]: """Split the string. @@ -1002,7 +1014,7 @@ def startswith(self, prefix: StringVar | str) -> BooleanVar: """ if not isinstance(prefix, (StringVar, str)): raise_unsupported_operand_types("startswith", (type(self), type(prefix))) - return string_starts_with_operation(self, prefix) + return string_starts_with_operation(self._or_empty(), prefix) def endswith(self, suffix: StringVar | str) -> BooleanVar: """Check if the string ends with a suffix. @@ -1015,7 +1027,7 @@ def endswith(self, suffix: StringVar | str) -> BooleanVar: """ if not isinstance(suffix, (StringVar, str)): raise_unsupported_operand_types("endswith", (type(self), type(suffix))) - return string_ends_with_operation(self, suffix) + return string_ends_with_operation(self._or_empty(), suffix) def __lt__(self, other: StringVar | str) -> BooleanVar: """Check if the string is less than another string. @@ -1098,7 +1110,7 @@ def replace(self, search_value: Any, new_value: Any) -> StringVar: # pyright: i if not isinstance(new_value, (StringVar, str)): raise_unsupported_operand_types("replace", (type(self), type(new_value))) - return string_replace_operation(self, search_value, new_value) + return string_replace_operation(self._or_empty(), search_value, new_value) @var_operation @@ -1157,6 +1169,22 @@ def string_ge_operation(lhs: StringVar[Any] | str, rhs: StringVar[Any] | str): return var_operation_return(js_expression=f"{lhs!s} >= {rhs!s}", var_type=bool) +@var_operation +def string_or_empty_operation(string: Var) -> CustomVarOperationReturn[str]: + """View an optional string as an empty string while it holds None. + + Args: + string: The optional string. + + Returns: + The guarded string operation, typed without None. + """ + return var_operation_return( + js_expression=f'({string!s} ?? "")', + var_type=types.value_inside_optional(string._var_type), + ) + + @var_operation def string_lower_operation(string: StringVar[Any]): """Convert a string to lowercase. @@ -1771,13 +1799,14 @@ def string_split_operation(string: StringVar[Any], sep: StringVar | str = ""): The split string. An empty separator splits by code points. """ if not isinstance(sep, LiteralStringVar): - js_expression = f"pyStrSplit({string!s}, {sep!s})" + js_expression = f"pyStrSplit({string._or_empty()!s}, {sep!s})" var_data = VarData(imports=_PY_STR_SPLIT_IMPORT) elif sep._var_value == "": + # pyStrChars reads null as an empty string itself. js_expression = f"pyStrChars({string!s})" var_data = VarData(imports=_PY_STR_CHARS_IMPORT) else: - js_expression = f"{string!s}.split({sep!s})" + js_expression = f"{string._or_empty()!s}.split({sep!s})" var_data = None return var_operation_return( js_expression=js_expression, var_type=list[str], var_data=var_data diff --git a/tests/integration/tests_playwright/test_var_operations.py b/tests/integration/tests_playwright/test_var_operations.py index 35671e5a6a0..71cd9c42acd 100644 --- a/tests/integration/tests_playwright/test_var_operations.py +++ b/tests/integration/tests_playwright/test_var_operations.py @@ -44,6 +44,8 @@ class VarOperationState(rx.State): optional_dict: rx.Field[dict[str, str] | None] = rx.field(None) optional_list_value: rx.Field[list[str] | None] = rx.field(["red", "yellow"]) optional_dict_value: rx.Field[dict[str, str] | None] = rx.field({"name": "red"}) + optional_str: rx.Field[str | None] = rx.field(None) + optional_str_value: rx.Field[str | None] = rx.field("Some Text") str_var1: rx.Field[str] = rx.field("first") str_var2: rx.Field[str] = rx.field("second") str_var3: rx.Field[str] = rx.field("ThIrD") @@ -823,6 +825,40 @@ def index(): rx.foreach(VarOperationState.optional_list_value, rx.text.span), id="optional_list_value", ), + # Operations on an optional string holding None read it as empty. + rx.text(VarOperationState.optional_str.lower(), id="optional_str_lower"), + rx.text(VarOperationState.optional_str.strip(), id="optional_str_strip"), + rx.text( + VarOperationState.optional_str.split(" ").to_string(), + id="optional_str_split", + ), + rx.text( + VarOperationState.optional_str.split( + VarOperationState.strip_chars + ).to_string(), + id="optional_str_split_var", + ), + rx.text( + VarOperationState.optional_str.contains("a"), + id="optional_str_contains", + ), + rx.text( + VarOperationState.optional_str.startswith("a"), + id="optional_str_startswith", + ), + rx.text( + VarOperationState.optional_str.replace("a", "b"), + id="optional_str_replace", + ), + rx.text(VarOperationState.optional_str * 2, id="optional_str_repeat"), + rx.text( + VarOperationState.optional_str_value.upper(), + id="optional_str_value_upper", + ), + rx.text( + VarOperationState.optional_str_value.split(" ").to_string(), + id="optional_str_value_split", + ), rx.box( rx.foreach(VarOperationState.optional_dict_value, rx.text.span), id="optional_dict_value", @@ -1160,6 +1196,16 @@ def test_var_operations(driver, var_operations: AppHarness): ("optional_list_join", ""), ("optional_list_value_length", "2"), ("optional_list_value_contains", "true"), + ("optional_str_lower", ""), + ("optional_str_strip", ""), + ("optional_str_split", '[""]'), + ("optional_str_split_var", '[""]'), + ("optional_str_contains", "false"), + ("optional_str_startswith", "false"), + ("optional_str_replace", ""), + ("optional_str_repeat", ""), + ("optional_str_value_upper", "SOME TEXT"), + ("optional_str_value_split", '["Some","Text"]'), ] for tag, expected in tests: diff --git a/tests/units/reflex_base/vars/test_sequence.py b/tests/units/reflex_base/vars/test_sequence.py index 4e0c38fb79b..35abcf341c1 100644 --- a/tests/units/reflex_base/vars/test_sequence.py +++ b/tests/units/reflex_base/vars/test_sequence.py @@ -6,7 +6,7 @@ import pytest from reflex_base.vars.base import Var -from reflex_base.vars.sequence import ArrayVar +from reflex_base.vars.sequence import ArrayVar, StringVar def _array(var_type: Any, js_expr: str = "state.maybe") -> ArrayVar: @@ -62,3 +62,71 @@ def test_optional_array_operation_results(): # Indexing is already null-safe, and the var itself still reads as None. assert str(maybe[0]) == "state.maybe?.at?.(0)" assert str(maybe.is_none()) == "!(isNotNullOrUndefined(state.maybe))" + + +def _string(var_type: Any, js_expr: str = "state.maybe") -> StringVar: + var = Var(_js_expr=js_expr, _var_type=var_type).guess_type() + assert isinstance(var, StringVar) + return var + + +_SEP = Var(_js_expr="state.sep").to(str) + +# Each operation with the JS it compiles to, given the JS of the string it reads. +_STRING_OPERATIONS: list[tuple[Callable[[StringVar], Var], str]] = [ + (lambda string: string.lower(), "{}.toLowerCase()"), + (lambda string: string.upper(), "{}.toUpperCase()"), + ( + lambda string: string.title(), + ( + "{}.split(' ').map(word => word.charAt(0).toUpperCase()" + " + word.slice(1).toLowerCase()).join(' ')" + ), + ), + ( + lambda string: string.capitalize(), + "(((s) => s.charAt(0).toUpperCase() + s.slice(1).toLowerCase())({}))", + ), + (lambda string: string.strip(), "pyStrip({}, null)"), + (lambda string: string.lstrip("-"), 'pyLstrip({}, "-")'), + (lambda string: string.rstrip(_SEP), "pyRstrip({}, state.sep)"), + (lambda string: string.contains("x"), '{}.includes("x")'), + (lambda string: string.split(" "), '{}.split(" ")'), + (lambda string: string.split(_SEP), "pyStrSplit({}, state.sep)"), + (lambda string: string.startswith("x"), '{}.startsWith("x")'), + (lambda string: string.endswith("x"), '{}.endsWith("x")'), + (lambda string: string.replace("x", "y"), '{}.replaceAll("x", "y")'), + (lambda string: string * 2, "{}.repeat(Math.max(0, 2))"), + (lambda string: 2 * string, "{}.repeat(Math.max(0, 2))"), +] + + +@pytest.mark.parametrize(("operation", "expected"), _STRING_OPERATIONS) +def test_optional_string_operations_treat_none_as_empty( + operation: Callable[[StringVar], Var], expected: str +): + """Every operation on an optional string reads a null value as an empty string.""" + assert str(operation(_string(str | None))) == expected.format('(state.maybe ?? "")') + + +@pytest.mark.parametrize(("operation", "expected"), _STRING_OPERATIONS) +def test_string_operations_do_not_guard_a_required_string( + operation: Callable[[StringVar], Var], expected: str +): + """A string that cannot be None compiles without the null guard.""" + assert str(operation(_string(str))) == expected.format("state.maybe") + + +def test_optional_string_operation_results(): + """The null guard drops None from the result type; null-safe helpers need none.""" + maybe = _string(str | None) + assert maybe.lower()._var_type is str + assert maybe.split(" ")._var_type == list[str] + # The code point helpers already read null as an empty string. + assert str(maybe.length()) == "pyStrLength(state.maybe)" + assert str(maybe[0]) == "pyStrAt(state.maybe, 0)" + assert str(maybe[1:]) == "pyStrSlice(state.maybe, 1, null)" + assert str(maybe.split()) == "pyStrChars(state.maybe)" + assert str(maybe.reversed()) == 'pyStrChars(state.maybe).slice().reverse().join("")' + # The var itself still reads as None. + assert str(maybe.is_none()) == "!(isNotNullOrUndefined(state.maybe))"