Skip to content

Commit cf50cea

Browse files
authored
feat(firestore): literals pipeline stage (#16028)
Succeeding googleapis/python-firestore#1170 for the monorepo migration.
1 parent 4b400fa commit cf50cea

File tree

5 files changed

+263
-3
lines changed

5 files changed

+263
-3
lines changed

packages/google-cloud-firestore/google/cloud/firestore_v1/pipeline_source.py

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919

2020
from __future__ import annotations
2121

22-
from typing import TYPE_CHECKING, Generic, TypeVar
22+
from typing import Generic, TypeVar, TYPE_CHECKING
2323

2424
from google.cloud.firestore_v1 import pipeline_stages as stages
2525
from google.cloud.firestore_v1._helpers import DOCUMENT_PATH_DELIMITER
@@ -32,6 +32,7 @@
3232
from google.cloud.firestore_v1.base_document import BaseDocumentReference
3333
from google.cloud.firestore_v1.base_query import BaseQuery
3434
from google.cloud.firestore_v1.client import Client
35+
from google.cloud.firestore_v1.pipeline_expressions import CONSTANT_TYPE, Expression
3536

3637

3738
PipelineType = TypeVar("PipelineType", bound=_BasePipeline)
@@ -108,3 +109,65 @@ def documents(self, *docs: "BaseDocumentReference") -> PipelineType:
108109
a new pipeline instance targeting the specified documents
109110
"""
110111
return self._create_pipeline(stages.Documents.of(*docs))
112+
113+
def literals(
114+
self, *documents: dict[str, Expression | CONSTANT_TYPE]
115+
) -> PipelineType:
116+
"""
117+
Returns documents from a fixed set of predefined document objects.
118+
119+
Example:
120+
>>> from google.cloud.firestore_v1.pipeline_expressions import Constant
121+
>>> documents = [
122+
... {"name": "joe", "age": 10},
123+
... {"name": "bob", "age": 30},
124+
... {"name": "alice", "age": 40}
125+
... ]
126+
>>> pipeline = client.pipeline()
127+
... .literals(*documents)
128+
... .where(field("age").lessThan(35))
129+
130+
Output documents:
131+
```json
132+
[
133+
{"name": "joe", "age": 10},
134+
{"name": "bob", "age": 30}
135+
]
136+
```
137+
138+
Behavior:
139+
The `literals(...)` stage can only be used as the first stage in a pipeline (or
140+
sub-pipeline). The order of documents returned from the `literals` matches the
141+
order in which they are defined.
142+
143+
While literal values are the most common, it is also possible to pass in
144+
expressions, which will be evaluated and returned, making it possible to test
145+
out different query / expression behavior without first needing to create some
146+
test data.
147+
148+
For example, the following shows how to quickly test out the `length(...)`
149+
function on some constant test sets:
150+
151+
Example:
152+
>>> from google.cloud.firestore_v1.pipeline_expressions import Constant
153+
>>> documents = [
154+
... {"x": Constant.of("foo-bar-baz").char_length()},
155+
... {"x": Constant.of("bar").char_length()}
156+
... ]
157+
>>> pipeline = client.pipeline().literals(*documents)
158+
159+
Output documents:
160+
```json
161+
[
162+
{"x": 11},
163+
{"x": 3}
164+
]
165+
```
166+
167+
Args:
168+
*documents: One or more documents to be returned by this stage. Each can be a `dict`
169+
of values of `Expression` or `CONSTANT_TYPE` types.
170+
Returns:
171+
A new Pipeline object with this stage appended to the stage list.
172+
"""
173+
return self._create_pipeline(stages.Literals(*documents))

packages/google-cloud-firestore/google/cloud/firestore_v1/pipeline_stages.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,15 @@
2121

2222
from abc import ABC, abstractmethod
2323
from enum import Enum
24-
from typing import TYPE_CHECKING, Optional, Sequence
24+
from typing import TYPE_CHECKING, Any, Mapping, Optional, Sequence
2525

2626
from google.cloud.firestore_v1._helpers import encode_value
2727
from google.cloud.firestore_v1.base_vector_query import DistanceMeasure
2828
from google.cloud.firestore_v1.pipeline_expressions import (
2929
AggregateFunction,
3030
AliasedExpression,
3131
BooleanExpression,
32+
CONSTANT_TYPE,
3233
Expression,
3334
Field,
3435
Ordering,
@@ -342,6 +343,26 @@ def _pb_args(self):
342343
return [Value(integer_value=self.limit)]
343344

344345

346+
class Literals(Stage):
347+
"""Returns documents from a fixed set of predefined document objects."""
348+
349+
def __init__(self, *documents: dict[str, Expression | CONSTANT_TYPE]):
350+
super().__init__("literals")
351+
self.documents: tuple[Mapping[str, Any], ...] = documents
352+
353+
def _pb_args(self):
354+
args = []
355+
for doc in self.documents:
356+
encoded_doc = {}
357+
for k, v in doc.items():
358+
if hasattr(v, "_to_pb"):
359+
encoded_doc[k] = v._to_pb()
360+
else:
361+
encoded_doc[k] = encode_value(v)
362+
args.append(Value(map_value={"fields": encoded_doc}))
363+
return args
364+
365+
345366
class Offset(Stage):
346367
"""Skips a specified number of documents."""
347368

packages/google-cloud-firestore/tests/system/pipeline_e2e/general.yaml

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -684,4 +684,63 @@ tests:
684684
- args:
685685
- fieldReferenceValue: awards
686686
- stringValue: full_replace
687-
name: replace_with
687+
name: replace_with
688+
- description: literals
689+
pipeline:
690+
- Literals:
691+
- title: "The Hitchhiker's Guide to the Galaxy"
692+
author: "Douglas Adams"
693+
- genre: "Science Fiction"
694+
year: 1979
695+
assert_results:
696+
- title: "The Hitchhiker's Guide to the Galaxy"
697+
author: "Douglas Adams"
698+
- genre: "Science Fiction"
699+
year: 1979
700+
assert_proto:
701+
pipeline:
702+
stages:
703+
- args:
704+
- mapValue:
705+
fields:
706+
author:
707+
stringValue: "Douglas Adams"
708+
title:
709+
stringValue: "The Hitchhiker's Guide to the Galaxy"
710+
- mapValue:
711+
fields:
712+
genre:
713+
stringValue: "Science Fiction"
714+
year:
715+
integerValue: '1979'
716+
name: literals
717+
- description: literals_with_expression_input
718+
pipeline:
719+
- Literals:
720+
- res:
721+
FunctionExpression.string_concat:
722+
- Constant: "A"
723+
- Constant: "B"
724+
- Select:
725+
- res
726+
assert_results:
727+
- res: "AB"
728+
assert_proto:
729+
pipeline:
730+
stages:
731+
- args:
732+
- mapValue:
733+
fields:
734+
res:
735+
functionValue:
736+
args:
737+
- stringValue: "A"
738+
- stringValue: "B"
739+
name: string_concat
740+
name: literals
741+
- args:
742+
- mapValue:
743+
fields:
744+
res:
745+
fieldReferenceValue: res
746+
name: select

packages/google-cloud-firestore/tests/unit/v1/test_pipeline_source.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,17 @@ def test_documents(self):
110110
assert first_stage.paths[1] == "/a/2"
111111
assert first_stage.paths[2] == "/a/3"
112112

113+
def test_literals(self):
114+
from google.cloud.firestore_v1.pipeline_expressions import Field
115+
116+
instance = self._make_client().pipeline()
117+
documents = ({"field": Field.of("a")}, {"name": "joe"})
118+
ppl = instance.literals(*documents)
119+
assert isinstance(ppl, self._expected_pipeline_type)
120+
assert len(ppl.stages) == 1
121+
first_stage = ppl.stages[0]
122+
assert isinstance(first_stage, stages.Literals)
123+
113124

114125
class TestPipelineSourceWithAsyncClient(TestPipelineSource):
115126
"""

packages/google-cloud-firestore/tests/unit/v1/test_pipeline_stages.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -517,6 +517,112 @@ def test_to_pb(self):
517517
assert len(result.options) == 0
518518

519519

520+
class TestLiterals:
521+
def _make_one(self, *args, **kwargs):
522+
return stages.Literals(*args, **kwargs)
523+
524+
def test_ctor(self):
525+
val1 = {"a": Field.of("a")}
526+
val2 = {"b": 2}
527+
instance = self._make_one(val1, val2)
528+
assert instance.documents == (val1, val2)
529+
assert instance.name == "literals"
530+
531+
def test_ctor_extended_types(self):
532+
import datetime
533+
from google.cloud.firestore_v1._helpers import GeoPoint
534+
from google.cloud.firestore_v1.vector import Vector
535+
536+
doc = {
537+
"a": 1,
538+
"b": "string",
539+
"c": 3.14,
540+
"d": True,
541+
"e": None,
542+
"f": b"bytes",
543+
"g": datetime.datetime(2025, 1, 1, tzinfo=datetime.timezone.utc),
544+
"h": GeoPoint(1.0, 2.0),
545+
"i": Vector([1.0, 2.0]),
546+
}
547+
instance = self._make_one(doc)
548+
assert instance.documents == (doc,)
549+
assert instance.name == "literals"
550+
551+
def test_ctor_w_expressions(self):
552+
from google.cloud.firestore_v1.pipeline_expressions import FunctionExpression
553+
554+
expr = FunctionExpression("string_concat", [Constant("A"), Constant("B")])
555+
doc = {"res": expr}
556+
instance = self._make_one(doc)
557+
assert instance.documents == (doc,)
558+
assert instance.name == "literals"
559+
560+
def test_repr(self):
561+
from google.cloud.firestore_v1.pipeline_expressions import Field
562+
563+
val1 = {"a": Field.of("a")}
564+
instance = self._make_one(val1, {"b": 2})
565+
repr_str = repr(instance)
566+
assert repr_str == "Literals(documents=({'a': Field.of('a')}, {'b': 2}))"
567+
568+
def test_to_pb_constant_types(self):
569+
import datetime
570+
from google.cloud.firestore_v1._helpers import GeoPoint
571+
from google.cloud.firestore_v1.vector import Vector
572+
573+
doc = {
574+
"a": 1,
575+
"b": "string",
576+
"c": 3.14,
577+
"d": True,
578+
"e": None,
579+
"f": b"bytes",
580+
"g": datetime.datetime(2025, 1, 1, tzinfo=datetime.timezone.utc),
581+
"h": GeoPoint(1.0, 2.0),
582+
"i": Vector([1.0, 2.0]),
583+
}
584+
instance = self._make_one(doc)
585+
result = instance._to_pb()
586+
assert result.name == "literals"
587+
assert len(result.args) == 1
588+
589+
fields = result.args[0].map_value.fields
590+
assert fields["a"].integer_value == 1
591+
assert fields["b"].string_value == "string"
592+
assert fields["c"].double_value == 3.14
593+
assert fields["d"].boolean_value is True
594+
assert fields["e"].null_value == 0
595+
assert fields["f"].bytes_value == b"bytes"
596+
assert fields["g"].timestamp_value == datetime.datetime(
597+
2025, 1, 1, tzinfo=datetime.timezone.utc
598+
)
599+
assert fields["h"].geo_point_value.latitude == 1.0
600+
assert fields["h"].geo_point_value.longitude == 2.0
601+
assert (
602+
fields["i"].map_value.fields["value"].array_value.values[0].double_value
603+
== 1.0
604+
)
605+
assert (
606+
fields["i"].map_value.fields["value"].array_value.values[1].double_value
607+
== 2.0
608+
)
609+
610+
def test_to_pb_w_expression(self):
611+
from google.cloud.firestore_v1.pipeline_expressions import FunctionExpression
612+
613+
expr = FunctionExpression("string_concat", [Constant("A"), Constant("B")])
614+
doc = {"res": expr}
615+
instance = self._make_one(doc)
616+
result = instance._to_pb()
617+
assert result.name == "literals"
618+
assert len(result.args) == 1
619+
620+
fields = result.args[0].map_value.fields
621+
assert fields["res"].function_value.name == "string_concat"
622+
assert fields["res"].function_value.args[0].string_value == "A"
623+
assert fields["res"].function_value.args[1].string_value == "B"
624+
625+
520626
class TestOffset:
521627
def _make_one(self, *args, **kwargs):
522628
return stages.Offset(*args, **kwargs)

0 commit comments

Comments
 (0)