From a67894eea0223a36ad84e544c3c147d06abe2119 Mon Sep 17 00:00:00 2001 From: Daniel Sanche Date: Mon, 23 Mar 2026 16:41:57 -0700 Subject: [PATCH] feat: add new string-based pipeline expressions --- .../firestore_v1/pipeline_expressions.py | 193 +++++++++ .../tests/system/pipeline_e2e/string.yaml | 396 ++++++++++++++++++ .../unit/v1/test_pipeline_expressions.py | 110 +++++ 3 files changed, 699 insertions(+) diff --git a/packages/google-cloud-firestore/google/cloud/firestore_v1/pipeline_expressions.py b/packages/google-cloud-firestore/google/cloud/firestore_v1/pipeline_expressions.py index 2edd510070ca..2232b0d324e0 100644 --- a/packages/google-cloud-firestore/google/cloud/firestore_v1/pipeline_expressions.py +++ b/packages/google-cloud-firestore/google/cloud/firestore_v1/pipeline_expressions.py @@ -897,6 +897,199 @@ def if_error(self, then_value: Expression | CONSTANT_TYPE) -> "Expression": "if_error", [self, self._cast_to_expr_or_convert_to_constant(then_value)] ) + @expose_as_static + def regex_find(self, pattern: Expression | CONSTANT_TYPE) -> "Expression": + """Creates an expression that returns the first substring that matches the specified regex pattern. + + Example: + >>> # Get the first match of email + >>> Field.of("text").regex_find(r"\\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Z|a-z]{2,}\\b") + + Args: + pattern: The regular expression pattern to search for. + + Returns: + A new `Expression` finding the regex substring. + """ + return FunctionExpression( + "regex_find", [self, self._cast_to_expr_or_convert_to_constant(pattern)] + ) + + @expose_as_static + def regex_find_all(self, pattern: Expression | CONSTANT_TYPE) -> "Expression": + """Creates an expression that returns all substrings that match the specified regex pattern. + + Example: + >>> # Get all hashtags + >>> Field.of("post").regex_find_all(r"#[a-zA-Z]+\\b") + + Args: + pattern: The regular expression pattern to search for. + + Returns: + A new `Expression` finding all regex substrings. + """ + return FunctionExpression( + "regex_find_all", [self, self._cast_to_expr_or_convert_to_constant(pattern)] + ) + + @expose_as_static + def string_split(self, delimiter: Expression | CONSTANT_TYPE) -> "Expression": + """Creates an expression that splits a string by a delimiter. + + Example: + >>> # Split by comma + >>> Field.of("tags").string_split(",") + + Args: + delimiter: The delimiter to split by. + + Returns: + A new `Expression` representing the split string. + """ + return FunctionExpression( + "split", [self, self._cast_to_expr_or_convert_to_constant(delimiter)], infix_name_override="string_split" + ) + + @expose_as_static + def string_repeat(self, repetition: Expression | CONSTANT_TYPE) -> "Expression": + """Creates an expression that repeats a string a specified number of times. + + Example: + >>> # Repeat the string 3 times + >>> Field.of("name").string_repeat(3) + + Args: + repetition: The number of times to repeat the string. + + Returns: + A new `Expression` representing the repeated string. + """ + return FunctionExpression( + "string_repeat", + [self, self._cast_to_expr_or_convert_to_constant(repetition)], + ) + + @expose_as_static + def string_replace_all( + self, + search: Expression | CONSTANT_TYPE, + replacement: Expression | CONSTANT_TYPE, + ) -> "Expression": + """Creates an expression that replaces all occurrences of a search value with a replacement value. + + Example: + >>> # Replace 'user' with 'admin' + >>> Field.of("role").string_replace_all("user", "admin") + + Args: + search: The string to search for. + replacement: The string to replace it with. + + Returns: + A new `Expression` representing the string with all replacements made. + """ + return FunctionExpression( + "string_replace_all", + [ + self, + self._cast_to_expr_or_convert_to_constant(search), + self._cast_to_expr_or_convert_to_constant(replacement), + ], + ) + + @expose_as_static + def string_replace_one( + self, + search: Expression | CONSTANT_TYPE, + replacement: Expression | CONSTANT_TYPE, + ) -> "Expression": + """Creates an expression that replaces the first occurrence of a search value with a replacement value. + + Example: + >>> # Replace first 'apple' with 'orange' + >>> Field.of("fruits").string_replace_one("apple", "orange") + + Args: + search: The string to search for. + replacement: The string to replace it with. + + Returns: + A new `Expression` representing the string with the first replacement made. + """ + return FunctionExpression( + "string_replace_one", + [ + self, + self._cast_to_expr_or_convert_to_constant(search), + self._cast_to_expr_or_convert_to_constant(replacement), + ], + ) + + @expose_as_static + def string_index_of(self, search: Expression | CONSTANT_TYPE) -> "Expression": + """Creates an expression that returns the index of a search value in a string. + + Example: + >>> # Get the index of 'target' + >>> Field.of("text").string_index_of("target") + + Args: + search: The string to search for. + + Returns: + A new `Expression` representing the index of the string. + """ + return FunctionExpression( + "string_index_of", [self, self._cast_to_expr_or_convert_to_constant(search)] + ) + + @expose_as_static + def ltrim( + self, values_to_trim: Expression | CONSTANT_TYPE | None = None + ) -> "Expression": + """Creates an expression that removes leading whitespace (or specified characters) from a string. + + Example: + >>> # Trim leading spaces + >>> Field.of("text").ltrim() + >>> # Trim specific character + >>> Field.of("text").ltrim("-") + + Args: + values_to_trim: The substring or expression defining characters to trim. Defaults to None (trims whitespace). + + Returns: + A new `Expression` representing the left-trimmed string. + """ + args = [self] + if values_to_trim is not None: + args.append(self._cast_to_expr_or_convert_to_constant(values_to_trim)) + return FunctionExpression("ltrim", args) + + @expose_as_static + def rtrim( + self, values_to_trim: Expression | CONSTANT_TYPE | None = None + ) -> "Expression": + """Creates an expression that removes trailing whitespace (or specified characters) from a string. + + Example: + >>> # Trim trailing spaces + >>> Field.of("text").rtrim() + >>> # Trim specific character + >>> Field.of("text").rtrim("-") + + Args: + values_to_trim: The substring or expression defining characters to trim. Defaults to None (trims whitespace). + + Returns: + A new `Expression` representing the right-trimmed string. + """ + args = [self] + if values_to_trim is not None: + args.append(self._cast_to_expr_or_convert_to_constant(values_to_trim)) + return FunctionExpression("rtrim", args) + @expose_as_static def exists(self) -> "BooleanExpression": """Creates an expression that checks if a field exists in the document. diff --git a/packages/google-cloud-firestore/tests/system/pipeline_e2e/string.yaml b/packages/google-cloud-firestore/tests/system/pipeline_e2e/string.yaml index 20a97ba60663..ffae09cf1b25 100644 --- a/packages/google-cloud-firestore/tests/system/pipeline_e2e/string.yaml +++ b/packages/google-cloud-firestore/tests/system/pipeline_e2e/string.yaml @@ -652,3 +652,399 @@ tests: - stringValue: ", " name: join name: select + - description: RegexFind + pipeline: + - Collection: books + - Where: + - FunctionExpression.equal: + - Field: title + - Constant: "The Hitchhiker's Guide to the Galaxy" + - Select: + - AliasedExpression: + - FunctionExpression.regex_find: + - Field: author + - Constant: "Adams" + - "regexFound" + assert_proto: + pipeline: + stages: + - args: + - referenceValue: /books + name: collection + - args: + - functionValue: + args: + - fieldReferenceValue: title + - stringValue: "The Hitchhiker's Guide to the Galaxy" + name: equal + name: where + - args: + - mapValue: + fields: + regexFound: + functionValue: + args: + - fieldReferenceValue: author + - stringValue: "Adams" + name: regex_find + name: select + - description: RegexFindAll + pipeline: + - Collection: books + - Where: + - FunctionExpression.equal: + - Field: title + - Constant: "The Hitchhiker's Guide to the Galaxy" + - Select: + - AliasedExpression: + - FunctionExpression.regex_find_all: + - Field: author + - Constant: "[A-Za-z]+" + - "regexFoundAll" + assert_proto: + pipeline: + stages: + - args: + - referenceValue: /books + name: collection + - args: + - functionValue: + args: + - fieldReferenceValue: title + - stringValue: "The Hitchhiker's Guide to the Galaxy" + name: equal + name: where + - args: + - mapValue: + fields: + regexFoundAll: + functionValue: + args: + - fieldReferenceValue: author + - stringValue: "[A-Za-z]+" + name: regex_find_all + name: select + - description: StringSplit + pipeline: + - Collection: books + - Where: + - FunctionExpression.equal: + - Field: title + - Constant: "The Hitchhiker's Guide to the Galaxy" + - Select: + - AliasedExpression: + - FunctionExpression.string_split: + - Field: author + - Constant: " " + - "splitAuthor" + assert_proto: + pipeline: + stages: + - args: + - referenceValue: /books + name: collection + - args: + - functionValue: + args: + - fieldReferenceValue: title + - stringValue: "The Hitchhiker's Guide to the Galaxy" + name: equal + name: where + - args: + - mapValue: + fields: + splitAuthor: + functionValue: + args: + - fieldReferenceValue: author + - stringValue: " " + name: split + name: select + - description: StringRepeat + pipeline: + - Collection: books + - Where: + - FunctionExpression.equal: + - Field: title + - Constant: "The Hitchhiker's Guide to the Galaxy" + - Select: + - AliasedExpression: + - FunctionExpression.string_repeat: + - Field: author + - Constant: 2 + - "repeatedAuthor" + assert_proto: + pipeline: + stages: + - args: + - referenceValue: /books + name: collection + - args: + - functionValue: + args: + - fieldReferenceValue: title + - stringValue: "The Hitchhiker's Guide to the Galaxy" + name: equal + name: where + - args: + - mapValue: + fields: + repeatedAuthor: + functionValue: + args: + - fieldReferenceValue: author + - integerValue: '2' + name: string_repeat + name: select + - description: StringReplaceAll + pipeline: + - Collection: books + - Where: + - FunctionExpression.equal: + - Field: title + - Constant: "The Hitchhiker's Guide to the Galaxy" + - Select: + - AliasedExpression: + - FunctionExpression.string_replace_all: + - Field: author + - Constant: "s" + - Constant: "z" + - "replacedAuthor" + assert_proto: + pipeline: + stages: + - args: + - referenceValue: /books + name: collection + - args: + - functionValue: + args: + - fieldReferenceValue: title + - stringValue: "The Hitchhiker's Guide to the Galaxy" + name: equal + name: where + - args: + - mapValue: + fields: + replacedAuthor: + functionValue: + args: + - fieldReferenceValue: author + - stringValue: "s" + - stringValue: "z" + name: string_replace_all + name: select + - description: StringReplaceOne + pipeline: + - Collection: books + - Where: + - FunctionExpression.equal: + - Field: title + - Constant: "The Hitchhiker's Guide to the Galaxy" + - Select: + - AliasedExpression: + - FunctionExpression.string_replace_one: + - Field: author + - Constant: "a" + - Constant: "o" + - "replacedAuthorOne" + assert_proto: + pipeline: + stages: + - args: + - referenceValue: /books + name: collection + - args: + - functionValue: + args: + - fieldReferenceValue: title + - stringValue: "The Hitchhiker's Guide to the Galaxy" + name: equal + name: where + - args: + - mapValue: + fields: + replacedAuthorOne: + functionValue: + args: + - fieldReferenceValue: author + - stringValue: "a" + - stringValue: "o" + name: string_replace_one + name: select + - description: StringIndexOf + pipeline: + - Collection: books + - Where: + - FunctionExpression.equal: + - Field: title + - Constant: "The Hitchhiker's Guide to the Galaxy" + - Select: + - AliasedExpression: + - FunctionExpression.string_index_of: + - Field: author + - Constant: "Adams" + - "indexOf" + assert_proto: + pipeline: + stages: + - args: + - referenceValue: /books + name: collection + - args: + - functionValue: + args: + - fieldReferenceValue: title + - stringValue: "The Hitchhiker's Guide to the Galaxy" + name: equal + name: where + - args: + - mapValue: + fields: + indexOf: + functionValue: + args: + - fieldReferenceValue: author + - stringValue: "Adams" + name: string_index_of + name: select + - description: LTrim + pipeline: + - Collection: books + - Where: + - FunctionExpression.equal: + - Field: title + - Constant: "The Hitchhiker's Guide to the Galaxy" + - Select: + - AliasedExpression: + - FunctionExpression.ltrim: + - Field: author + - "trimmed" + assert_proto: + pipeline: + stages: + - args: + - referenceValue: /books + name: collection + - args: + - functionValue: + args: + - fieldReferenceValue: title + - stringValue: "The Hitchhiker's Guide to the Galaxy" + name: equal + name: where + - args: + - mapValue: + fields: + trimmed: + functionValue: + args: + - fieldReferenceValue: author + name: ltrim + name: select + - description: LTrimWithValues + pipeline: + - Collection: books + - Where: + - FunctionExpression.equal: + - Field: title + - Constant: "The Hitchhiker's Guide to the Galaxy" + - Select: + - AliasedExpression: + - FunctionExpression.ltrim: + - Field: author + - Constant: "D" + - "trimmedValues" + assert_proto: + pipeline: + stages: + - args: + - referenceValue: /books + name: collection + - args: + - functionValue: + args: + - fieldReferenceValue: title + - stringValue: "The Hitchhiker's Guide to the Galaxy" + name: equal + name: where + - args: + - mapValue: + fields: + trimmedValues: + functionValue: + args: + - fieldReferenceValue: author + - stringValue: "D" + name: ltrim + name: select + - description: RTrim + pipeline: + - Collection: books + - Where: + - FunctionExpression.equal: + - Field: title + - Constant: "The Hitchhiker's Guide to the Galaxy" + - Select: + - AliasedExpression: + - FunctionExpression.rtrim: + - Field: author + - "trimmed" + assert_proto: + pipeline: + stages: + - args: + - referenceValue: /books + name: collection + - args: + - functionValue: + args: + - fieldReferenceValue: title + - stringValue: "The Hitchhiker's Guide to the Galaxy" + name: equal + name: where + - args: + - mapValue: + fields: + trimmed: + functionValue: + args: + - fieldReferenceValue: author + name: rtrim + name: select + - description: RTrimWithValues + pipeline: + - Collection: books + - Where: + - FunctionExpression.equal: + - Field: title + - Constant: "The Hitchhiker's Guide to the Galaxy" + - Select: + - AliasedExpression: + - FunctionExpression.rtrim: + - Field: author + - Constant: "s" + - "trimmedValues" + assert_proto: + pipeline: + stages: + - args: + - referenceValue: /books + name: collection + - args: + - functionValue: + args: + - fieldReferenceValue: title + - stringValue: "The Hitchhiker's Guide to the Galaxy" + name: equal + name: where + - args: + - mapValue: + fields: + trimmedValues: + functionValue: + args: + - fieldReferenceValue: author + - stringValue: "s" + name: rtrim + name: select diff --git a/packages/google-cloud-firestore/tests/unit/v1/test_pipeline_expressions.py b/packages/google-cloud-firestore/tests/unit/v1/test_pipeline_expressions.py index f4804da25e14..df350c9e8393 100644 --- a/packages/google-cloud-firestore/tests/unit/v1/test_pipeline_expressions.py +++ b/packages/google-cloud-firestore/tests/unit/v1/test_pipeline_expressions.py @@ -1642,3 +1642,113 @@ def test_last(self): assert repr(instance) == "Value.last()" infix_instance = arg1.last() assert infix_instance == instance + + def test_regex_find(self): + arg1 = self._make_arg("Value") + arg2 = self._make_arg("Pattern") + instance = Expression.regex_find(arg1, arg2) + assert instance.name == "regex_find" + assert instance.params == [arg1, arg2] + assert repr(instance) == "Value.regex_find(Pattern)" + infix_instance = arg1.regex_find(arg2) + assert infix_instance == instance + + def test_regex_find_all(self): + arg1 = self._make_arg("Value") + arg2 = self._make_arg("Pattern") + instance = Expression.regex_find_all(arg1, arg2) + assert instance.name == "regex_find_all" + assert instance.params == [arg1, arg2] + assert repr(instance) == "Value.regex_find_all(Pattern)" + infix_instance = arg1.regex_find_all(arg2) + assert infix_instance == instance + + def test_string_split(self): + arg1 = self._make_arg("Value") + arg2 = self._make_arg("Delimiter") + instance = Expression.string_split(arg1, arg2) + assert instance.name == "split" + assert instance.params == [arg1, arg2] + assert repr(instance) == "Value.string_split(Delimiter)" + infix_instance = arg1.string_split(arg2) + assert infix_instance == instance + + def test_string_repeat(self): + arg1 = self._make_arg("Value") + arg2 = self._make_arg("Repetition") + instance = Expression.string_repeat(arg1, arg2) + assert instance.name == "string_repeat" + assert instance.params == [arg1, arg2] + assert repr(instance) == "Value.string_repeat(Repetition)" + infix_instance = arg1.string_repeat(arg2) + assert infix_instance == instance + + def test_string_replace_all(self): + arg1 = self._make_arg("Value") + arg2 = self._make_arg("Search") + arg3 = self._make_arg("Replacement") + instance = Expression.string_replace_all(arg1, arg2, arg3) + assert instance.name == "string_replace_all" + assert instance.params == [arg1, arg2, arg3] + assert repr(instance) == "Value.string_replace_all(Search, Replacement)" + infix_instance = arg1.string_replace_all(arg2, arg3) + assert infix_instance == instance + + def test_string_replace_one(self): + arg1 = self._make_arg("Value") + arg2 = self._make_arg("Search") + arg3 = self._make_arg("Replacement") + instance = Expression.string_replace_one(arg1, arg2, arg3) + assert instance.name == "string_replace_one" + assert instance.params == [arg1, arg2, arg3] + assert repr(instance) == "Value.string_replace_one(Search, Replacement)" + infix_instance = arg1.string_replace_one(arg2, arg3) + assert infix_instance == instance + + def test_string_index_of(self): + arg1 = self._make_arg("Value") + arg2 = self._make_arg("Search") + instance = Expression.string_index_of(arg1, arg2) + assert instance.name == "string_index_of" + assert instance.params == [arg1, arg2] + assert repr(instance) == "Value.string_index_of(Search)" + infix_instance = arg1.string_index_of(arg2) + assert infix_instance == instance + + def test_ltrim(self): + arg1 = self._make_arg("Value") + instance = Expression.ltrim(arg1) + assert instance.name == "ltrim" + assert instance.params == [arg1] + assert repr(instance) == "Value.ltrim()" + infix_instance = arg1.ltrim() + assert infix_instance == instance + + def test_ltrim_with_values(self): + arg1 = self._make_arg("Value") + arg2 = self._make_arg("ValuesToTrim") + instance = Expression.ltrim(arg1, arg2) + assert instance.name == "ltrim" + assert instance.params == [arg1, arg2] + assert repr(instance) == "Value.ltrim(ValuesToTrim)" + infix_instance = arg1.ltrim(arg2) + assert infix_instance == instance + + def test_rtrim(self): + arg1 = self._make_arg("Value") + instance = Expression.rtrim(arg1) + assert instance.name == "rtrim" + assert instance.params == [arg1] + assert repr(instance) == "Value.rtrim()" + infix_instance = arg1.rtrim() + assert infix_instance == instance + + def test_rtrim_with_values(self): + arg1 = self._make_arg("Value") + arg2 = self._make_arg("ValuesToTrim") + instance = Expression.rtrim(arg1, arg2) + assert instance.name == "rtrim" + assert instance.params == [arg1, arg2] + assert repr(instance) == "Value.rtrim(ValuesToTrim)" + infix_instance = arg1.rtrim(arg2) + assert infix_instance == instance