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 31fa88f7c6fe..1c6de5cc8ba7 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 @@ -25,6 +25,7 @@ from typing import ( TYPE_CHECKING, Any, + Callable, Generic, Sequence, TypeVar, @@ -193,7 +194,7 @@ class Expression(ABC): """Represents an expression that can be evaluated to a value within the execution of a pipeline. - Expressionessions are the building blocks for creating complex queries and + Expressions are the building blocks for creating complex queries and transformations in Firestore pipelines. They can represent: - **Field references:** Access values from document fields. @@ -569,7 +570,7 @@ def logical_maximum(self, *others: Expression | CONSTANT_TYPE) -> "Expression": return FunctionExpression( "maximum", [self] + [self._cast_to_expr_or_convert_to_constant(o) for o in others], - infix_name_override="logical_maximum", + repr_function=FunctionExpression._build_infix_repr("logical_maximum"), ) @expose_as_static @@ -595,7 +596,7 @@ def logical_minimum(self, *others: Expression | CONSTANT_TYPE) -> "Expression": return FunctionExpression( "minimum", [self] + [self._cast_to_expr_or_convert_to_constant(o) for o in others], - infix_name_override="logical_minimum", + repr_function=FunctionExpression._build_infix_repr("logical_minimum"), ) @expose_as_static @@ -841,6 +842,9 @@ def array_get(self, offset: Expression | int) -> "FunctionExpression": Creates an expression that indexes into an array from the beginning or end and returns the element. A negative offset starts from the end. + If the expression is evaluated against a non-array type, it evaluates to an error. See `offset` + for an alternative that evaluates to unset instead. + Example: >>> Array([1,2,3]).array_get(0) @@ -854,6 +858,26 @@ def array_get(self, offset: Expression | int) -> "FunctionExpression": "array_get", [self, self._cast_to_expr_or_convert_to_constant(offset)] ) + @expose_as_static + def offset(self, offset: Expression | int) -> "FunctionExpression": + """ + Creates an expression that indexes into an array from the beginning or end and returns the + element. A negative offset starts from the end. + If the expression is evaluated against a non-array type, it evaluates to unset. + + Example: + >>> Array([1,2,3]).offset(0) + + Args: + offset: the index of the element to return + + Returns: + A new `Expression` representing the `offset` operation. + """ + return FunctionExpression( + "offset", [self, self._cast_to_expr_or_convert_to_constant(offset)] + ) + @expose_as_static def array_contains( self, element: Expression | CONSTANT_TYPE @@ -957,6 +981,73 @@ def array_reverse(self) -> "Expression": """ return FunctionExpression("array_reverse", [self]) + @expose_as_static + def array_filter( + self, + filter_expr: "BooleanExpression", + element_alias: str | Constant[str], + ) -> "Expression": + """Filters an array based on a predicate. + + Example: + >>> # Filter the 'tags' array to only include the tag "comedy" + >>> Field.of("tags").array_filter(Variable("tag").equal("comedy"), "tag") + + Args: + filter_expr: The predicate boolean expression used to filter the elements. + element_alias: A string or string constant used to refer to the current array + element as a variable within the filter expression. + + + Returns: + A new `Expression` representing the filtered array. + """ + args = [self, self._cast_to_expr_or_convert_to_constant(element_alias)] + args.append(filter_expr) + + repr_func = ( + lambda expr: f"{expr.params[0]!r}.{expr.name}({expr.params[2]!r}, {expr.params[1]!r})" + ) + return FunctionExpression("array_filter", args, repr_function=repr_func) + + @expose_as_static + def array_transform( + self, + transform_expr: "Expression", + element_alias: str | Constant[str], + index_alias: str | Constant[str] | None = None, + ) -> "Expression": + """Creates an expression that applies a provided transformation to each element in an array. + + Example: + >>> # Convert each tag in the 'tags' array to uppercase + >>> Field.of("tags").array_transform(Variable("tag").to_upper(), "tag") + >>> # Append the index to each tag in the 'tags' array + >>> Field.of("tags").array_transform( + ... Variable("tag").string_concat(Variable("i")), + ... element_alias="tag", index_alias="i" + ... ) + + Args: + transform_expr: The expression used to transform the elements. + element_alias: A string or string constant used to refer to the current array + element as a variable within the transform expression. + index_alias: An optional string or string constant used to refer to the index + of the current array element as a variable within the transform expression. + + Returns: + A new `Expression` representing the transformed array. + """ + args = [self, self._cast_to_expr_or_convert_to_constant(element_alias)] + if index_alias is not None: + args.append(self._cast_to_expr_or_convert_to_constant(index_alias)) + args.append(transform_expr) + + repr_func = ( + lambda expr: f"{expr.params[0]!r}.{expr.name}({expr.params[-1]!r}, {expr.params[1]!r}{', ' + repr(expr.params[2]) if len(expr.params) == 4 else ''})" + ) + return FunctionExpression("array_transform", args, repr_function=repr_func) + @expose_as_static def array_concat( self, *other_arrays: Array | list[Expression | CONSTANT_TYPE] | Expression @@ -1018,7 +1109,7 @@ def is_absent(self) -> "BooleanExpression": >>> Field.of("email").is_absent() Returns: - A new `BooleanExpressionession` representing the isAbsent operation. + A new `BooleanExpression` representing the isAbsent operation. """ return BooleanExpression("is_absent", [self]) @@ -1086,6 +1177,76 @@ def exists(self) -> "BooleanExpression": """ return BooleanExpression("exists", [self]) + @expose_as_static + def coalesce(self, *others: Expression | CONSTANT_TYPE) -> "Expression": + """Creates an expression that evaluates to the first non-null/non-missing value. + + Example: + >>> # Return the "preferredName" field if it exists. + >>> # Otherwise, check the "fullName" field. + >>> # Otherwise, return the literal string "Anonymous". + >>> Field.of("preferredName").coalesce(Field.of("fullName"), "Anonymous") + + >>> # Equivalent static call: + >>> Expression.coalesce(Field.of("preferredName"), Field.of("fullName"), "Anonymous") + + Args: + *others: Additional expressions or constants to evaluate if the current + expression evaluates to null or is missing. + + Returns: + An Expression representing the coalesce operation. + """ + return FunctionExpression( + "coalesce", + [self] + + [Expression._cast_to_expr_or_convert_to_constant(x) for x in others], + ) + + @expose_as_static + def switch_on( + self, result: Expression | CONSTANT_TYPE, *others: Expression | CONSTANT_TYPE + ) -> "Expression": + """Creates an expression that evaluates to the result corresponding to the first true condition. + + This function behaves like a `switch` statement. It accepts an alternating sequence of + conditions and their corresponding results. If an odd number of arguments is provided, the + final argument serves as a default fallback result. If no default is provided and no condition + evaluates to true, it throws an error. + + Example: + >>> # Return "Pending" if status is 1, "Active" if status is 2, otherwise "Unknown" + >>> Field.of("status").equal(1).switch_on( + ... "Pending", Field.of("status").equal(2), "Active", "Unknown" + ... ) + + Args: + result: The result to return if this condition is true. + *others: Additional alternating conditions and results, optionally followed by a default value. + + Returns: + An Expression representing the "switch_on" operation. + """ + return FunctionExpression( + "switch_on", + [self, Expression._cast_to_expr_or_convert_to_constant(result)] + + [Expression._cast_to_expr_or_convert_to_constant(x) for x in others], + ) + + @expose_as_static + def storage_size(self) -> "Expression": + """Calculates the Firestore storage size of a given value. + + Mirrors the sizing rules detailed in Firebase/Firestore documentation. + + Example: + >>> Field.of("content").storage_size() + + Returns: + A new `Expression` representing the storage size. + """ + return FunctionExpression("storage_size", [self]) + @expose_as_static def sum(self) -> "Expression": """Creates an aggregation that calculates the sum of a numeric field across multiple stage inputs. @@ -1446,6 +1607,7 @@ def join(self, delimeter: Expression | str) -> "Expression": @expose_as_static def map_get(self, key: str | Constant[str]) -> "Expression": """Accesses a value from the map produced by evaluating this expression. + If the expression is evaluated against a non-map type, it evaluates to an error. Example: >>> Map({"city": "London"}).map_get("city") @@ -2051,7 +2213,9 @@ def array_maximum(self) -> "Expression": A new `Expression` representing the maximum element of the array. """ return FunctionExpression( - "maximum", [self], infix_name_override="array_maximum" + "maximum", + [self], + repr_function=FunctionExpression._build_infix_repr("array_maximum"), ) @expose_as_static @@ -2066,7 +2230,9 @@ def array_minimum(self) -> "Expression": A new `Expression` representing the minimum element of the array. """ return FunctionExpression( - "minimum", [self], infix_name_override="array_minimum" + "minimum", + [self], + repr_function=FunctionExpression._build_infix_repr("array_minimum"), ) @expose_as_static @@ -2088,7 +2254,7 @@ def array_maximum_n(self, n: int | "Expression") -> "Expression": return FunctionExpression( "maximum_n", [self, self._cast_to_expr_or_convert_to_constant(n)], - infix_name_override="array_maximum_n", + repr_function=FunctionExpression._build_infix_repr("array_maximum_n"), ) @expose_as_static @@ -2110,7 +2276,7 @@ def array_minimum_n(self, n: int | "Expression") -> "Expression": return FunctionExpression( "minimum_n", [self, self._cast_to_expr_or_convert_to_constant(n)], - infix_name_override="array_minimum_n", + repr_function=FunctionExpression._build_infix_repr("array_minimum_n"), ) @expose_as_static @@ -2580,13 +2746,11 @@ def __init__( name: str, params: Sequence[Expression], *, - use_infix_repr: bool = True, - infix_name_override: str | None = None, + repr_function: Callable[["FunctionExpression"], str] | None = None, ): self.name = name self.params = list(params) - self._use_infix_repr = use_infix_repr - self._infix_name_override = infix_name_override + self._repr_function = repr_function or self._build_infix_repr() def __repr__(self): """ @@ -2594,15 +2758,7 @@ def __repr__(self): Display them this way in the repr string where possible """ - if self._use_infix_repr: - infix_name = self._infix_name_override or self.name - if len(self.params) == 1: - return f"{self.params[0]!r}.{infix_name}()" - elif len(self.params) == 2: - return f"{self.params[0]!r}.{infix_name}({self.params[1]!r})" - else: - return f"{self.params[0]!r}.{infix_name}({', '.join([repr(p) for p in self.params[1:]])})" - return f"{self.__class__.__name__}({', '.join([repr(p) for p in self.params])})" + return self._repr_function(self) def __eq__(self, other): if not isinstance(other, FunctionExpression): @@ -2610,6 +2766,46 @@ def __eq__(self, other): else: return other.name == self.name and other.params == self.params + @staticmethod + def _build_infix_repr( + name_override: str | None = None, + ) -> Callable[["FunctionExpression"], str]: + """Creates a repr_function that displays a FunctionExpression using infix notation. + + Example: + `value.greater_than(18)` + """ + + def build_repr(expr): + final_name = name_override or expr.name + args = expr.params + if len(args) == 0: + return f"{final_name}()" + elif len(args) == 1: + return f"{args[0]!r}.{final_name}()" + elif len(args) == 2: + return f"{args[0]!r}.{final_name}({args[1]!r})" + else: + return f"{args[0]!r}.{final_name}({', '.join([repr(a) for a in args[1:]])})" + + return build_repr + + @staticmethod + def _build_standalone_repr( + name_override: str | None = None, + ) -> Callable[["FunctionExpression"], str]: + """Creates a repr_function that displays a FunctionExpression using standalone function notation. + + Example: + `GreaterThan(value, 18)` + """ + + def build_repr(expr): + final_name = name_override or expr.__class__.__name__ + return f"{final_name}({', '.join([repr(a) for a in expr.params])})" + + return build_repr + def _to_pb(self): return Value( function_value={ @@ -2863,7 +3059,9 @@ class And(BooleanExpression): """ def __init__(self, *conditions: "BooleanExpression"): - super().__init__("and", conditions, use_infix_repr=False) + super().__init__( + "and", conditions, repr_function=FunctionExpression._build_standalone_repr() + ) class Not(BooleanExpression): @@ -2879,7 +3077,11 @@ class Not(BooleanExpression): """ def __init__(self, condition: BooleanExpression): - super().__init__("not", [condition], use_infix_repr=False) + super().__init__( + "not", + [condition], + repr_function=FunctionExpression._build_standalone_repr(), + ) class Or(BooleanExpression): @@ -2896,7 +3098,27 @@ class Or(BooleanExpression): """ def __init__(self, *conditions: "BooleanExpression"): - super().__init__("or", conditions, use_infix_repr=False) + super().__init__( + "or", conditions, repr_function=FunctionExpression._build_standalone_repr() + ) + + +class Nor(BooleanExpression): + """ + Represents an expression that performs a logical 'NOR' operation on multiple filter conditions. + + Example: + >>> # Check if neither the 'age' field is greater than 18 nor the 'city' field is "London" + >>> Nor(Field.of("age").greater_than(18), Field.of("city").equal("London")) + + Args: + *conditions: The filter conditions to 'NOR' together. + """ + + def __init__(self, *conditions: "BooleanExpression"): + super().__init__( + "nor", conditions, repr_function=FunctionExpression._build_standalone_repr() + ) class Xor(BooleanExpression): @@ -2913,7 +3135,9 @@ class Xor(BooleanExpression): """ def __init__(self, conditions: Sequence["BooleanExpression"]): - super().__init__("xor", conditions, use_infix_repr=False) + super().__init__( + "xor", conditions, repr_function=FunctionExpression._build_standalone_repr() + ) class Conditional(BooleanExpression): @@ -2935,7 +3159,9 @@ def __init__( self, condition: BooleanExpression, then_expr: Expression, else_expr: Expression ): super().__init__( - "conditional", [condition, then_expr, else_expr], use_infix_repr=False + "conditional", + [condition, then_expr, else_expr], + repr_function=FunctionExpression._build_standalone_repr(), ) @@ -2956,7 +3182,13 @@ class Count(AggregateFunction): def __init__(self, expression: Expression | None = None): expression_list = [expression] if expression else [] - super().__init__("count", expression_list, use_infix_repr=bool(expression_list)) + super().__init__( + "count", + expression_list, + repr_function=FunctionExpression._build_infix_repr() + if expression_list + else FunctionExpression._build_standalone_repr(), + ) class CurrentTimestamp(FunctionExpression): @@ -2967,7 +3199,11 @@ class CurrentTimestamp(FunctionExpression): """ def __init__(self): - super().__init__("current_timestamp", [], use_infix_repr=False) + super().__init__( + "current_timestamp", + [], + repr_function=FunctionExpression._build_standalone_repr(), + ) class Rand(FunctionExpression): @@ -2979,7 +3215,9 @@ class Rand(FunctionExpression): """ def __init__(self): - super().__init__("rand", [], use_infix_repr=False) + super().__init__( + "rand", [], repr_function=FunctionExpression._build_standalone_repr() + ) class Score(FunctionExpression): @@ -3003,7 +3241,9 @@ class Score(FunctionExpression): """ def __init__(self): - super().__init__("score", [], use_infix_repr=False) + super().__init__( + "score", [], repr_function=FunctionExpression._build_standalone_repr() + ) class DocumentMatches(BooleanExpression): @@ -3028,7 +3268,7 @@ def __init__(self, query: Expression | str): super().__init__( "document_matches", [Expression._cast_to_expr_or_convert_to_constant(query)], - use_infix_repr=False, + repr_function=FunctionExpression._build_standalone_repr(), ) @@ -3068,4 +3308,8 @@ class CurrentDocument(FunctionExpression): """ def __init__(self): - super().__init__("current_document", []) + super().__init__( + "current_document", + [], + repr_function=FunctionExpression._build_standalone_repr(), + ) diff --git a/packages/google-cloud-firestore/noxfile.py b/packages/google-cloud-firestore/noxfile.py index 5a7c0a1b8536..a7275f9c46e6 100644 --- a/packages/google-cloud-firestore/noxfile.py +++ b/packages/google-cloud-firestore/noxfile.py @@ -399,6 +399,7 @@ def system(session): session.run( "py.test", "--quiet", + "-s", f"--junitxml=system_{session.python}_sponge_log.xml", system_test_path, *session.posargs, @@ -407,6 +408,7 @@ def system(session): session.run( "py.test", "--quiet", + "-s", f"--junitxml=system_{session.python}_sponge_log.xml", system_test_folder_path, *session.posargs, diff --git a/packages/google-cloud-firestore/tests/system/pipeline_e2e/array.yaml b/packages/google-cloud-firestore/tests/system/pipeline_e2e/array.yaml index e29ef0d6c2ed..e4610cc4b410 100644 --- a/packages/google-cloud-firestore/tests/system/pipeline_e2e/array.yaml +++ b/packages/google-cloud-firestore/tests/system/pipeline_e2e/array.yaml @@ -424,6 +424,60 @@ tests: - integerValue: '0' name: array_get name: select + - description: testArrayGet_NonArray + pipeline: + - Collection: books + - Where: + - FunctionExpression.equal: + - Field: title + - Constant: "The Hitchhiker's Guide to the Galaxy" + - Select: + - AliasedExpression: + - FunctionExpression.is_error: + - FunctionExpression.array_get: + - Field: title + - Constant: 0 + - "isError" + assert_results: + - isError: true + 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: + isError: + functionValue: + args: + - functionValue: + args: + - fieldReferenceValue: title + - integerValue: '0' + name: array_get + name: is_error + name: select + - description: testOffset_NonArray + pipeline: + - Collection: books + - Where: + - FunctionExpression.equal: + - Field: title + - Constant: "The Hitchhiker's Guide to the Galaxy" + - Where: + - FunctionExpression.offset: + - Field: title + - Constant: 0 + assert_count: 0 - description: testArrayGet_NegativeOffset pipeline: - Collection: books @@ -462,6 +516,116 @@ tests: - integerValue: '-1' name: array_get name: select + - description: testOffset + pipeline: + - Collection: books + - Where: + - FunctionExpression.equal: + - Field: title + - Constant: "The Hitchhiker's Guide to the Galaxy" + - Select: + - AliasedExpression: + - FunctionExpression.offset: + - Field: tags + - Constant: -1 + - "lastTag" + assert_results: + - lastTag: "adventure" + 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: + lastTag: + functionValue: + args: + - fieldReferenceValue: tags + - integerValue: '-1' + name: offset + name: select + - description: testOffset_LiteralArray + pipeline: + - Collection: books + - Limit: 1 + - Select: + - AliasedExpression: + - FunctionExpression.offset: + - Array: [10, 20, 30] + - Constant: 1 + - "element" + assert_results: + - element: 20 + assert_proto: + pipeline: + stages: + - args: + - referenceValue: /books + name: collection + - args: + - integerValue: '1' + name: limit + - args: + - mapValue: + fields: + element: + functionValue: + args: + - functionValue: + args: + - integerValue: '10' + - integerValue: '20' + - integerValue: '30' + name: array + - integerValue: '1' + name: offset + name: select + - description: testOffset_LiteralArray_Negative + pipeline: + - Collection: books + - Limit: 1 + - Select: + - AliasedExpression: + - FunctionExpression.offset: + - Array: [10, 20, 30] + - Constant: -1 + - "element" + assert_results: + - element: 30 + assert_proto: + pipeline: + stages: + - args: + - referenceValue: /books + name: collection + - args: + - integerValue: '1' + name: limit + - args: + - mapValue: + fields: + element: + functionValue: + args: + - functionValue: + args: + - integerValue: '10' + - integerValue: '20' + - integerValue: '30' + name: array + - integerValue: '-1' + name: offset + name: select - description: testArrayFirst pipeline: - Collection: books @@ -800,3 +964,94 @@ tests: - stringValue: "Science Fiction" name: array_index_of_all name: select + - description: testArrayFilter + pipeline: + - Collection: books + - Where: + - FunctionExpression.equal: + - Field: title + - Constant: "The Hitchhiker's Guide to the Galaxy" + - Select: + - AliasedExpression: + - FunctionExpression.array_filter: + - Field: tags + - FunctionExpression.equal: + - Variable: tag + - Constant: comedy + - "tag" + - "comedyTag" + assert_results: + - comedyTag: ["comedy"] + 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: + comedyTag: + functionValue: + args: + - fieldReferenceValue: tags + - stringValue: "tag" + - functionValue: + args: + - variableReferenceValue: tag + - stringValue: "comedy" + name: equal + name: array_filter + name: select + - description: testArrayTransform + pipeline: + - Collection: books + - Where: + - FunctionExpression.equal: + - Field: title + - Constant: "The Hitchhiker's Guide to the Galaxy" + - Select: + - AliasedExpression: + - FunctionExpression.array_transform: + - Field: tags + - FunctionExpression.to_upper: + - Variable: tag + - "tag" + - "upperTags" + assert_results: + - upperTags: ["COMEDY", "SPACE", "ADVENTURE"] + 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: + upperTags: + functionValue: + args: + - fieldReferenceValue: tags + - stringValue: "tag" + - functionValue: + args: + - variableReferenceValue: tag + name: to_upper + name: array_transform + name: select + diff --git a/packages/google-cloud-firestore/tests/system/pipeline_e2e/general.yaml b/packages/google-cloud-firestore/tests/system/pipeline_e2e/general.yaml index 4063d8b971ca..2c901e6c6f7f 100644 --- a/packages/google-cloud-firestore/tests/system/pipeline_e2e/general.yaml +++ b/packages/google-cloud-firestore/tests/system/pipeline_e2e/general.yaml @@ -295,53 +295,6 @@ tests: - Pipeline: - Collection: books assert_count: 20 # Results will be duplicated - - description: testDocumentId - pipeline: - - Collection: books - - Where: - - FunctionExpression.equal: - - Field: title - - Constant: "The Hitchhiker's Guide to the Galaxy" - - Select: - - AliasedExpression: - - FunctionExpression.document_id: - - Field: __name__ - - "doc_id" - assert_results: - - doc_id: "book1" - 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: - doc_id: - functionValue: - name: document_id - args: - - fieldReferenceValue: __name__ - name: select - - description: testCollectionId - pipeline: - - Collection: books - - Limit: 1 - - Select: - - AliasedExpression: - - FunctionExpression.collection_id: - - Field: __name__ - - "collectionName" - assert_results: - - collectionName: "books" - description: testCollectionGroup pipeline: - CollectionGroup: books @@ -765,6 +718,177 @@ tests: res: fieldReferenceValue: res name: select + - description: testCoalesce + pipeline: + - Collection: books + - Limit: 1 + - Select: + - AliasedExpression: + - FunctionExpression.coalesce: + - Field: non_existent_field + - Constant: "B" + - "res" + assert_results: + - res: "B" + - description: testCoalesceMultipleFailures + pipeline: + - Collection: books + - Limit: 1 + - Select: + - AliasedExpression: + - FunctionExpression.coalesce: + - Field: non_existent_field1 + - Field: non_existent_field2 + - Field: non_existent_field3 + - Constant: "Found" + - "res" + assert_results: + - res: "Found" + - description: testCoalesceShortCircuit + pipeline: + - Collection: books + - Limit: 1 + - Select: + - AliasedExpression: + - FunctionExpression.coalesce: + - Field: non_existent_field + - "Hello" + - "Never Reaches" + - "res" + assert_results: + - res: "Hello" + - description: testCoalesceNumber + pipeline: + - Collection: books + - Limit: 1 + - Select: + - AliasedExpression: + - FunctionExpression.coalesce: + - Field: non_existent_field + - 42 + - "res" + assert_results: + - res: 42 + - description: testCoalesceNoResult + pipeline: + - Collection: books + - Limit: 1 + - Select: + - AliasedExpression: + - FunctionExpression.coalesce: + - Field: non_existent_field1 + - Field: non_existent_field2 + - "res" + assert_results: + - {} + - description: testCoalesceFieldResult + pipeline: + - Collection: books + - Where: + - FunctionExpression.equal: + - Field: title + - Constant: "1984" + - Select: + - AliasedExpression: + - FunctionExpression.coalesce: + - Field: non_existent_field + - Field: title + - "res" + assert_results: + - res: "1984" + - description: testCoalesceNull + pipeline: + - Documents: + - /errors/doc_with_null + - Select: + - AliasedExpression: + - FunctionExpression.coalesce: + - Field: value + - Constant: "Success" + - "res" + assert_results: + - res: "Success" + - description: testSwitchOn + pipeline: + - Literals: + - res: + FunctionExpression.switch_on: + - FunctionExpression.equal: + - Constant: 1 + - Constant: 2 + - Constant: "A" + - FunctionExpression.equal: + - Constant: 1 + - Constant: 1 + - Constant: "B" + - Constant: "C" + - Select: + - res + assert_results: + - res: "B" + - description: testSwitchOn_Default + pipeline: + - Literals: + - res: + FunctionExpression.switch_on: + - FunctionExpression.equal: + - Constant: 1 + - Constant: 2 + - Constant: "A" + - FunctionExpression.equal: + - Constant: 1 + - Constant: 3 + - Constant: "B" + - Constant: "C" + - Select: + - res + assert_results: + - res: "C" + - description: testSwitchOn_Error + pipeline: + - Literals: + - res: + FunctionExpression.switch_on: + - FunctionExpression.equal: + - Constant: 1 + - Constant: 2 + - Constant: "A" + - FunctionExpression.equal: + - Constant: 1 + - Constant: 3 + - Constant: "B" + - Select: + - res + assert_error: ".*all switch cases evaluate to false, and no default provided" + - description: testStorageSize + pipeline: + - Collection: books + - Limit: 1 + - Select: + - AliasedExpression: + - FunctionExpression.storage_size: + - Field: __name__ + - res + assert_results: + - res: 29 + assert_proto: + pipeline: + stages: + - args: + - referenceValue: /books + name: collection + - args: + - integerValue: '1' + name: limit + - args: + - mapValue: + fields: + res: + functionValue: + args: + - fieldReferenceValue: __name__ + name: storage_size + name: select - description: union_subpipeline_error pipeline: - Collection: books diff --git a/packages/google-cloud-firestore/tests/system/pipeline_e2e/logical.yaml b/packages/google-cloud-firestore/tests/system/pipeline_e2e/logical.yaml index 253ffcd89a09..ed75ca73b696 100644 --- a/packages/google-cloud-firestore/tests/system/pipeline_e2e/logical.yaml +++ b/packages/google-cloud-firestore/tests/system/pipeline_e2e/logical.yaml @@ -760,6 +760,77 @@ tests: - "value_or_default" assert_results: - value_or_default: "1984" + - description: whereByNorCondition + pipeline: + - Collection: books + - Where: + - Nor: + - FunctionExpression.equal: + - Field: genre + - Constant: Romance + - FunctionExpression.equal: + - Field: genre + - Constant: Dystopian + - FunctionExpression.equal: + - Field: genre + - Constant: Fantasy + - FunctionExpression.greater_than: + - Field: published + - Constant: 1949 + - Select: + - title + - Sort: + - Ordering: + - Field: title + - ASCENDING + assert_results: + - title: "Crime and Punishment" + - title: "The Great Gatsby" + assert_proto: + pipeline: + stages: + - args: + - referenceValue: /books + name: collection + - args: + - functionValue: + args: + - functionValue: + args: + - fieldReferenceValue: genre + - stringValue: Romance + name: equal + - functionValue: + args: + - fieldReferenceValue: genre + - stringValue: Dystopian + name: equal + - functionValue: + args: + - fieldReferenceValue: genre + - stringValue: Fantasy + name: equal + - functionValue: + args: + - fieldReferenceValue: published + - integerValue: '1949' + name: greater_than + name: nor + name: where + - args: + - mapValue: + fields: + title: + fieldReferenceValue: title + name: select + - args: + - mapValue: + fields: + direction: + stringValue: ascending + expression: + fieldReferenceValue: title + name: sort - description: expression_between pipeline: - Collection: restaurants diff --git a/packages/google-cloud-firestore/tests/system/pipeline_e2e/references.yaml b/packages/google-cloud-firestore/tests/system/pipeline_e2e/references.yaml new file mode 100644 index 000000000000..ed29330f811d --- /dev/null +++ b/packages/google-cloud-firestore/tests/system/pipeline_e2e/references.yaml @@ -0,0 +1,49 @@ +tests: + - description: testDocumentId + pipeline: + - Collection: books + - Where: + - FunctionExpression.equal: + - Field: title + - Constant: "The Hitchhiker's Guide to the Galaxy" + - Select: + - AliasedExpression: + - FunctionExpression.document_id: + - Field: __name__ + - "doc_id" + assert_results: + - doc_id: "book1" + 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: + doc_id: + functionValue: + name: document_id + args: + - fieldReferenceValue: __name__ + name: select + - description: testCollectionId + pipeline: + - Collection: books + - Limit: 1 + - Select: + - AliasedExpression: + - FunctionExpression.collection_id: + - Field: __name__ + - "collectionName" + assert_results: + - collectionName: "books" + 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 add5aa9dfbc9..ab38d5b77837 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 @@ -735,6 +735,14 @@ def test_or(self): assert instance.params == [arg1, arg2] assert repr(instance) == "Or(Arg1, Arg2)" + def test_nor(self): + arg1 = self._make_arg("Arg1") + arg2 = self._make_arg("Arg2") + instance = expr.Nor(arg1, arg2) + assert instance.name == "nor" + assert instance.params == [arg1, arg2] + assert repr(instance) == "Nor(Arg1, Arg2)" + def test_array_get(self): arg1 = self._make_arg("ArrayField") arg2 = self._make_arg("Offset") @@ -742,8 +750,18 @@ def test_array_get(self): assert instance.name == "array_get" assert instance.params == [arg1, arg2] assert repr(instance) == "ArrayField.array_get(Offset)" - infix_istance = arg1.array_get(arg2) - assert infix_istance == instance + infix_instance = arg1.array_get(arg2) + assert infix_instance == instance + + def test_offset(self): + arg1 = self._make_arg("ArrayField") + arg2 = self._make_arg("Offset") + instance = Expression.offset(arg1, arg2) + assert instance.name == "offset" + assert instance.params == [arg1, arg2] + assert repr(instance) == "ArrayField.offset(Offset)" + infix_instance = arg1.offset(arg2) + assert infix_instance == instance def test_array_contains(self): arg1 = self._make_arg("ArrayField") @@ -960,6 +978,42 @@ def test_if_error(self): infix_instance = arg1.if_error(arg2) assert infix_instance == instance + def test_coalesce(self): + arg1 = self._make_arg("Arg1") + arg2 = self._make_arg("Arg2") + arg3 = self._make_arg("Arg3") + instance = Expression.coalesce(arg1, arg2, arg3) + assert instance.name == "coalesce" + assert instance.params == [arg1, arg2, arg3] + assert repr(instance) == "Arg1.coalesce(Arg2, Arg3)" + infix_instance = arg1.coalesce(arg2, arg3) + assert infix_instance == instance + + def test_switch_on(self): + arg1 = self._make_arg("Condition1") + arg2 = self._make_arg("Result1") + arg3 = self._make_arg("Condition2") + arg4 = self._make_arg("Result2") + arg5 = self._make_arg("Default") + instance = Expression.switch_on(arg1, arg2, arg3, arg4, arg5) + assert instance.name == "switch_on" + assert instance.params == [arg1, arg2, arg3, arg4, arg5] + assert ( + repr(instance) + == "Condition1.switch_on(Result1, Condition2, Result2, Default)" + ) + infix_instance = arg1.switch_on(arg2, arg3, arg4, arg5) + assert infix_instance == instance + + def test_storage_size(self): + arg1 = self._make_arg("Input") + instance = Expression.storage_size(arg1) + assert instance.name == "storage_size" + assert instance.params == [arg1] + assert repr(instance) == "Input.storage_size()" + infix_instance = arg1.storage_size() + assert infix_instance == instance + def test_not(self): arg1 = self._make_arg("Condition") instance = expr.Not(arg1) @@ -1661,14 +1715,62 @@ def test_array_length(self): assert infix_instance == instance def test_array_reverse(self): - arg1 = self._make_arg("Array") + arg1 = self._make_arg("ArrayField") instance = Expression.array_reverse(arg1) assert instance.name == "array_reverse" assert instance.params == [arg1] - assert repr(instance) == "Array.array_reverse()" + assert repr(instance) == "ArrayField.array_reverse()" infix_instance = arg1.array_reverse() assert infix_instance == instance + def test_array_filter(self): + arr = self._make_arg("ArrayField") + filter_expr = self._make_arg("FilterExpr") + elm_alias = "element_alias" + instance = Expression.array_filter(arr, filter_expr, elm_alias) + assert instance.name == "array_filter" + assert instance.params == [arr, Constant.of(elm_alias), filter_expr] + assert ( + repr(instance) + == "ArrayField.array_filter(FilterExpr, Constant.of('element_alias'))" + ) + infix_instance = arr.array_filter(filter_expr, elm_alias) + assert infix_instance == instance + + def test_array_transform(self): + arr = self._make_arg("ArrayField") + transform_expr = self._make_arg("TransformExpr") + elm_alias = "element_alias" + instance = Expression.array_transform(arr, transform_expr, elm_alias) + assert instance.name == "array_transform" + assert instance.params == [arr, Constant.of(elm_alias), transform_expr] + assert ( + repr(instance) + == "ArrayField.array_transform(TransformExpr, Constant.of('element_alias'))" + ) + infix_instance = arr.array_transform(transform_expr, elm_alias) + assert infix_instance == instance + + idx_alias = "index_alias" + instance_with_idx = Expression.array_transform( + arr, transform_expr, elm_alias, idx_alias + ) + assert instance_with_idx.name == "array_transform" + assert instance_with_idx.params == [ + arr, + Constant.of(elm_alias), + Constant.of(idx_alias), + transform_expr, + ] + assert ( + repr(instance_with_idx) + == "ArrayField.array_transform(TransformExpr, Constant.of('element_alias'), Constant.of('index_alias'))" + ) + infix_instance_with_idx = arr.array_transform( + transform_expr, elm_alias, idx_alias + ) + assert infix_instance_with_idx == instance_with_idx + def test_array_concat(self): arg1 = self._make_arg("ArrayRef1") arg2 = self._make_arg("ArrayRef2")