diff --git a/firebase-config/gradle.properties b/firebase-config/gradle.properties index 88599534856..1a890ef106e 100644 --- a/firebase-config/gradle.properties +++ b/firebase-config/gradle.properties @@ -14,7 +14,7 @@ # limitations under the License. # -version=23.0.2 +version=23.1.0 latestReleasedVersion=23.0.1 android.enableUnitTestBinaryResources=true diff --git a/firebase-firestore/CHANGELOG.md b/firebase-firestore/CHANGELOG.md index f58805c0ec7..97da3766b6e 100644 --- a/firebase-firestore/CHANGELOG.md +++ b/firebase-firestore/CHANGELOG.md @@ -11,6 +11,8 @@ [#7893](https://github.com/firebase/firebase-android-sdk/pull/7893) - [feature] Added support for `rand` and `trunc` Pipeline expressions. [#7886](https://github.com/firebase/firebase-android-sdk/pull/7886) +- [feature] Add public preview support for full-text search and geo search. + [#7949](https://github.com/firebase/firebase-android-sdk/pull/7949) # 26.1.2 diff --git a/firebase-firestore/api.txt b/firebase-firestore/api.txt index 43fc71a2ddd..ce5cc6b0f2f 100644 --- a/firebase-firestore/api.txt +++ b/firebase-firestore/api.txt @@ -441,6 +441,7 @@ package com.google.firebase.firestore { method public com.google.firebase.firestore.Pipeline replaceWith(String field); method public com.google.firebase.firestore.Pipeline sample(com.google.firebase.firestore.pipeline.SampleStage sample); method public com.google.firebase.firestore.Pipeline sample(int documents); + method @com.google.common.annotations.Beta public com.google.firebase.firestore.Pipeline search(com.google.firebase.firestore.pipeline.SearchStage searchStage); method public com.google.firebase.firestore.Pipeline select(com.google.firebase.firestore.pipeline.Selectable selection, java.lang.Object... additionalSelections); method public com.google.firebase.firestore.Pipeline select(String fieldName, java.lang.Object... additionalSelections); method public com.google.firebase.firestore.Pipeline sort(com.google.firebase.firestore.pipeline.Ordering order, com.google.firebase.firestore.pipeline.Ordering... additionalOrders); @@ -997,6 +998,7 @@ package com.google.firebase.firestore.pipeline { method public static final com.google.firebase.firestore.pipeline.Expression documentId(com.google.firebase.firestore.DocumentReference docRef); method public static final com.google.firebase.firestore.pipeline.Expression documentId(com.google.firebase.firestore.pipeline.Expression documentPath); method public static final com.google.firebase.firestore.pipeline.Expression documentId(String documentPath); + method @com.google.common.annotations.Beta public static final com.google.firebase.firestore.pipeline.BooleanExpression documentMatches(String rquery); method public final com.google.firebase.firestore.pipeline.Expression dotProduct(com.google.firebase.firestore.pipeline.Expression vector); method public static final com.google.firebase.firestore.pipeline.Expression dotProduct(com.google.firebase.firestore.pipeline.Expression vector1, com.google.firebase.firestore.pipeline.Expression vector2); method public static final com.google.firebase.firestore.pipeline.Expression dotProduct(com.google.firebase.firestore.pipeline.Expression vector1, com.google.firebase.firestore.VectorValue vector2); @@ -1045,6 +1047,8 @@ package com.google.firebase.firestore.pipeline { method public final com.google.firebase.firestore.pipeline.Expression floor(); method public static final com.google.firebase.firestore.pipeline.Expression floor(com.google.firebase.firestore.pipeline.Expression numericExpr); method public static final com.google.firebase.firestore.pipeline.Expression floor(String numericField); + method @com.google.common.annotations.Beta public static final com.google.firebase.firestore.pipeline.Expression geoDistance(com.google.firebase.firestore.pipeline.Field field, com.google.firebase.firestore.GeoPoint location); + method @com.google.common.annotations.Beta public static final com.google.firebase.firestore.pipeline.Expression geoDistance(String fieldName, com.google.firebase.firestore.GeoPoint location); method public final com.google.firebase.firestore.pipeline.Expression getField(com.google.firebase.firestore.pipeline.Expression keyExpression); method public static final com.google.firebase.firestore.pipeline.Expression getField(com.google.firebase.firestore.pipeline.Expression expression, com.google.firebase.firestore.pipeline.Expression keyExpression); method public static final com.google.firebase.firestore.pipeline.Expression getField(com.google.firebase.firestore.pipeline.Expression expression, String key); @@ -1211,6 +1215,7 @@ package com.google.firebase.firestore.pipeline { method public final com.google.firebase.firestore.pipeline.Expression roundToPrecision(int decimalPlace); method public static final com.google.firebase.firestore.pipeline.Expression roundToPrecision(String numericField, com.google.firebase.firestore.pipeline.Expression decimalPlace); method public static final com.google.firebase.firestore.pipeline.Expression roundToPrecision(String numericField, int decimalPlace); + method @com.google.common.annotations.Beta public static final com.google.firebase.firestore.pipeline.Expression score(); method public final com.google.firebase.firestore.pipeline.Expression split(com.google.firebase.firestore.Blob delimiter); method public final com.google.firebase.firestore.pipeline.Expression split(com.google.firebase.firestore.pipeline.Expression delimiter); method public static final com.google.firebase.firestore.pipeline.Expression split(com.google.firebase.firestore.pipeline.Expression value, com.google.firebase.firestore.Blob delimiter); @@ -1493,6 +1498,7 @@ package com.google.firebase.firestore.pipeline { method public com.google.firebase.firestore.pipeline.Expression documentId(com.google.firebase.firestore.DocumentReference docRef); method public com.google.firebase.firestore.pipeline.Expression documentId(com.google.firebase.firestore.pipeline.Expression documentPath); method public com.google.firebase.firestore.pipeline.Expression documentId(String documentPath); + method @com.google.common.annotations.Beta public com.google.firebase.firestore.pipeline.BooleanExpression documentMatches(String rquery); method public com.google.firebase.firestore.pipeline.Expression dotProduct(com.google.firebase.firestore.pipeline.Expression vector1, com.google.firebase.firestore.pipeline.Expression vector2); method public com.google.firebase.firestore.pipeline.Expression dotProduct(com.google.firebase.firestore.pipeline.Expression vector1, com.google.firebase.firestore.VectorValue vector2); method public com.google.firebase.firestore.pipeline.Expression dotProduct(com.google.firebase.firestore.pipeline.Expression vector1, double[] vector2); @@ -1525,6 +1531,8 @@ package com.google.firebase.firestore.pipeline { method public com.google.firebase.firestore.pipeline.Field field(String name); method public com.google.firebase.firestore.pipeline.Expression floor(com.google.firebase.firestore.pipeline.Expression numericExpr); method public com.google.firebase.firestore.pipeline.Expression floor(String numericField); + method @com.google.common.annotations.Beta public com.google.firebase.firestore.pipeline.Expression geoDistance(com.google.firebase.firestore.pipeline.Field field, com.google.firebase.firestore.GeoPoint location); + method @com.google.common.annotations.Beta public com.google.firebase.firestore.pipeline.Expression geoDistance(String fieldName, com.google.firebase.firestore.GeoPoint location); method public com.google.firebase.firestore.pipeline.Expression getField(com.google.firebase.firestore.pipeline.Expression expression, com.google.firebase.firestore.pipeline.Expression keyExpression); method public com.google.firebase.firestore.pipeline.Expression getField(com.google.firebase.firestore.pipeline.Expression expression, String key); method public com.google.firebase.firestore.pipeline.Expression getField(String fieldName, com.google.firebase.firestore.pipeline.Expression keyExpression); @@ -1638,6 +1646,7 @@ package com.google.firebase.firestore.pipeline { method public com.google.firebase.firestore.pipeline.Expression roundToPrecision(com.google.firebase.firestore.pipeline.Expression numericExpr, int decimalPlace); method public com.google.firebase.firestore.pipeline.Expression roundToPrecision(String numericField, com.google.firebase.firestore.pipeline.Expression decimalPlace); method public com.google.firebase.firestore.pipeline.Expression roundToPrecision(String numericField, int decimalPlace); + method @com.google.common.annotations.Beta public com.google.firebase.firestore.pipeline.Expression score(); method public com.google.firebase.firestore.pipeline.Expression split(com.google.firebase.firestore.pipeline.Expression value, com.google.firebase.firestore.Blob delimiter); method public com.google.firebase.firestore.pipeline.Expression split(com.google.firebase.firestore.pipeline.Expression value, com.google.firebase.firestore.pipeline.Expression delimiter); method public com.google.firebase.firestore.pipeline.Expression split(com.google.firebase.firestore.pipeline.Expression value, String delimiter); @@ -1741,6 +1750,7 @@ package com.google.firebase.firestore.pipeline { } public final class Field extends com.google.firebase.firestore.pipeline.Selectable { + method @com.google.common.annotations.Beta public com.google.firebase.firestore.pipeline.Expression geoDistance(com.google.firebase.firestore.GeoPoint location); field public static final com.google.firebase.firestore.pipeline.Field.Companion Companion; } @@ -1840,6 +1850,19 @@ package com.google.firebase.firestore.pipeline { property public final error.NonExistentClass PERCENT; } + @com.google.common.annotations.Beta public final class SearchStage extends com.google.firebase.firestore.pipeline.Stage { + method public com.google.firebase.firestore.pipeline.SearchStage withAddFields(com.google.firebase.firestore.pipeline.Selectable field, com.google.firebase.firestore.pipeline.Selectable... additionalFields); + method public static com.google.firebase.firestore.pipeline.SearchStage withQuery(com.google.firebase.firestore.pipeline.BooleanExpression query); + method public static com.google.firebase.firestore.pipeline.SearchStage withQuery(String rquery); + method public com.google.firebase.firestore.pipeline.SearchStage withSort(com.google.firebase.firestore.pipeline.Ordering order, com.google.firebase.firestore.pipeline.Ordering... additionalOrderings); + field public static final com.google.firebase.firestore.pipeline.SearchStage.Companion Companion; + } + + public static final class SearchStage.Companion { + method public com.google.firebase.firestore.pipeline.SearchStage withQuery(com.google.firebase.firestore.pipeline.BooleanExpression query); + method public com.google.firebase.firestore.pipeline.SearchStage withQuery(String rquery); + } + public abstract class Selectable extends com.google.firebase.firestore.pipeline.Expression { ctor public Selectable(); } @@ -1851,6 +1874,10 @@ package com.google.firebase.firestore.pipeline { method protected final T withOption(String key, error.NonExistentClass value); method public final T withOption(String key, String value); method public final T withOption(String key, long value); + field public static final com.google.firebase.firestore.pipeline.Stage.Companion Companion; + } + + public static final class Stage.Companion { } public final class SubcollectionSource extends com.google.firebase.firestore.pipeline.Stage { diff --git a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/PipelineSearchTest.kt b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/PipelineSearchTest.kt new file mode 100644 index 00000000000..f8c4148e197 --- /dev/null +++ b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/PipelineSearchTest.kt @@ -0,0 +1,669 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package com.google.firebase.firestore + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.google.android.gms.tasks.Task +import com.google.android.gms.tasks.Tasks +import com.google.common.truth.Truth.assertThat +import com.google.firebase.firestore.pipeline.Expression.Companion.and +import com.google.firebase.firestore.pipeline.Expression.Companion.documentMatches +import com.google.firebase.firestore.pipeline.Expression.Companion.field +import com.google.firebase.firestore.pipeline.Expression.Companion.score +import com.google.firebase.firestore.pipeline.SearchStage +import com.google.firebase.firestore.testutil.IntegrationTestUtil +import org.junit.Assume +import org.junit.BeforeClass +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class PipelineSearchTest { + companion object { + private lateinit var firestore: FirebaseFirestore + private lateinit var restaurantsCollection: CollectionReference + + private const val COLLECTION_NAME = "TextSearchIntegrationTests" + + private val restaurantDocs: Map> = + mapOf( + "sunnySideUp" to + mapOf( + "name" to "The Sunny Side Up", + "description" to + "A cozy neighborhood diner serving classic breakfast favorites all day long, from fluffy pancakes to savory omelets.", + "location" to GeoPoint(39.7541, -105.0002), + "menu" to + "

