Skip to content

Commit b0e075d

Browse files
committed
Merge branch 'main' into shuowei-deprecate-blob-api
2 parents 8605075 + 705e23d commit b0e075d

File tree

10 files changed

+976
-27
lines changed

10 files changed

+976
-27
lines changed

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,37 @@ def sort(self, *orders: stages.Ordering) -> "_BasePipeline":
394394
"""
395395
return self._append(stages.Sort(*orders))
396396

397+
def search(
398+
self, query_or_options: str | BooleanExpression | stages.SearchOptions
399+
) -> "_BasePipeline":
400+
"""
401+
Adds a search stage to the pipeline.
402+
403+
This stage filters documents based on the provided query expression.
404+
405+
Example:
406+
>>> from google.cloud.firestore_v1.pipeline_stages import SearchOptions
407+
>>> from google.cloud.firestore_v1.pipeline_expressions import And, DocumentMatches, Field, GeoPoint
408+
>>> # Search for restaurants matching either "waffles" or "pancakes" near a location
409+
>>> pipeline = client.pipeline().collection("restaurants").search(
410+
... SearchOptions(
411+
... query=And(
412+
... DocumentMatches("waffles OR pancakes"),
413+
... Field.of("location").geo_distance(GeoPoint(38.9, -107.0)).less_than(1000)
414+
... ),
415+
... sort=Score().descending()
416+
... )
417+
... )
418+
419+
Args:
420+
options: Either a string or expression representing the search query, or
421+
A `SearchOptions` instance configuring the search.
422+
423+
Returns:
424+
A new Pipeline object with this stage appended to the stage list
425+
"""
426+
return self._append(stages.Search(query_or_options))
427+
397428
def sample(self, limit_or_options: int | stages.SampleOptions) -> "_BasePipeline":
398429
"""
399430
Performs a pseudo-random sampling of the documents from the previous stage.

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

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -730,6 +730,61 @@ def less_than_or_equal(
730730
[self, self._cast_to_expr_or_convert_to_constant(other)],
731731
)
732732