Breakfast Classics

  • Denver Omelet - $12
  • Buttermilk Pancakes - $10
  • Steak and Eggs - $16

Sides

  • Hash Browns - $4
  • Thick-cut Bacon - $5
  • Drip Coffee - $2
", + "average_price_per_person" to 15 + ), + "goldenWaffle" to + mapOf( + "name" to "The Golden Waffle", + "description" to + "Specializing exclusively in Belgian-style waffles. Open daily from 6:00 AM to 11:00 AM.", + "location" to GeoPoint(39.7183, -104.9621), + "menu" to + "

Signature Waffles

  • Strawberry Delight - $11
  • Chicken and Waffles - $14
  • Chocolate Chip Crunch - $10

Drinks

  • Fresh OJ - $4
  • Artisan Coffee - $3
", + "average_price_per_person" to 13 + ), + "lotusBlossomThai" to + mapOf( + "name" to "Lotus Blossom Thai", + "description" to + "Authentic Thai cuisine featuring hand-crushed spices and traditional family recipes from the Chiang Mai region.", + "location" to GeoPoint(39.7315, -104.9847), + "menu" to + "

Appetizers

  • Spring Rolls - $7
  • Chicken Satay - $9

Main Course

  • Pad Thai - $15
  • Green Curry - $16
  • Drunken Noodles - $15
", + "average_price_per_person" to 22 + ), + "mileHighCatch" to + mapOf( + "name" to "Mile High Catch", + "description" to + "Freshly sourced seafood offering a wide variety of Pacific fish and Atlantic shellfish in an upscale atmosphere.", + "location" to GeoPoint(39.7401, -104.9903), + "menu" to + "

From the Raw Bar

  • Oysters (Half Dozen) - $18
  • Lobster Cocktail - $22

Entrees

  • Pan-Seared Salmon - $28
  • King Crab Legs - $45
  • Fish and Chips - $19
", + "average_price_per_person" to 45 + ), + "peakBurgers" to + mapOf( + "name" to "Peak Burgers", + "description" to + "Casual burger joint focused on locally sourced Colorado beef and hand-cut fries.", + "location" to GeoPoint(39.7622, -105.0125), + "menu" to + "

Burgers

  • The Peak Double - $12
  • Bison Burger - $15
  • Veggie Stack - $11

Sides

  • Truffle Fries - $6
  • Onion Rings - $5
", + "average_price_per_person" to 18 + ), + "solTacos" to + mapOf( + "name" to "El Sol Tacos", + "description" to + "A vibrant street-side taco stand serving up quick, delicious, and traditional Mexican street food.", + "location" to GeoPoint(39.6952, -105.0274), + "menu" to + "

Tacos ($3.50 each)

  • Al Pastor
  • Carne Asada
  • Pollo Asado
  • Nopales (Cactus)

Beverages

  • Horchata - $4
  • Mexican Coke - $3
", + "average_price_per_person" to 12 + ), + "eastsideTacos" to + mapOf( + "name" to "Eastside Cantina", + "description" to + "Authentic street tacos and hand-shaken margaritas on the vibrant east side of the city.", + "location" to GeoPoint(39.735, -104.885), + "menu" to + "

Tacos

  • Carnitas Tacos - $4
  • Barbacoa Tacos - $4.50
  • Shrimp Tacos - $5

Drinks

  • House Margarita - $9
  • Jarritos - $3
", + "average_price_per_person" to 18 + ), + "eastsideChicken" to + mapOf( + "name" to "Eastside Chicken", + "description" to "Fried chicken to go - next to Eastside Cantina.", + "location" to GeoPoint(39.735, -104.885), + "menu" to + "

Fried Chicken

  • Drumstick - $4
  • Wings - $1
  • Sandwich - $9

Drinks

  • House Margarita - $9
  • Jarritos - $3