733+
@expose_as_static
734+
def between(
735+
self, lower: Expression | float, upper: Expression | float
736+
) -> "BooleanExpression":
737+
"""Evaluates if the result of this expression is between
738+
the lower bound (inclusive) and upper bound (inclusive).
739+
740+
This is functionally equivalent to performing an `And` operation with
741+
`greater_than_or_equal` and `less_than_or_equal`.
742+
743+
Example:
744+
>>> # Check if the 'age' field is between 18 and 65
745+
>>> Field.of("age").between(18, 65)
746+
747+
Args:
748+
lower: Lower bound (inclusive) of the range.
749+
upper: Upper bound (inclusive) of the range.
750+
751+
Returns:
752+
A new `BooleanExpression` representing the between comparison.
753+
"""
754+
return And(
755+
self.greater_than_or_equal(lower),
756+
self.less_than_or_equal(upper),
757+
)
758+
759+
@expose_as_static
760+
def geo_distance(
761+
self, other: Expression | GeoPoint | tuple[float, float]
762+
) -> "FunctionExpression":
763+
"""Evaluates to the distance in meters between the location in the specified
764+
field and the query location.
765+
766+
Note: This Expression can only be used within a `Search` stage.
767+
768+
Example:
769+
>>> # Calculate distance between the 'location' field and a target GeoPoint
770+
>>> Field.of("location").geo_distance(target_point)
771+
>>> # Calculate distance between the 'location' field and a (latitude, longitude) tuple
772+
>>> Field.of("location").geo_distance((37.7749, -122.4194))
773+
774+
Args:
775+
other: target point used to calculate distance. Can be a GeoPoint, an
776+
Expression resolving to a GeoPoint, or a (latitude, longitude) tuple.
777+
778+
Returns:
779+
A new `FunctionExpression` representing the distance.
780+
"""
781+
if isinstance(other, tuple) and len(other) == 2:
782+
other = GeoPoint(other[0], other[1])
783+
784+
return FunctionExpression(
785+
"geo_distance", [self, self._cast_to_expr_or_convert_to_constant(other)]
786+
)
787+
733788
@expose_as_static
734789
def equal_any(
735790
self, array: Array | Sequence[Expression | CONSTANT_TYPE] | Expression
@@ -2927,6 +2982,56 @@ def __init__(self):
29272982
super().__init__("rand", [], use_infix_repr=False)
29282983

29292984

2985+
class Score(FunctionExpression):
2986+
"""Evaluates to the search score that reflects the topicality of the document
2987+
to all of the text predicates (`queryMatch`)
2988+
in the search query. If `SearchOptions.query` is not set or does not contain
2989+
any text predicates, then this topicality score will always be `0`.
2990+
2991+
Note: This Expression can only be used within a `Search` stage.
2992+
2993+
Example:
2994+
>>> # Sort by search score and retrieve it via add_fields
2995+
>>> db.pipeline().collection("restaurants").search(
2996+
... query="tacos",
2997+
... sort=Score().descending(),
2998+
... add_fields=[Score().as_("search_score")]
2999+
... )
3000+
3001+
Returns:
3002+
A new `Expression` representing the score operation.
3003+
"""
3004+
3005+
def __init__(self):
3006+
super().__init__("score", [], use_infix_repr=False)
3007+
3008+
3009+
class DocumentMatches(BooleanExpression):
3010+
"""Creates a boolean expression for a document match query.
3011+
3012+
Note: This Expression can only be used within a `Search` stage.
3013+
3014+
Example:
3015+
>>> # Find documents matching the query string
3016+
>>> db.pipeline().collection("restaurants").search(
3017+
... query=DocumentMatches("pizza OR pasta")
3018+
... )
3019+
3020+
Args:
3021+
query: The search query string or expression.
3022+
3023+
Returns:
3024+
A new `BooleanExpression` representing the document match.
3025+
"""
3026+
3027+
def __init__(self, query: Expression | str):
3028+
super().__init__(
3029+
"document_matches",
3030+
[Expression._cast_to_expr_or_convert_to_constant(query)],
3031+
use_infix_repr=False,
3032+
)
3033+
3034+
29303035
class Variable(Expression):
29313036
"""
29323037
Creates an expression that retrieves the value of a variable bound via `Pipeline.define`.

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

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
AliasedExpression,
3131
BooleanExpression,
3232
CONSTANT_TYPE,
33+
DocumentMatches,
3334
Expression,
3435
Field,
3536
Ordering,
@@ -109,6 +110,79 @@ def percentage(value: float):
109110
return SampleOptions(value, mode=SampleOptions.Mode.PERCENT)
110111

111112

113+
class SearchOptions:
114+
"""Options for configuring the `Search` pipeline stage."""
115+
116+
def __init__(
117+
self,
118+
query: str | BooleanExpression,
119+
*,
120+
limit: Optional[int] = None,
121+
retrieval_depth: Optional[int] = None,
122+
sort: Optional[Sequence[Ordering] | Ordering] = None,
123+
add_fields: Optional[Sequence[Selectable]] = None,
124+
offset: Optional[int] = None,
125+
language_code: Optional[str] = None,
126+
):
127+
"""
128+
Initializes a SearchOptions instance.
129+
130+
Args:
131+
query (str | BooleanExpression): Specifies the search query that will be used to query and score documents
132+
by the search stage. The query can be expressed as an `Expression`, which will be used to score
133+
and filter the results. Not all expressions supported by Pipelines are supported in the Search query.
134+
The query can also be expressed as a string in the Search DSL.
135+
limit (Optional[int]): The maximum number of documents to return from the Search stage.
136+
retrieval_depth (Optional[int]): The maximum number of documents for the search stage to score. Documents
137+
will be processed in the pre-sort order specified by the search index.
138+
sort (Optional[Sequence[Ordering] | Ordering]): Orderings specify how the input documents are sorted.
139+
add_fields (Optional[Sequence[Selectable]]): The fields to add to each document, specified as a `Selectable`.
140+
offset (Optional[int]): The number of documents to skip.
141+
language_code (Optional[str]): The BCP-47 language code of text in the search query, such as "en-US" or "sr-Latn".
142+
"""
143+
self.query = DocumentMatches(query) if isinstance(query, str) else query
144+
self.limit = limit
145+
self.retrieval_depth = retrieval_depth
146+
self.sort = [sort] if isinstance(sort, Ordering) else sort
147+
self.add_fields = add_fields
148+
self.offset = offset
149+
self.language_code = language_code
150+
151+
def __repr__(self):
152+
args = [f"query={self.query!r}"]
153+
if self.limit is not None:
154+
args.append(f"limit={self.limit}")
155+
if self.retrieval_depth is not None:
156+
args.append(f"retrieval_depth={self.retrieval_depth}")
157+
if self.sort is not None:
158+
args.append(f"sort={self.sort}")
159+
if self.add_fields is not None:
160+
args.append(f"add_fields={self.add_fields}")
161+
if self.offset is not None:
162+
args.append(f"offset={self.offset}")
163+
if self.language_code is not None:
164+
args.append(f"language_code={self.language_code!r}")
165+
return f"{self.__class__.__name__}({', '.join(args)})"
166+
167+
def _to_dict(self) -> dict[str, Value]:
168+
options = {"query": self.query._to_pb()}
169+
if self.limit is not None:
170+
options["limit"] = Value(integer_value=self.limit)
171+
if self.retrieval_depth is not None:
172+
options["retrieval_depth"] = Value(integer_value=self.retrieval_depth)
173+
if self.sort is not None:
174+
options["sort"] = Value(
175+
array_value={"values": [s._to_pb() for s in self.sort]}
176+
)
177+
if self.add_fields is not None:
178+
options["add_fields"] = Selectable._to_value(self.add_fields)
179+
if self.offset is not None:
180+
options["offset"] = Value(integer_value=self.offset)
181+
if self.language_code is not None:
182+
options["language_code"] = Value(string_value=self.language_code)
183+
return options
184+
185+
112186
class UnnestOptions:
113187
"""Options for configuring the `Unnest` pipeline stage.
114188
@@ -423,6 +497,24 @@ def _pb_args(self):
423497
]
424498

425499

500+
class Search(Stage):
501+
"""Search stage."""
502+
503+
def __init__(self, query_or_options: str | BooleanExpression | SearchOptions):
504+
super().__init__("search")
505+
if isinstance(query_or_options, SearchOptions):
506+
options = query_or_options
507+
else:
508+
options = SearchOptions(query=query_or_options)
509+
self.options = options
510+
511+
def _pb_args(self) -> list[Value]:
512+
return []
513+
514+
def _pb_options(self) -> dict[str, Value]:
515+
return self.options._to_dict()
516+
517+
426518
class Select(Stage):
427519
"""Selects or creates a set of fields."""
428520

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

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,8 +148,13 @@ data:
148148
cities:
149149
city1:
150150
name: "San Francisco"
151+
location: GEOPOINT(37.7749,-122.4194)
151152
city2:
152153
name: "New York"
154+
location: GEOPOINT(40.7128,-74.0060)
155+
city3:
156+
name: "Saskatoon"
157+
location: GEOPOINT(52.1579,-106.6702)
153158
"cities/city1/landmarks":
154159
lm1:
155160
name: "Golden Gate Bridge"
@@ -167,4 +172,57 @@ data:
167172
rating: 5
168173
rev2:
169174
author: "Bob"
170-
rating: 4
175+
rating: 4
176+
"cities/city3/landmarks":
177+
lm4:
178+
name: "Western Development Museum"
179+
type: "Museum"
180+
restaurants:
181+
sunnySideUp:
182+
name: "The Sunny Side Up"
183+
description: "A cozy neighborhood diner serving classic breakfast favorites all day long, from fluffy pancakes to savory omelets."
184+
location: GEOPOINT(39.7541,-105.0002)
185+
menu: "<h3>Breakfast Classics</h3><ul><li>Denver Omelet - $12</li><li>Buttermilk Pancakes - $10</li><li>Steak and Eggs - $16</li></ul><h3>Sides</h3><ul><li>Hash Browns - $4</li><li>Thick-cut Bacon - $5</li><li>Drip Coffee - $2</li></ul>"
186+
average_price_per_person: 15
187+
goldenWaffle:
188+
name: "The Golden Waffle"
189+
description: "Specializing exclusively in Belgian-style waffles. Open daily from 6:00 AM to 11:00 AM."
190+
location: GEOPOINT(39.7183,-104.9621)
191+
menu: "<h3>Signature Waffles</h3><ul><li>Strawberry Delight - $11</li><li>Chicken and Waffles - $14</li><li>Chocolate Chip Crunch - $10</li></ul><h3>Drinks</h3><ul><li>Fresh OJ - $4</li><li>Artisan Coffee - $3</li></ul>"
192+
average_price_per_person: 13
193+
lotusBlossomThai:
194+
name: "Lotus Blossom Thai"
195+
description: "Authentic Thai cuisine featuring hand-crushed spices and traditional family recipes from the Chiang Mai region."
196+
location: GEOPOINT(39.7315,-104.9847)
197+
menu: "<h3>Appetizers</h3><ul><li>Spring Rolls - $7</li><li>Chicken Satay - $9</li></ul><h3>Main Course</h3><ul><li>Pad Thai - $15</li><li>Green Curry - $16</li><li>Drunken Noodles - $15</li></ul>"
198+
average_price_per_person: 22
199+
mileHighCatch:
200+
name: "Mile High Catch"
201+
description: "Freshly sourced seafood offering a wide variety of Pacific fish and Atlantic shellfish in an upscale atmosphere."
202+
location: GEOPOINT(39.7401,-104.9903)
203+
menu: "<h3>From the Raw Bar</h3><ul><li>Oysters (Half Dozen) - $18</li><li>Lobster Cocktail - $22</li></ul><h3>Entrees</h3><ul><li>Pan-Seared Salmon - $28</li><li>King Crab Legs - $45</li><li>Fish and Chips - $19</li></ul>"
204+
average_price_per_person: 45
205+
peakBurgers:
206+
name: "Peak Burgers"
207+
description: "Casual burger joint focused on locally sourced Colorado beef and hand-cut fries."
208+
location: GEOPOINT(39.7622,-105.0125)
209+
menu: "<h3>Burgers</h3><ul><li>The Peak Double - $12</li><li>Bison Burger - $15</li><li>Veggie Stack - $11</li></ul><h3>Sides</h3><ul><li>Truffle Fries - $6</li><li>Onion Rings - $5</li></ul>"
210+
average_price_per_person: 18
211+
solTacos:
212+
name: "El Sol Tacos"
213+
description: "A vibrant street-side taco stand serving up quick, delicious, and traditional Mexican street food."
214+
location: GEOPOINT(39.6952,-105.0274)
215+
menu: "<h3>Tacos ($3.50 each)</h3><ul><li>Al Pastor</li><li>Carne Asada</li><li>Pollo Asado</li><li>Nopales (Cactus)</li></ul><h3>Beverages</h3><ul><li>Horchata - $4</li><li>Mexican Coke - $3</li></ul>"
216+
average_price_per_person: 12
217+
eastsideTacos:
218+
name: "Eastside Cantina"
219+
description: "Authentic street tacos and hand-shaken margaritas on the vibrant east side of the city."
220+
location: GEOPOINT(39.735,-104.885)
221+
menu: "<h3>Tacos</h3><ul><li>Carnitas Tacos - $4</li><li>Barbacoa Tacos - $4.50</li><li>Shrimp Tacos - $5</li></ul><h3>Drinks</h3><ul><li>House Margarita - $9</li><li>Jarritos - $3</li></ul>"
222+
average_price_per_person: 18
223+
eastsideChicken:
224+
name: "Eastside Chicken"
225+
description: "Fried chicken to go - next to Eastside Cantina."
226+
location: GEOPOINT(39.735,-104.885)
227+
menu: "<h3>Fried Chicken</h3><ul><li>Drumstick - $4</li><li>Wings - $1</li><li>Sandwich - $9</li></ul><h3>Drinks</h3><ul><li>House Margarita - $9</li><li>Jarritos - $3</li></ul>"
228+
average_price_per_person: 12

0 commit comments

Comments
 (0)