", + "average_price_per_person" to 12 + ) + ) + + @JvmStatic + @BeforeClass + fun setupRestaurantDocs() { + Assume.assumeTrue( + "true".equals( + InstrumentationRegistry.getArguments().getString("RUN_SEARCH_TESTS"), + ignoreCase = true + ) + ) + + Assume.assumeTrue( + IntegrationTestUtil.getBackendEdition() == IntegrationTestUtil.BackendEdition.ENTERPRISE + ) + + firestore = IntegrationTestUtil.testFirestore() + restaurantsCollection = firestore.collection(COLLECTION_NAME) + + val collectionSnapshot = IntegrationTestUtil.waitFor(restaurantsCollection.get()) + val expectedDocIds = restaurantDocs.keys + val deletes = mutableListOf>() + for (ds in collectionSnapshot.documents) { + if (!expectedDocIds.contains(ds.id)) { + deletes.add(ds.reference.delete()) + } + } + IntegrationTestUtil.waitFor(Tasks.whenAll(deletes)) + + // Add/overwrite all restaurant docs + val writes = mutableListOf>() + for ((id, data) in restaurantDocs) { + writes.add(restaurantsCollection.document(id).set(data)) + } + IntegrationTestUtil.waitFor(Tasks.whenAll(writes)) + } + } + + private fun assertResultIds(snapshot: Pipeline.Snapshot, vararg ids: String) { + val resultIds = snapshot.results.mapNotNull { it.getId() } + assertThat(resultIds).containsExactly(*ids).inOrder() + } + + // ========================================================================= + // Search stage + // ========================================================================= + + // --- DISABLE query expansion --- + + // query + // TODO(search) enable with backend support + // @Test + // fun searchWithLanguageCode() { + // val ppl = + // firestore + // .pipeline() + // .collection(COLLECTION_NAME) + // .search( + // SearchStage.withQuery("waffles") + // .withLanguageCode("en") + // .withQueryEnhancement(SearchStage.QueryEnhancement.DISABLED) + // ) + // + // val snapshot = IntegrationTestUtil.waitFor(ppl.execute()) + // assertResultIds(snapshot, "goldenWaffle") + // } + + @Test + fun searchFullDocument() { + val ppl = + firestore + .pipeline() + .collection(COLLECTION_NAME) + .search( + SearchStage( + query = documentMatches("waffles"), + // queryEnhancement = SearchStage.QueryEnhancement.DISABLED + ) + ) + + val snapshot = IntegrationTestUtil.waitFor(ppl.execute()) + assertResultIds(snapshot, "goldenWaffle") + } + + // TODO(search) enable with backend support + // @Test + // fun searchSpecificField() { + // val ppl = + // firestore + // .pipeline() + // .collection(COLLECTION_NAME) + // .search( + // SearchStage.withQuery(field("menu").matches("waffles")) + // .withQueryEnhancement(SearchStage.QueryEnhancement.DISABLED) + // ) + // + // val snapshot = IntegrationTestUtil.waitFor(ppl.execute()) + // assertResultIds(snapshot, "goldenWaffle") + // } + + @Test + fun geoNearQuery() { + val ppl = + firestore + .pipeline() + .collection(COLLECTION_NAME) + .search( + SearchStage.withQuery( + field("location").geoDistance(GeoPoint(39.6985, -105.024)).lessThan(1000) + ) + // .withQueryEnhancement(SearchStage.QueryEnhancement.DISABLED) + ) + + val snapshot = IntegrationTestUtil.waitFor(ppl.execute()) + assertResultIds(snapshot, "solTacos") + } + + // TODO(search) enable with backend support + // @Test + // fun conjunctionOfTextSearchPredicates() { + // val ppl = + // firestore + // .pipeline() + // .collection(COLLECTION_NAME) + // .search( + // SearchStage.withQuery( + // and(field("menu").matches("waffles"), field("description").matches("diner")) + // ) + // .withQueryEnhancement(SearchStage.QueryEnhancement.DISABLED) + // ) + // + // val snapshot = IntegrationTestUtil.waitFor(ppl.execute()) + // assertResultIds(snapshot, "goldenWaffle", "sunnySideUp") + // } + + // TODO(search) enable with backend support + // @Test + // fun conjunctionOfTextSearchAndGeoNear() { + // val ppl = + // firestore + // .pipeline() + // .collection(COLLECTION_NAME) + // .search( + // SearchStage.withQuery( + // and( + // field("menu").matches("tacos"), + // field("location").geoDistance(GeoPoint(39.6985, -105.024)).lessThan(10_000) + // ) + // ) + // .withQueryEnhancement(SearchStage.QueryEnhancement.DISABLED) + // ) + // + // val snapshot = IntegrationTestUtil.waitFor(ppl.execute()) + // assertResultIds(snapshot, "solTacos") + // } + + @Test + fun negateMatch() { + val ppl = + firestore + .pipeline() + .collection(COLLECTION_NAME) + .search( + SearchStage.withQuery(documentMatches("coffee -waffles")) + // .withQueryEnhancement(SearchStage.QueryEnhancement.DISABLED) + ) + + val snapshot = IntegrationTestUtil.waitFor(ppl.execute()) + assertResultIds(snapshot, "sunnySideUp") + } + + // TODO(search) enable with backend support + // @Test + // fun rquerySearchTheDocumentWithConjunctionAndDisjunction() { + // val ppl = + // firestore + // .pipeline() + // .collection(COLLECTION_NAME) + // .search( + // SearchStage.withQuery(documentMatches("(waffles OR pancakes) AND coffee")) + // .withQueryEnhancement(SearchStage.QueryEnhancement.DISABLED) + // ) + // + // val snapshot = IntegrationTestUtil.waitFor(ppl.execute()) + // assertResultIds(snapshot, "goldenWaffle", "sunnySideUp") + // } + + @Test + fun rqueryAsQueryParam() { + val ppl = + firestore + .pipeline() + .collection(COLLECTION_NAME) + .search( + SearchStage.withQuery("chicken wings") + // .withQueryEnhancement(SearchStage.QueryEnhancement.DISABLED) + ) + + val snapshot = IntegrationTestUtil.waitFor(ppl.execute()) + assertResultIds(snapshot, "eastsideChicken") + } + + // TODO(search) enable with backend support + // @Test + // fun rquerySupportsFieldPaths() { + // val ppl = + // firestore + // .pipeline() + // .collection(COLLECTION_NAME) + // .search( + // SearchStage.withQuery("menu:(waffles OR pancakes) AND description:\"breakfast all + // day\"") + // .withQueryEnhancement(SearchStage.QueryEnhancement.DISABLED) + // ) + // + // val snapshot = IntegrationTestUtil.waitFor(ppl.execute()) + // assertResultIds(snapshot, "sunnySideUp") + // } + + // TODO(search) enable with backend support + // @Test + // fun conjunctionOfRqueryAndExpression() { + // val ppl = + // firestore + // .pipeline() + // .collection(COLLECTION_NAME) + // .search( + // SearchStage.withQuery( + // and( + // documentMatches("tacos"), + // greaterThanOrEqual(field("average_price_per_person"), 8), + // lessThanOrEqual(field("average_price_per_person"), 15) + // ) + // ) + // .withQueryEnhancement(SearchStage.QueryEnhancement.DISABLED) + // ) + // + // val snapshot = IntegrationTestUtil.waitFor(ppl.execute()) + // assertResultIds(snapshot, "solTacos") + // } + + // --- REQUIRE query expansion --- + + // TODO(search) enable with backend support + // @Test + // fun requireQueryExpansion_searchFullDocument() { + // val ppl = + // firestore + // .pipeline() + // .collection(COLLECTION_NAME) + // .search( + // SearchStage.withQuery(documentMatches("waffles")) + // .withQueryEnhancement(SearchStage.QueryEnhancement.REQUIRED) + // ) + // + // val snapshot = IntegrationTestUtil.waitFor(ppl.execute()) + // assertResultIds(snapshot, "goldenWaffle", "sunnySideUp") + // } + + // TODO(search) enable with backend support + // @Test + // fun requireQueryExpansion_searchSpecificField() { + // val ppl = + // firestore + // .pipeline() + // .collection(COLLECTION_NAME) + // .search( + // SearchStage.withQuery(field("menu").matches("waffles")) + // .withQueryEnhancement(SearchStage.QueryEnhancement.REQUIRED) + // ) + // + // val snapshot = IntegrationTestUtil.waitFor(ppl.execute()) + // assertResultIds(snapshot, "goldenWaffle", "sunnySideUp") + // } + + // add fields + @Test + fun addFields_score() { + val ppl = + firestore + .pipeline() + .collection(COLLECTION_NAME) + .search( + SearchStage.withQuery(documentMatches("waffles")) + .withAddFields( + score().alias("searchScore"), + // field("menu").snippet("waffles").alias("snippet") + ) + // .withQueryEnhancement(SearchStage.QueryEnhancement.DISABLED) + ) + .select("name", "searchScore", "snippet") + + val snapshot = IntegrationTestUtil.waitFor(ppl.execute()) + assertThat(snapshot.results).hasSize(1) + val result = snapshot.results[0] + assertThat(result.get("name")).isEqualTo("The Golden Waffle") + assertThat(result.get("searchScore") as Double).isGreaterThan(0.0) + // assertThat((result.get("snippet") as String).length).isGreaterThan(0) + } + @Test + fun addFields_geoDistance() { + val ppl = + firestore + .pipeline() + .collection(COLLECTION_NAME) + .search( + SearchStage.withQuery(documentMatches("waffles")) + .withAddFields( + field("location").geoDistance(GeoPoint(39.6985, -105.024)).alias("distance"), + ) + // .withQueryEnhancement(SearchStage.QueryEnhancement.DISABLED) + ) + .select("name", "distance") + + val snapshot = IntegrationTestUtil.waitFor(ppl.execute()) + assertThat(snapshot.results).hasSize(1) + val result = snapshot.results[0] + assertThat(result.get("name")).isEqualTo("The Golden Waffle") + assertThat(result.get("distance") as Double).isGreaterThan(0.0) + } + + // select + // TODO(search) enable with backend support + // @Test + // fun select_topicalityScoreAndSnippet() { + // val ppl = + // firestore + // .pipeline() + // .collection(COLLECTION_NAME) + // .search( + // SearchStage.withQuery(field("menu").matches("waffles")) + // .withSelect( + // field("name"), + // field("location"), + // score().alias("searchScore"), + // snippet("menu", "waffles").alias("snippet") + // ) + // .withQueryEnhancement(SearchStage.QueryEnhancement.DISABLED) + // ) + // + // val snapshot = IntegrationTestUtil.waitFor(ppl.execute()) + // assertThat(snapshot.results).hasSize(1) + // val result = snapshot.results[0] + // assertThat(result.get("name")).isEqualTo("The Golden Waffle") + // assertThat(result.get("location")).isEqualTo(GeoPoint(39.7183, -104.9621)) + // assertThat(result.get("searchScore") as Double).isGreaterThan(0.0) + // assertThat((result.get("snippet") as String).length).isGreaterThan(0) + // assertThat(result.getData().keys.sorted()) + // .containsExactly("location", "name", "searchScore", "snippet") + // } + + // sort + @Test + fun sort_byScore() { + val ppl = + firestore + .pipeline() + .collection(COLLECTION_NAME) + .search( + SearchStage.withQuery(documentMatches("tacos")).withSort(score().descending()) + // .withQueryEnhancement(SearchStage.QueryEnhancement.DISABLED) + ) + + val snapshot = IntegrationTestUtil.waitFor(ppl.execute()) + assertResultIds(snapshot, "eastsideTacos", "solTacos") + } + + @Test + fun sort_byDistance() { + val ppl = + firestore + .pipeline() + .collection(COLLECTION_NAME) + .search( + SearchStage.withQuery(documentMatches("tacos")) + .withSort(field("location").geoDistance(GeoPoint(39.6985, -105.024)).ascending()) + // .withQueryEnhancement(SearchStage.QueryEnhancement.DISABLED) + ) + + val snapshot = IntegrationTestUtil.waitFor(ppl.execute()) + assertResultIds(snapshot, "solTacos", "eastsideTacos") + } + + // TODO(search) enable with backend support + // @Test + // fun sort_byMultipleOrderings() { + // val ppl = + // firestore + // .pipeline() + // .collection(COLLECTION_NAME) + // .search( + // SearchStage.withQuery(field("menu").matches("tacos OR chicken")) + // .withSort( + // field("location").geoDistance(GeoPoint(39.6985, -105.024)).ascending(), + // score().descending() + // ) + // .withQueryEnhancement(SearchStage.QueryEnhancement.DISABLED) + // ) + // + // val snapshot = IntegrationTestUtil.waitFor(ppl.execute()) + // assertResultIds(snapshot, "solTacos", "eastsideTacos", "eastsideChicken") + // } + + // limit + // TODO(search) enable with backend support + // @Test + // fun limit_limitsTheNumberOfDocumentsReturned() { + // val ppl = + // firestore + // .pipeline() + // .collection(COLLECTION_NAME) + // .search( + // SearchStage.withQuery(constant(true)) + // .withSort(field("location").geoDistance(GeoPoint(39.6985, -105.024)).ascending()) + // .withLimit(5) + // .withQueryEnhancement(SearchStage.QueryEnhancement.DISABLED) + // ) + // + // val snapshot = IntegrationTestUtil.waitFor(ppl.execute()) + // assertResultIds(snapshot, "solTacos", "lotusBlossomThai", "goldenWaffle") + // } + + // TODO(search) enable with backend support + // @Test + // fun limit_limitsTheNumberOfDocumentsScored() { + // val ppl = + // firestore + // .pipeline() + // .collection(COLLECTION_NAME) + // .search( + // SearchStage.withQuery(field("menu").matches("chicken OR tacos OR fish OR waffles")) + // .withRetrievalDepth(6) + // .withQueryEnhancement(SearchStage.QueryEnhancement.DISABLED) + // ) + // + // val snapshot = IntegrationTestUtil.waitFor(ppl.execute()) + // assertResultIds(snapshot, "eastsideChicken", "eastsideTacos", "solTacos", "mileHighCatch") + // } + + // offset + // TODO(search) enable with backend support + // @Test + // fun offset_skipsNDocuments() { + // val ppl = + // firestore + // .pipeline() + // .collection(COLLECTION_NAME) + // .search( + // SearchStage.withQuery(constant(true)) + // .withLimit(2) + // .withOffset(2) + // .withQueryEnhancement(SearchStage.QueryEnhancement.DISABLED) + // ) + // + // val snapshot = IntegrationTestUtil.waitFor(ppl.execute()) + // assertResultIds(snapshot, "eastsideChicken", "eastsideTacos") + // } + + // ========================================================================= + // Snippet + // ========================================================================= + + // TODO(search) enable with backend support + // @Test + // @Ignore("Snippet options not implemented yet") + // fun snippetOptions() { + // val ppl1 = + // firestore + // .pipeline() + // .collection(COLLECTION_NAME) + // .search( + // SearchStage.withQuery(field("menu").matches("waffles")) + // .withAddFields( + // field("menu") + // .snippet(SnippetOptions("waffles").withMaxSnippetWidth(10)) + // .alias("snippet") + // ) + // .withQueryEnhancement(SearchStage.QueryEnhancement.DISABLED) + // ) + // + // val snapshot1 = IntegrationTestUtil.waitFor(ppl1.execute()) + // assertThat(snapshot1.results).hasSize(1) + // assertThat(snapshot1.results[0].get("name")).isEqualTo("The Golden Waffle") + // val snip1 = snapshot1.results[0].get("snippet") as String + // assertThat(snip1.length).isGreaterThan(0) + // + // val ppl2 = + // firestore + // .pipeline() + // .collection(COLLECTION_NAME) + // .search( + // SearchStage.withQuery(field("menu").matches("waffles")) + // .withAddFields( + // field("menu") + // .snippet(SnippetOptions("waffles").withMaxSnippetWidth(1000)) + // .alias("snippet") + // ) + // .withQueryEnhancement(SearchStage.QueryEnhancement.DISABLED) + // ) + // + // val snapshot2 = IntegrationTestUtil.waitFor(ppl2.execute()) + // assertThat(snapshot2.results).hasSize(1) + // assertThat(snapshot2.results[0].get("name")).isEqualTo("The Golden Waffle") + // val snip2 = snapshot2.results[0].get("snippet") as String + // assertThat(snip2.length).isGreaterThan(snip1.length) + // } + + // TODO(search) enable with backend support + // @Test + // fun snippetOnMultipleFields() { + // // Get snippet from 1 field + // val ppl1 = + // firestore + // .pipeline() + // .collection(COLLECTION_NAME) + // .search( + // SearchStage.withQuery(documentMatches("waffle")) + // .withAddFields(field("menu").snippet("waffles").alias("snippet")) + // .withQueryEnhancement(SearchStage.QueryEnhancement.DISABLED) + // ) + // + // val snapshot1 = IntegrationTestUtil.waitFor(ppl1.execute()) + // assertThat(snapshot1.results).hasSize(1) + // assertThat(snapshot1.results[0].get("name")).isEqualTo("The Golden Waffle") + // val snip1 = snapshot1.results[0].get("snippet") as String + // assertThat(snip1.length).isGreaterThan(0) + // + // // Get snippet from 2 fields + // val ppl2 = + // firestore + // .pipeline() + // .collection(COLLECTION_NAME) + // .search( + // SearchStage.withQuery(documentMatches("waffle")) + // .withAddFields( + // concat(field("menu"), field("description")) + // .snippet(SnippetOptions("waffles").withMaxSnippetWidth(2000)) + // .alias("snippet") + // ) + // .withQueryEnhancement(SearchStage.QueryEnhancement.DISABLED) + // ) + // + // val snapshot2 = IntegrationTestUtil.waitFor(ppl2.execute()) + // assertThat(snapshot2.results).hasSize(1) + // assertThat(snapshot2.results[0].get("name")).isEqualTo("The Golden Waffle") + // val snip2 = snapshot2.results[0].get("snippet") as String + // assertThat(snip2.length).isGreaterThan(snip1.length) + // } +} diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt b/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt index d4004e9caa2..81bc671a366 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt @@ -16,6 +16,7 @@ package com.google.firebase.firestore import com.google.android.gms.tasks.Task import com.google.android.gms.tasks.TaskCompletionSource +import com.google.common.annotations.Beta import com.google.firebase.Timestamp import com.google.firebase.firestore.model.Document import com.google.firebase.firestore.model.DocumentKey @@ -51,6 +52,7 @@ import com.google.firebase.firestore.pipeline.RawStage import com.google.firebase.firestore.pipeline.RemoveFieldsStage import com.google.firebase.firestore.pipeline.ReplaceStage import com.google.firebase.firestore.pipeline.SampleStage +import com.google.firebase.firestore.pipeline.SearchStage import com.google.firebase.firestore.pipeline.SelectStage import com.google.firebase.firestore.pipeline.Selectable import com.google.firebase.firestore.pipeline.SortStage @@ -143,7 +145,7 @@ internal constructor( return Pipeline(firestore, userDataReader, stages.plus(stage)) } - private fun toStructuredPipelineProto( + internal fun toStructuredPipelineProto( options: InternalOptions?, userDataReader: UserDataReader ): StructuredPipeline { @@ -159,7 +161,7 @@ internal constructor( .build() } - private fun toExecutePipelineRequest(options: InternalOptions?): ExecutePipelineRequest { + internal fun toExecutePipelineRequest(options: InternalOptions?): ExecutePipelineRequest { checkNotNull(firestore) { "This pipeline was created without a database (e.g., as a subcollection pipeline) and cannot be executed directly. It can only be used as part of another pipeline." } @@ -1076,6 +1078,29 @@ internal constructor( fun toScalarExpression(): Expression { return FunctionExpression("scalar", notImplemented, Expression.toExprOrConstant(this)) } + + /** + * Add a search stage to the Pipeline. + * + * Note: This must be the first stage of the pipeline. + * + * A limited set of expressions are supported in the search stage. + * + * @example + * ```kotlin + * db.pipeline().collection('restaurants').search( + * SearchStage( + * query = documentMatches("waffles OR pancakes"), + * sort = arrayOf(score().descending()), + * limit = 10 + * ) + * ) + * ``` + * + * @param searchStage An object that specifies how search is performed. + * @return A new `Pipeline` object with this stage appended to the stage list. + */ + @Beta fun search(searchStage: SearchStage): Pipeline = append(searchStage) } /** Start of a Firestore Pipeline */ diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/expressions.kt b/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/expressions.kt index e485208b1b0..7ccc52b62d2 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/expressions.kt +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/expressions.kt @@ -14,6 +14,7 @@ package com.google.firebase.firestore.pipeline +import com.google.common.annotations.Beta import com.google.firebase.Timestamp import com.google.firebase.firestore.Blob import com.google.firebase.firestore.DocumentReference @@ -65,9 +66,11 @@ abstract class Expression internal constructor() { override fun evaluateFunction(context: EvaluationContext) = { _: MutableDocument -> EvaluateResultValue(value) } + override fun toString(): String { return canonicalId() } + override fun canonicalId() = "cst(${canonicalId(value)})" override fun equals(other: Any?): Boolean { @@ -6559,8 +6562,258 @@ abstract class Expression internal constructor() { */ @JvmStatic fun currentDocument(): Expression = FunctionExpression("current_document", notImplemented) + + /** + * Evaluates to the distance in meters between the location in the specified field and the query + * location. + * + * Note: This Expression can only be used within a `Search` stage. + * + * @example + * ```kotlin + * db.pipeline().collection("restaurants").search( + * SearchStage(query = documentMatches("waffles"), sort = arrayOf(geoDistance("location", GeoPoint(37.0, -122.0)).ascending())) + * ) + * ``` + * + * @param fieldName Specifies the field in the document which contains the first GeoPoint for + * distance computation. + * @param location Compute distance to this GeoPoint. + */ + @Beta + @JvmStatic + fun geoDistance(fieldName: String, location: GeoPoint): Expression = + geoDistance(field(fieldName), location) + + /** + * Evaluates to the distance in meters between the location in the specified field and the query + * location. + * + * Note: This Expression can only be used within a `Search` stage. + * + * @example + * ```kotlin + * db.pipeline().collection("restaurants").search( + * SearchStage(query = documentMatches("waffles"), sort = arrayOf(geoDistance(field("location"), GeoPoint(37.0, -122.0)).ascending())) + * ) + * ``` + * + * @param field Specifies the field in the document which contains the first GeoPoint for + * distance computation. + * @param location Compute distance to this GeoPoint. + */ + @Beta + @JvmStatic + fun geoDistance(field: Field, location: GeoPoint): Expression = + FunctionExpression("geo_distance", notImplemented, field, constant(location)) + + /** + * Perform a full-text search on all indexed search fields in the document. + * + * Note: This Expression can only be used within a `Search` stage. + * + * @example + * ```kotlin + * db.pipeline().collection("restaurants").search( + * SearchStage(query = documentMatches("waffles OR pancakes")) + * ) + * ``` + * + * @param rquery Define the search query using the search DSL. + */ + @Beta + @JvmStatic + fun documentMatches(rquery: String): BooleanExpression = + BooleanFunctionExpression("document_matches", notImplemented, constant(rquery)) + + // /** + // * Perform a full-text search on the specified field. + // * + // * Note: This Expression can only be used within a `Search` stage. + // * + // * @example + // * ```kotlin + // * db.pipeline().collection("restaurants").search( + // * SearchStage(query = matches("menu", "waffles")) + // * ) + // * ``` + // * + // * @param fieldName Perform search on this field. + // * @param rquery Define the search query using the search DSL. + // */ + // // TODO(search) this is internal until supported by the backend + // @Beta + // @JvmStatic + // internal fun matches(fieldName: String, rquery: String): BooleanExpression = + // matches(field(fieldName), rquery) + // + // /** + // * Perform a full-text search on the specified field. + // * + // * Note: This Expression can only be used within a `Search` stage. + // * + // * @example + // * ```kotlin + // * db.pipeline().collection("restaurants").search( + // * SearchStage(query = matches(field("menu"), "waffles")) + // * ) + // * ``` + // * + // * @param field Perform search on this field. + // * @param rquery Define the search query using the search DSL. + // */ + // // TODO(search) this is internal until supported by the backend + // @Beta + // @JvmStatic + // internal fun matches(field: Field, rquery: String): BooleanExpression = + // BooleanFunctionExpression("matches", notImplemented, field, constant(rquery)) + + /** + * Evaluates to the search score that reflects the topicality of the document to all of the text + * predicates (for example: `documentMatches`) in the search query. If `SearchOptions.query` is + * not set or does not contain any text predicates, then this score will always be `0`. + * + * Note: This Expression can only be used within a `Search` stage. + * @example + * ```kotlin + * db.pipeline().collection("restaurants").search( + * SearchStage(query = documentMatches("waffles"), sort = arrayOf(score().descending())) + * ) + * ``` + */ + @Beta @JvmStatic fun score(): Expression = FunctionExpression("score", notImplemented) + + // /** + // * Evaluates to an HTML-formatted text snippet that highlights terms matching the search + // query + // * in `bold`. + // * + // * This Expression can only be used within a `Search` stage. + // * + // * @example + // * ```kotlin + // * db.pipeline().collection("restaurants").search( + // * SearchStage(query = documentMatches("waffles"), addFields = arrayOf(snippet("menu", + // "waffles").alias("snippet"))) + // * ) + // * ``` + // * + // * @param fieldName Search the specified field for matching terms. + // * @param rquery Define the search query using the search DSL. + // */ + // @Beta + // @JvmStatic + // fun snippet(fieldName: String, rquery: String): Expression = + // FunctionExpression("snippet", notImplemented, field(fieldName), constant(rquery)) + + // /** + // * Evaluates to an HTML-formatted text snippet that highlights terms matching the search + // query + // * in `bold`. + // * + // * This Expression can only be used within a `Search` stage. + // * + // * @param fieldName Search the specified field for matching terms. + // * @param options Define how the snippet is generated. + // */ + // // TODO(search) snippet with options is internal and unimplemented until supported by the + // // backend + // @Beta + // @JvmStatic + // internal fun snippet(fieldName: String, options: SnippetOptions): Expression { + // throw NotImplementedError("Not implemented") + // } + + // /** + // * Evaluates if the value in the field specified by `fieldName` is between the evaluated + // values + // * for `lowerBound` (inclusive) and `upperBound` (inclusive). + // * + // * @param fieldName Determine if this field is between two bounds. + // * @param lowerBound Lower bound (inclusive). + // * @param upperBound Upper bound (inclusive). + // */ + // // TODO(search) between is internal and unimplemented until supported by the backend + // @JvmStatic + // internal fun between( + // fieldName: String, + // lowerBound: Expression, + // upperBound: Expression + // ): BooleanExpression = + // BooleanFunctionExpression("between", notImplemented, fieldName, lowerBound, upperBound) + + // /** + // * Evaluates if the value in the field specified by `fieldName` is between the values for + // * `lowerBound` (inclusive) and `upperBound` (inclusive). + // * + // * @param fieldName Determine if this field is between two bounds. + // * @param lowerBound Lower bound (inclusive). + // * @param upperBound Upper bound (inclusive). + // */ + // @JvmStatic + // internal fun between(fieldName: String, lowerBound: Any, upperBound: Any): + // BooleanExpression = + // between(fieldName, toExprOrConstant(lowerBound), toExprOrConstant(upperBound)) + + // /** + // * Evaluates if the result of the specified `expression` is between the results of + // `lowerBound` + // * (inclusive) and `upperBound` (inclusive). + // * + // * @param expression Determine if the result of this expression is between two bounds. + // * @param lowerBound Lower bound (inclusive). + // * @param upperBound Upper bound (inclusive). + // */ + // @JvmStatic + // internal fun between( + // expression: Expression, + // lowerBound: Expression, + // upperBound: Expression + // ): BooleanExpression = + // BooleanFunctionExpression("between", notImplemented, expression, lowerBound, upperBound) + + // /** + // * Evaluates if the result of the specified `expression` is between the `lowerBound` + // (inclusive) + // * and `upperBound` (inclusive). + // * + // * @param expression Determine if the result of this expression is between two bounds. + // * @param lowerBound Lower bound (inclusive). + // * @param upperBound Upper bound (inclusive). + // */ + // @JvmStatic + // internal fun between( + // expression: Expression, + // lowerBound: Any, + // upperBound: Any + // ): BooleanExpression = + // between(expression, toExprOrConstant(lowerBound), toExprOrConstant(upperBound)) } + // // TODO(search) SnippetOptions is internal until supported by the backend + // @Beta + // internal class SnippetOptions private constructor(options: InternalOptions) : + // AbstractOptions(options) { + // /** Creates a new, empty `SnippetOptions` object. */ + // constructor(rquery: String) : this(InternalOptions.EMPTY.with("query", encodeValue(rquery))) + // + // fun withMaxSnippetWidth(max: Int): SnippetOptions { + // return with("max_snippet_width", encodeValue(max)) + // } + // + // fun withMaxSnippets(max: Int): SnippetOptions { + // return with("max_snippets", encodeValue(max)) + // } + // + // fun withSeparator(separator: String): SnippetOptions { + // return with("separator", encodeValue(separator)) + // } + // + // internal override fun self(options: InternalOptions): SnippetOptions { + // return SnippetOptions(options) + // } + // } + /** * Creates an expression that applies a bitwise AND operation with other expression. * @@ -9058,6 +9311,86 @@ abstract class Expression internal constructor() { } } + // /** + // * Evaluates to an HTML-formatted text snippet that highlights terms matching the search query + // in + // * `bold`. + // * + // * Note: This Expression can only be used within a `Search` stage. + // * + // * @param rquery Define the search query using the search DTS. + // */ + // @Beta + // fun snippet(rquery: String): Expression = + // FunctionExpression( + // "snippet", + // notImplemented, + // arrayOf(this, constant(rquery)), + // SnippetOptions(rquery).options + // ) + // + // /** + // * Evaluates to an HTML-formatted text snippet that highlights terms matching the search query + // in + // * `bold`. + // * + // * Note: This Expression can only be used within a `Search` stage. + // * + // * @param options Define how the snippet is generated. + // * + // * TODO(search) implement snippet with SnippetOptions - out of scope for first release + // */ + // @Beta + // internal fun snippet(options: SnippetOptions): Expression { + // throw NotImplementedError() + // } + // + // /** + // * Evaluates if the result of this `expression` is between the `lowerBound` (inclusive) and + // * `upperBound` (inclusive). + // * + // * @example + // * ``` + // * // Evaluate if the 'tireWidth' is between 2.2 and 2.4 + // * field('tireWidth').between(constant(2.2), constant(2.4)) + // * + // * // This is functionally equivalent to + // * and( + // * field('tireWidth').greaterThanOrEqual(constant(2.2)), + // * field('tireWidth').lessThanOrEqual(constant(2.4))) + // * ``` + // * + // * @param lowerBound Lower bound (inclusive). + // * @param upperBound Upper bound (inclusive). + // * + // * TODO(search) publish between - out of scope for first release + // */ + // internal fun between(lowerBound: Expression, upperBound: Expression): BooleanExpression = + // Companion.between(this, lowerBound, upperBound) + // + // /** + // * Evaluates if the result of this `expression` is between the `lowerBound` (inclusive) and + // * `upperBound` (inclusive). + // * + // * @example + // * ``` + // * // Evaluate if the 'tireWidth' is between 2.2 and 2.4 + // * field('tireWidth').between(2.2, 2.4) + // * + // * // This is functionally equivalent to + // * and( + // * field('tireWidth').greaterThanOrEqual(2.2), + // * field('tireWidth').lessThanOrEqual(2.4)) + // * ``` + // * + // * @param lowerBound Lower bound (inclusive). + // * @param upperBound Upper bound (inclusive). + // * + // * TODO(search) publish between - out of scope for first release + // */ + // internal fun between(lowerBound: Any, upperBound: Any): BooleanExpression = + // Companion.between(this, lowerBound, upperBound) + // internal abstract fun toProto(userDataReader: UserDataReader): Value internal abstract fun evaluateFunction(context: EvaluationContext): EvaluateDocument @@ -9154,6 +9487,7 @@ class Field internal constructor(internal val fieldPath: ModelFieldPath) : Selec ?: EvaluateResultUnset // This value is used if getField() returns null. } } + private fun getServerTimestamp(fieldValue: Value, context: EvaluationContext): EvaluateResult { val behavior = context.pipeline.internalOptions?.serverTimestampBehavior @@ -9180,6 +9514,25 @@ class Field internal constructor(internal val fieldPath: ModelFieldPath) : Selec override fun hashCode(): Int { return fieldPath.hashCode() } + + /** + * Evaluates to the distance in meters between the location specified by this field and the query + * location. + * + * Note: This Expression can only be used within a `Search` stage. + * + * @param location Compute distance to this GeoPoint. + */ + @Beta fun geoDistance(location: GeoPoint): Expression = geoDistance(this, location) + + // /** + // * Perform a full-text search on this field. + // * + // * Note: This Expression can only be used within a `Search` stage. + // * + // * @param rquery Define the search query using the rquery DTS. + // */ + // @Beta internal fun matches(rquery: String): BooleanExpression = matches(this, rquery) } /** @@ -9202,27 +9555,32 @@ internal constructor( params: List, options: InternalOptions = InternalOptions.EMPTY ) : this(name, FunctionRegistry.functions[name] ?: notImplemented, params.toTypedArray(), options) + internal constructor( name: String, function: EvaluateFunction ) : this(name, function, emptyArray()) + internal constructor( name: String, function: EvaluateFunction, param: Expression ) : this(name, function, arrayOf(param)) + internal constructor( name: String, function: EvaluateFunction, param: Expression, vararg params: Any ) : this(name, function, arrayOf(param, *toArrayOfExprOrConstant(params))) + internal constructor( name: String, function: EvaluateFunction, param1: Expression, param2: Expression ) : this(name, function, arrayOf(param1, param2)) + internal constructor( name: String, function: EvaluateFunction, @@ -9230,11 +9588,13 @@ internal constructor( param2: Expression, vararg params: Any ) : this(name, function, arrayOf(param1, param2, *toArrayOfExprOrConstant(params))) + internal constructor( name: String, function: EvaluateFunction, fieldName: String ) : this(name, function, arrayOf(field(fieldName))) + internal constructor( name: String, function: EvaluateFunction, @@ -9358,34 +9718,40 @@ internal class BooleanFunctionExpression internal constructor(val expr: Expressi function: EvaluateFunction, params: Array ) : this(FunctionExpression(name, function, params)) + internal constructor( name: String, function: EvaluateFunction, param: Expression ) : this(name, function, arrayOf(param)) + internal constructor( name: String, function: EvaluateFunction, param1: Expression, param2: Any ) : this(name, function, arrayOf(param1, Expression.toExprOrConstant(param2))) + internal constructor( name: String, function: EvaluateFunction, param: Expression, vararg params: Any ) : this(name, function, arrayOf(param, *Expression.toArrayOfExprOrConstant(params))) + internal constructor( name: String, function: EvaluateFunction, param1: Expression, param2: Expression ) : this(name, function, arrayOf(param1, param2)) + internal constructor( name: String, function: EvaluateFunction, fieldName: String ) : this(name, function, arrayOf(field(fieldName))) + internal constructor( name: String, function: EvaluateFunction, diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/stage.kt b/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/stage.kt index 57e539fe5f2..c83bf88efa0 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/stage.kt +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/stage.kt @@ -14,6 +14,7 @@ package com.google.firebase.firestore.pipeline +import com.google.common.annotations.Beta import com.google.firebase.firestore.UserDataReader import com.google.firebase.firestore.VectorValue import com.google.firebase.firestore.model.Document @@ -23,6 +24,7 @@ import com.google.firebase.firestore.model.ResourcePath import com.google.firebase.firestore.model.Values import com.google.firebase.firestore.model.Values.encodeValue import com.google.firebase.firestore.pipeline.Expression.Companion.constant +import com.google.firebase.firestore.pipeline.Expression.Companion.documentMatches import com.google.firebase.firestore.pipeline.Expression.Companion.field import com.google.firebase.firestore.pipeline.evaluation.EvaluationContext import com.google.firebase.firestore.remote.RemoteSerializer @@ -31,12 +33,23 @@ import com.google.firestore.v1.Value import javax.annotation.Nonnull sealed class Stage>(internal val name: String, internal val options: InternalOptions) { - internal fun toProtoStage(userDataReader: UserDataReader): Pipeline.Stage { - val builder = Pipeline.Stage.newBuilder() - builder.setName(name) - args(userDataReader).forEach(builder::addArgs) - options.forEach(builder::putOptions) - return builder.build() + companion object { + internal fun toProtoStage( + name: String, + args: Sequence, + options: InternalOptions, + userDataReader: UserDataReader + ): Pipeline.Stage { + val builder = Pipeline.Stage.newBuilder() + builder.setName(name) + args.forEach(builder::addArgs) + options.forEach(builder::putOptions) + return builder.build() + } + } + + internal open fun toProtoStage(userDataReader: UserDataReader): Pipeline.Stage { + return Stage.toProtoStage(name, args(userDataReader), options, userDataReader) } internal abstract fun canonicalId(): String @@ -846,6 +859,341 @@ class FindNearestOptions private constructor(options: InternalOptions) : } } +/** + * The Search stage executes full-text search or geo search operations. + * + * The Search stage must be the first stage in a Pipeline. + * + * @example + * ```kotlin + * db.pipeline().collection('restaurants').search( + * SearchStage( + * query = documentMatches("waffles OR pancakes"), + * sort = arrayOf(score().descending()), + * limit = 10 + * ) + * ) + * ``` + */ +@Beta +class SearchStage +internal constructor( + private val query: BooleanExpression, + // TODO(search) enable with backend support + // private val languageCode: String? = null, + // TODO add indexPartition here when supported + // private val retrievalDepth: Long? = null, + private val sort: Array? = null, + // private val offset: Long? = null, + // private val limit: Long? = null, + // private val select: Array? = null, + private val addFields: Array? = null, + // private val queryEnhancement: QueryEnhancement? = null, + options: InternalOptions = InternalOptions.EMPTY +) : Stage("search", options) { + override fun self(options: InternalOptions) = + SearchStage( + query, + // languageCode, + // retrievalDepth, + sort, + // offset, + // limit, + // select, + addFields, + // queryEnhancement, + options + ) + override fun canonicalId(): String { + TODO("Not yet implemented") + } + override fun args(userDataReader: UserDataReader): Sequence = emptySequence() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is SearchStage) return false + // if (languageCode != other.languageCode) return false + // if (retrievalDepth != other.retrievalDepth) return false + if (!sort.contentEquals(other.sort)) return false + // if (limit != other.limit) return false + // if (!select.contentEquals(other.select)) return false + if (!addFields.contentEquals(other.addFields)) return false + // if (queryEnhancement != other.queryEnhancement) return false + return true + } + + override fun hashCode(): Int { + var result = query.hashCode() + // result = 31 * result + (languageCode?.hashCode() ?: 0) + // result = 31 * result + (retrievalDepth?.hashCode() ?: 0) + result = 31 * result + (sort?.contentHashCode() ?: 0) + // result = 31 * result + (offset?.hashCode() ?: 0) + // result = 31 * result + (limit?.hashCode() ?: 0) + // result = 31 * result + (select?.contentHashCode() ?: 0) + result = 31 * result + (addFields?.contentHashCode() ?: 0) + // result = 31 * result + (queryEnhancement?.hashCode() ?: 0) + result = 31 * result + options.hashCode() + return result + } + + override fun toProtoStage(userDataReader: UserDataReader): Pipeline.Stage { + var completeOptions = options.with("query", query.toProto(userDataReader)) + + // if (languageCode != null) { + // completeOptions = completeOptions.with("language_code", encodeValue(languageCode)) + // } + // if (retrievalDepth != null) { + // completeOptions = completeOptions.with("retrieval_depth", encodeValue(retrievalDepth)) + // } + if (sort != null) { + completeOptions = completeOptions.with("sort", sort.map { it.toProto(userDataReader) }) + } + // if (offset != null) { + // completeOptions = completeOptions.with("offset", encodeValue(offset)) + // } + // if (limit != null) { + // completeOptions = completeOptions.with("limit", encodeValue(limit)) + // } + // if (select != null) { + // completeOptions = + // completeOptions.with( + // "select", + // encodeValue(associateWithoutDuplications(select, userDataReader)) + // ) + // } + if (addFields != null) { + completeOptions = + completeOptions.with( + "add_fields", + encodeValue(associateWithoutDuplications(addFields, userDataReader)) + ) + } + // if (queryEnhancement != null) { + // completeOptions = completeOptions.with("query_enhancement", queryEnhancement.proto) + // } + + return toProtoStage(name, args(userDataReader), completeOptions, userDataReader) + } + + companion object { + /** + * Create [SearchStage] with an expression search query. + * + * `query` specifies the search query that will be used to query and score documents by the + * search stage. + * + * The query can be expressed as an `Expression`, which will be used to score and filter the + * results. Not all expressions supported by Pipelines are supported in the Search query. + * + * ``` + * db.pipeline().collection('restaurants').search({ + * query: or( + * documentContainsText("breakfast"), + * field('menu').containsText('waffle AND coffee') + * ) + * }) + * ``` + */ + @JvmStatic + fun withQuery(query: BooleanExpression): SearchStage { + return SearchStage(query) + } + + /** + * Create [SearchStage] with an expression search query. + * + * `query` specifies the search query that will be used to query and score documents by the + * search stage. + * + * The query can also be expressed as a string in the Search DSL: + * + * ``` + * db.pipeline().collection('restaurants').search({ + * query: 'menu:(waffle and coffee) OR breakfast' + * }) + * ``` + */ + @JvmStatic fun withQuery(rquery: String): SearchStage = withQuery(documentMatches(rquery)) + } + + // TODO(search) enable with backend support + /// ** + // * Specifies if the `matches` and `snippet` expressions will enhance the user provided query to + // * perform matching of synonyms, misspellings, lemmatization, stemming. + // */ + // @Beta + // class QueryEnhancement private constructor(internal val proto: Value) { + // private constructor(protoString: String) : this(encodeValue(protoString)) + // + // companion object { + // /** + // * Search will fall back to the un-enhanced, user provided query, if the query enhancement + // * fails. + // */ + // @JvmField val PREFERRED = QueryEnhancement("preferred") + // + // /** + // * Search will fail if the query enhancement times out or if the query enhancement is not + // * supported by the project's DRZ compliance requirements. + // */ + // @JvmField val REQUIRED = QueryEnhancement("required") + // + // /** Search will use the un-enhanced, user provided query. */ + // @JvmField val DISABLED = QueryEnhancement("disabled") + // } + // } + + /** Specify the fields to add to each document. */ + fun withAddFields(field: Selectable, vararg additionalFields: Selectable): SearchStage { + val allAddFields = (listOf(field) + additionalFields).toTypedArray() + + return SearchStage( + query, + // languageCode, + // retrievalDepth, + sort, + // offset, + // limit, + // select, + allAddFields, + // queryEnhancement, + options + ) + } + + // TODO(search) enable with backend support + /// ** Specify the fields to keep or add to each document. */ + // fun withSelect(selection: Selectable, vararg additionalSelections: Any): SearchStage { + // val allSelections = + // (listOf(selection) + additionalSelections.map { Selectable.toSelectable(it) + // }).toTypedArray() + // + // return SearchStage( + // query, + // //languageCode, + // //retrievalDepth, + // sort, + // //offset, + // //limit, + // allSelections, + // addFields, + // //queryEnhancement, + // options + // ) + // } + // + /// ** Specify the fields to keep or add to each document. */ + // fun withSelect(fieldName: String, vararg additionalSelections: Any): SearchStage { + // return withSelect(field(fieldName), *additionalSelections) + // } + + /** Specify how the returned documents are sorted. One or more ordering are required. */ + fun withSort(order: Ordering, vararg additionalOrderings: Ordering): SearchStage { + val allOrderings = (listOf(order) + additionalOrderings).toTypedArray() + return SearchStage( + query, + // languageCode, + // retrievalDepth, + allOrderings, + // offset, + // limit, + // select, + addFields, + // queryEnhancement, + options + ) + } + + // TODO(search) enable with backend support + /// ** Specify the maximum number of documents to return from the Search stage. */ + // fun withLimit(limit: Long): SearchStage { + // return SearchStage( + // query, + // //languageCode, + // //retrievalDepth, + // sort, + // //offset, + // limit, + // //select, + // addFields, + // //queryEnhancement, + // options + // ) + // } + // + /// ** + // * Specify the maximum number of documents to retrieve. Documents will be retrieved in the + // * pre-sort order specified by the search index. + // */ + // fun withRetrievalDepth(retrievalDepth: Long): SearchStage { + // return SearchStage( + // query, + // //languageCode, + // retrievalDepth, + // sort, + // //offset, + // //limit, + // //select, + // addFields, + // //queryEnhancement, + // options + // ) + // } + // + /// ** Specify the number of documents to skip. */ + // fun withOffset(offset: Long): SearchStage { + // return SearchStage( + // query, + // //languageCode, + // //retrievalDepth, + // sort, + // offset, + // //limit, + // //select, + // addFields, + // //queryEnhancement, + // options + // ) + // } + // + /// ** Specify the BCP-47 language code of text in the search query, such as, “en-US” or “sr-Latn” + // */ + // fun withLanguageCode(value: String): SearchStage { + // return SearchStage( + // query, + // value, + // //retrievalDepth, + // sort, + // //offset, + // //limit, + // //select, + // addFields, + // //queryEnhancement, + // options + // ) + // } + // + /// ** + // * Specify the query expansion behavior used by full-text search expressions in this search + // stage. + // * Default: `.PREFERRED` + // */ + // fun withQueryEnhancement(queryEnhancement: QueryEnhancement): SearchStage { + // return SearchStage( + // query, + // //languageCode, + // //retrievalDepth, + // sort, + // //offset, + // //limit, + // //select, + // addFields, + // queryEnhancement, + // options + // ) + // } +} + internal class LimitStage internal constructor(val limit: Int, options: InternalOptions = InternalOptions.EMPTY) : Stage("limit", options) { diff --git a/firebase-firestore/src/test/java/com/google/firebase/firestore/PipelineProtoTest.kt b/firebase-firestore/src/test/java/com/google/firebase/firestore/PipelineProtoTest.kt new file mode 100644 index 00000000000..fbd7e3e904f --- /dev/null +++ b/firebase-firestore/src/test/java/com/google/firebase/firestore/PipelineProtoTest.kt @@ -0,0 +1,105 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.firebase.firestore + +import com.google.common.truth.Truth.assertThat +import com.google.firebase.firestore.model.DatabaseId +import com.google.firebase.firestore.pipeline.Expression.Companion.constant +import com.google.firebase.firestore.pipeline.Expression.Companion.field +import com.google.firebase.firestore.pipeline.SearchStage +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class PipelineProtoTest { + + @Test + fun testSearchStageProtoEncoding() { + val databaseId = DatabaseId.forDatabase("new-project", "(default)") + val firestore = FirebaseFirestoreIntegrationTestFactory(databaseId).firestore + + val pipeline = + firestore + .pipeline() + .collection("foo") + .search( + SearchStage.withQuery("foo") + // TODO(search) enable with backend support + // .withLimit(1) + // .withRetrievalDepth(2) + // .withOffset(3) + // .withQueryEnhancement(SearchStage.QueryEnhancement.REQUIRED) + // .withLanguageCode("en-US") + .withSort(field("foo").ascending()) + .withAddFields(constant(true).alias("bar")) + // .withSelect(field("id")) + ) + + val request = pipeline.toExecutePipelineRequest(null) + + assertThat(request.database).isEqualTo("projects/new-project/databases/(default)") + + val structuredPipeline = request.structuredPipeline + val protoPipeline = structuredPipeline.pipeline + assertThat(protoPipeline.stagesCount).isEqualTo(2) + + val collectionStage = protoPipeline.getStages(0) + assertThat(collectionStage.name).isEqualTo("collection") + assertThat(collectionStage.getArgs(0).referenceValue).isEqualTo("/foo") + + val searchStage = protoPipeline.getStages(1) + assertThat(searchStage.name).isEqualTo("search") + + val options = searchStage.optionsMap + + // query + val query = options["query"]!! + assertThat(query.functionValue.name).isEqualTo("document_matches") + assertThat(query.functionValue.getArgs(0).stringValue).isEqualTo("foo") + + // TODO(search) enable with backend support + // // limit + // assertThat(options["limit"]?.integerValue).isEqualTo(1L) + + // // retrieval_depth + // assertThat(options["retrieval_depth"]?.integerValue).isEqualTo(2L) + + // // offset + // assertThat(options["offset"]?.integerValue).isEqualTo(3L) + + // // query_enhancement + // assertThat(options["query_enhancement"]?.stringValue).isEqualTo("required") + + // // language_code + // assertThat(options["language_code"]?.stringValue).isEqualTo("en-US") + + // // select + // val select = options["select"]!! + // assertThat(select.mapValue.fieldsMap["id"]?.fieldReferenceValue).isEqualTo("id") + + // sort + val sort = options["sort"]!! + val sortEntry = sort.arrayValue.getValues(0).mapValue.fieldsMap + assertThat(sortEntry["direction"]?.stringValue).isEqualTo("ascending") + assertThat(sortEntry["expression"]?.fieldReferenceValue).isEqualTo("foo") + + // add_fields + val addFields = options["add_fields"]!! + assertThat(addFields.mapValue.fieldsMap["bar"]?.booleanValue).isTrue() + } +}